添加即时收款流程并对齐 Android

This commit is contained in:
2026-07-07 09:29:03 +08:00
parent 0964ef8b80
commit 16e86c8899
17 changed files with 1548 additions and 1 deletions

View File

@ -16,6 +16,7 @@ final class NetworkServices {
let statisticsAPI: StatisticsAPI let statisticsAPI: StatisticsAPI
let orderAPI: OrderAPI let orderAPI: OrderAPI
let homeAPI: HomeAPI let homeAPI: HomeAPI
let paymentAPI: PaymentAPI
let uploadAPI: UploadAPI let uploadAPI: UploadAPI
let ossUploadService: OSSUploadService let ossUploadService: OSSUploadService
@ -27,6 +28,7 @@ final class NetworkServices {
statisticsAPI = StatisticsAPI(client: client) statisticsAPI = StatisticsAPI(client: client)
orderAPI = OrderAPI(client: client) orderAPI = OrderAPI(client: client)
homeAPI = HomeAPI(client: client) homeAPI = HomeAPI(client: client)
paymentAPI = PaymentAPI(client: client)
uploadAPI = UploadAPI(client: client) uploadAPI = UploadAPI(client: client)
ossUploadService = OSSUploadService(configService: uploadAPI) ossUploadService = OSSUploadService(configService: uploadAPI)
client.bindAuthTokenProvider { client.bindAuthTokenProvider {
@ -43,6 +45,7 @@ final class NetworkServices {
statisticsAPI = StatisticsAPI(client: apiClient) statisticsAPI = StatisticsAPI(client: apiClient)
orderAPI = OrderAPI(client: apiClient) orderAPI = OrderAPI(client: apiClient)
homeAPI = HomeAPI(client: apiClient) homeAPI = HomeAPI(client: apiClient)
paymentAPI = PaymentAPI(client: apiClient)
uploadAPI = UploadAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient)
ossUploadService = OSSUploadService(configService: uploadAPI) ossUploadService = OSSUploadService(configService: uploadAPI)
} }

View File

@ -0,0 +1,37 @@
//
// PhotoLibrarySaver.swift
// suixinkan
//
import Photos
import UIKit
/// 使
enum PhotoLibrarySaver {
///
static func saveImage(_ image: UIImage) async -> Bool {
let status = await requestAuthorizationIfNeeded()
guard status == .authorized || status == .limited else {
return false
}
return await withCheckedContinuation { continuation in
PHPhotoLibrary.shared().performChanges({
PHAssetChangeRequest.creationRequestForAsset(from: image)
}, completionHandler: { success, _ in
continuation.resume(returning: success)
})
}
}
private static func requestAuthorizationIfNeeded() async -> PHAuthorizationStatus {
let current = PHPhotoLibrary.authorizationStatus(for: .addOnly)
switch current {
case .notDetermined:
return await PHPhotoLibrary.requestAuthorization(for: .addOnly)
default:
return current
}
}
}

View File

@ -0,0 +1,59 @@
//
// QRCodeGenerator.swift
// suixinkan
//
import CoreImage
import UIKit
/// Android `QrCodeUtils.generateStyledQrBitmap`
enum QRCodeGenerator {
private static let defaultPixelSize = 580
private static let defaultCornerRadius: CGFloat = 24
///
static func generateStyledQRCode(
content: String,
pixelSize: Int = defaultPixelSize,
backgroundColor: UIColor = AppColor.inputBackground,
cornerRadius: CGFloat = defaultCornerRadius
) -> UIImage? {
guard !content.isEmpty,
let qrImage = makeQRCodeImage(from: content, pixelSize: pixelSize) else {
return nil
}
let size = CGSize(width: pixelSize, height: pixelSize)
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { context in
let rect = CGRect(origin: .zero, size: size)
let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius)
backgroundColor.setFill()
path.fill()
context.cgContext.saveGState()
path.addClip()
qrImage.draw(in: rect)
context.cgContext.restoreGState()
}
}
private static func makeQRCodeImage(from content: String, pixelSize: Int) -> UIImage? {
guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
let data = Data(content.utf8)
filter.setValue(data, forKey: "inputMessage")
filter.setValue("M", forKey: "inputCorrectionLevel")
guard let outputImage = filter.outputImage else { return nil }
let scale = CGFloat(pixelSize) / outputImage.extent.width
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
let context = CIContext()
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else {
return nil
}
return UIImage(cgImage: cgImage)
}
}

View File

@ -33,6 +33,7 @@ final class AppStore {
static let onlineStatus = "key_online_status" static let onlineStatus = "key_online_status"
static let lastLocationReportTime = "key_last_location_report_time" static let lastLocationReportTime = "key_last_location_report_time"
static let locationReminderMinutes = "key_location_reminder_minutes" static let locationReminderMinutes = "key_location_reminder_minutes"
static let isOpenReceiveVoice = "key_is_open_receive_voice"
} }
private let defaults: UserDefaults private let defaults: UserDefaults
@ -154,6 +155,12 @@ final class AppStore {
set { defaults.set(newValue, forKey: accountScopedKey(Key.locationReminderMinutes)) } set { defaults.set(newValue, forKey: accountScopedKey(Key.locationReminderMinutes)) }
} }
/// Android `getIsOpenReceiveVoice`
var isOpenReceiveVoice: Bool {
get { defaults.bool(forKey: accountScopedKey(Key.isOpenReceiveVoice)) }
set { defaults.set(newValue, forKey: accountScopedKey(Key.isOpenReceiveVoice)) }
}
/// Android `getAccountCachePrefix` /// Android `getAccountCachePrefix`
var accountCachePrefix: String { var accountCachePrefix: String {
let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines) let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines)
@ -309,6 +316,7 @@ final class AppStore {
Key.onlineStatus, Key.onlineStatus,
Key.lastLocationReportTime, Key.lastLocationReportTime,
Key.locationReminderMinutes, Key.locationReminderMinutes,
Key.isOpenReceiveVoice,
] ]
keys.forEach { defaults.removeObject(forKey: prefix + $0) } keys.forEach { defaults.removeObject(forKey: prefix + $0) }
} }

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

