Files
suixinkan_uikit/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift

259 lines
8.2 KiB
Swift

//
// 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 = ""
private(set) var scenicId = 0
private(set) var payPageConfig = PayPageConfig.nalatiDefault
private(set) var brandingRefreshVersion = 0
let merchantInfo = PaymentMerchantInfo.defaultInfo
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private var staticPayURL = ""
private var dynamicPayURL = ""
private var hasCompletedPayCodeRequest = false
private let appStore: AppStore
private let payPageConfigCache: PayPageConfigCaching
init(
appStore: AppStore = .shared,
payPageConfigCache: PayPageConfigCaching = PayPageConfigCache()
) {
self.appStore = appStore
self.payPageConfigCache = payPageConfigCache
scenicId = appStore.session.currentScenicId
scenicName = appStore.session.currentScenicName
staffId = appStore.session.userId
isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice
restorePayPageConfig()
}
var hasPayCode: Bool {
qrImage != nil && !staticPayURL.isEmpty
}
/// 是否应在二维码区域展示加载状态,首次请求开始前也视为加载中,避免短暂闪现错误占位。
var isPayCodeLoading: Bool {
loading || !hasCompletedPayCodeRequest
}
var displayScenicName: String {
scenicName.isEmpty ? "暂无景区" : scenicName
}
/// 是否启用品牌收款页,包含那拉提与赛里木湖。
var usesBranding: Bool {
PayPageBrandingPolicy.defaultConfig(scenicId: scenicId) != nil
}
/// 当前景区的随包背景资源名称。
var brandBackgroundAssetName: String {
scenicId == PayPageBrandingPolicy.sailimuScenicId
? "payment_sailimu_background" : "payment_nalati_background"
}
/// 当前景区的随包 Logo 资源名称。
var brandLogoAssetName: String {
scenicId == PayPageBrandingPolicy.sailimuScenicId
? "payment_sailimu_logo" : "payment_nalati_logo"
}
/// 品牌主标题的最大行数,赛里木湖允许两行展示。
var brandTitleNumberOfLines: Int {
scenicId == PayPageBrandingPolicy.sailimuScenicId ? 2 : 1
}
var displayPhotographerName: String {
PaymentAccountDisplayFormatter.photographerName(
realName: appStore.session.realName
)
}
var displayStoreNames: String {
let names = appStore.permissions.rolePermissionList().flatMap(\.store).map(\.name)
return PaymentAccountDisplayFormatter.storeNames(names)
}
func refreshLocalState() {
scenicId = appStore.session.currentScenicId
scenicName = appStore.session.currentScenicName
staffId = appStore.session.userId
isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice
restorePayPageConfig()
notifyStateChange()
}
private func restorePayPageConfig() {
guard let defaults = PayPageBrandingPolicy.defaultConfig(scenicId: scenicId) else {
payPageConfig = .nalatiDefault
return
}
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? defaults
}
func loadPayCode(api: PaymentAPI) async {
guard !loading else { return }
loading = true
refreshLocalState()
defer {
loading = false
hasCompletedPayCodeRequest = true
notifyStateChange()
}
let scenicId = self.scenicId
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 loadPayPageConfig(api: PaymentAPI) async {
let requestedScenicId = appStore.session.currentScenicId
guard let defaults = PayPageBrandingPolicy.defaultConfig(scenicId: requestedScenicId) else { return }
do {
let response = try await api.payPageConfig(scenicId: requestedScenicId)
guard appStore.session.currentScenicId == requestedScenicId,
scenicId == requestedScenicId else { return }
let cached = payPageConfigCache.config(scenicId: requestedScenicId) ?? defaults
let resolved = cached.merging(response)
payPageConfigCache.save(resolved, scenicId: requestedScenicId)
payPageConfig = resolved
brandingRefreshVersion += 1
notifyStateChange()
} catch {
// 品牌配置失败时保留缓存或随包默认值,不影响收款码与页面交互。
}
}
func refresh(api: PaymentAPI) async {
async let payCode: Void = loadPayCode(api: api)
async let pageConfig: Void = loadPayPageConfig(api: api)
_ = await (payCode, pageConfig)
}
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.payment.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
}
}