fix(ios): improve fleet overview and app identity
- Show stable group health in a fixed 2x2 metric grid - Add link-chain state to fleet cards and parse hardware/control/SIM/cellular - Replace the wrapped icon with a full-bleed app icon - Bump the IPA build number
This commit is contained in:
@@ -10,6 +10,108 @@ public struct DeviceSnapshot: Equatable, Sendable {
|
||||
public let sections: [DeviceSection]
|
||||
}
|
||||
|
||||
public struct DeviceLinkState: Equatable, Sendable {
|
||||
public init(
|
||||
hardwareOnline: Bool? = nil,
|
||||
controlOnline: Bool? = nil,
|
||||
simPresent: Bool? = nil,
|
||||
cellularOnline: Bool? = nil
|
||||
) {
|
||||
self.hardwareOnline = hardwareOnline
|
||||
self.controlOnline = controlOnline
|
||||
self.simPresent = simPresent
|
||||
self.cellularOnline = cellularOnline
|
||||
}
|
||||
|
||||
public var hardwareOnline: Bool?
|
||||
public var controlOnline: Bool?
|
||||
public var simPresent: Bool?
|
||||
public var cellularOnline: Bool?
|
||||
}
|
||||
|
||||
extension DeviceService {
|
||||
static var placeholderSections: [DeviceSection] {
|
||||
[
|
||||
DeviceSection(id: "device", title: "设备", values: [:]),
|
||||
DeviceSection(id: "sim", title: "SIM", values: [:]),
|
||||
DeviceSection(id: "network", title: "蜂窝网络", values: [:]),
|
||||
DeviceSection(id: "stats", title: "系统资源", values: [:]),
|
||||
DeviceSection(id: "cpu", title: "CPU", values: [:]),
|
||||
DeviceSection(id: "connectivity", title: "连接状态", values: [:])
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
public struct DeviceSummary: Equatable, Sendable {
|
||||
public init(
|
||||
deviceName: String? = nil,
|
||||
phoneNumber: String? = nil,
|
||||
operatorName: String? = nil,
|
||||
networkTechnology: String? = nil,
|
||||
signalPercent: Int? = nil,
|
||||
cpuPercent: Double? = nil,
|
||||
memoryPercent: Double? = nil,
|
||||
cpuCount: Int? = nil,
|
||||
temperatureCelsius: Double? = nil,
|
||||
batteryPercent: Double? = nil,
|
||||
ipAddress: String? = nil,
|
||||
latencyMilliseconds: Int? = nil,
|
||||
connectivity: String? = nil,
|
||||
firmwareVersion: String? = nil,
|
||||
totalStorageBytes: Int64? = nil,
|
||||
availableStorageBytes: Int64? = nil,
|
||||
downloadRateBytesPerSecond: Double? = nil,
|
||||
uploadRateBytesPerSecond: Double? = nil,
|
||||
messagesToday: Int? = nil,
|
||||
uptimeSeconds: Double? = nil,
|
||||
linkState: DeviceLinkState = DeviceLinkState()
|
||||
) {
|
||||
self.deviceName = deviceName
|
||||
self.phoneNumber = phoneNumber
|
||||
self.operatorName = operatorName
|
||||
self.networkTechnology = networkTechnology
|
||||
self.signalPercent = signalPercent
|
||||
self.cpuPercent = cpuPercent
|
||||
self.memoryPercent = memoryPercent
|
||||
self.cpuCount = cpuCount
|
||||
self.temperatureCelsius = temperatureCelsius
|
||||
self.batteryPercent = batteryPercent
|
||||
self.ipAddress = ipAddress
|
||||
self.latencyMilliseconds = latencyMilliseconds
|
||||
self.connectivity = connectivity
|
||||
self.firmwareVersion = firmwareVersion
|
||||
self.totalStorageBytes = totalStorageBytes
|
||||
self.availableStorageBytes = availableStorageBytes
|
||||
self.downloadRateBytesPerSecond = downloadRateBytesPerSecond
|
||||
self.uploadRateBytesPerSecond = uploadRateBytesPerSecond
|
||||
self.messagesToday = messagesToday
|
||||
self.uptimeSeconds = uptimeSeconds
|
||||
self.linkState = linkState
|
||||
}
|
||||
|
||||
public var deviceName: String?
|
||||
public var phoneNumber: String?
|
||||
public var operatorName: String?
|
||||
public var networkTechnology: String?
|
||||
public var signalPercent: Int?
|
||||
public var cpuPercent: Double?
|
||||
public var memoryPercent: Double?
|
||||
public var cpuCount: Int?
|
||||
public var temperatureCelsius: Double?
|
||||
public var batteryPercent: Double?
|
||||
public var ipAddress: String?
|
||||
public var latencyMilliseconds: Int?
|
||||
public var connectivity: String?
|
||||
public var firmwareVersion: String?
|
||||
public var totalStorageBytes: Int64?
|
||||
public var availableStorageBytes: Int64?
|
||||
public var downloadRateBytesPerSecond: Double?
|
||||
public var uploadRateBytesPerSecond: Double?
|
||||
public var messagesToday: Int?
|
||||
public var uptimeSeconds: Double?
|
||||
public var linkState: DeviceLinkState
|
||||
}
|
||||
|
||||
public enum DeviceServiceError: Error, Equatable {
|
||||
case unauthorized
|
||||
case invalidResponse
|
||||
|
||||
@@ -47,6 +47,34 @@ public struct DeviceService: Sendable {
|
||||
return DeviceSnapshot(sections: sections)
|
||||
}
|
||||
|
||||
public func summary(_ instance: Instance) async throws -> DeviceSummary {
|
||||
let routes: [(id: String, route: UpstreamRoute)] = [
|
||||
("device", .device),
|
||||
("sim", .sim),
|
||||
("network", .network),
|
||||
("stats", .stats),
|
||||
("cpu", .statsCpu),
|
||||
("connectivity", .connectivity)
|
||||
]
|
||||
|
||||
var sections: [(String, [String: String])] = []
|
||||
var successful = 0
|
||||
for item in routes {
|
||||
do {
|
||||
let response = try await request(item.route, instance)
|
||||
let values = try Self.flatten(Self.dataObject(response.data))
|
||||
sections.append((item.id, values))
|
||||
successful += 1
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
guard successful > 0 else {
|
||||
throw DeviceServiceError.invalidResponse
|
||||
}
|
||||
return Self.summary(from: sections)
|
||||
}
|
||||
|
||||
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
||||
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
do {
|
||||
@@ -86,6 +114,92 @@ public struct DeviceService: Sendable {
|
||||
return root
|
||||
}
|
||||
|
||||
static func summary(from sections: [(String, [String: String])]) -> DeviceSummary {
|
||||
func key(_ sectionID: String, _ names: [String]) -> String? {
|
||||
guard let section = sections.first(where: { $0.0 == sectionID }) else { return nil }
|
||||
return names.lazy.compactMap { section.1[$0] }.first { !$0.isEmpty }
|
||||
}
|
||||
|
||||
func number(_ text: String?) -> Double? {
|
||||
guard let text = text?.replacingOccurrences(of: "%", with: "") else { return nil }
|
||||
return Double(text)
|
||||
}
|
||||
|
||||
func bool(_ text: String?) -> Bool? {
|
||||
switch text?.lowercased() {
|
||||
case "是", "true", "yes", "1": return true
|
||||
case "否", "false", "no", "0": return false
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
let linkState = DeviceLinkState(
|
||||
hardwareOnline: bool(key("device", ["powered"])),
|
||||
controlOnline: bool(key("device", ["online"])),
|
||||
simPresent: sections.contains { $0.0 == "sim" },
|
||||
cellularOnline: key("network", ["registration_status"])?
|
||||
.lowercased()
|
||||
.hasPrefix("registered")
|
||||
)
|
||||
|
||||
return DeviceSummary(
|
||||
deviceName: key("device", ["model", "name", "device"]),
|
||||
phoneNumber: key("sim", ["phone_numbers", "phone_number"]),
|
||||
operatorName: key("network", ["operator_name", "operator", "carrier"]),
|
||||
networkTechnology: key(
|
||||
"network",
|
||||
["network_type", "technology_preference", "technology", "radio_mode"]
|
||||
),
|
||||
signalPercent: number(key("network", ["signal_percent", "signal_strength"]))
|
||||
.flatMap { Int($0.rounded()) },
|
||||
cpuPercent: number(key("cpu", ["cpu_percent", "cpu_load", "usage"])),
|
||||
memoryPercent: number(key("stats", ["memory_percent", "usage"])),
|
||||
cpuCount: number(key("cpu", ["core_count", "cores"])).flatMap { Int($0.rounded()) },
|
||||
temperatureCelsius: number(key("stats", ["temperature", "max_temperature_c"])),
|
||||
batteryPercent: number(key("stats", ["battery_percent", "battery_level"])),
|
||||
ipAddress: key("network", ["ipv4", "ip_address", "ipv6"]),
|
||||
latencyMilliseconds: number(key("connectivity", ["latency_ms", "latency"])).flatMap {
|
||||
Int($0.rounded())
|
||||
},
|
||||
connectivity: key("connectivity", ["state", "connected", "latency_ms"]),
|
||||
firmwareVersion: key("device", ["revision", "firmware_version"]),
|
||||
totalStorageBytes: Self.byteCount(
|
||||
key("stats", ["disk_total_bytes", "storage_total_bytes", "total_bytes"])
|
||||
),
|
||||
availableStorageBytes: Self.byteCount(
|
||||
key("stats", ["disk_available_bytes", "storage_available_bytes", "available_bytes"])
|
||||
),
|
||||
downloadRateBytesPerSecond: Self.byteRate(
|
||||
key("connectivity", ["download", "download_bytes_per_second", "rx_rate"])
|
||||
),
|
||||
uploadRateBytesPerSecond: Self.byteRate(
|
||||
key("connectivity", ["upload", "upload_bytes_per_second", "tx_rate"])
|
||||
),
|
||||
messagesToday: number(
|
||||
key("connectivity", ["messages_today", "messages", "sms_today"])
|
||||
).flatMap { Int($0.rounded()) },
|
||||
uptimeSeconds: number(
|
||||
key("stats", ["uptime_seconds", "uptime", "system_uptime_seconds"])
|
||||
) ?? number(key("stats", ["uptime_uptime_seconds"]))
|
||||
?? number(key("stats", ["system_uptime_seconds"])),
|
||||
linkState: linkState
|
||||
)
|
||||
}
|
||||
|
||||
private static func byteCount(_ text: String?) -> Int64? {
|
||||
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return Int64(value.rounded())
|
||||
}
|
||||
|
||||
private static func byteRate(_ text: String?) -> Double? {
|
||||
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
static func flatten(_ source: [String: Any], maximum: Int = 40) -> [String: String] {
|
||||
var output: [String: String] = [:]
|
||||
flatten(source, prefix: "", into: &output, remaining: maximum)
|
||||
|
||||
@@ -5,13 +5,16 @@ final class DeviceViewModel: ObservableObject {
|
||||
@Published var snapshot: DeviceSnapshot?
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published private(set) var connectionStatus: ConnectionStatus = .unknown
|
||||
|
||||
let instance: Instance
|
||||
private let service: DeviceService
|
||||
private let probe: ConnectionProbe?
|
||||
|
||||
init(instance: Instance, service: DeviceService) {
|
||||
init(instance: Instance, service: DeviceService, probe: ConnectionProbe? = nil) {
|
||||
self.instance = instance
|
||||
self.service = service
|
||||
self.probe = probe
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
@@ -24,6 +27,13 @@ final class DeviceViewModel: ObservableObject {
|
||||
} catch {
|
||||
errorMessage = "无法加载设备数据。"
|
||||
}
|
||||
guard let probe else {
|
||||
connectionStatus = .offline
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
let result = try? await probe.test(instance)
|
||||
connectionStatus = result?.status ?? .offline
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,23 @@ 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) {
|
||||
init(
|
||||
instance: Instance,
|
||||
groupName: String? = nil,
|
||||
model: DeviceViewModel? = nil
|
||||
) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance))
|
||||
self.groupName = groupName
|
||||
}
|
||||
|
||||
private let groupName: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
header
|
||||
VStack(spacing: 12) {
|
||||
headerCard
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
@@ -35,56 +43,460 @@ struct InstanceDetailView: View {
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
.sheet(isPresented: $showsSensitiveInfo) {
|
||||
SensitiveValueSheet(
|
||||
values: sensitiveValues,
|
||||
isRevealed: $revealsSensitive
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private var headerCard: some View {
|
||||
AICard(padding: 16, emphasis: true) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(model.instance.name)
|
||||
.font(.title3.weight(.bold))
|
||||
.kerning(-0.2)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(model.instance.origin.absoluteString)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
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)
|
||||
DetailMetricGrid(metrics: headerMetrics)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("数据模块")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Text(availableCount.map { "\($0)/\(moduleSections.count)" } ?? "读取中")
|
||||
.font(.caption.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(availableCount == moduleSections.count ? AITheme.success : AITheme.muted)
|
||||
}
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 9) {
|
||||
ForEach(stride(from: 0, to: moduleSections.count, by: 2).map {
|
||||
Array(moduleSections[$0..<min($0 + 2, moduleSections.count)])
|
||||
}, id: \.first?.id) { rowSections in
|
||||
GridRow {
|
||||
ForEach(rowSections, id: \.id) { section in
|
||||
moduleIndicator(
|
||||
section.title,
|
||||
isAvailable: section.values.isEmpty == false
|
||||
)
|
||||
}
|
||||
if rowSections.count == 1 {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
AITag(revealsSensitive ? "敏感值已显示" : "敏感值已隐藏", tint: AITheme.primary)
|
||||
if let availableCount {
|
||||
AITag("\(availableCount)/6 模块", tint: AITheme.success)
|
||||
if groupName != nil || !model.instance.tags.isEmpty {
|
||||
FlowLayout(spacing: 7) {
|
||||
if let groupName {
|
||||
AITag("分组 · \(groupName)", tint: AITheme.primary)
|
||||
}
|
||||
ForEach(model.instance.tags, id: \.self) { tag in
|
||||
AITag(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeaderMetric: Identifiable {
|
||||
let title: String
|
||||
let value: String?
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
let progress: Double?
|
||||
|
||||
var id: String { title }
|
||||
}
|
||||
|
||||
private var headerMetrics: [HeaderMetric] {
|
||||
let loading = model.snapshot == nil
|
||||
let phoneValue = stringValue(
|
||||
section: "sim",
|
||||
keys: ["phone_numbers", "phone_number"]
|
||||
)
|
||||
let firmwareValue = stringValue(
|
||||
section: "device",
|
||||
keys: ["revision", "firmware_version"]
|
||||
)
|
||||
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) }
|
||||
)
|
||||
let deviceValue = stringValue(section: "device", keys: ["model", "name", "device"])
|
||||
let carrierValue = stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])
|
||||
let networkValue = stringValue(
|
||||
section: "network",
|
||||
keys: ["network_type", "technology_preference", "technology", "radio_mode"]
|
||||
)
|
||||
let ipValue = stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"])
|
||||
|
||||
return [
|
||||
HeaderMetric(
|
||||
title: "连接状态",
|
||||
value: loading ? "--" : model.connectionStatus.label,
|
||||
systemImage: "dot.radiowaves.left.and.right",
|
||||
tint: loading ? AITheme.muted : statusColor(model.connectionStatus),
|
||||
progress: loading ? 0 : (model.connectionStatus == .online ? 1 : 0)
|
||||
),
|
||||
HeaderMetric(
|
||||
title: "号码",
|
||||
value: loading ? "--" : phoneValue ?? "无数据",
|
||||
systemImage: "simcard",
|
||||
tint: loading ? AITheme.muted : AITheme.ink,
|
||||
progress: loading ? 0 : (phoneValue == nil ? 0 : 1)
|
||||
),
|
||||
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),
|
||||
HeaderMetric(
|
||||
title: "CPU",
|
||||
value: loading ? "--" : cpu?.value ?? "无数据",
|
||||
systemImage: "cpu",
|
||||
tint: loading ? AITheme.muted : cpu?.tint ?? AITheme.muted,
|
||||
progress: loading ? 0 : cpu?.progress
|
||||
),
|
||||
HeaderMetric(
|
||||
title: "内存",
|
||||
value: loading ? "--" : memory?.value ?? "无数据",
|
||||
systemImage: "memorychip",
|
||||
tint: loading ? AITheme.muted : memory?.tint ?? AITheme.muted,
|
||||
progress: loading ? 0 : memory?.progress
|
||||
),
|
||||
makeMetric(title: "设备", value: loading ? nil : deviceValue, systemImage: "iphone.gen3", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "运营商", value: loading ? nil : carrierValue, systemImage: "antenna.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "网络", value: loading ? nil : networkValue, systemImage: "dot.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "IP", value: loading ? nil : ipValue, systemImage: "network", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "固件", value: loading ? nil : firmwareValue, systemImage: "waveform.path.ecg", tint: loading ? nil : AITheme.ink),
|
||||
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 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.success : (value >= 30 ? AITheme.warning : AITheme.danger)
|
||||
}
|
||||
|
||||
private func latencyTint(_ value: Double) -> Color {
|
||||
value < 300 ? AITheme.success : (value < 800 ? AITheme.warning : AITheme.danger)
|
||||
}
|
||||
|
||||
private func resourceTint(_ value: Double) -> Color {
|
||||
value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.success)
|
||||
}
|
||||
|
||||
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: 12, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
if isProgressVisible {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(tint)
|
||||
.frame(height: 4)
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(height: 4)
|
||||
}
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, minHeight: 76, maxHeight: 76, alignment: .topLeading)
|
||||
.background(AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailMetricGrid: View {
|
||||
let metrics: [HeaderMetric]
|
||||
|
||||
private let primaryKeys: Set<String> = ["连接状态", "号码", "信号", "延迟"]
|
||||
private let valueKeys: Set<String> = [
|
||||
"设备", "运营商", "网络", "IP", "固件", "存储", "运行时间"
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
let primary = metrics.filter { primaryKeys.contains($0.title) }
|
||||
let values = metrics.filter { valueKeys.contains($0.title) }
|
||||
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
ForEach(primary) { DetailHeaderMetric(metric: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
revealsSensitive.toggle()
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
||||
ForEach(
|
||||
stride(from: 0, to: values.count, by: 2).map {
|
||||
Array(values[$0..<min($0 + 2, values.count)])
|
||||
},
|
||||
id: \.first?.id
|
||||
) { rowMetrics in
|
||||
GridRow {
|
||||
ForEach(rowMetrics) { DetailHeaderMetric(metric: $0) }
|
||||
if rowMetrics.count == 1 {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
revealsSensitive ? "隐藏敏感值" : "显示敏感值",
|
||||
systemImage: revealsSensitive ? "eye.slash" : "eye"
|
||||
)
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact, fixedWidth: 112))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if model.isLoading {
|
||||
AILoadingCard("正在读取设备详情")
|
||||
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)
|
||||
}
|
||||
|
||||
private struct FlowLayout: Layout {
|
||||
let spacing: CGFloat
|
||||
|
||||
func sizeThatFits(
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout ()
|
||||
) -> CGSize {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var x: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x > 0, x + size.width > maxWidth {
|
||||
x = 0
|
||||
height += size.height + spacing
|
||||
}
|
||||
x += size.width + spacing
|
||||
height = max(height, size.height)
|
||||
}
|
||||
|
||||
return CGSize(width: max(0, x - spacing), height: height)
|
||||
}
|
||||
|
||||
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 + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,6 +506,20 @@ struct InstanceDetailView: View {
|
||||
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 {
|
||||
@@ -117,7 +543,7 @@ struct InstanceDetailView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AICard(padding: 18) {
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
AISectionTitle(section.title, systemImage: sectionIcon(section.id))
|
||||
Divider()
|
||||
@@ -306,4 +732,79 @@ struct InstanceDetailView: View {
|
||||
}
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user