feat: 优化账号注销流程
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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 提交、当前工作区改动、门店身份注销接入记录及已保存的真机测试摘要;本次周报整理未重新运行测试。
|
||||||
@@ -32,6 +32,9 @@ enum NotificationName {
|
|||||||
/// 当前门店身份受限或申请结果待核实;保留凭证转入只读状态查询。
|
/// 当前门店身份受限或申请结果待核实;保留凭证转入只读状态查询。
|
||||||
static let storeAccountDeregistrationRestricted = name("storeAccountDeregistrationRestricted")
|
static let storeAccountDeregistrationRestricted = name("storeAccountDeregistrationRestricted")
|
||||||
|
|
||||||
|
/// 当前门店身份的注销申请已被服务端明确受理;先清理登录态,再展示提交结果页。
|
||||||
|
static let storeAccountDeregistrationSubmitted = name("storeAccountDeregistrationSubmitted")
|
||||||
|
|
||||||
// MARK: - Scenic
|
// MARK: - Scenic
|
||||||
|
|
||||||
/// 当前景区切换
|
/// 当前景区切换
|
||||||
@@ -74,6 +77,12 @@ enum NotificationUserInfoKey {
|
|||||||
/// 产生注销限制错误的原请求凭证,仅在内存中匹配当前会话,禁止记录日志。
|
/// 产生注销限制错误的原请求凭证,仅在内存中匹配当前会话,禁止记录日志。
|
||||||
static let deregistrationRequestToken = "deregistrationRequestToken"
|
static let deregistrationRequestToken = "deregistrationRequestToken"
|
||||||
|
|
||||||
|
/// 注销申请提交时冻结的身份展示名称,不包含身份凭证。
|
||||||
|
static let deregistrationIdentityName = "deregistrationIdentityName"
|
||||||
|
|
||||||
|
/// 注销申请明确受理后查询到的服务端冷静期截止时间。
|
||||||
|
static let deregistrationCoolingUntil = "deregistrationCoolingUntil"
|
||||||
|
|
||||||
static let scenicId = "scenicId"
|
static let scenicId = "scenicId"
|
||||||
static let scenicName = "scenicName"
|
static let scenicName = "scenicName"
|
||||||
static let orderId = "orderId"
|
static let orderId = "orderId"
|
||||||
|
|||||||
+51
-5
@@ -23,6 +23,8 @@ final class StoreAccountDeregistrationViewModel {
|
|||||||
private(set) var submissionAttempted = false
|
private(set) var submissionAttempted = false
|
||||||
/// 成功响应仅代表申请已提交,不代表账号注销完成。
|
/// 成功响应仅代表申请已提交,不代表账号注销完成。
|
||||||
private(set) var submissionAccepted = false
|
private(set) var submissionAccepted = false
|
||||||
|
/// 申请明确受理后,以当前凭证只读查询到的冷静期截止时间;查询失败不改变受理结果。
|
||||||
|
private(set) var submittedCoolingUntil: String?
|
||||||
private let storeUserID: Int
|
private let storeUserID: Int
|
||||||
private let submissionStore: (any StoreAccountDeregistrationSubmissionTracking)?
|
private let submissionStore: (any StoreAccountDeregistrationSubmissionTracking)?
|
||||||
private var previousCancellationFingerprint: String?
|
private var previousCancellationFingerprint: String?
|
||||||
@@ -179,20 +181,42 @@ final class StoreAccountDeregistrationViewModel {
|
|||||||
} catch { invalidate(error); return false }
|
} 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 {
|
func submit(smsCode: String, reason: String, api: any StoreAccountDeregistrationServing) async {
|
||||||
guard canContinue, step == .verification else { return }
|
guard canContinue, step == .verification else { return }
|
||||||
let code = smsCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
let code = smsCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !code.isEmpty, !reason.isEmpty else {
|
guard validateSubmissionInput(smsCode: code, reason: reason) else { return }
|
||||||
errorMessage = "请输入收到的短信验证码和注销原因"
|
var verifiedEligibility: StoreAccountDeregistrationEligibility?
|
||||||
return
|
var verifiedStatus: StoreAccountDeregistrationStatus?
|
||||||
}
|
|
||||||
isBusy = true
|
isBusy = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
do {
|
do {
|
||||||
try await checkReady(api: api)
|
try await checkReady(api: api)
|
||||||
|
verifiedEligibility = eligibility
|
||||||
|
verifiedStatus = status
|
||||||
previousCancellationFingerprint = status?.cancellationFingerprint
|
previousCancellationFingerprint = status?.cancellationFingerprint
|
||||||
try submissionStore?.recordSubmissionIntent(previousStatus: status)
|
try submissionStore?.recordSubmissionIntent(previousStatus: status)
|
||||||
submissionAttempted = true
|
submissionAttempted = true
|
||||||
@@ -200,7 +224,21 @@ final class StoreAccountDeregistrationViewModel {
|
|||||||
eligibility = nil
|
eligibility = nil
|
||||||
try await api.apply(smsCode: code, reason: reason)
|
try await api.apply(smsCode: code, reason: reason)
|
||||||
submissionAccepted = true
|
submissionAccepted = true
|
||||||
|
// 申请成功已经成立;状态查询只用于结果页展示,失败或尚未进入冷静期都不能推翻成功响应。
|
||||||
|
if let acceptedStatus = try? await api.status(), acceptedStatus.isCooling {
|
||||||
|
status = acceptedStatus
|
||||||
|
submittedCoolingUntil = acceptedStatus.coolingUntil
|
||||||
|
}
|
||||||
} catch {
|
} 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 {
|
if !submissionAccepted, case APIError.serverCode = error {
|
||||||
submissionStore?.clearRejectedSubmission()
|
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 {
|
private func checkReady(api: any StoreAccountDeregistrationServing) async throws {
|
||||||
try await reload(api: api)
|
try await reload(api: api)
|
||||||
guard status?.permitsPreparation == true, eligibility?.permitsApplication == true else {
|
guard status?.permitsPreparation == true, eligibility?.permitsApplication == true else {
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
self, selector: #selector(handleStoreDeregistrationRestriction(_:)),
|
self, selector: #selector(handleStoreDeregistrationRestriction(_:)),
|
||||||
name: NotificationName.storeAccountDeregistrationRestricted, object: nil
|
name: NotificationName.storeAccountDeregistrationRestricted, object: nil
|
||||||
)
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleStoreDeregistrationSubmitted(_:)),
|
||||||
|
name: NotificationName.storeAccountDeregistrationSubmitted, object: nil
|
||||||
|
)
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleSessionDidExpire),
|
selector: #selector(handleSessionDidExpire),
|
||||||
@@ -136,18 +140,44 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func transitionToLogin() {
|
private func transitionToLogin() {
|
||||||
|
clearAuthenticatedSession()
|
||||||
|
AppRouter.setRoot(.login, on: window)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一清除当前会话及依赖登录态的后台能力,不执行页面跳转。
|
||||||
|
private func clearAuthenticatedSession() {
|
||||||
deregistrationCoordinator?.cancelPendingCheck()
|
deregistrationCoordinator?.cancelPendingCheck()
|
||||||
|
sessionExpiredDialog?.dismiss(animated: false)
|
||||||
sessionExpiredDialog = nil
|
sessionExpiredDialog = nil
|
||||||
|
GlobalLoadingManager.shared.hideAll()
|
||||||
PushNotificationManager.shared.handleLogout()
|
PushNotificationManager.shared.handleLogout()
|
||||||
PushNotificationManager.shared.setAccountBindingSuspended(false)
|
PushNotificationManager.shared.setAccountBindingSuspended(false)
|
||||||
AppStore.shared.logout()
|
AppStore.shared.logout()
|
||||||
AppRouter.setRoot(.login, on: window)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleUserDidLogout() {
|
@objc private func handleUserDidLogout() {
|
||||||
transitionToLogin()
|
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() {
|
@objc private func handleUserDidLogin() {
|
||||||
sessionExpiredDialog?.dismiss(animated: false)
|
sessionExpiredDialog?.dismiss(animated: false)
|
||||||
sessionExpiredDialog = nil
|
sessionExpiredDialog = nil
|
||||||
|
|||||||
@@ -18,17 +18,19 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
|
|||||||
private let stack = UIStackView()
|
private let stack = UIStackView()
|
||||||
private let bottomBar = UIView()
|
private let bottomBar = UIView()
|
||||||
private let titleLabel = Style.label(size: 24, weight: .semibold)
|
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 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 deadlineCard = UIView()
|
||||||
|
private var infoCard = UIView()
|
||||||
private let messageLabel = UILabel()
|
private let messageLabel = UILabel()
|
||||||
private let retryButton = UIButton(type: .system)
|
private let retryButton = UIButton(type: .system)
|
||||||
private let conditionsButton = UIButton(type: .system)
|
|
||||||
private let cancelButton = UIButton(type: .system)
|
private let cancelButton = UIButton(type: .system)
|
||||||
private let logoutButton = UIButton(type: .system)
|
private let logoutButton = UIButton(type: .system)
|
||||||
private var queryTask: Task<Void, Never>?
|
private var queryTask: Task<Void, Never>?
|
||||||
private var needsForegroundRefresh = false
|
private var needsForegroundRefresh = false
|
||||||
|
private var previousPopGestureEnabled: Bool?
|
||||||
|
|
||||||
/// 显式注入会话与 API;身份构造失败时仍展示错误,不自动退出或重新登录。
|
/// 显式注入会话与 API;身份构造失败时仍展示错误,不自动退出或重新登录。
|
||||||
init(identity: StoreAccountDeregistrationIdentity?, api: (any StoreAccountDeregistrationServing)?,
|
init(identity: StoreAccountDeregistrationIdentity?, api: (any StoreAccountDeregistrationServing)?,
|
||||||
@@ -53,10 +55,11 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
|
|||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "注销账号"
|
title = "注销账号"
|
||||||
|
navigationItem.hidesBackButton = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = AppColor.pageBackground
|
view.backgroundColor = Style.pageBackground
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
scrollView.alwaysBounceVertical = true
|
scrollView.alwaysBounceVertical = true
|
||||||
refreshControl.accessibilityIdentifier = "deregister.access.refresh"
|
refreshControl.accessibilityIdentifier = "deregister.access.refresh"
|
||||||
@@ -66,47 +69,55 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
|
|||||||
scrollView.refreshControl = refreshControl
|
scrollView.refreshControl = refreshControl
|
||||||
scrollView.addSubview(stack)
|
scrollView.addSubview(stack)
|
||||||
stack.axis = .vertical
|
stack.axis = .vertical
|
||||||
stack.spacing = 20
|
stack.spacing = 18
|
||||||
stateIcon.snp.makeConstraints { $0.height.equalTo(64) }
|
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
|
titleLabel.textAlignment = .center
|
||||||
messageLabel.numberOfLines = 0
|
messageLabel.numberOfLines = 0
|
||||||
messageLabel.font = .systemFont(ofSize: 15)
|
messageLabel.font = .systemFont(ofSize: 15)
|
||||||
messageLabel.textColor = AppColor.textSecondary
|
messageLabel.textColor = Style.textSecondary
|
||||||
messageLabel.textAlignment = .center
|
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)
|
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"
|
deadlineLabel.accessibilityIdentifier = "deregister.access.deadline"
|
||||||
deadlineCard = Style.card(Style.stack([
|
let deadlineIcon = Style.icon("calendar", size: 22)
|
||||||
Style.label("冷静期截止时间", size: 13, color: AppColor.textSecondary),
|
deadlineIcon.snp.makeConstraints { $0.width.equalTo(28) }
|
||||||
deadlineLabel,
|
let deadlineText = Style.stack([
|
||||||
Style.label("以服务端时间为准,到期后仍需复核。", size: 12, color: AppColor.textSecondary)
|
Style.label("冷静期截止时间", size: 13, color: Style.textSecondary), deadlineLabel
|
||||||
], spacing: 10))
|
], spacing: 8)
|
||||||
|
let deadlineRow = UIStackView(arrangedSubviews: [deadlineIcon, deadlineText])
|
||||||
|
deadlineRow.axis = .horizontal
|
||||||
|
deadlineRow.alignment = .center
|
||||||
|
deadlineRow.spacing = 12
|
||||||
|
deadlineCard = Style.card(deadlineRow)
|
||||||
stack.addArrangedSubview(deadlineCard)
|
stack.addArrangedSubview(deadlineCard)
|
||||||
warningLabel.text = "重新登录或选中此身份,会自动撤销尚未完成的注销申请。其他身份不受影响。"
|
let infoIcon = Style.icon("info.circle", size: 17)
|
||||||
stack.addArrangedSubview(warningLabel)
|
infoIcon.snp.makeConstraints { $0.width.equalTo(22) }
|
||||||
conditionsButton.setTitle("查看注销条件", for: .normal)
|
let infoRow = UIStackView(arrangedSubviews: [infoIcon, infoLabel])
|
||||||
conditionsButton.titleLabel?.font = .systemFont(ofSize: 14)
|
infoRow.axis = .horizontal
|
||||||
conditionsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
|
infoRow.alignment = .center
|
||||||
stack.addArrangedSubview(conditionsButton)
|
infoRow.spacing = 8
|
||||||
|
infoCard = Style.card(infoRow, background: Style.infoBackground)
|
||||||
|
stack.addArrangedSubview(infoCard)
|
||||||
|
|
||||||
view.addSubview(bottomBar)
|
view.addSubview(bottomBar)
|
||||||
bottomBar.backgroundColor = .white
|
bottomBar.backgroundColor = .white
|
||||||
Style.configure(retryButton, title: "重新查询状态")
|
Style.configure(retryButton, title: "重新查询状态")
|
||||||
Style.configure(logoutButton, title: "退出登录")
|
Style.configure(logoutButton, title: "退出登录")
|
||||||
Style.configure(cancelButton, title: "撤销注销申请", primary: false)
|
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)
|
bottomBar.addSubview(actions)
|
||||||
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
|
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
|
||||||
for button in [retryButton, logoutButton, cancelButton] {
|
for button in [retryButton, logoutButton, cancelButton] {
|
||||||
button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(50) }
|
button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(50) }
|
||||||
}
|
}
|
||||||
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||||
conditionsButton.addTarget(self, action: #selector(conditionsTapped), for: .touchUpInside)
|
|
||||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
logoutButton.addTarget(self, action: #selector(logoutTapped), for: .touchUpInside)
|
logoutButton.addTarget(self, action: #selector(logoutTapped), for: .touchUpInside)
|
||||||
messageLabel.accessibilityIdentifier = "deregister.access.message"
|
messageLabel.accessibilityIdentifier = "deregister.access.message"
|
||||||
@@ -114,7 +125,6 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
|
|||||||
retryButton.accessibilityIdentifier = "deregister.access.retry"
|
retryButton.accessibilityIdentifier = "deregister.access.retry"
|
||||||
cancelButton.accessibilityIdentifier = "deregister.access.cancel"
|
cancelButton.accessibilityIdentifier = "deregister.access.cancel"
|
||||||
logoutButton.accessibilityIdentifier = "deregister.access.logout"
|
logoutButton.accessibilityIdentifier = "deregister.access.logout"
|
||||||
conditionsButton.accessibilityIdentifier = "deregister.access.conditions"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
@@ -138,6 +148,20 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
|
|||||||
if hasInitialResult { applyViewModel() } else { retryTapped() }
|
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() {
|
func recordRestriction() {
|
||||||
viewModel.recordRestriction()
|
viewModel.recordRestriction()
|
||||||
@@ -164,15 +188,13 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
|
|||||||
retryButton.isEnabled = !busy && api != nil
|
retryButton.isEnabled = !busy && api != nil
|
||||||
retryButton.isHidden = cooling
|
retryButton.isHidden = cooling
|
||||||
refreshControl.isEnabled = !busy && api != nil
|
refreshControl.isEnabled = !busy && api != nil
|
||||||
conditionsButton.isEnabled = !busy && api != nil
|
|
||||||
conditionsButton.isHidden = ![.unresolved, .cooling].contains(viewModel.decision)
|
|
||||||
cancelButton.isHidden = !cooling
|
cancelButton.isHidden = !cooling
|
||||||
cancelButton.isEnabled = !busy && api != nil
|
cancelButton.isEnabled = !busy && api != nil
|
||||||
logoutButton.isEnabled = !busy
|
logoutButton.isEnabled = !busy
|
||||||
Style.configure(logoutButton, title: "退出登录", primary: cooling)
|
Style.configure(logoutButton, title: "退出登录", primary: cooling)
|
||||||
deadlineCard.isHidden = !cooling
|
deadlineCard.isHidden = !cooling
|
||||||
warningLabel.isHidden = !cooling
|
infoCard.isHidden = !cooling
|
||||||
deadlineLabel.text = viewModel.status?.coolingUntil
|
deadlineLabel.text = viewModel.status?.coolingUntil ?? "—"
|
||||||
guard identity != nil, api != nil else {
|
guard identity != nil, api != nil else {
|
||||||
titleLabel.text = "暂时无法查询"
|
titleLabel.text = "暂时无法查询"
|
||||||
messageLabel.text = "当前身份信息不完整,请联系管理员。"
|
messageLabel.text = "当前身份信息不完整,请联系管理员。"
|
||||||
@@ -190,9 +212,8 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
|
|||||||
symbol = "checkmark.circle"
|
symbol = "checkmark.circle"
|
||||||
case .cooling:
|
case .cooling:
|
||||||
titleLabel.text = "注销申请已提交"
|
titleLabel.text = "注销申请已提交"
|
||||||
messageLabel.text = (viewModel.status?.remainingSeconds ?? 0) > 0
|
messageLabel.text = "当前身份:\(identity?.displayName ?? "当前身份")"
|
||||||
? "当前处于冷静期,期间暂停普通业务。\n你仍可以撤销申请,恢复使用。"
|
symbol = "lock"
|
||||||
: "冷静期已结束,正在等待服务端复核。\n最终结果请刷新后查看。"
|
|
||||||
case .unresolved:
|
case .unresolved:
|
||||||
titleLabel.text = "注销状态待确认"
|
titleLabel.text = "注销状态待确认"
|
||||||
messageLabel.text = "暂时未获取到明确结果,请刷新重试。\n请勿重复提交;如持续出现,请联系管理员。"
|
messageLabel.text = "暂时未获取到明确结果,请刷新重试。\n请勿重复提交;如持续出现,请联系管理员。"
|
||||||
@@ -208,6 +229,8 @@ final class StoreAccountDeregistrationAccessViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
stateIcon.image = UIImage(systemName: symbol,
|
stateIcon.image = UIImage(systemName: symbol,
|
||||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 36, weight: .medium))
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 36, weight: .medium))
|
||||||
|
navigationItem.hidesBackButton = true
|
||||||
|
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func cancelTapped() {
|
@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() {
|
@objc private func logoutTapped() {
|
||||||
let alert = UIAlertController(title: "退出登录?", message:
|
let alert = UIAlertController(title: "退出登录?", message:
|
||||||
"退出本身不会撤销申请,但会清除本机登录凭证。再次登录或选中此身份可能自动撤销尚未完成的申请。", preferredStyle: .alert)
|
"退出本身不会撤销申请,但会清除本机登录凭证。再次登录或选中此身份会自动撤销尚未完成的申请。", preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "继续保留查询", style: .cancel))
|
alert.addAction(UIAlertAction(title: "继续保留查询", style: .cancel))
|
||||||
alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { _ in
|
alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { _ in
|
||||||
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
|
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
|
||||||
|
|||||||
@@ -4,13 +4,22 @@ import UIKit
|
|||||||
/// 注销页面共用的轻量视觉组件,仅负责颜色、字号和布局,不包含业务状态。
|
/// 注销页面共用的轻量视觉组件,仅负责颜色、字号和布局,不包含业务状态。
|
||||||
@MainActor
|
@MainActor
|
||||||
enum StoreAccountDeregistrationStyle {
|
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,
|
static func label(_ text: String = "", size: CGFloat = 15, weight: UIFont.Weight = .regular,
|
||||||
color: UIColor = AppColor.textPrimary) -> UILabel {
|
color: UIColor? = nil) -> UILabel {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = text
|
label.text = text
|
||||||
label.font = .systemFont(ofSize: size, weight: weight)
|
label.font = .systemFont(ofSize: size, weight: weight)
|
||||||
label.textColor = color
|
label.textColor = color ?? textPrimary
|
||||||
label.numberOfLines = 0
|
label.numberOfLines = 0
|
||||||
return label
|
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()
|
let card = UIView()
|
||||||
card.backgroundColor = .white
|
card.backgroundColor = background
|
||||||
card.layer.cornerRadius = 16
|
card.layer.cornerRadius = 16
|
||||||
|
card.layer.borderWidth = background == .white ? 0.5 : 0
|
||||||
|
card.layer.borderColor = border.cgColor
|
||||||
card.addSubview(content)
|
card.addSubview(content)
|
||||||
content.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
|
content.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
|
||||||
return card
|
return card
|
||||||
@@ -46,9 +57,13 @@ enum StoreAccountDeregistrationStyle {
|
|||||||
static func configure(_ button: UIButton, title: String, primary: Bool = true) {
|
static func configure(_ button: UIButton, title: String, primary: Bool = true) {
|
||||||
var config = primary ? UIButton.Configuration.filled() : .plain()
|
var config = primary ? UIButton.Configuration.filled() : .plain()
|
||||||
config.title = title
|
config.title = title
|
||||||
config.baseBackgroundColor = AppColor.primary
|
config.baseBackgroundColor = StoreAccountDeregistrationStyle.primary
|
||||||
config.baseForegroundColor = primary ? .white : AppColor.primary
|
config.baseForegroundColor = primary ? .white : StoreAccountDeregistrationStyle.primary
|
||||||
config.background.cornerRadius = 12
|
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.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
|
||||||
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||||
var attributes = attributes
|
var attributes = attributes
|
||||||
@@ -60,12 +75,12 @@ enum StoreAccountDeregistrationStyle {
|
|||||||
let enabled = button.isEnabled
|
let enabled = button.isEnabled
|
||||||
var updated = button.configuration
|
var updated = button.configuration
|
||||||
updated?.background.backgroundColorTransformer = UIConfigurationColorTransformer { _ in
|
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
|
updated?.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||||
var attributes = attributes
|
var attributes = attributes
|
||||||
attributes.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
|
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
|
return attributes
|
||||||
}
|
}
|
||||||
button.configuration = updated
|
button.configuration = updated
|
||||||
@@ -76,9 +91,174 @@ enum StoreAccountDeregistrationStyle {
|
|||||||
static func icon(_ name: String, size: CGFloat = 22) -> UIImageView {
|
static func icon(_ name: String, size: CGFloat = 22) -> UIImageView {
|
||||||
let image = UIImageView(image: UIImage(systemName: name,
|
let image = UIImageView(image: UIImage(systemName: name,
|
||||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: size, weight: .medium)))
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: size, weight: .medium)))
|
||||||
image.tintColor = AppColor.primary
|
image.tintColor = primary
|
||||||
image.contentMode = .scaleAspectFit
|
image.contentMode = .scaleAspectFit
|
||||||
image.setContentHuggingPriority(.required, for: .horizontal)
|
image.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
return image
|
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 identityName: String
|
||||||
private let readOnly: Bool
|
private let readOnly: Bool
|
||||||
private let onUnresolvedSubmission: (() -> Void)?
|
private let onUnresolvedSubmission: (() -> Void)?
|
||||||
private let onSubmissionAccepted: () -> Void
|
private let onSubmissionAccepted: (String?) -> Void
|
||||||
private var didHandleAcceptedSubmission = false
|
private var didHandleAcceptedSubmission = false
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let refreshControl = UIRefreshControl()
|
private let refreshControl = UIRefreshControl()
|
||||||
private let content = UIStackView()
|
private let content = UIStackView()
|
||||||
private let bottomBar = UIView()
|
private let bottomBar = UIView()
|
||||||
private let steps = UIStackView()
|
private let steps = UIStackView()
|
||||||
private let firstStep = Style.label("1 确认资产", size: 13, weight: .semibold)
|
private let firstStepNumber = Style.label("1", size: 11, weight: .semibold)
|
||||||
private let secondStep = Style.label("2 手机验证", size: 13, 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 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 conditionStack = UIStackView()
|
||||||
private let verificationStack = UIStackView()
|
private let verificationStack = UIStackView()
|
||||||
private let walletAmount = Style.label("—", size: 28, weight: .semibold)
|
private let walletAmount = Style.label("—", size: 28, weight: .semibold)
|
||||||
private let pointsAmount = 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 walletState = Style.label(size: 12, color: Style.textSecondary)
|
||||||
private let pointsState = Style.label(size: 12, color: AppColor.textSecondary)
|
private let pointsState = Style.label(size: 12, color: Style.textSecondary)
|
||||||
private let assetHint = Style.label(size: 13, color: AppColor.textSecondary)
|
private let assetHint = Style.label(size: 13, color: Style.textSecondary)
|
||||||
private let blockersLabel = Style.label(size: 14, color: AppColor.textSecondary)
|
private let blockersLabel = Style.label(size: 14, color: Style.textSecondary)
|
||||||
private let riskLabel = Style.label(size: 13, color: AppColor.warning)
|
private let riskLabel = Style.label(size: 13, color: AppColor.warning)
|
||||||
private let statusLabel = Style.label(size: 13, color: AppColor.textSecondary)
|
private let statusLabel = Style.label(size: 13, color: Style.textSecondary)
|
||||||
private let errorLabel = Style.label(size: 14, color: AppColor.danger)
|
private let errorLabel = Style.label(size: 14, color: Style.danger)
|
||||||
private let codeField = UITextField()
|
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 smsButton = UIButton(type: .system)
|
||||||
private let continueButton = Style.button("确认资产并继续", id: "deregister.continue")
|
private let continueButton = Style.button("确认资产并继续", id: "deregister.continue")
|
||||||
private let submitButton = Style.button("提交注销申请", id: "deregister.submit")
|
private let submitButton = Style.button("提交注销申请", id: "deregister.submit")
|
||||||
private let backButton = UIButton(type: .system)
|
private var identityCard = UIView()
|
||||||
private let footerHint = Style.label(size: 12, color: AppColor.textSecondary)
|
|
||||||
private var blockersCard = UIView()
|
private var blockersCard = UIView()
|
||||||
private var errorCard = UIView()
|
private var errorCard = UIView()
|
||||||
private var actionTask: Task<Void, Never>?
|
private var actionTask: Task<Void, Never>?
|
||||||
private var previousPopGestureEnabled: Bool?
|
private var previousPopGestureEnabled: Bool?
|
||||||
private var previousViewportHeight: CGFloat = 0
|
private var previousViewportHeight: CGFloat = 0
|
||||||
|
private var reasonWasBlurred = false
|
||||||
|
|
||||||
/// 注入当前身份及旧接口,不读取任意手机号,也不自动确认资产。
|
/// 注入当前身份及旧接口,不读取任意手机号,也不自动确认资产。
|
||||||
init(identityName: String, viewModel: StoreAccountDeregistrationViewModel,
|
init(identityName: String, viewModel: StoreAccountDeregistrationViewModel,
|
||||||
api: any StoreAccountDeregistrationServing, readOnly: Bool = false,
|
api: any StoreAccountDeregistrationServing, readOnly: Bool = false,
|
||||||
onUnresolvedSubmission: (() -> Void)? = nil,
|
onUnresolvedSubmission: (() -> Void)? = nil,
|
||||||
onSubmissionAccepted: @escaping () -> Void = {
|
onSubmissionAccepted: ((String?) -> Void)? = nil) {
|
||||||
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: 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.identityName = identityName
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.api = api
|
self.api = api
|
||||||
self.readOnly = readOnly
|
self.readOnly = readOnly
|
||||||
self.onUnresolvedSubmission = onUnresolvedSubmission
|
self.onUnresolvedSubmission = onUnresolvedSubmission
|
||||||
self.onSubmissionAccepted = onSubmissionAccepted
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +87,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = AppColor.pageBackground
|
view.backgroundColor = Style.pageBackground
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
refreshControl.accessibilityIdentifier = "deregister.refresh"
|
refreshControl.accessibilityIdentifier = "deregister.refresh"
|
||||||
refreshControl.accessibilityLabel = "下拉刷新"
|
refreshControl.accessibilityLabel = "下拉刷新"
|
||||||
@@ -83,20 +99,28 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
content.spacing = 16
|
content.spacing = 16
|
||||||
content.addArrangedSubview(steps)
|
content.addArrangedSubview(steps)
|
||||||
steps.axis = .horizontal
|
steps.axis = .horizontal
|
||||||
|
steps.alignment = .fill
|
||||||
steps.distribution = .fillEqually
|
steps.distribution = .fillEqually
|
||||||
steps.addArrangedSubview(firstStep)
|
steps.spacing = 12
|
||||||
steps.addArrangedSubview(secondStep)
|
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
|
steps.isHidden = readOnly
|
||||||
content.addArrangedSubview(Style.stack([heading, subtitle], spacing: 8))
|
content.addArrangedSubview(Style.stack([heading, subtitle], spacing: 8))
|
||||||
let identityText = Style.stack([
|
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)
|
Style.label(identityName, size: 17, weight: .semibold)
|
||||||
], spacing: 5)
|
], spacing: 5)
|
||||||
let identityRow = UIStackView(arrangedSubviews: [Style.icon("person.crop.circle"), identityText])
|
let identityRow = UIStackView(arrangedSubviews: [Style.icon("person.crop.circle"), identityText])
|
||||||
identityRow.axis = .horizontal
|
identityRow.axis = .horizontal
|
||||||
identityRow.alignment = .center
|
identityRow.alignment = .center
|
||||||
identityRow.spacing = 12
|
identityRow.spacing = 12
|
||||||
content.addArrangedSubview(Style.card(identityRow))
|
identityCard = Style.card(identityRow)
|
||||||
|
content.addArrangedSubview(identityCard)
|
||||||
conditionStack.axis = .vertical
|
conditionStack.axis = .vertical
|
||||||
conditionStack.spacing = 16
|
conditionStack.spacing = 16
|
||||||
verificationStack.axis = .vertical
|
verificationStack.axis = .vertical
|
||||||
@@ -106,7 +130,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
content.addArrangedSubview(conditionStack)
|
content.addArrangedSubview(conditionStack)
|
||||||
content.addArrangedSubview(verificationStack)
|
content.addArrangedSubview(verificationStack)
|
||||||
errorCard = Style.card(errorLabel)
|
errorCard = Style.card(errorLabel)
|
||||||
errorCard.backgroundColor = AppColor.dangerBackground
|
errorCard.backgroundColor = UIColor(hex: 0xFFF1F1)
|
||||||
content.addArrangedSubview(errorCard)
|
content.addArrangedSubview(errorCard)
|
||||||
content.addArrangedSubview(statusLabel)
|
content.addArrangedSubview(statusLabel)
|
||||||
statusLabel.accessibilityIdentifier = "deregister.status"
|
statusLabel.accessibilityIdentifier = "deregister.status"
|
||||||
@@ -114,8 +138,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
|
|
||||||
view.addSubview(bottomBar)
|
view.addSubview(bottomBar)
|
||||||
bottomBar.backgroundColor = .white
|
bottomBar.backgroundColor = .white
|
||||||
footerHint.textAlignment = .center
|
let actions = Style.stack([continueButton, submitButton], spacing: 10)
|
||||||
let actions = Style.stack([continueButton, submitButton, footerHint], spacing: 10)
|
|
||||||
bottomBar.addSubview(actions)
|
bottomBar.addSubview(actions)
|
||||||
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
|
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
|
||||||
bottomBar.isHidden = readOnly
|
bottomBar.isHidden = readOnly
|
||||||
@@ -125,17 +148,45 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
scrollView.alwaysBounceVertical = true
|
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() {
|
private func buildConditions() {
|
||||||
let columns = UIStackView()
|
let columns = UIStackView()
|
||||||
columns.axis = .horizontal
|
columns.axis = .horizontal
|
||||||
columns.distribution = .fillEqually
|
columns.distribution = .fill
|
||||||
columns.spacing = 16
|
columns.spacing = 16
|
||||||
columns.addArrangedSubview(Style.stack([
|
let walletColumn = Style.stack([
|
||||||
Style.label("现金余额", size: 13, color: AppColor.textSecondary), walletAmount, walletState
|
Style.label("现金余额", size: 13, color: Style.textSecondary), walletAmount, walletState
|
||||||
], spacing: 8))
|
], spacing: 8)
|
||||||
columns.addArrangedSubview(Style.stack([
|
columns.addArrangedSubview(walletColumn)
|
||||||
Style.label("积分", size: 13, color: AppColor.textSecondary), pointsAmount, pointsState
|
let divider = UIView()
|
||||||
], spacing: 8))
|
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.adjustsFontSizeToFitWidth = true
|
||||||
walletAmount.minimumScaleFactor = 0.6
|
walletAmount.minimumScaleFactor = 0.6
|
||||||
walletAmount.numberOfLines = 1
|
walletAmount.numberOfLines = 1
|
||||||
@@ -155,23 +206,19 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
]))
|
]))
|
||||||
conditionStack.addArrangedSubview(blockersCard)
|
conditionStack.addArrangedSubview(blockersCard)
|
||||||
let notices = Style.stack([
|
let notices = Style.stack([
|
||||||
Style.label("注销须知", size: 16, weight: .semibold),
|
notice("person.crop.circle", title: "仅注销当前身份"),
|
||||||
notice("person.crop.circle", title: "仅注销当前身份", detail: "同手机号的其他身份不受影响。"),
|
notice("clock", title: "7 天冷静期"),
|
||||||
notice("clock", title: "7 天冷静期", detail: "提交后可撤销申请,到期由服务端复核。"),
|
notice("exclamationmark.shield", title: "正式完成后不可恢复")
|
||||||
notice("exclamationmark.shield", title: "正式完成后不可恢复", detail: "历史订单、财务及审计记录按规则保留。")
|
|
||||||
], spacing: 16)
|
], 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)
|
let image = Style.icon(icon, size: 18)
|
||||||
image.snp.makeConstraints { $0.width.equalTo(22) }
|
image.snp.makeConstraints { $0.width.equalTo(22) }
|
||||||
let row = UIStackView(arrangedSubviews: [image, Style.stack([
|
let row = UIStackView(arrangedSubviews: [image, Style.label(title, size: 14, weight: .medium)])
|
||||||
Style.label(title, size: 14, weight: .medium),
|
|
||||||
Style.label(detail, size: 13, color: AppColor.textSecondary)
|
|
||||||
], spacing: 4)])
|
|
||||||
row.axis = .horizontal
|
row.axis = .horizontal
|
||||||
row.alignment = .top
|
row.alignment = .center
|
||||||
row.spacing = 10
|
row.spacing = 10
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
@@ -182,18 +229,22 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
codeField.textContentType = .oneTimeCode
|
codeField.textContentType = .oneTimeCode
|
||||||
codeField.accessibilityIdentifier = "deregister.code"
|
codeField.accessibilityIdentifier = "deregister.code"
|
||||||
codeField.accessibilityLabel = "短信验证码"
|
codeField.accessibilityLabel = "短信验证码"
|
||||||
reasonField.placeholder = "请输入注销原因"
|
|
||||||
reasonField.accessibilityIdentifier = "deregister.reason"
|
reasonField.accessibilityIdentifier = "deregister.reason"
|
||||||
reasonField.accessibilityLabel = "注销原因"
|
reasonField.accessibilityLabel = "注销原因,必填"
|
||||||
for field in [codeField, reasonField] {
|
codeField.font = .systemFont(ofSize: 16)
|
||||||
field.font = .systemFont(ofSize: 16)
|
codeField.autocorrectionType = .no
|
||||||
field.autocorrectionType = .no
|
codeField.iq.enableMode = .disabled
|
||||||
// 本页通过keyboardLayoutGuide和滚动区域避让,避免全局键盘库重复抬升整页。
|
codeField.addTarget(self, action: #selector(inputChanged), for: .editingChanged)
|
||||||
field.iq.enableMode = .disabled
|
codeField.addTarget(self, action: #selector(revealFocusedInput), for: .editingDidBegin)
|
||||||
field.addTarget(self, action: #selector(inputChanged), for: .editingChanged)
|
reasonField.font = .systemFont(ofSize: 15)
|
||||||
field.addTarget(self, action: #selector(revealFocusedInput), for: .editingDidBegin)
|
reasonField.textColor = Style.textPrimary
|
||||||
field.snp.makeConstraints { $0.height.equalTo(48) }
|
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.setTitle("获取验证码", for: .normal)
|
||||||
smsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
smsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
smsButton.accessibilityIdentifier = "deregister.sms"
|
smsButton.accessibilityIdentifier = "deregister.sms"
|
||||||
@@ -203,20 +254,50 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
smsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
|
smsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
|
||||||
let codeRow = UIStackView(arrangedSubviews: [codeField, smsButton])
|
let codeRow = UIStackView(arrangedSubviews: [codeField, smsButton])
|
||||||
codeRow.axis = .horizontal
|
codeRow.axis = .horizontal
|
||||||
|
codeRow.alignment = .center
|
||||||
codeRow.spacing = 12
|
codeRow.spacing = 12
|
||||||
verificationStack.addArrangedSubview(Style.card(Style.stack([
|
codeRow.layoutMargins = .init(top: 0, left: 14, bottom: 0, right: 8)
|
||||||
Style.label("短信验证", size: 16, weight: .semibold), codeRow,
|
codeRow.isLayoutMarginsRelativeArrangement = true
|
||||||
Style.label("验证码将发送至当前身份绑定的手机号。", size: 13, color: AppColor.textSecondary)
|
codeRow.layer.cornerRadius = 10
|
||||||
], spacing: 8)))
|
codeRow.layer.borderWidth = 1
|
||||||
verificationStack.addArrangedSubview(Style.card(Style.stack([
|
codeRow.layer.borderColor = Style.border.cgColor
|
||||||
Style.label("注销原因", size: 16, weight: .semibold), reasonField
|
let smsTitle = Style.label("短信验证码", size: 16, weight: .semibold)
|
||||||
], spacing: 8)))
|
reasonContainer.layer.cornerRadius = 10
|
||||||
backButton.setTitle("返回查看资产与须知", for: .normal)
|
reasonContainer.layer.borderWidth = 1
|
||||||
backButton.titleLabel?.font = .systemFont(ofSize: 14)
|
reasonContainer.layer.borderColor = Style.border.cgColor
|
||||||
backButton.accessibilityIdentifier = "deregister.back"
|
reasonContainer.addSubview(reasonField)
|
||||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
reasonContainer.addSubview(reasonPlaceholder)
|
||||||
backButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
|
reasonContainer.addSubview(reasonCount)
|
||||||
verificationStack.addArrangedSubview(backButton)
|
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() {
|
override func setupConstraints() {
|
||||||
@@ -262,7 +343,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func revealFocusedInput() {
|
@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)
|
let rect = field.convert(field.bounds, to: scrollView).insetBy(dx: 0, dy: -12)
|
||||||
scrollView.scrollRectToVisible(rect, animated: false)
|
scrollView.scrollRectToVisible(rect, animated: false)
|
||||||
}
|
}
|
||||||
@@ -273,8 +354,9 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
let unresolved = viewModel.step == .unresolvedRequest && !readOnly
|
let unresolved = viewModel.step == .unresolvedRequest && !readOnly
|
||||||
heading.text = readOnly ? "当前注销条件" : (verifying ? "验证绑定手机号" : "注销前,请确认")
|
heading.text = readOnly ? "当前注销条件" : (verifying ? "验证绑定手机号" : "注销前,请确认")
|
||||||
subtitle.text = verifying ? "完成验证后,即可提交注销申请。" : "仅注销此身份,其他身份不受影响。"
|
subtitle.text = verifying ? "完成验证后,即可提交注销申请。" : "仅注销此身份,其他身份不受影响。"
|
||||||
firstStep.textColor = verifying ? AppColor.textSecondary : AppColor.primary
|
applyStepStyle(firstStepView, number: firstStepNumber, title: firstStep, selected: !verifying)
|
||||||
secondStep.textColor = verifying ? AppColor.primary : AppColor.textSecondary
|
applyStepStyle(secondStepView, number: secondStepNumber, title: secondStep, selected: verifying)
|
||||||
|
identityCard.isHidden = verifying || unresolved
|
||||||
conditionStack.isHidden = verifying || unresolved
|
conditionStack.isHidden = verifying || unresolved
|
||||||
verificationStack.isHidden = !verifying
|
verificationStack.isHidden = !verifying
|
||||||
continueButton.isHidden = verifying || readOnly
|
continueButton.isHidden = verifying || readOnly
|
||||||
@@ -284,10 +366,9 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
submitButton.isEnabled = !busy && viewModel.canContinue && hasVerificationInput
|
submitButton.isEnabled = !busy && viewModel.canContinue && hasVerificationInput
|
||||||
smsButton.isEnabled = !busy && viewModel.canContinue
|
smsButton.isEnabled = !busy && viewModel.canContinue
|
||||||
codeField.isEnabled = !busy
|
codeField.isEnabled = !busy
|
||||||
reasonField.isEnabled = !busy
|
reasonField.isEditable = !busy
|
||||||
backButton.isEnabled = !busy
|
applyReasonInputStyle()
|
||||||
refreshControl.isEnabled = !busy
|
refreshControl.isEnabled = !busy
|
||||||
footerHint.text = verifying ? "7 天内可撤销,正式完成后不可恢复" : "请确认资产及注销须知后继续"
|
|
||||||
errorLabel.text = viewModel.errorMessage
|
errorLabel.text = viewModel.errorMessage
|
||||||
errorCard.isHidden = viewModel.errorMessage == nil
|
errorCard.isHidden = viewModel.errorMessage == nil
|
||||||
|
|
||||||
@@ -296,13 +377,13 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
pointsAmount.text = "\(value.pointsBalance)"
|
pointsAmount.text = "\(value.pointsBalance)"
|
||||||
walletState.text = value.walletWaived ? "已确认放弃" : "待确认放弃"
|
walletState.text = value.walletWaived ? "已确认放弃" : "待确认放弃"
|
||||||
pointsState.text = value.pointsWaived ? "已确认放弃" : "待确认放弃"
|
pointsState.text = value.pointsWaived ? "已确认放弃" : "待确认放弃"
|
||||||
walletState.textColor = value.walletWaived ? AppColor.primary : AppColor.textSecondary
|
walletState.textColor = value.walletWaived ? Style.primary : Style.textSecondary
|
||||||
pointsState.textColor = value.pointsWaived ? AppColor.primary : AppColor.textSecondary
|
pointsState.textColor = value.pointsWaived ? Style.primary : Style.textSecondary
|
||||||
let assetIssues = value.blockers.filter(\.isAssetConfirmation)
|
let assetIssues = value.blockers.filter(\.isAssetConfirmation)
|
||||||
|
let needsAssetHint = readOnly || assetIssues.contains { $0.code == "WAIVER_STALE" }
|
||||||
|
assetHint.isHidden = !needsAssetHint
|
||||||
assetHint.text = readOnly ? "以当前查询结果为准,冷静期内不能再次确认资产。"
|
assetHint.text = readOnly ? "以当前查询结果为准,冷静期内不能再次确认资产。"
|
||||||
: (assetIssues.contains { $0.code == "WAIVER_STALE" }
|
: "资产已变化,请按最新金额重新确认。"
|
||||||
? "资产已变化,请按最新金额重新确认。"
|
|
||||||
: "正式注销时,将清空已确认放弃的现金和积分。")
|
|
||||||
blockersCard.isHidden = value.businessBlockers.isEmpty
|
blockersCard.isHidden = value.businessBlockers.isEmpty
|
||||||
blockersLabel.text = value.businessBlockers.map { "• \($0.message)\n \($0.guidance)" }.joined(separator: "\n\n")
|
blockersLabel.text = value.businessBlockers.map { "• \($0.message)\n \($0.guidance)" }.joined(separator: "\n\n")
|
||||||
riskLabel.text = value.eligibleAt.map { "业务风险期预计结束:\($0)" }
|
riskLabel.text = value.eligibleAt.map { "业务风险期预计结束:\($0)" }
|
||||||
@@ -312,6 +393,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
pointsAmount.text = "—"
|
pointsAmount.text = "—"
|
||||||
walletState.text = "待查询"
|
walletState.text = "待查询"
|
||||||
pointsState.text = "待查询"
|
pointsState.text = "待查询"
|
||||||
|
assetHint.isHidden = false
|
||||||
assetHint.text = busy ? "正在查询资产与注销条件…" : "暂未获取到资产,请刷新重试。"
|
assetHint.text = busy ? "正在查询资产与注销条件…" : "暂未获取到资产,请刷新重试。"
|
||||||
blockersCard.isHidden = true
|
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 {
|
private var hasVerificationInput: Bool {
|
||||||
!(codeField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
let code = (codeField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
&& !(reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
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) {
|
private func run(_ operation: @escaping @MainActor () async -> Void) {
|
||||||
@@ -344,7 +444,7 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
self.actionTask = nil
|
self.actionTask = nil
|
||||||
if !self.readOnly, self.viewModel.submissionAccepted {
|
if !self.readOnly, self.viewModel.submissionAccepted {
|
||||||
self.didHandleAcceptedSubmission = true
|
self.didHandleAcceptedSubmission = true
|
||||||
self.onSubmissionAccepted()
|
self.onSubmissionAccepted(self.viewModel.submittedCoolingUntil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.applyViewModel()
|
self.applyViewModel()
|
||||||
@@ -364,20 +464,20 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
|
run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let alert = UIAlertController(title: "确认放弃账号资产?", message:
|
let sheet = StoreAccountDeregistrationConfirmationSheetViewController(
|
||||||
"当前身份:\(identityName)\n\n现金余额:¥\(snapshot.walletBalance)\n积分:\(snapshot.pointsBalance)\n\n我确认自愿放弃以上现金余额,并确认自愿放弃以上积分。正式注销时将按规则清零。", preferredStyle: .alert)
|
title: "确认放弃账号资产",
|
||||||
alert.addAction(UIAlertAction(title: "暂不确认", style: .cancel))
|
summaries: [
|
||||||
alert.addAction(UIAlertAction(title: "确认并继续", style: .destructive) { [weak self] _ in
|
.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 }
|
guard let self else { return }
|
||||||
self.run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
|
self.run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
|
||||||
})
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
}
|
||||||
|
present(sheet, animated: true)
|
||||||
@objc private func backTapped() {
|
|
||||||
view.endEditing(true)
|
|
||||||
viewModel.returnToConditions()
|
|
||||||
applyViewModel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func smsTapped() {
|
@objc private func smsTapped() {
|
||||||
@@ -390,20 +490,58 @@ final class StoreAccountDeregistrationViewController: BaseViewController {
|
|||||||
@objc private func submitTapped() {
|
@objc private func submitTapped() {
|
||||||
guard !readOnly, viewModel.canContinue, hasVerificationInput else { return }
|
guard !readOnly, viewModel.canContinue, hasVerificationInput else { return }
|
||||||
let code = codeField.text ?? ""
|
let code = codeField.text ?? ""
|
||||||
let reason = reasonField.text ?? ""
|
let reason = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
let alert = UIAlertController(title: "提交注销申请?", message:
|
let sheet = StoreAccountDeregistrationConfirmationSheetViewController(
|
||||||
"仅注销“\(identityName)”。提交成功后将退出登录,并进入 7 天冷静期,最终由服务端复核;正式完成不可恢复。\n\n重新登录或选中此身份会自动撤销尚未完成的申请。", preferredStyle: .alert)
|
title: "确认提交注销申请?",
|
||||||
alert.addAction(UIAlertAction(title: "暂不提交", style: .cancel))
|
summaries: [
|
||||||
alert.addAction(UIAlertAction(title: "确认提交申请", style: .destructive) { [weak self] _ in
|
.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)
|
self?.submitConfirmedApplication(smsCode: code, reason: reason)
|
||||||
})
|
}
|
||||||
present(alert, animated: true)
|
present(sheet, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用户确认最终弹窗后提交;仅明确成功触发一次退出,未知结果仍进入核验流程。
|
/// 用户确认最终弹窗后提交;仅明确成功触发一次退出态结果页,未知结果仍进入核验流程。
|
||||||
func submitConfirmedApplication(smsCode: String, reason: String) {
|
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) }
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ final class StoreAccountDeregistrationAccessTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 在 iPhone 上渲染冷静期页面,确认撤销入口可见;不调用真实网络或修改接口。
|
/// 在 iPhone 上渲染不可返回的冷静期页面,只保留退出和撤销入口。
|
||||||
func testCoolingPageRendersCancellationEntryOnPhysicalDevice() async throws {
|
func testCoolingPageRendersCancellationEntryOnPhysicalDevice() async throws {
|
||||||
let name = "DeregistrationCoolingPage.\(UUID().uuidString)"
|
let name = "DeregistrationCoolingPage.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: name)!
|
let defaults = UserDefaults(suiteName: name)!
|
||||||
@@ -30,6 +30,7 @@ final class StoreAccountDeregistrationAccessTests: XCTestCase {
|
|||||||
session.accountType = .storeUser
|
session.accountType = .storeUser
|
||||||
session.userId = "101"
|
session.userId = "101"
|
||||||
session.token = "isolated-token"
|
session.token = "isolated-token"
|
||||||
|
session.accountDisplayName = "示例门店"
|
||||||
let identity = try StoreAccountDeregistrationIdentity(session: session)
|
let identity = try StoreAccountDeregistrationIdentity(session: session)
|
||||||
let service = try DeregistrationCancellationStub()
|
let service = try DeregistrationCancellationStub()
|
||||||
let controller = StoreAccountDeregistrationAccessViewController(identity: identity, api: service, session: session) { _ in
|
let controller = StoreAccountDeregistrationAccessViewController(identity: identity, api: service, session: session) { _ in
|
||||||
@@ -50,11 +51,16 @@ final class StoreAccountDeregistrationAccessTests: XCTestCase {
|
|||||||
window.layoutIfNeeded()
|
window.layoutIfNeeded()
|
||||||
XCTAssertFalse(cancel.isHidden)
|
XCTAssertFalse(cancel.isHidden)
|
||||||
XCTAssertTrue(cancel.isEnabled)
|
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)
|
let deadline = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.deadline" } as? UILabel)
|
||||||
XCTAssertEqual(deadline.text, "2026-09-04 14:23:40")
|
XCTAssertEqual(deadline.text, "2026-09-04 14:23:40")
|
||||||
XCTAssertFalse(message.text?.contains("604776") == true)
|
XCTAssertFalse(message.text?.contains("604776") == true)
|
||||||
XCTAssertTrue(window.bounds.contains(cancel.convert(cancel.bounds, to: window)))
|
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
|
let attachment = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { context in
|
||||||
window.layer.render(in: context.cgContext)
|
window.layer.render(in: context.cgContext)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -193,32 +193,47 @@ final class StoreAccountDeregistrationCombinedTests: XCTestCase {
|
|||||||
/// 两步页面的实际UIKit布局和交互测试;独立窗口不触碰用户真实会话。
|
/// 两步页面的实际UIKit布局和交互测试;独立窗口不触碰用户真实会话。
|
||||||
@MainActor
|
@MainActor
|
||||||
final class StoreAccountDeregistrationRedesignTests: XCTestCase {
|
final class StoreAccountDeregistrationRedesignTests: XCTestCase {
|
||||||
/// 明确受理后立即调用一次退出,不等待后续GET;在途连点及成功后再次点击均不重发。
|
override func setUp() {
|
||||||
func testAcceptedSubmissionExitsOnceWithoutPostSubmissionQuery() async throws {
|
super.setUp()
|
||||||
|
GlobalLoadingManager.shared.hideAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
GlobalLoadingManager.shared.hideAll()
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 明确受理后只读获取截止时间并调用一次退出;在途连点及成功后再次点击均不重发。
|
||||||
|
func testAcceptedSubmissionCapturesCoolingDeadlineAndExitsOnce() async throws {
|
||||||
let service = CombinedDeregistrationService()
|
let service = CombinedDeregistrationService()
|
||||||
service.walletWaived = true
|
service.walletWaived = true
|
||||||
service.pointsWaived = true
|
service.pointsWaived = true
|
||||||
service.holdApply = 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 suite = "DeregistrationLogout.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
|
let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults)
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
|
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
|
||||||
var logoutCount = 0
|
var logoutCount = 0
|
||||||
|
var acceptedDeadline: String?
|
||||||
var unresolvedCount = 0
|
var unresolvedCount = 0
|
||||||
let controller = StoreAccountDeregistrationViewController(
|
let controller = StoreAccountDeregistrationViewController(
|
||||||
identityName: "测试门店", viewModel: model, api: service,
|
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)
|
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
|
||||||
defer { service.finishApply(); close(window) }
|
defer { service.finishApply(); close(window) }
|
||||||
await waitUntil { model.canContinue }
|
await waitUntil { model.canContinue }
|
||||||
model.beginVerification()
|
model.beginVerification()
|
||||||
let initialStatusCount = service.statusCount
|
let initialStatusCount = service.statusCount
|
||||||
let initialEligibilityCount = service.eligibilityCount
|
let initialEligibilityCount = service.eligibilityCount
|
||||||
controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用")
|
controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ")
|
||||||
await waitUntil { service.applyContinuation != nil }
|
await waitUntil { service.applyContinuation != nil }
|
||||||
controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用")
|
controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ")
|
||||||
XCTAssertEqual(logoutCount, 0)
|
XCTAssertEqual(logoutCount, 0)
|
||||||
XCTAssertTrue(model.isBusy)
|
XCTAssertTrue(model.isBusy)
|
||||||
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
|
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
|
||||||
@@ -226,13 +241,16 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
|
|||||||
capture(window, name: "deregister-submitting-global-loading")
|
capture(window, name: "deregister-submitting-global-loading")
|
||||||
service.finishApply()
|
service.finishApply()
|
||||||
await waitUntil { logoutCount == 1 }
|
await waitUntil { logoutCount == 1 }
|
||||||
controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用")
|
controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ")
|
||||||
XCTAssertEqual(logoutCount, 1)
|
XCTAssertEqual(logoutCount, 1)
|
||||||
XCTAssertEqual(unresolvedCount, 0)
|
XCTAssertEqual(unresolvedCount, 0)
|
||||||
XCTAssertEqual(service.mutations, ["apply"])
|
XCTAssertEqual(service.mutations, ["apply"])
|
||||||
XCTAssertEqual(service.statusCount, initialStatusCount + 1, "只保留提交前校验")
|
XCTAssertEqual(service.lastReason, "不再使用")
|
||||||
|
XCTAssertEqual(service.statusCount, initialStatusCount + 2, "提交前校验并在成功后只读获取截止时间")
|
||||||
XCTAssertEqual(service.eligibilityCount, initialEligibilityCount + 1)
|
XCTAssertEqual(service.eligibilityCount, initialEligibilityCount + 1)
|
||||||
XCTAssertTrue(model.submissionAccepted)
|
XCTAssertTrue(model.submissionAccepted)
|
||||||
|
XCTAssertEqual(model.submittedCoolingUntil, coolingStatus.coolingUntil)
|
||||||
|
XCTAssertEqual(acceptedDeadline, coolingStatus.coolingUntil)
|
||||||
XCTAssertNil(model.errorMessage)
|
XCTAssertNil(model.errorMessage)
|
||||||
XCTAssertTrue(store.hasUnresolvedSubmission, "保留意图供下次登录核实")
|
XCTAssertTrue(store.hasUnresolvedSubmission, "保留意图供下次登录核实")
|
||||||
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
||||||
@@ -250,17 +268,29 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
|
|||||||
var unresolvedCount = 0
|
var unresolvedCount = 0
|
||||||
let controller = StoreAccountDeregistrationViewController(
|
let controller = StoreAccountDeregistrationViewController(
|
||||||
identityName: "测试门店", viewModel: model, api: service,
|
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)
|
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
|
||||||
defer { close(window) }
|
defer { close(window) }
|
||||||
await waitUntil { model.canContinue }
|
await waitUntil { model.canContinue }
|
||||||
model.beginVerification()
|
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: "不再使用")
|
controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用")
|
||||||
await waitUntil { model.errorMessage != nil && !model.isBusy }
|
await waitUntil { model.errorMessage != nil && !model.isBusy }
|
||||||
XCTAssertEqual(logoutCount, 0)
|
XCTAssertEqual(logoutCount, 0)
|
||||||
XCTAssertEqual(unresolvedCount, uncertain ? 1 : 0)
|
XCTAssertEqual(unresolvedCount, uncertain ? 1 : 0)
|
||||||
XCTAssertEqual(model.submissionAttempted, uncertain)
|
XCTAssertEqual(model.submissionAttempted, uncertain)
|
||||||
XCTAssertFalse(model.submissionAccepted)
|
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"])
|
XCTAssertEqual(service.mutations, ["apply"])
|
||||||
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
||||||
}
|
}
|
||||||
@@ -281,7 +311,7 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
|
|||||||
next.sendActions(for: .touchUpInside)
|
next.sendActions(for: .touchUpInside)
|
||||||
await waitUntil { model.step == .verification }
|
await waitUntil { model.step == .verification }
|
||||||
let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField)
|
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"
|
code.text = "654321"
|
||||||
reason.text = "不再使用"
|
reason.text = "不再使用"
|
||||||
let scroll = try XCTUnwrap(allViews(controller.view).first { $0 is UIScrollView } as? UIScrollView)
|
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 }
|
await waitUntil { service.statusCount == count + 1 && !model.isBusy }
|
||||||
let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
|
let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
|
||||||
XCTAssertFalse(texts.contains("景区"))
|
XCTAssertFalse(texts.contains("景区"))
|
||||||
XCTAssertTrue(texts.contains("同手机号的其他身份不受影响"))
|
XCTAssertTrue(texts.contains("仅注销当前身份"))
|
||||||
|
XCTAssertFalse(texts.contains("同手机号的其他身份不受影响"))
|
||||||
|
XCTAssertFalse(texts.contains("到期由服务端复核"))
|
||||||
|
XCTAssertFalse(texts.contains("历史订单、财务及审计记录"))
|
||||||
XCTAssertFalse(StoreAccountDeregistrationError.unsupportedIdentity.localizedDescription.contains("景区"))
|
XCTAssertFalse(StoreAccountDeregistrationError.unsupportedIdentity.localizedDescription.contains("景区"))
|
||||||
XCTAssertTrue(service.mutations.isEmpty)
|
XCTAssertTrue(service.mutations.isEmpty)
|
||||||
XCTAssertFalse(refresh.isRefreshing)
|
XCTAssertFalse(refresh.isRefreshing)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 点击提交仅显示最终确认,明确退出登录和冷静期,不自动申请或发送短信。
|
/// 点击提交仅显示高保真最终确认;原因必填并在确认弹层展示规范化内容。
|
||||||
func testSubmitRequiresFinalConfirmationWithLogoutNotice() async throws {
|
func testSubmitRequiresFinalConfirmationWithLogoutNotice() async throws {
|
||||||
let service = CombinedDeregistrationService()
|
let service = CombinedDeregistrationService()
|
||||||
service.walletWaived = true
|
service.walletWaived = true
|
||||||
@@ -338,7 +371,8 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
|
|||||||
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
|
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
|
||||||
var logoutCount = 0
|
var logoutCount = 0
|
||||||
let controller = StoreAccountDeregistrationViewController(
|
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)
|
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
|
||||||
defer { close(window) }
|
defer { close(window) }
|
||||||
await waitUntil { model.canContinue }
|
await waitUntil { model.canContinue }
|
||||||
@@ -346,23 +380,107 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
|
|||||||
next.sendActions(for: .touchUpInside)
|
next.sendActions(for: .touchUpInside)
|
||||||
await waitUntil { model.step == .verification }
|
await waitUntil { model.step == .verification }
|
||||||
let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField)
|
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"
|
code.text = "654321"
|
||||||
reason.text = "不再使用"
|
code.sendActions(for: .editingChanged)
|
||||||
reason.sendActions(for: .editingChanged)
|
|
||||||
let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton)
|
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)
|
submit.sendActions(for: .touchUpInside)
|
||||||
await waitUntil { controller.presentedViewController is UIAlertController }
|
await waitUntil { controller.presentedViewController is StoreAccountDeregistrationConfirmationSheetViewController }
|
||||||
let alert = try XCTUnwrap(controller.presentedViewController as? UIAlertController)
|
let sheet = try XCTUnwrap(controller.presentedViewController as? StoreAccountDeregistrationConfirmationSheetViewController)
|
||||||
XCTAssertTrue(alert.message?.contains("提交成功后将退出登录") == true)
|
sheet.loadViewIfNeeded()
|
||||||
XCTAssertTrue(alert.message?.contains("当前测试身份") == true)
|
let texts = allViews(sheet.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
|
||||||
XCTAssertTrue(alert.message?.contains("7 天冷静期") == true)
|
XCTAssertTrue(texts.contains("确认提交注销申请?"))
|
||||||
XCTAssertTrue(alert.message?.contains("正式完成不可恢复") == true)
|
XCTAssertTrue(texts.contains("当前测试身份"))
|
||||||
XCTAssertEqual(alert.actions.map(\.title), ["暂不提交", "确认提交申请"])
|
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)
|
XCTAssertTrue(service.mutations.isEmpty)
|
||||||
XCTAssertEqual(logoutCount, 0)
|
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小屏上仅一个主按钮,零资产确认也先弹窗,没有自动提交。
|
/// 375pt小屏上仅一个主按钮,零资产确认也先弹窗,没有自动提交。
|
||||||
func testSingleAssetButtonShowsOneExplicitConfirmation() async throws {
|
func testSingleAssetButtonShowsOneExplicitConfirmation() async throws {
|
||||||
let service = CombinedDeregistrationService()
|
let service = CombinedDeregistrationService()
|
||||||
@@ -383,16 +501,21 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
|
|||||||
XCTAssertTrue(window.bounds.contains(next.convert(next.bounds, to: window)))
|
XCTAssertTrue(window.bounds.contains(next.convert(next.bounds, to: window)))
|
||||||
capture(window, name: "redesign-assets-375")
|
capture(window, name: "redesign-assets-375")
|
||||||
next.sendActions(for: .touchUpInside)
|
next.sendActions(for: .touchUpInside)
|
||||||
await waitUntil { controller.presentedViewController is UIAlertController }
|
await waitUntil { controller.presentedViewController is StoreAccountDeregistrationConfirmationSheetViewController }
|
||||||
let alert = try XCTUnwrap(controller.presentedViewController as? UIAlertController)
|
let sheet = try XCTUnwrap(controller.presentedViewController as? StoreAccountDeregistrationConfirmationSheetViewController)
|
||||||
XCTAssertEqual(alert.actions.map(\.title), ["暂不确认", "确认并继续"])
|
sheet.loadViewIfNeeded()
|
||||||
XCTAssertTrue(alert.message?.contains("现金余额:¥0.00") == true)
|
let sheetTexts = allViews(sheet.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
|
||||||
XCTAssertTrue(alert.message?.contains("积分:0") == true)
|
XCTAssertTrue(sheetTexts.contains("确认放弃账号资产"))
|
||||||
XCTAssertTrue(alert.message?.contains("确认自愿放弃以上现金余额,并确认自愿放弃以上积分") == true)
|
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)
|
XCTAssertTrue(service.mutations.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 已确认资产不再弹窗;验证码与获取按钮同行,输入完整前禁用提交,键盘不遮挡操作区。
|
/// 已确认资产不再弹窗;验证码和原因均必填,多行输入与错误提示不被键盘遮挡。
|
||||||
func testVerificationLayoutInputAndKeyboard() async throws {
|
func testVerificationLayoutInputAndKeyboard() async throws {
|
||||||
let service = CombinedDeregistrationService()
|
let service = CombinedDeregistrationService()
|
||||||
service.walletWaived = true
|
service.walletWaived = true
|
||||||
@@ -406,24 +529,37 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
|
|||||||
next.sendActions(for: .touchUpInside)
|
next.sendActions(for: .touchUpInside)
|
||||||
await waitUntil { model.step == .verification }
|
await waitUntil { model.step == .verification }
|
||||||
window.layoutIfNeeded()
|
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 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 sms = try XCTUnwrap(find(controller.view, "deregister.sms") as? UIButton)
|
||||||
let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton)
|
let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton)
|
||||||
XCTAssertEqual(code.textContentType, .oneTimeCode)
|
XCTAssertEqual(code.textContentType, .oneTimeCode)
|
||||||
XCTAssertEqual(code.keyboardType, .numberPad)
|
XCTAssertEqual(code.keyboardType, .numberPad)
|
||||||
XCTAssertEqual(code.superview, sms.superview)
|
XCTAssertEqual(code.superview, sms.superview)
|
||||||
|
XCTAssertEqual(find(controller.view, "deregister.reason.title")?.accessibilityLabel, "注销原因,必填")
|
||||||
XCTAssertFalse(submit.isEnabled)
|
XCTAssertFalse(submit.isEnabled)
|
||||||
capture(window, name: "redesign-verification")
|
capture(window, name: "redesign-verification")
|
||||||
code.text = "123456"
|
code.text = "123456"
|
||||||
code.sendActions(for: .editingChanged)
|
code.sendActions(for: .editingChanged)
|
||||||
XCTAssertFalse(submit.isEnabled)
|
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.text = "测试"
|
||||||
reason.sendActions(for: .editingChanged)
|
controller.textViewDidChange(reason)
|
||||||
XCTAssertTrue(submit.isEnabled)
|
XCTAssertTrue(submit.isEnabled)
|
||||||
|
XCTAssertTrue(reasonError.isHidden)
|
||||||
let navigationFrame = controller.navigationController?.view.frame
|
let navigationFrame = controller.navigationController?.view.frame
|
||||||
code.becomeFirstResponder()
|
XCTAssertTrue(code.becomeFirstResponder())
|
||||||
await waitUntil { controller.view.keyboardLayoutGuide.layoutFrame.height > controller.view.safeAreaInsets.bottom + 80 }
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
window.layoutIfNeeded()
|
window.layoutIfNeeded()
|
||||||
let frame = submit.convert(submit.bounds, to: controller.view)
|
let frame = submit.convert(submit.bounds, to: controller.view)
|
||||||
XCTAssertLessThanOrEqual(frame.maxY, controller.view.keyboardLayoutGuide.layoutFrame.minY + 1)
|
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 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 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 {
|
private func waitUntil(_ condition: () -> Bool) async {
|
||||||
for _ in 0..<150 {
|
for _ in 0..<150 {
|
||||||
if condition() { return }
|
if condition() { return }
|
||||||
@@ -559,6 +703,7 @@ private final class CombinedDeregistrationService: StoreAccountDeregistrationSer
|
|||||||
var applyContinuation: CheckedContinuation<Void, Never>?
|
var applyContinuation: CheckedContinuation<Void, Never>?
|
||||||
var holdNextStatus = false
|
var holdNextStatus = false
|
||||||
var statusContinuation: CheckedContinuation<Void, Never>?
|
var statusContinuation: CheckedContinuation<Void, Never>?
|
||||||
|
var lastReason: String?
|
||||||
|
|
||||||
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
|
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
|
||||||
eligibilityCount += 1
|
eligibilityCount += 1
|
||||||
@@ -600,6 +745,7 @@ private final class CombinedDeregistrationService: StoreAccountDeregistrationSer
|
|||||||
func sendSMS() async throws { mutations.append("sms") }
|
func sendSMS() async throws { mutations.append("sms") }
|
||||||
func apply(smsCode: String, reason: String) async throws {
|
func apply(smsCode: String, reason: String) async throws {
|
||||||
mutations.append("apply")
|
mutations.append("apply")
|
||||||
|
lastReason = reason
|
||||||
if holdApply { await withCheckedContinuation { applyContinuation = $0 } }
|
if holdApply { await withCheckedContinuation { applyContinuation = $0 } }
|
||||||
if let applyError { throw applyError }
|
if let applyError { throw applyError }
|
||||||
onApply?()
|
onApply?()
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase {
|
|||||||
await waitUntil { context.message.contains("2026-09-04 15:27:24") }
|
await waitUntil { context.message.contains("2026-09-04 15:27:24") }
|
||||||
XCTAssertNotNil(context.coordinator.accessController)
|
XCTAssertNotNil(context.coordinator.accessController)
|
||||||
XCTAssertFalse(context.window.rootViewController === businessRoot)
|
XCTAssertFalse(context.window.rootViewController === businessRoot)
|
||||||
XCTAssertTrue(context.message.contains("处于冷静期"))
|
XCTAssertTrue(context.message.contains("注销申请已提交"))
|
||||||
XCTAssertFalse(context.message.contains("604681"))
|
XCTAssertFalse(context.message.contains("604681"))
|
||||||
XCTAssertTrue(context.bindingSuspended)
|
XCTAssertTrue(context.bindingSuspended)
|
||||||
XCTAssertEqual(context.resumeCount, 1)
|
XCTAssertEqual(context.resumeCount, 1)
|
||||||
@@ -167,7 +167,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase {
|
|||||||
let original = context.window.rootViewController
|
let original = context.window.rootViewController
|
||||||
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
|
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
|
||||||
context.coordinator.recordRestriction(requestToken: context.session.token)
|
context.coordinator.recordRestriction(requestToken: context.session.token)
|
||||||
await waitUntil { context.message.contains("处于冷静期") }
|
await waitUntil { context.message.contains("注销申请已提交") }
|
||||||
XCTAssertTrue(context.bindingSuspended)
|
XCTAssertTrue(context.bindingSuspended)
|
||||||
XCTAssertEqual(context.resumeCount, 1)
|
XCTAssertEqual(context.resumeCount, 1)
|
||||||
let gate = context.coordinator.accessController
|
let gate = context.coordinator.accessController
|
||||||
@@ -231,7 +231,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase {
|
|||||||
XCTAssertTrue(context.window.rootViewController === loginRoot)
|
XCTAssertTrue(context.window.rootViewController === loginRoot)
|
||||||
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
|
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
|
||||||
context.service.finishRead()
|
context.service.finishRead()
|
||||||
await waitUntil { context.message.contains("处于冷静期") }
|
await waitUntil { context.message.contains("注销申请已提交") }
|
||||||
XCTAssertNotNil(context.coordinator.accessController)
|
XCTAssertNotNil(context.coordinator.accessController)
|
||||||
XCTAssertEqual(context.service.statusCount, 2)
|
XCTAssertEqual(context.service.statusCount, 2)
|
||||||
XCTAssertEqual(context.resumeCount, 0)
|
XCTAssertEqual(context.resumeCount, 0)
|
||||||
@@ -292,7 +292,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase {
|
|||||||
defer { context.cleanUp() }
|
defer { context.cleanUp() }
|
||||||
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
|
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
|
||||||
await context.startChecking()
|
await context.startChecking()
|
||||||
await waitUntil { context.message.contains("处于冷静期") }
|
await waitUntil { context.message.contains("注销申请已提交") }
|
||||||
let gate = try XCTUnwrap(context.coordinator.accessController)
|
let gate = try XCTUnwrap(context.coordinator.accessController)
|
||||||
let cancel = try XCTUnwrap(context.button("deregister.access.cancel"))
|
let cancel = try XCTUnwrap(context.button("deregister.access.cancel"))
|
||||||
cancel.sendActions(for: .touchUpInside)
|
cancel.sendActions(for: .touchUpInside)
|
||||||
|
|||||||
@@ -267,6 +267,40 @@ final class StoreAccountDeregistrationViewModelTests: XCTestCase {
|
|||||||
XCTAssertFalse(model.canContinue)
|
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 {
|
func testLostApplyResponsePreventsAutomaticRetry() async throws {
|
||||||
let service = try DeregistrationServiceStub()
|
let service = try DeregistrationServiceStub()
|
||||||
@@ -285,8 +319,8 @@ final class StoreAccountDeregistrationViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(model.step, .unresolvedRequest)
|
XCTAssertEqual(model.step, .unresolvedRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 服务端明确拒绝验证码时允许重新核验,但不能保留之前的提交权限。
|
/// 服务端明确拒绝验证码时保留已核验条件并停留在手机验证,不要求重新确认资产。
|
||||||
func testServerRejectionRequiresFreshEligibility() async throws {
|
func testVerificationCodeRejectionStaysInVerification() async throws {
|
||||||
let service = try DeregistrationServiceStub()
|
let service = try DeregistrationServiceStub()
|
||||||
service.current = try StoreAccountDeregistrationFixtures.ready()
|
service.current = try StoreAccountDeregistrationFixtures.ready()
|
||||||
service.applyError = APIError.serverCode(100001, "验证码错误")
|
service.applyError = APIError.serverCode(100001, "验证码错误")
|
||||||
@@ -295,10 +329,10 @@ final class StoreAccountDeregistrationViewModelTests: XCTestCase {
|
|||||||
model.beginVerification()
|
model.beginVerification()
|
||||||
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
|
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
|
||||||
XCTAssertFalse(model.submissionAttempted)
|
XCTAssertFalse(model.submissionAttempted)
|
||||||
XCTAssertFalse(model.canContinue)
|
|
||||||
XCTAssertEqual(model.errorMessage, "验证码错误")
|
|
||||||
await model.refresh(api: service)
|
|
||||||
XCTAssertTrue(model.canContinue)
|
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 readError: Error?
|
||||||
var applyError: Error?
|
var applyError: Error?
|
||||||
var mutations: [String] = []
|
var mutations: [String] = []
|
||||||
|
var eligibilityCount = 0
|
||||||
|
var statusCount = 0
|
||||||
var lastCode: String?
|
var lastCode: String?
|
||||||
var lastReason: String?
|
var lastReason: String?
|
||||||
|
|
||||||
init() throws { current = try StoreAccountDeregistrationFixtures.eligibility() }
|
init() throws { current = try StoreAccountDeregistrationFixtures.eligibility() }
|
||||||
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
|
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
|
||||||
|
eligibilityCount += 1
|
||||||
if let readError { throw readError }
|
if let readError { throw readError }
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
func status() async throws -> StoreAccountDeregistrationStatus {
|
func status() async throws -> StoreAccountDeregistrationStatus {
|
||||||
|
statusCount += 1
|
||||||
if let readError { throw readError }
|
if let readError { throw readError }
|
||||||
return currentStatus
|
return currentStatus
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user