- 删除分组实体:分组视图/编辑器/仓储与 groupId 全部移除,升级首启清理 instance-groups.json 与 fleet.groupMode - 新增批量标签表:三态覆盖度(未贴 / N/M / 全部),整批加减与新建标签 - 配色重构,全应用零蓝色:石墨墨色承担主操作与选中态,颜色只留给状态语义; 浅色改中性冷灰,深色改低明度石墨三层 - 选中态改实心主色 + onPrimary,替换不可读的低透明度色雾(主题块、图例胶囊) - 应用图标重做为扁平石墨底 + 黄铜 SIM 面板,参数化光栅化脚本与参考稿 - 运行概览:检测进度并入副标题,读数栅格固定八格两行并保留不可用占位 - 实例详情:仪表改两列发丝线栅格,缺数据留轨道,修掉头部高度回弹; 补齐设备名称/存储总量/可用存储/使用率四个字段名 - 版本号显示去掉括号,设置页只显示 0.2.2 - 文档:新增 docs/ios-native-0.2.2.md,同步 ios-native.md 与两处 README
1563 lines
51 KiB
Swift
1563 lines
51 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|
||
}
|