import SwiftUI struct FleetView: View { @StateObject private var model: FleetViewModel @State private var showsDeleteDialog = false @State private var editingInstance: Instance? @State private var collapsedGroups: Set = [] @State private var isSummaryCollapsed = true @State private var showingGroupEditor = false 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("--sheet-editor") { _showingGroupEditor = State(initialValue: true) } if DebugLaunch.has("--select-mode") { target.selectionMode = true } if DebugLaunch.has("--sheet-form") { target.showingForm = true } if DebugLaunch.has("--sheet-groups") { target.showingGroupManager = true } if DebugLaunch.has("--group-status") { target.groupMode = .status } if DebugLaunch.has("--group-flat") { target.groupMode = .none } #endif _model = StateObject(wrappedValue: target) } var body: some View { ScrollView { LazyVStack(spacing: 12, pinnedViews: .sectionHeaders) { searchField if let errorMessage = model.errorMessage { AIStatusBanner( errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle" ) } if !model.rows.isEmpty { organizationControls if showsSummary { fleetSummary } } 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.groupedRows) { group in instanceGroup(group) } if !model.rows.isEmpty && !model.selectionMode { VStack(spacing: 0) { Button { showingGroupEditor = true } label: { Label("管理分组与批量归类", systemImage: "folder.badge.gearshape") } .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) .disabled(model.isLoading) } } 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: "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: "folder.badge.gearshape") { model.showingGroupManager = true } .disabled(model.isLoading) AIIconButton("批量选择", systemImage: "checklist") { model.selectionMode = true } .disabled(model.visibleRows.isEmpty) AIIconButton("添加实例", systemImage: "plus") { model.showingForm = true } .disabled(model.isLoading) } } } .sheet(isPresented: $model.showingGroupManager) { InstanceGroupManagerView(model: model) } .sheet(isPresented: $showingGroupEditor) { InstanceGroupEditorView( model: model, targetIDs: batchGroupTargetIDs, onSaved: { model.selectedIDs.removeAll() model.selectionMode = false } ) } .sheet(isPresented: $model.showingForm) { InstanceFormView( model: model, instance: editingInstance ) } .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 { 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 { guard let lastRefresh = model.lastRefresh else { return model.isLoading ? "正在读取" : "待刷新" } // RelativeDateTimeFormatter 在中文下会把刚发生的事说成"0秒后",这里直接兜成"刚刚"。 if Date().timeIntervalSince(lastRefresh) < 45 { return "刚刚更新" } return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))" } private var searchField: some View { AISearchField("搜索实例或地址", text: $model.searchText) { Task { await model.refresh() } } } private var organizationControls: some View { VStack(alignment: .leading, spacing: 10) { AISegmentedControl( "组织方式", selection: $model.groupMode, options: FleetGroupMode.allCases.map { ($0, $0.title) } ) if model.allTags.isEmpty == false { tagFilterStrip } } } // 标签直接作为筛选器使用:一条横向可滑动的胶囊,首个「全部」用于复位。 private var tagFilterStrip: some View { 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 groupedCount: Int { summaryRows.filter { model.groupName(for: $0.instance.groupId) != nil }.count } private var groupCount: Int { Set(summaryRows.compactMap(\.instance.groupId)).count } private var tagCount: Int { Set(summaryRows.flatMap(\.instance.tags)).count } private var summaryRows: [FleetViewModel.Row] { model.visibleRows } private var allVisibleSelected: Bool { model.visibleRows.isEmpty == false && model.selectedIDs.count == model.visibleRows.count } private var batchGroupTargetIDs: [UUID] { model.selectedIDs.isEmpty ? [] : Array(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 ? "--" : "\(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( groupCount: groupCount, tagCount: tagCount, grouped: groupedCount, 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(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) .foregroundStyle(isSelected ? tint : AITheme.muted) .background(isSelected ? tint.opacity(0.12) : AITheme.tile) .clipShape(Capsule(style: .continuous)) .overlay { Capsule(style: .continuous) .strokeBorder(isSelected ? tint.opacity(0.34) : 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 groupCount: Int let tagCount: Int let grouped: Int let total: Int var body: some View { HStack(spacing: 6) { Image(systemName: "folder") .font(.system(size: 10, weight: .semibold)) .accessibilityHidden(true) Text("\(groupCount) 个分组 · \(tagCount) 个标签 · 已归类 \(grouped)/\(max(grouped, 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] { var items = [ FleetStatItem( title: "检测进度", value: "\(settledCount)/\(summaryRows.count)", systemImage: "checkmark.seal", tint: testingCount > 0 ? AITheme.warning : AITheme.success ), 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) ] if summaryRows.contains(where: { $0.temperatureCelsius != nil }) { items.append( FleetStatItem( title: "温度", value: temperatureSummary, systemImage: "thermometer.medium", tint: temperatureSummaryTint ) ) } if summaryRows.contains(where: { $0.batteryPercent != nil }) { items.append( FleetStatItem( title: "电量", value: batterySummary, systemImage: "battery.100", tint: batterySummaryTint ) ) } return items } 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 "--" } 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 "不可用" } let used = pairs.map { max(0, $0.total - $0.available) }.reduce(0, +) let total = pairs.map(\.total).reduce(0, +) guard total > 0 else { return "不可用" } 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 "不可用" } 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 "不可用" } 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) ) } Text(row.instance.origin.absoluteString) .font(.caption) .foregroundStyle(AITheme.muted) .lineLimit(1) .truncationMode(.middle) } // 健康节点不再占一行重复"连接正常",只有需要解释的状态才出现说明与链路。 if let statusLine = statusLine(row) { FleetStatusStrip(status: row.status, statusDetail: statusLine) } if let health = chainHealth(row) { FleetChainRow(items: health) } if model.groupName(for: row.instance.groupId) != nil || row.instance.tags.isEmpty == false { FlowLayout(spacing: 6) { if let groupName = model.groupName(for: row.instance.groupId) { InlinePill(title: groupName, systemImage: "folder", tint: AITheme.primary) } 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) } FleetMetricGrid( metrics: metrics, isPending: row.testing && metrics.isEmpty ) } } } /// 选择态的勾选列:与卡片标题行同一基线,靠布局让位而不是压在内容上。 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 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.signalPercent.map { "\($0)%" }, tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted ), .init( label: "延迟", value: row.latencyMilliseconds.map { "\($0) ms" }, tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted ), .init( label: "存储", value: row.storageUsageRatio.map { "\(Int(($0 * 100).rounded()))% 已用" }, tint: row.storageUsageRatio.flatMap(storageColor) ?? AITheme.muted ), .init( label: "CPU", value: Self.cpuValue(percent: row.cpuPercent, cores: row.cpuCount), tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted ), .init( label: "内存", value: row.memoryPercent.map(Self.percentText), tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted ), .init(label: "制式", value: row.networkTechnology), .init(label: "IP", value: row.ipAddress), .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) ?? "--" 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 statusLine(_ row: FleetViewModel.Row) -> String? { if row.testing { return "正在读取运行信息" } switch row.status { case .online: return nil case .degraded: return "部分接口不可用,可在详情查看失败接口" case .authRequired: return "节点开启了访问密码,进入详情登录" case .offline: 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 groupDetail(_ group: FleetGroup) -> String { model.health(for: group).detail } private func groupTint(_ group: FleetGroup) -> Color { switch model.groupMode { case .status: return group.rows.first.map { statusColor($0.status) } ?? AITheme.muted case .groups: return AITheme.primary case .none: return AITheme.muted } } @ViewBuilder private func instanceGroup(_ group: FleetGroup) -> some View { if model.groupMode == .none { ForEach(group.rows) { row in instanceEntry(row) } } else { Section { ForEach(group.rows) { row in if collapsedGroups.contains(group.id) { EmptyView() } else { instanceEntry(row) } } } header: { let health = model.health(for: group) FleetGroupHeader( title: group.title, detail: groupDetail(group), onlineValue: health.onlineAverageText, abnormalValue: health.abnormalAverageText, onlineTint: health.onlineTint ?? AITheme.muted, abnormalTint: health.abnormalTint ?? AITheme.muted, total: group.rows.count, testing: health.testing, signalValue: health.signalAverageText, latencyValue: health.latencyAverageText, storageValue: health.storageAverageText, trafficValue: health.trafficTotalText, signalTint: health.signalTint ?? AITheme.muted, latencyTint: health.latencyTint ?? AITheme.muted, storageTint: health.storageTint ?? AITheme.muted, trafficTint: health.trafficTint ?? AITheme.muted, tint: groupTint(group), isCollapsed: collapsedGroups.contains(group.id) ) { toggleGroup(group.id) } .padding(.top, 4) .background(AITheme.canvas) } } } private func toggleGroup(_ id: String) { if collapsedGroups.contains(id) { collapsedGroups.remove(id) } else { collapsedGroups.insert(id) } } 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, groupName: model.groupName(for: row.instance.groupId) ) } 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 = 6 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 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 == "暂无" } } /// 状态摘要行:只用一个状态点 + 一行说明,不再套底色。 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)") } } 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 y: CGFloat = 0 var rowHeight: CGFloat = 0 var rowWidth: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if x > 0, x + size.width > maxWidth { x = 0 y += rowHeight + spacing rowHeight = 0 rowWidth = 0 } x += size.width + spacing rowHeight = max(rowHeight, size.height) rowWidth = max(rowWidth, x - spacing) } return CGSize(width: rowWidth, height: max(0, y + rowHeight - (rowHeight > 0 ? 0 : spacing))) } 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) } } } struct FleetGroupHeader: View { let title: String let detail: String let onlineValue: String let abnormalValue: String let onlineTint: Color let abnormalTint: Color let total: Int let testing: Int let signalValue: String let latencyValue: String let storageValue: String let trafficValue: String let signalTint: Color let latencyTint: Color let storageTint: Color let trafficTint: Color let tint: Color let isCollapsed: Bool let action: () -> Void private var onlineRatio: Double { guard let text = onlineValue.split(separator: "%").first, let value = Double(text) else { return 0 } return min(1, max(0, value / 100)) } private var progressTint: Color { guard hasSettledResults else { return AITheme.muted } return onlineRatio >= 1 ? AITheme.success : (onlineRatio > 0 ? AITheme.warning : AITheme.muted) } private var progress: Double { guard hasSettledResults else { return 0 } return onlineRatio } // 整组都还在检测时,在线率/异常率没有任何可信依据,先不给结论。 private var hasSettledResults: Bool { total > 0 && testing < total } private var displayedOnlineValue: String { hasSettledResults ? onlineValue : "--" } private var displayedAbnormalValue: String { hasSettledResults ? abnormalValue : "--" } private var displayedOnlineTint: Color { hasSettledResults ? onlineTint : AITheme.muted } private var displayedAbnormalTint: Color { hasSettledResults ? abnormalTint : AITheme.muted } // 分组头部与运行概览共用同一套读数样式,扫读时不需要重新对齐视线。 private var headerReadouts: [FleetStatItem] { [ FleetStatItem(title: "节点", value: "\(total)", systemImage: "server.rack", tint: AITheme.ink), FleetStatItem( title: "在线率", value: displayedOnlineValue, systemImage: "checkmark.circle", tint: displayedOnlineTint ), FleetStatItem( title: "异常率", value: displayedAbnormalValue, systemImage: "exclamationmark.triangle", tint: displayedAbnormalTint ), FleetStatItem( title: "检测中", value: testing > 0 ? "\(testing)" : "--", systemImage: "clock.arrow.circlepath", tint: testing > 0 ? AITheme.warning : AITheme.muted ), FleetStatItem(title: "信号", value: signalValue, systemImage: "cellularbars", tint: signalTint), FleetStatItem(title: "延迟", value: latencyValue, systemImage: "speedometer", tint: latencyTint), FleetStatItem(title: "存储", value: storageValue, systemImage: "internaldrive", tint: storageTint), FleetStatItem(title: "流量", value: trafficValue, systemImage: "arrow.up.arrow.down", tint: trafficTint) ] .map { item in item.value == "不可用" ? FleetStatItem(title: item.title, value: "--", systemImage: item.systemImage, tint: AITheme.muted) : item } // 全组检测完成后,这一列只剩一个空占位,属于纯噪音。 .filter { item in item.title != "检测中" || testing > 0 } } var body: some View { Button(action: action) { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 10) { RoundedRectangle(cornerRadius: 3, style: .continuous) .fill(tint) .frame(width: 3, height: 20) Text(title) .font(.headline.weight(.semibold)) .foregroundStyle(AITheme.ink) .lineLimit(1) .layoutPriority(1) Spacer(minLength: 8) Image(systemName: isCollapsed ? "chevron.down" : "chevron.up") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(AITheme.muted) .frame(width: 24, height: 24) .accessibilityHidden(true) } // 结论未出时不画空进度条,否则会被读成"0% 在线"。 if hasSettledResults { ProgressView(value: progress) .progressViewStyle(.linear) .tint(progressTint) .frame(height: 3) } else { HStack(spacing: 6) { Image(systemName: "arrow.triangle.2.circlepath") .font(.system(size: 10, weight: .semibold)) Text("正在检测 \(testing)/\(total) 台") .font(.caption2.weight(.semibold)) .monospacedDigit() } .foregroundStyle(AITheme.muted) .frame(maxWidth: .infinity, alignment: .leading) } FleetReadoutGrid(items: headerReadouts) } .padding(.horizontal, 14) .padding(.vertical, 12) .background(AITheme.chrome) .contentShape(Rectangle()) } .buttonStyle(.plain) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(AITheme.line) } .accessibilityElement(children: .combine) .accessibilityLabel("\(title),\(detail)") .accessibilityHint(isCollapsed ? "展开分组" : "折叠分组") .accessibilityAddTraits(.isButton) } } 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 ?? "--") .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) } } } }