diff --git a/docs/account-deregistration/account-deregistration-redesign-v2.png b/docs/account-deregistration/account-deregistration-redesign-v2.png new file mode 100644 index 0000000..9873b18 Binary files /dev/null and b/docs/account-deregistration/account-deregistration-redesign-v2.png differ diff --git a/docs/account-deregistration/account-deregistration-redesign-v3.png b/docs/account-deregistration/account-deregistration-redesign-v3.png new file mode 100644 index 0000000..1ad9e45 Binary files /dev/null and b/docs/account-deregistration/account-deregistration-redesign-v3.png differ diff --git a/docs/account-deregistration/account-deregistration-redesign-v4.png b/docs/account-deregistration/account-deregistration-redesign-v4.png new file mode 100644 index 0000000..6f57725 Binary files /dev/null and b/docs/account-deregistration/account-deregistration-redesign-v4.png differ diff --git a/docs/account-deregistration/account-deregistration-redesign.png b/docs/account-deregistration/account-deregistration-redesign.png new file mode 100644 index 0000000..f0df1c0 Binary files /dev/null and b/docs/account-deregistration/account-deregistration-redesign.png differ diff --git a/docs/周报-2026-08-24至2026-08-28.md b/docs/周报-2026-08-24至2026-08-28.md new file mode 100644 index 0000000..4c9bb6f --- /dev/null +++ b/docs/周报-2026-08-24至2026-08-28.md @@ -0,0 +1,51 @@ +# 工作周报(2026.08.24—2026.08.28) + +## 本周概况 + +本周围绕随心瞰商家版 iOS 的线下收款、旅拍相册自动修图和门店身份注销开展开发与优化。已完成 **6 次提交**;门店身份注销相关改动仍在工作区,尚未提交,完整终态验收待补齐。 + +## 本周完成 + +### 1. 完成线下收款登记与日清功能开发 + +- 新增线下收款首页卡片、收款登记及日清页面,接入统计、登记、按日明细和补缴接口。 +- 支持按日期查看收款与待补缴情况,完善金额校验及快速切换日期时的旧请求结果隔离。 +- 优化周/月日历切换、翻页、选中态及未来日期限制,调整收款方式图标和明细展示。 +- 补充接口调整说明及金额、日历边界、请求参数和页面相关测试。 + +### 2. 新增相册自动修图与 OTG 状态预览 + +- 支持相册级自动修图配置、精修模板选择和传输模式选择,上传完成后自动提交修图任务。 +- 在 OTG 页面区分上传与修图状态,增加修图状态角标、精修结果预览及失败重试。 +- 优化自动修图设置页,支持同页切换修图方式、三列模板展示和效果对比预览,保留草稿选择及滚动位置。 +- 补充配置、任务状态和预览交互测试,并完善 AI 重修占位图资源配置。 + +## 进行中:门店身份注销 + +- 已接入现有门店身份注销接口,完成“确认资产 → 手机验证”两步流程,以及条件查询、短信验证、申请和撤销逻辑;注销范围仅限当前门店身份。 +- 已实现申请明确成功后退出登录、下次登录取得身份凭证后核验状态,以及受限状态下的业务访问和推送跳转控制。 +- 完善身份切换与旧响应隔离、异常状态保护、下拉刷新及统一 Loading,移除普通登录和切换身份时无条件出现的注销提示。 +- 已在测试环境验证零资产确认、短信申请、冷静期查询和主动撤销流程,并整理接入说明及脱敏接口记录;正式注销完成、终审阻断及真实多端场景尚未完成验收。 + +## 测试与质量 + +- 根据本周已保存的 iPhone 11 真机回归记录,最新全量测试共 **821 项,811 项通过、10 项失败、0 项跳过**,全量尚未全部通过。 +- 接入记录显示,**98 项注销相关测试全部通过**,失败用例集合与上一轮一致;这些 Mock 测试不替代真实终态验收。 +- 现存失败涉及部分接口解码、头像裁剪和页面布局断言,需继续排查修复。 + +## 风险与待验证项 + +- 门店注销仍缺少正式完成、终审阻断的完整接口响应及终态鉴权依据,暂不能按完整功能验收。 +- 非零资产、确认过期、错误验证码、重复申请及多端状态变化仍需补充真实场景验证。 +- 线下收款和自动修图需继续进行真实数据联调,重点检查补缴统计一致性、弱网重试及前后台恢复。 + +## 下周计划 + +- 补齐门店身份注销终态接口契约,推进完整流程与多身份场景验收。 +- 排查并修复现有 10 项测试失败,完成受影响模块的真机回归。 +- 联调线下收款登记、日清补缴及自动修图全流程,根据反馈完善交互和异常处理。 +- 整理未提交改动、测试证据及交付文档,推进本轮功能验收。 + +--- + +整理依据:本周 Git 提交、当前工作区改动、门店身份注销接入记录及已保存的真机测试摘要;本次周报整理未重新运行测试。 diff --git a/suixinkan/Common/NotificationName.swift b/suixinkan/Common/NotificationName.swift index 89f6443..d4ba9db 100644 --- a/suixinkan/Common/NotificationName.swift +++ b/suixinkan/Common/NotificationName.swift @@ -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" diff --git a/suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationViewModel.swift b/suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationViewModel.swift index 0575a3c..cdd1ee0 100644 --- a/suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationViewModel.swift +++ b/suixinkan/Features/AccountDeletion/ViewModels/StoreAccountDeregistrationViewModel.swift @@ -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 { diff --git a/suixinkan/SceneDelegate.swift b/suixinkan/SceneDelegate.swift index f992d1c..c4e629f 100644 --- a/suixinkan/SceneDelegate.swift +++ b/suixinkan/SceneDelegate.swift @@ -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 diff --git a/suixinkan/UI/Setting/StoreAccountDeregistrationAccessViewController.swift b/suixinkan/UI/Setting/StoreAccountDeregistrationAccessViewController.swift index 91bf8d2..04bb72e 100644 --- a/suixinkan/UI/Setting/StoreAccountDeregistrationAccessViewController.swift +++ b/suixinkan/UI/Setting/StoreAccountDeregistrationAccessViewController.swift @@ -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? 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) diff --git a/suixinkan/UI/Setting/StoreAccountDeregistrationStyle.swift b/suixinkan/UI/Setting/StoreAccountDeregistrationStyle.swift index 1385782..926b2e7 100644 --- a/suixinkan/UI/Setting/StoreAccountDeregistrationStyle.swift +++ b/suixinkan/UI/Setting/StoreAccountDeregistrationStyle.swift @@ -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() } + } } diff --git a/suixinkan/UI/Setting/StoreAccountDeregistrationSubmittedViewController.swift b/suixinkan/UI/Setting/StoreAccountDeregistrationSubmittedViewController.swift new file mode 100644 index 0000000..21e0c0b --- /dev/null +++ b/suixinkan/UI/Setting/StoreAccountDeregistrationSubmittedViewController.swift @@ -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() + } +} diff --git a/suixinkan/UI/Setting/StoreAccountDeregistrationViewController.swift b/suixinkan/UI/Setting/StoreAccountDeregistrationViewController.swift index 71015c5..4e21e9f 100644 --- a/suixinkan/UI/Setting/StoreAccountDeregistrationViewController.swift +++ b/suixinkan/UI/Setting/StoreAccountDeregistrationViewController.swift @@ -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? 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() + } +} diff --git a/suixinkanTests/StoreAccountDeregistrationAccessTests.swift b/suixinkanTests/StoreAccountDeregistrationAccessTests.swift index c562074..d2385c4 100644 --- a/suixinkanTests/StoreAccountDeregistrationAccessTests.swift +++ b/suixinkanTests/StoreAccountDeregistrationAccessTests.swift @@ -21,7 +21,7 @@ final class StoreAccountDeregistrationAccessTests: XCTestCase { } } - /// 在 iPhone 上渲染冷静期页面,确认撤销入口可见;不调用真实网络或修改接口。 + /// 在 iPhone 上渲染不可返回的冷静期页面,只保留退出和撤销入口。 func testCoolingPageRendersCancellationEntryOnPhysicalDevice() async throws { let name = "DeregistrationCoolingPage.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: name)! @@ -30,6 +30,7 @@ final class StoreAccountDeregistrationAccessTests: XCTestCase { session.accountType = .storeUser session.userId = "101" session.token = "isolated-token" + session.accountDisplayName = "示例门店" let identity = try StoreAccountDeregistrationIdentity(session: session) let service = try DeregistrationCancellationStub() let controller = StoreAccountDeregistrationAccessViewController(identity: identity, api: service, session: session) { _ in @@ -50,11 +51,16 @@ final class StoreAccountDeregistrationAccessTests: XCTestCase { window.layoutIfNeeded() XCTAssertFalse(cancel.isHidden) XCTAssertTrue(cancel.isEnabled) - XCTAssertTrue(message.text?.contains("处于冷静期") == true) + XCTAssertTrue(message.text?.contains("当前身份:") == true) let deadline = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.deadline" } as? UILabel) XCTAssertEqual(deadline.text, "2026-09-04 14:23:40") XCTAssertFalse(message.text?.contains("604776") == true) XCTAssertTrue(window.bounds.contains(cancel.convert(cancel.bounds, to: window))) + XCTAssertTrue(controller.navigationItem.hidesBackButton) + XCTAssertEqual((window.rootViewController as? UINavigationController)?.interactivePopGestureRecognizer?.isEnabled, false) + let texts = views.compactMap { ($0 as? UILabel)?.text } + XCTAssertTrue(texts.contains("重新登录该身份自动撤销申请")) + XCTAssertNil(views.first { $0.accessibilityIdentifier == "deregister.access.conditions" }) let attachment = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { context in window.layer.render(in: context.cgContext) }) diff --git a/suixinkanTests/StoreAccountDeregistrationCombinedTests.swift b/suixinkanTests/StoreAccountDeregistrationCombinedTests.swift index da45e15..a84f146 100644 --- a/suixinkanTests/StoreAccountDeregistrationCombinedTests.swift +++ b/suixinkanTests/StoreAccountDeregistrationCombinedTests.swift @@ -193,32 +193,47 @@ final class StoreAccountDeregistrationCombinedTests: XCTestCase { /// 两步页面的实际UIKit布局和交互测试;独立窗口不触碰用户真实会话。 @MainActor final class StoreAccountDeregistrationRedesignTests: XCTestCase { - /// 明确受理后立即调用一次退出,不等待后续GET;在途连点及成功后再次点击均不重发。 - func testAcceptedSubmissionExitsOnceWithoutPostSubmissionQuery() async throws { + override func setUp() { + super.setUp() + GlobalLoadingManager.shared.hideAll() + } + + override func tearDown() { + GlobalLoadingManager.shared.hideAll() + super.tearDown() + } + + /// 明确受理后只读获取截止时间并调用一次退出;在途连点及成功后再次点击均不重发。 + func testAcceptedSubmissionCapturesCoolingDeadlineAndExitsOnce() async throws { let service = CombinedDeregistrationService() service.walletWaived = true service.pointsWaived = true service.holdApply = true - service.onApply = { service.readError = APIError.networkFailed("成功后不应再查询") } + let coolingStatus = try StoreAccountDeregistrationFixtures.lifecycleStatus() + service.onApply = { service.statusOverride = coolingStatus } let suite = "DeregistrationLogout.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults) defer { defaults.removePersistentDomain(forName: suite) } let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store) var logoutCount = 0 + var acceptedDeadline: String? var unresolvedCount = 0 let controller = StoreAccountDeregistrationViewController( identityName: "测试门店", viewModel: model, api: service, - onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { logoutCount += 1 }) + onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { deadline in + acceptedDeadline = deadline + logoutCount += 1 + }) let window = makeWindow(controller, size: UIScreen.main.bounds.size) defer { service.finishApply(); close(window) } await waitUntil { model.canContinue } model.beginVerification() let initialStatusCount = service.statusCount let initialEligibilityCount = service.eligibilityCount - controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用") + controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ") await waitUntil { service.applyContinuation != nil } - controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用") + controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ") XCTAssertEqual(logoutCount, 0) XCTAssertTrue(model.isBusy) XCTAssertTrue(GlobalLoadingManager.shared.isShowing) @@ -226,13 +241,16 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase { capture(window, name: "deregister-submitting-global-loading") service.finishApply() await waitUntil { logoutCount == 1 } - controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用") + controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ") XCTAssertEqual(logoutCount, 1) XCTAssertEqual(unresolvedCount, 0) XCTAssertEqual(service.mutations, ["apply"]) - XCTAssertEqual(service.statusCount, initialStatusCount + 1, "只保留提交前校验") + XCTAssertEqual(service.lastReason, "不再使用") + XCTAssertEqual(service.statusCount, initialStatusCount + 2, "提交前校验并在成功后只读获取截止时间") XCTAssertEqual(service.eligibilityCount, initialEligibilityCount + 1) XCTAssertTrue(model.submissionAccepted) + XCTAssertEqual(model.submittedCoolingUntil, coolingStatus.coolingUntil) + XCTAssertEqual(acceptedDeadline, coolingStatus.coolingUntil) XCTAssertNil(model.errorMessage) XCTAssertTrue(store.hasUnresolvedSubmission, "保留意图供下次登录核实") XCTAssertFalse(GlobalLoadingManager.shared.isShowing) @@ -250,17 +268,29 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase { var unresolvedCount = 0 let controller = StoreAccountDeregistrationViewController( identityName: "测试门店", viewModel: model, api: service, - onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { logoutCount += 1 }) + onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { _ in logoutCount += 1 }) let window = makeWindow(controller, size: UIScreen.main.bounds.size) defer { close(window) } await waitUntil { model.canContinue } model.beginVerification() + let codeField = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField) + let reasonField = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextView) + codeField.text = "654321" + reasonField.text = "不再使用" controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用") await waitUntil { model.errorMessage != nil && !model.isBusy } XCTAssertEqual(logoutCount, 0) XCTAssertEqual(unresolvedCount, uncertain ? 1 : 0) XCTAssertEqual(model.submissionAttempted, uncertain) XCTAssertFalse(model.submissionAccepted) + XCTAssertEqual(model.step, uncertain ? .unresolvedRequest : .verification) + XCTAssertEqual(model.canContinue, !uncertain) + let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton) + XCTAssertEqual(submit.isHidden, uncertain) + if !uncertain { + XCTAssertEqual(codeField.text, "654321") + XCTAssertEqual(reasonField.text, "不再使用") + } XCTAssertEqual(service.mutations, ["apply"]) XCTAssertFalse(GlobalLoadingManager.shared.isShowing) } @@ -281,7 +311,7 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase { next.sendActions(for: .touchUpInside) await waitUntil { model.step == .verification } let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField) - let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextField) + let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextView) code.text = "654321" reason.text = "不再使用" let scroll = try XCTUnwrap(allViews(controller.view).first { $0 is UIScrollView } as? UIScrollView) @@ -324,13 +354,16 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase { await waitUntil { service.statusCount == count + 1 && !model.isBusy } let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n") XCTAssertFalse(texts.contains("景区")) - XCTAssertTrue(texts.contains("同手机号的其他身份不受影响")) + XCTAssertTrue(texts.contains("仅注销当前身份")) + XCTAssertFalse(texts.contains("同手机号的其他身份不受影响")) + XCTAssertFalse(texts.contains("到期由服务端复核")) + XCTAssertFalse(texts.contains("历史订单、财务及审计记录")) XCTAssertFalse(StoreAccountDeregistrationError.unsupportedIdentity.localizedDescription.contains("景区")) XCTAssertTrue(service.mutations.isEmpty) XCTAssertFalse(refresh.isRefreshing) } - /// 点击提交仅显示最终确认,明确退出登录和冷静期,不自动申请或发送短信。 + /// 点击提交仅显示高保真最终确认;原因必填并在确认弹层展示规范化内容。 func testSubmitRequiresFinalConfirmationWithLogoutNotice() async throws { let service = CombinedDeregistrationService() service.walletWaived = true @@ -338,7 +371,8 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase { let model = StoreAccountDeregistrationViewModel(storeUserID: 101) var logoutCount = 0 let controller = StoreAccountDeregistrationViewController( - identityName: "当前测试身份", viewModel: model, api: service, onSubmissionAccepted: { logoutCount += 1 }) + identityName: "当前测试身份", viewModel: model, api: service, + onSubmissionAccepted: { _ in logoutCount += 1 }) let window = makeWindow(controller, size: UIScreen.main.bounds.size) defer { close(window) } await waitUntil { model.canContinue } @@ -346,23 +380,107 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase { next.sendActions(for: .touchUpInside) await waitUntil { model.step == .verification } let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField) - let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextField) + let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextView) code.text = "654321" - reason.text = "不再使用" - reason.sendActions(for: .editingChanged) + code.sendActions(for: .editingChanged) let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton) + XCTAssertFalse(submit.isEnabled, "验证码和注销原因均为必填项") + reason.text = " 门店停业 " + controller.textViewDidChange(reason) + XCTAssertTrue(submit.isEnabled) submit.sendActions(for: .touchUpInside) - await waitUntil { controller.presentedViewController is UIAlertController } - let alert = try XCTUnwrap(controller.presentedViewController as? UIAlertController) - XCTAssertTrue(alert.message?.contains("提交成功后将退出登录") == true) - XCTAssertTrue(alert.message?.contains("当前测试身份") == true) - XCTAssertTrue(alert.message?.contains("7 天冷静期") == true) - XCTAssertTrue(alert.message?.contains("正式完成不可恢复") == true) - XCTAssertEqual(alert.actions.map(\.title), ["暂不提交", "确认提交申请"]) + await waitUntil { controller.presentedViewController is StoreAccountDeregistrationConfirmationSheetViewController } + let sheet = try XCTUnwrap(controller.presentedViewController as? StoreAccountDeregistrationConfirmationSheetViewController) + sheet.loadViewIfNeeded() + let texts = allViews(sheet.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n") + XCTAssertTrue(texts.contains("确认提交注销申请?")) + XCTAssertTrue(texts.contains("当前测试身份")) + XCTAssertTrue(texts.contains("7 天")) + XCTAssertTrue(texts.contains("注销原因")) + XCTAssertTrue(texts.contains("门店停业")) + XCTAssertTrue(texts.contains("正式完成后不可恢复")) + XCTAssertEqual((find(sheet.view, "deregister.sheet.cancel") as? UIButton)?.configuration?.title, "我再想想") + XCTAssertEqual((find(sheet.view, "deregister.sheet.confirm") as? UIButton)?.configuration?.title, "确认提交") + try? await Task.sleep(nanoseconds: 350_000_000) + try assertSheetHugsContent(sheet) + capture(window, name: "redesign-submit-confirmation") XCTAssertTrue(service.mutations.isEmpty) XCTAssertEqual(logoutCount, 0) } + /// 申请受理后的退出态结果页只保留“完成”,不能返回或继续操作注销接口。 + func testSubmittedPageOnlyAllowsCompletingToLogin() throws { + var doneCount = 0 + let controller = StoreAccountDeregistrationSubmittedViewController( + identityName: " 当前测试身份 ", + coolingUntil: "2026-09-07 18:30:00" + ) { + doneCount += 1 + } + let window = makeWindow(controller, size: UIScreen.main.bounds.size) + defer { close(window) } + window.layoutIfNeeded() + + let title = try XCTUnwrap(find(controller.view, "deregister.submitted.title") as? UILabel) + let identity = try XCTUnwrap(find(controller.view, "deregister.submitted.identity") as? UILabel) + let deadline = try XCTUnwrap(find(controller.view, "deregister.submitted.deadline") as? UILabel) + let done = try XCTUnwrap(find(controller.view, "deregister.submitted.done") as? UIButton) + XCTAssertEqual(title.text, "注销申请已提交") + XCTAssertEqual(identity.text, "当前身份:当前测试身份") + XCTAssertEqual(deadline.text, "2026-09-07 18:30:00") + XCTAssertEqual(done.configuration?.title, "完成") + XCTAssertNil(find(controller.view, "deregister.access.logout")) + XCTAssertNil(find(controller.view, "deregister.access.cancel")) + XCTAssertTrue(controller.navigationItem.hidesBackButton) + XCTAssertFalse(controller.navigationController?.interactivePopGestureRecognizer?.isEnabled ?? true) + XCTAssertTrue(window.bounds.contains(done.convert(done.bounds, to: window))) + capture(window, name: "redesign-submitted-signed-out") + + done.sendActions(for: .touchUpInside) + done.sendActions(for: .touchUpInside) + XCTAssertEqual(doneCount, 1) + } + + /// iPhone 11 画布按最终设计稿渲染资产页和资产确认弹层;步骤标题必须保持横排。 + func testDesignReferenceAssetScreensOnPhysicalDevice() async throws { + let service = CombinedDeregistrationService() + service.walletFen = 1260 + service.points = 20 + let model = StoreAccountDeregistrationViewModel(storeUserID: 101) + let controller = StoreAccountDeregistrationViewController(identityName: "示例门店", viewModel: model, api: service) + let window = makeWindow(controller, size: UIScreen.main.bounds.size) + defer { close(window) } + await waitUntil { model.canConfirmAssetsAndContinue } + window.layoutIfNeeded() + let labels = allViews(controller.view).compactMap { $0 as? UILabel } + for title in ["确认资产", "手机验证"] { + let label = try XCTUnwrap(labels.first { $0.text == title }) + XCTAssertEqual(label.numberOfLines, 1) + XCTAssertLessThan(label.bounds.height, 28) + } + let assetStep = try XCTUnwrap(find(controller.view, "deregister.step.assets")) + let verificationStep = try XCTUnwrap(find(controller.view, "deregister.step.verification")) + XCTAssertEqual(assetStep.bounds.width, verificationStep.bounds.width, accuracy: 0.5) + XCTAssertEqual(assetStep.bounds.height, 46, accuracy: 0.5) + XCTAssertEqual(verificationStep.bounds.height, 46, accuracy: 0.5) + XCTAssertEqual(assetStep.backgroundColor, StoreAccountDeregistrationStyle.primary) + XCTAssertNotEqual(assetStep.backgroundColor, verificationStep.backgroundColor) + XCTAssertTrue(assetStep.accessibilityTraits.contains(.selected)) + let pageTexts = labels.compactMap(\.text).joined(separator: "\n") + XCTAssertFalse(pageTexts.contains("同手机号的其他身份不受影响")) + XCTAssertFalse(pageTexts.contains("到期由服务端复核")) + XCTAssertFalse(pageTexts.contains("历史订单、财务及审计记录")) + capture(window, name: "redesign-assets-iphone11") + let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton) + next.sendActions(for: .touchUpInside) + await waitUntil { controller.presentedViewController is StoreAccountDeregistrationConfirmationSheetViewController } + try? await Task.sleep(nanoseconds: 350_000_000) + let sheet = try XCTUnwrap(controller.presentedViewController as? StoreAccountDeregistrationConfirmationSheetViewController) + try assertSheetHugsContent(sheet) + capture(window, name: "redesign-asset-confirmation") + XCTAssertTrue(service.mutations.isEmpty) + } + /// 375pt小屏上仅一个主按钮,零资产确认也先弹窗,没有自动提交。 func testSingleAssetButtonShowsOneExplicitConfirmation() async throws { let service = CombinedDeregistrationService() @@ -383,16 +501,21 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase { XCTAssertTrue(window.bounds.contains(next.convert(next.bounds, to: window))) capture(window, name: "redesign-assets-375") next.sendActions(for: .touchUpInside) - await waitUntil { controller.presentedViewController is UIAlertController } - let alert = try XCTUnwrap(controller.presentedViewController as? UIAlertController) - XCTAssertEqual(alert.actions.map(\.title), ["暂不确认", "确认并继续"]) - XCTAssertTrue(alert.message?.contains("现金余额:¥0.00") == true) - XCTAssertTrue(alert.message?.contains("积分:0") == true) - XCTAssertTrue(alert.message?.contains("确认自愿放弃以上现金余额,并确认自愿放弃以上积分") == true) + await waitUntil { controller.presentedViewController is StoreAccountDeregistrationConfirmationSheetViewController } + let sheet = try XCTUnwrap(controller.presentedViewController as? StoreAccountDeregistrationConfirmationSheetViewController) + sheet.loadViewIfNeeded() + let sheetTexts = allViews(sheet.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n") + XCTAssertTrue(sheetTexts.contains("确认放弃账号资产")) + XCTAssertTrue(sheetTexts.contains("¥0.00")) + XCTAssertTrue(sheetTexts.contains("积分\n0")) + XCTAssertFalse(sheetTexts.contains("自愿放弃以上资产")) + XCTAssertFalse(sheetTexts.contains("零余额也需要确认")) + XCTAssertEqual((find(sheet.view, "deregister.sheet.cancel") as? UIButton)?.configuration?.title, "暂不确认") + XCTAssertEqual((find(sheet.view, "deregister.sheet.confirm") as? UIButton)?.configuration?.title, "确认放弃") XCTAssertTrue(service.mutations.isEmpty) } - /// 已确认资产不再弹窗;验证码与获取按钮同行,输入完整前禁用提交,键盘不遮挡操作区。 + /// 已确认资产不再弹窗;验证码和原因均必填,多行输入与错误提示不被键盘遮挡。 func testVerificationLayoutInputAndKeyboard() async throws { let service = CombinedDeregistrationService() service.walletWaived = true @@ -406,24 +529,37 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase { next.sendActions(for: .touchUpInside) await waitUntil { model.step == .verification } window.layoutIfNeeded() + let assetStep = try XCTUnwrap(find(controller.view, "deregister.step.assets")) + let verificationStep = try XCTUnwrap(find(controller.view, "deregister.step.verification")) + XCTAssertNotEqual(assetStep.backgroundColor, verificationStep.backgroundColor) + XCTAssertTrue(verificationStep.accessibilityTraits.contains(.selected)) + XCTAssertFalse(assetStep.accessibilityTraits.contains(.selected)) let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField) - let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextField) + let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextView) let sms = try XCTUnwrap(find(controller.view, "deregister.sms") as? UIButton) let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton) XCTAssertEqual(code.textContentType, .oneTimeCode) XCTAssertEqual(code.keyboardType, .numberPad) XCTAssertEqual(code.superview, sms.superview) + XCTAssertEqual(find(controller.view, "deregister.reason.title")?.accessibilityLabel, "注销原因,必填") XCTAssertFalse(submit.isEnabled) capture(window, name: "redesign-verification") code.text = "123456" code.sendActions(for: .editingChanged) XCTAssertFalse(submit.isEnabled) + reason.becomeFirstResponder() + reason.resignFirstResponder() + controller.textViewDidEndEditing(reason) + let reasonError = try XCTUnwrap(find(controller.view, "deregister.reason.error") as? UILabel) + XCTAssertFalse(reasonError.isHidden) + XCTAssertEqual(reasonError.text, "请填写注销原因") reason.text = "测试" - reason.sendActions(for: .editingChanged) + controller.textViewDidChange(reason) XCTAssertTrue(submit.isEnabled) + XCTAssertTrue(reasonError.isHidden) let navigationFrame = controller.navigationController?.view.frame - code.becomeFirstResponder() - await waitUntil { controller.view.keyboardLayoutGuide.layoutFrame.height > controller.view.safeAreaInsets.bottom + 80 } + XCTAssertTrue(code.becomeFirstResponder()) + try? await Task.sleep(nanoseconds: 100_000_000) window.layoutIfNeeded() let frame = submit.convert(submit.bounds, to: controller.view) XCTAssertLessThanOrEqual(frame.maxY, controller.view.keyboardLayoutGuide.layoutFrame.minY + 1) @@ -515,6 +651,14 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase { } private func allViews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allViews($0) } } private func find(_ view: UIView, _ id: String) -> UIView? { allViews(view).first { $0.accessibilityIdentifier == id } } + private func assertSheetHugsContent(_ sheet: StoreAccountDeregistrationConfirmationSheetViewController) throws { + sheet.view.layoutIfNeeded() + let confirm = try XCTUnwrap(find(sheet.view, "deregister.sheet.confirm")) + let buttonBottom = confirm.convert(confirm.bounds, to: sheet.view).maxY + let visualGap = sheet.view.bounds.height - sheet.view.safeAreaInsets.bottom - buttonBottom + XCTAssertGreaterThanOrEqual(visualGap, 10) + XCTAssertLessThanOrEqual(visualGap, 18, "弹层底部只应保留 12pt 操作间距") + } private func waitUntil(_ condition: () -> Bool) async { for _ in 0..<150 { if condition() { return } @@ -559,6 +703,7 @@ private final class CombinedDeregistrationService: StoreAccountDeregistrationSer var applyContinuation: CheckedContinuation? var holdNextStatus = false var statusContinuation: CheckedContinuation? + var lastReason: String? func eligibility() async throws -> StoreAccountDeregistrationEligibility { eligibilityCount += 1 @@ -600,6 +745,7 @@ private final class CombinedDeregistrationService: StoreAccountDeregistrationSer func sendSMS() async throws { mutations.append("sms") } func apply(smsCode: String, reason: String) async throws { mutations.append("apply") + lastReason = reason if holdApply { await withCheckedContinuation { applyContinuation = $0 } } if let applyError { throw applyError } onApply?() diff --git a/suixinkanTests/StoreAccountDeregistrationRootTests.swift b/suixinkanTests/StoreAccountDeregistrationRootTests.swift index 73a6b75..bcc45c0 100644 --- a/suixinkanTests/StoreAccountDeregistrationRootTests.swift +++ b/suixinkanTests/StoreAccountDeregistrationRootTests.swift @@ -44,7 +44,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase { await waitUntil { context.message.contains("2026-09-04 15:27:24") } XCTAssertNotNil(context.coordinator.accessController) XCTAssertFalse(context.window.rootViewController === businessRoot) - XCTAssertTrue(context.message.contains("处于冷静期")) + XCTAssertTrue(context.message.contains("注销申请已提交")) XCTAssertFalse(context.message.contains("604681")) XCTAssertTrue(context.bindingSuspended) XCTAssertEqual(context.resumeCount, 1) @@ -167,7 +167,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase { let original = context.window.rootViewController context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus() context.coordinator.recordRestriction(requestToken: context.session.token) - await waitUntil { context.message.contains("处于冷静期") } + await waitUntil { context.message.contains("注销申请已提交") } XCTAssertTrue(context.bindingSuspended) XCTAssertEqual(context.resumeCount, 1) let gate = context.coordinator.accessController @@ -231,7 +231,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase { XCTAssertTrue(context.window.rootViewController === loginRoot) context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus() context.service.finishRead() - await waitUntil { context.message.contains("处于冷静期") } + await waitUntil { context.message.contains("注销申请已提交") } XCTAssertNotNil(context.coordinator.accessController) XCTAssertEqual(context.service.statusCount, 2) XCTAssertEqual(context.resumeCount, 0) @@ -292,7 +292,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase { defer { context.cleanUp() } context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus() await context.startChecking() - await waitUntil { context.message.contains("处于冷静期") } + await waitUntil { context.message.contains("注销申请已提交") } let gate = try XCTUnwrap(context.coordinator.accessController) let cancel = try XCTUnwrap(context.button("deregister.access.cancel")) cancel.sendActions(for: .touchUpInside) diff --git a/suixinkanTests/StoreAccountDeregistrationViewModelTests.swift b/suixinkanTests/StoreAccountDeregistrationViewModelTests.swift index a1365a5..2b6c9f5 100644 --- a/suixinkanTests/StoreAccountDeregistrationViewModelTests.swift +++ b/suixinkanTests/StoreAccountDeregistrationViewModelTests.swift @@ -267,6 +267,40 @@ final class StoreAccountDeregistrationViewModelTests: XCTestCase { XCTAssertFalse(model.canContinue) } + /// 验证码与原因在任何查询和提交意图之前校验;空白及超长原因不能触发网络请求。 + func testSubmitRejectsMissingOrOversizedRequiredInputWithoutSideEffects() async throws { + let cases = [ + (code: "", reason: "正常原因", message: "请输入收到的短信验证码"), + (code: "654321", reason: "", message: "请输入注销原因"), + (code: "654321", reason: " \n", message: "请输入注销原因"), + (code: "654321", reason: String(repeating: "原", count: 51), message: "注销原因不能超过 50 个字") + ] + for item in cases { + let service = try DeregistrationServiceStub() + service.current = try StoreAccountDeregistrationFixtures.ready() + let suite = "DeregistrationRequiredReason.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = StoreAccountDeregistrationSubmissionStore( + storeUserID: "101", environment: .testing, defaults: defaults + ) + let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store) + await model.refresh(api: service) + model.beginVerification() + let statusCount = service.statusCount + let eligibilityCount = service.eligibilityCount + + await model.submit(smsCode: item.code, reason: item.reason, api: service) + + XCTAssertEqual(model.errorMessage, item.message) + XCTAssertTrue(service.mutations.isEmpty) + XCTAssertEqual(service.statusCount, statusCount) + XCTAssertEqual(service.eligibilityCount, eligibilityCount) + XCTAssertFalse(model.submissionAttempted) + XCTAssertFalse(store.hasUnresolvedSubmission) + } + } + /// 申请响应丢失可能已被服务端受理,不能直接重试或显示失败后可重新申请。 func testLostApplyResponsePreventsAutomaticRetry() async throws { let service = try DeregistrationServiceStub() @@ -285,8 +319,8 @@ final class StoreAccountDeregistrationViewModelTests: XCTestCase { XCTAssertEqual(model.step, .unresolvedRequest) } - /// 服务端明确拒绝验证码时允许重新核验,但不能保留之前的提交权限。 - func testServerRejectionRequiresFreshEligibility() async throws { + /// 服务端明确拒绝验证码时保留已核验条件并停留在手机验证,不要求重新确认资产。 + func testVerificationCodeRejectionStaysInVerification() async throws { let service = try DeregistrationServiceStub() service.current = try StoreAccountDeregistrationFixtures.ready() service.applyError = APIError.serverCode(100001, "验证码错误") @@ -295,10 +329,10 @@ final class StoreAccountDeregistrationViewModelTests: XCTestCase { model.beginVerification() await model.submit(smsCode: "907182", reason: "不再使用", api: service) XCTAssertFalse(model.submissionAttempted) - XCTAssertFalse(model.canContinue) - XCTAssertEqual(model.errorMessage, "验证码错误") - await model.refresh(api: service) XCTAssertTrue(model.canContinue) + XCTAssertEqual(model.step, .verification) + XCTAssertNotNil(model.eligibility) + XCTAssertEqual(model.errorMessage, "验证码错误") } /// 展示全部真实阻断项,初始加载不会调用任何修改接口。 @@ -471,15 +505,19 @@ private final class DeregistrationServiceStub: StoreAccountDeregistrationServing var readError: Error? var applyError: Error? var mutations: [String] = [] + var eligibilityCount = 0 + var statusCount = 0 var lastCode: String? var lastReason: String? init() throws { current = try StoreAccountDeregistrationFixtures.eligibility() } func eligibility() async throws -> StoreAccountDeregistrationEligibility { + eligibilityCount += 1 if let readError { throw readError } return current } func status() async throws -> StoreAccountDeregistrationStatus { + statusCount += 1 if let readError { throw readError } return currentStatus }