1617 lines
52 KiB
Swift
1617 lines
52 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 collapsedGroups: Set<String> = []
|
||
@State private var isSummaryCollapsed = true
|
||
@State private var showingGroupEditor = false
|
||
|
||
init(model: FleetViewModel? = nil) {
|
||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
|
||
}
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
LazyVStack(spacing: 12, pinnedViews: .sectionHeaders) {
|
||
searchField
|
||
if let errorMessage = model.errorMessage {
|
||
AIStatusBanner(
|
||
errorMessage,
|
||
tint: AITheme.danger,
|
||
systemImage: "exclamationmark.triangle"
|
||
)
|
||
}
|
||
if !model.rows.isEmpty {
|
||
organizationControls
|
||
fleetSummary
|
||
}
|
||
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.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)
|
||
}
|
||
}
|
||
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 {
|
||
ToolbarItemGroup(placement: .navigation) {
|
||
Button {
|
||
model.selectedIDs = model.selectedIDs.count == model.visibleRows.count
|
||
? []
|
||
: Set(model.visibleRows.map(\.id))
|
||
} label: {
|
||
Image(systemName: model.selectedIDs.count == model.visibleRows.count ? "circle" : "checkmark.circle")
|
||
}
|
||
.accessibilityLabel(model.selectedIDs.count == model.visibleRows.count ? "取消全选" : "全选")
|
||
.disabled(model.visibleRows.isEmpty)
|
||
|
||
Button {
|
||
model.selectionMode = false
|
||
model.selectedIDs.removeAll()
|
||
} label: {
|
||
Image(systemName: "xmark")
|
||
}
|
||
.accessibilityLabel("退出批量选择")
|
||
}
|
||
|
||
ToolbarItemGroup(placement: .primaryAction) {
|
||
Button {
|
||
showsDeleteDialog = true
|
||
} label: {
|
||
Image(systemName: "trash")
|
||
}
|
||
.accessibilityLabel(model.selectedIDs.isEmpty ? "删除" : "删除 \(model.selectedIDs.count) 个实例")
|
||
.disabled(model.selectedIDs.isEmpty)
|
||
}
|
||
} else {
|
||
ToolbarItemGroup(placement: .primaryAction) {
|
||
AIIconButton("管理分组", systemImage: "folder.badge.gearshape") {
|
||
model.showingGroupManager = true
|
||
}
|
||
.disabled(model.isLoading)
|
||
|
||
AIIconButton("批量选择", systemImage: "checklist") {
|
||
model.selectionMode = true
|
||
}
|
||
.disabled(model.visibleRows.isEmpty)
|
||
|
||
AIIconButton("添加实例", systemImage: "plus") {
|
||
model.showingForm = true
|
||
}
|
||
.disabled(model.isLoading)
|
||
}
|
||
}
|
||
}
|
||
.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.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("节点总览")
|
||
.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
|
||
return formatter
|
||
}()
|
||
|
||
private var updatedDetail: String {
|
||
guard let lastRefresh = model.lastRefresh else {
|
||
return model.isLoading ? "正在读取" : "待刷新"
|
||
}
|
||
return "更新于 \(Self.relativeFormatter.localizedString(for: lastRefresh, relativeTo: Date()))"
|
||
}
|
||
|
||
private var searchField: some View {
|
||
AISearchField("搜索实例或地址", text: $model.searchText) {
|
||
Task { await model.refresh() }
|
||
}
|
||
}
|
||
|
||
private var organizationControls: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
AISegmentedControl(
|
||
"组织方式",
|
||
selection: $model.groupMode,
|
||
options: FleetGroupMode.allCases.map { ($0, $0.title) }
|
||
)
|
||
|
||
}
|
||
}
|
||
|
||
private var fleetOverviewHeader: some View {
|
||
Button {
|
||
withAnimation(.easeOut(duration: 0.22)) {
|
||
isSummaryCollapsed.toggle()
|
||
}
|
||
} label: {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "gauge.with.needle")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(AITheme.primary)
|
||
.frame(width: 22, height: 22)
|
||
.accessibilityHidden(true)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text("运行概览")
|
||
.font(.headline.weight(.semibold))
|
||
.kerning(-0.1)
|
||
.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: 12, weight: .semibold))
|
||
.foregroundStyle(AITheme.muted)
|
||
.frame(width: 30, height: 30)
|
||
.background(AITheme.control)
|
||
.clipShape(Circle())
|
||
.accessibilityHidden(true)
|
||
}
|
||
.padding(.vertical, 7)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityElement(children: .contain)
|
||
.accessibilityLabel(isSummaryCollapsed ? "展开运行概览" : "折叠运行概览")
|
||
.accessibilityAddTraits(.isButton)
|
||
}
|
||
|
||
|
||
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 summaryRows: [FleetViewModel.Row] { model.visibleRows }
|
||
|
||
private var batchGroupTargetIDs: [UUID] {
|
||
model.selectedIDs.isEmpty ? [] : Array(model.selectedIDs).sorted {
|
||
$0.uuidString < $1.uuidString
|
||
}
|
||
}
|
||
|
||
private var fleetSummary: some View {
|
||
AICard(padding: 16) {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
fleetOverviewHeader
|
||
|
||
HStack(spacing: 10) {
|
||
FleetPrimaryStat(
|
||
title: "在线率",
|
||
value: summaryRows.isEmpty ? "--" : "\(Int((Double(onlineCount) / Double(summaryRows.count) * 100).rounded()))%",
|
||
detail: "\(onlineCount)/\(summaryRows.count) 台在线",
|
||
systemImage: "checkmark.seal.fill",
|
||
tint: summaryRows.isEmpty ? AITheme.muted : (onlineCount == summaryRows.count ? AITheme.success : AITheme.warning)
|
||
)
|
||
FleetPrimaryStat(
|
||
title: "异常节点",
|
||
value: "\(abnormalCount)",
|
||
detail: abnormalCount > 0 ? "需要尽快处理" : "无异常节点",
|
||
systemImage: abnormalCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.seal.fill",
|
||
tint: abnormalCount > 0 ? AITheme.danger : AITheme.success
|
||
)
|
||
}
|
||
|
||
if isSummaryCollapsed {
|
||
HStack(spacing: 8) {
|
||
FleetHeaderMiniStat(
|
||
title: "信号",
|
||
value: signalSummary,
|
||
tint: signalSummaryTint,
|
||
systemImage: "cellularbars"
|
||
)
|
||
FleetHeaderMiniStat(
|
||
title: "存储",
|
||
value: storageSummary,
|
||
tint: storageSummaryTint,
|
||
systemImage: "internaldrive"
|
||
)
|
||
FleetHeaderMiniStat(
|
||
title: "分组",
|
||
value: "\(groupCount)",
|
||
tint: AITheme.primary,
|
||
systemImage: "folder"
|
||
)
|
||
}
|
||
} else {
|
||
HStack(alignment: .center, spacing: 10) {
|
||
HStack {
|
||
Text("节点状态分布")
|
||
.font(.caption.weight(.semibold))
|
||
.kerning(0.2)
|
||
.foregroundStyle(AITheme.muted)
|
||
Spacer()
|
||
Text("\(summaryRows.count) 台")
|
||
.font(.caption.weight(.semibold))
|
||
.monospacedDigit()
|
||
.foregroundStyle(AITheme.muted)
|
||
}
|
||
|
||
Grid(alignment: .leading, horizontalSpacing: 7, verticalSpacing: 0) {
|
||
GridRow {
|
||
ForEach(statusSegments, id: \.status) { segment in
|
||
statusSegmentButton(segment)
|
||
}
|
||
}
|
||
}
|
||
|
||
if model.statusFilter != nil {
|
||
Button {
|
||
model.statusFilter = nil
|
||
} label: {
|
||
Label("清除状态筛选", systemImage: "xmark.circle.fill")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(AITheme.primary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.frame(minHeight: 32)
|
||
}
|
||
}
|
||
}
|
||
|
||
if !isSummaryCollapsed {
|
||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
||
ForEach(stride(from: 0, to: fleetSummaryItems.count, by: 2).map {
|
||
Array(fleetSummaryItems[$0..<min($0 + 2, fleetSummaryItems.count)])
|
||
}, id: \.first?.title) { rowItems in
|
||
GridRow {
|
||
ForEach(rowItems, id: \.title) { item in
|
||
FleetStatCard(
|
||
title: item.title,
|
||
value: item.value,
|
||
systemImage: item.systemImage,
|
||
tint: item.tint
|
||
)
|
||
}
|
||
if rowItems.count == 1 {
|
||
Color.clear
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if summaryRows.isEmpty && !model.rows.isEmpty {
|
||
Text("当前筛选没有节点")
|
||
.font(.footnote.weight(.medium))
|
||
.foregroundStyle(AITheme.muted)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
FleetGroupDistributionStrip(
|
||
grouped: groupedCount,
|
||
ungrouped: summaryRows.count - groupedCount,
|
||
groupCount: groupCount,
|
||
tagCount: tagCount
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func statusSegmentButton(_ segment: FleetStatusSegment) -> some View {
|
||
let isSelected = model.statusFilter == segment.status
|
||
let tint = statusColor(segment.status)
|
||
|
||
return Button {
|
||
model.statusFilter = isSelected ? nil : segment.status
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("\(segment.count)")
|
||
.font(.subheadline.weight(.bold))
|
||
.monospacedDigit()
|
||
.foregroundStyle(tint)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
Capsule(style: .continuous)
|
||
.fill(tint)
|
||
.frame(height: 4)
|
||
}
|
||
.padding(.vertical, 9)
|
||
.padding(.horizontal, 10)
|
||
.frame(maxWidth: .infinity, minHeight: 44)
|
||
.background(isSelected ? tint.opacity(0.14) : AITheme.control)
|
||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.strokeBorder(isSelected ? tint.opacity(0.38) : AITheme.line)
|
||
}
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel("\(segment.status.label) \(segment.count) 台")
|
||
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton])
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private struct FleetStatusSegment {
|
||
let status: ConnectionStatus
|
||
let count: Int
|
||
|
||
var ratio: Double { max(0, Double(count)) }
|
||
}
|
||
|
||
private struct FleetGroupDistributionStrip: View {
|
||
let grouped: Int
|
||
let ungrouped: Int
|
||
let groupCount: Int
|
||
let tagCount: Int
|
||
|
||
private var total: Int { max(1, grouped + ungrouped) }
|
||
private var groupedRatio: CGFloat { CGFloat(grouped) / CGFloat(total) }
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack(spacing: 10) {
|
||
DistributionBadge(
|
||
title: "分组",
|
||
value: "\(groupCount)",
|
||
systemImage: "folder",
|
||
tint: AITheme.primary
|
||
)
|
||
DistributionBadge(
|
||
title: "标签",
|
||
value: "\(tagCount)",
|
||
systemImage: "tag",
|
||
tint: AITheme.success
|
||
)
|
||
Spacer(minLength: 8)
|
||
Text("已分组 \(grouped)/\(grouped + ungrouped)")
|
||
.font(.caption2.weight(.semibold))
|
||
.monospacedDigit()
|
||
.foregroundStyle(AITheme.muted)
|
||
}
|
||
|
||
GeometryReader { proxy in
|
||
ZStack(alignment: .leading) {
|
||
Capsule(style: .continuous)
|
||
.fill(AITheme.control)
|
||
Capsule(style: .continuous)
|
||
.fill(AITheme.primary.opacity(0.30))
|
||
.frame(width: max(4, proxy.size.width * groupedRatio))
|
||
}
|
||
}
|
||
.frame(height: 5)
|
||
}
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
}
|
||
|
||
private struct DistributionBadge: View {
|
||
let title: String
|
||
let value: String
|
||
let systemImage: String
|
||
let tint: Color
|
||
|
||
var body: some View {
|
||
HStack(spacing: 5) {
|
||
Image(systemName: systemImage)
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundStyle(tint)
|
||
.accessibilityHidden(true)
|
||
Text("\(title) \(value)")
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(AITheme.ink)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.8)
|
||
}
|
||
.padding(.horizontal, 9)
|
||
.frame(minHeight: 28)
|
||
.background(tint.opacity(0.10))
|
||
.clipShape(Capsule(style: .continuous))
|
||
.overlay {
|
||
Capsule(style: .continuous)
|
||
.strokeBorder(tint.opacity(0.16))
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
private var fleetSummaryItems: [FleetStatItem] {
|
||
var items = [
|
||
FleetStatItem(
|
||
title: "节点状态",
|
||
value: "\(onlineCount) 在线 · \(abnormalCount) 异常",
|
||
systemImage: "server.rack",
|
||
tint: abnormalCount > 0 ? AITheme.warning : AITheme.success
|
||
),
|
||
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)
|
||
]
|
||
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 }
|
||
|
||
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 signalValues: [Int] {
|
||
summaryRows.compactMap(\.signalPercent)
|
||
}
|
||
|
||
private var signalSummary: String {
|
||
formatAverage(signalValues) { "\($0)%" }
|
||
}
|
||
|
||
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 {
|
||
AICard {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 7) {
|
||
HStack(spacing: 8) {
|
||
Circle()
|
||
.fill(statusColor(row.status))
|
||
.frame(width: 7, height: 7)
|
||
.overlay {
|
||
Circle()
|
||
.strokeBorder(AITheme.line)
|
||
}
|
||
.accessibilityHidden(true)
|
||
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)
|
||
}
|
||
|
||
FleetStatusStrip(
|
||
status: row.status,
|
||
statusDetail: row.testing ? "正在读取运行信息" : statusDetail(row)
|
||
)
|
||
|
||
if let health = chainHealth(row) {
|
||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 6), GridItem(.flexible())], spacing: 6) {
|
||
ForEach(health) { item in
|
||
FleetLinkState(item: item)
|
||
}
|
||
}
|
||
}
|
||
|
||
if model.groupName(for: row.instance.groupId) != nil || !row.instance.tags.isEmpty {
|
||
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
|
||
InlinePill(title: tag, systemImage: "tag", tint: AITheme.muted)
|
||
}
|
||
}
|
||
}
|
||
|
||
FleetMetricGrid(metrics: rowMetrics(row))
|
||
}
|
||
}
|
||
.overlay(alignment: .topLeading) {
|
||
if model.selectionMode {
|
||
Button {
|
||
toggle(row.id)
|
||
} label: {
|
||
Image(
|
||
systemName: model.selectedIDs.contains(row.id)
|
||
? "checkmark.circle.fill" : "circle"
|
||
)
|
||
.font(.system(size: 20, weight: .medium))
|
||
.foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted)
|
||
.frame(width: 44, height: 44)
|
||
.background(.ultraThinMaterial, in: Circle())
|
||
.overlay {
|
||
Circle()
|
||
.strokeBorder(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.line)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(4)
|
||
.accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择")
|
||
.accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
|
||
}
|
||
}
|
||
}
|
||
|
||
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 }
|
||
return [
|
||
FleetLinkItem(
|
||
title: "硬件",
|
||
isAvailable: values.hardwareOnline,
|
||
),
|
||
FleetLinkItem(
|
||
title: "控制",
|
||
isAvailable: values.controlOnline,
|
||
),
|
||
FleetLinkItem(
|
||
title: "SIM",
|
||
isAvailable: values.simPresent,
|
||
),
|
||
FleetLinkItem(
|
||
title: "蜂窝",
|
||
isAvailable: values.cellularOnline,
|
||
),
|
||
]
|
||
}
|
||
|
||
|
||
private func rowMetrics(_ row: FleetViewModel.Row) -> [FleetMetric] {
|
||
var metrics: [FleetMetric] = [
|
||
.init(label: "设备", value: row.deviceName, systemImage: "iphone.gen3"),
|
||
.init(label: "运营商", value: row.operatorName, systemImage: "antenna.radiowaves.left.and.right"),
|
||
.init(label: "号码", value: row.phoneNumber, systemImage: "simcard"),
|
||
.init(label: "制式", value: row.networkTechnology, systemImage: "dot.radiowaves.left.and.right"),
|
||
.init(
|
||
label: "信号",
|
||
value: row.signalPercent.map { "\($0)%" },
|
||
systemImage: "cellularbars",
|
||
tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted
|
||
),
|
||
.init(label: "IP", value: row.ipAddress, systemImage: "network"),
|
||
.init(
|
||
label: "延迟",
|
||
value: row.latencyMilliseconds.map { "\($0) ms" },
|
||
systemImage: "speedometer",
|
||
tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted
|
||
)
|
||
]
|
||
|
||
metrics.append(
|
||
contentsOf: [
|
||
.init(
|
||
label: "CPU",
|
||
value: Self.cpuValue(percent: row.cpuPercent, cores: row.cpuCount),
|
||
systemImage: "cpu",
|
||
tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted
|
||
),
|
||
.init(
|
||
label: "内存",
|
||
value: row.memoryPercent.map { Self.percentText($0) },
|
||
systemImage: "memorychip",
|
||
tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted
|
||
),
|
||
.init(
|
||
label: "温度",
|
||
value: row.temperatureCelsius.map { Self.temperatureText($0) },
|
||
systemImage: "thermometer.medium",
|
||
tint: row.temperatureCelsius.flatMap(temperatureColor) ?? AITheme.muted
|
||
),
|
||
.init(
|
||
label: "电量",
|
||
value: row.batteryPercent.map { Self.percentText($0) },
|
||
systemImage: "battery.100",
|
||
tint: row.batteryPercent.flatMap(batteryColor) ?? AITheme.muted
|
||
)
|
||
]
|
||
)
|
||
|
||
metrics.append(
|
||
contentsOf: [
|
||
.init(label: "固件", value: row.firmwareVersion, systemImage: "waveform.path.ecg"),
|
||
.init(label: "运行时间", value: row.uptimeSeconds.map(Self.durationText), systemImage: "clock"),
|
||
.init(
|
||
label: "存储",
|
||
value: row.storageUsageRatio.map { "\(Int(($0 * 100).rounded()))% 已用" },
|
||
systemImage: "internaldrive",
|
||
tint: row.storageUsageRatio.flatMap(storageColor) ?? AITheme.muted
|
||
),
|
||
.init(
|
||
label: "下行",
|
||
value: row.downloadRateBytesPerSecond.map(Self.rateText),
|
||
systemImage: "arrow.down"
|
||
),
|
||
.init(
|
||
label: "上行",
|
||
value: row.uploadRateBytesPerSecond.map(Self.rateText),
|
||
systemImage: "arrow.up"
|
||
),
|
||
.init(label: "今日消息", value: row.messagesToday.map { "\($0) 条" }, systemImage: "message")
|
||
]
|
||
)
|
||
|
||
return metrics.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)
|
||
}
|
||
|
||
private func statusDetail(_ row: FleetViewModel.Row) -> String {
|
||
let parts = [
|
||
row.operatorName,
|
||
row.deviceName,
|
||
row.connectivity,
|
||
row.latencyMilliseconds.map { "延迟 \($0) ms" }
|
||
]
|
||
.compactMap { $0 }
|
||
.filter { !$0.isEmpty }
|
||
guard parts.isEmpty == false else {
|
||
return row.status == .online ? "连接正常" : "暂无运行信息"
|
||
}
|
||
return parts.joined(separator: " · ")
|
||
}
|
||
|
||
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 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 .tags, .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 {
|
||
instanceCard(row)
|
||
.contentShape(Rectangle())
|
||
} else {
|
||
NavigationLink {
|
||
InstanceDetailView(
|
||
instance: row.instance,
|
||
groupName: model.groupName(for: row.instance.groupId)
|
||
)
|
||
} label: {
|
||
instanceCard(row)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.contextMenu {
|
||
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 body: some View {
|
||
if metrics.isEmpty {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "clock.arrow.circlepath")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(AITheme.muted)
|
||
.accessibilityHidden(true)
|
||
Text("运行信息等待节点返回")
|
||
.font(.caption)
|
||
.foregroundStyle(AITheme.muted)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(11)
|
||
.frame(maxWidth: .infinity, minHeight: 42, alignment: .leading)
|
||
.background(AITheme.control)
|
||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
} else {
|
||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
||
ForEach(stride(from: 0, to: metrics.count, by: 2).map { metrics[$0..<min($0 + 2, metrics.count)] }, id: \.first?.label) { rowMetrics in
|
||
GridRow {
|
||
ForEach(Array(rowMetrics.enumerated()), id: \.offset) { _, metric in
|
||
metric
|
||
}
|
||
if rowMetrics.count == 1 {
|
||
Color.clear
|
||
.gridCellColumns(1)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(10)
|
||
.background(AITheme.control)
|
||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
}
|
||
}
|
||
}
|
||
|
||
struct FleetStatItem {
|
||
let title: String
|
||
let value: String
|
||
let systemImage: String
|
||
let tint: Color
|
||
}
|
||
|
||
struct FleetStatCard: View {
|
||
let title: String
|
||
let value: String
|
||
let systemImage: String
|
||
let tint: Color
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 7) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: systemImage)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(tint)
|
||
.accessibilityHidden(true)
|
||
Text(title)
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(AITheme.muted)
|
||
.lineLimit(1)
|
||
}
|
||
Text(value)
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.minimumScaleFactor(0.72)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(9)
|
||
.frame(maxWidth: .infinity, minHeight: 50, alignment: .topLeading)
|
||
.background(AITheme.control)
|
||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 4, style: .continuous))
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
}
|
||
|
||
struct FleetPrimaryStat: View {
|
||
let title: String
|
||
let value: String
|
||
let detail: String
|
||
let systemImage: String
|
||
let tint: Color
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 7) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: systemImage)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(tint)
|
||
.accessibilityHidden(true)
|
||
Text(title)
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(AITheme.muted)
|
||
.lineLimit(1)
|
||
}
|
||
Text(value)
|
||
.font(.title3.weight(.semibold))
|
||
.monospacedDigit()
|
||
.foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.7)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
Text(detail)
|
||
.font(.caption2)
|
||
.foregroundStyle(AITheme.muted)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.8)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, minHeight: 76, alignment: .topLeading)
|
||
.background(tint.opacity(0.08))
|
||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 4, style: .continuous))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: AITheme.cardRadius - 4, style: .continuous)
|
||
.strokeBorder(tint.opacity(0.14))
|
||
}
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
}
|
||
|
||
struct FleetHeaderMiniStat: View {
|
||
let title: String
|
||
let value: String
|
||
let tint: Color
|
||
let systemImage: String
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 5) {
|
||
Image(systemName: systemImage)
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundStyle(tint)
|
||
.accessibilityHidden(true)
|
||
Text(title)
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(AITheme.muted)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.75)
|
||
}
|
||
Text(value)
|
||
.font(.caption2.weight(.semibold))
|
||
.monospacedDigit()
|
||
.foregroundStyle(tint == AITheme.muted ? AITheme.ink : tint)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.7)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(.horizontal, 9)
|
||
.padding(.vertical, 8)
|
||
.frame(maxWidth: .infinity, minHeight: 44, alignment: .topLeading)
|
||
.background(AITheme.control)
|
||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
}
|
||
|
||
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: 8) {
|
||
Circle()
|
||
.fill(statusTint)
|
||
.frame(width: 6, height: 6)
|
||
Text(statusDetail)
|
||
.font(.caption)
|
||
.foregroundStyle(AITheme.muted)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.frame(maxWidth: .infinity, minHeight: 30, alignment: .leading)
|
||
.background(AITheme.control)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
.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 FleetLinkState: 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 {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(item.title)
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundStyle(AITheme.muted)
|
||
.lineLimit(1)
|
||
HStack(spacing: 5) {
|
||
Circle()
|
||
.fill(tint)
|
||
.frame(width: 5, height: 5)
|
||
.accessibilityHidden(true)
|
||
Text(stateText)
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(tint)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.75)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 6)
|
||
.frame(maxWidth: .infinity, minHeight: 36, alignment: .topLeading)
|
||
.background(tint.opacity(0.08))
|
||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||
.strokeBorder(tint.opacity(0.16))
|
||
}
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
}
|
||
|
||
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 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 {
|
||
if testing > 0 { return AITheme.warning }
|
||
return onlineRatio >= 1 ? AITheme.success : (onlineRatio > 0 ? AITheme.warning : AITheme.muted)
|
||
}
|
||
|
||
private var progress: Double {
|
||
onlineRatio
|
||
}
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
VStack(alignment: .leading, spacing: 9) {
|
||
HStack(spacing: 10) {
|
||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||
.fill(tint)
|
||
.frame(width: 4, height: 24)
|
||
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)
|
||
}
|
||
|
||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 7) {
|
||
GridRow {
|
||
FleetHeaderMetric(
|
||
title: "节点",
|
||
value: "\(total)",
|
||
systemImage: "server.rack",
|
||
tint: AITheme.ink
|
||
)
|
||
FleetHeaderMetric(
|
||
title: "在线率",
|
||
value: onlineValue,
|
||
systemImage: "checkmark.circle",
|
||
tint: onlineTint,
|
||
placeholder: onlineValue == "不可用"
|
||
)
|
||
}
|
||
GridRow {
|
||
FleetHeaderMetric(
|
||
title: "异常率",
|
||
value: abnormalValue,
|
||
systemImage: "exclamationmark.triangle",
|
||
tint: abnormalTint,
|
||
placeholder: abnormalValue == "不可用"
|
||
)
|
||
FleetHeaderMetric(
|
||
title: "检测中",
|
||
value: "\(testing)",
|
||
systemImage: "clock.arrow.circlepath",
|
||
tint: testing > 0 ? AITheme.warning : AITheme.muted,
|
||
placeholder: testing == 0
|
||
)
|
||
}
|
||
}
|
||
|
||
HStack(spacing: 8) {
|
||
ProgressView(value: progress)
|
||
.progressViewStyle(.linear)
|
||
.tint(progressTint)
|
||
.frame(height: 4)
|
||
Text("\(onlineRatio.formatted(.number.precision(.fractionLength(0))))%")
|
||
.font(.caption2.weight(.semibold))
|
||
.monospacedDigit()
|
||
.foregroundStyle(progressTint)
|
||
.frame(width: 38, alignment: .trailing)
|
||
}
|
||
|
||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 7) {
|
||
GridRow {
|
||
FleetHeaderMetric(title: "信号", value: signalValue, systemImage: "cellularbars", tint: signalTint, placeholder: signalValue == "不可用")
|
||
FleetHeaderMetric(title: "延迟", value: latencyValue, systemImage: "speedometer", tint: latencyTint, placeholder: latencyValue == "不可用")
|
||
}
|
||
GridRow {
|
||
FleetHeaderMetric(title: "存储", value: storageValue, systemImage: "internaldrive", tint: storageTint, placeholder: storageValue == "不可用")
|
||
FleetHeaderMetric(title: "流量", value: trafficValue, systemImage: "arrow.up.arrow.down", tint: trafficTint, placeholder: trafficValue == "不可用")
|
||
}
|
||
}
|
||
}
|
||
.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)
|
||
}
|
||
|
||
private struct FleetHeaderMetric: View {
|
||
let title: String
|
||
let value: String
|
||
let systemImage: String
|
||
let tint: Color
|
||
var placeholder = false
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: systemImage)
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundStyle(placeholder ? AITheme.muted : tint)
|
||
.accessibilityHidden(true)
|
||
Text(title)
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundStyle(AITheme.muted)
|
||
.lineLimit(1)
|
||
}
|
||
Text(value)
|
||
.font(.caption2.weight(.semibold))
|
||
.monospacedDigit()
|
||
.foregroundStyle(placeholder ? AITheme.muted : AITheme.ink)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.7)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct FleetMetric: View {
|
||
let label: String
|
||
let value: String?
|
||
let systemImage: String
|
||
var tint: Color = AITheme.primary
|
||
|
||
var hasValue: Bool { value?.isEmpty == false }
|
||
|
||
var body: some View {
|
||
if hasValue {
|
||
HStack(spacing: 7) {
|
||
Image(systemName: systemImage)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(tint)
|
||
.frame(width: 16)
|
||
.accessibilityHidden(true)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(label)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(AITheme.muted)
|
||
.lineLimit(1)
|
||
Text(value ?? "暂无")
|
||
.font(.caption.weight(.medium))
|
||
.foregroundStyle(AITheme.ink)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|