Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift
T
chick 3607eddc9f release(ios): 发布 0.2.1 并重构图标与界面细节
- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读
- 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失
- 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升
- 详情头部指标精简为 4 行,数据模块改为单条读数
- 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt
- 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
2026-09-14 16:30:39 +08:00

1758 lines
57 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> = []
@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..<min(start + 4, items.count)])
}
}
var body: some View {
VStack(spacing: 0) {
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in
if rowIndex > 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..<min(start + 2, visible.count)])
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if metrics.isEmpty {
if isPending {
// 检测中用骨架占位,保持卡片高度稳定。
VStack(alignment: .leading, spacing: 8) {
AISkeletonLine(width: 0.42, height: 13)
AISkeletonLine(width: 0.68, height: 13)
}
.padding(.vertical, 9)
.accessibilityLabel("正在读取运行信息")
} else {
Text("节点未返回运行信息")
.font(.caption)
.foregroundStyle(AITheme.faint)
.padding(.vertical, 9)
}
} else {
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in
if rowIndex > 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)
}
}
}
}