View 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: "扬州元智享网络科技有限公司"
)
}

View File

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

View File

@ -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] ?? []
)
}
}
}

View File

@ -10,6 +10,8 @@
<string>需要定位以获取多点位核销打卡点列表</string> <string>需要定位以获取多点位核销打卡点列表</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以选择头像、身份证和银行卡照片</string> <string>需要访问相册以选择头像、身份证和银行卡照片</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要保存收款二维码到相册</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>

View File

@ -77,7 +77,16 @@ final class HomeViewController: BaseViewController {
locationReportCardView.onReportTap = { [weak self] in locationReportCardView.onReportTap = { [weak self] in
Task { await self?.viewModel.manualReportLocation(api: self?.homeAPI ?? NetworkServices.shared.homeAPI) } Task { await self?.viewModel.manualReportLocation(api: self?.homeAPI ?? NetworkServices.shared.homeAPI) }
} }
quickActionsView.onCollectPayment = { [weak self] in self?.showToast("功能开发中") } quickActionsView.onCollectPayment = { [weak self] in
guard AppStore.shared.currentScenicId > 0 else {
self?.showToast("请先选择景区")
return
}
self?.navigationController?.pushViewController(
PaymentCollectionDetailsViewController(),
animated: true
)
}
quickActionsView.onSubmitTask = { [weak self] in self?.showToast("功能开发中") } quickActionsView.onSubmitTask = { [weak self] in self?.showToast("功能开发中") }
quickActionsView.onToggleOnline = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() } quickActionsView.onToggleOnline = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() }
commonAppsGridView.onMenuSelected = { [weak self] menu in commonAppsGridView.onMenuSelected = { [weak self] menu in

View File

@ -0,0 +1,382 @@
//
// PaymentCollectionDetailsViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class PaymentCollectionDetailsViewController: BaseViewController {
private let viewModel = PaymentCollectionDetailsViewModel()
private let paymentAPI = NetworkServices.shared.paymentAPI
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let qrCardView = UIView()
private let scenicNameLabel = UILabel()
private let qrImageView = UIImageView()
private let qrPlaceholderView = UIView()
private let qrErrorLabel = UILabel()
private let refreshButton = UIButton(type: .system)
private let setAmountButton = UIButton(type: .system)
private let saveQRButton = UIButton(type: .system)
private let actionRow = UIStackView()
private let infoCardView = UIView()
private let scenicRow = PaymentInfoRowView(title: "景区名称")
private let merchantRow = PaymentInfoRowView(title: "收款方")
private let staffRow = PaymentInfoRowView(title: "员工 ID")
private let recordRow = PaymentNavigationRowView(title: "收款记录")
private let voiceCardView = UIView()
private let voiceTitleLabel = UILabel()
private let voiceSwitch = UISwitch()
private var amountDialog: PaymentSetAmountDialogView?
override func setupNavigationBar() {
title = "收款详情"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.md
qrCardView.backgroundColor = .white
qrCardView.layer.cornerRadius = AppRadius.lg
scenicNameLabel.font = .systemFont(ofSize: 16, weight: .medium)
scenicNameLabel.textColor = AppColor.textPrimary
scenicNameLabel.textAlignment = .center
qrImageView.contentMode = .scaleAspectFit
qrImageView.isHidden = true
qrPlaceholderView.backgroundColor = AppColor.inputBackground
qrPlaceholderView.layer.cornerRadius = 24
qrPlaceholderView.isHidden = true
qrErrorLabel.text = "未选景区或网络问题"
qrErrorLabel.font = .systemFont(ofSize: 14)
qrErrorLabel.textColor = AppColor.danger
qrErrorLabel.textAlignment = .center
refreshButton.setTitle("点击刷新", for: .normal)
refreshButton.setTitleColor(AppColor.primary, for: .normal)
refreshButton.titleLabel?.font = .systemFont(ofSize: 14)
actionRow.axis = .horizontal
actionRow.alignment = .center
actionRow.spacing = AppSpacing.xl
configureLinkButton(setAmountButton, title: "设置金额")
configureLinkButton(saveQRButton, title: "保存二维码")
infoCardView.backgroundColor = .white
infoCardView.layer.cornerRadius = AppRadius.lg
recordRow.backgroundColor = .white
recordRow.layer.cornerRadius = AppRadius.lg
voiceCardView.backgroundColor = .white
voiceCardView.layer.cornerRadius = AppRadius.lg
voiceTitleLabel.text = "收款到账语音提醒"
voiceTitleLabel.font = .systemFont(ofSize: 14)
voiceTitleLabel.textColor = UIColor(hex: 0x4B5563)
voiceSwitch.onTintColor = AppColor.primary
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(qrCardView)
contentStack.addArrangedSubview(infoCardView)
contentStack.addArrangedSubview(recordRow)
contentStack.addArrangedSubview(voiceCardView)
let qrContentStack = UIStackView(arrangedSubviews: [
scenicNameLabel,
qrImageView,
qrPlaceholderView,
actionRow,
])
qrContentStack.axis = .vertical
qrContentStack.alignment = .center
qrContentStack.spacing = AppSpacing.lg
qrCardView.addSubview(qrContentStack)
qrPlaceholderView.addSubview(qrErrorLabel)
qrPlaceholderView.addSubview(refreshButton)
actionRow.addArrangedSubview(setAmountButton)
actionRow.addArrangedSubview(saveQRButton)
let infoStack = UIStackView(arrangedSubviews: [scenicRow, merchantRow, staffRow])
infoStack.axis = .vertical
infoCardView.addSubview(infoStack)
voiceCardView.addSubview(voiceTitleLabel)
voiceCardView.addSubview(voiceSwitch)
qrContentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 32, left: 16, bottom: 32, right: 16))
}
scenicNameLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
}
qrImageView.snp.makeConstraints { make in
make.width.height.equalTo(182)
}
qrPlaceholderView.snp.makeConstraints { make in
make.width.height.equalTo(182)
}
qrErrorLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(56)
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
}
refreshButton.snp.makeConstraints { make in
make.top.equalTo(qrErrorLabel.snp.bottom).offset(AppSpacing.xs)
make.centerX.equalToSuperview()
}
infoStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
voiceTitleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
}
voiceSwitch.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
}
voiceCardView.snp.makeConstraints { make in
make.height.equalTo(AppSpacing.formRowHeight)
}
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
setAmountButton.addTarget(self, action: #selector(setAmountTapped), for: .touchUpInside)
saveQRButton.addTarget(self, action: #selector(saveQRTapped), for: .touchUpInside)
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
recordRow.addTarget(self, action: #selector(recordTapped), for: .touchUpInside)
voiceSwitch.addTarget(self, action: #selector(voiceSwitchChanged), for: .valueChanged)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await loadData() }
}
private func loadData() async {
showLoading()
await viewModel.loadPayCode(api: paymentAPI)
hideLoading()
}
@MainActor
private func applyViewModel() {
scenicNameLabel.text = viewModel.displayScenicName
scenicRow.setValue(viewModel.displayScenicName)
merchantRow.setValue(viewModel.merchantInfo.company)
staffRow.setValue(viewModel.staffId.isEmpty ? "-" : viewModel.staffId)
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
if let image = viewModel.qrImage {
qrImageView.image = image
qrImageView.isHidden = false
qrPlaceholderView.isHidden = true
actionRow.isHidden = false
} else {
qrImageView.isHidden = true
qrPlaceholderView.isHidden = false
actionRow.isHidden = true
}
if viewModel.showAmountDialog {
presentAmountDialogIfNeeded()
} else {
amountDialog?.dismiss()
amountDialog = nil
}
}
private func presentAmountDialogIfNeeded() {
guard amountDialog == nil else {
amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark)
return
}
let dialog = PaymentSetAmountDialogView()
dialog.onAmountChange = { [weak self] value in
self?.viewModel.updateAmount(value)
}
dialog.onRemarkChange = { [weak self] value in
self?.viewModel.updateRemark(value)
}
dialog.onConfirm = { [weak self] in
self?.viewModel.confirmAmount()
}
dialog.onCancel = { [weak self] in
self?.viewModel.hideAmountDialog()
}
dialog.apply(amount: viewModel.amount, remark: viewModel.remark)
dialog.show(in: view)
amountDialog = dialog
}
private func configureLinkButton(_ button: UIButton, title: String) {
button.setTitle(title, for: .normal)
button.setTitleColor(AppColor.primary, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14)
}
@objc private func setAmountTapped() {
viewModel.showSetAmountDialog()
}
@objc private func saveQRTapped() {
Task {
await viewModel.saveQRCode()
}
}
@objc private func refreshTapped() {
Task {
showLoading()
await viewModel.refresh(api: paymentAPI)
hideLoading()
}
}
@objc private func recordTapped() {
navigationController?.pushViewController(PaymentCollectionRecordViewController(), animated: true)
}
@objc private func voiceSwitchChanged() {
viewModel.toggleReceiveVoice()
}
}
///
private final class PaymentInfoRowView: UIView {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
private let divider = UIView()
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setValue(_ value: String) {
valueLabel.text = value
}
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 2
divider.backgroundColor = UIColor(hex: 0xE5E7EB)
addSubview(titleLabel)
addSubview(valueLabel)
addSubview(divider)
snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(AppSpacing.formRowHeight)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
make.width.equalTo(102)
}
valueLabel.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xs)
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.top.bottom.equalToSuperview().inset(AppSpacing.sm)
}
divider.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(0.5)
}
}
}
///
private final class PaymentNavigationRowView: UIControl {
private let titleLabel = UILabel()
private let chevronView = UIImageView()
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
chevronView.image = UIImage(systemName: "chevron.right")
chevronView.tintColor = AppColor.textSecondary
chevronView.contentMode = .scaleAspectFit
addSubview(titleLabel)
addSubview(chevronView)
snp.makeConstraints { make in
make.height.equalTo(AppSpacing.formRowHeight)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
}
chevronView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
make.width.height.equalTo(16)
}
}
}

