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 或自有签名流程可以后续处理。