Files
suixinkan_uikit/suixinkan/UI/Setting/StoreAccountDeregistrationAccessViewController.swift

299 lines
14 KiB
Swift

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<Void, Never>?
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)
}
}