287 lines
14 KiB
Swift
287 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("clock", size: 36)
|
|
private let deadlineLabel = Style.label(size: 19, weight: .semibold)
|
|
private let warningLabel = Style.label(size: 13, color: AppColor.textSecondary)
|
|
private var deadlineCard = UIView()
|
|
private let messageLabel = UILabel()
|
|
private let retryButton = UIButton(type: .system)
|
|
private let conditionsButton = 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
|
|
|
|
/// 显式注入会话与 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 = "注销账号"
|
|
}
|
|
|
|
override func setupUI() {
|
|
view.backgroundColor = AppColor.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 = 20
|
|
stateIcon.snp.makeConstraints { $0.height.equalTo(64) }
|
|
titleLabel.textAlignment = .center
|
|
messageLabel.numberOfLines = 0
|
|
messageLabel.font = .systemFont(ofSize: 15)
|
|
messageLabel.textColor = AppColor.textSecondary
|
|
messageLabel.textAlignment = .center
|
|
let hero = Style.stack([stateIcon, titleLabel, messageLabel], spacing: 12)
|
|
stack.addArrangedSubview(hero)
|
|
let identity = Style.stack([
|
|
Style.label("当前门店身份", size: 12, color: AppColor.textSecondary),
|
|
Style.label(self.identity?.displayName ?? "当前身份", size: 17, weight: .semibold)
|
|
], spacing: 8)
|
|
stack.addArrangedSubview(Style.card(identity))
|
|
deadlineLabel.accessibilityIdentifier = "deregister.access.deadline"
|
|
deadlineCard = Style.card(Style.stack([
|
|
Style.label("冷静期截止时间", size: 13, color: AppColor.textSecondary),
|
|
deadlineLabel,
|
|
Style.label("以服务端时间为准,到期后仍需复核。", size: 12, color: AppColor.textSecondary)
|
|
], spacing: 10))
|
|
stack.addArrangedSubview(deadlineCard)
|
|
warningLabel.text = "重新登录或选中此身份,会自动撤销尚未完成的注销申请。其他身份不受影响。"
|
|
stack.addArrangedSubview(warningLabel)
|
|
conditionsButton.setTitle("查看注销条件", for: .normal)
|
|
conditionsButton.titleLabel?.font = .systemFont(ofSize: 14)
|
|
conditionsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
|
|
stack.addArrangedSubview(conditionsButton)
|
|
|
|
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: 4)
|
|
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)
|
|
conditionsButton.addTarget(self, action: #selector(conditionsTapped), 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"
|
|
conditionsButton.accessibilityIdentifier = "deregister.access.conditions"
|
|
}
|
|
|
|
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() }
|
|
}
|
|
|
|
/// 在途旧查询不能覆盖刚收到的受限信号;之后由用户主动重新查询。
|
|
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
|
|
conditionsButton.isEnabled = !busy && api != nil
|
|
conditionsButton.isHidden = ![.unresolved, .cooling].contains(viewModel.decision)
|
|
cancelButton.isHidden = !cooling
|
|
cancelButton.isEnabled = !busy && api != nil
|
|
logoutButton.isEnabled = !busy
|
|
Style.configure(logoutButton, title: "退出登录", primary: cooling)
|
|
deadlineCard.isHidden = !cooling
|
|
warningLabel.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 = (viewModel.status?.remainingSeconds ?? 0) > 0
|
|
? "当前处于冷静期,期间暂停普通业务。\n你仍可以撤销申请,恢复使用。"
|
|
: "冷静期已结束,正在等待服务端复核。\n最终结果请刷新后查看。"
|
|
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))
|
|
}
|
|
|
|
@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 conditionsTapped() {
|
|
guard let identity, let api, identity.matches(session: session),
|
|
[.unresolved, .cooling].contains(viewModel.decision) else { return }
|
|
let controller = StoreAccountDeregistrationViewController(
|
|
identityName: identity.displayName,
|
|
viewModel: StoreAccountDeregistrationViewModel(storeUserID: Int(identity.userID) ?? 0), api: api,
|
|
readOnly: true
|
|
)
|
|
navigationController?.pushViewController(controller, animated: true)
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|