diff --git a/.gitignore b/.gitignore index 491778c..54e667c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,6 @@ coverage/ apps/web/dist/ .hermes/ .local-run/ -artifacts/ +apps/ios/.build/ build/ data/ diff --git a/apps/ios/Assets/AppIcon.svg b/apps/ios/Assets/AppIcon.svg index 8d3d180..0ff9523 100644 --- a/apps/ios/Assets/AppIcon.svg +++ b/apps/ios/Assets/AppIcon.svg @@ -1,42 +1,23 @@ - - - - - - - + + - - + + - - - - - - - - + + + - - + + - - - - - - - - - - - + + diff --git a/apps/ios/Design/AppIcon.svg b/apps/ios/Design/AppIcon.svg index 8d3d180..0ff9523 100644 --- a/apps/ios/Design/AppIcon.svg +++ b/apps/ios/Design/AppIcon.svg @@ -1,42 +1,23 @@ - - - - - - - + + - - + + - - - - - - - - + + + - - + + - - - - - - - - - - - + + diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceModels.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceModels.swift index 48d625b..9cb05c8 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceModels.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceModels.swift @@ -10,6 +10,108 @@ public struct DeviceSnapshot: Equatable, Sendable { public let sections: [DeviceSection] } +public struct DeviceLinkState: Equatable, Sendable { + public init( + hardwareOnline: Bool? = nil, + controlOnline: Bool? = nil, + simPresent: Bool? = nil, + cellularOnline: Bool? = nil + ) { + self.hardwareOnline = hardwareOnline + self.controlOnline = controlOnline + self.simPresent = simPresent + self.cellularOnline = cellularOnline + } + + public var hardwareOnline: Bool? + public var controlOnline: Bool? + public var simPresent: Bool? + public var cellularOnline: Bool? +} + +extension DeviceService { + static var placeholderSections: [DeviceSection] { + [ + DeviceSection(id: "device", title: "设备", values: [:]), + DeviceSection(id: "sim", title: "SIM", values: [:]), + DeviceSection(id: "network", title: "蜂窝网络", values: [:]), + DeviceSection(id: "stats", title: "系统资源", values: [:]), + DeviceSection(id: "cpu", title: "CPU", values: [:]), + DeviceSection(id: "connectivity", title: "连接状态", values: [:]) + ] + } +} + +public struct DeviceSummary: Equatable, Sendable { + public init( + deviceName: String? = nil, + phoneNumber: String? = nil, + operatorName: String? = nil, + networkTechnology: String? = nil, + signalPercent: Int? = nil, + cpuPercent: Double? = nil, + memoryPercent: Double? = nil, + cpuCount: Int? = nil, + temperatureCelsius: Double? = nil, + batteryPercent: Double? = nil, + ipAddress: String? = nil, + latencyMilliseconds: Int? = nil, + connectivity: String? = nil, + firmwareVersion: String? = nil, + totalStorageBytes: Int64? = nil, + availableStorageBytes: Int64? = nil, + downloadRateBytesPerSecond: Double? = nil, + uploadRateBytesPerSecond: Double? = nil, + messagesToday: Int? = nil, + uptimeSeconds: Double? = nil, + linkState: DeviceLinkState = DeviceLinkState() + ) { + self.deviceName = deviceName + self.phoneNumber = phoneNumber + self.operatorName = operatorName + self.networkTechnology = networkTechnology + self.signalPercent = signalPercent + self.cpuPercent = cpuPercent + self.memoryPercent = memoryPercent + self.cpuCount = cpuCount + self.temperatureCelsius = temperatureCelsius + self.batteryPercent = batteryPercent + self.ipAddress = ipAddress + self.latencyMilliseconds = latencyMilliseconds + self.connectivity = connectivity + self.firmwareVersion = firmwareVersion + self.totalStorageBytes = totalStorageBytes + self.availableStorageBytes = availableStorageBytes + self.downloadRateBytesPerSecond = downloadRateBytesPerSecond + self.uploadRateBytesPerSecond = uploadRateBytesPerSecond + self.messagesToday = messagesToday + self.uptimeSeconds = uptimeSeconds + self.linkState = linkState + } + + public var deviceName: String? + public var phoneNumber: String? + public var operatorName: String? + public var networkTechnology: String? + public var signalPercent: Int? + public var cpuPercent: Double? + public var memoryPercent: Double? + public var cpuCount: Int? + public var temperatureCelsius: Double? + public var batteryPercent: Double? + public var ipAddress: String? + public var latencyMilliseconds: Int? + public var connectivity: String? + public var firmwareVersion: String? + public var totalStorageBytes: Int64? + public var availableStorageBytes: Int64? + public var downloadRateBytesPerSecond: Double? + public var uploadRateBytesPerSecond: Double? + public var messagesToday: Int? + public var uptimeSeconds: Double? + public var linkState: DeviceLinkState +} + public enum DeviceServiceError: Error, Equatable { case unauthorized case invalidResponse diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift index 522b55c..048cd85 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift @@ -47,6 +47,34 @@ public struct DeviceService: Sendable { return DeviceSnapshot(sections: sections) } + public func summary(_ instance: Instance) async throws -> DeviceSummary { + let routes: [(id: String, route: UpstreamRoute)] = [ + ("device", .device), + ("sim", .sim), + ("network", .network), + ("stats", .stats), + ("cpu", .statsCpu), + ("connectivity", .connectivity) + ] + + var sections: [(String, [String: String])] = [] + var successful = 0 + for item in routes { + do { + let response = try await request(item.route, instance) + let values = try Self.flatten(Self.dataObject(response.data)) + sections.append((item.id, values)) + successful += 1 + } catch { + continue + } + } + guard successful > 0 else { + throw DeviceServiceError.invalidResponse + } + return Self.summary(from: sections) + } + private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse { var cookie = await sessions.cookie(for: instance.id, origin: instance.origin) do { @@ -86,6 +114,92 @@ public struct DeviceService: Sendable { return root } + static func summary(from sections: [(String, [String: String])]) -> DeviceSummary { + func key(_ sectionID: String, _ names: [String]) -> String? { + guard let section = sections.first(where: { $0.0 == sectionID }) else { return nil } + return names.lazy.compactMap { section.1[$0] }.first { !$0.isEmpty } + } + + func number(_ text: String?) -> Double? { + guard let text = text?.replacingOccurrences(of: "%", with: "") else { return nil } + return Double(text) + } + + func bool(_ text: String?) -> Bool? { + switch text?.lowercased() { + case "是", "true", "yes", "1": return true + case "否", "false", "no", "0": return false + default: return nil + } + } + + let linkState = DeviceLinkState( + hardwareOnline: bool(key("device", ["powered"])), + controlOnline: bool(key("device", ["online"])), + simPresent: sections.contains { $0.0 == "sim" }, + cellularOnline: key("network", ["registration_status"])? + .lowercased() + .hasPrefix("registered") + ) + + return DeviceSummary( + deviceName: key("device", ["model", "name", "device"]), + phoneNumber: key("sim", ["phone_numbers", "phone_number"]), + operatorName: key("network", ["operator_name", "operator", "carrier"]), + networkTechnology: key( + "network", + ["network_type", "technology_preference", "technology", "radio_mode"] + ), + signalPercent: number(key("network", ["signal_percent", "signal_strength"])) + .flatMap { Int($0.rounded()) }, + cpuPercent: number(key("cpu", ["cpu_percent", "cpu_load", "usage"])), + memoryPercent: number(key("stats", ["memory_percent", "usage"])), + cpuCount: number(key("cpu", ["core_count", "cores"])).flatMap { Int($0.rounded()) }, + temperatureCelsius: number(key("stats", ["temperature", "max_temperature_c"])), + batteryPercent: number(key("stats", ["battery_percent", "battery_level"])), + ipAddress: key("network", ["ipv4", "ip_address", "ipv6"]), + latencyMilliseconds: number(key("connectivity", ["latency_ms", "latency"])).flatMap { + Int($0.rounded()) + }, + connectivity: key("connectivity", ["state", "connected", "latency_ms"]), + firmwareVersion: key("device", ["revision", "firmware_version"]), + totalStorageBytes: Self.byteCount( + key("stats", ["disk_total_bytes", "storage_total_bytes", "total_bytes"]) + ), + availableStorageBytes: Self.byteCount( + key("stats", ["disk_available_bytes", "storage_available_bytes", "available_bytes"]) + ), + downloadRateBytesPerSecond: Self.byteRate( + key("connectivity", ["download", "download_bytes_per_second", "rx_rate"]) + ), + uploadRateBytesPerSecond: Self.byteRate( + key("connectivity", ["upload", "upload_bytes_per_second", "tx_rate"]) + ), + messagesToday: number( + key("connectivity", ["messages_today", "messages", "sms_today"]) + ).flatMap { Int($0.rounded()) }, + uptimeSeconds: number( + key("stats", ["uptime_seconds", "uptime", "system_uptime_seconds"]) + ) ?? number(key("stats", ["uptime_uptime_seconds"])) + ?? number(key("stats", ["system_uptime_seconds"])), + linkState: linkState + ) + } + + private static func byteCount(_ text: String?) -> Int64? { + guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else { + return nil + } + return Int64(value.rounded()) + } + + private static func byteRate(_ text: String?) -> Double? { + guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else { + return nil + } + return value + } + static func flatten(_ source: [String: Any], maximum: Int = 40) -> [String: String] { var output: [String: String] = [:] flatten(source, prefix: "", into: &output, remaining: maximum) diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift index badfc97..f6d0517 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift @@ -5,13 +5,16 @@ final class DeviceViewModel: ObservableObject { @Published var snapshot: DeviceSnapshot? @Published var isLoading = false @Published var errorMessage: String? + @Published private(set) var connectionStatus: ConnectionStatus = .unknown let instance: Instance private let service: DeviceService + private let probe: ConnectionProbe? - init(instance: Instance, service: DeviceService) { + init(instance: Instance, service: DeviceService, probe: ConnectionProbe? = nil) { self.instance = instance self.service = service + self.probe = probe } func refresh() async { @@ -24,6 +27,13 @@ final class DeviceViewModel: ObservableObject { } catch { errorMessage = "无法加载设备数据。" } + guard let probe else { + connectionStatus = .offline + isLoading = false + return + } + let result = try? await probe.test(instance) + connectionStatus = result?.status ?? .offline isLoading = false } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift index 5eead1d..5aec34c 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift @@ -3,15 +3,23 @@ import SwiftUI struct InstanceDetailView: View { @StateObject private var model: DeviceViewModel @State private var revealsSensitive = false + @State private var showsSensitiveInfo = false - init(instance: Instance, model: DeviceViewModel? = nil) { + 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 { - LazyVStack(spacing: 12) { - header + VStack(spacing: 12) { + headerCard if let errorMessage = model.errorMessage { AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") } @@ -35,56 +43,460 @@ struct InstanceDetailView: View { .task { await model.refresh() } + .sheet(isPresented: $showsSensitiveInfo) { + SensitiveValueSheet( + values: sensitiveValues, + isRevealed: $revealsSensitive + ) + } } - private var header: some View { - AICard(padding: 18) { - VStack(alignment: .leading, spacing: 16) { - HStack(alignment: .top, spacing: 14) { - Image(systemName: "cpu") - .font(.system(size: 21, weight: .semibold)) - .foregroundStyle(AITheme.primary) - .frame(width: 46, height: 46) - .background(AITheme.primary.opacity(0.11)) - .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) - .accessibilityHidden(true) + private var headerCard: some View { + AICard(padding: 16, emphasis: true) { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + Text(model.instance.name) + .font(.title3.weight(.bold)) + .kerning(-0.2) + .foregroundStyle(AITheme.ink) + .lineLimit(2) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity, alignment: .leading) + Text(model.instance.origin.absoluteString) + .font(.caption) + .foregroundStyle(AITheme.muted) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + } - VStack(alignment: .leading, spacing: 5) { - Text(model.instance.name) - .font(.title3.weight(.bold)) - .kerning(-0.2) - .foregroundStyle(AITheme.ink) - .lineLimit(2) - Text(model.instance.origin.absoluteString) - .font(.caption) + DetailMetricGrid(metrics: headerMetrics) + + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("数据模块") + .font(.caption.weight(.semibold)) .foregroundStyle(AITheme.muted) - .lineLimit(2) - .truncationMode(.middle) + Spacer() + Text(availableCount.map { "\($0)/\(moduleSections.count)" } ?? "读取中") + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(availableCount == moduleSections.count ? AITheme.success : AITheme.muted) + } + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 9) { + ForEach(stride(from: 0, to: moduleSections.count, by: 2).map { + Array(moduleSections[$0.. HeaderMetric { + HeaderMetric(title: title, value: value, systemImage: systemImage, tint: tint, progress: progress) + } + + private var storageSummary: String { + guard let total = numericValue(section: "stats", keys: [ + "disk_total_bytes", "storage_total_bytes", "total_bytes" + ]), let available = numericValue(section: "stats", keys: [ + "disk_available_bytes", "storage_available_bytes", "available_bytes" + ]), total > 0, available >= 0, total >= available else { + return "无数据" + } + return "\(Int((Double(total - available) / Double(total) * 100).rounded()))% 已用" + } + + private var storageTint: Color { + if storageRatio == nil { return AITheme.ink } + return resourceTint(storageRatio! * 100) + } + + private var storageProgress: Double { + storageRatio ?? 0 + } + + private var storageRatio: Double? { + guard let total = numericValue(section: "stats", keys: [ + "disk_total_bytes", "storage_total_bytes", "total_bytes" + ]), let available = numericValue(section: "stats", keys: [ + "disk_available_bytes", "storage_available_bytes", "available_bytes" + ]), total > 0, available >= 0, total >= available else { + return nil + } + return Double(total - available) / Double(total) + } + + private func numericValue( + section: String, + keys: [String] + ) -> Int64? { + stringValue(section: section, keys: keys) + .flatMap { Double($0.replacingOccurrences(of: "%", with: "")) } + .flatMap { Int64($0.rounded()) } + } + + private func durationValue(_ text: String) -> String? { + guard let seconds = Double(text.replacingOccurrences(of: "%", with: "")), seconds >= 0 else { + return nil + } + return Duration.seconds(seconds).formatted( + .units(allowed: [.days, .hours, .minutes], width: .abbreviated) + ) + } + + private func statusColor(_ value: ConnectionStatus) -> Color { + switch value { + case .online: return AITheme.success + case .degraded, .authRequired: return AITheme.warning + case .offline: return AITheme.danger + case .unknown: return AITheme.muted + } + } + + private struct FormattedMetric { + let value: String + let tint: Color + let progress: Double + } + + private func stringValue( + section: String, + keys: [String] + ) -> String? { + guard let values = model.snapshot?.sections.first(where: { $0.id == section })?.values else { + return nil + } + return keys.lazy.compactMap { values[$0] }.first { !$0.isEmpty } + } + + private func summaryValue( + section: String, + keys: [String], + format: ((Double) -> String)?, + progress: ((Double) -> Double)?, + tint: ((Double) -> Color)? + ) -> FormattedMetric? { + guard let snapshot = model.snapshot, + let values = snapshot.sections.first(where: { $0.id == section })?.values + else { return nil } + + let raw = keys.compactMap { values[$0] }.first { !$0.isEmpty } + guard let raw, let number = Double(raw.replacingOccurrences(of: "%", with: "")), number >= 0 else { + return nil + } + guard let format, let progress, let tint else { return nil } + return FormattedMetric( + value: format(number), + tint: tint(number), + progress: progress(number) + ) + } + + private func signalTint(_ value: Double) -> Color { + value >= 60 ? AITheme.success : (value >= 30 ? AITheme.warning : AITheme.danger) + } + + private func latencyTint(_ value: Double) -> Color { + value < 300 ? AITheme.success : (value < 800 ? AITheme.warning : AITheme.danger) + } + + private func resourceTint(_ value: Double) -> Color { + value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.success) + } + + private struct DetailHeaderMetric: View { + let metric: HeaderMetric + + var title: String { metric.title } + var value: String? { metric.value } + var systemImage: String { metric.systemImage } + var tint: Color { metric.tint ?? AITheme.muted } + var progress: Double { metric.progress ?? 0 } + var isProgressVisible: Bool { + metric.title == "连接状态" || metric.progress != nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(tint) + .accessibilityHidden(true) + Text(title) + .font(.caption.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)) + .monospacedDigit() + .foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint) + .lineLimit(1) + .truncationMode(.middle) + .minimumScaleFactor(0.72) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Capsule(style: .continuous) + .fill(AITheme.line.opacity(0.55)) + .frame(maxWidth: .infinity, minHeight: 14, maxHeight: 14) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(11) + .frame(maxWidth: .infinity, minHeight: 76, maxHeight: 76, alignment: .topLeading) + .background(AITheme.control) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .accessibilityElement(children: .combine) + } + } + + private struct DetailMetricGrid: View { + let metrics: [HeaderMetric] + + private let primaryKeys: Set = ["连接状态", "号码", "信号", "延迟"] + private let valueKeys: Set = [ + "设备", "运营商", "网络", "IP", "固件", "存储", "运行时间" + ] + + var body: some View { + let primary = metrics.filter { primaryKeys.contains($0.title) } + let values = metrics.filter { valueKeys.contains($0.title) } + + VStack(alignment: .leading, spacing: 9) { + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { + GridRow { + ForEach(primary) { DetailHeaderMetric(metric: $0) } } } - Button { - withAnimation(.easeOut(duration: 0.18)) { - revealsSensitive.toggle() + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { + ForEach( + stride(from: 0, to: values.count, by: 2).map { + Array(values[$0.. some View { + HStack(spacing: 4) { + Circle() + .fill(isAvailable ? AITheme.success : AITheme.control) + .frame(width: 6, height: 6) + .overlay { + Circle() + .strokeBorder(AITheme.line) } + Text(title) + .font(.caption2.weight(.medium)) + .foregroundStyle(isAvailable ? AITheme.ink : AITheme.muted) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } + + private struct FlowLayout: Layout { + let spacing: CGFloat + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var x: CGFloat = 0 + var height: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > 0, x + size.width > maxWidth { + x = 0 + height += size.height + spacing + } + x += size.width + spacing + height = max(height, size.height) + } + + return CGSize(width: max(0, x - spacing), height: height) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > bounds.minX, x + size.width > bounds.maxX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) } } } @@ -94,6 +506,20 @@ struct InstanceDetailView: View { return snapshot.sections.filter { !$0.values.isEmpty }.count } + private var moduleSections: [DeviceSection] { + model.snapshot?.sections ?? DeviceService.placeholderSections + } + + private var sensitiveValues: [(title: String, value: String)] { + guard let snapshot = model.snapshot else { return [] } + return snapshot.sections.flatMap { section in + section.values + .filter { sensitiveKeys.contains($0.key) } + .sorted { $0.key < $1.key } + .map { (fieldLabel($0.key), $0.value) } + } + } + @ViewBuilder private func sectionCard(_ section: DeviceSection) -> some View { if section.values.isEmpty { @@ -117,7 +543,7 @@ struct InstanceDetailView: View { } } } else { - AICard(padding: 18) { + AICard(padding: 16) { VStack(alignment: .leading, spacing: 16) { AISectionTitle(section.title, systemImage: sectionIcon(section.id)) Divider() @@ -306,4 +732,79 @@ struct InstanceDetailView: View { } return suffixes.contains { keys.contains($0) } } + + private struct SensitiveValueSheet: View { + let values: [(title: String, value: String)] + @Binding var isRevealed: Bool + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 12) { + if values.isEmpty { + AICard { + AIEmptyState( + systemImage: "lock.open", + title: "暂无敏感值", + message: "设备数据加载后,这里会集中显示 IMEI、ICCID 等信息。" + ) + } + } else { + AICard { + VStack(alignment: .leading, spacing: 14) { + Toggle(isOn: $isRevealed) { + VStack(alignment: .leading, spacing: 2) { + Text("显示敏感值") + .font(.subheadline.weight(.semibold)) + Text("离开页面或手动关闭后自动隐藏。") + .font(.caption) + .foregroundStyle(AITheme.muted) + } + } + .toggleStyle(.switch) + .tint(AITheme.primary) + + Divider() + .overlay(AITheme.line.opacity(0.6)) + + ForEach(Array(values.enumerated()), id: \.offset) { index, item in + if index > 0 { + Divider() + .overlay(AITheme.line.opacity(0.6)) + } + AIKeyValueRow( + item.title, + value: isRevealed ? item.value : masked(item.value) + ) + } + } + } + } + } + .padding(16) + .padding(.bottom, 8) + } + .background(AITheme.canvas.ignoresSafeArea()) + .navigationTitle("敏感值") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("完成") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + } + + private func masked(_ value: String) -> String { + guard let first = value.first, value.count > 6 else { + return String(repeating: "•", count: min(value.count, 8)) + } + let tail = value.suffix(4) + return "\(first) •••• \(tail)" + } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift index 21681c5..8941c40 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift @@ -3,6 +3,10 @@ import SwiftUI 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) { _model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel()) @@ -10,21 +14,33 @@ struct FleetView: View { var body: some View { ScrollView { - LazyVStack(spacing: 12) { - searchField - if let errorMessage = model.errorMessage { - AIStatusBanner( - errorMessage, - tint: AITheme.danger, - systemImage: "exclamationmark.triangle" - ) - } - if model.selectionMode { - selectionBar - } - if model.isLoading && model.rows.isEmpty { - AILoadingCard("正在同步实例数据") + LazyVStack(spacing: 12, pinnedViews: .sectionHeaders) { + searchField + if let errorMessage = model.errorMessage { + AIStatusBanner( + errorMessage, + tint: AITheme.danger, + systemImage: "exclamationmark.triangle" + ) + } + if !model.rows.isEmpty { + organizationControls + fleetSummary + } + if model.isLoading && model.rows.isEmpty { + AICard(padding: 16) { + VStack(alignment: .leading, spacing: 12) { + AISkeletonLine(width: 0.38, height: 17) + HStack(spacing: 10) { + AISkeletonCard(height: 78) + AISkeletonCard(height: 78) + } + AISkeletonLine(width: 0.54, height: 15) + AISkeletonLine(width: 0.72, height: 15) + } } + } + if model.rows.isEmpty && !model.isLoading { AICard { AIEmptyState( @@ -34,63 +50,128 @@ struct FleetView: View { ) } } - ForEach(model.rows) { row in - if model.selectionMode { - instanceCard(row) - } else { - NavigationLink { - InstanceDetailView(instance: row.instance) - } label: { - instanceCard(row) - } - .buttonStyle(.plain) - } - } - } - .padding(16) - .padding(.bottom, 8) - } - .aiBottomActionBar { - if !model.selectionMode { - AIActionRow { - Button { - model.selectionMode = true - } label: { - Label("批量选择", systemImage: "checklist") - } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) - - Button { - model.showingForm = true - } label: { - Label("添加实例", systemImage: "plus") - } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) - } - } else { - AIActionRow { - Button { - model.selectionMode = false - model.selectedIDs.removeAll() - } label: { - Label("取消选择", systemImage: "xmark.circle") - } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) - - Button { - showsDeleteDialog = true - } label: { - Label( - model.selectedIDs.isEmpty ? "删除" : "删除 \(model.selectedIDs.count)", - systemImage: "trash" + if model.visibleRows.isEmpty && !model.rows.isEmpty { + AICard { + AIEmptyState( + systemImage: "magnifyingglass", + title: "未找到实例", + message: "换个名称、地址、运营商或标签试试。" ) } - .buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true)) - .disabled(model.selectedIDs.isEmpty) + } + 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) } } + if !model.rows.isEmpty { + Text("共 \(model.rows.count) 台实例 · 显示 \(summaryRows.count) 台") + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(AITheme.muted) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .accessibilityElement(children: .ignore) + } + } + .padding(16) + } + .toolbar { + if model.selectionMode { + ToolbarItemGroup(placement: .navigation) { + Button { + model.selectedIDs = model.selectedIDs.count == model.visibleRows.count + ? [] + : Set(model.visibleRows.map(\.id)) + } label: { + Image(systemName: model.selectedIDs.count == model.visibleRows.count ? "circle" : "checkmark.circle") + } + .accessibilityLabel(model.selectedIDs.count == model.visibleRows.count ? "取消全选" : "全选") + .disabled(model.visibleRows.isEmpty) + + Button { + model.selectionMode = false + model.selectedIDs.removeAll() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("退出批量选择") + } + + ToolbarItemGroup(placement: .primaryAction) { + Button { + showsDeleteDialog = true + } label: { + Image(systemName: "trash") + } + .accessibilityLabel(model.selectedIDs.isEmpty ? "删除" : "删除 \(model.selectedIDs.count) 个实例") + .disabled(model.selectedIDs.isEmpty) + } + } else { + ToolbarItemGroup(placement: .primaryAction) { + Button { + model.showingGroupManager = true + } label: { + Image(systemName: "folder.badge.gearshape") + } + .accessibilityLabel("管理分组") + .disabled(model.isLoading) + + Button { + model.selectionMode = true + } label: { + Image(systemName: "checklist") + } + .accessibilityLabel("批量选择") + .disabled(model.visibleRows.isEmpty) + + Button { + model.showingForm = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("添加实例") + .disabled(model.isLoading) + } + } + } + .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.showingForm) { + InstanceFormView( + model: model, + instance: editingInstance + ) + } + .onChange(of: model.showingForm) { showing in + if !showing { + editingInstance = nil + } } .background(AITheme.canvas.ignoresSafeArea()) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif .navigationTitle("节点总览") .confirmationDialog( "删除已选实例?登录信息也会一并清除。", @@ -108,9 +189,21 @@ struct FleetView: View { .task { await model.refresh() } - .sheet(isPresented: $model.showingForm) { - InstanceFormView(model: model) + } +} + +private extension FleetView { + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() + + private var updatedDetail: String { + guard let lastRefresh = model.lastRefresh else { + return model.isLoading ? "正在读取" : "待刷新" } + return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))" } private var searchField: some View { @@ -119,59 +212,513 @@ struct FleetView: View { } } - private var selectionBar: some View { - AICard { + private var organizationControls: some View { + VStack(alignment: .leading, spacing: 8) { + AISegmentedControl( + "组织方式", + selection: $model.groupMode, + options: FleetGroupMode.allCases.map { ($0, $0.title) } + ) + + } + } + + private var fleetOverviewHeader: some View { + Button { + withAnimation(.easeOut(duration: 0.22)) { + isSummaryCollapsed.toggle() + } + } label: { HStack(spacing: 10) { - Button { - if model.selectedIDs.count == model.rows.count { - model.selectedIDs.removeAll() - } else { - model.selectedIDs = Set(model.rows.map(\.id)) - } - } label: { - Label( - model.selectedIDs.count == model.rows.count ? "取消全选" : "全选", - systemImage: "checkmark.circle" + Image(systemName: "gauge.with.needle") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.primary) + .frame(width: 22, height: 22) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text("运行概览") + .font(.headline.weight(.semibold)) + .kerning(-0.1) + .foregroundStyle(AITheme.ink) + Text(updatedDetail) + .font(.caption2.weight(.medium)) + .foregroundStyle(AITheme.muted) + } + Spacer(minLength: 8) + Image(systemName: isSummaryCollapsed ? "chevron.down" : "chevron.up") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AITheme.muted) + .frame(width: 30, height: 30) + .background(AITheme.control) + .clipShape(Circle()) + .accessibilityHidden(true) + } + .padding(.vertical, 7) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityElement(children: .contain) + .accessibilityLabel(isSummaryCollapsed ? "展开运行概览" : "折叠运行概览") + .accessibilityAddTraits(.isButton) + } + + + 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 summaryRows: [FleetViewModel.Row] { model.visibleRows } + + private var batchGroupTargetIDs: [UUID] { + model.selectedIDs.isEmpty ? [] : Array(model.selectedIDs).sorted { + $0.uuidString < $1.uuidString + } + } + + private var fleetSummary: some View { + AICard(padding: 16) { + VStack(alignment: .leading, spacing: 12) { + fleetOverviewHeader + + HStack(spacing: 10) { + FleetPrimaryStat( + title: "在线率", + value: summaryRows.isEmpty ? "--" : "\(Int((Double(onlineCount) / Double(summaryRows.count) * 100).rounded()))%", + detail: "\(onlineCount)/\(summaryRows.count) 台在线", + systemImage: "checkmark.seal.fill", + tint: summaryRows.isEmpty ? AITheme.muted : (onlineCount == summaryRows.count ? AITheme.success : AITheme.warning) + ) + FleetPrimaryStat( + title: "异常节点", + value: "\(abnormalCount)", + detail: abnormalCount > 0 ? "需要尽快处理" : "无异常节点", + systemImage: abnormalCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.seal.fill", + tint: abnormalCount > 0 ? AITheme.danger : AITheme.success ) } - .buttonStyle( - AIButtonStyle( - kind: .secondary, - size: .regular, - fixedWidth: 96 + + if isSummaryCollapsed { + HStack(spacing: 8) { + FleetHeaderMiniStat( + title: "信号", + value: signalSummary, + tint: signalSummaryTint, + systemImage: "cellularbars" + ) + FleetHeaderMiniStat( + title: "存储", + value: storageSummary, + tint: storageSummaryTint, + systemImage: "internaldrive" + ) + FleetHeaderMiniStat( + title: "分组", + value: "\(groupCount)", + tint: AITheme.primary, + systemImage: "folder" + ) + } + } else { + HStack(alignment: .center, spacing: 10) { + HStack { + Text("节点状态分布") + .font(.caption.weight(.semibold)) + .kerning(0.2) + .foregroundStyle(AITheme.muted) + Spacer() + Text("\(summaryRows.count) 台") + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(AITheme.muted) + } + + Grid(alignment: .leading, horizontalSpacing: 7, verticalSpacing: 0) { + GridRow { + ForEach(statusSegments, id: \.status) { segment in + statusSegmentButton(segment) + } + } + } + + if model.statusFilter != nil { + Button { + model.statusFilter = nil + } label: { + Label("清除状态筛选", systemImage: "xmark.circle.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(AITheme.primary) + } + .buttonStyle(.plain) + .frame(minHeight: 32) + } + } + } + + if !isSummaryCollapsed { + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(stride(from: 0, to: fleetSummaryItems.count, by: 2).map { + Array(fleetSummaryItems[$0.. some View { + let isSelected = model.statusFilter == segment.status + let tint = statusColor(segment.status) + + return Button { + model.statusFilter = isSelected ? nil : segment.status + } label: { + VStack(alignment: .leading, spacing: 6) { + Text("\(segment.count)") + .font(.subheadline.weight(.bold)) + .monospacedDigit() + .foregroundStyle(tint) + .frame(maxWidth: .infinity, alignment: .leading) + Capsule(style: .continuous) + .fill(tint) + .frame(height: 4) + } + .padding(.vertical, 9) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, minHeight: 44) + .background(isSelected ? tint.opacity(0.14) : AITheme.control) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(isSelected ? tint.opacity(0.38) : AITheme.line) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(segment.status.label) \(segment.count) 台") + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton]) + } + .buttonStyle(.plain) + } + + private struct FleetStatusSegment { + let status: ConnectionStatus + let count: Int + + var ratio: Double { max(0, Double(count)) } + } + + private struct FleetGroupDistributionStrip: View { + let grouped: Int + let ungrouped: Int + let groupCount: Int + let tagCount: Int + + private var total: Int { max(1, grouped + ungrouped) } + private var groupedRatio: CGFloat { CGFloat(grouped) / CGFloat(total) } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + DistributionBadge( + title: "分组", + value: "\(groupCount)", + systemImage: "folder", + tint: AITheme.primary + ) + DistributionBadge( + title: "标签", + value: "\(tagCount)", + systemImage: "tag", + tint: AITheme.success + ) + Spacer(minLength: 8) + Text("已分组 \(grouped)/\(grouped + ungrouped)") + .font(.caption2.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(AITheme.muted) + } + + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule(style: .continuous) + .fill(AITheme.control) + Capsule(style: .continuous) + .fill(AITheme.primary.opacity(0.30)) + .frame(width: max(4, proxy.size.width * groupedRatio)) + } + } + .frame(height: 5) + } + .accessibilityElement(children: .combine) + } + } + + private struct DistributionBadge: View { + let title: String + let value: String + let systemImage: String + let tint: Color + + var body: some View { + HStack(spacing: 5) { + Image(systemName: systemImage) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(tint) + .accessibilityHidden(true) + Text("\(title) \(value)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(AITheme.ink) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .padding(.horizontal, 9) + .frame(minHeight: 28) + .background(tint.opacity(0.10)) + .clipShape(Capsule(style: .continuous)) + .overlay { + Capsule(style: .continuous) + .strokeBorder(tint.opacity(0.16)) + } + } + } + + private var statusSegments: [FleetStatusSegment] { + let order: [ConnectionStatus] = [.online, .degraded, .authRequired, .offline, .unknown] + return order.compactMap { status in + let count = summaryRows.filter { $0.status == status }.count + guard count > 0 else { return nil } + return FleetStatusSegment(status: status, count: count) + } + } + + private var fleetSummaryItems: [FleetStatItem] { + var items = [ + FleetStatItem( + title: "节点状态", + value: "\(onlineCount) 在线 · \(abnormalCount) 异常", + systemImage: "server.rack", + tint: abnormalCount > 0 ? AITheme.warning : AITheme.success + ), + FleetStatItem( + title: "检测进度", + value: "\(settledCount)/\(summaryRows.count) 已读取", + 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) + ] + 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 } + + private var abnormalCount: Int { + summaryRows.filter { $0.status != .online && $0.status != .unknown }.count + } + + private var testingCount: Int { summaryRows.filter(\.testing).count } + + private var settledCount: Int { summaryRows.count - testingCount } + + private var signalValues: [Int] { + summaryRows.compactMap(\.signalPercent) + } + + private var signalSummary: String { + formatAverage(signalValues) { "\($0)%" } + } + + private var signalSummaryTint: Color { + colorForAverage(signalValues) { $0 >= 60 ? AITheme.success : ($0 >= 30 ? AITheme.warning : AITheme.danger) } + } + + private var latencyValues: [Int] { + summaryRows.compactMap(\.latencyMilliseconds) + } + + private var latencySummary: String { + formatAverage(latencyValues) { "\(Int($0.rounded())) ms" } + } + + private var latencySummaryTint: Color { + colorForAverage(latencyValues) { $0 < 300 ? AITheme.success : ($0 < 800 ? AITheme.warning : AITheme.danger) } + } + + private var cpuSummary: String { + formatAverage(summaryRows.compactMap(\.cpuPercent)) { "\(Int($0.rounded()))%" } + } + + private var memorySummary: String { + formatAverage(summaryRows.compactMap(\.memoryPercent)) { "\(Int($0.rounded()))%" } + } + + private var loadSummaryTint: Color { + let values = (summaryRows.compactMap(\.cpuPercent) + summaryRows.compactMap(\.memoryPercent)) + return colorForAverage(values) { $0 >= 85 ? AITheme.danger : ($0 >= 65 ? AITheme.warning : AITheme.success) } + } + + private var temperatureSummary: String { + formatAverage(summaryRows.compactMap(\.temperatureCelsius)) { "\(Int($0.rounded()))°C" } + } + + private var temperatureSummaryTint: Color { + colorForAverage(summaryRows.compactMap(\.temperatureCelsius)) { + $0 >= 75 ? AITheme.danger : ($0 >= 60 ? AITheme.warning : AITheme.success) + } + } + + private var batterySummary: String { + formatAverage(summaryRows.compactMap(\.batteryPercent)) { "\(Int($0.rounded()))%" } + } + + private var batterySummaryTint: Color { + colorForAverage(summaryRows.compactMap(\.batteryPercent)) { + $0 <= 20 ? AITheme.danger : ($0 <= 40 ? AITheme.warning : AITheme.success) + } + } + + private var storageSummary: String { + let pairs = storagePairs + guard pairs.isEmpty == false else { return "不可用" } + let used = pairs.map { max(0, $0.total - $0.available) }.reduce(0, +) + let total = pairs.map(\.total).reduce(0, +) + guard total > 0 else { return "不可用" } + return "\(Int((Double(used) / Double(total) * 100).rounded()))% 已用" + } + + private var storageSummaryTint: Color { + guard storagePairs.isEmpty == false else { return AITheme.muted } + let usedBytes = storagePairs.map { max(0, $0.total - $0.available) }.reduce(0, +) + let totalBytes = storagePairs.map(\.total).reduce(0, +) + let ratio = Double(usedBytes) / Double(max(1, totalBytes)) + return colorForRatio(ratio) { + $0 >= 0.90 ? AITheme.danger : ($0 >= 0.75 ? AITheme.warning : AITheme.success) + } + } + + private var storagePairs: [(total: Int64, available: Int64)] { + summaryRows.compactMap { row in + guard let total = row.totalStorageBytes, let available = row.availableStorageBytes else { + return nil + } + return (total: total, available: available) + } + } + + private var trafficSummary: String { + let values = summaryRows.compactMap(\.downloadRateBytesPerSecond) + summaryRows.compactMap(\.uploadRateBytesPerSecond) + guard values.isEmpty == false else { return "不可用" } + return ByteCountFormatter.string(fromByteCount: Int64(values.reduce(0, +)), countStyle: .decimal) + "/s" + } + + private var trafficSummaryTint: Color { + let values = summaryRows.compactMap(\.downloadRateBytesPerSecond) + summaryRows.compactMap(\.uploadRateBytesPerSecond) + return colorForAverage(values) { + $0 >= 50_000_000 ? AITheme.warning : AITheme.success + } + } + + private func colorForRatio(_ value: Double, transform: (Double) -> Color) -> Color { + transform(max(0, min(1, value))) + } + + private func formatAverage(_ values: [Double], transform: (Double) -> String) -> String { + guard values.isEmpty == false else { return "不可用" } + let average = values.reduce(0, +) / Double(values.count) + return transform(average) + } + + private func formatAverage(_ values: [Int], transform: (Double) -> String) -> String { + formatAverage(values.map(Double.init), transform: transform) + } + + private func colorForAverage( + _ values: [Double], + transform: (Double) -> Color + ) -> Color { + guard values.isEmpty == false else { return AITheme.muted } + return transform(values.reduce(0, +) / Double(values.count)) + } + + private func colorForAverage( + _ values: [Int], + transform: (Double) -> Color + ) -> Color { + colorForAverage(values.map(Double.init), transform: transform) + } + private func instanceCard(_ row: FleetViewModel.Row) -> some View { AICard { - HStack(alignment: .top, spacing: 12) { - if model.selectionMode { - Button { - toggle(row.id) - } label: { - Image( - systemName: model.selectedIDs.contains(row.id) - ? "checkmark.circle.fill" : "circle" - ) - .font(.system(size: 20)) - .foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted) - } - .buttonStyle(.plain) - .frame(width: 44, height: 44) - .accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择") - .accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : []) - } - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 7) { HStack(spacing: 8) { + Circle() + .fill(statusColor(row.status)) + .frame(width: 7, height: 7) + .overlay { + Circle() + .strokeBorder(AITheme.line) + } + .accessibilityHidden(true) Text(row.instance.name) .font(.headline) .foregroundStyle(AITheme.ink) @@ -179,21 +726,338 @@ struct FleetView: View { .truncationMode(.middle) .layoutPriority(1) Spacer() - AITag(row.status.label, tint: statusColor(row.status)) + InlinePill( + title: row.status.label, + systemImage: statusIcon(row.status), + tint: statusColor(row.status) + ) } Text(row.instance.origin.absoluteString) - .font(.subheadline) + .font(.caption) .foregroundStyle(AITheme.muted) .lineLimit(1) .truncationMode(.middle) - if !row.instance.tags.isEmpty { - HStack(spacing: 6) { - ForEach(row.instance.tags, id: \.self) { tag in - AITag(tag) - } + } + + FleetStatusStrip( + status: row.status, + statusDetail: row.testing ? "正在读取运行信息" : statusDetail(row) + ) + + if let health = chainHealth(row) { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 6), GridItem(.flexible())], spacing: 6) { + ForEach(health) { item in + FleetLinkState(item: item) } } } + + if model.groupName(for: row.instance.groupId) != nil || !row.instance.tags.isEmpty { + FlowLayout(spacing: 6) { + if let groupName = model.groupName(for: row.instance.groupId) { + InlinePill(title: groupName, systemImage: "folder", tint: AITheme.primary) + } + ForEach(row.instance.tags, id: \.self) { tag in + InlinePill(title: tag, systemImage: "tag", tint: AITheme.muted) + } + } + } + + FleetMetricGrid(metrics: rowMetrics(row)) + } + } + .overlay(alignment: .topLeading) { + if model.selectionMode { + Button { + toggle(row.id) + } label: { + Image( + systemName: model.selectedIDs.contains(row.id) + ? "checkmark.circle.fill" : "circle" + ) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted) + .frame(width: 44, height: 44) + .background(.ultraThinMaterial, in: Circle()) + .overlay { + Circle() + .strokeBorder(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.line) + } + } + .buttonStyle(.plain) + .padding(4) + .accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择") + .accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : []) + } + } + } + + private var summaryColumns: [GridItem] { + [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)] + } + + private func chainHealth(_ row: FleetViewModel.Row) -> [FleetLinkItem]? { + guard let values = row.chainValues else { return nil } + return [ + FleetLinkItem( + title: "硬件", + isAvailable: values.hardwareOnline, + ), + FleetLinkItem( + title: "控制", + isAvailable: values.controlOnline, + ), + FleetLinkItem( + title: "SIM", + isAvailable: values.simPresent, + ), + FleetLinkItem( + title: "蜂窝", + isAvailable: values.cellularOnline, + ), + ] + } + + + private func rowMetrics(_ row: FleetViewModel.Row) -> [FleetMetric] { + var metrics: [FleetMetric] = [ + .init(label: "设备", value: row.deviceName, systemImage: "iphone.gen3"), + .init(label: "运营商", value: row.operatorName, systemImage: "antenna.radiowaves.left.and.right"), + .init(label: "号码", value: row.phoneNumber, systemImage: "simcard"), + .init(label: "制式", value: row.networkTechnology, systemImage: "dot.radiowaves.left.and.right"), + .init( + label: "信号", + value: row.signalPercent.map { "\($0)%" }, + systemImage: "cellularbars", + tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted + ), + .init(label: "IP", value: row.ipAddress, systemImage: "network"), + .init( + label: "延迟", + value: row.latencyMilliseconds.map { "\($0) ms" }, + systemImage: "speedometer", + tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted + ) + ] + + metrics.append( + contentsOf: [ + .init( + label: "CPU", + value: Self.cpuValue(percent: row.cpuPercent, cores: row.cpuCount), + systemImage: "cpu", + tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted + ), + .init( + label: "内存", + value: row.memoryPercent.map { Self.percentText($0) }, + systemImage: "memorychip", + tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted + ), + .init( + label: "温度", + value: row.temperatureCelsius.map { Self.temperatureText($0) }, + systemImage: "thermometer.medium", + tint: row.temperatureCelsius.flatMap(temperatureColor) ?? AITheme.muted + ), + .init( + label: "电量", + value: row.batteryPercent.map { Self.percentText($0) }, + systemImage: "battery.100", + tint: row.batteryPercent.flatMap(batteryColor) ?? AITheme.muted + ) + ] + ) + + metrics.append( + contentsOf: [ + .init(label: "固件", value: row.firmwareVersion, systemImage: "waveform.path.ecg"), + .init(label: "运行时间", value: row.uptimeSeconds.map(Self.durationText), systemImage: "clock"), + .init( + label: "存储", + value: row.storageUsageRatio.map { "\(Int(($0 * 100).rounded()))% 已用" }, + systemImage: "internaldrive", + tint: row.storageUsageRatio.flatMap(storageColor) ?? AITheme.muted + ), + .init( + label: "下行", + value: row.downloadRateBytesPerSecond.map(Self.rateText), + systemImage: "arrow.down" + ), + .init( + label: "上行", + value: row.uploadRateBytesPerSecond.map(Self.rateText), + systemImage: "arrow.up" + ), + .init(label: "今日消息", value: row.messagesToday.map { "\($0) 条" }, systemImage: "message") + ] + ) + + return metrics.filter(\.hasValue) + } + + private static func cpuValue(percent: Double?, cores: Int?) -> String? { + guard percent != nil || cores != nil else { return nil } + let usage = percent.map(Self.percentText) ?? "--" + guard let cores, cores > 0 else { return usage } + return "\(usage) · \(cores) 核" + } + + private static func durationText(_ seconds: Double) -> String { + Duration.seconds(seconds).formatted(.units(allowed: [.days, .hours, .minutes], width: .abbreviated)) + } + + private static func rateText(_ bytesPerSecond: Double) -> String { + ByteCountFormatter.string(fromByteCount: Int64(bytesPerSecond), countStyle: .decimal) + "/s" + } + + private static func percentText(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(0...1))) + "%" + } + + private static func temperatureText(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(0...1))) + "°C" + } + + private func storageColor(_ ratio: Double) -> Color { + ratio >= 0.90 ? AITheme.danger : (ratio >= 0.75 ? AITheme.warning : AITheme.success) + } + + private func signalColor(_ value: Int?) -> Color? { + guard let value else { return nil } + return value >= 60 ? AITheme.success : (value >= 30 ? AITheme.warning : AITheme.danger) + } + + private func resourceColor(_ value: Double) -> Color { + value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.success) + } + + private func temperatureColor(_ value: Double) -> Color { + value >= 75 ? AITheme.danger : (value >= 60 ? AITheme.warning : AITheme.success) + } + + private func batteryColor(_ value: Double) -> Color { + value <= 20 ? AITheme.danger : (value <= 40 ? AITheme.warning : AITheme.success) + } + + private func statusDetail(_ row: FleetViewModel.Row) -> String { + let parts = [ + row.operatorName, + row.deviceName, + row.connectivity, + row.latencyMilliseconds.map { "延迟 \($0) ms" } + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + guard parts.isEmpty == false else { + return row.status == .online ? "连接正常" : "暂无运行信息" + } + return parts.joined(separator: " · ") + } + + private func latencyColor(_ milliseconds: Int?) -> Color? { + guard let milliseconds else { return nil } + 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 .tags, .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 { + instanceCard(row) + .contentShape(Rectangle()) + } else { + NavigationLink { + InstanceDetailView( + instance: row.instance, + groupName: model.groupName(for: row.instance.groupId) + ) + } label: { + instanceCard(row) + } + .buttonStyle(.plain) + .contextMenu { + Button { + editingInstance = row.instance + model.showingForm = true + } label: { + Label("编辑实例", systemImage: "pencil") + } + + Button(role: .destructive) { + Task { await model.delete([row.id]) } + } label: { + Label("删除实例", systemImage: "trash") + } + } } } } @@ -208,6 +1072,16 @@ struct FleetView: View { } } + private func statusIcon(_ status: ConnectionStatus) -> String { + switch status { + case .online: return "checkmark.circle" + case .offline: return "xmark.circle" + case .authRequired: return "lock.circle" + case .degraded: return "exclamationmark.triangle" + case .unknown: return "questionmark.circle" + } + } + private func toggle(_ id: UUID) { if model.selectedIDs.contains(id) { model.selectedIDs.remove(id) @@ -216,3 +1090,536 @@ struct FleetView: View { } } } + +extension FleetView { + private struct FleetMetricGrid: View { + let metrics: [FleetMetric] + + var body: some View { + if metrics.isEmpty { + HStack(spacing: 8) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AITheme.muted) + .accessibilityHidden(true) + Text("运行信息等待节点返回") + .font(.caption) + .foregroundStyle(AITheme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(11) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + .background(AITheme.control) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } else { + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(stride(from: 0, to: metrics.count, by: 2).map { metrics[$0.. CGSize { + let maxWidth = proposal.width ?? .infinity + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + var rowWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > 0, x + size.width > maxWidth { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + rowWidth = 0 + } + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + rowWidth = max(rowWidth, x - spacing) + } + + return CGSize(width: rowWidth, height: max(0, y + rowHeight - (rowHeight > 0 ? 0 : spacing))) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > bounds.minX, x + size.width > bounds.maxX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } + } + + 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 { + if testing > 0 { return AITheme.warning } + return onlineRatio >= 1 ? AITheme.success : (onlineRatio > 0 ? AITheme.warning : AITheme.muted) + } + + private var progress: Double { + onlineRatio + } + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(tint) + .frame(width: 4, height: 24) + 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) + } + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 7) { + GridRow { + FleetHeaderMetric( + title: "节点", + value: "\(total)", + systemImage: "server.rack", + tint: AITheme.ink + ) + FleetHeaderMetric( + title: "在线率", + value: onlineValue, + systemImage: "checkmark.circle", + tint: onlineTint, + placeholder: onlineValue == "不可用" + ) + } + GridRow { + FleetHeaderMetric( + title: "异常率", + value: abnormalValue, + systemImage: "exclamationmark.triangle", + tint: abnormalTint, + placeholder: abnormalValue == "不可用" + ) + FleetHeaderMetric( + title: "检测中", + value: "\(testing)", + systemImage: "clock.arrow.circlepath", + tint: testing > 0 ? AITheme.warning : AITheme.muted, + placeholder: testing == 0 + ) + } + } + + HStack(spacing: 8) { + ProgressView(value: progress) + .progressViewStyle(.linear) + .tint(progressTint) + .frame(height: 4) + Text("\(onlineRatio.formatted(.number.precision(.fractionLength(0))))%") + .font(.caption2.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(progressTint) + .frame(width: 38, alignment: .trailing) + } + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 7) { + GridRow { + FleetHeaderMetric(title: "信号", value: signalValue, systemImage: "cellularbars", tint: signalTint, placeholder: signalValue == "不可用") + FleetHeaderMetric(title: "延迟", value: latencyValue, systemImage: "speedometer", tint: latencyTint, placeholder: latencyValue == "不可用") + } + GridRow { + FleetHeaderMetric(title: "存储", value: storageValue, systemImage: "internaldrive", tint: storageTint, placeholder: storageValue == "不可用") + FleetHeaderMetric(title: "流量", value: trafficValue, systemImage: "arrow.up.arrow.down", tint: trafficTint, placeholder: trafficValue == "不可用") + } + } + } + .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) + } + + private struct FleetHeaderMetric: View { + let title: String + let value: String + let systemImage: String + let tint: Color + var placeholder = false + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 4) { + Image(systemName: systemImage) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(placeholder ? AITheme.muted : tint) + .accessibilityHidden(true) + Text(title) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(AITheme.muted) + .lineLimit(1) + } + Text(value) + .font(.caption2.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(placeholder ? AITheme.muted : AITheme.ink) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + struct FleetMetric: View { + let label: String + let value: String? + let systemImage: String + var tint: Color = AITheme.primary + + var hasValue: Bool { value?.isEmpty == false } + + var body: some View { + if hasValue { + HStack(spacing: 7) { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 16) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AITheme.muted) + .lineLimit(1) + Text(value ?? "暂无") + .font(.caption.weight(.medium)) + .foregroundStyle(AITheme.ink) + .lineLimit(1) + .truncationMode(.middle) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } + } + } + +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift index f5cb838..ffe4ca3 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift @@ -1,4 +1,136 @@ import Foundation +import SwiftUI + +enum FleetGroupMode: String, CaseIterable, Identifiable { + case none + case status + case tags + case groups + + var id: String { rawValue } + + var title: String { + switch self { + case .none: return "平铺" + case .status: return "状态" + case .tags: 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 { @@ -6,34 +138,93 @@ final class FleetViewModel: ObservableObject { let id: UUID var instance: Instance var status: ConnectionStatus + var deviceName: String? + var phoneNumber: String? + var operatorName: String? + var networkTechnology: String? + var signalPercent: Int? + var cpuPercent: Double? + var memoryPercent: Double? + var cpuCount: Int? + var temperatureCelsius: Double? + var batteryPercent: Double? + var ipAddress: String? + var latencyMilliseconds: Int? + var connectivity: String? + var firmwareVersion: String? + var totalStorageBytes: Int64? + var availableStorageBytes: Int64? + var downloadRateBytesPerSecond: Double? + var uploadRateBytesPerSecond: Double? + var messagesToday: Int? + var uptimeSeconds: Double? + var chainValues: DeviceLinkState? var testing: Bool + + var storageUsageRatio: Double? { + guard let total = totalStorageBytes, let available = availableStorageBytes, total > 0 else { + return nil + } + return Double(max(0, total - available)) / Double(total) + } + + init( + id: UUID, + instance: Instance, + status: ConnectionStatus = .unknown, + testing: Bool = true + ) { + self.id = id + self.instance = instance + self.status = status + self.testing = testing + } } @Published var rows: [Row] = [] @Published var searchText = "" + @Published var statusFilter: ConnectionStatus? + @Published var groupMode: FleetGroupMode = { + let stored = UserDefaults.standard.string(forKey: "fleet.groupMode") + return FleetGroupMode(rawValue: stored ?? "") ?? .groups + }() { + didSet { + UserDefaults.standard.set(groupMode.rawValue, forKey: "fleet.groupMode") + } + } @Published var selectedIDs: Set = [] @Published var selectionMode = false @Published var isLoading = false + @Published private(set) var lastRefresh: Date? @Published var errorMessage: String? @Published var showingForm = false + @Published var showingGroupManager = false + @Published private(set) var groups: [InstanceGroup] = [] private let repository: any InstanceRepository private let probe: ConnectionProbe private let credentials: any CredentialStore + private let deviceService: (any DeviceSummaryService)? + private let groupStore: (any InstanceGroupStore)? init( repository: any InstanceRepository, probe: ConnectionProbe, - credentials: any CredentialStore + credentials: any CredentialStore, + deviceService: (any DeviceSummaryService)? = nil, + groupStore: (any InstanceGroupStore)? = nil ) { self.repository = repository self.probe = probe self.credentials = credentials + self.deviceService = deviceService + self.groupStore = groupStore } 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) @@ -42,25 +233,51 @@ final class FleetViewModel: ObservableObject { } await withTaskGroup(of: Void.self) { group in for instance in instances { - group.addTask { [probe] in + group.addTask { [probe, deviceService] in let result = try? await probe.test(instance) - await self.applyProbe(instance.id, result) + let summary = try? await deviceService?.summary(instance) + await self.applyProbe(instance.id, result, summary) } } } } catch { errorMessage = "无法刷新节点列表" } + lastRefresh = Date() isLoading = false } - func add(_ input: InstanceInput) async throws { + @discardableResult + func add(_ input: InstanceInput) async throws -> Instance { let created = try await repository.create(input) if let password = input.password, !password.isEmpty { try credentials.set(instanceID: created.id, password: password) } showingForm = false await refresh() + return created + } + + func update(_ id: UUID, _ input: InstanceInput) async throws { + guard let original = rows.first(where: { $0.id == id }) else { + throw RepositoryError.notFound + } + _ = try await repository.update( + id, + expectedRevision: original.instance.configRevision, + InstancePatch( + name: input.name, + origin: input.origin, + tags: input.tags, + groupId: input.groupId, + password: input.password + ) + ) + if let password = input.password, !password.isEmpty { + try credentials.set(instanceID: id, password: password) + } + showingForm = false + await refresh() } func delete(_ ids: [UUID]) async { @@ -83,9 +300,195 @@ final class FleetViewModel: ObservableObject { await delete(Array(selectedIDs)) } - private func applyProbe(_ id: UUID, _ result: ConnectionResult?) { + @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 { + var failures = 0 + for id in ids { + guard let row = rows.first(where: { $0.id == id }) else { continue } + do { + _ = try await repository.update( + id, + expectedRevision: row.instance.configRevision, + InstancePatch(groupId: groupID) + ) + } catch { + failures += 1 + errorMessage = "更新 \(row.instance.name) 分组失败" + } + } + if failures > 0 { + throw RepositoryError.databaseFailed + } + 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 + } + + private func applyProbe( + _ id: UUID, + _ result: ConnectionResult?, + _ summary: DeviceSummary? + ) { guard let index = rows.firstIndex(where: { $0.id == id }) else { return } rows[index].status = result?.status ?? .unknown + if let summary { + rows[index].deviceName = summary.deviceName + rows[index].phoneNumber = summary.phoneNumber + rows[index].operatorName = summary.operatorName + rows[index].networkTechnology = summary.networkTechnology + rows[index].signalPercent = summary.signalPercent + rows[index].cpuPercent = summary.cpuPercent + rows[index].memoryPercent = summary.memoryPercent + rows[index].cpuCount = summary.cpuCount + rows[index].temperatureCelsius = summary.temperatureCelsius + rows[index].batteryPercent = summary.batteryPercent + rows[index].ipAddress = summary.ipAddress + rows[index].latencyMilliseconds = summary.latencyMilliseconds + rows[index].connectivity = summary.connectivity + rows[index].firmwareVersion = summary.firmwareVersion + rows[index].totalStorageBytes = summary.totalStorageBytes + rows[index].availableStorageBytes = summary.availableStorageBytes + rows[index].downloadRateBytesPerSecond = summary.downloadRateBytesPerSecond + rows[index].uploadRateBytesPerSecond = summary.uploadRateBytesPerSecond + rows[index].messagesToday = summary.messagesToday + rows[index].uptimeSeconds = summary.uptimeSeconds + rows[index].chainValues = summary.linkState + } + if rows[index].latencyMilliseconds == nil { + rows[index].latencyMilliseconds = result?.requestLatencyMilliseconds + } rows[index].testing = false } + + var visibleRows: [Row] { + rows.filter { row in + if let statusFilter, row.status != statusFilter { + return false + } + guard !searchText.isEmpty else { return true } + let haystacks: [String] = [ + row.instance.name, + row.instance.origin.absoluteString, + row.instance.tags.joined(separator: " "), + row.operatorName ?? "", + row.phoneNumber ?? "", + row.networkTechnology ?? "", + row.deviceName ?? "" + ] + let strings: [String] = haystacks + return strings.contains { + $0.localizedCaseInsensitiveContains(searchText) + } + } + } + + 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 .tags: + let tags = Array(Set(rows.flatMap(\.instance.tags))).sorted() + let grouped = tags.map { tag in + FleetGroup( + id: tag, + title: tag, + rows: rows.filter { $0.instance.tags.contains(tag) } + ) + } + let untagged = rows.filter { $0.instance.tags.isEmpty } + return grouped + (untagged.isEmpty + ? [] + : [FleetGroup(id: "__untagged__", title: "未打标签", rows: untagged)]) + case .groups: + var groupedRows: [UUID: [Row]] = [:] + var ungroupedRows: [Row] = [] + 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 { + func summary(_ instance: Instance) async throws -> DeviceSummary +} + +extension DeviceService: DeviceSummaryService {} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift index bfce793..231cff7 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift @@ -3,13 +3,24 @@ import SwiftUI struct InstanceFormView: View { @Environment(\.dismiss) private var dismiss @ObservedObject var model: FleetViewModel + let instance: Instance? @State private var name = "" @State private var originText = "" @State private var tagsText = "" @State private var password = "" + @State private var selectedGroupId: UUID? @State private var validationMessage: String? @State private var isSaving = false + init(model: FleetViewModel, instance: Instance? = nil) { + self.model = model + self.instance = instance + _name = State(initialValue: instance?.name ?? "") + _originText = State(initialValue: instance?.origin.absoluteString ?? "") + _tagsText = State(initialValue: instance?.tags.joined(separator: ", ") ?? "") + _selectedGroupId = State(initialValue: instance?.groupId) + } + private var canSave: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -24,7 +35,13 @@ struct InstanceFormView: View { field("名称", text: $name, placeholder: "Home Node") field("地址", text: $originText, placeholder: "http://192.168.1.10:3000") field("标签", text: $tagsText, placeholder: "home, primary") - field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true) + groupPicker + field( + "实例密码", + text: $password, + placeholder: instance == nil ? "可选,保存在本机沙盒" : "留空则不修改密码", + secure: true + ) } } if let validationMessage { @@ -56,13 +73,37 @@ struct InstanceFormView: View { } } .background(AITheme.canvas.ignoresSafeArea()) - .navigationTitle("添加实例") + .navigationTitle(instance == nil ? "添加实例" : "编辑实例") #if os(iOS) .scrollDismissesKeyboard(.interactively) #endif } } + 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 func field( _ title: String, text: Binding, @@ -111,11 +152,16 @@ struct InstanceFormView: View { name: normalizedName, origin: origin, tags: tags, + groupId: selectedGroupId, password: password.isEmpty ? nil : password ) Task { do { - try await model.add(input) + if let instance { + try await model.update(instance.id, input) + } else { + try await model.add(input) + } } catch { validationMessage = "保存失败,请检查地址是否重复。" } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupEditorView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupEditorView.swift new file mode 100644 index 0000000..d63c754 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupEditorView.swift @@ -0,0 +1,205 @@ +import SwiftUI + +struct InstanceGroupEditorView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var model: FleetViewModel + let targetIDs: [UUID] + let onSaved: () -> Void + + @State private var selectedGroupID: UUID? + @State private var isCreatingGroup = false + @State private var newGroupName = "" + @State private var isWorking = false + @State private var errorMessage: String? + + private var selectedCount: Int { targetIDs.count } + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 12) { + if let errorMessage { + AIStatusBanner( + errorMessage, + tint: AITheme.danger, + systemImage: "exclamationmark.triangle" + ) + } + + AICard(padding: 16) { + VStack(alignment: .leading, spacing: 12) { + AISectionTitle( + "批量归类", + systemImage: "folder.badge.gearshape", + detail: selectedCount == 0 ? "全部实例" : "\(selectedCount) 个实例" + ) + Text("选择一个分组后保存,所选实例会立即移入该分组。") + .font(.footnote) + .foregroundStyle(AITheme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + if isCreatingGroup { + AICard(padding: 16) { + VStack(alignment: .leading, spacing: 10) { + AIFieldLabel("新分组名称") + TextField("例如:家里、外场、测试机", text: $newGroupName) + .textFieldStyle(AIFieldStyle()) + .submitLabel(.done) + } + } + } else { + AICard(padding: 8) { + VStack(spacing: 2) { + groupRow( + title: "未分组", + subtitle: "移出当前分组", + systemImage: "square.dashed", + value: nil, + isSelected: selectedGroupID == nil, + selectionTrait: selectedGroupID == nil + ) + + ForEach(model.groups) { group in + Divider() + .overlay(AITheme.line.opacity(0.55)) + groupRow( + title: group.name, + subtitle: "\(instanceCount(group.id)) 个实例", + systemImage: "folder", + value: group.id, + isSelected: selectedGroupID == group.id, + selectionTrait: selectedGroupID == group.id + ) + } + } + } + } + } + .padding(16) + } + .background(AITheme.canvas.ignoresSafeArea()) + .navigationTitle("实例分组") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button("新建") { + selectedGroupID = nil + isCreatingGroup = true + errorMessage = nil + } + .disabled(isWorking) + } + } + .aiBottomActionBar { + AIActionRow { + Button { + Task { await save() } + } label: { + Label( + selectedGroupID == nil ? "移出分组" : "移入分组", + systemImage: "tray.and.arrow.down" + ) + } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) + .disabled(isWorking || (selectedGroupID == nil && !isCreatingGroup)) + } + } + } + } + + private func groupRow( + title: String, + subtitle: String, + systemImage: String, + value: UUID?, + isSelected: Bool, + selectionTrait: Bool + ) -> some View { + Button { + selectedGroupID = value + isCreatingGroup = false + newGroupName = "" + errorMessage = nil + } label: { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(isSelected ? AITheme.primary : AITheme.muted) + .frame(width: 32, height: 32) + .background((isSelected ? AITheme.primary : AITheme.muted).opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .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) + + Image( + systemName: isSelected + ? "checkmark.circle.fill" + : "circle.dashed" + ) + .font(.system(size: 18)) + .foregroundStyle(isSelected ? AITheme.primary : AITheme.muted) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(minHeight: 54) + .background(isSelected ? AITheme.primary.opacity(0.08) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(title),\(subtitle)") + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton]) + } + .buttonStyle(.plain) + } + + private func instanceCount(_ id: UUID) -> Int { + model.rows.filter { $0.instance.groupId == id }.count + } + + private func save() async { + guard !isWorking else { return } + isWorking = true + errorMessage = nil + + do { + if isCreatingGroup { + let name = newGroupName.trimmingCharacters(in: .whitespacesAndNewlines) + let group = try await model.createGroup(named: name) + selectedGroupID = group.id + isCreatingGroup = false + newGroupName = "" + } + + try await model.assign( + groupID: selectedGroupID, + to: targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs + ) + onSaved() + dismiss() + } catch { + errorMessage = isCreatingGroup + ? "创建分组失败,名称不能为空且不能重复。" + : "保存分组失败,请检查节点后重试。" + } + isWorking = false + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupManagerView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupManagerView.swift new file mode 100644 index 0000000..cd27d24 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceGroupManagerView.swift @@ -0,0 +1,173 @@ +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 + + 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() + + Button { + editingGroup = group + editingName = group.name + validationMessage = nil + } label: { + Image(systemName: "pencil") + } + .buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 44)) + + Button { + Task { + await delete(group) + } + } label: { + Image(systemName: "trash") + } + .buttonStyle(AIButtonStyle(kind: .danger, size: .toolbarInline, fixedWidth: 44)) + .disabled(instanceCount(group.id) > 0 || 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 个字符。") + } + .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 + do { + try await model.deleteGroup(group.id) + } catch { + validationMessage = "删除失败,请稍后重试。" + } + isWorking = false + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index 0c051f9..3f75e66 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -18,7 +18,7 @@ struct MultiSimAdminApp: App { var body: some Scene { WindowGroup { - VStack(spacing: 0) { + Group { switch tab { case .fleet: NavigationStack { FleetView().aiInlineTitle() } @@ -27,6 +27,8 @@ struct MultiSimAdminApp: App { case .settings: NavigationStack { SettingsView().aiInlineTitle() } } + } + .safeAreaInset(edge: .bottom, spacing: 0) { AITabBar(selection: $tab) } .tint(AITheme.primary) diff --git a/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceGroupStore.swift b/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceGroupStore.swift new file mode 100644 index 0000000..1a52a45 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceGroupStore.swift @@ -0,0 +1,130 @@ +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/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png index 72827e6..0051e70 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 1e598e4..4ac8534 100644 --- a/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist +++ b/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist @@ -10,7 +10,7 @@ CFBundleNameMultiSimAdmin CFBundlePackageTypeAPPL CFBundleShortVersionString0.2.0 - CFBundleVersion2 + CFBundleVersion3 LSRequiresIPhoneOS MinimumOSVersion16.0 NSAppTransportSecurity diff --git a/apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift b/apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift index a68ec79..3d502b4 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift @@ -26,10 +26,16 @@ public enum ConnectionStatus: String, Equatable, Sendable { public struct ConnectionResult: Equatable, Sendable { public let status: ConnectionStatus + public let requestLatencyMilliseconds: Int? public let checkedAt: Date - public init(status: ConnectionStatus, checkedAt: Date) { + public init( + status: ConnectionStatus, + requestLatencyMilliseconds: Int? = nil, + checkedAt: Date + ) { self.status = status + self.requestLatencyMilliseconds = requestLatencyMilliseconds self.checkedAt = checkedAt } } @@ -42,21 +48,50 @@ public struct ConnectionProbe: Sendable { } public func test(_ instance: Instance) async throws -> ConnectionResult { + let startedAt = DispatchTime.now() do { let response = try await client.send(.health, baseURL: instance.origin, cookie: nil) + let latency = Self.elapsedMilliseconds(since: startedAt) if response.status == 401 || response.status == 403 { - return ConnectionResult(status: .authRequired, checkedAt: Date()) + return ConnectionResult( + status: .authRequired, + requestLatencyMilliseconds: latency, + checkedAt: Date() + ) } - return ConnectionResult(status: .online, checkedAt: Date()) + return ConnectionResult( + status: .online, + requestLatencyMilliseconds: latency, + checkedAt: Date() + ) } catch { + let latency = Self.elapsedMilliseconds(since: startedAt) switch error { case UpstreamError.unauthorized: - return ConnectionResult(status: .authRequired, checkedAt: Date()) + return ConnectionResult( + status: .authRequired, + requestLatencyMilliseconds: latency, + checkedAt: Date() + ) case UpstreamError.timeout, is URLError: - return ConnectionResult(status: .offline, checkedAt: Date()) + return ConnectionResult( + status: .offline, + requestLatencyMilliseconds: latency, + checkedAt: Date() + ) default: - return ConnectionResult(status: .degraded, checkedAt: Date()) + return ConnectionResult( + status: .degraded, + requestLatencyMilliseconds: latency, + checkedAt: Date() + ) } } } + + private static func elapsedMilliseconds(since startedAt: DispatchTime) -> Int? { + let nanoseconds = DispatchTime.now().uptimeNanoseconds &- startedAt.uptimeNanoseconds + let milliseconds = Double(nanoseconds) / 1_000_000 + return milliseconds.isFinite ? Int(milliseconds.rounded()) : nil + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift index 1b78dcb..52dbc51 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift @@ -5,6 +5,10 @@ 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") ) @@ -19,7 +23,13 @@ enum DefaultContainer { FleetViewModel( repository: FileInstanceRepository(location: documentFileURL("instances.json")), probe: ConnectionProbe(client: SimAdminClient()), - credentials: credentials + credentials: credentials, + deviceService: DeviceService( + client: SimAdminClient(), + sessions: InstanceSessionStore(), + credentials: credentials + ), + groupStore: instanceGroups ) } @@ -64,7 +74,8 @@ enum DefaultContainer { client: SimAdminClient(), sessions: InstanceSessionStore(), credentials: credentials - ) + ), + probe: ConnectionProbe(client: SimAdminClient()) ) } } diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift index 507c10d..8564124 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift @@ -3,16 +3,27 @@ import SwiftUI public struct AICard: View { private let content: Content private let padding: CGFloat + private let emphasis: Bool - public init(padding: CGFloat = 16, @ViewBuilder content: () -> Content) { + public init( + padding: CGFloat = 16, + emphasis: Bool = false, + @ViewBuilder content: () -> Content + ) { self.padding = padding + self.emphasis = emphasis self.content = content() } public var body: some View { content - .padding(padding) - .background(AICard.backgroundGradient) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(padding) + .background( + AICard.backgroundGradient( + raisedTop: emphasis + ) + ) .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)) .shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8) .overlay { @@ -22,10 +33,12 @@ public struct AICard: View { } } -private extension AICard { - static var backgroundGradient: LinearGradient { +extension AICard { + fileprivate static func backgroundGradient(raisedTop: Bool) -> LinearGradient { LinearGradient( - colors: [AITheme.surfaceRaised, AITheme.surface], + colors: raisedTop + ? [AITheme.surfaceRaised, AITheme.surfaceRaised.opacity(0.94)] + : [AITheme.surfaceRaised, AITheme.surface], startPoint: .top, endPoint: .bottom ) @@ -150,35 +163,49 @@ public struct AIStatCard: View { private let value: String private let detail: String? private let tint: Color + private let systemImage: String? - public init(title: String, value: String, detail: String? = nil, tint: Color) { + public init( + title: String, + value: String, + detail: String? = nil, + systemImage: String? = nil, + tint: Color + ) { self.title = title self.value = value self.detail = detail + self.systemImage = systemImage self.tint = tint } public var body: some View { - AICard(padding: 14) { - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(.caption.weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.85) - .foregroundStyle(AITheme.muted) - Text(value) - .font(.title3.weight(.bold)) - .foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint) - .lineLimit(1) - .minimumScaleFactor(0.75) - if let detail { - Text(detail) - .font(.caption) - .foregroundStyle(AITheme.muted) - .lineLimit(2) + AICard(padding: 12) { + HStack(alignment: .center, spacing: 10) { + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 32, height: 32) + .background(tint.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .accessibilityHidden(true) } + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.85) + .foregroundStyle(AITheme.muted) + Text(value) + .font(.headline.weight(.semibold)) + .foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) } } } @@ -446,6 +473,7 @@ public struct AIKeyValueRow: View { .font(.subheadline) .foregroundStyle(AITheme.muted) .lineLimit(2) + .layoutPriority(1) Spacer(minLength: 12) Text(value) .font(.subheadline.weight(.medium)) @@ -455,6 +483,7 @@ public struct AIKeyValueRow: View { .truncationMode(.middle) .minimumScaleFactor(0.85) .textSelection(.enabled) + .layoutPriority(2) } .accessibilityElement(children: .combine) } @@ -600,8 +629,7 @@ extension View { } func aiBottomActionBar(@ViewBuilder content: () -> Content) -> some View { - VStack(spacing: 0) { - self + safeAreaInset(edge: .bottom, spacing: 0) { content() .padding(.horizontal, 16) .padding(.top, 12) diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AISkeleton.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AISkeleton.swift new file mode 100644 index 0000000..3ee78c1 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AISkeleton.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct AISkeletonLine: View { + let width: CGFloat? + let height: CGFloat + + init(width: CGFloat? = nil, height: CGFloat) { + self.width = width + self.height = height + } + + init(width fraction: CGFloat, height: CGFloat) { + self.init(width: nil, height: height) + self.fraction = fraction + } + + private var fraction: CGFloat? + + var body: some View { + Group { + if let fraction { + GeometryReader { proxy in + Capsule(style: .continuous) + .frame(width: max(0, proxy.size.width * fraction)) + } + } else if let width { + Capsule(style: .continuous) + .frame(width: width) + } + } + .frame(height: height) + .background(AISkeletonFill()) + } +} + +struct AISkeletonCard: View { + let height: CGFloat + + var body: some View { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .frame(height: height) + .background(AISkeletonFill()) + } +} + +private struct AISkeletonFill: View { + var body: some View { + Rectangle() + .fill(AITheme.control) + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/DeviceServiceTests.swift b/apps/ios/Tests/MultiSimAdminTests/DeviceServiceTests.swift index fe25c97..c9c754e 100644 --- a/apps/ios/Tests/MultiSimAdminTests/DeviceServiceTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/DeviceServiceTests.swift @@ -22,6 +22,28 @@ final class DeviceServiceTests: XCTestCase { XCTAssertEqual(snapshot.sections[5].values["state"], "connected") } + func testBuildsRunningSummaryFromUpstreamSections() async throws { + let store = InstanceSessionStore() + let client = DeviceTestClient() + let service = DeviceService(client: client, sessions: store) + let instance = Self.instance() + await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token") + + let summary = try await service.summary(instance) + + XCTAssertEqual(summary.deviceName, "SIMBox 8") + XCTAssertEqual(summary.phoneNumber, "13800138000") + XCTAssertEqual(summary.operatorName, "中国移动") + XCTAssertEqual(summary.connectivity, "connected") + XCTAssertEqual(summary.firmwareVersion, "2.4.1") + XCTAssertEqual(summary.totalStorageBytes, 1024) + XCTAssertEqual(summary.availableStorageBytes, 512) + XCTAssertEqual(summary.downloadRateBytesPerSecond, 128) + XCTAssertEqual(summary.uploadRateBytesPerSecond, 64) + XCTAssertEqual(summary.messagesToday, 12) + XCTAssertEqual(summary.uptimeSeconds, 86400) + } + private static func instance() -> Instance { Instance( id: UUID(), @@ -43,12 +65,12 @@ private final class DeviceTestClient: SimAdminClientProtocol, @unchecked Sendabl throw UpstreamError.unauthorized } let payload: [UpstreamRoute: String] = [ - .device: #"{"data":{"model":"SIMBox 8","online":true}}"#, + .device: #"{"data":{"model":"SIMBox 8","online":true,"firmware_version":"2.4.1"}}"#, .sim: #"{"data":{"phone_numbers":["13800138000"],"iccid":"123"}}"#, .network: #"{"data":{"operator_name":"中国移动","registration_status":"registered"}}"#, - .stats: #"{"data":{"uptime":{"uptime_seconds":86400}}}"#, + .stats: #"{"data":{"uptime":{"uptime_seconds":86400},"disk_total_bytes":1024,"disk_available_bytes":512}}"#, .statsCpu: #"{"data":{"core_count":4,"model_name":"ARM CPU"}}"#, - .connectivity: #"{"data":{"state":"connected","latency_ms":21}}"# + .connectivity: #"{"data":{"state":"connected","latency_ms":21,"download":128,"upload":64,"messages_today":12}}"# ] guard let json = payload[route] else { throw UpstreamError.notSupported diff --git a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift index 00d1625..0f78a6d 100644 --- a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift @@ -28,6 +28,117 @@ final class FleetViewModelTests: XCTestCase { XCTAssertEqual(model.rows.map(\.instance.name), ["Alpha", "Zulu"]) XCTAssertEqual(model.rows.map(\.status), [.online, .online]) + XCTAssertTrue(model.rows.allSatisfy { $0.latencyMilliseconds != nil }) + } + + func testVisibleRowsAndGroupModesUseFilteredResults() async throws { + let repository = MemoryInstanceRepository() + let model = FleetViewModel( + repository: repository, + probe: ConnectionProbe(client: AlwaysOnlineClient()), + credentials: NoopCredentialStore() + ) + try await model.add(InstanceInput(name: "Home", origin: URL(string: "http://192.168.1.70:3000")!, tags: ["home"])) + try await model.add(InstanceInput(name: "Lab", origin: URL(string: "http://192.168.1.71:3000")!, tags: [])) + + await model.refresh() + model.searchText = "home" + + XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"]) + XCTAssertEqual(model.groupedRows.flatMap(\.rows).map(\.instance.name), ["Home"]) + + model.searchText = "" + model.groupMode = .tags + XCTAssertEqual(model.groupedRows.map(\.title), ["home", "未打标签"]) + XCTAssertEqual(model.groupedRows.map(\.rows.count), [1, 1]) + + model.groupMode = .status + XCTAssertEqual(model.groupedRows.map(\.title), ["在线"]) + } + + func testGroupModeKeepsUngroupedInstancesVisible() async throws { + let repository = MemoryInstanceRepository() + 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, + probe: ConnectionProbe(client: AlwaysOnlineClient()), + credentials: NoopCredentialStore(), + groupStore: groupStore + ) + + 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 + )) + + 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 + )) + + 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() + let model = FleetViewModel( + repository: repository, + probe: ConnectionProbe(client: AlwaysOnlineClient()), + credentials: NoopCredentialStore(), + groupStore: groupStore + ) + let first = try await model.add(InstanceInput( + name: "First", + origin: URL(string: "http://192.168.1.90:3000")! + )) + 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: "外场") + + try await model.assign(groupID: group.id, to: [first.id, second.id]) + XCTAssertEqual(model.rows.map(\.instance.groupId), [group.id, group.id]) + + try await model.assign(groupID: nil, to: [first.id]) + XCTAssertEqual(model.rows.first?.instance.groupId, nil) + XCTAssertEqual(model.rows.last?.instance.groupId, group.id) } func testAddValidatesPasswordAndDeleteRemovesCredential() async throws { @@ -153,8 +264,25 @@ final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository { return instance } - func update(_: UUID, expectedRevision: Int, _: InstancePatch) async throws -> Instance { - throw RepositoryError.notFound + func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance { + guard let index = items.firstIndex(where: { $0.id == id }) else { + throw RepositoryError.notFound + } + guard items[index].configRevision == expectedRevision else { + throw RepositoryError.revisionConflict + } + var instance = items[index] + 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 + } + instance.updatedAt = Date() + instance.configRevision += 1 + items[index] = instance + return instance } func delete(_ id: UUID, expectedRevision: Int) async throws { diff --git a/scripts/build-ios-ipa.sh b/scripts/build-ios-ipa.sh index 484d214..055b2ad 100755 --- a/scripts/build-ios-ipa.sh +++ b/scripts/build-ios-ipa.sh @@ -14,7 +14,8 @@ xcodebuild -scheme "$APP_NAME" \ -derivedDataPath "$DERIVED_ROOT" \ build -rm -rf "$BUILD_ROOT" +mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app" +find "$BUILD_ROOT" -mindepth 1 -maxdepth 1 -exec rm -rf {} + mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app" cp "$DERIVED_ROOT/Build/Products/Release-iphoneos/$APP_NAME" \ "$BUILD_ROOT/Payload/$APP_NAME.app/$APP_NAME"