import SwiftUI struct InstanceDetailView: View { @StateObject private var model: DeviceViewModel @State private var revealsSensitive = false @State private var showsSensitiveInfo = false init( instance: Instance, groupName: String? = nil, model: DeviceViewModel? = nil ) { _model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance)) self.groupName = groupName } private let groupName: String? var body: some View { ScrollView { VStack(spacing: 12) { headerCard if let errorMessage = model.errorMessage { AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") } if let snapshot = model.snapshot { ForEach(snapshot.sections) { section in sectionCard(section) } } } .padding(16) .padding(.bottom, 8) } .background(AITheme.canvas.ignoresSafeArea()) .navigationTitle(model.instance.name) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .refreshable { await model.refresh() } .task { await model.refresh() } .sheet(isPresented: $showsSensitiveInfo) { SensitiveValueSheet( values: sensitiveValues, isRevealed: $revealsSensitive ) } } 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) } DetailMetricGrid(metrics: headerMetrics) VStack(alignment: .leading, spacing: 10) { HStack { Text("数据模块") .font(.caption.weight(.semibold)) .foregroundStyle(AITheme.muted) Spacer() Text(availableCount.map { "\($0)/\(moduleSections.count)" } ?? "读取中") .font(.caption.weight(.semibold)) .monospacedDigit() .foregroundStyle(availableCount == moduleSections.count ? AITheme.success : AITheme.muted) } Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 9) { ForEach(stride(from: 0, to: moduleSections.count, by: 2).map { Array(moduleSections[$0.. 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) } } } 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) } } } private var availableCount: Int? { guard let snapshot = model.snapshot else { return nil } 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 { AICard(padding: 14) { HStack(spacing: 10) { Image(systemName: "wifi.slash") .font(.subheadline.weight(.semibold)) .foregroundStyle(AITheme.muted) .frame(width: 32, height: 32) .background(AITheme.control) .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(section.title) .font(.subheadline.weight(.semibold)) .foregroundStyle(AITheme.ink) Text("模块未返回数据") .font(.caption) .foregroundStyle(AITheme.muted) } } } } else { AICard(padding: 16) { VStack(alignment: .leading, spacing: 16) { AISectionTitle(section.title, systemImage: sectionIcon(section.id)) Divider() .overlay(AITheme.line) ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in if index > 0 { Divider() .overlay(AITheme.line.opacity(0.6)) } AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value)) } } } } } private func sectionIcon(_ id: String) -> String { switch id { case "device": return "iphone.gen3" case "sim": return "simcard" case "network": return "antenna.radiowaves.left.and.right" case "stats": return "gauge.with.needle" case "cpu": return "cpu" case "connectivity": return "dot.radiowaves.left.and.right" default: return "list.bullet.rectangle" } } private let fieldLabels: [String: String] = [ "model": "型号", "manufacturer": "制造商", "imei": "IMEI", "revision": "固件版本", "online": "在线", "powered": "基带电源", "present": "SIM 在位", "iccid": "ICCID", "imsi": "IMSI", "phone_numbers": "号码", "sms_center": "短信中心", "operator_name": "运营商", "registration_status": "注册状态", "technology_preference": "网络制式", "signal_strength": "信号强度", "brand": "品牌", "meid": "MEID", "phone_number": "号码", "firmware_version": "固件版本", "os_version": "系统版本", "baseband_version": "基带版本", "uptime": "运行时间", "uptime_seconds": "运行时间", "slots": "卡槽数", "active_slots": "活跃数", "active": "已激活", "enabled": "已启用", "operator": "运营商", "carrier": "运营商", "network_type": "接入制式", "technology": "技术", "radio_mode": "网络模式", "signal": "信号强度", "signal_dbm": "信号强度(dBm)", "signal_percent": "信号质量", "rsrp": "RSRP", "rsrq": "RSRQ", "sinr": "SINR", "mcc": "MCC", "mnc": "MNC", "registration": "注册状态", "registration_state": "注册状态", "roaming": "漫游", "airplane_mode": "飞行模式", "ipv4": "IPv4 地址", "ipv6": "IPv6 地址", "ip_address": "IP 地址", "gateway": "网关", "dns": "DNS", "download": "下行速率", "upload": "上行速率", "download_bytes": "下行流量", "upload_bytes": "上行流量", "messages_today": "今日消息数", "calls": "通话数", "usage": "使用率", "temperature": "温度", "max_temperature_c": "最高温度", "cpu_percent": "CPU 使用率", "cpu_load": "CPU 负载", "memory_percent": "内存使用率", "memory_total_mb": "内存总量", "memory_available_mb": "可用内存", "battery_level": "电量", "battery_percent": "电量", "latency": "延迟", "connected": "连接状态", "interface": "网络接口", "ssid": "无线网络", "apn": "APN", "core_count": "核心数", "model_name": "CPU 型号", "state": "连接状态", "latency_ms": "延迟(ms)" ] private let valueOrder: [String] = [ "model", "manufacturer", "brand", "imei", "meid", "revision", "firmware_version", "online", "powered", "present", "slots", "active_slots", "active", "iccid", "imsi", "phone_numbers", "phone_number", "sms_center", "operator_name", "operator", "carrier", "registration_status", "registration_state", "technology_preference", "network_type", "radio_mode", "signal_strength", "signal_dbm", "signal_percent", "rsrp", "rsrq", "sinr", "state", "connected", "latency", "latency_ms", "uptime", "uptime_seconds", "cpu_percent", "cpu_load", "memory_percent", "memory_total_mb", "memory_available_mb", "usage", "temperature", "max_temperature_c", "battery_level", "battery_percent", "download", "upload", "download_bytes", "upload_bytes", "ipv4", "ipv6", "ip_address", "gateway", "dns", "interface", "ssid", "apn", "core_count", "model_name", "messages_today", "calls" ] private let durationKeys: Set = ["uptime", "uptime_seconds"] private let percentKeys: Set = ["usage", "cpu_percent", "memory_percent"] private let byteRateKeys: Set = ["download", "upload"] private let byteCountKeys: Set = ["download_bytes", "upload_bytes"] private let sensitiveKeys: Set = ["imei", "iccid", "imsi", "phone_numbers", "sms_center"] private func sortedValues(_ values: [String: String]) -> [(key: String, value: String)] { values .map { (key: $0.key, value: $0.value) } .sorted { lhs, rhs in let lhsRank = valueOrder.firstIndex(of: lhs.key) ?? Int.max let rhsRank = valueOrder.firstIndex(of: rhs.key) ?? Int.max if lhsRank != rhsRank { return lhsRank < rhsRank } return lhs.key < rhs.key } } private func fieldLabel(_ key: String) -> String { if let exact = fieldLabels[key] { return exact } let parts = key.split(separator: "_").map(String.init) for count in [2, 1] where parts.count >= count { let suffix = parts.suffix(count).joined(separator: "_") if let label = fieldLabels[suffix] { return label } } return parts.joined(separator: " ") } private func display(_ key: String, _ value: String) -> String { if matches(key, durationKeys), let seconds = Double(value), seconds >= 0 { return Duration.seconds(seconds).formatted(.units(allowed: [.days, .hours, .minutes], width: .abbreviated)) } if matches(key, percentKeys), let number = Double(value), number >= 0 { return "\(number.formatted(.number.precision(.fractionLength(0...1))))%" } if matches(key, byteRateKeys), let bytesPerSecond = Double(value), bytesPerSecond >= 0 { return ByteCountFormatter.string(fromByteCount: Int64(bytesPerSecond), countStyle: .decimal) + "/s" } if matches(key, byteCountKeys), let bytes = Double(value), bytes >= 0 { return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .decimal) } if matches(key, ["memory_total_mb"]), let megabytes = Double(value) { return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory) } if matches(key, ["memory_available_mb"]), let megabytes = Double(value) { return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory) } if key.hasSuffix("temperature"), let temperature = Double(value) { return "\(temperature.formatted(.number.precision(.fractionLength(0...1))))°C" } if sensitiveKeys.contains(key), !revealsSensitive { return masked(value) } return value } 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)" } private func matches(_ key: String, _ keys: Set) -> Bool { let parts = key.split(separator: "_").map(String.init) let suffixes = [key] + (1...2).compactMap { count in parts.count >= count ? parts.suffix(count).joined(separator: "_") : nil } 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)" } } }