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) { if isUnavailable { unavailableCard } else { headerCard if let errorMessage = model.errorMessage { AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") } if let snapshot = model.snapshot { ForEach(snapshot.sections) { section in sectionCard(section) } } } } .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 isUnavailable: Bool { model.snapshot == nil && model.errorMessage != nil && !model.isLoading } private var unavailableCard: some View { VStack(spacing: 12) { AICard(padding: 16, emphasis: true) { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top, spacing: 12) { Image(systemName: "wifi.slash") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(AITheme.danger) .frame(width: 38, height: 38) .background(AITheme.danger.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) .accessibilityHidden(true) Text("节点无法连接") .font(.headline.weight(.semibold)) .foregroundStyle(AITheme.ink) Spacer(minLength: 8) statusChip(title: "不可达", tint: AITheme.danger) } Text(model.errorMessage ?? "无法加载设备数据。") .font(.subheadline) .foregroundStyle(AITheme.muted) .fixedSize(horizontal: false, vertical: true) Button { Task { await model.refresh() } } label: { Label("重新检测", systemImage: "arrow.clockwise") } .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) .disabled(model.isLoading) } } AICard { VStack(alignment: .leading, spacing: 12) { AISectionTitle("本机保存的配置", systemImage: "slider.horizontal.3") AIKeyValueRow("地址", value: model.instance.origin.absoluteString, monospaced: true) if let groupName { AIKeyValueRow("分组", value: groupName) } AIKeyValueRow("标签", value: model.instance.tags.isEmpty ? "无" : model.instance.tags.joined(separator: "、")) AIKeyValueRow("访问凭据", value: model.instance.credentialConfigured ? "已保存" : "未设置") } } // 配置是否丢失不是用户需要被告知的事,卡片本身已经说明了一切。 } } private var headerCard: some View { AICard(padding: 16, emphasis: true) { VStack(alignment: .leading, spacing: 14) { // 导航栏已经显示实例名,卡片内不再重复,只留地址与状态。 HStack(spacing: 8) { Image(systemName: "globe") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(AITheme.muted) .accessibilityHidden(true) Text(model.instance.origin.absoluteString) .font(.footnote.weight(.medium)) .monospacedDigit() .foregroundStyle(AITheme.ink) .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 8) statusChip() } DetailMetricGrid(metrics: gaugeMetrics) DetailIdentityStrip(items: identitySummary, isLoading: model.isLoading) moduleAvailability if groupName != nil || !model.instance.tags.isEmpty { FlowLayout(spacing: 7) { if let groupName { AITag("分组 · \(groupName)", tint: AITheme.primary) } ForEach(model.instance.tags, id: \.self) { tag in AITag(tag) } } .frame(maxWidth: .infinity, alignment: .leading) } } } } private func statusChip(title: String? = nil, tint overrideTint: Color? = nil) -> some View { let loading = model.isLoading && model.snapshot == nil let tint = overrideTint ?? (loading ? AITheme.muted : statusColor(model.connectionStatus)) let label = title ?? (loading ? "检测中" : model.connectionStatus.label) return HStack(spacing: 5) { Circle() .fill(tint) .frame(width: 7, height: 7) .accessibilityHidden(true) Text(label) .font(.caption.weight(.semibold)) .foregroundStyle(tint) } .padding(.horizontal, 10) .frame(minHeight: 30) .background(tint.opacity(0.12)) .clipShape(Capsule(style: .continuous)) .overlay { Capsule(style: .continuous) .strokeBorder(tint.opacity(0.22)) } .accessibilityLabel("连接状态 \(label)") } /// 模块可用性压成一行读数:六个胶囊和下方分栏标题一一对应,属于纯重复。 /// 只有真的读不到时才展开,直接点名是哪几个模块。 private var moduleAvailability: some View { let unavailable = moduleSections.filter { $0.values.isEmpty }.map(\.title) return VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Text("数据模块") .font(.caption2.weight(.semibold)) .foregroundStyle(AITheme.muted) Spacer() if let available = availableCount { Image(systemName: available == moduleSections.count ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(available == moduleSections.count ? AITheme.success : AITheme.warning) .accessibilityHidden(true) Text("\(available)/\(moduleSections.count) 可用") .font(.caption2.weight(.semibold)) .monospacedDigit() .foregroundStyle(available == moduleSections.count ? AITheme.success : AITheme.warning) } else { Text("读取中") .font(.caption2.weight(.semibold)) .foregroundStyle(AITheme.muted) } } if unavailable.isEmpty == false { Text("未返回:" + unavailable.joined(separator: " · ")) .font(.caption2) .foregroundStyle(AITheme.muted) .frame(maxWidth: .infinity, alignment: .leading) } } } private struct HeaderMetric: Identifiable { let title: String let value: String? let systemImage: String let tint: Color? let progress: Double? var id: String { title } } private var allHeaderMetrics: [HeaderMetric] { let loading = model.isLoading let phoneValue = stringValue( section: "sim", keys: ["phone_numbers", "phone_number"] ) let storageValue = storageSummary let uptimeValue = stringValue( section: "stats", keys: ["uptime_seconds", "uptime", "system_uptime_seconds"] ).flatMap(durationValue) let signal = summaryValue( section: "network", keys: ["signal_percent", "signal_strength"], format: loading ? nil : { "\(Int($0.rounded()))%" }, progress: loading ? nil : { max(0, min(1, $0 / 100)) }, tint: loading ? nil : { signalTint($0) } ) let latency = summaryValue( section: "connectivity", keys: ["latency_ms", "latency"], format: loading ? nil : { "\(Int($0.rounded())) ms" }, progress: loading ? nil : { _ in 1 }, tint: loading ? nil : { latencyTint($0) } ) let cpu = summaryValue( section: "cpu", keys: ["cpu_percent", "cpu_load", "usage"], format: loading ? nil : { "\(Int($0.rounded()))%" }, progress: loading ? nil : { max(0, min(1, $0 / 100)) }, tint: loading ? nil : { resourceTint($0) } ) let memory = summaryValue( section: "stats", keys: ["memory_percent", "usage"], format: loading ? nil : { "\(Int($0.rounded()))%" }, progress: loading ? nil : { max(0, min(1, $0 / 100)) }, tint: loading ? nil : { resourceTint($0) } ) return [ makeMetric( title: "号码", value: loading ? nil : phoneValue ?? "无数据", systemImage: "simcard", tint: loading ? nil : AITheme.ink ), makeMetric(title: "信号", value: loading ? nil : signal?.value, systemImage: "cellularbars", tint: loading ? nil : signal?.tint, progress: loading ? nil : signal?.progress), makeMetric(title: "延迟", value: loading ? nil : latency?.value, systemImage: "speedometer", tint: loading ? nil : latency?.tint, progress: loading ? nil : latency?.progress), makeMetric( title: "CPU", value: loading ? nil : cpu?.value ?? "无数据", systemImage: "cpu", tint: loading ? nil : cpu?.tint ?? AITheme.muted, progress: loading ? nil : cpu?.progress ), makeMetric( title: "内存", value: loading ? nil : memory?.value ?? "无数据", systemImage: "memorychip", tint: loading ? nil : memory?.tint ?? AITheme.muted, progress: loading ? nil : memory?.progress ), makeMetric(title: "存储", value: loading ? nil : storageValue, systemImage: "internaldrive", tint: loading ? nil : storageTint, progress: loading ? nil : storageProgress), makeMetric(title: "运行时间", value: loading ? nil : uptimeValue, systemImage: "clock", tint: loading ? nil : AITheme.ink) ] } // 头部只保留四块带进度语义的量表,其余信息压缩成文本行。 private static let gaugeOrder: [String] = ["信号", "延迟", "CPU", "内存"] private var gaugeMetrics: [HeaderMetric] { let metrics = allHeaderMetrics return Self.gaugeOrder.compactMap { title in metrics.first { $0.title == title } } } private var identitySummary: [(label: String, value: String)] { let metrics = allHeaderMetrics func derived(_ title: String) -> String? { metrics.first { $0.title == title }?.value } // 顶部只留"这是哪张卡、连在哪个网上"四件事; // 设备型号、固件、存储、运行时间属于硬件清单,下方分栏里已经逐条列出。 let pairs: [(String, String?)] = [ ("号码", derived("号码")), ("运营商", stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])), ("网络", stringValue( section: "network", keys: ["network_type", "technology_preference", "technology", "radio_mode"] )), ("IP", stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"])) ] return pairs.compactMap { label, value in guard let value, value.isEmpty == false, value != "无数据" else { return nil } return (label, value) } } private func makeMetric( title: String, value: String?, systemImage: String, tint: Color?, progress: Double? = nil ) -> 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) .aiInsetSurface() .accessibilityElement(children: .combine) } } // 统一两列栅格。此前首行放 4 个指标、其余两列,导致首行指标卡只有下方一半宽。 private struct DetailMetricGrid: View { let metrics: [HeaderMetric] var body: some View { Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { ForEach( stride(from: 0, to: metrics.count, by: 2).map { Array(metrics[$0.. some View { HStack(spacing: 4) { Circle() .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) } // FlowLayout 按固有尺寸排布,这里不能再用 maxWidth: .infinity。 private struct FlowLayout: Layout { let spacing: CGFloat var lineSpacing: CGFloat? private var rowGap: CGFloat { lineSpacing ?? spacing } func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) -> CGSize { let maxWidth = proposal.width ?? .infinity var x: CGFloat = 0 var rowHeight: CGFloat = 0 var totalHeight: CGFloat = 0 var widestRow: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if x > 0, x + size.width > maxWidth { widestRow = max(widestRow, x - spacing) totalHeight += rowHeight + rowGap x = 0 rowHeight = 0 } x += size.width + spacing rowHeight = max(rowHeight, size.height) } widestRow = max(widestRow, x - spacing) totalHeight += rowHeight return CGSize(width: max(0, widestRow), height: max(0, totalHeight)) } 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 + rowGap 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: 10) { AISectionTitle(section.title, systemImage: sectionIcon(section.id)) // 行与行只靠发丝线分隔,纵向节奏交给 row 自己的内边距, // 否则卡片会越拉越长,一屏读不到两组数据。 VStack(alignment: .leading, spacing: 0) { ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in if index > 0 { Divider() .overlay(AITheme.line.opacity(0.6)) .padding(.vertical, 9) } AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value)) } } } } } } 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 "chart.bar.fill" 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)" } } }