release(ios): 发布 0.2.1 并重构图标与界面细节
- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读 - 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失 - 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升 - 详情头部指标精简为 4 行,数据模块改为单条读数 - 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt - 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ import SwiftUI
|
||||
enum FleetGroupMode: String, CaseIterable, Identifiable {
|
||||
case none
|
||||
case status
|
||||
case tags
|
||||
case groups
|
||||
|
||||
var id: String { rawValue }
|
||||
@@ -13,7 +12,6 @@ enum FleetGroupMode: String, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .none: return "平铺"
|
||||
case .status: return "状态"
|
||||
case .tags: return "标签"
|
||||
case .groups: return "分组"
|
||||
}
|
||||
}
|
||||
@@ -184,12 +182,15 @@ final class FleetViewModel: ObservableObject {
|
||||
@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
|
||||
}() {
|
||||
@Published var groupMode: FleetGroupMode = .groups {
|
||||
didSet {
|
||||
UserDefaults.standard.set(groupMode.rawValue, forKey: "fleet.groupMode")
|
||||
preferences.set(groupMode.rawValue, forKey: "fleet.groupMode")
|
||||
}
|
||||
}
|
||||
@Published var selectedTagFilters: Set<String> = [] {
|
||||
didSet {
|
||||
let values = Array(selectedTagFilters).sorted()
|
||||
preferences.set(values, forKey: "fleet.selectedTagFilters")
|
||||
}
|
||||
}
|
||||
@Published var selectedIDs: Set<UUID> = []
|
||||
@@ -206,19 +207,35 @@ final class FleetViewModel: ObservableObject {
|
||||
private let credentials: any CredentialStore
|
||||
private let deviceService: (any DeviceSummaryService)?
|
||||
private let groupStore: (any InstanceGroupStore)?
|
||||
private let preferences: UserDefaults
|
||||
|
||||
init(
|
||||
repository: any InstanceRepository,
|
||||
probe: ConnectionProbe,
|
||||
credentials: any CredentialStore,
|
||||
deviceService: (any DeviceSummaryService)? = nil,
|
||||
groupStore: (any InstanceGroupStore)? = nil
|
||||
groupStore: (any InstanceGroupStore)? = nil,
|
||||
preferences: UserDefaults = .standard
|
||||
) {
|
||||
self.repository = repository
|
||||
self.probe = probe
|
||||
self.credentials = credentials
|
||||
self.deviceService = deviceService
|
||||
self.groupStore = groupStore
|
||||
self.preferences = preferences
|
||||
// 旧版本的「按标签分组」已废弃,标签现在只作为筛选条件存在。
|
||||
let storedMode = preferences.string(forKey: "fleet.groupMode")
|
||||
groupMode = FleetGroupMode(rawValue: storedMode ?? "") ?? .groups
|
||||
selectedTagFilters = Set(preferences.stringArray(forKey: "fleet.selectedTagFilters") ?? [])
|
||||
}
|
||||
|
||||
/// 节点列表变化后,丢弃已经不存在任何实例的标签筛选,避免列表被隐藏条件清空。
|
||||
func pruneTagFilters() {
|
||||
guard selectedTagFilters.isEmpty == false else { return }
|
||||
let live = selectedTagFilters.filter { countForTagFilter($0) > 0 }
|
||||
if live != selectedTagFilters {
|
||||
selectedTagFilters = live
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
@@ -243,6 +260,7 @@ final class FleetViewModel: ObservableObject {
|
||||
} catch {
|
||||
errorMessage = "无法刷新节点列表"
|
||||
}
|
||||
pruneTagFilters()
|
||||
lastRefresh = Date()
|
||||
isLoading = false
|
||||
}
|
||||
@@ -400,6 +418,13 @@ final class FleetViewModel: ObservableObject {
|
||||
if let statusFilter, row.status != statusFilter {
|
||||
return false
|
||||
}
|
||||
if selectedTagFilters.isEmpty == false {
|
||||
let tags = Set(row.instance.tags.map { Self.normalizedTag($0) })
|
||||
let needles = Set(selectedTagFilters.map { Self.normalizedTag($0) })
|
||||
guard needles.isSubset(of: tags) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
guard !searchText.isEmpty else { return true }
|
||||
let haystacks: [String] = [
|
||||
row.instance.name,
|
||||
@@ -417,6 +442,47 @@ final class FleetViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
var allTags: [String] {
|
||||
Array(tagCounts.keys).sorted { $0.localizedCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
var tagCounts: [String: Int] {
|
||||
var counts: [String: Int] = [:]
|
||||
for row in rows {
|
||||
for tag in Set(row.instance.tags.map(Self.normalizedTag)) where tag.isEmpty == false {
|
||||
counts[tag, default: 0] += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func countForTagFilter(_ tag: String) -> Int {
|
||||
tagCounts[Self.normalizedTag(tag)] ?? 0
|
||||
}
|
||||
|
||||
func isTagFilterActive(_ tag: String) -> Bool {
|
||||
selectedTagFilters.contains(Self.normalizedTag(tag))
|
||||
}
|
||||
|
||||
func toggleTagFilter(_ tag: String) {
|
||||
let normalized = Self.normalizedTag(tag)
|
||||
guard normalized.isEmpty == false else { return }
|
||||
if selectedTagFilters.contains(normalized) {
|
||||
selectedTagFilters.remove(normalized)
|
||||
} else {
|
||||
selectedTagFilters.insert(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func clearTagFilters() {
|
||||
guard selectedTagFilters.isEmpty == false else { return }
|
||||
selectedTagFilters.removeAll()
|
||||
}
|
||||
|
||||
private static func normalizedTag(_ tag: String) -> String {
|
||||
tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
var groupedRows: [FleetGroup] {
|
||||
let rows = visibleRows.sorted {
|
||||
$0.instance.name.localizedCompare($1.instance.name) == .orderedAscending
|
||||
@@ -431,19 +497,6 @@ final class FleetViewModel: ObservableObject {
|
||||
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] = []
|
||||
|
||||
@@ -6,7 +6,8 @@ struct InstanceFormView: View {
|
||||
let instance: Instance?
|
||||
@State private var name = ""
|
||||
@State private var originText = ""
|
||||
@State private var tagsText = ""
|
||||
@State private var tags: [String] = []
|
||||
@State private var tagDraft = ""
|
||||
@State private var password = ""
|
||||
@State private var selectedGroupId: UUID?
|
||||
@State private var validationMessage: String?
|
||||
@@ -17,7 +18,7 @@ struct InstanceFormView: View {
|
||||
self.instance = instance
|
||||
_name = State(initialValue: instance?.name ?? "")
|
||||
_originText = State(initialValue: instance?.origin.absoluteString ?? "")
|
||||
_tagsText = State(initialValue: instance?.tags.joined(separator: ", ") ?? "")
|
||||
_tags = State(initialValue: instance?.tags ?? [])
|
||||
_selectedGroupId = State(initialValue: instance?.groupId)
|
||||
}
|
||||
|
||||
@@ -34,12 +35,12 @@ struct InstanceFormView: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
field("名称", text: $name, placeholder: "Home Node")
|
||||
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
|
||||
field("标签", text: $tagsText, placeholder: "home, primary")
|
||||
tagEditor
|
||||
groupPicker
|
||||
field(
|
||||
"实例密码",
|
||||
text: $password,
|
||||
placeholder: instance == nil ? "可选,保存在本机沙盒" : "留空则不修改密码",
|
||||
placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码",
|
||||
secure: true
|
||||
)
|
||||
}
|
||||
@@ -104,6 +105,14 @@ struct InstanceFormView: View {
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private var tagEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("标签")
|
||||
TagEditorView(tags: $tags, suggestions: model.allTags)
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private func field(
|
||||
_ title: String,
|
||||
text: Binding<String>,
|
||||
@@ -121,12 +130,12 @@ struct InstanceFormView: View {
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.accessibilityLabel(title)
|
||||
.submitLabel(title == "地址" ? .next : .done)
|
||||
.submitLabel(.done)
|
||||
#if os(iOS)
|
||||
.keyboardType(title == "地址" ? .URL : .default)
|
||||
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled(title == "地址" || title == "标签")
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,11 +152,6 @@ struct InstanceFormView: View {
|
||||
isSaving = false
|
||||
return
|
||||
}
|
||||
let tags =
|
||||
tagsText
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
let input = InstanceInput(
|
||||
name: normalizedName,
|
||||
origin: origin,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ struct InstanceGroupManagerView: View {
|
||||
@State private var editingName = ""
|
||||
@State private var validationMessage: String?
|
||||
@State private var isWorking = false
|
||||
@State private var pendingDelete: InstanceGroup?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -62,24 +63,16 @@ struct InstanceGroupManagerView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
AISymbolAction("重命名分组 \(group.name)", systemImage: "pencil", tint: AITheme.muted) {
|
||||
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")
|
||||
AISymbolAction("删除分组 \(group.name)", systemImage: "trash", tint: AITheme.danger) {
|
||||
pendingDelete = group
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .danger, size: .toolbarInline, fixedWidth: 44))
|
||||
.disabled(instanceCount(group.id) > 0 || isWorking)
|
||||
.disabled(isWorking)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +117,23 @@ struct InstanceGroupManagerView: View {
|
||||
} message: {
|
||||
Text("分组名称最多 100 个字符。")
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingDelete == nil
|
||||
? "删除分组?"
|
||||
: "删除「\(pendingDelete!.name)」?该分组下的 \(instanceCount(pendingDelete!.id)) 个实例会回到未分组。",
|
||||
isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let group = pendingDelete {
|
||||
Button("删除分组", role: .destructive) {
|
||||
Task { await delete(group) }
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.task {
|
||||
await model.loadGroups()
|
||||
}
|
||||
@@ -163,6 +173,7 @@ struct InstanceGroupManagerView: View {
|
||||
private func delete(_ group: InstanceGroup) async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
pendingDelete = nil
|
||||
do {
|
||||
try await model.deleteGroup(group.id)
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 标签编辑器:已选标签用可删除胶囊展示,历史标签可点选复用。
|
||||
struct TagEditorView: View {
|
||||
@Binding var tags: [String]
|
||||
let suggestions: [String]
|
||||
@State private var draft = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("新标签,回车添加", text: $draft)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.done)
|
||||
#endif
|
||||
.onSubmit { commit(draft) }
|
||||
|
||||
Button {
|
||||
commit(draft)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.frame(width: 44, height: 44)
|
||||
.foregroundStyle(canAdd ? AITheme.primary : AITheme.muted)
|
||||
.background(canAdd ? AITheme.primary.opacity(0.10) : AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(canAdd ? AITheme.primary.opacity(0.40) : AITheme.line)
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(canAdd == false)
|
||||
.accessibilityLabel("添加标签")
|
||||
}
|
||||
|
||||
if tags.isEmpty == false || suggestions.isEmpty == false {
|
||||
// 已选与可选分成两条横向胶囊带:自定义流式布局在弹层里拿到的宽度提案不稳定,
|
||||
// 会出现高度算少、胶囊压到下一个字段的问题,横向滚动条带既稳又和节点页一致。
|
||||
if tags.isEmpty == false {
|
||||
chipStrip(ids: tags.map { "sel-\($0)" }) { index in
|
||||
removableTag(tags[index])
|
||||
}
|
||||
}
|
||||
if availableSuggestions.isEmpty == false {
|
||||
chipStrip(ids: availableSuggestions.map { "sug-\($0)" }) { index in
|
||||
suggestionTag(availableSuggestions[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("标签")
|
||||
}
|
||||
|
||||
private func chipStrip<Item: View>(
|
||||
ids: [String],
|
||||
@ViewBuilder content: @escaping (Int) -> Item
|
||||
) -> some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(ids.enumerated()), id: \.element) { index, _ in
|
||||
content(index)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.accessibilityHidden(false)
|
||||
}
|
||||
|
||||
private var trimmedDraft: String {
|
||||
draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var canAdd: Bool {
|
||||
trimmedDraft.isEmpty == false
|
||||
}
|
||||
|
||||
private var availableSuggestions: [String] {
|
||||
let taken = Set(tags.map(Self.key))
|
||||
return suggestions
|
||||
.filter { taken.contains(Self.key($0)) == false }
|
||||
.prefix(10)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
private func removableTag(_ tag: String) -> some View {
|
||||
Button {
|
||||
tags.removeAll { Self.key($0) == Self.key(tag) }
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Text(tag)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.opacity(0.65)
|
||||
}
|
||||
.padding(.horizontal, 11)
|
||||
.frame(height: 32)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.background(AITheme.control)
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
.overlay {
|
||||
Capsule(style: .continuous).strokeBorder(AITheme.line)
|
||||
}
|
||||
.contentShape(Capsule(style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("移除标签 \(tag)")
|
||||
}
|
||||
|
||||
private func suggestionTag(_ tag: String) -> some View {
|
||||
Button {
|
||||
add(tag)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
Text(tag)
|
||||
.font(.footnote.weight(.medium))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 11)
|
||||
.frame(height: 32)
|
||||
.foregroundStyle(AITheme.primary)
|
||||
.background(AITheme.primary.opacity(0.09))
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
.overlay {
|
||||
Capsule(style: .continuous).strokeBorder(AITheme.primary.opacity(0.24))
|
||||
}
|
||||
.contentShape(Capsule(style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("使用已有标签 \(tag)")
|
||||
}
|
||||
|
||||
private func commit(_ raw: String) {
|
||||
let parts = raw
|
||||
.split(whereSeparator: { $0 == "," || $0 == "," || $0 == "\n" })
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
parts.forEach(add)
|
||||
draft = ""
|
||||
}
|
||||
|
||||
private func add(_ tag: String) {
|
||||
let value = tag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard value.isEmpty == false else { return }
|
||||
guard tags.contains(where: { Self.key($0) == Self.key(value) }) == false else { return }
|
||||
tags.append(value)
|
||||
}
|
||||
|
||||
private static func key(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user