View File

@ -0,0 +1,110 @@
//
// PaymentCollectionRecordViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 7
final class PaymentCollectionRecordViewController: BaseViewController {
private let viewModel = PaymentCollectionRecordViewModel()
private let paymentAPI = NetworkServices.shared.paymentAPI
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let emptyLabel = UILabel()
private let recordCardView = UIView()
private let groupsStack = UIStackView()
private let footerLabel = UILabel()
override func setupNavigationBar() {
title = "收款记录"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.md
emptyLabel.text = "暂无收款记录"
emptyLabel.font = .systemFont(ofSize: 14)
emptyLabel.textColor = AppColor.textTertiary
emptyLabel.textAlignment = .center
emptyLabel.isHidden = true
recordCardView.backgroundColor = .white
recordCardView.layer.cornerRadius = AppRadius.lg
recordCardView.isHidden = true
groupsStack.axis = .vertical
groupsStack.spacing = AppSpacing.md
footerLabel.text = "仅支持查看7天的数据"
footerLabel.font = .systemFont(ofSize: 12)
footerLabel.textColor = UIColor(hex: 0xB6BECA)
footerLabel.textAlignment = .center
footerLabel.isHidden = true
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(emptyLabel)
contentStack.addArrangedSubview(recordCardView)
contentStack.addArrangedSubview(footerLabel)
recordCardView.addSubview(groupsStack)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
}
emptyLabel.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(240)
}
groupsStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await loadData() }
}
private func loadData() async {
showLoading()
await viewModel.loadRecord(api: paymentAPI)
hideLoading()
}
@MainActor
private func applyViewModel() {
let hasData = !viewModel.groups.isEmpty
emptyLabel.isHidden = hasData
recordCardView.isHidden = !hasData
footerLabel.isHidden = !hasData
groupsStack.arrangedSubviews.forEach {
groupsStack.removeArrangedSubview($0)
$0.removeFromSuperview()
}
viewModel.groups.forEach { group in
let groupView = PaymentRecordGroupView()
groupView.apply(group: group)
groupsStack.addArrangedSubview(groupView)
}
}
}

