Compare commits

..

2 Commits

Author SHA1 Message Date
7ccbc5568d fix: 优化旅拍相册入口与二维码提示布局 2026-07-23 15:40:51 +08:00
787a166263 feat: 品牌化那拉提景区收款页 2026-07-23 15:39:34 +08:00
15 changed files with 881 additions and 36 deletions

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -9,10 +9,40 @@ import Foundation
struct RolePermissionResponse: Codable, Equatable, Sendable { struct RolePermissionResponse: Codable, Equatable, Sendable {
let role: RoleInfo let role: RoleInfo
let scenic: [ScenicInfo] 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.role = role
self.scenic = scenic 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
} }
} }

View File

@ -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 /// 7
func collectionRecord(scenicId: Int) async throws -> RepaymentCollectionRecordResponse { func collectionRecord(scenicId: Int) async throws -> RepaymentCollectionRecordResponse {
try await client.send( try await client.send(

View File

@ -5,6 +5,83 @@
import Foundation 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 /// URL
struct PayCodeResponse: Decodable, Sendable, Equatable { struct PayCodeResponse: Decodable, Sendable, Equatable {
let staticPayURL: String let staticPayURL: String
@ -101,3 +178,10 @@ struct PaymentMerchantInfo: Sendable, Equatable {
company: "扬州元智享网络科技有限公司" company: "扬州元智享网络科技有限公司"
) )
} }
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}

View 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)"
}
}

View File

