Add instant payment collection flow aligned with Android.
Implement收款详情与收款记录 pages with pay-code API, QR generation, amount setting, and record grouping so merchants can collect payments from the home quick action. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
38
suixinkan/Features/Payment/API/PaymentAPI.swift
Normal file
38
suixinkan/Features/Payment/API/PaymentAPI.swift
Normal file
@ -0,0 +1,38 @@
|
||||
//
|
||||
// PaymentAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 收款相关 API,封装摄影师收款码与收款记录接口。
|
||||
final class PaymentAPI {
|
||||
private let client: APIClient
|
||||
|
||||
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))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取近 7 天收款记录。
|
||||
func collectionRecord(scenicId: Int) async throws -> RepaymentCollectionRecordResponse {
|
||||
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))]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
103
suixinkan/Features/Payment/Models/PaymentModels.swift
Normal file
103
suixinkan/Features/Payment/Models/PaymentModels.swift
Normal file
@ -0,0 +1,103 @@
|
||||
//
|
||||
// PaymentModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 摄影师收款码响应,包含静态与动态支付 URL。
|
||||
struct PayCodeResponse: Decodable, Sendable, Equatable {
|
||||
let staticPayURL: String
|
||||
let dynamicPayURL: String
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录响应,含按日汇总与明细列表。
|
||||
struct RepaymentCollectionRecordResponse: Decodable, Sendable, Equatable {
|
||||
let analyse: [RepaymentCollectionRecordAnalyseItem]
|
||||
let list: [RepaymentCollectionRecordItem]
|
||||
|
||||
init(
|
||||
analyse: [RepaymentCollectionRecordAnalyseItem] = [],
|
||||
list: [RepaymentCollectionRecordItem] = []
|
||||
) {
|
||||
self.analyse = analyse
|
||||
self.list = list
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录按日汇总项。
|
||||
struct RepaymentCollectionRecordAnalyseItem: Decodable, Sendable, Equatable, Hashable {
|
||||
let date: String
|
||||
let orderCount: Int
|
||||
let orderAmountSum: String
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录明细项。
|
||||
struct RepaymentCollectionRecordItem: Decodable, Sendable, Equatable, Hashable {
|
||||
let orderNumber: String
|
||||
let userPhone: String
|
||||
let orderAmount: String
|
||||
let createDate: String
|
||||
let createTime: String
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 按日分组的收款记录,对齐 Android `RepaymentCollectionRecordGroup`。
|
||||
struct RepaymentCollectionRecordGroup: Sendable, Equatable, Hashable {
|
||||
let analyse: RepaymentCollectionRecordAnalyseItem
|
||||
let items: [RepaymentCollectionRecordItem]
|
||||
}
|
||||
|
||||
/// 收款商户展示信息。
|
||||
struct PaymentMerchantInfo: Sendable, Equatable {
|
||||
let name: String
|
||||
let company: String
|
||||
|
||||
static let defaultInfo = PaymentMerchantInfo(
|
||||
name: "元智享",
|
||||
company: "扬州元智享网络科技有限公司"
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
//
|
||||
// PaymentCollectionDetailsViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 收款详情 ViewModel,管理收款码拉取、设金额、保存二维码与语音开关。
|
||||
final class PaymentCollectionDetailsViewModel {
|
||||
|
||||
private(set) var loading = false
|
||||
private(set) var qrImage: UIImage?
|
||||
private(set) var scenicName = ""
|
||||
private(set) var staffId = ""
|
||||
private(set) var isVoiceBroadcastOpen = false
|
||||
private(set) var showAmountDialog = false
|
||||
private(set) var amount = ""
|
||||
private(set) var remark = ""
|
||||
|
||||
let merchantInfo = PaymentMerchantInfo.defaultInfo
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
private var staticPayURL = ""
|
||||
private var dynamicPayURL = ""
|
||||
private let appStore: AppStore
|
||||
|
||||
init(appStore: AppStore = .shared) {
|
||||
self.appStore = appStore
|
||||
}
|
||||
|
||||
var hasPayCode: Bool {
|
||||
qrImage != nil && !staticPayURL.isEmpty
|
||||
}
|
||||
|
||||
var displayScenicName: String {
|
||||
scenicName.isEmpty ? "暂无景区" : scenicName
|
||||
}
|
||||
|
||||
func refreshLocalState() {
|
||||
scenicName = appStore.currentScenicName
|
||||
staffId = appStore.userId
|
||||
isVoiceBroadcastOpen = appStore.isOpenReceiveVoice
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func loadPayCode(api: PaymentAPI) async {
|
||||
guard !loading else { return }
|
||||
refreshLocalState()
|
||||
loading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
loading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let scenicId = appStore.currentScenicId
|
||||
guard scenicId > 0 else {
|
||||
clearPayCode()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.payCode(scenicId: scenicId)
|
||||
staticPayURL = response.staticPayURL
|
||||
dynamicPayURL = response.dynamicPayURL
|
||||
qrImage = QRCodeGenerator.generateStyledQRCode(content: staticPayURL)
|
||||
} catch {
|
||||
clearPayCode()
|
||||
}
|
||||
}
|
||||
|
||||
func refresh(api: PaymentAPI) async {
|
||||
await loadPayCode(api: api)
|
||||
}
|
||||
|
||||
func showSetAmountDialog() {
|
||||
guard !staticPayURL.isEmpty else {
|
||||
onShowMessage?("请先获取收款码")
|
||||
return
|
||||
}
|
||||
amount = ""
|
||||
remark = ""
|
||||
showAmountDialog = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func hideAmountDialog() {
|
||||
showAmountDialog = false
|
||||
amount = ""
|
||||
remark = ""
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateAmount(_ value: String) {
|
||||
guard value.isEmpty || value.matches(PaymentAmountValidator.inputPattern) else { return }
|
||||
amount = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateRemark(_ value: String) {
|
||||
guard value.count <= 200 else { return }
|
||||
remark = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func confirmAmount() {
|
||||
if let message = PaymentAmountValidator.validationMessage(for: amount) {
|
||||
onShowMessage?(message)
|
||||
return
|
||||
}
|
||||
guard !dynamicPayURL.isEmpty else {
|
||||
onShowMessage?("请先获取收款码")
|
||||
return
|
||||
}
|
||||
|
||||
let dynamicURL = dynamicPayURL + "&amount=\(amount)&remark=\(remark)"
|
||||
qrImage = QRCodeGenerator.generateStyledQRCode(content: dynamicURL)
|
||||
showAmountDialog = false
|
||||
onShowMessage?("金额设置成功,二维码已更新")
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func saveQRCode() async {
|
||||
guard qrImage != nil else {
|
||||
onShowMessage?("请先获取收款码")
|
||||
return
|
||||
}
|
||||
let success = await PhotoLibrarySaver.saveImage(qrImage!)
|
||||
onShowMessage?(success ? "保存成功" : "保存失败")
|
||||
}
|
||||
|
||||
func toggleReceiveVoice() {
|
||||
isVoiceBroadcastOpen.toggle()
|
||||
appStore.isOpenReceiveVoice = isVoiceBroadcastOpen
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func clearPayCode() {
|
||||
staticPayURL = ""
|
||||
dynamicPayURL = ""
|
||||
qrImage = nil
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款金额校验,对齐 Android `PaymentCollectionDetailsViewModel.confirmAmount`。
|
||||
enum PaymentAmountValidator {
|
||||
static let inputPattern = #"^\d*(\.\d{0,2})?$"#
|
||||
private static let confirmPattern = #"^\d+(\.\d{1,2})?$"#
|
||||
|
||||
static func validationMessage(for amount: String) -> String? {
|
||||
if amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "请输入收款金额"
|
||||
}
|
||||
guard let value = Double(amount), value > 0 else {
|
||||
return "请输入有效的收款金额"
|
||||
}
|
||||
if !amount.matches(confirmPattern) {
|
||||
return "金额最多支持两位小数"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func matches(_ pattern: String) -> Bool {
|
||||
range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
//
|
||||
// PaymentCollectionRecordViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 收款记录 ViewModel,拉取近 7 天记录并按日分组展示。
|
||||
final class PaymentCollectionRecordViewModel {
|
||||
|
||||
private(set) var loading = false
|
||||
private(set) var groups: [RepaymentCollectionRecordGroup] = []
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
private let appStore: AppStore
|
||||
|
||||
init(appStore: AppStore = .shared) {
|
||||
self.appStore = appStore
|
||||
}
|
||||
|
||||
func loadRecord(api: PaymentAPI) async {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
loading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let scenicId = appStore.currentScenicId
|
||||
guard scenicId > 0 else {
|
||||
groups = []
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.collectionRecord(scenicId: scenicId)
|
||||
groups = PaymentRecordGrouper.group(response: response)
|
||||
} catch {
|
||||
groups = []
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录分组逻辑,对齐 Android `PaymentCollectionRecordViewModel.getRecord`。
|
||||
enum PaymentRecordGrouper {
|
||||
static func group(response: RepaymentCollectionRecordResponse) -> [RepaymentCollectionRecordGroup] {
|
||||
let mapByDate = Dictionary(grouping: response.list, by: \.createDate)
|
||||
return response.analyse.map { analyseItem in
|
||||
RepaymentCollectionRecordGroup(
|
||||
analyse: analyseItem,
|
||||
items: mapByDate[analyseItem.date] ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user