对齐 Android 立即收款页面 UI,并补齐推送状态与语音开关。
统一收款详情布局、设置金额弹窗和收款记录样式,接入 type 6/1 推送驱动与轮询 fallback,使 iOS 收款反馈与 Android 一致。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -124,6 +124,13 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func route(from payload: PushPayload) {
|
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 }
|
guard let router else { return }
|
||||||
|
|
||||||
switch payload.route {
|
switch payload.route {
|
||||||
@ -147,4 +154,15 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
|||||||
router.select(.home)
|
router.select(.home)
|
||||||
router.router(for: .home).navigate(to: .home(route))
|
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)元")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,7 +46,7 @@ struct PushPayload: Sendable {
|
|||||||
let actionText = values["action"] ?? ""
|
let actionText = values["action"] ?? ""
|
||||||
let merged = [typeText, routeText, uriText, actionText].joined(separator: " ").lowercased()
|
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
|
return .payment
|
||||||
}
|
}
|
||||||
if merged.contains("queue") || merged.contains("排队") || merged.contains("叫号") || uriText == "/scenic-queue" {
|
if merged.contains("queue") || merged.contains("排队") || merged.contains("叫号") || uriText == "/scenic-queue" {
|
||||||
@ -64,6 +64,34 @@ struct PushPayload: Sendable {
|
|||||||
return .messageCenter
|
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]) {
|
nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) {
|
||||||
if let dict = value as? [String: Any] {
|
if let dict = value as? [String: Any] {
|
||||||
for (key, value) in dict {
|
for (key, value) in dict {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ final class AppPreferencesStore {
|
|||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
|
private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
|
||||||
private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted"
|
private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted"
|
||||||
|
private let paymentReceiveVoiceKey = "suixinkan.preferences.payment_receive_voice"
|
||||||
|
|
||||||
/// 初始化偏好存储服务,并允许测试注入独立的 UserDefaults。
|
/// 初始化偏好存储服务,并允许测试注入独立的 UserDefaults。
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
@ -43,6 +44,16 @@ final class AppPreferencesStore {
|
|||||||
defaults.bool(forKey: privacyAgreementAcceptedKey)
|
defaults.bool(forKey: privacyAgreementAcceptedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取收款到账语音提醒开关。
|
||||||
|
func loadPaymentReceiveVoiceEnabled() -> Bool {
|
||||||
|
defaults.bool(forKey: paymentReceiveVoiceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存收款到账语音提醒开关。
|
||||||
|
func savePaymentReceiveVoiceEnabled(_ enabled: Bool) {
|
||||||
|
defaults.set(enabled, forKey: paymentReceiveVoiceKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// 清空登录页偏好。
|
/// 清空登录页偏好。
|
||||||
func clear() {
|
func clear() {
|
||||||
defaults.removeObject(forKey: lastLoginUsernameKey)
|
defaults.removeObject(forKey: lastLoginUsernameKey)
|
||||||
|
|||||||
@ -137,6 +137,19 @@ struct PaymentCollectionRecordGroup: Equatable, Identifiable {
|
|||||||
var id: String { analyse.id }
|
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 {
|
enum PaymentCollectionStatus: Equatable {
|
||||||
case idle
|
case idle
|
||||||
|
|||||||
@ -2,24 +2,43 @@
|
|||||||
|
|
||||||
## 模块职责
|
## 模块职责
|
||||||
|
|
||||||
`Features/Payment` 承接首页 `payment_collection`、`payment_qr`、`payment_code` 权限入口,负责当前景区下的收款码、动态金额二维码、收款记录和到账轮询。
|
`Features/Payment` 承接首页 `payment_collection`、`payment_qr`、`payment_code` 权限入口,负责当前景区下的收款码、动态金额二维码、收款记录和到账反馈。
|
||||||
|
|
||||||
## 代码结构
|
## 代码结构
|
||||||
|
|
||||||
- `PaymentAPI`:封装收款码和扫码收款记录接口。
|
- `PaymentAPI`:封装收款码和扫码收款记录接口。
|
||||||
- `PaymentCollectionViewModel`:管理收款码加载、金额校验、动态二维码生成和到账轮询。
|
- `PaymentCollectionViewModel`:管理收款码加载、金额校验、动态二维码生成、推送状态和到账轮询 fallback。
|
||||||
- `PaymentCollectionRecordViewModel`:管理收款记录加载和按日期分组。
|
- `PaymentCollectionRecordViewModel`:管理收款记录加载和按日期分组。
|
||||||
- `PaymentCollectionView`:展示收款码、金额弹窗、保存二维码和收款状态。
|
- `PaymentCollectionView`:展示 Android 对齐后的收款详情页 UI。
|
||||||
|
- `PaymentCollectionComponents`:收款页复用 UI 组件(白卡、QR 区、设置金额弹窗、记录卡片)。
|
||||||
|
- `PaymentPushEventCenter`:应用内收款推送事件总线,对齐 Android `PushBus`。
|
||||||
- `PaymentCollectionRecordView`:展示收款记录明细。
|
- `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=...` 获取收款码。
|
2. 有景区时调用 `/api/yf-handset-app/photog/pay-code?scenic_id=...` 获取收款码。
|
||||||
3. 设置金额后基于动态收款码 URL 追加 `amount` 和可选 `remark`,生成二维码。
|
3. 设置金额后基于动态收款码 URL 追加 `amount` 和可选 `remark`,生成二维码。
|
||||||
4. 开始轮询 `/api/yf-handset-app/photog/order/photo-scan-order-list?scenic_id=...`,最多 60 次,每 2 秒一次。
|
4. 优先通过推送更新 UI:
|
||||||
5. 命中新收款记录后进入成功态,并使用系统语音播报到账金额。
|
- `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。
|
||||||
|
|||||||
@ -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<PaymentPushEvent, Never>()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/// 收款页可订阅的推送事件流。
|
||||||
|
var events: AnyPublisher<PaymentPushEvent, Never> {
|
||||||
|
subject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布一条收款相关推送事件。
|
||||||
|
func post(type: Int, dataJSON: String?) {
|
||||||
|
subject.send(PaymentPushEvent(type: type, dataJSON: dataJSON))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,12 +7,12 @@
|
|||||||
|
|
||||||
import CoreImage
|
import CoreImage
|
||||||
import CoreImage.CIFilterBuiltins
|
import CoreImage.CIFilterBuiltins
|
||||||
import Foundation
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
/// 收款页 ViewModel,管理收款码加载、动态金额二维码和到账轮询。
|
/// 收款页 ViewModel,管理收款码加载、动态金额二维码、推送状态和到账轮询。
|
||||||
final class PaymentCollectionViewModel: ObservableObject {
|
final class PaymentCollectionViewModel: ObservableObject {
|
||||||
@Published var staticPayUrl = ""
|
@Published var staticPayUrl = ""
|
||||||
@Published var dynamicPayUrl = ""
|
@Published var dynamicPayUrl = ""
|
||||||
@ -20,20 +20,43 @@ final class PaymentCollectionViewModel: ObservableObject {
|
|||||||
@Published var remarkText = ""
|
@Published var remarkText = ""
|
||||||
@Published var currentPayUrl = ""
|
@Published var currentPayUrl = ""
|
||||||
@Published var qrImage: UIImage?
|
@Published var qrImage: UIImage?
|
||||||
private var isLoading = false
|
|
||||||
private var isPolling = false
|
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var status: PaymentCollectionStatus = .idle
|
@Published var status: PaymentCollectionStatus = .idle
|
||||||
|
@Published var isVoiceBroadcast = false
|
||||||
|
@Published var scanSuccessTime: String?
|
||||||
|
@Published var paymentSuccessData: PaymentSuccessDisplayData?
|
||||||
|
|
||||||
@Published private var knownRecordIDs = Set<String>()
|
@Published private var knownRecordIDs = Set<String>()
|
||||||
|
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 context = CIContext()
|
||||||
private let qrFilter = CIFilter.qrCodeGenerator()
|
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 {
|
var hasStaticPayCode: Bool {
|
||||||
!staticPayUrl.paymentTrimmed.isEmpty
|
!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 {
|
func loadPayCode(api: PaymentServing, scenicId: Int?) async {
|
||||||
guard let scenicId else {
|
guard let scenicId else {
|
||||||
@ -53,38 +76,92 @@ final class PaymentCollectionViewModel: ObservableObject {
|
|||||||
currentPayUrl = response.staticPayUrl
|
currentPayUrl = response.staticPayUrl
|
||||||
qrImage = makeQRCode(from: currentPayUrl)
|
qrImage = makeQRCode(from: currentPayUrl)
|
||||||
status = .idle
|
status = .idle
|
||||||
|
scanSuccessTime = nil
|
||||||
|
paymentSuccessData = nil
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据输入金额和备注生成动态二维码 URL。
|
/// 打开设置金额弹窗前清空输入并校验静态码是否可用。
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func applyDynamicAmount() -> Bool {
|
func prepareAmountDialog() -> Bool {
|
||||||
guard let amount = Self.normalizedMoney(amountText), Decimal(string: amount) ?? 0 > 0 else {
|
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 = "请输入有效收款金额"
|
errorMessage = "请输入有效收款金额"
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
guard !dynamicPayUrl.paymentTrimmed.isEmpty else {
|
guard amountText.matches(regex: #"^\d+(\.\d{1,2})?$"#) else {
|
||||||
errorMessage = "动态收款码暂不可用"
|
errorMessage = "金额最多支持两位小数"
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
guard applyDynamicAmount(normalizedAmount: amount) else {
|
||||||
var components = URLComponents(string: dynamicPayUrl)
|
return false
|
||||||
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
|
scanSuccessTime = nil
|
||||||
currentPayUrl = components?.url?.absoluteString ?? "\(dynamicPayUrl)&amount=\(amount)"
|
paymentSuccessData = nil
|
||||||
qrImage = makeQRCode(from: currentPayUrl)
|
|
||||||
amountText = amount
|
|
||||||
status = .waiting
|
|
||||||
return true
|
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 {
|
func primePaymentRecords(api: PaymentServing, scenicId: Int?) async {
|
||||||
guard let scenicId else { return }
|
guard let scenicId else { return }
|
||||||
@ -115,7 +192,7 @@ final class PaymentCollectionViewModel: ObservableObject {
|
|||||||
let response = try await api.paymentCollectionRecords(scenicId: scenicId)
|
let response = try await api.paymentCollectionRecords(scenicId: scenicId)
|
||||||
if let item = matchedNewRecord(in: response.list) {
|
if let item = matchedNewRecord(in: response.list) {
|
||||||
knownRecordIDs.insert(item.id)
|
knownRecordIDs.insert(item.id)
|
||||||
status = .success(item)
|
applySuccessState(from: item)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -137,6 +214,8 @@ final class PaymentCollectionViewModel: ObservableObject {
|
|||||||
qrImage = nil
|
qrImage = nil
|
||||||
knownRecordIDs = []
|
knownRecordIDs = []
|
||||||
status = .idle
|
status = .idle
|
||||||
|
scanSuccessTime = nil
|
||||||
|
paymentSuccessData = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将输入金额规范成最多两位小数。
|
/// 将输入金额规范成最多两位小数。
|
||||||
@ -157,6 +236,90 @@ final class PaymentCollectionViewModel: ObservableObject {
|
|||||||
return number.rounding(accordingToBehavior: handler).stringValue
|
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? {
|
private func matchedNewRecord(in records: [PaymentCollectionRecordItem]) -> PaymentCollectionRecordItem? {
|
||||||
let newRecords = records.filter { !knownRecordIDs.contains($0.id) }
|
let newRecords = records.filter { !knownRecordIDs.contains($0.id) }
|
||||||
@ -187,6 +350,11 @@ private extension String {
|
|||||||
var paymentTrimmed: String {
|
var paymentTrimmed: String {
|
||||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断字符串是否匹配正则表达式。
|
||||||
|
func matches(regex pattern: String) -> Bool {
|
||||||
|
range(of: pattern, options: .regularExpression) != nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@ -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<Content: View>: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,31 +17,69 @@ struct PaymentCollectionView: View {
|
|||||||
@Environment(\.globalLoading) private var globalLoading
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
|
||||||
@StateObject private var viewModel = PaymentCollectionViewModel()
|
@StateObject private var viewModel = PaymentCollectionViewModel()
|
||||||
@State private var showingAmountSheet = false
|
@State private var showingAmountDialog = false
|
||||||
@State private var pollingTask: Task<Void, Never>?
|
@State private var pollingTask: Task<Void, Never>?
|
||||||
@State private var speaker = PaymentVoiceSpeaker()
|
|
||||||
|
private var scenicName: String {
|
||||||
|
accountContext.currentScenic?.name ?? "暂无景区"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var staffID: String {
|
||||||
|
accountContext.profile?.userId ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
contextHeader
|
PaymentQRSection(
|
||||||
qrCard
|
scenicName: scenicName,
|
||||||
actionGrid
|
qrImage: viewModel.qrImage,
|
||||||
statusCard
|
paymentSuccessData: viewModel.paymentSuccessData,
|
||||||
}
|
onSetAmount: openAmountDialog,
|
||||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
onSaveQRCode: saveQRCode,
|
||||||
.padding(.vertical, AppMetrics.Spacing.pageVertical)
|
onRefresh: refreshPayCode
|
||||||
}
|
)
|
||||||
.background(Color(hex: 0xF5F7FA))
|
|
||||||
.navigationTitle("立即收款")
|
PaymentStatusSection(
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
scanSuccessTime: viewModel.scanSuccessTime,
|
||||||
.toolbar {
|
paymentSuccessData: viewModel.paymentSuccessData
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
)
|
||||||
|
|
||||||
|
infoCard
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
PaymentCollectionRecordView()
|
PaymentCollectionRecordView()
|
||||||
} label: {
|
} 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) {
|
.task(id: accountContext.currentScenic?.id) {
|
||||||
@ -49,10 +87,6 @@ struct PaymentCollectionView: View {
|
|||||||
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAmountSheet) {
|
|
||||||
amountSheet
|
|
||||||
.presentationDetents([.medium])
|
|
||||||
}
|
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
pollingTask?.cancel()
|
pollingTask?.cancel()
|
||||||
}
|
}
|
||||||
@ -61,167 +95,61 @@ struct PaymentCollectionView: View {
|
|||||||
toastCenter.show(message)
|
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 {
|
private var voiceToggleRow: some View {
|
||||||
HStack(spacing: AppMetrics.Spacing.small) {
|
HStack {
|
||||||
Image(systemName: "mappin.and.ellipse")
|
Text("收款到账语音提醒")
|
||||||
.foregroundStyle(AppDesign.primary)
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
|
.foregroundStyle(AppDesign.orderLabelColor)
|
||||||
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()
|
Spacer()
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { viewModel.isVoiceBroadcast },
|
||||||
|
set: { _ in viewModel.toggleVoiceBroadcast() }
|
||||||
|
))
|
||||||
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
.padding(AppMetrics.Spacing.medium)
|
.frame(height: 52)
|
||||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 二维码展示卡片。
|
/// 打开设置金额弹窗。
|
||||||
private var qrCard: some View {
|
private func openAmountDialog() {
|
||||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
if viewModel.prepareAmountDialog() {
|
||||||
if let image = viewModel.qrImage {
|
showingAmountDialog = true
|
||||||
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 ? "请先加载收款码" : "向客户展示二维码完成收款")
|
/// 确认设置金额并启动轮询 fallback。
|
||||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
private func confirmAmount() {
|
||||||
.foregroundStyle(AppDesign.textSecondary)
|
if viewModel.confirmAmount() {
|
||||||
.multilineTextAlignment(.center)
|
showingAmountDialog = false
|
||||||
|
toastCenter.show("金额设置成功,二维码已更新")
|
||||||
|
startPolling()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(AppMetrics.Spacing.large)
|
|
||||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 收款操作按钮区。
|
/// 刷新收款码。
|
||||||
private var actionGrid: some View {
|
private func refreshPayCode() {
|
||||||
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 {
|
Task {
|
||||||
await globalLoading.withLoading(message: "刷新中...") {
|
await globalLoading.withLoading(message: "刷新中...") {
|
||||||
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
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() {
|
private func startPolling() {
|
||||||
@ -234,8 +162,8 @@ struct PaymentCollectionView: View {
|
|||||||
|
|
||||||
/// 保存当前二维码到系统相册。
|
/// 保存当前二维码到系统相册。
|
||||||
private func saveQRCode() {
|
private func saveQRCode() {
|
||||||
guard let image = viewModel.qrImage else {
|
guard viewModel.hasStaticPayCode, let image = viewModel.qrImage else {
|
||||||
toastCenter.show("暂无可保存二维码")
|
toastCenter.show("请先获取收款码")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
||||||
@ -247,7 +175,7 @@ struct PaymentCollectionView: View {
|
|||||||
PHAssetChangeRequest.creationRequestForAsset(from: image)
|
PHAssetChangeRequest.creationRequestForAsset(from: image)
|
||||||
} completionHandler: { success, error in
|
} completionHandler: { success, error in
|
||||||
Task { @MainActor 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()
|
@StateObject private var viewModel = PaymentCollectionRecordViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
ScrollView {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
if viewModel.groups.isEmpty {
|
||||||
|
PaymentRecordEmptyView()
|
||||||
|
} else {
|
||||||
|
PaymentWhiteCard {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
ForEach(viewModel.groups) { group in
|
ForEach(viewModel.groups) { group in
|
||||||
Section {
|
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||||
|
PaymentRecordDayHeader(group: group)
|
||||||
ForEach(group.items) { item in
|
ForEach(group.items) { item in
|
||||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
PaymentRecordItemCard(item: item)
|
||||||
HStack {
|
}
|
||||||
Text("¥\(item.orderAmount)")
|
}
|
||||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
}
|
||||||
Spacer()
|
}
|
||||||
Text(item.createTime)
|
}
|
||||||
|
|
||||||
|
Text("仅支持查看7天的数据")
|
||||||
.font(.system(size: AppMetrics.FontSize.caption))
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
.foregroundStyle(AppDesign.textSecondary)
|
.foregroundStyle(Color(hex: 0xB6BECA))
|
||||||
}
|
.frame(maxWidth: .infinity)
|
||||||
Text(item.orderNumber)
|
.multilineTextAlignment(.center)
|
||||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
.padding(.bottom, AppMetrics.Spacing.medium)
|
||||||
.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)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||||
|
.padding(.top, AppMetrics.Spacing.medium)
|
||||||
}
|
}
|
||||||
|
.background(PaymentCollectionView.pageBackground)
|
||||||
.navigationTitle("收款记录")
|
.navigationTitle("收款记录")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.refreshable {
|
.refreshable {
|
||||||
@ -315,8 +242,12 @@ struct PaymentCollectionRecordView: View {
|
|||||||
/// 收款语音播报服务,使用系统 TTS 播报到账提示。
|
/// 收款语音播报服务,使用系统 TTS 播报到账提示。
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PaymentVoiceSpeaker {
|
final class PaymentVoiceSpeaker {
|
||||||
|
static let shared = PaymentVoiceSpeaker()
|
||||||
|
|
||||||
private let synthesizer = AVSpeechSynthesizer()
|
private let synthesizer = AVSpeechSynthesizer()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
/// 播报指定中文文案。
|
/// 播报指定中文文案。
|
||||||
func speak(_ text: String) {
|
func speak(_ text: String) {
|
||||||
let utterance = AVSpeechUtterance(string: text)
|
let utterance = AVSpeechUtterance(string: text)
|
||||||
|
|||||||
@ -14,7 +14,7 @@ final class PaymentViewModelTests: XCTestCase {
|
|||||||
/// 测试无景区时清空二维码且不请求接口。
|
/// 测试无景区时清空二维码且不请求接口。
|
||||||
func testLoadPayCodeWithoutScenicClearsState() async {
|
func testLoadPayCodeWithoutScenicClearsState() async {
|
||||||
let api = PaymentMockAPI()
|
let api = PaymentMockAPI()
|
||||||
let viewModel = PaymentCollectionViewModel()
|
let viewModel = makeViewModel()
|
||||||
viewModel.staticPayUrl = "old"
|
viewModel.staticPayUrl = "old"
|
||||||
|
|
||||||
await viewModel.loadPayCode(api: api, scenicId: nil)
|
await viewModel.loadPayCode(api: api, scenicId: nil)
|
||||||
@ -28,7 +28,7 @@ final class PaymentViewModelTests: XCTestCase {
|
|||||||
func testLoadPayCodeUsesScenicIdAndAppliesStaticURL() async {
|
func testLoadPayCodeUsesScenicIdAndAppliesStaticURL() async {
|
||||||
let api = PaymentMockAPI()
|
let api = PaymentMockAPI()
|
||||||
api.payCodeResponse = PayCodeResponse(staticPayUrl: "https://pay.example.com/static", dynamicPayUrl: "https://pay.example.com/dynamic")
|
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)
|
await viewModel.loadPayCode(api: api, scenicId: 9)
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ final class PaymentViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
/// 测试设置金额会校验金额并生成动态收款 URL。
|
/// 测试设置金额会校验金额并生成动态收款 URL。
|
||||||
func testApplyDynamicAmountBuildsDynamicURL() async {
|
func testApplyDynamicAmountBuildsDynamicURL() async {
|
||||||
let viewModel = PaymentCollectionViewModel()
|
let viewModel = makeViewModel()
|
||||||
viewModel.dynamicPayUrl = "https://pay.example.com/dynamic?scenic_id=9"
|
viewModel.dynamicPayUrl = "https://pay.example.com/dynamic?scenic_id=9"
|
||||||
viewModel.amountText = "12.345"
|
viewModel.amountText = "12.345"
|
||||||
viewModel.remarkText = "茶水"
|
viewModel.remarkText = "茶水"
|
||||||
@ -50,7 +50,71 @@ final class PaymentViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.status, .waiting)
|
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 {
|
func testPollingFindsNewPaymentRecord() async {
|
||||||
let api = PaymentMockAPI()
|
let api = PaymentMockAPI()
|
||||||
api.recordResponses = [
|
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")
|
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.amountText = "12.35"
|
||||||
|
viewModel.remarkText = "备注"
|
||||||
await viewModel.primePaymentRecords(api: api, scenicId: 9)
|
await viewModel.primePaymentRecords(api: api, scenicId: 9)
|
||||||
|
|
||||||
await viewModel.pollUntilPaymentDetected(api: api, scenicId: 9, maxAttempts: 1, intervalNanoseconds: 0)
|
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.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.orderCount, 2)
|
||||||
XCTAssertEqual(groups[0].analyse.orderAmountSum, "20")
|
XCTAssertEqual(groups[0].analyse.orderAmountSum, "20")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建测试用 ViewModel,并允许注入独立偏好存储。
|
||||||
|
private func makeViewModel(preferencesStore: AppPreferencesStore = AppPreferencesStore(defaults: UserDefaults(suiteName: "PaymentViewModelTests.default")!)) -> PaymentCollectionViewModel {
|
||||||
|
PaymentCollectionViewModel(preferencesStore: preferencesStore)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@ -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("收款详情")),
|
||||||
Entry(menuTitle: "相册云盘", expectation: .navigationTitle("相册云盘")),
|
Entry(menuTitle: "相册云盘", expectation: .navigationTitle("相册云盘")),
|
||||||
Entry(menuTitle: "传输管理", expectation: .navigationTitle("传输记录")),
|
Entry(menuTitle: "传输管理", expectation: .navigationTitle("传输记录")),
|
||||||
Entry(menuTitle: "素材管理", expectation: .navigationTitle("素材管理")),
|
Entry(menuTitle: "素材管理", expectation: .navigationTitle("素材管理")),
|
||||||
|
|||||||
Reference in New Issue
Block a user