// // 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() 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) } } }