Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift
T
chick 36ce791215 polish(ios): 中性化标签与方向胶囊并收紧详情头部间距
- AITag 拆为两类:不传状态色时是中性归类胶囊(tile 底、发丝描边、墨字,可带 10pt 图标),
  传状态色时才用 14% 色雾与 30% 描边。颜色只服务状态语义。
- 详情页标签改用 AITag(tag, systemImage: "tag"),与节点卡片同一枚形状;头部 VStack 间距
  14 -> 12,读数格 minHeight 62 -> 56,加载完成后不再明显小于下方卡片。
- 消息中心「收件 / 发件」由绿/墨配色改回中性胶囊配 arrow.down.left / arrow.up.right,
  绿色重新只表示健康状态。
- 批量标签表在无勾选时给出「先在节点列表勾选要归类的节点」,不再留空白行。
- 文档:0.2.2 增量记录与校验值更新为 0e4ffb74…;版本号后不再带括号,
  0.2.0/0.2.1/0.2.2 与 README、现行说明统一改写为「0.2.x 构建 N」。
2026-09-15 03:56:31 +08:00

985 lines
41 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance))
}
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)
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: 12) {
// 导航栏已经显示实例名,卡片内不再重复,只留地址与状态。
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 model.instance.tags.isEmpty == false {
FlowLayout(spacing: 7) {
ForEach(model.instance.tags, id: \.self) { tag in
AITag(tag, systemImage: "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 }
// 与节点卡片同一规则:只有越过警告线才着色,正常值走墨色。
var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning }
}
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.ink : (value >= 30 ? AITheme.warning : AITheme.danger)
}
private func latencyTint(_ value: Double) -> Color {
value < 300 ? AITheme.ink : (value < 800 ? AITheme.warning : AITheme.danger)
}
private func resourceTint(_ value: Double) -> Color {
value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.ink)
}
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: 11, weight: .semibold))
.foregroundStyle(metric.isAlert ? tint : AITheme.faint)
.accessibilityHidden(true)
Text(title)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
if let value {
Text(value)
.font(.title3.weight(.semibold))
.monospacedDigit()
.foregroundStyle(metric.isAlert ? tint : AITheme.ink)
.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)
}
// 读数在上、进度在下,缺数据时也留一条轨道,数值到位后格子不跳动。
if isProgressVisible {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule(style: .continuous).fill(AITheme.line)
Capsule(style: .continuous)
.fill(metric.isAlert ? tint : AITheme.primary)
.frame(width: max(3, proxy.size.width * CGFloat(min(1, max(0, progress)))))
}
}
.frame(height: 4)
.accessibilityHidden(true)
} else {
Color.clear
.frame(height: 4)
}
}
.frame(maxWidth: .infinity, minHeight: 56, alignment: .topLeading)
.accessibilityElement(children: .combine)
}
}
/// 两列发丝线栅格:量表是头部卡片内的读数,不再各自套一层底色块。
private struct DetailMetricGrid: View {
let metrics: [HeaderMetric]
private var rows: [[HeaderMetric]] {
stride(from: 0, to: metrics.count, by: 2).map {
Array(metrics[$0..<min($0 + 2, metrics.count)])
}
}
var body: some View {
VStack(spacing: 0) {
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, rowMetrics in
if rowIndex > 0 {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
HStack(alignment: .top, spacing: 0) {
ForEach(Array(rowMetrics.enumerated()), id: \.element.id) { columnIndex, metric in
DetailHeaderMetric(metric: metric)
.padding(.leading, columnIndex == 0 ? 0 : 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
if rowMetrics.count < 2 {
Color.clear
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 10)
}
}
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
}
}
// 设备身份信息改为一行摘要,细节交给下方的分区卡片,避免头部堆成十几块同宽小卡。
private struct DetailIdentityStrip: View {
let items: [(label: String, value: String)]
let isLoading: Bool
var body: some View {
if isLoading {
// 首屏还没有数据时用骨架条,避免出现一整列"读取中"。
VStack(alignment: .leading, spacing: 9) {
ForEach(0..<3, id: \.self) { _ in
HStack(spacing: 8) {
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.7))
.frame(width: 42, height: 10)
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.5))
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 10)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityLabel("设备信息读取中")
} else if items.isEmpty == false {
VStack(alignment: .leading, spacing: 6) {
ForEach(items, id: \.label) { item in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(item.label)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.frame(width: 52, alignment: .leading)
Text(item.value)
.font(.caption.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.accessibilityElement(children: .combine)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
.padding(.top, 1)
}
}
}
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)
}
// 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)",
"name": "设备名称",
"disk_total_bytes": "存储总量",
"disk_available_bytes": "可用存储",
"percent": "使用率"
]
private let valueOrder: [String] = [
"model", "manufacturer", "brand", "name", "imei", "meid", "revision", "firmware_version",
"online", "powered", "present", "slots", "active_slots", "active", "iccid", "imsi",
"phone_numbers", "phone_number", "sms_center", "operator_name", "operator", "carrier",
"registration_status", "registration_state", "technology_preference", "network_type",
"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",
"disk_total_bytes", "disk_available_bytes", "percent"
]
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)"
}
}
}