diff --git a/suixinkan/Core/Push/PushNotificationManager.swift b/suixinkan/Core/Push/PushNotificationManager.swift index 16cb377..c8eaf03 100644 --- a/suixinkan/Core/Push/PushNotificationManager.swift +++ b/suixinkan/Core/Push/PushNotificationManager.swift @@ -124,6 +124,13 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate } private func route(from payload: PushPayload) { + if let type = payload.paymentEventType, type == 1 || type == 6 { + PaymentPushEventCenter.shared.post(type: type, dataJSON: payload.paymentEventData) + if type == 1 { + handlePaymentVoiceIfNeeded(payload: payload) + } + } + guard let router else { return } switch payload.route { @@ -147,4 +154,15 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate router.select(.home) router.router(for: .home).navigate(to: .home(route)) } + + /// 付款成功推送且语音开关开启时播报到账金额。 + private func handlePaymentVoiceIfNeeded(payload: PushPayload) { + let preferences = AppPreferencesStore() + guard preferences.loadPaymentReceiveVoiceEnabled(), + let amount = payload.paymentSuccessAmount, + !amount.isEmpty else { + return + } + PaymentVoiceSpeaker.shared.speak("收款到账\(amount)元") + } } diff --git a/suixinkan/Core/Push/PushPayload.swift b/suixinkan/Core/Push/PushPayload.swift index 64554e8..808b022 100644 --- a/suixinkan/Core/Push/PushPayload.swift +++ b/suixinkan/Core/Push/PushPayload.swift @@ -46,7 +46,7 @@ struct PushPayload: Sendable { let actionText = values["action"] ?? "" let merged = [typeText, routeText, uriText, actionText].joined(separator: " ").lowercased() - if typeText == "1" || merged.contains("payment") || merged.contains("pay") || merged.contains("收款") { + if typeText == "1" || typeText == "6" || merged.contains("payment") || merged.contains("pay") || merged.contains("收款") { return .payment } if merged.contains("queue") || merged.contains("排队") || merged.contains("叫号") || uriText == "/scenic-queue" { @@ -64,6 +64,34 @@ struct PushPayload: Sendable { return .messageCenter } + /// 推送 extras 中的 type 字段,供收款页区分扫码/付款事件。 + nonisolated var paymentEventType: Int? { + guard let typeText = values["type"], !typeText.isEmpty else { return nil } + return Int(typeText) + } + + /// 推送 extras 中的 data 字段,付款成功时通常为 JSON 字符串。 + nonisolated var paymentEventData: String? { + let data = values["data"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return data.isEmpty ? nil : data + } + + /// 从付款成功 data JSON 中解析到账金额,供全局语音播报使用。 + nonisolated var paymentSuccessAmount: String? { + guard let dataJSON = paymentEventData, + let data = dataJSON.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + if let amount = json["order_amount"] as? String, !amount.isEmpty { + return amount + } + if let amount = json["order_amount"] as? NSNumber { + return amount.stringValue + } + return nil + } + nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) { if let dict = value as? [String: Any] { for (key, value) in dict { diff --git a/suixinkan/Core/Storage/AppPreferencesStore.swift b/suixinkan/Core/Storage/AppPreferencesStore.swift index 55b2f8c..4923860 100644 --- a/suixinkan/Core/Storage/AppPreferencesStore.swift +++ b/suixinkan/Core/Storage/AppPreferencesStore.swift @@ -13,6 +13,7 @@ final class AppPreferencesStore { private let defaults: UserDefaults private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username" private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted" + private let paymentReceiveVoiceKey = "suixinkan.preferences.payment_receive_voice" /// 初始化偏好存储服务,并允许测试注入独立的 UserDefaults。 init(defaults: UserDefaults = .standard) { @@ -43,6 +44,16 @@ final class AppPreferencesStore { defaults.bool(forKey: privacyAgreementAcceptedKey) } + /// 读取收款到账语音提醒开关。 + func loadPaymentReceiveVoiceEnabled() -> Bool { + defaults.bool(forKey: paymentReceiveVoiceKey) + } + + /// 保存收款到账语音提醒开关。 + func savePaymentReceiveVoiceEnabled(_ enabled: Bool) { + defaults.set(enabled, forKey: paymentReceiveVoiceKey) + } + /// 清空登录页偏好。 func clear() { defaults.removeObject(forKey: lastLoginUsernameKey) diff --git a/suixinkan/Features/Payment/Models/PaymentModels.swift b/suixinkan/Features/Payment/Models/PaymentModels.swift index 3c7f8ee..cfadb3b 100644 --- a/suixinkan/Features/Payment/Models/PaymentModels.swift +++ b/suixinkan/Features/Payment/Models/PaymentModels.swift @@ -137,6 +137,19 @@ struct PaymentCollectionRecordGroup: Equatable, Identifiable { var id: String { analyse.id } } +/// 付款成功展示数据,对齐 Android PaymentSuccessData。 +struct PaymentSuccessDisplayData: Equatable { + let orderAmount: String + let orderNumber: String + let payTime: String + let remark: String +} + +/// 收款模块固定商户信息,对齐 Android MerchantInfo。 +enum PaymentMerchantInfo { + static let payeeCompany = "扬州元智享网络科技有限公司" +} + /// 收款状态实体,表示当前动态收款轮询结果。 enum PaymentCollectionStatus: Equatable { case idle diff --git a/suixinkan/Features/Payment/Payment.md b/suixinkan/Features/Payment/Payment.md index 36549be..29c9030 100644 --- a/suixinkan/Features/Payment/Payment.md +++ b/suixinkan/Features/Payment/Payment.md @@ -2,24 +2,43 @@ ## 模块职责 -`Features/Payment` 承接首页 `payment_collection`、`payment_qr`、`payment_code` 权限入口,负责当前景区下的收款码、动态金额二维码、收款记录和到账轮询。 +`Features/Payment` 承接首页 `payment_collection`、`payment_qr`、`payment_code` 权限入口,负责当前景区下的收款码、动态金额二维码、收款记录和到账反馈。 ## 代码结构 - `PaymentAPI`:封装收款码和扫码收款记录接口。 -- `PaymentCollectionViewModel`:管理收款码加载、金额校验、动态二维码生成和到账轮询。 +- `PaymentCollectionViewModel`:管理收款码加载、金额校验、动态二维码生成、推送状态和到账轮询 fallback。 - `PaymentCollectionRecordViewModel`:管理收款记录加载和按日期分组。 -- `PaymentCollectionView`:展示收款码、金额弹窗、保存二维码和收款状态。 +- `PaymentCollectionView`:展示 Android 对齐后的收款详情页 UI。 +- `PaymentCollectionComponents`:收款页复用 UI 组件(白卡、QR 区、设置金额弹窗、记录卡片)。 +- `PaymentPushEventCenter`:应用内收款推送事件总线,对齐 Android `PushBus`。 - `PaymentCollectionRecordView`:展示收款记录明细。 +## 页面结构(对齐 Android) + +主页面标题为「收款详情」,自上而下: + +1. QR 卡:景区名、182×182 二维码、设置金额/保存二维码文字链;失败态显示红字与点击刷新。 +2. 状态卡:推送 type=6 时显示「扫码成功支付中...」;付款成功后显示金额/时间/单号/备注。 +3. 信息卡:景区名称、收款方、员工 ID。 +4. 收款记录入口行。 +5. 收款到账语音提醒开关。 + +设置金额使用居中 Dialog,确认后直接更新主二维码。 + ## 数据流 -1. 页面读取 `AccountContext.currentScenic`。 +1. 页面读取 `AccountContext.currentScenic` 和 `AccountContext.profile.userId`。 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. 命中新收款记录后进入成功态,并使用系统语音播报到账金额。 +4. 优先通过推送更新 UI: + - `type=6`:扫码成功,展示扫码时间 + 「扫码成功支付中...」 + - `type=1`:付款成功,QR 区大成功态 + 详情卡;若语音开关开启则全局 TTS 播报 +5. 保留轮询 fallback:`/api/yf-handset-app/photog/order/photo-scan-order-list?scenic_id=...`,最多 60 次,每 2 秒一次。 +6. 命中新收款记录后进入成功态,并填充 `PaymentSuccessDisplayData`。 ## 缓存边界 -收款码 URL、付款状态、轮询结果不落盘。保存二维码只写入系统相册,App 不额外缓存图片文件或 Data。 +- 收款码 URL、付款状态、轮询结果不落盘。 +- 语音开关写入 `AppPreferencesStore`(`suixinkan.preferences.payment_receive_voice`)。 +- 保存二维码只写入系统相册,App 不额外缓存图片文件或 Data。 diff --git a/suixinkan/Features/Payment/Services/PaymentPushEventCenter.swift b/suixinkan/Features/Payment/Services/PaymentPushEventCenter.swift new file mode 100644 index 0000000..3ce70e6 --- /dev/null +++ b/suixinkan/Features/Payment/Services/PaymentPushEventCenter.swift @@ -0,0 +1,35 @@ +// +// PaymentPushEventCenter.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Combine +import Foundation + +/// 收款推送事件,对齐 Android PushBus 在收款页内的分发语义。 +struct PaymentPushEvent: Equatable, Sendable { + let type: Int + let dataJSON: String? +} + +@MainActor +/// 收款推送事件中心,供 PushNotificationManager 发布、收款页订阅。 +final class PaymentPushEventCenter { + static let shared = PaymentPushEventCenter() + + private let subject = PassthroughSubject() + + private init() {} + + /// 收款页可订阅的推送事件流。 + var events: AnyPublisher { + subject.eraseToAnyPublisher() + } + + /// 发布一条收款相关推送事件。 + func post(type: Int, dataJSON: String?) { + subject.send(PaymentPushEvent(type: type, dataJSON: dataJSON)) + } +} diff --git a/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift b/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift index ba626d1..5825e43 100644 --- a/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift +++ b/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift @@ -7,12 +7,12 @@ import CoreImage import CoreImage.CIFilterBuiltins -import Foundation import Combine +import Foundation import UIKit @MainActor -/// 收款页 ViewModel,管理收款码加载、动态金额二维码和到账轮询。 +/// 收款页 ViewModel,管理收款码加载、动态金额二维码、推送状态和到账轮询。 final class PaymentCollectionViewModel: ObservableObject { @Published var staticPayUrl = "" @Published var dynamicPayUrl = "" @@ -20,20 +20,43 @@ final class PaymentCollectionViewModel: ObservableObject { @Published var remarkText = "" @Published var currentPayUrl = "" @Published var qrImage: UIImage? - private var isLoading = false - private var isPolling = false @Published var errorMessage: String? @Published var status: PaymentCollectionStatus = .idle + @Published var isVoiceBroadcast = false + @Published var scanSuccessTime: String? + @Published var paymentSuccessData: PaymentSuccessDisplayData? @Published private var knownRecordIDs = Set() + private var isLoading = false + private var isPolling = false + private let preferencesStore: AppPreferencesStore + private let pushEventCenter: PaymentPushEventCenter + private var pushEventsCancellable: AnyCancellable? private let context = CIContext() private let qrFilter = CIFilter.qrCodeGenerator() + /// 创建收款页 ViewModel,并注入偏好存储与推送事件中心。 + init( + preferencesStore: AppPreferencesStore = AppPreferencesStore(), + pushEventCenter: PaymentPushEventCenter = .shared + ) { + self.preferencesStore = preferencesStore + self.pushEventCenter = pushEventCenter + isVoiceBroadcast = preferencesStore.loadPaymentReceiveVoiceEnabled() + startListeningPushEvents() + } + /// 当前静态收款码是否可展示。 var hasStaticPayCode: Bool { !staticPayUrl.paymentTrimmed.isEmpty } + /// 金额是否满足设置动态二维码条件。 + var canConfirmAmount: Bool { + guard let amount = Self.normalizedMoney(amountText) else { return false } + return (Decimal(string: amount) ?? 0) > 0 + } + /// 加载当前景区收款码,无景区时清空收款状态。 func loadPayCode(api: PaymentServing, scenicId: Int?) async { guard let scenicId else { @@ -53,38 +76,92 @@ final class PaymentCollectionViewModel: ObservableObject { currentPayUrl = response.staticPayUrl qrImage = makeQRCode(from: currentPayUrl) status = .idle + scanSuccessTime = nil + paymentSuccessData = nil } catch { errorMessage = error.localizedDescription } } - /// 根据输入金额和备注生成动态二维码 URL。 + /// 打开设置金额弹窗前清空输入并校验静态码是否可用。 @discardableResult - func applyDynamicAmount() -> Bool { - guard let amount = Self.normalizedMoney(amountText), Decimal(string: amount) ?? 0 > 0 else { + func prepareAmountDialog() -> Bool { + guard hasStaticPayCode else { + errorMessage = "请先获取收款码" + return false + } + amountText = "" + remarkText = "" + return true + } + + /// 确认设置金额并更新主二维码,对齐 Android confirmAmount。 + @discardableResult + func confirmAmount() -> Bool { + guard hasStaticPayCode else { + errorMessage = "请先获取收款码" + return false + } + guard let amount = Self.normalizedMoney(amountText), (Decimal(string: amount) ?? 0) > 0 else { errorMessage = "请输入有效收款金额" return false } - guard !dynamicPayUrl.paymentTrimmed.isEmpty else { - errorMessage = "动态收款码暂不可用" + guard amountText.matches(regex: #"^\d+(\.\d{1,2})?$"#) 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)) + guard applyDynamicAmount(normalizedAmount: amount) else { + return false } - components?.queryItems = queryItems - currentPayUrl = components?.url?.absoluteString ?? "\(dynamicPayUrl)&amount=\(amount)" - qrImage = makeQRCode(from: currentPayUrl) - amountText = amount - status = .waiting + scanSuccessTime = nil + paymentSuccessData = nil return true } + /// 切换收款到账语音提醒开关并持久化。 + func toggleVoiceBroadcast() { + isVoiceBroadcast.toggle() + preferencesStore.savePaymentReceiveVoiceEnabled(isVoiceBroadcast) + } + + /// 处理收款相关推送事件,对齐 Android dealWithPushEvent。 + func handlePaymentPush(type: Int, dataJSON: String?) { + if type == 6 { + scanSuccessTime = Self.currentTimestamp() + paymentSuccessData = nil + return + } + + guard type == 1, + let dataJSON, + let data = dataJSON.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return + } + + let orderAmount = Self.stringValue(json["order_amount"]) + guard !orderAmount.isEmpty else { return } + + paymentSuccessData = PaymentSuccessDisplayData( + orderAmount: orderAmount, + orderNumber: Self.stringValue(json["order_number"]), + payTime: Self.formattedPayTime(from: json["pay_time"]), + remark: Self.stringValue(json["remark"]) + ) + scanSuccessTime = nil + status = .idle + } + + /// 根据输入金额和备注生成动态二维码 URL。 + @discardableResult + func applyDynamicAmount() -> Bool { + guard let amount = Self.normalizedMoney(amountText) else { + errorMessage = "请输入有效收款金额" + return false + } + return applyDynamicAmount(normalizedAmount: amount) + } + /// 记录当前已知收款记录,轮询时只把新增记录判定为本次到账。 func primePaymentRecords(api: PaymentServing, scenicId: Int?) async { guard let scenicId else { return } @@ -115,7 +192,7 @@ final class PaymentCollectionViewModel: ObservableObject { let response = try await api.paymentCollectionRecords(scenicId: scenicId) if let item = matchedNewRecord(in: response.list) { knownRecordIDs.insert(item.id) - status = .success(item) + applySuccessState(from: item) return } } catch { @@ -137,6 +214,8 @@ final class PaymentCollectionViewModel: ObservableObject { qrImage = nil knownRecordIDs = [] status = .idle + scanSuccessTime = nil + paymentSuccessData = nil } /// 将输入金额规范成最多两位小数。 @@ -157,6 +236,90 @@ final class PaymentCollectionViewModel: ObservableObject { return number.rounding(accordingToBehavior: handler).stringValue } + /// 生成当前时间字符串,格式对齐 Android 扫码成功展示。 + static func currentTimestamp() -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: Date()) + } + + /// 从推送或接口字段中解析付款时间。 + static func formattedPayTime(from value: Any?) -> String { + if let text = value as? String, !text.isEmpty { + return text + } + if let number = value as? NSNumber { + let timestamp = number.int64Value + let epochSecond = timestamp > 1_000_000_000_000 ? timestamp / 1000 : timestamp + let date = Date(timeIntervalSince1970: TimeInterval(epochSecond)) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: date) + } + return "" + } + + /// 从 JSON 字段中提取字符串值。 + static func stringValue(_ value: Any?) -> String { + switch value { + case let string as String: + return string + case let number as NSNumber: + return number.stringValue + case .some(let value): + return "\(value)" + case nil: + return "" + } + } + + /// 订阅收款推送事件并转发到 ViewModel 状态。 + private func startListeningPushEvents() { + pushEventsCancellable = pushEventCenter.events + .sink { [weak self] event in + self?.handlePaymentPush(type: event.type, dataJSON: event.dataJSON) + } + } + + /// 根据输入金额和备注生成动态二维码 URL。 + @discardableResult + private func applyDynamicAmount(normalizedAmount amount: String) -> Bool { + 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 + } + + /// 轮询命中成功后同步 Android 付款详情卡所需字段。 + private func applySuccessState(from item: PaymentCollectionRecordItem) { + status = .success(item) + paymentSuccessData = PaymentSuccessDisplayData( + orderAmount: item.orderAmount, + orderNumber: item.orderNumber, + payTime: [item.createDate, item.createTime] + .filter { !$0.isEmpty } + .joined(separator: " "), + remark: remarkText.paymentTrimmed + ) + scanSuccessTime = nil + } + /// 从收款记录中找到本次动态收款对应的新记录。 private func matchedNewRecord(in records: [PaymentCollectionRecordItem]) -> PaymentCollectionRecordItem? { let newRecords = records.filter { !knownRecordIDs.contains($0.id) } @@ -187,6 +350,11 @@ private extension String { var paymentTrimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } + + /// 判断字符串是否匹配正则表达式。 + func matches(regex pattern: String) -> Bool { + range(of: pattern, options: .regularExpression) != nil + } } @MainActor diff --git a/suixinkan/Features/Payment/Views/PaymentCollectionComponents.swift b/suixinkan/Features/Payment/Views/PaymentCollectionComponents.swift new file mode 100644 index 0000000..3b20a5f --- /dev/null +++ b/suixinkan/Features/Payment/Views/PaymentCollectionComponents.swift @@ -0,0 +1,399 @@ +// +// PaymentCollectionComponents.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import SwiftUI + +private enum PaymentCollectionDesign { + static let pageBackground = Color(hex: 0xF4F4F4) + static let cardRadius: CGFloat = 12 + static let qrSize: CGFloat = 182 + static let qrPlaceholderRadius: CGFloat = 24 + static let scanSuccess = Color(hex: 0x22C55E) + static let errorText = Color(hex: 0xEF4444) + static let mutedText = Color(hex: 0x9CA3AF) + static let recordFooter = Color(hex: 0xB6BECA) + static let recordBorder = Color(hex: 0xF3F4F6) + static let orderBadgeBackground = Color(hex: 0xF4F4F4) + static let orderBadgeText = Color(hex: 0x7B8EAA) + static let navigationRowHeight: CGFloat = 52 +} + +/// 收款模块通用白底圆角卡片容器。 +struct PaymentWhiteCard: View { + let verticalPadding: CGFloat + let horizontalPadding: CGFloat + @ViewBuilder var content: () -> Content + + /// 创建收款白卡容器。 + init( + verticalPadding: CGFloat = AppMetrics.Spacing.medium, + horizontalPadding: CGFloat = AppMetrics.Spacing.medium, + @ViewBuilder content: @escaping () -> Content + ) { + self.verticalPadding = verticalPadding + self.horizontalPadding = horizontalPadding + self.content = content + } + + var body: some View { + content() + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .background(.white, in: RoundedRectangle(cornerRadius: PaymentCollectionDesign.cardRadius)) + } +} + +/// 收款信息键值行,对齐 Android 信息卡 label/value 布局。 +struct PaymentInfoRow: View { + let label: String + let value: String + + var body: some View { + HStack(alignment: .top) { + Text(label) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.orderLabelColor) + Spacer(minLength: AppMetrics.Spacing.small) + Text(value) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(.black) + .multilineTextAlignment(.trailing) + } + } +} + +/// 收款页导航行,用于收款记录等入口。 +struct PaymentNavigationRow: View { + let title: String + + var body: some View { + HStack { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.orderLabelColor) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(PaymentCollectionDesign.mutedText) + } + .frame(height: PaymentCollectionDesign.navigationRowHeight) + .padding(.horizontal, AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: PaymentCollectionDesign.cardRadius)) + } +} + +/// 收款二维码区域,包含正常、失败和付款成功三态。 +struct PaymentQRSection: View { + let scenicName: String + let qrImage: UIImage? + let paymentSuccessData: PaymentSuccessDisplayData? + let onSetAmount: () -> Void + let onSaveQRCode: () -> Void + let onRefresh: () -> Void + + var body: some View { + PaymentWhiteCard(verticalPadding: 32, horizontalPadding: 0) { + VStack(spacing: AppMetrics.Spacing.xLarge) { + if let paymentSuccessData { + paymentSuccessContent(paymentSuccessData) + } else { + Text(scenicName) + .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) + .foregroundStyle(.black) + .lineLimit(1) + .padding(.horizontal, AppMetrics.Spacing.medium) + + if let qrImage { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: PaymentCollectionDesign.qrSize, height: PaymentCollectionDesign.qrSize) + } else { + qrPlaceholder + } + + HStack(spacing: 32) { + actionLink(title: "设置金额", action: onSetAmount) + actionLink(title: "保存二维码", action: onSaveQRCode) + } + } + } + .frame(maxWidth: .infinity) + } + } + + /// 二维码缺失时的失败占位。 + private var qrPlaceholder: some View { + VStack(spacing: AppMetrics.Spacing.xSmall) { + Text("未选景区或网络问题") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(PaymentCollectionDesign.errorText) + + Button(action: onRefresh) { + HStack(spacing: AppMetrics.Spacing.xxSmall) { + Image(systemName: "arrow.clockwise") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + Text("点击刷新") + .font(.system(size: AppMetrics.FontSize.subheadline)) + } + .foregroundStyle(AppDesign.primary) + } + } + .frame(width: PaymentCollectionDesign.qrSize, height: PaymentCollectionDesign.qrSize) + .background(PaymentCollectionDesign.pageBackground, in: RoundedRectangle(cornerRadius: PaymentCollectionDesign.qrPlaceholderRadius)) + } + + /// 付款成功后 QR 区展示的大成功态。 + private func paymentSuccessContent(_ data: PaymentSuccessDisplayData) -> some View { + VStack(spacing: AppMetrics.Spacing.medium) { + Text("付款成功") + .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) + .foregroundStyle(.black) + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 90)) + .foregroundStyle(PaymentCollectionDesign.scanSuccess) + + Text("¥ \(data.orderAmount)") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(.black) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, AppMetrics.Spacing.medium) + } + + /// 蓝色文字操作链。 + private func actionLink(title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.primary) + } + } +} + +/// 扫码成功或付款详情状态卡。 +struct PaymentStatusSection: View { + let scanSuccessTime: String? + let paymentSuccessData: PaymentSuccessDisplayData? + + var body: some View { + if let scanSuccessTime { + PaymentWhiteCard { + HStack { + Text(scanSuccessTime) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.orderLabelColor) + Spacer() + Text("扫码成功支付中...") + .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) + .foregroundStyle(PaymentCollectionDesign.scanSuccess) + } + } + } else if let paymentSuccessData { + PaymentWhiteCard { + VStack(spacing: 6) { + PaymentDetailRow(label: "付款金额", value: "¥ \(paymentSuccessData.orderAmount)", valueColor: AppDesign.primary) + PaymentDetailRow(label: "付款时间", value: paymentSuccessData.payTime) + PaymentDetailRow(label: "付款单号", value: paymentSuccessData.orderNumber) + if !paymentSuccessData.remark.isEmpty { + PaymentDetailRow(label: "备注信息", value: paymentSuccessData.remark) + } + } + } + } + } +} + +/// 付款详情键值行。 +private struct PaymentDetailRow: View { + let label: String + let value: String + var valueColor: Color = .black + + var body: some View { + HStack(alignment: .top) { + Text(label) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.orderLabelColor) + Spacer(minLength: AppMetrics.Spacing.small) + Text(value) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(valueColor) + .multilineTextAlignment(.trailing) + } + } +} + +/// 设置收款金额弹窗,对齐 Android SetAmountDialog。 +struct PaymentSetAmountDialog: View { + @Binding var amountText: String + @Binding var remarkText: String + let canConfirm: Bool + let onConfirm: () -> Void + let onDismiss: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.35) + .ignoresSafeArea() + .onTapGesture { } + + VStack(alignment: .leading, spacing: 0) { + Text("收款金额") + .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) + .foregroundStyle(.black) + .padding(.bottom, AppMetrics.Spacing.large) + + HStack(spacing: AppMetrics.Spacing.xSmall) { + Text("¥") + .font(.system(size: AppMetrics.FontSize.title2, weight: .bold)) + .foregroundStyle(.black) + TextField("请输入收款金额", text: $amountText) + .keyboardType(.decimalPad) + .font(.system(size: AppMetrics.FontSize.title3, weight: .medium)) + .foregroundStyle(.black) + } + .padding(.horizontal, AppMetrics.Spacing.medium) + .frame(minHeight: AppMetrics.ControlSize.inputHeight) + .background(PaymentCollectionDesign.pageBackground, in: RoundedRectangle(cornerRadius: 8)) + .padding(.bottom, AppMetrics.Spacing.medium) + + Text("备注 (选填)") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.orderLabelColor) + .padding(.bottom, AppMetrics.Spacing.xSmall) + + TextField("请输入备注信息...", text: $remarkText, axis: .vertical) + .lineLimit(4, reservesSpace: true) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(hex: 0xE5E7EB), lineWidth: 1) + } + .onChange(of: remarkText) { newValue in + if newValue.count > 200 { + remarkText = String(newValue.prefix(200)) + } + } + + HStack { + Spacer() + Text("\(remarkText.count)/200") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(PaymentCollectionDesign.mutedText) + } + .padding(.top, AppMetrics.Spacing.xxSmall) + .padding(.bottom, AppMetrics.Spacing.medium) + + HStack { + Spacer() + Button(action: onDismiss) { + Text("取消") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.primary) + .frame(width: 84, height: 38) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8)) + } + + Button(action: onConfirm) { + Text("确定") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(.white) + .frame(width: 84, height: 38) + .background(AppDesign.primary.opacity(canConfirm ? 1 : 0.5), in: RoundedRectangle(cornerRadius: 8)) + } + .disabled(!canConfirm) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.12), radius: 16, y: 8) + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + } + } +} + +/// 收款记录日期分组头部。 +struct PaymentRecordDayHeader: View { + let group: PaymentCollectionRecordGroup + + var body: some View { + HStack { + Text(group.analyse.date) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(PaymentCollectionDesign.mutedText) + Spacer() + Text("共收款\(group.analyse.orderCount)笔, 当日收益:¥\(group.analyse.orderAmountSum)") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.orderLabelColor) + } + .padding(.vertical, AppMetrics.Spacing.xSmall) + } +} + +/// 收款记录明细卡片。 +struct PaymentRecordItemCard: View { + let item: PaymentCollectionRecordItem + + var body: some View { + VStack(spacing: 6) { + HStack(alignment: .center) { + Text("¥\(item.orderAmount)") + .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) + .foregroundStyle(AppDesign.primary) + Spacer() + Text("订单号:\(item.orderNumber)") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(PaymentCollectionDesign.orderBadgeText) + .padding(.horizontal, AppMetrics.Spacing.xxSmall) + .padding(.vertical, 2) + .background(PaymentCollectionDesign.orderBadgeBackground, in: RoundedRectangle(cornerRadius: 4)) + } + + HStack { + Text(item.userPhone) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(PaymentCollectionDesign.mutedText) + Spacer() + Text(item.createTime) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.orderLabelColor) + } + } + .padding(AppMetrics.Spacing.medium) + .overlay { + RoundedRectangle(cornerRadius: PaymentCollectionDesign.cardRadius) + .stroke(PaymentCollectionDesign.recordBorder, lineWidth: 1) + } + } +} + +/// 收款记录空态。 +struct PaymentRecordEmptyView: View { + var body: some View { + VStack { + Spacer(minLength: 80) + Text("暂无收款记录") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(PaymentCollectionDesign.mutedText) + Spacer() + } + .frame(maxWidth: .infinity) + } +} + +/// 收款页背景色。 +extension PaymentCollectionView { + static var pageBackground: Color { + PaymentCollectionDesign.pageBackground + } +} diff --git a/suixinkan/Features/Payment/Views/PaymentCollectionView.swift b/suixinkan/Features/Payment/Views/PaymentCollectionView.swift index bef1757..d60e6b0 100644 --- a/suixinkan/Features/Payment/Views/PaymentCollectionView.swift +++ b/suixinkan/Features/Payment/Views/PaymentCollectionView.swift @@ -17,31 +17,69 @@ struct PaymentCollectionView: View { @Environment(\.globalLoading) private var globalLoading @StateObject private var viewModel = PaymentCollectionViewModel() - @State private var showingAmountSheet = false + @State private var showingAmountDialog = false @State private var pollingTask: Task? - @State private var speaker = PaymentVoiceSpeaker() + + private var scenicName: String { + accountContext.currentScenic?.name ?? "暂无景区" + } + + private var staffID: String { + accountContext.profile?.userId ?? "" + } 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) { + PaymentQRSection( + scenicName: scenicName, + qrImage: viewModel.qrImage, + paymentSuccessData: viewModel.paymentSuccessData, + onSetAmount: openAmountDialog, + onSaveQRCode: saveQRCode, + onRefresh: refreshPayCode + ) + + PaymentStatusSection( + scanSuccessTime: viewModel.scanSuccessTime, + paymentSuccessData: viewModel.paymentSuccessData + ) + + infoCard + NavigationLink { PaymentCollectionRecordView() } label: { - Image(systemName: "list.bullet.rectangle") + PaymentNavigationRow(title: "收款记录") } + .buttonStyle(.plain) + + voiceToggleRow + + if case .failed(let message) = viewModel.status { + PaymentWhiteCard { + Text(message) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.warning) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Self.pageBackground) + .navigationTitle("收款详情") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if showingAmountDialog { + PaymentSetAmountDialog( + amountText: $viewModel.amountText, + remarkText: $viewModel.remarkText, + canConfirm: viewModel.canConfirmAmount, + onConfirm: confirmAmount, + onDismiss: { showingAmountDialog = false } + ) } } .task(id: accountContext.currentScenic?.id) { @@ -49,10 +87,6 @@ struct PaymentCollectionView: View { await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } } - .sheet(isPresented: $showingAmountSheet) { - amountSheet - .presentationDetents([.medium]) - } .onDisappear { pollingTask?.cancel() } @@ -61,166 +95,60 @@ struct PaymentCollectionView: View { toastCenter.show(message) } } - .onChange(of: viewModel.status) { status in - if case .success(let item) = status { - speaker.speak("收款到账\(item.orderAmount)元") + } + + /// 景区/收款方/员工信息卡。 + private var infoCard: some View { + PaymentWhiteCard { + VStack(spacing: 6) { + PaymentInfoRow(label: "景区名称", value: scenicName) + PaymentInfoRow(label: "收款方", value: PaymentMerchantInfo.payeeCompany) + PaymentInfoRow(label: "员工ID", value: staffID) } } } - /// 景区上下文展示区。 - 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) - } + /// 收款到账语音提醒开关行。 + private var voiceToggleRow: some View { + HStack { + Text("收款到账语音提醒") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.orderLabelColor) Spacer() + Toggle("", isOn: Binding( + get: { viewModel.isVoiceBroadcast }, + set: { _ in viewModel.toggleVoiceBroadcast() } + )) + .labelsHidden() } - .padding(AppMetrics.Spacing.medium) - .background(.white, in: RoundedRectangle(cornerRadius: 8)) + .frame(height: 52) + .padding(.horizontal, AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) } - /// 二维码展示卡片。 - private var qrCard: some View { - VStack(spacing: AppMetrics.Spacing.medium) { - 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 globalLoading.withLoading(message: "刷新中...") { - await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) - } - } - } + /// 打开设置金额弹窗。 + private func openAmountDialog() { + if viewModel.prepareAmountDialog() { + showingAmountDialog = true } } - /// 当前收款状态展示卡。 - @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)) + /// 确认设置金额并启动轮询 fallback。 + private func confirmAmount() { + if viewModel.confirmAmount() { + showingAmountDialog = false + toastCenter.show("金额设置成功,二维码已更新") + startPolling() } } - /// 设置金额弹窗。 - 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)) + /// 刷新收款码。 + private func refreshPayCode() { + Task { + await globalLoading.withLoading(message: "刷新中...") { + await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } } - .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)) - } } /// 启动本次动态收款轮询。 @@ -234,8 +162,8 @@ struct PaymentCollectionView: View { /// 保存当前二维码到系统相册。 private func saveQRCode() { - guard let image = viewModel.qrImage else { - toastCenter.show("暂无可保存二维码") + guard viewModel.hasStaticPayCode, let image = viewModel.qrImage else { + toastCenter.show("请先获取收款码") return } PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in @@ -247,7 +175,7 @@ struct PaymentCollectionView: View { PHAssetChangeRequest.creationRequestForAsset(from: image) } completionHandler: { success, error in Task { @MainActor in - toastCenter.show(success ? "二维码已保存" : (error?.localizedDescription ?? "保存失败")) + toastCenter.show(success ? "保存成功" : (error?.localizedDescription ?? "保存失败")) } } } @@ -263,37 +191,36 @@ struct PaymentCollectionRecordView: View { @StateObject private var viewModel = PaymentCollectionRecordViewModel() var body: some View { - List { - 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) + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + if viewModel.groups.isEmpty { + PaymentRecordEmptyView() + } else { + PaymentWhiteCard { + VStack(spacing: AppMetrics.Spacing.medium) { + ForEach(viewModel.groups) { group in + VStack(spacing: AppMetrics.Spacing.xSmall) { + PaymentRecordDayHeader(group: group) + ForEach(group.items) { item in + PaymentRecordItemCard(item: item) + } + } } - 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)") } + + Text("仅支持查看7天的数据") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(Color(hex: 0xB6BECA)) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.bottom, AppMetrics.Spacing.medium) } } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.top, AppMetrics.Spacing.medium) } + .background(PaymentCollectionView.pageBackground) .navigationTitle("收款记录") .navigationBarTitleDisplayMode(.inline) .refreshable { @@ -315,8 +242,12 @@ struct PaymentCollectionRecordView: View { /// 收款语音播报服务,使用系统 TTS 播报到账提示。 @MainActor final class PaymentVoiceSpeaker { + static let shared = PaymentVoiceSpeaker() + private let synthesizer = AVSpeechSynthesizer() + private init() {} + /// 播报指定中文文案。 func speak(_ text: String) { let utterance = AVSpeechUtterance(string: text) diff --git a/suixinkanTests/PaymentViewModelTests.swift b/suixinkanTests/PaymentViewModelTests.swift index a7f44da..c6b2c60 100644 --- a/suixinkanTests/PaymentViewModelTests.swift +++ b/suixinkanTests/PaymentViewModelTests.swift @@ -14,7 +14,7 @@ final class PaymentViewModelTests: XCTestCase { /// 测试无景区时清空二维码且不请求接口。 func testLoadPayCodeWithoutScenicClearsState() async { let api = PaymentMockAPI() - let viewModel = PaymentCollectionViewModel() + let viewModel = makeViewModel() viewModel.staticPayUrl = "old" await viewModel.loadPayCode(api: api, scenicId: nil) @@ -28,7 +28,7 @@ final class PaymentViewModelTests: XCTestCase { func testLoadPayCodeUsesScenicIdAndAppliesStaticURL() async { let api = PaymentMockAPI() api.payCodeResponse = PayCodeResponse(staticPayUrl: "https://pay.example.com/static", dynamicPayUrl: "https://pay.example.com/dynamic") - let viewModel = PaymentCollectionViewModel() + let viewModel = makeViewModel() await viewModel.loadPayCode(api: api, scenicId: 9) @@ -39,7 +39,7 @@ final class PaymentViewModelTests: XCTestCase { /// 测试设置金额会校验金额并生成动态收款 URL。 func testApplyDynamicAmountBuildsDynamicURL() async { - let viewModel = PaymentCollectionViewModel() + let viewModel = makeViewModel() viewModel.dynamicPayUrl = "https://pay.example.com/dynamic?scenic_id=9" viewModel.amountText = "12.345" viewModel.remarkText = "茶水" @@ -50,7 +50,71 @@ final class PaymentViewModelTests: XCTestCase { XCTAssertEqual(viewModel.status, .waiting) } - /// 测试轮询命中新收款记录后进入成功态。 + /// 测试确认金额会校验格式并更新二维码。 + func testConfirmAmountUpdatesQRCode() { + let viewModel = makeViewModel() + viewModel.staticPayUrl = "https://pay.example.com/static" + viewModel.dynamicPayUrl = "https://pay.example.com/dynamic?scenic_id=9" + viewModel.amountText = "18.50" + viewModel.remarkText = "门票" + + XCTAssertTrue(viewModel.confirmAmount()) + XCTAssertTrue(viewModel.currentPayUrl.contains("amount=18.5")) + XCTAssertEqual(viewModel.status, .waiting) + XCTAssertNil(viewModel.scanSuccessTime) + XCTAssertNil(viewModel.paymentSuccessData) + } + + /// 测试扫码成功推送会写入扫码时间并清空付款成功态。 + func testHandlePaymentPushScanSuccess() { + let viewModel = makeViewModel() + viewModel.paymentSuccessData = PaymentSuccessDisplayData( + orderAmount: "10", + orderNumber: "A", + payTime: "2026-06-22 10:00", + remark: "" + ) + + viewModel.handlePaymentPush(type: 6, dataJSON: nil) + + XCTAssertNotNil(viewModel.scanSuccessTime) + XCTAssertNil(viewModel.paymentSuccessData) + } + + /// 测试付款成功推送会解析详情并清空扫码态。 + func testHandlePaymentPushPaymentSuccess() { + let viewModel = makeViewModel() + viewModel.scanSuccessTime = "2026-06-22 10:00" + let payload = """ + {"order_amount":"88.00","order_number":"ORDER-1","pay_time":"2026-06-22 10:01","remark":"测试"} + """ + + viewModel.handlePaymentPush(type: 1, dataJSON: payload) + + XCTAssertNil(viewModel.scanSuccessTime) + XCTAssertEqual(viewModel.paymentSuccessData?.orderAmount, "88.00") + XCTAssertEqual(viewModel.paymentSuccessData?.orderNumber, "ORDER-1") + XCTAssertEqual(viewModel.paymentSuccessData?.payTime, "2026-06-22 10:01") + XCTAssertEqual(viewModel.paymentSuccessData?.remark, "测试") + } + + /// 测试语音开关会写入偏好存储。 + func testToggleVoiceBroadcastPersistsPreference() { + let defaults = UserDefaults(suiteName: "PaymentViewModelTests.voice")! + defaults.removePersistentDomain(forName: "PaymentViewModelTests.voice") + let preferences = AppPreferencesStore(defaults: defaults) + let viewModel = makeViewModel(preferencesStore: preferences) + + XCTAssertFalse(viewModel.isVoiceBroadcast) + viewModel.toggleVoiceBroadcast() + XCTAssertTrue(viewModel.isVoiceBroadcast) + XCTAssertTrue(preferences.loadPaymentReceiveVoiceEnabled()) + + let reloaded = makeViewModel(preferencesStore: preferences) + XCTAssertTrue(reloaded.isVoiceBroadcast) + } + + /// 测试轮询命中新收款记录后进入成功态并填充详情。 func testPollingFindsNewPaymentRecord() async { let api = PaymentMockAPI() api.recordResponses = [ @@ -62,13 +126,18 @@ final class PaymentViewModelTests: XCTestCase { PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01") ]) ] - let viewModel = PaymentCollectionViewModel() + let viewModel = makeViewModel() viewModel.amountText = "12.35" + viewModel.remarkText = "备注" 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"))) + XCTAssertEqual(viewModel.paymentSuccessData?.orderAmount, "12.35") + XCTAssertEqual(viewModel.paymentSuccessData?.orderNumber, "B") + XCTAssertEqual(viewModel.paymentSuccessData?.payTime, "2026-06-22 10:01") + XCTAssertEqual(viewModel.paymentSuccessData?.remark, "备注") } /// 测试后端缺少日汇总时能从列表兜底生成分组。 @@ -84,6 +153,11 @@ final class PaymentViewModelTests: XCTestCase { XCTAssertEqual(groups[0].analyse.orderCount, 2) XCTAssertEqual(groups[0].analyse.orderAmountSum, "20") } + + /// 创建测试用 ViewModel,并允许注入独立偏好存储。 + private func makeViewModel(preferencesStore: AppPreferencesStore = AppPreferencesStore(defaults: UserDefaults(suiteName: "PaymentViewModelTests.default")!)) -> PaymentCollectionViewModel { + PaymentCollectionViewModel(preferencesStore: preferencesStore) + } } @MainActor diff --git a/suixinkanUITests/Support/UITestHomeMenuCatalog.swift b/suixinkanUITests/Support/UITestHomeMenuCatalog.swift index c26972f..e949e54 100644 --- a/suixinkanUITests/Support/UITestHomeMenuCatalog.swift +++ b/suixinkanUITests/Support/UITestHomeMenuCatalog.swift @@ -30,7 +30,7 @@ enum UITestHomeMenuCatalog { Entry(menuTitle: "相册管理", expectation: .navigationTitle("相册管理")), Entry(menuTitle: "相册预览上传", expectation: .navigationTitle("相册预览上传")), Entry(menuTitle: "我的钱包", expectation: .navigationTitle("我的钱包")), - Entry(menuTitle: "立即收款", expectation: .navigationTitle("立即收款")), + Entry(menuTitle: "立即收款", expectation: .navigationTitle("收款详情")), Entry(menuTitle: "相册云盘", expectation: .navigationTitle("相册云盘")), Entry(menuTitle: "传输管理", expectation: .navigationTitle("传输记录")), Entry(menuTitle: "素材管理", expectation: .navigationTitle("素材管理")),