@ -17,6 +17,9 @@ final class PaymentCollectionDetailsViewModel {
private(set) var showAmountDialog = false private(set) var showAmountDialog = false
private(set) var amount = "" private(set) var amount = ""
private(set) var remark = "" private(set) var remark = ""
private(set) var scenicId = 0
private(set) var payPageConfig = PayPageConfig.nalatiDefault
private(set) var brandingRefreshVersion = 0
let merchantInfo = PaymentMerchantInfo.defaultInfo let merchantInfo = PaymentMerchantInfo.defaultInfo
@ -26,9 +29,21 @@ final class PaymentCollectionDetailsViewModel {
private var staticPayURL = "" private var staticPayURL = ""
private var dynamicPayURL = "" private var dynamicPayURL = ""
private let appStore: AppStore private let appStore: AppStore
private let payPageConfigCache: PayPageConfigCaching
init(appStore: AppStore = .shared) { init(
appStore: AppStore = .shared,
payPageConfigCache: PayPageConfigCaching = PayPageConfigCache()
) {
self.appStore = appStore 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 { var hasPayCode: Bool {
@ -39,10 +54,32 @@ final class PaymentCollectionDetailsViewModel {
scenicName.isEmpty ? "暂无景区" : scenicName 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() { func refreshLocalState() {
scenicId = appStore.session.currentScenicId
scenicName = appStore.session.currentScenicName scenicName = appStore.session.currentScenicName
staffId = appStore.session.userId staffId = appStore.session.userId
isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice
if usesNalatiBranding {
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
} else {
payPageConfig = .nalatiDefault
}
notifyStateChange() notifyStateChange()
} }
@ -56,7 +93,7 @@ final class PaymentCollectionDetailsViewModel {
notifyStateChange() notifyStateChange()
} }
let scenicId = appStore.session.currentScenicId let scenicId = self.scenicId
guard scenicId > 0 else { guard scenicId > 0 else {
clearPayCode() clearPayCode()
return 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 { 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() { func showSetAmountDialog() {

View File

@ -3,6 +3,7 @@
// suixinkan // suixinkan
// //
import Kingfisher
import SnapKit import SnapKit
import UIKit import UIKit
@ -13,10 +14,18 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let paymentAPI = NetworkServices.shared.paymentAPI private let paymentAPI = NetworkServices.shared.paymentAPI
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let contentContainerView = UIView()
private let contentStack = UIStackView() 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 qrCardView = UIView()
private let scenicNameLabel = UILabel() private let scenicNameLabel = UILabel()
private let qrContainerView = UIView()
private let qrImageView = UIImageView() private let qrImageView = UIImageView()
private let qrPlaceholderView = UIView() private let qrPlaceholderView = UIView()
private let qrErrorLabel = UILabel() private let qrErrorLabel = UILabel()
@ -29,26 +38,75 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let scenicRow = PaymentInfoRowView(title: "景区名称") private let scenicRow = PaymentInfoRowView(title: "景区名称")
private let merchantRow = PaymentInfoRowView(title: "收款方") private let merchantRow = PaymentInfoRowView(title: "收款方")
private let staffRow = PaymentInfoRowView(title: "员工 ID") private let staffRow = PaymentInfoRowView(title: "员工 ID")
private let photographerRow = PaymentInfoRowView(title: "摄影师名称")
private let storeRow = PaymentInfoRowView(title: "店铺名称")
private let recordRow = PaymentNavigationRowView(title: "收款记录") private let recordRow = PaymentNavigationRowView(title: "收款记录")
private let settingsCardView = UIView()
private let voiceCardView = UIView() private let voiceCardView = UIView()
private let voiceTitleLabel = UILabel() private let voiceTitleLabel = UILabel()
private let voiceSwitch = UISwitch() private let voiceSwitch = UISwitch()
private var amountDialog: PaymentSetAmountDialogView? 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() { override func setupNavigationBar() {
title = "收款详情" title = viewModel.usesNalatiBranding ? nil : "收款详情"
} }
override func setupUI() { 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.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 qrCardView.layer.cornerRadius = AppRadius.lg
scenicNameLabel.font = .systemFont(ofSize: 16, weight: .medium) scenicNameLabel.font = .systemFont(ofSize: 16, weight: .medium)
@ -57,9 +115,16 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrImageView.contentMode = .scaleAspectFit qrImageView.contentMode = .scaleAspectFit
qrImageView.isHidden = true 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.backgroundColor = AppColor.inputBackground
qrPlaceholderView.layer.cornerRadius = 24 qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
qrErrorLabel.text = "未选景区或网络问题" qrErrorLabel.text = "未选景区或网络问题"
@ -73,19 +138,29 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
actionRow.axis = .horizontal actionRow.axis = .horizontal
actionRow.alignment = .center 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(setAmountButton, title: "设置金额")
configureLinkButton(saveQRButton, title: "保存二维码") configureLinkButton(saveQRButton, title: "保存二维码")
infoCardView.backgroundColor = .white configureCard(infoCardView, branded: usesBranding)
infoCardView.layer.cornerRadius = AppRadius.lg
recordRow.backgroundColor = .white recordRow.backgroundColor = usesBranding ? .clear : .white
recordRow.layer.cornerRadius = AppRadius.lg recordRow.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg
voiceCardView.backgroundColor = .white voiceCardView.backgroundColor = usesBranding ? .clear : .white
voiceCardView.layer.cornerRadius = AppRadius.lg voiceCardView.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg
voiceTitleLabel.text = "收款到账语音提醒" voiceTitleLabel.text = "收款到账语音提醒"
voiceTitleLabel.font = .systemFont(ofSize: 14) voiceTitleLabel.font = .systemFont(ofSize: 14)
@ -93,49 +168,112 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
voiceSwitch.onTintColor = AppColor.primary voiceSwitch.onTintColor = AppColor.primary
view.addSubview(scrollView) if usesBranding {
scrollView.addSubview(contentStack) 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(qrCardView)
contentStack.addArrangedSubview(infoCardView) contentStack.addArrangedSubview(infoCardView)
if usesBranding {
configureCard(settingsCardView, branded: true)
contentStack.addArrangedSubview(settingsCardView)
} else {
contentStack.addArrangedSubview(recordRow) contentStack.addArrangedSubview(recordRow)
contentStack.addArrangedSubview(voiceCardView) contentStack.addArrangedSubview(voiceCardView)
}
let qrDisplayView = usesBranding ? qrContainerView : qrImageView
let qrContentStack = UIStackView(arrangedSubviews: [ let qrContentStack = UIStackView(arrangedSubviews: [
scenicNameLabel, scenicNameLabel,
qrImageView, qrDisplayView,
qrPlaceholderView, qrPlaceholderView,
actionRow, actionRow,
]) ])
qrContentStack.axis = .vertical qrContentStack.axis = .vertical
qrContentStack.alignment = .center qrContentStack.alignment = .center
qrContentStack.spacing = AppSpacing.lg qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
qrCardView.addSubview(qrContentStack) qrCardView.addSubview(qrContentStack)
qrPlaceholderView.addSubview(qrErrorLabel) qrPlaceholderView.addSubview(qrErrorLabel)
qrPlaceholderView.addSubview(refreshButton) qrPlaceholderView.addSubview(refreshButton)
actionRow.addArrangedSubview(setAmountButton) 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) 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 infoStack.axis = .vertical
infoCardView.addSubview(infoStack) infoCardView.addSubview(infoStack)
voiceCardView.addSubview(voiceTitleLabel) voiceCardView.addSubview(voiceTitleLabel)
voiceCardView.addSubview(voiceSwitch) voiceCardView.addSubview(voiceSwitch)
qrContentStack.snp.makeConstraints { make in if usesBranding {
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 32, left: 16, bottom: 32, right: 16)) 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 scenicNameLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview() make.leading.trailing.equalToSuperview()
} }
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 qrImageView.snp.makeConstraints { make in
make.width.height.equalTo(182) make.width.height.equalTo(182)
} }
}
qrPlaceholderView.snp.makeConstraints { make in qrPlaceholderView.snp.makeConstraints { make in
make.width.height.equalTo(182) make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
} }
qrErrorLabel.snp.makeConstraints { make in qrErrorLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(56) make.top.equalToSuperview().offset(56)
@ -162,12 +300,36 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
} }
override func setupConstraints() { override func setupConstraints() {
if viewModel.usesNalatiBranding {
backgroundImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
scrollView.snp.makeConstraints { make in 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.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 contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) 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() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
applyViewModel()
Task { await loadData() } 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 { private func loadData() async {
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
showLoading() showLoading()
await viewModel.loadPayCode(api: paymentAPI) await viewModel.loadPayCode(api: paymentAPI)
hideLoading() hideLoading()
await pageConfig
} }
@MainActor @MainActor
@ -203,15 +382,20 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
scenicRow.setValue(viewModel.displayScenicName) scenicRow.setValue(viewModel.displayScenicName)
merchantRow.setValue(viewModel.merchantInfo.company) merchantRow.setValue(viewModel.merchantInfo.company)
staffRow.setValue(viewModel.staffId.isEmpty ? "-" : viewModel.staffId) staffRow.setValue(viewModel.staffId.isEmpty ? "-" : viewModel.staffId)
photographerRow.setValue(viewModel.displayPhotographerName)
storeRow.setValue(viewModel.displayStoreNames)
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
applyBrandConfigIfNeeded()
if let image = viewModel.qrImage { if let image = viewModel.qrImage {
qrImageView.image = image qrImageView.image = image
qrImageView.isHidden = false qrImageView.isHidden = false
qrContainerView.isHidden = !viewModel.usesNalatiBranding
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
actionRow.isHidden = false actionRow.isHidden = false
} else { } else {
qrImageView.isHidden = true qrImageView.isHidden = true
qrContainerView.isHidden = true
qrPlaceholderView.isHidden = false qrPlaceholderView.isHidden = false
actionRow.isHidden = true actionRow.isHidden = true
} }
@ -254,6 +438,181 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
button.titleLabel?.font = .systemFont(ofSize: 14) 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() { @objc private func setAmountTapped() {
viewModel.showSetAmountDialog() viewModel.showSetAmountDialog()
} }
@ -310,7 +669,7 @@ private final class PaymentInfoRowView: UIView {
valueLabel.font = .systemFont(ofSize: 14) valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary valueLabel.textColor = AppColor.textPrimary
valueLabel.textAlignment = .right valueLabel.textAlignment = .right
valueLabel.numberOfLines = 2 valueLabel.numberOfLines = 0
divider.backgroundColor = UIColor(hex: 0xE5E7EB) divider.backgroundColor = UIColor(hex: 0xE5E7EB)

View File

@ -20,7 +20,9 @@ final class TravelAlbumCodeDialogViewController: UIViewController {
private let albumNameLabel = UILabel() private let albumNameLabel = UILabel()
private let qrImageView = UIImageView() private let qrImageView = UIImageView()
private let loadingIndicator = UIActivityIndicatorView(style: .medium) private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let hintLabel = UILabel() private let hintLabel = TravelAlbumCodeHintLabel(
contentInsets: UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
)
private let downloadButton = UIButton(type: .system) private let downloadButton = UIButton(type: .system)
init(state: TravelAlbumEntryViewModel.AlbumCodeState) { init(state: TravelAlbumEntryViewModel.AlbumCodeState) {
@ -117,8 +119,8 @@ final class TravelAlbumCodeDialogViewController: UIViewController {
hintLabel.snp.makeConstraints { make in hintLabel.snp.makeConstraints { make in
make.top.equalTo(qrImageView.snp.bottom).offset(18) make.top.equalTo(qrImageView.snp.bottom).offset(18)
make.centerX.equalToSuperview() make.centerX.equalToSuperview()
make.height.equalTo(34)
make.leading.greaterThanOrEqualToSuperview().offset(20) make.leading.greaterThanOrEqualToSuperview().offset(20)
make.trailing.lessThanOrEqualToSuperview().offset(-20)
} }
downloadButton.snp.makeConstraints { make in downloadButton.snp.makeConstraints { make in
make.top.equalTo(hintLabel.snp.bottom).offset(20) make.top.equalTo(hintLabel.snp.bottom).offset(20)
@ -160,3 +162,30 @@ final class TravelAlbumCodeDialogViewController: UIViewController {
onDownload?() onDownload?()
} }
} }
/// Android
private final class TravelAlbumCodeHintLabel: UILabel {
private let contentInsets: UIEdgeInsets
init(contentInsets: UIEdgeInsets) {
self.contentInsets = contentInsets
super.init(frame: .zero)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: contentInsets))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(
width: size.width + contentInsets.left + contentInsets.right,
height: size.height + contentInsets.top + contentInsets.bottom
)
}
}

View File

@ -184,7 +184,8 @@ extension TravelAlbumEntryViewController: UITableViewDelegate {
/// ///
private final class TravelAlbumHeroCard: UIControl { private final class TravelAlbumHeroCard: UIControl {
private let gradientLayer = CAGradientLayer() private let gradientLayer = CAGradientLayer()
private let plusView = UIImageView(image: UIImage(systemName: "plus")) private let plusView = UIView()
private let plusImageView = UIImageView(image: UIImage(systemName: "plus"))
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let subtitleLabel = UILabel() private let subtitleLabel = UILabel()
private let arrowLabel = UILabel() private let arrowLabel = UILabel()
@ -202,10 +203,11 @@ private final class TravelAlbumHeroCard: UIControl {
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
layer.insertSublayer(gradientLayer, at: 0) layer.insertSublayer(gradientLayer, at: 0)
plusView.tintColor = AppColor.primary
plusView.contentMode = .scaleAspectFit
plusView.backgroundColor = .white plusView.backgroundColor = .white
plusView.layer.cornerRadius = 17 plusView.layer.cornerRadius = 17
plusView.clipsToBounds = true
plusImageView.tintColor = AppColor.primary
plusImageView.contentMode = .scaleAspectFit
titleLabel.text = "新建相册任务" titleLabel.text = "新建相册任务"
titleLabel.font = .systemFont(ofSize: 19, weight: .medium) titleLabel.font = .systemFont(ofSize: 19, weight: .medium)
@ -218,6 +220,7 @@ private final class TravelAlbumHeroCard: UIControl {
arrowLabel.font = .systemFont(ofSize: 36, weight: .light) arrowLabel.font = .systemFont(ofSize: 36, weight: .light)
addSubview(plusView) addSubview(plusView)
plusView.addSubview(plusImageView)
addSubview(titleLabel) addSubview(titleLabel)
addSubview(subtitleLabel) addSubview(subtitleLabel)
addSubview(arrowLabel) addSubview(arrowLabel)
@ -226,6 +229,10 @@ private final class TravelAlbumHeroCard: UIControl {
make.centerY.equalToSuperview() make.centerY.equalToSuperview()
make.size.equalTo(34) make.size.equalTo(34)
} }
plusImageView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(26)
}
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(30) make.top.equalToSuperview().offset(30)
make.leading.equalTo(plusView.snp.trailing).offset(16) make.leading.equalTo(plusView.snp.trailing).offset(16)

View File

@ -79,6 +79,24 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.isVoiceBroadcastOpen) 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 { func testLoadPayCodeClearsQRWhenScenicMissing() async {
appStore.session.currentScenicId = 0 appStore.session.currentScenicId = 0
let session = MockURLSession(responses: []) let session = MockURLSession(responses: [])
@ -90,4 +108,74 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.hasPayCode) XCTAssertFalse(viewModel.hasPayCode)
XCTAssertEqual(session.requests.count, 0) 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))
}
} }

View File

@ -8,6 +8,88 @@ import XCTest
/// ///
final class PaymentModelsTests: XCTestCase { 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 { func testPayCodeResponseDecodesSnakeCaseFields() throws {
let json = """ let json = """
{ {