Add Payment and Wallet modules with home routing integration.

Introduce real payment collection and wallet screens to replace home menu placeholders, wire APIs through RootView, and support bank card OSS uploads plus QR code saving to the photo library.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 17:46:01 +08:00
parent c2652b83c4
commit e8bc9b7f44
23 changed files with 3117 additions and 9 deletions

View File

@ -23,6 +23,8 @@ struct RootView: View {
@State private var accountContextAPI: AccountContextAPI
@State private var ordersAPI: OrdersAPI
@State private var statisticsAPI: StatisticsAPI
@State private var paymentAPI: PaymentAPI
@State private var walletAPI: WalletAPI
@State private var authSessionCoordinator: AuthSessionCoordinator
@State private var sessionBootstrapper: SessionBootstrapper
@ -40,6 +42,8 @@ struct RootView: View {
_accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient))
_ordersAPI = State(initialValue: OrdersAPI(client: apiClient))
_statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient))
_paymentAPI = State(initialValue: PaymentAPI(client: apiClient))
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
_authSessionCoordinator = State(
initialValue: AuthSessionCoordinator(
tokenStore: tokenStore,
@ -72,6 +76,8 @@ struct RootView: View {
.environment(accountContextAPI)
.environment(ordersAPI)
.environment(statisticsAPI)
.environment(paymentAPI)
.environment(walletAPI)
.environment(authSessionCoordinator)
.task {
apiClient.bindAuthTokenProvider { appSession.token }

View File

@ -36,6 +36,9 @@ protocol OSSUploadServing {
/// 访 OSS URL
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
}
@MainActor
@ -84,6 +87,11 @@ final class OSSUploadService {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "scenic_apply", onProgress: onProgress)
}
/// 访 OSS URL
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
}
/// OSS
private func uploadFile(
data: Data,
@ -188,6 +196,8 @@ enum OSSUploadPolicy {
return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "real_name":
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "bank_card":
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
default:
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
}

View File

@ -33,4 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
## 后续迁移
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,本轮保留占位,后续应作为独立功能迁移。
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。

View File

@ -24,6 +24,8 @@ enum HomeRoute: Hashable {
case scenicSelection
case moreFunctions
case settings
case paymentCollection
case wallet
case modulePlaceholder(uri: String, title: String)
}

View File

@ -81,6 +81,10 @@ enum HomeMenuRouter {
return .destination(.settings)
case "scenicselection":
return .destination(.scenicSelection)
case "wallet":
return .destination(.wallet)
case "payment_collection", "payment_qr", "payment_code":
return .destination(.paymentCollection)
case "store", "more_functions":
return .destination(.moreFunctions)
case "fly", "pilot_controller":
@ -89,13 +93,9 @@ enum HomeMenuRouter {
title: title.isEmpty ? self.title(for: uri) : title,
reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。"
)
case "wallet",
"message_center",
case "message_center",
"/scenic-queue",
"queue_management",
"payment_collection",
"payment_qr",
"payment_code",
"deposit_order_detail",
"deposit_order",
"deposit_order_shooting_info",

View File

@ -20,6 +20,10 @@ extension HomeRoute {
HomeMoreFunctionsView()
case .settings:
SettingsCenterView()
case .paymentCollection:
PaymentCollectionView()
case .wallet:
WalletView()
case let .modulePlaceholder(uri, title):
HomeMigrationModuleView(title: title, uri: uri)
}

View File

@ -0,0 +1,55 @@
//
// PaymentAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import Observation
///
@MainActor
protocol PaymentServing {
/// URL
func payCode(scenicId: Int) async throws -> PayCodeResponse
///
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse
}
@MainActor
@Observable
/// API
final class PaymentAPI {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
/// URL
func payCode(scenicId: Int) async throws -> PayCodeResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/pay-code",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
)
)
}
///
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/order/photo-scan-order-list",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
)
)
}
}
extension PaymentAPI: PaymentServing {}

View File

@ -0,0 +1,182 @@
//
// PaymentModels.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// URL URL
struct PayCodeResponse: Decodable, Equatable {
let staticPayUrl: String
let dynamicPayUrl: String
/// JSON
enum CodingKeys: String, CodingKey {
case staticPayUrl = "static_pay_url"
case dynamicPayUrl = "dynamic_pay_url"
}
///
init(staticPayUrl: String = "", dynamicPayUrl: String = "") {
self.staticPayUrl = staticPayUrl
self.dynamicPayUrl = dynamicPayUrl
}
/// URL
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
staticPayUrl = try container.decodeLossyString(forKey: .staticPayUrl)
dynamicPayUrl = try container.decodeLossyString(forKey: .dynamicPayUrl)
}
}
///
struct PaymentCollectionRecordResponse: Decodable, Equatable {
let analyse: [PaymentCollectionRecordAnalyseItem]
let list: [PaymentCollectionRecordItem]
/// JSON
enum CodingKeys: String, CodingKey {
case analyse
case list
}
///
init(analyse: [PaymentCollectionRecordAnalyseItem] = [], list: [PaymentCollectionRecordItem] = []) {
self.analyse = analyse
self.list = list
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
analyse = try container.decodeIfPresent([PaymentCollectionRecordAnalyseItem].self, forKey: .analyse) ?? []
list = try container.decodeIfPresent([PaymentCollectionRecordItem].self, forKey: .list) ?? []
}
}
///
struct PaymentCollectionRecordAnalyseItem: Decodable, Equatable, Identifiable {
let date: String
let orderCount: Int
let orderAmountSum: String
var id: String { date }
/// JSON
enum CodingKeys: String, CodingKey {
case date
case orderCount = "order_count"
case orderAmountSum = "order_amount_sum"
}
///
init(date: String, orderCount: Int, orderAmountSum: String) {
self.date = date
self.orderCount = orderCount
self.orderAmountSum = orderAmountSum
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
date = try container.decodeLossyString(forKey: .date)
orderCount = try container.decodeLossyInt(forKey: .orderCount) ?? 0
orderAmountSum = try container.decodeLossyString(forKey: .orderAmountSum)
}
}
///
struct PaymentCollectionRecordItem: Decodable, Equatable, Identifiable {
let orderNumber: String
let userPhone: String
let orderAmount: String
let createDate: String
let createTime: String
var id: String {
orderNumber.isEmpty ? "\(createDate)-\(createTime)-\(orderAmount)" : orderNumber
}
/// JSON
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
case userPhone = "user_phone"
case orderAmount = "order_amount"
case createDate = "create_date"
case createTime = "create_time"
}
///
init(orderNumber: String, userPhone: String, orderAmount: String, createDate: String, createTime: String) {
self.orderNumber = orderNumber
self.userPhone = userPhone
self.orderAmount = orderAmount
self.createDate = createDate
self.createTime = createTime
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
userPhone = try container.decodeLossyString(forKey: .userPhone)
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
createDate = try container.decodeLossyString(forKey: .createDate)
createTime = try container.decodeLossyString(forKey: .createTime)
}
}
///
struct PaymentCollectionRecordGroup: Equatable, Identifiable {
let analyse: PaymentCollectionRecordAnalyseItem
let items: [PaymentCollectionRecordItem]
var id: String { analyse.id }
}
///
enum PaymentCollectionStatus: Equatable {
case idle
case waiting
case success(PaymentCollectionRecordItem)
case failed(String)
}
private extension KeyedDecodingContainer {
///
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 value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(value)
}
return ""
}
///
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
}

View File

@ -0,0 +1,25 @@
# 收款模块
## 模块职责
`Features/Payment` 承接首页 `payment_collection``payment_qr``payment_code` 权限入口,负责当前景区下的收款码、动态金额二维码、收款记录和到账轮询。
## 代码结构
- `PaymentAPI`:封装收款码和扫码收款记录接口。
- `PaymentCollectionViewModel`:管理收款码加载、金额校验、动态二维码生成和到账轮询。
- `PaymentCollectionRecordViewModel`:管理收款记录加载和按日期分组。
- `PaymentCollectionView`:展示收款码、金额弹窗、保存二维码和收款状态。
- `PaymentCollectionRecordView`:展示收款记录明细。
## 数据流
1. 页面读取 `AccountContext.currentScenic`
2. 有景区时调用 `/api/yf-handset-app/photog/pay-code?scenic_id=...` 获取收款码。
3. 设置金额后基于动态收款码 URL 追加 `amount` 和可选 `remark`,生成二维码。
4. 开始轮询 `/api/yf-handset-app/photog/order/photo-scan-order-list?scenic_id=...`,最多 60 次,每 2 秒一次。
5. 命中新收款记录后进入成功态,并使用系统语音播报到账金额。
## 缓存边界
收款码 URL、付款状态、轮询结果不落盘。保存二维码只写入系统相册App 不额外缓存图片文件或 Data。

