fix(ios): improve fleet overview and app identity
- Show stable group health in a fixed 2x2 metric grid - Add link-chain state to fleet cards and parse hardware/control/SIM/cellular - Replace the wrapped icon with a full-bleed app icon - Bump the IPA build number
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,136 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum FleetGroupMode: String, CaseIterable, Identifiable {
|
||||
case none
|
||||
case status
|
||||
case tags
|
||||
case groups
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .none: return "平铺"
|
||||
case .status: return "状态"
|
||||
case .tags: return "标签"
|
||||
case .groups: return "分组"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FleetGroup: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let rows: [FleetViewModel.Row]
|
||||
}
|
||||
|
||||
struct FleetGroupHealth: Equatable {
|
||||
var total = 0
|
||||
var online = 0
|
||||
var abnormal = 0
|
||||
var testing = 0
|
||||
var signalValues: [Int] = []
|
||||
var latencyValues: [Int] = []
|
||||
var batteryValues: [Double] = []
|
||||
var storageValues: [Double] = []
|
||||
var trafficValues: [Double] = []
|
||||
var onlineValues: [Bool] = []
|
||||
var abnormalValues: [Bool] = []
|
||||
|
||||
private var signalAverage: Double? {
|
||||
average(signalValues.map(Double.init))
|
||||
}
|
||||
|
||||
private var latencyAverage: Double? {
|
||||
average(latencyValues.map(Double.init))
|
||||
}
|
||||
|
||||
private var batteryAverage: Double? {
|
||||
average(batteryValues)
|
||||
}
|
||||
|
||||
private var storageAverage: Double? {
|
||||
average(storageValues)
|
||||
}
|
||||
|
||||
private var trafficTotal: Double {
|
||||
trafficValues.reduce(0, +)
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
var parts = ["\(total) 台"]
|
||||
if let signal = signalAverage {
|
||||
parts.append("信号 \(Int(signal.rounded()))%")
|
||||
}
|
||||
if let latency = latencyAverage {
|
||||
parts.append("延迟 \(Int(latency.rounded())) ms")
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var signalTint: Color? {
|
||||
signalAverage.map { $0 >= 60 ? AITheme.success : ($0 >= 30 ? AITheme.warning : AITheme.danger) }
|
||||
}
|
||||
|
||||
var latencyTint: Color? {
|
||||
latencyAverage.map { $0 < 300 ? AITheme.success : ($0 < 800 ? AITheme.warning : AITheme.danger) }
|
||||
}
|
||||
|
||||
var batteryTint: Color? {
|
||||
batteryAverage.map { $0 <= 20 ? AITheme.danger : ($0 <= 40 ? AITheme.warning : AITheme.success) }
|
||||
}
|
||||
|
||||
var storageTint: Color? {
|
||||
storageAverage.map { $0 >= 0.90 ? AITheme.danger : ($0 >= 0.75 ? AITheme.warning : AITheme.success) }
|
||||
}
|
||||
|
||||
var trafficTint: Color? {
|
||||
trafficValues.isEmpty ? nil : (trafficTotal >= 50_000_000 ? AITheme.warning : AITheme.success)
|
||||
}
|
||||
|
||||
var onlineAverageText: String {
|
||||
guard onlineValues.isEmpty == false else { return "不可用" }
|
||||
let ratio = Double(onlineValues.filter { $0 }.count) / Double(onlineValues.count)
|
||||
return "\(Int((ratio * 100).rounded()))%"
|
||||
}
|
||||
|
||||
var onlineTint: Color? {
|
||||
onlineValues.isEmpty ? nil : (online > 0 ? AITheme.success : AITheme.muted)
|
||||
}
|
||||
|
||||
var abnormalAverageText: String {
|
||||
guard abnormalValues.isEmpty == false else { return "不可用" }
|
||||
let ratio = Double(abnormalValues.filter { $0 }.count) / Double(abnormalValues.count)
|
||||
return "\(Int((ratio * 100).rounded()))%"
|
||||
}
|
||||
|
||||
var abnormalTint: Color? {
|
||||
abnormalValues.isEmpty ? nil : (abnormal > 0 ? AITheme.danger : AITheme.success)
|
||||
}
|
||||
|
||||
private func average(_ values: [Double]) -> Double? {
|
||||
guard values.isEmpty == false else { return nil }
|
||||
return values.reduce(0, +) / Double(values.count)
|
||||
}
|
||||
|
||||
var signalAverageText: String {
|
||||
signalAverage.map { "\(Int($0.rounded()))%" } ?? "不可用"
|
||||
}
|
||||
|
||||
var latencyAverageText: String {
|
||||
latencyAverage.map { "\(Int($0.rounded())) ms" } ?? "不可用"
|
||||
}
|
||||
|
||||
var storageAverageText: String {
|
||||
storageAverage.map { "\(Int(($0 * 100).rounded()))% 已用" } ?? "不可用"
|
||||
}
|
||||
|
||||
var trafficTotalText: String {
|
||||
guard trafficValues.isEmpty == false else { return "不可用" }
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(trafficTotal), countStyle: .decimal) + "/s"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FleetViewModel: ObservableObject {
|
||||
@@ -6,34 +138,93 @@ final class FleetViewModel: ObservableObject {
|
||||
let id: UUID
|
||||
var instance: Instance
|
||||
var status: ConnectionStatus
|
||||
var deviceName: String?
|
||||
var phoneNumber: String?
|
||||
var operatorName: String?
|
||||
var networkTechnology: String?
|
||||
var signalPercent: Int?
|
||||
var cpuPercent: Double?
|
||||
var memoryPercent: Double?
|
||||
var cpuCount: Int?
|
||||
var temperatureCelsius: Double?
|
||||
var batteryPercent: Double?
|
||||
var ipAddress: String?
|
||||
var latencyMilliseconds: Int?
|
||||
var connectivity: String?
|
||||
var firmwareVersion: String?
|
||||
var totalStorageBytes: Int64?
|
||||
var availableStorageBytes: Int64?
|
||||
var downloadRateBytesPerSecond: Double?
|
||||
var uploadRateBytesPerSecond: Double?
|
||||
var messagesToday: Int?
|
||||
var uptimeSeconds: Double?
|
||||
var chainValues: DeviceLinkState?
|
||||
var testing: Bool
|
||||
|
||||
var storageUsageRatio: Double? {
|
||||
guard let total = totalStorageBytes, let available = availableStorageBytes, total > 0 else {
|
||||
return nil
|
||||
}
|
||||
return Double(max(0, total - available)) / Double(total)
|
||||
}
|
||||
|
||||
init(
|
||||
id: UUID,
|
||||
instance: Instance,
|
||||
status: ConnectionStatus = .unknown,
|
||||
testing: Bool = true
|
||||
) {
|
||||
self.id = id
|
||||
self.instance = instance
|
||||
self.status = status
|
||||
self.testing = testing
|
||||
}
|
||||
}
|
||||
|
||||
@Published var rows: [Row] = []
|
||||
@Published var searchText = ""
|
||||
@Published var statusFilter: ConnectionStatus?
|
||||
@Published var groupMode: FleetGroupMode = {
|
||||
let stored = UserDefaults.standard.string(forKey: "fleet.groupMode")
|
||||
return FleetGroupMode(rawValue: stored ?? "") ?? .groups
|
||||
}() {
|
||||
didSet {
|
||||
UserDefaults.standard.set(groupMode.rawValue, forKey: "fleet.groupMode")
|
||||
}
|
||||
}
|
||||
@Published var selectedIDs: Set<UUID> = []
|
||||
@Published var selectionMode = false
|
||||
@Published var isLoading = false
|
||||
@Published private(set) var lastRefresh: Date?
|
||||
@Published var errorMessage: String?
|
||||
@Published var showingForm = false
|
||||
@Published var showingGroupManager = false
|
||||
@Published private(set) var groups: [InstanceGroup] = []
|
||||
|
||||
private let repository: any InstanceRepository
|
||||
private let probe: ConnectionProbe
|
||||
private let credentials: any CredentialStore
|
||||
private let deviceService: (any DeviceSummaryService)?
|
||||
private let groupStore: (any InstanceGroupStore)?
|
||||
|
||||
init(
|
||||
repository: any InstanceRepository,
|
||||
probe: ConnectionProbe,
|
||||
credentials: any CredentialStore
|
||||
credentials: any CredentialStore,
|
||||
deviceService: (any DeviceSummaryService)? = nil,
|
||||
groupStore: (any InstanceGroupStore)? = nil
|
||||
) {
|
||||
self.repository = repository
|
||||
self.probe = probe
|
||||
self.credentials = credentials
|
||||
self.deviceService = deviceService
|
||||
self.groupStore = groupStore
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
await loadGroups()
|
||||
do {
|
||||
let query = InstanceQuery(search: searchText.isEmpty ? nil : searchText)
|
||||
let instances = try await repository.list(query)
|
||||
@@ -42,25 +233,51 @@ final class FleetViewModel: ObservableObject {
|
||||
}
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for instance in instances {
|
||||
group.addTask { [probe] in
|
||||
group.addTask { [probe, deviceService] in
|
||||
let result = try? await probe.test(instance)
|
||||
await self.applyProbe(instance.id, result)
|
||||
let summary = try? await deviceService?.summary(instance)
|
||||
await self.applyProbe(instance.id, result, summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "无法刷新节点列表"
|
||||
}
|
||||
lastRefresh = Date()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func add(_ input: InstanceInput) async throws {
|
||||
@discardableResult
|
||||
func add(_ input: InstanceInput) async throws -> Instance {
|
||||
let created = try await repository.create(input)
|
||||
if let password = input.password, !password.isEmpty {
|
||||
try credentials.set(instanceID: created.id, password: password)
|
||||
}
|
||||
showingForm = false
|
||||
await refresh()
|
||||
return created
|
||||
}
|
||||
|
||||
func update(_ id: UUID, _ input: InstanceInput) async throws {
|
||||
guard let original = rows.first(where: { $0.id == id }) else {
|
||||
throw RepositoryError.notFound
|
||||
}
|
||||
_ = try await repository.update(
|
||||
id,
|
||||
expectedRevision: original.instance.configRevision,
|
||||
InstancePatch(
|
||||
name: input.name,
|
||||
origin: input.origin,
|
||||
tags: input.tags,
|
||||
groupId: input.groupId,
|
||||
password: input.password
|
||||
)
|
||||
)
|
||||
if let password = input.password, !password.isEmpty {
|
||||
try credentials.set(instanceID: id, password: password)
|
||||
}
|
||||
showingForm = false
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func delete(_ ids: [UUID]) async {
|
||||
@@ -83,9 +300,195 @@ final class FleetViewModel: ObservableObject {
|
||||
await delete(Array(selectedIDs))
|
||||
}
|
||||
|
||||
private func applyProbe(_ id: UUID, _ result: ConnectionResult?) {
|
||||
@discardableResult
|
||||
func createGroup(named name: String) async throws -> InstanceGroup {
|
||||
guard let groupStore else { throw RepositoryError.notFound }
|
||||
let group = try await groupStore.create(InstanceGroupInput(name: name))
|
||||
await loadGroups()
|
||||
return group
|
||||
}
|
||||
|
||||
func updateGroup(_ id: UUID, name: String) async throws {
|
||||
guard let groupStore else { return }
|
||||
_ = try await groupStore.update(id, name)
|
||||
await loadGroups()
|
||||
}
|
||||
|
||||
func deleteGroup(_ id: UUID) async throws {
|
||||
guard let groupStore else { return }
|
||||
try await groupStore.delete(id)
|
||||
for row in rows where row.instance.groupId == id {
|
||||
_ = try await repository.update(
|
||||
row.instance.id,
|
||||
expectedRevision: row.instance.configRevision,
|
||||
InstancePatch(groupId: nil)
|
||||
)
|
||||
}
|
||||
await loadGroups()
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func assign(groupID: UUID?, to ids: [UUID]) async throws {
|
||||
var failures = 0
|
||||
for id in ids {
|
||||
guard let row = rows.first(where: { $0.id == id }) else { continue }
|
||||
do {
|
||||
_ = try await repository.update(
|
||||
id,
|
||||
expectedRevision: row.instance.configRevision,
|
||||
InstancePatch(groupId: groupID)
|
||||
)
|
||||
} catch {
|
||||
failures += 1
|
||||
errorMessage = "更新 \(row.instance.name) 分组失败"
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
throw RepositoryError.databaseFailed
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func loadGroups() async {
|
||||
guard let groupStore else { return }
|
||||
groups = (try? await groupStore.list()) ?? []
|
||||
}
|
||||
|
||||
func groupName(for id: UUID?) -> String? {
|
||||
guard let id else { return nil }
|
||||
return groups.first { $0.id == id }?.name
|
||||
}
|
||||
|
||||
private func applyProbe(
|
||||
_ id: UUID,
|
||||
_ result: ConnectionResult?,
|
||||
_ summary: DeviceSummary?
|
||||
) {
|
||||
guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
|
||||
rows[index].status = result?.status ?? .unknown
|
||||
if let summary {
|
||||
rows[index].deviceName = summary.deviceName
|
||||
rows[index].phoneNumber = summary.phoneNumber
|
||||
rows[index].operatorName = summary.operatorName
|
||||
rows[index].networkTechnology = summary.networkTechnology
|
||||
rows[index].signalPercent = summary.signalPercent
|
||||
rows[index].cpuPercent = summary.cpuPercent
|
||||
rows[index].memoryPercent = summary.memoryPercent
|
||||
rows[index].cpuCount = summary.cpuCount
|
||||
rows[index].temperatureCelsius = summary.temperatureCelsius
|
||||
rows[index].batteryPercent = summary.batteryPercent
|
||||
rows[index].ipAddress = summary.ipAddress
|
||||
rows[index].latencyMilliseconds = summary.latencyMilliseconds
|
||||
rows[index].connectivity = summary.connectivity
|
||||
rows[index].firmwareVersion = summary.firmwareVersion
|
||||
rows[index].totalStorageBytes = summary.totalStorageBytes
|
||||
rows[index].availableStorageBytes = summary.availableStorageBytes
|
||||
rows[index].downloadRateBytesPerSecond = summary.downloadRateBytesPerSecond
|
||||
rows[index].uploadRateBytesPerSecond = summary.uploadRateBytesPerSecond
|
||||
rows[index].messagesToday = summary.messagesToday
|
||||
rows[index].uptimeSeconds = summary.uptimeSeconds
|
||||
rows[index].chainValues = summary.linkState
|
||||
}
|
||||
if rows[index].latencyMilliseconds == nil {
|
||||
rows[index].latencyMilliseconds = result?.requestLatencyMilliseconds
|
||||
}
|
||||
rows[index].testing = false
|
||||
}
|
||||
|
||||
var visibleRows: [Row] {
|
||||
rows.filter { row in
|
||||
if let statusFilter, row.status != statusFilter {
|
||||
return false
|
||||
}
|
||||
guard !searchText.isEmpty else { return true }
|
||||
let haystacks: [String] = [
|
||||
row.instance.name,
|
||||
row.instance.origin.absoluteString,
|
||||
row.instance.tags.joined(separator: " "),
|
||||
row.operatorName ?? "",
|
||||
row.phoneNumber ?? "",
|
||||
row.networkTechnology ?? "",
|
||||
row.deviceName ?? ""
|
||||
]
|
||||
let strings: [String] = haystacks
|
||||
return strings.contains {
|
||||
$0.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var groupedRows: [FleetGroup] {
|
||||
let rows = visibleRows.sorted {
|
||||
$0.instance.name.localizedCompare($1.instance.name) == .orderedAscending
|
||||
}
|
||||
switch groupMode {
|
||||
case .none:
|
||||
return [FleetGroup(id: "", title: "", rows: rows)]
|
||||
case .status:
|
||||
let order: [ConnectionStatus] = [.online, .degraded, .authRequired, .offline, .unknown]
|
||||
return order.compactMap { status in
|
||||
let matched = rows.filter { $0.status == status }
|
||||
guard !matched.isEmpty else { return nil }
|
||||
return FleetGroup(id: status.rawValue, title: status.label, rows: matched)
|
||||
}
|
||||
case .tags:
|
||||
let tags = Array(Set(rows.flatMap(\.instance.tags))).sorted()
|
||||
let grouped = tags.map { tag in
|
||||
FleetGroup(
|
||||
id: tag,
|
||||
title: tag,
|
||||
rows: rows.filter { $0.instance.tags.contains(tag) }
|
||||
)
|
||||
}
|
||||
let untagged = rows.filter { $0.instance.tags.isEmpty }
|
||||
return grouped + (untagged.isEmpty
|
||||
? []
|
||||
: [FleetGroup(id: "__untagged__", title: "未打标签", rows: untagged)])
|
||||
case .groups:
|
||||
var groupedRows: [UUID: [Row]] = [:]
|
||||
var ungroupedRows: [Row] = []
|
||||
for row in rows {
|
||||
if let id = row.instance.groupId, groupName(for: id) != nil {
|
||||
groupedRows[id, default: []].append(row)
|
||||
} else {
|
||||
ungroupedRows.append(row)
|
||||
}
|
||||
}
|
||||
let namedGroups = groupedRows.map { key, values in
|
||||
FleetGroup(
|
||||
id: key.uuidString,
|
||||
title: groupName(for: key) ?? "未命名分组",
|
||||
rows: values
|
||||
)
|
||||
}
|
||||
.sorted { $0.title.localizedCompare($1.title) == .orderedAscending }
|
||||
return namedGroups + (ungroupedRows.isEmpty
|
||||
? []
|
||||
: [FleetGroup(id: "__ungrouped__", title: "未分组", rows: ungroupedRows)])
|
||||
}
|
||||
}
|
||||
|
||||
func health(for group: FleetGroup) -> FleetGroupHealth {
|
||||
FleetGroupHealth(
|
||||
total: group.rows.count,
|
||||
online: group.rows.filter { $0.status == .online }.count,
|
||||
abnormal: group.rows.filter { $0.status != .online && $0.status != .unknown }.count,
|
||||
testing: group.rows.filter(\.testing).count,
|
||||
signalValues: group.rows.compactMap(\.signalPercent),
|
||||
latencyValues: group.rows.compactMap(\.latencyMilliseconds),
|
||||
batteryValues: group.rows.compactMap(\.batteryPercent),
|
||||
storageValues: group.rows.compactMap(\.storageUsageRatio),
|
||||
trafficValues: group.rows.compactMap(\.downloadRateBytesPerSecond)
|
||||
+ group.rows.compactMap(\.uploadRateBytesPerSecond)
|
||||
,
|
||||
onlineValues: group.rows.map { $0.status == .online },
|
||||
abnormalValues: group.rows.map { $0.status != .online && $0.status != .unknown }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protocol DeviceSummaryService {
|
||||
func summary(_ instance: Instance) async throws -> DeviceSummary
|
||||
}
|
||||
|
||||
extension DeviceService: DeviceSummaryService {}
|
||||
|
||||
@@ -3,13 +3,24 @@ import SwiftUI
|
||||
struct InstanceFormView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
let instance: Instance?
|
||||
@State private var name = ""
|
||||
@State private var originText = ""
|
||||
@State private var tagsText = ""
|
||||
@State private var password = ""
|
||||
@State private var selectedGroupId: UUID?
|
||||
@State private var validationMessage: String?
|
||||
@State private var isSaving = false
|
||||
|
||||
init(model: FleetViewModel, instance: Instance? = nil) {
|
||||
self.model = model
|
||||
self.instance = instance
|
||||
_name = State(initialValue: instance?.name ?? "")
|
||||
_originText = State(initialValue: instance?.origin.absoluteString ?? "")
|
||||
_tagsText = State(initialValue: instance?.tags.joined(separator: ", ") ?? "")
|
||||
_selectedGroupId = State(initialValue: instance?.groupId)
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
@@ -24,7 +35,13 @@ struct InstanceFormView: View {
|
||||
field("名称", text: $name, placeholder: "Home Node")
|
||||
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
|
||||
field("标签", text: $tagsText, placeholder: "home, primary")
|
||||
field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true)
|
||||
groupPicker
|
||||
field(
|
||||
"实例密码",
|
||||
text: $password,
|
||||
placeholder: instance == nil ? "可选,保存在本机沙盒" : "留空则不修改密码",
|
||||
secure: true
|
||||
)
|
||||
}
|
||||
}
|
||||
if let validationMessage {
|
||||
@@ -56,13 +73,37 @@ struct InstanceFormView: View {
|
||||
}
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("添加实例")
|
||||
.navigationTitle(instance == nil ? "添加实例" : "编辑实例")
|
||||
#if os(iOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var groupPicker: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
AIFieldLabel("分组")
|
||||
Spacer()
|
||||
Button {
|
||||
model.showingGroupManager = true
|
||||
} label: {
|
||||
Label("管理", systemImage: "folder.badge.gearshape")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 72))
|
||||
}
|
||||
Picker("分组", selection: $selectedGroupId) {
|
||||
Text("未分组").tag(UUID?.none)
|
||||
ForEach(model.groups) { group in
|
||||
Text(group.name).tag(UUID?.some(group.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private func field(
|
||||
_ title: String,
|
||||
text: Binding<String>,
|
||||
@@ -111,11 +152,16 @@ struct InstanceFormView: View {
|
||||
name: normalizedName,
|
||||
origin: origin,
|
||||
tags: tags,
|
||||
groupId: selectedGroupId,
|
||||
password: password.isEmpty ? nil : password
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
try await model.add(input)
|
||||
if let instance {
|
||||
try await model.update(instance.id, input)
|
||||
} else {
|
||||
try await model.add(input)
|
||||
}
|
||||
} catch {
|
||||
validationMessage = "保存失败,请检查地址是否重复。"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceGroupEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
let targetIDs: [UUID]
|
||||
let onSaved: () -> Void
|
||||
|
||||
@State private var selectedGroupID: UUID?
|
||||
@State private var isCreatingGroup = false
|
||||
@State private var newGroupName = ""
|
||||
@State private var isWorking = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private var selectedCount: Int { targetIDs.count }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage,
|
||||
tint: AITheme.danger,
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
}
|
||||
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle(
|
||||
"批量归类",
|
||||
systemImage: "folder.badge.gearshape",
|
||||
detail: selectedCount == 0 ? "全部实例" : "\(selectedCount) 个实例"
|
||||
)
|
||||
Text("选择一个分组后保存,所选实例会立即移入该分组。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
if isCreatingGroup {
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AIFieldLabel("新分组名称")
|
||||
TextField("例如:家里、外场、测试机", text: $newGroupName)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.submitLabel(.done)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AICard(padding: 8) {
|
||||
VStack(spacing: 2) {
|
||||
groupRow(
|
||||
title: "未分组",
|
||||
subtitle: "移出当前分组",
|
||||
systemImage: "square.dashed",
|
||||
value: nil,
|
||||
isSelected: selectedGroupID == nil,
|
||||
selectionTrait: selectedGroupID == nil
|
||||
)
|
||||
|
||||
ForEach(model.groups) { group in
|
||||
Divider()
|
||||
.overlay(AITheme.line.opacity(0.55))
|
||||
groupRow(
|
||||
title: group.name,
|
||||
subtitle: "\(instanceCount(group.id)) 个实例",
|
||||
systemImage: "folder",
|
||||
value: group.id,
|
||||
isSelected: selectedGroupID == group.id,
|
||||
selectionTrait: selectedGroupID == group.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("实例分组")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("新建") {
|
||||
selectedGroupID = nil
|
||||
isCreatingGroup = true
|
||||
errorMessage = nil
|
||||
}
|
||||
.disabled(isWorking)
|
||||
}
|
||||
}
|
||||
.aiBottomActionBar {
|
||||
AIActionRow {
|
||||
Button {
|
||||
Task { await save() }
|
||||
} label: {
|
||||
Label(
|
||||
selectedGroupID == nil ? "移出分组" : "移入分组",
|
||||
systemImage: "tray.and.arrow.down"
|
||||
)
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(isWorking || (selectedGroupID == nil && !isCreatingGroup))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func groupRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
value: UUID?,
|
||||
isSelected: Bool,
|
||||
selectionTrait: Bool
|
||||
) -> some View {
|
||||
Button {
|
||||
selectedGroupID = value
|
||||
isCreatingGroup = false
|
||||
newGroupName = ""
|
||||
errorMessage = nil
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
|
||||
.frame(width: 32, height: 32)
|
||||
.background((isSelected ? AITheme.primary : AITheme.muted).opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(
|
||||
systemName: isSelected
|
||||
? "checkmark.circle.fill"
|
||||
: "circle.dashed"
|
||||
)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(minHeight: 54)
|
||||
.background(isSelected ? AITheme.primary.opacity(0.08) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(title),\(subtitle)")
|
||||
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton])
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func instanceCount(_ id: UUID) -> Int {
|
||||
model.rows.filter { $0.instance.groupId == id }.count
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
if isCreatingGroup {
|
||||
let name = newGroupName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let group = try await model.createGroup(named: name)
|
||||
selectedGroupID = group.id
|
||||
isCreatingGroup = false
|
||||
newGroupName = ""
|
||||
}
|
||||
|
||||
try await model.assign(
|
||||
groupID: selectedGroupID,
|
||||
to: targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs
|
||||
)
|
||||
onSaved()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = isCreatingGroup
|
||||
? "创建分组失败,名称不能为空且不能重复。"
|
||||
: "保存分组失败,请检查节点后重试。"
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceGroupManagerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
@State private var newName = ""
|
||||
@State private var editingGroup: InstanceGroup?
|
||||
@State private var editingName = ""
|
||||
@State private var validationMessage: String?
|
||||
@State private var isWorking = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AIFieldLabel("新增分组")
|
||||
TextField("例如:家里、外场、测试机", text: $newName)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.submitLabel(.done)
|
||||
}
|
||||
}
|
||||
|
||||
if let validationMessage {
|
||||
AIStatusBanner(
|
||||
validationMessage,
|
||||
tint: AITheme.danger,
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
}
|
||||
|
||||
if model.groups.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "folder.badge.plus",
|
||||
title: "暂无分组",
|
||||
message: "创建分组后,实例可按业务或位置归类。"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ForEach(model.groups) { group in
|
||||
AICard {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "folder")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(AITheme.primary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(group.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
Text("\(instanceCount(group.id)) 个实例")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
editingGroup = group
|
||||
editingName = group.name
|
||||
validationMessage = nil
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 44))
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await delete(group)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .danger, size: .toolbarInline, fixedWidth: 44))
|
||||
.disabled(instanceCount(group.id) > 0 || isWorking)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("分组管理")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("完成") { dismiss() }
|
||||
}
|
||||
}
|
||||
.aiBottomActionBar {
|
||||
AIActionRow {
|
||||
Button {
|
||||
Task { await create() }
|
||||
} label: {
|
||||
Label("创建分组", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(isWorking)
|
||||
}
|
||||
}
|
||||
.alert("编辑分组", isPresented: Binding(
|
||||
get: { editingGroup != nil },
|
||||
set: { if !$0 { editingGroup = nil } }
|
||||
)) {
|
||||
TextField("分组名称", text: $editingName)
|
||||
Button("保存") {
|
||||
Task { await saveEditing() }
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
} message: {
|
||||
Text("分组名称最多 100 个字符。")
|
||||
}
|
||||
.task {
|
||||
await model.loadGroups()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func instanceCount(_ id: UUID) -> Int {
|
||||
model.rows.filter { $0.instance.groupId == id }.count
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
validationMessage = nil
|
||||
do {
|
||||
try await model.createGroup(named: newName)
|
||||
newName = ""
|
||||
} catch {
|
||||
validationMessage = "创建失败,名称不能为空且不能重复。"
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
|
||||
private func saveEditing() async {
|
||||
guard let editingGroup else { return }
|
||||
isWorking = true
|
||||
validationMessage = nil
|
||||
do {
|
||||
try await model.updateGroup(editingGroup.id, name: editingName)
|
||||
self.editingGroup = nil
|
||||
} catch {
|
||||
validationMessage = "更新失败,名称不能为空且不能重复。"
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
|
||||
private func delete(_ group: InstanceGroup) async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
do {
|
||||
try await model.deleteGroup(group.id)
|
||||
} catch {
|
||||
validationMessage = "删除失败,请稍后重试。"
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user