4 Commits
main ... 1.2.1

Author SHA1 Message Date
1cdce616e4 chore: 更新 iOS 版本号至 1.2.1 2026-07-31 15:25:59 +08:00
212607fc82 fix: 增加登录失效确认弹窗 2026-07-31 15:25:12 +08:00
0cf0a41a60 fix: 修正账号姓名展示与资料缓存 2026-07-31 15:19:22 +08:00
f863004c8d fix: 优化收款码加载态,避免首屏错误占位闪现
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 14:07:05 +08:00
15 changed files with 375 additions and 55 deletions

View File

@ -436,7 +436,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@ -460,7 +460,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@ -502,7 +502,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@ -526,7 +526,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@ -741,7 +741,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -761,7 +761,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",

View File

@ -70,10 +70,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId) AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .scenicUser AppStore.shared.session.accountType = .scenicUser
AppStore.shared.session.accountDisplayName = firstNonEmpty( AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.scenicName, user.nickname, user.realName, user.username user.scenicName, user.nickname, user.realName
) )
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.username, user.realName) AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.realName)
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.nickname, user.username) AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.session.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId
AppStore.shared.session.currentScenicName = user.scenicName AppStore.shared.session.currentScenicName = user.scenicName
@ -90,10 +90,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId) AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .storeUser AppStore.shared.session.accountType = .storeUser
AppStore.shared.session.accountDisplayName = firstNonEmpty( AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.storeName, user.scenicName, user.realName, user.userName, user.username user.storeName, user.scenicName, user.realName
) )
AppStore.shared.session.userName = firstNonEmpty(user.userName, user.username, user.realName) AppStore.shared.session.userName = user.realName
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.userName, user.username) AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.session.avatar = user.avatar AppStore.shared.session.avatar = user.avatar
AppStore.shared.session.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId

View File