View File

@ -0,0 +1,246 @@
//
// PaymentViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import CoreImage
import CoreImage.CIFilterBuiltins
import Foundation
import Observation
import UIKit
@MainActor
@Observable
/// ViewModel
final class PaymentCollectionViewModel {
var staticPayUrl = ""
var dynamicPayUrl = ""
var amountText = ""
var remarkText = ""
var currentPayUrl = ""
var qrImage: UIImage?
var isLoading = false
var isPolling = false
var errorMessage: String?
var status: PaymentCollectionStatus = .idle
private var knownRecordIDs = Set<String>()
private let context = CIContext()
private let qrFilter = CIFilter.qrCodeGenerator()
///
var hasStaticPayCode: Bool {
!staticPayUrl.paymentTrimmed.isEmpty
}
///
func loadPayCode(api: PaymentServing, scenicId: Int?) async {
guard let scenicId else {
resetPayCode()
errorMessage = "请先选择景区"
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response = try await api.payCode(scenicId: scenicId)
staticPayUrl = response.staticPayUrl
dynamicPayUrl = response.dynamicPayUrl
currentPayUrl = response.staticPayUrl
qrImage = makeQRCode(from: currentPayUrl)
status = .idle
} catch {
errorMessage = error.localizedDescription
}
}
/// URL
@discardableResult
func applyDynamicAmount() -> Bool {
guard let amount = Self.normalizedMoney(amountText), Decimal(string: amount) ?? 0 > 0 else {
errorMessage = "请输入有效收款金额"
return false
}
guard !dynamicPayUrl.paymentTrimmed.isEmpty else {
errorMessage = "动态收款码暂不可用"
return false
}
var components = URLComponents(string: dynamicPayUrl)
var queryItems = components?.queryItems ?? []
queryItems.removeAll { $0.name == "amount" || $0.name == "remark" }
queryItems.append(URLQueryItem(name: "amount", value: amount))
if !remarkText.paymentTrimmed.isEmpty {
queryItems.append(URLQueryItem(name: "remark", value: remarkText.paymentTrimmed))
}
components?.queryItems = queryItems
currentPayUrl = components?.url?.absoluteString ?? "\(dynamicPayUrl)&amount=\(amount)"
qrImage = makeQRCode(from: currentPayUrl)
amountText = amount
status = .waiting
return true
}
///
func primePaymentRecords(api: PaymentServing, scenicId: Int?) async {
guard let scenicId else { return }
if let response = try? await api.paymentCollectionRecords(scenicId: scenicId) {
knownRecordIDs = Set(response.list.map(\.id))
}
}
///
func pollUntilPaymentDetected(
api: PaymentServing,
scenicId: Int?,
maxAttempts: Int = 60,
intervalNanoseconds: UInt64 = 2_000_000_000
) async {
guard let scenicId else {
status = .failed("请先选择景区")
return
}
guard maxAttempts > 0 else { return }
isPolling = true
defer { isPolling = false }
for attempt in 0 ..< maxAttempts {
if Task.isCancelled { return }
do {
let response = try await api.paymentCollectionRecords(scenicId: scenicId)
if let item = matchedNewRecord(in: response.list) {
knownRecordIDs.insert(item.id)
status = .success(item)
return
}
} catch {
status = .failed(error.localizedDescription)
return
}
if attempt < maxAttempts - 1 {
try? await Task.sleep(nanoseconds: intervalNanoseconds)
}
}
status = .failed("暂未查询到到账记录,请稍后刷新收款记录")
}
///
func resetPayCode() {
staticPayUrl = ""
dynamicPayUrl = ""
currentPayUrl = ""
qrImage = nil
knownRecordIDs = []
status = .idle
}
///
static func normalizedMoney(_ rawValue: String) -> String? {
let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
let sanitized = text.replacingOccurrences(of: ",", with: "")
guard let decimal = Decimal(string: sanitized), decimal > 0 else { return nil }
let number = NSDecimalNumber(decimal: decimal)
let handler = NSDecimalNumberHandler(
roundingMode: .plain,
scale: 2,
raiseOnExactness: false,
raiseOnOverflow: false,
raiseOnUnderflow: false,
raiseOnDivideByZero: false
)
return number.rounding(accordingToBehavior: handler).stringValue
}
///
private func matchedNewRecord(in records: [PaymentCollectionRecordItem]) -> PaymentCollectionRecordItem? {
let newRecords = records.filter { !knownRecordIDs.contains($0.id) }
guard !newRecords.isEmpty else { return nil }
guard let amount = Self.normalizedMoney(amountText) else {
return newRecords.first
}
return newRecords.first { Self.normalizedMoney($0.orderAmount) == amount } ?? newRecords.first
}
/// 使
private func makeQRCode(from text: String) -> UIImage? {
guard !text.paymentTrimmed.isEmpty,
let data = text.data(using: .utf8) else {
return nil
}
qrFilter.setValue(data, forKey: "inputMessage")
qrFilter.setValue("M", forKey: "inputCorrectionLevel")
guard let output = qrFilter.outputImage else { return nil }
let transformed = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}
private extension String {
/// 使 fileprivate
var paymentTrimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@MainActor
@Observable
/// ViewModel
final class PaymentCollectionRecordViewModel {
var groups: [PaymentCollectionRecordGroup] = []
var isLoading = false
var errorMessage: String?
///
func load(api: PaymentServing, scenicId: Int?) async {
guard let scenicId else {
groups = []
errorMessage = "请先选择景区"
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response = try await api.paymentCollectionRecords(scenicId: scenicId)
groups = Self.makeGroups(from: response)
} catch {
errorMessage = error.localizedDescription
}
}
/// analyse list
static func makeGroups(from response: PaymentCollectionRecordResponse) -> [PaymentCollectionRecordGroup] {
if !response.analyse.isEmpty {
return response.analyse.map { analyse in
PaymentCollectionRecordGroup(
analyse: analyse,
items: response.list.filter { $0.createDate == analyse.date }
)
}
}
let grouped = Dictionary(grouping: response.list, by: \.createDate)
return grouped.keys.sorted(by: >).map { date in
let items = grouped[date] ?? []
let total = items.reduce(Decimal(0)) { partial, item in
partial + (Decimal(string: item.orderAmount) ?? 0)
}
let analyse = PaymentCollectionRecordAnalyseItem(
date: date,
orderCount: items.count,
orderAmountSum: NSDecimalNumber(decimal: total).stringValue
)
return PaymentCollectionRecordGroup(analyse: analyse, items: items)
}
}
}

View File

@ -0,0 +1,325 @@
//
// PaymentCollectionView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import AVFoundation
import Photos
import SwiftUI
///
struct PaymentCollectionView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(PaymentAPI.self) private var paymentAPI
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = PaymentCollectionViewModel()
@State private var showingAmountSheet = false
@State private var pollingTask: Task<Void, Never>?
@State private var speaker = PaymentVoiceSpeaker()
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
contextHeader
qrCard
actionGrid
statusCard
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.pageVertical)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("立即收款")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
PaymentCollectionRecordView()
} label: {
Image(systemName: "list.bullet.rectangle")
}
}
}
.task(id: accountContext.currentScenic?.id) {
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
.sheet(isPresented: $showingAmountSheet) {
amountSheet
.presentationDetents([.medium])
}
.onDisappear {
pollingTask?.cancel()
}
.onChange(of: viewModel.errorMessage) { _, message in
if let message {
toastCenter.show(message)
}
}
.onChange(of: viewModel.status) { _, status in
if case .success(let item) = status {
speaker.speak("收款到账\(item.orderAmount)")
}
}
}
///
private var contextHeader: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "mappin.and.ellipse")
.foregroundStyle(AppDesign.primary)
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
Text(accountContext.currentScenic?.name ?? "未选择景区")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text("收款记录按当前景区查询")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
///
private var qrCard: some View {
VStack(spacing: AppMetrics.Spacing.medium) {
if viewModel.isLoading {
ProgressView()
.frame(width: 220, height: 220)
} else if let image = viewModel.qrImage {
Image(uiImage: image)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: 220, height: 220)
.background(.white)
} else {
VStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "qrcode")
.font(.system(size: 56, weight: .semibold))
.foregroundStyle(AppDesign.placeholder)
Text("暂无收款码")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
}
.frame(width: 220, height: 220)
}
Text(viewModel.currentPayUrl.isEmpty ? "请先加载收款码" : "向客户展示二维码完成收款")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(AppMetrics.Spacing.large)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
///
private var actionGrid: some View {
HStack(spacing: AppMetrics.Spacing.small) {
paymentActionButton(title: "设置金额", icon: "yensign.circle.fill") {
showingAmountSheet = true
}
paymentActionButton(title: "保存二维码", icon: "square.and.arrow.down") {
saveQRCode()
}
paymentActionButton(title: "刷新", icon: "arrow.clockwise") {
Task { await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) }
}
}
}
///
@ViewBuilder
private var statusCard: some View {
switch viewModel.status {
case .idle:
EmptyView()
case .waiting:
HStack(spacing: AppMetrics.Spacing.small) {
ProgressView()
Text("等待付款到账...")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
case .success(let item):
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Label("收款成功", systemImage: "checkmark.circle.fill")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.success)
Text("订单号:\(item.orderNumber)")
Text("金额:¥\(item.orderAmount)")
}
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
case .failed(let message):
Text(message)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.warning)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
}
///
private var amountSheet: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
Text("设置收款金额")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
TextField("请输入金额", text: $viewModel.amountText)
.keyboardType(.decimalPad)
.appInputFieldStyle(cornerRadius: 8, minHeight: AppMetrics.ControlSize.inputHeight)
TextField("备注,可不填", text: $viewModel.remarkText)
.appInputFieldStyle(cornerRadius: 8, minHeight: AppMetrics.ControlSize.inputHeight)
Button {
if viewModel.applyDynamicAmount() {
showingAmountSheet = false
startPolling()
} else if let message = viewModel.errorMessage {
toastCenter.show(message)
}
} label: {
Text("生成动态二维码")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight)
.foregroundStyle(.white)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
}
}
.padding(AppMetrics.Spacing.large)
}
///
private func paymentActionButton(title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: icon)
.font(.system(size: 22, weight: .semibold))
Text(title)
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
}
.foregroundStyle(AppDesign.primary)
.frame(maxWidth: .infinity, minHeight: 76)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
}
///
private func startPolling() {
pollingTask?.cancel()
pollingTask = Task {
await viewModel.primePaymentRecords(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
await viewModel.pollUntilPaymentDetected(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
}
///
private func saveQRCode() {
guard let image = viewModel.qrImage else {
toastCenter.show("暂无可保存二维码")
return
}
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
guard status == .authorized || status == .limited else {
Task { @MainActor in toastCenter.show("请允许添加照片权限") }
return
}
PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAsset(from: image)
} completionHandler: { success, error in
Task { @MainActor in
toastCenter.show(success ? "二维码已保存" : (error?.localizedDescription ?? "保存失败"))
}
}
}
}
}
///
struct PaymentCollectionRecordView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(PaymentAPI.self) private var paymentAPI
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = PaymentCollectionRecordViewModel()
var body: some View {
List {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
}
ForEach(viewModel.groups) { group in
Section {
ForEach(group.items) { item in
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
HStack {
Text("¥\(item.orderAmount)")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
Spacer()
Text(item.createTime)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Text(item.orderNumber)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
Text(item.userPhone)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
}
.padding(.vertical, AppMetrics.Spacing.xxSmall)
}
} header: {
HStack {
Text(group.analyse.date)
Spacer()
Text("\(group.analyse.orderCount)笔 ¥\(group.analyse.orderAmountSum)")
}
}
}
}
.navigationTitle("收款记录")
.navigationBarTitleDisplayMode(.inline)
.refreshable {
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
.task(id: accountContext.currentScenic?.id) {
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
.onChange(of: viewModel.errorMessage) { _, message in
if let message {
toastCenter.show(message)
}
}
}
}
/// 使 TTS
@MainActor
@Observable
final class PaymentVoiceSpeaker {
@ObservationIgnored private let synthesizer = AVSpeechSynthesizer()
///
func speak(_ text: String) {
let utterance = AVSpeechUtterance(string: text)
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
utterance.rate = 0.5
synthesizer.speak(utterance)
}
}

