新增订单长尾流程,并迁移排队、消息、结算与审核模块

将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 13:39:02 +08:00
parent 311a70d610
commit c39c3d3c75
63 changed files with 9823 additions and 58 deletions

View File

@ -0,0 +1,302 @@
//
// 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("删除后不可恢复,是否继续?")
}
}
}
}