diff --git a/suixinkan/Assets.xcassets/Payment/payment_sailimu_background.imageset/Contents.json b/suixinkan/Assets.xcassets/Payment/payment_sailimu_background.imageset/Contents.json new file mode 100644 index 0000000..6406738 --- /dev/null +++ b/suixinkan/Assets.xcassets/Payment/payment_sailimu_background.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "payment_sailimu_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_sailimu_background.imageset/payment_sailimu_background.png b/suixinkan/Assets.xcassets/Payment/payment_sailimu_background.imageset/payment_sailimu_background.png new file mode 100644 index 0000000..4081eff Binary files /dev/null and b/suixinkan/Assets.xcassets/Payment/payment_sailimu_background.imageset/payment_sailimu_background.png differ diff --git a/suixinkan/Assets.xcassets/Payment/payment_sailimu_logo.imageset/Contents.json b/suixinkan/Assets.xcassets/Payment/payment_sailimu_logo.imageset/Contents.json new file mode 100644 index 0000000..30bfb67 --- /dev/null +++ b/suixinkan/Assets.xcassets/Payment/payment_sailimu_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "payment_sailimu_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_sailimu_logo.imageset/payment_sailimu_logo.png b/suixinkan/Assets.xcassets/Payment/payment_sailimu_logo.imageset/payment_sailimu_logo.png new file mode 100644 index 0000000..4ac47bb Binary files /dev/null and b/suixinkan/Assets.xcassets/Payment/payment_sailimu_logo.imageset/payment_sailimu_logo.png differ diff --git a/suixinkan/Features/Payment/Models/PaymentModels.swift b/suixinkan/Features/Payment/Models/PaymentModels.swift index 6fc2b1a..4f004c0 100644 --- a/suixinkan/Features/Payment/Models/PaymentModels.swift +++ b/suixinkan/Features/Payment/Models/PaymentModels.swift @@ -35,6 +35,14 @@ struct PayPageConfig: Codable, Sendable, Equatable { subtitle: "那拉提景区" ) + /// 赛里木湖景区随包默认文案,图片由展示层选择本地资源。 + static let sailimuDefault = PayPageConfig( + background: "", + logo: "", + title: "赛里木湖旅拍管理\n收费平台", + subtitle: "赛里木湖景区" + ) + /// 将服务端非空有效字段合并到当前可用配置。 func merging(_ response: PayPageConfigResponse) -> PayPageConfig { PayPageConfig( @@ -61,10 +69,22 @@ enum PayPageBrandingPolicy { /// 那拉提景区正式 ID。 static let nalatiScenicId = 128 - /// 判断当前景区是否启用品牌收款页。 + /// 赛里木湖景区正式 ID。 + static let sailimuScenicId = 125 + + /// 判断当前景区是否为那拉提。 static func isNalati(scenicId: Int) -> Bool { scenicId == nalatiScenicId } + + /// 返回支持品牌收款页的景区默认配置;其他景区继续使用普通页面。 + static func defaultConfig(scenicId: Int) -> PayPageConfig? { + switch scenicId { + case nalatiScenicId: return .nalatiDefault + case sailimuScenicId: return .sailimuDefault + default: return nil + } + } } /// 收款页当前账号信息的展示文本格式化规则。 diff --git a/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift b/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift index 26fec58..ba77499 100644 --- a/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift +++ b/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift @@ -42,9 +42,7 @@ final class PaymentCollectionDetailsViewModel { scenicName = appStore.session.currentScenicName staffId = appStore.session.userId isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice - if PayPageBrandingPolicy.isNalati(scenicId: scenicId) { - payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault - } + restorePayPageConfig() } var hasPayCode: Bool { @@ -60,8 +58,26 @@ final class PaymentCollectionDetailsViewModel { scenicName.isEmpty ? "暂无景区" : scenicName } - var usesNalatiBranding: Bool { - PayPageBrandingPolicy.isNalati(scenicId: scenicId) + /// 是否启用品牌收款页,包含那拉提与赛里木湖。 + 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 { @@ -80,14 +96,18 @@ final class PaymentCollectionDetailsViewModel { scenicName = appStore.session.currentScenicName staffId = appStore.session.userId isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice - if usesNalatiBranding { - payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault - } else { - payPageConfig = .nalatiDefault - } + 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 @@ -116,12 +136,14 @@ final class PaymentCollectionDetailsViewModel { func loadPayPageConfig(api: PaymentAPI) async { let requestedScenicId = appStore.session.currentScenicId - guard PayPageBrandingPolicy.isNalati(scenicId: requestedScenicId) else { return } + guard let defaults = PayPageBrandingPolicy.defaultConfig(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) + 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 diff --git a/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift index 69f5dc3..a8d93e9 100644 --- a/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift +++ b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift @@ -101,15 +101,16 @@ final class PaymentCollectionDetailsViewController: BaseViewController { } override func setupNavigationBar() { - title = viewModel.usesNalatiBranding ? nil : "收款详情" + title = viewModel.usesBranding ? nil : "收款详情" } override func setupUI() { - let usesBranding = viewModel.usesNalatiBranding + let usesBranding = viewModel.usesBranding view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground scrollView.showsVerticalScrollIndicator = !usesBranding scrollView.isScrollEnabled = true scrollView.alwaysBounceVertical = true + scrollView.contentInsetAdjustmentBehavior = .always contentStack.axis = .vertical contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md @@ -187,7 +188,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { configureBrandHeader() backgroundImageView.contentMode = .scaleAspectFill backgroundImageView.clipsToBounds = true - backgroundImageView.image = UIImage(named: "payment_nalati_background") + backgroundImageView.image = UIImage(named: viewModel.brandBackgroundAssetName) view.addSubview(backgroundImageView) } @@ -320,30 +321,24 @@ final class PaymentCollectionDetailsViewController: BaseViewController { } override func setupConstraints() { - if viewModel.usesNalatiBranding { + if viewModel.usesBranding { backgroundImageView.snp.makeConstraints { make in make.edges.equalToSuperview() } } scrollView.snp.makeConstraints { make in - 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) - } + make.edges.equalToSuperview() } contentContainerView.snp.makeConstraints { make in - make.edges.equalToSuperview() - make.width.equalTo(scrollView.snp.width) - if viewModel.usesNalatiBranding { + make.edges.equalTo(scrollView.contentLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) + if viewModel.usesBranding { make.height.greaterThanOrEqualTo(scrollView.snp.height) } } contentStack.snp.makeConstraints { make in make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) - if viewModel.usesNalatiBranding { + if viewModel.usesBranding { make.centerX.equalToSuperview() make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset) } else { @@ -403,7 +398,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { } override var preferredStatusBarStyle: UIStatusBarStyle { - viewModel.usesNalatiBranding ? .darkContent : .default + viewModel.usesBranding ? .darkContent : .default } private func loadData() async { @@ -426,7 +421,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { if viewModel.isPayCodeLoading { qrImageView.isHidden = true qrContainerView.isHidden = true - qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding + qrPlaceholderView.backgroundColor = viewModel.usesBranding ? .white : AppColor.inputBackground qrPlaceholderView.isHidden = false @@ -437,7 +432,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { } else if let image = viewModel.qrImage { qrImageView.image = image qrImageView.isHidden = false - qrContainerView.isHidden = !viewModel.usesNalatiBranding + qrContainerView.isHidden = !viewModel.usesBranding qrPlaceholderView.isHidden = true qrLoadingIndicator.stopAnimating() setActionRowAvailable(true) @@ -527,7 +522,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { brandHeaderStack.alignment = .center brandHeaderStack.spacing = brandScreenHeight <= 700 ? 6 : 8 - brandLogoImageView.image = UIImage(named: "payment_nalati_logo") + brandLogoImageView.image = UIImage(named: viewModel.brandLogoAssetName) brandLogoImageView.contentMode = .scaleAspectFit brandLogoImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) @@ -537,7 +532,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { ) brandTitleLabel.textColor = UIColor(hex: 0x102D54) brandTitleLabel.textAlignment = .center - brandTitleLabel.numberOfLines = 1 + brandTitleLabel.numberOfLines = viewModel.brandTitleNumberOfLines brandTitleLabel.adjustsFontSizeToFitWidth = true brandTitleLabel.minimumScaleFactor = 0.8 @@ -604,7 +599,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { @MainActor private func applyBrandConfigIfNeeded() { - guard viewModel.usesNalatiBranding else { return } + guard viewModel.usesBranding else { return } let config = viewModel.payPageConfig brandTitleLabel.text = config.title brandSubtitleLabel.text = config.subtitle @@ -615,13 +610,13 @@ final class PaymentCollectionDetailsViewController: BaseViewController { setBrandImage( backgroundImageView, urlString: config.background, - fallbackName: "payment_nalati_background", + fallbackName: viewModel.brandBackgroundAssetName, forceRefresh: forceRefresh ) setBrandImage( brandLogoImageView, urlString: config.logo, - fallbackName: "payment_nalati_logo", + fallbackName: viewModel.brandLogoAssetName, forceRefresh: forceRefresh ) appliedBrandConfig = config @@ -639,6 +634,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { guard let url = URL(string: trimmed), url.scheme?.lowercased() == "https", url.host != nil else { + imageView.kf.cancelDownloadTask() imageView.image = fallback return } @@ -655,7 +651,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { } private func applyBrandNavigationAppearanceIfNeeded() { - guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return } + guard viewModel.usesBranding, let navigationBar = navigationController?.navigationBar else { return } previousStandardAppearance = navigationBar.standardAppearance previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance previousCompactAppearance = navigationBar.compactAppearance @@ -677,7 +673,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { } private func restoreNavigationAppearanceIfNeeded() { - guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return } + guard viewModel.usesBranding, let navigationBar = navigationController?.navigationBar else { return } if let previousStandardAppearance { navigationBar.standardAppearance = previousStandardAppearance } diff --git a/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift index 1ee75c5..0ac2320 100644 --- a/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift +++ b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift @@ -4,6 +4,7 @@ // import XCTest +import UIKit @testable import suixinkan /// 收款详情 ViewModel 测试。 @@ -187,14 +188,14 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase { ) } - func testNonNalatiDoesNotRequestPayPageConfig() async { + func testUnbrandedScenicDoesNotRequestPayPageConfig() 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) + XCTAssertFalse(viewModel.usesBranding) XCTAssertEqual(session.requests.count, 0) } @@ -209,4 +210,96 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase { XCTAssertEqual(production.config(scenicId: 128), .nalatiDefault) XCTAssertNil(testing.config(scenicId: 128)) } + + func testSailimuRefreshPersistsOnlyItsOwnConfigAndReusesBrandUI() async throws { + let suite = "PaymentCollectionDetailsViewModelTests.Sailimu.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let cache = PayPageConfigCache(defaults: defaults, environment: .testing) + cache.save(.nalatiDefault, scenicId: 128) + let cached = PayPageConfig( + background: "https://cache.example.com/sailimu.png", + logo: "https://cache.example.com/sailimu-logo.png", + title: "缓存赛里木湖标题", + subtitle: "赛里木湖景区" + ) + cache.save(cached, scenicId: 125) + appStore.session.currentScenicId = 125 + let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore, payPageConfigCache: cache) + XCTAssertTrue(viewModel.usesBranding) + XCTAssertEqual(viewModel.payPageConfig, cached) + + let response = """ + {"code":100000,"msg":"success","data":{ + "background":"https://new.example.com/sailimu.png", + "logo":"", "title":"最新赛里木湖标题", "subtitle":"" + }} + """.data(using: .utf8)! + let session = MockURLSession(responses: [response]) + let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) + await viewModel.loadPayPageConfig(api: api) + + let url = try XCTUnwrap(session.requests.first?.url) + XCTAssertEqual(url.path, "/api/yf-handset-app/photog/pay-page-config") + XCTAssertEqual(URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "scenic_id" })?.value, "125") + XCTAssertEqual(viewModel.payPageConfig.title, "最新赛里木湖标题") + XCTAssertEqual(viewModel.payPageConfig.background, "https://new.example.com/sailimu.png") + XCTAssertEqual(viewModel.payPageConfig.logo, cached.logo) + XCTAssertEqual(viewModel.payPageConfig.subtitle, cached.subtitle) + XCTAssertEqual(viewModel.brandingRefreshVersion, 1) + XCTAssertEqual(cache.config(scenicId: 128), .nalatiDefault) + XCTAssertEqual(cache.config(scenicId: 125), viewModel.payPageConfig) + let reopened = PaymentCollectionDetailsViewModel(appStore: appStore, payPageConfigCache: cache) + XCTAssertEqual(reopened.payPageConfig, viewModel.payPageConfig) + + appStore.session.currentScenicId = 128 + viewModel.refreshLocalState() + XCTAssertEqual(viewModel.payPageConfig, .nalatiDefault) + appStore.session.currentScenicId = 125 + viewModel.refreshLocalState() + XCTAssertEqual(viewModel.payPageConfig, reopened.payPageConfig) + } + + func testSailimuRequestFailurePreservesDefaultsOrItsOwnCache() async { + let suite = "PaymentCollectionDetailsViewModelTests.SailimuFailure.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let cache = PayPageConfigCache(defaults: defaults, environment: .testing) + cache.save(.nalatiDefault, scenicId: 128) + appStore.session.currentScenicId = 125 + let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore, payPageConfigCache: cache) + let session = MockURLSession(responses: []) + let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) + XCTAssertEqual(viewModel.payPageConfig, .sailimuDefault) + await viewModel.loadPayPageConfig(api: api) + XCTAssertEqual(viewModel.payPageConfig, .sailimuDefault) + XCTAssertNil(cache.config(scenicId: 125)) + + let cached = PayPageConfig(background: "https://cache.example.com/lake.png", + logo: "", title: "缓存标题", subtitle: "赛里木湖") + cache.save(cached, scenicId: 125) + viewModel.refreshLocalState() + await viewModel.loadPayPageConfig(api: api) + XCTAssertEqual(viewModel.payPageConfig, cached) + XCTAssertEqual(cache.config(scenicId: 125), cached) + XCTAssertEqual(cache.config(scenicId: 128), .nalatiDefault) + XCTAssertEqual(viewModel.brandingRefreshVersion, 0) + } + + + func testBrandPresentationUsesMatchingBundledAssetsAndTitleLines() { + let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) + for (scenicId, prefix, lines) in [(125, "payment_sailimu", 2), (128, "payment_nalati", 1)] { + appStore.session.currentScenicId = scenicId + viewModel.refreshLocalState() + XCTAssertTrue(viewModel.usesBranding) + XCTAssertEqual(viewModel.brandBackgroundAssetName, "\(prefix)_background") + XCTAssertEqual(viewModel.brandLogoAssetName, "\(prefix)_logo") + XCTAssertEqual(viewModel.brandTitleNumberOfLines, lines) + XCTAssertNotNil(UIImage(named: viewModel.brandBackgroundAssetName)) + XCTAssertNotNil(UIImage(named: viewModel.brandLogoAssetName)) + } + } + } diff --git a/suixinkanTests/PaymentModelsTests.swift b/suixinkanTests/PaymentModelsTests.swift index 60f54e7..b34a7bb 100644 --- a/suixinkanTests/PaymentModelsTests.swift +++ b/suixinkanTests/PaymentModelsTests.swift @@ -55,6 +55,16 @@ final class PaymentModelsTests: XCTestCase { XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0)) } + func testBrandDefaultsAreScopedToSupportedScenicIds() { + XCTAssertEqual(PayPageBrandingPolicy.defaultConfig(scenicId: 128), .nalatiDefault) + XCTAssertEqual(PayPageBrandingPolicy.defaultConfig(scenicId: 125), .sailimuDefault) + XCTAssertEqual(PayPageConfig.sailimuDefault.title, "赛里木湖旅拍管理\n收费平台") + XCTAssertEqual(PayPageConfig.sailimuDefault.subtitle, "赛里木湖景区") + XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 125)) + XCTAssertNil(PayPageBrandingPolicy.defaultConfig(scenicId: 127)) + XCTAssertNil(PayPageBrandingPolicy.defaultConfig(scenicId: 0)) + } + func testPaymentAccountDisplayFormatterUsesRealNameAndAllUniqueStores() { XCTAssertEqual( PaymentAccountDisplayFormatter.photographerName(realName: " 摄影师真实姓名 "),