release(ios): 发布 0.2.1 并重构图标与界面细节
- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读 - 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失 - 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升 - 详情头部指标精简为 4 行,数据模块改为单条读数 - 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt - 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
This commit is contained in:
@@ -1,18 +1,35 @@
|
||||
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 }
|
||||
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 {
|
||||
@@ -26,56 +43,63 @@ struct InstanceGroupEditorView: View {
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
AISectionTitle(
|
||||
"移动到",
|
||||
systemImage: "folder",
|
||||
detail: "\(targets.count) 台实例"
|
||||
)
|
||||
|
||||
if isCreatingGroup {
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
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
|
||||
)
|
||||
.onSubmit { Task { await createAndAssign() } }
|
||||
AIActionRow(spacing: 8) {
|
||||
Button("取消") {
|
||||
isCreatingGroup = false
|
||||
newGroupName = ""
|
||||
errorMessage = nil
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
.disabled(isWorking)
|
||||
|
||||
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
|
||||
)
|
||||
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)
|
||||
}
|
||||
@@ -89,28 +113,13 @@ struct InstanceGroupEditorView: View {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("新建") {
|
||||
selectedGroupID = nil
|
||||
isCreatingGroup = true
|
||||
AIIconButton("新建分组", systemImage: "plus") {
|
||||
errorMessage = nil
|
||||
isCreatingGroup = true
|
||||
}
|
||||
.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,23 +127,27 @@ struct InstanceGroupEditorView: View {
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
value: UUID?,
|
||||
isSelected: Bool,
|
||||
selectionTrait: Bool
|
||||
groupID: UUID?
|
||||
) -> some View {
|
||||
Button {
|
||||
selectedGroupID = value
|
||||
isCreatingGroup = false
|
||||
newGroupName = ""
|
||||
errorMessage = nil
|
||||
guard uniformGroup != groupID else {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
Task { await apply(groupID: groupID) }
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
|
||||
.foregroundStyle(isCurrent(groupID) ? AITheme.primary : AITheme.muted)
|
||||
.frame(width: 32, height: 32)
|
||||
.background((isSelected ? AITheme.primary : AITheme.muted).opacity(0.10))
|
||||
.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) {
|
||||
@@ -150,56 +163,64 @@ struct InstanceGroupEditorView: View {
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(
|
||||
systemName: isSelected
|
||||
? "checkmark.circle.fill"
|
||||
: "circle.dashed"
|
||||
)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
|
||||
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)
|
||||
.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])
|
||||
.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 save() async {
|
||||
private func apply(groupID: UUID?) 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
|
||||
)
|
||||
try await model.assign(groupID: groupID, to: targets)
|
||||
onSaved()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = isCreatingGroup
|
||||
? "创建分组失败,名称不能为空且不能重复。"
|
||||
: "保存分组失败,请检查节点后重试。"
|
||||
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
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user