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()) } var body: some View { 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) .padding(.bottom, 8) } .aiBottomActionBar { 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( 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: "下拉刷新后从已配置实例同步。" ) } } 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 { 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 { VStack(alignment: .leading, spacing: 10) { AISectionTitle( "通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类" ) if overview.config.channelTypes.isEmpty { AICard { 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 { VStack(alignment: .leading, spacing: 10) { AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项") if overview.queue.items.isEmpty { AICard { AIEmptyState( systemImage: "tray.full", title: "投递队列为空", message: "新通知触发后会在这里等待投递。" ) } } 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, fillsWidth: true) ) .disabled(model.busyQueueID != nil) Button { pendingDeleteQueueID = item.id } label: { Label("删除", systemImage: "trash") } .buttonStyle(AIButtonStyle(kind: .danger, size: .compact, fillsWidth: true)) .disabled(model.busyQueueID != nil) } } } } } } 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 { AIEmptyState( systemImage: "clock.arrow.circlepath", title: "暂无投递日志", message: "通知送达或失败后会在这里记录。" ) } } 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 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 "异常" } } 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 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 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 } } }