Add Payment and Wallet modules with home routing integration.
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>
This commit is contained in:
55
suixinkan/Features/Payment/API/PaymentAPI.swift
Normal file
55
suixinkan/Features/Payment/API/PaymentAPI.swift
Normal file
@ -0,0 +1,55 @@
|
||||
//
|
||||
// PaymentAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 收款模块服务协议,定义收款码和收款记录接口能力。
|
||||
@MainActor
|
||||
protocol PaymentServing {
|
||||
/// 获取当前景区的静态收款码和动态收款码基础 URL。
|
||||
func payCode(scenicId: Int) async throws -> PayCodeResponse
|
||||
|
||||
/// 获取当前景区的扫码收款记录。
|
||||
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 收款 API,封装首页“收款”模块需要的网络请求。
|
||||
final class PaymentAPI {
|
||||
@ObservationIgnored 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/Features/Payment/Models/PaymentModels.swift
Normal file
182
suixinkan/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/Features/Payment/Payment.md
Normal file
25
suixinkan/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。
|
||||
246
suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift
Normal file
246
suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift
Normal file
@ -0,0 +1,246 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
325
suixinkan/Features/Payment/Views/PaymentCollectionView.swift
Normal file
325
suixinkan/Features/Payment/Views/PaymentCollectionView.swift
Normal file
@ -0,0 +1,325 @@
|
||||
//
|
||||
// PaymentCollectionView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Photos
|
||||
import SwiftUI
|
||||
|
||||
/// 收款页,展示静态收款码、动态金额收款和收款记录入口。
|
||||
struct PaymentCollectionView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PaymentAPI.self) private var paymentAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
|
||||
@State private var viewModel = PaymentCollectionViewModel()
|
||||
@State private var showingAmountSheet = false
|
||||
@State private var pollingTask: Task<Void, Never>?
|
||||
@State private var speaker = PaymentVoiceSpeaker()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
contextHeader
|
||||
qrCard
|
||||
actionGrid
|
||||
statusCard
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.pageVertical)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("立即收款")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
PaymentCollectionRecordView()
|
||||
} label: {
|
||||
Image(systemName: "list.bullet.rectangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: accountContext.currentScenic?.id) {
|
||||
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||
}
|
||||
.sheet(isPresented: $showingAmountSheet) {
|
||||
amountSheet
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
.onDisappear {
|
||||
pollingTask?.cancel()
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.status) { _, status in
|
||||
if case .success(let item) = status {
|
||||
speaker.speak("收款到账\(item.orderAmount)元")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区上下文展示区。
|
||||
private var contextHeader: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
|
||||
Text(accountContext.currentScenic?.name ?? "未选择景区")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text("收款记录按当前景区查询")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// 二维码展示卡片。
|
||||
private var qrCard: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(width: 220, height: 220)
|
||||
} else if let image = viewModel.qrImage {
|
||||
Image(uiImage: image)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 220, height: 220)
|
||||
.background(.white)
|
||||
} else {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.system(size: 56, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
Text("暂无收款码")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
.frame(width: 220, height: 220)
|
||||
}
|
||||
|
||||
Text(viewModel.currentPayUrl.isEmpty ? "请先加载收款码" : "向客户展示二维码完成收款")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(AppMetrics.Spacing.large)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// 收款操作按钮区。
|
||||
private var actionGrid: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
paymentActionButton(title: "设置金额", icon: "yensign.circle.fill") {
|
||||
showingAmountSheet = true
|
||||
}
|
||||
paymentActionButton(title: "保存二维码", icon: "square.and.arrow.down") {
|
||||
saveQRCode()
|
||||
}
|
||||
paymentActionButton(title: "刷新", icon: "arrow.clockwise") {
|
||||
Task { await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前收款状态展示卡。
|
||||
@ViewBuilder
|
||||
private var statusCard: some View {
|
||||
switch viewModel.status {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .waiting:
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
ProgressView()
|
||||
Text("等待付款到账...")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
case .success(let item):
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Label("收款成功", systemImage: "checkmark.circle.fill")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.success)
|
||||
Text("订单号:\(item.orderNumber)")
|
||||
Text("金额:¥\(item.orderAmount)")
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
case .failed(let message):
|
||||
Text(message)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置金额弹窗。
|
||||
private var amountSheet: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
Text("设置收款金额")
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
|
||||
TextField("请输入金额", text: $viewModel.amountText)
|
||||
.keyboardType(.decimalPad)
|
||||
.appInputFieldStyle(cornerRadius: 8, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
|
||||
TextField("备注,可不填", text: $viewModel.remarkText)
|
||||
.appInputFieldStyle(cornerRadius: 8, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
|
||||
Button {
|
||||
if viewModel.applyDynamicAmount() {
|
||||
showingAmountSheet = false
|
||||
startPolling()
|
||||
} else if let message = viewModel.errorMessage {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
} label: {
|
||||
Text("生成动态二维码")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
.foregroundStyle(.white)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.large)
|
||||
}
|
||||
|
||||
/// 生成操作按钮。
|
||||
private func paymentActionButton(title: String, icon: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(maxWidth: .infinity, minHeight: 76)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动本次动态收款轮询。
|
||||
private func startPolling() {
|
||||
pollingTask?.cancel()
|
||||
pollingTask = Task {
|
||||
await viewModel.primePaymentRecords(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||
await viewModel.pollUntilPaymentDetected(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存当前二维码到系统相册。
|
||||
private func saveQRCode() {
|
||||
guard let image = viewModel.qrImage else {
|
||||
toastCenter.show("暂无可保存二维码")
|
||||
return
|
||||
}
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
||||
guard status == .authorized || status == .limited else {
|
||||
Task { @MainActor in toastCenter.show("请允许添加照片权限") }
|
||||
return
|
||||
}
|
||||
PHPhotoLibrary.shared().performChanges {
|
||||
PHAssetChangeRequest.creationRequestForAsset(from: image)
|
||||
} completionHandler: { success, error in
|
||||
Task { @MainActor in
|
||||
toastCenter.show(success ? "二维码已保存" : (error?.localizedDescription ?? "保存失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录页,按日期展示扫码收款明细。
|
||||
struct PaymentCollectionRecordView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PaymentAPI.self) private var paymentAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = PaymentCollectionRecordViewModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
ForEach(viewModel.groups) { group in
|
||||
Section {
|
||||
ForEach(group.items) { item in
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
HStack {
|
||||
Text("¥\(item.orderAmount)")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
Spacer()
|
||||
Text(item.createTime)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
Text(item.orderNumber)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(item.userPhone)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text(group.analyse.date)
|
||||
Spacer()
|
||||
Text("\(group.analyse.orderCount)笔 ¥\(group.analyse.orderAmountSum)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("收款记录")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.refreshable {
|
||||
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||
}
|
||||
.task(id: accountContext.currentScenic?.id) {
|
||||
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款语音播报服务,使用系统 TTS 播报到账提示。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PaymentVoiceSpeaker {
|
||||
@ObservationIgnored private let synthesizer = AVSpeechSynthesizer()
|
||||
|
||||
/// 播报指定中文文案。
|
||||
func speak(_ text: String) {
|
||||
let utterance = AVSpeechUtterance(string: text)
|
||||
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
|
||||
utterance.rate = 0.5
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user