feat(ios): unify list cards and loading states

This commit is contained in:
chick
2026-09-10 01:35:12 +08:00
parent 995206ac23
commit c841748971
32 changed files with 1297 additions and 607 deletions
@@ -3,6 +3,12 @@ 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
}()
init(model: NotificationsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel())
@@ -14,32 +20,24 @@ struct NotificationsView: View {
ScrollView {
LazyVStack(spacing: 12) {
if let actionMessage = model.actionMessage {
AICard {
Text(actionMessage)
.font(.subheadline)
.foregroundStyle(AITheme.success)
}
AIStatusBanner(
actionMessage,
tint: model.actionSucceeded ? AITheme.success : AITheme.danger,
systemImage: model.actionSucceeded ? "checkmark.circle" : "exclamationmark.triangle"
)
}
if let errorMessage = model.errorMessage {
AICard {
Text(errorMessage)
.font(.subheadline)
.foregroundStyle(AITheme.warning)
}
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let overview = model.overview {
stats(overview)
devices(overview)
channels(overview)
queue(overview)
channels(overview)
logs(overview)
devices(overview)
} else if model.isLoading {
AICard {
ProgressView("正在加载通知中心...")
.frame(maxWidth: .infinity)
.padding(.vertical, 22)
}
AILoadingCard("正在加载通知中心")
} else {
empty
}
@@ -48,16 +46,14 @@ struct NotificationsView: View {
}
}
.navigationTitle("通知中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
Task { await model.retryAll() }
} label: {
Label("立即投递", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
.disabled(model.isLoading)
.aiBottomActionBar {
Button {
Task { await model.retryAll() }
} label: {
Label("立即投递", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(model.isLoading)
}
.confirmationDialog(
"删除这条通知队列记录?操作不可恢复。",
@@ -87,64 +83,57 @@ struct NotificationsView: View {
private var empty: some View {
AICard {
VStack(spacing: 10) {
Image(systemName: "bell")
.font(.system(size: 28))
.foregroundStyle(AITheme.muted)
Text("暂无通知数据")
.font(.headline)
Text("下拉刷新后从已配置实例同步。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
AIEmptyState(
systemImage: "bell",
title: "暂无通知数据",
message: "下拉刷新后从已配置实例同步。"
)
}
}
private func stats(_ overview: NotificationOverview) -> some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
statCard("通道", "\(overview.config.channelEnabled)/\(overview.config.channelCount)", "已启用/总数", AITheme.primary)
statCard("规则", "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)", "已启用/总数", AITheme.primary)
statCard("队列", "\(overview.queue.pending + overview.queue.retrying)", "等待与重试", AITheme.warning)
statCard("失败", "\(overview.queue.failed + overview.logs.failed)", "队列与投递", AITheme.danger)
}
}
private func statCard(_ title: String, _ value: String, _ detail: String, _ tint: Color) -> some View {
AICard {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.caption)
.foregroundStyle(AITheme.muted)
Text(value)
.font(.title2.weight(.bold))
.foregroundStyle(tint)
Text(detail)
.font(.caption)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity, alignment: .leading)
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 {
AICard {
VStack(alignment: .leading, spacing: 10) {
title("实例状态")
AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个")
if overview.devices.isEmpty {
Text("尚未添加实例。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
AIEmptyState(systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。")
} else {
ForEach(overview.devices) { device in
HStack(spacing: 8) {
Text(device.name)
.font(.subheadline)
Spacer()
AITag(deviceState(device.state), tint: deviceTint(device.state))
ForEach(overview.devices) { device in
AIListCard(
title: device.name,
status: deviceState(device.state),
statusTint: deviceTint(device.state)
)
}
}
}
}
}
@@ -153,20 +142,17 @@ struct NotificationsView: View {
private func channels(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
title("通道类型")
AISectionTitle("通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类")
if overview.config.channelTypes.isEmpty {
Text("实例未配置通知通道。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
AIEmptyState(systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。")
} else {
ForEach(overview.config.channelTypes) { channel in
HStack(spacing: 8) {
Text(channelTypeLabel(channel.type))
.font(.subheadline)
Spacer()
AITag("\(channel.enabled)/\(channel.total)", tint: channel.enabled > 0 ? AITheme.success : AITheme.muted)
ForEach(overview.config.channelTypes) { channel in
AIListCard(
title: channelTypeLabel(channel.type),
status: "\(channel.enabled)/\(channel.total)",
statusTint: channel.enabled > 0 ? AITheme.success : AITheme.muted
)
}
}
}
}
}
@@ -175,36 +161,32 @@ struct NotificationsView: View {
private func queue(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack {
title("最近队列")
Spacer()
Text("\(overview.queue.total) 项")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项")
if overview.queue.items.isEmpty {
Text("队列为空。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
} else {
} else {
ForEach(overview.queue.items) { item in
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(eventTypeLabel(item.eventType))
.font(.subheadline.weight(.semibold))
Spacer()
AITag(statusLabel(item.status), tint: statusTint(item.status))
}
Text("\(item.instanceName) · \(item.createdAt)")
.font(.caption)
.foregroundStyle(AITheme.muted)
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))
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
.disabled(model.busyQueueID == item.id)
Button {
@@ -212,10 +194,15 @@ struct NotificationsView: View {
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact))
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
.padding(.bottom, 4)
}
}
}
@@ -225,41 +212,33 @@ struct NotificationsView: View {
private func logs(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack {
title("投递日志")
Spacer()
Text("\(overview.logs.total) 条")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
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
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(eventTypeLabel(entry.eventType))
.font(.subheadline.weight(.semibold))
Spacer()
AITag(statusLabel(entry.status), tint: statusTint(entry.status))
}
Text("\(entry.channelName ?? "未指定通道") · \(entry.createdAt)")
.font(.caption)
.foregroundStyle(AITheme.muted)
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)
)
}
.padding(.bottom, 4)
}
}
}
}
}
private func title(_ text: String) -> some View {
Text(text)
.font(.headline)
.foregroundStyle(AITheme.ink)
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 {
@@ -7,6 +7,7 @@ final class NotificationsViewModel: ObservableObject {
@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?
private let repository: any InstanceRepository
@@ -32,6 +33,7 @@ final class NotificationsViewModel: ObservableObject {
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 {
@@ -67,9 +69,11 @@ final class NotificationsViewModel: ObservableObject {
do {
try await action(instance, item.id)
actionMessage = "队列操作已完成。"
actionSucceeded = true
errorMessage = nil
} catch {
errorMessage = "队列操作失败。"
actionSucceeded = false
}
busyQueueID = nil
}