Add instant payment collection flow aligned with Android.
Implement收款详情与收款记录 pages with pay-code API, QR generation, amount setting, and record grouping so merchants can collect payments from the home quick action. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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)
|
||||
}
|
||||
|
||||
37
suixinkan/Common/PhotoLibrarySaver.swift
Normal file
37
suixinkan/Common/PhotoLibrarySaver.swift
Normal file
@ -0,0 +1,37 @@
|
||||
//
|
||||
// PhotoLibrarySaver.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
/// 将图片保存到系统相册,供收款二维码保存使用。
|
||||
enum PhotoLibrarySaver {
|
||||
|
||||
/// 请求写入权限并将图片保存到相册。
|
||||
static func saveImage(_ image: UIImage) async -> Bool {
|
||||
let status = await requestAuthorizationIfNeeded()
|
||||
guard status == .authorized || status == .limited else {
|
||||
return false
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
PHAssetChangeRequest.creationRequestForAsset(from: image)
|
||||
}, completionHandler: { success, _ in
|
||||
continuation.resume(returning: success)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestAuthorizationIfNeeded() async -> PHAuthorizationStatus {
|
||||
let current = PHPhotoLibrary.authorizationStatus(for: .addOnly)
|
||||
switch current {
|
||||
case .notDetermined:
|
||||
return await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||||
default:
|
||||
return current
|
||||
}
|
||||
}
|
||||
}
|
||||
59
suixinkan/Common/QRCodeGenerator.swift
Normal file
59
suixinkan/Common/QRCodeGenerator.swift
Normal file
@ -0,0 +1,59 @@
|
||||
//
|
||||
// QRCodeGenerator.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreImage
|
||||
import UIKit
|
||||
|
||||
/// 圆角样式二维码生成器,对齐 Android `QrCodeUtils.generateStyledQrBitmap`。
|
||||
enum QRCodeGenerator {
|
||||
|
||||
private static let defaultPixelSize = 580
|
||||
private static let defaultCornerRadius: CGFloat = 24
|
||||
|
||||
/// 根据内容生成带圆角背景的二维码图片。
|
||||
static func generateStyledQRCode(
|
||||
content: String,
|
||||
pixelSize: Int = defaultPixelSize,
|
||||
backgroundColor: UIColor = AppColor.inputBackground,
|
||||
cornerRadius: CGFloat = defaultCornerRadius
|
||||
) -> UIImage? {
|
||||
guard !content.isEmpty,
|
||||
let qrImage = makeQRCodeImage(from: content, pixelSize: pixelSize) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let size = CGSize(width: pixelSize, height: pixelSize)
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(size: size, format: format)
|
||||
return renderer.image { context in
|
||||
let rect = CGRect(origin: .zero, size: size)
|
||||
let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius)
|
||||
backgroundColor.setFill()
|
||||
path.fill()
|
||||
|
||||
context.cgContext.saveGState()
|
||||
path.addClip()
|
||||
qrImage.draw(in: rect)
|
||||
context.cgContext.restoreGState()
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeQRCodeImage(from content: String, pixelSize: Int) -> UIImage? {
|
||||
guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
|
||||
let data = Data(content.utf8)
|
||||
filter.setValue(data, forKey: "inputMessage")
|
||||
filter.setValue("M", forKey: "inputCorrectionLevel")
|
||||
guard let outputImage = filter.outputImage else { return nil }
|
||||
|
||||
let scale = CGFloat(pixelSize) / outputImage.extent.width
|
||||
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
|
||||
let context = CIContext()
|
||||
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else {
|
||||
return nil
|
||||
}
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
38
suixinkan/Features/Payment/API/PaymentAPI.swift
Normal file
38
suixinkan/Features/Payment/API/PaymentAPI.swift
Normal file
@ -0,0 +1,38 @@
|
||||
//
|
||||
// PaymentAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 收款相关 API,封装摄影师收款码与收款记录接口。
|
||||
final class PaymentAPI {
|
||||
private let client: APIClient
|
||||
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 拉取当前景区的静态/动态支付 URL。
|
||||
func payCode(scenicId: Int) async throws -> PayCodeResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/pay-code",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取近 7 天收款记录。
|
||||
func collectionRecord(scenicId: Int) async throws -> RepaymentCollectionRecordResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/photo-scan-order-list",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
103
suixinkan/Features/Payment/Models/PaymentModels.swift
Normal file
103
suixinkan/Features/Payment/Models/PaymentModels.swift
Normal file
@ -0,0 +1,103 @@
|
||||
//
|
||||
// PaymentModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 摄影师收款码响应,包含静态与动态支付 URL。
|
||||
struct PayCodeResponse: Decodable, Sendable, Equatable {
|
||||
let staticPayURL: String
|
||||
let dynamicPayURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case staticPayURL = "static_pay_url"
|
||||
case dynamicPayURL = "dynamic_pay_url"
|
||||
}
|
||||
|
||||
init(staticPayURL: String, dynamicPayURL: String) {
|
||||
self.staticPayURL = staticPayURL
|
||||
self.dynamicPayURL = dynamicPayURL
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录响应,含按日汇总与明细列表。
|
||||
struct RepaymentCollectionRecordResponse: Decodable, Sendable, Equatable {
|
||||
let analyse: [RepaymentCollectionRecordAnalyseItem]
|
||||
let list: [RepaymentCollectionRecordItem]
|
||||
|
||||
init(
|
||||
analyse: [RepaymentCollectionRecordAnalyseItem] = [],
|
||||
list: [RepaymentCollectionRecordItem] = []
|
||||
) {
|
||||
self.analyse = analyse
|
||||
self.list = list
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录按日汇总项。
|
||||
struct RepaymentCollectionRecordAnalyseItem: Decodable, Sendable, Equatable, Hashable {
|
||||
let date: String
|
||||
let orderCount: Int
|
||||
let orderAmountSum: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case date
|
||||
case orderCount = "order_count"
|
||||
case orderAmountSum = "order_amount_sum"
|
||||
}
|
||||
|
||||
init(date: String, orderCount: Int, orderAmountSum: String) {
|
||||
self.date = date
|
||||
self.orderCount = orderCount
|
||||
self.orderAmountSum = orderAmountSum
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录明细项。
|
||||
struct RepaymentCollectionRecordItem: Decodable, Sendable, Equatable, Hashable {
|
||||
let orderNumber: String
|
||||
let userPhone: String
|
||||
let orderAmount: String
|
||||
let createDate: String
|
||||
let createTime: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case userPhone = "user_phone"
|
||||
case orderAmount = "order_amount"
|
||||
case createDate = "create_date"
|
||||
case createTime = "create_time"
|
||||
}
|
||||
|
||||
init(
|
||||
orderNumber: String,
|
||||
userPhone: String,
|
||||
orderAmount: String,
|
||||
createDate: String,
|
||||
createTime: String
|
||||
) {
|
||||
self.orderNumber = orderNumber
|
||||
self.userPhone = userPhone
|
||||
self.orderAmount = orderAmount
|
||||
self.createDate = createDate
|
||||
self.createTime = createTime
|
||||
}
|
||||
}
|
||||
|
||||
/// 按日分组的收款记录,对齐 Android `RepaymentCollectionRecordGroup`。
|
||||
struct RepaymentCollectionRecordGroup: Sendable, Equatable, Hashable {
|
||||
let analyse: RepaymentCollectionRecordAnalyseItem
|
||||
let items: [RepaymentCollectionRecordItem]
|
||||
}
|
||||
|
||||
/// 收款商户展示信息。
|
||||
struct PaymentMerchantInfo: Sendable, Equatable {
|
||||
let name: String
|
||||
let company: String
|
||||
|
||||
static let defaultInfo = PaymentMerchantInfo(
|
||||
name: "元智享",
|
||||
company: "扬州元智享网络科技有限公司"
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
//
|
||||
// PaymentCollectionDetailsViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 收款详情 ViewModel,管理收款码拉取、设金额、保存二维码与语音开关。
|
||||
final class PaymentCollectionDetailsViewModel {
|
||||
|
||||
private(set) var loading = false
|
||||
private(set) var qrImage: UIImage?
|
||||
private(set) var scenicName = ""
|
||||
private(set) var staffId = ""
|
||||
private(set) var isVoiceBroadcastOpen = false
|
||||
private(set) var showAmountDialog = false
|
||||
private(set) var amount = ""
|
||||
private(set) var remark = ""
|
||||
|
||||
let merchantInfo = PaymentMerchantInfo.defaultInfo
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
private var staticPayURL = ""
|
||||
private var dynamicPayURL = ""
|
||||
private let appStore: AppStore
|
||||
|
||||
init(appStore: AppStore = .shared) {
|
||||
self.appStore = appStore
|
||||
}
|
||||
|
||||
var hasPayCode: Bool {
|
||||
qrImage != nil && !staticPayURL.isEmpty
|
||||
}
|
||||
|
||||
var displayScenicName: String {
|
||||
scenicName.isEmpty ? "暂无景区" : scenicName
|
||||
}
|
||||
|
||||
func refreshLocalState() {
|
||||
scenicName = appStore.currentScenicName
|
||||
staffId = appStore.userId
|
||||
isVoiceBroadcastOpen = appStore.isOpenReceiveVoice
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func loadPayCode(api: PaymentAPI) async {
|
||||
guard !loading else { return }
|
||||
refreshLocalState()
|
||||
loading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
loading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let scenicId = appStore.currentScenicId
|
||||
guard scenicId > 0 else {
|
||||
clearPayCode()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.payCode(scenicId: scenicId)
|
||||
staticPayURL = response.staticPayURL
|
||||
dynamicPayURL = response.dynamicPayURL
|
||||
qrImage = QRCodeGenerator.generateStyledQRCode(content: staticPayURL)
|
||||
} catch {
|
||||
clearPayCode()
|
||||
}
|
||||
}
|
||||
|
||||
func refresh(api: PaymentAPI) async {
|
||||
await loadPayCode(api: api)
|
||||
}
|
||||
|
||||
func showSetAmountDialog() {
|
||||
guard !staticPayURL.isEmpty else {
|
||||
onShowMessage?("请先获取收款码")
|
||||
return
|
||||
}
|
||||
amount = ""
|
||||
remark = ""
|
||||
showAmountDialog = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func hideAmountDialog() {
|
||||
showAmountDialog = false
|
||||
amount = ""
|
||||
remark = ""
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateAmount(_ value: String) {
|
||||
guard value.isEmpty || value.matches(PaymentAmountValidator.inputPattern) else { return }
|
||||
amount = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateRemark(_ value: String) {
|
||||
guard value.count <= 200 else { return }
|
||||
remark = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func confirmAmount() {
|
||||
if let message = PaymentAmountValidator.validationMessage(for: amount) {
|
||||
onShowMessage?(message)
|
||||
return
|
||||
}
|
||||
guard !dynamicPayURL.isEmpty else {
|
||||
onShowMessage?("请先获取收款码")
|
||||
return
|
||||
}
|
||||
|
||||
let dynamicURL = dynamicPayURL + "&amount=\(amount)&remark=\(remark)"
|
||||
qrImage = QRCodeGenerator.generateStyledQRCode(content: dynamicURL)
|
||||
showAmountDialog = false
|
||||
onShowMessage?("金额设置成功,二维码已更新")
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func saveQRCode() async {
|
||||
guard qrImage != nil else {
|
||||
onShowMessage?("请先获取收款码")
|
||||
return
|
||||
}
|
||||
let success = await PhotoLibrarySaver.saveImage(qrImage!)
|
||||
onShowMessage?(success ? "保存成功" : "保存失败")
|
||||
}
|
||||
|
||||
func toggleReceiveVoice() {
|
||||
isVoiceBroadcastOpen.toggle()
|
||||
appStore.isOpenReceiveVoice = isVoiceBroadcastOpen
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func clearPayCode() {
|
||||
staticPayURL = ""
|
||||
dynamicPayURL = ""
|
||||
qrImage = nil
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款金额校验,对齐 Android `PaymentCollectionDetailsViewModel.confirmAmount`。
|
||||
enum PaymentAmountValidator {
|
||||
static let inputPattern = #"^\d*(\.\d{0,2})?$"#
|
||||
private static let confirmPattern = #"^\d+(\.\d{1,2})?$"#
|
||||
|
||||
static func validationMessage(for amount: String) -> String? {
|
||||
if amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "请输入收款金额"
|
||||
}
|
||||
guard let value = Double(amount), value > 0 else {
|
||||
return "请输入有效的收款金额"
|
||||
}
|
||||
if !amount.matches(confirmPattern) {
|
||||
return "金额最多支持两位小数"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func matches(_ pattern: String) -> Bool {
|
||||
range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
//
|
||||
// PaymentCollectionRecordViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 收款记录 ViewModel,拉取近 7 天记录并按日分组展示。
|
||||
final class PaymentCollectionRecordViewModel {
|
||||
|
||||
private(set) var loading = false
|
||||
private(set) var groups: [RepaymentCollectionRecordGroup] = []
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
private let appStore: AppStore
|
||||
|
||||
init(appStore: AppStore = .shared) {
|
||||
self.appStore = appStore
|
||||
}
|
||||
|
||||
func loadRecord(api: PaymentAPI) async {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
loading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let scenicId = appStore.currentScenicId
|
||||
guard scenicId > 0 else {
|
||||
groups = []
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.collectionRecord(scenicId: scenicId)
|
||||
groups = PaymentRecordGrouper.group(response: response)
|
||||
} catch {
|
||||
groups = []
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款记录分组逻辑,对齐 Android `PaymentCollectionRecordViewModel.getRecord`。
|
||||
enum PaymentRecordGrouper {
|
||||
static func group(response: RepaymentCollectionRecordResponse) -> [RepaymentCollectionRecordGroup] {
|
||||
let mapByDate = Dictionary(grouping: response.list, by: \.createDate)
|
||||
return response.analyse.map { analyseItem in
|
||||
RepaymentCollectionRecordGroup(
|
||||
analyse: analyseItem,
|
||||
items: mapByDate[analyseItem.date] ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,8 @@
|
||||
<string>需要定位以获取多点位核销打卡点列表</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>需要访问相册以选择头像、身份证和银行卡照片</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要保存收款二维码到相册</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
110
suixinkan/UI/Payment/PaymentCollectionRecordViewController.swift
Normal file
110
suixinkan/UI/Payment/PaymentCollectionRecordViewController.swift
Normal file
@ -0,0 +1,110 @@
|
||||
//
|
||||
// PaymentCollectionRecordViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 收款记录页,展示近 7 天按日分组的收款明细。
|
||||
final class PaymentCollectionRecordViewController: BaseViewController {
|
||||
|
||||
private let viewModel = PaymentCollectionRecordViewModel()
|
||||
private let paymentAPI = NetworkServices.shared.paymentAPI
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let emptyLabel = UILabel()
|
||||
private let recordCardView = UIView()
|
||||
private let groupsStack = UIStackView()
|
||||
private let footerLabel = UILabel()
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "收款记录"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = AppSpacing.md
|
||||
|
||||
emptyLabel.text = "暂无收款记录"
|
||||
emptyLabel.font = .systemFont(ofSize: 14)
|
||||
emptyLabel.textColor = AppColor.textTertiary
|
||||
emptyLabel.textAlignment = .center
|
||||
emptyLabel.isHidden = true
|
||||
|
||||
recordCardView.backgroundColor = .white
|
||||
recordCardView.layer.cornerRadius = AppRadius.lg
|
||||
recordCardView.isHidden = true
|
||||
|
||||
groupsStack.axis = .vertical
|
||||
groupsStack.spacing = AppSpacing.md
|
||||
|
||||
footerLabel.text = "仅支持查看7天的数据"
|
||||
footerLabel.font = .systemFont(ofSize: 12)
|
||||
footerLabel.textColor = UIColor(hex: 0xB6BECA)
|
||||
footerLabel.textAlignment = .center
|
||||
footerLabel.isHidden = true
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
contentStack.addArrangedSubview(emptyLabel)
|
||||
contentStack.addArrangedSubview(recordCardView)
|
||||
contentStack.addArrangedSubview(footerLabel)
|
||||
recordCardView.addSubview(groupsStack)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.height.greaterThanOrEqualTo(240)
|
||||
}
|
||||
groupsStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await loadData() }
|
||||
}
|
||||
|
||||
private func loadData() async {
|
||||
showLoading()
|
||||
await viewModel.loadRecord(api: paymentAPI)
|
||||
hideLoading()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
let hasData = !viewModel.groups.isEmpty
|
||||
emptyLabel.isHidden = hasData
|
||||
recordCardView.isHidden = !hasData
|
||||
footerLabel.isHidden = !hasData
|
||||
|
||||
groupsStack.arrangedSubviews.forEach {
|
||||
groupsStack.removeArrangedSubview($0)
|
||||
$0.removeFromSuperview()
|
||||
}
|
||||
|
||||
viewModel.groups.forEach { group in
|
||||
let groupView = PaymentRecordGroupView()
|
||||
groupView.apply(group: group)
|
||||
groupsStack.addArrangedSubview(groupView)
|
||||
}
|
||||
}
|
||||
}
|
||||
136
suixinkan/UI/Payment/Views/PaymentRecordGroupView.swift
Normal file
136
suixinkan/UI/Payment/Views/PaymentRecordGroupView.swift
Normal file
@ -0,0 +1,136 @@
|
||||
//
|
||||
// PaymentRecordGroupView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 单日收款记录分组视图,含日期汇总与明细卡片。
|
||||
final class PaymentRecordGroupView: UIView {
|
||||
|
||||
private let headerStack = UIStackView()
|
||||
private let dateLabel = UILabel()
|
||||
private let summaryLabel = UILabel()
|
||||
private let itemsStack = UIStackView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(group: RepaymentCollectionRecordGroup) {
|
||||
dateLabel.text = group.analyse.date
|
||||
summaryLabel.text = "共收款\(group.analyse.orderCount)笔, 当日收益:¥\(group.analyse.orderAmountSum)"
|
||||
|
||||
itemsStack.arrangedSubviews.forEach {
|
||||
itemsStack.removeArrangedSubview($0)
|
||||
$0.removeFromSuperview()
|
||||
}
|
||||
|
||||
group.items.forEach { item in
|
||||
let card = PaymentRecordItemView()
|
||||
card.apply(item: item)
|
||||
itemsStack.addArrangedSubview(card)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
headerStack.axis = .horizontal
|
||||
headerStack.alignment = .center
|
||||
headerStack.distribution = .equalSpacing
|
||||
|
||||
dateLabel.font = .systemFont(ofSize: 14)
|
||||
dateLabel.textColor = AppColor.textTertiary
|
||||
|
||||
summaryLabel.font = .systemFont(ofSize: 14)
|
||||
summaryLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
summaryLabel.textAlignment = .right
|
||||
summaryLabel.numberOfLines = 2
|
||||
|
||||
itemsStack.axis = .vertical
|
||||
itemsStack.spacing = AppSpacing.sm
|
||||
|
||||
addSubview(headerStack)
|
||||
addSubview(itemsStack)
|
||||
headerStack.addArrangedSubview(dateLabel)
|
||||
headerStack.addArrangedSubview(summaryLabel)
|
||||
|
||||
headerStack.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
}
|
||||
itemsStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(headerStack.snp.bottom).offset(6)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条收款记录卡片。
|
||||
final class PaymentRecordItemView: UIView {
|
||||
|
||||
private let amountLabel = UILabel()
|
||||
private let orderNumberLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
private let timeLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(item: RepaymentCollectionRecordItem) {
|
||||
amountLabel.text = "¥ \(item.orderAmount)"
|
||||
orderNumberLabel.text = item.orderNumber
|
||||
phoneLabel.text = item.userPhone
|
||||
timeLabel.text = item.createTime
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .white
|
||||
layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
|
||||
layer.borderWidth = 1
|
||||
layer.cornerRadius = AppRadius.lg
|
||||
|
||||
amountLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
amountLabel.textColor = AppColor.primary
|
||||
|
||||
orderNumberLabel.font = .systemFont(ofSize: 14)
|
||||
orderNumberLabel.textColor = AppColor.textSecondary
|
||||
orderNumberLabel.textAlignment = .right
|
||||
|
||||
phoneLabel.font = .systemFont(ofSize: 14)
|
||||
phoneLabel.textColor = AppColor.textSecondary
|
||||
|
||||
timeLabel.font = .systemFont(ofSize: 14)
|
||||
timeLabel.textColor = AppColor.textSecondary
|
||||
timeLabel.textAlignment = .right
|
||||
|
||||
let topRow = UIStackView(arrangedSubviews: [amountLabel, orderNumberLabel])
|
||||
topRow.axis = .horizontal
|
||||
topRow.distribution = .fillEqually
|
||||
|
||||
let bottomRow = UIStackView(arrangedSubviews: [phoneLabel, timeLabel])
|
||||
bottomRow.axis = .horizontal
|
||||
bottomRow.distribution = .fillEqually
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [topRow, bottomRow])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppSpacing.xs
|
||||
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
205
suixinkan/UI/Payment/Views/PaymentSetAmountDialogView.swift
Normal file
205
suixinkan/UI/Payment/Views/PaymentSetAmountDialogView.swift
Normal file
@ -0,0 +1,205 @@
|
||||
//
|
||||
// PaymentSetAmountDialogView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 设置收款金额弹窗,对齐 Android `SetAmountDialog`。
|
||||
final class PaymentSetAmountDialogView: UIView {
|
||||
|
||||
var onConfirm: (() -> Void)?
|
||||
var onCancel: (() -> Void)?
|
||||
var onAmountChange: ((String) -> Void)?
|
||||
var onRemarkChange: ((String) -> Void)?
|
||||
|
||||
private let dimView = UIView()
|
||||
private let cardView = UIView()
|
||||
private let titleLabel = UILabel()
|
||||
private let amountContainer = UIView()
|
||||
private let currencyLabel = UILabel()
|
||||
private let amountField = UITextField()
|
||||
private let remarkTitleLabel = UILabel()
|
||||
private let remarkField = UITextView()
|
||||
private let remarkCountLabel = UILabel()
|
||||
private let buttonStack = UIStackView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(amount: String, remark: String) {
|
||||
if amountField.text != amount {
|
||||
amountField.text = amount
|
||||
}
|
||||
if remarkField.text != remark {
|
||||
remarkField.text = remark
|
||||
}
|
||||
remarkCountLabel.text = "\(remark.count)/200"
|
||||
}
|
||||
|
||||
func show(in parent: UIView) {
|
||||
frame = parent.bounds
|
||||
autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
alpha = 0
|
||||
parent.addSubview(self)
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.alpha = 1
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
endEditing(true)
|
||||
UIView.animate(withDuration: 0.2, animations: {
|
||||
self.alpha = 0
|
||||
}, completion: { _ in
|
||||
self.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
dimView.backgroundColor = AppColor.overlayScrim
|
||||
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = AppRadius.xl
|
||||
cardView.clipsToBounds = true
|
||||
|
||||
titleLabel.text = "收款金额"
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
|
||||
amountContainer.backgroundColor = AppColor.inputBackground
|
||||
amountContainer.layer.cornerRadius = AppRadius.sm
|
||||
|
||||
currencyLabel.text = "¥"
|
||||
currencyLabel.font = .systemFont(ofSize: 20, weight: .bold)
|
||||
currencyLabel.textColor = AppColor.textPrimary
|
||||
|
||||
amountField.placeholder = "请输入收款金额"
|
||||
amountField.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
amountField.keyboardType = .decimalPad
|
||||
amountField.borderStyle = .none
|
||||
amountField.backgroundColor = .clear
|
||||
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
|
||||
|
||||
remarkTitleLabel.text = "备注 (选填)"
|
||||
remarkTitleLabel.font = .systemFont(ofSize: 14)
|
||||
remarkTitleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
|
||||
remarkField.font = .systemFont(ofSize: 14)
|
||||
remarkField.textColor = AppColor.textPrimary
|
||||
remarkField.backgroundColor = .white
|
||||
remarkField.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||
remarkField.layer.borderWidth = 1
|
||||
remarkField.layer.cornerRadius = AppRadius.sm
|
||||
remarkField.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8)
|
||||
remarkField.delegate = self
|
||||
|
||||
remarkCountLabel.font = .systemFont(ofSize: 12)
|
||||
remarkCountLabel.textColor = AppColor.textTertiary
|
||||
remarkCountLabel.textAlignment = .right
|
||||
remarkCountLabel.text = "0/200"
|
||||
|
||||
buttonStack.axis = .horizontal
|
||||
buttonStack.spacing = AppSpacing.sm
|
||||
buttonStack.distribution = .fillEqually
|
||||
|
||||
cancelButton.setTitle("取消", for: .normal)
|
||||
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
|
||||
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
cancelButton.backgroundColor = AppColor.inputBackground
|
||||
cancelButton.layer.cornerRadius = AppRadius.sm
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
|
||||
confirmButton.setTitle("确定", for: .normal)
|
||||
confirmButton.setTitleColor(.white, for: .normal)
|
||||
confirmButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
confirmButton.backgroundColor = AppColor.primary
|
||||
confirmButton.layer.cornerRadius = AppRadius.sm
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
|
||||
addSubview(dimView)
|
||||
addSubview(cardView)
|
||||
cardView.addSubview(titleLabel)
|
||||
cardView.addSubview(amountContainer)
|
||||
amountContainer.addSubview(currencyLabel)
|
||||
amountContainer.addSubview(amountField)
|
||||
cardView.addSubview(remarkTitleLabel)
|
||||
cardView.addSubview(remarkField)
|
||||
cardView.addSubview(remarkCountLabel)
|
||||
cardView.addSubview(buttonStack)
|
||||
buttonStack.addArrangedSubview(cancelButton)
|
||||
buttonStack.addArrangedSubview(confirmButton)
|
||||
|
||||
dimView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
amountContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(20)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(48)
|
||||
}
|
||||
currencyLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(AppSpacing.sm)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
amountField.snp.makeConstraints { make in
|
||||
make.leading.equalTo(currencyLabel.snp.trailing).offset(AppSpacing.xs)
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.sm)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
remarkTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(amountContainer.snp.bottom).offset(AppSpacing.md)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
remarkField.snp.makeConstraints { make in
|
||||
make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(100)
|
||||
}
|
||||
remarkCountLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(remarkField.snp.bottom).offset(AppSpacing.xxs)
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
buttonStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(remarkCountLabel.snp.bottom).offset(AppSpacing.lg)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func amountChanged() {
|
||||
onAmountChange?(amountField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
onCancel?()
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
onConfirm?()
|
||||
}
|
||||
}
|
||||
|
||||
extension PaymentSetAmountDialogView: UITextViewDelegate {
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
onRemarkChange?(textView.text ?? "")
|
||||
}
|
||||
}
|
||||
94
suixinkanTests/PaymentCollectionDetailsViewModelTests.swift
Normal file
94
suixinkanTests/PaymentCollectionDetailsViewModelTests.swift
Normal file
@ -0,0 +1,94 @@
|
||||
//
|
||||
// PaymentCollectionDetailsViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 收款详情 ViewModel 测试。
|
||||
@MainActor
|
||||
final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
||||
|
||||
private var appStore: AppStore!
|
||||
|
||||
override func setUp() {
|
||||
let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests")!
|
||||
defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests")
|
||||
appStore = AppStore(defaults: defaults)
|
||||
appStore.userId = "10001"
|
||||
appStore.currentScenicId = 10
|
||||
appStore.currentScenicName = "测试景区"
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
appStore.logout()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testPaymentAmountValidatorRejectsEmptyAmount() {
|
||||
XCTAssertEqual(PaymentAmountValidator.validationMessage(for: ""), "请输入收款金额")
|
||||
}
|
||||
|
||||
func testPaymentAmountValidatorRejectsZeroAmount() {
|
||||
XCTAssertEqual(PaymentAmountValidator.validationMessage(for: "0"), "请输入有效的收款金额")
|
||||
}
|
||||
|
||||
func testPaymentAmountValidatorRejectsMoreThanTwoDecimals() {
|
||||
XCTAssertEqual(PaymentAmountValidator.validationMessage(for: "1.234"), "金额最多支持两位小数")
|
||||
}
|
||||
|
||||
func testPaymentAmountValidatorAcceptsValidAmount() {
|
||||
XCTAssertNil(PaymentAmountValidator.validationMessage(for: "12.50"))
|
||||
}
|
||||
|
||||
func testShowSetAmountDialogRequiresPayCode() {
|
||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||
var message: String?
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
|
||||
viewModel.showSetAmountDialog()
|
||||
|
||||
XCTAssertEqual(message, "请先获取收款码")
|
||||
XCTAssertFalse(viewModel.showAmountDialog)
|
||||
}
|
||||
|
||||
func testConfirmAmountBuildsDynamicURL() async throws {
|
||||
let payCodeJSON = """
|
||||
{"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}}
|
||||
""".data(using: .utf8)!
|
||||
let session = MockURLSession(responses: [payCodeJSON])
|
||||
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
|
||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||
|
||||
await viewModel.loadPayCode(api: api)
|
||||
viewModel.updateAmount("88.50")
|
||||
viewModel.updateRemark("测试备注")
|
||||
viewModel.confirmAmount()
|
||||
|
||||
XCTAssertTrue(viewModel.hasPayCode)
|
||||
XCTAssertFalse(viewModel.showAmountDialog)
|
||||
}
|
||||
|
||||
func testToggleReceiveVoicePersistsInAppStore() {
|
||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||
viewModel.refreshLocalState()
|
||||
|
||||
XCTAssertFalse(viewModel.isVoiceBroadcastOpen)
|
||||
viewModel.toggleReceiveVoice()
|
||||
XCTAssertTrue(appStore.isOpenReceiveVoice)
|
||||
XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
|
||||
}
|
||||
|
||||
func testLoadPayCodeClearsQRWhenScenicMissing() async {
|
||||
appStore.currentScenicId = 0
|
||||
let session = MockURLSession(responses: [])
|
||||
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
|
||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||
|
||||
await viewModel.loadPayCode(api: api)
|
||||
|
||||
XCTAssertFalse(viewModel.hasPayCode)
|
||||
XCTAssertEqual(session.requests.count, 0)
|
||||
}
|
||||
}
|
||||
72
suixinkanTests/PaymentCollectionRecordViewModelTests.swift
Normal file
72
suixinkanTests/PaymentCollectionRecordViewModelTests.swift
Normal file
@ -0,0 +1,72 @@
|
||||
//
|
||||
// PaymentCollectionRecordViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 收款记录 ViewModel 测试。
|
||||
@MainActor
|
||||
final class PaymentCollectionRecordViewModelTests: XCTestCase {
|
||||
|
||||
func testPaymentRecordGrouperGroupsByCreateDate() {
|
||||
let response = RepaymentCollectionRecordResponse(
|
||||
analyse: [
|
||||
RepaymentCollectionRecordAnalyseItem(date: "2024-01-15", orderCount: 2, orderAmountSum: "150.00"),
|
||||
RepaymentCollectionRecordAnalyseItem(date: "2024-01-14", orderCount: 1, orderAmountSum: "80.00"),
|
||||
],
|
||||
list: [
|
||||
RepaymentCollectionRecordItem(
|
||||
orderNumber: "NO001",
|
||||
userPhone: "13800138000",
|
||||
orderAmount: "100.00",
|
||||
createDate: "2024-01-15",
|
||||
createTime: "10:00:00"
|
||||
),
|
||||
RepaymentCollectionRecordItem(
|
||||
orderNumber: "NO002",
|
||||
userPhone: "13800138001",
|
||||
orderAmount: "50.00",
|
||||
createDate: "2024-01-15",
|
||||
createTime: "11:00:00"
|
||||
),
|
||||
RepaymentCollectionRecordItem(
|
||||
orderNumber: "NO003",
|
||||
userPhone: "13800138002",
|
||||
orderAmount: "80.00",
|
||||
createDate: "2024-01-14",
|
||||
createTime: "09:00:00"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
let groups = PaymentRecordGrouper.group(response: response)
|
||||
|
||||
XCTAssertEqual(groups.count, 2)
|
||||
XCTAssertEqual(groups[0].analyse.date, "2024-01-15")
|
||||
XCTAssertEqual(groups[0].items.count, 2)
|
||||
XCTAssertEqual(groups[1].analyse.date, "2024-01-14")
|
||||
XCTAssertEqual(groups[1].items.count, 1)
|
||||
}
|
||||
|
||||
func testLoadRecordUsesAPIAndGroupsResult() async {
|
||||
let defaults = UserDefaults(suiteName: "PaymentCollectionRecordViewModelTests")!
|
||||
defaults.removePersistentDomain(forName: "PaymentCollectionRecordViewModelTests")
|
||||
let appStore = AppStore(defaults: defaults)
|
||||
appStore.currentScenicId = 10
|
||||
|
||||
let recordJSON = """
|
||||
{"code":100000,"msg":"success","data":{"analyse":[{"date":"2024-01-15","order_count":1,"order_amount_sum":"50.00"}],"list":[{"order_number":"NO001","user_phone":"13800138000","order_amount":"50.00","create_date":"2024-01-15","create_time":"10:00:00"}]}}
|
||||
""".data(using: .utf8)!
|
||||
let session = MockURLSession(responses: [recordJSON])
|
||||
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
|
||||
let viewModel = PaymentCollectionRecordViewModel(appStore: appStore)
|
||||
|
||||
await viewModel.loadRecord(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.groups.count, 1)
|
||||
XCTAssertEqual(viewModel.groups[0].items.first?.orderNumber, "NO001")
|
||||
XCTAssertTrue(session.requests.first?.url?.absoluteString.contains("photo-scan-order-list") == true)
|
||||
}
|
||||
}
|
||||
53
suixinkanTests/PaymentModelsTests.swift
Normal file
53
suixinkanTests/PaymentModelsTests.swift
Normal file
@ -0,0 +1,53 @@
|
||||
//
|
||||
// PaymentModelsTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 收款模型解码测试。
|
||||
final class PaymentModelsTests: XCTestCase {
|
||||
func testPayCodeResponseDecodesSnakeCaseFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"static_pay_url": "https://pay.example.com/static",
|
||||
"dynamic_pay_url": "https://pay.example.com/dynamic"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let response = try JSONDecoder().decode(PayCodeResponse.self, from: json)
|
||||
|
||||
XCTAssertEqual(response.staticPayURL, "https://pay.example.com/static")
|
||||
XCTAssertEqual(response.dynamicPayURL, "https://pay.example.com/dynamic")
|
||||
}
|
||||
|
||||
func testRepaymentCollectionRecordResponseDecodesGroupedFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"analyse": [
|
||||
{
|
||||
"date": "2024-01-15",
|
||||
"order_count": 2,
|
||||
"order_amount_sum": "100.00"
|
||||
}
|
||||
],
|
||||
"list": [
|
||||
{
|
||||
"order_number": "NO001",
|
||||
"user_phone": "13800138000",
|
||||
"order_amount": "50.00",
|
||||
"create_date": "2024-01-15",
|
||||
"create_time": "10:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let response = try JSONDecoder().decode(RepaymentCollectionRecordResponse.self, from: json)
|
||||
|
||||
XCTAssertEqual(response.analyse.count, 1)
|
||||
XCTAssertEqual(response.analyse[0].orderCount, 2)
|
||||
XCTAssertEqual(response.list[0].orderNumber, "NO001")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user