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:
@@ -7,15 +7,11 @@ struct InstanceDetailView: View {
|
||||
|
||||
init(
|
||||
instance: Instance,
|
||||
groupName: String? = nil,
|
||||
model: DeviceViewModel? = nil
|
||||
) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance))
|
||||
self.groupName = groupName
|
||||
}
|
||||
|
||||
private let groupName: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
@@ -98,9 +94,6 @@ struct InstanceDetailView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("本机保存的配置", systemImage: "slider.horizontal.3")
|
||||
AIKeyValueRow("地址", value: model.instance.origin.absoluteString, monospaced: true)
|
||||
if let groupName {
|
||||
AIKeyValueRow("分组", value: groupName)
|
||||
}
|
||||
AIKeyValueRow("标签", value: model.instance.tags.isEmpty ? "无" : model.instance.tags.joined(separator: "、"))
|
||||
AIKeyValueRow("访问凭据", value: model.instance.credentialConfigured ? "已保存" : "未设置")
|
||||
}
|
||||
@@ -135,11 +128,8 @@ struct InstanceDetailView: View {
|
||||
|
||||
moduleAvailability
|
||||
|
||||
if groupName != nil || !model.instance.tags.isEmpty {
|
||||
if !model.instance.tags.isEmpty {
|
||||
FlowLayout(spacing: 7) {
|
||||
if let groupName {
|
||||
AITag("分组 · \(groupName)", tint: AITheme.primary)
|
||||
}
|
||||
ForEach(model.instance.tags, id: \.self) { tag in
|
||||
AITag(tag)
|
||||
}
|
||||
@@ -220,6 +210,8 @@ struct InstanceDetailView: View {
|
||||
let progress: Double?
|
||||
|
||||
var id: String { title }
|
||||
// 与节点卡片同一规则:只有越过警告线才着色,正常值走墨色。
|
||||
var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning }
|
||||
}
|
||||
|
||||
private var allHeaderMetrics: [HeaderMetric] {
|
||||
@@ -428,16 +420,17 @@ struct InstanceDetailView: View {
|
||||
)
|
||||
}
|
||||
|
||||
/// 阈值只回答"要不要提醒",健康值不着色,四格量表不会染成一片绿。
|
||||
private func signalTint(_ value: Double) -> Color {
|
||||
value >= 60 ? AITheme.success : (value >= 30 ? AITheme.warning : AITheme.danger)
|
||||
value >= 60 ? AITheme.ink : (value >= 30 ? AITheme.warning : AITheme.danger)
|
||||
}
|
||||
|
||||
private func latencyTint(_ value: Double) -> Color {
|
||||
value < 300 ? AITheme.success : (value < 800 ? AITheme.warning : AITheme.danger)
|
||||
value < 300 ? AITheme.ink : (value < 800 ? AITheme.warning : AITheme.danger)
|
||||
}
|
||||
|
||||
private func resourceTint(_ value: Double) -> Color {
|
||||
value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.success)
|
||||
value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.ink)
|
||||
}
|
||||
|
||||
private struct DetailHeaderMetric: View {
|
||||
@@ -456,29 +449,20 @@ struct InstanceDetailView: View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(metric.isAlert ? tint : AITheme.faint)
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
if isProgressVisible {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(tint)
|
||||
.frame(height: 4)
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(height: 4)
|
||||
}
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(.title3.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
|
||||
.foregroundStyle(metric.isAlert ? tint : AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.minimumScaleFactor(0.72)
|
||||
@@ -489,34 +473,65 @@ struct InstanceDetailView: View {
|
||||
.frame(maxWidth: .infinity, minHeight: 14, maxHeight: 14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
// 读数在上、进度在下,缺数据时也留一条轨道,数值到位后格子不跳动。
|
||||
if isProgressVisible {
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule(style: .continuous).fill(AITheme.line)
|
||||
Capsule(style: .continuous)
|
||||
.fill(metric.isAlert ? tint : AITheme.primary)
|
||||
.frame(width: max(3, proxy.size.width * CGFloat(min(1, max(0, progress)))))
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
.accessibilityHidden(true)
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, minHeight: 76, maxHeight: 76, alignment: .topLeading)
|
||||
.aiInsetSurface()
|
||||
.frame(maxWidth: .infinity, minHeight: 62, alignment: .topLeading)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// 统一两列栅格。此前首行放 4 个指标、其余两列,导致首行指标卡只有下方一半宽。
|
||||
/// 两列发丝线栅格:量表是头部卡片内的读数,不再各自套一层底色块。
|
||||
private struct DetailMetricGrid: View {
|
||||
let metrics: [HeaderMetric]
|
||||
|
||||
private var rows: [[HeaderMetric]] {
|
||||
stride(from: 0, to: metrics.count, by: 2).map {
|
||||
Array(metrics[$0..<min($0 + 2, metrics.count)])
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
||||
ForEach(
|
||||
stride(from: 0, to: metrics.count, by: 2).map {
|
||||
Array(metrics[$0..<min($0 + 2, metrics.count)])
|
||||
},
|
||||
id: \.first?.id
|
||||
) { rowMetrics in
|
||||
GridRow {
|
||||
ForEach(rowMetrics) { DetailHeaderMetric(metric: $0) }
|
||||
if rowMetrics.count == 1 {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, rowMetrics in
|
||||
if rowIndex > 0 {
|
||||
Rectangle()
|
||||
.fill(AITheme.line)
|
||||
.frame(height: 1)
|
||||
}
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
ForEach(Array(rowMetrics.enumerated()), id: \.element.id) { columnIndex, metric in
|
||||
DetailHeaderMetric(metric: metric)
|
||||
.padding(.leading, columnIndex == 0 ? 0 : 14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if rowMetrics.count < 2 {
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(AITheme.line)
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,9 +556,7 @@ struct InstanceDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.aiInsetSurface()
|
||||
.accessibilityLabel("设备信息读取中")
|
||||
} else if items.isEmpty == false {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -564,9 +577,13 @@ struct InstanceDetailView: View {
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.aiInsetSurface()
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(AITheme.line)
|
||||
.frame(height: 1)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -797,11 +814,15 @@ struct InstanceDetailView: View {
|
||||
"core_count": "核心数",
|
||||
"model_name": "CPU 型号",
|
||||
"state": "连接状态",
|
||||
"latency_ms": "延迟(ms)"
|
||||
"latency_ms": "延迟(ms)",
|
||||
"name": "设备名称",
|
||||
"disk_total_bytes": "存储总量",
|
||||
"disk_available_bytes": "可用存储",
|
||||
"percent": "使用率"
|
||||
]
|
||||
|
||||
private let valueOrder: [String] = [
|
||||
"model", "manufacturer", "brand", "imei", "meid", "revision", "firmware_version",
|
||||
"model", "manufacturer", "brand", "name", "imei", "meid", "revision", "firmware_version",
|
||||
"online", "powered", "present", "slots", "active_slots", "active", "iccid", "imsi",
|
||||
"phone_numbers", "phone_number", "sms_center", "operator_name", "operator", "carrier",
|
||||
"registration_status", "registration_state", "technology_preference", "network_type",
|
||||
@@ -810,7 +831,8 @@ struct InstanceDetailView: View {
|
||||
"cpu_load", "memory_percent", "memory_total_mb", "memory_available_mb", "usage",
|
||||
"temperature", "max_temperature_c", "battery_level", "battery_percent", "download",
|
||||
"upload", "download_bytes", "upload_bytes", "ipv4", "ipv6", "ip_address", "gateway",
|
||||
"dns", "interface", "ssid", "apn", "core_count", "model_name", "messages_today", "calls"
|
||||
"dns", "interface", "ssid", "apn", "core_count", "model_name", "messages_today", "calls",
|
||||
"disk_total_bytes", "disk_available_bytes", "percent"
|
||||
]
|
||||
|
||||
private let durationKeys: Set<String> = ["uptime", "uptime_seconds"]
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 分组被移除后,批量归类这件事由标签承担:一次点选给多台节点贴同一个标签。
|
||||
struct FleetTagBatchSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
@State private var draft = ""
|
||||
@State private var isBusy = false
|
||||
|
||||
private var targets: [UUID] { model.selectedIDs.sorted { $0.uuidString < $1.uuidString } }
|
||||
|
||||
private var selectedNames: [String] {
|
||||
model.rows.filter { model.selectedIDs.contains($0.id) }.map(\.instance.name).sorted {
|
||||
$0.localizedCompare($1) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
targetsCard
|
||||
tagGridCard
|
||||
newTagCard
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("批量标签")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
AIToolbarButton("完成", kind: .primary) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var targetsCard: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle("已选 \(targets.count) 台", systemImage: "checklist")
|
||||
Text(selectedNames.joined(separator: "、"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(3)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var tagGridCard: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("现有标签", systemImage: "tag")
|
||||
if model.allTags.isEmpty {
|
||||
Text("还没有标签,先在下方新建一个。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.faint)
|
||||
} else {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 112), alignment: .leading)],
|
||||
alignment: .leading,
|
||||
spacing: 8
|
||||
) {
|
||||
ForEach(model.allTags, id: \.self) { tag in
|
||||
CoverageChip(
|
||||
title: tag,
|
||||
covered: model.tagCoverage(tag, in: targets),
|
||||
total: targets.count,
|
||||
isBusy: isBusy
|
||||
) {
|
||||
Task { await apply(tag, removing: model.tagCoverage(tag, in: targets) == targets.count) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var newTagCard: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AIFieldLabel("新建标签并贴上")
|
||||
HStack(spacing: 10) {
|
||||
TextField("如 深圳-现场", text: $draft)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.accessibilityLabel("新标签名称")
|
||||
.submitLabel(.done)
|
||||
.onSubmit { submitDraft() }
|
||||
.disabled(isBusy)
|
||||
Button(isBusy ? "处理中" : "添加") { submitDraft() }
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fixedWidth: 84))
|
||||
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func submitDraft() {
|
||||
let tag = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard tag.isEmpty == false, isBusy == false else { return }
|
||||
draft = ""
|
||||
Task { await apply(tag, removing: false) }
|
||||
}
|
||||
|
||||
private func apply(_ tag: String, removing: Bool) async {
|
||||
guard isBusy == false, targets.isEmpty == false else { return }
|
||||
isBusy = true
|
||||
await model.applyTag(tag, to: targets, removing: removing)
|
||||
isBusy = false
|
||||
}
|
||||
|
||||
/// 覆盖状态直接写在胶囊上:全部已贴 / 部分已贴 / 未贴,点击即切换整批。
|
||||
private struct CoverageChip: View {
|
||||
let title: String
|
||||
let covered: Int
|
||||
let total: Int
|
||||
let isBusy: Bool
|
||||
let action: () -> Void
|
||||
|
||||
private var isComplete: Bool { total > 0 && covered == total }
|
||||
private var isPartial: Bool { covered > 0 && covered < total }
|
||||
|
||||
private var stateText: String {
|
||||
if isComplete { return "全部" }
|
||||
if isPartial { return "\(covered)/\(total)" }
|
||||
return "未贴"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: isComplete ? "checkmark.circle.fill" : (isPartial ? "circle.lefthalf.filled" : "circle"))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.font(.footnote.weight(isComplete || isPartial ? .semibold : .medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text(stateText)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.opacity(0.72)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 38)
|
||||
.foregroundStyle(isComplete ? AITheme.onPrimary : (isPartial ? AITheme.ink : AITheme.muted))
|
||||
.background(isComplete ? AITheme.primary : (isPartial ? AITheme.tile : AITheme.surface))
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
.overlay {
|
||||
Capsule(style: .continuous)
|
||||
.strokeBorder(isComplete ? AITheme.primary : (isPartial ? AITheme.primary.opacity(0.42) : AITheme.line))
|
||||
}
|
||||
.contentShape(Capsule(style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isBusy || total == 0)
|
||||
.accessibilityLabel(isComplete ? "从已选节点移除标签 \(title)" : "给已选节点贴上标签 \(title)")
|
||||
.accessibilityAddTraits(isComplete ? [.isButton, .isSelected] : .isButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
|
||||
@@ -1,135 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum FleetGroupMode: String, CaseIterable, Identifiable {
|
||||
case none
|
||||
case status
|
||||
case groups
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .none: return "平铺"
|
||||
case .status: return "状态"
|
||||
case .groups: return "分组"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FleetGroup: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let rows: [FleetViewModel.Row]
|
||||
}
|
||||
|
||||
struct FleetGroupHealth: Equatable {
|
||||
var total = 0
|
||||
var online = 0
|
||||
var abnormal = 0
|
||||
var testing = 0
|
||||
var signalValues: [Int] = []
|
||||
var latencyValues: [Int] = []
|
||||
var batteryValues: [Double] = []
|
||||
var storageValues: [Double] = []
|
||||
var trafficValues: [Double] = []
|
||||
var onlineValues: [Bool] = []
|
||||
var abnormalValues: [Bool] = []
|
||||
|
||||
private var signalAverage: Double? {
|
||||
average(signalValues.map(Double.init))
|
||||
}
|
||||
|
||||
private var latencyAverage: Double? {
|
||||
average(latencyValues.map(Double.init))
|
||||
}
|
||||
|
||||
private var batteryAverage: Double? {
|
||||
average(batteryValues)
|
||||
}
|
||||
|
||||
private var storageAverage: Double? {
|
||||
average(storageValues)
|
||||
}
|
||||
|
||||
private var trafficTotal: Double {
|
||||
trafficValues.reduce(0, +)
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
var parts = ["\(total) 台"]
|
||||
if let signal = signalAverage {
|
||||
parts.append("信号 \(Int(signal.rounded()))%")
|
||||
}
|
||||
if let latency = latencyAverage {
|
||||
parts.append("延迟 \(Int(latency.rounded())) ms")
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var signalTint: Color? {
|
||||
signalAverage.map { $0 >= 60 ? AITheme.success : ($0 >= 30 ? AITheme.warning : AITheme.danger) }
|
||||
}
|
||||
|
||||
var latencyTint: Color? {
|
||||
latencyAverage.map { $0 < 300 ? AITheme.success : ($0 < 800 ? AITheme.warning : AITheme.danger) }
|
||||
}
|
||||
|
||||
var batteryTint: Color? {
|
||||
batteryAverage.map { $0 <= 20 ? AITheme.danger : ($0 <= 40 ? AITheme.warning : AITheme.success) }
|
||||
}
|
||||
|
||||
var storageTint: Color? {
|
||||
storageAverage.map { $0 >= 0.90 ? AITheme.danger : ($0 >= 0.75 ? AITheme.warning : AITheme.success) }
|
||||
}
|
||||
|
||||
var trafficTint: Color? {
|
||||
trafficValues.isEmpty ? nil : (trafficTotal >= 50_000_000 ? AITheme.warning : AITheme.success)
|
||||
}
|
||||
|
||||
var onlineAverageText: String {
|
||||
guard onlineValues.isEmpty == false else { return "不可用" }
|
||||
let ratio = Double(onlineValues.filter { $0 }.count) / Double(onlineValues.count)
|
||||
return "\(Int((ratio * 100).rounded()))%"
|
||||
}
|
||||
|
||||
var onlineTint: Color? {
|
||||
onlineValues.isEmpty ? nil : (online > 0 ? AITheme.success : AITheme.muted)
|
||||
}
|
||||
|
||||
var abnormalAverageText: String {
|
||||
guard abnormalValues.isEmpty == false else { return "不可用" }
|
||||
let ratio = Double(abnormalValues.filter { $0 }.count) / Double(abnormalValues.count)
|
||||
return "\(Int((ratio * 100).rounded()))%"
|
||||
}
|
||||
|
||||
var abnormalTint: Color? {
|
||||
abnormalValues.isEmpty ? nil : (abnormal > 0 ? AITheme.danger : AITheme.success)
|
||||
}
|
||||
|
||||
private func average(_ values: [Double]) -> Double? {
|
||||
guard values.isEmpty == false else { return nil }
|
||||
return values.reduce(0, +) / Double(values.count)
|
||||
}
|
||||
|
||||
var signalAverageText: String {
|
||||
signalAverage.map { "\(Int($0.rounded()))%" } ?? "不可用"
|
||||
}
|
||||
|
||||
var latencyAverageText: String {
|
||||
latencyAverage.map { "\(Int($0.rounded())) ms" } ?? "不可用"
|
||||
}
|
||||
|
||||
var storageAverageText: String {
|
||||
storageAverage.map { "\(Int(($0 * 100).rounded()))% 已用" } ?? "不可用"
|
||||
}
|
||||
|
||||
var trafficTotalText: String {
|
||||
guard trafficValues.isEmpty == false else { return "不可用" }
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(trafficTotal), countStyle: .decimal) + "/s"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FleetViewModel: ObservableObject {
|
||||
struct Row: Equatable, Identifiable {
|
||||
@@ -182,11 +53,6 @@ final class FleetViewModel: ObservableObject {
|
||||
@Published var rows: [Row] = []
|
||||
@Published var searchText = ""
|
||||
@Published var statusFilter: ConnectionStatus?
|
||||
@Published var groupMode: FleetGroupMode = .groups {
|
||||
didSet {
|
||||
preferences.set(groupMode.rawValue, forKey: "fleet.groupMode")
|
||||
}
|
||||
}
|
||||
@Published var selectedTagFilters: Set<String> = [] {
|
||||
didSet {
|
||||
let values = Array(selectedTagFilters).sorted()
|
||||
@@ -199,14 +65,12 @@ final class FleetViewModel: ObservableObject {
|
||||
@Published private(set) var lastRefresh: Date?
|
||||
@Published var errorMessage: String?
|
||||
@Published var showingForm = false
|
||||
@Published var showingGroupManager = false
|
||||
@Published private(set) var groups: [InstanceGroup] = []
|
||||
@Published var showingTagBatch = false
|
||||
|
||||
private let repository: any InstanceRepository
|
||||
private let probe: ConnectionProbe
|
||||
private let credentials: any CredentialStore
|
||||
private let deviceService: (any DeviceSummaryService)?
|
||||
private let groupStore: (any InstanceGroupStore)?
|
||||
private let preferences: UserDefaults
|
||||
|
||||
init(
|
||||
@@ -214,18 +78,13 @@ final class FleetViewModel: ObservableObject {
|
||||
probe: ConnectionProbe,
|
||||
credentials: any CredentialStore,
|
||||
deviceService: (any DeviceSummaryService)? = nil,
|
||||
groupStore: (any InstanceGroupStore)? = nil,
|
||||
preferences: UserDefaults = .standard
|
||||
) {
|
||||
self.repository = repository
|
||||
self.probe = probe
|
||||
self.credentials = credentials
|
||||
self.deviceService = deviceService
|
||||
self.groupStore = groupStore
|
||||
self.preferences = preferences
|
||||
// 旧版本的「按标签分组」已废弃,标签现在只作为筛选条件存在。
|
||||
let storedMode = preferences.string(forKey: "fleet.groupMode")
|
||||
groupMode = FleetGroupMode(rawValue: storedMode ?? "") ?? .groups
|
||||
selectedTagFilters = Set(preferences.stringArray(forKey: "fleet.selectedTagFilters") ?? [])
|
||||
}
|
||||
|
||||
@@ -241,7 +100,6 @@ final class FleetViewModel: ObservableObject {
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
await loadGroups()
|
||||
do {
|
||||
let query = InstanceQuery(search: searchText.isEmpty ? nil : searchText)
|
||||
let instances = try await repository.list(query)
|
||||
@@ -287,7 +145,6 @@ final class FleetViewModel: ObservableObject {
|
||||
name: input.name,
|
||||
origin: input.origin,
|
||||
tags: input.tags,
|
||||
groupId: input.groupId,
|
||||
password: input.password
|
||||
)
|
||||
)
|
||||
@@ -318,63 +175,37 @@ final class FleetViewModel: ObservableObject {
|
||||
await delete(Array(selectedIDs))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createGroup(named name: String) async throws -> InstanceGroup {
|
||||
guard let groupStore else { throw RepositoryError.notFound }
|
||||
let group = try await groupStore.create(InstanceGroupInput(name: name))
|
||||
await loadGroups()
|
||||
return group
|
||||
}
|
||||
|
||||
func updateGroup(_ id: UUID, name: String) async throws {
|
||||
guard let groupStore else { return }
|
||||
_ = try await groupStore.update(id, name)
|
||||
await loadGroups()
|
||||
}
|
||||
|
||||
func deleteGroup(_ id: UUID) async throws {
|
||||
guard let groupStore else { return }
|
||||
try await groupStore.delete(id)
|
||||
for row in rows where row.instance.groupId == id {
|
||||
_ = try await repository.update(
|
||||
row.instance.id,
|
||||
expectedRevision: row.instance.configRevision,
|
||||
InstancePatch(groupId: nil)
|
||||
)
|
||||
}
|
||||
await loadGroups()
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func assign(groupID: UUID?, to ids: [UUID]) async throws {
|
||||
/// 批量给选中的节点增删标签:标签是唯一的归类手段,所以这里必须能成批操作。
|
||||
func applyTag(_ tag: String, to ids: [UUID], removing: Bool = false) async {
|
||||
let normalized = Self.normalizedTag(tag)
|
||||
guard normalized.isEmpty == false else { return }
|
||||
var failures = 0
|
||||
for id in ids {
|
||||
guard let row = rows.first(where: { $0.id == id }) else { continue }
|
||||
var updated = row.instance.tags.filter { Self.normalizedTag($0) != normalized }
|
||||
if removing == false {
|
||||
updated.append(tag.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
do {
|
||||
_ = try await repository.update(
|
||||
id,
|
||||
expectedRevision: row.instance.configRevision,
|
||||
InstancePatch(groupId: groupID)
|
||||
InstancePatch(tags: updated)
|
||||
)
|
||||
} catch {
|
||||
failures += 1
|
||||
errorMessage = "更新 \(row.instance.name) 分组失败"
|
||||
errorMessage = "更新 \(row.instance.name) 标签失败"
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
throw RepositoryError.databaseFailed
|
||||
if failures > 0, errorMessage == nil {
|
||||
errorMessage = "部分节点标签更新失败"
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func loadGroups() async {
|
||||
guard let groupStore else { return }
|
||||
groups = (try? await groupStore.list()) ?? []
|
||||
}
|
||||
|
||||
func groupName(for id: UUID?) -> String? {
|
||||
guard let id else { return nil }
|
||||
return groups.first { $0.id == id }?.name
|
||||
func tagNames(in ids: [UUID]) -> [String] {
|
||||
let set = Set(rows.filter { ids.contains($0.id) }.flatMap { $0.instance.tags.map(Self.normalizedTag) })
|
||||
return set.sorted { $0.localizedCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
private func applyProbe(
|
||||
@@ -413,6 +244,7 @@ final class FleetViewModel: ObservableObject {
|
||||
rows[index].testing = false
|
||||
}
|
||||
|
||||
/// 列表顺序:状态差的排前面,同组内按名称,扫读时问题节点一定在第一屏。
|
||||
var visibleRows: [Row] {
|
||||
rows.filter { row in
|
||||
if let statusFilter, row.status != statusFilter {
|
||||
@@ -440,6 +272,21 @@ final class FleetViewModel: ObservableObject {
|
||||
$0.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
.sorted { lhs, rhs in
|
||||
let left = severity(lhs.status)
|
||||
let right = severity(rhs.status)
|
||||
if left != right { return left < right }
|
||||
return lhs.instance.name.localizedCompare(rhs.instance.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func severity(_ status: ConnectionStatus) -> Int {
|
||||
switch status {
|
||||
case .offline: return 0
|
||||
case .authRequired, .degraded: return 1
|
||||
case .unknown: return 2
|
||||
case .online: return 3
|
||||
}
|
||||
}
|
||||
|
||||
var allTags: [String] {
|
||||
@@ -464,6 +311,17 @@ final class FleetViewModel: ObservableObject {
|
||||
selectedTagFilters.contains(Self.normalizedTag(tag))
|
||||
}
|
||||
|
||||
/// 选中的这批节点里,有多少台已经带上这个标签:决定胶囊显示"全部/部分/未标记"。
|
||||
func tagCoverage(_ tag: String, in ids: [UUID]) -> Int {
|
||||
let needle = Self.normalizedTag(tag)
|
||||
guard needle.isEmpty == false else { return 0 }
|
||||
let targets = Set(ids)
|
||||
return rows.filter { row in
|
||||
targets.contains(row.id)
|
||||
&& row.instance.tags.map(Self.normalizedTag).contains(needle)
|
||||
}.count
|
||||
}
|
||||
|
||||
func toggleTagFilter(_ tag: String) {
|
||||
let normalized = Self.normalizedTag(tag)
|
||||
guard normalized.isEmpty == false else { return }
|
||||
@@ -482,62 +340,6 @@ final class FleetViewModel: ObservableObject {
|
||||
private static func normalizedTag(_ tag: String) -> String {
|
||||
tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
var groupedRows: [FleetGroup] {
|
||||
let rows = visibleRows.sorted {
|
||||
$0.instance.name.localizedCompare($1.instance.name) == .orderedAscending
|
||||
}
|
||||
switch groupMode {
|
||||
case .none:
|
||||
return [FleetGroup(id: "", title: "", rows: rows)]
|
||||
case .status:
|
||||
let order: [ConnectionStatus] = [.online, .degraded, .authRequired, .offline, .unknown]
|
||||
return order.compactMap { status in
|
||||
let matched = rows.filter { $0.status == status }
|
||||
guard !matched.isEmpty else { return nil }
|
||||
return FleetGroup(id: status.rawValue, title: status.label, rows: matched)
|
||||
}
|
||||
case .groups:
|
||||
var groupedRows: [UUID: [Row]] = [:]
|
||||
var ungroupedRows: [Row] = []
|
||||
for row in rows {
|
||||
if let id = row.instance.groupId, groupName(for: id) != nil {
|
||||
groupedRows[id, default: []].append(row)
|
||||
} else {
|
||||
ungroupedRows.append(row)
|
||||
}
|
||||
}
|
||||
let namedGroups = groupedRows.map { key, values in
|
||||
FleetGroup(
|
||||
id: key.uuidString,
|
||||
title: groupName(for: key) ?? "未命名分组",
|
||||
rows: values
|
||||
)
|
||||
}
|
||||
.sorted { $0.title.localizedCompare($1.title) == .orderedAscending }
|
||||
return namedGroups + (ungroupedRows.isEmpty
|
||||
? []
|
||||
: [FleetGroup(id: "__ungrouped__", title: "未分组", rows: ungroupedRows)])
|
||||
}
|
||||
}
|
||||
|
||||
func health(for group: FleetGroup) -> FleetGroupHealth {
|
||||
FleetGroupHealth(
|
||||
total: group.rows.count,
|
||||
online: group.rows.filter { $0.status == .online }.count,
|
||||
abnormal: group.rows.filter { $0.status != .online && $0.status != .unknown }.count,
|
||||
testing: group.rows.filter(\.testing).count,
|
||||
signalValues: group.rows.compactMap(\.signalPercent),
|
||||
latencyValues: group.rows.compactMap(\.latencyMilliseconds),
|
||||
batteryValues: group.rows.compactMap(\.batteryPercent),
|
||||
storageValues: group.rows.compactMap(\.storageUsageRatio),
|
||||
trafficValues: group.rows.compactMap(\.downloadRateBytesPerSecond)
|
||||
+ group.rows.compactMap(\.uploadRateBytesPerSecond)
|
||||
,
|
||||
onlineValues: group.rows.map { $0.status == .online },
|
||||
abnormalValues: group.rows.map { $0.status != .online && $0.status != .unknown }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protocol DeviceSummaryService {
|
||||
|
||||
@@ -9,7 +9,6 @@ struct InstanceFormView: View {
|
||||
@State private var tags: [String] = []
|
||||
@State private var tagDraft = ""
|
||||
@State private var password = ""
|
||||
@State private var selectedGroupId: UUID?
|
||||
@State private var validationMessage: String?
|
||||
@State private var isSaving = false
|
||||
|
||||
@@ -19,7 +18,6 @@ struct InstanceFormView: View {
|
||||
_name = State(initialValue: instance?.name ?? "")
|
||||
_originText = State(initialValue: instance?.origin.absoluteString ?? "")
|
||||
_tags = State(initialValue: instance?.tags ?? [])
|
||||
_selectedGroupId = State(initialValue: instance?.groupId)
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
@@ -36,7 +34,6 @@ struct InstanceFormView: View {
|
||||
field("名称", text: $name, placeholder: "Home Node")
|
||||
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
|
||||
tagEditor
|
||||
groupPicker
|
||||
field(
|
||||
"实例密码",
|
||||
text: $password,
|
||||
@@ -81,30 +78,6 @@ struct InstanceFormView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var groupPicker: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
AIFieldLabel("分组")
|
||||
Spacer()
|
||||
Button {
|
||||
model.showingGroupManager = true
|
||||
} label: {
|
||||
Label("管理", systemImage: "folder.badge.gearshape")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 72))
|
||||
}
|
||||
Picker("分组", selection: $selectedGroupId) {
|
||||
Text("未分组").tag(UUID?.none)
|
||||
ForEach(model.groups) { group in
|
||||
Text(group.name).tag(UUID?.some(group.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private var tagEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("标签")
|
||||
@@ -156,7 +129,6 @@ struct InstanceFormView: View {
|
||||
name: normalizedName,
|
||||
origin: origin,
|
||||
tags: tags,
|
||||
groupId: selectedGroupId,
|
||||
password: password.isEmpty ? nil : password
|
||||
)
|
||||
Task {
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 批量归类:点一个分组就把目标实例移进去并立即生效,不再需要二次“保存”。
|
||||
struct InstanceGroupEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
let targetIDs: [UUID]
|
||||
let onSaved: () -> Void
|
||||
|
||||
@State private var isCreatingGroup = false
|
||||
@State private var newGroupName = ""
|
||||
@State private var isWorking = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private var targets: [UUID] {
|
||||
targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs
|
||||
}
|
||||
|
||||
private var targetGroupIDs: [UUID?] {
|
||||
let owner = Dictionary(model.rows.map { ($0.id, $0.instance.groupId) }, uniquingKeysWith: { a, _ in a })
|
||||
return targets.compactMap { owner[$0] }
|
||||
}
|
||||
|
||||
/// 目标实例当前是否同属一个分组;nil 表示 mixed,不显示对勾。
|
||||
private var uniformGroup: UUID?? {
|
||||
guard let first = targetGroupIDs.first else { return nil }
|
||||
return targetGroupIDs.allSatisfy { $0 == first } ? .some(first) : nil
|
||||
}
|
||||
|
||||
private var trimmedName: String {
|
||||
newGroupName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage,
|
||||
tint: AITheme.danger,
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
}
|
||||
|
||||
AISectionTitle(
|
||||
"移动到",
|
||||
systemImage: "folder",
|
||||
detail: "\(targets.count) 台实例"
|
||||
)
|
||||
|
||||
if isCreatingGroup {
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AIFieldLabel("新分组名称")
|
||||
TextField("例如:家里、外场、测试机", text: $newGroupName)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.submitLabel(.done)
|
||||
.onSubmit { Task { await createAndAssign() } }
|
||||
AIActionRow(spacing: 8) {
|
||||
Button("取消") {
|
||||
isCreatingGroup = false
|
||||
newGroupName = ""
|
||||
errorMessage = nil
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
.disabled(isWorking)
|
||||
|
||||
Button {
|
||||
Task { await createAndAssign() }
|
||||
} label: {
|
||||
Label("创建并移入", systemImage: "folder.badge.plus")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(trimmedName.isEmpty || isWorking)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AICard(padding: 8) {
|
||||
VStack(spacing: 2) {
|
||||
groupRow(
|
||||
title: "未分组",
|
||||
subtitle: "不归属任何分组",
|
||||
systemImage: "square.dashed",
|
||||
groupID: nil
|
||||
)
|
||||
|
||||
ForEach(model.groups) { group in
|
||||
Divider()
|
||||
.overlay(AITheme.line.opacity(0.55))
|
||||
groupRow(
|
||||
title: group.name,
|
||||
subtitle: "\(instanceCount(group.id)) 台在此",
|
||||
systemImage: "folder",
|
||||
groupID: group.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(isWorking ? 0.55 : 1)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("实例分组")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
AIIconButton("新建分组", systemImage: "plus") {
|
||||
errorMessage = nil
|
||||
isCreatingGroup = true
|
||||
}
|
||||
.disabled(isWorking)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func groupRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
groupID: UUID?
|
||||
) -> some View {
|
||||
Button {
|
||||
guard uniformGroup != groupID else {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
Task { await apply(groupID: groupID) }
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(isCurrent(groupID) ? AITheme.primary : AITheme.muted)
|
||||
.frame(width: 32, height: 32)
|
||||
.background((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay {
|
||||
// 深色下 10% 色洗几乎看不出边界,补一圈同色淡描边保住方块轮廓。
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.20))
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Group {
|
||||
if isWorking {
|
||||
EmptyView()
|
||||
} else if isCurrent(groupID) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
}
|
||||
}
|
||||
.frame(width: 20, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(minHeight: 54)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(isCurrent(groupID) ? "\(title),当前分组" : "移动到\(title)")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isWorking)
|
||||
}
|
||||
|
||||
private func isCurrent(_ groupID: UUID?) -> Bool {
|
||||
uniformGroup == groupID && targetGroupIDs.isEmpty == false
|
||||
}
|
||||
|
||||
private func instanceCount(_ id: UUID) -> Int {
|
||||
model.rows.filter { $0.instance.groupId == id }.count
|
||||
}
|
||||
|
||||
private func apply(groupID: UUID?) async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
try await model.assign(groupID: groupID, to: targets)
|
||||
onSaved()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = "分组保存失败,请重试。"
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
|
||||
private func createAndAssign() async {
|
||||
guard !isWorking, trimmedName.isEmpty == false else { return }
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let group = try await model.createGroup(named: trimmedName)
|
||||
try await model.assign(groupID: group.id, to: targets)
|
||||
onSaved()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = "创建分组失败,名称不能为空且不能重复。"
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceGroupManagerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
@State private var newName = ""
|
||||
@State private var editingGroup: InstanceGroup?
|
||||
@State private var editingName = ""
|
||||
@State private var validationMessage: String?
|
||||
@State private var isWorking = false
|
||||
@State private var pendingDelete: InstanceGroup?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AIFieldLabel("新增分组")
|
||||
TextField("例如:家里、外场、测试机", text: $newName)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.submitLabel(.done)
|
||||
}
|
||||
}
|
||||
|
||||
if let validationMessage {
|
||||
AIStatusBanner(
|
||||
validationMessage,
|
||||
tint: AITheme.danger,
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
}
|
||||
|
||||
if model.groups.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "folder.badge.plus",
|
||||
title: "暂无分组",
|
||||
message: "创建分组后,实例可按业务或位置归类。"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ForEach(model.groups) { group in
|
||||
AICard {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "folder")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(AITheme.primary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(group.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
Text("\(instanceCount(group.id)) 个实例")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
AISymbolAction("重命名分组 \(group.name)", systemImage: "pencil", tint: AITheme.muted) {
|
||||
editingGroup = group
|
||||
editingName = group.name
|
||||
validationMessage = nil
|
||||
}
|
||||
|
||||
AISymbolAction("删除分组 \(group.name)", systemImage: "trash", tint: AITheme.danger) {
|
||||
pendingDelete = group
|
||||
}
|
||||
.disabled(isWorking)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("分组管理")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("完成") { dismiss() }
|
||||
}
|
||||
}
|
||||
.aiBottomActionBar {
|
||||
AIActionRow {
|
||||
Button {
|
||||
Task { await create() }
|
||||
} label: {
|
||||
Label("创建分组", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(isWorking)
|
||||
}
|
||||
}
|
||||
.alert("编辑分组", isPresented: Binding(
|
||||
get: { editingGroup != nil },
|
||||
set: { if !$0 { editingGroup = nil } }
|
||||
)) {
|
||||
TextField("分组名称", text: $editingName)
|
||||
Button("保存") {
|
||||
Task { await saveEditing() }
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
} message: {
|
||||
Text("分组名称最多 100 个字符。")
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingDelete == nil
|
||||
? "删除分组?"
|
||||
: "删除「\(pendingDelete!.name)」?该分组下的 \(instanceCount(pendingDelete!.id)) 个实例会回到未分组。",
|
||||
isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let group = pendingDelete {
|
||||
Button("删除分组", role: .destructive) {
|
||||
Task { await delete(group) }
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.task {
|
||||
await model.loadGroups()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func instanceCount(_ id: UUID) -> Int {
|
||||
model.rows.filter { $0.instance.groupId == id }.count
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
validationMessage = nil
|
||||
do {
|
||||
try await model.createGroup(named: newName)
|
||||
newName = ""
|
||||
} catch {
|
||||
validationMessage = "创建失败,名称不能为空且不能重复。"
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
|
||||
private func saveEditing() async {
|
||||
guard let editingGroup else { return }
|
||||
isWorking = true
|
||||
validationMessage = nil
|
||||
do {
|
||||
try await model.updateGroup(editingGroup.id, name: editingName)
|
||||
self.editingGroup = nil
|
||||
} catch {
|
||||
validationMessage = "更新失败,名称不能为空且不能重复。"
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
|
||||
private func delete(_ group: InstanceGroup) async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
pendingDelete = nil
|
||||
do {
|
||||
try await model.deleteGroup(group.id)
|
||||
} catch {
|
||||
validationMessage = "删除失败,请稍后重试。"
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ struct SettingsView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("应用信息", systemImage: "info.circle")
|
||||
if let runtime = model.runtime {
|
||||
AIKeyValueRow("版本", value: "\(runtime.marketingVersion) (\(runtime.buildVersion))")
|
||||
AIKeyValueRow("版本", value: runtime.marketingVersion)
|
||||
AIKeyValueRow("系统", value: runtime.systemVersion)
|
||||
} else {
|
||||
Text("正在读取应用信息…")
|
||||
@@ -98,20 +98,21 @@ struct SettingsView: View {
|
||||
VStack(spacing: 7) {
|
||||
Image(systemName: mode.systemImage)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
|
||||
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
|
||||
.accessibilityHidden(true)
|
||||
Text(mode.label)
|
||||
.font(.caption.weight(isSelected ? .semibold : .medium))
|
||||
.foregroundStyle(isSelected ? AITheme.ink : AITheme.muted)
|
||||
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 68)
|
||||
.background(isSelected ? AITheme.primary.opacity(0.10) : AITheme.control)
|
||||
// 选中格直接反色成实心块,浅底黑、深底白;一层 10% 的色雾在三块同色瓷砖里根本分不出来。
|
||||
.background(isSelected ? AITheme.primary : AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(isSelected ? AITheme.primary.opacity(0.42) : AITheme.line)
|
||||
.strokeBorder(isSelected ? Color.clear : AITheme.line)
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user