release(ios): 发布 0.2.1 并重构图标与界面细节

- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读
- 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失
- 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升
- 详情头部指标精简为 4 行,数据模块改为单条读数
- 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt
- 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
This commit is contained in:
chick
2026-09-14 16:30:39 +08:00
parent 27687868ef
commit 3607eddc9f
34 changed files with 2656 additions and 1213 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
## iOS Native 应用
`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用。当前 0.2.0 保留节点总览、跨实例短信、实例详情、日志诊断、全局外观切换和本地数据管理;构建方式见 [docs/ios-native-0.2.0.md](docs/ios-native-0.2.0.md)。
`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用。当前 0.2.1 保留节点总览、标签筛选与分组、跨实例短信、实例详情、日志诊断、全局外观切换和本地数据管理;构建方式见 [docs/ios-native-0.2.1.md](docs/ios-native-0.2.1.md)。
## 功能
+37 -19
View File
@@ -1,26 +1,44 @@
<!-- 设计参考稿,构图与 Design/generate_app_icon.swift 一致;构建实际用 Swift 脚本光栅化 1024 PNG。 -->
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="gold" x1="0.18" y1="0.04" x2="0.82" y2="0.96">
<stop offset="0" stop-color="#FFE9B0"/>
<stop offset="0.24" stop-color="#F4C767"/>
<stop offset="0.68" stop-color="#DA9F45"/>
<stop offset="1" stop-color="#B77B2D"/>
<linearGradient id="field" x1="0.10" y1="0" x2="0.92" y2="1">
<stop offset="0" stop-color="#1C4280"/>
<stop offset="0.46" stop-color="#0D2450"/>
<stop offset="1" stop-color="#040B1B"/>
</linearGradient>
<linearGradient id="sheen" x1="0.18" y1="0" x2="0.82" y2="0.88">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.24"/>
<stop offset="0.52" stop-color="#FFFFFF" stop-opacity="0.04"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
<radialGradient id="key" cx="0.26" cy="-0.04" r="0.92">
<stop offset="0" stop-color="#96C4FF" stop-opacity="0.25"/>
<stop offset="0.55" stop-color="#70A2E6" stop-opacity="0.08"/>
<stop offset="1" stop-color="#70A2E6" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vig" cx="0.5" cy="0.52" r="0.78">
<stop offset="0.55" stop-color="#020712" stop-opacity="0"/>
<stop offset="1" stop-color="#020712" stop-opacity="0.59"/>
</radialGradient>
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FFF4CD"/>
<stop offset="0.30" stop-color="#FACE6A"/>
<stop offset="0.62" stop-color="#D69628"/>
<stop offset="1" stop-color="#925C10"/>
</linearGradient>
<linearGradient id="bar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2A2F37"/>
<stop offset="1" stop-color="#111419"/>
<linearGradient id="slot" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#050D1E"/>
<stop offset="0.58" stop-color="#091732"/>
<stop offset="1" stop-color="#112649"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" fill="url(#gold)"/>
<rect width="1024" height="1024" fill="url(#sheen)"/>
<g fill="url(#bar)">
<rect x="317" y="602" width="108" height="188" rx="54"/>
<rect x="458" y="475" width="108" height="315" rx="54"/>
<rect x="599" y="348" width="108" height="442" rx="54"/>
</g>
<rect width="1024" height="1024" fill="url(#field)"/>
<rect width="1024" height="1024" fill="url(#key)"/>
<rect width="1024" height="1024" fill="url(#vig)"/>
<!-- SIM 卡体:左上切角,其余三角大圆角 -->
<path d="M494.5,218 L688,218 Q822,218 822,352 L822,704 Q822,838 688,838 L336,838
Q202,838 202,704 L202,510.5 Q202,420 266,356 L340,282 Q404,218 494.5,218 Z"
fill="url(#gold)"/>
<!-- 三格信号:底边对齐,右高,读作多卡队列健康度 -->
<rect x="318" y="546" width="88" height="142" rx="26" fill="url(#slot)"/>
<rect x="468" y="446" width="88" height="242" rx="26" fill="url(#slot)"/>
<rect x="618" y="346" width="88" height="342" rx="26" fill="url(#slot)"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

+276
View File
@@ -0,0 +1,276 @@
// Renders the 1024x1024 MultiSimAdmin app icon with CoreGraphics only.
// Usage: swift Design/generate_app_icon.swift <output.png>
//
// The mark is the same geometry as the web favicon: a gold SIM plate carrying
// three ascending signal bars. One flat silhouette, one light source, and no
// photographic detail, so it still reads at the 29pt settings size. The plate
// keeps the classic SIM chamfer, moved to the top-left so the tallest bar can
// sit under the intact top-right corner.
//
// Every coordinate is in visual space (origin top-left, y grows down).
import AppKit
import CoreGraphics
import Foundation
let side: CGFloat = 1024
let output = CommandLine.arguments.count > 1
? CommandLine.arguments[1]
: "Design/AppIcon.png"
func envDouble(_ name: String, _ fallback: Double) -> CGFloat {
guard let raw = ProcessInfo.processInfo.environment[name], let value = Double(raw) else {
return CGFloat(fallback)
}
return CGFloat(value)
}
func rgb(_ r: Int, _ g: Int, _ b: Int, _ a: Int = 255) -> CGColor {
CGColor(srgbRed: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
}
guard let space = CGColorSpace(name: CGColorSpace.sRGB),
let ctx = CGContext(
data: nil, width: Int(side), height: Int(side), bitsPerComponent: 8, bytesPerRow: 0,
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
else {
FileHandle.standardError.write("cannot create bitmap context\n".data(using: .utf8)!)
exit(1)
}
ctx.translateBy(x: 0, y: side)
ctx.scaleBy(x: 1, y: -1)
typealias Stop = (location: CGFloat, color: (Int, Int, Int, Int))
func makeGradient(_ stops: [Stop]) -> CGGradient? {
var components: [CGFloat] = []
for stop in stops {
components.append(CGFloat(stop.color.0) / 255)
components.append(CGFloat(stop.color.1) / 255)
components.append(CGFloat(stop.color.2) / 255)
components.append(CGFloat(stop.color.3) / 255)
}
let locations = stops.map(\.location)
return CGGradient(
colorSpace: space,
colorComponents: components,
locations: locations,
count: stops.count
)
}
func fillLinear(_ path: CGPath, _ stops: [Stop], _ start: CGPoint, _ end: CGPoint) {
guard let gradient = makeGradient(stops) else { return }
ctx.saveGState()
ctx.addPath(path)
ctx.clip()
ctx.drawLinearGradient(
gradient, start: start, end: end,
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
)
ctx.restoreGState()
}
func fillRadial(_ center: CGPoint, _ radius: CGFloat, _ stops: [Stop]) {
guard let gradient = makeGradient(stops) else { return }
ctx.saveGState()
ctx.drawRadialGradient(
gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius,
options: [.drawsAfterEndLocation]
)
ctx.restoreGState()
}
func rectPath(_ rect: CGRect) -> CGPath { CGPath(rect: rect, transform: nil) }
func roundedPath(_ rect: CGRect, _ radius: CGFloat) -> CGPath {
CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
}
/// SIM plate outline: rounded square with one cut top corner.
func platePath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath {
let bevel = corner * 0.28
let vertices: [(point: CGPoint, radius: CGFloat)] = [
(CGPoint(x: rect.minX + chamfer, y: rect.minY), bevel),
(CGPoint(x: rect.maxX, y: rect.minY), corner),
(CGPoint(x: rect.maxX, y: rect.maxY), corner),
(CGPoint(x: rect.minX, y: rect.maxY), corner),
(CGPoint(x: rect.minX, y: rect.minY + chamfer), bevel),
]
let path = CGMutablePath()
path.move(to: vertices[0].point)
for index in vertices.indices {
path.addArc(
tangent1End: vertices[index].point,
tangent2End: vertices[(index + 1) % vertices.count].point,
radius: vertices[index].radius
)
}
path.closeSubpath()
return path
}
/// Bright along the upper-left edges, dead by the lower-right: an even outline
/// reads like a sticker cut rather than a plate lit from above.
func directionalRim(_ path: CGPath, _ rect: CGRect, _ width: CGFloat, _ stops: [Stop]) {
ctx.saveGState()
ctx.addPath(path)
ctx.clip()
ctx.addPath(path)
ctx.setLineWidth(width)
ctx.replacePathWithStrokedPath()
ctx.clip()
fillLinear(rectPath(rect.insetBy(dx: -8, dy: -8)), stops,
CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.maxY))
ctx.restoreGState()
}
// MARK: - Field
fillLinear(
rectPath(CGRect(x: 0, y: 0, width: side, height: side)),
[
(0.00, (28, 66, 128, 255)),
(0.46, (13, 36, 80, 255)),
(1.00, (4, 11, 27, 255)),
] as [Stop],
CGPoint(x: side * 0.10, y: 0),
CGPoint(x: side * 0.92, y: side)
)
fillRadial(
CGPoint(x: side * 0.26, y: -side * 0.04), CGFloat(side * 0.92),
[(0.0, (150, 196, 255, 64)), (0.55, (112, 162, 230, 20)), (1.0, (112, 162, 230, 0))] as [Stop]
)
fillRadial(
CGPoint(x: side * 0.5, y: side * 0.52), CGFloat(side * 0.78),
[(0.55, (2, 7, 18, 0)), (1.0, (2, 7, 18, 150))] as [Stop]
)
// MARK: - Plate
let plateSide = envDouble("ICON_PLATE_SIDE", 620)
let plateCenterY = envDouble("ICON_PLATE_Y", 528)
let plateCorner = envDouble("ICON_PLATE_CORNER", 134)
let plateChamfer = envDouble("ICON_PLATE_CHAMFER", 202)
let plateRect = CGRect(
x: (side - plateSide) / 2,
y: plateCenterY - plateSide / 2,
width: plateSide,
height: plateSide
)
let plate = platePath(plateRect, corner: plateCorner, chamfer: plateChamfer)
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: -30), blur: 62, color: rgb(2, 8, 20, 168))
ctx.addPath(plate)
ctx.setFillColor(rgb(0, 0, 0))
ctx.fillPath()
ctx.restoreGState()
fillLinear(
plate,
[
(0.00, (255, 244, 205, 255)),
(0.30, (250, 206, 106, 255)),
(0.62, (214, 150, 40, 255)),
(1.00, (146, 92, 16, 255)),
] as [Stop],
CGPoint(x: plateRect.minX, y: plateRect.minY),
CGPoint(x: plateRect.maxX, y: plateRect.maxY)
)
directionalRim(
plate, plateRect, plateSide * 0.016,
[
(0.00, (255, 251, 232, 230)),
(0.40, (255, 240, 190, 66)),
(0.74, (255, 232, 170, 0)),
] as [Stop]
)
ctx.saveGState()
ctx.addPath(plate)
ctx.clip()
ctx.move(to: CGPoint(x: plateRect.minX - 40, y: plateRect.minY + plateSide * 0.52))
ctx.addLine(to: CGPoint(x: plateRect.maxX + 40, y: plateRect.minY + plateSide * 0.10))
ctx.addLine(to: CGPoint(x: plateRect.maxX + 40, y: plateRect.minY + plateSide * 0.22))
ctx.addLine(to: CGPoint(x: plateRect.minX - 40, y: plateRect.minY + plateSide * 0.64))
ctx.closePath()
ctx.setFillColor(rgb(255, 252, 236, 5))
ctx.fillPath()
ctx.restoreGState()
// MARK: - Signal bars
let barWidth = envDouble("ICON_BAR_W", 88)
let barGap = envDouble("ICON_BAR_GAP", 62)
let barBottom = envDouble("ICON_BAR_BOTTOM", 688)
let barHeights: [CGFloat] = [142, 242, 342]
let barsWidth = CGFloat(barHeights.count) * barWidth + CGFloat(barHeights.count - 1) * barGap
let barsStartX = (side - barsWidth) / 2
for (index, height) in barHeights.enumerated() {
let rect = CGRect(
x: barsStartX + CGFloat(index) * (barWidth + barGap),
y: barBottom - height,
width: barWidth,
height: height
)
let radius = barWidth * 0.30
let path = roundedPath(rect, radius)
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: 5), blur: 16, color: rgb(255, 236, 176, 104))
ctx.addPath(path)
ctx.setFillColor(rgb(0, 0, 0))
ctx.fillPath()
ctx.restoreGState()
fillLinear(
path,
[
(0.00, (5, 13, 30, 255)),
(0.58, (9, 23, 50, 255)),
(1.00, (17, 38, 74, 255)),
] as [Stop],
CGPoint(x: rect.minX, y: rect.minY),
CGPoint(x: rect.minX, y: rect.maxY)
)
directionalRim(
path, rect, radius * 0.34,
[
(0.00, (0, 0, 0, 120)),
(0.48, (0, 0, 0, 26)),
(0.84, (255, 226, 152, 70)),
(1.00, (255, 236, 176, 118)),
] as [Stop]
)
}
// MARK: - Surface sheen
ctx.saveGState()
ctx.addPath(rectPath(CGRect(x: 0, y: 0, width: side, height: side)))
ctx.clip()
ctx.move(to: CGPoint(x: -120, y: 430))
ctx.addLine(to: CGPoint(x: 1140, y: 40))
ctx.addLine(to: CGPoint(x: 1140, y: 214))
ctx.addLine(to: CGPoint(x: -120, y: 604))
ctx.closePath()
ctx.setFillColor(rgb(255, 255, 255, 4))
ctx.fillPath()
ctx.restoreGState()
guard let image = ctx.makeImage() else {
FileHandle.standardError.write("cannot finalize image\n".data(using: .utf8)!)
exit(1)
}
let rep = NSBitmapImageRep(cgImage: image)
guard let data = rep.representation(using: .png, properties: [:]) else {
FileHandle.standardError.write("cannot encode png\n".data(using: .utf8)!)
exit(1)
}
try! data.write(to: URL(fileURLWithPath: output))
print(output)
@@ -1,6 +1,6 @@
public enum AppMetadata {
public static let name = "MultiSimAdmin"
public static let bundleIdentifier = "fun.chick.multi-simadmin"
public static let marketingVersion = "0.2.0"
public static let buildVersion = "3"
public static let marketingVersion = "0.2.1"
public static let buildVersion = "6"
}
@@ -25,7 +25,7 @@ final class DeviceViewModel: ObservableObject {
} catch DeviceServiceError.unauthorized {
errorMessage = "需要实例密码登录后才能读取设备数据。"
} catch {
errorMessage = "无法加载设备数据。"
errorMessage = "节点无响应,请确认地址可访问、设备已开机且端口未被拦截。"
}
guard let probe else {
connectionStatus = .offline
@@ -19,13 +19,17 @@ struct InstanceDetailView: View {
var body: some View {
ScrollView {
VStack(spacing: 12) {
headerCard
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let snapshot = model.snapshot {
ForEach(snapshot.sections) { section in
sectionCard(section)
if isUnavailable {
unavailableCard
} else {
headerCard
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let snapshot = model.snapshot {
ForEach(snapshot.sections) { section in
sectionCard(section)
}
}
}
}
@@ -51,56 +55,85 @@ struct InstanceDetailView: View {
}
}
// 读取失败且从未拿到数据时,整屏骨架没有意义,改成一屏可读的失败卡片。
private var isUnavailable: Bool {
model.snapshot == nil && model.errorMessage != nil && !model.isLoading
}
private var unavailableCard: some View {
VStack(spacing: 12) {
AICard(padding: 16, emphasis: true) {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "wifi.slash")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(AITheme.danger)
.frame(width: 38, height: 38)
.background(AITheme.danger.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.accessibilityHidden(true)
Text("节点无法连接")
.font(.headline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Spacer(minLength: 8)
statusChip(title: "不可达", tint: AITheme.danger)
}
Text(model.errorMessage ?? "无法加载设备数据。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.fixedSize(horizontal: false, vertical: true)
Button {
Task { await model.refresh() }
} label: {
Label("重新检测", systemImage: "arrow.clockwise")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(model.isLoading)
}
}
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("本机保存的配置", systemImage: "slider.horizontal.3")
AIKeyValueRow("地址", value: model.instance.origin.absoluteString, monospaced: true)
if let groupName {
AIKeyValueRow("分组", value: groupName)
}
AIKeyValueRow("标签", value: model.instance.tags.isEmpty ? "无" : model.instance.tags.joined(separator: "、"))
AIKeyValueRow("访问凭据", value: model.instance.credentialConfigured ? "已保存" : "未设置")
}
}
// 配置是否丢失不是用户需要被告知的事,卡片本身已经说明了一切。
}
}
private var headerCard: some View {
AICard(padding: 16, emphasis: true) {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 5) {
Text(model.instance.name)
.font(.title3.weight(.bold))
.kerning(-0.2)
.foregroundStyle(AITheme.ink)
.lineLimit(2)
.minimumScaleFactor(0.85)
.frame(maxWidth: .infinity, alignment: .leading)
Text(model.instance.origin.absoluteString)
.font(.caption)
// 导航栏已经显示实例名,卡片内不再重复,只留地址与状态。
HStack(spacing: 8) {
Image(systemName: "globe")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
Text(model.instance.origin.absoluteString)
.font(.footnote.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 8)
statusChip()
}
DetailMetricGrid(metrics: headerMetrics)
DetailMetricGrid(metrics: gaugeMetrics)
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("数据模块")
.font(.caption.weight(.semibold))
.foregroundStyle(AITheme.muted)
Spacer()
Text(availableCount.map { "\($0)/\(moduleSections.count)" } ?? "读取中")
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(availableCount == moduleSections.count ? AITheme.success : AITheme.muted)
}
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 9) {
ForEach(stride(from: 0, to: moduleSections.count, by: 2).map {
Array(moduleSections[$0..<min($0 + 2, moduleSections.count)])
}, id: \.first?.id) { rowSections in
GridRow {
ForEach(rowSections, id: \.id) { section in
moduleIndicator(
section.title,
isAvailable: section.values.isEmpty == false
)
}
if rowSections.count == 1 {
Color.clear
}
}
}
}
}
DetailIdentityStrip(items: identitySummary, isLoading: model.isLoading)
moduleAvailability
if groupName != nil || !model.instance.tags.isEmpty {
FlowLayout(spacing: 7) {
@@ -111,11 +144,74 @@ struct InstanceDetailView: View {
AITag(tag)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
private func statusChip(title: String? = nil, tint overrideTint: Color? = nil) -> some View {
let loading = model.isLoading && model.snapshot == nil
let tint = overrideTint ?? (loading ? AITheme.muted : statusColor(model.connectionStatus))
let label = title ?? (loading ? "检测中" : model.connectionStatus.label)
return HStack(spacing: 5) {
Circle()
.fill(tint)
.frame(width: 7, height: 7)
.accessibilityHidden(true)
Text(label)
.font(.caption.weight(.semibold))
.foregroundStyle(tint)
}
.padding(.horizontal, 10)
.frame(minHeight: 30)
.background(tint.opacity(0.12))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(tint.opacity(0.22))
}
.accessibilityLabel("连接状态 \(label)")
}
/// 模块可用性压成一行读数:六个胶囊和下方分栏标题一一对应,属于纯重复。
/// 只有真的读不到时才展开,直接点名是哪几个模块。
private var moduleAvailability: some View {
let unavailable = moduleSections.filter { $0.values.isEmpty }.map(\.title)
return VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Text("数据模块")
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
Spacer()
if let available = availableCount {
Image(systemName: available == moduleSections.count
? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(available == moduleSections.count
? AITheme.success : AITheme.warning)
.accessibilityHidden(true)
Text("\(available)/\(moduleSections.count) 可用")
.font(.caption2.weight(.semibold))
.monospacedDigit()
.foregroundStyle(available == moduleSections.count
? AITheme.success : AITheme.warning)
} else {
Text("读取中")
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
}
}
if unavailable.isEmpty == false {
Text("未返回:" + unavailable.joined(separator: " · "))
.font(.caption2)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private struct HeaderMetric: Identifiable {
let title: String
let value: String?
@@ -126,16 +222,12 @@ struct InstanceDetailView: View {
var id: String { title }
}
private var headerMetrics: [HeaderMetric] {
let loading = model.snapshot == nil
private var allHeaderMetrics: [HeaderMetric] {
let loading = model.isLoading
let phoneValue = stringValue(
section: "sim",
keys: ["phone_numbers", "phone_number"]
)
let firmwareValue = stringValue(
section: "device",
keys: ["revision", "firmware_version"]
)
let storageValue = storageSummary
let uptimeValue = stringValue(
section: "stats",
@@ -169,55 +261,66 @@ struct InstanceDetailView: View {
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
tint: loading ? nil : { resourceTint($0) }
)
let deviceValue = stringValue(section: "device", keys: ["model", "name", "device"])
let carrierValue = stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])
let networkValue = stringValue(
section: "network",
keys: ["network_type", "technology_preference", "technology", "radio_mode"]
)
let ipValue = stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"])
return [
HeaderMetric(
title: "连接状态",
value: loading ? "--" : model.connectionStatus.label,
systemImage: "dot.radiowaves.left.and.right",
tint: loading ? AITheme.muted : statusColor(model.connectionStatus),
progress: loading ? 0 : (model.connectionStatus == .online ? 1 : 0)
),
HeaderMetric(
makeMetric(
title: "号码",
value: loading ? "--" : phoneValue ?? "无数据",
value: loading ? nil : phoneValue ?? "无数据",
systemImage: "simcard",
tint: loading ? AITheme.muted : AITheme.ink,
progress: loading ? 0 : (phoneValue == nil ? 0 : 1)
tint: loading ? nil : AITheme.ink
),
makeMetric(title: "信号", value: loading ? nil : signal?.value, systemImage: "cellularbars", tint: loading ? nil : signal?.tint, progress: loading ? nil : signal?.progress),
makeMetric(title: "延迟", value: loading ? nil : latency?.value, systemImage: "speedometer", tint: loading ? nil : latency?.tint, progress: loading ? nil : latency?.progress),
HeaderMetric(
makeMetric(
title: "CPU",
value: loading ? "--" : cpu?.value ?? "无数据",
value: loading ? nil : cpu?.value ?? "无数据",
systemImage: "cpu",
tint: loading ? AITheme.muted : cpu?.tint ?? AITheme.muted,
progress: loading ? 0 : cpu?.progress
tint: loading ? nil : cpu?.tint ?? AITheme.muted,
progress: loading ? nil : cpu?.progress
),
HeaderMetric(
makeMetric(
title: "内存",
value: loading ? "--" : memory?.value ?? "无数据",
value: loading ? nil : memory?.value ?? "无数据",
systemImage: "memorychip",
tint: loading ? AITheme.muted : memory?.tint ?? AITheme.muted,
progress: loading ? 0 : memory?.progress
tint: loading ? nil : memory?.tint ?? AITheme.muted,
progress: loading ? nil : memory?.progress
),
makeMetric(title: "设备", value: loading ? nil : deviceValue, systemImage: "iphone.gen3", tint: loading ? nil : AITheme.ink),
makeMetric(title: "运营商", value: loading ? nil : carrierValue, systemImage: "antenna.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
makeMetric(title: "网络", value: loading ? nil : networkValue, systemImage: "dot.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
makeMetric(title: "IP", value: loading ? nil : ipValue, systemImage: "network", tint: loading ? nil : AITheme.ink),
makeMetric(title: "固件", value: loading ? nil : firmwareValue, systemImage: "waveform.path.ecg", tint: loading ? nil : AITheme.ink),
makeMetric(title: "存储", value: loading ? nil : storageValue, systemImage: "internaldrive", tint: loading ? nil : storageTint, progress: loading ? nil : storageProgress),
makeMetric(title: "运行时间", value: loading ? nil : uptimeValue, systemImage: "clock", tint: loading ? nil : AITheme.ink)
]
}
// 头部只保留四块带进度语义的量表,其余信息压缩成文本行。
private static let gaugeOrder: [String] = ["信号", "延迟", "CPU", "内存"]
private var gaugeMetrics: [HeaderMetric] {
let metrics = allHeaderMetrics
return Self.gaugeOrder.compactMap { title in
metrics.first { $0.title == title }
}
}
private var identitySummary: [(label: String, value: String)] {
let metrics = allHeaderMetrics
func derived(_ title: String) -> String? {
metrics.first { $0.title == title }?.value
}
// 顶部只留"这是哪张卡、连在哪个网上"四件事;
// 设备型号、固件、存储、运行时间属于硬件清单,下方分栏里已经逐条列出。
let pairs: [(String, String?)] = [
("号码", derived("号码")),
("运营商", stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])),
("网络", stringValue(
section: "network",
keys: ["network_type", "technology_preference", "technology", "radio_mode"]
)),
("IP", stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"]))
]
return pairs.compactMap { label, value in
guard let value, value.isEmpty == false, value != "无数据" else { return nil }
return (label, value)
}
}
private func makeMetric(
title: String,
value: String?,
@@ -389,43 +492,27 @@ struct InstanceDetailView: View {
}
.padding(11)
.frame(maxWidth: .infinity, minHeight: 76, maxHeight: 76, alignment: .topLeading)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.aiInsetSurface()
.accessibilityElement(children: .combine)
}
}
// 统一两列栅格。此前首行放 4 个指标、其余两列,导致首行指标卡只有下方一半宽。
private struct DetailMetricGrid: View {
let metrics: [HeaderMetric]
private let primaryKeys: Set<String> = ["连接状态", "号码", "信号", "延迟"]
private let valueKeys: Set<String> = [
"设备", "运营商", "网络", "IP", "固件", "存储", "运行时间"
]
var body: some View {
let primary = metrics.filter { primaryKeys.contains($0.title) }
let values = metrics.filter { valueKeys.contains($0.title) }
VStack(alignment: .leading, spacing: 9) {
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
ForEach(
stride(from: 0, to: metrics.count, by: 2).map {
Array(metrics[$0..<min($0 + 2, metrics.count)])
},
id: \.first?.id
) { rowMetrics in
GridRow {
ForEach(primary) { DetailHeaderMetric(metric: $0) }
}
}
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
ForEach(
stride(from: 0, to: values.count, by: 2).map {
Array(values[$0..<min($0 + 2, values.count)])
},
id: \.first?.id
) { rowMetrics in
GridRow {
ForEach(rowMetrics) { DetailHeaderMetric(metric: $0) }
if rowMetrics.count == 1 {
Color.clear
}
ForEach(rowMetrics) { DetailHeaderMetric(metric: $0) }
if rowMetrics.count == 1 {
Color.clear
}
}
}
@@ -433,6 +520,57 @@ struct InstanceDetailView: View {
}
}
// 设备身份信息改为一行摘要,细节交给下方的分区卡片,避免头部堆成十几块同宽小卡。
private struct DetailIdentityStrip: View {
let items: [(label: String, value: String)]
let isLoading: Bool
var body: some View {
if isLoading {
// 首屏还没有数据时用骨架条,避免出现一整列"读取中"。
VStack(alignment: .leading, spacing: 9) {
ForEach(0..<3, id: \.self) { _ in
HStack(spacing: 8) {
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.7))
.frame(width: 42, height: 10)
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.5))
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 10)
}
}
}
.padding(11)
.frame(maxWidth: .infinity, alignment: .leading)
.aiInsetSurface()
.accessibilityLabel("设备信息读取中")
} else if items.isEmpty == false {
VStack(alignment: .leading, spacing: 6) {
ForEach(items, id: \.label) { item in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(item.label)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.frame(width: 52, alignment: .leading)
Text(item.value)
.font(.caption.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.accessibilityElement(children: .combine)
}
}
.padding(11)
.frame(maxWidth: .infinity, alignment: .leading)
.aiInsetSurface()
}
}
}
private func moduleIndicator(_ title: String, isAvailable: Bool) -> some View {
HStack(spacing: 4) {
Circle()
@@ -452,8 +590,12 @@ struct InstanceDetailView: View {
.accessibilityElement(children: .combine)
}
// FlowLayout 按固有尺寸排布,这里不能再用 maxWidth: .infinity。
private struct FlowLayout: Layout {
let spacing: CGFloat
var lineSpacing: CGFloat?
private var rowGap: CGFloat { lineSpacing ?? spacing }
func sizeThatFits(
proposal: ProposedViewSize,
@@ -462,19 +604,25 @@ struct InstanceDetailView: View {
) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var height: CGFloat = 0
var rowHeight: CGFloat = 0
var totalHeight: CGFloat = 0
var widestRow: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > 0, x + size.width > maxWidth {
widestRow = max(widestRow, x - spacing)
totalHeight += rowHeight + rowGap
x = 0
height += size.height + spacing
rowHeight = 0
}
x += size.width + spacing
height = max(height, size.height)
rowHeight = max(rowHeight, size.height)
}
widestRow = max(widestRow, x - spacing)
totalHeight += rowHeight
return CGSize(width: max(0, x - spacing), height: height)
return CGSize(width: max(0, widestRow), height: max(0, totalHeight))
}
func placeSubviews(
@@ -491,7 +639,7 @@ struct InstanceDetailView: View {
let size = subview.sizeThatFits(.unspecified)
if x > bounds.minX, x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + spacing
y += rowHeight + rowGap
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
@@ -544,16 +692,19 @@ struct InstanceDetailView: View {
}
} else {
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(section.title, systemImage: sectionIcon(section.id))
Divider()
.overlay(AITheme.line)
ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in
if index > 0 {
Divider()
.overlay(AITheme.line.opacity(0.6))
// 行与行只靠发丝线分隔,纵向节奏交给 row 自己的内边距,
// 否则卡片会越拉越长,一屏读不到两组数据。
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in
if index > 0 {
Divider()
.overlay(AITheme.line.opacity(0.6))
.padding(.vertical, 9)
}
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
}
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
}
}
}
@@ -565,7 +716,7 @@ struct InstanceDetailView: View {
case "device": return "iphone.gen3"
case "sim": return "simcard"
case "network": return "antenna.radiowaves.left.and.right"
case "stats": return "gauge.with.needle"
case "stats": return "chart.bar.fill"
case "cpu": return "cpu"
case "connectivity": return "dot.radiowaves.left.and.right"
default: return "list.bullet.rectangle"
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,6 @@ import SwiftUI
enum FleetGroupMode: String, CaseIterable, Identifiable {
case none
case status
case tags
case groups
var id: String { rawValue }
@@ -13,7 +12,6 @@ enum FleetGroupMode: String, CaseIterable, Identifiable {
switch self {
case .none: return "平铺"
case .status: return "状态"
case .tags: return "标签"
case .groups: return "分组"
}
}
@@ -184,12 +182,15 @@ final class FleetViewModel: ObservableObject {
@Published var rows: [Row] = []
@Published var searchText = ""
@Published var statusFilter: ConnectionStatus?
@Published var groupMode: FleetGroupMode = {
let stored = UserDefaults.standard.string(forKey: "fleet.groupMode")
return FleetGroupMode(rawValue: stored ?? "") ?? .groups
}() {
@Published var groupMode: FleetGroupMode = .groups {
didSet {
UserDefaults.standard.set(groupMode.rawValue, forKey: "fleet.groupMode")
preferences.set(groupMode.rawValue, forKey: "fleet.groupMode")
}
}
@Published var selectedTagFilters: Set<String> = [] {
didSet {
let values = Array(selectedTagFilters).sorted()
preferences.set(values, forKey: "fleet.selectedTagFilters")
}
}
@Published var selectedIDs: Set<UUID> = []
@@ -206,19 +207,35 @@ final class FleetViewModel: ObservableObject {
private let credentials: any CredentialStore
private let deviceService: (any DeviceSummaryService)?
private let groupStore: (any InstanceGroupStore)?
private let preferences: UserDefaults
init(
repository: any InstanceRepository,
probe: ConnectionProbe,
credentials: any CredentialStore,
deviceService: (any DeviceSummaryService)? = nil,
groupStore: (any InstanceGroupStore)? = nil
groupStore: (any InstanceGroupStore)? = nil,
preferences: UserDefaults = .standard
) {
self.repository = repository
self.probe = probe
self.credentials = credentials
self.deviceService = deviceService
self.groupStore = groupStore
self.preferences = preferences
// 旧版本的「按标签分组」已废弃,标签现在只作为筛选条件存在。
let storedMode = preferences.string(forKey: "fleet.groupMode")
groupMode = FleetGroupMode(rawValue: storedMode ?? "") ?? .groups
selectedTagFilters = Set(preferences.stringArray(forKey: "fleet.selectedTagFilters") ?? [])
}
/// 节点列表变化后,丢弃已经不存在任何实例的标签筛选,避免列表被隐藏条件清空。
func pruneTagFilters() {
guard selectedTagFilters.isEmpty == false else { return }
let live = selectedTagFilters.filter { countForTagFilter($0) > 0 }
if live != selectedTagFilters {
selectedTagFilters = live
}
}
func refresh() async {
@@ -243,6 +260,7 @@ final class FleetViewModel: ObservableObject {
} catch {
errorMessage = "无法刷新节点列表"
}
pruneTagFilters()
lastRefresh = Date()
isLoading = false
}
@@ -400,6 +418,13 @@ final class FleetViewModel: ObservableObject {
if let statusFilter, row.status != statusFilter {
return false
}
if selectedTagFilters.isEmpty == false {
let tags = Set(row.instance.tags.map { Self.normalizedTag($0) })
let needles = Set(selectedTagFilters.map { Self.normalizedTag($0) })
guard needles.isSubset(of: tags) else {
return false
}
}
guard !searchText.isEmpty else { return true }
let haystacks: [String] = [
row.instance.name,
@@ -417,6 +442,47 @@ final class FleetViewModel: ObservableObject {
}
}
var allTags: [String] {
Array(tagCounts.keys).sorted { $0.localizedCompare($1) == .orderedAscending }
}
var tagCounts: [String: Int] {
var counts: [String: Int] = [:]
for row in rows {
for tag in Set(row.instance.tags.map(Self.normalizedTag)) where tag.isEmpty == false {
counts[tag, default: 0] += 1
}
}
return counts
}
func countForTagFilter(_ tag: String) -> Int {
tagCounts[Self.normalizedTag(tag)] ?? 0
}
func isTagFilterActive(_ tag: String) -> Bool {
selectedTagFilters.contains(Self.normalizedTag(tag))
}
func toggleTagFilter(_ tag: String) {
let normalized = Self.normalizedTag(tag)
guard normalized.isEmpty == false else { return }
if selectedTagFilters.contains(normalized) {
selectedTagFilters.remove(normalized)
} else {
selectedTagFilters.insert(normalized)
}
}
func clearTagFilters() {
guard selectedTagFilters.isEmpty == false else { return }
selectedTagFilters.removeAll()
}
private static func normalizedTag(_ tag: String) -> String {
tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
var groupedRows: [FleetGroup] {
let rows = visibleRows.sorted {
$0.instance.name.localizedCompare($1.instance.name) == .orderedAscending
@@ -431,19 +497,6 @@ final class FleetViewModel: ObservableObject {
guard !matched.isEmpty else { return nil }
return FleetGroup(id: status.rawValue, title: status.label, rows: matched)
}
case .tags:
let tags = Array(Set(rows.flatMap(\.instance.tags))).sorted()
let grouped = tags.map { tag in
FleetGroup(
id: tag,
title: tag,
rows: rows.filter { $0.instance.tags.contains(tag) }
)
}
let untagged = rows.filter { $0.instance.tags.isEmpty }
return grouped + (untagged.isEmpty
? []
: [FleetGroup(id: "__untagged__", title: "未打标签", rows: untagged)])
case .groups:
var groupedRows: [UUID: [Row]] = [:]
var ungroupedRows: [Row] = []
@@ -6,7 +6,8 @@ struct InstanceFormView: View {
let instance: Instance?
@State private var name = ""
@State private var originText = ""
@State private var tagsText = ""
@State private var tags: [String] = []
@State private var tagDraft = ""
@State private var password = ""
@State private var selectedGroupId: UUID?
@State private var validationMessage: String?
@@ -17,7 +18,7 @@ struct InstanceFormView: View {
self.instance = instance
_name = State(initialValue: instance?.name ?? "")
_originText = State(initialValue: instance?.origin.absoluteString ?? "")
_tagsText = State(initialValue: instance?.tags.joined(separator: ", ") ?? "")
_tags = State(initialValue: instance?.tags ?? [])
_selectedGroupId = State(initialValue: instance?.groupId)
}
@@ -34,12 +35,12 @@ struct InstanceFormView: View {
VStack(alignment: .leading, spacing: 14) {
field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
field("标签", text: $tagsText, placeholder: "home, primary")
tagEditor
groupPicker
field(
"实例密码",
text: $password,
placeholder: instance == nil ? "可选,保存在本机沙盒" : "留空则不修改密码",
placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码",
secure: true
)
}
@@ -104,6 +105,14 @@ struct InstanceFormView: View {
.accessibilityElement(children: .contain)
}
private var tagEditor: some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("标签")
TagEditorView(tags: $tags, suggestions: model.allTags)
}
.accessibilityElement(children: .contain)
}
private func field(
_ title: String,
text: Binding<String>,
@@ -121,12 +130,12 @@ struct InstanceFormView: View {
TextField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel(title)
.submitLabel(title == "地址" ? .next : .done)
.submitLabel(.done)
#if os(iOS)
.keyboardType(title == "地址" ? .URL : .default)
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled(title == "地址" || title == "标签")
.autocorrectionDisabled()
}
}
}
@@ -143,11 +152,6 @@ struct InstanceFormView: View {
isSaving = false
return
}
let tags =
tagsText
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let input = InstanceInput(
name: normalizedName,
origin: origin,
@@ -1,18 +1,35 @@
import SwiftUI
/// 批量归类:点一个分组就把目标实例移进去并立即生效,不再需要二次“保存”。
struct InstanceGroupEditorView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
let targetIDs: [UUID]
let onSaved: () -> Void
@State private var selectedGroupID: UUID?
@State private var isCreatingGroup = false
@State private var newGroupName = ""
@State private var isWorking = false
@State private var errorMessage: String?
private var selectedCount: Int { targetIDs.count }
private var targets: [UUID] {
targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs
}
private var targetGroupIDs: [UUID?] {
let owner = Dictionary(model.rows.map { ($0.id, $0.instance.groupId) }, uniquingKeysWith: { a, _ in a })
return targets.compactMap { owner[$0] }
}
/// 目标实例当前是否同属一个分组;nil 表示 mixed,不显示对勾。
private var uniformGroup: UUID?? {
guard let first = targetGroupIDs.first else { return nil }
return targetGroupIDs.allSatisfy { $0 == first } ? .some(first) : nil
}
private var trimmedName: String {
newGroupName.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
NavigationStack {
@@ -26,56 +43,63 @@ struct InstanceGroupEditorView: View {
)
}
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle(
"批量归类",
systemImage: "folder.badge.gearshape",
detail: selectedCount == 0 ? "全部实例" : "\(selectedCount) 个实例"
)
Text("选择一个分组后保存,所选实例会立即移入该分组。")
.font(.footnote)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
AISectionTitle(
"移动到",
systemImage: "folder",
detail: "\(targets.count) 台实例"
)
if isCreatingGroup {
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 12) {
AIFieldLabel("新分组名称")
TextField("例如:家里、外场、测试机", text: $newGroupName)
.textFieldStyle(AIFieldStyle())
.submitLabel(.done)
}
}
} else {
AICard(padding: 8) {
VStack(spacing: 2) {
groupRow(
title: "未分组",
subtitle: "移出当前分组",
systemImage: "square.dashed",
value: nil,
isSelected: selectedGroupID == nil,
selectionTrait: selectedGroupID == nil
)
.onSubmit { Task { await createAndAssign() } }
AIActionRow(spacing: 8) {
Button("取消") {
isCreatingGroup = false
newGroupName = ""
errorMessage = nil
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.disabled(isWorking)
ForEach(model.groups) { group in
Divider()
.overlay(AITheme.line.opacity(0.55))
groupRow(
title: group.name,
subtitle: "\(instanceCount(group.id)) 个实例",
systemImage: "folder",
value: group.id,
isSelected: selectedGroupID == group.id,
selectionTrait: selectedGroupID == group.id
)
Button {
Task { await createAndAssign() }
} label: {
Label("创建并移入", systemImage: "folder.badge.plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(trimmedName.isEmpty || isWorking)
}
}
}
}
AICard(padding: 8) {
VStack(spacing: 2) {
groupRow(
title: "未分组",
subtitle: "不归属任何分组",
systemImage: "square.dashed",
groupID: nil
)
ForEach(model.groups) { group in
Divider()
.overlay(AITheme.line.opacity(0.55))
groupRow(
title: group.name,
subtitle: "\(instanceCount(group.id)) 台在此",
systemImage: "folder",
groupID: group.id
)
}
}
}
.opacity(isWorking ? 0.55 : 1)
}
.padding(16)
}
@@ -89,28 +113,13 @@ struct InstanceGroupEditorView: View {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button("新建") {
selectedGroupID = nil
isCreatingGroup = true
AIIconButton("新建分组", systemImage: "plus") {
errorMessage = nil
isCreatingGroup = true
}
.disabled(isWorking)
}
}
.aiBottomActionBar {
AIActionRow {
Button {
Task { await save() }
} label: {
Label(
selectedGroupID == nil ? "移出分组" : "移入分组",
systemImage: "tray.and.arrow.down"
)
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(isWorking || (selectedGroupID == nil && !isCreatingGroup))
}
}
}
}
@@ -118,23 +127,27 @@ struct InstanceGroupEditorView: View {
title: String,
subtitle: String,
systemImage: String,
value: UUID?,
isSelected: Bool,
selectionTrait: Bool
groupID: UUID?
) -> some View {
Button {
selectedGroupID = value
isCreatingGroup = false
newGroupName = ""
errorMessage = nil
guard uniformGroup != groupID else {
dismiss()
return
}
Task { await apply(groupID: groupID) }
} label: {
HStack(spacing: 12) {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
.foregroundStyle(isCurrent(groupID) ? AITheme.primary : AITheme.muted)
.frame(width: 32, height: 32)
.background((isSelected ? AITheme.primary : AITheme.muted).opacity(0.10))
.background((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
// 深色下 10% 色洗几乎看不出边界,补一圈同色淡描边保住方块轮廓。
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.20))
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
@@ -150,56 +163,64 @@ struct InstanceGroupEditorView: View {
Spacer(minLength: 8)
Image(
systemName: isSelected
? "checkmark.circle.fill"
: "circle.dashed"
)
.font(.system(size: 18))
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
Group {
if isWorking {
EmptyView()
} else if isCurrent(groupID) {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AITheme.primary)
}
}
.frame(width: 20, alignment: .trailing)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(minHeight: 54)
.background(isSelected ? AITheme.primary.opacity(0.08) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityLabel("\(title),\(subtitle)")
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton])
.accessibilityLabel(isCurrent(groupID) ? "\(title),当前分组" : "移动到\(title)")
.accessibilityAddTraits(.isButton)
}
.buttonStyle(.plain)
.disabled(isWorking)
}
private func isCurrent(_ groupID: UUID?) -> Bool {
uniformGroup == groupID && targetGroupIDs.isEmpty == false
}
private func instanceCount(_ id: UUID) -> Int {
model.rows.filter { $0.instance.groupId == id }.count
}
private func save() async {
private func apply(groupID: UUID?) async {
guard !isWorking else { return }
isWorking = true
errorMessage = nil
do {
if isCreatingGroup {
let name = newGroupName.trimmingCharacters(in: .whitespacesAndNewlines)
let group = try await model.createGroup(named: name)
selectedGroupID = group.id
isCreatingGroup = false
newGroupName = ""
}
try await model.assign(
groupID: selectedGroupID,
to: targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs
)
try await model.assign(groupID: groupID, to: targets)
onSaved()
dismiss()
} catch {
errorMessage = isCreatingGroup
? "创建分组失败,名称不能为空且不能重复。"
: "保存分组失败,请检查节点后重试。"
errorMessage = "分组保存失败,请重试。"
isWorking = false
}
}
private func createAndAssign() async {
guard !isWorking, trimmedName.isEmpty == false else { return }
isWorking = true
errorMessage = nil
do {
let group = try await model.createGroup(named: trimmedName)
try await model.assign(groupID: group.id, to: targets)
onSaved()
dismiss()
} catch {
errorMessage = "创建分组失败,名称不能为空且不能重复。"
isWorking = false
}
isWorking = false
}
}
@@ -8,6 +8,7 @@ struct InstanceGroupManagerView: View {
@State private var editingName = ""
@State private var validationMessage: String?
@State private var isWorking = false
@State private var pendingDelete: InstanceGroup?
var body: some View {
NavigationStack {
@@ -62,24 +63,16 @@ struct InstanceGroupManagerView: View {
Spacer()
Button {
AISymbolAction("重命名分组 \(group.name)", systemImage: "pencil", tint: AITheme.muted) {
editingGroup = group
editingName = group.name
validationMessage = nil
} label: {
Image(systemName: "pencil")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 44))
Button {
Task {
await delete(group)
}
} label: {
Image(systemName: "trash")
AISymbolAction("删除分组 \(group.name)", systemImage: "trash", tint: AITheme.danger) {
pendingDelete = group
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .toolbarInline, fixedWidth: 44))
.disabled(instanceCount(group.id) > 0 || isWorking)
.disabled(isWorking)
}
}
}
@@ -124,6 +117,23 @@ struct InstanceGroupManagerView: View {
} message: {
Text("分组名称最多 100 个字符。")
}
.confirmationDialog(
pendingDelete == nil
? "删除分组?"
: "删除「\(pendingDelete!.name)」?该分组下的 \(instanceCount(pendingDelete!.id)) 个实例会回到未分组。",
isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
),
titleVisibility: .visible
) {
if let group = pendingDelete {
Button("删除分组", role: .destructive) {
Task { await delete(group) }
}
}
Button("取消", role: .cancel) {}
}
.task {
await model.loadGroups()
}
@@ -163,6 +173,7 @@ struct InstanceGroupManagerView: View {
private func delete(_ group: InstanceGroup) async {
guard !isWorking else { return }
isWorking = true
pendingDelete = nil
do {
try await model.deleteGroup(group.id)
} catch {
@@ -0,0 +1,160 @@
import SwiftUI
/// 标签编辑器:已选标签用可删除胶囊展示,历史标签可点选复用。
struct TagEditorView: View {
@Binding var tags: [String]
let suggestions: [String]
@State private var draft = ""
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
TextField("新标签,回车添加", text: $draft)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
#endif
.onSubmit { commit(draft) }
Button {
commit(draft)
} label: {
Image(systemName: "plus")
.font(.system(size: 15, weight: .semibold))
.frame(width: 44, height: 44)
.foregroundStyle(canAdd ? AITheme.primary : AITheme.muted)
.background(canAdd ? AITheme.primary.opacity(0.10) : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(canAdd ? AITheme.primary.opacity(0.40) : AITheme.line)
}
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.disabled(canAdd == false)
.accessibilityLabel("添加标签")
}
if tags.isEmpty == false || suggestions.isEmpty == false {
// 已选与可选分成两条横向胶囊带:自定义流式布局在弹层里拿到的宽度提案不稳定,
// 会出现高度算少、胶囊压到下一个字段的问题,横向滚动条带既稳又和节点页一致。
if tags.isEmpty == false {
chipStrip(ids: tags.map { "sel-\($0)" }) { index in
removableTag(tags[index])
}
}
if availableSuggestions.isEmpty == false {
chipStrip(ids: availableSuggestions.map { "sug-\($0)" }) { index in
suggestionTag(availableSuggestions[index])
}
}
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel("标签")
}
private func chipStrip<Item: View>(
ids: [String],
@ViewBuilder content: @escaping (Int) -> Item
) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(ids.enumerated()), id: \.element) { index, _ in
content(index)
}
}
.padding(.vertical, 2)
}
.accessibilityHidden(false)
}
private var trimmedDraft: String {
draft.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canAdd: Bool {
trimmedDraft.isEmpty == false
}
private var availableSuggestions: [String] {
let taken = Set(tags.map(Self.key))
return suggestions
.filter { taken.contains(Self.key($0)) == false }
.prefix(10)
.map { $0 }
}
private func removableTag(_ tag: String) -> some View {
Button {
tags.removeAll { Self.key($0) == Self.key(tag) }
} label: {
HStack(spacing: 5) {
Text(tag)
.font(.footnote.weight(.semibold))
.lineLimit(1)
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
.opacity(0.65)
}
.padding(.horizontal, 11)
.frame(height: 32)
.foregroundStyle(AITheme.ink)
.background(AITheme.control)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous).strokeBorder(AITheme.line)
}
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("移除标签 \(tag)")
}
private func suggestionTag(_ tag: String) -> some View {
Button {
add(tag)
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
.font(.system(size: 10, weight: .bold))
Text(tag)
.font(.footnote.weight(.medium))
.lineLimit(1)
}
.padding(.horizontal, 11)
.frame(height: 32)
.foregroundStyle(AITheme.primary)
.background(AITheme.primary.opacity(0.09))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous).strokeBorder(AITheme.primary.opacity(0.24))
}
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("使用已有标签 \(tag)")
}
private func commit(_ raw: String) {
let parts = raw
.split(whereSeparator: { $0 == "," || $0 == "," || $0 == "\n" })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
parts.forEach(add)
draft = ""
}
private func add(_ tag: String) {
let value = tag.trimmingCharacters(in: .whitespacesAndNewlines)
guard value.isEmpty == false else { return }
guard tags.contains(where: { Self.key($0) == Self.key(value) }) == false else { return }
tags.append(value)
}
private static func key(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}
@@ -18,17 +18,38 @@ struct LogsView: View {
self.autoRefreshOnAppear = resolvedModel.autoRefreshOnAppear
}
private var isUnavailable: Bool {
model.snapshot == nil && model.errorMessage != nil && !model.isLoading
}
/// 空列表配一排筛选器只是噪音,有内容才给筛选入口。
private var hasRowsForTab: Bool {
switch tab {
case .runtime: return model.filteredRuntime.isEmpty == false
case .connections: return model.filteredConnections.isEmpty == false
case .diagnostics: return model.filteredDiagnostics.isEmpty == false
}
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if model.snapshot == nil && model.isLoading {
if isUnavailable {
AICard {
AIEmptyState(
systemImage: "arrow.triangle.2.circlepath",
title: "未能读取日志",
message: model.errorMessage ?? "所有节点都没有返回日志数据。",
actionTitle: "重试"
) {
Task { await model.refresh() }
}
}
} else if model.snapshot == nil && model.isLoading {
AILoadingCard("正在同步日志数据")
} else {
stats
AISegmentedControl(
"日志视图",
selection: $tab,
@@ -39,7 +60,14 @@ struct LogsView: View {
]
)
filterBar
if hasRowsForTab {
filterBar
}
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
switch tab {
case .runtime:
@@ -58,11 +86,7 @@ struct LogsView: View {
.navigationTitle("日志中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
AIToolbarButton(
"刷新",
systemImage: "arrow.clockwise",
kind: .primary
) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
@@ -90,67 +114,74 @@ struct LogsView: View {
}
}
/// 三块全 0 的读数和下方的空态说的是同一件事,没有记录时整段不出现。
private var stats: some View {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12
) {
AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary)
AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success)
AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger)
let runtime = model.snapshot?.runtime.count ?? 0
let connections = model.snapshot?.connections.count ?? 0
let diagnostics = model.snapshot?.diagnostics.count ?? 0
return Group {
if runtime + connections + diagnostics > 0 {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12
) {
AIStatCard(
title: "运行日志", value: "\(runtime)", systemImage: "list.bullet", tint: AITheme.primary)
AIStatCard(
title: "连接探测", value: "\(connections)", systemImage: "dot.radiowaves.left.and.right",
tint: model.offlineCount == 0 ? AITheme.success : AITheme.warning)
AIStatCard(
title: "设备诊断", value: "\(diagnostics)", systemImage: "iphone.gen3", tint: AITheme.ink)
}
}
}
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
VStack(spacing: 8) {
AISearchField("搜索日志、错误码或实例", text: $model.searchText, minHeight: AITheme.compactControlHeight)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
if tab == .runtime {
HStack(spacing: 8) {
Picker("来源", selection: $model.selectedSource) {
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
Text(source.label).tag(Optional(source))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
Picker("级别", selection: $model.selectedLevel) {
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
Text(level.label).tag(Optional(level))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
}
} else if tab == .connections {
Picker("结果", selection: $model.selectedOutcome) {
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
Text(outcome.label).tag(Optional(outcome))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
Group {
if tab == .runtime {
HStack(spacing: 10) {
Picker("来源", selection: $model.selectedSource) {
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
Text(source.label).tag(Optional(source))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
Picker("级别", selection: $model.selectedLevel) {
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
Text(level.label).tag(Optional(level))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
.frame(maxWidth: .infinity)
} else if tab == .connections {
Picker("结果", selection: $model.selectedOutcome) {
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
Text(outcome.label).tag(Optional(outcome))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
}
.transition(.opacity)
.aiSelectSurface(compact: true)
}
.animation(.easeInOut(duration: 0.18), value: tab)
}
.animation(.easeInOut(duration: 0.18), value: tab)
}
private var runtimeSection: some View {
@@ -158,7 +189,7 @@ struct LogsView: View {
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
if model.filteredRuntime.isEmpty {
AICard {
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会同步实例运行记录。")
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "所有节点都没有返回运行记录。")
}
} else {
ForEach(model.filteredRuntime) { entry in
@@ -255,10 +286,6 @@ struct LogsView: View {
}
}
private var onlineCount: Int {
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
}
private func levelTint(_ level: RuntimeLogLevel) -> Color {
switch level {
case .info: return AITheme.success
@@ -15,7 +15,8 @@ public enum SMSDirection: String, Equatable, Sendable {
}
public struct SMSMessage: Equatable, Identifiable, Sendable {
public let id: String
/// 上游消息 id 只在单个节点内唯一,聚合展示时不能直接当 Identifiable 用。
public let upstreamID: String
public let instanceID: UUID
public let instanceName: String
public let phoneNumber: String
@@ -23,6 +24,48 @@ public struct SMSMessage: Equatable, Identifiable, Sendable {
public let direction: SMSDirection
public let status: String
public let timestamp: String
public var id: String { "\(instanceID.uuidString)#\(upstreamID)" }
/// 上游 status 是英文原词,列表里直接展示会混进一行英文;未知值仍然原样显示,不做假结论。
public var statusLabel: String? {
let key = status.trimmingCharacters(in: .whitespaces).lowercased()
guard key.isEmpty == false else { return nil }
switch key {
case "received", "in", "incoming": return "已接收"
case "sent": return "已发送"
case "delivered": return "已送达"
case "read": return "已读"
case "failed", "error": return "发送失败"
case "pending", "queued", "sending": return "发送中"
default: return status
}
}
/// 收件的"已接收"和方向标签说的是同一件事,只有发件状态才值得再占一个位置。
public var deliveryLabel: String? {
guard direction == .outgoing else { return nil }
return statusLabel
}
public init(
upstreamID: String,
instanceID: UUID,
instanceName: String,
phoneNumber: String,
content: String,
direction: SMSDirection,
status: String,
timestamp: String
) {
self.upstreamID = upstreamID
self.instanceID = instanceID
self.instanceName = instanceName
self.phoneNumber = phoneNumber
self.content = content
self.direction = direction
self.status = status
self.timestamp = timestamp
}
}
public struct MessagePage: Equatable, Sendable {
@@ -133,7 +133,7 @@ public struct MessageService: Sendable {
: directionText == "outgoing" ? .outgoing
: .unknown
return SMSMessage(
id: id,
upstreamID: id,
instanceID: instance.id,
instanceName: instance.name,
phoneNumber: value["phone_number"] as? String ?? "",
@@ -12,24 +12,35 @@ struct MessagesView: View {
ScrollView {
LazyVStack(spacing: 12) {
filterBar
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息数据")
}
if model.filteredRows.isEmpty && !model.isLoading {
} else if model.filteredRows.isEmpty {
AICard {
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "刷新后从实例同步短信。"
)
if let errorMessage = model.errorMessage {
AIEmptyState(
systemImage: "arrow.triangle.2.circlepath",
title: "未能读取消息",
message: errorMessage,
actionTitle: "重试"
) {
Task { await model.refresh() }
}
} else {
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "所有节点都没有返回短信。"
)
}
}
} else {
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
.padding(16)
@@ -45,6 +56,14 @@ struct MessagesView: View {
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("消息中心")
.toolbar {
ToolbarItem(placement: .primaryAction) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
}
}
.confirmationDialog(
"删除这条消息?操作不可恢复。",
isPresented: Binding(
@@ -71,68 +90,83 @@ struct MessagesView: View {
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
VStack(spacing: 8) {
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
.pickerStyle(.menu)
.aiSelectSurface()
)
AISearchField("搜索号码或内容", text: $model.searchText)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
// 选择器右侧原本是一整片空白,这里补上当前条数,让这一行读完就知道结果规模。
.overlay(alignment: .trailing) {
Text("\(model.filteredRows.count) 条")
.font(.caption.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.muted)
.padding(.trailing, 12)
.accessibilityHidden(true)
}
AISearchField("搜索号码或内容", text: $model.searchText, minHeight: AITheme.compactControlHeight)
}
}
private func messageCard(_ message: SMSMessage) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
AICard(padding: 14) {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
.font(.headline)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
AITag(
message.direction.label,
tint: message.direction == .outgoing ? AITheme.primary : AITheme.success)
Spacer(minLength: 8)
Text(TimestampText.short(message.timestamp))
.font(.caption2.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.faint)
.lineLimit(1)
.accessibilityLabel("时间 \(message.timestamp)")
}
Text(message.content)
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
HStack(alignment: .center, spacing: 10) {
Text(message.instanceName)
HStack(alignment: .center, spacing: 8) {
Label(message.instanceName, systemImage: "antenna.radiowaves.left.and.right")
.font(.caption)
.labelStyle(.titleAndIcon)
.imageScale(.small)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
if let delivery = message.deliveryLabel {
Text(delivery)
.font(.caption2.weight(.medium))
.foregroundStyle(delivery == "发送失败" ? AITheme.danger : AITheme.faint)
.lineLimit(1)
}
Spacer(minLength: 8)
Button {
AISymbolAction("删除这条消息", systemImage: "trash", tint: AITheme.danger) {
pendingDeleteMessage = message
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
}
@@ -61,9 +61,9 @@ final class MessagesViewModel: ObservableObject {
let page = try await service.messages(loaded, limit: 100)
rows = page.messages
} catch MessageServiceError.unauthorized {
errorMessage = "有实例需要登录,未获取受保护消息。"
errorMessage = "节点需要访问密码,请在编辑节点中填写后重试。"
} catch {
errorMessage = "无法加载消息。"
errorMessage = "所有节点都没有返回消息,可能节点离线或未开启短信接口。"
}
isLoading = false
}
@@ -80,7 +80,7 @@ final class MessagesViewModel: ObservableObject {
guard let instance = instances.first(where: { $0.id == message.instanceID }) else { return }
do {
try await service.delete([message.id], from: instance)
rows.removeAll { $0.id == message.id && $0.instanceID == message.instanceID }
rows.removeAll { $0.id == message.id }
} catch {
errorMessage = "删除消息失败。"
}
@@ -20,8 +20,8 @@ struct SettingsView: View {
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
}
runtimeCard
themeCard
runtimeCard
storageCard
securityCard
dangerZoneCard
@@ -33,11 +33,7 @@ struct SettingsView: View {
.navigationTitle("设置")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
AIToolbarButton(
"刷新",
systemImage: "arrow.clockwise",
kind: .primary
) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
@@ -67,7 +63,7 @@ struct SettingsView: View {
AISectionTitle("应用信息", systemImage: "info.circle")
if let runtime = model.runtime {
AIKeyValueRow("版本", value: "\(runtime.marketingVersion) (\(runtime.buildVersion))")
AIKeyValueRow("设备", value: runtime.deviceModel)
AIKeyValueRow("系统", value: runtime.systemVersion)
} else {
Text("正在读取应用信息…")
.font(.subheadline)
@@ -80,19 +76,51 @@ struct SettingsView: View {
private var themeCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("外观", systemImage: "circle.lefthalf.filled", detail: themeStore.mode.label)
AISegmentedControl(
"主题模式",
selection: Binding(
get: { themeStore.mode },
set: { themeStore.set($0) }
),
options: ThemeMode.allCases.map { (value: $0, title: $0.label) }
)
// 三个选项块本身已经标出当前模式,标题右侧再重复一次是冗余信息。
AISectionTitle("主题外观", systemImage: "circle.lefthalf.filled")
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
GridRow {
ForEach(ThemeMode.allCases) { mode in
themeOption(mode)
}
}
}
}
}
}
private func themeOption(_ mode: ThemeMode) -> some View {
let isSelected = themeStore.mode == mode
return Button {
themeStore.set(mode)
} label: {
VStack(spacing: 7) {
Image(systemName: mode.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
.accessibilityHidden(true)
Text(mode.label)
.font(.caption.weight(isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? AITheme.ink : AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, minHeight: 68)
.background(isSelected ? AITheme.primary.opacity(0.10) : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(isSelected ? AITheme.primary.opacity(0.42) : AITheme.line)
}
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("主题:\(mode.label)")
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
}
private var storageCard: some View {
AICard {
NavigationLink {
@@ -107,8 +135,7 @@ struct SettingsView: View {
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
}
AIKeyValueRow("数据位置", value: "仅保存在本机设备")
AIKeyValueRow("当前占用", value: model.storageLabel)
AIKeyValueRow("日志记录", value: model.storageLabel)
}
}
.buttonStyle(.plain)
@@ -120,14 +147,21 @@ struct SettingsView: View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("隐私与数据", systemImage: "lock.shield")
AIKeyValueRow("节点信息", value: "仅保存在本机设备")
AIKeyValueRow("实例密码", value: "保存在本地数据文件中")
AIKeyValueRow("登录会话", value: "应用关闭后自动清除")
AIStatusBanner(
"节点地址、实例密码和运行数据都保存在本机。应用只连接你添加的节点,登录会话关闭应用后即被清除。",
tint: AITheme.primary,
systemImage: "shield.checkered"
)
AIKeyValueRow("节点与凭据", value: "仅写入本机存储")
AIKeyValueRow("网络请求", value: "只访问你添加的节点")
// 卡内再套一个描边框会变成"卡中卡",这里退成一段脚注。
HStack(alignment: .top, spacing: 7) {
Image(systemName: "checkmark.seal")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AITheme.success)
.padding(.top, 2)
.accessibilityHidden(true)
Text("不经过中转服务器,也不上传遥测数据;节点地址、密码与日志只保存在这台 iPhone 上,删除应用即彻底清除。")
.font(.caption)
.foregroundStyle(AITheme.muted)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 2)
}
}
}
@@ -136,12 +170,35 @@ struct SettingsView: View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("数据清理", systemImage: "trash.square", detail: "不可恢复")
// 整块实心红是这页最大的色块,会把"读设置"的视线抢走;退成一行危险色入口。
Button {
showsClearDialog = true
} label: {
Label("清除运行日志", systemImage: "trash")
HStack(spacing: 8) {
Image(systemName: "trash")
.font(.system(size: 14, weight: .semibold))
.accessibilityHidden(true)
Text("清除运行日志")
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.opacity(0.7)
.accessibilityHidden(true)
}
.foregroundStyle(AITheme.danger)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.compactControlHeight)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AITheme.danger.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
.strokeBorder(AITheme.danger.opacity(0.20))
}
.contentShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true))
.buttonStyle(.plain)
}
}
}
@@ -1,12 +1,14 @@
import Foundation
import Darwin
#if canImport(UIKit)
import UIKit
#endif
struct SettingsRuntime: Equatable {
let appName: String
let marketingVersion: String
let buildVersion: String
let platform: String
let deviceModel: String
let systemVersion: String
}
@MainActor
@@ -32,7 +34,7 @@ final class SettingsViewModel: ObservableObject {
marketingVersion: AppMetadata.marketingVersion,
buildVersion: AppMetadata.buildVersion,
platform: "iOS",
deviceModel: Self.deviceModel()
systemVersion: Self.systemVersion()
)
await loadStorageLabel()
@@ -64,14 +66,14 @@ final class SettingsViewModel: ObservableObject {
}
}
private static func deviceModel() -> String {
var systemInfo = utsname()
guard uname(&systemInfo) == 0 else { return "未知设备" }
let bytes = withUnsafeBytes(of: systemInfo.machine) { Data($0) }
let model = String(
decoding: bytes.prefix { $0 != 0 },
as: UTF8.self
)
return model.isEmpty ? "未知设备" : model
/// 只报对用户有意义的系统版本,不暴露机器标识之类的原始信息。
private static func systemVersion() -> String {
#if canImport(UIKit)
let device = UIDevice.current
return "\(device.systemName) \(device.systemVersion)"
#else
let version = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(version.majorVersion).\(version.minorVersion)"
#endif
}
}
@@ -7,13 +7,21 @@ struct MultiSimAdminApp: App {
init() {
let arguments = ProcessInfo.processInfo.arguments
var initial: AppTab = .fleet
if arguments.contains("--tab-messages") {
_tab = State(initialValue: .messages)
initial = .messages
} else if arguments.contains("--tab-settings") {
_tab = State(initialValue: .settings)
} else {
_tab = State(initialValue: .fleet)
initial = .settings
}
#if DEBUG
if arguments.contains("--tab-detail") {
initial = .debugDetail
}
if arguments.contains("--tab-logs") {
initial = .debugLogs
}
#endif
_tab = State(initialValue: initial)
}
var body: some Scene {
@@ -26,6 +34,12 @@ struct MultiSimAdminApp: App {
NavigationStack { MessagesView().aiInlineTitle() }
case .settings:
NavigationStack { SettingsView().aiInlineTitle() }
#if DEBUG
case .debugDetail:
NavigationStack { DebugDetailView().aiInlineTitle() }
case .debugLogs:
NavigationStack { LogsView().aiInlineTitle() }
#endif
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
@@ -57,8 +71,40 @@ enum AppTab: Hashable {
case fleet
case messages
case settings
#if DEBUG
case debugDetail
case debugLogs
#endif
}
#if DEBUG
/// 本地截图验证用的入口,Release 构建不包含。
struct DebugDetailView: View {
@State private var target: (Instance, String?)?
var body: some View {
Group {
if let (instance, groupName) = target {
InstanceDetailView(instance: instance, groupName: groupName)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.task {
let repository = FileInstanceRepository(location: DefaultContainer.documentFileURL("instances.json"))
let groups = FileInstanceGroupStore(location: DefaultContainer.documentFileURL("instance-groups.json"))
guard let listed = try? await repository.list(InstanceQuery()), listed.isEmpty == false else { return }
// 截图验证优先看可达节点(3000),否则详情页全是空态,看不出真实排版。
let instance =
listed.first { $0.origin.port == 3000 } ?? listed[0]
let name = (try? await groups.list())?.first { $0.id == instance.groupId }?.name
target = (instance, name)
}
}
}
#endif
struct AITabBar: View {
@Binding var selection: AppTab
Binary file not shown.

Before

Width:  |  Height:  |  Size: 992 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -9,8 +9,8 @@
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>MultiSimAdmin</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.2.0</string>
<key>CFBundleVersion</key><string>3</string>
<key>CFBundleShortVersionString</key><string>0.2.1</string>
<key>CFBundleVersion</key><string>6</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>MinimumOSVersion</key><string>16.0</string>
<key>NSAppTransportSecurity</key>
@@ -0,0 +1,10 @@
import Foundation
/// 仅 DEBUG 构建可用的启动开关,用于在模拟器里直接打开某个界面做视觉回归。
#if DEBUG
enum DebugLaunch {
private static let args = ProcessInfo.processInfo.arguments
static func has(_ flag: String) -> Bool { args.contains(flag) }
}
#endif
@@ -0,0 +1,84 @@
import Foundation
/// 上游返回的时间戳格式并不统一(`yyyy-MM-dd HH:mm:ss`、ISO8601、秒/毫秒时间戳都出现过),
/// 列表里需要一个稳定的短读法,解析不出来时原样回退,避免出现"--"这种假结论。
public enum TimestampText {
private static let parsers: [DateFormatter] = {
let patterns = [
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd HH:mm",
"yyyy/MM/dd HH:mm:ss",
"yyyy-MM-dd",
]
return patterns.map { pattern in
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = .current
formatter.dateFormat = pattern
return formatter
}
}()
private static let utcParser: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
return formatter
}()
private static let timeFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "HH:mm"
return formatter
}()
private static let monthDayFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "MM-dd HH:mm"
return formatter
}()
private static let fullFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
public static func date(from raw: String) -> Date? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.isEmpty == false else { return nil }
if let number = Double(text), text.allSatisfy({ $0.isNumber }) {
// 13 位是毫秒,10 位是秒。
return Date(timeIntervalSince1970: text.count > 11 ? number / 1000 : number)
}
let head = text.hasSuffix("Z") ? String(text.dropLast()) : text
for parser in parsers {
if let parsed = parser.date(from: head) {
return text.hasSuffix("Z") ? utcParser.date(from: head) ?? parsed : parsed
}
}
return nil
}
/// 今天只看时分,今年看月日,跨年才写全年份。
public static func short(_ raw: String, now: Date = Date()) -> String {
guard let parsed = date(from: raw) else {
return raw.trimmingCharacters(in: .whitespacesAndNewlines)
}
let calendar = Calendar.current
if calendar.isDate(parsed, inSameDayAs: now) {
return timeFormat.string(from: parsed)
}
if calendar.isDate(parsed, equalTo: now, toGranularity: .year) {
return monthDayFormat.string(from: parsed)
}
return fullFormat.string(from: parsed)
}
}
@@ -89,15 +89,9 @@ public struct AIButtonStyle: ButtonStyle {
.frame(maxWidth: fillsWidth ? .infinity : nil)
.frame(width: fixedWidth)
.background(
kind == .primary && isEnabled && size != .toolbarInline
? AnyShapeStyle(
LinearGradient(
colors: [AITheme.primaryTop, AITheme.primaryBottom],
startPoint: .top,
endPoint: .bottom
)
)
: AnyShapeStyle(kind.backgroundColor(isEnabled: isEnabled))
// 主色面用纯色。之前的 primaryTop/primaryBottom 竖向渐变在系统里会被合成成灰紫,
// 实测 #3B82F6 掉到 #6B81CA,整片按钮发脏。
AnyShapeStyle(kind.backgroundColor(isEnabled: isEnabled))
)
.shadow(
color: (kind == .secondary || !isEnabled) ? .clear : .black.opacity(0.10),
@@ -107,13 +101,13 @@ public struct AIButtonStyle: ButtonStyle {
)
.clipShape(
RoundedRectangle(
cornerRadius: size == .toolbarInline ? AITheme.toolbarButtonHeight / 2 : AITheme.buttonRadius,
cornerRadius: cornerRadius,
style: .continuous
)
)
.overlay {
RoundedRectangle(
cornerRadius: size == .toolbarInline ? AITheme.toolbarButtonHeight / 2 : AITheme.buttonRadius,
cornerRadius: cornerRadius,
style: .continuous
)
.strokeBorder(
@@ -126,6 +120,15 @@ public struct AIButtonStyle: ButtonStyle {
.opacity(configuration.isPressed ? 0.82 : 1)
}
/// 工具栏里的图标按钮需要更小的圆角,否则 34pt 方块看起来像被切过。
private var cornerRadius: CGFloat {
switch size {
case .toolbarInline: return AITheme.toolbarButtonHeight / 2
case .icon: return AITheme.iconButtonRadius
default: return AITheme.buttonRadius
}
}
private var minimumWidth: CGFloat {
switch size {
case .compact: return AITheme.compactButtonMinWidth
+172 -57
View File
@@ -25,7 +25,7 @@ public struct AICard<Content: View>: View {
)
)
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
.shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8)
.shadow(color: AITheme.cardShadow, radius: 14, x: 0, y: 6)
.overlay {
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
.strokeBorder(AITheme.line)
@@ -61,12 +61,14 @@ public struct AITag: View {
.lineLimit(1)
.truncationMode(.middle)
.padding(.horizontal, 10)
.frame(minHeight: 30)
.background(tint.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
// 与节点卡片上的标签胶囊同一形状,同一高度;深色底上 10% 色雾几乎看不见,
// 所以描边权重必须够,不能让标签在暗色主题里消失。
.frame(height: 28)
.background(tint.opacity(0.14))
.clipShape(Capsule(style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(tint.opacity(0.16))
Capsule(style: .continuous)
.strokeBorder(tint.opacity(0.30))
}
}
}
@@ -127,7 +129,6 @@ public struct AISectionTitle: View {
.accessibilityHidden(true)
Text(title)
.font(.headline.weight(.semibold))
.kerning(-0.1)
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -158,6 +159,41 @@ public struct AIFieldLabel: View {
}
}
extension View {
/// 卡片内的二级面板:极浅底色 + 更浅的描边,只做"分组",不抢主信息的对比度。
func aiInsetSurface(cornerRadius: CGFloat = 12) -> some View {
self
.background(AITheme.tile)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(AITheme.tileLine)
}
}
}
/// 带语义色的内嵌指标块:底色/描边都由 tint 决定,浅色下是一层很淡的色洗。
struct AITintTile<Content: View>: View {
private let tint: Color
private let content: Content
init(tint: Color, @ViewBuilder content: () -> Content) {
self.tint = tint
self.content = content()
}
var body: some View {
content
.frame(maxWidth: .infinity, alignment: .leading)
.background(tint.opacity(0.09))
.clipShape(RoundedRectangle(cornerRadius: AITheme.tileRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.tileRadius, style: .continuous)
.strokeBorder(tint.opacity(0.20))
}
}
}
public struct AIStatCard: View {
private let title: String
private let value: String
@@ -180,33 +216,36 @@ public struct AIStatCard: View {
}
public var body: some View {
AICard(padding: 12) {
HStack(alignment: .center, spacing: 10) {
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 32, height: 32)
.background(tint.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption2.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.85)
.foregroundStyle(AITheme.muted)
Text(value)
.font(.headline.weight(.semibold))
.foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
.frame(maxWidth: .infinity, alignment: .leading)
// 卡内不再叠一张卡:双层阴影 + 双层描边是浅色发灰的主因。
HStack(alignment: .center, spacing: 10) {
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 30, height: 30)
.background(tint.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
.accessibilityHidden(true)
}
.accessibilityElement(children: .combine)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption2.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.85)
.foregroundStyle(AITheme.muted)
Text(value)
.font(.headline.weight(.semibold))
.monospacedDigit()
.foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.aiInsetSurface(cornerRadius: AITheme.tileRadius)
.accessibilityElement(children: .combine)
}
}
@@ -247,11 +286,34 @@ public struct AIEmptyState: View {
private let systemImage: String
private let title: String
private let message: String
private let actionTitle: String?
private let actionSystemImage: String
private let action: (() -> Void)?
public init(systemImage: String, title: String, message: String) {
self.systemImage = systemImage
self.title = title
self.message = message
self.actionTitle = nil
self.actionSystemImage = "arrow.clockwise"
self.action = nil
}
/// 失败/空态带一个就地重试入口,避免"红色横幅 + 暂无数据"互相矛盾。
public init(
systemImage: String,
title: String,
message: String,
actionTitle: String,
actionSystemImage: String = "arrow.clockwise",
action: @escaping () -> Void
) {
self.systemImage = systemImage
self.title = title
self.message = message
self.actionTitle = actionTitle
self.actionSystemImage = actionSystemImage
self.action = action
}
public var body: some View {
@@ -271,6 +333,13 @@ public struct AIEmptyState: View {
.foregroundStyle(AITheme.muted)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
if let actionTitle, let action {
Button(action: action) {
Label(actionTitle, systemImage: actionSystemImage)
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
.padding(.top, 4)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
@@ -375,7 +444,7 @@ public struct AILoadingCard: View {
)
)
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
.shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8)
.shadow(color: AITheme.brand.opacity(0.08), radius: 20, x: 0, y: 10)
.overlay {
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
.strokeBorder(AITheme.line)
@@ -460,11 +529,13 @@ public struct AIKeyValueRow: View {
private let title: String
private let value: String
private let valueTint: Color
private let monospaced: Bool
public init(_ title: String, value: String, tint: Color = AITheme.ink) {
public init(_ title: String, value: String, tint: Color = AITheme.ink, monospaced: Bool = false) {
self.title = title
self.value = value
self.valueTint = tint
self.monospaced = monospaced
}
public var body: some View {
@@ -476,7 +547,12 @@ public struct AIKeyValueRow: View {
.layoutPriority(1)
Spacer(minLength: 12)
Text(value)
.font(.subheadline.weight(.medium))
.font(
monospaced
? Font.system(.subheadline, design: .monospaced, weight: .medium)
: Font.subheadline.weight(.medium)
)
.monospacedDigit()
.foregroundStyle(valueTint)
.multilineTextAlignment(.trailing)
.lineLimit(2)
@@ -505,7 +581,7 @@ public struct AISegmentedControl<Value: Hashable>: View {
}
public var body: some View {
HStack(spacing: 4) {
HStack(spacing: 3) {
ForEach(options, id: \.value) { option in
let isSelected = selection == option.value
@@ -513,22 +589,16 @@ public struct AISegmentedControl<Value: Hashable>: View {
selection = option.value
} label: {
Text(option.title)
.font(.body.weight(isSelected ? .semibold : .medium))
.font(.subheadline.weight(isSelected ? .semibold : .medium))
.lineLimit(1)
.minimumScaleFactor(0.75)
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
.foregroundStyle(isSelected ? AITheme.ink : AITheme.muted)
.frame(maxWidth: .infinity, minHeight: AITheme.segmentedControlHeight)
// 分段控件是"视图切换"而不是主操作:抬起的中性块,避免和标签筛选抢注意力。
.background {
if isSelected {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [AITheme.primaryTop, AITheme.primaryBottom],
startPoint: .top,
endPoint: .bottom
)
)
}
RoundedRectangle(cornerRadius: 11, style: .continuous)
.fill(isSelected ? AITheme.segmentSelected : Color.clear)
.shadow(color: isSelected ? AITheme.cardShadow : .clear, radius: 3, x: 0, y: 1)
}
}
.buttonStyle(.plain)
@@ -537,12 +607,12 @@ public struct AISegmentedControl<Value: Hashable>: View {
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
.padding(4)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(3)
.background(AITheme.segmentTrack)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(AITheme.line)
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(AITheme.tileLine)
}
.animation(.easeOut(duration: 0.16), value: selection)
.accessibilityElement(children: .contain)
@@ -567,15 +637,18 @@ public struct AISearchField: View {
private let placeholder: String
@Binding private var text: String
private var onSubmit: (() -> Void)?
private let minHeight: CGFloat
@FocusState private var isFocused: Bool
public init(
_ placeholder: String,
text: Binding<String>,
minHeight: CGFloat = AITheme.buttonHeight,
onSubmit: (() -> Void)? = nil
) {
self.placeholder = placeholder
self._text = text
self.minHeight = minHeight
self.onSubmit = onSubmit
}
@@ -607,7 +680,7 @@ public struct AISearchField: View {
.accessibilityLabel("清除搜索")
}
}
.aiInputSurface(minHeight: AITheme.buttonHeight, isFocused: isFocused)
.aiInputSurface(minHeight: minHeight, isFocused: isFocused)
}
}
@@ -651,14 +724,14 @@ extension View {
}
}
func aiSelectSurface() -> some View {
func aiSelectSurface(compact: Bool = false) -> some View {
self
.tint(AITheme.ink)
.font(.subheadline.weight(.semibold))
.font(compact ? .footnote.weight(.semibold) : .subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
.aiInputSurface()
.aiInputSurface(minHeight: compact ? AITheme.compactControlHeight : AITheme.inputHeight)
}
}
@@ -727,3 +800,45 @@ public struct AIIconButton: View {
.accessibilityLabel(title)
}
}
/// 列表行内的轻量图标动作:同色淡底小方块,触区仍补足 44pt。
/// 行内操作用实心药丸会把一行读成"按钮墙",这里让颜色只表达危险程度。
public struct AISymbolAction: View {
private let title: String
private let systemImage: String
private let tint: Color
private let action: () -> Void
public init(
_ title: String,
systemImage: String,
tint: Color = AITheme.muted,
action: @escaping () -> Void
) {
self.title = title
self.systemImage = systemImage
self.tint = tint
self.action = action
}
public var body: some View {
Button {
action()
} label: {
Image(systemName: systemImage)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint)
.frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize)
.background(tint.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous)
.strokeBorder(tint.opacity(0.18))
}
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(title)
}
}
@@ -28,41 +28,50 @@ private enum AIPalette {
dynamicColor(light: light, dark: dark)
}
static let canvas = color(light: rgba(245, 246, 248, 1), dark: rgba(6, 7, 9, 1))
static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(18, 20, 24, 1))
static let surfaceRaised = color(light: rgba(251, 252, 253, 1), dark: rgba(27, 30, 35, 1))
static let control = color(light: rgba(243, 245, 247, 1), dark: rgba(26, 29, 34, 1))
static let chrome = color(light: rgba(255, 255, 255, 0.82), dark: rgba(11, 12, 15, 0.82))
static let ink = color(light: rgba(15, 18, 22, 1), dark: rgba(243, 245, 247, 1))
static let muted = color(light: rgba(98, 107, 119, 1), dark: rgba(151, 160, 172, 1))
static let line = color(light: rgba(227, 230, 234, 1), dark: rgba(255, 255, 255, 0.09))
static let brand = color(light: rgba(13, 15, 18, 1), dark: rgba(8, 9, 11, 1))
static let primary = color(light: rgba(17, 20, 25, 1), dark: rgba(220, 178, 111, 1))
static let primaryTop = color(light: rgba(43, 49, 58, 1), dark: rgba(240, 209, 154, 1))
static let primaryBottom = color(light: rgba(15, 18, 22, 1), dark: rgba(198, 154, 92, 1))
static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(12, 14, 17, 1))
static let danger = color(light: rgba(190, 65, 57, 1), dark: rgba(242, 124, 113, 1))
static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(22, 9, 8, 1))
static let warning = color(light: rgba(170, 111, 23, 1), dark: rgba(226, 166, 65, 1))
static let success = color(light: rgba(50, 150, 101, 1), dark: rgba(103, 198, 148, 1))
// 浅色:中性冷灰底 + 纯白卡 + 极浅内嵌面,避免"灰蓝蒙层"把层级糊成一团。
static let canvas = color(light: rgba(242, 244, 248, 1), dark: rgba(7, 10, 16, 1))
static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(20, 26, 36, 1))
static let surfaceRaised = color(light: rgba(255, 255, 255, 1), dark: rgba(26, 35, 49, 1))
static let control = color(light: rgba(237, 241, 247, 1), dark: rgba(34, 44, 60, 1))
static let tile = color(light: rgba(247, 249, 252, 1), dark: rgba(255, 255, 255, 0.05))
static let tileLine = color(light: rgba(233, 239, 247, 1), dark: rgba(255, 255, 255, 0.08))
static let chrome = color(light: rgba(255, 255, 255, 0.90), dark: rgba(12, 17, 25, 0.86))
static let ink = color(light: rgba(15, 23, 41, 1), dark: rgba(241, 245, 249, 1))
static let muted = color(light: rgba(92, 106, 128, 1), dark: rgba(154, 167, 184, 1))
static let faint = color(light: rgba(141, 152, 170, 1), dark: rgba(112, 127, 148, 1))
static let line = color(light: rgba(224, 231, 240, 1), dark: rgba(255, 255, 255, 0.10))
static let brand = color(light: rgba(11, 27, 51, 1), dark: rgba(7, 10, 16, 1))
static let primary = color(light: rgba(18, 96, 214, 1), dark: rgba(96, 165, 250, 1))
static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(5, 22, 43, 1))
static let danger = color(light: rgba(199, 44, 44, 1), dark: rgba(248, 113, 113, 1))
static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(24, 7, 7, 1))
static let warning = color(light: rgba(176, 84, 12, 1), dark: rgba(245, 158, 11, 1))
static let success = color(light: rgba(16, 124, 90, 1), dark: rgba(74, 222, 128, 1))
static let segmentTrack = color(light: rgba(236, 240, 246, 1), dark: rgba(12, 17, 25, 1))
static let segmentSelected = color(light: rgba(255, 255, 255, 1), dark: rgba(48, 61, 81, 1))
static let cardShadow = color(light: rgba(15, 30, 60, 0.07), dark: rgba(0, 0, 0, 0.55))
#elseif canImport(AppKit)
static let canvas = Color(red: 0.953, green: 0.961, blue: 0.973)
static let canvas = Color(red: 0.914, green: 0.933, blue: 0.961)
static let surface = Color.white
static let control = Color(red: 0.969, green: 0.976, blue: 0.984)
static let ink = Color(red: 0.110, green: 0.153, blue: 0.220)
static let muted = Color(red: 0.435, green: 0.486, blue: 0.561)
static let line = Color(red: 0.878, green: 0.898, blue: 0.922)
static let brand = Color(red: 0.075, green: 0.118, blue: 0.188)
static let surfaceRaised = Color(red: 0.984, green: 0.988, blue: 0.992)
static let chrome = Color.white.opacity(0.82)
static let control = Color(red: 0.941, green: 0.957, blue: 0.980)
static let ink = Color(red: 0.035, green: 0.086, blue: 0.169)
static let muted = Color(red: 0.275, green: 0.341, blue: 0.435)
static let line = Color(red: 0.827, green: 0.867, blue: 0.918)
static let brand = Color(red: 0.035, green: 0.086, blue: 0.169)
static let surfaceRaised = Color.white
static let chrome = Color.white.opacity(0.86)
static let onPrimary = Color.white
static let onDanger = Color.white
static let primary = Color(red: 0.067, green: 0.078, blue: 0.098)
static let primaryTop = Color(red: 0.17, green: 0.19, blue: 0.23)
static let primaryBottom = Color(red: 0.06, green: 0.07, blue: 0.09)
static let danger = Color(red: 0.745, green: 0.255, blue: 0.224)
static let warning = Color(red: 0.667, green: 0.435, blue: 0.09)
static let success = Color(red: 0.196, green: 0.588, blue: 0.396)
static let primary = Color(red: 0.145, green: 0.388, blue: 0.922)
static let danger = Color(red: 0.784, green: 0.118, blue: 0.118)
static let warning = Color(red: 0.706, green: 0.325, blue: 0.035)
static let success = Color(red: 0.071, green: 0.502, blue: 0.361)
static let tile = Color(red: 0.969, green: 0.976, blue: 0.988)
static let tileLine = Color(red: 0.914, green: 0.937, blue: 0.969)
static let faint = Color(red: 0.553, green: 0.596, blue: 0.667)
static let segmentTrack = Color(red: 0.925, green: 0.941, blue: 0.965)
static let segmentSelected = Color.white
static let cardShadow = Color(red: 0.059, green: 0.118, blue: 0.235).opacity(0.07)
#else
private static func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> Color {
Color(red: red / 255, green: green / 255, blue: blue / 255)
@@ -80,11 +89,15 @@ private enum AIPalette {
static let onPrimary = color(255, 255, 255)
static let onDanger = color(255, 255, 255)
static let primary = color(17, 20, 25)
static let primaryTop = color(43, 49, 58)
static let primaryBottom = color(15, 18, 22)
static let danger = color(190, 65, 57)
static let warning = color(170, 111, 23)
static let success = color(50, 150, 101)
static let tile = color(247, 249, 252)
static let tileLine = color(233, 239, 247)
static let faint = color(141, 152, 170)
static let segmentTrack = color(236, 240, 246)
static let segmentSelected = color(255, 255, 255)
static let cardShadow = color(15, 30, 60)
#endif
}
@@ -93,30 +106,39 @@ public enum AITheme {
public static let surface = AIPalette.surface
public static let surfaceRaised = AIPalette.surfaceRaised
public static let control = AIPalette.control
public static let tile = AIPalette.tile
public static let tileLine = AIPalette.tileLine
public static let chrome = AIPalette.chrome
public static let ink = AIPalette.ink
public static let muted = AIPalette.muted
public static let faint = AIPalette.faint
public static let line = AIPalette.line
public static let brand = AIPalette.brand
public static let primary = AIPalette.primary
public static let primaryTop = AIPalette.primaryTop
public static let primaryBottom = AIPalette.primaryBottom
public static let onPrimary = AIPalette.onPrimary
public static let danger = AIPalette.danger
public static let onDanger = AIPalette.onDanger
public static let warning = AIPalette.warning
public static let success = AIPalette.success
public static let segmentTrack = AIPalette.segmentTrack
public static let segmentSelected = AIPalette.segmentSelected
public static let cardShadow = AIPalette.cardShadow
public static let buttonHeight: CGFloat = 48
public static let compactButtonHeight: CGFloat = 44
public static let largeButtonHeight: CGFloat = 56
public static let toolbarButtonHeight: CGFloat = 44
public static let iconButtonSize: CGFloat = 44
// 导航栏只给约 38pt 高度,44pt 的方块会被上下裁切,所以工具栏图标按钮单独取 34。
public static let iconButtonSize: CGFloat = 34
public static let iconButtonRadius: CGFloat = 11
public static let buttonRadius: CGFloat = 16
public static let cardRadius: CGFloat = 22
public static let inputRadius: CGFloat = 16
public static let inputHeight: CGFloat = 52
public static let segmentedControlHeight: CGFloat = 44
public static let tileRadius: CGFloat = 14
public static let chipHeight: CGFloat = 34
public static let compactControlHeight: CGFloat = 44
public static let compactButtonMinWidth: CGFloat = 72
public static let regularButtonMinWidth: CGFloat = 88
public static let toolbarButtonMinWidth: CGFloat = 96
@@ -11,7 +11,8 @@ final class AIThemeTests: XCTestCase {
XCTAssertEqual(AITheme.toolbarButtonHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.segmentedControlHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.buttonRadius, 16, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 34, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonRadius, 11, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonMinWidth, 96, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonWidth, 104, accuracy: 0.1)
XCTAssertEqual(AITheme.footerSecondaryButtonWidth, 104, accuracy: 0.1)
@@ -6,7 +6,7 @@ final class AppMetadataTests: XCTestCase {
func testMetadataUsesIPAFoundationContract() {
XCTAssertEqual(AppMetadata.name, "MultiSimAdmin")
XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin")
XCTAssertEqual(AppMetadata.marketingVersion, "0.2.0")
XCTAssertEqual(AppMetadata.buildVersion, "3")
XCTAssertEqual(AppMetadata.marketingVersion, "0.2.1")
XCTAssertEqual(AppMetadata.buildVersion, "6")
}
}
@@ -4,12 +4,18 @@ import XCTest
@MainActor
final class FleetViewModelTests: XCTestCase {
private func transientPreferences() -> UserDefaults {
let suite = "FleetViewModelTests.\(UUID().uuidString)"
return UserDefaults(suiteName: suite)!
}
func testRefreshSortsByNameAndReportsStatus() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore()
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(
name: "Zulu",
@@ -36,7 +42,8 @@ final class FleetViewModelTests: XCTestCase {
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore()
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(name: "Home", origin: URL(string: "http://192.168.1.70:3000")!, tags: ["home"]))
try await model.add(InstanceInput(name: "Lab", origin: URL(string: "http://192.168.1.71:3000")!, tags: []))
@@ -48,14 +55,80 @@ final class FleetViewModelTests: XCTestCase {
XCTAssertEqual(model.groupedRows.flatMap(\.rows).map(\.instance.name), ["Home"])
model.searchText = ""
model.groupMode = .tags
XCTAssertEqual(model.groupedRows.map(\.title), ["home", "未打标签"])
XCTAssertEqual(model.groupedRows.map(\.rows.count), [1, 1])
model.clearTagFilters()
model.toggleTagFilter("Home")
XCTAssertEqual(model.allTags, ["home"])
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
XCTAssertEqual(model.countForTagFilter("home"), 1)
model.clearTagFilters()
model.groupMode = .status
XCTAssertEqual(model.groupedRows.map(\.title), ["在线"])
}
func testTagFiltersCombineWithAndSemanticsAndPruneStaleSelections() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(
name: "Both",
origin: URL(string: "http://192.168.1.90:3000")!,
tags: ["primary", "field"]
))
try await model.add(InstanceInput(
name: "Single",
origin: URL(string: "http://192.168.1.91:3000")!,
tags: ["primary"]
))
await model.refresh()
model.toggleTagFilter("primary")
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Both", "Single"])
model.toggleTagFilter("FIELD")
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Both"])
model.toggleTagFilter("field")
model.toggleTagFilter("primary")
XCTAssertTrue(model.selectedTagFilters.isEmpty)
model.toggleTagFilter("vanished")
XCTAssertTrue(model.isTagFilterActive("vanished"))
await model.refresh()
XCTAssertTrue(model.selectedTagFilters.isEmpty, "已不存在的标签筛选应当在刷新后自动清理")
}
func testTagFilterSelectionSurvivesAcrossInstances() async throws {
let suite = "FleetTagFilterTests.\(UUID().uuidString)"
let repository = MemoryInstanceRepository()
let first = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: UserDefaults(suiteName: suite)!
)
try await first.add(InstanceInput(
name: "Relay",
origin: URL(string: "http://192.168.1.92:3000")!,
tags: ["relay"]
))
await first.refresh()
first.toggleTagFilter("relay")
let second = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: UserDefaults(suiteName: suite)!
)
XCTAssertTrue(second.selectedTagFilters.contains("relay"))
UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite)
}
func testGroupModeKeepsUngroupedInstancesVisible() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
@@ -15,7 +15,8 @@ final class MessageServiceTests: XCTestCase {
XCTAssertEqual(client.routes.first, .smsList(limit: 2, offset: 0, direction: nil))
XCTAssertEqual(page.messages.count, 1)
let message = try XCTUnwrap(page.messages.first)
XCTAssertEqual(message.id, "42")
XCTAssertEqual(message.upstreamID, "42")
XCTAssertEqual(message.id, "\(instance.id.uuidString)#42")
XCTAssertEqual(message.instanceID, instance.id)
XCTAssertEqual(message.instanceName, "Message Node")
XCTAssertEqual(message.phoneNumber, "13800138000")
@@ -11,10 +11,10 @@ final class SettingsViewModelTests: XCTestCase {
await model.refresh()
XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin")
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.0")
XCTAssertEqual(model.runtime?.buildVersion, "3")
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.1")
XCTAssertEqual(model.runtime?.buildVersion, "6")
XCTAssertEqual(model.runtime?.platform, "iOS")
XCTAssertTrue(model.runtime?.deviceModel.isEmpty == false)
XCTAssertTrue(model.runtime?.systemVersion.hasPrefix("iOS") ?? false)
XCTAssertNil(model.errorMessage)
}
@@ -0,0 +1,50 @@
import XCTest
@testable import MultiSimAdmin
final class TimestampTextTests: XCTestCase {
private let calendar = Calendar(identifier: .gregorian)
func testTodayOnlyShowsClockTime() {
let output = TimestampText.short("2026-09-14 08:05:00", now: date(2026, 9, 14, 20, 0))
XCTAssertEqual(output, "08:05")
}
func testSameYearShowsMonthAndTime() {
let output = TimestampText.short("2026-03-02 08:05:00", now: date(2026, 9, 14, 20, 0))
XCTAssertEqual(output, "03-02 08:05")
}
func testOtherYearFallsBackToDate() {
let output = TimestampText.short("2025-12-31 23:59:00", now: date(2026, 9, 14, 20, 0))
XCTAssertEqual(output, "2025-12-31")
}
func testParsesEpochMilliseconds() {
XCTAssertEqual(TimestampText.date(from: "1767484800000"), Date(timeIntervalSince1970: 1_767_484_800))
}
func testParsesEpochSeconds() {
XCTAssertEqual(TimestampText.date(from: "1767484800"), Date(timeIntervalSince1970: 1_767_484_800))
}
func testZuluTimestampIsReadAsUTC() {
let iso = try? XCTUnwrap(TimestampText.date(from: "2026-01-04T00:00:00Z"))
XCTAssertEqual(iso, Date(timeIntervalSince1970: 1_767_484_800))
}
func testUnparseableTextIsKeptAsIs() {
XCTAssertEqual(TimestampText.short(" 刚刚 "), "刚刚")
XCTAssertEqual(TimestampText.short(""), "")
}
private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date {
var components = DateComponents()
components.year = year
components.month = month
components.day = day
components.hour = hour
components.minute = minute
return calendar.date(from: components)!
}
}
+40
View File
@@ -0,0 +1,40 @@
# iOS Native 0.2.1
0.2.1 (6) 在 0.2.0 的基础上做界面与图标重构,功能集合不变。
## 本次变更
- 应用图标重做:改用与 Web 端 favicon 同一套几何的扁平标记——金色 SIM 卡体加三格递增信号,左上保留 SIM 切角。放弃原先的照片级立体卡,29pt 设置图标下也能读清轮廓。
- 标签形状统一:详情页与日志页的状态标签改为与节点卡片一致的胶囊,不再一处圆角矩形、一处胶囊。
- 深色主题下标签可见性提高,色雾与描边权重上调,避免浅底可读、深底消失。
- 消息卡片去掉分隔线并压缩内边距,单屏可见消息数由 2.5 条提升到 3.5 条。
- 节点详情的分区卡片收紧行距,"设备"分区同一屏位由 3 行增加到 5 行。
- 节点详情头部指标由 8 行精简为 4 行(号码、运营商、网络、IP),数据模块由 6 枚胶囊改为一条读数,仅在缺模块时展开。
- 批量选择勾选框移出实例名称区域,选中与未选中都不再压住文字。
- 批量选择工具栏统一为 34pt 图标按钮,全选、删除、退出三个控件等高。
- 分组编辑器改为点选即生效,去掉常驻禁用的"移出分组"按钮和说明文字。
## 数据与安全
与 0.2.0 一致:实例信息保存在应用沙盒本地数据文件,实例密码保存在同一沙盒的私有凭据文件,不使用 iOS Keychain,登录会话只存在于进程内存。应用仅连接用户添加的节点地址。
## 构建与安装
从仓库根目录构建:
```bash
./scripts/build-ios-ipa.sh
```
产物:
- `build/ipa/MultiSimAdmin.ipa`
- `build/ipa/MultiSimAdmin.ipa.sha256`
0.2.1 (6) 校验值:
```text
3bef3c678d0bb77a765d4920cb1fe917b82216f158e05b6f08315014e94bd8b7 MultiSimAdmin.ipa
```
IPA 目标为 iOS 16.0 及以上,`arm64`,Bundle ID 为 `fun.chick.multi-simadmin`。构建脚本不要求开发者证书,TrollStore 或自有签名流程可以后续处理。