- 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」。
985 lines
41 KiB
Swift
985 lines
41 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,
|
||
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)"
|
||
}
|
||
}
|
||
}
|