将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
303 lines
12 KiB
Swift
303 lines
12 KiB
Swift
//
|
||
// MessageCenterViews.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
/// 消息中心页面,展示消息列表、筛选、已读和删除入口。
|
||
struct MessageCenterView: View {
|
||
@Environment(MessageCenterAPI.self) private var messageAPI
|
||
@Environment(ToastCenter.self) private var toastCenter
|
||
@State 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 {
|
||
ContentUnavailableView {
|
||
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 {
|
||
ContentUnavailableView("暂无消息", 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("删除后不可恢复,是否继续?")
|
||
}
|
||
}
|
||
}
|
||
}
|