Files
汉秋 3dcfb99254 对齐 Android 立即收款页面 UI,并补齐推送状态与语音开关。
统一收款详情布局、设置金额弹窗和收款记录样式,接入 type 6/1 推送驱动与轮询 fallback,使 iOS 收款反馈与 Android 一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:17:09 +08:00

413 lines
15 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// PaymentViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import CoreImage
import CoreImage.CIFilterBuiltins
import Combine
import Foundation
import UIKit
@MainActor
/// ViewModel
final class PaymentCollectionViewModel: ObservableObject {
@Published var staticPayUrl = ""
@Published var dynamicPayUrl = ""
@Published var amountText = ""
@Published var remarkText = ""
@Published var currentPayUrl = ""
@Published var qrImage: UIImage?
@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 {
resetPayCode()
errorMessage = "请先选择景区"
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response = try await api.payCode(scenicId: scenicId)
staticPayUrl = response.staticPayUrl
dynamicPayUrl = response.dynamicPayUrl
currentPayUrl = response.staticPayUrl
qrImage = makeQRCode(from: currentPayUrl)
status = .idle
scanSuccessTime = nil
paymentSuccessData = nil
} catch {
errorMessage = error.localizedDescription
}
}
///
@discardableResult
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 amountText.matches(regex: #"^\d+(\.\d{1,2})?$"#) else {
errorMessage = "金额最多支持两位小数"
return false
}
guard applyDynamicAmount(normalizedAmount: amount) else {
return false
}
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 }
if let response = try? await api.paymentCollectionRecords(scenicId: scenicId) {
knownRecordIDs = Set(response.list.map(\.id))
}
}
///
func pollUntilPaymentDetected(
api: PaymentServing,
scenicId: Int?,
maxAttempts: Int = 60,
intervalNanoseconds: UInt64 = 2_000_000_000
) async {
guard let scenicId else {
status = .failed("请先选择景区")
return
}
guard maxAttempts > 0 else { return }
isPolling = true
defer { isPolling = false }
for attempt in 0 ..< maxAttempts {
if Task.isCancelled { return }
do {
let response = try await api.paymentCollectionRecords(scenicId: scenicId)
if let item = matchedNewRecord(in: response.list) {
knownRecordIDs.insert(item.id)
applySuccessState(from: item)
return
}
} catch {
status = .failed(error.localizedDescription)
return
}
if attempt < maxAttempts - 1 {
try? await Task.sleep(nanoseconds: intervalNanoseconds)
}
}
status = .failed("暂未查询到到账记录,请稍后刷新收款记录")
}
///
func resetPayCode() {
staticPayUrl = ""
dynamicPayUrl = ""
currentPayUrl = ""
qrImage = nil
knownRecordIDs = []
status = .idle
scanSuccessTime = nil
paymentSuccessData = nil
}
///
static func normalizedMoney(_ rawValue: String) -> String? {
let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
let sanitized = text.replacingOccurrences(of: ",", with: "")
guard let decimal = Decimal(string: sanitized), decimal > 0 else { return nil }
let number = NSDecimalNumber(decimal: decimal)
let handler = NSDecimalNumberHandler(
roundingMode: .plain,
scale: 2,
raiseOnExactness: false,
raiseOnOverflow: false,
raiseOnUnderflow: false,
raiseOnDivideByZero: false
)
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) }
guard !newRecords.isEmpty else { return nil }
guard let amount = Self.normalizedMoney(amountText) else {
return newRecords.first
}
return newRecords.first { Self.normalizedMoney($0.orderAmount) == amount } ?? newRecords.first
}
/// 使
private func makeQRCode(from text: String) -> UIImage? {
guard !text.paymentTrimmed.isEmpty,
let data = text.data(using: .utf8) else {
return nil
}
qrFilter.setValue(data, forKey: "inputMessage")
qrFilter.setValue("M", forKey: "inputCorrectionLevel")
guard let output = qrFilter.outputImage else { return nil }
let transformed = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}
private extension String {
/// 使 fileprivate
var paymentTrimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
///
func matches(regex pattern: String) -> Bool {
range(of: pattern, options: .regularExpression) != nil
}
}
@MainActor
/// ViewModel
final class PaymentCollectionRecordViewModel: ObservableObject {
@Published var groups: [PaymentCollectionRecordGroup] = []
private var isLoading = false
@Published var errorMessage: String?
///
func load(api: PaymentServing, scenicId: Int?) async {
guard let scenicId else {
groups = []
errorMessage = "请先选择景区"
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response = try await api.paymentCollectionRecords(scenicId: scenicId)
groups = Self.makeGroups(from: response)
} catch {
errorMessage = error.localizedDescription
}
}
/// analyse list
static func makeGroups(from response: PaymentCollectionRecordResponse) -> [PaymentCollectionRecordGroup] {
if !response.analyse.isEmpty {
return response.analyse.map { analyse in
PaymentCollectionRecordGroup(
analyse: analyse,
items: response.list.filter { $0.createDate == analyse.date }
)
}
}
let grouped = Dictionary(grouping: response.list, by: \.createDate)
return grouped.keys.sorted(by: >).map { date in
let items = grouped[date] ?? []
let total = items.reduce(Decimal(0)) { partial, item in
partial + (Decimal(string: item.orderAmount) ?? 0)
}
let analyse = PaymentCollectionRecordAnalyseItem(
date: date,
orderCount: items.count,
orderAmountSum: NSDecimalNumber(decimal: total).stringValue
)
return PaymentCollectionRecordGroup(analyse: analyse, items: items)
}
}
}