Introduce real payment collection and wallet screens to replace home menu placeholders, wire APIs through RootView, and support bank card OSS uploads plus QR code saving to the photo library. Co-authored-by: Cursor <cursoragent@cursor.com>
247 lines
8.7 KiB
Swift
247 lines
8.7 KiB
Swift
//
|
||
// PaymentViewModel.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/22.
|
||
//
|
||
|
||
import CoreImage
|
||
import CoreImage.CIFilterBuiltins
|
||
import Foundation
|
||
import Observation
|
||
import UIKit
|
||
|
||
@MainActor
|
||
@Observable
|
||
/// 收款页 ViewModel,管理收款码加载、动态金额二维码和到账轮询。
|
||
final class PaymentCollectionViewModel {
|
||
var staticPayUrl = ""
|
||
var dynamicPayUrl = ""
|
||
var amountText = ""
|
||
var remarkText = ""
|
||
var currentPayUrl = ""
|
||
var qrImage: UIImage?
|
||
var isLoading = false
|
||
var isPolling = false
|
||
var errorMessage: String?
|
||
var status: PaymentCollectionStatus = .idle
|
||
|
||
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
|
||
@Observable
|
||
/// 收款记录 ViewModel,管理收款记录加载、日期分组和汇总兜底。
|
||
final class PaymentCollectionRecordViewModel {
|
||
var groups: [PaymentCollectionRecordGroup] = []
|
||
var isLoading = false
|
||
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)
|
||
}
|
||
}
|
||
}
|