317 lines
12 KiB
Swift
317 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
struct NotificationsView: View {
|
|
@StateObject private var model: NotificationsViewModel
|
|
|
|
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 {
|
|
AICard {
|
|
Text(actionMessage)
|
|
.font(.subheadline)
|
|
.foregroundStyle(AITheme.success)
|
|
}
|
|
}
|
|
if let errorMessage = model.errorMessage {
|
|
AICard {
|
|
Text(errorMessage)
|
|
.font(.subheadline)
|
|
.foregroundStyle(AITheme.warning)
|
|
}
|
|
}
|
|
|
|
if let overview = model.overview {
|
|
stats(overview)
|
|
devices(overview)
|
|
channels(overview)
|
|
queue(overview)
|
|
logs(overview)
|
|
} else if model.isLoading {
|
|
AICard {
|
|
ProgressView("正在加载通知中心...")
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 22)
|
|
}
|
|
} else {
|
|
empty
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
.navigationTitle("通知中心")
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .primaryAction) {
|
|
Button {
|
|
Task { await model.retryAll() }
|
|
} label: {
|
|
Label("立即投递", systemImage: "paperplane")
|
|
}
|
|
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
|
|
.disabled(model.isLoading)
|
|
}
|
|
}
|
|
.refreshable {
|
|
await model.refresh()
|
|
}
|
|
.task {
|
|
await model.refresh()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
private func devices(_ overview: NotificationOverview) -> some View {
|
|
AICard {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
title("实例状态")
|
|
if overview.devices.isEmpty {
|
|
Text("尚未添加实例。")
|
|
.font(.subheadline)
|
|
.foregroundStyle(AITheme.muted)
|
|
} else {
|
|
ForEach(overview.devices) { device in
|
|
HStack(spacing: 8) {
|
|
Text(device.name)
|
|
.font(.subheadline)
|
|
Spacer()
|
|
AITag(deviceState(device.state), tint: deviceTint(device.state))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func channels(_ overview: NotificationOverview) -> some View {
|
|
AICard {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
title("通道类型")
|
|
if overview.config.channelTypes.isEmpty {
|
|
Text("实例未配置通知通道。")
|
|
.font(.subheadline)
|
|
.foregroundStyle(AITheme.muted)
|
|
} 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
if overview.queue.items.isEmpty {
|
|
Text("队列为空。")
|
|
.font(.subheadline)
|
|
.foregroundStyle(AITheme.muted)
|
|
} 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)
|
|
HStack(spacing: 8) {
|
|
Button {
|
|
Task { await model.retryQueue(item) }
|
|
} label: {
|
|
Label("重试", systemImage: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
|
|
.disabled(model.busyQueueID == item.id)
|
|
|
|
Button {
|
|
Task { await model.deleteQueue(item) }
|
|
} label: {
|
|
Label("删除", systemImage: "trash")
|
|
}
|
|
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact))
|
|
}
|
|
}
|
|
.padding(.bottom, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
.padding(.bottom, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func title(_ text: String) -> some View {
|
|
Text(text)
|
|
.font(.headline)
|
|
.foregroundStyle(AITheme.ink)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|