import SnapKit import UIKit /// 业务根页面之前核验注销状态;冷静期提供明确撤销,未知状态只允许查询和主动退出。 @MainActor final class StoreAccountDeregistrationAccessViewController: BaseViewController { /// 冻结的门店身份,用于 Scene 对旧请求的隔离。 let identity: StoreAccountDeregistrationIdentity? private let api: (any StoreAccountDeregistrationServing)? private let session: AppSessionStore private let onAllowed: (StoreAccountDeregistrationAccessViewController) -> Void private let onAccessDenied: () -> Void private let viewModel: StoreAccountDeregistrationAccessViewModel private let hasInitialResult: Bool private typealias Style = StoreAccountDeregistrationStyle private let scrollView = UIScrollView() private let refreshControl = UIRefreshControl() private let stack = UIStackView() private let bottomBar = UIView() private let titleLabel = Style.label(size: 24, weight: .semibold) private let stateIcon = Style.icon("lock", size: 36) private let stateIconBadge = UIView() private let deadlineLabel = Style.label(size: 19, weight: .semibold) private let infoLabel = Style.label("重新登录该身份自动撤销申请", size: 13, color: Style.textSecondary) private var deadlineCard = UIView() private var infoCard = UIView() private let messageLabel = UILabel() private let retryButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system) private let logoutButton = UIButton(type: .system) private var queryTask: Task? private var needsForegroundRefresh = false private var previousPopGestureEnabled: Bool? /// 显式注入会话与 API;身份构造失败时仍展示错误,不自动退出或重新登录。 init(identity: StoreAccountDeregistrationIdentity?, api: (any StoreAccountDeregistrationServing)?, session: AppSessionStore, requiresSubmissionReconciliation: Bool = false, submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil, initialViewModel: StoreAccountDeregistrationAccessViewModel? = nil, onAccessDenied: @escaping () -> Void = {}, onAllowed: @escaping (StoreAccountDeregistrationAccessViewController) -> Void) { self.identity = identity self.api = api self.session = session self.onAllowed = onAllowed self.onAccessDenied = onAccessDenied hasInitialResult = initialViewModel != nil viewModel = initialViewModel ?? StoreAccountDeregistrationAccessViewModel( requiresSubmissionReconciliation: requiresSubmissionReconciliation, submissionStore: submissionStore) super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "注销账号" navigationItem.hidesBackButton = true } override func setupUI() { view.backgroundColor = Style.pageBackground view.addSubview(scrollView) scrollView.alwaysBounceVertical = true refreshControl.accessibilityIdentifier = "deregister.access.refresh" refreshControl.accessibilityLabel = "下拉刷新" refreshControl.tintColor = .clear refreshControl.addTarget(self, action: #selector(retryTapped), for: .valueChanged) scrollView.refreshControl = refreshControl scrollView.addSubview(stack) stack.axis = .vertical stack.spacing = 18 stateIconBadge.backgroundColor = Style.infoBackground stateIconBadge.layer.cornerRadius = 44 stateIconBadge.addSubview(stateIcon) stateIconBadge.snp.makeConstraints { $0.width.height.equalTo(88) } stateIcon.snp.makeConstraints { $0.center.equalToSuperview(); $0.width.height.equalTo(44) } titleLabel.textAlignment = .center messageLabel.numberOfLines = 0 messageLabel.font = .systemFont(ofSize: 15) messageLabel.textColor = Style.textSecondary messageLabel.textAlignment = .center let iconRow = UIView() iconRow.addSubview(stateIconBadge) stateIconBadge.snp.makeConstraints { $0.top.bottom.centerX.equalToSuperview() } let hero = Style.stack([iconRow, titleLabel, messageLabel], spacing: 12) stack.addArrangedSubview(hero) deadlineLabel.accessibilityIdentifier = "deregister.access.deadline" let deadlineIcon = Style.icon("calendar", size: 22) deadlineIcon.snp.makeConstraints { $0.width.equalTo(28) } let deadlineText = Style.stack([ Style.label("冷静期截止时间", size: 13, color: Style.textSecondary), deadlineLabel ], spacing: 8) let deadlineRow = UIStackView(arrangedSubviews: [deadlineIcon, deadlineText]) deadlineRow.axis = .horizontal deadlineRow.alignment = .center deadlineRow.spacing = 12 deadlineCard = Style.card(deadlineRow) stack.addArrangedSubview(deadlineCard) let infoIcon = Style.icon("info.circle", size: 17) infoIcon.snp.makeConstraints { $0.width.equalTo(22) } let infoRow = UIStackView(arrangedSubviews: [infoIcon, infoLabel]) infoRow.axis = .horizontal infoRow.alignment = .center infoRow.spacing = 8 infoCard = Style.card(infoRow, background: Style.infoBackground) stack.addArrangedSubview(infoCard) view.addSubview(bottomBar) bottomBar.backgroundColor = .white Style.configure(retryButton, title: "重新查询状态") Style.configure(logoutButton, title: "退出登录") Style.configure(cancelButton, title: "撤销注销申请", primary: false) let actions = Style.stack([retryButton, logoutButton, cancelButton], spacing: 12) bottomBar.addSubview(actions) actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) } for button in [retryButton, logoutButton, cancelButton] { button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(50) } } retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) logoutButton.addTarget(self, action: #selector(logoutTapped), for: .touchUpInside) messageLabel.accessibilityIdentifier = "deregister.access.message" titleLabel.accessibilityIdentifier = "deregister.access.title" retryButton.accessibilityIdentifier = "deregister.access.retry" cancelButton.accessibilityIdentifier = "deregister.access.cancel" logoutButton.accessibilityIdentifier = "deregister.access.logout" } override func setupConstraints() { bottomBar.snp.makeConstraints { $0.leading.trailing.equalToSuperview() $0.bottom.equalTo(view.safeAreaLayoutGuide) } scrollView.snp.makeConstraints { $0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) $0.bottom.equalTo(bottomBar.snp.top) } stack.snp.makeConstraints { $0.top.equalTo(scrollView.contentLayoutGuide).offset(24) $0.leading.trailing.bottom.equalTo(scrollView.contentLayoutGuide).inset(16) $0.width.equalTo(scrollView.frameLayoutGuide).offset(-32) } } override func viewDidLoad() { super.viewDidLoad() if hasInitialResult { applyViewModel() } else { retryTapped() } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) previousPopGestureEnabled = navigationController?.interactivePopGestureRecognizer?.isEnabled navigationItem.hidesBackButton = true navigationController?.interactivePopGestureRecognizer?.isEnabled = false } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) if let previousPopGestureEnabled { navigationController?.interactivePopGestureRecognizer?.isEnabled = previousPopGestureEnabled } } /// 在途旧查询不能覆盖刚收到的受限信号;之后由用户主动重新查询。 func recordRestriction() { viewModel.recordRestriction() onAccessDenied() if isViewLoaded { applyViewModel() } } /// 从后台回来只读重查;废弃后台前的在途结果,不重发申请或撤销操作。 func refreshAfterBackground() { viewModel.recordRestriction() navigationController?.popToRootViewController(animated: false) if presentedViewController is UIAlertController { dismiss(animated: false) } if queryTask != nil { needsForegroundRefresh = true applyViewModel() } else { retryTapped() } } private func applyViewModel() { let busy = queryTask != nil let cooling = viewModel.decision == .cooling retryButton.isEnabled = !busy && api != nil retryButton.isHidden = cooling refreshControl.isEnabled = !busy && api != nil cancelButton.isHidden = !cooling cancelButton.isEnabled = !busy && api != nil logoutButton.isEnabled = !busy Style.configure(logoutButton, title: "退出登录", primary: cooling) deadlineCard.isHidden = !cooling infoCard.isHidden = !cooling deadlineLabel.text = viewModel.status?.coolingUntil ?? "—" guard identity != nil, api != nil else { titleLabel.text = "暂时无法查询" messageLabel.text = "当前身份信息不完整,请联系管理员。" stateIcon.image = UIImage(systemName: "exclamationmark.circle") return } var symbol = "clock" switch viewModel.decision { case .notChecked, .checking: titleLabel.text = nil messageLabel.text = nil case .allowed: titleLabel.text = "当前身份可正常使用" messageLabel.text = "正在返回首页" symbol = "checkmark.circle" case .cooling: titleLabel.text = "注销申请已提交" messageLabel.text = "当前身份:\(identity?.displayName ?? "当前身份")" symbol = "lock" case .unresolved: titleLabel.text = "注销状态待确认" messageLabel.text = "暂时未获取到明确结果,请刷新重试。\n请勿重复提交;如持续出现,请联系管理员。" symbol = "questionmark.circle" case .failed: titleLabel.text = "暂时无法查询" messageLabel.text = "请检查网络后重试。\n查询失败不会撤销你的注销申请。" symbol = "wifi.exclamationmark" case .obsolete: titleLabel.text = "当前身份已变化" messageLabel.text = "请返回当前账号后重试。" symbol = "person.crop.circle.badge.exclamationmark" } stateIcon.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 36, weight: .medium)) navigationItem.hidesBackButton = true navigationController?.interactivePopGestureRecognizer?.isEnabled = false } @objc private func cancelTapped() { guard queryTask == nil, let identity, let api, identity.matches(session: session), viewModel.decision == .cooling else { return } let alert = UIAlertController(title: "撤销当前身份的注销申请?", message: "将撤销“\(identity.displayName)”的申请,服务端确认后恢复使用。其他身份不受影响。", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "保留注销申请", style: .cancel)) alert.addAction(UIAlertAction(title: "确认撤销", style: .destructive) { [weak self] _ in guard let self, self.queryTask == nil else { return } self.queryTask = Task { @MainActor [weak self] in guard let self else { return } self.showLoading() await self.viewModel.cancel(api: api) { [session = self.session] in identity.matches(session: session) } self.hideLoading() self.finishQuery() } self.applyViewModel() }) present(alert, animated: true) } @objc private func retryTapped() { guard queryTask == nil, let identity, let api else { refreshControl.endRefreshing() applyViewModel() return } queryTask = Task { @MainActor [weak self] in guard let self else { return } self.showLoading() await self.viewModel.verify(api: api) { [session = self.session] in identity.matches(session: session) } self.hideLoading() self.finishQuery() } applyViewModel() } private func finishQuery() { queryTask = nil refreshControl.endRefreshing() if needsForegroundRefresh { needsForegroundRefresh = false retryTapped() return } applyViewModel() if viewModel.decision == .allowed, identity?.matches(session: session) == true { onAllowed(self) } else { onAccessDenied() } } @objc private func logoutTapped() { let alert = UIAlertController(title: "退出登录?", message: "退出本身不会撤销申请,但会清除本机登录凭证。再次登录或选中此身份会自动撤销尚未完成的申请。", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "继续保留查询", style: .cancel)) alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { _ in NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil) }) present(alert, animated: true) } }