feat: 增加门店身份注销流程
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user