Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift
T
chick 20632be86a feat(ios): unify action hierarchy and surfaces
Refine primary and toolbar actions, align row buttons, normalize status surfaces, and split storage from destructive cleanup. Verified with Swift tests and the simulator build.
2026-09-12 16:15:57 +08:00

336 lines
11 KiB
Swift

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,
fixedWidth: AITheme.listActionButtonWidth
)
)
.disabled(model.busyQueueID != nil)
Button {
pendingDeleteQueueID = item.id
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
.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
}
}
}