Add order tail flows and migrate queue, message, settlement, and audit modules.
Wire deposit orders, historical shooting, and multi-travel uploads into Orders, and replace home placeholders for queue management, message center, scenic settlement, and withdrawal audit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 ?? "--"
|
||||
}
|
||||
}
|
||||
16
suixinkan/Features/WithdrawalAudit/WithdrawalAudit.md
Normal file
16
suixinkan/Features/WithdrawalAudit/WithdrawalAudit.md
Normal file
@ -0,0 +1,16 @@
|
||||
# 提现审核模块
|
||||
|
||||
## 模块职责
|
||||
|
||||
`Features/WithdrawalAudit` 承接首页 `withdrawal_audit` 权限入口,以审核视角展示钱包提现记录、状态筛选和单笔处理进度。
|
||||
|
||||
## 业务流程
|
||||
|
||||
- `WithdrawalAuditView` 进入后调用 `WalletAPI.walletWithdrawList` 加载第一页提现记录。
|
||||
- `WithdrawalAuditViewModel` 管理分页、加载更多、筛选状态和失败清理。
|
||||
- 筛选只在本地执行:处理中匹配状态文案中的“中/待”,已完成匹配“完成/到账/通过”。
|
||||
- 刷新失败会清空旧记录和分页状态,避免账号或权限切换后残留上一组财务数据。
|
||||
|
||||
## 边界
|
||||
|
||||
本模块不做管理员提现审批操作。旧 Android 工程未找到提现审批列表或操作接口,当前按旧 iOS 的提现记录审核视角迁移。
|
||||
Reference in New Issue
Block a user