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

将定金订单、历史拍摄与多行程上传接入 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,139 @@
//
// WithdrawalAuditViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import Observation
///
enum WithdrawalAuditFilter: String, CaseIterable, Identifiable {
case all
case processing
case completed
var id: String { rawValue }
///
var title: String {
switch self {
case .all: "全部"
case .processing: "处理中"
case .completed: "已完成"
}
}
}
@MainActor
@Observable
/// ViewModel
final class WithdrawalAuditViewModel {
var records: [WalletWithdrawRecord] = []
var selectedFilter: WithdrawalAuditFilter = .all
var isLoading = false
var isLoadingMore = false
var loadFailed = false
var loadFailureReason: String?
var errorMessage: String?
private let pageSize = 10
private var page = 1
private var total = 0
///
var filteredRecords: [WalletWithdrawRecord] {
filteredRecords(for: selectedFilter)
}
///
var hasMore: Bool {
records.count < total
}
/// 使 total
var totalCount: Int {
max(total, records.count)
}
///
var processingCount: Int {
filteredRecords(for: .processing).count
}
///
var completedCount: Int {
filteredRecords(for: .completed).count
}
///
func filteredRecords(for filter: WithdrawalAuditFilter) -> [WalletWithdrawRecord] {
switch filter {
case .all:
return records
case .processing:
return records.filter { record in
let status = record.statusLabel
return status.contains("") || status.contains("")
}
case .completed:
return records.filter { record in
let status = record.statusLabel
return status.contains("完成") || status.contains("到账") || status.contains("通过")
}
}
}
///
func selectFilter(_ filter: WithdrawalAuditFilter) {
selectedFilter = filter
}
///
func reload(api: WalletServing) async {
isLoading = true
loadFailed = false
loadFailureReason = nil
errorMessage = nil
defer { isLoading = false }
do {
let response = try await api.walletWithdrawList(page: 1, pageSize: pageSize)
records = response.item
total = response.total
page = 1
} catch {
clearRecords()
loadFailed = true
loadFailureReason = error.localizedDescription
errorMessage = error.localizedDescription
}
}
///
func loadMore(api: WalletServing) async {
guard hasMore, !isLoadingMore, !isLoading else { return }
isLoadingMore = true
errorMessage = nil
defer { isLoadingMore = false }
let nextPage = page + 1
do {
let response = try await api.walletWithdrawList(page: nextPage, pageSize: pageSize)
records += response.item
total = response.total
page = nextPage
} catch {
errorMessage = error.localizedDescription
}
}
///
private func clearRecords() {
records = []
page = 1
total = 0
isLoadingMore = false
}
}

View File

