feat(ios): unify list cards and loading states
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user