410 lines
21 KiB
Swift
410 lines
21 KiB
Swift
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) }
|
|
}
|
|
}
|