Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests. Co-authored-by: Cursor <cursoragent@cursor.com>
245 lines
8.9 KiB
Swift
245 lines
8.9 KiB
Swift
//
|
||
// PaymentViewModel.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/22.
|
||
//
|
||
|
||
import CoreImage
|
||
import CoreImage.CIFilterBuiltins
|
||
import Foundation
|
||
import Combine
|
||
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 isLoading = false
|
||
@Published var isPolling = false
|
||
@Published var errorMessage: String?
|
||
@Published var status: PaymentCollectionStatus = .idle
|
||
|
||
@Published private var knownRecordIDs = Set<String>()
|
||
private let context = CIContext()
|
||
private let qrFilter = CIFilter.qrCodeGenerator()
|
||
|
||
/// 当前静态收款码是否可展示。
|
||
var hasStaticPayCode: Bool {
|
||
!staticPayUrl.paymentTrimmed.isEmpty
|
||
}
|
||
|
||
/// 加载当前景区收款码,无景区时清空收款状态。
|
||
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
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 根据输入金额和备注生成动态二维码 URL。
|
||
@discardableResult
|
||
func applyDynamicAmount() -> Bool {
|
||
guard let amount = Self.normalizedMoney(amountText), Decimal(string: amount) ?? 0 > 0 else {
|
||
errorMessage = "请输入有效收款金额"
|
||
return false
|
||
}
|
||
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
|
||
}
|
||
|
||
/// 记录当前已知收款记录,轮询时只把新增记录判定为本次到账。
|
||
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)
|
||
status = .success(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
|
||
}
|
||
|
||
/// 将输入金额规范成最多两位小数。
|
||
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
|
||
}
|
||
|
||
/// 从收款记录中找到本次动态收款对应的新记录。
|
||
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)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
/// 收款记录 ViewModel,管理收款记录加载、日期分组和汇总兜底。
|
||
final class PaymentCollectionRecordViewModel: ObservableObject {
|
||
@Published var groups: [PaymentCollectionRecordGroup] = []
|
||
@Published 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)
|
||
}
|
||
}
|
||
}
|