Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift
T
chick eb880e0d14 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
2026-09-15 02:23:24 +08:00

1563 lines
51 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct FleetView: View {
@StateObject private var model: FleetViewModel
@State private var showsDeleteDialog = false
@State private var editingInstance: Instance?
@State private var isSummaryCollapsed = true
init(model: FleetViewModel? = nil) {
// StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上,
// 所以调试开关必须先落到这个具体的 model 对象。
let target = model ?? DefaultContainer.makeFleetModel()
#if DEBUG
if DebugLaunch.has("--summary-open") {
_isSummaryCollapsed = State(initialValue: false)
}
if DebugLaunch.has("--select-mode") {
target.selectionMode = true
}
if DebugLaunch.has("--sheet-form") {
target.showingForm = true
}
if DebugLaunch.has("--sheet-tagbatch") {
target.selectionMode = true
target.showingTagBatch = true
}
// 图例胶囊的选中态只能靠点选进入,逐页截图时留一个直达开关。
if DebugLaunch.has("--filter-degraded") {
target.statusFilter = .degraded
}
if DebugLaunch.has("--tag-field") {
target.selectedTagFilters = ["field"]
}
#endif
_model = StateObject(wrappedValue: target)
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
searchField
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage,
tint: AITheme.danger,
systemImage: "exclamationmark.triangle"
)
}
if !model.rows.isEmpty {
if showsSummary {
fleetSummary
}
tagFilterStrip
}
if model.isLoading && model.rows.isEmpty {
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 12) {
AISkeletonLine(width: 0.38, height: 17)
HStack(spacing: 10) {
AISkeletonCard(height: 78)
AISkeletonCard(height: 78)
}
AISkeletonLine(width: 0.54, height: 15)
AISkeletonLine(width: 0.72, height: 15)
}
}
}
if model.rows.isEmpty && !model.isLoading {
AICard {
AIEmptyState(
systemImage: "server.rack",
title: "暂无实例",
message: "添加 SimAdmin 实例后即可开始管理。"
)
}
}
if model.visibleRows.isEmpty && !model.rows.isEmpty {
AICard {
AIEmptyState(
systemImage: "magnifyingglass",
title: "未找到实例",
message: "换个名称、地址、运营商或标签试试。"
)
}
}
ForEach(model.visibleRows) { row in
instanceEntry(row)
}
if !model.rows.isEmpty {
Text("共 \(model.rows.count) 台实例 · 显示 \(summaryRows.count) 台")
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.accessibilityElement(children: .ignore)
}
}
.padding(16)
}
.toolbar {
if model.selectionMode {
ToolbarItem(placement: .navigation) {
AIIconButton(
allVisibleSelected ? "取消全选" : "全选",
systemImage: allVisibleSelected ? "checkmark.circle.fill" : "checkmark.circle",
kind: .secondary
) {
model.selectedIDs = allVisibleSelected ? [] : Set(model.visibleRows.map(\.id))
}
.disabled(model.visibleRows.isEmpty)
}
ToolbarItemGroup(placement: .primaryAction) {
AIIconButton("批量标签", systemImage: "tag", kind: .secondary) {
model.showingTagBatch = true
}
.disabled(model.selectedIDs.isEmpty)
AIIconButton("删除已选实例", systemImage: "trash", kind: .danger) {
showsDeleteDialog = true
}
.disabled(model.selectedIDs.isEmpty)
AIIconButton("退出批量选择", systemImage: "xmark", kind: .secondary) {
model.selectionMode = false
model.selectedIDs.removeAll()
}
}
} else {
ToolbarItemGroup(placement: .primaryAction) {
AIIconButton("批量选择", systemImage: "checklist") {
model.selectionMode = true
}
.disabled(model.visibleRows.isEmpty)
AIIconButton("添加实例", systemImage: "plus") {
model.showingForm = true
}
.disabled(model.isLoading)
}
}
}
.sheet(isPresented: $model.showingTagBatch) {
FleetTagBatchSheet(model: model)
}
.sheet(isPresented: $model.showingForm) {
InstanceFormView(
model: model,
instance: editingInstance
)
}
.onChange(of: model.showingForm) { showing in
if !showing {
editingInstance = nil
}
}
.background(AITheme.canvas.ignoresSafeArea())
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationTitle(model.selectionMode ? "已选 \(model.selectedIDs.count) 台" : "节点总览")
.confirmationDialog(
"删除已选实例?登录信息也会一并清除。",
isPresented: $showsDeleteDialog,
titleVisibility: .visible
) {
Button("删除 \(model.selectedIDs.count) 个实例", role: .destructive) {
Task { await model.deleteSelected() }
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
}
}
private extension FleetView {
private static let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
formatter.locale = Locale(identifier: "zh_CN")
return formatter
}()
/// 视觉回归用:`--hide-summary` 只看节点卡片,不影响 Release。
var showsSummary: Bool {
#if DEBUG
return !DebugLaunch.has("--hide-summary")
#else
return true
#endif
}
/// 概览副标题:更新时间,检测未完成时追加进度。进度原本占读数栅格一格,挪到这里后栅格正好两行。
private var updatedDetail: String {
let progress = testingCount > 0 ? " · 已检测 \(settledCount)/\(summaryRows.count) 台" : ""
guard let lastRefresh = model.lastRefresh else {
return (model.isLoading ? "正在读取" : "待刷新") + progress
}
// RelativeDateTimeFormatter 在中文下会把刚发生的事说成"0秒后",这里直接兜成"刚刚"。
if Date().timeIntervalSince(lastRefresh) < 45 { return "刚刚更新" + progress }
return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))" + progress
}
private var searchField: some View {
AISearchField("搜索实例或地址", text: $model.searchText) {
Task { await model.refresh() }
}
}
// 标签是唯一的归类手段:一条横向可滑动的胶囊,首个「全部」用于复位。
@ViewBuilder
private var tagFilterStrip: some View {
if model.allTags.isEmpty == false {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
title: "全部",
count: model.rows.count,
isStreaming: false,
isSelected: model.selectedTagFilters.isEmpty
) {
guard model.selectedTagFilters.isEmpty == false else { return }
withAnimation(.easeOut(duration: 0.18)) { model.clearTagFilters() }
}
ForEach(model.allTags, id: \.self) { tag in
FilterChip(
title: tag,
count: model.countForTagFilter(tag),
isStreaming: true,
isSelected: model.isTagFilterActive(tag)
) {
withAnimation(.easeOut(duration: 0.18)) { model.toggleTagFilter(tag) }
}
}
}
.padding(.vertical, 3)
.padding(.horizontal, 1)
}
.accessibilityElement(children: .contain)
.accessibilityLabel("按标签筛选")
}
}
private struct FilterChip: View {
let title: String
let count: Int
let isStreaming: Bool
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 5) {
if isStreaming {
Image(systemName: isSelected ? "checkmark" : "tag")
.font(.system(size: 10, weight: .semibold))
.accessibilityHidden(true)
}
Text(title)
.font(.footnote.weight(isSelected ? .semibold : .medium))
.lineLimit(1)
Text("\(count)")
.font(.caption2.weight(.semibold))
.monospacedDigit()
.opacity(0.75)
}
.padding(.horizontal, 12)
.frame(height: 38)
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.ink)
.background(isSelected ? AITheme.primary : AITheme.surface)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(isSelected ? AITheme.primary : AITheme.line)
}
.shadow(color: isSelected ? AITheme.cardShadow : .clear, radius: 6, x: 0, y: 2)
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel(isStreaming ? "标签 \(title),\(count) 台" : "显示全部 \(count) 台")
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
}
}
private var fleetOverviewHeader: some View {
Button {
withAnimation(.easeOut(duration: 0.22)) {
isSummaryCollapsed.toggle()
}
} label: {
HStack(spacing: 10) {
Image(systemName: "chart.bar.fill")
// 仪表盘符号在小字号下会糊成一团,换成同尺寸能读清的柱状图。
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(AITheme.primary)
.frame(width: 22, height: 22)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
Text("运行概览")
.font(.headline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Text(updatedDetail)
.font(.caption2.weight(.medium))
.foregroundStyle(AITheme.muted)
}
Spacer(minLength: 8)
Image(systemName: isSummaryCollapsed ? "chevron.down" : "chevron.up")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(AITheme.muted)
.frame(width: 24, height: 24)
.accessibilityHidden(true)
}
.padding(.vertical, 7)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityElement(children: .contain)
.accessibilityLabel(isSummaryCollapsed ? "展开运行概览" : "折叠运行概览")
.accessibilityAddTraits(.isButton)
}
private var 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 {
model.visibleRows.isEmpty == false
&& model.selectedIDs.count == model.visibleRows.count
}
var selectedIDs: [UUID] {
model.selectedIDs.sorted { $0.uuidString < $1.uuidString }
}
private var fleetSummary: some View {
AICard(padding: 14) {
VStack(alignment: .leading, spacing: 10) {
fleetOverviewHeader
// 结论行只用排版,不额外套色块,卡片里不再出现卡片。
HStack(alignment: .top, spacing: 14) {
FleetHeadlineStat(
title: "在线率",
value: onlineRateValue,
detail: onlineRateDetail,
tint: onlineRateTint
)
FleetHeadlineStat(
title: "异常节点",
value: settledCount == 0 ? "--" : "\(abnormalCount)",
detail: abnormalDetail,
tint: abnormalTint
)
}
FleetDistributionBar(
segments: statusSegments,
total: summaryRows.count,
selection: Binding(
get: { model.statusFilter },
set: { model.statusFilter = $0 }
)
)
if summaryRows.isEmpty && model.rows.isEmpty == false {
Text("当前筛选没有节点")
.font(.footnote.weight(.medium))
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
FleetReadoutGrid(
items: isSummaryCollapsed
? Array(fleetSummaryItems.prefix(4))
: fleetSummaryItems
)
if !isSummaryCollapsed {
FleetOrganizationFootnote(
tagCount: tagCount,
covered: taggedCount,
total: summaryRows.count
)
}
}
}
}
/// 概览顶部的大字结论:标题在上,数字与说明同排。
struct FleetHeadlineStat: View {
let title: String
let value: String
let detail: String
let tint: Color
var body: some View {
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
HStack(alignment: .firstTextBaseline, spacing: 5) {
Text(value)
.font(.system(size: 25, weight: .semibold))
.monospacedDigit()
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.6)
Text(detail)
.font(.caption2)
.foregroundStyle(AITheme.faint)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
}
/// 状态分布:一条堆叠比例条 + 可点击图例,图例即状态筛选器。
struct FleetDistributionBar: View {
let segments: [FleetStatusSegment]
let total: Int
@Binding var selection: ConnectionStatus?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
GeometryReader { proxy in
HStack(spacing: 2) {
ForEach(segments, id: \.status) { segment in
Capsule(style: .continuous)
.fill(segment.tint.opacity(isDimmed(segment) ? 0.22 : 1))
.frame(width: segmentWidth(segment, in: proxy.size.width))
}
}
}
.frame(height: 6)
.accessibilityHidden(true)
HStack(spacing: 6) {
ForEach(segments, id: \.status) { segment in
FleetLegendChip(
title: segment.status.label,
count: segment.count,
tint: segment.tint,
isSelected: selection == segment.status
) {
selection = selection == segment.status ? nil : segment.status
}
}
Spacer(minLength: 0)
}
}
}
private func isDimmed(_ segment: FleetStatusSegment) -> Bool {
selection != nil && selection != segment.status
}
private func segmentWidth(_ segment: FleetStatusSegment, in width: CGFloat) -> CGFloat {
let gaps = CGFloat(max(0, segments.count - 1)) * 2
let usable = max(1, width - gaps)
guard total > 0 else { return usable / CGFloat(max(1, segments.count)) }
return max(6, usable * CGFloat(max(0, segment.count)) / CGFloat(total))
}
}
struct FleetLegendChip: View {
let title: String
let count: Int
let tint: Color
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 5) {
Circle()
.fill(isSelected ? AITheme.onPrimary : tint)
.frame(width: 6, height: 6)
.accessibilityHidden(true)
Text(title)
.font(.caption2.weight(isSelected ? .semibold : .regular))
.lineLimit(1)
Text("\(count)")
.font(.caption2.weight(.semibold))
.monospacedDigit()
.opacity(0.72)
}
.padding(.horizontal, 9)
.frame(height: 26)
// 图例即筛选器:选中时整颗胶囊填成状态色,文字走反色。
// onPrimary 在浅色下是白、深色下是近黑,正好是两套状态色的对比搭档。
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
.background(isSelected ? tint : AITheme.tile)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(isSelected ? Color.clear : AITheme.tileLine)
}
// 视觉高度保持 26,但把点击区扩到 44pt 以上。
.contentShape(Capsule(style: .continuous).inset(by: -9))
}
.buttonStyle(.plain)
.accessibilityLabel("\(title) \(count) 台")
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
}
}
/// 读数面板:四列栅格,仅用发丝线分隔,正常值不上颜色。
struct FleetReadoutGrid: View {
let items: [FleetStatItem]
private var rows: [[FleetStatItem]] {
stride(from: 0, to: items.count, by: 4).map { start in
Array(items[start..<min(start + 4, items.count)])
}
}
var body: some View {
VStack(spacing: 0) {
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in
if rowIndex > 0 {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
HStack(alignment: .top, spacing: 0) {
ForEach(Array(row.enumerated()), id: \.element.title) { columnIndex, item in
FleetReadoutCell(item: item)
.padding(.leading, columnIndex == 0 ? 0 : 10)
.frame(maxWidth: .infinity, alignment: .leading)
}
if row.count < 4 {
ForEach(row.count..<4, id: \.self) { _ in
Color.clear
.frame(maxWidth: .infinity, minHeight: 1)
}
}
}
.padding(.vertical, 9)
}
}
}
}
struct FleetReadoutCell: View {
let item: FleetStatItem
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.caption2)
.foregroundStyle(AITheme.faint)
.lineLimit(1)
Text(item.value)
.font(.footnote.weight(.semibold))
.monospacedDigit()
.foregroundStyle(readoutColor)
.lineLimit(1)
.minimumScaleFactor(0.68)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
private var readoutColor: Color {
if item.isPlaceholder { return AITheme.faint }
return item.isAlert ? item.tint : AITheme.ink
}
}
struct FleetOrganizationFootnote: View {
let tagCount: Int
let covered: Int
let total: Int
var body: some View {
HStack(spacing: 6) {
Image(systemName: "tag")
.font(.system(size: 10, weight: .semibold))
.accessibilityHidden(true)
Text(tagCount == 0 ? "还没有标签,可在编辑实例时添加" : "\(tagCount) 个标签 · 已标记 \(covered)/\(total) 台")
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
.font(.caption2)
.foregroundStyle(AITheme.faint)
.padding(.top, 9)
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
.accessibilityElement(children: .combine)
}
}
private var statusSegments: [FleetStatusSegment] {
let order: [ConnectionStatus] = [.online, .degraded, .authRequired, .offline, .unknown]
return order.compactMap { status in
let count = summaryRows.filter { $0.status == status }.count
guard count > 0 else { return nil }
return FleetStatusSegment(status: status, count: count)
}
}
/// 概览分布条的一段,颜色与节点卡片上的状态色保持一致。
struct FleetStatusSegment {
let status: ConnectionStatus
let count: Int
var tint: Color {
switch status {
case .online: return AITheme.success
case .offline: return AITheme.danger
case .authRequired, .degraded: return AITheme.warning
case .unknown: return AITheme.muted
}
}
}
/// 读数区固定八格两行:温度与电量即使整群节点都没有上报也保留占位,
/// 否则栅格会在七格、九格之间跳,最后一行只剩孤立的一列。
private var fleetSummaryItems: [FleetStatItem] {
[
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: temperatureSummary, systemImage: "thermometer.medium", tint: temperatureSummaryTint),
FleetStatItem(title: "电量", value: batterySummary, systemImage: "battery.100", tint: batterySummaryTint)
]
}
private var onlineCount: Int { summaryRows.filter { $0.status == .online }.count }
private var abnormalCount: Int {
summaryRows.filter { $0.status != .online && $0.status != .unknown }.count
}
private var testingCount: Int { summaryRows.filter(\.testing).count }
private var settledCount: Int { summaryRows.count - testingCount }
// 检测尚未完成时不给出结论性数字,避免把"还没读到"误读成"全部离线"。
private var hasSettledResults: Bool { summaryRows.isEmpty == false && settledCount > 0 }
private var onlineRateValue: String {
guard hasSettledResults else { return "--" }
return "\(Int((Double(onlineCount) / Double(summaryRows.count) * 100).rounded()))%"
}
private var onlineRateDetail: String {
guard hasSettledResults else { return "\(summaryRows.count) 台正在检测" }
return "\(onlineCount)/\(summaryRows.count) 台在线"
}
private var onlineRateTint: Color {
guard hasSettledResults else { return AITheme.muted }
return onlineCount == summaryRows.count ? AITheme.success : AITheme.warning
}
private var abnormalDetail: String {
guard hasSettledResults else { return "等待检测结果" }
return abnormalCount > 0 ? "需要尽快处理" : "无异常节点"
}
private var abnormalTint: Color {
guard hasSettledResults else { return AITheme.muted }
return abnormalCount > 0 ? AITheme.danger : AITheme.success
}
private var signalValues: [Int] {
summaryRows.compactMap(\.signalPercent)
}
private var signalSummary: String {
formatAverage(signalValues) { "\(Int($0.rounded()))%" }
}
private var signalSummaryTint: Color {
colorForAverage(signalValues) { $0 >= 60 ? AITheme.success : ($0 >= 30 ? AITheme.warning : AITheme.danger) }
}
private var latencyValues: [Int] {
summaryRows.compactMap(\.latencyMilliseconds)
}
private var latencySummary: String {
formatAverage(latencyValues) { "\(Int($0.rounded())) ms" }
}
private var latencySummaryTint: Color {
colorForAverage(latencyValues) { $0 < 300 ? AITheme.success : ($0 < 800 ? AITheme.warning : AITheme.danger) }
}
private var cpuSummary: String {
formatAverage(summaryRows.compactMap(\.cpuPercent)) { "\(Int($0.rounded()))%" }
}
private var memorySummary: String {
formatAverage(summaryRows.compactMap(\.memoryPercent)) { "\(Int($0.rounded()))%" }
}
private var loadSummaryTint: Color {
let values = (summaryRows.compactMap(\.cpuPercent) + summaryRows.compactMap(\.memoryPercent))
return colorForAverage(values) { $0 >= 85 ? AITheme.danger : ($0 >= 65 ? AITheme.warning : AITheme.success) }
}
private var temperatureSummary: String {
formatAverage(summaryRows.compactMap(\.temperatureCelsius)) { "\(Int($0.rounded()))°C" }
}
private var temperatureSummaryTint: Color {
colorForAverage(summaryRows.compactMap(\.temperatureCelsius)) {
$0 >= 75 ? AITheme.danger : ($0 >= 60 ? AITheme.warning : AITheme.success)
}
}
private var batterySummary: String {
formatAverage(summaryRows.compactMap(\.batteryPercent)) { "\(Int($0.rounded()))%" }
}
private var batterySummaryTint: Color {
colorForAverage(summaryRows.compactMap(\.batteryPercent)) {
$0 <= 20 ? AITheme.danger : ($0 <= 40 ? AITheme.warning : AITheme.success)
}
}
private var storageSummary: String {
let pairs = storagePairs
guard pairs.isEmpty == false else { return "不可用" }
let used = pairs.map { max(0, $0.total - $0.available) }.reduce(0, +)
let total = pairs.map(\.total).reduce(0, +)
guard total > 0 else { return "不可用" }
return "\(Int((Double(used) / Double(total) * 100).rounded()))% 已用"
}
private var storageSummaryTint: Color {
guard storagePairs.isEmpty == false else { return AITheme.muted }
let usedBytes = storagePairs.map { max(0, $0.total - $0.available) }.reduce(0, +)
let totalBytes = storagePairs.map(\.total).reduce(0, +)
let ratio = Double(usedBytes) / Double(max(1, totalBytes))
return colorForRatio(ratio) {
$0 >= 0.90 ? AITheme.danger : ($0 >= 0.75 ? AITheme.warning : AITheme.success)
}
}
private var storagePairs: [(total: Int64, available: Int64)] {
summaryRows.compactMap { row in
guard let total = row.totalStorageBytes, let available = row.availableStorageBytes else {
return nil
}
return (total: total, available: available)
}
}
private var trafficSummary: String {
let values = summaryRows.compactMap(\.downloadRateBytesPerSecond) + summaryRows.compactMap(\.uploadRateBytesPerSecond)
guard values.isEmpty == false else { return "不可用" }
return ByteCountFormatter.string(fromByteCount: Int64(values.reduce(0, +)), countStyle: .decimal) + "/s"
}
private var trafficSummaryTint: Color {
let values = summaryRows.compactMap(\.downloadRateBytesPerSecond) + summaryRows.compactMap(\.uploadRateBytesPerSecond)
return colorForAverage(values) {
$0 >= 50_000_000 ? AITheme.warning : AITheme.success
}
}
private func colorForRatio(_ value: Double, transform: (Double) -> Color) -> Color {
transform(max(0, min(1, value)))
}
private func formatAverage(_ values: [Double], transform: (Double) -> String) -> String {
guard values.isEmpty == false else { return "不可用" }
let average = values.reduce(0, +) / Double(values.count)
return transform(average)
}
private func formatAverage(_ values: [Int], transform: (Double) -> String) -> String {
formatAverage(values.map(Double.init), transform: transform)
}
private func colorForAverage(
_ values: [Double],
transform: (Double) -> Color
) -> Color {
guard values.isEmpty == false else { return AITheme.muted }
return transform(values.reduce(0, +) / Double(values.count))
}
private func colorForAverage(
_ values: [Int],
transform: (Double) -> Color
) -> Color {
colorForAverage(values.map(Double.init), transform: transform)
}
private func instanceCard(_ row: FleetViewModel.Row) -> some View {
let metrics = rowMetrics(row)
return AICard {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 8) {
Text(row.instance.name)
.font(.headline)
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
InlinePill(
title: row.status.label,
systemImage: statusIcon(row.status),
tint: statusColor(row.status)
)
}
Text(row.instance.origin.absoluteString)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
}
// 四条量表把"要不要担心这台"变成一次扫视:信号、延迟、CPU、内存。
FleetGaugeStrip(
gauges: rowGauges(row),
isPending: row.testing
)
// 健康节点不再占一行重复"连接正常",只有需要解释的状态才出现说明与链路。
if let statusLine = statusLine(row) {
FleetStatusStrip(status: row.status, statusDetail: statusLine)
}
if let health = chainHealth(row) {
FleetChainRow(items: health)
}
FleetMetricGrid(
metrics: metrics,
isPending: row.testing && metrics.isEmpty
)
if row.instance.tags.isEmpty == false {
FlowLayout(spacing: 6) {
ForEach(row.instance.tags, id: \.self) { tag in
TagToken(
title: tag,
isActive: model.isTagFilterActive(tag),
isInteractive: model.selectionMode == false
) {
withAnimation(.easeOut(duration: 0.18)) { model.toggleTagFilter(tag) }
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
/// 选择态的勾选列:与卡片标题行同一基线,靠布局让位而不是压在内容上。
private func selectionMark(_ id: UUID) -> some View {
let checked = model.selectedIDs.contains(id)
return Image(systemName: checked ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22, weight: .regular))
.foregroundStyle(checked ? AITheme.primary : AITheme.muted.opacity(0.72))
.frame(width: 26)
.padding(.top, 17)
.accessibilityHidden(true)
}
private var summaryColumns: [GridItem] {
[GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)]
}
private func chainHealth(_ row: FleetViewModel.Row) -> [FleetLinkItem]? {
guard let values = row.chainValues else { return nil }
let items = [
FleetLinkItem(
title: "硬件",
isAvailable: values.hardwareOnline,
),
FleetLinkItem(
title: "控制",
isAvailable: values.controlOnline,
),
FleetLinkItem(
title: "SIM",
isAvailable: values.simPresent,
),
FleetLinkItem(
title: "蜂窝",
isAvailable: values.cellularOnline,
),
]
// 四段全绿时这一行没有任何信息量,只在链路真的出了问题(或未知)时才出现。
guard items.contains(where: { $0.isAvailable != true }) else { return nil }
return items
}
/// 卡片顶部的四条量表:数值配一条发丝进度,正常时不着色,异常时只给颜色不给感叹号。
private func 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.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: "温度",
value: row.temperatureCelsius.map(Self.temperatureText),
tint: row.temperatureCelsius.flatMap(temperatureColor) ?? AITheme.muted
),
.init(
label: "电量",
value: row.batteryPercent.map(Self.percentText),
tint: row.batteryPercent.flatMap(batteryColor) ?? AITheme.muted
),
.init(label: "固件", value: row.firmwareVersion),
.init(label: "运行时长", value: row.uptimeSeconds.map(Self.durationText)),
.init(
label: "下行",
value: row.downloadRateBytesPerSecond.map(Self.rateText)
),
.init(
label: "上行",
value: row.uploadRateBytesPerSecond.map(Self.rateText)
),
.init(label: "今日消息", value: row.messagesToday.map { "\($0) 条" })
]
return ordered.filter(\.hasValue)
}
private static func cpuValue(percent: Double?, cores: Int?) -> String? {
guard percent != nil || cores != nil else { return nil }
let usage = percent.map(Self.percentText) ?? "--"
guard let cores, cores > 0 else { return usage }
return "\(usage) · \(cores) 核"
}
private static func durationText(_ seconds: Double) -> String {
Duration.seconds(seconds).formatted(.units(allowed: [.days, .hours, .minutes], width: .abbreviated))
}
private static func rateText(_ bytesPerSecond: Double) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytesPerSecond), countStyle: .decimal) + "/s"
}
private static func percentText(_ value: Double) -> String {
value.formatted(.number.precision(.fractionLength(0...1))) + "%"
}
private static func temperatureText(_ value: Double) -> String {
value.formatted(.number.precision(.fractionLength(0...1))) + "°C"
}
private func storageColor(_ ratio: Double) -> Color {
ratio >= 0.90 ? AITheme.danger : (ratio >= 0.75 ? AITheme.warning : AITheme.success)
}
private func signalColor(_ value: Int?) -> Color? {
guard let value else { return nil }
return value >= 60 ? AITheme.success : (value >= 30 ? AITheme.warning : AITheme.danger)
}
private func resourceColor(_ value: Double) -> Color {
value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.success)
}
private func temperatureColor(_ value: Double) -> Color {
value >= 75 ? AITheme.danger : (value >= 60 ? AITheme.warning : AITheme.success)
}
private func batteryColor(_ value: Double) -> Color {
value <= 20 ? AITheme.danger : (value <= 40 ? AITheme.warning : AITheme.success)
}
/// 只解释"为什么不是正常状态"。在线节点的细节交给指标栅格,上游原词(online 之类)不外泄。
private func statusLine(_ row: FleetViewModel.Row) -> String? {
if row.testing { return "正在读取运行信息" }
switch row.status {
case .online:
return nil
case .degraded:
return "部分接口不可用,可在详情查看失败接口"
case .authRequired:
return "节点开启了访问密码,进入详情登录"
case .offline:
return "节点不可达,最后结果已过期"
case .unknown:
return "尚未检测"
}
}
private func latencyColor(_ milliseconds: Int?) -> Color? {
guard let milliseconds else { return nil }
return milliseconds < 300 ? AITheme.success : (milliseconds < 800 ? AITheme.warning : AITheme.danger)
}
private func instanceEntry(_ row: FleetViewModel.Row) -> some View {
Group {
if model.selectionMode {
Button {
toggle(row.id)
} label: {
HStack(alignment: .top, spacing: 10) {
selectionMark(row.id)
instanceCard(row)
}
.fixedSize(horizontal: false, vertical: true)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择 \(row.instance.name)" : "选择 \(row.instance.name)")
.accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
} else {
NavigationLink {
InstanceDetailView(instance: row.instance)
} label: {
instanceCard(row)
}
.buttonStyle(.plain)
.contextMenu {
ForEach(row.instance.tags, id: \.self) { tag in
Button {
withAnimation(.easeOut(duration: 0.18)) {
model.clearTagFilters()
model.toggleTagFilter(tag)
}
} label: {
Label("仅看「\(tag)」", systemImage: "line.3.horizontal.decrease.circle")
}
}
Button {
editingInstance = row.instance
model.showingForm = true
} label: {
Label("编辑实例", systemImage: "pencil")
}
Button(role: .destructive) {
Task { await model.delete([row.id]) }
} label: {
Label("删除实例", systemImage: "trash")
}
}
}
}
}
private func statusColor(_ status: ConnectionStatus) -> Color {
switch status {
case .online: return AITheme.success
case .offline: return AITheme.danger
case .authRequired: return AITheme.warning
case .degraded: return AITheme.warning
case .unknown: return AITheme.muted
}
}
private func statusIcon(_ status: ConnectionStatus) -> String {
switch status {
case .online: return "checkmark.circle"
case .offline: return "xmark.circle"
case .authRequired: return "lock.circle"
case .degraded: return "exclamationmark.triangle"
case .unknown: return "questionmark.circle"
}
}
private func toggle(_ id: UUID) {
if model.selectedIDs.contains(id) {
model.selectedIDs.remove(id)
} else {
model.selectedIDs.insert(id)
}
}
}
extension FleetView {
/// 节点卡片的指标区:两列发丝线栅格,最多八项,其余留给详情页。
private struct FleetMetricGrid: View {
let metrics: [FleetMetric]
var isPending = false
private static let maxVisible = 8
private var visible: [FleetMetric] { Array(metrics.prefix(Self.maxVisible)) }
private var overflow: Int { max(0, metrics.count - Self.maxVisible) }
private var rows: [[FleetMetric]] {
stride(from: 0, to: visible.count, by: 2).map { start in
Array(visible[start..<min(start + 2, visible.count)])
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if metrics.isEmpty {
if isPending {
// 检测中用骨架占位,保持卡片高度稳定。
VStack(alignment: .leading, spacing: 8) {
AISkeletonLine(width: 0.42, height: 13)
AISkeletonLine(width: 0.68, height: 13)
}
.padding(.vertical, 9)
.accessibilityLabel("正在读取运行信息")
} else {
Text("节点未返回运行信息")
.font(.caption)
.foregroundStyle(AITheme.faint)
.padding(.vertical, 9)
}
} else {
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in
if rowIndex > 0 {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
HStack(alignment: .top, spacing: 0) {
ForEach(Array(row.enumerated()), id: \.offset) { columnIndex, metric in
metric
.padding(.leading, columnIndex == 0 ? 0 : 12)
.frame(maxWidth: .infinity, alignment: .leading)
}
// 只有成对出现的行才需要保留右半列对齐;单独一项时占位会让卡片右下出现空洞。
if row.count < 2 && rows.count > 1 {
Color.clear
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 8)
}
if overflow > 0 {
Text("另有 \(overflow) 项,进入详情查看")
.font(.caption2)
.foregroundStyle(AITheme.faint)
.padding(.top, 7)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .top) {
if metrics.isEmpty == false {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
}
}
}
/// 量表单元:一个可读数值加一条发丝进度。
struct 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
let systemImage: String
let tint: Color
// 只有真正需要盯的数值才着色,正常值一律用墨色,避免"满屏绿灯"。
var isAlert: Bool {
tint == AITheme.danger || tint == AITheme.warning
}
// 没有数据时用弱色占位,读起来不会像"0%"那样的假结论。
var isPlaceholder: Bool {
value == "--" || value == "不可用" || value == "暂无"
}
}
/// 状态摘要行:只用一个状态点 + 一行说明,不再套底色。
struct FleetStatusStrip: View {
let status: ConnectionStatus
let statusDetail: String
private var statusTint: Color {
switch status {
case .online: return AITheme.success
case .offline: return AITheme.danger
case .authRequired, .degraded: return AITheme.warning
case .unknown: return AITheme.muted
}
}
var body: some View {
HStack(spacing: 7) {
Circle()
.fill(statusTint)
.frame(width: 6, height: 6)
.accessibilityHidden(true)
Text(statusDetail)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.accessibilityElement(children: .combine)
}
}
struct InlinePill: View {
let title: String
let systemImage: String
let tint: Color
var body: some View {
HStack(spacing: 4) {
Image(systemName: systemImage)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(tint)
.accessibilityHidden(true)
Text(title)
.font(.caption2.weight(.semibold))
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(tint.opacity(0.10))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(tint.opacity(0.18))
}
}
}
struct FleetLinkItem: Identifiable, Equatable {
let title: String
let isAvailable: Bool?
var id: String { title }
}
/// 实例卡片上的标签胶囊,点击即可把它当作筛选条件。
struct TagToken: View {
let title: String
let isActive: Bool
var isInteractive: Bool = true
let action: () -> Void
var body: some View {
// 卡片整体是一个 NavigationLink,嵌套 Button 的命中不稳定,
// 这里用高优先级手势接管点击,保证标签筛选一定生效。
HStack(spacing: 4) {
Image(systemName: isActive ? "checkmark.circle.fill" : "tag")
.font(.system(size: 10, weight: .semibold))
.accessibilityHidden(true)
Text(title)
.font(.caption2.weight(.semibold))
.lineLimit(1)
.truncationMode(.middle)
}
.padding(.horizontal, 8)
.frame(height: 28)
.foregroundStyle(isActive ? AITheme.onPrimary : AITheme.muted)
.background(isActive ? AITheme.primary : AITheme.tile)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(isActive ? AITheme.primary : AITheme.tileLine)
}
.contentShape(Capsule(style: .continuous))
.highPriorityGesture(
isInteractive ? TapGesture().onEnded(action) : nil
)
.accessibilityAction {
if isInteractive {
action()
}
}
.accessibilityLabel(isActive ? "取消筛选标签 \(title)" : "按标签 \(title) 筛选")
.accessibilityAddTraits(isActive ? [.isSelected, .isButton] : .isButton)
}
}
/// 链路健康:硬件 / 控制 / SIM / 蜂窝 四段一行读完,用发丝线分隔而不是四个色块。
struct FleetChainRow: View {
let items: [FleetLinkItem]
var body: some View {
HStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
if index > 0 {
Rectangle()
.fill(AITheme.line)
.frame(width: 1, height: 16)
}
FleetChainCell(item: item)
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 8)
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
.accessibilityElement(children: .contain)
}
}
private struct FleetChainCell: View {
let item: FleetLinkItem
private var tint: Color {
switch item.isAvailable {
case true: return AITheme.success
case false: return AITheme.danger
case nil: return AITheme.muted
}
}
private var stateText: String {
switch item.isAvailable {
case true: return "可用"
case false: return "不可用"
case nil: return "未知"
}
}
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(tint)
.frame(width: 5, height: 5)
.accessibilityHidden(true)
Text(item.title)
.font(.caption2)
.foregroundStyle(AITheme.faint)
.lineLimit(1)
Text(stateText)
.font(.caption2.weight(.semibold))
.foregroundStyle(tint == AITheme.muted ? AITheme.faint : tint)
.lineLimit(1)
.minimumScaleFactor(0.8)
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(item.title) \(stateText)")
}
}
struct FlowLayout: Layout {
let spacing: CGFloat
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var rowWidth: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > 0, x + size.width > maxWidth {
x = 0
y += rowHeight + spacing
rowHeight = 0
rowWidth = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
rowWidth = max(rowWidth, x - spacing)
}
return CGSize(width: rowWidth, height: max(0, y + rowHeight - (rowHeight > 0 ? 0 : spacing)))
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > bounds.minX, x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
struct FleetMetric: View {
let label: String
let value: String?
var tint: Color = AITheme.muted
var hasValue: Bool { value?.isEmpty == false }
var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning }
var body: some View {
if hasValue {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(AITheme.faint)
.lineLimit(1)
Text(value ?? "--")
.font(.footnote.weight(.semibold))
.monospacedDigit()
.foregroundStyle(isAlert ? tint : AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.minimumScaleFactor(0.72)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
}
}
}