release(ios): 0.2.2 移除分组改用标签归类并完成石墨配色重构

- 删除分组实体:分组视图/编辑器/仓储与 groupId 全部移除,升级首启清理
  instance-groups.json 与 fleet.groupMode
- 新增批量标签表:三态覆盖度(未贴 / N/M / 全部),整批加减与新建标签
- 配色重构,全应用零蓝色:石墨墨色承担主操作与选中态,颜色只留给状态语义;
  浅色改中性冷灰,深色改低明度石墨三层
- 选中态改实心主色 + onPrimary,替换不可读的低透明度色雾(主题块、图例胶囊)
- 应用图标重做为扁平石墨底 + 黄铜 SIM 面板,参数化光栅化脚本与参考稿
- 运行概览:检测进度并入副标题,读数栅格固定八格两行并保留不可用占位
- 实例详情:仪表改两列发丝线栅格,缺数据留轨道,修掉头部高度回弹;
  补齐设备名称/存储总量/可用存储/使用率四个字段名
- 版本号显示去掉括号,设置页只显示 0.2.2
- 文档:新增 docs/ios-native-0.2.2.md,同步 ios-native.md 与两处 README
This commit is contained in:
chick
2026-09-15 02:23:24 +08:00
parent ec3ffb9db3
commit eb880e0d14
28 changed files with 757 additions and 1524 deletions
@@ -4,9 +4,7 @@ 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 对象。
@@ -15,23 +13,22 @@ struct FleetView: View {
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("--sheet-tagbatch") {
target.selectionMode = true
target.showingTagBatch = true
}
if DebugLaunch.has("--group-status") {
target.groupMode = .status
// 图例胶囊的选中态只能靠点选进入,逐页截图时留一个直达开关。
if DebugLaunch.has("--filter-degraded") {
target.statusFilter = .degraded
}
if DebugLaunch.has("--group-flat") {
target.groupMode = .none
if DebugLaunch.has("--tag-field") {
target.selectedTagFilters = ["field"]
}
#endif
_model = StateObject(wrappedValue: target)
@@ -39,7 +36,7 @@ struct FleetView: View {
var body: some View {
ScrollView {
LazyVStack(spacing: 12, pinnedViews: .sectionHeaders) {
LazyVStack(spacing: 12) {
searchField
if let errorMessage = model.errorMessage {
AIStatusBanner(
@@ -49,10 +46,10 @@ struct FleetView: View {
)
}
if !model.rows.isEmpty {
organizationControls
if showsSummary {
fleetSummary
}
tagFilterStrip
}
if model.isLoading && model.rows.isEmpty {
AICard(padding: 16) {
@@ -86,19 +83,8 @@ struct FleetView: View {
)
}
}
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)
}
ForEach(model.visibleRows) { row in
instanceEntry(row)
}
if !model.rows.isEmpty {
Text("共 \(model.rows.count) 台实例 · 显示 \(summaryRows.count) 台")
@@ -126,6 +112,11 @@ struct FleetView: View {
}
ToolbarItemGroup(placement: .primaryAction) {
AIIconButton("批量标签", systemImage: "tag", kind: .secondary) {
model.showingTagBatch = true
}
.disabled(model.selectedIDs.isEmpty)
AIIconButton("删除已选实例", systemImage: "trash", kind: .danger) {
showsDeleteDialog = true
}
@@ -138,11 +129,6 @@ struct FleetView: View {
}
} else {
ToolbarItemGroup(placement: .primaryAction) {
AIIconButton("管理分组", systemImage: "folder.badge.gearshape") {
model.showingGroupManager = true
}
.disabled(model.isLoading)
AIIconButton("批量选择", systemImage: "checklist") {
model.selectionMode = true
}
@@ -155,18 +141,8 @@ struct FleetView: View {
}
}
}
.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.showingTagBatch) {
FleetTagBatchSheet(model: model)
}
.sheet(isPresented: $model.showingForm) {
InstanceFormView(
@@ -220,13 +196,15 @@ private extension FleetView {
#endif
}
/// 概览副标题:更新时间,检测未完成时追加进度。进度原本占读数栅格一格,挪到这里后栅格正好两行。
private var updatedDetail: String {
let progress = testingCount > 0 ? " · 已检测 \(settledCount)/\(summaryRows.count) 台" : ""
guard let lastRefresh = model.lastRefresh else {
return model.isLoading ? "正在读取" : "待刷新"
return (model.isLoading ? "正在读取" : "待刷新") + progress
}
// RelativeDateTimeFormatter 在中文下会把刚发生的事说成"0秒后",这里直接兜成"刚刚"。
if Date().timeIntervalSince(lastRefresh) < 45 { return "刚刚更新" }
return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))"
if Date().timeIntervalSince(lastRefresh) < 45 { return "刚刚更新" + progress }
return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))" + progress
}
private var searchField: some View {
@@ -235,23 +213,11 @@ private extension FleetView {
}
}
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
}
}
}
// 标签直接作为筛选器使用:一条横向可滑动的胶囊,首个「全部」用于复位。
// 标签是唯一的归类手段:一条横向可滑动的胶囊,首个「全部」用于复位。
@ViewBuilder
private var tagFilterStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
if model.allTags.isEmpty == false {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
title: "全部",
@@ -276,9 +242,10 @@ private extension FleetView {
}
.padding(.vertical, 3)
.padding(.horizontal, 1)
}
.accessibilityElement(children: .contain)
.accessibilityLabel("按标签筛选")
}
.accessibilityElement(children: .contain)
.accessibilityLabel("按标签筛选")
}
private struct FilterChip: View {
@@ -360,18 +327,14 @@ private extension FleetView {
}
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 taggedCount: Int {
summaryRows.filter { $0.instance.tags.isEmpty == false }.count
}
private var summaryRows: [FleetViewModel.Row] { model.visibleRows }
private var allVisibleSelected: Bool {
@@ -379,10 +342,8 @@ private extension FleetView {
&& model.selectedIDs.count == model.visibleRows.count
}
private var batchGroupTargetIDs: [UUID] {
model.selectedIDs.isEmpty ? [] : Array(model.selectedIDs).sorted {
$0.uuidString < $1.uuidString
}
var selectedIDs: [UUID] {
model.selectedIDs.sorted { $0.uuidString < $1.uuidString }
}
private var fleetSummary: some View {
@@ -430,9 +391,8 @@ private extension FleetView {
if !isSummaryCollapsed {
FleetOrganizationFootnote(
groupCount: groupCount,
tagCount: tagCount,
grouped: groupedCount,
covered: taggedCount,
total: summaryRows.count
)
}
@@ -531,7 +491,7 @@ private extension FleetView {
Button(action: action) {
HStack(spacing: 5) {
Circle()
.fill(tint)
.fill(isSelected ? AITheme.onPrimary : tint)
.frame(width: 6, height: 6)
.accessibilityHidden(true)
Text(title)
@@ -544,12 +504,14 @@ private extension FleetView {
}
.padding(.horizontal, 9)
.frame(height: 26)
.foregroundStyle(isSelected ? tint : AITheme.muted)
.background(isSelected ? tint.opacity(0.12) : AITheme.tile)
// 图例即筛选器:选中时整颗胶囊填成状态色,文字走反色。
// onPrimary 在浅色下是白、深色下是近黑,正好是两套状态色的对比搭档。
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
.background(isSelected ? tint : AITheme.tile)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(isSelected ? tint.opacity(0.34) : AITheme.tileLine)
.strokeBorder(isSelected ? Color.clear : AITheme.tileLine)
}
// 视觉高度保持 26,但把点击区扩到 44pt 以上。
.contentShape(Capsule(style: .continuous).inset(by: -9))
@@ -624,17 +586,16 @@ private extension FleetView {
}
struct FleetOrganizationFootnote: View {
let groupCount: Int
let tagCount: Int
let grouped: Int
let covered: Int
let total: Int
var body: some View {
HStack(spacing: 6) {
Image(systemName: "folder")
Image(systemName: "tag")
.font(.system(size: 10, weight: .semibold))
.accessibilityHidden(true)
Text("\(groupCount) 个分组 · \(tagCount) 个标签 · 已归类 \(grouped)/\(max(grouped, total))")
Text(tagCount == 0 ? "还没有标签,可在编辑实例时添加" : "\(tagCount) 个标签 · 已标记 \(covered)/\(total) 台")
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
@@ -649,6 +610,7 @@ private extension FleetView {
}
.accessibilityElement(children: .combine)
}
}
private var statusSegments: [FleetStatusSegment] {
@@ -675,42 +637,19 @@ private extension FleetView {
}
}
/// 读数区固定八格两行:温度与电量即使整群节点都没有上报也保留占位,
/// 否则栅格会在七格、九格之间跳,最后一行只剩孤立的一列。
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)
FleetStatItem(title: "内存", value: memorySummary, systemImage: "memorychip", tint: loadSummaryTint),
FleetStatItem(title: "温度", value: temperatureSummary, systemImage: "thermometer.medium", tint: temperatureSummaryTint),
FleetStatItem(title: "电量", value: batterySummary, systemImage: "battery.100", tint: batterySummaryTint)
]
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 }
@@ -904,6 +843,12 @@ private extension FleetView {
.truncationMode(.middle)
}
// 四条量表把"要不要担心这台"变成一次扫视:信号、延迟、CPU、内存。
FleetGaugeStrip(
gauges: rowGauges(row),
isPending: row.testing
)
// 健康节点不再占一行重复"连接正常",只有需要解释的状态才出现说明与链路。
if let statusLine = statusLine(row) {
FleetStatusStrip(status: row.status, statusDetail: statusLine)
@@ -913,11 +858,13 @@ private extension FleetView {
FleetChainRow(items: health)
}
if model.groupName(for: row.instance.groupId) != nil || row.instance.tags.isEmpty == false {
FleetMetricGrid(
metrics: metrics,
isPending: row.testing && metrics.isEmpty
)
if 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,
@@ -930,11 +877,6 @@ private extension FleetView {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
FleetMetricGrid(
metrics: metrics,
isPending: row.testing && metrics.isEmpty
)
}
}
}
@@ -980,39 +922,49 @@ private extension FleetView {
}
/// 卡片顶部的四条量表:数值配一条发丝进度,正常时不着色,异常时只给颜色不给感叹号。
private func rowGauges(_ row: FleetViewModel.Row) -> [FleetGauge] {
[
FleetGauge(
label: "信号",
value: row.signalPercent.map { "\($0)%" } ?? "--",
ratio: row.signalPercent.map { Double($0) / 100 },
tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted
),
FleetGauge(
label: "延迟",
value: row.latencyMilliseconds.map { "\($0)ms" } ?? "--",
ratio: row.latencyMilliseconds.map { max(0, 1 - Double($0) / 1000) },
tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted
),
FleetGauge(
label: "CPU",
value: row.cpuPercent.map(Self.percentText) ?? "--",
ratio: row.cpuPercent.map { $0 / 100 },
tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted
),
FleetGauge(
label: "内存",
value: row.memoryPercent.map(Self.percentText) ?? "--",
ratio: row.memoryPercent.map { $0 / 100 },
tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted
),
]
}
private func rowMetrics(_ row: FleetViewModel.Row) -> [FleetMetric] {
// 卡片只留最该被扫到的六项:先身份(设备/运营商/号码),再链路(信号/延迟/存储)。
// 信号质量已经交给量表,这一区只放身份与容量:设备、网络、存储、续航、运行时长。
let ordered: [FleetMetric] = [
.init(label: "设备", value: row.deviceName),
.init(label: "运营商", value: row.operatorName),
.init(label: "号码", value: row.phoneNumber),
.init(
label: "信号",
value: row.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.networkTechnology),
.init(label: "IP", value: row.ipAddress),
.init(
label: "存储",
value: row.storageUsageRatio.map { "\(Int(($0 * 100).rounded()))% 已用" },
tint: row.storageUsageRatio.flatMap(storageColor) ?? AITheme.muted
),
.init(
label: "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),
@@ -1024,7 +976,7 @@ private extension FleetView {
tint: row.batteryPercent.flatMap(batteryColor) ?? AITheme.muted
),
.init(label: "固件", value: row.firmwareVersion),
.init(label: "运行时间", value: row.uptimeSeconds.map(Self.durationText)),
.init(label: "运行时长", value: row.uptimeSeconds.map(Self.durationText)),
.init(
label: "下行",
value: row.downloadRateBytesPerSecond.map(Self.rateText)
@@ -1105,75 +1057,6 @@ private extension FleetView {
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 {
@@ -1192,10 +1075,7 @@ private extension FleetView {
.accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
} else {
NavigationLink {
InstanceDetailView(
instance: row.instance,
groupName: model.groupName(for: row.instance.groupId)
)
InstanceDetailView(instance: row.instance)
} label: {
instanceCard(row)
}
@@ -1263,7 +1143,7 @@ extension FleetView {
let metrics: [FleetMetric]
var isPending = false
private static let maxVisible = 6
private static let maxVisible = 8
private var visible: [FleetMetric] { Array(metrics.prefix(Self.maxVisible)) }
@@ -1315,7 +1195,7 @@ extension FleetView {
}
if overflow > 0 {
Text("另有 \(overflow) 项指标")
Text("另有 \(overflow) 项,进入详情查看")
.font(.caption2)
.foregroundStyle(AITheme.faint)
.padding(.top, 7)
@@ -1333,6 +1213,76 @@ extension FleetView {
}
}
/// 量表单元:一个可读数值加一条发丝进度。
struct FleetGauge: Identifiable {
let label: String
let value: String
let ratio: Double?
let tint: Color
var id: String { label }
var hasValue: Bool { ratio != nil }
// 与读数栅格同一规则:只有警告和危险才配颜色。
var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning }
}
/// 四格量表条:等宽排布,缺数据时保留占位,避免数值到位后卡片抖动。
struct FleetGaugeStrip: View {
let gauges: [FleetGauge]
let isPending: Bool
var body: some View {
HStack(alignment: .top, spacing: 10) {
ForEach(gauges) { gauge in
VStack(alignment: .leading, spacing: 4) {
Text(gauge.label)
.font(.caption2)
.foregroundStyle(AITheme.faint)
.lineLimit(1)
Text(gauge.value)
.font(.footnote.weight(.semibold))
.monospacedDigit()
.foregroundStyle(color(for: gauge))
.lineLimit(1)
.minimumScaleFactor(0.7)
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule(style: .continuous)
.fill(AITheme.line)
Capsule(style: .continuous)
.fill(barColor(for: gauge))
.frame(width: max(3, proxy.size.width * CGFloat(gauge.ratio ?? 0)))
}
}
.frame(height: 4)
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(gauge.label) \(gauge.value)")
}
}
.padding(.top, 2)
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
.offset(y: -7)
}
}
private func color(for gauge: FleetGauge) -> Color {
if !gauge.hasValue { return isPending ? AITheme.faint : AITheme.muted }
return gauge.isAlert ? gauge.tint : AITheme.ink
}
/// 正常读数的进度条用墨色,颜色只留给异常,四格量表不会把卡片染成一片绿。
private func barColor(for gauge: FleetGauge) -> Color {
guard gauge.hasValue else { return AITheme.line }
return gauge.isAlert ? gauge.tint : AITheme.primary
}
}
struct FleetStatItem {
let title: String
let value: String
@@ -1580,151 +1530,6 @@ extension FleetView {
}
}
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?