新增支付与钱包模块,并接入首页路由
引入真实收款与钱包页面替换首页占位入口,通过 RootView 接入 API,并支持银行卡 OSS 上传及二维码保存到相册。 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