Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
53
suixinkan_ios/Features/Payment/API/PaymentAPI.swift
Normal file
53
suixinkan_ios/Features/Payment/API/PaymentAPI.swift
Normal file
@ -0,0 +1,53 @@
|
||||
//
|
||||
// PaymentAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 收款模块服务协议,定义收款码和收款记录接口能力。
|
||||
@MainActor
|
||||
protocol PaymentServing {
|
||||
/// 获取当前景区的静态收款码和动态收款码基础 URL。
|
||||
func payCode(scenicId: Int) async throws -> PayCodeResponse
|
||||
|
||||
/// 获取当前景区的扫码收款记录。
|
||||
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 收款 API,封装首页“收款”模块需要的网络请求。
|
||||
final class PaymentAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化收款 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取当前景区的静态收款码和动态收款码基础 URL。
|
||||
func payCode(scenicId: Int) async throws -> PayCodeResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/pay-code",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前景区的扫码收款记录。
|
||||
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/photo-scan-order-list",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PaymentAPI: PaymentServing {}
|
||||
182
suixinkan_ios/Features/Payment/Models/PaymentModels.swift
Normal file
182
suixinkan_ios/Features/Payment/Models/PaymentModels.swift
Normal file
@ -0,0 +1,182 @@
|
||||
//
|
||||
// PaymentModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 收款码响应实体,表示静态收款码 URL 和动态收款码基础 URL。
|
||||
struct PayCodeResponse: Decodable, Equatable {
|
||||
let staticPayUrl: String
|
||||
let dynamicPayUrl: String
|
||||
|
||||
/// 收款码 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case staticPayUrl = "static_pay_url"
|
||||
case dynamicPayUrl = "dynamic_pay_url"
|
||||
}
|
||||
|
||||
/// 创建收款码响应实体,主要用于测试替身。
|
||||
init(staticPayUrl: String = "", dynamicPayUrl: String = "") {
|
||||
self.staticPayUrl = staticPayUrl
|
||||
self.dynamicPayUrl = dynamicPayUrl
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容后端把 URL 字段返回为数字或空值。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
staticPayUrl = try container.decodeLossyString(forKey: .staticPayUrl)
|
||||
dynamicPayUrl = try container.decodeLossyString(forKey: .dynamicPayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录响应实体,包含后端日汇总和原始记录列表。
|
||||
struct PaymentCollectionRecordResponse: Decodable, Equatable {
|
||||
let analyse: [PaymentCollectionRecordAnalyseItem]
|
||||
let list: [PaymentCollectionRecordItem]
|
||||
|
||||
/// 收款记录响应 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case analyse
|
||||
case list
|
||||
}
|
||||
|
||||
/// 创建收款记录响应实体,主要用于测试替身。
|
||||
init(analyse: [PaymentCollectionRecordAnalyseItem] = [], list: [PaymentCollectionRecordItem] = []) {
|
||||
self.analyse = analyse
|
||||
self.list = list
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容后端字段缺失。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
analyse = try container.decodeIfPresent([PaymentCollectionRecordAnalyseItem].self, forKey: .analyse) ?? []
|
||||
list = try container.decodeIfPresent([PaymentCollectionRecordItem].self, forKey: .list) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款日汇总实体,表示某一天的收款笔数和金额。
|
||||
struct PaymentCollectionRecordAnalyseItem: Decodable, Equatable, Identifiable {
|
||||
let date: String
|
||||
let orderCount: Int
|
||||
let orderAmountSum: String
|
||||
|
||||
var id: String { date }
|
||||
|
||||
/// 收款日汇总 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case date
|
||||
case orderCount = "order_count"
|
||||
case orderAmountSum = "order_amount_sum"
|
||||
}
|
||||
|
||||
/// 创建收款日汇总实体,主要用于列表兜底和测试。
|
||||
init(date: String, orderCount: Int, orderAmountSum: String) {
|
||||
self.date = date
|
||||
self.orderCount = orderCount
|
||||
self.orderAmountSum = orderAmountSum
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容金额和数量字段类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
date = try container.decodeLossyString(forKey: .date)
|
||||
orderCount = try container.decodeLossyInt(forKey: .orderCount) ?? 0
|
||||
orderAmountSum = try container.decodeLossyString(forKey: .orderAmountSum)
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录实体,表示一次扫码收款订单。
|
||||
struct PaymentCollectionRecordItem: Decodable, Equatable, Identifiable {
|
||||
let orderNumber: String
|
||||
let userPhone: String
|
||||
let orderAmount: String
|
||||
let createDate: String
|
||||
let createTime: String
|
||||
|
||||
var id: String {
|
||||
orderNumber.isEmpty ? "\(createDate)-\(createTime)-\(orderAmount)" : orderNumber
|
||||
}
|
||||
|
||||
/// 收款记录 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case userPhone = "user_phone"
|
||||
case orderAmount = "order_amount"
|
||||
case createDate = "create_date"
|
||||
case createTime = "create_time"
|
||||
}
|
||||
|
||||
/// 创建收款记录实体,主要用于测试替身。
|
||||
init(orderNumber: String, userPhone: String, orderAmount: String, createDate: String, createTime: String) {
|
||||
self.orderNumber = orderNumber
|
||||
self.userPhone = userPhone
|
||||
self.orderAmount = orderAmount
|
||||
self.createDate = createDate
|
||||
self.createTime = createTime
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容订单号、手机号和金额字段类型不稳定。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
|
||||
createDate = try container.decodeLossyString(forKey: .createDate)
|
||||
createTime = try container.decodeLossyString(forKey: .createTime)
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录分组实体,表示一个日期下的汇总和明细。
|
||||
struct PaymentCollectionRecordGroup: Equatable, Identifiable {
|
||||
let analyse: PaymentCollectionRecordAnalyseItem
|
||||
let items: [PaymentCollectionRecordItem]
|
||||
|
||||
var id: String { analyse.id }
|
||||
}
|
||||
|
||||
/// 收款状态实体,表示当前动态收款轮询结果。
|
||||
enum PaymentCollectionStatus: Equatable {
|
||||
case idle
|
||||
case waiting
|
||||
case success(PaymentCollectionRecordItem)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将字符串、数字或空值宽松解码为字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try decodeIfPresent(Double.self, forKey: key) {
|
||||
return value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将字符串或数字宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
25
suixinkan_ios/Features/Payment/Payment.md
Normal file
25
suixinkan_ios/Features/Payment/Payment.md
Normal file
@ -0,0 +1,25 @@
|
||||
# 收款模块
|
||||
|
||||
## 模块职责
|
||||
|
||||
`Features/Payment` 承接首页 `payment_collection`、`payment_qr`、`payment_code` 权限入口,负责当前景区下的收款码、动态金额二维码、收款记录和到账轮询。
|
||||
|
||||
## 代码结构
|
||||
|
||||
- `PaymentAPI`:封装收款码和扫码收款记录接口。
|
||||
- `PaymentCollectionViewModel`:管理收款码加载、金额校验、动态二维码生成和到账轮询。
|
||||
- `PaymentCollectionRecordViewModel`:管理收款记录加载和按日期分组。
|
||||
- `PaymentCollectionView`:展示收款码、金额弹窗、保存二维码和收款状态。
|
||||
- `PaymentCollectionRecordView`:展示收款记录明细。
|
||||
|
||||
## 数据流
|
||||
|
||||
1. 页面读取 `AccountContext.currentScenic`。
|
||||
2. 有景区时调用 `/api/yf-handset-app/photog/pay-code?scenic_id=...` 获取收款码。
|
||||
3. 设置金额后基于动态收款码 URL 追加 `amount` 和可选 `remark`,生成二维码。
|
||||
4. 开始轮询 `/api/yf-handset-app/photog/order/photo-scan-order-list?scenic_id=...`,最多 60 次,每 2 秒一次。
|
||||
5. 命中新收款记录后进入成功态,并使用系统语音播报到账金额。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
收款码 URL、付款状态、轮询结果不落盘。保存二维码只写入系统相册,App 不额外缓存图片文件或 Data。
|
||||
@ -0,0 +1,81 @@
|
||||
//
|
||||
// PaymentCollectionViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 收款页。
|
||||
final class PaymentCollectionViewController: UIViewController {
|
||||
private let services = AppServices.shared
|
||||
private let viewModel = PaymentCollectionViewModel()
|
||||
private let qrImageView = UIImageView()
|
||||
private let statusLabel = UILabel()
|
||||
private let amountField = UITextField()
|
||||
private let remarkField = UITextField()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "收款"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
setupUI()
|
||||
viewModel.onChange = { [weak self] in self?.render() }
|
||||
Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) }
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
qrImageView.contentMode = .scaleAspectFit
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.textAlignment = .center
|
||||
statusLabel.textColor = AppDesign.textSecondary
|
||||
amountField.placeholder = "动态金额"
|
||||
amountField.borderStyle = .roundedRect
|
||||
amountField.keyboardType = .decimalPad
|
||||
remarkField.placeholder = "备注"
|
||||
remarkField.borderStyle = .roundedRect
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [qrImageView, statusLabel, amountField, remarkField])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
view.addSubview(stack)
|
||||
view.addSubview(activityIndicator)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(24)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
qrImageView.snp.makeConstraints { $0.height.equalTo(240) }
|
||||
activityIndicator.snp.makeConstraints { $0.center.equalTo(qrImageView) }
|
||||
|
||||
navigationItem.rightBarButtonItems = [
|
||||
UIBarButtonItem(title: "生成", style: .plain, target: self, action: #selector(applyAmount)),
|
||||
UIBarButtonItem(title: "轮询", style: .plain, target: self, action: #selector(togglePolling))
|
||||
]
|
||||
}
|
||||
|
||||
private func render() {
|
||||
qrImageView.image = viewModel.qrImage
|
||||
statusLabel.text = viewModel.errorMessage ?? String(describing: viewModel.status)
|
||||
if viewModel.isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
|
||||
}
|
||||
|
||||
@objc private func applyAmount() {
|
||||
viewModel.amountText = amountField.text ?? ""
|
||||
viewModel.remarkText = remarkField.text ?? ""
|
||||
_ = viewModel.applyDynamicAmount()
|
||||
}
|
||||
|
||||
@objc private func togglePolling() {
|
||||
Task {
|
||||
await viewModel.pollUntilPaymentDetected(
|
||||
api: services.paymentAPI,
|
||||
scenicId: services.currentScenicId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PaymentCollectionViewModel: ViewModelBindable {}
|
||||
245
suixinkan_ios/Features/Payment/ViewModels/PaymentViewModel.swift
Normal file
245
suixinkan_ios/Features/Payment/ViewModels/PaymentViewModel.swift
Normal file
@ -0,0 +1,245 @@
|
||||
//
|
||||
// PaymentViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 收款页 ViewModel,管理收款码加载、动态金额二维码和到账轮询。
|
||||
final class PaymentCollectionViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var staticPayUrl = "" { didSet { onChange?() } }
|
||||
var dynamicPayUrl = "" { didSet { onChange?() } }
|
||||
var amountText = "" { didSet { onChange?() } }
|
||||
var remarkText = "" { didSet { onChange?() } }
|
||||
var currentPayUrl = "" { didSet { onChange?() } }
|
||||
var qrImage: UIImage? { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isPolling = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
var status: PaymentCollectionStatus = .idle { didSet { onChange?() } }
|
||||
|
||||
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 {
|
||||
var onChange: (() -> Void)?
|
||||
var groups: [PaymentCollectionRecordGroup] = [] { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载当前景区收款记录,无景区时不请求接口。
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user