2 Commits
Author SHA1 Message Date
han xin e1175066b9 删除没用的卡片 2026-08-24 11:05:51 +08:00
han xin 5cf4409bad 新增线下收款记录和 ai 修图优化 2026-08-21 15:51:52 +08:00
40 changed files with 5405 additions and 56 deletions
+45
View File
@@ -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
+22 -2
View File
@@ -1,8 +1,8 @@
# 相册管理与 AI 修图功能说明 # 相册管理与 AI 修图功能说明
> 文档日期:2026-07-29 > 文档日期:2026-08-20
> 适用范围:新增相册 → 相册任务 → 相册管理 → 照片上传与传输 → 照片预览与 AI 修图 > 适用范围:新增相册 → 相册任务 → 相册管理 → 照片上传与传输 → 照片预览与 AI 修图
> 数据说明:相册和照片列表、有线相机连接及照片传输继续使用项目已有真实数据能力;传输模式选择保存在本地。原图精修结果、氛围感结果、封面模板、剩余 9 张额度及状态角标目前为本地可演示数据,尚未接入服务端持久化,App 进程重启后会清空。 > 数据说明:相册和照片列表、有线相机连接及原图上传继续使用项目已有真实数据能力;传输模式与相册自动修图模板保存在本地。自动修图目前使用本地图像效果演示,修图结果图和任务状态可随 OTG 相册恢复;手动原图精修、氛围感、封面模板和剩余 9 张额度仍为本地演示数据。
## 1. 照片预览、左右滑动与底部操作 ## 1. 照片预览、左右滑动与底部操作
@@ -51,6 +51,21 @@
本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据,不创建新的相册或假照片。 本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据,不创建新的相册或假照片。
### 自动 AI 修图
新建相册时在价格字段下方固定展示“不修图”和“AI 修图”两个选项,默认选中“不修图”。点击“AI 修图”后再从底部弹出模板选择;每个模板同时展示本地演示照片套用该效果后的缩略图和效果说明。未选模板时不会开启 AI 修图。相册创建成功后,配置按真实相册 ID 保存,上传页仅回显当前一级方式“不修图 / AI 修图”。
有线传输页顶部的设置项也仅回显“不修图 / AI 修图”。点击后底部弹窗的第一层始终只有这两个选项;选择“AI 修图”后进入带效果缩略图的模板列表,可返回第一层重新选择。新配置只影响尚未开始的上传任务。每张照片仍先按原流程上传原图,得到服务端素材 ID 后才执行自动修图:
| 阶段 | 列表展示 | 统计归类 | 失败处理 |
| --- | --- | --- | --- |
| 原图上传 | 蓝色“上传中”及进度 | 全部 | 进入上传失败,可重传 |
| 自动修图 | 蓝色“修图中” | 全部 | 不回退原图上传结果 |
| 修图成功 | 绿色“已上传”,缩略图右上角显示紫色“修” | 已上传 | 使用修图后结果预览 |
| 修图失败 | 红色“修图失败” | 已上传 | 更多菜单选择“重新修图”,不重复上传原图 |
修图中退出 App 时,再次进入会恢复为“修图失败”并允许重试,避免任务长期停留在处理中。
进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。 进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。
### 状态流转图 ### 状态流转图
@@ -65,6 +80,11 @@
- [ ] 弹窗同时显示“边拍边传”和“拍后传输”及对应说明。 - [ ] 弹窗同时显示“边拍边传”和“拍后传输”及对应说明。
- [ ] 选择“边拍边传”后,下一页模式显示为“边拍边传”。 - [ ] 选择“边拍边传”后,下一页模式显示为“边拍边传”。
- [ ] 选择“拍后传输”后,下一页模式显示为“拍后传输”。 - [ ] 选择“拍后传输”后,下一页模式显示为“拍后传输”。
- [ ] 新建相册固定展示“不修图”和“AI 修图”两个选项。
- [ ] 选择“AI 修图”后才弹出模板列表,每个模板都能看到差异化效果图。
- [ ] 上传页一级弹窗只有两种修图方式,新配置仅影响后续上传。
- [ ] 修图中、修图成功“修”角标和修图失败三种状态展示正确。
- [ ] 修图失败仍计入“已上传”,点击“重新修图”不重复上传原图。
- [ ] 本次选择不会被上一次保存的模式覆盖。 - [ ] 本次选择不会被上一次保存的模式覆盖。
- [ ] 取消或关闭弹窗后仍停留在相册管理。 - [ ] 取消或关闭弹窗后仍停留在相册管理。
- [ ] 非批量选择状态点击缩略图后,从被点击照片进入全屏预览。 - [ ] 非批量选择状态点击缩略图后,从被点击照片进入全屏预览。
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "payment_method_alipay.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -0,0 +1,4 @@
<!-- 来源:阿里巴巴矢量图标库,支付宝支付,图标 ID 1486848 -->
<svg width="24" height="24" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path d="M1024.0512 701.0304V196.864A196.9664 196.9664 0 0 0 827.136 0H196.864A196.9664 196.9664 0 0 0 0 196.864v630.272A196.9152 196.9152 0 0 0 196.864 1024h630.272a197.12 197.12 0 0 0 193.8432-162.0992c-52.224-22.6304-278.528-120.32-396.4416-176.64-89.7024 108.6976-183.7056 173.9264-325.3248 173.9264s-236.1856-87.2448-224.8192-194.048c7.4752-70.0416 55.552-184.576 264.2944-164.9664 110.08 10.3424 160.4096 30.8736 250.1632 60.5184 23.1936-42.5984 42.496-89.4464 57.1392-139.264H248.064v-39.424h196.9152V311.1424H204.8V267.776h240.128V165.632s2.1504-15.9744 19.8144-15.9744h98.4576V267.776h256v43.4176h-256V381.952h208.8448a805.9904 805.9904 0 0 1-84.8384 212.6848c60.672 22.016 336.7936 106.3936 336.7936 106.3936zM283.5456 791.6032c-149.6576 0-173.312-94.464-165.376-133.9392 7.8336-39.3216 51.2-90.624 134.4-90.624 95.5904 0 181.248 24.4736 284.0576 74.5472-72.192 94.0032-160.9216 150.016-253.0816 150.016z" fill="#009FE8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "payment_method_cash.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -0,0 +1,5 @@
<!-- 来源:阿里巴巴矢量图标库,现金,图标 ID 25537599 -->
<svg width="24" height="24" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path d="M128 256v512h768V256H128z m853.333333-85.333333v682.666666H42.666667V170.666667h938.666666z" fill="#2E2F30"/>
<path d="M512 682.666667a170.666667 170.666667 0 1 1 0-341.333334 170.666667 170.666667 0 0 1 0 341.333334z m0-85.333334a85.333333 85.333333 0 1 0 0-170.666666 85.333333 85.333333 0 0 0 0 170.666666z" fill="#2E2F30"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "payment_method_wechat.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -0,0 +1,4 @@
<!-- 来源:阿里巴巴矢量图标库,微信支付,图标 ID 781032 -->
<svg width="24" height="24" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path d="M404.511405 600.865957c-4.042059 2.043542-8.602935 3.223415-13.447267 3.223415-11.197016 0-20.934798-6.169513-26.045189-15.278985l-1.959631-4.296863-81.56569-178.973184c-0.880043-1.954515-1.430582-4.14746-1.430582-6.285147 0-8.251941 6.686283-14.944364 14.938224-14.944364 3.351328 0 6.441713 1.108241 8.94165 2.966565l96.242971 68.521606c7.037277 4.609994 15.433504 7.305383 24.464181 7.305383 5.40101 0 10.533914-1.00284 15.328104-2.75167l452.645171-201.459315C811.496653 163.274644 677.866167 100.777241 526.648117 100.777241c-247.448742 0-448.035176 167.158091-448.035176 373.361453 0 112.511493 60.353576 213.775828 154.808832 282.214547 7.582699 5.405103 12.537548 14.292518 12.537548 24.325012 0 3.312442-0.712221 6.358825-1.569752 9.515724-7.544837 28.15013-19.62599 73.202209-20.188808 75.314313-0.940418 3.529383-2.416026 7.220449-2.416026 10.917654 0 8.245801 6.692423 14.933107 14.944364 14.933107 3.251044 0 5.89015-1.202385 8.629541-2.7793l98.085946-56.621579c7.377014-4.266164 15.188934-6.89913 23.790846-6.89913 4.577249 0 9.003048 0.703011 13.174044 1.978051 45.75509 13.159718 95.123474 20.476357 146.239666 20.476357 247.438509 0 448.042339-167.162184 448.042339-373.372709 0-62.451354-18.502399-121.275087-51.033303-173.009356L407.778822 598.977957 404.511405 600.865957z" fill="#00C800"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -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)
}
}
@@ -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<AccountDeletionAssetSummary.Kind>,
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<AccountDeletionAssetSummary.Kind>,
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)
}
}
@@ -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?()
}
}
@@ -53,10 +53,11 @@ enum LoginValidationError: Equatable {
} }
} }
/// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。 /// 登录结果实体,区分正常登录、账号选择和注销冷静期恢复三种情况。
enum LoginResolution { enum LoginResolution {
case completed(V9AuthResponse, AccountSwitchAccount) case completed(V9AuthResponse, AccountSwitchAccount)
case needsAccountSelection(AccountSelectionPayload) case needsAccountSelection(AccountSelectionPayload)
case accountDeletionPending(V9AuthResponse, AccountDeletionRequest)
} }
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。 /// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
@@ -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
}
}
@@ -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<String> = 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)
}
}
@@ -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()
}
}
@@ -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)"
}
}
@@ -19,6 +19,8 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
var errorMessage: String? var errorMessage: String?
let localPath: String let localPath: String
var remoteUrl: String var remoteUrl: String
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
/// 是否未上传完成。 /// 是否未上传完成。
var isNotUploaded: Bool { var isNotUploaded: Bool {
@@ -346,7 +348,8 @@ enum TravelAlbumOTGPhotoFormatMatcher {
extension TravelAlbumOTGPhotoRecord { extension TravelAlbumOTGPhotoRecord {
/// 转为页面展示项。 /// 转为页面展示项。
func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem { 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 } guard !path.isEmpty else { return false }
if path.hasPrefix("http") { return true } if path.hasPrefix("http") { return true }
return storage.fileExists(relativePath: path, albumId: albumId) return storage.fileExists(relativePath: path, albumId: albumId)
@@ -371,7 +374,9 @@ extension TravelAlbumOTGPhotoRecord {
progress: progress, progress: progress,
errorMessage: errorMessage, errorMessage: errorMessage,
localPath: localPath, localPath: localPath,
remoteUrl: remoteUrl remoteUrl: remoteUrl,
autoRetouchState: autoRetouchState,
autoRetouchTemplateId: autoRetouchTemplateId
) )
} }
} }
@@ -56,6 +56,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
let albumId: Int let albumId: Int
let userId: String let userId: String
var remoteUrl: String var remoteUrl: String
var materialId: Int?
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
var retouchedPath: String
var updatedAt: Int64 var updatedAt: Int64
/// 创建 OTG 本地照片记录。 /// 创建 OTG 本地照片记录。
@@ -74,6 +78,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId: Int, albumId: Int,
userId: String, userId: String,
remoteUrl: String = "", remoteUrl: String = "",
materialId: Int? = nil,
autoRetouchState: TravelAlbumAutoRetouchState = .none,
autoRetouchTemplateId: String? = nil,
retouchedPath: String = "",
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000) updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
) { ) {
self.id = id self.id = id
@@ -90,12 +98,17 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
self.albumId = albumId self.albumId = albumId
self.userId = userId self.userId = userId
self.remoteUrl = remoteUrl self.remoteUrl = remoteUrl
self.materialId = materialId
self.autoRetouchState = autoRetouchState
self.autoRetouchTemplateId = autoRetouchTemplateId
self.retouchedPath = retouchedPath
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt 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 一次性迁移并回写。 /// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
@@ -115,16 +128,30 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId = try container.decode(Int.self, forKey: .albumId) albumId = try container.decode(Int.self, forKey: .albumId)
userId = try container.decode(String.self, forKey: .userId) userId = try container.decode(String.self, forKey: .userId)
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? "" 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 updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
} }
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。 /// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord { 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 var copy = self
copy.status = .pending if interruptedUpload {
copy.progress = 0 copy.status = copy.materialId == nil ? .pending : .uploaded
copy.progress = copy.materialId == nil ? 0 : 100
copy.errorMessage = nil copy.errorMessage = nil
}
if interruptedRetouch {
copy.autoRetouchState = copy.materialId == nil ? .none : .failed
}
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
return copy return copy
} }
@@ -242,7 +269,7 @@ final class TravelAlbumOTGPhotoStore {
?? TravelAlbumClientPhotoID.make() ?? TravelAlbumClientPhotoID.make()
return copy.normalizedAfterInterruptedTransfer() return copy.normalizedAfterInterruptedTransfer()
} }
if migrated.map(\.clientPhotoId) != scoped.map(\.clientPhotoId) { if migrated != scoped {
save(migrated, albumId: albumId) save(migrated, albumId: albumId)
} }
return migrated return migrated
@@ -333,6 +360,22 @@ final class TravelAlbumOTGPhotoStore {
return url 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 { func relativePath(for url: URL, albumId: Int) -> String {
let path = url.standardizedFileURL.path let path = url.standardizedFileURL.path
@@ -382,6 +425,9 @@ final class TravelAlbumOTGPhotoStore {
if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) { if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) {
resolved.thumbnailPath = thumbnailURL.path resolved.thumbnailPath = thumbnailURL.path
} }
if let retouchedURL = absoluteURL(for: record.retouchedPath, albumId: albumId) {
resolved.retouchedPath = retouchedURL.path
}
return resolved return resolved
} }
@@ -433,7 +479,7 @@ final class TravelAlbumOTGPhotoStore {
} }
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) { 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 !path.isEmpty else { return }
guard let url = absoluteURL(for: path, albumId: record.albumId) else { return } guard let url = absoluteURL(for: path, albumId: record.albumId) else { return }
try? fileManager.removeItem(at: url) try? fileManager.removeItem(at: url)
@@ -463,6 +509,11 @@ final class TravelAlbumOTGPhotoStore {
? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId) ? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId)
: record.thumbnailPath : record.thumbnailPath
} }
if !record.retouchedPath.isEmpty {
normalized.retouchedPath = record.retouchedPath.hasPrefix("/")
? relativePath(for: URL(fileURLWithPath: record.retouchedPath), albumId: albumId)
: record.retouchedPath
}
return normalized return normalized
} }
@@ -90,6 +90,18 @@ final class TravelAlbumAIEditResultStore {
notifyChange(materialID: materialID) 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) { func failProcessing(materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return } guard var record = recordsByMaterialID[materialID] else { return }
@@ -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 "修图结果无法读取"
}
}
}
@@ -42,13 +42,16 @@ final class TravelAlbumEntryViewModel {
private let currentScenicIdProvider: () -> Int private let currentScenicIdProvider: () -> Int
private let dateProvider: () -> Date private let dateProvider: () -> Date
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
init( init(
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }, 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.currentScenicIdProvider = currentScenicIdProvider
self.dateProvider = dateProvider self.dateProvider = dateProvider
self.autoRetouchConfigurationStore = autoRetouchConfigurationStore
} }
/// 重新拉取相册列表。 /// 重新拉取相册列表。
@@ -110,6 +113,7 @@ final class TravelAlbumEntryViewModel {
freeCount: String, freeCount: String,
singlePrice: String, singlePrice: String,
packagePrice: String, packagePrice: String,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
order: TravelAlbumAvailableOrder?, order: TravelAlbumAvailableOrder?,
api: any TravelAlbumServing api: any TravelAlbumServing
) async { ) async {
@@ -132,6 +136,10 @@ final class TravelAlbumEntryViewModel {
onShowMessage?("请输入有效的单张照片价格") onShowMessage?("请输入有效的单张照片价格")
return return
} }
if !autoRetouchConfiguration.isValid {
onShowMessage?("请选择修图模板")
return
}
let albumName: String let albumName: String
switch mode { switch mode {
@@ -174,6 +182,7 @@ final class TravelAlbumEntryViewModel {
do { do {
let response = try await api.create(request) let response = try await api.create(request)
autoRetouchConfigurationStore.save(autoRetouchConfiguration, albumID: response.id)
isCreateSheetVisible = false isCreateSheetVisible = false
onShowMessage?("任务创建成功") onShowMessage?("任务创建成功")
onCreatedAlbum?(response) onCreatedAlbum?(response)
@@ -63,7 +63,7 @@ final class WiredCameraTransferViewModel {
private(set) var sonyMTPHint: String? private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false private(set) var isContentCatalogReady = false
let retouchOption = "不修图" private(set) var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload { private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
didSet { notifyStateChanged() } didSet { notifyStateChanged() }
@@ -80,6 +80,9 @@ final class WiredCameraTransferViewModel {
private let api: any TravelAlbumServing private let api: any TravelAlbumServing
private let appStore: AppStore private let appStore: AppStore
private let userDefaults: UserDefaults private let userDefaults: UserDefaults
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
private let autoRetouchProcessor: any TravelAlbumAutoRetouchProcessing
private let aiEditResultStore: TravelAlbumAIEditResultStore
private var currentDriver: CameraDriver? private var currentDriver: CameraDriver?
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:] private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
@@ -100,7 +103,10 @@ final class WiredCameraTransferViewModel {
uploader: (any TravelAlbumOTGUploading)? = nil, uploader: (any TravelAlbumOTGUploading)? = nil,
api: (any TravelAlbumServing)? = nil, api: (any TravelAlbumServing)? = nil,
appStore: AppStore = .shared, 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.albumId = albumId
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
@@ -113,6 +119,10 @@ final class WiredCameraTransferViewModel {
self.api = api ?? NetworkServices.shared.travelAlbumAPI self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore self.appStore = appStore
self.userDefaults = userDefaults 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) self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
if let initialTransferMode { if let initialTransferMode {
@@ -218,6 +228,11 @@ final class WiredCameraTransferViewModel {
transferMode.title transferMode.title
} }
/// 自动 AI 修图设置项展示文案。
var retouchOption: String {
autoRetouchConfiguration.uploadOptionTitle
}
/// 指定上传弹窗选项。 /// 指定上传弹窗选项。
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] { var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
TravelAlbumOTGSpecifyUploadOption.allCases TravelAlbumOTGSpecifyUploadOption.allCases
@@ -306,6 +321,15 @@ final class WiredCameraTransferViewModel {
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey) 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) { func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
guard photoFormatOption != option else { return } guard photoFormatOption != option else { return }
@@ -467,8 +491,31 @@ final class WiredCameraTransferViewModel {
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传") 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) { func deletePhoto(photoId: String) {
if let materialId = persistedRecordsById[photoId]?.materialId {
aiEditResultStore.remove(materialID: materialId)
}
storage.remove(albumId: albumId, photoId: photoId) storage.remove(albumId: albumId, photoId: photoId)
persistedRecordsById.removeValue(forKey: photoId) persistedRecordsById.removeValue(forKey: photoId)
selectedPhotoIds.remove(photoId) selectedPhotoIds.remove(photoId)
@@ -478,6 +525,9 @@ final class WiredCameraTransferViewModel {
/// 清空当前相册本地 OTG 缓存。 /// 清空当前相册本地 OTG 缓存。
func clearLocalAlbumCache() { func clearLocalAlbumCache() {
persistedRecordsById.values.compactMap(\.materialId).forEach { materialId in
aiEditResultStore.remove(materialID: materialId)
}
storage.clearAlbum(albumId: albumId) storage.clearAlbum(albumId: albumId)
persistedRecordsById = [:] persistedRecordsById = [:]
photos = [] photos = []
@@ -492,9 +542,27 @@ final class WiredCameraTransferViewModel {
private func loadPersistedPhotos() { private func loadPersistedPhotos() {
let records = storage.load(albumId: albumId) let records = storage.load(albumId: albumId)
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
restorePersistedAutoRetouchResults(records)
applyMergedPhotos() 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() { private func syncServerUploadStatuses() {
serverStatusSyncTask?.cancel() serverStatusSyncTask?.cancel()
guard albumId > 0 else { return } guard albumId > 0 else { return }
@@ -593,6 +661,8 @@ final class WiredCameraTransferViewModel {
} }
private func uploadPhoto(id: String) async { private func uploadPhoto(id: String) async {
// 每张照片开始上传时固定一次配置,避免修图过程中切换模板导致结果不一致。
let retouchConfiguration = autoRetouchConfiguration
do { do {
let record = try await localRecordForUpload(id: id) let record = try await localRecordForUpload(id: id)
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil) updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
@@ -602,13 +672,89 @@ final class WiredCameraTransferViewModel {
) { [weak self] progress in ) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) 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 { } catch {
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription) updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
showMessage(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 { private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id], if let record = persistedRecordsById[id],
!record.localPath.isEmpty, !record.localPath.isEmpty,
@@ -623,7 +769,8 @@ final class WiredCameraTransferViewModel {
status: TravelAlbumOTGUploadStatus, status: TravelAlbumOTGUploadStatus,
progress: Int, progress: Int,
error: String?, error: String?,
remoteUrl: String? = nil remoteUrl: String? = nil,
materialId: Int? = nil
) { ) {
var record = persistedRecordsById[id] var record = persistedRecordsById[id]
if record == nil, let item = photos.first(where: { $0.id == id }) { if record == nil, let item = photos.first(where: { $0.id == id }) {
@@ -642,7 +789,10 @@ final class WiredCameraTransferViewModel {
errorMessage: error, errorMessage: error,
albumId: albumId, albumId: albumId,
userId: appStore.session.userId, 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 } guard var record else { return }
@@ -652,6 +802,35 @@ final class WiredCameraTransferViewModel {
if let remoteUrl { if let remoteUrl {
record.remoteUrl = 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) record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[id] = record persistedRecordsById[id] = record
storage.upsert(record, albumId: albumId) storage.upsert(record, albumId: albumId)
+30 -1
View File
@@ -19,8 +19,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
AppNavigationBarAppearance.applyGlobalAppearance() AppNavigationBarAppearance.applyGlobalAppearance()
let debugPreviewRoot = makeAccountDeletionDebugPreviewRootIfNeeded()
if debugPreviewRoot == nil {
enforceAccountDeletionStateBeforeLaunch()
}
let window = UIWindow(windowScene: windowScene) let window = UIWindow(windowScene: windowScene)
window.rootViewController = AppRouter.makeRootViewController() window.rootViewController = debugPreviewRoot ?? AppRouter.makeRootViewController()
window.makeKeyAndVisible() window.makeKeyAndVisible()
self.window = window self.window = window
(UIApplication.shared.delegate as? AppDelegate)?.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() { @objc private func handleSessionDidExpire() {
guard AppStore.shared.session.isLoggedIn else { return } guard AppStore.shared.session.isLoggedIn else { return }
guard sessionExpiredDialog == nil else { return } guard sessionExpiredDialog == nil else { return }
@@ -273,12 +273,53 @@ final class LoginViewController: BaseViewController {
do { do {
let resolution = try await viewModel.login(authAPI: authAPI) let resolution = try await viewModel.login(authAPI: authAPI)
handleLoginResolution(resolution)
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
private func handleLoginResolution(_ resolution: LoginResolution) {
switch resolution { switch resolution {
case let .completed(response, account): case let .completed(response, account):
completeLogin(with: response, account: account) completeLogin(with: response, account: account)
case .needsAccountSelection: case .needsAccountSelection:
break 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 { } catch is CancellationError {
return return
} catch { } catch {
+25
View File
@@ -8,6 +8,8 @@ import Foundation
/// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。 /// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。
final class LoginViewModel { final class LoginViewModel {
private let accountDeletionService: any AccountDeletionServing
private(set) var account = "" private(set) var account = ""
private(set) var password = "" private(set) var password = ""
private(set) var isPrivacyChecked = false private(set) var isPrivacyChecked = false
@@ -16,6 +18,11 @@ final class LoginViewModel {
private(set) var isSelectingAccount = false private(set) var isSelectingAccount = false
private(set) var pendingAccountSelection: AccountSelectionPayload? private(set) var pendingAccountSelection: AccountSelectionPayload?
/// 创建登录 ViewModel,并注入注销状态服务以支持7天内恢复账号。
init(accountDeletionService: any AccountDeletionServing = AccountDeletionMockService.shared) {
self.accountDeletionService = accountDeletionService
}
var isLoginEnabled: Bool { var isLoginEnabled: Bool {
isValidPhone && !trimmedPassword.isEmpty isValidPhone && !trimmedPassword.isEmpty
} }
@@ -122,9 +129,27 @@ final class LoginViewModel {
username: normalizedUsername, username: normalizedUsername,
password: trimmedPassword 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) 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。 /// 选择一个账号并调用 set-user 换取正式 token。
func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse { func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse {
guard let payload = pendingAccountSelection, payload.hasTempToken else { guard let payload = pendingAccountSelection, payload.hasTempToken else {
@@ -0,0 +1,606 @@
//
// OfflineCollectionDailyViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 日清列表的分区。
enum OfflineDailyListSection: Hashable {
case records
case empty
}
/// 日清列表的条目数据。
enum OfflineDailyListItem: Hashable {
case record(OfflineCollectionRecord)
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<OfflineDailyListSection, OfflineDailyListItem>!
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(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<OfflineDailyListSection, OfflineDailyListItem>()
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)
}
dataSource.apply(snapshot, animatingDifferences: view.window != nil)
}
private func makeDataSource() -> UITableViewDiffableDataSource<OfflineDailyListSection, OfflineDailyListItem> {
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 .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 = "线下收款明细"
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 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
}
}
@@ -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)
}
}
@@ -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?()
}
}
@@ -12,6 +12,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let viewModel = PaymentCollectionDetailsViewModel() private let viewModel = PaymentCollectionDetailsViewModel()
private let paymentAPI = NetworkServices.shared.paymentAPI private let paymentAPI = NetworkServices.shared.paymentAPI
private let offlineCollectionViewModel = OfflineCollectionHomeViewModel()
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let contentContainerView = UIView() private let contentContainerView = UIView()
@@ -48,6 +49,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let voiceCardView = UIView() private let voiceCardView = UIView()
private let voiceTitleLabel = UILabel() private let voiceTitleLabel = UILabel()
private let voiceSwitch = UISwitch() private let voiceSwitch = UISwitch()
private let offlineCollectionView = OfflineCollectionHomeView()
private var amountDialog: PaymentSetAmountDialogView? private var amountDialog: PaymentSetAmountDialogView?
private var appliedBrandConfig: PayPageConfig? private var appliedBrandConfig: PayPageConfig?
@@ -100,9 +102,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
override func setupUI() { override func setupUI() {
let usesBranding = viewModel.usesNalatiBranding let usesBranding = viewModel.usesNalatiBranding
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
scrollView.showsVerticalScrollIndicator = !usesBranding scrollView.showsVerticalScrollIndicator = true
scrollView.isScrollEnabled = !usesBranding scrollView.isScrollEnabled = true
scrollView.alwaysBounceVertical = false scrollView.alwaysBounceVertical = true
contentStack.axis = .vertical contentStack.axis = .vertical
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
@@ -201,6 +203,8 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
contentStack.addArrangedSubview(recordRow) contentStack.addArrangedSubview(recordRow)
contentStack.addArrangedSubview(voiceCardView) contentStack.addArrangedSubview(voiceCardView)
} }
// 线下收款与原有真实收款功能并列,不替换二维码或收款记录入口。
contentStack.addArrangedSubview(offlineCollectionView)
let qrDisplayView = usesBranding ? qrContainerView : qrImageView let qrDisplayView = usesBranding ? qrContainerView : qrImageView
let qrContentStack = UIStackView(arrangedSubviews: [ let qrContentStack = UIStackView(arrangedSubviews: [
@@ -330,15 +334,14 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
make.edges.equalToSuperview() make.edges.equalToSuperview()
make.width.equalTo(scrollView.snp.width) make.width.equalTo(scrollView.snp.width)
if viewModel.usesNalatiBranding { if viewModel.usesNalatiBranding {
make.height.equalTo(scrollView.snp.height) make.height.greaterThanOrEqualTo(scrollView.snp.height)
} }
} }
contentStack.snp.makeConstraints { make in contentStack.snp.makeConstraints { make in
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
if viewModel.usesNalatiBranding { if viewModel.usesNalatiBranding {
make.centerX.centerY.equalToSuperview() make.centerX.equalToSuperview()
make.top.greaterThanOrEqualToSuperview() make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.bottom.lessThanOrEqualToSuperview()
} else { } else {
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset) make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
} }
@@ -352,6 +355,18 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
viewModel.onShowMessage = { [weak self] message in viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) } 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) setAmountButton.addTarget(self, action: #selector(setAmountTapped), for: .touchUpInside)
saveQRButton.addTarget(self, action: #selector(saveQRTapped), for: .touchUpInside) saveQRButton.addTarget(self, action: #selector(saveQRTapped), for: .touchUpInside)
@@ -369,6 +384,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
applyBrandNavigationAppearanceIfNeeded() applyBrandNavigationAppearanceIfNeeded()
offlineCollectionViewModel.load()
} }
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
@@ -432,6 +448,17 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
amountDialog?.dismiss() amountDialog?.dismiss()
amountDialog = nil 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) { private func setActionRowAvailable(_ isAvailable: Bool) {
@@ -668,6 +695,24 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
@objc private func voiceSwitchChanged() { @objc private func voiceSwitchChanged() {
viewModel.toggleReceiveVoice() 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)
}
} }
/// 收款详情信息行。 /// 收款详情信息行。
@@ -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
}
}
@@ -14,6 +14,12 @@ final class SettingViewController: BaseViewController {
private let contentView = UIView() private let contentView = UIView()
private let cardView = UIView() private let cardView = UIView()
private let rowsStack = UIStackView() 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 versionRow = SettingMenuRow(title: "系统版本", showsChevron: false)
private let copyrightLabel = UILabel() private let copyrightLabel = UILabel()
@@ -41,12 +47,18 @@ final class SettingViewController: BaseViewController {
view.addSubview(contentView) view.addSubview(contentView)
contentView.addSubview(cardView) contentView.addSubview(cardView)
cardView.addSubview(rowsStack) cardView.addSubview(rowsStack)
contentView.addSubview(dangerCardView)
dangerCardView.addSubview(accountDeletionRow)
contentView.addSubview(copyrightLabel) contentView.addSubview(copyrightLabel)
cardView.backgroundColor = .white cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12 cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true cardView.clipsToBounds = true
dangerCardView.backgroundColor = .white
dangerCardView.layer.cornerRadius = 12
dangerCardView.clipsToBounds = true
rowsStack.axis = .vertical rowsStack.axis = .vertical
copyrightLabel.text = "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号" 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[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside)
rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside) rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside)
rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside) rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside)
accountDeletionRow.addTarget(self, action: #selector(accountDeletionTapped), for: .touchUpInside)
} }
override func setupConstraints() { override func setupConstraints() {
@@ -80,6 +93,13 @@ final class SettingViewController: BaseViewController {
rowsStack.snp.makeConstraints { make in rowsStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)) 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 copyrightLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview() make.leading.trailing.equalToSuperview()
make.bottom.equalToSuperview().offset(-16) make.bottom.equalToSuperview().offset(-16)
@@ -119,6 +139,10 @@ final class SettingViewController: BaseViewController {
openAgreement(.privacyPolicy) openAgreement(.privacyPolicy)
} }
@objc private func accountDeletionTapped() {
navigationController?.pushViewController(AccountDeletionViewController(), animated: true)
}
private func openAgreement(_ kind: SettingAgreementKind) { private func openAgreement(_ kind: SettingAgreementKind) {
let destination = viewModel.agreementDestination(for: kind) let destination = viewModel.agreementDestination(for: kind)
navigationController?.pushViewController( navigationController?.pushViewController(
@@ -141,6 +165,7 @@ final class SettingMenuRow: UIControl {
init( init(
title: String, title: String,
value: String? = nil, value: String? = nil,
titleColor: UIColor = UIColor(hex: 0x4B5563),
valueColor: UIColor = AppColor.textPrimary, valueColor: UIColor = AppColor.textPrimary,
showsChevron: Bool = true, showsChevron: Bool = true,
showsDivider: Bool = true showsDivider: Bool = true
@@ -150,6 +175,7 @@ final class SettingMenuRow: UIControl {
setupUI() setupUI()
setupConstraints() setupConstraints()
titleLabel.text = title titleLabel.text = title
titleLabel.textColor = titleColor
valueLabel.text = value valueLabel.text = value
valueLabel.textColor = valueColor valueLabel.textColor = valueColor
chevronImageView.isHidden = !showsChevron chevronImageView.isHidden = !showsChevron
@@ -175,7 +201,6 @@ final class SettingMenuRow: UIControl {
private func setupUI() { private func setupUI() {
titleLabel.font = .systemFont(ofSize: 14) titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
valueLabel.font = .systemFont(ofSize: 14) valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary valueLabel.textColor = AppColor.textPrimary
@@ -22,8 +22,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
private let freeCountField = UITextField() private let freeCountField = UITextField()
private let singlePriceField = UITextField() private let singlePriceField = UITextField()
private let packagePriceField = 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 cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system)
private var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) { init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
self.viewModel = viewModel self.viewModel = viewModel
@@ -31,8 +37,16 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet modalPresentationStyle = .pageSheet
if let sheetPresentationController { 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.prefersGrabberVisible = false
sheetPresentationController.preferredCornerRadius = AppRadius.xl
} }
} }
@@ -62,6 +76,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad) configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad) configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad) configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
configureAutoRetouchSection()
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary) configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white) configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
@@ -112,6 +127,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
[freeCountField, singlePriceField, packagePriceField].forEach { [freeCountField, singlePriceField, packagePriceField].forEach {
$0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged) $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) { 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: false, field: freeCountField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField)) 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 { private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
@@ -188,6 +244,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
freeCount: freeCountField.text ?? "", freeCount: freeCountField.text ?? "",
singlePrice: singlePriceField.text ?? "", singlePrice: singlePriceField.text ?? "",
packagePrice: packagePriceField.text ?? "", packagePrice: packagePriceField.text ?? "",
autoRetouchConfiguration: autoRetouchConfiguration,
order: nil, order: nil,
api: api 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 { private func sanitizeMoneyInput(_ value: String) -> String {
var result = "" var result = ""
var hasDot = false var hasDot = false
@@ -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<Int, Item>!
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<Int, Item>(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<Int, Item>()
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 "高饱和浓郁油画质感"
}
}
}
@@ -40,7 +40,7 @@ final class WiredCameraTransferViewController: BaseViewController {
private let refreshButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system)
private let helpLabel = UILabel() private let helpLabel = UILabel()
private let chipsStack = UIStackView() private let chipsStack = UIStackView()
private let retouchButton = WiredTransferSettingChipButton() private let retouchButton = WiredTransferSettingChipButton(showsChevron: true)
private let formatButton = WiredTransferSettingChipButton() private let formatButton = WiredTransferSettingChipButton()
private let modeButton = WiredTransferSettingChipButton(showsChevron: true) private let modeButton = WiredTransferSettingChipButton(showsChevron: true)
private let settingsStatsDivider = UIView() private let settingsStatsDivider = UIView()
@@ -161,7 +161,6 @@ final class WiredCameraTransferViewController: BaseViewController {
[retouchButton, formatButton, modeButton].forEach { [retouchButton, formatButton, modeButton].forEach {
chipsStack.addArrangedSubview($0) chipsStack.addArrangedSubview($0)
} }
retouchButton.isUserInteractionEnabled = false
formatButton.isUserInteractionEnabled = false formatButton.isUserInteractionEnabled = false
settingsStatsDivider.backgroundColor = AppColor.border settingsStatsDivider.backgroundColor = AppColor.border
@@ -387,6 +386,7 @@ final class WiredCameraTransferViewController: BaseViewController {
Task { @MainActor in self?.showToast(message) } Task { @MainActor in self?.showToast(message) }
} }
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
retouchButton.addTarget(self, action: #selector(retouchTapped), for: .touchUpInside)
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside) batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside) historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside)
albumImportButton.addTarget(self, action: #selector(albumImportTapped), 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) let selected = viewModel.selectedPhotoIds.contains(item.id)
cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected) cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected)
cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) } 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) } cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) }
} }
@@ -711,6 +712,17 @@ final class WiredCameraTransferViewController: BaseViewController {
viewModel.refreshCameraFiles() 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() { @objc private func batchTapped() {
viewModel.onBatchUploadButtonClick() viewModel.onBatchUploadButtonClick()
} }
@@ -1126,6 +1138,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
private let selectionIconView = UIImageView() private let selectionIconView = UIImageView()
private let imageView = UIImageView() private let imageView = UIImageView()
private let retouchBadgeLabel = UILabel()
private let statusLabel = UILabel() private let statusLabel = UILabel()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let sizeLabel = UILabel() private let sizeLabel = UILabel()
@@ -1134,6 +1147,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
private let separatorView = UIView() private let separatorView = UIView()
var onRetry: (() -> Void)? var onRetry: (() -> Void)?
var onRetryRetouch: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
override init(frame: CGRect) { override init(frame: CGRect) {
@@ -1151,6 +1165,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
imageView.kf.cancelDownloadTask() imageView.kf.cancelDownloadTask()
imageView.image = nil imageView.image = nil
onRetry = nil onRetry = nil
onRetryRetouch = nil
onDelete = nil onDelete = nil
menuButton.menu = nil menuButton.menu = nil
} }
@@ -1178,16 +1193,21 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
titleLabel.alpha = rowAlpha titleLabel.alpha = rowAlpha
sizeLabel.text = item.fileSizeText sizeLabel.text = item.fileSizeText
sizeLabel.alpha = rowAlpha sizeLabel.alpha = rowAlpha
statusLabel.text = statusText(item.status) statusLabel.text = statusText(item)
statusLabel.textColor = statusTextColor(item.status) statusLabel.textColor = statusTextColor(item)
statusLabel.backgroundColor = statusBackgroundColor(item.status) statusLabel.backgroundColor = statusBackgroundColor(item)
progressView.isHidden = item.status != .uploading && item.status != .transferring retouchBadgeLabel.isHidden = item.autoRetouchState != .completed
progressView.progress = Float(item.progress) / 100.0 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.isHidden = !selectionMode
selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5) selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5)
menuButton.isHidden = selectionMode 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) updateImageConstraints(selectionMode: selectionMode)
} }
@@ -1197,6 +1217,14 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
imageView.layer.cornerRadius = 6 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.font = .systemFont(ofSize: 9)
statusLabel.textAlignment = .center statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 3 statusLabel.layer.cornerRadius = 3
@@ -1219,6 +1247,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
contentView.addSubview(selectionIconView) contentView.addSubview(selectionIconView)
contentView.addSubview(imageView) contentView.addSubview(imageView)
contentView.addSubview(retouchBadgeLabel)
contentView.addSubview(titleLabel) contentView.addSubview(titleLabel)
contentView.addSubview(statusLabel) contentView.addSubview(statusLabel)
contentView.addSubview(sizeLabel) contentView.addSubview(sizeLabel)
@@ -1232,6 +1261,11 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
make.size.equalTo(20) make.size.equalTo(20)
} }
updateImageConstraints(selectionMode: true) 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 titleLabel.snp.makeConstraints { make in
make.top.equalTo(imageView).offset(1) make.top.equalTo(imageView).offset(1)
make.leading.equalTo(imageView.snp.trailing).offset(8) 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 let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in
self?.onRetry?() 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 let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
self?.onDelete?() self?.onDelete?()
} }
return UIMenu(children: [retry, delete]) return UIMenu(children: [retry, retryRetouch, delete])
} }
private func updateImageConstraints(selectionMode: Bool) { private func updateImageConstraints(selectionMode: Bool) {
@@ -1290,8 +1328,13 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
} }
} }
private func statusText(_ status: TravelAlbumOTGUploadStatus) -> String { private func statusText(_ item: TravelAlbumOTGPhotoItem) -> String {
switch status { switch item.autoRetouchState {
case .processing: return "修图中"
case .failed: return "修图失败"
case .none, .completed: break
}
switch item.status {
case .pending: return "待上传" case .pending: return "待上传"
case .transferring: return "传输中" case .transferring: return "传输中"
case .uploading: return "上传中" case .uploading: return "上传中"
@@ -1300,8 +1343,10 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
} }
} }
private func statusTextColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { private func statusTextColor(_ item: TravelAlbumOTGPhotoItem) -> UIColor {
switch status { if item.autoRetouchState == .processing { return AppColor.primary }
if item.autoRetouchState == .failed { return AppColor.danger }
switch item.status {
case .pending: return AppColor.textSecondary case .pending: return AppColor.textSecondary
case .transferring, .uploading: return AppColor.primary case .transferring, .uploading: return AppColor.primary
case .uploaded: return UIColor(hex: 0x16A34A) case .uploaded: return UIColor(hex: 0x16A34A)
@@ -1309,8 +1354,10 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
} }
} }
private func statusBackgroundColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { private func statusBackgroundColor(_ item: TravelAlbumOTGPhotoItem) -> UIColor {
switch status { 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: case .pending:
return AppColor.pageBackground return AppColor.pageBackground
case .transferring, .uploading: case .transferring, .uploading:
@@ -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()
)
}
}
+87
View File
@@ -200,4 +200,91 @@ final class LoginViewModelTests: XCTestCase {
XCTFail("意外错误: \(error)") 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",
])
}
} }
@@ -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)
}
}
@@ -60,12 +60,44 @@ final class TravelAlbumOTGStorageTests: XCTestCase {
XCTAssertEqual(store.absoluteURL(for: loaded.first?.localPath ?? "", albumId: 33), file) 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 { func testRemoveAndClearAlbumDeleteFiles() throws {
let root = try makeTempDirectory() let root = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: root) } defer { try? FileManager.default.removeItem(at: root) }
let store = makeStore(root: root) let store = makeStore(root: root)
let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33) let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33)
try Data([1]).write(to: file) try Data([1]).write(to: file)
let retouchedFile = try store.writeRetouchedImage(Data([2]), filename: "A.JPG", albumId: 33)
let record = TravelAlbumOTGPhotoRecord( let record = TravelAlbumOTGPhotoRecord(
id: "1", id: "1",
fileName: "A.JPG", fileName: "A.JPG",
@@ -74,13 +106,17 @@ final class TravelAlbumOTGStorageTests: XCTestCase {
fileSizeBytes: 1, fileSizeBytes: 1,
status: .pending, status: .pending,
albumId: 33, albumId: 33,
userId: "u1" userId: "u1",
autoRetouchState: .completed,
autoRetouchTemplateId: "brocade",
retouchedPath: retouchedFile.path
) )
store.save([record], albumId: 33) store.save([record], albumId: 33)
store.remove(albumId: 33, photoId: "1") store.remove(albumId: 33, photoId: "1")
XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: file.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: retouchedFile.path))
XCTAssertTrue(store.load(albumId: 33).isEmpty) XCTAssertTrue(store.load(albumId: 33).isEmpty)
let another = try store.uniqueOriginalFileURL(filename: "B.JPG", albumId: 33) let another = try store.uniqueOriginalFileURL(filename: "B.JPG", albumId: 33)
+234 -4
View File
@@ -59,6 +59,55 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.isCreateSheetVisible) 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 { func testOrderAlreadyBoundReloadsOrders() async {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
api.createError = APIError.serverCode(100001, "订单已关联") 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 @MainActor
/// 旅拍相册详情 ViewModel 测试。 /// 旅拍相册详情 ViewModel 测试。
final class TravelAlbumDetailViewModelTests: XCTestCase { final class TravelAlbumDetailViewModelTests: XCTestCase {
@@ -785,6 +859,134 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertTrue(records.allSatisfy { UUID(uuidString: $0.clientPhotoId) != nil }) 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 { func testStartSynchronizesPersistedUploadStatusesFromServerClientPhotoIds() async {
let context = makeOTGTestContext() let context = makeOTGTestContext()
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
@@ -1013,9 +1215,13 @@ private func makeWiredViewModel(
manager: MockWiredCameraConnectionManager, manager: MockWiredCameraConnectionManager,
uploader: MockTravelAlbumOTGUploader? = nil, uploader: MockTravelAlbumOTGUploader? = nil,
api: TravelAlbumMockAPI? = nil, api: TravelAlbumMockAPI? = nil,
userDefaults: UserDefaults = makeOTGTestDefaults() userDefaults: UserDefaults = makeOTGTestDefaults(),
autoRetouchConfigurationStore: (any TravelAlbumAutoRetouchConfigurationStoring)? = nil,
autoRetouchProcessor: (any TravelAlbumAutoRetouchProcessing)? = nil
) -> WiredCameraTransferViewModel { ) -> WiredCameraTransferViewModel {
prepareAppStoreForOTGTests() prepareAppStoreForOTGTests()
let configurationStore = autoRetouchConfigurationStore
?? TravelAlbumAutoRetouchConfigurationStore(userDefaults: userDefaults)
return WiredCameraTransferViewModel( return WiredCameraTransferViewModel(
albumId: albumId, albumId: albumId,
albumTitle: "相册", albumTitle: "相册",
@@ -1024,7 +1230,9 @@ private func makeWiredViewModel(
storage: context.store, storage: context.store,
uploader: uploader ?? MockTravelAlbumOTGUploader(), uploader: uploader ?? MockTravelAlbumOTGUploader(),
api: api ?? TravelAlbumMockAPI(), api: api ?? TravelAlbumMockAPI(),
userDefaults: userDefaults userDefaults: userDefaults,
autoRetouchConfigurationStore: configurationStore,
autoRetouchProcessor: autoRetouchProcessor
) )
} }
@@ -1054,7 +1262,9 @@ private func makeOTGPhotoItem(
progress: 0, progress: 0,
errorMessage: nil, errorMessage: nil,
localPath: "originals/\(id).JPG", 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 final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
private(set) var uploadCallCount = 0 private(set) var uploadCallCount = 0
private(set) var uploadedRecordIds: [String] = [] private(set) var uploadedRecordIds: [String] = []
private let materialID: Int
init(materialID: Int = 1) {
self.materialID = materialID
}
func upload( func upload(
record: TravelAlbumOTGPhotoRecord, record: TravelAlbumOTGPhotoRecord,
@@ -1233,7 +1448,22 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
) async throws -> TravelAlbumMaterial { ) async throws -> TravelAlbumMaterial {
uploadCallCount += 1 uploadCallCount += 1
uploadedRecordIds.append(record.id) 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=")!
} }
} }