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:
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user