feat(ios): refine native interface and app icon
Unify bottom actions, selection controls, navigation structure, spacing, and theme colors across iOS pages. Replace the app icon with a higher-contrast native-style asset and include its source SVG.
This commit is contained in:
@@ -9,24 +9,26 @@ struct InstanceDetailView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
header
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if let snapshot = model.snapshot {
|
||||
ForEach(snapshot.sections) { section in
|
||||
sectionCard(section)
|
||||
}
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
header
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if let snapshot = model.snapshot {
|
||||
ForEach(snapshot.sections) { section in
|
||||
sectionCard(section)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle(model.instance.name)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
|
||||
@@ -1,212 +1,217 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FleetView: View {
|
||||
@StateObject private var model: FleetViewModel
|
||||
@State private var showsDeleteDialog = false
|
||||
@StateObject private var model: FleetViewModel
|
||||
@State private var showsDeleteDialog = false
|
||||
|
||||
init(model: FleetViewModel? = nil) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
|
||||
}
|
||||
init(model: FleetViewModel? = nil) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
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)
|
||||
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 实例后即可开始管理。"
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("节点总览")
|
||||
.aiBottomActionBar {
|
||||
if !model.selectionMode {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
model.selectionMode = true
|
||||
} label: {
|
||||
Label("批量选择", systemImage: "checklist")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Button {
|
||||
model.showingForm = true
|
||||
} label: {
|
||||
Label("添加实例", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
ForEach(model.rows) { row in
|
||||
if model.selectionMode {
|
||||
instanceCard(row)
|
||||
} else {
|
||||
NavigationLink {
|
||||
InstanceDetailView(instance: row.instance)
|
||||
} label: {
|
||||
instanceCard(row)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"删除已选实例?登录信息也会一并清除。",
|
||||
isPresented: $showsDeleteDialog,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("删除 \(model.selectedIDs.count) 个实例", role: .destructive) {
|
||||
Task { await model.deleteSelected() }
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.aiBottomActionBar {
|
||||
if !model.selectionMode {
|
||||
AIActionRow {
|
||||
Button {
|
||||
model.selectionMode = true
|
||||
} label: {
|
||||
Label("批量选择", systemImage: "checklist")
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
.sheet(isPresented: $model.showingForm) {
|
||||
InstanceFormView(model: model)
|
||||
}
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
|
||||
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: .compact,
|
||||
fixedWidth: AITheme.footerSecondaryButtonWidth
|
||||
)
|
||||
)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("已选 \(model.selectedIDs.count)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
|
||||
Button {
|
||||
showsDeleteDialog = true
|
||||
} label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .danger,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.footerSecondaryButtonWidth
|
||||
)
|
||||
)
|
||||
.disabled(model.selectedIDs.isEmpty)
|
||||
Button {
|
||||
model.showingForm = true
|
||||
} label: {
|
||||
Label("添加实例", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: 32, height: 32)
|
||||
.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)
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
}
|
||||
} else {
|
||||
model.selectedIDs.insert(id)
|
||||
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: .compact
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +1,125 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceFormView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
@State private var name = ""
|
||||
@State private var originText = ""
|
||||
@State private var tagsText = ""
|
||||
@State private var password = ""
|
||||
@State private var validationMessage: String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
@State private var name = ""
|
||||
@State private var originText = ""
|
||||
@State private var tagsText = ""
|
||||
@State private var password = ""
|
||||
@State private var validationMessage: String?
|
||||
@State private var isSaving = false
|
||||
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
AICard {
|
||||
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")
|
||||
field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true)
|
||||
}
|
||||
}
|
||||
if let validationMessage {
|
||||
AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
AICard {
|
||||
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")
|
||||
field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true)
|
||||
}
|
||||
.navigationTitle("添加实例")
|
||||
.aiBottomActionBar {
|
||||
HStack(spacing: 10) {
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
if let validationMessage {
|
||||
AIStatusBanner(
|
||||
validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.aiBottomActionBar {
|
||||
AIActionRow {
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
|
||||
Button("保存实例") { save() }
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(!canSave)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
Button {
|
||||
save()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isSaving {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(isSaving ? "保存中" : "保存实例")
|
||||
}
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(!canSave || isSaving)
|
||||
}
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("添加实例")
|
||||
#if os(iOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func field(
|
||||
_ title: String,
|
||||
text: Binding<String>,
|
||||
placeholder: String,
|
||||
secure: Bool = false
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel(title)
|
||||
if secure {
|
||||
SecureField(placeholder, text: text)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.textContentType(.password)
|
||||
} else {
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
#if os(iOS)
|
||||
.keyboardType(title == "地址" ? .URL : .default)
|
||||
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
|
||||
#endif
|
||||
.autocorrectionDisabled(title == "地址" || title == "标签")
|
||||
}
|
||||
}
|
||||
private func field(
|
||||
_ title: String,
|
||||
text: Binding<String>,
|
||||
placeholder: String,
|
||||
secure: Bool = false
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel(title)
|
||||
if secure {
|
||||
SecureField(placeholder, text: text)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.textContentType(.password)
|
||||
.accessibilityLabel(title)
|
||||
} else {
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.accessibilityLabel(title)
|
||||
.submitLabel(title == "地址" ? .next : .done)
|
||||
#if os(iOS)
|
||||
.keyboardType(title == "地址" ? .URL : .default)
|
||||
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
|
||||
#endif
|
||||
.autocorrectionDisabled(title == "地址" || title == "标签")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
origin.scheme == "http" || origin.scheme == "https",
|
||||
origin.host != nil
|
||||
else {
|
||||
validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。"
|
||||
return
|
||||
}
|
||||
let tags = tagsText
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
let input = InstanceInput(
|
||||
name: normalizedName,
|
||||
origin: origin,
|
||||
tags: tags,
|
||||
password: password.isEmpty ? nil : password
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
try await model.add(input)
|
||||
dismiss()
|
||||
} catch {
|
||||
validationMessage = "保存失败,请检查地址是否重复。"
|
||||
}
|
||||
}
|
||||
private func save() {
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
origin.scheme == "http" || origin.scheme == "https",
|
||||
origin.host != nil
|
||||
else {
|
||||
validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。"
|
||||
isSaving = false
|
||||
return
|
||||
}
|
||||
let tags =
|
||||
tagsText
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
let input = InstanceInput(
|
||||
name: normalizedName,
|
||||
origin: origin,
|
||||
tags: tags,
|
||||
password: password.isEmpty ? nil : password
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
try await model.add(input)
|
||||
} catch {
|
||||
validationMessage = "保存失败,请检查地址是否重复。"
|
||||
}
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,43 +16,42 @@ struct LogsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if model.snapshot == nil && model.isLoading {
|
||||
AILoadingCard("正在同步日志数据")
|
||||
} else {
|
||||
stats
|
||||
AISegmentedControl(
|
||||
"日志视图",
|
||||
selection: $tab,
|
||||
options: [
|
||||
(value: Tab.runtime, title: "运行日志"),
|
||||
(value: Tab.connections, title: "连接日志"),
|
||||
(value: Tab.diagnostics, title: "设备诊断"),
|
||||
]
|
||||
)
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if model.snapshot == nil && model.isLoading {
|
||||
AILoadingCard("正在同步日志数据")
|
||||
} else {
|
||||
stats
|
||||
AISegmentedControl(
|
||||
"日志视图",
|
||||
selection: $tab,
|
||||
options: [
|
||||
(value: Tab.runtime, title: "运行日志"),
|
||||
(value: Tab.connections, title: "连接日志"),
|
||||
(value: Tab.diagnostics, title: "设备诊断"),
|
||||
]
|
||||
)
|
||||
|
||||
filterBar
|
||||
filterBar
|
||||
|
||||
switch tab {
|
||||
case .runtime:
|
||||
runtimeSection
|
||||
case .connections:
|
||||
connectionSection
|
||||
case .diagnostics:
|
||||
diagnosticSection
|
||||
}
|
||||
switch tab {
|
||||
case .runtime:
|
||||
runtimeSection
|
||||
case .connections:
|
||||
connectionSection
|
||||
case .diagnostics:
|
||||
diagnosticSection
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("日志中心")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
|
||||
@@ -1,138 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MessagesView: View {
|
||||
@StateObject private var model: MessagesViewModel
|
||||
@State private var pendingDeleteMessage: SMSMessage?
|
||||
@StateObject private var model: MessagesViewModel
|
||||
@State private var pendingDeleteMessage: SMSMessage?
|
||||
|
||||
init(model: MessagesViewModel? = nil) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel())
|
||||
init(model: MessagesViewModel? = nil) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
filterBar
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if model.isLoading && model.filteredRows.isEmpty {
|
||||
AILoadingCard("正在同步消息数据")
|
||||
}
|
||||
if model.filteredRows.isEmpty && !model.isLoading {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "message",
|
||||
title: "暂无消息",
|
||||
message: "刷新后从实例同步短信。"
|
||||
)
|
||||
}
|
||||
}
|
||||
ForEach(model.filteredRows) { message in
|
||||
messageCard(message)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
filterBar
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if model.isLoading && model.filteredRows.isEmpty {
|
||||
AILoadingCard("正在同步消息数据")
|
||||
}
|
||||
if model.filteredRows.isEmpty && !model.isLoading {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "message",
|
||||
title: "暂无消息",
|
||||
message: "刷新后从实例同步短信。"
|
||||
)
|
||||
}
|
||||
}
|
||||
ForEach(model.filteredRows) { message in
|
||||
messageCard(message)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("消息中心")
|
||||
.aiBottomActionBar {
|
||||
Button {
|
||||
model.showingSendForm = true
|
||||
} label: {
|
||||
Label("发送消息", systemImage: "paperplane")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
}
|
||||
.confirmationDialog(
|
||||
"删除这条消息?操作不可恢复。",
|
||||
isPresented: Binding(
|
||||
get: { pendingDeleteMessage != nil },
|
||||
set: { if !$0 { pendingDeleteMessage = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("删除消息", role: .destructive) {
|
||||
guard let message = pendingDeleteMessage else { return }
|
||||
Task { await model.delete(message) }
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
.sheet(isPresented: $model.showingSendForm) {
|
||||
SendMessageFormView(model: model)
|
||||
.aiBottomActionBar {
|
||||
Button {
|
||||
model.showingSendForm = true
|
||||
} label: {
|
||||
Label("发送消息", systemImage: "paperplane")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
}
|
||||
|
||||
private var filterBar: some View {
|
||||
AICard {
|
||||
VStack(spacing: 10) {
|
||||
AISegmentedControl(
|
||||
"消息方向",
|
||||
selection: $model.direction,
|
||||
options: MessagesViewModel.DirectionFilter.allCases.map {
|
||||
(value: $0, title: $0.rawValue)
|
||||
}
|
||||
)
|
||||
|
||||
Picker("实例", selection: $model.selectedInstanceID) {
|
||||
Text("全部实例").tag(Optional<UUID>.none)
|
||||
ForEach(model.instances) { instance in
|
||||
Text(instance.name).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
|
||||
AISearchField("搜索号码或内容", text: $model.searchText)
|
||||
}
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("消息中心")
|
||||
.confirmationDialog(
|
||||
"删除这条消息?操作不可恢复。",
|
||||
isPresented: Binding(
|
||||
get: { pendingDeleteMessage != nil },
|
||||
set: { if !$0 { pendingDeleteMessage = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("删除消息", role: .destructive) {
|
||||
guard let message = pendingDeleteMessage else { return }
|
||||
Task { await model.delete(message) }
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
|
||||
private func messageCard(_ message: SMSMessage) -> some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
Spacer()
|
||||
AITag(message.direction.label, tint: message.direction == .outgoing ? AITheme.primary : AITheme.success)
|
||||
}
|
||||
Text(message.content)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text(message.instanceName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
pendingDeleteMessage = message
|
||||
} label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .danger,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.listActionButtonWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
.sheet(isPresented: $model.showingSendForm) {
|
||||
SendMessageFormView(model: model)
|
||||
}
|
||||
}
|
||||
|
||||
private var filterBar: some View {
|
||||
AICard {
|
||||
VStack(spacing: 10) {
|
||||
AISegmentedControl(
|
||||
"消息方向",
|
||||
selection: $model.direction,
|
||||
options: MessagesViewModel.DirectionFilter.allCases.map {
|
||||
(value: $0, title: $0.rawValue)
|
||||
}
|
||||
)
|
||||
|
||||
Picker("实例", selection: $model.selectedInstanceID) {
|
||||
Text("全部实例").tag(Optional<UUID>.none)
|
||||
ForEach(model.instances) { instance in
|
||||
Text(instance.name).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
|
||||
AISearchField("搜索号码或内容", text: $model.searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func messageCard(_ message: SMSMessage) -> some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
Spacer()
|
||||
AITag(
|
||||
message.direction.label,
|
||||
tint: message.direction == .outgoing ? AITheme.primary : AITheme.success)
|
||||
}
|
||||
Text(message.content)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text(message.instanceName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
pendingDeleteMessage = message
|
||||
} label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .danger,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.listActionButtonWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,124 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SendMessageFormView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: MessagesViewModel
|
||||
@State private var phoneNumber = ""
|
||||
@State private var content = ""
|
||||
@State private var validationMessage: String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: MessagesViewModel
|
||||
@State private var phoneNumber = ""
|
||||
@State private var content = ""
|
||||
@State private var validationMessage: String?
|
||||
@State private var isSending = false
|
||||
|
||||
private var trimmedPhoneNumber: String {
|
||||
phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
private var trimmedPhoneNumber: String {
|
||||
phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var trimmedContent: String {
|
||||
content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
private var trimmedContent: String {
|
||||
content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var canSend: Bool {
|
||||
!trimmedPhoneNumber.isEmpty && !trimmedContent.isEmpty
|
||||
}
|
||||
private var canSend: Bool {
|
||||
!model.instances.isEmpty && !trimmedPhoneNumber.isEmpty && !trimmedContent.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("实例")
|
||||
Picker("实例", selection: Binding(
|
||||
get: { model.selectedInstanceID ?? model.instances.first?.id },
|
||||
set: { model.selectedInstanceID = $0 }
|
||||
)) {
|
||||
ForEach(model.instances) { instance in
|
||||
Text(instance.name).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("收件号码")
|
||||
TextField("收件号码", text: $phoneNumber)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
#if os(iOS)
|
||||
.keyboardType(.phonePad)
|
||||
#endif
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("消息内容")
|
||||
TextField("消息内容", text: $content, axis: .vertical)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.lineLimit(4...8)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let validationMessage {
|
||||
AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("实例")
|
||||
Picker(
|
||||
"实例",
|
||||
selection: Binding(
|
||||
get: { model.selectedInstanceID ?? model.instances.first?.id },
|
||||
set: { model.selectedInstanceID = $0 }
|
||||
)
|
||||
) {
|
||||
if model.instances.isEmpty {
|
||||
Text("暂无实例").tag(Optional<UUID>.none)
|
||||
}
|
||||
ForEach(model.instances) { instance in
|
||||
Text(instance.name).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("发送消息")
|
||||
.aiBottomActionBar {
|
||||
HStack(spacing: 10) {
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
.frame(maxWidth: .infinity)
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
}
|
||||
|
||||
Button("发送") { send() }
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(!canSend)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("收件号码")
|
||||
TextField("收件号码", text: $phoneNumber)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.accessibilityLabel("收件号码")
|
||||
.submitLabel(.next)
|
||||
#if os(iOS)
|
||||
.keyboardType(.phonePad)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func send() {
|
||||
Task {
|
||||
do {
|
||||
try await model.send(phoneNumber: trimmedPhoneNumber, content: trimmedContent)
|
||||
phoneNumber = ""
|
||||
content = ""
|
||||
validationMessage = nil
|
||||
dismiss()
|
||||
} catch {
|
||||
validationMessage = "发送失败,请检查号码、内容和实例登录状态。"
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("消息内容")
|
||||
TextField("消息内容", text: $content, axis: .vertical)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.accessibilityLabel("消息内容")
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
if canSend { send() }
|
||||
}
|
||||
.lineLimit(4...8)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let validationMessage {
|
||||
AIStatusBanner(
|
||||
validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.aiBottomActionBar {
|
||||
AIActionRow {
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
|
||||
Button {
|
||||
send()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isSending {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(isSending ? "发送中" : "发送")
|
||||
}
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(!canSend || isSending)
|
||||
}
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("发送消息")
|
||||
#if os(iOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
private func send() {
|
||||
guard !isSending else { return }
|
||||
isSending = true
|
||||
Task {
|
||||
do {
|
||||
try await model.send(phoneNumber: trimmedPhoneNumber, content: trimmedContent)
|
||||
phoneNumber = ""
|
||||
content = ""
|
||||
validationMessage = nil
|
||||
dismiss()
|
||||
} catch {
|
||||
validationMessage = "发送失败,请检查号码、内容和实例登录状态。"
|
||||
}
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ struct NotificationsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let actionMessage = model.actionMessage {
|
||||
AIStatusBanner(
|
||||
@@ -44,18 +42,29 @@ struct NotificationsView: View {
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.navigationTitle("通知中心")
|
||||
.aiBottomActionBar {
|
||||
Button {
|
||||
Task { await model.retryAll() }
|
||||
} label: {
|
||||
Label("立即投递", systemImage: "paperplane")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(model.isLoading)
|
||||
Button {
|
||||
Task { await model.retryAll() }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if model.isRetryingAll {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(
|
||||
model.isRetryingAll
|
||||
? "投递中"
|
||||
: (model.overview?.queue.total == 0 ? "暂无待投递" : "立即投递")
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(model.isLoading || model.isRetryingAll || model.overview?.queue.total == 0)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("通知中心")
|
||||
.confirmationDialog(
|
||||
"删除这条通知队列记录?操作不可恢复。",
|
||||
isPresented: Binding(
|
||||
@@ -176,13 +185,12 @@ struct NotificationsView: View {
|
||||
}
|
||||
} else {
|
||||
ForEach(overview.queue.items) { item in
|
||||
AICard {
|
||||
AIListCard(
|
||||
title: eventTypeLabel(item.eventType),
|
||||
subtitle: "\(item.instanceName) · \(timestampText(item.createdAt))",
|
||||
status: statusLabel(item.status),
|
||||
statusTint: statusTint(item.status)
|
||||
) {
|
||||
AIListCard(
|
||||
title: eventTypeLabel(item.eventType),
|
||||
subtitle: "\(item.instanceName) · \(timestampText(item.createdAt))",
|
||||
status: statusLabel(item.status),
|
||||
statusTint: statusTint(item.status)
|
||||
) {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await model.retryQueue(item) }
|
||||
@@ -190,27 +198,17 @@ struct NotificationsView: View {
|
||||
Label("重试", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .secondary,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.listActionButtonWidth
|
||||
)
|
||||
AIButtonStyle(kind: .secondary, size: .compact, fillsWidth: true)
|
||||
)
|
||||
.disabled(model.busyQueueID == item.id)
|
||||
.disabled(model.busyQueueID != nil)
|
||||
|
||||
Button {
|
||||
pendingDeleteQueueID = item.id
|
||||
} label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .danger,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.listActionButtonWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact, fillsWidth: true))
|
||||
.disabled(model.busyQueueID != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+67
-63
@@ -2,79 +2,83 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
final class NotificationsViewModel: ObservableObject {
|
||||
@Published private(set) var overview: NotificationOverview?
|
||||
@Published private(set) var instances: [Instance] = []
|
||||
@Published private(set) var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published private(set) var actionMessage: String?
|
||||
@Published private(set) var actionSucceeded = true
|
||||
@Published private(set) var busyQueueID: String?
|
||||
@Published private(set) var overview: NotificationOverview?
|
||||
@Published private(set) var instances: [Instance] = []
|
||||
@Published private(set) var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published private(set) var actionMessage: String?
|
||||
@Published private(set) var actionSucceeded = true
|
||||
@Published private(set) var busyQueueID: String?
|
||||
@Published private(set) var isRetryingAll = false
|
||||
|
||||
private let repository: any InstanceRepository
|
||||
private let service: NotificationService
|
||||
private let repository: any InstanceRepository
|
||||
private let service: NotificationService
|
||||
|
||||
init(repository: any InstanceRepository, service: NotificationService) {
|
||||
self.repository = repository
|
||||
self.service = service
|
||||
init(repository: any InstanceRepository, service: NotificationService) {
|
||||
self.repository = repository
|
||||
self.service = service
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let loaded = try await repository.list(InstanceQuery())
|
||||
instances = loaded
|
||||
overview = try await service.overview(loaded)
|
||||
} catch {
|
||||
errorMessage = "无法加载通知中心。"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let loaded = try await repository.list(InstanceQuery())
|
||||
instances = loaded
|
||||
overview = try await service.overview(loaded)
|
||||
} catch {
|
||||
errorMessage = "无法加载通知中心。"
|
||||
}
|
||||
isLoading = false
|
||||
func retryAll() async {
|
||||
guard !isRetryingAll && !isLoading else { return }
|
||||
isRetryingAll = true
|
||||
let result = await service.retryAll(instances)
|
||||
actionSucceeded = result.succeeded > 0
|
||||
if result.succeeded > 0 {
|
||||
actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。"
|
||||
} else if result.skipped > 0 {
|
||||
actionMessage = "没有可重试的队列接口。"
|
||||
} else {
|
||||
actionMessage = "队列重试失败。"
|
||||
}
|
||||
await refresh()
|
||||
isRetryingAll = false
|
||||
}
|
||||
|
||||
func retryAll() async {
|
||||
let result = await service.retryAll(instances)
|
||||
actionSucceeded = result.succeeded > 0
|
||||
if result.succeeded > 0 {
|
||||
actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。"
|
||||
} else if result.skipped > 0 {
|
||||
actionMessage = "没有可重试的队列接口。"
|
||||
} else {
|
||||
actionMessage = "队列重试失败。"
|
||||
}
|
||||
await refresh()
|
||||
func retryQueue(_ item: NotificationQueueEntry) async {
|
||||
await queueAction(item) { instance, id in
|
||||
try await self.service.retryQueue(instance, id: id)
|
||||
}
|
||||
}
|
||||
|
||||
func retryQueue(_ item: NotificationQueueEntry) async {
|
||||
await queueAction(item) { instance, id in
|
||||
try await self.service.retryQueue(instance, id: id)
|
||||
}
|
||||
func deleteQueue(_ item: NotificationQueueEntry) async {
|
||||
await queueAction(item) { instance, id in
|
||||
try await self.service.deleteQueueItem(instance, id: id)
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func deleteQueue(_ item: NotificationQueueEntry) async {
|
||||
await queueAction(item) { instance, id in
|
||||
try await self.service.deleteQueueItem(instance, id: id)
|
||||
}
|
||||
await refresh()
|
||||
private func queueAction(
|
||||
_ item: NotificationQueueEntry,
|
||||
action: (Instance, String) async throws -> Void
|
||||
) async {
|
||||
guard let instance = instances.first(where: { $0.id == item.instanceID }) else {
|
||||
errorMessage = "找不到队列所属实例。"
|
||||
return
|
||||
}
|
||||
|
||||
private func queueAction(
|
||||
_ item: NotificationQueueEntry,
|
||||
action: (Instance, String) async throws -> Void
|
||||
) async {
|
||||
guard let instance = instances.first(where: { $0.id == item.instanceID }) else {
|
||||
errorMessage = "找不到队列所属实例。"
|
||||
return
|
||||
}
|
||||
busyQueueID = item.id
|
||||
do {
|
||||
try await action(instance, item.id)
|
||||
actionMessage = "队列操作已完成。"
|
||||
actionSucceeded = true
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "队列操作失败。"
|
||||
actionSucceeded = false
|
||||
}
|
||||
busyQueueID = nil
|
||||
busyQueueID = item.id
|
||||
do {
|
||||
try await action(instance, item.id)
|
||||
actionMessage = "队列操作已完成。"
|
||||
actionSucceeded = true
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "队列操作失败。"
|
||||
actionSucceeded = false
|
||||
}
|
||||
busyQueueID = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,24 +9,23 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if let notice = model.notice {
|
||||
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
|
||||
}
|
||||
|
||||
runtimeCard
|
||||
storageCard
|
||||
securityCard
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
.padding(16)
|
||||
if let notice = model.notice {
|
||||
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
|
||||
}
|
||||
|
||||
runtimeCard
|
||||
storageCard
|
||||
securityCard
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("设置")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
@@ -81,7 +80,7 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
Label("清除运行日志", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .danger, size: .large, fillsWidth: true))
|
||||
.buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user