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:
chick
2026-09-13 00:46:26 +08:00
parent 409345cba3
commit 2ff23d1fb0
23 changed files with 3600 additions and 269 deletions
@@ -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)"
}
}
}