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

将定金订单、历史拍摄与多行程上传接入 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,74 @@
//
// MessageCenterAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import Observation
///
@MainActor
protocol MessageCenterServing {
/// 使 last_id
func messageList(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse
///
func messageRead(id: Int) async throws
///
func messageDelete(id: Int) async throws
}
@MainActor
@Observable
/// API
final class MessageCenterAPI {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
/// 使 last_id
func messageList(lastId: Int = 0, limit: Int = 20, unread: Int = 0) async throws -> MessageListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/msg/list",
queryItems: [
URLQueryItem(name: "last_id", value: String(max(lastId, 0))),
URLQueryItem(name: "limit", value: String(max(limit, 1))),
URLQueryItem(name: "unread", value: String(max(unread, 0)))
]
)
)
}
///
func messageRead(id: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/msg/read",
queryItems: [URLQueryItem(name: "id", value: String(id))],
body: EmptyPayload()
)
)
}
///
func messageDelete(id: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/msg/delete",
body: MessageDeleteRequest(id: id)
)
)
}
}
extension MessageCenterAPI: MessageCenterServing {}

View File

@ -0,0 +1,18 @@
# 消息中心模块
## 模块职责
`Features/MessageCenter` 承接首页 `message_center` 权限入口,负责消息列表、未读筛选、标记已读、全部已读、详情展示和删除。
## 代码结构
- `MessageCenterAPI`:封装 `/api/app/msg/list``/api/app/msg/read``/api/app/msg/delete`
- `MessageCenterViewModel`:管理 `last_id` 游标分页、筛选、去重排序、已读和删除后的本地状态同步。
- `MessageCenterView`:提供统计卡、全部/未读筛选、列表、失败重试、空态、详情和删除确认。
## 数据与边界
- 消息详情不请求独立详情接口,直接使用列表返回的标题、内容、类型和推送时间。
- 点击消息时先调用已读接口,再进入详情,并将本地未读状态同步为已读。
- 全部已读沿用旧 iOS 能力,逐条调用已读接口;任一失败时保留原状态并透出错误。
- 首屏刷新失败会清空旧数据和分页状态,加载更多失败保留当前列表。

View File

@ -0,0 +1,226 @@
//
// MessageCenterModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import SwiftUI
/// 使 last_id
struct MessageListResponse: Decodable, Equatable {
let hasMore: Bool
let lastId: Int
let items: [MessageEntity]
enum CodingKeys: String, CodingKey {
case hasMore = "has_more"
case lastId = "last_id"
case items
}
///
init(hasMore: Bool = false, lastId: Int = 0, items: [MessageEntity] = []) {
self.hasMore = hasMore
self.lastId = lastId
self.items = items
}
/// //
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
hasMore = try container.decodeLossyBool(forKey: .hasMore) ?? false
lastId = try container.decodeLossyInt(forKey: .lastId) ?? 0
items = try container.decodeIfPresent([MessageEntity].self, forKey: .items) ?? []
}
}
///
struct MessageEntity: Decodable, Equatable, Identifiable {
let id: Int
let type: Int
let typeName: String
let title: String
let content: String
let pushAt: String
let createdAt: String
let isRead: Bool
enum CodingKeys: String, CodingKey {
case id
case type
case typeName = "type_name"
case title
case content
case pushAt = "push_at"
case createdAt = "created_at"
case isRead = "is_read"
}
///
init(
id: Int,
type: Int,
typeName: String = "",
title: String = "",
content: String = "",
pushAt: String = "",
createdAt: String = "",
isRead: Bool = false
) {
self.id = id
self.type = type
self.typeName = typeName
self.title = title
self.content = content
self.pushAt = pushAt
self.createdAt = createdAt
self.isRead = isRead
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
type = try container.decodeLossyInt(forKey: .type) ?? 0
typeName = try container.decodeLossyString(forKey: .typeName)
title = try container.decodeLossyString(forKey: .title)
content = try container.decodeLossyString(forKey: .content)
pushAt = try container.decodeLossyString(forKey: .pushAt)
createdAt = try container.decodeLossyString(forKey: .createdAt)
isRead = try container.decodeLossyBool(forKey: .isRead) ?? false
}
}
///
struct MessageDeleteRequest: Encodable {
let id: Int
}
///
enum MessageType: Equatable {
case order
case writeOff
case system
///
var iconName: String {
switch self {
case .order:
return "cart.fill"
case .writeOff:
return "qrcode.viewfinder"
case .system:
return "bell.badge.fill"
}
}
///
var title: String {
switch self {
case .order:
return "订单"
case .writeOff:
return "核销"
case .system:
return "系统"
}
}
///
var color: Color {
switch self {
case .order:
return AppDesign.primary
case .writeOff:
return AppDesign.success
case .system:
return AppDesign.warning
}
}
}
///
struct MessageItem: Identifiable, Equatable {
let id: String
let msgId: Int?
let title: String
let detail: String
let time: String
var isRead: Bool
let type: MessageType
}
///
enum MessageFilter: Int, CaseIterable, Identifiable {
case all = 0
case unread = 1
var id: Int { rawValue }
///
var title: String {
switch self {
case .all:
return "全部"
case .unread:
return "未读"
}
}
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "1" : "0"
}
return ""
}
/// String Bool Int
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key),
case let text = value.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
return Int(text) ?? Int(Double(text) ?? 0)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? 1 : 0
}
return nil
}
/// String Bool Bool
func decodeLossyBool(forKey key: Key) throws -> Bool? {
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value != 0
}
if let value = try? decodeIfPresent(String.self, forKey: key),
case let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!text.isEmpty {
if ["1", "true", "yes"].contains(text) { return true }
if ["0", "false", "no"].contains(text) { return false }
}
return nil
}
}