View File

@ -0,0 +1,204 @@
//
// WalletAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import Observation
///
@MainActor
protocol WalletServing {
///
func walletSummary(type: Int) async throws -> WalletSummaryResponse
///
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse
///
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse
///
func withdrawInfo() async throws -> WithdrawInfoResponse
///
func withdrawSendSms() async throws
///
func withdrawApply(amount: String, smsCode: String) async throws
///
func bankCardInfo() async throws -> BankCardInfoResponse
///
func bankList() async throws -> BankListResponse
///
func areas() async throws -> [AreaNode]
///
func bankCardVerifyCode() async throws
///
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws
///
func pointOverview(staffId: Int) async throws -> PointOverviewResponse
///
func pointWithdrawApply(points: Int, remark: String) async throws
///
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse
}
@MainActor
@Observable
/// API
final class WalletAPI {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func walletSummary(type: Int = 0) async throws -> WalletSummaryResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/wallet/summary",
queryItems: [URLQueryItem(name: "type", value: String(type))]
)
)
}
///
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/wallet/earning-detail",
queryItems: [
URLQueryItem(name: "start_date", value: startDate),
URLQueryItem(name: "end_date", value: endDate),
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "page_size", value: String(pageSize))
]
)
)
}
///
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/wallet/withdraw-list",
queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "page_size", value: String(pageSize))
]
)
)
}
///
func withdrawInfo() async throws -> WithdrawInfoResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/withdraw-info")
)
}
///
func withdrawSendSms() async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/wallet/withdraw-send-sms", body: EmptyPayload())
)
}
///
func withdrawApply(amount: String, smsCode: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/wallet/withdraw-apply",
body: WithdrawApplyRequest(amount: amount, smsCode: smsCode)
)
)
}
///
func bankCardInfo() async throws -> BankCardInfoResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-card/info")
)
}
///
func bankList() async throws -> BankListResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-list")
)
}
///
func areas() async throws -> [AreaNode] {
try await client.send(
APIRequest(method: .get, path: "/api/app/config/areas")
)
}
///
func bankCardVerifyCode() async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/wallet/bank-card/sms-verify-code", body: EmptyPayload())
)
}
///
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/wallet/bank-card/update", body: request)
)
}
///
func pointOverview(staffId: Int) async throws -> PointOverviewResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/overview",
queryItems: [URLQueryItem(name: "staff_id", value: String(staffId))],
body: EmptyPayload()
)
)
}
///
func pointWithdrawApply(points: Int, remark: String = "积分提现申请") async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/withdraw/apply",
body: PointWithdrawApplyRequest(points: points, remark: remark)
)
)
}
///
func pointWithdrawList(status: Int? = nil, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/withdraw/list",
body: PointWithdrawListRequest(status: status, page: page, pageSize: pageSize)
)
)
}
}
extension WalletAPI: WalletServing {}

View File

