对齐 Android 立即收款页面 UI,并补齐推送状态与语音开关。
统一收款详情布局、设置金额弹窗和收款记录样式,接入 type 6/1 推送驱动与轮询 fallback,使 iOS 收款反馈与 Android 一致。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user