Unify bottom actions, selection controls, navigation structure, spacing, and theme colors across iOS pages. Replace the app icon with a higher-contrast native-style asset and include its source SVG.
243 lines
9.8 KiB
Swift
243 lines
9.8 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 {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
AISectionTitle(
|
||
"设备详情",
|
||
systemImage: "iphone.gen3",
|
||
actionTitle: revealsSensitive ? "隐藏敏感值" : "显示敏感值",
|
||
actionSystemImage: revealsSensitive ? "eye.slash" : "eye"
|
||
) {
|
||
revealsSensitive.toggle()
|
||
}
|
||
Text(model.instance.origin.absoluteString)
|
||
.font(.subheadline)
|
||
.foregroundStyle(AITheme.muted)
|
||
.lineLimit(2)
|
||
.truncationMode(.middle)
|
||
if model.isLoading {
|
||
Text("正在读取设备详情…")
|
||
.font(.subheadline)
|
||
.foregroundStyle(AITheme.muted)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func sectionCard(_ section: DeviceSection) -> some View {
|
||
AICard {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
AISectionTitle(section.title, systemImage: "list.bullet.rectangle")
|
||
if section.values.isEmpty {
|
||
Text("此模块当前不可用或未返回数据。")
|
||
.font(.subheadline)
|
||
.foregroundStyle(AITheme.muted)
|
||
} else {
|
||
ForEach(sortedValues(section.values), id: \.key) { item in
|
||
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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) }
|
||
}
|
||
}
|