diff --git a/README.md b/README.md index 74c5764..7895fc2 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ ## iOS Native 应用 -`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用,当前版本 **0.2.1 (build 6)**,运行在 iOS 16 及以上,产物为未签名 IPA,供 TrollStore 或自有签名流程安装。iOS 开发在 `ios-native-ipa` 分支进行。 +`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用,当前版本 **0.2.2**(build 7),运行在 iOS 16 及以上,产物为未签名 IPA,供 TrollStore 或自有签名流程安装。iOS 开发在 `ios-native-ipa` 分支进行。 底部为节点、消息、设置三个入口: -- **节点**:运行概览、分组视图、标签多选筛选、搜索、批量选择、实例增删改,卡片直连实例详情。 +- **节点**:运行概览、标签多选筛选、标签批量归类、搜索、批量选择、实例增删改,卡片直连实例详情。 - **实例详情**:设备、SIM、蜂窝网络、系统资源、CPU、连接状态六个模块,敏感值默认脱敏,显示后离开页面即隐藏。 - **消息**:跨实例短信读取、按实例与方向筛选、搜索、发送、删除。 - **设置**:主题外观(跟随系统 / 浅色 / 深色)、应用信息、本地数据与日志中心、隐私与数据、数据清理。 @@ -24,7 +24,7 @@ cd apps/ios && swift test # 48 个用例 ``` -文档:现行说明 [docs/ios-native.md](docs/ios-native.md),发布记录 [0.2.1](docs/ios-native-0.2.1.md) 与 [0.2.0](docs/ios-native-0.2.0.md)。 +文档:现行说明 [docs/ios-native.md](docs/ios-native.md),发布记录 [0.2.2](docs/ios-native-0.2.2.md)、[0.2.1](docs/ios-native-0.2.1.md) 与 [0.2.0](docs/ios-native-0.2.0.md)。 ## 功能 diff --git a/apps/ios/Design/AppIcon.svg b/apps/ios/Design/AppIcon.svg index 9c192f5..ed183fc 100644 --- a/apps/ios/Design/AppIcon.svg +++ b/apps/ios/Design/AppIcon.svg @@ -1,44 +1,31 @@ - - - - + + + + - - - - - - - - - - - - - - + + + + - - - + + - - - + - - - - + + + + diff --git a/apps/ios/Design/generate_app_icon.swift b/apps/ios/Design/generate_app_icon.swift index d9726d6..f72274f 100644 --- a/apps/ios/Design/generate_app_icon.swift +++ b/apps/ios/Design/generate_app_icon.swift @@ -1,11 +1,10 @@ // 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. +// The mark is the web favicon grown up: a flat brass SIM plate with three +// ascending slots cut out of it, on a graphite field. Two colours, one light +// source from the upper left, no metal texture, so it still reads at the 29pt +// settings size. The plate keeps the classic single SIM cut, top-left. // // Every coordinate is in visual space (origin top-left, y grows down). import AppKit @@ -87,15 +86,15 @@ 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 +/// SIM plate outline: rounded square with the top-left corner cut at 45 degrees. +func simPath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath { + let bevel = corner * 0.26 let vertices: [(point: CGPoint, radius: CGFloat)] = [ + (CGPoint(x: rect.minX, y: rect.minY + chamfer), bevel), (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) @@ -110,7 +109,7 @@ func platePath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath { return path } -/// Bright along the upper-left edges, dead by the lower-right: an even outline +/// Bright along the upper-left edges, gone 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() @@ -130,38 +129,34 @@ func directionalRim(_ path: CGPath, _ rect: CGRect, _ width: CGFloat, _ stops: [ 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)), + (0.00, (26, 31, 30, 255)), + (0.55, (17, 21, 20, 255)), + (1.00, (11, 14, 13, 255)), ] as [Stop], - CGPoint(x: side * 0.10, y: 0), - CGPoint(x: side * 0.92, y: side) + CGPoint(x: side * 0.06, y: 0), + CGPoint(x: side * 0.94, 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] + CGPoint(x: side * 0.22, y: -side * 0.10), CGFloat(side * 0.86), + [(0.0, (214, 226, 218, 26)), (0.60, (214, 226, 218, 7)), (1.0, (214, 226, 218, 0))] 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 plateSide = envDouble("ICON_PLATE_SIDE", 592) +let plateCenterY = envDouble("ICON_PLATE_Y", 500) +let plateCorner = envDouble("ICON_PLATE_CORNER", 122) +let plateChamfer = envDouble("ICON_PLATE_CHAMFER", 168) let plateRect = CGRect( x: (side - plateSide) / 2, y: plateCenterY - plateSide / 2, width: plateSide, height: plateSide ) -let plate = platePath(plateRect, corner: plateCorner, chamfer: plateChamfer) +let plate = simPath(plateRect, corner: plateCorner, chamfer: plateChamfer) ctx.saveGState() -ctx.setShadow(offset: CGSize(width: 0, height: -30), blur: 62, color: rgb(2, 8, 20, 168)) +ctx.setShadow(offset: CGSize(width: 0, height: -22), blur: 46, color: rgb(0, 3, 2, 150)) ctx.addPath(plate) ctx.setFillColor(rgb(0, 0, 0)) ctx.fillPath() @@ -170,98 +165,61 @@ 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)), + (0.00, (248, 213, 124, 255)), + (0.50, (236, 189, 86, 255)), + (1.00, (212, 158, 54, 255)), ] as [Stop], CGPoint(x: plateRect.minX, y: plateRect.minY), CGPoint(x: plateRect.maxX, y: plateRect.maxY) ) - directionalRim( - plate, plateRect, plateSide * 0.016, + plate, plateRect, plateSide * 0.007, [ - (0.00, (255, 251, 232, 230)), - (0.40, (255, 240, 190, 66)), - (0.74, (255, 232, 170, 0)), + (0.00, (255, 244, 214, 132)), + (0.36, (255, 238, 198, 30)), + (0.68, (255, 234, 186, 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: - Slots -// MARK: - Signal bars +let slotWidth = envDouble("ICON_SLOT_W", 92) +let slotGap = envDouble("ICON_SLOT_GAP", 54) +let slotBottom = envDouble("ICON_SLOT_BOTTOM", 640) +let slotHeights: [CGFloat] = [136, 232, 328] +let slotsWidth = CGFloat(slotHeights.count) * slotWidth + CGFloat(slotHeights.count - 1) * slotGap +let slotsStartX = (side - slotsWidth) / 2 -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() { +for (index, height) in slotHeights.enumerated() { let rect = CGRect( - x: barsStartX + CGFloat(index) * (barWidth + barGap), - y: barBottom - height, - width: barWidth, + x: slotsStartX + CGFloat(index) * (slotWidth + slotGap), + y: slotBottom - height, + width: slotWidth, height: height ) - let radius = barWidth * 0.30 + let radius = slotWidth * 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)), + (0.00, (15, 19, 18, 255)), + (1.00, (29, 35, 33, 255)), ] as [Stop], CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.minX, y: rect.maxY) ) directionalRim( - path, rect, radius * 0.34, + path, rect, radius * 0.30, [ - (0.00, (0, 0, 0, 120)), - (0.48, (0, 0, 0, 26)), - (0.84, (255, 226, 152, 70)), - (1.00, (255, 236, 176, 118)), + (0.00, (120, 84, 18, 96)), + (0.52, (120, 84, 18, 18)), + (1.00, (255, 232, 176, 58)), ] 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) diff --git a/apps/ios/README.md b/apps/ios/README.md index 30b39ec..d4d6f12 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -9,7 +9,7 @@ iPhone 原生应用的 Swift Package。产品与技术说明见 [docs/ios-native Sources/MultiSimAdmin/ AppMetadata.swift 版本号与 Bundle ID 的唯一来源 MultiSimAdminApp.swift 入口、三个底部 Tab、调试直达参数 - Models/ 实例、分组、查询等值类型 + Models/ 实例、标签、查询等值类型 Features/{Fleet,Device,Messages,Logs,Settings}/ 页面与 view model Upstream/ 路由白名单、HTTP 客户端、按实例隔离的会话 Cookie Services/ 连接探测、凭据存储、依赖装配 @@ -49,5 +49,8 @@ README 的版本引用。 ## 界面约定 -主操作只用一种蓝色,危险操作只用红色;同屏并排控件等高;选中态不得改变控件尺寸;标签统一 -28pt 胶囊;卡片内部不再嵌套卡片;不得出现因配色而在深色背景上消失的按钮或文字。 +界面不含任何蓝色:主操作、选中态、进度填充一律用石墨墨色(`AITheme.primary` / `AITheme.ink`), +颜色只留给状态语义——危险红、告警琥珀、成功绿,且只在真的表示状态时出现,健康读数保持墨色。 +选中态用实心主色加 `onPrimary` 文字,不用低透明度色雾,浅色深色两套主题下都必须一眼可读; +同屏并排控件等高;选中态不得改变控件尺寸;标签统一 28pt 胶囊;卡片内部不再嵌套卡片, +并列读数改用发丝线分隔的栅格;不得出现因配色而在深色背景上消失的按钮或文字。 diff --git a/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift b/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift index b391520..1f5dc2e 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.1" - public static let buildVersion = "6" + public static let marketingVersion = "0.2.2" + public static let buildVersion = "7" } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift index a225895..1168be5 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift @@ -7,15 +7,11 @@ struct InstanceDetailView: View { init( instance: Instance, - groupName: String? = nil, model: DeviceViewModel? = nil ) { _model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance)) - self.groupName = groupName } - private let groupName: String? - var body: some View { ScrollView { VStack(spacing: 12) { @@ -98,9 +94,6 @@ struct InstanceDetailView: View { 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 ? "已保存" : "未设置") } @@ -135,11 +128,8 @@ struct InstanceDetailView: View { moduleAvailability - if groupName != nil || !model.instance.tags.isEmpty { + if !model.instance.tags.isEmpty { FlowLayout(spacing: 7) { - if let groupName { - AITag("分组 · \(groupName)", tint: AITheme.primary) - } ForEach(model.instance.tags, id: \.self) { tag in AITag(tag) } @@ -220,6 +210,8 @@ struct InstanceDetailView: View { let progress: Double? var id: String { title } + // 与节点卡片同一规则:只有越过警告线才着色,正常值走墨色。 + var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning } } private var allHeaderMetrics: [HeaderMetric] { @@ -428,16 +420,17 @@ struct InstanceDetailView: View { ) } + /// 阈值只回答"要不要提醒",健康值不着色,四格量表不会染成一片绿。 private func signalTint(_ value: Double) -> Color { - value >= 60 ? AITheme.success : (value >= 30 ? AITheme.warning : AITheme.danger) + value >= 60 ? AITheme.ink : (value >= 30 ? AITheme.warning : AITheme.danger) } private func latencyTint(_ value: Double) -> Color { - value < 300 ? AITheme.success : (value < 800 ? AITheme.warning : AITheme.danger) + value < 300 ? AITheme.ink : (value < 800 ? AITheme.warning : AITheme.danger) } private func resourceTint(_ value: Double) -> Color { - value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.success) + value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.ink) } private struct DetailHeaderMetric: View { @@ -456,29 +449,20 @@ struct InstanceDetailView: View { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Image(systemName: systemImage) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(tint) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(metric.isAlert ? tint : AITheme.faint) .accessibilityHidden(true) Text(title) - .font(.caption.weight(.semibold)) + .font(.caption2.weight(.semibold)) .foregroundStyle(AITheme.muted) .lineLimit(1) .minimumScaleFactor(0.75) } - if isProgressVisible { - ProgressView(value: progress) - .progressViewStyle(.linear) - .tint(tint) - .frame(height: 4) - } else { - Color.clear - .frame(height: 4) - } if let value { Text(value) - .font(.subheadline.weight(.semibold)) + .font(.title3.weight(.semibold)) .monospacedDigit() - .foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint) + .foregroundStyle(metric.isAlert ? tint : AITheme.ink) .lineLimit(1) .truncationMode(.middle) .minimumScaleFactor(0.72) @@ -489,34 +473,65 @@ struct InstanceDetailView: View { .frame(maxWidth: .infinity, minHeight: 14, maxHeight: 14) .frame(maxWidth: .infinity, alignment: .leading) } + // 读数在上、进度在下,缺数据时也留一条轨道,数值到位后格子不跳动。 + if isProgressVisible { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule(style: .continuous).fill(AITheme.line) + Capsule(style: .continuous) + .fill(metric.isAlert ? tint : AITheme.primary) + .frame(width: max(3, proxy.size.width * CGFloat(min(1, max(0, progress))))) + } + } + .frame(height: 4) + .accessibilityHidden(true) + } else { + Color.clear + .frame(height: 4) + } } - .padding(11) - .frame(maxWidth: .infinity, minHeight: 76, maxHeight: 76, alignment: .topLeading) - .aiInsetSurface() + .frame(maxWidth: .infinity, minHeight: 62, alignment: .topLeading) .accessibilityElement(children: .combine) } } - // 统一两列栅格。此前首行放 4 个指标、其余两列,导致首行指标卡只有下方一半宽。 + /// 两列发丝线栅格:量表是头部卡片内的读数,不再各自套一层底色块。 private struct DetailMetricGrid: View { let metrics: [HeaderMetric] + private var rows: [[HeaderMetric]] { + stride(from: 0, to: metrics.count, by: 2).map { + Array(metrics[$0.. 0 { + Rectangle() + .fill(AITheme.line) + .frame(height: 1) + } + HStack(alignment: .top, spacing: 0) { + ForEach(Array(rowMetrics.enumerated()), id: \.element.id) { columnIndex, metric in + DetailHeaderMetric(metric: metric) + .padding(.leading, columnIndex == 0 ? 0 : 14) + .frame(maxWidth: .infinity, alignment: .leading) + } + if rowMetrics.count < 2 { Color.clear + .frame(maxWidth: .infinity) } } + .padding(.vertical, 10) } } + .overlay(alignment: .top) { + Rectangle() + .fill(AITheme.line) + .frame(height: 1) + } } } @@ -541,9 +556,7 @@ struct InstanceDetailView: View { } } } - .padding(11) .frame(maxWidth: .infinity, alignment: .leading) - .aiInsetSurface() .accessibilityLabel("设备信息读取中") } else if items.isEmpty == false { VStack(alignment: .leading, spacing: 6) { @@ -564,9 +577,13 @@ struct InstanceDetailView: View { .accessibilityElement(children: .combine) } } - .padding(11) .frame(maxWidth: .infinity, alignment: .leading) - .aiInsetSurface() + .overlay(alignment: .top) { + Rectangle() + .fill(AITheme.line) + .frame(height: 1) + } + .padding(.top, 1) } } } @@ -797,11 +814,15 @@ struct InstanceDetailView: View { "core_count": "核心数", "model_name": "CPU 型号", "state": "连接状态", - "latency_ms": "延迟(ms)" + "latency_ms": "延迟(ms)", + "name": "设备名称", + "disk_total_bytes": "存储总量", + "disk_available_bytes": "可用存储", + "percent": "使用率" ] private let valueOrder: [String] = [ - "model", "manufacturer", "brand", "imei", "meid", "revision", "firmware_version", + "model", "manufacturer", "brand", "name", "imei", "meid", "revision", "firmware_version", "online", "powered", "present", "slots", "active_slots", "active", "iccid", "imsi", "phone_numbers", "phone_number", "sms_center", "operator_name", "operator", "carrier", "registration_status", "registration_state", "technology_preference", "network_type", @@ -810,7 +831,8 @@ struct InstanceDetailView: View { "cpu_load", "memory_percent", "memory_total_mb", "memory_available_mb", "usage", "temperature", "max_temperature_c", "battery_level", "battery_percent", "download", "upload", "download_bytes", "upload_bytes", "ipv4", "ipv6", "ip_address", "gateway", - "dns", "interface", "ssid", "apn", "core_count", "model_name", "messages_today", "calls" + "dns", "interface", "ssid", "apn", "core_count", "model_name", "messages_today", "calls", + "disk_total_bytes", "disk_available_bytes", "percent" ] private let durationKeys: Set = ["uptime", "uptime_seconds"] diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetTagBatchSheet.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetTagBatchSheet.swift new file mode 100644 index 0000000..fa0a9d3 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetTagBatchSheet.swift @@ -0,0 +1,172 @@ +import SwiftUI + +/// 分组被移除后,批量归类这件事由标签承担:一次点选给多台节点贴同一个标签。 +struct FleetTagBatchSheet: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var model: FleetViewModel + @State private var draft = "" + @State private var isBusy = false + + private var targets: [UUID] { model.selectedIDs.sorted { $0.uuidString < $1.uuidString } } + + private var selectedNames: [String] { + model.rows.filter { model.selectedIDs.contains($0.id) }.map(\.instance.name).sorted { + $0.localizedCompare($1) == .orderedAscending + } + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 12) { + if let errorMessage = model.errorMessage { + AIStatusBanner( + errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") + } + targetsCard + tagGridCard + newTagCard + } + .padding(16) + } + .background(AITheme.canvas.ignoresSafeArea()) + .navigationTitle("批量标签") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .scrollDismissesKeyboard(.interactively) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + AIToolbarButton("完成", kind: .primary) { dismiss() } + } + } + } + } + + private var targetsCard: some View { + AICard { + VStack(alignment: .leading, spacing: 10) { + AISectionTitle("已选 \(targets.count) 台", systemImage: "checklist") + Text(selectedNames.joined(separator: "、")) + .font(.caption) + .foregroundStyle(AITheme.muted) + .lineLimit(3) + .truncationMode(.tail) + } + } + } + + private var tagGridCard: some View { + AICard { + VStack(alignment: .leading, spacing: 12) { + AISectionTitle("现有标签", systemImage: "tag") + if model.allTags.isEmpty { + Text("还没有标签,先在下方新建一个。") + .font(.caption) + .foregroundStyle(AITheme.faint) + } else { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 112), alignment: .leading)], + alignment: .leading, + spacing: 8 + ) { + ForEach(model.allTags, id: \.self) { tag in + CoverageChip( + title: tag, + covered: model.tagCoverage(tag, in: targets), + total: targets.count, + isBusy: isBusy + ) { + Task { await apply(tag, removing: model.tagCoverage(tag, in: targets) == targets.count) } + } + } + } + } + } + } + } + + private var newTagCard: some View { + AICard { + VStack(alignment: .leading, spacing: 10) { + AIFieldLabel("新建标签并贴上") + HStack(spacing: 10) { + TextField("如 深圳-现场", text: $draft) + .textFieldStyle(AIFieldStyle()) + .accessibilityLabel("新标签名称") + .submitLabel(.done) + .onSubmit { submitDraft() } + .disabled(isBusy) + Button(isBusy ? "处理中" : "添加") { submitDraft() } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fixedWidth: 84)) + .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isBusy) + } + } + } + } + + private func submitDraft() { + let tag = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard tag.isEmpty == false, isBusy == false else { return } + draft = "" + Task { await apply(tag, removing: false) } + } + + private func apply(_ tag: String, removing: Bool) async { + guard isBusy == false, targets.isEmpty == false else { return } + isBusy = true + await model.applyTag(tag, to: targets, removing: removing) + isBusy = false + } + + /// 覆盖状态直接写在胶囊上:全部已贴 / 部分已贴 / 未贴,点击即切换整批。 + private struct CoverageChip: View { + let title: String + let covered: Int + let total: Int + let isBusy: Bool + let action: () -> Void + + private var isComplete: Bool { total > 0 && covered == total } + private var isPartial: Bool { covered > 0 && covered < total } + + private var stateText: String { + if isComplete { return "全部" } + if isPartial { return "\(covered)/\(total)" } + return "未贴" + } + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: isComplete ? "checkmark.circle.fill" : (isPartial ? "circle.lefthalf.filled" : "circle")) + .font(.system(size: 12, weight: .semibold)) + .accessibilityHidden(true) + Text(title) + .font(.footnote.weight(isComplete || isPartial ? .semibold : .medium)) + .lineLimit(1) + .truncationMode(.middle) + Text(stateText) + .font(.caption2.weight(.semibold)) + .monospacedDigit() + .opacity(0.72) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .frame(height: 38) + .foregroundStyle(isComplete ? AITheme.onPrimary : (isPartial ? AITheme.ink : AITheme.muted)) + .background(isComplete ? AITheme.primary : (isPartial ? AITheme.tile : AITheme.surface)) + .clipShape(Capsule(style: .continuous)) + .overlay { + Capsule(style: .continuous) + .strokeBorder(isComplete ? AITheme.primary : (isPartial ? AITheme.primary.opacity(0.42) : AITheme.line)) + } + .contentShape(Capsule(style: .continuous)) + } + .buttonStyle(.plain) + .disabled(isBusy || total == 0) + .accessibilityLabel(isComplete ? "从已选节点移除标签 \(title)" : "给已选节点贴上标签 \(title)") + .accessibilityAddTraits(isComplete ? [.isButton, .isSelected] : .isButton) + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift index 6c03a31..6dfb066 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift @@ -4,9 +4,7 @@ struct FleetView: View { @StateObject private var model: FleetViewModel @State private var showsDeleteDialog = false @State private var editingInstance: Instance? - @State private var collapsedGroups: Set = [] @State private var isSummaryCollapsed = true - @State private var showingGroupEditor = false init(model: FleetViewModel? = nil) { // StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上, // 所以调试开关必须先落到这个具体的 model 对象。 @@ -15,23 +13,22 @@ struct FleetView: View { 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("--sheet-tagbatch") { + target.selectionMode = true + target.showingTagBatch = true } - if DebugLaunch.has("--group-status") { - target.groupMode = .status + // 图例胶囊的选中态只能靠点选进入,逐页截图时留一个直达开关。 + if DebugLaunch.has("--filter-degraded") { + target.statusFilter = .degraded } - if DebugLaunch.has("--group-flat") { - target.groupMode = .none + if DebugLaunch.has("--tag-field") { + target.selectedTagFilters = ["field"] } #endif _model = StateObject(wrappedValue: target) @@ -39,7 +36,7 @@ struct FleetView: View { var body: some View { ScrollView { - LazyVStack(spacing: 12, pinnedViews: .sectionHeaders) { + LazyVStack(spacing: 12) { searchField if let errorMessage = model.errorMessage { AIStatusBanner( @@ -49,10 +46,10 @@ struct FleetView: View { ) } if !model.rows.isEmpty { - organizationControls if showsSummary { fleetSummary } + tagFilterStrip } if model.isLoading && model.rows.isEmpty { AICard(padding: 16) { @@ -86,19 +83,8 @@ struct FleetView: View { ) } } - ForEach(model.groupedRows) { group in - instanceGroup(group) - } - if !model.rows.isEmpty && !model.selectionMode { - VStack(spacing: 0) { - Button { - showingGroupEditor = true - } label: { - Label("管理分组与批量归类", systemImage: "folder.badge.gearshape") - } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) - .disabled(model.isLoading) - } + ForEach(model.visibleRows) { row in + instanceEntry(row) } if !model.rows.isEmpty { Text("共 \(model.rows.count) 台实例 · 显示 \(summaryRows.count) 台") @@ -126,6 +112,11 @@ struct FleetView: View { } ToolbarItemGroup(placement: .primaryAction) { + AIIconButton("批量标签", systemImage: "tag", kind: .secondary) { + model.showingTagBatch = true + } + .disabled(model.selectedIDs.isEmpty) + AIIconButton("删除已选实例", systemImage: "trash", kind: .danger) { showsDeleteDialog = true } @@ -138,11 +129,6 @@ struct FleetView: View { } } else { ToolbarItemGroup(placement: .primaryAction) { - AIIconButton("管理分组", systemImage: "folder.badge.gearshape") { - model.showingGroupManager = true - } - .disabled(model.isLoading) - AIIconButton("批量选择", systemImage: "checklist") { model.selectionMode = true } @@ -155,18 +141,8 @@ struct FleetView: View { } } } - .sheet(isPresented: $model.showingGroupManager) { - InstanceGroupManagerView(model: model) - } - .sheet(isPresented: $showingGroupEditor) { - InstanceGroupEditorView( - model: model, - targetIDs: batchGroupTargetIDs, - onSaved: { - model.selectedIDs.removeAll() - model.selectionMode = false - } - ) + .sheet(isPresented: $model.showingTagBatch) { + FleetTagBatchSheet(model: model) } .sheet(isPresented: $model.showingForm) { InstanceFormView( @@ -220,13 +196,15 @@ private extension FleetView { #endif } + /// 概览副标题:更新时间,检测未完成时追加进度。进度原本占读数栅格一格,挪到这里后栅格正好两行。 private var updatedDetail: String { + let progress = testingCount > 0 ? " · 已检测 \(settledCount)/\(summaryRows.count) 台" : "" guard let lastRefresh = model.lastRefresh else { - return model.isLoading ? "正在读取" : "待刷新" + return (model.isLoading ? "正在读取" : "待刷新") + progress } // RelativeDateTimeFormatter 在中文下会把刚发生的事说成"0秒后",这里直接兜成"刚刚"。 - if Date().timeIntervalSince(lastRefresh) < 45 { return "刚刚更新" } - return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))" + if Date().timeIntervalSince(lastRefresh) < 45 { return "刚刚更新" + progress } + return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))" + progress } private var searchField: some View { @@ -235,23 +213,11 @@ private extension FleetView { } } - private var organizationControls: some View { - VStack(alignment: .leading, spacing: 10) { - AISegmentedControl( - "组织方式", - selection: $model.groupMode, - options: FleetGroupMode.allCases.map { ($0, $0.title) } - ) - - if model.allTags.isEmpty == false { - tagFilterStrip - } - } - } - - // 标签直接作为筛选器使用:一条横向可滑动的胶囊,首个「全部」用于复位。 + // 标签是唯一的归类手段:一条横向可滑动的胶囊,首个「全部」用于复位。 + @ViewBuilder private var tagFilterStrip: some View { - ScrollView(.horizontal, showsIndicators: false) { + if model.allTags.isEmpty == false { + ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { FilterChip( title: "全部", @@ -276,9 +242,10 @@ private extension FleetView { } .padding(.vertical, 3) .padding(.horizontal, 1) + } + .accessibilityElement(children: .contain) + .accessibilityLabel("按标签筛选") } - .accessibilityElement(children: .contain) - .accessibilityLabel("按标签筛选") } private struct FilterChip: View { @@ -360,18 +327,14 @@ private extension FleetView { } - private var groupedCount: Int { - summaryRows.filter { model.groupName(for: $0.instance.groupId) != nil }.count - } - - private var groupCount: Int { - Set(summaryRows.compactMap(\.instance.groupId)).count - } - private var tagCount: Int { Set(summaryRows.flatMap(\.instance.tags)).count } + private var taggedCount: Int { + summaryRows.filter { $0.instance.tags.isEmpty == false }.count + } + private var summaryRows: [FleetViewModel.Row] { model.visibleRows } private var allVisibleSelected: Bool { @@ -379,10 +342,8 @@ private extension FleetView { && model.selectedIDs.count == model.visibleRows.count } - private var batchGroupTargetIDs: [UUID] { - model.selectedIDs.isEmpty ? [] : Array(model.selectedIDs).sorted { - $0.uuidString < $1.uuidString - } + var selectedIDs: [UUID] { + model.selectedIDs.sorted { $0.uuidString < $1.uuidString } } private var fleetSummary: some View { @@ -430,9 +391,8 @@ private extension FleetView { if !isSummaryCollapsed { FleetOrganizationFootnote( - groupCount: groupCount, tagCount: tagCount, - grouped: groupedCount, + covered: taggedCount, total: summaryRows.count ) } @@ -531,7 +491,7 @@ private extension FleetView { Button(action: action) { HStack(spacing: 5) { Circle() - .fill(tint) + .fill(isSelected ? AITheme.onPrimary : tint) .frame(width: 6, height: 6) .accessibilityHidden(true) Text(title) @@ -544,12 +504,14 @@ private extension FleetView { } .padding(.horizontal, 9) .frame(height: 26) - .foregroundStyle(isSelected ? tint : AITheme.muted) - .background(isSelected ? tint.opacity(0.12) : AITheme.tile) + // 图例即筛选器:选中时整颗胶囊填成状态色,文字走反色。 + // onPrimary 在浅色下是白、深色下是近黑,正好是两套状态色的对比搭档。 + .foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted) + .background(isSelected ? tint : AITheme.tile) .clipShape(Capsule(style: .continuous)) .overlay { Capsule(style: .continuous) - .strokeBorder(isSelected ? tint.opacity(0.34) : AITheme.tileLine) + .strokeBorder(isSelected ? Color.clear : AITheme.tileLine) } // 视觉高度保持 26,但把点击区扩到 44pt 以上。 .contentShape(Capsule(style: .continuous).inset(by: -9)) @@ -624,17 +586,16 @@ private extension FleetView { } struct FleetOrganizationFootnote: View { - let groupCount: Int let tagCount: Int - let grouped: Int + let covered: Int let total: Int var body: some View { HStack(spacing: 6) { - Image(systemName: "folder") + Image(systemName: "tag") .font(.system(size: 10, weight: .semibold)) .accessibilityHidden(true) - Text("\(groupCount) 个分组 · \(tagCount) 个标签 · 已归类 \(grouped)/\(max(grouped, total))") + Text(tagCount == 0 ? "还没有标签,可在编辑实例时添加" : "\(tagCount) 个标签 · 已标记 \(covered)/\(total) 台") .lineLimit(1) .truncationMode(.tail) Spacer(minLength: 0) @@ -649,6 +610,7 @@ private extension FleetView { } .accessibilityElement(children: .combine) } + } private var statusSegments: [FleetStatusSegment] { @@ -675,42 +637,19 @@ private extension FleetView { } } + /// 读数区固定八格两行:温度与电量即使整群节点都没有上报也保留占位, + /// 否则栅格会在七格、九格之间跳,最后一行只剩孤立的一列。 private var fleetSummaryItems: [FleetStatItem] { - var items = [ - FleetStatItem( - title: "检测进度", - value: "\(settledCount)/\(summaryRows.count)", - systemImage: "checkmark.seal", - tint: testingCount > 0 ? AITheme.warning : AITheme.success - ), + [ FleetStatItem(title: "信号", value: signalSummary, systemImage: "cellularbars", tint: signalSummaryTint), FleetStatItem(title: "延迟", value: latencySummary, systemImage: "speedometer", tint: latencySummaryTint), FleetStatItem(title: "存储", value: storageSummary, systemImage: "internaldrive", tint: storageSummaryTint), FleetStatItem(title: "流量", value: trafficSummary, systemImage: "arrow.up.arrow.down", tint: trafficSummaryTint), FleetStatItem(title: "CPU", value: cpuSummary, systemImage: "cpu", tint: loadSummaryTint), - FleetStatItem(title: "内存", value: memorySummary, systemImage: "memorychip", tint: loadSummaryTint) + FleetStatItem(title: "内存", value: memorySummary, systemImage: "memorychip", tint: loadSummaryTint), + FleetStatItem(title: "温度", value: temperatureSummary, systemImage: "thermometer.medium", tint: temperatureSummaryTint), + FleetStatItem(title: "电量", value: batterySummary, systemImage: "battery.100", tint: batterySummaryTint) ] - if summaryRows.contains(where: { $0.temperatureCelsius != nil }) { - items.append( - FleetStatItem( - title: "温度", - value: temperatureSummary, - systemImage: "thermometer.medium", - tint: temperatureSummaryTint - ) - ) - } - if summaryRows.contains(where: { $0.batteryPercent != nil }) { - items.append( - FleetStatItem( - title: "电量", - value: batterySummary, - systemImage: "battery.100", - tint: batterySummaryTint - ) - ) - } - return items } private var onlineCount: Int { summaryRows.filter { $0.status == .online }.count } @@ -904,6 +843,12 @@ private extension FleetView { .truncationMode(.middle) } + // 四条量表把"要不要担心这台"变成一次扫视:信号、延迟、CPU、内存。 + FleetGaugeStrip( + gauges: rowGauges(row), + isPending: row.testing + ) + // 健康节点不再占一行重复"连接正常",只有需要解释的状态才出现说明与链路。 if let statusLine = statusLine(row) { FleetStatusStrip(status: row.status, statusDetail: statusLine) @@ -913,11 +858,13 @@ private extension FleetView { FleetChainRow(items: health) } - if model.groupName(for: row.instance.groupId) != nil || row.instance.tags.isEmpty == false { + FleetMetricGrid( + metrics: metrics, + isPending: row.testing && metrics.isEmpty + ) + + if 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 TagToken( title: tag, @@ -930,11 +877,6 @@ private extension FleetView { } .frame(maxWidth: .infinity, alignment: .leading) } - - FleetMetricGrid( - metrics: metrics, - isPending: row.testing && metrics.isEmpty - ) } } } @@ -980,39 +922,49 @@ private extension FleetView { } + /// 卡片顶部的四条量表:数值配一条发丝进度,正常时不着色,异常时只给颜色不给感叹号。 + private func rowGauges(_ row: FleetViewModel.Row) -> [FleetGauge] { + [ + FleetGauge( + label: "信号", + value: row.signalPercent.map { "\($0)%" } ?? "--", + ratio: row.signalPercent.map { Double($0) / 100 }, + tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted + ), + FleetGauge( + label: "延迟", + value: row.latencyMilliseconds.map { "\($0)ms" } ?? "--", + ratio: row.latencyMilliseconds.map { max(0, 1 - Double($0) / 1000) }, + tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted + ), + FleetGauge( + label: "CPU", + value: row.cpuPercent.map(Self.percentText) ?? "--", + ratio: row.cpuPercent.map { $0 / 100 }, + tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted + ), + FleetGauge( + label: "内存", + value: row.memoryPercent.map(Self.percentText) ?? "--", + ratio: row.memoryPercent.map { $0 / 100 }, + tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted + ), + ] + } + private func rowMetrics(_ row: FleetViewModel.Row) -> [FleetMetric] { - // 卡片只留最该被扫到的六项:先身份(设备/运营商/号码),再链路(信号/延迟/存储)。 + // 信号质量已经交给量表,这一区只放身份与容量:设备、网络、存储、续航、运行时长。 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)%" }, - tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted - ), - .init( - label: "延迟", - value: row.latencyMilliseconds.map { "\($0) ms" }, - tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted - ), + .init(label: "制式", value: row.networkTechnology), + .init(label: "IP", value: row.ipAddress), .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), @@ -1024,7 +976,7 @@ private extension FleetView { tint: row.batteryPercent.flatMap(batteryColor) ?? AITheme.muted ), .init(label: "固件", value: row.firmwareVersion), - .init(label: "运行时间", value: row.uptimeSeconds.map(Self.durationText)), + .init(label: "运行时长", value: row.uptimeSeconds.map(Self.durationText)), .init( label: "下行", value: row.downloadRateBytesPerSecond.map(Self.rateText) @@ -1105,75 +1057,6 @@ private extension FleetView { return milliseconds < 300 ? AITheme.success : (milliseconds < 800 ? AITheme.warning : AITheme.danger) } - private func groupDetail(_ group: FleetGroup) -> String { - model.health(for: group).detail - } - - private func groupTint(_ group: FleetGroup) -> Color { - switch model.groupMode { - case .status: - return group.rows.first.map { statusColor($0.status) } ?? AITheme.muted - case .groups: - return AITheme.primary - case .none: - return AITheme.muted - } - } - - @ViewBuilder - private func instanceGroup(_ group: FleetGroup) -> some View { - if model.groupMode == .none { - ForEach(group.rows) { row in - instanceEntry(row) - } - } else { - Section { - ForEach(group.rows) { row in - if collapsedGroups.contains(group.id) { - EmptyView() - } else { - instanceEntry(row) - } - } - } header: { - let health = model.health(for: group) - - FleetGroupHeader( - title: group.title, - detail: groupDetail(group), - onlineValue: health.onlineAverageText, - abnormalValue: health.abnormalAverageText, - onlineTint: health.onlineTint ?? AITheme.muted, - abnormalTint: health.abnormalTint ?? AITheme.muted, - total: group.rows.count, - testing: health.testing, - signalValue: health.signalAverageText, - latencyValue: health.latencyAverageText, - storageValue: health.storageAverageText, - trafficValue: health.trafficTotalText, - signalTint: health.signalTint ?? AITheme.muted, - latencyTint: health.latencyTint ?? AITheme.muted, - storageTint: health.storageTint ?? AITheme.muted, - trafficTint: health.trafficTint ?? AITheme.muted, - tint: groupTint(group), - isCollapsed: collapsedGroups.contains(group.id) - ) { - toggleGroup(group.id) - } - .padding(.top, 4) - .background(AITheme.canvas) - } - } - } - - private func toggleGroup(_ id: String) { - if collapsedGroups.contains(id) { - collapsedGroups.remove(id) - } else { - collapsedGroups.insert(id) - } - } - private func instanceEntry(_ row: FleetViewModel.Row) -> some View { Group { if model.selectionMode { @@ -1192,10 +1075,7 @@ private extension FleetView { .accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : []) } else { NavigationLink { - InstanceDetailView( - instance: row.instance, - groupName: model.groupName(for: row.instance.groupId) - ) + InstanceDetailView(instance: row.instance) } label: { instanceCard(row) } @@ -1263,7 +1143,7 @@ extension FleetView { let metrics: [FleetMetric] var isPending = false - private static let maxVisible = 6 + private static let maxVisible = 8 private var visible: [FleetMetric] { Array(metrics.prefix(Self.maxVisible)) } @@ -1315,7 +1195,7 @@ extension FleetView { } if overflow > 0 { - Text("另有 \(overflow) 项指标") + Text("另有 \(overflow) 项,进入详情查看") .font(.caption2) .foregroundStyle(AITheme.faint) .padding(.top, 7) @@ -1333,6 +1213,76 @@ extension FleetView { } } + /// 量表单元:一个可读数值加一条发丝进度。 + struct FleetGauge: Identifiable { + let label: String + let value: String + let ratio: Double? + let tint: Color + + var id: String { label } + var hasValue: Bool { ratio != nil } + // 与读数栅格同一规则:只有警告和危险才配颜色。 + var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning } + } + + /// 四格量表条:等宽排布,缺数据时保留占位,避免数值到位后卡片抖动。 + struct FleetGaugeStrip: View { + let gauges: [FleetGauge] + let isPending: Bool + + var body: some View { + HStack(alignment: .top, spacing: 10) { + ForEach(gauges) { gauge in + VStack(alignment: .leading, spacing: 4) { + Text(gauge.label) + .font(.caption2) + .foregroundStyle(AITheme.faint) + .lineLimit(1) + Text(gauge.value) + .font(.footnote.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(color(for: gauge)) + .lineLimit(1) + .minimumScaleFactor(0.7) + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule(style: .continuous) + .fill(AITheme.line) + Capsule(style: .continuous) + .fill(barColor(for: gauge)) + .frame(width: max(3, proxy.size.width * CGFloat(gauge.ratio ?? 0))) + } + } + .frame(height: 4) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(gauge.label) \(gauge.value)") + } + } + .padding(.top, 2) + .overlay(alignment: .top) { + Rectangle() + .fill(AITheme.line) + .frame(height: 1) + .offset(y: -7) + } + } + + private func color(for gauge: FleetGauge) -> Color { + if !gauge.hasValue { return isPending ? AITheme.faint : AITheme.muted } + return gauge.isAlert ? gauge.tint : AITheme.ink + } + + /// 正常读数的进度条用墨色,颜色只留给异常,四格量表不会把卡片染成一片绿。 + private func barColor(for gauge: FleetGauge) -> Color { + guard gauge.hasValue else { return AITheme.line } + return gauge.isAlert ? gauge.tint : AITheme.primary + } + } + struct FleetStatItem { let title: String let value: String @@ -1580,151 +1530,6 @@ extension FleetView { } } - struct FleetGroupHeader: View { - let title: String - let detail: String - let onlineValue: String - let abnormalValue: String - let onlineTint: Color - let abnormalTint: Color - let total: Int - let testing: Int - let signalValue: String - let latencyValue: String - let storageValue: String - let trafficValue: String - let signalTint: Color - let latencyTint: Color - let storageTint: Color - let trafficTint: Color - let tint: Color - let isCollapsed: Bool - let action: () -> Void - - private var onlineRatio: Double { - guard let text = onlineValue.split(separator: "%").first, let value = Double(text) else { - return 0 - } - return min(1, max(0, value / 100)) - } - - private var progressTint: Color { - guard hasSettledResults else { return AITheme.muted } - return onlineRatio >= 1 ? AITheme.success : (onlineRatio > 0 ? AITheme.warning : AITheme.muted) - } - - private var progress: Double { - 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: 10) { - HStack(spacing: 10) { - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(tint) - .frame(width: 3, height: 20) - Text(title) - .font(.headline.weight(.semibold)) - .foregroundStyle(AITheme.ink) - .lineLimit(1) - .layoutPriority(1) - Spacer(minLength: 8) - Image(systemName: isCollapsed ? "chevron.down" : "chevron.up") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(AITheme.muted) - .frame(width: 24, height: 24) - .accessibilityHidden(true) - } - - // 结论未出时不画空进度条,否则会被读成"0% 在线"。 - if hasSettledResults { - ProgressView(value: progress) - .progressViewStyle(.linear) - .tint(progressTint) - .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) - } - - FleetReadoutGrid(items: headerReadouts) - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background(AITheme.chrome) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(AITheme.line) - } - .accessibilityElement(children: .combine) - .accessibilityLabel("\(title),\(detail)") - .accessibilityHint(isCollapsed ? "展开分组" : "折叠分组") - .accessibilityAddTraits(.isButton) - } - } - struct FleetMetric: View { let label: String let value: String? diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift index 05f17f8..27cfbfd 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift @@ -1,135 +1,6 @@ import Foundation import SwiftUI -enum FleetGroupMode: String, CaseIterable, Identifiable { - case none - case status - case groups - - var id: String { rawValue } - - var title: String { - switch self { - case .none: return "平铺" - case .status: return "状态" - case .groups: return "分组" - } - } -} - -struct FleetGroup: Identifiable { - let id: String - let title: String - let rows: [FleetViewModel.Row] -} - -struct FleetGroupHealth: Equatable { - var total = 0 - var online = 0 - var abnormal = 0 - var testing = 0 - var signalValues: [Int] = [] - var latencyValues: [Int] = [] - var batteryValues: [Double] = [] - var storageValues: [Double] = [] - var trafficValues: [Double] = [] - var onlineValues: [Bool] = [] - var abnormalValues: [Bool] = [] - - private var signalAverage: Double? { - average(signalValues.map(Double.init)) - } - - private var latencyAverage: Double? { - average(latencyValues.map(Double.init)) - } - - private var batteryAverage: Double? { - average(batteryValues) - } - - private var storageAverage: Double? { - average(storageValues) - } - - private var trafficTotal: Double { - trafficValues.reduce(0, +) - } - - var detail: String { - var parts = ["\(total) 台"] - if let signal = signalAverage { - parts.append("信号 \(Int(signal.rounded()))%") - } - if let latency = latencyAverage { - parts.append("延迟 \(Int(latency.rounded())) ms") - } - return parts.joined(separator: " · ") - } - - var signalTint: Color? { - signalAverage.map { $0 >= 60 ? AITheme.success : ($0 >= 30 ? AITheme.warning : AITheme.danger) } - } - - var latencyTint: Color? { - latencyAverage.map { $0 < 300 ? AITheme.success : ($0 < 800 ? AITheme.warning : AITheme.danger) } - } - - var batteryTint: Color? { - batteryAverage.map { $0 <= 20 ? AITheme.danger : ($0 <= 40 ? AITheme.warning : AITheme.success) } - } - - var storageTint: Color? { - storageAverage.map { $0 >= 0.90 ? AITheme.danger : ($0 >= 0.75 ? AITheme.warning : AITheme.success) } - } - - var trafficTint: Color? { - trafficValues.isEmpty ? nil : (trafficTotal >= 50_000_000 ? AITheme.warning : AITheme.success) - } - - var onlineAverageText: String { - guard onlineValues.isEmpty == false else { return "不可用" } - let ratio = Double(onlineValues.filter { $0 }.count) / Double(onlineValues.count) - return "\(Int((ratio * 100).rounded()))%" - } - - var onlineTint: Color? { - onlineValues.isEmpty ? nil : (online > 0 ? AITheme.success : AITheme.muted) - } - - var abnormalAverageText: String { - guard abnormalValues.isEmpty == false else { return "不可用" } - let ratio = Double(abnormalValues.filter { $0 }.count) / Double(abnormalValues.count) - return "\(Int((ratio * 100).rounded()))%" - } - - var abnormalTint: Color? { - abnormalValues.isEmpty ? nil : (abnormal > 0 ? AITheme.danger : AITheme.success) - } - - private func average(_ values: [Double]) -> Double? { - guard values.isEmpty == false else { return nil } - return values.reduce(0, +) / Double(values.count) - } - - var signalAverageText: String { - signalAverage.map { "\(Int($0.rounded()))%" } ?? "不可用" - } - - var latencyAverageText: String { - latencyAverage.map { "\(Int($0.rounded())) ms" } ?? "不可用" - } - - var storageAverageText: String { - storageAverage.map { "\(Int(($0 * 100).rounded()))% 已用" } ?? "不可用" - } - - var trafficTotalText: String { - guard trafficValues.isEmpty == false else { return "不可用" } - return ByteCountFormatter.string(fromByteCount: Int64(trafficTotal), countStyle: .decimal) + "/s" - } -} - @MainActor final class FleetViewModel: ObservableObject { struct Row: Equatable, Identifiable { @@ -182,11 +53,6 @@ final class FleetViewModel: ObservableObject { @Published var rows: [Row] = [] @Published var searchText = "" @Published var statusFilter: ConnectionStatus? - @Published var groupMode: FleetGroupMode = .groups { - didSet { - preferences.set(groupMode.rawValue, forKey: "fleet.groupMode") - } - } @Published var selectedTagFilters: Set = [] { didSet { let values = Array(selectedTagFilters).sorted() @@ -199,14 +65,12 @@ final class FleetViewModel: ObservableObject { @Published private(set) var lastRefresh: Date? @Published var errorMessage: String? @Published var showingForm = false - @Published var showingGroupManager = false - @Published private(set) var groups: [InstanceGroup] = [] + @Published var showingTagBatch = false private let repository: any InstanceRepository private let probe: ConnectionProbe private let credentials: any CredentialStore private let deviceService: (any DeviceSummaryService)? - private let groupStore: (any InstanceGroupStore)? private let preferences: UserDefaults init( @@ -214,18 +78,13 @@ final class FleetViewModel: ObservableObject { probe: ConnectionProbe, credentials: any CredentialStore, deviceService: (any DeviceSummaryService)? = 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") ?? []) } @@ -241,7 +100,6 @@ final class FleetViewModel: ObservableObject { func refresh() async { isLoading = true errorMessage = nil - await loadGroups() do { let query = InstanceQuery(search: searchText.isEmpty ? nil : searchText) let instances = try await repository.list(query) @@ -287,7 +145,6 @@ final class FleetViewModel: ObservableObject { name: input.name, origin: input.origin, tags: input.tags, - groupId: input.groupId, password: input.password ) ) @@ -318,63 +175,37 @@ final class FleetViewModel: ObservableObject { await delete(Array(selectedIDs)) } - @discardableResult - func createGroup(named name: String) async throws -> InstanceGroup { - guard let groupStore else { throw RepositoryError.notFound } - let group = try await groupStore.create(InstanceGroupInput(name: name)) - await loadGroups() - return group - } - - func updateGroup(_ id: UUID, name: String) async throws { - guard let groupStore else { return } - _ = try await groupStore.update(id, name) - await loadGroups() - } - - func deleteGroup(_ id: UUID) async throws { - guard let groupStore else { return } - try await groupStore.delete(id) - for row in rows where row.instance.groupId == id { - _ = try await repository.update( - row.instance.id, - expectedRevision: row.instance.configRevision, - InstancePatch(groupId: nil) - ) - } - await loadGroups() - await refresh() - } - - func assign(groupID: UUID?, to ids: [UUID]) async throws { + /// 批量给选中的节点增删标签:标签是唯一的归类手段,所以这里必须能成批操作。 + func applyTag(_ tag: String, to ids: [UUID], removing: Bool = false) async { + let normalized = Self.normalizedTag(tag) + guard normalized.isEmpty == false else { return } var failures = 0 for id in ids { guard let row = rows.first(where: { $0.id == id }) else { continue } + var updated = row.instance.tags.filter { Self.normalizedTag($0) != normalized } + if removing == false { + updated.append(tag.trimmingCharacters(in: .whitespacesAndNewlines)) + } do { _ = try await repository.update( id, expectedRevision: row.instance.configRevision, - InstancePatch(groupId: groupID) + InstancePatch(tags: updated) ) } catch { failures += 1 - errorMessage = "更新 \(row.instance.name) 分组失败" + errorMessage = "更新 \(row.instance.name) 标签失败" } } - if failures > 0 { - throw RepositoryError.databaseFailed + if failures > 0, errorMessage == nil { + errorMessage = "部分节点标签更新失败" } await refresh() } - func loadGroups() async { - guard let groupStore else { return } - groups = (try? await groupStore.list()) ?? [] - } - - func groupName(for id: UUID?) -> String? { - guard let id else { return nil } - return groups.first { $0.id == id }?.name + func tagNames(in ids: [UUID]) -> [String] { + let set = Set(rows.filter { ids.contains($0.id) }.flatMap { $0.instance.tags.map(Self.normalizedTag) }) + return set.sorted { $0.localizedCompare($1) == .orderedAscending } } private func applyProbe( @@ -413,6 +244,7 @@ final class FleetViewModel: ObservableObject { rows[index].testing = false } + /// 列表顺序:状态差的排前面,同组内按名称,扫读时问题节点一定在第一屏。 var visibleRows: [Row] { rows.filter { row in if let statusFilter, row.status != statusFilter { @@ -440,6 +272,21 @@ final class FleetViewModel: ObservableObject { $0.localizedCaseInsensitiveContains(searchText) } } + .sorted { lhs, rhs in + let left = severity(lhs.status) + let right = severity(rhs.status) + if left != right { return left < right } + return lhs.instance.name.localizedCompare(rhs.instance.name) == .orderedAscending + } + } + + private func severity(_ status: ConnectionStatus) -> Int { + switch status { + case .offline: return 0 + case .authRequired, .degraded: return 1 + case .unknown: return 2 + case .online: return 3 + } } var allTags: [String] { @@ -464,6 +311,17 @@ final class FleetViewModel: ObservableObject { selectedTagFilters.contains(Self.normalizedTag(tag)) } + /// 选中的这批节点里,有多少台已经带上这个标签:决定胶囊显示"全部/部分/未标记"。 + func tagCoverage(_ tag: String, in ids: [UUID]) -> Int { + let needle = Self.normalizedTag(tag) + guard needle.isEmpty == false else { return 0 } + let targets = Set(ids) + return rows.filter { row in + targets.contains(row.id) + && row.instance.tags.map(Self.normalizedTag).contains(needle) + }.count + } + func toggleTagFilter(_ tag: String) { let normalized = Self.normalizedTag(tag) guard normalized.isEmpty == false else { return } @@ -482,62 +340,6 @@ final class FleetViewModel: ObservableObject { 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 - } - switch groupMode { - case .none: - return [FleetGroup(id: "", title: "", rows: rows)] - case .status: - let order: [ConnectionStatus] = [.online, .degraded, .authRequired, .offline, .unknown] - return order.compactMap { status in - let matched = rows.filter { $0.status == status } - guard !matched.isEmpty else { return nil } - return FleetGroup(id: status.rawValue, title: status.label, rows: matched) - } - case .groups: - var groupedRows: [UUID: [Row]] = [:] - var ungroupedRows: [Row] = [] - for row in rows { - if let id = row.instance.groupId, groupName(for: id) != nil { - groupedRows[id, default: []].append(row) - } else { - ungroupedRows.append(row) - } - } - let namedGroups = groupedRows.map { key, values in - FleetGroup( - id: key.uuidString, - title: groupName(for: key) ?? "未命名分组", - rows: values - ) - } - .sorted { $0.title.localizedCompare($1.title) == .orderedAscending } - return namedGroups + (ungroupedRows.isEmpty - ? [] - : [FleetGroup(id: "__ungrouped__", title: "未分组", rows: ungroupedRows)]) - } - } - - func health(for group: FleetGroup) -> FleetGroupHealth { - FleetGroupHealth( - total: group.rows.count, - online: group.rows.filter { $0.status == .online }.count, - abnormal: group.rows.filter { $0.status != .online && $0.status != .unknown }.count, - testing: group.rows.filter(\.testing).count, - signalValues: group.rows.compactMap(\.signalPercent), - latencyValues: group.rows.compactMap(\.latencyMilliseconds), - batteryValues: group.rows.compactMap(\.batteryPercent), - storageValues: group.rows.compactMap(\.storageUsageRatio), - trafficValues: group.rows.compactMap(\.downloadRateBytesPerSecond) - + group.rows.compactMap(\.uploadRateBytesPerSecond) - , - onlineValues: group.rows.map { $0.status == .online }, - abnormalValues: group.rows.map { $0.status != .online && $0.status != .unknown } - ) - } } protocol DeviceSummaryService { diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift index 281194f..5619101 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift @@ -9,7 +9,6 @@ struct InstanceFormView: View { @State private var tags: [String] = [] @State private var tagDraft = "" @State private var password = "" - @State private var selectedGroupId: UUID? @State private var validationMessage: String? @State private var isSaving = false @@ -19,7 +18,6 @@ struct InstanceFormView: View { _name = State(initialValue: instance?.name ?? "") _originText = State(initialValue: instance?.origin.absoluteString ?? "") _tags = State(initialValue: instance?.tags ?? []) - _selectedGroupId = State(initialValue: instance?.groupId) } private var canSave: Bool { @@ -36,7 +34,6 @@ struct InstanceFormView: View { field("名称", text: $name, placeholder: "Home Node") field("地址", text: $originText, placeholder: "http://192.168.1.10:3000") tagEditor - groupPicker field( "实例密码", text: $password, @@ -81,30 +78,6 @@ struct InstanceFormView: View { } } - private var groupPicker: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - AIFieldLabel("分组") - Spacer() - Button { - model.showingGroupManager = true - } label: { - Label("管理", systemImage: "folder.badge.gearshape") - } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 72)) - } - Picker("分组", selection: $selectedGroupId) { - Text("未分组").tag(UUID?.none) - ForEach(model.groups) { group in - Text(group.name).tag(UUID?.some(group.id)) - } - } - .pickerStyle(.menu) - .aiSelectSurface() - } - .accessibilityElement(children: .contain) - } - private var tagEditor: some View { VStack(alignment: .leading, spacing: 6) { AIFieldLabel("标签") @@ -156,7 +129,6 @@ struct InstanceFormView: View { name: normalizedName, origin: origin, tags: tags, - groupId: selectedGroupId, password: password.isEmpty ? nil : password ) Task { diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupEditorView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupEditorView.swift deleted file mode 100644 index 392e5df..0000000 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupEditorView.swift +++ /dev/null @@ -1,226 +0,0 @@ -import SwiftUI - -/// 批量归类:点一个分组就把目标实例移进去并立即生效,不再需要二次“保存”。 -struct InstanceGroupEditorView: View { - @Environment(\.dismiss) private var dismiss - @ObservedObject var model: FleetViewModel - let targetIDs: [UUID] - let onSaved: () -> Void - - @State private var isCreatingGroup = false - @State private var newGroupName = "" - @State private var isWorking = false - @State private var errorMessage: String? - - 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 { - ScrollView { - LazyVStack(spacing: 12) { - if let errorMessage { - AIStatusBanner( - errorMessage, - tint: AITheme.danger, - systemImage: "exclamationmark.triangle" - ) - } - - AISectionTitle( - "移动到", - systemImage: "folder", - detail: "\(targets.count) 台实例" - ) - - if isCreatingGroup { - AICard(padding: 16) { - VStack(alignment: .leading, spacing: 12) { - AIFieldLabel("新分组名称") - TextField("例如:家里、外场、测试机", text: $newGroupName) - .textFieldStyle(AIFieldStyle()) - .submitLabel(.done) - .onSubmit { Task { await createAndAssign() } } - AIActionRow(spacing: 8) { - Button("取消") { - isCreatingGroup = false - newGroupName = "" - errorMessage = nil - } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) - .disabled(isWorking) - - 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) - } - .background(AITheme.canvas.ignoresSafeArea()) - .navigationTitle("实例分组") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("取消") { dismiss() } - } - ToolbarItem(placement: .primaryAction) { - AIIconButton("新建分组", systemImage: "plus") { - errorMessage = nil - isCreatingGroup = true - } - .disabled(isWorking) - } - } - } - } - - private func groupRow( - title: String, - subtitle: String, - systemImage: String, - groupID: UUID? - ) -> some View { - Button { - guard uniformGroup != groupID else { - dismiss() - return - } - Task { await apply(groupID: groupID) } - } label: { - HStack(spacing: 12) { - Image(systemName: systemImage) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(isCurrent(groupID) ? AITheme.primary : AITheme.muted) - .frame(width: 32, height: 32) - .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) { - Text(title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(AITheme.ink) - .lineLimit(1) - Text(subtitle) - .font(.caption) - .foregroundStyle(AITheme.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - - 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) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .contentShape(Rectangle()) - .accessibilityElement(children: .combine) - .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 apply(groupID: UUID?) async { - guard !isWorking else { return } - isWorking = true - errorMessage = nil - do { - try await model.assign(groupID: groupID, to: targets) - onSaved() - dismiss() - } catch { - 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 - } - } -} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupManagerView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupManagerView.swift deleted file mode 100644 index c6e3dea..0000000 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupManagerView.swift +++ /dev/null @@ -1,184 +0,0 @@ -import SwiftUI - -struct InstanceGroupManagerView: View { - @Environment(\.dismiss) private var dismiss - @ObservedObject var model: FleetViewModel - @State private var newName = "" - @State private var editingGroup: InstanceGroup? - @State private var editingName = "" - @State private var validationMessage: String? - @State private var isWorking = false - @State private var pendingDelete: InstanceGroup? - - var body: some View { - NavigationStack { - ScrollView { - LazyVStack(spacing: 12) { - AICard { - VStack(alignment: .leading, spacing: 10) { - AIFieldLabel("新增分组") - TextField("例如:家里、外场、测试机", text: $newName) - .textFieldStyle(AIFieldStyle()) - .submitLabel(.done) - } - } - - if let validationMessage { - AIStatusBanner( - validationMessage, - tint: AITheme.danger, - systemImage: "exclamationmark.triangle" - ) - } - - if model.groups.isEmpty { - AICard { - AIEmptyState( - systemImage: "folder.badge.plus", - title: "暂无分组", - message: "创建分组后,实例可按业务或位置归类。" - ) - } - } else { - ForEach(model.groups) { group in - AICard { - HStack(spacing: 12) { - Image(systemName: "folder") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(AITheme.primary) - .frame(width: 34, height: 34) - .background(AITheme.primary.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 3) { - Text(group.name) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(AITheme.ink) - .lineLimit(1) - Text("\(instanceCount(group.id)) 个实例") - .font(.caption) - .foregroundStyle(AITheme.muted) - } - - Spacer() - - AISymbolAction("重命名分组 \(group.name)", systemImage: "pencil", tint: AITheme.muted) { - editingGroup = group - editingName = group.name - validationMessage = nil - } - - AISymbolAction("删除分组 \(group.name)", systemImage: "trash", tint: AITheme.danger) { - pendingDelete = group - } - .disabled(isWorking) - } - } - } - } - } - .padding(16) - .padding(.bottom, 8) - } - .background(AITheme.canvas.ignoresSafeArea()) - .navigationTitle("分组管理") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("取消") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("完成") { dismiss() } - } - } - .aiBottomActionBar { - AIActionRow { - Button { - Task { await create() } - } label: { - Label("创建分组", systemImage: "plus") - } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) - .disabled(isWorking) - } - } - .alert("编辑分组", isPresented: Binding( - get: { editingGroup != nil }, - set: { if !$0 { editingGroup = nil } } - )) { - TextField("分组名称", text: $editingName) - Button("保存") { - Task { await saveEditing() } - } - Button("取消", role: .cancel) {} - } 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() - } - } - } - - private func instanceCount(_ id: UUID) -> Int { - model.rows.filter { $0.instance.groupId == id }.count - } - - private func create() async { - guard !isWorking else { return } - isWorking = true - validationMessage = nil - do { - try await model.createGroup(named: newName) - newName = "" - } catch { - validationMessage = "创建失败,名称不能为空且不能重复。" - } - isWorking = false - } - - private func saveEditing() async { - guard let editingGroup else { return } - isWorking = true - validationMessage = nil - do { - try await model.updateGroup(editingGroup.id, name: editingName) - self.editingGroup = nil - } catch { - validationMessage = "更新失败,名称不能为空且不能重复。" - } - isWorking = false - } - - private func delete(_ group: InstanceGroup) async { - guard !isWorking else { return } - isWorking = true - pendingDelete = nil - do { - try await model.deleteGroup(group.id) - } catch { - validationMessage = "删除失败,请稍后重试。" - } - isWorking = false - } -} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift index 562edb2..d2308b3 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift @@ -62,7 +62,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 12) { AISectionTitle("应用信息", systemImage: "info.circle") if let runtime = model.runtime { - AIKeyValueRow("版本", value: "\(runtime.marketingVersion) (\(runtime.buildVersion))") + AIKeyValueRow("版本", value: runtime.marketingVersion) AIKeyValueRow("系统", value: runtime.systemVersion) } else { Text("正在读取应用信息…") @@ -98,20 +98,21 @@ struct SettingsView: View { VStack(spacing: 7) { Image(systemName: mode.systemImage) .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(isSelected ? AITheme.primary : AITheme.muted) + .foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted) .accessibilityHidden(true) Text(mode.label) .font(.caption.weight(isSelected ? .semibold : .medium)) - .foregroundStyle(isSelected ? AITheme.ink : AITheme.muted) + .foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted) .lineLimit(1) .minimumScaleFactor(0.8) } .frame(maxWidth: .infinity, minHeight: 68) - .background(isSelected ? AITheme.primary.opacity(0.10) : AITheme.control) + // 选中格直接反色成实心块,浅底黑、深底白;一层 10% 的色雾在三块同色瓷砖里根本分不出来。 + .background(isSelected ? AITheme.primary : AITheme.control) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(isSelected ? AITheme.primary.opacity(0.42) : AITheme.line) + .strokeBorder(isSelected ? Color.clear : AITheme.line) } .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } diff --git a/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift b/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift index 11d08be..62962f0 100644 --- a/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift +++ b/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift @@ -13,7 +13,6 @@ public struct Instance: Codable, Equatable, Identifiable, Sendable { public var name: String public var origin: URL public var tags: [String] - public var groupId: UUID? public var credentialConfigured: Bool public var configRevision: Int public let createdAt: Date @@ -24,7 +23,6 @@ public struct Instance: Codable, Equatable, Identifiable, Sendable { name: String, origin: URL, tags: [String] = [], - groupId: UUID? = nil, credentialConfigured: Bool = false, configRevision: Int = 1, createdAt: Date, @@ -34,7 +32,6 @@ public struct Instance: Codable, Equatable, Identifiable, Sendable { self.name = name self.origin = origin self.tags = tags - self.groupId = groupId self.credentialConfigured = credentialConfigured self.configRevision = configRevision self.createdAt = createdAt @@ -46,20 +43,17 @@ public struct InstanceInput: Sendable { public var name: String public var origin: URL public var tags: [String] - public var groupId: UUID? public var password: String? public init( name: String, origin: URL, tags: [String] = [], - groupId: UUID? = nil, password: String? = nil ) { self.name = name self.origin = origin self.tags = tags - self.groupId = groupId self.password = password } } @@ -68,20 +62,17 @@ public struct InstancePatch: Sendable { public var name: String? public var origin: URL? public var tags: [String]? - public var groupId: UUID? public var password: String? public init( name: String? = nil, origin: URL? = nil, tags: [String]? = nil, - groupId: UUID? = nil, password: String? = nil ) { self.name = name self.origin = origin self.tags = tags - self.groupId = groupId self.password = password } } @@ -89,12 +80,10 @@ public struct InstancePatch: Sendable { public struct InstanceQuery: Sendable { public var search: String? public var tags: [String]? - public var groupId: UUID? - public init(search: String? = nil, tags: [String]? = nil, groupId: UUID? = nil) { + public init(search: String? = nil, tags: [String]? = nil) { self.search = search self.tags = tags - self.groupId = groupId } } diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index ceedffa..0570a97 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -80,12 +80,12 @@ enum AppTab: Hashable { #if DEBUG /// 本地截图验证用的入口,Release 构建不包含。 struct DebugDetailView: View { - @State private var target: (Instance, String?)? + @State private var target: Instance? var body: some View { Group { - if let (instance, groupName) = target { - InstanceDetailView(instance: instance, groupName: groupName) + if let instance = target { + InstanceDetailView(instance: instance) } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -93,13 +93,9 @@ enum AppTab: Hashable { } .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) + target = listed.first { $0.origin.port == 3000 } ?? listed[0] } } } diff --git a/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceGroupStore.swift b/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceGroupStore.swift deleted file mode 100644 index 1a52a45..0000000 --- a/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceGroupStore.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation - -public struct InstanceGroup: Codable, Equatable, Identifiable, Sendable { - public var id: UUID - public var name: String - public var createdAt: Date - - public init(id: UUID, name: String, createdAt: Date) { - self.id = id - self.name = name - self.createdAt = createdAt - } -} - -public struct InstanceGroupInput: Sendable { - public var name: String - - public init(name: String) { - self.name = name - } -} - -public protocol InstanceGroupStore: Sendable { - func list() async throws -> [InstanceGroup] - func create(_ input: InstanceGroupInput) async throws -> InstanceGroup - func update(_ id: UUID, _ name: String) async throws -> InstanceGroup - func delete(_ id: UUID) async throws -} - -public final class FileInstanceGroupStore: @unchecked Sendable, InstanceGroupStore { - private struct FileModel: Codable { - var groups: [InstanceGroup] = [] - } - - private let location: URL - private let encoder: JSONEncoder - private let decoder: JSONDecoder - private let lock = NSLock() - private var fileModel = FileModel() - private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-group-store") - - public init(location: URL) { - self.location = URL(fileURLWithPath: location.path) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - self.decoder = decoder - - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - self.encoder = encoder - - lock.withLock { - if let data = try? Data(contentsOf: location), - let decoded = try? decoder.decode(FileModel.self, from: data) { - fileModel = decoded - } - } - } - - public func list() async throws -> [InstanceGroup] { - lock.withLock { - fileModel.groups.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } - } - } - - public func create(_ input: InstanceGroupInput) async throws -> InstanceGroup { - try await run { model in - let name = try Self.normalized(input.name) - guard !model.groups.contains(where: { $0.name == name }) else { - throw RepositoryError.duplicateOrigin - } - let group = InstanceGroup(id: UUID(), name: name, createdAt: Date()) - model.groups.append(group) - return group - } - } - - public func update(_ id: UUID, _ name: String) async throws -> InstanceGroup { - try await run { model in - let normalized = try Self.normalized(name) - guard let index = model.groups.firstIndex(where: { $0.id == id }) else { - throw RepositoryError.notFound - } - guard !model.groups.contains(where: { $0.id != id && $0.name == normalized }) else { - throw RepositoryError.duplicateOrigin - } - model.groups[index].name = normalized - return model.groups[index] - } - } - - public func delete(_ id: UUID) async throws { - try await run { model in - model.groups.removeAll { $0.id == id } - } - } - - private func run( - _ transform: @escaping @Sendable (inout FileModel) throws -> T - ) async throws -> T { - try await withCheckedThrowingContinuation { continuation in - queue.async { - do { - var model = self.lock.withLock { self.fileModel } - let output = try transform(&model) - try self.write(model) - self.lock.withLock { self.fileModel = model } - continuation.resume(returning: output) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - private func write(_ model: FileModel) throws { - let directory = location.deletingLastPathComponent() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try encoder.encode(model).write(to: location, options: [.atomic]) - } - - private static func normalized(_ value: String) throws -> String { - let name = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, name.count <= 100 else { - throw RepositoryError.invalidOrigin - } - return name - } -} diff --git a/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift b/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift index 7054232..fd95811 100644 --- a/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift +++ b/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift @@ -35,9 +35,6 @@ public struct FileInstanceRepository: InstanceRepository { if let tags = query.tags, !tags.isEmpty { instances = instances.filter { !Set($0.tags).isDisjoint(with: tags) } } - if let groupId = query.groupId { - instances = instances.filter { $0.groupId == groupId } - } return instances.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } } } @@ -61,7 +58,6 @@ public struct FileInstanceRepository: InstanceRepository { name: input.name.trimmingCharacters(in: .whitespacesAndNewlines), origin: origin, tags: Self.normalizedTags(input.tags), - groupId: input.groupId, credentialConfigured: !(input.password ?? "").isEmpty, createdAt: now, updatedAt: now @@ -96,9 +92,6 @@ public struct FileInstanceRepository: InstanceRepository { if let tags = patch.tags { instance.tags = Self.normalizedTags(tags) } - if patch.groupId != nil || instance.groupId != nil { - instance.groupId = patch.groupId - } if let password = patch.password { instance.credentialConfigured = !password.isEmpty } 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 4b469ac..fe8f355 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 2a9df63..c434eeb 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.1 - CFBundleVersion6 + CFBundleShortVersionString0.2.2 + CFBundleVersion7 LSRequiresIPhoneOS MinimumOSVersion16.0 NSAppTransportSecurity diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift index 52dbc51..d7c78ca 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift @@ -5,10 +5,6 @@ enum DefaultContainer { location: documentFileURL("credentials.json") ) - private static let instanceGroups: any InstanceGroupStore = FileInstanceGroupStore( - location: documentFileURL("instance-groups.json") - ) - private static let logStore: any LogStore = FileLogStore( location: documentFileURL("log-center.json") ) @@ -20,7 +16,8 @@ enum DefaultContainer { @MainActor static func makeFleetModel() -> FleetViewModel { - FleetViewModel( + purgeLegacyGrouping() + return FleetViewModel( repository: FileInstanceRepository(location: documentFileURL("instances.json")), probe: ConnectionProbe(client: SimAdminClient()), credentials: credentials, @@ -28,11 +25,16 @@ enum DefaultContainer { client: SimAdminClient(), sessions: InstanceSessionStore(), credentials: credentials - ), - groupStore: instanceGroups + ) ) } + /// 0.2.2 起分组被标签取代,遗留的分组文件与偏好键一次性清掉,避免 Documents 里留死数据。 + private static func purgeLegacyGrouping() { + try? FileManager.default.removeItem(at: documentFileURL("instance-groups.json")) + UserDefaults.standard.removeObject(forKey: "fleet.groupMode") + } + @MainActor static func makeMessagesModel() -> MessagesViewModel { MessagesViewModel( diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift index 931611c..c8d412b 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift @@ -28,50 +28,50 @@ private enum AIPalette { dynamicColor(light: light, dark: dark) } - // 浅色:中性冷灰底 + 纯白卡 + 极浅内嵌面,避免"灰蓝蒙层"把层级糊成一团。 - 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)) + // 石墨灰阶:所有中性色 R≈G≈B,不带蓝调;主色让位给黑/白,颜色只用来表达状态。 + static let canvas = color(light: rgba(244, 245, 245, 1), dark: rgba(8, 9, 9, 1)) + static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(19, 21, 21, 1)) + static let surfaceRaised = color(light: rgba(255, 255, 255, 1), dark: rgba(27, 29, 30, 1)) + static let control = color(light: rgba(238, 239, 239, 1), dark: rgba(36, 39, 40, 1)) + static let tile = color(light: rgba(246, 247, 247, 1), dark: rgba(255, 255, 255, 0.055)) + static let tileLine = color(light: rgba(231, 233, 233, 1), dark: rgba(255, 255, 255, 0.09)) + static let chrome = color(light: rgba(252, 252, 252, 0.92), dark: rgba(11, 12, 12, 0.88)) + static let ink = color(light: rgba(17, 19, 20, 1), dark: rgba(240, 242, 242, 1)) + static let muted = color(light: rgba(88, 94, 97, 1), dark: rgba(158, 165, 168, 1)) + static let faint = color(light: rgba(126, 132, 135, 1), dark: rgba(112, 118, 121, 1)) + static let line = color(light: rgba(223, 226, 226, 1), dark: rgba(255, 255, 255, 0.11)) + static let brand = color(light: rgba(18, 20, 21, 1), dark: rgba(8, 9, 9, 1)) + static let primary = color(light: rgba(21, 24, 25, 1), dark: rgba(238, 241, 241, 1)) + static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(12, 14, 14, 1)) + static let danger = color(light: rgba(193, 46, 40, 1), dark: rgba(247, 113, 105, 1)) + static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(24, 8, 7, 1)) + static let warning = color(light: rgba(170, 92, 15, 1), dark: rgba(233, 166, 60, 1)) + static let success = color(light: rgba(16, 122, 86, 1), dark: rgba(66, 199, 132, 1)) + static let segmentTrack = color(light: rgba(234, 236, 236, 1), dark: rgba(26, 29, 29, 1)) + static let segmentSelected = color(light: rgba(255, 255, 255, 1), dark: rgba(54, 58, 59, 1)) + static let cardShadow = color(light: rgba(17, 22, 25, 0.06), dark: rgba(0, 0, 0, 0.60)) #elseif canImport(AppKit) - static let canvas = Color(red: 0.914, green: 0.933, blue: 0.961) + static let canvas = Color(red: 0.957, green: 0.961, blue: 0.961) static let surface = Color.white - 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 control = Color(red: 0.933, green: 0.937, blue: 0.937) + static let ink = Color(red: 0.067, green: 0.075, blue: 0.078) + static let muted = Color(red: 0.345, green: 0.369, blue: 0.380) + static let line = Color(red: 0.875, green: 0.886, blue: 0.886) + static let brand = Color(red: 0.071, green: 0.078, blue: 0.082) 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.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 primary = Color(red: 0.082, green: 0.094, blue: 0.098) + static let danger = Color(red: 0.757, green: 0.180, blue: 0.157) + static let warning = Color(red: 0.667, green: 0.361, blue: 0.059) + static let success = Color(red: 0.063, green: 0.478, blue: 0.337) + static let tile = Color(red: 0.965, green: 0.969, blue: 0.969) + static let tileLine = Color(red: 0.906, green: 0.914, blue: 0.914) + static let faint = Color(red: 0.494, green: 0.518, blue: 0.529) + static let segmentTrack = Color(red: 0.918, green: 0.925, blue: 0.925) static let segmentSelected = Color.white - static let cardShadow = Color(red: 0.059, green: 0.118, blue: 0.235).opacity(0.07) + static let cardShadow = Color(red: 0.067, green: 0.086, blue: 0.098).opacity(0.06) #else private static func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> Color { Color(red: red / 255, green: green / 255, blue: blue / 255) diff --git a/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift b/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift index 3e43cc8..f384891 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.1") - XCTAssertEqual(AppMetadata.buildVersion, "6") + XCTAssertEqual(AppMetadata.marketingVersion, "0.2.2") + XCTAssertEqual(AppMetadata.buildVersion, "7") } } diff --git a/apps/ios/Tests/MultiSimAdminTests/ConnectionProbeTests.swift b/apps/ios/Tests/MultiSimAdminTests/ConnectionProbeTests.swift index 1ef12d9..06ae4a8 100644 --- a/apps/ios/Tests/MultiSimAdminTests/ConnectionProbeTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/ConnectionProbeTests.swift @@ -24,7 +24,6 @@ final class ConnectionProbeTests: XCTestCase { name: "Probe", origin: URL(string: "http://192.168.1.20:3000")!, tags: [], - groupId: nil, credentialConfigured: false, configRevision: 1, createdAt: Date(), diff --git a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift index 1088499..e1d81ce 100644 --- a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift @@ -37,7 +37,7 @@ final class FleetViewModelTests: XCTestCase { XCTAssertTrue(model.rows.allSatisfy { $0.latencyMilliseconds != nil }) } - func testVisibleRowsAndGroupModesUseFilteredResults() async throws { + func testVisibleRowsFollowSearchAndTagFilters() async throws { let repository = MemoryInstanceRepository() let model = FleetViewModel( repository: repository, @@ -52,7 +52,6 @@ final class FleetViewModelTests: XCTestCase { model.searchText = "home" XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"]) - XCTAssertEqual(model.groupedRows.flatMap(\.rows).map(\.instance.name), ["Home"]) model.searchText = "" model.clearTagFilters() @@ -61,9 +60,22 @@ final class FleetViewModelTests: XCTestCase { XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"]) XCTAssertEqual(model.countForTagFilter("home"), 1) model.clearTagFilters() + XCTAssertEqual(model.visibleRows.count, 2, "没有筛选时全部节点都要出现") + } - model.groupMode = .status - XCTAssertEqual(model.groupedRows.map(\.title), ["在线"]) + func testVisibleRowsPutProblemNodesFirst() async throws { + let model = FleetViewModel( + repository: MemoryInstanceRepository(), + probe: ConnectionProbe(client: UnreachableForClient(host: "192.168.1.99")), + credentials: NoopCredentialStore(), + preferences: transientPreferences() + ) + try await model.add(InstanceInput(name: "Healthy", origin: URL(string: "http://192.168.1.98:3000")!)) + try await model.add(InstanceInput(name: "Broken", origin: URL(string: "http://192.168.1.99:3000")!)) + + await model.refresh() + + XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Broken", "Healthy"]) } func testTagFiltersCombineWithAndSemanticsAndPruneStaleSelections() async throws { @@ -129,89 +141,58 @@ final class FleetViewModelTests: XCTestCase { UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) } - func testGroupModeKeepsUngroupedInstancesVisible() async throws { - let repository = MemoryInstanceRepository() + func testUpdateRewritesNameAndTags() async throws { let model = FleetViewModel( - repository: repository, - probe: ConnectionProbe(client: AlwaysOnlineClient()), - credentials: NoopCredentialStore() - ) - try await model.add(InstanceInput(name: "Ungrouped", origin: URL(string: "http://192.168.1.72:3000")!)) - - await model.refresh() - model.groupMode = .groups - - XCTAssertEqual(model.groupedRows.map(\.title), ["未分组"]) - XCTAssertEqual(model.groupedRows.first?.rows.map(\.instance.name), ["Ungrouped"]) - } - - func testStoredGroupsResolveNamesAndCanUpdateInstance() async throws { - let location = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathComponent("groups.json") - let groupStore = FileInstanceGroupStore(location: location) - let repository = MemoryInstanceRepository() - let model = FleetViewModel( - repository: repository, + repository: MemoryInstanceRepository(), probe: ConnectionProbe(client: AlwaysOnlineClient()), credentials: NoopCredentialStore(), - groupStore: groupStore + preferences: transientPreferences() ) - - let createdGroup = try await groupStore.create(InstanceGroupInput(name: "外场")) try await model.add(InstanceInput( name: "Van", origin: URL(string: "http://192.168.1.80:3000")!, - groupId: createdGroup.id + tags: ["old"] )) - await model.refresh() - model.groupMode = .groups - - XCTAssertEqual(model.groups.map(\.name), ["外场"]) - XCTAssertEqual(model.groupedRows.map(\.title), ["外场"]) - XCTAssertEqual(model.groupedRows.first?.rows.map(\.instance.name), ["Van"]) - let row = try XCTUnwrap(model.rows.first) + try await model.update(row.id, InstanceInput( name: "Van Pro", origin: row.instance.origin, - tags: ["field"], - groupId: createdGroup.id + tags: ["field"] )) XCTAssertEqual(model.rows.first?.instance.name, "Van Pro") XCTAssertEqual(model.rows.first?.instance.tags, ["field"]) } - func testAssignMovesSelectedInstancesIntoAndOutOfGroup() async throws { - let location = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathComponent("groups.json") - let groupStore = FileInstanceGroupStore(location: location) - let repository = MemoryInstanceRepository() + func testApplyTagAddsAndRemovesAcrossTheWholeSelection() async throws { let model = FleetViewModel( - repository: repository, + repository: MemoryInstanceRepository(), probe: ConnectionProbe(client: AlwaysOnlineClient()), credentials: NoopCredentialStore(), - groupStore: groupStore + preferences: transientPreferences() ) let first = try await model.add(InstanceInput( name: "First", - origin: URL(string: "http://192.168.1.90:3000")! + origin: URL(string: "http://192.168.1.90:3000")!, + tags: ["keep"] )) let second = try await model.add(InstanceInput( name: "Second", origin: URL(string: "http://192.168.1.91:3000")! )) - let group = try await model.createGroup(named: "外场") + await model.refresh() + let ids = [first.id, second.id] - try await model.assign(groupID: group.id, to: [first.id, second.id]) - XCTAssertEqual(model.rows.map(\.instance.groupId), [group.id, group.id]) + await model.applyTag("外场", to: ids) + XCTAssertEqual(model.rows.map(\.instance.tags).map { $0.contains("外场") }, [true, true]) + XCTAssertEqual(model.tagCoverage("外场", in: ids), 2) - try await model.assign(groupID: nil, to: [first.id]) - XCTAssertEqual(model.rows.first?.instance.groupId, nil) - XCTAssertEqual(model.rows.last?.instance.groupId, group.id) + // 再点一次只撕掉这一个标签,其他标签必须留下。 + await model.applyTag("外场", to: [first.id], removing: true) + XCTAssertEqual(model.rows.first?.instance.tags, ["keep"]) + XCTAssertEqual(model.tagCoverage("外场", in: ids), 1) } func testAddValidatesPasswordAndDeleteRemovesCredential() async throws { @@ -274,6 +255,18 @@ struct AlwaysOnlineClient: SimAdminClientProtocol { } } +/// 指定主机一律不可达,用来验证"问题节点排在前面"。 +struct UnreachableForClient: SimAdminClientProtocol { + let host: String + + func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse { + if baseURL.host == host { + throw UpstreamError.transportFailed + } + return UpstreamResponse(status: 200, data: Data("{}".utf8)) + } +} + final class NoopCredentialStore: @unchecked Sendable, CredentialStore { private let lock = NSLock() private var savedStorage: [UUID: String] = [:] @@ -327,7 +320,6 @@ final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository { name: input.name, origin: input.origin, tags: input.tags, - groupId: input.groupId, credentialConfigured: !(input.password ?? "").isEmpty, configRevision: 1, createdAt: now, @@ -348,7 +340,6 @@ final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository { if let name = patch.name { instance.name = name } if let origin = patch.origin { instance.origin = origin } if let tags = patch.tags { instance.tags = tags } - if patch.groupId != nil || instance.groupId != nil { instance.groupId = patch.groupId } if let password = patch.password { instance.credentialConfigured = !password.isEmpty } diff --git a/apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift b/apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift index cfa11a7..e557196 100644 --- a/apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift @@ -22,7 +22,6 @@ final class InstanceRepositoryTests: XCTestCase { name: "Lab Alpha", origin: URL(string: "http://192.168.3.55:3000/")!, tags: ["primary", "lab"], - groupId: nil, password: "secret" ) @@ -48,7 +47,7 @@ final class InstanceRepositoryTests: XCTestCase { func testRejectsInvalidOriginDuplicateOriginAndStaleRevision() async throws { let repository = FileInstanceRepository(location: location) let origin = URL(string: "http://192.168.1.20:3000")! - let input = InstanceInput(name: "One", origin: origin, tags: [], groupId: nil, password: nil) + let input = InstanceInput(name: "One", origin: origin, tags: [], password: nil) let first = try await repository.create(input) do { @@ -56,7 +55,6 @@ final class InstanceRepositoryTests: XCTestCase { name: "Nested", origin: URL(string: "http://192.168.1.20:3000/admin")!, tags: [], - groupId: nil, password: nil ) _ = try await repository.create(nested) @@ -80,21 +78,18 @@ final class InstanceRepositoryTests: XCTestCase { } } - func testListFiltersBySearchTagAndGroup() async throws { + func testListFiltersBySearchAndTag() async throws { let repository = FileInstanceRepository(location: location) - let groupId = UUID() _ = try await repository.create(InstanceInput( name: "Lab Alpha", origin: URL(string: "http://192.168.1.10:3000")!, tags: ["production"], - groupId: groupId, password: nil )) _ = try await repository.create(InstanceInput( name: "Home Beta", origin: URL(string: "http://192.168.1.20:3000")!, tags: ["lab"], - groupId: nil, password: nil )) @@ -102,8 +97,8 @@ final class InstanceRepositoryTests: XCTestCase { XCTAssertEqual(search.map(\.name), ["Lab Alpha"]) let tagged = try await repository.list(InstanceQuery(tags: ["lab"])) XCTAssertEqual(tagged.map(\.name), ["Home Beta"]) - let grouped = try await repository.list(InstanceQuery(groupId: groupId)) - XCTAssertEqual(grouped.map(\.name), ["Lab Alpha"]) + let production = try await repository.list(InstanceQuery(tags: ["production"])) + XCTAssertEqual(production.map(\.name), ["Lab Alpha"]) let sorted = try await repository.list(InstanceQuery()) XCTAssertEqual(sorted.map(\.name), ["Home Beta", "Lab Alpha"]) } diff --git a/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift index 6c3ad01..6c929e6 100644 --- a/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift @@ -11,8 +11,8 @@ final class SettingsViewModelTests: XCTestCase { await model.refresh() XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin") - XCTAssertEqual(model.runtime?.marketingVersion, "0.2.1") - XCTAssertEqual(model.runtime?.buildVersion, "6") + XCTAssertEqual(model.runtime?.marketingVersion, "0.2.2") + XCTAssertEqual(model.runtime?.buildVersion, "7") XCTAssertEqual(model.runtime?.platform, "iOS") XCTAssertTrue(model.runtime?.systemVersion.hasPrefix("iOS") ?? false) diff --git a/docs/ios-native-0.2.2.md b/docs/ios-native-0.2.2.md new file mode 100644 index 0000000..a9aa457 --- /dev/null +++ b/docs/ios-native-0.2.2.md @@ -0,0 +1,70 @@ +# iOS Native 0.2.2 + +0.2.2 (7) 把「分组」这个实体从应用里彻底删掉,归类只靠标签;同时完成第二轮配色与图标重构, +界面上不再出现蓝色。页面能力、上游白名单与本地数据布局见现行说明 [docs/ios-native.md](ios-native.md), +本文只记录本次发布的变化。 + +## 本次变更 + +- 分组概念移除:删除分组视图、分组编辑器与分组仓储,实例模型不再有 `groupId`。升级后首次启动 + 一次性删除 `Documents/instance-groups.json` 与偏好键 `fleet.groupMode`,本机不留死数据。 +- 标签接管归类:新增「批量标签」表,批量选择后一次给多台节点贴同一个标签;每个标签显示 + 「未贴 / N/M / 全部」三态覆盖度,点击即在整批上加减,也可以在同一张表里新建标签。卡片与 + 标签条上的标签仍然可点,点一下即加入筛选。 +- 配色重构,全应用零蓝色:主操作、选中态、进度填充一律用石墨墨色,黄铜只作图标点缀;颜色 + 只保留给状态语义(危险红、告警琥珀、成功绿),健康读数回到墨色,深色主题不再整屏泛绿。 + 浅色主题重做为中性冷灰画布加纯白卡片,深色主题为低明度石墨三层。 +- 选中态可见性:主题切换块与状态图例胶囊原先用 10%~12% 色雾表示选中,浅色下几乎看不出来, + 改为实心主色加 `onPrimary` 文字,两套主题都一眼可读,且选中不改变控件尺寸。 +- 应用图标重做:放弃立体卡与投影,改扁平石墨底加黄铜 SIM 面板,三根底部对齐的递增信号条, + 29pt 下轮廓仍可读;面板边长、圆角、切角与信号条间距可用环境变量调参后重新光栅化。 +- 运行概览收紧:检测进度从读数格移进卡片副标题(`刚刚更新 · 已检测 6/6 台`);读数栅格固定 + 八格两行,温度与电量即使整群节点都没上报也保留「不可用」占位,栅格不再在七格与九格之间跳; + 概览量表健康时用墨色,只在告警与危险时上色。 +- 实例详情头部重排:四项仪表从四枚嵌套小卡改为两列发丝线栅格,读数在上、进度在下,缺数据时 + 保留空轨道,数据到位后卡片高度不变,修掉 0.2.1「加载完成后头部比下方窄一圈」的问题;身份摘要 + 去掉内嵌底色,改用顶部发丝线分隔。 +- 字段名补全:详情页里 `name`、`disk_total_bytes`、`disk_available_bytes`、`percent` 四个键 + 此前直接把原始键名渲染到界面上,现补为「设备名称」「存储总量」「可用存储」「使用率」并排进 + 固定顺序。 +- 版本号显示去掉括号:设置页「应用信息」只显示 `0.2.2`。 + +## 文档同步 + +- 新增本文,[docs/ios-native.md](ios-native.md) 同步到 0.2.2:导航表去掉分组视图,节点总览与 + 实例详情两节按实况重写,本地数据表删掉 `instance-groups.json`,设计语言一节改为石墨与墨色 + 中性、颜色只留给状态、不含蓝色,偏离表新增分组一行。 +- 仓库根 README 与 `apps/ios/README.md` 同步版本号、节点入口描述、目录说明与界面约定;界面 + 约定里「主操作只用一种蓝色」按本次结论改写。 + +## 数据与安全 + +与 0.2.1 一致:实例信息保存在应用沙盒本地数据文件,实例密码保存在同一沙盒的私有凭据文件, +不使用 iOS Keychain,登录会话只存在于进程内存。应用仅连接用户添加的节点地址,不上传任何数据。 +本次新增的唯一数据动作是删除遗留的分组文件,不改动实例、标签与凭据内容。 + +## 构建与安装 + +从仓库根目录构建: + +```bash +./scripts/build-ios-ipa.sh +``` + +产物: + +- `build/ipa/MultiSimAdmin.ipa` +- `build/ipa/MultiSimAdmin.ipa.sha256` + +0.2.2 (7) 校验值: + +```text +a8ac7a33aa8dbbeb3197d8131b8c5f77847d1908cbb65beadd8ea6e44c475c12 MultiSimAdmin.ipa +``` + +IPA 目标为 iOS 16.0 及以上,`arm64`,Bundle ID 为 `fun.chick.multi-simadmin`,未签名, +校验方式: + +```bash +cd build/ipa && shasum -a 256 -c MultiSimAdmin.ipa.sha256 +``` diff --git a/docs/ios-native.md b/docs/ios-native.md index b8c30c5..e79bd3b 100644 --- a/docs/ios-native.md +++ b/docs/ios-native.md @@ -3,7 +3,7 @@ 本文是 iPhone 应用的唯一现行说明,内容与仓库代码一致。历史设计稿和执行计划保留在 `docs/superpowers/` 下,其中已被实现推翻的决策在本文第 9 节逐条列出。 -- 当前版本:`0.2.1 (build 6)` +- 当前版本:`0.2.2`(build 7) - 承载分支:`ios-native-ipa` - 运行要求:iOS 16.0 及以上,`arm64`,Bundle ID `fun.chick.multi-simadmin` - 产物:未签名 `MultiSimAdmin.ipa`,由 TrollStore 或用户自有签名流程安装 @@ -20,7 +20,7 @@ Node 控制面仍然独立存在,两者只共享上游接口契约。 | 入口 | 页面 | 内容 | | --- | --- | --- | -| 节点 | `FleetView` | 节点总览、运行概览、标签筛选、分组视图、批量选择、添加与编辑实例 | +| 节点 | `FleetView` | 节点总览、运行概览、标签筛选与批量归类、批量选择、添加与编辑实例 | | 消息 | `MessagesView` | 跨实例短信读取、按实例与方向筛选、搜索、发送、删除 | | 设置 | `SettingsView` | 主题外观、应用信息、本地数据(进入日志中心)、隐私与数据、数据清理 | @@ -32,24 +32,31 @@ Node 控制面仍然独立存在,两者只共享上游接口契约。 ### 节点总览 -- 运行概览:在线率、异常节点、检测进度、今日消息、分组与标签统计,可折叠。 -- 分组视图:按分组折叠成段,段头给出该组健康度与台数,支持展开与折叠。 -- 标签筛选:标签条支持多选叠加,标签上直接显示命中台数,可单个取消或全部清除。 +- 运行概览:在线率与异常节点两枚大字结论、状态分布条与图例、八格两行的群集读数(信号、延迟、 + 存储、流量、CPU、内存、温度、电量),可折叠,折叠时保留前四格。检测进度与更新时间写在卡片 + 副标题里(`刚刚更新 · 已检测 6/6 台`),不占读数格;整群节点没有上报的读数保留「不可用」占位, + 栅格不会在七格与九格之间跳动。 +- 标签归类:标签是唯一归类手段,分组概念已在 0.2.2 移除。标签条支持多选叠加,胶囊上直接显示 + 命中台数,可单个取消或全部清除;卡片内的标签同样可点,点一下即加入筛选。 +- 批量标签:批量选择后一次给多台节点贴同一个标签,每个标签显示「未贴 / N/M / 全部」三态覆盖度, + 点击即在整批上加减;可在同一张表里新建标签。 - 搜索:匹配名称、地址、运营商与标签。 -- 节点卡片:号码、运营商、制式、温度、电量、内存、存储、延迟、信号、固件、运行时间等 - 指标,超出一屏时折叠为「另有 N 项指标」。 +- 节点卡片:顶部四条量表(信号、延迟、CPU、内存)配发丝进度,下面是号码、运营商、设备、存储、 + 温度、电量、运行时间等指标,超出一屏时折叠为「另有 N 项指标」。健康读数不着色,只有越过阈值 + 才出现告警或危险色。 - 批量选择:工具栏为 34pt 图标按钮(全选、删除、退出等高),勾选列独立于名称区域, 选中态不遮挡文字。 -- 实例增删改:地址、名称、标签、分组、访问凭据;删除前确认,并一并清除本地登录信息。 +- 实例增删改:地址、名称、标签、访问凭据;删除前确认,并一并清除本地登录信息。 ### 实例详情 -- 头部卡片:地址、连接状态、四项仪表(信号、延迟、CPU、内存)、身份摘要(号码、运营商、 - 网络、IP)、模块可用度读数、分组与标签。 +- 头部卡片:地址与连接状态、四项仪表(信号、延迟、CPU、内存)、身份摘要(号码、运营商、网络、 + IP)、模块可用度读数、标签。四项仪表排成两列发丝线栅格,读数在上、进度在下,缺数据时保留 + 空轨道,数据到位后卡片高度不变。 - 数据模块:设备、SIM、蜂窝网络、系统资源、CPU、连接状态共 6 个分区,逐模块独立加载, 单模块失败只标记该模块,不影响其余分区。 - 敏感值(IMEI、ICCID 等)默认隐藏,需要显式开启显示,离开页面后自动恢复隐藏。 -- 本机保存的配置:地址、凭据状态、标签、分组,以及登录、登出、重新检测。 +- 本机保存的配置:地址、凭据状态、标签,以及登录、登出、重新检测。 ### 消息中心 @@ -97,22 +104,27 @@ Node 控制面仍然独立存在,两者只共享上游接口契约。 | 文件 | 内容 | | --- | --- | -| `instances.json` | 实例名称、地址、标签、分组、修订号 | -| `instance-groups.json` | 分组定义 | +| `instances.json` | 实例名称、地址、标签、修订号 | | `credentials.json` | 实例访问密码 | | `log-center.json` | 运行日志、连接探测记录、诊断快照缓存 | -主题偏好存在 `UserDefaults`(键 `aiThemeMode`)。登录会话只存在于进程内存,应用退出即消失。 +0.2.1 及更早版本写过的 `instance-groups.json` 与偏好键 `fleet.groupMode` 在升级后的首次启动 +一次性删除。主题偏好存在 `UserDefaults`(键 `aiThemeMode`)。登录会话只存在于进程内存,应用退出即消失。 密码与 Cookie 不写日志、不进导出内容。删除应用即彻底清除本机数据。允许 HTTP 明文访问是 为了兼容局域网 SimAdmin,公网节点应由用户自行通过 HTTPS 反代暴露。 ## 6. 设计语言 -深墨蓝与金色构成品牌色,界面在浅色下使用中性冷灰画布与纯白卡片,深色下使用低明度蓝黑分层, -主操作使用蓝色,危险操作使用红色。控件令牌集中在 `AITheme`:主按钮高 48pt(紧凑 44pt、大号 -56pt)、工具栏按钮高 44pt(宽 104pt)、导航栏图标按钮 34pt 方形(圆角 11pt)、输入框高 52pt、 -卡片圆角 22pt、按钮圆角 16pt、标签为 28pt 胶囊。 -同屏并排控件必须等高,选中态不得改变控件尺寸,卡片内部不再嵌套卡片,正文与标题不使用负字距。 +界面不含蓝色。品牌色是石墨与黄铜:浅色画布中性冷灰 `#F4F5F5`、纯白卡片、墨黑主操作 +`#151819`;深色画布 `#080909`、低明度石墨分层 `#131515` / `#1B1D1E`、近白主操作 `#EEF1F1`。 +颜色只保留给状态语义——危险 `#C12E28` / `#F77169`、告警 `#AA5C0F` / `#E9A63C`、成功 +`#107A56` / `#42C784`,健康读数一律用墨色,避免整屏泛绿或整屏泛红。选中态用实心主色加 +`onPrimary` 文字,不使用低透明度色雾,浅色深色两套主题下都必须一眼可读。 + +控件令牌集中在 `AITheme`:主按钮高 48pt(紧凑 44pt、大号 56pt)、工具栏按钮高 44pt(宽 104pt)、 +导航栏图标按钮 34pt 方形(圆角 11pt)、输入框高 52pt、卡片圆角 22pt、按钮圆角 16pt、标签为 +28pt 胶囊。同屏并排控件必须等高,选中态不得改变控件尺寸,卡片内部不再嵌套卡片——并列读数改用 +发丝线分隔的栅格,正文与标题不使用负字距,字距为 0。 ## 7. 构建与产物 @@ -137,8 +149,10 @@ cd apps/ios && swift Design/generate_app_icon.swift \ Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png ``` -图标几何与 Web 端 favicon 一致:深墨蓝底、金色 SIM 面板(切角在左上)、三根底部对齐的递增 -信号条。`apps/ios/Design/AppIcon.svg` 是同构图参考稿,实际构建使用 PNG。 +图标为扁平构图,不用立体卡与投影:石墨底、黄铜 SIM 面板(切角在左上)、三根底部对齐的递增 +信号条,29pt 尺寸下轮廓仍然可读。`apps/ios/Design/AppIcon.svg` 是同构图参考稿,实际构建使用 +由 `Design/generate_app_icon.swift` 光栅化出的 PNG,面板边长、圆角、切角与信号条间距都可用 +环境变量调整。 ## 8. 验证方式 @@ -169,7 +183,8 @@ xcodebuild -scheme MultiSimAdmin \ | GRDB + SQLite 控制面库 | 沙盒内 JSON 文件 | 去掉第三方依赖,数据结构与备份语义更直观 | | 操作目录、任务、审计、维护、备份 | 未实现 | 高风险写操作与常驻调度不在 iPhone 前台场景内 | | 设备模块 13 项(含通话、eSIM、OTA、WiFi Calling、自动化) | 6 个只读模块 + 短信读写 | 上游只保留有稳定读取价值的接口,其余按需在后续版本评估 | -| 浅灰画布、青绿主按钮 | 深浅双主题、蓝色主操作、金色品牌色 | 0.2.x 视觉重构结论 | +| 浅灰画布、青绿主按钮 | 深浅双主题、石墨墨色主操作、黄铜点缀 | 0.2.x 视觉重构结论,用户明确排除蓝色 | +| 实例分组(分组视图、分组编辑器、分组仓储) | 标签归类 + 批量标签 | 分组与标签职责重复,分组作为实体在 0.2.2 移除 | ## 10. 已确认的移除边界 @@ -182,6 +197,7 @@ xcodebuild -scheme MultiSimAdmin \ ## 11. 文档索引 - [docs/ios-native.md](ios-native.md):本文,现行说明 +- [docs/ios-native-0.2.2.md](ios-native-0.2.2.md):0.2.2 (7) 发布说明与校验值 - [docs/ios-native-0.2.1.md](ios-native-0.2.1.md):0.2.1 (6) 发布说明与校验值 - [docs/ios-native-0.2.0.md](ios-native-0.2.0.md):0.2.0 发布说明 - [docs/superpowers/specs/2026-09-08-ios-native-ipa-design.md](superpowers/specs/2026-09-08-ios-native-ipa-design.md):历史设计稿