From 5d4613f317d042b693645400a815e774f6ea63d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 22 Jun 2026 17:46:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=94=AF=E4=BB=98=E4=B8=8E?= =?UTF-8?q?=E9=92=B1=E5=8C=85=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=B9=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E9=A6=96=E9=A1=B5=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入真实收款与钱包页面替换首页占位入口,通过 RootView 接入 API,并支持银行卡 OSS 上传及二维码保存到相册。 Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 2 + suixinkan/App/RootView.swift | 6 + suixinkan/Core/Upload/OSSUploadService.swift | 10 + suixinkan/Features/Home/Home.md | 2 + .../Features/Home/Models/HomeMenuItem.swift | 2 + .../Home/Routing/HomeMenuRouter.swift | 10 +- .../Home/Views/HomeSupportViews.swift | 4 + .../Features/Payment/API/PaymentAPI.swift | 55 ++ .../Payment/Models/PaymentModels.swift | 182 ++++++ suixinkan/Features/Payment/Payment.md | 25 + .../Payment/ViewModels/PaymentViewModel.swift | 246 +++++++ .../Payment/Views/PaymentCollectionView.swift | 325 ++++++++++ suixinkan/Features/Wallet/API/WalletAPI.swift | 204 ++++++ .../Features/Wallet/Models/WalletModels.swift | 604 ++++++++++++++++++ .../Wallet/ViewModels/WalletViewModels.swift | 538 ++++++++++++++++ .../Features/Wallet/Views/WalletView.swift | 531 +++++++++++++++ suixinkan/Features/Wallet/Wallet.md | 31 + suixinkanTests/HomeMenuRouterTests.swift | 8 +- suixinkanTests/NavigationRouterTests.swift | 2 + suixinkanTests/PaymentViewModelTests.swift | 108 ++++ .../ProfileSecondaryPagesTests.swift | 5 + suixinkanTests/UploadTests.swift | 4 + suixinkanTests/WalletViewModelTests.swift | 222 +++++++ 23 files changed, 3117 insertions(+), 9 deletions(-) create mode 100644 suixinkan/Features/Payment/API/PaymentAPI.swift create mode 100644 suixinkan/Features/Payment/Models/PaymentModels.swift create mode 100644 suixinkan/Features/Payment/Payment.md create mode 100644 suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift create mode 100644 suixinkan/Features/Payment/Views/PaymentCollectionView.swift create mode 100644 suixinkan/Features/Wallet/API/WalletAPI.swift create mode 100644 suixinkan/Features/Wallet/Models/WalletModels.swift create mode 100644 suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift create mode 100644 suixinkan/Features/Wallet/Views/WalletView.swift create mode 100644 suixinkan/Features/Wallet/Wallet.md create mode 100644 suixinkanTests/PaymentViewModelTests.swift create mode 100644 suixinkanTests/WalletViewModelTests.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 26db165..27d46d9 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -345,6 +345,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -383,6 +384,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index aca796e..a4597a8 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -23,6 +23,8 @@ struct RootView: View { @State private var accountContextAPI: AccountContextAPI @State private var ordersAPI: OrdersAPI @State private var statisticsAPI: StatisticsAPI + @State private var paymentAPI: PaymentAPI + @State private var walletAPI: WalletAPI @State private var authSessionCoordinator: AuthSessionCoordinator @State private var sessionBootstrapper: SessionBootstrapper @@ -40,6 +42,8 @@ struct RootView: View { _accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient)) _ordersAPI = State(initialValue: OrdersAPI(client: apiClient)) _statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient)) + _paymentAPI = State(initialValue: PaymentAPI(client: apiClient)) + _walletAPI = State(initialValue: WalletAPI(client: apiClient)) _authSessionCoordinator = State( initialValue: AuthSessionCoordinator( tokenStore: tokenStore, @@ -72,6 +76,8 @@ struct RootView: View { .environment(accountContextAPI) .environment(ordersAPI) .environment(statisticsAPI) + .environment(paymentAPI) + .environment(walletAPI) .environment(authSessionCoordinator) .task { apiClient.bindAuthTokenProvider { appSession.token } diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index 573d41c..ea6c562 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -36,6 +36,9 @@ protocol OSSUploadServing { /// 上传景区申请图片,并返回可访问的 OSS 文件 URL。 func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传银行卡照片,并返回可访问的 OSS 文件 URL。 + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String } @MainActor @@ -84,6 +87,11 @@ final class OSSUploadService { try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "scenic_apply", onProgress: onProgress) } + /// 上传银行卡照片,并返回可访问的 OSS 文件 URL。 + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress) + } + /// 执行通用 OSS 上传流程。 private func uploadFile( data: Data, @@ -188,6 +196,8 @@ enum OSSUploadPolicy { return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "real_name": return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "bank_card": + return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" default: return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" } diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index e9c94d3..7bc5672 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -33,4 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 ## 后续迁移 +目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,本轮保留占位,后续应作为独立功能迁移。 + 后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index 67e6e27..197c2a6 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -24,6 +24,8 @@ enum HomeRoute: Hashable { case scenicSelection case moreFunctions case settings + case paymentCollection + case wallet case modulePlaceholder(uri: String, title: String) } diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 39aaf53..cb42b03 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -81,6 +81,10 @@ enum HomeMenuRouter { return .destination(.settings) case "scenicselection": return .destination(.scenicSelection) + case "wallet": + return .destination(.wallet) + case "payment_collection", "payment_qr", "payment_code": + return .destination(.paymentCollection) case "store", "more_functions": return .destination(.moreFunctions) case "fly", "pilot_controller": @@ -89,13 +93,9 @@ enum HomeMenuRouter { title: title.isEmpty ? self.title(for: uri) : title, reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。" ) - case "wallet", - "message_center", + case "message_center", "/scenic-queue", "queue_management", - "payment_collection", - "payment_qr", - "payment_code", "deposit_order_detail", "deposit_order", "deposit_order_shooting_info", diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index a9a3b6c..680816d 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -20,6 +20,10 @@ extension HomeRoute { HomeMoreFunctionsView() case .settings: SettingsCenterView() + case .paymentCollection: + PaymentCollectionView() + case .wallet: + WalletView() case let .modulePlaceholder(uri, title): HomeMigrationModuleView(title: title, uri: uri) } diff --git a/suixinkan/Features/Payment/API/PaymentAPI.swift b/suixinkan/Features/Payment/API/PaymentAPI.swift new file mode 100644 index 0000000..a9231b7 --- /dev/null +++ b/suixinkan/Features/Payment/API/PaymentAPI.swift @@ -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 {} diff --git a/suixinkan/Features/Payment/Models/PaymentModels.swift b/suixinkan/Features/Payment/Models/PaymentModels.swift new file mode 100644 index 0000000..3c7f8ee --- /dev/null +++ b/suixinkan/Features/Payment/Models/PaymentModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/Payment/Payment.md b/suixinkan/Features/Payment/Payment.md new file mode 100644 index 0000000..36549be --- /dev/null +++ b/suixinkan/Features/Payment/Payment.md @@ -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。 diff --git a/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift b/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift new file mode 100644 index 0000000..b1fa072 --- /dev/null +++ b/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift @@ -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() + 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) + } + } +} diff --git a/suixinkan/Features/Payment/Views/PaymentCollectionView.swift b/suixinkan/Features/Payment/Views/PaymentCollectionView.swift new file mode 100644 index 0000000..0166a27 --- /dev/null +++ b/suixinkan/Features/Payment/Views/PaymentCollectionView.swift @@ -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? + @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) + } +} diff --git a/suixinkan/Features/Wallet/API/WalletAPI.swift b/suixinkan/Features/Wallet/API/WalletAPI.swift new file mode 100644 index 0000000..f17cabf --- /dev/null +++ b/suixinkan/Features/Wallet/API/WalletAPI.swift @@ -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 {} diff --git a/suixinkan/Features/Wallet/Models/WalletModels.swift b/suixinkan/Features/Wallet/Models/WalletModels.swift new file mode 100644 index 0000000..d711321 --- /dev/null +++ b/suixinkan/Features/Wallet/Models/WalletModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift new file mode 100644 index 0000000..0a175f4 --- /dev/null +++ b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift @@ -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) + } +} diff --git a/suixinkan/Features/Wallet/Views/WalletView.swift b/suixinkan/Features/Wallet/Views/WalletView.swift new file mode 100644 index 0000000..5a7743e --- /dev/null +++ b/suixinkan/Features/Wallet/Views/WalletView.swift @@ -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(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, 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 ?? "") + } +} diff --git a/suixinkan/Features/Wallet/Wallet.md b/suixinkan/Features/Wallet/Wallet.md new file mode 100644 index 0000000..8c0aff5 --- /dev/null +++ b/suixinkan/Features/Wallet/Wallet.md @@ -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 和积分兑换表单均不落盘。 diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index f5db8c9..a8309f8 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -25,6 +25,10 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "space_settings", title: ""), .destination(.profileSpace)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicselection", title: ""), .destination(.scenicSelection)) 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: ""), .destination(.modulePlaceholder(uri: "task_management", title: "任务管理")) ) - XCTAssertEqual( - HomeMenuRouter.resolve(uri: "payment_qr", title: ""), - .destination(.modulePlaceholder(uri: "payment_qr", title: "收款码")) - ) } /// 测试 Tab 路由仍集中在 HomeMenuRouter。 diff --git a/suixinkanTests/NavigationRouterTests.swift b/suixinkanTests/NavigationRouterTests.swift index 3b25e5e..2685043 100644 --- a/suixinkanTests/NavigationRouterTests.swift +++ b/suixinkanTests/NavigationRouterTests.swift @@ -14,6 +14,8 @@ final class NavigationRouterTests: XCTestCase { func testPushedRoutesHideTabBarByDefault() { XCTAssertTrue(AppRoute.placeholder(title: "详情").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(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed) } diff --git a/suixinkanTests/PaymentViewModelTests.swift b/suixinkanTests/PaymentViewModelTests.swift new file mode 100644 index 0000000..a7f44da --- /dev/null +++ b/suixinkanTests/PaymentViewModelTests.swift @@ -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() + } +} diff --git a/suixinkanTests/ProfileSecondaryPagesTests.swift b/suixinkanTests/ProfileSecondaryPagesTests.swift index 1d3e552..b53f1f2 100644 --- a/suixinkanTests/ProfileSecondaryPagesTests.swift +++ b/suixinkanTests/ProfileSecondaryPagesTests.swift @@ -370,6 +370,11 @@ private final class MockOSSUploader: OSSUploadServing { func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { avatarURL } + + /// 模拟上传银行卡照片。 + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } } /// 生成测试用 JPEG 图片数据。 diff --git a/suixinkanTests/UploadTests.swift b/suixinkanTests/UploadTests.swift index 1d463dd..c01b15f 100644 --- a/suixinkanTests/UploadTests.swift +++ b/suixinkanTests/UploadTests.swift @@ -43,6 +43,10 @@ final class UploadTests: XCTestCase { OSSUploadPolicy.objectKey(fileName: "video.mp4", scenicId: 9, moduleType: "task_upload", date: date, uuid: uuid, timeZone: timeZone), "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 拼接规则。 diff --git a/suixinkanTests/WalletViewModelTests.swift b/suixinkanTests/WalletViewModelTests.swift new file mode 100644 index 0000000..7854aad --- /dev/null +++ b/suixinkanTests/WalletViewModelTests.swift @@ -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 { "" } +}