import SwiftUI /// 批量归类:点一个分组就把目标实例移进去并立即生效,不再需要二次“保存”。 struct InstanceGroupEditorView: View { @Environment(\.dismiss) private var dismiss @ObservedObject var model: FleetViewModel let targetIDs: [UUID] let onSaved: () -> Void @State private var isCreatingGroup = false @State private var newGroupName = "" @State private var isWorking = false @State private var errorMessage: String? private var targets: [UUID] { targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs } private var targetGroupIDs: [UUID?] { let owner = Dictionary(model.rows.map { ($0.id, $0.instance.groupId) }, uniquingKeysWith: { a, _ in a }) return targets.compactMap { owner[$0] } } /// 目标实例当前是否同属一个分组;nil 表示 mixed,不显示对勾。 private var uniformGroup: UUID?? { guard let first = targetGroupIDs.first else { return nil } return targetGroupIDs.allSatisfy { $0 == first } ? .some(first) : nil } private var trimmedName: String { newGroupName.trimmingCharacters(in: .whitespacesAndNewlines) } var body: some View { NavigationStack { ScrollView { LazyVStack(spacing: 12) { if let errorMessage { AIStatusBanner( errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle" ) } AISectionTitle( "移动到", systemImage: "folder", detail: "\(targets.count) 台实例" ) if isCreatingGroup { AICard(padding: 16) { VStack(alignment: .leading, spacing: 12) { AIFieldLabel("新分组名称") TextField("例如:家里、外场、测试机", text: $newGroupName) .textFieldStyle(AIFieldStyle()) .submitLabel(.done) .onSubmit { Task { await createAndAssign() } } AIActionRow(spacing: 8) { Button("取消") { isCreatingGroup = false newGroupName = "" errorMessage = nil } .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) .disabled(isWorking) Button { Task { await createAndAssign() } } label: { Label("创建并移入", systemImage: "folder.badge.plus") } .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) .disabled(trimmedName.isEmpty || isWorking) } } } } AICard(padding: 8) { VStack(spacing: 2) { groupRow( title: "未分组", subtitle: "不归属任何分组", systemImage: "square.dashed", groupID: nil ) ForEach(model.groups) { group in Divider() .overlay(AITheme.line.opacity(0.55)) groupRow( title: group.name, subtitle: "\(instanceCount(group.id)) 台在此", systemImage: "folder", groupID: group.id ) } } } .opacity(isWorking ? 0.55 : 1) } .padding(16) } .background(AITheme.canvas.ignoresSafeArea()) .navigationTitle("实例分组") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } ToolbarItem(placement: .primaryAction) { AIIconButton("新建分组", systemImage: "plus") { errorMessage = nil isCreatingGroup = true } .disabled(isWorking) } } } } private func groupRow( title: String, subtitle: String, systemImage: String, groupID: UUID? ) -> some View { Button { guard uniformGroup != groupID else { dismiss() return } Task { await apply(groupID: groupID) } } label: { HStack(spacing: 12) { Image(systemName: systemImage) .font(.subheadline.weight(.semibold)) .foregroundStyle(isCurrent(groupID) ? AITheme.primary : AITheme.muted) .frame(width: 32, height: 32) .background((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.10)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay { // 深色下 10% 色洗几乎看不出边界,补一圈同色淡描边保住方块轮廓。 RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.20)) } .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) Group { if isWorking { EmptyView() } else if isCurrent(groupID) { Image(systemName: "checkmark") .font(.system(size: 14, weight: .bold)) .foregroundStyle(AITheme.primary) } } .frame(width: 20, alignment: .trailing) } .padding(.horizontal, 12) .padding(.vertical, 8) .frame(minHeight: 54) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .contentShape(Rectangle()) .accessibilityElement(children: .combine) .accessibilityLabel(isCurrent(groupID) ? "\(title),当前分组" : "移动到\(title)") .accessibilityAddTraits(.isButton) } .buttonStyle(.plain) .disabled(isWorking) } private func isCurrent(_ groupID: UUID?) -> Bool { uniformGroup == groupID && targetGroupIDs.isEmpty == false } private func instanceCount(_ id: UUID) -> Int { model.rows.filter { $0.instance.groupId == id }.count } private func apply(groupID: UUID?) async { guard !isWorking else { return } isWorking = true errorMessage = nil do { try await model.assign(groupID: groupID, to: targets) onSaved() dismiss() } catch { errorMessage = "分组保存失败,请重试。" isWorking = false } } private func createAndAssign() async { guard !isWorking, trimmedName.isEmpty == false else { return } isWorking = true errorMessage = nil do { let group = try await model.createGroup(named: trimmedName) try await model.assign(groupID: group.id, to: targets) onSaved() dismiss() } catch { errorMessage = "创建分组失败,名称不能为空且不能重复。" isWorking = false } } }