feat: 品牌化那拉提景区收款页
This commit is contained in:
@ -9,10 +9,40 @@ import Foundation
|
||||
struct RolePermissionResponse: Codable, Equatable, Sendable {
|
||||
let role: RoleInfo
|
||||
let scenic: [ScenicInfo]
|
||||
let store: [RolePermissionStoreInfo]
|
||||
|
||||
init(role: RoleInfo, scenic: [ScenicInfo] = []) {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role
|
||||
case scenic
|
||||
case store
|
||||
}
|
||||
|
||||
init(
|
||||
role: RoleInfo,
|
||||
scenic: [ScenicInfo] = [],
|
||||
store: [RolePermissionStoreInfo] = []
|
||||
) {
|
||||
self.role = role
|
||||
self.scenic = scenic
|
||||
self.store = store
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
role = try container.decode(RoleInfo.self, forKey: .role)
|
||||
scenic = try container.decodeIfPresent([ScenicInfo].self, forKey: .scenic) ?? []
|
||||
store = try container.decodeIfPresent([RolePermissionStoreInfo].self, forKey: .store) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前角色账号可使用的店铺摘要,来源于 `role-permission` 的 `store` 字段。
|
||||
struct RolePermissionStoreInfo: Codable, Equatable, Sendable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
init(id: Int = 0, name: String = "") {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,17 @@ final class PaymentAPI {
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前景区收款页的背景、Logo 与标题配置。
|
||||
func payPageConfig(scenicId: Int) async throws -> PayPageConfigResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/pay-page-config",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取近 7 天收款记录。
|
||||
func collectionRecord(scenicId: Int) async throws -> RepaymentCollectionRecordResponse {
|
||||
try await client.send(
|
||||
|
||||
@ -5,6 +5,83 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 收款页展示配置接口响应。
|
||||
struct PayPageConfigResponse: Decodable, Sendable, Equatable {
|
||||
let background: String
|
||||
let logo: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
|
||||
init(background: String, logo: String, title: String, subtitle: String) {
|
||||
self.background = background
|
||||
self.logo = logo
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款页最终可展示并可持久化的品牌配置。
|
||||
struct PayPageConfig: Codable, Sendable, Equatable {
|
||||
let background: String
|
||||
let logo: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
|
||||
/// 那拉提景区随包默认配置。
|
||||
static let nalatiDefault = PayPageConfig(
|
||||
background: "",
|
||||
logo: "",
|
||||
title: "那拉提旅拍管理收费平台",
|
||||
subtitle: "那拉提景区"
|
||||
)
|
||||
|
||||
/// 将服务端非空有效字段合并到当前可用配置。
|
||||
func merging(_ response: PayPageConfigResponse) -> PayPageConfig {
|
||||
PayPageConfig(
|
||||
background: Self.validRemoteImageURL(response.background) ?? background,
|
||||
logo: Self.validRemoteImageURL(response.logo) ?? logo,
|
||||
title: response.title.trimmedNonEmpty ?? title,
|
||||
subtitle: response.subtitle.trimmedNonEmpty ?? subtitle
|
||||
)
|
||||
}
|
||||
|
||||
private static func validRemoteImageURL(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let url = URL(string: trimmed),
|
||||
url.scheme?.lowercased() == "https",
|
||||
url.host != nil else {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款页品牌化景区判定策略。
|
||||
enum PayPageBrandingPolicy {
|
||||
/// 那拉提景区正式 ID。
|
||||
static let nalatiScenicId = 128
|
||||
|
||||
/// 判断当前景区是否启用品牌收款页。
|
||||
static func isNalati(scenicId: Int) -> Bool {
|
||||
scenicId == nalatiScenicId
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款页当前账号信息的展示文本格式化规则。
|
||||
enum PaymentAccountDisplayFormatter {
|
||||
/// 解析当前登录摄影师名称,昵称为空时使用实名,仍为空时显示占位符。
|
||||
static func photographerName(userName: String, realName: String) -> String {
|
||||
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-"
|
||||
}
|
||||
|
||||
/// 去除空值与重复项后,合并当前账号的全部店铺名称。
|
||||
static func storeNames(_ names: [String]) -> String {
|
||||
var seen = Set<String>()
|
||||
let resolved = names.compactMap(\.trimmedNonEmpty).filter { seen.insert($0).inserted }
|
||||
return resolved.isEmpty ? "-" : resolved.joined(separator: "、")
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄影师收款码响应,包含静态与动态支付 URL。
|
||||
struct PayCodeResponse: Decodable, Sendable, Equatable {
|
||||
let staticPayURL: String
|
||||
@ -101,3 +178,10 @@ struct PaymentMerchantInfo: Sendable, Equatable {
|
||||
company: "扬州元智享网络科技有限公司"
|
||||
)
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmedNonEmpty: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
|
||||
51
suixinkan/Features/Payment/Services/PayPageConfigCache.swift
Normal file
51
suixinkan/Features/Payment/Services/PayPageConfigCache.swift
Normal file
@ -0,0 +1,51 @@
|
||||
//
|
||||
// PayPageConfigCache.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 收款页品牌配置缓存协议,供 ViewModel 注入持久化实现或测试替身。
|
||||
protocol PayPageConfigCaching {
|
||||
/// 读取指定景区的品牌配置。
|
||||
func config(scenicId: Int) -> PayPageConfig?
|
||||
|
||||
/// 保存指定景区的完整品牌配置。
|
||||
func save(_ config: PayPageConfig, scenicId: Int)
|
||||
}
|
||||
|
||||
/// 使用 UserDefaults 按接口环境与景区隔离的收款页品牌配置缓存。
|
||||
final class PayPageConfigCache: PayPageConfigCaching {
|
||||
private static let version = "v1"
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let environmentNamespace: String
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
/// 使用指定持久化容器和 API 环境创建缓存。
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
environment: APIEnvironment = .current
|
||||
) {
|
||||
self.defaults = defaults
|
||||
environmentNamespace = environment.baseURL.host ?? environment.baseURL.absoluteString
|
||||
}
|
||||
|
||||
func config(scenicId: Int) -> PayPageConfig? {
|
||||
guard scenicId > 0,
|
||||
let data = defaults.data(forKey: key(scenicId: scenicId)) else {
|
||||
return nil
|
||||
}
|
||||
return try? decoder.decode(PayPageConfig.self, from: data)
|
||||
}
|
||||
|
||||
func save(_ config: PayPageConfig, scenicId: Int) {
|
||||
guard scenicId > 0, let data = try? encoder.encode(config) else { return }
|
||||
defaults.set(data, forKey: key(scenicId: scenicId))
|
||||
}
|
||||
|
||||
private func key(scenicId: Int) -> String {
|
||||
"payment.pay_page_config.\(Self.version).\(environmentNamespace).\(scenicId)"
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,9 @@ final class PaymentCollectionDetailsViewModel {
|
||||
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
|
||||
|
||||
@ -26,9 +29,21 @@ final class PaymentCollectionDetailsViewModel {
|
||||
private var staticPayURL = ""
|
||||
private var dynamicPayURL = ""
|
||||
private let appStore: AppStore
|
||||
private let payPageConfigCache: PayPageConfigCaching
|
||||
|
||||
init(appStore: AppStore = .shared) {
|
||||
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
|
||||
if PayPageBrandingPolicy.isNalati(scenicId: scenicId) {
|
||||
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
|
||||
}
|
||||
}
|
||||
|
||||
var hasPayCode: Bool {
|
||||
@ -39,10 +54,32 @@ final class PaymentCollectionDetailsViewModel {
|
||||
scenicName.isEmpty ? "暂无景区" : scenicName
|
||||
}
|
||||
|
||||
var usesNalatiBranding: Bool {
|
||||
PayPageBrandingPolicy.isNalati(scenicId: scenicId)
|
||||
}
|
||||
|
||||
var displayPhotographerName: String {
|
||||
PaymentAccountDisplayFormatter.photographerName(
|
||||
userName: appStore.session.userName,
|
||||
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
|
||||
if usesNalatiBranding {
|
||||
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
|
||||
} else {
|
||||
payPageConfig = .nalatiDefault
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
@ -56,7 +93,7 @@ final class PaymentCollectionDetailsViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let scenicId = appStore.session.currentScenicId
|
||||
let scenicId = self.scenicId
|
||||
guard scenicId > 0 else {
|
||||
clearPayCode()
|
||||
return
|
||||
@ -72,8 +109,27 @@ final class PaymentCollectionDetailsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func loadPayPageConfig(api: PaymentAPI) async {
|
||||
let requestedScenicId = appStore.session.currentScenicId
|
||||
guard PayPageBrandingPolicy.isNalati(scenicId: requestedScenicId) else { return }
|
||||
|
||||
do {
|
||||
let response = try await api.payPageConfig(scenicId: requestedScenicId)
|
||||
guard appStore.session.currentScenicId == requestedScenicId else { return }
|
||||
let resolved = payPageConfig.merging(response)
|
||||
payPageConfigCache.save(resolved, scenicId: requestedScenicId)
|
||||
payPageConfig = resolved
|
||||
brandingRefreshVersion += 1
|
||||
notifyStateChange()
|
||||
} catch {
|
||||
// 品牌配置失败时保留缓存或随包默认值,不影响收款码与页面交互。
|
||||
}
|
||||
}
|
||||
|
||||
func refresh(api: PaymentAPI) async {
|
||||
await loadPayCode(api: api)
|
||||
async let payCode: Void = loadPayCode(api: api)
|
||||
async let pageConfig: Void = loadPayPageConfig(api: api)
|
||||
_ = await (payCode, pageConfig)
|
||||
}
|
||||
|
||||
func showSetAmountDialog() {
|
||||
|
||||
Reference in New Issue
Block a user