From d8329d19fd2fa1a6140a7f39f34ae2abe1e164dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 7 Jul 2026 09:29:03 +0800 Subject: [PATCH] Add instant payment collection flow aligned with Android. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- suixinkan/App/NetworkServices.swift | 3 + suixinkan/Common/PhotoLibrarySaver.swift | 37 ++ suixinkan/Common/QRCodeGenerator.swift | 59 +++ suixinkan/DataStore/AppStore.swift | 8 + .../Features/Payment/API/PaymentAPI.swift | 38 ++ .../Payment/Models/PaymentModels.swift | 103 +++++ .../PaymentCollectionDetailsViewModel.swift | 175 ++++++++ .../PaymentCollectionRecordViewModel.swift | 61 +++ suixinkan/Info.plist | 2 + suixinkan/UI/Home/HomeViewController.swift | 11 +- ...ymentCollectionDetailsViewController.swift | 382 ++++++++++++++++++ ...aymentCollectionRecordViewController.swift | 110 +++++ .../Views/PaymentRecordGroupView.swift | 136 +++++++ .../Views/PaymentSetAmountDialogView.swift | 205 ++++++++++ ...ymentCollectionDetailsViewModelTests.swift | 94 +++++ ...aymentCollectionRecordViewModelTests.swift | 72 ++++ suixinkanTests/PaymentModelsTests.swift | 53 +++ 17 files changed, 1548 insertions(+), 1 deletion(-) create mode 100644 suixinkan/Common/PhotoLibrarySaver.swift create mode 100644 suixinkan/Common/QRCodeGenerator.swift create mode 100644 suixinkan/Features/Payment/API/PaymentAPI.swift create mode 100644 suixinkan/Features/Payment/Models/PaymentModels.swift create mode 100644 suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift create mode 100644 suixinkan/Features/Payment/ViewModels/PaymentCollectionRecordViewModel.swift create mode 100644 suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift create mode 100644 suixinkan/UI/Payment/PaymentCollectionRecordViewController.swift create mode 100644 suixinkan/UI/Payment/Views/PaymentRecordGroupView.swift create mode 100644 suixinkan/UI/Payment/Views/PaymentSetAmountDialogView.swift create mode 100644 suixinkanTests/PaymentCollectionDetailsViewModelTests.swift create mode 100644 suixinkanTests/PaymentCollectionRecordViewModelTests.swift create mode 100644 suixinkanTests/PaymentModelsTests.swift diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index a2cd5ef..8fe4077 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -16,6 +16,7 @@ final class NetworkServices { let statisticsAPI: StatisticsAPI let orderAPI: OrderAPI let homeAPI: HomeAPI + let paymentAPI: PaymentAPI let uploadAPI: UploadAPI let ossUploadService: OSSUploadService @@ -27,6 +28,7 @@ final class NetworkServices { statisticsAPI = StatisticsAPI(client: client) orderAPI = OrderAPI(client: client) homeAPI = HomeAPI(client: client) + paymentAPI = PaymentAPI(client: client) uploadAPI = UploadAPI(client: client) ossUploadService = OSSUploadService(configService: uploadAPI) client.bindAuthTokenProvider { @@ -43,6 +45,7 @@ final class NetworkServices { statisticsAPI = StatisticsAPI(client: apiClient) orderAPI = OrderAPI(client: apiClient) homeAPI = HomeAPI(client: apiClient) + paymentAPI = PaymentAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient) ossUploadService = OSSUploadService(configService: uploadAPI) } diff --git a/suixinkan/Common/PhotoLibrarySaver.swift b/suixinkan/Common/PhotoLibrarySaver.swift new file mode 100644 index 0000000..a08e0a2 --- /dev/null +++ b/suixinkan/Common/PhotoLibrarySaver.swift @@ -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 + } + } +} diff --git a/suixinkan/Common/QRCodeGenerator.swift b/suixinkan/Common/QRCodeGenerator.swift new file mode 100644 index 0000000..307244d --- /dev/null +++ b/suixinkan/Common/QRCodeGenerator.swift @@ -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) + } +} diff --git a/suixinkan/DataStore/AppStore.swift b/suixinkan/DataStore/AppStore.swift index 07141df..8c8c427 100644 --- a/suixinkan/DataStore/AppStore.swift +++ b/suixinkan/DataStore/AppStore.swift @@ -33,6 +33,7 @@ final class AppStore { static let onlineStatus = "key_online_status" static let lastLocationReportTime = "key_last_location_report_time" static let locationReminderMinutes = "key_location_reminder_minutes" + static let isOpenReceiveVoice = "key_is_open_receive_voice" } private let defaults: UserDefaults @@ -154,6 +155,12 @@ final class AppStore { 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`。 var accountCachePrefix: String { let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -309,6 +316,7 @@ final class AppStore { Key.onlineStatus, Key.lastLocationReportTime, Key.locationReminderMinutes, + Key.isOpenReceiveVoice, ] keys.forEach { defaults.removeObject(forKey: prefix + $0) } } diff --git a/suixinkan/Features/Payment/API/PaymentAPI.swift b/suixinkan/Features/Payment/API/PaymentAPI.swift new file mode 100644 index 0000000..2d821e2 --- /dev/null +++ b/suixinkan/Features/Payment/API/PaymentAPI.swift @@ -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))] + ) + ) + } +} diff --git a/suixinkan/Features/Payment/Models/PaymentModels.swift b/suixinkan/Features/Payment/Models/PaymentModels.swift new file mode 100644 index 0000000..672149b --- /dev/null +++ b/suixinkan/Features/Payment/Models/PaymentModels.swift @@ -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: "扬州元智享网络科技有限公司" + ) +} diff --git a/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift b/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift new file mode 100644 index 0000000..6873b8d --- /dev/null +++ b/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift @@ -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 + } +} diff --git a/suixinkan/Features/Payment/ViewModels/PaymentCollectionRecordViewModel.swift b/suixinkan/Features/Payment/ViewModels/PaymentCollectionRecordViewModel.swift new file mode 100644 index 0000000..cd300ff --- /dev/null +++ b/suixinkan/Features/Payment/ViewModels/PaymentCollectionRecordViewModel.swift @@ -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] ?? [] + ) + } + } +} diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index c529184..f2ef9e7 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -10,6 +10,8 @@ 需要定位以获取多点位核销打卡点列表 NSPhotoLibraryUsageDescription 需要访问相册以选择头像、身份证和银行卡照片 + NSPhotoLibraryAddUsageDescription + 需要保存收款二维码到相册 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index 7ba29d9..1f89e49 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -77,7 +77,16 @@ final class HomeViewController: BaseViewController { locationReportCardView.onReportTap = { [weak self] in 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.onToggleOnline = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() } commonAppsGridView.onMenuSelected = { [weak self] menu in diff --git a/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift new file mode 100644 index 0000000..6266310 --- /dev/null +++ b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift @@ -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) + } + } +} diff --git a/suixinkan/UI/Payment/PaymentCollectionRecordViewController.swift b/suixinkan/UI/Payment/PaymentCollectionRecordViewController.swift new file mode 100644 index 0000000..a52b4a3 --- /dev/null +++ b/suixinkan/UI/Payment/PaymentCollectionRecordViewController.swift @@ -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) + } + } +} diff --git a/suixinkan/UI/Payment/Views/PaymentRecordGroupView.swift b/suixinkan/UI/Payment/Views/PaymentRecordGroupView.swift new file mode 100644 index 0000000..2860df7 --- /dev/null +++ b/suixinkan/UI/Payment/Views/PaymentRecordGroupView.swift @@ -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) + } + } +} diff --git a/suixinkan/UI/Payment/Views/PaymentSetAmountDialogView.swift b/suixinkan/UI/Payment/Views/PaymentSetAmountDialogView.swift new file mode 100644 index 0000000..627e1d0 --- /dev/null +++ b/suixinkan/UI/Payment/Views/PaymentSetAmountDialogView.swift @@ -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 ?? "") + } +} diff --git a/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift new file mode 100644 index 0000000..9247aca --- /dev/null +++ b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift @@ -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) + } +} diff --git a/suixinkanTests/PaymentCollectionRecordViewModelTests.swift b/suixinkanTests/PaymentCollectionRecordViewModelTests.swift new file mode 100644 index 0000000..7c1bb96 --- /dev/null +++ b/suixinkanTests/PaymentCollectionRecordViewModelTests.swift @@ -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) + } +} diff --git a/suixinkanTests/PaymentModelsTests.swift b/suixinkanTests/PaymentModelsTests.swift new file mode 100644 index 0000000..7da2b97 --- /dev/null +++ b/suixinkanTests/PaymentModelsTests.swift @@ -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") + } +}