Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View 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 {}

View 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
}
}

View 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。

View File

@ -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 {}

View 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)
}
}
}