diff --git a/suixinkan/SceneDelegate.swift b/suixinkan/SceneDelegate.swift index 598975b..95bac71 100644 --- a/suixinkan/SceneDelegate.swift +++ b/suixinkan/SceneDelegate.swift @@ -8,6 +8,7 @@ import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + private var sessionExpiredDialog: SessionExpiredDialogViewController? func scene( _ scene: UIScene, @@ -86,18 +87,36 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } @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() AppStore.shared.logout() AppRouter.setRoot(.login, on: window) } @objc private func handleUserDidLogout() { - PushNotificationManager.shared.handleLogout() - AppStore.shared.logout() - AppRouter.setRoot(.login, on: window) + transitionToLogin() } @objc private func handleUserDidLogin() { + sessionExpiredDialog?.dismiss(animated: false) + sessionExpiredDialog = nil AppRouter.setRoot(.mainTab, on: window) DispatchQueue.main.async { PushNotificationManager.shared.handleLoginCompleted() @@ -108,4 +127,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { @objc private func handleAccountDidSwitch() { 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 + } } diff --git a/suixinkan/UI/Common/SessionExpiredDialogViewController.swift b/suixinkan/UI/Common/SessionExpiredDialogViewController.swift new file mode 100644 index 0000000..138e1d5 --- /dev/null +++ b/suixinkan/UI/Common/SessionExpiredDialogViewController.swift @@ -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() + } +} diff --git a/suixinkanTests/SessionExpiredDialogViewControllerTests.swift b/suixinkanTests/SessionExpiredDialogViewControllerTests.swift new file mode 100644 index 0000000..4e9df0a --- /dev/null +++ b/suixinkanTests/SessionExpiredDialogViewControllerTests.swift @@ -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(of type: View.Type) -> [View] { + subviews.flatMap { subview -> [View] in + let current = (subview as? View).map { [$0] } ?? [] + return current + subview.allSubviews(of: type) + } + } +}