feat: 优化账号注销流程

This commit is contained in:
2026-08-31 15:56:16 +08:00
parent e529bb5942
commit 4444d328df
16 changed files with 982 additions and 208 deletions
+9
View File
@@ -32,6 +32,9 @@ enum NotificationName {
/// 当前门店身份受限或申请结果待核实;保留凭证转入只读状态查询。
static let storeAccountDeregistrationRestricted = name("storeAccountDeregistrationRestricted")
/// 当前门店身份的注销申请已被服务端明确受理;先清理登录态,再展示提交结果页。
static let storeAccountDeregistrationSubmitted = name("storeAccountDeregistrationSubmitted")
// MARK: - Scenic
/// 当前景区切换
@@ -74,6 +77,12 @@ enum NotificationUserInfoKey {
/// 产生注销限制错误的原请求凭证,仅在内存中匹配当前会话,禁止记录日志。
static let deregistrationRequestToken = "deregistrationRequestToken"
/// 注销申请提交时冻结的身份展示名称,不包含身份凭证。
static let deregistrationIdentityName = "deregistrationIdentityName"
/// 注销申请明确受理后查询到的服务端冷静期截止时间。
static let deregistrationCoolingUntil = "deregistrationCoolingUntil"
static let scenicId = "scenicId"
static let scenicName = "scenicName"
static let orderId = "orderId"
@@ -23,6 +23,8 @@ final class StoreAccountDeregistrationViewModel {
private(set) var submissionAttempted = false
/// 成功响应仅代表申请已提交,不代表账号注销完成。
private(set) var submissionAccepted = false
/// 申请明确受理后,以当前凭证只读查询到的冷静期截止时间;查询失败不改变受理结果。
private(set) var submittedCoolingUntil: String?
private let storeUserID: Int
private let submissionStore: (any StoreAccountDeregistrationSubmissionTracking)?
private var previousCancellationFingerprint: String?
@@ -179,20 +181,42 @@ final class StoreAccountDeregistrationViewModel {
} catch { invalidate(error); return false }
}
/// 校验输入后申请;服务端明确接受即交由页面退出,不再查询状态或推断正式完成。
/// 同步校验验证码与必填原因;失败时不启动查询、持久化或申请请求。
@discardableResult
func validateSubmissionInput(smsCode: String, reason: String) -> Bool {
let code = smsCode.trimmingCharacters(in: .whitespacesAndNewlines)
let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
if code.isEmpty {
errorMessage = "请输入收到的短信验证码"
return false
}
if reason.isEmpty {
errorMessage = "请输入注销原因"
return false
}
if reason.count > 50 {
errorMessage = "注销原因不能超过 50 个字"
return false
}
errorMessage = nil
return true
}
/// 校验输入后申请;服务端明确接受后只读查询一次冷静期截止时间,再交由页面退出。
func submit(smsCode: String, reason: String, api: any StoreAccountDeregistrationServing) async {
guard canContinue, step == .verification else { return }
let code = smsCode.trimmingCharacters(in: .whitespacesAndNewlines)
let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
guard !code.isEmpty, !reason.isEmpty else {
errorMessage = "请输入收到的短信验证码和注销原因"
return
}
guard validateSubmissionInput(smsCode: code, reason: reason) else { return }
var verifiedEligibility: StoreAccountDeregistrationEligibility?
var verifiedStatus: StoreAccountDeregistrationStatus?
isBusy = true
errorMessage = nil
defer { isBusy = false }
do {
try await checkReady(api: api)
verifiedEligibility = eligibility
verifiedStatus = status
previousCancellationFingerprint = status?.cancellationFingerprint
try submissionStore?.recordSubmissionIntent(previousStatus: status)
submissionAttempted = true
@@ -200,7 +224,21 @@ final class StoreAccountDeregistrationViewModel {
eligibility = nil
try await api.apply(smsCode: code, reason: reason)
submissionAccepted = true
// 申请成功已经成立;状态查询只用于结果页展示,失败或尚未进入冷静期都不能推翻成功响应。
if let acceptedStatus = try? await api.status(), acceptedStatus.isCooling {
status = acceptedStatus
submittedCoolingUntil = acceptedStatus.coolingUntil
}
} catch {
if !submissionAccepted, isVerificationCodeRejection(error) {
submissionStore?.clearRejectedSubmission()
submissionAttempted = false
eligibility = verifiedEligibility
status = verifiedStatus
step = .verification
errorMessage = error.localizedDescription
return
}
// 业务码明确拒绝申请时可重新核验;网络/解码错误不能证明服务端没有收到申请。
if !submissionAccepted, case APIError.serverCode = error {
submissionStore?.clearRejectedSubmission()
@@ -211,6 +249,14 @@ final class StoreAccountDeregistrationViewModel {
}
}
/// 旧接口没有独立错误码,验证码拒绝只能按服务端业务提示识别。
private func isVerificationCodeRejection(_ error: Error) -> Bool {
guard case let APIError.serverCode(_, message) = error else { return false }
return message.localizedCaseInsensitiveContains("验证码")
|| message.localizedCaseInsensitiveContains("verification code")
|| message.localizedCaseInsensitiveContains("sms code")
}
private func checkReady(api: any StoreAccountDeregistrationServing) async throws {
try await reload(api: api)
guard status?.permitsPreparation == true, eligibility?.permitsApplication == true else {
+31 -1
View File
@@ -91,6 +91,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
self, selector: #selector(handleStoreDeregistrationRestriction(_:)),
name: NotificationName.storeAccountDeregistrationRestricted, object: nil
)
NotificationCenter.default.addObserver(
self, selector: #selector(handleStoreDeregistrationSubmitted(_:)),
name: NotificationName.storeAccountDeregistrationSubmitted, object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleSessionDidExpire),
@@ -136,18 +140,44 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
private func transitionToLogin() {
clearAuthenticatedSession()
AppRouter.setRoot(.login, on: window)
}
/// 统一清除当前会话及依赖登录态的后台能力,不执行页面跳转。
private func clearAuthenticatedSession() {
deregistrationCoordinator?.cancelPendingCheck()
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
GlobalLoadingManager.shared.hideAll()
PushNotificationManager.shared.handleLogout()
PushNotificationManager.shared.setAccountBindingSuspended(false)
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window)
}
@objc private func handleUserDidLogout() {
transitionToLogin()
}
/// 申请明确受理后先退出,再展示只含“完成”的结果页;结果页不再依赖已清除的凭证。
@objc private func handleStoreDeregistrationSubmitted(_ notification: Notification) {
let identityName = notification.userInfo?[NotificationUserInfoKey.deregistrationIdentityName] as? String
?? "当前门店身份"
let coolingUntil = notification.userInfo?[NotificationUserInfoKey.deregistrationCoolingUntil] as? String
clearAuthenticatedSession()
guard !AppStore.shared.session.isLoggedIn else {
AppRouter.setRoot(.login, on: window)
return
}
let controller = StoreAccountDeregistrationSubmittedViewController(
identityName: identityName,
coolingUntil: coolingUntil
) { [weak self] in
AppRouter.setRoot(.login, on: self?.window)
}
AppRouter.setRoot(UINavigationController(rootViewController: controller), on: window)
}
@objc private func handleUserDidLogin() {
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
@@ -18,17 +18,19 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
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 stateIcon = Style.icon("lock", size: 36)
private let stateIconBadge = UIView()
private let deadlineLabel = Style.label(size: 19, weight: .semibold)
private let warningLabel = Style.label(size: 13, color: AppColor.textSecondary)
private let infoLabel = Style.label("重新登录该身份自动撤销申请", size: 13, color: Style.textSecondary)
private var deadlineCard = UIView()
private var infoCard = 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
private var previousPopGestureEnabled: Bool?
/// 显式注入会话与 API;身份构造失败时仍展示错误,不自动退出或重新登录。
init(identity: StoreAccountDeregistrationIdentity?, api: (any StoreAccountDeregistrationServing)?,
@@ -53,10 +55,11 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
override func setupNavigationBar() {
title = "注销账号"
navigationItem.hidesBackButton = true
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
view.backgroundColor = Style.pageBackground
view.addSubview(scrollView)
scrollView.alwaysBounceVertical = true
refreshControl.accessibilityIdentifier = "deregister.access.refresh"
@@ -66,47 +69,55 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
scrollView.refreshControl = refreshControl
scrollView.addSubview(stack)
stack.axis = .vertical
stack.spacing = 20
stateIcon.snp.makeConstraints { $0.height.equalTo(64) }
stack.spacing = 18
stateIconBadge.backgroundColor = Style.infoBackground
stateIconBadge.layer.cornerRadius = 44
stateIconBadge.addSubview(stateIcon)
stateIconBadge.snp.makeConstraints { $0.width.height.equalTo(88) }
stateIcon.snp.makeConstraints { $0.center.equalToSuperview(); $0.width.height.equalTo(44) }
titleLabel.textAlignment = .center
messageLabel.numberOfLines = 0
messageLabel.font = .systemFont(ofSize: 15)
messageLabel.textColor = AppColor.textSecondary
messageLabel.textColor = Style.textSecondary
messageLabel.textAlignment = .center
let hero = Style.stack([stateIcon, titleLabel, messageLabel], spacing: 12)
let iconRow = UIView()
iconRow.addSubview(stateIconBadge)
stateIconBadge.snp.makeConstraints { $0.top.bottom.centerX.equalToSuperview() }
let hero = Style.stack([iconRow, 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))
let deadlineIcon = Style.icon("calendar", size: 22)
deadlineIcon.snp.makeConstraints { $0.width.equalTo(28) }
let deadlineText = Style.stack([
Style.label("冷静期截止时间", size: 13, color: Style.textSecondary), deadlineLabel
], spacing: 8)
let deadlineRow = UIStackView(arrangedSubviews: [deadlineIcon, deadlineText])
deadlineRow.axis = .horizontal
deadlineRow.alignment = .center
deadlineRow.spacing = 12
deadlineCard = Style.card(deadlineRow)
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)
let infoIcon = Style.icon("info.circle", size: 17)
infoIcon.snp.makeConstraints { $0.width.equalTo(22) }
let infoRow = UIStackView(arrangedSubviews: [infoIcon, infoLabel])
infoRow.axis = .horizontal
infoRow.alignment = .center
infoRow.spacing = 8
infoCard = Style.card(infoRow, background: Style.infoBackground)
stack.addArrangedSubview(infoCard)
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)
let actions = Style.stack([retryButton, logoutButton, cancelButton], spacing: 12)
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"
@@ -114,7 +125,6 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
retryButton.accessibilityIdentifier = "deregister.access.retry"
cancelButton.accessibilityIdentifier = "deregister.access.cancel"
logoutButton.accessibilityIdentifier = "deregister.access.logout"
conditionsButton.accessibilityIdentifier = "deregister.access.conditions"
}
override func setupConstraints() {
@@ -138,6 +148,20 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
if hasInitialResult { applyViewModel() } else { retryTapped() }
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
previousPopGestureEnabled = navigationController?.interactivePopGestureRecognizer?.isEnabled
navigationItem.hidesBackButton = true
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if let previousPopGestureEnabled {
navigationController?.interactivePopGestureRecognizer?.isEnabled = previousPopGestureEnabled
}
}
/// 在途旧查询不能覆盖刚收到的受限信号;之后由用户主动重新查询。
func recordRestriction() {
viewModel.recordRestriction()
@@ -164,15 +188,13 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
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
infoCard.isHidden = !cooling
deadlineLabel.text = viewModel.status?.coolingUntil ?? "—"
guard identity != nil, api != nil else {
titleLabel.text = "暂时无法查询"
messageLabel.text = "当前身份信息不完整,请联系管理员。"
@@ -190,9 +212,8 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
symbol = "checkmark.circle"
case .cooling:
titleLabel.text = "注销申请已提交"
messageLabel.text = (viewModel.status?.remainingSeconds ?? 0) > 0
? "当前处于冷静期,期间暂停普通业务。\n你仍可以撤销申请,恢复使用。"
: "冷静期已结束,正在等待服务端复核。\n最终结果请刷新后查看。"
messageLabel.text = "当前身份:\(identity?.displayName ?? "当前身份")"
symbol = "lock"
case .unresolved:
titleLabel.text = "注销状态待确认"
messageLabel.text = "暂时未获取到明确结果,请刷新重试。\n请勿重复提交;如持续出现,请联系管理员。"
@@ -208,6 +229,8 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
}
stateIcon.image = UIImage(systemName: symbol,
withConfiguration: UIImage.SymbolConfiguration(pointSize: 36, weight: .medium))
navigationItem.hidesBackButton = true
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
@objc private func cancelTapped() {
@@ -263,20 +286,9 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
}
}
@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)
"退出本身不会撤销申请,但会清除本机登录凭证。再次登录或选中此身份会自动撤销尚未完成的申请。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "继续保留查询", style: .cancel))
alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { _ in
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
@@ -4,13 +4,22 @@ import UIKit
/// 注销页面共用的轻量视觉组件,仅负责颜色、字号和布局,不包含业务状态。
@MainActor
enum StoreAccountDeregistrationStyle {
/// 注销流程独立视觉令牌,与设计稿保持一致且不修改全局主题。
static let primary = UIColor(hex: 0x1677FF)
static let pageBackground = UIColor(hex: 0xF5F7FA)
static let textPrimary = UIColor(hex: 0x172033)
static let textSecondary = UIColor(hex: 0x7A8496)
static let border = UIColor(hex: 0xE7ECF3)
static let infoBackground = UIColor(hex: 0xEEF5FF)
static let danger = UIColor(hex: 0xE5484D)
/// 创建支持多行的系统字体标签。
static func label(_ text: String = "", size: CGFloat = 15, weight: UIFont.Weight = .regular,
color: UIColor = AppColor.textPrimary) -> UILabel {
color: UIColor? = nil) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: size, weight: weight)
label.textColor = color
label.textColor = color ?? textPrimary
label.numberOfLines = 0
return label
}
@@ -24,10 +33,12 @@ enum StoreAccountDeregistrationStyle {
}
/// 白色圆角卡片,内容由页面提供。
static func card(_ content: UIView) -> UIView {
static func card(_ content: UIView, background: UIColor = .white) -> UIView {
let card = UIView()
card.backgroundColor = .white
card.backgroundColor = background
card.layer.cornerRadius = 16
card.layer.borderWidth = background == .white ? 0.5 : 0
card.layer.borderColor = border.cgColor
card.addSubview(content)
content.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
return card
@@ -46,9 +57,13 @@ enum StoreAccountDeregistrationStyle {
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.baseBackgroundColor = StoreAccountDeregistrationStyle.primary
config.baseForegroundColor = primary ? .white : StoreAccountDeregistrationStyle.primary
config.background.cornerRadius = 12
if !primary {
config.background.strokeColor = StoreAccountDeregistrationStyle.primary
config.background.strokeWidth = 1
}
config.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
@@ -60,12 +75,12 @@ enum StoreAccountDeregistrationStyle {
let enabled = button.isEnabled
var updated = button.configuration
updated?.background.backgroundColorTransformer = UIConfigurationColorTransformer { _ in
primary ? (enabled ? AppColor.primary : AppColor.primary.withAlphaComponent(0.12)) : .clear
primary ? (enabled ? StoreAccountDeregistrationStyle.primary : StoreAccountDeregistrationStyle.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
attributes.foregroundColor = enabled ? (primary ? .white : StoreAccountDeregistrationStyle.primary) : StoreAccountDeregistrationStyle.textSecondary
return attributes
}
button.configuration = updated
@@ -76,9 +91,174 @@ enum StoreAccountDeregistrationStyle {
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.tintColor = primary
image.contentMode = .scaleAspectFit
image.setContentHuggingPriority(.required, for: .horizontal)
return image
}
/// 带浅色圆形底的状态图标,用于冷静期和身份信息强调。
static func iconBadge(_ name: String, iconSize: CGFloat = 30, diameter: CGFloat = 88) -> UIView {
let container = UIView()
container.backgroundColor = infoBackground
container.layer.cornerRadius = diameter / 2
let image = icon(name, size: iconSize)
container.addSubview(image)
container.snp.makeConstraints { $0.width.height.equalTo(diameter) }
image.snp.makeConstraints { $0.center.equalToSuperview(); $0.width.height.equalTo(iconSize + 8) }
return container
}
/// 为危险确认操作应用红色实心按钮样式。
static func configureDestructive(_ button: UIButton, title: String) {
var config = UIButton.Configuration.filled()
config.title = title
config.baseBackgroundColor = danger
config.baseForegroundColor = .white
config.background.cornerRadius = 12
config.contentInsets = .init(top: 13, leading: 16, bottom: 13, trailing: 16)
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
return attributes
}
button.configuration = config
}
}
/// 注销资产及最终提交共用的高保真底部确认弹层。
@MainActor
final class StoreAccountDeregistrationConfirmationSheetViewController: UIViewController {
/// 弹层展示的一行摘要数据。
struct Summary {
let icon: String
let title: String
let value: String
}
private typealias Style = StoreAccountDeregistrationStyle
private let sheetTitle: String
private let summaries: [Summary]
private let notices: [(icon: String, text: String, danger: Bool)]
private let cancelTitle: String
private let confirmTitle: String
private let onConfirm: () -> Void
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private let contentStack = UIStackView()
/// 创建只在用户明确确认后回调的弹层;下拉或取消不会触发业务请求。
init(title: String, summaries: [Summary], notices: [(String, String, Bool)],
cancelTitle: String, confirmTitle: String, onConfirm: @escaping () -> Void) {
sheetTitle = title
self.summaries = summaries
self.notices = notices
self.cancelTitle = cancelTitle
self.confirmTitle = confirmTitle
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
let titleLabel = Style.label(sheetTitle, size: 20, weight: .semibold)
titleLabel.textAlignment = .center
let summaryStack = Style.stack([], spacing: 0)
for (index, summary) in summaries.enumerated() {
let icon = Style.icon(summary.icon, size: 18)
icon.snp.makeConstraints { $0.width.equalTo(24) }
let title = Style.label(summary.title, size: 14, color: Style.textSecondary)
let value = Style.label(summary.value, size: 15, weight: .semibold)
value.textAlignment = .right
let row = UIStackView(arrangedSubviews: [icon, title, UIView(), value])
row.axis = .horizontal
row.alignment = .center
row.spacing = 8
row.snp.makeConstraints { $0.height.greaterThanOrEqualTo(48) }
summaryStack.addArrangedSubview(row)
if index < summaries.count - 1 {
let divider = UIView()
divider.backgroundColor = Style.border
divider.snp.makeConstraints { $0.height.equalTo(0.5) }
summaryStack.addArrangedSubview(divider)
}
}
let summaryCard = Style.card(summaryStack)
summaryCard.layer.cornerRadius = 12
let noticeStack = Style.stack([], spacing: 12)
for notice in notices {
let icon = Style.icon(notice.icon, size: 16)
icon.tintColor = notice.danger ? Style.danger : Style.primary
icon.snp.makeConstraints { $0.width.equalTo(22) }
let text = Style.label(notice.text, size: 13,
color: notice.danger ? Style.danger : Style.textSecondary)
let row = UIStackView(arrangedSubviews: [icon, text])
row.axis = .horizontal
row.alignment = .top
row.spacing = 8
noticeStack.addArrangedSubview(row)
}
Style.configure(cancelButton, title: cancelTitle, primary: false)
Style.configureDestructive(confirmButton, title: confirmTitle)
cancelButton.accessibilityIdentifier = "deregister.sheet.cancel"
confirmButton.accessibilityIdentifier = "deregister.sheet.confirm"
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let actions = UIStackView(arrangedSubviews: [cancelButton, confirmButton])
actions.axis = .horizontal
actions.distribution = .fillEqually
actions.spacing = 12
actions.snp.makeConstraints { $0.height.equalTo(50) }
contentStack.axis = .vertical
contentStack.spacing = 16
contentStack.addArrangedSubview(titleLabel)
contentStack.addArrangedSubview(summaryCard)
if !notices.isEmpty { contentStack.addArrangedSubview(noticeStack) }
contentStack.addArrangedSubview(actions)
view.addSubview(contentStack)
contentStack.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide).offset(28)
$0.leading.trailing.equalToSuperview().inset(16)
$0.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).inset(12)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard let sheet = sheetPresentationController else { return }
view.layoutIfNeeded()
let sheetWidth = presentingViewController?.view.bounds.width ?? view.bounds.width
let contentWidth = max(0, sheetWidth - 32)
let contentHeight = contentStack.systemLayoutSizeFitting(
CGSize(width: contentWidth, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
).height
// 自定义 detent 会由系统计入底部安全区,这里只计算内容和视觉间距,避免重复留白。
let preferredHeight = ceil(28 + contentHeight + 12)
let identifier = UISheetPresentationController.Detent.Identifier("accountDeregistrationConfirmation")
sheet.detents = [.custom(identifier: identifier) { context in
min(preferredHeight, context.maximumDetentValue)
}]
sheet.selectedDetentIdentifier = identifier
sheet.prefersGrabberVisible = true
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
sheet.preferredCornerRadius = 22
preferredContentSize.height = preferredHeight
}
@objc private func cancelTapped() { dismiss(animated: true) }
@objc private func confirmTapped() {
confirmButton.isEnabled = false
dismiss(animated: true) { [onConfirm] in onConfirm() }
}
}
@@ -0,0 +1,118 @@
import SnapKit
import UIKit
/// 注销申请明确受理后的退出态结果页;不持有会话、凭证或注销接口。
@MainActor
final class StoreAccountDeregistrationSubmittedViewController: BaseViewController {
private typealias Style = StoreAccountDeregistrationStyle
private let identityName: String
private let coolingUntil: String?
private let onDone: () -> Void
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let bottomBar = UIView()
private let doneButton = Style.button("完成", id: "deregister.submitted.done")
private var didFinish = false
/// 使用提交时冻结的展示信息创建页面;点击完成后由 Scene 进入登录页。
init(identityName: String, coolingUntil: String?, onDone: @escaping () -> Void) {
let normalizedName = identityName.trimmingCharacters(in: .whitespacesAndNewlines)
self.identityName = normalizedName.isEmpty ? "当前门店身份" : normalizedName
self.coolingUntil = coolingUntil?.trimmingCharacters(in: .whitespacesAndNewlines)
self.onDone = onDone
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 = Style.pageBackground
view.addSubview(scrollView)
scrollView.alwaysBounceVertical = true
scrollView.addSubview(contentStack)
contentStack.axis = .vertical
contentStack.spacing = 18
let badge = Style.iconBadge("lock", iconSize: 36)
let badgeRow = UIView()
badgeRow.addSubview(badge)
badge.snp.makeConstraints { $0.top.bottom.centerX.equalToSuperview() }
let titleLabel = Style.label("注销申请已提交", size: 24, weight: .semibold)
titleLabel.textAlignment = .center
titleLabel.accessibilityIdentifier = "deregister.submitted.title"
let identityLabel = Style.label("当前身份:\(identityName)", size: 15, color: Style.textSecondary)
identityLabel.textAlignment = .center
identityLabel.accessibilityIdentifier = "deregister.submitted.identity"
contentStack.addArrangedSubview(Style.stack([badgeRow, titleLabel, identityLabel], spacing: 12))
let deadlineIcon = Style.icon("calendar", size: 22)
deadlineIcon.snp.makeConstraints { $0.width.equalTo(28) }
let deadlineTextValue = coolingUntil.flatMap { $0.isEmpty ? nil : $0 } ?? "以服务端状态为准"
let deadlineLabel = Style.label(deadlineTextValue, size: 19, weight: .semibold)
deadlineLabel.accessibilityIdentifier = "deregister.submitted.deadline"
let deadlineText = Style.stack([
Style.label("冷静期截止时间", size: 13, color: Style.textSecondary),
deadlineLabel
], spacing: 8)
let deadlineRow = UIStackView(arrangedSubviews: [deadlineIcon, deadlineText])
deadlineRow.axis = .horizontal
deadlineRow.alignment = .center
deadlineRow.spacing = 12
contentStack.addArrangedSubview(Style.card(deadlineRow))
let infoIcon = Style.icon("info.circle", size: 17)
infoIcon.snp.makeConstraints { $0.width.equalTo(22) }
let infoLabel = Style.label("重新登录该身份自动撤销申请", size: 13, color: Style.textSecondary)
let infoRow = UIStackView(arrangedSubviews: [infoIcon, infoLabel])
infoRow.axis = .horizontal
infoRow.alignment = .center
infoRow.spacing = 8
contentStack.addArrangedSubview(Style.card(infoRow, background: Style.infoBackground))
view.addSubview(bottomBar)
bottomBar.backgroundColor = .white
bottomBar.addSubview(doneButton)
doneButton.addTarget(self, action: #selector(doneTapped), for: .touchUpInside)
doneButton.snp.makeConstraints {
$0.top.equalToSuperview().offset(12)
$0.leading.trailing.equalToSuperview().inset(16)
$0.bottom.equalToSuperview().inset(12)
}
}
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)
}
contentStack.snp.makeConstraints {
$0.top.equalTo(scrollView.contentLayoutGuide).offset(32)
$0.leading.trailing.bottom.equalTo(scrollView.contentLayoutGuide).inset(16)
$0.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationItem.hidesBackButton = true
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
@objc private func doneTapped() {
guard !didFinish else { return }
didFinish = true
doneButton.isEnabled = false
onDone()
}
}
@@ -12,54 +12,70 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
private let identityName: String
private let readOnly: Bool
private let onUnresolvedSubmission: (() -> Void)?
private let onSubmissionAccepted: () -> 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 firstStep = Style.label("1 确认资产", size: 13, weight: .semibold)
private let secondStep = Style.label("2 手机验证", size: 13, weight: .semibold)
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: AppColor.textSecondary)
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: 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 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: AppColor.textSecondary)
private let errorLabel = Style.label(size: 14, color: AppColor.danger)
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 = 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 let backButton = UIButton(type: .system)
private let footerHint = Style.label(size: 12, color: AppColor.textSecondary)
private var identityCard = UIView()
private var blockersCard = UIView()
private var errorCard = UIView()
private var actionTask: Task<Void, Never>?
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: @escaping () -> Void = {
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: 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
self.onSubmissionAccepted = onSubmissionAccepted
super.init(nibName: nil, bundle: nil)
}
@@ -71,7 +87,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
view.backgroundColor = Style.pageBackground
view.addSubview(scrollView)
refreshControl.accessibilityIdentifier = "deregister.refresh"
refreshControl.accessibilityLabel = "下拉刷新"
@@ -83,20 +99,28 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
content.spacing = 16
content.addArrangedSubview(steps)
steps.axis = .horizontal
steps.alignment = .fill
steps.distribution = .fillEqually
steps.addArrangedSubview(firstStep)
steps.addArrangedSubview(secondStep)
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: AppColor.textSecondary),
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
content.addArrangedSubview(Style.card(identityRow))
identityCard = Style.card(identityRow)
content.addArrangedSubview(identityCard)
conditionStack.axis = .vertical
conditionStack.spacing = 16
verificationStack.axis = .vertical
@@ -106,7 +130,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
content.addArrangedSubview(conditionStack)
content.addArrangedSubview(verificationStack)
errorCard = Style.card(errorLabel)
errorCard.backgroundColor = AppColor.dangerBackground
errorCard.backgroundColor = UIColor(hex: 0xFFF1F1)
content.addArrangedSubview(errorCard)
content.addArrangedSubview(statusLabel)
statusLabel.accessibilityIdentifier = "deregister.status"
@@ -114,8 +138,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
view.addSubview(bottomBar)
bottomBar.backgroundColor = .white
footerHint.textAlignment = .center
let actions = Style.stack([continueButton, submitButton, footerHint], spacing: 10)
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
@@ -125,17 +148,45 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
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 = .fillEqually
columns.distribution = .fill
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))
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
@@ -155,23 +206,19 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
]))
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: "历史订单、财务及审计记录按规则保留。")
notice("person.crop.circle", title: "仅注销当前身份"),
notice("clock", title: "7 天冷静期"),
notice("exclamationmark.shield", title: "正式完成后不可恢复")
], spacing: 16)
conditionStack.addArrangedSubview(Style.card(notices))
conditionStack.addArrangedSubview(Style.card(notices, background: Style.infoBackground))
}
private func notice(_ icon: String, title: String, detail: String) -> UIView {
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.stack([
Style.label(title, size: 14, weight: .medium),
Style.label(detail, size: 13, color: AppColor.textSecondary)
], spacing: 4)])
let row = UIStackView(arrangedSubviews: [image, Style.label(title, size: 14, weight: .medium)])
row.axis = .horizontal
row.alignment = .top
row.alignment = .center
row.spacing = 10
return row
}
@@ -182,18 +229,22 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
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) }
}
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"
@@ -203,20 +254,50 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
smsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
let codeRow = UIStackView(arrangedSubviews: [codeField, smsButton])
codeRow.axis = .horizontal
codeRow.alignment = .center
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)
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() {
@@ -262,7 +343,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
}
@objc private func revealFocusedInput() {
guard let field = [codeField, reasonField].first(where: \.isFirstResponder) else { return }
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)
}
@@ -273,8 +354,9 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
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
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
@@ -284,10 +366,9 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
submitButton.isEnabled = !busy && viewModel.canContinue && hasVerificationInput
smsButton.isEnabled = !busy && viewModel.canContinue
codeField.isEnabled = !busy
reasonField.isEnabled = !busy
backButton.isEnabled = !busy
reasonField.isEditable = !busy
applyReasonInputStyle()
refreshControl.isEnabled = !busy
footerHint.text = verifying ? "7 天内可撤销,正式完成后不可恢复" : "请确认资产及注销须知后继续"
errorLabel.text = viewModel.errorMessage
errorCard.isHidden = viewModel.errorMessage == nil
@@ -296,13 +377,13 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
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
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 ? "以当前查询结果为准,冷静期内不能再次确认资产。"
: (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)" }
@@ -312,6 +393,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
pointsAmount.text = "—"
walletState.text = "待查询"
pointsState.text = "待查询"
assetHint.isHidden = false
assetHint.text = busy ? "正在查询资产与注销条件…" : "暂未获取到资产,请刷新重试。"
blockersCard.isHidden = true
}
@@ -325,9 +407,27 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
}
}
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 {
!(codeField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !(reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
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) {
@@ -344,7 +444,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
self.actionTask = nil
if !self.readOnly, self.viewModel.submissionAccepted {
self.didHandleAcceptedSubmission = true
self.onSubmissionAccepted()
self.onSubmissionAccepted(self.viewModel.submittedCoolingUntil)
return
}
self.applyViewModel()
@@ -364,20 +464,20 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
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
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(alert, animated: true)
}
@objc private func backTapped() {
view.endEditing(true)
viewModel.returnToConditions()
applyViewModel()
}
present(sheet, animated: true)
}
@objc private func smsTapped() {
@@ -390,20 +490,58 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
@objc private func submitTapped() {
guard !readOnly, viewModel.canContinue, hasVerificationInput else { return }
let code = codeField.text ?? ""
let reason = reasonField.text ?? ""
let reason = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
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
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(alert, animated: true)
}
present(sheet, animated: true)
}
/// 用户确认最终弹窗后提交;仅明确成功触发一次退出,未知结果仍进入核验流程。
/// 用户确认最终弹窗后提交;仅明确成功触发一次退出态结果页,未知结果仍进入核验流程。
func submitConfirmedApplication(smsCode: String, reason: String) {
guard !readOnly else { return }
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()
}
}