@ -196,7 +196,6 @@ struct V9ScenicUser: Decodable, Equatable {
let userId: Int let userId: Int
let scenicUserId: Int let scenicUserId: Int
let ssUserId: Int let ssUserId: Int
let username: String
let realName: String let realName: String
let nickname: String let nickname: String
let phone: String let phone: String
@ -212,7 +211,7 @@ struct V9ScenicUser: Decodable, Equatable {
} }
var displayName: String { var displayName: String {
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username scenicName.nonEmpty ?? nickname.nonEmpty ?? realName
} }
func toAccountSwitchAccount() -> AccountSwitchAccount { func toAccountSwitchAccount() -> AccountSwitchAccount {
@ -238,8 +237,6 @@ struct V9ScenicUser: Decodable, Equatable {
case userId = "user_id" case userId = "user_id"
case scenicUserId = "scenic_user_id" case scenicUserId = "scenic_user_id"
case ssUserId = "ss_user_id" case ssUserId = "ss_user_id"
case username = "user_name"
case legacyUsername = "username"
case realName = "real_name" case realName = "real_name"
case nickname case nickname
case phone case phone
@ -258,9 +255,6 @@ struct V9ScenicUser: Decodable, Equatable {
userId = try container.decodeLossyInt(forKey: .userId) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0 scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0 ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
let preferredUsername = try container.decodeLossyString(forKey: .username)
let legacyUsername = try container.decodeLossyString(forKey: .legacyUsername)
username = preferredUsername.nonEmpty ?? legacyUsername
realName = try container.decodeLossyString(forKey: .realName) realName = try container.decodeLossyString(forKey: .realName)
nickname = try container.decodeLossyString(forKey: .nickname) nickname = try container.decodeLossyString(forKey: .nickname)
phone = try container.decodeLossyString(forKey: .phone) phone = try container.decodeLossyString(forKey: .phone)
@ -281,8 +275,6 @@ struct V9StoreUser: Decodable, Equatable {
let id: Int let id: Int
let userId: Int let userId: Int
let storeUserId: Int let storeUserId: Int
let username: String
let userName: String
let realName: String let realName: String
let phone: String let phone: String
let avatar: String let avatar: String
@ -300,14 +292,14 @@ struct V9StoreUser: Decodable, Equatable {
} }
var displayName: String { var displayName: String {
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username storeName.nonEmpty ?? scenicName.nonEmpty ?? realName
} }
func toAccountSwitchAccount() -> AccountSwitchAccount { func toAccountSwitchAccount() -> AccountSwitchAccount {
AccountSwitchAccount( AccountSwitchAccount(
accountType: accountType.nonEmpty ?? Self.accountTypeValue, accountType: accountType.nonEmpty ?? Self.accountTypeValue,
businessUserId: businessUserId, businessUserId: businessUserId,
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号", title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? "门店账号",
subtitle: joinAccountSubtitle(scenicName, roleName), subtitle: joinAccountSubtitle(scenicName, roleName),
phone: phone, phone: phone,
realName: realName, realName: realName,
@ -325,8 +317,6 @@ struct V9StoreUser: Decodable, Equatable {
case id case id
case userId = "user_id" case userId = "user_id"
case storeUserId = "store_user_id" case storeUserId = "store_user_id"
case username
case userName = "user_name"
case realName = "real_name" case realName = "real_name"
case phone case phone
case avatar case avatar
@ -346,8 +336,6 @@ struct V9StoreUser: Decodable, Equatable {
id = try container.decodeLossyInt(forKey: .id) ?? 0 id = try container.decodeLossyInt(forKey: .id) ?? 0
userId = try container.decodeLossyInt(forKey: .userId) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0 storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0
username = try container.decodeLossyString(forKey: .username)
userName = try container.decodeLossyString(forKey: .userName)
realName = try container.decodeLossyString(forKey: .realName) realName = try container.decodeLossyString(forKey: .realName)
phone = try container.decodeLossyString(forKey: .phone) phone = try container.decodeLossyString(forKey: .phone)
avatar = try container.decodeLossyString(forKey: .avatar) avatar = try container.decodeLossyString(forKey: .avatar)

View File

@ -69,9 +69,9 @@ enum PayPageBrandingPolicy {
/// ///
enum PaymentAccountDisplayFormatter { enum PaymentAccountDisplayFormatter {
/// 使 ///
static func photographerName(userName: String, realName: String) -> String { static func photographerName(realName: String) -> String {
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-" realName.trimmedNonEmpty ?? "-"
} }
/// ///

View File

@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewModel {
private var staticPayURL = "" private var staticPayURL = ""
private var dynamicPayURL = "" private var dynamicPayURL = ""
private var hasCompletedPayCodeRequest = false
private let appStore: AppStore private let appStore: AppStore
private let payPageConfigCache: PayPageConfigCaching private let payPageConfigCache: PayPageConfigCaching
@ -50,6 +51,11 @@ final class PaymentCollectionDetailsViewModel {
qrImage != nil && !staticPayURL.isEmpty qrImage != nil && !staticPayURL.isEmpty
} }
///
var isPayCodeLoading: Bool {
loading || !hasCompletedPayCodeRequest
}
var displayScenicName: String { var displayScenicName: String {
scenicName.isEmpty ? "暂无景区" : scenicName scenicName.isEmpty ? "暂无景区" : scenicName
} }
@ -60,7 +66,6 @@ final class PaymentCollectionDetailsViewModel {
var displayPhotographerName: String { var displayPhotographerName: String {
PaymentAccountDisplayFormatter.photographerName( PaymentAccountDisplayFormatter.photographerName(
userName: appStore.session.userName,
realName: appStore.session.realName realName: appStore.session.realName
) )
} }
@ -85,11 +90,11 @@ final class PaymentCollectionDetailsViewModel {
func loadPayCode(api: PaymentAPI) async { func loadPayCode(api: PaymentAPI) async {
guard !loading else { return } guard !loading else { return }
refreshLocalState()
loading = true loading = true
notifyStateChange() refreshLocalState()
defer { defer {
loading = false loading = false
hasCompletedPayCodeRequest = true
notifyStateChange() notifyStateChange()
} }

View File

@ -17,11 +17,11 @@ final class ProfileViewModel {
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var displayNickname: String { var displayNickname: String {
nonEmpty(userInfo?.nickname) ?? "未设置昵称" nonEmpty(userInfo?.nickname) ?? AppStore.shared.session.userName.nonEmpty ?? "未设置昵称"
} }
var displayRealName: String { var displayRealName: String {
nonEmpty(userInfo?.realName) ?? "--" nonEmpty(userInfo?.realName) ?? AppStore.shared.session.realName.nonEmpty ?? "--"
} }
var displayPhone: String { var displayPhone: String {
@ -37,6 +37,17 @@ final class ProfileViewModel {
return uid.isEmpty ? "--" : uid return uid.isEmpty ? "--" : uid
} }
///
var hasCachedBasicInfo: Bool {
[
AppStore.shared.session.userName,
AppStore.shared.session.realName,
AppStore.shared.session.phone,
AppStore.shared.session.avatar,
AppStore.shared.session.userId,
].contains { nonEmpty($0) != nil }
}
var accountDisplayName: String { var accountDisplayName: String {
let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name return name.isEmpty ? "--" : name
@ -112,6 +123,7 @@ final class ProfileViewModel {
if !info.roleName.isEmpty { if !info.roleName.isEmpty {
AppStore.shared.session.roleName = info.roleName AppStore.shared.session.roleName = info.roleName
} }
notifyStateChange()
if showPhotographerFields { if showPhotographerFields {
async let realName = api.realNameInfo() async let realName = api.realNameInfo()

View File

@ -8,6 +8,7 @@ import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate { class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow? var window: UIWindow?
private var sessionExpiredDialog: SessionExpiredDialogViewController?
func scene( func scene(
_ scene: UIScene, _ scene: UIScene,
@ -86,18 +87,36 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
@objc private func handleSessionDidExpire() { @objc private func handleSessionDidExpire() {
guard AppStore.shared.session.isLoggedIn else { return }
guard sessionExpiredDialog == nil else { return }
GlobalLoadingManager.shared.hideAll()
let dialog = SessionExpiredDialogViewController { [weak self] in
self?.transitionToLogin()
}
sessionExpiredDialog = dialog
guard let presenter = topViewController(from: window?.rootViewController) else {
sessionExpiredDialog = nil
return
}
presenter.present(dialog, animated: true)
}
private func transitionToLogin() {
sessionExpiredDialog = nil
PushNotificationManager.shared.handleLogout() PushNotificationManager.shared.handleLogout()
AppStore.shared.logout() AppStore.shared.logout()
AppRouter.setRoot(.login, on: window) AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogout() { @objc private func handleUserDidLogout() {
PushNotificationManager.shared.handleLogout() transitionToLogin()
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogin() { @objc private func handleUserDidLogin() {
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
AppRouter.setRoot(.mainTab, on: window) AppRouter.setRoot(.mainTab, on: window)
DispatchQueue.main.async { DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted() PushNotificationManager.shared.handleLoginCompleted()
@ -108,4 +127,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@objc private func handleAccountDidSwitch() { @objc private func handleAccountDidSwitch() {
PushNotificationManager.shared.handleAccountSwitched() PushNotificationManager.shared.handleAccountSwitched()
} }
private func topViewController(from viewController: UIViewController?) -> UIViewController? {
guard let viewController else { return nil }
if let presented = viewController.presentedViewController {
return topViewController(from: presented)
}
if let navigationController = viewController as? UINavigationController {
return topViewController(from: navigationController.visibleViewController)
}
if let tabBarController = viewController as? UITabBarController {
return topViewController(from: tabBarController.selectedViewController)
}
return viewController
}
} }

View File

@ -0,0 +1,103 @@
//
// SessionExpiredDialogViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 退
@MainActor
final class SessionExpiredDialogViewController: UIViewController {
private let onConfirm: () -> Void
private let confirmButton = AppButton(title: "确认并退出登录")
private var didConfirm = false
///
init(onConfirm: @escaping () -> Void) {
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
isModalInPresentation = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
view.backgroundColor = UIColor.black.withAlphaComponent(0.32)
view.accessibilityViewIsModal = true
view.accessibilityIdentifier = "sessionExpired.dialog"
let cardView = UIView()
cardView.backgroundColor = AppColor.cardBackground
cardView.layer.cornerRadius = AppRadius.lg
cardView.clipsToBounds = true
let warningImageView = UIImageView(
image: UIImage(systemName: "exclamationmark.circle.fill")
)
warningImageView.tintColor = UIColor(hex: 0xF05A5A)
warningImageView.contentMode = .scaleAspectFit
warningImageView.isAccessibilityElement = true
warningImageView.accessibilityLabel = "登录状态异常"
let titleLabel = UILabel()
titleLabel.text = "登录提醒"
titleLabel.textColor = AppColor.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textAlignment = .center
let messageLabel = UILabel()
messageLabel.text = "当前用户已在其他设备登录,\n请重新登录"
messageLabel.textColor = UIColor(hex: 0x4B5563)
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
confirmButton.accessibilityIdentifier = "sessionExpired.confirmButton"
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let contentStack = UIStackView(arrangedSubviews: [
warningImageView,
titleLabel,
messageLabel,
confirmButton,
])
contentStack.axis = .vertical
contentStack.alignment = .fill
contentStack.spacing = 16
contentStack.setCustomSpacing(4, after: titleLabel)
contentStack.setCustomSpacing(20, after: messageLabel)
view.addSubview(cardView)
cardView.addSubview(contentStack)
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(24)
}
warningImageView.snp.makeConstraints { make in
make.height.equalTo(64)
}
}
@objc private func confirmTapped() {
guard !didConfirm else { return }
didConfirm = true
confirmButton.isEnabled = false
onConfirm()
}
}

View File

@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let qrContainerView = UIView() private let qrContainerView = UIView()
private let qrImageView = UIImageView() private let qrImageView = UIImageView()
private let qrPlaceholderView = UIView() private let qrPlaceholderView = UIView()
private let qrLoadingIndicator = UIActivityIndicatorView(style: .medium)
private let qrErrorLabel = UILabel() private let qrErrorLabel = UILabel()
private let refreshButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system)
private let setAmountButton = UIButton(type: .system) private let setAmountButton = UIButton(type: .system)
@ -127,6 +128,10 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24 qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
qrLoadingIndicator.color = AppColor.primary
qrLoadingIndicator.hidesWhenStopped = true
qrLoadingIndicator.accessibilityLabel = "收款码加载中"
qrErrorLabel.text = "未选景区或网络问题" qrErrorLabel.text = "未选景区或网络问题"
qrErrorLabel.font = .systemFont(ofSize: 14) qrErrorLabel.font = .systemFont(ofSize: 14)
qrErrorLabel.textColor = AppColor.danger qrErrorLabel.textColor = AppColor.danger
@ -150,6 +155,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
trailing: 24 trailing: 24
) )
} }
actionRow.alpha = 0
actionRow.isUserInteractionEnabled = false
actionRow.accessibilityElementsHidden = true
configureLinkButton(setAmountButton, title: "设置金额") configureLinkButton(setAmountButton, title: "设置金额")
configureLinkButton(saveQRButton, title: "保存二维码") configureLinkButton(saveQRButton, title: "保存二维码")
@ -206,6 +214,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
qrCardView.addSubview(qrContentStack) qrCardView.addSubview(qrContentStack)
qrPlaceholderView.addSubview(qrLoadingIndicator)
qrPlaceholderView.addSubview(qrErrorLabel) qrPlaceholderView.addSubview(qrErrorLabel)
qrPlaceholderView.addSubview(refreshButton) qrPlaceholderView.addSubview(refreshButton)
@ -275,6 +284,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.snp.makeConstraints { make in qrPlaceholderView.snp.makeConstraints { make in
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182) make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
} }
qrLoadingIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
qrErrorLabel.snp.makeConstraints { make in qrErrorLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(56) make.top.equalToSuperview().offset(56)
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
@ -370,9 +382,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private func loadData() async { private func loadData() async {
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI) async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
showLoading()
await viewModel.loadPayCode(api: paymentAPI) await viewModel.loadPayCode(api: paymentAPI)
hideLoading()
await pageConfig await pageConfig
} }
@ -387,17 +397,33 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
applyBrandConfigIfNeeded() applyBrandConfigIfNeeded()
if let image = viewModel.qrImage { if viewModel.isPayCodeLoading {
qrImageView.isHidden = true
qrContainerView.isHidden = true
qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding
? .white
: AppColor.inputBackground
qrPlaceholderView.isHidden = false
qrErrorLabel.isHidden = true
refreshButton.isHidden = true
qrLoadingIndicator.startAnimating()
setActionRowAvailable(false)
} else if let image = viewModel.qrImage {
qrImageView.image = image qrImageView.image = image
qrImageView.isHidden = false qrImageView.isHidden = false
qrContainerView.isHidden = !viewModel.usesNalatiBranding qrContainerView.isHidden = !viewModel.usesNalatiBranding
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
actionRow.isHidden = false qrLoadingIndicator.stopAnimating()
setActionRowAvailable(true)
} else { } else {
qrImageView.isHidden = true qrImageView.isHidden = true
qrContainerView.isHidden = true qrContainerView.isHidden = true
qrPlaceholderView.backgroundColor = AppColor.inputBackground
qrPlaceholderView.isHidden = false qrPlaceholderView.isHidden = false
actionRow.isHidden = true qrErrorLabel.isHidden = false
refreshButton.isHidden = false
qrLoadingIndicator.stopAnimating()
setActionRowAvailable(false)
} }
if viewModel.showAmountDialog { if viewModel.showAmountDialog {
@ -408,6 +434,12 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
} }
} }
private func setActionRowAvailable(_ isAvailable: Bool) {
actionRow.alpha = isAvailable ? 1 : 0
actionRow.isUserInteractionEnabled = isAvailable
actionRow.accessibilityElementsHidden = !isAvailable
}
private func presentAmountDialogIfNeeded() { private func presentAmountDialogIfNeeded() {
guard amountDialog == nil else { guard amountDialog == nil else {
amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark) amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark)
@ -625,9 +657,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
@objc private func refreshTapped() { @objc private func refreshTapped() {
Task { Task {
showLoading()
await viewModel.refresh(api: paymentAPI) await viewModel.refresh(api: paymentAPI)
hideLoading()
} }
} }

View File

@ -38,6 +38,11 @@ final class ProfileViewController: BaseViewController {
title = "我的" title = "我的"
} }
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func setupUI() { override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF) view.backgroundColor = UIColor(hex: 0xF7FAFF)
@ -104,12 +109,10 @@ final class ProfileViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
let showLoading = !hasLoadedProfileOnce let showLoading = !hasLoadedProfileOnce && !viewModel.hasCachedBasicInfo
Task { Task {
await reloadProfile(showGlobalLoading: showLoading) await reloadProfile(showGlobalLoading: showLoading)
if showLoading { hasLoadedProfileOnce = true
hasLoadedProfileOnce = true
}
} }
} }

View File

@ -62,6 +62,7 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(response.token, "person-temp-token") XCTAssertEqual(response.token, "person-temp-token")
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201]) XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"]) XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"])
XCTAssertEqual(response.accounts.map(\.realName), ["张三", "李四"])
} }
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws { func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
@ -137,4 +138,39 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三") XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三")
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201)) XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
} }
func testV9AccountModelsIgnoreBackendIdentifiersAndUseRealNameFallback() throws {
let json = """
{
"token": "person-temp-token",
"scenic_users": [
{
"account_type": "scenic_user",
"ss_user_id": 101,
"username": "backend_scenic_account",
"user_name": "backend_scenic_unique_id",
"real_name": ""
}
],
"store_users": [
{
"account_type": "store_user",
"store_user_id": 201,
"username": "backend_store_account",
"user_name": "backend_store_unique_id",
"real_name": ""
}
]
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(V9AuthResponse.self, from: json)
XCTAssertEqual(response.scenicUsers.first?.realName, "景区真实姓名")
XCTAssertEqual(response.scenicUsers.first?.displayName, "景区真实姓名")
XCTAssertEqual(response.scenicUsers.first?.toAccountSwitchAccount().title, "景区真实姓名")
XCTAssertEqual(response.storeUsers.first?.realName, "门店真实姓名")
XCTAssertEqual(response.storeUsers.first?.displayName, "门店真实姓名")
XCTAssertEqual(response.storeUsers.first?.toAccountSwitchAccount().title, "门店真实姓名")
}
} }

View File

@ -69,6 +69,26 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.showAmountDialog) XCTAssertFalse(viewModel.showAmountDialog)
} }
func testPayCodeLoadingStateCoversInitialRenderAndRequest() async {
let payCodeJSON = """
{"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}}
""".data(using: .utf8)!
let session = MockURLSession(responses: [payCodeJSON])
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
var loadingStates = [viewModel.isPayCodeLoading]
viewModel.onStateChange = {
loadingStates.append(viewModel.isPayCodeLoading)
}
await viewModel.loadPayCode(api: api)
XCTAssertEqual(loadingStates.first, true)
XCTAssertTrue(loadingStates.dropLast().allSatisfy { $0 })
XCTAssertEqual(loadingStates.last, false)
XCTAssertTrue(viewModel.hasPayCode)
}
func testToggleReceiveVoicePersistsInAppStore() { func testToggleReceiveVoicePersistsInAppStore() {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
viewModel.refreshLocalState() viewModel.refreshLocalState()
@ -79,8 +99,9 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.isVoiceBroadcastOpen) XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
} }
func testNalatiAccountInfoUsesLoggedInPhotographerAndAllPermissionStores() { func testNalatiAccountInfoUsesPhotographerRealNameAndAllPermissionStores() {
appStore.session.userName = "" appStore.session.userName = ""
appStore.session.realName = ""
appStore.permissions.saveRolePermissionList([ appStore.permissions.saveRolePermissionList([
RolePermissionResponse( RolePermissionResponse(
role: RoleInfo(id: 41, name: "", roleCode: "photographer"), role: RoleInfo(id: 41, name: "", roleCode: "photographer"),
@ -93,10 +114,19 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "") XCTAssertEqual(viewModel.displayPhotographerName, "")
XCTAssertEqual(viewModel.displayStoreNames, "") XCTAssertEqual(viewModel.displayStoreNames, "")
} }
func testPhotographerNameDoesNotFallBackToNicknameWhenRealNameMissing() {
appStore.session.userName = ""
appStore.session.realName = " "
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "-")
}
func testLoadPayCodeClearsQRWhenScenicMissing() async { func testLoadPayCodeClearsQRWhenScenicMissing() async {
appStore.session.currentScenicId = 0 appStore.session.currentScenicId = 0
let session = MockURLSession(responses: []) let session = MockURLSession(responses: [])
@ -105,6 +135,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
await viewModel.loadPayCode(api: api) await viewModel.loadPayCode(api: api)
XCTAssertFalse(viewModel.isPayCodeLoading)
XCTAssertFalse(viewModel.hasPayCode) XCTAssertFalse(viewModel.hasPayCode)
XCTAssertEqual(session.requests.count, 0) XCTAssertEqual(session.requests.count, 0)
} }

View File

@ -55,11 +55,12 @@ final class PaymentModelsTests: XCTestCase {
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0)) XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
} }
func testPaymentAccountDisplayFormatterUsesCurrentPhotographerAndAllUniqueStores() { func testPaymentAccountDisplayFormatterUsesRealNameAndAllUniqueStores() {
XCTAssertEqual( XCTAssertEqual(
PaymentAccountDisplayFormatter.photographerName(userName: " 当前摄影师 ", realName: "实名"), PaymentAccountDisplayFormatter.photographerName(realName: " 摄影师真实姓名 "),
"当前摄影师" "摄影师真实姓名"
) )
XCTAssertEqual(PaymentAccountDisplayFormatter.photographerName(realName: " "), "-")
XCTAssertEqual( XCTAssertEqual(
PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]), PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]),
"一号店、二号店" "一号店、二号店"

View File

@ -26,6 +26,22 @@ final class ProfileViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.displayNickname, "未设置昵称") XCTAssertEqual(viewModel.displayNickname, "未设置昵称")
} }
func testColdStartBasicInfoUsesPersistedSessionCache() {
AppStore.shared.session.userName = "缓存昵称"
AppStore.shared.session.realName = "缓存真实姓名"
AppStore.shared.session.phone = "186****7230"
AppStore.shared.session.avatar = "https://cdn.example.com/cached-avatar.jpg"
let viewModel = ProfileViewModel()
XCTAssertTrue(viewModel.hasCachedBasicInfo)
XCTAssertEqual(viewModel.displayNickname, "缓存昵称")
XCTAssertEqual(viewModel.displayRealName, "缓存真实姓名")
XCTAssertEqual(viewModel.displayPhone, "186****7230")
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/cached-avatar.jpg")
XCTAssertEqual(viewModel.displayUID, "profile-user")
}
func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() { func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() {
AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue
let viewModel = ProfileViewModel() let viewModel = ProfileViewModel()

View File

@ -0,0 +1,62 @@
//
// SessionExpiredDialogViewControllerTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// Android
@MainActor
final class SessionExpiredDialogViewControllerTests: XCTestCase {
func testDialogUsesForcedPresentationAndAndroidCopy() throws {
let dialog = SessionExpiredDialogViewController(onConfirm: {})
dialog.loadViewIfNeeded()
XCTAssertEqual(dialog.modalPresentationStyle, .overFullScreen)
XCTAssertEqual(dialog.modalTransitionStyle, .crossDissolve)
XCTAssertTrue(dialog.isModalInPresentation)
XCTAssertTrue(dialog.view.accessibilityViewIsModal)
let labels = dialog.view.allSubviews(of: UILabel.self)
XCTAssertTrue(labels.contains { $0.text == "登录提醒" })
XCTAssertTrue(labels.contains { $0.text == "当前用户已在其他设备登录,\n请重新登录" })
let button = try XCTUnwrap(
dialog.view.allSubviews(of: UIButton.self).first {
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
}
)
XCTAssertEqual(button.title(for: .normal), "确认并退出登录")
}
func testConfirmCallbackOnlyRunsOnce() throws {
var confirmationCount = 0
let dialog = SessionExpiredDialogViewController {
confirmationCount += 1
}
dialog.loadViewIfNeeded()
let button = try XCTUnwrap(
dialog.view.allSubviews(of: UIButton.self).first {
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
}
)
button.sendActions(for: .touchUpInside)
button.sendActions(for: .touchUpInside)
XCTAssertEqual(confirmationCount, 1)
XCTAssertFalse(button.isEnabled)
}
}
private extension UIView {
func allSubviews<View: UIView>(of type: View.Type) -> [View] {
subviews.flatMap { subview -> [View] in
let current = (subview as? View).map { [$0] } ?? []
return current + subview.allSubviews(of: type)
}
}
}