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) { _model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel()) } 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 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 { ToolbarItemGroup(placement: .navigation) { Button { model.selectedIDs = model.selectedIDs.count == model.visibleRows.count ? [] : Set(model.visibleRows.map(\.id)) } label: { Image(systemName: model.selectedIDs.count == model.visibleRows.count ? "circle" : "checkmark.circle") } .accessibilityLabel(model.selectedIDs.count == model.visibleRows.count ? "取消全选" : "全选") .disabled(model.visibleRows.isEmpty) Button { model.selectionMode = false model.selectedIDs.removeAll() } label: { Image(systemName: "xmark") } .accessibilityLabel("退出批量选择") } ToolbarItemGroup(placement: .primaryAction) { Button { showsDeleteDialog = true } label: { Image(systemName: "trash") } .accessibilityLabel(model.selectedIDs.isEmpty ? "删除" : "删除 \(model.selectedIDs.count) 个实例") .disabled(model.selectedIDs.isEmpty) } } 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("节点总览") .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 return formatter }() private var updatedDetail: String { guard let lastRefresh = model.lastRefresh else { return model.isLoading ? "正在读取" : "待刷新" } 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: 8) { AISegmentedControl( "组织方式", selection: $model.groupMode, options: FleetGroupMode.allCases.map { ($0, $0.title) } ) } } private var fleetOverviewHeader: some View { Button { withAnimation(.easeOut(duration: 0.22)) { isSummaryCollapsed.toggle() } } label: { HStack(spacing: 10) { Image(systemName: "gauge.with.needle") .font(.subheadline.weight(.semibold)) .foregroundStyle(AITheme.primary) .frame(width: 22, height: 22) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 1) { Text("运行概览") .font(.headline.weight(.semibold)) .kerning(-0.1) .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: 12, weight: .semibold)) .foregroundStyle(AITheme.muted) .frame(width: 30, height: 30) .background(AITheme.control) .clipShape(Circle()) .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 batchGroupTargetIDs: [UUID] { model.selectedIDs.isEmpty ? [] : Array(model.selectedIDs).sorted { $0.uuidString < $1.uuidString } } private var fleetSummary: some View { AICard(padding: 16) { VStack(alignment: .leading, spacing: 12) { fleetOverviewHeader HStack(spacing: 10) { FleetPrimaryStat( title: "在线率", value: summaryRows.isEmpty ? "--" : "\(Int((Double(onlineCount) / Double(summaryRows.count) * 100).rounded()))%", detail: "\(onlineCount)/\(summaryRows.count) 台在线", systemImage: "checkmark.seal.fill", tint: summaryRows.isEmpty ? AITheme.muted : (onlineCount == summaryRows.count ? AITheme.success : AITheme.warning) ) FleetPrimaryStat( title: "异常节点", value: "\(abnormalCount)", detail: abnormalCount > 0 ? "需要尽快处理" : "无异常节点", systemImage: abnormalCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.seal.fill", tint: abnormalCount > 0 ? AITheme.danger : AITheme.success ) } if isSummaryCollapsed { HStack(spacing: 8) { FleetHeaderMiniStat( title: "信号", value: signalSummary, tint: signalSummaryTint, systemImage: "cellularbars" ) FleetHeaderMiniStat( title: "存储", value: storageSummary, tint: storageSummaryTint, systemImage: "internaldrive" ) FleetHeaderMiniStat( title: "分组", value: "\(groupCount)", tint: AITheme.primary, systemImage: "folder" ) } } else { HStack(alignment: .center, spacing: 10) { HStack { Text("节点状态分布") .font(.caption.weight(.semibold)) .kerning(0.2) .foregroundStyle(AITheme.muted) Spacer() Text("\(summaryRows.count) 台") .font(.caption.weight(.semibold)) .monospacedDigit() .foregroundStyle(AITheme.muted) } Grid(alignment: .leading, horizontalSpacing: 7, verticalSpacing: 0) { GridRow { ForEach(statusSegments, id: \.status) { segment in statusSegmentButton(segment) } } } if model.statusFilter != nil { Button { model.statusFilter = nil } label: { Label("清除状态筛选", systemImage: "xmark.circle.fill") .font(.caption.weight(.semibold)) .foregroundStyle(AITheme.primary) } .buttonStyle(.plain) .frame(minHeight: 32) } } } if !isSummaryCollapsed { Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { ForEach(stride(from: 0, to: fleetSummaryItems.count, by: 2).map { Array(fleetSummaryItems[$0.. some View { let isSelected = model.statusFilter == segment.status let tint = statusColor(segment.status) return Button { model.statusFilter = isSelected ? nil : segment.status } label: { VStack(alignment: .leading, spacing: 6) { Text("\(segment.count)") .font(.subheadline.weight(.bold)) .monospacedDigit() .foregroundStyle(tint) .frame(maxWidth: .infinity, alignment: .leading) Capsule(style: .continuous) .fill(tint) .frame(height: 4) } .padding(.vertical, 9) .padding(.horizontal, 10) .frame(maxWidth: .infinity, minHeight: 44) .background(isSelected ? tint.opacity(0.14) : AITheme.control) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(isSelected ? tint.opacity(0.38) : AITheme.line) } .accessibilityElement(children: .combine) .accessibilityLabel("\(segment.status.label) \(segment.count) 台") .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton]) } .buttonStyle(.plain) } private struct FleetStatusSegment { let status: ConnectionStatus let count: Int var ratio: Double { max(0, Double(count)) } } private struct FleetGroupDistributionStrip: View { let grouped: Int let ungrouped: Int let groupCount: Int let tagCount: Int private var total: Int { max(1, grouped + ungrouped) } private var groupedRatio: CGFloat { CGFloat(grouped) / CGFloat(total) } var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { DistributionBadge( title: "分组", value: "\(groupCount)", systemImage: "folder", tint: AITheme.primary ) DistributionBadge( title: "标签", value: "\(tagCount)", systemImage: "tag", tint: AITheme.success ) Spacer(minLength: 8) Text("已分组 \(grouped)/\(grouped + ungrouped)") .font(.caption2.weight(.semibold)) .monospacedDigit() .foregroundStyle(AITheme.muted) } GeometryReader { proxy in ZStack(alignment: .leading) { Capsule(style: .continuous) .fill(AITheme.control) Capsule(style: .continuous) .fill(AITheme.primary.opacity(0.30)) .frame(width: max(4, proxy.size.width * groupedRatio)) } } .frame(height: 5) } .accessibilityElement(children: .combine) } } private struct DistributionBadge: View { let title: String let value: String let systemImage: String let tint: Color var body: some View { HStack(spacing: 5) { Image(systemName: systemImage) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(tint) .accessibilityHidden(true) Text("\(title) \(value)") .font(.caption2.weight(.semibold)) .foregroundStyle(AITheme.ink) .lineLimit(1) .minimumScaleFactor(0.8) } .padding(.horizontal, 9) .frame(minHeight: 28) .background(tint.opacity(0.10)) .clipShape(Capsule(style: .continuous)) .overlay { Capsule(style: .continuous) .strokeBorder(tint.opacity(0.16)) } } } 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) } } private var fleetSummaryItems: [FleetStatItem] { var items = [ FleetStatItem( title: "节点状态", value: "\(onlineCount) 在线 · \(abnormalCount) 异常", systemImage: "server.rack", tint: abnormalCount > 0 ? AITheme.warning : AITheme.success ), 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 signalValues: [Int] { summaryRows.compactMap(\.signalPercent) } private var signalSummary: String { formatAverage(signalValues) { "\($0)%" } } 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 { AICard { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 7) { HStack(spacing: 8) { Circle() .fill(statusColor(row.status)) .frame(width: 7, height: 7) .overlay { Circle() .strokeBorder(AITheme.line) } .accessibilityHidden(true) 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) } FleetStatusStrip( status: row.status, statusDetail: row.testing ? "正在读取运行信息" : statusDetail(row) ) if let health = chainHealth(row) { LazyVGrid(columns: [GridItem(.flexible(), spacing: 6), GridItem(.flexible())], spacing: 6) { ForEach(health) { item in FleetLinkState(item: item) } } } if model.groupName(for: row.instance.groupId) != nil || !row.instance.tags.isEmpty { 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 InlinePill(title: tag, systemImage: "tag", tint: AITheme.muted) } } } FleetMetricGrid(metrics: rowMetrics(row)) } } .overlay(alignment: .topLeading) { if model.selectionMode { Button { toggle(row.id) } label: { Image( systemName: model.selectedIDs.contains(row.id) ? "checkmark.circle.fill" : "circle" ) .font(.system(size: 20, weight: .medium)) .foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted) .frame(width: 44, height: 44) .background(.ultraThinMaterial, in: Circle()) .overlay { Circle() .strokeBorder(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.line) } } .buttonStyle(.plain) .padding(4) .accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择") .accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : []) } } } 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 } return [ FleetLinkItem( title: "硬件", isAvailable: values.hardwareOnline, ), FleetLinkItem( title: "控制", isAvailable: values.controlOnline, ), FleetLinkItem( title: "SIM", isAvailable: values.simPresent, ), FleetLinkItem( title: "蜂窝", isAvailable: values.cellularOnline, ), ] } private func rowMetrics(_ row: FleetViewModel.Row) -> [FleetMetric] { var metrics: [FleetMetric] = [ .init(label: "设备", value: row.deviceName, systemImage: "iphone.gen3"), .init(label: "运营商", value: row.operatorName, systemImage: "antenna.radiowaves.left.and.right"), .init(label: "号码", value: row.phoneNumber, systemImage: "simcard"), .init(label: "制式", value: row.networkTechnology, systemImage: "dot.radiowaves.left.and.right"), .init( label: "信号", value: row.signalPercent.map { "\($0)%" }, systemImage: "cellularbars", tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted ), .init(label: "IP", value: row.ipAddress, systemImage: "network"), .init( label: "延迟", value: row.latencyMilliseconds.map { "\($0) ms" }, systemImage: "speedometer", tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted ) ] metrics.append( contentsOf: [ .init( label: "CPU", value: Self.cpuValue(percent: row.cpuPercent, cores: row.cpuCount), systemImage: "cpu", tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted ), .init( label: "内存", value: row.memoryPercent.map { Self.percentText($0) }, systemImage: "memorychip", tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted ), .init( label: "温度", value: row.temperatureCelsius.map { Self.temperatureText($0) }, systemImage: "thermometer.medium", tint: row.temperatureCelsius.flatMap(temperatureColor) ?? AITheme.muted ), .init( label: "电量", value: row.batteryPercent.map { Self.percentText($0) }, systemImage: "battery.100", tint: row.batteryPercent.flatMap(batteryColor) ?? AITheme.muted ) ] ) metrics.append( contentsOf: [ .init(label: "固件", value: row.firmwareVersion, systemImage: "waveform.path.ecg"), .init(label: "运行时间", value: row.uptimeSeconds.map(Self.durationText), systemImage: "clock"), .init( label: "存储", value: row.storageUsageRatio.map { "\(Int(($0 * 100).rounded()))% 已用" }, systemImage: "internaldrive", tint: row.storageUsageRatio.flatMap(storageColor) ?? AITheme.muted ), .init( label: "下行", value: row.downloadRateBytesPerSecond.map(Self.rateText), systemImage: "arrow.down" ), .init( label: "上行", value: row.uploadRateBytesPerSecond.map(Self.rateText), systemImage: "arrow.up" ), .init(label: "今日消息", value: row.messagesToday.map { "\($0) 条" }, systemImage: "message") ] ) return metrics.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) } private func statusDetail(_ row: FleetViewModel.Row) -> String { let parts = [ row.operatorName, row.deviceName, row.connectivity, row.latencyMilliseconds.map { "延迟 \($0) ms" } ] .compactMap { $0 } .filter { !$0.isEmpty } guard parts.isEmpty == false else { return row.status == .online ? "连接正常" : "暂无运行信息" } return parts.joined(separator: " · ") } 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 .tags, .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 { instanceCard(row) .contentShape(Rectangle()) } else { NavigationLink { InstanceDetailView( instance: row.instance, groupName: model.groupName(for: row.instance.groupId) ) } label: { instanceCard(row) } .buttonStyle(.plain) .contextMenu { 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 body: some View { if metrics.isEmpty { HStack(spacing: 8) { Image(systemName: "clock.arrow.circlepath") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(AITheme.muted) .accessibilityHidden(true) Text("运行信息等待节点返回") .font(.caption) .foregroundStyle(AITheme.muted) .frame(maxWidth: .infinity, alignment: .leading) } .padding(11) .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) .background(AITheme.control) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } else { Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { ForEach(stride(from: 0, to: metrics.count, by: 2).map { metrics[$0.. 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 { if testing > 0 { return AITheme.warning } return onlineRatio >= 1 ? AITheme.success : (onlineRatio > 0 ? AITheme.warning : AITheme.muted) } private var progress: Double { onlineRatio } var body: some View { Button(action: action) { VStack(alignment: .leading, spacing: 9) { HStack(spacing: 10) { RoundedRectangle(cornerRadius: 3, style: .continuous) .fill(tint) .frame(width: 4, height: 24) 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) } Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 7) { GridRow { FleetHeaderMetric( title: "节点", value: "\(total)", systemImage: "server.rack", tint: AITheme.ink ) FleetHeaderMetric( title: "在线率", value: onlineValue, systemImage: "checkmark.circle", tint: onlineTint, placeholder: onlineValue == "不可用" ) } GridRow { FleetHeaderMetric( title: "异常率", value: abnormalValue, systemImage: "exclamationmark.triangle", tint: abnormalTint, placeholder: abnormalValue == "不可用" ) FleetHeaderMetric( title: "检测中", value: "\(testing)", systemImage: "clock.arrow.circlepath", tint: testing > 0 ? AITheme.warning : AITheme.muted, placeholder: testing == 0 ) } } HStack(spacing: 8) { ProgressView(value: progress) .progressViewStyle(.linear) .tint(progressTint) .frame(height: 4) Text("\(onlineRatio.formatted(.number.precision(.fractionLength(0))))%") .font(.caption2.weight(.semibold)) .monospacedDigit() .foregroundStyle(progressTint) .frame(width: 38, alignment: .trailing) } Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 7) { GridRow { FleetHeaderMetric(title: "信号", value: signalValue, systemImage: "cellularbars", tint: signalTint, placeholder: signalValue == "不可用") FleetHeaderMetric(title: "延迟", value: latencyValue, systemImage: "speedometer", tint: latencyTint, placeholder: latencyValue == "不可用") } GridRow { FleetHeaderMetric(title: "存储", value: storageValue, systemImage: "internaldrive", tint: storageTint, placeholder: storageValue == "不可用") FleetHeaderMetric(title: "流量", value: trafficValue, systemImage: "arrow.up.arrow.down", tint: trafficTint, placeholder: trafficValue == "不可用") } } } .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) } private struct FleetHeaderMetric: View { let title: String let value: String let systemImage: String let tint: Color var placeholder = false var body: some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 4) { Image(systemName: systemImage) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(placeholder ? AITheme.muted : tint) .accessibilityHidden(true) Text(title) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(AITheme.muted) .lineLimit(1) } Text(value) .font(.caption2.weight(.semibold)) .monospacedDigit() .foregroundStyle(placeholder ? AITheme.muted : AITheme.ink) .lineLimit(1) .minimumScaleFactor(0.7) } .frame(maxWidth: .infinity, alignment: .leading) } } } struct FleetMetric: View { let label: String let value: String? let systemImage: String var tint: Color = AITheme.primary var hasValue: Bool { value?.isEmpty == false } var body: some View { if hasValue { HStack(spacing: 7) { Image(systemName: systemImage) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(tint) .frame(width: 16) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 1) { Text(label) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(AITheme.muted) .lineLimit(1) Text(value ?? "暂无") .font(.caption.weight(.medium)) .foregroundStyle(AITheme.ink) .lineLimit(1) .truncationMode(.middle) } } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } } } }