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")
+ }
+}