新增支付与钱包模块,并接入首页路由
引入真实收款与钱包页面替换首页占位入口,通过 RootView 接入 API,并支持银行卡 OSS 上传及二维码保存到相册。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -33,4 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
||||
|
||||
## 后续迁移
|
||||
|
||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,本轮保留占位,后续应作为独立功能迁移。
|
||||
|
||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||
|
||||
@ -24,6 +24,8 @@ enum HomeRoute: Hashable {
|
||||
case scenicSelection
|
||||
case moreFunctions
|
||||
case settings
|
||||
case paymentCollection
|
||||
case wallet
|
||||
case modulePlaceholder(uri: String, title: String)
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
55
suixinkan/Features/Payment/API/PaymentAPI.swift
Normal file
55
suixinkan/Features/Payment/API/PaymentAPI.swift
Normal 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 {}
|
||||
182
suixinkan/Features/Payment/Models/PaymentModels.swift
Normal file
182
suixinkan/Features/Payment/Models/PaymentModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
25
suixinkan/Features/Payment/Payment.md
Normal file
25
suixinkan/Features/Payment/Payment.md
Normal 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。
|
||||
246
suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift
Normal file
246
suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
325
suixinkan/Features/Payment/Views/PaymentCollectionView.swift
Normal file
325
suixinkan/Features/Payment/Views/PaymentCollectionView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
204
suixinkan/Features/Wallet/API/WalletAPI.swift
Normal file
204
suixinkan/Features/Wallet/API/WalletAPI.swift
Normal 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 {}
|
||||
604
suixinkan/Features/Wallet/Models/WalletModels.swift
Normal file
604
suixinkan/Features/Wallet/Models/WalletModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
538
suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift
Normal file
538
suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
531
suixinkan/Features/Wallet/Views/WalletView.swift
Normal file
531
suixinkan/Features/Wallet/Views/WalletView.swift
Normal 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 ?? "")
|
||||
}
|
||||
}
|
||||
31
suixinkan/Features/Wallet/Wallet.md
Normal file
31
suixinkan/Features/Wallet/Wallet.md
Normal 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 和积分兑换表单均不落盘。
|
||||
Reference in New Issue
Block a user