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