diff --git a/suixinkan/Assets.xcassets/Payment/Contents.json b/suixinkan/Assets.xcassets/Payment/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/Payment/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/Contents.json b/suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/Contents.json new file mode 100644 index 0000000..f4c4b6f --- /dev/null +++ b/suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "payment_nalati_background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/payment_nalati_background.png b/suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/payment_nalati_background.png new file mode 100644 index 0000000..530f46e Binary files /dev/null and b/suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/payment_nalati_background.png differ diff --git a/suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/Contents.json b/suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/Contents.json new file mode 100644 index 0000000..79a164d --- /dev/null +++ b/suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "payment_nalati_logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/payment_nalati_logo.png b/suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/payment_nalati_logo.png new file mode 100644 index 0000000..b21b6ca Binary files /dev/null and b/suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/payment_nalati_logo.png differ diff --git a/suixinkan/Features/Home/Models/RolePermissionModels.swift b/suixinkan/Features/Home/Models/RolePermissionModels.swift index 33b0ebb..97f8cb3 100644 --- a/suixinkan/Features/Home/Models/RolePermissionModels.swift +++ b/suixinkan/Features/Home/Models/RolePermissionModels.swift @@ -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 } } diff --git a/suixinkan/Features/Payment/API/PaymentAPI.swift b/suixinkan/Features/Payment/API/PaymentAPI.swift index 2d821e2..ebce962 100644 --- a/suixinkan/Features/Payment/API/PaymentAPI.swift +++ b/suixinkan/Features/Payment/API/PaymentAPI.swift @@ -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( diff --git a/suixinkan/Features/Payment/Models/PaymentModels.swift b/suixinkan/Features/Payment/Models/PaymentModels.swift index 672149b..7234348 100644 --- a/suixinkan/Features/Payment/Models/PaymentModels.swift +++ b/suixinkan/Features/Payment/Models/PaymentModels.swift @@ -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() + 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 + } +} diff --git a/suixinkan/Features/Payment/Services/PayPageConfigCache.swift b/suixinkan/Features/Payment/Services/PayPageConfigCache.swift new file mode 100644 index 0000000..d930dec --- /dev/null +++ b/suixinkan/Features/Payment/Services/PayPageConfigCache.swift @@ -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)" + } +} diff --git a/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift b/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift index 8b22f38..9a2c43d 100644 --- a/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift +++ b/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift @@ -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() { diff --git a/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift index 6266310..f89b9d7 100644 --- a/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift +++ b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift @@ -3,6 +3,7 @@ // suixinkan // +import Kingfisher import SnapKit import UIKit @@ -13,10 +14,18 @@ final class PaymentCollectionDetailsViewController: BaseViewController { private let paymentAPI = NetworkServices.shared.paymentAPI private let scrollView = UIScrollView() + private let contentContainerView = UIView() private let contentStack = UIStackView() + private let backgroundImageView = UIImageView() + private let brandHeaderStack = UIStackView() + private let brandLogoImageView = UIImageView() + private let brandTitleLabel = UILabel() + private let brandSubtitleLabel = UILabel() + private let qrCardView = UIView() private let scenicNameLabel = UILabel() + private let qrContainerView = UIView() private let qrImageView = UIImageView() private let qrPlaceholderView = UIView() private let qrErrorLabel = UILabel() @@ -29,26 +38,75 @@ final class PaymentCollectionDetailsViewController: BaseViewController { private let scenicRow = PaymentInfoRowView(title: "景区名称") private let merchantRow = PaymentInfoRowView(title: "收款方") private let staffRow = PaymentInfoRowView(title: "员工 ID") + private let photographerRow = PaymentInfoRowView(title: "摄影师名称") + private let storeRow = PaymentInfoRowView(title: "店铺名称") private let recordRow = PaymentNavigationRowView(title: "收款记录") + private let settingsCardView = UIView() private let voiceCardView = UIView() private let voiceTitleLabel = UILabel() private let voiceSwitch = UISwitch() private var amountDialog: PaymentSetAmountDialogView? + private var appliedBrandConfig: PayPageConfig? + private var appliedBrandingRefreshVersion = -1 + + private var previousStandardAppearance: UINavigationBarAppearance? + private var previousScrollEdgeAppearance: UINavigationBarAppearance? + private var previousCompactAppearance: UINavigationBarAppearance? + private var previousNavigationTintColor: UIColor? + private var previousNavigationTranslucency = false + + private var brandScreenHeight: CGFloat { + UIScreen.main.bounds.height + } + + private var brandLogoHeight: CGFloat { + if brandScreenHeight <= 700 { return 82 } + if brandScreenHeight <= 820 { return 96 } + return 112 + } + + private var brandQRContainerSize: CGFloat { + if brandScreenHeight <= 700 { return 170 } + if brandScreenHeight <= 820 { return 190 } + return 210 + } + + private var brandQRImageSize: CGFloat { + if brandScreenHeight <= 700 { return 146 } + if brandScreenHeight <= 820 { return 166 } + return 182 + } + + private var brandSectionSpacing: CGFloat { + if brandScreenHeight <= 700 { return 8 } + if brandScreenHeight <= 820 { return 10 } + return 12 + } + + private var brandHeaderToQRSpacing: CGFloat { + if brandScreenHeight <= 700 { return 16 } + if brandScreenHeight <= 820 { return 18 } + return 20 + } override func setupNavigationBar() { - title = "收款详情" + title = viewModel.usesNalatiBranding ? nil : "收款详情" } override func setupUI() { - view.backgroundColor = AppColor.pageBackground + let usesBranding = viewModel.usesNalatiBranding + view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground + scrollView.showsVerticalScrollIndicator = !usesBranding + scrollView.isScrollEnabled = !usesBranding + scrollView.alwaysBounceVertical = false contentStack.axis = .vertical - contentStack.spacing = AppSpacing.md + contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md - qrCardView.backgroundColor = .white + qrCardView.backgroundColor = usesBranding ? .clear : .white qrCardView.layer.cornerRadius = AppRadius.lg scenicNameLabel.font = .systemFont(ofSize: 16, weight: .medium) @@ -57,9 +115,16 @@ final class PaymentCollectionDetailsViewController: BaseViewController { qrImageView.contentMode = .scaleAspectFit qrImageView.isHidden = true + if usesBranding { + qrContainerView.backgroundColor = .white + qrContainerView.layer.cornerRadius = 10 + qrContainerView.clipsToBounds = true + qrContainerView.isHidden = true + qrContainerView.addSubview(qrImageView) + } qrPlaceholderView.backgroundColor = AppColor.inputBackground - qrPlaceholderView.layer.cornerRadius = 24 + qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24 qrPlaceholderView.isHidden = true qrErrorLabel.text = "未选景区或网络问题" @@ -73,19 +138,29 @@ final class PaymentCollectionDetailsViewController: BaseViewController { actionRow.axis = .horizontal actionRow.alignment = .center - actionRow.spacing = AppSpacing.xl + actionRow.spacing = usesBranding ? AppSpacing.lg : AppSpacing.xl + if usesBranding { + actionRow.backgroundColor = UIColor.white.withAlphaComponent(0.88) + actionRow.layer.cornerRadius = 24 + actionRow.isLayoutMarginsRelativeArrangement = true + actionRow.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 10, + leading: 24, + bottom: 10, + trailing: 24 + ) + } configureLinkButton(setAmountButton, title: "设置金额") configureLinkButton(saveQRButton, title: "保存二维码") - infoCardView.backgroundColor = .white - infoCardView.layer.cornerRadius = AppRadius.lg + configureCard(infoCardView, branded: usesBranding) - recordRow.backgroundColor = .white - recordRow.layer.cornerRadius = AppRadius.lg + recordRow.backgroundColor = usesBranding ? .clear : .white + recordRow.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg - voiceCardView.backgroundColor = .white - voiceCardView.layer.cornerRadius = AppRadius.lg + voiceCardView.backgroundColor = usesBranding ? .clear : .white + voiceCardView.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg voiceTitleLabel.text = "收款到账语音提醒" voiceTitleLabel.font = .systemFont(ofSize: 14) @@ -93,49 +168,112 @@ final class PaymentCollectionDetailsViewController: BaseViewController { voiceSwitch.onTintColor = AppColor.primary - view.addSubview(scrollView) - scrollView.addSubview(contentStack) + if usesBranding { + configureBrandHeader() + backgroundImageView.contentMode = .scaleAspectFill + backgroundImageView.clipsToBounds = true + backgroundImageView.image = UIImage(named: "payment_nalati_background") + view.addSubview(backgroundImageView) + } + view.addSubview(scrollView) + scrollView.addSubview(contentContainerView) + contentContainerView.addSubview(contentStack) + + if usesBranding { + contentStack.addArrangedSubview(brandHeaderStack) + contentStack.setCustomSpacing(brandHeaderToQRSpacing, after: brandHeaderStack) + } contentStack.addArrangedSubview(qrCardView) contentStack.addArrangedSubview(infoCardView) - contentStack.addArrangedSubview(recordRow) - contentStack.addArrangedSubview(voiceCardView) + if usesBranding { + configureCard(settingsCardView, branded: true) + contentStack.addArrangedSubview(settingsCardView) + } else { + contentStack.addArrangedSubview(recordRow) + contentStack.addArrangedSubview(voiceCardView) + } + let qrDisplayView = usesBranding ? qrContainerView : qrImageView let qrContentStack = UIStackView(arrangedSubviews: [ scenicNameLabel, - qrImageView, + qrDisplayView, qrPlaceholderView, actionRow, ]) qrContentStack.axis = .vertical qrContentStack.alignment = .center - qrContentStack.spacing = AppSpacing.lg + qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg qrCardView.addSubview(qrContentStack) qrPlaceholderView.addSubview(qrErrorLabel) qrPlaceholderView.addSubview(refreshButton) actionRow.addArrangedSubview(setAmountButton) + if usesBranding { + let divider = UIView() + divider.backgroundColor = UIColor(hex: 0xD6E1EE) + actionRow.addArrangedSubview(divider) + divider.snp.makeConstraints { make in + make.width.equalTo(1) + make.height.equalTo(24) + } + } actionRow.addArrangedSubview(saveQRButton) - let infoStack = UIStackView(arrangedSubviews: [scenicRow, merchantRow, staffRow]) + let infoRows = usesBranding + ? [photographerRow, storeRow] + : [scenicRow, merchantRow, staffRow] + let infoStack = UIStackView(arrangedSubviews: infoRows) 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)) + if usesBranding { + let settingsStack = UIStackView(arrangedSubviews: [recordRow, voiceCardView]) + settingsStack.axis = .vertical + settingsCardView.addSubview(settingsStack) + settingsStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + let divider = UIView() + divider.backgroundColor = UIColor(hex: 0xD9E2EA).withAlphaComponent(0.75) + settingsCardView.addSubview(divider) + divider.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.centerY.equalToSuperview() + make.height.equalTo(0.5) + } } + + qrContentStack.snp.makeConstraints { make in + let verticalInset: CGFloat = usesBranding ? 0 : 32 + make.edges.equalToSuperview().inset( + UIEdgeInsets(top: verticalInset, left: 16, bottom: verticalInset, right: 16) + ) + } + scenicNameLabel.isHidden = usesBranding scenicNameLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() } - qrImageView.snp.makeConstraints { make in - make.width.height.equalTo(182) + if usesBranding { + qrContainerView.snp.makeConstraints { make in + make.width.height.equalTo(brandQRContainerSize) + } + qrImageView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(brandQRImageSize) + } + } else { + qrImageView.snp.makeConstraints { make in + make.width.height.equalTo(182) + } } qrPlaceholderView.snp.makeConstraints { make in - make.width.height.equalTo(182) + make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182) } qrErrorLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(56) @@ -162,12 +300,36 @@ final class PaymentCollectionDetailsViewController: BaseViewController { } override func setupConstraints() { + if viewModel.usesNalatiBranding { + backgroundImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } scrollView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) + if viewModel.usesNalatiBranding { + make.leading.trailing.equalToSuperview() + make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(-44) + make.bottom.equalTo(view.safeAreaLayoutGuide) + } else { + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + contentContainerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(scrollView.snp.width) + if viewModel.usesNalatiBranding { + make.height.equalTo(scrollView.snp.height) + } } contentStack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset) make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) + if viewModel.usesNalatiBranding { + make.centerX.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview() + make.bottom.lessThanOrEqualToSuperview() + } else { + make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset) + } } } @@ -188,13 +350,30 @@ final class PaymentCollectionDetailsViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() + applyViewModel() Task { await loadData() } } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + applyBrandNavigationAppearanceIfNeeded() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + restoreNavigationAppearanceIfNeeded() + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + viewModel.usesNalatiBranding ? .darkContent : .default + } + private func loadData() async { + async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI) showLoading() await viewModel.loadPayCode(api: paymentAPI) hideLoading() + await pageConfig } @MainActor @@ -203,15 +382,20 @@ final class PaymentCollectionDetailsViewController: BaseViewController { scenicRow.setValue(viewModel.displayScenicName) merchantRow.setValue(viewModel.merchantInfo.company) staffRow.setValue(viewModel.staffId.isEmpty ? "-" : viewModel.staffId) + photographerRow.setValue(viewModel.displayPhotographerName) + storeRow.setValue(viewModel.displayStoreNames) voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen + applyBrandConfigIfNeeded() if let image = viewModel.qrImage { qrImageView.image = image qrImageView.isHidden = false + qrContainerView.isHidden = !viewModel.usesNalatiBranding qrPlaceholderView.isHidden = true actionRow.isHidden = false } else { qrImageView.isHidden = true + qrContainerView.isHidden = true qrPlaceholderView.isHidden = false actionRow.isHidden = true } @@ -254,6 +438,181 @@ final class PaymentCollectionDetailsViewController: BaseViewController { button.titleLabel?.font = .systemFont(ofSize: 14) } + private func configureCard(_ view: UIView, branded: Bool) { + view.backgroundColor = branded ? UIColor.white.withAlphaComponent(0.9) : .white + view.layer.cornerRadius = branded ? 18 : AppRadius.lg + view.clipsToBounds = true + } + + private func configureBrandHeader() { + brandHeaderStack.axis = .vertical + brandHeaderStack.alignment = .center + brandHeaderStack.spacing = brandScreenHeight <= 700 ? 6 : 8 + + brandLogoImageView.image = UIImage(named: "payment_nalati_logo") + brandLogoImageView.contentMode = .scaleAspectFit + brandLogoImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + brandTitleLabel.font = .systemFont( + ofSize: brandScreenHeight <= 700 ? 21 : 23, + weight: .bold + ) + brandTitleLabel.textColor = UIColor(hex: 0x102D54) + brandTitleLabel.textAlignment = .center + brandTitleLabel.numberOfLines = 1 + brandTitleLabel.adjustsFontSizeToFitWidth = true + brandTitleLabel.minimumScaleFactor = 0.8 + + brandSubtitleLabel.font = .systemFont( + ofSize: brandScreenHeight <= 700 ? 15 : 16, + weight: .medium + ) + brandSubtitleLabel.textColor = UIColor(hex: 0x183A61) + brandSubtitleLabel.textAlignment = .center + + let subtitleRow = UIStackView() + subtitleRow.axis = .horizontal + subtitleRow.alignment = .center + subtitleRow.spacing = 9 + + let leftLine = makeBrandLine() + let leftDiamond = makeBrandDiamond() + let rightDiamond = makeBrandDiamond() + let rightLine = makeBrandLine() + [leftLine, leftDiamond, brandSubtitleLabel, rightDiamond, rightLine].forEach( + subtitleRow.addArrangedSubview + ) + + brandHeaderStack.addArrangedSubview(brandLogoImageView) + brandHeaderStack.addArrangedSubview(brandTitleLabel) + brandHeaderStack.addArrangedSubview(subtitleRow) + brandHeaderStack.setCustomSpacing( + brandScreenHeight <= 700 ? 8 : 10, + after: brandLogoImageView + ) + + brandLogoImageView.snp.makeConstraints { make in + make.width.equalToSuperview().inset(8) + make.height.equalTo(brandLogoHeight) + } + brandTitleLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(8) + } + [leftLine, rightLine].forEach { line in + line.snp.makeConstraints { make in + make.width.equalTo(42) + make.height.equalTo(1) + } + } + [leftDiamond, rightDiamond].forEach { diamond in + diamond.snp.makeConstraints { make in + make.width.height.equalTo(6) + } + } + } + + private func makeBrandLine() -> UIView { + let view = UIView() + view.backgroundColor = UIColor(hex: 0x27496D).withAlphaComponent(0.75) + return view + } + + private func makeBrandDiamond() -> UIView { + let view = UIView() + view.backgroundColor = UIColor(hex: 0x27496D) + view.transform = CGAffineTransform(rotationAngle: .pi / 4) + return view + } + + @MainActor + private func applyBrandConfigIfNeeded() { + guard viewModel.usesNalatiBranding else { return } + let config = viewModel.payPageConfig + brandTitleLabel.text = config.title + brandSubtitleLabel.text = config.subtitle + + let versionChanged = appliedBrandingRefreshVersion != viewModel.brandingRefreshVersion + guard appliedBrandConfig != config || versionChanged else { return } + let forceRefresh = appliedBrandingRefreshVersion >= 0 && versionChanged + setBrandImage( + backgroundImageView, + urlString: config.background, + fallbackName: "payment_nalati_background", + forceRefresh: forceRefresh + ) + setBrandImage( + brandLogoImageView, + urlString: config.logo, + fallbackName: "payment_nalati_logo", + forceRefresh: forceRefresh + ) + appliedBrandConfig = config + appliedBrandingRefreshVersion = viewModel.brandingRefreshVersion + } + + private func setBrandImage( + _ imageView: UIImageView, + urlString: String, + fallbackName: String, + forceRefresh: Bool + ) { + let fallback = UIImage(named: fallbackName) + let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), + url.scheme?.lowercased() == "https", + url.host != nil else { + imageView.image = fallback + return + } + + var options: KingfisherOptionsInfo = [ + .keepCurrentImageWhileLoading, + .cacheOriginalImage, + .transition(.fade(0.2)), + ] + if forceRefresh { + options.append(.forceRefresh) + } + imageView.kf.setImage(with: url, placeholder: fallback, options: options) + } + + private func applyBrandNavigationAppearanceIfNeeded() { + guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return } + previousStandardAppearance = navigationBar.standardAppearance + previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance + previousCompactAppearance = navigationBar.compactAppearance + previousNavigationTintColor = navigationBar.tintColor + previousNavigationTranslucency = navigationBar.isTranslucent + + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.shadowColor = .clear + appearance.titleTextAttributes = [ + .foregroundColor: UIColor(hex: 0x111827), + .font: UIFont.app(.title), + ] + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + navigationBar.tintColor = UIColor(hex: 0x111827) + navigationBar.isTranslucent = true + } + + private func restoreNavigationAppearanceIfNeeded() { + guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return } + if let previousStandardAppearance { + navigationBar.standardAppearance = previousStandardAppearance + } + navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance + navigationBar.compactAppearance = previousCompactAppearance + navigationBar.tintColor = previousNavigationTintColor + navigationBar.isTranslucent = previousNavigationTranslucency + previousStandardAppearance = nil + previousScrollEdgeAppearance = nil + previousCompactAppearance = nil + previousNavigationTintColor = nil + } + @objc private func setAmountTapped() { viewModel.showSetAmountDialog() } @@ -310,7 +669,7 @@ private final class PaymentInfoRowView: UIView { valueLabel.font = .systemFont(ofSize: 14) valueLabel.textColor = AppColor.textPrimary valueLabel.textAlignment = .right - valueLabel.numberOfLines = 2 + valueLabel.numberOfLines = 0 divider.backgroundColor = UIColor(hex: 0xE5E7EB) diff --git a/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift index c7da538..fe4fe17 100644 --- a/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift +++ b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift @@ -79,6 +79,24 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase { XCTAssertTrue(viewModel.isVoiceBroadcastOpen) } + func testNalatiAccountInfoUsesLoggedInPhotographerAndAllPermissionStores() { + appStore.session.userName = "摄影师小王" + appStore.permissions.saveRolePermissionList([ + RolePermissionResponse( + role: RoleInfo(id: 41, name: "摄影师", roleCode: "photographer"), + store: [ + RolePermissionStoreInfo(id: 1, name: "空中草原店"), + RolePermissionStoreInfo(id: 2, name: "河谷草原店"), + ] + ), + ]) + + let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) + + XCTAssertEqual(viewModel.displayPhotographerName, "摄影师小王") + XCTAssertEqual(viewModel.displayStoreNames, "空中草原店、河谷草原店") + } + func testLoadPayCodeClearsQRWhenScenicMissing() async { appStore.session.currentScenicId = 0 let session = MockURLSession(responses: []) @@ -90,4 +108,74 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase { XCTAssertFalse(viewModel.hasPayCode) XCTAssertEqual(session.requests.count, 0) } + + func testNalatiLoadsCachedConfigBeforeRefreshingRemoteConfig() async throws { + let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests.Config")! + defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests.Config") + let cache = PayPageConfigCache(defaults: defaults, environment: .testing) + let cached = PayPageConfig( + background: "https://cache.example.com/background.png", + logo: "https://cache.example.com/logo.png", + title: "缓存标题", + subtitle: "缓存副标题" + ) + cache.save(cached, scenicId: 128) + appStore.session.currentScenicId = 128 + appStore.session.currentScenicName = "那拉提景区" + + let responseJSON = """ + {"code":100000,"msg":"success","data":{ + "background":"https://new.example.com/background.png", + "logo":"", + "title":"最新标题", + "subtitle":"" + }} + """.data(using: .utf8)! + let session = MockURLSession(responses: [responseJSON]) + let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = PaymentCollectionDetailsViewModel( + appStore: appStore, + payPageConfigCache: cache + ) + + XCTAssertEqual(viewModel.payPageConfig, cached) + await viewModel.loadPayPageConfig(api: api) + + XCTAssertEqual(viewModel.payPageConfig.background, "https://new.example.com/background.png") + XCTAssertEqual(viewModel.payPageConfig.logo, cached.logo) + XCTAssertEqual(viewModel.payPageConfig.title, "最新标题") + XCTAssertEqual(viewModel.payPageConfig.subtitle, cached.subtitle) + XCTAssertEqual(viewModel.brandingRefreshVersion, 1) + XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/pay-page-config") + XCTAssertEqual( + URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "scenic_id" })? + .value, + "128" + ) + } + + func testNonNalatiDoesNotRequestPayPageConfig() async { + let session = MockURLSession(responses: []) + let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) + + await viewModel.loadPayPageConfig(api: api) + + XCTAssertFalse(viewModel.usesNalatiBranding) + XCTAssertEqual(session.requests.count, 0) + } + + func testPayPageConfigCacheIsEnvironmentScoped() { + let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests.Environment")! + defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests.Environment") + let production = PayPageConfigCache(defaults: defaults, environment: .production) + let testing = PayPageConfigCache(defaults: defaults, environment: .testing) + + production.save(.nalatiDefault, scenicId: 128) + + XCTAssertEqual(production.config(scenicId: 128), .nalatiDefault) + XCTAssertNil(testing.config(scenicId: 128)) + } } diff --git a/suixinkanTests/PaymentModelsTests.swift b/suixinkanTests/PaymentModelsTests.swift index 7da2b97..c120bb9 100644 --- a/suixinkanTests/PaymentModelsTests.swift +++ b/suixinkanTests/PaymentModelsTests.swift @@ -8,6 +8,88 @@ import XCTest /// 收款模型解码测试。 final class PaymentModelsTests: XCTestCase { + func testPayPageConfigResponseDecodesBrandFields() throws { + let json = """ + { + "background": "https://example.com/background.png", + "logo": "https://example.com/logo.png", + "title": "那拉提旅拍管理收费平台", + "subtitle": "那拉提景区" + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(PayPageConfigResponse.self, from: json) + + XCTAssertEqual(response.background, "https://example.com/background.png") + XCTAssertEqual(response.logo, "https://example.com/logo.png") + XCTAssertEqual(response.title, "那拉提旅拍管理收费平台") + XCTAssertEqual(response.subtitle, "那拉提景区") + } + + func testPayPageConfigMergesOnlyUsableRemoteFields() { + let cached = PayPageConfig( + background: "https://cache.example.com/background.png", + logo: "https://cache.example.com/logo.png", + title: "缓存标题", + subtitle: "缓存副标题" + ) + + let merged = cached.merging( + PayPageConfigResponse( + background: "", + logo: "http://unsafe.example.com/logo.png", + title: " 最新标题 ", + subtitle: "" + ) + ) + + XCTAssertEqual(merged.background, cached.background) + XCTAssertEqual(merged.logo, cached.logo) + XCTAssertEqual(merged.title, "最新标题") + XCTAssertEqual(merged.subtitle, cached.subtitle) + } + + func testPayPageBrandingOnlyEnablesNalatiScenicId() { + XCTAssertTrue(PayPageBrandingPolicy.isNalati(scenicId: 128)) + XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 127)) + XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0)) + } + + func testPaymentAccountDisplayFormatterUsesCurrentPhotographerAndAllUniqueStores() { + XCTAssertEqual( + PaymentAccountDisplayFormatter.photographerName(userName: " 当前摄影师 ", realName: "实名"), + "当前摄影师" + ) + XCTAssertEqual( + PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]), + "一号店、二号店" + ) + XCTAssertEqual(PaymentAccountDisplayFormatter.storeNames([]), "-") + } + + func testRolePermissionResponseDecodesAllAccountStores() throws { + let json = """ + { + "role": { + "id": 41, + "name": "摄影师", + "role_code": "photographer", + "notes": null, + "permission": [] + }, + "scenic": [], + "store": [ + {"id": 37, "name": "随心旅拍", "status": 1}, + {"id": 38, "name": "空中草原店", "status": 1} + ] + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(RolePermissionResponse.self, from: json) + + XCTAssertEqual(response.store.map(\.name), ["随心旅拍", "空中草原店"]) + } + func testPayCodeResponseDecodesSnakeCaseFields() throws { let json = """ {