feat(ios): refine adaptive theme and unified controls

This commit is contained in:
chick
2026-09-10 03:19:03 +08:00
parent c841748971
commit 31b2f33d15
12 changed files with 1161 additions and 984 deletions
@@ -38,29 +38,21 @@ struct InstanceDetailView: View {
private var header: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("设备详情", systemImage: "iphone.gen3")
Button {
AISectionTitle(
"设备详情",
systemImage: "iphone.gen3",
actionTitle: revealsSensitive ? "隐藏敏感值" : "显示敏感值",
actionSystemImage: revealsSensitive ? "eye.slash" : "eye"
) {
revealsSensitive.toggle()
} label: {
Label(
revealsSensitive ? "隐藏敏感值" : "显示敏感值",
systemImage: revealsSensitive ? "eye.slash" : "eye"
)
}
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
Text(model.instance.origin.absoluteString)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.lineLimit(2)
.truncationMode(.middle)
if model.isLoading {
Text("正在读取设备能力…")
Text("正在读取设备详情…")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
@@ -14,14 +14,18 @@ struct FleetView: View {
ScrollView {
LazyVStack(spacing: 12) {
searchField
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage,
tint: AITheme.danger,
systemImage: "exclamationmark.triangle"
)
}
if model.selectionMode {
selectionBar
}
if model.isLoading && model.rows.isEmpty {
AILoadingCard("正在同步实例")
AILoadingCard("正在同步实例数据")
}
if model.rows.isEmpty && !model.isLoading {
AICard {
@@ -58,6 +62,7 @@ struct FleetView: View {
Label("批量选择", systemImage: "checklist")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
Button {
model.showingForm = true
@@ -65,6 +70,7 @@ struct FleetView: View {
Label("添加实例", systemImage: "plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
}
}
}
@@ -163,6 +169,9 @@ struct FleetView: View {
Text(row.instance.name)
.font(.headline)
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
AITag(row.status.label, tint: statusColor(row.status))
}
@@ -39,17 +39,13 @@ struct InstanceFormView: View {
.aiBottomActionBar {
HStack(spacing: 10) {
Button("取消") { dismiss() }
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .regular,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
Button("保存实例") { save() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSave)
.frame(maxWidth: .infinity)
}
}
}
@@ -62,9 +58,7 @@ struct InstanceFormView: View {
secure: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
AIFieldLabel(title)
if secure {
SecureField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
@@ -1,286 +1,278 @@
import SwiftUI
struct LogsView: View {
private enum Tab: Hashable {
case runtime
case connections
case diagnostics
}
private enum Tab: Hashable {
case runtime
case connections
case diagnostics
}
@StateObject private var model: LogsViewModel
@State private var tab: Tab = .runtime
@State private var showsPruneDialog = false
@StateObject private var model: LogsViewModel
@State private var tab: Tab = .runtime
@State private var showsPruneDialog = false
init(model: LogsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel())
}
init(model: LogsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel())
}
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
Picker("视图", selection: $tab) {
Text("运行日志").tag(Tab.runtime)
Text("连接日志").tag(Tab.connections)
Text("设备诊断").tag(Tab.diagnostics)
}
.pickerStyle(.segmented)
filterBar
switch tab {
case .runtime:
runtimeSection
case .connections:
connectionSection
case .diagnostics:
diagnosticSection
}
}
}
.padding(16)
}
}
.navigationTitle("日志中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
AIToolbarButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
}
}
.confirmationDialog(
"清理 7 天前的连接日志?操作不可恢复。",
isPresented: $showsPruneDialog,
titleVisibility: .visible
) {
Button("清理连接日志", role: .destructive) {
Task {
_ = await model.pruneConnections(olderThanDays: 7)
}
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
}
private var stats: some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary)
AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success)
AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger)
}
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
Group {
if tab == .runtime {
HStack(spacing: 10) {
Picker("来源", selection: $model.selectedSource) {
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
Text(source.label).tag(Optional(source))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
Picker("级别", selection: $model.selectedLevel) {
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
Text(level.label).tag(Optional(level))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
.frame(maxWidth: .infinity)
} else if tab == .connections {
Picker("结果", selection: $model.selectedOutcome) {
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
Text(outcome.label).tag(Optional(outcome))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
}
.transition(.opacity)
}
.animation(.easeInOut(duration: 0.18), value: tab)
}
}
private var runtimeSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
if model.filteredRuntime.isEmpty {
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会汇总实例日志。")
} else {
ForEach(model.filteredRuntime) { entry in
AIListCard(
title: entry.message,
subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())",
status: entry.level.label,
statusTint: levelTint(entry.level),
details: {
var values = [entry.source.label]
if let duration = entry.durationMs {
values.append("耗时 \(duration) ms")
}
return values
}()
)
}
}
}
}
}
private var connectionSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) {
AISectionTitle(
"连接探测记录",
systemImage: "dot.radiowaves.left.and.right",
detail: "\(model.filteredConnections.count) 条"
)
compactActionButton("清理", systemImage: "trash") {
showsPruneDialog = true
}
}
if model.filteredConnections.isEmpty {
AIEmptyState(systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。")
} else {
ForEach(model.filteredConnections) { entry in
AIListCard(
title: entry.instanceName,
subtitle: "\(entry.observedAt.formatted()) · \(entry.state)",
status: entry.outcome.label,
statusTint: outcomeTint(entry.outcome),
details: [
"HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms",
entry.errorCode ?? "无错误码"
]
)
}
}
}
}
}
private var diagnosticSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台")
if model.filteredDiagnostics.isEmpty {
AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。")
} else {
ForEach(model.filteredDiagnostics) { diagnostic in
AIListCard(
title: diagnostic.name,
status: diagnostic.healthStatus ?? "未知",
statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning,
details: [
diagnostic.origin,
"版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")"
]
) {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 82), spacing: 6)],
alignment: .leading,
spacing: 6
) {
ForEach(diagnostic.featureStatus) { feature in
AITag(feature.name, tint: featureTint(feature.state))
}
if let connection = diagnostic.connection, connection.total > 1 {
AITag(
"延迟 \(connection.averageDurationMs) ms",
tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning
)
}
}
}
}
}
}
}
}
private var onlineCount: Int {
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
}
private func compactActionButton(
_ title: String,
systemImage: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: systemImage)
}
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
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: "设备诊断"),
]
)
)
}
private func levelTint(_ level: RuntimeLogLevel) -> Color {
switch level {
case .info: return AITheme.success
case .warning: return AITheme.warning
case .error: return AITheme.danger
}
}
filterBar
private func outcomeTint(_ outcome: ConnectionOutcome) -> Color {
switch outcome {
case .success: return AITheme.success
case .stale: return AITheme.warning
case .failed: return AITheme.danger
case .unsupported: return AITheme.muted
switch tab {
case .runtime:
runtimeSection
case .connections:
connectionSection
case .diagnostics:
diagnosticSection
}
}
}
.padding(16)
}
}
.navigationTitle("日志中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
AIToolbarButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
}
}
.confirmationDialog(
"清理 7 天前的连接日志?操作不可恢复。",
isPresented: $showsPruneDialog,
titleVisibility: .visible
) {
Button("清理连接日志", role: .destructive) {
Task {
_ = await model.pruneConnections(olderThanDays: 7)
}
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
}
private func featureTint(_ state: FeatureStatus.State) -> Color {
switch state {
case .ready: return AITheme.success
case .requiresLogin: return AITheme.warning
case .unavailable: return AITheme.muted
}
private var stats: some View {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12
) {
AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary)
AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success)
AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger)
}
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
Group {
if tab == .runtime {
HStack(spacing: 10) {
Picker("来源", selection: $model.selectedSource) {
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
Text(source.label).tag(Optional(source))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
Picker("级别", selection: $model.selectedLevel) {
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
Text(level.label).tag(Optional(level))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
.frame(maxWidth: .infinity)
} else if tab == .connections {
Picker("结果", selection: $model.selectedOutcome) {
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
Text(outcome.label).tag(Optional(outcome))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
}
.transition(.opacity)
}
.animation(.easeInOut(duration: 0.18), value: tab)
}
}
private var runtimeSection: some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
if model.filteredRuntime.isEmpty {
AICard {
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会同步实例运行记录。")
}
} else {
ForEach(model.filteredRuntime) { entry in
AIListCard(
title: entry.message,
subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())",
status: entry.level.label,
statusTint: levelTint(entry.level),
details: {
var values = [entry.source.label]
if let duration = entry.durationMs {
values.append("耗时 \(duration) ms")
}
return values
}()
)
}
}
}
}
private var connectionSection: some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(
"连接探测记录",
systemImage: "dot.radiowaves.left.and.right",
detail: "\(model.filteredConnections.count) 条",
actionTitle: "清理",
actionSystemImage: "trash",
actionKind: .danger
) {
showsPruneDialog = true
}
if model.filteredConnections.isEmpty {
AICard {
AIEmptyState(
systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。"
)
}
} else {
ForEach(model.filteredConnections) { entry in
AIListCard(
title: entry.instanceName,
subtitle: "\(entry.observedAt.formatted()) · \(entry.state)",
status: entry.outcome.label,
statusTint: outcomeTint(entry.outcome),
details: [
"HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms",
entry.errorCode ?? "无错误码",
]
)
}
}
}
}
private var diagnosticSection: some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(
"设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台")
if model.filteredDiagnostics.isEmpty {
AICard {
AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。")
}
} else {
ForEach(model.filteredDiagnostics) { diagnostic in
AIListCard(
title: diagnostic.name,
status: diagnostic.healthStatus ?? "未知",
statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning,
details: [
diagnostic.origin,
"版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")",
]
) {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 82), spacing: 6)],
alignment: .leading,
spacing: 6
) {
ForEach(diagnostic.featureStatus) { feature in
AITag(feature.name, tint: featureTint(feature.state))
}
if let connection = diagnostic.connection, connection.total > 1 {
AITag(
"延迟 \(connection.averageDurationMs) ms",
tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning
)
}
}
}
}
}
}
}
private var onlineCount: Int {
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
}
private func levelTint(_ level: RuntimeLogLevel) -> Color {
switch level {
case .info: return AITheme.success
case .warning: return AITheme.warning
case .error: return AITheme.danger
}
}
private func outcomeTint(_ outcome: ConnectionOutcome) -> Color {
switch outcome {
case .success: return AITheme.success
case .stale: return AITheme.warning
case .failed: return AITheme.danger
case .unsupported: return AITheme.muted
}
}
private func featureTint(_ state: FeatureStatus.State) -> Color {
switch state {
case .ready: return AITheme.success
case .requiresLogin: return AITheme.warning
case .unavailable: return AITheme.muted
}
}
}
@@ -18,7 +18,7 @@ struct MessagesView: View {
AIStatusBanner(errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息")
AILoadingCard("正在同步消息数据")
}
if model.filteredRows.isEmpty && !model.isLoading {
AICard {
@@ -73,12 +73,13 @@ struct MessagesView: View {
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
Picker("方向", selection: $model.direction) {
ForEach(MessagesViewModel.DirectionFilter.allCases) { item in
Text(item.rawValue).tag(item)
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
}
.pickerStyle(.segmented)
)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
@@ -100,6 +101,9 @@ struct MessagesView: View {
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)
}
@@ -107,16 +111,18 @@ struct MessagesView: View {
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.fixedSize(horizontal: false, vertical: true)
HStack {
HStack(alignment: .center, spacing: 10) {
Text(message.instanceName)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
Button("删除") {
Spacer(minLength: 8)
Button {
pendingDeleteMessage = message
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
@@ -28,9 +28,7 @@ struct SendMessageFormView: View {
AICard {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("实例")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
AIFieldLabel("实例")
Picker("实例", selection: Binding(
get: { model.selectedInstanceID ?? model.instances.first?.id },
set: { model.selectedInstanceID = $0 }
@@ -43,23 +41,21 @@ struct SendMessageFormView: View {
.aiSelectSurface()
}
TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.keyboardType(.phonePad)
#endif
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("收件号码")
TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.keyboardType(.phonePad)
#endif
}
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
.lineLimit(4...8)
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("消息内容")
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(AIFieldStyle())
.lineLimit(4...8)
}
}
}
if let validationMessage {
@@ -73,17 +69,13 @@ struct SendMessageFormView: View {
.aiBottomActionBar {
HStack(spacing: 10) {
Button("取消") { dismiss() }
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .regular,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
Button("发送") { send() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSend)
.frame(maxWidth: .infinity)
}
}
}
@@ -1,314 +1,327 @@
import SwiftUI
struct NotificationsView: View {
@StateObject private var model: NotificationsViewModel
@State private var pendingDeleteQueueID: String?
private static let timestampFormatter = ISO8601DateFormatter()
private static let fractionalTimestampFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
@StateObject private var model: NotificationsViewModel
@State private var pendingDeleteQueueID: String?
private static let timestampFormatter = ISO8601DateFormatter()
private static let fractionalTimestampFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
init(model: NotificationsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel())
}
init(model: NotificationsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel())
}
var body: some View {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
if let actionMessage = model.actionMessage {
AIStatusBanner(
actionMessage,
tint: model.actionSucceeded ? AITheme.success : AITheme.danger,
systemImage: model.actionSucceeded ? "checkmark.circle" : "exclamationmark.triangle"
)
}
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let overview = model.overview {
stats(overview)
queue(overview)
channels(overview)
logs(overview)
devices(overview)
} else if model.isLoading {
AILoadingCard("正在加载通知中心")
} else {
empty
}
}
.padding(16)
}
}
.navigationTitle("通知中心")
.aiBottomActionBar {
Button {
Task { await model.retryAll() }
} label: {
Label("立即投递", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(model.isLoading)
}
.confirmationDialog(
"删除这条通知队列记录?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteQueueID != nil },
set: { if !$0 { pendingDeleteQueueID = nil } }
),
titleVisibility: .visible
) {
Button("删除队列记录", role: .destructive) {
guard let id = pendingDeleteQueueID else { return }
Task {
if let item = model.overview?.queue.items.first(where: { $0.id == id }) {
await model.deleteQueue(item)
}
}
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
}
private var empty: some View {
AICard {
AIEmptyState(
systemImage: "bell",
title: "暂无通知数据",
message: "下拉刷新后从已配置实例同步。"
var body: some View {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
if let actionMessage = model.actionMessage {
AIStatusBanner(
actionMessage,
tint: model.actionSucceeded ? AITheme.success : AITheme.danger,
systemImage: model.actionSucceeded ? "checkmark.circle" : "exclamationmark.triangle"
)
}
}
}
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
private func stats(_ overview: NotificationOverview) -> some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
AIStatCard(
title: "通道",
value: "\(overview.config.channelEnabled)/\(overview.config.channelCount)",
detail: "已启用 / 总数",
tint: AITheme.primary
)
AIStatCard(
title: "规则",
value: "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)",
detail: "已启用 / 总数",
tint: AITheme.primary
)
AIStatCard(
title: "队列",
value: "\(overview.queue.pending + overview.queue.retrying)",
detail: "等待与重试",
tint: AITheme.warning
)
AIStatCard(
title: "失败",
value: "\(overview.queue.failed + overview.logs.failed)",
detail: "队列与投递",
tint: AITheme.danger
)
if let overview = model.overview {
stats(overview)
queue(overview)
channels(overview)
logs(overview)
devices(overview)
} else if model.isLoading {
AILoadingCard("正在加载通知中心")
} else {
empty
}
}
.padding(16)
}
}
.navigationTitle("通知中心")
.aiBottomActionBar {
Button {
Task { await model.retryAll() }
} label: {
Label("立即投递", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(model.isLoading)
}
.confirmationDialog(
"删除这条通知队列记录?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteQueueID != nil },
set: { if !$0 { pendingDeleteQueueID = nil } }
),
titleVisibility: .visible
) {
Button("删除队列记录", role: .destructive) {
guard let id = pendingDeleteQueueID else { return }
Task {
if let item = model.overview?.queue.items.first(where: { $0.id == id }) {
await model.deleteQueue(item)
}
}
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
}
private func devices(_ overview: NotificationOverview) -> some View {
private var empty: some View {
AICard {
AIEmptyState(
systemImage: "bell",
title: "暂无通知数据",
message: "下拉刷新后从已配置实例同步。"
)
}
}
private func stats(_ overview: NotificationOverview) -> some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
AIStatCard(
title: "通道",
value: "\(overview.config.channelEnabled)/\(overview.config.channelCount)",
detail: "已启用 / 总数",
tint: AITheme.primary
)
AIStatCard(
title: "规则",
value: "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)",
detail: "已启用 / 总数",
tint: AITheme.primary
)
AIStatCard(
title: "队列",
value: "\(overview.queue.pending + overview.queue.retrying)",
detail: "等待与重试",
tint: AITheme.warning
)
AIStatCard(
title: "失败",
value: "\(overview.queue.failed + overview.logs.failed)",
detail: "队列与投递",
tint: AITheme.danger
)
}
}
private func devices(_ overview: NotificationOverview) -> some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个")
if overview.devices.isEmpty {
AICard {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个")
if overview.devices.isEmpty {
AIEmptyState(systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。")
} else {
ForEach(overview.devices) { device in
AIListCard(
title: device.name,
status: deviceState(device.state),
statusTint: deviceTint(device.state)
)
}
}
}
AIEmptyState(
systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。")
}
} else {
ForEach(overview.devices) { device in
AIListCard(
title: device.name,
status: deviceState(device.state),
statusTint: deviceTint(device.state)
)
}
}
}
}
private func channels(_ overview: NotificationOverview) -> some View {
private func channels(_ overview: NotificationOverview) -> some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(
"通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类"
)
if overview.config.channelTypes.isEmpty {
AICard {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类")
if overview.config.channelTypes.isEmpty {
AIEmptyState(systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。")
} else {
ForEach(overview.config.channelTypes) { channel in
AIListCard(
title: channelTypeLabel(channel.type),
status: "\(channel.enabled)/\(channel.total)",
statusTint: channel.enabled > 0 ? AITheme.success : AITheme.muted
)
}
}
}
AIEmptyState(
systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。")
}
} else {
ForEach(overview.config.channelTypes) { channel in
AIListCard(
title: channelTypeLabel(channel.type),
status: "\(channel.enabled)/\(channel.total)",
statusTint: channel.enabled > 0 ? AITheme.success : AITheme.muted
)
}
}
}
}
private func queue(_ overview: NotificationOverview) -> some View {
private func queue(_ overview: NotificationOverview) -> some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项")
if overview.queue.items.isEmpty {
AICard {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项")
if overview.queue.items.isEmpty {
Text("队列为空。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
} else {
ForEach(overview.queue.items) { item in
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) }
} label: {
Label("重试", systemImage: "arrow.clockwise")
}
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
.disabled(model.busyQueueID == item.id)
Button {
pendingDeleteQueueID = item.id
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
}
}
}
AIEmptyState(
systemImage: "tray.full",
title: "投递队列为空",
message: "新通知触发后会在这里等待投递。"
)
}
}
} 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)
) {
HStack(spacing: 8) {
Button {
Task { await model.retryQueue(item) }
} label: {
Label("重试", systemImage: "arrow.clockwise")
}
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
.disabled(model.busyQueueID == item.id)
private func logs(_ overview: NotificationOverview) -> some View {
Button {
pendingDeleteQueueID = item.id
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
}
}
}
}
}
private func logs(_ overview: NotificationOverview) -> some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(
"投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条")
if overview.logs.entries.isEmpty {
AICard {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条")
if overview.logs.entries.isEmpty {
Text("暂无投递日志。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
} else {
ForEach(overview.logs.entries) { entry in
AIListCard(
title: eventTypeLabel(entry.eventType),
subtitle: "\(entry.channelName ?? "未指定通道") · \(timestampText(entry.createdAt))",
status: statusLabel(entry.status),
statusTint: statusTint(entry.status)
)
}
}
}
AIEmptyState(
systemImage: "clock.arrow.circlepath",
title: "暂无投递日志",
message: "通知送达或失败后会在这里记录。"
)
}
}
private func timestampText(_ value: String) -> String {
let date = Self.fractionalTimestampFormatter.date(from: value)
?? Self.timestampFormatter.date(from: value)
guard let date else { return value }
return date.formatted(
Date.FormatStyle(date: .numeric, time: .shortened)
.locale(Locale(identifier: "zh_CN"))
)
}
private func deviceState(_ state: NotificationDeviceState) -> String {
switch state {
case .ready: return "在线"
case .unauthorized: return "需登录"
case .unavailable: return "离线"
case .failed: return "异常"
} else {
ForEach(overview.logs.entries) { entry in
AIListCard(
title: eventTypeLabel(entry.eventType),
subtitle: "\(entry.channelName ?? "未指定通道") · \(timestampText(entry.createdAt))",
status: statusLabel(entry.status),
statusTint: statusTint(entry.status)
)
}
}
}
}
private func deviceTint(_ state: NotificationDeviceState) -> Color {
switch state {
case .ready: return AITheme.success
case .unauthorized: return AITheme.warning
case .unavailable: return AITheme.muted
case .failed: return AITheme.danger
}
}
private func timestampText(_ value: String) -> String {
let date =
Self.fractionalTimestampFormatter.date(from: value)
?? Self.timestampFormatter.date(from: value)
guard let date else { return value }
return date.formatted(
Date.FormatStyle(date: .numeric, time: .shortened)
.locale(Locale(identifier: "zh_CN"))
)
}
private func channelTypeLabel(_ type: String) -> String {
switch type {
case "webhook": return "Webhook"
case "bark": return "Bark"
case "pushplus": return "PushPlus"
case "wecom_app": return "企业微信应用"
case "wecom_robot": return "企业微信机器人"
case "dingtalk_robot": return "钉钉机器人"
case "dingtalk_app": return "钉钉应用"
case "feishu_robot": return "飞书机器人"
case "telegram": return "Telegram"
case "email": return "邮件"
case "serverchan": return "Server 酱"
default: return type
}
private func deviceState(_ state: NotificationDeviceState) -> String {
switch state {
case .ready: return "在线"
case .unauthorized: return "需登录"
case .unavailable: return "离线"
case .failed: return "异常"
}
}
private func eventTypeLabel(_ value: String) -> String {
switch value {
case "sms": return "短信"
case "ddns": return "DDNS"
case "version": return "版本"
case "system": return "系统"
case "device": return "设备"
case "automation": return "自动化"
default: return value
}
private func deviceTint(_ state: NotificationDeviceState) -> Color {
switch state {
case .ready: return AITheme.success
case .unauthorized: return AITheme.warning
case .unavailable: return AITheme.muted
case .failed: return AITheme.danger
}
}
private func statusLabel(_ value: String) -> String {
switch value {
case "success", "succeeded": return "成功"
case "failed": return "失败"
case "pending": return "等待"
case "sending": return "发送中"
case "retrying": return "重试中"
case "cancelled": return "已取消"
case "unmatched": return "未匹配"
case "no_available_channel": return "无可用通道"
case "quiet_hours": return "免打扰"
default: return value
}
private func channelTypeLabel(_ type: String) -> String {
switch type {
case "webhook": return "Webhook"
case "bark": return "Bark"
case "pushplus": return "PushPlus"
case "wecom_app": return "企业微信应用"
case "wecom_robot": return "企业微信机器人"
case "dingtalk_robot": return "钉钉机器人"
case "dingtalk_app": return "钉钉应用"
case "feishu_robot": return "飞书机器人"
case "telegram": return "Telegram"
case "email": return "邮件"
case "serverchan": return "Server 酱"
default: return type
}
}
private func statusTint(_ value: String) -> Color {
switch value {
case "success", "succeeded": return AITheme.success
case "pending", "sending", "retrying": return AITheme.warning
case "failed": return AITheme.danger
default: return AITheme.muted
}
private func eventTypeLabel(_ value: String) -> String {
switch value {
case "sms": return "短信"
case "ddns": return "DDNS"
case "version": return "版本"
case "system": return "系统"
case "device": return "设备"
case "automation": return "自动化"
default: return value
}
}
private func statusLabel(_ value: String) -> String {
switch value {
case "success", "succeeded": return "成功"
case "failed": return "失败"
case "pending": return "等待"
case "sending": return "发送中"
case "retrying": return "重试中"
case "cancelled": return "已取消"
case "unmatched": return "未匹配"
case "no_available_channel": return "无可用通道"
case "quiet_hours": return "免打扰"
default: return value
}
}
private func statusTint(_ value: String) -> Color {
switch value {
case "success", "succeeded": return AITheme.success
case "pending", "sending", "retrying": return AITheme.warning
case "failed": return AITheme.danger
default: return AITheme.muted
}
}
}
@@ -92,6 +92,11 @@ struct SettingsView: View {
AISectionTitle("安全与登录", systemImage: "lock.shield")
AIKeyValueRow("实例密码", value: "保存在本机应用沙盒")
AIKeyValueRow("登录会话", value: "仅保存在内存")
AIStatusBanner(
"实例凭据只保存在本机应用沙盒,登录会话只保留在内存中,应用关闭后自动失效。",
tint: AITheme.primary,
systemImage: "shield.checkered"
)
}
}
}