4 Commits
Author SHA1 Message Date
han xin e1175066b9 删除没用的卡片 2026-08-24 11:05:51 +08:00
han xin 5cf4409bad 新增线下收款记录和 ai 修图优化 2026-08-21 15:51:52 +08:00
lujiuyin 6fe9928b49 Merge branch 'dev_1_2_1' into xh_test 2026-08-03 10:48:41 +08:00
XHYourName 6129a1632f 新增ai修图 2026-07-31 16:48:28 +08:00
143 changed files with 9094 additions and 10652 deletions
+45
View File
@@ -0,0 +1,45 @@
# 注销账号页 Design QA
## 对比目标
- source visual truth path: `/Users/hxindemacmini/.codex/generated_images/01a01eb7-eda7-7f73-ae64-60c5195c974a/exec-1ece6a7c-2a8f-4d8e-a33f-aa5a4fc11c80.png`
- implementation screenshot path: `/Users/hxindemacmini/.codex/visualizations/2026/08/20/01a01eb7-eda7-7f73-ae64-60c5195c974a/account-deletion-implementation-v2.png`
- full-view comparison path: `/Users/hxindemacmini/.codex/visualizations/2026/08/20/01a01eb7-eda7-7f73-ae64-60c5195c974a/account-deletion-comparison-v2.png`
- viewport: iPhone 17 Pro 模拟器,402 × 874 pt,竖屏,浅色模式
- source pixels: 852 × 1846,按 @2x 归一化为约 426 × 923 pt
- implementation pixels: 1206 × 2622,@3x,对应 402 × 874 pt
- density normalization: 对比图将实现截图归一化到 852 × 1846,与设计图等像素并排比较
- state: 资产核验加载完成、确认框未勾选、主按钮可点击
## Full-view comparison evidence
信息层级、红色风险提示、四项资产卡片、资产明细入口、注销后果卡片、确认框和蓝色主按钮均与设计方向一致。实现保留 iOS 原生状态栏和安全区;设计图没有状态栏且画布更高,因此较小设备首屏只展示注销后果卡片的前两项,剩余内容通过原生滚动区域查看,底部确认和主按钮始终可操作。
## Focused region comparison evidence
无需额外局部裁图。等尺寸并排图中,标题、资产数字、分割线、风险色、图标、确认框和主按钮均清晰可读,已经足以核对字体、间距、颜色、图标和文案。
## Findings
- 无可执行的 P0、P1 或 P2 差异。
- 字体与排版:使用系统中文字体;标题、资产名、资产值和说明文本的层级、字重与设计一致,无截断。
- 间距与布局:16pt 页面边距、12pt 圆角、资产行节奏和卡片间距一致;原生状态栏及较短屏幕导致后果列表需要滚动,属于平台和视口差异。
- 颜色与视觉令牌:使用项目现有的页面背景、主色、危险色和边框令牌,语义与设计一致。
- 图片与图标:页面没有照片类素材;风险和后果图标使用 SF Symbols,清晰且风格统一。
- 文案与内容:资产名称、数值、注销后果和7天恢复提示与确认稿一致;没有“可放弃”标签。
## Comparison history
1. 第一轮证据:`account-deletion-comparison-v1.png`。发现 [P2] 设计图在未勾选状态仍显示蓝色主按钮,而实现显示灰色禁用按钮。
2. 修正:将主按钮的视觉可点击状态与业务确认校验分离。资产加载完成后按钮保持蓝色;未勾选时点击会提示先确认,不能进入验证页。
3. 第二轮证据:`account-deletion-comparison-v2.png`。按钮状态已与设计一致,勾选保护由单元测试验证,无新增 P0/P1/P2 差异。
## Implementation checklist
- [x] 保持资产数据中性展示,不出现“可放弃”标签
- [x] 保持未勾选时蓝色主按钮视觉
- [x] 未勾选时阻止进入验证流程并提示用户
- [x] 小屏幕使用滚动区域,底部确认与主操作常驻安全区
- [x] 保留原生 iOS 状态栏、导航栏和动态岛安全区
final result: passed
@@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="430" viewBox="0 0 1200 430">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.action { fill: #F6F8FB; stroke: #D5DEE9; stroke-width: 2; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="430" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">照片预览与左右切换</text>
<rect x="42" y="92" width="205" height="104" rx="16" class="box"/>
<text x="144" y="126" text-anchor="middle" class="label">相册管理缩略图</text>
<text x="144" y="158" text-anchor="middle" class="small">点击任意照片</text>
<path d="M247 144 H326" class="line"/>
<rect x="326" y="78" width="300" height="132" rx="18" class="active"/>
<text x="476" y="115" text-anchor="middle" class="label">单一返回按钮 + 文件信息</text>
<text x="476" y="146" text-anchor="middle" class="small">文件名 / 文件大小 / 当前照片</text>
<text x="476" y="176" text-anchor="middle" class="small">底部:AI修图、删除、刷新</text>
<path d="M626 144 H706" class="line"/>
<rect x="706" y="78" width="205" height="132" rx="18" class="box"/>
<text x="808" y="115" text-anchor="middle" class="label">左右滑动</text>
<text x="808" y="146" text-anchor="middle" class="small">切换上一张 / 下一张</text>
<text x="808" y="176" text-anchor="middle" class="small">首尾不循环</text>
<path d="M911 144 H990" class="line"/>
<rect x="990" y="92" width="168" height="104" rx="16" class="box"/>
<text x="1074" y="126" text-anchor="middle" class="label">同步刷新信息</text>
<text x="1074" y="158" text-anchor="middle" class="small">名称、大小、结果状态</text>
<rect x="42" y="258" width="344" height="112" rx="16" class="action"/>
<text x="214" y="292" text-anchor="middle" class="label">删除当前照片</text>
<text x="214" y="323" text-anchor="middle" class="small">删除后定位到相邻照片</text>
<text x="214" y="349" text-anchor="middle" class="small">最后一张删除后返回相册管理</text>
<rect x="428" y="258" width="344" height="112" rx="16" class="action"/>
<text x="600" y="292" text-anchor="middle" class="label">刷新当前照片</text>
<text x="600" y="323" text-anchor="middle" class="small">重新加载原图或最新修图结果</text>
<text x="600" y="349" text-anchor="middle" class="small">加载失败保留当前可见内容</text>
<rect x="814" y="258" width="344" height="112" rx="16" class="action"/>
<text x="986" y="292" text-anchor="middle" class="label">预览底部操作</text>
<text x="986" y="323" text-anchor="middle" class="small">保留 AI修图、删除、刷新</text>
<text x="986" y="349" text-anchor="middle" class="small">不展示打印按钮</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="430" viewBox="0 0 1200 430">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.danger { fill: #FFF1F1; stroke: #E35D6A; stroke-width: 2; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="430" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">相册管理多选操作栏</text>
<rect x="42" y="92" width="215" height="110" rx="16" class="box"/>
<text x="149" y="127" text-anchor="middle" class="label">普通浏览状态</text>
<text x="149" y="160" text-anchor="middle" class="small">底部显示“上传照片”</text>
<path d="M257 147 H337" class="line"/>
<rect x="337" y="92" width="215" height="110" rx="16" class="active"/>
<text x="444" y="127" text-anchor="middle" class="label">点击右上角选择</text>
<text x="444" y="160" text-anchor="middle" class="small">缩略图出现单选圆圈</text>
<path d="M552 147 H632" class="line"/>
<rect x="632" y="92" width="225" height="110" rx="16" class="active"/>
<text x="744" y="127" text-anchor="middle" class="label">进入多选状态</text>
<text x="744" y="160" text-anchor="middle" class="small">上传按钮隐藏</text>
<text x="744" y="184" text-anchor="middle" class="small">显示 AI修图、删除</text>
<path d="M857 147 H937" class="line"/>
<rect x="937" y="92" width="221" height="110" rx="16" class="box"/>
<text x="1048" y="127" text-anchor="middle" class="label">选择至少 1 张</text>
<text x="1048" y="160" text-anchor="middle" class="small">两个操作按钮恢复可用</text>
<rect x="140" y="268" width="310" height="110" rx="16" class="active"/>
<text x="295" y="304" text-anchor="middle" class="label">点击 AI修图</text>
<text x="295" y="336" text-anchor="middle" class="small">进入批量 AI 修图弹窗</text>
<text x="295" y="360" text-anchor="middle" class="small">包含处理中照片时按钮置灰</text>
<rect x="750" y="268" width="310" height="110" rx="16" class="danger"/>
<text x="905" y="304" text-anchor="middle" class="label">点击删除</text>
<text x="905" y="336" text-anchor="middle" class="small">二次确认后删除选中素材</text>
<text x="905" y="360" text-anchor="middle" class="small">取消确认则保持原选择</text>
<path d="M744 202 V235 H295 V268" class="line"/>
<path d="M744 202 V235 H905 V268" class="line"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="300" viewBox="0 0 1080 300">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#7A8699"/>
</marker>
<style>
.title { font: 600 18px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #1F2937; }
.detail { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #667085; }
.box { fill: #FFFFFF; stroke: #D9E2EF; stroke-width: 2; rx: 16; }
.active { fill: #EEF5FF; stroke: #1677FF; stroke-width: 2; rx: 16; }
</style>
</defs>
<rect width="1080" height="300" fill="#F7F9FC"/>
<rect class="box" x="40" y="104" width="180" height="92"/>
<text class="title" x="130" y="142" text-anchor="middle">相册管理</text>
<text class="detail" x="130" y="170" text-anchor="middle">点击上传照片</text>
<line x1="220" y1="150" x2="292" y2="150" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="active" x="305" y="70" width="230" height="160"/>
<text class="title" x="420" y="108" text-anchor="middle">选择传输模式</text>
<text class="detail" x="420" y="142" text-anchor="middle">边拍边传</text>
<text class="detail" x="420" y="174" text-anchor="middle">拍后传输</text>
<text class="detail" x="420" y="206" text-anchor="middle">取消:留在当前页</text>
<line x1="535" y1="118" x2="622" y2="78" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="535" y1="182" x2="622" y2="222" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="box" x="635" y="28" width="400" height="100"/>
<text class="title" x="835" y="68" text-anchor="middle">有线传输 · 边拍边传</text>
<text class="detail" x="835" y="96" text-anchor="middle">新照片传入后自动上传当前相册</text>
<rect class="box" x="635" y="172" width="400" height="100"/>
<text class="title" x="835" y="212" text-anchor="middle">有线传输 · 拍后传输</text>
<text class="detail" x="835" y="240" text-anchor="middle">拍摄完成后选择照片批量上传</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="470" viewBox="0 0 1200 470">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.optional { fill: #FFF8E8; stroke: #F2A93B; stroke-width: 2; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="470" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">AI 修图弹窗:原图精修、氛围感与结果组合</text>
<rect x="42" y="92" width="190" height="116" rx="16" class="box"/>
<text x="137" y="127" text-anchor="middle" class="label">打开 AI修图</text>
<text x="137" y="158" text-anchor="middle" class="small">显示已选照片数量</text>
<text x="137" y="183" text-anchor="middle" class="small">内容区域可纵向滚动</text>
<path d="M232 150 H272" class="line"/>
<rect x="272" y="78" width="230" height="144" rx="18" class="active"/>
<text x="387" y="116" text-anchor="middle" class="label">原图精修</text>
<text x="387" y="148" text-anchor="middle" class="small">预设文字 + 对应效果图</text>
<text x="387" y="174" text-anchor="middle" class="small">横向滚动,始终单选</text>
<text x="387" y="200" text-anchor="middle" class="small">生成基础“精修后”结果</text>
<path d="M502 150 H542" class="line"/>
<rect x="542" y="78" width="230" height="144" rx="18" class="optional"/>
<text x="657" y="116" text-anchor="middle" class="label">氛围感修图(选填)</text>
<text x="657" y="148" text-anchor="middle" class="small">横向滚动,多样式单选</text>
<text x="657" y="174" text-anchor="middle" class="small">默认不选,重复点击可取消</text>
<text x="657" y="200" text-anchor="middle" class="small">额外生成独立氛围感结果</text>
<path d="M772 150 H812" class="line"/>
<rect x="812" y="92" width="170" height="116" rx="16" class="box"/>
<text x="897" y="127" text-anchor="middle" class="label">只读额度</text>
<text x="897" y="158" text-anchor="middle" class="small">显示剩余 9 张</text>
<text x="897" y="183" text-anchor="middle" class="small">不提供勾选操作</text>
<path d="M982 150 H1014" class="line"/>
<rect x="1014" y="92" width="144" height="116" rx="16" class="active"/>
<text x="1086" y="127" text-anchor="middle" class="label">点击确定</text>
<text x="1086" y="158" text-anchor="middle" class="small">提交样式组合</text>
<text x="1086" y="183" text-anchor="middle" class="small">开始处理</text>
<rect x="42" y="286" width="344" height="120" rx="16" class="box"/>
<text x="214" y="322" text-anchor="middle" class="label">点击取消 / 下拉关闭</text>
<text x="214" y="354" text-anchor="middle" class="small">不启动任务</text>
<text x="214" y="380" text-anchor="middle" class="small">不改变原图、旧结果或额度状态</text>
<rect x="428" y="286" width="344" height="120" rx="16" class="box"/>
<text x="600" y="322" text-anchor="middle" class="label">效果图预览</text>
<text x="600" y="354" text-anchor="middle" class="small">各样式卡片显示“预览”按钮</text>
<text x="600" y="380" text-anchor="middle" class="small">全屏查看,不改变当前选中项</text>
<rect x="814" y="286" width="344" height="120" rx="16" class="box"/>
<text x="986" y="322" text-anchor="middle" class="label">结果独立保存</text>
<text x="986" y="354" text-anchor="middle" class="small">原图 / 精修后 / 氛围感分别查看</text>
<text x="986" y="380" text-anchor="middle" class="small">再次确认后以新组合覆盖旧结果</text>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="300" viewBox="0 0 1080 300">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#7A8699"/>
</marker>
<style>
.title { font: 600 18px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #1F2937; }
.detail { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #667085; }
.box { fill: #FFFFFF; stroke: #D9E2EF; stroke-width: 2; rx: 16; }
.active { fill: #EEF5FF; stroke: #1677FF; stroke-width: 2; rx: 16; }
</style>
</defs>
<rect width="1080" height="300" fill="#F7F9FC"/>
<rect class="box" x="40" y="104" width="210" height="92"/>
<text class="title" x="145" y="142" text-anchor="middle">有线传输照片列表</text>
<text class="detail" x="145" y="170" text-anchor="middle">点击任意缩略图</text>
<line x1="250" y1="150" x2="320" y2="150" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="active" x="333" y="70" width="260" height="160"/>
<text class="title" x="463" y="108" text-anchor="middle">多图全屏预览</text>
<text class="detail" x="463" y="142" text-anchor="middle">从被点击照片开始</text>
<text class="detail" x="463" y="174" text-anchor="middle">页码显示 当前张 / 总张数</text>
<text class="detail" x="463" y="206" text-anchor="middle">不可预览照片自动跳过</text>
<line x1="593" y1="112" x2="668" y2="74" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="593" y1="188" x2="668" y2="226" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="box" x="681" y="28" width="354" height="100"/>
<text class="title" x="858" y="68" text-anchor="middle">向右滑动</text>
<text class="detail" x="858" y="96" text-anchor="middle">查看上一张,第一张处停止</text>
<rect class="box" x="681" y="172" width="354" height="100"/>
<text class="title" x="858" y="212" text-anchor="middle">向左滑动</text>
<text class="detail" x="858" y="240" text-anchor="middle">查看下一张,最后一张处停止</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500" viewBox="0 0 1200 500">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.processing { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.success { fill: #EEFAF3; stroke: #37A86B; stroke-width: 2.5; }
.failed { fill: #FFF1F1; stroke: #E35D6A; stroke-width: 2.5; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
.retry { fill: none; stroke: #E35D6A; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="500" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">AI 修图任务生命周期与覆盖规则</text>
<rect x="42" y="104" width="205" height="116" rx="16" class="box"/>
<text x="144" y="140" text-anchor="middle" class="label">可发起</text>
<text x="144" y="172" text-anchor="middle" class="small">未修图或已有旧结果</text>
<text x="144" y="198" text-anchor="middle" class="small">AI修图按钮可点击</text>
<path d="M247 162 H337" class="line"/>
<rect x="337" y="104" width="230" height="116" rx="16" class="processing"/>
<text x="452" y="140" text-anchor="middle" class="label">修图中</text>
<text x="452" y="172" text-anchor="middle" class="small">按钮置灰,禁止重复提交</text>
<text x="452" y="198" text-anchor="middle" class="small">离开页面后任务继续完成</text>
<path d="M567 142 H692" class="line"/>
<path d="M567 182 H692" class="retry"/>
<rect x="692" y="76" width="230" height="116" rx="16" class="success"/>
<text x="807" y="112" text-anchor="middle" class="label">成功</text>
<text x="807" y="144" text-anchor="middle" class="small">分别保存精修与氛围感结果</text>
<text x="807" y="170" text-anchor="middle" class="small">默认展示“精修后”</text>
<rect x="692" y="228" width="230" height="116" rx="16" class="failed"/>
<text x="807" y="264" text-anchor="middle" class="label">失败</text>
<text x="807" y="296" text-anchor="middle" class="small">显示“修图失败”</text>
<text x="807" y="322" text-anchor="middle" class="small">恢复 AI修图按钮,可重试</text>
<path d="M922 134 H1012" class="line"/>
<rect x="1012" y="76" width="146" height="116" rx="16" class="box"/>
<text x="1085" y="112" text-anchor="middle" class="label">再次修图</text>
<text x="1085" y="144" text-anchor="middle" class="small">新组合完成后</text>
<text x="1085" y="170" text-anchor="middle" class="small">整组覆盖旧结果</text>
<path d="M1085 192 V405 H144 V220" class="line"/>
<path d="M807 344 V405 H452 V220" class="retry"/>
<rect x="337" y="380" width="230" height="82" rx="16" class="box"/>
<text x="452" y="414" text-anchor="middle" class="label">还原为原图</text>
<text x="452" y="440" text-anchor="middle" class="small">清除精修、氛围感、封面及失败状态</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500" viewBox="0 0 1200 500">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.free { fill: #FFF8E8; stroke: #F2A93B; stroke-width: 2.5; }
.cover { fill: #FFF4E8; stroke: #F59E0B; stroke-width: 2.5; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="500" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">多图三类风格选择与封面生成</text>
<rect x="42" y="92" width="220" height="116" rx="16" class="box"/>
<text x="152" y="128" text-anchor="middle" class="label">统计选中照片数</text>
<text x="152" y="160" text-anchor="middle" class="small">进入批量 AI 修图前判断</text>
<text x="152" y="186" text-anchor="middle" class="small">阈值以当前需求为准</text>
<path d="M262 150 H342" class="line"/>
<rect x="342" y="76" width="230" height="148" rx="18" class="active"/>
<text x="457" y="112" text-anchor="middle" class="label">数量是否 ≥ 4 张?</text>
<text x="457" y="147" text-anchor="middle" class="small">否:显示精修 + 氛围感</text>
<text x="457" y="175" text-anchor="middle" class="small">是:再显示封面模板</text>
<text x="457" y="201" text-anchor="middle" class="small">弹窗自动使用更高高度</text>
<path d="M572 150 H652" class="line"/>
<rect x="652" y="76" width="250" height="148" rx="18" class="free"/>
<text x="777" y="112" text-anchor="middle" class="label">显示三个风格模块</text>
<text x="777" y="147" text-anchor="middle" class="small">原图精修:单选基础预设</text>
<text x="777" y="175" text-anchor="middle" class="small">氛围感:横向单选,可取消</text>
<text x="777" y="201" text-anchor="middle" class="small">封面:横向单选,可全屏预览</text>
<path d="M902 150 H982" class="line"/>
<rect x="982" y="92" width="176" height="116" rx="16" class="cover"/>
<text x="1070" y="128" text-anchor="middle" class="label">确认并生成</text>
<text x="1070" y="160" text-anchor="middle" class="small">每张保存独立结果</text>
<text x="1070" y="186" text-anchor="middle" class="small">第一张另作封面</text>
<rect x="42" y="300" width="344" height="126" rx="16" class="box"/>
<text x="214" y="336" text-anchor="middle" class="label">当前免费阶段</text>
<text x="214" y="368" text-anchor="middle" class="small">封面 quotaCost = 0,不减少额度</text>
<text x="214" y="394" text-anchor="middle" class="small">弹窗显示“赠送 · 不占精修张数”</text>
<rect x="428" y="300" width="344" height="126" rx="16" class="box"/>
<text x="600" y="336" text-anchor="middle" class="label">未来收费阶段</text>
<text x="600" y="368" text-anchor="middle" class="small">服务端将 quotaCost 配置为正数</text>
<text x="600" y="394" text-anchor="middle" class="small">确认前展示实际消耗,成功后再扣减</text>
<rect x="814" y="300" width="344" height="126" rx="16" class="box"/>
<text x="986" y="336" text-anchor="middle" class="label">重复选择封面模板</text>
<text x="986" y="368" text-anchor="middle" class="small">新封面效果覆盖旧封面效果</text>
<text x="986" y="394" text-anchor="middle" class="small">失败时不把结果标记为已生成</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="520" viewBox="0 0 1200 520">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#7A8CA5"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 700 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.card { fill: #FFFFFF; stroke: #D5DEE9; stroke-width: 2; }
.line { fill: none; stroke: #7A8CA5; stroke-width: 2.3; marker-end: url(#arrow); }
.badge { font: 700 15px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #FFFFFF; }
</style>
</defs>
<rect width="1200" height="520" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">相册缩略图结果类型与状态优先级</text>
<rect x="42" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="58" y="114" width="70" height="28" rx="8" fill="#34C759"/>
<text x="93" y="134" text-anchor="middle" class="badge">已上传</text>
<text x="137" y="178" text-anchor="middle" class="label">普通原图</text>
<text x="137" y="210" text-anchor="middle" class="small">显示服务端缩略图</text>
<path d="M232 172 H282" class="line"/>
<rect x="282" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="298" y="114" width="70" height="28" rx="8" fill="#4D7CFE"/>
<text x="333" y="134" text-anchor="middle" class="badge">修图中</text>
<text x="377" y="178" text-anchor="middle" class="label">处理中</text>
<text x="377" y="210" text-anchor="middle" class="small">AI入口同步置灰</text>
<path d="M472 172 H522" class="line"/>
<rect x="522" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="538" y="114" width="70" height="28" rx="8" fill="#6750A4"/>
<text x="573" y="134" text-anchor="middle" class="badge">AI已修</text>
<text x="617" y="178" text-anchor="middle" class="label">普通修图结果</text>
<text x="617" y="210" text-anchor="middle" class="small">缩略图展示最新结果</text>
<path d="M712 172 H762" class="line"/>
<rect x="762" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="778" y="114" width="78" height="28" rx="8" fill="#F59E0B"/>
<text x="817" y="134" text-anchor="middle" class="badge">AI封面</text>
<text x="857" y="178" text-anchor="middle" class="label">封面结果</text>
<text x="857" y="210" text-anchor="middle" class="small">显示封面模板排版图</text>
<path d="M952 172 H1002" class="line"/>
<rect x="1002" y="98" width="156" height="148" rx="18" class="card"/>
<rect x="1018" y="114" width="88" height="28" rx="8" fill="#E53935"/>
<text x="1062" y="134" text-anchor="middle" class="badge">修图失败</text>
<text x="1080" y="178" text-anchor="middle" class="label">失败结果</text>
<text x="1080" y="210" text-anchor="middle" class="small">可再次修图</text>
<rect x="42" y="310" width="1116" height="150" rx="18" fill="#FFFFFF" stroke="#D5DEE9" stroke-width="2"/>
<text x="70" y="346" class="label">状态显示优先级</text>
<text x="70" y="382" class="small">修图中 > 修图失败 > AI封面 > AI已修 > 已上传。高优先级状态先展示,避免处理中或失败被旧成功结果遮盖。</text>
<text x="70" y="416" class="small">成功:缩略图切换为最新修图图或封面图;再次成功:覆盖旧结果;还原原图:回到“已上传”;删除素材:同步清除本地结果。</text>
<text x="70" y="446" class="small">当前 AI 修图、封面结果及角标为本地演示数据,App 进程重启后会清空;服务端接入后应返回结果类型、状态、结果图地址和模板 ID。</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

-85
View File
@@ -1,85 +0,0 @@
# AI 修图完整链路设计稿 V1
## 交付内容
| 文件 | 页面 / 状态 |
|---|---|
| `00-flow-overview.png` | 六张关键屏稿总览 |
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张照片 |
| `02-template-selection.png` | AI 修图模板选择 Sheet |
| `03-template-preview.png` | 模板全屏预览 |
| `04-result-success.png` | 精修结果 · 成功 |
| `05-result-processing.png` | 精修结果 · 生成新版本中 |
| `06-result-failed.png` | 精修结果 · 新版本生成失败 |
独立屏稿均为 853 × 1844 像素(约 9:19.5),面向 iOS 16+、iPhone 竖屏、浅色模式。
## 设计系统
- 主色:`#1677FF`;辅助渐变:`#5CA8FF`、`#A7D8FF`。
- 页面底色:`#F7FAFF`;主文字:`#172033`;次文字:`#667085`。
- 失败色:`#EF4444`;赠送/免费标签使用橙色,避免与主要操作竞争。
- 字体:SF Pro / 苹方风格;圆角 12–16 pt;以 8 pt 为基础间距。
- 正文置于高不透明度表面;极光光晕只用于背景与重点操作,不承载关键信息。
- 结果页只通过“原图 / 精修后 / 氛围感”标签切换,不使用对比滑杆。
- 重新修图期间及失败后保留上一版成功图片;状态反馈只占局部,不遮挡照片。
## 可复用 ImageGen 提示词
以下提示词使用内置 ImageGen。首屏以 `../travel-album-management-redesign-v2.png` 为风格参考;后续页面以上一张已确认屏稿作为参考,确保视觉连续。
### 通用前缀
```text
Use case: ui-mockup
Asset type: shippable high-fidelity iOS app screen, full portrait canvas, no device frame
Style/medium: realistic production UIKit UI, light Aurora blue, crisp SF Pro / PingFang-like typography, SF Symbols-style icons, 12–16 pt corners, 8 pt spacing grid, subtle borders and restrained shadows.
Color palette: primary #1677FF, gradient accents #5CA8FF and #A7D8FF, background #F7FAFF, text #172033 and #667085, danger #EF4444.
Constraints: iPhone portrait 9:19.5; iOS status bar and home indicator; practical UIKit-buildable hierarchy; accessible contrast; minimum 44 pt touch targets; no dark mode; no watermark; no decorative device frame; no annotations.
Text constraints: render specified Chinese text exactly once; no gibberish, duplicate labels or unrelated copy.
```
### 01 · 相册多选
```text
Create the album management screen in multi-selection mode. Top navigation: “相册管理” and “完成”. Album summary: “旅拍相册”, “138****8000”. Filter row: “全部 128”, “已购 36”, “时间 ↓”. Use a precise three-column grid of nine travel photos. Four photos have blue checkmarks, blue outlines and subtle selected overlays; other photos show empty selectors. Fixed bottom actions: red outline “删除 4 张” and blue gradient “AI修图 4 张”.
```
### 02 · 模板选择
```text
Create a large native page sheet titled “AI修图” with subtitle “已选择 4 张照片”. Show the note “每张照片生成精修结果,氛围感可选;本次可免费生成 1 张封面”. Include three horizontal sections: “原图精修” + “必选”, “氛围感修图” + “选填”, “封面风格” + “赠送 · 不占额度”. Each section has three photographic cards, a clear single selected state and “预览” buttons. Cost card: “AI精修 · 按张收费”, “预计消耗 8 张”, “剩余 12 张”. Fixed actions: “取消” and “确定”.
```
### 03 · 模板预览
```text
Create an immersive full-screen preview for the selected “自然通透” template. Use a dark navy photographic canvas, a circular back button, title “自然通透” and subtitle “双指缩放查看细节”. The large photo shows a woman in a straw hat overlooking Santorini. Add “模板效果预览”. Bottom information panel: “自然通透”, “提亮肤色与画面通透感,保留自然质感”, “当前已选择”. Primary action: “使用此模板”. Do not add a comparison slider.
```
### 04 · 正常结果
```text
Create “照片预览” with counter “1 / 9”. Add exactly three tabs: “原图”, “精修后”, “氛围感”; select “精修后”. Show the same Santorini photo with badge “AI已修”, filename “IMG_8291.JPG” and “自然通透 · 2026/08/13”. Information card: “精修结果”, “自然通透模板 · 已生成最新版本”. Fixed actions: “AI修图”, “删除”, “刷新”. Use tabs only; no slider or press-and-hold comparison.
```
### 05 · 处理中状态编辑
```text
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with spinner + “修图中”. Replace the result card with “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, plus an indeterminate shimmer line with no percentage. Keep the previous photo unobscured. Disable only the “AI修图” button; leave “删除” and “刷新” available.
```
### 06 · 失败状态编辑
```text
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with a local red badge “生成失败”. Replace the result card with pale-red recovery content: “新版本生成失败”, “上一版结果已保留,可重新修图”, and an outline action “重新修图”. Keep the previous photo unobscured and restore the bottom “AI修图” button to its primary blue state. Do not show a modal, stack trace or error code.
```
## QA 记录
- 六张独立屏稿尺寸一致,使用同一旅拍照片体系与组件语言。
- 精修必选、氛围感选填、四张照片触发封面、封面不占额度均已表达。
- 成功、处理中、失败三种状态均保留同一照片与结果标签,便于直接比较。
- 处理中不展示虚构百分比;重复提交按钮禁用,刷新仍可用。
- 失败状态提供“重新修图”恢复路径,且上一版结果保持可见。
- 本目录只包含设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

-76
View File
@@ -1,76 +0,0 @@
# AI 修图完整链路设计稿 V2
## 本轮调整
- 第 1、2 张沿用 V1 的浅色“轻盈极光蓝”风格。
- 第 3–6 张改为统一的黑色专业影像工作区,突出照片本身。
- 暗色页面不做简单反色:背景、浮层、边框、文字和按钮均使用独立暗色语义层级。
- V1 完整保留在 `../ai-retouch-v1/`,便于对比和回退。
## 交付内容
| 文件 | 页面 / 状态 | 风格 |
|---|---|---|
| `00-flow-overview-v2.png` | 六张关键屏稿总览 | 混合流程 |
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张 | 浅色 |
| `02-template-selection.png` | AI 修图模板选择 Sheet | 浅色 |
| `03-template-preview-dark.png` | 模板全屏预览 | 暗色 |
| `04-result-success-dark.png` | 精修结果 · 成功 | 暗色 |
| `05-result-processing-dark.png` | 精修结果 · 生成中 | 暗色 |
| `06-result-failed-dark.png` | 精修结果 · 生成失败 | 暗色 |
## 暗色设计 Token
- 页面背景:`#05070D`,允许向深海军蓝 `#07111F` 做非常轻微的层次过渡。
- 抬升表面:`#111827`;边框:`#263244`。
- 主文字:`#F8FAFC`;次文字:`#9CA3AF`;未选中标签:`#C7CED9`。
- 品牌主色:`#1677FF`;蓝色高光:`#5CA8FF`。
- 失败色:`#EF4444`,仅用于失败徽标、恢复入口和局部边框。
- 图片保持明亮自然,不叠加全屏蒙层;信息区和操作区不出现白色表面。
## 可复用 ImageGen 暗色编辑提示词
以下提示词使用内置 ImageGen,以对应 V1 页面作为 edit target;第 3 张暗色稿确定后,也作为第 4–6 张的风格参考。
### 通用暗色编辑约束
```text
Use case: precise-object-edit
Restyle the target into a cohesive dark professional photography workspace.
Preserve the exact iPhone portrait dimensions, photo content and crop, person identity and pose, all Chinese copy, hierarchy, controls and safe-area spacing.
Use background #05070D, elevated surface #111827, border #263244, primary #1677FF, text #F8FAFC and secondary #9CA3AF.
Keep the bright photo unchanged as the focal point. Use white text and SF Symbols-style white icons. Use brand blue only for selected state and primary actions.
No white page areas, no comparison slider, no device frame, no annotations, no watermark and no additional copy.
```
### 模板预览
```text
Keep “自然通透”, “双指缩放查看细节”, the Santorini photo and “模板效果预览”. Change the lower information panel to charcoal with white heading, muted description and blue “当前已选择”. Change the complete bottom safe area to black and retain the blue “使用此模板” action.
```
### 修图结果
```text
Keep “照片预览”, “1 / 9” and the three tabs “原图 / 精修后 / 氛围感”. Use a charcoal segmented control with “精修后” selected in blue. Keep “AI已修”, the filename metadata, the photo and the result information. Change the result card and secondary actions “删除 / 刷新” to dark surfaces; keep “AI修图” blue.
```
### 处理中状态
```text
Based on the approved dark result screen, change only the badge to spinner + “修图中”. Keep the previous photo visible. Change the card to “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, with an indeterminate shimmer line and no percentage. Disable only “AI修图”.
```
### 失败状态
```text
Based on the approved dark result screen, change only the badge to “生成失败”. Keep the previous photo visible. Use a charcoal recovery card with restrained red border, “新版本生成失败”, “上一版结果已保留,可重新修图” and “重新修图”. Keep the main “AI修图” button blue. Do not use a white or pale-red card.
```
## QA 记录
- 第 1、2 张与 V1 像素级复用,没有重新生成。
- 第 3–6 张均为 853 × 1844,暗色背景、表面、文字和操作层级一致。
- 成功、处理中、失败三态保留同一结果标签、照片和底部操作布局。
- 处理中无虚构百分比;失败状态有明确恢复动作;旧结果始终可见。
- 本目录只新增设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

+281
View File
@@ -0,0 +1,281 @@
# 相册管理与 AI 修图功能说明
> 文档日期:2026-08-20
> 适用范围:新增相册 → 相册任务 → 相册管理 → 照片上传与传输 → 照片预览与 AI 修图
> 数据说明:相册和照片列表、有线相机连接及原图上传继续使用项目已有真实数据能力;传输模式与相册自动修图模板保存在本地。自动修图目前使用本地图像效果演示,修图结果图和任务状态可随 OTG 相册恢复;手动原图精修、氛围感、封面模板和剩余 9 张额度仍为本地演示数据。
## 1. 照片预览、左右滑动与底部操作
### 功能说明
用户在“相册管理”照片网格中点击任意缩略图,进入单张照片预览页。页面只保留一个返回按钮,导航区域展示当前照片文件名和文件大小;照片区域支持左右滑动查看同一相册内的上一张或下一张照片。
预览页统一使用黑色沉浸式自定义头部,不再混用系统导航栏:左侧为圆形返回按钮,中间展示文件名和文件大小,右侧展示“当前张数/总张数”。切换照片时三处信息同步更新。照片预览与原图精修、氛围感、封面模板预览都直接使用同一个全屏模态容器,不进入项目导航栈,因此不会叠加系统白色导航栏或出现两个返回按钮;关闭后回到进入前的相册管理页或修图弹窗。
底部固定展示以下三个操作,不展示打印按钮:
| 操作 | 点击结果 | 操作期间或异常处理 |
| --- | --- | --- |
| AI修图 | 打开当前照片的 AI 修图弹窗 | 当前照片处于“修图中”时按钮置灰,避免重复提交 |
| 删除 | 二次确认后删除当前照片 | 删除成功后自动定位到相邻照片;删除最后一张后返回相册管理 |
| 刷新 | 重新加载当前照片及最新修图结果 | 刷新失败时不清空当前已显示内容,可再次点击 |
左右滑动时同步刷新文件名、文件大小、原图/修图后状态和底部按钮状态。第一张继续向右滑、最后一张继续向左滑时保持当前照片,不循环跳转。
页面底部图标使用项目内 `TravelAlbumPreviewIcons` SVG 资源,确保 AI 修图、删除、刷新三个入口视觉一致。
### 状态流转图
![照片预览与左右切换流程](./assets/travel-album-ai/01-photo-preview-flow.svg)
### 本功能验收
- [ ] 从任意缩略图进入时只显示一个返回入口。
- [ ] 文件名和文件大小与当前照片一致。
- [ ] 导航头部保持黑色沉浸样式,右侧张数随照片切换同步变化。
- [ ] 左右滑动后照片和文件信息同步变化。
- [ ] 首尾照片不会越界或循环。
- [ ] 底部仅显示 AI修图、删除、刷新,不显示打印。
- [ ] 删除当前照片后能正确定位或退出预览。
## 2. 上传照片与传输模式选择
### 功能说明
相册管理处于普通状态时,用户点击底部“上传照片”,先从底部弹出“选择传输模式”,展示“边拍边传”和“拍后传输”两个选项。点击任一选项后弹窗关闭,并进入当前相册的有线传输页面;点击“取消”或向下关闭弹窗时停留在相册管理,不改变现有照片和选择状态。
| 传输模式 | 弹窗说明 | 下一页初始状态 | 后续行为 |
| --- | --- | --- | --- |
| 边拍边传 | 相机拍摄后,照片自动传输并上传到当前相册 | 传输模式显示“边拍边传” | 新传入 App 的照片自动上传 |
| 拍后传输 | 拍摄完成后,再选择相机照片批量传输 | 传输模式显示“拍后传输” | 新照片先保留,用户后续选择并上传 |
本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据,不创建新的相册或假照片。
### 自动 AI 修图
新建相册时在价格字段下方固定展示“不修图”和“AI 修图”两个选项,默认选中“不修图”。点击“AI 修图”后再从底部弹出模板选择;每个模板同时展示本地演示照片套用该效果后的缩略图和效果说明。未选模板时不会开启 AI 修图。相册创建成功后,配置按真实相册 ID 保存,上传页仅回显当前一级方式“不修图 / AI 修图”。
有线传输页顶部的设置项也仅回显“不修图 / AI 修图”。点击后底部弹窗的第一层始终只有这两个选项;选择“AI 修图”后进入带效果缩略图的模板列表,可返回第一层重新选择。新配置只影响尚未开始的上传任务。每张照片仍先按原流程上传原图,得到服务端素材 ID 后才执行自动修图:
| 阶段 | 列表展示 | 统计归类 | 失败处理 |
| --- | --- | --- | --- |
| 原图上传 | 蓝色“上传中”及进度 | 全部 | 进入上传失败,可重传 |
| 自动修图 | 蓝色“修图中” | 全部 | 不回退原图上传结果 |
| 修图成功 | 绿色“已上传”,缩略图右上角显示紫色“修” | 已上传 | 使用修图后结果预览 |
| 修图失败 | 红色“修图失败” | 已上传 | 更多菜单选择“重新修图”,不重复上传原图 |
修图中退出 App 时,再次进入会恢复为“修图失败”并允许重试,避免任务长期停留在处理中。
进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。
### 状态流转图
![上传照片与传输模式选择流程](./assets/travel-album-ai/02-upload-transfer-mode-flow.svg)
![有线传输缩略图预览流程](./assets/travel-album-ai/03-wired-transfer-preview-flow.svg)
### 本功能验收
- [ ] 点击“上传照片”后先显示底部传输模式弹窗,不直接进入下一页。
- [ ] 弹窗同时显示“边拍边传”和“拍后传输”及对应说明。
- [ ] 选择“边拍边传”后,下一页模式显示为“边拍边传”。
- [ ] 选择“拍后传输”后,下一页模式显示为“拍后传输”。
- [ ] 新建相册固定展示“不修图”和“AI 修图”两个选项。
- [ ] 选择“AI 修图”后才弹出模板列表,每个模板都能看到差异化效果图。
- [ ] 上传页一级弹窗只有两种修图方式,新配置仅影响后续上传。
- [ ] 修图中、修图成功“修”角标和修图失败三种状态展示正确。
- [ ] 修图失败仍计入“已上传”,点击“重新修图”不重复上传原图。
- [ ] 本次选择不会被上一次保存的模式覆盖。
- [ ] 取消或关闭弹窗后仍停留在相册管理。
- [ ] 非批量选择状态点击缩略图后,从被点击照片进入全屏预览。
- [ ] 预览可左右滑动查看当前 Tab 中的上一张和下一张,页码同步变化且首尾不循环。
- [ ] 批量选择状态点击照片只改变选择状态,不打开预览。
## 3. 相册管理多选与底部操作栏切换
### 功能说明
相册管理页普通状态下,底部显示“上传照片”。用户点击右上角选择按钮后进入多选状态,每张缩略图右上角显示选择圆圈,底部“上传照片”隐藏,替换为“AI修图”和“删除”。
| 多选状态 | 页面表现 | 可用操作 | 下一状态 |
| --- | --- | --- | --- |
| 未进入多选 | 不显示选择圆圈,底部显示上传照片 | 点击照片进入预览;点击右上角选择 | 进入多选 |
| 已进入但未选照片 | 显示未选圆圈,AI修图和删除置灰 | 选择照片;再次点击右上角选择退出 | 已选择或退出 |
| 已选择照片 | 选中照片显示勾选,AI修图和删除可用 | 发起批量修图或批量删除 | 弹窗、处理中或删除结果 |
| 选中项含修图中的照片 | 保留当前选择,AI修图置灰 | 等待任务完成;删除仍按现有删除规则处理 | 任务结束后恢复 |
| 退出多选 | 清空本次选择,恢复上传照片 | 上传或进入照片预览 | 普通状态 |
批量删除必须二次确认;用户取消时不删除任何照片并保留当前选择。批量 AI 修图复用与单张预览相同的预设、任务状态和结果覆盖逻辑。
### 状态流转图
![相册管理多选操作栏流程](./assets/travel-album-ai/02-multi-select-flow.svg)
### 本功能验收
- [ ] 进入多选后上传按钮消失,AI修图和删除出现。
- [ ] 未选择照片时两个批量按钮不可点击。
- [ ] 选择至少一张后两个批量按钮恢复可用。
- [ ] 包含修图中照片时不能再次发起 AI 修图。
- [ ] 退出多选后选择状态清空并恢复上传按钮。
## 4. AI 修图弹窗、原图精修、氛围感与按张收费模式
### 功能说明
用户从单张预览或相册多选操作栏点击“AI修图”后,以同一个底部弹窗展示 AI 修图配置。标题与弹窗顶部保留足够间距,标题下方展示“已选择 N 张照片”。弹窗内容可纵向滚动,取消和确定按钮固定在底部,照片较多并出现封面模块时也不会遮挡选择项。
“原图精修”是基础修图模块,使用横向滚动单选列表,每个预设同时展示名称和对应效果图:
| 预设 | 当前效果用途 |
| --- | --- |
| 还原为原图 | 清除该照片已有修图结果和封面类型 |
| 写真-简约肖像 | 低饱和、提亮的人像效果 |
| 写真-清冷古风 | 清冷复古效果 |
| 旅拍-锦绣 | 提升饱和度和对比度 |
| 旅拍-远山 | 低饱和、柔和远景效果 |
| 旅拍-薄雾 | 提亮并降低对比度 |
| 油画-夏日 | 偏暖夏日效果 |
| 油画-浓郁 | 高饱和、高对比效果 |
原图精修默认选中“还原为原图”,用户点击其他预设后仅保留一个选中项。没有可用原图时,效果图位置显示稳定占位图,不影响弹窗选择。每张样式效果图右下角都有“预览”按钮,点击后全屏查看当前照片应用该样式的效果,不改变当前选中项。
“氛围感修图”位于原图精修下方,并明确标记为“选填”。该模块与原图精修保持一致,使用横向滚动效果图单选,当前本地演示包含日落暖阳、复古胶片、清透蓝调、森系薄雾、浓郁故事五种样式。默认不选中;点击一种样式后仅保留该项,再次点击当前样式即可取消。每个样式同样提供“预览”按钮。选中后不是把氛围效果叠加并合并到精修图中,而是为每张照片额外生成一张可独立查看的“氛围感”结果。
原图精修、氛围感、封面模板和“查看高清图”统一进入同一个沉浸式大图预览页。大图页不再套用系统导航栏,只显示一个返回按钮、当前样式名称和“双指缩放查看细节”提示;关闭后回到原弹窗或照片页,并保留关闭前的选择状态。
弹窗内的 Tips 会随当前组合实时变化:
| 当前选择 | 确认后的结果 |
| --- | --- |
| 仅原图精修 | 每张照片生成 1 个“精修后”结果 |
| 原图精修 + 氛围感修图 | 每张照片生成“精修后 + 氛围感”2 个独立结果 |
| 有封面、未选氛围感 | 每张照片生成精修结果,第一张选中照片额外生成封面 |
| 有封面、已选氛围感 | 每张照片生成精修和氛围感 2 个结果,第一张选中照片再额外生成封面 |
“修图模式”区域只读展示“AI精修 · 按张收费”和“剩余 9 张”,不再显示勾选圆圈,也不能点击选择。当前剩余数量为本地演示数据,不会真实扣减额度;未来接入服务端时由接口返回剩余额度,并以任务创建成功或服务端明确返回的扣减结果为准。
点击“确定”提交当前原图精修、氛围感和封面组合并关闭弹窗;点击“取消”或关闭弹窗时不创建任务、不改变旧修图结果,也不改变额度。
### 状态流转图
![AI 修图弹窗流程](./assets/travel-album-ai/03-ai-edit-sheet-flow.svg)
### 本功能验收
- [ ] 标题与弹窗顶部间距自然,不贴近顶部。
- [ ] 已选照片数量准确。
- [ ] 原图精修预设同时展示文字和效果图,并可横向滚动、保持单选。
- [ ] 原图精修、氛围感和封面模板效果图均可通过小按钮全屏预览,预览不改变选择。
- [ ] 所有大图预览使用同一黑色头部、单一返回入口,并支持双指缩放。
- [ ] 氛围感修图横向展示多个效果图,默认不选中、保持单选,可再次点击当前样式取消。
- [ ] 同时选择原图精修和氛围感时,每张照片生成两个独立结果。
- [ ] Tips 能根据氛围感和封面选择动态说明实际输出结果。
- [ ] 修图模式只展示按张收费与剩余张数,不提供选择操作。
- [ ] 取消弹窗不会创建任务或覆盖旧结果。
## 5. AI 修图任务状态、失败重试与结果覆盖
### 功能说明
用户确认修图后,所选照片立即进入“修图中”。任务未结束前,相关 AI 修图入口置灰;即使用户离开照片预览页,本地演示任务仍会继续完成,并通过共享结果状态刷新相册缩略图。
| 任务状态 | 进入条件 | 页面表现 | 可用操作 | 后续状态 |
| --- | --- | --- | --- | --- |
| 可发起 | 未修图、已有成功结果或上次任务已结束 | AI修图按钮可用 | 选择预设并确认 | 修图中 |
| 修图中 | 已提交预设,等待结果 | 按钮置灰;缩略图显示“修图中” | 查看其他照片、离开页面 | 成功或失败 |
| 成功 | 获取原图并完成本地效果处理 | 分别保存精修、氛围感和封面结果;默认展示“精修后”;按钮恢复 | 在已有结果间切换、再次修图、还原原图 | 新任务或已上传 |
| 失败 | 原图无法获取或处理失败 | 缩略图显示“修图失败”;按钮恢复 | 再次修图 | 修图中 |
成功后,照片预览页至少出现“原图 / 精修后”切换;本次选择了氛围感时增加“氛围感”,存在封面结果时增加“封面”。各结果互相独立,不把两个效果合成同一张图;有修图结果时还可以点击“查看高清图”。再次修图时,旧结果在新任务处理期间继续保留,新任务成功后整组替换为本次最新组合:本次未选择氛围感时会清除旧氛围感结果;新任务失败时保留旧成功图片,但状态优先显示“修图失败”,提醒用户本次任务未成功。
选择“还原为原图”会同时清除修图结果、失败状态和封面类型,缩略图恢复为“已上传”。删除照片时同步清理该照片的本地修图记录,避免已删除照片的结果残留。
### 状态流转图
![AI 修图任务状态流转](./assets/travel-album-ai/04-ai-task-state-flow.svg)
### 本功能验收
- [ ] 修图中不能重复点击 AI修图。
- [ ] 离开预览页后任务仍能结束并刷新缩略图。
- [ ] 成功后可切换查看原图、精修后以及本次实际生成的氛围感或封面。
- [ ] 同选原图精修和氛围感时两个结果均可独立查看。
- [ ] 再次修图成功后以本次结果组合覆盖旧结果。
- [ ] 失败后按钮恢复并允许重试。
- [ ] 还原原图后所有本地修图类型和结果被清除。
## 6. 选中 4 张及以上时的封面模板选择
### 功能说明
从相册管理多选照片并点击“AI修图”时,系统根据选中照片数量决定是否展示“封面风格模板”:
| 选中数量 | 弹窗表现 |
| --- | --- |
| 1~3 张 | 展示“原图精修 + 氛围感修图(选填)”两个模块,不展示封面模板 |
| ≥ 4 张 | 展示“原图精修 + 氛围感修图(选填)+ 封面模板”三个模块,并提高弹窗高度 |
封面模板为横向滚动单选,默认选中“旅行纪念册”。每个模板效果图右下角提供“预览”按钮,点击后全屏查看完整排版,不改变当前模板选择。当前提供:
- 旅行纪念册
- 简约留白
- 景区故事
- 电影胶片
每个模板展示名称和以第一张选中照片生成的排版效果图。用户确认后,所有选中照片先生成原图精修结果;选择了氛围感时,所有照片再各自生成一个氛围感结果;第一张选中照片另外生成独立封面并作为封面承载图。封面不会替代精修或氛围感结果,用户进入预览后可分别切换查看。
当前所有封面模板 `quotaCost = 0`,弹窗显示“赠送 · 不占精修张数”,不会减少剩余修图额度。后续收费时保留同一选择流程,由服务端为模板返回大于 0 的 `quotaCost`,客户端在确认前显示实际消耗,并以服务端成功结果为准扣减;收费能力目前尚未实现。
再次对不少于 4 张照片选择新封面模板并修图时,新封面效果覆盖旧封面效果。封面承载图处理失败时不标记为封面成功,缩略图显示“修图失败”并允许重试。选择“还原为原图”时清除封面效果和封面类型。
### 状态流转图
![多图封面模板选择流程](./assets/travel-album-ai/05-cover-template-flow.svg)
### 本功能验收
- [ ] 选中 3 张时不显示封面模板。
- [ ] 选中 4 张或更多时显示封面模板。
- [ ] 有封面模块时,弹窗同时完整显示原图精修、氛围感和封面三个风格模块。
- [ ] 封面模板可横向滚动且始终单选。
- [ ] 每个封面模板均可独立预览,关闭预览后保留原选择。
- [ ] 默认选中旅行纪念册。
- [ ] 当前显示赠送并且不消耗精修额度。
- [ ] 成功后第一张选中照片成为封面承载图。
- [ ] 再次生成封面时以新结果覆盖旧结果。
## 7. 相册缩略图结果类型与显示优先级
### 功能说明
相册管理缩略图左上角复用同一状态位,根据照片当前结果显示不同类型,让用户无需进入预览即可区分未修、处理中、已修、封面和失败照片。
| 缩略图状态 | 角标文案与颜色 | 缩略图内容 | 进入条件 | 可用操作 |
| --- | --- | --- | --- | --- |
| 普通原图 | 绿色“已上传” | 服务端原图或缩略图 | 没有本地修图结果 | 预览、选择、AI修图、删除 |
| 处理中 | 蓝色“修图中” | 有旧成功结果时继续显示旧结果,否则显示原图 | AI 任务未结束 | 预览、删除;不能再次修图 |
| 普通修图结果 | 紫色“AI已修” | 最新精修图片 | 原图精修成功,可同时存在氛围感结果 | 预览原图/精修后/氛围感、再次修图、还原 |
| 封面结果 | 橙色“AI封面” | 所选封面模板排版图 | 封面承载图生成成功 | 预览、再次修图、重新生成封面、还原 |
| 修图失败 | 红色“修图失败” | 有旧成功结果时保留旧图,否则显示原图 | 最近一次任务失败 | 再次修图、删除 |
状态显示优先级为:
`修图中 > 修图失败 > AI封面 > AI已修 > 已上传`
该优先级保证处理中或失败状态不会被历史成功结果遮盖。普通修图成功后显示“AI已修”;封面模板成功后,第一张选中照片显示封面排版图和“AI封面”,其他照片继续显示“AI已修”。再次修图成功后缩略图切换为最新结果;还原原图后回到“已上传”;删除照片后对应本地状态一起删除。
当前状态和结果由内存数据源在相册管理与照片预览之间共享,尚不支持 App 重启后的恢复。服务端接入时至少需要返回素材 ID、任务状态、结果类型、结果图地址、原图精修预设 ID、是否生成氛围感、封面模板 ID、失败原因和额度消耗,客户端才能恢复完全一致的缩略图状态。
### 状态流转图
![相册缩略图结果状态](./assets/travel-album-ai/06-thumbnail-state-flow.svg)
### 本功能验收
- [ ] 未修图照片显示“已上传”。
- [ ] 修图中照片显示“修图中”且不能重复提交。
- [ ] 普通成功结果显示“AI已修”和最新效果图。
- [ ] 封面成功结果显示“AI封面”和封面排版图。
- [ ] 最近一次任务失败时优先显示“修图失败”。
- [ ] 还原、覆盖和删除后缩略图状态同步更新。
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "payment_method_alipay.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -0,0 +1,4 @@
<!-- 来源:阿里巴巴矢量图标库,支付宝支付,图标 ID 1486848 -->
<svg width="24" height="24" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path d="M1024.0512 701.0304V196.864A196.9664 196.9664 0 0 0 827.136 0H196.864A196.9664 196.9664 0 0 0 0 196.864v630.272A196.9152 196.9152 0 0 0 196.864 1024h630.272a197.12 197.12 0 0 0 193.8432-162.0992c-52.224-22.6304-278.528-120.32-396.4416-176.64-89.7024 108.6976-183.7056 173.9264-325.3248 173.9264s-236.1856-87.2448-224.8192-194.048c7.4752-70.0416 55.552-184.576 264.2944-164.9664 110.08 10.3424 160.4096 30.8736 250.1632 60.5184 23.1936-42.5984 42.496-89.4464 57.1392-139.264H248.064v-39.424h196.9152V311.1424H204.8V267.776h240.128V165.632s2.1504-15.9744 19.8144-15.9744h98.4576V267.776h256v43.4176h-256V381.952h208.8448a805.9904 805.9904 0 0 1-84.8384 212.6848c60.672 22.016 336.7936 106.3936 336.7936 106.3936zM283.5456 791.6032c-149.6576 0-173.312-94.464-165.376-133.9392 7.8336-39.3216 51.2-90.624 134.4-90.624 95.5904 0 181.248 24.4736 284.0576 74.5472-72.192 94.0032-160.9216 150.016-253.0816 150.016z" fill="#009FE8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 512 B

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

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,13 +0,0 @@
{
"images" : [
{
"filename" : "ai_retouch_template_placeholder.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

@@ -1,8 +0,0 @@
{
"images" : [
{ "filename" : "travel_album_cover_photo_icon.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "travel_album_cover_photo_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "travel_album_cover_photo_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

@@ -1,8 +0,0 @@
{
"images" : [
{ "filename" : "travel_album_header_background@1x.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "travel_album_header_background@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "travel_album_header_background@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

@@ -1,8 +0,0 @@
{
"images" : [
{ "filename" : "travel_album_sort_icon.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "travel_album_sort_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "travel_album_sort_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 970 B

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "travel_album_preview_ai_edit.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path fill="#FFFFFF" d="M764 824q-40 40 -97 40t-97 -40l-544 -542v-320q0 -25 17.5 -43t42.5 -18h340l528 538q41 40 41 97t-41 97zM338 -29h-179q-26 0 -44.5 18.5t-18.5 45.5v196q12 -3 31 -9t65 -29.5t77 -53.5q29 -27 63.5 -86t38.5 -82h-33zM885 463l-435 -438q-73 119 -124 171q-15 15 -39.5 32t-48 31t-45.5 26t-35 19l-13 6l448 446q24 24 60.5 24t60.5 -25l171 -171q25 -26 25 -61.5t-25 -59.5zM611 315l-43 43l32 31l42 -42zM692 397l-42 42l126 127l43 -43z"/>
</svg>

After

Width:  |  Height:  |  Size: 541 B

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "travel_album_preview_delete.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path fill="#FFFFFF" d="M368 440v-279q0 -13 8.5 -22t20.5 -9t20 8q9 10 9 23v279q0 13 -8.5 22.5t-20.5 9.5t-20.5 -9.5t-8.5 -22.5zM569 440v-279q0 -13 8.5 -22t20.5 -9t20.5 9t8.5 22v279q0 13 -8.5 22.5t-20.5 9.5t-20.5 -9.5t-8.5 -22.5zM283 -109h442q57 0 97 42t40 102v606h95q12 0 20.5 9.5t8.5 22.5t-8.5 22t-20.5 9h-259l-1 25q0 53 -35 101t-81 48h-154q-45 0 -80 -48t-36 -101v-25h-251q-12 0 -20.5 -9t-8.5 -22t8.5 -22.5t20.5 -9.5h87v-606q0 -60 40 -102t96 -42zM369 730q1 27 18.5 55t37.5 30h2h154q21 -2 39 -30t19 -55l1 -26h-272zM205 641h599v-606q0 -34 -23 -57.5t-56 -23.5h-442q-33 0 -55.5 23.5t-22.5 57.5v606v0z"/>
</svg>

After

Width:  |  Height:  |  Size: 700 B

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "travel_album_preview_refresh.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path fill="#FFFFFF" d="M505.5 -127q-102.5 0 -196.5 39q-90 39 -159.5 108.5t-108.5 159.5q-39 94 -39 196.5t39 195.5q39 90 108.5 160t159.5 108q94 40 196.5 40t195.5 -40q90 -38 160 -108t108 -160q40 -93 40 -195.5t-40 -196.5q-38 -90 -108 -159.5t-160 -108.5q-93 -39 -195.5 -39zM505 819q-90 0 -172 -35q-80 -33 -141 -94.5t-95 -140.5q-35 -83 -35 -173t35 -172q34 -80 95 -141t141 -95q82 -35 172 -35t173 35q79 34 140.5 95t94.5 141q35 82 35 172t-35 173q-33 79 -94.5 140.5t-140.5 94.5q-83 35 -173 35zM595 472h-240l62 62q9 9 9 21.5t-9 21.5t-21.5 9t-21.5 -9l-113 -114q-9 -9 -9 -21.5t9 -21.5l113 -113q9 -9 21.5 -9t21.5 9t9 21.5t-9 21.5l-62 61h240q41 0 69.5 -28t28.5 -68t-28.5 -68.5t-69.5 -28.5h-221q-12 0 -21 -9t-9 -21.5t9 -21.5t21 -9h221q66 0 112 46.5t46 111.5t-46 111t-112 46z"/>
</svg>

After

Width:  |  Height:  |  Size: 863 B

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "purchased_alpine_lake.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "purchased_forest_waterfall.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "purchased_grassland_path.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "purchased_lakeside_flowers.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 389 B

+1 -57
View File
@@ -5,32 +5,25 @@ enum PushDestination: Sendable, Equatable {
case paymentRecord
case paymentDetails
case messageCenter
case aiRetouchTaskList
case aiRetouchTaskDetail(batchId: Int)
}
/// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。
struct PushPayload: Sendable, Equatable {
private let type: String
private let aiRetouchBatchId: Int?
/// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。
nonisolated init(userInfo: [AnyHashable: Any]) {
type = Self.extractType(from: userInfo)
aiRetouchBatchId = Self.extractBatchId(from: userInfo)
}
/// 从可发送的字符串字典创建 payload,仅保留业务消息类型。
nonisolated init(values: [String: String]) {
type = Self.normalizedType(values["type"])
aiRetouchBatchId = Int(values["ai_retouch_batch_id"] ?? "").flatMap { $0 > 0 ? $0 : nil }
}
/// 可发送的规范化字段快照。
nonisolated var normalizedValues: [String: String] {
var values = type.isEmpty ? [:] : ["type": type]
if let aiRetouchBatchId { values["ai_retouch_batch_id"] = String(aiRetouchBatchId) }
return values
type.isEmpty ? [:] : ["type": type]
}
/// 仅根据后端业务消息类型解析目标页面。
@@ -40,11 +33,6 @@ struct PushPayload: Sendable, Equatable {
return .paymentRecord
case "6":
return .paymentDetails
case "14":
if let aiRetouchBatchId, aiRetouchBatchId > 0 {
return .aiRetouchTaskDetail(batchId: aiRetouchBatchId)
}
return .aiRetouchTaskList
default:
return .messageCenter
}
@@ -92,50 +80,6 @@ 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 = [
"extras", "extra", "JMessageExtra", "n_extras",
]
@@ -0,0 +1,134 @@
//
// AccountDeletionModels.swift
// suixinkan
//
import Foundation
/// 注销核验资产,使用中性文案展示当前账号拥有的数据与权益。
struct AccountDeletionAssetSummary: Codable, Hashable, Sendable {
/// 注销核验覆盖的资产类型。
enum Kind: String, Codable, CaseIterable, Sendable {
case wallet
case works
case projects
case cloudFiles
}
let kind: Kind
let title: String
let valueText: String
}
/// 注销前置核验结果,包含资产快照和注销影响说明。
struct AccountDeletionPrecheck: Equatable, Sendable {
let assets: [AccountDeletionAssetSummary]
let consequences: [String]
}
/// 注销申请当前状态。
enum AccountDeletionRequestStatus: String, Codable, Sendable {
case pending
case canceled
case completed
}
/// 注销申请记录,保存7天冷静期及用户确认的资产范围。
struct AccountDeletionRequest: Codable, Equatable, Sendable {
let id: UUID
let clientRequestID: UUID
let username: String
let submittedAt: Date
let scheduledDeletionAt: Date
var status: AccountDeletionRequestStatus
let acknowledgedAssetKinds: [AccountDeletionAssetSummary.Kind]
}
/// 登录时需要处理的注销状态。
enum AccountDeletionLoginState: Equatable, Sendable {
case none
case pending(AccountDeletionRequest)
case completed(AccountDeletionRequest)
}
/// 注销短信验证页所需的稳定上下文。
struct AccountDeletionVerificationContext: Equatable, Sendable {
let username: String
let maskedPhone: String
let assets: [AccountDeletionAssetSummary]
}
/// 注销业务错误,统一提供可直接展示的中文提示。
enum AccountDeletionError: LocalizedError, Equatable {
case missingIdentity
case invalidVerificationCode
case incompleteAcknowledgement
case noPendingRequest
case cancellationExpired
case deletionCompleted
case persistenceFailed
var errorDescription: String? {
switch self {
case .missingIdentity:
"当前登录账号缺少手机号,暂时无法提交注销"
case .invalidVerificationCode:
"验证码错误,请输入演示验证码 123456"
case .incompleteAcknowledgement:
"请先确认已了解全部资产和注销后果"
case .noPendingRequest:
"当前账号没有待取消的注销申请"
case .cancellationExpired:
"7天取消期限已过,无法恢复账号"
case .deletionCompleted:
"账号已完成注销,无法继续登录"
case .persistenceFailed:
"注销状态保存失败,请稍后重试"
}
}
}
/// 注销账号身份工具,统一手机号规范化、掩码和当前登录身份解析。
enum AccountDeletionIdentity {
/// 从当前会话读取登录手机号,优先使用登录页保存的原始账号。
static func currentUsername(session: AppSessionStore = AppStore.shared.session) -> String? {
let candidates = [session.lastLoginUsername, session.phone]
return candidates
.compactMap { $0 }
.map(normalizedUsername)
.first { !$0.isEmpty }
}
/// 将手机号账号规范化为纯数字,并兼容 +86 前缀。
static func normalizedUsername(_ value: String) -> String {
var digits = value.filter(\.isNumber)
if digits.hasPrefix("86"), digits.count == 13 {
digits.removeFirst(2)
}
return digits
}
/// 将手机号转换为用于页面展示的脱敏格式。
static func maskedPhone(_ value: String) -> String {
let username = normalizedUsername(value)
guard username.count == 11 else { return username }
let start = username.prefix(3)
let end = username.suffix(4)
return "\(start)****\(end)"
}
}
/// 注销时间格式化工具,统一成功页和登录恢复弹窗的中文时间。
enum AccountDeletionDateFormatter {
/// 将服务端计划删除时间格式化为中文日期时间。
static func displayText(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
formatter.dateFormat = "yyyy年M月d日 HH:mm"
return formatter.string(from: date)
}
}
@@ -0,0 +1,198 @@
//
// AccountDeletionMockService.swift
// suixinkan
//
import Foundation
/// 注销服务协议,隔离前置核验、短信验证、提交和取消能力。
protocol AccountDeletionServing: AnyObject {
/// 加载整个登录账号的注销资产快照。
func loadPrecheck(username: String) async throws -> AccountDeletionPrecheck
/// 向当前绑定手机号发送注销验证码。
func sendVerificationCode(to username: String) async throws
/// 提交注销申请并开始7天冷静期。
func submitDeletion(
username: String,
verificationCode: String,
acknowledgedAssetKinds: Set<AccountDeletionAssetSummary.Kind>,
clientRequestID: UUID
) async throws -> AccountDeletionRequest
/// 查询指定登录账号在登录时应处理的注销状态。
func loginState(for username: String) -> AccountDeletionLoginState
/// 在7天冷静期内取消注销申请。
func cancelDeletion(username: String) throws -> AccountDeletionRequest
}
/// 注销功能 Mock 服务,使用 UserDefaults 完成可重复演示的7天注销闭环。
final class AccountDeletionMockService: AccountDeletionServing {
/// 应用内共享的注销 Mock 服务。
static let shared = AccountDeletionMockService()
/// Mock 短信验证码。
static let verificationCode = "123456"
private let defaults: UserDefaults
private let now: () -> Date
private let responseDelayNanoseconds: UInt64
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let keyPrefix = "account_deletion_mock_v1_"
/// 创建注销 Mock 服务,测试可注入独立存储、时间和响应延迟。
init(
defaults: UserDefaults = .standard,
now: @escaping () -> Date = Date.init,
responseDelayNanoseconds: UInt64 = 180_000_000
) {
self.defaults = defaults
self.now = now
self.responseDelayNanoseconds = responseDelayNanoseconds
}
func loadPrecheck(username: String) async throws -> AccountDeletionPrecheck {
try await simulateDelay()
_ = try validateUsername(username)
return AccountDeletionPrecheck(
assets: [
AccountDeletionAssetSummary(kind: .wallet, title: "钱包余额", valueText: "¥286.50"),
AccountDeletionAssetSummary(kind: .works, title: "作品与相册", valueText: "36个"),
AccountDeletionAssetSummary(kind: .projects, title: "项目", valueText: "4个"),
AccountDeletionAssetSummary(kind: .cloudFiles, title: "云盘文件", valueText: "8.6 GB"),
],
consequences: [
"所有景区与门店账号将解除",
"个人作品和云盘文件将删除",
"提交后7天内再次登录可取消注销",
]
)
}
func sendVerificationCode(to username: String) async throws {
try await simulateDelay()
_ = try validateUsername(username)
}
func submitDeletion(
username: String,
verificationCode: String,
acknowledgedAssetKinds: Set<AccountDeletionAssetSummary.Kind>,
clientRequestID: UUID
) async throws -> AccountDeletionRequest {
try await simulateDelay()
let normalized = try validateUsername(username)
guard verificationCode == Self.verificationCode else {
throw AccountDeletionError.invalidVerificationCode
}
guard acknowledgedAssetKinds == Set(AccountDeletionAssetSummary.Kind.allCases) else {
throw AccountDeletionError.incompleteAcknowledgement
}
if let existing = storedRequest(for: normalized), existing.status == .pending {
return existing
}
if let existing = storedRequest(for: normalized), existing.status == .completed {
throw AccountDeletionError.deletionCompleted
}
let submittedAt = now()
guard let scheduledDeletionAt = Calendar(identifier: .gregorian)
.date(byAdding: .day, value: 7, to: submittedAt) else {
throw AccountDeletionError.persistenceFailed
}
let request = AccountDeletionRequest(
id: UUID(),
clientRequestID: clientRequestID,
username: normalized,
submittedAt: submittedAt,
scheduledDeletionAt: scheduledDeletionAt,
status: .pending,
acknowledgedAssetKinds: AccountDeletionAssetSummary.Kind.allCases
)
try save(request)
return request
}
func loginState(for username: String) -> AccountDeletionLoginState {
let normalized = AccountDeletionIdentity.normalizedUsername(username)
guard var request = storedRequest(for: normalized) else { return .none }
switch request.status {
case .canceled:
return .none
case .completed:
return .completed(request)
case .pending:
guard now() < request.scheduledDeletionAt else {
request.status = .completed
try? save(request)
return .completed(request)
}
return .pending(request)
}
}
func cancelDeletion(username: String) throws -> AccountDeletionRequest {
let normalized = try validateUsername(username)
guard var request = storedRequest(for: normalized) else {
throw AccountDeletionError.noPendingRequest
}
guard request.status == .pending else {
if request.status == .completed {
throw AccountDeletionError.cancellationExpired
}
throw AccountDeletionError.noPendingRequest
}
guard now() < request.scheduledDeletionAt else {
request.status = .completed
try save(request)
throw AccountDeletionError.cancellationExpired
}
request.status = .canceled
try save(request)
return request
}
/// 删除指定账号的 Mock 注销状态,仅供单元测试与调试使用。
func reset(username: String) {
let normalized = AccountDeletionIdentity.normalizedUsername(username)
defaults.removeObject(forKey: storageKey(username: normalized))
}
private func validateUsername(_ username: String) throws -> String {
let normalized = AccountDeletionIdentity.normalizedUsername(username)
guard normalized.count == 11, normalized.first == "1" else {
throw AccountDeletionError.missingIdentity
}
return normalized
}
private func storedRequest(for username: String) -> AccountDeletionRequest? {
guard let data = defaults.data(forKey: storageKey(username: username)) else { return nil }
return try? decoder.decode(AccountDeletionRequest.self, from: data)
}
private func save(_ request: AccountDeletionRequest) throws {
do {
let data = try encoder.encode(request)
defaults.set(data, forKey: storageKey(username: request.username))
} catch {
throw AccountDeletionError.persistenceFailed
}
}
private func storageKey(username: String) -> String {
keyPrefix + username
}
private func simulateDelay() async throws {
guard responseDelayNanoseconds > 0 else { return }
try await Task.sleep(nanoseconds: responseDelayNanoseconds)
}
}
@@ -0,0 +1,184 @@
//
// AccountDeletionViewModels.swift
// suixinkan
//
import Foundation
/// 注销资产核验页 ViewModel,管理资产快照、确认状态与验证页上下文。
final class AccountDeletionViewModel {
private let service: any AccountDeletionServing
let username: String
private(set) var assets: [AccountDeletionAssetSummary] = []
private(set) var consequences: [String] = []
private(set) var isAcknowledged = false
private(set) var isLoading = false
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
/// 创建注销核验 ViewModel,默认读取当前登录账号并使用共享 Mock 服务。
init(
username: String? = AccountDeletionIdentity.currentUsername(),
service: any AccountDeletionServing = AccountDeletionMockService.shared
) {
self.username = AccountDeletionIdentity.normalizedUsername(username ?? "")
self.service = service
}
/// 勾选后才允许进入短信验证。
var isContinueEnabled: Bool {
!assets.isEmpty && isAcknowledged && !isLoading
}
/// 资产加载完成后主按钮保持可点击,未勾选时由业务校验给出明确提示。
var isContinueButtonEnabled: Bool {
!assets.isEmpty && !isLoading
}
/// 拉取注销前置核验结果。
func load() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
let precheck = try await service.loadPrecheck(username: username)
assets = precheck.assets
consequences = precheck.consequences
} catch {
errorMessage = error.localizedDescription
}
}
/// 切换用户对注销影响的确认状态。
func toggleAcknowledgement() {
isAcknowledged.toggle()
notifyStateChange()
}
/// 生成短信验证页上下文。
func makeVerificationContext() throws -> AccountDeletionVerificationContext {
guard isContinueEnabled else {
throw AccountDeletionError.incompleteAcknowledgement
}
guard !username.isEmpty else {
throw AccountDeletionError.missingIdentity
}
return AccountDeletionVerificationContext(
username: username,
maskedPhone: AccountDeletionIdentity.maskedPhone(username),
assets: assets
)
}
/// 使用当前服务创建短信验证 ViewModel,保持同一套 Mock 状态。
func makeVerificationViewModel() throws -> AccountDeletionVerificationViewModel {
try AccountDeletionVerificationViewModel(
context: makeVerificationContext(),
service: service
)
}
private func notifyStateChange() {
onStateChange?()
}
}
/// 注销短信验证页 ViewModel,负责验证码发送、校验和提交申请。
final class AccountDeletionVerificationViewModel {
private let service: any AccountDeletionServing
private let clientRequestID = UUID()
let context: AccountDeletionVerificationContext
private(set) var verificationCode = ""
private(set) var isSendingCode = false
private(set) var isSubmitting = false
private(set) var hasSentCode = false
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
/// 创建短信验证 ViewModel。
init(
context: AccountDeletionVerificationContext,
service: any AccountDeletionServing = AccountDeletionMockService.shared
) throws {
guard !context.username.isEmpty else {
throw AccountDeletionError.missingIdentity
}
self.context = context
self.service = service
}
/// 验证码满足6位数字且当前未提交时允许继续。
var isSubmitEnabled: Bool {
verificationCode.count == 6
&& verificationCode.allSatisfy(\.isNumber)
&& !isSubmitting
}
/// 更新用户输入的验证码,只保留前6位数字。
func updateVerificationCode(_ value: String) {
verificationCode = String(value.filter(\.isNumber).prefix(6))
errorMessage = nil
notifyStateChange()
}
/// 发送 Mock 短信验证码。
func sendVerificationCode() async {
guard !isSendingCode else { return }
isSendingCode = true
errorMessage = nil
notifyStateChange()
defer {
isSendingCode = false
notifyStateChange()
}
do {
try await service.sendVerificationCode(to: context.username)
hasSentCode = true
} catch {
errorMessage = error.localizedDescription
}
}
/// 提交注销申请并返回计划删除时间。
func submit() async throws -> AccountDeletionRequest {
guard isSubmitEnabled else {
throw AccountDeletionError.invalidVerificationCode
}
guard !isSubmitting else { throw CancellationError() }
isSubmitting = true
errorMessage = nil
notifyStateChange()
defer {
isSubmitting = false
notifyStateChange()
}
do {
return try await service.submitDeletion(
username: context.username,
verificationCode: verificationCode,
acknowledgedAssetKinds: Set(context.assets.map(\.kind)),
clientRequestID: clientRequestID
)
} catch {
errorMessage = error.localizedDescription
throw error
}
}
private func notifyStateChange() {
onStateChange?()
}
}
@@ -53,10 +53,11 @@ enum LoginValidationError: Equatable {
}
}
/// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。
/// 登录结果实体,区分正常登录、账号选择和注销冷静期恢复三种情况。
enum LoginResolution {
case completed(V9AuthResponse, AccountSwitchAccount)
case needsAccountSelection(AccountSelectionPayload)
case accountDeletionPending(V9AuthResponse, AccountDeletionRequest)
}
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
@@ -30,37 +30,6 @@ enum MessageJSONValue: Decodable, Hashable, Sendable {
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
}
}
}
/// 消息未读数量响应,用于同步首页红点和桌面图标角标。
@@ -234,20 +203,6 @@ struct MessageItem: Decodable, Hashable, Sendable {
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 {
MessageItem(
@@ -163,16 +163,6 @@ final class MessageDetailViewModel {
self.message = message
}
/// AI 修图任务通知在详情页展示任务入口。
var showsAIRetouchTaskAction: Bool {
message.isAIRetouchTaskNotification
}
/// 当前消息携带的 AI 修图批次 ID;缺失时由页面降级进入任务列表。
var aiRetouchBatchId: Int? {
message.aiRetouchBatchId
}
/// 删除当前消息。
func delete(api: any MessageCenterServing) async {
guard message.id > 0 else {
@@ -0,0 +1,290 @@
//
// OfflineCollectionModels.swift
// suixinkan
//
import Foundation
/// 线下收款方式,与线上订单支付方式保持独立。
enum OfflineCollectionPaymentMethod: String, Codable, CaseIterable, Sendable, Hashable {
case wechat = "WECHAT"
case alipay = "ALIPAY"
case cash = "CASH"
/// 页面展示的中文名称。
var displayName: String {
switch self {
case .wechat: "微信"
case .alipay: "支付宝"
case .cash: "现金"
}
}
/// 页面使用的本地矢量图标资源名称。
var assetName: String {
switch self {
case .wechat: "payment_method_wechat"
case .alipay: "payment_method_alipay"
case .cash: "payment_method_cash"
}
}
}
/// 线下收款记录的补缴状态。
enum OfflineCollectionStatus: String, Codable, Sendable, Hashable {
case pending = "PENDING"
case settled = "SETTLED"
case overdue = "OVERDUE"
/// 状态对应的中文文案。
var displayName: String {
switch self {
case .pending: "待补缴"
case .settled: "已补缴"
case .overdue: "逾期未补缴"
}
}
}
/// 当前线下收款数据所属的用户、店铺与景区上下文。
struct OfflineCollectionContext: Codable, Sendable, Hashable {
let collectorId: String
let collectorName: String
let storeId: String
let storeName: String
let scenicId: String
let scenicName: String
/// 生成用于 UserDefaults 隔离不同账号和店铺数据的稳定键。
var storageScope: String {
[collectorId, storeId, scenicId]
.map { value in
value.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? String($0) : "_" }.joined()
}
.joined(separator: "_")
}
/// 从当前登录上下文解析收款归属,缺少演示数据时使用文档约定的兜底值。
static func current(appStore: AppStore = .shared) -> OfflineCollectionContext {
let session = appStore.session
let stores = appStore.permissions.rolePermissionList().flatMap(\.store)
let currentStore = stores.first(where: { $0.id == session.currentStoreId }) ?? stores.first
let collectorName = [session.realName, session.userName, session.accountDisplayName]
.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
return OfflineCollectionContext(
collectorId: session.userId.nonEmptyValue ?? "U10086",
collectorName: collectorName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "张三",
storeId: currentStore.map { String($0.id) } ?? (session.currentStoreId > 0 ? String(session.currentStoreId) : "S1001"),
storeName: currentStore?.name.nonEmptyValue ?? "那拉提旅拍一店",
scenicId: session.currentScenicId > 0 ? String(session.currentScenicId) : "SC001",
scenicName: session.currentScenicName.nonEmptyValue ?? "那拉提景区"
)
}
}
/// 独立的线下收款记录,不包含任何订单字段。
struct OfflineCollectionRecord: Codable, Sendable, Hashable {
let id: String
let collectorId: String
let collectorName: String
let storeId: String
let storeName: String
let scenicId: String
let scenicName: String
let amountFen: Int
let paymentMethod: OfflineCollectionPaymentMethod
let registeredAt: Date
let receivedAt: Date
let businessDate: String
var status: OfflineCollectionStatus
var settlementBatchId: String?
var settledAt: Date?
}
/// 线下收款的独立补缴流水,仅关联本批次收款记录快照。
struct OfflineSettlementBatch: Codable, Sendable, Hashable {
let id: String
let businessDate: String
let collectorId: String
let collectorName: String
let storeId: String
let storeName: String
let scenicId: String
let scenicName: String
let recordIds: [String]
let recordCount: Int
let amountFen: Int
let paidAt: Date
let payerId: String
let payerName: String
let clientRequestId: String
/// 流水类型固定为线下收款补缴。
var transactionType: String { "OFFLINE_COLLECTION_SETTLEMENT" }
}
/// 某一营业日的线下收款汇总。
struct OfflineDailySummary: Sendable, Hashable {
let businessDate: String
let totalCount: Int
let totalAmountFen: Int
let settledCount: Int
let settledAmountFen: Int
let pendingCount: Int
let pendingAmountFen: Int
let hasOverdue: Bool
/// 按记录状态用整数“分”生成日汇总。
static func make(businessDate: String, records: [OfflineCollectionRecord]) -> OfflineDailySummary {
let settled = records.filter { $0.status == .settled }
let pending = records.filter { $0.status == .pending || $0.status == .overdue }
return OfflineDailySummary(
businessDate: businessDate,
totalCount: records.count,
totalAmountFen: records.reduce(0) { $0 + $1.amountFen },
settledCount: settled.count,
settledAmountFen: settled.reduce(0) { $0 + $1.amountFen },
pendingCount: pending.count,
pendingAmountFen: pending.reduce(0) { $0 + $1.amountFen },
hasOverdue: pending.contains { $0.status == .overdue }
)
}
}
/// 补缴确认时冻结的记录和金额快照,用于防止处理中新记录被误结清。
struct OfflineSettlementRequest: Sendable, Hashable {
let businessDate: String
let recordIds: [String]
let amountFen: Int
let clientRequestId: String
}
/// 登记成功后页面反馈所需的最小数据。
struct OfflineCollectionRegistrationReceipt: Sendable, Hashable {
let record: OfflineCollectionRecord
let summary: OfflineDailySummary
}
/// 线下收款 Mock 流程可识别的业务异常。
enum OfflineCollectionError: LocalizedError, Sendable, Equatable {
case invalidAmount
case createFailed
case noPendingRecords
case summaryMismatch
case recordsChanged
case settlementFailed
case persistenceFailed
/// 供页面展示的中文错误提示。
var errorDescription: String? {
switch self {
case .invalidAmount: "请输入0.01~99,999.99元的有效金额"
case .createFailed: "登记失败,请重试"
case .noPendingRecords: "当前营业日无待补缴记录"
case .summaryMismatch: "金额汇总异常,请刷新数据"
case .recordsChanged: "待补缴记录已变化,请重新确认"
case .settlementFailed: "补缴失败,请重试"
case .persistenceFailed: "本地数据保存失败,请重试"
}
}
}
/// 金额输入、整数分换算和展示的统一工具。
enum OfflineCollectionMoney {
static let maximumFen = 9_999_999
/// 判断文本是否可作为金额输入中间态。
static func acceptsEditingText(_ text: String) -> Bool {
guard !text.contains(where: { !$0.isNumber && $0 != "." }) else { return false }
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count <= 2 else { return false }
let wholeCount = parts.first?.count ?? 0
let fractionCount = parts.count == 2 ? parts[1].count : 0
return wholeCount <= 5 && fractionCount <= 2
}
/// 将用户输入精确转换为整数“分”,不经过浮点计算。
static func parseFen(_ rawValue: String) -> Int? {
let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, text == rawValue, acceptsEditingText(text), text != "." else { return nil }
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
let wholeText = parts[0].isEmpty ? "0" : String(parts[0])
let fractionText = parts.count == 2 ? String(parts[1]) : ""
guard wholeText.allSatisfy(\.isNumber), fractionText.allSatisfy(\.isNumber),
let whole = Int(wholeText) else { return nil }
let cents: Int
switch fractionText.count {
case 0: cents = 0
case 1: cents = (Int(fractionText) ?? 0) * 10
case 2: cents = Int(fractionText) ?? 0
default: return nil
}
let amountFen = whole * 100 + cents
guard amountFen > 0, amountFen <= maximumFen else { return nil }
return amountFen
}
/// 将整数分格式化为带人民币符号且固定两位小数的文本。
static func display(_ amountFen: Int) -> String {
let sign = amountFen < 0 ? "-" : ""
let absolute = abs(amountFen)
return "\(sign)¥\(absolute / 100).\(String(format: "%02d", absolute % 100))"
}
}
/// 营业日与页面时间文案的统一格式化工具。
enum OfflineCollectionDate {
static let dailyCutoffTime = "23:59:59"
/// 按指定日历生成 YYYY-MM-DD 营业日。
static func businessDate(for date: Date, calendar: Calendar = .current) -> String {
let components = calendar.dateComponents([.year, .month, .day], from: date)
return String(
format: "%04d-%02d-%02d",
components.year ?? 0,
components.month ?? 0,
components.day ?? 0
)
}
/// 将营业日文本解析为当地日期。
static func date(from businessDate: String, calendar: Calendar = .current) -> Date? {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: businessDate)
}
/// 将时间格式化为当日明细所需的 HH:mm。
static func timeText(_ date: Date, calendar: Calendar = .current) -> String {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "zh_CN")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
/// 将时间格式化为补缴流水的完整时间。
static func dateTimeText(_ date: Date, calendar: Calendar = .current) -> String {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "zh_CN")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "yyyy-MM-dd HH:mm"
return formatter.string(from: date)
}
}
private extension String {
/// 去除首尾空白后的非空文本。
var nonEmptyValue: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
@@ -0,0 +1,359 @@
//
// OfflineCollectionMockService.swift
// suixinkan
//
import Foundation
/// 线下收款本地 Mock 服务,负责演示数据、持久化、逾期流转与幂等补缴。
final class OfflineCollectionMockService: @unchecked Sendable {
/// App 内共享的线下收款 Mock 数据源。
static let shared = OfflineCollectionMockService()
/// 用于本地持久化的数据快照。
private struct Snapshot: Codable, Equatable {
var records: [OfflineCollectionRecord]
var batches: [OfflineSettlementBatch]
}
private let defaults: UserDefaults
private let calendar: Calendar
private let nowProvider: @Sendable () -> Date
private let requestDelayNanoseconds: UInt64
private let lock = NSLock()
private var shouldFailNextCreation = false
private var shouldFailNextSettlement = false
/// 创建 Mock 服务,测试可注入独立 UserDefaults、时间与请求延迟。
init(
defaults: UserDefaults = .standard,
calendar: Calendar = .current,
nowProvider: @escaping @Sendable () -> Date = Date.init,
requestDelayNanoseconds: UInt64 = 450_000_000
) {
self.defaults = defaults
self.calendar = calendar
self.nowProvider = nowProvider
self.requestDelayNanoseconds = requestDelayNanoseconds
}
/// 当前设备时区下的今日营业日。
var todayBusinessDate: String {
OfflineCollectionDate.businessDate(for: nowProvider(), calendar: calendar)
}
/// 获取某营业日汇总。
func getDailySummary(date: String, context: OfflineCollectionContext) -> OfflineDailySummary {
lock.withLock {
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
let records = matchingRecords(snapshot.records, date: date, context: context)
return OfflineDailySummary.make(businessDate: date, records: records)
}
}
/// 获取某营业日明细,按登记时间倒序返回。
func getDailyRecords(date: String, context: OfflineCollectionContext) -> [OfflineCollectionRecord] {
lock.withLock {
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
return matchingRecords(snapshot.records, date: date, context: context)
.sorted { $0.registeredAt > $1.registeredAt }
}
}
/// 获取某营业日的成功补缴流水。
func getSettlementBatches(date: String, context: OfflineCollectionContext) -> [OfflineSettlementBatch] {
lock.withLock {
let snapshot = loadSnapshotLocked(context: context)
return snapshot.batches
.filter { batch in
batch.businessDate == date
&& batch.collectorId == context.collectorId
&& batch.storeId == context.storeId
&& batch.scenicId == context.scenicId
}
.sorted { $0.paidAt > $1.paidAt }
}
}
/// 获取当前用户与店铺的历史逾期营业日,按日期升序方便先处理最早欠款。
func getOverdueSummaries(context: OfflineCollectionContext) -> [OfflineDailySummary] {
lock.withLock {
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
let overdueDates: Set<String> = Set(snapshot.records.compactMap { record -> String? in
guard matches(record, context: context), record.status == .overdue else { return nil }
return record.businessDate
})
return overdueDates.sorted().map { date in
OfflineDailySummary.make(
businessDate: date,
records: matchingRecords(snapshot.records, date: date, context: context)
)
}
}
}
/// 获取可查看的营业日,始终包含今日。
func getAvailableBusinessDates(context: OfflineCollectionContext) -> [String] {
lock.withLock {
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
var dates = Set(snapshot.records.filter { matches($0, context: context) }.map(\.businessDate))
dates.insert(todayBusinessDate)
return dates.sorted(by: >)
}
}
/// 新建一条待补缴线下收款记录,不触发任何订单流程。
func createOfflineCollection(
amountFen: Int,
paymentMethod: OfflineCollectionPaymentMethod,
context: OfflineCollectionContext
) async throws -> OfflineCollectionRegistrationReceipt {
guard amountFen > 0, amountFen <= OfflineCollectionMoney.maximumFen else {
throw OfflineCollectionError.invalidAmount
}
await simulateRequestDelay()
return try lock.withLock {
if shouldFailNextCreation {
shouldFailNextCreation = false
throw OfflineCollectionError.createFailed
}
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
let now = nowProvider()
let businessDate = OfflineCollectionDate.businessDate(for: now, calendar: calendar)
let sequence = snapshot.records.filter { $0.businessDate == businessDate }.count + 1
let record = OfflineCollectionRecord(
id: String(format: "OCR%@%04d", businessDate.replacingOccurrences(of: "-", with: ""), sequence),
collectorId: context.collectorId,
collectorName: context.collectorName,
storeId: context.storeId,
storeName: context.storeName,
scenicId: context.scenicId,
scenicName: context.scenicName,
amountFen: amountFen,
paymentMethod: paymentMethod,
registeredAt: now,
receivedAt: now,
businessDate: businessDate,
status: .pending,
settlementBatchId: nil,
settledAt: nil
)
snapshot.records.append(record)
try saveSnapshotLocked(snapshot, context: context)
let summary = OfflineDailySummary.make(
businessDate: businessDate,
records: matchingRecords(snapshot.records, date: businessDate, context: context)
)
return OfflineCollectionRegistrationReceipt(record: record, summary: summary)
}
}
/// 按确认时冻结的记录快照执行全额补缴,同一请求标识只生成一条流水。
func settleDailyCollections(
request: OfflineSettlementRequest,
context: OfflineCollectionContext
) async throws -> OfflineSettlementBatch {
await simulateRequestDelay()
return try lock.withLock {
var snapshot = loadSnapshotLocked(context: context)
refreshOverdueLocked(snapshot: &snapshot, context: context)
if let existing = snapshot.batches.first(where: { $0.clientRequestId == request.clientRequestId }) {
return existing
}
if shouldFailNextSettlement {
shouldFailNextSettlement = false
throw OfflineCollectionError.settlementFailed
}
let requestedIds = Set(request.recordIds)
guard !requestedIds.isEmpty, requestedIds.count == request.recordIds.count else {
throw OfflineCollectionError.noPendingRecords
}
let pendingRecords = snapshot.records.filter { record in
requestedIds.contains(record.id)
&& matches(record, context: context)
&& record.businessDate == request.businessDate
&& (record.status == .pending || record.status == .overdue)
}
guard pendingRecords.count == request.recordIds.count else {
throw OfflineCollectionError.recordsChanged
}
let calculatedAmount = pendingRecords.reduce(0) { $0 + $1.amountFen }
guard calculatedAmount == request.amountFen else {
throw OfflineCollectionError.summaryMismatch
}
let now = nowProvider()
let batchSequence = snapshot.batches.filter { $0.businessDate == request.businessDate }.count + 1
let batchId = String(
format: "OCS%@%04d",
request.businessDate.replacingOccurrences(of: "-", with: ""),
batchSequence
)
let batch = OfflineSettlementBatch(
id: batchId,
businessDate: request.businessDate,
collectorId: context.collectorId,
collectorName: context.collectorName,
storeId: context.storeId,
storeName: context.storeName,
scenicId: context.scenicId,
scenicName: context.scenicName,
recordIds: request.recordIds,
recordCount: request.recordIds.count,
amountFen: request.amountFen,
paidAt: now,
payerId: context.collectorId,
payerName: context.collectorName,
clientRequestId: request.clientRequestId
)
// 流水与记录在同一份本地快照内一次性更新,避免出现金额已减但流水缺失。
snapshot.batches.append(batch)
for index in snapshot.records.indices where requestedIds.contains(snapshot.records[index].id) {
snapshot.records[index].status = .settled
snapshot.records[index].settlementBatchId = batch.id
snapshot.records[index].settledAt = now
}
try saveSnapshotLocked(snapshot, context: context)
return batch
}
}
/// 开发环境中使下一次登记失败,用于验收输入保留和重试。
func simulateNextCreationFailure() {
lock.withLock { shouldFailNextCreation = true }
}
/// 开发环境中使下一次补缴失败,失败时不改变记录和流水。
func simulateNextSettlementFailure() {
lock.withLock { shouldFailNextSettlement = true }
}
/// 将当前上下文恢复为文档约定的默认演示数据。
func resetOfflineCollectionMockData(context: OfflineCollectionContext) {
lock.withLock {
let snapshot = makeDefaultSnapshot(context: context)
try? saveSnapshotLocked(snapshot, context: context)
shouldFailNextCreation = false
shouldFailNextSettlement = false
}
}
private func matchingRecords(
_ records: [OfflineCollectionRecord],
date: String,
context: OfflineCollectionContext
) -> [OfflineCollectionRecord] {
records.filter { $0.businessDate == date && matches($0, context: context) }
}
private func matches(_ record: OfflineCollectionRecord, context: OfflineCollectionContext) -> Bool {
record.collectorId == context.collectorId
&& record.storeId == context.storeId
&& record.scenicId == context.scenicId
}
private func refreshOverdueLocked(snapshot: inout Snapshot, context: OfflineCollectionContext) {
let today = todayBusinessDate
var changed = false
for index in snapshot.records.indices where matches(snapshot.records[index], context: context) {
guard snapshot.records[index].status != .settled else { continue }
let expected: OfflineCollectionStatus = snapshot.records[index].businessDate < today ? .overdue : .pending
if snapshot.records[index].status != expected {
snapshot.records[index].status = expected
changed = true
}
}
if changed {
try? saveSnapshotLocked(snapshot, context: context)
}
}
private func loadSnapshotLocked(context: OfflineCollectionContext) -> Snapshot {
let key = storageKey(context: context)
guard let data = defaults.data(forKey: key) else {
let snapshot = makeDefaultSnapshot(context: context)
try? saveSnapshotLocked(snapshot, context: context)
return snapshot
}
do {
return try JSONDecoder().decode(Snapshot.self, from: data)
} catch {
#if DEBUG
print("线下收款 Mock 数据损坏,已恢复默认数据:\(error)")
#endif
let snapshot = makeDefaultSnapshot(context: context)
try? saveSnapshotLocked(snapshot, context: context)
return snapshot
}
}
private func saveSnapshotLocked(_ snapshot: Snapshot, context: OfflineCollectionContext) throws {
do {
defaults.set(try JSONEncoder().encode(snapshot), forKey: storageKey(context: context))
} catch {
throw OfflineCollectionError.persistenceFailed
}
}
private func storageKey(context: OfflineCollectionContext) -> String {
"offline_collection_mock_v1_\(context.storageScope)"
}
private func makeDefaultSnapshot(context: OfflineCollectionContext) -> Snapshot {
let todayDate = nowProvider()
let today = OfflineCollectionDate.businessDate(for: todayDate, calendar: calendar)
let previousDate = calendar.date(byAdding: .day, value: -1, to: todayDate) ?? todayDate
let previousBusinessDate = OfflineCollectionDate.businessDate(for: previousDate, calendar: calendar)
let samples: [(String, Int, OfflineCollectionPaymentMethod, Date, OfflineCollectionStatus)] = [
("OCR\(today.replacingOccurrences(of: "-", with: ""))0001", 29_900, .wechat, time(on: todayDate, hour: 10, minute: 21), .pending),
("OCR\(today.replacingOccurrences(of: "-", with: ""))0002", 39_900, .alipay, time(on: todayDate, hour: 14, minute: 16), .pending),
("OCR\(today.replacingOccurrences(of: "-", with: ""))0003", 19_900, .wechat, time(on: todayDate, hour: 17, minute: 42), .pending),
("OCR\(previousBusinessDate.replacingOccurrences(of: "-", with: ""))0001", 30_000, .cash, time(on: previousDate, hour: 16, minute: 10), .overdue),
("OCR\(previousBusinessDate.replacingOccurrences(of: "-", with: ""))0002", 20_000, .wechat, time(on: previousDate, hour: 19, minute: 32), .overdue),
]
let records = samples.map { sample in
let businessDate = sample.3 < calendar.startOfDay(for: todayDate) ? previousBusinessDate : today
return OfflineCollectionRecord(
id: sample.0,
collectorId: context.collectorId,
collectorName: context.collectorName,
storeId: context.storeId,
storeName: context.storeName,
scenicId: context.scenicId,
scenicName: context.scenicName,
amountFen: sample.1,
paymentMethod: sample.2,
registeredAt: sample.3,
receivedAt: sample.3,
businessDate: businessDate,
status: sample.4,
settlementBatchId: nil,
settledAt: nil
)
}
return Snapshot(records: records, batches: [])
}
private func time(on date: Date, hour: Int, minute: Int) -> Date {
calendar.date(bySettingHour: hour, minute: minute, second: 0, of: date) ?? date
}
private func simulateRequestDelay() async {
guard requestDelayNanoseconds > 0 else { return }
try? await Task.sleep(nanoseconds: requestDelayNanoseconds)
}
}
@@ -0,0 +1,248 @@
//
// OfflineCollectionViewModels.swift
// suixinkan
//
import Foundation
/// 收款首页线下收款区域的 ViewModel。
final class OfflineCollectionHomeViewModel {
private(set) var todaySummary: OfflineDailySummary
private(set) var overdueSummaries: [OfflineDailySummary] = []
let context: OfflineCollectionContext
let service: OfflineCollectionMockService
var onStateChange: (() -> Void)?
/// 使用当前账号上下文创建首页 ViewModel。
init(
context: OfflineCollectionContext = .current(),
service: OfflineCollectionMockService = .shared
) {
self.context = context
self.service = service
todaySummary = OfflineDailySummary.make(businessDate: service.todayBusinessDate, records: [])
load()
}
/// 当前今日营业日。
var todayBusinessDate: String { service.todayBusinessDate }
/// 最早一个需要处理的逾期营业日。
var earliestOverdueBusinessDate: String? { overdueSummaries.first?.businessDate }
/// 历史逾期记录的合计笔数。
var overdueRecordCount: Int { overdueSummaries.reduce(0) { $0 + $1.pendingCount } }
/// 历史逾期记录的合计待补缴金额。
var overdueAmountFen: Int { overdueSummaries.reduce(0) { $0 + $1.pendingAmountFen } }
/// 从本地 Mock 服务重新读取今日和逾期汇总。
func load() {
todaySummary = service.getDailySummary(date: todayBusinessDate, context: context)
overdueSummaries = service.getOverdueSummaries(context: context)
onStateChange?()
}
}
/// 线下收款登记页 ViewModel,管理表单校验、提交防重和成功反馈。
final class OfflineCollectionRegistrationViewModel {
private(set) var amountText = ""
private(set) var paymentMethod: OfflineCollectionPaymentMethod? = .wechat
private(set) var isSubmitting = false
private(set) var receipt: OfflineCollectionRegistrationReceipt?
let context: OfflineCollectionContext
let service: OfflineCollectionMockService
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onRegistrationSuccess: ((OfflineCollectionRegistrationReceipt) -> Void)?
/// 使用指定收款上下文和 Mock 服务创建登记 ViewModel。
init(
context: OfflineCollectionContext = .current(),
service: OfflineCollectionMockService = .shared
) {
self.context = context
self.service = service
}
/// 当前金额和收款方式是否允许提交。
var canSubmit: Bool {
!isSubmitting && OfflineCollectionMoney.parseFen(amountText) != nil && paymentMethod != nil
}
/// 更新金额输入,仅接受数字、单个小数点和最多两位小数。
func updateAmount(_ value: String) {
guard OfflineCollectionMoney.acceptsEditingText(value) else { return }
amountText = value
receipt = nil
onStateChange?()
}
/// 选择线下收款方式。
func selectPaymentMethod(_ method: OfflineCollectionPaymentMethod) {
paymentMethod = method
receipt = nil
onStateChange?()
}
/// 提交一笔线下收款登记,提交中的重复调用会被忽略。
func submit() async {
guard !isSubmitting else { return }
guard let amountFen = OfflineCollectionMoney.parseFen(amountText) else {
onShowMessage?(OfflineCollectionError.invalidAmount.localizedDescription)
return
}
guard let paymentMethod else {
onShowMessage?("请选择收款方式")
return
}
isSubmitting = true
onStateChange?()
defer {
isSubmitting = false
onStateChange?()
}
do {
let result = try await service.createOfflineCollection(
amountFen: amountFen,
paymentMethod: paymentMethod,
context: context
)
receipt = result
onRegistrationSuccess?(result)
} catch {
onShowMessage?(error.localizedDescription)
}
}
/// 登记成功后开始下一笔,清空金额并保留本次收款方式。
func startAnotherRegistration() {
amountText = ""
receipt = nil
onStateChange?()
}
}
/// 日清页的补缴处理状态。
enum OfflineSettlementPresentationState: Sendable, Equatable {
case idle
case processing
case success(OfflineSettlementBatch)
case failed(String)
}
/// 线下收款日清页 ViewModel,负责单营业日汇总、快照冻结和全额补缴。
final class OfflineCollectionDailyViewModel {
private(set) var businessDate: String
private(set) var summary: OfflineDailySummary
private(set) var records: [OfflineCollectionRecord] = []
private(set) var batches: [OfflineSettlementBatch] = []
private(set) var availableBusinessDates: [String] = []
private(set) var settlementState: OfflineSettlementPresentationState = .idle
private(set) var preparedRequest: OfflineSettlementRequest?
let context: OfflineCollectionContext
let service: OfflineCollectionMockService
var onStateChange: (() -> Void)?
/// 使用指定营业日、收款上下文和 Mock 服务创建日清 ViewModel。
init(
businessDate: String,
context: OfflineCollectionContext = .current(),
service: OfflineCollectionMockService = .shared
) {
self.businessDate = businessDate
self.context = context
self.service = service
summary = OfflineDailySummary.make(businessDate: businessDate, records: [])
load()
}
/// 当前查看的是否为今日营业日。
var isToday: Bool { businessDate == service.todayBusinessDate }
/// 当前是否可以发起补缴。
var canSettle: Bool {
summary.pendingAmountFen > 0 && summary.pendingCount > 0 && settlementState != .processing
}
/// 从本地 Mock 服务加载当前营业日的全部数据。
func load() {
records = service.getDailyRecords(date: businessDate, context: context)
summary = service.getDailySummary(date: businessDate, context: context)
batches = service.getSettlementBatches(date: businessDate, context: context)
availableBusinessDates = service.getAvailableBusinessDates(context: context)
onStateChange?()
}
/// 切换至已存在数据或今日的营业日。
func selectBusinessDate(_ date: String) {
guard availableBusinessDates.contains(date), settlementState != .processing else { return }
businessDate = date
preparedRequest = nil
settlementState = .idle
load()
}
/// 按页面当前待补缴记录冻结一份不可编辑的补缴快照。
func prepareSettlement() throws -> OfflineSettlementRequest {
guard canSettle else { throw OfflineCollectionError.noPendingRecords }
let pendingRecords = records.filter { $0.status == .pending || $0.status == .overdue }
let amountFen = pendingRecords.reduce(0) { $0 + $1.amountFen }
guard pendingRecords.count == summary.pendingCount, amountFen == summary.pendingAmountFen else {
throw OfflineCollectionError.summaryMismatch
}
let request = OfflineSettlementRequest(
businessDate: businessDate,
recordIds: pendingRecords.map(\.id),
amountFen: amountFen,
clientRequestId: "LOCAL-\(UUID().uuidString.uppercased())"
)
preparedRequest = request
return request
}
/// 取消未提交的补缴确认快照。
func cancelPreparedSettlement() {
guard settlementState != .processing else { return }
preparedRequest = nil
}
/// 提交已冻结的补缴快照,失败后保留快照以便原样重试。
func confirmSettlement() async {
guard settlementState != .processing else { return }
guard let request = preparedRequest else {
settlementState = .failed(OfflineCollectionError.noPendingRecords.localizedDescription)
onStateChange?()
return
}
settlementState = .processing
onStateChange?()
do {
let batch = try await service.settleDailyCollections(request: request, context: context)
preparedRequest = nil
settlementState = .success(batch)
load()
} catch {
settlementState = .failed(error.localizedDescription)
load()
}
}
/// 将成功或失败反馈恢复为可操作状态。
func clearSettlementFeedback() {
guard settlementState != .processing else { return }
settlementState = .idle
onStateChange?()
}
/// 开发环境下将下一次 Mock 补缴设为失败。
func simulateNextSettlementFailure() {
service.simulateNextSettlementFailure()
}
}
@@ -1,814 +0,0 @@
# AI 修图任务中心需求与接口设计
> 文档状态:待产品、后端、Android、iOS 联审
> 更新日期:2026-08-14
> 适用范围:随心瞰商家版 AI 修图任务,不包含人工修图任务
## 1. 背景与目标
AI 修图属于异步长耗时任务。当前用户提交后只能等待或主动返回相册刷新,无法明确知道任务是否仍在排队、预计何时完成、哪些照片成功或失败。
本需求增加:
1. 当前账号全部相册的 AI 修图任务列表。
2. 单次 AI 修图任务详情。
3. AI 修图终态消息推送。
4. 从推送、任务列表和提交成功提示进入对应任务的完整导航链路。
本期解决“看得到进度、完成会通知、结果可直达”的问题,不增加任务取消、批量重试、历史版本管理或后台供应商诊断能力。
## 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
→ 客户端提示“AI修图任务已提交”并提供“查看任务”
→ 用户可离开页面
→ 任务进入终态后收到 type = 14 推送
→ 点击推送进入任务详情
→ 查看成功结果或进入相册处理失败项
```
### 3.2 页面入口
- 相册管理页导航栏右侧增加“修图任务”,进入当前账号的全局任务列表;新增相册页不展示该入口。
- AI 修图提交成功提示提供“查看任务”,直接进入本次任务详情。
- 任务列表点击卡片进入对应任务详情。
- `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 设计稿
![AI 修图任务列表设计稿](../../../docs/design/ai-retouch-task-center/01-task-list.png)
### 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 设计稿
![AI 修图任务详情设计稿 V2](../../../docs/design/ai-retouch-task-center/02-task-detail-v2.png)
### 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,不把设计稿作为页面背景切图。
@@ -1,531 +0,0 @@
# AI 修图接口文档
## 接口列表
| # | 方法 | 路径 | 用途 |
|---|---|---|---|
| 1 | `GET` | `/api/yf-handset-app/photog/travel-album/material-list` | 获取相册网格素材和预览初始数据 |
| 2 | `GET` | `/api/yf-handset-app/photog/travel-album/material-info` | 获取/刷新当前原图及关联图 |
| 3 | `GET` | `/api/yf-handset-app/photog/travel-album/ai-retouch-options` | 获取 AI 模板、选择规则和剩余额度 |
| 4 | `POST` | `/api/yf-handset-app/photog/travel-album/ai-retouch` | 提交首次修图或重新修图任务 |
| 5 | `GET` | `/api/yf-handset-app/photog/travel-album/ai-retouch-job-info` | 查询 AI 修图任务进度和结果 |
| 6 | `POST` | `/api/yf-handset-app/photog/travel-album/delete-material` | 单张/批量删除素材及关联图 |
## 接口详情
### 获取相册素材列表
```http
GET /api/yf-handset-app/photog/travel-album/material-list?user_equity_travel_id=88&limit=30&cursor=opaque_cursor&sort=created_at_desc
```
| 参数 | 必填 | 说明 |
|---|---|---|
| `user_equity_travel_id` | 是 | 相册 ID |
| `limit` | 否 | 默认 30,最大 100 |
| `cursor` | 否 | 服务端返回的不透明游标,首页不传 |
| `sort` | 否 | `created_at_desc` 或 `created_at_asc` |
| `material_type` | 否 | `original`、`cover`;不传表示全部 |
响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"items": [
{
"id": "2031",
"user_equity_travel_id": "88",
"material_type": "original",
"original_asset": {
"id": "asset_8001",
"url": "https://cdn.example.com/albums/88/IMG_1024.jpg",
"thumbnail_url": "https://cdn.example.com/albums/88/IMG_1024_thumb.jpg",
"file_name": "IMG_1024.jpg",
"mime_type": "image/jpeg",
"file_size": 4821931,
"width": 4032,
"height": 3024,
"version": 1,
"created_at": "2026-08-09T01:00:00.000Z"
},
"display_status": "uploaded",
"variant_slots": [],
"revision": 7,
"created_at": "2026-08-09T01:00:00.000Z",
"updated_at": "2026-08-11T07:30:18.000Z"
}
],
"next_cursor": "next_opaque_cursor",
"has_more": true
}
}
```
- 只返回网格顶层素材:原图和独立封面。
- 原图如存在关联图,`variant_slots` 包含预览页 Tab 初始数据。
- 建议使用游标分页,因为 AI 任务完成时可能实时插入封面素材。
### 获取/刷新单个原图项目
```http
GET /api/yf-handset-app/photog/travel-album/material-info?user_equity_travel_id=88&material_id=2031
```
查询参数 `user_equity_travel_id` 和 `material_id` 均必填。响应的 `data` 直接返完整 `material_project`,字段与列表 Item 完全一致。该接口供预览页刷新和提交后定向更新使用。
如 `material_id` 已删除,返回 `404 MATERIAL_NOT_FOUND`。
### 获取 AI 修图选项
```http
GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_travel_id=88&scope=batch&source_count=4
```
| `scope` | 使用场景 |
|---|---|
| `batch` | 网格多选 AI 修图 |
| `all_variants` | 预览页无 Tab,或当前是原图 Tab |
| `refined_only` | 当前是精修后 Tab |
| `atmosphere_only` | 当前是氛围感 Tab |
`user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。
响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"scope": "batch",
"source_count": 4,
"groups": [
{
"type": "refined",
"title": "原图精修",
"required": true,
"selection_mode": "single",
"quota_cost": {
"mode": "per_source",
"units": 1
},
"templates": [
{
"id": "tpl_refined_12",
"name": "清透精修",
"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
}
]
},
{
"type": "atmosphere",
"title": "氛围感修图",
"required": false,
"selection_mode": "single",
"quota_cost": {
"mode": "per_source",
"units": 1
},
"templates": [
{
"id": "tpl_atmosphere_06",
"name": "暖阳",
"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
}
]
},
{
"type": "cover",
"title": "封面风格模板",
"required": true,
"selection_mode": "single",
"quota_cost": {
"mode": "per_job",
"units": 0
},
"templates": [
{
"id": "tpl_cover_03",
"name": "旅行画册",
"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
}
]
}
],
"quota": {
"unit": "retouch_time",
"remaining": 12,
"reserved": 3,
"display_text": "剩余 12 次",
"updated_at": "2026-08-11T07:29:58.000Z"
},
"limits": {
"minimum_source_count": 1,
"maximum_source_count": 50,
"cover_minimum_source_count": 4
}
}
}
```
设计要点:
- 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。
- 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。
- `preview_url` 用于模板卡片缩略图;`before_url` 与 `after_url` 必须是同尺寸、同构图的配对图片,供客户端滑动对比。
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
- 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。
- 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。
- `quota.remaining` 是扣除其他未完成任务已预占数量后、当前立即可用的额度;弹窗底部使用此值显示“剩余 N 次”。
- 额度文案位于模板选择弹窗的固定底部区域,不随中间模板列表滚动。
- `quota.reserved` 是当前用户其他未完成任务的预占额度,仅用于解释账户状态。
- 客户端预估本次消耗时,对已选输出求和:`per_source` 类型为 `source_count * units`,`per_job` 类型为 `units`。封面始终为 0。
- 本文档默认精修和氛围感每生成一张各消耗 1 次。如实际商业规则变更,后端只需调整 `quota_cost`,客户端不需改接口。
### 创建 AI 修图任务
```http
POST /api/yf-handset-app/photog/travel-album/ai-retouch
Idempotency-Key: 9A7820CE-78AB-45C6-9E0F-5DF268995379
Content-Type: application/json
```
#### 5.4.1 网格批量修图
```json
{
"user_equity_travel_id": "88",
"scope": "batch",
"source_material_ids": ["2031", "2032", "2033", "2034"],
"outputs": [
{
"type": "refined",
"template_id": "tpl_refined_12"
},
{
"type": "atmosphere",
"template_id": "tpl_atmosphere_06"
},
{
"type": "cover",
"template_id": "tpl_cover_03"
}
]
}
```
#### 5.4.2 预览页原图 Tab
```json
{
"user_equity_travel_id": "88",
"scope": "all_variants",
"source_material_ids": ["2031"],
"outputs": [
{
"type": "refined",
"template_id": "tpl_refined_15"
},
{
"type": "atmosphere",
"template_id": "tpl_atmosphere_09"
}
]
}
```
如用户未选氛围感模板,`outputs` 中不传 `atmosphere`。缺失表示“本次不处理”,不表示删除现有氛围感图。
#### 5.4.3 只重新精修
```json
{
"user_equity_travel_id": "88",
"scope": "refined_only",
"source_material_ids": ["2031"],
"outputs": [
{
"type": "refined",
"template_id": "tpl_refined_18"
}
]
}
```
#### 5.4.4 只重新生成氛围感
```json
{
"user_equity_travel_id": "88",
"scope": "atmosphere_only",
"source_material_ids": ["2031"],
"outputs": [
{
"type": "atmosphere",
"template_id": "tpl_atmosphere_11"
}
]
}
```
#### 5.4.5 提交校验矩阵
| `scope` | 原图数 | 精修 | 氛围感 | 封面 |
|---|---:|---|---|---|
| `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 |
| `all_variants` | 必须为 1 | 必选 | 可选 | 禁止 |
| `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 |
| `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 |
另外:
- `source_material_ids` 必须去重,且全部为 `original` 类型。
- 模板必须启用、类型匹配,且适用于相册所属景区。
- 后端必须使用提交时的最新额度计费规则重新计算消耗,不信任客户端的预估数值。
- 封面输出不论首次生成、重试或任务结果如何,额度消耗始终为 0。
- 每个输出槽位均采用 `replace_on_success`:成功后替换旧版本,失败保留旧版本。
- 批量选中已有 AI 结果的原图时,允许重新生成,只覆盖本次 `outputs` 列出的类型。
- 同一原图、同一槽位已有 `queued/processing` 任务时,整次请求返回 `409 RETOUCH_TARGET_BUSY`,并列出冲突项。
#### 5.4.6 提交响应
HTTP 状态码:`202 Accepted`
```json
{
"code": 100000,
"msg": "AI 修图任务已提交",
"data": {
"job": {
"id": "job_01K2E4",
"user_equity_travel_id": "88",
"scope": "all_variants",
"status": "queued",
"source_material_ids": ["2031"],
"progress": {
"total": 2,
"queued": 2,
"processing": 0,
"succeeded": 0,
"failed": 0
},
"created_at": "2026-08-11T07:30:15.123Z",
"started_at": null,
"finished_at": null
},
"quota_reservation": {
"before_remaining": 12,
"reserved_units": 2,
"remaining_after_reservation": 10,
"cover_units": 0
},
"affected_projects": [
{
"id": "2031",
"user_equity_travel_id": "88",
"material_type": "original",
"original_asset": {
"id": "asset_8001",
"url": "https://cdn.example.com/albums/88/IMG_1024.jpg",
"thumbnail_url": "https://cdn.example.com/albums/88/IMG_1024_thumb.jpg",
"file_name": "IMG_1024.jpg",
"mime_type": "image/jpeg",
"file_size": 4821931,
"width": 4032,
"height": 3024,
"version": 1,
"created_at": "2026-08-09T01:00:00.000Z"
},
"display_status": "pending",
"variant_slots": [
{
"type": "refined",
"asset": null,
"latest_generation": {
"job_id": "job_01K2E4",
"status": "queued",
"is_replacement": false,
"template": {
"id": "tpl_refined_15",
"name": "自然精修"
},
"requested_at": "2026-08-11T07:30:15.123Z",
"started_at": null,
"finished_at": null,
"error": null
}
},
{
"type": "atmosphere",
"asset": null,
"latest_generation": {
"job_id": "job_01K2E4",
"status": "queued",
"is_replacement": false,
"template": {
"id": "tpl_atmosphere_09",
"name": "暖阳"
},
"requested_at": "2026-08-11T07:30:15.123Z",
"started_at": null,
"finished_at": null,
"error": null
}
}
],
"revision": 8,
"created_at": "2026-08-09T01:00:00.000Z",
"updated_at": "2026-08-11T07:30:15.123Z"
}
]
}
}
```
必须返回 `job.id`、`quota_reservation` 和更新后的 `affected_projects`。客户端用 `remaining_after_reservation` 立即更新剩余额度,并更新 Cell 状态;已有 `asset` 的重修 Tab 可同步显示处理态,首次生成且 `asset = null` 的槽位不显示 Tab。
### 查询 AI 修图任务
```http
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-info?user_equity_travel_id=88&job_id=job_01K2E4
```
响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"id": "job_01K2E4",
"user_equity_travel_id": "88",
"scope": "batch",
"status": "partially_succeeded",
"source_material_ids": ["2031", "2032", "2033", "2034"],
"progress": {
"total": 9,
"queued": 0,
"processing": 0,
"succeeded": 8,
"failed": 1
},
"quota_settlement": {
"status": "settled",
"reserved_units": 8,
"consumed_units": 7,
"released_units": 1,
"cover_units": 0
},
"targets": [
{
"source_material_id": "2031",
"output_type": "refined",
"status": "succeeded",
"result_asset_id": "asset_9201",
"error": null
},
{
"source_material_id": "2032",
"output_type": "atmosphere",
"status": "failed",
"result_asset_id": null,
"error": {
"code": "AI_PROVIDER_TIMEOUT",
"message": "AI 服务处理超时,请重试",
"retryable": true
}
},
{
"source_material_id": null,
"output_type": "cover",
"status": "succeeded",
"result_material_id": "cover_301",
"error": null
}
],
"created_at": "2026-08-11T07:30:15.123Z",
"started_at": "2026-08-11T07:30:18.000Z",
"finished_at": "2026-08-11T07:33:00.000Z"
}
}
```
| 整体状态 | 含义 |
|---|---|
| `queued` | 已受理,尚无子任务开始 |
| `processing` | 至少一个子任务正在执行,且尚未全部完成 |
| `succeeded` | 所有子任务成功 |
| `partially_succeeded` | 子任务全部结束,且既有成功也有失败 |
| `failed` | 子任务全部结束,没有任何成功结果 |
| `canceled` | 任务因素材删除或管理操作被取消 |
额度结算规则:
- 任务提交成功时先预占所有非封面子任务的额度,封面不进入预占。
- 每个精修/氛围感子任务成功后,将对应预占转为实际消耗。
- 子任务失败或取消时释放对应预占。因此部分成功示例中,预占 8 次、成功消耗 7 次、失败退回 1 次。
- `quota_settlement.status` 取值为 `reserved | partially_settled | settled`。
客户端如只关心当前预览图,优先调用单素材接口;本接口主要用于批量进度、问题排查和任务详情。
### 批量删除素材
```http
POST /api/yf-handset-app/photog/travel-album/delete-material
Content-Type: application/json
```
请求:
```json
{
"user_equity_travel_id": "88",
"material_ids": ["2031", "2032", "cover_301"]
}
```
为兼容旧客户端,原单张请求体继续有效:
```json
{
"id": "2031"
}
```
新请求使用 `user_equity_travel_id + material_ids`,旧请求使用 `id`,两组参数不同时传入。
响应:
```json
{
"code": 100000,
"msg": "删除成功",
"data": {
"deleted_material_ids": ["2031", "2032", "cover_301"],
"deleted_variant_asset_ids": ["asset_9101", "asset_9102"],
"canceled_job_ids": ["job_01K2E4"],
"oss_cleanup_status": "scheduled"
}
}
```
语义:
- 传入原图 ID:级联软删除精修图、氛围感图,并取消该原图的未完成子任务。
- 传入独立封面 ID:只删除该封面素材。
- 删除原图不级联删除以它为输入之一的已生成封面。
- 数据库业务删除采用一个事务,默认全部成功或全部失败。
- 任一 ID 不属于当前相册或无权删除时,整次请求失败。已删除的 ID 可视为幂等成功。
- OSS 文件清理异步执行,不影响业务删除成功。
@@ -1,168 +0,0 @@
# AI 修图需求文档
> 本文档描述旅拍相册中 AI 修图的产品需求和业务规则。后端接口建议见同目录《AI 修图接口文档》。
## 1. 目标与范围
- 相册管理页面展示用户已在其他流程上传到 OSS、并已在后端登记的图片素材。
- 用户可从网格页批量发起 AI 修图,也可从单图预览页首次修图或重新修图。
- 精修图和氛围感图是某张原图的“关联图”;封面图是相册内的独立素材,不归属于任何一张原图。
- AI 生成为异步长流程,页面必须能分辨已提交、排队、处理、成功和失败。
## 2. 核心概念
### 2.1 原图项目
一张原图与它的精修图、氛围感图共同组成一个原图项目。原图的素材 ID 是项目的稳定标识,OSS URL 不作为业务标识。
### 2.2 关联图
- `refined`:精修图,一张原图最多保留一个当前有效版本。
- `atmosphere`:氛围感图,一张原图最多保留一个当前有效版本。
- 关联图不作为独立 Cell 出现在相册网格中,只在该原图的预览页 Tab 中展示。
### 2.3 封面图
- 一次选择不少于 4 张原图时,必须选择封面模板。
- 封面模板使用本次选中的多张原图生成封面图。
- 封面图不关联某张原图,生成成功后作为 `cover` 类型的独立素材出现在相册网格中。
- 删除参与生成封面的原图时,不级联删除已生成的独立封面图。
## 3. 相册管理页
### 3.1 网格列表
- 网格展示原图和独立封面图,不直接展示精修图、氛围感图。
- 列表需支持稳定分页。AI 任务执行期间可能插入新的封面素材,不应造成重复或漏项。
- 点击原图 Cell 进入图片预览页,可横向滑动切换其他原图项目。
### 3.2 Cell 修图状态
Cell 左上角展示稳定的修图状态:
| 状态 | 含义 |
|---|---|
| 已上传 | 原图已登记,从未提交 AI 修图,当前也没有任务 |
| 待处理 | AI 任务已提交,正在排队 |
| 修图中 | AI 任务已开始执行 |
| AI 已修 | 最新任务已成功,存在有效 AI 结果 |
| 失败 | 最新任务执行失败,可能仍保留上一版成功结果 |
状态以后端返回的枚举代码为准,客户端自行映射中文文案,不依赖可变的中文状态名。
### 3.3 批量 AI 修图
1. 用户进入多选模式并选择一张或多张原图。
2. 点击底部“AI 修图”,弹出模板选择页。
3. 精修模板必选且单选。
4. 氛围感模板可选且单选,已选时可取消。
5. 选中原图数量小于 4 时不展示封面模板。
6. 选中原图数量大于等于 4 时展示封面模板,且必须单选一个。
7. 提交后,每张原图分别生成精修图,选了氛围感模板时再分别生成氛围感图。
8. 选了封面模板时,整个批次额外生成独立封面图。
9. 提交成功指后端已受理任务,不代表图片已生成。
### 3.4 修图额度
- 模板选择弹窗的底部固定显示当前用户可用的 AI 修图剩余额度,文案例如“剩余 12 次”,不随模板列表滚动消失。
- 精修和氛围感生成会消耗修图额度;封面模板生成不消耗任何修图额度。
- 本需求默认按生成结果张数计费:每张原图的精修结果消耗 1 次,每张原图的氛围感结果消耗 1 次,封面结果消耗 0 次。最终单价由后端返回,客户端不硬编码。
- 重新修图与首次修图使用相同的额度规则。
- 后端受理任务时预占本次所需额度,避免用户并发提交造成超额。
- 只对最终生成成功的精修/氛围感结果扣减额度;失败或取消的子任务释放对应预占额度。
- 如剩余可用额度不足,提交按钮禁用并显示明确提示;后端提交接口仍必须做最终校验。
### 3.5 批量删除
- 用户多选原图后点击“删除”,删除选中原图及各自的精修图、氛围感图,并取消关联的未完成任务。已有任务记录保留用于审计和排查,但不再对客户端展示已删除素材。
- 已生成的独立封面图不在级联删除范围内,除非用户明确选中该封面素材本身。
- 批量删除应只调用一次后端接口,后端应返回整体或逐项结果,不由客户端循环调用单删接口。
## 4. 图片预览页
### 4.1 横向切换
- 从网格点击某张原图进入预览页。
- 横向滑动在相册内的不同原图项目之间切换。
- 切换到新的原图项目时,默认显示其“原图”。
### 4.2 底部操作
页面底部固定显示三个按钮:
- AI 修图
- 删除
- 刷新
### 4.3 关联图 Tab
- 如果当前原图没有任何已生成的关联图,不显示 Tab;首次生成任务排队或处理期间仍不显示。
- 存在至少一张已成功的关联图时显示 Tab。
- Tab 最多包含“原图”、“精修后”、“氛围感”;关联图 Tab 只在对应图片已生成时显示。
- 点击 Tab 切换当前原图项目内的展示图片,不切换原图项目。
- 某个已有结果的 Tab 重新提交修图后,仍可展示上一版图片,同时通过该结果槽位的任务状态展示“待处理/修图中/失败”。
- 首次生成尚无图片 URL 时,通过原图 Cell 状态和项目任务状态表达进度,不创建空 Tab。
### 4.4 预览页删除
- 删除的对象始终是当前原图项目,而不是当前 Tab 上的单张关联图。
- 删除原图及其所有关联图,并取消关联的未完成任务;历史任务记录保留用于审计。
- 删除成功后显示下一个原图项目;如果删除的是末项,则回退上一项;无剩余项时关闭预览页。
### 4.5 预览页 AI 修图
| 当前情况 | 模板要求 | 生成与覆盖规则 |
|---|---|---|
| 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 |
| 已显示 Tab,当前为“原图” | 精修必选,氛围感可选 | 重新生成精修图;如选氛围感则也重新生成。新结果成功后原子替换对应旧结果 |
| 已显示 Tab,当前为“精修后” | 精修必选 | 只重新生成精修图,成功后覆盖旧精修图,不影响氛围感图 |
| 已显示 Tab,当前为“氛围感” | 氛围感必选 | 只重新生成氛围感图,成功后覆盖旧氛围感图,不影响精修图 |
重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。
### 4.6 刷新
- 点击“刷新”只获取当前显示原图项目的最新数据,包括原图、关联图、各结果槽位的任务状态和整体修图状态。
- 不应为刷新一张图重新拉取整个分页列表。
- 服务端对已替换的图片应返回新的资源版本或新 URL,避免 CDN/客户端缓存继续显示旧图。
## 5. 异步任务规则
- 每次提交返回唯一 `job_id`、服务端接收时间和当前状态。
- 每次提交同时返回额度预占数、提交前剩余额度和预占后可用额度。
- 任务状态至少包含:`queued`、`processing`、`succeeded`、`partially_succeeded`、`failed`、`canceled`。
- 精修、氛围感和封面子任务可以独立成功或失败,服务端需保留逐原图、逐结果类型的状态。
- 客户端重试同一次提交时不得创建重复任务,由幂等键保证。
- 同一原图、同一结果类型存在未完成任务时,服务端拒绝再次提交,避免旧任务晚完成后覆盖新结果。
## 6. 删除与数据一致性
- 删除原图项目需在一个后端业务操作中完成:标记原图删除、标记关联图删除、取消未完成任务。
- 数据库状态成功后再异步清理 OSS 文件,不应因 OSS 删除失败导致客户端删除失败。
- AI 回调必须检查任务和素材是否已删除或已被新版本替代,过期回调不得恢复已删除素材或覆盖新结果。
## 7. 异常与提示
- 模板加载失败:保留页面并允许重试。
- 模板已下线:提交时返回明确错误码,客户端刷新模板列表。
- 素材不存在、不属于当前相册或不是原图:整次提交不受理,并返回问题素材 ID。
- 数量不满足封面规则:返回稳定错误码和最小数量。
- 任务部分成功:保留已成功结果,失败槽位提供错误码、错误信息和是否可重试。
- 网络超时:客户端使用原幂等键重试,不可产生两个任务。
## 8. 权限与校验
- 后端必须从登录态校验用户对相册、原图和模板的访问权,不信任客户端传入的用户 ID、景区 ID 或批次 ID。
- 模板是否适用于相册所属景区,由后端根据相册 ID 推导和校验。
- 后端负责校验精修、氛围感和封面模板的必选/可选规则,客户端校验只用于交互提示。
## 9. 默认产品决策
为使接口和异步覆盖行为可实现,本文档采用以下默认决策:
1. 重新修图不先删旧图;新图成功后才原子切换,失败仍保留旧图。
2. 在“原图” Tab 重新修图时,若用户未选氛围感模板,保留现有氛围感图,只替换精修图。
3. 封面图只在生成成功后插入网格;生成前由所选原图的修图状态和任务详情表达进度。
4. 每个关联图类型只对外暴露一个当前有效版本;历史版本是否长期保留属于后端存储策略,不在当前客户端功能中展示。
> 第 2 条是对“氛围感模板可选”的安全解释:未选不等于删除旧结果。如产品希望未选时删除旧氛围感图,应增加明确的“移除氛围感”操作,不建议让后端根据字段缺失隐式删除。
@@ -29,9 +29,6 @@ protocol TravelAlbumServing {
isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
/// 拉取单个相册素材及其最新关联图片信息。
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial
/// 上传并登记旅拍相册素材。
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
@@ -41,30 +38,11 @@ protocol TravelAlbumServing {
/// 删除旅拍相册。
func deleteAlbum(id: Int) async throws
/// 批量删除旅拍相册素材。
func batchDeleteMaterials(ids: [Int]) async throws
/// 删除旅拍相册素材。
func deleteMaterial(id: Int) async throws
/// 拉取相册小程序码。
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
/// 拉取当前景区可用的 AI 修图模板。
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
/// 提交相册素材 AI 修图任务。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
/// 提交单张素材重新修图任务。
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
@@ -135,20 +113,6 @@ final class TravelAlbumAPI: TravelAlbumServing {
)
}
/// 拉取单个相册素材及其最新关联图片信息。
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial {
try await client.send(
APIRequest(
method: .get,
path: "\(basePath)/material-info",
queryItems: [
URLQueryItem(name: "user_equity_travel_id", value: String(userEquityTravelId)),
URLQueryItem(name: "material_id", value: String(materialId)),
]
)
)
}
/// 上传并登记旅拍相册素材。
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
@@ -174,14 +138,10 @@ final class TravelAlbumAPI: TravelAlbumServing {
)
}
/// 批量删除旅拍相册素材。
func batchDeleteMaterials(ids: [Int]) async throws {
/// 删除旅拍相册素材。
func deleteMaterial(id: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "\(basePath)/batch-delete-material",
body: TravelAlbumBatchDeleteMaterialRequest(ids: ids)
)
APIRequest(method: .post, path: "\(basePath)/delete-material", body: TravelAlbumIDRequest(id: id))
)
}
@@ -195,64 +155,9 @@ final class TravelAlbumAPI: TravelAlbumServing {
)
)
}
/// 拉取当前景区可用的 AI 修图模板。
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
try await client.send(
APIRequest(
method: .get,
path: "\(basePath)/ai-retouch-templates",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
)
)
}
/// 提交相册素材 AI 修图任务并返回任务摘要。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", 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))]
)
)
}
}
/// 旅拍相册 ID 请求体。
private struct TravelAlbumIDRequest: Encodable, Sendable {
let id: Int
}
/// 旅拍相册素材批量删除请求体。
private struct TravelAlbumBatchDeleteMaterialRequest: Encodable, Sendable {
let ids: [Int]
}
@@ -1,380 +0,0 @@
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())
}
}
@@ -0,0 +1,122 @@
//
// TravelAlbumAutoRetouchModels.swift
// suixinkan
//
import Foundation
/// 自动修图第一级方式,页面上始终只展示“不修图”和“AI 修图”两个选项。
enum TravelAlbumRetouchMode: String, CaseIterable, Hashable, Sendable {
case disabled
case aiRetouch
/// 修图方式的用户可见名称。
var title: String {
switch self {
case .disabled:
return "不修图"
case .aiRetouch:
return "AI 修图"
}
}
}
/// 相册自动 AI 修图配置,用于在新建相册与照片上传页之间传递模板选择。
struct TravelAlbumAutoRetouchConfiguration: Codable, Equatable, Sendable {
let isEnabled: Bool
let templateID: String?
/// 默认不启用自动修图。
static let disabled = TravelAlbumAutoRetouchConfiguration(isEnabled: false, templateID: nil)
/// 根据已选模板创建启用状态配置。
static func enabled(templateID: String) -> TravelAlbumAutoRetouchConfiguration {
TravelAlbumAutoRetouchConfiguration(isEnabled: true, templateID: templateID)
}
/// 当前配置对应的修图模板。
var template: TravelAlbumEditPreset? {
guard isEnabled, let templateID else { return nil }
return TravelAlbumEditPreset.autoRetouchOptions.first { $0.id == templateID }
}
/// 是否可以作为完整的自动修图配置提交。
var isValid: Bool {
!isEnabled || template != nil
}
/// 上传页紧凑设置项的展示文案。
var uploadOptionTitle: String {
isEnabled ? TravelAlbumRetouchMode.aiRetouch.title : TravelAlbumRetouchMode.disabled.title
}
}
extension TravelAlbumEditPreset {
/// 自动修图可选模板,不包含手动修图中的“还原为原图”。
static var autoRetouchOptions: [TravelAlbumEditPreset] {
defaultOptions.filter { $0.effect != .original }
}
}
/// 单张 OTG 照片的自动 AI 修图状态,与原有上传状态分开计算。
enum TravelAlbumAutoRetouchState: String, Codable, Equatable, Sendable {
case none = "NONE"
case processing = "PROCESSING"
case completed = "COMPLETED"
case failed = "FAILED"
}
/// 相册自动修图配置读写接口,便于 ViewModel 与单元测试注入。
protocol TravelAlbumAutoRetouchConfigurationStoring: AnyObject {
/// 读取指定服务端相册的配置。
func configuration(albumID: Int) -> TravelAlbumAutoRetouchConfiguration
/// 保存指定服务端相册的配置。
func save(_ configuration: TravelAlbumAutoRetouchConfiguration, albumID: Int)
/// 删除指定相册的本地配置。
func remove(albumID: Int)
}
/// 使用 UserDefaults 按服务端相册 ID 持久化自动 AI 修图配置。
final class TravelAlbumAutoRetouchConfigurationStore: TravelAlbumAutoRetouchConfigurationStoring {
static let shared = TravelAlbumAutoRetouchConfigurationStore()
private let userDefaults: UserDefaults
private let keyPrefix: String
/// 创建配置存储;测试可传入独立 UserDefaults suite。
init(
userDefaults: UserDefaults = .standard,
keyPrefix: String = "travelAlbum.autoRetouch.album"
) {
self.userDefaults = userDefaults
self.keyPrefix = keyPrefix
}
func configuration(albumID: Int) -> TravelAlbumAutoRetouchConfiguration {
guard albumID > 0,
let data = userDefaults.data(forKey: key(albumID: albumID)),
let configuration = try? JSONDecoder().decode(TravelAlbumAutoRetouchConfiguration.self, from: data),
configuration.isValid else {
return .disabled
}
return configuration
}
func save(_ configuration: TravelAlbumAutoRetouchConfiguration, albumID: Int) {
guard albumID > 0 else { return }
let normalized = configuration.isValid ? configuration : .disabled
guard let data = try? JSONEncoder().encode(normalized) else { return }
userDefaults.set(data, forKey: key(albumID: albumID))
}
func remove(albumID: Int) {
guard albumID > 0 else { return }
userDefaults.removeObject(forKey: key(albumID: albumID))
}
private func key(albumID: Int) -> String {
"\(keyPrefix).\(albumID)"
}
}
@@ -116,6 +116,8 @@ struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable {
/// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
private static let bundledImageURLPrefix = "asset://"
let id: Int
let userEquityTravelId: Int
let status: Int
@@ -127,11 +129,6 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
let fileSize: Int
let coverUrl: String
let isPurchased: Bool
let aiRetouchStatus: Int
let aiRetouchStatusName: String
let aiRetouchBatchId: Int
let aiRefinedURL: String
let aiAtmosphereURL: String
let createdAt: String
let updatedAt: String
@@ -147,42 +144,10 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
case fileSize = "file_size"
case coverUrl = "cover_url"
case isPurchased = "is_purchased"
case aiRetouchStatus = "ai_retouch_status"
case aiRetouchStatusName = "ai_retouch_status_name"
case aiRetouchBatchId = "ai_retouch_batch_id"
case aiRefinedURL = "ai_refined_url"
case aiAtmosphereURL = "ai_atmosphere_url"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
/// 从素材接口解码;AI 修图扩展字段缺失、为空或类型异常时使用安全默认值。
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
userEquityTravelId = try container.decode(Int.self, forKey: .userEquityTravelId)
status = try container.decode(Int.self, forKey: .status)
orderNumber = try container.decode(String.self, forKey: .orderNumber)
userId = try container.decode(Int.self, forKey: .userId)
fileName = try container.decode(String.self, forKey: .fileName)
fileType = try container.decode(Int.self, forKey: .fileType)
fileUrl = try container.decode(String.self, forKey: .fileUrl)
fileSize = try container.decode(Int.self, forKey: .fileSize)
coverUrl = try container.decode(String.self, forKey: .coverUrl)
isPurchased = try container.decode(Bool.self, forKey: .isPurchased)
aiRetouchStatus = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchStatus)) ?? 0
aiRetouchStatusName = (
try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName)
) ?? ""
aiRetouchBatchId = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchBatchId)) ?? 0
aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? ""
aiAtmosphereURL = (
try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL)
) ?? ""
createdAt = try container.decode(String.self, forKey: .createdAt)
updatedAt = try container.decode(String.self, forKey: .updatedAt)
}
init(
id: Int = 0,
userEquityTravelId: Int = 0,
@@ -195,11 +160,6 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
fileSize: Int = 0,
coverUrl: String = "",
isPurchased: Bool = false,
aiRetouchStatus: Int = 0,
aiRetouchStatusName: String = "",
aiRetouchBatchId: Int = 0,
aiRefinedURL: String = "",
aiAtmosphereURL: String = "",
createdAt: String = "",
updatedAt: String = ""
) {
@@ -214,66 +174,20 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
self.fileSize = fileSize
self.coverUrl = coverUrl
self.isPurchased = isPurchased
self.aiRetouchStatus = aiRetouchStatus
self.aiRetouchStatusName = aiRetouchStatusName
self.aiRetouchBatchId = aiRetouchBatchId
self.aiRefinedURL = aiRefinedURL
self.aiAtmosphereURL = aiAtmosphereURL
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
case purchased
case pending
case processing
case retouched
case cover
case failed
}
/// 相册素材网格角标展示内容。
struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
let kind: TravelAlbumMaterialBadgeKind
let text: String
}
extension TravelAlbumMaterial {
/// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。
var badgePresentation: TravelAlbumMaterialBadgePresentation? {
if aiRetouchStatus == 0 {
return isPurchased
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
: nil
/// 将工程内图片资源转换为素材可识别的本地地址,仅供无接口数据的演示素材使用。
static func bundledImageURL(assetName: String) -> String {
bundledImageURLPrefix + assetName
}
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
switch aiRetouchStatus {
case 1:
return TravelAlbumMaterialBadgePresentation(
kind: .pending,
text: statusName.isEmpty ? "待处理" : statusName
)
case 2:
return TravelAlbumMaterialBadgePresentation(
kind: .processing,
text: statusName.isEmpty ? "修图中" : statusName
)
case 3:
return TravelAlbumMaterialBadgePresentation(
kind: statusName == "AI封面" ? .cover : .retouched,
text: statusName.isEmpty ? "AI已修" : statusName
)
case 4:
return TravelAlbumMaterialBadgePresentation(
kind: .failed,
text: statusName.isEmpty ? "失败" : statusName
)
default:
return nil
}
/// 返回本地演示素材对应的图片资源名;真实网络素材返回 `nil`。
var bundledImageAssetName: String? {
guard fileUrl.hasPrefix(Self.bundledImageURLPrefix) else { return nil }
let assetName = String(fileUrl.dropFirst(Self.bundledImageURLPrefix.count))
return assetName.isEmpty ? nil : assetName
}
}
@@ -347,182 +261,6 @@ struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable {
}
}
/// AI 修图模板类别,决定页面分组、选择规则和提交字段。
enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
case refined
case atmosphere
case cover
/// 模板分组展示标题。
var title: String {
switch self {
case .refined: "原图精修"
case .atmosphere: "氛围感修图"
case .cover: "封面风格"
}
}
}
/// AI 修图模板页工作流,明确区分首次批量修图与单张结果覆盖重修。
enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
/// 对一个相册内的原始素材发起首次 AI 修图。
case initial(albumId: Int, materialIds: [Int])
/// 对单张素材的既有 AI 结果发起覆盖重修。
case reretouch(materialId: Int, batchId: Int, type: TravelAlbumAIReretouchType)
/// 当前页面需要展示的模板分组。
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
switch self {
case .initial(_, let materialIds):
var categories: [TravelAlbumAIRetouchTemplateCategory] = [.refined, .atmosphere]
if materialIds.count >= 4 { categories.append(.cover) }
return categories
case .reretouch(_, _, .refined):
return [.refined]
case .reretouch(_, _, .atmosphere):
return [.atmosphere]
case .reretouch(_, _, .all):
return [.refined, .atmosphere]
}
}
/// 当前分组是否允许不选择;仅首次修图的氛围感模板选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
if case .initial = self, category == .atmosphere { return true }
return false
}
/// 工作流目标是否满足接口的最小参数要求。
var isValid: Bool {
switch self {
case .initial(let albumId, let materialIds):
return albumId > 0 && !materialIds.isEmpty
case .reretouch(let materialId, let batchId, _):
return materialId > 0 && batchId > 0
}
}
}
/// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let name: String
let previewURL: String
let beforeURL: String
let afterURL: String
enum CodingKeys: String, CodingKey {
case id
case name
case previewURL = "preview_url"
case beforeURL = "before_url"
case afterURL = "after_url"
}
/// 创建 AI 修图模板。
init(
id: Int,
name: String,
previewURL: String,
beforeURL: String = "",
afterURL: String = ""
) {
self.id = id
self.name = name
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)) ?? ""
}
}
/// AI 修图模板接口响应,按原图、氛围感和封面风格分组。
struct TravelAlbumAIRetouchTemplatesResponse: Decodable, Sendable, Equatable {
let refinedTemplates: [TravelAlbumAIRetouchTemplate]
let atmosphereTemplates: [TravelAlbumAIRetouchTemplate]
let coverTemplates: [TravelAlbumAIRetouchTemplate]
let remainingQuota: Int
enum CodingKeys: String, CodingKey {
case refinedTemplates = "refined_templates"
case atmosphereTemplates = "atmosphere_templates"
case coverTemplates = "cover_templates"
case remainingQuota = "remaining_quota"
}
/// 创建分组模板响应,默认各组为空。
init(
refinedTemplates: [TravelAlbumAIRetouchTemplate] = [],
atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [],
coverTemplates: [TravelAlbumAIRetouchTemplate] = [],
remainingQuota: Int = 0
) {
self.refinedTemplates = refinedTemplates
self.atmosphereTemplates = atmosphereTemplates
self.coverTemplates = coverTemplates
self.remainingQuota = max(0, remainingQuota)
}
/// 解码模板和剩余额度;旧响应缺少额度时按零处理,避免误提交付费任务。
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
refinedTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .refinedTemplates)
atmosphereTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .atmosphereTemplates)
coverTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .coverTemplates)
remainingQuota = max(0, try container.decodeIfPresent(Int.self, forKey: .remainingQuota) ?? 0)
}
}
/// 提交 AI 修图任务的请求参数。
struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
let userEquityTravelId: Int
let materialIds: [Int]
let refinedTemplateId: Int
let atmosphereTemplateId: Int?
let coverTemplateId: Int?
enum CodingKeys: String, CodingKey {
case userEquityTravelId = "user_equity_travel_id"
case materialIds = "material_ids"
case refinedTemplateId = "refined_template_id"
case atmosphereTemplateId = "atmosphere_template_id"
case coverTemplateId = "cover_template_id"
}
}
/// 重新修图类型,决定后端覆盖的 AI 结果及必需模板字段。
enum TravelAlbumAIReretouchType: Int, Encodable, Sendable, Equatable {
case refined = 1
case atmosphere = 2
case all = 3
}
/// 提交单张素材重新修图的最小请求参数。
struct TravelAlbumAIReretouchRequest: Encodable, Sendable, Equatable {
let id: Int
let aiRetouchBatchId: Int
let type: TravelAlbumAIReretouchType
let refinedTemplateId: Int?
let atmosphereTemplateId: Int?
enum CodingKeys: String, CodingKey {
case id
case aiRetouchBatchId = "ai_retouch_batch_id"
case type
case refinedTemplateId = "refined_template_id"
case atmosphereTemplateId = "atmosphere_template_id"
}
}
/// 旅拍相册展示格式化工具。
enum TravelAlbumDisplayFormatter {
/// 脱敏手机号。
@@ -553,22 +291,4 @@ enum TravelAlbumDisplayFormatter {
}
return text
}
/// 将服务端创建时间格式化为摘要卡使用的 `yyyy/MM/dd HH:mm`。
static func creationTimeText(_ text: String) -> String {
guard !text.isEmpty else { return "--" }
let normalized = text.replacingOccurrences(of: "T", with: " ")
guard normalized.count >= 16 else { return normalized }
return String(normalized.prefix(16)).replacingOccurrences(of: "-", with: "/")
}
/// 计算相册摘要卡封面,按相册、素材缩略图、素材原图顺序回退。
static func albumCoverURL(album: TravelAlbum?, materials: [TravelAlbumMaterial]) -> String {
if let coverURL = album?.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines), !coverURL.isEmpty {
return coverURL
}
guard let first = materials.first else { return "" }
let materialCover = first.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines)
return materialCover.isEmpty ? first.fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) : materialCover
}
}
@@ -0,0 +1,316 @@
//
// TravelAlbumPhotoPreviewModels.swift
// suixinkan
//
import Foundation
/// 单张照片的 AI 修图状态,控制处理中禁用、结果对比以及再次修图覆盖。
struct TravelAlbumAIEditResultState: Equatable, Sendable {
/// 相册缩略图需要展示的 AI 修图状态。
enum ThumbnailStatus: Equatable, Sendable {
case none
case processing
case edited
case cover
case failed
}
/// 当前预览展示的版本。
enum DisplayMode: Equatable, Sendable {
case original
case edited
case atmosphere
case cover
}
private(set) var isProcessing = false
private(set) var didFailLastAttempt = false
private(set) var appliedPresetID: String?
private(set) var appliedAtmosphereOptionID: String?
private(set) var appliedCoverTemplateID: String?
private(set) var displayMode: DisplayMode = .original
/// 已经存在可查看的修图结果。
var hasEditedResult: Bool {
appliedPresetID != nil
}
/// 处理期间禁止再次发起 AI 修图。
var canStartEditing: Bool {
!isProcessing
}
/// 当前结果是否为封面模板生成的封面图。
var isCover: Bool {
appliedCoverTemplateID != nil
}
/// 当前是否已经生成可独立查看的氛围感修图结果。
var hasAtmosphereResult: Bool {
appliedAtmosphereOptionID != nil
}
/// 根据修图任务状态生成缩略图角标,处理中和失败优先于历史成功结果。
var thumbnailStatus: ThumbnailStatus {
if isProcessing {
return .processing
}
if didFailLastAttempt {
return .failed
}
if isCover {
return .cover
}
return hasEditedResult ? .edited : .none
}
/// 开始修图;已有结果保留到新结果完成,避免处理中画面回退。
mutating func startProcessing() {
guard !isProcessing else { return }
isProcessing = true
didFailLastAttempt = false
}
/// 完成修图并覆盖旧预设结果,默认展示新的修图后版本。
mutating func complete(
presetID: String,
atmosphereOptionID: String? = nil,
coverTemplateID: String? = nil
) {
isProcessing = false
didFailLastAttempt = false
appliedPresetID = presetID
appliedAtmosphereOptionID = atmosphereOptionID
appliedCoverTemplateID = coverTemplateID
displayMode = coverTemplateID == nil ? .edited : .cover
}
/// 处理失败时恢复入口;已有成功结果继续保留。
mutating func failProcessing() {
isProcessing = false
didFailLastAttempt = true
}
/// 选择还原原图后清除历史修图结果和失败状态。
mutating func restoreOriginal() {
isProcessing = false
didFailLastAttempt = false
appliedPresetID = nil
appliedAtmosphereOptionID = nil
appliedCoverTemplateID = nil
displayMode = .original
}
/// 在已有结果时切换原图或修图后预览。
mutating func selectDisplayMode(_ mode: DisplayMode) {
guard hasEditedResult else {
displayMode = .original
return
}
if mode == .atmosphere, !hasAtmosphereResult {
displayMode = .edited
return
}
if mode == .cover, !isCover {
displayMode = .edited
return
}
displayMode = mode
}
}
/// 相册管理底部操作栏状态,用于在上传与多选操作之间切换。
struct TravelAlbumSelectionToolbarState: Equatable, Sendable {
let isSelectionMode: Bool
let selectedCount: Int
/// 普通状态显示上传照片。
var showsUpload: Bool {
!isSelectionMode
}
/// 多选状态显示 AI 修图和删除。
var showsSelectionActions: Bool {
isSelectionMode
}
/// 至少选中一张照片后才允许执行批量操作。
var selectionActionsEnabled: Bool {
isSelectionMode && selectedCount > 0
}
}
/// 相册照片预览状态,维护当前照片位置并处理左右切换。
struct TravelAlbumPhotoPreviewState: Equatable, Sendable {
private(set) var materials: [TravelAlbumMaterial]
private(set) var currentIndex: Int
init(materials: [TravelAlbumMaterial], initialIndex: Int) {
self.materials = materials
currentIndex = min(max(initialIndex, 0), max(materials.count - 1, 0))
}
/// 当前正在预览的照片。
var currentMaterial: TravelAlbumMaterial? {
materials.indices.contains(currentIndex) ? materials[currentIndex] : nil
}
/// 切换至下一张;已经是最后一张时保持不变。
@discardableResult
mutating func moveNext() -> Bool {
guard currentIndex + 1 < materials.count else { return false }
currentIndex += 1
return true
}
/// 切换至上一张;已经是第一张时保持不变。
@discardableResult
mutating func movePrevious() -> Bool {
guard currentIndex > 0 else { return false }
currentIndex -= 1
return true
}
/// 删除当前照片,并自动定位到相邻照片。
@discardableResult
mutating func removeCurrent() -> Bool {
guard materials.indices.contains(currentIndex) else { return false }
materials.remove(at: currentIndex)
if currentIndex >= materials.count {
currentIndex = max(materials.count - 1, 0)
}
return !materials.isEmpty
}
}
/// 相册照片 AI 修图预设,当前为本地演示数据,后续可替换为服务端下发的预设列表。
struct TravelAlbumEditPreset: Hashable, Sendable, Identifiable {
/// 图像效果类型,用于在预览页生成对应效果图。
enum Effect: String, Hashable, Sendable {
case original
case portrait
case vintage
case brocade
case distantMountain
case mist
case summer
case rich
}
let id: String
let title: String
let effect: Effect
/// AI 修图底部弹窗展示的默认预设,暂无接口时用于完整演示选择流程。
static let defaultOptions: [TravelAlbumEditPreset] = [
TravelAlbumEditPreset(id: "original", title: "还原为原图", effect: .original),
TravelAlbumEditPreset(id: "portrait", title: "写真-简约肖像", effect: .portrait),
TravelAlbumEditPreset(id: "vintage", title: "写真-清冷古风", effect: .vintage),
TravelAlbumEditPreset(id: "brocade", title: "旅拍-锦绣", effect: .brocade),
TravelAlbumEditPreset(id: "distant_mountain", title: "旅拍-远山", effect: .distantMountain),
TravelAlbumEditPreset(id: "mist", title: "旅拍-薄雾", effect: .mist),
TravelAlbumEditPreset(id: "summer", title: "油画-夏日", effect: .summer),
TravelAlbumEditPreset(id: "rich", title: "油画-浓郁", effect: .rich),
]
}
/// 可选的氛围感修图样式;选中后在原图精修之外额外生成一张独立结果。
struct TravelAlbumAtmosphereEditOption: Hashable, Sendable, Identifiable {
let id: String
let title: String
let subtitle: String
let effect: TravelAlbumEditPreset.Effect
/// 暂无接口时用于演示横向滚动单选的氛围感样式。
static let defaultOptions: [TravelAlbumAtmosphereEditOption] = [
TravelAlbumAtmosphereEditOption(
id: "warm_sun",
title: "日落暖阳",
subtitle: "温暖柔和",
effect: .summer
),
TravelAlbumAtmosphereEditOption(
id: "retro_film",
title: "复古胶片",
subtitle: "怀旧颗粒感",
effect: .vintage
),
TravelAlbumAtmosphereEditOption(
id: "clear_blue",
title: "清透蓝调",
subtitle: "通透轻盈",
effect: .mist
),
TravelAlbumAtmosphereEditOption(
id: "forest_mist",
title: "森系薄雾",
subtitle: "低饱和自然感",
effect: .distantMountain
),
TravelAlbumAtmosphereEditOption(
id: "vivid_story",
title: "浓郁故事",
subtitle: "高饱和电影感",
effect: .rich
),
]
/// 兼容现有结果测试与默认演示数据的首个氛围感样式。
static let defaultOption = defaultOptions[0]
}
/// 多图 AI 修图使用的封面风格模板,当前为本地演示数据,后续可替换为服务端配置。
struct TravelAlbumCoverTemplate: Hashable, Sendable, Identifiable {
/// 封面预览的排版样式,用于生成可辨识的本地效果图。
enum PreviewStyle: String, Hashable, Sendable {
case travelMemoir
case minimal
case scenicStory
case film
}
/// 触发展示封面模板选择的最少照片数量。
static let minimumPhotoCount = 4
let id: String
let title: String
let previewStyle: PreviewStyle
/// 当前消耗的精修额度;免费阶段为 0,后续收费可由服务端改为正数。
let quotaCost: Int
/// 当前模板是否为赠送且不占用精修张数。
var isFreeGift: Bool {
quotaCost == 0
}
/// 判断本次多选是否需要展示封面模板模块。
static func shouldShow(for selectedPhotoCount: Int) -> Bool {
selectedPhotoCount >= minimumPhotoCount
}
/// 暂无接口时用于完整演示单选流程的封面模板。
static let defaultOptions: [TravelAlbumCoverTemplate] = [
TravelAlbumCoverTemplate(id: "travel_memoir", title: "旅行纪念册", previewStyle: .travelMemoir, quotaCost: 0),
TravelAlbumCoverTemplate(id: "minimal", title: "简约留白", previewStyle: .minimal, quotaCost: 0),
TravelAlbumCoverTemplate(id: "scenic_story", title: "景区故事", previewStyle: .scenicStory, quotaCost: 0),
TravelAlbumCoverTemplate(id: "film", title: "电影胶片", previewStyle: .film, quotaCost: 0),
]
}
/// AI 修图弹窗最终提交的样式组合,统一承载原图精修、氛围感和封面模板。
struct TravelAlbumAIEditSelection: Equatable, Sendable {
let refinedPreset: TravelAlbumEditPreset
let atmosphereOption: TravelAlbumAtmosphereEditOption?
let coverTemplate: TravelAlbumCoverTemplate?
/// 每张普通照片需要生成的修图结果数量,不包含原图。
var resultCountPerPhoto: Int {
atmosphereOption == nil ? 1 : 2
}
/// 弹窗当前展示并提交的样式类别数量。
var selectedStyleCount: Int {
1 + (atmosphereOption == nil ? 0 : 1) + (coverTemplate == nil ? 0 : 1)
}
}
@@ -1,270 +0,0 @@
//
// TravelAlbumPreviewModels.swift
// suixinkan
//
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 {
/// 横滑只切换原图项目,关联图通过 Tab 切换。
case projectsOnly
/// 横滑依次切换当前项目的关联图,并在边界进入相邻项目原图。
case includeVariants
}
/// 相册预览页配置。
struct TravelAlbumPreviewConfiguration: Sendable {
let swipeMode: TravelAlbumPreviewSwipeMode
/// 创建预览配置,默认只按原图项目分页。
init(swipeMode: TravelAlbumPreviewSwipeMode = .projectsOnly) {
self.swipeMode = swipeMode
}
}
/// 预览图片类型,顺序同时决定 Tab 与关联图浏览顺序。
enum TravelAlbumPreviewAssetKind: Int, CaseIterable, Sendable, Hashable {
case original
case retouched
case atmosphere
case cover
var title: String {
switch self {
case .original: "原图"
case .retouched: "精修后"
case .atmosphere: "氛围感"
case .cover: "封面"
}
}
}
/// 预览页使用的单张图片信息,与后端关联图字段解耦。
struct TravelAlbumPreviewAsset: Identifiable, Sendable, Hashable {
let id: String
let kind: TravelAlbumPreviewAssetKind
let fileURL: String
let coverURL: String
let fileName: String
let fileSize: Int
/// 实际用于展示的地址,优先使用原图地址。
var displayURL: String {
let trimmedFileURL = fileURL.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedFileURL.isEmpty ? coverURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedFileURL
}
/// 横滑预览地址,优先使用体积更小的封面图以降低解码开销。
var previewURL: String {
let trimmedCoverURL = coverURL.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedCoverURL.isEmpty ? fileURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedCoverURL
}
}
/// 一张原图及其所有关联图片组成的预览项目。
struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
let originalMaterialId: Int
let aiRetouchBatchId: Int
let assets: [TravelAlbumPreviewAsset]
var id: Int { originalMaterialId }
/// 按产品约定顺序返回实际存在的图片。
var orderedAssets: [TravelAlbumPreviewAsset] {
TravelAlbumPreviewAssetKind.allCases.compactMap(asset(for:))
}
/// 是否存在原图之外的关联图片。
var hasVariants: Bool {
orderedAssets.contains { $0.kind != .original }
}
/// 返回指定类型的图片。
func asset(for kind: TravelAlbumPreviewAssetKind) -> TravelAlbumPreviewAsset? {
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 精修、氛围感结果图。
init(material: TravelAlbumMaterial) {
originalMaterialId = material.id
aiRetouchBatchId = material.aiRetouchBatchId
var mappedAssets = [
TravelAlbumPreviewAsset(
id: "original-\(material.id)",
kind: .original,
fileURL: material.fileUrl,
coverURL: material.coverUrl,
fileName: material.fileName,
fileSize: material.fileSize
),
]
let refinedURL = material.aiRefinedURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !refinedURL.isEmpty {
mappedAssets.append(
TravelAlbumPreviewAsset(
id: "retouched-\(material.id)",
kind: .retouched,
fileURL: refinedURL,
coverURL: refinedURL,
fileName: material.fileName,
fileSize: material.fileSize
)
)
}
let atmosphereURL = material.aiAtmosphereURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !atmosphereURL.isEmpty {
mappedAssets.append(
TravelAlbumPreviewAsset(
id: "atmosphere-\(material.id)",
kind: .atmosphere,
fileURL: atmosphereURL,
coverURL: atmosphereURL,
fileName: material.fileName,
fileSize: material.fileSize
)
)
}
assets = mappedAssets
}
/// 创建包含关联图片的项目,主要供适配器和测试使用。
init(originalMaterialId: Int, aiRetouchBatchId: Int = 0, assets: [TravelAlbumPreviewAsset]) {
self.originalMaterialId = originalMaterialId
self.aiRetouchBatchId = aiRetouchBatchId
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
}
/// 根据当前 Tab 生成首次修图或覆盖重修工作流。
func aiRetouchWorkflow(
albumId: Int,
selectedKind: TravelAlbumPreviewAssetKind
) -> TravelAlbumAIRetouchWorkflow? {
guard hasVariants else {
return .initial(albumId: albumId, materialIds: [originalMaterialId])
}
switch selectedKind {
case .original:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
case .retouched:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .refined)
case .atmosphere:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .atmosphere)
case .cover:
return nil
}
}
}
extension TravelAlbumAIRetouchTemplate {
/// 使用接口返回的模板示例前后图构建对比页内容。
var comparisonContent: BeforeAfterComparisonContent? {
let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
return content.isValid ? content : nil
}
}
/// 预览分页节点,记录当前图片所属项目及类型。
struct TravelAlbumPreviewNode: Sendable, Hashable {
let projectIndex: Int
let kind: TravelAlbumPreviewAssetKind
}
/// 预览页纯状态工具,负责构建节点和处理分组滑动边界。
enum TravelAlbumPreviewNavigator {
/// 根据滑动模式生成页面节点。
static func nodes(
projects: [TravelAlbumPreviewProject],
mode: TravelAlbumPreviewSwipeMode
) -> [TravelAlbumPreviewNode] {
projects.enumerated().flatMap { index, project in
switch mode {
case .projectsOnly:
[TravelAlbumPreviewNode(projectIndex: index, kind: .original)]
case .includeVariants:
project.orderedAssets.map { TravelAlbumPreviewNode(projectIndex: index, kind: $0.kind) }
}
}
}
/// 处理反向滑动边界:从项目原图向右滑时直接进入上一项目原图。
static func backwardTargetIndex(
nodes: [TravelAlbumPreviewNode],
currentIndex: Int
) -> Int {
guard nodes.indices.contains(currentIndex), currentIndex > 0 else { return max(0, currentIndex) }
let current = nodes[currentIndex]
guard current.kind == .original, current.projectIndex > 0 else { return currentIndex - 1 }
return nodes.firstIndex {
$0.projectIndex == current.projectIndex - 1 && $0.kind == .original
} ?? currentIndex - 1
}
/// 返回删除当前项目后应展示的项目索引;优先保持原索引以显示下一张,末项则回退上一张。
static func projectIndexAfterDeletion(
deletedProjectIndex: Int,
remainingProjectCount: Int
) -> Int? {
guard remainingProjectCount > 0 else { return nil }
return min(max(0, deletedProjectIndex), remainingProjectCount - 1)
}
}
/// 预览删除操作执行结果,统一表达成功、失败与暂不可用状态。
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
case success(String?)
case failure(String)
case unavailable(String)
}
/// 预览页删除操作协议,以原素材项目 ID 为目标。
protocol TravelAlbumPreviewActionHandling: AnyObject {
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
}
/// 预览删除接口接入前的占位操作实现,不修改任何业务数据。
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
.unavailable("项目删除接口待接入")
}
}
@@ -19,6 +19,8 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
var errorMessage: String?
let localPath: String
var remoteUrl: String
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
/// 是否未上传完成。
var isNotUploaded: Bool {
@@ -52,6 +54,12 @@ struct TravelAlbumOTGPhotoSection: Hashable, Sendable {
let photos: [TravelAlbumOTGPhotoItem]
}
/// 有线传输照片预览上下文,保存可预览照片顺序和点击照片位置。
struct TravelAlbumOTGPhotoPreviewContext: Equatable, Sendable {
let photos: [TravelAlbumOTGPhotoItem]
let initialIndex: Int
}
/// OTG 传输页 Tab。
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
case all
@@ -80,6 +88,16 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
}
}
/// 模式选择弹窗中的业务说明。
var detailText: String {
switch self {
case .liveUpload:
return "相机拍摄后,照片自动传输并上传到当前相册"
case .postTransfer:
return "拍摄完成后,再选择照片批量传输"
}
}
/// 是否在新照片导入 App 后自动上传。
var shouldAutoUploadNewImports: Bool {
self == .liveUpload
@@ -113,6 +131,18 @@ extension TravelAlbumOTGPhotoItem {
}
extension Array where Element == TravelAlbumOTGPhotoItem {
/// 按当前列表顺序生成预览上下文,不可预览照片不会占用左右滑动页码。
func previewContext(startingWith photoID: String) -> TravelAlbumOTGPhotoPreviewContext? {
let previewablePhotos = filter { $0.thumbnailURL != nil }
guard let initialIndex = previewablePhotos.firstIndex(where: { $0.id == photoID }) else {
return nil
}
return TravelAlbumOTGPhotoPreviewContext(
photos: previewablePhotos,
initialIndex: initialIndex
)
}
/// 按 Android OTG 页规则构造半小时照片分段。
func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] {
let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in
@@ -318,7 +348,8 @@ enum TravelAlbumOTGPhotoFormatMatcher {
extension TravelAlbumOTGPhotoRecord {
/// 转为页面展示项。
func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem {
let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in
let preferredRetouchedPath = autoRetouchState == .completed ? retouchedPath : ""
let previewPath = [preferredRetouchedPath, thumbnailPath, localPath, remoteUrl].first { path in
guard !path.isEmpty else { return false }
if path.hasPrefix("http") { return true }
return storage.fileExists(relativePath: path, albumId: albumId)
@@ -343,7 +374,9 @@ extension TravelAlbumOTGPhotoRecord {
progress: progress,
errorMessage: errorMessage,
localPath: localPath,
remoteUrl: remoteUrl
remoteUrl: remoteUrl,
autoRetouchState: autoRetouchState,
autoRetouchTemplateId: autoRetouchTemplateId
)
}
}
@@ -56,6 +56,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
let albumId: Int
let userId: String
var remoteUrl: String
var materialId: Int?
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
var retouchedPath: String
var updatedAt: Int64
/// 创建 OTG 本地照片记录。
@@ -74,6 +78,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId: Int,
userId: String,
remoteUrl: String = "",
materialId: Int? = nil,
autoRetouchState: TravelAlbumAutoRetouchState = .none,
autoRetouchTemplateId: String? = nil,
retouchedPath: String = "",
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
) {
self.id = id
@@ -90,12 +98,17 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
self.albumId = albumId
self.userId = userId
self.remoteUrl = remoteUrl
self.materialId = materialId
self.autoRetouchState = autoRetouchState
self.autoRetouchTemplateId = autoRetouchTemplateId
self.retouchedPath = retouchedPath
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, materialId
case autoRetouchState, autoRetouchTemplateId, retouchedPath, updatedAt
}
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
@@ -115,16 +128,30 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId = try container.decode(Int.self, forKey: .albumId)
userId = try container.decode(String.self, forKey: .userId)
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? ""
materialId = try container.decodeIfPresent(Int.self, forKey: .materialId)
autoRetouchState = try container.decodeIfPresent(
TravelAlbumAutoRetouchState.self,
forKey: .autoRetouchState
) ?? .none
autoRetouchTemplateId = try container.decodeIfPresent(String.self, forKey: .autoRetouchTemplateId)
retouchedPath = try container.decodeIfPresent(String.self, forKey: .retouchedPath) ?? ""
updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
}
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
guard status == .transferring || status == .uploading else { return self }
let interruptedUpload = status == .transferring || status == .uploading
let interruptedRetouch = autoRetouchState == .processing
guard interruptedUpload || interruptedRetouch else { return self }
var copy = self
copy.status = .pending
copy.progress = 0
if interruptedUpload {
copy.status = copy.materialId == nil ? .pending : .uploaded
copy.progress = copy.materialId == nil ? 0 : 100
copy.errorMessage = nil
}
if interruptedRetouch {
copy.autoRetouchState = copy.materialId == nil ? .none : .failed
}
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
return copy
}
@@ -242,7 +269,7 @@ final class TravelAlbumOTGPhotoStore {
?? TravelAlbumClientPhotoID.make()
return copy.normalizedAfterInterruptedTransfer()
}
if migrated.map(\.clientPhotoId) != scoped.map(\.clientPhotoId) {
if migrated != scoped {
save(migrated, albumId: albumId)
}
return migrated
@@ -333,6 +360,22 @@ final class TravelAlbumOTGPhotoStore {
return url
}
/// 写入自动 AI 修图结果,返回当前相册预览目录中的文件 URL。
func writeRetouchedImage(_ data: Data, filename: String, albumId: Int) throws -> URL {
let directory = try previewsDirectory(albumId: albumId)
let sanitized = PTPHelper.sanitizeFilename(filename)
let stem = (sanitized as NSString).deletingPathExtension
let preferredName = "\(stem)_retouched.jpg"
var candidate = directory.appendingPathComponent(preferredName)
var counter = 1
while fileManager.fileExists(atPath: candidate.path), counter < 10_000 {
candidate = directory.appendingPathComponent("\(stem)_retouched_\(counter).jpg")
counter += 1
}
try data.write(to: candidate, options: .atomic)
return candidate
}
/// 返回写入索引用的相对路径。
func relativePath(for url: URL, albumId: Int) -> String {
let path = url.standardizedFileURL.path
@@ -382,6 +425,9 @@ final class TravelAlbumOTGPhotoStore {
if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) {
resolved.thumbnailPath = thumbnailURL.path
}
if let retouchedURL = absoluteURL(for: record.retouchedPath, albumId: albumId) {
resolved.retouchedPath = retouchedURL.path
}
return resolved
}
@@ -433,7 +479,7 @@ final class TravelAlbumOTGPhotoStore {
}
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) {
[record.localPath, record.thumbnailPath].forEach { path in
[record.localPath, record.thumbnailPath, record.retouchedPath].forEach { path in
guard !path.isEmpty else { return }
guard let url = absoluteURL(for: path, albumId: record.albumId) else { return }
try? fileManager.removeItem(at: url)
@@ -463,6 +509,11 @@ final class TravelAlbumOTGPhotoStore {
? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId)
: record.thumbnailPath
}
if !record.retouchedPath.isEmpty {
normalized.retouchedPath = record.retouchedPath.hasPrefix("/")
? relativePath(for: URL(fileURLWithPath: record.retouchedPath), albumId: albumId)
: record.retouchedPath
}
return normalized
}
@@ -0,0 +1,320 @@
//
// TravelAlbumAIEditResultStore.swift
// suixinkan
//
import CoreImage
import UIKit
/// AI 修图结果的内存数据源,在相册管理与照片预览之间共享本地演示结果。
@MainActor
final class TravelAlbumAIEditResultStore {
/// 单张照片的修图记录。
struct Record {
var state: TravelAlbumAIEditResultState
var editedImage: UIImage?
var atmosphereImage: UIImage?
var coverImage: UIImage?
}
static let shared = TravelAlbumAIEditResultStore()
static let didChangeNotification = Notification.Name("TravelAlbumAIEditResultStore.didChange")
static let materialIDUserInfoKey = "materialID"
private var recordsByMaterialID: [Int: Record] = [:]
private init() {}
/// 获取指定照片的修图状态。
func state(for materialID: Int) -> TravelAlbumAIEditResultState {
recordsByMaterialID[materialID]?.state ?? TravelAlbumAIEditResultState()
}
/// 获取指定照片最新的修图结果。
func editedImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.editedImage
}
/// 获取指定照片最新的氛围感修图结果。
func atmosphereImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.atmosphereImage
}
/// 获取指定照片最新的封面模板结果。
func coverImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.coverImage
}
/// 将照片标记为修图中,已有结果继续保留到新结果完成。
func startProcessing(materialIDs: [Int]) {
materialIDs.forEach { materialID in
var record = recordsByMaterialID[materialID]
?? Record(
state: TravelAlbumAIEditResultState(),
editedImage: nil,
atmosphereImage: nil,
coverImage: nil
)
record.state.startProcessing()
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
}
/// 保存最新结果并覆盖同一照片之前的修图结果。
func complete(
materialID: Int,
presetID: String,
atmosphereOptionID: String? = nil,
coverTemplateID: String? = nil,
editedImage: UIImage,
atmosphereImage: UIImage? = nil,
coverImage: UIImage? = nil
) {
var record = recordsByMaterialID[materialID]
?? Record(
state: TravelAlbumAIEditResultState(),
editedImage: nil,
atmosphereImage: nil,
coverImage: nil
)
record.state.complete(
presetID: presetID,
atmosphereOptionID: atmosphereOptionID,
coverTemplateID: coverTemplateID
)
record.editedImage = editedImage
record.atmosphereImage = atmosphereImage
record.coverImage = coverImage
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
/// 使用已编码图片数据保存自动修图结果,供无 UIKit 依赖的 ViewModel 调用。
@discardableResult
func completeFromImageData(
materialID: Int,
presetID: String,
editedImageData: Data
) -> Bool {
guard let image = UIImage(data: editedImageData) else { return false }
complete(materialID: materialID, presetID: presetID, editedImage: image)
return true
}
/// 修图失败时恢复按钮可用状态,并保留之前成功的结果。
func failProcessing(materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
record.state.failProcessing()
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
/// 还原为原图时清除该照片的本地修图结果。
func restoreOriginal(materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
record.state.restoreOriginal()
record.editedImage = nil
record.atmosphereImage = nil
record.coverImage = nil
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
/// 切换指定照片当前展示的原图或修图后版本。
func selectDisplayMode(_ mode: TravelAlbumAIEditResultState.DisplayMode, materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
record.state.selectDisplayMode(mode)
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
/// 删除照片时同步清理其本地修图结果。
func remove(materialID: Int) {
recordsByMaterialID.removeValue(forKey: materialID)
notifyChange(materialID: materialID)
}
private func notifyChange(materialID: Int) {
NotificationCenter.default.post(
name: Self.didChangeNotification,
object: self,
userInfo: [Self.materialIDUserInfoKey: materialID]
)
}
}
/// 本地 AI 修图效果生成器;后续接入服务端时可替换为结果图片下载。
@MainActor
enum TravelAlbumAIEditImageProcessor {
private static let context = CIContext()
/// 根据预设生成演示用修图图片。
static func render(
effect: TravelAlbumEditPreset.Effect,
source: UIImage
) -> UIImage {
guard effect != .original, let ciImage = CIImage(image: source) else { return source }
let output: CIImage
switch effect {
case .original:
output = ciImage
case .portrait:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.86,
kCIInputBrightnessKey: 0.07,
kCIInputContrastKey: 1.08,
])
case .vintage:
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.45])
case .brocade:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 1.22,
kCIInputBrightnessKey: 0.04,
kCIInputContrastKey: 1.1,
])
case .distantMountain:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.72,
kCIInputBrightnessKey: 0.1,
kCIInputContrastKey: 0.9,
])
case .mist:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.7,
kCIInputBrightnessKey: 0.14,
kCIInputContrastKey: 0.82,
])
case .summer:
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.24])
case .rich:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 1.38,
kCIInputBrightnessKey: -0.02,
kCIInputContrastKey: 1.2,
])
}
guard let cgImage = context.createCGImage(output, from: output.extent) else { return source }
return UIImage(cgImage: cgImage, scale: source.scale, orientation: source.imageOrientation)
}
}
/// 本地封面模板效果生成器,让封面结果在缩略图和预览页中与普通照片明显区分。
@MainActor
enum TravelAlbumCoverTemplateImageProcessor {
/// 将照片渲染为指定封面排版;无原图时使用稳定的演示背景。
static func render(
template: TravelAlbumCoverTemplate,
source: UIImage?,
targetSize: CGSize = CGSize(width: 1200, height: 900)
) -> UIImage {
UIGraphicsImageRenderer(size: targetSize).image { context in
let bounds = CGRect(origin: .zero, size: targetSize)
UIColor(hex: 0xCAD8E8).setFill()
context.cgContext.fill(bounds)
if let source {
source.draw(in: aspectFillRect(imageSize: source.size, bounds: bounds))
}
let unit = min(targetSize.width / 232, targetSize.height / 208)
switch template.previewStyle {
case .travelMemoir:
UIColor.black.withAlphaComponent(0.24).setFill()
context.cgContext.fill(bounds)
UIColor.white.setStroke()
let oval = UIBezierPath(ovalIn: CGRect(
x: bounds.midX - 50 * unit,
y: 28 * unit,
width: 100 * unit,
height: 100 * unit
))
oval.lineWidth = 9 * unit
oval.stroke()
drawCaption(
"TRAVEL\nMEMOIR",
in: CGRect(x: 30 * unit, y: bounds.height - 62 * unit, width: bounds.width - 60 * unit, height: 46 * unit),
alignment: .center,
fontSize: 17 * unit
)
case .minimal:
UIColor.white.withAlphaComponent(0.9).setFill()
context.cgContext.fill(CGRect(x: bounds.width * 0.55, y: 0, width: bounds.width * 0.45, height: bounds.height))
drawCaption(
"LESS\nIS MORE",
in: CGRect(x: bounds.width * 0.62, y: bounds.midY - 27 * unit, width: bounds.width * 0.3, height: 54 * unit),
alignment: .left,
color: .black,
fontSize: 17 * unit
)
case .scenicStory:
UIColor.black.withAlphaComponent(0.2).setFill()
context.cgContext.fill(bounds)
UIColor.white.setStroke()
let inset = 20 * unit
let frame = UIBezierPath(rect: bounds.insetBy(dx: inset, dy: inset))
frame.lineWidth = 4 * unit
frame.stroke()
drawCaption(
"SCENIC STORY",
in: CGRect(x: 34 * unit, y: bounds.height - 58 * unit, width: bounds.width - 68 * unit, height: 24 * unit),
alignment: .center,
fontSize: 17 * unit
)
case .film:
let stripHeight = 24 * unit
UIColor.black.withAlphaComponent(0.72).setFill()
context.cgContext.fill(CGRect(x: 0, y: 0, width: bounds.width, height: stripHeight))
context.cgContext.fill(CGRect(x: 0, y: bounds.height - stripHeight, width: bounds.width, height: stripHeight))
UIColor.white.withAlphaComponent(0.9).setFill()
var x = 10 * unit
while x <= bounds.width - 16 * unit {
context.cgContext.fill(CGRect(x: x, y: 6 * unit, width: 14 * unit, height: 10 * unit))
context.cgContext.fill(CGRect(
x: x,
y: bounds.height - 16 * unit,
width: 14 * unit,
height: 10 * unit
))
x += 30 * unit
}
drawCaption(
"THE JOURNEY",
in: CGRect(x: 28 * unit, y: bounds.height - 60 * unit, width: bounds.width - 56 * unit, height: 24 * unit),
alignment: .center,
fontSize: 17 * unit
)
}
}
}
private static func aspectFillRect(imageSize: CGSize, bounds: CGRect) -> CGRect {
guard imageSize.width > 0, imageSize.height > 0 else { return bounds }
let scale = max(bounds.width / imageSize.width, bounds.height / imageSize.height)
let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
return CGRect(
x: bounds.midX - drawSize.width / 2,
y: bounds.midY - drawSize.height / 2,
width: drawSize.width,
height: drawSize.height
)
}
private static func drawCaption(
_ text: String,
in rect: CGRect,
alignment: NSTextAlignment,
color: UIColor = .white,
fontSize: CGFloat
) {
let paragraph = NSMutableParagraphStyle()
paragraph.alignment = alignment
text.draw(
in: rect,
withAttributes: [
.font: UIFont.systemFont(ofSize: fontSize, weight: .bold),
.foregroundColor: color,
.paragraphStyle: paragraph,
]
)
}
}
@@ -0,0 +1,59 @@
//
// TravelAlbumAutoRetouchProcessor.swift
// suixinkan
//
import Foundation
import UIKit
/// 自动 AI 修图处理接口,把本地原图与已选模板转换为可持久化的结果数据。
@MainActor
protocol TravelAlbumAutoRetouchProcessing {
/// 按指定模板处理一张本地照片。
func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data
}
/// 使用项目现有 Core Image 效果生成自动修图演示结果。
@MainActor
final class TravelAlbumAutoRetouchProcessor: TravelAlbumAutoRetouchProcessing {
private let processingDelayNanoseconds: UInt64
/// 创建本地修图处理器,默认保留短暂处理时间用于展示“修图中”状态。
init(processingDelayNanoseconds: UInt64 = 900_000_000) {
self.processingDelayNanoseconds = processingDelayNanoseconds
}
func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data {
let sourceData = try Data(contentsOf: sourceURL)
guard let sourceImage = UIImage(data: sourceData) else {
throw TravelAlbumAutoRetouchError.invalidSourceImage
}
if processingDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: processingDelayNanoseconds)
}
try Task.checkCancellation()
let editedImage = TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: sourceImage)
guard let resultData = editedImage.jpegData(compressionQuality: 0.92) else {
throw TravelAlbumAutoRetouchError.resultEncodingFailed
}
return resultData
}
}
/// 本地自动 AI 修图过程的可展示错误。
enum TravelAlbumAutoRetouchError: LocalizedError, Equatable {
case invalidSourceImage
case resultEncodingFailed
case invalidImageData
var errorDescription: String? {
switch self {
case .invalidSourceImage:
return "原图无法读取,修图失败"
case .resultEncodingFailed:
return "修图结果生成失败"
case .invalidImageData:
return "修图结果无法读取"
}
}
}
@@ -1,79 +0,0 @@
//
// 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
}
}
@@ -1,144 +0,0 @@
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?() }
}
@@ -1,266 +0,0 @@
//
// TravelAlbumAIRetouchTemplateViewModel.swift
// suixinkan
//
import Foundation
/// AI 修图模板选择状态,负责实时加载、按工作流单选、校验和任务提交。
final class TravelAlbumAIRetouchTemplateViewModel {
private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = []
private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = []
private(set) var coverTemplates: [TravelAlbumAIRetouchTemplate] = []
private(set) var selectedRefinedTemplateId: Int?
private(set) var selectedAtmosphereTemplateId: Int?
private(set) var selectedCoverTemplateId: Int?
private(set) var remainingQuota: Int?
private(set) var isLoading = false
private(set) var isSubmitting = false
private(set) var loadErrorMessage: String?
let scenicId: Int
let workflow: TravelAlbumAIRetouchWorkflow
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitted: ((TravelAlbumAIJobSubmission) -> Void)?
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
self.init(
scenicId: scenicId,
workflow: .initial(albumId: albumId, materialIds: materialIds)
)
}
/// 使用明确工作流创建模板选择状态。
init(scenicId: Int, workflow: TravelAlbumAIRetouchWorkflow) {
self.scenicId = scenicId
switch workflow {
case .initial(let albumId, let materialIds):
self.workflow = .initial(
albumId: albumId,
materialIds: Array(Set(materialIds)).sorted()
)
case .reretouch:
self.workflow = workflow
}
}
/// 当前工作流需要展示的模板分组。
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
workflow.visibleCategories
}
/// 当前工作流选择的原始照片数量。
var selectedPhotoCount: Int {
switch workflow {
case .initial(_, let materialIds): materialIds.count
case .reretouch: 1
}
}
/// 是否展示首次修图的氛围感与封面生成说明。
var shouldShowInitialTips: Bool {
if case .initial = workflow { return true }
return false
}
/// 首次修图页固定展示的氛围感与封面生成规则说明。
var initialTipsText: String {
"Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。"
}
/// 当前分组是否为选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
workflow.isOptional(category)
}
/// 当前选择预计消耗的修图次数;封面模板免费,不计入额度。
var requiredQuota: Int {
switch workflow {
case .initial(_, let materialIds):
let outputCount = 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1)
return materialIds.count * outputCount
case .reretouch(_, _, let type):
switch type {
case .refined, .atmosphere:
return 1
case .all:
return 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1)
}
}
}
/// 当前必选模板或业务参数缺失时用于底部提示的文案。
var validationMessage: String? {
guard !isLoading, loadErrorMessage == nil else { return nil }
guard workflow.isValid else {
if case .reretouch = workflow {
return "当前图片缺少修图批次,请刷新后重试"
}
return "请选择要修图的照片"
}
for category in visibleCategories where !isOptional(category) {
if templates(for: category).isEmpty || selectedTemplateId(for: category) == nil {
return unavailableMessage(for: category)
}
}
guard let remainingQuota else {
return "剩余修图次数获取失败,请刷新后重试"
}
if requiredQuota > remainingQuota {
return "剩余修图次数不足,需要\(requiredQuota)次,当前剩余\(remainingQuota)次"
}
return nil
}
/// 是否满足提交条件。
var canSubmit: Bool {
!isLoading
&& !isSubmitting
&& loadErrorMessage == nil
&& workflow.isValid
&& validationMessage == nil
}
/// 实时拉取当前景区模板并按工作流设置默认选择。
func loadTemplates(api: any TravelAlbumServing) async {
guard scenicId > 0 else {
loadErrorMessage = "请先选择景区"
notifyStateChange()
return
}
guard !isLoading else { return }
isLoading = true
loadErrorMessage = nil
remainingQuota = nil
notifyStateChange()
do {
let response = try await api.aiRetouchTemplates(scenicId: scenicId)
refinedTemplates = response.refinedTemplates
atmosphereTemplates = response.atmosphereTemplates
coverTemplates = response.coverTemplates
remainingQuota = response.remainingQuota
selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
? atmosphereTemplates.first?.id
: nil
selectedCoverTemplateId = visibleCategories.contains(.cover) ? coverTemplates.first?.id : nil
isLoading = false
notifyStateChange()
} catch is CancellationError {
isLoading = false
notifyStateChange()
} catch {
refinedTemplates = []
atmosphereTemplates = []
coverTemplates = []
selectedRefinedTemplateId = nil
selectedAtmosphereTemplateId = nil
selectedCoverTemplateId = nil
remainingQuota = nil
isLoading = false
loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
notifyStateChange()
}
}
/// 返回指定分组的模板。
func templates(for category: TravelAlbumAIRetouchTemplateCategory) -> [TravelAlbumAIRetouchTemplate] {
switch category {
case .refined: refinedTemplates
case .atmosphere: atmosphereTemplates
case .cover: coverTemplates
}
}
/// 返回指定分组当前选中的模板 ID。
func selectedTemplateId(for category: TravelAlbumAIRetouchTemplateCategory) -> Int? {
switch category {
case .refined: selectedRefinedTemplateId
case .atmosphere: selectedAtmosphereTemplateId
case .cover: selectedCoverTemplateId
}
}
/// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
guard visibleCategories.contains(category),
templates(for: category).contains(where: { $0.id == id })
else { return }
switch category {
case .refined:
selectedRefinedTemplateId = id
case .atmosphere:
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
case .cover:
selectedCoverTemplateId = id
}
notifyStateChange()
}
/// 按当前工作流提交首次修图或覆盖重修任务。
func submit(api: any TravelAlbumServing) async {
guard !isSubmitting else { return }
guard canSubmit else {
onShowMessage?(validationMessage ?? "当前无法提交AI修图")
return
}
isSubmitting = true
notifyStateChange()
defer {
isSubmitting = false
notifyStateChange()
}
do {
let submission: TravelAlbumAIJobSubmission
switch workflow {
case .initial(let albumId, let materialIds):
guard let refinedTemplateId = selectedRefinedTemplateId else {
onShowMessage?("请选择原图精修模板")
return
}
submission = try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: albumId,
materialIds: materialIds,
refinedTemplateId: refinedTemplateId,
atmosphereTemplateId: selectedAtmosphereTemplateId,
coverTemplateId: selectedCoverTemplateId
)
)
case .reretouch(let materialId, let batchId, let type):
submission = try await api.submitAIReretouch(
TravelAlbumAIReretouchRequest(
id: materialId,
aiRetouchBatchId: batchId,
type: type,
refinedTemplateId: type == .atmosphere ? nil : selectedRefinedTemplateId,
atmosphereTemplateId: type == .refined ? nil : selectedAtmosphereTemplateId
)
)
}
onSubmitted?(submission)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "AI修图任务提交失败" : error.localizedDescription)
}
}
private func unavailableMessage(for category: TravelAlbumAIRetouchTemplateCategory) -> String {
switch category {
case .refined: "暂无可用的原图精修模板"
case .atmosphere: "暂无可用的氛围感修图模板"
case .cover: "暂无可用的封面风格模板"
}
}
private func notifyStateChange() {
onStateChange?()
}
}

Some files were not shown because too many files have changed in this diff Show More