Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10249776ad | ||
|
|
9b65913bc4 | ||
|
|
b8343ad9eb | ||
|
|
4444d328df | ||
|
|
e529bb5942 | ||
|
|
396597a160 | ||
|
|
d04641b623 | ||
|
|
9fce6ef713 | ||
|
|
4351c26e74 | ||
|
|
825a0448cc | ||
|
|
dca4bd5a20 | ||
|
|
eb57666033 | ||
|
|
2c4123214d | ||
|
|
8cd6c0aac8 | ||
|
|
1757ba5e36 | ||
|
|
c34c4e1923 | ||
|
|
b127495e52 | ||
|
|
76aa7bb4aa | ||
|
|
8e444f7434 | ||
|
|
83afcb6deb | ||
|
|
30bfe0313f | ||
|
|
0f3b26991e | ||
|
|
e190b5958b | ||
|
|
6e2899dbfd | ||
|
|
48b6b9be22 | ||
|
|
202b0f5e41 |
|
After Width: | Height: | Size: 974 KiB |
|
After Width: | Height: | Size: 961 KiB |
|
After Width: | Height: | Size: 905 KiB |
@@ -0,0 +1,53 @@
|
|||||||
|
# 9.7 线下收款接口调整
|
||||||
|
|
||||||
|
本次仅调整以下两个接口,路径保持不变:
|
||||||
|
|
||||||
|
- `GET /api/yf-handset-app/photog/offline-pay-collect/statistics`
|
||||||
|
- `GET /api/yf-handset-app/photog/offline-pay-collect/details`
|
||||||
|
|
||||||
|
## 1. statistics
|
||||||
|
|
||||||
|
请求:`scenic_id`
|
||||||
|
|
||||||
|
在现有 `pending` 中增加所有未补缴日期,按日期升序:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"dates": [
|
||||||
|
{ "date": "2026-08-23", "unpaid_amount": "230.00", "unpaid_count": 2 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 包含今天及历史所有仍有待补缴数据的日期。
|
||||||
|
- `date_count`、待补缴总金额和总笔数必须与 `dates` 汇总一致。
|
||||||
|
- `today.date` 按 `Asia/Shanghai` 返回当前营业日,客户端以此限制未来日期。
|
||||||
|
|
||||||
|
## 2. details
|
||||||
|
|
||||||
|
请求改为:`scenic_id + date`,删除 `page/page_size`。
|
||||||
|
|
||||||
|
`data` 直接返回单日对象:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"total_amount": "350.00",
|
||||||
|
"collect_count": 3,
|
||||||
|
"paid_amount": "100.00",
|
||||||
|
"paid_count": 1,
|
||||||
|
"unpaid_amount": "250.00",
|
||||||
|
"unpaid_count": 2,
|
||||||
|
"status": 0,
|
||||||
|
"status_text": "本日未结清",
|
||||||
|
"collects": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 无数据日期也返回成功、零值汇总和空 `collects`。
|
||||||
|
- 明细按登记时间倒序,再按 `collect_no` 保证稳定顺序。
|
||||||
|
|
||||||
|
## 不调整的接口
|
||||||
|
|
||||||
|
- `POST /register`:继续只接收 `scenic_id/amount/pay_method`,响应保持现状。
|
||||||
|
- `POST /supplement`:继续只接收 `scenic_id/date`,响应保持现状。
|
||||||
|
|
||||||
|
客户端不会向这两个接口传入 `request_id`、补缴金额快照或补缴笔数快照,也不依赖新增错误码。
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# AI 重新修图接口优化需求
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
图片预览页面支持用户在“原图”Tab 上重新选择修图模板。
|
||||||
|
|
||||||
|
用户可能只生成过一种关联图片,例如之前仅生成了“原图精修”,尚未生成“氛围感”图片。此时用户重新修图时,可以同时选择:
|
||||||
|
|
||||||
|
- 原图精修模板
|
||||||
|
- 氛围感修图模板
|
||||||
|
|
||||||
|
客户端会调用现有 `ai-reretouch` 接口,并通过 `type = 3` 提交两个模板,希望一次创建两个修图输出任务。
|
||||||
|
|
||||||
|
## 2. 问题复现
|
||||||
|
|
||||||
|
### 前置状态
|
||||||
|
|
||||||
|
- 原图素材 ID:`702`
|
||||||
|
- 原 AI 修图批次 ID:`61`
|
||||||
|
- 已存在并完成:原图精修
|
||||||
|
- 从未生成:氛围感图片
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/yf-handset-app/photog/travel-album/ai-reretouch
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ai_retouch_batch_id": 61,
|
||||||
|
"id": 702,
|
||||||
|
"type": 3,
|
||||||
|
"refined_template_id": 2,
|
||||||
|
"atmosphere_template_id": 14
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段含义:
|
||||||
|
|
||||||
|
| 字段 | 值 | 说明 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `ai_retouch_batch_id` | `61` | 原 AI 修图批次 ID |
|
||||||
|
| `id` | `702` | 原图素材 ID |
|
||||||
|
| `type` | `3` | 同时处理精修和氛围感 |
|
||||||
|
| `refined_template_id` | `2` | 本次选择的精修模板 |
|
||||||
|
| `atmosphere_template_id` | `14` | 本次选择的氛围感模板 |
|
||||||
|
|
||||||
|
### 当前响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100099,
|
||||||
|
"data": [],
|
||||||
|
"msg": "仅已完成修图的照片可重新修图",
|
||||||
|
"time": "2026-08-14 17:41:17"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 当前问题
|
||||||
|
|
||||||
|
从响应判断,后端可能在 `type = 3` 时要求精修图和氛围感图都已经存在且完成,才允许重新修图。
|
||||||
|
|
||||||
|
但本场景中:
|
||||||
|
|
||||||
|
- 精修图已经存在,本次需要重新生成,并在成功后覆盖旧精修图。
|
||||||
|
- 氛围感图从未生成,本次需要创建新的关联图片。
|
||||||
|
|
||||||
|
由于氛围感图历史上不存在,接口拒绝了整个请求,导致客户端无法通过一次请求同时完成“覆盖已有精修图”和“新增氛围感图”。
|
||||||
|
|
||||||
|
客户端当前请求已经正确传递 `type = 3` 和两个模板 ID,无需修改请求结构。
|
||||||
|
|
||||||
|
## 4. 期望接口行为
|
||||||
|
|
||||||
|
建议将 `ai-reretouch` 调整为按输出类型分别执行的 **upsert(存在则覆盖,不存在则新增)** 语义。
|
||||||
|
|
||||||
|
对于本次请求:
|
||||||
|
|
||||||
|
### 原图精修
|
||||||
|
|
||||||
|
- 请求传入了 `refined_template_id`。
|
||||||
|
- 旧精修图已经存在。
|
||||||
|
- 后端创建新的精修任务。
|
||||||
|
- 新任务成功后覆盖旧精修图。
|
||||||
|
- 新任务处理中或失败时保留旧精修图。
|
||||||
|
|
||||||
|
### 氛围感修图
|
||||||
|
|
||||||
|
- 请求传入了 `atmosphere_template_id`。
|
||||||
|
- 旧氛围感图不存在。
|
||||||
|
- 后端创建新的氛围感任务。
|
||||||
|
- 任务成功后,将结果保存为原图的新关联图片。
|
||||||
|
|
||||||
|
### 整体任务
|
||||||
|
|
||||||
|
- 一次请求创建两个输出任务:`refined` 和 `atmosphere`。
|
||||||
|
- 按两个实际创建的输出预占 2 张修图额度。
|
||||||
|
- 两个输出分别执行、分别记录状态、分别结算额度。
|
||||||
|
- 单个输出失败不应影响另一个已经成功的输出。
|
||||||
|
|
||||||
|
## 5. 建议的后端处理规则
|
||||||
|
|
||||||
|
### 5.1 基础校验
|
||||||
|
|
||||||
|
建议校验:
|
||||||
|
|
||||||
|
- 原图素材存在。
|
||||||
|
- 原图素材属于指定相册或批次。
|
||||||
|
- 原图当前状态允许提交 AI 修图任务。
|
||||||
|
- 请求中至少传入一个与 `type` 匹配的有效模板 ID。
|
||||||
|
- 用户剩余额度满足本次实际创建的输出数量。
|
||||||
|
|
||||||
|
不建议校验:
|
||||||
|
|
||||||
|
- `type = 3` 时要求精修和氛围感历史结果都必须存在。
|
||||||
|
- 某一种关联图从未生成时直接拒绝整个请求。
|
||||||
|
|
||||||
|
### 5.2 按模板字段创建任务
|
||||||
|
|
||||||
|
后端根据本次实际传入的模板字段创建对应任务:
|
||||||
|
|
||||||
|
| 请求情况 | 期望处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 只传 `refined_template_id` | 只创建精修任务 |
|
||||||
|
| 只传 `atmosphere_template_id` | 只创建氛围感任务 |
|
||||||
|
| 两个模板 ID 都传 | 同时创建精修和氛围感任务 |
|
||||||
|
| 某个模板 ID 未传 | 不处理、不覆盖该类型的已有结果 |
|
||||||
|
|
||||||
|
### 5.3 按输出类型处理已有结果
|
||||||
|
|
||||||
|
对于每个实际提交的输出类型,分别判断:
|
||||||
|
|
||||||
|
| 历史状态 | 建议处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 已存在成功结果 | 创建重修任务,新结果成功后替换旧结果 |
|
||||||
|
| 从未生成 | 创建新任务,成功后新增关联图片 |
|
||||||
|
| 历史任务失败或已取消 | 允许重新创建任务 |
|
||||||
|
| 当前已有处理中或排队任务 | 拒绝该类型重复提交,或通过幂等机制复用已有任务 |
|
||||||
|
|
||||||
|
### 5.4 替换时机
|
||||||
|
|
||||||
|
继续采用现有 `replace_on_success` 语义:
|
||||||
|
|
||||||
|
- 新结果成功后,才替换同类型旧结果。
|
||||||
|
- 新任务排队、处理中或失败时,保留旧结果。
|
||||||
|
- 精修和氛围感分别替换,互不影响。
|
||||||
|
|
||||||
|
## 6. `type` 参数建议语义
|
||||||
|
|
||||||
|
保持现有请求类型和接口路径不变:
|
||||||
|
|
||||||
|
| `type` | 语义 | 模板规则 |
|
||||||
|
| ---: | --- | --- |
|
||||||
|
| `1` | 只处理精修 | 必须传 `refined_template_id`,忽略氛围感模板 |
|
||||||
|
| `2` | 只处理氛围感 | 必须传 `atmosphere_template_id`,忽略精修模板 |
|
||||||
|
| `3` | 同时处理精修和氛围感 | 根据实际传入的两个模板分别创建任务 |
|
||||||
|
|
||||||
|
对于 `type = 3`:
|
||||||
|
|
||||||
|
- 必须传 `refined_template_id`。
|
||||||
|
- `atmosphere_template_id` 可以选传。
|
||||||
|
- 传入氛围感模板时创建氛围感任务。
|
||||||
|
- 未传氛围感模板时,只重修精修图,并保留已有氛围感图。
|
||||||
|
|
||||||
|
## 7. 建议处理伪代码
|
||||||
|
|
||||||
|
```text
|
||||||
|
validateOriginalMaterial(request.id)
|
||||||
|
validateBatch(request.ai_retouch_batch_id)
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
|
||||||
|
if request.refined_template_id is not null:
|
||||||
|
outputs.add(
|
||||||
|
createTask(
|
||||||
|
outputType = refined,
|
||||||
|
templateId = request.refined_template_id,
|
||||||
|
mode = existingRefinedResult ? replace_on_success : create
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.atmosphere_template_id is not null:
|
||||||
|
outputs.add(
|
||||||
|
createTask(
|
||||||
|
outputType = atmosphere,
|
||||||
|
templateId = request.atmosphere_template_id,
|
||||||
|
mode = existingAtmosphereResult ? replace_on_success : create
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if outputs is empty:
|
||||||
|
return invalid_template_error
|
||||||
|
|
||||||
|
reserveQuota(outputs.count)
|
||||||
|
submitTasks(outputs)
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:实际是否存在旧结果,应按照各自的 `output_type` 独立查询,不能用“两个类型都已完成”作为 `type = 3` 的整体前置条件。
|
||||||
|
|
||||||
|
## 8. 期望响应
|
||||||
|
|
||||||
|
接口成功响应建议继续返回任务批次信息,并明确本次创建的输出类型。例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 61,
|
||||||
|
"source_material_id": 702,
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"mode": "replace_on_success",
|
||||||
|
"status": "queued"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"mode": "create",
|
||||||
|
"status": "queued"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reserved_units": 2
|
||||||
|
},
|
||||||
|
"msg": "AI修图任务已提交"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应结构可沿用现有实现,不强制增加上述字段;关键要求是一次请求能够成功创建两个对应的修图任务。
|
||||||
|
|
||||||
|
## 9. 验收用例
|
||||||
|
|
||||||
|
### 用例一:只有旧精修图,同时提交两个模板
|
||||||
|
|
||||||
|
- 历史状态:精修已完成,氛围感不存在。
|
||||||
|
- 请求:`type = 3`,同时传精修和氛围感模板。
|
||||||
|
- 期望:创建两个任务;精修成功后覆盖,氛围感成功后新增。
|
||||||
|
|
||||||
|
### 用例二:只有旧氛围感图,同时提交两个模板
|
||||||
|
|
||||||
|
- 历史状态:精修不存在,氛围感已完成。
|
||||||
|
- 请求:`type = 3`,同时传精修和氛围感模板。
|
||||||
|
- 期望:创建两个任务;精修成功后新增,氛围感成功后覆盖。
|
||||||
|
|
||||||
|
### 用例三:两种旧结果都存在
|
||||||
|
|
||||||
|
- 历史状态:精修、氛围感都已完成。
|
||||||
|
- 请求:`type = 3`,同时传两个模板。
|
||||||
|
- 期望:创建两个任务,分别在成功后覆盖各自旧结果。
|
||||||
|
|
||||||
|
### 用例四:原图 Tab 只选择精修模板
|
||||||
|
|
||||||
|
- 历史状态:精修、氛围感均可能存在。
|
||||||
|
- 请求:`type = 3`,只传 `refined_template_id`。
|
||||||
|
- 期望:只创建精修任务;不处理、不删除、不覆盖旧氛围感图;预占 1 张额度。
|
||||||
|
|
||||||
|
### 用例五:精修后 Tab 重新修图
|
||||||
|
|
||||||
|
- 请求:`type = 1`,只传 `refined_template_id`。
|
||||||
|
- 期望:只创建精修任务,新精修成功后覆盖旧精修图。
|
||||||
|
|
||||||
|
### 用例六:氛围感 Tab 重新修图
|
||||||
|
|
||||||
|
- 请求:`type = 2`,只传 `atmosphere_template_id`。
|
||||||
|
- 期望:只创建氛围感任务,新氛围感成功后覆盖旧氛围感图。
|
||||||
|
|
||||||
|
### 用例七:其中一个输出失败
|
||||||
|
|
||||||
|
- 请求创建精修和氛围感两个任务。
|
||||||
|
- 精修成功,氛围感失败。
|
||||||
|
- 期望:保留成功的新精修结果;氛围感旧图存在时继续保留旧图,不存在时不生成关联图;额度分别结算。
|
||||||
|
|
||||||
|
### 用例八:防止重复提交
|
||||||
|
|
||||||
|
- 同一原图、同一输出类型已经存在排队中或处理中的任务。
|
||||||
|
- 用户再次提交相同任务。
|
||||||
|
- 期望:通过幂等机制返回原任务,或仅拒绝重复的输出类型,避免重复扣减额度和重复生成结果。
|
||||||
|
|
||||||
|
## 10. 总结
|
||||||
|
|
||||||
|
希望 `ai-reretouch` 支持以下统一语义:
|
||||||
|
|
||||||
|
> 对本次传入模板对应的每种输出独立处理:已有结果则在新任务成功后覆盖,没有结果则新增;未传模板的类型不处理。不要因为某一种关联图历史上从未生成,就拒绝整个重新修图请求。
|
||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,54 @@
|
|||||||
|
# 工作周报(2026.08.10—2026.08.14)
|
||||||
|
|
||||||
|
## 本周概况
|
||||||
|
|
||||||
|
本周围绕旅拍相册和 AI 修图功能开展开发,共完成 **23 次提交**,涉及 **37 个文件**,代码变更约新增 **11,495 行**、删除 **1,120 行**。
|
||||||
|
|
||||||
|
## 本周完成
|
||||||
|
|
||||||
|
### 1. 完善旅拍相册图片预览
|
||||||
|
|
||||||
|
- 新增 iOS 端全屏图片预览。
|
||||||
|
- 完善预览工具栏、图片索引胶囊、分段控件及关闭按钮样式。
|
||||||
|
- 支持原图与 AI 修图版本切换,并修复切换时出现黑屏的问题。
|
||||||
|
- 增加相册管理页下拉刷新。
|
||||||
|
- 接入旅拍相册批量删除接口。
|
||||||
|
|
||||||
|
### 2. 打通 AI 修图业务流程
|
||||||
|
|
||||||
|
- 从相册及图片预览页接入 AI 修图入口。
|
||||||
|
- 完成图片选择、模板选择、模板预览和任务提交流程。
|
||||||
|
- 优化修图标签、页面布局及提交反馈。
|
||||||
|
- 新增 AI 修图前后效果对比功能。
|
||||||
|
|
||||||
|
### 3. 新增 AI 修图任务中心
|
||||||
|
|
||||||
|
- 完成任务列表及任务详情页面。
|
||||||
|
- 接入 AI 修图任务相关接口和状态模型。
|
||||||
|
- 支持处理中、成功、失败等任务状态展示。
|
||||||
|
- 打通消息中心、推送通知与任务详情跳转链路。
|
||||||
|
|
||||||
|
### 4. 优化相册数据同步
|
||||||
|
|
||||||
|
- 修复从 OTG 页面返回后旅拍相册未及时刷新的问题。
|
||||||
|
- 优化修图提交后的页面反馈及状态更新。
|
||||||
|
- 完善异常状态和页面切换场景下的数据刷新逻辑。
|
||||||
|
|
||||||
|
### 5. 补充文档与测试
|
||||||
|
|
||||||
|
- 补充 AI 修图需求、接口和交互设计文档。
|
||||||
|
- 完善相册 API、数据模型、ViewModel、消息中心及推送相关单元测试。
|
||||||
|
- 增加图片预览、任务中心和修图流程的回归测试覆盖。
|
||||||
|
|
||||||
|
## 风险与待验证项
|
||||||
|
|
||||||
|
- AI 修图任务中心涉及接口、推送、消息中心和页面跳转,需要结合测试环境继续进行全链路验证。
|
||||||
|
- 需重点回归任务处理中、成功、失败及网络异常等状态。
|
||||||
|
- OTG 返回刷新、批量删除和图片版本切换需要在真机及大相册场景下继续验证。
|
||||||
|
|
||||||
|
## 下周计划
|
||||||
|
|
||||||
|
- 联调 AI 修图任务全生命周期及推送跳转。
|
||||||
|
- 完成旅拍相册与 AI 修图功能的真机回归。
|
||||||
|
- 优化大图加载、任务刷新和弱网场景下的交互体验。
|
||||||
|
- 根据测试反馈修复问题,推进功能验收与发布准备。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 工作周报(2026.08.17—2026.08.21)
|
||||||
|
|
||||||
|
## 本周概况
|
||||||
|
|
||||||
|
本周围绕旅拍相册和 AI 重修流程开展功能完善与体验优化,共完成 **7 次提交**,涉及 **20 个文件**,代码及文档变更约新增 **1,101 行**、删除 **240 行**;同时完成版本号升级,为 **1.3.1** 版本发布做准备。
|
||||||
|
|
||||||
|
## 本周完成
|
||||||
|
|
||||||
|
### 1. 完善 AI 重修业务规则
|
||||||
|
|
||||||
|
- 根据是否已有 AI 结果区分首次修图与重新修图流程。
|
||||||
|
- 已有 AI 结果时,统一支持精修和氛围感模板按需选择,并校验至少选择一种模板。
|
||||||
|
- 优化单图、多图场景下的模板必选规则、生成数量与额度提示。
|
||||||
|
- 对齐 AI 重修请求参数、模板范围及结果覆盖规则,确保未选结果类型保持原有版本不变。
|
||||||
|
|
||||||
|
### 2. 优化相册素材状态与删除保护
|
||||||
|
|
||||||
|
- 将购买状态和 AI 修图状态拆分展示,避免不同业务状态互相覆盖。
|
||||||
|
- 扩展素材选择能力,支持跨相册 Tab 进入选择模式。
|
||||||
|
- 增加已购素材删除保护,批量删除时仅提交未购买素材。
|
||||||
|
- 优化删除确认提示,明确已选、已购及实际可删除数量;全部为已购素材时阻止删除并给出反馈。
|
||||||
|
|
||||||
|
### 3. 改进任务详情与图片预览体验
|
||||||
|
|
||||||
|
- 优化 AI 修图任务详情中的结果类型标签、状态区域和失败信息布局,提升信息辨识度。
|
||||||
|
- 调整任务目标标题、模板和操作按钮布局,改善长文本与不同状态下的适配表现。
|
||||||
|
- 完善图片预览初始版本选择,当前版本不可用时自动回退到有效图片。
|
||||||
|
- 新增高对比度延迟加载指示器,减少快速加载时的闪烁,并优化未知文件大小的展示。
|
||||||
|
|
||||||
|
### 4. 完善相册刷新能力
|
||||||
|
|
||||||
|
- 为旅拍相册入口增加下拉刷新。
|
||||||
|
- 补充刷新状态管理,确保成功、失败及取消场景均能正确结束刷新动画。
|
||||||
|
|
||||||
|
### 5. 补充文档与测试覆盖
|
||||||
|
|
||||||
|
- 新增 AI 重新修图接口优化需求文档,并同步更新 AI 修图需求及接口说明。
|
||||||
|
- 完善 AI 修图模板、重修请求、相册素材模型、删除保护及页面布局相关单元测试。
|
||||||
|
- 增加首次修图、已有结果重修、模板选填和异常状态等关键场景的回归覆盖。
|
||||||
|
|
||||||
|
### 6. 推进版本发布准备
|
||||||
|
|
||||||
|
- 将项目版本号升级至 **1.3.1**。
|
||||||
|
- 整理本轮 AI 重修规则、交互调整及测试范围,为后续联调验收提供依据。
|
||||||
|
|
||||||
|
## 进行中
|
||||||
|
|
||||||
|
- 补充 AI 重修模板占位图的多倍图资源配置。
|
||||||
|
- 继续验证 AI 重修、批量删除及图片预览在真机和真实数据下的完整表现。
|
||||||
|
|
||||||
|
## 风险与待验证项
|
||||||
|
|
||||||
|
- AI 重修规则涉及模板范围、额度计算和结果覆盖,需要结合测试环境验证服务端行为是否与最新规则一致。
|
||||||
|
- 已购与未购素材混合选择时,需要重点回归删除参数、数量提示及刷新后的数据一致性。
|
||||||
|
- 图片预览需继续验证弱网、大图及不同 AI 结果组合下的加载、回退和切换体验。
|
||||||
|
- 本周测试代码已补充,仍需在已连接的 iPhone 真机上完成完整测试回归并记录结果。
|
||||||
|
|
||||||
|
## 下周计划
|
||||||
|
|
||||||
|
- 完成 1.3.1 版本真机测试、问题修复及发布验收。
|
||||||
|
- 联调 AI 重修模板选择、额度扣减、任务生成与结果覆盖全链路。
|
||||||
|
- 回归相册下拉刷新、素材状态展示和已购素材删除保护。
|
||||||
|
- 补齐 AI 重修模板多倍图资源,优化弱网和大图加载体验。
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 工作周报(2026.08.24—2026.08.28)
|
||||||
|
|
||||||
|
## 本周概况
|
||||||
|
|
||||||
|
本周围绕随心瞰商家版 iOS 的线下收款、旅拍相册自动修图和门店身份注销开展开发与优化。已完成 **6 次提交**;门店身份注销相关改动仍在工作区,尚未提交,完整终态验收待补齐。
|
||||||
|
|
||||||
|
## 本周完成
|
||||||
|
|
||||||
|
### 1. 完成线下收款登记与日清功能开发
|
||||||
|
|
||||||
|
- 新增线下收款首页卡片、收款登记及日清页面,接入统计、登记、按日明细和补缴接口。
|
||||||
|
- 支持按日期查看收款与待补缴情况,完善金额校验及快速切换日期时的旧请求结果隔离。
|
||||||
|
- 优化周/月日历切换、翻页、选中态及未来日期限制,调整收款方式图标和明细展示。
|
||||||
|
- 补充接口调整说明及金额、日历边界、请求参数和页面相关测试。
|
||||||
|
|
||||||
|
### 2. 新增相册自动修图与 OTG 状态预览
|
||||||
|
|
||||||
|
- 支持相册级自动修图配置、精修模板选择和传输模式选择,上传完成后自动提交修图任务。
|
||||||
|
- 在 OTG 页面区分上传与修图状态,增加修图状态角标、精修结果预览及失败重试。
|
||||||
|
- 优化自动修图设置页,支持同页切换修图方式、三列模板展示和效果对比预览,保留草稿选择及滚动位置。
|
||||||
|
- 补充配置、任务状态和预览交互测试,并完善 AI 重修占位图资源配置。
|
||||||
|
|
||||||
|
## 进行中:门店身份注销
|
||||||
|
|
||||||
|
- 已接入现有门店身份注销接口,完成“确认资产 → 手机验证”两步流程,以及条件查询、短信验证、申请和撤销逻辑;注销范围仅限当前门店身份。
|
||||||
|
- 已实现申请明确成功后退出登录、下次登录取得身份凭证后核验状态,以及受限状态下的业务访问和推送跳转控制。
|
||||||
|
- 完善身份切换与旧响应隔离、异常状态保护、下拉刷新及统一 Loading,移除普通登录和切换身份时无条件出现的注销提示。
|
||||||
|
- 已在测试环境验证零资产确认、短信申请、冷静期查询和主动撤销流程,并整理接入说明及脱敏接口记录;正式注销完成、终审阻断及真实多端场景尚未完成验收。
|
||||||
|
|
||||||
|
## 测试与质量
|
||||||
|
|
||||||
|
- 根据本周已保存的 iPhone 11 真机回归记录,最新全量测试共 **821 项,811 项通过、10 项失败、0 项跳过**,全量尚未全部通过。
|
||||||
|
- 接入记录显示,**98 项注销相关测试全部通过**,失败用例集合与上一轮一致;这些 Mock 测试不替代真实终态验收。
|
||||||
|
- 现存失败涉及部分接口解码、头像裁剪和页面布局断言,需继续排查修复。
|
||||||
|
|
||||||
|
## 风险与待验证项
|
||||||
|
|
||||||
|
- 门店注销仍缺少正式完成、终审阻断的完整接口响应及终态鉴权依据,暂不能按完整功能验收。
|
||||||
|
- 非零资产、确认过期、错误验证码、重复申请及多端状态变化仍需补充真实场景验证。
|
||||||
|
- 线下收款和自动修图需继续进行真实数据联调,重点检查补缴统计一致性、弱网重试及前后台恢复。
|
||||||
|
|
||||||
|
## 下周计划
|
||||||
|
|
||||||
|
- 补齐门店身份注销终态接口契约,推进完整流程与多身份场景验收。
|
||||||
|
- 排查并修复现有 10 项测试失败,完成受影响模块的真机回归。
|
||||||
|
- 联调线下收款登记、日清补缴及自动修图全流程,根据反馈完善交互和异常处理。
|
||||||
|
- 整理未提交改动、测试证据及交付文档,推进本轮功能验收。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
整理依据:本周 Git 提交、当前工作区改动、门店身份注销接入记录及已保存的真机测试摘要;本次周报整理未重新运行测试。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 账号注销后端接口需求(简洁版)
|
||||||
|
|
||||||
|
> **本方案已被替代:** 2026-08-28 确认改用旧接口实现当前门店身份注销,不再实施主账号注销。参见 [门店身份注销接入说明](门店身份注销接入说明.md)。下文仅为历史记录。
|
||||||
|
|
||||||
|
当前 iOS 已有注销页面和本地 Mock 流程,需要后端提供真实能力。以下路径为建议,可复用已有等价接口。
|
||||||
|
|
||||||
|
## 1. 需要的接口
|
||||||
|
|
||||||
|
统一前缀:`/api/app/account-deletion`
|
||||||
|
|
||||||
|
| 接口 | 入参 | 需要返回 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /precheck` 注销核验 | 无,以 Token 识别主账号 | 钱包、作品与相册、项目、云盘资产;脱敏手机号;注销影响说明;是否可注销及阻断原因;核验标识和有效期 |
|
||||||
|
| `POST /send-sms-code` 发送验证码 | 无,发送至主账号绑定手机号 | 验证码会话标识、有效秒数、重发间隔;不返回验证码 |
|
||||||
|
| `POST /submit` 提交申请 | 核验标识、验证码会话及验证码、资产确认项、说明版本、幂等请求 ID | 申请 ID、状态、提交时间、计划注销时间 |
|
||||||
|
| `GET /status` 查询状态 | 无,以有效身份凭证识别主账号 | 当前状态、申请信息、能否取消、服务端时间 |
|
||||||
|
| `POST /cancel` 取消申请 | 申请 ID,使用恢复专用凭证 | 取消结果、新登录临时 Token、当前可选景区/门店身份 |
|
||||||
|
|
||||||
|
沿用现有 `token` 请求头和 `code/msg/data` 响应结构,成功码为 `100000`。错误需区分:注销条件不满足、核验过期、验证码错误/过期/限流、已有申请、超过取消期限、凭证失效。
|
||||||
|
|
||||||
|
## 2. 现有登录与鉴权需要配合
|
||||||
|
|
||||||
|
- 修改 `POST /api/app/v9/login`:身份验证通过后,正常账号按原流程登录;冷静期账号返回注销信息及**短时恢复专用 Token**,等待用户确认;已到期或已注销账号禁止登录。
|
||||||
|
- 恢复专用 Token 只能查询状态和取消绑定申请,不能访问业务接口。用户点击“恢复账号并登录”才调用取消接口,重新登录本身不能自动取消注销。
|
||||||
|
- `/api/app/v9/set-user`、刷新凭证及统一鉴权必须检查主账号状态,防止旧 Token、旧版本或其他设备绕过限制。取消成功后签发新凭证,不恢复旧 Token。
|
||||||
|
|
||||||
|
## 3. 必须保证的业务规则
|
||||||
|
|
||||||
|
1. **注销范围:** 手机号登录对应的主账号及其关联身份,由后端从凭证识别;不接受客户端指定任意手机号或用户 ID,不删除景区、门店实体或他人的共享资产。
|
||||||
|
2. **提交校验:** 后端再次检查验证码、资产确认及未完成业务;重复提交不能生成多个申请或延长冷静期。
|
||||||
|
3. **七天冷静期:** 截止时间由后端返回和判断,截止前可取消,恰好到期即不可取消;取消与到期任务必须互斥。
|
||||||
|
4. **会话限制:** 提交成功立即禁止该主账号全部设备的业务访问;客户端清理登录态。提交超时不能直接视为失败,应重新验证身份后查询结果。
|
||||||
|
5. **到期处理:** 后端自动执行,不依赖 App 在线;冷静期内不做不可逆删除,处理失败可重试,实际处理完成后才标记已注销。
|
||||||
|
|
||||||
|
建议状态:`none` 无申请、`pending` 冷静期、`canceled` 已取消、`processing` 到期处理中、`completed` 已完成。
|
||||||
|
|
||||||
|
## 4. 请后端与产品确认
|
||||||
|
|
||||||
|
- 余额、冻结款、提现中、未完成订单、线下未补缴款和负责人身份是否阻断注销,如何处理。
|
||||||
|
- 个人资产、共享资产、客户已购内容和交易记录分别删除、保留还是移交;注销后同手机号能否重新注册。
|
||||||
|
- 最终接口字段和错误码、短信频控、七天是否按 168 小时计算,以及测试账号、到期测试方式和可联调时间。
|
||||||
|
|
||||||
|
> 当前 Mock 的固定验证码和“放弃资产”文案仅用于演示,不能直接作为真实业务规则。
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
# 账号注销后端接口需求
|
||||||
|
|
||||||
|
> **2026-08-28 已被新范围替代:** 本次迭代改为使用旧 `account-deregister` 接口,仅注销当前 `store_user` 身份,不实施本文的主账号注销、新接口或恢复专用 Token 方案。当前接入情况见 [门店身份注销接入说明](门店身份注销接入说明.md)。下文保留为历史讨论记录。
|
||||||
|
|
||||||
|
更新日期:2026-08-27
|
||||||
|
适用端:随心瞰商家版 iOS;后端账号状态应同时约束 Android、旧版本客户端及其他登录入口。
|
||||||
|
文档性质:**接口需求建议稿,以下新增路径和字段尚未与后端确认,不代表线上已有接口。**
|
||||||
|
|
||||||
|
## 1. 需要后端提供什么
|
||||||
|
|
||||||
|
需要 **5 个注销接口、现有登录与鉴权流程改造,以及服务端到期处理任务**。
|
||||||
|
|
||||||
|
| 类型 | 建议接口 / 能力 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 新增 | `GET /api/app/account-deletion/precheck` | 返回真实资产、注销影响说明及阻断原因 |
|
||||||
|
| 新增 | `POST /api/app/account-deletion/send-sms-code` | 向主账号绑定手机号发送注销专用验证码 |
|
||||||
|
| 新增 | `POST /api/app/account-deletion/submit` | 校验验证码和用户确认,提交注销申请 |
|
||||||
|
| 新增 | `GET /api/app/account-deletion/status` | 查询当前账号的服务端注销状态及截止时间 |
|
||||||
|
| 新增 | `POST /api/app/account-deletion/cancel` | 冷静期内由用户明确确认后取消注销 |
|
||||||
|
| 修改 | `POST /api/app/v9/login` | 身份验证通过后区分正常登录、待注销恢复和不可恢复状态 |
|
||||||
|
| 修改 | `POST /api/app/v9/set-user` 与统一鉴权 | 禁止待注销或已注销账号取得、使用业务 Token |
|
||||||
|
| 服务端任务 | 到期注销、失败重试、会话失效 | 不依赖客户端在线或再次打开 App |
|
||||||
|
|
||||||
|
如果已有等价能力,可以复用后端现有路径,但需覆盖本文的数据和行为要求,不必重复建设。
|
||||||
|
|
||||||
|
### 当前客户端状态
|
||||||
|
|
||||||
|
- 已有设置入口、资产核验页、短信验证页、成功页、密码登录时的恢复确认和冷启动检查。
|
||||||
|
- 当前为 `AccountDeletionMockService`:资产是固定示例,验证码固定为 `123456`,注销记录保存在本机 `UserDefaults`。
|
||||||
|
- **目前不会发送真实短信,也不会注销后端账号或删除后端数据。**
|
||||||
|
- 接入真实接口后,服务端状态为唯一依据;本机时间、手机号输入值及本地记录不能作为注销结果或操作权限的依据。
|
||||||
|
|
||||||
|
## 2. 账号范围与统一约定
|
||||||
|
|
||||||
|
### 2.1 注销对象
|
||||||
|
|
||||||
|
当前功能意图是注销**手机号登录对应的主账号及其关联业务身份**,不是仅退出当前登录,也不是只停用当前选中的景区或门店身份。
|
||||||
|
|
||||||
|
- 后端根据 Token 解析稳定的主账号 ID,并据此聚合所有关联景区、门店身份的数据。
|
||||||
|
- 请求不接受客户端指定待注销的 `user_id`、`username`、`phone`、`scenic_id` 或 `store_id`;短信收件人也由后端确定。
|
||||||
|
- 当前业务身份资料里的手机号可能与主账号绑定手机号不同,不能直接用业务身份手机号发送注销短信。
|
||||||
|
- “解除景区、门店账号”指解除该用户的关联身份;**不应删除景区、门店实体,也不能误删其他用户、门店或客户共同拥有的数据**。
|
||||||
|
- 若存在管理员、负责人或共享资产,需明确移交、保留或阻断策略,不能因为客户端勾选“放弃资产”就直接删除。
|
||||||
|
|
||||||
|
### 2.2 请求和响应
|
||||||
|
|
||||||
|
沿用当前 App 网络层约定:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
token: <当前请求所需的凭证>
|
||||||
|
X-APP-VERSION: <客户端版本>
|
||||||
|
X-OS-TYPE: <客户端现有平台标识>
|
||||||
|
```
|
||||||
|
|
||||||
|
当前工程使用 `token` 请求头,**不是** `Authorization: Bearer ...`。恢复专用凭证也建议放在同一请求头,由服务端识别凭证类型和权限。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 成功业务码沿用 `100000`;新增失败码由后端统一分配,见第 6 节。
|
||||||
|
- JSON 字段使用 `snake_case`;布尔值使用 `true/false`,空列表使用 `[]`。
|
||||||
|
- ID 建议统一返回字符串,客户端不依赖数据库自增 ID 或 UUID 的内部格式。
|
||||||
|
- 时间统一使用带时区的 ISO 8601 字符串,例如 `2026-08-27T08:00:00Z`;客户端负责本地化显示。
|
||||||
|
- 时间相关响应返回 `server_time`;冷静期截止、验证码到期和取消资格全部由服务端判断。
|
||||||
|
|
||||||
|
### 2.3 状态及七天冷静期
|
||||||
|
|
||||||
|
建议在真实服务中区分“不可再取消”和“数据已处理完成”,避免定时任务尚未完成就展示为永久删除成功。
|
||||||
|
|
||||||
|
| `state` | 含义 | 允许恢复 | 允许进入业务 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `none` | 没有注销申请 | 不适用 | 是 |
|
||||||
|
| `pending` | 在冷静期内,待用户取消或到期 | 是,且服务端当前时间必须早于截止时间 | 否 |
|
||||||
|
| `canceled` | 最近一次申请已取消 | 不适用;可以重新申请 | 是,需重新取得有效业务 Token |
|
||||||
|
| `processing` | 已到截止时间,正在执行最终处理 | 否 | 否 |
|
||||||
|
| `completed` | 服务端已完成约定的注销处理 | 否 | 否 |
|
||||||
|
|
||||||
|
流转:`none/canceled → pending → processing → completed`;仅 `pending` 且未到期时允许转为 `canceled`。
|
||||||
|
|
||||||
|
- 建议默认冷静期为提交成功后 `7 × 24` 小时,后端返回准确的 `scheduled_deletion_at`,客户端不自行推算。
|
||||||
|
- 恰好到达截止时刻即不可取消;即使定时任务尚未运行,接口也必须立即按不可恢复处理。
|
||||||
|
- 任务失败保持不可恢复状态,记录原因并重试,不能重新开放登录或重置冷静期。
|
||||||
|
- 当前 Mock 没有 `processing` 状态;接入真实后端时客户端需同步扩展。
|
||||||
|
|
||||||
|
## 3. 五个接口的详细需求
|
||||||
|
|
||||||
|
### 3.1 注销前置核验
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/app/account-deletion/precheck
|
||||||
|
```
|
||||||
|
|
||||||
|
使用有效业务 Token,无查询参数。返回整个主账号范围内的资产快照、当前绑定手机号的脱敏值、注销后果,以及是否允许提交。
|
||||||
|
|
||||||
|
成功响应示例(资产数值仅为示例):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"precheck_id": "precheck_example_001",
|
||||||
|
"expires_at": "2026-08-27T08:10:00Z",
|
||||||
|
"server_time": "2026-08-27T08:00:00Z",
|
||||||
|
"masked_phone": "138****0000",
|
||||||
|
"can_submit": true,
|
||||||
|
"blocking_reasons": [],
|
||||||
|
"assets": [
|
||||||
|
{"kind": "wallet", "title": "钱包余额", "value": "0.00", "unit": "CNY", "value_text": "¥0.00"},
|
||||||
|
{"kind": "works", "title": "作品与相册", "value": "36", "unit": "item", "value_text": "36个"},
|
||||||
|
{"kind": "projects", "title": "项目", "value": "4", "unit": "item", "value_text": "4个"},
|
||||||
|
{"kind": "cloud_files", "title": "云盘文件", "value": "8589934592", "unit": "byte", "value_text": "8 GB"}
|
||||||
|
],
|
||||||
|
"consequences": [
|
||||||
|
"本人关联的景区与门店身份将解除",
|
||||||
|
"个人作品和云盘文件将按已确认的规则处理",
|
||||||
|
"提交后7天内再次登录并确认恢复,可取消注销"
|
||||||
|
],
|
||||||
|
"acknowledgement_version": "account-deletion-v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 要求 |
|
||||||
|
| --- | --- |
|
||||||
|
| `precheck_id` / `expires_at` | 后端生成的核验快照标识及有效期,绑定当前主账号,用于确认用户看到的资产和后果 |
|
||||||
|
| `masked_phone` | 主账号实际绑定手机号的脱敏值;不返回短信验证码 |
|
||||||
|
| `can_submit` | 是否满足注销条件;客户端据此禁止或允许继续 |
|
||||||
|
| `blocking_reasons` | 不可提交时返回 `[{"reason_code":"WALLET_NOT_SETTLED","message":"请先处理钱包余额或结算中的款项"}]`,可有多项 |
|
||||||
|
| `assets` | 当前页面需要 `wallet`、`works`、`projects`、`cloud_files` 四类;零资产也返回对应项 |
|
||||||
|
| `value` / `unit` / `value_text` | 原始值统一为字符串;金额为元且保留两位小数,计数、字节为整数字符串;`value_text` 供页面直接展示 |
|
||||||
|
| `consequences` | 由后端按最终业务规则返回,不能宣称会删除实际需要保留的数据 |
|
||||||
|
| `acknowledgement_version` | 本次注销说明版本,随提交保存确认记录 |
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 核验不得触发删除、资金扣除、身份解绑或短信发送。
|
||||||
|
- 作品、相册、项目及云盘文件的统计口径和去重方式由后端明确,不能把共享资产全部算成该用户可删除的资产。
|
||||||
|
- 余额、冻结款、提现中、未完成订单、未补缴收款等是否阻断,由产品和后端确认。**在规则确认前,建议未结清资金类问题阻断注销,不直接照搬 Mock 的“放弃余额”。**
|
||||||
|
- 提交时必须再次校验条件。资产或影响范围发生需要重新确认的变化时,返回“请重新核验”,不得默默沿用旧快照。
|
||||||
|
|
||||||
|
### 3.2 发送注销短信验证码
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/app/account-deletion/send-sms-code
|
||||||
|
```
|
||||||
|
|
||||||
|
使用有效业务 Token,请求体为 `{}`。只发送给当前主账号绑定手机号,不接受任意手机号参数。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"verification_id": "verification_example_001",
|
||||||
|
"masked_phone": "138****0000",
|
||||||
|
"expires_in": 300,
|
||||||
|
"retry_after": 60,
|
||||||
|
"server_time": "2026-08-27T08:01:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `verification_id` 是验证码会话标识,绑定主账号、当时的绑定手机号及“账号注销”用途,提交时一并携带。
|
||||||
|
- `expires_in`、`retry_after` 单位为秒;示例为 5 分钟有效、60 秒后可重发,实际值由后端配置并返回。
|
||||||
|
- 使用 6 位数字验证码;不在响应、日志或埋点中返回明文验证码。
|
||||||
|
- 设置账号、手机号、IP 等维度的频率限制及错误次数限制;重发后旧验证码失效。
|
||||||
|
- 与登录、提现、实名认证等验证码用途隔离。可复用短信基础设施,不可混用验证码。
|
||||||
|
- 主账号换绑手机号后,旧手机号对应的验证码会话及核验快照失效,要求重新开始。
|
||||||
|
- 缺少绑定手机号、发送失败或触发限流时返回明确错误;前端不能在失败时显示“已发送”。
|
||||||
|
|
||||||
|
### 3.3 提交注销申请
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/app/account-deletion/submit
|
||||||
|
```
|
||||||
|
|
||||||
|
使用有效业务 Token。请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_request_id": "709277cb-2085-49c1-b80f-042476c7c36b",
|
||||||
|
"precheck_id": "precheck_example_001",
|
||||||
|
"verification_id": "verification_example_001",
|
||||||
|
"sms_code": "482951",
|
||||||
|
"acknowledged_asset_kinds": ["wallet", "works", "projects", "cloud_files"],
|
||||||
|
"acknowledgement_version": "account-deletion-v1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `client_request_id` | 是 | 客户端为本次提交生成的 UUID 字符串;网络重试沿用同一个值 |
|
||||||
|
| `precheck_id` | 是 | 当前主账号的有效核验快照,不接受其他账号的快照 |
|
||||||
|
| `verification_id` / `sms_code` | 是 | 验证码会话及用户输入的真实短信码;示例不是固定验证码 |
|
||||||
|
| `acknowledged_asset_kinds` | 是 | 用户已确认的资产类别,须与快照中要求确认的集合一致 |
|
||||||
|
| `acknowledgement_version` | 是 | 用户确认的注销说明版本 |
|
||||||
|
|
||||||
|
成功响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"state": "pending",
|
||||||
|
"can_cancel": true,
|
||||||
|
"server_time": "2026-08-27T08:02:00Z",
|
||||||
|
"request": {
|
||||||
|
"id": "deletion_example_001",
|
||||||
|
"client_request_id": "709277cb-2085-49c1-b80f-042476c7c36b",
|
||||||
|
"status": "pending",
|
||||||
|
"submitted_at": "2026-08-27T08:02:00Z",
|
||||||
|
"scheduled_deletion_at": "2026-09-03T08:02:00Z",
|
||||||
|
"canceled_at": null,
|
||||||
|
"completed_at": null,
|
||||||
|
"acknowledged_asset_kinds": ["wallet", "works", "projects", "cloud_files"],
|
||||||
|
"acknowledgement_version": "account-deletion-v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
处理要求:
|
||||||
|
|
||||||
|
1. 服务端重新校验身份、验证码、快照、确认版本以及资金和未完成业务条件。
|
||||||
|
2. 原子保存申请及用户确认记录、消耗验证码,并使该主账号所有设备上的业务 Token、旧登录临时 Token 和刷新凭证失去业务访问权限。
|
||||||
|
3. 冷静期内保留可恢复的身份及数据,不提前执行不可逆删除;状态必须限制新建订单、上传、提现等业务操作。
|
||||||
|
4. 同一主账号同一时刻最多有一条有效申请。已成功的同一 `client_request_id` 重试应返回原申请,不因验证码已消费而重复报错;同一幂等键不能承载不同请求内容。
|
||||||
|
5. 账号已有待处理申请时,不创建第二条,也不延长原截止时间;返回原状态或可识别的“已有申请”错误,客户端转查询状态。
|
||||||
|
6. 成功页使用服务端返回的截止时间。客户端收到成功响应后应立即清理业务登录态;即使用户尚未点击成功页“退出”,后端也已禁止业务访问。
|
||||||
|
|
||||||
|
**响应丢失的处理:** 提交可能已成功且原 Token 已失效。此时客户端重新完成身份验证,通过登录接口取得恢复专用凭证,再查 `status`;不得把网络超时直接当成提交失败,也不得为了查询结果自动取消注销。
|
||||||
|
|
||||||
|
### 3.4 查询注销状态
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/app/account-deletion/status
|
||||||
|
```
|
||||||
|
|
||||||
|
- 无查询参数,以凭证定位主账号。
|
||||||
|
- 接受有效业务 Token、正常登录临时 Token,或第 4 节定义的恢复专用 Token;不能提供匿名按手机号查询。
|
||||||
|
- 原业务 Token 已被注销操作撤销时,不重新赋予其查询权限,应先重新验证身份取得恢复专用 Token。
|
||||||
|
- 用于启动检查、回到前台、多设备状态校准,以及提交/取消请求超时后的结果确认。
|
||||||
|
|
||||||
|
响应 `data` 与提交接口一致,包含 `state`、`can_cancel`、`server_time`、`request`。无申请时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"state": "none",
|
||||||
|
"can_cancel": false,
|
||||||
|
"server_time": "2026-08-27T08:00:00Z",
|
||||||
|
"request": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `canceled` 返回最近取消的申请和 `canceled_at`;`completed` 返回实际完成时间 `completed_at`。
|
||||||
|
- `can_cancel` 由后端计算,只能在状态为 `pending` 且未到截止时刻时为 `true`。
|
||||||
|
- 查询不得取消注销或延长冷静期。到期任务尚未完成时返回 `processing`,不能仅凭时间到了就返回 `completed`。
|
||||||
|
- 状态查询失败时,不应默认账号正常;客户端提示重试或重新登录,业务接口仍由后端鉴权保护。
|
||||||
|
|
||||||
|
### 3.5 取消注销申请
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/app/account-deletion/cancel
|
||||||
|
token: <恢复专用 Token>
|
||||||
|
```
|
||||||
|
|
||||||
|
只有重新验证身份且用户点击“恢复账号并登录”后调用:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "deletion_example_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"request_id": "deletion_example_001",
|
||||||
|
"state": "canceled",
|
||||||
|
"canceled_at": "2026-08-28T01:00:00Z",
|
||||||
|
"server_time": "2026-08-28T01:00:00Z",
|
||||||
|
"login": {
|
||||||
|
"token": "new-account-selection-token",
|
||||||
|
"scenic_users": [],
|
||||||
|
"store_users": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `login` 结构复用现有 v9 登录响应;示例数组省略业务内容,实际需返回恢复后当前可选的景区、门店身份。
|
||||||
|
- 返回的 `login.token` 为新生成的正常账号选择临时 Token;客户端继续走原有单账号自动选择、多账号选择及 `/v9/set-user` 流程,不复用注销前的旧凭证。
|
||||||
|
- `request_id` 必须与凭证绑定的主账号和注销申请一致,不能取消别人的申请或该账号下一次新申请。
|
||||||
|
- 与到期任务通过事务或等效并发控制互斥;按服务端时间决定取消是否成功,不能出现既恢复又删除的结果。
|
||||||
|
- 对同一已取消申请的重试,不重复改变状态或产生副作用。若凭证仍有效,可返回原取消结果和有效登录上下文;若凭证已撤销或过期,重新登录确认状态,不要求用户再次取消。
|
||||||
|
- 到期、`processing`、`completed` 均拒绝恢复,返回可识别错误;不得重新启用旧 Token。
|
||||||
|
|
||||||
|
## 4. 登录、会话与多端配合
|
||||||
|
|
||||||
|
### 4.1 调整现有 `/api/app/v9/login`
|
||||||
|
|
||||||
|
保留现有手机号密码登录参数。必须先完成密码/验证码等身份验证,再返回该主账号的注销状态,避免泄露任意手机号是否注册或注销。
|
||||||
|
|
||||||
|
| 状态 | 登录接口行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| `none` / `canceled` | 沿用当前 `token`、`scenic_users`、`store_users`;可附带最新注销状态 |
|
||||||
|
| 未到期的 `pending` | 不签发正常账号选择或业务 Token;返回申请信息和恢复专用 Token,等待用户决定 |
|
||||||
|
| `processing` / `completed` | 返回明确的不可登录、不可恢复业务错误,不签发可登录凭证 |
|
||||||
|
|
||||||
|
待注销登录建议使用成功 Envelope 承载“验证身份成功但尚未登录”的结果:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"token": "",
|
||||||
|
"scenic_users": [],
|
||||||
|
"store_users": [],
|
||||||
|
"account_deletion": {
|
||||||
|
"state": "pending",
|
||||||
|
"request_id": "deletion_example_001",
|
||||||
|
"submitted_at": "2026-08-27T08:02:00Z",
|
||||||
|
"scheduled_deletion_at": "2026-09-03T08:02:00Z",
|
||||||
|
"server_time": "2026-08-28T01:00:00Z",
|
||||||
|
"can_cancel": true
|
||||||
|
},
|
||||||
|
"recovery_token": "opaque-recovery-token",
|
||||||
|
"recovery_token_expires_at": "2026-08-28T01:10:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复专用 Token 要求:
|
||||||
|
|
||||||
|
- 短时有效,并绑定主账号、当前注销申请和允许的操作;示例有效期为 10 分钟,实际由后端配置。
|
||||||
|
- **仅可查询注销状态和取消该申请**,不能调用 `/v9/set-user`、订单、钱包、文件下载等业务接口,也不能兑换或刷新成业务 Token。
|
||||||
|
- 不能通过改请求参数变成其他用户的凭证;不记录到普通日志、埋点或 URL。
|
||||||
|
- 用户选择“暂不登录”只结束本次登录,不调用取消接口;重新登录或查询本身不得自动恢复账号。
|
||||||
|
- 取消完成后撤销该申请的恢复操作权限;新登录凭证按取消接口约定返回。
|
||||||
|
|
||||||
|
### 4.2 修改 `/api/app/v9/set-user` 和统一鉴权
|
||||||
|
|
||||||
|
- 无论客户端是否接入新功能,待注销及不可恢复账号都不能通过旧临时 Token、刷新 Token、账号切换或其他登录入口继续使用业务。
|
||||||
|
- 校验“凭证属于谁、凭证可做什么、账号当前是什么状态”,不能只检查签名或过期时间。
|
||||||
|
- 提交注销需要覆盖同一主账号的全部设备和全部关联业务身份,不仅让提交申请的 iPhone 退出。
|
||||||
|
- Android 当前有 `/v9/login` 的密码和短信两种登录方式;所有方式执行相同的状态检查。iOS 短信登录入口目前仍待接入,不能因此遗漏后端约束。
|
||||||
|
- 冷静期取消后签发新凭证,不恢复旧凭证的有效性;业务身份列表以取消时的最新数据为准。
|
||||||
|
|
||||||
|
### 4.3 与客户端接入的关系
|
||||||
|
|
||||||
|
当前本地 `loginState`、`cancelDeletion` 是同步方法,接入网络后需改为异步;不能只替换 Mock 类名。
|
||||||
|
|
||||||
|
客户端还需扩展登录返回模型、恢复专用 Token 上下文、`processing` 状态、短信倒计时、阻断原因和核验快照处理。当前 Mock 使用登录输入框中的手机号取消注销,真实接口必须改用受验证的主账号/申请上下文,不能继续依赖可编辑输入值。
|
||||||
|
|
||||||
|
当前 Mock 申请 ID 使用 Swift `UUID`,资产枚举原始值包含 `cloudFiles`;本文建议接口使用字符串 ID 和 `cloud_files`。客户端需通过网络 DTO 显式映射,不能把本地 Mock 模型直接序列化后当作请求契约。
|
||||||
|
|
||||||
|
## 5. 服务端必须负责的后台处理
|
||||||
|
|
||||||
|
- 提交成功后持久保存申请,App 卸载、退出或长期离线都不影响到期处理。
|
||||||
|
- 冷静期内不执行不可逆清理;到期后先禁止恢复,再按最终确认的规则解除身份、处理个人资产和第三方关联。
|
||||||
|
- 共享资产、资金账务、交易记录、客户已购买内容等分别制定处理方案;需要保留的记录与可删除的个人数据应区分,保留范围及周期由相关负责人确认。
|
||||||
|
- 处理任务可重复执行且有重试机制;只有约定的必需处理步骤完成后才标记 `completed`,部分失败不能假报成功。
|
||||||
|
- 保留可审计的申请、确认说明版本、时间、取消记录、处理进度及失败原因;审计中不保留明文验证码或 Token。
|
||||||
|
- 到期删除与取消操作必须有统一的并发保护;业务鉴权和后台任务读取一致的主账号状态。
|
||||||
|
- 需要测试环境的可控时间或缩短冷静期能力,以便验证到期边界及失败重试;不得在生产开放客户端任意修改截止时间的接口。
|
||||||
|
|
||||||
|
## 6. 错误返回要求
|
||||||
|
|
||||||
|
沿用整数 `code` 和可直接展示的中文 `msg`。以下名称是**待后端分配业务码的语义清单**,不是现有错误码。
|
||||||
|
|
||||||
|
| 错误语义 | 典型场景 | 客户端处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ACCOUNT_IDENTITY_REQUIRED` | 主账号没有可用于验证的绑定手机号 | 提示先处理账号信息 |
|
||||||
|
| `DELETION_BLOCKED` | 资金未结清、订单未完成、负责人未移交等 | 展示阻断原因,禁止提交 |
|
||||||
|
| `PRECHECK_EXPIRED` / `PRECHECK_CHANGED` | 快照过期、资产或说明版本发生变化 | 重新核验并要求再次确认 |
|
||||||
|
| `ACKNOWLEDGEMENT_REQUIRED` | 缺少必需资产确认或确认版本不匹配 | 返回资产核验页 |
|
||||||
|
| `SMS_SEND_FAILED` / `SMS_RATE_LIMITED` | 短信发送失败或限流 | 提示重试;限流返回重试秒数 |
|
||||||
|
| `SMS_CODE_INVALID` / `SMS_CODE_EXPIRED` / `SMS_ATTEMPTS_EXCEEDED` | 验证码错误、过期、尝试超限 | 提示重输或重新发送 |
|
||||||
|
| `DELETION_ALREADY_PENDING` | 已有有效申请 | 查询原申请,不重复创建 |
|
||||||
|
| `NO_PENDING_DELETION` | 申请不存在或并非当前可取消申请 | 查询最新状态 |
|
||||||
|
| `DELETION_NOT_CANCELABLE` | 已到截止时间或正在最终处理 | 不再提供恢复入口 |
|
||||||
|
| `ACCOUNT_DELETION_COMPLETED` | 已完成注销 | 禁止登录和恢复 |
|
||||||
|
| `RECOVERY_TOKEN_INVALID` / `RECOVERY_TOKEN_EXPIRED` | 恢复凭证失效 | 重新验证身份,不自动取消 |
|
||||||
|
| `IDEMPOTENCY_CONFLICT` | 同一幂等键用于不同请求内容 | 停止自动重试,提示重新操作 |
|
||||||
|
|
||||||
|
错误响应继续采用现有 Envelope。后端如需在 `data` 中返回 `blocking_reasons`、`retry_after` 等结构化详情,需同时给出字段契约;当前 iOS `APIClient` 对失败响应只暴露 `code/msg`,接入时需要补充详情解析。
|
||||||
|
|
||||||
|
**与现有全局登录失效处理区分:** 当前 iOS 会将 HTTP `401/403` 及业务码 `180024`、`100091`、`100090`、`100060` 视为登录失效。资产阻断、验证码错误、快照过期等业务问题不要复用这些码,避免误触发全局退出。真实凭证失效仍沿用项目鉴权规则。
|
||||||
|
|
||||||
|
## 7. 联调验收清单
|
||||||
|
|
||||||
|
1. 主账号关联多个景区、门店时,核验范围完整;切换业务身份不会变成另一个注销对象;不能查询、提交或取消其他主账号的申请。
|
||||||
|
2. 无手机号、无资产、存在余额/冻结款/未完成业务时,分别返回约定的正常或阻断结果。
|
||||||
|
3. 正确验证码可提交;错误、过期、重发前旧码、其他用途码、其他账号验证码都不可提交。
|
||||||
|
4. 资产确认不完整、核验过期、资产发生变化、说明版本不一致时,后端拒绝并要求重新确认。
|
||||||
|
5. 成功申请返回准确截止时间;重复请求不产生新申请、不重置冷静期;请求超时后可重新验证身份并查到真实结果。
|
||||||
|
6. 提交成功后所有设备及旧版本业务访问受限;旧 Token、账号选择、短信登录等不能绕过注销状态。
|
||||||
|
7. 冷静期登录只显示恢复确认;点“暂不登录”保持待注销,点“恢复账号并登录”后取消并取得新的账号选择凭证。
|
||||||
|
8. 恢复专用 Token 只能查询状态和取消绑定申请;不能访问业务或取消其他申请。
|
||||||
|
9. 截止前可取消、恰好截止不可取消;取消与到期任务并发时只产生一个一致结果。
|
||||||
|
10. 未运行 App 也会按期处理;任务部分失败会重试,处理期间保持不可恢复,未完成时不返回 `completed`。
|
||||||
|
11. 已取消账号可再次发起新申请;旧申请和旧恢复凭证不能影响新申请。
|
||||||
|
12. 完成注销后不能登录或恢复;共享数据、客户内容和需保留记录按确认的方案处理,没有越权删除。
|
||||||
|
|
||||||
|
## 8. 请后端与产品确认并回传
|
||||||
|
|
||||||
|
- **账号及数据边界:** 主账号映射、关联身份范围;作品与相册/项目的统计口径;共享资产和已售内容的处理方式。
|
||||||
|
- **准入规则:** 余额是否允许放弃;冻结资金、提现中、未完成订单、线下未补缴款及负责人身份是否阻断,如何解除阻断。
|
||||||
|
- **时限和最终处理:** 七天是否按 168 小时计算;到期处理内容、记录保留范围与周期;注销后同手机号能否重新注册,且不得恢复旧账号数据。
|
||||||
|
- **接口契约:** 最终路径、字段类型、完整正常/异常响应、业务码、短信频控、恢复凭证权限及有效期。
|
||||||
|
- **联调交付:** 测试环境地址、测试账号及各状态样例、到期和失败重试验证方式、预计可联调时间。
|
||||||
|
|
||||||
|
上述规则确认前,客户端中的“永久删除”“放弃资产”等 Mock 文案不能直接作为最终业务承诺,应随实际后端处理规则调整。
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# 门店身份注销接入说明
|
||||||
|
|
||||||
|
更新时间:2026-08-28。当前分支 `dev_9_7`,未提交或推送。
|
||||||
|
|
||||||
|
**最新实测:15:27:24的申请已于15:55:36因重新登录自动撤销(9 / CANCELLED_BY_LOGIN)。最新交互改为申请成功立即退出登录、下次登录取得身份凭证后核验;普通启动和普通页面返回前台不主动查询。本轮不重新申请、不等待终态。**
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
采用用户提供的《260821-App 门店用户账号注销.md》中的旧接口,不需要新增手机号主账号接口。仅注销当前 `store_user` 对应的 `ss_store_user.id`;同手机号其他身份不受影响,不支持景区身份注销。移除了原手机号级 Mock、固定验证码和本地七天完成判定。
|
||||||
|
|
||||||
|
统一前缀:`/api/yf-handset-app/account-deregister`。
|
||||||
|
|
||||||
|
| 请求 | 请求体 |
|
||||||
|
| --- | --- |
|
||||||
|
| GET `/eligibility`、GET `/status` | 无 |
|
||||||
|
| POST `/waivers/wallet`、POST `/waivers/points` | `{"accepted":true}` |
|
||||||
|
| POST `/send-sms`、POST `/cancel` | 无业务字段 |
|
||||||
|
| POST `/apply` | `sms_code`、`reason` |
|
||||||
|
|
||||||
|
请求层冻结当前门店用户 ID 和 Token,请求前后核对身份;使用 `session.userId`,不是门店实体 ID。注销请求与响应正文不写入调试日志。完整脱敏数据见[旧接口返回数据说明](门店身份注销旧接口返回数据说明.md),操作经过见[接口实测](门店身份注销接口实测.md)。
|
||||||
|
|
||||||
|
## 已实现
|
||||||
|
|
||||||
|
- 设置页仅门店身份显示注销入口;页面采用“确认资产 → 手机验证”两步流程,以身份卡、资产卡、须知卡和底部主按钮明确注销范围。
|
||||||
|
- 注销页、只读条件页和状态页均使用下拉刷新,删除右上角刷新入口;操作过程统一使用全局通用 Loading,不保留页面内的小转圈。“其他身份不受影响”等提示不再区分其他身份类别。
|
||||||
|
- 查询资产、业务风险等待时间和全部阻断项;现金与积分合并为一个入口和一次弹窗确认,分别列明两项自愿放弃说明,零资产也不省略。底层顺序调用两个旧接口,逐次核对身份、余额及财务归属;部分成功保留真实标记,不自动重发修改请求。
|
||||||
|
- 条件满足后进入真实短信验证和申请确认。验证码为用户输入,收件手机号由后端决定。`/apply` 明确成功后立即复用已有退出通知清理会话并返回登录页,不再等一次状态查询,也不自动重新登录;响应丢失则保留核验保护,不能冒充成功或自动重发。
|
||||||
|
- 区分风险等待期 `eligible_at` 与冷静期 `cooling_until`;按服务端原文展示时间,不猜测时区。
|
||||||
|
- 严格识别实测状态:无记录、`0` 待确认草稿、`1` 冷静期、`9` 已撤销。字段缺失、状态矛盾和未知枚举都不默认放行。
|
||||||
|
- 普通冷启动使用已有会话直接进入首页;登录流程取得门店身份凭证后才核验状态,期间保留登录页面背景并显示全局 Loading,不显示“正在查询注销状态”的独立页面。正常结果直接进入业务页,冷静期、未知状态或失败才展示结果页。登录中的多身份选择仍属于此核验入口;已登录时切换身份不额外主动查询。完整草稿不影响第二次资产确认或正常使用。若核验发现冷静期,仍以独立卡片显示截止时间并提供下拉刷新、只读条件、主动撤销和退出入口。
|
||||||
|
- 用户确认撤销后先重查同一申请,POST 成功后再次 GET 核实。确认已撤销才恢复业务;响应丢失不自动重发,旧身份或旧限制版本的响应不能放行当前会话。旧 null/草稿不能作为撤销成功依据。
|
||||||
|
- 提交前按环境与门店用户 ID 保存意图,不保存 Token、手机号、验证码或资产。明确业务拒绝或新撤销记录可清除;重新申请前的旧撤销记录不能清除新意图。UserDefaults 不是后端幂等或断电事务保障。
|
||||||
|
- 冷静期 `eligibility` 虽然返回两项确认 false,也禁止再次确认;已撤销可重新准备申请,但必须按最新条件重新确认资产。
|
||||||
|
- 网络层将 `150015` 作为当前门店凭证的业务限制,不当作全局登录失效。核验期间暂停推送绑定和业务通知跳转;核验通过后恢复。
|
||||||
|
- 用15:29实测的150015与状态JSON补充集成回归,覆盖 `ProfileAPI.userInfo → APIClient → 身份限制通知 → RootCoordinator → 冷静期页面`;确认原Token和身份保留、业务暂停、无登录或撤销请求。通知转发在测试中使用独立NotificationCenter,不等同于真实SceneDelegate端到端操作。
|
||||||
|
- 普通页面从后台返回不主动查询,不替换导航栈。仅当前已显示的受限/核验页返回前台时刷新,并废弃后台前的旧查询;不会重新发送短信、申请或撤销。业务接口明确返回当前 Token 的 `150015` 时仍立即限制业务并核验。
|
||||||
|
- 普通登录、选择及切换身份不再无条件弹出注销提示;自动撤销规则保留在注销提交确认和申请状态页。登录后的服务端状态核验保持不变。身份列表为空时不进入首页,但“全部身份注销后后端究竟返回什么”尚未实测。
|
||||||
|
|
||||||
|
## 真实接口和页面验证
|
||||||
|
|
||||||
|
2026-08-28 在测试环境、配对 iPhone 11 上分次授权验证:
|
||||||
|
|
||||||
|
1. 两项零资产确认成功,第一次确认产生草稿0;第二次确认后允许申请。
|
||||||
|
2. 真机发送短信并收到验证码,14:23:40 提交测试申请,GET 确认冷静期1。
|
||||||
|
3. 14:24:04 主动撤销成功,14:24:05 两个 GET 均确认已撤销9。未留下待注销申请,未最终删除身份。
|
||||||
|
4. 安装新增状态处理后,14:39 重新打开 App,原身份直接恢复首页,没有重新登录;再次进入设置中的注销入口成功。读取详情页的镜像操作随后超时,因此未把撤销后详情页的完整展示记为通过。
|
||||||
|
|
||||||
|
短信和申请 POST 的原始响应体未保存,只有界面和后续 GET 的成功证据;不能将 GET 结构冒充 POST 响应。撤销响应体已保存脱敏样例。上述14:24立即撤销流程已结束。
|
||||||
|
|
||||||
|
随后用户明确要求在已登录的 iPhone Air 模拟器操作,并确认保留新申请至到期复核:15:27:24提交一次,界面自动进入冷静期限制页。15:28 GET `/status` 和 `/eligibility` 均确认冷静期1;15:29 GET `/userinfo` 返回真实150015,前后状态查询仍为1。截至15:50未撤销或重新登录;15:56再次只读复核时,服务端已返回15:55:36因重新登录自动撤销(9 / CANCELLED_BY_LOGIN)。该申请不再等待到期。模拟器仅用于用户指定的人工操作,不用于替代单元测试的真机要求。
|
||||||
|
|
||||||
|
## 自动化验证
|
||||||
|
|
||||||
|
- iPhone 11(`00008030-001E48E21139802E`),未使用模拟器。
|
||||||
|
- 冷静期/撤销首轮:61 项注销测试全部通过(API 8、进入核验21、响应8、流程24)。随后补充一项取消前后旧 null/草稿的防御测试,纳入最后全量回归。
|
||||||
|
- 冷静期页面真机 Mock 渲染截图已导出并检查,文案、截止时间和撤销入口完整可见。
|
||||||
|
- 测试中的短信、资产确认、申请与撤销只使用 Mock;宿主 App 仍可能产生既有后台请求,不能称整台设备离线。
|
||||||
|
- 前台恢复改动之前的全量:781项中771通过、10项失败(15条失败记录,2条 unexpected);与原始基线逐项对比,失败用例集合完全一致,无新增失败。注销相关62项全部通过(API 8、进入核验22、响应8、流程24)。全量不是全部通过。
|
||||||
|
- 前台路由版全量:791项中781通过、10项失败(15条失败记录,2条 unexpected),失败用例集合与基线完全一致;注销相关72项全部通过。
|
||||||
|
- UI改版前全量(15:45):792项中782通过、10项失败,无跳过项;失败用例集合仍与基线完全一致,无新增失败。注销相关73项全部通过(API 8、进入核验23、响应8、根路由10、流程24),均在iPhone 11运行;日志与xcresult摘要已交叉核对。
|
||||||
|
|
||||||
|
结果文件:
|
||||||
|
|
||||||
|
- 本轮61项:`/private/tmp/suixinkan-deregister-cooling-20260828.xcresult`
|
||||||
|
- 本轮冷静期截图:`/private/tmp/suixinkan-deregister-cooling-20260828-attachments/DB6A885A-C10C-49E0-A5F4-D3EB5D0AA3B1.png`
|
||||||
|
- 前台恢复改动之前的全量:`/private/tmp/suixinkan-deregister-cooling-full-20260828.xcresult`
|
||||||
|
- 前台恢复测试尝试(手机锁定,未执行,中断):`/private/tmp/suixinkan-deregister-foreground-20260828.xcresult`
|
||||||
|
- 当前最终构建(成功,仅编译):`/private/tmp/suixinkan-deregister-final-build-20260828.xcresult`
|
||||||
|
- 路由修正后32项回归:`/private/tmp/suixinkan-deregister-foreground-r2-20260828.xcresult`
|
||||||
|
- 前台路由版全量:`/private/tmp/suixinkan-deregister-foreground-final-20260828.xcresult`
|
||||||
|
- 当前最终全量:`/private/tmp/suixinkan-deregister-real-contract-20260828.xcresult`
|
||||||
|
- 原始基线:`/private/tmp/suixinkan-account-deletion-baseline-20260827-r2.xcresult`
|
||||||
|
|
||||||
|
## 尚未完成,不能视为完整上线验收
|
||||||
|
|
||||||
|
1. 旧文档没有给出终审“阻断”“正式完成”的数字枚举和完整响应;当前只能保守展示待核验页,不能伪造终态或自动清理账号。
|
||||||
|
2. 重新登录/选择身份自动撤销发生在哪个接口、受限 Token 能否切换其他身份、最终注销后鉴权及所有身份注销后的登录响应,尚未实测。
|
||||||
|
3. 真实150015接口响应已采样,客户端限制通知到核验页已用真实数据Mock验证;实际SceneDelegate端到端操作和真实多端验证仍缺。最新流程不再主动轮询其他设备的申请变化,依赖登录核验、用户进入注销功能查询或业务接口明确限制。
|
||||||
|
4. 新撤销按钮交互使用 Mock 验证与截图检查;真实撤销通过受控 API 完成,没有再次创建申请来验证新按钮。完整终态页面验收仍未完成。
|
||||||
|
5. 非零资产、确认过期、错误验证码、重复申请/撤销及完成后的错误响应还缺真实样例。
|
||||||
|
|
||||||
|
不要求后端新增接口;补充现有 Controller/Resource/DTO 源码或脱敏响应即可继续。严禁通过本机时间、`remaining_seconds: 0`、`can_apply` 或仍存在 `cooling_until` 推断已注销。
|
||||||
|
|
||||||
|
|
||||||
|
## 此前完整目标核对(15:50历史记录)
|
||||||
|
|
||||||
|
下表保留当时的验证范围。本轮最新结果见文末UI改版小节,不继续等待已撤销申请的终态。
|
||||||
|
|
||||||
|
| 要求 | 当前证据 | 结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 仅当前门店身份,保留同手机号其他身份 | 请求冻结 `session.userId`/Token,8项请求层测试;无批量接口 | 已实现,路由回归已通过 |
|
||||||
|
| 展示所有条件、分别确认资产及快照失效 | 真实两项零资产确认与条件响应;模型/流程测试 | 已验证零资产正常流程,未真实操作非零资产 |
|
||||||
|
| 实际短信、申请确认、7天冷静期 | 真机收到短信,申请后 GET 返回1及7天截止时间 | 已验证,未捕获两个 POST 的原始响应体 |
|
||||||
|
| 主动撤销并恢复使用 | 真实 cancel 返回9,随后 GET 复核,冷启动恢复首页;撤销逻辑测试 | 主动接口已验证,新弹窗及前台路由Mock测试已通过 |
|
||||||
|
| 不将150015当作手机号登录失效 | 原请求Token隔离测试、推送暂停测试、真实userinfo响应;真实JSON重放至冷静期UI | 接口及客户端Mock集成已验证,实际SceneDelegate端到端操作待验证 |
|
||||||
|
| 冷启动、前台、多端及过期响应 | 冷启动已真机检查;RootTests 10项和状态回退测试1项 | 已通过iPhone真机Mock测试;不代替真实多端验证 |
|
||||||
|
| 冷静期到期复核、阻断、正式完成页面及清理 | 旧文档描述规则,但无终态枚举/响应 | 未完成,需要现有接口样例或源码 |
|
||||||
|
| 登录/选择同一身份自动撤销、全部身份注销后登录 | 旧文档规则、登录提示和空身份列表Mock测试 | 后端真实行为未验证 |
|
||||||
|
| 所有相关测试、全量回归及最终页面验收 | 当前73项注销相关测试通过;全量792项中10项基线失败 | 当前代码回归已执行;完整终态及真实多端验收未完成 |
|
||||||
|
| 不改变其他业务配置,不切分支、提交或推送 | 当前 `dev_9_7`;配置/依赖差异检查为空 | 保持约定 |
|
||||||
|
|
||||||
|
若后续扩展终态,仍需:`/status` 在“终审阻断”和“正式完成”时的脱敏完整响应或对应资源模型源码,以及终态 Token 查询和撤销规则。此前获准保留的测试申请已经自动撤销,本轮不重新申请。不得跳过7天冷静期、修改数据库或制造订单/资产变化来取得样例。
|
||||||
|
|
||||||
|
### 15:50完成度复核:仍未满足完整目标
|
||||||
|
|
||||||
|
直接核对当前状态模型和AccessViewModel:只映射草稿0、冷静期1和已撤销9,未知终态仍进入待核验页;尚无终审阻断、正式完成页面及清理依据。因此73项相关测试通过不能证明完整注销终态已经接入。
|
||||||
|
|
||||||
|
15:48与15:50的真实GET都返回同一冷静期申请,`completed_at`和`cancel_time`仍为null。期间只结束并重启iPhone Air的App进程,未清空数据或重新登录;重启后原Token仍能查询同一申请。窗口工具停留在另一模拟器窗口且菜单操作失败,未取得iPhone Air冷启动页面,故不将该项UI验证记为通过。
|
||||||
|
|
||||||
|
已查本机、原文档、公开文档入口、现有权限可见代码站及项目列表;仍无终态源码或响应。该缺口在提交后、真实响应集成回归后及本次复核中持续存在。当前需要服务端到期产生新状态,或取得现有后端源码才能推进终态接入;暂停重复测试与状态轮询。没有创建定时任务或自动监控,后续复核需恢复本任务。
|
||||||
|
|
||||||
|
## 本轮UI改版(2026-08-28)
|
||||||
|
|
||||||
|
- 两步页面沿用App蓝白色、16pt边距和白色圆角卡片;底部固定唯一主按钮,验证码与获取按钮同行;输入完整后才允许提交。
|
||||||
|
- 资产确认类阻断集中在资产卡,其他条件完整展示为待处理事项;订单等条件未满足时不能开始合并确认。
|
||||||
|
- 新增confirmAssetsAndContinue(snapshot:api:),跳过已确认项;任何一项失败或资产变化均停留在条件页,核对后由用户主动重试。
|
||||||
|
- 状态页用图标、标题、身份和截止时间分层展示。未知和失败状态只提供简短说明与重试,不伪造注销完成;删除草稿等调试文案。
|
||||||
|
- 本轮不扩展终态,不改变登录和状态核验架构,不操作真实短信、确认、申请或撤销。上文15:27—15:50记录为历史过程,当前以15:56已撤销结果为准。
|
||||||
|
- 最终真机回归:90项注销相关测试全部通过,其中本轮新增17项(13项合并确认、4项UIKit交互/布局);全量809项中799通过、10项失败、0跳过。失败用例集合与改版前基线完全一致,没有新增失败;全量不宣称全部通过。
|
||||||
|
- 已检查真机Mock截图:375pt资产页、非零资产及业务阻断、手机验证、冷静期、查询失败、未知状态;验证码与原因输入时,输入框可滚动至可见区域,底部按钮位于键盘上方。截图只包含测试窗口,未合成系统独立键盘窗口。所有短信及注销修改请求均为Mock,未再次操作真实注销。
|
||||||
|
- 现有键盘库的局部兼容处理已验证:只禁用本页两个输入框的重复位移,保持导航栏位置稳定;验证码和原因均可滚动至键盘上方,不改变其他页面的键盘设置。最终回归包含该处理。
|
||||||
|
|
||||||
|
本轮验证文件:
|
||||||
|
|
||||||
|
- 全量结果:`/private/tmp/suixinkan-deregister-redesign-r5-20260828.xcresult`
|
||||||
|
- 结构化摘要:`/private/tmp/suixinkan-deregister-redesign-r5-summary.json`
|
||||||
|
- 截图目录:`/private/tmp/suixinkan-deregister-redesign-r5-images/`
|
||||||
|
- 中间一次构建成功但因iPhone锁屏未能启动测试;解锁后使用同一构建完成上述真机回归,没有改用模拟器。
|
||||||
|
|
||||||
|
## 登录入口提示修复(2026-08-28)
|
||||||
|
|
||||||
|
- 移除登录、选择门店身份和切换门店身份时无条件出现的注销提示,删除无用的提示组件。
|
||||||
|
- 保留手机号/密码/协议校验和加载期间的防重复操作;不改变登录后的注销状态核验。自动撤销规则继续在注销提交确认和申请状态页说明。
|
||||||
|
- 登录页面支持注入现有AuthAPI用于Mock回归,不改登录协议、不访问真实登录接口,也不写入测试登录会话。
|
||||||
|
- iPhone 11回归:登录12项、账号切换2项、注销90项全部通过。全量812项中802通过、10项失败、0跳过;失败集合与修复前一致,没有新增失败。
|
||||||
|
- 结果:`/private/tmp/suixinkan-login-prompt-fix-20260828.xcresult`;摘要:`/private/tmp/suixinkan-login-prompt-fix-20260828-summary.json`。
|
||||||
|
|
||||||
|
## 申请成功退出与查询时机简化(2026-08-28)
|
||||||
|
|
||||||
|
- 提交前仍核对状态和资产;`/apply` 明确成功即发送现有退出通知,复用推送解绑、会话清理及返回登录页流程。不再追加成功后的 GET,也不先显示申请状态页。
|
||||||
|
- 仅填验证码、点击提交或打开最终确认弹窗都不会自动申请。最终弹窗新增“提交成功后将退出登录”;连点和成功后重复回调均不能重复申请或重复退出。
|
||||||
|
- 验证码等明确业务拒绝不退出;申请响应丢失保留提交意图并进入核验保护,不自动重发。已接受的提交意图仍按环境、门店身份保存,供下次登录核实。
|
||||||
|
- 普通启动和普通页面返回前台不主动查询注销状态;登录得到身份 Token 后核验。旧 `/status` 需要身份凭证,不能在未登录时只凭手机号查询;旧后端登录/选择该身份可能已自动撤销申请,因此此时可能返回已撤销。
|
||||||
|
- 保留业务 `150015` 限制和受限页面的刷新;不能因为简化正常启动就忽略服务端明确限制。进入注销功能时仍需查询条件与状态。
|
||||||
|
- 本轮只使用 Mock 验证提交和退出回调,不操作真实短信、资产确认、申请、撤销或登录。
|
||||||
|
- iPhone 11 真机全量回归:817项中807通过、10项失败、0跳过;失败用例集合与原始基线及上一轮完全一致,无新增失败。注销相关94项全部通过,包含本轮新增的登录核验时机、成功一次退出、失败不退出和最终确认弹窗测试;普通启动/前台不查询的路由断言同步更新。
|
||||||
|
- 结果:`/private/tmp/suixinkan-deregister-logout-20260828.xcresult`;摘要:`/private/tmp/suixinkan-deregister-logout-20260828-summary.json`。退出通过注入回调验证,没有为验收再次提交真实注销;现有退出通知到会话清理沿用原实现。
|
||||||
|
|
||||||
|
## 下拉刷新与统一 Loading(2026-08-28)
|
||||||
|
|
||||||
|
- 删除注销流程右上角刷新按钮,资产/验证页、只读条件页和申请状态页均支持下拉刷新;只执行查询,不自动确认资产、发短信、申请或撤销。
|
||||||
|
- 去掉注销页“景区”相关固定文案,统一描述其他身份不受影响。真实身份名称和服务端待处理事项仍如实展示,不改业务身份或接口字段。
|
||||||
|
- 初次查询、下拉刷新、确认资产、发送短信、提交申请及撤销操作统一复用 `GlobalLoadingManager` 的全屏遮罩与动画。下拉只作为触发手势,隐藏其系统转圈,避免叠加两套 Loading。
|
||||||
|
- 登录核验不再先创建查询根页面:保留当前登录页面背景,核验成功直接进入首页;异常结果才创建状态页,复用已查询的结果,不追加重复 GET。
|
||||||
|
- 保留请求前后的身份校验、限制信号优先级和后台旧响应作废。退出/切换会话会结束本次核验持有的 Loading,迟到响应不能关闭新请求的 Loading 或替换新账号页面。
|
||||||
|
- 测试使用独立会话、Mock 网络和测试窗口;本轮不发出真实短信、资产确认、申请、撤销或登录请求。
|
||||||
|
- 最终 iPhone 11 全量回归:821项中811通过、10项既有失败、0跳过,失败集合与上一轮完全一致。98项注销相关测试全部通过,覆盖下拉刷新、失败后结束加载、输入保留、登录期间不换根、重复核验及旧响应隔离。
|
||||||
|
- 已检查真机 Mock 截图:资产页和冷静期页右上角无刷新按钮;提交中显示现有白色圆角动画卡片与全屏灰色遮罩,没有页面内转圈。登录不换根通过独立窗口路由测试验证,未再次操作真实账号登录。
|
||||||
|
- 首次增量构建存在旧初始化签名缓存,清理构建产物后解决;连续 UI 回归需要等待测试窗口显示稳定后再模拟登录,修正仅在测试夹具中,业务代码没有新增延时,也没有放宽断言。
|
||||||
|
- 结果:`/private/tmp/suixinkan-deregister-loading-r3-20260828.xcresult`;摘要:`/private/tmp/suixinkan-deregister-loading-r3-20260828-summary.json`;截图:`/private/tmp/suixinkan-deregister-loading-r3-20260828-images/`。
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# 门店身份注销接口实测
|
||||||
|
|
||||||
|
实测时间:2026-08-28 11:39;追加 13:45–13:46 零资产确认(服务端响应时间)。
|
||||||
|
|
||||||
|
环境:`https://api-test.zhifly.cn`。首次 11:39 使用用户明确授权的 iPhone 当前 `store_user` 登录 Token,仅执行 `GET /eligibility` 与 `GET /status`,当时未调用修改接口。后续额外授权的两项零资产确认见第 5 节。请求头与当前 Debug App 一致:`token`、`X-APP-VERSION: 1.3.1`、`X-OS-TYPE: iOS`、JSON Accept/Content-Type。
|
||||||
|
|
||||||
|
以下为真实响应;仅 `store_user_id`、`finance_identity_id` 统一替换为数值 `0` 脱敏,不能把该值用作有效身份。没有保存 Token。
|
||||||
|
|
||||||
|
## 1. 注销条件
|
||||||
|
|
||||||
|
`GET /api/yf-handset-app/account-deregister/eligibility`
|
||||||
|
|
||||||
|
HTTP 200,业务码 `100000`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"can_apply": false,
|
||||||
|
"store_user_id": 0,
|
||||||
|
"finance_identity_id": 0,
|
||||||
|
"wallet_balance_fen": 0,
|
||||||
|
"wallet_balance": "0.00",
|
||||||
|
"points_balance": 0,
|
||||||
|
"wallet_waived": false,
|
||||||
|
"points_waived": false,
|
||||||
|
"unfulfilled_count": 37,
|
||||||
|
"fulfillment_in_progress_count": 0,
|
||||||
|
"risk_end_at": "2026-08-27 15:47:32",
|
||||||
|
"eligible_at": "2026-09-03 15:47:32",
|
||||||
|
"risk_window_hours": 168,
|
||||||
|
"blockers": [
|
||||||
|
{
|
||||||
|
"code": "ORDER_UNFULFILLED",
|
||||||
|
"message": "账户仍有未履约订单或带单",
|
||||||
|
"action": "complete_orders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RISK_WINDOW_NOT_EXPIRED",
|
||||||
|
"message": "最后一笔业务的风险结束时间尚未超过7天",
|
||||||
|
"action": "wait_risk_window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "WALLET_WAIVER_MISSING",
|
||||||
|
"message": "请先确认放弃现金余额",
|
||||||
|
"action": "confirm_wallet_waiver"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "POINTS_WAIVER_MISSING",
|
||||||
|
"message": "请先确认放弃积分",
|
||||||
|
"action": "confirm_points_waiver"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deregister": null
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 11:39:51"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
本次观测到的类型:
|
||||||
|
|
||||||
|
| 字段 | JSON 类型 / 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `can_apply` | 布尔值,服务端是否允许申请 |
|
||||||
|
| `store_user_id`、`finance_identity_id` | 整数 |
|
||||||
|
| `wallet_balance_fen` | 整数,现金余额(分) |
|
||||||
|
| `wallet_balance` | 字符串,金额展示值;本次为 `"0.00"` |
|
||||||
|
| `points_balance` | 整数,积分余额 |
|
||||||
|
| `wallet_waived`、`points_waived` | 独立布尔值,是否已确认放弃 |
|
||||||
|
| `unfulfilled_count`、`fulfillment_in_progress_count` | 整数 |
|
||||||
|
| `risk_end_at`、`eligible_at` | 时间字符串,格式 `yyyy-MM-dd HH:mm:ss`;时区及无历史业务时的空值形式尚未确认 |
|
||||||
|
| `risk_window_hours` | 整数,本次为 `168` |
|
||||||
|
| `blockers` | 对象数组,每项有字符串 `code`、`message`、`action` |
|
||||||
|
| `deregister` | 本次为 `null`,尚未观测非空结构 |
|
||||||
|
|
||||||
|
本次余额与积分均为零,服务端仍返回两项放弃确认缺失。客户端不能因为资产为零就省略确认。当前还有 37 项未履约订单或带单,且风险等待期未结束,不能提交注销。`eligible_at` 是业务风险期截止时间,不是提交申请后的冷静期截止时间;达到该时间也不代表其他条件自动满足。
|
||||||
|
|
||||||
|
## 2. 注销状态
|
||||||
|
|
||||||
|
`GET /api/yf-handset-app/account-deregister/status`
|
||||||
|
|
||||||
|
HTTP 200,业务码 `100000`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"deregister": null
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 11:39:51"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前查询没有返回注销申请。不能据此推断非空申请的字段名称、状态枚举或是否可撤销。
|
||||||
|
|
||||||
|
## 3. 首次只读探测后尚缺的信息(后续进展见第 5、6 节)
|
||||||
|
|
||||||
|
- 草稿、冷静期、阻断、撤销、完成时 `data.deregister` 的非空结构、状态字段和值。
|
||||||
|
- `cooling_until`、`remaining_seconds` 的实际位置及类型。
|
||||||
|
- 无历史业务时风险时间的空值形式、时间字符串所用时区。
|
||||||
|
- 重新登录或选择身份自动撤销的具体接口时机,以及受限 Token 是否允许切换其他身份。
|
||||||
|
|
||||||
|
这些情况需要现有后端响应样例/源码,或在获得明确授权的专用测试身份上验证。本次授权仅限只读查询,不为采样创建、撤销注销申请或确认资产放弃。
|
||||||
|
|
||||||
|
## 4. 后续零资产确认授权(2026-08-28 13:42)
|
||||||
|
|
||||||
|
用户随后明确允许:在测试环境重新核实现金余额与积分均为 0 后,分别调用两项资产放弃确认接口,并查询是否产生草稿。此授权不包含发送短信、提交或撤销注销申请,也不允许非零资产确认。
|
||||||
|
|
||||||
|
已确认 iPhone 11 连接,并读取本 App 的当前会话:没有登录 Token,保存的账号类型为 `photog`,不是旧注销接口要求的 `store_user`。因此此次没有发出任何接口请求或资产确认,设备偏好未修改,本地临时偏好副本已删除。需在测试版 App 登录门店身份后继续;不要为采样登录已有未完成注销申请的身份,以免触发自动撤销。
|
||||||
|
|
||||||
|
## 5. 已完成两项真实零资产确认(2026-08-28 13:45–13:46)
|
||||||
|
|
||||||
|
用户重新登录后,核实当前为有效 `store_user`。每项确认前均重新查询:身份与设备会话一致、现金余额(分和展示金额)与积分均为 0;只访问测试环境,禁止重定向,不自动重试修改请求。仅执行了已授权的 `POST /waivers/wallet`、`POST /waivers/points` 各一次,没有发送短信、提交或撤销注销申请。
|
||||||
|
|
||||||
|
本次身份初始没有未履约/交付处理中记录,`risk_end_at`、`eligible_at` 均为 JSON `null`,阻断仅为两项确认缺失。与第 1 节较早查询的身份情况不同,不能沿用之前的 37 项未履约记录。
|
||||||
|
|
||||||
|
| 阶段 | `wallet_waived` | `points_waived` | `can_apply` | `deregister` |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 确认前 | false | false | false | null |
|
||||||
|
| 现金确认后 | true | false | false | `status: 0` 草稿 |
|
||||||
|
| 积分确认后 | true | true | true | 同一未提交草稿 |
|
||||||
|
|
||||||
|
两个确认请求均 HTTP 200、`code: 100000`,`data` 返回 `asset_type`(分别为 `"wallet"`、`"points"`)、`wallet_balance_fen: 0`、`wallet_balance: "0.00"`、`points_balance: 0` 及两项确认时间。现金确认后 `wallet_waived_at: "2026-08-28 13:45:57"`、`points_waived_at: null`;积分确认后后者变为 `"2026-08-28 13:46:42"`。
|
||||||
|
|
||||||
|
两个 GET 中的 `data.deregister` 都返回以下结构;仅 `id` 脱敏为 0,实际是正整数:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"status": 0,
|
||||||
|
"status_label": "待确认",
|
||||||
|
"reason": "",
|
||||||
|
"apply_time": null,
|
||||||
|
"cooling_until": null,
|
||||||
|
"remaining_seconds": 0,
|
||||||
|
"cancel_time": null,
|
||||||
|
"blocked_code": "",
|
||||||
|
"blocked_reason": "",
|
||||||
|
"completed_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**确认结论:第一次资产确认即产生非空草稿;`status` 为数字,0 对应“待确认”。非空记录不等于已提交,不能一律限制普通业务或后续资产确认。两项确认后即使仍有草稿,`can_apply` 也会变为 true。** `remaining_seconds: 0` 在草稿中存在,不能单独据此认定冷静期已结束或注销已完成。
|
||||||
|
|
||||||
|
已据此修正客户端草稿判断、继续确认/验证和启动核验,并补充 Mock 测试。只将完整且无提交、撤销、阻断或完成字段冲突的 `status: 0` 识别为草稿;未知状态仍不推断。所有本地会话临时副本已删除,未写回设备偏好,未保存 Token。
|
||||||
|
|
||||||
|
仍缺:冷静期、撤销、阻断、完成的实际状态值及对应时间数据;确认过期响应;登录/切换自动撤销的具体时机。继续真实验证需要另行授权短信、提交与撤销操作,当前授权不包含这些操作。
|
||||||
|
|
||||||
|
|
||||||
|
## 6. 短信、申请与立即撤销(2026-08-28 14:16–14:24)
|
||||||
|
|
||||||
|
用户另外授权在切换后的专用测试身份上发送短信、提交申请,获取状态后立即撤销。真机页面核实:现金/积分为0、两项确认已完成、无未履约及风险等待阻断。通过 iPhone 镜像发送短信,用户提供验证码后提交;未将验证码写入源码或文档。短信和申请的原始 POST 响应体没有保存,不能把后续 GET 当作 POST 原文。
|
||||||
|
|
||||||
|
- 申请原因:`API test; cancel immediately`。
|
||||||
|
- 申请时间:`2026-08-28 14:23:40`。
|
||||||
|
- 查询确认:`status: 1`、`status_label: "冷静期中"`,冷静期截止 `2026-09-04 14:23:40`。
|
||||||
|
- 核对当前身份、同一申请及原因后只调用一次 `/cancel`,不自动重试。
|
||||||
|
- 撤销成功时间:`2026-08-28 14:24:04`,返回 `status: 9`、`status_label: "已撤销"`。
|
||||||
|
- `14:24:05` 再次 GET `/status` 与 `/eligibility` 确认已撤销;未留下待注销申请,未执行最终注销。
|
||||||
|
- 本地临时会话副本已删除,未编辑设备偏好,未在文档中保存 Token、手机号或真实身份 ID。
|
||||||
|
|
||||||
|
### 冷静期状态
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"deregister": {
|
||||||
|
"id": 0,
|
||||||
|
"status": 1,
|
||||||
|
"status_label": "冷静期中",
|
||||||
|
"reason": "API test; cancel immediately",
|
||||||
|
"apply_time": "2026-08-28 14:23:40",
|
||||||
|
"cooling_until": "2026-09-04 14:23:40",
|
||||||
|
"remaining_seconds": 604776,
|
||||||
|
"cancel_time": null,
|
||||||
|
"blocked_code": "",
|
||||||
|
"blocked_reason": "",
|
||||||
|
"completed_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 14:24:03"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 撤销响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "注销申请已撤销",
|
||||||
|
"data": {
|
||||||
|
"deregister": {
|
||||||
|
"id": 0,
|
||||||
|
"status": 9,
|
||||||
|
"status_label": "已撤销",
|
||||||
|
"reason": "API test; cancel immediately",
|
||||||
|
"apply_time": "2026-08-28 14:23:40",
|
||||||
|
"cooling_until": "2026-09-04 14:23:40",
|
||||||
|
"remaining_seconds": 0,
|
||||||
|
"cancel_time": "2026-08-28 14:24:04",
|
||||||
|
"blocked_code": "CANCELLED_BY_USER",
|
||||||
|
"blocked_reason": "用户主动撤销注销",
|
||||||
|
"completed_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 14:24:04"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 撤销后的条件
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"can_apply": false,
|
||||||
|
"store_user_id": 0,
|
||||||
|
"finance_identity_id": 0,
|
||||||
|
"wallet_balance_fen": 0,
|
||||||
|
"wallet_balance": "0.00",
|
||||||
|
"points_balance": 0,
|
||||||
|
"wallet_waived": false,
|
||||||
|
"points_waived": false,
|
||||||
|
"unfulfilled_count": 0,
|
||||||
|
"fulfillment_in_progress_count": 0,
|
||||||
|
"risk_end_at": null,
|
||||||
|
"eligible_at": null,
|
||||||
|
"risk_window_hours": 168,
|
||||||
|
"blockers": [
|
||||||
|
{
|
||||||
|
"code": "WALLET_WAIVER_MISSING",
|
||||||
|
"message": "请先确认放弃现金余额",
|
||||||
|
"action": "confirm_wallet_waiver"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "POINTS_WAIVER_MISSING",
|
||||||
|
"message": "请先确认放弃积分",
|
||||||
|
"action": "confirm_points_waiver"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deregister": {
|
||||||
|
"id": 0,
|
||||||
|
"status": 9,
|
||||||
|
"status_label": "已撤销",
|
||||||
|
"reason": "API test; cancel immediately",
|
||||||
|
"apply_time": "2026-08-28 14:23:40",
|
||||||
|
"cooling_until": "2026-09-04 14:23:40",
|
||||||
|
"remaining_seconds": 0,
|
||||||
|
"cancel_time": "2026-08-28 14:24:04",
|
||||||
|
"blocked_code": "CANCELLED_BY_USER",
|
||||||
|
"blocked_reason": "用户主动撤销注销",
|
||||||
|
"completed_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 14:24:05"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
冷静期 GET `/eligibility` 已返回两项确认 false 和缺少确认的两个阻断,撤销后也是如此;冷静期不能因此重新确认资产。撤销记录保留旧冷静期时间;remaining_seconds 为0不等于注销完成。正式完成与终审阻断的状态值、自动撤销和所有身份注销后的登录响应仍未验证。此次授权操作已结束,不继续发送短信或创建申请。
|
||||||
|
|
||||||
|
## 2026-08-28:补查终态契约及新申请准备
|
||||||
|
|
||||||
|
用户随后允许继续申请注销,以尝试获取缺失的终态响应。本节是新的调查记录,不代表已经创建新的申请。
|
||||||
|
|
||||||
|
| 查找范围 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 本机 iOS、Android 参考工程及桌面相关文件 | 未找到旧注销后端的 Controller、Resource、状态枚举或 migration 源码 |
|
||||||
|
| 测试 API 的 `/docs`、`/api/documentation`、`/openapi.json`、`/swagger.json` | HTTP 200,但业务体均为路由不存在,不能当作可用接口文档 |
|
||||||
|
| Gitea 匿名仓库搜索 | 没有返回匹配仓库;不代表私有仓库不存在 |
|
||||||
|
| Chrome 中已有登录的 Gitea 会话 | 可访问当前用户的仓库列表;代码搜索 `StoreUserDeregister`、`deregister` 均未匹配,未取得注销后端源码 |
|
||||||
|
| 已打开的飞书原始文档 | 页面显示最近修改为8月21日;相关规则与本地文档一致,未给出终态数字枚举、完整 JSON 或完成后的鉴权响应 |
|
||||||
|
| 真机当前 App 会话 | 无有效门店登录态,因此没有发起认证查询、短信、资产确认、申请或撤销 |
|
||||||
|
|
||||||
|
当前仍需先在真机登录一个允许最终注销的专用测试门店身份,再读取该身份的条件。若保留申请等待正式完成,需经过服务端7天冷静期;不能修改本机时间来替代服务端等待,也不能为制造终审阻断而擅自修改订单、资产或数据库。按旧文档,重新登录或选中申请中的同一身份会自动撤销申请,等待期间应避免这种操作。
|
||||||
|
|
||||||
|
“终审阻断”需要到期复核发现条件变化,单纯提交并等待不能保证得到该状态。此次只读查找未新增任何终态实测结论。
|
||||||
|
|
||||||
|
## 2026-08-28 15:23:按用户要求改在模拟器准备新申请
|
||||||
|
|
||||||
|
- 用户明确要求操作其已登录的 iPhone Air 模拟器。本次是人工界面操作,不是模拟器单元测试;不改变此前真机测试结论。
|
||||||
|
- 页面当前身份为北大科技园,显示现金0元、积分0;初始只有两项资产确认阻断,上次申请已撤销。
|
||||||
|
- 用户授权分别确认两项零资产并发送验证码。继续操作时,现金已显示确认完成,因此没有重复提交现金确认;随后单独确认零积分。
|
||||||
|
- 页面显示两项资产均已确认、当前条件满足;仍为未提交草稿。
|
||||||
|
- 15:23通过页面发送一次注销验证码,界面提示已发送至当前身份绑定手机号。未保存短信 POST 原始响应,不能把界面提示当作完整接口 JSON。
|
||||||
|
- 停留在验证码输入页,等待用户提供本次验证码;此时尚未提交新申请,也未进入新的冷静期。
|
||||||
|
|
||||||
|
## 2026-08-28 15:27—15:29:新申请已提交并保留
|
||||||
|
|
||||||
|
- 用户提供本次验证码,并在最终弹窗前明确确认:只注销北大科技园身份,保留申请至服务端7天后复核,正式完成不可恢复,不像上次立即撤销。
|
||||||
|
- 15:27:24在模拟器点击一次“确认提交申请”。原因为 `Test account deregistration`;验证码不写入文档或代码。
|
||||||
|
- 页面进入冷静期核验页,显示截止时间 `2026-09-04 15:27:24`(服务端时间)。
|
||||||
|
- 15:28:27只读 GET `/status`、`/eligibility` 均确认 `deregister.status: 1`,`cancel_time`、`completed_at` 均为 null。
|
||||||
|
- 15:29:22以同一现有 Token GET `/api/yf-handset-app/userinfo`,返回 HTTP 200、业务码150015、`data.status: "cooling"`、相同冷静期截止时间;响应没有 `time` 字段。前后 GET `/status` 均成功且仍是同一冷静期记录。
|
||||||
|
- 未重复发送短信、未重复申请、未执行撤销、登录或身份选择。未保留 Token 副本或修改模拟器偏好设置。
|
||||||
|
|
||||||
|
**此时申请仍待注销,与14:24已撤销的旧申请不同;后续已于15:55:36因重新登录自动撤销,见文末15:56记录。** 终审阻断/正式完成未发生;不能把冷静期截止当作已经注销。查询样例已加入[旧接口返回数据说明](门店身份注销旧接口返回数据说明.md)。短信和申请 POST 原始响应体仍未捕获,不能用 GET 代替。
|
||||||
|
|
||||||
|
本轮脱敏证据:
|
||||||
|
|
||||||
|
- `/private/tmp/suixinkan-contract-discovery-20260828/152826-simulator-status-redacted.json`
|
||||||
|
- `/private/tmp/suixinkan-contract-discovery-20260828/152826-simulator-eligibility-redacted.json`
|
||||||
|
- `/private/tmp/suixinkan-contract-discovery-20260828/152921-simulator-userinfo-cooling-redacted.json`
|
||||||
|
- `/private/tmp/suixinkan-contract-discovery-20260828/152921-simulator-status-before-auth-redacted.json`
|
||||||
|
- `/private/tmp/suixinkan-contract-discovery-20260828/152921-simulator-status-after-auth-redacted.json`
|
||||||
|
|
||||||
|
## 2026-08-28 15:45:真实响应回归与源码补查
|
||||||
|
|
||||||
|
- 将本轮150015及对应status响应整理为Swift测试夹具;逐字段对比采样JSON,仅注销记录ID替换为测试值301。
|
||||||
|
- 在iPhone 11上完成全量792项测试:782通过、10项既有失败,无跳过;失败用例集合与原始基线一致。注销相关73项全部通过,包括真实userinfo限制响应经API、通知和路由进入冷静期页面的新增Mock集成测试。
|
||||||
|
- 本轮仅更新测试与文档,未操作模拟器中的真实申请;未变更工程配置、版本号或依赖。
|
||||||
|
- 补查已保存项目列表,未发现相关后端工程;Android工程现有Git主机的HTTP网页根返回空响应,未取得源码。未猜测后端仓库路径、修改服务器或扩展访问权限。
|
||||||
|
- 结果:`/private/tmp/suixinkan-deregister-real-contract-20260828.xcresult`。终态样例仍缺,不能将测试通过作为正式注销完成的证据。
|
||||||
|
|
||||||
|
## 2026-08-28 15:48—15:50:最终只读复核与冷启动尝试
|
||||||
|
|
||||||
|
- 15:48:04查询仍为申请时间15:27:24的冷静期1。
|
||||||
|
- 仅终止并重启iPhone Air中的App进程;未卸载、清空数据、登录或选择身份。工具未能切换至正确模拟器窗口,故冷启动UI没有验证完成。
|
||||||
|
- 15:50:51用原会话再次GET `/status`、`/eligibility`,均返回同一申请的冷静期1,截止时间不变,`remaining_seconds: 603392`,撤销时间与完成时间均为null。
|
||||||
|
- 证据:`/private/tmp/suixinkan-contract-discovery-20260828/155050-simulator-status-redacted.json`及同前缀的`eligibility-redacted.json`。
|
||||||
|
- 未产生新的终态依据,不继续重复轮询或制造业务变化;待服务端到期状态或已有源码可用后再推进。
|
||||||
|
|
||||||
|
## 2026-08-28 15:56:重新登录自动撤销
|
||||||
|
|
||||||
|
用户告知已进入后,只读查询发现15:27:24申请已于15:55:36自动撤销。两次GET均返回status9、CANCELLED_BY_LOGIN、用户重新登录自动撤销的说明,completed_at仍为null。没有调用cancel或重新申请;未采集触发撤销的具体登录请求,因此不能断言具体接口时机。
|
||||||
|
|
||||||
|
证据:/private/tmp/suixinkan-contract-discovery-20260828/155633-simulator-status-redacted.json及同前缀eligibility文件。此前待注销记录的截止时间已失效,不再等待该申请于9月4日完成。用户随后要求UI简化,本轮仅使用Mock测试,不再操作真实注销。
|
||||||
|
|
||||||
|
## 2026-08-28 17:01:验证资产确认接口的false参数
|
||||||
|
|
||||||
|
用户单独授权尝试现金、积分接口传`accepted: false`。使用iPhone 11当前已登录门店身份和测试环境,先只读确认两项标记均为true、金额均为0、状态为未提交草稿0,再分别调用一次两个确认接口。
|
||||||
|
|
||||||
|
- 现金:17:01:14返回HTTP 200、业务码100099、`msg: 请明确确认自愿放弃对应资产`、`data: []`。
|
||||||
|
- 积分:17:01:32返回同样结果。
|
||||||
|
- 每次调用后重新GET查询:`wallet_waived`和`points_waived`仍为true,草稿仍为0,金额未变化,没有进入冷静期。
|
||||||
|
- 结论:当前接口不接受false作为撤回确认;本轮没有调用true、短信、申请、撤销或登录接口。未重试POST。
|
||||||
|
|
||||||
|
完整返回及前后状态见[旧接口返回数据说明4.4节](门店身份注销旧接口返回数据说明.md)。脱敏证据目录:`/private/tmp/suixinkan-waiver-false-20260828/`。
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
# 门店身份注销旧接口返回数据说明
|
||||||
|
|
||||||
|
整理日期:2026-08-28。依据旧接口文档及当天测试环境的真实响应。
|
||||||
|
|
||||||
|
**注销范围:当前 `store_user` 对应的门店用户身份,不是手机号主账号,不影响同手机号的其他身份。**
|
||||||
|
|
||||||
|
示例中的 `store_user_id`、`finance_identity_id` 和注销记录 `id` 均脱敏为 `0`,实际为正整数;脱敏值不能用于请求或业务判断。本文不包含 Token、手机号或姓名。
|
||||||
|
|
||||||
|
## 1. 请求信息与实测范围
|
||||||
|
|
||||||
|
- 测试环境:`https://api-test.zhifly.cn`
|
||||||
|
- 统一路径前缀:`/api/yf-handset-app/account-deregister`
|
||||||
|
- 请求头:`token: <当前身份Token>`、`X-APP-VERSION: 1.3.1`、`X-OS-TYPE: iOS`、JSON Accept/Content-Type。
|
||||||
|
|
||||||
|
| 方法 | 路径后缀 | 用途 | 真实响应覆盖情况 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/eligibility` | 查询注销条件 | 已实测:阻断、两项确认、允许申请、冷静期、撤销后 |
|
||||||
|
| GET | `/status` | 查询注销记录 | 已实测:无记录、待确认草稿、冷静期、已撤销 |
|
||||||
|
| POST | `/waivers/wallet` | 确认放弃现金 | 已实测:零余额确认成功 |
|
||||||
|
| POST | `/waivers/points` | 确认放弃积分 | 已实测:零积分确认成功 |
|
||||||
|
| POST | `/send-sms` | 发送注销验证码 | 真机发送并收到短信;未保存原始响应体 |
|
||||||
|
| POST | `/apply` | 提交注销申请 | 真机提交后 GET 确认冷静期;未保存 POST 原始响应体 |
|
||||||
|
| POST | `/cancel` | 撤销注销申请 | 已实测:撤销成功,并 GET 复核 |
|
||||||
|
|
||||||
|
已按用户分次授权执行两项零资产确认,以及一次“短信 → 提交 → 查询 → 立即撤销”。申请时间为 14:23:40,14:24:04 撤销成功,14:24:05 查询确认已撤销;未保留待注销申请,未执行最终注销。正文不记录验证码。
|
||||||
|
|
||||||
|
**此前申请状态(15:56复核):15:27:24创建的申请已于15:55:36因重新登录自动撤销(9 / CANCELLED_BY_LOGIN)。没有保留这次待注销申请,旧截止时间不再代表有效冷静期。**
|
||||||
|
|
||||||
|
17:01补充实测:当前真机身份查询为未提交草稿0,两项资产已确认,尚未进入冷静期。按用户授权分别尝试`accepted: false`,两条接口均拒绝,确认标记没有重置,见4.4节。
|
||||||
|
|
||||||
|
## 2. 公共响应与业务码
|
||||||
|
|
||||||
|
| 字段 | 已观测 JSON 类型 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `code` | number(整数) | 业务码,成功为 `100000` |
|
||||||
|
| `msg` | string | 响应说明,成功可为 `"success"` 或 `"注销申请已撤销"`,不能固定匹配文案 |
|
||||||
|
| `data` | object;未登录时为 array | 具体业务数据 |
|
||||||
|
| `time` | string | 成功样例中的服务端时间,格式 `yyyy-MM-dd HH:mm:ss`;时区待确认 |
|
||||||
|
|
||||||
|
**HTTP 200 不等于业务成功,必须检查 `code`。**
|
||||||
|
|
||||||
|
| 业务码 | 含义 | 验证情况 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `100000` | 请求成功 | 两个 GET、两个资产确认及撤销响应均观察到 |
|
||||||
|
| `100090` | 未登录 | 不带 Token 查询两个 GET 时实测 |
|
||||||
|
| `100099` | 本次表示未明确同意放弃资产 | 两项确认接口传`accepted: false`时实测,见4.4 |
|
||||||
|
| `150015` | 冷静期身份访问普通业务接口受限 | 15:29使用申请中的身份 GET `/api/yf-handset-app/userinfo` 实测,见5.7 |
|
||||||
|
|
||||||
|
两个 GET 的未登录响应均为 HTTP 200,正文如下,没有 `time` 字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"code":100090,"msg":"未登录","data":[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 注销条件:GET `/eligibility`
|
||||||
|
|
||||||
|
### 3.1 存在阻断时的真实响应
|
||||||
|
|
||||||
|
HTTP 200,以下为服务端 `2026-08-28 11:39:51` 的响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"can_apply": false,
|
||||||
|
"store_user_id": 0,
|
||||||
|
"finance_identity_id": 0,
|
||||||
|
"wallet_balance_fen": 0,
|
||||||
|
"wallet_balance": "0.00",
|
||||||
|
"points_balance": 0,
|
||||||
|
"wallet_waived": false,
|
||||||
|
"points_waived": false,
|
||||||
|
"unfulfilled_count": 37,
|
||||||
|
"fulfillment_in_progress_count": 0,
|
||||||
|
"risk_end_at": "2026-08-27 15:47:32",
|
||||||
|
"eligible_at": "2026-09-03 15:47:32",
|
||||||
|
"risk_window_hours": 168,
|
||||||
|
"blockers": [
|
||||||
|
{
|
||||||
|
"code": "ORDER_UNFULFILLED",
|
||||||
|
"message": "账户仍有未履约订单或带单",
|
||||||
|
"action": "complete_orders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RISK_WINDOW_NOT_EXPIRED",
|
||||||
|
"message": "最后一笔业务的风险结束时间尚未超过7天",
|
||||||
|
"action": "wait_risk_window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "WALLET_WAIVER_MISSING",
|
||||||
|
"message": "请先确认放弃现金余额",
|
||||||
|
"action": "confirm_wallet_waiver"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "POINTS_WAIVER_MISSING",
|
||||||
|
"message": "请先确认放弃积分",
|
||||||
|
"action": "confirm_points_waiver"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deregister": null
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 11:39:51"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 `data` 字段说明
|
||||||
|
|
||||||
|
| 字段 | 已观测 JSON 类型 | 数据信息 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `can_apply` | boolean | 服务端是否允许提交申请 |
|
||||||
|
| `store_user_id` | number(整数) | 门店用户身份 ID,不是门店实体 ID |
|
||||||
|
| `finance_identity_id` | number(整数) | 财务身份 ID |
|
||||||
|
| `wallet_balance_fen` | number(整数) | 现金余额,单位分,适合金额计算 |
|
||||||
|
| `wallet_balance` | string | 现金金额展示值,如 `"0.00"`,不是 JSON 数字 |
|
||||||
|
| `points_balance` | number(整数) | 积分余额 |
|
||||||
|
| `wallet_waived` | boolean | 当前现金余额快照是否已确认放弃 |
|
||||||
|
| `points_waived` | boolean | 当前积分余额快照是否已确认放弃 |
|
||||||
|
| `unfulfilled_count` | number(整数) | 未履约记录数量;本次分别观察到 37 和 0 |
|
||||||
|
| `fulfillment_in_progress_count` | number(整数) | 处理中记录数量,本次为 0;具体统计范围待后端确认 |
|
||||||
|
| `risk_end_at` | string / null | 最后业务风险结束时间 |
|
||||||
|
| `eligible_at` | string / null | 业务风险等待截止时间,**不是注销冷静期截止** |
|
||||||
|
| `risk_window_hours` | number(整数) | 业务风险等待时长,本次为 168 小时 |
|
||||||
|
| `blockers` | array<object> | 全部阻断项;无阻断时为 `[]` |
|
||||||
|
| `deregister` | object / null | 注销记录;草稿结构见第 5 节 |
|
||||||
|
|
||||||
|
时间字符串本次采用 `yyyy-MM-dd HH:mm:ss`。另一个测试身份的两个风险时间均为 null,不能把 null 转成当前时间或自行追加 168 小时等待。
|
||||||
|
|
||||||
|
### 3.3 `blockers[]` 字段与取值
|
||||||
|
|
||||||
|
每项包含三个字符串:`code` 为业务标识,`message` 为展示提示,`action` 为建议动作。应展示全部阻断项。
|
||||||
|
|
||||||
|
| 已实测 `code` | 含义 | 已实测 `action` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ORDER_UNFULFILLED` | 存在未履约订单或带单 | `complete_orders` |
|
||||||
|
| `RISK_WINDOW_NOT_EXPIRED` | 风险等待期未结束 | `wait_risk_window` |
|
||||||
|
| `WALLET_WAIVER_MISSING` | 未确认放弃现金 | `confirm_wallet_waiver` |
|
||||||
|
| `POINTS_WAIVER_MISSING` | 未确认放弃积分 | `confirm_points_waiver` |
|
||||||
|
|
||||||
|
旧文档另列出以下代码,但未实测其完整对象和 `action`:
|
||||||
|
|
||||||
|
| 仅文档列出的代码 | 文档含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `SHARED_FINANCE_IDENTITY` | 与其他启用身份共用财务账本,需人工处理 |
|
||||||
|
| `NEGATIVE_ASSET` | 负余额或负积分 |
|
||||||
|
| `FINANCE_IN_FLIGHT` | 财务流程尚未收口 |
|
||||||
|
| `FULFILLMENT_IN_PROGRESS` | 退款、分账或交付任务处理中 |
|
||||||
|
| `WAIVER_STALE` | 确认后余额变化,需重新确认 |
|
||||||
|
|
||||||
|
### 3.4 另一个身份的连续数据变化
|
||||||
|
|
||||||
|
以下身份现金、积分均为 0,未履约及处理中数量为 0,两个风险时间均为 null。它与 3.1 中有 37 项未履约记录的身份不同。
|
||||||
|
|
||||||
|
| 阶段 | `wallet_waived` | `points_waived` | `can_apply` | 阻断 | `deregister` |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 确认前 | false | false | false | 缺两项确认 | null |
|
||||||
|
| 现金确认后 | true | false | false | 仅缺积分确认 | `status: 0` 草稿 |
|
||||||
|
| 积分确认后 | true | true | true | `[]` | 仍为 `status: 0` 草稿 |
|
||||||
|
| 申请后的冷静期 | false | false | false | 缺两项确认 | `status: 1` 冷静期中 |
|
||||||
|
| 主动撤销后 | false | false | false | 缺两项确认 | `status: 9` 已撤销 |
|
||||||
|
|
||||||
|
**第一次资产确认就会产生非空草稿。两项确认后可以申请,但尚未提交申请,也未进入冷静期。**
|
||||||
|
|
||||||
|
冷静期中两项确认已变回 false,不应按阻断建议再次确认资产。撤销后重新申请需重新核验条件和确认资产;尚未真实测试再次申请。
|
||||||
|
|
||||||
|
## 4. 两项资产确认接口
|
||||||
|
|
||||||
|
现金和积分分别请求,均使用以下 JSON 请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"accepted":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 POST `/waivers/wallet`
|
||||||
|
|
||||||
|
HTTP 200,现金确认成功的真实响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"asset_type": "wallet",
|
||||||
|
"wallet_balance_fen": 0,
|
||||||
|
"wallet_balance": "0.00",
|
||||||
|
"points_balance": 0,
|
||||||
|
"wallet_waived_at": "2026-08-28 13:45:57",
|
||||||
|
"points_waived_at": null
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 13:45:57"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 POST `/waivers/points`
|
||||||
|
|
||||||
|
HTTP 200,积分确认成功的真实响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"asset_type": "points",
|
||||||
|
"wallet_balance_fen": 0,
|
||||||
|
"wallet_balance": "0.00",
|
||||||
|
"points_balance": 0,
|
||||||
|
"wallet_waived_at": "2026-08-28 13:45:57",
|
||||||
|
"points_waived_at": "2026-08-28 13:46:42"
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 13:46:42"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 确认响应字段
|
||||||
|
|
||||||
|
| 字段 | 已观测 JSON 类型 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `asset_type` | string | 本次确认的资产:`wallet` 或 `points` |
|
||||||
|
| `wallet_balance_fen` | number(整数) | 当前现金余额,分 |
|
||||||
|
| `wallet_balance` | string | 当前现金金额展示值 |
|
||||||
|
| `points_balance` | number(整数) | 当前积分余额 |
|
||||||
|
| `wallet_waived_at` | string(本次均非空) | 现金确认时间;其他场景是否可为 null 尚未实测 |
|
||||||
|
| `points_waived_at` | string / null | 积分确认时间;现金确认后仍为 null,积分确认后为时间字符串 |
|
||||||
|
|
||||||
|
零余额、零积分也需分别确认。确认成功后重新查询条件和状态,以最新标记为准。旧文档规定确认绑定余额快照;确认本身不等于提交注销或立即清零资产。
|
||||||
|
|
||||||
|
### 4.4 `accepted: false` 不支持撤回确认(真实测试)
|
||||||
|
|
||||||
|
2026-08-28 17:01,使用iPhone 11当前已登录门店身份,在测试环境分别发送一次:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"accepted":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
前置状态:`status: 0`未提交草稿,`wallet_waived: true`、`points_waived: true`,现金与积分均为0;`apply_time`、`cooling_until`、`completed_at`均为null。
|
||||||
|
|
||||||
|
| 接口 | 服务端响应时间 | HTTP状态 | 业务码 | 结果 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| POST `/waivers/wallet` | 2026-08-28 17:01:14 | 200 | 100099 | 拒绝false,确认标记未变化 |
|
||||||
|
| POST `/waivers/points` | 2026-08-28 17:01:32 | 200 | 100099 | 拒绝false,确认标记未变化 |
|
||||||
|
|
||||||
|
现金接口完整响应如下;积分接口除`time`为`2026-08-28 17:01:32`外,其余字段相同:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100099,
|
||||||
|
"msg": "请明确确认自愿放弃对应资产",
|
||||||
|
"data": [],
|
||||||
|
"time": "2026-08-28 17:01:14"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每次POST后均再次查询`/status`和`/eligibility`:两项确认仍为true,草稿仍为0,金额未变化,`can_apply`仍为true。**当前接口不能通过传false把已确认状态重置为未确认。** 本轮没有发送true、短信、申请或撤销请求,也没有重新登录。`/cancel`能否取消未提交草稿仍未验证,不能由本次结果推断。
|
||||||
|
|
||||||
|
脱敏证据:`/private/tmp/suixinkan-waiver-false-20260828/device-wallet-response.json`、`device-points-response.json`及同目录各自的`before/after-status`、`before/after-eligibility`文件。Token未输出或保存;读取真机会话时的临时副本已删除。
|
||||||
|
|
||||||
|
## 5. 注销状态:GET `/status`
|
||||||
|
|
||||||
|
### 5.1 无记录
|
||||||
|
|
||||||
|
HTTP 200,真实响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"deregister": null
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 11:39:51"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 已确认资产、尚未提交的草稿
|
||||||
|
|
||||||
|
HTTP 200,真实响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"deregister": {
|
||||||
|
"id": 0,
|
||||||
|
"status": 0,
|
||||||
|
"status_label": "待确认",
|
||||||
|
"reason": "",
|
||||||
|
"apply_time": null,
|
||||||
|
"cooling_until": null,
|
||||||
|
"remaining_seconds": 0,
|
||||||
|
"cancel_time": null,
|
||||||
|
"blocked_code": "",
|
||||||
|
"blocked_reason": "",
|
||||||
|
"completed_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 13:46:42"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 `data.deregister` 字段
|
||||||
|
|
||||||
|
| 字段 | 本次实际类型/值 | 含义与待确认事项 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | number(正整数,示例脱敏为 0) | 注销记录 ID,不是门店用户 ID |
|
||||||
|
| `status` | number(整数),`0` / `1` / `9` | 分别为待确认、冷静期中、已撤销;其他值未知 |
|
||||||
|
| `status_label` | string | 服务端状态文案,如 `"冷静期中"`、`"已撤销"` |
|
||||||
|
| `reason` | string | 草稿为空;提交后保留用户输入的原因 |
|
||||||
|
| `apply_time` | string / null | 申请时间,本次 `2026-08-28 14:23:40` |
|
||||||
|
| `cooling_until` | string / null | 冷静期截止,本次 `2026-09-04 14:23:40`;撤销后仍保留 |
|
||||||
|
| `remaining_seconds` | number(整数) | 查询时剩余秒数;草稿、已撤销都为 0,不能据此认定已完成 |
|
||||||
|
| `cancel_time` | string / null | 撤销时间,本次 `2026-08-28 14:24:04` |
|
||||||
|
| `blocked_code` | string | 草稿/冷静期为空;主动撤销为 `CANCELLED_BY_USER` |
|
||||||
|
| `blocked_reason` | string | 草稿/冷静期为空;主动撤销为 `用户主动撤销注销` |
|
||||||
|
| `completed_at` | null | 完成时间;非空类型和格式未实测 |
|
||||||
|
|
||||||
|
同一草稿结构也出现在 `eligibility` 的 `data.deregister` 内。两个 GET 的 `cooling_until`、`remaining_seconds` 位于该记录对象中;不能据此推断 `/apply` 响应的嵌套结构。
|
||||||
|
|
||||||
|
### 5.4 冷静期的真实响应
|
||||||
|
|
||||||
|
GET `/status`,HTTP 200。该结果确认申请已受理,但不是 `/apply` 的原始响应体。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"deregister": {
|
||||||
|
"id": 0,
|
||||||
|
"status": 1,
|
||||||
|
"status_label": "冷静期中",
|
||||||
|
"reason": "API test; cancel immediately",
|
||||||
|
"apply_time": "2026-08-28 14:23:40",
|
||||||
|
"cooling_until": "2026-09-04 14:23:40",
|
||||||
|
"remaining_seconds": 604776,
|
||||||
|
"cancel_time": null,
|
||||||
|
"blocked_code": "",
|
||||||
|
"blocked_reason": "",
|
||||||
|
"completed_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 14:24:03"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 主动撤销的真实响应
|
||||||
|
|
||||||
|
POST `/cancel`,HTTP 200,无业务请求字段。撤销后再次 GET `/status` 返回相同记录,`msg: "success"`、`time: "2026-08-28 14:24:05"`。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "注销申请已撤销",
|
||||||
|
"data": {
|
||||||
|
"deregister": {
|
||||||
|
"id": 0,
|
||||||
|
"status": 9,
|
||||||
|
"status_label": "已撤销",
|
||||||
|
"reason": "API test; cancel immediately",
|
||||||
|
"apply_time": "2026-08-28 14:23:40",
|
||||||
|
"cooling_until": "2026-09-04 14:23:40",
|
||||||
|
"remaining_seconds": 0,
|
||||||
|
"cancel_time": "2026-08-28 14:24:04",
|
||||||
|
"blocked_code": "CANCELLED_BY_USER",
|
||||||
|
"blocked_reason": "用户主动撤销注销",
|
||||||
|
"completed_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 14:24:04"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status: 9` 表示已撤销;保留旧 `apply_time`、`cooling_until` 不代表仍在冷静期。这里的 `CANCELLED_BY_USER` 是撤销原因,不是终审阻断状态。两个 GET 均确认两项资产确认标记变为 false;冷静期时已经是 false,不能断言由撤销动作单独导致。
|
||||||
|
|
||||||
|
### 5.6 新申请保留在冷静期的真实响应
|
||||||
|
|
||||||
|
用户最终确认后,于模拟器点击一次提交;以下为后续 GET `/status` 的真实响应(HTTP 200),不是 POST `/apply` 的响应体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"deregister": {
|
||||||
|
"id": 0,
|
||||||
|
"status": 1,
|
||||||
|
"status_label": "冷静期中",
|
||||||
|
"reason": "Test account deregistration",
|
||||||
|
"apply_time": "2026-08-28 15:27:24",
|
||||||
|
"cooling_until": "2026-09-04 15:27:24",
|
||||||
|
"remaining_seconds": 604736,
|
||||||
|
"cancel_time": null,
|
||||||
|
"blocked_code": "",
|
||||||
|
"blocked_reason": "",
|
||||||
|
"completed_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 15:28:27"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同时 GET `/eligibility` 返回现金0、积分0、两项确认 false,以及缺少资产确认的两个阻断;其 `deregister` 仍为上述冷静期记录。不能因此重新确认资产或重复申请。
|
||||||
|
|
||||||
|
### 5.7 冷静期普通业务限制的真实响应
|
||||||
|
|
||||||
|
15:29以同一身份的现有 Token 只读请求 GET `/api/yf-handset-app/userinfo`,HTTP 200:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 150015,
|
||||||
|
"msg": "账号处于注销冷静期,请先撤销注销后再继续使用",
|
||||||
|
"data": {
|
||||||
|
"status": "cooling",
|
||||||
|
"cooling_until": "2026-09-04 15:27:24",
|
||||||
|
"remaining_seconds": 604681
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
该响应没有 `time`,且 `data.status` 是字符串 `"cooling"`;与 `/status` 中 `data.deregister.status` 的数字1不是同一层级或类型,不能共用数字状态 DTO。
|
||||||
|
|
||||||
|
请求前后 GET `/status` 均成功且保持同一冷静期申请,`cancel_time`、`completed_at` 均为 null。这证明本次普通信息查询受限,但当前 Token 仍可查询注销状态;不代表正式完成后的鉴权规则已验证,也不能外推为全部普通业务接口已逐一验证。
|
||||||
|
|
||||||
|
上述150015和随后status响应已整理为测试夹具(仅记录ID替换为301),并在iPhone 11重放验证业务API、限制通知、状态查询和冷静期页面之间的衔接。此项为Mock集成验证,不是再次请求真实账号或重新申请。
|
||||||
|
|
||||||
|
### 5.8 重新登录自动撤销的真实响应
|
||||||
|
|
||||||
|
用户重新进入后,15:56只读GET确认之前的申请已撤销,HTTP 200:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"deregister": {
|
||||||
|
"id": 0,
|
||||||
|
"status": 9,
|
||||||
|
"status_label": "已撤销",
|
||||||
|
"reason": "Test account deregistration",
|
||||||
|
"apply_time": "2026-08-28 15:27:24",
|
||||||
|
"cooling_until": "2026-09-04 15:27:24",
|
||||||
|
"remaining_seconds": 0,
|
||||||
|
"cancel_time": "2026-08-28 15:55:36",
|
||||||
|
"blocked_code": "CANCELLED_BY_LOGIN",
|
||||||
|
"blocked_reason": "用户重新登录,自动撤销注销",
|
||||||
|
"completed_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"time": "2026-08-28 15:56:34"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CANCELLED_BY_LOGIN与主动撤销的CANCELLED_BY_USER不同,但同为已撤销9。本次未捕获触发撤销的登录请求,不能确定是哪个登录或身份选择接口触发。并未完成正式注销。
|
||||||
|
|
||||||
|
## 6. 尚缺的响应与验证
|
||||||
|
|
||||||
|
| 接口 | 旧文档已知信息 | 尚缺信息 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST `/send-sms` | 已通过真机发送并收到短信 | 原始成功/失败响应、频率限制及业务码 |
|
||||||
|
| POST `/apply` | 请求字段 `sms_code`、`reason`;提交后查到冷静期1 | POST 完整响应层级、验证码错误及重复申请响应 |
|
||||||
|
| POST `/cancel` | 成功体及撤销后9状态已实测 | 失败、重复撤销和终态撤销响应 |
|
||||||
|
|
||||||
|
尚未获得阻断、正式完成的 `/status` 响应,不能猜测数字枚举。时间字符串的时区仍待确认,`completed_at` 非空类型和格式仍未实测。
|
||||||
|
|
||||||
|
已获得登录自动撤销后的真实状态9和CANCELLED_BY_LOGIN原因,但具体触发接口仍未捕获。到期复核、正式完成后的鉴权响应及全部身份注销后的登录响应仍未验证。此前两次申请现均已撤销。
|
||||||
|
|
||||||
|
## 7. 接入注意
|
||||||
|
|
||||||
|
1. 按当前门店用户身份隔离状态,不能按手机号共用状态。
|
||||||
|
2. 展示全部阻断项,不能只使用 `can_apply` 或 HTTP 状态码判断流程。
|
||||||
|
3. 完整的未提交草稿不应阻止第二项资产确认或手机号验证,非空记录不等于已提交。
|
||||||
|
4. `eligible_at` 是提交前的业务风险等待截止;`cooling_until` 是提交后的冷静期截止,两者不能混用。
|
||||||
|
5. `remaining_seconds: 0`、本机时间到期、`can_apply: true` 都不能单独作为注销完成依据。
|
||||||
|
6. 未知状态、字段缺失或请求失败不能默认解释为“没有申请”。
|
||||||
|
7. 已撤销9仍含 `cooling_until`,且 `blocked_code` 非空;不能误显示为仍在冷静期或终审阻断。
|
||||||
|
|
||||||
|
历史探测过程见 [接口实测记录](门店身份注销接口实测.md),客户端进度见 [接入说明](门店身份注销接入说明.md)。
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1020101;
|
CURRENT_PROJECT_VERSION = 1040101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.1;
|
MARKETING_VERSION = 1.4.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -502,7 +502,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1020101;
|
CURRENT_PROJECT_VERSION = 1040101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -526,7 +526,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.1;
|
MARKETING_VERSION = 1.4.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -741,7 +741,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1020101;
|
CURRENT_PROJECT_VERSION = 1040101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -761,7 +761,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.1;
|
MARKETING_VERSION = 1.4.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 应用根页面路由。
|
/// 应用根页面路由。
|
||||||
|
@MainActor
|
||||||
enum AppRouter {
|
enum AppRouter {
|
||||||
|
|
||||||
enum Root {
|
enum Root {
|
||||||
@@ -37,7 +38,8 @@ enum AppRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func setRoot(_ viewController: UIViewController, on window: UIWindow?, animated: Bool) {
|
/// 切换到显式构造的根页面,用于业务进入前的注销状态核验。
|
||||||
|
static func setRoot(_ viewController: UIViewController, on window: UIWindow?, animated: Bool = true) {
|
||||||
guard let window else { return }
|
guard let window else { return }
|
||||||
|
|
||||||
guard animated, let snapshot = window.snapshotView(afterScreenUpdates: true) else {
|
guard animated, let snapshot = window.snapshotView(afterScreenUpdates: true) else {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ final class NetworkServices {
|
|||||||
let orderAPI: OrderAPI
|
let orderAPI: OrderAPI
|
||||||
let homeAPI: HomeAPI
|
let homeAPI: HomeAPI
|
||||||
let paymentAPI: PaymentAPI
|
let paymentAPI: PaymentAPI
|
||||||
|
let offlineCollectionAPI: OfflineCollectionAPI
|
||||||
let taskAPI: TaskAPI
|
let taskAPI: TaskAPI
|
||||||
let inviteAPI: InviteAPI
|
let inviteAPI: InviteAPI
|
||||||
let walletAPI: WalletAPI
|
let walletAPI: WalletAPI
|
||||||
@@ -43,6 +44,7 @@ final class NetworkServices {
|
|||||||
orderAPI = OrderAPI(client: client)
|
orderAPI = OrderAPI(client: client)
|
||||||
homeAPI = HomeAPI(client: client)
|
homeAPI = HomeAPI(client: client)
|
||||||
paymentAPI = PaymentAPI(client: client)
|
paymentAPI = PaymentAPI(client: client)
|
||||||
|
offlineCollectionAPI = OfflineCollectionAPI(client: client)
|
||||||
taskAPI = TaskAPI(client: client)
|
taskAPI = TaskAPI(client: client)
|
||||||
inviteAPI = InviteAPI(client: client)
|
inviteAPI = InviteAPI(client: client)
|
||||||
walletAPI = WalletAPI(client: client)
|
walletAPI = WalletAPI(client: client)
|
||||||
@@ -74,6 +76,7 @@ final class NetworkServices {
|
|||||||
orderAPI = OrderAPI(client: apiClient)
|
orderAPI = OrderAPI(client: apiClient)
|
||||||
homeAPI = HomeAPI(client: apiClient)
|
homeAPI = HomeAPI(client: apiClient)
|
||||||
paymentAPI = PaymentAPI(client: apiClient)
|
paymentAPI = PaymentAPI(client: apiClient)
|
||||||
|
offlineCollectionAPI = OfflineCollectionAPI(client: apiClient)
|
||||||
taskAPI = TaskAPI(client: apiClient)
|
taskAPI = TaskAPI(client: apiClient)
|
||||||
inviteAPI = InviteAPI(client: apiClient)
|
inviteAPI = InviteAPI(client: apiClient)
|
||||||
walletAPI = WalletAPI(client: apiClient)
|
walletAPI = WalletAPI(client: apiClient)
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
if AppStore.shared.session.privacyAgreementAccepted, !AppStore.shared.session.token.isEmpty {
|
if AppStore.shared.session.privacyAgreementAccepted, !AppStore.shared.session.token.isEmpty {
|
||||||
AMapBootstrap.configureIfNeeded()
|
AMapBootstrap.configureIfNeeded()
|
||||||
}
|
}
|
||||||
PushNotificationManager.shared.initializeIfPrivacyAccepted(launchOptions: launchOptions)
|
let pushManager = PushNotificationManager.shared
|
||||||
|
pushManager.initializeIfPrivacyAccepted(launchOptions: launchOptions)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "offline_security_shield_generated.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 161 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "payment_method_alipay_official.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "payment_method_cash_generated.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 218 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "payment_method_wechat_official.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 36 KiB |
@@ -4,6 +4,14 @@
|
|||||||
"filename" : "ai_retouch_template_placeholder.png",
|
"filename" : "ai_retouch_template_placeholder.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "travel_album_before_after.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 4H20V20H12Z" fill="#000000" fill-opacity="0.32"/>
|
||||||
|
<rect x="4" y="4" width="16" height="16" stroke="#000000" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 4V9L15 12L12 15V20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -29,6 +29,12 @@ enum NotificationName {
|
|||||||
/// Token 失效或鉴权失败,需重新登录
|
/// Token 失效或鉴权失败,需重新登录
|
||||||
static let sessionDidExpire = name("sessionDidExpire")
|
static let sessionDidExpire = name("sessionDidExpire")
|
||||||
|
|
||||||
|
/// 当前门店身份受限或申请结果待核实;保留凭证转入只读状态查询。
|
||||||
|
static let storeAccountDeregistrationRestricted = name("storeAccountDeregistrationRestricted")
|
||||||
|
|
||||||
|
/// 当前门店身份的注销申请已被服务端明确受理;先清理登录态,再展示提交结果页。
|
||||||
|
static let storeAccountDeregistrationSubmitted = name("storeAccountDeregistrationSubmitted")
|
||||||
|
|
||||||
// MARK: - Scenic
|
// MARK: - Scenic
|
||||||
|
|
||||||
/// 当前景区切换
|
/// 当前景区切换
|
||||||
@@ -68,6 +74,15 @@ enum NotificationName {
|
|||||||
/// `Notification.userInfo` 字典键的统一入口。
|
/// `Notification.userInfo` 字典键的统一入口。
|
||||||
enum NotificationUserInfoKey {
|
enum NotificationUserInfoKey {
|
||||||
|
|
||||||
|
/// 产生注销限制错误的原请求凭证,仅在内存中匹配当前会话,禁止记录日志。
|
||||||
|
static let deregistrationRequestToken = "deregistrationRequestToken"
|
||||||
|
|
||||||
|
/// 注销申请提交时冻结的身份展示名称,不包含身份凭证。
|
||||||
|
static let deregistrationIdentityName = "deregistrationIdentityName"
|
||||||
|
|
||||||
|
/// 注销申请明确受理后查询到的服务端冷静期截止时间。
|
||||||
|
static let deregistrationCoolingUntil = "deregistrationCoolingUntil"
|
||||||
|
|
||||||
static let scenicId = "scenicId"
|
static let scenicId = "scenicId"
|
||||||
static let scenicName = "scenicName"
|
static let scenicName = "scenicName"
|
||||||
static let orderId = "orderId"
|
static let orderId = "orderId"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ final class APIClient {
|
|||||||
private let session: URLSessionProtocol
|
private let session: URLSessionProtocol
|
||||||
private let encoder: JSONEncoder
|
private let encoder: JSONEncoder
|
||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
|
private let notificationCenter: NotificationCenter
|
||||||
private var authTokenProvider: (() -> String?)?
|
private var authTokenProvider: (() -> String?)?
|
||||||
|
|
||||||
private let environment: APIEnvironment
|
private let environment: APIEnvironment
|
||||||
@@ -32,12 +33,14 @@ final class APIClient {
|
|||||||
encoder: JSONEncoder = JSONEncoder(),
|
encoder: JSONEncoder = JSONEncoder(),
|
||||||
decoder: JSONDecoder = JSONDecoder(),
|
decoder: JSONDecoder = JSONDecoder(),
|
||||||
appVersion: String = AppClientInfo.appVersion(),
|
appVersion: String = AppClientInfo.appVersion(),
|
||||||
osType: String = AppClientInfo.osType
|
osType: String = AppClientInfo.osType,
|
||||||
|
notificationCenter: NotificationCenter = .default
|
||||||
) {
|
) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.session = session
|
self.session = session
|
||||||
self.encoder = encoder
|
self.encoder = encoder
|
||||||
self.decoder = decoder
|
self.decoder = decoder
|
||||||
|
self.notificationCenter = notificationCenter
|
||||||
self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0"
|
self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0"
|
||||||
self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType
|
self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType
|
||||||
}
|
}
|
||||||
@@ -61,7 +64,7 @@ final class APIClient {
|
|||||||
tokenOverride: String? = nil
|
tokenOverride: String? = nil
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride)
|
let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride)
|
||||||
logRequest(request)
|
logRequest(request, includeBody: apiRequest.logsPayload)
|
||||||
|
|
||||||
let data: Data
|
let data: Data
|
||||||
let response: URLResponse
|
let response: URLResponse
|
||||||
@@ -80,12 +83,12 @@ final class APIClient {
|
|||||||
throw APIError.networkFailed(error.localizedDescription)
|
throw APIError.networkFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
logResponse(for: request, response: response, data: data)
|
logResponse(for: request, response: response, data: data, includeBody: apiRequest.logsPayload)
|
||||||
do {
|
do {
|
||||||
try validateHTTPResponse(response, data: data)
|
try validateHTTPResponse(response, data: data)
|
||||||
return try decodeEnvelope(Response.self, from: data)
|
return try decodeEnvelope(Response.self, from: data)
|
||||||
} catch let error as APIError {
|
} catch let error as APIError {
|
||||||
notifySessionExpiredIfNeeded(for: error)
|
notifySessionErrorIfNeeded(for: error, request: request)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +144,7 @@ final class APIClient {
|
|||||||
try validateHTTPResponse(response, data: data)
|
try validateHTTPResponse(response, data: data)
|
||||||
return try decodeEnvelope(Response.self, from: data)
|
return try decodeEnvelope(Response.self, from: data)
|
||||||
} catch let error as APIError {
|
} catch let error as APIError {
|
||||||
notifySessionExpiredIfNeeded(for: error)
|
notifySessionErrorIfNeeded(for: error, request: request)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +215,7 @@ final class APIClient {
|
|||||||
try validateHTTPResponse(response, data: responseData)
|
try validateHTTPResponse(response, data: responseData)
|
||||||
return try decodeEnvelope(Response.self, from: responseData)
|
return try decodeEnvelope(Response.self, from: responseData)
|
||||||
} catch let error as APIError {
|
} catch let error as APIError {
|
||||||
notifySessionExpiredIfNeeded(for: error)
|
notifySessionErrorIfNeeded(for: error, request: request)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +270,9 @@ final class APIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard 200 ..< 300 ~= httpResponse.statusCode else {
|
guard 200 ..< 300 ~= httpResponse.statusCode else {
|
||||||
|
if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data), envelope.code == 150015 {
|
||||||
|
throw APIError.serverCode(150015, parseHTTPErrorMessage(data: data))
|
||||||
|
}
|
||||||
throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data))
|
throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,10 +301,19 @@ final class APIClient {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Token 失效时广播 sessionDidExpire,触发全局登出。
|
/// 区分身份注销受限与凭证失效;150015 附带原请求 Token,便于忽略切换身份后的旧错误。
|
||||||
private func notifySessionExpiredIfNeeded(for error: APIError) {
|
private func notifySessionErrorIfNeeded(for error: APIError, request: URLRequest) {
|
||||||
|
if case .serverCode(150015, _) = error {
|
||||||
|
guard let token = request.value(forHTTPHeaderField: "token"), !token.isEmpty else { return }
|
||||||
|
notificationCenter.post(
|
||||||
|
name: NotificationName.storeAccountDeregistrationRestricted,
|
||||||
|
object: nil,
|
||||||
|
userInfo: [NotificationUserInfoKey.deregistrationRequestToken: token]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard APIError.isAuthenticationExpired(error) else { return }
|
guard APIError.isAuthenticationExpired(error) else { return }
|
||||||
NotificationCenter.default.post(name: NotificationName.sessionDidExpire, object: nil)
|
notificationCenter.post(name: NotificationName.sessionDidExpire, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。
|
/// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。
|
||||||
@@ -341,7 +356,7 @@ final class APIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 在 Debug 环境打印请求信息(含 GET 查询参数与 POST 请求体,对齐 Android Ktor `LogLevel.BODY`)。
|
/// 在 Debug 环境打印请求信息(含 GET 查询参数与 POST 请求体,对齐 Android Ktor `LogLevel.BODY`)。
|
||||||
private func logRequest(_ request: URLRequest) {
|
private func logRequest(_ request: URLRequest, includeBody: Bool = true) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let method = request.httpMethod ?? "REQUEST"
|
let method = request.httpMethod ?? "REQUEST"
|
||||||
let url = request.url?.absoluteString ?? "<invalid url>"
|
let url = request.url?.absoluteString ?? "<invalid url>"
|
||||||
@@ -359,7 +374,7 @@ final class APIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let contentType = request.value(forHTTPHeaderField: "Content-Type")
|
let contentType = request.value(forHTTPHeaderField: "Content-Type")
|
||||||
if let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) {
|
if includeBody, let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) {
|
||||||
lines.append("body:\n\(body)")
|
lines.append("body:\n\(body)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,12 +383,12 @@ final class APIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 在 Debug 环境打印响应状态和响应体。
|
/// 在 Debug 环境打印响应状态和响应体。
|
||||||
private func logResponse(for request: URLRequest, response: URLResponse, data: Data) {
|
private func logResponse(for request: URLRequest, response: URLResponse, data: Data, includeBody: Bool = true) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let method = request.httpMethod ?? "REQUEST"
|
let method = request.httpMethod ?? "REQUEST"
|
||||||
let url = request.url?.absoluteString ?? "<invalid url>"
|
let url = request.url?.absoluteString ?? "<invalid url>"
|
||||||
let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown"
|
let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown"
|
||||||
let body = Self.debugResponseBody(from: data)
|
let body = includeBody ? Self.debugResponseBody(from: data) : "<sensitive payload omitted>"
|
||||||
print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)")
|
print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ nonisolated struct APIRequest<Response: Decodable> {
|
|||||||
var queryItems: [URLQueryItem]
|
var queryItems: [URLQueryItem]
|
||||||
var headers: [String: String]
|
var headers: [String: String]
|
||||||
var body: AnyEncodable?
|
var body: AnyEncodable?
|
||||||
|
/// 敏感请求关闭正文日志,避免验证码和注销资产进入调试日志。
|
||||||
|
var logsPayload: Bool
|
||||||
|
|
||||||
/// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。
|
/// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。
|
||||||
init<Body: Encodable>(
|
init<Body: Encodable>(
|
||||||
@@ -27,13 +29,15 @@ nonisolated struct APIRequest<Response: Decodable> {
|
|||||||
path: String,
|
path: String,
|
||||||
queryItems: [URLQueryItem] = [],
|
queryItems: [URLQueryItem] = [],
|
||||||
headers: [String: String] = [:],
|
headers: [String: String] = [:],
|
||||||
body: Body? = Optional<EmptyPayload>.none
|
body: Body? = Optional<EmptyPayload>.none,
|
||||||
|
logsPayload: Bool = true
|
||||||
) {
|
) {
|
||||||
self.method = method
|
self.method = method
|
||||||
self.path = path
|
self.path = path
|
||||||
self.queryItems = queryItems
|
self.queryItems = queryItems
|
||||||
self.headers = headers
|
self.headers = headers
|
||||||
self.body = body.map(AnyEncodable.init)
|
self.body = body.map(AnyEncodable.init)
|
||||||
|
self.logsPayload = logsPayload
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,25 +5,32 @@ enum PushDestination: Sendable, Equatable {
|
|||||||
case paymentRecord
|
case paymentRecord
|
||||||
case paymentDetails
|
case paymentDetails
|
||||||
case messageCenter
|
case messageCenter
|
||||||
|
case aiRetouchTaskList
|
||||||
|
case aiRetouchTaskDetail(batchId: Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。
|
/// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。
|
||||||
struct PushPayload: Sendable, Equatable {
|
struct PushPayload: Sendable, Equatable {
|
||||||
private let type: String
|
private let type: String
|
||||||
|
private let aiRetouchBatchId: Int?
|
||||||
|
|
||||||
/// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。
|
/// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。
|
||||||
nonisolated init(userInfo: [AnyHashable: Any]) {
|
nonisolated init(userInfo: [AnyHashable: Any]) {
|
||||||
type = Self.extractType(from: userInfo)
|
type = Self.extractType(from: userInfo)
|
||||||
|
aiRetouchBatchId = Self.extractBatchId(from: userInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从可发送的字符串字典创建 payload,仅保留业务消息类型。
|
/// 从可发送的字符串字典创建 payload,仅保留业务消息类型。
|
||||||
nonisolated init(values: [String: String]) {
|
nonisolated init(values: [String: String]) {
|
||||||
type = Self.normalizedType(values["type"])
|
type = Self.normalizedType(values["type"])
|
||||||
|
aiRetouchBatchId = Int(values["ai_retouch_batch_id"] ?? "").flatMap { $0 > 0 ? $0 : nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 可发送的规范化字段快照。
|
/// 可发送的规范化字段快照。
|
||||||
nonisolated var normalizedValues: [String: String] {
|
nonisolated var normalizedValues: [String: String] {
|
||||||
type.isEmpty ? [:] : ["type": type]
|
var values = type.isEmpty ? [:] : ["type": type]
|
||||||
|
if let aiRetouchBatchId { values["ai_retouch_batch_id"] = String(aiRetouchBatchId) }
|
||||||
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 仅根据后端业务消息类型解析目标页面。
|
/// 仅根据后端业务消息类型解析目标页面。
|
||||||
@@ -33,6 +40,11 @@ struct PushPayload: Sendable, Equatable {
|
|||||||
return .paymentRecord
|
return .paymentRecord
|
||||||
case "6":
|
case "6":
|
||||||
return .paymentDetails
|
return .paymentDetails
|
||||||
|
case "14":
|
||||||
|
if let aiRetouchBatchId, aiRetouchBatchId > 0 {
|
||||||
|
return .aiRetouchTaskDetail(batchId: aiRetouchBatchId)
|
||||||
|
}
|
||||||
|
return .aiRetouchTaskList
|
||||||
default:
|
default:
|
||||||
return .messageCenter
|
return .messageCenter
|
||||||
}
|
}
|
||||||
@@ -80,6 +92,50 @@ struct PushPayload: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated static func extractBatchId(
|
||||||
|
from dictionary: [AnyHashable: Any],
|
||||||
|
depth: Int = 0
|
||||||
|
) -> Int? {
|
||||||
|
guard depth <= 5 else { return nil }
|
||||||
|
if let value = normalizedPositiveInt(dictionary["ai_retouch_batch_id"]) { return value }
|
||||||
|
|
||||||
|
if let data = dictionary["data"] {
|
||||||
|
if let nested = data as? [AnyHashable: Any],
|
||||||
|
let value = extractBatchId(from: nested, depth: depth + 1) { return value }
|
||||||
|
if let text = data as? String,
|
||||||
|
let nested = jsonDictionary(text),
|
||||||
|
let value = extractBatchId(from: nested, depth: depth + 1) { return value }
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in nestedTypeContainerKeys {
|
||||||
|
guard let value = dictionary[key] else { continue }
|
||||||
|
if let nested = value as? [AnyHashable: Any],
|
||||||
|
let batchId = extractBatchId(from: nested, depth: depth + 1) { return batchId }
|
||||||
|
if let text = value as? String,
|
||||||
|
let nested = jsonDictionary(text),
|
||||||
|
let batchId = extractBatchId(from: nested, depth: depth + 1) { return batchId }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func normalizedPositiveInt(_ value: Any?) -> Int? {
|
||||||
|
let parsed: Int?
|
||||||
|
switch value {
|
||||||
|
case let number as NSNumber: parsed = number.intValue
|
||||||
|
case let string as String: parsed = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
default: parsed = nil
|
||||||
|
}
|
||||||
|
return parsed.flatMap { $0 > 0 ? $0 : nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func jsonDictionary(_ text: String) -> [AnyHashable: Any]? {
|
||||||
|
guard let data = text.data(using: .utf8),
|
||||||
|
let object = try? JSONSerialization.jsonObject(with: data),
|
||||||
|
let dictionary = object as? [String: Any]
|
||||||
|
else { return nil }
|
||||||
|
return Dictionary(uniqueKeysWithValues: dictionary.map { (AnyHashable($0.key), $0.value) })
|
||||||
|
}
|
||||||
|
|
||||||
private nonisolated static let nestedTypeContainerKeys = [
|
private nonisolated static let nestedTypeContainerKeys = [
|
||||||
"extras", "extra", "JMessageExtra", "n_extras",
|
"extras", "extra", "JMessageExtra", "n_extras",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ final class PushNotificationManager: NSObject {
|
|||||||
private var isInitialized = false
|
private var isInitialized = false
|
||||||
private var didRequestAuthorization = false
|
private var didRequestAuthorization = false
|
||||||
private var uploadTask: Task<Void, Never>?
|
private var uploadTask: Task<Void, Never>?
|
||||||
|
private var uploadAttemptID: UUID?
|
||||||
|
private var isAccountBindingSuspended = false
|
||||||
private var queuedForcedUpload = false
|
private var queuedForcedUpload = false
|
||||||
private var isFetchingRegistrationID = false
|
private var isFetchingRegistrationID = false
|
||||||
private var queuedForcedFetch = false
|
private var queuedForcedFetch = false
|
||||||
@@ -161,7 +163,7 @@ final class PushNotificationManager: NSObject {
|
|||||||
/// 登录成功后请求通知权限,并强制绑定当前账号。
|
/// 登录成功后请求通知权限,并强制绑定当前账号。
|
||||||
func handleLoginCompleted() {
|
func handleLoginCompleted() {
|
||||||
initializeIfPrivacyAccepted()
|
initializeIfPrivacyAccepted()
|
||||||
guard isInitialized, appStore.session.isLoggedIn else { return }
|
guard !isAccountBindingSuspended, isInitialized, appStore.session.isLoggedIn else { return }
|
||||||
if !didRequestAuthorization {
|
if !didRequestAuthorization {
|
||||||
didRequestAuthorization = true
|
didRequestAuthorization = true
|
||||||
sdk.requestAuthorization(delegate: self)
|
sdk.requestAuthorization(delegate: self)
|
||||||
@@ -171,7 +173,7 @@ final class PushNotificationManager: NSObject {
|
|||||||
|
|
||||||
/// 账号切换后把同一设备重新绑定到新的业务账号。
|
/// 账号切换后把同一设备重新绑定到新的业务账号。
|
||||||
func handleAccountSwitched() {
|
func handleAccountSwitched() {
|
||||||
guard appStore.session.isLoggedIn else { return }
|
guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return }
|
||||||
bindCurrentAccount()
|
bindCurrentAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,11 +181,22 @@ final class PushNotificationManager: NSObject {
|
|||||||
func handleLogout() {
|
func handleLogout() {
|
||||||
uploadTask?.cancel()
|
uploadTask?.cancel()
|
||||||
uploadTask = nil
|
uploadTask = nil
|
||||||
|
uploadAttemptID = nil
|
||||||
queuedForcedUpload = false
|
queuedForcedUpload = false
|
||||||
router.resetPendingRoute()
|
router.resetPendingRoute()
|
||||||
Task { await updateApplicationIconBadgeCount(0) }
|
Task { await updateApplicationIconBadgeCount(0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 注销核验或受限期间暂停业务账号绑定,保留 Token、Registration ID 与待处理通知。
|
||||||
|
func setAccountBindingSuspended(_ suspended: Bool) {
|
||||||
|
isAccountBindingSuspended = suspended
|
||||||
|
guard suspended else { return }
|
||||||
|
uploadTask?.cancel()
|
||||||
|
uploadTask = nil
|
||||||
|
uploadAttemptID = nil
|
||||||
|
queuedForcedUpload = false
|
||||||
|
}
|
||||||
|
|
||||||
/// 将桌面 App Icon 角标更新为最新未读消息数量。
|
/// 将桌面 App Icon 角标更新为最新未读消息数量。
|
||||||
func updateApplicationIconBadgeCount(_ count: Int) async {
|
func updateApplicationIconBadgeCount(_ count: Int) async {
|
||||||
await applicationIconBadgeSetter.setBadgeCount(max(count, 0))
|
await applicationIconBadgeSetter.setBadgeCount(max(count, 0))
|
||||||
@@ -191,13 +204,14 @@ final class PushNotificationManager: NSObject {
|
|||||||
|
|
||||||
/// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。
|
/// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。
|
||||||
func retryPendingRegistrationUpload() {
|
func retryPendingRegistrationUpload() {
|
||||||
guard appStore.session.isLoggedIn else { return }
|
guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return }
|
||||||
uploadCachedRegistrationID(force: false)
|
uploadCachedRegistrationID(force: false)
|
||||||
refreshRegistrationID(forceUpload: false)
|
refreshRegistrationID(forceUpload: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 登录根页面建立后继续执行通知点击暂存的路由。
|
/// 登录根页面建立后继续执行通知点击暂存的路由。
|
||||||
func routePendingNotificationIfPossible() {
|
func routePendingNotificationIfPossible() {
|
||||||
|
guard !isAccountBindingSuspended else { return }
|
||||||
router.routePendingIfPossible()
|
router.routePendingIfPossible()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +337,7 @@ final class PushNotificationManager: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func upload(registrationID: String, force: Bool) {
|
private func upload(registrationID: String, force: Bool) {
|
||||||
guard appStore.session.isLoggedIn,
|
guard !isAccountBindingSuspended, appStore.session.isLoggedIn,
|
||||||
let uploadedKey = appStore.session.accountScopedKey(Key.uploadedRegistrationIDSuffix)
|
let uploadedKey = appStore.session.accountScopedKey(Key.uploadedRegistrationIDSuffix)
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
@@ -336,12 +350,15 @@ final class PushNotificationManager: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let accountScope = appStore.session.accountCachePrefix
|
let accountScope = appStore.session.accountCachePrefix
|
||||||
|
let attemptID = UUID()
|
||||||
|
uploadAttemptID = attemptID
|
||||||
uploadTask = Task { [weak self] in
|
uploadTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
var succeeded = false
|
var succeeded = false
|
||||||
do {
|
do {
|
||||||
try await self.api.registerJPushID(registrationID)
|
try await self.api.registerJPushID(registrationID)
|
||||||
if self.appStore.session.accountCachePrefix == accountScope {
|
if self.uploadAttemptID == attemptID, !self.isAccountBindingSuspended,
|
||||||
|
self.appStore.session.accountCachePrefix == accountScope {
|
||||||
self.defaults.set(registrationID, forKey: uploadedKey)
|
self.defaults.set(registrationID, forKey: uploadedKey)
|
||||||
}
|
}
|
||||||
succeeded = true
|
succeeded = true
|
||||||
@@ -353,7 +370,9 @@ final class PushNotificationManager: NSObject {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard self.uploadAttemptID == attemptID else { return }
|
||||||
self.uploadTask = nil
|
self.uploadTask = nil
|
||||||
|
self.uploadAttemptID = nil
|
||||||
let shouldForceAgain = self.queuedForcedUpload
|
let shouldForceAgain = self.queuedForcedUpload
|
||||||
self.queuedForcedUpload = false
|
self.queuedForcedUpload = false
|
||||||
if succeeded, shouldForceAgain {
|
if succeeded, shouldForceAgain {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 门店身份注销依赖;网络服务在主线程协调,ViewModel 自身不绑定 MainActor。
|
||||||
|
@MainActor
|
||||||
|
protocol StoreAccountDeregistrationStatusServing {
|
||||||
|
/// 只读查询当前注销记录,供进入普通业务前核验。
|
||||||
|
func status() async throws -> StoreAccountDeregistrationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完整注销服务,在状态查询之外提供条件、确认和申请操作。
|
||||||
|
@MainActor
|
||||||
|
protocol StoreAccountDeregistrationServing: StoreAccountDeregistrationStatusServing {
|
||||||
|
/// 获取资产与全部注销条件。
|
||||||
|
func eligibility() async throws -> StoreAccountDeregistrationEligibility
|
||||||
|
/// 分别确认现金余额。
|
||||||
|
func waiveWallet() async throws
|
||||||
|
/// 分别确认积分。
|
||||||
|
func waivePoints() async throws
|
||||||
|
/// 向当前身份的绑定手机号发送短信。
|
||||||
|
func sendSMS() async throws
|
||||||
|
/// 提交用户输入的验证码与注销原因。
|
||||||
|
func apply(smsCode: String, reason: String) async throws
|
||||||
|
/// 服务端判定是否允许撤销,不按本机截止时间判断。
|
||||||
|
func cancel() async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旧门店身份注销接口请求层;仅使用提供的 account-deregister 路径和请求字段。
|
||||||
|
@MainActor
|
||||||
|
final class StoreAccountDeregistrationAPI: StoreAccountDeregistrationServing {
|
||||||
|
private let client: APIClient
|
||||||
|
private let identity: StoreAccountDeregistrationIdentity
|
||||||
|
private let isCurrentIdentity: @MainActor () -> Bool
|
||||||
|
private let basePath = "/api/yf-handset-app/account-deregister"
|
||||||
|
|
||||||
|
/// 注入网络客户端、冻结的门店身份和当前会话校验,便于隔离真实与测试请求。
|
||||||
|
init(
|
||||||
|
client: APIClient,
|
||||||
|
identity: StoreAccountDeregistrationIdentity,
|
||||||
|
isCurrentIdentity: @escaping @MainActor () -> Bool
|
||||||
|
) {
|
||||||
|
self.client = client
|
||||||
|
self.identity = identity
|
||||||
|
self.isCurrentIdentity = isCurrentIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询当前门店身份的资产、风险期和全部阻断项。
|
||||||
|
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
|
||||||
|
let value: StoreAccountDeregistrationEligibility = try await send(APIRequest(method: .get, path: basePath + "/eligibility"))
|
||||||
|
guard String(value.storeUserID) == identity.userID else {
|
||||||
|
throw StoreAccountDeregistrationError.sessionChanged
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调用旧现金确认接口;与积分接口的顺序协调由ViewModel负责。
|
||||||
|
func waiveWallet() async throws {
|
||||||
|
let _: EmptyPayload = try await send(APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: basePath + "/waivers/wallet",
|
||||||
|
body: StoreAccountDeregistrationWaiverRequest()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调用旧积分确认接口;与现金接口的顺序协调由ViewModel负责。
|
||||||
|
func waivePoints() async throws {
|
||||||
|
let _: EmptyPayload = try await send(APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: basePath + "/waivers/points",
|
||||||
|
body: StoreAccountDeregistrationWaiverRequest()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 向当前门店身份绑定手机号发送短信;收件人由后端从 Token 确定。
|
||||||
|
func sendSMS() async throws {
|
||||||
|
let _: EmptyPayload = try await send(APIRequest(method: .post, path: basePath + "/send-sms"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用用户输入的验证码和原因提交申请;成功后需查询服务端状态。
|
||||||
|
func apply(smsCode: String, reason: String) async throws {
|
||||||
|
let _: EmptyPayload = try await send(APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: basePath + "/apply",
|
||||||
|
body: StoreAccountDeregistrationApplyRequest(smsCode: smsCode, reason: reason)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询当前身份的草稿、冷静期、撤销、阻断或完成状态,不触发重新登录。
|
||||||
|
func status() async throws -> StoreAccountDeregistrationStatus {
|
||||||
|
try await send(APIRequest(method: .get, path: basePath + "/status"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 正式完成前由用户主动撤销;是否可撤销始终由服务端判定。
|
||||||
|
func cancel() async throws {
|
||||||
|
let _: EmptyPayload = try await send(APIRequest(method: .post, path: basePath + "/cancel"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send<Response: Decodable>(_ request: APIRequest<Response>) async throws -> Response {
|
||||||
|
guard isCurrentIdentity() else { throw StoreAccountDeregistrationError.sessionChanged }
|
||||||
|
var sensitiveRequest = request
|
||||||
|
sensitiveRequest.logsPayload = false
|
||||||
|
let response = try await client.send(sensitiveRequest, tokenOverride: identity.token)
|
||||||
|
guard isCurrentIdentity() else { throw StoreAccountDeregistrationError.sessionChanged }
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 旧注销接口的门店身份上下文;冻结身份与凭证,防止切换账号后误操作另一身份。
|
||||||
|
struct StoreAccountDeregistrationIdentity: Equatable, Sendable {
|
||||||
|
/// 当前门店用户身份 ID,对应 ss_store_user.id,不是门店实体 ID。
|
||||||
|
let userID: String
|
||||||
|
/// 进入流程时的门店身份 Token,仅用于接口鉴权。
|
||||||
|
let token: String
|
||||||
|
/// 用于确认页展示当前正在注销的门店身份。
|
||||||
|
let displayName: String
|
||||||
|
|
||||||
|
/// 从当前业务会话构造上下文;景区身份、未知身份和未登录状态均不可发起注销。
|
||||||
|
init(session: AppSessionStore) throws {
|
||||||
|
guard session.accountType == .storeUser else {
|
||||||
|
throw StoreAccountDeregistrationError.unsupportedIdentity
|
||||||
|
}
|
||||||
|
let id = session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let token = session.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let numericID = Int(id), numericID > 0, !token.isEmpty else {
|
||||||
|
throw StoreAccountDeregistrationError.invalidSession
|
||||||
|
}
|
||||||
|
userID = id
|
||||||
|
self.token = token
|
||||||
|
let name = session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
displayName = name.isEmpty ? "当前门店身份" : name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验操作前后仍为同一个身份和同一份登录凭证。
|
||||||
|
func matches(session: AppSessionStore) -> Bool {
|
||||||
|
session.accountType == .storeUser
|
||||||
|
&& session.userId.trimmingCharacters(in: .whitespacesAndNewlines) == userID
|
||||||
|
&& session.token.trimmingCharacters(in: .whitespacesAndNewlines) == token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 门店身份注销的本地安全校验错误,不替代后端业务错误码。
|
||||||
|
enum StoreAccountDeregistrationError: LocalizedError, Equatable {
|
||||||
|
case unsupportedIdentity
|
||||||
|
case invalidSession
|
||||||
|
case sessionChanged
|
||||||
|
|
||||||
|
/// 可直接展示给用户的错误说明。
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsupportedIdentity:
|
||||||
|
"当前身份暂不支持注销"
|
||||||
|
case .invalidSession:
|
||||||
|
"当前门店身份信息不完整,请重新登录"
|
||||||
|
case .sessionChanged:
|
||||||
|
"登录状态或当前身份已变化,请重新进入注销页面"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旧接口的余额或积分放弃确认请求;两种资产必须分别调用对应接口。
|
||||||
|
struct StoreAccountDeregistrationWaiverRequest: Encodable, Sendable {
|
||||||
|
/// 用户明确接受后才创建该请求,固定提交 true。
|
||||||
|
let accepted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旧接口提交注销申请的请求体,不附加新协议中的主账号 ID 或核验参数。
|
||||||
|
struct StoreAccountDeregistrationApplyRequest: Encodable, Sendable {
|
||||||
|
/// 用户实际收到的短信验证码,不使用本地固定验证码。
|
||||||
|
let smsCode: String
|
||||||
|
/// 用户确认的注销原因。
|
||||||
|
let reason: String
|
||||||
|
|
||||||
|
/// 对齐旧文档的请求字段。
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case smsCode = "sms_code"
|
||||||
|
case reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未定义完整响应字段的旧接口原始 JSON;保留信息,待实际契约确认后映射为业务模型。
|
||||||
|
/// 此类型不推断状态、余额或确认结果,也不应直接用于决定是否允许注销。
|
||||||
|
indirect enum StoreAccountDeregistrationJSON: Decodable, Equatable, Sendable {
|
||||||
|
case object([String: StoreAccountDeregistrationJSON])
|
||||||
|
case array([StoreAccountDeregistrationJSON])
|
||||||
|
case string(String)
|
||||||
|
case number(Decimal)
|
||||||
|
case bool(Bool)
|
||||||
|
case null
|
||||||
|
|
||||||
|
/// 无损保留 JSON 结构,金额数值使用 Decimal,避免浮点误差。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if container.decodeNil() {
|
||||||
|
self = .null
|
||||||
|
} else if let value = try? container.decode(Bool.self) {
|
||||||
|
self = .bool(value)
|
||||||
|
} else if let value = try? container.decode(String.self) {
|
||||||
|
self = .string(value)
|
||||||
|
} else if let value = try? container.decode(Decimal.self) {
|
||||||
|
self = .number(value)
|
||||||
|
} else if let value = try? container.decode([String: Self].self) {
|
||||||
|
self = .object(value)
|
||||||
|
} else {
|
||||||
|
self = .array(try container.decode([Self].self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 注销条件中的单个服务端阻断项;保留未知 code/action,不能据此绕过服务端限制。
|
||||||
|
struct StoreAccountDeregistrationBlocker: Decodable, Equatable, Sendable {
|
||||||
|
/// 稳定业务错误码。
|
||||||
|
let code: String
|
||||||
|
/// 供用户阅读的阻断说明。
|
||||||
|
let message: String
|
||||||
|
/// 后端建议动作;客户端仅对已确认的动作提供说明。
|
||||||
|
let action: String
|
||||||
|
|
||||||
|
/// 已知资产确认问题集中展示在资产卡,其余阻断仍逐条展示并阻止继续。
|
||||||
|
var isAssetConfirmation: Bool {
|
||||||
|
["WALLET_WAIVER_MISSING", "POINTS_WAIVER_MISSING", "WAIVER_STALE"].contains(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未实现跳转的动作仍展示处理建议,不悄悄丢弃该阻断项。
|
||||||
|
var guidance: String {
|
||||||
|
switch action {
|
||||||
|
case "complete_orders": "请先处理未履约订单或带单,然后刷新条件。"
|
||||||
|
case "wait_risk_window": "请等待业务风险期结束,然后刷新条件。"
|
||||||
|
case "confirm_wallet_waiver": "请单独确认放弃现金余额。"
|
||||||
|
case "confirm_points_waiver": "请单独确认放弃积分。"
|
||||||
|
default: "请按上述说明处理;如不清楚如何操作,请联系管理员或客服。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 2026-08-28 真实 eligibility 响应;必需的金额、确认及权限字段缺失时解码失败。
|
||||||
|
struct StoreAccountDeregistrationEligibility: Decodable, Equatable, Sendable {
|
||||||
|
/// 服务端是否允许申请,不能单独替代完整阻断检查。
|
||||||
|
let canApply: Bool
|
||||||
|
/// 当前门店用户身份 ID。
|
||||||
|
let storeUserID: Int
|
||||||
|
/// 对应的财务身份 ID。
|
||||||
|
let financeIdentityID: Int
|
||||||
|
/// 现金余额,单位分,避免浮点运算。
|
||||||
|
let walletBalanceFen: Int64
|
||||||
|
/// 服务端金额展示字符串。
|
||||||
|
let walletBalance: String
|
||||||
|
/// 积分余额。
|
||||||
|
let pointsBalance: Int64
|
||||||
|
/// 服务端当前余额快照是否已有现金放弃确认。
|
||||||
|
let walletWaived: Bool
|
||||||
|
/// 服务端当前余额快照是否已有积分放弃确认。
|
||||||
|
let pointsWaived: Bool
|
||||||
|
/// 未履约主订单及带单数量。
|
||||||
|
let unfulfilledCount: Int
|
||||||
|
/// 尚在处理的交付任务数量。
|
||||||
|
let fulfillmentInProgressCount: Int
|
||||||
|
/// 最后业务风险结束时间;尚未确认时区,按服务端原文展示。
|
||||||
|
let riskEndAt: String?
|
||||||
|
/// 业务风险等待截止时间,不是注销冷静期截止时间。
|
||||||
|
let eligibleAt: String?
|
||||||
|
/// 服务端业务风险等待时长。
|
||||||
|
let riskWindowHours: Int
|
||||||
|
/// 全部阻断原因,包含客户端尚不认识的新错误码。
|
||||||
|
let blockers: [StoreAccountDeregistrationBlocker]
|
||||||
|
/// 当前注销记录;已实测草稿、冷静期和主动撤销,其他状态保留原始结构。
|
||||||
|
let deregister: StoreAccountDeregistrationJSON
|
||||||
|
|
||||||
|
/// 资产之外尚需处理的业务条件,包含客户端不认识的阻断代码。
|
||||||
|
var businessBlockers: [StoreAccountDeregistrationBlocker] {
|
||||||
|
blockers.filter { !$0.isAssetConfirmation }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两步页面可以开始资产确认的前提,不替代提交前的完整服务端核验。
|
||||||
|
var permitsAssetConfirmation: Bool {
|
||||||
|
permitsPreparation && businessBlockers.isEmpty && storeUserID > 0 && financeIdentityID > 0
|
||||||
|
&& walletBalanceFen >= 0 && pointsBalance >= 0
|
||||||
|
&& unfulfilledCount == 0 && fulfillmentInProgressCount == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字段名对齐真实响应,不接受缺失字段的乐观默认值。
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case canApply = "can_apply", storeUserID = "store_user_id", financeIdentityID = "finance_identity_id"
|
||||||
|
case walletBalanceFen = "wallet_balance_fen", walletBalance = "wallet_balance", pointsBalance = "points_balance"
|
||||||
|
case walletWaived = "wallet_waived", pointsWaived = "points_waived"
|
||||||
|
case unfulfilledCount = "unfulfilled_count", fulfillmentInProgressCount = "fulfillment_in_progress_count"
|
||||||
|
case riskEndAt = "risk_end_at", eligibleAt = "eligible_at", riskWindowHours = "risk_window_hours"
|
||||||
|
case blockers, deregister
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 没有记录、未提交草稿或已撤销时可准备申请,其他条件仍以服务端核验为准。
|
||||||
|
var permitsPreparation: Bool {
|
||||||
|
StoreAccountDeregistrationStatus(deregister: deregister).permitsPreparation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完整条件满足才允许提交;有矛盾或未知状态则拒绝,截止时间不由本机推断。
|
||||||
|
var permitsApplication: Bool {
|
||||||
|
canApply && blockers.isEmpty && walletWaived && pointsWaived
|
||||||
|
&& walletBalanceFen >= 0 && pointsBalance >= 0
|
||||||
|
&& unfulfilledCount == 0 && fulfillmentInProgressCount == 0
|
||||||
|
&& storeUserID > 0 && permitsPreparation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确认弹窗期间两种资产或财务身份发生变化,必须重新展示并确认。
|
||||||
|
func hasSameAssets(as other: Self) -> Bool {
|
||||||
|
storeUserID == other.storeUserID && financeIdentityID == other.financeIdentityID
|
||||||
|
&& walletBalanceFen == other.walletBalanceFen && pointsBalance == other.pointsBalance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// status 的实测结构:0 草稿、1 冷静期、9 已撤销;缺失或未知状态不默认放行。
|
||||||
|
struct StoreAccountDeregistrationStatus: Decodable, Equatable, Sendable {
|
||||||
|
/// 保留原始记录,尚不推断未观测的阻断和完成状态值。
|
||||||
|
let deregister: StoreAccountDeregistrationJSON
|
||||||
|
|
||||||
|
/// 2026-08-28 两次资产确认后的真实草稿形态;不能仅凭 status=0 忽略矛盾的提交或终态字段。
|
||||||
|
var isUnsubmittedDraft: Bool {
|
||||||
|
guard case let .object(record) = deregister,
|
||||||
|
case let .number(id) = record["id"], id > 0,
|
||||||
|
id <= Decimal(Int64.max), id == Decimal(NSDecimalNumber(decimal: id).int64Value),
|
||||||
|
record["status"] == .number(0),
|
||||||
|
case let .string(label) = record["status_label"], !label.isEmpty,
|
||||||
|
case .string = record["reason"],
|
||||||
|
record["apply_time"] == .null, record["cooling_until"] == .null,
|
||||||
|
record["remaining_seconds"] == .number(0), record["cancel_time"] == .null,
|
||||||
|
record["blocked_code"] == .string(""), record["blocked_reason"] == .string(""),
|
||||||
|
record["completed_at"] == .null else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实测冷静期;即使剩余秒数为 0,也须等待服务端复核,不能视为完成。
|
||||||
|
var isCooling: Bool {
|
||||||
|
guard let record = validatedRecord else { return false }
|
||||||
|
return record["status"] == .number(1) && nonemptyString(record["apply_time"]) != nil
|
||||||
|
&& nonemptyString(record["cooling_until"]) != nil && record["cancel_time"] == .null
|
||||||
|
&& record["completed_at"] == .null && record["blocked_code"] == .string("")
|
||||||
|
&& record["blocked_reason"] == .string("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已撤销必须同时有撤销时间,不能只因剩余秒数为零或仍保留冷静期截止时间而推断。
|
||||||
|
var isCancelled: Bool {
|
||||||
|
guard let record = validatedRecord else { return false }
|
||||||
|
return record["status"] == .number(9) && nonemptyString(record["apply_time"]) != nil
|
||||||
|
&& nonemptyString(record["cancel_time"]) != nil && record["completed_at"] == .null
|
||||||
|
&& record["remaining_seconds"] == .number(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用于核对取消前后是否为同一申请,不包含账号凭证。
|
||||||
|
var recordID: String? {
|
||||||
|
guard let record = validatedRecord, case let .number(id) = record["id"] else { return nil }
|
||||||
|
return NSDecimalNumber(decimal: id).stringValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 识别同一次已撤销记录;避免重新申请时用旧撤销响应清除新的提交意图。
|
||||||
|
var cancellationFingerprint: String? {
|
||||||
|
guard isCancelled, let recordID, let time = nonemptyString(validatedRecord?["cancel_time"]) else { return nil }
|
||||||
|
return recordID + "|" + time
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按服务端原文展示,不推断时间字符串的时区。
|
||||||
|
var coolingUntil: String? { nonemptyString(validatedRecord?["cooling_until"]) }
|
||||||
|
|
||||||
|
/// 服务端查询时的剩余秒数;客户端不据此宣布注销完成。
|
||||||
|
var remainingSeconds: Int64? {
|
||||||
|
guard let record = validatedRecord, case let .number(seconds) = record["remaining_seconds"] else { return nil }
|
||||||
|
return NSDecimalNumber(decimal: seconds).int64Value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 没有申请、未提交草稿或已撤销允许恢复业务;准备新申请仍须重新核验资产及条件。
|
||||||
|
var permitsPreparation: Bool { deregister == .null || isUnsubmittedDraft || isCancelled }
|
||||||
|
|
||||||
|
private var validatedRecord: [String: StoreAccountDeregistrationJSON]? {
|
||||||
|
guard case let .object(record) = deregister,
|
||||||
|
case let .number(id) = record["id"], id > 0, isInteger(id),
|
||||||
|
case let .number(seconds) = record["remaining_seconds"], seconds >= 0, isInteger(seconds),
|
||||||
|
case let .string(label) = record["status_label"], !label.isEmpty,
|
||||||
|
case .string = record["reason"], case .string = record["blocked_code"],
|
||||||
|
case .string = record["blocked_reason"] else { return nil }
|
||||||
|
for key in ["apply_time", "cooling_until", "cancel_time", "completed_at"] {
|
||||||
|
guard record[key] == .null || nonemptyString(record[key]) != nil else { return nil }
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nonemptyString(_ value: StoreAccountDeregistrationJSON?) -> String? {
|
||||||
|
guard case let .string(text) = value, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isInteger(_ value: Decimal) -> Bool {
|
||||||
|
value <= Decimal(Int64.max) && value >= Decimal(Int64.min)
|
||||||
|
&& value == Decimal(NSDecimalNumber(decimal: value).int64Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 必须分别确认的两种资产。
|
||||||
|
enum StoreAccountDeregistrationAsset: Sendable {
|
||||||
|
case wallet
|
||||||
|
case points
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreFoundation
|
||||||
|
|
||||||
|
/// 本机提交意图记录,不代表后端已受理或任何注销业务状态。
|
||||||
|
protocol StoreAccountDeregistrationSubmissionTracking {
|
||||||
|
/// 是否存在本机尚无法确定结果的提交意图。
|
||||||
|
var hasUnresolvedSubmission: Bool { get }
|
||||||
|
/// 在发出 POST 前记录意图;无法记录时不允许发出申请。
|
||||||
|
func recordSubmissionIntent(previousStatus: StoreAccountDeregistrationStatus?) throws
|
||||||
|
/// 仅在服务端明确拒绝本次申请时清除此意图。
|
||||||
|
func clearRejectedSubmission()
|
||||||
|
/// 用新撤销记录核销提交意图;与重新申请前相同的旧撤销响应不能放行。
|
||||||
|
func clearCancelledSubmission(matching status: StoreAccountDeregistrationStatus) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按环境和门店用户 ID 隔离提交意图;不按手机号保存,不存储凭证、验证码或资产。
|
||||||
|
final class StoreAccountDeregistrationSubmissionStore: StoreAccountDeregistrationSubmissionTracking {
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let key: String
|
||||||
|
|
||||||
|
/// 注入持久化容器便于测试;生产环境使用当前服务域名隔离测试/正式账号。
|
||||||
|
init(storeUserID: String, environment: APIEnvironment = .current, defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
key = "store_deregister_submission_intent_v1_\(environment.baseURL.host ?? "unknown")_\(storeUserID)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任何残留记录都按待核实处理,不因不认识的本地值而允许重新提交。
|
||||||
|
var hasUnresolvedSubmission: Bool { defaults.object(forKey: key) != nil }
|
||||||
|
|
||||||
|
/// UserDefaults 记录提交意图;不把本地写入成功等同于后端收到申请。
|
||||||
|
func recordSubmissionIntent(previousStatus: StoreAccountDeregistrationStatus? = nil) throws {
|
||||||
|
defaults.set(["previous_cancellation": previousStatus?.cancellationFingerprint ?? ""], forKey: key)
|
||||||
|
guard hasUnresolvedSubmission else { throw SubmissionPersistenceError.unavailable }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只删除当前环境、当前门店用户对应的意图,不影响其他身份。
|
||||||
|
func clearRejectedSubmission() { defaults.removeObject(forKey: key) }
|
||||||
|
|
||||||
|
/// 旧版本只会从无记录/草稿提交,其布尔意图可由完整撤销记录核销。
|
||||||
|
func clearCancelledSubmission(matching status: StoreAccountDeregistrationStatus) -> Bool {
|
||||||
|
guard let fingerprint = status.cancellationFingerprint else { return false }
|
||||||
|
if let stored = defaults.object(forKey: key) {
|
||||||
|
if let record = stored as? [String: String], let previous = record["previous_cancellation"] {
|
||||||
|
guard previous != fingerprint else { return false }
|
||||||
|
} else {
|
||||||
|
// 仅兼容旧版本写入的 true;损坏或未知结构继续等待核验。
|
||||||
|
guard let value = stored as? NSNumber,
|
||||||
|
CFGetTypeID(value) == CFBooleanGetTypeID(), value.boolValue else { return false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无法保存提交意图时阻止申请,避免在重启后失去重复提交保护。
|
||||||
|
private enum SubmissionPersistenceError: LocalizedError {
|
||||||
|
case unavailable
|
||||||
|
var errorDescription: String? { "无法保存提交核验记录,请稍后重试" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 进入业务页面前的核验结果,不将非空未知记录解释为任何已知注销状态。
|
||||||
|
enum StoreAccountDeregistrationAccessDecision: Equatable {
|
||||||
|
case notChecked
|
||||||
|
case checking
|
||||||
|
case allowed
|
||||||
|
case cooling
|
||||||
|
case unresolved
|
||||||
|
case failed(String)
|
||||||
|
case obsolete
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进入业务前核验当前身份;冷静期只能查询或由用户明确撤销,不自动重新登录。
|
||||||
|
final class StoreAccountDeregistrationAccessViewModel {
|
||||||
|
/// 未核验与查询失败都不能创建普通业务根页面。
|
||||||
|
private(set) var decision: StoreAccountDeregistrationAccessDecision = .notChecked
|
||||||
|
/// 防止同一页面重复发起查询。
|
||||||
|
private(set) var isChecking = false
|
||||||
|
/// 最近一次通过身份核验的服务端状态,用于冷静期展示和撤销前比对。
|
||||||
|
private(set) var status: StoreAccountDeregistrationStatus?
|
||||||
|
private var restrictionRevision = 0
|
||||||
|
private var requiresSubmissionReconciliation: Bool
|
||||||
|
private let submissionStore: (any StoreAccountDeregistrationSubmissionTracking)?
|
||||||
|
private var observedCooling = false
|
||||||
|
|
||||||
|
/// 本机存在待核实提交意图时,即使暂时返回 null 也不直接恢复普通业务。
|
||||||
|
init(requiresSubmissionReconciliation: Bool = false,
|
||||||
|
submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil) {
|
||||||
|
self.submissionStore = submissionStore
|
||||||
|
self.requiresSubmissionReconciliation = requiresSubmissionReconciliation
|
||||||
|
|| submissionStore?.hasUnresolvedSubmission == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新的限制信号优先于此前已发出的状态查询,防止旧正常响应覆盖新限制。
|
||||||
|
func recordRestriction() {
|
||||||
|
restrictionRevision += 1
|
||||||
|
status = nil
|
||||||
|
decision = .unresolved
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询前后核对身份;旧身份响应和错误均不能决定新会话是否进入业务页。
|
||||||
|
func verify(api: any StoreAccountDeregistrationStatusServing,
|
||||||
|
isCurrentIdentity: @escaping @MainActor () -> Bool) async {
|
||||||
|
guard !isChecking else { return }
|
||||||
|
isChecking = true
|
||||||
|
let revision = restrictionRevision
|
||||||
|
decision = .checking
|
||||||
|
status = nil
|
||||||
|
defer { isChecking = false }
|
||||||
|
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||||
|
do {
|
||||||
|
let result = try await api.status()
|
||||||
|
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||||
|
guard restrictionRevision == revision else { return }
|
||||||
|
apply(result)
|
||||||
|
} catch {
|
||||||
|
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||||
|
guard restrictionRevision == revision else { return }
|
||||||
|
if case APIError.serverCode(150015, _) = error {
|
||||||
|
decision = .unresolved
|
||||||
|
} else if case StoreAccountDeregistrationError.sessionChanged = error {
|
||||||
|
decision = .obsolete
|
||||||
|
} else {
|
||||||
|
decision = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户确认后先重查同一申请再撤销,结果丢失时保留限制,禁止自动重发 POST。
|
||||||
|
func cancel(api: any StoreAccountDeregistrationServing,
|
||||||
|
isCurrentIdentity: @escaping @MainActor () -> Bool) async {
|
||||||
|
guard !isChecking, decision == .cooling, let recordID = status?.recordID else { return }
|
||||||
|
isChecking = true
|
||||||
|
let revision = restrictionRevision
|
||||||
|
defer { isChecking = false }
|
||||||
|
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||||
|
do {
|
||||||
|
let current = try await api.status()
|
||||||
|
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||||
|
guard restrictionRevision == revision else { return }
|
||||||
|
guard current.isCooling, current.recordID == recordID else { applyCancellationCheck(current); return }
|
||||||
|
try await api.cancel()
|
||||||
|
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||||
|
guard restrictionRevision == revision else { return }
|
||||||
|
let confirmed = try await api.status()
|
||||||
|
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||||
|
guard restrictionRevision == revision else { return }
|
||||||
|
applyCancellationCheck(confirmed)
|
||||||
|
} catch {
|
||||||
|
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||||
|
guard restrictionRevision == revision else { return }
|
||||||
|
status = nil
|
||||||
|
decision = .failed("撤销结果尚未确认,请重新查询状态。\n" + error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCancellationCheck(_ result: StoreAccountDeregistrationStatus) {
|
||||||
|
// 曾查到冷静期后,旧 null/草稿不能证明撤销完成;须重新核实完整状态。
|
||||||
|
guard result.isCooling || result.isCancelled else {
|
||||||
|
status = result
|
||||||
|
decision = .unresolved
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apply(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(_ result: StoreAccountDeregistrationStatus) {
|
||||||
|
status = result
|
||||||
|
if submissionStore?.hasUnresolvedSubmission == true { requiresSubmissionReconciliation = true }
|
||||||
|
if result.isCancelled, submissionStore?.clearCancelledSubmission(matching: result) == true {
|
||||||
|
requiresSubmissionReconciliation = false
|
||||||
|
}
|
||||||
|
if result.isCooling {
|
||||||
|
observedCooling = true
|
||||||
|
decision = .cooling
|
||||||
|
} else {
|
||||||
|
decision = result.permitsPreparation && !requiresSubmissionReconciliation
|
||||||
|
&& (!observedCooling || result.isCancelled) ? .allowed : .unresolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 150015 只影响发出请求时的同一门店身份凭证,不能按手机号传播限制。
|
||||||
|
static func restrictionApplies(requestToken: String?, session: AppSessionStore) -> Bool {
|
||||||
|
guard session.accountType == .storeUser,
|
||||||
|
let requestToken, !requestToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
|
||||||
|
return requestToken == session.token
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 注销流程本地交互阶段;不使用这些阶段伪造服务端业务状态。
|
||||||
|
enum StoreAccountDeregistrationStep: Equatable {
|
||||||
|
case conditions
|
||||||
|
case verification
|
||||||
|
case unresolvedRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 门店身份注销流程,所有资产确认和提交都重新检查服务端条件,不持久化手机号级注销状态。
|
||||||
|
final class StoreAccountDeregistrationViewModel {
|
||||||
|
/// 当前已核验的条件;刷新失败即清空,禁止继续使用过期权限。
|
||||||
|
private(set) var eligibility: StoreAccountDeregistrationEligibility?
|
||||||
|
/// 当前已查询的原始申请记录。
|
||||||
|
private(set) var status: StoreAccountDeregistrationStatus?
|
||||||
|
/// 页面交互阶段,不代表服务端冷静期状态。
|
||||||
|
private(set) var step: StoreAccountDeregistrationStep = .conditions
|
||||||
|
/// 同一流程只允许一个在途操作。
|
||||||
|
private(set) var isBusy = false
|
||||||
|
/// 显示给用户的错误或需要重新核验的原因。
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
/// 请求已发出后阻止同一流程重复提交,包括响应丢失的情况。
|
||||||
|
private(set) var submissionAttempted = false
|
||||||
|
/// 成功响应仅代表申请已提交,不代表账号注销完成。
|
||||||
|
private(set) var submissionAccepted = false
|
||||||
|
/// 申请明确受理后,以当前凭证只读查询到的冷静期截止时间;查询失败不改变受理结果。
|
||||||
|
private(set) var submittedCoolingUntil: String?
|
||||||
|
private let storeUserID: Int
|
||||||
|
private let submissionStore: (any StoreAccountDeregistrationSubmissionTracking)?
|
||||||
|
private var previousCancellationFingerprint: String?
|
||||||
|
private var needsUpdatedAssetConsent = false
|
||||||
|
|
||||||
|
/// 注入被冻结的业务身份 ID;网络服务另行注入以便测试。
|
||||||
|
init(storeUserID: Int, submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil) {
|
||||||
|
self.storeUserID = storeUserID
|
||||||
|
self.submissionStore = submissionStore
|
||||||
|
if submissionStore?.hasUnresolvedSubmission == true {
|
||||||
|
submissionAttempted = true
|
||||||
|
step = .unresolvedRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅当完整条件、两项确认和状态查询一致时,允许进入验证码步骤。
|
||||||
|
var canContinue: Bool {
|
||||||
|
!isBusy && !submissionAttempted && status?.permitsPreparation == true
|
||||||
|
&& eligibility?.permitsApplication == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个入口处理两项确认;其他业务条件未满足时不能开始。
|
||||||
|
var canConfirmAssetsAndContinue: Bool {
|
||||||
|
!isBusy && !submissionAttempted && status?.permitsPreparation == true
|
||||||
|
&& eligibility?.permitsAssetConfirmation == true
|
||||||
|
&& (requiresAssetConfirmation || eligibility?.permitsApplication == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未确认或确认期间资产变化时,必须重新向用户列出两项金额。
|
||||||
|
var requiresAssetConfirmation: Bool {
|
||||||
|
needsUpdatedAssetConsent || eligibility?.walletWaived != true || eligibility?.pointsWaived != true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户一次明确确认两项资产后,顺序调用旧接口;只读核实部分结果,不自动重发修改请求。
|
||||||
|
func confirmAssetsAndContinue(snapshot: StoreAccountDeregistrationEligibility,
|
||||||
|
api: any StoreAccountDeregistrationServing) async {
|
||||||
|
guard canConfirmAssetsAndContinue else { return }
|
||||||
|
isBusy = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isBusy = false }
|
||||||
|
do {
|
||||||
|
for asset in [StoreAccountDeregistrationAsset.wallet, .points] {
|
||||||
|
try await reload(api: api)
|
||||||
|
let current = try validateAssetConsent(snapshot)
|
||||||
|
let waived = asset == .wallet ? current.walletWaived : current.pointsWaived
|
||||||
|
if !waived {
|
||||||
|
if asset == .wallet { try await api.waiveWallet() }
|
||||||
|
else { try await api.waivePoints() }
|
||||||
|
try await reload(api: api)
|
||||||
|
let confirmed = try validateAssetConsent(snapshot)
|
||||||
|
guard asset == .wallet ? confirmed.walletWaived : confirmed.pointsWaived else {
|
||||||
|
throw StoreAccountDeregistrationFlowError.confirmationIncomplete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard eligibility?.permitsApplication == true, status?.permitsPreparation == true else {
|
||||||
|
throw StoreAccountDeregistrationFlowError.conditionsChanged
|
||||||
|
}
|
||||||
|
needsUpdatedAssetConsent = false
|
||||||
|
step = .verification
|
||||||
|
} catch {
|
||||||
|
if case StoreAccountDeregistrationFlowError.assetsChanged = error { needsUpdatedAssetConsent = true }
|
||||||
|
if case StoreAccountDeregistrationError.sessionChanged = error { invalidate(error); return }
|
||||||
|
// POST响应丢失也可能已经成功:仅GET恢复真实确认标记,下一次由用户主动重试。
|
||||||
|
do { try await reload(api: api) }
|
||||||
|
catch { invalidate(error); return }
|
||||||
|
if status?.permitsPreparation == true, eligibility?.permitsPreparation == true { step = .conditions }
|
||||||
|
let partial = eligibility?.walletWaived == true && eligibility?.pointsWaived == false
|
||||||
|
? "现金余额已确认,积分尚未确认。\n" : ""
|
||||||
|
errorMessage = partial + error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validateAssetConsent(_ snapshot: StoreAccountDeregistrationEligibility) throws -> StoreAccountDeregistrationEligibility {
|
||||||
|
guard status?.permitsPreparation == true, let current = eligibility, current.permitsPreparation else {
|
||||||
|
throw StoreAccountDeregistrationFlowError.existingRequest
|
||||||
|
}
|
||||||
|
guard current.hasSameAssets(as: snapshot),
|
||||||
|
!(snapshot.walletWaived && !current.walletWaived),
|
||||||
|
!(snapshot.pointsWaived && !current.pointsWaived) else {
|
||||||
|
throw StoreAccountDeregistrationFlowError.assetsChanged
|
||||||
|
}
|
||||||
|
guard current.permitsAssetConfirmation else { throw StoreAccountDeregistrationFlowError.conditionsChanged }
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无申请或未提交草稿且条件可读取时允许确认资产,零余额也不自动确认。
|
||||||
|
func canConfirm(_ asset: StoreAccountDeregistrationAsset) -> Bool {
|
||||||
|
guard !isBusy, !submissionAttempted, status?.permitsPreparation == true,
|
||||||
|
let eligibility, eligibility.permitsPreparation,
|
||||||
|
eligibility.walletBalanceFen >= 0, eligibility.pointsBalance >= 0 else { return false }
|
||||||
|
return asset == .wallet ? !eligibility.walletWaived : !eligibility.pointsWaived
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户主动刷新;后端非空记录无法识别时禁止继续申请,不以七天本地倒计时替代查询。
|
||||||
|
func refresh(api: any StoreAccountDeregistrationServing) async {
|
||||||
|
guard !isBusy else { return }
|
||||||
|
isBusy = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isBusy = false }
|
||||||
|
do { try await reload(api: api) }
|
||||||
|
catch { invalidate(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户已在单独弹窗中确认某种资产;提交前比对弹窗中的两项余额快照。
|
||||||
|
func confirm(_ asset: StoreAccountDeregistrationAsset, snapshot: StoreAccountDeregistrationEligibility,
|
||||||
|
api: any StoreAccountDeregistrationServing) async {
|
||||||
|
guard canConfirm(asset) else { return }
|
||||||
|
isBusy = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isBusy = false }
|
||||||
|
do {
|
||||||
|
try await reload(api: api)
|
||||||
|
guard status?.permitsPreparation == true, let current = eligibility, current.permitsPreparation else {
|
||||||
|
throw StoreAccountDeregistrationFlowError.existingRequest
|
||||||
|
}
|
||||||
|
guard current.hasSameAssets(as: snapshot) else { throw StoreAccountDeregistrationFlowError.assetsChanged }
|
||||||
|
guard current.walletBalanceFen >= 0, current.pointsBalance >= 0 else {
|
||||||
|
throw StoreAccountDeregistrationFlowError.conditionsChanged
|
||||||
|
}
|
||||||
|
if asset == .wallet {
|
||||||
|
if !current.walletWaived { try await api.waiveWallet() }
|
||||||
|
} else if !current.pointsWaived {
|
||||||
|
try await api.waivePoints()
|
||||||
|
}
|
||||||
|
// 不乐观设置确认标记:只有新查询返回 true 才算已确认。
|
||||||
|
try await reload(api: api)
|
||||||
|
} catch { invalidate(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 条件全部满足后进入真实短信验证步骤。
|
||||||
|
func beginVerification() {
|
||||||
|
guard canContinue else { return }
|
||||||
|
step = .verification
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未发出申请前可以返回重新查看条件。
|
||||||
|
func returnToConditions() {
|
||||||
|
guard !isBusy, !submissionAttempted else { return }
|
||||||
|
step = .conditions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 不接受用户指定收件手机号,使用接口绑定的门店手机号。
|
||||||
|
func sendSMS(api: any StoreAccountDeregistrationServing) async -> Bool {
|
||||||
|
guard canContinue, step == .verification else { return false }
|
||||||
|
isBusy = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isBusy = false }
|
||||||
|
do {
|
||||||
|
try await checkReady(api: api)
|
||||||
|
try await api.sendSMS()
|
||||||
|
return true
|
||||||
|
} catch { invalidate(error); return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步校验验证码与必填原因;失败时不启动查询、持久化或申请请求。
|
||||||
|
@discardableResult
|
||||||
|
func validateSubmissionInput(smsCode: String, reason: String) -> Bool {
|
||||||
|
let code = smsCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if code.isEmpty {
|
||||||
|
errorMessage = "请输入收到的短信验证码"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if reason.isEmpty {
|
||||||
|
errorMessage = "请输入注销原因"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if reason.count > 50 {
|
||||||
|
errorMessage = "注销原因不能超过 50 个字"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
errorMessage = nil
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验输入后申请;服务端明确接受后只读查询一次冷静期截止时间,再交由页面退出。
|
||||||
|
func submit(smsCode: String, reason: String, api: any StoreAccountDeregistrationServing) async {
|
||||||
|
guard canContinue, step == .verification else { return }
|
||||||
|
let code = smsCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard validateSubmissionInput(smsCode: code, reason: reason) else { return }
|
||||||
|
var verifiedEligibility: StoreAccountDeregistrationEligibility?
|
||||||
|
var verifiedStatus: StoreAccountDeregistrationStatus?
|
||||||
|
isBusy = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isBusy = false }
|
||||||
|
do {
|
||||||
|
try await checkReady(api: api)
|
||||||
|
verifiedEligibility = eligibility
|
||||||
|
verifiedStatus = status
|
||||||
|
previousCancellationFingerprint = status?.cancellationFingerprint
|
||||||
|
try submissionStore?.recordSubmissionIntent(previousStatus: status)
|
||||||
|
submissionAttempted = true
|
||||||
|
step = .unresolvedRequest
|
||||||
|
eligibility = nil
|
||||||
|
try await api.apply(smsCode: code, reason: reason)
|
||||||
|
submissionAccepted = true
|
||||||
|
// 申请成功已经成立;状态查询只用于结果页展示,失败或尚未进入冷静期都不能推翻成功响应。
|
||||||
|
if let acceptedStatus = try? await api.status(), acceptedStatus.isCooling {
|
||||||
|
status = acceptedStatus
|
||||||
|
submittedCoolingUntil = acceptedStatus.coolingUntil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if !submissionAccepted, isVerificationCodeRejection(error) {
|
||||||
|
submissionStore?.clearRejectedSubmission()
|
||||||
|
submissionAttempted = false
|
||||||
|
eligibility = verifiedEligibility
|
||||||
|
status = verifiedStatus
|
||||||
|
step = .verification
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 业务码明确拒绝申请时可重新核验;网络/解码错误不能证明服务端没有收到申请。
|
||||||
|
if !submissionAccepted, case APIError.serverCode = error {
|
||||||
|
submissionStore?.clearRejectedSubmission()
|
||||||
|
submissionAttempted = false
|
||||||
|
step = .conditions
|
||||||
|
}
|
||||||
|
invalidate(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旧接口没有独立错误码,验证码拒绝只能按服务端业务提示识别。
|
||||||
|
private func isVerificationCodeRejection(_ error: Error) -> Bool {
|
||||||
|
guard case let APIError.serverCode(_, message) = error else { return false }
|
||||||
|
return message.localizedCaseInsensitiveContains("验证码")
|
||||||
|
|| message.localizedCaseInsensitiveContains("verification code")
|
||||||
|
|| message.localizedCaseInsensitiveContains("sms code")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkReady(api: any StoreAccountDeregistrationServing) async throws {
|
||||||
|
try await reload(api: api)
|
||||||
|
guard status?.permitsPreparation == true, eligibility?.permitsApplication == true else {
|
||||||
|
throw StoreAccountDeregistrationFlowError.conditionsChanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload(api: any StoreAccountDeregistrationServing) async throws {
|
||||||
|
eligibility = nil
|
||||||
|
status = nil
|
||||||
|
let currentStatus = try await api.status()
|
||||||
|
status = currentStatus
|
||||||
|
if !currentStatus.permitsPreparation || submissionAttempted { step = .unresolvedRequest }
|
||||||
|
let current = try await api.eligibility()
|
||||||
|
guard current.storeUserID == storeUserID, storeUserID > 0 else {
|
||||||
|
throw StoreAccountDeregistrationError.sessionChanged
|
||||||
|
}
|
||||||
|
eligibility = current
|
||||||
|
let eligibilityStatus = StoreAccountDeregistrationStatus(deregister: current.deregister)
|
||||||
|
if submissionAttempted, let fingerprint = currentStatus.cancellationFingerprint,
|
||||||
|
eligibilityStatus.cancellationFingerprint == fingerprint {
|
||||||
|
let reconciled = submissionStore.map { $0.clearCancelledSubmission(matching: currentStatus) }
|
||||||
|
?? (fingerprint != previousCancellationFingerprint)
|
||||||
|
if reconciled {
|
||||||
|
submissionAttempted = false
|
||||||
|
submissionAccepted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.permitsPreparation { step = .unresolvedRequest }
|
||||||
|
if step == .unresolvedRequest, currentStatus.permitsPreparation, current.permitsPreparation, !submissionAttempted {
|
||||||
|
step = .conditions
|
||||||
|
}
|
||||||
|
if step == .verification, !current.permitsApplication { step = .conditions }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func invalidate(_ error: Error) {
|
||||||
|
eligibility = nil
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
if !submissionAttempted, status?.permitsPreparation == true { step = .conditions }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地交互检查失败,不替代服务端业务错误。
|
||||||
|
private enum StoreAccountDeregistrationFlowError: LocalizedError {
|
||||||
|
case assetsChanged
|
||||||
|
case conditionsChanged
|
||||||
|
case existingRequest
|
||||||
|
case confirmationIncomplete
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .assetsChanged: "资产余额已变化,请刷新后重新确认"
|
||||||
|
case .conditionsChanged: "注销条件已变化,请刷新并处理全部阻断项"
|
||||||
|
case .existingRequest: "已查询到注销记录,请先核实申请状态"
|
||||||
|
case .confirmationIncomplete: "资产确认尚未完成,请刷新后重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ enum LoginValidationError: Equatable {
|
|||||||
enum LoginResolution {
|
enum LoginResolution {
|
||||||
case completed(V9AuthResponse, AccountSwitchAccount)
|
case completed(V9AuthResponse, AccountSwitchAccount)
|
||||||
case needsAccountSelection(AccountSelectionPayload)
|
case needsAccountSelection(AccountSelectionPayload)
|
||||||
|
case needsDeregistrationConfirmation(DeregistrationLoginConfirmation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
|
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
|
||||||
@@ -124,8 +125,40 @@ struct AccountSwitchAccount: Identifiable, Hashable {
|
|||||||
let storeId: Int?
|
let storeId: Int?
|
||||||
let storeName: String
|
let storeName: String
|
||||||
let scenicId: Int?
|
let scenicId: Int?
|
||||||
|
let status: Int
|
||||||
let isCurrent: Bool
|
let isCurrent: Bool
|
||||||
|
|
||||||
|
/// 创建统一账号展示模型;门店状态默认为正常,兼容尚未返回 `status` 的旧响应。
|
||||||
|
init(
|
||||||
|
accountType: String,
|
||||||
|
businessUserId: Int,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
phone: String,
|
||||||
|
realName: String,
|
||||||
|
avatar: String,
|
||||||
|
scenicName: String,
|
||||||
|
storeId: Int?,
|
||||||
|
storeName: String,
|
||||||
|
scenicId: Int?,
|
||||||
|
status: Int = 1,
|
||||||
|
isCurrent: Bool
|
||||||
|
) {
|
||||||
|
self.accountType = accountType
|
||||||
|
self.businessUserId = businessUserId
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.phone = phone
|
||||||
|
self.realName = realName
|
||||||
|
self.avatar = avatar
|
||||||
|
self.scenicName = scenicName
|
||||||
|
self.storeId = storeId
|
||||||
|
self.storeName = storeName
|
||||||
|
self.scenicId = scenicId
|
||||||
|
self.status = status
|
||||||
|
self.isCurrent = isCurrent
|
||||||
|
}
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
"\(accountType)_\(businessUserId)"
|
"\(accountType)_\(businessUserId)"
|
||||||
}
|
}
|
||||||
@@ -134,6 +167,11 @@ struct AccountSwitchAccount: Identifiable, Hashable {
|
|||||||
accountType == V9StoreUser.accountTypeValue
|
accountType == V9StoreUser.accountTypeValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `status == 2` 表示门店身份已提交注销申请,登录前需要用户明确确认撤销。
|
||||||
|
var requiresDeregistrationConfirmation: Bool {
|
||||||
|
isStoreUser && status == 2
|
||||||
|
}
|
||||||
|
|
||||||
var accountTypeLabel: String {
|
var accountTypeLabel: String {
|
||||||
isStoreUser ? "门店账号" : "景区账号"
|
isStoreUser ? "门店账号" : "景区账号"
|
||||||
}
|
}
|
||||||
@@ -146,6 +184,12 @@ struct AccountSwitchAccount: Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 冷静期门店身份的待确认登录信息;确认后继续调用 `set-user`,由后端自动撤销注销申请。
|
||||||
|
struct DeregistrationLoginConfirmation: Equatable {
|
||||||
|
let tempToken: String
|
||||||
|
let account: AccountSwitchAccount
|
||||||
|
}
|
||||||
|
|
||||||
/// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。
|
/// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。
|
||||||
struct AccountSelectionPayload: Equatable, Identifiable {
|
struct AccountSelectionPayload: Equatable, Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
@@ -285,6 +329,7 @@ struct V9StoreUser: Decodable, Equatable {
|
|||||||
let roleName: String
|
let roleName: String
|
||||||
let appRoleCode: String
|
let appRoleCode: String
|
||||||
let appRoleName: String
|
let appRoleName: String
|
||||||
|
let status: Int
|
||||||
let isCurrent: Bool
|
let isCurrent: Bool
|
||||||
|
|
||||||
var businessUserId: Int {
|
var businessUserId: Int {
|
||||||
@@ -308,6 +353,7 @@ struct V9StoreUser: Decodable, Equatable {
|
|||||||
storeId: storeId > 0 ? storeId : nil,
|
storeId: storeId > 0 ? storeId : nil,
|
||||||
storeName: storeName,
|
storeName: storeName,
|
||||||
scenicId: scenicId > 0 ? scenicId : nil,
|
scenicId: scenicId > 0 ? scenicId : nil,
|
||||||
|
status: status,
|
||||||
isCurrent: isCurrent
|
isCurrent: isCurrent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -327,6 +373,7 @@ struct V9StoreUser: Decodable, Equatable {
|
|||||||
case roleName = "role_name"
|
case roleName = "role_name"
|
||||||
case appRoleCode = "app_role_code"
|
case appRoleCode = "app_role_code"
|
||||||
case appRoleName = "app_role_name"
|
case appRoleName = "app_role_name"
|
||||||
|
case status
|
||||||
case isCurrent = "is_current"
|
case isCurrent = "is_current"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +393,7 @@ struct V9StoreUser: Decodable, Equatable {
|
|||||||
roleName = try container.decodeLossyString(forKey: .roleName)
|
roleName = try container.decodeLossyString(forKey: .roleName)
|
||||||
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
|
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
|
||||||
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
|
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
|
||||||
|
status = try container.decodeLossyInt(forKey: .status) ?? 1
|
||||||
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,37 @@ enum MessageJSONValue: Decodable, Hashable, Sendable {
|
|||||||
self = .object(try container.decode([String: MessageJSONValue].self))
|
self = .object(try container.decode([String: MessageJSONValue].self))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将消息字段转换为合法的正整数标识;兼容后端返回数字或数字字符串。
|
||||||
|
var positiveInt: Int? {
|
||||||
|
let value: Int?
|
||||||
|
switch self {
|
||||||
|
case let .number(number):
|
||||||
|
guard number.isFinite, number.rounded() == number else { return nil }
|
||||||
|
value = Int(exactly: number)
|
||||||
|
case let .string(string):
|
||||||
|
value = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
default:
|
||||||
|
value = nil
|
||||||
|
}
|
||||||
|
guard let value, value > 0 else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回对象类型的原始键值。
|
||||||
|
var objectValue: [String: MessageJSONValue]? {
|
||||||
|
switch self {
|
||||||
|
case let .object(value):
|
||||||
|
return value
|
||||||
|
case let .string(value):
|
||||||
|
guard let data = value.data(using: .utf8),
|
||||||
|
let decoded = try? JSONDecoder().decode(MessageJSONValue.self, from: data)
|
||||||
|
else { return nil }
|
||||||
|
return decoded.objectValue
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 消息未读数量响应,用于同步首页红点和桌面图标角标。
|
/// 消息未读数量响应,用于同步首页红点和桌面图标角标。
|
||||||
@@ -203,6 +234,20 @@ struct MessageItem: Decodable, Hashable, Sendable {
|
|||||||
return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt
|
return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 是否为 AI 修图任务通知;人工修图的 `type = 10` 不属于该类型。
|
||||||
|
var isAIRetouchTaskNotification: Bool {
|
||||||
|
type == 14
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图批次 ID,兼容 `extra_data` 直接字段及 `extra_data.data` 包装结构。
|
||||||
|
var aiRetouchBatchId: Int? {
|
||||||
|
guard isAIRetouchTaskNotification, let extraData else { return nil }
|
||||||
|
if let batchId = extraData["ai_retouch_batch_id"]?.positiveInt {
|
||||||
|
return batchId
|
||||||
|
}
|
||||||
|
return extraData["data"]?.objectValue?["ai_retouch_batch_id"]?.positiveInt
|
||||||
|
}
|
||||||
|
|
||||||
/// 返回已读状态的消息副本。
|
/// 返回已读状态的消息副本。
|
||||||
func markedRead() -> MessageItem {
|
func markedRead() -> MessageItem {
|
||||||
MessageItem(
|
MessageItem(
|
||||||
|
|||||||
@@ -163,6 +163,16 @@ final class MessageDetailViewModel {
|
|||||||
self.message = message
|
self.message = message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务通知在详情页展示任务入口。
|
||||||
|
var showsAIRetouchTaskAction: Bool {
|
||||||
|
message.isAIRetouchTaskNotification
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前消息携带的 AI 修图批次 ID;缺失时由页面降级进入任务列表。
|
||||||
|
var aiRetouchBatchId: Int? {
|
||||||
|
message.aiRetouchBatchId
|
||||||
|
}
|
||||||
|
|
||||||
/// 删除当前消息。
|
/// 删除当前消息。
|
||||||
func delete(api: any MessageCenterServing) async {
|
func delete(api: any MessageCenterServing) async {
|
||||||
guard message.id > 0 else {
|
guard message.id > 0 else {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 线下收款接口抽象,供生产 API 与测试替身共用。
|
||||||
|
@MainActor
|
||||||
|
protocol OfflineCollectionServing: AnyObject {
|
||||||
|
func statistics(scenicId: Int) async throws -> OfflineCollectionStatisticsResponse
|
||||||
|
func details(scenicId: Int, date: String) async throws -> OfflineCollectionDetailsResponse
|
||||||
|
func register(_ request: OfflineCollectionRegisterRequest) async throws -> OfflineCollectionRegisterResponse
|
||||||
|
func supplement(_ request: OfflineSettlementRequest) async throws -> OfflineSettlementResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款真实网络 API。
|
||||||
|
@MainActor
|
||||||
|
final class OfflineCollectionAPI: OfflineCollectionServing {
|
||||||
|
private let client: APIClient
|
||||||
|
private let prefix = "/api/yf-handset-app/photog/offline-pay-collect"
|
||||||
|
|
||||||
|
init(client: APIClient) {
|
||||||
|
self.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
func statistics(scenicId: Int) async throws -> OfflineCollectionStatisticsResponse {
|
||||||
|
try await client.send(APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(prefix)/statistics",
|
||||||
|
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func details(scenicId: Int, date: String) async throws -> OfflineCollectionDetailsResponse {
|
||||||
|
try await client.send(APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(prefix)/details",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||||
|
URLQueryItem(name: "date", value: date),
|
||||||
|
]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(_ request: OfflineCollectionRegisterRequest) async throws -> OfflineCollectionRegisterResponse {
|
||||||
|
try await client.send(APIRequest(method: .post, path: "\(prefix)/register", body: request))
|
||||||
|
}
|
||||||
|
|
||||||
|
func supplement(_ request: OfflineSettlementRequest) async throws -> OfflineSettlementResult {
|
||||||
|
try await client.send(APIRequest(method: .post, path: "\(prefix)/supplement", body: request))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 日清日历的展示模式。
|
||||||
|
enum OfflineCollectionCalendarMode: Sendable, Equatable {
|
||||||
|
case week
|
||||||
|
case month
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 周/月日历日期运算状态,不依赖 UIKit,便于单元测试。
|
||||||
|
struct OfflineCollectionCalendarState: Sendable, Equatable {
|
||||||
|
private let calendar: Calendar
|
||||||
|
private(set) var selectedDate: Date
|
||||||
|
private(set) var maximumDate: Date
|
||||||
|
private(set) var mode: OfflineCollectionCalendarMode
|
||||||
|
|
||||||
|
init(
|
||||||
|
selectedDate: Date,
|
||||||
|
maximumDate: Date,
|
||||||
|
mode: OfflineCollectionCalendarMode = .week,
|
||||||
|
calendar: Calendar = OfflineCollectionDate.calendar
|
||||||
|
) {
|
||||||
|
self.calendar = calendar
|
||||||
|
self.maximumDate = calendar.startOfDay(for: maximumDate)
|
||||||
|
self.selectedDate = min(calendar.startOfDay(for: selectedDate), self.maximumDate)
|
||||||
|
self.mode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换周历和月历并保留选中日期。
|
||||||
|
mutating func toggleMode() {
|
||||||
|
mode = mode == .week ? .month : .week
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择不晚于服务端今日的日期。
|
||||||
|
mutating func select(_ date: Date) -> Bool {
|
||||||
|
let value = calendar.startOfDay(for: date)
|
||||||
|
guard value <= maximumDate else { return false }
|
||||||
|
selectedDate = value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 横滑一个周或一个月,并返回新的选中日期。
|
||||||
|
@discardableResult
|
||||||
|
mutating func movePage(_ offset: Int) -> Date {
|
||||||
|
let candidate: Date
|
||||||
|
switch mode {
|
||||||
|
case .week:
|
||||||
|
candidate = calendar.date(byAdding: .day, value: offset * 7, to: selectedDate) ?? selectedDate
|
||||||
|
case .month:
|
||||||
|
let current = calendar.dateComponents([.year, .month, .day], from: selectedDate)
|
||||||
|
let monthStart = calendar.date(from: DateComponents(year: current.year, month: current.month, day: 1)) ?? selectedDate
|
||||||
|
let targetMonth = calendar.date(byAdding: .month, value: offset, to: monthStart) ?? monthStart
|
||||||
|
let days = calendar.range(of: .day, in: .month, for: targetMonth)?.count ?? 1
|
||||||
|
candidate = calendar.date(byAdding: .day, value: min(current.day ?? 1, days) - 1, to: targetMonth) ?? targetMonth
|
||||||
|
}
|
||||||
|
selectedDate = min(calendar.startOfDay(for: candidate), maximumDate)
|
||||||
|
return selectedDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断指定方向是否存在不晚于服务端今日的周或月页面。
|
||||||
|
func canMovePage(_ offset: Int) -> Bool {
|
||||||
|
guard offset != 0 else { return false }
|
||||||
|
if offset < 0 { return true }
|
||||||
|
switch mode {
|
||||||
|
case .week:
|
||||||
|
guard let candidate = calendar.date(byAdding: .day, value: offset * 7, to: selectedDate) else { return false }
|
||||||
|
let weekday = calendar.component(.weekday, from: candidate)
|
||||||
|
let daysFromMonday = (weekday + 5) % 7
|
||||||
|
let targetMonday = calendar.date(byAdding: .day, value: -daysFromMonday, to: candidate) ?? candidate
|
||||||
|
return calendar.startOfDay(for: targetMonday) <= maximumDate
|
||||||
|
case .month:
|
||||||
|
let current = calendar.dateComponents([.year, .month], from: selectedDate)
|
||||||
|
guard let monthStart = calendar.date(from: current),
|
||||||
|
let targetMonth = calendar.date(byAdding: .month, value: offset, to: monthStart) else { return false }
|
||||||
|
let maximumMonth = calendar.date(
|
||||||
|
from: calendar.dateComponents([.year, .month], from: maximumDate)
|
||||||
|
) ?? maximumDate
|
||||||
|
return targetMonth <= maximumMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前周的周一至周日。
|
||||||
|
var weekDates: [Date] {
|
||||||
|
let weekday = calendar.component(.weekday, from: selectedDate)
|
||||||
|
let daysFromMonday = (weekday + 5) % 7
|
||||||
|
let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: selectedDate) ?? selectedDate
|
||||||
|
return (0 ..< 7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前月份固定六行、周一开周的 42 个日期。
|
||||||
|
var monthDates: [Date] {
|
||||||
|
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
|
||||||
|
let first = calendar.date(from: parts) ?? selectedDate
|
||||||
|
let weekday = calendar.component(.weekday, from: first)
|
||||||
|
let leading = (weekday + 5) % 7
|
||||||
|
let start = calendar.date(byAdding: .day, value: -leading, to: first) ?? first
|
||||||
|
return (0 ..< 42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选中日期所在周在固定六行月历中的零基行号。
|
||||||
|
var selectedWeekRowIndex: Int {
|
||||||
|
let index = monthDates.firstIndex { calendar.isDate($0, inSameDayAs: selectedDate) } ?? 0
|
||||||
|
return index / 7
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选中月份标题。
|
||||||
|
var monthTitle: String {
|
||||||
|
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
|
||||||
|
return "\(parts.year ?? 0)年\(parts.month ?? 0)月"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 线下收款方式,对应后端 `pay_method`。
|
||||||
|
enum OfflineCollectionPaymentMethod: Int, Codable, CaseIterable, Sendable, Hashable {
|
||||||
|
case wechat = 2
|
||||||
|
case alipay = 1
|
||||||
|
case cash = 3
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self { case .wechat: "微信"; case .alipay: "支付宝"; case .cash: "现金" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImageName: String {
|
||||||
|
switch self { case .wechat: "message.fill"; case .alipay: "a.circle.fill"; case .cash: "banknote.fill" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetName: String {
|
||||||
|
switch self { case .wechat: "payment_method_wechat"; case .alipay: "payment_method_alipay"; case .cash: "payment_method_cash" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款记录的补缴展示状态。
|
||||||
|
enum OfflineCollectionStatus: Int, Codable, Sendable, Hashable {
|
||||||
|
case pending = 0
|
||||||
|
case settled = 1
|
||||||
|
case overdue = 2
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self { case .pending: "待补缴"; case .settled: "已补缴"; case .overdue: "逾期未补缴" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前登录账号在线下收款页面中的展示和请求上下文。
|
||||||
|
struct OfflineCollectionContext: Sendable, Hashable {
|
||||||
|
let collectorName: String
|
||||||
|
let storeName: String
|
||||||
|
let scenicId: Int
|
||||||
|
let scenicName: String
|
||||||
|
|
||||||
|
static func current(appStore: AppStore = .shared) -> OfflineCollectionContext {
|
||||||
|
let session = appStore.session
|
||||||
|
let stores = appStore.permissions.rolePermissionList().flatMap(\.store)
|
||||||
|
let store = stores.first(where: { $0.id == session.currentStoreId }) ?? stores.first
|
||||||
|
let name = [session.realName, session.userName, session.accountDisplayName]
|
||||||
|
.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? "-"
|
||||||
|
return OfflineCollectionContext(
|
||||||
|
collectorName: name,
|
||||||
|
storeName: store?.name.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-",
|
||||||
|
scenicId: session.currentScenicId,
|
||||||
|
scenicName: session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日清页展示的一笔线下收款记录。
|
||||||
|
struct OfflineCollectionRecord: Sendable, Hashable {
|
||||||
|
let collectNo: String
|
||||||
|
let amountFen: Int
|
||||||
|
let paymentMethod: OfflineCollectionPaymentMethod
|
||||||
|
let timeText: String
|
||||||
|
let status: OfflineCollectionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某一营业日的线下收款汇总。
|
||||||
|
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
|
||||||
|
|
||||||
|
static func empty(_ date: String) -> OfflineDailySummary {
|
||||||
|
OfflineDailySummary(
|
||||||
|
businessDate: date, totalCount: 0, totalAmountFen: 0,
|
||||||
|
settledCount: 0, settledAmountFen: 0, pendingCount: 0, pendingAmountFen: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统计接口中的一个待补缴日期标记。
|
||||||
|
struct OfflinePendingDate: Decodable, Sendable, Hashable {
|
||||||
|
let date: String
|
||||||
|
let unpaidAmount: String
|
||||||
|
let unpaidCount: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case date
|
||||||
|
case unpaidAmount = "unpaid_amount"
|
||||||
|
case unpaidCount = "unpaid_count"
|
||||||
|
}
|
||||||
|
|
||||||
|
var unpaidAmountFen: Int { OfflineCollectionMoney.responseFen(unpaidAmount) ?? 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款统计接口响应。
|
||||||
|
struct OfflineCollectionStatisticsResponse: Decodable, Sendable {
|
||||||
|
/// 服务端今日营业日及金额汇总。
|
||||||
|
struct Today: Decodable, Sendable {
|
||||||
|
let date: String
|
||||||
|
let totalAmount: String
|
||||||
|
let paidAmount: String
|
||||||
|
let unpaidAmount: String
|
||||||
|
let collectCount: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case date
|
||||||
|
case totalAmount = "total_amount"
|
||||||
|
case paidAmount = "paid_amount"
|
||||||
|
case unpaidAmount = "unpaid_amount"
|
||||||
|
case collectCount = "collect_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前账号在所选景区内的全部待补缴汇总。
|
||||||
|
struct Pending: Decodable, Sendable {
|
||||||
|
let amount: String
|
||||||
|
let collectCount: Int
|
||||||
|
let dateCount: Int
|
||||||
|
let dates: [OfflinePendingDate]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case amount
|
||||||
|
case collectCount = "collect_count"
|
||||||
|
case dateCount = "date_count"
|
||||||
|
case dates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let today: Today
|
||||||
|
let pending: Pending
|
||||||
|
|
||||||
|
var todaySummary: OfflineDailySummary {
|
||||||
|
let pendingCount = pending.dates.first(where: { $0.date == today.date })?.unpaidCount ?? 0
|
||||||
|
return OfflineDailySummary(
|
||||||
|
businessDate: today.date,
|
||||||
|
totalCount: today.collectCount,
|
||||||
|
totalAmountFen: OfflineCollectionMoney.responseFen(today.totalAmount) ?? 0,
|
||||||
|
settledCount: max(0, today.collectCount - pendingCount),
|
||||||
|
settledAmountFen: OfflineCollectionMoney.responseFen(today.paidAmount) ?? 0,
|
||||||
|
pendingCount: pendingCount,
|
||||||
|
pendingAmountFen: OfflineCollectionMoney.responseFen(today.unpaidAmount) ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单日详情接口响应。
|
||||||
|
struct OfflineCollectionDetailsResponse: Decodable, Sendable {
|
||||||
|
/// 单日详情中的一笔登记记录。
|
||||||
|
struct Collect: Decodable, Sendable {
|
||||||
|
let collectNo: String
|
||||||
|
let amount: String
|
||||||
|
let payMethod: Int
|
||||||
|
let status: Int
|
||||||
|
let time: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case collectNo = "collect_no"
|
||||||
|
case amount
|
||||||
|
case payMethod = "pay_method"
|
||||||
|
case status
|
||||||
|
case time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let date: String
|
||||||
|
let totalAmount: String
|
||||||
|
let collectCount: Int
|
||||||
|
let paidAmount: String
|
||||||
|
let paidCount: Int
|
||||||
|
let unpaidAmount: String
|
||||||
|
let unpaidCount: Int
|
||||||
|
let status: Int?
|
||||||
|
let statusText: String?
|
||||||
|
let collects: [Collect]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case date
|
||||||
|
case totalAmount = "total_amount"
|
||||||
|
case collectCount = "collect_count"
|
||||||
|
case paidAmount = "paid_amount"
|
||||||
|
case paidCount = "paid_count"
|
||||||
|
case unpaidAmount = "unpaid_amount"
|
||||||
|
case unpaidCount = "unpaid_count"
|
||||||
|
case status
|
||||||
|
case statusText = "status_text"
|
||||||
|
case collects
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: OfflineDailySummary {
|
||||||
|
OfflineDailySummary(
|
||||||
|
businessDate: date,
|
||||||
|
totalCount: collectCount,
|
||||||
|
totalAmountFen: OfflineCollectionMoney.responseFen(totalAmount) ?? 0,
|
||||||
|
settledCount: paidCount,
|
||||||
|
settledAmountFen: OfflineCollectionMoney.responseFen(paidAmount) ?? 0,
|
||||||
|
pendingCount: unpaidCount,
|
||||||
|
pendingAmountFen: OfflineCollectionMoney.responseFen(unpaidAmount) ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func records(serverToday: String) -> [OfflineCollectionRecord] {
|
||||||
|
collects.compactMap { item in
|
||||||
|
guard let method = OfflineCollectionPaymentMethod(rawValue: item.payMethod) else { return nil }
|
||||||
|
let rawStatus = OfflineCollectionStatus(rawValue: item.status) ?? .pending
|
||||||
|
let status: OfflineCollectionStatus = rawStatus == .pending && date < serverToday ? .overdue : rawStatus
|
||||||
|
return OfflineCollectionRecord(
|
||||||
|
collectNo: item.collectNo,
|
||||||
|
amountFen: OfflineCollectionMoney.responseFen(item.amount) ?? 0,
|
||||||
|
paymentMethod: method,
|
||||||
|
timeText: String(item.time.prefix(5)),
|
||||||
|
status: status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记一笔线下收款的请求。
|
||||||
|
struct OfflineCollectionRegisterRequest: Encodable, Sendable {
|
||||||
|
let scenicId: Int
|
||||||
|
let amount: String
|
||||||
|
let payMethod: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case scenicId = "scenic_id"
|
||||||
|
case amount
|
||||||
|
case payMethod = "pay_method"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记线下收款响应。
|
||||||
|
struct OfflineCollectionRegisterResponse: Decodable, Sendable {
|
||||||
|
let collectNo: String
|
||||||
|
let amount: String
|
||||||
|
let payMethod: Int
|
||||||
|
let status: Int
|
||||||
|
let createdAt: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case collectNo = "collect_no"
|
||||||
|
case amount
|
||||||
|
case payMethod = "pay_method"
|
||||||
|
case status
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var record: OfflineCollectionRecord {
|
||||||
|
OfflineCollectionRecord(
|
||||||
|
collectNo: collectNo,
|
||||||
|
amountFen: OfflineCollectionMoney.responseFen(amount) ?? 0,
|
||||||
|
paymentMethod: OfflineCollectionPaymentMethod(rawValue: payMethod) ?? .wechat,
|
||||||
|
timeText: OfflineCollectionDate.timePart(createdAt),
|
||||||
|
status: OfflineCollectionStatus(rawValue: status) ?? .pending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 补缴确认数据;金额和笔数仅用于客户端确认,后端只接收景区和日期。
|
||||||
|
struct OfflineSettlementRequest: Encodable, Sendable, Hashable {
|
||||||
|
let scenicId: Int
|
||||||
|
let businessDate: String
|
||||||
|
let amountFen: Int
|
||||||
|
let pendingCount: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case scenicId = "scenic_id"
|
||||||
|
case businessDate = "date"
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(scenicId, forKey: .scenicId)
|
||||||
|
try container.encode(businessDate, forKey: .businessDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次成功补缴的返回结果。
|
||||||
|
struct OfflineSettlementResult: Decodable, Sendable, Hashable {
|
||||||
|
let date: String
|
||||||
|
let updatedCount: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case date
|
||||||
|
case updatedCount = "updated_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记成功弹窗所需数据。
|
||||||
|
struct OfflineCollectionRegistrationReceipt: Sendable {
|
||||||
|
let record: OfflineCollectionRecord
|
||||||
|
let businessDate: String
|
||||||
|
let summary: OfflineDailySummary?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款业务的客户端异常。
|
||||||
|
enum OfflineCollectionError: LocalizedError, Sendable, Equatable {
|
||||||
|
case missingScenic
|
||||||
|
case invalidAmount
|
||||||
|
case noPendingRecords
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingScenic: "请先选择景区"
|
||||||
|
case .invalidAmount: "请输入0.01~99,999.99元的有效金额"
|
||||||
|
case .noPendingRecords: "当前营业日无待补缴记录"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款金额的精确解析与格式化工具。
|
||||||
|
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 }
|
||||||
|
return (parts.first?.count ?? 0) <= 5 && (parts.count == 2 ? parts[1].count : 0) <= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseFen(_ rawValue: String) -> Int? {
|
||||||
|
guard !rawValue.isEmpty, rawValue == rawValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
acceptsEditingText(rawValue), rawValue != "." else { return nil }
|
||||||
|
let parts = rawValue.split(separator: ".", omittingEmptySubsequences: false)
|
||||||
|
guard let whole = Int(parts[0].isEmpty ? "0" : String(parts[0])) else { return nil }
|
||||||
|
let fraction = parts.count == 2 ? String(parts[1]) : ""
|
||||||
|
let cents = fraction.isEmpty ? 0 : (fraction.count == 1 ? (Int(fraction) ?? 0) * 10 : (Int(fraction) ?? 0))
|
||||||
|
let value = whole * 100 + cents
|
||||||
|
return (1 ... maximumFen).contains(value) ? value : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func responseFen(_ value: String) -> Int? { parseFen(value) }
|
||||||
|
static func apiAmount(_ fen: Int) -> String { "\(fen / 100).\(String(format: "%02d", fen % 100))" }
|
||||||
|
static func displayAmount(_ fen: Int) -> String {
|
||||||
|
let whole = fen / 100
|
||||||
|
let cents = fen % 100
|
||||||
|
if cents == 0 { return "\(whole)" }
|
||||||
|
if cents.isMultiple(of: 10) { return "\(whole).\(cents / 10)" }
|
||||||
|
return "\(whole).\(String(format: "%02d", cents))"
|
||||||
|
}
|
||||||
|
static func display(_ fen: Int) -> String { "¥\(displayAmount(fen))" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款统一使用的营业日工具。
|
||||||
|
enum OfflineCollectionDate {
|
||||||
|
static var calendar: Calendar {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.locale = Locale(identifier: "zh_CN")
|
||||||
|
calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")!
|
||||||
|
calendar.firstWeekday = 2
|
||||||
|
return calendar
|
||||||
|
}
|
||||||
|
|
||||||
|
static func businessDate(for date: Date, calendar: Calendar = calendar) -> String {
|
||||||
|
let values = calendar.dateComponents([.year, .month, .day], from: date)
|
||||||
|
return String(format: "%04d-%02d-%02d", values.year ?? 0, values.month ?? 0, values.day ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func date(from value: String, calendar: Calendar = calendar) -> 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: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func timePart(_ value: String) -> String {
|
||||||
|
String((value.split(separator: " ").last ?? Substring(value)).prefix(5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var nonEmpty: String? { isEmpty ? nil : self }
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 收款首页线下收款区域的加载状态与业务数据。
|
||||||
|
final class OfflineCollectionHomeViewModel {
|
||||||
|
private(set) var statistics: OfflineCollectionStatisticsResponse?
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
let context: OfflineCollectionContext
|
||||||
|
private let api: any OfflineCollectionServing
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
|
||||||
|
init(context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) {
|
||||||
|
self.context = context
|
||||||
|
self.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
var todayBusinessDate: String? { statistics?.today.date }
|
||||||
|
var todaySummary: OfflineDailySummary {
|
||||||
|
statistics?.todaySummary ?? .empty(todayBusinessDate ?? "")
|
||||||
|
}
|
||||||
|
var overdueDates: [OfflinePendingDate] {
|
||||||
|
guard let statistics else { return [] }
|
||||||
|
return statistics.pending.dates.filter { $0.date < statistics.today.date }
|
||||||
|
}
|
||||||
|
var earliestOverdueBusinessDate: String? { overdueDates.first?.date }
|
||||||
|
var overdueRecordCount: Int { overdueDates.reduce(0) { $0 + $1.unpaidCount } }
|
||||||
|
var overdueAmountFen: Int { overdueDates.reduce(0) { $0 + $1.unpaidAmountFen } }
|
||||||
|
|
||||||
|
/// 从真实统计接口刷新首页卡片。
|
||||||
|
func load() async {
|
||||||
|
guard context.scenicId > 0 else {
|
||||||
|
errorMessage = OfflineCollectionError.missingScenic.localizedDescription
|
||||||
|
onStateChange?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
onStateChange?()
|
||||||
|
do {
|
||||||
|
statistics = try await api.statistics(scenicId: context.scenicId)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款登记页 ViewModel,负责表单校验和登记后的统计刷新。
|
||||||
|
final class OfflineCollectionRegistrationViewModel {
|
||||||
|
private(set) var amountText = ""
|
||||||
|
private(set) var paymentMethod: OfflineCollectionPaymentMethod = .wechat
|
||||||
|
private(set) var isSubmitting = false
|
||||||
|
|
||||||
|
let context: OfflineCollectionContext
|
||||||
|
let api: any OfflineCollectionServing
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onRegistrationSuccess: ((OfflineCollectionRegistrationReceipt) -> Void)?
|
||||||
|
|
||||||
|
init(context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) {
|
||||||
|
self.context = context
|
||||||
|
self.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
var canSubmit: Bool {
|
||||||
|
!isSubmitting && context.scenicId > 0 && OfflineCollectionMoney.parseFen(amountText) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAmount(_ value: String) {
|
||||||
|
guard OfflineCollectionMoney.acceptsEditingText(value) else { return }
|
||||||
|
guard value != amountText else { return }
|
||||||
|
amountText = value
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectPaymentMethod(_ method: OfflineCollectionPaymentMethod) {
|
||||||
|
guard method != paymentMethod else { return }
|
||||||
|
paymentMethod = method
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按后端现有契约提交一笔线下收款。
|
||||||
|
func submit() async {
|
||||||
|
guard !isSubmitting else { return }
|
||||||
|
guard context.scenicId > 0 else {
|
||||||
|
onShowMessage?(OfflineCollectionError.missingScenic.localizedDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let amountFen = OfflineCollectionMoney.parseFen(amountText) else {
|
||||||
|
onShowMessage?(OfflineCollectionError.invalidAmount.localizedDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting = true
|
||||||
|
onStateChange?()
|
||||||
|
defer {
|
||||||
|
isSubmitting = false
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await api.register(OfflineCollectionRegisterRequest(
|
||||||
|
scenicId: context.scenicId,
|
||||||
|
amount: OfflineCollectionMoney.apiAmount(amountFen),
|
||||||
|
payMethod: paymentMethod.rawValue
|
||||||
|
))
|
||||||
|
let statistics = try? await api.statistics(scenicId: context.scenicId)
|
||||||
|
let responseDate = String(response.createdAt.prefix(10))
|
||||||
|
onRegistrationSuccess?(OfflineCollectionRegistrationReceipt(
|
||||||
|
record: response.record,
|
||||||
|
businessDate: statistics?.today.date ?? responseDate,
|
||||||
|
summary: statistics?.todaySummary
|
||||||
|
))
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAnotherRegistration() {
|
||||||
|
amountText = ""
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日清页的补缴呈现状态。
|
||||||
|
enum OfflineSettlementPresentationState: Sendable, Equatable {
|
||||||
|
case idle
|
||||||
|
case processing
|
||||||
|
case success(OfflineSettlementResult)
|
||||||
|
case failed(String, canRetry: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日清页 ViewModel,管理服务端营业日、日期标记、详情请求与补缴快照。
|
||||||
|
final class OfflineCollectionDailyViewModel {
|
||||||
|
private(set) var businessDate: String
|
||||||
|
private(set) var serverToday: String
|
||||||
|
private(set) var summary: OfflineDailySummary
|
||||||
|
private(set) var records: [OfflineCollectionRecord] = []
|
||||||
|
private(set) var pendingDates: Set<String> = []
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
private(set) var settlementState: OfflineSettlementPresentationState = .idle
|
||||||
|
private(set) var preparedRequest: OfflineSettlementRequest?
|
||||||
|
|
||||||
|
let context: OfflineCollectionContext
|
||||||
|
private let api: any OfflineCollectionServing
|
||||||
|
private var detailRequestVersion = 0
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
|
||||||
|
init(businessDate: String, context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) {
|
||||||
|
self.businessDate = businessDate
|
||||||
|
serverToday = businessDate
|
||||||
|
summary = .empty(businessDate)
|
||||||
|
self.context = context
|
||||||
|
self.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
var isToday: Bool { businessDate == serverToday }
|
||||||
|
var canSettle: Bool {
|
||||||
|
summary.pendingAmountFen > 0 && summary.pendingCount > 0 && settlementState != .processing && !isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次进入或页面重新出现时刷新统计和当前详情。
|
||||||
|
func refresh() async {
|
||||||
|
guard context.scenicId > 0 else {
|
||||||
|
errorMessage = OfflineCollectionError.missingScenic.localizedDescription
|
||||||
|
onStateChange?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
onStateChange?()
|
||||||
|
do {
|
||||||
|
let statistics = try await api.statistics(scenicId: context.scenicId)
|
||||||
|
serverToday = statistics.today.date
|
||||||
|
pendingDates = Set(statistics.pending.dates.map(\.date))
|
||||||
|
if businessDate.isEmpty || businessDate > serverToday { businessDate = serverToday }
|
||||||
|
await loadDetails(for: businessDate)
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择任意不晚于服务端今日的日期,并只接受最后一次请求结果。
|
||||||
|
func selectBusinessDate(_ date: String) async {
|
||||||
|
guard date <= serverToday, settlementState != .processing else { return }
|
||||||
|
businessDate = date
|
||||||
|
preparedRequest = nil
|
||||||
|
settlementState = .idle
|
||||||
|
await loadDetails(for: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDetails(for date: String) async {
|
||||||
|
detailRequestVersion += 1
|
||||||
|
let version = detailRequestVersion
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
records = []
|
||||||
|
summary = .empty(date)
|
||||||
|
onStateChange?()
|
||||||
|
do {
|
||||||
|
let details = try await api.details(scenicId: context.scenicId, date: date)
|
||||||
|
guard version == detailRequestVersion, date == businessDate else { return }
|
||||||
|
summary = details.summary
|
||||||
|
records = details.records(serverToday: serverToday)
|
||||||
|
isLoading = false
|
||||||
|
onStateChange?()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
guard version == detailRequestVersion, date == businessDate else { return }
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareSettlement() throws -> OfflineSettlementRequest {
|
||||||
|
guard canSettle else { throw OfflineCollectionError.noPendingRecords }
|
||||||
|
let request = OfflineSettlementRequest(
|
||||||
|
scenicId: context.scenicId,
|
||||||
|
businessDate: businessDate,
|
||||||
|
amountFen: summary.pendingAmountFen,
|
||||||
|
pendingCount: summary.pendingCount
|
||||||
|
)
|
||||||
|
preparedRequest = request
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelPreparedSettlement() {
|
||||||
|
guard settlementState != .processing else { return }
|
||||||
|
preparedRequest = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按后端现有契约补缴指定日期;失败时允许用户再次提交。
|
||||||
|
func confirmSettlement() async {
|
||||||
|
guard settlementState != .processing, let request = preparedRequest else { return }
|
||||||
|
settlementState = .processing
|
||||||
|
onStateChange?()
|
||||||
|
do {
|
||||||
|
let result = try await api.supplement(request)
|
||||||
|
preparedRequest = nil
|
||||||
|
settlementState = .success(result)
|
||||||
|
await refreshAfterSettlement()
|
||||||
|
} catch {
|
||||||
|
settlementState = .failed(error.localizedDescription, canRetry: true)
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAfterSettlement() async {
|
||||||
|
if let statistics = try? await api.statistics(scenicId: context.scenicId) {
|
||||||
|
serverToday = statistics.today.date
|
||||||
|
pendingDates = Set(statistics.pending.dates.map(\.date))
|
||||||
|
}
|
||||||
|
detailRequestVersion += 1
|
||||||
|
let version = detailRequestVersion
|
||||||
|
if let details = try? await api.details(scenicId: context.scenicId, date: businessDate), version == detailRequestVersion {
|
||||||
|
summary = details.summary
|
||||||
|
records = details.records(serverToday: serverToday)
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSettlementFeedback() {
|
||||||
|
guard settlementState != .processing else { return }
|
||||||
|
settlementState = .idle
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
# AI 修图任务中心需求与接口设计
|
||||||
|
|
||||||
|
> 文档状态:待产品、后端、Android、iOS 联审
|
||||||
|
> 更新日期:2026-08-14
|
||||||
|
> 适用范围:随心瞰商家版 AI 修图任务,不包含人工修图任务
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
AI 修图属于异步长耗时任务。当前用户提交后只能等待或主动返回相册刷新,无法明确知道任务是否仍在排队、预计何时完成、哪些照片成功或失败。
|
||||||
|
|
||||||
|
本需求增加:
|
||||||
|
|
||||||
|
1. 当前账号全部相册的 AI 修图任务列表。
|
||||||
|
2. 单次 AI 修图任务详情。
|
||||||
|
3. AI 修图终态消息推送。
|
||||||
|
4. 从推送和任务列表进入对应任务的完整导航链路;提交成功后仅 Toast 提示并返回相册管理。
|
||||||
|
|
||||||
|
本期解决“看得到进度、完成会通知、结果可直达”的问题,不增加任务取消、批量重试、历史版本管理或后台供应商诊断能力。
|
||||||
|
|
||||||
|
## 2. 核心产品决策
|
||||||
|
|
||||||
|
### 2.1 推送跳转结论
|
||||||
|
|
||||||
|
AI 修图推送优先跳转到对应任务详情页。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 用户点击完成通知时,核心意图是确认这一次任务的结果,而不是重新查找任务。
|
||||||
|
- 详情页可以直接表达成功、部分成功和失败,减少一次列表定位操作。
|
||||||
|
- `ai_retouch_batch_id` 是稳定任务标识,可以支撑前台、后台和冷启动直达。
|
||||||
|
|
||||||
|
以下情况降级进入任务列表:
|
||||||
|
|
||||||
|
- 推送缺少 `ai_retouch_batch_id`。
|
||||||
|
- ID 类型异常、为 0 或负数。
|
||||||
|
- 详情接口返回任务不存在、已失效或当前账号无权访问。
|
||||||
|
- 未来客户端收到无法识别的 AI 修图终态数据。
|
||||||
|
|
||||||
|
### 2.2 推送类型
|
||||||
|
|
||||||
|
| `type` | 业务含义 | 本需求处理 |
|
||||||
|
|---:|---|---|
|
||||||
|
| `10` | 人工修图完成通知 | 保持原业务语义和原点击行为,不作复用 |
|
||||||
|
| `14` | AI 修图任务通知 | 新增,按 `ai_retouch_batch_id` 进入 AI 修图任务详情 |
|
||||||
|
|
||||||
|
后端需在 `PushMsg` 类型定义及类型名称映射中增加:
|
||||||
|
|
||||||
|
```text
|
||||||
|
14 => AI修图任务通知
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 任务唯一标识
|
||||||
|
|
||||||
|
列表、详情、推送和提交响应统一使用已有的 `ai_retouch_batch_id`,类型固定为正整数。不得再引入另一套 `job_id`,避免与素材接口和重新修图接口中的批次标识无法对应。
|
||||||
|
|
||||||
|
### 2.4 列表范围
|
||||||
|
|
||||||
|
任务列表展示当前登录账号有权限查看的全部相册任务,不要求用户先进入某个相册。任务卡必须显示所属相册信息。
|
||||||
|
|
||||||
|
## 3. 用户流程与入口
|
||||||
|
|
||||||
|
### 3.1 主流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
相册管理选择照片
|
||||||
|
→ 提交 AI 修图
|
||||||
|
→ 后端返回 ai_retouch_batch_id
|
||||||
|
→ 客户端 Toast 提示“AI修图任务已提交,完成后将通过消息通知”
|
||||||
|
→ 关闭模板页及可能存在的照片预览页,回到相册管理并刷新列表
|
||||||
|
→ 用户可离开页面
|
||||||
|
→ 任务进入终态后收到 type = 14 推送
|
||||||
|
→ 点击推送进入任务详情
|
||||||
|
→ 查看成功结果或进入相册处理失败项
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 页面入口
|
||||||
|
|
||||||
|
- 相册管理页导航栏右侧增加“修图任务”,进入当前账号的全局任务列表;新增相册页不展示该入口。
|
||||||
|
- AI 修图提交成功仅展示自动消失的 Toast,不弹出查看任务确认框,也不提供立即跳转操作。
|
||||||
|
- 提交成功后回到相册管理页,并刷新相册摘要、数量和当前素材列表。
|
||||||
|
- 任务列表点击卡片进入对应任务详情。
|
||||||
|
- `type = 14` 推送携带有效任务 ID 时直达详情,否则进入任务列表。
|
||||||
|
|
||||||
|
## 4. 任务状态定义
|
||||||
|
|
||||||
|
后端状态值必须稳定,客户端根据枚举映射中文,不依赖后端返回的中文状态名。
|
||||||
|
|
||||||
|
| 状态 | 是否终态 | 列表分组 | 中文展示 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `queued` | 否 | 进行中 | 排队中 | 已受理,尚无子任务开始 |
|
||||||
|
| `processing` | 否 | 进行中 | 修图中 | 至少一个子任务开始,尚未全部结束 |
|
||||||
|
| `succeeded` | 是 | 已完成 | 已完成 | 所有子任务成功 |
|
||||||
|
| `partially_succeeded` | 是 | 已完成 | 部分完成 | 子任务全部结束,既有成功也有失败或取消 |
|
||||||
|
| `failed` | 是 | 失败 | 处理失败 | 子任务全部结束,没有成功结果 |
|
||||||
|
| `canceled` | 是 | 失败 | 已取消 | 任务因素材删除或后台操作被取消 |
|
||||||
|
|
||||||
|
进度必须满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
total = queued + processing + succeeded + failed + canceled
|
||||||
|
completed = succeeded + failed + canceled
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端进度百分比只能通过 `completed / total` 计算;`total <= 0` 时隐藏进度百分比,不显示虚构进度。
|
||||||
|
|
||||||
|
## 5. 任务列表页
|
||||||
|
|
||||||
|
### 5.1 设计稿
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 5.2 页面结构
|
||||||
|
|
||||||
|
1. 导航栏
|
||||||
|
- 返回按钮。
|
||||||
|
- 标题“AI修图任务”。
|
||||||
|
2. 状态筛选
|
||||||
|
- 全部。
|
||||||
|
- 进行中。
|
||||||
|
- 已完成。
|
||||||
|
- 失败。
|
||||||
|
3. 任务卡列表
|
||||||
|
- 所属相册名称。
|
||||||
|
- 用户脱敏手机号。
|
||||||
|
- 提交时间。
|
||||||
|
- 任务编号,例如“任务 #9521”。
|
||||||
|
- 1 至 3 张原图缩略图;多余图片通过数量表达,不继续横向堆叠。
|
||||||
|
- 输出摘要,例如“精修 4 张 · 氛围感 4 张 · 封面 1 张”。
|
||||||
|
- 状态图标与文字标签。
|
||||||
|
- 进度或终态结果摘要。
|
||||||
|
- 详情指示。
|
||||||
|
|
||||||
|
### 5.3 状态展示
|
||||||
|
|
||||||
|
| 状态 | 卡片主信息 | 辅助信息 |
|
||||||
|
|---|---|---|
|
||||||
|
| `queued` | 排队中 | 有 ETA 时显示预计完成时间,否则显示“完成后将通过消息通知” |
|
||||||
|
| `processing` | 已完成 N / M | 显示进度条与预计完成时间 |
|
||||||
|
| `succeeded` | N 张结果已生成 | 显示完成时间 |
|
||||||
|
| `partially_succeeded` | N 张成功 · M 张失败 | 使用橙色警示,不按整单失败展示 |
|
||||||
|
| `failed` | 处理失败 | 引导点击查看详情 |
|
||||||
|
| `canceled` | 已取消 | 显示取消时间;不展示供应商或内部日志 |
|
||||||
|
|
||||||
|
### 5.4 失败信息展示
|
||||||
|
|
||||||
|
- 列表只承担任务级概览,不逐张展开失败原因。
|
||||||
|
- `partially_succeeded` 使用“N 张成功 · M 张失败”作为唯一结果摘要;`failed` 使用“失败”,不再增加独立的失败摘要行。
|
||||||
|
- 列表卡整体可点击并保留“查看详情”指示,具体失败原因统一进入详情查看,避免与结果数量重复。
|
||||||
|
- 后端返回的 `failure_summary` 继续解析并保留,供通知、无逐项错误数据等降级场景使用;列表首版不直接展示。
|
||||||
|
- 详情里的逐照片 `error.message` 仍是用户查看失败原因的主要信息来源,不展示原始错误码。
|
||||||
|
|
||||||
|
### 5.5 刷新与分页
|
||||||
|
|
||||||
|
- 首次进入、下拉刷新和 App 回到前台时拉取第一页。
|
||||||
|
- 页面可见且存在 `queued` 或 `processing` 任务时,每 15 秒刷新第一页。
|
||||||
|
- 页面离开、App 进入后台或页面内所有任务终态后停止定时刷新。
|
||||||
|
- 加载更多使用不透明游标;客户端不得解析或拼接游标。
|
||||||
|
- 服务端排序固定为 `created_at DESC, ai_retouch_batch_id DESC`,避免同一时间创建的任务分页不稳定。
|
||||||
|
- 刷新第一页时按 `ai_retouch_batch_id` 合并,不能产生重复卡片。
|
||||||
|
|
||||||
|
### 5.6 空态和异常
|
||||||
|
|
||||||
|
| 场景 | 展示 |
|
||||||
|
|---|---|
|
||||||
|
| 账号从未提交任务 | “暂无AI修图任务”与“提交修图后可在这里查看进度” |
|
||||||
|
| 当前筛选无数据 | “暂无该状态的任务” |
|
||||||
|
| 首屏加载失败 | 错误说明和“重新加载” |
|
||||||
|
| 加载更多失败 | 保留现有列表,底部提供重试 |
|
||||||
|
|
||||||
|
## 6. 任务详情页
|
||||||
|
|
||||||
|
### 6.1 设计稿
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 6.2 页面结构
|
||||||
|
|
||||||
|
1. 导航栏
|
||||||
|
- 返回按钮。
|
||||||
|
- 标题“任务详情”。
|
||||||
|
- 手动刷新按钮。
|
||||||
|
2. 状态摘要卡
|
||||||
|
- 任务状态图标和文字。
|
||||||
|
- 完成数量与进度条。
|
||||||
|
- 预计完成时间或实际完成时间。
|
||||||
|
- 非终态展示“完成后将通过消息通知你”。
|
||||||
|
3. 相册与任务信息
|
||||||
|
- 相册封面、相册名称、脱敏手机号。
|
||||||
|
- 任务编号、提交时间、开始时间、完成时间或处理耗时。
|
||||||
|
4. 任务内容
|
||||||
|
- 精修、氛围感、封面的目标数量。
|
||||||
|
- 额度预占、实际消耗和释放数量。
|
||||||
|
5. 处理明细
|
||||||
|
- 按原图组织精修和氛围感子任务。
|
||||||
|
- 封面作为独立明细。
|
||||||
|
- 每项展示照片缩略图、文件名、模板名称和状态。
|
||||||
|
- 某个输出失败时,在该照片、该输出项内部紧邻状态展示后端失败原因。
|
||||||
|
6. 页面操作
|
||||||
|
- 成功结果:“查看结果”,进入照片预览对应 Tab。
|
||||||
|
- 失败结果:仅展示后端返回的用户可理解失败原因,不在失败原因后追加操作按钮。
|
||||||
|
- 页面底部始终可提供“查看相册”。
|
||||||
|
|
||||||
|
### 6.3 失败原因展示
|
||||||
|
|
||||||
|
失败原因必须和失败照片、失败输出类型绑定展示,不使用全局弹窗、Toast 或脱离上下文的页面顶部提示代替。
|
||||||
|
|
||||||
|
| 场景 | 展示规则 |
|
||||||
|
|---|---|
|
||||||
|
| 单个精修/氛围感失败 | 在对应输出项状态“生成失败”下方显示“失败原因:{error.message}” |
|
||||||
|
| 同一照片两种输出均失败 | 精修、氛围感分别显示各自原因,不合并为一个模糊原因 |
|
||||||
|
| 封面失败 | 在封面独立明细下方显示原因 |
|
||||||
|
| 任务仍在处理中但已有失败项 | 立即展示已确定的照片失败原因,不等整批任务终态 |
|
||||||
|
| 后端原因为空 | 展示客户端兜底“处理失败,请稍后重试” |
|
||||||
|
| 原因超过两行 | 默认显示两行并提供“查看完整原因”;辅助功能朗读完整文本 |
|
||||||
|
|
||||||
|
视觉规则:
|
||||||
|
|
||||||
|
- 使用红色错误图标、红色“生成失败”和淡红色原因容器;颜色不是唯一状态提示。
|
||||||
|
- `error.message` 使用正文级字号和足够对比度,不使用脚注小字弱化重要信息。
|
||||||
|
- 不展示 `error.code`、供应商名称、堆栈、请求 ID 或服务器路径。
|
||||||
|
- 无论 `retryable` 取值如何,失败原因区域均只展示原因;用户仍可通过详情页底部“查看相册”进入相册管理页。
|
||||||
|
- 用户返回页面或手动刷新后,失败原因随接口最新值更新。
|
||||||
|
|
||||||
|
### 6.4 刷新规则
|
||||||
|
|
||||||
|
- 详情首次出现时立即请求最新数据。
|
||||||
|
- 非终态且页面可见时每 8 秒刷新。
|
||||||
|
- 进入终态、页面离开或 App 进入后台时停止刷新。
|
||||||
|
- 手动刷新与定时刷新不能并发发起重复请求。
|
||||||
|
- 推送不作为唯一状态来源;用户关闭通知权限后仍可通过页面刷新获得终态。
|
||||||
|
|
||||||
|
### 6.5 查看结果规则
|
||||||
|
|
||||||
|
- `output_type = refined`:打开来源原图的照片预览,默认选中“精修后”。
|
||||||
|
- `output_type = atmosphere`:打开来源原图的照片预览,默认选中“氛围感”。
|
||||||
|
- `output_type = cover`:打开所属相册,并定位到生成的封面素材;无法定位时进入相册第一页。
|
||||||
|
- 结果资源已删除或不可用时,隐藏“查看结果”并展示“结果已失效”。
|
||||||
|
|
||||||
|
## 7. 视觉与可访问性规范
|
||||||
|
|
||||||
|
### 7.1 视觉 Token
|
||||||
|
|
||||||
|
| 用途 | 建议值 |
|
||||||
|
|---|---|
|
||||||
|
| 页面背景 | `#F5F7FB` |
|
||||||
|
| 卡片背景 | `#FFFFFF` |
|
||||||
|
| 品牌主色 | `#1677FF` |
|
||||||
|
| 蓝色高光 | `#3A91FF` |
|
||||||
|
| 主文字 | `#111827` |
|
||||||
|
| 次文字 | `#64748B` |
|
||||||
|
| 成功 | `#22A06B` |
|
||||||
|
| 警示/部分完成 | `#F59E0B` |
|
||||||
|
| 失败 | `#EF4444` |
|
||||||
|
| 卡片圆角 | 12–16 pt |
|
||||||
|
|
||||||
|
### 7.2 交互要求
|
||||||
|
|
||||||
|
- 状态必须同时使用图标、文字和颜色,不得只用颜色区分。
|
||||||
|
- 正文文字与背景对比度至少 4.5:1。
|
||||||
|
- 卡片、筛选项和操作按钮的最小触控区域为 44 × 44 pt。
|
||||||
|
- 动态字体至少覆盖系统默认至辅助功能常用档位;文字放大时允许卡片增高。
|
||||||
|
- 进度动画尊重“减弱动态效果”;关闭动画后保留静态进度与文字。
|
||||||
|
- 网络图片使用缩略图地址,并提供占位图和失败占位状态。
|
||||||
|
|
||||||
|
## 8. 后端接口总览
|
||||||
|
|
||||||
|
基础路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/yf-handset-app/photog/travel-album
|
||||||
|
```
|
||||||
|
|
||||||
|
| 方法 | 路径 | 类型 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `POST` | `/ai-retouch` | 扩展现有响应 | 首次/批量提交后返回任务标识 |
|
||||||
|
| `POST` | `/ai-reretouch` | 扩展现有响应 | 重新修图提交后返回任务标识 |
|
||||||
|
| `GET` | `/ai-retouch-job-list` | 新增 | 获取当前账号全部 AI 修图任务 |
|
||||||
|
| `GET` | `/ai-retouch-job-info` | 实现并统一 | 获取指定任务详情 |
|
||||||
|
|
||||||
|
时间字段统一使用 ISO 8601 UTC,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026-08-14T06:26:12.123Z
|
||||||
|
```
|
||||||
|
|
||||||
|
所有 ID 的 JSON 类型必须稳定;本需求中的 `ai_retouch_batch_id`、`user_equity_travel_id` 和素材 ID 均使用整数。
|
||||||
|
|
||||||
|
## 9. 扩展 AI 修图提交响应
|
||||||
|
|
||||||
|
现有 `/ai-retouch` 和 `/ai-reretouch` 请求体保持不变,成功后统一返回可供跳转和立即展示的任务摘要。
|
||||||
|
|
||||||
|
HTTP 状态码:`202 Accepted`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "AI修图任务已提交",
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"status": "queued",
|
||||||
|
"progress": {
|
||||||
|
"total": 9,
|
||||||
|
"queued": 9,
|
||||||
|
"processing": 0,
|
||||||
|
"succeeded": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"canceled": 0
|
||||||
|
},
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
必有字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ai_retouch_batch_id` | int | 大于 0 的任务唯一标识 |
|
||||||
|
| `user_equity_travel_id` | int | 所属相册 ID |
|
||||||
|
| `status` | string | 提交成功时通常为 `queued` |
|
||||||
|
| `progress` | object | 提交时的真实子任务数量 |
|
||||||
|
| `created_at` | string | 服务端受理时间 |
|
||||||
|
|
||||||
|
客户端超时后使用原幂等键重试时,后端必须返回同一个 `ai_retouch_batch_id`,不得创建重复任务。
|
||||||
|
|
||||||
|
## 10. 获取 AI 修图任务列表
|
||||||
|
|
||||||
|
### 10.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-list?status_group=in_progress&limit=20&cursor=<opaque_cursor>
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 类型 | 默认值 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `status_group` | 否 | string | `all` | `all`、`in_progress`、`completed`、`failed` |
|
||||||
|
| `limit` | 否 | int | `20` | 最小 1,最大 50 |
|
||||||
|
| `cursor` | 否 | string | — | 服务端返回的不透明游标,第一页不传 |
|
||||||
|
|
||||||
|
服务端分组映射:
|
||||||
|
|
||||||
|
| `status_group` | 包含状态 |
|
||||||
|
|---|---|
|
||||||
|
| `all` | 全部六种状态 |
|
||||||
|
| `in_progress` | `queued`、`processing` |
|
||||||
|
| `completed` | `succeeded`、`partially_succeeded` |
|
||||||
|
| `failed` | `failed`、`canceled` |
|
||||||
|
|
||||||
|
### 10.2 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"scope": "batch",
|
||||||
|
"status": "processing",
|
||||||
|
"album": {
|
||||||
|
"id": 88,
|
||||||
|
"name": "旅拍相册",
|
||||||
|
"user_phone": "13812348000",
|
||||||
|
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
|
||||||
|
},
|
||||||
|
"source_count": 4,
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"preview_images": [
|
||||||
|
{
|
||||||
|
"material_id": 2031,
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"material_id": 2032,
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"material_id": 2033,
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"progress": {
|
||||||
|
"total": 9,
|
||||||
|
"queued": 3,
|
||||||
|
"processing": 1,
|
||||||
|
"succeeded": 5,
|
||||||
|
"failed": 0,
|
||||||
|
"canceled": 0
|
||||||
|
},
|
||||||
|
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
|
||||||
|
"failure_summary": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:26:18.000Z",
|
||||||
|
"finished_at": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0xNFQwNjoyNjoxMi4xMjNaIiwiaWQiOjk1MjF9",
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 列表字段约定
|
||||||
|
|
||||||
|
| 字段 | 必有 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `album` | 是 | 服务端按登录态校验后返回,客户端不再逐任务查询相册 |
|
||||||
|
| `album.user_phone` | 否 | 返回原始手机号时客户端脱敏;没有时返回空字符串,不返回多种类型 |
|
||||||
|
| `outputs` | 是 | 各输出类型的计划生成数量 |
|
||||||
|
| `preview_images` | 是 | 最多返回 3 项,允许为空数组 |
|
||||||
|
| `progress` | 是 | 六种子任务数量必须满足进度恒等式 |
|
||||||
|
| `estimated_finish_at` | 否 | 无法估算或已终态时返回 `null` |
|
||||||
|
| `failure_summary` | 否 | 任务级可展示摘要;`failed` 时必有,其他状态存在失败子任务时建议返回 |
|
||||||
|
| `next_cursor` | 是 | 无下一页时为 `null` |
|
||||||
|
| `has_more` | 是 | 与 `next_cursor` 语义一致 |
|
||||||
|
|
||||||
|
`estimated_finish_at` 是动态估算而非 SLA。估算变化时允许更新;客户端只展示最新值,不做倒计时承诺。
|
||||||
|
|
||||||
|
`failure_summary` 不代替逐照片失败原因。存在多个不同失败原因时,列表建议返回“N 张照片处理失败,点击查看原因”;只有单一且简短的原因时才直接返回该原因。建议限制在 60 个中文字符以内。
|
||||||
|
|
||||||
|
## 11. 获取 AI 修图任务详情
|
||||||
|
|
||||||
|
### 11.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-info?ai_retouch_batch_id=9521
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ai_retouch_batch_id` | 是 | int | 大于 0 的 AI 修图批次 ID |
|
||||||
|
|
||||||
|
接口不要求客户端再传 `user_equity_travel_id`。后端应从登录态和任务归属完成权限校验,并在响应中返回相册信息。
|
||||||
|
|
||||||
|
### 11.2 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"scope": "batch",
|
||||||
|
"status": "processing",
|
||||||
|
"album": {
|
||||||
|
"id": 88,
|
||||||
|
"name": "旅拍相册",
|
||||||
|
"user_phone": "13812348000",
|
||||||
|
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
|
||||||
|
},
|
||||||
|
"source_count": 4,
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"progress": {
|
||||||
|
"total": 9,
|
||||||
|
"queued": 2,
|
||||||
|
"processing": 1,
|
||||||
|
"succeeded": 5,
|
||||||
|
"failed": 1,
|
||||||
|
"canceled": 0
|
||||||
|
},
|
||||||
|
"quota_settlement": {
|
||||||
|
"status": "partially_settled",
|
||||||
|
"reserved_units": 8,
|
||||||
|
"consumed_units": 5,
|
||||||
|
"released_units": 1,
|
||||||
|
"cover_units": 0
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"target_id": 30101,
|
||||||
|
"source_material": {
|
||||||
|
"id": 2031,
|
||||||
|
"file_name": "IMG_8291.JPG",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
|
||||||
|
},
|
||||||
|
"input_material_ids": [2031],
|
||||||
|
"output_type": "refined",
|
||||||
|
"template": {
|
||||||
|
"id": 11,
|
||||||
|
"name": "自然通透"
|
||||||
|
},
|
||||||
|
"status": "succeeded",
|
||||||
|
"result_asset": {
|
||||||
|
"id": 9201,
|
||||||
|
"material_id": 2031,
|
||||||
|
"url": "https://cdn.example.com/albums/88/2031_refined_v2.jpg",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2031_refined_v2_thumb.jpg"
|
||||||
|
},
|
||||||
|
"error": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:26:18.000Z",
|
||||||
|
"finished_at": "2026-08-14T06:28:40.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_id": 30102,
|
||||||
|
"source_material": {
|
||||||
|
"id": 2032,
|
||||||
|
"file_name": "IMG_8292.JPG",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
|
||||||
|
},
|
||||||
|
"input_material_ids": [2032],
|
||||||
|
"output_type": "atmosphere",
|
||||||
|
"template": {
|
||||||
|
"id": 21,
|
||||||
|
"name": "暖阳氛围"
|
||||||
|
},
|
||||||
|
"status": "processing",
|
||||||
|
"result_asset": null,
|
||||||
|
"error": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:29:02.000Z",
|
||||||
|
"finished_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_id": 30103,
|
||||||
|
"source_material": {
|
||||||
|
"id": 2033,
|
||||||
|
"file_name": "IMG_8293.JPG",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
|
||||||
|
},
|
||||||
|
"input_material_ids": [2033],
|
||||||
|
"output_type": "refined",
|
||||||
|
"template": {
|
||||||
|
"id": 11,
|
||||||
|
"name": "自然通透"
|
||||||
|
},
|
||||||
|
"status": "failed",
|
||||||
|
"result_asset": null,
|
||||||
|
"error": {
|
||||||
|
"code": "AI_PROVIDER_TIMEOUT",
|
||||||
|
"message": "AI服务处理超时,请重新修图",
|
||||||
|
"retryable": true
|
||||||
|
},
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:27:10.000Z",
|
||||||
|
"finished_at": "2026-08-14T06:29:30.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_id": 30109,
|
||||||
|
"source_material": null,
|
||||||
|
"input_material_ids": [2031, 2032, 2033, 2034],
|
||||||
|
"output_type": "cover",
|
||||||
|
"template": {
|
||||||
|
"id": 31,
|
||||||
|
"name": "旅拍拼贴"
|
||||||
|
},
|
||||||
|
"status": "queued",
|
||||||
|
"result_asset": null,
|
||||||
|
"error": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": null,
|
||||||
|
"finished_at": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:26:18.000Z",
|
||||||
|
"finished_at": null,
|
||||||
|
"duration_seconds": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 失败明细
|
||||||
|
|
||||||
|
失败子任务的 `error` 结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "AI_PROVIDER_TIMEOUT",
|
||||||
|
"message": "AI服务处理超时,请重新修图",
|
||||||
|
"retryable": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
约定:
|
||||||
|
|
||||||
|
- `status = failed` 时 `error` 和非空 `error.message` 必须返回,不能只返回错误码。
|
||||||
|
- `message` 必须是经过后端转换、可直接展示给用户的中文文案,建议不超过 120 个中文字符。
|
||||||
|
- `code` 用于客户端判断与联调排查,不直接展示。
|
||||||
|
- 不得返回供应商密钥、内部请求、堆栈、服务器路径或原始异常。
|
||||||
|
- `retryable` 只表达业务上是否允许重新提交,本期详情页不直接发起批量重试。
|
||||||
|
- `queued`、`processing`、`succeeded` 时 `error` 固定返回 `null`,不要返回空对象。
|
||||||
|
- 同一来源照片的精修和氛围感必须分别返回自己的 `error`,客户端不通过素材级公共错误猜测具体失败输出。
|
||||||
|
|
||||||
|
### 11.4 额度结算
|
||||||
|
|
||||||
|
| 状态 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `reserved` | 已预占,尚无子任务完成 |
|
||||||
|
| `partially_settled` | 部分预占已转为消耗或释放 |
|
||||||
|
| `settled` | 所有额度完成结算 |
|
||||||
|
|
||||||
|
必须满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reserved_units >= consumed_units + released_units
|
||||||
|
```
|
||||||
|
|
||||||
|
终态时应满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reserved_units = consumed_units + released_units
|
||||||
|
```
|
||||||
|
|
||||||
|
封面不消耗额度,`cover_units` 固定为 0。
|
||||||
|
|
||||||
|
## 12. 接口错误码
|
||||||
|
|
||||||
|
| HTTP | 业务码 | 场景 | 客户端行为 |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 400 | `INVALID_RETOUCH_STATUS_GROUP` | 列表筛选参数错误 | 回退“全部”并记录联调日志 |
|
||||||
|
| 400 | `INVALID_CURSOR` | 游标无效或过期 | 清空游标并重新加载第一页 |
|
||||||
|
| 400 | `INVALID_RETOUCH_BATCH_ID` | 任务 ID 非法 | 进入任务列表 |
|
||||||
|
| 401 | `UNAUTHORIZED` | 登录态失效 | 走现有重新登录流程,登录后继续待处理路由 |
|
||||||
|
| 404 | `AI_RETOUCH_JOB_NOT_FOUND` | 不存在、已失效或无权访问 | 提示“任务不存在或已失效”,进入任务列表 |
|
||||||
|
| 429 | `TOO_MANY_REQUESTS` | 刷新过于频繁 | 停止本轮轮询,按服务端建议时间重试 |
|
||||||
|
| 500 | `AI_RETOUCH_JOB_QUERY_FAILED` | 服务端异常 | 保留已有数据并提供重试 |
|
||||||
|
|
||||||
|
权限不足建议统一返回 404,避免泄露其他账号任务是否存在。
|
||||||
|
|
||||||
|
## 13. AI 修图推送协议
|
||||||
|
|
||||||
|
### 13.1 发送条件
|
||||||
|
|
||||||
|
- 只在任务第一次从非终态进入 `succeeded`、`partially_succeeded` 或 `failed` 时发送。
|
||||||
|
- `canceled` 默认不发送通知。
|
||||||
|
- 同一 `ai_retouch_batch_id` 只发送一次终态推送。
|
||||||
|
- 后端应通过事务字段或唯一记录保证幂等,例如 `terminal_push_sent_at`。
|
||||||
|
- 设备没有有效极光 Registration ID 时不影响任务结算;用户仍可在任务中心查看结果。
|
||||||
|
|
||||||
|
### 13.2 统一业务结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "AI修图已完成",
|
||||||
|
"content": "「旅拍相册」的9张结果已生成,点击查看。",
|
||||||
|
"msg_id": 2014,
|
||||||
|
"type": 14,
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"status": "succeeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段约定:
|
||||||
|
|
||||||
|
| 字段 | 必有 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `type` | 是 | int | 固定为 `14` |
|
||||||
|
| `msg_id` | 是 | int | 后端业务消息 ID,不是极光 `_j_msgid` |
|
||||||
|
| `data` | 是 | object | 不得发送为 JSON 字符串 |
|
||||||
|
| `ai_retouch_batch_id` | 是 | int | 大于 0;点击详情的主键 |
|
||||||
|
| `user_equity_travel_id` | 是 | int | 用于降级列表和联调排查 |
|
||||||
|
| `status` | 是 | string | 仅允许三个会推送的终态值 |
|
||||||
|
|
||||||
|
### 13.3 通知文案
|
||||||
|
|
||||||
|
| 状态 | 标题 | 正文模板 |
|
||||||
|
|---|---|---|
|
||||||
|
| `succeeded` | AI修图已完成 | `「{相册名}」的{成功数}张结果已生成,点击查看。` |
|
||||||
|
| `partially_succeeded` | AI修图部分完成 | `{成功数}张成功,{失败数}张失败,点击查看详情。` |
|
||||||
|
| `failed` | AI修图未完成 | `本次任务处理失败,点击查看原因。` |
|
||||||
|
|
||||||
|
相册名称为空时,正文使用“本次AI修图任务”,不得出现空书名号。
|
||||||
|
|
||||||
|
### 13.4 iOS 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"title": "AI修图已完成",
|
||||||
|
"body": "「旅拍相册」的9张结果已生成,点击查看。"
|
||||||
|
},
|
||||||
|
"sound": "default"
|
||||||
|
},
|
||||||
|
"msg_id": 2014,
|
||||||
|
"type": 14,
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"status": "succeeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Android 的 `extras` 中放入同一业务对象;字段名、字段类型和业务含义必须与 iOS 一致。
|
||||||
|
|
||||||
|
## 14. 推送点击路由
|
||||||
|
|
||||||
|
### 14.1 路由规则
|
||||||
|
|
||||||
|
```text
|
||||||
|
收到通知点击
|
||||||
|
→ 解析业务 type
|
||||||
|
→ type != 14:继续走现有类型路由
|
||||||
|
→ type = 14:解析 data.ai_retouch_batch_id
|
||||||
|
→ 合法:打开 AI 修图任务详情
|
||||||
|
→ 缺失/非法:打开 AI 修图任务列表
|
||||||
|
→ 详情请求 404:提示后返回任务列表
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.2 生命周期
|
||||||
|
|
||||||
|
- 前台、后台点击和冷启动使用同一套 `type + data` 解析规则。
|
||||||
|
- 未登录时暂存 `ai_retouch_batch_id`,登录成功且主 Tab 建立后继续路由。
|
||||||
|
- 账号切换或退出登录时清除上一账号尚未执行的待处理路由。
|
||||||
|
- 同一系统通知的 request identifier 只处理一次。
|
||||||
|
- 客户端不得从标题或正文解析任务 ID。
|
||||||
|
|
||||||
|
### 14.3 向后兼容
|
||||||
|
|
||||||
|
- 旧客户端无法识别 `type = 14` 时按既有默认逻辑进入消息中心,不应崩溃。
|
||||||
|
- 新客户端收到 `type = 10` 时仍按人工修图处理,不进入 AI 修图任务中心。
|
||||||
|
- 对 `data` 中新增的未知字段,客户端应忽略。
|
||||||
|
|
||||||
|
### 14.4 站内消息详情入口
|
||||||
|
|
||||||
|
- `type = 14` 的站内消息详情页在消息正文下方展示品牌蓝主按钮“查看任务详情”。
|
||||||
|
- 消息 `extra_data.ai_retouch_batch_id` 合法时进入对应任务详情;兼容 `extra_data.data.ai_retouch_batch_id` 包装结构。
|
||||||
|
- 任务 ID 缺失、无法解析或小于等于 0 时,按钮仍保留,点击后降级进入 AI 修图任务列表。
|
||||||
|
- `type = 10` 人工修图及其他类型消息不展示该入口,原有消息详情行为保持不变。
|
||||||
|
- 底部“删除并返回”继续作为独立的破坏性操作,不承担业务跳转职责。
|
||||||
|
|
||||||
|
## 15. 数据、安全与一致性
|
||||||
|
|
||||||
|
- 列表和详情只能返回当前登录账号有权限查看的任务。
|
||||||
|
- 后端从登录态确定账号范围,不接受客户端传入用户 ID 扩大查询范围。
|
||||||
|
- 手机号仅用于业务识别,客户端统一脱敏展示。
|
||||||
|
- 缩略图和结果 URL 应使用受控 CDN 地址;如需签名,过期时间应覆盖合理浏览时长。
|
||||||
|
- 任务状态和所有子任务状态必须在同一份一致性快照中返回。
|
||||||
|
- 推送发送失败不得回滚已完成的 AI 任务或额度结算。
|
||||||
|
- 已删除且不再允许客户端展示的素材,不应继续通过任务详情暴露原图或结果 URL。
|
||||||
|
- 如果任务已整体不可见,详情统一返回 `AI_RETOUCH_JOB_NOT_FOUND`。
|
||||||
|
|
||||||
|
## 16. 验收标准
|
||||||
|
|
||||||
|
### 16.1 页面
|
||||||
|
|
||||||
|
- 能从相册管理页进入当前账号全部 AI 修图任务列表,新增相册页不展示该入口。
|
||||||
|
- 四种筛选与六种状态映射正确。
|
||||||
|
- 进行中任务显示真实完成数量;有 ETA 才显示预计完成时间。
|
||||||
|
- 详情可展示任务摘要、相册信息、额度和逐输出明细。
|
||||||
|
- 单照片、单输出失败时,失败原因展示在对应明细内部,不需要用户从任务级摘要猜测失败对象。
|
||||||
|
- 后端原因超过两行时可查看完整内容;原因缺失时有稳定兜底文案。
|
||||||
|
- 成功结果可进入对应照片结果,失败项可进入所属相册。
|
||||||
|
- 页面离开或任务终态后停止轮询。
|
||||||
|
|
||||||
|
### 16.2 接口
|
||||||
|
|
||||||
|
- 两个提交接口返回稳定且可跳转的 `ai_retouch_batch_id`。
|
||||||
|
- 游标分页在任务新增和状态更新期间不重复、不漏项。
|
||||||
|
- 进度数量满足恒等式,任务终态与子任务状态一致。
|
||||||
|
- ETA 缺失时返回 `null`,不返回空字符串或 0 时间。
|
||||||
|
- `status = failed` 的子任务必有非空 `error.message`,且详情能够按照片和输出类型准确展示。
|
||||||
|
- 失败信息不包含供应商、堆栈、服务器路径等内部实现。
|
||||||
|
|
||||||
|
### 16.3 推送
|
||||||
|
|
||||||
|
- `type = 14` 未被其他业务占用,后端类型名称映射完整。
|
||||||
|
- `type = 10` 人工修图和 `type = 14` AI 修图互不影响。
|
||||||
|
- 成功、部分成功、失败各只发送一次终态通知。
|
||||||
|
- 前台、后台、冷启动、未登录和重复点击均符合路由规则。
|
||||||
|
- 缺少任务 ID、非法 ID、任务失效时均安全降级到任务列表。
|
||||||
|
|
||||||
|
## 17. 本期不做
|
||||||
|
|
||||||
|
- 从任务中心取消 AI 修图任务。
|
||||||
|
- 一键批量重试失败子任务。
|
||||||
|
- 展示 AI 供应商、内部错误堆栈或链路日志。
|
||||||
|
- 展示同一照片的历史修图版本。
|
||||||
|
- 为 ETA 提供 SLA 倒计时承诺。
|
||||||
|
- 修改 `type = 10` 人工修图协议。
|
||||||
|
|
||||||
|
## 18. 设计稿生成说明
|
||||||
|
|
||||||
|
两张设计稿使用内置 ImageGen 生成,参考现有 AI 修图 V2 的浅色相册管理页面、旅行摄影素材和状态视觉语言。
|
||||||
|
|
||||||
|
- 任务列表:853 × 1844,展示进行中、已完成、部分完成三种代表性卡片。
|
||||||
|
- 任务详情 V2:852 × 1846,展示处理中的任务、6/9 进度、ETA、消息通知说明,以及单照片“生成失败 + 失败原因”的内联状态。
|
||||||
|
- 设计稿用于产品和研发对齐;实际 UIKit 实现应使用项目内颜色、字体、SnapKit 和 SF Symbols,不把设计稿作为页面背景切图。
|
||||||
@@ -86,10 +86,10 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
|||||||
|
|
||||||
| `scope` | 使用场景 |
|
| `scope` | 使用场景 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `batch` | 网格多选 AI 修图 |
|
| `batch` | 网格多选 AI 修图,或预览页尚无 AI 结果 Tab 时首次修图 |
|
||||||
| `all_variants` | 预览页无 Tab,或当前是原图 Tab |
|
| `all_variants` | 预览页已有 AI 结果 Tab;当前为原图、精修后或氛围感均使用此范围 |
|
||||||
| `refined_only` | 当前是精修后 Tab |
|
| `refined_only` | 仅重修精修结果的接口能力,当前客户端无独立入口 |
|
||||||
| `atmosphere_only` | 当前是氛围感 Tab |
|
| `atmosphere_only` | 仅重修氛围感结果的接口能力,当前客户端无独立入口 |
|
||||||
|
|
||||||
`user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。
|
`user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。
|
||||||
|
|
||||||
@@ -117,6 +117,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
|||||||
"id": "tpl_refined_12",
|
"id": "tpl_refined_12",
|
||||||
"name": "清透精修",
|
"name": "清透精修",
|
||||||
"preview_url": "https://cdn.example.com/templates/refined_12.jpg",
|
"preview_url": "https://cdn.example.com/templates/refined_12.jpg",
|
||||||
|
"before_url": "https://cdn.example.com/templates/refined_12_before.jpg",
|
||||||
|
"after_url": "https://cdn.example.com/templates/refined_12_after.jpg",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -135,6 +137,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
|||||||
"id": "tpl_atmosphere_06",
|
"id": "tpl_atmosphere_06",
|
||||||
"name": "暖阳",
|
"name": "暖阳",
|
||||||
"preview_url": "https://cdn.example.com/templates/atmosphere_06.jpg",
|
"preview_url": "https://cdn.example.com/templates/atmosphere_06.jpg",
|
||||||
|
"before_url": "https://cdn.example.com/templates/atmosphere_06_before.jpg",
|
||||||
|
"after_url": "https://cdn.example.com/templates/atmosphere_06_after.jpg",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -153,6 +157,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
|||||||
"id": "tpl_cover_03",
|
"id": "tpl_cover_03",
|
||||||
"name": "旅行画册",
|
"name": "旅行画册",
|
||||||
"preview_url": "https://cdn.example.com/templates/cover_03.jpg",
|
"preview_url": "https://cdn.example.com/templates/cover_03.jpg",
|
||||||
|
"before_url": "https://cdn.example.com/templates/cover_03_before.jpg",
|
||||||
|
"after_url": "https://cdn.example.com/templates/cover_03_after.jpg",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -178,6 +184,7 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
|||||||
|
|
||||||
- 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。
|
- 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。
|
||||||
- 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。
|
- 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。
|
||||||
|
- `preview_url` 用于模板卡片缩略图;`before_url` 与 `after_url` 必须是同尺寸、同构图的配对图片,供客户端滑动对比。
|
||||||
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
|
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
|
||||||
- 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。
|
- 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。
|
||||||
- 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。
|
- 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。
|
||||||
@@ -219,7 +226,7 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 5.4.2 预览页原图 Tab
|
#### 5.4.2 预览页已有 AI 结果 Tab
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -227,10 +234,6 @@ Content-Type: application/json
|
|||||||
"scope": "all_variants",
|
"scope": "all_variants",
|
||||||
"source_material_ids": ["2031"],
|
"source_material_ids": ["2031"],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
|
||||||
"type": "refined",
|
|
||||||
"template_id": "tpl_refined_15"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "atmosphere",
|
"type": "atmosphere",
|
||||||
"template_id": "tpl_atmosphere_09"
|
"template_id": "tpl_atmosphere_09"
|
||||||
@@ -239,7 +242,7 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
如用户未选氛围感模板,`outputs` 中不传 `atmosphere`。缺失表示“本次不处理”,不表示删除现有氛围感图。
|
已有精修或氛围感结果时,无论当前位于原图、精修后还是氛围感 Tab,精修和氛围感模板均为选填,但至少选择一种。`outputs` 只传本次选中的结果类型;缺失表示“本次不处理”,不表示删除或覆盖对应的已有结果。
|
||||||
|
|
||||||
#### 5.4.3 只重新精修
|
#### 5.4.3 只重新精修
|
||||||
|
|
||||||
@@ -278,7 +281,7 @@ Content-Type: application/json
|
|||||||
| `scope` | 原图数 | 精修 | 氛围感 | 封面 |
|
| `scope` | 原图数 | 精修 | 氛围感 | 封面 |
|
||||||
|---|---:|---|---|---|
|
|---|---:|---|---|---|
|
||||||
| `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 |
|
| `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 |
|
||||||
| `all_variants` | 必须为 1 | 必选 | 可选 | 禁止 |
|
| `all_variants` | 必须为 1 | 可选(与氛围感至少一项) | 可选(与精修至少一项) | 禁止 |
|
||||||
| `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 |
|
| `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 |
|
||||||
| `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 |
|
| `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 |
|
||||||
|
|
||||||
|
|||||||
@@ -114,9 +114,9 @@ Cell 左上角展示稳定的修图状态:
|
|||||||
| 当前情况 | 模板要求 | 生成与覆盖规则 |
|
| 当前情况 | 模板要求 | 生成与覆盖规则 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 |
|
| 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 |
|
||||||
| 已显示 Tab,当前为“原图” | 精修必选,氛围感可选 | 重新生成精修图;如选氛围感则也重新生成。新结果成功后原子替换对应旧结果 |
|
| 已显示 Tab,当前为“原图” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
| 已显示 Tab,当前为“精修后” | 精修必选 | 只重新生成精修图,成功后覆盖旧精修图,不影响氛围感图 |
|
| 已显示 Tab,当前为“精修后” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
| 已显示 Tab,当前为“氛围感” | 氛围感必选 | 只重新生成氛围感图,成功后覆盖旧氛围感图,不影响精修图 |
|
| 已显示 Tab,当前为“氛围感” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
|
|
||||||
重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。
|
重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# AI 自动修图后端接口改造(精简版)
|
||||||
|
|
||||||
|
## 一、需要修改的接口
|
||||||
|
|
||||||
|
### 1. 创建相册
|
||||||
|
|
||||||
|
`POST /api/yf-handset-app/photog/travel-album/create`
|
||||||
|
|
||||||
|
请求新增:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auto_retouch_config": {
|
||||||
|
"enabled": true,
|
||||||
|
"refined_template_id": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `enabled = true` 时,`refined_template_id` 必填且模板必须有效。
|
||||||
|
- `enabled = false` 时,服务端将 `refined_template_id` 规范化为 `null`。
|
||||||
|
- 响应返回完整 `auto_retouch_config`。
|
||||||
|
|
||||||
|
### 2. 相册详情和列表
|
||||||
|
|
||||||
|
- `GET /api/yf-handset-app/photog/travel-album/info`
|
||||||
|
- `GET /api/yf-handset-app/photog/travel-album/list`
|
||||||
|
|
||||||
|
每个相册新增响应字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auto_retouch_config": {
|
||||||
|
"enabled": true,
|
||||||
|
"refined_template_id": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 历史相册没有配置时按关闭状态返回。
|
||||||
|
|
||||||
|
## 二、需要新增的接口
|
||||||
|
|
||||||
|
### 更新相册自动修图配置
|
||||||
|
|
||||||
|
`POST /api/yf-handset-app/photog/travel-album/auto-retouch-config`
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"enabled": true,
|
||||||
|
"refined_template_id": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应 `data`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"refined_template_id": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 返回服务端规范化后的完整配置。
|
||||||
|
- 多设备同时修改时采用最后一次写入生效。
|
||||||
|
|
||||||
|
## 三、无需新增但需要确保可用的接口
|
||||||
|
|
||||||
|
- `POST .../upload-material`:成功后必须返回稳定的服务端素材 `id`。
|
||||||
|
- `GET .../ai-retouch-templates`:直接使用现有 `refined_templates`,无需新增模板描述字段。
|
||||||
|
- `POST .../ai-retouch`:请求和响应保持现状,不增加 `client_request_id`。
|
||||||
|
- `GET .../ai-retouch-job-info`:支持按任务批次查询 `queued / processing / succeeded / partially_succeeded / failed / canceled` 状态。
|
||||||
|
|
||||||
|
客户端会在 `upload-material` 成功后提交 AI 任务,并在前台每 8 秒查询任务状态。AI 额度不足、模板失效或任务失败都不能回滚原图上传结果。
|
||||||
|
|
||||||
|
由于 `ai-retouch` 不提供幂等能力,客户端在请求超时、断网等结果不确定的情况下不得自动重复提交;只能先同步素材或任务状态。若无法确认是否已创建任务,需提示用户“提交状态未知”,避免直接重试导致重复任务或重复扣额。
|
||||||
|
|
||||||
|
## 四、后端验收重点
|
||||||
|
|
||||||
|
1. 创建、详情和列表中的配置字段保持一致。
|
||||||
|
2. 配置关闭时服务端将 `refined_template_id` 规范化为 `null`。
|
||||||
|
3. 原图登记成功后可以使用现有 `ai-retouch` 接口创建单素材精修任务。
|
||||||
|
4. AI 任务失败或额度不足时,原图上传结果保持成功状态。
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# AI 自动修图接口补充
|
||||||
|
|
||||||
|
本文仅描述相册级自动修图新增契约;现有手动批量 AI 修图接口保持不变。
|
||||||
|
|
||||||
|
## 相册配置
|
||||||
|
|
||||||
|
`create` 请求新增:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auto_retouch_config": {
|
||||||
|
"enabled": true,
|
||||||
|
"refined_template_id": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`create`、`info`、`list` 响应返回服务端规范化后的完整配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auto_retouch_config": {
|
||||||
|
"enabled": true,
|
||||||
|
"refined_template_id": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
旧相册缺少 `auto_retouch_config` 时,客户端按关闭处理。
|
||||||
|
|
||||||
|
## 更新配置
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/yf-handset-app/photog/travel-album/auto-retouch-config
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"enabled": true,
|
||||||
|
"refined_template_id": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应 `data` 为服务端规范化后的完整配置。多设备同时修改时采用最后一次写入生效。
|
||||||
|
|
||||||
|
## AI 任务提交
|
||||||
|
|
||||||
|
`POST ai-retouch` 请求和响应保持现状,不增加 `client_request_id`。请求结果不确定时客户端不得自动重复提交,应先同步素材或任务状态;无法确认时提示用户“提交状态未知”。
|
||||||
|
|
||||||
|
自动修图只使用现有模板接口的 `refined_templates`,不展示模板描述,也不自动生成氛围感或封面。
|
||||||
@@ -50,11 +50,26 @@ protocol TravelAlbumServing {
|
|||||||
/// 拉取当前景区可用的 AI 修图模板。
|
/// 拉取当前景区可用的 AI 修图模板。
|
||||||
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
||||||
|
|
||||||
|
/// 更新相册级自动 AI 修图配置。
|
||||||
|
func updateAutoRetouchConfiguration(
|
||||||
|
_ request: TravelAlbumAutoRetouchConfigurationRequest
|
||||||
|
) async throws -> TravelAlbumAutoRetouchConfiguration
|
||||||
|
|
||||||
/// 提交相册素材 AI 修图任务。
|
/// 提交相册素材 AI 修图任务。
|
||||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||||
|
|
||||||
/// 提交单张素材重新修图任务。
|
/// 提交单张素材重新修图任务。
|
||||||
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||||
|
|
||||||
|
/// 拉取当前账号的 AI 修图任务列表。
|
||||||
|
func aiRetouchJobList(
|
||||||
|
statusGroup: TravelAlbumAIJobFilter,
|
||||||
|
limit: Int,
|
||||||
|
cursor: String?
|
||||||
|
) async throws -> TravelAlbumAIJobListResponse
|
||||||
|
|
||||||
|
/// 拉取指定 AI 修图任务详情。
|
||||||
|
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -197,17 +212,51 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 提交相册素材 AI 修图任务;服务端 data 内容无需客户端消费。
|
/// 更新自动修图配置并返回服务端最新版本。
|
||||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
|
func updateAutoRetouchConfiguration(
|
||||||
let _: EmptyPayload = try await client.send(
|
_ request: TravelAlbumAutoRetouchConfigurationRequest
|
||||||
APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)
|
) async throws -> TravelAlbumAutoRetouchConfiguration {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(method: .post, path: "\(basePath)/auto-retouch-config", body: request)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 提交重新修图任务;服务端返回的批次与额度信息当前无需消费。
|
/// 提交相册素材 AI 修图任务并返回任务摘要。
|
||||||
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||||
let _: EmptyPayload = try await client.send(
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request))
|
||||||
APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request)
|
}
|
||||||
|
|
||||||
|
/// 提交重新修图任务并返回任务摘要。
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||||
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取当前账号的 AI 修图任务列表。
|
||||||
|
func aiRetouchJobList(
|
||||||
|
statusGroup: TravelAlbumAIJobFilter,
|
||||||
|
limit: Int = 20,
|
||||||
|
cursor: String? = nil
|
||||||
|
) async throws -> TravelAlbumAIJobListResponse {
|
||||||
|
var queryItems = [
|
||||||
|
URLQueryItem(name: "status_group", value: statusGroup.rawValue),
|
||||||
|
URLQueryItem(name: "limit", value: String(min(50, max(1, limit)))),
|
||||||
|
]
|
||||||
|
if let cursor = cursor?.trimmingCharacters(in: .whitespacesAndNewlines), !cursor.isEmpty {
|
||||||
|
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
|
||||||
|
}
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(method: .get, path: "\(basePath)/ai-retouch-job-list", queryItems: queryItems)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取指定 AI 修图任务详情。
|
||||||
|
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/ai-retouch-job-info",
|
||||||
|
queryItems: [URLQueryItem(name: "ai_retouch_batch_id", value: String(batchId))]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// AI 修图任务筛选分组,对应任务列表接口的 `status_group`。
|
||||||
|
enum TravelAlbumAIJobFilter: String, CaseIterable, Sendable, Hashable {
|
||||||
|
case all
|
||||||
|
case inProgress = "in_progress"
|
||||||
|
case completed
|
||||||
|
case failed
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .all: "全部"
|
||||||
|
case .inProgress: "进行中"
|
||||||
|
case .completed: "已完成"
|
||||||
|
case .failed: "失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务及子任务状态;未知值安全降级,避免新增后端状态导致整页解码失败。
|
||||||
|
enum TravelAlbumAIJobStatus: Sendable, Hashable {
|
||||||
|
case queued
|
||||||
|
case processing
|
||||||
|
case succeeded
|
||||||
|
case partiallySucceeded
|
||||||
|
case failed
|
||||||
|
case canceled
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
init(rawValue: String) {
|
||||||
|
switch rawValue {
|
||||||
|
case "queued": self = .queued
|
||||||
|
case "processing": self = .processing
|
||||||
|
case "succeeded": self = .succeeded
|
||||||
|
case "partially_succeeded": self = .partiallySucceeded
|
||||||
|
case "failed": self = .failed
|
||||||
|
case "canceled": self = .canceled
|
||||||
|
default: self = .unknown(rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawValue: String {
|
||||||
|
switch self {
|
||||||
|
case .queued: "queued"
|
||||||
|
case .processing: "processing"
|
||||||
|
case .succeeded: "succeeded"
|
||||||
|
case .partiallySucceeded: "partially_succeeded"
|
||||||
|
case .failed: "failed"
|
||||||
|
case .canceled: "canceled"
|
||||||
|
case .unknown(let value): value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isInProgress: Bool { self == .queued || self == .processing }
|
||||||
|
var isTerminal: Bool { !isInProgress && !isUnknown }
|
||||||
|
private var isUnknown: Bool { if case .unknown = self { true } else { false } }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .queued: "排队中"
|
||||||
|
case .processing: "修图中"
|
||||||
|
case .succeeded: "已完成"
|
||||||
|
case .partiallySucceeded: "部分完成"
|
||||||
|
case .failed: "失败"
|
||||||
|
case .canceled: "已取消"
|
||||||
|
case .unknown: "状态更新中"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIJobStatus: Decodable {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图输出类型。
|
||||||
|
enum TravelAlbumAIJobOutputType: Sendable, Hashable {
|
||||||
|
case refined
|
||||||
|
case atmosphere
|
||||||
|
case cover
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
init(rawValue: String) {
|
||||||
|
switch rawValue {
|
||||||
|
case "refined": self = .refined
|
||||||
|
case "atmosphere": self = .atmosphere
|
||||||
|
case "cover": self = .cover
|
||||||
|
default: self = .unknown(rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "原图精修"
|
||||||
|
case .atmosphere: "氛围感"
|
||||||
|
case .cover: "封面"
|
||||||
|
case .unknown: "其他结果"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewKind: TravelAlbumPreviewAssetKind? {
|
||||||
|
switch self {
|
||||||
|
case .refined: .retouched
|
||||||
|
case .atmosphere: .atmosphere
|
||||||
|
case .cover: .cover
|
||||||
|
case .unknown: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIJobOutputType: Decodable {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图子任务数量进度。
|
||||||
|
struct TravelAlbumAIJobProgress: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let total: Int
|
||||||
|
let queued: Int
|
||||||
|
let processing: Int
|
||||||
|
let succeeded: Int
|
||||||
|
let failed: Int
|
||||||
|
let canceled: Int
|
||||||
|
|
||||||
|
var completed: Int { min(total, succeeded + failed + canceled) }
|
||||||
|
var fraction: Double { total > 0 ? min(1, Double(completed) / Double(total)) : 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图提交成功后返回的任务摘要。
|
||||||
|
struct TravelAlbumAIJobSubmission: Decodable, Sendable, Equatable {
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let createdAt: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case status, progress
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务所属相册摘要。
|
||||||
|
struct TravelAlbumAIJobAlbum: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let userPhone: String
|
||||||
|
let coverURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name
|
||||||
|
case userPhone = "user_phone"
|
||||||
|
case coverURL = "cover_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务计划输出数量。
|
||||||
|
struct TravelAlbumAIJobOutput: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let type: TravelAlbumAIJobOutputType
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务列表缩略图。
|
||||||
|
struct TravelAlbumAIJobPreviewImage: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let materialId: Int
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case materialId = "material_id"
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务列表项。
|
||||||
|
struct TravelAlbumAIJobSummary: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
var id: Int { aiRetouchBatchId }
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let scope: String
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let album: TravelAlbumAIJobAlbum
|
||||||
|
let sourceCount: Int
|
||||||
|
let outputs: [TravelAlbumAIJobOutput]
|
||||||
|
let previewImages: [TravelAlbumAIJobPreviewImage]
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let estimatedFinishAt: String?
|
||||||
|
let failureSummary: String?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case scope, status, album, outputs, progress
|
||||||
|
case sourceCount = "source_count"
|
||||||
|
case previewImages = "preview_images"
|
||||||
|
case estimatedFinishAt = "estimated_finish_at"
|
||||||
|
case failureSummary = "failure_summary"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayFailureSummary: String? {
|
||||||
|
guard status == .failed || status == .partiallySucceeded || progress.failed > 0 else { return nil }
|
||||||
|
let value = failureSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return value.isEmpty ? "部分照片处理失败,点击查看原因" : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务游标分页响应。
|
||||||
|
struct TravelAlbumAIJobListResponse: Decodable, Sendable, Equatable {
|
||||||
|
let items: [TravelAlbumAIJobSummary]
|
||||||
|
let nextCursor: String?
|
||||||
|
let hasMore: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case items
|
||||||
|
case nextCursor = "next_cursor"
|
||||||
|
case hasMore = "has_more"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图额度结算信息。
|
||||||
|
struct TravelAlbumAIJobQuotaSettlement: Decodable, Sendable, Equatable {
|
||||||
|
let status: String
|
||||||
|
let reservedUnits: Int
|
||||||
|
let consumedUnits: Int
|
||||||
|
let releasedUnits: Int
|
||||||
|
let coverUnits: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case status
|
||||||
|
case reservedUnits = "reserved_units"
|
||||||
|
case consumedUnits = "consumed_units"
|
||||||
|
case releasedUnits = "released_units"
|
||||||
|
case coverUnits = "cover_units"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图目标的来源素材。
|
||||||
|
struct TravelAlbumAIJobSourceMaterial: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let fileName: String
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case fileName = "file_name"
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图目标使用的模板摘要。
|
||||||
|
struct TravelAlbumAIJobTemplate: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图成功结果资源。
|
||||||
|
struct TravelAlbumAIJobResultAsset: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let materialId: Int
|
||||||
|
let url: String
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case materialId = "material_id"
|
||||||
|
case url
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可直接展示给用户的 AI 修图失败信息。
|
||||||
|
struct TravelAlbumAIJobError: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let code: String
|
||||||
|
let message: String
|
||||||
|
let retryable: Bool
|
||||||
|
|
||||||
|
var displayMessage: String {
|
||||||
|
let value = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return value.isEmpty ? "处理失败,请前往相册重新修图" : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个输出目标的处理明细。
|
||||||
|
struct TravelAlbumAIJobTarget: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
var id: Int { targetId }
|
||||||
|
let targetId: Int
|
||||||
|
let sourceMaterial: TravelAlbumAIJobSourceMaterial?
|
||||||
|
let inputMaterialIds: [Int]
|
||||||
|
let outputType: TravelAlbumAIJobOutputType
|
||||||
|
let template: TravelAlbumAIJobTemplate?
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let resultAsset: TravelAlbumAIJobResultAsset?
|
||||||
|
let error: TravelAlbumAIJobError?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case targetId = "target_id"
|
||||||
|
case sourceMaterial = "source_material"
|
||||||
|
case inputMaterialIds = "input_material_ids"
|
||||||
|
case outputType = "output_type"
|
||||||
|
case template, status, error
|
||||||
|
case resultAsset = "result_asset"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayFailureMessage: String? {
|
||||||
|
guard status == .failed else { return nil }
|
||||||
|
return error?.displayMessage ?? "处理失败,请前往相册重新修图"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务完整详情。
|
||||||
|
struct TravelAlbumAIJobDetail: Decodable, Sendable, Equatable {
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let scope: String
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let album: TravelAlbumAIJobAlbum
|
||||||
|
let sourceCount: Int
|
||||||
|
let outputs: [TravelAlbumAIJobOutput]
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let quotaSettlement: TravelAlbumAIJobQuotaSettlement
|
||||||
|
let targets: [TravelAlbumAIJobTarget]
|
||||||
|
let estimatedFinishAt: String?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
let durationSeconds: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case scope, status, album, outputs, progress, targets
|
||||||
|
case sourceCount = "source_count"
|
||||||
|
case quotaSettlement = "quota_settlement"
|
||||||
|
case estimatedFinishAt = "estimated_finish_at"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
case durationSeconds = "duration_seconds"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务日期展示工具。
|
||||||
|
enum TravelAlbumAIJobDateFormatter {
|
||||||
|
private static let internetFormatter = ISO8601DateFormatter()
|
||||||
|
private static let preciseFormatter: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func date(_ value: String?) -> Date? {
|
||||||
|
guard let value, !value.isEmpty else { return nil }
|
||||||
|
return preciseFormatter.date(from: value) ?? internetFormatter.date(from: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func display(_ value: String?) -> String {
|
||||||
|
guard let date = date(value) else { return "--" }
|
||||||
|
return date.formatted(.dateTime.month().day().hour().minute())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func time(_ value: String?) -> String {
|
||||||
|
guard let date = date(value) else { return "--" }
|
||||||
|
return date.formatted(.dateTime.hour().minute())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,36 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// 相册级自动 AI 修图配置,由服务端同步并在每次上传开始时固定快照。
|
||||||
|
struct TravelAlbumAutoRetouchConfiguration: Codable, Sendable, Equatable, Hashable {
|
||||||
|
let enabled: Bool
|
||||||
|
let refinedTemplateId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case enabled
|
||||||
|
case refinedTemplateId = "refined_template_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认关闭自动修图,用于兼容尚未返回配置字段的旧接口。
|
||||||
|
static let disabled = TravelAlbumAutoRetouchConfiguration(
|
||||||
|
enabled: false,
|
||||||
|
refinedTemplateId: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
/// 创建经过规范化的自动修图配置。
|
||||||
|
init(enabled: Bool, refinedTemplateId: Int?) {
|
||||||
|
let validTemplateId = refinedTemplateId.flatMap { $0 > 0 ? $0 : nil }
|
||||||
|
self.enabled = enabled
|
||||||
|
self.refinedTemplateId = enabled ? validTemplateId : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传页紧凑设置项的展示文案。
|
||||||
|
var displayTitle: String { enabled ? "AI修图" : "不修图" }
|
||||||
|
|
||||||
|
/// 当前配置是否可以用于提交自动修图任务。
|
||||||
|
var isValid: Bool { !enabled || refinedTemplateId != nil }
|
||||||
|
}
|
||||||
|
|
||||||
/// 旅拍相册用户信息,对齐 Android `TravelAlbumUserEntity`。
|
/// 旅拍相册用户信息,对齐 Android `TravelAlbumUserEntity`。
|
||||||
struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable {
|
struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable {
|
||||||
let id: Int
|
let id: Int
|
||||||
@@ -33,6 +63,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
|||||||
let createdAt: String
|
let createdAt: String
|
||||||
let updatedAt: String
|
let updatedAt: String
|
||||||
let user: TravelAlbumUser?
|
let user: TravelAlbumUser?
|
||||||
|
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
@@ -50,6 +81,31 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
|||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case updatedAt = "updated_at"
|
case updatedAt = "updated_at"
|
||||||
case user
|
case user
|
||||||
|
case autoRetouchConfiguration = "auto_retouch_config"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解码相册详情;旧响应缺少自动修图配置时按关闭状态兼容。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(Int.self, forKey: .id)
|
||||||
|
storeUserId = try container.decode(Int.self, forKey: .storeUserId)
|
||||||
|
name = try container.decode(String.self, forKey: .name)
|
||||||
|
type = try container.decode(Int.self, forKey: .type)
|
||||||
|
orderNumber = try container.decode(String.self, forKey: .orderNumber)
|
||||||
|
materialNum = try container.decode(Int.self, forKey: .materialNum)
|
||||||
|
materialPrice = try container.decode(Int.self, forKey: .materialPrice)
|
||||||
|
materialPackagePrice = try container.decode(Int.self, forKey: .materialPackagePrice)
|
||||||
|
photoPrice = try container.decode(Int.self, forKey: .photoPrice)
|
||||||
|
coverUrl = try container.decode(String.self, forKey: .coverUrl)
|
||||||
|
userId = try container.decode(Int.self, forKey: .userId)
|
||||||
|
status = try container.decode(Int.self, forKey: .status)
|
||||||
|
createdAt = try container.decode(String.self, forKey: .createdAt)
|
||||||
|
updatedAt = try container.decode(String.self, forKey: .updatedAt)
|
||||||
|
user = try container.decodeIfPresent(TravelAlbumUser.self, forKey: .user)
|
||||||
|
autoRetouchConfiguration = try container.decodeIfPresent(
|
||||||
|
TravelAlbumAutoRetouchConfiguration.self,
|
||||||
|
forKey: .autoRetouchConfiguration
|
||||||
|
) ?? .disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -67,7 +123,8 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
|||||||
status: Int = 0,
|
status: Int = 0,
|
||||||
createdAt: String = "",
|
createdAt: String = "",
|
||||||
updatedAt: String = "",
|
updatedAt: String = "",
|
||||||
user: TravelAlbumUser? = nil
|
user: TravelAlbumUser? = nil,
|
||||||
|
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.storeUserId = storeUserId
|
self.storeUserId = storeUserId
|
||||||
@@ -84,6 +141,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
|||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
self.user = user
|
self.user = user
|
||||||
|
self.autoRetouchConfiguration = autoRetouchConfiguration
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 展示用手机号。
|
/// 展示用手机号。
|
||||||
@@ -224,7 +282,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。
|
/// 相册素材网格角标类别,用于稳定映射独立业务状态和语义颜色。
|
||||||
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
||||||
case purchased
|
case purchased
|
||||||
case pending
|
case pending
|
||||||
@@ -241,14 +299,15 @@ struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension TravelAlbumMaterial {
|
extension TravelAlbumMaterial {
|
||||||
/// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。
|
/// 生成购买状态角标;购买状态与修图状态互不覆盖。
|
||||||
var badgePresentation: TravelAlbumMaterialBadgePresentation? {
|
var purchaseBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
if aiRetouchStatus == 0 {
|
isPurchased
|
||||||
return isPurchased
|
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
: nil
|
||||||
: nil
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/// 生成 AI 修图状态角标;未进入修图流程时返回 nil。
|
||||||
|
var aiRetouchBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
switch aiRetouchStatus {
|
switch aiRetouchStatus {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -286,6 +345,7 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
|||||||
let materialPrice: Double?
|
let materialPrice: Double?
|
||||||
let materialPackagePrice: Double?
|
let materialPackagePrice: Double?
|
||||||
let photoPrice: Double?
|
let photoPrice: Double?
|
||||||
|
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
@@ -295,6 +355,67 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
|||||||
case materialPrice = "material_price"
|
case materialPrice = "material_price"
|
||||||
case materialPackagePrice = "material_package_price"
|
case materialPackagePrice = "material_package_price"
|
||||||
case photoPrice = "photo_price"
|
case photoPrice = "photo_price"
|
||||||
|
case autoRetouchConfiguration = "auto_retouch_config"
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AutoRetouchConfigurationCodingKeys: String, CodingKey {
|
||||||
|
case enabled
|
||||||
|
case refinedTemplateId = "refined_template_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建相册请求;自动修图配置默认关闭以兼容现有调用方。
|
||||||
|
init(
|
||||||
|
name: String,
|
||||||
|
type: Int,
|
||||||
|
orderNumber: String?,
|
||||||
|
materialNum: Int?,
|
||||||
|
materialPrice: Double?,
|
||||||
|
materialPackagePrice: Double?,
|
||||||
|
photoPrice: Double?,
|
||||||
|
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.type = type
|
||||||
|
self.orderNumber = orderNumber
|
||||||
|
self.materialNum = materialNum
|
||||||
|
self.materialPrice = materialPrice
|
||||||
|
self.materialPackagePrice = materialPackagePrice
|
||||||
|
self.photoPrice = photoPrice
|
||||||
|
self.autoRetouchConfiguration = autoRetouchConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编码创建参数;相册配置版本由服务端生成,不随创建请求上送。
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(name, forKey: .name)
|
||||||
|
try container.encode(type, forKey: .type)
|
||||||
|
try container.encodeIfPresent(orderNumber, forKey: .orderNumber)
|
||||||
|
try container.encodeIfPresent(materialNum, forKey: .materialNum)
|
||||||
|
try container.encodeIfPresent(materialPrice, forKey: .materialPrice)
|
||||||
|
try container.encodeIfPresent(materialPackagePrice, forKey: .materialPackagePrice)
|
||||||
|
try container.encodeIfPresent(photoPrice, forKey: .photoPrice)
|
||||||
|
var configuration = container.nestedContainer(
|
||||||
|
keyedBy: AutoRetouchConfigurationCodingKeys.self,
|
||||||
|
forKey: .autoRetouchConfiguration
|
||||||
|
)
|
||||||
|
try configuration.encode(autoRetouchConfiguration.enabled, forKey: .enabled)
|
||||||
|
try configuration.encodeIfPresent(
|
||||||
|
autoRetouchConfiguration.refinedTemplateId,
|
||||||
|
forKey: .refinedTemplateId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新相册自动修图配置的请求参数。
|
||||||
|
struct TravelAlbumAutoRetouchConfigurationRequest: Encodable, Sendable, Equatable {
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let enabled: Bool
|
||||||
|
let refinedTemplateId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case enabled
|
||||||
|
case refinedTemplateId = "refined_template_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +457,28 @@ struct TravelAlbumListResponse<Item: Decodable & Sendable & Equatable>: Decodabl
|
|||||||
/// 旅拍相册创建响应。
|
/// 旅拍相册创建响应。
|
||||||
struct TravelAlbumCreateResponse: Decodable, Sendable, Equatable {
|
struct TravelAlbumCreateResponse: Decodable, Sendable, Equatable {
|
||||||
let id: Int
|
let id: Int
|
||||||
|
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case autoRetouchConfiguration = "auto_retouch_config"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建相册响应;后端暂未回传配置时按关闭状态兼容。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(Int.self, forKey: .id)
|
||||||
|
autoRetouchConfiguration = try container.decodeIfPresent(
|
||||||
|
TravelAlbumAutoRetouchConfiguration.self,
|
||||||
|
forKey: .autoRetouchConfiguration
|
||||||
|
) ?? .disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建相册响应测试数据。
|
||||||
|
init(id: Int, autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled) {
|
||||||
|
self.id = id
|
||||||
|
self.autoRetouchConfiguration = autoRetouchConfiguration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册小程序码响应。
|
/// 旅拍相册小程序码响应。
|
||||||
@@ -386,10 +529,16 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前分组是否允许不选择;仅首次修图的氛围感模板选填。
|
/// 当前分组是否允许不选择;首次修图仅氛围感选填,已有 AI 结果的预览重修两类模板均选填。
|
||||||
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||||
if case .initial = self, category == .atmosphere { return true }
|
switch self {
|
||||||
return false
|
case .initial:
|
||||||
|
return category == .atmosphere
|
||||||
|
case .reretouch(_, _, .all):
|
||||||
|
return category == .refined || category == .atmosphere
|
||||||
|
case .reretouch:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 工作流目标是否满足接口的最小参数要求。
|
/// 工作流目标是否满足接口的最小参数要求。
|
||||||
@@ -403,23 +552,45 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AI 修图模板,包含业务 ID、展示名称和预览图地址。
|
/// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。
|
||||||
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let name: String
|
let name: String
|
||||||
let previewURL: String
|
let previewURL: String
|
||||||
|
let beforeURL: String
|
||||||
|
let afterURL: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
case previewURL = "preview_url"
|
case previewURL = "preview_url"
|
||||||
|
case beforeURL = "before_url"
|
||||||
|
case afterURL = "after_url"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建 AI 修图模板。
|
/// 创建 AI 修图模板。
|
||||||
init(id: Int, name: String, previewURL: String) {
|
init(
|
||||||
|
id: Int,
|
||||||
|
name: String,
|
||||||
|
previewURL: String,
|
||||||
|
beforeURL: String = "",
|
||||||
|
afterURL: String = ""
|
||||||
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.previewURL = previewURL
|
self.previewURL = previewURL
|
||||||
|
self.beforeURL = beforeURL
|
||||||
|
self.afterURL = afterURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解码模板;前后对比字段缺失或为 null 时按空字符串兼容旧接口响应。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(Int.self, forKey: .id)
|
||||||
|
name = try container.decode(String.self, forKey: .name)
|
||||||
|
previewURL = try container.decode(String.self, forKey: .previewURL)
|
||||||
|
beforeURL = (try? container.decodeIfPresent(String.self, forKey: .beforeURL)) ?? ""
|
||||||
|
afterURL = (try? container.decodeIfPresent(String.self, forKey: .afterURL)) ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +638,7 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
|
|||||||
let refinedTemplateId: Int
|
let refinedTemplateId: Int
|
||||||
let atmosphereTemplateId: Int?
|
let atmosphereTemplateId: Int?
|
||||||
let coverTemplateId: Int?
|
let coverTemplateId: Int?
|
||||||
|
let clientRequestId: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case userEquityTravelId = "user_equity_travel_id"
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
@@ -474,6 +646,24 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
|
|||||||
case refinedTemplateId = "refined_template_id"
|
case refinedTemplateId = "refined_template_id"
|
||||||
case atmosphereTemplateId = "atmosphere_template_id"
|
case atmosphereTemplateId = "atmosphere_template_id"
|
||||||
case coverTemplateId = "cover_template_id"
|
case coverTemplateId = "cover_template_id"
|
||||||
|
case clientRequestId = "client_request_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建首次 AI 修图请求;手动修图可不传幂等标识,自动修图必须传稳定标识。
|
||||||
|
init(
|
||||||
|
userEquityTravelId: Int,
|
||||||
|
materialIds: [Int],
|
||||||
|
refinedTemplateId: Int,
|
||||||
|
atmosphereTemplateId: Int?,
|
||||||
|
coverTemplateId: Int?,
|
||||||
|
clientRequestId: String? = nil
|
||||||
|
) {
|
||||||
|
self.userEquityTravelId = userEquityTravelId
|
||||||
|
self.materialIds = materialIds
|
||||||
|
self.refinedTemplateId = refinedTemplateId
|
||||||
|
self.atmosphereTemplateId = atmosphereTemplateId
|
||||||
|
self.coverTemplateId = coverTemplateId
|
||||||
|
self.clientRequestId = clientRequestId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,32 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// 前后图片对比页所需的通用内容,与具体入口和 UI 框架解耦。
|
||||||
|
struct BeforeAfterComparisonContent: Sendable, Equatable {
|
||||||
|
let beforeURL: String
|
||||||
|
let afterURL: String
|
||||||
|
let beforeLabel: String
|
||||||
|
let afterLabel: String
|
||||||
|
|
||||||
|
/// 创建前后对比内容,默认使用 AI 修图模块的中文角标。
|
||||||
|
init(
|
||||||
|
beforeURL: String,
|
||||||
|
afterURL: String,
|
||||||
|
beforeLabel: String = "原图",
|
||||||
|
afterLabel: String = "效果图"
|
||||||
|
) {
|
||||||
|
self.beforeURL = beforeURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.afterURL = afterURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.beforeLabel = beforeLabel
|
||||||
|
self.afterLabel = afterLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两张图片地址均存在时才允许进入对比页。
|
||||||
|
var isValid: Bool {
|
||||||
|
!beforeURL.isEmpty && !afterURL.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 相册预览页横向滑动策略,由调用方通过配置注入。
|
/// 相册预览页横向滑动策略,由调用方通过配置注入。
|
||||||
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
|
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
|
||||||
/// 横滑只切换原图项目,关联图通过 Tab 切换。
|
/// 横滑只切换原图项目,关联图通过 Tab 切换。
|
||||||
@@ -85,6 +111,19 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
|||||||
assets.first { $0.kind == kind }
|
assets.first { $0.kind == kind }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 为精修或氛围感结果构建与原图的对比内容;其他类型或无效地址返回 nil。
|
||||||
|
func comparisonContent(for kind: TravelAlbumPreviewAssetKind) -> BeforeAfterComparisonContent? {
|
||||||
|
guard kind == .retouched || kind == .atmosphere,
|
||||||
|
let original = asset(for: .original),
|
||||||
|
let result = asset(for: kind)
|
||||||
|
else { return nil }
|
||||||
|
let content = BeforeAfterComparisonContent(
|
||||||
|
beforeURL: original.displayURL,
|
||||||
|
afterURL: result.displayURL
|
||||||
|
)
|
||||||
|
return content.isValid ? content : nil
|
||||||
|
}
|
||||||
|
|
||||||
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
||||||
init(material: TravelAlbumMaterial) {
|
init(material: TravelAlbumMaterial) {
|
||||||
originalMaterialId = material.id
|
originalMaterialId = material.id
|
||||||
@@ -136,7 +175,7 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
|||||||
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据当前 Tab 生成首次修图或覆盖重修工作流。
|
/// 根据是否已有 AI 结果生成首次修图或双模板覆盖重修工作流。
|
||||||
func aiRetouchWorkflow(
|
func aiRetouchWorkflow(
|
||||||
albumId: Int,
|
albumId: Int,
|
||||||
selectedKind: TravelAlbumPreviewAssetKind
|
selectedKind: TravelAlbumPreviewAssetKind
|
||||||
@@ -144,16 +183,16 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
|||||||
guard hasVariants else {
|
guard hasVariants else {
|
||||||
return .initial(albumId: albumId, materialIds: [originalMaterialId])
|
return .initial(albumId: albumId, materialIds: [originalMaterialId])
|
||||||
}
|
}
|
||||||
switch selectedKind {
|
guard selectedKind != .cover else { return nil }
|
||||||
case .original:
|
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
|
||||||
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
|
}
|
||||||
case .retouched:
|
}
|
||||||
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .refined)
|
|
||||||
case .atmosphere:
|
extension TravelAlbumAIRetouchTemplate {
|
||||||
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .atmosphere)
|
/// 使用接口返回的模板示例前后图构建对比页内容。
|
||||||
case .cover:
|
var comparisonContent: BeforeAfterComparisonContent? {
|
||||||
return nil
|
let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
|
||||||
}
|
return content.isValid ? content : nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
|
|||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
let localPath: String
|
let localPath: String
|
||||||
var remoteUrl: String
|
var remoteUrl: String
|
||||||
|
var serverMaterialId: Int = 0
|
||||||
|
var autoRetouchState: TravelAlbumAutoRetouchState = .none
|
||||||
|
var autoRetouchTemplateId: Int? = nil
|
||||||
|
var autoRetouchClientRequestId: String = ""
|
||||||
|
var autoRetouchBatchId: Int = 0
|
||||||
|
var autoRetouchAttempt: Int = 0
|
||||||
|
var autoRetouchErrorMessage: String? = nil
|
||||||
|
|
||||||
/// 是否未上传完成。
|
/// 是否未上传完成。
|
||||||
var isNotUploaded: Bool {
|
var isNotUploaded: Bool {
|
||||||
@@ -31,6 +38,16 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OTG 照片自动修图阶段,与原图上传状态独立保存。
|
||||||
|
enum TravelAlbumAutoRetouchState: String, Codable, Sendable, Equatable, Hashable {
|
||||||
|
case none = "NONE"
|
||||||
|
case pendingSubmission = "PENDING_SUBMISSION"
|
||||||
|
case submitting = "SUBMITTING"
|
||||||
|
case processing = "PROCESSING"
|
||||||
|
case completed = "COMPLETED"
|
||||||
|
case failed = "FAILED"
|
||||||
|
}
|
||||||
|
|
||||||
/// OTG 传输页半小时维度时间槽。
|
/// OTG 传输页半小时维度时间槽。
|
||||||
struct TravelAlbumOTGTimeSlot: Hashable, Sendable {
|
struct TravelAlbumOTGTimeSlot: Hashable, Sendable {
|
||||||
let id: String
|
let id: String
|
||||||
@@ -85,6 +102,14 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
|
|||||||
self == .liveUpload
|
self == .liveUpload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 模式选择 Sheet 的辅助说明。
|
||||||
|
var detailText: String {
|
||||||
|
switch self {
|
||||||
|
case .liveUpload: return "相机拍摄后,照片自动传输并上传到当前相册"
|
||||||
|
case .postTransfer: return "拍摄完成后,再选择照片批量传输"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 根据展示标题解析传输模式。
|
/// 根据展示标题解析传输模式。
|
||||||
static func option(title: String) -> TravelAlbumOTGTransferMode? {
|
static func option(title: String) -> TravelAlbumOTGTransferMode? {
|
||||||
allCases.first { $0.title == title }
|
allCases.first { $0.title == title }
|
||||||
@@ -343,7 +368,14 @@ extension TravelAlbumOTGPhotoRecord {
|
|||||||
progress: progress,
|
progress: progress,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
localPath: localPath,
|
localPath: localPath,
|
||||||
remoteUrl: remoteUrl
|
remoteUrl: remoteUrl,
|
||||||
|
serverMaterialId: serverMaterialId,
|
||||||
|
autoRetouchState: autoRetouchState,
|
||||||
|
autoRetouchTemplateId: autoRetouchTemplateId,
|
||||||
|
autoRetouchClientRequestId: autoRetouchClientRequestId,
|
||||||
|
autoRetouchBatchId: autoRetouchBatchId,
|
||||||
|
autoRetouchAttempt: autoRetouchAttempt,
|
||||||
|
autoRetouchErrorMessage: autoRetouchErrorMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
|||||||
let albumId: Int
|
let albumId: Int
|
||||||
let userId: String
|
let userId: String
|
||||||
var remoteUrl: String
|
var remoteUrl: String
|
||||||
|
var serverMaterialId: Int
|
||||||
|
var autoRetouchState: TravelAlbumAutoRetouchState
|
||||||
|
var autoRetouchTemplateId: Int?
|
||||||
|
var autoRetouchClientRequestId: String
|
||||||
|
var autoRetouchBatchId: Int
|
||||||
|
var autoRetouchAttempt: Int
|
||||||
|
var autoRetouchErrorMessage: String?
|
||||||
var updatedAt: Int64
|
var updatedAt: Int64
|
||||||
|
|
||||||
/// 创建 OTG 本地照片记录。
|
/// 创建 OTG 本地照片记录。
|
||||||
@@ -74,6 +81,13 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
|||||||
albumId: Int,
|
albumId: Int,
|
||||||
userId: String,
|
userId: String,
|
||||||
remoteUrl: String = "",
|
remoteUrl: String = "",
|
||||||
|
serverMaterialId: Int = 0,
|
||||||
|
autoRetouchState: TravelAlbumAutoRetouchState = .none,
|
||||||
|
autoRetouchTemplateId: Int? = nil,
|
||||||
|
autoRetouchClientRequestId: String = "",
|
||||||
|
autoRetouchBatchId: Int = 0,
|
||||||
|
autoRetouchAttempt: Int = 0,
|
||||||
|
autoRetouchErrorMessage: String? = nil,
|
||||||
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
|
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -90,12 +104,22 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
|||||||
self.albumId = albumId
|
self.albumId = albumId
|
||||||
self.userId = userId
|
self.userId = userId
|
||||||
self.remoteUrl = remoteUrl
|
self.remoteUrl = remoteUrl
|
||||||
|
self.serverMaterialId = max(0, serverMaterialId)
|
||||||
|
self.autoRetouchState = autoRetouchState
|
||||||
|
self.autoRetouchTemplateId = autoRetouchTemplateId
|
||||||
|
self.autoRetouchClientRequestId = autoRetouchClientRequestId
|
||||||
|
self.autoRetouchBatchId = max(0, autoRetouchBatchId)
|
||||||
|
self.autoRetouchAttempt = max(0, autoRetouchAttempt)
|
||||||
|
self.autoRetouchErrorMessage = autoRetouchErrorMessage
|
||||||
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, updatedAt
|
||||||
|
case serverMaterialId, autoRetouchState, autoRetouchTemplateId
|
||||||
|
case autoRetouchClientRequestId, autoRetouchBatchId, autoRetouchAttempt
|
||||||
|
case autoRetouchErrorMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
|
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
|
||||||
@@ -115,16 +139,34 @@ 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) ?? ""
|
||||||
|
serverMaterialId = try container.decodeIfPresent(Int.self, forKey: .serverMaterialId) ?? 0
|
||||||
|
autoRetouchState = try container.decodeIfPresent(
|
||||||
|
TravelAlbumAutoRetouchState.self,
|
||||||
|
forKey: .autoRetouchState
|
||||||
|
) ?? .none
|
||||||
|
autoRetouchTemplateId = try container.decodeIfPresent(Int.self, forKey: .autoRetouchTemplateId)
|
||||||
|
autoRetouchClientRequestId = try container.decodeIfPresent(
|
||||||
|
String.self,
|
||||||
|
forKey: .autoRetouchClientRequestId
|
||||||
|
) ?? ""
|
||||||
|
autoRetouchBatchId = try container.decodeIfPresent(Int.self, forKey: .autoRetouchBatchId) ?? 0
|
||||||
|
autoRetouchAttempt = try container.decodeIfPresent(Int.self, forKey: .autoRetouchAttempt) ?? 0
|
||||||
|
autoRetouchErrorMessage = try container.decodeIfPresent(String.self, forKey: .autoRetouchErrorMessage)
|
||||||
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 }
|
|
||||||
var copy = self
|
var copy = self
|
||||||
copy.status = .pending
|
if status == .transferring || status == .uploading {
|
||||||
copy.progress = 0
|
copy.status = .pending
|
||||||
copy.errorMessage = nil
|
copy.progress = 0
|
||||||
|
copy.errorMessage = nil
|
||||||
|
}
|
||||||
|
if autoRetouchState == .submitting {
|
||||||
|
copy.autoRetouchState = .pendingSubmission
|
||||||
|
}
|
||||||
|
guard copy != self else { return self }
|
||||||
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// BeforeAfterComparisonViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 前后对比分隔线的共享运动参数与计算规则。
|
||||||
|
enum BeforeAfterComparisonMotion {
|
||||||
|
static let automaticCenter: CGFloat = 0.5
|
||||||
|
static let automaticAmplitude: CGFloat = 0.3
|
||||||
|
static let automaticSpeed: Double = 0.8
|
||||||
|
static let manualRange: ClosedRange<CGFloat> = 0.05 ... 0.95
|
||||||
|
|
||||||
|
/// 返回指定时间点的自动往返位置,运动范围固定为 20% 至 80%。
|
||||||
|
static func automaticFraction(elapsed: TimeInterval) -> CGFloat {
|
||||||
|
automaticCenter + CGFloat(sin(elapsed * automaticSpeed)) * automaticAmplitude
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将手动拖动位置限制在两侧 5% 的安全边界内。
|
||||||
|
static func clampedManualFraction(_ fraction: CGFloat) -> CGFloat {
|
||||||
|
min(manualRange.upperBound, max(manualRange.lowerBound, fraction))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算从当前位置恢复正向自动运动所需的正弦相位时间。
|
||||||
|
static func phaseTime(for fraction: CGFloat) -> TimeInterval {
|
||||||
|
let normalized = Double(
|
||||||
|
min(1, max(-1, (fraction - automaticCenter) / automaticAmplitude))
|
||||||
|
)
|
||||||
|
return asin(normalized) / automaticSpeed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前后对比图片在可用区域内完整展示时的等比布局计算。
|
||||||
|
enum BeforeAfterComparisonLayout {
|
||||||
|
static let fallbackAspectRatio: CGFloat = 3.0 / 4.0
|
||||||
|
|
||||||
|
/// 从图片尺寸提取有效宽高比,异常尺寸回退为 3:4。
|
||||||
|
static func aspectRatio(for imageSize: CGSize) -> CGFloat {
|
||||||
|
guard imageSize.width > 0, imageSize.height > 0 else { return fallbackAspectRatio }
|
||||||
|
let ratio = imageSize.width / imageSize.height
|
||||||
|
return ratio.isFinite && ratio > 0 ? ratio : fallbackAspectRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在最大宽高内返回不裁剪、不拉伸的最大显示尺寸。
|
||||||
|
static func fittedSize(aspectRatio: CGFloat, maximumSize: CGSize) -> CGSize {
|
||||||
|
guard maximumSize.width > 0, maximumSize.height > 0 else { return .zero }
|
||||||
|
let ratio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : fallbackAspectRatio
|
||||||
|
if maximumSize.width / maximumSize.height > ratio {
|
||||||
|
return CGSize(width: maximumSize.height * ratio, height: maximumSize.height)
|
||||||
|
}
|
||||||
|
return CGSize(width: maximumSize.width, height: maximumSize.width / ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前后图片对比页的只读状态,负责校验并解析两张远程图片地址。
|
||||||
|
final class BeforeAfterComparisonViewModel {
|
||||||
|
let content: BeforeAfterComparisonContent
|
||||||
|
|
||||||
|
/// 创建指定内容的前后对比页状态。
|
||||||
|
init(content: BeforeAfterComparisonContent) {
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 原图远程地址。
|
||||||
|
var beforeImageURL: URL? {
|
||||||
|
URL(string: content.beforeURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 效果图远程地址。
|
||||||
|
var afterImageURL: URL? {
|
||||||
|
URL(string: content.afterURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两张图片地址都可供页面加载时返回 true。
|
||||||
|
var isValid: Bool {
|
||||||
|
content.isValid && beforeImageURL != nil && afterImageURL != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// AI 修图任务列表状态,负责筛选、游标分页、去重和静默刷新。
|
||||||
|
final class TravelAlbumAIJobListViewModel {
|
||||||
|
private(set) var items: [TravelAlbumAIJobSummary] = []
|
||||||
|
private(set) var selectedFilter: TravelAlbumAIJobFilter = .all
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var isLoadingMore = false
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
private var nextCursor: String?
|
||||||
|
private var hasMore = false
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
|
var containsInProgressJobs: Bool { items.contains { $0.status.isInProgress } }
|
||||||
|
var canLoadMore: Bool { hasMore && !isLoadingMore }
|
||||||
|
|
||||||
|
/// 选择筛选项并重新加载第一页。
|
||||||
|
func selectFilter(_ filter: TravelAlbumAIJobFilter, api: any TravelAlbumServing) async {
|
||||||
|
guard selectedFilter != filter else { return }
|
||||||
|
selectedFilter = filter
|
||||||
|
items = []
|
||||||
|
notify()
|
||||||
|
await loadFirstPage(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次加载或下拉刷新第一页。
|
||||||
|
func loadFirstPage(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
|
||||||
|
guard !isLoading && !isRefreshing else { return }
|
||||||
|
if refreshing { isRefreshing = true } else if !silent { isLoading = true }
|
||||||
|
errorMessage = nil
|
||||||
|
notify()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载下一页。
|
||||||
|
func loadMore(api: any TravelAlbumServing) async {
|
||||||
|
guard canLoadMore, let cursor = nextCursor else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
notify()
|
||||||
|
defer { isLoadingMore = false; notify() }
|
||||||
|
await request(cursor: cursor, reset: false, api: api, allowCursorRecovery: true, silent: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(
|
||||||
|
cursor: String?,
|
||||||
|
reset: Bool,
|
||||||
|
api: any TravelAlbumServing,
|
||||||
|
allowCursorRecovery: Bool,
|
||||||
|
silent: Bool
|
||||||
|
) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.aiRetouchJobList(
|
||||||
|
statusGroup: selectedFilter,
|
||||||
|
limit: 20,
|
||||||
|
cursor: cursor
|
||||||
|
)
|
||||||
|
if reset {
|
||||||
|
items = response.items
|
||||||
|
} else {
|
||||||
|
var known = Set(items.map(\.id))
|
||||||
|
items += response.items.filter { known.insert($0.id).inserted }
|
||||||
|
}
|
||||||
|
nextCursor = response.nextCursor
|
||||||
|
hasMore = response.hasMore && response.nextCursor?.isEmpty == false
|
||||||
|
errorMessage = nil
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription
|
||||||
|
if allowCursorRecovery && message.localizedCaseInsensitiveContains("cursor") {
|
||||||
|
nextCursor = nil
|
||||||
|
hasMore = false
|
||||||
|
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items.isEmpty { errorMessage = message.isEmpty ? "任务加载失败" : message }
|
||||||
|
if !silent { onShowMessage?(message.isEmpty ? "任务加载失败" : message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notify() { onStateChange?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务详情状态,负责刷新、404 识别和终态判断。
|
||||||
|
final class TravelAlbumAIJobDetailViewModel {
|
||||||
|
private(set) var detail: TravelAlbumAIJobDetail?
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
private(set) var isNotFound = false
|
||||||
|
|
||||||
|
let batchId: Int
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onNotFound: (() -> Void)?
|
||||||
|
|
||||||
|
init(batchId: Int) { self.batchId = batchId }
|
||||||
|
|
||||||
|
var shouldPoll: Bool { detail?.status.isInProgress == true }
|
||||||
|
|
||||||
|
/// 拉取任务详情;静默刷新失败时保留现有内容。
|
||||||
|
func load(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
|
||||||
|
guard batchId > 0, !isLoading && !isRefreshing else {
|
||||||
|
if batchId <= 0 { markNotFound() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if refreshing { isRefreshing = true } else if detail == nil && !silent { isLoading = true }
|
||||||
|
errorMessage = nil
|
||||||
|
notify()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
detail = try await api.aiRetouchJobInfo(batchId: batchId)
|
||||||
|
isNotFound = false
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch APIError.httpStatus(let status, _) where status == 404 {
|
||||||
|
markNotFound()
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription.isEmpty ? "任务详情加载失败" : error.localizedDescription
|
||||||
|
if detail == nil { errorMessage = message }
|
||||||
|
if !silent { onShowMessage?(message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markNotFound() {
|
||||||
|
guard !isNotFound else { return }
|
||||||
|
isNotFound = true
|
||||||
|
onNotFound?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notify() { onStateChange?() }
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
var onSubmitted: (() -> Void)?
|
var onSubmitted: ((TravelAlbumAIJobSubmission) -> Void)?
|
||||||
|
|
||||||
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
|
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
|
||||||
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
||||||
@@ -66,9 +66,12 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 首次修图页固定展示的氛围感与封面生成规则说明。
|
/// 首次修图页按照当前选图数量展示氛围感与封面生成规则。
|
||||||
var initialTipsText: String {
|
var initialTipsText: String {
|
||||||
"Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。"
|
if visibleCategories.contains(.cover) {
|
||||||
|
return "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
|
||||||
|
}
|
||||||
|
return "Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前分组是否为选填。
|
/// 当前分组是否为选填。
|
||||||
@@ -87,7 +90,8 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
case .refined, .atmosphere:
|
case .refined, .atmosphere:
|
||||||
return 1
|
return 1
|
||||||
case .all:
|
case .all:
|
||||||
return 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
return (selectedRefinedTemplateId == nil ? 0 : 1)
|
||||||
|
+ (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +110,11 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
return unavailableMessage(for: category)
|
return unavailableMessage(for: category)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if case .reretouch(_, _, .all) = workflow,
|
||||||
|
selectedRefinedTemplateId == nil,
|
||||||
|
selectedAtmosphereTemplateId == nil {
|
||||||
|
return "请至少选择一个修图模板"
|
||||||
|
}
|
||||||
guard let remainingQuota else {
|
guard let remainingQuota else {
|
||||||
return "剩余修图次数获取失败,请刷新后重试"
|
return "剩余修图次数获取失败,请刷新后重试"
|
||||||
}
|
}
|
||||||
@@ -143,7 +152,9 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
atmosphereTemplates = response.atmosphereTemplates
|
atmosphereTemplates = response.atmosphereTemplates
|
||||||
coverTemplates = response.coverTemplates
|
coverTemplates = response.coverTemplates
|
||||||
remainingQuota = response.remainingQuota
|
remainingQuota = response.remainingQuota
|
||||||
selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil
|
selectedRefinedTemplateId = visibleCategories.contains(.refined) && !isOptional(.refined)
|
||||||
|
? refinedTemplates.first?.id
|
||||||
|
: nil
|
||||||
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
|
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
|
||||||
? atmosphereTemplates.first?.id
|
? atmosphereTemplates.first?.id
|
||||||
: nil
|
: nil
|
||||||
@@ -185,14 +196,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。
|
/// 选择模板;选填分组允许再次点击取消,必选分组保持单选。
|
||||||
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
|
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
|
||||||
guard visibleCategories.contains(category),
|
guard visibleCategories.contains(category),
|
||||||
templates(for: category).contains(where: { $0.id == id })
|
templates(for: category).contains(where: { $0.id == id })
|
||||||
else { return }
|
else { return }
|
||||||
switch category {
|
switch category {
|
||||||
case .refined:
|
case .refined:
|
||||||
selectedRefinedTemplateId = id
|
selectedRefinedTemplateId = isOptional(category) && selectedRefinedTemplateId == id ? nil : id
|
||||||
case .atmosphere:
|
case .atmosphere:
|
||||||
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
|
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
|
||||||
case .cover:
|
case .cover:
|
||||||
@@ -217,13 +228,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
let submission: TravelAlbumAIJobSubmission
|
||||||
switch workflow {
|
switch workflow {
|
||||||
case .initial(let albumId, let materialIds):
|
case .initial(let albumId, let materialIds):
|
||||||
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
||||||
onShowMessage?("请选择原图精修模板")
|
onShowMessage?("请选择原图精修模板")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try await api.submitAIRetouch(
|
submission = try await api.submitAIRetouch(
|
||||||
TravelAlbumAIRetouchRequest(
|
TravelAlbumAIRetouchRequest(
|
||||||
userEquityTravelId: albumId,
|
userEquityTravelId: albumId,
|
||||||
materialIds: materialIds,
|
materialIds: materialIds,
|
||||||
@@ -233,7 +245,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
case .reretouch(let materialId, let batchId, let type):
|
case .reretouch(let materialId, let batchId, let type):
|
||||||
try await api.submitAIReretouch(
|
submission = try await api.submitAIReretouch(
|
||||||
TravelAlbumAIReretouchRequest(
|
TravelAlbumAIReretouchRequest(
|
||||||
id: materialId,
|
id: materialId,
|
||||||
aiRetouchBatchId: batchId,
|
aiRetouchBatchId: batchId,
|
||||||
@@ -243,7 +255,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onSubmitted?()
|
onSubmitted?(submission)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 自动修图设置状态,负责加载真实精修模板、单选和配置校验,不直接创建修图任务。
|
||||||
|
final class TravelAlbumAutoRetouchSettingViewModel {
|
||||||
|
let scenicId: Int
|
||||||
|
/// 是否在模板上方展示修图方式;创建页已选择 AI 时只展示模板。
|
||||||
|
let allowsModeSelection: Bool
|
||||||
|
private(set) var templates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
|
private(set) var selectedTemplateId: Int?
|
||||||
|
private(set) var isEnabled: Bool
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
|
||||||
|
/// 创建自动修图配置状态。
|
||||||
|
init(
|
||||||
|
scenicId: Int,
|
||||||
|
configuration: TravelAlbumAutoRetouchConfiguration,
|
||||||
|
allowsModeSelection: Bool
|
||||||
|
) {
|
||||||
|
self.scenicId = scenicId
|
||||||
|
self.allowsModeSelection = allowsModeSelection
|
||||||
|
self.isEnabled = configuration.enabled || !allowsModeSelection
|
||||||
|
self.selectedTemplateId = configuration.refinedTemplateId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭自动修图不依赖模板加载;开启时必须选择有效模板。
|
||||||
|
var canConfirm: Bool {
|
||||||
|
!isEnabled || (!isLoading && pendingConfiguration != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前草稿模板名称,用于固定底部的选择摘要。
|
||||||
|
var selectedTemplateName: String? {
|
||||||
|
templates.first { $0.id == selectedTemplateId }?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前可提交配置;启用状态必须已经选择服务端模板。
|
||||||
|
var pendingConfiguration: TravelAlbumAutoRetouchConfiguration? {
|
||||||
|
if !isEnabled { return .disabled }
|
||||||
|
guard let selectedTemplateId,
|
||||||
|
templates.contains(where: { $0.id == selectedTemplateId }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return TravelAlbumAutoRetouchConfiguration(
|
||||||
|
enabled: true,
|
||||||
|
refinedTemplateId: selectedTemplateId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取当前景区的真实精修模板。
|
||||||
|
func loadTemplates(api: any TravelAlbumServing) async {
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
errorMessage = "请先选择景区"
|
||||||
|
notify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
notify()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let response = try await api.aiRetouchTemplates(scenicId: scenicId)
|
||||||
|
templates = response.refinedTemplates
|
||||||
|
if !templates.contains(where: { $0.id == selectedTemplateId }) {
|
||||||
|
selectedTemplateId = nil
|
||||||
|
}
|
||||||
|
if templates.isEmpty { errorMessage = "暂无可用的原图精修模板" }
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
templates = []
|
||||||
|
errorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换修图方式,保留本次草稿模板以便再次开启;关闭配置提交时仍不携带模板。
|
||||||
|
func selectMode(enabled: Bool) {
|
||||||
|
guard allowsModeSelection else { return }
|
||||||
|
isEnabled = enabled
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择一个真实精修模板。
|
||||||
|
func selectTemplate(id: Int) {
|
||||||
|
guard templates.contains(where: { $0.id == id }) else { return }
|
||||||
|
isEnabled = true
|
||||||
|
selectedTemplateId = id
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notify() { onStateChange?() }
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ final class TravelAlbumDetailViewModel {
|
|||||||
private(set) var isRefreshing = false
|
private(set) var isRefreshing = false
|
||||||
private(set) var isSelectionMode = false
|
private(set) var isSelectionMode = false
|
||||||
private(set) var selectedMaterialIds: Set<Int> = []
|
private(set) var selectedMaterialIds: Set<Int> = []
|
||||||
|
private var selectedMaterialPurchaseStates: [Int: Bool] = [:]
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
@@ -142,12 +143,29 @@ final class TravelAlbumDetailViewModel {
|
|||||||
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
|
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前选中素材中的已购买数量。
|
||||||
|
var selectedPurchasedMaterialCount: Int {
|
||||||
|
selectedMaterialIds.count - selectedDeletableMaterialIds.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除确认弹窗文案;选中已购素材时明确说明保护规则与实际删除数量。
|
||||||
|
var deleteSelectedConfirmationMessage: String {
|
||||||
|
let selectedCount = selectedMaterialIds.count
|
||||||
|
let purchasedCount = selectedPurchasedMaterialCount
|
||||||
|
guard purchasedCount > 0 else {
|
||||||
|
return "确定删除选中的 \(selectedCount) 张素材吗?"
|
||||||
|
}
|
||||||
|
let deletableCount = selectedCount - purchasedCount
|
||||||
|
return "已选择 \(selectedCount) 张素材,其中 \(purchasedCount) 张已购买。"
|
||||||
|
+ "不会删除已购买的项目,只会删除 \(deletableCount) 张未购买的项目。"
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换素材 tab。
|
/// 切换素材 tab。
|
||||||
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
||||||
guard selectedTab != tab else { return }
|
guard selectedTab != tab else { return }
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
await loadMaterials(reset: true, api: api)
|
await loadMaterials(reset: true, api: api)
|
||||||
}
|
}
|
||||||
@@ -223,10 +241,9 @@ final class TravelAlbumDetailViewModel {
|
|||||||
|
|
||||||
/// 切换选择模式。
|
/// 切换选择模式。
|
||||||
func toggleSelectionMode() {
|
func toggleSelectionMode() {
|
||||||
guard selectedTab == .all else { return }
|
|
||||||
isSelectionMode.toggle()
|
isSelectionMode.toggle()
|
||||||
if !isSelectionMode {
|
if !isSelectionMode {
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
@@ -234,14 +251,12 @@ final class TravelAlbumDetailViewModel {
|
|||||||
/// 切换素材选中状态。
|
/// 切换素材选中状态。
|
||||||
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
||||||
guard isSelectionMode else { return }
|
guard isSelectionMode else { return }
|
||||||
guard material.status == 1 else {
|
|
||||||
onShowMessage?("仅未购买素材可删除")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if selectedMaterialIds.contains(material.id) {
|
if selectedMaterialIds.contains(material.id) {
|
||||||
selectedMaterialIds.remove(material.id)
|
selectedMaterialIds.remove(material.id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: material.id)
|
||||||
} else {
|
} else {
|
||||||
selectedMaterialIds.insert(material.id)
|
selectedMaterialIds.insert(material.id)
|
||||||
|
selectedMaterialPurchaseStates[material.id] = material.isPurchased
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
@@ -249,15 +264,22 @@ final class TravelAlbumDetailViewModel {
|
|||||||
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
||||||
func completeAIRetouchSubmission() {
|
func completeAIRetouchSubmission() {
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 修图提交成功后重置选择状态,并刷新相册摘要、数量与当前素材列表。
|
||||||
|
func refreshAfterAIRetouchSubmission(api: any TravelAlbumServing) async {
|
||||||
|
completeAIRetouchSubmission()
|
||||||
|
await refreshAll(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
|
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
|
||||||
func removeMaterialAfterPreviewDeletion(id: Int) {
|
func removeMaterialAfterPreviewDeletion(id: Int) {
|
||||||
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
||||||
let material = materials.remove(at: index)
|
let material = materials.remove(at: index)
|
||||||
selectedMaterialIds.remove(id)
|
selectedMaterialIds.remove(id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: id)
|
||||||
allPhotoCount = max(0, allPhotoCount - 1)
|
allPhotoCount = max(0, allPhotoCount - 1)
|
||||||
if material.isPurchased {
|
if material.isPurchased {
|
||||||
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
||||||
@@ -267,16 +289,20 @@ final class TravelAlbumDetailViewModel {
|
|||||||
|
|
||||||
/// 删除已选素材。
|
/// 删除已选素材。
|
||||||
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||||
let ids = selectedMaterialIds.sorted()
|
guard !selectedMaterialIds.isEmpty else {
|
||||||
guard !ids.isEmpty else {
|
|
||||||
onShowMessage?("请选择要删除的素材")
|
onShowMessage?("请选择要删除的素材")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let ids = selectedDeletableMaterialIds
|
||||||
|
guard !ids.isEmpty else {
|
||||||
|
onShowMessage?("选中的素材均已购买,未删除任何项目")
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
try await api.batchDeleteMaterials(ids: ids)
|
try await api.batchDeleteMaterials(ids: ids)
|
||||||
onShowMessage?("删除成功")
|
onShowMessage?("删除成功")
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
await refreshAll(api: api)
|
await refreshAll(api: api)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
@@ -301,4 +327,18 @@ final class TravelAlbumDetailViewModel {
|
|||||||
private func notifyStateChange() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func clearMaterialSelection() {
|
||||||
|
selectedMaterialIds = []
|
||||||
|
selectedMaterialPurchaseStates = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedDeletableMaterialIds: [Int] {
|
||||||
|
selectedMaterialIds
|
||||||
|
.filter { id in
|
||||||
|
guard selectedMaterialPurchaseStates[id] == false else { return false }
|
||||||
|
return materials.first(where: { $0.id == id })?.isPurchased != true
|
||||||
|
}
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ final class TravelAlbumEntryViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 重新拉取相册列表。
|
/// 重新拉取相册列表。
|
||||||
func loadAlbums(api: any TravelAlbumServing) async {
|
func loadAlbums(
|
||||||
|
api: any TravelAlbumServing,
|
||||||
|
preservingContentOnFailure: Bool = false
|
||||||
|
) async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
defer {
|
defer {
|
||||||
@@ -67,12 +70,20 @@ final class TravelAlbumEntryViewModel {
|
|||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
albums = []
|
if !preservingContentOnFailure {
|
||||||
albumTotal = 0
|
albums = []
|
||||||
|
albumTotal = 0
|
||||||
|
}
|
||||||
onShowMessage?(error.localizedDescription)
|
onShowMessage?(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 用户下拉刷新相册列表;失败时保留当前内容,避免页面瞬间清空。
|
||||||
|
func refreshAlbums(api: any TravelAlbumServing) async {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
await loadAlbums(api: api, preservingContentOnFailure: true)
|
||||||
|
}
|
||||||
|
|
||||||
/// 打开创建相册弹窗。
|
/// 打开创建相册弹窗。
|
||||||
func openCreateSheet(api: any TravelAlbumServing) async {
|
func openCreateSheet(api: any TravelAlbumServing) async {
|
||||||
guard !isCreating else { return }
|
guard !isCreating else { return }
|
||||||
@@ -110,6 +121,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 +144,10 @@ final class TravelAlbumEntryViewModel {
|
|||||||
onShowMessage?("请输入有效的单张照片价格")
|
onShowMessage?("请输入有效的单张照片价格")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if autoRetouchConfiguration.enabled && !autoRetouchConfiguration.isValid {
|
||||||
|
onShowMessage?("请选择有效的 AI 修图模板")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let albumName: String
|
let albumName: String
|
||||||
switch mode {
|
switch mode {
|
||||||
@@ -151,7 +167,8 @@ final class TravelAlbumEntryViewModel {
|
|||||||
materialNum: Int(freeCount) ?? 0,
|
materialNum: Int(freeCount) ?? 0,
|
||||||
materialPrice: materialPrice,
|
materialPrice: materialPrice,
|
||||||
materialPackagePrice: Double(packagePrice) ?? 0,
|
materialPackagePrice: Double(packagePrice) ?? 0,
|
||||||
photoPrice: 0
|
photoPrice: 0,
|
||||||
|
autoRetouchConfiguration: autoRetouchConfiguration
|
||||||
)
|
)
|
||||||
case .preOrder:
|
case .preOrder:
|
||||||
request = TravelAlbumCreateRequest(
|
request = TravelAlbumCreateRequest(
|
||||||
@@ -161,7 +178,8 @@ final class TravelAlbumEntryViewModel {
|
|||||||
materialNum: nil,
|
materialNum: nil,
|
||||||
materialPrice: nil,
|
materialPrice: nil,
|
||||||
materialPackagePrice: nil,
|
materialPackagePrice: nil,
|
||||||
photoPrice: nil
|
photoPrice: nil,
|
||||||
|
autoRetouchConfiguration: autoRetouchConfiguration
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,22 @@ struct TravelAlbumPhoneAlbumImportItem: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OTG 自动修图预览的数据校验错误,避免进入缺少素材或结果的空白预览页。
|
||||||
|
enum TravelAlbumOTGPreviewError: LocalizedError, Equatable {
|
||||||
|
case materialUnavailable
|
||||||
|
case resultNotReady
|
||||||
|
|
||||||
|
/// 预览入口可直接展示给用户的错误说明。
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .materialUnavailable:
|
||||||
|
return "素材信息暂不可用,请返回相册刷新后重试"
|
||||||
|
case .resultNotReady:
|
||||||
|
return "修图结果暂未同步,请稍后重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。
|
/// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。
|
||||||
@MainActor
|
@MainActor
|
||||||
final class WiredCameraTransferViewModel {
|
final class WiredCameraTransferViewModel {
|
||||||
@@ -63,7 +79,12 @@ 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 {
|
||||||
|
didSet { notifyStateChanged() }
|
||||||
|
}
|
||||||
|
private(set) var isUpdatingAutoRetouchConfiguration = false {
|
||||||
|
didSet { notifyStateChanged() }
|
||||||
|
}
|
||||||
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() }
|
||||||
@@ -87,6 +108,8 @@ final class WiredCameraTransferViewModel {
|
|||||||
private var queuedAutoUploadPhotoIds: Set<String> = []
|
private var queuedAutoUploadPhotoIds: Set<String> = []
|
||||||
private var suppressSelectedTimeSlotNotification = false
|
private var suppressSelectedTimeSlotNotification = false
|
||||||
private var serverStatusSyncTask: Task<Void, Never>?
|
private var serverStatusSyncTask: Task<Void, Never>?
|
||||||
|
private var configurationSyncTask: Task<Void, Never>?
|
||||||
|
private var autoRetouchPollingTask: Task<Void, Never>?
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init(
|
init(
|
||||||
@@ -94,6 +117,8 @@ final class WiredCameraTransferViewModel {
|
|||||||
albumTitle: String,
|
albumTitle: String,
|
||||||
headerPhone: String,
|
headerPhone: String,
|
||||||
scenicSpotLabel: String? = nil,
|
scenicSpotLabel: String? = nil,
|
||||||
|
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
|
||||||
|
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
|
||||||
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
||||||
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
||||||
uploader: (any TravelAlbumOTGUploading)? = nil,
|
uploader: (any TravelAlbumOTGUploading)? = nil,
|
||||||
@@ -112,7 +137,11 @@ 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.transferMode = Self.persistedTransferMode(in: userDefaults)
|
self.autoRetouchConfiguration = initialAutoRetouchConfiguration
|
||||||
|
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
|
||||||
|
if let initialTransferMode {
|
||||||
|
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相机状态文案。
|
/// 相机状态文案。
|
||||||
@@ -213,6 +242,40 @@ final class WiredCameraTransferViewModel {
|
|||||||
transferMode.title
|
transferMode.title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 自动修图设置 Chip 的展示文案。
|
||||||
|
var retouchOption: String { autoRetouchConfiguration.displayTitle }
|
||||||
|
|
||||||
|
/// 自动修图设置页复用当前注入的相册服务。
|
||||||
|
var autoRetouchAPI: any TravelAlbumServing { api }
|
||||||
|
|
||||||
|
/// 获取已完成自动修图照片的最新原图与精修图,仅供只读预览,不改变上传或修图状态。
|
||||||
|
func loadAutoRetouchPreviewProject(photoId: String) async throws -> TravelAlbumPreviewProject {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
guard let record = persistedRecordsById[photoId],
|
||||||
|
record.autoRetouchState == .completed,
|
||||||
|
record.serverMaterialId > 0 else {
|
||||||
|
throw TravelAlbumOTGPreviewError.materialUnavailable
|
||||||
|
}
|
||||||
|
let material = try await api.materialInfo(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
materialId: record.serverMaterialId
|
||||||
|
)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let project = TravelAlbumPreviewProject(material: material)
|
||||||
|
guard material.id == record.serverMaterialId,
|
||||||
|
let original = project.asset(for: .original), !original.displayURL.isEmpty else {
|
||||||
|
throw TravelAlbumOTGPreviewError.materialUnavailable
|
||||||
|
}
|
||||||
|
guard let retouched = project.asset(for: .retouched), !retouched.displayURL.isEmpty else {
|
||||||
|
throw TravelAlbumOTGPreviewError.resultNotReady
|
||||||
|
}
|
||||||
|
return TravelAlbumPreviewProject(
|
||||||
|
originalMaterialId: project.originalMaterialId,
|
||||||
|
aiRetouchBatchId: project.aiRetouchBatchId,
|
||||||
|
assets: [original, retouched]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 指定上传弹窗选项。
|
/// 指定上传弹窗选项。
|
||||||
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
|
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
|
||||||
TravelAlbumOTGSpecifyUploadOption.allCases
|
TravelAlbumOTGSpecifyUploadOption.allCases
|
||||||
@@ -230,6 +293,9 @@ final class WiredCameraTransferViewModel {
|
|||||||
syncFromConnectionManager()
|
syncFromConnectionManager()
|
||||||
loadPersistedPhotos()
|
loadPersistedPhotos()
|
||||||
syncServerUploadStatuses()
|
syncServerUploadStatuses()
|
||||||
|
syncAutoRetouchConfiguration()
|
||||||
|
resumePendingAutoRetouches()
|
||||||
|
updateAutoRetouchPolling()
|
||||||
connectionManager.start()
|
connectionManager.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,10 +303,29 @@ final class WiredCameraTransferViewModel {
|
|||||||
func stop() {
|
func stop() {
|
||||||
serverStatusSyncTask?.cancel()
|
serverStatusSyncTask?.cancel()
|
||||||
serverStatusSyncTask = nil
|
serverStatusSyncTask = nil
|
||||||
|
configurationSyncTask?.cancel()
|
||||||
|
configurationSyncTask = nil
|
||||||
|
autoRetouchPollingTask?.cancel()
|
||||||
|
autoRetouchPollingTask = nil
|
||||||
connectionManager.unbindDelegate()
|
connectionManager.unbindDelegate()
|
||||||
connectionManager.suspendLiveTransfer()
|
connectionManager.suspendLiveTransfer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// App 进入后台时停止配置请求和任务轮询,保留待恢复状态。
|
||||||
|
func applicationDidEnterBackground() {
|
||||||
|
configurationSyncTask?.cancel()
|
||||||
|
configurationSyncTask = nil
|
||||||
|
autoRetouchPollingTask?.cancel()
|
||||||
|
autoRetouchPollingTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App 回到前台时同步跨设备配置并恢复自动修图任务。
|
||||||
|
func applicationDidBecomeActive() {
|
||||||
|
syncAutoRetouchConfiguration()
|
||||||
|
resumePendingAutoRetouches()
|
||||||
|
updateAutoRetouchPolling()
|
||||||
|
}
|
||||||
|
|
||||||
/// 主动断开相机连接。
|
/// 主动断开相机连接。
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
connectionManager.disconnect()
|
connectionManager.disconnect()
|
||||||
@@ -301,6 +386,50 @@ final class WiredCameraTransferViewModel {
|
|||||||
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
|
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 保存相册级自动修图配置;服务端成功后才更新页面状态。
|
||||||
|
func updateAutoRetouchConfiguration(_ configuration: TravelAlbumAutoRetouchConfiguration) async {
|
||||||
|
guard !isUpdatingAutoRetouchConfiguration else { return }
|
||||||
|
guard configuration.isValid else {
|
||||||
|
showMessage("请选择有效的 AI 修图模板")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isUpdatingAutoRetouchConfiguration = true
|
||||||
|
defer { isUpdatingAutoRetouchConfiguration = false }
|
||||||
|
do {
|
||||||
|
autoRetouchConfiguration = try await api.updateAutoRetouchConfiguration(
|
||||||
|
TravelAlbumAutoRetouchConfigurationRequest(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
enabled: configuration.enabled,
|
||||||
|
refinedTemplateId: configuration.refinedTemplateId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
showMessage(autoRetouchConfiguration.enabled ? "已开启 AI 自动修图" : "已关闭 AI 自动修图")
|
||||||
|
} catch {
|
||||||
|
showMessage(error.localizedDescription.isEmpty ? "自动修图设置保存失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重试单张已上传照片的自动修图,不重复上传原图。
|
||||||
|
func retryAutoRetouch(photoId: String) {
|
||||||
|
guard var record = persistedRecordsById[photoId],
|
||||||
|
record.status == .uploaded,
|
||||||
|
record.serverMaterialId > 0,
|
||||||
|
record.autoRetouchState == .failed,
|
||||||
|
record.autoRetouchTemplateId != nil else {
|
||||||
|
showMessage("当前照片无法重新修图")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if record.autoRetouchBatchId > 0 {
|
||||||
|
record.autoRetouchAttempt += 1
|
||||||
|
record.autoRetouchBatchId = 0
|
||||||
|
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
|
||||||
|
}
|
||||||
|
record.autoRetouchState = .pendingSubmission
|
||||||
|
record.autoRetouchErrorMessage = nil
|
||||||
|
persist(record)
|
||||||
|
Task { await submitAutoRetouch(photoId: photoId) }
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换上传格式。
|
/// 切换上传格式。
|
||||||
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
|
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
|
||||||
guard photoFormatOption != option else { return }
|
guard photoFormatOption != option else { return }
|
||||||
@@ -523,6 +652,166 @@ final class WiredCameraTransferViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func syncAutoRetouchConfiguration() {
|
||||||
|
configurationSyncTask?.cancel()
|
||||||
|
configurationSyncTask = Task { [weak self] in
|
||||||
|
await self?.refreshAutoRetouchConfiguration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAutoRetouchConfiguration() async {
|
||||||
|
do {
|
||||||
|
let album = try await api.info(id: albumId)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
autoRetouchConfiguration = album.autoRetouchConfiguration
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
OTGLog.error(.connection, "sync auto retouch configuration failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resumePendingAutoRetouches() {
|
||||||
|
let photoIds = persistedRecordsById.values.compactMap { record in
|
||||||
|
record.status == .uploaded && record.autoRetouchState == .pendingSubmission
|
||||||
|
? record.id
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
guard !photoIds.isEmpty else { return }
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for photoId in photoIds {
|
||||||
|
await submitAutoRetouch(photoId: photoId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submitAutoRetouch(photoId: String) async {
|
||||||
|
guard var record = persistedRecordsById[photoId],
|
||||||
|
record.status == .uploaded,
|
||||||
|
record.serverMaterialId > 0,
|
||||||
|
let templateId = record.autoRetouchTemplateId,
|
||||||
|
record.autoRetouchState == .pendingSubmission || record.autoRetouchState == .failed else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if record.autoRetouchClientRequestId.isEmpty {
|
||||||
|
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
|
||||||
|
}
|
||||||
|
record.autoRetouchState = .submitting
|
||||||
|
record.autoRetouchErrorMessage = nil
|
||||||
|
persist(record)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let submission = try await api.submitAIRetouch(
|
||||||
|
TravelAlbumAIRetouchRequest(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
materialIds: [record.serverMaterialId],
|
||||||
|
refinedTemplateId: templateId,
|
||||||
|
atmosphereTemplateId: nil,
|
||||||
|
coverTemplateId: nil,
|
||||||
|
clientRequestId: record.autoRetouchClientRequestId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
guard var latest = persistedRecordsById[photoId] else { return }
|
||||||
|
latest.autoRetouchBatchId = submission.aiRetouchBatchId
|
||||||
|
latest.autoRetouchState = autoRetouchState(for: submission.status)
|
||||||
|
latest.autoRetouchErrorMessage = latest.autoRetouchState == .failed ? submission.status.title : nil
|
||||||
|
persist(latest)
|
||||||
|
updateAutoRetouchPolling()
|
||||||
|
} catch is CancellationError {
|
||||||
|
guard var latest = persistedRecordsById[photoId], latest.autoRetouchState == .submitting else { return }
|
||||||
|
latest.autoRetouchState = .pendingSubmission
|
||||||
|
persist(latest)
|
||||||
|
} catch {
|
||||||
|
guard var latest = persistedRecordsById[photoId] else { return }
|
||||||
|
if isAmbiguousAutoRetouchSubmissionFailure(error) {
|
||||||
|
latest.autoRetouchState = .pendingSubmission
|
||||||
|
latest.autoRetouchErrorMessage = "网络中断,恢复后将自动继续修图"
|
||||||
|
persist(latest)
|
||||||
|
showMessage(latest.autoRetouchErrorMessage ?? "网络恢复后将自动继续修图")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
latest.autoRetouchState = .failed
|
||||||
|
latest.autoRetouchErrorMessage = error.localizedDescription.isEmpty
|
||||||
|
? "AI 修图任务提交失败"
|
||||||
|
: error.localizedDescription
|
||||||
|
persist(latest)
|
||||||
|
showMessage(latest.autoRetouchErrorMessage ?? "AI 修图任务提交失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAutoRetouchPolling() {
|
||||||
|
guard autoRetouchPollingTask == nil,
|
||||||
|
persistedRecordsById.values.contains(where: {
|
||||||
|
$0.autoRetouchState == .processing && $0.autoRetouchBatchId > 0
|
||||||
|
}) else { return }
|
||||||
|
autoRetouchPollingTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await refreshAutoRetouchJobs()
|
||||||
|
guard persistedRecordsById.values.contains(where: {
|
||||||
|
$0.autoRetouchState == .processing && $0.autoRetouchBatchId > 0
|
||||||
|
}) else { break }
|
||||||
|
try? await Task.sleep(for: .seconds(8))
|
||||||
|
}
|
||||||
|
autoRetouchPollingTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAutoRetouchJobs() async {
|
||||||
|
let batchIds = Set(persistedRecordsById.values.compactMap { record in
|
||||||
|
record.autoRetouchState == .processing && record.autoRetouchBatchId > 0
|
||||||
|
? record.autoRetouchBatchId
|
||||||
|
: nil
|
||||||
|
})
|
||||||
|
for batchId in batchIds {
|
||||||
|
do {
|
||||||
|
let detail = try await api.aiRetouchJobInfo(batchId: batchId)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let state = autoRetouchState(for: detail.status)
|
||||||
|
guard state != .processing else { continue }
|
||||||
|
let matchingRecords = persistedRecordsById.values.filter { $0.autoRetouchBatchId == batchId }
|
||||||
|
for var record in matchingRecords {
|
||||||
|
record.autoRetouchState = state
|
||||||
|
record.autoRetouchErrorMessage = state == .failed ? detail.status.title : nil
|
||||||
|
persist(record)
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
OTGLog.error(.connection, "poll auto retouch job failed: \(batchId) \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func autoRetouchState(for status: TravelAlbumAIJobStatus) -> TravelAlbumAutoRetouchState {
|
||||||
|
switch status {
|
||||||
|
case .queued, .processing, .unknown:
|
||||||
|
return .processing
|
||||||
|
case .succeeded, .partiallySucceeded:
|
||||||
|
return .completed
|
||||||
|
case .failed, .canceled:
|
||||||
|
return .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAutoRetouchClientRequestId(record: TravelAlbumOTGPhotoRecord) -> String {
|
||||||
|
let templateId = record.autoRetouchTemplateId ?? 0
|
||||||
|
return "auto-\(albumId)-\(record.serverMaterialId)-tpl\(templateId)-a\(record.autoRetouchAttempt)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isAmbiguousAutoRetouchSubmissionFailure(_ error: Error) -> Bool {
|
||||||
|
guard let apiError = error as? APIError else { return false }
|
||||||
|
switch apiError {
|
||||||
|
case .networkFailed, .invalidResponse, .emptyData, .decodeFailed:
|
||||||
|
return true
|
||||||
|
case .httpStatus(let status, _):
|
||||||
|
return status == 408 || status >= 500
|
||||||
|
case .invalidURL, .serverCode:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func applyMergedPhotos() {
|
private func applyMergedPhotos() {
|
||||||
let persistedItems = persistedRecordsById.values
|
let persistedItems = persistedRecordsById.values
|
||||||
.map { $0.toPhotoItem(storage: storage, albumId: albumId) }
|
.map { $0.toPhotoItem(storage: storage, albumId: albumId) }
|
||||||
@@ -588,6 +877,7 @@ final class WiredCameraTransferViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func uploadPhoto(id: String) async {
|
private func uploadPhoto(id: String) async {
|
||||||
|
let retouchSnapshot = 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)
|
||||||
@@ -597,13 +887,44 @@ 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)
|
completeOriginalUpload(id: id, material: material, retouchSnapshot: retouchSnapshot)
|
||||||
|
if retouchSnapshot.enabled {
|
||||||
|
await submitAutoRetouch(photoId: id)
|
||||||
|
}
|
||||||
} 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 completeOriginalUpload(
|
||||||
|
id: String,
|
||||||
|
material: TravelAlbumMaterial,
|
||||||
|
retouchSnapshot: TravelAlbumAutoRetouchConfiguration
|
||||||
|
) {
|
||||||
|
guard var record = persistedRecordsById[id] else {
|
||||||
|
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record.status = .uploaded
|
||||||
|
record.progress = 100
|
||||||
|
record.errorMessage = nil
|
||||||
|
record.remoteUrl = material.fileUrl
|
||||||
|
record.serverMaterialId = material.id
|
||||||
|
record.autoRetouchTemplateId = retouchSnapshot.enabled ? retouchSnapshot.refinedTemplateId : nil
|
||||||
|
record.autoRetouchBatchId = 0
|
||||||
|
record.autoRetouchAttempt = 0
|
||||||
|
record.autoRetouchErrorMessage = nil
|
||||||
|
if retouchSnapshot.enabled, retouchSnapshot.refinedTemplateId != nil {
|
||||||
|
record.autoRetouchState = .pendingSubmission
|
||||||
|
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
|
||||||
|
} else {
|
||||||
|
record.autoRetouchState = .none
|
||||||
|
record.autoRetouchClientRequestId = ""
|
||||||
|
}
|
||||||
|
persist(record)
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -653,6 +974,14 @@ final class WiredCameraTransferViewModel {
|
|||||||
applyMergedPhotos()
|
applyMergedPhotos()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func persist(_ record: TravelAlbumOTGPhotoRecord) {
|
||||||
|
var updated = record
|
||||||
|
updated.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
persistedRecordsById[updated.id] = updated
|
||||||
|
storage.upsert(updated, albumId: albumId)
|
||||||
|
applyMergedPhotos()
|
||||||
|
}
|
||||||
|
|
||||||
private func notifyStateChanged() {
|
private func notifyStateChanged() {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
private var sessionExpiredDialog: SessionExpiredDialogViewController?
|
private var sessionExpiredDialog: SessionExpiredDialogViewController?
|
||||||
|
private var deregistrationCoordinator: StoreAccountDeregistrationRootCoordinator?
|
||||||
|
private var needsForegroundDeregistrationCheck = false
|
||||||
|
|
||||||
func scene(
|
func scene(
|
||||||
_ scene: UIScene,
|
_ scene: UIScene,
|
||||||
@@ -20,26 +22,39 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
AppNavigationBarAppearance.applyGlobalAppearance()
|
AppNavigationBarAppearance.applyGlobalAppearance()
|
||||||
|
|
||||||
let window = UIWindow(windowScene: windowScene)
|
let window = UIWindow(windowScene: windowScene)
|
||||||
window.rootViewController = AppRouter.makeRootViewController()
|
window.rootViewController = UIViewController()
|
||||||
window.makeKeyAndVisible()
|
|
||||||
self.window = window
|
self.window = window
|
||||||
(UIApplication.shared.delegate as? AppDelegate)?.window = window
|
(UIApplication.shared.delegate as? AppDelegate)?.window = window
|
||||||
|
let appSession = AppStore.shared.session
|
||||||
|
deregistrationCoordinator = StoreAccountDeregistrationRootCoordinator(
|
||||||
|
window: window, session: appSession,
|
||||||
|
makeAPI: { identity in
|
||||||
|
StoreAccountDeregistrationAPI(client: NetworkServices.shared.apiClient, identity: identity) {
|
||||||
|
identity.matches(session: appSession)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
makeSubmissionStore: { StoreAccountDeregistrationSubmissionStore(storeUserID: $0) },
|
||||||
|
makeBusinessRoot: { MainTabBarController() },
|
||||||
|
setBindingSuspended: { PushNotificationManager.shared.setAccountBindingSuspended($0) },
|
||||||
|
onBusinessResumed: {
|
||||||
|
PushNotificationManager.shared.handleLoginCompleted()
|
||||||
|
PushNotificationManager.shared.routePendingNotificationIfPossible()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
PushNotificationManager.shared.attach(window: window)
|
PushNotificationManager.shared.attach(window: window)
|
||||||
|
|
||||||
registerNotifications()
|
registerNotifications()
|
||||||
|
refreshRootForCurrentSession()
|
||||||
if AppStore.shared.session.isLoggedIn {
|
window.makeKeyAndVisible()
|
||||||
DispatchQueue.main.async {
|
|
||||||
PushNotificationManager.shared.handleLoginCompleted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let response = connectionOptions.notificationResponse {
|
if let response = connectionOptions.notificationResponse {
|
||||||
PushNotificationManager.shared.handleNotificationResponse(response)
|
PushNotificationManager.shared.handleNotificationResponse(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidDisconnect(_ scene: UIScene) {
|
func sceneDidDisconnect(_ scene: UIScene) {
|
||||||
|
deregistrationCoordinator?.cancelPendingCheck()
|
||||||
|
deregistrationCoordinator = nil
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
if (UIApplication.shared.delegate as? AppDelegate)?.window === window {
|
if (UIApplication.shared.delegate as? AppDelegate)?.window === window {
|
||||||
(UIApplication.shared.delegate as? AppDelegate)?.window = nil
|
(UIApplication.shared.delegate as? AppDelegate)?.window = nil
|
||||||
@@ -56,10 +71,30 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||||
|
guard accessController == nil, deregistrationCoordinator?.isChecking != true else { return }
|
||||||
PushNotificationManager.shared.retryPendingRegistrationUpload()
|
PushNotificationManager.shared.retryPendingRegistrationUpload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sceneDidEnterBackground(_ scene: UIScene) {
|
||||||
|
needsForegroundDeregistrationCheck = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneWillEnterForeground(_ scene: UIScene) {
|
||||||
|
guard needsForegroundDeregistrationCheck else { return }
|
||||||
|
needsForegroundDeregistrationCheck = false
|
||||||
|
guard sessionExpiredDialog == nil else { return }
|
||||||
|
deregistrationCoordinator?.resumeFromBackground()
|
||||||
|
}
|
||||||
|
|
||||||
private func registerNotifications() {
|
private func registerNotifications() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleStoreDeregistrationRestriction(_:)),
|
||||||
|
name: NotificationName.storeAccountDeregistrationRestricted, object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleStoreDeregistrationSubmitted(_:)),
|
||||||
|
name: NotificationName.storeAccountDeregistrationSubmitted, object: nil
|
||||||
|
)
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleSessionDidExpire),
|
selector: #selector(handleSessionDidExpire),
|
||||||
@@ -90,6 +125,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
guard AppStore.shared.session.isLoggedIn else { return }
|
guard AppStore.shared.session.isLoggedIn else { return }
|
||||||
guard sessionExpiredDialog == nil else { return }
|
guard sessionExpiredDialog == nil else { return }
|
||||||
|
|
||||||
|
deregistrationCoordinator?.cancelPendingCheck()
|
||||||
GlobalLoadingManager.shared.hideAll()
|
GlobalLoadingManager.shared.hideAll()
|
||||||
let dialog = SessionExpiredDialogViewController { [weak self] in
|
let dialog = SessionExpiredDialogViewController { [weak self] in
|
||||||
self?.transitionToLogin()
|
self?.transitionToLogin()
|
||||||
@@ -104,28 +140,85 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func transitionToLogin() {
|
private func transitionToLogin() {
|
||||||
sessionExpiredDialog = nil
|
clearAuthenticatedSession()
|
||||||
PushNotificationManager.shared.handleLogout()
|
|
||||||
AppStore.shared.logout()
|
|
||||||
AppRouter.setRoot(.login, on: window)
|
AppRouter.setRoot(.login, on: window)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 统一清除当前会话及依赖登录态的后台能力,不执行页面跳转。
|
||||||
|
private func clearAuthenticatedSession() {
|
||||||
|
deregistrationCoordinator?.cancelPendingCheck()
|
||||||
|
sessionExpiredDialog?.dismiss(animated: false)
|
||||||
|
sessionExpiredDialog = nil
|
||||||
|
GlobalLoadingManager.shared.hideAll()
|
||||||
|
PushNotificationManager.shared.handleLogout()
|
||||||
|
PushNotificationManager.shared.setAccountBindingSuspended(false)
|
||||||
|
AppStore.shared.logout()
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func handleUserDidLogout() {
|
@objc private func handleUserDidLogout() {
|
||||||
transitionToLogin()
|
transitionToLogin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 申请明确受理后先退出,再展示只含“完成”的结果页;结果页不再依赖已清除的凭证。
|
||||||
|
@objc private func handleStoreDeregistrationSubmitted(_ notification: Notification) {
|
||||||
|
let identityName = notification.userInfo?[NotificationUserInfoKey.deregistrationIdentityName] as? String
|
||||||
|
?? "当前门店身份"
|
||||||
|
let coolingUntil = notification.userInfo?[NotificationUserInfoKey.deregistrationCoolingUntil] as? String
|
||||||
|
clearAuthenticatedSession()
|
||||||
|
guard !AppStore.shared.session.isLoggedIn else {
|
||||||
|
AppRouter.setRoot(.login, on: window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let controller = StoreAccountDeregistrationSubmittedViewController(
|
||||||
|
identityName: identityName,
|
||||||
|
coolingUntil: coolingUntil
|
||||||
|
) { [weak self] in
|
||||||
|
AppRouter.setRoot(.login, on: self?.window)
|
||||||
|
}
|
||||||
|
AppRouter.setRoot(UINavigationController(rootViewController: controller), on: window)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func handleUserDidLogin() {
|
@objc private func handleUserDidLogin() {
|
||||||
sessionExpiredDialog?.dismiss(animated: false)
|
sessionExpiredDialog?.dismiss(animated: false)
|
||||||
sessionExpiredDialog = nil
|
sessionExpiredDialog = nil
|
||||||
AppRouter.setRoot(.mainTab, on: window)
|
// v9 登录响应中的 store_users[].status 是登录是否需要注销确认的唯一依据。
|
||||||
DispatchQueue.main.async {
|
// 旧 account-deregister/status 仅供用户主动进入注销设置流程时查询,不能再拦截正常登录。
|
||||||
PushNotificationManager.shared.handleLoginCompleted()
|
refreshRootForCurrentSession()
|
||||||
PushNotificationManager.shared.routePendingNotificationIfPossible()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleAccountDidSwitch() {
|
@objc private func handleAccountDidSwitch() {
|
||||||
PushNotificationManager.shared.handleAccountSwitched()
|
if AppStore.shared.session.accountType == .storeUser {
|
||||||
|
refreshRootForCurrentSession()
|
||||||
|
} else {
|
||||||
|
deregistrationCoordinator?.cancelPendingCheck()
|
||||||
|
PushNotificationManager.shared.setAccountBindingSuspended(false)
|
||||||
|
PushNotificationManager.shared.handleAccountSwitched()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅检查真实根控制器,避免被旧的异步查询或页面导航状态误判。
|
||||||
|
private var accessController: StoreAccountDeregistrationAccessViewController? {
|
||||||
|
deregistrationCoordinator?.accessController
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 冷启动和切换身份使用已有会话,不主动查询注销状态;新登录由独立入口核验。
|
||||||
|
private func refreshRootForCurrentSession() {
|
||||||
|
let session = AppStore.shared.session
|
||||||
|
guard session.isLoggedIn else {
|
||||||
|
deregistrationCoordinator?.cancelPendingCheck()
|
||||||
|
PushNotificationManager.shared.setAccountBindingSuspended(false)
|
||||||
|
AppRouter.setRoot(.login, on: window, animated: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deregistrationCoordinator?.restoreSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 150015 只限制原请求对应的当前门店身份;旧 Token 响应不会影响新身份。
|
||||||
|
@objc private func handleStoreDeregistrationRestriction(_ notification: Notification) {
|
||||||
|
let token = notification.userInfo?[NotificationUserInfoKey.deregistrationRequestToken] as? String
|
||||||
|
let session = AppStore.shared.session
|
||||||
|
guard StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: token, session: session) else { return }
|
||||||
|
deregistrationCoordinator?.recordRestriction(requestToken: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func topViewController(from viewController: UIViewController?) -> UIViewController? {
|
private func topViewController(from viewController: UIViewController?) -> UIViewController? {
|
||||||
|
|||||||
@@ -163,7 +163,17 @@ final class AccountSelectionViewController: UIViewController, UITableViewDelegat
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func confirmTapped() {
|
@objc private func confirmTapped() {
|
||||||
guard let selectedAccount else { return }
|
guard canConfirm, let selectedAccount else { return }
|
||||||
|
if selectedAccount.requiresDeregistrationConfirmation {
|
||||||
|
present(
|
||||||
|
makeDeregistrationLoginAlert(
|
||||||
|
account: selectedAccount,
|
||||||
|
onConfirm: { [weak self] in self?.onConfirm(selectedAccount) }
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
onConfirm(selectedAccount)
|
onConfirm(selectedAccount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +187,23 @@ final class AccountSelectionViewController: UIViewController, UITableViewDelegat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建冷静期账号登录确认弹窗,确认后 `set-user` 会由后端自动撤销原注销申请。
|
||||||
|
func makeDeregistrationLoginAlert(
|
||||||
|
account: AccountSwitchAccount,
|
||||||
|
onConfirm: @escaping () -> Void
|
||||||
|
) -> UIAlertController {
|
||||||
|
let accountName = account.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let displayName = accountName.isEmpty ? "该门店账号" : "“\(accountName)”"
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "该账号正在注销",
|
||||||
|
message: "\(displayName)已提交注销申请,目前处于冷静期。确认登录后,将自动撤销之前的注销申请。",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "暂不登录", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "确认登录", style: .default) { _ in onConfirm() })
|
||||||
|
return alert
|
||||||
|
}
|
||||||
|
|
||||||
/// 账号选择列表 Cell。
|
/// 账号选择列表 Cell。
|
||||||
private final class AccountSelectionCell: UITableViewCell {
|
private final class AccountSelectionCell: UITableViewCell {
|
||||||
static let reuseIdentifier = "AccountSelectionCell"
|
static let reuseIdentifier = "AccountSelectionCell"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import UIKit
|
|||||||
final class LoginViewController: BaseViewController {
|
final class LoginViewController: BaseViewController {
|
||||||
|
|
||||||
private let viewModel = LoginViewModel()
|
private let viewModel = LoginViewModel()
|
||||||
private let authAPI = NetworkServices.shared.authAPI
|
private let authAPI: AuthAPI
|
||||||
|
|
||||||
private let backgroundImageView = UIImageView()
|
private let backgroundImageView = UIImageView()
|
||||||
private let welcomeLabel = UILabel()
|
private let welcomeLabel = UILabel()
|
||||||
@@ -29,6 +29,17 @@ final class LoginViewController: BaseViewController {
|
|||||||
|
|
||||||
private weak var accountSelectionController: AccountSelectionViewController?
|
private weak var accountSelectionController: AccountSelectionViewController?
|
||||||
|
|
||||||
|
/// 默认使用共享登录服务,测试可注入Mock网络客户端而不修改真实会话。
|
||||||
|
init(authAPI: AuthAPI? = nil) {
|
||||||
|
self.authAPI = authAPI ?? NetworkServices.shared.authAPI
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
.lightContent
|
.lightContent
|
||||||
}
|
}
|
||||||
@@ -264,6 +275,7 @@ final class LoginViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func performLogin() {
|
private func performLogin() {
|
||||||
|
guard !viewModel.isLoading else { return }
|
||||||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||||||
accountField.text = viewModel.normalizedUsername
|
accountField.text = viewModel.normalizedUsername
|
||||||
|
|
||||||
@@ -278,6 +290,8 @@ final class LoginViewController: BaseViewController {
|
|||||||
completeLogin(with: response, account: account)
|
completeLogin(with: response, account: account)
|
||||||
case .needsAccountSelection:
|
case .needsAccountSelection:
|
||||||
break
|
break
|
||||||
|
case let .needsDeregistrationConfirmation(confirmation):
|
||||||
|
presentDeregistrationLoginConfirmation(confirmation)
|
||||||
}
|
}
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
@@ -323,6 +337,31 @@ final class LoginViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func presentDeregistrationLoginConfirmation(_ confirmation: DeregistrationLoginConfirmation) {
|
||||||
|
let alert = makeDeregistrationLoginAlert(
|
||||||
|
account: confirmation.account,
|
||||||
|
onConfirm: { [weak self] in
|
||||||
|
self?.confirmDeregistrationLogin(confirmation)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmDeregistrationLogin(_ confirmation: DeregistrationLoginConfirmation) {
|
||||||
|
Task {
|
||||||
|
showLoading()
|
||||||
|
defer { hideLoading() }
|
||||||
|
do {
|
||||||
|
let response = try await viewModel.confirmDeregistrationLogin(confirmation, authAPI: authAPI)
|
||||||
|
completeLogin(with: response, account: confirmation.account)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
showToast(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func completeLogin(with response: V9AuthResponse, account: AccountSwitchAccount) {
|
private func completeLogin(with response: V9AuthResponse, account: AccountSwitchAccount) {
|
||||||
AuthSessionHelper.completeLogin(
|
AuthSessionHelper.completeLogin(
|
||||||
with: response,
|
with: response,
|
||||||
|
|||||||
@@ -130,6 +130,28 @@ final class LoginViewModel {
|
|||||||
guard let payload = pendingAccountSelection, payload.hasTempToken else {
|
guard let payload = pendingAccountSelection, payload.hasTempToken else {
|
||||||
throw LoginFlowError.missingToken
|
throw LoginFlowError.missingToken
|
||||||
}
|
}
|
||||||
|
let response = try await setUser(account, tempToken: payload.tempToken, authAPI: authAPI)
|
||||||
|
pendingAccountSelection = nil
|
||||||
|
notifyStateChange()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户确认登录冷静期账号后继续换取正式 token;`set-user` 成功即由后端撤销原注销申请。
|
||||||
|
func confirmDeregistrationLogin(
|
||||||
|
_ confirmation: DeregistrationLoginConfirmation,
|
||||||
|
authAPI: AuthAPI
|
||||||
|
) async throws -> V9AuthResponse {
|
||||||
|
try await setUser(confirmation.account, tempToken: confirmation.tempToken, authAPI: authAPI)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUser(
|
||||||
|
_ account: AccountSwitchAccount,
|
||||||
|
tempToken: String,
|
||||||
|
authAPI: AuthAPI
|
||||||
|
) async throws -> V9AuthResponse {
|
||||||
|
guard !tempToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
throw LoginFlowError.missingToken
|
||||||
|
}
|
||||||
guard account.businessUserId > 0 else {
|
guard account.businessUserId > 0 else {
|
||||||
throw LoginFlowError.invalidAccount
|
throw LoginFlowError.invalidAccount
|
||||||
}
|
}
|
||||||
@@ -144,12 +166,10 @@ final class LoginViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken)
|
let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: tempToken)
|
||||||
guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
throw LoginFlowError.missingToken
|
throw LoginFlowError.missingToken
|
||||||
}
|
}
|
||||||
pendingAccountSelection = nil
|
|
||||||
notifyStateChange()
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +198,11 @@ final class LoginViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if accounts.count == 1, let account = accounts.first {
|
if accounts.count == 1, let account = accounts.first {
|
||||||
|
if account.requiresDeregistrationConfirmation {
|
||||||
|
return .needsDeregistrationConfirmation(
|
||||||
|
DeregistrationLoginConfirmation(tempToken: token, account: account)
|
||||||
|
)
|
||||||
|
}
|
||||||
let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token)
|
let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token)
|
||||||
guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
throw LoginFlowError.missingToken
|
throw LoginFlowError.missingToken
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ final class MainTabBarController: UITabBarController {
|
|||||||
controller = PaymentCollectionDetailsViewController()
|
controller = PaymentCollectionDetailsViewController()
|
||||||
case .messageCenter:
|
case .messageCenter:
|
||||||
controller = MessageCenterViewController()
|
controller = MessageCenterViewController()
|
||||||
|
case .aiRetouchTaskList:
|
||||||
|
controller = TravelAlbumAIJobListViewController()
|
||||||
|
case .aiRetouchTaskDetail(let batchId):
|
||||||
|
controller = TravelAlbumAIJobDetailViewController(batchId: batchId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let controller {
|
if let controller {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
|
|
||||||
private let viewModel: MessageDetailViewModel
|
private let viewModel: MessageDetailViewModel
|
||||||
private let api: any MessageCenterServing
|
private let api: any MessageCenterServing
|
||||||
|
private let travelAlbumAPI: any TravelAlbumServing
|
||||||
|
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentView = UIView()
|
private let contentView = UIView()
|
||||||
@@ -20,13 +21,19 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
private let timeContainer = UIView()
|
private let timeContainer = UIView()
|
||||||
private let timeLabel = UILabel()
|
private let timeLabel = UILabel()
|
||||||
private let bodyLabel = UILabel()
|
private let bodyLabel = UILabel()
|
||||||
|
private let taskDetailButton = UIButton(type: .system)
|
||||||
private let bottomBar = UIView()
|
private let bottomBar = UIView()
|
||||||
private let deleteButton = UIButton(type: .system)
|
private let deleteButton = UIButton(type: .system)
|
||||||
|
|
||||||
/// 初始化消息详情页。
|
/// 初始化消息详情页。
|
||||||
init(message: MessageItem, api: (any MessageCenterServing)? = nil) {
|
init(
|
||||||
|
message: MessageItem,
|
||||||
|
api: (any MessageCenterServing)? = nil,
|
||||||
|
travelAlbumAPI: (any TravelAlbumServing)? = nil
|
||||||
|
) {
|
||||||
viewModel = MessageDetailViewModel(message: message)
|
viewModel = MessageDetailViewModel(message: message)
|
||||||
self.api = api ?? NetworkServices.shared.messageCenterAPI
|
self.api = api ?? NetworkServices.shared.messageCenterAPI
|
||||||
|
self.travelAlbumAPI = travelAlbumAPI ?? NetworkServices.shared.travelAlbumAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +74,15 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
bodyLabel.numberOfLines = 0
|
bodyLabel.numberOfLines = 0
|
||||||
bodyLabel.textAlignment = .left
|
bodyLabel.textAlignment = .left
|
||||||
|
|
||||||
|
taskDetailButton.setTitle("查看任务详情", for: .normal)
|
||||||
|
taskDetailButton.setTitleColor(.white, for: .normal)
|
||||||
|
taskDetailButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
taskDetailButton.backgroundColor = AppColor.primary
|
||||||
|
taskDetailButton.layer.cornerRadius = 10
|
||||||
|
taskDetailButton.clipsToBounds = true
|
||||||
|
taskDetailButton.isHidden = !viewModel.showsAIRetouchTaskAction
|
||||||
|
taskDetailButton.accessibilityLabel = "查看AI修图任务详情"
|
||||||
|
|
||||||
bottomBar.backgroundColor = .white
|
bottomBar.backgroundColor = .white
|
||||||
deleteButton.setTitle("删除并返回", for: .normal)
|
deleteButton.setTitle("删除并返回", for: .normal)
|
||||||
deleteButton.setTitleColor(.white, for: .normal)
|
deleteButton.setTitleColor(.white, for: .normal)
|
||||||
@@ -77,7 +93,7 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
|
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
scrollView.addSubview(contentView)
|
scrollView.addSubview(contentView)
|
||||||
[typeImageView, titleLabel, timeContainer, bodyLabel].forEach(contentView.addSubview)
|
[typeImageView, titleLabel, timeContainer, bodyLabel, taskDetailButton].forEach(contentView.addSubview)
|
||||||
timeContainer.addSubview(timeLabel)
|
timeContainer.addSubview(timeLabel)
|
||||||
view.addSubview(bottomBar)
|
view.addSubview(bottomBar)
|
||||||
bottomBar.addSubview(deleteButton)
|
bottomBar.addSubview(deleteButton)
|
||||||
@@ -121,12 +137,23 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
bodyLabel.snp.makeConstraints { make in
|
bodyLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(timeContainer.snp.bottom).offset(24)
|
make.top.equalTo(timeContainer.snp.bottom).offset(24)
|
||||||
make.leading.trailing.equalToSuperview().inset(32)
|
make.leading.trailing.equalToSuperview().inset(32)
|
||||||
make.bottom.lessThanOrEqualToSuperview().inset(24)
|
if !viewModel.showsAIRetouchTaskAction {
|
||||||
|
make.bottom.lessThanOrEqualToSuperview().inset(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewModel.showsAIRetouchTaskAction {
|
||||||
|
taskDetailButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(bodyLabel.snp.bottom).offset(24)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(32)
|
||||||
|
make.height.equalTo(52)
|
||||||
|
make.bottom.lessThanOrEqualToSuperview().inset(24)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func bindActions() {
|
override func bindActions() {
|
||||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||||
|
taskDetailButton.addTarget(self, action: #selector(taskDetailTapped), for: .touchUpInside)
|
||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.applyState()
|
self?.applyState()
|
||||||
@@ -145,6 +172,16 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func taskDetailTapped() {
|
||||||
|
let target: UIViewController
|
||||||
|
if let batchId = viewModel.aiRetouchBatchId {
|
||||||
|
target = TravelAlbumAIJobDetailViewController(batchId: batchId, api: travelAlbumAPI)
|
||||||
|
} else {
|
||||||
|
target = TravelAlbumAIJobListViewController(api: travelAlbumAPI)
|
||||||
|
}
|
||||||
|
navigationController?.pushViewController(target, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func deleteTapped() {
|
@objc private func deleteTapped() {
|
||||||
let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert)
|
let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
|||||||
@@ -0,0 +1,630 @@
|
|||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 日清列表条目。
|
||||||
|
private enum OfflineDailyItem: Hashable {
|
||||||
|
case record(OfflineCollectionRecord)
|
||||||
|
case empty(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日清记录在白色列表卡中的圆角位置。
|
||||||
|
private enum OfflineRecordPosition {
|
||||||
|
case single
|
||||||
|
case first
|
||||||
|
case middle
|
||||||
|
case last
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款日清页,按 9.7 视觉稿展示日历、2×2 汇总、明细和补缴操作。
|
||||||
|
@MainActor
|
||||||
|
final class OfflineCollectionDailyViewController: BaseViewController {
|
||||||
|
private let viewModel: OfflineCollectionDailyViewModel
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Int, OfflineDailyItem>!
|
||||||
|
private let headerContainer = UIView()
|
||||||
|
private let headerStack = UIStackView()
|
||||||
|
private let calendarView = OfflineCollectionCalendarView()
|
||||||
|
private var calendarHeightConstraint: Constraint?
|
||||||
|
private var isAnimatingHeaderResize = false
|
||||||
|
private let summaryCard = UIView()
|
||||||
|
private let summaryDateLabel = UILabel()
|
||||||
|
private let totalValue = UILabel()
|
||||||
|
private let countValue = UILabel()
|
||||||
|
private let settledValue = UILabel()
|
||||||
|
private let pendingValue = UILabel()
|
||||||
|
private let statusButton = UIButton(type: .system)
|
||||||
|
private let bottomContainer = UIView()
|
||||||
|
private let settlementButton = OfflineCollectionGradientButton(
|
||||||
|
startColor: UIColor(hex: 0xFF8B00),
|
||||||
|
endColor: UIColor(hex: 0xFF7200)
|
||||||
|
)
|
||||||
|
private var feedbackPresented = false
|
||||||
|
private var isShowingGlobalLoading = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
businessDate: String,
|
||||||
|
context: OfflineCollectionContext = .current(),
|
||||||
|
api: any OfflineCollectionServing = NetworkServices.shared.offlineCollectionAPI
|
||||||
|
) {
|
||||||
|
viewModel = OfflineCollectionDailyViewModel(businessDate: businessDate, context: context, api: api)
|
||||||
|
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 = UIColor(hex: 0xF7FAFF)
|
||||||
|
configureTableView()
|
||||||
|
configureHeader()
|
||||||
|
configureBottomBar()
|
||||||
|
|
||||||
|
view.addSubview(tableView)
|
||||||
|
view.addSubview(bottomContainer)
|
||||||
|
tableView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
make.bottom.equalTo(bottomContainer.snp.top)
|
||||||
|
}
|
||||||
|
bottomContainer.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
calendarView.onDateSelected = { [weak self] date in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.viewModel.selectBusinessDate(date) }
|
||||||
|
}
|
||||||
|
calendarView.onModeChanged = { [weak self] animated in self?.resizeHeader(animated: animated) }
|
||||||
|
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
Task { await viewModel.refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
setGlobalLoadingVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
if !isAnimatingHeaderResize {
|
||||||
|
resizeHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureTableView() {
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.estimatedRowHeight = 96
|
||||||
|
tableView.showsVerticalScrollIndicator = false
|
||||||
|
tableView.contentInset.bottom = 12
|
||||||
|
tableView.register(OfflineCollectionRecordCell.self, forCellReuseIdentifier: OfflineCollectionRecordCell.reuseIdentifier)
|
||||||
|
tableView.register(OfflineCollectionEmptyCell.self, forCellReuseIdentifier: OfflineCollectionEmptyCell.reuseIdentifier)
|
||||||
|
dataSource = makeDataSource()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureHeader() {
|
||||||
|
headerStack.axis = .vertical
|
||||||
|
headerStack.spacing = 16
|
||||||
|
headerContainer.addSubview(headerStack)
|
||||||
|
headerStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(12)
|
||||||
|
make.bottom.equalToSuperview().inset(10)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
headerStack.addArrangedSubview(calendarView)
|
||||||
|
calendarView.snp.makeConstraints { make in
|
||||||
|
calendarHeightConstraint = make.height.equalTo(calendarView.preferredHeight).constraint
|
||||||
|
}
|
||||||
|
configureSummaryCard()
|
||||||
|
headerStack.addArrangedSubview(summaryCard)
|
||||||
|
|
||||||
|
let listTitle = UILabel()
|
||||||
|
listTitle.text = "收款明细"
|
||||||
|
listTitle.font = .systemFont(ofSize: 18, weight: .bold)
|
||||||
|
listTitle.textColor = UIColor(hex: 0x081739)
|
||||||
|
let listTitleContainer = UIView()
|
||||||
|
listTitleContainer.addSubview(listTitle)
|
||||||
|
listTitle.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(8)
|
||||||
|
make.trailing.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
listTitleContainer.snp.makeConstraints { make in make.height.equalTo(42) }
|
||||||
|
headerStack.setCustomSpacing(18, after: summaryCard)
|
||||||
|
headerStack.addArrangedSubview(listTitleContainer)
|
||||||
|
|
||||||
|
statusButton.configuration = .plain()
|
||||||
|
statusButton.configuration?.baseForegroundColor = AppColor.primary
|
||||||
|
statusButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||||
|
statusButton.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) }
|
||||||
|
statusButton.isHidden = true
|
||||||
|
headerStack.addArrangedSubview(statusButton)
|
||||||
|
tableView.tableHeaderView = headerContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureSummaryCard() {
|
||||||
|
summaryCard.backgroundColor = .white
|
||||||
|
summaryCard.layer.cornerRadius = 12
|
||||||
|
summaryCard.layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor
|
||||||
|
summaryCard.layer.shadowOpacity = 0.12
|
||||||
|
summaryCard.layer.shadowRadius = 10
|
||||||
|
summaryCard.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||||
|
|
||||||
|
summaryDateLabel.font = .systemFont(ofSize: 17, weight: .bold)
|
||||||
|
summaryDateLabel.textColor = UIColor(hex: 0x081739)
|
||||||
|
|
||||||
|
let topLeft = metric(title: "线下收款总额", value: totalValue)
|
||||||
|
let topRight = metric(title: "登记笔数", value: countValue)
|
||||||
|
let bottomLeft = metric(title: "已补缴", value: settledValue)
|
||||||
|
let bottomRight = metric(title: "待补缴", value: pendingValue)
|
||||||
|
let grid = UIView()
|
||||||
|
[topLeft, topRight, bottomLeft, bottomRight].forEach(grid.addSubview)
|
||||||
|
topLeft.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview()
|
||||||
|
make.width.equalToSuperview().multipliedBy(0.5)
|
||||||
|
make.height.equalToSuperview().multipliedBy(0.5)
|
||||||
|
}
|
||||||
|
topRight.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview()
|
||||||
|
make.width.height.equalTo(topLeft)
|
||||||
|
}
|
||||||
|
bottomLeft.snp.makeConstraints { make in
|
||||||
|
make.bottom.leading.equalToSuperview()
|
||||||
|
make.width.height.equalTo(topLeft)
|
||||||
|
}
|
||||||
|
bottomRight.snp.makeConstraints { make in
|
||||||
|
make.bottom.trailing.equalToSuperview()
|
||||||
|
make.width.height.equalTo(topLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
let horizontalDivider = UIView()
|
||||||
|
let verticalDivider = UIView()
|
||||||
|
[horizontalDivider, verticalDivider].forEach {
|
||||||
|
$0.backgroundColor = UIColor(hex: 0xE7EBF1)
|
||||||
|
grid.addSubview($0)
|
||||||
|
}
|
||||||
|
horizontalDivider.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.centerY.equalToSuperview()
|
||||||
|
make.height.equalTo(1)
|
||||||
|
}
|
||||||
|
verticalDivider.snp.makeConstraints { make in
|
||||||
|
make.top.bottom.centerX.equalToSuperview()
|
||||||
|
make.width.equalTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryCard.addSubview(summaryDateLabel)
|
||||||
|
summaryCard.addSubview(grid)
|
||||||
|
summaryDateLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(14)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(22)
|
||||||
|
}
|
||||||
|
grid.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(summaryDateLabel.snp.bottom).offset(6)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(10)
|
||||||
|
make.bottom.equalToSuperview().inset(8)
|
||||||
|
}
|
||||||
|
summaryCard.snp.makeConstraints { make in make.height.equalTo(196) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metric(title: String, value: UILabel) -> UIView {
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = title
|
||||||
|
titleLabel.font = .systemFont(ofSize: 12)
|
||||||
|
titleLabel.textColor = UIColor(hex: 0x7B8494)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
|
value.font = .systemFont(ofSize: 20, weight: .bold)
|
||||||
|
value.textColor = UIColor(hex: 0x081739)
|
||||||
|
value.textAlignment = .center
|
||||||
|
value.adjustsFontSizeToFitWidth = true
|
||||||
|
value.minimumScaleFactor = 0.72
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [titleLabel, value])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.alignment = .fill
|
||||||
|
stack.spacing = 6
|
||||||
|
|
||||||
|
let container = UIView()
|
||||||
|
container.addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureBottomBar() {
|
||||||
|
bottomContainer.backgroundColor = .white
|
||||||
|
bottomContainer.layer.shadowColor = UIColor(hex: 0x8090A8).cgColor
|
||||||
|
bottomContainer.layer.shadowOpacity = 0.12
|
||||||
|
bottomContainer.layer.shadowRadius = 12
|
||||||
|
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -3)
|
||||||
|
settlementButton.setTitle("当日暂无待补缴", for: .normal)
|
||||||
|
settlementButton.setTitleColor(.white, for: .normal)
|
||||||
|
settlementButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
settlementButton.layer.cornerRadius = 10
|
||||||
|
settlementButton.clipsToBounds = true
|
||||||
|
settlementButton.accessibilityIdentifier = "offlineCollection.settle"
|
||||||
|
settlementButton.addTarget(self, action: #selector(settlementTapped), for: .touchUpInside)
|
||||||
|
bottomContainer.addSubview(settlementButton)
|
||||||
|
settlementButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(14)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(54)
|
||||||
|
make.bottom.equalTo(bottomContainer.safeAreaLayoutGuide).inset(14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor private func applyState() {
|
||||||
|
let processing = viewModel.settlementState == .processing
|
||||||
|
setGlobalLoadingVisible(viewModel.isLoading || processing)
|
||||||
|
calendarView.apply(
|
||||||
|
selectedDate: viewModel.businessDate,
|
||||||
|
maximumDate: viewModel.serverToday,
|
||||||
|
pendingDates: viewModel.pendingDates
|
||||||
|
)
|
||||||
|
summaryDateLabel.text = formattedSummaryDate()
|
||||||
|
totalValue.text = OfflineCollectionMoney.display(viewModel.summary.totalAmountFen)
|
||||||
|
countValue.text = "\(viewModel.summary.totalCount)笔"
|
||||||
|
settledValue.text = OfflineCollectionMoney.display(viewModel.summary.settledAmountFen)
|
||||||
|
pendingValue.text = OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen)
|
||||||
|
pendingValue.textColor = viewModel.summary.pendingCount > 0 ? UIColor(hex: 0xFF7600) : UIColor(hex: 0x081739)
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage, !viewModel.isLoading {
|
||||||
|
statusButton.isHidden = false
|
||||||
|
statusButton.isUserInteractionEnabled = true
|
||||||
|
statusButton.configuration?.title = "\(error) 点击重试"
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: error)
|
||||||
|
} else {
|
||||||
|
statusButton.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
settlementButton.isEnabled = viewModel.canSettle && !processing
|
||||||
|
if processing {
|
||||||
|
settlementButton.setTitle("补缴中", for: .normal)
|
||||||
|
} else if viewModel.summary.totalCount > 0 && viewModel.summary.pendingCount == 0 {
|
||||||
|
settlementButton.setTitle("本日已全部补缴", for: .normal)
|
||||||
|
} else if viewModel.summary.pendingCount == 0 {
|
||||||
|
settlementButton.setTitle("当日暂无待补缴", for: .normal)
|
||||||
|
} else {
|
||||||
|
settlementButton.setTitle(
|
||||||
|
"补缴本日全部 \(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen))",
|
||||||
|
for: .normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
settlementButton.alpha = viewModel.canSettle || processing ? 1 : 0.45
|
||||||
|
applySnapshot()
|
||||||
|
resizeHeader()
|
||||||
|
presentFeedbackIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedSummaryDate() -> String {
|
||||||
|
guard let date = OfflineCollectionDate.date(from: viewModel.businessDate) else { return viewModel.businessDate }
|
||||||
|
let calendar = OfflineCollectionDate.calendar
|
||||||
|
let text = "\(calendar.component(.month, from: date))月\(calendar.component(.day, from: date))日"
|
||||||
|
return viewModel.businessDate == viewModel.serverToday ? "\(text) · 今日" : text
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setGlobalLoadingVisible(_ visible: Bool) {
|
||||||
|
guard visible != isShowingGlobalLoading else { return }
|
||||||
|
isShowingGlobalLoading = visible
|
||||||
|
visible ? showLoading() : hideLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor private func applySnapshot() {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Int, OfflineDailyItem>()
|
||||||
|
snapshot.appendSections([0])
|
||||||
|
if viewModel.records.isEmpty {
|
||||||
|
let text = viewModel.errorMessage == nil && !viewModel.isLoading ? "本日暂无线下收款记录" : ""
|
||||||
|
snapshot.appendItems([.empty(text)])
|
||||||
|
} else {
|
||||||
|
snapshot.appendItems(viewModel.records.map(OfflineDailyItem.record))
|
||||||
|
}
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDataSource() -> UITableViewDiffableDataSource<Int, OfflineDailyItem> {
|
||||||
|
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||||
|
switch item {
|
||||||
|
case let .record(record):
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: OfflineCollectionRecordCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! OfflineCollectionRecordCell
|
||||||
|
let count = self?.viewModel.records.count ?? 1
|
||||||
|
let position: OfflineRecordPosition
|
||||||
|
if count == 1 { position = .single }
|
||||||
|
else if indexPath.row == 0 { position = .first }
|
||||||
|
else if indexPath.row == count - 1 { position = .last }
|
||||||
|
else { position = .middle }
|
||||||
|
cell.apply(record, position: position)
|
||||||
|
return cell
|
||||||
|
case let .empty(text):
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: OfflineCollectionEmptyCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! OfflineCollectionEmptyCell
|
||||||
|
cell.apply(text)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizeHeader(animated: Bool = false) {
|
||||||
|
guard tableView.bounds.width > 0 else { return }
|
||||||
|
if isAnimatingHeaderResize, !animated { return }
|
||||||
|
headerContainer.frame.size.width = tableView.bounds.width
|
||||||
|
|
||||||
|
let targetCalendarHeight = calendarView.preferredHeight
|
||||||
|
if animated,
|
||||||
|
!UIAccessibility.isReduceMotionEnabled,
|
||||||
|
headerContainer.frame.height > 0,
|
||||||
|
calendarView.bounds.height > 0 {
|
||||||
|
let targetHeaderHeight = headerContainer.frame.height + targetCalendarHeight - calendarView.bounds.height
|
||||||
|
guard abs(headerContainer.frame.height - targetHeaderHeight) > 0.5 else { return }
|
||||||
|
calendarHeightConstraint?.update(offset: targetCalendarHeight)
|
||||||
|
isAnimatingHeaderResize = true
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: 0.3,
|
||||||
|
delay: 0,
|
||||||
|
options: [.curveEaseInOut, .beginFromCurrentState],
|
||||||
|
animations: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
headerContainer.frame.size.height = targetHeaderHeight
|
||||||
|
headerContainer.layoutIfNeeded()
|
||||||
|
tableView.tableHeaderView = headerContainer
|
||||||
|
tableView.layoutIfNeeded()
|
||||||
|
},
|
||||||
|
completion: { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
isAnimatingHeaderResize = false
|
||||||
|
resizeHeader()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
calendarHeightConstraint?.update(offset: targetCalendarHeight)
|
||||||
|
headerContainer.setNeedsLayout()
|
||||||
|
headerContainer.layoutIfNeeded()
|
||||||
|
let height = headerContainer.systemLayoutSizeFitting(
|
||||||
|
CGSize(width: tableView.bounds.width, height: UIView.layoutFittingCompressedSize.height),
|
||||||
|
withHorizontalFittingPriority: .required,
|
||||||
|
verticalFittingPriority: .fittingSizeLevel
|
||||||
|
).height
|
||||||
|
guard abs(headerContainer.frame.height - height) > 0.5 else { return }
|
||||||
|
let updates = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
headerContainer.frame.size.height = height
|
||||||
|
tableView.tableHeaderView = headerContainer
|
||||||
|
tableView.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
updates()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor private func presentFeedbackIfNeeded() {
|
||||||
|
guard !feedbackPresented else { return }
|
||||||
|
switch viewModel.settlementState {
|
||||||
|
case .idle, .processing:
|
||||||
|
return
|
||||||
|
case .success:
|
||||||
|
// 成功后页面数据已经刷新,直接清理反馈状态,不再阻断用户操作。
|
||||||
|
viewModel.clearSettlementFeedback()
|
||||||
|
case let .failed(message, canRetry):
|
||||||
|
feedbackPresented = true
|
||||||
|
let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "暂不处理", style: .cancel) { [weak self] _ in
|
||||||
|
self?.viewModel.cancelPreparedSettlement()
|
||||||
|
self?.finishFeedback()
|
||||||
|
})
|
||||||
|
if canRetry {
|
||||||
|
alert.addAction(UIAlertAction(title: "重新补缴", style: .default) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.feedbackPresented = false
|
||||||
|
Task { await self.viewModel.confirmSettlement() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishFeedback() {
|
||||||
|
feedbackPresented = false
|
||||||
|
viewModel.clearSettlementFeedback()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func retryTapped() {
|
||||||
|
Task { await viewModel.refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func settlementTapped() {
|
||||||
|
do {
|
||||||
|
let request = try viewModel.prepareSettlement()
|
||||||
|
let message = "营业日:\(request.businessDate)\n待补缴记录:\(request.pendingCount) 笔\n本次补缴金额:\(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: "确认补缴", style: .default) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.viewModel.confirmSettlement() }
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
} catch {
|
||||||
|
showToast(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日清页的一笔收款记录行,纯展示支付方式、编号、金额、状态与时间。
|
||||||
|
private final class OfflineCollectionRecordCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "OfflineCollectionRecordCell"
|
||||||
|
|
||||||
|
private let card = UIView()
|
||||||
|
private let iconView = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let idLabel = UILabel()
|
||||||
|
private let amountLabel = UILabel()
|
||||||
|
private let statusLabel = UILabel()
|
||||||
|
private let timeLabel = UILabel()
|
||||||
|
private let separator = UIView()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
contentView.backgroundColor = .clear
|
||||||
|
|
||||||
|
card.backgroundColor = .white
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
titleLabel.textColor = UIColor(hex: 0x081739)
|
||||||
|
|
||||||
|
idLabel.font = .systemFont(ofSize: 9.5, weight: .medium)
|
||||||
|
idLabel.textColor = UIColor(hex: 0x6F7B8F)
|
||||||
|
idLabel.textAlignment = .left
|
||||||
|
idLabel.numberOfLines = 0
|
||||||
|
idLabel.lineBreakMode = .byCharWrapping
|
||||||
|
idLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
idLabel.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
|
|
||||||
|
amountLabel.font = .systemFont(ofSize: 16, weight: .bold)
|
||||||
|
amountLabel.textColor = UIColor(hex: 0x081739)
|
||||||
|
amountLabel.textAlignment = .right
|
||||||
|
amountLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
amountLabel.minimumScaleFactor = 0.72
|
||||||
|
|
||||||
|
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
|
statusLabel.textAlignment = .center
|
||||||
|
statusLabel.layer.cornerRadius = 7
|
||||||
|
statusLabel.clipsToBounds = true
|
||||||
|
|
||||||
|
timeLabel.font = .systemFont(ofSize: 12)
|
||||||
|
timeLabel.textColor = UIColor(hex: 0x7B8494)
|
||||||
|
timeLabel.textAlignment = .right
|
||||||
|
separator.backgroundColor = UIColor(hex: 0xE7EBF1)
|
||||||
|
|
||||||
|
let idContainer = UIView()
|
||||||
|
idContainer.backgroundColor = UIColor(hex: 0xF0F2F6)
|
||||||
|
idContainer.layer.cornerRadius = 5
|
||||||
|
idContainer.clipsToBounds = true
|
||||||
|
idContainer.addSubview(idLabel)
|
||||||
|
idLabel.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6))
|
||||||
|
}
|
||||||
|
|
||||||
|
card.addSubview(iconView)
|
||||||
|
card.addSubview(titleLabel)
|
||||||
|
card.addSubview(idContainer)
|
||||||
|
card.addSubview(amountLabel)
|
||||||
|
card.addSubview(statusLabel)
|
||||||
|
card.addSubview(timeLabel)
|
||||||
|
card.addSubview(separator)
|
||||||
|
contentView.addSubview(card)
|
||||||
|
|
||||||
|
card.snp.makeConstraints { make in
|
||||||
|
make.top.bottom.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.greaterThanOrEqualTo(90)
|
||||||
|
}
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(12)
|
||||||
|
make.top.equalToSuperview().offset(10)
|
||||||
|
make.width.height.equalTo(38)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(iconView.snp.trailing).offset(10)
|
||||||
|
make.centerY.equalTo(iconView)
|
||||||
|
}
|
||||||
|
amountLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(4)
|
||||||
|
make.centerY.equalTo(iconView)
|
||||||
|
make.width.equalTo(68)
|
||||||
|
}
|
||||||
|
statusLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(amountLabel.snp.trailing).offset(4)
|
||||||
|
make.trailing.equalToSuperview().inset(12)
|
||||||
|
make.centerY.equalTo(iconView)
|
||||||
|
make.width.equalTo(50)
|
||||||
|
make.height.equalTo(27)
|
||||||
|
}
|
||||||
|
timeLabel.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(12)
|
||||||
|
make.centerY.equalTo(idContainer)
|
||||||
|
make.width.equalTo(40)
|
||||||
|
}
|
||||||
|
idContainer.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(iconView)
|
||||||
|
make.top.equalTo(iconView.snp.bottom).offset(6)
|
||||||
|
make.trailing.lessThanOrEqualTo(timeLabel.snp.leading).offset(-8)
|
||||||
|
make.bottom.equalToSuperview().inset(10)
|
||||||
|
}
|
||||||
|
separator.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(12)
|
||||||
|
make.trailing.equalToSuperview().inset(8)
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
make.height.equalTo(1 / UIScreen.main.scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func apply(_ record: OfflineCollectionRecord, position: OfflineRecordPosition) {
|
||||||
|
iconView.image = UIImage(named: record.paymentMethod.assetName)
|
||||||
|
titleLabel.text = record.paymentMethod == .wechat ? "微信收款" : record.paymentMethod.displayName
|
||||||
|
idLabel.text = "编号 \(record.collectNo)"
|
||||||
|
amountLabel.text = OfflineCollectionMoney.display(record.amountFen)
|
||||||
|
let settled = record.status == .settled
|
||||||
|
statusLabel.text = settled ? "已补缴" : "未补缴"
|
||||||
|
statusLabel.textColor = settled ? UIColor(hex: 0x16A34A) : UIColor(hex: 0xFF7600)
|
||||||
|
statusLabel.backgroundColor = settled ? UIColor(hex: 0xEAF8EF) : UIColor(hex: 0xFFF0E2)
|
||||||
|
timeLabel.text = record.timeText
|
||||||
|
separator.isHidden = position == .single || position == .last
|
||||||
|
|
||||||
|
card.layer.cornerRadius = 12
|
||||||
|
switch position {
|
||||||
|
case .single:
|
||||||
|
card.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||||
|
case .first:
|
||||||
|
card.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
|
case .middle:
|
||||||
|
card.layer.maskedCorners = []
|
||||||
|
case .last:
|
||||||
|
card.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||||
|
}
|
||||||
|
accessibilityLabel = "\(titleLabel.text ?? ""),编号 \(record.collectNo),\(amountLabel.text ?? ""),\(statusLabel.text ?? ""),\(record.timeText)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日清页空数据占位。
|
||||||
|
private final class OfflineCollectionEmptyCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "OfflineCollectionEmptyCell"
|
||||||
|
private let label = UILabel()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
label.font = .systemFont(ofSize: 14)
|
||||||
|
label.textColor = UIColor(hex: 0x7B8494)
|
||||||
|
label.textAlignment = .center
|
||||||
|
contentView.addSubview(label)
|
||||||
|
label.snp.makeConstraints { make in make.edges.equalToSuperview().inset(36) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func apply(_ text: String) {
|
||||||
|
label.text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 线下收款登记页,按 9.7 视觉稿展示金额、支付方式和登记说明。
|
||||||
|
@MainActor
|
||||||
|
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 = OfflineCollectionGradientButton(
|
||||||
|
startColor: UIColor(hex: 0x087BFF),
|
||||||
|
endColor: UIColor(hex: 0x0067F4)
|
||||||
|
)
|
||||||
|
private var hasFocusedAmountField = false
|
||||||
|
private var isShowingGlobalLoading = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: OfflineCollectionContext = .current(),
|
||||||
|
api: any OfflineCollectionServing = NetworkServices.shared.offlineCollectionAPI
|
||||||
|
) {
|
||||||
|
viewModel = OfflineCollectionRegistrationViewModel(context: context, api: api)
|
||||||
|
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 = UIColor(hex: 0xF7FAFF)
|
||||||
|
configureScrollContent()
|
||||||
|
configureAmountCard()
|
||||||
|
configureMethodCard()
|
||||||
|
configureExplanationCard()
|
||||||
|
configureContextLabel()
|
||||||
|
configureBottomBar()
|
||||||
|
|
||||||
|
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().offset(16)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(18)
|
||||||
|
make.height.equalTo(56)
|
||||||
|
make.bottom.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
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.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 16, left: 16, bottom: 24, right: 16))
|
||||||
|
make.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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?.applyState() } }
|
||||||
|
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
|
||||||
|
viewModel.onRegistrationSuccess = { [weak self] receipt in Task { @MainActor in self?.showSuccess(receipt) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
applyState()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
guard !hasFocusedAmountField else { return }
|
||||||
|
hasFocusedAmountField = true
|
||||||
|
amountField.becomeFirstResponder()
|
||||||
|
moveAmountCursorToEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
setGlobalLoadingVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureScrollContent() {
|
||||||
|
scrollView.keyboardDismissMode = .interactive
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
scrollView.showsVerticalScrollIndicator = false
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = 16
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureAmountCard() {
|
||||||
|
configureCard(amountCard)
|
||||||
|
|
||||||
|
let titleLabel = makeTitleLabel("收款金额")
|
||||||
|
let helperLabel = UILabel()
|
||||||
|
helperLabel.text = "单笔金额 0.01~99,999.99 元"
|
||||||
|
helperLabel.font = .systemFont(ofSize: 13)
|
||||||
|
helperLabel.textColor = UIColor(hex: 0x7B8494)
|
||||||
|
helperLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
helperLabel.minimumScaleFactor = 0.82
|
||||||
|
|
||||||
|
let currencyLabel = UILabel()
|
||||||
|
currencyLabel.text = "¥"
|
||||||
|
currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold)
|
||||||
|
currencyLabel.textColor = UIColor(hex: 0x081739)
|
||||||
|
|
||||||
|
amountField.placeholder = "0.00"
|
||||||
|
amountField.font = .systemFont(ofSize: 42, weight: .bold)
|
||||||
|
amountField.textColor = UIColor(hex: 0x081739)
|
||||||
|
amountField.tintColor = AppColor.primary
|
||||||
|
amountField.textAlignment = .right
|
||||||
|
amountField.keyboardType = .decimalPad
|
||||||
|
amountField.adjustsFontSizeToFitWidth = true
|
||||||
|
amountField.minimumFontSize = 30
|
||||||
|
amountField.delegate = self
|
||||||
|
amountField.accessibilityLabel = "收款金额"
|
||||||
|
amountField.accessibilityIdentifier = "offlineCollection.amount"
|
||||||
|
|
||||||
|
let amountInputStack = UIStackView(arrangedSubviews: [currencyLabel, amountField])
|
||||||
|
amountInputStack.axis = .horizontal
|
||||||
|
amountInputStack.alignment = .center
|
||||||
|
amountInputStack.spacing = 6
|
||||||
|
|
||||||
|
let amountRow = UIView()
|
||||||
|
amountRow.addSubview(helperLabel)
|
||||||
|
amountRow.addSubview(amountInputStack)
|
||||||
|
helperLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.centerY.equalToSuperview()
|
||||||
|
make.trailing.lessThanOrEqualTo(amountInputStack.snp.leading).offset(-8)
|
||||||
|
}
|
||||||
|
amountInputStack.snp.makeConstraints { make in
|
||||||
|
make.trailing.centerY.equalToSuperview()
|
||||||
|
make.width.lessThanOrEqualTo(205)
|
||||||
|
}
|
||||||
|
amountField.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(56)
|
||||||
|
make.width.greaterThanOrEqualTo(98)
|
||||||
|
}
|
||||||
|
|
||||||
|
let underline = UIView()
|
||||||
|
underline.backgroundColor = UIColor(hex: 0x1684FC)
|
||||||
|
underline.snp.makeConstraints { make in make.height.equalTo(1) }
|
||||||
|
|
||||||
|
let quickAmounts: [(String, Int)] = [("¥50", 5_000), ("¥100", 10_000), ("¥200", 20_000), ("¥500", 50_000)]
|
||||||
|
let quickStack = UIStackView()
|
||||||
|
quickStack.axis = .horizontal
|
||||||
|
quickStack.distribution = .fillEqually
|
||||||
|
quickStack.spacing = 12
|
||||||
|
quickAmounts.forEach { title, fen in
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.tag = fen
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.setTitleColor(UIColor(hex: 0x1677FF), for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
|
||||||
|
button.backgroundColor = UIColor(hex: 0xF5F8FD)
|
||||||
|
button.layer.cornerRadius = 8
|
||||||
|
button.layer.borderWidth = 1
|
||||||
|
button.layer.borderColor = UIColor(hex: 0xE3E8F0).cgColor
|
||||||
|
button.addTarget(self, action: #selector(quickAmountTapped(_:)), for: .touchUpInside)
|
||||||
|
button.snp.makeConstraints { make in make.height.equalTo(36) }
|
||||||
|
quickStack.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [titleLabel, amountRow, underline, quickStack])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.setCustomSpacing(8, after: titleLabel)
|
||||||
|
stack.setCustomSpacing(2, after: amountRow)
|
||||||
|
stack.setCustomSpacing(22, after: underline)
|
||||||
|
amountCard.addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(20) }
|
||||||
|
amountRow.snp.makeConstraints { make in make.height.equalTo(56) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureMethodCard() {
|
||||||
|
configureCard(methodCard)
|
||||||
|
let titleLabel = makeTitleLabel("收款方式")
|
||||||
|
methodStack.axis = .horizontal
|
||||||
|
methodStack.distribution = .fillEqually
|
||||||
|
methodStack.spacing = 12
|
||||||
|
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 stack = UIStackView(arrangedSubviews: [titleLabel, methodStack])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 20
|
||||||
|
methodCard.addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(20) }
|
||||||
|
methodStack.snp.makeConstraints { make in make.height.equalTo(120) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureExplanationCard() {
|
||||||
|
configureCard(explanationCard)
|
||||||
|
let iconView = UIImageView(image: UIImage(named: "offline_security_shield"))
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
iconView.accessibilityLabel = "安全说明"
|
||||||
|
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "登记后计入今日待补缴,不生成订单"
|
||||||
|
label.font = .systemFont(ofSize: 15)
|
||||||
|
label.textColor = UIColor(hex: 0x22304D)
|
||||||
|
label.numberOfLines = 0
|
||||||
|
|
||||||
|
explanationCard.addSubview(iconView)
|
||||||
|
explanationCard.addSubview(label)
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(20)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.width.height.equalTo(44)
|
||||||
|
}
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(iconView.snp.trailing).offset(14)
|
||||||
|
make.trailing.equalToSuperview().inset(18)
|
||||||
|
make.top.bottom.equalToSuperview().inset(20)
|
||||||
|
}
|
||||||
|
explanationCard.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(72) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureContextLabel() {
|
||||||
|
contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)"
|
||||||
|
contextLabel.font = .systemFont(ofSize: 13)
|
||||||
|
contextLabel.textColor = UIColor(hex: 0x7B8494)
|
||||||
|
contextLabel.numberOfLines = 0
|
||||||
|
contextLabel.textAlignment = .center
|
||||||
|
contextLabel.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureBottomBar() {
|
||||||
|
bottomContainer.backgroundColor = .white
|
||||||
|
bottomContainer.layer.shadowColor = UIColor(hex: 0x8090A8).cgColor
|
||||||
|
bottomContainer.layer.shadowOpacity = 0.12
|
||||||
|
bottomContainer.layer.shadowRadius = 12
|
||||||
|
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -3)
|
||||||
|
submitButton.setTitle("确认登记", for: .normal)
|
||||||
|
submitButton.setTitleColor(.white, for: .normal)
|
||||||
|
submitButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
submitButton.layer.cornerRadius = 10
|
||||||
|
submitButton.clipsToBounds = true
|
||||||
|
submitButton.accessibilityIdentifier = "offlineCollection.submit"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor private func applyState() {
|
||||||
|
setGlobalLoadingVisible(viewModel.isSubmitting)
|
||||||
|
if amountField.text != viewModel.amountText {
|
||||||
|
amountField.text = viewModel.amountText
|
||||||
|
moveAmountCursorToEnd()
|
||||||
|
}
|
||||||
|
methodButtons.forEach { method, button in button.setSelected(method == viewModel.paymentMethod) }
|
||||||
|
submitButton.isEnabled = viewModel.canSubmit && !viewModel.isSubmitting
|
||||||
|
submitButton.alpha = viewModel.canSubmit || viewModel.isSubmitting ? 1 : 0.45
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor private func showSuccess(_ receipt: OfflineCollectionRegistrationReceipt) {
|
||||||
|
setGlobalLoadingVisible(false)
|
||||||
|
amountField.resignFirstResponder()
|
||||||
|
let totalText = receipt.summary.map { OfflineCollectionMoney.display($0.totalAmountFen) } ?? "—"
|
||||||
|
let pendingText = receipt.summary.map { OfflineCollectionMoney.display($0.pendingAmountFen) } ?? "—"
|
||||||
|
let message = """
|
||||||
|
本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen))
|
||||||
|
今日累计线下收款 \(totalText)
|
||||||
|
今日待补缴 \(pendingText)
|
||||||
|
"""
|
||||||
|
let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in
|
||||||
|
self?.viewModel.startAnotherRegistration()
|
||||||
|
self?.amountField.becomeFirstResponder()
|
||||||
|
self?.moveAmountCursorToEnd()
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.navigationController?.pushViewController(
|
||||||
|
OfflineCollectionDailyViewController(
|
||||||
|
businessDate: receipt.businessDate,
|
||||||
|
context: self.viewModel.context,
|
||||||
|
api: self.viewModel.api
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setGlobalLoadingVisible(_ visible: Bool) {
|
||||||
|
guard visible != isShowingGlobalLoading else { return }
|
||||||
|
isShowingGlobalLoading = visible
|
||||||
|
visible ? showLoading() : hideLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTitleLabel(_ text: String) -> UILabel {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = text
|
||||||
|
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
label.textColor = UIColor(hex: 0x081739)
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureCard(_ card: UIView) {
|
||||||
|
card.backgroundColor = .white
|
||||||
|
card.layer.cornerRadius = 12
|
||||||
|
card.layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor
|
||||||
|
card.layer.shadowOpacity = 0.12
|
||||||
|
card.layer.shadowRadius = 10
|
||||||
|
card.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moveAmountCursorToEnd() {
|
||||||
|
guard amountField.isFirstResponder else { return }
|
||||||
|
let end = amountField.endOfDocument
|
||||||
|
amountField.selectedTextRange = amountField.textRange(from: end, to: end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func amountChanged() {
|
||||||
|
viewModel.updateAmount(amountField.text ?? "")
|
||||||
|
moveAmountCursorToEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func quickAmountTapped(_ sender: UIButton) {
|
||||||
|
viewModel.updateAmount(OfflineCollectionMoney.apiAmount(sender.tag))
|
||||||
|
amountField.becomeFirstResponder()
|
||||||
|
moveAmountCursorToEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 swiftRange = Range(range, in: current) else { return false }
|
||||||
|
return OfflineCollectionMoney.acceptsEditingText(current.replacingCharacters(in: swiftRange, with: string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记页的收款方式单选卡,展示官方品牌图标和右上角选中标记。
|
||||||
|
@MainActor
|
||||||
|
final class OfflinePaymentMethodButton: UIControl {
|
||||||
|
let method: OfflineCollectionPaymentMethod
|
||||||
|
|
||||||
|
private let iconView = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let selectionBadge = UIView()
|
||||||
|
private let checkmarkView = UIImageView(
|
||||||
|
image: UIImage(
|
||||||
|
systemName: "checkmark",
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 10, weight: .bold)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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: 0xF1F7FF) : .white
|
||||||
|
layer.borderColor = (selected ? UIColor(hex: 0x1684FC) : UIColor(hex: 0xE2E7EF)).cgColor
|
||||||
|
layer.borderWidth = selected ? 1.5 : 1
|
||||||
|
selectionBadge.isHidden = !selected
|
||||||
|
titleLabel.textColor = UIColor(hex: 0x081739)
|
||||||
|
accessibilityTraits = selected ? [.button, .selected] : .button
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
layer.cornerRadius = 10
|
||||||
|
clipsToBounds = false
|
||||||
|
accessibilityLabel = method.displayName
|
||||||
|
iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal)
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
titleLabel.text = method.displayName
|
||||||
|
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
selectionBadge.backgroundColor = UIColor(hex: 0x1684FC)
|
||||||
|
selectionBadge.layer.cornerRadius = 10
|
||||||
|
selectionBadge.layer.borderColor = UIColor.white.cgColor
|
||||||
|
selectionBadge.layer.borderWidth = 2
|
||||||
|
selectionBadge.isHidden = true
|
||||||
|
selectionBadge.isUserInteractionEnabled = false
|
||||||
|
checkmarkView.tintColor = .white
|
||||||
|
checkmarkView.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.alignment = .center
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.isUserInteractionEnabled = false
|
||||||
|
addSubview(stack)
|
||||||
|
addSubview(selectionBadge)
|
||||||
|
selectionBadge.addSubview(checkmarkView)
|
||||||
|
stack.snp.makeConstraints { make in make.center.equalToSuperview() }
|
||||||
|
iconView.snp.makeConstraints { make in make.width.height.equalTo(46) }
|
||||||
|
selectionBadge.snp.makeConstraints { make in
|
||||||
|
make.width.height.equalTo(20)
|
||||||
|
make.top.trailing.equalToSuperview().inset(6)
|
||||||
|
}
|
||||||
|
checkmarkView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.width.height.equalTo(11)
|
||||||
|
}
|
||||||
|
setSelected(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款页面共用的双端色渐变按钮。
|
||||||
|
@MainActor
|
||||||
|
final class OfflineCollectionGradientButton: UIButton {
|
||||||
|
private let gradientLayer = CAGradientLayer()
|
||||||
|
|
||||||
|
init(startColor: UIColor, endColor: UIColor) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
gradientLayer.colors = [startColor.cgColor, endColor.cgColor]
|
||||||
|
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
||||||
|
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
||||||
|
layer.insertSublayer(gradientLayer, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
gradientLayer.frame = bounds
|
||||||
|
gradientLayer.cornerRadius = layer.cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
override var isEnabled: Bool {
|
||||||
|
didSet { gradientLayer.opacity = isEnabled ? 1 : 0.45 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 日清页顶部可横滑的周/月日历组件。
|
||||||
|
@MainActor
|
||||||
|
final class OfflineCollectionCalendarView: UIView {
|
||||||
|
var onDateSelected: ((String) -> Void)?
|
||||||
|
var onModeChanged: ((_ animated: Bool) -> Void)?
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let toggleButton = UIButton(type: .system)
|
||||||
|
private let previousButton = UIButton(type: .system)
|
||||||
|
private let nextButton = UIButton(type: .system)
|
||||||
|
private let rowsStack = UIStackView()
|
||||||
|
private var rowStacks: [UIStackView] = []
|
||||||
|
private var dayButtons: [OfflineCollectionDayButton] = []
|
||||||
|
private var pendingDates: Set<String> = []
|
||||||
|
private var state = OfflineCollectionCalendarState(selectedDate: Date(), maximumDate: Date())
|
||||||
|
private let calendar = OfflineCollectionDate.calendar
|
||||||
|
private var isAnimatingModeChange = false
|
||||||
|
|
||||||
|
/// 当前周/月模式期望占用的固定高度,供外层表头同步执行高度动画。
|
||||||
|
var preferredHeight: CGFloat {
|
||||||
|
state.mode == .week ? 160 : 378
|
||||||
|
}
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
CGSize(width: UIView.noIntrinsicMetric, height: preferredHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
/// 使用选中日、服务端今日和待补缴日期刷新组件。
|
||||||
|
func apply(selectedDate: String, maximumDate: String, pendingDates: Set<String>) {
|
||||||
|
guard let selected = OfflineCollectionDate.date(from: selectedDate),
|
||||||
|
let maximum = OfflineCollectionDate.date(from: maximumDate) else { return }
|
||||||
|
self.pendingDates = pendingDates
|
||||||
|
state = OfflineCollectionCalendarState(
|
||||||
|
selectedDate: selected,
|
||||||
|
maximumDate: maximum,
|
||||||
|
mode: state.mode,
|
||||||
|
calendar: calendar
|
||||||
|
)
|
||||||
|
reloadDates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.cornerRadius = AppRadius.lg
|
||||||
|
layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor
|
||||||
|
layer.shadowOpacity = 0.12
|
||||||
|
layer.shadowRadius = 10
|
||||||
|
layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||||
|
|
||||||
|
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
|
||||||
|
titleLabel.textColor = UIColor(hex: 0x081739)
|
||||||
|
toggleButton.configuration = .plain()
|
||||||
|
toggleButton.configuration?.baseForegroundColor = UIColor(hex: 0x081739)
|
||||||
|
toggleButton.configuration?.imagePlacement = .trailing
|
||||||
|
toggleButton.configuration?.imagePadding = 8
|
||||||
|
toggleButton.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 12)
|
||||||
|
toggleButton.layer.cornerRadius = 9
|
||||||
|
toggleButton.layer.borderWidth = 1
|
||||||
|
toggleButton.layer.borderColor = UIColor(hex: 0xE2E7EF).cgColor
|
||||||
|
toggleButton.addTarget(self, action: #selector(toggleMode), for: .touchUpInside)
|
||||||
|
toggleButton.accessibilityIdentifier = "offlineCollection.calendar.toggle"
|
||||||
|
|
||||||
|
configureNavigationButton(previousButton, imageName: "chevron.left", action: #selector(previousPage))
|
||||||
|
configureNavigationButton(nextButton, imageName: "chevron.right", action: #selector(nextPage))
|
||||||
|
|
||||||
|
let spacer = UIView()
|
||||||
|
let header = UIStackView(arrangedSubviews: [titleLabel, spacer, previousButton, toggleButton, nextButton])
|
||||||
|
header.axis = .horizontal
|
||||||
|
header.alignment = .center
|
||||||
|
header.spacing = 8
|
||||||
|
|
||||||
|
let weekdayStack = UIStackView()
|
||||||
|
weekdayStack.axis = .horizontal
|
||||||
|
weekdayStack.distribution = .fillEqually
|
||||||
|
["一", "二", "三", "四", "五", "六", "日"].forEach { value in
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = value
|
||||||
|
label.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
|
label.textColor = UIColor(hex: 0x657084)
|
||||||
|
label.textAlignment = .center
|
||||||
|
weekdayStack.addArrangedSubview(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsStack.axis = .vertical
|
||||||
|
rowsStack.distribution = .fillEqually
|
||||||
|
rowsStack.spacing = 1
|
||||||
|
for _ in 0 ..< 6 {
|
||||||
|
let row = UIStackView()
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.distribution = .fillEqually
|
||||||
|
for _ in 0 ..< 7 {
|
||||||
|
let button = OfflineCollectionDayButton()
|
||||||
|
button.addTarget(self, action: #selector(dayTapped(_:)), for: .touchUpInside)
|
||||||
|
dayButtons.append(button)
|
||||||
|
row.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
rowStacks.append(row)
|
||||||
|
rowsStack.addArrangedSubview(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubview(header)
|
||||||
|
addSubview(weekdayStack)
|
||||||
|
addSubview(rowsStack)
|
||||||
|
header.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(14)
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.trailing.equalToSuperview().inset(10)
|
||||||
|
make.height.equalTo(44)
|
||||||
|
}
|
||||||
|
weekdayStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(header.snp.bottom).offset(8)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(10)
|
||||||
|
make.height.equalTo(24)
|
||||||
|
}
|
||||||
|
rowsStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(weekdayStack.snp.bottom).offset(4)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(10)
|
||||||
|
make.bottom.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
let left = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
|
||||||
|
left.direction = .left
|
||||||
|
let right = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
|
||||||
|
right.direction = .right
|
||||||
|
addGestureRecognizer(left)
|
||||||
|
addGestureRecognizer(right)
|
||||||
|
reloadDates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureNavigationButton(_ button: UIButton, imageName: String, action: Selector) {
|
||||||
|
button.setImage(UIImage(systemName: imageName), for: .normal)
|
||||||
|
button.tintColor = UIColor(hex: 0x081739)
|
||||||
|
button.addTarget(self, action: action, for: .touchUpInside)
|
||||||
|
button.snp.makeConstraints { make in make.width.height.equalTo(44) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadDates() {
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
titleLabel.text = state.monthTitle
|
||||||
|
titleLabel.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
let isMonth = state.mode == .month
|
||||||
|
toggleButton.configuration?.title = isMonth ? "周" : "月"
|
||||||
|
toggleButton.configuration?.image = UIImage(systemName: isMonth ? "chevron.up" : "chevron.down")
|
||||||
|
toggleButton.accessibilityLabel = isMonth ? "切换为周历" : "切换为月历"
|
||||||
|
previousButton.accessibilityLabel = isMonth ? "上一月" : "上一周"
|
||||||
|
nextButton.accessibilityLabel = isMonth ? "下一月" : "下一周"
|
||||||
|
nextButton.isEnabled = state.canMovePage(1) && !isAnimatingModeChange
|
||||||
|
nextButton.alpha = nextButton.isEnabled ? 1 : 0.35
|
||||||
|
previousButton.isEnabled = !isAnimatingModeChange
|
||||||
|
previousButton.alpha = previousButton.isEnabled ? 1 : 0.35
|
||||||
|
toggleButton.isEnabled = !isAnimatingModeChange
|
||||||
|
|
||||||
|
let dates = state.monthDates
|
||||||
|
let selectedMonth = calendar.component(.month, from: state.selectedDate)
|
||||||
|
for (index, button) in dayButtons.enumerated() {
|
||||||
|
guard index < dates.count else {
|
||||||
|
button.isHidden = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let date = dates[index]
|
||||||
|
let key = OfflineCollectionDate.businessDate(for: date, calendar: calendar)
|
||||||
|
button.isHidden = false
|
||||||
|
button.apply(
|
||||||
|
date: date,
|
||||||
|
key: key,
|
||||||
|
selected: calendar.isDate(date, inSameDayAs: state.selectedDate),
|
||||||
|
pending: pendingDates.contains(key),
|
||||||
|
today: calendar.isDate(date, inSameDayAs: state.maximumDate),
|
||||||
|
enabled: date <= state.maximumDate,
|
||||||
|
inCurrentMonth: !isMonth || calendar.component(.month, from: date) == selectedMonth,
|
||||||
|
calendar: calendar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !isAnimatingModeChange {
|
||||||
|
applyRowVisibility(expanded: isMonth, selectedRow: state.selectedWeekRowIndex)
|
||||||
|
}
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyRowVisibility(expanded: Bool, selectedRow: Int) {
|
||||||
|
for (index, row) in rowStacks.enumerated() {
|
||||||
|
let visible = expanded || index == selectedRow
|
||||||
|
row.isHidden = !visible
|
||||||
|
row.alpha = visible ? 1 : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func toggleMode() {
|
||||||
|
guard !isAnimatingModeChange else { return }
|
||||||
|
let selectedRow = state.selectedWeekRowIndex
|
||||||
|
state.toggleMode()
|
||||||
|
let expanded = state.mode == .month
|
||||||
|
let animated = !UIAccessibility.isReduceMotionEnabled
|
||||||
|
|
||||||
|
guard animated else {
|
||||||
|
reloadDates()
|
||||||
|
applyRowVisibility(expanded: expanded, selectedRow: selectedRow)
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
onModeChanged?(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isAnimatingModeChange = true
|
||||||
|
rowsStack.isUserInteractionEnabled = false
|
||||||
|
reloadDates()
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
onModeChanged?(true)
|
||||||
|
|
||||||
|
let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for (index, row) in rowStacks.enumerated() where index != selectedRow {
|
||||||
|
row.isHidden = !expanded
|
||||||
|
row.alpha = expanded ? 1 : 0
|
||||||
|
}
|
||||||
|
layoutIfNeeded()
|
||||||
|
superview?.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
animator.addCompletion { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
isAnimatingModeChange = false
|
||||||
|
rowsStack.isUserInteractionEnabled = true
|
||||||
|
applyRowVisibility(expanded: expanded, selectedRow: selectedRow)
|
||||||
|
reloadDates()
|
||||||
|
}
|
||||||
|
animator.startAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dayTapped(_ sender: OfflineCollectionDayButton) {
|
||||||
|
guard let date = sender.date, state.select(date) else { return }
|
||||||
|
reloadDates()
|
||||||
|
onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
|
||||||
|
let offset = gesture.direction == .left ? 1 : -1
|
||||||
|
movePage(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func previousPage() {
|
||||||
|
movePage(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func nextPage() {
|
||||||
|
movePage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func movePage(_ offset: Int) {
|
||||||
|
guard !isAnimatingModeChange, state.canMovePage(offset) else { return }
|
||||||
|
let oldDate = state.selectedDate
|
||||||
|
let date = state.movePage(offset)
|
||||||
|
guard !calendar.isDate(oldDate, inSameDayAs: date) else { return }
|
||||||
|
if !UIAccessibility.isReduceMotionEnabled {
|
||||||
|
let transition = CATransition()
|
||||||
|
transition.duration = 0.22
|
||||||
|
transition.type = .push
|
||||||
|
transition.subtype = offset > 0 ? .fromRight : .fromLeft
|
||||||
|
transition.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
|
rowsStack.layer.add(transition, forKey: "offlineCollection.calendar.page")
|
||||||
|
}
|
||||||
|
reloadDates()
|
||||||
|
onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日历中的单个日期按钮,同时表达选中、今日和待补缴状态。
|
||||||
|
private final class OfflineCollectionDayButton: UIControl {
|
||||||
|
private let stateBackgroundView = UIView()
|
||||||
|
private let dayLabel = UILabel()
|
||||||
|
private let pendingDot = UIView()
|
||||||
|
private(set) var date: Date?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
stateBackgroundView.isUserInteractionEnabled = false
|
||||||
|
stateBackgroundView.layer.cornerRadius = 11
|
||||||
|
dayLabel.font = .systemFont(ofSize: 17, weight: .medium)
|
||||||
|
dayLabel.textAlignment = .center
|
||||||
|
pendingDot.layer.cornerRadius = 3.5
|
||||||
|
addSubview(stateBackgroundView)
|
||||||
|
addSubview(dayLabel)
|
||||||
|
addSubview(pendingDot)
|
||||||
|
stateBackgroundView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.width.height.equalTo(36)
|
||||||
|
}
|
||||||
|
dayLabel.snp.makeConstraints { make in make.center.equalTo(stateBackgroundView) }
|
||||||
|
pendingDot.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalTo(stateBackgroundView)
|
||||||
|
make.width.height.equalTo(7)
|
||||||
|
}
|
||||||
|
snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func apply(
|
||||||
|
date: Date,
|
||||||
|
key: String,
|
||||||
|
selected: Bool,
|
||||||
|
pending: Bool,
|
||||||
|
today: Bool,
|
||||||
|
enabled: Bool,
|
||||||
|
inCurrentMonth: Bool,
|
||||||
|
calendar: Calendar
|
||||||
|
) {
|
||||||
|
self.date = date
|
||||||
|
isEnabled = enabled
|
||||||
|
dayLabel.text = String(calendar.component(.day, from: date))
|
||||||
|
let warningColor = UIColor(hex: 0xFF7600)
|
||||||
|
pendingDot.isHidden = !pending
|
||||||
|
pendingDot.backgroundColor = warningColor
|
||||||
|
pendingDot.layer.borderWidth = selected ? 1.5 : 0
|
||||||
|
pendingDot.layer.borderColor = UIColor.white.cgColor
|
||||||
|
backgroundColor = .clear
|
||||||
|
stateBackgroundView.backgroundColor = selected ? AppColor.primary : (pending ? UIColor(hex: 0xFFF0E2) : .clear)
|
||||||
|
stateBackgroundView.layer.cornerRadius = today && !selected && !pending ? 18 : 11
|
||||||
|
stateBackgroundView.layer.borderWidth = today && !selected ? 1 : 0
|
||||||
|
stateBackgroundView.layer.borderColor = AppColor.primary.cgColor
|
||||||
|
if selected {
|
||||||
|
dayLabel.textColor = .white
|
||||||
|
} else if !enabled || !inCurrentMonth {
|
||||||
|
dayLabel.textColor = AppColor.textTertiary
|
||||||
|
} else if pending {
|
||||||
|
dayLabel.textColor = warningColor
|
||||||
|
} else if today {
|
||||||
|
dayLabel.textColor = AppColor.primary
|
||||||
|
} else {
|
||||||
|
dayLabel.textColor = AppColor.textPrimary
|
||||||
|
}
|
||||||
|
alpha = enabled ? 1 : 0.35
|
||||||
|
accessibilityLabel = "\(key)\(today ? ",今天" : "")\(pending ? ",待补缴" : "")\(selected ? ",已选中" : "")"
|
||||||
|
accessibilityTraits = selected ? [.button, .selected] : .button
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 收款页中的线下收款入口、今日汇总和逾期提醒区域。
|
||||||
|
@MainActor
|
||||||
|
final class OfflineCollectionHomeView: UIView {
|
||||||
|
var onRegister: (() -> Void)?
|
||||||
|
var onOpenToday: (() -> Void)?
|
||||||
|
var onOpenOverdue: (() -> Void)?
|
||||||
|
var onRetry: (() -> Void)?
|
||||||
|
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private let statusButton = UIButton(type: .system)
|
||||||
|
private let overdueCard = OfflineCollectionHomeCard()
|
||||||
|
private let registerCard = OfflineCollectionHomeCard()
|
||||||
|
private let todayCard = OfflineCollectionHomeCard()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = AppSpacing.md
|
||||||
|
let title = UILabel()
|
||||||
|
title.text = "线下收款"
|
||||||
|
title.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
title.textColor = AppColor.textPrimary
|
||||||
|
stack.addArrangedSubview(title)
|
||||||
|
stack.addArrangedSubview(statusButton)
|
||||||
|
stack.addArrangedSubview(overdueCard)
|
||||||
|
stack.addArrangedSubview(registerCard)
|
||||||
|
stack.addArrangedSubview(todayCard)
|
||||||
|
addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||||
|
title.snp.makeConstraints { make in make.height.equalTo(24) }
|
||||||
|
|
||||||
|
statusButton.configuration = .plain()
|
||||||
|
statusButton.configuration?.baseForegroundColor = AppColor.primary
|
||||||
|
statusButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||||
|
statusButton.isHidden = true
|
||||||
|
|
||||||
|
registerCard.apply(
|
||||||
|
title: "线下收款登记",
|
||||||
|
detail: "线下收款后,请及时登记并在当日完成补缴",
|
||||||
|
action: "去登记",
|
||||||
|
image: "plus.circle.fill",
|
||||||
|
tint: AppColor.primary,
|
||||||
|
background: .white
|
||||||
|
)
|
||||||
|
registerCard.addTarget(self, action: #selector(registerTapped), for: .touchUpInside)
|
||||||
|
todayCard.addTarget(self, action: #selector(todayTapped), for: .touchUpInside)
|
||||||
|
overdueCard.addTarget(self, action: #selector(overdueTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
/// 根据真实统计接口状态刷新区域。
|
||||||
|
func apply(
|
||||||
|
today: OfflineDailySummary,
|
||||||
|
overdueDayCount: Int,
|
||||||
|
overdueRecordCount: Int,
|
||||||
|
overdueAmountFen: Int,
|
||||||
|
errorMessage: String?
|
||||||
|
) {
|
||||||
|
if let errorMessage {
|
||||||
|
statusButton.configuration?.title = "\(errorMessage) 点击重试"
|
||||||
|
statusButton.configuration?.showsActivityIndicator = false
|
||||||
|
statusButton.isUserInteractionEnabled = true
|
||||||
|
statusButton.isHidden = false
|
||||||
|
statusButton.accessibilityTraits.insert(.button)
|
||||||
|
} else {
|
||||||
|
statusButton.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasOverdue = overdueDayCount > 0
|
||||||
|
overdueCard.isHidden = !hasOverdue
|
||||||
|
if hasOverdue {
|
||||||
|
overdueCard.apply(
|
||||||
|
title: "存在逾期未补缴",
|
||||||
|
detail: "\(overdueDayCount) 个营业日,共 \(overdueRecordCount) 笔,待补缴 \(OfflineCollectionMoney.display(overdueAmountFen))",
|
||||||
|
action: "立即处理",
|
||||||
|
image: "exclamationmark.circle.fill",
|
||||||
|
tint: AppColor.danger,
|
||||||
|
background: UIColor(hex: 0xFFF1F0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let settled = today.totalCount > 0 && today.pendingCount == 0
|
||||||
|
todayCard.apply(
|
||||||
|
title: "今日待补缴 \(OfflineCollectionMoney.display(today.pendingAmountFen))",
|
||||||
|
detail: "\(today.pendingCount) 笔 · 今日已登记 \(OfflineCollectionMoney.display(today.totalAmountFen))\(settled ? " · 已结清" : "")",
|
||||||
|
action: today.pendingCount > 0 ? "去补缴" : "查看明细",
|
||||||
|
image: "calendar",
|
||||||
|
tint: today.pendingCount > 0 ? UIColor(hex: 0xD97706) : AppColor.primary,
|
||||||
|
background: .white
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func retryTapped() { onRetry?() }
|
||||||
|
@objc private func registerTapped() { onRegister?() }
|
||||||
|
@objc private func todayTapped() { onOpenToday?() }
|
||||||
|
@objc private func overdueTapped() { onOpenOverdue?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款首页区域的统一可点击卡片。
|
||||||
|
private final class OfflineCollectionHomeCard: UIControl {
|
||||||
|
private let iconView = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let detailLabel = UILabel()
|
||||||
|
private let actionLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
layer.cornerRadius = AppRadius.lg
|
||||||
|
clipsToBounds = true
|
||||||
|
isAccessibilityElement = true
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
detailLabel.font = .systemFont(ofSize: 13)
|
||||||
|
detailLabel.textColor = AppColor.textSecondary
|
||||||
|
detailLabel.numberOfLines = 0
|
||||||
|
actionLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||||
|
actionLabel.textColor = AppColor.primary
|
||||||
|
actionLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
let textStack = UIStackView(arrangedSubviews: [titleLabel, detailLabel])
|
||||||
|
textStack.axis = .vertical
|
||||||
|
textStack.spacing = 5
|
||||||
|
[iconView, titleLabel, detailLabel, actionLabel, textStack].forEach {
|
||||||
|
$0.isUserInteractionEnabled = false
|
||||||
|
}
|
||||||
|
addSubview(iconView)
|
||||||
|
addSubview(textStack)
|
||||||
|
addSubview(actionLabel)
|
||||||
|
iconView.snp.makeConstraints { make in make.leading.equalToSuperview().inset(AppSpacing.md); make.centerY.equalToSuperview(); make.width.height.equalTo(30) }
|
||||||
|
textStack.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm); make.top.bottom.equalToSuperview().inset(AppSpacing.md); make.trailing.lessThanOrEqualTo(actionLabel.snp.leading).offset(-AppSpacing.sm) }
|
||||||
|
actionLabel.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(AppSpacing.md); make.centerY.equalToSuperview() }
|
||||||
|
snp.makeConstraints { make in make.height.greaterThanOrEqualTo(86) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||||
|
guard isEnabled,
|
||||||
|
isUserInteractionEnabled,
|
||||||
|
!isHidden,
|
||||||
|
alpha > 0.01,
|
||||||
|
self.point(inside: point, with: event) else { return nil }
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
override var isHighlighted: Bool {
|
||||||
|
didSet {
|
||||||
|
UIView.animate(withDuration: 0.12) {
|
||||||
|
self.alpha = self.isHighlighted ? 0.78 : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(title: String, detail: String, action: String, image: String, tint: UIColor, background: UIColor) {
|
||||||
|
titleLabel.text = title
|
||||||
|
detailLabel.text = detail
|
||||||
|
actionLabel.text = "\(action) ›"
|
||||||
|
iconView.image = UIImage(systemName: image)
|
||||||
|
iconView.tintColor = tint
|
||||||
|
backgroundColor = background
|
||||||
|
accessibilityLabel = "\(title),\(detail),\(action)"
|
||||||
|
accessibilityTraits = .button
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@ 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 offlineCollectionAPI = NetworkServices.shared.offlineCollectionAPI
|
||||||
|
private lazy var offlineCollectionViewModel = OfflineCollectionHomeViewModel(
|
||||||
|
context: .current(),
|
||||||
|
api: offlineCollectionAPI
|
||||||
|
)
|
||||||
|
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentContainerView = UIView()
|
private let contentContainerView = UIView()
|
||||||
@@ -48,10 +53,12 @@ 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?
|
||||||
private var appliedBrandingRefreshVersion = -1
|
private var appliedBrandingRefreshVersion = -1
|
||||||
|
private var isShowingOfflineCollectionLoading = false
|
||||||
|
|
||||||
private var previousStandardAppearance: UINavigationBarAppearance?
|
private var previousStandardAppearance: UINavigationBarAppearance?
|
||||||
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
|
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
|
||||||
@@ -101,8 +108,8 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
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 = !usesBranding
|
||||||
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 +208,7 @@ 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 +338,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)
|
||||||
}
|
}
|
||||||
@@ -358,6 +365,22 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
||||||
recordRow.addTarget(self, action: #selector(recordTapped), for: .touchUpInside)
|
recordRow.addTarget(self, action: #selector(recordTapped), for: .touchUpInside)
|
||||||
voiceSwitch.addTarget(self, action: #selector(voiceSwitchChanged), for: .valueChanged)
|
voiceSwitch.addTarget(self, action: #selector(voiceSwitchChanged), for: .valueChanged)
|
||||||
|
|
||||||
|
offlineCollectionViewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyOfflineCollection() }
|
||||||
|
}
|
||||||
|
offlineCollectionView.onRetry = { [weak self] in
|
||||||
|
Task { await self?.offlineCollectionViewModel.load() }
|
||||||
|
}
|
||||||
|
offlineCollectionView.onRegister = { [weak self] in self?.openOfflineRegistration() }
|
||||||
|
offlineCollectionView.onOpenToday = { [weak self] in
|
||||||
|
guard let self, let date = self.offlineCollectionViewModel.todayBusinessDate else { return }
|
||||||
|
self.openOfflineDaily(date: date)
|
||||||
|
}
|
||||||
|
offlineCollectionView.onOpenOverdue = { [weak self] in
|
||||||
|
guard let self, let date = self.offlineCollectionViewModel.earliestOverdueBusinessDate else { return }
|
||||||
|
self.openOfflineDaily(date: date)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
@@ -369,10 +392,13 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
applyBrandNavigationAppearanceIfNeeded()
|
applyBrandNavigationAppearanceIfNeeded()
|
||||||
|
applyOfflineCollection()
|
||||||
|
Task { await offlineCollectionViewModel.load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
setOfflineCollectionLoadingVisible(false)
|
||||||
restoreNavigationAppearanceIfNeeded()
|
restoreNavigationAppearanceIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +458,26 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
amountDialog?.dismiss()
|
amountDialog?.dismiss()
|
||||||
amountDialog = nil
|
amountDialog = nil
|
||||||
}
|
}
|
||||||
|
applyOfflineCollection()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyOfflineCollection() {
|
||||||
|
setOfflineCollectionLoadingVisible(offlineCollectionViewModel.isLoading)
|
||||||
|
offlineCollectionView.apply(
|
||||||
|
today: offlineCollectionViewModel.todaySummary,
|
||||||
|
overdueDayCount: offlineCollectionViewModel.overdueDates.count,
|
||||||
|
overdueRecordCount: offlineCollectionViewModel.overdueRecordCount,
|
||||||
|
overdueAmountFen: offlineCollectionViewModel.overdueAmountFen,
|
||||||
|
errorMessage: offlineCollectionViewModel.errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func setOfflineCollectionLoadingVisible(_ visible: Bool) {
|
||||||
|
guard visible != isShowingOfflineCollectionLoading else { return }
|
||||||
|
isShowingOfflineCollectionLoading = visible
|
||||||
|
visible ? showLoading() : hideLoading()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setActionRowAvailable(_ isAvailable: Bool) {
|
private func setActionRowAvailable(_ isAvailable: Bool) {
|
||||||
@@ -668,6 +714,23 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
@objc private func voiceSwitchChanged() {
|
@objc private func voiceSwitchChanged() {
|
||||||
viewModel.toggleReceiveVoice()
|
viewModel.toggleReceiveVoice()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func openOfflineRegistration() {
|
||||||
|
let controller = OfflineCollectionRegistrationViewController(
|
||||||
|
context: offlineCollectionViewModel.context,
|
||||||
|
api: offlineCollectionAPI
|
||||||
|
)
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openOfflineDaily(date: String) {
|
||||||
|
let controller = OfflineCollectionDailyViewController(
|
||||||
|
businessDate: date,
|
||||||
|
context: offlineCollectionViewModel.context,
|
||||||
|
api: offlineCollectionAPI
|
||||||
|
)
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 收款详情信息行。
|
/// 收款详情信息行。
|
||||||
|
|||||||
@@ -96,14 +96,8 @@ final class AccountSwitchViewController: BaseViewController, UITableViewDelegate
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var currentAccountId: String? {
|
private var currentAccountId: String? {
|
||||||
let store = AppStore.shared
|
let scope = AppStore.shared.session.accountCachePrefix
|
||||||
if store.session.currentStoreId > 0 {
|
return scope.isEmpty ? nil : scope
|
||||||
return "\(V9StoreUser.accountTypeValue)_\(store.session.currentStoreId)"
|
|
||||||
}
|
|
||||||
if store.session.currentScenicId > 0 {
|
|
||||||
return "\(V9ScenicUser.accountTypeValue)_\(store.session.userId)"
|
|
||||||
}
|
|
||||||
return store.session.userId.isEmpty ? nil : store.session.userId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
||||||
@@ -126,6 +120,16 @@ final class AccountSwitchViewController: BaseViewController, UITableViewDelegate
|
|||||||
navigationController?.popViewController(animated: true)
|
navigationController?.popViewController(animated: true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if account.requiresDeregistrationConfirmation {
|
||||||
|
present(
|
||||||
|
makeDeregistrationLoginAlert(account: account) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.switchAccount(account) }
|
||||||
|
},
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
Task { await switchAccount(account) }
|
Task { await switchAccount(account) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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 deregistrationRow = 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()
|
||||||
|
|
||||||
@@ -68,6 +69,10 @@ 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)
|
||||||
|
if AppStore.shared.session.accountType == .storeUser {
|
||||||
|
rowsStack.addArrangedSubview(deregistrationRow)
|
||||||
|
deregistrationRow.addTarget(self, action: #selector(deregistrationTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
@@ -119,6 +124,29 @@ final class SettingViewController: BaseViewController {
|
|||||||
openAgreement(.privacyPolicy)
|
openAgreement(.privacyPolicy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 仅从当前门店业务会话创建注销流程,冻结 Token 与门店用户 ID。
|
||||||
|
@objc private func deregistrationTapped() {
|
||||||
|
do {
|
||||||
|
let session = AppStore.shared.session
|
||||||
|
let identity = try StoreAccountDeregistrationIdentity(session: session)
|
||||||
|
let api = StoreAccountDeregistrationAPI(client: NetworkServices.shared.apiClient, identity: identity) {
|
||||||
|
identity.matches(session: session)
|
||||||
|
}
|
||||||
|
let controller = StoreAccountDeregistrationViewController(
|
||||||
|
identityName: identity.displayName,
|
||||||
|
viewModel: StoreAccountDeregistrationViewModel(
|
||||||
|
storeUserID: Int(identity.userID) ?? 0,
|
||||||
|
submissionStore: StoreAccountDeregistrationSubmissionStore(storeUserID: identity.userID)
|
||||||
|
), api: api, onUnresolvedSubmission: {
|
||||||
|
NotificationCenter.default.post(name: NotificationName.storeAccountDeregistrationRestricted,
|
||||||
|
object: nil, userInfo: [NotificationUserInfoKey.deregistrationRequestToken: identity.token])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
controller.hidesBottomBarWhenPushed = true
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
} catch { showError(error.localizedDescription) }
|
||||||
|
}
|
||||||
|
|
||||||
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(
|
||||||
@@ -138,8 +166,10 @@ final class SettingMenuRow: UIControl {
|
|||||||
private let divider = UIView()
|
private let divider = UIView()
|
||||||
private let showsChevron: Bool
|
private let showsChevron: Bool
|
||||||
|
|
||||||
|
/// 创建菜单行,可为注销等操作单独指定标题颜色,不影响其他行。
|
||||||
init(
|
init(
|
||||||
title: String,
|
title: String,
|
||||||
|
titleColor: UIColor = UIColor(hex: 0x4B5563),
|
||||||
value: String? = nil,
|
value: String? = nil,
|
||||||
valueColor: UIColor = AppColor.textPrimary,
|
valueColor: UIColor = AppColor.textPrimary,
|
||||||
showsChevron: Bool = true,
|
showsChevron: Bool = true,
|
||||||
@@ -150,6 +180,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 +206,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
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 业务根页面之前核验注销状态;冷静期提供明确撤销,未知状态只允许查询和主动退出。
|
||||||
|
@MainActor
|
||||||
|
final class StoreAccountDeregistrationAccessViewController: BaseViewController {
|
||||||
|
/// 冻结的门店身份,用于 Scene 对旧请求的隔离。
|
||||||
|
let identity: StoreAccountDeregistrationIdentity?
|
||||||
|
private let api: (any StoreAccountDeregistrationServing)?
|
||||||
|
private let session: AppSessionStore
|
||||||
|
private let onAllowed: (StoreAccountDeregistrationAccessViewController) -> Void
|
||||||
|
private let onAccessDenied: () -> Void
|
||||||
|
private let viewModel: StoreAccountDeregistrationAccessViewModel
|
||||||
|
private let hasInitialResult: Bool
|
||||||
|
private typealias Style = StoreAccountDeregistrationStyle
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let refreshControl = UIRefreshControl()
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
private let titleLabel = Style.label(size: 24, weight: .semibold)
|
||||||
|
private let stateIcon = Style.icon("lock", size: 36)
|
||||||
|
private let stateIconBadge = UIView()
|
||||||
|
private let deadlineLabel = Style.label(size: 19, weight: .semibold)
|
||||||
|
private let infoLabel = Style.label("重新登录该身份自动撤销申请", size: 13, color: Style.textSecondary)
|
||||||
|
private var deadlineCard = UIView()
|
||||||
|
private var infoCard = UIView()
|
||||||
|
private let messageLabel = UILabel()
|
||||||
|
private let retryButton = UIButton(type: .system)
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
private let logoutButton = UIButton(type: .system)
|
||||||
|
private var queryTask: Task<Void, Never>?
|
||||||
|
private var needsForegroundRefresh = false
|
||||||
|
private var previousPopGestureEnabled: Bool?
|
||||||
|
|
||||||
|
/// 显式注入会话与 API;身份构造失败时仍展示错误,不自动退出或重新登录。
|
||||||
|
init(identity: StoreAccountDeregistrationIdentity?, api: (any StoreAccountDeregistrationServing)?,
|
||||||
|
session: AppSessionStore, requiresSubmissionReconciliation: Bool = false,
|
||||||
|
submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil,
|
||||||
|
initialViewModel: StoreAccountDeregistrationAccessViewModel? = nil,
|
||||||
|
onAccessDenied: @escaping () -> Void = {},
|
||||||
|
onAllowed: @escaping (StoreAccountDeregistrationAccessViewController) -> Void) {
|
||||||
|
self.identity = identity
|
||||||
|
self.api = api
|
||||||
|
self.session = session
|
||||||
|
self.onAllowed = onAllowed
|
||||||
|
self.onAccessDenied = onAccessDenied
|
||||||
|
hasInitialResult = initialViewModel != nil
|
||||||
|
viewModel = initialViewModel ?? StoreAccountDeregistrationAccessViewModel(
|
||||||
|
requiresSubmissionReconciliation: requiresSubmissionReconciliation, submissionStore: submissionStore)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "注销账号"
|
||||||
|
navigationItem.hidesBackButton = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = Style.pageBackground
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
refreshControl.accessibilityIdentifier = "deregister.access.refresh"
|
||||||
|
refreshControl.accessibilityLabel = "下拉刷新"
|
||||||
|
refreshControl.tintColor = .clear
|
||||||
|
refreshControl.addTarget(self, action: #selector(retryTapped), for: .valueChanged)
|
||||||
|
scrollView.refreshControl = refreshControl
|
||||||
|
scrollView.addSubview(stack)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 18
|
||||||
|
stateIconBadge.backgroundColor = Style.infoBackground
|
||||||
|
stateIconBadge.layer.cornerRadius = 44
|
||||||
|
stateIconBadge.addSubview(stateIcon)
|
||||||
|
stateIconBadge.snp.makeConstraints { $0.width.height.equalTo(88) }
|
||||||
|
stateIcon.snp.makeConstraints { $0.center.equalToSuperview(); $0.width.height.equalTo(44) }
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
messageLabel.numberOfLines = 0
|
||||||
|
messageLabel.font = .systemFont(ofSize: 15)
|
||||||
|
messageLabel.textColor = Style.textSecondary
|
||||||
|
messageLabel.textAlignment = .center
|
||||||
|
let iconRow = UIView()
|
||||||
|
iconRow.addSubview(stateIconBadge)
|
||||||
|
stateIconBadge.snp.makeConstraints { $0.top.bottom.centerX.equalToSuperview() }
|
||||||
|
let hero = Style.stack([iconRow, titleLabel, messageLabel], spacing: 12)
|
||||||
|
stack.addArrangedSubview(hero)
|
||||||
|
deadlineLabel.accessibilityIdentifier = "deregister.access.deadline"
|
||||||
|
let deadlineIcon = Style.icon("calendar", size: 22)
|
||||||
|
deadlineIcon.snp.makeConstraints { $0.width.equalTo(28) }
|
||||||
|
let deadlineText = Style.stack([
|
||||||
|
Style.label("冷静期截止时间", size: 13, color: Style.textSecondary), deadlineLabel
|
||||||
|
], spacing: 8)
|
||||||
|
let deadlineRow = UIStackView(arrangedSubviews: [deadlineIcon, deadlineText])
|
||||||
|
deadlineRow.axis = .horizontal
|
||||||
|
deadlineRow.alignment = .center
|
||||||
|
deadlineRow.spacing = 12
|
||||||
|
deadlineCard = Style.card(deadlineRow)
|
||||||
|
stack.addArrangedSubview(deadlineCard)
|
||||||
|
let infoIcon = Style.icon("info.circle", size: 17)
|
||||||
|
infoIcon.snp.makeConstraints { $0.width.equalTo(22) }
|
||||||
|
let infoRow = UIStackView(arrangedSubviews: [infoIcon, infoLabel])
|
||||||
|
infoRow.axis = .horizontal
|
||||||
|
infoRow.alignment = .center
|
||||||
|
infoRow.spacing = 8
|
||||||
|
infoCard = Style.card(infoRow, background: Style.infoBackground)
|
||||||
|
stack.addArrangedSubview(infoCard)
|
||||||
|
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
Style.configure(retryButton, title: "重新查询状态")
|
||||||
|
Style.configure(logoutButton, title: "退出登录")
|
||||||
|
Style.configure(cancelButton, title: "撤销注销申请", primary: false)
|
||||||
|
let actions = Style.stack([retryButton, logoutButton, cancelButton], spacing: 12)
|
||||||
|
bottomBar.addSubview(actions)
|
||||||
|
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
|
||||||
|
for button in [retryButton, logoutButton, cancelButton] {
|
||||||
|
button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(50) }
|
||||||
|
}
|
||||||
|
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
logoutButton.addTarget(self, action: #selector(logoutTapped), for: .touchUpInside)
|
||||||
|
messageLabel.accessibilityIdentifier = "deregister.access.message"
|
||||||
|
titleLabel.accessibilityIdentifier = "deregister.access.title"
|
||||||
|
retryButton.accessibilityIdentifier = "deregister.access.retry"
|
||||||
|
cancelButton.accessibilityIdentifier = "deregister.access.cancel"
|
||||||
|
logoutButton.accessibilityIdentifier = "deregister.access.logout"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
bottomBar.snp.makeConstraints {
|
||||||
|
$0.leading.trailing.equalToSuperview()
|
||||||
|
$0.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
|
scrollView.snp.makeConstraints {
|
||||||
|
$0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
$0.bottom.equalTo(bottomBar.snp.top)
|
||||||
|
}
|
||||||
|
stack.snp.makeConstraints {
|
||||||
|
$0.top.equalTo(scrollView.contentLayoutGuide).offset(24)
|
||||||
|
$0.leading.trailing.bottom.equalTo(scrollView.contentLayoutGuide).inset(16)
|
||||||
|
$0.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
if hasInitialResult { applyViewModel() } else { retryTapped() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
previousPopGestureEnabled = navigationController?.interactivePopGestureRecognizer?.isEnabled
|
||||||
|
navigationItem.hidesBackButton = true
|
||||||
|
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
if let previousPopGestureEnabled {
|
||||||
|
navigationController?.interactivePopGestureRecognizer?.isEnabled = previousPopGestureEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在途旧查询不能覆盖刚收到的受限信号;之后由用户主动重新查询。
|
||||||
|
func recordRestriction() {
|
||||||
|
viewModel.recordRestriction()
|
||||||
|
onAccessDenied()
|
||||||
|
if isViewLoaded { applyViewModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从后台回来只读重查;废弃后台前的在途结果,不重发申请或撤销操作。
|
||||||
|
func refreshAfterBackground() {
|
||||||
|
viewModel.recordRestriction()
|
||||||
|
navigationController?.popToRootViewController(animated: false)
|
||||||
|
if presentedViewController is UIAlertController { dismiss(animated: false) }
|
||||||
|
if queryTask != nil {
|
||||||
|
needsForegroundRefresh = true
|
||||||
|
applyViewModel()
|
||||||
|
} else {
|
||||||
|
retryTapped()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyViewModel() {
|
||||||
|
let busy = queryTask != nil
|
||||||
|
let cooling = viewModel.decision == .cooling
|
||||||
|
retryButton.isEnabled = !busy && api != nil
|
||||||
|
retryButton.isHidden = cooling
|
||||||
|
refreshControl.isEnabled = !busy && api != nil
|
||||||
|
cancelButton.isHidden = !cooling
|
||||||
|
cancelButton.isEnabled = !busy && api != nil
|
||||||
|
logoutButton.isEnabled = !busy
|
||||||
|
Style.configure(logoutButton, title: "退出登录", primary: cooling)
|
||||||
|
deadlineCard.isHidden = !cooling
|
||||||
|
infoCard.isHidden = !cooling
|
||||||
|
deadlineLabel.text = viewModel.status?.coolingUntil ?? "—"
|
||||||
|
guard identity != nil, api != nil else {
|
||||||
|
titleLabel.text = "暂时无法查询"
|
||||||
|
messageLabel.text = "当前身份信息不完整,请联系管理员。"
|
||||||
|
stateIcon.image = UIImage(systemName: "exclamationmark.circle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var symbol = "clock"
|
||||||
|
switch viewModel.decision {
|
||||||
|
case .notChecked, .checking:
|
||||||
|
titleLabel.text = nil
|
||||||
|
messageLabel.text = nil
|
||||||
|
case .allowed:
|
||||||
|
titleLabel.text = "当前身份可正常使用"
|
||||||
|
messageLabel.text = "正在返回首页"
|
||||||
|
symbol = "checkmark.circle"
|
||||||
|
case .cooling:
|
||||||
|
titleLabel.text = "注销申请已提交"
|
||||||
|
messageLabel.text = "当前身份:\(identity?.displayName ?? "当前身份")"
|
||||||
|
symbol = "lock"
|
||||||
|
case .unresolved:
|
||||||
|
titleLabel.text = "注销状态待确认"
|
||||||
|
messageLabel.text = "暂时未获取到明确结果,请刷新重试。\n请勿重复提交;如持续出现,请联系管理员。"
|
||||||
|
symbol = "questionmark.circle"
|
||||||
|
case .failed:
|
||||||
|
titleLabel.text = "暂时无法查询"
|
||||||
|
messageLabel.text = "请检查网络后重试。\n查询失败不会撤销你的注销申请。"
|
||||||
|
symbol = "wifi.exclamationmark"
|
||||||
|
case .obsolete:
|
||||||
|
titleLabel.text = "当前身份已变化"
|
||||||
|
messageLabel.text = "请返回当前账号后重试。"
|
||||||
|
symbol = "person.crop.circle.badge.exclamationmark"
|
||||||
|
}
|
||||||
|
stateIcon.image = UIImage(systemName: symbol,
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 36, weight: .medium))
|
||||||
|
navigationItem.hidesBackButton = true
|
||||||
|
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() {
|
||||||
|
guard queryTask == nil, let identity, let api, identity.matches(session: session),
|
||||||
|
viewModel.decision == .cooling else { return }
|
||||||
|
let alert = UIAlertController(title: "撤销当前身份的注销申请?",
|
||||||
|
message: "将撤销“\(identity.displayName)”的申请,服务端确认后恢复使用。其他身份不受影响。",
|
||||||
|
preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "保留注销申请", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "确认撤销", style: .destructive) { [weak self] _ in
|
||||||
|
guard let self, self.queryTask == nil else { return }
|
||||||
|
self.queryTask = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.showLoading()
|
||||||
|
await self.viewModel.cancel(api: api) { [session = self.session] in identity.matches(session: session) }
|
||||||
|
self.hideLoading()
|
||||||
|
self.finishQuery()
|
||||||
|
}
|
||||||
|
self.applyViewModel()
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func retryTapped() {
|
||||||
|
guard queryTask == nil, let identity, let api else {
|
||||||
|
refreshControl.endRefreshing()
|
||||||
|
applyViewModel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queryTask = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.showLoading()
|
||||||
|
await self.viewModel.verify(api: api) { [session = self.session] in identity.matches(session: session) }
|
||||||
|
self.hideLoading()
|
||||||
|
self.finishQuery()
|
||||||
|
}
|
||||||
|
applyViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishQuery() {
|
||||||
|
queryTask = nil
|
||||||
|
refreshControl.endRefreshing()
|
||||||
|
if needsForegroundRefresh {
|
||||||
|
needsForegroundRefresh = false
|
||||||
|
retryTapped()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyViewModel()
|
||||||
|
if viewModel.decision == .allowed, identity?.matches(session: session) == true {
|
||||||
|
onAllowed(self)
|
||||||
|
} else {
|
||||||
|
onAccessDenied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func logoutTapped() {
|
||||||
|
let alert = UIAlertController(title: "退出登录?", message:
|
||||||
|
"退出本身不会撤销申请,但会清除本机登录凭证。再次登录或选中此身份会自动撤销尚未完成的申请。", preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "继续保留查询", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { _ in
|
||||||
|
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 登录后或收到业务限制时核验门店注销状态;普通启动和前台恢复不主动查询。
|
||||||
|
@MainActor
|
||||||
|
final class StoreAccountDeregistrationRootCoordinator {
|
||||||
|
private weak var window: UIWindow?
|
||||||
|
private let session: AppSessionStore
|
||||||
|
private let makeAPI: (StoreAccountDeregistrationIdentity) -> any StoreAccountDeregistrationServing
|
||||||
|
private let makeSubmissionStore: (String) -> any StoreAccountDeregistrationSubmissionTracking
|
||||||
|
private let makeBusinessRoot: () -> UIViewController
|
||||||
|
private let setBindingSuspended: (Bool) -> Void
|
||||||
|
private let onBusinessResumed: () -> Void
|
||||||
|
private var pendingCheck: PendingCheck?
|
||||||
|
|
||||||
|
/// 登录核验期间保留原页面与身份;只允许该次请求结束其持有的全局加载。
|
||||||
|
private final class PendingCheck {
|
||||||
|
let identity: StoreAccountDeregistrationIdentity
|
||||||
|
let root: UIViewController?
|
||||||
|
let viewModel: StoreAccountDeregistrationAccessViewModel
|
||||||
|
var task: Task<Void, Never>?
|
||||||
|
var needsForegroundRefresh = false
|
||||||
|
|
||||||
|
init(identity: StoreAccountDeregistrationIdentity, root: UIViewController?,
|
||||||
|
viewModel: StoreAccountDeregistrationAccessViewModel) {
|
||||||
|
self.identity = identity
|
||||||
|
self.root = root
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注入窗口、会话、服务和业务恢复动作;测试无需访问共享会话或真实网络。
|
||||||
|
init(window: UIWindow, session: AppSessionStore,
|
||||||
|
makeAPI: @escaping (StoreAccountDeregistrationIdentity) -> any StoreAccountDeregistrationServing,
|
||||||
|
makeSubmissionStore: @escaping (String) -> any StoreAccountDeregistrationSubmissionTracking,
|
||||||
|
makeBusinessRoot: @escaping () -> UIViewController,
|
||||||
|
setBindingSuspended: @escaping (Bool) -> Void,
|
||||||
|
onBusinessResumed: @escaping () -> Void) {
|
||||||
|
self.window = window
|
||||||
|
self.session = session
|
||||||
|
self.makeAPI = makeAPI
|
||||||
|
self.makeSubmissionStore = makeSubmissionStore
|
||||||
|
self.makeBusinessRoot = makeBusinessRoot
|
||||||
|
self.setBindingSuspended = setBindingSuspended
|
||||||
|
self.onBusinessResumed = onBusinessResumed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只认当前窗口实际显示的核验根,不用旧请求保存的控制器判断。
|
||||||
|
var accessController: StoreAccountDeregistrationAccessViewController? {
|
||||||
|
(window?.rootViewController as? UINavigationController)?.viewControllers.first
|
||||||
|
as? StoreAccountDeregistrationAccessViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 正在原登录页面上核验时,也应暂停推送账号绑定。
|
||||||
|
var isChecking: Bool { pendingCheck != nil }
|
||||||
|
|
||||||
|
/// 使用已有登录会话直接恢复首页,不创建注销服务或发起状态查询。
|
||||||
|
func restoreSession() {
|
||||||
|
guard let window, session.isLoggedIn else { return }
|
||||||
|
cancelPendingCheck()
|
||||||
|
AppRouter.setRoot(makeBusinessRoot(), on: window, animated: false)
|
||||||
|
setBindingSuspended(false)
|
||||||
|
onBusinessResumed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保留登录页背景并显示全局加载;核验通过进入首页,有异常结果才展示状态页。
|
||||||
|
func check() {
|
||||||
|
guard let window, session.isLoggedIn, session.accountType == .storeUser else { return }
|
||||||
|
if let pendingCheck, pendingCheck.identity.matches(session: session),
|
||||||
|
window.rootViewController === pendingCheck.root { return }
|
||||||
|
cancelPendingCheck()
|
||||||
|
setBindingSuspended(true)
|
||||||
|
guard let identity = try? StoreAccountDeregistrationIdentity(session: session) else {
|
||||||
|
showResult(identity: nil, api: nil, viewModel: StoreAccountDeregistrationAccessViewModel())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let api = makeAPI(identity)
|
||||||
|
let model = StoreAccountDeregistrationAccessViewModel(submissionStore: makeSubmissionStore(identity.userID))
|
||||||
|
let pending = PendingCheck(identity: identity, root: window.rootViewController, viewModel: model)
|
||||||
|
pendingCheck = pending
|
||||||
|
GlobalLoadingManager.shared.show()
|
||||||
|
pending.task = Task { @MainActor [weak self, session] in
|
||||||
|
await model.verify(api: api) { identity.matches(session: session) }
|
||||||
|
guard let self, self.pendingCheck === pending else { return }
|
||||||
|
let isCurrent = self.window?.rootViewController === pending.root && identity.matches(session: session)
|
||||||
|
self.pendingCheck = nil
|
||||||
|
pending.task = nil
|
||||||
|
GlobalLoadingManager.shared.hide()
|
||||||
|
guard isCurrent else { return }
|
||||||
|
if pending.needsForegroundRefresh {
|
||||||
|
self.check()
|
||||||
|
} else if model.decision == .allowed {
|
||||||
|
self.restoreSession()
|
||||||
|
} else {
|
||||||
|
self.showResult(identity: identity, api: api, viewModel: model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 退出或切换会话时释放本次加载;迟到的旧响应不能关闭新请求的加载或替换新根。
|
||||||
|
func cancelPendingCheck() {
|
||||||
|
guard let pending = pendingCheck else { return }
|
||||||
|
pendingCheck = nil
|
||||||
|
pending.task?.cancel()
|
||||||
|
pending.task = nil
|
||||||
|
GlobalLoadingManager.shared.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showResult(identity: StoreAccountDeregistrationIdentity?,
|
||||||
|
api: (any StoreAccountDeregistrationServing)?,
|
||||||
|
viewModel: StoreAccountDeregistrationAccessViewModel) {
|
||||||
|
guard let window else { return }
|
||||||
|
let controller = StoreAccountDeregistrationAccessViewController(
|
||||||
|
identity: identity, api: api, session: session, initialViewModel: viewModel
|
||||||
|
) { [weak self] candidate in
|
||||||
|
guard let self, self.accessController === candidate,
|
||||||
|
let identity = candidate.identity, identity.matches(session: self.session) else { return }
|
||||||
|
self.restoreSession()
|
||||||
|
}
|
||||||
|
AppRouter.setRoot(UINavigationController(rootViewController: controller), on: window, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 普通页面返回前台不查询;仅已受限的核验页刷新,并废弃后台前的旧查询。
|
||||||
|
func resumeFromBackground() {
|
||||||
|
guard session.isLoggedIn, session.accountType == .storeUser else { return }
|
||||||
|
if let pendingCheck, pendingCheck.identity.matches(session: session),
|
||||||
|
window?.rootViewController === pendingCheck.root {
|
||||||
|
pendingCheck.viewModel.recordRestriction()
|
||||||
|
pendingCheck.needsForegroundRefresh = true
|
||||||
|
} else if let controller = accessController, controller.identity?.matches(session: session) == true {
|
||||||
|
setBindingSuspended(true)
|
||||||
|
controller.refreshAfterBackground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅原请求 Token 与当前门店会话一致时限制业务,不恢复被新页面替换的旧根。
|
||||||
|
func recordRestriction(requestToken: String?) {
|
||||||
|
guard StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: requestToken, session: session) else { return }
|
||||||
|
setBindingSuspended(true)
|
||||||
|
if let pendingCheck, pendingCheck.identity.matches(session: session),
|
||||||
|
window?.rootViewController === pendingCheck.root {
|
||||||
|
pendingCheck.viewModel.recordRestriction()
|
||||||
|
} else if let controller = accessController, controller.identity?.matches(session: session) == true {
|
||||||
|
controller.recordRestriction()
|
||||||
|
} else {
|
||||||
|
check()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 注销页面共用的轻量视觉组件,仅负责颜色、字号和布局,不包含业务状态。
|
||||||
|
@MainActor
|
||||||
|
enum StoreAccountDeregistrationStyle {
|
||||||
|
/// 注销流程独立视觉令牌,与设计稿保持一致且不修改全局主题。
|
||||||
|
static let primary = UIColor(hex: 0x1677FF)
|
||||||
|
static let pageBackground = UIColor(hex: 0xF5F7FA)
|
||||||
|
static let textPrimary = UIColor(hex: 0x172033)
|
||||||
|
static let textSecondary = UIColor(hex: 0x7A8496)
|
||||||
|
static let border = UIColor(hex: 0xE7ECF3)
|
||||||
|
static let infoBackground = UIColor(hex: 0xEEF5FF)
|
||||||
|
static let danger = UIColor(hex: 0xE5484D)
|
||||||
|
|
||||||
|
/// 创建支持多行的系统字体标签。
|
||||||
|
static func label(_ text: String = "", size: CGFloat = 15, weight: UIFont.Weight = .regular,
|
||||||
|
color: UIColor? = nil) -> UILabel {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = text
|
||||||
|
label.font = .systemFont(ofSize: size, weight: weight)
|
||||||
|
label.textColor = color ?? textPrimary
|
||||||
|
label.numberOfLines = 0
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建统一间距的纵向内容组。
|
||||||
|
static func stack(_ views: [UIView], spacing: CGFloat = 12) -> UIStackView {
|
||||||
|
let stack = UIStackView(arrangedSubviews: views)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = spacing
|
||||||
|
return stack
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 白色圆角卡片,内容由页面提供。
|
||||||
|
static func card(_ content: UIView, background: UIColor = .white) -> UIView {
|
||||||
|
let card = UIView()
|
||||||
|
card.backgroundColor = background
|
||||||
|
card.layer.cornerRadius = 16
|
||||||
|
card.layer.borderWidth = background == .white ? 0.5 : 0
|
||||||
|
card.layer.borderColor = border.cgColor
|
||||||
|
card.addSubview(content)
|
||||||
|
content.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一主次按钮;禁用态及加载期间不依赖系统默认的灰底样式。
|
||||||
|
static func button(_ title: String, id: String, primary: Bool = true) -> UIButton {
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.accessibilityIdentifier = id
|
||||||
|
configure(button, title: title, primary: primary)
|
||||||
|
button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(50) }
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新按钮角色和文案,保留清晰的主次关系。
|
||||||
|
static func configure(_ button: UIButton, title: String, primary: Bool = true) {
|
||||||
|
var config = primary ? UIButton.Configuration.filled() : .plain()
|
||||||
|
config.title = title
|
||||||
|
config.baseBackgroundColor = StoreAccountDeregistrationStyle.primary
|
||||||
|
config.baseForegroundColor = primary ? .white : StoreAccountDeregistrationStyle.primary
|
||||||
|
config.background.cornerRadius = 12
|
||||||
|
if !primary {
|
||||||
|
config.background.strokeColor = StoreAccountDeregistrationStyle.primary
|
||||||
|
config.background.strokeWidth = 1
|
||||||
|
}
|
||||||
|
config.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
|
||||||
|
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||||
|
var attributes = attributes
|
||||||
|
attributes.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
button.configuration = config
|
||||||
|
button.configurationUpdateHandler = { button in
|
||||||
|
let enabled = button.isEnabled
|
||||||
|
var updated = button.configuration
|
||||||
|
updated?.background.backgroundColorTransformer = UIConfigurationColorTransformer { _ in
|
||||||
|
primary ? (enabled ? StoreAccountDeregistrationStyle.primary : StoreAccountDeregistrationStyle.primary.withAlphaComponent(0.12)) : .clear
|
||||||
|
}
|
||||||
|
updated?.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||||
|
var attributes = attributes
|
||||||
|
attributes.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
attributes.foregroundColor = enabled ? (primary ? .white : StoreAccountDeregistrationStyle.primary) : StoreAccountDeregistrationStyle.textSecondary
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
button.configuration = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 以统一的SF Symbol展示提示图标,不加载额外图片资源。
|
||||||
|
static func icon(_ name: String, size: CGFloat = 22) -> UIImageView {
|
||||||
|
let image = UIImageView(image: UIImage(systemName: name,
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: size, weight: .medium)))
|
||||||
|
image.tintColor = primary
|
||||||
|
image.contentMode = .scaleAspectFit
|
||||||
|
image.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带浅色圆形底的状态图标,用于冷静期和身份信息强调。
|
||||||
|
static func iconBadge(_ name: String, iconSize: CGFloat = 30, diameter: CGFloat = 88) -> UIView {
|
||||||
|
let container = UIView()
|
||||||
|
container.backgroundColor = infoBackground
|
||||||
|
container.layer.cornerRadius = diameter / 2
|
||||||
|
let image = icon(name, size: iconSize)
|
||||||
|
container.addSubview(image)
|
||||||
|
container.snp.makeConstraints { $0.width.height.equalTo(diameter) }
|
||||||
|
image.snp.makeConstraints { $0.center.equalToSuperview(); $0.width.height.equalTo(iconSize + 8) }
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为危险确认操作应用红色实心按钮样式。
|
||||||
|
static func configureDestructive(_ button: UIButton, title: String) {
|
||||||
|
var config = UIButton.Configuration.filled()
|
||||||
|
config.title = title
|
||||||
|
config.baseBackgroundColor = danger
|
||||||
|
config.baseForegroundColor = .white
|
||||||
|
config.background.cornerRadius = 12
|
||||||
|
config.contentInsets = .init(top: 13, leading: 16, bottom: 13, trailing: 16)
|
||||||
|
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||||
|
var attributes = attributes
|
||||||
|
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
button.configuration = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注销资产及最终提交共用的高保真底部确认弹层。
|
||||||
|
@MainActor
|
||||||
|
final class StoreAccountDeregistrationConfirmationSheetViewController: UIViewController {
|
||||||
|
/// 弹层展示的一行摘要数据。
|
||||||
|
struct Summary {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private typealias Style = StoreAccountDeregistrationStyle
|
||||||
|
private let sheetTitle: String
|
||||||
|
private let summaries: [Summary]
|
||||||
|
private let notices: [(icon: String, text: String, danger: Bool)]
|
||||||
|
private let cancelTitle: String
|
||||||
|
private let confirmTitle: String
|
||||||
|
private let onConfirm: () -> Void
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
private let confirmButton = UIButton(type: .system)
|
||||||
|
private let contentStack = UIStackView()
|
||||||
|
|
||||||
|
/// 创建只在用户明确确认后回调的弹层;下拉或取消不会触发业务请求。
|
||||||
|
init(title: String, summaries: [Summary], notices: [(String, String, Bool)],
|
||||||
|
cancelTitle: String, confirmTitle: String, onConfirm: @escaping () -> Void) {
|
||||||
|
sheetTitle = title
|
||||||
|
self.summaries = summaries
|
||||||
|
self.notices = notices
|
||||||
|
self.cancelTitle = cancelTitle
|
||||||
|
self.confirmTitle = confirmTitle
|
||||||
|
self.onConfirm = onConfirm
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
modalPresentationStyle = .pageSheet
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .white
|
||||||
|
let titleLabel = Style.label(sheetTitle, size: 20, weight: .semibold)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
let summaryStack = Style.stack([], spacing: 0)
|
||||||
|
for (index, summary) in summaries.enumerated() {
|
||||||
|
let icon = Style.icon(summary.icon, size: 18)
|
||||||
|
icon.snp.makeConstraints { $0.width.equalTo(24) }
|
||||||
|
let title = Style.label(summary.title, size: 14, color: Style.textSecondary)
|
||||||
|
let value = Style.label(summary.value, size: 15, weight: .semibold)
|
||||||
|
value.textAlignment = .right
|
||||||
|
let row = UIStackView(arrangedSubviews: [icon, title, UIView(), value])
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .center
|
||||||
|
row.spacing = 8
|
||||||
|
row.snp.makeConstraints { $0.height.greaterThanOrEqualTo(48) }
|
||||||
|
summaryStack.addArrangedSubview(row)
|
||||||
|
if index < summaries.count - 1 {
|
||||||
|
let divider = UIView()
|
||||||
|
divider.backgroundColor = Style.border
|
||||||
|
divider.snp.makeConstraints { $0.height.equalTo(0.5) }
|
||||||
|
summaryStack.addArrangedSubview(divider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let summaryCard = Style.card(summaryStack)
|
||||||
|
summaryCard.layer.cornerRadius = 12
|
||||||
|
|
||||||
|
let noticeStack = Style.stack([], spacing: 12)
|
||||||
|
for notice in notices {
|
||||||
|
let icon = Style.icon(notice.icon, size: 16)
|
||||||
|
icon.tintColor = notice.danger ? Style.danger : Style.primary
|
||||||
|
icon.snp.makeConstraints { $0.width.equalTo(22) }
|
||||||
|
let text = Style.label(notice.text, size: 13,
|
||||||
|
color: notice.danger ? Style.danger : Style.textSecondary)
|
||||||
|
let row = UIStackView(arrangedSubviews: [icon, text])
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .top
|
||||||
|
row.spacing = 8
|
||||||
|
noticeStack.addArrangedSubview(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
Style.configure(cancelButton, title: cancelTitle, primary: false)
|
||||||
|
Style.configureDestructive(confirmButton, title: confirmTitle)
|
||||||
|
cancelButton.accessibilityIdentifier = "deregister.sheet.cancel"
|
||||||
|
confirmButton.accessibilityIdentifier = "deregister.sheet.confirm"
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
let actions = UIStackView(arrangedSubviews: [cancelButton, confirmButton])
|
||||||
|
actions.axis = .horizontal
|
||||||
|
actions.distribution = .fillEqually
|
||||||
|
actions.spacing = 12
|
||||||
|
actions.snp.makeConstraints { $0.height.equalTo(50) }
|
||||||
|
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = 16
|
||||||
|
contentStack.addArrangedSubview(titleLabel)
|
||||||
|
contentStack.addArrangedSubview(summaryCard)
|
||||||
|
if !notices.isEmpty { contentStack.addArrangedSubview(noticeStack) }
|
||||||
|
contentStack.addArrangedSubview(actions)
|
||||||
|
view.addSubview(contentStack)
|
||||||
|
contentStack.snp.makeConstraints {
|
||||||
|
$0.top.equalTo(view.safeAreaLayoutGuide).offset(28)
|
||||||
|
$0.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
$0.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).inset(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
guard let sheet = sheetPresentationController else { return }
|
||||||
|
view.layoutIfNeeded()
|
||||||
|
let sheetWidth = presentingViewController?.view.bounds.width ?? view.bounds.width
|
||||||
|
let contentWidth = max(0, sheetWidth - 32)
|
||||||
|
let contentHeight = contentStack.systemLayoutSizeFitting(
|
||||||
|
CGSize(width: contentWidth, height: UIView.layoutFittingCompressedSize.height),
|
||||||
|
withHorizontalFittingPriority: .required,
|
||||||
|
verticalFittingPriority: .fittingSizeLevel
|
||||||
|
).height
|
||||||
|
// 自定义 detent 会由系统计入底部安全区,这里只计算内容和视觉间距,避免重复留白。
|
||||||
|
let preferredHeight = ceil(28 + contentHeight + 12)
|
||||||
|
let identifier = UISheetPresentationController.Detent.Identifier("accountDeregistrationConfirmation")
|
||||||
|
sheet.detents = [.custom(identifier: identifier) { context in
|
||||||
|
min(preferredHeight, context.maximumDetentValue)
|
||||||
|
}]
|
||||||
|
sheet.selectedDetentIdentifier = identifier
|
||||||
|
sheet.prefersGrabberVisible = true
|
||||||
|
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
|
||||||
|
sheet.preferredCornerRadius = 22
|
||||||
|
preferredContentSize.height = preferredHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() { dismiss(animated: true) }
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
confirmButton.isEnabled = false
|
||||||
|
dismiss(animated: true) { [onConfirm] in onConfirm() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 注销申请明确受理后的退出态结果页;不持有会话、凭证或注销接口。
|
||||||
|
@MainActor
|
||||||
|
final class StoreAccountDeregistrationSubmittedViewController: BaseViewController {
|
||||||
|
private typealias Style = StoreAccountDeregistrationStyle
|
||||||
|
private let identityName: String
|
||||||
|
private let coolingUntil: String?
|
||||||
|
private let onDone: () -> Void
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let contentStack = UIStackView()
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
private let doneButton = Style.button("完成", id: "deregister.submitted.done")
|
||||||
|
private var didFinish = false
|
||||||
|
|
||||||
|
/// 使用提交时冻结的展示信息创建页面;点击完成后由 Scene 进入登录页。
|
||||||
|
init(identityName: String, coolingUntil: String?, onDone: @escaping () -> Void) {
|
||||||
|
let normalizedName = identityName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.identityName = normalizedName.isEmpty ? "当前门店身份" : normalizedName
|
||||||
|
self.coolingUntil = coolingUntil?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.onDone = onDone
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "注销账号"
|
||||||
|
navigationItem.hidesBackButton = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = Style.pageBackground
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
scrollView.addSubview(contentStack)
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = 18
|
||||||
|
|
||||||
|
let badge = Style.iconBadge("lock", iconSize: 36)
|
||||||
|
let badgeRow = UIView()
|
||||||
|
badgeRow.addSubview(badge)
|
||||||
|
badge.snp.makeConstraints { $0.top.bottom.centerX.equalToSuperview() }
|
||||||
|
|
||||||
|
let titleLabel = Style.label("注销申请已提交", size: 24, weight: .semibold)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
titleLabel.accessibilityIdentifier = "deregister.submitted.title"
|
||||||
|
let identityLabel = Style.label("当前身份:\(identityName)", size: 15, color: Style.textSecondary)
|
||||||
|
identityLabel.textAlignment = .center
|
||||||
|
identityLabel.accessibilityIdentifier = "deregister.submitted.identity"
|
||||||
|
contentStack.addArrangedSubview(Style.stack([badgeRow, titleLabel, identityLabel], spacing: 12))
|
||||||
|
|
||||||
|
let deadlineIcon = Style.icon("calendar", size: 22)
|
||||||
|
deadlineIcon.snp.makeConstraints { $0.width.equalTo(28) }
|
||||||
|
let deadlineTextValue = coolingUntil.flatMap { $0.isEmpty ? nil : $0 } ?? "以服务端状态为准"
|
||||||
|
let deadlineLabel = Style.label(deadlineTextValue, size: 19, weight: .semibold)
|
||||||
|
deadlineLabel.accessibilityIdentifier = "deregister.submitted.deadline"
|
||||||
|
let deadlineText = Style.stack([
|
||||||
|
Style.label("冷静期截止时间", size: 13, color: Style.textSecondary),
|
||||||
|
deadlineLabel
|
||||||
|
], spacing: 8)
|
||||||
|
let deadlineRow = UIStackView(arrangedSubviews: [deadlineIcon, deadlineText])
|
||||||
|
deadlineRow.axis = .horizontal
|
||||||
|
deadlineRow.alignment = .center
|
||||||
|
deadlineRow.spacing = 12
|
||||||
|
contentStack.addArrangedSubview(Style.card(deadlineRow))
|
||||||
|
|
||||||
|
let infoIcon = Style.icon("info.circle", size: 17)
|
||||||
|
infoIcon.snp.makeConstraints { $0.width.equalTo(22) }
|
||||||
|
let infoLabel = Style.label("重新登录该身份自动撤销申请", size: 13, color: Style.textSecondary)
|
||||||
|
let infoRow = UIStackView(arrangedSubviews: [infoIcon, infoLabel])
|
||||||
|
infoRow.axis = .horizontal
|
||||||
|
infoRow.alignment = .center
|
||||||
|
infoRow.spacing = 8
|
||||||
|
contentStack.addArrangedSubview(Style.card(infoRow, background: Style.infoBackground))
|
||||||
|
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
bottomBar.addSubview(doneButton)
|
||||||
|
doneButton.addTarget(self, action: #selector(doneTapped), for: .touchUpInside)
|
||||||
|
doneButton.snp.makeConstraints {
|
||||||
|
$0.top.equalToSuperview().offset(12)
|
||||||
|
$0.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
$0.bottom.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
bottomBar.snp.makeConstraints {
|
||||||
|
$0.leading.trailing.equalToSuperview()
|
||||||
|
$0.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
|
scrollView.snp.makeConstraints {
|
||||||
|
$0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
$0.bottom.equalTo(bottomBar.snp.top)
|
||||||
|
}
|
||||||
|
contentStack.snp.makeConstraints {
|
||||||
|
$0.top.equalTo(scrollView.contentLayoutGuide).offset(32)
|
||||||
|
$0.leading.trailing.bottom.equalTo(scrollView.contentLayoutGuide).inset(16)
|
||||||
|
$0.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
navigationItem.hidesBackButton = true
|
||||||
|
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func doneTapped() {
|
||||||
|
guard !didFinish else { return }
|
||||||
|
didFinish = true
|
||||||
|
doneButton.isEnabled = false
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import IQKeyboardCore
|
||||||
|
import IQKeyboardManagerSwift
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 简洁两步注销页面;一次确认两项资产,短信和实际申请仍由用户主动操作。
|
||||||
|
@MainActor
|
||||||
|
final class StoreAccountDeregistrationViewController: BaseViewController {
|
||||||
|
private typealias Style = StoreAccountDeregistrationStyle
|
||||||
|
private let viewModel: StoreAccountDeregistrationViewModel
|
||||||
|
private let api: any StoreAccountDeregistrationServing
|
||||||
|
private let identityName: String
|
||||||
|
private let readOnly: Bool
|
||||||
|
private let onUnresolvedSubmission: (() -> Void)?
|
||||||
|
private let onSubmissionAccepted: (String?) -> Void
|
||||||
|
private var didHandleAcceptedSubmission = false
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let refreshControl = UIRefreshControl()
|
||||||
|
private let content = UIStackView()
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
private let steps = UIStackView()
|
||||||
|
private let firstStepNumber = Style.label("1", size: 11, weight: .semibold)
|
||||||
|
private let secondStepNumber = Style.label("2", size: 11, weight: .semibold)
|
||||||
|
private let firstStep = Style.label("确认资产", size: 13, weight: .semibold)
|
||||||
|
private let secondStep = Style.label("手机验证", size: 13, weight: .semibold)
|
||||||
|
private let firstStepView = UIView()
|
||||||
|
private let secondStepView = UIView()
|
||||||
|
private let heading = Style.label(size: 24, weight: .semibold)
|
||||||
|
private let subtitle = Style.label(size: 14, color: Style.textSecondary)
|
||||||
|
private let conditionStack = UIStackView()
|
||||||
|
private let verificationStack = UIStackView()
|
||||||
|
private let walletAmount = Style.label("—", size: 28, weight: .semibold)
|
||||||
|
private let pointsAmount = Style.label("—", size: 28, weight: .semibold)
|
||||||
|
private let walletState = Style.label(size: 12, color: Style.textSecondary)
|
||||||
|
private let pointsState = Style.label(size: 12, color: Style.textSecondary)
|
||||||
|
private let assetHint = Style.label(size: 13, color: Style.textSecondary)
|
||||||
|
private let blockersLabel = Style.label(size: 14, color: Style.textSecondary)
|
||||||
|
private let riskLabel = Style.label(size: 13, color: AppColor.warning)
|
||||||
|
private let statusLabel = Style.label(size: 13, color: Style.textSecondary)
|
||||||
|
private let errorLabel = Style.label(size: 14, color: Style.danger)
|
||||||
|
private let codeField = UITextField()
|
||||||
|
private let reasonField = UITextView()
|
||||||
|
private let reasonTitle = Style.label(size: 16, weight: .semibold)
|
||||||
|
private let reasonPlaceholder = Style.label("请填写注销原因", size: 14, color: Style.textSecondary)
|
||||||
|
private let reasonCount = Style.label("0/50", size: 12, color: Style.textSecondary)
|
||||||
|
private let reasonValidationLabel = Style.label("请填写注销原因", size: 12, color: Style.danger)
|
||||||
|
private let reasonContainer = UIView()
|
||||||
|
private let smsButton = UIButton(type: .system)
|
||||||
|
private let continueButton = Style.button("确认资产并继续", id: "deregister.continue")
|
||||||
|
private let submitButton = Style.button("提交注销申请", id: "deregister.submit")
|
||||||
|
private var identityCard = UIView()
|
||||||
|
private var blockersCard = UIView()
|
||||||
|
private var errorCard = UIView()
|
||||||
|
private var actionTask: Task<Void, Never>?
|
||||||
|
private var previousPopGestureEnabled: Bool?
|
||||||
|
private var previousViewportHeight: CGFloat = 0
|
||||||
|
private var reasonWasBlurred = false
|
||||||
|
|
||||||
|
/// 注入当前身份及旧接口,不读取任意手机号,也不自动确认资产。
|
||||||
|
init(identityName: String, viewModel: StoreAccountDeregistrationViewModel,
|
||||||
|
api: any StoreAccountDeregistrationServing, readOnly: Bool = false,
|
||||||
|
onUnresolvedSubmission: (() -> Void)? = nil,
|
||||||
|
onSubmissionAccepted: ((String?) -> Void)? = nil) {
|
||||||
|
let frozenIdentityName = identityName
|
||||||
|
self.onSubmissionAccepted = onSubmissionAccepted ?? { coolingUntil in
|
||||||
|
var userInfo = [NotificationUserInfoKey.deregistrationIdentityName: frozenIdentityName]
|
||||||
|
if let coolingUntil { userInfo[NotificationUserInfoKey.deregistrationCoolingUntil] = coolingUntil }
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: NotificationName.storeAccountDeregistrationSubmitted,
|
||||||
|
object: nil,
|
||||||
|
userInfo: userInfo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.identityName = identityName
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.api = api
|
||||||
|
self.readOnly = readOnly
|
||||||
|
self.onUnresolvedSubmission = onUnresolvedSubmission
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = readOnly ? "注销条件" : "注销账号"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = Style.pageBackground
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
refreshControl.accessibilityIdentifier = "deregister.refresh"
|
||||||
|
refreshControl.accessibilityLabel = "下拉刷新"
|
||||||
|
refreshControl.tintColor = .clear
|
||||||
|
refreshControl.addTarget(self, action: #selector(refreshTapped), for: .valueChanged)
|
||||||
|
scrollView.refreshControl = refreshControl
|
||||||
|
scrollView.addSubview(content)
|
||||||
|
content.axis = .vertical
|
||||||
|
content.spacing = 16
|
||||||
|
content.addArrangedSubview(steps)
|
||||||
|
steps.axis = .horizontal
|
||||||
|
steps.alignment = .fill
|
||||||
|
steps.distribution = .fillEqually
|
||||||
|
steps.spacing = 12
|
||||||
|
configureStep(firstStepView, number: firstStepNumber, title: firstStep,
|
||||||
|
identifier: "deregister.step.assets", accessibilityLabel: "第 1 步,确认资产")
|
||||||
|
configureStep(secondStepView, number: secondStepNumber, title: secondStep,
|
||||||
|
identifier: "deregister.step.verification", accessibilityLabel: "第 2 步,手机验证")
|
||||||
|
steps.addArrangedSubview(firstStepView)
|
||||||
|
steps.addArrangedSubview(secondStepView)
|
||||||
|
steps.snp.makeConstraints { $0.height.equalTo(46) }
|
||||||
|
steps.isHidden = readOnly
|
||||||
|
content.addArrangedSubview(Style.stack([heading, subtitle], spacing: 8))
|
||||||
|
let identityText = Style.stack([
|
||||||
|
Style.label("当前门店身份", size: 12, color: Style.textSecondary),
|
||||||
|
Style.label(identityName, size: 17, weight: .semibold)
|
||||||
|
], spacing: 5)
|
||||||
|
let identityRow = UIStackView(arrangedSubviews: [Style.icon("person.crop.circle"), identityText])
|
||||||
|
identityRow.axis = .horizontal
|
||||||
|
identityRow.alignment = .center
|
||||||
|
identityRow.spacing = 12
|
||||||
|
identityCard = Style.card(identityRow)
|
||||||
|
content.addArrangedSubview(identityCard)
|
||||||
|
conditionStack.axis = .vertical
|
||||||
|
conditionStack.spacing = 16
|
||||||
|
verificationStack.axis = .vertical
|
||||||
|
verificationStack.spacing = 16
|
||||||
|
buildConditions()
|
||||||
|
buildVerification()
|
||||||
|
content.addArrangedSubview(conditionStack)
|
||||||
|
content.addArrangedSubview(verificationStack)
|
||||||
|
errorCard = Style.card(errorLabel)
|
||||||
|
errorCard.backgroundColor = UIColor(hex: 0xFFF1F1)
|
||||||
|
content.addArrangedSubview(errorCard)
|
||||||
|
content.addArrangedSubview(statusLabel)
|
||||||
|
statusLabel.accessibilityIdentifier = "deregister.status"
|
||||||
|
errorLabel.accessibilityIdentifier = "deregister.error"
|
||||||
|
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
let actions = Style.stack([continueButton, submitButton], spacing: 10)
|
||||||
|
bottomBar.addSubview(actions)
|
||||||
|
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
|
||||||
|
bottomBar.isHidden = readOnly
|
||||||
|
continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
|
||||||
|
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||||
|
scrollView.keyboardDismissMode = .onDrag
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureStep(_ container: UIView, number: UILabel, title: UILabel,
|
||||||
|
identifier: String, accessibilityLabel: String) {
|
||||||
|
number.numberOfLines = 1
|
||||||
|
title.numberOfLines = 1
|
||||||
|
number.textAlignment = .center
|
||||||
|
number.layer.cornerRadius = 10
|
||||||
|
number.clipsToBounds = true
|
||||||
|
number.snp.makeConstraints { $0.width.height.equalTo(20) }
|
||||||
|
let row = UIStackView(arrangedSubviews: [number, title])
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .center
|
||||||
|
row.spacing = 7
|
||||||
|
title.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
container.layer.cornerRadius = 12
|
||||||
|
container.accessibilityIdentifier = identifier
|
||||||
|
container.isAccessibilityElement = true
|
||||||
|
container.accessibilityLabel = accessibilityLabel
|
||||||
|
container.addSubview(row)
|
||||||
|
row.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildConditions() {
|
||||||
|
let columns = UIStackView()
|
||||||
|
columns.axis = .horizontal
|
||||||
|
columns.distribution = .fill
|
||||||
|
columns.spacing = 16
|
||||||
|
let walletColumn = Style.stack([
|
||||||
|
Style.label("现金余额", size: 13, color: Style.textSecondary), walletAmount, walletState
|
||||||
|
], spacing: 8)
|
||||||
|
columns.addArrangedSubview(walletColumn)
|
||||||
|
let divider = UIView()
|
||||||
|
divider.backgroundColor = Style.border
|
||||||
|
divider.snp.makeConstraints { $0.width.equalTo(1) }
|
||||||
|
columns.addArrangedSubview(divider)
|
||||||
|
let pointsColumn = Style.stack([
|
||||||
|
Style.label("积分", size: 13, color: Style.textSecondary), pointsAmount, pointsState
|
||||||
|
], spacing: 8)
|
||||||
|
columns.addArrangedSubview(pointsColumn)
|
||||||
|
walletColumn.snp.makeConstraints { $0.width.equalTo(pointsColumn) }
|
||||||
|
walletAmount.adjustsFontSizeToFitWidth = true
|
||||||
|
walletAmount.minimumScaleFactor = 0.6
|
||||||
|
walletAmount.numberOfLines = 1
|
||||||
|
pointsAmount.adjustsFontSizeToFitWidth = true
|
||||||
|
pointsAmount.minimumScaleFactor = 0.6
|
||||||
|
pointsAmount.numberOfLines = 1
|
||||||
|
walletState.accessibilityIdentifier = "deregister.wallet.state"
|
||||||
|
pointsState.accessibilityIdentifier = "deregister.points.state"
|
||||||
|
let assets = Style.card(Style.stack([
|
||||||
|
Style.label("账号资产", size: 16, weight: .semibold), columns, assetHint
|
||||||
|
], spacing: 16))
|
||||||
|
assets.accessibilityIdentifier = "deregister.assets"
|
||||||
|
conditionStack.addArrangedSubview(assets)
|
||||||
|
blockersLabel.accessibilityIdentifier = "deregister.blockers"
|
||||||
|
blockersCard = Style.card(Style.stack([
|
||||||
|
Style.label("待处理事项", size: 16, weight: .semibold), blockersLabel, riskLabel
|
||||||
|
]))
|
||||||
|
conditionStack.addArrangedSubview(blockersCard)
|
||||||
|
let notices = Style.stack([
|
||||||
|
notice("person.crop.circle", title: "仅注销当前身份"),
|
||||||
|
notice("clock", title: "7 天冷静期"),
|
||||||
|
notice("exclamationmark.shield", title: "正式完成后不可恢复")
|
||||||
|
], spacing: 16)
|
||||||
|
conditionStack.addArrangedSubview(Style.card(notices, background: Style.infoBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notice(_ icon: String, title: String) -> UIView {
|
||||||
|
let image = Style.icon(icon, size: 18)
|
||||||
|
image.snp.makeConstraints { $0.width.equalTo(22) }
|
||||||
|
let row = UIStackView(arrangedSubviews: [image, Style.label(title, size: 14, weight: .medium)])
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .center
|
||||||
|
row.spacing = 10
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildVerification() {
|
||||||
|
codeField.placeholder = "请输入短信验证码"
|
||||||
|
codeField.keyboardType = .numberPad
|
||||||
|
codeField.textContentType = .oneTimeCode
|
||||||
|
codeField.accessibilityIdentifier = "deregister.code"
|
||||||
|
codeField.accessibilityLabel = "短信验证码"
|
||||||
|
reasonField.accessibilityIdentifier = "deregister.reason"
|
||||||
|
reasonField.accessibilityLabel = "注销原因,必填"
|
||||||
|
codeField.font = .systemFont(ofSize: 16)
|
||||||
|
codeField.autocorrectionType = .no
|
||||||
|
codeField.iq.enableMode = .disabled
|
||||||
|
codeField.addTarget(self, action: #selector(inputChanged), for: .editingChanged)
|
||||||
|
codeField.addTarget(self, action: #selector(revealFocusedInput), for: .editingDidBegin)
|
||||||
|
reasonField.font = .systemFont(ofSize: 15)
|
||||||
|
reasonField.textColor = Style.textPrimary
|
||||||
|
reasonField.backgroundColor = .clear
|
||||||
|
reasonField.delegate = self
|
||||||
|
reasonField.autocorrectionType = .no
|
||||||
|
reasonField.iq.enableMode = .disabled
|
||||||
|
reasonField.textContainerInset = .init(top: 12, left: 12, bottom: 24, right: 12)
|
||||||
|
codeField.snp.makeConstraints { $0.height.equalTo(50) }
|
||||||
|
reasonField.snp.makeConstraints { $0.height.equalTo(96) }
|
||||||
|
smsButton.setTitle("获取验证码", for: .normal)
|
||||||
|
smsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
smsButton.accessibilityIdentifier = "deregister.sms"
|
||||||
|
smsButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
smsButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
smsButton.addTarget(self, action: #selector(smsTapped), for: .touchUpInside)
|
||||||
|
smsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
|
||||||
|
let codeRow = UIStackView(arrangedSubviews: [codeField, smsButton])
|
||||||
|
codeRow.axis = .horizontal
|
||||||
|
codeRow.alignment = .center
|
||||||
|
codeRow.spacing = 12
|
||||||
|
codeRow.layoutMargins = .init(top: 0, left: 14, bottom: 0, right: 8)
|
||||||
|
codeRow.isLayoutMarginsRelativeArrangement = true
|
||||||
|
codeRow.layer.cornerRadius = 10
|
||||||
|
codeRow.layer.borderWidth = 1
|
||||||
|
codeRow.layer.borderColor = Style.border.cgColor
|
||||||
|
let smsTitle = Style.label("短信验证码", size: 16, weight: .semibold)
|
||||||
|
reasonContainer.layer.cornerRadius = 10
|
||||||
|
reasonContainer.layer.borderWidth = 1
|
||||||
|
reasonContainer.layer.borderColor = Style.border.cgColor
|
||||||
|
reasonContainer.addSubview(reasonField)
|
||||||
|
reasonContainer.addSubview(reasonPlaceholder)
|
||||||
|
reasonContainer.addSubview(reasonCount)
|
||||||
|
reasonField.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||||
|
reasonPlaceholder.snp.makeConstraints { $0.top.leading.equalToSuperview().inset(16) }
|
||||||
|
reasonCount.snp.makeConstraints { $0.trailing.bottom.equalToSuperview().inset(12) }
|
||||||
|
let titleText = NSMutableAttributedString(
|
||||||
|
string: "注销原因 ",
|
||||||
|
attributes: [.font: UIFont.systemFont(ofSize: 16, weight: .semibold), .foregroundColor: Style.textPrimary]
|
||||||
|
)
|
||||||
|
titleText.append(NSAttributedString(
|
||||||
|
string: "*",
|
||||||
|
attributes: [.font: UIFont.systemFont(ofSize: 16, weight: .semibold), .foregroundColor: Style.danger]
|
||||||
|
))
|
||||||
|
reasonTitle.attributedText = titleText
|
||||||
|
reasonTitle.accessibilityLabel = "注销原因,必填"
|
||||||
|
reasonTitle.accessibilityIdentifier = "deregister.reason.title"
|
||||||
|
reasonValidationLabel.isHidden = true
|
||||||
|
reasonValidationLabel.accessibilityIdentifier = "deregister.reason.error"
|
||||||
|
let form = Style.stack([smsTitle, codeRow, reasonTitle, reasonContainer, reasonValidationLabel], spacing: 12)
|
||||||
|
form.setCustomSpacing(18, after: codeRow)
|
||||||
|
verificationStack.addArrangedSubview(Style.card(form))
|
||||||
|
let helperIcon = Style.icon("shield", size: 16)
|
||||||
|
helperIcon.tintColor = Style.textSecondary
|
||||||
|
helperIcon.snp.makeConstraints { $0.width.equalTo(22) }
|
||||||
|
let helper = UIStackView(arrangedSubviews: [
|
||||||
|
helperIcon,
|
||||||
|
Style.label("验证码将发送至当前身份绑定的手机号。", size: 13, color: Style.textSecondary)
|
||||||
|
])
|
||||||
|
helper.axis = .horizontal
|
||||||
|
helper.alignment = .center
|
||||||
|
helper.spacing = 8
|
||||||
|
verificationStack.addArrangedSubview(helper)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
bottomBar.snp.makeConstraints {
|
||||||
|
$0.leading.trailing.equalToSuperview()
|
||||||
|
$0.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
||||||
|
}
|
||||||
|
scrollView.snp.makeConstraints {
|
||||||
|
$0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
if readOnly { $0.bottom.equalTo(view.safeAreaLayoutGuide) }
|
||||||
|
else { $0.bottom.equalTo(bottomBar.snp.top) }
|
||||||
|
}
|
||||||
|
content.snp.makeConstraints {
|
||||||
|
$0.edges.equalTo(scrollView.contentLayoutGuide).inset(16)
|
||||||
|
$0.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
applyViewModel()
|
||||||
|
refreshTapped()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
previousPopGestureEnabled = navigationController?.interactivePopGestureRecognizer?.isEnabled
|
||||||
|
applyViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
if let previousPopGestureEnabled {
|
||||||
|
navigationController?.interactivePopGestureRecognizer?.isEnabled = previousPopGestureEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
guard scrollView.bounds.height != previousViewportHeight else { return }
|
||||||
|
previousViewportHeight = scrollView.bounds.height
|
||||||
|
revealFocusedInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func revealFocusedInput() {
|
||||||
|
guard let field = ([codeField, reasonField] as [UIView]).first(where: \.isFirstResponder) else { return }
|
||||||
|
let rect = field.convert(field.bounds, to: scrollView).insetBy(dx: 0, dy: -12)
|
||||||
|
scrollView.scrollRectToVisible(rect, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyViewModel() {
|
||||||
|
let busy = actionTask != nil || viewModel.isBusy
|
||||||
|
let verifying = viewModel.step == .verification && !readOnly
|
||||||
|
let unresolved = viewModel.step == .unresolvedRequest && !readOnly
|
||||||
|
heading.text = readOnly ? "当前注销条件" : (verifying ? "验证绑定手机号" : "注销前,请确认")
|
||||||
|
subtitle.text = verifying ? "完成验证后,即可提交注销申请。" : "仅注销此身份,其他身份不受影响。"
|
||||||
|
applyStepStyle(firstStepView, number: firstStepNumber, title: firstStep, selected: !verifying)
|
||||||
|
applyStepStyle(secondStepView, number: secondStepNumber, title: secondStep, selected: verifying)
|
||||||
|
identityCard.isHidden = verifying || unresolved
|
||||||
|
conditionStack.isHidden = verifying || unresolved
|
||||||
|
verificationStack.isHidden = !verifying
|
||||||
|
continueButton.isHidden = verifying || readOnly
|
||||||
|
submitButton.isHidden = !verifying
|
||||||
|
continueButton.configuration?.title = viewModel.requiresAssetConfirmation ? "确认资产并继续" : "下一步"
|
||||||
|
continueButton.isEnabled = !busy && viewModel.canConfirmAssetsAndContinue
|
||||||
|
submitButton.isEnabled = !busy && viewModel.canContinue && hasVerificationInput
|
||||||
|
smsButton.isEnabled = !busy && viewModel.canContinue
|
||||||
|
codeField.isEnabled = !busy
|
||||||
|
reasonField.isEditable = !busy
|
||||||
|
applyReasonInputStyle()
|
||||||
|
refreshControl.isEnabled = !busy
|
||||||
|
errorLabel.text = viewModel.errorMessage
|
||||||
|
errorCard.isHidden = viewModel.errorMessage == nil
|
||||||
|
|
||||||
|
if let value = viewModel.eligibility {
|
||||||
|
walletAmount.text = "¥\(value.walletBalance)"
|
||||||
|
pointsAmount.text = "\(value.pointsBalance)"
|
||||||
|
walletState.text = value.walletWaived ? "已确认放弃" : "待确认放弃"
|
||||||
|
pointsState.text = value.pointsWaived ? "已确认放弃" : "待确认放弃"
|
||||||
|
walletState.textColor = value.walletWaived ? Style.primary : Style.textSecondary
|
||||||
|
pointsState.textColor = value.pointsWaived ? Style.primary : Style.textSecondary
|
||||||
|
let assetIssues = value.blockers.filter(\.isAssetConfirmation)
|
||||||
|
let needsAssetHint = readOnly || assetIssues.contains { $0.code == "WAIVER_STALE" }
|
||||||
|
assetHint.isHidden = !needsAssetHint
|
||||||
|
assetHint.text = readOnly ? "以当前查询结果为准,冷静期内不能再次确认资产。"
|
||||||
|
: "资产已变化,请按最新金额重新确认。"
|
||||||
|
blockersCard.isHidden = value.businessBlockers.isEmpty
|
||||||
|
blockersLabel.text = value.businessBlockers.map { "• \($0.message)\n \($0.guidance)" }.joined(separator: "\n\n")
|
||||||
|
riskLabel.text = value.eligibleAt.map { "业务风险期预计结束:\($0)" }
|
||||||
|
riskLabel.isHidden = value.eligibleAt == nil
|
||||||
|
} else {
|
||||||
|
walletAmount.text = "—"
|
||||||
|
pointsAmount.text = "—"
|
||||||
|
walletState.text = "待查询"
|
||||||
|
pointsState.text = "待查询"
|
||||||
|
assetHint.isHidden = false
|
||||||
|
assetHint.text = busy ? "正在查询资产与注销条件…" : "暂未获取到资产,请刷新重试。"
|
||||||
|
blockersCard.isHidden = true
|
||||||
|
}
|
||||||
|
statusLabel.text = unresolved ? "申请状态待确认,请刷新后重试,暂勿重复提交。"
|
||||||
|
: (viewModel.status?.isCancelled == true ? "上次申请已撤销,可重新申请。" : nil)
|
||||||
|
statusLabel.isHidden = statusLabel.text == nil
|
||||||
|
let preventsBack = busy || unresolved || (!readOnly && viewModel.submissionAttempted)
|
||||||
|
navigationItem.hidesBackButton = preventsBack
|
||||||
|
if view.window != nil {
|
||||||
|
navigationController?.interactivePopGestureRecognizer?.isEnabled = !preventsBack && (previousPopGestureEnabled ?? true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyStepStyle(_ container: UIView, number: UILabel, title: UILabel, selected: Bool) {
|
||||||
|
container.backgroundColor = selected ? Style.primary : UIColor(hex: 0xE9EEF5)
|
||||||
|
container.accessibilityTraits = selected ? [.selected] : []
|
||||||
|
number.backgroundColor = selected ? UIColor.white.withAlphaComponent(0.2) : UIColor(hex: 0xDCE3EC)
|
||||||
|
number.textColor = selected ? .white : Style.textSecondary
|
||||||
|
title.textColor = selected ? .white : Style.textSecondary
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasVerificationInput: Bool {
|
||||||
|
let code = (codeField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let reason = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return !code.isEmpty && !reason.isEmpty && reason.count <= 50
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyReasonInputStyle() {
|
||||||
|
let missing = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
let invalid = reasonWasBlurred && missing
|
||||||
|
reasonContainer.layer.borderColor = reasonField.isFirstResponder
|
||||||
|
? Style.primary.cgColor
|
||||||
|
: (invalid ? Style.danger.cgColor : Style.border.cgColor)
|
||||||
|
reasonValidationLabel.isHidden = !invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ operation: @escaping @MainActor () async -> Void) {
|
||||||
|
guard actionTask == nil, !didHandleAcceptedSubmission else {
|
||||||
|
refreshControl.endRefreshing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actionTask = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.showLoading()
|
||||||
|
await operation()
|
||||||
|
self.hideLoading()
|
||||||
|
self.refreshControl.endRefreshing()
|
||||||
|
self.actionTask = nil
|
||||||
|
if !self.readOnly, self.viewModel.submissionAccepted {
|
||||||
|
self.didHandleAcceptedSubmission = true
|
||||||
|
self.onSubmissionAccepted(self.viewModel.submittedCoolingUntil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.applyViewModel()
|
||||||
|
if !self.readOnly, self.viewModel.submissionAttempted || self.viewModel.status?.isCooling == true {
|
||||||
|
self.onUnresolvedSubmission?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func inputChanged() { applyViewModel() }
|
||||||
|
@objc private func refreshTapped() { run { [self] in await self.viewModel.refresh(api: self.api) } }
|
||||||
|
|
||||||
|
@objc private func continueTapped() {
|
||||||
|
guard !readOnly, viewModel.canConfirmAssetsAndContinue, let snapshot = viewModel.eligibility else { return }
|
||||||
|
if !viewModel.requiresAssetConfirmation {
|
||||||
|
run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let sheet = StoreAccountDeregistrationConfirmationSheetViewController(
|
||||||
|
title: "确认放弃账号资产",
|
||||||
|
summaries: [
|
||||||
|
.init(icon: "person.crop.circle", title: "当前身份", value: identityName),
|
||||||
|
.init(icon: "banknote", title: "现金余额", value: "¥\(snapshot.walletBalance)"),
|
||||||
|
.init(icon: "star.circle", title: "积分", value: "\(snapshot.pointsBalance)")
|
||||||
|
],
|
||||||
|
notices: [],
|
||||||
|
cancelTitle: "暂不确认", confirmTitle: "确认放弃"
|
||||||
|
) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
|
||||||
|
}
|
||||||
|
present(sheet, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func smsTapped() {
|
||||||
|
guard !readOnly else { return }
|
||||||
|
run { [self] in
|
||||||
|
if await self.viewModel.sendSMS(api: self.api) { showToast("验证码已发送至当前身份绑定手机号") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func submitTapped() {
|
||||||
|
guard !readOnly, viewModel.canContinue, hasVerificationInput else { return }
|
||||||
|
let code = codeField.text ?? ""
|
||||||
|
let reason = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
view.endEditing(true)
|
||||||
|
let sheet = StoreAccountDeregistrationConfirmationSheetViewController(
|
||||||
|
title: "确认提交注销申请?",
|
||||||
|
summaries: [
|
||||||
|
.init(icon: "person.crop.circle", title: "当前身份", value: identityName),
|
||||||
|
.init(icon: "clock", title: "冷静期", value: "7 天"),
|
||||||
|
.init(icon: "text.bubble", title: "注销原因", value: reason)
|
||||||
|
],
|
||||||
|
notices: [
|
||||||
|
("info.circle", "注销后当前身份将无法使用", false),
|
||||||
|
("checkmark.shield", "冷静期内可撤销,重新登录该身份自动撤销申请", false),
|
||||||
|
("exclamationmark.circle.fill", "正式完成后不可恢复,历史记录保留", true)
|
||||||
|
],
|
||||||
|
cancelTitle: "我再想想", confirmTitle: "确认提交"
|
||||||
|
) { [weak self] in
|
||||||
|
self?.submitConfirmedApplication(smsCode: code, reason: reason)
|
||||||
|
}
|
||||||
|
present(sheet, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户确认最终弹窗后提交;仅明确成功触发一次退出态结果页,未知结果仍进入核验流程。
|
||||||
|
func submitConfirmedApplication(smsCode: String, reason: String) {
|
||||||
|
guard !readOnly, viewModel.validateSubmissionInput(smsCode: smsCode, reason: reason) else {
|
||||||
|
applyViewModel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run { [self] in await viewModel.submit(smsCode: smsCode, reason: reason, api: api) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StoreAccountDeregistrationViewController: UITextViewDelegate {
|
||||||
|
/// 注销原因必填;限制长度并同步必填状态、占位和计数展示。
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
if textView.text.count > 50 {
|
||||||
|
textView.text = String(textView.text.prefix(50))
|
||||||
|
}
|
||||||
|
reasonPlaceholder.isHidden = !textView.text.isEmpty
|
||||||
|
reasonCount.text = "\(textView.text.count)/50"
|
||||||
|
if !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
reasonWasBlurred = false
|
||||||
|
}
|
||||||
|
applyViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
|
applyReasonInputStyle()
|
||||||
|
revealFocusedInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidEndEditing(_ textView: UITextView) {
|
||||||
|
reasonWasBlurred = true
|
||||||
|
applyReasonInputStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
//
|
||||||
|
// BeforeAfterComparisonViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 暗色全屏前后图片对比页,通过可拖拽分隔线查看原图与效果图。
|
||||||
|
final class BeforeAfterComparisonViewController: UIViewController {
|
||||||
|
private let viewModel: BeforeAfterComparisonViewModel
|
||||||
|
private let backButton = UIButton(type: .system)
|
||||||
|
private let comparisonContainer = UIView()
|
||||||
|
private let afterImageView = UIImageView()
|
||||||
|
private let beforeImageView = UIImageView()
|
||||||
|
private let beforeRevealMask = CAShapeLayer()
|
||||||
|
private let dividerLine = UIView()
|
||||||
|
private let dividerHandle = BeforeAfterComparisonHandleView()
|
||||||
|
private let beforeLabel = BeforeAfterComparisonLabel()
|
||||||
|
private let afterLabel = BeforeAfterComparisonLabel()
|
||||||
|
private let statusContainer = UIView()
|
||||||
|
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
||||||
|
private let statusLabel = UILabel()
|
||||||
|
private let retryButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
private var dividerFraction: CGFloat = 0.5
|
||||||
|
private var loadGeneration = 0
|
||||||
|
private var beforeLoaded = false
|
||||||
|
private var afterLoaded = false
|
||||||
|
private var isReady = false
|
||||||
|
private var displayLink: CADisplayLink?
|
||||||
|
private var animationStartTime: CFTimeInterval?
|
||||||
|
private var isDraggingDivider = false
|
||||||
|
private var imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio
|
||||||
|
private var beforeImageSize: CGSize?
|
||||||
|
private var afterImageSize: CGSize?
|
||||||
|
|
||||||
|
/// 创建指定前后图片内容的全屏对比页。
|
||||||
|
init(viewModel: BeforeAfterComparisonViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
modalPresentationStyle = .fullScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
setupUI()
|
||||||
|
setupConstraints()
|
||||||
|
bindActions()
|
||||||
|
loadImages()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
layoutComparisonContainer()
|
||||||
|
layoutDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
startAutomaticMotion(from: dividerFraction)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
stopAutomaticMotion()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
displayLink?.invalidate()
|
||||||
|
displayLink = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
displayLink?.invalidate()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
view.backgroundColor = UIColor(hex: 0x05070D)
|
||||||
|
view.accessibilityIdentifier = "travelAlbum.beforeAfterComparison"
|
||||||
|
|
||||||
|
var backConfiguration = UIButton.Configuration.filled()
|
||||||
|
backConfiguration.image = UIImage(systemName: "chevron.left")
|
||||||
|
backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
|
||||||
|
pointSize: 19,
|
||||||
|
weight: .semibold
|
||||||
|
)
|
||||||
|
backConfiguration.baseForegroundColor = .white
|
||||||
|
backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||||
|
backConfiguration.background.cornerRadius = 24
|
||||||
|
backConfiguration.contentInsets = .zero
|
||||||
|
backButton.configuration = backConfiguration
|
||||||
|
backButton.accessibilityLabel = "返回"
|
||||||
|
backButton.accessibilityIdentifier = "travelAlbum.beforeAfterBackButton"
|
||||||
|
|
||||||
|
comparisonContainer.backgroundColor = UIColor(hex: 0x111827)
|
||||||
|
comparisonContainer.layer.cornerRadius = 0
|
||||||
|
comparisonContainer.layer.borderColor = UIColor(hex: 0x263244).cgColor
|
||||||
|
comparisonContainer.layer.borderWidth = 1
|
||||||
|
comparisonContainer.clipsToBounds = true
|
||||||
|
comparisonContainer.accessibilityIdentifier = "travelAlbum.beforeAfterImageContainer"
|
||||||
|
|
||||||
|
[afterImageView, beforeImageView].forEach { imageView in
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
imageView.backgroundColor = UIColor(hex: 0x111827)
|
||||||
|
imageView.isHidden = true
|
||||||
|
}
|
||||||
|
afterImageView.accessibilityLabel = viewModel.content.afterLabel
|
||||||
|
beforeImageView.accessibilityLabel = viewModel.content.beforeLabel
|
||||||
|
beforeImageView.layer.mask = beforeRevealMask
|
||||||
|
|
||||||
|
dividerLine.backgroundColor = UIColor.white.withAlphaComponent(0.72)
|
||||||
|
dividerLine.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
dividerLine.layer.shadowOpacity = 0.35
|
||||||
|
dividerLine.layer.shadowRadius = 2
|
||||||
|
dividerLine.layer.shadowOffset = .zero
|
||||||
|
dividerLine.isHidden = true
|
||||||
|
dividerLine.isUserInteractionEnabled = false
|
||||||
|
dividerHandle.isHidden = true
|
||||||
|
dividerHandle.accessibilityIdentifier = "travelAlbum.beforeAfterDivider"
|
||||||
|
|
||||||
|
beforeLabel.text = viewModel.content.beforeLabel
|
||||||
|
beforeLabel.accessibilityIdentifier = "travelAlbum.beforeLabel"
|
||||||
|
afterLabel.text = viewModel.content.afterLabel
|
||||||
|
afterLabel.accessibilityIdentifier = "travelAlbum.afterLabel"
|
||||||
|
beforeLabel.isHidden = true
|
||||||
|
afterLabel.isHidden = true
|
||||||
|
|
||||||
|
statusContainer.backgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.86)
|
||||||
|
activityIndicator.color = .white
|
||||||
|
activityIndicator.hidesWhenStopped = true
|
||||||
|
statusLabel.textColor = UIColor.white.withAlphaComponent(0.78)
|
||||||
|
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
statusLabel.textAlignment = .center
|
||||||
|
statusLabel.numberOfLines = 0
|
||||||
|
statusLabel.text = "正在加载对比图片"
|
||||||
|
statusLabel.accessibilityIdentifier = "travelAlbum.beforeAfterStatusLabel"
|
||||||
|
var retryConfiguration = UIButton.Configuration.filled()
|
||||||
|
retryConfiguration.title = "重试"
|
||||||
|
retryConfiguration.baseForegroundColor = .white
|
||||||
|
retryConfiguration.baseBackgroundColor = UIColor(hex: 0x1677FF)
|
||||||
|
retryConfiguration.background.cornerRadius = 12
|
||||||
|
retryButton.configuration = retryConfiguration
|
||||||
|
retryButton.accessibilityIdentifier = "travelAlbum.beforeAfterRetryButton"
|
||||||
|
retryButton.isHidden = true
|
||||||
|
|
||||||
|
view.addSubview(backButton)
|
||||||
|
view.addSubview(comparisonContainer)
|
||||||
|
comparisonContainer.addSubview(afterImageView)
|
||||||
|
comparisonContainer.addSubview(beforeImageView)
|
||||||
|
comparisonContainer.addSubview(dividerLine)
|
||||||
|
comparisonContainer.addSubview(dividerHandle)
|
||||||
|
comparisonContainer.addSubview(beforeLabel)
|
||||||
|
comparisonContainer.addSubview(afterLabel)
|
||||||
|
comparisonContainer.addSubview(statusContainer)
|
||||||
|
statusContainer.addSubview(activityIndicator)
|
||||||
|
statusContainer.addSubview(statusLabel)
|
||||||
|
statusContainer.addSubview(retryButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
backButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
||||||
|
make.leading.equalToSuperview().offset(18)
|
||||||
|
make.size.equalTo(48)
|
||||||
|
}
|
||||||
|
afterImageView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
beforeImageView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
beforeLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.bottom.equalToSuperview().inset(10)
|
||||||
|
make.height.equalTo(26)
|
||||||
|
}
|
||||||
|
afterLabel.snp.makeConstraints { make in
|
||||||
|
make.trailing.bottom.equalToSuperview().inset(10)
|
||||||
|
make.height.equalTo(26)
|
||||||
|
}
|
||||||
|
statusContainer.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
activityIndicator.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.centerY.equalToSuperview().offset(-28)
|
||||||
|
}
|
||||||
|
statusLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(activityIndicator.snp.bottom).offset(14)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(32)
|
||||||
|
}
|
||||||
|
retryButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(statusLabel.snp.bottom).offset(14)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.width.equalTo(96)
|
||||||
|
make.height.equalTo(44)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindActions() {
|
||||||
|
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||||
|
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||||
|
let scrubberGesture = UILongPressGestureRecognizer(target: self, action: #selector(dividerDragged(_:)))
|
||||||
|
scrubberGesture.minimumPressDuration = 0
|
||||||
|
scrubberGesture.allowableMovement = .greatestFiniteMagnitude
|
||||||
|
comparisonContainer.addGestureRecognizer(scrubberGesture)
|
||||||
|
dividerHandle.onIncrement = { [weak self] in self?.adjustDivider(by: 0.1) }
|
||||||
|
dividerHandle.onDecrement = { [weak self] in self?.adjustDivider(by: -0.1) }
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(reduceMotionStatusChanged),
|
||||||
|
name: UIAccessibility.reduceMotionStatusDidChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadImages() {
|
||||||
|
loadGeneration += 1
|
||||||
|
let generation = loadGeneration
|
||||||
|
beforeImageView.kf.cancelDownloadTask()
|
||||||
|
afterImageView.kf.cancelDownloadTask()
|
||||||
|
beforeImageView.image = nil
|
||||||
|
afterImageView.image = nil
|
||||||
|
beforeLoaded = false
|
||||||
|
afterLoaded = false
|
||||||
|
beforeImageSize = nil
|
||||||
|
afterImageSize = nil
|
||||||
|
imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio
|
||||||
|
isReady = false
|
||||||
|
dividerFraction = BeforeAfterComparisonMotion.automaticCenter
|
||||||
|
stopAutomaticMotion()
|
||||||
|
setComparisonVisible(false)
|
||||||
|
statusContainer.isHidden = false
|
||||||
|
statusLabel.text = "正在加载对比图片"
|
||||||
|
retryButton.isHidden = true
|
||||||
|
activityIndicator.startAnimating()
|
||||||
|
|
||||||
|
guard viewModel.isValid,
|
||||||
|
let beforeURL = viewModel.beforeImageURL,
|
||||||
|
let afterURL = viewModel.afterImageURL
|
||||||
|
else {
|
||||||
|
showLoadFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeImageView.kf.setImage(with: beforeURL) { [weak self] result in
|
||||||
|
self?.handleImageResult(result, isBefore: true, generation: generation)
|
||||||
|
}
|
||||||
|
afterImageView.kf.setImage(with: afterURL) { [weak self] result in
|
||||||
|
self?.handleImageResult(result, isBefore: false, generation: generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleImageResult(
|
||||||
|
_ result: Result<RetrieveImageResult, KingfisherError>,
|
||||||
|
isBefore: Bool,
|
||||||
|
generation: Int
|
||||||
|
) {
|
||||||
|
guard generation == loadGeneration else { return }
|
||||||
|
switch result {
|
||||||
|
case let .success(value):
|
||||||
|
if isBefore {
|
||||||
|
beforeLoaded = true
|
||||||
|
beforeImageSize = value.image.size
|
||||||
|
} else {
|
||||||
|
afterLoaded = true
|
||||||
|
afterImageSize = value.image.size
|
||||||
|
}
|
||||||
|
guard beforeLoaded && afterLoaded else { return }
|
||||||
|
let resolvedSize = afterImageSize ?? beforeImageSize ?? .zero
|
||||||
|
imageAspectRatio = BeforeAfterComparisonLayout.aspectRatio(for: resolvedSize)
|
||||||
|
view.setNeedsLayout()
|
||||||
|
view.layoutIfNeeded()
|
||||||
|
isReady = true
|
||||||
|
activityIndicator.stopAnimating()
|
||||||
|
statusContainer.isHidden = true
|
||||||
|
setComparisonVisible(true)
|
||||||
|
layoutDivider()
|
||||||
|
startAutomaticMotion(from: dividerFraction)
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: "对比图片已加载")
|
||||||
|
case .failure:
|
||||||
|
showLoadFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showLoadFailure() {
|
||||||
|
isReady = false
|
||||||
|
activityIndicator.stopAnimating()
|
||||||
|
statusContainer.isHidden = false
|
||||||
|
statusLabel.text = "图片加载失败,请重试"
|
||||||
|
retryButton.isHidden = false
|
||||||
|
setComparisonVisible(false)
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: statusLabel.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setComparisonVisible(_ visible: Bool) {
|
||||||
|
afterImageView.isHidden = !visible
|
||||||
|
beforeImageView.isHidden = !visible
|
||||||
|
dividerLine.isHidden = !visible
|
||||||
|
dividerHandle.isHidden = !visible
|
||||||
|
beforeLabel.isHidden = !visible
|
||||||
|
afterLabel.isHidden = !visible
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoutComparisonContainer() {
|
||||||
|
let safeFrame = view.bounds.inset(by: view.safeAreaInsets)
|
||||||
|
let topBoundary = max(safeFrame.minY, backButton.frame.maxY + 24)
|
||||||
|
let bottomBoundary = safeFrame.maxY - 24
|
||||||
|
let center = CGPoint(x: view.bounds.midX, y: safeFrame.midY)
|
||||||
|
let verticalRadius = max(
|
||||||
|
1,
|
||||||
|
min(center.y - topBoundary, bottomBoundary - center.y)
|
||||||
|
)
|
||||||
|
let maximumSize = CGSize(
|
||||||
|
width: max(1, view.bounds.width),
|
||||||
|
height: verticalRadius * 2
|
||||||
|
)
|
||||||
|
let fittedSize = BeforeAfterComparisonLayout.fittedSize(
|
||||||
|
aspectRatio: imageAspectRatio,
|
||||||
|
maximumSize: maximumSize
|
||||||
|
)
|
||||||
|
comparisonContainer.frame = CGRect(
|
||||||
|
x: center.x - fittedSize.width / 2,
|
||||||
|
y: center.y - fittedSize.height / 2,
|
||||||
|
width: fittedSize.width,
|
||||||
|
height: fittedSize.height
|
||||||
|
)
|
||||||
|
comparisonContainer.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoutDivider() {
|
||||||
|
guard comparisonContainer.bounds.width > 0 else { return }
|
||||||
|
let bounds = comparisonContainer.bounds
|
||||||
|
let x = bounds.width * dividerFraction
|
||||||
|
beforeRevealMask.frame = beforeImageView.bounds
|
||||||
|
beforeRevealMask.path = UIBezierPath(
|
||||||
|
rect: CGRect(x: 0, y: 0, width: x, height: bounds.height)
|
||||||
|
).cgPath
|
||||||
|
dividerLine.frame = CGRect(x: x - 1.5, y: 0, width: 3, height: bounds.height)
|
||||||
|
dividerHandle.frame = CGRect(x: x - 24, y: bounds.midY - 24, width: 48, height: 48)
|
||||||
|
dividerHandle.accessibilityValue = "原图占比 \(Int((dividerFraction * 100).rounded()))%"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateDivider(locationX: CGFloat) {
|
||||||
|
guard isReady, comparisonContainer.bounds.width > 0 else { return }
|
||||||
|
dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction(
|
||||||
|
locationX / comparisonContainer.bounds.width
|
||||||
|
)
|
||||||
|
layoutDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func adjustDivider(by delta: CGFloat) {
|
||||||
|
stopAutomaticMotion()
|
||||||
|
dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction(dividerFraction + delta)
|
||||||
|
layoutDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dividerDragged(_ gesture: UILongPressGestureRecognizer) {
|
||||||
|
switch gesture.state {
|
||||||
|
case .began:
|
||||||
|
isDraggingDivider = true
|
||||||
|
stopAutomaticMotion()
|
||||||
|
dividerHandle.setActive(true, animated: !UIAccessibility.isReduceMotionEnabled)
|
||||||
|
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
|
||||||
|
case .changed:
|
||||||
|
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
|
||||||
|
case .ended, .cancelled, .failed:
|
||||||
|
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
|
||||||
|
isDraggingDivider = false
|
||||||
|
dividerHandle.setActive(false, animated: !UIAccessibility.isReduceMotionEnabled)
|
||||||
|
startAutomaticMotion(from: dividerFraction)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func automaticMotionTick(_ link: CADisplayLink) {
|
||||||
|
guard isReady, !isDraggingDivider, let animationStartTime else { return }
|
||||||
|
dividerFraction = BeforeAfterComparisonMotion.automaticFraction(
|
||||||
|
elapsed: link.timestamp - animationStartTime
|
||||||
|
)
|
||||||
|
layoutDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAutomaticMotion(from fraction: CGFloat) {
|
||||||
|
guard isReady,
|
||||||
|
viewIfLoaded?.window != nil,
|
||||||
|
!isDraggingDivider,
|
||||||
|
!UIAccessibility.isReduceMotionEnabled
|
||||||
|
else { return }
|
||||||
|
animationStartTime = CACurrentMediaTime() - BeforeAfterComparisonMotion.phaseTime(for: fraction)
|
||||||
|
if displayLink == nil {
|
||||||
|
let link = CADisplayLink(target: self, selector: #selector(automaticMotionTick(_:)))
|
||||||
|
link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)
|
||||||
|
link.add(to: .main, forMode: .common)
|
||||||
|
displayLink = link
|
||||||
|
}
|
||||||
|
displayLink?.isPaused = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopAutomaticMotion() {
|
||||||
|
displayLink?.isPaused = true
|
||||||
|
animationStartTime = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func reduceMotionStatusChanged() {
|
||||||
|
if UIAccessibility.isReduceMotionEnabled {
|
||||||
|
stopAutomaticMotion()
|
||||||
|
} else {
|
||||||
|
startAutomaticMotion(from: dividerFraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func backTapped() {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func retryTapped() {
|
||||||
|
loadImages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前后对比分隔线的可调节圆形手柄。
|
||||||
|
private final class BeforeAfterComparisonHandleView: UIView {
|
||||||
|
var onIncrement: (() -> Void)?
|
||||||
|
var onDecrement: (() -> Void)?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = UIColor.black.withAlphaComponent(0.38)
|
||||||
|
layer.cornerRadius = 24
|
||||||
|
layer.borderColor = UIColor.white.withAlphaComponent(0.82).cgColor
|
||||||
|
layer.borderWidth = 2
|
||||||
|
layer.shadowColor = UIColor.black.cgColor
|
||||||
|
layer.shadowOpacity = 0.32
|
||||||
|
layer.shadowRadius = 7
|
||||||
|
layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||||
|
isAccessibilityElement = true
|
||||||
|
accessibilityLabel = "前后对比分隔线"
|
||||||
|
accessibilityTraits = [.adjustable]
|
||||||
|
|
||||||
|
let imageView = UIImageView(image: UIImage(systemName: "arrow.left.and.right"))
|
||||||
|
imageView.tintColor = .white
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
addSubview(imageView)
|
||||||
|
imageView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新手柄按压态,为拖动接管提供轻微但不改变布局的视觉反馈。
|
||||||
|
func setActive(_ active: Bool, animated: Bool) {
|
||||||
|
let changes = {
|
||||||
|
self.transform = active ? CGAffineTransform(scaleX: 1.08, y: 1.08) : .identity
|
||||||
|
self.backgroundColor = UIColor.black.withAlphaComponent(active ? 0.52 : 0.38)
|
||||||
|
}
|
||||||
|
guard animated else {
|
||||||
|
changes()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: 0.18,
|
||||||
|
delay: 0,
|
||||||
|
options: [.curveEaseOut, .beginFromCurrentState],
|
||||||
|
animations: changes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func accessibilityIncrement() {
|
||||||
|
onIncrement?()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func accessibilityDecrement() {
|
||||||
|
onDecrement?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前后对比图片底部的高对比度胶囊角标。
|
||||||
|
private final class BeforeAfterComparisonLabel: UILabel {
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
textColor = .white
|
||||||
|
font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
textAlignment = .center
|
||||||
|
backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||||||
|
layer.cornerRadius = 13
|
||||||
|
layer.borderColor = UIColor.white.withAlphaComponent(0.2).cgColor
|
||||||
|
layer.borderWidth = 0.5
|
||||||
|
clipsToBounds = true
|
||||||
|
setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
layoutMargins = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
let size = super.intrinsicContentSize
|
||||||
|
return CGSize(width: size.width + 20, height: 26)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,14 @@ 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) { min(650, $0.maximumDetentValue) },
|
||||||
|
.large(),
|
||||||
|
]
|
||||||
|
sheetPresentationController.selectedDetentIdentifier = formDetent
|
||||||
sheetPresentationController.prefersGrabberVisible = false
|
sheetPresentationController.prefersGrabberVisible = false
|
||||||
|
sheetPresentationController.preferredCornerRadius = 22
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +74,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 +125,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 +163,42 @@ 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 = 10
|
||||||
|
autoRetouchSectionView.layer.borderWidth = 1
|
||||||
|
autoRetouchSectionView.layer.borderColor = AppColor.border.cgColor
|
||||||
|
autoRetouchTitleLabel.text = "修图方式"
|
||||||
|
autoRetouchTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
autoRetouchTitleLabel.textColor = AppColor.textPrimary
|
||||||
|
autoRetouchDetailLabel.text = "选择 AI 修图后,需要再选一个效果模板"
|
||||||
|
autoRetouchDetailLabel.font = .systemFont(ofSize: 12)
|
||||||
|
autoRetouchDetailLabel.textColor = AppColor.textSecondary
|
||||||
|
|
||||||
|
let optionsStack = UIStackView(arrangedSubviews: [noRetouchOption, aiRetouchOption])
|
||||||
|
optionsStack.axis = .horizontal
|
||||||
|
optionsStack.spacing = 10
|
||||||
|
optionsStack.distribution = .fillEqually
|
||||||
|
autoRetouchSectionView.addSubview(autoRetouchTitleLabel)
|
||||||
|
autoRetouchSectionView.addSubview(autoRetouchDetailLabel)
|
||||||
|
autoRetouchSectionView.addSubview(optionsStack)
|
||||||
|
autoRetouchTitleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(14)
|
||||||
|
}
|
||||||
|
autoRetouchDetailLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(autoRetouchTitleLabel.snp.bottom).offset(4)
|
||||||
|
make.leading.trailing.equalTo(autoRetouchTitleLabel)
|
||||||
|
}
|
||||||
|
optionsStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(autoRetouchDetailLabel.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview().inset(12)
|
||||||
|
make.height.equalTo(76)
|
||||||
|
}
|
||||||
|
updateAutoRetouchSection()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
|
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
|
||||||
@@ -188,6 +239,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
|
||||||
)
|
)
|
||||||
@@ -199,6 +251,42 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func noRetouchTapped() {
|
||||||
|
autoRetouchConfiguration = .disabled
|
||||||
|
updateAutoRetouchSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func aiRetouchTapped() {
|
||||||
|
guard presentedViewController == nil else { return }
|
||||||
|
let settingViewModel = TravelAlbumAutoRetouchSettingViewModel(
|
||||||
|
scenicId: AppStore.shared.session.currentScenicId,
|
||||||
|
configuration: autoRetouchConfiguration,
|
||||||
|
allowsModeSelection: false
|
||||||
|
)
|
||||||
|
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
|
||||||
|
viewModel: settingViewModel,
|
||||||
|
api: api
|
||||||
|
)
|
||||||
|
controller.onConfirm = { [weak self] configuration in
|
||||||
|
self?.autoRetouchConfiguration = configuration
|
||||||
|
self?.updateAutoRetouchSection()
|
||||||
|
}
|
||||||
|
present(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAutoRetouchSection() {
|
||||||
|
noRetouchOption.apply(title: "不修图", desc: "保留原图", selected: !autoRetouchConfiguration.enabled)
|
||||||
|
aiRetouchOption.apply(
|
||||||
|
title: "AI 修图",
|
||||||
|
desc: autoRetouchConfiguration.enabled ? "已选择真实 AI 模板" : "点击选模板",
|
||||||
|
selected: autoRetouchConfiguration.enabled
|
||||||
|
)
|
||||||
|
noRetouchOption.accessibilityLabel = "不修图,保留原图"
|
||||||
|
aiRetouchOption.accessibilityLabel = "AI 修图,点击选择模板"
|
||||||
|
noRetouchOption.accessibilityValue = autoRetouchConfiguration.enabled ? "未选择" : "已选择"
|
||||||
|
aiRetouchOption.accessibilityValue = autoRetouchConfiguration.enabled ? "已选择" : "未选择"
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func textFieldEditingChanged(_ field: UITextField) {
|
@objc private func textFieldEditingChanged(_ field: UITextField) {
|
||||||
let text = field.text ?? ""
|
let text = field.text ?? ""
|
||||||
if field === freeCountField {
|
if field === freeCountField {
|
||||||
|
|||||||
@@ -0,0 +1,923 @@
|
|||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// AI 修图任务详情页,按设计稿展示状态总览、相册信息、任务内容与逐输出处理明细。
|
||||||
|
final class TravelAlbumAIJobDetailViewController: BaseViewController {
|
||||||
|
private let viewModel: TravelAlbumAIJobDetailViewModel
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let contentStack = UIStackView()
|
||||||
|
private let refreshControl = UIRefreshControl()
|
||||||
|
private let emptyView = AIJobDetailEmptyView()
|
||||||
|
private var pollingTask: Task<Void, Never>?
|
||||||
|
private var isVisible = false
|
||||||
|
private var isPresentingLoading = false
|
||||||
|
|
||||||
|
/// 创建指定批次的任务详情。
|
||||||
|
init(batchId: Int, api: (any TravelAlbumServing)? = nil) {
|
||||||
|
viewModel = TravelAlbumAIJobDetailViewModel(batchId: batchId)
|
||||||
|
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = "任务详情"
|
||||||
|
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||||
|
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||||
|
navigationItem.titleView = titleLabel
|
||||||
|
navigationItem.backButtonDisplayMode = .minimal
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
image: UIImage(systemName: "arrow.clockwise"),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(manualRefresh)
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem?.tintColor = AIJobDetailStyle.textPrimary
|
||||||
|
navigationItem.rightBarButtonItem?.accessibilityLabel = "刷新任务详情"
|
||||||
|
let appearance = UINavigationBarAppearance()
|
||||||
|
appearance.configureWithOpaqueBackground()
|
||||||
|
appearance.backgroundColor = .white
|
||||||
|
appearance.shadowColor = .clear
|
||||||
|
navigationItem.standardAppearance = appearance
|
||||||
|
navigationItem.scrollEdgeAppearance = appearance
|
||||||
|
navigationItem.compactAppearance = appearance
|
||||||
|
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AIJobDetailStyle.pageBackground
|
||||||
|
scrollView.backgroundColor = .clear
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
scrollView.refreshControl = refreshControl
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = 14
|
||||||
|
emptyView.onRetry = { [weak self] in self?.reload() }
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(contentStack)
|
||||||
|
view.addSubview(emptyView)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
scrollView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||||
|
contentStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(scrollView.contentLayoutGuide).offset(12)
|
||||||
|
make.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(18)
|
||||||
|
make.bottom.equalTo(scrollView.contentLayoutGuide).inset(24)
|
||||||
|
}
|
||||||
|
emptyView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
|
||||||
|
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
|
||||||
|
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
|
||||||
|
viewModel.onNotFound = { [weak self] in Task { @MainActor in self?.presentNotFound() } }
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(applicationBecameActive),
|
||||||
|
name: UIApplication.didBecomeActiveNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(applicationEnteredBackground),
|
||||||
|
name: UIApplication.didEnterBackgroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task { await viewModel.load(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
isVisible = true
|
||||||
|
updateLoadingPresentation()
|
||||||
|
updatePolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
isVisible = false
|
||||||
|
setLoadingPresented(false)
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
pollingTask?.cancel()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyState() {
|
||||||
|
refreshControl.endRefreshing()
|
||||||
|
let unavailable = viewModel.detail == nil && !viewModel.isLoading
|
||||||
|
emptyView.isHidden = !unavailable
|
||||||
|
emptyView.apply(
|
||||||
|
title: "任务详情加载失败",
|
||||||
|
message: viewModel.errorMessage ?? "暂时无法获取任务信息,请稍后重试。"
|
||||||
|
)
|
||||||
|
scrollView.isHidden = viewModel.detail == nil
|
||||||
|
if let detail = viewModel.detail { rebuildContent(detail) }
|
||||||
|
updateLoadingPresentation()
|
||||||
|
updatePolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func rebuildContent(_ detail: TravelAlbumAIJobDetail) {
|
||||||
|
contentStack.arrangedSubviews.forEach {
|
||||||
|
contentStack.removeArrangedSubview($0)
|
||||||
|
$0.removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusCard = AIJobDetailStatusCard()
|
||||||
|
statusCard.apply(detail)
|
||||||
|
contentStack.addArrangedSubview(statusCard)
|
||||||
|
|
||||||
|
let albumCard = AIJobDetailAlbumCard()
|
||||||
|
albumCard.apply(detail)
|
||||||
|
contentStack.addArrangedSubview(albumCard)
|
||||||
|
|
||||||
|
let contentCard = AIJobDetailContentCard()
|
||||||
|
contentCard.apply(detail)
|
||||||
|
contentStack.addArrangedSubview(contentCard)
|
||||||
|
|
||||||
|
let processingCard = AIJobDetailProcessingCard()
|
||||||
|
processingCard.apply(
|
||||||
|
detail.targets,
|
||||||
|
onViewResult: { [weak self] target in self?.openResult(target) }
|
||||||
|
)
|
||||||
|
contentStack.addArrangedSubview(processingCard)
|
||||||
|
|
||||||
|
let albumButton = AIJobDetailAlbumButton()
|
||||||
|
albumButton.addTarget(self, action: #selector(openAlbumTapped), for: .touchUpInside)
|
||||||
|
contentStack.addArrangedSubview(albumButton)
|
||||||
|
albumButton.snp.makeConstraints { $0.height.equalTo(56) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func updateLoadingPresentation() {
|
||||||
|
setLoadingPresented(isVisible && viewModel.isLoading && viewModel.detail == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func setLoadingPresented(_ presented: Bool) {
|
||||||
|
guard isPresentingLoading != presented else { return }
|
||||||
|
isPresentingLoading = presented
|
||||||
|
presented ? showLoading() : hideLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload() { Task { await viewModel.load(api: api) } }
|
||||||
|
|
||||||
|
@objc private func refreshTriggered() { Task { await viewModel.load(api: api, refreshing: true) } }
|
||||||
|
@objc private func manualRefresh() { Task { await viewModel.load(api: api, refreshing: true) } }
|
||||||
|
@objc private func applicationBecameActive() { updatePolling() }
|
||||||
|
@objc private func applicationEnteredBackground() { stopPolling() }
|
||||||
|
@objc private func openAlbumTapped() { openAlbum() }
|
||||||
|
|
||||||
|
private func updatePolling() {
|
||||||
|
guard isVisible,
|
||||||
|
UIApplication.shared.applicationState == .active,
|
||||||
|
viewModel.shouldPoll,
|
||||||
|
pollingTask == nil
|
||||||
|
else {
|
||||||
|
if !viewModel.shouldPoll { stopPolling() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pollingTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(8))
|
||||||
|
guard !Task.isCancelled, let self else { break }
|
||||||
|
await self.viewModel.load(api: self.api, silent: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopPolling() {
|
||||||
|
pollingTask?.cancel()
|
||||||
|
pollingTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func presentNotFound() {
|
||||||
|
guard presentedViewController == nil else { return }
|
||||||
|
let alert = UIAlertController(title: nil, message: "任务不存在或已失效", preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "知道了", style: .default) { [weak self] _ in
|
||||||
|
self?.replaceWithTaskList()
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replaceWithTaskList() {
|
||||||
|
guard let navigationController else { return }
|
||||||
|
if let existing = navigationController.viewControllers.first(where: { $0 is TravelAlbumAIJobListViewController }) {
|
||||||
|
navigationController.popToViewController(existing, animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var stack = navigationController.viewControllers.filter { $0 !== self }
|
||||||
|
stack.append(TravelAlbumAIJobListViewController(api: api))
|
||||||
|
navigationController.setViewControllers(stack, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openAlbum() {
|
||||||
|
guard let id = viewModel.detail?.userEquityTravelId, id > 0 else { return }
|
||||||
|
navigationController?.pushViewController(TravelAlbumDetailViewController(albumId: id, api: api), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openResult(_ target: TravelAlbumAIJobTarget) {
|
||||||
|
guard let kind = target.outputType.previewKind,
|
||||||
|
let asset = target.resultAsset,
|
||||||
|
!asset.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
else {
|
||||||
|
showToast("结果资源已失效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
var project: TravelAlbumPreviewProject
|
||||||
|
if kind == .cover {
|
||||||
|
project = TravelAlbumPreviewProject(
|
||||||
|
originalMaterialId: asset.materialId,
|
||||||
|
aiRetouchBatchId: viewModel.batchId,
|
||||||
|
assets: [TravelAlbumPreviewAsset(
|
||||||
|
id: "cover-\(asset.id)",
|
||||||
|
kind: .cover,
|
||||||
|
fileURL: asset.url,
|
||||||
|
coverURL: asset.thumbnailURL,
|
||||||
|
fileName: "AI封面",
|
||||||
|
fileSize: 0
|
||||||
|
)]
|
||||||
|
)
|
||||||
|
} else if let materialId = target.sourceMaterial?.id {
|
||||||
|
do {
|
||||||
|
let material = try await api.materialInfo(
|
||||||
|
userEquityTravelId: viewModel.detail?.userEquityTravelId ?? 0,
|
||||||
|
materialId: materialId
|
||||||
|
)
|
||||||
|
let current = TravelAlbumPreviewProject(material: material)
|
||||||
|
let result = TravelAlbumPreviewAsset(
|
||||||
|
id: "job-result-\(asset.id)",
|
||||||
|
kind: kind,
|
||||||
|
fileURL: asset.url,
|
||||||
|
coverURL: asset.thumbnailURL,
|
||||||
|
fileName: material.fileName,
|
||||||
|
fileSize: material.fileSize
|
||||||
|
)
|
||||||
|
project = TravelAlbumPreviewProject(
|
||||||
|
originalMaterialId: material.id,
|
||||||
|
aiRetouchBatchId: viewModel.batchId,
|
||||||
|
assets: current.assets + [result]
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { self.showToast("结果资源加载失败,请前往相册查看") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await MainActor.run { self.showToast("结果资源已失效") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
self.present(
|
||||||
|
TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: [project],
|
||||||
|
totalCount: 1,
|
||||||
|
startProjectIndex: 0,
|
||||||
|
startKind: kind,
|
||||||
|
allowsActions: false
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图详情页专用视觉常量。
|
||||||
|
private enum AIJobDetailStyle {
|
||||||
|
static let pageBackground = UIColor(hex: 0xF5F7FB)
|
||||||
|
static let textPrimary = UIColor(hex: 0x111827)
|
||||||
|
static let textSecondary = UIColor(hex: 0x64748B)
|
||||||
|
static let border = UIColor(hex: 0xE4EAF3)
|
||||||
|
|
||||||
|
static func styleCard(_ view: UIView) {
|
||||||
|
view.backgroundColor = .white
|
||||||
|
view.layer.cornerRadius = 14
|
||||||
|
view.layer.borderWidth = 1
|
||||||
|
view.layer.borderColor = border.cgColor
|
||||||
|
view.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.08).cgColor
|
||||||
|
view.layer.shadowOpacity = 1
|
||||||
|
view.layer.shadowRadius = 8
|
||||||
|
view.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务状态总览卡,包含环形状态、完成进度、ETA 和通知说明。
|
||||||
|
private final class AIJobDetailStatusCard: UIView {
|
||||||
|
private let ringView = AIJobCircularProgressView()
|
||||||
|
private let statusLabel = UILabel()
|
||||||
|
private let completedLabel = UILabel()
|
||||||
|
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||||
|
private let percentLabel = UILabel()
|
||||||
|
private let timeIcon = UIImageView(image: UIImage(systemName: "clock"))
|
||||||
|
private let timeLabel = UILabel()
|
||||||
|
private let notificationIcon = UIImageView(image: UIImage(systemName: "bell"))
|
||||||
|
private let notificationLabel = UILabel()
|
||||||
|
private let timeRow = UIStackView()
|
||||||
|
private let notificationRow = UIStackView()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
AIJobDetailStyle.styleCard(self)
|
||||||
|
accessibilityIdentifier = "aiRetouchJob.detail.statusCard"
|
||||||
|
statusLabel.font = .systemFont(ofSize: 23, weight: .semibold)
|
||||||
|
completedLabel.font = .systemFont(ofSize: 15)
|
||||||
|
completedLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
progressView.trackTintColor = UIColor(hex: 0xE5EAF2)
|
||||||
|
progressView.layer.cornerRadius = 3
|
||||||
|
progressView.clipsToBounds = true
|
||||||
|
percentLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
percentLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
percentLabel.textAlignment = .right
|
||||||
|
[timeIcon, notificationIcon].forEach {
|
||||||
|
$0.tintColor = AppColor.primary
|
||||||
|
$0.contentMode = .scaleAspectFit
|
||||||
|
$0.snp.makeConstraints { $0.size.equalTo(17) }
|
||||||
|
}
|
||||||
|
timeLabel.font = .systemFont(ofSize: 14)
|
||||||
|
timeLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
notificationLabel.font = .systemFont(ofSize: 14)
|
||||||
|
notificationLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
timeRow.axis = .horizontal
|
||||||
|
timeRow.spacing = 8
|
||||||
|
timeRow.alignment = .center
|
||||||
|
timeRow.addArrangedSubview(timeIcon)
|
||||||
|
timeRow.addArrangedSubview(timeLabel)
|
||||||
|
notificationRow.axis = .horizontal
|
||||||
|
notificationRow.spacing = 8
|
||||||
|
notificationRow.alignment = .center
|
||||||
|
notificationRow.addArrangedSubview(notificationIcon)
|
||||||
|
notificationRow.addArrangedSubview(notificationLabel)
|
||||||
|
|
||||||
|
let progressRow = UIStackView(arrangedSubviews: [progressView, percentLabel])
|
||||||
|
progressRow.axis = .horizontal
|
||||||
|
progressRow.spacing = 10
|
||||||
|
progressRow.alignment = .center
|
||||||
|
progressView.snp.makeConstraints { $0.height.equalTo(7) }
|
||||||
|
percentLabel.snp.makeConstraints { $0.width.greaterThanOrEqualTo(38) }
|
||||||
|
let rightStack = UIStackView(arrangedSubviews: [statusLabel, completedLabel, progressRow, timeRow, notificationRow])
|
||||||
|
rightStack.axis = .vertical
|
||||||
|
rightStack.spacing = 7
|
||||||
|
addSubview(ringView)
|
||||||
|
addSubview(rightStack)
|
||||||
|
ringView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(92)
|
||||||
|
}
|
||||||
|
rightStack.snp.makeConstraints { make in
|
||||||
|
make.top.bottom.equalToSuperview().inset(16)
|
||||||
|
make.leading.equalTo(ringView.snp.trailing).offset(20)
|
||||||
|
make.trailing.equalToSuperview().inset(18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func apply(_ detail: TravelAlbumAIJobDetail) {
|
||||||
|
let color = detail.status.semanticColor
|
||||||
|
statusLabel.text = detail.status.title
|
||||||
|
statusLabel.textColor = color
|
||||||
|
completedLabel.text = "已完成 \(detail.progress.completed) / \(detail.progress.total)"
|
||||||
|
progressView.progressTintColor = color
|
||||||
|
progressView.progress = Float(detail.progress.fraction)
|
||||||
|
percentLabel.text = "\(Int((detail.progress.fraction * 100).rounded()))%"
|
||||||
|
ringView.apply(progress: detail.progress.fraction, color: color, status: detail.status)
|
||||||
|
if detail.status.isInProgress {
|
||||||
|
timeLabel.text = detail.estimatedFinishAt.map {
|
||||||
|
"预计 \(TravelAlbumAIJobDateFormatter.time($0)) 前完成"
|
||||||
|
} ?? "完成时间暂无法预估"
|
||||||
|
notificationLabel.text = "完成后将通过消息通知你"
|
||||||
|
notificationRow.isHidden = false
|
||||||
|
} else {
|
||||||
|
timeLabel.text = "完成时间 \(TravelAlbumAIJobDateFormatter.display(detail.finishedAt))"
|
||||||
|
notificationRow.isHidden = true
|
||||||
|
}
|
||||||
|
accessibilityLabel = [statusLabel.text, completedLabel.text, percentLabel.text, timeLabel.text, notificationLabel.text]
|
||||||
|
.compactMap { $0 }.joined(separator: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 环形进度组件,使用图形、文字与颜色共同表达任务状态。
|
||||||
|
private final class AIJobCircularProgressView: UIView {
|
||||||
|
private let trackLayer = CAShapeLayer()
|
||||||
|
private let progressLayer = CAShapeLayer()
|
||||||
|
private let iconView = UIImageView(image: UIImage(systemName: "sparkles"))
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
layer.addSublayer(trackLayer)
|
||||||
|
layer.addSublayer(progressLayer)
|
||||||
|
[trackLayer, progressLayer].forEach {
|
||||||
|
$0.fillColor = UIColor.clear.cgColor
|
||||||
|
$0.lineWidth = 8
|
||||||
|
$0.lineCap = .round
|
||||||
|
}
|
||||||
|
trackLayer.strokeColor = UIColor(hex: 0xE7F0FF).cgColor
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
addSubview(iconView)
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(37)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||||
|
let radius = max(0, min(bounds.width, bounds.height) / 2 - 6)
|
||||||
|
let path = UIBezierPath(
|
||||||
|
arcCenter: center,
|
||||||
|
radius: radius,
|
||||||
|
startAngle: -.pi / 2,
|
||||||
|
endAngle: .pi * 1.5,
|
||||||
|
clockwise: true
|
||||||
|
).cgPath
|
||||||
|
trackLayer.path = path
|
||||||
|
progressLayer.path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(progress: Double, color: UIColor, status: TravelAlbumAIJobStatus) {
|
||||||
|
progressLayer.strokeColor = color.cgColor
|
||||||
|
progressLayer.strokeEnd = max(0.04, min(1, progress))
|
||||||
|
iconView.tintColor = color
|
||||||
|
iconView.image = UIImage(systemName: status.isInProgress ? "sparkles" : status == .succeeded ? "checkmark" : "exclamationmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相册与任务信息卡,匹配设计稿中的封面、账号与提交信息结构。
|
||||||
|
private final class AIJobDetailAlbumCard: UIView {
|
||||||
|
private let coverView = UIImageView()
|
||||||
|
private let albumLabel = UILabel()
|
||||||
|
private let phoneLabel = UILabel()
|
||||||
|
private let taskLabel = UILabel()
|
||||||
|
private let submitLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
AIJobDetailStyle.styleCard(self)
|
||||||
|
accessibilityIdentifier = "aiRetouchJob.detail.albumCard"
|
||||||
|
coverView.contentMode = .scaleAspectFill
|
||||||
|
coverView.clipsToBounds = true
|
||||||
|
coverView.layer.cornerRadius = 12
|
||||||
|
coverView.backgroundColor = AppColor.pageBackground
|
||||||
|
coverView.tintColor = AppColor.textTertiary
|
||||||
|
albumLabel.font = .systemFont(ofSize: 19, weight: .semibold)
|
||||||
|
albumLabel.textColor = AIJobDetailStyle.textPrimary
|
||||||
|
phoneLabel.font = .systemFont(ofSize: 14)
|
||||||
|
phoneLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
taskLabel.font = .systemFont(ofSize: 14)
|
||||||
|
taskLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
submitLabel.font = .systemFont(ofSize: 14)
|
||||||
|
submitLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
addSubview(coverView)
|
||||||
|
addSubview(albumLabel)
|
||||||
|
addSubview(phoneLabel)
|
||||||
|
addSubview(taskLabel)
|
||||||
|
addSubview(submitLabel)
|
||||||
|
coverView.snp.makeConstraints { make in
|
||||||
|
make.leading.top.bottom.equalToSuperview().inset(16)
|
||||||
|
make.size.equalTo(84)
|
||||||
|
}
|
||||||
|
albumLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(coverView.snp.trailing).offset(14)
|
||||||
|
make.top.equalToSuperview().offset(19)
|
||||||
|
make.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
phoneLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(albumLabel)
|
||||||
|
make.top.equalTo(albumLabel.snp.bottom).offset(7)
|
||||||
|
}
|
||||||
|
taskLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(albumLabel)
|
||||||
|
make.bottom.equalToSuperview().inset(19)
|
||||||
|
}
|
||||||
|
submitLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.greaterThanOrEqualTo(taskLabel.snp.trailing).offset(12)
|
||||||
|
make.trailing.equalToSuperview().inset(16)
|
||||||
|
make.centerY.equalTo(taskLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func apply(_ detail: TravelAlbumAIJobDetail) {
|
||||||
|
coverView.kf.setImage(with: URL(string: detail.album.coverURL), placeholder: UIImage(systemName: "photo"))
|
||||||
|
albumLabel.text = detail.album.name.isEmpty ? "未命名相册" : detail.album.name
|
||||||
|
let phone = TravelAlbumDisplayFormatter.maskPhone(detail.album.userPhone)
|
||||||
|
phoneLabel.text = phone.isEmpty ? "未提供手机号" : phone
|
||||||
|
taskLabel.text = "任务 #\(detail.aiRetouchBatchId)"
|
||||||
|
submitLabel.text = "\(TravelAlbumAIJobDateFormatter.display(detail.createdAt)) 提交"
|
||||||
|
accessibilityLabel = [albumLabel.text, phoneLabel.text, taskLabel.text, submitLabel.text]
|
||||||
|
.compactMap { $0 }.joined(separator: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务内容卡,以三色紧凑标签展示各输出目标数量并补充额度结算。
|
||||||
|
private final class AIJobDetailContentCard: UIView {
|
||||||
|
private let outputStack = UIStackView()
|
||||||
|
private let quotaLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
AIJobDetailStyle.styleCard(self)
|
||||||
|
accessibilityIdentifier = "aiRetouchJob.detail.contentCard"
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = "任务内容"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||||
|
outputStack.axis = .horizontal
|
||||||
|
outputStack.spacing = 10
|
||||||
|
outputStack.distribution = .fill
|
||||||
|
outputStack.alignment = .center
|
||||||
|
quotaLabel.font = .systemFont(ofSize: 12)
|
||||||
|
quotaLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
quotaLabel.numberOfLines = 0
|
||||||
|
let stack = UIStackView(arrangedSubviews: [titleLabel, outputStack, quotaLabel])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 14
|
||||||
|
addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func apply(_ detail: TravelAlbumAIJobDetail) {
|
||||||
|
outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
detail.outputs.forEach { outputStack.addArrangedSubview(makeChip($0)) }
|
||||||
|
if let lastChip = outputStack.arrangedSubviews.last {
|
||||||
|
let spacer = UIView()
|
||||||
|
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
|
outputStack.setCustomSpacing(0, after: lastChip)
|
||||||
|
outputStack.addArrangedSubview(spacer)
|
||||||
|
}
|
||||||
|
outputStack.isHidden = detail.outputs.isEmpty
|
||||||
|
let quota = detail.quotaSettlement
|
||||||
|
quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)"
|
||||||
|
accessibilityLabel = ["任务内容", detail.outputs.map { "\($0.type.title) \($0.count)张" }.joined(separator: ","), quotaLabel.text]
|
||||||
|
.compactMap { $0 }.joined(separator: ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeChip(_ output: TravelAlbumAIJobOutput) -> UIView {
|
||||||
|
AIJobDetailOutputChip(
|
||||||
|
text: "\(output.type.shortTitle) \(output.count)张",
|
||||||
|
backgroundColor: output.type.chipBackgroundColor,
|
||||||
|
accessibilityIdentifier: "aiRetouchJob.contentChip.\(output.type.chipIdentifier)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依据文字固有宽度展示的任务输出类型标签。
|
||||||
|
private final class AIJobDetailOutputChip: UIView {
|
||||||
|
private let label = UILabel()
|
||||||
|
|
||||||
|
init(text: String, backgroundColor: UIColor, accessibilityIdentifier: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
self.backgroundColor = backgroundColor
|
||||||
|
self.accessibilityIdentifier = accessibilityIdentifier
|
||||||
|
layer.cornerRadius = 9
|
||||||
|
setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
label.text = text
|
||||||
|
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
label.textColor = AIJobDetailStyle.textPrimary
|
||||||
|
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
addSubview(label)
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
snp.makeConstraints { $0.height.equalTo(44) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
CGSize(width: ceil(label.intrinsicContentSize.width) + 24, height: 44)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理明细卡,将逐照片、逐输出状态及失败原因放在同一卡片中展示。
|
||||||
|
private final class AIJobDetailProcessingCard: UIView {
|
||||||
|
private let rowsStack = UIStackView()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
AIJobDetailStyle.styleCard(self)
|
||||||
|
accessibilityIdentifier = "aiRetouchJob.detail.processingCard"
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = "处理明细"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||||
|
rowsStack.axis = .vertical
|
||||||
|
rowsStack.spacing = 0
|
||||||
|
let stack = UIStackView(arrangedSubviews: [titleLabel, rowsStack])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func apply(
|
||||||
|
_ targets: [TravelAlbumAIJobTarget],
|
||||||
|
onViewResult: @escaping (TravelAlbumAIJobTarget) -> Void
|
||||||
|
) {
|
||||||
|
rowsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
if targets.isEmpty {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "暂无处理明细"
|
||||||
|
label.font = .systemFont(ofSize: 14)
|
||||||
|
label.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.snp.makeConstraints { $0.height.equalTo(52) }
|
||||||
|
rowsStack.addArrangedSubview(label)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (index, target) in targets.enumerated() {
|
||||||
|
let row = AIJobDetailTargetRow()
|
||||||
|
row.apply(target)
|
||||||
|
row.onViewResult = { onViewResult(target) }
|
||||||
|
rowsStack.addArrangedSubview(row)
|
||||||
|
if index < targets.count - 1 {
|
||||||
|
let separator = UIView()
|
||||||
|
separator.backgroundColor = AIJobDetailStyle.border
|
||||||
|
separator.snp.makeConstraints { $0.height.equalTo(1) }
|
||||||
|
rowsStack.addArrangedSubview(separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个输出明细行,内联展示结果操作或后端返回的失败原因。
|
||||||
|
private final class AIJobDetailTargetRow: UIView {
|
||||||
|
private let thumbnailView = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let templateLabel = UILabel()
|
||||||
|
private let statusIconView = UIImageView()
|
||||||
|
private let statusLabel = UILabel()
|
||||||
|
private let statusStack = UIStackView()
|
||||||
|
private let statusRow = UIStackView()
|
||||||
|
private let contentStack = UIStackView()
|
||||||
|
private let resultButton = UIButton(type: .system)
|
||||||
|
private let errorContainer = UIView()
|
||||||
|
private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
|
||||||
|
private let errorLabel = UILabel()
|
||||||
|
var onViewResult: (() -> Void)?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
thumbnailView.contentMode = .scaleAspectFill
|
||||||
|
thumbnailView.clipsToBounds = true
|
||||||
|
thumbnailView.layer.cornerRadius = 10
|
||||||
|
thumbnailView.backgroundColor = AppColor.pageBackground
|
||||||
|
thumbnailView.tintColor = AppColor.textTertiary
|
||||||
|
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||||
|
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||||
|
titleLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
|
titleLabel.numberOfLines = 1
|
||||||
|
templateLabel.font = .systemFont(ofSize: 13)
|
||||||
|
templateLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
templateLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
templateLabel.numberOfLines = 1
|
||||||
|
statusIconView.contentMode = .scaleAspectFit
|
||||||
|
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
resultButton.setTitle("查看结果", for: .normal)
|
||||||
|
resultButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
|
resultButton.addTarget(self, action: #selector(viewResultTapped), for: .touchUpInside)
|
||||||
|
errorContainer.backgroundColor = AppColor.dangerBackground
|
||||||
|
errorContainer.layer.cornerRadius = 8
|
||||||
|
errorIconView.tintColor = AppColor.danger
|
||||||
|
errorLabel.font = .systemFont(ofSize: 13)
|
||||||
|
errorLabel.textColor = AppColor.danger
|
||||||
|
errorLabel.numberOfLines = 0
|
||||||
|
|
||||||
|
statusStack.addArrangedSubview(statusIconView)
|
||||||
|
statusStack.addArrangedSubview(statusLabel)
|
||||||
|
statusStack.axis = .horizontal
|
||||||
|
statusStack.spacing = 6
|
||||||
|
statusStack.alignment = .center
|
||||||
|
statusStack.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
statusLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
let statusSpacer = UIView()
|
||||||
|
statusRow.addArrangedSubview(statusStack)
|
||||||
|
statusRow.addArrangedSubview(statusSpacer)
|
||||||
|
statusRow.addArrangedSubview(resultButton)
|
||||||
|
statusRow.axis = .horizontal
|
||||||
|
statusRow.spacing = 8
|
||||||
|
statusRow.alignment = .center
|
||||||
|
contentStack.addArrangedSubview(titleLabel)
|
||||||
|
contentStack.addArrangedSubview(templateLabel)
|
||||||
|
contentStack.addArrangedSubview(statusRow)
|
||||||
|
contentStack.addArrangedSubview(errorContainer)
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = 0
|
||||||
|
contentStack.setCustomSpacing(7, after: titleLabel)
|
||||||
|
contentStack.setCustomSpacing(8, after: templateLabel)
|
||||||
|
contentStack.setCustomSpacing(12, after: statusRow)
|
||||||
|
statusIconView.snp.makeConstraints { $0.size.equalTo(18) }
|
||||||
|
addSubview(thumbnailView)
|
||||||
|
addSubview(contentStack)
|
||||||
|
errorContainer.addSubview(errorIconView)
|
||||||
|
errorContainer.addSubview(errorLabel)
|
||||||
|
|
||||||
|
thumbnailView.snp.makeConstraints { make in
|
||||||
|
make.leading.top.equalToSuperview().offset(4)
|
||||||
|
make.size.equalTo(80)
|
||||||
|
make.bottom.lessThanOrEqualToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
contentStack.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(thumbnailView.snp.trailing).offset(14)
|
||||||
|
make.top.equalTo(thumbnailView).offset(18)
|
||||||
|
make.trailing.equalToSuperview().inset(4)
|
||||||
|
make.bottom.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
errorIconView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(10)
|
||||||
|
make.top.equalToSuperview().offset(11)
|
||||||
|
make.size.equalTo(16)
|
||||||
|
}
|
||||||
|
errorLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(errorIconView.snp.trailing).offset(7)
|
||||||
|
make.top.bottom.equalToSuperview().inset(9)
|
||||||
|
make.trailing.equalToSuperview().inset(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func apply(_ target: TravelAlbumAIJobTarget) {
|
||||||
|
let thumbnail = target.sourceMaterial?.thumbnailURL ?? target.resultAsset?.thumbnailURL ?? ""
|
||||||
|
thumbnailView.kf.setImage(with: URL(string: thumbnail), placeholder: UIImage(systemName: "photo"))
|
||||||
|
titleLabel.text = target.outputType == .cover
|
||||||
|
? "相册封面"
|
||||||
|
: (target.sourceMaterial?.fileName.isEmpty == false ? target.sourceMaterial?.fileName : "照片 \(target.sourceMaterial?.id ?? 0)")
|
||||||
|
templateLabel.text = target.template?.name ?? target.outputType.title
|
||||||
|
let color = target.status.semanticColor
|
||||||
|
statusIconView.tintColor = color
|
||||||
|
statusLabel.textColor = color
|
||||||
|
statusIconView.image = UIImage(systemName: target.status.symbolName)
|
||||||
|
switch target.status {
|
||||||
|
case .succeeded:
|
||||||
|
statusLabel.text = "\(target.outputType.shortTitle)完成"
|
||||||
|
case .processing:
|
||||||
|
statusLabel.text = "正在生成"
|
||||||
|
case .queued:
|
||||||
|
statusLabel.text = "等待处理"
|
||||||
|
case .failed:
|
||||||
|
statusLabel.text = "生成失败"
|
||||||
|
default:
|
||||||
|
statusLabel.text = target.status.title
|
||||||
|
}
|
||||||
|
let hasResult = target.status == .succeeded && target.resultAsset?.url.isEmpty == false
|
||||||
|
resultButton.isHidden = !hasResult
|
||||||
|
errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" }
|
||||||
|
let showsError = target.displayFailureMessage != nil
|
||||||
|
errorContainer.isHidden = !showsError
|
||||||
|
titleLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).title"
|
||||||
|
templateLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).template"
|
||||||
|
statusStack.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).status"
|
||||||
|
accessibilityLabel = [titleLabel.text, templateLabel.text, statusLabel.text, errorLabel.text]
|
||||||
|
.compactMap { $0 }.joined(separator: ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func viewResultTapped() { onViewResult?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 页面底部的查看相册主操作按钮。
|
||||||
|
private final class AIJobDetailAlbumButton: UIButton {
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
var configuration = UIButton.Configuration.plain()
|
||||||
|
configuration.title = "查看相册"
|
||||||
|
configuration.image = UIImage(systemName: "photo")
|
||||||
|
configuration.imagePadding = 10
|
||||||
|
configuration.baseForegroundColor = AppColor.primary
|
||||||
|
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||||
|
var outgoing = incoming
|
||||||
|
outgoing.font = .systemFont(ofSize: 18, weight: .medium)
|
||||||
|
return outgoing
|
||||||
|
}
|
||||||
|
self.configuration = configuration
|
||||||
|
layer.cornerRadius = 10
|
||||||
|
layer.borderWidth = 1.5
|
||||||
|
layer.borderColor = AppColor.primary.cgColor
|
||||||
|
backgroundColor = .white
|
||||||
|
accessibilityIdentifier = "aiRetouchJob.detail.albumButton"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务详情加载失败时的空态。
|
||||||
|
private final class AIJobDetailEmptyView: UIView {
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let messageLabel = UILabel()
|
||||||
|
private let button = UIButton(type: .system)
|
||||||
|
var onRetry: (() -> Void)?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
messageLabel.font = .systemFont(ofSize: 14)
|
||||||
|
messageLabel.textColor = AppColor.textSecondary
|
||||||
|
messageLabel.textAlignment = .center
|
||||||
|
messageLabel.numberOfLines = 0
|
||||||
|
button.setTitle("重新加载", for: .normal)
|
||||||
|
button.addTarget(self, action: #selector(retry), for: .touchUpInside)
|
||||||
|
let stack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, button])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.alignment = .center
|
||||||
|
addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func apply(title: String, message: String) { titleLabel.text = title; messageLabel.text = message }
|
||||||
|
@objc private func retry() { onRetry?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension TravelAlbumAIJobStatus {
|
||||||
|
var semanticColor: UIColor {
|
||||||
|
switch self {
|
||||||
|
case .queued, .processing: AppColor.primary
|
||||||
|
case .succeeded: AppColor.success
|
||||||
|
case .partiallySucceeded: AppColor.warning
|
||||||
|
case .failed: AppColor.danger
|
||||||
|
case .canceled, .unknown: AppColor.textSecondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .queued: "clock"
|
||||||
|
case .processing: "arrow.triangle.2.circlepath"
|
||||||
|
case .succeeded: "checkmark.circle.fill"
|
||||||
|
case .partiallySucceeded: "exclamationmark.circle.fill"
|
||||||
|
case .failed: "exclamationmark.circle"
|
||||||
|
case .canceled, .unknown: "minus.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension TravelAlbumAIJobOutputType {
|
||||||
|
var shortTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "精修"
|
||||||
|
case .atmosphere: "氛围感"
|
||||||
|
case .cover: "封面"
|
||||||
|
case .unknown: "其他"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chipBackgroundColor: UIColor {
|
||||||
|
switch self {
|
||||||
|
case .refined: UIColor(hex: 0xEAF3FF)
|
||||||
|
case .atmosphere: UIColor(hex: 0xFFF2DC)
|
||||||
|
case .cover: UIColor(hex: 0xF2ECFF)
|
||||||
|
case .unknown: UIColor(hex: 0xF1F5F9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chipIdentifier: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "refined"
|
||||||
|
case .atmosphere: "atmosphere"
|
||||||
|
case .cover: "cover"
|
||||||
|
case .unknown: "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 当前账号的 AI 修图任务中心,提供筛选、游标分页、刷新与状态轮询。
|
||||||
|
final class TravelAlbumAIJobListViewController: BaseViewController {
|
||||||
|
private enum Section { case main }
|
||||||
|
|
||||||
|
private let viewModel = TravelAlbumAIJobListViewModel()
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
|
private let filterContainer = UIView()
|
||||||
|
private let filterStack = UIStackView()
|
||||||
|
private var filterButtons: [TravelAlbumAIJobFilter: UIButton] = [:]
|
||||||
|
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||||
|
private let refreshControl = UIRefreshControl()
|
||||||
|
private let emptyView = AIJobEmptyView()
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Section, TravelAlbumAIJobSummary>!
|
||||||
|
private var pollingTask: Task<Void, Never>?
|
||||||
|
private var isVisible = false
|
||||||
|
private var isPresentingLoading = false
|
||||||
|
|
||||||
|
/// 创建任务中心,可注入 API 以支持测试。
|
||||||
|
init(api: (any TravelAlbumServing)? = nil) {
|
||||||
|
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = "AI修图任务"
|
||||||
|
titleLabel.textColor = UIColor(hex: 0x0F1F3D)
|
||||||
|
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||||
|
navigationItem.titleView = titleLabel
|
||||||
|
navigationItem.backButtonDisplayMode = .minimal
|
||||||
|
let appearance = UINavigationBarAppearance()
|
||||||
|
appearance.configureWithOpaqueBackground()
|
||||||
|
appearance.backgroundColor = .white
|
||||||
|
appearance.shadowColor = .clear
|
||||||
|
navigationItem.standardAppearance = appearance
|
||||||
|
navigationItem.scrollEdgeAppearance = appearance
|
||||||
|
navigationItem.compactAppearance = appearance
|
||||||
|
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AppColor.pageBackgroundSoft
|
||||||
|
configureFilters()
|
||||||
|
collectionView.backgroundColor = .clear
|
||||||
|
collectionView.alwaysBounceVertical = true
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.refreshControl = refreshControl
|
||||||
|
collectionView.register(AIJobSummaryCell.self, forCellWithReuseIdentifier: AIJobSummaryCell.reuseIdentifier)
|
||||||
|
dataSource = UICollectionViewDiffableDataSource<Section, TravelAlbumAIJobSummary>(collectionView: collectionView) {
|
||||||
|
collectionView, indexPath, item in
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: AIJobSummaryCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! AIJobSummaryCell
|
||||||
|
cell.apply(item)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
emptyView.onRetry = { [weak self] in self?.reload() }
|
||||||
|
view.addSubview(filterContainer)
|
||||||
|
filterContainer.addSubview(filterStack)
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
view.addSubview(emptyView)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
filterContainer.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(20)
|
||||||
|
make.height.equalTo(46)
|
||||||
|
}
|
||||||
|
filterStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(2)
|
||||||
|
}
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(filterContainer.snp.bottom).offset(14)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
emptyView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(filterContainer.snp.bottom)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
|
||||||
|
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
|
||||||
|
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(applicationBecameActive),
|
||||||
|
name: UIApplication.didBecomeActiveNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(applicationEnteredBackground),
|
||||||
|
name: UIApplication.didEnterBackgroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task { await viewModel.loadFirstPage(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
isVisible = true
|
||||||
|
updateLoadingPresentation()
|
||||||
|
updatePolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
isVisible = false
|
||||||
|
setLoadingPresented(false)
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
pollingTask?.cancel()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureFilters() {
|
||||||
|
filterContainer.accessibilityIdentifier = "aiRetouchJob.filterContainer"
|
||||||
|
filterContainer.backgroundColor = .white
|
||||||
|
filterContainer.layer.cornerRadius = 12
|
||||||
|
filterContainer.layer.borderWidth = 1
|
||||||
|
filterContainer.layer.borderColor = UIColor(hex: 0xE8EEF7).cgColor
|
||||||
|
filterContainer.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.12).cgColor
|
||||||
|
filterContainer.layer.shadowOpacity = 1
|
||||||
|
filterContainer.layer.shadowRadius = 7
|
||||||
|
filterContainer.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||||
|
filterStack.axis = .horizontal
|
||||||
|
filterStack.spacing = 0
|
||||||
|
filterStack.distribution = .fillEqually
|
||||||
|
TravelAlbumAIJobFilter.allCases.forEach { filter in
|
||||||
|
var configuration = UIButton.Configuration.plain()
|
||||||
|
configuration.title = filter.title
|
||||||
|
configuration.contentInsets = .zero
|
||||||
|
let button = UIButton(configuration: configuration)
|
||||||
|
button.tag = TravelAlbumAIJobFilter.allCases.firstIndex(of: filter) ?? 0
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
|
||||||
|
button.layer.cornerRadius = 10
|
||||||
|
button.addTarget(self, action: #selector(filterTapped(_:)), for: .touchUpInside)
|
||||||
|
button.accessibilityLabel = "筛选:\(filter.title)"
|
||||||
|
filterButtons[filter] = button
|
||||||
|
filterStack.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeLayout() -> UICollectionViewLayout {
|
||||||
|
UICollectionViewCompositionalLayout { _, environment in
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: .init(
|
||||||
|
widthDimension: .fractionalWidth(1),
|
||||||
|
heightDimension: .estimated(250)
|
||||||
|
))
|
||||||
|
let group = NSCollectionLayoutGroup.vertical(
|
||||||
|
layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(250)),
|
||||||
|
subitems: [item]
|
||||||
|
)
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.interGroupSpacing = 14
|
||||||
|
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 24, trailing: 20)
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyState() {
|
||||||
|
filterButtons.forEach { filter, button in
|
||||||
|
let selected = filter == viewModel.selectedFilter
|
||||||
|
button.configuration?.baseForegroundColor = selected ? .white : AppColor.textSecondary
|
||||||
|
button.configuration?.background.backgroundColor = .clear
|
||||||
|
button.backgroundColor = selected ? AppColor.primary : .clear
|
||||||
|
button.accessibilityTraits = selected ? [.button, .selected] : .button
|
||||||
|
}
|
||||||
|
refreshControl.endRefreshing()
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, TravelAlbumAIJobSummary>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
snapshot.appendItems(viewModel.items)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
let empty = viewModel.items.isEmpty && !viewModel.isLoading
|
||||||
|
emptyView.isHidden = !empty
|
||||||
|
emptyView.apply(
|
||||||
|
title: viewModel.errorMessage == nil ? "暂无AI修图任务" : "任务加载失败",
|
||||||
|
message: viewModel.errorMessage ?? "提交AI修图后,可以在这里查看进度和结果。",
|
||||||
|
showsRetry: viewModel.errorMessage != nil
|
||||||
|
)
|
||||||
|
updateLoadingPresentation()
|
||||||
|
updatePolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func updateLoadingPresentation() {
|
||||||
|
setLoadingPresented(isVisible && viewModel.isLoading && viewModel.items.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func setLoadingPresented(_ presented: Bool) {
|
||||||
|
guard isPresentingLoading != presented else { return }
|
||||||
|
isPresentingLoading = presented
|
||||||
|
if presented {
|
||||||
|
showLoading()
|
||||||
|
} else {
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload() { Task { await viewModel.loadFirstPage(api: api) } }
|
||||||
|
|
||||||
|
@objc private func refreshTriggered() {
|
||||||
|
Task { await viewModel.loadFirstPage(api: api, refreshing: true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func filterTapped(_ sender: UIButton) {
|
||||||
|
guard TravelAlbumAIJobFilter.allCases.indices.contains(sender.tag) else { return }
|
||||||
|
Task { await viewModel.selectFilter(TravelAlbumAIJobFilter.allCases[sender.tag], api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func applicationBecameActive() { updatePolling() }
|
||||||
|
@objc private func applicationEnteredBackground() { stopPolling() }
|
||||||
|
|
||||||
|
private func updatePolling() {
|
||||||
|
guard isVisible,
|
||||||
|
UIApplication.shared.applicationState == .active,
|
||||||
|
viewModel.containsInProgressJobs,
|
||||||
|
pollingTask == nil
|
||||||
|
else {
|
||||||
|
if !viewModel.containsInProgressJobs { stopPolling() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pollingTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(15))
|
||||||
|
guard !Task.isCancelled, let self else { break }
|
||||||
|
await self.viewModel.loadFirstPage(api: self.api, silent: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopPolling() {
|
||||||
|
pollingTask?.cancel()
|
||||||
|
pollingTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIJobListViewController: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
TravelAlbumAIJobDetailViewController(batchId: item.aiRetouchBatchId, api: api),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
guard scrollView.contentSize.height > 0,
|
||||||
|
scrollView.contentOffset.y + scrollView.bounds.height > scrollView.contentSize.height - 240
|
||||||
|
else { return }
|
||||||
|
Task { await viewModel.loadMore(api: api) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务列表卡片。
|
||||||
|
private final class AIJobSummaryCell: UICollectionViewCell {
|
||||||
|
static let reuseIdentifier = "AIJobSummaryCell"
|
||||||
|
private let topInfoView = UIView()
|
||||||
|
private let albumCoverImageView = UIImageView()
|
||||||
|
private let albumLabel = UILabel()
|
||||||
|
private let phoneLabel = UILabel()
|
||||||
|
private let taskLabel = UILabel()
|
||||||
|
private let timeIconView = UIImageView(image: UIImage(systemName: "clock"))
|
||||||
|
private let timeLabel = UILabel()
|
||||||
|
private let timeStack = UIStackView()
|
||||||
|
private let statusBadge = AIJobStatusBadgeView()
|
||||||
|
private let previewStack = UIStackView()
|
||||||
|
private var previewImageViews: [UIImageView] = []
|
||||||
|
private let progressRow = UIStackView()
|
||||||
|
private let completedLabel = UILabel()
|
||||||
|
private let outputLabel = UILabel()
|
||||||
|
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||||
|
private let percentLabel = UILabel()
|
||||||
|
private let etaLabel = UILabel()
|
||||||
|
private let bottomRow = UIView()
|
||||||
|
private let outputPill = UIView()
|
||||||
|
private let outputIconView = UIImageView(image: UIImage(systemName: "wand.and.stars"))
|
||||||
|
private let detailsLabel = UILabel()
|
||||||
|
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.right"))
|
||||||
|
private let contentStack = UIStackView()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = .white
|
||||||
|
contentView.layer.cornerRadius = 16
|
||||||
|
contentView.layer.borderWidth = 1
|
||||||
|
contentView.layer.borderColor = UIColor(hex: 0xE6EDF8).cgColor
|
||||||
|
contentView.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.1).cgColor
|
||||||
|
contentView.layer.shadowOpacity = 1
|
||||||
|
contentView.layer.shadowRadius = 9
|
||||||
|
contentView.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||||
|
|
||||||
|
configureTopInfo()
|
||||||
|
configurePreviewGrid()
|
||||||
|
|
||||||
|
completedLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
completedLabel.textColor = UIColor(hex: 0x263754)
|
||||||
|
progressView.tintColor = AppColor.primary
|
||||||
|
progressView.trackTintColor = UIColor(hex: 0xE5EAF2)
|
||||||
|
progressView.layer.cornerRadius = 3
|
||||||
|
progressView.clipsToBounds = true
|
||||||
|
percentLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
percentLabel.textColor = UIColor(hex: 0x263754)
|
||||||
|
percentLabel.textAlignment = .right
|
||||||
|
progressRow.axis = .horizontal
|
||||||
|
progressRow.alignment = .center
|
||||||
|
progressRow.spacing = 10
|
||||||
|
progressRow.addArrangedSubview(completedLabel)
|
||||||
|
progressRow.addArrangedSubview(progressView)
|
||||||
|
progressRow.addArrangedSubview(percentLabel)
|
||||||
|
progressView.snp.makeConstraints { $0.height.equalTo(6) }
|
||||||
|
completedLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
percentLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
|
||||||
|
etaLabel.font = .systemFont(ofSize: 13)
|
||||||
|
etaLabel.textColor = UIColor(hex: 0x7F8CA3)
|
||||||
|
configureBottomRow()
|
||||||
|
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = 12
|
||||||
|
[topInfoView, previewStack, progressRow, etaLabel, bottomRow].forEach {
|
||||||
|
contentStack.addArrangedSubview($0)
|
||||||
|
}
|
||||||
|
contentView.addSubview(contentStack)
|
||||||
|
contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(14) }
|
||||||
|
topInfoView.snp.makeConstraints { $0.height.equalTo(72) }
|
||||||
|
previewStack.snp.makeConstraints { $0.height.equalTo(104) }
|
||||||
|
bottomRow.snp.makeConstraints { $0.height.equalTo(34) }
|
||||||
|
accessibilityIdentifier = "aiRetouchJob.summaryCell"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
albumCoverImageView.kf.cancelDownloadTask()
|
||||||
|
albumCoverImageView.image = nil
|
||||||
|
previewImageViews.forEach {
|
||||||
|
$0.kf.cancelDownloadTask()
|
||||||
|
$0.image = nil
|
||||||
|
$0.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(_ item: TravelAlbumAIJobSummary) {
|
||||||
|
albumLabel.text = item.album.name.isEmpty ? "未命名相册" : item.album.name
|
||||||
|
let phone = TravelAlbumDisplayFormatter.maskPhone(item.album.userPhone)
|
||||||
|
phoneLabel.text = phone.isEmpty ? "未提供手机号" : phone
|
||||||
|
taskLabel.text = "任务 #\(item.aiRetouchBatchId)"
|
||||||
|
timeLabel.text = relativeTime(item.createdAt)
|
||||||
|
statusBadge.apply(item.status)
|
||||||
|
albumCoverImageView.kf.setImage(
|
||||||
|
with: URL(string: item.album.coverURL),
|
||||||
|
placeholder: UIImage(systemName: "photo")
|
||||||
|
)
|
||||||
|
previewImageViews.enumerated().forEach { index, imageView in
|
||||||
|
guard item.previewImages.indices.contains(index) else {
|
||||||
|
imageView.isHidden = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
imageView.isHidden = false
|
||||||
|
imageView.kf.setImage(
|
||||||
|
with: URL(string: item.previewImages[index].thumbnailURL),
|
||||||
|
placeholder: UIImage(systemName: "photo")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
previewStack.isHidden = item.previewImages.isEmpty
|
||||||
|
|
||||||
|
progressView.progress = Float(item.progress.fraction)
|
||||||
|
if item.status.isInProgress {
|
||||||
|
completedLabel.text = "已完成 \(item.progress.completed) / \(item.progress.total)"
|
||||||
|
percentLabel.text = "\(Int((item.progress.fraction * 100).rounded()))%"
|
||||||
|
etaLabel.text = item.estimatedFinishAt.map {
|
||||||
|
"预计 \(TravelAlbumAIJobDateFormatter.time($0)) 前完成"
|
||||||
|
} ?? "完成后将通过消息通知"
|
||||||
|
progressRow.isHidden = false
|
||||||
|
etaLabel.isHidden = false
|
||||||
|
outputIconView.image = UIImage(systemName: "wand.and.stars")
|
||||||
|
outputIconView.tintColor = AppColor.primary
|
||||||
|
outputPill.backgroundColor = AppColor.primaryLight
|
||||||
|
outputLabel.textColor = AppColor.primary
|
||||||
|
outputLabel.text = item.outputs.map { "\(shortTitle($0.type)) \($0.count)张" }.joined(separator: " · ")
|
||||||
|
} else if item.status == .partiallySucceeded {
|
||||||
|
progressRow.isHidden = true
|
||||||
|
etaLabel.isHidden = true
|
||||||
|
outputIconView.image = UIImage(systemName: "exclamationmark.circle.fill")
|
||||||
|
outputIconView.tintColor = AppColor.warning
|
||||||
|
outputPill.backgroundColor = .clear
|
||||||
|
outputLabel.textColor = UIColor(hex: 0xB56A00)
|
||||||
|
outputLabel.text = "\(item.progress.succeeded)张成功 · \(item.progress.failed)张失败"
|
||||||
|
} else {
|
||||||
|
progressRow.isHidden = true
|
||||||
|
etaLabel.isHidden = true
|
||||||
|
let succeeded = item.status == .succeeded
|
||||||
|
outputIconView.image = UIImage(systemName: succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||||
|
outputIconView.tintColor = succeeded ? AppColor.success : AppColor.danger
|
||||||
|
outputPill.backgroundColor = .clear
|
||||||
|
outputLabel.textColor = succeeded ? UIColor(hex: 0x178A55) : AppColor.danger
|
||||||
|
outputLabel.text = succeeded ? "\(item.progress.succeeded)张结果已生成" : item.status.title
|
||||||
|
}
|
||||||
|
accessibilityLabel = [albumLabel.text, statusBadge.accessibilityLabel, timeLabel.text, phoneLabel.text, taskLabel.text,
|
||||||
|
outputLabel.text, completedLabel.text, etaLabel.text]
|
||||||
|
.compactMap { $0 }.joined(separator: ",")
|
||||||
|
accessibilityTraits = .button
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureTopInfo() {
|
||||||
|
albumCoverImageView.accessibilityIdentifier = "aiRetouchJob.albumCover"
|
||||||
|
albumCoverImageView.contentMode = .scaleAspectFill
|
||||||
|
albumCoverImageView.clipsToBounds = true
|
||||||
|
albumCoverImageView.layer.cornerRadius = 10
|
||||||
|
albumCoverImageView.backgroundColor = AppColor.pageBackground
|
||||||
|
albumCoverImageView.tintColor = AppColor.textTertiary
|
||||||
|
albumLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
albumLabel.textColor = UIColor(hex: 0x0F1F3D)
|
||||||
|
albumLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
phoneLabel.font = .systemFont(ofSize: 14)
|
||||||
|
phoneLabel.textColor = UIColor(hex: 0x72809A)
|
||||||
|
taskLabel.font = .systemFont(ofSize: 14)
|
||||||
|
taskLabel.textColor = UIColor(hex: 0x72809A)
|
||||||
|
timeIconView.tintColor = UIColor(hex: 0x7F8CA3)
|
||||||
|
timeIconView.contentMode = .scaleAspectFit
|
||||||
|
timeLabel.font = .systemFont(ofSize: 13)
|
||||||
|
timeLabel.textColor = UIColor(hex: 0x7F8CA3)
|
||||||
|
timeStack.axis = .horizontal
|
||||||
|
timeStack.spacing = 5
|
||||||
|
timeStack.alignment = .center
|
||||||
|
timeStack.addArrangedSubview(timeIconView)
|
||||||
|
timeStack.addArrangedSubview(timeLabel)
|
||||||
|
|
||||||
|
[albumCoverImageView, albumLabel, phoneLabel, taskLabel, timeStack, statusBadge].forEach {
|
||||||
|
topInfoView.addSubview($0)
|
||||||
|
}
|
||||||
|
albumCoverImageView.snp.makeConstraints { make in
|
||||||
|
make.leading.top.equalToSuperview()
|
||||||
|
make.size.equalTo(68)
|
||||||
|
}
|
||||||
|
timeIconView.snp.makeConstraints { $0.size.equalTo(15) }
|
||||||
|
timeStack.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
statusBadge.snp.makeConstraints { make in
|
||||||
|
make.trailing.bottom.equalToSuperview()
|
||||||
|
make.height.equalTo(30)
|
||||||
|
}
|
||||||
|
albumLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(1)
|
||||||
|
make.leading.equalTo(albumCoverImageView.snp.trailing).offset(12)
|
||||||
|
make.trailing.lessThanOrEqualTo(timeStack.snp.leading).offset(-8)
|
||||||
|
}
|
||||||
|
phoneLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(albumLabel)
|
||||||
|
make.top.equalTo(albumLabel.snp.bottom).offset(6)
|
||||||
|
make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8)
|
||||||
|
}
|
||||||
|
taskLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(albumLabel)
|
||||||
|
make.top.equalTo(phoneLabel.snp.bottom).offset(5)
|
||||||
|
make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configurePreviewGrid() {
|
||||||
|
previewStack.axis = .horizontal
|
||||||
|
previewStack.spacing = 8
|
||||||
|
previewStack.distribution = .fillEqually
|
||||||
|
for index in 0..<3 {
|
||||||
|
let slot = UIView()
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
imageView.layer.cornerRadius = 10
|
||||||
|
imageView.backgroundColor = AppColor.pageBackground
|
||||||
|
imageView.tintColor = AppColor.textTertiary
|
||||||
|
imageView.accessibilityIdentifier = "aiRetouchJob.preview.\(index)"
|
||||||
|
slot.addSubview(imageView)
|
||||||
|
imageView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||||
|
previewStack.addArrangedSubview(slot)
|
||||||
|
previewImageViews.append(imageView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureBottomRow() {
|
||||||
|
outputPill.layer.cornerRadius = 9
|
||||||
|
outputPill.clipsToBounds = true
|
||||||
|
outputIconView.contentMode = .scaleAspectFit
|
||||||
|
outputLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
|
outputLabel.numberOfLines = 1
|
||||||
|
outputLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
outputLabel.minimumScaleFactor = 0.8
|
||||||
|
outputPill.addSubview(outputIconView)
|
||||||
|
outputPill.addSubview(outputLabel)
|
||||||
|
outputIconView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(10)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(17)
|
||||||
|
}
|
||||||
|
outputLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(outputIconView.snp.trailing).offset(6)
|
||||||
|
make.trailing.equalToSuperview().inset(10)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
detailsLabel.text = "查看详情"
|
||||||
|
detailsLabel.font = .systemFont(ofSize: 13)
|
||||||
|
detailsLabel.textColor = UIColor(hex: 0x7F8CA3)
|
||||||
|
chevronView.tintColor = UIColor(hex: 0x7F8CA3)
|
||||||
|
chevronView.contentMode = .scaleAspectFit
|
||||||
|
[outputPill, detailsLabel, chevronView].forEach { bottomRow.addSubview($0) }
|
||||||
|
outputPill.snp.makeConstraints { make in
|
||||||
|
make.leading.top.bottom.equalToSuperview()
|
||||||
|
make.trailing.lessThanOrEqualTo(detailsLabel.snp.leading).offset(-8)
|
||||||
|
}
|
||||||
|
chevronView.snp.makeConstraints { make in
|
||||||
|
make.trailing.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(14)
|
||||||
|
}
|
||||||
|
detailsLabel.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalTo(chevronView.snp.leading).offset(-6)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shortTitle(_ type: TravelAlbumAIJobOutputType) -> String {
|
||||||
|
switch type {
|
||||||
|
case .refined: "精修"
|
||||||
|
case .atmosphere: "氛围感"
|
||||||
|
case .cover: "封面"
|
||||||
|
case .unknown: "其他"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func relativeTime(_ value: String) -> String {
|
||||||
|
guard let date = TravelAlbumAIJobDateFormatter.date(value) else { return "--" }
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let prefix: String
|
||||||
|
if calendar.isDateInToday(date) {
|
||||||
|
prefix = "今天"
|
||||||
|
} else if calendar.isDateInYesterday(date) {
|
||||||
|
prefix = "昨天"
|
||||||
|
} else {
|
||||||
|
prefix = date.formatted(.dateTime.month().day())
|
||||||
|
}
|
||||||
|
return "\(prefix) \(date.formatted(.dateTime.hour().minute()))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带图标与底色的任务状态徽标,保证状态不只依赖颜色表达。
|
||||||
|
private final class AIJobStatusBadgeView: UIView {
|
||||||
|
private let iconView = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
layer.cornerRadius = 9
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
addSubview(iconView)
|
||||||
|
addSubview(titleLabel)
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(9)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(17)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(iconView.snp.trailing).offset(5)
|
||||||
|
make.trailing.equalToSuperview().inset(10)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func apply(_ status: TravelAlbumAIJobStatus) {
|
||||||
|
titleLabel.text = status.title
|
||||||
|
let color: UIColor
|
||||||
|
let background: UIColor
|
||||||
|
let symbol: String
|
||||||
|
switch status {
|
||||||
|
case .queued:
|
||||||
|
color = AppColor.primary
|
||||||
|
background = AppColor.infoBackground
|
||||||
|
symbol = "clock.arrow.circlepath"
|
||||||
|
case .processing:
|
||||||
|
color = AppColor.primary
|
||||||
|
background = AppColor.infoBackground
|
||||||
|
symbol = "arrow.triangle.2.circlepath"
|
||||||
|
case .succeeded:
|
||||||
|
color = AppColor.success
|
||||||
|
background = AppColor.successBackground
|
||||||
|
symbol = "checkmark.circle.fill"
|
||||||
|
case .partiallySucceeded:
|
||||||
|
color = AppColor.warning
|
||||||
|
background = AppColor.warningBackground
|
||||||
|
symbol = "exclamationmark.circle.fill"
|
||||||
|
case .failed:
|
||||||
|
color = AppColor.danger
|
||||||
|
background = AppColor.dangerBackground
|
||||||
|
symbol = "xmark.circle.fill"
|
||||||
|
case .canceled, .unknown:
|
||||||
|
color = AppColor.textSecondary
|
||||||
|
background = AppColor.pageBackground
|
||||||
|
symbol = "minus.circle.fill"
|
||||||
|
}
|
||||||
|
titleLabel.textColor = color
|
||||||
|
iconView.tintColor = color
|
||||||
|
iconView.image = UIImage(systemName: symbol)
|
||||||
|
backgroundColor = background
|
||||||
|
accessibilityLabel = status.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务列表空态和错误态。
|
||||||
|
private final class AIJobEmptyView: UIView {
|
||||||
|
private let imageView = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let messageLabel = UILabel()
|
||||||
|
private let retryButton = UIButton(type: .system)
|
||||||
|
var onRetry: (() -> Void)?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
imageView.tintColor = AppColor.primary
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
messageLabel.font = .systemFont(ofSize: 14)
|
||||||
|
messageLabel.textColor = AppColor.textSecondary
|
||||||
|
messageLabel.textAlignment = .center
|
||||||
|
messageLabel.numberOfLines = 0
|
||||||
|
retryButton.setTitle("重新加载", for: .normal)
|
||||||
|
retryButton.addTarget(self, action: #selector(retry), for: .touchUpInside)
|
||||||
|
let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, messageLabel, retryButton])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.alignment = .center
|
||||||
|
stack.spacing = 12
|
||||||
|
addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview().offset(-40)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(40)
|
||||||
|
}
|
||||||
|
imageView.snp.makeConstraints { $0.size.equalTo(52) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func apply(title: String, message: String, showsRetry: Bool) {
|
||||||
|
titleLabel.text = title
|
||||||
|
messageLabel.text = message
|
||||||
|
retryButton.isHidden = !showsRetry
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func retry() { onRetry?() }
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
|
|
||||||
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
|
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
|
||||||
private let api: any TravelAlbumServing
|
private let api: any TravelAlbumServing
|
||||||
private let onSubmitted: () -> Void
|
private let onSubmitted: (TravelAlbumAIJobSubmission) -> Void
|
||||||
|
|
||||||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
@@ -45,7 +45,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
init(
|
init(
|
||||||
viewModel: TravelAlbumAIRetouchTemplateViewModel,
|
viewModel: TravelAlbumAIRetouchTemplateViewModel,
|
||||||
api: any TravelAlbumServing,
|
api: any TravelAlbumServing,
|
||||||
onSubmitted: @escaping () -> Void
|
onSubmitted: @escaping (TravelAlbumAIJobSubmission) -> Void
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.api = api
|
self.api = api
|
||||||
@@ -182,10 +182,10 @@ final class TravelAlbumAIRetouchTemplateViewController: 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) }
|
||||||
}
|
}
|
||||||
viewModel.onSubmitted = { [weak self] in
|
viewModel.onSubmitted = { [weak self] submission in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.dismiss(animated: true, completion: self.onSubmitted)
|
self.dismiss(animated: true) { self.onSubmitted(submission) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +284,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
header.apply(
|
header.apply(
|
||||||
title: category.title,
|
title: category.title,
|
||||||
badge: category == .cover
|
badge: category == .cover
|
||||||
? .gift
|
? .requiredGift
|
||||||
: (self.viewModel.isOptional(category) ? .optional : .required)
|
: (self.viewModel.isOptional(category) ? .optional : .required)
|
||||||
)
|
)
|
||||||
case .mode:
|
case .mode:
|
||||||
@@ -434,8 +434,13 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
|
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
|
||||||
let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
|
guard let content = template.comparisonContent else {
|
||||||
controller.modalPresentationStyle = .fullScreen
|
showToast("暂无对比预览")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let controller = BeforeAfterComparisonViewController(
|
||||||
|
viewModel: BeforeAfterComparisonViewModel(content: content)
|
||||||
|
)
|
||||||
present(controller, animated: true)
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -842,38 +847,44 @@ final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
|
|||||||
fileprivate enum AIRetouchTemplateSectionBadge {
|
fileprivate enum AIRetouchTemplateSectionBadge {
|
||||||
case required
|
case required
|
||||||
case optional
|
case optional
|
||||||
case gift
|
case requiredGift
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AI 修图模板分组标题,可附带选填或赠送标记。
|
/// AI 修图模板分组标题,展示必选、选填或封面赠送规则。
|
||||||
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
||||||
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
|
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
|
||||||
|
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let optionalLabel = UILabel()
|
private let badgeContainer = UIView()
|
||||||
|
private let badgeLabel = UILabel()
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||||
optionalLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
badgeContainer.layer.cornerRadius = 10
|
||||||
optionalLabel.textAlignment = .center
|
badgeContainer.clipsToBounds = true
|
||||||
optionalLabel.layer.cornerRadius = 10
|
badgeLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
optionalLabel.clipsToBounds = true
|
badgeLabel.textAlignment = .center
|
||||||
|
badgeLabel.numberOfLines = 1
|
||||||
|
|
||||||
addSubview(titleLabel)
|
addSubview(titleLabel)
|
||||||
addSubview(optionalLabel)
|
addSubview(badgeContainer)
|
||||||
|
badgeContainer.addSubview(badgeLabel)
|
||||||
titleLabel.snp.makeConstraints { make in
|
titleLabel.snp.makeConstraints { make in
|
||||||
make.leading.equalToSuperview().offset(18)
|
make.leading.equalToSuperview().offset(18)
|
||||||
make.centerY.equalToSuperview()
|
make.centerY.equalToSuperview()
|
||||||
}
|
}
|
||||||
optionalLabel.snp.makeConstraints { make in
|
badgeContainer.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||||||
make.centerY.equalTo(titleLabel)
|
make.centerY.equalTo(titleLabel)
|
||||||
make.width.greaterThanOrEqualTo(38)
|
|
||||||
make.height.equalTo(20)
|
make.height.equalTo(20)
|
||||||
make.trailing.lessThanOrEqualToSuperview().offset(-18)
|
make.trailing.lessThanOrEqualToSuperview().offset(-18)
|
||||||
}
|
}
|
||||||
|
badgeLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(9)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -884,151 +895,29 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
|||||||
/// 更新分组标题与业务标记。
|
/// 更新分组标题与业务标记。
|
||||||
fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) {
|
fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) {
|
||||||
titleLabel.text = title
|
titleLabel.text = title
|
||||||
optionalLabel.isHidden = badge == nil
|
badgeContainer.isHidden = badge == nil
|
||||||
switch badge {
|
switch badge {
|
||||||
case .required:
|
case .required:
|
||||||
optionalLabel.text = " 必选 "
|
badgeLabel.text = "必选"
|
||||||
optionalLabel.textColor = AIRetouchTemplateStyle.primary
|
badgeLabel.textColor = AIRetouchTemplateStyle.danger
|
||||||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
badgeContainer.backgroundColor = AIRetouchTemplateStyle.danger.withAlphaComponent(0.1)
|
||||||
case .optional:
|
case .optional:
|
||||||
optionalLabel.text = " 选填 "
|
badgeLabel.text = "选填"
|
||||||
optionalLabel.textColor = AIRetouchTemplateStyle.primary
|
badgeLabel.textColor = AIRetouchTemplateStyle.primary
|
||||||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
badgeContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||||||
case .gift:
|
case .requiredGift:
|
||||||
optionalLabel.text = " 赠送 · 不占额度 "
|
badgeLabel.text = "必选 · 赠送 · 不占额度"
|
||||||
optionalLabel.textColor = AIRetouchTemplateStyle.gift
|
badgeLabel.textColor = AIRetouchTemplateStyle.gift
|
||||||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.giftBackground
|
badgeContainer.backgroundColor = AIRetouchTemplateStyle.giftBackground
|
||||||
case nil:
|
case nil:
|
||||||
optionalLabel.text = nil
|
badgeLabel.text = nil
|
||||||
}
|
}
|
||||||
let badgeText = optionalLabel.text?.trimmingCharacters(in: .whitespaces)
|
let badgeText = badgeLabel.text
|
||||||
accessibilityLabel = badgeText.map { "\(title),\($0)" } ?? title
|
accessibilityLabel = badgeText.map { "\(title),\($0)" } ?? title
|
||||||
accessibilityTraits = .header
|
accessibilityTraits = .header
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AI 修图模板全屏预览,支持双指缩放查看模板细节。
|
|
||||||
final class TravelAlbumAIRetouchTemplatePreviewViewController: UIViewController {
|
|
||||||
private let template: TravelAlbumAIRetouchTemplate
|
|
||||||
private let backButton = UIButton(type: .system)
|
|
||||||
private let titleLabel = UILabel()
|
|
||||||
private let subtitleLabel = UILabel()
|
|
||||||
private let scrollView = UIScrollView()
|
|
||||||
private let imageView = UIImageView()
|
|
||||||
|
|
||||||
/// 创建指定模板的全屏预览页。
|
|
||||||
init(template: TravelAlbumAIRetouchTemplate) {
|
|
||||||
self.template = template
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
modalPresentationStyle = .fullScreen
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
setupUI()
|
|
||||||
setupConstraints()
|
|
||||||
loadPreview()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupUI() {
|
|
||||||
view.backgroundColor = .black
|
|
||||||
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreview"
|
|
||||||
|
|
||||||
var backConfiguration = UIButton.Configuration.filled()
|
|
||||||
backConfiguration.image = UIImage(systemName: "chevron.left")
|
|
||||||
backConfiguration.baseForegroundColor = .white
|
|
||||||
backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12)
|
|
||||||
backConfiguration.background.cornerRadius = 24
|
|
||||||
backButton.configuration = backConfiguration
|
|
||||||
backButton.accessibilityLabel = "返回模板选择"
|
|
||||||
backButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewBackButton"
|
|
||||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
|
||||||
|
|
||||||
titleLabel.text = template.name
|
|
||||||
titleLabel.textColor = .white
|
|
||||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
|
||||||
titleLabel.textAlignment = .center
|
|
||||||
titleLabel.lineBreakMode = .byTruncatingTail
|
|
||||||
|
|
||||||
subtitleLabel.text = "双指缩放查看细节"
|
|
||||||
subtitleLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
|
||||||
subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
|
|
||||||
subtitleLabel.textAlignment = .center
|
|
||||||
|
|
||||||
scrollView.minimumZoomScale = 1
|
|
||||||
scrollView.maximumZoomScale = 4
|
|
||||||
scrollView.delegate = self
|
|
||||||
scrollView.showsHorizontalScrollIndicator = false
|
|
||||||
scrollView.showsVerticalScrollIndicator = false
|
|
||||||
scrollView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewScrollView"
|
|
||||||
imageView.contentMode = .scaleAspectFit
|
|
||||||
imageView.clipsToBounds = true
|
|
||||||
imageView.backgroundColor = UIColor(hex: 0x111111)
|
|
||||||
imageView.accessibilityLabel = "\(template.name)模板预览图"
|
|
||||||
|
|
||||||
view.addSubview(backButton)
|
|
||||||
view.addSubview(titleLabel)
|
|
||||||
view.addSubview(subtitleLabel)
|
|
||||||
view.addSubview(scrollView)
|
|
||||||
scrollView.addSubview(imageView)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupConstraints() {
|
|
||||||
backButton.snp.makeConstraints { make in
|
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
|
||||||
make.leading.equalToSuperview().offset(18)
|
|
||||||
make.size.equalTo(48)
|
|
||||||
}
|
|
||||||
titleLabel.snp.makeConstraints { make in
|
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
|
||||||
make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(12)
|
|
||||||
make.centerX.equalToSuperview()
|
|
||||||
make.trailing.lessThanOrEqualToSuperview().offset(-66)
|
|
||||||
make.height.equalTo(26)
|
|
||||||
}
|
|
||||||
subtitleLabel.snp.makeConstraints { make in
|
|
||||||
make.top.equalTo(titleLabel.snp.bottom).offset(2)
|
|
||||||
make.centerX.equalToSuperview()
|
|
||||||
}
|
|
||||||
scrollView.snp.makeConstraints { make in
|
|
||||||
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
|
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
|
||||||
}
|
|
||||||
imageView.snp.makeConstraints { make in
|
|
||||||
make.center.equalTo(scrollView.frameLayoutGuide)
|
|
||||||
make.width.equalTo(scrollView.frameLayoutGuide)
|
|
||||||
make.height.equalTo(imageView.snp.width).multipliedBy(0.75)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadPreview() {
|
|
||||||
let placeholder = UIImage(named: "ai_retouch_template_placeholder")
|
|
||||||
guard let url = URL(string: template.previewURL), !template.previewURL.isEmpty else {
|
|
||||||
imageView.image = placeholder
|
|
||||||
return
|
|
||||||
}
|
|
||||||
imageView.kf.setImage(
|
|
||||||
with: url,
|
|
||||||
placeholder: placeholder
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func backTapped() {
|
|
||||||
dismiss(animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TravelAlbumAIRetouchTemplatePreviewViewController: UIScrollViewDelegate {
|
|
||||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
|
||||||
imageView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AI 修图模板页视觉常量。
|
/// AI 修图模板页视觉常量。
|
||||||
private enum AIRetouchTemplateStyle {
|
private enum AIRetouchTemplateStyle {
|
||||||
static let primary = UIColor(hex: 0x1677FF)
|
static let primary = UIColor(hex: 0x1677FF)
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 相册自动修图设置:在同一页切换修图方式、展开模板并确认草稿。
|
||||||
|
final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController {
|
||||||
|
/// 确认草稿后通知调用方保存相册配置。
|
||||||
|
var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)?
|
||||||
|
|
||||||
|
private let viewModel: TravelAlbumAutoRetouchSettingViewModel
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let subtitleLabel = UILabel()
|
||||||
|
private let headerStack = UIStackView()
|
||||||
|
private let modeStack = UIStackView()
|
||||||
|
private let originalOption = AutoRetouchModeControl(title: "不修图", detail: "保留相机原始照片", symbol: "photo")
|
||||||
|
private let aiOption = AutoRetouchModeControl(title: "AI 修图", detail: "上传后自动套用模板", symbol: "wand.and.stars")
|
||||||
|
private let contentRegion = UIView()
|
||||||
|
private let templateHeader = UIStackView()
|
||||||
|
private lazy var templateCollectionView = UICollectionView(frame: .zero, collectionViewLayout: makeTemplateLayout())
|
||||||
|
private var templateDataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumAIRetouchTemplate>!
|
||||||
|
private let originalHint = UIStackView()
|
||||||
|
private let statusStack = UIStackView()
|
||||||
|
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private let statusLabel = UILabel()
|
||||||
|
private let retryButton = UIButton(type: .system)
|
||||||
|
private let footer = UIView()
|
||||||
|
private let selectionLabel = UILabel()
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
private let confirmButton = UIButton(type: .system)
|
||||||
|
private var lastTemplatesVisible: Bool?
|
||||||
|
|
||||||
|
/// 创建设置 Sheet;方式选择与模板区域共用同一份配置草稿。
|
||||||
|
init(viewModel: TravelAlbumAutoRetouchSettingViewModel, api: any TravelAlbumServing) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.api = api
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
modalPresentationStyle = .pageSheet
|
||||||
|
if let sheet = sheetPresentationController {
|
||||||
|
sheet.detents = [.large()]
|
||||||
|
sheet.selectedDetentIdentifier = .large
|
||||||
|
sheet.prefersGrabberVisible = true
|
||||||
|
sheet.preferredCornerRadius = 24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = UIColor(hex: 0xF6F8FC)
|
||||||
|
titleLabel.text = viewModel.allowsModeSelection ? "选择修图方式" : "选择修图模板"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
titleLabel.accessibilityTraits = .header
|
||||||
|
subtitleLabel.text = "设置仅对后续上传的照片生效"
|
||||||
|
subtitleLabel.font = .systemFont(ofSize: 13)
|
||||||
|
subtitleLabel.textColor = AppColor.textSecondary
|
||||||
|
headerStack.axis = .vertical
|
||||||
|
headerStack.spacing = 8
|
||||||
|
headerStack.addArrangedSubview(titleLabel)
|
||||||
|
headerStack.addArrangedSubview(subtitleLabel)
|
||||||
|
headerStack.setCustomSpacing(20, after: subtitleLabel)
|
||||||
|
modeStack.axis = .horizontal
|
||||||
|
modeStack.distribution = .fillEqually
|
||||||
|
modeStack.spacing = 12
|
||||||
|
modeStack.accessibilityIdentifier = "travelAlbum.autoRetouchModeOptions"
|
||||||
|
originalOption.accessibilityIdentifier = "travelAlbum.autoRetouchOriginalOption"
|
||||||
|
aiOption.accessibilityIdentifier = "travelAlbum.autoRetouchAIOption"
|
||||||
|
modeStack.addArrangedSubview(originalOption)
|
||||||
|
modeStack.addArrangedSubview(aiOption)
|
||||||
|
headerStack.addArrangedSubview(modeStack)
|
||||||
|
modeStack.isHidden = !viewModel.allowsModeSelection
|
||||||
|
|
||||||
|
let templateTitle = UILabel()
|
||||||
|
templateTitle.text = viewModel.allowsModeSelection ? "选择修图模板" : "精修模板"
|
||||||
|
templateTitle.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
templateTitle.textColor = AppColor.textPrimary
|
||||||
|
templateTitle.accessibilityTraits = .header
|
||||||
|
let templateTips = UILabel()
|
||||||
|
templateTips.text = "单选模板 · 点击预览查看前后效果"
|
||||||
|
templateTips.font = .systemFont(ofSize: 12)
|
||||||
|
templateTips.textColor = AppColor.textSecondary
|
||||||
|
templateHeader.axis = .vertical
|
||||||
|
templateHeader.spacing = 5
|
||||||
|
templateHeader.addArrangedSubview(templateTitle)
|
||||||
|
templateHeader.addArrangedSubview(templateTips)
|
||||||
|
|
||||||
|
templateCollectionView.backgroundColor = .clear
|
||||||
|
templateCollectionView.alwaysBounceVertical = true
|
||||||
|
templateCollectionView.accessibilityIdentifier = "travelAlbum.autoRetouchTemplateCollection"
|
||||||
|
templateCollectionView.delegate = self
|
||||||
|
templateCollectionView.register(TravelAlbumAIRetouchTemplateCell.self,
|
||||||
|
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier)
|
||||||
|
configureDataSource()
|
||||||
|
|
||||||
|
let hintIcon = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
|
||||||
|
hintIcon.tintColor = AppColor.primary.withAlphaComponent(0.6)
|
||||||
|
hintIcon.contentMode = .scaleAspectFit
|
||||||
|
let hintTitle = UILabel()
|
||||||
|
hintTitle.text = "保留原图,直接上传"
|
||||||
|
hintTitle.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
hintTitle.textColor = AppColor.textPrimary
|
||||||
|
let hintDetail = UILabel()
|
||||||
|
hintDetail.text = "选择上方 AI 修图,即可在这里挑选模板"
|
||||||
|
hintDetail.font = .systemFont(ofSize: 13)
|
||||||
|
hintDetail.textColor = AppColor.textSecondary
|
||||||
|
hintDetail.numberOfLines = 2
|
||||||
|
hintDetail.textAlignment = .center
|
||||||
|
originalHint.axis = .vertical
|
||||||
|
originalHint.alignment = .center
|
||||||
|
originalHint.spacing = 12
|
||||||
|
originalHint.accessibilityIdentifier = "travelAlbum.autoRetouchOriginalHint"
|
||||||
|
originalHint.addArrangedSubview(hintIcon)
|
||||||
|
originalHint.addArrangedSubview(hintTitle)
|
||||||
|
originalHint.addArrangedSubview(hintDetail)
|
||||||
|
hintIcon.snp.makeConstraints { $0.size.equalTo(48) }
|
||||||
|
|
||||||
|
statusStack.axis = .vertical
|
||||||
|
statusStack.alignment = .center
|
||||||
|
statusStack.spacing = 12
|
||||||
|
statusStack.addArrangedSubview(activityIndicator)
|
||||||
|
statusStack.addArrangedSubview(statusLabel)
|
||||||
|
statusStack.addArrangedSubview(retryButton)
|
||||||
|
activityIndicator.color = AppColor.primary
|
||||||
|
statusLabel.font = .systemFont(ofSize: 14)
|
||||||
|
statusLabel.textColor = AppColor.textSecondary
|
||||||
|
statusLabel.textAlignment = .center
|
||||||
|
statusLabel.numberOfLines = 0
|
||||||
|
retryButton.setTitle("重新加载", for: .normal)
|
||||||
|
retryButton.tintColor = AppColor.primary
|
||||||
|
retryButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||||
|
retryButton.snp.makeConstraints { $0.height.equalTo(44) }
|
||||||
|
|
||||||
|
footer.backgroundColor = .white
|
||||||
|
footer.layer.shadowColor = UIColor(hex: 0x182B49).cgColor
|
||||||
|
footer.layer.shadowOpacity = 0.04
|
||||||
|
footer.layer.shadowRadius = 12
|
||||||
|
footer.layer.shadowOffset = CGSize(width: 0, height: -3)
|
||||||
|
selectionLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
|
selectionLabel.textColor = AppColor.textSecondary
|
||||||
|
selectionLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
selectionLabel.accessibilityIdentifier = "travelAlbum.autoRetouchSelectionSummary"
|
||||||
|
configureAction(cancelButton, title: "取消", filled: false)
|
||||||
|
configureAction(confirmButton, title: "确定", filled: true)
|
||||||
|
confirmButton.accessibilityIdentifier = "travelAlbum.autoRetouchConfirmButton"
|
||||||
|
|
||||||
|
view.addSubview(headerStack)
|
||||||
|
view.addSubview(contentRegion)
|
||||||
|
contentRegion.addSubview(templateHeader)
|
||||||
|
contentRegion.addSubview(templateCollectionView)
|
||||||
|
contentRegion.addSubview(originalHint)
|
||||||
|
contentRegion.addSubview(statusStack)
|
||||||
|
view.addSubview(footer)
|
||||||
|
footer.addSubview(selectionLabel)
|
||||||
|
footer.addSubview(cancelButton)
|
||||||
|
footer.addSubview(confirmButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
headerStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
contentRegion.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(headerStack.snp.bottom).offset(22)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalTo(footer.snp.top).offset(-8)
|
||||||
|
}
|
||||||
|
templateHeader.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
templateCollectionView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(templateHeader.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
originalHint.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview().offset(-12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(24)
|
||||||
|
}
|
||||||
|
statusStack.snp.makeConstraints { make in
|
||||||
|
make.center.equalTo(templateCollectionView)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(32)
|
||||||
|
}
|
||||||
|
footer.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
selectionLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(14)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
cancelButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(selectionLabel.snp.bottom).offset(12)
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.height.equalTo(50)
|
||||||
|
make.width.equalTo(96)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||||
|
}
|
||||||
|
confirmButton.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
|
||||||
|
make.trailing.equalToSuperview().offset(-16)
|
||||||
|
make.top.bottom.equalTo(cancelButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
originalOption.addTarget(self, action: #selector(originalTapped), for: .touchUpInside)
|
||||||
|
aiOption.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
applyViewModel()
|
||||||
|
Task { await viewModel.loadTemplates(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureDataSource() {
|
||||||
|
templateDataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumAIRetouchTemplate>(
|
||||||
|
collectionView: templateCollectionView
|
||||||
|
) { [weak self] collectionView, indexPath, template in
|
||||||
|
guard let self else { return nil }
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier, for: indexPath
|
||||||
|
) as! TravelAlbumAIRetouchTemplateCell
|
||||||
|
cell.apply(template: template, selected: self.viewModel.selectedTemplateId == template.id)
|
||||||
|
cell.onPreviewTapped = { [weak self] in self?.showPreview(for: template) }
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTemplateLayout() -> UICollectionViewCompositionalLayout {
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(
|
||||||
|
widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)
|
||||||
|
))
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(
|
||||||
|
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(156)),
|
||||||
|
subitem: item, count: 3
|
||||||
|
)
|
||||||
|
group.interItemSpacing = .fixed(12)
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.interGroupSpacing = 12
|
||||||
|
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 16, bottom: 12, trailing: 16)
|
||||||
|
return UICollectionViewCompositionalLayout(section: section)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
|
||||||
|
guard presentedViewController == nil else { return }
|
||||||
|
guard let content = template.comparisonContent else { showToast("暂无对比预览"); return }
|
||||||
|
present(BeforeAfterComparisonViewController(viewModel: BeforeAfterComparisonViewModel(content: content)), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
let showsTemplates = viewModel.isEnabled
|
||||||
|
originalOption.apply(selected: !showsTemplates)
|
||||||
|
aiOption.apply(selected: showsTemplates)
|
||||||
|
confirmButton.isEnabled = viewModel.canConfirm
|
||||||
|
confirmButton.alpha = viewModel.canConfirm ? 1 : 0.4
|
||||||
|
selectionLabel.text = !showsTemplates ? "将保留原图,不进行 AI 修图"
|
||||||
|
: viewModel.selectedTemplateName.map { "已选择:\($0)" } ?? "请选择一个修图模板"
|
||||||
|
selectionLabel.textColor = showsTemplates && viewModel.selectedTemplateName != nil ? AppColor.primary : AppColor.textSecondary
|
||||||
|
let updateVisibility = {
|
||||||
|
self.originalHint.isHidden = showsTemplates
|
||||||
|
self.templateHeader.isHidden = !showsTemplates
|
||||||
|
self.templateCollectionView.isHidden = !showsTemplates || self.viewModel.isLoading || self.viewModel.errorMessage != nil
|
||||||
|
self.statusStack.isHidden = !showsTemplates || (!self.viewModel.isLoading && self.viewModel.errorMessage == nil)
|
||||||
|
}
|
||||||
|
if let previous = lastTemplatesVisible, previous != showsTemplates,
|
||||||
|
view.window != nil, !UIAccessibility.isReduceMotionEnabled {
|
||||||
|
UIView.transition(with: contentRegion, duration: 0.2, options: [.transitionCrossDissolve, .beginFromCurrentState], animations: updateVisibility)
|
||||||
|
} else {
|
||||||
|
updateVisibility()
|
||||||
|
}
|
||||||
|
lastTemplatesVisible = showsTemplates
|
||||||
|
if viewModel.isLoading {
|
||||||
|
activityIndicator.startAnimating()
|
||||||
|
} else {
|
||||||
|
activityIndicator.stopAnimating()
|
||||||
|
}
|
||||||
|
activityIndicator.isHidden = !viewModel.isLoading
|
||||||
|
statusLabel.text = viewModel.isLoading ? "正在加载修图模板…" : viewModel.errorMessage
|
||||||
|
retryButton.isHidden = viewModel.isLoading
|
||||||
|
let previousTemplates = Set(templateDataSource.snapshot().itemIdentifiers)
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumAIRetouchTemplate>()
|
||||||
|
snapshot.appendSections([0])
|
||||||
|
snapshot.appendItems(viewModel.templates)
|
||||||
|
snapshot.reconfigureItems(viewModel.templates.filter(previousTemplates.contains))
|
||||||
|
templateDataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureAction(_ button: UIButton, title: String, filled: Bool) {
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
button.layer.cornerRadius = 14
|
||||||
|
button.backgroundColor = filled ? AppColor.primary : .white
|
||||||
|
button.setTitleColor(filled ? .white : AppColor.textSecondary, for: .normal)
|
||||||
|
button.layer.borderWidth = filled ? 0 : 1
|
||||||
|
button.layer.borderColor = UIColor(hex: 0xDFE5EF).cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func originalTapped() { viewModel.selectMode(enabled: false) }
|
||||||
|
@objc private func aiTapped() { viewModel.selectMode(enabled: true) }
|
||||||
|
@objc private func cancelTapped() { dismiss(animated: true) }
|
||||||
|
@objc private func retryTapped() { Task { await viewModel.loadTemplates(api: api) } }
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
guard viewModel.canConfirm, let configuration = viewModel.pendingConfiguration else { return }
|
||||||
|
let completion = onConfirm
|
||||||
|
dismiss(animated: true) { completion?(configuration) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAutoRetouchSettingSheetViewController: UICollectionViewDelegate {
|
||||||
|
/// 点击模板更新草稿;预览按钮独立处理,不改变选择。
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard let template = templateDataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
viewModel.selectTemplate(id: template.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 修图方式单选卡片,使用图标、边框和勾选共同传达当前选择。
|
||||||
|
private final class AutoRetouchModeControl: UIControl {
|
||||||
|
private let iconContainer = UIView()
|
||||||
|
private let iconView = UIImageView()
|
||||||
|
private let checkView = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
|
||||||
|
/// 创建可点击的方式卡片;所有内容由整个控件统一响应触摸。
|
||||||
|
init(title: String, detail: String, symbol: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
layer.cornerRadius = 16
|
||||||
|
layer.borderWidth = 1
|
||||||
|
iconContainer.layer.cornerRadius = 10
|
||||||
|
iconView.image = UIImage(systemName: symbol)
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
titleLabel.text = title
|
||||||
|
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
let detailLabel = UILabel()
|
||||||
|
detailLabel.text = detail
|
||||||
|
detailLabel.font = .systemFont(ofSize: 12)
|
||||||
|
detailLabel.textColor = AppColor.textSecondary
|
||||||
|
detailLabel.numberOfLines = 2
|
||||||
|
checkView.contentMode = .scaleAspectFit
|
||||||
|
[iconContainer, iconView, checkView, titleLabel, detailLabel].forEach {
|
||||||
|
$0.isUserInteractionEnabled = false
|
||||||
|
addSubview($0)
|
||||||
|
}
|
||||||
|
iconContainer.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview().offset(12)
|
||||||
|
make.size.equalTo(32)
|
||||||
|
}
|
||||||
|
iconView.snp.makeConstraints { $0.edges.equalTo(iconContainer).inset(7) }
|
||||||
|
checkView.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview().inset(12)
|
||||||
|
make.size.equalTo(22)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(iconContainer.snp.bottom).offset(10)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
detailLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||||
|
make.leading.trailing.equalTo(titleLabel)
|
||||||
|
make.bottom.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
isAccessibilityElement = true
|
||||||
|
accessibilityLabel = "\(title),\(detail)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
/// 更新卡片背景与单选语义。
|
||||||
|
func apply(selected: Bool) {
|
||||||
|
isSelected = selected
|
||||||
|
backgroundColor = selected ? AppColor.primaryLight : .white
|
||||||
|
layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xDFE5EF)).cgColor
|
||||||
|
layer.borderWidth = selected ? 1.5 : 1
|
||||||
|
iconContainer.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEFF2F7)
|
||||||
|
iconView.tintColor = selected ? .white : AppColor.textSecondary
|
||||||
|
titleLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary
|
||||||
|
checkView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||||
|
checkView.tintColor = selected ? AppColor.primary : UIColor(hex: 0xC7CFDC)
|
||||||
|
accessibilityValue = selected ? "已选择" : "未选择"
|
||||||
|
accessibilityTraits = selected ? [.button, .selected] : [.button]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
private let aiRetouchButton = UIButton(type: .system)
|
private let aiRetouchButton = UIButton(type: .system)
|
||||||
private let deleteSelectedButton = UIButton(type: .system)
|
private let deleteSelectedButton = UIButton(type: .system)
|
||||||
private let uploadButton = UIButton(type: .system)
|
private let uploadButton = UIButton(type: .system)
|
||||||
|
private var refreshState = TravelAlbumReturnRefreshState()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
albumId: Int,
|
albumId: Int,
|
||||||
@@ -65,7 +66,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
navigationItem.scrollEdgeAppearance = appearance
|
navigationItem.scrollEdgeAppearance = appearance
|
||||||
navigationItem.compactAppearance = appearance
|
navigationItem.compactAppearance = appearance
|
||||||
|
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
let moreItem = UIBarButtonItem(
|
||||||
image: UIImage(systemName: "ellipsis"),
|
image: UIImage(systemName: "ellipsis"),
|
||||||
menu: UIMenu(children: [
|
menu: UIMenu(children: [
|
||||||
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
|
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
|
||||||
@@ -73,7 +74,19 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "更多操作"
|
moreItem.accessibilityLabel = "更多操作"
|
||||||
|
let taskItem = UIBarButtonItem(
|
||||||
|
title: "修图任务",
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(openAIJobList)
|
||||||
|
)
|
||||||
|
taskItem.accessibilityLabel = "查看AI修图任务"
|
||||||
|
navigationItem.rightBarButtonItems = [moreItem, taskItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func openAIJobList() {
|
||||||
|
navigationController?.pushViewController(TravelAlbumAIJobListViewController(api: api), animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
@@ -250,6 +263,18 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
Task { await viewModel.refreshAll(api: api) }
|
Task { await viewModel.refreshAll(api: api) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
guard refreshState.beginRefreshIfNeeded() else { return }
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshAll(api: api)
|
||||||
|
await MainActor.run {
|
||||||
|
self.applyViewModel()
|
||||||
|
self.refreshState.finishRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
if let album = viewModel.album {
|
if let album = viewModel.album {
|
||||||
@@ -268,14 +293,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
sortButton.accessibilityValue = viewModel.sortOption.title
|
sortButton.accessibilityValue = viewModel.sortOption.title
|
||||||
sortButton.menu = makeSortMenu()
|
sortButton.menu = makeSortMenu()
|
||||||
|
|
||||||
let canSelectMaterials = viewModel.selectedTab == .all
|
|
||||||
selectButton.isHidden = false
|
selectButton.isHidden = false
|
||||||
selectButton.isEnabled = canSelectMaterials
|
selectButton.isEnabled = true
|
||||||
selectButton.alpha = canSelectMaterials ? 1 : 0.45
|
selectButton.alpha = 1
|
||||||
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
|
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
|
||||||
selectButton.accessibilityValue = canSelectMaterials
|
selectButton.accessibilityValue = viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭"
|
||||||
? (viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭")
|
|
||||||
: "已购照片不可删除"
|
|
||||||
|
|
||||||
let selectedCount = viewModel.selectedMaterialIds.count
|
let selectedCount = viewModel.selectedMaterialIds.count
|
||||||
let hasSelection = selectedCount > 0
|
let hasSelection = selectedCount > 0
|
||||||
@@ -294,7 +316,10 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
if !snapshot.itemIdentifiers.isEmpty {
|
if !snapshot.itemIdentifiers.isEmpty {
|
||||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||||
}
|
}
|
||||||
dataSource.apply(snapshot, animatingDifferences: true)
|
dataSource.apply(
|
||||||
|
snapshot,
|
||||||
|
animatingDifferences: !refreshState.suppressesSnapshotAnimations
|
||||||
|
)
|
||||||
|
|
||||||
if !viewModel.isRefreshing {
|
if !viewModel.isRefreshing {
|
||||||
refreshControl.endRefreshing()
|
refreshControl.endRefreshing()
|
||||||
@@ -446,19 +471,24 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
materialIds: materialIds
|
materialIds: materialIds
|
||||||
),
|
),
|
||||||
api: api,
|
api: api,
|
||||||
onSubmitted: { [weak self] in
|
onSubmitted: { [weak self] _ in self?.handleAIRetouchSubmitted() }
|
||||||
guard let self else { return }
|
|
||||||
self.viewModel.completeAIRetouchSubmission()
|
|
||||||
self.showToast("AI修图任务已提交")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
present(controller, animated: true)
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleAIRetouchSubmitted() {
|
||||||
|
showToast("AI修图任务已提交,完成后将通过消息通知")
|
||||||
|
Task { await viewModel.refreshAfterAIRetouchSubmission(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func deleteSelectedTapped() {
|
@objc private func deleteSelectedTapped() {
|
||||||
let count = viewModel.selectedMaterialIds.count
|
let count = viewModel.selectedMaterialIds.count
|
||||||
guard count > 0 else { return }
|
guard count > 0 else { return }
|
||||||
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
|
let alert = UIAlertController(
|
||||||
|
title: "删除素材",
|
||||||
|
message: viewModel.deleteSelectedConfirmationMessage,
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -468,12 +498,25 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func uploadTapped() {
|
@objc private func uploadTapped() {
|
||||||
|
guard presentedViewController == nil else { return }
|
||||||
|
let selector = TravelAlbumTransferModeSheetViewController()
|
||||||
|
selector.onModeSelected = { [weak self] mode in
|
||||||
|
self?.openWiredTransfer(mode: mode)
|
||||||
|
}
|
||||||
|
present(selector, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openWiredTransfer(mode: TravelAlbumOTGTransferMode) {
|
||||||
let album = viewModel.album
|
let album = viewModel.album
|
||||||
|
refreshState.markRefreshNeeded()
|
||||||
let controller = WiredCameraTransferViewController(
|
let controller = WiredCameraTransferViewController(
|
||||||
viewModel: WiredCameraTransferViewModel(
|
viewModel: WiredCameraTransferViewModel(
|
||||||
albumId: album?.id ?? viewModel.albumId,
|
albumId: album?.id ?? viewModel.albumId,
|
||||||
albumTitle: album?.name ?? "",
|
albumTitle: album?.name ?? "",
|
||||||
headerPhone: album?.displayPhone ?? ""
|
headerPhone: album?.displayPhone ?? "",
|
||||||
|
initialTransferMode: mode,
|
||||||
|
initialAutoRetouchConfiguration: album?.autoRetouchConfiguration ?? .disabled,
|
||||||
|
api: api
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
navigationController?.pushViewController(controller, animated: true)
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
@@ -505,7 +548,8 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
},
|
},
|
||||||
onProjectDeleted: { materialId in
|
onProjectDeleted: { materialId in
|
||||||
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
||||||
}
|
},
|
||||||
|
onAIRetouchSubmitted: { [weak self] in self?.handleAIRetouchSubmitted() }
|
||||||
)
|
)
|
||||||
present(controller, animated: true)
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
@@ -663,14 +707,16 @@ private final class TravelAlbumInfoCard: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
|
/// 旅拍相册素材网格单元,独立展示购买、修图和选择状态。
|
||||||
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||||
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
||||||
|
|
||||||
private let imageView = UIImageView()
|
private let imageView = UIImageView()
|
||||||
private let checkImageView = UIImageView()
|
private let checkImageView = UIImageView()
|
||||||
private let badgeView = UIView()
|
private let purchaseBadgeView = UIView()
|
||||||
private let badgeLabel = UILabel()
|
private let purchaseBadgeLabel = UILabel()
|
||||||
|
private let aiRetouchBadgeView = UIView()
|
||||||
|
private let aiRetouchBadgeLabel = UILabel()
|
||||||
private let nameLabel = UILabel()
|
private let nameLabel = UILabel()
|
||||||
private let sizeLabel = UILabel()
|
private let sizeLabel = UILabel()
|
||||||
|
|
||||||
@@ -684,18 +730,18 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
checkImageView.layer.cornerRadius = 11
|
checkImageView.layer.cornerRadius = 11
|
||||||
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
||||||
badgeView.layer.cornerRadius = 5
|
configureBadge(
|
||||||
badgeView.clipsToBounds = true
|
purchaseBadgeView,
|
||||||
badgeView.isHidden = true
|
label: purchaseBadgeLabel,
|
||||||
badgeView.isAccessibilityElement = false
|
viewIdentifier: "travelAlbum.materialPurchaseBadge",
|
||||||
badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge"
|
labelIdentifier: "travelAlbum.materialPurchaseBadgeLabel"
|
||||||
badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold)
|
)
|
||||||
badgeLabel.textColor = .white
|
configureBadge(
|
||||||
badgeLabel.textAlignment = .center
|
aiRetouchBadgeView,
|
||||||
badgeLabel.isAccessibilityElement = false
|
label: aiRetouchBadgeLabel,
|
||||||
badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel"
|
viewIdentifier: "travelAlbum.materialAIRetouchBadge",
|
||||||
badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
|
labelIdentifier: "travelAlbum.materialAIRetouchBadgeLabel"
|
||||||
badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
)
|
||||||
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||||||
nameLabel.lineBreakMode = .byTruncatingMiddle
|
nameLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
@@ -703,8 +749,8 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
||||||
|
|
||||||
contentView.addSubview(imageView)
|
contentView.addSubview(imageView)
|
||||||
imageView.addSubview(badgeView)
|
imageView.addSubview(purchaseBadgeView)
|
||||||
badgeView.addSubview(badgeLabel)
|
imageView.addSubview(aiRetouchBadgeView)
|
||||||
imageView.addSubview(checkImageView)
|
imageView.addSubview(checkImageView)
|
||||||
contentView.addSubview(nameLabel)
|
contentView.addSubview(nameLabel)
|
||||||
contentView.addSubview(sizeLabel)
|
contentView.addSubview(sizeLabel)
|
||||||
@@ -716,14 +762,13 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
make.top.trailing.equalToSuperview().inset(6)
|
make.top.trailing.equalToSuperview().inset(6)
|
||||||
make.size.equalTo(22)
|
make.size.equalTo(22)
|
||||||
}
|
}
|
||||||
badgeView.snp.makeConstraints { make in
|
aiRetouchBadgeView.snp.makeConstraints { make in
|
||||||
make.top.leading.equalToSuperview().inset(6)
|
make.top.leading.equalToSuperview().inset(6)
|
||||||
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
||||||
}
|
}
|
||||||
badgeLabel.snp.makeConstraints { make in
|
purchaseBadgeView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview().inset(
|
make.leading.bottom.equalToSuperview().inset(6)
|
||||||
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
make.trailing.lessThanOrEqualToSuperview().inset(6)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
nameLabel.snp.makeConstraints { make in
|
nameLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(imageView.snp.bottom).offset(6)
|
make.top.equalTo(imageView.snp.bottom).offset(6)
|
||||||
@@ -753,14 +798,54 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
checkImageView.isHidden = !selectionMode
|
checkImageView.isHidden = !selectionMode
|
||||||
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||||
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
|
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
|
||||||
let badge = material.badgePresentation
|
let purchaseBadge = material.purchaseBadgePresentation
|
||||||
badgeView.isHidden = badge == nil
|
let aiRetouchBadge = material.aiRetouchBadgePresentation
|
||||||
badgeLabel.text = badge?.text
|
applyBadge(purchaseBadge, to: purchaseBadgeView, label: purchaseBadgeLabel)
|
||||||
badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
applyBadge(aiRetouchBadge, to: aiRetouchBadgeView, label: aiRetouchBadgeLabel)
|
||||||
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
||||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
||||||
let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? ""
|
let purchaseAccessibilityText = purchaseBadge.map { ",购买状态:\($0.text)" } ?? ""
|
||||||
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)"
|
let aiRetouchAccessibilityText = aiRetouchBadge.map { ",修图状态:\($0.text)" } ?? ""
|
||||||
|
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")"
|
||||||
|
+ purchaseAccessibilityText
|
||||||
|
+ aiRetouchAccessibilityText
|
||||||
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
|
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configureBadge(
|
||||||
|
_ badgeView: UIView,
|
||||||
|
label: UILabel,
|
||||||
|
viewIdentifier: String,
|
||||||
|
labelIdentifier: String
|
||||||
|
) {
|
||||||
|
badgeView.layer.cornerRadius = 5
|
||||||
|
badgeView.clipsToBounds = true
|
||||||
|
badgeView.isHidden = true
|
||||||
|
badgeView.isAccessibilityElement = false
|
||||||
|
badgeView.accessibilityIdentifier = viewIdentifier
|
||||||
|
label.font = .systemFont(ofSize: 10, weight: .semibold)
|
||||||
|
label.textColor = .white
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.lineBreakMode = .byTruncatingTail
|
||||||
|
label.isAccessibilityElement = false
|
||||||
|
label.accessibilityIdentifier = labelIdentifier
|
||||||
|
label.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||||
|
badgeView.addSubview(label)
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(
|
||||||
|
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyBadge(
|
||||||
|
_ presentation: TravelAlbumMaterialBadgePresentation?,
|
||||||
|
to badgeView: UIView,
|
||||||
|
label: UILabel
|
||||||
|
) {
|
||||||
|
badgeView.isHidden = presentation == nil
|
||||||
|
label.text = presentation?.text
|
||||||
|
badgeView.backgroundColor = presentation.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,27 @@ import Kingfisher
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
/// 相册页面的一次性返回刷新状态,避免初次展示或重复出现时多发列表请求。
|
||||||
|
struct TravelAlbumReturnRefreshState {
|
||||||
|
private var needsRefresh = false
|
||||||
|
private(set) var suppressesSnapshotAnimations = false
|
||||||
|
|
||||||
|
mutating func markRefreshNeeded() {
|
||||||
|
needsRefresh = true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func beginRefreshIfNeeded() -> Bool {
|
||||||
|
guard needsRefresh else { return false }
|
||||||
|
needsRefresh = false
|
||||||
|
suppressesSnapshotAnimations = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func finishRefresh() {
|
||||||
|
suppressesSnapshotAnimations = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 新增相册入口页,对齐 Android `TravelAlbumEntryScreen`。
|
/// 新增相册入口页,对齐 Android `TravelAlbumEntryScreen`。
|
||||||
final class TravelAlbumEntryViewController: BaseViewController {
|
final class TravelAlbumEntryViewController: BaseViewController {
|
||||||
private let viewModel = TravelAlbumEntryViewModel()
|
private let viewModel = TravelAlbumEntryViewModel()
|
||||||
@@ -15,8 +36,10 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
private let heroCard = TravelAlbumHeroCard()
|
private let heroCard = TravelAlbumHeroCard()
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private let refreshControl = UIRefreshControl()
|
||||||
private let emptyView = TravelAlbumEmptyView()
|
private let emptyView = TravelAlbumEmptyView()
|
||||||
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
|
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
|
||||||
|
private var refreshState = TravelAlbumReturnRefreshState()
|
||||||
|
|
||||||
init(api: (any TravelAlbumServing)? = nil) {
|
init(api: (any TravelAlbumServing)? = nil) {
|
||||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||||
@@ -43,6 +66,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
tableView.estimatedRowHeight = 148
|
tableView.estimatedRowHeight = 148
|
||||||
|
tableView.alwaysBounceVertical = true
|
||||||
|
tableView.refreshControl = refreshControl
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
|
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
|
||||||
|
|
||||||
@@ -62,6 +87,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
view.addSubview(titleLabel)
|
view.addSubview(titleLabel)
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
view.addSubview(emptyView)
|
view.addSubview(emptyView)
|
||||||
|
emptyView.isUserInteractionEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
@@ -86,6 +112,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
|
|
||||||
override func bindActions() {
|
override func bindActions() {
|
||||||
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
|
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
|
||||||
|
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in
|
||||||
Task { @MainActor in self?.applyViewModel() }
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
}
|
}
|
||||||
@@ -99,24 +126,49 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
Task { await viewModel.loadAlbums(api: api) }
|
Task { await viewModel.loadAlbums(api: api) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
guard refreshState.beginRefreshIfNeeded() else { return }
|
||||||
|
Task {
|
||||||
|
await viewModel.loadAlbums(api: api)
|
||||||
|
await MainActor.run {
|
||||||
|
self.applyViewModel()
|
||||||
|
self.refreshState.finishRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
heroCard.isLoading = viewModel.isCreating
|
heroCard.isLoading = viewModel.isCreating
|
||||||
titleLabel.text = "我的任务(\(viewModel.albumTotal))"
|
titleLabel.text = "我的任务(\(viewModel.albumTotal))"
|
||||||
titleLabel.isHidden = viewModel.albums.isEmpty
|
titleLabel.isHidden = viewModel.albums.isEmpty
|
||||||
tableView.isHidden = viewModel.albums.isEmpty
|
emptyView.isHidden = !viewModel.albums.isEmpty
|
||||||
emptyView.isHidden = !viewModel.albums.isEmpty || viewModel.isLoading
|
|| (viewModel.isLoading && !refreshControl.isRefreshing)
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
|
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
|
||||||
snapshot.appendSections([0])
|
snapshot.appendSections([0])
|
||||||
snapshot.appendItems(viewModel.albums)
|
snapshot.appendItems(viewModel.albums)
|
||||||
dataSource.apply(snapshot, animatingDifferences: true)
|
dataSource.apply(
|
||||||
if viewModel.isLoading && viewModel.albums.isEmpty {
|
snapshot,
|
||||||
|
animatingDifferences: !refreshState.suppressesSnapshotAnimations
|
||||||
|
)
|
||||||
|
if viewModel.isLoading && viewModel.albums.isEmpty && !refreshControl.isRefreshing {
|
||||||
showLoading()
|
showLoading()
|
||||||
} else {
|
} else {
|
||||||
hideLoading()
|
hideLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func refreshPulled() {
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshAlbums(api: api)
|
||||||
|
await MainActor.run {
|
||||||
|
self.refreshControl.endRefreshing()
|
||||||
|
self.applyViewModel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func createTapped() {
|
@objc private func createTapped() {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.openCreateSheet(api: api)
|
await viewModel.openCreateSheet(api: api)
|
||||||
@@ -162,6 +214,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func pushWiredTransfer(album: TravelAlbum) {
|
private func pushWiredTransfer(album: TravelAlbum) {
|
||||||
|
refreshState.markRefreshNeeded()
|
||||||
let controller = WiredCameraTransferViewController(
|
let controller = WiredCameraTransferViewController(
|
||||||
viewModel: WiredCameraTransferViewModel(
|
viewModel: WiredCameraTransferViewModel(
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
@@ -177,7 +230,11 @@ extension TravelAlbumEntryViewController: UITableViewDelegate {
|
|||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
guard let album = dataSource.itemIdentifier(for: indexPath) else { return }
|
guard let album = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
navigationController?.pushViewController(TravelAlbumDetailViewController(albumId: album.id), animated: true)
|
refreshState.markRefreshNeeded()
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
TravelAlbumDetailViewController(albumId: album.id, api: api),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
private let loadMore: TravelAlbumPreviewLoadMore?
|
private let loadMore: TravelAlbumPreviewLoadMore?
|
||||||
private let reload: TravelAlbumPreviewReload?
|
private let reload: TravelAlbumPreviewReload?
|
||||||
private let onProjectDeleted: ((Int) -> Void)?
|
private let onProjectDeleted: ((Int) -> Void)?
|
||||||
|
private let onAIRetouchSubmitted: (() -> Void)?
|
||||||
|
private let allowsActions: Bool
|
||||||
private var nodes: [TravelAlbumPreviewNode] = []
|
private var nodes: [TravelAlbumPreviewNode] = []
|
||||||
private var currentNodeIndex = 0
|
private var currentNodeIndex = 0
|
||||||
private var dragStartIndex = 0
|
private var dragStartIndex = 0
|
||||||
@@ -41,16 +43,19 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
private let closeButton = UIButton(type: .system)
|
private let closeButton = UIButton(type: .system)
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let sizeLabel = UILabel()
|
private let sizeLabel = UILabel()
|
||||||
|
private let counterContainer = UIView()
|
||||||
private let counterLabel = UILabel()
|
private let counterLabel = UILabel()
|
||||||
private let bottomChrome = UIView()
|
private let bottomChrome = UIView()
|
||||||
private let divider = UIView()
|
private let divider = UIView()
|
||||||
private let variantControls = UIView()
|
private let variantControls = UIView()
|
||||||
private let variantSegmentedControl = UISegmentedControl()
|
private let variantSegmentedControl = UISegmentedControl()
|
||||||
private let highResolutionButton = UIButton(type: .system)
|
private let highResolutionButton = UIButton(type: .system)
|
||||||
|
private let comparisonButton = UIButton(type: .system)
|
||||||
private let actionStack = UIStackView()
|
private let actionStack = UIStackView()
|
||||||
private let deleteButton = UIButton(type: .system)
|
private let deleteButton = UIButton(type: .system)
|
||||||
private let refreshButton = UIButton(type: .system)
|
private let refreshButton = UIButton(type: .system)
|
||||||
private var variantControlsHeightConstraint: Constraint?
|
private var variantControlsHeightConstraint: Constraint?
|
||||||
|
private var variantSegmentedControlWidthConstraint: Constraint?
|
||||||
private var didConfigureVariantControls = false
|
private var didConfigureVariantControls = false
|
||||||
private var showsVariantControls = false
|
private var showsVariantControls = false
|
||||||
private var variantControlsAnimationGeneration = 0
|
private var variantControlsAnimationGeneration = 0
|
||||||
@@ -60,6 +65,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
projects: [TravelAlbumPreviewProject],
|
projects: [TravelAlbumPreviewProject],
|
||||||
totalCount: Int,
|
totalCount: Int,
|
||||||
startProjectIndex: Int,
|
startProjectIndex: Int,
|
||||||
|
startKind: TravelAlbumPreviewAssetKind = .original,
|
||||||
|
allowsActions: Bool = true,
|
||||||
configuration: TravelAlbumPreviewConfiguration = .init(),
|
configuration: TravelAlbumPreviewConfiguration = .init(),
|
||||||
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
||||||
albumId: Int = 0,
|
albumId: Int = 0,
|
||||||
@@ -67,7 +74,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
aiRetouchAPI: (any TravelAlbumServing)? = nil,
|
aiRetouchAPI: (any TravelAlbumServing)? = nil,
|
||||||
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
||||||
reload: TravelAlbumPreviewReload? = nil,
|
reload: TravelAlbumPreviewReload? = nil,
|
||||||
onProjectDeleted: ((Int) -> Void)? = nil
|
onProjectDeleted: ((Int) -> Void)? = nil,
|
||||||
|
onAIRetouchSubmitted: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.projects = Self.deduplicated(projects)
|
self.projects = Self.deduplicated(projects)
|
||||||
self.totalCount = max(totalCount, projects.count)
|
self.totalCount = max(totalCount, projects.count)
|
||||||
@@ -79,9 +87,19 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
self.loadMore = loadMore
|
self.loadMore = loadMore
|
||||||
self.reload = reload
|
self.reload = reload
|
||||||
self.onProjectDeleted = onProjectDeleted
|
self.onProjectDeleted = onProjectDeleted
|
||||||
|
self.onAIRetouchSubmitted = onAIRetouchSubmitted
|
||||||
|
self.allowsActions = allowsActions
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
modalPresentationStyle = .fullScreen
|
modalPresentationStyle = .fullScreen
|
||||||
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original)
|
let resolvedProjectIndex = max(0, min(startProjectIndex, self.projects.count - 1))
|
||||||
|
let initialProject = self.projects.indices.contains(resolvedProjectIndex)
|
||||||
|
? self.projects[resolvedProjectIndex]
|
||||||
|
: nil
|
||||||
|
let resolvedStartKind = initialProject?.asset(for: startKind) == nil
|
||||||
|
? initialProject?.orderedAssets.first?.kind ?? .original
|
||||||
|
: startKind
|
||||||
|
selectedKind = resolvedStartKind
|
||||||
|
rebuildNodes(keepingProjectIndex: resolvedProjectIndex, kind: resolvedStartKind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -136,7 +154,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
divider.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
divider.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||||
|
|
||||||
var backConfiguration = UIButton.Configuration.plain()
|
var backConfiguration = UIButton.Configuration.plain()
|
||||||
backConfiguration.image = UIImage(systemName: "chevron.backward")
|
backConfiguration.image = UIImage(systemName: "chevron.down")
|
||||||
backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
|
backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
|
||||||
pointSize: 21,
|
pointSize: 21,
|
||||||
weight: .semibold
|
weight: .semibold
|
||||||
@@ -155,23 +173,26 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
||||||
sizeLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
sizeLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
||||||
sizeLabel.textAlignment = .center
|
sizeLabel.textAlignment = .center
|
||||||
|
sizeLabel.accessibilityIdentifier = "travelAlbum.previewFileSizeLabel"
|
||||||
|
|
||||||
counterLabel.textColor = UIColor.white.withAlphaComponent(0.82)
|
counterLabel.textColor = UIColor.white.withAlphaComponent(0.82)
|
||||||
counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium)
|
counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium)
|
||||||
counterLabel.textAlignment = .center
|
counterLabel.textAlignment = .center
|
||||||
counterLabel.backgroundColor = UIColor(white: 0.11, alpha: 1)
|
counterLabel.numberOfLines = 1
|
||||||
counterLabel.layer.cornerRadius = 17
|
counterLabel.accessibilityIdentifier = "travelAlbum.previewCounterLabel"
|
||||||
counterLabel.clipsToBounds = true
|
counterLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
counterContainer.backgroundColor = UIColor(white: 0.11, alpha: 1)
|
||||||
|
counterContainer.layer.cornerRadius = 17
|
||||||
|
counterContainer.clipsToBounds = true
|
||||||
|
|
||||||
variantSegmentedControl.selectedSegmentTintColor = UIColor(white: 0.22, alpha: 1)
|
variantSegmentedControl.selectedSegmentTintColor = UIColor.white.withAlphaComponent(0.2)
|
||||||
variantSegmentedControl.backgroundColor = UIColor(white: 0.065, alpha: 1)
|
|
||||||
variantSegmentedControl.setTitleTextAttributes(
|
variantSegmentedControl.setTitleTextAttributes(
|
||||||
[.foregroundColor: UIColor.white.withAlphaComponent(0.62), .font: UIFont.systemFont(ofSize: 15)],
|
[.foregroundColor: UIColor.white.withAlphaComponent(0.7)],
|
||||||
for: .normal
|
for: .normal
|
||||||
)
|
)
|
||||||
variantSegmentedControl.setTitleTextAttributes(
|
variantSegmentedControl.setTitleTextAttributes(
|
||||||
[.foregroundColor: UIColor.white, .font: UIFont.systemFont(ofSize: 15, weight: .semibold)],
|
[.foregroundColor: UIColor.white],
|
||||||
for: .selected
|
for: .selected
|
||||||
)
|
)
|
||||||
variantSegmentedControl.accessibilityIdentifier = "travelAlbum.previewVariantSegmentedControl"
|
variantSegmentedControl.accessibilityIdentifier = "travelAlbum.previewVariantSegmentedControl"
|
||||||
@@ -189,7 +210,9 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
)
|
)
|
||||||
actionStack.accessibilityIdentifier = "travelAlbum.previewActionStack"
|
actionStack.accessibilityIdentifier = "travelAlbum.previewActionStack"
|
||||||
configureHighResolutionButton()
|
configureHighResolutionButton()
|
||||||
|
configureComparisonButton()
|
||||||
configureActions()
|
configureActions()
|
||||||
|
actionStack.isHidden = !allowsActions
|
||||||
|
|
||||||
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
|
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
|
||||||
titleStack.axis = .vertical
|
titleStack.axis = .vertical
|
||||||
@@ -200,13 +223,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
view.addSubview(topChrome)
|
view.addSubview(topChrome)
|
||||||
topChrome.addSubview(closeButton)
|
topChrome.addSubview(closeButton)
|
||||||
topChrome.addSubview(titleStack)
|
topChrome.addSubview(titleStack)
|
||||||
topChrome.addSubview(counterLabel)
|
topChrome.addSubview(counterContainer)
|
||||||
|
counterContainer.addSubview(counterLabel)
|
||||||
view.addSubview(bottomChrome)
|
view.addSubview(bottomChrome)
|
||||||
bottomChrome.addSubview(divider)
|
bottomChrome.addSubview(divider)
|
||||||
bottomChrome.addSubview(variantControls)
|
bottomChrome.addSubview(variantControls)
|
||||||
variantControls.addSubview(variantSegmentedControl)
|
variantControls.addSubview(variantSegmentedControl)
|
||||||
variantControls.addSubview(highResolutionButton)
|
variantControls.addSubview(highResolutionButton)
|
||||||
bottomChrome.addSubview(actionStack)
|
bottomChrome.addSubview(actionStack)
|
||||||
|
view.addSubview(comparisonButton)
|
||||||
|
|
||||||
topChrome.snp.makeConstraints { make in
|
topChrome.snp.makeConstraints { make in
|
||||||
make.top.leading.trailing.equalToSuperview()
|
make.top.leading.trailing.equalToSuperview()
|
||||||
@@ -217,16 +242,20 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
make.bottom.equalToSuperview().offset(-14)
|
make.bottom.equalToSuperview().offset(-14)
|
||||||
make.size.equalTo(44)
|
make.size.equalTo(44)
|
||||||
}
|
}
|
||||||
counterLabel.snp.makeConstraints { make in
|
counterContainer.snp.makeConstraints { make in
|
||||||
make.trailing.equalToSuperview().inset(16)
|
make.trailing.equalToSuperview().inset(16)
|
||||||
make.centerY.equalTo(closeButton)
|
make.centerY.equalTo(closeButton)
|
||||||
make.width.greaterThanOrEqualTo(48)
|
make.width.greaterThanOrEqualTo(48)
|
||||||
make.height.equalTo(34)
|
make.height.equalTo(34)
|
||||||
}
|
}
|
||||||
|
counterLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
titleStack.snp.makeConstraints { make in
|
titleStack.snp.makeConstraints { make in
|
||||||
make.centerY.equalTo(closeButton)
|
make.centerY.equalTo(closeButton)
|
||||||
make.leading.greaterThanOrEqualTo(closeButton.snp.trailing).offset(8)
|
make.leading.greaterThanOrEqualTo(closeButton.snp.trailing).offset(8)
|
||||||
make.trailing.lessThanOrEqualTo(counterLabel.snp.leading).offset(-8)
|
make.trailing.lessThanOrEqualTo(counterContainer.snp.leading).offset(-8)
|
||||||
make.centerX.equalToSuperview()
|
make.centerX.equalToSuperview()
|
||||||
make.width.lessThanOrEqualTo(220)
|
make.width.lessThanOrEqualTo(220)
|
||||||
}
|
}
|
||||||
@@ -249,8 +278,9 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
variantSegmentedControl.snp.makeConstraints { make in
|
variantSegmentedControl.snp.makeConstraints { make in
|
||||||
make.leading.equalToSuperview().offset(20)
|
make.leading.equalToSuperview().offset(20)
|
||||||
make.centerY.equalToSuperview()
|
make.centerY.equalToSuperview()
|
||||||
make.height.equalTo(40)
|
variantSegmentedControlWidthConstraint = make.width.equalTo(124).constraint
|
||||||
make.trailing.equalTo(highResolutionButton.snp.leading).offset(-12)
|
make.height.equalTo(32)
|
||||||
|
make.trailing.lessThanOrEqualTo(highResolutionButton.snp.leading).offset(-12)
|
||||||
}
|
}
|
||||||
highResolutionButton.snp.makeConstraints { make in
|
highResolutionButton.snp.makeConstraints { make in
|
||||||
make.trailing.equalToSuperview().inset(18)
|
make.trailing.equalToSuperview().inset(18)
|
||||||
@@ -264,6 +294,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
make.height.equalTo(70)
|
make.height.equalTo(70)
|
||||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||||
}
|
}
|
||||||
|
comparisonButton.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(16)
|
||||||
|
make.bottom.equalTo(bottomChrome.snp.top).offset(-12)
|
||||||
|
make.size.equalTo(48)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureHighResolutionButton() {
|
private func configureHighResolutionButton() {
|
||||||
@@ -283,6 +318,27 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
highResolutionButton.addTarget(self, action: #selector(highResolutionTapped), for: .touchUpInside)
|
highResolutionButton.addTarget(self, action: #selector(highResolutionTapped), for: .touchUpInside)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configureComparisonButton() {
|
||||||
|
var configuration = UIButton.Configuration.filled()
|
||||||
|
configuration.image = UIImage(named: "travel_album_before_after")?.withRenderingMode(.alwaysTemplate)
|
||||||
|
configuration.contentInsets = .zero
|
||||||
|
configuration.baseForegroundColor = .white
|
||||||
|
configuration.baseBackgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.94)
|
||||||
|
configuration.background.cornerRadius = 24
|
||||||
|
configuration.background.strokeColor = UIColor(hex: 0x263244)
|
||||||
|
configuration.background.strokeWidth = 1
|
||||||
|
comparisonButton.configuration = configuration
|
||||||
|
comparisonButton.accessibilityLabel = "前后对比"
|
||||||
|
comparisonButton.accessibilityHint = "对比当前效果图与原图"
|
||||||
|
comparisonButton.accessibilityIdentifier = "travelAlbum.previewComparisonButton"
|
||||||
|
comparisonButton.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
comparisonButton.layer.shadowOpacity = 0.28
|
||||||
|
comparisonButton.layer.shadowRadius = 8
|
||||||
|
comparisonButton.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||||
|
comparisonButton.isHidden = true
|
||||||
|
comparisonButton.addTarget(self, action: #selector(comparisonTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
private func configureActions() {
|
private func configureActions() {
|
||||||
let aiButton = makeActionButton(
|
let aiButton = makeActionButton(
|
||||||
title: "AI修图",
|
title: "AI修图",
|
||||||
@@ -376,15 +432,29 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
guard let node = currentNode else { return }
|
guard let node = currentNode else { return }
|
||||||
let asset = currentAsset
|
let asset = currentAsset
|
||||||
titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片"
|
titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片"
|
||||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(asset?.fileSize ?? 0)
|
let fileSize = asset?.fileSize ?? 0
|
||||||
|
sizeLabel.text = fileSize > 0 ? TravelAlbumDisplayFormatter.fileSizeText(fileSize) : nil
|
||||||
|
sizeLabel.isHidden = fileSize <= 0
|
||||||
counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
|
counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
|
||||||
counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张"
|
counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张"
|
||||||
highResolutionButton.isEnabled = asset != nil
|
highResolutionButton.isEnabled = asset != nil
|
||||||
highResolutionButton.alpha = asset == nil ? 0.45 : 1
|
highResolutionButton.alpha = asset == nil ? 0.45 : 1
|
||||||
rebuildTabs()
|
rebuildTabs()
|
||||||
|
updateComparisonButton()
|
||||||
loadMoreIfNeeded(projectIndex: node.projectIndex)
|
loadMoreIfNeeded(projectIndex: node.projectIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateComparisonButton() {
|
||||||
|
let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil
|
||||||
|
let shouldShow = chromeVisible && hasComparison
|
||||||
|
comparisonButton.isHidden = !hasComparison
|
||||||
|
comparisonButton.isUserInteractionEnabled = shouldShow
|
||||||
|
comparisonButton.alpha = shouldShow ? 1 : 0
|
||||||
|
comparisonButton.transform = shouldShow
|
||||||
|
? .identity
|
||||||
|
: CGAffineTransform(translationX: 0, y: 10)
|
||||||
|
}
|
||||||
|
|
||||||
private func rebuildTabs() {
|
private func rebuildTabs() {
|
||||||
while variantSegmentedControl.numberOfSegments > 0 {
|
while variantSegmentedControl.numberOfSegments > 0 {
|
||||||
variantSegmentedControl.removeSegment(at: 0, animated: false)
|
variantSegmentedControl.removeSegment(at: 0, animated: false)
|
||||||
@@ -397,6 +467,13 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
for (index, asset) in project.orderedAssets.enumerated() {
|
for (index, asset) in project.orderedAssets.enumerated() {
|
||||||
variantSegmentedControl.insertSegment(withTitle: asset.kind.title, at: index, animated: false)
|
variantSegmentedControl.insertSegment(withTitle: asset.kind.title, at: index, animated: false)
|
||||||
}
|
}
|
||||||
|
let controlWidth: CGFloat
|
||||||
|
switch project.orderedAssets.count {
|
||||||
|
case 4: controlWidth = 220
|
||||||
|
case 3: controlWidth = 180
|
||||||
|
default: controlWidth = 124
|
||||||
|
}
|
||||||
|
variantSegmentedControlWidthConstraint?.update(offset: controlWidth)
|
||||||
variantSegmentedControl.selectedSegmentIndex = project.orderedAssets.firstIndex {
|
variantSegmentedControl.selectedSegmentIndex = project.orderedAssets.firstIndex {
|
||||||
$0.kind == activeKind
|
$0.kind == activeKind
|
||||||
} ?? 0
|
} ?? 0
|
||||||
@@ -564,6 +641,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
|
|
||||||
private func toggleChrome() {
|
private func toggleChrome() {
|
||||||
chromeVisible.toggle()
|
chromeVisible.toggle()
|
||||||
|
let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil
|
||||||
|
if chromeVisible && hasComparison {
|
||||||
|
comparisonButton.isHidden = false
|
||||||
|
}
|
||||||
|
comparisonButton.isUserInteractionEnabled = chromeVisible && hasComparison
|
||||||
let reduceMotion = UIAccessibility.isReduceMotionEnabled
|
let reduceMotion = UIAccessibility.isReduceMotionEnabled
|
||||||
let animations = {
|
let animations = {
|
||||||
self.topChrome.alpha = self.chromeVisible ? 1 : 0
|
self.topChrome.alpha = self.chromeVisible ? 1 : 0
|
||||||
@@ -574,12 +656,20 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
self.bottomChrome.transform = self.chromeVisible
|
self.bottomChrome.transform = self.chromeVisible
|
||||||
? .identity
|
? .identity
|
||||||
: CGAffineTransform(translationX: 0, y: self.bottomChrome.bounds.height)
|
: CGAffineTransform(translationX: 0, y: self.bottomChrome.bounds.height)
|
||||||
|
self.comparisonButton.alpha = self.chromeVisible && hasComparison ? 1 : 0
|
||||||
|
self.comparisonButton.transform = self.chromeVisible && hasComparison
|
||||||
|
? .identity
|
||||||
|
: CGAffineTransform(translationX: 0, y: 10)
|
||||||
}
|
}
|
||||||
UIView.animate(
|
UIView.animate(
|
||||||
withDuration: reduceMotion ? 0.12 : 0.22,
|
withDuration: reduceMotion ? 0.12 : 0.22,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState],
|
options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState],
|
||||||
animations: animations
|
animations: animations,
|
||||||
|
completion: { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.comparisonButton.isHidden = !self.chromeVisible || !hasComparison
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,6 +705,17 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
toggleChrome()
|
toggleChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func comparisonTapped() {
|
||||||
|
guard presentedViewController == nil,
|
||||||
|
let project = currentProject,
|
||||||
|
let content = project.comparisonContent(for: currentAsset?.kind ?? .original)
|
||||||
|
else { return }
|
||||||
|
let controller = BeforeAfterComparisonViewController(
|
||||||
|
viewModel: BeforeAfterComparisonViewModel(content: content)
|
||||||
|
)
|
||||||
|
present(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func aiTapped() {
|
@objc private func aiTapped() {
|
||||||
guard presentedViewController == nil else { return }
|
guard presentedViewController == nil else { return }
|
||||||
guard let project = currentProject else { return }
|
guard let project = currentProject else { return }
|
||||||
@@ -639,10 +740,9 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
workflow: workflow
|
workflow: workflow
|
||||||
),
|
),
|
||||||
api: aiRetouchAPI,
|
api: aiRetouchAPI,
|
||||||
onSubmitted: { [weak self] in
|
onSubmitted: { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.showPreviewToast("AI修图任务已提交")
|
self.dismiss(animated: true) { self.onAIRetouchSubmitted?() }
|
||||||
self.reloadProjects(showGlobalLoading: false, forceRefreshImage: false)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
present(controller, animated: true)
|
present(controller, animated: true)
|
||||||
@@ -893,6 +993,56 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 深色图片预览中使用的高对比度延迟加载指示器。
|
||||||
|
@MainActor
|
||||||
|
private final class TravelAlbumPreviewLoadingIndicator: Indicator {
|
||||||
|
private let containerView = UIView()
|
||||||
|
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private var revealTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var view: IndicatorView { containerView }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
containerView.backgroundColor = UIColor(red: 39 / 255, green: 39 / 255, blue: 42 / 255, alpha: 0.94)
|
||||||
|
containerView.layer.cornerRadius = 22
|
||||||
|
containerView.isAccessibilityElement = true
|
||||||
|
containerView.accessibilityIdentifier = "travelAlbum.previewLoadingIndicator"
|
||||||
|
containerView.accessibilityLabel = "图片加载中"
|
||||||
|
activityIndicator.color = UIColor.white.withAlphaComponent(0.92)
|
||||||
|
activityIndicator.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
||||||
|
containerView.addSubview(activityIndicator)
|
||||||
|
activityIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAnimatingView() {
|
||||||
|
revealTask?.cancel()
|
||||||
|
containerView.alpha = 0
|
||||||
|
containerView.isHidden = true
|
||||||
|
revealTask = Task { @MainActor [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(150))
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
activityIndicator.startAnimating()
|
||||||
|
containerView.isHidden = false
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.12,
|
||||||
|
animations: { self.containerView.alpha = 1 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAnimatingView() {
|
||||||
|
revealTask?.cancel()
|
||||||
|
revealTask = nil
|
||||||
|
activityIndicator.stopAnimating()
|
||||||
|
containerView.alpha = 0
|
||||||
|
containerView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeStrategy(in imageView: KFCrossPlatformImageView) -> IndicatorSizeStrategy {
|
||||||
|
.size(CGSize(width: 44, height: 44))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
|
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
|
||||||
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
||||||
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
||||||
@@ -965,7 +1115,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
|||||||
|
|
||||||
imageView.contentMode = .scaleAspectFit
|
imageView.contentMode = .scaleAspectFit
|
||||||
imageView.backgroundColor = .black
|
imageView.backgroundColor = .black
|
||||||
imageView.kf.indicatorType = .activity
|
imageView.kf.indicatorType = .custom(indicator: TravelAlbumPreviewLoadingIndicator())
|
||||||
imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
|
imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
|
||||||
|
|
||||||
retryButton.setTitle("图片加载失败,点击重试", for: .normal)
|
retryButton.setTitle("图片加载失败,点击重试", for: .normal)
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 相册管理上传入口的传输模式选择 Sheet。
|
||||||
|
final class TravelAlbumTransferModeSheetViewController: UIViewController {
|
||||||
|
var onModeSelected: ((TravelAlbumOTGTransferMode) -> Void)?
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let subtitleLabel = UILabel()
|
||||||
|
private let optionsStack = UIStackView()
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||||||
|
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||||
|
modalPresentationStyle = .pageSheet
|
||||||
|
if let sheetPresentationController {
|
||||||
|
let identifier = UISheetPresentationController.Detent.Identifier("travelAlbumTransferMode")
|
||||||
|
sheetPresentationController.detents = [
|
||||||
|
.custom(identifier: identifier) { min(356, $0.maximumDetentValue) },
|
||||||
|
]
|
||||||
|
sheetPresentationController.selectedDetentIdentifier = identifier
|
||||||
|
sheetPresentationController.prefersGrabberVisible = true
|
||||||
|
sheetPresentationController.preferredCornerRadius = 22
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
setupUI()
|
||||||
|
setupConstraints()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
view.backgroundColor = .white
|
||||||
|
titleLabel.text = "选择传输模式"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
subtitleLabel.text = "选择后进入对应的照片传输页面"
|
||||||
|
subtitleLabel.font = .systemFont(ofSize: 13)
|
||||||
|
subtitleLabel.textColor = AppColor.textSecondary
|
||||||
|
subtitleLabel.textAlignment = .center
|
||||||
|
optionsStack.axis = .vertical
|
||||||
|
optionsStack.spacing = 12
|
||||||
|
TravelAlbumOTGTransferMode.allCases.enumerated().forEach { index, mode in
|
||||||
|
optionsStack.addArrangedSubview(makeOption(mode: mode, index: index))
|
||||||
|
}
|
||||||
|
cancelButton.setTitle("取消", for: .normal)
|
||||||
|
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
|
||||||
|
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
cancelButton.backgroundColor = UIColor(hex: 0xF4F5F7)
|
||||||
|
cancelButton.layer.cornerRadius = 12
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
view.addSubview(titleLabel)
|
||||||
|
view.addSubview(subtitleLabel)
|
||||||
|
view.addSubview(optionsStack)
|
||||||
|
view.addSubview(cancelButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(20)
|
||||||
|
}
|
||||||
|
subtitleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(7)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(20)
|
||||||
|
}
|
||||||
|
optionsStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
cancelButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(optionsStack.snp.bottom).offset(14)
|
||||||
|
make.leading.trailing.equalTo(optionsStack)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeOption(mode: TravelAlbumOTGTransferMode, index: Int) -> UIView {
|
||||||
|
let container = UIView()
|
||||||
|
container.backgroundColor = UIColor(hex: 0xF7F9FC)
|
||||||
|
container.layer.cornerRadius = 12
|
||||||
|
container.layer.borderWidth = 1
|
||||||
|
container.layer.borderColor = UIColor(hex: 0xE6ECF5).cgColor
|
||||||
|
let title = UILabel()
|
||||||
|
title.text = mode.title
|
||||||
|
title.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
title.textColor = AppColor.textPrimary
|
||||||
|
let detail = UILabel()
|
||||||
|
detail.text = mode.detailText
|
||||||
|
detail.font = .systemFont(ofSize: 12)
|
||||||
|
detail.textColor = AppColor.textSecondary
|
||||||
|
detail.numberOfLines = 2
|
||||||
|
let enter = UILabel()
|
||||||
|
enter.text = "进入"
|
||||||
|
enter.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||||
|
enter.textColor = AppColor.primary
|
||||||
|
let button = UIButton(type: .custom)
|
||||||
|
button.tag = index
|
||||||
|
button.accessibilityLabel = "\(mode.title),\(mode.detailText)"
|
||||||
|
button.addTarget(self, action: #selector(modeTapped(_:)), for: .touchUpInside)
|
||||||
|
container.addSubview(title)
|
||||||
|
container.addSubview(detail)
|
||||||
|
container.addSubview(enter)
|
||||||
|
container.addSubview(button)
|
||||||
|
title.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(13)
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.trailing.lessThanOrEqualTo(enter.snp.leading).offset(-12)
|
||||||
|
}
|
||||||
|
detail.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(title.snp.bottom).offset(5)
|
||||||
|
make.leading.equalTo(title)
|
||||||
|
make.trailing.lessThanOrEqualTo(enter.snp.leading).offset(-12)
|
||||||
|
}
|
||||||
|
enter.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().offset(-16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
button.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||||
|
container.snp.makeConstraints { $0.height.equalTo(78) }
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func modeTapped(_ sender: UIButton) {
|
||||||
|
guard TravelAlbumOTGTransferMode.allCases.indices.contains(sender.tag) else { return }
|
||||||
|
let mode = TravelAlbumOTGTransferMode.allCases[sender.tag]
|
||||||
|
let completion = onModeSelected
|
||||||
|
dismiss(animated: true) { completion?(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() { dismiss(animated: true) }
|
||||||
|
}
|
||||||