View File

@ -0,0 +1,136 @@
//
// PaymentRecordGroupView.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class PaymentRecordGroupView: UIView {
private let headerStack = UIStackView()
private let dateLabel = UILabel()
private let summaryLabel = UILabel()
private let itemsStack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(group: RepaymentCollectionRecordGroup) {
dateLabel.text = group.analyse.date
summaryLabel.text = "共收款\(group.analyse.orderCount)笔, 当日收益:¥\(group.analyse.orderAmountSum)"
itemsStack.arrangedSubviews.forEach {
itemsStack.removeArrangedSubview($0)
$0.removeFromSuperview()
}
group.items.forEach { item in
let card = PaymentRecordItemView()
card.apply(item: item)
itemsStack.addArrangedSubview(card)
}
}
private func setupUI() {
headerStack.axis = .horizontal
headerStack.alignment = .center
headerStack.distribution = .equalSpacing
dateLabel.font = .systemFont(ofSize: 14)
dateLabel.textColor = AppColor.textTertiary
summaryLabel.font = .systemFont(ofSize: 14)
summaryLabel.textColor = UIColor(hex: 0x4B5563)
summaryLabel.textAlignment = .right
summaryLabel.numberOfLines = 2
itemsStack.axis = .vertical
itemsStack.spacing = AppSpacing.sm
addSubview(headerStack)
addSubview(itemsStack)
headerStack.addArrangedSubview(dateLabel)
headerStack.addArrangedSubview(summaryLabel)
headerStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
itemsStack.snp.makeConstraints { make in
make.top.equalTo(headerStack.snp.bottom).offset(6)
make.leading.trailing.bottom.equalToSuperview()
}
}
}
///
final class PaymentRecordItemView: UIView {
private let amountLabel = UILabel()
private let orderNumberLabel = UILabel()
private let phoneLabel = UILabel()
private let timeLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(item: RepaymentCollectionRecordItem) {
amountLabel.text = "¥ \(item.orderAmount)"
orderNumberLabel.text = item.orderNumber
phoneLabel.text = item.userPhone
timeLabel.text = item.createTime
}
private func setupUI() {
backgroundColor = .white
layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
layer.borderWidth = 1
layer.cornerRadius = AppRadius.lg
amountLabel.font = .systemFont(ofSize: 18, weight: .bold)
amountLabel.textColor = AppColor.primary
orderNumberLabel.font = .systemFont(ofSize: 14)
orderNumberLabel.textColor = AppColor.textSecondary
orderNumberLabel.textAlignment = .right
phoneLabel.font = .systemFont(ofSize: 14)
phoneLabel.textColor = AppColor.textSecondary
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = AppColor.textSecondary
timeLabel.textAlignment = .right
let topRow = UIStackView(arrangedSubviews: [amountLabel, orderNumberLabel])
topRow.axis = .horizontal
topRow.distribution = .fillEqually
let bottomRow = UIStackView(arrangedSubviews: [phoneLabel, timeLabel])
bottomRow.axis = .horizontal
bottomRow.distribution = .fillEqually
let stack = UIStackView(arrangedSubviews: [topRow, bottomRow])
stack.axis = .vertical
stack.spacing = AppSpacing.xs
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
}
}
}