@ -0,0 +1,311 @@
//
// WithdrawalAuditView.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import SwiftUI
///
struct WithdrawalAuditView: View {
@Environment(WalletAPI.self) private var walletAPI
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = WithdrawalAuditViewModel()
@State private var selectedRecord: WalletWithdrawRecord?
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.small) {
summarySection
filterSection
contentSection
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle("提现审核")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.reload(api: walletAPI)
}
.refreshable {
await viewModel.reload(api: walletAPI)
}
.sheet(item: $selectedRecord) { record in
NavigationStack {
WithdrawalAuditDetailView(record: record)
}
}
.onChange(of: viewModel.errorMessage) { _, message in
guard let message else { return }
toastCenter.show(message)
viewModel.errorMessage = nil
}
}
private var summarySection: some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
summaryCard("记录总数", "\(viewModel.totalCount)")
summaryCard("处理中", "\(viewModel.processingCount)")
summaryCard("已完成", "\(viewModel.completedCount)")
}
}
private var filterSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Picker("审核筛选", selection: Binding(
get: { viewModel.selectedFilter },
set: { viewModel.selectFilter($0) }
)) {
ForEach(WithdrawalAuditFilter.allCases) { filter in
Text(filter.title).tag(filter)
}
}
.pickerStyle(.segmented)
HStack(spacing: AppMetrics.Spacing.xSmall) {
badge("当前 \(viewModel.selectedFilter.title)", tint: AppDesign.primary)
badge("\(viewModel.filteredRecords.count)", tint: AppDesign.textSecondary)
Spacer(minLength: 0)
}
}
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
@ViewBuilder
private var contentSection: some View {
if viewModel.isLoading && viewModel.records.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 220)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
} else if viewModel.loadFailed && viewModel.records.isEmpty {
VStack(spacing: AppMetrics.Spacing.small) {
ContentUnavailableView(
"提现审核记录加载失败",
systemImage: "exclamationmark.triangle",
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
)
Button("重新加载") {
Task { await viewModel.reload(api: walletAPI) }
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 34)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8))
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, minHeight: 220)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
} else if viewModel.filteredRecords.isEmpty {
ContentUnavailableView("暂无提现审核记录", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新"))
.frame(maxWidth: .infinity, minHeight: 220)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
} else {
VStack(spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.filteredRecords) { record in
recordCard(record)
}
if viewModel.hasMore {
Button(viewModel.isLoadingMore ? "加载中..." : "加载更多") {
Task { await viewModel.loadMore(api: walletAPI) }
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
.foregroundStyle(AppDesign.primary)
.frame(maxWidth: .infinity)
.frame(height: 40)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
.buttonStyle(.plain)
.disabled(viewModel.isLoadingMore)
}
}
}
}
private func recordCard(_ record: WalletWithdrawRecord) -> some View {
HStack(alignment: .top, spacing: AppMetrics.Spacing.small) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text("¥ \(moneyText(record.amount))")
.font(.system(size: AppMetrics.FontSize.title, weight: .semibold))
.foregroundStyle(AppDesign.primary)
Text("申请时间:\(record.createdAt.withdrawalAuditDash)")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer(minLength: AppMetrics.Spacing.small)
VStack(alignment: .trailing, spacing: AppMetrics.Spacing.xSmall) {
Text(statusText(record.statusLabel))
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(statusColor(record.statusLabel))
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 24)
.background(statusColor(record.statusLabel).opacity(0.12), in: Capsule())
Button("查看详情") {
selectedRecord = record
}
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 28)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8))
.buttonStyle(.plain)
}
}
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private func summaryCard(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Text(value)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private func badge(_ text: String, tint: Color) -> some View {
Text(text)
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(tint)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 22)
.background(tint.opacity(0.12), in: Capsule())
}
}
/// 线
private struct WithdrawalAuditDetailView: View {
let record: WalletWithdrawRecord
@Environment(\.dismiss) private var dismiss
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.small) {
card {
row("提现金额", "¥ \(moneyText(record.amount))")
row("申请时间", record.createdAt.withdrawalAuditDash)
HStack {
Text("当前状态")
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Text(statusText(record.statusLabel))
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(statusColor(record.statusLabel))
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 24)
.background(statusColor(record.statusLabel).opacity(0.12), in: Capsule())
}
if let expectedAt = record.expectedAt?.withdrawalAuditNonEmpty {
row("预计到账", expectedAt)
}
if let auditTime = record.auditTime?.withdrawalAuditNonEmpty {
row("审核时间", auditTime)
}
if let completedAt = record.completedAt?.withdrawalAuditNonEmpty {
row("到账时间", completedAt)
}
}
card {
Text("处理进度")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
timelineRow("1. 提交申请", record.createdAt.withdrawalAuditDash, done: true)
timelineRow("2. 审核中", record.auditTime?.withdrawalAuditDash ?? "--", done: true)
timelineRow("3. 打款中", record.expectedAt?.withdrawalAuditDash ?? "--", done: record.completedAt?.withdrawalAuditNonEmpty == nil)
timelineRow("4. 已完成", record.completedAt?.withdrawalAuditDash ?? "--", done: record.completedAt?.withdrawalAuditNonEmpty != nil)
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle("审核详情")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("关闭") { dismiss() }
}
}
}
private func card<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
content()
}
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private func row(_ title: String, _ value: String) -> some View {
HStack {
Text(title)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Text(value)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textPrimary)
.multilineTextAlignment(.trailing)
}
}
private func timelineRow(_ title: String, _ time: String, done: Bool) -> some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Circle()
.fill(done ? AppDesign.success : Color(hex: 0xD1D5DB))
.frame(width: 8, height: 8)
Text(title)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
Text(time)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 34)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
}
}
private func statusText(_ status: String) -> String {
status.withdrawalAuditNonEmpty ?? "处理中"
}
private func statusColor(_ status: String) -> Color {
if status.contains("通过") || status.contains("完成") || status.contains("到账") {
return AppDesign.success
}
if status.contains("") || status.contains("失败") {
return Color(hex: 0xDC2626)
}
return AppDesign.warning
}
private func moneyText(_ raw: String) -> String {
let value = raw.filter { "0123456789.-".contains($0) }
guard let decimal = Decimal(string: value), decimal > 0 else {
return "0.00"
}
return NSDecimalNumber(decimal: decimal).stringValue
}
private extension String {
var withdrawalAuditNonEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
var withdrawalAuditDash: String {
withdrawalAuditNonEmpty ?? "--"
}
}

View File

@ -0,0 +1,16 @@
# 提现审核模块
## 模块职责
`Features/WithdrawalAudit` 承接首页 `withdrawal_audit` 权限入口,以审核视角展示钱包提现记录、状态筛选和单笔处理进度。
## 业务流程
- `WithdrawalAuditView` 进入后调用 `WalletAPI.walletWithdrawList` 加载第一页提现记录。
- `WithdrawalAuditViewModel` 管理分页、加载更多、筛选状态和失败清理。
- 筛选只在本地执行:处理中匹配状态文案中的“中/待”,已完成匹配“完成/到账/通过”。
- 刷新失败会清空旧记录和分页状态,避免账号或权限切换后残留上一组财务数据。
## 边界
本模块不做管理员提现审批操作。旧 Android 工程未找到提现审批列表或操作接口,当前按旧 iOS 的提现记录审核视角迁移。