diff --git a/README.md b/README.md
index 568861c..7086ff9 100644
--- a/README.md
+++ b/README.md
@@ -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)。
## 功能
diff --git a/apps/ios/Design/AppIcon.svg b/apps/ios/Design/AppIcon.svg
index 0ff9523..9c192f5 100644
--- a/apps/ios/Design/AppIcon.svg
+++ b/apps/ios/Design/AppIcon.svg
@@ -1,26 +1,44 @@
+
diff --git a/apps/ios/Design/generate_app_icon.swift b/apps/ios/Design/generate_app_icon.swift
new file mode 100644
index 0000000..d9726d6
--- /dev/null
+++ b/apps/ios/Design/generate_app_icon.swift
@@ -0,0 +1,276 @@
+// Renders the 1024x1024 MultiSimAdmin app icon with CoreGraphics only.
+// Usage: swift Design/generate_app_icon.swift
+//
+// 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)
diff --git a/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift b/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift
index 7928bd3..b391520 100644
--- a/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift
+++ b/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift
@@ -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"
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift
index f6d0517..1e0342c 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift
@@ -25,7 +25,7 @@ final class DeviceViewModel: ObservableObject {
} catch DeviceServiceError.unauthorized {
errorMessage = "需要实例密码登录后才能读取设备数据。"
} catch {
- errorMessage = "无法加载设备数据。"
+ errorMessage = "节点无响应,请确认地址可访问、设备已开机且端口未被拦截。"
}
guard let probe else {
connectionStatus = .offline
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift
index 5aec34c..a225895 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift
@@ -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.. 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 = ["连接状态", "号码", "信号", "延迟"]
- private let valueKeys: Set = [
- "设备", "运营商", "网络", "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.. 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"
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift
index 6680a4f..6c03a31 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift
@@ -7,9 +7,34 @@ struct FleetView: View {
@State private var collapsedGroups: Set = []
@State private var isSummaryCollapsed = true
@State private var showingGroupEditor = false
-
init(model: FleetViewModel? = nil) {
- _model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
+ // StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上,
+ // 所以调试开关必须先落到这个具体的 model 对象。
+ let target = model ?? DefaultContainer.makeFleetModel()
+ #if DEBUG
+ if DebugLaunch.has("--summary-open") {
+ _isSummaryCollapsed = State(initialValue: false)
+ }
+ if DebugLaunch.has("--sheet-editor") {
+ _showingGroupEditor = State(initialValue: true)
+ }
+ if DebugLaunch.has("--select-mode") {
+ target.selectionMode = true
+ }
+ if DebugLaunch.has("--sheet-form") {
+ target.showingForm = true
+ }
+ if DebugLaunch.has("--sheet-groups") {
+ target.showingGroupManager = true
+ }
+ if DebugLaunch.has("--group-status") {
+ target.groupMode = .status
+ }
+ if DebugLaunch.has("--group-flat") {
+ target.groupMode = .none
+ }
+ #endif
+ _model = StateObject(wrappedValue: target)
}
var body: some View {
@@ -25,7 +50,9 @@ struct FleetView: View {
}
if !model.rows.isEmpty {
organizationControls
- fleetSummary
+ if showsSummary {
+ fleetSummary
+ }
}
if model.isLoading && model.rows.isEmpty {
AICard(padding: 16) {
@@ -87,34 +114,27 @@ struct FleetView: View {
}
.toolbar {
if model.selectionMode {
- ToolbarItemGroup(placement: .navigation) {
- Button {
- model.selectedIDs = model.selectedIDs.count == model.visibleRows.count
- ? []
- : Set(model.visibleRows.map(\.id))
- } label: {
- Image(systemName: model.selectedIDs.count == model.visibleRows.count ? "circle" : "checkmark.circle")
+ ToolbarItem(placement: .navigation) {
+ AIIconButton(
+ allVisibleSelected ? "取消全选" : "全选",
+ systemImage: allVisibleSelected ? "checkmark.circle.fill" : "checkmark.circle",
+ kind: .secondary
+ ) {
+ model.selectedIDs = allVisibleSelected ? [] : Set(model.visibleRows.map(\.id))
}
- .accessibilityLabel(model.selectedIDs.count == model.visibleRows.count ? "取消全选" : "全选")
.disabled(model.visibleRows.isEmpty)
-
- Button {
- model.selectionMode = false
- model.selectedIDs.removeAll()
- } label: {
- Image(systemName: "xmark")
- }
- .accessibilityLabel("退出批量选择")
}
ToolbarItemGroup(placement: .primaryAction) {
- Button {
+ AIIconButton("删除已选实例", systemImage: "trash", kind: .danger) {
showsDeleteDialog = true
- } label: {
- Image(systemName: "trash")
}
- .accessibilityLabel(model.selectedIDs.isEmpty ? "删除" : "删除 \(model.selectedIDs.count) 个实例")
.disabled(model.selectedIDs.isEmpty)
+
+ AIIconButton("退出批量选择", systemImage: "xmark", kind: .secondary) {
+ model.selectionMode = false
+ model.selectedIDs.removeAll()
+ }
}
} else {
ToolbarItemGroup(placement: .primaryAction) {
@@ -163,7 +183,7 @@ struct FleetView: View {
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
- .navigationTitle("节点总览")
+ .navigationTitle(model.selectionMode ? "已选 \(model.selectedIDs.count) 台" : "节点总览")
.confirmationDialog(
"删除已选实例?登录信息也会一并清除。",
isPresented: $showsDeleteDialog,
@@ -187,13 +207,25 @@ private extension FleetView {
private static let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
+ formatter.locale = Locale(identifier: "zh_CN")
return formatter
}()
+ /// 视觉回归用:`--hide-summary` 只看节点卡片,不影响 Release。
+ var showsSummary: Bool {
+ #if DEBUG
+ return !DebugLaunch.has("--hide-summary")
+ #else
+ return true
+ #endif
+ }
+
private var updatedDetail: String {
guard let lastRefresh = model.lastRefresh else {
return model.isLoading ? "正在读取" : "待刷新"
}
+ // RelativeDateTimeFormatter 在中文下会把刚发生的事说成"0秒后",这里直接兜成"刚刚"。
+ if Date().timeIntervalSince(lastRefresh) < 45 { return "刚刚更新" }
return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))"
}
@@ -204,13 +236,89 @@ private extension FleetView {
}
private var organizationControls: some View {
- VStack(alignment: .leading, spacing: 8) {
+ VStack(alignment: .leading, spacing: 10) {
AISegmentedControl(
"组织方式",
selection: $model.groupMode,
options: FleetGroupMode.allCases.map { ($0, $0.title) }
)
+ if model.allTags.isEmpty == false {
+ tagFilterStrip
+ }
+ }
+ }
+
+ // 标签直接作为筛选器使用:一条横向可滑动的胶囊,首个「全部」用于复位。
+ private var tagFilterStrip: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ FilterChip(
+ title: "全部",
+ count: model.rows.count,
+ isStreaming: false,
+ isSelected: model.selectedTagFilters.isEmpty
+ ) {
+ guard model.selectedTagFilters.isEmpty == false else { return }
+ withAnimation(.easeOut(duration: 0.18)) { model.clearTagFilters() }
+ }
+
+ ForEach(model.allTags, id: \.self) { tag in
+ FilterChip(
+ title: tag,
+ count: model.countForTagFilter(tag),
+ isStreaming: true,
+ isSelected: model.isTagFilterActive(tag)
+ ) {
+ withAnimation(.easeOut(duration: 0.18)) { model.toggleTagFilter(tag) }
+ }
+ }
+ }
+ .padding(.vertical, 3)
+ .padding(.horizontal, 1)
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("按标签筛选")
+ }
+
+ private struct FilterChip: View {
+ let title: String
+ let count: Int
+ let isStreaming: Bool
+ let isSelected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 5) {
+ if isStreaming {
+ Image(systemName: isSelected ? "checkmark" : "tag")
+ .font(.system(size: 10, weight: .semibold))
+ .accessibilityHidden(true)
+ }
+ Text(title)
+ .font(.footnote.weight(isSelected ? .semibold : .medium))
+ .lineLimit(1)
+ Text("\(count)")
+ .font(.caption2.weight(.semibold))
+ .monospacedDigit()
+ .opacity(0.75)
+ }
+ .padding(.horizontal, 12)
+ .frame(height: 38)
+ .foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.ink)
+ .background(isSelected ? AITheme.primary : AITheme.surface)
+ .clipShape(Capsule(style: .continuous))
+ .overlay {
+ Capsule(style: .continuous)
+ .strokeBorder(isSelected ? AITheme.primary : AITheme.line)
+ }
+ .shadow(color: isSelected ? AITheme.cardShadow : .clear, radius: 6, x: 0, y: 2)
+ .contentShape(Capsule(style: .continuous))
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(isStreaming ? "标签 \(title),\(count) 台" : "显示全部 \(count) 台")
+ .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
}
}
@@ -221,15 +329,15 @@ private extension FleetView {
}
} label: {
HStack(spacing: 10) {
- Image(systemName: "gauge.with.needle")
- .font(.subheadline.weight(.semibold))
+ Image(systemName: "chart.bar.fill")
+ // 仪表盘符号在小字号下会糊成一团,换成同尺寸能读清的柱状图。
+ .font(.system(size: 15, weight: .semibold))
.foregroundStyle(AITheme.primary)
.frame(width: 22, height: 22)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
Text("运行概览")
.font(.headline.weight(.semibold))
- .kerning(-0.1)
.foregroundStyle(AITheme.ink)
Text(updatedDetail)
.font(.caption2.weight(.medium))
@@ -237,11 +345,9 @@ private extension FleetView {
}
Spacer(minLength: 8)
Image(systemName: isSummaryCollapsed ? "chevron.down" : "chevron.up")
- .font(.system(size: 12, weight: .semibold))
+ .font(.system(size: 11, weight: .semibold))
.foregroundStyle(AITheme.muted)
- .frame(width: 30, height: 30)
- .background(AITheme.control)
- .clipShape(Circle())
+ .frame(width: 24, height: 24)
.accessibilityHidden(true)
}
.padding(.vertical, 7)
@@ -268,6 +374,11 @@ private extension FleetView {
private var summaryRows: [FleetViewModel.Row] { model.visibleRows }
+ private var allVisibleSelected: Bool {
+ model.visibleRows.isEmpty == false
+ && model.selectedIDs.count == model.visibleRows.count
+ }
+
private var batchGroupTargetIDs: [UUID] {
model.selectedIDs.isEmpty ? [] : Array(model.selectedIDs).sorted {
$0.uuidString < $1.uuidString
@@ -275,236 +386,269 @@ private extension FleetView {
}
private var fleetSummary: some View {
- AICard(padding: 16) {
- VStack(alignment: .leading, spacing: 12) {
+ AICard(padding: 14) {
+ VStack(alignment: .leading, spacing: 10) {
fleetOverviewHeader
- HStack(spacing: 10) {
- FleetPrimaryStat(
+ // 结论行只用排版,不额外套色块,卡片里不再出现卡片。
+ HStack(alignment: .top, spacing: 14) {
+ FleetHeadlineStat(
title: "在线率",
- value: summaryRows.isEmpty ? "--" : "\(Int((Double(onlineCount) / Double(summaryRows.count) * 100).rounded()))%",
- detail: "\(onlineCount)/\(summaryRows.count) 台在线",
- systemImage: "checkmark.seal.fill",
- tint: summaryRows.isEmpty ? AITheme.muted : (onlineCount == summaryRows.count ? AITheme.success : AITheme.warning)
+ value: onlineRateValue,
+ detail: onlineRateDetail,
+ tint: onlineRateTint
)
- FleetPrimaryStat(
+ FleetHeadlineStat(
title: "异常节点",
- value: "\(abnormalCount)",
- detail: abnormalCount > 0 ? "需要尽快处理" : "无异常节点",
- systemImage: abnormalCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.seal.fill",
- tint: abnormalCount > 0 ? AITheme.danger : AITheme.success
+ value: settledCount == 0 ? "--" : "\(abnormalCount)",
+ detail: abnormalDetail,
+ tint: abnormalTint
)
}
- if isSummaryCollapsed {
- HStack(spacing: 8) {
- FleetHeaderMiniStat(
- title: "信号",
- value: signalSummary,
- tint: signalSummaryTint,
- systemImage: "cellularbars"
- )
- FleetHeaderMiniStat(
- title: "存储",
- value: storageSummary,
- tint: storageSummaryTint,
- systemImage: "internaldrive"
- )
- FleetHeaderMiniStat(
- title: "分组",
- value: "\(groupCount)",
- tint: AITheme.primary,
- systemImage: "folder"
- )
- }
- } else {
- HStack(alignment: .center, spacing: 10) {
- HStack {
- Text("节点状态分布")
- .font(.caption.weight(.semibold))
- .kerning(0.2)
- .foregroundStyle(AITheme.muted)
- Spacer()
- Text("\(summaryRows.count) 台")
- .font(.caption.weight(.semibold))
- .monospacedDigit()
- .foregroundStyle(AITheme.muted)
- }
+ FleetDistributionBar(
+ segments: statusSegments,
+ total: summaryRows.count,
+ selection: Binding(
+ get: { model.statusFilter },
+ set: { model.statusFilter = $0 }
+ )
+ )
- Grid(alignment: .leading, horizontalSpacing: 7, verticalSpacing: 0) {
- GridRow {
- ForEach(statusSegments, id: \.status) { segment in
- statusSegmentButton(segment)
- }
- }
- }
+ if summaryRows.isEmpty && model.rows.isEmpty == false {
+ Text("当前筛选没有节点")
+ .font(.footnote.weight(.medium))
+ .foregroundStyle(AITheme.muted)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
- if model.statusFilter != nil {
- Button {
- model.statusFilter = nil
- } label: {
- Label("清除状态筛选", systemImage: "xmark.circle.fill")
- .font(.caption.weight(.semibold))
- .foregroundStyle(AITheme.primary)
- }
- .buttonStyle(.plain)
- .frame(minHeight: 32)
- }
- }
- }
+ FleetReadoutGrid(
+ items: isSummaryCollapsed
+ ? Array(fleetSummaryItems.prefix(4))
+ : fleetSummaryItems
+ )
if !isSummaryCollapsed {
- Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
- ForEach(stride(from: 0, to: fleetSummaryItems.count, by: 2).map {
- Array(fleetSummaryItems[$0.. some View {
- let isSelected = model.statusFilter == segment.status
- let tint = statusColor(segment.status)
-
- return Button {
- model.statusFilter = isSelected ? nil : segment.status
- } label: {
- VStack(alignment: .leading, spacing: 6) {
- Text("\(segment.count)")
- .font(.subheadline.weight(.bold))
- .monospacedDigit()
- .foregroundStyle(tint)
- .frame(maxWidth: .infinity, alignment: .leading)
- Capsule(style: .continuous)
- .fill(tint)
- .frame(height: 4)
- }
- .padding(.vertical, 9)
- .padding(.horizontal, 10)
- .frame(maxWidth: .infinity, minHeight: 44)
- .background(isSelected ? tint.opacity(0.14) : AITheme.control)
- .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
- .overlay {
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .strokeBorder(isSelected ? tint.opacity(0.38) : AITheme.line)
- }
- .accessibilityElement(children: .combine)
- .accessibilityLabel("\(segment.status.label) \(segment.count) 台")
- .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton])
- }
- .buttonStyle(.plain)
- }
-
- private struct FleetStatusSegment {
- let status: ConnectionStatus
- let count: Int
-
- var ratio: Double { max(0, Double(count)) }
- }
-
- private struct FleetGroupDistributionStrip: View {
- let grouped: Int
- let ungrouped: Int
- let groupCount: Int
- let tagCount: Int
-
- private var total: Int { max(1, grouped + ungrouped) }
- private var groupedRatio: CGFloat { CGFloat(grouped) / CGFloat(total) }
-
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- HStack(spacing: 10) {
- DistributionBadge(
- title: "分组",
- value: "\(groupCount)",
- systemImage: "folder",
- tint: AITheme.primary
- )
- DistributionBadge(
- title: "标签",
- value: "\(tagCount)",
- systemImage: "tag",
- tint: AITheme.success
- )
- Spacer(minLength: 8)
- Text("已分组 \(grouped)/\(grouped + ungrouped)")
- .font(.caption2.weight(.semibold))
- .monospacedDigit()
- .foregroundStyle(AITheme.muted)
- }
-
- GeometryReader { proxy in
- ZStack(alignment: .leading) {
- Capsule(style: .continuous)
- .fill(AITheme.control)
- Capsule(style: .continuous)
- .fill(AITheme.primary.opacity(0.30))
- .frame(width: max(4, proxy.size.width * groupedRatio))
- }
- }
- .frame(height: 5)
- }
- .accessibilityElement(children: .combine)
- }
- }
-
- private struct DistributionBadge: View {
+ /// 概览顶部的大字结论:标题在上,数字与说明同排。
+ struct FleetHeadlineStat: View {
let title: String
let value: String
- let systemImage: String
+ let detail: String
let tint: Color
var body: some View {
- HStack(spacing: 5) {
- Image(systemName: systemImage)
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(tint)
- .accessibilityHidden(true)
- Text("\(title) \(value)")
+ VStack(alignment: .leading, spacing: 1) {
+ Text(title)
.font(.caption2.weight(.semibold))
- .foregroundStyle(AITheme.ink)
+ .foregroundStyle(AITheme.muted)
.lineLimit(1)
- .minimumScaleFactor(0.8)
+ HStack(alignment: .firstTextBaseline, spacing: 5) {
+ Text(value)
+ .font(.system(size: 25, weight: .semibold))
+ .monospacedDigit()
+ .foregroundStyle(tint)
+ .lineLimit(1)
+ .minimumScaleFactor(0.6)
+ Text(detail)
+ .font(.caption2)
+ .foregroundStyle(AITheme.faint)
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ }
}
- .padding(.horizontal, 9)
- .frame(minHeight: 28)
- .background(tint.opacity(0.10))
- .clipShape(Capsule(style: .continuous))
- .overlay {
- Capsule(style: .continuous)
- .strokeBorder(tint.opacity(0.16))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityElement(children: .combine)
+ }
+ }
+
+ /// 状态分布:一条堆叠比例条 + 可点击图例,图例即状态筛选器。
+ struct FleetDistributionBar: View {
+ let segments: [FleetStatusSegment]
+ let total: Int
+ @Binding var selection: ConnectionStatus?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ GeometryReader { proxy in
+ HStack(spacing: 2) {
+ ForEach(segments, id: \.status) { segment in
+ Capsule(style: .continuous)
+ .fill(segment.tint.opacity(isDimmed(segment) ? 0.22 : 1))
+ .frame(width: segmentWidth(segment, in: proxy.size.width))
+ }
+ }
+ }
+ .frame(height: 6)
+ .accessibilityHidden(true)
+
+ HStack(spacing: 6) {
+ ForEach(segments, id: \.status) { segment in
+ FleetLegendChip(
+ title: segment.status.label,
+ count: segment.count,
+ tint: segment.tint,
+ isSelected: selection == segment.status
+ ) {
+ selection = selection == segment.status ? nil : segment.status
+ }
+ }
+ Spacer(minLength: 0)
+ }
}
}
+
+ private func isDimmed(_ segment: FleetStatusSegment) -> Bool {
+ selection != nil && selection != segment.status
+ }
+
+ private func segmentWidth(_ segment: FleetStatusSegment, in width: CGFloat) -> CGFloat {
+ let gaps = CGFloat(max(0, segments.count - 1)) * 2
+ let usable = max(1, width - gaps)
+ guard total > 0 else { return usable / CGFloat(max(1, segments.count)) }
+ return max(6, usable * CGFloat(max(0, segment.count)) / CGFloat(total))
+ }
+ }
+
+ struct FleetLegendChip: View {
+ let title: String
+ let count: Int
+ let tint: Color
+ let isSelected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 5) {
+ Circle()
+ .fill(tint)
+ .frame(width: 6, height: 6)
+ .accessibilityHidden(true)
+ Text(title)
+ .font(.caption2.weight(isSelected ? .semibold : .regular))
+ .lineLimit(1)
+ Text("\(count)")
+ .font(.caption2.weight(.semibold))
+ .monospacedDigit()
+ .opacity(0.72)
+ }
+ .padding(.horizontal, 9)
+ .frame(height: 26)
+ .foregroundStyle(isSelected ? tint : AITheme.muted)
+ .background(isSelected ? tint.opacity(0.12) : AITheme.tile)
+ .clipShape(Capsule(style: .continuous))
+ .overlay {
+ Capsule(style: .continuous)
+ .strokeBorder(isSelected ? tint.opacity(0.34) : AITheme.tileLine)
+ }
+ // 视觉高度保持 26,但把点击区扩到 44pt 以上。
+ .contentShape(Capsule(style: .continuous).inset(by: -9))
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("\(title) \(count) 台")
+ .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
+ }
+ }
+
+ /// 读数面板:四列栅格,仅用发丝线分隔,正常值不上颜色。
+ struct FleetReadoutGrid: View {
+ let items: [FleetStatItem]
+
+ private var rows: [[FleetStatItem]] {
+ stride(from: 0, to: items.count, by: 4).map { start in
+ Array(items[start.. 0 {
+ Rectangle()
+ .fill(AITheme.line)
+ .frame(height: 1)
+ }
+ HStack(alignment: .top, spacing: 0) {
+ ForEach(Array(row.enumerated()), id: \.element.title) { columnIndex, item in
+ FleetReadoutCell(item: item)
+ .padding(.leading, columnIndex == 0 ? 0 : 10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ if row.count < 4 {
+ ForEach(row.count..<4, id: \.self) { _ in
+ Color.clear
+ .frame(maxWidth: .infinity, minHeight: 1)
+ }
+ }
+ }
+ .padding(.vertical, 9)
+ }
+ }
+ }
+ }
+
+ struct FleetReadoutCell: View {
+ let item: FleetStatItem
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(item.title)
+ .font(.caption2)
+ .foregroundStyle(AITheme.faint)
+ .lineLimit(1)
+ Text(item.value)
+ .font(.footnote.weight(.semibold))
+ .monospacedDigit()
+ .foregroundStyle(readoutColor)
+ .lineLimit(1)
+ .minimumScaleFactor(0.68)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityElement(children: .combine)
+ }
+
+ private var readoutColor: Color {
+ if item.isPlaceholder { return AITheme.faint }
+ return item.isAlert ? item.tint : AITheme.ink
+ }
+ }
+
+ struct FleetOrganizationFootnote: View {
+ let groupCount: Int
+ let tagCount: Int
+ let grouped: Int
+ let total: Int
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Image(systemName: "folder")
+ .font(.system(size: 10, weight: .semibold))
+ .accessibilityHidden(true)
+ Text("\(groupCount) 个分组 · \(tagCount) 个标签 · 已归类 \(grouped)/\(max(grouped, total))")
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Spacer(minLength: 0)
+ }
+ .font(.caption2)
+ .foregroundStyle(AITheme.faint)
+ .padding(.top, 9)
+ .overlay(alignment: .top) {
+ Rectangle()
+ .fill(AITheme.line)
+ .frame(height: 1)
+ }
+ .accessibilityElement(children: .combine)
+ }
}
private var statusSegments: [FleetStatusSegment] {
@@ -516,17 +660,26 @@ private extension FleetView {
}
}
+ /// 概览分布条的一段,颜色与节点卡片上的状态色保持一致。
+ struct FleetStatusSegment {
+ let status: ConnectionStatus
+ let count: Int
+
+ var tint: Color {
+ switch status {
+ case .online: return AITheme.success
+ case .offline: return AITheme.danger
+ case .authRequired, .degraded: return AITheme.warning
+ case .unknown: return AITheme.muted
+ }
+ }
+ }
+
private var fleetSummaryItems: [FleetStatItem] {
var items = [
- FleetStatItem(
- title: "节点状态",
- value: "\(onlineCount) 在线 · \(abnormalCount) 异常",
- systemImage: "server.rack",
- tint: abnormalCount > 0 ? AITheme.warning : AITheme.success
- ),
FleetStatItem(
title: "检测进度",
- value: "\(settledCount)/\(summaryRows.count) 已读取",
+ value: "\(settledCount)/\(summaryRows.count)",
systemImage: "checkmark.seal",
tint: testingCount > 0 ? AITheme.warning : AITheme.success
),
@@ -570,12 +723,40 @@ private extension FleetView {
private var settledCount: Int { summaryRows.count - testingCount }
+ // 检测尚未完成时不给出结论性数字,避免把"还没读到"误读成"全部离线"。
+ private var hasSettledResults: Bool { summaryRows.isEmpty == false && settledCount > 0 }
+
+ private var onlineRateValue: String {
+ guard hasSettledResults else { return "--" }
+ return "\(Int((Double(onlineCount) / Double(summaryRows.count) * 100).rounded()))%"
+ }
+
+ private var onlineRateDetail: String {
+ guard hasSettledResults else { return "\(summaryRows.count) 台正在检测" }
+ return "\(onlineCount)/\(summaryRows.count) 台在线"
+ }
+
+ private var onlineRateTint: Color {
+ guard hasSettledResults else { return AITheme.muted }
+ return onlineCount == summaryRows.count ? AITheme.success : AITheme.warning
+ }
+
+ private var abnormalDetail: String {
+ guard hasSettledResults else { return "等待检测结果" }
+ return abnormalCount > 0 ? "需要尽快处理" : "无异常节点"
+ }
+
+ private var abnormalTint: Color {
+ guard hasSettledResults else { return AITheme.muted }
+ return abnormalCount > 0 ? AITheme.danger : AITheme.success
+ }
+
private var signalValues: [Int] {
summaryRows.compactMap(\.signalPercent)
}
private var signalSummary: String {
- formatAverage(signalValues) { "\($0)%" }
+ formatAverage(signalValues) { "\(Int($0.rounded()))%" }
}
private var signalSummaryTint: Color {
@@ -698,18 +879,11 @@ private extension FleetView {
}
private func instanceCard(_ row: FleetViewModel.Row) -> some View {
- AICard {
+ let metrics = rowMetrics(row)
+ return AICard {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 8) {
- Circle()
- .fill(statusColor(row.status))
- .frame(width: 7, height: 7)
- .overlay {
- Circle()
- .strokeBorder(AITheme.line)
- }
- .accessibilityHidden(true)
Text(row.instance.name)
.font(.headline)
.foregroundStyle(AITheme.ink)
@@ -730,66 +904,59 @@ private extension FleetView {
.truncationMode(.middle)
}
- FleetStatusStrip(
- status: row.status,
- statusDetail: row.testing ? "正在读取运行信息" : statusDetail(row)
- )
-
- if let health = chainHealth(row) {
- LazyVGrid(columns: [GridItem(.flexible(), spacing: 6), GridItem(.flexible())], spacing: 6) {
- ForEach(health) { item in
- FleetLinkState(item: item)
- }
- }
+ // 健康节点不再占一行重复"连接正常",只有需要解释的状态才出现说明与链路。
+ if let statusLine = statusLine(row) {
+ FleetStatusStrip(status: row.status, statusDetail: statusLine)
}
- if model.groupName(for: row.instance.groupId) != nil || !row.instance.tags.isEmpty {
+ if let health = chainHealth(row) {
+ FleetChainRow(items: health)
+ }
+
+ if model.groupName(for: row.instance.groupId) != nil || row.instance.tags.isEmpty == false {
FlowLayout(spacing: 6) {
if let groupName = model.groupName(for: row.instance.groupId) {
InlinePill(title: groupName, systemImage: "folder", tint: AITheme.primary)
}
ForEach(row.instance.tags, id: \.self) { tag in
- InlinePill(title: tag, systemImage: "tag", tint: AITheme.muted)
+ TagToken(
+ title: tag,
+ isActive: model.isTagFilterActive(tag),
+ isInteractive: model.selectionMode == false
+ ) {
+ withAnimation(.easeOut(duration: 0.18)) { model.toggleTagFilter(tag) }
+ }
}
}
+ .frame(maxWidth: .infinity, alignment: .leading)
}
- FleetMetricGrid(metrics: rowMetrics(row))
- }
- }
- .overlay(alignment: .topLeading) {
- if model.selectionMode {
- Button {
- toggle(row.id)
- } label: {
- Image(
- systemName: model.selectedIDs.contains(row.id)
- ? "checkmark.circle.fill" : "circle"
- )
- .font(.system(size: 20, weight: .medium))
- .foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted)
- .frame(width: 44, height: 44)
- .background(.ultraThinMaterial, in: Circle())
- .overlay {
- Circle()
- .strokeBorder(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.line)
- }
- }
- .buttonStyle(.plain)
- .padding(4)
- .accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择")
- .accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
+ FleetMetricGrid(
+ metrics: metrics,
+ isPending: row.testing && metrics.isEmpty
+ )
}
}
}
+ /// 选择态的勾选列:与卡片标题行同一基线,靠布局让位而不是压在内容上。
+ private func selectionMark(_ id: UUID) -> some View {
+ let checked = model.selectedIDs.contains(id)
+ return Image(systemName: checked ? "checkmark.circle.fill" : "circle")
+ .font(.system(size: 22, weight: .regular))
+ .foregroundStyle(checked ? AITheme.primary : AITheme.muted.opacity(0.72))
+ .frame(width: 26)
+ .padding(.top, 17)
+ .accessibilityHidden(true)
+ }
+
private var summaryColumns: [GridItem] {
[GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)]
}
private func chainHealth(_ row: FleetViewModel.Row) -> [FleetLinkItem]? {
guard let values = row.chainValues else { return nil }
- return [
+ let items = [
FleetLinkItem(
title: "硬件",
isAvailable: values.hardwareOnline,
@@ -807,84 +974,69 @@ private extension FleetView {
isAvailable: values.cellularOnline,
),
]
+ // 四段全绿时这一行没有任何信息量,只在链路真的出了问题(或未知)时才出现。
+ guard items.contains(where: { $0.isAvailable != true }) else { return nil }
+ return items
}
private func rowMetrics(_ row: FleetViewModel.Row) -> [FleetMetric] {
- var metrics: [FleetMetric] = [
- .init(label: "设备", value: row.deviceName, systemImage: "iphone.gen3"),
- .init(label: "运营商", value: row.operatorName, systemImage: "antenna.radiowaves.left.and.right"),
- .init(label: "号码", value: row.phoneNumber, systemImage: "simcard"),
- .init(label: "制式", value: row.networkTechnology, systemImage: "dot.radiowaves.left.and.right"),
+ // 卡片只留最该被扫到的六项:先身份(设备/运营商/号码),再链路(信号/延迟/存储)。
+ let ordered: [FleetMetric] = [
+ .init(label: "设备", value: row.deviceName),
+ .init(label: "运营商", value: row.operatorName),
+ .init(label: "号码", value: row.phoneNumber),
.init(
label: "信号",
value: row.signalPercent.map { "\($0)%" },
- systemImage: "cellularbars",
- tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted
+ tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted
),
- .init(label: "IP", value: row.ipAddress, systemImage: "network"),
.init(
label: "延迟",
value: row.latencyMilliseconds.map { "\($0) ms" },
- systemImage: "speedometer",
- tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted
- )
+ tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted
+ ),
+ .init(
+ label: "存储",
+ value: row.storageUsageRatio.map { "\(Int(($0 * 100).rounded()))% 已用" },
+ tint: row.storageUsageRatio.flatMap(storageColor) ?? AITheme.muted
+ ),
+ .init(
+ label: "CPU",
+ value: Self.cpuValue(percent: row.cpuPercent, cores: row.cpuCount),
+ tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted
+ ),
+ .init(
+ label: "内存",
+ value: row.memoryPercent.map(Self.percentText),
+ tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted
+ ),
+ .init(label: "制式", value: row.networkTechnology),
+ .init(label: "IP", value: row.ipAddress),
+ .init(
+ label: "温度",
+ value: row.temperatureCelsius.map(Self.temperatureText),
+ tint: row.temperatureCelsius.flatMap(temperatureColor) ?? AITheme.muted
+ ),
+ .init(
+ label: "电量",
+ value: row.batteryPercent.map(Self.percentText),
+ tint: row.batteryPercent.flatMap(batteryColor) ?? AITheme.muted
+ ),
+ .init(label: "固件", value: row.firmwareVersion),
+ .init(label: "运行时间", value: row.uptimeSeconds.map(Self.durationText)),
+ .init(
+ label: "下行",
+ value: row.downloadRateBytesPerSecond.map(Self.rateText)
+ ),
+ .init(
+ label: "上行",
+ value: row.uploadRateBytesPerSecond.map(Self.rateText)
+ ),
+ .init(label: "今日消息", value: row.messagesToday.map { "\($0) 条" })
]
- metrics.append(
- contentsOf: [
- .init(
- label: "CPU",
- value: Self.cpuValue(percent: row.cpuPercent, cores: row.cpuCount),
- systemImage: "cpu",
- tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted
- ),
- .init(
- label: "内存",
- value: row.memoryPercent.map { Self.percentText($0) },
- systemImage: "memorychip",
- tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted
- ),
- .init(
- label: "温度",
- value: row.temperatureCelsius.map { Self.temperatureText($0) },
- systemImage: "thermometer.medium",
- tint: row.temperatureCelsius.flatMap(temperatureColor) ?? AITheme.muted
- ),
- .init(
- label: "电量",
- value: row.batteryPercent.map { Self.percentText($0) },
- systemImage: "battery.100",
- tint: row.batteryPercent.flatMap(batteryColor) ?? AITheme.muted
- )
- ]
- )
-
- metrics.append(
- contentsOf: [
- .init(label: "固件", value: row.firmwareVersion, systemImage: "waveform.path.ecg"),
- .init(label: "运行时间", value: row.uptimeSeconds.map(Self.durationText), systemImage: "clock"),
- .init(
- label: "存储",
- value: row.storageUsageRatio.map { "\(Int(($0 * 100).rounded()))% 已用" },
- systemImage: "internaldrive",
- tint: row.storageUsageRatio.flatMap(storageColor) ?? AITheme.muted
- ),
- .init(
- label: "下行",
- value: row.downloadRateBytesPerSecond.map(Self.rateText),
- systemImage: "arrow.down"
- ),
- .init(
- label: "上行",
- value: row.uploadRateBytesPerSecond.map(Self.rateText),
- systemImage: "arrow.up"
- ),
- .init(label: "今日消息", value: row.messagesToday.map { "\($0) 条" }, systemImage: "message")
- ]
- )
-
- return metrics.filter(\.hasValue)
+ return ordered.filter(\.hasValue)
}
private static func cpuValue(percent: Double?, cores: Int?) -> String? {
@@ -931,19 +1083,21 @@ private extension FleetView {
value <= 20 ? AITheme.danger : (value <= 40 ? AITheme.warning : AITheme.success)
}
- private func statusDetail(_ row: FleetViewModel.Row) -> String {
- let parts = [
- row.operatorName,
- row.deviceName,
- row.connectivity,
- row.latencyMilliseconds.map { "延迟 \($0) ms" }
- ]
- .compactMap { $0 }
- .filter { !$0.isEmpty }
- guard parts.isEmpty == false else {
- return row.status == .online ? "连接正常" : "暂无运行信息"
+ /// 只解释"为什么不是正常状态"。在线节点的细节交给指标栅格,上游原词(online 之类)不外泄。
+ private func statusLine(_ row: FleetViewModel.Row) -> String? {
+ if row.testing { return "正在读取运行信息" }
+ switch row.status {
+ case .online:
+ return nil
+ case .degraded:
+ return "部分接口不可用,可在详情查看失败接口"
+ case .authRequired:
+ return "节点开启了访问密码,进入详情登录"
+ case .offline:
+ return "节点不可达,最后结果已过期"
+ case .unknown:
+ return "尚未检测"
}
- return parts.joined(separator: " · ")
}
private func latencyColor(_ milliseconds: Int?) -> Color? {
@@ -959,7 +1113,7 @@ private extension FleetView {
switch model.groupMode {
case .status:
return group.rows.first.map { statusColor($0.status) } ?? AITheme.muted
- case .tags, .groups:
+ case .groups:
return AITheme.primary
case .none:
return AITheme.muted
@@ -1023,8 +1177,19 @@ private extension FleetView {
private func instanceEntry(_ row: FleetViewModel.Row) -> some View {
Group {
if model.selectionMode {
- instanceCard(row)
+ Button {
+ toggle(row.id)
+ } label: {
+ HStack(alignment: .top, spacing: 10) {
+ selectionMark(row.id)
+ instanceCard(row)
+ }
+ .fixedSize(horizontal: false, vertical: true)
.contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择 \(row.instance.name)" : "选择 \(row.instance.name)")
+ .accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
} else {
NavigationLink {
InstanceDetailView(
@@ -1035,7 +1200,17 @@ private extension FleetView {
instanceCard(row)
}
.buttonStyle(.plain)
- .contextMenu {
+ .contextMenu {
+ ForEach(row.instance.tags, id: \.self) { tag in
+ Button {
+ withAnimation(.easeOut(duration: 0.18)) {
+ model.clearTagFilters()
+ model.toggleTagFilter(tag)
+ }
+ } label: {
+ Label("仅看「\(tag)」", systemImage: "line.3.horizontal.decrease.circle")
+ }
+ }
Button {
editingInstance = row.instance
model.showingForm = true
@@ -1083,42 +1258,77 @@ private extension FleetView {
}
extension FleetView {
+ /// 节点卡片的指标区:两列发丝线栅格,最多八项,其余留给详情页。
private struct FleetMetricGrid: View {
let metrics: [FleetMetric]
+ var isPending = false
+
+ private static let maxVisible = 6
+
+ private var visible: [FleetMetric] { Array(metrics.prefix(Self.maxVisible)) }
+
+ private var overflow: Int { max(0, metrics.count - Self.maxVisible) }
+
+ private var rows: [[FleetMetric]] {
+ stride(from: 0, to: visible.count, by: 2).map { start in
+ Array(visible[start.. 0 {
+ Rectangle()
+ .fill(AITheme.line)
+ .frame(height: 1)
+ }
+ HStack(alignment: .top, spacing: 0) {
+ ForEach(Array(row.enumerated()), id: \.offset) { columnIndex, metric in
metric
+ .padding(.leading, columnIndex == 0 ? 0 : 12)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
- if rowMetrics.count == 1 {
+ // 只有成对出现的行才需要保留右半列对齐;单独一项时占位会让卡片右下出现空洞。
+ if row.count < 2 && rows.count > 1 {
Color.clear
- .gridCellColumns(1)
+ .frame(maxWidth: .infinity)
}
}
+ .padding(.vertical, 8)
+ }
+
+ if overflow > 0 {
+ Text("另有 \(overflow) 项指标")
+ .font(.caption2)
+ .foregroundStyle(AITheme.faint)
+ .padding(.top, 7)
}
}
- .padding(10)
- .background(AITheme.control)
- .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .overlay(alignment: .top) {
+ if metrics.isEmpty == false {
+ Rectangle()
+ .fill(AITheme.line)
+ .frame(height: 1)
+ }
}
}
}
@@ -1128,123 +1338,19 @@ extension FleetView {
let value: String
let systemImage: String
let tint: Color
- }
- struct FleetStatCard: View {
- let title: String
- let value: String
- let systemImage: String
- let tint: Color
+ // 只有真正需要盯的数值才着色,正常值一律用墨色,避免"满屏绿灯"。
+ var isAlert: Bool {
+ tint == AITheme.danger || tint == AITheme.warning
+ }
- var body: some View {
- VStack(alignment: .leading, spacing: 7) {
- HStack(spacing: 6) {
- Image(systemName: systemImage)
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(tint)
- .accessibilityHidden(true)
- Text(title)
- .font(.caption2.weight(.semibold))
- .foregroundStyle(AITheme.muted)
- .lineLimit(1)
- }
- Text(value)
- .font(.caption2.weight(.semibold))
- .foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
- .lineLimit(1)
- .truncationMode(.tail)
- .minimumScaleFactor(0.72)
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .padding(9)
- .frame(maxWidth: .infinity, minHeight: 50, alignment: .topLeading)
- .background(AITheme.control)
- .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 4, style: .continuous))
- .accessibilityElement(children: .combine)
- }
- }
-
- struct FleetPrimaryStat: View {
- let title: String
- let value: String
- let detail: String
- let systemImage: String
- let tint: Color
-
- var body: some View {
- VStack(alignment: .leading, spacing: 7) {
- HStack(spacing: 6) {
- Image(systemName: systemImage)
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(tint)
- .accessibilityHidden(true)
- Text(title)
- .font(.caption2.weight(.semibold))
- .foregroundStyle(AITheme.muted)
- .lineLimit(1)
- }
- Text(value)
- .font(.title3.weight(.semibold))
- .monospacedDigit()
- .foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
- .lineLimit(1)
- .minimumScaleFactor(0.7)
- .frame(maxWidth: .infinity, alignment: .leading)
- Text(detail)
- .font(.caption2)
- .foregroundStyle(AITheme.muted)
- .lineLimit(1)
- .minimumScaleFactor(0.8)
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .padding(12)
- .frame(maxWidth: .infinity, minHeight: 76, alignment: .topLeading)
- .background(tint.opacity(0.08))
- .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 4, style: .continuous))
- .overlay {
- RoundedRectangle(cornerRadius: AITheme.cardRadius - 4, style: .continuous)
- .strokeBorder(tint.opacity(0.14))
- }
- .accessibilityElement(children: .combine)
- }
- }
-
- struct FleetHeaderMiniStat: View {
- let title: String
- let value: String
- let tint: Color
- let systemImage: String
-
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- HStack(spacing: 5) {
- Image(systemName: systemImage)
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(tint)
- .accessibilityHidden(true)
- Text(title)
- .font(.caption2.weight(.semibold))
- .foregroundStyle(AITheme.muted)
- .lineLimit(1)
- .minimumScaleFactor(0.75)
- }
- Text(value)
- .font(.caption2.weight(.semibold))
- .monospacedDigit()
- .foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
- .lineLimit(1)
- .minimumScaleFactor(0.7)
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .padding(.horizontal, 9)
- .padding(.vertical, 8)
- .frame(maxWidth: .infinity, minHeight: 44, alignment: .topLeading)
- .background(AITheme.control)
- .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
- .accessibilityElement(children: .combine)
+ // 没有数据时用弱色占位,读起来不会像"0%"那样的假结论。
+ var isPlaceholder: Bool {
+ value == "--" || value == "不可用" || value == "暂无"
}
}
+ /// 状态摘要行:只用一个状态点 + 一行说明,不再套底色。
struct FleetStatusStrip: View {
let status: ConnectionStatus
let statusDetail: String
@@ -1259,20 +1365,18 @@ extension FleetView {
}
var body: some View {
- HStack(spacing: 8) {
+ HStack(spacing: 7) {
Circle()
.fill(statusTint)
.frame(width: 6, height: 6)
+ .accessibilityHidden(true)
Text(statusDetail)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
+ Spacer(minLength: 0)
}
- .padding(.horizontal, 10)
- .frame(maxWidth: .infinity, minHeight: 30, alignment: .leading)
- .background(AITheme.control)
- .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.accessibilityElement(children: .combine)
}
}
@@ -1312,7 +1416,75 @@ extension FleetView {
var id: String { title }
}
- struct FleetLinkState: View {
+ /// 实例卡片上的标签胶囊,点击即可把它当作筛选条件。
+ struct TagToken: View {
+ let title: String
+ let isActive: Bool
+ var isInteractive: Bool = true
+ let action: () -> Void
+
+ var body: some View {
+ // 卡片整体是一个 NavigationLink,嵌套 Button 的命中不稳定,
+ // 这里用高优先级手势接管点击,保证标签筛选一定生效。
+ HStack(spacing: 4) {
+ Image(systemName: isActive ? "checkmark.circle.fill" : "tag")
+ .font(.system(size: 10, weight: .semibold))
+ .accessibilityHidden(true)
+ Text(title)
+ .font(.caption2.weight(.semibold))
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ .padding(.horizontal, 8)
+ .frame(height: 28)
+ .foregroundStyle(isActive ? AITheme.onPrimary : AITheme.muted)
+ .background(isActive ? AITheme.primary : AITheme.tile)
+ .clipShape(Capsule(style: .continuous))
+ .overlay {
+ Capsule(style: .continuous)
+ .strokeBorder(isActive ? AITheme.primary : AITheme.tileLine)
+ }
+ .contentShape(Capsule(style: .continuous))
+ .highPriorityGesture(
+ isInteractive ? TapGesture().onEnded(action) : nil
+ )
+ .accessibilityAction {
+ if isInteractive {
+ action()
+ }
+ }
+ .accessibilityLabel(isActive ? "取消筛选标签 \(title)" : "按标签 \(title) 筛选")
+ .accessibilityAddTraits(isActive ? [.isSelected, .isButton] : .isButton)
+ }
+ }
+
+ /// 链路健康:硬件 / 控制 / SIM / 蜂窝 四段一行读完,用发丝线分隔而不是四个色块。
+ struct FleetChainRow: View {
+ let items: [FleetLinkItem]
+
+ var body: some View {
+ HStack(spacing: 0) {
+ ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
+ if index > 0 {
+ Rectangle()
+ .fill(AITheme.line)
+ .frame(width: 1, height: 16)
+ }
+ FleetChainCell(item: item)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .padding(.vertical, 8)
+ .overlay(alignment: .top) {
+ Rectangle()
+ .fill(AITheme.line)
+ .frame(height: 1)
+ }
+ .accessibilityElement(children: .contain)
+ }
+ }
+
+ private struct FleetChainCell: View {
let item: FleetLinkItem
private var tint: Color {
@@ -1332,34 +1504,25 @@ extension FleetView {
}
var body: some View {
- VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 4) {
+ Circle()
+ .fill(tint)
+ .frame(width: 5, height: 5)
+ .accessibilityHidden(true)
Text(item.title)
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(AITheme.muted)
+ .font(.caption2)
+ .foregroundStyle(AITheme.faint)
.lineLimit(1)
- HStack(spacing: 5) {
- Circle()
- .fill(tint)
- .frame(width: 5, height: 5)
- .accessibilityHidden(true)
- Text(stateText)
- .font(.caption2.weight(.semibold))
- .foregroundStyle(tint)
- .lineLimit(1)
- .minimumScaleFactor(0.75)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .padding(.horizontal, 7)
- .padding(.vertical, 6)
- .frame(maxWidth: .infinity, minHeight: 36, alignment: .topLeading)
- .background(tint.opacity(0.08))
- .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
- .overlay {
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .strokeBorder(tint.opacity(0.16))
+ Text(stateText)
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(tint == AITheme.muted ? AITheme.faint : tint)
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ Spacer(minLength: 0)
}
+ .padding(.horizontal, 8)
.accessibilityElement(children: .combine)
+ .accessibilityLabel("\(item.title) \(stateText)")
}
}
@@ -1446,21 +1609,71 @@ extension FleetView {
}
private var progressTint: Color {
- if testing > 0 { return AITheme.warning }
+ guard hasSettledResults else { return AITheme.muted }
return onlineRatio >= 1 ? AITheme.success : (onlineRatio > 0 ? AITheme.warning : AITheme.muted)
}
private var progress: Double {
- onlineRatio
+ guard hasSettledResults else { return 0 }
+ return onlineRatio
+ }
+
+ // 整组都还在检测时,在线率/异常率没有任何可信依据,先不给结论。
+ private var hasSettledResults: Bool { total > 0 && testing < total }
+
+ private var displayedOnlineValue: String { hasSettledResults ? onlineValue : "--" }
+
+ private var displayedAbnormalValue: String { hasSettledResults ? abnormalValue : "--" }
+
+ private var displayedOnlineTint: Color { hasSettledResults ? onlineTint : AITheme.muted }
+
+ private var displayedAbnormalTint: Color { hasSettledResults ? abnormalTint : AITheme.muted }
+
+ // 分组头部与运行概览共用同一套读数样式,扫读时不需要重新对齐视线。
+ private var headerReadouts: [FleetStatItem] {
+ [
+ FleetStatItem(title: "节点", value: "\(total)", systemImage: "server.rack", tint: AITheme.ink),
+ FleetStatItem(
+ title: "在线率",
+ value: displayedOnlineValue,
+ systemImage: "checkmark.circle",
+ tint: displayedOnlineTint
+ ),
+ FleetStatItem(
+ title: "异常率",
+ value: displayedAbnormalValue,
+ systemImage: "exclamationmark.triangle",
+ tint: displayedAbnormalTint
+ ),
+ FleetStatItem(
+ title: "检测中",
+ value: testing > 0 ? "\(testing)" : "--",
+ systemImage: "clock.arrow.circlepath",
+ tint: testing > 0 ? AITheme.warning : AITheme.muted
+ ),
+ FleetStatItem(title: "信号", value: signalValue, systemImage: "cellularbars", tint: signalTint),
+ FleetStatItem(title: "延迟", value: latencyValue, systemImage: "speedometer", tint: latencyTint),
+ FleetStatItem(title: "存储", value: storageValue, systemImage: "internaldrive", tint: storageTint),
+ FleetStatItem(title: "流量", value: trafficValue, systemImage: "arrow.up.arrow.down", tint: trafficTint)
+ ]
+ .map { item in
+ item.value == "不可用"
+ ? FleetStatItem(title: item.title, value: "--", systemImage: item.systemImage, tint: AITheme.muted)
+ : item
+ }
+ // 全组检测完成后,这一列只剩一个空占位,属于纯噪音。
+ .filter { item in
+ item.title != "检测中" || testing > 0
+ }
}
var body: some View {
Button(action: action) {
- VStack(alignment: .leading, spacing: 9) {
+ VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(tint)
- .frame(width: 4, height: 24)
+ .frame(width: 3, height: 20)
Text(title)
.font(.headline.weight(.semibold))
.foregroundStyle(AITheme.ink)
@@ -1474,62 +1687,25 @@ extension FleetView {
.accessibilityHidden(true)
}
- Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 7) {
- GridRow {
- FleetHeaderMetric(
- title: "节点",
- value: "\(total)",
- systemImage: "server.rack",
- tint: AITheme.ink
- )
- FleetHeaderMetric(
- title: "在线率",
- value: onlineValue,
- systemImage: "checkmark.circle",
- tint: onlineTint,
- placeholder: onlineValue == "不可用"
- )
- }
- GridRow {
- FleetHeaderMetric(
- title: "异常率",
- value: abnormalValue,
- systemImage: "exclamationmark.triangle",
- tint: abnormalTint,
- placeholder: abnormalValue == "不可用"
- )
- FleetHeaderMetric(
- title: "检测中",
- value: "\(testing)",
- systemImage: "clock.arrow.circlepath",
- tint: testing > 0 ? AITheme.warning : AITheme.muted,
- placeholder: testing == 0
- )
- }
- }
-
- HStack(spacing: 8) {
+ // 结论未出时不画空进度条,否则会被读成"0% 在线"。
+ if hasSettledResults {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(progressTint)
- .frame(height: 4)
- Text("\(onlineRatio.formatted(.number.precision(.fractionLength(0))))%")
- .font(.caption2.weight(.semibold))
- .monospacedDigit()
- .foregroundStyle(progressTint)
- .frame(width: 38, alignment: .trailing)
+ .frame(height: 3)
+ } else {
+ HStack(spacing: 6) {
+ Image(systemName: "arrow.triangle.2.circlepath")
+ .font(.system(size: 10, weight: .semibold))
+ Text("正在检测 \(testing)/\(total) 台")
+ .font(.caption2.weight(.semibold))
+ .monospacedDigit()
+ }
+ .foregroundStyle(AITheme.muted)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
- Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 7) {
- GridRow {
- FleetHeaderMetric(title: "信号", value: signalValue, systemImage: "cellularbars", tint: signalTint, placeholder: signalValue == "不可用")
- FleetHeaderMetric(title: "延迟", value: latencyValue, systemImage: "speedometer", tint: latencyTint, placeholder: latencyValue == "不可用")
- }
- GridRow {
- FleetHeaderMetric(title: "存储", value: storageValue, systemImage: "internaldrive", tint: storageTint, placeholder: storageValue == "不可用")
- FleetHeaderMetric(title: "流量", value: trafficValue, systemImage: "arrow.up.arrow.down", tint: trafficTint, placeholder: trafficValue == "不可用")
- }
- }
+ FleetReadoutGrid(items: headerReadouts)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
@@ -1547,70 +1723,35 @@ extension FleetView {
.accessibilityHint(isCollapsed ? "展开分组" : "折叠分组")
.accessibilityAddTraits(.isButton)
}
-
- private struct FleetHeaderMetric: View {
- let title: String
- let value: String
- let systemImage: String
- let tint: Color
- var placeholder = false
-
- var body: some View {
- VStack(alignment: .leading, spacing: 3) {
- HStack(spacing: 4) {
- Image(systemName: systemImage)
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(placeholder ? AITheme.muted : tint)
- .accessibilityHidden(true)
- Text(title)
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(AITheme.muted)
- .lineLimit(1)
- }
- Text(value)
- .font(.caption2.weight(.semibold))
- .monospacedDigit()
- .foregroundStyle(placeholder ? AITheme.muted : AITheme.ink)
- .lineLimit(1)
- .minimumScaleFactor(0.7)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
}
struct FleetMetric: View {
let label: String
let value: String?
- let systemImage: String
- var tint: Color = AITheme.primary
+ var tint: Color = AITheme.muted
var hasValue: Bool { value?.isEmpty == false }
+ var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning }
+
var body: some View {
if hasValue {
- HStack(spacing: 7) {
- Image(systemName: systemImage)
- .font(.system(size: 12, weight: .semibold))
- .foregroundStyle(tint)
- .frame(width: 16)
- .accessibilityHidden(true)
- VStack(alignment: .leading, spacing: 1) {
- Text(label)
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(AITheme.muted)
- .lineLimit(1)
- Text(value ?? "暂无")
- .font(.caption.weight(.medium))
- .foregroundStyle(AITheme.ink)
- .lineLimit(1)
- .truncationMode(.middle)
- }
+ VStack(alignment: .leading, spacing: 2) {
+ Text(label)
+ .font(.caption2)
+ .foregroundStyle(AITheme.faint)
+ .lineLimit(1)
+ Text(value ?? "--")
+ .font(.footnote.weight(.semibold))
+ .monospacedDigit()
+ .foregroundStyle(isAlert ? tint : AITheme.ink)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .minimumScaleFactor(0.72)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
}
}
-
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift
index ffe4ca3..05f17f8 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift
@@ -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 = [] {
+ didSet {
+ let values = Array(selectedTagFilters).sorted()
+ preferences.set(values, forKey: "fleet.selectedTagFilters")
}
}
@Published var selectedIDs: Set = []
@@ -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] = []
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift
index 231cff7..281194f 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift
@@ -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,
@@ -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,
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupEditorView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupEditorView.swift
index d63c754..392e5df 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupEditorView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupEditorView.swift
@@ -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
}
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupManagerView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupManagerView.swift
index cd27d24..c6e3dea 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupManagerView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupManagerView.swift
@@ -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 {
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/TagEditorView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/TagEditorView.swift
new file mode 100644
index 0000000..e21855a
--- /dev/null
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/TagEditorView.swift
@@ -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(
+ 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()
+ }
+}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift
index be820cb..9208fd6 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift
@@ -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.none)
- ForEach(model.instances) { instance in
- Text(instance.name).tag(Optional(instance.id))
+ Picker("实例", selection: $model.selectedInstanceID) {
+ Text("全部实例").tag(Optional.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.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.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.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.none)
- ForEach(RuntimeLogSource.allCases, id: \.self) { source in
- Text(source.label).tag(Optional(source))
- }
- }
- .pickerStyle(.menu)
- .aiSelectSurface()
-
- Picker("级别", selection: $model.selectedLevel) {
- Text("全部级别").tag(Optional.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.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
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageModels.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageModels.swift
index 1a17ac2..0eb93b4 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageModels.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageModels.swift
@@ -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 {
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift
index 6fbda68..3fd2586 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift
@@ -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 ?? "",
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift
index b23482f..f2ab3d9 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift
@@ -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.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.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
- )
- )
}
}
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift
index 5b26f10..3173d9c 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift
@@ -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 = "删除消息失败。"
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift
index 01c6576..562edb2 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift
@@ -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)
}
}
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift
index 6952be1..cde9e62 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift
@@ -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
}
}
diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift
index 3f75e66..ceedffa 100644
--- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift
+++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift
@@ -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
diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png
index 0051e70..4b469ac 100644
Binary files a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png and b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png differ
diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist b/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist
index 4ac8534..2a9df63 100644
--- a/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist
+++ b/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist
@@ -9,8 +9,8 @@
CFBundleInfoDictionaryVersion6.0
CFBundleNameMultiSimAdmin
CFBundlePackageTypeAPPL
- CFBundleShortVersionString0.2.0
- CFBundleVersion3
+ CFBundleShortVersionString0.2.1
+ CFBundleVersion6
LSRequiresIPhoneOS
MinimumOSVersion16.0
NSAppTransportSecurity
diff --git a/apps/ios/Sources/MultiSimAdmin/Support/DebugLaunch.swift b/apps/ios/Sources/MultiSimAdmin/Support/DebugLaunch.swift
new file mode 100644
index 0000000..b97ae7f
--- /dev/null
+++ b/apps/ios/Sources/MultiSimAdmin/Support/DebugLaunch.swift
@@ -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
diff --git a/apps/ios/Sources/MultiSimAdmin/Support/TimestampText.swift b/apps/ios/Sources/MultiSimAdmin/Support/TimestampText.swift
new file mode 100644
index 0000000..5a0d6e1
--- /dev/null
+++ b/apps/ios/Sources/MultiSimAdmin/Support/TimestampText.swift
@@ -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)
+ }
+}
diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift
index b28d782..bb89325 100644
--- a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift
@@ -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
diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift
index 53440b2..485a8e8 100644
--- a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift
@@ -25,7 +25,7 @@ public struct AICard: 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: 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: 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: 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: 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,
+ 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)
+ }
+}
diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift
index 51c9055..931611c 100644
--- a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift
@@ -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
diff --git a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift
index 51d4a82..9c49aaa 100644
--- a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift
+++ b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift
@@ -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)
diff --git a/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift b/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift
index e057a48..3e43cc8 100644
--- a/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift
+++ b/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift
@@ -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")
}
}
diff --git a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift
index 0f78a6d..1088499 100644
--- a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift
+++ b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift
@@ -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(
diff --git a/apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift b/apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift
index 4a87499..eaf1d9a 100644
--- a/apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift
+++ b/apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift
@@ -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")
diff --git a/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift
index 40ad120..6c3ad01 100644
--- a/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift
+++ b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift
@@ -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)
}
diff --git a/apps/ios/Tests/MultiSimAdminTests/TimestampTextTests.swift b/apps/ios/Tests/MultiSimAdminTests/TimestampTextTests.swift
new file mode 100644
index 0000000..52e491e
--- /dev/null
+++ b/apps/ios/Tests/MultiSimAdminTests/TimestampTextTests.swift
@@ -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)!
+ }
+}
diff --git a/docs/ios-native-0.2.1.md b/docs/ios-native-0.2.1.md
new file mode 100644
index 0000000..3a869d1
--- /dev/null
+++ b/docs/ios-native-0.2.1.md
@@ -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 或自有签名流程可以后续处理。