对齐 Android 立即收款页面 UI,并补齐推送状态与语音开关。

统一收款详情布局、设置金额弹窗和收款记录样式,接入 type 6/1 推送驱动与轮询 fallback,使 iOS 收款反馈与 Android 一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 15:17:09 +08:00
parent 5bdf4a7dbf
commit 3dcfb99254
11 changed files with 924 additions and 228 deletions

View File

@ -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