View File

@ -0,0 +1,205 @@
//
// PaymentSetAmountDialogView.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `SetAmountDialog`
final class PaymentSetAmountDialogView: UIView {
var onConfirm: (() -> Void)?
var onCancel: (() -> Void)?
var onAmountChange: ((String) -> Void)?
var onRemarkChange: ((String) -> Void)?
private let dimView = UIView()
private let cardView = UIView()
private let titleLabel = UILabel()
private let amountContainer = UIView()
private let currencyLabel = UILabel()
private let amountField = UITextField()
private let remarkTitleLabel = UILabel()
private let remarkField = UITextView()
private let remarkCountLabel = UILabel()
private let buttonStack = UIStackView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(amount: String, remark: String) {
if amountField.text != amount {
amountField.text = amount
}
if remarkField.text != remark {
remarkField.text = remark
}
remarkCountLabel.text = "\(remark.count)/200"
}
func show(in parent: UIView) {
frame = parent.bounds
autoresizingMask = [.flexibleWidth, .flexibleHeight]
alpha = 0
parent.addSubview(self)
UIView.animate(withDuration: 0.2) {
self.alpha = 1
}
}
func dismiss() {
endEditing(true)
UIView.animate(withDuration: 0.2, animations: {
self.alpha = 0
}, completion: { _ in
self.removeFromSuperview()
})
}
private func setupUI() {
dimView.backgroundColor = AppColor.overlayScrim
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.xl
cardView.clipsToBounds = true
titleLabel.text = "收款金额"
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
amountContainer.backgroundColor = AppColor.inputBackground
amountContainer.layer.cornerRadius = AppRadius.sm
currencyLabel.text = "¥"
currencyLabel.font = .systemFont(ofSize: 20, weight: .bold)
currencyLabel.textColor = AppColor.textPrimary
amountField.placeholder = "请输入收款金额"
amountField.font = .systemFont(ofSize: 18, weight: .medium)
amountField.keyboardType = .decimalPad
amountField.borderStyle = .none
amountField.backgroundColor = .clear
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
remarkTitleLabel.text = "备注 (选填)"
remarkTitleLabel.font = .systemFont(ofSize: 14)
remarkTitleLabel.textColor = UIColor(hex: 0x4B5563)
remarkField.font = .systemFont(ofSize: 14)
remarkField.textColor = AppColor.textPrimary
remarkField.backgroundColor = .white
remarkField.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
remarkField.layer.borderWidth = 1
remarkField.layer.cornerRadius = AppRadius.sm
remarkField.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8)
remarkField.delegate = self
remarkCountLabel.font = .systemFont(ofSize: 12)
remarkCountLabel.textColor = AppColor.textTertiary
remarkCountLabel.textAlignment = .right
remarkCountLabel.text = "0/200"
buttonStack.axis = .horizontal
buttonStack.spacing = AppSpacing.sm
buttonStack.distribution = .fillEqually
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
cancelButton.backgroundColor = AppColor.inputBackground
cancelButton.layer.cornerRadius = AppRadius.sm
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.setTitle("确定", for: .normal)
confirmButton.setTitleColor(.white, for: .normal)
confirmButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
confirmButton.backgroundColor = AppColor.primary
confirmButton.layer.cornerRadius = AppRadius.sm
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
addSubview(dimView)
addSubview(cardView)
cardView.addSubview(titleLabel)
cardView.addSubview(amountContainer)
amountContainer.addSubview(currencyLabel)
amountContainer.addSubview(amountField)
cardView.addSubview(remarkTitleLabel)
cardView.addSubview(remarkField)
cardView.addSubview(remarkCountLabel)
cardView.addSubview(buttonStack)
buttonStack.addArrangedSubview(cancelButton)
buttonStack.addArrangedSubview(confirmButton)
dimView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
cardView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
amountContainer.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(20)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(48)
}
currencyLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.sm)
make.centerY.equalToSuperview()
}
amountField.snp.makeConstraints { make in
make.leading.equalTo(currencyLabel.snp.trailing).offset(AppSpacing.xs)
make.trailing.equalToSuperview().inset(AppSpacing.sm)
make.centerY.equalToSuperview()
}
remarkTitleLabel.snp.makeConstraints { make in
make.top.equalTo(amountContainer.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
remarkField.snp.makeConstraints { make in
make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(100)
}
remarkCountLabel.snp.makeConstraints { make in
make.top.equalTo(remarkField.snp.bottom).offset(AppSpacing.xxs)
make.trailing.equalToSuperview().inset(AppSpacing.md)
}
buttonStack.snp.makeConstraints { make in
make.top.equalTo(remarkCountLabel.snp.bottom).offset(AppSpacing.lg)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(44)
}
}
@objc private func amountChanged() {
onAmountChange?(amountField.text ?? "")
}
@objc private func cancelTapped() {
onCancel?()
}
@objc private func confirmTapped() {
onConfirm?()
}
}
extension PaymentSetAmountDialogView: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
onRemarkChange?(textView.text ?? "")
}
}

