import SwiftUI struct FleetView: View { @StateObject private var model: FleetViewModel @State private var showsDeleteDialog = false @State private var editingInstance: Instance? @State private var isSummaryCollapsed = true @State private var showingPairing = false @StateObject private var pairingModel: RelayPairingViewModel init(model: FleetViewModel? = nil) { // StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上, // 所以调试开关必须先落到这个具体的 model 对象。 let target = model ?? DefaultContainer.makeFleetModel() #if DEBUG if DebugLaunch.has("--summary-open") { _isSummaryCollapsed = State(initialValue: false) } if DebugLaunch.has("--select-mode") { target.selectionMode = true } if DebugLaunch.has("--sheet-form") { target.showingForm = true } if DebugLaunch.has("--sheet-tagbatch") { target.selectionMode = true target.showingTagBatch = true } // 图例胶囊的选中态只能靠点选进入,逐页截图时留一个直达开关。 if DebugLaunch.has("--filter-degraded") { target.statusFilter = .degraded } if DebugLaunch.has("--tag-field") { target.selectedTagFilters = ["field"] } #endif _model = StateObject(wrappedValue: target) // 配对视图模型与列表共用同一个仓库,否则配对成功后列表看不到新节点。 _pairingModel = StateObject(wrappedValue: RelayPairingViewModel( repository: DefaultContainer.makeInstanceRepository(), identities: DefaultContainer.deviceIdentities )) } var body: some View { ScrollView { LazyVStack(spacing: 12) { searchField if let errorMessage = model.errorMessage { AIStatusBanner( errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle" ) } if !model.rows.isEmpty { if showsSummary { fleetSummary } tagFilterStrip } if model.isLoading && model.rows.isEmpty { AICard(padding: 16) { VStack(alignment: .leading, spacing: 12) { AISkeletonLine(width: 0.38, height: 17) HStack(spacing: 10) { AISkeletonCard(height: 78) AISkeletonCard(height: 78) } AISkeletonLine(width: 0.54, height: 15) AISkeletonLine(width: 0.72, height: 15) } } } if model.rows.isEmpty && !model.isLoading { AICard { AIEmptyState( systemImage: "server.rack", title: "暂无实例", message: "添加 SimAdmin 实例后即可开始管理。" ) } } if model.visibleRows.isEmpty && !model.rows.isEmpty { AICard { AIEmptyState( systemImage: "magnifyingglass", title: "未找到实例", message: "换个名称、地址、运营商或标签试试。" ) } } ForEach(model.visibleRows) { row in instanceEntry(row) } if !model.rows.isEmpty { Text("共 \(model.rows.count) 台实例 · 显示 \(summaryRows.count) 台") .font(.caption.weight(.semibold)) .monospacedDigit() .foregroundStyle(AITheme.muted) .frame(maxWidth: .infinity) .padding(.vertical, 4) .accessibilityElement(children: .ignore) } } .padding(16) } .toolbar { if model.selectionMode { ToolbarItem(placement: .navigation) { AIIconButton( allVisibleSelected ? "取消全选" : "全选", systemImage: allVisibleSelected ? "checkmark.circle.fill" : "checkmark.circle", kind: .secondary ) { model.selectedIDs = allVisibleSelected ? [] : Set(model.visibleRows.map(\.id)) } .disabled(model.visibleRows.isEmpty) } ToolbarItemGroup(placement: .primaryAction) { AIIconButton("批量标签", systemImage: "tag", kind: .secondary) { model.showingTagBatch = true } .disabled(model.selectedIDs.isEmpty) AIIconButton("删除已选实例", systemImage: "trash", kind: .danger) { showsDeleteDialog = true } .disabled(model.selectedIDs.isEmpty) AIIconButton("退出批量选择", systemImage: "xmark", kind: .secondary) { model.selectionMode = false model.selectedIDs.removeAll() } } } else { ToolbarItemGroup(placement: .primaryAction) { AIIconButton("批量选择", systemImage: "checklist") { model.selectionMode = true } .disabled(model.visibleRows.isEmpty) AIIconButton("配对中继节点", systemImage: "link.badge.plus") { showingPairing = true } .disabled(model.isLoading) AIIconButton("添加实例", systemImage: "plus") { model.showingForm = true } .disabled(model.isLoading) } } } .sheet(isPresented: $model.showingTagBatch) { FleetTagBatchSheet(model: model) } .sheet(isPresented: $model.showingForm) { InstanceFormView( model: model, instance: editingInstance ) } .sheet(isPresented: $showingPairing) { RelayPairingView(model: pairingModel, fleet: model) } .onChange(of: model.showingForm) { showing in if !showing { editingInstance = nil } } .background(AITheme.canvas.ignoresSafeArea()) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .navigationTitle(model.selectionMode ? "已选 \(model.selectedIDs.count) 台" : "节点总览") .confirmationDialog( "删除已选实例?登录信息也会一并清除。", isPresented: $showsDeleteDialog, titleVisibility: .visible ) { Button("删除 \(model.selectedIDs.count) 个实例", role: .destructive) { Task { await model.deleteSelected() } } Button("取消", role: .cancel) {} } .refreshable { await model.refresh() } .task { await model.refresh() } } } private extension FleetView { /// 概览读数缺数据时只画一根短横:「不可用」在四列窄格里既占位又容易被读成故障结论。 static let noDataText = "—" private static let relativeFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .abbreviated formatter.locale = Locale(identifier: "zh_CN") return formatter }() /// 视觉回归用:`--hide-summary` 只看节点卡片,不影响 Release。 var showsSummary: Bool { #if DEBUG return !DebugLaunch.has("--hide-summary") #else return true #endif } /// 概览副标题:更新时间,检测未完成时追加进度。进度原本占读数栅格一格,挪到这里后栅格正好两行。 private var updatedDetail: String { let progress = testingCount > 0 ? " · 已检测 \(settledCount)/\(summaryRows.count) 台" : "" guard let lastRefresh = model.lastRefresh else { return (model.isLoading ? "正在读取" : "待刷新") + progress } // RelativeDateTimeFormatter 在中文下会把刚发生的事说成"0秒后",这里直接兜成"刚刚"。 if Date().timeIntervalSince(lastRefresh) < 45 { return "刚刚更新" + progress } return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))" + progress } private var searchField: some View { AISearchField("搜索实例或地址", text: $model.searchText) { Task { await model.refresh() } } } // 标签是唯一的归类手段:一条横向可滑动的胶囊,首个「全部」用于复位。 @ViewBuilder private var tagFilterStrip: some View { if model.allTags.isEmpty == false { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { FilterChip( title: "全部", count: model.rows.count, isStreaming: false, isSelected: model.selectedTagFilters.isEmpty ) { guard model.selectedTagFilters.isEmpty == false else { return } withAnimation(.easeOut(duration: 0.18)) { model.clearTagFilters() } } ForEach(model.allTags, id: \.self) { tag in FilterChip( title: tag, count: model.countForTagFilter(tag), isStreaming: true, isSelected: model.isTagFilterActive(tag) ) { withAnimation(.easeOut(duration: 0.18)) { model.toggleTagFilter(tag) } } } } .padding(.vertical, 3) .padding(.horizontal, 1) } .accessibilityElement(children: .contain) .accessibilityLabel("按标签筛选") } } private struct FilterChip: View { let title: String let count: Int let isStreaming: Bool let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 5) { if isStreaming { Image(systemName: isSelected ? "checkmark" : "tag") .font(.system(size: 10, weight: .semibold)) .accessibilityHidden(true) } Text(title) .font(.footnote.weight(isSelected ? .semibold : .medium)) .lineLimit(1) Text("\(count)") .font(.caption2.weight(.semibold)) .monospacedDigit() .opacity(0.75) } .padding(.horizontal, 12) .frame(height: 38) .foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.ink) .background(isSelected ? AITheme.primary : AITheme.surface) .clipShape(Capsule(style: .continuous)) .overlay { Capsule(style: .continuous) .strokeBorder(isSelected ? AITheme.primary : AITheme.line) } .shadow(color: isSelected ? AITheme.cardShadow : .clear, radius: 6, x: 0, y: 2) .contentShape(Capsule(style: .continuous)) } .buttonStyle(.plain) .accessibilityLabel(isStreaming ? "标签 \(title),\(count) 台" : "显示全部 \(count) 台") .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) } } private var fleetOverviewHeader: some View { Button { withAnimation(.easeOut(duration: 0.22)) { isSummaryCollapsed.toggle() } } label: { HStack(spacing: 10) { Image(systemName: "chart.bar.fill") // 仪表盘符号在小字号下会糊成一团,换成同尺寸能读清的柱状图。 .font(.system(size: 15, weight: .semibold)) .foregroundStyle(AITheme.primary) .frame(width: 22, height: 22) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 1) { Text("运行概览") .font(.headline.weight(.semibold)) .foregroundStyle(AITheme.ink) Text(updatedDetail) .font(.caption2.weight(.medium)) .foregroundStyle(AITheme.muted) } Spacer(minLength: 8) Image(systemName: isSummaryCollapsed ? "chevron.down" : "chevron.up") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(AITheme.muted) .frame(width: 24, height: 24) .accessibilityHidden(true) } .padding(.vertical, 7) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityElement(children: .contain) .accessibilityLabel(isSummaryCollapsed ? "展开运行概览" : "折叠运行概览") .accessibilityAddTraits(.isButton) } private var tagCount: Int { Set(summaryRows.flatMap(\.instance.tags)).count } private var taggedCount: Int { summaryRows.filter { $0.instance.tags.isEmpty == false }.count } private var summaryRows: [FleetViewModel.Row] { model.visibleRows } private var allVisibleSelected: Bool { model.visibleRows.isEmpty == false && model.selectedIDs.count == model.visibleRows.count } var selectedIDs: [UUID] { model.selectedIDs.sorted { $0.uuidString < $1.uuidString } } private var fleetSummary: some View { AICard(padding: 14) { VStack(alignment: .leading, spacing: 10) { fleetOverviewHeader // 结论行只用排版,不额外套色块,卡片里不再出现卡片。 HStack(alignment: .top, spacing: 14) { FleetHeadlineStat( title: "在线率", value: onlineRateValue, detail: onlineRateDetail, tint: onlineRateTint ) FleetHeadlineStat( title: "异常节点", value: settledCount == 0 ? Self.noDataText : "\(abnormalCount)", detail: abnormalDetail, tint: abnormalTint ) } FleetDistributionBar( segments: statusSegments, total: summaryRows.count, selection: Binding( get: { model.statusFilter }, set: { model.statusFilter = $0 } ) ) if summaryRows.isEmpty && model.rows.isEmpty == false { Text("当前筛选没有节点") .font(.footnote.weight(.medium)) .foregroundStyle(AITheme.muted) .frame(maxWidth: .infinity, alignment: .leading) } FleetReadoutGrid( items: isSummaryCollapsed ? Array(fleetSummaryItems.prefix(4)) : fleetSummaryItems ) if !isSummaryCollapsed { FleetOrganizationFootnote( tagCount: tagCount, covered: taggedCount, total: summaryRows.count ) } } } } /// 概览顶部的大字结论:标题在上,数字与说明同排。 struct FleetHeadlineStat: View { let title: String let value: String let detail: String let tint: Color var body: some View { VStack(alignment: .leading, spacing: 1) { Text(title) .font(.caption2.weight(.semibold)) .foregroundStyle(AITheme.muted) .lineLimit(1) HStack(alignment: .firstTextBaseline, spacing: 5) { Text(value) .font(.system(size: 25, weight: .semibold)) .monospacedDigit() .foregroundStyle(tint) .lineLimit(1) .minimumScaleFactor(0.6) Text(detail) .font(.caption2) .foregroundStyle(AITheme.faint) .lineLimit(1) .minimumScaleFactor(0.8) } } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } } /// 状态分布:一条堆叠比例条 + 可点击图例,图例即状态筛选器。 struct FleetDistributionBar: View { let segments: [FleetStatusSegment] let total: Int @Binding var selection: ConnectionStatus? var body: some View { VStack(alignment: .leading, spacing: 8) { GeometryReader { proxy in HStack(spacing: 2) { ForEach(segments, id: \.status) { segment in Capsule(style: .continuous) .fill(segment.tint.opacity(isDimmed(segment) ? 0.22 : 1)) .frame(width: segmentWidth(segment, in: proxy.size.width)) } } } .frame(height: 6) .accessibilityHidden(true) HStack(spacing: 6) { ForEach(segments, id: \.status) { segment in FleetLegendChip( title: segment.status.label, count: segment.count, tint: segment.tint, isSelected: selection == segment.status ) { selection = selection == segment.status ? nil : segment.status } } Spacer(minLength: 0) } } } private func isDimmed(_ segment: FleetStatusSegment) -> Bool { selection != nil && selection != segment.status } private func segmentWidth(_ segment: FleetStatusSegment, in width: CGFloat) -> CGFloat { let gaps = CGFloat(max(0, segments.count - 1)) * 2 let usable = max(1, width - gaps) guard total > 0 else { return usable / CGFloat(max(1, segments.count)) } return max(6, usable * CGFloat(max(0, segment.count)) / CGFloat(total)) } } struct FleetLegendChip: View { let title: String let count: Int let tint: Color let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 5) { Circle() .fill(isSelected ? AITheme.onPrimary : tint) .frame(width: 6, height: 6) .accessibilityHidden(true) Text(title) .font(.caption2.weight(isSelected ? .semibold : .regular)) .lineLimit(1) Text("\(count)") .font(.caption2.weight(.semibold)) .monospacedDigit() .opacity(0.72) } .padding(.horizontal, 9) .frame(height: 26) // 图例即筛选器:选中时整颗胶囊填成状态色,文字走反色。 // onPrimary 在浅色下是白、深色下是近黑,正好是两套状态色的对比搭档。 .foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted) .background(isSelected ? tint : AITheme.tile) .clipShape(Capsule(style: .continuous)) .overlay { Capsule(style: .continuous) .strokeBorder(isSelected ? Color.clear : AITheme.tileLine) } // 视觉高度保持 26,但把点击区扩到 44pt 以上。 .contentShape(Capsule(style: .continuous).inset(by: -9)) } .buttonStyle(.plain) .accessibilityLabel("\(title) \(count) 台") .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) } } /// 读数面板:四列栅格,仅用发丝线分隔,正常值不上颜色。 struct FleetReadoutGrid: View { let items: [FleetStatItem] private var rows: [[FleetStatItem]] { stride(from: 0, to: items.count, by: 4).map { start in Array(items[start.. 0 { Rectangle() .fill(AITheme.line) .frame(height: 1) } HStack(alignment: .top, spacing: 0) { ForEach(Array(row.enumerated()), id: \.element.title) { columnIndex, item in FleetReadoutCell(item: item) .padding(.leading, columnIndex == 0 ? 0 : 10) .frame(maxWidth: .infinity, alignment: .leading) } if row.count < 4 { ForEach(row.count..<4, id: \.self) { _ in Color.clear .frame(maxWidth: .infinity, minHeight: 1) } } } .padding(.vertical, 9) } } } } struct FleetReadoutCell: View { let item: FleetStatItem var body: some View { VStack(alignment: .leading, spacing: 2) { Text(item.title) .font(.caption2) .foregroundStyle(AITheme.faint) .lineLimit(1) Text(item.value) .font(.footnote.weight(.semibold)) .monospacedDigit() .foregroundStyle(readoutColor) .lineLimit(1) .minimumScaleFactor(0.68) } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } private var readoutColor: Color { if item.isPlaceholder { return AITheme.faint } return item.isAlert ? item.tint : AITheme.ink } } struct FleetOrganizationFootnote: View { let tagCount: Int let covered: Int let total: Int var body: some View { HStack(spacing: 6) { Image(systemName: "tag") .font(.system(size: 10, weight: .semibold)) .accessibilityHidden(true) Text(tagCount == 0 ? "还没有标签,可在编辑实例时添加" : "\(tagCount) 个标签 · 已标记 \(covered)/\(total) 台") .lineLimit(1) .truncationMode(.tail) Spacer(minLength: 0) } .font(.caption2) .foregroundStyle(AITheme.faint) .padding(.top, 9) .overlay(alignment: .top) { Rectangle() .fill(AITheme.line) .frame(height: 1) } .accessibilityElement(children: .combine) } } private var statusSegments: [FleetStatusSegment] { let order: [ConnectionStatus] = [.online, .degraded, .authRequired, .offline, .unknown] return order.compactMap { status in let count = summaryRows.filter { $0.status == status }.count guard count > 0 else { return nil } return FleetStatusSegment(status: status, count: count) } } /// 概览分布条的一段,颜色与节点卡片上的状态色保持一致。 struct FleetStatusSegment { let status: ConnectionStatus let count: Int var tint: Color { switch status { case .online: return AITheme.success case .offline: return AITheme.danger case .authRequired, .degraded: return AITheme.warning case .unknown: return AITheme.muted } } } /// 读数区固定八格两行:温度与电量即使整群节点都没有上报也保留占位, /// 否则栅格会在七格、九格之间跳,最后一行只剩孤立的一列。 private var fleetSummaryItems: [FleetStatItem] { [ FleetStatItem(title: "信号", value: signalSummary, systemImage: "cellularbars", tint: signalSummaryTint), FleetStatItem(title: "延迟", value: latencySummary, systemImage: "speedometer", tint: latencySummaryTint), FleetStatItem(title: "存储", value: storageSummary, systemImage: "internaldrive", tint: storageSummaryTint), FleetStatItem(title: "流量", value: trafficSummary, systemImage: "arrow.up.arrow.down", tint: trafficSummaryTint), FleetStatItem(title: "CPU", value: cpuSummary, systemImage: "cpu", tint: loadSummaryTint), FleetStatItem(title: "内存", value: memorySummary, systemImage: "memorychip", tint: loadSummaryTint), FleetStatItem(title: "温度", value: temperatureSummary, systemImage: "thermometer.medium", tint: temperatureSummaryTint), FleetStatItem(title: "电量", value: batterySummary, systemImage: "battery.100", tint: batterySummaryTint) ] } private var onlineCount: Int { summaryRows.filter { $0.status == .online }.count } private var abnormalCount: Int { summaryRows.filter { $0.status != .online && $0.status != .unknown }.count } private var testingCount: Int { summaryRows.filter(\.testing).count } private var settledCount: Int { summaryRows.count - testingCount } // 检测尚未完成时不给出结论性数字,避免把"还没读到"误读成"全部离线"。 private var hasSettledResults: Bool { summaryRows.isEmpty == false && settledCount > 0 } private var onlineRateValue: String { guard hasSettledResults else { return Self.noDataText } return "\(Int((Double(onlineCount) / Double(summaryRows.count) * 100).rounded()))%" } private var onlineRateDetail: String { guard hasSettledResults else { return "\(summaryRows.count) 台正在检测" } return "\(onlineCount)/\(summaryRows.count) 台在线" } private var onlineRateTint: Color { guard hasSettledResults else { return AITheme.muted } return onlineCount == summaryRows.count ? AITheme.success : AITheme.warning } private var abnormalDetail: String { guard hasSettledResults else { return "等待检测结果" } return abnormalCount > 0 ? "需要尽快处理" : "无异常节点" } private var abnormalTint: Color { guard hasSettledResults else { return AITheme.muted } return abnormalCount > 0 ? AITheme.danger : AITheme.success } private var signalValues: [Int] { summaryRows.compactMap(\.signalPercent) } private var signalSummary: String { formatAverage(signalValues) { "\(Int($0.rounded()))%" } } private var signalSummaryTint: Color { colorForAverage(signalValues) { $0 >= 60 ? AITheme.success : ($0 >= 30 ? AITheme.warning : AITheme.danger) } } private var latencyValues: [Int] { summaryRows.compactMap(\.latencyMilliseconds) } private var latencySummary: String { formatAverage(latencyValues) { "\(Int($0.rounded())) ms" } } private var latencySummaryTint: Color { colorForAverage(latencyValues) { $0 < 300 ? AITheme.success : ($0 < 800 ? AITheme.warning : AITheme.danger) } } private var cpuSummary: String { formatAverage(summaryRows.compactMap(\.cpuPercent)) { "\(Int($0.rounded()))%" } } private var memorySummary: String { formatAverage(summaryRows.compactMap(\.memoryPercent)) { "\(Int($0.rounded()))%" } } private var loadSummaryTint: Color { let values = (summaryRows.compactMap(\.cpuPercent) + summaryRows.compactMap(\.memoryPercent)) return colorForAverage(values) { $0 >= 85 ? AITheme.danger : ($0 >= 65 ? AITheme.warning : AITheme.success) } } private var temperatureSummary: String { formatAverage(summaryRows.compactMap(\.temperatureCelsius)) { "\(Int($0.rounded()))°C" } } private var temperatureSummaryTint: Color { colorForAverage(summaryRows.compactMap(\.temperatureCelsius)) { $0 >= 75 ? AITheme.danger : ($0 >= 60 ? AITheme.warning : AITheme.success) } } private var batterySummary: String { formatAverage(summaryRows.compactMap(\.batteryPercent)) { "\(Int($0.rounded()))%" } } private var batterySummaryTint: Color { colorForAverage(summaryRows.compactMap(\.batteryPercent)) { $0 <= 20 ? AITheme.danger : ($0 <= 40 ? AITheme.warning : AITheme.success) } } private var storageSummary: String { let pairs = storagePairs guard pairs.isEmpty == false else { return Self.noDataText } let used = pairs.map { max(0, $0.total - $0.available) }.reduce(0, +) let total = pairs.map(\.total).reduce(0, +) guard total > 0 else { return Self.noDataText } return "\(Int((Double(used) / Double(total) * 100).rounded()))% 已用" } private var storageSummaryTint: Color { guard storagePairs.isEmpty == false else { return AITheme.muted } let usedBytes = storagePairs.map { max(0, $0.total - $0.available) }.reduce(0, +) let totalBytes = storagePairs.map(\.total).reduce(0, +) let ratio = Double(usedBytes) / Double(max(1, totalBytes)) return colorForRatio(ratio) { $0 >= 0.90 ? AITheme.danger : ($0 >= 0.75 ? AITheme.warning : AITheme.success) } } private var storagePairs: [(total: Int64, available: Int64)] { summaryRows.compactMap { row in guard let total = row.totalStorageBytes, let available = row.availableStorageBytes else { return nil } return (total: total, available: available) } } private var trafficSummary: String { let values = summaryRows.compactMap(\.downloadRateBytesPerSecond) + summaryRows.compactMap(\.uploadRateBytesPerSecond) guard values.isEmpty == false else { return Self.noDataText } return ByteCountFormatter.string(fromByteCount: Int64(values.reduce(0, +)), countStyle: .decimal) + "/s" } private var trafficSummaryTint: Color { let values = summaryRows.compactMap(\.downloadRateBytesPerSecond) + summaryRows.compactMap(\.uploadRateBytesPerSecond) return colorForAverage(values) { $0 >= 50_000_000 ? AITheme.warning : AITheme.success } } private func colorForRatio(_ value: Double, transform: (Double) -> Color) -> Color { transform(max(0, min(1, value))) } private func formatAverage(_ values: [Double], transform: (Double) -> String) -> String { guard values.isEmpty == false else { return Self.noDataText } let average = values.reduce(0, +) / Double(values.count) return transform(average) } private func formatAverage(_ values: [Int], transform: (Double) -> String) -> String { formatAverage(values.map(Double.init), transform: transform) } private func colorForAverage( _ values: [Double], transform: (Double) -> Color ) -> Color { guard values.isEmpty == false else { return AITheme.muted } return transform(values.reduce(0, +) / Double(values.count)) } private func colorForAverage( _ values: [Int], transform: (Double) -> Color ) -> Color { colorForAverage(values.map(Double.init), transform: transform) } private func instanceCard(_ row: FleetViewModel.Row) -> some View { let metrics = rowMetrics(row) return AICard { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 7) { HStack(spacing: 8) { Text(row.instance.name) .font(.headline) .foregroundStyle(AITheme.ink) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) Spacer() InlinePill( title: row.status.label, systemImage: statusIcon(row.status), tint: statusColor(row.status) ) } HStack(spacing: 6) { Text(row.instance.endpoint.displayAddress) .font(.caption) .foregroundStyle(AITheme.muted) .lineLimit(1) .truncationMode(.middle) // 通道徽标:没有它,"这台为什么慢"无法回答。 channelBadge(row.channel) if !row.testing, row.freshness != .fresh { Text(row.freshness.label) .font(.caption2) .foregroundStyle(AITheme.muted) } } } // 四条量表把"要不要担心这台"变成一次扫视:信号、延迟、CPU、内存。 FleetGaugeStrip( gauges: rowGauges(row), isPending: row.testing ) // 健康节点不再占一行重复"连接正常",只有需要解释的状态才出现说明与链路。 if let statusLine = statusLine(row) { FleetStatusStrip(status: row.status, statusDetail: statusLine) } if let health = chainHealth(row) { FleetChainRow(items: health) } FleetMetricGrid( metrics: metrics, isPending: row.testing && metrics.isEmpty ) if row.instance.tags.isEmpty == false { FlowLayout(spacing: 6) { ForEach(row.instance.tags, id: \.self) { tag in TagToken( title: tag, isActive: model.isTagFilterActive(tag), isInteractive: model.selectionMode == false ) { withAnimation(.easeOut(duration: 0.18)) { model.toggleTagFilter(tag) } } } } .frame(maxWidth: .infinity, alignment: .leading) } } } } /// 选择态的勾选列:与卡片标题行同一基线,靠布局让位而不是压在内容上。 private func selectionMark(_ id: UUID) -> some View { let checked = model.selectedIDs.contains(id) return Image(systemName: checked ? "checkmark.circle.fill" : "circle") .font(.system(size: 22, weight: .regular)) .foregroundStyle(checked ? AITheme.primary : AITheme.muted.opacity(0.72)) .frame(width: 26) .padding(.top, 17) .accessibilityHidden(true) } private var summaryColumns: [GridItem] { [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)] } private func chainHealth(_ row: FleetViewModel.Row) -> [FleetLinkItem]? { guard let values = row.chainValues else { return nil } let items = [ FleetLinkItem( title: "硬件", isAvailable: values.hardwareOnline, ), FleetLinkItem( title: "控制", isAvailable: values.controlOnline, ), FleetLinkItem( title: "SIM", isAvailable: values.simPresent, ), FleetLinkItem( title: "蜂窝", isAvailable: values.cellularOnline, ), ] // 四段全绿时这一行没有任何信息量,只在链路真的出了问题(或未知)时才出现。 guard items.contains(where: { $0.isAvailable != true }) else { return nil } return items } /// 卡片顶部的四条量表:数值配一条发丝进度,正常时不着色,异常时只给颜色不给感叹号。 private func rowGauges(_ row: FleetViewModel.Row) -> [FleetGauge] { [ FleetGauge( label: "信号", value: row.signalPercent.map { "\($0)%" } ?? Self.noDataText, ratio: row.signalPercent.map { Double($0) / 100 }, tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted ), FleetGauge( label: "延迟", value: row.latencyMilliseconds.map { "\($0) ms" } ?? Self.noDataText, ratio: row.latencyMilliseconds.map { max(0, 1 - Double($0) / 1000) }, tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted ), FleetGauge( label: "CPU", value: row.cpuPercent.map(Self.percentText) ?? Self.noDataText, ratio: row.cpuPercent.map { $0 / 100 }, tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted ), FleetGauge( label: "内存", value: row.memoryPercent.map(Self.percentText) ?? Self.noDataText, ratio: row.memoryPercent.map { $0 / 100 }, tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted ), ] } private func rowMetrics(_ row: FleetViewModel.Row) -> [FleetMetric] { // 信号质量已经交给量表,这一区只放身份与容量:设备、网络、存储、续航、运行时长。 let ordered: [FleetMetric] = [ .init(label: "设备", value: row.deviceName), .init(label: "运营商", value: row.operatorName), .init(label: "号码", value: row.phoneNumber), .init(label: "制式", value: row.networkTechnology), .init(label: "IP", value: row.ipAddress), .init( label: "存储", value: row.storageUsageRatio.map { "\(Int(($0 * 100).rounded()))% 已用" }, tint: row.storageUsageRatio.flatMap(storageColor) ?? AITheme.muted ), .init( label: "温度", value: row.temperatureCelsius.map(Self.temperatureText), tint: row.temperatureCelsius.flatMap(temperatureColor) ?? AITheme.muted ), .init( label: "电量", value: row.batteryPercent.map(Self.percentText), tint: row.batteryPercent.flatMap(batteryColor) ?? AITheme.muted ), .init(label: "固件", value: row.firmwareVersion), .init(label: "运行时长", value: row.uptimeSeconds.map(Self.durationText)), .init( label: "下行", value: row.downloadRateBytesPerSecond.map(Self.rateText) ), .init( label: "上行", value: row.uploadRateBytesPerSecond.map(Self.rateText) ), .init(label: "今日消息", value: row.messagesToday.map { "\($0) 条" }) ] return ordered.filter(\.hasValue) } private static func cpuValue(percent: Double?, cores: Int?) -> String? { guard percent != nil || cores != nil else { return nil } let usage = percent.map(Self.percentText) ?? FleetView.noDataText guard let cores, cores > 0 else { return usage } return "\(usage) · \(cores) 核" } private static func durationText(_ seconds: Double) -> String { Duration.seconds(seconds).formatted(.units(allowed: [.days, .hours, .minutes], width: .abbreviated)) } private static func rateText(_ bytesPerSecond: Double) -> String { ByteCountFormatter.string(fromByteCount: Int64(bytesPerSecond), countStyle: .decimal) + "/s" } private static func percentText(_ value: Double) -> String { value.formatted(.number.precision(.fractionLength(0...1))) + "%" } private static func temperatureText(_ value: Double) -> String { value.formatted(.number.precision(.fractionLength(0...1))) + "°C" } private func storageColor(_ ratio: Double) -> Color { ratio >= 0.90 ? AITheme.danger : (ratio >= 0.75 ? AITheme.warning : AITheme.success) } private func signalColor(_ value: Int?) -> Color? { guard let value else { return nil } return value >= 60 ? AITheme.success : (value >= 30 ? AITheme.warning : AITheme.danger) } private func resourceColor(_ value: Double) -> Color { value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.success) } private func temperatureColor(_ value: Double) -> Color { value >= 75 ? AITheme.danger : (value >= 60 ? AITheme.warning : AITheme.success) } private func batteryColor(_ value: Double) -> Color { value <= 20 ? AITheme.danger : (value <= 40 ? AITheme.warning : AITheme.success) } /// 只解释"为什么不是正常状态"。在线节点的细节交给指标栅格,上游原词(online 之类)不外泄。 /// 通道徽标是刻意低调的:它解释延迟来源,不是需要用户处理的状态。 private func channelBadge(_ channel: InstanceChannel) -> some View { Text(channel.label) .font(.caption2.weight(.semibold)) .foregroundStyle(AITheme.muted) .padding(.horizontal, 6) .padding(.vertical, 1) .background(AITheme.canvas, in: Capsule()) .overlay(Capsule().stroke(AITheme.line, lineWidth: 1)) .accessibilityLabel(channel == .relay ? "经由中继" : "内网直通") } private func statusLine(_ row: FleetViewModel.Row) -> String? { if row.testing { return "正在读取运行信息" } switch row.status { case .online: return nil case .degraded: // 中继模式下"部分接口不可用"可能有具体得多的原因,优先给能照做的说法。 if let error = row.probeError { return ErrorMessage.describe(error, nodeName: row.instance.name) } return "部分接口不可用,可在详情查看失败接口" case .authRequired: return "节点开启了访问密码,进入详情登录" case .offline: if let error = row.probeError { return ErrorMessage.describe(error, nodeName: row.instance.name) } return "节点不可达,最后结果已过期" case .unknown: return "尚未检测" } } private func latencyColor(_ milliseconds: Int?) -> Color? { guard let milliseconds else { return nil } return milliseconds < 300 ? AITheme.success : (milliseconds < 800 ? AITheme.warning : AITheme.danger) } private func instanceEntry(_ row: FleetViewModel.Row) -> some View { Group { if model.selectionMode { Button { toggle(row.id) } label: { HStack(alignment: .top, spacing: 10) { selectionMark(row.id) instanceCard(row) } .fixedSize(horizontal: false, vertical: true) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择 \(row.instance.name)" : "选择 \(row.instance.name)") .accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : []) } else { NavigationLink { InstanceDetailView(instance: row.instance) } label: { instanceCard(row) } .buttonStyle(.plain) .contextMenu { ForEach(row.instance.tags, id: \.self) { tag in Button { withAnimation(.easeOut(duration: 0.18)) { model.clearTagFilters() model.toggleTagFilter(tag) } } label: { Label("仅看「\(tag)」", systemImage: "line.3.horizontal.decrease.circle") } } Button { editingInstance = row.instance model.showingForm = true } label: { Label("编辑实例", systemImage: "pencil") } Button(role: .destructive) { Task { await model.delete([row.id]) } } label: { Label("删除实例", systemImage: "trash") } } } } } private func statusColor(_ status: ConnectionStatus) -> Color { switch status { case .online: return AITheme.success case .offline: return AITheme.danger case .authRequired: return AITheme.warning case .degraded: return AITheme.warning case .unknown: return AITheme.muted } } private func statusIcon(_ status: ConnectionStatus) -> String { switch status { case .online: return "checkmark.circle" case .offline: return "xmark.circle" case .authRequired: return "lock.circle" case .degraded: return "exclamationmark.triangle" case .unknown: return "questionmark.circle" } } private func toggle(_ id: UUID) { if model.selectedIDs.contains(id) { model.selectedIDs.remove(id) } else { model.selectedIDs.insert(id) } } } extension FleetView { /// 节点卡片的指标区:两列发丝线栅格,最多八项,其余留给详情页。 private struct FleetMetricGrid: View { let metrics: [FleetMetric] var isPending = false private static let maxVisible = 8 private var visible: [FleetMetric] { Array(metrics.prefix(Self.maxVisible)) } private var overflow: Int { max(0, metrics.count - Self.maxVisible) } private var rows: [[FleetMetric]] { stride(from: 0, to: visible.count, by: 2).map { start in Array(visible[start.. 0 { Rectangle() .fill(AITheme.line) .frame(height: 1) } HStack(alignment: .top, spacing: 0) { ForEach(Array(row.enumerated()), id: \.offset) { columnIndex, metric in metric .padding(.leading, columnIndex == 0 ? 0 : 12) .frame(maxWidth: .infinity, alignment: .leading) } // 只有成对出现的行才需要保留右半列对齐;单独一项时占位会让卡片右下出现空洞。 if row.count < 2 && rows.count > 1 { Color.clear .frame(maxWidth: .infinity) } } .padding(.vertical, 8) } if overflow > 0 { Text("另有 \(overflow) 项,进入详情查看") .font(.caption2) .foregroundStyle(AITheme.faint) .padding(.top, 7) } } } .frame(maxWidth: .infinity, alignment: .leading) .overlay(alignment: .top) { if metrics.isEmpty == false { Rectangle() .fill(AITheme.line) .frame(height: 1) } } } } /// 量表单元:一个可读数值加一条发丝进度。 struct FleetGauge: Identifiable { let label: String let value: String let ratio: Double? let tint: Color var id: String { label } var hasValue: Bool { ratio != nil } // 与读数栅格同一规则:只有警告和危险才配颜色。 var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning } } /// 四格量表条:等宽排布,缺数据时保留占位,避免数值到位后卡片抖动。 struct FleetGaugeStrip: View { let gauges: [FleetGauge] let isPending: Bool var body: some View { HStack(alignment: .top, spacing: 10) { ForEach(gauges) { gauge in VStack(alignment: .leading, spacing: 4) { Text(gauge.label) .font(.caption2) .foregroundStyle(AITheme.faint) .lineLimit(1) Text(gauge.value) .font(.footnote.weight(.semibold)) .monospacedDigit() .foregroundStyle(color(for: gauge)) .lineLimit(1) .minimumScaleFactor(0.7) GeometryReader { proxy in ZStack(alignment: .leading) { Capsule(style: .continuous) .fill(AITheme.line) Capsule(style: .continuous) .fill(barColor(for: gauge)) .frame(width: max(3, proxy.size.width * CGFloat(gauge.ratio ?? 0))) } } .frame(height: 4) .accessibilityHidden(true) } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) .accessibilityLabel("\(gauge.label) \(gauge.value)") } } .padding(.top, 2) .overlay(alignment: .top) { Rectangle() .fill(AITheme.line) .frame(height: 1) .offset(y: -7) } } private func color(for gauge: FleetGauge) -> Color { if !gauge.hasValue { return isPending ? AITheme.faint : AITheme.muted } return gauge.isAlert ? gauge.tint : AITheme.ink } /// 正常读数的进度条用墨色,颜色只留给异常,四格量表不会把卡片染成一片绿。 private func barColor(for gauge: FleetGauge) -> Color { guard gauge.hasValue else { return AITheme.line } return gauge.isAlert ? gauge.tint : AITheme.primary } } struct FleetStatItem { let title: String let value: String let systemImage: String let tint: Color // 只有真正需要盯的数值才着色,正常值一律用墨色,避免"满屏绿灯"。 var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning } // 没有数据时用弱色占位,读起来不会像"0%"那样的假结论。 var isPlaceholder: Bool { value == "--" || value == "—" || value == "不可用" || value == "暂无" } } /// 状态摘要行:只用一个状态点 + 一行说明,不再套底色。 struct FleetStatusStrip: View { let status: ConnectionStatus let statusDetail: String private var statusTint: Color { switch status { case .online: return AITheme.success case .offline: return AITheme.danger case .authRequired, .degraded: return AITheme.warning case .unknown: return AITheme.muted } } var body: some View { HStack(spacing: 7) { Circle() .fill(statusTint) .frame(width: 6, height: 6) .accessibilityHidden(true) Text(statusDetail) .font(.caption) .foregroundStyle(AITheme.muted) .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 0) } .accessibilityElement(children: .combine) } } struct InlinePill: View { let title: String let systemImage: String let tint: Color var body: some View { HStack(spacing: 4) { Image(systemName: systemImage) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(tint) .accessibilityHidden(true) Text(title) .font(.caption2.weight(.semibold)) .foregroundStyle(tint) .lineLimit(1) .minimumScaleFactor(0.8) } .padding(.horizontal, 7) .padding(.vertical, 3) .background(tint.opacity(0.10)) .clipShape(Capsule(style: .continuous)) .overlay { Capsule(style: .continuous) .strokeBorder(tint.opacity(0.18)) } } } struct FleetLinkItem: Identifiable, Equatable { let title: String let isAvailable: Bool? var id: String { title } } /// 实例卡片上的标签胶囊,点击即可把它当作筛选条件。 struct TagToken: View { let title: String let isActive: Bool var isInteractive: Bool = true let action: () -> Void var body: some View { // 卡片整体是一个 NavigationLink,嵌套 Button 的命中不稳定, // 这里用高优先级手势接管点击,保证标签筛选一定生效。 HStack(spacing: 4) { Image(systemName: isActive ? "checkmark.circle.fill" : "tag") .font(.system(size: 10, weight: .semibold)) .accessibilityHidden(true) Text(title) .font(.caption2.weight(.semibold)) .lineLimit(1) .truncationMode(.middle) } .padding(.horizontal, 8) .frame(height: 28) .foregroundStyle(isActive ? AITheme.onPrimary : AITheme.muted) .background(isActive ? AITheme.primary : AITheme.tile) .clipShape(Capsule(style: .continuous)) .overlay { Capsule(style: .continuous) .strokeBorder(isActive ? AITheme.primary : AITheme.tileLine) } .contentShape(Capsule(style: .continuous)) .highPriorityGesture( isInteractive ? TapGesture().onEnded(action) : nil ) .accessibilityAction { if isInteractive { action() } } .accessibilityLabel(isActive ? "取消筛选标签 \(title)" : "按标签 \(title) 筛选") .accessibilityAddTraits(isActive ? [.isSelected, .isButton] : .isButton) } } /// 链路健康:硬件 / 控制 / SIM / 蜂窝 四段一行读完,用发丝线分隔而不是四个色块。 struct FleetChainRow: View { let items: [FleetLinkItem] var body: some View { HStack(spacing: 0) { ForEach(Array(items.enumerated()), id: \.element.id) { index, item in if index > 0 { Rectangle() .fill(AITheme.line) .frame(width: 1, height: 16) } FleetChainCell(item: item) .frame(maxWidth: .infinity) } } .padding(.vertical, 8) .overlay(alignment: .top) { Rectangle() .fill(AITheme.line) .frame(height: 1) } .accessibilityElement(children: .contain) } } private struct FleetChainCell: View { let item: FleetLinkItem private var tint: Color { switch item.isAvailable { case true: return AITheme.success case false: return AITheme.danger case nil: return AITheme.muted } } private var stateText: String { switch item.isAvailable { case true: return "可用" case false: return "不可用" case nil: return "未知" } } var body: some View { HStack(spacing: 4) { Circle() .fill(tint) .frame(width: 5, height: 5) .accessibilityHidden(true) Text(item.title) .font(.caption2) .foregroundStyle(AITheme.faint) .lineLimit(1) Text(stateText) .font(.caption2.weight(.semibold)) .foregroundStyle(tint == AITheme.muted ? AITheme.faint : tint) .lineLimit(1) .minimumScaleFactor(0.8) Spacer(minLength: 0) } .padding(.horizontal, 8) .accessibilityElement(children: .combine) .accessibilityLabel("\(item.title) \(stateText)") } } /// 流式排布已提到主题层,这里保留原名,卡片标签行不用改调用点。 typealias FlowLayout = AIFlowLayout struct FleetMetric: View { let label: String let value: String? var tint: Color = AITheme.muted var hasValue: Bool { value?.isEmpty == false } var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning } var body: some View { if hasValue { VStack(alignment: .leading, spacing: 2) { Text(label) .font(.caption2) .foregroundStyle(AITheme.faint) .lineLimit(1) Text(value ?? FleetView.noDataText) .font(.footnote.weight(.semibold)) .monospacedDigit() .foregroundStyle(isAlert ? tint : AITheme.ink) .lineLimit(1) .truncationMode(.middle) .minimumScaleFactor(0.72) } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } } } }