Add Payment and Wallet modules with home routing integration.
Introduce real payment collection and wallet screens to replace home menu placeholders, wire APIs through RootView, and support bank card OSS uploads plus QR code saving to the photo library. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -345,6 +345,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单";
|
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@ -383,6 +384,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单";
|
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|||||||
@ -23,6 +23,8 @@ struct RootView: View {
|
|||||||
@State private var accountContextAPI: AccountContextAPI
|
@State private var accountContextAPI: AccountContextAPI
|
||||||
@State private var ordersAPI: OrdersAPI
|
@State private var ordersAPI: OrdersAPI
|
||||||
@State private var statisticsAPI: StatisticsAPI
|
@State private var statisticsAPI: StatisticsAPI
|
||||||
|
@State private var paymentAPI: PaymentAPI
|
||||||
|
@State private var walletAPI: WalletAPI
|
||||||
@State private var authSessionCoordinator: AuthSessionCoordinator
|
@State private var authSessionCoordinator: AuthSessionCoordinator
|
||||||
@State private var sessionBootstrapper: SessionBootstrapper
|
@State private var sessionBootstrapper: SessionBootstrapper
|
||||||
|
|
||||||
@ -40,6 +42,8 @@ struct RootView: View {
|
|||||||
_accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient))
|
_accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient))
|
||||||
_ordersAPI = State(initialValue: OrdersAPI(client: apiClient))
|
_ordersAPI = State(initialValue: OrdersAPI(client: apiClient))
|
||||||
_statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient))
|
_statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient))
|
||||||
|
_paymentAPI = State(initialValue: PaymentAPI(client: apiClient))
|
||||||
|
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
|
||||||
_authSessionCoordinator = State(
|
_authSessionCoordinator = State(
|
||||||
initialValue: AuthSessionCoordinator(
|
initialValue: AuthSessionCoordinator(
|
||||||
tokenStore: tokenStore,
|
tokenStore: tokenStore,
|
||||||
@ -72,6 +76,8 @@ struct RootView: View {
|
|||||||
.environment(accountContextAPI)
|
.environment(accountContextAPI)
|
||||||
.environment(ordersAPI)
|
.environment(ordersAPI)
|
||||||
.environment(statisticsAPI)
|
.environment(statisticsAPI)
|
||||||
|
.environment(paymentAPI)
|
||||||
|
.environment(walletAPI)
|
||||||
.environment(authSessionCoordinator)
|
.environment(authSessionCoordinator)
|
||||||
.task {
|
.task {
|
||||||
apiClient.bindAuthTokenProvider { appSession.token }
|
apiClient.bindAuthTokenProvider { appSession.token }
|
||||||
|
|||||||
@ -36,6 +36,9 @@ protocol OSSUploadServing {
|
|||||||
|
|
||||||
/// 上传景区申请图片,并返回可访问的 OSS 文件 URL。
|
/// 上传景区申请图片,并返回可访问的 OSS 文件 URL。
|
||||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||||
|
|
||||||
|
/// 上传银行卡照片,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -84,6 +87,11 @@ final class OSSUploadService {
|
|||||||
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "scenic_apply", onProgress: onProgress)
|
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "scenic_apply", onProgress: onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 上传银行卡照片,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
/// 执行通用 OSS 上传流程。
|
/// 执行通用 OSS 上传流程。
|
||||||
private func uploadFile(
|
private func uploadFile(
|
||||||
data: Data,
|
data: Data,
|
||||||
@ -188,6 +196,8 @@ enum OSSUploadPolicy {
|
|||||||
return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
case "real_name":
|
case "real_name":
|
||||||
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "bank_card":
|
||||||
|
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
default:
|
default:
|
||||||
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,4 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
|||||||
|
|
||||||
## 后续迁移
|
## 后续迁移
|
||||||
|
|
||||||
|
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,本轮保留占位,后续应作为独立功能迁移。
|
||||||
|
|
||||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||||
|
|||||||
@ -24,6 +24,8 @@ enum HomeRoute: Hashable {
|
|||||||
case scenicSelection
|
case scenicSelection
|
||||||
case moreFunctions
|
case moreFunctions
|
||||||
case settings
|
case settings
|
||||||
|
case paymentCollection
|
||||||
|
case wallet
|
||||||
case modulePlaceholder(uri: String, title: String)
|
case modulePlaceholder(uri: String, title: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -81,6 +81,10 @@ enum HomeMenuRouter {
|
|||||||
return .destination(.settings)
|
return .destination(.settings)
|
||||||
case "scenicselection":
|
case "scenicselection":
|
||||||
return .destination(.scenicSelection)
|
return .destination(.scenicSelection)
|
||||||
|
case "wallet":
|
||||||
|
return .destination(.wallet)
|
||||||
|
case "payment_collection", "payment_qr", "payment_code":
|
||||||
|
return .destination(.paymentCollection)
|
||||||
case "store", "more_functions":
|
case "store", "more_functions":
|
||||||
return .destination(.moreFunctions)
|
return .destination(.moreFunctions)
|
||||||
case "fly", "pilot_controller":
|
case "fly", "pilot_controller":
|
||||||
@ -89,13 +93,9 @@ enum HomeMenuRouter {
|
|||||||
title: title.isEmpty ? self.title(for: uri) : title,
|
title: title.isEmpty ? self.title(for: uri) : title,
|
||||||
reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。"
|
reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。"
|
||||||
)
|
)
|
||||||
case "wallet",
|
case "message_center",
|
||||||
"message_center",
|
|
||||||
"/scenic-queue",
|
"/scenic-queue",
|
||||||
"queue_management",
|
"queue_management",
|
||||||
"payment_collection",
|
|
||||||
"payment_qr",
|
|
||||||
"payment_code",
|
|
||||||
"deposit_order_detail",
|
"deposit_order_detail",
|
||||||
"deposit_order",
|
"deposit_order",
|
||||||
"deposit_order_shooting_info",
|
"deposit_order_shooting_info",
|
||||||
|
|||||||
@ -20,6 +20,10 @@ extension HomeRoute {
|
|||||||
HomeMoreFunctionsView()
|
HomeMoreFunctionsView()
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsCenterView()
|
SettingsCenterView()
|
||||||
|
case .paymentCollection:
|
||||||
|
PaymentCollectionView()
|
||||||
|
case .wallet:
|
||||||
|
WalletView()
|
||||||
case let .modulePlaceholder(uri, title):
|
case let .modulePlaceholder(uri, title):
|
||||||
HomeMigrationModuleView(title: title, uri: uri)
|
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 和积分兑换表单均不落盘。
|
||||||
@ -25,6 +25,10 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "space_settings", title: ""), .destination(.profileSpace))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "space_settings", title: ""), .destination(.profileSpace))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicselection", title: ""), .destination(.scenicSelection))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicselection", title: ""), .destination(.scenicSelection))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "store", title: ""), .destination(.moreFunctions))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "store", title: ""), .destination(.moreFunctions))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "wallet", title: ""), .destination(.wallet))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_collection", title: ""), .destination(.paymentCollection))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_qr", title: ""), .destination(.paymentCollection))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_code", title: ""), .destination(.paymentCollection))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试已知但未迁移的首页路由会进入安全占位。
|
/// 测试已知但未迁移的首页路由会进入安全占位。
|
||||||
@ -33,10 +37,6 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
HomeMenuRouter.resolve(uri: "task_management", title: ""),
|
HomeMenuRouter.resolve(uri: "task_management", title: ""),
|
||||||
.destination(.modulePlaceholder(uri: "task_management", title: "任务管理"))
|
.destination(.modulePlaceholder(uri: "task_management", title: "任务管理"))
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
|
||||||
HomeMenuRouter.resolve(uri: "payment_qr", title: ""),
|
|
||||||
.destination(.modulePlaceholder(uri: "payment_qr", title: "收款码"))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
||||||
|
|||||||
@ -14,6 +14,8 @@ final class NavigationRouterTests: XCTestCase {
|
|||||||
func testPushedRoutesHideTabBarByDefault() {
|
func testPushedRoutesHideTabBarByDefault() {
|
||||||
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
|
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
|
||||||
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
|
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
|
||||||
|
XCTAssertTrue(AppRoute.home(.paymentCollection).hidesTabBarWhenPushed)
|
||||||
|
XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed)
|
||||||
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
|
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
|
||||||
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed)
|
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed)
|
||||||
}
|
}
|
||||||
|
|||||||
108
suixinkanTests/PaymentViewModelTests.swift
Normal file
108
suixinkanTests/PaymentViewModelTests.swift
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
//
|
||||||
|
// PaymentViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 收款模块测试,覆盖收款码、动态二维码、轮询和记录分组。
|
||||||
|
final class PaymentViewModelTests: XCTestCase {
|
||||||
|
/// 测试无景区时清空二维码且不请求接口。
|
||||||
|
func testLoadPayCodeWithoutScenicClearsState() async {
|
||||||
|
let api = PaymentMockAPI()
|
||||||
|
let viewModel = PaymentCollectionViewModel()
|
||||||
|
viewModel.staticPayUrl = "old"
|
||||||
|
|
||||||
|
await viewModel.loadPayCode(api: api, scenicId: nil)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.staticPayUrl.isEmpty)
|
||||||
|
XCTAssertEqual(api.payCodeScenicIds, [])
|
||||||
|
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试收款码接口加载后生成当前二维码 URL。
|
||||||
|
func testLoadPayCodeUsesScenicIdAndAppliesStaticURL() async {
|
||||||
|
let api = PaymentMockAPI()
|
||||||
|
api.payCodeResponse = PayCodeResponse(staticPayUrl: "https://pay.example.com/static", dynamicPayUrl: "https://pay.example.com/dynamic")
|
||||||
|
let viewModel = PaymentCollectionViewModel()
|
||||||
|
|
||||||
|
await viewModel.loadPayCode(api: api, scenicId: 9)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.payCodeScenicIds, [9])
|
||||||
|
XCTAssertEqual(viewModel.currentPayUrl, "https://pay.example.com/static")
|
||||||
|
XCTAssertTrue(viewModel.hasStaticPayCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试设置金额会校验金额并生成动态收款 URL。
|
||||||
|
func testApplyDynamicAmountBuildsDynamicURL() async {
|
||||||
|
let viewModel = PaymentCollectionViewModel()
|
||||||
|
viewModel.dynamicPayUrl = "https://pay.example.com/dynamic?scenic_id=9"
|
||||||
|
viewModel.amountText = "12.345"
|
||||||
|
viewModel.remarkText = "茶水"
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.applyDynamicAmount())
|
||||||
|
XCTAssertTrue(viewModel.currentPayUrl.contains("amount=12.35"))
|
||||||
|
XCTAssertTrue(viewModel.currentPayUrl.contains("remark="))
|
||||||
|
XCTAssertEqual(viewModel.status, .waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试轮询命中新收款记录后进入成功态。
|
||||||
|
func testPollingFindsNewPaymentRecord() async {
|
||||||
|
let api = PaymentMockAPI()
|
||||||
|
api.recordResponses = [
|
||||||
|
PaymentCollectionRecordResponse(list: [
|
||||||
|
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00")
|
||||||
|
]),
|
||||||
|
PaymentCollectionRecordResponse(list: [
|
||||||
|
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00"),
|
||||||
|
PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01")
|
||||||
|
])
|
||||||
|
]
|
||||||
|
let viewModel = PaymentCollectionViewModel()
|
||||||
|
viewModel.amountText = "12.35"
|
||||||
|
await viewModel.primePaymentRecords(api: api, scenicId: 9)
|
||||||
|
|
||||||
|
await viewModel.pollUntilPaymentDetected(api: api, scenicId: 9, maxAttempts: 1, intervalNanoseconds: 0)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.status, .success(PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试后端缺少日汇总时能从列表兜底生成分组。
|
||||||
|
func testRecordGroupsFallbackFromList() {
|
||||||
|
let response = PaymentCollectionRecordResponse(list: [
|
||||||
|
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-21", createTime: "10:00"),
|
||||||
|
PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.00", createDate: "2026-06-21", createTime: "10:01")
|
||||||
|
])
|
||||||
|
|
||||||
|
let groups = PaymentCollectionRecordViewModel.makeGroups(from: response)
|
||||||
|
|
||||||
|
XCTAssertEqual(groups.count, 1)
|
||||||
|
XCTAssertEqual(groups[0].analyse.orderCount, 2)
|
||||||
|
XCTAssertEqual(groups[0].analyse.orderAmountSum, "20")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 收款 API 测试替身。
|
||||||
|
private final class PaymentMockAPI: PaymentServing {
|
||||||
|
var payCodeResponse = PayCodeResponse()
|
||||||
|
var recordResponses: [PaymentCollectionRecordResponse] = []
|
||||||
|
private(set) var payCodeScenicIds: [Int] = []
|
||||||
|
private(set) var recordScenicIds: [Int] = []
|
||||||
|
|
||||||
|
/// 记录景区 ID 并返回收款码响应。
|
||||||
|
func payCode(scenicId: Int) async throws -> PayCodeResponse {
|
||||||
|
payCodeScenicIds.append(scenicId)
|
||||||
|
return payCodeResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录景区 ID 并返回下一份收款记录响应。
|
||||||
|
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse {
|
||||||
|
recordScenicIds.append(scenicId)
|
||||||
|
return recordResponses.isEmpty ? PaymentCollectionRecordResponse() : recordResponses.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -370,6 +370,11 @@ private final class MockOSSUploader: OSSUploadServing {
|
|||||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
avatarURL
|
avatarURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 模拟上传银行卡照片。
|
||||||
|
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
avatarURL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成测试用 JPEG 图片数据。
|
/// 生成测试用 JPEG 图片数据。
|
||||||
|
|||||||
@ -43,6 +43,10 @@ final class UploadTests: XCTestCase {
|
|||||||
OSSUploadPolicy.objectKey(fileName: "video.mp4", scenicId: 9, moduleType: "task_upload", date: date, uuid: uuid, timeZone: timeZone),
|
OSSUploadPolicy.objectKey(fileName: "video.mp4", scenicId: 9, moduleType: "task_upload", date: date, uuid: uuid, timeZone: timeZone),
|
||||||
"task_upload/20260101/9/123456781234123412341234567890AB_video.mp4"
|
"task_upload/20260101/9/123456781234123412341234567890AB_video.mp4"
|
||||||
)
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
OSSUploadPolicy.objectKey(fileName: "bank.jpg", scenicId: 9, moduleType: "bank_card", date: date, uuid: uuid, timeZone: timeZone),
|
||||||
|
"bank_card/20260101/9/123456781234123412341234567890AB_bank.jpg"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试 MIME 类型推断和 URL 拼接规则。
|
/// 测试 MIME 类型推断和 URL 拼接规则。
|
||||||
|
|||||||
222
suixinkanTests/WalletViewModelTests.swift
Normal file
222
suixinkanTests/WalletViewModelTests.swift
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
//
|
||||||
|
// WalletViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 钱包模块测试,覆盖首页、提现资格、银行卡上传和积分兑换。
|
||||||
|
final class WalletViewModelTests: XCTestCase {
|
||||||
|
/// 测试钱包首页会加载汇总和收益明细。
|
||||||
|
func testWalletInitialLoadFetchesSummaryAndEarnings() async {
|
||||||
|
let api = WalletMockAPI()
|
||||||
|
api.summary = WalletSummaryResponse(amountTotal: "100", amountCurrentBalance: "80", amountWithdrawable: "60")
|
||||||
|
api.earningResponses = [WalletEarningDetailResponse(total: 1, list: [WalletEarningDetailGroup(date: "2026-06-22")])]
|
||||||
|
let viewModel = WalletViewModel()
|
||||||
|
|
||||||
|
await viewModel.loadInitial(api: api, staffId: 7)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.withdrawableText, "¥ 60")
|
||||||
|
XCTAssertEqual(api.pointOverviewStaffIds, [7])
|
||||||
|
XCTAssertEqual(api.earningQueries.count, 1)
|
||||||
|
XCTAssertEqual(viewModel.earningsGroups.map(\.date), ["2026-06-22"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试切换到提现记录 Tab 会请求提现分页。
|
||||||
|
func testSelectWithdrawTabLoadsWithdrawRecords() async {
|
||||||
|
let api = WalletMockAPI()
|
||||||
|
let viewModel = WalletViewModel()
|
||||||
|
|
||||||
|
await viewModel.selectTab(.withdraws, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.withdrawListPages, [1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试提现资格流根据实名认证和银行卡状态进入正确页面。
|
||||||
|
func testWithdrawDecisionRoutesByRealNameAndBankCardState() async throws {
|
||||||
|
let profile = WalletRealNameMockAPI()
|
||||||
|
let wallet = WalletMockAPI()
|
||||||
|
let viewModel = WalletViewModel()
|
||||||
|
|
||||||
|
profile.response = RealNameInfoResponse(realNameInfo: nil)
|
||||||
|
var decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
|
||||||
|
XCTAssertEqual(decision, .route(.realNameAuth))
|
||||||
|
|
||||||
|
profile.response = try Self.realNameInfo(status: 2)
|
||||||
|
wallet.bankCardResponse = BankCardInfoResponse(bankCard: nil)
|
||||||
|
decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
|
||||||
|
XCTAssertEqual(decision, .route(.bankCardSettings))
|
||||||
|
|
||||||
|
wallet.bankCardResponse = try BankCardInfoResponse(bankCard: Self.bankCard(status: 2))
|
||||||
|
decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
|
||||||
|
XCTAssertEqual(decision, .route(.withdrawApply))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试提现申请提交会校验金额并传递规范化金额。
|
||||||
|
func testWithdrawApplySubmitsNormalizedAmount() async throws {
|
||||||
|
let api = WalletMockAPI()
|
||||||
|
api.withdrawInfoResponse = try Self.withdrawInfo(withdrawable: "100")
|
||||||
|
let viewModel = WithdrawApplyViewModel()
|
||||||
|
await viewModel.load(api: api)
|
||||||
|
viewModel.amountText = "12.345"
|
||||||
|
viewModel.smsCode = "8899"
|
||||||
|
|
||||||
|
let success = await viewModel.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertTrue(success)
|
||||||
|
XCTAssertEqual(api.withdrawApplyBodies.map(\.amount), ["12.35"])
|
||||||
|
XCTAssertEqual(api.withdrawApplyBodies.map(\.smsCode), ["8899"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试银行卡设置会先上传正反面照片,再提交最终 URL。
|
||||||
|
func testWithdrawalSettingsUploadsImagesBeforeSubmit() async {
|
||||||
|
let api = WalletMockAPI()
|
||||||
|
let uploader = WalletUploadMock()
|
||||||
|
let viewModel = WithdrawalSettingsViewModel()
|
||||||
|
viewModel.realName = "张三"
|
||||||
|
viewModel.cardNumber = "622200"
|
||||||
|
viewModel.bankName = "中国银行"
|
||||||
|
viewModel.branchName = "杭州支行"
|
||||||
|
viewModel.provinceCode = "330000"
|
||||||
|
viewModel.cityCode = "330100"
|
||||||
|
viewModel.smsCode = "1234"
|
||||||
|
viewModel.frontImageData = Data([1])
|
||||||
|
viewModel.backImageData = Data([2])
|
||||||
|
|
||||||
|
let success = await viewModel.submit(api: api, uploader: uploader, scenicId: 9)
|
||||||
|
|
||||||
|
XCTAssertTrue(success)
|
||||||
|
XCTAssertEqual(uploader.bankUploads.count, 2)
|
||||||
|
XCTAssertEqual(api.updateBankRequests.first?.frontUrl, "https://cdn.example.com/bank_card_front.jpg")
|
||||||
|
XCTAssertEqual(api.updateBankRequests.first?.backUrl, "https://cdn.example.com/bank_card_back.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试积分兑换不能超过可兑换积分,成功后会刷新列表。
|
||||||
|
func testPointsRedemptionValidationAndSubmit() async {
|
||||||
|
let api = WalletMockAPI()
|
||||||
|
api.pointOverviewResponse = PointOverviewResponse(withdrawnPoints: 100)
|
||||||
|
let viewModel = PointsRedemptionViewModel()
|
||||||
|
await viewModel.load(api: api, staffId: 7)
|
||||||
|
viewModel.pointsText = "120"
|
||||||
|
|
||||||
|
var success = await viewModel.submit(api: api, staffId: 7)
|
||||||
|
XCTAssertFalse(success)
|
||||||
|
XCTAssertEqual(viewModel.errorMessage, "兑换积分不能超过可兑换积分")
|
||||||
|
|
||||||
|
viewModel.pointsText = "80"
|
||||||
|
success = await viewModel.submit(api: api, staffId: 7)
|
||||||
|
XCTAssertTrue(success)
|
||||||
|
XCTAssertEqual(api.pointApplyBodies.map(\.points), [80])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 JSON 创建实名认证响应。
|
||||||
|
private static func realNameInfo(status: Int) throws -> RealNameInfoResponse {
|
||||||
|
let data = """
|
||||||
|
{"real_name_info":{"real_name":"张三","id_card_no":"11010519491231002X","audit_status":\(status),"is_long_valid":1}}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
return try JSONDecoder().decode(RealNameInfoResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 JSON 创建银行卡信息。
|
||||||
|
private static func bankCard(status: Int) throws -> WalletBankCardInfo {
|
||||||
|
let data = """
|
||||||
|
{"real_name":"张三","bank_name":"中国银行","branch_name":"杭州支行","card_number":"622200","audit_status":\(status),"audit_status_label":"通过"}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
return try JSONDecoder().decode(WalletBankCardInfo.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 JSON 创建提现信息。
|
||||||
|
fileprivate static func withdrawInfo(withdrawable: String) throws -> WithdrawInfoResponse {
|
||||||
|
let data = """
|
||||||
|
{"amount_withdrawable":"\(withdrawable)","min_withdraw_amount":"1","max_single_withdraw_amount":"1000","max_daily_withdraw_amount":"1000","user_phone":"13800000000","bank_card":{"real_name":"张三","bank_name":"中国银行","card_number":"622200"},"withdraw_info":[]}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
return try JSONDecoder().decode(WithdrawInfoResponse.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 钱包 API 测试替身。
|
||||||
|
private final class WalletMockAPI: WalletServing {
|
||||||
|
var summary = WalletSummaryResponse()
|
||||||
|
var earningResponses: [WalletEarningDetailResponse] = []
|
||||||
|
var withdrawResponses: [WalletWithdrawListResponse] = []
|
||||||
|
var withdrawInfoResponse: WithdrawInfoResponse?
|
||||||
|
var bankCardResponse = BankCardInfoResponse(bankCard: nil)
|
||||||
|
var pointOverviewResponse = PointOverviewResponse()
|
||||||
|
private(set) var earningQueries: [(start: String, end: String, page: Int)] = []
|
||||||
|
private(set) var withdrawListPages: [Int] = []
|
||||||
|
private(set) var withdrawApplyBodies: [(amount: String, smsCode: String)] = []
|
||||||
|
private(set) var updateBankRequests: [UpdateBankInfoRequest] = []
|
||||||
|
private(set) var pointOverviewStaffIds: [Int] = []
|
||||||
|
private(set) var pointApplyBodies: [(points: Int, remark: String)] = []
|
||||||
|
|
||||||
|
func walletSummary(type: Int) async throws -> WalletSummaryResponse { summary }
|
||||||
|
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse {
|
||||||
|
earningQueries.append((startDate, endDate, page))
|
||||||
|
return earningResponses.isEmpty ? WalletEarningDetailResponse() : earningResponses.removeFirst()
|
||||||
|
}
|
||||||
|
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
|
||||||
|
withdrawListPages.append(page)
|
||||||
|
return withdrawResponses.isEmpty ? WalletWithdrawListResponse() : withdrawResponses.removeFirst()
|
||||||
|
}
|
||||||
|
func withdrawInfo() async throws -> WithdrawInfoResponse {
|
||||||
|
if let withdrawInfoResponse { return withdrawInfoResponse }
|
||||||
|
return try WalletViewModelTests.withdrawInfo(withdrawable: "0")
|
||||||
|
}
|
||||||
|
func withdrawSendSms() async throws {}
|
||||||
|
func withdrawApply(amount: String, smsCode: String) async throws {
|
||||||
|
withdrawApplyBodies.append((amount, smsCode))
|
||||||
|
}
|
||||||
|
func bankCardInfo() async throws -> BankCardInfoResponse { bankCardResponse }
|
||||||
|
func bankList() async throws -> BankListResponse { BankListResponse(banks: ["中国银行"]) }
|
||||||
|
func areas() async throws -> [AreaNode] { [] }
|
||||||
|
func bankCardVerifyCode() async throws {}
|
||||||
|
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {
|
||||||
|
updateBankRequests.append(request)
|
||||||
|
}
|
||||||
|
func pointOverview(staffId: Int) async throws -> PointOverviewResponse {
|
||||||
|
pointOverviewStaffIds.append(staffId)
|
||||||
|
return pointOverviewResponse
|
||||||
|
}
|
||||||
|
func pointWithdrawApply(points: Int, remark: String) async throws {
|
||||||
|
pointApplyBodies.append((points, remark))
|
||||||
|
}
|
||||||
|
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse {
|
||||||
|
PointWithdrawListResponse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 钱包实名认证测试替身。
|
||||||
|
private final class WalletRealNameMockAPI: WalletRealNameServing {
|
||||||
|
var response = RealNameInfoResponse(realNameInfo: nil)
|
||||||
|
|
||||||
|
/// 返回预设实名认证响应。
|
||||||
|
func realNameInfo() async throws -> RealNameInfoResponse {
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 钱包上传测试替身。
|
||||||
|
private final class WalletUploadMock: OSSUploadServing {
|
||||||
|
private(set) var bankUploads: [String] = []
|
||||||
|
|
||||||
|
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
bankUploads.append(fileName)
|
||||||
|
onProgress(100)
|
||||||
|
return "https://cdn.example.com/\(fileName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user