View File

@ -0,0 +1,94 @@
//
// PaymentCollectionDetailsViewModelTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// ViewModel
@MainActor
final class PaymentCollectionDetailsViewModelTests: XCTestCase {
private var appStore: AppStore!
override func setUp() {
let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests")!
defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests")
appStore = AppStore(defaults: defaults)
appStore.userId = "10001"
appStore.currentScenicId = 10
appStore.currentScenicName = "测试景区"
}
override func tearDown() {
appStore.logout()
super.tearDown()
}
func testPaymentAmountValidatorRejectsEmptyAmount() {
XCTAssertEqual(PaymentAmountValidator.validationMessage(for: ""), "请输入收款金额")
}
func testPaymentAmountValidatorRejectsZeroAmount() {
XCTAssertEqual(PaymentAmountValidator.validationMessage(for: "0"), "请输入有效的收款金额")
}
func testPaymentAmountValidatorRejectsMoreThanTwoDecimals() {
XCTAssertEqual(PaymentAmountValidator.validationMessage(for: "1.234"), "金额最多支持两位小数")
}
func testPaymentAmountValidatorAcceptsValidAmount() {
XCTAssertNil(PaymentAmountValidator.validationMessage(for: "12.50"))
}
func testShowSetAmountDialogRequiresPayCode() {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
var message: String?
viewModel.onShowMessage = { message = $0 }
viewModel.showSetAmountDialog()
XCTAssertEqual(message, "请先获取收款码")
XCTAssertFalse(viewModel.showAmountDialog)
}
func testConfirmAmountBuildsDynamicURL() async throws {
let payCodeJSON = """
{"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}}
""".data(using: .utf8)!
let session = MockURLSession(responses: [payCodeJSON])
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
await viewModel.loadPayCode(api: api)
viewModel.updateAmount("88.50")
viewModel.updateRemark("")
viewModel.confirmAmount()
XCTAssertTrue(viewModel.hasPayCode)
XCTAssertFalse(viewModel.showAmountDialog)
}
func testToggleReceiveVoicePersistsInAppStore() {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
viewModel.refreshLocalState()
XCTAssertFalse(viewModel.isVoiceBroadcastOpen)
viewModel.toggleReceiveVoice()
XCTAssertTrue(appStore.isOpenReceiveVoice)
XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
}
func testLoadPayCodeClearsQRWhenScenicMissing() async {
appStore.currentScenicId = 0
let session = MockURLSession(responses: [])
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
await viewModel.loadPayCode(api: api)
XCTAssertFalse(viewModel.hasPayCode)
XCTAssertEqual(session.requests.count, 0)
}
}

