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: (String?) -> 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 firstStepNumber = Style.label("1", size: 11, weight: .semibold) private let secondStepNumber = Style.label("2", size: 11, weight: .semibold) private let firstStep = Style.label("确认资产", size: 13, weight: .semibold) private let secondStep = Style.label("手机验证", size: 13, weight: .semibold) private let firstStepView = UIView() private let secondStepView = UIView() private let heading = Style.label(size: 24, weight: .semibold) private let subtitle = Style.label(size: 14, color: Style.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: Style.textSecondary) private let pointsState = Style.label(size: 12, color: Style.textSecondary) private let assetHint = Style.label(size: 13, color: Style.textSecondary) private let blockersLabel = Style.label(size: 14, color: Style.textSecondary) private let riskLabel = Style.label(size: 13, color: AppColor.warning) private let statusLabel = Style.label(size: 13, color: Style.textSecondary) private let errorLabel = Style.label(size: 14, color: Style.danger) private let codeField = UITextField() private let reasonField = UITextView() private let reasonTitle = Style.label(size: 16, weight: .semibold) private let reasonPlaceholder = Style.label("请填写注销原因", size: 14, color: Style.textSecondary) private let reasonCount = Style.label("0/50", size: 12, color: Style.textSecondary) private let reasonValidationLabel = Style.label("请填写注销原因", size: 12, color: Style.danger) private let reasonContainer = UIView() private let smsButton = UIButton(type: .system) private let continueButton = Style.button("确认资产并继续", id: "deregister.continue") private let submitButton = Style.button("提交注销申请", id: "deregister.submit") private var identityCard = UIView() private var blockersCard = UIView() private var errorCard = UIView() private var actionTask: Task? private var previousPopGestureEnabled: Bool? private var previousViewportHeight: CGFloat = 0 private var reasonWasBlurred = false /// 注入当前身份及旧接口,不读取任意手机号,也不自动确认资产。 init(identityName: String, viewModel: StoreAccountDeregistrationViewModel, api: any StoreAccountDeregistrationServing, readOnly: Bool = false, onUnresolvedSubmission: (() -> Void)? = nil, onSubmissionAccepted: ((String?) -> Void)? = nil) { let frozenIdentityName = identityName self.onSubmissionAccepted = onSubmissionAccepted ?? { coolingUntil in var userInfo = [NotificationUserInfoKey.deregistrationIdentityName: frozenIdentityName] if let coolingUntil { userInfo[NotificationUserInfoKey.deregistrationCoolingUntil] = coolingUntil } NotificationCenter.default.post( name: NotificationName.storeAccountDeregistrationSubmitted, object: nil, userInfo: userInfo ) } self.identityName = identityName self.viewModel = viewModel self.api = api self.readOnly = readOnly self.onUnresolvedSubmission = onUnresolvedSubmission 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 = Style.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.alignment = .fill steps.distribution = .fillEqually steps.spacing = 12 configureStep(firstStepView, number: firstStepNumber, title: firstStep, identifier: "deregister.step.assets", accessibilityLabel: "第 1 步,确认资产") configureStep(secondStepView, number: secondStepNumber, title: secondStep, identifier: "deregister.step.verification", accessibilityLabel: "第 2 步,手机验证") steps.addArrangedSubview(firstStepView) steps.addArrangedSubview(secondStepView) steps.snp.makeConstraints { $0.height.equalTo(46) } steps.isHidden = readOnly content.addArrangedSubview(Style.stack([heading, subtitle], spacing: 8)) let identityText = Style.stack([ Style.label("当前门店身份", size: 12, color: Style.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 identityCard = Style.card(identityRow) content.addArrangedSubview(identityCard) 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 = UIColor(hex: 0xFFF1F1) content.addArrangedSubview(errorCard) content.addArrangedSubview(statusLabel) statusLabel.accessibilityIdentifier = "deregister.status" errorLabel.accessibilityIdentifier = "deregister.error" view.addSubview(bottomBar) bottomBar.backgroundColor = .white let actions = Style.stack([continueButton, submitButton], 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 configureStep(_ container: UIView, number: UILabel, title: UILabel, identifier: String, accessibilityLabel: String) { number.numberOfLines = 1 title.numberOfLines = 1 number.textAlignment = .center number.layer.cornerRadius = 10 number.clipsToBounds = true number.snp.makeConstraints { $0.width.height.equalTo(20) } let row = UIStackView(arrangedSubviews: [number, title]) row.axis = .horizontal row.alignment = .center row.spacing = 7 title.setContentCompressionResistancePriority(.required, for: .horizontal) container.layer.cornerRadius = 12 container.accessibilityIdentifier = identifier container.isAccessibilityElement = true container.accessibilityLabel = accessibilityLabel container.addSubview(row) row.snp.makeConstraints { $0.center.equalToSuperview() } } private func buildConditions() { let columns = UIStackView() columns.axis = .horizontal columns.distribution = .fill columns.spacing = 16 let walletColumn = Style.stack([ Style.label("现金余额", size: 13, color: Style.textSecondary), walletAmount, walletState ], spacing: 8) columns.addArrangedSubview(walletColumn) let divider = UIView() divider.backgroundColor = Style.border divider.snp.makeConstraints { $0.width.equalTo(1) } columns.addArrangedSubview(divider) let pointsColumn = Style.stack([ Style.label("积分", size: 13, color: Style.textSecondary), pointsAmount, pointsState ], spacing: 8) columns.addArrangedSubview(pointsColumn) walletColumn.snp.makeConstraints { $0.width.equalTo(pointsColumn) } 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([ notice("person.crop.circle", title: "仅注销当前身份"), notice("clock", title: "7 天冷静期"), notice("exclamationmark.shield", title: "正式完成后不可恢复") ], spacing: 16) conditionStack.addArrangedSubview(Style.card(notices, background: Style.infoBackground)) } private func notice(_ icon: String, title: String) -> UIView { let image = Style.icon(icon, size: 18) image.snp.makeConstraints { $0.width.equalTo(22) } let row = UIStackView(arrangedSubviews: [image, Style.label(title, size: 14, weight: .medium)]) row.axis = .horizontal row.alignment = .center row.spacing = 10 return row } private func buildVerification() { codeField.placeholder = "请输入短信验证码" codeField.keyboardType = .numberPad codeField.textContentType = .oneTimeCode codeField.accessibilityIdentifier = "deregister.code" codeField.accessibilityLabel = "短信验证码" reasonField.accessibilityIdentifier = "deregister.reason" reasonField.accessibilityLabel = "注销原因,必填" codeField.font = .systemFont(ofSize: 16) codeField.autocorrectionType = .no codeField.iq.enableMode = .disabled codeField.addTarget(self, action: #selector(inputChanged), for: .editingChanged) codeField.addTarget(self, action: #selector(revealFocusedInput), for: .editingDidBegin) reasonField.font = .systemFont(ofSize: 15) reasonField.textColor = Style.textPrimary reasonField.backgroundColor = .clear reasonField.delegate = self reasonField.autocorrectionType = .no reasonField.iq.enableMode = .disabled reasonField.textContainerInset = .init(top: 12, left: 12, bottom: 24, right: 12) codeField.snp.makeConstraints { $0.height.equalTo(50) } reasonField.snp.makeConstraints { $0.height.equalTo(96) } 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.alignment = .center codeRow.spacing = 12 codeRow.layoutMargins = .init(top: 0, left: 14, bottom: 0, right: 8) codeRow.isLayoutMarginsRelativeArrangement = true codeRow.layer.cornerRadius = 10 codeRow.layer.borderWidth = 1 codeRow.layer.borderColor = Style.border.cgColor let smsTitle = Style.label("短信验证码", size: 16, weight: .semibold) reasonContainer.layer.cornerRadius = 10 reasonContainer.layer.borderWidth = 1 reasonContainer.layer.borderColor = Style.border.cgColor reasonContainer.addSubview(reasonField) reasonContainer.addSubview(reasonPlaceholder) reasonContainer.addSubview(reasonCount) reasonField.snp.makeConstraints { $0.edges.equalToSuperview() } reasonPlaceholder.snp.makeConstraints { $0.top.leading.equalToSuperview().inset(16) } reasonCount.snp.makeConstraints { $0.trailing.bottom.equalToSuperview().inset(12) } let titleText = NSMutableAttributedString( string: "注销原因 ", attributes: [.font: UIFont.systemFont(ofSize: 16, weight: .semibold), .foregroundColor: Style.textPrimary] ) titleText.append(NSAttributedString( string: "*", attributes: [.font: UIFont.systemFont(ofSize: 16, weight: .semibold), .foregroundColor: Style.danger] )) reasonTitle.attributedText = titleText reasonTitle.accessibilityLabel = "注销原因,必填" reasonTitle.accessibilityIdentifier = "deregister.reason.title" reasonValidationLabel.isHidden = true reasonValidationLabel.accessibilityIdentifier = "deregister.reason.error" let form = Style.stack([smsTitle, codeRow, reasonTitle, reasonContainer, reasonValidationLabel], spacing: 12) form.setCustomSpacing(18, after: codeRow) verificationStack.addArrangedSubview(Style.card(form)) let helperIcon = Style.icon("shield", size: 16) helperIcon.tintColor = Style.textSecondary helperIcon.snp.makeConstraints { $0.width.equalTo(22) } let helper = UIStackView(arrangedSubviews: [ helperIcon, Style.label("验证码将发送至当前身份绑定的手机号。", size: 13, color: Style.textSecondary) ]) helper.axis = .horizontal helper.alignment = .center helper.spacing = 8 verificationStack.addArrangedSubview(helper) } 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] as [UIView]).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 ? "完成验证后,即可提交注销申请。" : "仅注销此身份,其他身份不受影响。" applyStepStyle(firstStepView, number: firstStepNumber, title: firstStep, selected: !verifying) applyStepStyle(secondStepView, number: secondStepNumber, title: secondStep, selected: verifying) identityCard.isHidden = verifying || unresolved 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.isEditable = !busy applyReasonInputStyle() refreshControl.isEnabled = !busy 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 ? Style.primary : Style.textSecondary pointsState.textColor = value.pointsWaived ? Style.primary : Style.textSecondary let assetIssues = value.blockers.filter(\.isAssetConfirmation) let needsAssetHint = readOnly || assetIssues.contains { $0.code == "WAIVER_STALE" } assetHint.isHidden = !needsAssetHint assetHint.text = readOnly ? "以当前查询结果为准,冷静期内不能再次确认资产。" : "资产已变化,请按最新金额重新确认。" 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.isHidden = false 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 func applyStepStyle(_ container: UIView, number: UILabel, title: UILabel, selected: Bool) { container.backgroundColor = selected ? Style.primary : UIColor(hex: 0xE9EEF5) container.accessibilityTraits = selected ? [.selected] : [] number.backgroundColor = selected ? UIColor.white.withAlphaComponent(0.2) : UIColor(hex: 0xDCE3EC) number.textColor = selected ? .white : Style.textSecondary title.textColor = selected ? .white : Style.textSecondary } private var hasVerificationInput: Bool { let code = (codeField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let reason = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return !code.isEmpty && !reason.isEmpty && reason.count <= 50 } private func applyReasonInputStyle() { let missing = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let invalid = reasonWasBlurred && missing reasonContainer.layer.borderColor = reasonField.isFirstResponder ? Style.primary.cgColor : (invalid ? Style.danger.cgColor : Style.border.cgColor) reasonValidationLabel.isHidden = !invalid } 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(self.viewModel.submittedCoolingUntil) 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 sheet = StoreAccountDeregistrationConfirmationSheetViewController( title: "确认放弃账号资产", summaries: [ .init(icon: "person.crop.circle", title: "当前身份", value: identityName), .init(icon: "banknote", title: "现金余额", value: "¥\(snapshot.walletBalance)"), .init(icon: "star.circle", title: "积分", value: "\(snapshot.pointsBalance)") ], notices: [], cancelTitle: "暂不确认", confirmTitle: "确认放弃" ) { [weak self] in guard let self else { return } self.run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) } } present(sheet, animated: true) } @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 ?? "").trimmingCharacters(in: .whitespacesAndNewlines) view.endEditing(true) let sheet = StoreAccountDeregistrationConfirmationSheetViewController( title: "确认提交注销申请?", summaries: [ .init(icon: "person.crop.circle", title: "当前身份", value: identityName), .init(icon: "clock", title: "冷静期", value: "7 天"), .init(icon: "text.bubble", title: "注销原因", value: reason) ], notices: [ ("info.circle", "注销后当前身份将无法使用", false), ("checkmark.shield", "冷静期内可撤销,重新登录该身份自动撤销申请", false), ("exclamationmark.circle.fill", "正式完成后不可恢复,历史记录保留", true) ], cancelTitle: "我再想想", confirmTitle: "确认提交" ) { [weak self] in self?.submitConfirmedApplication(smsCode: code, reason: reason) } present(sheet, animated: true) } /// 用户确认最终弹窗后提交;仅明确成功触发一次退出态结果页,未知结果仍进入核验流程。 func submitConfirmedApplication(smsCode: String, reason: String) { guard !readOnly, viewModel.validateSubmissionInput(smsCode: smsCode, reason: reason) else { applyViewModel() return } run { [self] in await viewModel.submit(smsCode: smsCode, reason: reason, api: api) } } } extension StoreAccountDeregistrationViewController: UITextViewDelegate { /// 注销原因必填;限制长度并同步必填状态、占位和计数展示。 func textViewDidChange(_ textView: UITextView) { if textView.text.count > 50 { textView.text = String(textView.text.prefix(50)) } reasonPlaceholder.isHidden = !textView.text.isEmpty reasonCount.text = "\(textView.text.count)/50" if !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { reasonWasBlurred = false } applyViewModel() } func textViewDidBeginEditing(_ textView: UITextView) { applyReasonInputStyle() revealFocusedInput() } func textViewDidEndEditing(_ textView: UITextView) { reasonWasBlurred = true applyReasonInputStyle() } }