@ -0,0 +1,604 @@
//
// WalletModels.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
///
struct WalletSummaryResponse: Decodable, Equatable {
let amountTotal: String
let amountCurrentBalance: String
let amountWithdrawable: String
enum CodingKeys: String, CodingKey {
case amountTotal = "amount_total"
case amountCurrentBalance = "amount_current_balance"
case amountWithdrawable = "amount_withdrawable"
}
///
init(amountTotal: String = "0.00", amountCurrentBalance: String = "0.00", amountWithdrawable: String = "0.00") {
self.amountTotal = amountTotal
self.amountCurrentBalance = amountCurrentBalance
self.amountWithdrawable = amountWithdrawable
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
amountTotal = try container.decodeLossyString(forKey: .amountTotal)
amountCurrentBalance = try container.decodeLossyString(forKey: .amountCurrentBalance)
amountWithdrawable = try container.decodeLossyString(forKey: .amountWithdrawable)
}
}
///
struct WalletEarningDetailResponse: Decodable, Equatable {
let totalAmount: String
let totalPoints: Int
let total: Int
let list: [WalletEarningDetailGroup]
enum CodingKeys: String, CodingKey {
case totalAmount = "total_amount"
case totalPoints = "total_points"
case total
case list
}
///
init(totalAmount: String = "0.00", totalPoints: Int = 0, total: Int = 0, list: [WalletEarningDetailGroup] = []) {
self.totalAmount = totalAmount
self.totalPoints = totalPoints
self.total = total
self.list = list
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
totalAmount = try container.decodeLossyString(forKey: .totalAmount)
totalPoints = try container.decodeLossyInt(forKey: .totalPoints) ?? 0
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([WalletEarningDetailGroup].self, forKey: .list) ?? []
}
}
///
struct WalletEarningDetailGroup: Decodable, Equatable, Identifiable {
let date: String
let dayAmount: String
let dayPoints: Int
let items: [WalletEarningDetailItem]
var id: String { date }
enum CodingKeys: String, CodingKey {
case date
case dayAmount = "day_amount"
case dayPoints = "day_points"
case items
}
///
init(date: String, dayAmount: String = "0.00", dayPoints: Int = 0, items: [WalletEarningDetailItem] = []) {
self.date = date
self.dayAmount = dayAmount
self.dayPoints = dayPoints
self.items = items
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
date = try container.decodeLossyString(forKey: .date)
dayAmount = try container.decodeLossyString(forKey: .dayAmount)
dayPoints = try container.decodeLossyInt(forKey: .dayPoints) ?? 0
items = try container.decodeIfPresent([WalletEarningDetailItem].self, forKey: .items) ?? []
}
}
///
struct WalletEarningDetailItem: Decodable, Equatable, Identifiable {
let id: Int64
let amount: String
let points: Int
let type: Int
let typeLabel: String
let orderNumberSuffix: String
let createdAt: String
let withdrawLabel: String?
let source: String?
enum CodingKeys: String, CodingKey {
case id
case amount
case points
case type
case typeLabel = "type_label"
case orderNumberSuffix = "order_number_suffix"
case createdAt = "created_at"
case withdrawLabel = "withdraw_label"
case source
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0)
amount = try container.decodeLossyString(forKey: .amount)
points = try container.decodeLossyInt(forKey: .points) ?? 0
type = try container.decodeLossyInt(forKey: .type) ?? 0
typeLabel = try container.decodeLossyString(forKey: .typeLabel)
orderNumberSuffix = try container.decodeLossyString(forKey: .orderNumberSuffix)
createdAt = try container.decodeLossyString(forKey: .createdAt)
withdrawLabel = try container.decodeIfPresent(String.self, forKey: .withdrawLabel)
source = try container.decodeIfPresent(String.self, forKey: .source)
}
}
///
struct WalletWithdrawListResponse: Decodable, Equatable {
let total: Int
let item: [WalletWithdrawRecord]
enum CodingKeys: String, CodingKey {
case total
case item
}
///
init(total: Int = 0, item: [WalletWithdrawRecord] = []) {
self.total = total
self.item = item
}
/// total
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
item = try container.decodeIfPresent([WalletWithdrawRecord].self, forKey: .item) ?? []
}
}
///
struct WalletWithdrawRecord: Decodable, Equatable, Identifiable {
let id: Int64
let amount: String
let createdAt: String
let statusLabel: String
let expectedAt: String?
let auditTime: String?
let completedAt: String?
enum CodingKeys: String, CodingKey {
case id
case amount
case createdAt = "created_at"
case statusLabel = "status_label"
case expectedAt = "expected_at"
case auditTime = "audit_time"
case completedAt = "completed_at"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0)
amount = try container.decodeLossyString(forKey: .amount)
createdAt = try container.decodeLossyString(forKey: .createdAt)
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
expectedAt = try container.decodeIfPresent(String.self, forKey: .expectedAt)
auditTime = try container.decodeIfPresent(String.self, forKey: .auditTime)
completedAt = try container.decodeIfPresent(String.self, forKey: .completedAt)
}
}
///
struct BankCardInfoResponse: Decodable, Equatable {
let bankCard: WalletBankCardInfo?
enum CodingKeys: String, CodingKey {
case bankCard = "bank_card"
}
}
///
struct WalletBankCardInfo: Decodable, Equatable, Identifiable {
var id: String { cardNumber }
let realName: String
let bankName: String
let branchName: String
let cardNumber: String
let frontUrl: String?
let backUrl: String?
let provinceCode: String?
let cityCode: String?
let rejectReason: String?
let auditStatus: Int
let auditStatusLabel: String
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case bankName = "bank_name"
case branchName = "branch_name"
case cardNumber = "card_number"
case frontUrl = "front_url"
case backUrl = "back_url"
case provinceCode = "province_code"
case cityCode = "city_code"
case rejectReason = "reject_reason"
case auditStatus = "audit_status"
case auditStatusLabel = "audit_status_label"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
realName = try container.decodeLossyString(forKey: .realName)
bankName = try container.decodeLossyString(forKey: .bankName)
branchName = try container.decodeLossyString(forKey: .branchName)
cardNumber = try container.decodeLossyString(forKey: .cardNumber)
frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl)
backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl)
provinceCode = try container.decodeIfPresent(String.self, forKey: .provinceCode)
cityCode = try container.decodeIfPresent(String.self, forKey: .cityCode)
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason)
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
auditStatusLabel = try container.decodeLossyString(forKey: .auditStatusLabel)
}
}
///
struct BankListResponse: Decodable, Equatable {
let banks: [String]
enum CodingKeys: String, CodingKey {
case banks
}
///
init(banks: [String] = []) {
self.banks = banks
}
}
///
struct AreaNode: Decodable, Equatable, Identifiable {
let id: String
let code: String
let name: String
let children: [AreaNode]
enum CodingKeys: String, CodingKey {
case id
case code
case name
case children
}
/// id/code
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeLossyString(forKey: .code)
let rawId = try container.decodeLossyString(forKey: .id).trimmingCharacters(in: .whitespacesAndNewlines)
id = rawId.isEmpty ? code : rawId
name = try container.decodeLossyString(forKey: .name)
children = try container.decodeIfPresent([AreaNode].self, forKey: .children) ?? []
}
}
///
struct WithdrawInfoResponse: Decodable, Equatable {
let amountWithdrawable: String
let minWithdrawAmount: String
let maxSingleWithdrawAmount: String
let maxDailyWithdrawAmount: String
let userPhone: String
let bankCard: WithdrawBankCardInfo
let withdrawInfo: [String]
enum CodingKeys: String, CodingKey {
case amountWithdrawable = "amount_withdrawable"
case minWithdrawAmount = "min_withdraw_amount"
case maxSingleWithdrawAmount = "max_single_withdraw_amount"
case maxDailyWithdrawAmount = "max_daily_withdraw_amount"
case userPhone = "user_phone"
case bankCard = "bank_card"
case withdrawInfo = "withdraw_info"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
amountWithdrawable = try container.decodeLossyString(forKey: .amountWithdrawable)
minWithdrawAmount = try container.decodeLossyString(forKey: .minWithdrawAmount)
maxSingleWithdrawAmount = try container.decodeLossyString(forKey: .maxSingleWithdrawAmount)
maxDailyWithdrawAmount = try container.decodeLossyString(forKey: .maxDailyWithdrawAmount)
userPhone = try container.decodeLossyString(forKey: .userPhone)
bankCard = try container.decodeIfPresent(WithdrawBankCardInfo.self, forKey: .bankCard) ?? WithdrawBankCardInfo()
withdrawInfo = try container.decodeIfPresent([String].self, forKey: .withdrawInfo) ?? []
}
}
///
struct WithdrawBankCardInfo: Decodable, Equatable {
let realName: String
let bankName: String
let cardNumber: String
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case bankName = "bank_name"
case cardNumber = "card_number"
}
///
init(realName: String = "", bankName: String = "", cardNumber: String = "") {
self.realName = realName
self.bankName = bankName
self.cardNumber = cardNumber
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
realName = try container.decodeLossyString(forKey: .realName)
bankName = try container.decodeLossyString(forKey: .bankName)
cardNumber = try container.decodeLossyString(forKey: .cardNumber)
}
}
///
struct WithdrawApplyRequest: Encodable {
let amount: String
let smsCode: String
enum CodingKeys: String, CodingKey {
case amount
case smsCode = "sms_code"
}
}
/// URL
struct UpdateBankInfoRequest: Encodable, Equatable {
let realName: String
let cardNumber: String
let bankName: String
let branchName: String
let frontUrl: String
let backUrl: String
let provinceCode: String
let cityCode: String
let smsVerifyCode: String
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case cardNumber = "card_number"
case bankName = "bank_name"
case branchName = "branch_name"
case frontUrl = "front_url"
case backUrl = "back_url"
case provinceCode = "province_code"
case cityCode = "city_code"
case smsVerifyCode = "sms_verify_code"
}
}
///
struct PointOverviewResponse: Decodable, Equatable {
let totalPoints: Int
let availablePoints: Int
let withdrawnPoints: Int
let pendingPoints: Int
let time: String
enum CodingKeys: String, CodingKey {
case totalPoints = "total_points"
case availablePoints = "available_points"
case withdrawnPoints = "withdrawn_points"
case pendingPoints = "pending_points"
case time
}
///
init(totalPoints: Int = 0, availablePoints: Int = 0, withdrawnPoints: Int = 0, pendingPoints: Int = 0, time: String = "") {
self.totalPoints = totalPoints
self.availablePoints = availablePoints
self.withdrawnPoints = withdrawnPoints
self.pendingPoints = pendingPoints
self.time = time
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
totalPoints = try container.decodeLossyInt(forKey: .totalPoints) ?? 0
availablePoints = try container.decodeLossyInt(forKey: .availablePoints) ?? 0
withdrawnPoints = try container.decodeLossyInt(forKey: .withdrawnPoints) ?? 0
pendingPoints = try container.decodeLossyInt(forKey: .pendingPoints) ?? 0
time = try container.decodeLossyString(forKey: .time)
}
}
///
struct PointWithdrawApplyRequest: Encodable {
let points: Int
let remark: String
}
///
struct PointWithdrawListRequest: Encodable {
let status: Int?
let page: Int
let pageSize: Int
enum CodingKeys: String, CodingKey {
case status
case page
case pageSize = "page_size"
}
}
///
struct PointWithdrawListResponse: Decodable, Equatable {
let total: Int
let list: [PointWithdrawItem]
enum CodingKeys: String, CodingKey {
case total
case list
}
///
init(total: Int = 0, list: [PointWithdrawItem] = []) {
self.total = total
self.list = list
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([PointWithdrawItem].self, forKey: .list) ?? []
}
}
///
struct PointWithdrawItem: Decodable, Equatable, Identifiable {
let id: Int
let points: Int
let amount: Double
let status: Int
let createdAt: String
enum CodingKeys: String, CodingKey {
case id
case points
case amount
case status
case createdAt = "created_at"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
points = try container.decodeLossyInt(forKey: .points) ?? 0
amount = try container.decodeLossyDouble(forKey: .amount) ?? 0
status = try container.decodeLossyInt(forKey: .status) ?? 0
createdAt = try container.decodeLossyString(forKey: .createdAt)
}
}
///
enum WalletRoute: Hashable, Identifiable {
case withdrawApply
case bankCardSettings
case pointsRedemption
case realNameAuth
var id: String {
switch self {
case .withdrawApply: "withdrawApply"
case .bankCardSettings: "bankCardSettings"
case .pointsRedemption: "pointsRedemption"
case .realNameAuth: "realNameAuth"
}
}
}
///
enum WalletWithdrawDecision: Equatable {
case route(WalletRoute)
case message(String)
}
///
enum WalletDateFilter: String, CaseIterable, Identifiable {
case last7
case last30
case last180
var id: String { rawValue }
///
var title: String {
switch self {
case .last7: "近7日"
case .last30: "近30日"
case .last180: "近180日"
}
}
///
func range(today: Date = Date(), calendar: Calendar = .current) -> (start: String, end: String) {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.dateFormat = "yyyy-MM-dd"
let days: Int
switch self {
case .last7:
days = 6
case .last30:
days = 29
case .last180:
days = 179
}
let startDate = calendar.date(byAdding: .day, value: -days, to: today) ?? today
return (formatter.string(from: startDate), formatter.string(from: today))
}
}
private extension KeyedDecodingContainer {
///
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 value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(value)
}
return ""
}
///
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
///
func decodeLossyDouble(forKey key: Key) throws -> Double? {
if let value = try decodeIfPresent(Double.self, forKey: key) {
return value
}
if let value = try decodeIfPresent(Int.self, forKey: key) {
return Double(value)
}
if let value = try decodeIfPresent(String.self, forKey: key) {
return Double(value.trimmingCharacters(in: .whitespacesAndNewlines))
}
return nil
}
}

