feat: 增加门店身份注销流程

This commit is contained in:
2026-08-31 09:43:14 +08:00
parent 396597a160
commit e529bb5942
35 changed files with 6062 additions and 45 deletions
@@ -14,6 +14,7 @@ final class SettingViewController: BaseViewController {
private let contentView = UIView()
private let cardView = UIView()
private let rowsStack = UIStackView()
private let deregistrationRow = SettingMenuRow(title: "注销当前门店身份", titleColor: AppColor.danger, showsDivider: false)
private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false)
private let copyrightLabel = UILabel()
@@ -68,6 +69,10 @@ final class SettingViewController: BaseViewController {
rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside)
rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside)
rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside)
if AppStore.shared.session.accountType == .storeUser {
rowsStack.addArrangedSubview(deregistrationRow)
deregistrationRow.addTarget(self, action: #selector(deregistrationTapped), for: .touchUpInside)
}
}
override func setupConstraints() {
@@ -119,6 +124,29 @@ final class SettingViewController: BaseViewController {
openAgreement(.privacyPolicy)
}
/// 仅从当前门店业务会话创建注销流程,冻结 Token 与门店用户 ID。
@objc private func deregistrationTapped() {
do {
let session = AppStore.shared.session
let identity = try StoreAccountDeregistrationIdentity(session: session)
let api = StoreAccountDeregistrationAPI(client: NetworkServices.shared.apiClient, identity: identity) {
identity.matches(session: session)
}
let controller = StoreAccountDeregistrationViewController(
identityName: identity.displayName,
viewModel: StoreAccountDeregistrationViewModel(
storeUserID: Int(identity.userID) ?? 0,
submissionStore: StoreAccountDeregistrationSubmissionStore(storeUserID: identity.userID)
), api: api, onUnresolvedSubmission: {
NotificationCenter.default.post(name: NotificationName.storeAccountDeregistrationRestricted,
object: nil, userInfo: [NotificationUserInfoKey.deregistrationRequestToken: identity.token])
}
)
controller.hidesBottomBarWhenPushed = true
navigationController?.pushViewController(controller, animated: true)
} catch { showError(error.localizedDescription) }
}
private func openAgreement(_ kind: SettingAgreementKind) {
let destination = viewModel.agreementDestination(for: kind)
navigationController?.pushViewController(
@@ -138,8 +166,10 @@ final class SettingMenuRow: UIControl {
private let divider = UIView()
private let showsChevron: Bool
/// 创建菜单行,可为注销等操作单独指定标题颜色,不影响其他行。
init(
title: String,
titleColor: UIColor = UIColor(hex: 0x4B5563),
value: String? = nil,
valueColor: UIColor = AppColor.textPrimary,
showsChevron: Bool = true,
@@ -150,6 +180,7 @@ final class SettingMenuRow: UIControl {
setupUI()
setupConstraints()
titleLabel.text = title
titleLabel.textColor = titleColor
valueLabel.text = value
valueLabel.textColor = valueColor
chevronImageView.isHidden = !showsChevron
@@ -175,7 +206,6 @@ final class SettingMenuRow: UIControl {
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary
@@ -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)
}
}
@@ -0,0 +1,148 @@
import UIKit
/// 登录后或收到业务限制时核验门店注销状态;普通启动和前台恢复不主动查询。
@MainActor
final class StoreAccountDeregistrationRootCoordinator {
private weak var window: UIWindow?
private let session: AppSessionStore
private let makeAPI: (StoreAccountDeregistrationIdentity) -> any StoreAccountDeregistrationServing
private let makeSubmissionStore: (String) -> any StoreAccountDeregistrationSubmissionTracking
private let makeBusinessRoot: () -> UIViewController
private let setBindingSuspended: (Bool) -> Void
private let onBusinessResumed: () -> Void
private var pendingCheck: PendingCheck?
/// 登录核验期间保留原页面与身份;只允许该次请求结束其持有的全局加载。
private final class PendingCheck {
let identity: StoreAccountDeregistrationIdentity
let root: UIViewController?
let viewModel: StoreAccountDeregistrationAccessViewModel
var task: Task<Void, Never>?
var needsForegroundRefresh = false
init(identity: StoreAccountDeregistrationIdentity, root: UIViewController?,
viewModel: StoreAccountDeregistrationAccessViewModel) {
self.identity = identity
self.root = root
self.viewModel = viewModel
}
}
/// 注入窗口、会话、服务和业务恢复动作;测试无需访问共享会话或真实网络。
init(window: UIWindow, session: AppSessionStore,
makeAPI: @escaping (StoreAccountDeregistrationIdentity) -> any StoreAccountDeregistrationServing,
makeSubmissionStore: @escaping (String) -> any StoreAccountDeregistrationSubmissionTracking,
makeBusinessRoot: @escaping () -> UIViewController,
setBindingSuspended: @escaping (Bool) -> Void,
onBusinessResumed: @escaping () -> Void) {
self.window = window
self.session = session
self.makeAPI = makeAPI
self.makeSubmissionStore = makeSubmissionStore
self.makeBusinessRoot = makeBusinessRoot
self.setBindingSuspended = setBindingSuspended
self.onBusinessResumed = onBusinessResumed
}
/// 只认当前窗口实际显示的核验根,不用旧请求保存的控制器判断。
var accessController: StoreAccountDeregistrationAccessViewController? {
(window?.rootViewController as? UINavigationController)?.viewControllers.first
as? StoreAccountDeregistrationAccessViewController
}
/// 正在原登录页面上核验时,也应暂停推送账号绑定。
var isChecking: Bool { pendingCheck != nil }
/// 使用已有登录会话直接恢复首页,不创建注销服务或发起状态查询。
func restoreSession() {
guard let window, session.isLoggedIn else { return }
cancelPendingCheck()
AppRouter.setRoot(makeBusinessRoot(), on: window, animated: false)
setBindingSuspended(false)
onBusinessResumed()
}
/// 保留登录页背景并显示全局加载;核验通过进入首页,有异常结果才展示状态页。
func check() {
guard let window, session.isLoggedIn, session.accountType == .storeUser else { return }
if let pendingCheck, pendingCheck.identity.matches(session: session),
window.rootViewController === pendingCheck.root { return }
cancelPendingCheck()
setBindingSuspended(true)
guard let identity = try? StoreAccountDeregistrationIdentity(session: session) else {
showResult(identity: nil, api: nil, viewModel: StoreAccountDeregistrationAccessViewModel())
return
}
let api = makeAPI(identity)
let model = StoreAccountDeregistrationAccessViewModel(submissionStore: makeSubmissionStore(identity.userID))
let pending = PendingCheck(identity: identity, root: window.rootViewController, viewModel: model)
pendingCheck = pending
GlobalLoadingManager.shared.show()
pending.task = Task { @MainActor [weak self, session] in
await model.verify(api: api) { identity.matches(session: session) }
guard let self, self.pendingCheck === pending else { return }
let isCurrent = self.window?.rootViewController === pending.root && identity.matches(session: session)
self.pendingCheck = nil
pending.task = nil
GlobalLoadingManager.shared.hide()
guard isCurrent else { return }
if pending.needsForegroundRefresh {
self.check()
} else if model.decision == .allowed {
self.restoreSession()
} else {
self.showResult(identity: identity, api: api, viewModel: model)
}
}
}
/// 退出或切换会话时释放本次加载;迟到的旧响应不能关闭新请求的加载或替换新根。
func cancelPendingCheck() {
guard let pending = pendingCheck else { return }
pendingCheck = nil
pending.task?.cancel()
pending.task = nil
GlobalLoadingManager.shared.hide()
}
private func showResult(identity: StoreAccountDeregistrationIdentity?,
api: (any StoreAccountDeregistrationServing)?,
viewModel: StoreAccountDeregistrationAccessViewModel) {
guard let window else { return }
let controller = StoreAccountDeregistrationAccessViewController(
identity: identity, api: api, session: session, initialViewModel: viewModel
) { [weak self] candidate in
guard let self, self.accessController === candidate,
let identity = candidate.identity, identity.matches(session: self.session) else { return }
self.restoreSession()
}
AppRouter.setRoot(UINavigationController(rootViewController: controller), on: window, animated: false)
}
/// 普通页面返回前台不查询;仅已受限的核验页刷新,并废弃后台前的旧查询。
func resumeFromBackground() {
guard session.isLoggedIn, session.accountType == .storeUser else { return }
if let pendingCheck, pendingCheck.identity.matches(session: session),
window?.rootViewController === pendingCheck.root {
pendingCheck.viewModel.recordRestriction()
pendingCheck.needsForegroundRefresh = true
} else if let controller = accessController, controller.identity?.matches(session: session) == true {
setBindingSuspended(true)
controller.refreshAfterBackground()
}
}
/// 仅原请求 Token 与当前门店会话一致时限制业务,不恢复被新页面替换的旧根。
func recordRestriction(requestToken: String?) {
guard StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: requestToken, session: session) else { return }
setBindingSuspended(true)
if let pendingCheck, pendingCheck.identity.matches(session: session),
window?.rootViewController === pendingCheck.root {
pendingCheck.viewModel.recordRestriction()
} else if let controller = accessController, controller.identity?.matches(session: session) == true {
controller.recordRestriction()
} else {
check()
}
}
}
@@ -0,0 +1,84 @@
import SnapKit
import UIKit
/// 注销页面共用的轻量视觉组件,仅负责颜色、字号和布局,不包含业务状态。
@MainActor
enum StoreAccountDeregistrationStyle {
/// 创建支持多行的系统字体标签。
static func label(_ text: String = "", size: CGFloat = 15, weight: UIFont.Weight = .regular,
color: UIColor = AppColor.textPrimary) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: size, weight: weight)
label.textColor = color
label.numberOfLines = 0
return label
}
/// 创建统一间距的纵向内容组。
static func stack(_ views: [UIView], spacing: CGFloat = 12) -> UIStackView {
let stack = UIStackView(arrangedSubviews: views)
stack.axis = .vertical
stack.spacing = spacing
return stack
}
/// 白色圆角卡片,内容由页面提供。
static func card(_ content: UIView) -> UIView {
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = 16
card.addSubview(content)
content.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
return card
}
/// 统一主次按钮;禁用态及加载期间不依赖系统默认的灰底样式。
static func button(_ title: String, id: String, primary: Bool = true) -> UIButton {
let button = UIButton(type: .system)
button.accessibilityIdentifier = id
configure(button, title: title, primary: primary)
button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(50) }
return button
}
/// 更新按钮角色和文案,保留清晰的主次关系。
static func configure(_ button: UIButton, title: String, primary: Bool = true) {
var config = primary ? UIButton.Configuration.filled() : .plain()
config.title = title
config.baseBackgroundColor = AppColor.primary
config.baseForegroundColor = primary ? .white : AppColor.primary
config.background.cornerRadius = 12
config.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
return attributes
}
button.configuration = config
button.configurationUpdateHandler = { button in
let enabled = button.isEnabled
var updated = button.configuration
updated?.background.backgroundColorTransformer = UIConfigurationColorTransformer { _ in
primary ? (enabled ? AppColor.primary : AppColor.primary.withAlphaComponent(0.12)) : .clear
}
updated?.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
attributes.foregroundColor = enabled ? (primary ? .white : AppColor.primary) : AppColor.textSecondary
return attributes
}
button.configuration = updated
}
}
/// 以统一的SF Symbol展示提示图标,不加载额外图片资源。
static func icon(_ name: String, size: CGFloat = 22) -> UIImageView {
let image = UIImageView(image: UIImage(systemName: name,
withConfiguration: UIImage.SymbolConfiguration(pointSize: size, weight: .medium)))
image.tintColor = AppColor.primary
image.contentMode = .scaleAspectFit
image.setContentHuggingPriority(.required, for: .horizontal)
return image
}
}
@@ -0,0 +1,409 @@
import IQKeyboardCore
import IQKeyboardManagerSwift
import SnapKit
import UIKit
/// 简洁两步注销页面;一次确认两项资产,短信和实际申请仍由用户主动操作。
@MainActor
final class StoreAccountDeregistrationViewController: BaseViewController {
private typealias Style = StoreAccountDeregistrationStyle
private let viewModel: StoreAccountDeregistrationViewModel
private let api: any StoreAccountDeregistrationServing
private let identityName: String
private let readOnly: Bool
private let onUnresolvedSubmission: (() -> Void)?
private let onSubmissionAccepted: () -> Void
private var didHandleAcceptedSubmission = false
private let scrollView = UIScrollView()
private let refreshControl = UIRefreshControl()
private let content = UIStackView()
private let bottomBar = UIView()
private let steps = UIStackView()
private let firstStep = Style.label("1 确认资产", size: 13, weight: .semibold)
private let secondStep = Style.label("2 手机验证", size: 13, weight: .semibold)
private let heading = Style.label(size: 24, weight: .semibold)
private let subtitle = Style.label(size: 14, color: AppColor.textSecondary)
private let conditionStack = UIStackView()
private let verificationStack = UIStackView()
private let walletAmount = Style.label("—", size: 28, weight: .semibold)
private let pointsAmount = Style.label("—", size: 28, weight: .semibold)
private let walletState = Style.label(size: 12, color: AppColor.textSecondary)
private let pointsState = Style.label(size: 12, color: AppColor.textSecondary)
private let assetHint = Style.label(size: 13, color: AppColor.textSecondary)
private let blockersLabel = Style.label(size: 14, color: AppColor.textSecondary)
private let riskLabel = Style.label(size: 13, color: AppColor.warning)
private let statusLabel = Style.label(size: 13, color: AppColor.textSecondary)
private let errorLabel = Style.label(size: 14, color: AppColor.danger)
private let codeField = UITextField()
private let reasonField = UITextField()
private let smsButton = UIButton(type: .system)
private let continueButton = Style.button("确认资产并继续", id: "deregister.continue")
private let submitButton = Style.button("提交注销申请", id: "deregister.submit")
private let backButton = UIButton(type: .system)
private let footerHint = Style.label(size: 12, color: AppColor.textSecondary)
private var blockersCard = UIView()
private var errorCard = UIView()
private var actionTask: Task<Void, Never>?
private var previousPopGestureEnabled: Bool?
private var previousViewportHeight: CGFloat = 0
/// 注入当前身份及旧接口,不读取任意手机号,也不自动确认资产。
init(identityName: String, viewModel: StoreAccountDeregistrationViewModel,
api: any StoreAccountDeregistrationServing, readOnly: Bool = false,
onUnresolvedSubmission: (() -> Void)? = nil,
onSubmissionAccepted: @escaping () -> Void = {
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
}) {
self.identityName = identityName
self.viewModel = viewModel
self.api = api
self.readOnly = readOnly
self.onUnresolvedSubmission = onUnresolvedSubmission
self.onSubmissionAccepted = onSubmissionAccepted
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
title = readOnly ? "注销条件" : "注销账号"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
view.addSubview(scrollView)
refreshControl.accessibilityIdentifier = "deregister.refresh"
refreshControl.accessibilityLabel = "下拉刷新"
refreshControl.tintColor = .clear
refreshControl.addTarget(self, action: #selector(refreshTapped), for: .valueChanged)
scrollView.refreshControl = refreshControl
scrollView.addSubview(content)
content.axis = .vertical
content.spacing = 16
content.addArrangedSubview(steps)
steps.axis = .horizontal
steps.distribution = .fillEqually
steps.addArrangedSubview(firstStep)
steps.addArrangedSubview(secondStep)
steps.isHidden = readOnly
content.addArrangedSubview(Style.stack([heading, subtitle], spacing: 8))
let identityText = Style.stack([
Style.label("当前门店身份", size: 12, color: AppColor.textSecondary),
Style.label(identityName, size: 17, weight: .semibold)
], spacing: 5)
let identityRow = UIStackView(arrangedSubviews: [Style.icon("person.crop.circle"), identityText])
identityRow.axis = .horizontal
identityRow.alignment = .center
identityRow.spacing = 12
content.addArrangedSubview(Style.card(identityRow))
conditionStack.axis = .vertical
conditionStack.spacing = 16
verificationStack.axis = .vertical
verificationStack.spacing = 16
buildConditions()
buildVerification()
content.addArrangedSubview(conditionStack)
content.addArrangedSubview(verificationStack)
errorCard = Style.card(errorLabel)
errorCard.backgroundColor = AppColor.dangerBackground
content.addArrangedSubview(errorCard)
content.addArrangedSubview(statusLabel)
statusLabel.accessibilityIdentifier = "deregister.status"
errorLabel.accessibilityIdentifier = "deregister.error"
view.addSubview(bottomBar)
bottomBar.backgroundColor = .white
footerHint.textAlignment = .center
let actions = Style.stack([continueButton, submitButton, footerHint], spacing: 10)
bottomBar.addSubview(actions)
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
bottomBar.isHidden = readOnly
continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
scrollView.keyboardDismissMode = .onDrag
scrollView.alwaysBounceVertical = true
}
private func buildConditions() {
let columns = UIStackView()
columns.axis = .horizontal
columns.distribution = .fillEqually
columns.spacing = 16
columns.addArrangedSubview(Style.stack([
Style.label("现金余额", size: 13, color: AppColor.textSecondary), walletAmount, walletState
], spacing: 8))
columns.addArrangedSubview(Style.stack([
Style.label("积分", size: 13, color: AppColor.textSecondary), pointsAmount, pointsState
], spacing: 8))
walletAmount.adjustsFontSizeToFitWidth = true
walletAmount.minimumScaleFactor = 0.6
walletAmount.numberOfLines = 1
pointsAmount.adjustsFontSizeToFitWidth = true
pointsAmount.minimumScaleFactor = 0.6
pointsAmount.numberOfLines = 1
walletState.accessibilityIdentifier = "deregister.wallet.state"
pointsState.accessibilityIdentifier = "deregister.points.state"
let assets = Style.card(Style.stack([
Style.label("账号资产", size: 16, weight: .semibold), columns, assetHint
], spacing: 16))
assets.accessibilityIdentifier = "deregister.assets"
conditionStack.addArrangedSubview(assets)
blockersLabel.accessibilityIdentifier = "deregister.blockers"
blockersCard = Style.card(Style.stack([
Style.label("待处理事项", size: 16, weight: .semibold), blockersLabel, riskLabel
]))
conditionStack.addArrangedSubview(blockersCard)
let notices = Style.stack([
Style.label("注销须知", size: 16, weight: .semibold),
notice("person.crop.circle", title: "仅注销当前身份", detail: "同手机号的其他身份不受影响。"),
notice("clock", title: "7 天冷静期", detail: "提交后可撤销申请,到期由服务端复核。"),
notice("exclamationmark.shield", title: "正式完成后不可恢复", detail: "历史订单、财务及审计记录按规则保留。")
], spacing: 16)
conditionStack.addArrangedSubview(Style.card(notices))
}
private func notice(_ icon: String, title: String, detail: String) -> UIView {
let image = Style.icon(icon, size: 18)
image.snp.makeConstraints { $0.width.equalTo(22) }
let row = UIStackView(arrangedSubviews: [image, Style.stack([
Style.label(title, size: 14, weight: .medium),
Style.label(detail, size: 13, color: AppColor.textSecondary)
], spacing: 4)])
row.axis = .horizontal
row.alignment = .top
row.spacing = 10
return row
}
private func buildVerification() {
codeField.placeholder = "请输入短信验证码"
codeField.keyboardType = .numberPad
codeField.textContentType = .oneTimeCode
codeField.accessibilityIdentifier = "deregister.code"
codeField.accessibilityLabel = "短信验证码"
reasonField.placeholder = "请输入注销原因"
reasonField.accessibilityIdentifier = "deregister.reason"
reasonField.accessibilityLabel = "注销原因"
for field in [codeField, reasonField] {
field.font = .systemFont(ofSize: 16)
field.autocorrectionType = .no
// 本页通过keyboardLayoutGuide和滚动区域避让,避免全局键盘库重复抬升整页。
field.iq.enableMode = .disabled
field.addTarget(self, action: #selector(inputChanged), for: .editingChanged)
field.addTarget(self, action: #selector(revealFocusedInput), for: .editingDidBegin)
field.snp.makeConstraints { $0.height.equalTo(48) }
}
smsButton.setTitle("获取验证码", for: .normal)
smsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
smsButton.accessibilityIdentifier = "deregister.sms"
smsButton.setContentHuggingPriority(.required, for: .horizontal)
smsButton.setContentCompressionResistancePriority(.required, for: .horizontal)
smsButton.addTarget(self, action: #selector(smsTapped), for: .touchUpInside)
smsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
let codeRow = UIStackView(arrangedSubviews: [codeField, smsButton])
codeRow.axis = .horizontal
codeRow.spacing = 12
verificationStack.addArrangedSubview(Style.card(Style.stack([
Style.label("短信验证", size: 16, weight: .semibold), codeRow,
Style.label("验证码将发送至当前身份绑定的手机号。", size: 13, color: AppColor.textSecondary)
], spacing: 8)))
verificationStack.addArrangedSubview(Style.card(Style.stack([
Style.label("注销原因", size: 16, weight: .semibold), reasonField
], spacing: 8)))
backButton.setTitle("返回查看资产与须知", for: .normal)
backButton.titleLabel?.font = .systemFont(ofSize: 14)
backButton.accessibilityIdentifier = "deregister.back"
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
backButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
verificationStack.addArrangedSubview(backButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints {
$0.leading.trailing.equalToSuperview()
$0.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
}
scrollView.snp.makeConstraints {
$0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
if readOnly { $0.bottom.equalTo(view.safeAreaLayoutGuide) }
else { $0.bottom.equalTo(bottomBar.snp.top) }
}
content.snp.makeConstraints {
$0.edges.equalTo(scrollView.contentLayoutGuide).inset(16)
$0.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
refreshTapped()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
previousPopGestureEnabled = navigationController?.interactivePopGestureRecognizer?.isEnabled
applyViewModel()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if let previousPopGestureEnabled {
navigationController?.interactivePopGestureRecognizer?.isEnabled = previousPopGestureEnabled
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard scrollView.bounds.height != previousViewportHeight else { return }
previousViewportHeight = scrollView.bounds.height
revealFocusedInput()
}
@objc private func revealFocusedInput() {
guard let field = [codeField, reasonField].first(where: \.isFirstResponder) else { return }
let rect = field.convert(field.bounds, to: scrollView).insetBy(dx: 0, dy: -12)
scrollView.scrollRectToVisible(rect, animated: false)
}
private func applyViewModel() {
let busy = actionTask != nil || viewModel.isBusy
let verifying = viewModel.step == .verification && !readOnly
let unresolved = viewModel.step == .unresolvedRequest && !readOnly
heading.text = readOnly ? "当前注销条件" : (verifying ? "验证绑定手机号" : "注销前,请确认")
subtitle.text = verifying ? "完成验证后,即可提交注销申请。" : "仅注销此身份,其他身份不受影响。"
firstStep.textColor = verifying ? AppColor.textSecondary : AppColor.primary
secondStep.textColor = verifying ? AppColor.primary : AppColor.textSecondary
conditionStack.isHidden = verifying || unresolved
verificationStack.isHidden = !verifying
continueButton.isHidden = verifying || readOnly
submitButton.isHidden = !verifying
continueButton.configuration?.title = viewModel.requiresAssetConfirmation ? "确认资产并继续" : "下一步"
continueButton.isEnabled = !busy && viewModel.canConfirmAssetsAndContinue
submitButton.isEnabled = !busy && viewModel.canContinue && hasVerificationInput
smsButton.isEnabled = !busy && viewModel.canContinue
codeField.isEnabled = !busy
reasonField.isEnabled = !busy
backButton.isEnabled = !busy
refreshControl.isEnabled = !busy
footerHint.text = verifying ? "7 天内可撤销,正式完成后不可恢复" : "请确认资产及注销须知后继续"
errorLabel.text = viewModel.errorMessage
errorCard.isHidden = viewModel.errorMessage == nil
if let value = viewModel.eligibility {
walletAmount.text = "¥\(value.walletBalance)"
pointsAmount.text = "\(value.pointsBalance)"
walletState.text = value.walletWaived ? "已确认放弃" : "待确认放弃"
pointsState.text = value.pointsWaived ? "已确认放弃" : "待确认放弃"
walletState.textColor = value.walletWaived ? AppColor.primary : AppColor.textSecondary
pointsState.textColor = value.pointsWaived ? AppColor.primary : AppColor.textSecondary
let assetIssues = value.blockers.filter(\.isAssetConfirmation)
assetHint.text = readOnly ? "以当前查询结果为准,冷静期内不能再次确认资产。"
: (assetIssues.contains { $0.code == "WAIVER_STALE" }
? "资产已变化,请按最新金额重新确认。"
: "正式注销时,将清空已确认放弃的现金和积分。")
blockersCard.isHidden = value.businessBlockers.isEmpty
blockersLabel.text = value.businessBlockers.map { "• \($0.message)\n \($0.guidance)" }.joined(separator: "\n\n")
riskLabel.text = value.eligibleAt.map { "业务风险期预计结束:\($0)" }
riskLabel.isHidden = value.eligibleAt == nil
} else {
walletAmount.text = "—"
pointsAmount.text = "—"
walletState.text = "待查询"
pointsState.text = "待查询"
assetHint.text = busy ? "正在查询资产与注销条件…" : "暂未获取到资产,请刷新重试。"
blockersCard.isHidden = true
}
statusLabel.text = unresolved ? "申请状态待确认,请刷新后重试,暂勿重复提交。"
: (viewModel.status?.isCancelled == true ? "上次申请已撤销,可重新申请。" : nil)
statusLabel.isHidden = statusLabel.text == nil
let preventsBack = busy || unresolved || (!readOnly && viewModel.submissionAttempted)
navigationItem.hidesBackButton = preventsBack
if view.window != nil {
navigationController?.interactivePopGestureRecognizer?.isEnabled = !preventsBack && (previousPopGestureEnabled ?? true)
}
}
private var hasVerificationInput: Bool {
!(codeField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !(reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func run(_ operation: @escaping @MainActor () async -> Void) {
guard actionTask == nil, !didHandleAcceptedSubmission else {
refreshControl.endRefreshing()
return
}
actionTask = Task { @MainActor [weak self] in
guard let self else { return }
self.showLoading()
await operation()
self.hideLoading()
self.refreshControl.endRefreshing()
self.actionTask = nil
if !self.readOnly, self.viewModel.submissionAccepted {
self.didHandleAcceptedSubmission = true
self.onSubmissionAccepted()
return
}
self.applyViewModel()
if !self.readOnly, self.viewModel.submissionAttempted || self.viewModel.status?.isCooling == true {
self.onUnresolvedSubmission?()
}
}
applyViewModel()
}
@objc private func inputChanged() { applyViewModel() }
@objc private func refreshTapped() { run { [self] in await self.viewModel.refresh(api: self.api) } }
@objc private func continueTapped() {
guard !readOnly, viewModel.canConfirmAssetsAndContinue, let snapshot = viewModel.eligibility else { return }
if !viewModel.requiresAssetConfirmation {
run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
return
}
let alert = UIAlertController(title: "确认放弃账号资产?", message:
"当前身份:\(identityName)\n\n现金余额:¥\(snapshot.walletBalance)\n积分:\(snapshot.pointsBalance)\n\n我确认自愿放弃以上现金余额,并确认自愿放弃以上积分。正式注销时将按规则清零。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "暂不确认", style: .cancel))
alert.addAction(UIAlertAction(title: "确认并继续", style: .destructive) { [weak self] _ in
guard let self else { return }
self.run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
})
present(alert, animated: true)
}
@objc private func backTapped() {
view.endEditing(true)
viewModel.returnToConditions()
applyViewModel()
}
@objc private func smsTapped() {
guard !readOnly else { return }
run { [self] in
if await self.viewModel.sendSMS(api: self.api) { showToast("验证码已发送至当前身份绑定手机号") }
}
}
@objc private func submitTapped() {
guard !readOnly, viewModel.canContinue, hasVerificationInput else { return }
let code = codeField.text ?? ""
let reason = reasonField.text ?? ""
view.endEditing(true)
let alert = UIAlertController(title: "提交注销申请?", message:
"仅注销“\(identityName)”。提交成功后将退出登录,并进入 7 天冷静期,最终由服务端复核;正式完成不可恢复。\n\n重新登录或选中此身份会自动撤销尚未完成的申请。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "暂不提交", style: .cancel))
alert.addAction(UIAlertAction(title: "确认提交申请", style: .destructive) { [weak self] _ in
self?.submitConfirmedApplication(smsCode: code, reason: reason)
})
present(alert, animated: true)
}
/// 用户确认最终弹窗后提交;仅明确成功触发一次退出,未知结果仍进入核验流程。
func submitConfirmedApplication(smsCode: String, reason: String) {
guard !readOnly else { return }
run { [self] in await viewModel.submit(smsCode: smsCode, reason: reason, api: api) }
}
}