Move diagnostics into settings, remove the app-local notification module, unify the global theme switcher, refine privacy copy and the instance detail experience, and rebuild the app icon.
310 lines
13 KiB
Swift
310 lines
13 KiB
Swift
import SwiftUI
|
||
|
||
struct InstanceDetailView: View {
|
||
@StateObject private var model: DeviceViewModel
|
||
@State private var revealsSensitive = false
|
||
|
||
init(instance: Instance, model: DeviceViewModel? = nil) {
|
||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance))
|
||
}
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
LazyVStack(spacing: 12) {
|
||
header
|
||
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()
|
||
}
|
||
}
|
||
|
||
private var header: some View {
|
||
AICard(padding: 18) {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
HStack(alignment: .top, spacing: 14) {
|
||
Image(systemName: "cpu")
|
||
.font(.system(size: 21, weight: .semibold))
|
||
.foregroundStyle(AITheme.primary)
|
||
.frame(width: 46, height: 46)
|
||
.background(AITheme.primary.opacity(0.11))
|
||
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
|
||
.accessibilityHidden(true)
|
||
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
Text(model.instance.name)
|
||
.font(.title3.weight(.bold))
|
||
.kerning(-0.2)
|
||
.foregroundStyle(AITheme.ink)
|
||
.lineLimit(2)
|
||
Text(model.instance.origin.absoluteString)
|
||
.font(.caption)
|
||
.foregroundStyle(AITheme.muted)
|
||
.lineLimit(2)
|
||
.truncationMode(.middle)
|
||
}
|
||
}
|
||
|
||
HStack(spacing: 10) {
|
||
AITag(revealsSensitive ? "敏感值已显示" : "敏感值已隐藏", tint: AITheme.primary)
|
||
if let availableCount {
|
||
AITag("\(availableCount)/6 模块", tint: AITheme.success)
|
||
}
|
||
}
|
||
|
||
Button {
|
||
withAnimation(.easeOut(duration: 0.18)) {
|
||
revealsSensitive.toggle()
|
||
}
|
||
} label: {
|
||
Label(
|
||
revealsSensitive ? "隐藏敏感值" : "显示敏感值",
|
||
systemImage: revealsSensitive ? "eye.slash" : "eye"
|
||
)
|
||
}
|
||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact, fixedWidth: 112))
|
||
|
||
if model.isLoading {
|
||
AILoadingCard("正在读取设备详情")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var availableCount: Int? {
|
||
guard let snapshot = model.snapshot else { return nil }
|
||
return snapshot.sections.filter { !$0.values.isEmpty }.count
|
||
}
|
||
|
||
@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: 18) {
|
||
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) }
|
||
}
|
||
}
|