View File

@ -0,0 +1,170 @@
//
// MessageCenterViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import Observation
@MainActor
@Observable
/// ViewModel
final class MessageCenterViewModel {
var messages: [MessageItem] = []
var selectedFilter: MessageFilter = .all
var isLoading = false
var isLoadingMore = false
var loadFailed = false
var loadFailureReason: String?
var message: String?
private(set) var hasMoreMessages = false
private(set) var lastId = 0
private let pageSize = 20
///
var unreadCount: Int {
messages.filter { !$0.isRead }.count
}
///
var filterDescription: String {
selectedFilter == .all ? "显示全部消息" : "仅显示未读消息"
}
///
func selectFilter(_ filter: MessageFilter, api: any MessageCenterServing) async {
guard selectedFilter != filter else { return }
selectedFilter = filter
await reloadFirstPage(api: api)
}
///
func reloadFirstPage(api: any MessageCenterServing) async {
isLoading = true
loadFailed = false
loadFailureReason = nil
message = nil
defer { isLoading = false }
do {
let response = try await api.messageList(lastId: 0, limit: pageSize, unread: selectedFilter.rawValue)
messages = mapMessages(response.items)
hasMoreMessages = response.hasMore
lastId = response.lastId
} catch {
clearMessages()
loadFailed = true
loadFailureReason = error.localizedDescription
message = error.localizedDescription
}
}
///
func loadMore(api: any MessageCenterServing) async {
guard hasMoreMessages, !isLoadingMore, !isLoading else { return }
isLoadingMore = true
message = nil
defer { isLoadingMore = false }
do {
let response = try await api.messageList(lastId: lastId, limit: pageSize, unread: selectedFilter.rawValue)
messages.append(contentsOf: mapMessages(response.items))
messages = deduplicatedAndSorted(messages)
hasMoreMessages = response.hasMore
lastId = response.lastId
} catch {
message = error.localizedDescription
}
}
///
func markAsRead(api: any MessageCenterServing, item: MessageItem) async throws {
guard !item.isRead, let msgId = item.msgId else { return }
try await api.messageRead(id: msgId)
guard let index = messages.firstIndex(where: { $0.id == item.id }) else { return }
messages[index].isRead = true
}
///
func markAllAsRead(api: any MessageCenterServing) async throws {
let original = messages
do {
for item in messages where !item.isRead {
if let msgId = item.msgId {
try await api.messageRead(id: msgId)
}
}
messages = messages.map { item in
var next = item
next.isRead = true
return next
}
} catch {
messages = original
throw error
}
}
///
func deleteMessage(api: any MessageCenterServing, item: MessageItem) async throws {
if let msgId = item.msgId {
try await api.messageDelete(id: msgId)
}
messages.removeAll { $0.id == item.id }
}
///
func mapMessages(_ source: [MessageEntity]) -> [MessageItem] {
source.map { item in
MessageItem(
id: "msg_\(item.id)",
msgId: item.id,
title: item.title.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? item.typeName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "系统通知",
detail: item.content,
time: item.pushAt.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? item.createdAt.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "0000-00-00 00:00",
isRead: item.isRead,
type: Self.messageType(from: item.type)
)
}
}
///
static func messageType(from value: Int) -> MessageType {
switch value {
case 1:
return .order
case 2:
return .writeOff
default:
return .system
}
}
private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] {
var unique: [String: MessageItem] = [:]
for item in source {
unique[item.id] = item
}
return unique.values.sorted { $0.time > $1.time }
}
private func clearMessages() {
messages = []
hasMoreMessages = false
lastId = 0
isLoadingMore = false
}
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}

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