新增支付与钱包模块,并接入首页路由

引入真实收款与钱包页面替换首页占位入口,通过 RootView 接入 API,并支持银行卡 OSS 上传及二维码保存到相册。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 17:46:01 +08:00
parent d02fd2c069
commit 5d4613f317
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 和积分兑换表单均不落盘。