对齐 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) {
|
||||
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)元")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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.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<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 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
|
||||
|
||||
@ -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
|
||||
|
||||
@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 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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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("素材管理")),
|
||||
|
||||
Reference in New Issue
Block a user