View File

@ -0,0 +1,538 @@
//
// WalletViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import Observation
@MainActor
/// 便
protocol WalletRealNameServing {
///
func realNameInfo() async throws -> RealNameInfoResponse
}
extension ProfileAPI: WalletRealNameServing {}
/// Tab
enum WalletLedgerTab: String, CaseIterable, Identifiable {
case earnings
case withdraws
var id: String { rawValue }
/// Tab
var title: String {
switch self {
case .earnings: "收益明细"
case .withdraws: "提现记录"
}
}
}
@MainActor
@Observable
/// ViewModel
final class WalletViewModel {
var summary: WalletSummaryResponse?
var pointsOverview: PointOverviewResponse?
var selectedTab: WalletLedgerTab = .earnings
var selectedFilter: WalletDateFilter = .last30
var earningsGroups: [WalletEarningDetailGroup] = []
var withdrawRecords: [WalletWithdrawRecord] = []
var isLoadingSummary = false
var isLoadingList = false
var isCheckingWithdraw = false
var errorMessage: String?
private let pageSize = 20
private var earningsPage = 1
private var earningsTotal = 0
private var withdrawPage = 1
private var withdrawTotal = 0
///
var withdrawableText: String {
"¥ \(Self.moneyText(summary?.amountWithdrawable))"
}
///
var totalAmountText: String {
"¥ \(Self.moneyText(summary?.amountTotal))"
}
///
var currentBalanceText: String {
"¥ \(Self.moneyText(summary?.amountCurrentBalance))"
}
///
var canLoadMoreEarnings: Bool {
earningsGroups.flatMap(\.items).count < earningsTotal
}
///
var canLoadMoreWithdraws: Bool {
withdrawRecords.count < withdrawTotal
}
///
func loadInitial(api: WalletServing, staffId: Int?) async {
isLoadingSummary = true
errorMessage = nil
async let summaryResult = api.walletSummary(type: 0)
async let pointsResult: PointOverviewResponse? = {
guard let staffId else { return nil }
return try? await api.pointOverview(staffId: staffId)
}()
do {
summary = try await summaryResult
pointsOverview = await pointsResult
} catch {
errorMessage = error.localizedDescription
}
isLoadingSummary = false
await reloadList(api: api)
}
/// Tab
func reloadList(api: WalletServing) async {
switch selectedTab {
case .earnings:
earningsPage = 1
await loadEarnings(api: api, reset: true)
case .withdraws:
withdrawPage = 1
await loadWithdraws(api: api, reset: true)
}
}
/// Tab
func selectTab(_ tab: WalletLedgerTab, api: WalletServing) async {
guard selectedTab != tab else { return }
selectedTab = tab
await reloadList(api: api)
}
///
func selectFilter(_ filter: WalletDateFilter, api: WalletServing) async {
guard selectedFilter != filter else { return }
selectedFilter = filter
if selectedTab == .earnings {
await reloadList(api: api)
}
}
///
func loadMore(api: WalletServing) async {
switch selectedTab {
case .earnings where canLoadMoreEarnings:
earningsPage += 1
await loadEarnings(api: api, reset: false)
case .withdraws where canLoadMoreWithdraws:
withdrawPage += 1
await loadWithdraws(api: api, reset: false)
default:
break
}
}
///
func resolveWithdrawDecision(profileAPI: WalletRealNameServing, walletAPI: WalletServing) async -> WalletWithdrawDecision {
isCheckingWithdraw = true
defer { isCheckingWithdraw = false }
do {
let realName = try await profileAPI.realNameInfo().realNameInfo
guard let realName else {
return .route(.realNameAuth)
}
switch realName.auditStatus {
case 2:
let bankCard = try await walletAPI.bankCardInfo().bankCard
guard let bankCard else {
return .route(.bankCardSettings)
}
if bankCard.auditStatus == 2 {
return .route(.withdrawApply)
}
if bankCard.auditStatus == 3 {
return .route(.bankCardSettings)
}
return .message("银行卡审核中,请审核通过后再试")
case 3:
return .route(.realNameAuth)
default:
return .message("实名认证审核中,请审核通过后再试")
}
} catch {
return .message(error.localizedDescription)
}
}
///
private func loadEarnings(api: WalletServing, reset: Bool) async {
isLoadingList = true
errorMessage = nil
defer { isLoadingList = false }
let range = selectedFilter.range()
do {
let response = try await api.walletEarningDetail(
startDate: range.start,
endDate: range.end,
page: earningsPage,
pageSize: pageSize
)
earningsTotal = response.total
earningsGroups = reset ? response.list : earningsGroups + response.list
} catch {
if !reset { earningsPage = max(1, earningsPage - 1) }
if reset { earningsGroups = [] }
errorMessage = error.localizedDescription
}
}
///
private func loadWithdraws(api: WalletServing, reset: Bool) async {
isLoadingList = true
errorMessage = nil
defer { isLoadingList = false }
do {
let response = try await api.walletWithdrawList(page: withdrawPage, pageSize: pageSize)
withdrawTotal = response.total
withdrawRecords = reset ? response.item : withdrawRecords + response.item
} catch {
if !reset { withdrawPage = max(1, withdrawPage - 1) }
if reset { withdrawRecords = [] }
errorMessage = error.localizedDescription
}
}
/// 0.00
static func moneyText(_ rawValue: String?) -> String {
let text = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let decimal = Decimal(string: text), decimal > 0 else {
return "0.00"
}
return NSDecimalNumber(decimal: decimal).stringValue
}
}
@MainActor
@Observable
/// ViewModel
final class WithdrawApplyViewModel {
var info: WithdrawInfoResponse?
var amountText = ""
var smsCode = ""
var isLoading = false
var isSubmitting = false
var smsCountdown = 0
var errorMessage: String?
///
func load(api: WalletServing) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
info = try await api.withdrawInfo()
} catch {
errorMessage = error.localizedDescription
}
}
///
func sendSms(api: WalletServing) async {
guard smsCountdown == 0 else { return }
do {
try await api.withdrawSendSms()
smsCountdown = 60
} catch {
errorMessage = error.localizedDescription
}
}
///
func submit(api: WalletServing) async -> Bool {
guard !isSubmitting else { return false }
guard let amount = PaymentCollectionViewModel.normalizedMoney(amountText) else {
errorMessage = "请输入有效提现金额"
return false
}
guard let info else {
errorMessage = "提现信息未加载"
return false
}
guard (Decimal(string: amount) ?? 0) <= (Decimal(string: info.amountWithdrawable) ?? 0) else {
errorMessage = "提现金额不能超过可提现金额"
return false
}
guard !smsCode.walletTrimmed.isEmpty else {
errorMessage = "请输入短信验证码"
return false
}
isSubmitting = true
defer { isSubmitting = false }
do {
try await api.withdrawApply(amount: amount, smsCode: smsCode.walletTrimmed)
amountText = ""
smsCode = ""
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
}
@MainActor
@Observable
/// ViewModel
final class WithdrawalSettingsViewModel {
var bankCard: WalletBankCardInfo?
var banks: [String] = []
var areas: [AreaNode] = []
var realName = ""
var cardNumber = ""
var bankName = ""
var branchName = ""
var provinceCode = ""
var cityCode = ""
var smsCode = ""
var frontImageData: Data?
var backImageData: Data?
var frontImageURL = ""
var backImageURL = ""
var uploadProgress = 0
var isLoading = false
var isSubmitting = false
var smsCountdown = 0
var errorMessage: String?
///
func load(api: WalletServing) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
async let bankCardResult = api.bankCardInfo()
async let bankListResult = api.bankList()
async let areaResult = api.areas()
do {
let (cardResponse, bankResponse, areaResponse) = try await (bankCardResult, bankListResult, areaResult)
bankCard = cardResponse.bankCard
banks = bankResponse.banks
areas = areaResponse
apply(cardResponse.bankCard)
} catch {
errorMessage = error.localizedDescription
}
}
///
func sendSms(api: WalletServing) async {
guard smsCountdown == 0 else { return }
do {
try await api.bankCardVerifyCode()
smsCountdown = 60
} catch {
errorMessage = error.localizedDescription
}
}
///
func submit(api: WalletServing, uploader: OSSUploadServing, scenicId: Int?) async -> Bool {
guard !isSubmitting else { return false }
guard let scenicId else {
errorMessage = "请先选择景区"
return false
}
guard validateForm() else { return false }
isSubmitting = true
uploadProgress = 0
defer { isSubmitting = false }
do {
let frontURL = try await resolvedImageURL(
currentURL: frontImageURL,
data: frontImageData,
fileName: "bank_card_front.jpg",
uploader: uploader,
scenicId: scenicId
)
let backURL = try await resolvedImageURL(
currentURL: backImageURL,
data: backImageData,
fileName: "bank_card_back.jpg",
uploader: uploader,
scenicId: scenicId
)
let request = UpdateBankInfoRequest(
realName: realName.walletTrimmed,
cardNumber: cardNumber.walletTrimmed,
bankName: bankName.walletTrimmed,
branchName: branchName.walletTrimmed,
frontUrl: frontURL,
backUrl: backURL,
provinceCode: provinceCode.walletTrimmed,
cityCode: cityCode.walletTrimmed,
smsVerifyCode: smsCode.walletTrimmed
)
try await api.updateBankInfo(request)
frontImageURL = frontURL
backImageURL = backURL
frontImageData = nil
backImageData = nil
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
///
private func apply(_ info: WalletBankCardInfo?) {
guard let info else { return }
realName = info.realName
cardNumber = info.cardNumber
bankName = info.bankName
branchName = info.branchName
provinceCode = info.provinceCode ?? ""
cityCode = info.cityCode ?? ""
frontImageURL = info.frontUrl ?? ""
backImageURL = info.backUrl ?? ""
}
///
private func validateForm() -> Bool {
if realName.walletTrimmed.isEmpty { errorMessage = "请输入持卡人姓名"; return false }
if cardNumber.walletTrimmed.isEmpty { errorMessage = "请输入银行卡号"; return false }
if bankName.walletTrimmed.isEmpty { errorMessage = "请选择开户银行"; return false }
if branchName.walletTrimmed.isEmpty { errorMessage = "请输入开户支行"; return false }
if provinceCode.walletTrimmed.isEmpty || cityCode.walletTrimmed.isEmpty { errorMessage = "请选择开户地区"; return false }
if frontImageURL.walletTrimmed.isEmpty && frontImageData == nil { errorMessage = "请选择银行卡正面照片"; return false }
if backImageURL.walletTrimmed.isEmpty && backImageData == nil { errorMessage = "请选择银行卡反面照片"; return false }
if smsCode.walletTrimmed.isEmpty { errorMessage = "请输入短信验证码"; return false }
return true
}
/// URL OSS
private func resolvedImageURL(
currentURL: String,
data: Data?,
fileName: String,
uploader: OSSUploadServing,
scenicId: Int
) async throws -> String {
guard let data else { return currentURL }
return try await uploader.uploadBankCardImage(data: data, fileName: fileName, scenicId: scenicId) { [weak self] progress in
self?.uploadProgress = progress
}
}
}
@MainActor
@Observable
/// ViewModel
final class PointsRedemptionViewModel {
var overview = PointOverviewResponse()
var records: [PointWithdrawItem] = []
var pointsText = ""
var isLoading = false
var isSubmitting = false
var errorMessage: String?
private let pageSize = 20
private var page = 1
private var total = 0
///
var canLoadMore: Bool {
records.count < total
}
///
func load(api: WalletServing, staffId: Int?) async {
guard let staffId else {
errorMessage = "缺少账号信息"
return
}
isLoading = true
errorMessage = nil
page = 1
defer { isLoading = false }
do {
async let overviewResult = api.pointOverview(staffId: staffId)
async let listResult = api.pointWithdrawList(status: nil, page: page, pageSize: pageSize)
overview = try await overviewResult
let list = try await listResult
total = list.total
records = list.list
} catch {
errorMessage = error.localizedDescription
}
}
///
func loadMore(api: WalletServing) async {
guard canLoadMore else { return }
page += 1
do {
let response = try await api.pointWithdrawList(status: nil, page: page, pageSize: pageSize)
total = response.total
records += response.list
} catch {
page = max(1, page - 1)
errorMessage = error.localizedDescription
}
}
///
func fillAllPoints() {
pointsText = String(overview.withdrawnPoints)
}
///
func submit(api: WalletServing, staffId: Int?) async -> Bool {
guard !isSubmitting else { return false }
guard let points = Int(pointsText.walletTrimmed), points > 0 else {
errorMessage = "请输入有效兑换积分"
return false
}
guard points <= overview.withdrawnPoints else {
errorMessage = "兑换积分不能超过可兑换积分"
return false
}
guard let staffId else {
errorMessage = "缺少账号信息"
return false
}
isSubmitting = true
defer { isSubmitting = false }
do {
try await api.pointWithdrawApply(points: points, remark: "积分提现申请")
pointsText = ""
await load(api: api, staffId: staffId)
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
}
private extension String {
/// 使 fileprivate
var walletTrimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@ -0,0 +1,531 @@
//
// WalletView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Combine
import PhotosUI
import SwiftUI
///
struct WalletView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(WalletAPI.self) private var walletAPI
@Environment(ProfileAPI.self) private var profileAPI
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = WalletViewModel()
@State private var route: WalletRoute?
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
summaryCard
walletActions
ledgerControls
ledgerList
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.pageVertical)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("我的钱包")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(item: $route) { route in
switch route {
case .withdrawApply:
WithdrawApplyView()
case .bankCardSettings:
WithdrawalSettingsView()
case .pointsRedemption:
PointsRedemptionView()
case .realNameAuth:
RealNameAuthView()
}
}
.task {
await viewModel.loadInitial(api: walletAPI, staffId: staffId)
}
.refreshable {
await viewModel.loadInitial(api: walletAPI, staffId: staffId)
}
.onChange(of: viewModel.errorMessage) { _, message in
if let message {
toastCenter.show(message)
}
}
}
/// staffId使 userId
private var staffId: Int? {
Int(accountContext.profile?.userId ?? "")
}
///
private var summaryCard: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
HStack {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text("可提现金额")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(.white.opacity(0.86))
Text(viewModel.withdrawableText)
.font(.system(size: AppMetrics.FontSize.largeTitle, weight: .bold))
.foregroundStyle(.white)
}
Spacer()
if viewModel.isLoadingSummary {
ProgressView()
.tint(.white)
}
}
HStack(spacing: AppMetrics.Spacing.medium) {
walletSummaryItem(title: "累计金额", value: viewModel.totalAmountText)
walletSummaryItem(title: "当前余额", value: viewModel.currentBalanceText)
walletSummaryItem(title: "可兑换积分", value: "\(viewModel.pointsOverview?.withdrawnPoints ?? 0)")
}
}
.padding(AppMetrics.Spacing.large)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
}
///
private var walletActions: some View {
HStack(spacing: AppMetrics.Spacing.small) {
walletActionButton(title: "提现", icon: "banknote") {
Task {
let decision = await viewModel.resolveWithdrawDecision(profileAPI: profileAPI, walletAPI: walletAPI)
switch decision {
case .route(let nextRoute):
route = nextRoute
case .message(let message):
toastCenter.show(message)
}
}
}
walletActionButton(title: "银行卡", icon: "creditcard") {
route = .bankCardSettings
}
walletActionButton(title: "积分兑换", icon: "giftcard") {
route = .pointsRedemption
}
}
}
///
private var ledgerControls: some View {
VStack(spacing: AppMetrics.Spacing.small) {
Picker("明细类型", selection: Binding(
get: { viewModel.selectedTab },
set: { tab in Task { await viewModel.selectTab(tab, api: walletAPI) } }
)) {
ForEach(WalletLedgerTab.allCases) { tab in
Text(tab.title).tag(tab)
}
}
.pickerStyle(.segmented)
if viewModel.selectedTab == .earnings {
HStack {
ForEach(WalletDateFilter.allCases) { filter in
Button {
Task { await viewModel.selectFilter(filter, api: walletAPI) }
} label: {
Text(filter.title)
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
.foregroundStyle(viewModel.selectedFilter == filter ? .white : AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xSmall)
.background(viewModel.selectedFilter == filter ? AppDesign.primary : AppDesign.primarySoft, in: Capsule())
}
}
Spacer()
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
///
@ViewBuilder
private var ledgerList: some View {
if viewModel.selectedTab == .earnings {
LazyVStack(spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.earningsGroups) { group in
walletSection(title: group.date, subtitle: "¥\(group.dayAmount) / \(group.dayPoints)") {
ForEach(group.items) { item in
ledgerRow(title: item.typeLabel.isEmpty ? "收益" : item.typeLabel, subtitle: item.createdAt, amount: "\(item.amount)", extra: item.orderNumberSuffix)
}
}
}
loadMoreButton(enabled: viewModel.canLoadMoreEarnings)
}
} else {
LazyVStack(spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.withdrawRecords) { record in
ledgerRow(title: record.statusLabel, subtitle: record.createdAt, amount: "\(record.amount)", extra: record.expectedAt ?? "")
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
loadMoreButton(enabled: viewModel.canLoadMoreWithdraws)
}
}
}
///
private func walletSummaryItem(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(.white.opacity(0.75))
Text(value)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(.white)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
///
private func walletActionButton(title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: icon)
.font(.system(size: 22, weight: .semibold))
Text(title)
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
}
.foregroundStyle(AppDesign.primary)
.frame(maxWidth: .infinity, minHeight: 76)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
}
///
private func walletSection<Content: View>(title: String, subtitle: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Spacer()
Text(subtitle)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
content()
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
///
private func ledgerRow(title: String, subtitle: String, amount: String, extra: String) -> some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text([subtitle, extra].filter { !$0.isEmpty }.joined(separator: " · "))
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Text(amount)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(amount.hasPrefix("+") ? AppDesign.success : AppDesign.textPrimary)
}
}
///
@ViewBuilder
private func loadMoreButton(enabled: Bool) -> some View {
if enabled {
Button {
Task { await viewModel.loadMore(api: walletAPI) }
} label: {
Text(viewModel.isLoadingList ? "加载中..." : "加载更多")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.frame(maxWidth: .infinity, minHeight: 44)
}
}
}
}
///
struct WithdrawApplyView: View {
@Environment(WalletAPI.self) private var walletAPI
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.dismiss) private var dismiss
@State private var viewModel = WithdrawApplyViewModel()
var body: some View {
Form {
if let info = viewModel.info {
Section("银行卡") {
Text("\(info.bankCard.bankName) \(info.bankCard.cardNumber)")
Text("可提现金额:¥\(WalletViewModel.moneyText(info.amountWithdrawable))")
}
}
Section("提现金额") {
TextField("请输入提现金额", text: $viewModel.amountText)
.keyboardType(.decimalPad)
HStack {
TextField("短信验证码", text: $viewModel.smsCode)
.keyboardType(.numberPad)
Button(viewModel.smsCountdown > 0 ? "\(viewModel.smsCountdown)s" : "获取验证码") {
Task { await viewModel.sendSms(api: walletAPI) }
}
.disabled(viewModel.smsCountdown > 0)
}
}
Button(viewModel.isSubmitting ? "提交中..." : "提交提现申请") {
Task {
if await viewModel.submit(api: walletAPI) {
toastCenter.show("提现申请已提交")
dismiss()
}
}
}
.disabled(viewModel.isSubmitting)
}
.navigationTitle("提现申请")
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load(api: walletAPI) }
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
if viewModel.smsCountdown > 0 {
viewModel.smsCountdown -= 1
}
}
.onChange(of: viewModel.errorMessage) { _, message in
if let message {
toastCenter.show(message)
}
}
}
}
/// 使 OSS
struct WithdrawalSettingsView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(WalletAPI.self) private var walletAPI
@Environment(OSSUploadService.self) private var ossUploadService
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.dismiss) private var dismiss
@State private var viewModel = WithdrawalSettingsViewModel()
@State private var frontItem: PhotosPickerItem?
@State private var backItem: PhotosPickerItem?
var body: some View {
Form {
if let bankCard = viewModel.bankCard {
Section("审核状态") {
Text(bankCard.auditStatusLabel.isEmpty ? "待提交" : bankCard.auditStatusLabel)
if let reason = bankCard.rejectReason, !reason.isEmpty {
Text(reason).foregroundStyle(AppDesign.warning)
}
}
}
Section("基础信息") {
TextField("持卡人姓名", text: $viewModel.realName)
TextField("银行卡号", text: $viewModel.cardNumber)
.keyboardType(.numberPad)
Picker("开户银行", selection: $viewModel.bankName) {
Text("请选择").tag("")
ForEach(viewModel.banks, id: \.self) { bank in
Text(bank).tag(bank)
}
}
TextField("开户支行", text: $viewModel.branchName)
Picker("省份", selection: $viewModel.provinceCode) {
Text("请选择").tag("")
ForEach(viewModel.areas) { area in
Text(area.name).tag(area.code)
}
}
Picker("城市", selection: $viewModel.cityCode) {
Text("请选择").tag("")
ForEach(selectedProvinceChildren) { city in
Text(city.name).tag(city.code)
}
}
}
Section("银行卡照片") {
bankCardImagePicker(title: "银行卡正面", item: $frontItem, data: viewModel.frontImageData, url: viewModel.frontImageURL)
bankCardImagePicker(title: "银行卡反面", item: $backItem, data: viewModel.backImageData, url: viewModel.backImageURL)
if viewModel.uploadProgress > 0 && viewModel.uploadProgress < 100 {
ProgressView(value: Double(viewModel.uploadProgress), total: 100)
}
}
Section("短信验证码") {
HStack {
TextField("请输入短信验证码", text: $viewModel.smsCode)
.keyboardType(.numberPad)
Button(viewModel.smsCountdown > 0 ? "\(viewModel.smsCountdown)s" : "获取验证码") {
Task { await viewModel.sendSms(api: walletAPI) }
}
.disabled(viewModel.smsCountdown > 0)
}
}
Button(viewModel.isSubmitting ? "提交中..." : "提交银行卡资料") {
Task {
if await viewModel.submit(api: walletAPI, uploader: ossUploadService, scenicId: accountContext.currentScenic?.id) {
toastCenter.show("银行卡资料已提交")
dismiss()
}
}
}
.disabled(viewModel.isSubmitting)
}
.navigationTitle("银行卡设置")
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load(api: walletAPI) }
.onChange(of: frontItem) { _, item in
Task { viewModel.frontImageData = try? await item?.loadTransferable(type: Data.self) }
}
.onChange(of: backItem) { _, item in
Task { viewModel.backImageData = try? await item?.loadTransferable(type: Data.self) }
}
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
if viewModel.smsCountdown > 0 {
viewModel.smsCountdown -= 1
}
}
.onChange(of: viewModel.errorMessage) { _, message in
if let message {
toastCenter.show(message)
}
}
}
///
private var selectedProvinceChildren: [AreaNode] {
viewModel.areas.first { $0.code == viewModel.provinceCode }?.children ?? []
}
///
private func bankCardImagePicker(title: String, item: Binding<PhotosPickerItem?>, data: Data?, url: String) -> some View {
PhotosPicker(selection: item, matching: .images) {
HStack(spacing: AppMetrics.Spacing.small) {
bankCardPreview(data: data, url: url)
.frame(width: 88, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(title)
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(AppDesign.placeholder)
}
}
}
///
@ViewBuilder
private func bankCardPreview(data: Data?, url: String) -> some View {
if let data, let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
RemoteImage(urlString: url) {
Rectangle()
.fill(AppDesign.primarySoft)
.overlay {
Image(systemName: "creditcard")
.foregroundStyle(AppDesign.primary)
}
}
}
}
}
///
struct PointsRedemptionView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(WalletAPI.self) private var walletAPI
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = PointsRedemptionViewModel()
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("可兑换积分")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
Text("\(viewModel.overview.withdrawnPoints)")
.font(.system(size: AppMetrics.FontSize.largeTitle, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
HStack {
TextField("请输入兑换积分", text: $viewModel.pointsText)
.keyboardType(.numberPad)
Button("全部") {
viewModel.fillAllPoints()
}
}
.appInputFieldStyle(cornerRadius: 8, minHeight: AppMetrics.ControlSize.inputHeight)
Button(viewModel.isSubmitting ? "提交中..." : "提交兑换") {
Task {
if await viewModel.submit(api: walletAPI, staffId: staffId) {
toastCenter.show("积分兑换申请已提交")
}
}
}
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight)
.foregroundStyle(.white)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
LazyVStack(spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.records) { item in
HStack {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
Text("\(item.points) 积分")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Text(item.createdAt)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Text("¥\(item.amount, specifier: "%.2f")")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
if viewModel.canLoadMore {
Button("加载更多") {
Task { await viewModel.loadMore(api: walletAPI) }
}
}
}
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.pageVertical)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("积分兑换")
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load(api: walletAPI, staffId: staffId) }
.onChange(of: viewModel.errorMessage) { _, message in
if let message {
toastCenter.show(message)
}
}
}
/// staffId
private var staffId: Int? {
Int(accountContext.profile?.userId ?? "")
}
}

