feat(ios): refine adaptive theme and unified controls
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user