// // MessageCenterViews.swift // suixinkan // // Created by Codex on 2026/6/25. // import SwiftUI /// 消息中心页面,展示消息列表、筛选、已读和删除入口。 struct MessageCenterView: View { @Environment(\.messageCenterAPI) private var messageAPI @EnvironmentObject private var toastCenter: ToastCenter @StateObject private var viewModel = MessageCenterViewModel() @State private var selectedMessage: MessageItem? @State private var processingMessageId: String? var body: some View { VStack(spacing: 0) { header content } .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) .navigationTitle("消息中心") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("全部已读") { Task { await markAllAsRead() } } .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .disabled(viewModel.unreadCount == 0 || viewModel.messages.isEmpty) } } .sheet(item: $selectedMessage) { item in MessageDetailView(item: item) { deleteItem in await deleteMessage(deleteItem) } } .task { if viewModel.messages.isEmpty { await viewModel.reloadFirstPage(api: messageAPI) } } .refreshable { await viewModel.reloadFirstPage(api: messageAPI) } } private var header: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { HStack(spacing: AppMetrics.Spacing.medium) { messageMetric(title: "消息总数", value: "\(viewModel.messages.count)", color: AppDesign.primary) messageMetric(title: "未读消息", value: "\(viewModel.unreadCount)", color: AppDesign.warning) } Picker("消息筛选", selection: Binding( get: { viewModel.selectedFilter }, set: { filter in Task { await viewModel.selectFilter(filter, api: messageAPI) } } )) { ForEach(MessageFilter.allCases) { filter in Text(filter.title).tag(filter) } } .pickerStyle(.segmented) Text(viewModel.filterDescription) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } .padding(AppMetrics.Spacing.pageHorizontal) .background(.white) } @ViewBuilder private var content: some View { if viewModel.isLoading && viewModel.messages.isEmpty { ProgressView("加载中...") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.loadFailed { AppContentUnavailableView { Label("消息加载失败", systemImage: "exclamationmark.triangle") } description: { Text(viewModel.loadFailureReason ?? "请稍后重试") } actions: { Button("重新加载") { Task { await viewModel.reloadFirstPage(api: messageAPI) } } .buttonStyle(.borderedProminent) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.messages.isEmpty { AppContentUnavailableView("暂无消息", systemImage: "bell", description: Text("暂无可查看的系统消息")) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { LazyVStack(spacing: AppMetrics.Spacing.medium) { ForEach(viewModel.messages) { item in Button { Task { await openMessage(item) } } label: { MessageRow(item: item, processing: processingMessageId == item.id) } .buttonStyle(.plain) .onAppear { if item.id == viewModel.messages.last?.id { Task { await viewModel.loadMore(api: messageAPI) } } } } if viewModel.isLoadingMore { ProgressView() .frame(maxWidth: .infinity) .padding(.vertical, AppMetrics.Spacing.medium) } } .padding(AppMetrics.Spacing.pageHorizontal) } } } private func messageMetric(title: String, value: String, color: Color) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) Text(value) .font(.system(size: 24, weight: .bold)) .foregroundStyle(color) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) .padding(AppMetrics.Spacing.medium) .background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } private func openMessage(_ item: MessageItem) async { processingMessageId = item.id defer { processingMessageId = nil } do { try await viewModel.markAsRead(api: messageAPI, item: item) } catch { toastCenter.show(error.localizedDescription) } selectedMessage = viewModel.messages.first(where: { $0.id == item.id }) ?? item } private func markAllAsRead() async { do { try await viewModel.markAllAsRead(api: messageAPI) toastCenter.show("已全部标记为已读") } catch { toastCenter.show(error.localizedDescription) } } @discardableResult private func deleteMessage(_ item: MessageItem) async -> Bool { processingMessageId = item.id defer { processingMessageId = nil } do { try await viewModel.deleteMessage(api: messageAPI, item: item) toastCenter.show("消息已删除") return true } catch { toastCenter.show(error.localizedDescription) return false } } } private struct MessageRow: View { let item: MessageItem let processing: Bool var body: some View { HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) { ZStack { RoundedRectangle(cornerRadius: 8) .fill(item.type.color.opacity(0.12)) Image(systemName: item.type.iconName) .font(.system(size: 20, weight: .semibold)) .foregroundStyle(item.type.color) } .frame(width: 48, height: 48) VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(item.title) .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) .lineLimit(1) if !item.isRead { Circle() .fill(Color(hex: 0xEF4444)) .frame(width: 7, height: 7) } Spacer() Text(item.type.title) .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) .foregroundStyle(item.type.color) } Text(item.detail.isEmpty ? "暂无消息内容" : item.detail) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) .lineLimit(2) Text(item.time) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(Color(hex: 0x9CA3AF)) } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) .overlay { RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card) .stroke(Color(hex: item.isRead ? 0xECEFF3 : 0xBFD7FF), lineWidth: 1) } .opacity(processing ? 0.6 : 1) } } private struct MessageDetailView: View { let item: MessageItem let onDelete: (MessageItem) async -> Bool @Environment(\.dismiss) private var dismiss @State private var showDeleteConfirm = false @State private var deleting = false var body: some View { NavigationStack { ScrollView { VStack(spacing: AppMetrics.Spacing.large) { ZStack { RoundedRectangle(cornerRadius: 12) .fill(item.type.color.opacity(0.12)) Image(systemName: item.type.iconName) .font(.system(size: 38, weight: .bold)) .foregroundStyle(item.type.color) } .frame(width: 82, height: 82) VStack(spacing: AppMetrics.Spacing.small) { Text(item.title) .font(.system(size: AppMetrics.FontSize.title2, weight: .bold)) .foregroundStyle(AppDesign.textPrimary) .multilineTextAlignment(.center) Text(item.time) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) } Text(item.detail.isEmpty ? "暂无消息内容" : item.detail) .font(.system(size: AppMetrics.FontSize.body)) .foregroundStyle(AppDesign.textPrimary) .lineSpacing(6) .frame(maxWidth: .infinity, alignment: .leading) .padding(AppMetrics.Spacing.large) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } .padding(AppMetrics.Spacing.pageHorizontal) } .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) .navigationTitle("消息详情") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button(role: .destructive) { showDeleteConfirm = true } label: { if deleting { ProgressView() } else { Image(systemName: "trash") } } .disabled(deleting || item.msgId == nil) } } .alert("确认删除", isPresented: $showDeleteConfirm) { Button("取消", role: .cancel) {} Button("删除", role: .destructive) { Task { deleting = true let success = await onDelete(item) deleting = false if success { dismiss() } } } } message: { Text("删除后不可恢复,是否继续?") } } } }