feat: 品牌化那拉提景区收款页
This commit is contained in:
6
suixinkan/Assets.xcassets/Payment/Contents.json
Normal file
6
suixinkan/Assets.xcassets/Payment/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
21
suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/Contents.json
vendored
Normal file
21
suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/payment_nalati_background.png
vendored
Normal file
BIN
suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/payment_nalati_background.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
21
suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/Contents.json
vendored
Normal file
21
suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/payment_nalati_logo.png
vendored
Normal file
BIN
suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/payment_nalati_logo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
51
suixinkan/Features/Payment/Services/PayPageConfigCache.swift
Normal file
51
suixinkan/Features/Payment/Services/PayPageConfigCache.swift
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
//
|
||||||
|
// PayPageConfigCache.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 收款页品牌配置缓存协议,供 ViewModel 注入持久化实现或测试替身。
|
||||||
|
protocol PayPageConfigCaching {
|
||||||
|
/// 读取指定景区的品牌配置。
|
||||||
|
func config(scenicId: Int) -> PayPageConfig?
|
||||||
|
|
||||||
|
/// 保存指定景区的完整品牌配置。
|
||||||
|
func save(_ config: PayPageConfig, scenicId: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 UserDefaults 按接口环境与景区隔离的收款页品牌配置缓存。
|
||||||
|
final class PayPageConfigCache: PayPageConfigCaching {
|
||||||
|
private static let version = "v1"
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let environmentNamespace: String
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
/// 使用指定持久化容器和 API 环境创建缓存。
|
||||||
|
init(
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
environment: APIEnvironment = .current
|
||||||
|
) {
|
||||||
|
self.defaults = defaults
|
||||||
|
environmentNamespace = environment.baseURL.host ?? environment.baseURL.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
|
func config(scenicId: Int) -> PayPageConfig? {
|
||||||
|
guard scenicId > 0,
|
||||||
|
let data = defaults.data(forKey: key(scenicId: scenicId)) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try? decoder.decode(PayPageConfig.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ config: PayPageConfig, scenicId: Int) {
|
||||||
|
guard scenicId > 0, let data = try? encoder.encode(config) else { return }
|
||||||
|
defaults.set(data, forKey: key(scenicId: scenicId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func key(scenicId: Int) -> String {
|
||||||
|
"payment.pay_page_config.\(Self.version).\(environmentNamespace).\(scenicId)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,9 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
private(set) var showAmountDialog = false
|
private(set) var 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() {
|
||||||
|
|||||||
@ -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)
|
||||||
contentStack.addArrangedSubview(recordRow)
|
if usesBranding {
|
||||||
contentStack.addArrangedSubview(voiceCardView)
|
configureCard(settingsCardView, branded: true)
|
||||||
|
contentStack.addArrangedSubview(settingsCardView)
|
||||||
|
} else {
|
||||||
|
contentStack.addArrangedSubview(recordRow)
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
qrImageView.snp.makeConstraints { make in
|
if usesBranding {
|
||||||
make.width.height.equalTo(182)
|
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
|
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
|
||||||
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
|
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)
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = """
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user