// // AccountDeletionViewControllers.swift // suixinkan // import SnapKit import UIKit /// 注销账号资产核验页,中性展示账号资产与注销后果。 final class AccountDeletionViewController: BaseViewController { private let viewModel: AccountDeletionViewModel private let scrollView = UIScrollView() private let contentView = UIView() private let contentStack = UIStackView() private let warningView = AccountDeletionWarningView() private let assetCardView = AccountDeletionAssetCardView() private let detailButton = UIButton(type: .system) private let consequenceCardView = AccountDeletionConsequenceCardView() private let bottomPanel = UIView() private let acknowledgementButton = UIButton(type: .system) private let continueButton = AppButton(title: "继续注销", style: .primary) private var lastErrorMessage: String? /// 创建注销资产核验页。 init(viewModel: AccountDeletionViewModel = AccountDeletionViewModel()) { self.viewModel = viewModel 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 scrollView.alwaysBounceVertical = true scrollView.showsVerticalScrollIndicator = false contentStack.axis = .vertical contentStack.spacing = 16 contentStack.isLayoutMarginsRelativeArrangement = true contentStack.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) detailButton.setTitle("查看资产明细", for: .normal) detailButton.setTitleColor(AppColor.primary, for: .normal) detailButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) bottomPanel.backgroundColor = AppColor.pageBackground acknowledgementButton.contentHorizontalAlignment = .leading acknowledgementButton.tintColor = AppColor.primary acknowledgementButton.setTitleColor(AppColor.textPrimary, for: .normal) acknowledgementButton.titleLabel?.font = .systemFont(ofSize: 14) acknowledgementButton.titleLabel?.numberOfLines = 0 acknowledgementButton.setTitle(" 我已知晓并自愿放弃以上资产和权益", for: .normal) acknowledgementButton.accessibilityLabel = "我已知晓并自愿放弃以上资产和权益" view.addSubview(scrollView) scrollView.addSubview(contentView) contentView.addSubview(contentStack) view.addSubview(bottomPanel) bottomPanel.addSubview(acknowledgementButton) bottomPanel.addSubview(continueButton) [warningView, assetCardView, detailButton, consequenceCardView].forEach(contentStack.addArrangedSubview) applyViewModel() } override func setupConstraints() { bottomPanel.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } acknowledgementButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(8) make.leading.trailing.equalToSuperview().inset(16) make.height.greaterThanOrEqualTo(44) } continueButton.snp.makeConstraints { make in make.top.equalTo(acknowledgementButton.snp.bottom).offset(8) make.leading.trailing.equalToSuperview().inset(16) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) } scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.bottom.equalTo(bottomPanel.snp.top) } contentView.snp.makeConstraints { make in make.edges.equalToSuperview() make.width.equalTo(scrollView.snp.width) } contentStack.snp.makeConstraints { make in make.edges.equalToSuperview() } warningView.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(64) } detailButton.snp.makeConstraints { make in make.height.equalTo(36) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } acknowledgementButton.addTarget(self, action: #selector(acknowledgementTapped), for: .touchUpInside) continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside) detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside) } override func viewDidLoad() { super.viewDidLoad() Task { showLoading() await viewModel.load() hideLoading() } } private func applyViewModel() { assetCardView.configure(assets: viewModel.assets) consequenceCardView.configure(items: viewModel.consequences) acknowledgementButton.setImage( UIImage(systemName: viewModel.isAcknowledged ? "checkmark.square.fill" : "square"), for: .normal ) acknowledgementButton.accessibilityValue = viewModel.isAcknowledged ? "已勾选" : "未勾选" continueButton.isEnabled = viewModel.isContinueButtonEnabled if let errorMessage = viewModel.errorMessage, !errorMessage.isEmpty, errorMessage != lastErrorMessage { lastErrorMessage = errorMessage showToast(errorMessage) } } @objc private func acknowledgementTapped() { viewModel.toggleAcknowledgement() } @objc private func continueTapped() { do { let verificationViewModel = try viewModel.makeVerificationViewModel() navigationController?.pushViewController( AccountDeletionVerificationViewController(viewModel: verificationViewModel), animated: true ) } catch { showToast(error.localizedDescription) } } @objc private func detailTapped() { let message = viewModel.assets .map { "\($0.title):\($0.valueText)" } .joined(separator: "\n") showAlert(title: "当前账号资产", message: message) } } /// 注销手机号验证页,使用短信验证码确认是账号本人操作。 final class AccountDeletionVerificationViewController: BaseViewController { private let viewModel: AccountDeletionVerificationViewModel private let contentStack = UIStackView() private let iconView = UIImageView() private let titleLabel = UILabel() private let descriptionLabel = UILabel() private let codeContainer = UIView() private let codeField = UITextField() private let resendButton = UIButton(type: .system) private let demoHintLabel = UILabel() private let noticeView = AccountDeletionNoticeView() private let submitButton = AppButton(title: "提交注销申请", style: .primary) private var lastErrorMessage: String? /// 创建注销手机号验证页。 init(viewModel: AccountDeletionVerificationViewModel) { self.viewModel = viewModel 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 contentStack.axis = .vertical contentStack.alignment = .fill contentStack.spacing = 16 iconView.image = UIImage(systemName: "message.badge.filled.fill") iconView.tintColor = AppColor.primary iconView.contentMode = .scaleAspectFit titleLabel.text = "确认是你本人操作" titleLabel.font = .systemFont(ofSize: 22, weight: .semibold) titleLabel.textColor = AppColor.textPrimary titleLabel.textAlignment = .center descriptionLabel.text = "验证码将发送至 \(viewModel.context.maskedPhone)" descriptionLabel.font = .systemFont(ofSize: 15) descriptionLabel.textColor = AppColor.textSecondary descriptionLabel.textAlignment = .center codeContainer.backgroundColor = .white codeContainer.layer.cornerRadius = 12 codeContainer.layer.borderWidth = 1 codeContainer.layer.borderColor = AppColor.border.cgColor codeField.placeholder = "请输入6位验证码" codeField.font = .systemFont(ofSize: 18, weight: .medium) codeField.textColor = AppColor.textPrimary codeField.keyboardType = .numberPad codeField.textContentType = .oneTimeCode codeField.clearButtonMode = .whileEditing resendButton.setTitle("重新发送", for: .normal) resendButton.setTitleColor(AppColor.primary, for: .normal) resendButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) demoHintLabel.text = "演示验证码:123456" demoHintLabel.font = .systemFont(ofSize: 13) demoHintLabel.textColor = AppColor.warning demoHintLabel.textAlignment = .center noticeView.configure( iconName: "calendar.badge.exclamationmark", text: "提交成功后账号将退出,7天内再次登录可取消注销。" ) view.addSubview(contentStack) view.addSubview(submitButton) codeContainer.addSubview(codeField) codeContainer.addSubview(resendButton) [iconView, titleLabel, descriptionLabel, codeContainer, demoHintLabel, noticeView] .forEach(contentStack.addArrangedSubview) applyViewModel() } override func setupConstraints() { contentStack.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(32) make.leading.trailing.equalToSuperview().inset(24) } iconView.snp.makeConstraints { make in make.height.equalTo(64) } codeContainer.snp.makeConstraints { make in make.height.equalTo(56) } codeField.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.top.bottom.equalToSuperview() make.trailing.equalTo(resendButton.snp.leading).offset(-8) } resendButton.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-12) make.centerY.equalToSuperview() make.width.equalTo(76) make.height.equalTo(44) } noticeView.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(64) } submitButton.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(16) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } codeField.addTarget(self, action: #selector(codeChanged), for: .editingChanged) resendButton.addTarget(self, action: #selector(resendTapped), for: .touchUpInside) submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.sendVerificationCode() if viewModel.hasSentCode { showToast("验证码已发送") } } } private func applyViewModel() { if codeField.text != viewModel.verificationCode { codeField.text = viewModel.verificationCode } resendButton.isEnabled = !viewModel.isSendingCode && !viewModel.isSubmitting resendButton.setTitle(viewModel.isSendingCode ? "发送中" : "重新发送", for: .normal) submitButton.isEnabled = viewModel.isSubmitEnabled if let errorMessage = viewModel.errorMessage, !errorMessage.isEmpty, errorMessage != lastErrorMessage { lastErrorMessage = errorMessage showToast(errorMessage) } } @objc private func codeChanged() { viewModel.updateVerificationCode(codeField.text ?? "") } @objc private func resendTapped() { Task { await viewModel.sendVerificationCode() if viewModel.hasSentCode { showToast("验证码已重新发送") } } } @objc private func submitTapped() { let alert = UIAlertController( title: "确认提交注销申请?", message: "提交后账号将退出,7天内再次登录可以取消注销。", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "再想想", style: .cancel)) alert.addAction(UIAlertAction(title: "确认提交", style: .destructive) { [weak self] _ in self?.submitDeletion() }) present(alert, animated: true) } private func submitDeletion() { Task { showLoading() defer { hideLoading() } do { let request = try await viewModel.submit() navigationController?.pushViewController( AccountDeletionSuccessViewController(request: request), animated: true ) } catch is CancellationError { return } catch { showToast(error.localizedDescription) } } } } /// 注销申请提交成功页,明确展示计划删除时间和恢复方式。 final class AccountDeletionSuccessViewController: BaseViewController { private let request: AccountDeletionRequest private let iconView = UIImageView() private let titleLabel = UILabel() private let descriptionLabel = UILabel() private let dateCardView = UIView() private let dateTitleLabel = UILabel() private let dateValueLabel = UILabel() private let recoveryLabel = UILabel() private let exitButton = AppButton(title: "退出并返回登录页", style: .primary) /// 创建注销申请提交成功页。 init(request: AccountDeletionRequest) { self.request = request super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "注销账号" navigationItem.hidesBackButton = true } override func setupUI() { view.backgroundColor = AppColor.pageBackground iconView.image = UIImage(systemName: "checkmark.circle.fill") iconView.tintColor = AppColor.success iconView.contentMode = .scaleAspectFit titleLabel.text = "注销申请已提交" titleLabel.font = .systemFont(ofSize: 24, weight: .semibold) titleLabel.textColor = AppColor.textPrimary titleLabel.textAlignment = .center descriptionLabel.text = "账号已进入7天注销冷静期" descriptionLabel.font = .systemFont(ofSize: 15) descriptionLabel.textColor = AppColor.textSecondary descriptionLabel.textAlignment = .center dateCardView.backgroundColor = .white dateCardView.layer.cornerRadius = 12 dateTitleLabel.text = "计划永久注销时间" dateTitleLabel.font = .systemFont(ofSize: 14) dateTitleLabel.textColor = AppColor.textSecondary dateValueLabel.text = AccountDeletionDateFormatter.displayText(request.scheduledDeletionAt) dateValueLabel.font = .systemFont(ofSize: 20, weight: .semibold) dateValueLabel.textColor = AppColor.danger dateValueLabel.textAlignment = .right recoveryLabel.text = "在上述时间前再次使用当前手机号和密码登录,确认恢复账号后即可取消注销。" recoveryLabel.font = .systemFont(ofSize: 15) recoveryLabel.textColor = AppColor.textSecondary recoveryLabel.numberOfLines = 0 recoveryLabel.textAlignment = .center [iconView, titleLabel, descriptionLabel, dateCardView, recoveryLabel, exitButton].forEach(view.addSubview) dateCardView.addSubview(dateTitleLabel) dateCardView.addSubview(dateValueLabel) } override func setupConstraints() { iconView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(56) make.centerX.equalToSuperview() make.width.height.equalTo(72) } titleLabel.snp.makeConstraints { make in make.top.equalTo(iconView.snp.bottom).offset(24) make.leading.trailing.equalToSuperview().inset(24) } descriptionLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(10) make.leading.trailing.equalToSuperview().inset(24) } dateCardView.snp.makeConstraints { make in make.top.equalTo(descriptionLabel.snp.bottom).offset(32) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(72) } dateTitleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() } dateValueLabel.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-16) make.centerY.equalToSuperview() make.leading.greaterThanOrEqualTo(dateTitleLabel.snp.trailing).offset(12) } recoveryLabel.snp.makeConstraints { make in make.top.equalTo(dateCardView.snp.bottom).offset(24) make.leading.trailing.equalToSuperview().inset(32) } exitButton.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(16) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) } } override func bindActions() { exitButton.addTarget(self, action: #selector(exitTapped), for: .touchUpInside) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) navigationController?.interactivePopGestureRecognizer?.isEnabled = false } @objc private func exitTapped() { NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil) } } /// 注销页顶部风险提示条。 private final class AccountDeletionWarningView: UIView { private let iconView = UIImageView() private let label = UILabel() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = AppColor.dangerBackground layer.cornerRadius = 12 layer.borderWidth = 1 layer.borderColor = AppColor.danger.withAlphaComponent(0.25).cgColor iconView.image = UIImage(systemName: "exclamationmark.shield.fill") iconView.tintColor = AppColor.danger iconView.contentMode = .scaleAspectFit label.text = "注销后,账号及相关数据将永久删除" label.font = .systemFont(ofSize: 15, weight: .medium) label.textColor = AppColor.danger label.numberOfLines = 0 addSubview(iconView) addSubview(label) iconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() make.width.height.equalTo(28) } label.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(12) make.trailing.equalToSuperview().offset(-16) make.top.bottom.equalToSuperview().inset(14) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 注销资产汇总卡片,以列表形式中性展示名称和数量。 private final class AccountDeletionAssetCardView: UIView { private let stackView = UIStackView() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white layer.cornerRadius = 12 clipsToBounds = true stackView.axis = .vertical addSubview(stackView) stackView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 使用最新资产快照刷新卡片。 func configure(assets: [AccountDeletionAssetSummary]) { stackView.arrangedSubviews.forEach { stackView.removeArrangedSubview($0) $0.removeFromSuperview() } assets.enumerated().forEach { index, asset in stackView.addArrangedSubview( AccountDeletionAssetRowView(asset: asset, showsDivider: index < assets.count - 1) ) } } } /// 注销资产列表行,仅显示客观资产名称和当前值。 private final class AccountDeletionAssetRowView: UIView { private let titleLabel = UILabel() private let valueLabel = UILabel() private let divider = UIView() init(asset: AccountDeletionAssetSummary, showsDivider: Bool) { super.init(frame: .zero) titleLabel.text = asset.title titleLabel.font = .systemFont(ofSize: 17, weight: .medium) titleLabel.textColor = AppColor.textPrimary valueLabel.text = asset.valueText valueLabel.font = .systemFont(ofSize: 18, weight: .semibold) valueLabel.textColor = AppColor.textPrimary valueLabel.textAlignment = .right divider.backgroundColor = AppColor.border divider.isHidden = !showsDivider [titleLabel, valueLabel, divider].forEach(addSubview) snp.makeConstraints { make in make.height.equalTo(76) } titleLabel.snp.makeConstraints { make in make.leading.centerY.equalToSuperview() } valueLabel.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12) } divider.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() make.height.equalTo(0.5) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 注销影响说明卡片,展示账号解除、内容删除和7天恢复规则。 private final class AccountDeletionConsequenceCardView: UIView { private let titleLabel = UILabel() private let stackView = UIStackView() private let iconNames = [ "person.crop.circle.badge.xmark", "folder", "calendar.badge.exclamationmark", ] override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white layer.cornerRadius = 12 clipsToBounds = true titleLabel.text = "注销后将发生" titleLabel.font = .systemFont(ofSize: 18, weight: .semibold) titleLabel.textColor = AppColor.textPrimary stackView.axis = .vertical addSubview(titleLabel) addSubview(stackView) titleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(16) } stackView.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 8, right: 16)) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 使用最新注销影响文案刷新卡片。 func configure(items: [String]) { stackView.arrangedSubviews.forEach { stackView.removeArrangedSubview($0) $0.removeFromSuperview() } items.enumerated().forEach { index, text in stackView.addArrangedSubview( AccountDeletionConsequenceRowView( iconName: iconNames.indices.contains(index) ? iconNames[index] : "info.circle", text: text, showsDivider: index < items.count - 1 ) ) } } } /// 注销影响说明列表行。 private final class AccountDeletionConsequenceRowView: UIView { private let iconBackgroundView = UIView() private let iconView = UIImageView() private let label = UILabel() private let divider = UIView() init(iconName: String, text: String, showsDivider: Bool) { super.init(frame: .zero) iconBackgroundView.backgroundColor = AppColor.primaryLight iconBackgroundView.layer.cornerRadius = 18 iconView.image = UIImage(systemName: iconName) iconView.tintColor = AppColor.primary iconView.contentMode = .scaleAspectFit label.text = text label.font = .systemFont(ofSize: 14) label.textColor = AppColor.textPrimary label.numberOfLines = 0 divider.backgroundColor = AppColor.border divider.isHidden = !showsDivider addSubview(iconBackgroundView) iconBackgroundView.addSubview(iconView) addSubview(label) addSubview(divider) snp.makeConstraints { make in make.height.greaterThanOrEqualTo(58) } iconBackgroundView.snp.makeConstraints { make in make.leading.centerY.equalToSuperview() make.width.height.equalTo(36) } iconView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(8) } label.snp.makeConstraints { make in make.leading.equalTo(iconBackgroundView.snp.trailing).offset(12) make.trailing.equalToSuperview() make.centerY.equalToSuperview() } divider.snp.makeConstraints { make in make.leading.equalTo(label) make.trailing.bottom.equalToSuperview() make.height.equalTo(0.5) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 注销流程通用说明条。 private final class AccountDeletionNoticeView: UIView { private let iconView = UIImageView() private let label = UILabel() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = AppColor.infoBackground layer.cornerRadius = 12 addSubview(iconView) addSubview(label) iconView.tintColor = AppColor.primary iconView.contentMode = .scaleAspectFit label.font = .systemFont(ofSize: 14) label.textColor = AppColor.textSecondary label.numberOfLines = 0 iconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() make.width.height.equalTo(24) } label.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(12) make.trailing.equalToSuperview().offset(-16) make.top.bottom.equalToSuperview().inset(14) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 配置说明条图标与文案。 func configure(iconName: String, text: String) { iconView.image = UIImage(systemName: iconName) label.text = text } }