View File

@ -0,0 +1,31 @@
# 钱包模块
## 模块职责
`Features/Wallet` 承接首页 `wallet` 权限入口,负责个人钱包首页、收益明细、提现记录、提现申请、银行卡设置和积分兑换。
## 代码结构
- `WalletAPI`:封装钱包、提现、银行卡、地区、积分相关接口。
- `WalletViewModel`:管理钱包汇总、收益明细分页、提现记录分页和提现资格判断。
- `WithdrawApplyViewModel`:管理提现信息、金额校验、短信验证码和提现提交。
- `WithdrawalSettingsViewModel`:管理银行卡资料、图片上传和银行卡更新提交。
- `PointsRedemptionViewModel`:管理积分概览、兑换记录和兑换申请。
- `WalletView`:钱包首页和钱包内部二级路由入口。
## 提现资格流
点击提现后先查询实名认证:
1. 未提交或驳回:进入已迁移的 `RealNameAuthView`
2. 审核中:提示等待实名认证审核通过。
3. 实名认证通过:查询银行卡资料。
4. 无银行卡或银行卡驳回:进入 `WithdrawalSettingsView`
5. 银行卡审核中:提示等待审核。
6. 银行卡通过:进入 `WithdrawApplyView`
## 上传与缓存边界
银行卡正反面照片使用 `PhotosPicker` 选择后,通过 `OSSUploadService.uploadBankCardImage` 上传到 `bank_card/yyyyMMdd/scenicId/...` 路径,再把最终 URL 提交给服务端。
钱包数据只保存在各 ViewModel 内存中。短信验证码、OSS STS、提现表单、银行卡图片 Data 和积分兑换表单均不落盘。