Refine primary and toolbar actions, align row buttons, normalize status surfaces, and split storage from destructive cleanup. Verified with Swift tests and the simulator build.
219 lines
6.4 KiB
Swift
219 lines
6.4 KiB
Swift
import SwiftUI
|
|
|
|
struct FleetView: View {
|
|
@StateObject private var model: FleetViewModel
|
|
@State private var showsDeleteDialog = false
|
|
|
|
init(model: FleetViewModel? = nil) {
|
|
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 12) {
|
|
searchField
|
|
if let errorMessage = model.errorMessage {
|
|
AIStatusBanner(
|
|
errorMessage,
|
|
tint: AITheme.danger,
|
|
systemImage: "exclamationmark.triangle"
|
|
)
|
|
}
|
|
if model.selectionMode {
|
|
selectionBar
|
|
}
|
|
if model.isLoading && model.rows.isEmpty {
|
|
AILoadingCard("正在同步实例数据")
|
|
}
|
|
if model.rows.isEmpty && !model.isLoading {
|
|
AICard {
|
|
AIEmptyState(
|
|
systemImage: "server.rack",
|
|
title: "暂无实例",
|
|
message: "添加 SimAdmin 实例后即可开始管理。"
|
|
)
|
|
}
|
|
}
|
|
ForEach(model.rows) { row in
|
|
if model.selectionMode {
|
|
instanceCard(row)
|
|
} else {
|
|
NavigationLink {
|
|
InstanceDetailView(instance: row.instance)
|
|
} label: {
|
|
instanceCard(row)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
.aiBottomActionBar {
|
|
if !model.selectionMode {
|
|
AIActionRow {
|
|
Button {
|
|
model.selectionMode = true
|
|
} label: {
|
|
Label("批量选择", systemImage: "checklist")
|
|
}
|
|
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
|
|
|
Button {
|
|
model.showingForm = true
|
|
} label: {
|
|
Label("添加实例", systemImage: "plus")
|
|
}
|
|
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
|
}
|
|
} else {
|
|
AIActionRow {
|
|
Button {
|
|
model.selectionMode = false
|
|
model.selectedIDs.removeAll()
|
|
} label: {
|
|
Label("取消选择", systemImage: "xmark.circle")
|
|
}
|
|
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
|
|
|
Button {
|
|
showsDeleteDialog = true
|
|
} label: {
|
|
Label(
|
|
model.selectedIDs.isEmpty ? "删除" : "删除 \(model.selectedIDs.count)",
|
|
systemImage: "trash"
|
|
)
|
|
}
|
|
.buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true))
|
|
.disabled(model.selectedIDs.isEmpty)
|
|
}
|
|
}
|
|
}
|
|
.background(AITheme.canvas.ignoresSafeArea())
|
|
.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()
|
|
}
|
|
.sheet(isPresented: $model.showingForm) {
|
|
InstanceFormView(model: model)
|
|
}
|
|
}
|
|
|
|
private var searchField: some View {
|
|
AISearchField("搜索实例或地址", text: $model.searchText) {
|
|
Task { await model.refresh() }
|
|
}
|
|
}
|
|
|
|
private var selectionBar: some View {
|
|
AICard {
|
|
HStack(spacing: 10) {
|
|
Button {
|
|
if model.selectedIDs.count == model.rows.count {
|
|
model.selectedIDs.removeAll()
|
|
} else {
|
|
model.selectedIDs = Set(model.rows.map(\.id))
|
|
}
|
|
} label: {
|
|
Label(
|
|
model.selectedIDs.count == model.rows.count ? "取消全选" : "全选",
|
|
systemImage: "checkmark.circle"
|
|
)
|
|
}
|
|
.buttonStyle(
|
|
AIButtonStyle(
|
|
kind: .secondary,
|
|
size: .regular,
|
|
fixedWidth: 96
|
|
)
|
|
)
|
|
|
|
Spacer()
|
|
|
|
Text("已选 \(model.selectedIDs.count)")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(AITheme.muted)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func instanceCard(_ row: FleetViewModel.Row) -> some View {
|
|
AICard {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
if model.selectionMode {
|
|
Button {
|
|
toggle(row.id)
|
|
} label: {
|
|
Image(
|
|
systemName: model.selectedIDs.contains(row.id)
|
|
? "checkmark.circle.fill" : "circle"
|
|
)
|
|
.font(.system(size: 20))
|
|
.foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: 44, height: 44)
|
|
.accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择")
|
|
.accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
|
|
}
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
Text(row.instance.name)
|
|
.font(.headline)
|
|
.foregroundStyle(AITheme.ink)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.layoutPriority(1)
|
|
Spacer()
|
|
AITag(row.status.label, tint: statusColor(row.status))
|
|
}
|
|
Text(row.instance.origin.absoluteString)
|
|
.font(.subheadline)
|
|
.foregroundStyle(AITheme.muted)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
if !row.instance.tags.isEmpty {
|
|
HStack(spacing: 6) {
|
|
ForEach(row.instance.tags, id: \.self) { tag in
|
|
AITag(tag)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 toggle(_ id: UUID) {
|
|
if model.selectedIDs.contains(id) {
|
|
model.selectedIDs.remove(id)
|
|
} else {
|
|
model.selectedIDs.insert(id)
|
|
}
|
|
}
|
|
}
|