- Show stable group health in a fixed 2x2 metric grid - Add link-chain state to fleet cards and parse hardware/control/SIM/cellular - Replace the wrapped icon with a full-bleed app icon - Bump the IPA build number
811 lines
33 KiB
Swift
811 lines
33 KiB
Swift
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..<min($0 + 2, moduleSections.count)])
|
||
}, id: \.first?.id) { rowSections in
|
||
GridRow {
|
||
ForEach(rowSections, id: \.id) { section in
|
||
moduleIndicator(
|
||
section.title,
|
||
isAvailable: section.values.isEmpty == false
|
||
)
|
||
}
|
||
if rowSections.count == 1 {
|
||
Color.clear
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 headerMetrics: [HeaderMetric] {
|
||
let loading = model.snapshot == nil
|
||
let phoneValue = stringValue(
|
||
section: "sim",
|
||
keys: ["phone_numbers", "phone_number"]
|
||
)
|
||
let firmwareValue = stringValue(
|
||
section: "device",
|
||
keys: ["revision", "firmware_version"]
|
||
)
|
||
let storageValue = storageSummary
|
||
let uptimeValue = stringValue(
|
||
section: "stats",
|
||
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) }
|
||
)
|
||
let deviceValue = stringValue(section: "device", keys: ["model", "name", "device"])
|
||
let carrierValue = stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])
|
||
let networkValue = stringValue(
|
||
section: "network",
|
||
keys: ["network_type", "technology_preference", "technology", "radio_mode"]
|
||
)
|
||
let ipValue = stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"])
|
||
|
||
return [
|
||
HeaderMetric(
|
||
title: "连接状态",
|
||
value: loading ? "--" : model.connectionStatus.label,
|
||
systemImage: "dot.radiowaves.left.and.right",
|
||
tint: loading ? AITheme.muted : statusColor(model.connectionStatus),
|
||
progress: loading ? 0 : (model.connectionStatus == .online ? 1 : 0)
|
||
),
|
||
HeaderMetric(
|
||
title: "号码",
|
||
value: loading ? "--" : phoneValue ?? "无数据",
|
||
systemImage: "simcard",
|
||
tint: loading ? AITheme.muted : AITheme.ink,
|
||
progress: loading ? 0 : (phoneValue == nil ? 0 : 1)
|
||
),
|
||
makeMetric(title: "信号", value: loading ? nil : signal?.value, systemImage: "cellularbars", tint: loading ? nil : signal?.tint, progress: loading ? nil : signal?.progress),
|
||
makeMetric(title: "延迟", value: loading ? nil : latency?.value, systemImage: "speedometer", tint: loading ? nil : latency?.tint, progress: loading ? nil : latency?.progress),
|
||
HeaderMetric(
|
||
title: "CPU",
|
||
value: loading ? "--" : cpu?.value ?? "无数据",
|
||
systemImage: "cpu",
|
||
tint: loading ? AITheme.muted : cpu?.tint ?? AITheme.muted,
|
||
progress: loading ? 0 : cpu?.progress
|
||
),
|
||
HeaderMetric(
|
||
title: "内存",
|
||
value: loading ? "--" : memory?.value ?? "无数据",
|
||
systemImage: "memorychip",
|
||
tint: loading ? AITheme.muted : memory?.tint ?? AITheme.muted,
|
||
progress: loading ? 0 : memory?.progress
|
||
),
|
||
makeMetric(title: "设备", value: loading ? nil : deviceValue, systemImage: "iphone.gen3", tint: loading ? nil : AITheme.ink),
|
||
makeMetric(title: "运营商", value: loading ? nil : carrierValue, systemImage: "antenna.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
|
||
makeMetric(title: "网络", value: loading ? nil : networkValue, systemImage: "dot.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
|
||
makeMetric(title: "IP", value: loading ? nil : ipValue, systemImage: "network", tint: loading ? nil : AITheme.ink),
|
||
makeMetric(title: "固件", value: loading ? nil : firmwareValue, systemImage: "waveform.path.ecg", tint: loading ? nil : AITheme.ink),
|
||
makeMetric(title: "存储", value: loading ? nil : storageValue, systemImage: "internaldrive", tint: loading ? nil : storageTint, progress: loading ? nil : storageProgress),
|
||
makeMetric(title: "运行时间", value: loading ? nil : uptimeValue, systemImage: "clock", tint: loading ? nil : AITheme.ink)
|
||
]
|
||
}
|
||
|
||
private 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)
|
||
.background(AITheme.control)
|
||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
}
|
||
|
||
private struct DetailMetricGrid: View {
|
||
let metrics: [HeaderMetric]
|
||
|
||
private let primaryKeys: Set<String> = ["连接状态", "号码", "信号", "延迟"]
|
||
private let valueKeys: Set<String> = [
|
||
"设备", "运营商", "网络", "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..<min($0 + 2, values.count)])
|
||
},
|
||
id: \.first?.id
|
||
) { rowMetrics in
|
||
GridRow {
|
||
ForEach(rowMetrics) { DetailHeaderMetric(metric: $0) }
|
||
if rowMetrics.count == 1 {
|
||
Color.clear
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func moduleIndicator(_ title: String, isAvailable: Bool) -> 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<String> = ["uptime", "uptime_seconds"]
|
||
private let percentKeys: Set<String> = ["usage", "cpu_percent", "memory_percent"]
|
||
private let byteRateKeys: Set<String> = ["download", "upload"]
|
||
private let byteCountKeys: Set<String> = ["download_bytes", "upload_bytes"]
|
||
private let sensitiveKeys: Set<String> = ["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<String>) -> 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)"
|
||
}
|
||
}
|
||
}
|