feat(ios): unify list cards and loading states

This commit is contained in:
chick
2026-09-10 01:35:12 +08:00
parent 995206ac23
commit c841748971
32 changed files with 1297 additions and 607 deletions
@@ -15,11 +15,7 @@ struct InstanceDetailView: View {
LazyVStack(spacing: 12) {
header
if let errorMessage = model.errorMessage {
AICard {
Text(errorMessage)
.font(.subheadline)
.foregroundStyle(AITheme.warning)
}
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let snapshot = model.snapshot {
ForEach(snapshot.sections) { section in
@@ -31,17 +27,6 @@ struct InstanceDetailView: View {
}
}
.navigationTitle(model.instance.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
revealsSensitive.toggle()
} label: {
Label(revealsSensitive ? "隐藏敏感值" : "显示敏感值",
systemImage: revealsSensitive ? "eye.slash" : "eye")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
}
}
.refreshable {
await model.refresh()
}
@@ -52,12 +37,28 @@ struct InstanceDetailView: View {
private var header: some View {
AICard {
VStack(alignment: .leading, spacing: 8) {
Text("设备详情")
.font(.headline)
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("设备详情", systemImage: "iphone.gen3")
Button {
revealsSensitive.toggle()
} label: {
Label(
revealsSensitive ? "隐藏敏感值" : "显示敏感值",
systemImage: revealsSensitive ? "eye.slash" : "eye"
)
}
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
Text(model.instance.origin.absoluteString)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.lineLimit(2)
.truncationMode(.middle)
if model.isLoading {
Text("正在读取设备能力…")
.font(.subheadline)
@@ -70,24 +71,14 @@ struct InstanceDetailView: View {
private func sectionCard(_ section: DeviceSection) -> some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
Text(section.title)
.font(.headline)
AISectionTitle(section.title, systemImage: "list.bullet.rectangle")
if section.values.isEmpty {
Text("此模块当前不可用或未返回数据。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
} else {
ForEach(section.values.sorted(by: { $0.key < $1.key }), id: \.key) { item in
HStack(alignment: .top, spacing: 10) {
Text(fieldLabel(item.key))
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
Text(display(item.key, item.value))
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.multilineTextAlignment(.trailing)
}
ForEach(sortedValues(section.values), id: \.key) { item in
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
}
}
}
@@ -110,22 +101,148 @@ struct InstanceDetailView: View {
"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 {
fieldLabels[key] ?? key.replacingOccurrences(of: "_", with: " ")
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 {
guard sensitiveKeys.contains(key), !revealsSensitive else { return value }
guard let first = value.first, value.count > 6 else { return String(repeating: "•", count: min(value.count, 8)) }
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) }
}
}