View File

@ -0,0 +1,72 @@
//
// PaymentCollectionRecordViewModelTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// ViewModel
@MainActor
final class PaymentCollectionRecordViewModelTests: XCTestCase {
func testPaymentRecordGrouperGroupsByCreateDate() {
let response = RepaymentCollectionRecordResponse(
analyse: [
RepaymentCollectionRecordAnalyseItem(date: "2024-01-15", orderCount: 2, orderAmountSum: "150.00"),
RepaymentCollectionRecordAnalyseItem(date: "2024-01-14", orderCount: 1, orderAmountSum: "80.00"),
],
list: [
RepaymentCollectionRecordItem(
orderNumber: "NO001",
userPhone: "13800138000",
orderAmount: "100.00",
createDate: "2024-01-15",
createTime: "10:00:00"
),
RepaymentCollectionRecordItem(
orderNumber: "NO002",
userPhone: "13800138001",
orderAmount: "50.00",
createDate: "2024-01-15",
createTime: "11:00:00"
),
RepaymentCollectionRecordItem(
orderNumber: "NO003",
userPhone: "13800138002",
orderAmount: "80.00",
createDate: "2024-01-14",
createTime: "09:00:00"
),
]
)
let groups = PaymentRecordGrouper.group(response: response)
XCTAssertEqual(groups.count, 2)
XCTAssertEqual(groups[0].analyse.date, "2024-01-15")
XCTAssertEqual(groups[0].items.count, 2)
XCTAssertEqual(groups[1].analyse.date, "2024-01-14")
XCTAssertEqual(groups[1].items.count, 1)
}
func testLoadRecordUsesAPIAndGroupsResult() async {
let defaults = UserDefaults(suiteName: "PaymentCollectionRecordViewModelTests")!
defaults.removePersistentDomain(forName: "PaymentCollectionRecordViewModelTests")
let appStore = AppStore(defaults: defaults)
appStore.currentScenicId = 10
let recordJSON = """
{"code":100000,"msg":"success","data":{"analyse":[{"date":"2024-01-15","order_count":1,"order_amount_sum":"50.00"}],"list":[{"order_number":"NO001","user_phone":"13800138000","order_amount":"50.00","create_date":"2024-01-15","create_time":"10:00:00"}]}}
""".data(using: .utf8)!
let session = MockURLSession(responses: [recordJSON])
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
let viewModel = PaymentCollectionRecordViewModel(appStore: appStore)
await viewModel.loadRecord(api: api)
XCTAssertEqual(viewModel.groups.count, 1)
XCTAssertEqual(viewModel.groups[0].items.first?.orderNumber, "NO001")
XCTAssertTrue(session.requests.first?.url?.absoluteString.contains("photo-scan-order-list") == true)
}
}

View File

@ -0,0 +1,53 @@
//
// PaymentModelsTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
///
final class PaymentModelsTests: XCTestCase {
func testPayCodeResponseDecodesSnakeCaseFields() throws {
let json = """
{
"static_pay_url": "https://pay.example.com/static",
"dynamic_pay_url": "https://pay.example.com/dynamic"
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(PayCodeResponse.self, from: json)
XCTAssertEqual(response.staticPayURL, "https://pay.example.com/static")
XCTAssertEqual(response.dynamicPayURL, "https://pay.example.com/dynamic")
}
func testRepaymentCollectionRecordResponseDecodesGroupedFields() throws {
let json = """
{
"analyse": [
{
"date": "2024-01-15",
"order_count": 2,
"order_amount_sum": "100.00"
}
],
"list": [
{
"order_number": "NO001",
"user_phone": "13800138000",
"order_amount": "50.00",
"create_date": "2024-01-15",
"create_time": "10:00:00"
}
]
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(RepaymentCollectionRecordResponse.self, from: json)
XCTAssertEqual(response.analyse.count, 1)
XCTAssertEqual(response.analyse[0].orderCount, 2)
XCTAssertEqual(response.list[0].orderNumber, "NO001")
}
}