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=")!
}
}