From 5cf4409bad69fe1b296f9a5968a7620a5e16c34c Mon Sep 17 00:00:00 2001 From: han xin <17600882423@163.com> Date: Fri, 21 Aug 2026 15:51:52 +0800 Subject: [PATCH] =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=E7=BA=BF=E4=B8=8B?= =?UTF-8?q?=E6=94=B6=E6=AC=BE=E8=AE=B0=E5=BD=95=E5=92=8C=20ai=20=E4=BF=AE?= =?UTF-8?q?=E5=9B=BE=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design-qa.md | 45 ++ docs/相册管理与AI修图功能说明.md | 24 +- .../OfflineCollection/Contents.json | 6 + .../Contents.json | 15 + .../payment_method_alipay.svg | 4 + .../Contents.json | 15 + .../payment_method_cash.svg | 5 + .../Contents.json | 15 + .../payment_method_wechat.svg | 4 + .../Models/AccountDeletionModels.swift | 134 +++ .../Services/AccountDeletionMockService.swift | 198 +++++ .../AccountDeletionViewModels.swift | 184 +++++ .../Features/Auth/Models/AuthModels.swift | 3 +- .../Models/OfflineCollectionModels.swift | 290 +++++++ .../OfflineCollectionMockService.swift | 359 +++++++++ .../OfflineCollectionViewModels.swift | 248 ++++++ .../Models/TravelAlbumAutoRetouchModels.swift | 122 +++ .../Models/TravelAlbumOTGTransferModels.swift | 9 +- .../Storage/TravelAlbumOTGPhotoStore.swift | 65 +- .../TravelAlbumAIEditResultStore.swift | 12 + .../TravelAlbumAutoRetouchProcessor.swift | 59 ++ .../TravelAlbumEntryViewModel.swift | 11 +- .../WiredCameraTransferViewModel.swift | 189 ++++- suixinkan/SceneDelegate.swift | 31 +- suixinkan/UI/Login/LoginViewController.swift | 53 +- suixinkan/UI/Login/LoginViewModel.swift | 25 + ...OfflineCollectionDailyViewController.swift | 683 ++++++++++++++++ ...CollectionRegistrationViewController.swift | 340 ++++++++ .../Views/OfflineCollectionHomeView.swift | 245 ++++++ ...ymentCollectionDetailsViewController.swift | 59 +- .../AccountDeletionViewControllers.swift | 761 ++++++++++++++++++ .../UI/Setting/SettingViewController.swift | 27 +- ...CreateTravelAlbumSheetViewController.swift | 96 ++- ...utoRetouchSettingSheetViewController.swift | 416 ++++++++++ .../WiredCameraTransferViewController.swift | 81 +- .../AccountDeletionFeatureTests.swift | 137 ++++ suixinkanTests/LoginViewModelTests.swift | 87 ++ .../OfflineCollectionFeatureTests.swift | 205 +++++ .../TravelAlbumOTGStorageTests.swift | 38 +- .../TravelAlbumViewModelTests.swift | 238 +++++- 40 files changed, 5482 insertions(+), 56 deletions(-) create mode 100644 design-qa.md create mode 100644 suixinkan/Assets.xcassets/OfflineCollection/Contents.json create mode 100644 suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/payment_method_alipay.svg create mode 100644 suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/payment_method_cash.svg create mode 100644 suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/payment_method_wechat.svg create mode 100644 suixinkan/Features/AccountDeletion/Models/AccountDeletionModels.swift create mode 100644 suixinkan/Features/AccountDeletion/Services/AccountDeletionMockService.swift create mode 100644 suixinkan/Features/AccountDeletion/ViewModels/AccountDeletionViewModels.swift create mode 100644 suixinkan/Features/OfflineCollection/Models/OfflineCollectionModels.swift create mode 100644 suixinkan/Features/OfflineCollection/Services/OfflineCollectionMockService.swift create mode 100644 suixinkan/Features/OfflineCollection/ViewModels/OfflineCollectionViewModels.swift create mode 100644 suixinkan/Features/TravelAlbum/Models/TravelAlbumAutoRetouchModels.swift create mode 100644 suixinkan/Features/TravelAlbum/Services/TravelAlbumAutoRetouchProcessor.swift create mode 100644 suixinkan/UI/OfflineCollection/OfflineCollectionDailyViewController.swift create mode 100644 suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift create mode 100644 suixinkan/UI/OfflineCollection/Views/OfflineCollectionHomeView.swift create mode 100644 suixinkan/UI/Setting/AccountDeletionViewControllers.swift create mode 100644 suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift create mode 100644 suixinkanTests/AccountDeletionFeatureTests.swift create mode 100644 suixinkanTests/OfflineCollectionFeatureTests.swift diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..e837f90 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,45 @@ +# 注销账号页 Design QA + +## 对比目标 + +- source visual truth path: `/Users/hxindemacmini/.codex/generated_images/01a01eb7-eda7-7f73-ae64-60c5195c974a/exec-1ece6a7c-2a8f-4d8e-a33f-aa5a4fc11c80.png` +- implementation screenshot path: `/Users/hxindemacmini/.codex/visualizations/2026/08/20/01a01eb7-eda7-7f73-ae64-60c5195c974a/account-deletion-implementation-v2.png` +- full-view comparison path: `/Users/hxindemacmini/.codex/visualizations/2026/08/20/01a01eb7-eda7-7f73-ae64-60c5195c974a/account-deletion-comparison-v2.png` +- viewport: iPhone 17 Pro 模拟器,402 × 874 pt,竖屏,浅色模式 +- source pixels: 852 × 1846,按 @2x 归一化为约 426 × 923 pt +- implementation pixels: 1206 × 2622,@3x,对应 402 × 874 pt +- density normalization: 对比图将实现截图归一化到 852 × 1846,与设计图等像素并排比较 +- state: 资产核验加载完成、确认框未勾选、主按钮可点击 + +## Full-view comparison evidence + +信息层级、红色风险提示、四项资产卡片、资产明细入口、注销后果卡片、确认框和蓝色主按钮均与设计方向一致。实现保留 iOS 原生状态栏和安全区;设计图没有状态栏且画布更高,因此较小设备首屏只展示注销后果卡片的前两项,剩余内容通过原生滚动区域查看,底部确认和主按钮始终可操作。 + +## Focused region comparison evidence + +无需额外局部裁图。等尺寸并排图中,标题、资产数字、分割线、风险色、图标、确认框和主按钮均清晰可读,已经足以核对字体、间距、颜色、图标和文案。 + +## Findings + +- 无可执行的 P0、P1 或 P2 差异。 +- 字体与排版:使用系统中文字体;标题、资产名、资产值和说明文本的层级、字重与设计一致,无截断。 +- 间距与布局:16pt 页面边距、12pt 圆角、资产行节奏和卡片间距一致;原生状态栏及较短屏幕导致后果列表需要滚动,属于平台和视口差异。 +- 颜色与视觉令牌:使用项目现有的页面背景、主色、危险色和边框令牌,语义与设计一致。 +- 图片与图标:页面没有照片类素材;风险和后果图标使用 SF Symbols,清晰且风格统一。 +- 文案与内容:资产名称、数值、注销后果和7天恢复提示与确认稿一致;没有“可放弃”标签。 + +## Comparison history + +1. 第一轮证据:`account-deletion-comparison-v1.png`。发现 [P2] 设计图在未勾选状态仍显示蓝色主按钮,而实现显示灰色禁用按钮。 +2. 修正:将主按钮的视觉可点击状态与业务确认校验分离。资产加载完成后按钮保持蓝色;未勾选时点击会提示先确认,不能进入验证页。 +3. 第二轮证据:`account-deletion-comparison-v2.png`。按钮状态已与设计一致,勾选保护由单元测试验证,无新增 P0/P1/P2 差异。 + +## Implementation checklist + +- [x] 保持资产数据中性展示,不出现“可放弃”标签 +- [x] 保持未勾选时蓝色主按钮视觉 +- [x] 未勾选时阻止进入验证流程并提示用户 +- [x] 小屏幕使用滚动区域,底部确认与主操作常驻安全区 +- [x] 保留原生 iOS 状态栏、导航栏和动态岛安全区 + +final result: passed diff --git a/docs/相册管理与AI修图功能说明.md b/docs/相册管理与AI修图功能说明.md index cd53e20..43be42e 100644 --- a/docs/相册管理与AI修图功能说明.md +++ b/docs/相册管理与AI修图功能说明.md @@ -1,8 +1,8 @@ # 相册管理与 AI 修图功能说明 -> 文档日期:2026-07-29 +> 文档日期:2026-08-20 > 适用范围:新增相册 → 相册任务 → 相册管理 → 照片上传与传输 → 照片预览与 AI 修图 -> 数据说明:相册和照片列表、有线相机连接及照片传输继续使用项目已有真实数据能力;传输模式选择保存在本地。原图精修结果、氛围感结果、封面模板、剩余 9 张额度及状态角标目前为本地可演示数据,尚未接入服务端持久化,App 进程重启后会清空。 +> 数据说明:相册和照片列表、有线相机连接及原图上传继续使用项目已有真实数据能力;传输模式与相册自动修图模板保存在本地。自动修图目前使用本地图像效果演示,修图结果图和任务状态可随 OTG 相册恢复;手动原图精修、氛围感、封面模板和剩余 9 张额度仍为本地演示数据。 ## 1. 照片预览、左右滑动与底部操作 @@ -51,6 +51,21 @@ 本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据,不创建新的相册或假照片。 +### 自动 AI 修图 + +新建相册时在价格字段下方固定展示“不修图”和“AI 修图”两个选项,默认选中“不修图”。点击“AI 修图”后再从底部弹出模板选择;每个模板同时展示本地演示照片套用该效果后的缩略图和效果说明。未选模板时不会开启 AI 修图。相册创建成功后,配置按真实相册 ID 保存,上传页仅回显当前一级方式“不修图 / AI 修图”。 + +有线传输页顶部的设置项也仅回显“不修图 / AI 修图”。点击后底部弹窗的第一层始终只有这两个选项;选择“AI 修图”后进入带效果缩略图的模板列表,可返回第一层重新选择。新配置只影响尚未开始的上传任务。每张照片仍先按原流程上传原图,得到服务端素材 ID 后才执行自动修图: + +| 阶段 | 列表展示 | 统计归类 | 失败处理 | +| --- | --- | --- | --- | +| 原图上传 | 蓝色“上传中”及进度 | 全部 | 进入上传失败,可重传 | +| 自动修图 | 蓝色“修图中” | 全部 | 不回退原图上传结果 | +| 修图成功 | 绿色“已上传”,缩略图右上角显示紫色“修” | 已上传 | 使用修图后结果预览 | +| 修图失败 | 红色“修图失败” | 已上传 | 更多菜单选择“重新修图”,不重复上传原图 | + +修图中退出 App 时,再次进入会恢复为“修图失败”并允许重试,避免任务长期停留在处理中。 + 进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。 ### 状态流转图 @@ -65,6 +80,11 @@ - [ ] 弹窗同时显示“边拍边传”和“拍后传输”及对应说明。 - [ ] 选择“边拍边传”后,下一页模式显示为“边拍边传”。 - [ ] 选择“拍后传输”后,下一页模式显示为“拍后传输”。 +- [ ] 新建相册固定展示“不修图”和“AI 修图”两个选项。 +- [ ] 选择“AI 修图”后才弹出模板列表,每个模板都能看到差异化效果图。 +- [ ] 上传页一级弹窗只有两种修图方式,新配置仅影响后续上传。 +- [ ] 修图中、修图成功“修”角标和修图失败三种状态展示正确。 +- [ ] 修图失败仍计入“已上传”,点击“重新修图”不重复上传原图。 - [ ] 本次选择不会被上一次保存的模式覆盖。 - [ ] 取消或关闭弹窗后仍停留在相册管理。 - [ ] 非批量选择状态点击缩略图后,从被点击照片进入全屏预览。 diff --git a/suixinkan/Assets.xcassets/OfflineCollection/Contents.json b/suixinkan/Assets.xcassets/OfflineCollection/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/Contents.json b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/Contents.json new file mode 100644 index 0000000..596092e --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "payment_method_alipay.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/payment_method_alipay.svg b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/payment_method_alipay.svg new file mode 100644 index 0000000..aa4b6e3 --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/payment_method_alipay.svg @@ -0,0 +1,4 @@ + + + + diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/Contents.json b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/Contents.json new file mode 100644 index 0000000..d71283b --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "payment_method_cash.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/payment_method_cash.svg b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/payment_method_cash.svg new file mode 100644 index 0000000..9669abc --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/payment_method_cash.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/Contents.json b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/Contents.json new file mode 100644 index 0000000..04ca0d2 --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "payment_method_wechat.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/payment_method_wechat.svg b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/payment_method_wechat.svg new file mode 100644 index 0000000..f3a8ed8 --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/payment_method_wechat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/suixinkan/Features/AccountDeletion/Models/AccountDeletionModels.swift b/suixinkan/Features/AccountDeletion/Models/AccountDeletionModels.swift new file mode 100644 index 0000000..c748d2a --- /dev/null +++ b/suixinkan/Features/AccountDeletion/Models/AccountDeletionModels.swift @@ -0,0 +1,134 @@ +// +// AccountDeletionModels.swift +// suixinkan +// + +import Foundation + +/// 注销核验资产,使用中性文案展示当前账号拥有的数据与权益。 +struct AccountDeletionAssetSummary: Codable, Hashable, Sendable { + + /// 注销核验覆盖的资产类型。 + enum Kind: String, Codable, CaseIterable, Sendable { + case wallet + case works + case projects + case cloudFiles + } + + let kind: Kind + let title: String + let valueText: String +} + +/// 注销前置核验结果,包含资产快照和注销影响说明。 +struct AccountDeletionPrecheck: Equatable, Sendable { + let assets: [AccountDeletionAssetSummary] + let consequences: [String] +} + +/// 注销申请当前状态。 +enum AccountDeletionRequestStatus: String, Codable, Sendable { + case pending + case canceled + case completed +} + +/// 注销申请记录,保存7天冷静期及用户确认的资产范围。 +struct AccountDeletionRequest: Codable, Equatable, Sendable { + let id: UUID + let clientRequestID: UUID + let username: String + let submittedAt: Date + let scheduledDeletionAt: Date + var status: AccountDeletionRequestStatus + let acknowledgedAssetKinds: [AccountDeletionAssetSummary.Kind] +} + +/// 登录时需要处理的注销状态。 +enum AccountDeletionLoginState: Equatable, Sendable { + case none + case pending(AccountDeletionRequest) + case completed(AccountDeletionRequest) +} + +/// 注销短信验证页所需的稳定上下文。 +struct AccountDeletionVerificationContext: Equatable, Sendable { + let username: String + let maskedPhone: String + let assets: [AccountDeletionAssetSummary] +} + +/// 注销业务错误,统一提供可直接展示的中文提示。 +enum AccountDeletionError: LocalizedError, Equatable { + case missingIdentity + case invalidVerificationCode + case incompleteAcknowledgement + case noPendingRequest + case cancellationExpired + case deletionCompleted + case persistenceFailed + + var errorDescription: String? { + switch self { + case .missingIdentity: + "当前登录账号缺少手机号,暂时无法提交注销" + case .invalidVerificationCode: + "验证码错误,请输入演示验证码 123456" + case .incompleteAcknowledgement: + "请先确认已了解全部资产和注销后果" + case .noPendingRequest: + "当前账号没有待取消的注销申请" + case .cancellationExpired: + "7天取消期限已过,无法恢复账号" + case .deletionCompleted: + "账号已完成注销,无法继续登录" + case .persistenceFailed: + "注销状态保存失败,请稍后重试" + } + } +} + +/// 注销账号身份工具,统一手机号规范化、掩码和当前登录身份解析。 +enum AccountDeletionIdentity { + + /// 从当前会话读取登录手机号,优先使用登录页保存的原始账号。 + static func currentUsername(session: AppSessionStore = AppStore.shared.session) -> String? { + let candidates = [session.lastLoginUsername, session.phone] + return candidates + .compactMap { $0 } + .map(normalizedUsername) + .first { !$0.isEmpty } + } + + /// 将手机号账号规范化为纯数字,并兼容 +86 前缀。 + static func normalizedUsername(_ value: String) -> String { + var digits = value.filter(\.isNumber) + if digits.hasPrefix("86"), digits.count == 13 { + digits.removeFirst(2) + } + return digits + } + + /// 将手机号转换为用于页面展示的脱敏格式。 + static func maskedPhone(_ value: String) -> String { + let username = normalizedUsername(value) + guard username.count == 11 else { return username } + let start = username.prefix(3) + let end = username.suffix(4) + return "\(start)****\(end)" + } +} + +/// 注销时间格式化工具,统一成功页和登录恢复弹窗的中文时间。 +enum AccountDeletionDateFormatter { + + /// 将服务端计划删除时间格式化为中文日期时间。 + static func displayText(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + formatter.dateFormat = "yyyy年M月d日 HH:mm" + return formatter.string(from: date) + } +} diff --git a/suixinkan/Features/AccountDeletion/Services/AccountDeletionMockService.swift b/suixinkan/Features/AccountDeletion/Services/AccountDeletionMockService.swift new file mode 100644 index 0000000..8fe5783 --- /dev/null +++ b/suixinkan/Features/AccountDeletion/Services/AccountDeletionMockService.swift @@ -0,0 +1,198 @@ +// +// AccountDeletionMockService.swift +// suixinkan +// + +import Foundation + +/// 注销服务协议,隔离前置核验、短信验证、提交和取消能力。 +protocol AccountDeletionServing: AnyObject { + /// 加载整个登录账号的注销资产快照。 + func loadPrecheck(username: String) async throws -> AccountDeletionPrecheck + + /// 向当前绑定手机号发送注销验证码。 + func sendVerificationCode(to username: String) async throws + + /// 提交注销申请并开始7天冷静期。 + func submitDeletion( + username: String, + verificationCode: String, + acknowledgedAssetKinds: Set, + clientRequestID: UUID + ) async throws -> AccountDeletionRequest + + /// 查询指定登录账号在登录时应处理的注销状态。 + func loginState(for username: String) -> AccountDeletionLoginState + + /// 在7天冷静期内取消注销申请。 + func cancelDeletion(username: String) throws -> AccountDeletionRequest +} + +/// 注销功能 Mock 服务,使用 UserDefaults 完成可重复演示的7天注销闭环。 +final class AccountDeletionMockService: AccountDeletionServing { + + /// 应用内共享的注销 Mock 服务。 + static let shared = AccountDeletionMockService() + + /// Mock 短信验证码。 + static let verificationCode = "123456" + + private let defaults: UserDefaults + private let now: () -> Date + private let responseDelayNanoseconds: UInt64 + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private let keyPrefix = "account_deletion_mock_v1_" + + /// 创建注销 Mock 服务,测试可注入独立存储、时间和响应延迟。 + init( + defaults: UserDefaults = .standard, + now: @escaping () -> Date = Date.init, + responseDelayNanoseconds: UInt64 = 180_000_000 + ) { + self.defaults = defaults + self.now = now + self.responseDelayNanoseconds = responseDelayNanoseconds + } + + func loadPrecheck(username: String) async throws -> AccountDeletionPrecheck { + try await simulateDelay() + _ = try validateUsername(username) + return AccountDeletionPrecheck( + assets: [ + AccountDeletionAssetSummary(kind: .wallet, title: "钱包余额", valueText: "¥286.50"), + AccountDeletionAssetSummary(kind: .works, title: "作品与相册", valueText: "36个"), + AccountDeletionAssetSummary(kind: .projects, title: "项目", valueText: "4个"), + AccountDeletionAssetSummary(kind: .cloudFiles, title: "云盘文件", valueText: "8.6 GB"), + ], + consequences: [ + "所有景区与门店账号将解除", + "个人作品和云盘文件将删除", + "提交后7天内再次登录可取消注销", + ] + ) + } + + func sendVerificationCode(to username: String) async throws { + try await simulateDelay() + _ = try validateUsername(username) + } + + func submitDeletion( + username: String, + verificationCode: String, + acknowledgedAssetKinds: Set, + clientRequestID: UUID + ) async throws -> AccountDeletionRequest { + try await simulateDelay() + let normalized = try validateUsername(username) + guard verificationCode == Self.verificationCode else { + throw AccountDeletionError.invalidVerificationCode + } + guard acknowledgedAssetKinds == Set(AccountDeletionAssetSummary.Kind.allCases) else { + throw AccountDeletionError.incompleteAcknowledgement + } + + if let existing = storedRequest(for: normalized), existing.status == .pending { + return existing + } + if let existing = storedRequest(for: normalized), existing.status == .completed { + throw AccountDeletionError.deletionCompleted + } + + let submittedAt = now() + guard let scheduledDeletionAt = Calendar(identifier: .gregorian) + .date(byAdding: .day, value: 7, to: submittedAt) else { + throw AccountDeletionError.persistenceFailed + } + let request = AccountDeletionRequest( + id: UUID(), + clientRequestID: clientRequestID, + username: normalized, + submittedAt: submittedAt, + scheduledDeletionAt: scheduledDeletionAt, + status: .pending, + acknowledgedAssetKinds: AccountDeletionAssetSummary.Kind.allCases + ) + try save(request) + return request + } + + func loginState(for username: String) -> AccountDeletionLoginState { + let normalized = AccountDeletionIdentity.normalizedUsername(username) + guard var request = storedRequest(for: normalized) else { return .none } + + switch request.status { + case .canceled: + return .none + case .completed: + return .completed(request) + case .pending: + guard now() < request.scheduledDeletionAt else { + request.status = .completed + try? save(request) + return .completed(request) + } + return .pending(request) + } + } + + func cancelDeletion(username: String) throws -> AccountDeletionRequest { + let normalized = try validateUsername(username) + guard var request = storedRequest(for: normalized) else { + throw AccountDeletionError.noPendingRequest + } + guard request.status == .pending else { + if request.status == .completed { + throw AccountDeletionError.cancellationExpired + } + throw AccountDeletionError.noPendingRequest + } + guard now() < request.scheduledDeletionAt else { + request.status = .completed + try save(request) + throw AccountDeletionError.cancellationExpired + } + + request.status = .canceled + try save(request) + return request + } + + /// 删除指定账号的 Mock 注销状态,仅供单元测试与调试使用。 + func reset(username: String) { + let normalized = AccountDeletionIdentity.normalizedUsername(username) + defaults.removeObject(forKey: storageKey(username: normalized)) + } + + private func validateUsername(_ username: String) throws -> String { + let normalized = AccountDeletionIdentity.normalizedUsername(username) + guard normalized.count == 11, normalized.first == "1" else { + throw AccountDeletionError.missingIdentity + } + return normalized + } + + private func storedRequest(for username: String) -> AccountDeletionRequest? { + guard let data = defaults.data(forKey: storageKey(username: username)) else { return nil } + return try? decoder.decode(AccountDeletionRequest.self, from: data) + } + + private func save(_ request: AccountDeletionRequest) throws { + do { + let data = try encoder.encode(request) + defaults.set(data, forKey: storageKey(username: request.username)) + } catch { + throw AccountDeletionError.persistenceFailed + } + } + + private func storageKey(username: String) -> String { + keyPrefix + username + } + + private func simulateDelay() async throws { + guard responseDelayNanoseconds > 0 else { return } + try await Task.sleep(nanoseconds: responseDelayNanoseconds) + } +} diff --git a/suixinkan/Features/AccountDeletion/ViewModels/AccountDeletionViewModels.swift b/suixinkan/Features/AccountDeletion/ViewModels/AccountDeletionViewModels.swift new file mode 100644 index 0000000..6e44003 --- /dev/null +++ b/suixinkan/Features/AccountDeletion/ViewModels/AccountDeletionViewModels.swift @@ -0,0 +1,184 @@ +// +// AccountDeletionViewModels.swift +// suixinkan +// + +import Foundation + +/// 注销资产核验页 ViewModel,管理资产快照、确认状态与验证页上下文。 +final class AccountDeletionViewModel { + private let service: any AccountDeletionServing + + let username: String + private(set) var assets: [AccountDeletionAssetSummary] = [] + private(set) var consequences: [String] = [] + private(set) var isAcknowledged = false + private(set) var isLoading = false + private(set) var errorMessage: String? + + var onStateChange: (() -> Void)? + + /// 创建注销核验 ViewModel,默认读取当前登录账号并使用共享 Mock 服务。 + init( + username: String? = AccountDeletionIdentity.currentUsername(), + service: any AccountDeletionServing = AccountDeletionMockService.shared + ) { + self.username = AccountDeletionIdentity.normalizedUsername(username ?? "") + self.service = service + } + + /// 勾选后才允许进入短信验证。 + var isContinueEnabled: Bool { + !assets.isEmpty && isAcknowledged && !isLoading + } + + /// 资产加载完成后主按钮保持可点击,未勾选时由业务校验给出明确提示。 + var isContinueButtonEnabled: Bool { + !assets.isEmpty && !isLoading + } + + /// 拉取注销前置核验结果。 + func load() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + + do { + let precheck = try await service.loadPrecheck(username: username) + assets = precheck.assets + consequences = precheck.consequences + } catch { + errorMessage = error.localizedDescription + } + } + + /// 切换用户对注销影响的确认状态。 + func toggleAcknowledgement() { + isAcknowledged.toggle() + notifyStateChange() + } + + /// 生成短信验证页上下文。 + func makeVerificationContext() throws -> AccountDeletionVerificationContext { + guard isContinueEnabled else { + throw AccountDeletionError.incompleteAcknowledgement + } + guard !username.isEmpty else { + throw AccountDeletionError.missingIdentity + } + return AccountDeletionVerificationContext( + username: username, + maskedPhone: AccountDeletionIdentity.maskedPhone(username), + assets: assets + ) + } + + /// 使用当前服务创建短信验证 ViewModel,保持同一套 Mock 状态。 + func makeVerificationViewModel() throws -> AccountDeletionVerificationViewModel { + try AccountDeletionVerificationViewModel( + context: makeVerificationContext(), + service: service + ) + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 注销短信验证页 ViewModel,负责验证码发送、校验和提交申请。 +final class AccountDeletionVerificationViewModel { + private let service: any AccountDeletionServing + private let clientRequestID = UUID() + + let context: AccountDeletionVerificationContext + private(set) var verificationCode = "" + private(set) var isSendingCode = false + private(set) var isSubmitting = false + private(set) var hasSentCode = false + private(set) var errorMessage: String? + + var onStateChange: (() -> Void)? + + /// 创建短信验证 ViewModel。 + init( + context: AccountDeletionVerificationContext, + service: any AccountDeletionServing = AccountDeletionMockService.shared + ) throws { + guard !context.username.isEmpty else { + throw AccountDeletionError.missingIdentity + } + self.context = context + self.service = service + } + + /// 验证码满足6位数字且当前未提交时允许继续。 + var isSubmitEnabled: Bool { + verificationCode.count == 6 + && verificationCode.allSatisfy(\.isNumber) + && !isSubmitting + } + + /// 更新用户输入的验证码,只保留前6位数字。 + func updateVerificationCode(_ value: String) { + verificationCode = String(value.filter(\.isNumber).prefix(6)) + errorMessage = nil + notifyStateChange() + } + + /// 发送 Mock 短信验证码。 + func sendVerificationCode() async { + guard !isSendingCode else { return } + isSendingCode = true + errorMessage = nil + notifyStateChange() + defer { + isSendingCode = false + notifyStateChange() + } + + do { + try await service.sendVerificationCode(to: context.username) + hasSentCode = true + } catch { + errorMessage = error.localizedDescription + } + } + + /// 提交注销申请并返回计划删除时间。 + func submit() async throws -> AccountDeletionRequest { + guard isSubmitEnabled else { + throw AccountDeletionError.invalidVerificationCode + } + guard !isSubmitting else { throw CancellationError() } + + isSubmitting = true + errorMessage = nil + notifyStateChange() + defer { + isSubmitting = false + notifyStateChange() + } + + do { + return try await service.submitDeletion( + username: context.username, + verificationCode: verificationCode, + acknowledgedAssetKinds: Set(context.assets.map(\.kind)), + clientRequestID: clientRequestID + ) + } catch { + errorMessage = error.localizedDescription + throw error + } + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/Auth/Models/AuthModels.swift b/suixinkan/Features/Auth/Models/AuthModels.swift index 8341e16..d6189fe 100644 --- a/suixinkan/Features/Auth/Models/AuthModels.swift +++ b/suixinkan/Features/Auth/Models/AuthModels.swift @@ -53,10 +53,11 @@ enum LoginValidationError: Equatable { } } -/// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。 +/// 登录结果实体,区分正常登录、账号选择和注销冷静期恢复三种情况。 enum LoginResolution { case completed(V9AuthResponse, AccountSwitchAccount) case needsAccountSelection(AccountSelectionPayload) + case accountDeletionPending(V9AuthResponse, AccountDeletionRequest) } /// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。 diff --git a/suixinkan/Features/OfflineCollection/Models/OfflineCollectionModels.swift b/suixinkan/Features/OfflineCollection/Models/OfflineCollectionModels.swift new file mode 100644 index 0000000..20a7852 --- /dev/null +++ b/suixinkan/Features/OfflineCollection/Models/OfflineCollectionModels.swift @@ -0,0 +1,290 @@ +// +// OfflineCollectionModels.swift +// suixinkan +// + +import Foundation + +/// 线下收款方式,与线上订单支付方式保持独立。 +enum OfflineCollectionPaymentMethod: String, Codable, CaseIterable, Sendable, Hashable { + case wechat = "WECHAT" + case alipay = "ALIPAY" + case cash = "CASH" + + /// 页面展示的中文名称。 + var displayName: String { + switch self { + case .wechat: "微信" + case .alipay: "支付宝" + case .cash: "现金" + } + } + + /// 页面使用的本地矢量图标资源名称。 + var assetName: String { + switch self { + case .wechat: "payment_method_wechat" + case .alipay: "payment_method_alipay" + case .cash: "payment_method_cash" + } + } +} + +/// 线下收款记录的补缴状态。 +enum OfflineCollectionStatus: String, Codable, Sendable, Hashable { + case pending = "PENDING" + case settled = "SETTLED" + case overdue = "OVERDUE" + + /// 状态对应的中文文案。 + var displayName: String { + switch self { + case .pending: "待补缴" + case .settled: "已补缴" + case .overdue: "逾期未补缴" + } + } +} + +/// 当前线下收款数据所属的用户、店铺与景区上下文。 +struct OfflineCollectionContext: Codable, Sendable, Hashable { + let collectorId: String + let collectorName: String + let storeId: String + let storeName: String + let scenicId: String + let scenicName: String + + /// 生成用于 UserDefaults 隔离不同账号和店铺数据的稳定键。 + var storageScope: String { + [collectorId, storeId, scenicId] + .map { value in + value.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? String($0) : "_" }.joined() + } + .joined(separator: "_") + } + + /// 从当前登录上下文解析收款归属,缺少演示数据时使用文档约定的兜底值。 + static func current(appStore: AppStore = .shared) -> OfflineCollectionContext { + let session = appStore.session + let stores = appStore.permissions.rolePermissionList().flatMap(\.store) + let currentStore = stores.first(where: { $0.id == session.currentStoreId }) ?? stores.first + let collectorName = [session.realName, session.userName, session.accountDisplayName] + .first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + return OfflineCollectionContext( + collectorId: session.userId.nonEmptyValue ?? "U10086", + collectorName: collectorName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "张三", + storeId: currentStore.map { String($0.id) } ?? (session.currentStoreId > 0 ? String(session.currentStoreId) : "S1001"), + storeName: currentStore?.name.nonEmptyValue ?? "那拉提旅拍一店", + scenicId: session.currentScenicId > 0 ? String(session.currentScenicId) : "SC001", + scenicName: session.currentScenicName.nonEmptyValue ?? "那拉提景区" + ) + } +} + +/// 独立的线下收款记录,不包含任何订单字段。 +struct OfflineCollectionRecord: Codable, Sendable, Hashable { + let id: String + let collectorId: String + let collectorName: String + let storeId: String + let storeName: String + let scenicId: String + let scenicName: String + let amountFen: Int + let paymentMethod: OfflineCollectionPaymentMethod + let registeredAt: Date + let receivedAt: Date + let businessDate: String + var status: OfflineCollectionStatus + var settlementBatchId: String? + var settledAt: Date? +} + +/// 线下收款的独立补缴流水,仅关联本批次收款记录快照。 +struct OfflineSettlementBatch: Codable, Sendable, Hashable { + let id: String + let businessDate: String + let collectorId: String + let collectorName: String + let storeId: String + let storeName: String + let scenicId: String + let scenicName: String + let recordIds: [String] + let recordCount: Int + let amountFen: Int + let paidAt: Date + let payerId: String + let payerName: String + let clientRequestId: String + + /// 流水类型固定为线下收款补缴。 + var transactionType: String { "OFFLINE_COLLECTION_SETTLEMENT" } +} + +/// 某一营业日的线下收款汇总。 +struct OfflineDailySummary: Sendable, Hashable { + let businessDate: String + let totalCount: Int + let totalAmountFen: Int + let settledCount: Int + let settledAmountFen: Int + let pendingCount: Int + let pendingAmountFen: Int + let hasOverdue: Bool + + /// 按记录状态用整数“分”生成日汇总。 + static func make(businessDate: String, records: [OfflineCollectionRecord]) -> OfflineDailySummary { + let settled = records.filter { $0.status == .settled } + let pending = records.filter { $0.status == .pending || $0.status == .overdue } + return OfflineDailySummary( + businessDate: businessDate, + totalCount: records.count, + totalAmountFen: records.reduce(0) { $0 + $1.amountFen }, + settledCount: settled.count, + settledAmountFen: settled.reduce(0) { $0 + $1.amountFen }, + pendingCount: pending.count, + pendingAmountFen: pending.reduce(0) { $0 + $1.amountFen }, + hasOverdue: pending.contains { $0.status == .overdue } + ) + } +} + +/// 补缴确认时冻结的记录和金额快照,用于防止处理中新记录被误结清。 +struct OfflineSettlementRequest: Sendable, Hashable { + let businessDate: String + let recordIds: [String] + let amountFen: Int + let clientRequestId: String +} + +/// 登记成功后页面反馈所需的最小数据。 +struct OfflineCollectionRegistrationReceipt: Sendable, Hashable { + let record: OfflineCollectionRecord + let summary: OfflineDailySummary +} + +/// 线下收款 Mock 流程可识别的业务异常。 +enum OfflineCollectionError: LocalizedError, Sendable, Equatable { + case invalidAmount + case createFailed + case noPendingRecords + case summaryMismatch + case recordsChanged + case settlementFailed + case persistenceFailed + + /// 供页面展示的中文错误提示。 + var errorDescription: String? { + switch self { + case .invalidAmount: "请输入0.01~99,999.99元的有效金额" + case .createFailed: "登记失败,请重试" + case .noPendingRecords: "当前营业日无待补缴记录" + case .summaryMismatch: "金额汇总异常,请刷新数据" + case .recordsChanged: "待补缴记录已变化,请重新确认" + case .settlementFailed: "补缴失败,请重试" + case .persistenceFailed: "本地数据保存失败,请重试" + } + } +} + +/// 金额输入、整数分换算和展示的统一工具。 +enum OfflineCollectionMoney { + static let maximumFen = 9_999_999 + + /// 判断文本是否可作为金额输入中间态。 + static func acceptsEditingText(_ text: String) -> Bool { + guard !text.contains(where: { !$0.isNumber && $0 != "." }) else { return false } + let parts = text.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count <= 2 else { return false } + let wholeCount = parts.first?.count ?? 0 + let fractionCount = parts.count == 2 ? parts[1].count : 0 + return wholeCount <= 5 && fractionCount <= 2 + } + + /// 将用户输入精确转换为整数“分”,不经过浮点计算。 + static func parseFen(_ rawValue: String) -> Int? { + let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, text == rawValue, acceptsEditingText(text), text != "." else { return nil } + + let parts = text.split(separator: ".", omittingEmptySubsequences: false) + let wholeText = parts[0].isEmpty ? "0" : String(parts[0]) + let fractionText = parts.count == 2 ? String(parts[1]) : "" + guard wholeText.allSatisfy(\.isNumber), fractionText.allSatisfy(\.isNumber), + let whole = Int(wholeText) else { return nil } + + let cents: Int + switch fractionText.count { + case 0: cents = 0 + case 1: cents = (Int(fractionText) ?? 0) * 10 + case 2: cents = Int(fractionText) ?? 0 + default: return nil + } + let amountFen = whole * 100 + cents + guard amountFen > 0, amountFen <= maximumFen else { return nil } + return amountFen + } + + /// 将整数分格式化为带人民币符号且固定两位小数的文本。 + static func display(_ amountFen: Int) -> String { + let sign = amountFen < 0 ? "-" : "" + let absolute = abs(amountFen) + return "\(sign)¥\(absolute / 100).\(String(format: "%02d", absolute % 100))" + } +} + +/// 营业日与页面时间文案的统一格式化工具。 +enum OfflineCollectionDate { + static let dailyCutoffTime = "23:59:59" + + /// 按指定日历生成 YYYY-MM-DD 营业日。 + static func businessDate(for date: Date, calendar: Calendar = .current) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String( + format: "%04d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0 + ) + } + + /// 将营业日文本解析为当地日期。 + static func date(from businessDate: String, calendar: Calendar = .current) -> Date? { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: businessDate) + } + + /// 将时间格式化为当日明细所需的 HH:mm。 + static func timeText(_ date: Date, calendar: Calendar = .current) -> String { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "zh_CN") + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } + + /// 将时间格式化为补缴流水的完整时间。 + static func dateTimeText(_ date: Date, calendar: Calendar = .current) -> String { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "zh_CN") + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: date) + } +} + +private extension String { + /// 去除首尾空白后的非空文本。 + var nonEmptyValue: String? { + let value = trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/suixinkan/Features/OfflineCollection/Services/OfflineCollectionMockService.swift b/suixinkan/Features/OfflineCollection/Services/OfflineCollectionMockService.swift new file mode 100644 index 0000000..0b39231 --- /dev/null +++ b/suixinkan/Features/OfflineCollection/Services/OfflineCollectionMockService.swift @@ -0,0 +1,359 @@ +// +// OfflineCollectionMockService.swift +// suixinkan +// + +import Foundation + +/// 线下收款本地 Mock 服务,负责演示数据、持久化、逾期流转与幂等补缴。 +final class OfflineCollectionMockService: @unchecked Sendable { + + /// App 内共享的线下收款 Mock 数据源。 + static let shared = OfflineCollectionMockService() + + /// 用于本地持久化的数据快照。 + private struct Snapshot: Codable, Equatable { + var records: [OfflineCollectionRecord] + var batches: [OfflineSettlementBatch] + } + + private let defaults: UserDefaults + private let calendar: Calendar + private let nowProvider: @Sendable () -> Date + private let requestDelayNanoseconds: UInt64 + private let lock = NSLock() + private var shouldFailNextCreation = false + private var shouldFailNextSettlement = false + + /// 创建 Mock 服务,测试可注入独立 UserDefaults、时间与请求延迟。 + init( + defaults: UserDefaults = .standard, + calendar: Calendar = .current, + nowProvider: @escaping @Sendable () -> Date = Date.init, + requestDelayNanoseconds: UInt64 = 450_000_000 + ) { + self.defaults = defaults + self.calendar = calendar + self.nowProvider = nowProvider + self.requestDelayNanoseconds = requestDelayNanoseconds + } + + /// 当前设备时区下的今日营业日。 + var todayBusinessDate: String { + OfflineCollectionDate.businessDate(for: nowProvider(), calendar: calendar) + } + + /// 获取某营业日汇总。 + func getDailySummary(date: String, context: OfflineCollectionContext) -> OfflineDailySummary { + lock.withLock { + var snapshot = loadSnapshotLocked(context: context) + refreshOverdueLocked(snapshot: &snapshot, context: context) + let records = matchingRecords(snapshot.records, date: date, context: context) + return OfflineDailySummary.make(businessDate: date, records: records) + } + } + + /// 获取某营业日明细,按登记时间倒序返回。 + func getDailyRecords(date: String, context: OfflineCollectionContext) -> [OfflineCollectionRecord] { + lock.withLock { + var snapshot = loadSnapshotLocked(context: context) + refreshOverdueLocked(snapshot: &snapshot, context: context) + return matchingRecords(snapshot.records, date: date, context: context) + .sorted { $0.registeredAt > $1.registeredAt } + } + } + + /// 获取某营业日的成功补缴流水。 + func getSettlementBatches(date: String, context: OfflineCollectionContext) -> [OfflineSettlementBatch] { + lock.withLock { + let snapshot = loadSnapshotLocked(context: context) + return snapshot.batches + .filter { batch in + batch.businessDate == date + && batch.collectorId == context.collectorId + && batch.storeId == context.storeId + && batch.scenicId == context.scenicId + } + .sorted { $0.paidAt > $1.paidAt } + } + } + + /// 获取当前用户与店铺的历史逾期营业日,按日期升序方便先处理最早欠款。 + func getOverdueSummaries(context: OfflineCollectionContext) -> [OfflineDailySummary] { + lock.withLock { + var snapshot = loadSnapshotLocked(context: context) + refreshOverdueLocked(snapshot: &snapshot, context: context) + let overdueDates: Set = Set(snapshot.records.compactMap { record -> String? in + guard matches(record, context: context), record.status == .overdue else { return nil } + return record.businessDate + }) + return overdueDates.sorted().map { date in + OfflineDailySummary.make( + businessDate: date, + records: matchingRecords(snapshot.records, date: date, context: context) + ) + } + } + } + + /// 获取可查看的营业日,始终包含今日。 + func getAvailableBusinessDates(context: OfflineCollectionContext) -> [String] { + lock.withLock { + var snapshot = loadSnapshotLocked(context: context) + refreshOverdueLocked(snapshot: &snapshot, context: context) + var dates = Set(snapshot.records.filter { matches($0, context: context) }.map(\.businessDate)) + dates.insert(todayBusinessDate) + return dates.sorted(by: >) + } + } + + /// 新建一条待补缴线下收款记录,不触发任何订单流程。 + func createOfflineCollection( + amountFen: Int, + paymentMethod: OfflineCollectionPaymentMethod, + context: OfflineCollectionContext + ) async throws -> OfflineCollectionRegistrationReceipt { + guard amountFen > 0, amountFen <= OfflineCollectionMoney.maximumFen else { + throw OfflineCollectionError.invalidAmount + } + await simulateRequestDelay() + + return try lock.withLock { + if shouldFailNextCreation { + shouldFailNextCreation = false + throw OfflineCollectionError.createFailed + } + + var snapshot = loadSnapshotLocked(context: context) + refreshOverdueLocked(snapshot: &snapshot, context: context) + let now = nowProvider() + let businessDate = OfflineCollectionDate.businessDate(for: now, calendar: calendar) + let sequence = snapshot.records.filter { $0.businessDate == businessDate }.count + 1 + let record = OfflineCollectionRecord( + id: String(format: "OCR%@%04d", businessDate.replacingOccurrences(of: "-", with: ""), sequence), + collectorId: context.collectorId, + collectorName: context.collectorName, + storeId: context.storeId, + storeName: context.storeName, + scenicId: context.scenicId, + scenicName: context.scenicName, + amountFen: amountFen, + paymentMethod: paymentMethod, + registeredAt: now, + receivedAt: now, + businessDate: businessDate, + status: .pending, + settlementBatchId: nil, + settledAt: nil + ) + snapshot.records.append(record) + try saveSnapshotLocked(snapshot, context: context) + let summary = OfflineDailySummary.make( + businessDate: businessDate, + records: matchingRecords(snapshot.records, date: businessDate, context: context) + ) + return OfflineCollectionRegistrationReceipt(record: record, summary: summary) + } + } + + /// 按确认时冻结的记录快照执行全额补缴,同一请求标识只生成一条流水。 + func settleDailyCollections( + request: OfflineSettlementRequest, + context: OfflineCollectionContext + ) async throws -> OfflineSettlementBatch { + await simulateRequestDelay() + + return try lock.withLock { + var snapshot = loadSnapshotLocked(context: context) + refreshOverdueLocked(snapshot: &snapshot, context: context) + + if let existing = snapshot.batches.first(where: { $0.clientRequestId == request.clientRequestId }) { + return existing + } + if shouldFailNextSettlement { + shouldFailNextSettlement = false + throw OfflineCollectionError.settlementFailed + } + + let requestedIds = Set(request.recordIds) + guard !requestedIds.isEmpty, requestedIds.count == request.recordIds.count else { + throw OfflineCollectionError.noPendingRecords + } + let pendingRecords = snapshot.records.filter { record in + requestedIds.contains(record.id) + && matches(record, context: context) + && record.businessDate == request.businessDate + && (record.status == .pending || record.status == .overdue) + } + guard pendingRecords.count == request.recordIds.count else { + throw OfflineCollectionError.recordsChanged + } + let calculatedAmount = pendingRecords.reduce(0) { $0 + $1.amountFen } + guard calculatedAmount == request.amountFen else { + throw OfflineCollectionError.summaryMismatch + } + + let now = nowProvider() + let batchSequence = snapshot.batches.filter { $0.businessDate == request.businessDate }.count + 1 + let batchId = String( + format: "OCS%@%04d", + request.businessDate.replacingOccurrences(of: "-", with: ""), + batchSequence + ) + let batch = OfflineSettlementBatch( + id: batchId, + businessDate: request.businessDate, + collectorId: context.collectorId, + collectorName: context.collectorName, + storeId: context.storeId, + storeName: context.storeName, + scenicId: context.scenicId, + scenicName: context.scenicName, + recordIds: request.recordIds, + recordCount: request.recordIds.count, + amountFen: request.amountFen, + paidAt: now, + payerId: context.collectorId, + payerName: context.collectorName, + clientRequestId: request.clientRequestId + ) + + // 流水与记录在同一份本地快照内一次性更新,避免出现金额已减但流水缺失。 + snapshot.batches.append(batch) + for index in snapshot.records.indices where requestedIds.contains(snapshot.records[index].id) { + snapshot.records[index].status = .settled + snapshot.records[index].settlementBatchId = batch.id + snapshot.records[index].settledAt = now + } + try saveSnapshotLocked(snapshot, context: context) + return batch + } + } + + /// 开发环境中使下一次登记失败,用于验收输入保留和重试。 + func simulateNextCreationFailure() { + lock.withLock { shouldFailNextCreation = true } + } + + /// 开发环境中使下一次补缴失败,失败时不改变记录和流水。 + func simulateNextSettlementFailure() { + lock.withLock { shouldFailNextSettlement = true } + } + + /// 将当前上下文恢复为文档约定的默认演示数据。 + func resetOfflineCollectionMockData(context: OfflineCollectionContext) { + lock.withLock { + let snapshot = makeDefaultSnapshot(context: context) + try? saveSnapshotLocked(snapshot, context: context) + shouldFailNextCreation = false + shouldFailNextSettlement = false + } + } + + private func matchingRecords( + _ records: [OfflineCollectionRecord], + date: String, + context: OfflineCollectionContext + ) -> [OfflineCollectionRecord] { + records.filter { $0.businessDate == date && matches($0, context: context) } + } + + private func matches(_ record: OfflineCollectionRecord, context: OfflineCollectionContext) -> Bool { + record.collectorId == context.collectorId + && record.storeId == context.storeId + && record.scenicId == context.scenicId + } + + private func refreshOverdueLocked(snapshot: inout Snapshot, context: OfflineCollectionContext) { + let today = todayBusinessDate + var changed = false + for index in snapshot.records.indices where matches(snapshot.records[index], context: context) { + guard snapshot.records[index].status != .settled else { continue } + let expected: OfflineCollectionStatus = snapshot.records[index].businessDate < today ? .overdue : .pending + if snapshot.records[index].status != expected { + snapshot.records[index].status = expected + changed = true + } + } + if changed { + try? saveSnapshotLocked(snapshot, context: context) + } + } + + private func loadSnapshotLocked(context: OfflineCollectionContext) -> Snapshot { + let key = storageKey(context: context) + guard let data = defaults.data(forKey: key) else { + let snapshot = makeDefaultSnapshot(context: context) + try? saveSnapshotLocked(snapshot, context: context) + return snapshot + } + do { + return try JSONDecoder().decode(Snapshot.self, from: data) + } catch { + #if DEBUG + print("线下收款 Mock 数据损坏,已恢复默认数据:\(error)") + #endif + let snapshot = makeDefaultSnapshot(context: context) + try? saveSnapshotLocked(snapshot, context: context) + return snapshot + } + } + + private func saveSnapshotLocked(_ snapshot: Snapshot, context: OfflineCollectionContext) throws { + do { + defaults.set(try JSONEncoder().encode(snapshot), forKey: storageKey(context: context)) + } catch { + throw OfflineCollectionError.persistenceFailed + } + } + + private func storageKey(context: OfflineCollectionContext) -> String { + "offline_collection_mock_v1_\(context.storageScope)" + } + + private func makeDefaultSnapshot(context: OfflineCollectionContext) -> Snapshot { + let todayDate = nowProvider() + let today = OfflineCollectionDate.businessDate(for: todayDate, calendar: calendar) + let previousDate = calendar.date(byAdding: .day, value: -1, to: todayDate) ?? todayDate + let previousBusinessDate = OfflineCollectionDate.businessDate(for: previousDate, calendar: calendar) + + let samples: [(String, Int, OfflineCollectionPaymentMethod, Date, OfflineCollectionStatus)] = [ + ("OCR\(today.replacingOccurrences(of: "-", with: ""))0001", 29_900, .wechat, time(on: todayDate, hour: 10, minute: 21), .pending), + ("OCR\(today.replacingOccurrences(of: "-", with: ""))0002", 39_900, .alipay, time(on: todayDate, hour: 14, minute: 16), .pending), + ("OCR\(today.replacingOccurrences(of: "-", with: ""))0003", 19_900, .wechat, time(on: todayDate, hour: 17, minute: 42), .pending), + ("OCR\(previousBusinessDate.replacingOccurrences(of: "-", with: ""))0001", 30_000, .cash, time(on: previousDate, hour: 16, minute: 10), .overdue), + ("OCR\(previousBusinessDate.replacingOccurrences(of: "-", with: ""))0002", 20_000, .wechat, time(on: previousDate, hour: 19, minute: 32), .overdue), + ] + + let records = samples.map { sample in + let businessDate = sample.3 < calendar.startOfDay(for: todayDate) ? previousBusinessDate : today + return OfflineCollectionRecord( + id: sample.0, + collectorId: context.collectorId, + collectorName: context.collectorName, + storeId: context.storeId, + storeName: context.storeName, + scenicId: context.scenicId, + scenicName: context.scenicName, + amountFen: sample.1, + paymentMethod: sample.2, + registeredAt: sample.3, + receivedAt: sample.3, + businessDate: businessDate, + status: sample.4, + settlementBatchId: nil, + settledAt: nil + ) + } + return Snapshot(records: records, batches: []) + } + + private func time(on date: Date, hour: Int, minute: Int) -> Date { + calendar.date(bySettingHour: hour, minute: minute, second: 0, of: date) ?? date + } + + private func simulateRequestDelay() async { + guard requestDelayNanoseconds > 0 else { return } + try? await Task.sleep(nanoseconds: requestDelayNanoseconds) + } +} diff --git a/suixinkan/Features/OfflineCollection/ViewModels/OfflineCollectionViewModels.swift b/suixinkan/Features/OfflineCollection/ViewModels/OfflineCollectionViewModels.swift new file mode 100644 index 0000000..b9d1830 --- /dev/null +++ b/suixinkan/Features/OfflineCollection/ViewModels/OfflineCollectionViewModels.swift @@ -0,0 +1,248 @@ +// +// OfflineCollectionViewModels.swift +// suixinkan +// + +import Foundation + +/// 收款首页线下收款区域的 ViewModel。 +final class OfflineCollectionHomeViewModel { + private(set) var todaySummary: OfflineDailySummary + private(set) var overdueSummaries: [OfflineDailySummary] = [] + + let context: OfflineCollectionContext + let service: OfflineCollectionMockService + var onStateChange: (() -> Void)? + + /// 使用当前账号上下文创建首页 ViewModel。 + init( + context: OfflineCollectionContext = .current(), + service: OfflineCollectionMockService = .shared + ) { + self.context = context + self.service = service + todaySummary = OfflineDailySummary.make(businessDate: service.todayBusinessDate, records: []) + load() + } + + /// 当前今日营业日。 + var todayBusinessDate: String { service.todayBusinessDate } + + /// 最早一个需要处理的逾期营业日。 + var earliestOverdueBusinessDate: String? { overdueSummaries.first?.businessDate } + + /// 历史逾期记录的合计笔数。 + var overdueRecordCount: Int { overdueSummaries.reduce(0) { $0 + $1.pendingCount } } + + /// 历史逾期记录的合计待补缴金额。 + var overdueAmountFen: Int { overdueSummaries.reduce(0) { $0 + $1.pendingAmountFen } } + + /// 从本地 Mock 服务重新读取今日和逾期汇总。 + func load() { + todaySummary = service.getDailySummary(date: todayBusinessDate, context: context) + overdueSummaries = service.getOverdueSummaries(context: context) + onStateChange?() + } +} + +/// 线下收款登记页 ViewModel,管理表单校验、提交防重和成功反馈。 +final class OfflineCollectionRegistrationViewModel { + private(set) var amountText = "" + private(set) var paymentMethod: OfflineCollectionPaymentMethod? = .wechat + private(set) var isSubmitting = false + private(set) var receipt: OfflineCollectionRegistrationReceipt? + + let context: OfflineCollectionContext + let service: OfflineCollectionMockService + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onRegistrationSuccess: ((OfflineCollectionRegistrationReceipt) -> Void)? + + /// 使用指定收款上下文和 Mock 服务创建登记 ViewModel。 + init( + context: OfflineCollectionContext = .current(), + service: OfflineCollectionMockService = .shared + ) { + self.context = context + self.service = service + } + + /// 当前金额和收款方式是否允许提交。 + var canSubmit: Bool { + !isSubmitting && OfflineCollectionMoney.parseFen(amountText) != nil && paymentMethod != nil + } + + /// 更新金额输入,仅接受数字、单个小数点和最多两位小数。 + func updateAmount(_ value: String) { + guard OfflineCollectionMoney.acceptsEditingText(value) else { return } + amountText = value + receipt = nil + onStateChange?() + } + + /// 选择线下收款方式。 + func selectPaymentMethod(_ method: OfflineCollectionPaymentMethod) { + paymentMethod = method + receipt = nil + onStateChange?() + } + + /// 提交一笔线下收款登记,提交中的重复调用会被忽略。 + func submit() async { + guard !isSubmitting else { return } + guard let amountFen = OfflineCollectionMoney.parseFen(amountText) else { + onShowMessage?(OfflineCollectionError.invalidAmount.localizedDescription) + return + } + guard let paymentMethod else { + onShowMessage?("请选择收款方式") + return + } + + isSubmitting = true + onStateChange?() + defer { + isSubmitting = false + onStateChange?() + } + + do { + let result = try await service.createOfflineCollection( + amountFen: amountFen, + paymentMethod: paymentMethod, + context: context + ) + receipt = result + onRegistrationSuccess?(result) + } catch { + onShowMessage?(error.localizedDescription) + } + } + + /// 登记成功后开始下一笔,清空金额并保留本次收款方式。 + func startAnotherRegistration() { + amountText = "" + receipt = nil + onStateChange?() + } +} + +/// 日清页的补缴处理状态。 +enum OfflineSettlementPresentationState: Sendable, Equatable { + case idle + case processing + case success(OfflineSettlementBatch) + case failed(String) +} + +/// 线下收款日清页 ViewModel,负责单营业日汇总、快照冻结和全额补缴。 +final class OfflineCollectionDailyViewModel { + private(set) var businessDate: String + private(set) var summary: OfflineDailySummary + private(set) var records: [OfflineCollectionRecord] = [] + private(set) var batches: [OfflineSettlementBatch] = [] + private(set) var availableBusinessDates: [String] = [] + private(set) var settlementState: OfflineSettlementPresentationState = .idle + private(set) var preparedRequest: OfflineSettlementRequest? + + let context: OfflineCollectionContext + let service: OfflineCollectionMockService + var onStateChange: (() -> Void)? + + /// 使用指定营业日、收款上下文和 Mock 服务创建日清 ViewModel。 + init( + businessDate: String, + context: OfflineCollectionContext = .current(), + service: OfflineCollectionMockService = .shared + ) { + self.businessDate = businessDate + self.context = context + self.service = service + summary = OfflineDailySummary.make(businessDate: businessDate, records: []) + load() + } + + /// 当前查看的是否为今日营业日。 + var isToday: Bool { businessDate == service.todayBusinessDate } + + /// 当前是否可以发起补缴。 + var canSettle: Bool { + summary.pendingAmountFen > 0 && summary.pendingCount > 0 && settlementState != .processing + } + + /// 从本地 Mock 服务加载当前营业日的全部数据。 + func load() { + records = service.getDailyRecords(date: businessDate, context: context) + summary = service.getDailySummary(date: businessDate, context: context) + batches = service.getSettlementBatches(date: businessDate, context: context) + availableBusinessDates = service.getAvailableBusinessDates(context: context) + onStateChange?() + } + + /// 切换至已存在数据或今日的营业日。 + func selectBusinessDate(_ date: String) { + guard availableBusinessDates.contains(date), settlementState != .processing else { return } + businessDate = date + preparedRequest = nil + settlementState = .idle + load() + } + + /// 按页面当前待补缴记录冻结一份不可编辑的补缴快照。 + func prepareSettlement() throws -> OfflineSettlementRequest { + guard canSettle else { throw OfflineCollectionError.noPendingRecords } + let pendingRecords = records.filter { $0.status == .pending || $0.status == .overdue } + let amountFen = pendingRecords.reduce(0) { $0 + $1.amountFen } + guard pendingRecords.count == summary.pendingCount, amountFen == summary.pendingAmountFen else { + throw OfflineCollectionError.summaryMismatch + } + let request = OfflineSettlementRequest( + businessDate: businessDate, + recordIds: pendingRecords.map(\.id), + amountFen: amountFen, + clientRequestId: "LOCAL-\(UUID().uuidString.uppercased())" + ) + preparedRequest = request + return request + } + + /// 取消未提交的补缴确认快照。 + func cancelPreparedSettlement() { + guard settlementState != .processing else { return } + preparedRequest = nil + } + + /// 提交已冻结的补缴快照,失败后保留快照以便原样重试。 + func confirmSettlement() async { + guard settlementState != .processing else { return } + guard let request = preparedRequest else { + settlementState = .failed(OfflineCollectionError.noPendingRecords.localizedDescription) + onStateChange?() + return + } + + settlementState = .processing + onStateChange?() + do { + let batch = try await service.settleDailyCollections(request: request, context: context) + preparedRequest = nil + settlementState = .success(batch) + load() + } catch { + settlementState = .failed(error.localizedDescription) + load() + } + } + + /// 将成功或失败反馈恢复为可操作状态。 + func clearSettlementFeedback() { + guard settlementState != .processing else { return } + settlementState = .idle + onStateChange?() + } + + /// 开发环境下将下一次 Mock 补缴设为失败。 + func simulateNextSettlementFailure() { + service.simulateNextSettlementFailure() + } +} diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumAutoRetouchModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumAutoRetouchModels.swift new file mode 100644 index 0000000..0c32f2d --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumAutoRetouchModels.swift @@ -0,0 +1,122 @@ +// +// TravelAlbumAutoRetouchModels.swift +// suixinkan +// + +import Foundation + +/// 自动修图第一级方式,页面上始终只展示“不修图”和“AI 修图”两个选项。 +enum TravelAlbumRetouchMode: String, CaseIterable, Hashable, Sendable { + case disabled + case aiRetouch + + /// 修图方式的用户可见名称。 + var title: String { + switch self { + case .disabled: + return "不修图" + case .aiRetouch: + return "AI 修图" + } + } +} + +/// 相册自动 AI 修图配置,用于在新建相册与照片上传页之间传递模板选择。 +struct TravelAlbumAutoRetouchConfiguration: Codable, Equatable, Sendable { + let isEnabled: Bool + let templateID: String? + + /// 默认不启用自动修图。 + static let disabled = TravelAlbumAutoRetouchConfiguration(isEnabled: false, templateID: nil) + + /// 根据已选模板创建启用状态配置。 + static func enabled(templateID: String) -> TravelAlbumAutoRetouchConfiguration { + TravelAlbumAutoRetouchConfiguration(isEnabled: true, templateID: templateID) + } + + /// 当前配置对应的修图模板。 + var template: TravelAlbumEditPreset? { + guard isEnabled, let templateID else { return nil } + return TravelAlbumEditPreset.autoRetouchOptions.first { $0.id == templateID } + } + + /// 是否可以作为完整的自动修图配置提交。 + var isValid: Bool { + !isEnabled || template != nil + } + + /// 上传页紧凑设置项的展示文案。 + var uploadOptionTitle: String { + isEnabled ? TravelAlbumRetouchMode.aiRetouch.title : TravelAlbumRetouchMode.disabled.title + } +} + +extension TravelAlbumEditPreset { + /// 自动修图可选模板,不包含手动修图中的“还原为原图”。 + static var autoRetouchOptions: [TravelAlbumEditPreset] { + defaultOptions.filter { $0.effect != .original } + } +} + +/// 单张 OTG 照片的自动 AI 修图状态,与原有上传状态分开计算。 +enum TravelAlbumAutoRetouchState: String, Codable, Equatable, Sendable { + case none = "NONE" + case processing = "PROCESSING" + case completed = "COMPLETED" + case failed = "FAILED" +} + +/// 相册自动修图配置读写接口,便于 ViewModel 与单元测试注入。 +protocol TravelAlbumAutoRetouchConfigurationStoring: AnyObject { + /// 读取指定服务端相册的配置。 + func configuration(albumID: Int) -> TravelAlbumAutoRetouchConfiguration + + /// 保存指定服务端相册的配置。 + func save(_ configuration: TravelAlbumAutoRetouchConfiguration, albumID: Int) + + /// 删除指定相册的本地配置。 + func remove(albumID: Int) +} + +/// 使用 UserDefaults 按服务端相册 ID 持久化自动 AI 修图配置。 +final class TravelAlbumAutoRetouchConfigurationStore: TravelAlbumAutoRetouchConfigurationStoring { + static let shared = TravelAlbumAutoRetouchConfigurationStore() + + private let userDefaults: UserDefaults + private let keyPrefix: String + + /// 创建配置存储;测试可传入独立 UserDefaults suite。 + init( + userDefaults: UserDefaults = .standard, + keyPrefix: String = "travelAlbum.autoRetouch.album" + ) { + self.userDefaults = userDefaults + self.keyPrefix = keyPrefix + } + + func configuration(albumID: Int) -> TravelAlbumAutoRetouchConfiguration { + guard albumID > 0, + let data = userDefaults.data(forKey: key(albumID: albumID)), + let configuration = try? JSONDecoder().decode(TravelAlbumAutoRetouchConfiguration.self, from: data), + configuration.isValid else { + return .disabled + } + return configuration + } + + func save(_ configuration: TravelAlbumAutoRetouchConfiguration, albumID: Int) { + guard albumID > 0 else { return } + let normalized = configuration.isValid ? configuration : .disabled + guard let data = try? JSONEncoder().encode(normalized) else { return } + userDefaults.set(data, forKey: key(albumID: albumID)) + } + + func remove(albumID: Int) { + guard albumID > 0 else { return } + userDefaults.removeObject(forKey: key(albumID: albumID)) + } + + private func key(albumID: Int) -> String { + "\(keyPrefix).\(albumID)" + } +} diff --git a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift index 7d8f56c..2401dc1 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift @@ -19,6 +19,8 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable { var errorMessage: String? let localPath: String var remoteUrl: String + var autoRetouchState: TravelAlbumAutoRetouchState + var autoRetouchTemplateId: String? /// 是否未上传完成。 var isNotUploaded: Bool { @@ -346,7 +348,8 @@ enum TravelAlbumOTGPhotoFormatMatcher { extension TravelAlbumOTGPhotoRecord { /// 转为页面展示项。 func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem { - let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in + let preferredRetouchedPath = autoRetouchState == .completed ? retouchedPath : "" + let previewPath = [preferredRetouchedPath, thumbnailPath, localPath, remoteUrl].first { path in guard !path.isEmpty else { return false } if path.hasPrefix("http") { return true } return storage.fileExists(relativePath: path, albumId: albumId) @@ -371,7 +374,9 @@ extension TravelAlbumOTGPhotoRecord { progress: progress, errorMessage: errorMessage, localPath: localPath, - remoteUrl: remoteUrl + remoteUrl: remoteUrl, + autoRetouchState: autoRetouchState, + autoRetouchTemplateId: autoRetouchTemplateId ) } } diff --git a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift index 1d9845b..8743fe8 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift @@ -56,6 +56,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { let albumId: Int let userId: String var remoteUrl: String + var materialId: Int? + var autoRetouchState: TravelAlbumAutoRetouchState + var autoRetouchTemplateId: String? + var retouchedPath: String var updatedAt: Int64 /// 创建 OTG 本地照片记录。 @@ -74,6 +78,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { albumId: Int, userId: String, remoteUrl: String = "", + materialId: Int? = nil, + autoRetouchState: TravelAlbumAutoRetouchState = .none, + autoRetouchTemplateId: String? = nil, + retouchedPath: String = "", updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000) ) { self.id = id @@ -90,12 +98,17 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { self.albumId = albumId self.userId = userId self.remoteUrl = remoteUrl + self.materialId = materialId + self.autoRetouchState = autoRetouchState + self.autoRetouchTemplateId = autoRetouchTemplateId + self.retouchedPath = retouchedPath self.updatedAt = updatedAt } private enum CodingKeys: String, CodingKey { case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt - case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt + case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, materialId + case autoRetouchState, autoRetouchTemplateId, retouchedPath, updatedAt } /// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。 @@ -115,16 +128,30 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { albumId = try container.decode(Int.self, forKey: .albumId) userId = try container.decode(String.self, forKey: .userId) remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? "" + materialId = try container.decodeIfPresent(Int.self, forKey: .materialId) + autoRetouchState = try container.decodeIfPresent( + TravelAlbumAutoRetouchState.self, + forKey: .autoRetouchState + ) ?? .none + autoRetouchTemplateId = try container.decodeIfPresent(String.self, forKey: .autoRetouchTemplateId) + retouchedPath = try container.decodeIfPresent(String.self, forKey: .retouchedPath) ?? "" updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0 } /// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。 func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord { - guard status == .transferring || status == .uploading else { return self } + let interruptedUpload = status == .transferring || status == .uploading + let interruptedRetouch = autoRetouchState == .processing + guard interruptedUpload || interruptedRetouch else { return self } var copy = self - copy.status = .pending - copy.progress = 0 - copy.errorMessage = nil + if interruptedUpload { + copy.status = copy.materialId == nil ? .pending : .uploaded + copy.progress = copy.materialId == nil ? 0 : 100 + copy.errorMessage = nil + } + if interruptedRetouch { + copy.autoRetouchState = copy.materialId == nil ? .none : .failed + } copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) return copy } @@ -242,7 +269,7 @@ final class TravelAlbumOTGPhotoStore { ?? TravelAlbumClientPhotoID.make() return copy.normalizedAfterInterruptedTransfer() } - if migrated.map(\.clientPhotoId) != scoped.map(\.clientPhotoId) { + if migrated != scoped { save(migrated, albumId: albumId) } return migrated @@ -333,6 +360,22 @@ final class TravelAlbumOTGPhotoStore { return url } + /// 写入自动 AI 修图结果,返回当前相册预览目录中的文件 URL。 + func writeRetouchedImage(_ data: Data, filename: String, albumId: Int) throws -> URL { + let directory = try previewsDirectory(albumId: albumId) + let sanitized = PTPHelper.sanitizeFilename(filename) + let stem = (sanitized as NSString).deletingPathExtension + let preferredName = "\(stem)_retouched.jpg" + var candidate = directory.appendingPathComponent(preferredName) + var counter = 1 + while fileManager.fileExists(atPath: candidate.path), counter < 10_000 { + candidate = directory.appendingPathComponent("\(stem)_retouched_\(counter).jpg") + counter += 1 + } + try data.write(to: candidate, options: .atomic) + return candidate + } + /// 返回写入索引用的相对路径。 func relativePath(for url: URL, albumId: Int) -> String { let path = url.standardizedFileURL.path @@ -382,6 +425,9 @@ final class TravelAlbumOTGPhotoStore { if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) { resolved.thumbnailPath = thumbnailURL.path } + if let retouchedURL = absoluteURL(for: record.retouchedPath, albumId: albumId) { + resolved.retouchedPath = retouchedURL.path + } return resolved } @@ -433,7 +479,7 @@ final class TravelAlbumOTGPhotoStore { } private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) { - [record.localPath, record.thumbnailPath].forEach { path in + [record.localPath, record.thumbnailPath, record.retouchedPath].forEach { path in guard !path.isEmpty else { return } guard let url = absoluteURL(for: path, albumId: record.albumId) else { return } try? fileManager.removeItem(at: url) @@ -463,6 +509,11 @@ final class TravelAlbumOTGPhotoStore { ? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId) : record.thumbnailPath } + if !record.retouchedPath.isEmpty { + normalized.retouchedPath = record.retouchedPath.hasPrefix("/") + ? relativePath(for: URL(fileURLWithPath: record.retouchedPath), albumId: albumId) + : record.retouchedPath + } return normalized } diff --git a/suixinkan/Features/TravelAlbum/Services/TravelAlbumAIEditResultStore.swift b/suixinkan/Features/TravelAlbum/Services/TravelAlbumAIEditResultStore.swift index 668d9f2..aafd7c7 100644 --- a/suixinkan/Features/TravelAlbum/Services/TravelAlbumAIEditResultStore.swift +++ b/suixinkan/Features/TravelAlbum/Services/TravelAlbumAIEditResultStore.swift @@ -90,6 +90,18 @@ final class TravelAlbumAIEditResultStore { notifyChange(materialID: materialID) } + /// 使用已编码图片数据保存自动修图结果,供无 UIKit 依赖的 ViewModel 调用。 + @discardableResult + func completeFromImageData( + materialID: Int, + presetID: String, + editedImageData: Data + ) -> Bool { + guard let image = UIImage(data: editedImageData) else { return false } + complete(materialID: materialID, presetID: presetID, editedImage: image) + return true + } + /// 修图失败时恢复按钮可用状态,并保留之前成功的结果。 func failProcessing(materialID: Int) { guard var record = recordsByMaterialID[materialID] else { return } diff --git a/suixinkan/Features/TravelAlbum/Services/TravelAlbumAutoRetouchProcessor.swift b/suixinkan/Features/TravelAlbum/Services/TravelAlbumAutoRetouchProcessor.swift new file mode 100644 index 0000000..7c9c7c4 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Services/TravelAlbumAutoRetouchProcessor.swift @@ -0,0 +1,59 @@ +// +// TravelAlbumAutoRetouchProcessor.swift +// suixinkan +// + +import Foundation +import UIKit + +/// 自动 AI 修图处理接口,把本地原图与已选模板转换为可持久化的结果数据。 +@MainActor +protocol TravelAlbumAutoRetouchProcessing { + /// 按指定模板处理一张本地照片。 + func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data +} + +/// 使用项目现有 Core Image 效果生成自动修图演示结果。 +@MainActor +final class TravelAlbumAutoRetouchProcessor: TravelAlbumAutoRetouchProcessing { + private let processingDelayNanoseconds: UInt64 + + /// 创建本地修图处理器,默认保留短暂处理时间用于展示“修图中”状态。 + init(processingDelayNanoseconds: UInt64 = 900_000_000) { + self.processingDelayNanoseconds = processingDelayNanoseconds + } + + func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data { + let sourceData = try Data(contentsOf: sourceURL) + guard let sourceImage = UIImage(data: sourceData) else { + throw TravelAlbumAutoRetouchError.invalidSourceImage + } + if processingDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: processingDelayNanoseconds) + } + try Task.checkCancellation() + let editedImage = TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: sourceImage) + guard let resultData = editedImage.jpegData(compressionQuality: 0.92) else { + throw TravelAlbumAutoRetouchError.resultEncodingFailed + } + return resultData + } +} + +/// 本地自动 AI 修图过程的可展示错误。 +enum TravelAlbumAutoRetouchError: LocalizedError, Equatable { + case invalidSourceImage + case resultEncodingFailed + case invalidImageData + + var errorDescription: String? { + switch self { + case .invalidSourceImage: + return "原图无法读取,修图失败" + case .resultEncodingFailed: + return "修图结果生成失败" + case .invalidImageData: + return "修图结果无法读取" + } + } +} diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift index dbb7c2c..90934ce 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift @@ -42,13 +42,16 @@ final class TravelAlbumEntryViewModel { private let currentScenicIdProvider: () -> Int private let dateProvider: () -> Date + private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring init( currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }, - dateProvider: @escaping () -> Date = Date.init + dateProvider: @escaping () -> Date = Date.init, + autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring = TravelAlbumAutoRetouchConfigurationStore.shared ) { self.currentScenicIdProvider = currentScenicIdProvider self.dateProvider = dateProvider + self.autoRetouchConfigurationStore = autoRetouchConfigurationStore } /// 重新拉取相册列表。 @@ -110,6 +113,7 @@ final class TravelAlbumEntryViewModel { freeCount: String, singlePrice: String, packagePrice: String, + autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled, order: TravelAlbumAvailableOrder?, api: any TravelAlbumServing ) async { @@ -132,6 +136,10 @@ final class TravelAlbumEntryViewModel { onShowMessage?("请输入有效的单张照片价格") return } + if !autoRetouchConfiguration.isValid { + onShowMessage?("请选择修图模板") + return + } let albumName: String switch mode { @@ -174,6 +182,7 @@ final class TravelAlbumEntryViewModel { do { let response = try await api.create(request) + autoRetouchConfigurationStore.save(autoRetouchConfiguration, albumID: response.id) isCreateSheetVisible = false onShowMessage?("任务创建成功") onCreatedAlbum?(response) diff --git a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift index 46e0740..6060b87 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift @@ -63,7 +63,7 @@ final class WiredCameraTransferViewModel { private(set) var sonyMTPHint: String? private(set) var isContentCatalogReady = false - let retouchOption = "不修图" + private(set) var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload { didSet { notifyStateChanged() } @@ -80,6 +80,9 @@ final class WiredCameraTransferViewModel { private let api: any TravelAlbumServing private let appStore: AppStore private let userDefaults: UserDefaults + private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring + private let autoRetouchProcessor: any TravelAlbumAutoRetouchProcessing + private let aiEditResultStore: TravelAlbumAIEditResultStore private var currentDriver: CameraDriver? private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:] @@ -100,7 +103,10 @@ final class WiredCameraTransferViewModel { uploader: (any TravelAlbumOTGUploading)? = nil, api: (any TravelAlbumServing)? = nil, appStore: AppStore = .shared, - userDefaults: UserDefaults = .standard + userDefaults: UserDefaults = .standard, + autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring = TravelAlbumAutoRetouchConfigurationStore.shared, + autoRetouchProcessor: (any TravelAlbumAutoRetouchProcessing)? = nil, + aiEditResultStore: TravelAlbumAIEditResultStore? = nil ) { self.albumId = albumId self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle @@ -113,6 +119,10 @@ final class WiredCameraTransferViewModel { self.api = api ?? NetworkServices.shared.travelAlbumAPI self.appStore = appStore self.userDefaults = userDefaults + self.autoRetouchConfigurationStore = autoRetouchConfigurationStore + self.autoRetouchProcessor = autoRetouchProcessor ?? TravelAlbumAutoRetouchProcessor() + self.aiEditResultStore = aiEditResultStore ?? .shared + self.autoRetouchConfiguration = autoRetouchConfigurationStore.configuration(albumID: albumId) // 从相册管理选择模式进入时,以本次选择为准;其他旧入口继续沿用上次记录。 self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults) if let initialTransferMode { @@ -218,6 +228,11 @@ final class WiredCameraTransferViewModel { transferMode.title } + /// 自动 AI 修图设置项展示文案。 + var retouchOption: String { + autoRetouchConfiguration.uploadOptionTitle + } + /// 指定上传弹窗选项。 var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] { TravelAlbumOTGSpecifyUploadOption.allCases @@ -306,6 +321,15 @@ final class WiredCameraTransferViewModel { userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey) } + /// 更新当前相册的自动 AI 修图配置,只影响尚未开始的上传任务。 + func updateAutoRetouchConfiguration(_ configuration: TravelAlbumAutoRetouchConfiguration) { + let normalized = configuration.isValid ? configuration : .disabled + guard normalized != autoRetouchConfiguration else { return } + autoRetouchConfiguration = normalized + autoRetouchConfigurationStore.save(normalized, albumID: albumId) + notifyStateChanged() + } + /// 切换上传格式。 func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) { guard photoFormatOption != option else { return } @@ -467,8 +491,31 @@ final class WiredCameraTransferViewModel { startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传") } + /// 使用该照片上次失败时的模板重试自动修图,不重复上传原图。 + func retryAutoRetouch(photoId: String) { + guard let record = persistedRecordsById[photoId], + record.autoRetouchState == .failed, + let materialId = record.materialId, + let templateID = record.autoRetouchTemplateId, + let preset = TravelAlbumEditPreset.autoRetouchOptions.first(where: { $0.id == templateID }) else { + showMessage("暂无可重试的修图任务") + return + } + Task { + await processAutoRetouch( + photoId: photoId, + sourceRecord: record, + materialId: materialId, + preset: preset + ) + } + } + /// 删除单张本地照片。 func deletePhoto(photoId: String) { + if let materialId = persistedRecordsById[photoId]?.materialId { + aiEditResultStore.remove(materialID: materialId) + } storage.remove(albumId: albumId, photoId: photoId) persistedRecordsById.removeValue(forKey: photoId) selectedPhotoIds.remove(photoId) @@ -478,6 +525,9 @@ final class WiredCameraTransferViewModel { /// 清空当前相册本地 OTG 缓存。 func clearLocalAlbumCache() { + persistedRecordsById.values.compactMap(\.materialId).forEach { materialId in + aiEditResultStore.remove(materialID: materialId) + } storage.clearAlbum(albumId: albumId) persistedRecordsById = [:] photos = [] @@ -492,9 +542,27 @@ final class WiredCameraTransferViewModel { private func loadPersistedPhotos() { let records = storage.load(albumId: albumId) persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) + restorePersistedAutoRetouchResults(records) applyMergedPhotos() } + private func restorePersistedAutoRetouchResults(_ records: [TravelAlbumOTGPhotoRecord]) { + records.forEach { record in + guard record.autoRetouchState == .completed, + let materialId = record.materialId, + let templateID = record.autoRetouchTemplateId, + let resultURL = storage.absoluteURL(for: record.retouchedPath, albumId: albumId), + let resultData = try? Data(contentsOf: resultURL) else { + return + } + _ = aiEditResultStore.completeFromImageData( + materialID: materialId, + presetID: templateID, + editedImageData: resultData + ) + } + } + private func syncServerUploadStatuses() { serverStatusSyncTask?.cancel() guard albumId > 0 else { return } @@ -593,6 +661,8 @@ final class WiredCameraTransferViewModel { } private func uploadPhoto(id: String) async { + // 每张照片开始上传时固定一次配置,避免修图过程中切换模板导致结果不一致。 + let retouchConfiguration = autoRetouchConfiguration do { let record = try await localRecordForUpload(id: id) updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil) @@ -602,13 +672,89 @@ final class WiredCameraTransferViewModel { ) { [weak self] progress in self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) } - updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl) + // 上传期间用户可能删除照片,删除后不再回写本地任务或发起修图。 + guard persistedRecordsById[id] != nil else { return } + updateRecord( + id: id, + status: retouchConfiguration.template == nil ? .uploaded : .uploading, + progress: 100, + error: nil, + remoteUrl: material.fileUrl, + materialId: material.id + ) + guard let preset = retouchConfiguration.template else { return } + await processAutoRetouch( + photoId: id, + sourceRecord: record, + materialId: material.id, + preset: preset + ) } catch { updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription) showMessage(error.localizedDescription) } } + /// 对已完成原图上传的照片执行本地演示修图,修图失败不回退上传成功状态。 + private func processAutoRetouch( + photoId: String, + sourceRecord: TravelAlbumOTGPhotoRecord, + materialId: Int, + preset: TravelAlbumEditPreset + ) async { + let resolvedRecord = storage.recordWithResolvedFilePaths(sourceRecord, albumId: albumId) + let sourceURL = URL(fileURLWithPath: resolvedRecord.localPath) + updateAutoRetouchRecord( + id: photoId, + state: .processing, + templateId: preset.id, + materialId: materialId, + status: .uploading, + error: nil + ) + aiEditResultStore.startProcessing(materialIDs: [materialId]) + + do { + let resultData = try await autoRetouchProcessor.process(sourceURL: sourceURL, preset: preset) + guard persistedRecordsById[photoId] != nil else { + aiEditResultStore.remove(materialID: materialId) + return + } + let resultURL = try storage.writeRetouchedImage( + resultData, + filename: sourceRecord.fileName, + albumId: albumId + ) + guard aiEditResultStore.completeFromImageData( + materialID: materialId, + presetID: preset.id, + editedImageData: resultData + ) else { + throw TravelAlbumAutoRetouchError.invalidImageData + } + updateAutoRetouchRecord( + id: photoId, + state: .completed, + templateId: preset.id, + materialId: materialId, + status: .uploaded, + error: nil, + retouchedPath: storage.relativePath(for: resultURL, albumId: albumId) + ) + } catch { + aiEditResultStore.failProcessing(materialID: materialId) + updateAutoRetouchRecord( + id: photoId, + state: .failed, + templateId: preset.id, + materialId: materialId, + status: .uploaded, + error: error.localizedDescription + ) + showMessage("修图失败,可点击更多重试") + } + } + private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord { if let record = persistedRecordsById[id], !record.localPath.isEmpty, @@ -623,7 +769,8 @@ final class WiredCameraTransferViewModel { status: TravelAlbumOTGUploadStatus, progress: Int, error: String?, - remoteUrl: String? = nil + remoteUrl: String? = nil, + materialId: Int? = nil ) { var record = persistedRecordsById[id] if record == nil, let item = photos.first(where: { $0.id == id }) { @@ -642,7 +789,10 @@ final class WiredCameraTransferViewModel { errorMessage: error, albumId: albumId, userId: appStore.session.userId, - remoteUrl: remoteUrl ?? item.remoteUrl + remoteUrl: remoteUrl ?? item.remoteUrl, + materialId: materialId, + autoRetouchState: item.autoRetouchState, + autoRetouchTemplateId: item.autoRetouchTemplateId ) } guard var record else { return } @@ -652,6 +802,35 @@ final class WiredCameraTransferViewModel { if let remoteUrl { record.remoteUrl = remoteUrl } + if let materialId { + record.materialId = materialId + } + record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) + persistedRecordsById[id] = record + storage.upsert(record, albumId: albumId) + applyMergedPhotos() + } + + /// 单独更新修图阶段,保留已完成的原图上传信息。 + private func updateAutoRetouchRecord( + id: String, + state: TravelAlbumAutoRetouchState, + templateId: String, + materialId: Int, + status: TravelAlbumOTGUploadStatus, + error: String?, + retouchedPath: String? = nil + ) { + guard var record = persistedRecordsById[id] else { return } + record.status = status + record.progress = 100 + record.errorMessage = error + record.materialId = materialId + record.autoRetouchState = state + record.autoRetouchTemplateId = templateId + if let retouchedPath { + record.retouchedPath = retouchedPath + } record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) persistedRecordsById[id] = record storage.upsert(record, albumId: albumId) diff --git a/suixinkan/SceneDelegate.swift b/suixinkan/SceneDelegate.swift index 95bac71..a8cfa8e 100644 --- a/suixinkan/SceneDelegate.swift +++ b/suixinkan/SceneDelegate.swift @@ -19,8 +19,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { AppNavigationBarAppearance.applyGlobalAppearance() + let debugPreviewRoot = makeAccountDeletionDebugPreviewRootIfNeeded() + if debugPreviewRoot == nil { + enforceAccountDeletionStateBeforeLaunch() + } + let window = UIWindow(windowScene: windowScene) - window.rootViewController = AppRouter.makeRootViewController() + window.rootViewController = debugPreviewRoot ?? AppRouter.makeRootViewController() window.makeKeyAndVisible() self.window = window (UIApplication.shared.delegate as? AppDelegate)?.window = window @@ -86,6 +91,30 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) } + /// 为设计核对提供仅 Debug 生效的注销页直达入口,不影响正式启动流程。 + private func makeAccountDeletionDebugPreviewRootIfNeeded() -> UIViewController? { +#if DEBUG + guard ProcessInfo.processInfo.arguments.contains("--account-deletion-preview") else { return nil } + let service = AccountDeletionMockService(responseDelayNanoseconds: 0) + let viewModel = AccountDeletionViewModel(username: "18651857230", service: service) + return UINavigationController(rootViewController: AccountDeletionViewController(viewModel: viewModel)) +#else + return nil +#endif + } + + /// App 冷启动时若账号已进入注销冷静期,则清除旧登录态并返回登录页。 + private func enforceAccountDeletionStateBeforeLaunch() { + guard AppStore.shared.session.isLoggedIn, + let username = AccountDeletionIdentity.currentUsername() else { return } + switch AccountDeletionMockService.shared.loginState(for: username) { + case .pending, .completed: + AppStore.shared.logout() + case .none: + break + } + } + @objc private func handleSessionDidExpire() { guard AppStore.shared.session.isLoggedIn else { return } guard sessionExpiredDialog == nil else { return } diff --git a/suixinkan/UI/Login/LoginViewController.swift b/suixinkan/UI/Login/LoginViewController.swift index fdc5118..47edfb6 100644 --- a/suixinkan/UI/Login/LoginViewController.swift +++ b/suixinkan/UI/Login/LoginViewController.swift @@ -273,12 +273,53 @@ final class LoginViewController: BaseViewController { do { let resolution = try await viewModel.login(authAPI: authAPI) - switch resolution { - case let .completed(response, account): - completeLogin(with: response, account: account) - case .needsAccountSelection: - break - } + handleLoginResolution(resolution) + } catch is CancellationError { + return + } catch { + showToast(error.localizedDescription) + } + } + } + + private func handleLoginResolution(_ resolution: LoginResolution) { + switch resolution { + case let .completed(response, account): + completeLogin(with: response, account: account) + case .needsAccountSelection: + break + case let .accountDeletionPending(response, request): + presentAccountDeletionRecovery(response: response, request: request) + } + } + + private func presentAccountDeletionRecovery( + response: V9AuthResponse, + request: AccountDeletionRequest + ) { + let deletionTime = AccountDeletionDateFormatter.displayText(request.scheduledDeletionAt) + let alert = UIAlertController( + title: "账号正在注销中", + message: "账号将在 \(deletionTime) 永久注销。继续登录将取消本次注销申请。", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "暂不登录", style: .cancel)) + alert.addAction(UIAlertAction(title: "恢复账号并登录", style: .default) { [weak self] _ in + self?.resumeLoginAfterCancelingDeletion(response: response) + }) + present(alert, animated: true) + } + + private func resumeLoginAfterCancelingDeletion(response: V9AuthResponse) { + Task { + showLoading() + defer { hideLoading() } + do { + let resolution = try await viewModel.resumeLoginAfterCancelingDeletion( + verifiedResponse: response, + authAPI: authAPI + ) + handleLoginResolution(resolution) } catch is CancellationError { return } catch { diff --git a/suixinkan/UI/Login/LoginViewModel.swift b/suixinkan/UI/Login/LoginViewModel.swift index 305ea95..6100a54 100644 --- a/suixinkan/UI/Login/LoginViewModel.swift +++ b/suixinkan/UI/Login/LoginViewModel.swift @@ -8,6 +8,8 @@ import Foundation /// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。 final class LoginViewModel { + private let accountDeletionService: any AccountDeletionServing + private(set) var account = "" private(set) var password = "" private(set) var isPrivacyChecked = false @@ -16,6 +18,11 @@ final class LoginViewModel { private(set) var isSelectingAccount = false private(set) var pendingAccountSelection: AccountSelectionPayload? + /// 创建登录 ViewModel,并注入注销状态服务以支持7天内恢复账号。 + init(accountDeletionService: any AccountDeletionServing = AccountDeletionMockService.shared) { + self.accountDeletionService = accountDeletionService + } + var isLoginEnabled: Bool { isValidPhone && !trimmedPassword.isEmpty } @@ -122,9 +129,27 @@ final class LoginViewModel { username: normalizedUsername, password: trimmedPassword ) + + switch accountDeletionService.loginState(for: normalizedUsername) { + case let .pending(request): + return .accountDeletionPending(response, request) + case .completed: + throw AccountDeletionError.deletionCompleted + case .none: + break + } return try await resolveLoginResponse(response, authAPI: authAPI) } + /// 用户确认恢复账号后取消注销,并继续处理已经通过密码验证的登录响应。 + func resumeLoginAfterCancelingDeletion( + verifiedResponse: V9AuthResponse, + authAPI: AuthAPI + ) async throws -> LoginResolution { + _ = try accountDeletionService.cancelDeletion(username: normalizedUsername) + return try await resolveLoginResponse(verifiedResponse, authAPI: authAPI) + } + /// 选择一个账号并调用 set-user 换取正式 token。 func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse { guard let payload = pendingAccountSelection, payload.hasTempToken else { diff --git a/suixinkan/UI/OfflineCollection/OfflineCollectionDailyViewController.swift b/suixinkan/UI/OfflineCollection/OfflineCollectionDailyViewController.swift new file mode 100644 index 0000000..31e4b7d --- /dev/null +++ b/suixinkan/UI/OfflineCollection/OfflineCollectionDailyViewController.swift @@ -0,0 +1,683 @@ +// +// OfflineCollectionDailyViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 日清列表的分区。 +enum OfflineDailyListSection: Hashable { + case records + case batches + case empty +} + +/// 日清列表的条目数据。 +enum OfflineDailyListItem: Hashable { + case record(OfflineCollectionRecord) + case batch(OfflineSettlementBatch) + case empty(isToday: Bool) +} + +/// 线下收款日清页,展示单营业日汇总、明细与独立补缴流水。 +final class OfflineCollectionDailyViewController: BaseViewController { + private let viewModel: OfflineCollectionDailyViewModel + + private let headerStack = UIStackView() + private let dateControl = UIButton(type: .system) + private let summaryCard = UIView() + private let summaryStatusLabel = UILabel() + private let totalMetric = OfflineDailyMetricView(title: "线下收款总额") + private let countMetric = OfflineDailyMetricView(title: "登记笔数") + private let settledMetric = OfflineDailyMetricView(title: "已补缴") + private let pendingMetric = OfflineDailyMetricView(title: "待补缴") + private let overdueBanner = UILabel() + private let tableView = UITableView(frame: .zero, style: .plain) + private var dataSource: UITableViewDiffableDataSource! + private var currentSections: [OfflineDailyListSection] = [] + private let bottomContainer = UIView() + private let settlementButton = UIButton(type: .system) + private var isFeedbackPresented = false + + /// 使用指定营业日、收款上下文与 Mock 服务创建日清页。 + init( + businessDate: String, + context: OfflineCollectionContext = .current(), + service: OfflineCollectionMockService = .shared + ) { + viewModel = OfflineCollectionDailyViewModel( + businessDate: businessDate, + context: context, + service: service + ) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "线下收款日清" + #if DEBUG + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "模拟失败", + style: .plain, + target: self, + action: #selector(simulateFailureTapped) + ) + #endif + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + headerStack.axis = .vertical + headerStack.spacing = AppSpacing.sm + + dateControl.contentHorizontalAlignment = .leading + dateControl.showsMenuAsPrimaryAction = true + var dateConfiguration = UIButton.Configuration.plain() + dateConfiguration.image = UIImage(systemName: "calendar") + dateConfiguration.imagePadding = AppSpacing.sm + dateConfiguration.baseForegroundColor = AppColor.textPrimary + dateConfiguration.background.backgroundColor = .white + dateConfiguration.background.cornerRadius = AppRadius.lg + dateConfiguration.contentInsets = NSDirectionalEdgeInsets( + top: 12, + leading: AppSpacing.md, + bottom: 12, + trailing: AppSpacing.md + ) + dateControl.configuration = dateConfiguration + dateControl.accessibilityIdentifier = "offlineCollection.businessDate" + + summaryCard.backgroundColor = .white + summaryCard.layer.cornerRadius = AppRadius.lg + summaryStatusLabel.font = .systemFont(ofSize: 13, weight: .semibold) + summaryStatusLabel.textAlignment = .center + summaryStatusLabel.textColor = AppColor.success + summaryStatusLabel.backgroundColor = UIColor(hex: 0xECFDF3) + summaryStatusLabel.layer.cornerRadius = 12 + summaryStatusLabel.clipsToBounds = true + + let firstMetricRow = UIStackView(arrangedSubviews: [totalMetric, countMetric]) + firstMetricRow.axis = .horizontal + firstMetricRow.distribution = .fillEqually + let secondMetricRow = UIStackView(arrangedSubviews: [settledMetric, pendingMetric]) + secondMetricRow.axis = .horizontal + secondMetricRow.distribution = .fillEqually + let metricStack = UIStackView(arrangedSubviews: [firstMetricRow, secondMetricRow]) + metricStack.axis = .vertical + metricStack.spacing = AppSpacing.md + summaryCard.addSubview(metricStack) + summaryCard.addSubview(summaryStatusLabel) + metricStack.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalToSuperview().inset(AppSpacing.md) + } + summaryStatusLabel.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(24) + } + + overdueBanner.text = "该营业日存在逾期未补缴,请尽快完成补缴" + overdueBanner.font = .systemFont(ofSize: 13, weight: .medium) + overdueBanner.textColor = AppColor.danger + overdueBanner.backgroundColor = UIColor(hex: 0xFFF1F0) + overdueBanner.layer.cornerRadius = AppRadius.md + overdueBanner.clipsToBounds = true + overdueBanner.textAlignment = .center + overdueBanner.numberOfLines = 0 + + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 92 + tableView.delegate = self + tableView.register(OfflineCollectionRecordCell.self, forCellReuseIdentifier: OfflineCollectionRecordCell.reuseIdentifier) + tableView.register(OfflineSettlementBatchCell.self, forCellReuseIdentifier: OfflineSettlementBatchCell.reuseIdentifier) + tableView.register(OfflineCollectionEmptyCell.self, forCellReuseIdentifier: OfflineCollectionEmptyCell.reuseIdentifier) + dataSource = makeDataSource() + + bottomContainer.backgroundColor = .white + bottomContainer.layer.shadowColor = UIColor.black.cgColor + bottomContainer.layer.shadowOpacity = 0.06 + bottomContainer.layer.shadowRadius = 8 + bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2) + + var settlementConfiguration = UIButton.Configuration.filled() + settlementConfiguration.title = "补缴今日全部" + settlementConfiguration.baseBackgroundColor = AppColor.primary + settlementConfiguration.baseForegroundColor = .white + settlementConfiguration.cornerStyle = .medium + settlementConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = .systemFont(ofSize: 17, weight: .semibold) + return outgoing + } + settlementButton.configuration = settlementConfiguration + settlementButton.accessibilityIdentifier = "offlineCollection.settle" + + view.addSubview(headerStack) + [dateControl, summaryCard, overdueBanner].forEach(headerStack.addArrangedSubview) + view.addSubview(tableView) + view.addSubview(bottomContainer) + bottomContainer.addSubview(settlementButton) + } + + override func setupConstraints() { + headerStack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) + } + dateControl.snp.makeConstraints { make in + make.height.equalTo(50) + } + summaryCard.snp.makeConstraints { make in + make.height.equalTo(172) + } + overdueBanner.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(48) + } + bottomContainer.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + settlementButton.snp.makeConstraints { make in + make.top.equalToSuperview().inset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) + make.height.equalTo(50) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(headerStack.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomContainer.snp.top) + } + } + + override func bindActions() { + settlementButton.addTarget(self, action: #selector(settlementTapped), for: .touchUpInside) + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + applyViewModel() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.load() + } + + @MainActor + private func applyViewModel() { + let datePrefix = viewModel.isToday ? "今日 · " : "营业日 · " + dateControl.configuration?.title = datePrefix + viewModel.businessDate + dateControl.menu = makeDateMenu() + + totalMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.totalAmountFen)) + countMetric.setValue("\(viewModel.summary.totalCount) 笔") + settledMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.settledAmountFen)) + pendingMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen), highlighted: viewModel.summary.pendingAmountFen > 0) + + let isSettled = viewModel.summary.totalCount > 0 && viewModel.summary.pendingAmountFen == 0 + summaryStatusLabel.text = " 本日已结清 " + summaryStatusLabel.isHidden = !isSettled + overdueBanner.isHidden = !viewModel.summary.hasOverdue + + let isProcessing = viewModel.settlementState == .processing + settlementButton.isEnabled = viewModel.canSettle + var configuration = settlementButton.configuration + configuration?.showsActivityIndicator = isProcessing + if isProcessing { + configuration?.title = "补缴中" + } else if isSettled { + configuration?.title = "本日已全部补缴" + } else if viewModel.summary.pendingAmountFen == 0 { + configuration?.title = "当日暂无待补缴" + } else { + let prefix = viewModel.isToday ? "补缴今日全部" : "补缴本日全部" + configuration?.title = "\(prefix) \(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen))" + } + settlementButton.configuration = configuration + settlementButton.alpha = settlementButton.isEnabled || isProcessing ? 1 : 0.5 + + applySnapshot() + presentSettlementFeedbackIfNeeded() + } + + @MainActor + private func applySnapshot() { + var snapshot = NSDiffableDataSourceSnapshot() + currentSections = [] + if viewModel.records.isEmpty { + currentSections.append(.empty) + snapshot.appendSections([.empty]) + snapshot.appendItems([.empty(isToday: viewModel.isToday)], toSection: .empty) + } else { + currentSections.append(.records) + snapshot.appendSections([.records]) + snapshot.appendItems(viewModel.records.map(OfflineDailyListItem.record), toSection: .records) + } + if !viewModel.batches.isEmpty { + currentSections.append(.batches) + snapshot.appendSections([.batches]) + snapshot.appendItems(viewModel.batches.map(OfflineDailyListItem.batch), toSection: .batches) + } + dataSource.apply(snapshot, animatingDifferences: view.window != nil) + } + + private func makeDataSource() -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in + switch item { + case let .record(record): + guard let cell = tableView.dequeueReusableCell( + withIdentifier: OfflineCollectionRecordCell.reuseIdentifier, + for: indexPath + ) as? OfflineCollectionRecordCell else { return UITableViewCell() } + cell.apply(record: record) + return cell + case let .batch(batch): + guard let cell = tableView.dequeueReusableCell( + withIdentifier: OfflineSettlementBatchCell.reuseIdentifier, + for: indexPath + ) as? OfflineSettlementBatchCell else { return UITableViewCell() } + cell.apply(batch: batch) + return cell + case let .empty(isToday): + guard let cell = tableView.dequeueReusableCell( + withIdentifier: OfflineCollectionEmptyCell.reuseIdentifier, + for: indexPath + ) as? OfflineCollectionEmptyCell else { return UITableViewCell() } + cell.apply(isToday: isToday) + cell.onRegister = { [weak self] in self?.openRegistration() } + return cell + } + } + } + + private func makeDateMenu() -> UIMenu { + let actions = viewModel.availableBusinessDates.map { [weak self] date in + let isCurrent = date == self?.viewModel.businessDate + let title = date == self?.viewModel.service.todayBusinessDate ? "今日 \(date)" : date + return UIAction(title: title, state: isCurrent ? .on : .off) { [weak self] _ in + self?.viewModel.selectBusinessDate(date) + } + } + return UIMenu(title: "选择营业日", children: actions) + } + + @MainActor + private func presentSettlementFeedbackIfNeeded() { + guard !isFeedbackPresented else { return } + switch viewModel.settlementState { + case .idle, .processing: + return + case let .success(batch): + isFeedbackPresented = true + let message = """ + 已补缴 \(OfflineCollectionMoney.display(batch.amountFen)) + 共结清 \(batch.recordCount) 笔线下收款 + 补缴流水号:\(batch.id) + """ + let alert = UIAlertController(title: "补缴成功", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "完成", style: .default) { [weak self] _ in + self?.isFeedbackPresented = false + self?.viewModel.clearSettlementFeedback() + }) + present(alert, animated: true) + case let .failed(message): + isFeedbackPresented = true + let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "暂不处理", style: .cancel) { [weak self] _ in + self?.isFeedbackPresented = false + self?.viewModel.cancelPreparedSettlement() + self?.viewModel.clearSettlementFeedback() + }) + alert.addAction(UIAlertAction(title: "重新补缴", style: .default) { [weak self] _ in + guard let self else { return } + self.isFeedbackPresented = false + self.viewModel.clearSettlementFeedback() + Task { await self.viewModel.confirmSettlement() } + }) + present(alert, animated: true) + } + } + + private func openRegistration() { + guard viewModel.isToday else { return } + navigationController?.pushViewController( + OfflineCollectionRegistrationViewController( + context: viewModel.context, + service: viewModel.service + ), + animated: true + ) + } + + @objc private func settlementTapped() { + do { + let request = try viewModel.prepareSettlement() + let message = """ + 营业日:\(request.businessDate) + 待补缴记录:\(request.recordIds.count) 笔 + 本次补缴金额:\(OfflineCollectionMoney.display(request.amountFen)) + + 补缴成功后,本批次记录将统一变为“已补缴”。 + """ + let alert = UIAlertController(title: "确认补缴", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in + self?.viewModel.cancelPreparedSettlement() + }) + alert.addAction(UIAlertAction( + title: "确认补缴 \(OfflineCollectionMoney.display(request.amountFen))", + style: .default + ) { [weak self] _ in + guard let self else { return } + Task { await self.viewModel.confirmSettlement() } + }) + present(alert, animated: true) + } catch { + showToast(error.localizedDescription) + viewModel.load() + } + } + + @objc private func simulateFailureTapped() { + viewModel.simulateNextSettlementFailure() + showToast("下一次补缴将模拟失败,可验收失败后重试") + } +} + +extension OfflineCollectionDailyViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + currentSections[safe: section] == .empty ? 0 : 40 + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let listSection = currentSections[safe: section], listSection != .empty else { return nil } + let container = UIView() + container.backgroundColor = AppColor.pageBackground + let label = UILabel() + label.text = listSection == .records ? "线下收款明细" : "线下收款补缴流水" + label.font = .systemFont(ofSize: 15, weight: .semibold) + label.textColor = AppColor.textPrimary + container.addSubview(label) + label.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) + make.bottom.equalToSuperview().inset(AppSpacing.xs) + } + return container + } +} + +/// 日清汇总卡片中的单项指标视图。 +final class OfflineDailyMetricView: UIView { + private let titleLabel = UILabel() + private let valueLabel = UILabel() + + /// 使用指标名称创建汇总视图。 + init(title: String) { + super.init(frame: .zero) + titleLabel.text = title + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新指标值及是否使用警示色强调。 + func setValue(_ value: String, highlighted: Bool = false) { + valueLabel.text = value + valueLabel.textColor = highlighted ? UIColor(hex: 0xD97706) : AppColor.textPrimary + } + + private func setupUI() { + titleLabel.font = .systemFont(ofSize: 12) + titleLabel.textColor = AppColor.textTertiary + valueLabel.font = .systemFont(ofSize: 19, weight: .bold) + valueLabel.textColor = AppColor.textPrimary + valueLabel.adjustsFontSizeToFitWidth = true + valueLabel.minimumScaleFactor = 0.75 + let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel]) + stack.axis = .vertical + stack.spacing = 4 + addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4)) + } + } +} + +/// 线下收款明细单元格。 +final class OfflineCollectionRecordCell: UITableViewCell { + static let reuseIdentifier = "OfflineCollectionRecordCell" + + private let cardView = UIView() + private let timeLabel = UILabel() + private let methodLabel = UILabel() + private let identifierLabel = UILabel() + private let amountLabel = UILabel() + private let statusLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 用线下收款记录刷新单元格。 + func apply(record: OfflineCollectionRecord) { + timeLabel.text = OfflineCollectionDate.timeText(record.registeredAt) + methodLabel.text = record.paymentMethod.displayName + identifierLabel.text = record.id + amountLabel.text = OfflineCollectionMoney.display(record.amountFen) + statusLabel.text = " \(record.status.displayName) " + + let color: UIColor + let background: UIColor + switch record.status { + case .pending: + color = UIColor(hex: 0xD97706) + background = UIColor(hex: 0xFFF7E6) + case .settled: + color = AppColor.success + background = UIColor(hex: 0xECFDF3) + case .overdue: + color = AppColor.danger + background = UIColor(hex: 0xFFF1F0) + } + statusLabel.textColor = color + statusLabel.backgroundColor = background + } + + private func setupUI() { + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + cardView.backgroundColor = .white + cardView.layer.cornerRadius = AppRadius.lg + + timeLabel.font = .systemFont(ofSize: 15, weight: .semibold) + timeLabel.textColor = AppColor.textPrimary + methodLabel.font = .systemFont(ofSize: 14) + methodLabel.textColor = AppColor.textSecondary + identifierLabel.font = .systemFont(ofSize: 11) + identifierLabel.textColor = AppColor.textTertiary + amountLabel.font = .systemFont(ofSize: 19, weight: .bold) + amountLabel.textColor = AppColor.textPrimary + amountLabel.textAlignment = .right + statusLabel.font = .systemFont(ofSize: 12, weight: .semibold) + statusLabel.textAlignment = .center + statusLabel.layer.cornerRadius = 11 + statusLabel.clipsToBounds = true + + let leftStack = UIStackView(arrangedSubviews: [timeLabel, methodLabel, identifierLabel]) + leftStack.axis = .vertical + leftStack.spacing = 4 + let rightStack = UIStackView(arrangedSubviews: [amountLabel, statusLabel]) + rightStack.axis = .vertical + rightStack.alignment = .trailing + rightStack.spacing = 8 + cardView.addSubview(leftStack) + cardView.addSubview(rightStack) + contentView.addSubview(cardView) + cardView.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(5) + make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) + } + leftStack.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md) + make.trailing.lessThanOrEqualTo(rightStack.snp.leading).offset(-AppSpacing.sm) + } + rightStack.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppSpacing.md) + make.centerY.equalToSuperview() + } + statusLabel.snp.makeConstraints { make in + make.height.equalTo(22) + } + } +} + +/// 线下收款补缴流水单元格。 +final class OfflineSettlementBatchCell: UITableViewCell { + static let reuseIdentifier = "OfflineSettlementBatchCell" + + private let cardView = UIView() + private let identifierLabel = UILabel() + private let amountLabel = UILabel() + private let detailLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 用成功补缴流水刷新单元格。 + func apply(batch: OfflineSettlementBatch) { + identifierLabel.text = batch.id + amountLabel.text = OfflineCollectionMoney.display(batch.amountFen) + detailLabel.text = "\(OfflineCollectionDate.dateTimeText(batch.paidAt)) · \(batch.payerName) · 关联 \(batch.recordCount) 笔" + } + + private func setupUI() { + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + cardView.backgroundColor = UIColor(hex: 0xF4FBF7) + cardView.layer.cornerRadius = AppRadius.lg + cardView.layer.borderColor = UIColor(hex: 0xCDEED8).cgColor + cardView.layer.borderWidth = 1 + + identifierLabel.font = .systemFont(ofSize: 13, weight: .semibold) + identifierLabel.textColor = AppColor.success + amountLabel.font = .systemFont(ofSize: 18, weight: .bold) + amountLabel.textColor = AppColor.textPrimary + amountLabel.textAlignment = .right + detailLabel.font = .systemFont(ofSize: 12) + detailLabel.textColor = AppColor.textSecondary + detailLabel.numberOfLines = 0 + + let topRow = UIStackView(arrangedSubviews: [identifierLabel, amountLabel]) + topRow.axis = .horizontal + topRow.distribution = .fillEqually + let stack = UIStackView(arrangedSubviews: [topRow, detailLabel]) + stack.axis = .vertical + stack.spacing = AppSpacing.sm + cardView.addSubview(stack) + contentView.addSubview(cardView) + cardView.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(5) + make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) + } + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.md) + } + } +} + +/// 日清页在指定营业日无记录时展示的空状态单元格。 +final class OfflineCollectionEmptyCell: UITableViewCell { + static let reuseIdentifier = "OfflineCollectionEmptyCell" + var onRegister: (() -> Void)? + + private let titleLabel = UILabel() + private let detailLabel = UILabel() + private let registerButton = UIButton(type: .system) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新空状态,仅今日允许前往登记。 + func apply(isToday: Bool) { + registerButton.isHidden = !isToday + } + + override func prepareForReuse() { + super.prepareForReuse() + onRegister = nil + } + + private func setupUI() { + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + titleLabel.text = "当日暂无线下收款记录" + titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) + titleLabel.textColor = AppColor.textPrimary + titleLabel.textAlignment = .center + detailLabel.text = "完成线下收款后,请及时登记" + detailLabel.font = .systemFont(ofSize: 13) + detailLabel.textColor = AppColor.textSecondary + detailLabel.textAlignment = .center + registerButton.setTitle("去登记", for: .normal) + registerButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) + registerButton.addTarget(self, action: #selector(registerTapped), for: .touchUpInside) + + let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, registerButton]) + stack.axis = .vertical + stack.spacing = AppSpacing.sm + stack.alignment = .center + contentView.addSubview(stack) + stack.snp.makeConstraints { make in + make.top.equalToSuperview().inset(48) + make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) + make.bottom.equalToSuperview().inset(32) + } + } + + @objc private func registerTapped() { + onRegister?() + } +} + +private extension Collection { + /// 安全按下标读取元素,超出范围时返回 nil。 + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift b/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift new file mode 100644 index 0000000..237269c --- /dev/null +++ b/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift @@ -0,0 +1,340 @@ +// +// OfflineCollectionRegistrationViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 线下收款登记页,仅收集金额和线下收款方式。 +final class OfflineCollectionRegistrationViewController: BaseViewController { + private let viewModel: OfflineCollectionRegistrationViewModel + + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let amountCard = UIView() + private let amountField = UITextField() + private let methodCard = UIView() + private let methodStack = UIStackView() + private var methodButtons: [OfflineCollectionPaymentMethod: OfflinePaymentMethodButton] = [:] + private let explanationCard = UIView() + private let contextLabel = UILabel() + private let bottomContainer = UIView() + private let submitButton = UIButton(type: .system) + private var hasFocusedAmountField = false + + /// 使用指定的收款上下文与 Mock 服务创建登记页。 + init( + context: OfflineCollectionContext = .current(), + service: OfflineCollectionMockService = .shared + ) { + viewModel = OfflineCollectionRegistrationViewModel(context: context, service: service) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "线下收款登记" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + scrollView.keyboardDismissMode = .interactive + scrollView.alwaysBounceVertical = true + contentStack.axis = .vertical + contentStack.spacing = AppSpacing.md + + configureCard(amountCard) + let amountTitleLabel = makeTitleLabel("收款金额") + let currencyLabel = UILabel() + currencyLabel.text = "¥" + currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold) + currencyLabel.textColor = AppColor.textPrimary + + amountField.placeholder = "0.00" + amountField.font = .systemFont(ofSize: 38, weight: .bold) + amountField.textColor = AppColor.textPrimary + amountField.keyboardType = .decimalPad + amountField.clearButtonMode = .whileEditing + amountField.delegate = self + amountField.accessibilityLabel = "收款金额" + + let amountHelperLabel = UILabel() + amountHelperLabel.text = "单笔金额 0.01~99,999.99 元,最多两位小数" + amountHelperLabel.font = .systemFont(ofSize: 12) + amountHelperLabel.textColor = AppColor.textTertiary + + let amountRow = UIStackView(arrangedSubviews: [currencyLabel, amountField]) + amountRow.axis = .horizontal + amountRow.alignment = .center + amountRow.spacing = AppSpacing.sm + let amountStack = UIStackView(arrangedSubviews: [amountTitleLabel, amountRow, amountHelperLabel]) + amountStack.axis = .vertical + amountStack.spacing = AppSpacing.sm + amountCard.addSubview(amountStack) + amountStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.lg) + } + amountField.snp.makeConstraints { make in + make.height.equalTo(58) + } + + configureCard(methodCard) + let methodTitleLabel = makeTitleLabel("收款方式") + methodStack.axis = .horizontal + methodStack.distribution = .fillEqually + methodStack.spacing = AppSpacing.sm + for method in OfflineCollectionPaymentMethod.allCases { + let button = OfflinePaymentMethodButton(method: method) + button.addTarget(self, action: #selector(methodTapped(_:)), for: .touchUpInside) + methodButtons[method] = button + methodStack.addArrangedSubview(button) + } + let methodContentStack = UIStackView(arrangedSubviews: [methodTitleLabel, methodStack]) + methodContentStack.axis = .vertical + methodContentStack.spacing = AppSpacing.md + methodCard.addSubview(methodContentStack) + methodContentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.lg) + } + methodStack.snp.makeConstraints { make in + make.height.equalTo(64) + } + + configureCard(explanationCard) + let explanationIcon = UIImageView(image: UIImage(systemName: "info.circle.fill")) + explanationIcon.tintColor = AppColor.primary + explanationIcon.contentMode = .scaleAspectFit + let explanationLabel = UILabel() + explanationLabel.text = "登记后将计入今日待补缴,不生成订单。" + explanationLabel.font = .systemFont(ofSize: 14) + explanationLabel.textColor = AppColor.textSecondary + explanationLabel.numberOfLines = 0 + explanationCard.addSubview(explanationIcon) + explanationCard.addSubview(explanationLabel) + explanationIcon.snp.makeConstraints { make in + make.leading.top.equalToSuperview().inset(AppSpacing.md) + make.width.height.equalTo(20) + } + explanationLabel.snp.makeConstraints { make in + make.leading.equalTo(explanationIcon.snp.trailing).offset(AppSpacing.sm) + make.trailing.bottom.equalToSuperview().inset(AppSpacing.md) + make.top.equalToSuperview().inset(AppSpacing.md) + } + + contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)" + contextLabel.font = .systemFont(ofSize: 12) + contextLabel.textColor = AppColor.textTertiary + contextLabel.numberOfLines = 0 + contextLabel.textAlignment = .center + + bottomContainer.backgroundColor = .white + bottomContainer.layer.shadowColor = UIColor.black.cgColor + bottomContainer.layer.shadowOpacity = 0.06 + bottomContainer.layer.shadowRadius = 8 + bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2) + + var submitConfiguration = UIButton.Configuration.filled() + submitConfiguration.title = "确认登记" + submitConfiguration.baseBackgroundColor = AppColor.primary + submitConfiguration.baseForegroundColor = .white + submitConfiguration.cornerStyle = .medium + submitConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = .systemFont(ofSize: 17, weight: .semibold) + return outgoing + } + submitButton.configuration = submitConfiguration + submitButton.accessibilityIdentifier = "offlineCollection.submit" + + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + [amountCard, methodCard, explanationCard, contextLabel].forEach(contentStack.addArrangedSubview) + view.addSubview(bottomContainer) + bottomContainer.addSubview(submitButton) + } + + override func setupConstraints() { + bottomContainer.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.keyboardLayoutGuide.snp.top) + } + submitButton.snp.makeConstraints { make in + make.top.equalToSuperview().inset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) + make.height.equalTo(50) + make.bottom.equalToSuperview().inset(AppSpacing.md) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomContainer.snp.top) + } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset) + make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) + } + } + + override func bindActions() { + amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged) + submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) + + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onRegistrationSuccess = { [weak self] receipt in + Task { @MainActor in self?.showRegistrationSuccess(receipt) } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + applyViewModel() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard !hasFocusedAmountField else { return } + hasFocusedAmountField = true + amountField.becomeFirstResponder() + } + + @MainActor + private func applyViewModel() { + if amountField.text != viewModel.amountText { + amountField.text = viewModel.amountText + } + methodButtons.forEach { method, button in + button.setSelected(method == viewModel.paymentMethod) + } + submitButton.isEnabled = viewModel.canSubmit + submitButton.configuration?.showsActivityIndicator = viewModel.isSubmitting + submitButton.configuration?.title = viewModel.isSubmitting ? "登记中" : "确认登记" + submitButton.alpha = submitButton.isEnabled || viewModel.isSubmitting ? 1 : 0.45 + } + + @MainActor + private func showRegistrationSuccess(_ receipt: OfflineCollectionRegistrationReceipt) { + amountField.resignFirstResponder() + let message = """ + 本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen)) + 今日累计线下收款 \(OfflineCollectionMoney.display(receipt.summary.totalAmountFen)) + 今日待补缴 \(OfflineCollectionMoney.display(receipt.summary.pendingAmountFen)) + """ + let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in + self?.viewModel.startAnotherRegistration() + self?.amountField.becomeFirstResponder() + }) + alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in + guard let self else { return } + let controller = OfflineCollectionDailyViewController( + businessDate: receipt.summary.businessDate, + context: self.viewModel.context, + service: self.viewModel.service + ) + self.navigationController?.pushViewController(controller, animated: true) + }) + present(alert, animated: true) + } + + private func makeTitleLabel(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = .systemFont(ofSize: 16, weight: .semibold) + label.textColor = AppColor.textPrimary + return label + } + + private func configureCard(_ card: UIView) { + card.backgroundColor = .white + card.layer.cornerRadius = AppRadius.lg + } + + @objc private func amountChanged() { + viewModel.updateAmount(amountField.text ?? "") + } + + @objc private func methodTapped(_ sender: OfflinePaymentMethodButton) { + viewModel.selectPaymentMethod(sender.method) + } + + @objc private func submitTapped() { + Task { await viewModel.submit() } + } +} + +extension OfflineCollectionRegistrationViewController: UITextFieldDelegate { + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + guard let current = textField.text, + let textRange = Range(range, in: current) else { return false } + let next = current.replacingCharacters(in: textRange, with: string) + return OfflineCollectionMoney.acceptsEditingText(next) + } +} + +/// 登记页的收款方式单选按钮。 +final class OfflinePaymentMethodButton: UIControl { + let method: OfflineCollectionPaymentMethod + + private let iconView = UIImageView() + private let titleLabel = UILabel() + + /// 使用指定收款方式创建单选按钮。 + init(method: OfflineCollectionPaymentMethod) { + self.method = method + super.init(frame: .zero) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新选中态视觉。 + func setSelected(_ selected: Bool) { + isSelected = selected + backgroundColor = selected ? UIColor(hex: 0xEEF5FF) : AppColor.inputBackground + layer.borderColor = (selected ? AppColor.primary : UIColor.clear).cgColor + layer.borderWidth = selected ? 1.5 : 0 + titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary + } + + private func setupUI() { + layer.cornerRadius = AppRadius.md + clipsToBounds = true + accessibilityLabel = method.displayName + + iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal) + iconView.contentMode = .scaleAspectFit + titleLabel.text = method.displayName + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textAlignment = .center + + let stack = UIStackView(arrangedSubviews: [iconView, titleLabel]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 5 + stack.isUserInteractionEnabled = false + addSubview(stack) + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + } + iconView.snp.makeConstraints { make in + make.width.height.equalTo(22) + } + setSelected(false) + } +} diff --git a/suixinkan/UI/OfflineCollection/Views/OfflineCollectionHomeView.swift b/suixinkan/UI/OfflineCollection/Views/OfflineCollectionHomeView.swift new file mode 100644 index 0000000..afe0ad5 --- /dev/null +++ b/suixinkan/UI/OfflineCollection/Views/OfflineCollectionHomeView.swift @@ -0,0 +1,245 @@ +// +// OfflineCollectionHomeView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 收款首页中的线下收款入口、今日汇总和历史逾期提醒区域。 +final class OfflineCollectionHomeView: UIView { + var onRegister: (() -> Void)? + var onOpenToday: (() -> Void)? + var onOpenOverdue: (() -> Void)? + + private let contentStack = UIStackView() + private let sectionTitleLabel = UILabel() + + private let overdueControl = UIControl() + private let overdueTitleLabel = UILabel() + private let overdueDetailLabel = UILabel() + private let overdueActionLabel = UILabel() + + private let registerControl = UIControl() + private let registerIconView = UIImageView() + private let registerTitleLabel = UILabel() + private let registerSubtitleLabel = UILabel() + private let registerActionLabel = UILabel() + + private let summaryControl = UIControl() + private let summaryTitleLabel = UILabel() + private let summaryAmountLabel = UILabel() + private let summaryCountLabel = UILabel() + private let summaryRegisteredLabel = UILabel() + private let summaryStatusLabel = UILabel() + private let summaryActionLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + bindActions() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 根据今日与历史逾期汇总刷新页面内容。 + func apply( + today: OfflineDailySummary, + overdueDayCount: Int, + overdueRecordCount: Int, + overdueAmountFen: Int + ) { + let hasOverdue = overdueDayCount > 0 && overdueAmountFen > 0 + overdueControl.isHidden = !hasOverdue + overdueDetailLabel.text = "\(overdueDayCount) 个营业日,共 \(overdueRecordCount) 笔,待补缴 \(OfflineCollectionMoney.display(overdueAmountFen))" + + summaryAmountLabel.text = OfflineCollectionMoney.display(today.pendingAmountFen) + summaryCountLabel.text = "\(today.pendingCount) 笔线下收款" + summaryRegisteredLabel.text = "今日已登记 \(OfflineCollectionMoney.display(today.totalAmountFen))" + let isSettled = today.totalCount > 0 && today.pendingAmountFen == 0 + summaryStatusLabel.text = isSettled ? "今日已结清" : "" + summaryStatusLabel.isHidden = !isSettled + summaryActionLabel.text = today.pendingAmountFen > 0 ? "去补缴" : "查看明细" + summaryActionLabel.backgroundColor = today.pendingAmountFen > 0 + ? AppColor.primary + : UIColor(hex: 0xEEF2F7) + summaryActionLabel.textColor = today.pendingAmountFen > 0 + ? .white + : AppColor.textSecondary + } + + private func setupUI() { + backgroundColor = .clear + + contentStack.axis = .vertical + contentStack.spacing = AppSpacing.md + + sectionTitleLabel.text = "线下收款" + sectionTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold) + sectionTitleLabel.textColor = AppColor.textPrimary + + configureCard(overdueControl, backgroundColor: UIColor(hex: 0xFFF1F0)) + overdueControl.layer.borderColor = UIColor(hex: 0xFFCCC7).cgColor + overdueControl.layer.borderWidth = 1 + overdueTitleLabel.text = "存在逾期未补缴" + overdueTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold) + overdueTitleLabel.textColor = AppColor.danger + overdueDetailLabel.font = .systemFont(ofSize: 13) + overdueDetailLabel.textColor = UIColor(hex: 0x8C2F27) + overdueDetailLabel.numberOfLines = 0 + configureActionLabel(overdueActionLabel, title: "立即处理", background: AppColor.danger, foreground: .white) + + configureCard(registerControl, backgroundColor: .white) + registerIconView.image = UIImage(systemName: "plus.circle.fill") + registerIconView.tintColor = AppColor.primary + registerIconView.contentMode = .scaleAspectFit + registerTitleLabel.text = "线下收款登记" + registerTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold) + registerTitleLabel.textColor = AppColor.textPrimary + registerSubtitleLabel.text = "线下收款后,请及时登记并在当日完成补缴" + registerSubtitleLabel.font = .systemFont(ofSize: 13) + registerSubtitleLabel.textColor = AppColor.textSecondary + registerSubtitleLabel.numberOfLines = 0 + configureActionLabel(registerActionLabel, title: "去登记", background: UIColor(hex: 0xEEF5FF), foreground: AppColor.primary) + + configureCard(summaryControl, backgroundColor: .white) + summaryTitleLabel.text = "今日待补缴" + summaryTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold) + summaryTitleLabel.textColor = AppColor.textPrimary + summaryAmountLabel.font = .systemFont(ofSize: 30, weight: .bold) + summaryAmountLabel.textColor = AppColor.textPrimary + summaryCountLabel.font = .systemFont(ofSize: 13) + summaryCountLabel.textColor = UIColor(hex: 0xD97706) + summaryRegisteredLabel.font = .systemFont(ofSize: 13) + summaryRegisteredLabel.textColor = AppColor.textSecondary + summaryStatusLabel.font = .systemFont(ofSize: 13, weight: .semibold) + summaryStatusLabel.textColor = AppColor.success + configureActionLabel(summaryActionLabel, title: "查看明细", background: UIColor(hex: 0xEEF2F7), foreground: AppColor.textSecondary) + + addSubview(contentStack) + [sectionTitleLabel, overdueControl, registerControl, summaryControl].forEach(contentStack.addArrangedSubview) + + let overdueTextStack = UIStackView(arrangedSubviews: [overdueTitleLabel, overdueDetailLabel]) + overdueTextStack.axis = .vertical + overdueTextStack.spacing = 5 + overdueControl.addSubview(overdueTextStack) + overdueControl.addSubview(overdueActionLabel) + + let registerTextStack = UIStackView(arrangedSubviews: [registerTitleLabel, registerSubtitleLabel]) + registerTextStack.axis = .vertical + registerTextStack.spacing = 5 + registerControl.addSubview(registerIconView) + registerControl.addSubview(registerTextStack) + registerControl.addSubview(registerActionLabel) + + let summaryTextStack = UIStackView(arrangedSubviews: [ + summaryTitleLabel, + summaryAmountLabel, + summaryCountLabel, + summaryRegisteredLabel, + summaryStatusLabel, + ]) + summaryTextStack.axis = .vertical + summaryTextStack.spacing = 6 + summaryControl.addSubview(summaryTextStack) + summaryControl.addSubview(summaryActionLabel) + } + + private func setupConstraints() { + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + sectionTitleLabel.snp.makeConstraints { make in + make.height.equalTo(24) + } + + overdueControl.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(84) + } + overdueControl.subviews.first?.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md) + make.trailing.lessThanOrEqualTo(overdueActionLabel.snp.leading).offset(-AppSpacing.sm) + } + overdueActionLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppSpacing.md) + make.centerY.equalToSuperview() + make.height.equalTo(32) + } + + registerControl.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(96) + } + registerIconView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(AppSpacing.md) + make.centerY.equalToSuperview() + make.width.height.equalTo(34) + } + registerTitleLabel.superview?.snp.makeConstraints { make in + make.leading.equalTo(registerIconView.snp.trailing).offset(AppSpacing.sm) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(registerActionLabel.snp.leading).offset(-AppSpacing.sm) + } + registerActionLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppSpacing.md) + make.centerY.equalToSuperview() + make.height.equalTo(32) + } + + summaryControl.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(180) + } + summaryTitleLabel.superview?.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md) + make.trailing.lessThanOrEqualTo(summaryActionLabel.snp.leading).offset(-AppSpacing.sm) + } + summaryActionLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(34) + } + } + + private func bindActions() { + overdueControl.addTarget(self, action: #selector(overdueTapped), for: .touchUpInside) + registerControl.addTarget(self, action: #selector(registerTapped), for: .touchUpInside) + summaryControl.addTarget(self, action: #selector(summaryTapped), for: .touchUpInside) + } + + private func configureCard(_ control: UIControl, backgroundColor: UIColor) { + control.backgroundColor = backgroundColor + control.layer.cornerRadius = AppRadius.lg + control.clipsToBounds = true + } + + private func configureActionLabel( + _ label: UILabel, + title: String, + background: UIColor, + foreground: UIColor + ) { + label.text = " \(title) " + label.font = .systemFont(ofSize: 13, weight: .semibold) + label.textAlignment = .center + label.textColor = foreground + label.backgroundColor = background + label.layer.cornerRadius = 16 + label.clipsToBounds = true + label.setContentCompressionResistancePriority(.required, for: .horizontal) + } + + @objc private func overdueTapped() { + onOpenOverdue?() + } + + @objc private func registerTapped() { + onRegister?() + } + + @objc private func summaryTapped() { + onOpenToday?() + } +} diff --git a/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift index a2b791f..ed1cacb 100644 --- a/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift +++ b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift @@ -12,6 +12,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { private let viewModel = PaymentCollectionDetailsViewModel() private let paymentAPI = NetworkServices.shared.paymentAPI + private let offlineCollectionViewModel = OfflineCollectionHomeViewModel() private let scrollView = UIScrollView() private let contentContainerView = UIView() @@ -48,6 +49,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { private let voiceCardView = UIView() private let voiceTitleLabel = UILabel() private let voiceSwitch = UISwitch() + private let offlineCollectionView = OfflineCollectionHomeView() private var amountDialog: PaymentSetAmountDialogView? private var appliedBrandConfig: PayPageConfig? @@ -100,9 +102,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController { override func setupUI() { let usesBranding = viewModel.usesNalatiBranding view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground - scrollView.showsVerticalScrollIndicator = !usesBranding - scrollView.isScrollEnabled = !usesBranding - scrollView.alwaysBounceVertical = false + scrollView.showsVerticalScrollIndicator = true + scrollView.isScrollEnabled = true + scrollView.alwaysBounceVertical = true contentStack.axis = .vertical contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md @@ -201,6 +203,8 @@ final class PaymentCollectionDetailsViewController: BaseViewController { contentStack.addArrangedSubview(recordRow) contentStack.addArrangedSubview(voiceCardView) } + // 线下收款与原有真实收款功能并列,不替换二维码或收款记录入口。 + contentStack.addArrangedSubview(offlineCollectionView) let qrDisplayView = usesBranding ? qrContainerView : qrImageView let qrContentStack = UIStackView(arrangedSubviews: [ @@ -330,15 +334,14 @@ final class PaymentCollectionDetailsViewController: BaseViewController { make.edges.equalToSuperview() make.width.equalTo(scrollView.snp.width) if viewModel.usesNalatiBranding { - make.height.equalTo(scrollView.snp.height) + make.height.greaterThanOrEqualTo(scrollView.snp.height) } } contentStack.snp.makeConstraints { make in make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) if viewModel.usesNalatiBranding { - make.centerX.centerY.equalToSuperview() - make.top.greaterThanOrEqualToSuperview() - make.bottom.lessThanOrEqualToSuperview() + make.centerX.equalToSuperview() + make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset) } else { make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset) } @@ -352,6 +355,18 @@ final class PaymentCollectionDetailsViewController: BaseViewController { viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } + offlineCollectionViewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyOfflineCollection() } + } + offlineCollectionView.onRegister = { [weak self] in + self?.openOfflineCollectionRegistration() + } + offlineCollectionView.onOpenToday = { [weak self] in + self?.openOfflineCollectionDaily(date: self?.offlineCollectionViewModel.todayBusinessDate) + } + offlineCollectionView.onOpenOverdue = { [weak self] in + self?.openOfflineCollectionDaily(date: self?.offlineCollectionViewModel.earliestOverdueBusinessDate) + } setAmountButton.addTarget(self, action: #selector(setAmountTapped), for: .touchUpInside) saveQRButton.addTarget(self, action: #selector(saveQRTapped), for: .touchUpInside) @@ -369,6 +384,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) applyBrandNavigationAppearanceIfNeeded() + offlineCollectionViewModel.load() } override func viewWillDisappear(_ animated: Bool) { @@ -432,6 +448,17 @@ final class PaymentCollectionDetailsViewController: BaseViewController { amountDialog?.dismiss() amountDialog = nil } + applyOfflineCollection() + } + + @MainActor + private func applyOfflineCollection() { + offlineCollectionView.apply( + today: offlineCollectionViewModel.todaySummary, + overdueDayCount: offlineCollectionViewModel.overdueSummaries.count, + overdueRecordCount: offlineCollectionViewModel.overdueRecordCount, + overdueAmountFen: offlineCollectionViewModel.overdueAmountFen + ) } private func setActionRowAvailable(_ isAvailable: Bool) { @@ -668,6 +695,24 @@ final class PaymentCollectionDetailsViewController: BaseViewController { @objc private func voiceSwitchChanged() { viewModel.toggleReceiveVoice() } + + private func openOfflineCollectionRegistration() { + let controller = OfflineCollectionRegistrationViewController( + context: offlineCollectionViewModel.context, + service: offlineCollectionViewModel.service + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func openOfflineCollectionDaily(date: String?) { + guard let date else { return } + let controller = OfflineCollectionDailyViewController( + businessDate: date, + context: offlineCollectionViewModel.context, + service: offlineCollectionViewModel.service + ) + navigationController?.pushViewController(controller, animated: true) + } } /// 收款详情信息行。 diff --git a/suixinkan/UI/Setting/AccountDeletionViewControllers.swift b/suixinkan/UI/Setting/AccountDeletionViewControllers.swift new file mode 100644 index 0000000..fc4dfd3 --- /dev/null +++ b/suixinkan/UI/Setting/AccountDeletionViewControllers.swift @@ -0,0 +1,761 @@ +// +// AccountDeletionViewControllers.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 注销账号资产核验页,中性展示账号资产与注销后果。 +final class AccountDeletionViewController: BaseViewController { + private let viewModel: AccountDeletionViewModel + + private let scrollView = UIScrollView() + private let contentView = UIView() + private let contentStack = UIStackView() + private let warningView = AccountDeletionWarningView() + private let assetCardView = AccountDeletionAssetCardView() + private let detailButton = UIButton(type: .system) + private let consequenceCardView = AccountDeletionConsequenceCardView() + private let bottomPanel = UIView() + private let acknowledgementButton = UIButton(type: .system) + private let continueButton = AppButton(title: "继续注销", style: .primary) + private var lastErrorMessage: String? + + /// 创建注销资产核验页。 + init(viewModel: AccountDeletionViewModel = AccountDeletionViewModel()) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "注销账号" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + scrollView.alwaysBounceVertical = true + scrollView.showsVerticalScrollIndicator = false + + contentStack.axis = .vertical + contentStack.spacing = 16 + contentStack.isLayoutMarginsRelativeArrangement = true + contentStack.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + + detailButton.setTitle("查看资产明细", for: .normal) + detailButton.setTitleColor(AppColor.primary, for: .normal) + detailButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + + bottomPanel.backgroundColor = AppColor.pageBackground + + acknowledgementButton.contentHorizontalAlignment = .leading + acknowledgementButton.tintColor = AppColor.primary + acknowledgementButton.setTitleColor(AppColor.textPrimary, for: .normal) + acknowledgementButton.titleLabel?.font = .systemFont(ofSize: 14) + acknowledgementButton.titleLabel?.numberOfLines = 0 + acknowledgementButton.setTitle(" 我已知晓并自愿放弃以上资产和权益", for: .normal) + acknowledgementButton.accessibilityLabel = "我已知晓并自愿放弃以上资产和权益" + + view.addSubview(scrollView) + scrollView.addSubview(contentView) + contentView.addSubview(contentStack) + view.addSubview(bottomPanel) + bottomPanel.addSubview(acknowledgementButton) + bottomPanel.addSubview(continueButton) + + [warningView, assetCardView, detailButton, consequenceCardView].forEach(contentStack.addArrangedSubview) + + applyViewModel() + } + + override func setupConstraints() { + bottomPanel.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + + acknowledgementButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(8) + make.leading.trailing.equalToSuperview().inset(16) + make.height.greaterThanOrEqualTo(44) + } + + continueButton.snp.makeConstraints { make in + make.top.equalTo(acknowledgementButton.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) + } + + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomPanel.snp.top) + } + + contentView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(scrollView.snp.width) + } + + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + warningView.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(64) + } + detailButton.snp.makeConstraints { make in + make.height.equalTo(36) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + acknowledgementButton.addTarget(self, action: #selector(acknowledgementTapped), for: .touchUpInside) + continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside) + detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside) + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { + showLoading() + await viewModel.load() + hideLoading() + } + } + + private func applyViewModel() { + assetCardView.configure(assets: viewModel.assets) + consequenceCardView.configure(items: viewModel.consequences) + acknowledgementButton.setImage( + UIImage(systemName: viewModel.isAcknowledged ? "checkmark.square.fill" : "square"), + for: .normal + ) + acknowledgementButton.accessibilityValue = viewModel.isAcknowledged ? "已勾选" : "未勾选" + continueButton.isEnabled = viewModel.isContinueButtonEnabled + + if let errorMessage = viewModel.errorMessage, + !errorMessage.isEmpty, + errorMessage != lastErrorMessage { + lastErrorMessage = errorMessage + showToast(errorMessage) + } + } + + @objc private func acknowledgementTapped() { + viewModel.toggleAcknowledgement() + } + + @objc private func continueTapped() { + do { + let verificationViewModel = try viewModel.makeVerificationViewModel() + navigationController?.pushViewController( + AccountDeletionVerificationViewController(viewModel: verificationViewModel), + animated: true + ) + } catch { + showToast(error.localizedDescription) + } + } + + @objc private func detailTapped() { + let message = viewModel.assets + .map { "\($0.title):\($0.valueText)" } + .joined(separator: "\n") + showAlert(title: "当前账号资产", message: message) + } +} + +/// 注销手机号验证页,使用短信验证码确认是账号本人操作。 +final class AccountDeletionVerificationViewController: BaseViewController { + private let viewModel: AccountDeletionVerificationViewModel + + private let contentStack = UIStackView() + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + private let codeContainer = UIView() + private let codeField = UITextField() + private let resendButton = UIButton(type: .system) + private let demoHintLabel = UILabel() + private let noticeView = AccountDeletionNoticeView() + private let submitButton = AppButton(title: "提交注销申请", style: .primary) + private var lastErrorMessage: String? + + /// 创建注销手机号验证页。 + init(viewModel: AccountDeletionVerificationViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "验证手机号" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + + contentStack.axis = .vertical + contentStack.alignment = .fill + contentStack.spacing = 16 + + iconView.image = UIImage(systemName: "message.badge.filled.fill") + iconView.tintColor = AppColor.primary + iconView.contentMode = .scaleAspectFit + + titleLabel.text = "确认是你本人操作" + titleLabel.font = .systemFont(ofSize: 22, weight: .semibold) + titleLabel.textColor = AppColor.textPrimary + titleLabel.textAlignment = .center + + descriptionLabel.text = "验证码将发送至 \(viewModel.context.maskedPhone)" + descriptionLabel.font = .systemFont(ofSize: 15) + descriptionLabel.textColor = AppColor.textSecondary + descriptionLabel.textAlignment = .center + + codeContainer.backgroundColor = .white + codeContainer.layer.cornerRadius = 12 + codeContainer.layer.borderWidth = 1 + codeContainer.layer.borderColor = AppColor.border.cgColor + + codeField.placeholder = "请输入6位验证码" + codeField.font = .systemFont(ofSize: 18, weight: .medium) + codeField.textColor = AppColor.textPrimary + codeField.keyboardType = .numberPad + codeField.textContentType = .oneTimeCode + codeField.clearButtonMode = .whileEditing + + resendButton.setTitle("重新发送", for: .normal) + resendButton.setTitleColor(AppColor.primary, for: .normal) + resendButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + + demoHintLabel.text = "演示验证码:123456" + demoHintLabel.font = .systemFont(ofSize: 13) + demoHintLabel.textColor = AppColor.warning + demoHintLabel.textAlignment = .center + + noticeView.configure( + iconName: "calendar.badge.exclamationmark", + text: "提交成功后账号将退出,7天内再次登录可取消注销。" + ) + + view.addSubview(contentStack) + view.addSubview(submitButton) + codeContainer.addSubview(codeField) + codeContainer.addSubview(resendButton) + + [iconView, titleLabel, descriptionLabel, codeContainer, demoHintLabel, noticeView] + .forEach(contentStack.addArrangedSubview) + + applyViewModel() + } + + override func setupConstraints() { + contentStack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(32) + make.leading.trailing.equalToSuperview().inset(24) + } + iconView.snp.makeConstraints { make in + make.height.equalTo(64) + } + codeContainer.snp.makeConstraints { make in + make.height.equalTo(56) + } + codeField.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.top.bottom.equalToSuperview() + make.trailing.equalTo(resendButton.snp.leading).offset(-8) + } + resendButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-12) + make.centerY.equalToSuperview() + make.width.equalTo(76) + make.height.equalTo(44) + } + noticeView.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(64) + } + submitButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + codeField.addTarget(self, action: #selector(codeChanged), for: .editingChanged) + resendButton.addTarget(self, action: #selector(resendTapped), for: .touchUpInside) + submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { + await viewModel.sendVerificationCode() + if viewModel.hasSentCode { + showToast("验证码已发送") + } + } + } + + private func applyViewModel() { + if codeField.text != viewModel.verificationCode { + codeField.text = viewModel.verificationCode + } + resendButton.isEnabled = !viewModel.isSendingCode && !viewModel.isSubmitting + resendButton.setTitle(viewModel.isSendingCode ? "发送中" : "重新发送", for: .normal) + submitButton.isEnabled = viewModel.isSubmitEnabled + + if let errorMessage = viewModel.errorMessage, + !errorMessage.isEmpty, + errorMessage != lastErrorMessage { + lastErrorMessage = errorMessage + showToast(errorMessage) + } + } + + @objc private func codeChanged() { + viewModel.updateVerificationCode(codeField.text ?? "") + } + + @objc private func resendTapped() { + Task { + await viewModel.sendVerificationCode() + if viewModel.hasSentCode { + showToast("验证码已重新发送") + } + } + } + + @objc private func submitTapped() { + let alert = UIAlertController( + title: "确认提交注销申请?", + message: "提交后账号将退出,7天内再次登录可以取消注销。", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "再想想", style: .cancel)) + alert.addAction(UIAlertAction(title: "确认提交", style: .destructive) { [weak self] _ in + self?.submitDeletion() + }) + present(alert, animated: true) + } + + private func submitDeletion() { + Task { + showLoading() + defer { hideLoading() } + do { + let request = try await viewModel.submit() + navigationController?.pushViewController( + AccountDeletionSuccessViewController(request: request), + animated: true + ) + } catch is CancellationError { + return + } catch { + showToast(error.localizedDescription) + } + } + } +} + +/// 注销申请提交成功页,明确展示计划删除时间和恢复方式。 +final class AccountDeletionSuccessViewController: BaseViewController { + private let request: AccountDeletionRequest + + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + private let dateCardView = UIView() + private let dateTitleLabel = UILabel() + private let dateValueLabel = UILabel() + private let recoveryLabel = UILabel() + private let exitButton = AppButton(title: "退出并返回登录页", style: .primary) + + /// 创建注销申请提交成功页。 + init(request: AccountDeletionRequest) { + self.request = request + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "注销账号" + navigationItem.hidesBackButton = true + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + + iconView.image = UIImage(systemName: "checkmark.circle.fill") + iconView.tintColor = AppColor.success + iconView.contentMode = .scaleAspectFit + + titleLabel.text = "注销申请已提交" + titleLabel.font = .systemFont(ofSize: 24, weight: .semibold) + titleLabel.textColor = AppColor.textPrimary + titleLabel.textAlignment = .center + + descriptionLabel.text = "账号已进入7天注销冷静期" + descriptionLabel.font = .systemFont(ofSize: 15) + descriptionLabel.textColor = AppColor.textSecondary + descriptionLabel.textAlignment = .center + + dateCardView.backgroundColor = .white + dateCardView.layer.cornerRadius = 12 + + dateTitleLabel.text = "计划永久注销时间" + dateTitleLabel.font = .systemFont(ofSize: 14) + dateTitleLabel.textColor = AppColor.textSecondary + + dateValueLabel.text = AccountDeletionDateFormatter.displayText(request.scheduledDeletionAt) + dateValueLabel.font = .systemFont(ofSize: 20, weight: .semibold) + dateValueLabel.textColor = AppColor.danger + dateValueLabel.textAlignment = .right + + recoveryLabel.text = "在上述时间前再次使用当前手机号和密码登录,确认恢复账号后即可取消注销。" + recoveryLabel.font = .systemFont(ofSize: 15) + recoveryLabel.textColor = AppColor.textSecondary + recoveryLabel.numberOfLines = 0 + recoveryLabel.textAlignment = .center + + [iconView, titleLabel, descriptionLabel, dateCardView, recoveryLabel, exitButton].forEach(view.addSubview) + dateCardView.addSubview(dateTitleLabel) + dateCardView.addSubview(dateValueLabel) + } + + override func setupConstraints() { + iconView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(56) + make.centerX.equalToSuperview() + make.width.height.equalTo(72) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(iconView.snp.bottom).offset(24) + make.leading.trailing.equalToSuperview().inset(24) + } + descriptionLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(10) + make.leading.trailing.equalToSuperview().inset(24) + } + dateCardView.snp.makeConstraints { make in + make.top.equalTo(descriptionLabel.snp.bottom).offset(32) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(72) + } + dateTitleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + } + dateValueLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-16) + make.centerY.equalToSuperview() + make.leading.greaterThanOrEqualTo(dateTitleLabel.snp.trailing).offset(12) + } + recoveryLabel.snp.makeConstraints { make in + make.top.equalTo(dateCardView.snp.bottom).offset(24) + make.leading.trailing.equalToSuperview().inset(32) + } + exitButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) + } + } + + override func bindActions() { + exitButton.addTarget(self, action: #selector(exitTapped), for: .touchUpInside) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + navigationController?.interactivePopGestureRecognizer?.isEnabled = false + } + + @objc private func exitTapped() { + NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil) + } +} + +/// 注销页顶部风险提示条。 +private final class AccountDeletionWarningView: UIView { + private let iconView = UIImageView() + private let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = AppColor.dangerBackground + layer.cornerRadius = 12 + layer.borderWidth = 1 + layer.borderColor = AppColor.danger.withAlphaComponent(0.25).cgColor + + iconView.image = UIImage(systemName: "exclamationmark.shield.fill") + iconView.tintColor = AppColor.danger + iconView.contentMode = .scaleAspectFit + + label.text = "注销后,账号及相关数据将永久删除" + label.font = .systemFont(ofSize: 15, weight: .medium) + label.textColor = AppColor.danger + label.numberOfLines = 0 + + addSubview(iconView) + addSubview(label) + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + make.width.height.equalTo(28) + } + label.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(12) + make.trailing.equalToSuperview().offset(-16) + make.top.bottom.equalToSuperview().inset(14) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 注销资产汇总卡片,以列表形式中性展示名称和数量。 +private final class AccountDeletionAssetCardView: UIView { + private let stackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + layer.cornerRadius = 12 + clipsToBounds = true + stackView.axis = .vertical + addSubview(stackView) + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 使用最新资产快照刷新卡片。 + func configure(assets: [AccountDeletionAssetSummary]) { + stackView.arrangedSubviews.forEach { + stackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + assets.enumerated().forEach { index, asset in + stackView.addArrangedSubview( + AccountDeletionAssetRowView(asset: asset, showsDivider: index < assets.count - 1) + ) + } + } +} + +/// 注销资产列表行,仅显示客观资产名称和当前值。 +private final class AccountDeletionAssetRowView: UIView { + private let titleLabel = UILabel() + private let valueLabel = UILabel() + private let divider = UIView() + + init(asset: AccountDeletionAssetSummary, showsDivider: Bool) { + super.init(frame: .zero) + titleLabel.text = asset.title + titleLabel.font = .systemFont(ofSize: 17, weight: .medium) + titleLabel.textColor = AppColor.textPrimary + + valueLabel.text = asset.valueText + valueLabel.font = .systemFont(ofSize: 18, weight: .semibold) + valueLabel.textColor = AppColor.textPrimary + valueLabel.textAlignment = .right + + divider.backgroundColor = AppColor.border + divider.isHidden = !showsDivider + + [titleLabel, valueLabel, divider].forEach(addSubview) + snp.makeConstraints { make in + make.height.equalTo(76) + } + titleLabel.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + } + valueLabel.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12) + } + divider.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(0.5) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 注销影响说明卡片,展示账号解除、内容删除和7天恢复规则。 +private final class AccountDeletionConsequenceCardView: UIView { + private let titleLabel = UILabel() + private let stackView = UIStackView() + private let iconNames = [ + "person.crop.circle.badge.xmark", + "folder", + "calendar.badge.exclamationmark", + ] + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + layer.cornerRadius = 12 + clipsToBounds = true + + titleLabel.text = "注销后将发生" + titleLabel.font = .systemFont(ofSize: 18, weight: .semibold) + titleLabel.textColor = AppColor.textPrimary + + stackView.axis = .vertical + addSubview(titleLabel) + addSubview(stackView) + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(16) + } + stackView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.leading.trailing.bottom.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 8, right: 16)) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 使用最新注销影响文案刷新卡片。 + func configure(items: [String]) { + stackView.arrangedSubviews.forEach { + stackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + items.enumerated().forEach { index, text in + stackView.addArrangedSubview( + AccountDeletionConsequenceRowView( + iconName: iconNames.indices.contains(index) ? iconNames[index] : "info.circle", + text: text, + showsDivider: index < items.count - 1 + ) + ) + } + } +} + +/// 注销影响说明列表行。 +private final class AccountDeletionConsequenceRowView: UIView { + private let iconBackgroundView = UIView() + private let iconView = UIImageView() + private let label = UILabel() + private let divider = UIView() + + init(iconName: String, text: String, showsDivider: Bool) { + super.init(frame: .zero) + iconBackgroundView.backgroundColor = AppColor.primaryLight + iconBackgroundView.layer.cornerRadius = 18 + iconView.image = UIImage(systemName: iconName) + iconView.tintColor = AppColor.primary + iconView.contentMode = .scaleAspectFit + label.text = text + label.font = .systemFont(ofSize: 14) + label.textColor = AppColor.textPrimary + label.numberOfLines = 0 + divider.backgroundColor = AppColor.border + divider.isHidden = !showsDivider + + addSubview(iconBackgroundView) + iconBackgroundView.addSubview(iconView) + addSubview(label) + addSubview(divider) + snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(58) + } + iconBackgroundView.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + make.width.height.equalTo(36) + } + iconView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(8) + } + label.snp.makeConstraints { make in + make.leading.equalTo(iconBackgroundView.snp.trailing).offset(12) + make.trailing.equalToSuperview() + make.centerY.equalToSuperview() + } + divider.snp.makeConstraints { make in + make.leading.equalTo(label) + make.trailing.bottom.equalToSuperview() + make.height.equalTo(0.5) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 注销流程通用说明条。 +private final class AccountDeletionNoticeView: UIView { + private let iconView = UIImageView() + private let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = AppColor.infoBackground + layer.cornerRadius = 12 + addSubview(iconView) + addSubview(label) + iconView.tintColor = AppColor.primary + iconView.contentMode = .scaleAspectFit + label.font = .systemFont(ofSize: 14) + label.textColor = AppColor.textSecondary + label.numberOfLines = 0 + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + make.width.height.equalTo(24) + } + label.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(12) + make.trailing.equalToSuperview().offset(-16) + make.top.bottom.equalToSuperview().inset(14) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 配置说明条图标与文案。 + func configure(iconName: String, text: String) { + iconView.image = UIImage(systemName: iconName) + label.text = text + } +} diff --git a/suixinkan/UI/Setting/SettingViewController.swift b/suixinkan/UI/Setting/SettingViewController.swift index 2f52403..d1d7517 100644 --- a/suixinkan/UI/Setting/SettingViewController.swift +++ b/suixinkan/UI/Setting/SettingViewController.swift @@ -14,6 +14,12 @@ final class SettingViewController: BaseViewController { private let contentView = UIView() private let cardView = UIView() private let rowsStack = UIStackView() + private let dangerCardView = UIView() + private let accountDeletionRow = SettingMenuRow( + title: "注销账号", + titleColor: AppColor.danger, + showsDivider: false + ) private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false) private let copyrightLabel = UILabel() @@ -41,12 +47,18 @@ final class SettingViewController: BaseViewController { view.addSubview(contentView) contentView.addSubview(cardView) cardView.addSubview(rowsStack) + contentView.addSubview(dangerCardView) + dangerCardView.addSubview(accountDeletionRow) contentView.addSubview(copyrightLabel) cardView.backgroundColor = .white cardView.layer.cornerRadius = 12 cardView.clipsToBounds = true + dangerCardView.backgroundColor = .white + dangerCardView.layer.cornerRadius = 12 + dangerCardView.clipsToBounds = true + rowsStack.axis = .vertical copyrightLabel.text = "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号" @@ -68,6 +80,7 @@ final class SettingViewController: BaseViewController { rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside) rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside) rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside) + accountDeletionRow.addTarget(self, action: #selector(accountDeletionTapped), for: .touchUpInside) } override func setupConstraints() { @@ -80,6 +93,13 @@ final class SettingViewController: BaseViewController { rowsStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)) } + dangerCardView.snp.makeConstraints { make in + make.top.equalTo(cardView.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview() + } + accountDeletionRow.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)) + } copyrightLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() make.bottom.equalToSuperview().offset(-16) @@ -119,6 +139,10 @@ final class SettingViewController: BaseViewController { openAgreement(.privacyPolicy) } + @objc private func accountDeletionTapped() { + navigationController?.pushViewController(AccountDeletionViewController(), animated: true) + } + private func openAgreement(_ kind: SettingAgreementKind) { let destination = viewModel.agreementDestination(for: kind) navigationController?.pushViewController( @@ -141,6 +165,7 @@ final class SettingMenuRow: UIControl { init( title: String, value: String? = nil, + titleColor: UIColor = UIColor(hex: 0x4B5563), valueColor: UIColor = AppColor.textPrimary, showsChevron: Bool = true, showsDivider: Bool = true @@ -150,6 +175,7 @@ final class SettingMenuRow: UIControl { setupUI() setupConstraints() titleLabel.text = title + titleLabel.textColor = titleColor valueLabel.text = value valueLabel.textColor = valueColor chevronImageView.isHidden = !showsChevron @@ -175,7 +201,6 @@ final class SettingMenuRow: UIControl { private func setupUI() { titleLabel.font = .systemFont(ofSize: 14) - titleLabel.textColor = UIColor(hex: 0x4B5563) valueLabel.font = .systemFont(ofSize: 14) valueLabel.textColor = AppColor.textPrimary diff --git a/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift index 6136835..268a88e 100644 --- a/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift +++ b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift @@ -22,8 +22,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { private let freeCountField = UITextField() private let singlePriceField = UITextField() private let packagePriceField = UITextField() + private let autoRetouchSectionView = UIView() + private let autoRetouchTitleLabel = UILabel() + private let autoRetouchDetailLabel = UILabel() + private let noRetouchOption = TravelAlbumModeOptionView() + private let aiRetouchOption = TravelAlbumModeOptionView() private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) + private var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) { self.viewModel = viewModel @@ -31,8 +37,16 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { super.init(nibName: nil, bundle: nil) modalPresentationStyle = .pageSheet if let sheetPresentationController { - sheetPresentationController.detents = [.medium(), .large()] + let formDetent = UISheetPresentationController.Detent.Identifier("createTravelAlbumForm") + sheetPresentationController.detents = [ + .custom(identifier: formDetent) { context in + min(590, context.maximumDetentValue) + }, + .large(), + ] + sheetPresentationController.selectedDetentIdentifier = formDetent sheetPresentationController.prefersGrabberVisible = false + sheetPresentationController.preferredCornerRadius = AppRadius.xl } } @@ -62,6 +76,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad) configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad) configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad) + configureAutoRetouchSection() configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary) configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white) @@ -112,6 +127,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { [freeCountField, singlePriceField, packagePriceField].forEach { $0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged) } + noRetouchOption.addTarget(self, action: #selector(noRetouchTapped), for: .touchUpInside) + aiRetouchOption.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside) } override func viewDidDisappear(_ animated: Bool) { @@ -148,6 +165,45 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField)) + fieldsStack.addArrangedSubview(autoRetouchSectionView) + } + + private func configureAutoRetouchSection() { + autoRetouchSectionView.backgroundColor = .white + autoRetouchSectionView.layer.cornerRadius = AppRadius.sm + autoRetouchSectionView.layer.borderWidth = 1 + autoRetouchSectionView.layer.borderColor = AppColor.border.cgColor + autoRetouchSectionView.clipsToBounds = true + + autoRetouchTitleLabel.text = "修图方式" + autoRetouchTitleLabel.font = .app(.bodyMedium) + autoRetouchTitleLabel.textColor = AppColor.textPrimary + autoRetouchDetailLabel.text = "选择 AI 修图后,需要再选一个效果模板" + autoRetouchDetailLabel.font = .app(.caption) + autoRetouchDetailLabel.textColor = AppColor.textSecondary + + let optionsStack = UIStackView(arrangedSubviews: [noRetouchOption, aiRetouchOption]) + optionsStack.axis = .horizontal + optionsStack.spacing = AppSpacing.xs + optionsStack.distribution = .fillEqually + + autoRetouchSectionView.addSubview(autoRetouchTitleLabel) + autoRetouchSectionView.addSubview(autoRetouchDetailLabel) + autoRetouchSectionView.addSubview(optionsStack) + autoRetouchTitleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + autoRetouchDetailLabel.snp.makeConstraints { make in + make.top.equalTo(autoRetouchTitleLabel.snp.bottom).offset(AppSpacing.xxs) + make.leading.trailing.equalTo(autoRetouchTitleLabel) + } + optionsStack.snp.makeConstraints { make in + make.top.equalTo(autoRetouchDetailLabel.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.sm) + make.height.equalTo(72) + } + updateAutoRetouchSection() } private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView { @@ -188,6 +244,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { freeCount: freeCountField.text ?? "", singlePrice: singlePriceField.text ?? "", packagePrice: packagePriceField.text ?? "", + autoRetouchConfiguration: autoRetouchConfiguration, order: nil, api: api ) @@ -208,6 +265,43 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { } } + @objc private func noRetouchTapped() { + autoRetouchConfiguration = .disabled + updateAutoRetouchSection() + } + + @objc private func aiRetouchTapped() { + presentTemplatePicker() + } + + private func presentTemplatePicker() { + guard presentedViewController == nil else { return } + let controller = TravelAlbumAutoRetouchSettingSheetViewController( + configuration: autoRetouchConfiguration, + startsWithModeSelection: false + ) + controller.onConfirm = { [weak self] configuration in + guard let self else { return } + self.autoRetouchConfiguration = configuration + self.updateAutoRetouchSection() + } + present(controller, animated: true) + } + + private func updateAutoRetouchSection() { + let isEnabled = autoRetouchConfiguration.isEnabled + noRetouchOption.apply( + title: TravelAlbumRetouchMode.disabled.title, + desc: "保留原图", + selected: !isEnabled + ) + aiRetouchOption.apply( + title: TravelAlbumRetouchMode.aiRetouch.title, + desc: autoRetouchConfiguration.template?.title ?? "点击选模板", + selected: isEnabled + ) + } + private func sanitizeMoneyInput(_ value: String) -> String { var result = "" var hasDot = false diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift new file mode 100644 index 0000000..8397dda --- /dev/null +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift @@ -0,0 +1,416 @@ +// +// TravelAlbumAutoRetouchSettingSheetViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 自动 AI 修图设置 Sheet,先选修图方式,选择 AI 修图后再选带效果图的模板。 +final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController, UITableViewDelegate { + private enum Step: Equatable { + case mode + case template + } + + private enum Item: Hashable { + case mode(TravelAlbumRetouchMode) + case template(TravelAlbumEditPreset) + } + + var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)? + var onCancelled: (() -> Void)? + + private let startsWithModeSelection: Bool + private let backButton = UIButton(type: .system) + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let tableView = UITableView(frame: .zero, style: .plain) + private let cancelButton = UIButton(type: .system) + private let confirmButton = UIButton(type: .system) + private var dataSource: UITableViewDiffableDataSource! + private var step: Step + private var pendingConfiguration: TravelAlbumAutoRetouchConfiguration + private var selectedItem: Item? + private var previewImages: [TravelAlbumEditPreset.Effect: UIImage] = [:] + private var didConfirm = false + + /// 创建自动修图设置 Sheet。 + /// - Parameters: + /// - configuration: 当前配置,用于回显已选方式和模板。 + /// - startsWithModeSelection: 是否先展示“不修图 / AI 修图”两个一级选项。 + init( + configuration: TravelAlbumAutoRetouchConfiguration, + startsWithModeSelection: Bool + ) { + self.startsWithModeSelection = startsWithModeSelection + step = startsWithModeSelection ? .mode : .template + pendingConfiguration = configuration + if startsWithModeSelection { + selectedItem = .mode(configuration.isEnabled ? .aiRetouch : .disabled) + } else if let template = configuration.template { + selectedItem = .template(template) + } + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .pageSheet + if let sheetPresentationController { + let identifier = UISheetPresentationController.Detent.Identifier("travelAlbumAutoRetouchSetting") + sheetPresentationController.detents = [ + .custom(identifier: identifier) { context in + min(660, context.maximumDetentValue) + }, + .large(), + ] + sheetPresentationController.selectedDetentIdentifier = identifier + sheetPresentationController.prefersGrabberVisible = false + sheetPresentationController.preferredCornerRadius = AppRadius.xl + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() {} + + override func setupUI() { + view.backgroundColor = .white + + backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) + backButton.tintColor = AppColor.textPrimary + backButton.accessibilityLabel = "返回修图方式" + + titleLabel.font = .systemFont(ofSize: 18, weight: .semibold) + titleLabel.textColor = AppColor.textPrimary + titleLabel.textAlignment = .center + + subtitleLabel.font = .app(.caption) + subtitleLabel.textColor = AppColor.textSecondary + subtitleLabel.textAlignment = .center + subtitleLabel.numberOfLines = 0 + + tableView.backgroundColor = .white + tableView.separatorStyle = .none + tableView.showsVerticalScrollIndicator = false + tableView.delegate = self + tableView.register( + TravelAlbumAutoRetouchOptionCell.self, + forCellReuseIdentifier: TravelAlbumAutoRetouchOptionCell.reuseIdentifier + ) + + dataSource = UITableViewDiffableDataSource(tableView: tableView) { + [weak self] tableView, indexPath, item in + let cell = tableView.dequeueReusableCell( + withIdentifier: TravelAlbumAutoRetouchOptionCell.reuseIdentifier, + for: indexPath + ) as! TravelAlbumAutoRetouchOptionCell + guard let self else { return cell } + switch item { + case .mode(let mode): + let iconName = mode == .disabled ? "photo" : "wand.and.stars" + let detail = mode == .disabled + ? "保留照片原效果" + : (self.pendingConfiguration.template.map { "当前模板:\($0.title)" } ?? "选择后需再选一个修图模板") + cell.apply( + title: mode.title, + detail: detail, + previewImage: UIImage(systemName: iconName), + usesTemplateImage: true, + selected: self.selectedItem == item + ) + case .template(let preset): + cell.apply( + title: preset.title, + detail: preset.autoRetouchEffectDescription, + previewImage: self.previewImage(for: preset), + usesTemplateImage: false, + selected: self.selectedItem == item + ) + } + return cell + } + + configureActionButton(cancelButton, title: "取消", isPrimary: false) + configureActionButton(confirmButton, title: "确定", isPrimary: true) + let buttonStack = UIStackView(arrangedSubviews: [cancelButton, confirmButton]) + buttonStack.axis = .horizontal + buttonStack.spacing = AppSpacing.sm + buttonStack.distribution = .fillEqually + + view.addSubview(backButton) + view.addSubview(titleLabel) + view.addSubview(subtitleLabel) + view.addSubview(tableView) + view.addSubview(buttonStack) + + backButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(AppSpacing.md) + make.centerY.equalTo(titleLabel) + make.size.equalTo(AppSpacing.minTouchTarget) + } + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(AppSpacing.lg) + make.leading.trailing.equalToSuperview().inset(60) + } + subtitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.equalToSuperview().inset(AppSpacing.lg) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(subtitleLabel.snp.bottom).offset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalTo(buttonStack.snp.top).offset(-AppSpacing.sm) + } + buttonStack.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-AppSpacing.md) + make.height.equalTo(AppSpacing.minTouchTarget) + } + + refreshContent() + } + + override func bindActions() { + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isBeingDismissed, !didConfirm { + onCancelled?() + } + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + step == .mode ? 80 : 108 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .mode(.disabled): + pendingConfiguration = .disabled + selectedItem = item + applySnapshot() + updateConfirmButton() + case .mode(.aiRetouch): + step = .template + selectedItem = pendingConfiguration.template.map(Item.template) + refreshContent() + case .template(let preset): + pendingConfiguration = .enabled(templateID: preset.id) + selectedItem = item + applySnapshot() + updateConfirmButton() + } + } + + private func refreshContent() { + switch step { + case .mode: + titleLabel.text = "选择修图方式" + subtitleLabel.text = "照片上传前可选择保留原图,或使用 AI 自动修图" + backButton.isHidden = true + case .template: + titleLabel.text = "选择修图模板" + subtitleLabel.text = "缩略图为模板实际效果,后续上传的照片将自动套用" + backButton.isHidden = !startsWithModeSelection + } + applySnapshot() + updateConfirmButton() + } + + private func applySnapshot() { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + switch step { + case .mode: + snapshot.appendItems(TravelAlbumRetouchMode.allCases.map(Item.mode)) + case .template: + snapshot.appendItems(TravelAlbumEditPreset.autoRetouchOptions.map(Item.template)) + } + + // 选中状态存放在页面状态中,item 本身的标识不会变化。 + // Diffable Data Source 不会主动重新配置相同 item,因此需显式刷新仍在当前列表中的选项。 + let currentItems = Set(dataSource.snapshot().itemIdentifiers) + let retainedItems = snapshot.itemIdentifiers.filter(currentItems.contains) + snapshot.reconfigureItems(retainedItems) + dataSource.apply(snapshot, animatingDifferences: false) + } + + private func updateConfirmButton() { + let isEnabled: Bool + switch selectedItem { + case .mode(.disabled): + isEnabled = step == .mode + case .mode(.aiRetouch): + isEnabled = step == .mode && pendingConfiguration.isEnabled && pendingConfiguration.isValid + case .template: + isEnabled = step == .template && pendingConfiguration.isEnabled && pendingConfiguration.isValid + case nil: + isEnabled = false + } + confirmButton.isEnabled = isEnabled + confirmButton.alpha = isEnabled ? 1 : 0.45 + } + + private func configureActionButton(_ button: UIButton, title: String, isPrimary: Bool) { + button.setTitle(title, for: .normal) + button.setTitleColor(isPrimary ? .white : AppColor.textSecondary, for: .normal) + button.titleLabel?.font = .app(.subtitle) + button.backgroundColor = isPrimary ? AppColor.primary : AppColor.inputBackground + button.layer.cornerRadius = AppRadius.md + } + + private func previewImage(for preset: TravelAlbumEditPreset) -> UIImage? { + if let image = previewImages[preset.effect] { + return image + } + guard let source = makePreviewSourceImage() else { return nil } + let image = TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: source) + previewImages[preset.effect] = image + return image + } + + private func makePreviewSourceImage() -> UIImage? { + guard let source = UIImage(named: "purchased_lakeside_flowers") else { return nil } + let size = CGSize(width: 176, height: 176) + return UIGraphicsImageRenderer(size: size).image { _ in + let scale = max(size.width / source.size.width, size.height / source.size.height) + let drawSize = CGSize(width: source.size.width * scale, height: source.size.height * scale) + let origin = CGPoint(x: (size.width - drawSize.width) / 2, y: (size.height - drawSize.height) / 2) + source.draw(in: CGRect(origin: origin, size: drawSize)) + } + } + + @objc private func backTapped() { + guard startsWithModeSelection else { return } + step = .mode + selectedItem = .mode(pendingConfiguration.isEnabled ? .aiRetouch : .disabled) + refreshContent() + } + + @objc private func cancelTapped() { + dismiss(animated: true) + } + + @objc private func confirmTapped() { + guard confirmButton.isEnabled else { return } + didConfirm = true + onConfirm?(pendingConfiguration) + dismiss(animated: true) + } +} + +/// 自动修图选项行,一级方式显示图标,模板显示本地生成的真实效果缩略图。 +private final class TravelAlbumAutoRetouchOptionCell: UITableViewCell { + static let reuseIdentifier = "TravelAlbumAutoRetouchOptionCell" + + private let cardView = UIView() + private let previewImageView = UIImageView() + private let titleLabel = UILabel() + private let detailLabel = UILabel() + private let selectionImageView = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + + cardView.layer.cornerRadius = AppRadius.sm + cardView.layer.borderWidth = 1 + previewImageView.clipsToBounds = true + previewImageView.layer.cornerRadius = AppRadius.xs + titleLabel.font = .app(.bodyMedium) + titleLabel.textColor = AppColor.textPrimary + detailLabel.font = .app(.caption) + detailLabel.textColor = AppColor.textTertiary + detailLabel.numberOfLines = 2 + selectionImageView.contentMode = .scaleAspectFit + + contentView.addSubview(cardView) + cardView.addSubview(previewImageView) + cardView.addSubview(titleLabel) + cardView.addSubview(detailLabel) + cardView.addSubview(selectionImageView) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) + } + previewImageView.snp.makeConstraints { make in + make.top.bottom.leading.equalToSuperview().inset(8) + make.width.equalTo(88) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(previewImageView.snp.trailing).offset(AppSpacing.sm) + make.trailing.lessThanOrEqualTo(selectionImageView.snp.leading).offset(-AppSpacing.sm) + make.bottom.equalTo(cardView.snp.centerY).offset(-2) + } + detailLabel.snp.makeConstraints { make in + make.top.equalTo(cardView.snp.centerY).offset(2) + make.leading.trailing.equalTo(titleLabel) + } + selectionImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-AppSpacing.md) + make.centerY.equalToSuperview() + make.size.equalTo(22) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 刷新选项文案、效果图与单选外观。 + func apply( + title: String, + detail: String, + previewImage: UIImage?, + usesTemplateImage: Bool, + selected: Bool + ) { + titleLabel.text = title + detailLabel.text = detail + previewImageView.image = usesTemplateImage + ? previewImage?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 28, weight: .medium)) + : previewImage + previewImageView.contentMode = usesTemplateImage ? .center : .scaleAspectFill + previewImageView.tintColor = AppColor.primary + previewImageView.backgroundColor = usesTemplateImage ? AppColor.primaryLight : AppColor.pageBackground + cardView.backgroundColor = selected ? AppColor.primaryLight : .white + cardView.layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor + selectionImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") + selectionImageView.tintColor = selected ? AppColor.primary : AppColor.textTertiary + accessibilityLabel = "\(title),\(detail)" + accessibilityValue = selected ? "已选择" : "未选择" + } +} + +private extension TravelAlbumEditPreset { + /// 模板效果的简短说明,与缩略图一起帮助用户判断效果。 + var autoRetouchEffectDescription: String { + switch effect { + case .original: + return "保留原图色彩" + case .portrait: + return "明亮柔和的人像质感" + case .vintage: + return "低饱和复古色调" + case .brocade: + return "鲜活通透的旅拍风格" + case .distantMountain: + return "自然淡雅的远山色调" + case .mist: + return "轻雾低对比氛围" + case .summer: + return "温暖明亮的油画色彩" + case .rich: + return "高饱和浓郁油画质感" + } + } +} diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift index 33f25e5..c631c1b 100644 --- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -40,7 +40,7 @@ final class WiredCameraTransferViewController: BaseViewController { private let refreshButton = UIButton(type: .system) private let helpLabel = UILabel() private let chipsStack = UIStackView() - private let retouchButton = WiredTransferSettingChipButton() + private let retouchButton = WiredTransferSettingChipButton(showsChevron: true) private let formatButton = WiredTransferSettingChipButton() private let modeButton = WiredTransferSettingChipButton(showsChevron: true) private let settingsStatsDivider = UIView() @@ -161,7 +161,6 @@ final class WiredCameraTransferViewController: BaseViewController { [retouchButton, formatButton, modeButton].forEach { chipsStack.addArrangedSubview($0) } - retouchButton.isUserInteractionEnabled = false formatButton.isUserInteractionEnabled = false settingsStatsDivider.backgroundColor = AppColor.border @@ -387,6 +386,7 @@ final class WiredCameraTransferViewController: BaseViewController { Task { @MainActor in self?.showToast(message) } } refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) + retouchButton.addTarget(self, action: #selector(retouchTapped), for: .touchUpInside) batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside) historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside) albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside) @@ -567,6 +567,7 @@ final class WiredCameraTransferViewController: BaseViewController { let selected = viewModel.selectedPhotoIds.contains(item.id) cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected) cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) } + cell.onRetryRetouch = { [weak self] in self?.viewModel.retryAutoRetouch(photoId: item.id) } cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) } } @@ -711,6 +712,17 @@ final class WiredCameraTransferViewController: BaseViewController { viewModel.refreshCameraFiles() } + @objc private func retouchTapped() { + let controller = TravelAlbumAutoRetouchSettingSheetViewController( + configuration: viewModel.autoRetouchConfiguration, + startsWithModeSelection: true + ) + controller.onConfirm = { [weak self] configuration in + self?.viewModel.updateAutoRetouchConfiguration(configuration) + } + present(controller, animated: true) + } + @objc private func batchTapped() { viewModel.onBatchUploadButtonClick() } @@ -1126,6 +1138,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { private let selectionIconView = UIImageView() private let imageView = UIImageView() + private let retouchBadgeLabel = UILabel() private let statusLabel = UILabel() private let titleLabel = UILabel() private let sizeLabel = UILabel() @@ -1134,6 +1147,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { private let separatorView = UIView() var onRetry: (() -> Void)? + var onRetryRetouch: (() -> Void)? var onDelete: (() -> Void)? override init(frame: CGRect) { @@ -1151,6 +1165,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { imageView.kf.cancelDownloadTask() imageView.image = nil onRetry = nil + onRetryRetouch = nil onDelete = nil menuButton.menu = nil } @@ -1178,16 +1193,21 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { titleLabel.alpha = rowAlpha sizeLabel.text = item.fileSizeText sizeLabel.alpha = rowAlpha - statusLabel.text = statusText(item.status) - statusLabel.textColor = statusTextColor(item.status) - statusLabel.backgroundColor = statusBackgroundColor(item.status) - progressView.isHidden = item.status != .uploading && item.status != .transferring - progressView.progress = Float(item.progress) / 100.0 + statusLabel.text = statusText(item) + statusLabel.textColor = statusTextColor(item) + statusLabel.backgroundColor = statusBackgroundColor(item) + retouchBadgeLabel.isHidden = item.autoRetouchState != .completed + let isProcessing = item.autoRetouchState == .processing + progressView.isHidden = !isProcessing && item.status != .uploading && item.status != .transferring + progressView.progress = isProcessing ? 0.62 : Float(item.progress) / 100.0 selectionIconView.isHidden = !selectionMode selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5) menuButton.isHidden = selectionMode - menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending) + menuButton.menu = makeMenu( + canRetryUpload: item.status == .failed || item.status == .pending, + canRetryRetouch: item.autoRetouchState == .failed + ) updateImageConstraints(selectionMode: selectionMode) } @@ -1197,6 +1217,14 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.layer.cornerRadius = 6 + retouchBadgeLabel.text = "修" + retouchBadgeLabel.font = .systemFont(ofSize: 9, weight: .semibold) + retouchBadgeLabel.textColor = .white + retouchBadgeLabel.textAlignment = .center + retouchBadgeLabel.backgroundColor = UIColor(hex: 0x7C3AED) + retouchBadgeLabel.layer.cornerRadius = 8 + retouchBadgeLabel.clipsToBounds = true + retouchBadgeLabel.isHidden = true statusLabel.font = .systemFont(ofSize: 9) statusLabel.textAlignment = .center statusLabel.layer.cornerRadius = 3 @@ -1219,6 +1247,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { contentView.addSubview(selectionIconView) contentView.addSubview(imageView) + contentView.addSubview(retouchBadgeLabel) contentView.addSubview(titleLabel) contentView.addSubview(statusLabel) contentView.addSubview(sizeLabel) @@ -1232,6 +1261,11 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { make.size.equalTo(20) } updateImageConstraints(selectionMode: true) + retouchBadgeLabel.snp.makeConstraints { make in + make.top.equalTo(imageView).offset(2) + make.trailing.equalTo(imageView).offset(-2) + make.size.equalTo(16) + } titleLabel.snp.makeConstraints { make in make.top.equalTo(imageView).offset(1) make.leading.equalTo(imageView.snp.trailing).offset(8) @@ -1266,15 +1300,19 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { } } - private func makeMenu(canRetry: Bool) -> UIMenu { + private func makeMenu(canRetryUpload: Bool, canRetryRetouch: Bool) -> UIMenu { let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in self?.onRetry?() } - retry.attributes = canRetry ? [] : [.disabled] + retry.attributes = canRetryUpload ? [] : [.disabled] + let retryRetouch = UIAction(title: "重新修图", image: UIImage(systemName: "wand.and.stars")) { [weak self] _ in + self?.onRetryRetouch?() + } + retryRetouch.attributes = canRetryRetouch ? [] : [.disabled] let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in self?.onDelete?() } - return UIMenu(children: [retry, delete]) + return UIMenu(children: [retry, retryRetouch, delete]) } private func updateImageConstraints(selectionMode: Bool) { @@ -1290,8 +1328,13 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { } } - private func statusText(_ status: TravelAlbumOTGUploadStatus) -> String { - switch status { + private func statusText(_ item: TravelAlbumOTGPhotoItem) -> String { + switch item.autoRetouchState { + case .processing: return "修图中" + case .failed: return "修图失败" + case .none, .completed: break + } + switch item.status { case .pending: return "待上传" case .transferring: return "传输中" case .uploading: return "上传中" @@ -1300,8 +1343,10 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { } } - private func statusTextColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { - switch status { + private func statusTextColor(_ item: TravelAlbumOTGPhotoItem) -> UIColor { + if item.autoRetouchState == .processing { return AppColor.primary } + if item.autoRetouchState == .failed { return AppColor.danger } + switch item.status { case .pending: return AppColor.textSecondary case .transferring, .uploading: return AppColor.primary case .uploaded: return UIColor(hex: 0x16A34A) @@ -1309,8 +1354,10 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { } } - private func statusBackgroundColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { - switch status { + private func statusBackgroundColor(_ item: TravelAlbumOTGPhotoItem) -> UIColor { + if item.autoRetouchState == .processing { return AppColor.primary.withAlphaComponent(0.12) } + if item.autoRetouchState == .failed { return AppColor.danger.withAlphaComponent(0.12) } + switch item.status { case .pending: return AppColor.pageBackground case .transferring, .uploading: diff --git a/suixinkanTests/AccountDeletionFeatureTests.swift b/suixinkanTests/AccountDeletionFeatureTests.swift new file mode 100644 index 0000000..98e6e2e --- /dev/null +++ b/suixinkanTests/AccountDeletionFeatureTests.swift @@ -0,0 +1,137 @@ +import XCTest +@testable import suixinkan + +/// 注销账号功能的业务规则测试,覆盖资产确认、七天冷静期与账号恢复。 +final class AccountDeletionFeatureTests: XCTestCase { + private var suiteName = "" + private var defaults: UserDefaults! + private var currentDate: Date! + private var service: AccountDeletionMockService! + + override func setUp() { + super.setUp() + suiteName = "AccountDeletionFeatureTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + currentDate = Date(timeIntervalSince1970: 1_800_000_000) + service = AccountDeletionMockService( + defaults: defaults, + now: { [weak self] in self?.currentDate ?? Date() }, + responseDelayNanoseconds: 0 + ) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + service = nil + currentDate = nil + defaults = nil + suiteName = "" + super.tearDown() + } + + /// 预检查应返回全部资产项,并保持中性展示文案。 + func testPrecheckReturnsAllAccountAssetsWithoutAbandonmentLabel() async throws { + let precheck = try await service.loadPrecheck(username: "18651857230") + + XCTAssertEqual(precheck.assets.map(\.kind), AccountDeletionAssetSummary.Kind.allCases) + XCTAssertEqual(precheck.assets.map(\.valueText), ["¥286.50", "36个", "4个", "8.6 GB"]) + XCTAssertFalse(precheck.assets.map(\.title).contains(where: { $0.contains("可放弃") })) + XCTAssertFalse(precheck.assets.map(\.valueText).contains(where: { $0.contains("可放弃") })) + } + + /// 用户勾选确认前不允许进入验证页。 + func testViewModelRequiresExplicitAssetAcknowledgement() async throws { + let viewModel = AccountDeletionViewModel(username: "18651857230", service: service) + + XCTAssertFalse(viewModel.isContinueEnabled) + await viewModel.load() + XCTAssertFalse(viewModel.isContinueEnabled) + XCTAssertTrue(viewModel.isContinueButtonEnabled) + + XCTAssertThrowsError(try viewModel.makeVerificationContext()) { error in + XCTAssertEqual(error as? AccountDeletionError, .incompleteAcknowledgement) + } + + viewModel.toggleAcknowledgement() + XCTAssertTrue(viewModel.isContinueEnabled) + + let context = try viewModel.makeVerificationContext() + XCTAssertEqual(context.maskedPhone, "186****7230") + XCTAssertEqual(Set(context.assets.map(\.kind)), Set(AccountDeletionAssetSummary.Kind.allCases)) + } + + /// 注销申请必须校验验证码,并确认全部资产项。 + func testSubmitRequiresCorrectCodeAndAllAssetKinds() async throws { + do { + _ = try await service.submitDeletion( + username: "18651857230", + verificationCode: "000000", + acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases), + clientRequestID: UUID() + ) + XCTFail("错误验证码不应提交成功") + } catch { + XCTAssertEqual(error as? AccountDeletionError, .invalidVerificationCode) + } + + do { + _ = try await service.submitDeletion( + username: "18651857230", + verificationCode: AccountDeletionMockService.verificationCode, + acknowledgedAssetKinds: [.wallet, .works, .projects], + clientRequestID: UUID() + ) + XCTFail("未确认全部资产项不应提交成功") + } catch { + XCTAssertEqual(error as? AccountDeletionError, .incompleteAcknowledgement) + } + } + + /// 提交后进入七天冷静期,期间再次登录可以取消注销。 + func testPendingDeletionCanBeCanceledWithinSevenDays() async throws { + let request = try await submitDeletion(username: "18651857230") + + XCTAssertEqual(request.scheduledDeletionAt, currentDate.addingTimeInterval(7 * 24 * 60 * 60)) + guard case let .pending(savedRequest) = service.loginState(for: "18651857230") else { + return XCTFail("应进入待注销状态") + } + XCTAssertEqual(savedRequest, request) + + currentDate = currentDate.addingTimeInterval(6 * 24 * 60 * 60) + _ = try service.cancelDeletion(username: "18651857230") + XCTAssertEqual(service.loginState(for: "18651857230"), .none) + } + + /// 七天后账号进入已注销状态,不能再通过登录恢复。 + func testDeletionBecomesCompletedAfterSevenDays() async throws { + _ = try await submitDeletion(username: "18651857230") + currentDate = currentDate.addingTimeInterval(7 * 24 * 60 * 60 + 1) + + guard case .completed = service.loginState(for: "18651857230") else { + return XCTFail("冷静期结束后应为已注销状态") + } + + do { + _ = try service.cancelDeletion(username: "18651857230") + XCTFail("已超过七天不应恢复账号") + } catch { + XCTAssertEqual(error as? AccountDeletionError, .cancellationExpired) + } + } + + /// 手机号展示仅保留前三位和后四位。 + func testPhoneMaskingNormalizesStoredUsername() { + XCTAssertEqual(AccountDeletionIdentity.normalizedUsername("+86 186-5185-7230"), "18651857230") + XCTAssertEqual(AccountDeletionIdentity.maskedPhone("+86 186-5185-7230"), "186****7230") + } + + private func submitDeletion(username: String) async throws -> AccountDeletionRequest { + try await service.submitDeletion( + username: username, + verificationCode: AccountDeletionMockService.verificationCode, + acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases), + clientRequestID: UUID() + ) + } +} diff --git a/suixinkanTests/LoginViewModelTests.swift b/suixinkanTests/LoginViewModelTests.swift index c077891..1120f04 100644 --- a/suixinkanTests/LoginViewModelTests.swift +++ b/suixinkanTests/LoginViewModelTests.swift @@ -200,4 +200,91 @@ final class LoginViewModelTests: XCTestCase { XCTFail("意外错误: \(error)") } } + + /// 密码校验成功后发现账号处于注销冷静期,应先确认恢复,再继续原有登录流程。 + func testPendingAccountDeletionCanBeCanceledAndResumeLogin() async throws { + let loginJSON = """ + { + "code": 100000, + "msg": "success", + "data": { + "token": "pending-token", + "scenic_users": [ + { + "account_type": "scenic_user", + "id": 101, + "user_id": 101, + "scenic_user_id": 101, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "示例景区", + "is_current": false + } + ], + "store_users": [] + } + } + """.data(using: .utf8)! + let setUserJSON = """ + { + "code": 100000, + "msg": "success", + "data": { + "token": "business-token-after-restore", + "scenic_users": [], + "store_users": [] + } + } + """.data(using: .utf8)! + + let suiteName = "LoginViewModelTests.AccountDeletion.\(UUID().uuidString)" + let deletionDefaults = UserDefaults(suiteName: suiteName)! + deletionDefaults.removePersistentDomain(forName: suiteName) + defer { deletionDefaults.removePersistentDomain(forName: suiteName) } + let fixedDate = Date(timeIntervalSince1970: 1_800_000_000) + let deletionService = AccountDeletionMockService( + defaults: deletionDefaults, + now: { fixedDate }, + responseDelayNanoseconds: 0 + ) + _ = try await deletionService.submitDeletion( + username: "18651857230", + verificationCode: AccountDeletionMockService.verificationCode, + acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases), + clientRequestID: UUID() + ) + + let session = MockURLSession(responses: [loginJSON, setUserJSON]) + let api = AuthAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = LoginViewModel(accountDeletionService: deletionService) + viewModel.updateAccount("18651857230") + viewModel.updatePassword("secret123") + viewModel.togglePrivacyChecked() + + let initialResolution = try await viewModel.login(authAPI: api) + guard case let .accountDeletionPending(verifiedResponse, request) = initialResolution else { + return XCTFail("应先提示账号正在注销中") + } + XCTAssertEqual(request.username, "18651857230") + XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login"]) + + let resumedResolution = try await viewModel.resumeLoginAfterCancelingDeletion( + verifiedResponse: verifiedResponse, + authAPI: api + ) + guard case let .completed(response, account) = resumedResolution else { + return XCTFail("取消注销后应完成登录") + } + XCTAssertEqual(response.token, "business-token-after-restore") + XCTAssertEqual(account.businessUserId, 101) + XCTAssertEqual(deletionService.loginState(for: "18651857230"), .none) + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/app/v9/login", + "/api/app/v9/set-user", + ]) + } } diff --git a/suixinkanTests/OfflineCollectionFeatureTests.swift b/suixinkanTests/OfflineCollectionFeatureTests.swift new file mode 100644 index 0000000..de0a16a --- /dev/null +++ b/suixinkanTests/OfflineCollectionFeatureTests.swift @@ -0,0 +1,205 @@ +// +// OfflineCollectionFeatureTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 线下收款登记、日汇总、失败回滚与幂等补缴流程测试。 +final class OfflineCollectionFeatureTests: XCTestCase { + private var defaults: UserDefaults! + private var calendar: Calendar! + private var fixedNow: Date! + private var service: OfflineCollectionMockService! + private let context = OfflineCollectionContext( + collectorId: "U10086", + collectorName: "张三", + storeId: "S1001", + storeName: "那拉提旅拍一店", + scenicId: "SC001", + scenicName: "那拉提景区" + ) + + override func setUp() { + super.setUp() + let suiteName = "OfflineCollectionFeatureTests" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! + fixedNow = calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 8, + day: 19, + hour: 20, + minute: 5 + ))! + let now = fixedNow! + service = OfflineCollectionMockService( + defaults: defaults, + calendar: calendar, + nowProvider: { now }, + requestDelayNanoseconds: 0 + ) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: "OfflineCollectionFeatureTests") + service = nil + calendar = nil + fixedNow = nil + defaults = nil + super.tearDown() + } + + func testMoneyParserUsesIntegerFenAndEnforcesRange() { + XCTAssertEqual(OfflineCollectionMoney.parseFen(".5"), 50) + XCTAssertEqual(OfflineCollectionMoney.parseFen("299"), 29_900) + XCTAssertEqual(OfflineCollectionMoney.parseFen("99999.99"), 9_999_999) + XCTAssertNil(OfflineCollectionMoney.parseFen("")) + XCTAssertNil(OfflineCollectionMoney.parseFen("0")) + XCTAssertNil(OfflineCollectionMoney.parseFen("-1")) + XCTAssertNil(OfflineCollectionMoney.parseFen("1.234")) + XCTAssertNil(OfflineCollectionMoney.parseFen("100000")) + XCTAssertNil(OfflineCollectionMoney.parseFen("1e2")) + XCTAssertEqual(OfflineCollectionMoney.display(29_900), "¥299.00") + } + + func testDefaultMockDataContainsTodayAndHistoricalOverdueRecords() { + let today = service.getDailySummary(date: "2026-08-19", context: context) + let overdue = service.getOverdueSummaries(context: context) + + XCTAssertEqual(today.totalCount, 3) + XCTAssertEqual(today.totalAmountFen, 89_700) + XCTAssertEqual(today.pendingAmountFen, 89_700) + XCTAssertEqual(overdue.count, 1) + XCTAssertEqual(overdue.first?.businessDate, "2026-08-18") + XCTAssertEqual(overdue.first?.pendingCount, 2) + XCTAssertEqual(overdue.first?.pendingAmountFen, 50_000) + XCTAssertEqual(overdue.first?.hasOverdue, true) + } + + func testCreateCollectionUpdatesSummaryAndPersists() async throws { + let receipt = try await service.createOfflineCollection( + amountFen: 29_900, + paymentMethod: .wechat, + context: context + ) + + XCTAssertEqual(receipt.record.status, .pending) + XCTAssertEqual(receipt.summary.totalCount, 4) + XCTAssertEqual(receipt.summary.totalAmountFen, 119_600) + XCTAssertEqual(receipt.summary.pendingAmountFen, 119_600) + + let now = fixedNow! + let reloaded = OfflineCollectionMockService( + defaults: defaults, + calendar: calendar, + nowProvider: { now }, + requestDelayNanoseconds: 0 + ) + XCTAssertEqual(reloaded.getDailySummary(date: "2026-08-19", context: context), receipt.summary) + } + + func testSuccessfulSettlementGeneratesIndependentBatchAndSettlesSnapshot() async throws { + let records = service.getDailyRecords(date: "2026-08-19", context: context) + let request = OfflineSettlementRequest( + businessDate: "2026-08-19", + recordIds: records.map(\.id), + amountFen: records.reduce(0) { $0 + $1.amountFen }, + clientRequestId: "LOCAL-SUCCESS-001" + ) + + let batch = try await service.settleDailyCollections(request: request, context: context) + let summary = service.getDailySummary(date: "2026-08-19", context: context) + let settledRecords = service.getDailyRecords(date: "2026-08-19", context: context) + + XCTAssertEqual(batch.amountFen, 89_700) + XCTAssertEqual(batch.recordCount, 3) + XCTAssertEqual(batch.transactionType, "OFFLINE_COLLECTION_SETTLEMENT") + XCTAssertEqual(summary.totalAmountFen, summary.settledAmountFen + summary.pendingAmountFen) + XCTAssertEqual(summary.pendingAmountFen, 0) + XCTAssertTrue(settledRecords.allSatisfy { $0.status == .settled }) + XCTAssertTrue(settledRecords.allSatisfy { $0.settlementBatchId == batch.id }) + } + + func testFailedSettlementDoesNotCreateBatchOrChangeRecords() async throws { + let before = service.getDailyRecords(date: "2026-08-19", context: context) + let request = OfflineSettlementRequest( + businessDate: "2026-08-19", + recordIds: before.map(\.id), + amountFen: before.reduce(0) { $0 + $1.amountFen }, + clientRequestId: "LOCAL-FAILURE-001" + ) + service.simulateNextSettlementFailure() + + do { + _ = try await service.settleDailyCollections(request: request, context: context) + XCTFail("应返回 Mock 补缴失败") + } catch { + XCTAssertEqual(error as? OfflineCollectionError, .settlementFailed) + } + + XCTAssertEqual(service.getDailyRecords(date: "2026-08-19", context: context), before) + XCTAssertTrue(service.getSettlementBatches(date: "2026-08-19", context: context).isEmpty) + XCTAssertEqual(service.getDailySummary(date: "2026-08-19", context: context).pendingAmountFen, 89_700) + } + + func testSettlementOnlySettlesFrozenRecordSnapshot() async throws { + let frozenRecords = service.getDailyRecords(date: "2026-08-19", context: context) + let request = OfflineSettlementRequest( + businessDate: "2026-08-19", + recordIds: frozenRecords.map(\.id), + amountFen: frozenRecords.reduce(0) { $0 + $1.amountFen }, + clientRequestId: "LOCAL-SNAPSHOT-001" + ) + let newReceipt = try await service.createOfflineCollection( + amountFen: 12_300, + paymentMethod: .cash, + context: context + ) + + _ = try await service.settleDailyCollections(request: request, context: context) + + let records = service.getDailyRecords(date: "2026-08-19", context: context) + let newRecord = try XCTUnwrap(records.first(where: { $0.id == newReceipt.record.id })) + XCTAssertEqual(newRecord.status, .pending) + XCTAssertEqual(service.getDailySummary(date: "2026-08-19", context: context).pendingAmountFen, 12_300) + } + + func testSameClientRequestIdDoesNotCreateDuplicateBatch() async throws { + let records = service.getDailyRecords(date: "2026-08-19", context: context) + let request = OfflineSettlementRequest( + businessDate: "2026-08-19", + recordIds: records.map(\.id), + amountFen: records.reduce(0) { $0 + $1.amountFen }, + clientRequestId: "LOCAL-IDEMPOTENT-001" + ) + + let first = try await service.settleDailyCollections(request: request, context: context) + let second = try await service.settleDailyCollections(request: request, context: context) + + XCTAssertEqual(first, second) + XCTAssertEqual(service.getSettlementBatches(date: "2026-08-19", context: context).count, 1) + } + + func testRegistrationViewModelValidatesAndSubmitsForm() async { + let viewModel = OfflineCollectionRegistrationViewModel(context: context, service: service) + var successReceipt: OfflineCollectionRegistrationReceipt? + viewModel.onRegistrationSuccess = { successReceipt = $0 } + + XCTAssertEqual(viewModel.paymentMethod, .wechat) + viewModel.updateAmount("299") + XCTAssertTrue(viewModel.canSubmit) + + await viewModel.submit() + + XCTAssertEqual(successReceipt?.record.amountFen, 29_900) + XCTAssertEqual(successReceipt?.record.paymentMethod, .wechat) + XCTAssertFalse(viewModel.isSubmitting) + XCTAssertEqual(successReceipt?.summary.totalCount, 4) + } +} diff --git a/suixinkanTests/TravelAlbumOTGStorageTests.swift b/suixinkanTests/TravelAlbumOTGStorageTests.swift index f911351..542174a 100644 --- a/suixinkanTests/TravelAlbumOTGStorageTests.swift +++ b/suixinkanTests/TravelAlbumOTGStorageTests.swift @@ -60,12 +60,44 @@ final class TravelAlbumOTGStorageTests: XCTestCase { XCTAssertEqual(store.absoluteURL(for: loaded.first?.localPath ?? "", albumId: 33), file) } + func testLoadNormalizesInterruptedRetouchToUploadedFailure() throws { + let root = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = makeStore(root: root) + let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33) + try Data([1, 2, 3]).write(to: file) + let record = TravelAlbumOTGPhotoRecord( + id: "retouching", + fileName: "A.JPG", + localPath: file.path, + capturedAt: "2026-07-07 10:00:00", + fileSizeBytes: 3, + status: .uploading, + progress: 100, + albumId: 33, + userId: "u1", + remoteUrl: "https://cdn/a.jpg", + materialId: 10, + autoRetouchState: .processing, + autoRetouchTemplateId: "brocade" + ) + + store.save([record], albumId: 33) + let loaded = try XCTUnwrap(store.load(albumId: 33).first) + + XCTAssertEqual(loaded.status, .uploaded) + XCTAssertEqual(loaded.progress, 100) + XCTAssertEqual(loaded.autoRetouchState, .failed) + XCTAssertEqual(loaded.autoRetouchTemplateId, "brocade") + } + func testRemoveAndClearAlbumDeleteFiles() throws { let root = try makeTempDirectory() defer { try? FileManager.default.removeItem(at: root) } let store = makeStore(root: root) let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33) try Data([1]).write(to: file) + let retouchedFile = try store.writeRetouchedImage(Data([2]), filename: "A.JPG", albumId: 33) let record = TravelAlbumOTGPhotoRecord( id: "1", fileName: "A.JPG", @@ -74,13 +106,17 @@ final class TravelAlbumOTGStorageTests: XCTestCase { fileSizeBytes: 1, status: .pending, albumId: 33, - userId: "u1" + userId: "u1", + autoRetouchState: .completed, + autoRetouchTemplateId: "brocade", + retouchedPath: retouchedFile.path ) store.save([record], albumId: 33) store.remove(albumId: 33, photoId: "1") XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: retouchedFile.path)) XCTAssertTrue(store.load(albumId: 33).isEmpty) let another = try store.uniqueOriginalFileURL(filename: "B.JPG", albumId: 33) diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 919acc4..3bac746 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -59,6 +59,55 @@ final class TravelAlbumEntryViewModelTests: XCTestCase { XCTAssertFalse(viewModel.isCreateSheetVisible) } + func testCreateSuccessPersistsSelectedAutoRetouchTemplate() async { + let api = TravelAlbumMockAPI() + api.createResponse = TravelAlbumCreateResponse(id: 19) + let defaults = makeOTGTestDefaults() + let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults) + let viewModel = TravelAlbumEntryViewModel( + currentScenicIdProvider: { 1 }, + autoRetouchConfigurationStore: configurationStore + ) + + await viewModel.confirmCreate( + mode: .preShoot, + freeCount: "2", + singlePrice: "12.5", + packagePrice: "88", + autoRetouchConfiguration: .enabled(templateID: "brocade"), + order: nil, + api: api + ) + + XCTAssertEqual( + configurationStore.configuration(albumID: 19), + .enabled(templateID: "brocade") + ) + } + + func testCreateRejectsEnabledAutoRetouchWithoutValidTemplate() async { + let api = TravelAlbumMockAPI() + let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }) + var message = "" + viewModel.onShowMessage = { message = $0 } + + await viewModel.confirmCreate( + mode: .preShoot, + freeCount: "", + singlePrice: "12.5", + packagePrice: "", + autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration( + isEnabled: true, + templateID: "missing-template" + ), + order: nil, + api: api + ) + + XCTAssertEqual(message, "请选择修图模板") + XCTAssertTrue(api.createRequests.isEmpty) + } + func testOrderAlreadyBoundReloadsOrders() async { let api = TravelAlbumMockAPI() api.createError = APIError.serverCode(100001, "订单已关联") @@ -73,6 +122,31 @@ final class TravelAlbumEntryViewModelTests: XCTestCase { } } +@MainActor +/// 自动修图选择弹窗的界面状态测试。 +final class TravelAlbumAutoRetouchSettingSheetTests: XCTestCase { + func testSelectingTemplateImmediatelyRefreshesSelectedAppearance() throws { + let controller = TravelAlbumAutoRetouchSettingSheetViewController( + configuration: .disabled, + startsWithModeSelection: false + ) + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 402, height: 874) + controller.view.layoutIfNeeded() + + let tableView = try XCTUnwrap( + controller.view.subviews.compactMap { $0 as? UITableView }.first + ) + let selectedIndexPath = IndexPath(row: 1, section: 0) + + tableView.delegate?.tableView?(tableView, didSelectRowAt: selectedIndexPath) + controller.view.layoutIfNeeded() + + XCTAssertEqual(tableView.cellForRow(at: selectedIndexPath)?.accessibilityValue, "已选择") + XCTAssertEqual(tableView.cellForRow(at: IndexPath(row: 0, section: 0))?.accessibilityValue, "未选择") + } +} + @MainActor /// 旅拍相册详情 ViewModel 测试。 final class TravelAlbumDetailViewModelTests: XCTestCase { @@ -785,6 +859,134 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertTrue(records.allSatisfy { UUID(uuidString: $0.clientPhotoId) != nil }) } + func testAutoRetouchConfigurationLoadsAndUpdatesUploadChip() { + let context = makeOTGTestContext() + let defaults = makeOTGTestDefaults() + let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults) + configurationStore.save(.enabled(templateID: "brocade"), albumID: 9) + let viewModel = makeWiredViewModel( + context: context, + manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])), + userDefaults: defaults, + autoRetouchConfigurationStore: configurationStore + ) + + XCTAssertEqual(viewModel.retouchOption, "AI 修图") + + viewModel.updateAutoRetouchConfiguration(.disabled) + + XCTAssertEqual(viewModel.retouchOption, "不修图") + XCTAssertEqual(configurationStore.configuration(albumID: 9), .disabled) + } + + func testAutoRetouchFirstLevelOnlyContainsNoRetouchAndAIRetouch() { + XCTAssertEqual(TravelAlbumRetouchMode.allCases, [.disabled, .aiRetouch]) + XCTAssertEqual(TravelAlbumRetouchMode.disabled.title, "不修图") + XCTAssertEqual(TravelAlbumRetouchMode.aiRetouch.title, "AI 修图") + } + + func testUploadAutomaticallyRetouchesAndPersistsEditedResult() async throws { + let context = makeOTGTestContext() + defer { try? FileManager.default.removeItem(at: context.root) } + let defaults = makeOTGTestDefaults() + let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults) + configurationStore.save(.enabled(templateID: "brocade"), albumID: 9) + let materialID = 990_901 + let uploader = MockTravelAlbumOTGUploader(materialID: materialID) + let processor = MockTravelAlbumAutoRetouchProcessor() + let resultStore = TravelAlbumAIEditResultStore.shared + resultStore.remove(materialID: materialID) + defer { resultStore.remove(materialID: materialID) } + let record = try makePersistedOTGRecord( + context: context, + id: "auto-retouch", + capturedAt: "2026-07-08 12:05:00", + status: .pending + ) + context.store.save([record], albumId: 9) + let viewModel = makeWiredViewModel( + context: context, + manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])), + uploader: uploader, + userDefaults: defaults, + autoRetouchConfigurationStore: configurationStore, + autoRetouchProcessor: processor + ) + + viewModel.reloadLocalPhotos() + viewModel.onSpecifyUploadOptionSelected(.allPending) + await waitUntil(timeout: 2) { + context.store.load(albumId: 9).first?.autoRetouchState == .completed + } + + let saved = try XCTUnwrap(context.store.load(albumId: 9).first) + XCTAssertEqual(saved.status, .uploaded) + XCTAssertEqual(saved.materialId, materialID) + XCTAssertEqual(saved.autoRetouchState, .completed) + XCTAssertEqual(saved.autoRetouchTemplateId, "brocade") + XCTAssertTrue(context.store.fileExists(relativePath: saved.retouchedPath, albumId: 9)) + XCTAssertEqual(processor.processedPresetIDs, ["brocade"]) + XCTAssertEqual(resultStore.state(for: materialID).appliedPresetID, "brocade") + XCTAssertEqual(viewModel.photos.first?.autoRetouchState, .completed) + + viewModel.deletePhoto(photoId: saved.id) + + XCTAssertFalse(context.store.fileExists(relativePath: saved.retouchedPath, albumId: 9)) + XCTAssertFalse(resultStore.state(for: materialID).hasEditedResult) + } + + func testAutoRetouchFailureKeepsUploadSuccessfulAndCanRetryWithoutReupload() async throws { + let context = makeOTGTestContext() + defer { try? FileManager.default.removeItem(at: context.root) } + let defaults = makeOTGTestDefaults() + let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults) + configurationStore.save(.enabled(templateID: "mist"), albumID: 9) + let materialID = 990_902 + let resultStore = TravelAlbumAIEditResultStore.shared + resultStore.remove(materialID: materialID) + defer { resultStore.remove(materialID: materialID) } + let uploader = MockTravelAlbumOTGUploader(materialID: materialID) + let processor = MockTravelAlbumAutoRetouchProcessor() + processor.shouldFail = true + let record = try makePersistedOTGRecord( + context: context, + id: "retry-retouch", + capturedAt: "2026-07-08 12:05:00", + status: .pending + ) + context.store.save([record], albumId: 9) + let viewModel = makeWiredViewModel( + context: context, + manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])), + uploader: uploader, + userDefaults: defaults, + autoRetouchConfigurationStore: configurationStore, + autoRetouchProcessor: processor + ) + + viewModel.reloadLocalPhotos() + viewModel.onSpecifyUploadOptionSelected(.allPending) + await waitUntil(timeout: 2) { + context.store.load(albumId: 9).first?.autoRetouchState == .failed + } + + var saved = try XCTUnwrap(context.store.load(albumId: 9).first) + XCTAssertEqual(saved.status, .uploaded) + XCTAssertEqual(viewModel.tabCounts[TravelAlbumOTGTransferTab.failed.rawValue], 0) + XCTAssertEqual(uploader.uploadCallCount, 1) + + processor.shouldFail = false + viewModel.retryAutoRetouch(photoId: saved.id) + await waitUntil(timeout: 2) { + context.store.load(albumId: 9).first?.autoRetouchState == .completed + } + + saved = try XCTUnwrap(context.store.load(albumId: 9).first) + XCTAssertEqual(saved.autoRetouchState, .completed) + XCTAssertEqual(uploader.uploadCallCount, 1) + XCTAssertEqual(processor.processedPresetIDs, ["mist", "mist"]) + } + func testStartSynchronizesPersistedUploadStatusesFromServerClientPhotoIds() async { let context = makeOTGTestContext() let api = TravelAlbumMockAPI() @@ -1013,9 +1215,13 @@ private func makeWiredViewModel( manager: MockWiredCameraConnectionManager, uploader: MockTravelAlbumOTGUploader? = nil, api: TravelAlbumMockAPI? = nil, - userDefaults: UserDefaults = makeOTGTestDefaults() + userDefaults: UserDefaults = makeOTGTestDefaults(), + autoRetouchConfigurationStore: (any TravelAlbumAutoRetouchConfigurationStoring)? = nil, + autoRetouchProcessor: (any TravelAlbumAutoRetouchProcessing)? = nil ) -> WiredCameraTransferViewModel { prepareAppStoreForOTGTests() + let configurationStore = autoRetouchConfigurationStore + ?? TravelAlbumAutoRetouchConfigurationStore(userDefaults: userDefaults) return WiredCameraTransferViewModel( albumId: albumId, albumTitle: "相册", @@ -1024,7 +1230,9 @@ private func makeWiredViewModel( storage: context.store, uploader: uploader ?? MockTravelAlbumOTGUploader(), api: api ?? TravelAlbumMockAPI(), - userDefaults: userDefaults + userDefaults: userDefaults, + autoRetouchConfigurationStore: configurationStore, + autoRetouchProcessor: autoRetouchProcessor ) } @@ -1054,7 +1262,9 @@ private func makeOTGPhotoItem( progress: 0, errorMessage: nil, localPath: "originals/\(id).JPG", - remoteUrl: "" + remoteUrl: "", + autoRetouchState: .none, + autoRetouchTemplateId: nil ) } @@ -1225,6 +1435,11 @@ private final class MockCameraDriver: CameraDriver { private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading { private(set) var uploadCallCount = 0 private(set) var uploadedRecordIds: [String] = [] + private let materialID: Int + + init(materialID: Int = 1) { + self.materialID = materialID + } func upload( record: TravelAlbumOTGPhotoRecord, @@ -1233,7 +1448,22 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading { ) async throws -> TravelAlbumMaterial { uploadCallCount += 1 uploadedRecordIds.append(record.id) - return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)") + return TravelAlbumMaterial(id: materialID, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)") + } +} + +/// 自动修图测试替身,可切换失败以验证重试闭环。 +@MainActor +private final class MockTravelAlbumAutoRetouchProcessor: TravelAlbumAutoRetouchProcessing { + var shouldFail = false + private(set) var processedPresetIDs: [String] = [] + + func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data { + processedPresetIDs.append(preset.id) + if shouldFail { + throw TravelAlbumAutoRetouchError.resultEncodingFailed + } + return Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6rE8AAAAASUVORK5CYII=")! } }