88 changed files with 9474 additions and 163 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

+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封面”和封面排版图。
- [ ] 最近一次任务失败时优先显示“修图失败”。
- [ ] 还原、覆盖和删除后缩略图状态同步更新。
+6 -6
View File
@@ -436,7 +436,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -460,7 +460,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -502,7 +502,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -526,7 +526,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -741,7 +741,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -761,7 +761,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "payment_method_alipay.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -0,0 +1,4 @@
<!-- 来源:阿里巴巴矢量图标库,支付宝支付,图标 ID 1486848 -->
<svg width="24" height="24" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path d="M1024.0512 701.0304V196.864A196.9664 196.9664 0 0 0 827.136 0H196.864A196.9664 196.9664 0 0 0 0 196.864v630.272A196.9152 196.9152 0 0 0 196.864 1024h630.272a197.12 197.12 0 0 0 193.8432-162.0992c-52.224-22.6304-278.528-120.32-396.4416-176.64-89.7024 108.6976-183.7056 173.9264-325.3248 173.9264s-236.1856-87.2448-224.8192-194.048c7.4752-70.0416 55.552-184.576 264.2944-164.9664 110.08 10.3424 160.4096 30.8736 250.1632 60.5184 23.1936-42.5984 42.496-89.4464 57.1392-139.264H248.064v-39.424h196.9152V311.1424H204.8V267.776h240.128V165.632s2.1504-15.9744 19.8144-15.9744h98.4576V267.776h256v43.4176h-256V381.952h208.8448a805.9904 805.9904 0 0 1-84.8384 212.6848c60.672 22.016 336.7936 106.3936 336.7936 106.3936zM283.5456 791.6032c-149.6576 0-173.312-94.464-165.376-133.9392 7.8336-39.3216 51.2-90.624 134.4-90.624 95.5904 0 181.248 24.4736 284.0576 74.5472-72.192 94.0032-160.9216 150.016-253.0816 150.016z" fill="#009FE8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 512 B

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

After

Width:  |  Height:  |  Size: 1.5 KiB

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

@@ -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?()
}
}
@@ -70,10 +70,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId) AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .scenicUser AppStore.shared.session.accountType = .scenicUser
AppStore.shared.session.accountDisplayName = firstNonEmpty( AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.scenicName, user.nickname, user.realName, user.username user.scenicName, user.nickname, user.realName
) )
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.username, user.realName) AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.realName)
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.nickname, user.username) AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.session.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId
AppStore.shared.session.currentScenicName = user.scenicName AppStore.shared.session.currentScenicName = user.scenicName
@@ -90,10 +90,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId) AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .storeUser AppStore.shared.session.accountType = .storeUser
AppStore.shared.session.accountDisplayName = firstNonEmpty( AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.storeName, user.scenicName, user.realName, user.userName, user.username user.storeName, user.scenicName, user.realName
) )
AppStore.shared.session.userName = firstNonEmpty(user.userName, user.username, user.realName) AppStore.shared.session.userName = user.realName
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.userName, user.username) AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.session.avatar = user.avatar AppStore.shared.session.avatar = user.avatar
AppStore.shared.session.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId
@@ -53,10 +53,11 @@ enum LoginValidationError: Equatable {
} }
} }
/// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。 /// 登录结果实体,区分正常登录、账号选择和注销冷静期恢复三种情况。
enum LoginResolution { enum LoginResolution {
case completed(V9AuthResponse, AccountSwitchAccount) case completed(V9AuthResponse, AccountSwitchAccount)
case needsAccountSelection(AccountSelectionPayload) case needsAccountSelection(AccountSelectionPayload)
case accountDeletionPending(V9AuthResponse, AccountDeletionRequest)
} }
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。 /// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
@@ -196,7 +197,6 @@ struct V9ScenicUser: Decodable, Equatable {
let userId: Int let userId: Int
let scenicUserId: Int let scenicUserId: Int
let ssUserId: Int let ssUserId: Int
let username: String
let realName: String let realName: String
let nickname: String let nickname: String
let phone: String let phone: String
@@ -212,7 +212,7 @@ struct V9ScenicUser: Decodable, Equatable {
} }
var displayName: String { var displayName: String {
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username scenicName.nonEmpty ?? nickname.nonEmpty ?? realName
} }
func toAccountSwitchAccount() -> AccountSwitchAccount { func toAccountSwitchAccount() -> AccountSwitchAccount {
@@ -238,8 +238,6 @@ struct V9ScenicUser: Decodable, Equatable {
case userId = "user_id" case userId = "user_id"
case scenicUserId = "scenic_user_id" case scenicUserId = "scenic_user_id"
case ssUserId = "ss_user_id" case ssUserId = "ss_user_id"
case username = "user_name"
case legacyUsername = "username"
case realName = "real_name" case realName = "real_name"
case nickname case nickname
case phone case phone
@@ -258,9 +256,6 @@ struct V9ScenicUser: Decodable, Equatable {
userId = try container.decodeLossyInt(forKey: .userId) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0 scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0 ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
let preferredUsername = try container.decodeLossyString(forKey: .username)
let legacyUsername = try container.decodeLossyString(forKey: .legacyUsername)
username = preferredUsername.nonEmpty ?? legacyUsername
realName = try container.decodeLossyString(forKey: .realName) realName = try container.decodeLossyString(forKey: .realName)
nickname = try container.decodeLossyString(forKey: .nickname) nickname = try container.decodeLossyString(forKey: .nickname)
phone = try container.decodeLossyString(forKey: .phone) phone = try container.decodeLossyString(forKey: .phone)
@@ -281,8 +276,6 @@ struct V9StoreUser: Decodable, Equatable {
let id: Int let id: Int
let userId: Int let userId: Int
let storeUserId: Int let storeUserId: Int
let username: String
let userName: String
let realName: String let realName: String
let phone: String let phone: String
let avatar: String let avatar: String
@@ -300,14 +293,14 @@ struct V9StoreUser: Decodable, Equatable {
} }
var displayName: String { var displayName: String {
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username storeName.nonEmpty ?? scenicName.nonEmpty ?? realName
} }
func toAccountSwitchAccount() -> AccountSwitchAccount { func toAccountSwitchAccount() -> AccountSwitchAccount {
AccountSwitchAccount( AccountSwitchAccount(
accountType: accountType.nonEmpty ?? Self.accountTypeValue, accountType: accountType.nonEmpty ?? Self.accountTypeValue,
businessUserId: businessUserId, businessUserId: businessUserId,
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号", title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? "门店账号",
subtitle: joinAccountSubtitle(scenicName, roleName), subtitle: joinAccountSubtitle(scenicName, roleName),
phone: phone, phone: phone,
realName: realName, realName: realName,
@@ -325,8 +318,6 @@ struct V9StoreUser: Decodable, Equatable {
case id case id
case userId = "user_id" case userId = "user_id"
case storeUserId = "store_user_id" case storeUserId = "store_user_id"
case username
case userName = "user_name"
case realName = "real_name" case realName = "real_name"
case phone case phone
case avatar case avatar
@@ -346,8 +337,6 @@ struct V9StoreUser: Decodable, Equatable {
id = try container.decodeLossyInt(forKey: .id) ?? 0 id = try container.decodeLossyInt(forKey: .id) ?? 0
userId = try container.decodeLossyInt(forKey: .userId) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0 storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0
username = try container.decodeLossyString(forKey: .username)
userName = try container.decodeLossyString(forKey: .userName)
realName = try container.decodeLossyString(forKey: .realName) realName = try container.decodeLossyString(forKey: .realName)
phone = try container.decodeLossyString(forKey: .phone) phone = try container.decodeLossyString(forKey: .phone)
avatar = try container.decodeLossyString(forKey: .avatar) avatar = try container.decodeLossyString(forKey: .avatar)
@@ -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()
}
}
@@ -69,9 +69,9 @@ enum PayPageBrandingPolicy {
/// 收款页当前账号信息的展示文本格式化规则。 /// 收款页当前账号信息的展示文本格式化规则。
enum PaymentAccountDisplayFormatter { enum PaymentAccountDisplayFormatter {
/// 解析当前登录摄影师名称,昵称为空时使用实名,仍为空时显示占位符。 /// 解析当前登录摄影师的真实姓名,空值显示占位符。
static func photographerName(userName: String, realName: String) -> String { static func photographerName(realName: String) -> String {
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-" realName.trimmedNonEmpty ?? "-"
} }
/// 去除空值与重复项后,合并当前账号的全部店铺名称。 /// 去除空值与重复项后,合并当前账号的全部店铺名称。
@@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewModel {
private var staticPayURL = "" private var staticPayURL = ""
private var dynamicPayURL = "" private var dynamicPayURL = ""
private var hasCompletedPayCodeRequest = false
private let appStore: AppStore private let appStore: AppStore
private let payPageConfigCache: PayPageConfigCaching private let payPageConfigCache: PayPageConfigCaching
@@ -50,6 +51,11 @@ final class PaymentCollectionDetailsViewModel {
qrImage != nil && !staticPayURL.isEmpty qrImage != nil && !staticPayURL.isEmpty
} }
/// 是否应在二维码区域展示加载状态,首次请求开始前也视为加载中,避免短暂闪现错误占位。
var isPayCodeLoading: Bool {
loading || !hasCompletedPayCodeRequest
}
var displayScenicName: String { var displayScenicName: String {
scenicName.isEmpty ? "暂无景区" : scenicName scenicName.isEmpty ? "暂无景区" : scenicName
} }
@@ -60,7 +66,6 @@ final class PaymentCollectionDetailsViewModel {
var displayPhotographerName: String { var displayPhotographerName: String {
PaymentAccountDisplayFormatter.photographerName( PaymentAccountDisplayFormatter.photographerName(
userName: appStore.session.userName,
realName: appStore.session.realName realName: appStore.session.realName
) )
} }
@@ -85,11 +90,11 @@ final class PaymentCollectionDetailsViewModel {
func loadPayCode(api: PaymentAPI) async { func loadPayCode(api: PaymentAPI) async {
guard !loading else { return } guard !loading else { return }
refreshLocalState()
loading = true loading = true
notifyStateChange() refreshLocalState()
defer { defer {
loading = false loading = false
hasCompletedPayCodeRequest = true
notifyStateChange() notifyStateChange()
} }
@@ -17,11 +17,11 @@ final class ProfileViewModel {
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var displayNickname: String { var displayNickname: String {
nonEmpty(userInfo?.nickname) ?? "未设置昵称" nonEmpty(userInfo?.nickname) ?? AppStore.shared.session.userName.nonEmpty ?? "未设置昵称"
} }
var displayRealName: String { var displayRealName: String {
nonEmpty(userInfo?.realName) ?? "--" nonEmpty(userInfo?.realName) ?? AppStore.shared.session.realName.nonEmpty ?? "--"
} }
var displayPhone: String { var displayPhone: String {
@@ -37,6 +37,17 @@ final class ProfileViewModel {
return uid.isEmpty ? "--" : uid return uid.isEmpty ? "--" : uid
} }
/// 本地是否已有可供冷启动首屏展示的基本资料。
var hasCachedBasicInfo: Bool {
[
AppStore.shared.session.userName,
AppStore.shared.session.realName,
AppStore.shared.session.phone,
AppStore.shared.session.avatar,
AppStore.shared.session.userId,
].contains { nonEmpty($0) != nil }
}
var accountDisplayName: String { var accountDisplayName: String {
let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name return name.isEmpty ? "--" : name
@@ -112,6 +123,7 @@ final class ProfileViewModel {
if !info.roleName.isEmpty { if !info.roleName.isEmpty {
AppStore.shared.session.roleName = info.roleName AppStore.shared.session.roleName = info.roleName
} }
notifyStateChange()
if showPhotographerFields { if showPhotographerFields {
async let realName = api.realNameInfo() async let realName = api.realNameInfo()
@@ -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`。 /// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable { struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
private static let bundledImageURLPrefix = "asset://"
let id: Int let id: Int
let userEquityTravelId: Int let userEquityTravelId: Int
let status: Int let status: Int
@@ -175,6 +177,18 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
/// 将工程内图片资源转换为素材可识别的本地地址,仅供无接口数据的演示素材使用。
static func bundledImageURL(assetName: String) -> String {
bundledImageURLPrefix + assetName
}
/// 返回本地演示素材对应的图片资源名;真实网络素材返回 `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
}
} }
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。 /// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
@@ -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)
}
}
@@ -19,6 +19,8 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
var errorMessage: String? var errorMessage: String?
let localPath: String let localPath: String
var remoteUrl: String var remoteUrl: String
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
/// 是否未上传完成。 /// 是否未上传完成。
var isNotUploaded: Bool { var isNotUploaded: Bool {
@@ -52,6 +54,12 @@ struct TravelAlbumOTGPhotoSection: Hashable, Sendable {
let photos: [TravelAlbumOTGPhotoItem] let photos: [TravelAlbumOTGPhotoItem]
} }
/// 有线传输照片预览上下文,保存可预览照片顺序和点击照片位置。
struct TravelAlbumOTGPhotoPreviewContext: Equatable, Sendable {
let photos: [TravelAlbumOTGPhotoItem]
let initialIndex: Int
}
/// OTG 传输页 Tab。 /// OTG 传输页 Tab。
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable { enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
case all case all
@@ -80,6 +88,16 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
} }
} }
/// 模式选择弹窗中的业务说明。
var detailText: String {
switch self {
case .liveUpload:
return "相机拍摄后,照片自动传输并上传到当前相册"
case .postTransfer:
return "拍摄完成后,再选择照片批量传输"
}
}
/// 是否在新照片导入 App 后自动上传。 /// 是否在新照片导入 App 后自动上传。
var shouldAutoUploadNewImports: Bool { var shouldAutoUploadNewImports: Bool {
self == .liveUpload self == .liveUpload
@@ -113,6 +131,18 @@ extension TravelAlbumOTGPhotoItem {
} }
extension Array where Element == 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 页规则构造半小时照片分段。 /// 按 Android OTG 页规则构造半小时照片分段。
func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] { func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] {
let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in
@@ -318,7 +348,8 @@ enum TravelAlbumOTGPhotoFormatMatcher {
extension TravelAlbumOTGPhotoRecord { extension TravelAlbumOTGPhotoRecord {
/// 转为页面展示项。 /// 转为页面展示项。
func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem { func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem {
let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in let preferredRetouchedPath = autoRetouchState == .completed ? retouchedPath : ""
let previewPath = [preferredRetouchedPath, thumbnailPath, localPath, remoteUrl].first { path in
guard !path.isEmpty else { return false } guard !path.isEmpty else { return false }
if path.hasPrefix("http") { return true } if path.hasPrefix("http") { return true }
return storage.fileExists(relativePath: path, albumId: albumId) return storage.fileExists(relativePath: path, albumId: albumId)
@@ -343,7 +374,9 @@ extension TravelAlbumOTGPhotoRecord {
progress: progress, progress: progress,
errorMessage: errorMessage, errorMessage: errorMessage,
localPath: localPath, localPath: localPath,
remoteUrl: remoteUrl remoteUrl: remoteUrl,
autoRetouchState: autoRetouchState,
autoRetouchTemplateId: autoRetouchTemplateId
) )
} }
} }
@@ -56,6 +56,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
let albumId: Int let albumId: Int
let userId: String let userId: String
var remoteUrl: String var remoteUrl: String
var materialId: Int?
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
var retouchedPath: String
var updatedAt: Int64 var updatedAt: Int64
/// 创建 OTG 本地照片记录。 /// 创建 OTG 本地照片记录。
@@ -74,6 +78,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId: Int, albumId: Int,
userId: String, userId: String,
remoteUrl: String = "", remoteUrl: String = "",
materialId: Int? = nil,
autoRetouchState: TravelAlbumAutoRetouchState = .none,
autoRetouchTemplateId: String? = nil,
retouchedPath: String = "",
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000) updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
) { ) {
self.id = id self.id = id
@@ -90,12 +98,17 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
self.albumId = albumId self.albumId = albumId
self.userId = userId self.userId = userId
self.remoteUrl = remoteUrl self.remoteUrl = remoteUrl
self.materialId = materialId
self.autoRetouchState = autoRetouchState
self.autoRetouchTemplateId = autoRetouchTemplateId
self.retouchedPath = retouchedPath
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, materialId
case autoRetouchState, autoRetouchTemplateId, retouchedPath, updatedAt
} }
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。 /// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
@@ -115,16 +128,30 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId = try container.decode(Int.self, forKey: .albumId) albumId = try container.decode(Int.self, forKey: .albumId)
userId = try container.decode(String.self, forKey: .userId) userId = try container.decode(String.self, forKey: .userId)
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? "" remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? ""
materialId = try container.decodeIfPresent(Int.self, forKey: .materialId)
autoRetouchState = try container.decodeIfPresent(
TravelAlbumAutoRetouchState.self,
forKey: .autoRetouchState
) ?? .none
autoRetouchTemplateId = try container.decodeIfPresent(String.self, forKey: .autoRetouchTemplateId)
retouchedPath = try container.decodeIfPresent(String.self, forKey: .retouchedPath) ?? ""
updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0 updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
} }
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。 /// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord { func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
guard status == .transferring || status == .uploading else { return self } let interruptedUpload = status == .transferring || status == .uploading
let interruptedRetouch = autoRetouchState == .processing
guard interruptedUpload || interruptedRetouch else { return self }
var copy = self var copy = self
copy.status = .pending if interruptedUpload {
copy.progress = 0 copy.status = copy.materialId == nil ? .pending : .uploaded
copy.errorMessage = nil 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) copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
return copy return copy
} }
@@ -242,7 +269,7 @@ final class TravelAlbumOTGPhotoStore {
?? TravelAlbumClientPhotoID.make() ?? TravelAlbumClientPhotoID.make()
return copy.normalizedAfterInterruptedTransfer() return copy.normalizedAfterInterruptedTransfer()
} }
if migrated.map(\.clientPhotoId) != scoped.map(\.clientPhotoId) { if migrated != scoped {
save(migrated, albumId: albumId) save(migrated, albumId: albumId)
} }
return migrated return migrated
@@ -333,6 +360,22 @@ final class TravelAlbumOTGPhotoStore {
return url return url
} }
/// 写入自动 AI 修图结果,返回当前相册预览目录中的文件 URL。
func writeRetouchedImage(_ data: Data, filename: String, albumId: Int) throws -> URL {
let directory = try previewsDirectory(albumId: albumId)
let sanitized = PTPHelper.sanitizeFilename(filename)
let stem = (sanitized as NSString).deletingPathExtension
let preferredName = "\(stem)_retouched.jpg"
var candidate = directory.appendingPathComponent(preferredName)
var counter = 1
while fileManager.fileExists(atPath: candidate.path), counter < 10_000 {
candidate = directory.appendingPathComponent("\(stem)_retouched_\(counter).jpg")
counter += 1
}
try data.write(to: candidate, options: .atomic)
return candidate
}
/// 返回写入索引用的相对路径。 /// 返回写入索引用的相对路径。
func relativePath(for url: URL, albumId: Int) -> String { func relativePath(for url: URL, albumId: Int) -> String {
let path = url.standardizedFileURL.path let path = url.standardizedFileURL.path
@@ -382,6 +425,9 @@ final class TravelAlbumOTGPhotoStore {
if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) { if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) {
resolved.thumbnailPath = thumbnailURL.path resolved.thumbnailPath = thumbnailURL.path
} }
if let retouchedURL = absoluteURL(for: record.retouchedPath, albumId: albumId) {
resolved.retouchedPath = retouchedURL.path
}
return resolved return resolved
} }
@@ -433,7 +479,7 @@ final class TravelAlbumOTGPhotoStore {
} }
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) { private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) {
[record.localPath, record.thumbnailPath].forEach { path in [record.localPath, record.thumbnailPath, record.retouchedPath].forEach { path in
guard !path.isEmpty else { return } guard !path.isEmpty else { return }
guard let url = absoluteURL(for: path, albumId: record.albumId) else { return } guard let url = absoluteURL(for: path, albumId: record.albumId) else { return }
try? fileManager.removeItem(at: url) try? fileManager.removeItem(at: url)
@@ -463,6 +509,11 @@ final class TravelAlbumOTGPhotoStore {
? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId) ? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId)
: record.thumbnailPath : record.thumbnailPath
} }
if !record.retouchedPath.isEmpty {
normalized.retouchedPath = record.retouchedPath.hasPrefix("/")
? relativePath(for: URL(fileURLWithPath: record.retouchedPath), albumId: albumId)
: record.retouchedPath
}
return normalized return normalized
} }
@@ -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 "修图结果无法读取"
}
}
}
@@ -36,6 +36,7 @@ final class TravelAlbumDetailViewModel {
private(set) var sortOption: SortOption = .createdDesc private(set) var sortOption: SortOption = .createdDesc
private(set) var isLoading = true private(set) var isLoading = true
private(set) var isRefreshing = false private(set) var isRefreshing = false
private(set) var isUsingPurchasedDemoData = false
private(set) var isSelectionMode = false private(set) var isSelectionMode = false
private(set) var selectedMaterialIds: Set<Int> = [] private(set) var selectedMaterialIds: Set<Int> = []
@@ -126,6 +127,7 @@ final class TravelAlbumDetailViewModel {
if reset { if reset {
currentPage = 1 currentPage = 1
canLoadMore = false canLoadMore = false
isUsingPurchasedDemoData = false
} else { } else {
guard canLoadMore, !isLoadingMore else { return } guard canLoadMore, !isLoadingMore else { return }
isLoadingMore = true isLoadingMore = true
@@ -140,8 +142,15 @@ final class TravelAlbumDetailViewModel {
orderBy: sortOption.rawValue, orderBy: sortOption.rawValue,
isPurchased: selectedTab == .purchased ? 1 : nil isPurchased: selectedTab == .purchased ? 1 : nil
) )
materials = reset ? response.list : materials + response.list if reset,
canLoadMore = materials.count < response.total selectedTab == .purchased,
response.total == 0,
response.list.isEmpty {
applyPurchasedDemoMaterials()
} else {
materials = reset ? response.list : materials + response.list
canLoadMore = materials.count < response.total
}
if selectedTab == .all { if selectedTab == .all {
allPhotoCount = response.total allPhotoCount = response.total
} }
@@ -154,6 +163,12 @@ final class TravelAlbumDetailViewModel {
currentPage -= 1 currentPage -= 1
} }
isLoadingMore = false isLoadingMore = false
if reset, selectedTab == .purchased {
// 已购接口暂不可用时继续提供稳定的本地演示数据,保证产品流程可演示。
applyPurchasedDemoMaterials()
notifyStateChange()
return
}
onShowMessage?(error.localizedDescription) onShowMessage?(error.localizedDescription)
} }
} }
@@ -225,6 +240,44 @@ final class TravelAlbumDetailViewModel {
} }
} }
/// 已购照片接口无数据时生成稳定的本地素材;真实接口一旦返回数据即不再使用。
private func applyPurchasedDemoMaterials() {
let demoItems: [(fileName: String, fileSize: Int, assetName: String, createdAt: String)] = [
("IMG_0724_0001.jpeg", 2_684_928, "purchased_alpine_lake", "2026-07-24 11:25:18"),
("IMG_0724_0002.jpeg", 3_215_360, "purchased_forest_waterfall", "2026-07-24 11:27:46"),
("IMG_0724_0003.jpeg", 2_936_832, "purchased_grassland_path", "2026-07-24 11:31:09"),
("IMG_0724_0004.jpeg", 3_481_600, "purchased_lakeside_flowers", "2026-07-24 11:34:22"),
]
var materials = demoItems.enumerated().map { index, item in
TravelAlbumMaterial(
id: -((albumId * 10) + index + 1),
userEquityTravelId: albumId,
status: 2,
fileName: item.fileName,
fileType: 1,
fileUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
fileSize: item.fileSize,
coverUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
isPurchased: true,
createdAt: item.createdAt,
updatedAt: item.createdAt
)
}
switch sortOption {
case .createdAsc:
materials.sort { $0.createdAt < $1.createdAt }
case .createdDesc:
materials.sort { $0.createdAt > $1.createdAt }
case .fileNameAsc:
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedAscending }
case .fileNameDesc:
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedDescending }
}
self.materials = materials
canLoadMore = false
isUsingPurchasedDemoData = true
}
private func notifyStateChange() { private func notifyStateChange() {
onStateChange?() onStateChange?()
} }
@@ -42,13 +42,16 @@ final class TravelAlbumEntryViewModel {
private let currentScenicIdProvider: () -> Int private let currentScenicIdProvider: () -> Int
private let dateProvider: () -> Date private let dateProvider: () -> Date
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
init( init(
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }, currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
dateProvider: @escaping () -> Date = Date.init dateProvider: @escaping () -> Date = Date.init,
autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring = TravelAlbumAutoRetouchConfigurationStore.shared
) { ) {
self.currentScenicIdProvider = currentScenicIdProvider self.currentScenicIdProvider = currentScenicIdProvider
self.dateProvider = dateProvider self.dateProvider = dateProvider
self.autoRetouchConfigurationStore = autoRetouchConfigurationStore
} }
/// 重新拉取相册列表。 /// 重新拉取相册列表。
@@ -110,6 +113,7 @@ final class TravelAlbumEntryViewModel {
freeCount: String, freeCount: String,
singlePrice: String, singlePrice: String,
packagePrice: String, packagePrice: String,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
order: TravelAlbumAvailableOrder?, order: TravelAlbumAvailableOrder?,
api: any TravelAlbumServing api: any TravelAlbumServing
) async { ) async {
@@ -132,6 +136,10 @@ final class TravelAlbumEntryViewModel {
onShowMessage?("请输入有效的单张照片价格") onShowMessage?("请输入有效的单张照片价格")
return return
} }
if !autoRetouchConfiguration.isValid {
onShowMessage?("请选择修图模板")
return
}
let albumName: String let albumName: String
switch mode { switch mode {
@@ -174,6 +182,7 @@ final class TravelAlbumEntryViewModel {
do { do {
let response = try await api.create(request) let response = try await api.create(request)
autoRetouchConfigurationStore.save(autoRetouchConfiguration, albumID: response.id)
isCreateSheetVisible = false isCreateSheetVisible = false
onShowMessage?("任务创建成功") onShowMessage?("任务创建成功")
onCreatedAlbum?(response) onCreatedAlbum?(response)
@@ -63,7 +63,7 @@ final class WiredCameraTransferViewModel {
private(set) var sonyMTPHint: String? private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false private(set) var isContentCatalogReady = false
let retouchOption = "不修图" private(set) var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload { private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
didSet { notifyStateChanged() } didSet { notifyStateChanged() }
@@ -80,6 +80,9 @@ final class WiredCameraTransferViewModel {
private let api: any TravelAlbumServing private let api: any TravelAlbumServing
private let appStore: AppStore private let appStore: AppStore
private let userDefaults: UserDefaults private let userDefaults: UserDefaults
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
private let autoRetouchProcessor: any TravelAlbumAutoRetouchProcessing
private let aiEditResultStore: TravelAlbumAIEditResultStore
private var currentDriver: CameraDriver? private var currentDriver: CameraDriver?
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:] private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
@@ -94,12 +97,16 @@ final class WiredCameraTransferViewModel {
albumTitle: String, albumTitle: String,
headerPhone: String, headerPhone: String,
scenicSpotLabel: String? = nil, scenicSpotLabel: String? = nil,
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
connectionManager: (any WiredCameraConnectionManaging)? = nil, connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil, uploader: (any TravelAlbumOTGUploading)? = nil,
api: (any TravelAlbumServing)? = nil, api: (any TravelAlbumServing)? = nil,
appStore: AppStore = .shared, appStore: AppStore = .shared,
userDefaults: UserDefaults = .standard userDefaults: UserDefaults = .standard,
autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring = TravelAlbumAutoRetouchConfigurationStore.shared,
autoRetouchProcessor: (any TravelAlbumAutoRetouchProcessing)? = nil,
aiEditResultStore: TravelAlbumAIEditResultStore? = nil
) { ) {
self.albumId = albumId self.albumId = albumId
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
@@ -112,7 +119,15 @@ final class WiredCameraTransferViewModel {
self.api = api ?? NetworkServices.shared.travelAlbumAPI self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore self.appStore = appStore
self.userDefaults = userDefaults self.userDefaults = userDefaults
self.transferMode = Self.persistedTransferMode(in: userDefaults) self.autoRetouchConfigurationStore = autoRetouchConfigurationStore
self.autoRetouchProcessor = autoRetouchProcessor ?? TravelAlbumAutoRetouchProcessor()
self.aiEditResultStore = aiEditResultStore ?? .shared
self.autoRetouchConfiguration = autoRetouchConfigurationStore.configuration(albumID: albumId)
// 从相册管理选择模式进入时,以本次选择为准;其他旧入口继续沿用上次记录。
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
if let initialTransferMode {
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
}
} }
/// 相机状态文案。 /// 相机状态文案。
@@ -213,6 +228,11 @@ final class WiredCameraTransferViewModel {
transferMode.title transferMode.title
} }
/// 自动 AI 修图设置项展示文案。
var retouchOption: String {
autoRetouchConfiguration.uploadOptionTitle
}
/// 指定上传弹窗选项。 /// 指定上传弹窗选项。
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] { var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
TravelAlbumOTGSpecifyUploadOption.allCases TravelAlbumOTGSpecifyUploadOption.allCases
@@ -301,6 +321,15 @@ final class WiredCameraTransferViewModel {
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey) userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
} }
/// 更新当前相册的自动 AI 修图配置,只影响尚未开始的上传任务。
func updateAutoRetouchConfiguration(_ configuration: TravelAlbumAutoRetouchConfiguration) {
let normalized = configuration.isValid ? configuration : .disabled
guard normalized != autoRetouchConfiguration else { return }
autoRetouchConfiguration = normalized
autoRetouchConfigurationStore.save(normalized, albumID: albumId)
notifyStateChanged()
}
/// 切换上传格式。 /// 切换上传格式。
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) { func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
guard photoFormatOption != option else { return } guard photoFormatOption != option else { return }
@@ -462,8 +491,31 @@ final class WiredCameraTransferViewModel {
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传") startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
} }
/// 使用该照片上次失败时的模板重试自动修图,不重复上传原图。
func retryAutoRetouch(photoId: String) {
guard let record = persistedRecordsById[photoId],
record.autoRetouchState == .failed,
let materialId = record.materialId,
let templateID = record.autoRetouchTemplateId,
let preset = TravelAlbumEditPreset.autoRetouchOptions.first(where: { $0.id == templateID }) else {
showMessage("暂无可重试的修图任务")
return
}
Task {
await processAutoRetouch(
photoId: photoId,
sourceRecord: record,
materialId: materialId,
preset: preset
)
}
}
/// 删除单张本地照片。 /// 删除单张本地照片。
func deletePhoto(photoId: String) { func deletePhoto(photoId: String) {
if let materialId = persistedRecordsById[photoId]?.materialId {
aiEditResultStore.remove(materialID: materialId)
}
storage.remove(albumId: albumId, photoId: photoId) storage.remove(albumId: albumId, photoId: photoId)
persistedRecordsById.removeValue(forKey: photoId) persistedRecordsById.removeValue(forKey: photoId)
selectedPhotoIds.remove(photoId) selectedPhotoIds.remove(photoId)
@@ -473,6 +525,9 @@ final class WiredCameraTransferViewModel {
/// 清空当前相册本地 OTG 缓存。 /// 清空当前相册本地 OTG 缓存。
func clearLocalAlbumCache() { func clearLocalAlbumCache() {
persistedRecordsById.values.compactMap(\.materialId).forEach { materialId in
aiEditResultStore.remove(materialID: materialId)
}
storage.clearAlbum(albumId: albumId) storage.clearAlbum(albumId: albumId)
persistedRecordsById = [:] persistedRecordsById = [:]
photos = [] photos = []
@@ -487,9 +542,27 @@ final class WiredCameraTransferViewModel {
private func loadPersistedPhotos() { private func loadPersistedPhotos() {
let records = storage.load(albumId: albumId) let records = storage.load(albumId: albumId)
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
restorePersistedAutoRetouchResults(records)
applyMergedPhotos() applyMergedPhotos()
} }
private func restorePersistedAutoRetouchResults(_ records: [TravelAlbumOTGPhotoRecord]) {
records.forEach { record in
guard record.autoRetouchState == .completed,
let materialId = record.materialId,
let templateID = record.autoRetouchTemplateId,
let resultURL = storage.absoluteURL(for: record.retouchedPath, albumId: albumId),
let resultData = try? Data(contentsOf: resultURL) else {
return
}
_ = aiEditResultStore.completeFromImageData(
materialID: materialId,
presetID: templateID,
editedImageData: resultData
)
}
}
private func syncServerUploadStatuses() { private func syncServerUploadStatuses() {
serverStatusSyncTask?.cancel() serverStatusSyncTask?.cancel()
guard albumId > 0 else { return } guard albumId > 0 else { return }
@@ -588,6 +661,8 @@ final class WiredCameraTransferViewModel {
} }
private func uploadPhoto(id: String) async { private func uploadPhoto(id: String) async {
// 每张照片开始上传时固定一次配置,避免修图过程中切换模板导致结果不一致。
let retouchConfiguration = autoRetouchConfiguration
do { do {
let record = try await localRecordForUpload(id: id) let record = try await localRecordForUpload(id: id)
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil) updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
@@ -597,13 +672,89 @@ final class WiredCameraTransferViewModel {
) { [weak self] progress in ) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
} }
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl) // 上传期间用户可能删除照片,删除后不再回写本地任务或发起修图。
guard persistedRecordsById[id] != nil else { return }
updateRecord(
id: id,
status: retouchConfiguration.template == nil ? .uploaded : .uploading,
progress: 100,
error: nil,
remoteUrl: material.fileUrl,
materialId: material.id
)
guard let preset = retouchConfiguration.template else { return }
await processAutoRetouch(
photoId: id,
sourceRecord: record,
materialId: material.id,
preset: preset
)
} catch { } catch {
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription) updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
showMessage(error.localizedDescription) showMessage(error.localizedDescription)
} }
} }
/// 对已完成原图上传的照片执行本地演示修图,修图失败不回退上传成功状态。
private func processAutoRetouch(
photoId: String,
sourceRecord: TravelAlbumOTGPhotoRecord,
materialId: Int,
preset: TravelAlbumEditPreset
) async {
let resolvedRecord = storage.recordWithResolvedFilePaths(sourceRecord, albumId: albumId)
let sourceURL = URL(fileURLWithPath: resolvedRecord.localPath)
updateAutoRetouchRecord(
id: photoId,
state: .processing,
templateId: preset.id,
materialId: materialId,
status: .uploading,
error: nil
)
aiEditResultStore.startProcessing(materialIDs: [materialId])
do {
let resultData = try await autoRetouchProcessor.process(sourceURL: sourceURL, preset: preset)
guard persistedRecordsById[photoId] != nil else {
aiEditResultStore.remove(materialID: materialId)
return
}
let resultURL = try storage.writeRetouchedImage(
resultData,
filename: sourceRecord.fileName,
albumId: albumId
)
guard aiEditResultStore.completeFromImageData(
materialID: materialId,
presetID: preset.id,
editedImageData: resultData
) else {
throw TravelAlbumAutoRetouchError.invalidImageData
}
updateAutoRetouchRecord(
id: photoId,
state: .completed,
templateId: preset.id,
materialId: materialId,
status: .uploaded,
error: nil,
retouchedPath: storage.relativePath(for: resultURL, albumId: albumId)
)
} catch {
aiEditResultStore.failProcessing(materialID: materialId)
updateAutoRetouchRecord(
id: photoId,
state: .failed,
templateId: preset.id,
materialId: materialId,
status: .uploaded,
error: error.localizedDescription
)
showMessage("修图失败,可点击更多重试")
}
}
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord { private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id], if let record = persistedRecordsById[id],
!record.localPath.isEmpty, !record.localPath.isEmpty,
@@ -618,7 +769,8 @@ final class WiredCameraTransferViewModel {
status: TravelAlbumOTGUploadStatus, status: TravelAlbumOTGUploadStatus,
progress: Int, progress: Int,
error: String?, error: String?,
remoteUrl: String? = nil remoteUrl: String? = nil,
materialId: Int? = nil
) { ) {
var record = persistedRecordsById[id] var record = persistedRecordsById[id]
if record == nil, let item = photos.first(where: { $0.id == id }) { if record == nil, let item = photos.first(where: { $0.id == id }) {
@@ -637,7 +789,10 @@ final class WiredCameraTransferViewModel {
errorMessage: error, errorMessage: error,
albumId: albumId, albumId: albumId,
userId: appStore.session.userId, userId: appStore.session.userId,
remoteUrl: remoteUrl ?? item.remoteUrl remoteUrl: remoteUrl ?? item.remoteUrl,
materialId: materialId,
autoRetouchState: item.autoRetouchState,
autoRetouchTemplateId: item.autoRetouchTemplateId
) )
} }
guard var record else { return } guard var record else { return }
@@ -647,6 +802,35 @@ final class WiredCameraTransferViewModel {
if let remoteUrl { if let remoteUrl {
record.remoteUrl = remoteUrl record.remoteUrl = remoteUrl
} }
if let materialId {
record.materialId = materialId
}
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[id] = record
storage.upsert(record, albumId: albumId)
applyMergedPhotos()
}
/// 单独更新修图阶段,保留已完成的原图上传信息。
private func updateAutoRetouchRecord(
id: String,
state: TravelAlbumAutoRetouchState,
templateId: String,
materialId: Int,
status: TravelAlbumOTGUploadStatus,
error: String?,
retouchedPath: String? = nil
) {
guard var record = persistedRecordsById[id] else { return }
record.status = status
record.progress = 100
record.errorMessage = error
record.materialId = materialId
record.autoRetouchState = state
record.autoRetouchTemplateId = templateId
if let retouchedPath {
record.retouchedPath = retouchedPath
}
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[id] = record persistedRecordsById[id] = record
storage.upsert(record, albumId: albumId) storage.upsert(record, albumId: albumId)
+66 -4
View File
@@ -8,6 +8,7 @@ import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate { class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow? var window: UIWindow?
private var sessionExpiredDialog: SessionExpiredDialogViewController?
func scene( func scene(
_ scene: UIScene, _ scene: UIScene,
@@ -18,8 +19,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
AppNavigationBarAppearance.applyGlobalAppearance() AppNavigationBarAppearance.applyGlobalAppearance()
let debugPreviewRoot = makeAccountDeletionDebugPreviewRootIfNeeded()
if debugPreviewRoot == nil {
enforceAccountDeletionStateBeforeLaunch()
}
let window = UIWindow(windowScene: windowScene) let window = UIWindow(windowScene: windowScene)
window.rootViewController = AppRouter.makeRootViewController() window.rootViewController = debugPreviewRoot ?? AppRouter.makeRootViewController()
window.makeKeyAndVisible() window.makeKeyAndVisible()
self.window = window self.window = window
(UIApplication.shared.delegate as? AppDelegate)?.window = window (UIApplication.shared.delegate as? AppDelegate)?.window = window
@@ -85,19 +91,61 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
) )
} }
/// 为设计核对提供仅 Debug 生效的注销页直达入口,不影响正式启动流程。
private func makeAccountDeletionDebugPreviewRootIfNeeded() -> UIViewController? {
#if DEBUG
guard ProcessInfo.processInfo.arguments.contains("--account-deletion-preview") else { return nil }
let service = AccountDeletionMockService(responseDelayNanoseconds: 0)
let viewModel = AccountDeletionViewModel(username: "18651857230", service: service)
return UINavigationController(rootViewController: AccountDeletionViewController(viewModel: viewModel))
#else
return nil
#endif
}
/// App 冷启动时若账号已进入注销冷静期,则清除旧登录态并返回登录页。
private func enforceAccountDeletionStateBeforeLaunch() {
guard AppStore.shared.session.isLoggedIn,
let username = AccountDeletionIdentity.currentUsername() else { return }
switch AccountDeletionMockService.shared.loginState(for: username) {
case .pending, .completed:
AppStore.shared.logout()
case .none:
break
}
}
@objc private func handleSessionDidExpire() { @objc private func handleSessionDidExpire() {
guard AppStore.shared.session.isLoggedIn else { return }
guard sessionExpiredDialog == nil else { return }
GlobalLoadingManager.shared.hideAll()
let dialog = SessionExpiredDialogViewController { [weak self] in
self?.transitionToLogin()
}
sessionExpiredDialog = dialog
guard let presenter = topViewController(from: window?.rootViewController) else {
sessionExpiredDialog = nil
return
}
presenter.present(dialog, animated: true)
}
private func transitionToLogin() {
sessionExpiredDialog = nil
PushNotificationManager.shared.handleLogout() PushNotificationManager.shared.handleLogout()
AppStore.shared.logout() AppStore.shared.logout()
AppRouter.setRoot(.login, on: window) AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogout() { @objc private func handleUserDidLogout() {
PushNotificationManager.shared.handleLogout() transitionToLogin()
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogin() { @objc private func handleUserDidLogin() {
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
AppRouter.setRoot(.mainTab, on: window) AppRouter.setRoot(.mainTab, on: window)
DispatchQueue.main.async { DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted() PushNotificationManager.shared.handleLoginCompleted()
@@ -108,4 +156,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@objc private func handleAccountDidSwitch() { @objc private func handleAccountDidSwitch() {
PushNotificationManager.shared.handleAccountSwitched() PushNotificationManager.shared.handleAccountSwitched()
} }
private func topViewController(from viewController: UIViewController?) -> UIViewController? {
guard let viewController else { return nil }
if let presented = viewController.presentedViewController {
return topViewController(from: presented)
}
if let navigationController = viewController as? UINavigationController {
return topViewController(from: navigationController.visibleViewController)
}
if let tabBarController = viewController as? UITabBarController {
return topViewController(from: tabBarController.selectedViewController)
}
return viewController
}
} }
@@ -0,0 +1,103 @@
//
// SessionExpiredDialogViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 登录凭证失效时展示的强制确认弹窗,用户确认后由调用方执行退出登录。
@MainActor
final class SessionExpiredDialogViewController: UIViewController {
private let onConfirm: () -> Void
private let confirmButton = AppButton(title: "确认并退出登录")
private var didConfirm = false
/// 创建不可通过点击遮罩关闭的登录失效弹窗。
init(onConfirm: @escaping () -> Void) {
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
isModalInPresentation = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
view.backgroundColor = UIColor.black.withAlphaComponent(0.32)
view.accessibilityViewIsModal = true
view.accessibilityIdentifier = "sessionExpired.dialog"
let cardView = UIView()
cardView.backgroundColor = AppColor.cardBackground
cardView.layer.cornerRadius = AppRadius.lg
cardView.clipsToBounds = true
let warningImageView = UIImageView(
image: UIImage(systemName: "exclamationmark.circle.fill")
)
warningImageView.tintColor = UIColor(hex: 0xF05A5A)
warningImageView.contentMode = .scaleAspectFit
warningImageView.isAccessibilityElement = true
warningImageView.accessibilityLabel = "登录状态异常"
let titleLabel = UILabel()
titleLabel.text = "登录提醒"
titleLabel.textColor = AppColor.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textAlignment = .center
let messageLabel = UILabel()
messageLabel.text = "当前用户已在其他设备登录,\n请重新登录"
messageLabel.textColor = UIColor(hex: 0x4B5563)
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
confirmButton.accessibilityIdentifier = "sessionExpired.confirmButton"
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let contentStack = UIStackView(arrangedSubviews: [
warningImageView,
titleLabel,
messageLabel,
confirmButton,
])
contentStack.axis = .vertical
contentStack.alignment = .fill
contentStack.spacing = 16
contentStack.setCustomSpacing(4, after: titleLabel)
contentStack.setCustomSpacing(20, after: messageLabel)
view.addSubview(cardView)
cardView.addSubview(contentStack)
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(24)
}
warningImageView.snp.makeConstraints { make in
make.height.equalTo(64)
}
}
@objc private func confirmTapped() {
guard !didConfirm else { return }
didConfirm = true
confirmButton.isEnabled = false
onConfirm()
}
}
+47 -6
View File
@@ -273,12 +273,53 @@ final class LoginViewController: BaseViewController {
do { do {
let resolution = try await viewModel.login(authAPI: authAPI) let resolution = try await viewModel.login(authAPI: authAPI)
switch resolution { handleLoginResolution(resolution)
case let .completed(response, account): } catch is CancellationError {
completeLogin(with: response, account: account) return
case .needsAccountSelection: } catch {
break showToast(error.localizedDescription)
} }
}
}
private func handleLoginResolution(_ resolution: LoginResolution) {
switch resolution {
case let .completed(response, account):
completeLogin(with: response, account: account)
case .needsAccountSelection:
break
case let .accountDeletionPending(response, request):
presentAccountDeletionRecovery(response: response, request: request)
}
}
private func presentAccountDeletionRecovery(
response: V9AuthResponse,
request: AccountDeletionRequest
) {
let deletionTime = AccountDeletionDateFormatter.displayText(request.scheduledDeletionAt)
let alert = UIAlertController(
title: "账号正在注销中",
message: "账号将在 \(deletionTime) 永久注销。继续登录将取消本次注销申请。",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "暂不登录", style: .cancel))
alert.addAction(UIAlertAction(title: "恢复账号并登录", style: .default) { [weak self] _ in
self?.resumeLoginAfterCancelingDeletion(response: response)
})
present(alert, animated: true)
}
private func resumeLoginAfterCancelingDeletion(response: V9AuthResponse) {
Task {
showLoading()
defer { hideLoading() }
do {
let resolution = try await viewModel.resumeLoginAfterCancelingDeletion(
verifiedResponse: response,
authAPI: authAPI
)
handleLoginResolution(resolution)
} catch is CancellationError { } catch is CancellationError {
return return
} catch { } catch {
+25
View File
@@ -8,6 +8,8 @@ import Foundation
/// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。 /// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。
final class LoginViewModel { final class LoginViewModel {
private let accountDeletionService: any AccountDeletionServing
private(set) var account = "" private(set) var account = ""
private(set) var password = "" private(set) var password = ""
private(set) var isPrivacyChecked = false private(set) var isPrivacyChecked = false
@@ -16,6 +18,11 @@ final class LoginViewModel {
private(set) var isSelectingAccount = false private(set) var isSelectingAccount = false
private(set) var pendingAccountSelection: AccountSelectionPayload? private(set) var pendingAccountSelection: AccountSelectionPayload?
/// 创建登录 ViewModel,并注入注销状态服务以支持7天内恢复账号。
init(accountDeletionService: any AccountDeletionServing = AccountDeletionMockService.shared) {
self.accountDeletionService = accountDeletionService
}
var isLoginEnabled: Bool { var isLoginEnabled: Bool {
isValidPhone && !trimmedPassword.isEmpty isValidPhone && !trimmedPassword.isEmpty
} }
@@ -122,9 +129,27 @@ final class LoginViewModel {
username: normalizedUsername, username: normalizedUsername,
password: trimmedPassword password: trimmedPassword
) )
switch accountDeletionService.loginState(for: normalizedUsername) {
case let .pending(request):
return .accountDeletionPending(response, request)
case .completed:
throw AccountDeletionError.deletionCompleted
case .none:
break
}
return try await resolveLoginResponse(response, authAPI: authAPI) return try await resolveLoginResponse(response, authAPI: authAPI)
} }
/// 用户确认恢复账号后取消注销,并继续处理已经通过密码验证的登录响应。
func resumeLoginAfterCancelingDeletion(
verifiedResponse: V9AuthResponse,
authAPI: AuthAPI
) async throws -> LoginResolution {
_ = try accountDeletionService.cancelDeletion(username: normalizedUsername)
return try await resolveLoginResponse(verifiedResponse, authAPI: authAPI)
}
/// 选择一个账号并调用 set-user 换取正式 token。 /// 选择一个账号并调用 set-user 换取正式 token。
func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse { func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse {
guard let payload = pendingAccountSelection, payload.hasTempToken else { guard let payload = pendingAccountSelection, payload.hasTempToken else {
@@ -0,0 +1,606 @@
//
// OfflineCollectionDailyViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 日清列表的分区。
enum OfflineDailyListSection: Hashable {
case records
case empty
}
/// 日清列表的条目数据。
enum OfflineDailyListItem: Hashable {
case record(OfflineCollectionRecord)
case empty(isToday: Bool)
}
/// 线下收款日清页,展示单营业日汇总、收款明细与补缴操作。
final class OfflineCollectionDailyViewController: BaseViewController {
private let viewModel: OfflineCollectionDailyViewModel
private let headerStack = UIStackView()
private let dateControl = UIButton(type: .system)
private let summaryCard = UIView()
private let summaryStatusLabel = UILabel()
private let totalMetric = OfflineDailyMetricView(title: "线下收款总额")
private let countMetric = OfflineDailyMetricView(title: "登记笔数")
private let settledMetric = OfflineDailyMetricView(title: "已补缴")
private let pendingMetric = OfflineDailyMetricView(title: "待补缴")
private let overdueBanner = UILabel()
private let tableView = UITableView(frame: .zero, style: .plain)
private var dataSource: UITableViewDiffableDataSource<OfflineDailyListSection, OfflineDailyListItem>!
private var currentSections: [OfflineDailyListSection] = []
private let bottomContainer = UIView()
private let settlementButton = UIButton(type: .system)
private var isFeedbackPresented = false
/// 使用指定营业日、收款上下文与 Mock 服务创建日清页。
init(
businessDate: String,
context: OfflineCollectionContext = .current(),
service: OfflineCollectionMockService = .shared
) {
viewModel = OfflineCollectionDailyViewModel(
businessDate: businessDate,
context: context,
service: service
)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "线下收款日清"
#if DEBUG
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "模拟失败",
style: .plain,
target: self,
action: #selector(simulateFailureTapped)
)
#endif
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
headerStack.axis = .vertical
headerStack.spacing = AppSpacing.sm
dateControl.contentHorizontalAlignment = .leading
dateControl.showsMenuAsPrimaryAction = true
var dateConfiguration = UIButton.Configuration.plain()
dateConfiguration.image = UIImage(systemName: "calendar")
dateConfiguration.imagePadding = AppSpacing.sm
dateConfiguration.baseForegroundColor = AppColor.textPrimary
dateConfiguration.background.backgroundColor = .white
dateConfiguration.background.cornerRadius = AppRadius.lg
dateConfiguration.contentInsets = NSDirectionalEdgeInsets(
top: 12,
leading: AppSpacing.md,
bottom: 12,
trailing: AppSpacing.md
)
dateControl.configuration = dateConfiguration
dateControl.accessibilityIdentifier = "offlineCollection.businessDate"
summaryCard.backgroundColor = .white
summaryCard.layer.cornerRadius = AppRadius.lg
summaryStatusLabel.font = .systemFont(ofSize: 13, weight: .semibold)
summaryStatusLabel.textAlignment = .center
summaryStatusLabel.textColor = AppColor.success
summaryStatusLabel.backgroundColor = UIColor(hex: 0xECFDF3)
summaryStatusLabel.layer.cornerRadius = 12
summaryStatusLabel.clipsToBounds = true
let firstMetricRow = UIStackView(arrangedSubviews: [totalMetric, countMetric])
firstMetricRow.axis = .horizontal
firstMetricRow.distribution = .fillEqually
let secondMetricRow = UIStackView(arrangedSubviews: [settledMetric, pendingMetric])
secondMetricRow.axis = .horizontal
secondMetricRow.distribution = .fillEqually
let metricStack = UIStackView(arrangedSubviews: [firstMetricRow, secondMetricRow])
metricStack.axis = .vertical
metricStack.spacing = AppSpacing.md
summaryCard.addSubview(metricStack)
summaryCard.addSubview(summaryStatusLabel)
metricStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalToSuperview().inset(AppSpacing.md)
}
summaryStatusLabel.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(24)
}
overdueBanner.text = "该营业日存在逾期未补缴,请尽快完成补缴"
overdueBanner.font = .systemFont(ofSize: 13, weight: .medium)
overdueBanner.textColor = AppColor.danger
overdueBanner.backgroundColor = UIColor(hex: 0xFFF1F0)
overdueBanner.layer.cornerRadius = AppRadius.md
overdueBanner.clipsToBounds = true
overdueBanner.textAlignment = .center
overdueBanner.numberOfLines = 0
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 92
tableView.delegate = self
tableView.register(OfflineCollectionRecordCell.self, forCellReuseIdentifier: OfflineCollectionRecordCell.reuseIdentifier)
tableView.register(OfflineCollectionEmptyCell.self, forCellReuseIdentifier: OfflineCollectionEmptyCell.reuseIdentifier)
dataSource = makeDataSource()
bottomContainer.backgroundColor = .white
bottomContainer.layer.shadowColor = UIColor.black.cgColor
bottomContainer.layer.shadowOpacity = 0.06
bottomContainer.layer.shadowRadius = 8
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2)
var settlementConfiguration = UIButton.Configuration.filled()
settlementConfiguration.title = "补缴今日全部"
settlementConfiguration.baseBackgroundColor = AppColor.primary
settlementConfiguration.baseForegroundColor = .white
settlementConfiguration.cornerStyle = .medium
settlementConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 17, weight: .semibold)
return outgoing
}
settlementButton.configuration = settlementConfiguration
settlementButton.accessibilityIdentifier = "offlineCollection.settle"
view.addSubview(headerStack)
[dateControl, summaryCard, overdueBanner].forEach(headerStack.addArrangedSubview)
view.addSubview(tableView)
view.addSubview(bottomContainer)
bottomContainer.addSubview(settlementButton)
}
override func setupConstraints() {
headerStack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
}
dateControl.snp.makeConstraints { make in
make.height.equalTo(50)
}
summaryCard.snp.makeConstraints { make in
make.height.equalTo(172)
}
overdueBanner.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(48)
}
bottomContainer.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
settlementButton.snp.makeConstraints { make in
make.top.equalToSuperview().inset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.height.equalTo(50)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(headerStack.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomContainer.snp.top)
}
}
override func bindActions() {
settlementButton.addTarget(self, action: #selector(settlementTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.load()
}
@MainActor
private func applyViewModel() {
let datePrefix = viewModel.isToday ? "今日 · " : "营业日 · "
dateControl.configuration?.title = datePrefix + viewModel.businessDate
dateControl.menu = makeDateMenu()
totalMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.totalAmountFen))
countMetric.setValue("\(viewModel.summary.totalCount) 笔")
settledMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.settledAmountFen))
pendingMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen), highlighted: viewModel.summary.pendingAmountFen > 0)
let isSettled = viewModel.summary.totalCount > 0 && viewModel.summary.pendingAmountFen == 0
summaryStatusLabel.text = " 本日已结清 "
summaryStatusLabel.isHidden = !isSettled
overdueBanner.isHidden = !viewModel.summary.hasOverdue
let isProcessing = viewModel.settlementState == .processing
settlementButton.isEnabled = viewModel.canSettle
var configuration = settlementButton.configuration
configuration?.showsActivityIndicator = isProcessing
if isProcessing {
configuration?.title = "补缴中"
} else if isSettled {
configuration?.title = "本日已全部补缴"
} else if viewModel.summary.pendingAmountFen == 0 {
configuration?.title = "当日暂无待补缴"
} else {
let prefix = viewModel.isToday ? "补缴今日全部" : "补缴本日全部"
configuration?.title = "\(prefix) \(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen))"
}
settlementButton.configuration = configuration
settlementButton.alpha = settlementButton.isEnabled || isProcessing ? 1 : 0.5
applySnapshot()
presentSettlementFeedbackIfNeeded()
}
@MainActor
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<OfflineDailyListSection, OfflineDailyListItem>()
currentSections = []
if viewModel.records.isEmpty {
currentSections.append(.empty)
snapshot.appendSections([.empty])
snapshot.appendItems([.empty(isToday: viewModel.isToday)], toSection: .empty)
} else {
currentSections.append(.records)
snapshot.appendSections([.records])
snapshot.appendItems(viewModel.records.map(OfflineDailyListItem.record), toSection: .records)
}
dataSource.apply(snapshot, animatingDifferences: view.window != nil)
}
private func makeDataSource() -> UITableViewDiffableDataSource<OfflineDailyListSection, OfflineDailyListItem> {
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
switch item {
case let .record(record):
guard let cell = tableView.dequeueReusableCell(
withIdentifier: OfflineCollectionRecordCell.reuseIdentifier,
for: indexPath
) as? OfflineCollectionRecordCell else { return UITableViewCell() }
cell.apply(record: record)
return cell
case let .empty(isToday):
guard let cell = tableView.dequeueReusableCell(
withIdentifier: OfflineCollectionEmptyCell.reuseIdentifier,
for: indexPath
) as? OfflineCollectionEmptyCell else { return UITableViewCell() }
cell.apply(isToday: isToday)
cell.onRegister = { [weak self] in self?.openRegistration() }
return cell
}
}
}
private func makeDateMenu() -> UIMenu {
let actions = viewModel.availableBusinessDates.map { [weak self] date in
let isCurrent = date == self?.viewModel.businessDate
let title = date == self?.viewModel.service.todayBusinessDate ? "今日 \(date)" : date
return UIAction(title: title, state: isCurrent ? .on : .off) { [weak self] _ in
self?.viewModel.selectBusinessDate(date)
}
}
return UIMenu(title: "选择营业日", children: actions)
}
@MainActor
private func presentSettlementFeedbackIfNeeded() {
guard !isFeedbackPresented else { return }
switch viewModel.settlementState {
case .idle, .processing:
return
case let .success(batch):
isFeedbackPresented = true
let message = """
已补缴 \(OfflineCollectionMoney.display(batch.amountFen))
共结清 \(batch.recordCount) 笔线下收款
补缴流水号:\(batch.id)
"""
let alert = UIAlertController(title: "补缴成功", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "完成", style: .default) { [weak self] _ in
self?.isFeedbackPresented = false
self?.viewModel.clearSettlementFeedback()
})
present(alert, animated: true)
case let .failed(message):
isFeedbackPresented = true
let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "暂不处理", style: .cancel) { [weak self] _ in
self?.isFeedbackPresented = false
self?.viewModel.cancelPreparedSettlement()
self?.viewModel.clearSettlementFeedback()
})
alert.addAction(UIAlertAction(title: "重新补缴", style: .default) { [weak self] _ in
guard let self else { return }
self.isFeedbackPresented = false
self.viewModel.clearSettlementFeedback()
Task { await self.viewModel.confirmSettlement() }
})
present(alert, animated: true)
}
}
private func openRegistration() {
guard viewModel.isToday else { return }
navigationController?.pushViewController(
OfflineCollectionRegistrationViewController(
context: viewModel.context,
service: viewModel.service
),
animated: true
)
}
@objc private func settlementTapped() {
do {
let request = try viewModel.prepareSettlement()
let message = """
营业日:\(request.businessDate)
待补缴记录:\(request.recordIds.count) 笔
本次补缴金额:\(OfflineCollectionMoney.display(request.amountFen))
补缴成功后,本批次记录将统一变为“已补缴”。
"""
let alert = UIAlertController(title: "确认补缴", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
self?.viewModel.cancelPreparedSettlement()
})
alert.addAction(UIAlertAction(
title: "确认补缴 \(OfflineCollectionMoney.display(request.amountFen))",
style: .default
) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.confirmSettlement() }
})
present(alert, animated: true)
} catch {
showToast(error.localizedDescription)
viewModel.load()
}
}
@objc private func simulateFailureTapped() {
viewModel.simulateNextSettlementFailure()
showToast("下一次补缴将模拟失败,可验收失败后重试")
}
}
extension OfflineCollectionDailyViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
currentSections[safe: section] == .empty ? 0 : 40
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
guard let listSection = currentSections[safe: section], listSection != .empty else { return nil }
let container = UIView()
container.backgroundColor = AppColor.pageBackground
let label = UILabel()
label.text = "线下收款明细"
label.font = .systemFont(ofSize: 15, weight: .semibold)
label.textColor = AppColor.textPrimary
container.addSubview(label)
label.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.bottom.equalToSuperview().inset(AppSpacing.xs)
}
return container
}
}
/// 日清汇总卡片中的单项指标视图。
final class OfflineDailyMetricView: UIView {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
/// 使用指标名称创建汇总视图。
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新指标值及是否使用警示色强调。
func setValue(_ value: String, highlighted: Bool = false) {
valueLabel.text = value
valueLabel.textColor = highlighted ? UIColor(hex: 0xD97706) : AppColor.textPrimary
}
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 12)
titleLabel.textColor = AppColor.textTertiary
valueLabel.font = .systemFont(ofSize: 19, weight: .bold)
valueLabel.textColor = AppColor.textPrimary
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.75
let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel])
stack.axis = .vertical
stack.spacing = 4
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4))
}
}
}
/// 线下收款明细单元格。
final class OfflineCollectionRecordCell: UITableViewCell {
static let reuseIdentifier = "OfflineCollectionRecordCell"
private let cardView = UIView()
private let timeLabel = UILabel()
private let methodLabel = UILabel()
private let identifierLabel = UILabel()
private let amountLabel = UILabel()
private let statusLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 用线下收款记录刷新单元格。
func apply(record: OfflineCollectionRecord) {
timeLabel.text = OfflineCollectionDate.timeText(record.registeredAt)
methodLabel.text = record.paymentMethod.displayName
identifierLabel.text = record.id
amountLabel.text = OfflineCollectionMoney.display(record.amountFen)
statusLabel.text = " \(record.status.displayName) "
let color: UIColor
let background: UIColor
switch record.status {
case .pending:
color = UIColor(hex: 0xD97706)
background = UIColor(hex: 0xFFF7E6)
case .settled:
color = AppColor.success
background = UIColor(hex: 0xECFDF3)
case .overdue:
color = AppColor.danger
background = UIColor(hex: 0xFFF1F0)
}
statusLabel.textColor = color
statusLabel.backgroundColor = background
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.lg
timeLabel.font = .systemFont(ofSize: 15, weight: .semibold)
timeLabel.textColor = AppColor.textPrimary
methodLabel.font = .systemFont(ofSize: 14)
methodLabel.textColor = AppColor.textSecondary
identifierLabel.font = .systemFont(ofSize: 11)
identifierLabel.textColor = AppColor.textTertiary
amountLabel.font = .systemFont(ofSize: 19, weight: .bold)
amountLabel.textColor = AppColor.textPrimary
amountLabel.textAlignment = .right
statusLabel.font = .systemFont(ofSize: 12, weight: .semibold)
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 11
statusLabel.clipsToBounds = true
let leftStack = UIStackView(arrangedSubviews: [timeLabel, methodLabel, identifierLabel])
leftStack.axis = .vertical
leftStack.spacing = 4
let rightStack = UIStackView(arrangedSubviews: [amountLabel, statusLabel])
rightStack.axis = .vertical
rightStack.alignment = .trailing
rightStack.spacing = 8
cardView.addSubview(leftStack)
cardView.addSubview(rightStack)
contentView.addSubview(cardView)
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(5)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
}
leftStack.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
make.trailing.lessThanOrEqualTo(rightStack.snp.leading).offset(-AppSpacing.sm)
}
rightStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
}
statusLabel.snp.makeConstraints { make in
make.height.equalTo(22)
}
}
}
/// 日清页在指定营业日无记录时展示的空状态单元格。
final class OfflineCollectionEmptyCell: UITableViewCell {
static let reuseIdentifier = "OfflineCollectionEmptyCell"
var onRegister: (() -> Void)?
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let registerButton = UIButton(type: .system)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新空状态,仅今日允许前往登记。
func apply(isToday: Bool) {
registerButton.isHidden = !isToday
}
override func prepareForReuse() {
super.prepareForReuse()
onRegister = nil
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
titleLabel.text = "当日暂无线下收款记录"
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
detailLabel.text = "完成线下收款后,请及时登记"
detailLabel.font = .systemFont(ofSize: 13)
detailLabel.textColor = AppColor.textSecondary
detailLabel.textAlignment = .center
registerButton.setTitle("去登记", for: .normal)
registerButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
registerButton.addTarget(self, action: #selector(registerTapped), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, registerButton])
stack.axis = .vertical
stack.spacing = AppSpacing.sm
stack.alignment = .center
contentView.addSubview(stack)
stack.snp.makeConstraints { make in
make.top.equalToSuperview().inset(48)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.bottom.equalToSuperview().inset(32)
}
}
@objc private func registerTapped() {
onRegister?()
}
}
private extension Collection {
/// 安全按下标读取元素,超出范围时返回 nil。
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
@@ -0,0 +1,340 @@
//
// OfflineCollectionRegistrationViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 线下收款登记页,仅收集金额和线下收款方式。
final class OfflineCollectionRegistrationViewController: BaseViewController {
private let viewModel: OfflineCollectionRegistrationViewModel
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let amountCard = UIView()
private let amountField = UITextField()
private let methodCard = UIView()
private let methodStack = UIStackView()
private var methodButtons: [OfflineCollectionPaymentMethod: OfflinePaymentMethodButton] = [:]
private let explanationCard = UIView()
private let contextLabel = UILabel()
private let bottomContainer = UIView()
private let submitButton = UIButton(type: .system)
private var hasFocusedAmountField = false
/// 使用指定的收款上下文与 Mock 服务创建登记页。
init(
context: OfflineCollectionContext = .current(),
service: OfflineCollectionMockService = .shared
) {
viewModel = OfflineCollectionRegistrationViewModel(context: context, service: service)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "线下收款登记"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
scrollView.keyboardDismissMode = .interactive
scrollView.alwaysBounceVertical = true
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.md
configureCard(amountCard)
let amountTitleLabel = makeTitleLabel("收款金额")
let currencyLabel = UILabel()
currencyLabel.text = "¥"
currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold)
currencyLabel.textColor = AppColor.textPrimary
amountField.placeholder = "0.00"
amountField.font = .systemFont(ofSize: 38, weight: .bold)
amountField.textColor = AppColor.textPrimary
amountField.keyboardType = .decimalPad
amountField.clearButtonMode = .whileEditing
amountField.delegate = self
amountField.accessibilityLabel = "收款金额"
let amountHelperLabel = UILabel()
amountHelperLabel.text = "单笔金额 0.01~99,999.99 元,最多两位小数"
amountHelperLabel.font = .systemFont(ofSize: 12)
amountHelperLabel.textColor = AppColor.textTertiary
let amountRow = UIStackView(arrangedSubviews: [currencyLabel, amountField])
amountRow.axis = .horizontal
amountRow.alignment = .center
amountRow.spacing = AppSpacing.sm
let amountStack = UIStackView(arrangedSubviews: [amountTitleLabel, amountRow, amountHelperLabel])
amountStack.axis = .vertical
amountStack.spacing = AppSpacing.sm
amountCard.addSubview(amountStack)
amountStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.lg)
}
amountField.snp.makeConstraints { make in
make.height.equalTo(58)
}
configureCard(methodCard)
let methodTitleLabel = makeTitleLabel("收款方式")
methodStack.axis = .horizontal
methodStack.distribution = .fillEqually
methodStack.spacing = AppSpacing.sm
for method in OfflineCollectionPaymentMethod.allCases {
let button = OfflinePaymentMethodButton(method: method)
button.addTarget(self, action: #selector(methodTapped(_:)), for: .touchUpInside)
methodButtons[method] = button
methodStack.addArrangedSubview(button)
}
let methodContentStack = UIStackView(arrangedSubviews: [methodTitleLabel, methodStack])
methodContentStack.axis = .vertical
methodContentStack.spacing = AppSpacing.md
methodCard.addSubview(methodContentStack)
methodContentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.lg)
}
methodStack.snp.makeConstraints { make in
make.height.equalTo(64)
}
configureCard(explanationCard)
let explanationIcon = UIImageView(image: UIImage(systemName: "info.circle.fill"))
explanationIcon.tintColor = AppColor.primary
explanationIcon.contentMode = .scaleAspectFit
let explanationLabel = UILabel()
explanationLabel.text = "登记后将计入今日待补缴,不生成订单。"
explanationLabel.font = .systemFont(ofSize: 14)
explanationLabel.textColor = AppColor.textSecondary
explanationLabel.numberOfLines = 0
explanationCard.addSubview(explanationIcon)
explanationCard.addSubview(explanationLabel)
explanationIcon.snp.makeConstraints { make in
make.leading.top.equalToSuperview().inset(AppSpacing.md)
make.width.height.equalTo(20)
}
explanationLabel.snp.makeConstraints { make in
make.leading.equalTo(explanationIcon.snp.trailing).offset(AppSpacing.sm)
make.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
make.top.equalToSuperview().inset(AppSpacing.md)
}
contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)"
contextLabel.font = .systemFont(ofSize: 12)
contextLabel.textColor = AppColor.textTertiary
contextLabel.numberOfLines = 0
contextLabel.textAlignment = .center
bottomContainer.backgroundColor = .white
bottomContainer.layer.shadowColor = UIColor.black.cgColor
bottomContainer.layer.shadowOpacity = 0.06
bottomContainer.layer.shadowRadius = 8
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2)
var submitConfiguration = UIButton.Configuration.filled()
submitConfiguration.title = "确认登记"
submitConfiguration.baseBackgroundColor = AppColor.primary
submitConfiguration.baseForegroundColor = .white
submitConfiguration.cornerStyle = .medium
submitConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 17, weight: .semibold)
return outgoing
}
submitButton.configuration = submitConfiguration
submitButton.accessibilityIdentifier = "offlineCollection.submit"
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
[amountCard, methodCard, explanationCard, contextLabel].forEach(contentStack.addArrangedSubview)
view.addSubview(bottomContainer)
bottomContainer.addSubview(submitButton)
}
override func setupConstraints() {
bottomContainer.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
}
submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().inset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.height.equalTo(50)
make.bottom.equalToSuperview().inset(AppSpacing.md)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomContainer.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
}
}
override func bindActions() {
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onRegistrationSuccess = { [weak self] receipt in
Task { @MainActor in self?.showRegistrationSuccess(receipt) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !hasFocusedAmountField else { return }
hasFocusedAmountField = true
amountField.becomeFirstResponder()
}
@MainActor
private func applyViewModel() {
if amountField.text != viewModel.amountText {
amountField.text = viewModel.amountText
}
methodButtons.forEach { method, button in
button.setSelected(method == viewModel.paymentMethod)
}
submitButton.isEnabled = viewModel.canSubmit
submitButton.configuration?.showsActivityIndicator = viewModel.isSubmitting
submitButton.configuration?.title = viewModel.isSubmitting ? "登记中" : "确认登记"
submitButton.alpha = submitButton.isEnabled || viewModel.isSubmitting ? 1 : 0.45
}
@MainActor
private func showRegistrationSuccess(_ receipt: OfflineCollectionRegistrationReceipt) {
amountField.resignFirstResponder()
let message = """
本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen))
今日累计线下收款 \(OfflineCollectionMoney.display(receipt.summary.totalAmountFen))
今日待补缴 \(OfflineCollectionMoney.display(receipt.summary.pendingAmountFen))
"""
let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in
self?.viewModel.startAnotherRegistration()
self?.amountField.becomeFirstResponder()
})
alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in
guard let self else { return }
let controller = OfflineCollectionDailyViewController(
businessDate: receipt.summary.businessDate,
context: self.viewModel.context,
service: self.viewModel.service
)
self.navigationController?.pushViewController(controller, animated: true)
})
present(alert, animated: true)
}
private func makeTitleLabel(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 16, weight: .semibold)
label.textColor = AppColor.textPrimary
return label
}
private func configureCard(_ card: UIView) {
card.backgroundColor = .white
card.layer.cornerRadius = AppRadius.lg
}
@objc private func amountChanged() {
viewModel.updateAmount(amountField.text ?? "")
}
@objc private func methodTapped(_ sender: OfflinePaymentMethodButton) {
viewModel.selectPaymentMethod(sender.method)
}
@objc private func submitTapped() {
Task { await viewModel.submit() }
}
}
extension OfflineCollectionRegistrationViewController: UITextFieldDelegate {
func textField(
_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String
) -> Bool {
guard let current = textField.text,
let textRange = Range(range, in: current) else { return false }
let next = current.replacingCharacters(in: textRange, with: string)
return OfflineCollectionMoney.acceptsEditingText(next)
}
}
/// 登记页的收款方式单选按钮。
final class OfflinePaymentMethodButton: UIControl {
let method: OfflineCollectionPaymentMethod
private let iconView = UIImageView()
private let titleLabel = UILabel()
/// 使用指定收款方式创建单选按钮。
init(method: OfflineCollectionPaymentMethod) {
self.method = method
super.init(frame: .zero)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新选中态视觉。
func setSelected(_ selected: Bool) {
isSelected = selected
backgroundColor = selected ? UIColor(hex: 0xEEF5FF) : AppColor.inputBackground
layer.borderColor = (selected ? AppColor.primary : UIColor.clear).cgColor
layer.borderWidth = selected ? 1.5 : 0
titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary
}
private func setupUI() {
layer.cornerRadius = AppRadius.md
clipsToBounds = true
accessibilityLabel = method.displayName
iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal)
iconView.contentMode = .scaleAspectFit
titleLabel.text = method.displayName
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 5
stack.isUserInteractionEnabled = false
addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(22)
}
setSelected(false)
}
}
@@ -0,0 +1,245 @@
//
// OfflineCollectionHomeView.swift
// suixinkan
//
import SnapKit
import UIKit
/// 收款首页中的线下收款入口、今日汇总和历史逾期提醒区域。
final class OfflineCollectionHomeView: UIView {
var onRegister: (() -> Void)?
var onOpenToday: (() -> Void)?
var onOpenOverdue: (() -> Void)?
private let contentStack = UIStackView()
private let sectionTitleLabel = UILabel()
private let overdueControl = UIControl()
private let overdueTitleLabel = UILabel()
private let overdueDetailLabel = UILabel()
private let overdueActionLabel = UILabel()
private let registerControl = UIControl()
private let registerIconView = UIImageView()
private let registerTitleLabel = UILabel()
private let registerSubtitleLabel = UILabel()
private let registerActionLabel = UILabel()
private let summaryControl = UIControl()
private let summaryTitleLabel = UILabel()
private let summaryAmountLabel = UILabel()
private let summaryCountLabel = UILabel()
private let summaryRegisteredLabel = UILabel()
private let summaryStatusLabel = UILabel()
private let summaryActionLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupConstraints()
bindActions()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 根据今日与历史逾期汇总刷新页面内容。
func apply(
today: OfflineDailySummary,
overdueDayCount: Int,
overdueRecordCount: Int,
overdueAmountFen: Int
) {
let hasOverdue = overdueDayCount > 0 && overdueAmountFen > 0
overdueControl.isHidden = !hasOverdue
overdueDetailLabel.text = "\(overdueDayCount) 个营业日,共 \(overdueRecordCount) 笔,待补缴 \(OfflineCollectionMoney.display(overdueAmountFen))"
summaryAmountLabel.text = OfflineCollectionMoney.display(today.pendingAmountFen)
summaryCountLabel.text = "\(today.pendingCount) 笔线下收款"
summaryRegisteredLabel.text = "今日已登记 \(OfflineCollectionMoney.display(today.totalAmountFen))"
let isSettled = today.totalCount > 0 && today.pendingAmountFen == 0
summaryStatusLabel.text = isSettled ? "今日已结清" : ""
summaryStatusLabel.isHidden = !isSettled
summaryActionLabel.text = today.pendingAmountFen > 0 ? "去补缴" : "查看明细"
summaryActionLabel.backgroundColor = today.pendingAmountFen > 0
? AppColor.primary
: UIColor(hex: 0xEEF2F7)
summaryActionLabel.textColor = today.pendingAmountFen > 0
? .white
: AppColor.textSecondary
}
private func setupUI() {
backgroundColor = .clear
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.md
sectionTitleLabel.text = "线下收款"
sectionTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
sectionTitleLabel.textColor = AppColor.textPrimary
configureCard(overdueControl, backgroundColor: UIColor(hex: 0xFFF1F0))
overdueControl.layer.borderColor = UIColor(hex: 0xFFCCC7).cgColor
overdueControl.layer.borderWidth = 1
overdueTitleLabel.text = "存在逾期未补缴"
overdueTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
overdueTitleLabel.textColor = AppColor.danger
overdueDetailLabel.font = .systemFont(ofSize: 13)
overdueDetailLabel.textColor = UIColor(hex: 0x8C2F27)
overdueDetailLabel.numberOfLines = 0
configureActionLabel(overdueActionLabel, title: "立即处理", background: AppColor.danger, foreground: .white)
configureCard(registerControl, backgroundColor: .white)
registerIconView.image = UIImage(systemName: "plus.circle.fill")
registerIconView.tintColor = AppColor.primary
registerIconView.contentMode = .scaleAspectFit
registerTitleLabel.text = "线下收款登记"
registerTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
registerTitleLabel.textColor = AppColor.textPrimary
registerSubtitleLabel.text = "线下收款后,请及时登记并在当日完成补缴"
registerSubtitleLabel.font = .systemFont(ofSize: 13)
registerSubtitleLabel.textColor = AppColor.textSecondary
registerSubtitleLabel.numberOfLines = 0
configureActionLabel(registerActionLabel, title: "去登记", background: UIColor(hex: 0xEEF5FF), foreground: AppColor.primary)
configureCard(summaryControl, backgroundColor: .white)
summaryTitleLabel.text = "今日待补缴"
summaryTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
summaryTitleLabel.textColor = AppColor.textPrimary
summaryAmountLabel.font = .systemFont(ofSize: 30, weight: .bold)
summaryAmountLabel.textColor = AppColor.textPrimary
summaryCountLabel.font = .systemFont(ofSize: 13)
summaryCountLabel.textColor = UIColor(hex: 0xD97706)
summaryRegisteredLabel.font = .systemFont(ofSize: 13)
summaryRegisteredLabel.textColor = AppColor.textSecondary
summaryStatusLabel.font = .systemFont(ofSize: 13, weight: .semibold)
summaryStatusLabel.textColor = AppColor.success
configureActionLabel(summaryActionLabel, title: "查看明细", background: UIColor(hex: 0xEEF2F7), foreground: AppColor.textSecondary)
addSubview(contentStack)
[sectionTitleLabel, overdueControl, registerControl, summaryControl].forEach(contentStack.addArrangedSubview)
let overdueTextStack = UIStackView(arrangedSubviews: [overdueTitleLabel, overdueDetailLabel])
overdueTextStack.axis = .vertical
overdueTextStack.spacing = 5
overdueControl.addSubview(overdueTextStack)
overdueControl.addSubview(overdueActionLabel)
let registerTextStack = UIStackView(arrangedSubviews: [registerTitleLabel, registerSubtitleLabel])
registerTextStack.axis = .vertical
registerTextStack.spacing = 5
registerControl.addSubview(registerIconView)
registerControl.addSubview(registerTextStack)
registerControl.addSubview(registerActionLabel)
let summaryTextStack = UIStackView(arrangedSubviews: [
summaryTitleLabel,
summaryAmountLabel,
summaryCountLabel,
summaryRegisteredLabel,
summaryStatusLabel,
])
summaryTextStack.axis = .vertical
summaryTextStack.spacing = 6
summaryControl.addSubview(summaryTextStack)
summaryControl.addSubview(summaryActionLabel)
}
private func setupConstraints() {
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
sectionTitleLabel.snp.makeConstraints { make in
make.height.equalTo(24)
}
overdueControl.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(84)
}
overdueControl.subviews.first?.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
make.trailing.lessThanOrEqualTo(overdueActionLabel.snp.leading).offset(-AppSpacing.sm)
}
overdueActionLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
make.height.equalTo(32)
}
registerControl.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(96)
}
registerIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
make.width.height.equalTo(34)
}
registerTitleLabel.superview?.snp.makeConstraints { make in
make.leading.equalTo(registerIconView.snp.trailing).offset(AppSpacing.sm)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(registerActionLabel.snp.leading).offset(-AppSpacing.sm)
}
registerActionLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
make.height.equalTo(32)
}
summaryControl.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(180)
}
summaryTitleLabel.superview?.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
make.trailing.lessThanOrEqualTo(summaryActionLabel.snp.leading).offset(-AppSpacing.sm)
}
summaryActionLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(34)
}
}
private func bindActions() {
overdueControl.addTarget(self, action: #selector(overdueTapped), for: .touchUpInside)
registerControl.addTarget(self, action: #selector(registerTapped), for: .touchUpInside)
summaryControl.addTarget(self, action: #selector(summaryTapped), for: .touchUpInside)
}
private func configureCard(_ control: UIControl, backgroundColor: UIColor) {
control.backgroundColor = backgroundColor
control.layer.cornerRadius = AppRadius.lg
control.clipsToBounds = true
}
private func configureActionLabel(
_ label: UILabel,
title: String,
background: UIColor,
foreground: UIColor
) {
label.text = " \(title) "
label.font = .systemFont(ofSize: 13, weight: .semibold)
label.textAlignment = .center
label.textColor = foreground
label.backgroundColor = background
label.layer.cornerRadius = 16
label.clipsToBounds = true
label.setContentCompressionResistancePriority(.required, for: .horizontal)
}
@objc private func overdueTapped() {
onOpenOverdue?()
}
@objc private func registerTapped() {
onRegister?()
}
@objc private func summaryTapped() {
onOpenToday?()
}
}
@@ -12,6 +12,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let viewModel = PaymentCollectionDetailsViewModel() private let viewModel = PaymentCollectionDetailsViewModel()
private let paymentAPI = NetworkServices.shared.paymentAPI private let paymentAPI = NetworkServices.shared.paymentAPI
private let offlineCollectionViewModel = OfflineCollectionHomeViewModel()
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let contentContainerView = UIView() private let contentContainerView = UIView()
@@ -28,6 +29,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let qrContainerView = UIView() private let qrContainerView = UIView()
private let qrImageView = UIImageView() private let qrImageView = UIImageView()
private let qrPlaceholderView = UIView() private let qrPlaceholderView = UIView()
private let qrLoadingIndicator = UIActivityIndicatorView(style: .medium)
private let qrErrorLabel = UILabel() private let qrErrorLabel = UILabel()
private let refreshButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system)
private let setAmountButton = UIButton(type: .system) private let setAmountButton = UIButton(type: .system)
@@ -47,6 +49,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let voiceCardView = UIView() private let voiceCardView = UIView()
private let voiceTitleLabel = UILabel() private let voiceTitleLabel = UILabel()
private let voiceSwitch = UISwitch() private let voiceSwitch = UISwitch()
private let offlineCollectionView = OfflineCollectionHomeView()
private var amountDialog: PaymentSetAmountDialogView? private var amountDialog: PaymentSetAmountDialogView?
private var appliedBrandConfig: PayPageConfig? private var appliedBrandConfig: PayPageConfig?
@@ -99,9 +102,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
override func setupUI() { override func setupUI() {
let usesBranding = viewModel.usesNalatiBranding let usesBranding = viewModel.usesNalatiBranding
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
scrollView.showsVerticalScrollIndicator = !usesBranding scrollView.showsVerticalScrollIndicator = true
scrollView.isScrollEnabled = !usesBranding scrollView.isScrollEnabled = true
scrollView.alwaysBounceVertical = false scrollView.alwaysBounceVertical = true
contentStack.axis = .vertical contentStack.axis = .vertical
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
@@ -127,6 +130,10 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24 qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
qrLoadingIndicator.color = AppColor.primary
qrLoadingIndicator.hidesWhenStopped = true
qrLoadingIndicator.accessibilityLabel = "收款码加载中"
qrErrorLabel.text = "未选景区或网络问题" qrErrorLabel.text = "未选景区或网络问题"
qrErrorLabel.font = .systemFont(ofSize: 14) qrErrorLabel.font = .systemFont(ofSize: 14)
qrErrorLabel.textColor = AppColor.danger qrErrorLabel.textColor = AppColor.danger
@@ -150,6 +157,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
trailing: 24 trailing: 24
) )
} }
actionRow.alpha = 0
actionRow.isUserInteractionEnabled = false
actionRow.accessibilityElementsHidden = true
configureLinkButton(setAmountButton, title: "设置金额") configureLinkButton(setAmountButton, title: "设置金额")
configureLinkButton(saveQRButton, title: "保存二维码") configureLinkButton(saveQRButton, title: "保存二维码")
@@ -193,6 +203,8 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
contentStack.addArrangedSubview(recordRow) contentStack.addArrangedSubview(recordRow)
contentStack.addArrangedSubview(voiceCardView) contentStack.addArrangedSubview(voiceCardView)
} }
// 线下收款与原有真实收款功能并列,不替换二维码或收款记录入口。
contentStack.addArrangedSubview(offlineCollectionView)
let qrDisplayView = usesBranding ? qrContainerView : qrImageView let qrDisplayView = usesBranding ? qrContainerView : qrImageView
let qrContentStack = UIStackView(arrangedSubviews: [ let qrContentStack = UIStackView(arrangedSubviews: [
@@ -206,6 +218,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
qrCardView.addSubview(qrContentStack) qrCardView.addSubview(qrContentStack)
qrPlaceholderView.addSubview(qrLoadingIndicator)
qrPlaceholderView.addSubview(qrErrorLabel) qrPlaceholderView.addSubview(qrErrorLabel)
qrPlaceholderView.addSubview(refreshButton) qrPlaceholderView.addSubview(refreshButton)
@@ -275,6 +288,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.snp.makeConstraints { make in qrPlaceholderView.snp.makeConstraints { make in
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182) make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
} }
qrLoadingIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
qrErrorLabel.snp.makeConstraints { make in qrErrorLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(56) make.top.equalToSuperview().offset(56)
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
@@ -318,15 +334,14 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
make.edges.equalToSuperview() make.edges.equalToSuperview()
make.width.equalTo(scrollView.snp.width) make.width.equalTo(scrollView.snp.width)
if viewModel.usesNalatiBranding { if viewModel.usesNalatiBranding {
make.height.equalTo(scrollView.snp.height) make.height.greaterThanOrEqualTo(scrollView.snp.height)
} }
} }
contentStack.snp.makeConstraints { make in contentStack.snp.makeConstraints { make in
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
if viewModel.usesNalatiBranding { if viewModel.usesNalatiBranding {
make.centerX.centerY.equalToSuperview() make.centerX.equalToSuperview()
make.top.greaterThanOrEqualToSuperview() make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.bottom.lessThanOrEqualToSuperview()
} else { } else {
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset) make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
} }
@@ -340,6 +355,18 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
viewModel.onShowMessage = { [weak self] message in viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) } Task { @MainActor in self?.showToast(message) }
} }
offlineCollectionViewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyOfflineCollection() }
}
offlineCollectionView.onRegister = { [weak self] in
self?.openOfflineCollectionRegistration()
}
offlineCollectionView.onOpenToday = { [weak self] in
self?.openOfflineCollectionDaily(date: self?.offlineCollectionViewModel.todayBusinessDate)
}
offlineCollectionView.onOpenOverdue = { [weak self] in
self?.openOfflineCollectionDaily(date: self?.offlineCollectionViewModel.earliestOverdueBusinessDate)
}
setAmountButton.addTarget(self, action: #selector(setAmountTapped), for: .touchUpInside) setAmountButton.addTarget(self, action: #selector(setAmountTapped), for: .touchUpInside)
saveQRButton.addTarget(self, action: #selector(saveQRTapped), for: .touchUpInside) saveQRButton.addTarget(self, action: #selector(saveQRTapped), for: .touchUpInside)
@@ -357,6 +384,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
applyBrandNavigationAppearanceIfNeeded() applyBrandNavigationAppearanceIfNeeded()
offlineCollectionViewModel.load()
} }
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
@@ -370,9 +398,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private func loadData() async { private func loadData() async {
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI) async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
showLoading()
await viewModel.loadPayCode(api: paymentAPI) await viewModel.loadPayCode(api: paymentAPI)
hideLoading()
await pageConfig await pageConfig
} }
@@ -387,17 +413,33 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
applyBrandConfigIfNeeded() applyBrandConfigIfNeeded()
if let image = viewModel.qrImage { if viewModel.isPayCodeLoading {
qrImageView.isHidden = true
qrContainerView.isHidden = true
qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding
? .white
: AppColor.inputBackground
qrPlaceholderView.isHidden = false
qrErrorLabel.isHidden = true
refreshButton.isHidden = true
qrLoadingIndicator.startAnimating()
setActionRowAvailable(false)
} else if let image = viewModel.qrImage {
qrImageView.image = image qrImageView.image = image
qrImageView.isHidden = false qrImageView.isHidden = false
qrContainerView.isHidden = !viewModel.usesNalatiBranding qrContainerView.isHidden = !viewModel.usesNalatiBranding
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
actionRow.isHidden = false qrLoadingIndicator.stopAnimating()
setActionRowAvailable(true)
} else { } else {
qrImageView.isHidden = true qrImageView.isHidden = true
qrContainerView.isHidden = true qrContainerView.isHidden = true
qrPlaceholderView.backgroundColor = AppColor.inputBackground
qrPlaceholderView.isHidden = false qrPlaceholderView.isHidden = false
actionRow.isHidden = true qrErrorLabel.isHidden = false
refreshButton.isHidden = false
qrLoadingIndicator.stopAnimating()
setActionRowAvailable(false)
} }
if viewModel.showAmountDialog { if viewModel.showAmountDialog {
@@ -406,6 +448,23 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
amountDialog?.dismiss() amountDialog?.dismiss()
amountDialog = nil amountDialog = nil
} }
applyOfflineCollection()
}
@MainActor
private func applyOfflineCollection() {
offlineCollectionView.apply(
today: offlineCollectionViewModel.todaySummary,
overdueDayCount: offlineCollectionViewModel.overdueSummaries.count,
overdueRecordCount: offlineCollectionViewModel.overdueRecordCount,
overdueAmountFen: offlineCollectionViewModel.overdueAmountFen
)
}
private func setActionRowAvailable(_ isAvailable: Bool) {
actionRow.alpha = isAvailable ? 1 : 0
actionRow.isUserInteractionEnabled = isAvailable
actionRow.accessibilityElementsHidden = !isAvailable
} }
private func presentAmountDialogIfNeeded() { private func presentAmountDialogIfNeeded() {
@@ -625,9 +684,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
@objc private func refreshTapped() { @objc private func refreshTapped() {
Task { Task {
showLoading()
await viewModel.refresh(api: paymentAPI) await viewModel.refresh(api: paymentAPI)
hideLoading()
} }
} }
@@ -638,6 +695,24 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
@objc private func voiceSwitchChanged() { @objc private func voiceSwitchChanged() {
viewModel.toggleReceiveVoice() viewModel.toggleReceiveVoice()
} }
private func openOfflineCollectionRegistration() {
let controller = OfflineCollectionRegistrationViewController(
context: offlineCollectionViewModel.context,
service: offlineCollectionViewModel.service
)
navigationController?.pushViewController(controller, animated: true)
}
private func openOfflineCollectionDaily(date: String?) {
guard let date else { return }
let controller = OfflineCollectionDailyViewController(
businessDate: date,
context: offlineCollectionViewModel.context,
service: offlineCollectionViewModel.service
)
navigationController?.pushViewController(controller, animated: true)
}
} }
/// 收款详情信息行。 /// 收款详情信息行。
@@ -38,6 +38,11 @@ final class ProfileViewController: BaseViewController {
title = "我的" title = "我的"
} }
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func setupUI() { override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF) view.backgroundColor = UIColor(hex: 0xF7FAFF)
@@ -104,12 +109,10 @@ final class ProfileViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
let showLoading = !hasLoadedProfileOnce let showLoading = !hasLoadedProfileOnce && !viewModel.hasCachedBasicInfo
Task { Task {
await reloadProfile(showGlobalLoading: showLoading) await reloadProfile(showGlobalLoading: showLoading)
if showLoading { hasLoadedProfileOnce = true
hasLoadedProfileOnce = true
}
} }
} }
@@ -0,0 +1,761 @@
//
// AccountDeletionViewControllers.swift
// suixinkan
//
import SnapKit
import UIKit
/// 注销账号资产核验页,中性展示账号资产与注销后果。
final class AccountDeletionViewController: BaseViewController {
private let viewModel: AccountDeletionViewModel
private let scrollView = UIScrollView()
private let contentView = UIView()
private let contentStack = UIStackView()
private let warningView = AccountDeletionWarningView()
private let assetCardView = AccountDeletionAssetCardView()
private let detailButton = UIButton(type: .system)
private let consequenceCardView = AccountDeletionConsequenceCardView()
private let bottomPanel = UIView()
private let acknowledgementButton = UIButton(type: .system)
private let continueButton = AppButton(title: "继续注销", style: .primary)
private var lastErrorMessage: String?
/// 创建注销资产核验页。
init(viewModel: AccountDeletionViewModel = AccountDeletionViewModel()) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "注销账号"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
scrollView.alwaysBounceVertical = true
scrollView.showsVerticalScrollIndicator = false
contentStack.axis = .vertical
contentStack.spacing = 16
contentStack.isLayoutMarginsRelativeArrangement = true
contentStack.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
detailButton.setTitle("查看资产明细", for: .normal)
detailButton.setTitleColor(AppColor.primary, for: .normal)
detailButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
bottomPanel.backgroundColor = AppColor.pageBackground
acknowledgementButton.contentHorizontalAlignment = .leading
acknowledgementButton.tintColor = AppColor.primary
acknowledgementButton.setTitleColor(AppColor.textPrimary, for: .normal)
acknowledgementButton.titleLabel?.font = .systemFont(ofSize: 14)
acknowledgementButton.titleLabel?.numberOfLines = 0
acknowledgementButton.setTitle(" 我已知晓并自愿放弃以上资产和权益", for: .normal)
acknowledgementButton.accessibilityLabel = "我已知晓并自愿放弃以上资产和权益"
view.addSubview(scrollView)
scrollView.addSubview(contentView)
contentView.addSubview(contentStack)
view.addSubview(bottomPanel)
bottomPanel.addSubview(acknowledgementButton)
bottomPanel.addSubview(continueButton)
[warningView, assetCardView, detailButton, consequenceCardView].forEach(contentStack.addArrangedSubview)
applyViewModel()
}
override func setupConstraints() {
bottomPanel.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
acknowledgementButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.trailing.equalToSuperview().inset(16)
make.height.greaterThanOrEqualTo(44)
}
continueButton.snp.makeConstraints { make in
make.top.equalTo(acknowledgementButton.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomPanel.snp.top)
}
contentView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalTo(scrollView.snp.width)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
warningView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(64)
}
detailButton.snp.makeConstraints { make in
make.height.equalTo(36)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
acknowledgementButton.addTarget(self, action: #selector(acknowledgementTapped), for: .touchUpInside)
continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
showLoading()
await viewModel.load()
hideLoading()
}
}
private func applyViewModel() {
assetCardView.configure(assets: viewModel.assets)
consequenceCardView.configure(items: viewModel.consequences)
acknowledgementButton.setImage(
UIImage(systemName: viewModel.isAcknowledged ? "checkmark.square.fill" : "square"),
for: .normal
)
acknowledgementButton.accessibilityValue = viewModel.isAcknowledged ? "已勾选" : "未勾选"
continueButton.isEnabled = viewModel.isContinueButtonEnabled
if let errorMessage = viewModel.errorMessage,
!errorMessage.isEmpty,
errorMessage != lastErrorMessage {
lastErrorMessage = errorMessage
showToast(errorMessage)
}
}
@objc private func acknowledgementTapped() {
viewModel.toggleAcknowledgement()
}
@objc private func continueTapped() {
do {
let verificationViewModel = try viewModel.makeVerificationViewModel()
navigationController?.pushViewController(
AccountDeletionVerificationViewController(viewModel: verificationViewModel),
animated: true
)
} catch {
showToast(error.localizedDescription)
}
}
@objc private func detailTapped() {
let message = viewModel.assets
.map { "\($0.title):\($0.valueText)" }
.joined(separator: "\n")
showAlert(title: "当前账号资产", message: message)
}
}
/// 注销手机号验证页,使用短信验证码确认是账号本人操作。
final class AccountDeletionVerificationViewController: BaseViewController {
private let viewModel: AccountDeletionVerificationViewModel
private let contentStack = UIStackView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let descriptionLabel = UILabel()
private let codeContainer = UIView()
private let codeField = UITextField()
private let resendButton = UIButton(type: .system)
private let demoHintLabel = UILabel()
private let noticeView = AccountDeletionNoticeView()
private let submitButton = AppButton(title: "提交注销申请", style: .primary)
private var lastErrorMessage: String?
/// 创建注销手机号验证页。
init(viewModel: AccountDeletionVerificationViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "验证手机号"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
contentStack.axis = .vertical
contentStack.alignment = .fill
contentStack.spacing = 16
iconView.image = UIImage(systemName: "message.badge.filled.fill")
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
titleLabel.text = "确认是你本人操作"
titleLabel.font = .systemFont(ofSize: 22, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
descriptionLabel.text = "验证码将发送至 \(viewModel.context.maskedPhone)"
descriptionLabel.font = .systemFont(ofSize: 15)
descriptionLabel.textColor = AppColor.textSecondary
descriptionLabel.textAlignment = .center
codeContainer.backgroundColor = .white
codeContainer.layer.cornerRadius = 12
codeContainer.layer.borderWidth = 1
codeContainer.layer.borderColor = AppColor.border.cgColor
codeField.placeholder = "请输入6位验证码"
codeField.font = .systemFont(ofSize: 18, weight: .medium)
codeField.textColor = AppColor.textPrimary
codeField.keyboardType = .numberPad
codeField.textContentType = .oneTimeCode
codeField.clearButtonMode = .whileEditing
resendButton.setTitle("重新发送", for: .normal)
resendButton.setTitleColor(AppColor.primary, for: .normal)
resendButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
demoHintLabel.text = "演示验证码:123456"
demoHintLabel.font = .systemFont(ofSize: 13)
demoHintLabel.textColor = AppColor.warning
demoHintLabel.textAlignment = .center
noticeView.configure(
iconName: "calendar.badge.exclamationmark",
text: "提交成功后账号将退出,7天内再次登录可取消注销。"
)
view.addSubview(contentStack)
view.addSubview(submitButton)
codeContainer.addSubview(codeField)
codeContainer.addSubview(resendButton)
[iconView, titleLabel, descriptionLabel, codeContainer, demoHintLabel, noticeView]
.forEach(contentStack.addArrangedSubview)
applyViewModel()
}
override func setupConstraints() {
contentStack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(32)
make.leading.trailing.equalToSuperview().inset(24)
}
iconView.snp.makeConstraints { make in
make.height.equalTo(64)
}
codeContainer.snp.makeConstraints { make in
make.height.equalTo(56)
}
codeField.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.top.bottom.equalToSuperview()
make.trailing.equalTo(resendButton.snp.leading).offset(-8)
}
resendButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalToSuperview()
make.width.equalTo(76)
make.height.equalTo(44)
}
noticeView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(64)
}
submitButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
codeField.addTarget(self, action: #selector(codeChanged), for: .editingChanged)
resendButton.addTarget(self, action: #selector(resendTapped), for: .touchUpInside)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
await viewModel.sendVerificationCode()
if viewModel.hasSentCode {
showToast("验证码已发送")
}
}
}
private func applyViewModel() {
if codeField.text != viewModel.verificationCode {
codeField.text = viewModel.verificationCode
}
resendButton.isEnabled = !viewModel.isSendingCode && !viewModel.isSubmitting
resendButton.setTitle(viewModel.isSendingCode ? "发送中" : "重新发送", for: .normal)
submitButton.isEnabled = viewModel.isSubmitEnabled
if let errorMessage = viewModel.errorMessage,
!errorMessage.isEmpty,
errorMessage != lastErrorMessage {
lastErrorMessage = errorMessage
showToast(errorMessage)
}
}
@objc private func codeChanged() {
viewModel.updateVerificationCode(codeField.text ?? "")
}
@objc private func resendTapped() {
Task {
await viewModel.sendVerificationCode()
if viewModel.hasSentCode {
showToast("验证码已重新发送")
}
}
}
@objc private func submitTapped() {
let alert = UIAlertController(
title: "确认提交注销申请?",
message: "提交后账号将退出,7天内再次登录可以取消注销。",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "再想想", style: .cancel))
alert.addAction(UIAlertAction(title: "确认提交", style: .destructive) { [weak self] _ in
self?.submitDeletion()
})
present(alert, animated: true)
}
private func submitDeletion() {
Task {
showLoading()
defer { hideLoading() }
do {
let request = try await viewModel.submit()
navigationController?.pushViewController(
AccountDeletionSuccessViewController(request: request),
animated: true
)
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
}
/// 注销申请提交成功页,明确展示计划删除时间和恢复方式。
final class AccountDeletionSuccessViewController: BaseViewController {
private let request: AccountDeletionRequest
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let descriptionLabel = UILabel()
private let dateCardView = UIView()
private let dateTitleLabel = UILabel()
private let dateValueLabel = UILabel()
private let recoveryLabel = UILabel()
private let exitButton = AppButton(title: "退出并返回登录页", style: .primary)
/// 创建注销申请提交成功页。
init(request: AccountDeletionRequest) {
self.request = request
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "注销账号"
navigationItem.hidesBackButton = true
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
iconView.image = UIImage(systemName: "checkmark.circle.fill")
iconView.tintColor = AppColor.success
iconView.contentMode = .scaleAspectFit
titleLabel.text = "注销申请已提交"
titleLabel.font = .systemFont(ofSize: 24, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
descriptionLabel.text = "账号已进入7天注销冷静期"
descriptionLabel.font = .systemFont(ofSize: 15)
descriptionLabel.textColor = AppColor.textSecondary
descriptionLabel.textAlignment = .center
dateCardView.backgroundColor = .white
dateCardView.layer.cornerRadius = 12
dateTitleLabel.text = "计划永久注销时间"
dateTitleLabel.font = .systemFont(ofSize: 14)
dateTitleLabel.textColor = AppColor.textSecondary
dateValueLabel.text = AccountDeletionDateFormatter.displayText(request.scheduledDeletionAt)
dateValueLabel.font = .systemFont(ofSize: 20, weight: .semibold)
dateValueLabel.textColor = AppColor.danger
dateValueLabel.textAlignment = .right
recoveryLabel.text = "在上述时间前再次使用当前手机号和密码登录,确认恢复账号后即可取消注销。"
recoveryLabel.font = .systemFont(ofSize: 15)
recoveryLabel.textColor = AppColor.textSecondary
recoveryLabel.numberOfLines = 0
recoveryLabel.textAlignment = .center
[iconView, titleLabel, descriptionLabel, dateCardView, recoveryLabel, exitButton].forEach(view.addSubview)
dateCardView.addSubview(dateTitleLabel)
dateCardView.addSubview(dateValueLabel)
}
override func setupConstraints() {
iconView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(56)
make.centerX.equalToSuperview()
make.width.height.equalTo(72)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(iconView.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(24)
}
descriptionLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(10)
make.leading.trailing.equalToSuperview().inset(24)
}
dateCardView.snp.makeConstraints { make in
make.top.equalTo(descriptionLabel.snp.bottom).offset(32)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(72)
}
dateTitleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
}
dateValueLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(dateTitleLabel.snp.trailing).offset(12)
}
recoveryLabel.snp.makeConstraints { make in
make.top.equalTo(dateCardView.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(32)
}
exitButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
}
override func bindActions() {
exitButton.addTarget(self, action: #selector(exitTapped), for: .touchUpInside)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
@objc private func exitTapped() {
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
}
}
/// 注销页顶部风险提示条。
private final class AccountDeletionWarningView: UIView {
private let iconView = UIImageView()
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.dangerBackground
layer.cornerRadius = 12
layer.borderWidth = 1
layer.borderColor = AppColor.danger.withAlphaComponent(0.25).cgColor
iconView.image = UIImage(systemName: "exclamationmark.shield.fill")
iconView.tintColor = AppColor.danger
iconView.contentMode = .scaleAspectFit
label.text = "注销后,账号及相关数据将永久删除"
label.font = .systemFont(ofSize: 15, weight: .medium)
label.textColor = AppColor.danger
label.numberOfLines = 0
addSubview(iconView)
addSubview(label)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.width.height.equalTo(28)
}
label.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(12)
make.trailing.equalToSuperview().offset(-16)
make.top.bottom.equalToSuperview().inset(14)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 注销资产汇总卡片,以列表形式中性展示名称和数量。
private final class AccountDeletionAssetCardView: UIView {
private let stackView = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 12
clipsToBounds = true
stackView.axis = .vertical
addSubview(stackView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 使用最新资产快照刷新卡片。
func configure(assets: [AccountDeletionAssetSummary]) {
stackView.arrangedSubviews.forEach {
stackView.removeArrangedSubview($0)
$0.removeFromSuperview()
}
assets.enumerated().forEach { index, asset in
stackView.addArrangedSubview(
AccountDeletionAssetRowView(asset: asset, showsDivider: index < assets.count - 1)
)
}
}
}
/// 注销资产列表行,仅显示客观资产名称和当前值。
private final class AccountDeletionAssetRowView: UIView {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
private let divider = UIView()
init(asset: AccountDeletionAssetSummary, showsDivider: Bool) {
super.init(frame: .zero)
titleLabel.text = asset.title
titleLabel.font = .systemFont(ofSize: 17, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
valueLabel.text = asset.valueText
valueLabel.font = .systemFont(ofSize: 18, weight: .semibold)
valueLabel.textColor = AppColor.textPrimary
valueLabel.textAlignment = .right
divider.backgroundColor = AppColor.border
divider.isHidden = !showsDivider
[titleLabel, valueLabel, divider].forEach(addSubview)
snp.makeConstraints { make in
make.height.equalTo(76)
}
titleLabel.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
}
divider.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(0.5)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 注销影响说明卡片,展示账号解除、内容删除和7天恢复规则。
private final class AccountDeletionConsequenceCardView: UIView {
private let titleLabel = UILabel()
private let stackView = UIStackView()
private let iconNames = [
"person.crop.circle.badge.xmark",
"folder",
"calendar.badge.exclamationmark",
]
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 12
clipsToBounds = true
titleLabel.text = "注销后将发生"
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
stackView.axis = .vertical
addSubview(titleLabel)
addSubview(stackView)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
}
stackView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 8, right: 16))
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 使用最新注销影响文案刷新卡片。
func configure(items: [String]) {
stackView.arrangedSubviews.forEach {
stackView.removeArrangedSubview($0)
$0.removeFromSuperview()
}
items.enumerated().forEach { index, text in
stackView.addArrangedSubview(
AccountDeletionConsequenceRowView(
iconName: iconNames.indices.contains(index) ? iconNames[index] : "info.circle",
text: text,
showsDivider: index < items.count - 1
)
)
}
}
}
/// 注销影响说明列表行。
private final class AccountDeletionConsequenceRowView: UIView {
private let iconBackgroundView = UIView()
private let iconView = UIImageView()
private let label = UILabel()
private let divider = UIView()
init(iconName: String, text: String, showsDivider: Bool) {
super.init(frame: .zero)
iconBackgroundView.backgroundColor = AppColor.primaryLight
iconBackgroundView.layer.cornerRadius = 18
iconView.image = UIImage(systemName: iconName)
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
label.text = text
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.textPrimary
label.numberOfLines = 0
divider.backgroundColor = AppColor.border
divider.isHidden = !showsDivider
addSubview(iconBackgroundView)
iconBackgroundView.addSubview(iconView)
addSubview(label)
addSubview(divider)
snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(58)
}
iconBackgroundView.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.width.height.equalTo(36)
}
iconView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(8)
}
label.snp.makeConstraints { make in
make.leading.equalTo(iconBackgroundView.snp.trailing).offset(12)
make.trailing.equalToSuperview()
make.centerY.equalToSuperview()
}
divider.snp.makeConstraints { make in
make.leading.equalTo(label)
make.trailing.bottom.equalToSuperview()
make.height.equalTo(0.5)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 注销流程通用说明条。
private final class AccountDeletionNoticeView: UIView {
private let iconView = UIImageView()
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.infoBackground
layer.cornerRadius = 12
addSubview(iconView)
addSubview(label)
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.textSecondary
label.numberOfLines = 0
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.width.height.equalTo(24)
}
label.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(12)
make.trailing.equalToSuperview().offset(-16)
make.top.bottom.equalToSuperview().inset(14)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 配置说明条图标与文案。
func configure(iconName: String, text: String) {
iconView.image = UIImage(systemName: iconName)
label.text = text
}
}
@@ -14,6 +14,12 @@ final class SettingViewController: BaseViewController {
private let contentView = UIView() private let contentView = UIView()
private let cardView = UIView() private let cardView = UIView()
private let rowsStack = UIStackView() private let rowsStack = UIStackView()
private let dangerCardView = UIView()
private let accountDeletionRow = SettingMenuRow(
title: "注销账号",
titleColor: AppColor.danger,
showsDivider: false
)
private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false) private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false)
private let copyrightLabel = UILabel() private let copyrightLabel = UILabel()
@@ -41,12 +47,18 @@ final class SettingViewController: BaseViewController {
view.addSubview(contentView) view.addSubview(contentView)
contentView.addSubview(cardView) contentView.addSubview(cardView)
cardView.addSubview(rowsStack) cardView.addSubview(rowsStack)
contentView.addSubview(dangerCardView)
dangerCardView.addSubview(accountDeletionRow)
contentView.addSubview(copyrightLabel) contentView.addSubview(copyrightLabel)
cardView.backgroundColor = .white cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12 cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true cardView.clipsToBounds = true
dangerCardView.backgroundColor = .white
dangerCardView.layer.cornerRadius = 12
dangerCardView.clipsToBounds = true
rowsStack.axis = .vertical rowsStack.axis = .vertical
copyrightLabel.text = "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号" copyrightLabel.text = "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
@@ -68,6 +80,7 @@ final class SettingViewController: BaseViewController {
rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside) rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside)
rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside) rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside)
rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside) rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside)
accountDeletionRow.addTarget(self, action: #selector(accountDeletionTapped), for: .touchUpInside)
} }
override func setupConstraints() { override func setupConstraints() {
@@ -80,6 +93,13 @@ final class SettingViewController: BaseViewController {
rowsStack.snp.makeConstraints { make in rowsStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)) make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
} }
dangerCardView.snp.makeConstraints { make in
make.top.equalTo(cardView.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview()
}
accountDeletionRow.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
}
copyrightLabel.snp.makeConstraints { make in copyrightLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview() make.leading.trailing.equalToSuperview()
make.bottom.equalToSuperview().offset(-16) make.bottom.equalToSuperview().offset(-16)
@@ -119,6 +139,10 @@ final class SettingViewController: BaseViewController {
openAgreement(.privacyPolicy) openAgreement(.privacyPolicy)
} }
@objc private func accountDeletionTapped() {
navigationController?.pushViewController(AccountDeletionViewController(), animated: true)
}
private func openAgreement(_ kind: SettingAgreementKind) { private func openAgreement(_ kind: SettingAgreementKind) {
let destination = viewModel.agreementDestination(for: kind) let destination = viewModel.agreementDestination(for: kind)
navigationController?.pushViewController( navigationController?.pushViewController(
@@ -141,6 +165,7 @@ final class SettingMenuRow: UIControl {
init( init(
title: String, title: String,
value: String? = nil, value: String? = nil,
titleColor: UIColor = UIColor(hex: 0x4B5563),
valueColor: UIColor = AppColor.textPrimary, valueColor: UIColor = AppColor.textPrimary,
showsChevron: Bool = true, showsChevron: Bool = true,
showsDivider: Bool = true showsDivider: Bool = true
@@ -150,6 +175,7 @@ final class SettingMenuRow: UIControl {
setupUI() setupUI()
setupConstraints() setupConstraints()
titleLabel.text = title titleLabel.text = title
titleLabel.textColor = titleColor
valueLabel.text = value valueLabel.text = value
valueLabel.textColor = valueColor valueLabel.textColor = valueColor
chevronImageView.isHidden = !showsChevron chevronImageView.isHidden = !showsChevron
@@ -175,7 +201,6 @@ final class SettingMenuRow: UIControl {
private func setupUI() { private func setupUI() {
titleLabel.font = .systemFont(ofSize: 14) titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
valueLabel.font = .systemFont(ofSize: 14) valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary valueLabel.textColor = AppColor.textPrimary
@@ -22,8 +22,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
private let freeCountField = UITextField() private let freeCountField = UITextField()
private let singlePriceField = UITextField() private let singlePriceField = UITextField()
private let packagePriceField = UITextField() private let packagePriceField = UITextField()
private let autoRetouchSectionView = UIView()
private let autoRetouchTitleLabel = UILabel()
private let autoRetouchDetailLabel = UILabel()
private let noRetouchOption = TravelAlbumModeOptionView()
private let aiRetouchOption = TravelAlbumModeOptionView()
private let cancelButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system)
private var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) { init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
self.viewModel = viewModel self.viewModel = viewModel
@@ -31,8 +37,16 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet modalPresentationStyle = .pageSheet
if let sheetPresentationController { if let sheetPresentationController {
sheetPresentationController.detents = [.medium(), .large()] let formDetent = UISheetPresentationController.Detent.Identifier("createTravelAlbumForm")
sheetPresentationController.detents = [
.custom(identifier: formDetent) { context in
min(590, context.maximumDetentValue)
},
.large(),
]
sheetPresentationController.selectedDetentIdentifier = formDetent
sheetPresentationController.prefersGrabberVisible = false sheetPresentationController.prefersGrabberVisible = false
sheetPresentationController.preferredCornerRadius = AppRadius.xl
} }
} }
@@ -62,6 +76,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad) configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad) configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad) configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
configureAutoRetouchSection()
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary) configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white) configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
@@ -112,6 +127,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
[freeCountField, singlePriceField, packagePriceField].forEach { [freeCountField, singlePriceField, packagePriceField].forEach {
$0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged) $0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged)
} }
noRetouchOption.addTarget(self, action: #selector(noRetouchTapped), for: .touchUpInside)
aiRetouchOption.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
} }
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {
@@ -148,6 +165,45 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
fieldsStack.addArrangedSubview(autoRetouchSectionView)
}
private func configureAutoRetouchSection() {
autoRetouchSectionView.backgroundColor = .white
autoRetouchSectionView.layer.cornerRadius = AppRadius.sm
autoRetouchSectionView.layer.borderWidth = 1
autoRetouchSectionView.layer.borderColor = AppColor.border.cgColor
autoRetouchSectionView.clipsToBounds = true
autoRetouchTitleLabel.text = "修图方式"
autoRetouchTitleLabel.font = .app(.bodyMedium)
autoRetouchTitleLabel.textColor = AppColor.textPrimary
autoRetouchDetailLabel.text = "选择 AI 修图后,需要再选一个效果模板"
autoRetouchDetailLabel.font = .app(.caption)
autoRetouchDetailLabel.textColor = AppColor.textSecondary
let optionsStack = UIStackView(arrangedSubviews: [noRetouchOption, aiRetouchOption])
optionsStack.axis = .horizontal
optionsStack.spacing = AppSpacing.xs
optionsStack.distribution = .fillEqually
autoRetouchSectionView.addSubview(autoRetouchTitleLabel)
autoRetouchSectionView.addSubview(autoRetouchDetailLabel)
autoRetouchSectionView.addSubview(optionsStack)
autoRetouchTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
autoRetouchDetailLabel.snp.makeConstraints { make in
make.top.equalTo(autoRetouchTitleLabel.snp.bottom).offset(AppSpacing.xxs)
make.leading.trailing.equalTo(autoRetouchTitleLabel)
}
optionsStack.snp.makeConstraints { make in
make.top.equalTo(autoRetouchDetailLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.sm)
make.height.equalTo(72)
}
updateAutoRetouchSection()
} }
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView { private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
@@ -188,6 +244,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
freeCount: freeCountField.text ?? "", freeCount: freeCountField.text ?? "",
singlePrice: singlePriceField.text ?? "", singlePrice: singlePriceField.text ?? "",
packagePrice: packagePriceField.text ?? "", packagePrice: packagePriceField.text ?? "",
autoRetouchConfiguration: autoRetouchConfiguration,
order: nil, order: nil,
api: api api: api
) )
@@ -208,6 +265,43 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
} }
} }
@objc private func noRetouchTapped() {
autoRetouchConfiguration = .disabled
updateAutoRetouchSection()
}
@objc private func aiRetouchTapped() {
presentTemplatePicker()
}
private func presentTemplatePicker() {
guard presentedViewController == nil else { return }
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
configuration: autoRetouchConfiguration,
startsWithModeSelection: false
)
controller.onConfirm = { [weak self] configuration in
guard let self else { return }
self.autoRetouchConfiguration = configuration
self.updateAutoRetouchSection()
}
present(controller, animated: true)
}
private func updateAutoRetouchSection() {
let isEnabled = autoRetouchConfiguration.isEnabled
noRetouchOption.apply(
title: TravelAlbumRetouchMode.disabled.title,
desc: "保留原图",
selected: !isEnabled
)
aiRetouchOption.apply(
title: TravelAlbumRetouchMode.aiRetouch.title,
desc: autoRetouchConfiguration.template?.title ?? "点击选模板",
selected: isEnabled
)
}
private func sanitizeMoneyInput(_ value: String) -> String { private func sanitizeMoneyInput(_ value: String) -> String {
var result = "" var result = ""
var hasDot = false var hasDot = false
@@ -0,0 +1,416 @@
//
// TravelAlbumAutoRetouchSettingSheetViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 自动 AI 修图设置 Sheet,先选修图方式,选择 AI 修图后再选带效果图的模板。
final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController, UITableViewDelegate {
private enum Step: Equatable {
case mode
case template
}
private enum Item: Hashable {
case mode(TravelAlbumRetouchMode)
case template(TravelAlbumEditPreset)
}
var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)?
var onCancelled: (() -> Void)?
private let startsWithModeSelection: Bool
private let backButton = UIButton(type: .system)
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let tableView = UITableView(frame: .zero, style: .plain)
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private var dataSource: UITableViewDiffableDataSource<Int, Item>!
private var step: Step
private var pendingConfiguration: TravelAlbumAutoRetouchConfiguration
private var selectedItem: Item?
private var previewImages: [TravelAlbumEditPreset.Effect: UIImage] = [:]
private var didConfirm = false
/// 创建自动修图设置 Sheet。
/// - Parameters:
/// - configuration: 当前配置,用于回显已选方式和模板。
/// - startsWithModeSelection: 是否先展示“不修图 / AI 修图”两个一级选项。
init(
configuration: TravelAlbumAutoRetouchConfiguration,
startsWithModeSelection: Bool
) {
self.startsWithModeSelection = startsWithModeSelection
step = startsWithModeSelection ? .mode : .template
pendingConfiguration = configuration
if startsWithModeSelection {
selectedItem = .mode(configuration.isEnabled ? .aiRetouch : .disabled)
} else if let template = configuration.template {
selectedItem = .template(template)
}
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
if let sheetPresentationController {
let identifier = UISheetPresentationController.Detent.Identifier("travelAlbumAutoRetouchSetting")
sheetPresentationController.detents = [
.custom(identifier: identifier) { context in
min(660, context.maximumDetentValue)
},
.large(),
]
sheetPresentationController.selectedDetentIdentifier = identifier
sheetPresentationController.prefersGrabberVisible = false
sheetPresentationController.preferredCornerRadius = AppRadius.xl
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {}
override func setupUI() {
view.backgroundColor = .white
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.tintColor = AppColor.textPrimary
backButton.accessibilityLabel = "返回修图方式"
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
subtitleLabel.font = .app(.caption)
subtitleLabel.textColor = AppColor.textSecondary
subtitleLabel.textAlignment = .center
subtitleLabel.numberOfLines = 0
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.showsVerticalScrollIndicator = false
tableView.delegate = self
tableView.register(
TravelAlbumAutoRetouchOptionCell.self,
forCellReuseIdentifier: TravelAlbumAutoRetouchOptionCell.reuseIdentifier
)
dataSource = UITableViewDiffableDataSource<Int, Item>(tableView: tableView) {
[weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
withIdentifier: TravelAlbumAutoRetouchOptionCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAutoRetouchOptionCell
guard let self else { return cell }
switch item {
case .mode(let mode):
let iconName = mode == .disabled ? "photo" : "wand.and.stars"
let detail = mode == .disabled
? "保留照片原效果"
: (self.pendingConfiguration.template.map { "当前模板:\($0.title)" } ?? "选择后需再选一个修图模板")
cell.apply(
title: mode.title,
detail: detail,
previewImage: UIImage(systemName: iconName),
usesTemplateImage: true,
selected: self.selectedItem == item
)
case .template(let preset):
cell.apply(
title: preset.title,
detail: preset.autoRetouchEffectDescription,
previewImage: self.previewImage(for: preset),
usesTemplateImage: false,
selected: self.selectedItem == item
)
}
return cell
}
configureActionButton(cancelButton, title: "取消", isPrimary: false)
configureActionButton(confirmButton, title: "确定", isPrimary: true)
let buttonStack = UIStackView(arrangedSubviews: [cancelButton, confirmButton])
buttonStack.axis = .horizontal
buttonStack.spacing = AppSpacing.sm
buttonStack.distribution = .fillEqually
view.addSubview(backButton)
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(tableView)
view.addSubview(buttonStack)
backButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.md)
make.centerY.equalTo(titleLabel)
make.size.equalTo(AppSpacing.minTouchTarget)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.lg)
make.leading.trailing.equalToSuperview().inset(60)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.lg)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalTo(buttonStack.snp.top).offset(-AppSpacing.sm)
}
buttonStack.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-AppSpacing.md)
make.height.equalTo(AppSpacing.minTouchTarget)
}
refreshContent()
}
override func bindActions() {
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isBeingDismissed, !didConfirm {
onCancelled?()
}
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
step == .mode ? 80 : 108
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .mode(.disabled):
pendingConfiguration = .disabled
selectedItem = item
applySnapshot()
updateConfirmButton()
case .mode(.aiRetouch):
step = .template
selectedItem = pendingConfiguration.template.map(Item.template)
refreshContent()
case .template(let preset):
pendingConfiguration = .enabled(templateID: preset.id)
selectedItem = item
applySnapshot()
updateConfirmButton()
}
}
private func refreshContent() {
switch step {
case .mode:
titleLabel.text = "选择修图方式"
subtitleLabel.text = "照片上传前可选择保留原图,或使用 AI 自动修图"
backButton.isHidden = true
case .template:
titleLabel.text = "选择修图模板"
subtitleLabel.text = "缩略图为模板实际效果,后续上传的照片将自动套用"
backButton.isHidden = !startsWithModeSelection
}
applySnapshot()
updateConfirmButton()
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Int, Item>()
snapshot.appendSections([0])
switch step {
case .mode:
snapshot.appendItems(TravelAlbumRetouchMode.allCases.map(Item.mode))
case .template:
snapshot.appendItems(TravelAlbumEditPreset.autoRetouchOptions.map(Item.template))
}
// 选中状态存放在页面状态中,item 本身的标识不会变化。
// Diffable Data Source 不会主动重新配置相同 item,因此需显式刷新仍在当前列表中的选项。
let currentItems = Set(dataSource.snapshot().itemIdentifiers)
let retainedItems = snapshot.itemIdentifiers.filter(currentItems.contains)
snapshot.reconfigureItems(retainedItems)
dataSource.apply(snapshot, animatingDifferences: false)
}
private func updateConfirmButton() {
let isEnabled: Bool
switch selectedItem {
case .mode(.disabled):
isEnabled = step == .mode
case .mode(.aiRetouch):
isEnabled = step == .mode && pendingConfiguration.isEnabled && pendingConfiguration.isValid
case .template:
isEnabled = step == .template && pendingConfiguration.isEnabled && pendingConfiguration.isValid
case nil:
isEnabled = false
}
confirmButton.isEnabled = isEnabled
confirmButton.alpha = isEnabled ? 1 : 0.45
}
private func configureActionButton(_ button: UIButton, title: String, isPrimary: Bool) {
button.setTitle(title, for: .normal)
button.setTitleColor(isPrimary ? .white : AppColor.textSecondary, for: .normal)
button.titleLabel?.font = .app(.subtitle)
button.backgroundColor = isPrimary ? AppColor.primary : AppColor.inputBackground
button.layer.cornerRadius = AppRadius.md
}
private func previewImage(for preset: TravelAlbumEditPreset) -> UIImage? {
if let image = previewImages[preset.effect] {
return image
}
guard let source = makePreviewSourceImage() else { return nil }
let image = TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: source)
previewImages[preset.effect] = image
return image
}
private func makePreviewSourceImage() -> UIImage? {
guard let source = UIImage(named: "purchased_lakeside_flowers") else { return nil }
let size = CGSize(width: 176, height: 176)
return UIGraphicsImageRenderer(size: size).image { _ in
let scale = max(size.width / source.size.width, size.height / source.size.height)
let drawSize = CGSize(width: source.size.width * scale, height: source.size.height * scale)
let origin = CGPoint(x: (size.width - drawSize.width) / 2, y: (size.height - drawSize.height) / 2)
source.draw(in: CGRect(origin: origin, size: drawSize))
}
}
@objc private func backTapped() {
guard startsWithModeSelection else { return }
step = .mode
selectedItem = .mode(pendingConfiguration.isEnabled ? .aiRetouch : .disabled)
refreshContent()
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
@objc private func confirmTapped() {
guard confirmButton.isEnabled else { return }
didConfirm = true
onConfirm?(pendingConfiguration)
dismiss(animated: true)
}
}
/// 自动修图选项行,一级方式显示图标,模板显示本地生成的真实效果缩略图。
private final class TravelAlbumAutoRetouchOptionCell: UITableViewCell {
static let reuseIdentifier = "TravelAlbumAutoRetouchOptionCell"
private let cardView = UIView()
private let previewImageView = UIImageView()
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let selectionImageView = UIImageView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
cardView.layer.cornerRadius = AppRadius.sm
cardView.layer.borderWidth = 1
previewImageView.clipsToBounds = true
previewImageView.layer.cornerRadius = AppRadius.xs
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = AppColor.textPrimary
detailLabel.font = .app(.caption)
detailLabel.textColor = AppColor.textTertiary
detailLabel.numberOfLines = 2
selectionImageView.contentMode = .scaleAspectFit
contentView.addSubview(cardView)
cardView.addSubview(previewImageView)
cardView.addSubview(titleLabel)
cardView.addSubview(detailLabel)
cardView.addSubview(selectionImageView)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
}
previewImageView.snp.makeConstraints { make in
make.top.bottom.leading.equalToSuperview().inset(8)
make.width.equalTo(88)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(previewImageView.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualTo(selectionImageView.snp.leading).offset(-AppSpacing.sm)
make.bottom.equalTo(cardView.snp.centerY).offset(-2)
}
detailLabel.snp.makeConstraints { make in
make.top.equalTo(cardView.snp.centerY).offset(2)
make.leading.trailing.equalTo(titleLabel)
}
selectionImageView.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-AppSpacing.md)
make.centerY.equalToSuperview()
make.size.equalTo(22)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 刷新选项文案、效果图与单选外观。
func apply(
title: String,
detail: String,
previewImage: UIImage?,
usesTemplateImage: Bool,
selected: Bool
) {
titleLabel.text = title
detailLabel.text = detail
previewImageView.image = usesTemplateImage
? previewImage?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 28, weight: .medium))
: previewImage
previewImageView.contentMode = usesTemplateImage ? .center : .scaleAspectFill
previewImageView.tintColor = AppColor.primary
previewImageView.backgroundColor = usesTemplateImage ? AppColor.primaryLight : AppColor.pageBackground
cardView.backgroundColor = selected ? AppColor.primaryLight : .white
cardView.layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor
selectionImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionImageView.tintColor = selected ? AppColor.primary : AppColor.textTertiary
accessibilityLabel = "\(title),\(detail)"
accessibilityValue = selected ? "已选择" : "未选择"
}
}
private extension TravelAlbumEditPreset {
/// 模板效果的简短说明,与缩略图一起帮助用户判断效果。
var autoRetouchEffectDescription: String {
switch effect {
case .original:
return "保留原图色彩"
case .portrait:
return "明亮柔和的人像质感"
case .vintage:
return "低饱和复古色调"
case .brocade:
return "鲜活通透的旅拍风格"
case .distantMountain:
return "自然淡雅的远山色调"
case .mist:
return "轻雾低对比氛围"
case .summer:
return "温暖明亮的油画色彩"
case .rich:
return "高饱和浓郁油画质感"
}
}
}
@@ -11,6 +11,9 @@ import UIKit
final class TravelAlbumDetailViewController: BaseViewController { final class TravelAlbumDetailViewController: BaseViewController {
private let viewModel: TravelAlbumDetailViewModel private let viewModel: TravelAlbumDetailViewModel
private let api: any TravelAlbumServing private let api: any TravelAlbumServing
private let aiEditResultStore = TravelAlbumAIEditResultStore.shared
private var pendingAIEditMaterialIDs: Set<Int> = []
private var failedAIEditCount = 0
private let scrollContainer = UIView() private let scrollContainer = UIView()
private let infoCard = TravelAlbumInfoCard() private let infoCard = TravelAlbumInfoCard()
@@ -24,9 +27,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>! private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
private let bottomBar = UIView() private let bottomBar = UIView()
private let bottomActionStack = UIStackView() private let bottomActionStack = UIStackView()
private let aiEditSelectedButton = UIButton(type: .system)
private let deleteSelectedButton = UIButton(type: .system) private let deleteSelectedButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system) private let uploadButton = UIButton(type: .system)
private var isDeleteSelectedButtonVisible = false
init(albumId: Int, api: (any TravelAlbumServing)? = nil) { init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId) viewModel = TravelAlbumDetailViewModel(albumId: albumId)
@@ -41,6 +44,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
override func setupNavigationBar() { override func setupNavigationBar() {
title = "相册管理" title = "相册管理"
navigationItem.backButtonDisplayMode = .minimal
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"), image: UIImage(systemName: "ellipsis"),
menu: UIMenu(children: [ menu: UIMenu(children: [
@@ -78,21 +82,39 @@ final class TravelAlbumDetailViewController: BaseViewController {
withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier, withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier,
for: indexPath for: indexPath
) as! TravelAlbumMaterialCell ) as! TravelAlbumMaterialCell
let aiEditState = self?.aiEditResultStore.state(for: material.id)
?? TravelAlbumAIEditResultState()
let resultImage = aiEditState.isCover
? (self?.aiEditResultStore.coverImage(for: material.id)
?? self?.aiEditResultStore.editedImage(for: material.id))
: self?.aiEditResultStore.editedImage(for: material.id)
cell.apply( cell.apply(
material: material, material: material,
selectionMode: self?.viewModel.isSelectionMode == true, selectionMode: self?.viewModel.isSelectionMode == true,
selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true,
aiEditState: aiEditState,
editedImage: resultImage
) )
return cell return cell
} }
bottomBar.backgroundColor = AppColor.pageBackground bottomBar.backgroundColor = AppColor.pageBackground
bottomActionStack.axis = .vertical bottomActionStack.axis = .horizontal
bottomActionStack.spacing = 8 bottomActionStack.spacing = 12
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935)) bottomActionStack.distribution = .fillEqually
configureSelectionActionButton(
aiEditSelectedButton,
title: "AI修图",
image: UIImage(named: "travel_album_preview_ai_edit")?.withRenderingMode(.alwaysTemplate)
)
configureSelectionActionButton(
deleteSelectedButton,
title: "删除",
image: UIImage(named: "travel_album_preview_delete")?.withRenderingMode(.alwaysTemplate)
)
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary) configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
aiEditSelectedButton.isHidden = true
deleteSelectedButton.isHidden = true deleteSelectedButton.isHidden = true
deleteSelectedButton.alpha = 0
view.addSubview(scrollContainer) view.addSubview(scrollContainer)
scrollContainer.addSubview(infoCard) scrollContainer.addSubview(infoCard)
@@ -105,6 +127,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
manageCard.addSubview(collectionView) manageCard.addSubview(collectionView)
view.addSubview(bottomBar) view.addSubview(bottomBar)
bottomBar.addSubview(bottomActionStack) bottomBar.addSubview(bottomActionStack)
bottomActionStack.addArrangedSubview(aiEditSelectedButton)
bottomActionStack.addArrangedSubview(deleteSelectedButton) bottomActionStack.addArrangedSubview(deleteSelectedButton)
bottomActionStack.addArrangedSubview(uploadButton) bottomActionStack.addArrangedSubview(uploadButton)
} }
@@ -118,11 +141,10 @@ final class TravelAlbumDetailViewController: BaseViewController {
make.leading.trailing.equalToSuperview().inset(20) make.leading.trailing.equalToSuperview().inset(20)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
} }
deleteSelectedButton.snp.makeConstraints { make in [aiEditSelectedButton, deleteSelectedButton, uploadButton].forEach { button in
make.height.equalTo(48).priority(.high) button.snp.makeConstraints { make in
} make.height.equalTo(58).priority(.high)
uploadButton.snp.makeConstraints { make in }
make.height.equalTo(48).priority(.high)
} }
scrollContainer.snp.makeConstraints { make in scrollContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12) make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
@@ -163,8 +185,15 @@ final class TravelAlbumDetailViewController: BaseViewController {
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside) purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside) sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside)
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside) selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
aiEditSelectedButton.addTarget(self, action: #selector(aiEditSelectedTapped), for: .touchUpInside)
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside) deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
NotificationCenter.default.addObserver(
self,
selector: #selector(aiEditResultDidChange(_:)),
name: TravelAlbumAIEditResultStore.didChangeNotification,
object: aiEditResultStore
)
viewModel.onStateChange = { [weak self] in viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() } Task { @MainActor in self?.applyViewModel() }
} }
@@ -195,8 +224,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
selectButton.isHidden = viewModel.selectedTab != .all selectButton.isHidden = viewModel.selectedTab != .all
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal) selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal)
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary
deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal) updateBottomToolbar()
setDeleteSelectedButtonVisible(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>() var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
snapshot.appendSections([0]) snapshot.appendSections([0])
@@ -213,6 +241,28 @@ final class TravelAlbumDetailViewController: BaseViewController {
} }
} }
/// 根据选择数量和修图任务刷新底部操作栏。
private func updateBottomToolbar() {
let toolbarState = TravelAlbumSelectionToolbarState(
isSelectionMode: viewModel.isSelectionMode,
selectedCount: viewModel.selectedMaterialIds.count
)
uploadButton.isHidden = !toolbarState.showsUpload
aiEditSelectedButton.isHidden = !toolbarState.showsSelectionActions
deleteSelectedButton.isHidden = !toolbarState.showsSelectionActions
let selectedContainsProcessingPhoto = viewModel.selectedMaterialIds.contains { materialID in
aiEditResultStore.state(for: materialID).isProcessing
}
let canStartAIEditing = toolbarState.selectionActionsEnabled
&& pendingAIEditMaterialIDs.isEmpty
&& !selectedContainsProcessingPhoto
aiEditSelectedButton.isEnabled = canStartAIEditing
deleteSelectedButton.isEnabled = toolbarState.selectionActionsEnabled
aiEditSelectedButton.alpha = canStartAIEditing ? 1 : 0.4
deleteSelectedButton.alpha = toolbarState.selectionActionsEnabled ? 1 : 0.4
bottomBar.backgroundColor = toolbarState.showsSelectionActions ? .white : AppColor.pageBackground
}
private func configurePillButton(_ button: UIButton) { private func configurePillButton(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
button.layer.cornerRadius = 16 button.layer.cornerRadius = 16
@@ -242,27 +292,20 @@ final class TravelAlbumDetailViewController: BaseViewController {
button.layer.cornerRadius = 10 button.layer.cornerRadius = 10
} }
private func setDeleteSelectedButtonVisible(_ visible: Bool) { /// 配置多选模式底部的图标操作按钮,图标复用照片预览页的 Iconfont 资源。
guard visible != isDeleteSelectedButtonVisible else { return } private func configureSelectionActionButton(_ button: UIButton, title: String, image: UIImage?) {
isDeleteSelectedButtonVisible = visible var configuration = UIButton.Configuration.plain()
configuration.title = title
let changes = { configuration.image = image
self.deleteSelectedButton.isHidden = !visible configuration.imagePlacement = .top
self.deleteSelectedButton.alpha = visible ? 1 : 0 configuration.imagePadding = 5
self.view.layoutIfNeeded() configuration.baseForegroundColor = AppColor.textSecondary
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 13, weight: .medium)
return attributes
} }
guard view.window != nil else { button.configuration = configuration
changes()
return
}
view.layoutIfNeeded()
UIView.animate(
withDuration: 0.25,
delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: changes
)
} }
private func makeLayout() -> UICollectionViewCompositionalLayout { private func makeLayout() -> UICollectionViewCompositionalLayout {
@@ -319,6 +362,157 @@ final class TravelAlbumDetailViewController: BaseViewController {
viewModel.toggleSelectionMode() viewModel.toggleSelectionMode()
} }
@objc private func aiEditSelectedTapped() {
let selectedMaterials = viewModel.materials.filter { viewModel.selectedMaterialIds.contains($0.id) }
guard let firstMaterial = selectedMaterials.first else {
showToast("请选择要修图的照片")
return
}
let imageURLString = firstMaterial.coverUrl.isEmpty ? firstMaterial.fileUrl : firstMaterial.coverUrl
guard let imageURL = URL(string: imageURLString), !imageURLString.isEmpty else {
presentAIEditSheet(sourceImage: nil, selectedMaterials: selectedMaterials)
return
}
KingfisherManager.shared.retrieveImage(with: imageURL) { [weak self] result in
guard let self else { return }
Task { @MainActor in
let sourceImage: UIImage?
if case let .success(value) = result {
sourceImage = value.image
} else {
sourceImage = nil
}
self.presentAIEditSheet(sourceImage: sourceImage, selectedMaterials: selectedMaterials)
}
}
}
/// 复用单张照片预览页的 AI 修图预设弹窗完成批量选择演示。
private func presentAIEditSheet(
sourceImage: UIImage?,
selectedMaterials: [TravelAlbumMaterial]
) {
let controller = TravelAlbumAIEditSheetViewController(
sourceImage: sourceImage,
selectedPhotoCount: selectedMaterials.count
)
controller.onConfirm = { [weak self] selection in
self?.startBatchAIEditing(
materials: selectedMaterials,
selection: selection
)
}
present(controller, animated: true)
}
/// 启动批量本地演示修图,并在全部照片完成前禁用 AI 修图入口。
private func startBatchAIEditing(
materials: [TravelAlbumMaterial],
selection: TravelAlbumAIEditSelection
) {
guard pendingAIEditMaterialIDs.isEmpty, !materials.isEmpty else { return }
let materialIDs = materials.map(\.id)
let coverMaterialID = selection.coverTemplate == nil ? nil : materials.first?.id
pendingAIEditMaterialIDs = Set(materialIDs)
failedAIEditCount = 0
aiEditResultStore.startProcessing(materialIDs: materialIDs)
applyViewModel()
materials.forEach { material in
let imageURLString = material.fileUrl.isEmpty ? material.coverUrl : material.fileUrl
guard let imageURL = URL(string: imageURLString), !imageURLString.isEmpty else {
finishAIEditing(
materialID: material.id,
selection: selection,
appliedCoverTemplate: material.id == coverMaterialID ? selection.coverTemplate : nil,
sourceImage: nil
)
return
}
KingfisherManager.shared.retrieveImage(with: imageURL) { [weak self] result in
guard let self else { return }
Task { @MainActor in
let sourceImage: UIImage?
if case let .success(value) = result {
sourceImage = value.image
} else {
sourceImage = nil
}
self.finishAIEditing(
materialID: material.id,
selection: selection,
appliedCoverTemplate: material.id == coverMaterialID ? selection.coverTemplate : nil,
sourceImage: sourceImage
)
}
}
}
}
/// 完成单张批量任务;最后一张结束后恢复入口并反馈整体结果。
private func finishAIEditing(
materialID: Int,
selection: TravelAlbumAIEditSelection,
appliedCoverTemplate: TravelAlbumCoverTemplate?,
sourceImage: UIImage?
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
guard let self else { return }
if let sourceImage {
let refinedImage = TravelAlbumAIEditImageProcessor.render(
effect: selection.refinedPreset.effect,
source: sourceImage
)
let atmosphereImage = selection.atmosphereOption.map { option in
TravelAlbumAIEditImageProcessor.render(effect: option.effect, source: sourceImage)
}
let coverImage = appliedCoverTemplate.map { template in
TravelAlbumCoverTemplateImageProcessor.render(
template: template,
source: refinedImage
)
}
if selection.refinedPreset.effect == .original,
selection.atmosphereOption == nil,
appliedCoverTemplate == nil {
self.aiEditResultStore.restoreOriginal(materialID: materialID)
} else {
self.aiEditResultStore.complete(
materialID: materialID,
presetID: selection.refinedPreset.id,
atmosphereOptionID: selection.atmosphereOption?.id,
coverTemplateID: appliedCoverTemplate?.id,
editedImage: refinedImage,
atmosphereImage: atmosphereImage,
coverImage: coverImage
)
}
} else {
self.aiEditResultStore.failProcessing(materialID: materialID)
self.failedAIEditCount += 1
}
self.pendingAIEditMaterialIDs.remove(materialID)
self.applyViewModel()
if self.pendingAIEditMaterialIDs.isEmpty {
let successMessage: String
if let coverTemplate = selection.coverTemplate,
selection.atmosphereOption != nil {
successMessage = "AI修图完成,每张生成 2 个结果,并生成「\(coverTemplate.title)」封面"
} else if let coverTemplate = selection.coverTemplate {
successMessage = "AI修图完成,已生成「\(coverTemplate.title)」封面"
} else if selection.atmosphereOption != nil {
successMessage = "AI修图完成,每张已生成 2 个结果"
} else {
successMessage = "AI修图完成"
}
let message = self.failedAIEditCount == 0
? successMessage
: "有 \(self.failedAIEditCount) 张照片修图失败,请重试"
self.showToast(message)
}
}
}
@objc private func deleteSelectedTapped() { @objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count let count = viewModel.selectedMaterialIds.count
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert) let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
@@ -331,22 +525,60 @@ final class TravelAlbumDetailViewController: BaseViewController {
} }
@objc private func uploadTapped() { @objc private func uploadTapped() {
let controller = TravelAlbumTransferModeSheetViewController()
controller.onModeSelected = { [weak self] mode in
self?.openWiredTransfer(mode: mode)
}
present(controller, animated: true)
}
/// 根据本次选择进入对应传输模式,避免被历史缓存模式覆盖。
private func openWiredTransfer(mode: TravelAlbumOTGTransferMode) {
let album = viewModel.album let album = viewModel.album
let controller = WiredCameraTransferViewController( let controller = WiredCameraTransferViewController(
viewModel: WiredCameraTransferViewModel( viewModel: WiredCameraTransferViewModel(
albumId: album?.id ?? viewModel.albumId, albumId: album?.id ?? viewModel.albumId,
albumTitle: album?.name ?? "", albumTitle: album?.name ?? "",
headerPhone: album?.displayPhone ?? "" headerPhone: album?.displayPhone ?? "",
initialTransferMode: mode
) )
) )
navigationController?.pushViewController(controller, animated: true) navigationController?.pushViewController(controller, animated: true)
} }
/// 仅刷新状态发生变化的缩略图,及时呈现修图结果和任务角标。
@objc private func aiEditResultDidChange(_ notification: Notification) {
guard let materialID = notification.userInfo?[TravelAlbumAIEditResultStore.materialIDUserInfoKey] as? Int,
let material = dataSource.snapshot().itemIdentifiers.first(where: { $0.id == materialID }) else {
return
}
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([material])
dataSource.apply(snapshot, animatingDifferences: true)
updateBottomToolbar()
}
} }
extension TravelAlbumDetailViewController: UICollectionViewDelegate { extension TravelAlbumDetailViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let material = dataSource.itemIdentifier(for: indexPath) else { return } guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
viewModel.toggleMaterialSelection(material) if viewModel.isSelectionMode {
viewModel.toggleMaterialSelection(material)
} else {
// 非多选状态进入预览,避免影响原有批量删除流程。
let initialIndex = viewModel.materials.firstIndex(where: { $0.id == material.id }) ?? 0
let controller = TravelAlbumPhotoPreviewViewController(
materials: viewModel.materials,
initialIndex: initialIndex,
api: api
)
controller.onDeleted = { [weak self] in
guard let self else { return }
Task { await self.viewModel.refreshAll(api: self.api) }
}
// 照片与修图样式、封面模板统一走全屏预览,避免系统导航栏重复出现。
present(controller, animated: true)
}
} }
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {
@@ -499,19 +731,70 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) { override func prepareForReuse() {
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl super.prepareForReuse()
if let url = URL(string: urlString), !urlString.isEmpty { imageView.kf.cancelDownloadTask()
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo")) imageView.image = nil
}
func apply(
material: TravelAlbumMaterial,
selectionMode: Bool,
selected: Bool,
aiEditState: TravelAlbumAIEditResultState,
editedImage: UIImage?
) {
imageView.backgroundColor = .clear
imageView.tintColor = nil
if aiEditState.hasEditedResult, let editedImage {
imageView.kf.cancelDownloadTask()
imageView.image = editedImage
} else if let assetName = material.bundledImageAssetName,
let bundledImage = UIImage(named: assetName) {
imageView.kf.cancelDownloadTask()
imageView.image = bundledImage
} else { } else {
imageView.image = UIImage(systemName: "photo") let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
imageView.tintColor = AppColor.primary if let url = URL(string: urlString), !urlString.isEmpty {
imageView.backgroundColor = UIColor(hex: 0xEAF2FF) imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
} else {
imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.primary
imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
}
} }
applyAIEditStatus(aiEditState.thumbnailStatus, isPurchased: material.isPurchased)
checkImageView.isHidden = !selectionMode checkImageView.isHidden = !selectionMode
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkImageView.tintColor = selected ? AppColor.primary : .white checkImageView.tintColor = selected ? AppColor.primary : .white
nameLabel.text = material.fileName nameLabel.text = material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize) sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
} }
/// 复用左上角状态位,让用户无需进入预览即可识别照片是否完成 AI 修图。
private func applyAIEditStatus(
_ status: TravelAlbumAIEditResultState.ThumbnailStatus,
isPurchased: Bool
) {
let appearance: (title: String, color: UIColor, width: CGFloat)
switch status {
case .none:
appearance = isPurchased
? ("已购", AppColor.primary, 48)
: ("已上传", UIColor(hex: 0x34C759), 48)
case .processing:
appearance = ("修图中", AppColor.primary, 48)
case .edited:
appearance = ("AI已修", UIColor(hex: 0x6750A4), 48)
case .cover:
appearance = ("AI封面", UIColor(hex: 0xF59E0B), 56)
case .failed:
appearance = ("修图失败", UIColor(hex: 0xE53935), 56)
}
statusLabel.text = appearance.title
statusLabel.backgroundColor = appearance.color
statusLabel.snp.updateConstraints { make in
make.width.equalTo(appearance.width)
}
}
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
//
// TravelAlbumTransferModeSheetViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 相册管理上传入口的传输模式选择弹窗。
final class TravelAlbumTransferModeSheetViewController: UIViewController {
/// 用户选择传输模式后的回调;弹窗关闭后触发。
var onModeSelected: ((TravelAlbumOTGTransferMode) -> Void)?
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let optionsStack = UIStackView()
private let cancelButton = UIButton(type: .system)
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
modalPresentationStyle = .pageSheet
if let sheetPresentationController {
let identifier = UISheetPresentationController.Detent.Identifier("transferMode")
sheetPresentationController.detents = [
.custom(identifier: identifier) { context in
min(356, context.maximumDetentValue)
},
]
sheetPresentationController.selectedDetentIdentifier = identifier
sheetPresentationController.prefersGrabberVisible = true
sheetPresentationController.preferredCornerRadius = 22
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
}
private func setupUI() {
view.backgroundColor = .white
titleLabel.text = "选择传输模式"
titleLabel.font = .systemFont(ofSize: 19, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
subtitleLabel.text = "选择后进入对应的照片传输页面"
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.textSecondary
subtitleLabel.textAlignment = .center
optionsStack.axis = .vertical
optionsStack.spacing = 12
TravelAlbumOTGTransferMode.allCases.enumerated().forEach { index, mode in
optionsStack.addArrangedSubview(makeOptionView(mode: mode, index: index))
}
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
cancelButton.backgroundColor = UIColor(hex: 0xF4F5F7)
cancelButton.layer.cornerRadius = 12
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(optionsStack)
view.addSubview(cancelButton)
}
private func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(14)
make.leading.trailing.equalToSuperview().inset(20)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(7)
make.leading.trailing.equalToSuperview().inset(20)
}
optionsStack.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(16)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(optionsStack.snp.bottom).offset(14)
make.leading.trailing.equalTo(optionsStack)
make.height.equalTo(48)
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-10)
}
}
/// 创建无需二次确认的模式选项,点击后直接进入下一页。
private func makeOptionView(mode: TravelAlbumOTGTransferMode, index: Int) -> UIView {
let container = UIView()
container.backgroundColor = UIColor(hex: 0xF7F9FC)
container.layer.cornerRadius = 12
container.layer.borderWidth = 1
container.layer.borderColor = UIColor(hex: 0xE6ECF5).cgColor
let titleLabel = UILabel()
titleLabel.text = mode.title
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
let detailLabel = UILabel()
detailLabel.text = mode.detailText
detailLabel.font = .systemFont(ofSize: 12)
detailLabel.textColor = AppColor.textSecondary
detailLabel.numberOfLines = 2
let enterLabel = UILabel()
enterLabel.text = "进入"
enterLabel.font = .systemFont(ofSize: 13, weight: .medium)
enterLabel.textColor = AppColor.primary
enterLabel.textAlignment = .right
let button = UIButton(type: .custom)
button.tag = index
button.accessibilityLabel = "\(mode.title),\(mode.detailText)"
button.addTarget(self, action: #selector(modeTapped(_:)), for: .touchUpInside)
container.addSubview(titleLabel)
container.addSubview(detailLabel)
container.addSubview(enterLabel)
container.addSubview(button)
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(13)
make.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(enterLabel.snp.leading).offset(-12)
}
detailLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(5)
make.leading.equalTo(titleLabel)
make.trailing.lessThanOrEqualTo(enterLabel.snp.leading).offset(-12)
make.bottom.lessThanOrEqualToSuperview().offset(-12)
}
enterLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.width.equalTo(34)
}
button.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
container.snp.makeConstraints { make in
make.height.equalTo(78)
}
return container
}
@objc private func modeTapped(_ sender: UIButton) {
guard TravelAlbumOTGTransferMode.allCases.indices.contains(sender.tag) else { return }
let mode = TravelAlbumOTGTransferMode.allCases[sender.tag]
// 先保留回调,避免弹窗关闭释放后无法进入对应模式页面。
let completion = onModeSelected
dismiss(animated: true) {
completion?(mode)
}
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
}
@@ -40,7 +40,7 @@ final class WiredCameraTransferViewController: BaseViewController {
private let refreshButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system)
private let helpLabel = UILabel() private let helpLabel = UILabel()
private let chipsStack = UIStackView() private let chipsStack = UIStackView()
private let retouchButton = WiredTransferSettingChipButton() private let retouchButton = WiredTransferSettingChipButton(showsChevron: true)
private let formatButton = WiredTransferSettingChipButton() private let formatButton = WiredTransferSettingChipButton()
private let modeButton = WiredTransferSettingChipButton(showsChevron: true) private let modeButton = WiredTransferSettingChipButton(showsChevron: true)
private let settingsStatsDivider = UIView() private let settingsStatsDivider = UIView()
@@ -161,7 +161,6 @@ final class WiredCameraTransferViewController: BaseViewController {
[retouchButton, formatButton, modeButton].forEach { [retouchButton, formatButton, modeButton].forEach {
chipsStack.addArrangedSubview($0) chipsStack.addArrangedSubview($0)
} }
retouchButton.isUserInteractionEnabled = false
formatButton.isUserInteractionEnabled = false formatButton.isUserInteractionEnabled = false
settingsStatsDivider.backgroundColor = AppColor.border settingsStatsDivider.backgroundColor = AppColor.border
@@ -387,6 +386,7 @@ final class WiredCameraTransferViewController: BaseViewController {
Task { @MainActor in self?.showToast(message) } Task { @MainActor in self?.showToast(message) }
} }
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
retouchButton.addTarget(self, action: #selector(retouchTapped), for: .touchUpInside)
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside) batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside) historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside)
albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside) albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside)
@@ -567,6 +567,7 @@ final class WiredCameraTransferViewController: BaseViewController {
let selected = viewModel.selectedPhotoIds.contains(item.id) let selected = viewModel.selectedPhotoIds.contains(item.id)
cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected) cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected)
cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) } cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) }
cell.onRetryRetouch = { [weak self] in self?.viewModel.retryAutoRetouch(photoId: item.id) }
cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) } cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) }
} }
@@ -711,6 +712,17 @@ final class WiredCameraTransferViewController: BaseViewController {
viewModel.refreshCameraFiles() viewModel.refreshCameraFiles()
} }
@objc private func retouchTapped() {
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
configuration: viewModel.autoRetouchConfiguration,
startsWithModeSelection: true
)
controller.onConfirm = { [weak self] configuration in
self?.viewModel.updateAutoRetouchConfiguration(configuration)
}
present(controller, animated: true)
}
@objc private func batchTapped() { @objc private func batchTapped() {
viewModel.onBatchUploadButtonClick() viewModel.onBatchUploadButtonClick()
} }
@@ -788,14 +800,23 @@ final class WiredCameraTransferViewController: BaseViewController {
} }
private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) { private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) {
guard let url = item.thumbnailURL else { // 使用当前筛选列表的实际展示顺序,让预览页可连续查看上一张和下一张。
let displayedPhotos = dataSource.snapshot().itemIdentifiers
guard let context = displayedPhotos.previewContext(startingWith: item.id) else {
showToast("暂无可预览图片") showToast("暂无可预览图片")
return return
} }
let previewItem: MediaPreviewItem = url.isFileURL let previewItems = context.photos.compactMap { photo -> MediaPreviewItem? in
? .localImage(url: url) guard let url = photo.thumbnailURL else { return nil }
: MediaPreviewItem(source: .remoteImage(url)) return url.isFileURL
MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0) ? .localImage(url: url)
: MediaPreviewItem(source: .remoteImage(url))
}
MediaPreviewViewController.present(
from: self,
items: previewItems,
startIndex: context.initialIndex
)
} }
} }
@@ -1117,6 +1138,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
private let selectionIconView = UIImageView() private let selectionIconView = UIImageView()
private let imageView = UIImageView() private let imageView = UIImageView()
private let retouchBadgeLabel = UILabel()
private let statusLabel = UILabel() private let statusLabel = UILabel()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let sizeLabel = UILabel() private let sizeLabel = UILabel()
@@ -1125,6 +1147,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
private let separatorView = UIView() private let separatorView = UIView()
var onRetry: (() -> Void)? var onRetry: (() -> Void)?
var onRetryRetouch: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
override init(frame: CGRect) { override init(frame: CGRect) {
@@ -1142,6 +1165,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
imageView.kf.cancelDownloadTask() imageView.kf.cancelDownloadTask()
imageView.image = nil imageView.image = nil
onRetry = nil onRetry = nil
onRetryRetouch = nil
onDelete = nil onDelete = nil
menuButton.menu = nil menuButton.menu = nil
} }
@@ -1169,16 +1193,21 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
titleLabel.alpha = rowAlpha titleLabel.alpha = rowAlpha
sizeLabel.text = item.fileSizeText sizeLabel.text = item.fileSizeText
sizeLabel.alpha = rowAlpha sizeLabel.alpha = rowAlpha
statusLabel.text = statusText(item.status) statusLabel.text = statusText(item)
statusLabel.textColor = statusTextColor(item.status) statusLabel.textColor = statusTextColor(item)
statusLabel.backgroundColor = statusBackgroundColor(item.status) statusLabel.backgroundColor = statusBackgroundColor(item)
progressView.isHidden = item.status != .uploading && item.status != .transferring retouchBadgeLabel.isHidden = item.autoRetouchState != .completed
progressView.progress = Float(item.progress) / 100.0 let isProcessing = item.autoRetouchState == .processing
progressView.isHidden = !isProcessing && item.status != .uploading && item.status != .transferring
progressView.progress = isProcessing ? 0.62 : Float(item.progress) / 100.0
selectionIconView.isHidden = !selectionMode selectionIconView.isHidden = !selectionMode
selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5) selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5)
menuButton.isHidden = selectionMode menuButton.isHidden = selectionMode
menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending) menuButton.menu = makeMenu(
canRetryUpload: item.status == .failed || item.status == .pending,
canRetryRetouch: item.autoRetouchState == .failed
)
updateImageConstraints(selectionMode: selectionMode) updateImageConstraints(selectionMode: selectionMode)
} }
@@ -1188,6 +1217,14 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
imageView.layer.cornerRadius = 6 imageView.layer.cornerRadius = 6
retouchBadgeLabel.text = "修"
retouchBadgeLabel.font = .systemFont(ofSize: 9, weight: .semibold)
retouchBadgeLabel.textColor = .white
retouchBadgeLabel.textAlignment = .center
retouchBadgeLabel.backgroundColor = UIColor(hex: 0x7C3AED)
retouchBadgeLabel.layer.cornerRadius = 8
retouchBadgeLabel.clipsToBounds = true
retouchBadgeLabel.isHidden = true
statusLabel.font = .systemFont(ofSize: 9) statusLabel.font = .systemFont(ofSize: 9)
statusLabel.textAlignment = .center statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 3 statusLabel.layer.cornerRadius = 3
@@ -1210,6 +1247,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
contentView.addSubview(selectionIconView) contentView.addSubview(selectionIconView)
contentView.addSubview(imageView) contentView.addSubview(imageView)
contentView.addSubview(retouchBadgeLabel)
contentView.addSubview(titleLabel) contentView.addSubview(titleLabel)
contentView.addSubview(statusLabel) contentView.addSubview(statusLabel)
contentView.addSubview(sizeLabel) contentView.addSubview(sizeLabel)
@@ -1223,6 +1261,11 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
make.size.equalTo(20) make.size.equalTo(20)
} }
updateImageConstraints(selectionMode: true) updateImageConstraints(selectionMode: true)
retouchBadgeLabel.snp.makeConstraints { make in
make.top.equalTo(imageView).offset(2)
make.trailing.equalTo(imageView).offset(-2)
make.size.equalTo(16)
}
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
make.top.equalTo(imageView).offset(1) make.top.equalTo(imageView).offset(1)
make.leading.equalTo(imageView.snp.trailing).offset(8) make.leading.equalTo(imageView.snp.trailing).offset(8)
@@ -1257,15 +1300,19 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
} }
} }
private func makeMenu(canRetry: Bool) -> UIMenu { private func makeMenu(canRetryUpload: Bool, canRetryRetouch: Bool) -> UIMenu {
let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in
self?.onRetry?() self?.onRetry?()
} }
retry.attributes = canRetry ? [] : [.disabled] retry.attributes = canRetryUpload ? [] : [.disabled]
let retryRetouch = UIAction(title: "重新修图", image: UIImage(systemName: "wand.and.stars")) { [weak self] _ in
self?.onRetryRetouch?()
}
retryRetouch.attributes = canRetryRetouch ? [] : [.disabled]
let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
self?.onDelete?() self?.onDelete?()
} }
return UIMenu(children: [retry, delete]) return UIMenu(children: [retry, retryRetouch, delete])
} }
private func updateImageConstraints(selectionMode: Bool) { private func updateImageConstraints(selectionMode: Bool) {
@@ -1281,8 +1328,13 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
} }
} }
private func statusText(_ status: TravelAlbumOTGUploadStatus) -> String { private func statusText(_ item: TravelAlbumOTGPhotoItem) -> String {
switch status { switch item.autoRetouchState {
case .processing: return "修图中"
case .failed: return "修图失败"
case .none, .completed: break
}
switch item.status {
case .pending: return "待上传" case .pending: return "待上传"
case .transferring: return "传输中" case .transferring: return "传输中"
case .uploading: return "上传中" case .uploading: return "上传中"
@@ -1291,8 +1343,10 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
} }
} }
private func statusTextColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { private func statusTextColor(_ item: TravelAlbumOTGPhotoItem) -> UIColor {
switch status { if item.autoRetouchState == .processing { return AppColor.primary }
if item.autoRetouchState == .failed { return AppColor.danger }
switch item.status {
case .pending: return AppColor.textSecondary case .pending: return AppColor.textSecondary
case .transferring, .uploading: return AppColor.primary case .transferring, .uploading: return AppColor.primary
case .uploaded: return UIColor(hex: 0x16A34A) case .uploaded: return UIColor(hex: 0x16A34A)
@@ -1300,8 +1354,10 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
} }
} }
private func statusBackgroundColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { private func statusBackgroundColor(_ item: TravelAlbumOTGPhotoItem) -> UIColor {
switch status { if item.autoRetouchState == .processing { return AppColor.primary.withAlphaComponent(0.12) }
if item.autoRetouchState == .failed { return AppColor.danger.withAlphaComponent(0.12) }
switch item.status {
case .pending: case .pending:
return AppColor.pageBackground return AppColor.pageBackground
case .transferring, .uploading: case .transferring, .uploading:
@@ -21,6 +21,11 @@ final class WiredTransferSettingChipButton: UIButton {
/// 当前展示的标题文案。 /// 当前展示的标题文案。
private(set) var titleText: String = "" private(set) var titleText: String = ""
/// 当前值的展示颜色,供状态测试确认不可交互时仍保持清晰。
var displayedTitleColor: UIColor? {
titleLabelView.textColor
}
/// 创建设置 chip 按钮。 /// 创建设置 chip 按钮。
/// - Parameter showsChevron: 是否展示下拉箭头,默认不展示。 /// - Parameter showsChevron: 是否展示下拉箭头,默认不展示。
init(showsChevron: Bool = false) { init(showsChevron: Bool = false) {
@@ -49,13 +54,14 @@ final class WiredTransferSettingChipButton: UIButton {
layer.borderColor = AppColor.border.cgColor layer.borderColor = AppColor.border.cgColor
titleLabelView.font = .systemFont(ofSize: 11) titleLabelView.font = .systemFont(ofSize: 11)
titleLabelView.textColor = AppColor.textTertiary // 当前设置值即使不可点击也代表已经生效,使用主文字色避免呈现为禁用状态。
titleLabelView.textColor = AppColor.textPrimary
titleLabelView.lineBreakMode = .byTruncatingTail titleLabelView.lineBreakMode = .byTruncatingTail
titleLabelView.isUserInteractionEnabled = false titleLabelView.isUserInteractionEnabled = false
chevronView.image = UIImage(systemName: "chevron.down")? chevronView.image = UIImage(systemName: "chevron.down")?
.withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold)) .withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold))
chevronView.tintColor = AppColor.textTertiary chevronView.tintColor = AppColor.textSecondary
chevronView.contentMode = .scaleAspectFit chevronView.contentMode = .scaleAspectFit
chevronView.isUserInteractionEnabled = false chevronView.isUserInteractionEnabled = false
@@ -0,0 +1,137 @@
import XCTest
@testable import suixinkan
/// 注销账号功能的业务规则测试,覆盖资产确认、七天冷静期与账号恢复。
final class AccountDeletionFeatureTests: XCTestCase {
private var suiteName = ""
private var defaults: UserDefaults!
private var currentDate: Date!
private var service: AccountDeletionMockService!
override func setUp() {
super.setUp()
suiteName = "AccountDeletionFeatureTests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)
defaults.removePersistentDomain(forName: suiteName)
currentDate = Date(timeIntervalSince1970: 1_800_000_000)
service = AccountDeletionMockService(
defaults: defaults,
now: { [weak self] in self?.currentDate ?? Date() },
responseDelayNanoseconds: 0
)
}
override func tearDown() {
defaults.removePersistentDomain(forName: suiteName)
service = nil
currentDate = nil
defaults = nil
suiteName = ""
super.tearDown()
}
/// 预检查应返回全部资产项,并保持中性展示文案。
func testPrecheckReturnsAllAccountAssetsWithoutAbandonmentLabel() async throws {
let precheck = try await service.loadPrecheck(username: "18651857230")
XCTAssertEqual(precheck.assets.map(\.kind), AccountDeletionAssetSummary.Kind.allCases)
XCTAssertEqual(precheck.assets.map(\.valueText), ["¥286.50", "36个", "4个", "8.6 GB"])
XCTAssertFalse(precheck.assets.map(\.title).contains(where: { $0.contains("可放弃") }))
XCTAssertFalse(precheck.assets.map(\.valueText).contains(where: { $0.contains("可放弃") }))
}
/// 用户勾选确认前不允许进入验证页。
func testViewModelRequiresExplicitAssetAcknowledgement() async throws {
let viewModel = AccountDeletionViewModel(username: "18651857230", service: service)
XCTAssertFalse(viewModel.isContinueEnabled)
await viewModel.load()
XCTAssertFalse(viewModel.isContinueEnabled)
XCTAssertTrue(viewModel.isContinueButtonEnabled)
XCTAssertThrowsError(try viewModel.makeVerificationContext()) { error in
XCTAssertEqual(error as? AccountDeletionError, .incompleteAcknowledgement)
}
viewModel.toggleAcknowledgement()
XCTAssertTrue(viewModel.isContinueEnabled)
let context = try viewModel.makeVerificationContext()
XCTAssertEqual(context.maskedPhone, "186****7230")
XCTAssertEqual(Set(context.assets.map(\.kind)), Set(AccountDeletionAssetSummary.Kind.allCases))
}
/// 注销申请必须校验验证码,并确认全部资产项。
func testSubmitRequiresCorrectCodeAndAllAssetKinds() async throws {
do {
_ = try await service.submitDeletion(
username: "18651857230",
verificationCode: "000000",
acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases),
clientRequestID: UUID()
)
XCTFail("错误验证码不应提交成功")
} catch {
XCTAssertEqual(error as? AccountDeletionError, .invalidVerificationCode)
}
do {
_ = try await service.submitDeletion(
username: "18651857230",
verificationCode: AccountDeletionMockService.verificationCode,
acknowledgedAssetKinds: [.wallet, .works, .projects],
clientRequestID: UUID()
)
XCTFail("未确认全部资产项不应提交成功")
} catch {
XCTAssertEqual(error as? AccountDeletionError, .incompleteAcknowledgement)
}
}
/// 提交后进入七天冷静期,期间再次登录可以取消注销。
func testPendingDeletionCanBeCanceledWithinSevenDays() async throws {
let request = try await submitDeletion(username: "18651857230")
XCTAssertEqual(request.scheduledDeletionAt, currentDate.addingTimeInterval(7 * 24 * 60 * 60))
guard case let .pending(savedRequest) = service.loginState(for: "18651857230") else {
return XCTFail("应进入待注销状态")
}
XCTAssertEqual(savedRequest, request)
currentDate = currentDate.addingTimeInterval(6 * 24 * 60 * 60)
_ = try service.cancelDeletion(username: "18651857230")
XCTAssertEqual(service.loginState(for: "18651857230"), .none)
}
/// 七天后账号进入已注销状态,不能再通过登录恢复。
func testDeletionBecomesCompletedAfterSevenDays() async throws {
_ = try await submitDeletion(username: "18651857230")
currentDate = currentDate.addingTimeInterval(7 * 24 * 60 * 60 + 1)
guard case .completed = service.loginState(for: "18651857230") else {
return XCTFail("冷静期结束后应为已注销状态")
}
do {
_ = try service.cancelDeletion(username: "18651857230")
XCTFail("已超过七天不应恢复账号")
} catch {
XCTAssertEqual(error as? AccountDeletionError, .cancellationExpired)
}
}
/// 手机号展示仅保留前三位和后四位。
func testPhoneMaskingNormalizesStoredUsername() {
XCTAssertEqual(AccountDeletionIdentity.normalizedUsername("+86 186-5185-7230"), "18651857230")
XCTAssertEqual(AccountDeletionIdentity.maskedPhone("+86 186-5185-7230"), "186****7230")
}
private func submitDeletion(username: String) async throws -> AccountDeletionRequest {
try await service.submitDeletion(
username: username,
verificationCode: AccountDeletionMockService.verificationCode,
acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases),
clientRequestID: UUID()
)
}
}
+36
View File
@@ -62,6 +62,7 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(response.token, "person-temp-token") XCTAssertEqual(response.token, "person-temp-token")
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201]) XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"]) XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"])
XCTAssertEqual(response.accounts.map(\.realName), ["张三", "李四"])
} }
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws { func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
@@ -137,4 +138,39 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三") XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三")
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201)) XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
} }
func testV9AccountModelsIgnoreBackendIdentifiersAndUseRealNameFallback() throws {
let json = """
{
"token": "person-temp-token",
"scenic_users": [
{
"account_type": "scenic_user",
"ss_user_id": 101,
"username": "backend_scenic_account",
"user_name": "backend_scenic_unique_id",
"real_name": "景区真实姓名"
}
],
"store_users": [
{
"account_type": "store_user",
"store_user_id": 201,
"username": "backend_store_account",
"user_name": "backend_store_unique_id",
"real_name": "门店真实姓名"
}
]
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(V9AuthResponse.self, from: json)
XCTAssertEqual(response.scenicUsers.first?.realName, "景区真实姓名")
XCTAssertEqual(response.scenicUsers.first?.displayName, "景区真实姓名")
XCTAssertEqual(response.scenicUsers.first?.toAccountSwitchAccount().title, "景区真实姓名")
XCTAssertEqual(response.storeUsers.first?.realName, "门店真实姓名")
XCTAssertEqual(response.storeUsers.first?.displayName, "门店真实姓名")
XCTAssertEqual(response.storeUsers.first?.toAccountSwitchAccount().title, "门店真实姓名")
}
} }
+87
View File
@@ -200,4 +200,91 @@ final class LoginViewModelTests: XCTestCase {
XCTFail("意外错误: \(error)") XCTFail("意外错误: \(error)")
} }
} }
/// 密码校验成功后发现账号处于注销冷静期,应先确认恢复,再继续原有登录流程。
func testPendingAccountDeletionCanBeCanceledAndResumeLogin() async throws {
let loginJSON = """
{
"code": 100000,
"msg": "success",
"data": {
"token": "pending-token",
"scenic_users": [
{
"account_type": "scenic_user",
"id": 101,
"user_id": 101,
"scenic_user_id": 101,
"ss_user_id": 101,
"username": "scenic_admin",
"real_name": "张三",
"nickname": "张三",
"phone": "13800138000",
"scenic_id": 10,
"scenic_name": "示例景区",
"is_current": false
}
],
"store_users": []
}
}
""".data(using: .utf8)!
let setUserJSON = """
{
"code": 100000,
"msg": "success",
"data": {
"token": "business-token-after-restore",
"scenic_users": [],
"store_users": []
}
}
""".data(using: .utf8)!
let suiteName = "LoginViewModelTests.AccountDeletion.\(UUID().uuidString)"
let deletionDefaults = UserDefaults(suiteName: suiteName)!
deletionDefaults.removePersistentDomain(forName: suiteName)
defer { deletionDefaults.removePersistentDomain(forName: suiteName) }
let fixedDate = Date(timeIntervalSince1970: 1_800_000_000)
let deletionService = AccountDeletionMockService(
defaults: deletionDefaults,
now: { fixedDate },
responseDelayNanoseconds: 0
)
_ = try await deletionService.submitDeletion(
username: "18651857230",
verificationCode: AccountDeletionMockService.verificationCode,
acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases),
clientRequestID: UUID()
)
let session = MockURLSession(responses: [loginJSON, setUserJSON])
let api = AuthAPI(client: APIClient(environment: .testing, session: session))
let viewModel = LoginViewModel(accountDeletionService: deletionService)
viewModel.updateAccount("18651857230")
viewModel.updatePassword("secret123")
viewModel.togglePrivacyChecked()
let initialResolution = try await viewModel.login(authAPI: api)
guard case let .accountDeletionPending(verifiedResponse, request) = initialResolution else {
return XCTFail("应先提示账号正在注销中")
}
XCTAssertEqual(request.username, "18651857230")
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login"])
let resumedResolution = try await viewModel.resumeLoginAfterCancelingDeletion(
verifiedResponse: verifiedResponse,
authAPI: api
)
guard case let .completed(response, account) = resumedResolution else {
return XCTFail("取消注销后应完成登录")
}
XCTAssertEqual(response.token, "business-token-after-restore")
XCTAssertEqual(account.businessUserId, 101)
XCTAssertEqual(deletionService.loginState(for: "18651857230"), .none)
XCTAssertEqual(session.requests.map { $0.url?.path }, [
"/api/app/v9/login",
"/api/app/v9/set-user",
])
}
} }
@@ -0,0 +1,205 @@
//
// OfflineCollectionFeatureTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// 线下收款登记、日汇总、失败回滚与幂等补缴流程测试。
final class OfflineCollectionFeatureTests: XCTestCase {
private var defaults: UserDefaults!
private var calendar: Calendar!
private var fixedNow: Date!
private var service: OfflineCollectionMockService!
private let context = OfflineCollectionContext(
collectorId: "U10086",
collectorName: "张三",
storeId: "S1001",
storeName: "那拉提旅拍一店",
scenicId: "SC001",
scenicName: "那拉提景区"
)
override func setUp() {
super.setUp()
let suiteName = "OfflineCollectionFeatureTests"
defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")!
fixedNow = calendar.date(from: DateComponents(
calendar: calendar,
timeZone: calendar.timeZone,
year: 2026,
month: 8,
day: 19,
hour: 20,
minute: 5
))!
let now = fixedNow!
service = OfflineCollectionMockService(
defaults: defaults,
calendar: calendar,
nowProvider: { now },
requestDelayNanoseconds: 0
)
}
override func tearDown() {
defaults.removePersistentDomain(forName: "OfflineCollectionFeatureTests")
service = nil
calendar = nil
fixedNow = nil
defaults = nil
super.tearDown()
}
func testMoneyParserUsesIntegerFenAndEnforcesRange() {
XCTAssertEqual(OfflineCollectionMoney.parseFen(".5"), 50)
XCTAssertEqual(OfflineCollectionMoney.parseFen("299"), 29_900)
XCTAssertEqual(OfflineCollectionMoney.parseFen("99999.99"), 9_999_999)
XCTAssertNil(OfflineCollectionMoney.parseFen(""))
XCTAssertNil(OfflineCollectionMoney.parseFen("0"))
XCTAssertNil(OfflineCollectionMoney.parseFen("-1"))
XCTAssertNil(OfflineCollectionMoney.parseFen("1.234"))
XCTAssertNil(OfflineCollectionMoney.parseFen("100000"))
XCTAssertNil(OfflineCollectionMoney.parseFen("1e2"))
XCTAssertEqual(OfflineCollectionMoney.display(29_900), "¥299.00")
}
func testDefaultMockDataContainsTodayAndHistoricalOverdueRecords() {
let today = service.getDailySummary(date: "2026-08-19", context: context)
let overdue = service.getOverdueSummaries(context: context)
XCTAssertEqual(today.totalCount, 3)
XCTAssertEqual(today.totalAmountFen, 89_700)
XCTAssertEqual(today.pendingAmountFen, 89_700)
XCTAssertEqual(overdue.count, 1)
XCTAssertEqual(overdue.first?.businessDate, "2026-08-18")
XCTAssertEqual(overdue.first?.pendingCount, 2)
XCTAssertEqual(overdue.first?.pendingAmountFen, 50_000)
XCTAssertEqual(overdue.first?.hasOverdue, true)
}
func testCreateCollectionUpdatesSummaryAndPersists() async throws {
let receipt = try await service.createOfflineCollection(
amountFen: 29_900,
paymentMethod: .wechat,
context: context
)
XCTAssertEqual(receipt.record.status, .pending)
XCTAssertEqual(receipt.summary.totalCount, 4)
XCTAssertEqual(receipt.summary.totalAmountFen, 119_600)
XCTAssertEqual(receipt.summary.pendingAmountFen, 119_600)
let now = fixedNow!
let reloaded = OfflineCollectionMockService(
defaults: defaults,
calendar: calendar,
nowProvider: { now },
requestDelayNanoseconds: 0
)
XCTAssertEqual(reloaded.getDailySummary(date: "2026-08-19", context: context), receipt.summary)
}
func testSuccessfulSettlementGeneratesIndependentBatchAndSettlesSnapshot() async throws {
let records = service.getDailyRecords(date: "2026-08-19", context: context)
let request = OfflineSettlementRequest(
businessDate: "2026-08-19",
recordIds: records.map(\.id),
amountFen: records.reduce(0) { $0 + $1.amountFen },
clientRequestId: "LOCAL-SUCCESS-001"
)
let batch = try await service.settleDailyCollections(request: request, context: context)
let summary = service.getDailySummary(date: "2026-08-19", context: context)
let settledRecords = service.getDailyRecords(date: "2026-08-19", context: context)
XCTAssertEqual(batch.amountFen, 89_700)
XCTAssertEqual(batch.recordCount, 3)
XCTAssertEqual(batch.transactionType, "OFFLINE_COLLECTION_SETTLEMENT")
XCTAssertEqual(summary.totalAmountFen, summary.settledAmountFen + summary.pendingAmountFen)
XCTAssertEqual(summary.pendingAmountFen, 0)
XCTAssertTrue(settledRecords.allSatisfy { $0.status == .settled })
XCTAssertTrue(settledRecords.allSatisfy { $0.settlementBatchId == batch.id })
}
func testFailedSettlementDoesNotCreateBatchOrChangeRecords() async throws {
let before = service.getDailyRecords(date: "2026-08-19", context: context)
let request = OfflineSettlementRequest(
businessDate: "2026-08-19",
recordIds: before.map(\.id),
amountFen: before.reduce(0) { $0 + $1.amountFen },
clientRequestId: "LOCAL-FAILURE-001"
)
service.simulateNextSettlementFailure()
do {
_ = try await service.settleDailyCollections(request: request, context: context)
XCTFail("应返回 Mock 补缴失败")
} catch {
XCTAssertEqual(error as? OfflineCollectionError, .settlementFailed)
}
XCTAssertEqual(service.getDailyRecords(date: "2026-08-19", context: context), before)
XCTAssertTrue(service.getSettlementBatches(date: "2026-08-19", context: context).isEmpty)
XCTAssertEqual(service.getDailySummary(date: "2026-08-19", context: context).pendingAmountFen, 89_700)
}
func testSettlementOnlySettlesFrozenRecordSnapshot() async throws {
let frozenRecords = service.getDailyRecords(date: "2026-08-19", context: context)
let request = OfflineSettlementRequest(
businessDate: "2026-08-19",
recordIds: frozenRecords.map(\.id),
amountFen: frozenRecords.reduce(0) { $0 + $1.amountFen },
clientRequestId: "LOCAL-SNAPSHOT-001"
)
let newReceipt = try await service.createOfflineCollection(
amountFen: 12_300,
paymentMethod: .cash,
context: context
)
_ = try await service.settleDailyCollections(request: request, context: context)
let records = service.getDailyRecords(date: "2026-08-19", context: context)
let newRecord = try XCTUnwrap(records.first(where: { $0.id == newReceipt.record.id }))
XCTAssertEqual(newRecord.status, .pending)
XCTAssertEqual(service.getDailySummary(date: "2026-08-19", context: context).pendingAmountFen, 12_300)
}
func testSameClientRequestIdDoesNotCreateDuplicateBatch() async throws {
let records = service.getDailyRecords(date: "2026-08-19", context: context)
let request = OfflineSettlementRequest(
businessDate: "2026-08-19",
recordIds: records.map(\.id),
amountFen: records.reduce(0) { $0 + $1.amountFen },
clientRequestId: "LOCAL-IDEMPOTENT-001"
)
let first = try await service.settleDailyCollections(request: request, context: context)
let second = try await service.settleDailyCollections(request: request, context: context)
XCTAssertEqual(first, second)
XCTAssertEqual(service.getSettlementBatches(date: "2026-08-19", context: context).count, 1)
}
func testRegistrationViewModelValidatesAndSubmitsForm() async {
let viewModel = OfflineCollectionRegistrationViewModel(context: context, service: service)
var successReceipt: OfflineCollectionRegistrationReceipt?
viewModel.onRegistrationSuccess = { successReceipt = $0 }
XCTAssertEqual(viewModel.paymentMethod, .wechat)
viewModel.updateAmount("299")
XCTAssertTrue(viewModel.canSubmit)
await viewModel.submit()
XCTAssertEqual(successReceipt?.record.amountFen, 29_900)
XCTAssertEqual(successReceipt?.record.paymentMethod, .wechat)
XCTAssertFalse(viewModel.isSubmitting)
XCTAssertEqual(successReceipt?.summary.totalCount, 4)
}
}
@@ -69,6 +69,26 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.showAmountDialog) XCTAssertFalse(viewModel.showAmountDialog)
} }
func testPayCodeLoadingStateCoversInitialRenderAndRequest() async {
let payCodeJSON = """
{"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}}
""".data(using: .utf8)!
let session = MockURLSession(responses: [payCodeJSON])
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
var loadingStates = [viewModel.isPayCodeLoading]
viewModel.onStateChange = {
loadingStates.append(viewModel.isPayCodeLoading)
}
await viewModel.loadPayCode(api: api)
XCTAssertEqual(loadingStates.first, true)
XCTAssertTrue(loadingStates.dropLast().allSatisfy { $0 })
XCTAssertEqual(loadingStates.last, false)
XCTAssertTrue(viewModel.hasPayCode)
}
func testToggleReceiveVoicePersistsInAppStore() { func testToggleReceiveVoicePersistsInAppStore() {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
viewModel.refreshLocalState() viewModel.refreshLocalState()
@@ -79,8 +99,9 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.isVoiceBroadcastOpen) XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
} }
func testNalatiAccountInfoUsesLoggedInPhotographerAndAllPermissionStores() { func testNalatiAccountInfoUsesPhotographerRealNameAndAllPermissionStores() {
appStore.session.userName = "摄影师小王" appStore.session.userName = "摄影师昵称"
appStore.session.realName = "摄影师真实姓名"
appStore.permissions.saveRolePermissionList([ appStore.permissions.saveRolePermissionList([
RolePermissionResponse( RolePermissionResponse(
role: RoleInfo(id: 41, name: "摄影师", roleCode: "photographer"), role: RoleInfo(id: 41, name: "摄影师", roleCode: "photographer"),
@@ -93,10 +114,19 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "摄影师小王") XCTAssertEqual(viewModel.displayPhotographerName, "摄影师真实姓名")
XCTAssertEqual(viewModel.displayStoreNames, "空中草原店、河谷草原店") XCTAssertEqual(viewModel.displayStoreNames, "空中草原店、河谷草原店")
} }
func testPhotographerNameDoesNotFallBackToNicknameWhenRealNameMissing() {
appStore.session.userName = "摄影师昵称"
appStore.session.realName = " "
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "-")
}
func testLoadPayCodeClearsQRWhenScenicMissing() async { func testLoadPayCodeClearsQRWhenScenicMissing() async {
appStore.session.currentScenicId = 0 appStore.session.currentScenicId = 0
let session = MockURLSession(responses: []) let session = MockURLSession(responses: [])
@@ -105,6 +135,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
await viewModel.loadPayCode(api: api) await viewModel.loadPayCode(api: api)
XCTAssertFalse(viewModel.isPayCodeLoading)
XCTAssertFalse(viewModel.hasPayCode) XCTAssertFalse(viewModel.hasPayCode)
XCTAssertEqual(session.requests.count, 0) XCTAssertEqual(session.requests.count, 0)
} }
+4 -3
View File
@@ -55,11 +55,12 @@ final class PaymentModelsTests: XCTestCase {
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0)) XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
} }
func testPaymentAccountDisplayFormatterUsesCurrentPhotographerAndAllUniqueStores() { func testPaymentAccountDisplayFormatterUsesRealNameAndAllUniqueStores() {
XCTAssertEqual( XCTAssertEqual(
PaymentAccountDisplayFormatter.photographerName(userName: " 当前摄影师 ", realName: "实名"), PaymentAccountDisplayFormatter.photographerName(realName: " 摄影师真实姓名 "),
"当前摄影师" "摄影师真实姓名"
) )
XCTAssertEqual(PaymentAccountDisplayFormatter.photographerName(realName: " "), "-")
XCTAssertEqual( XCTAssertEqual(
PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]), PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]),
"一号店、二号店" "一号店、二号店"
@@ -26,6 +26,22 @@ final class ProfileViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.displayNickname, "未设置昵称") XCTAssertEqual(viewModel.displayNickname, "未设置昵称")
} }
func testColdStartBasicInfoUsesPersistedSessionCache() {
AppStore.shared.session.userName = "缓存昵称"
AppStore.shared.session.realName = "缓存真实姓名"
AppStore.shared.session.phone = "186****7230"
AppStore.shared.session.avatar = "https://cdn.example.com/cached-avatar.jpg"
let viewModel = ProfileViewModel()
XCTAssertTrue(viewModel.hasCachedBasicInfo)
XCTAssertEqual(viewModel.displayNickname, "缓存昵称")
XCTAssertEqual(viewModel.displayRealName, "缓存真实姓名")
XCTAssertEqual(viewModel.displayPhone, "186****7230")
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/cached-avatar.jpg")
XCTAssertEqual(viewModel.displayUID, "profile-user")
}
func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() { func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() {
AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue
let viewModel = ProfileViewModel() let viewModel = ProfileViewModel()
@@ -0,0 +1,62 @@
//
// SessionExpiredDialogViewControllerTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// 登录失效弹窗测试,覆盖强制展示配置、Android 对齐文案与确认防重。
@MainActor
final class SessionExpiredDialogViewControllerTests: XCTestCase {
func testDialogUsesForcedPresentationAndAndroidCopy() throws {
let dialog = SessionExpiredDialogViewController(onConfirm: {})
dialog.loadViewIfNeeded()
XCTAssertEqual(dialog.modalPresentationStyle, .overFullScreen)
XCTAssertEqual(dialog.modalTransitionStyle, .crossDissolve)
XCTAssertTrue(dialog.isModalInPresentation)
XCTAssertTrue(dialog.view.accessibilityViewIsModal)
let labels = dialog.view.allSubviews(of: UILabel.self)
XCTAssertTrue(labels.contains { $0.text == "登录提醒" })
XCTAssertTrue(labels.contains { $0.text == "当前用户已在其他设备登录,\n请重新登录" })
let button = try XCTUnwrap(
dialog.view.allSubviews(of: UIButton.self).first {
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
}
)
XCTAssertEqual(button.title(for: .normal), "确认并退出登录")
}
func testConfirmCallbackOnlyRunsOnce() throws {
var confirmationCount = 0
let dialog = SessionExpiredDialogViewController {
confirmationCount += 1
}
dialog.loadViewIfNeeded()
let button = try XCTUnwrap(
dialog.view.allSubviews(of: UIButton.self).first {
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
}
)
button.sendActions(for: .touchUpInside)
button.sendActions(for: .touchUpInside)
XCTAssertEqual(confirmationCount, 1)
XCTAssertFalse(button.isEnabled)
}
}
private extension UIView {
func allSubviews<View: UIView>(of type: View.Type) -> [View] {
subviews.flatMap { subview -> [View] in
let current = (subview as? View).map { [$0] } ?? []
return current + subview.allSubviews(of: type)
}
}
}
@@ -60,12 +60,44 @@ final class TravelAlbumOTGStorageTests: XCTestCase {
XCTAssertEqual(store.absoluteURL(for: loaded.first?.localPath ?? "", albumId: 33), file) XCTAssertEqual(store.absoluteURL(for: loaded.first?.localPath ?? "", albumId: 33), file)
} }
func testLoadNormalizesInterruptedRetouchToUploadedFailure() throws {
let root = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let store = makeStore(root: root)
let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33)
try Data([1, 2, 3]).write(to: file)
let record = TravelAlbumOTGPhotoRecord(
id: "retouching",
fileName: "A.JPG",
localPath: file.path,
capturedAt: "2026-07-07 10:00:00",
fileSizeBytes: 3,
status: .uploading,
progress: 100,
albumId: 33,
userId: "u1",
remoteUrl: "https://cdn/a.jpg",
materialId: 10,
autoRetouchState: .processing,
autoRetouchTemplateId: "brocade"
)
store.save([record], albumId: 33)
let loaded = try XCTUnwrap(store.load(albumId: 33).first)
XCTAssertEqual(loaded.status, .uploaded)
XCTAssertEqual(loaded.progress, 100)
XCTAssertEqual(loaded.autoRetouchState, .failed)
XCTAssertEqual(loaded.autoRetouchTemplateId, "brocade")
}
func testRemoveAndClearAlbumDeleteFiles() throws { func testRemoveAndClearAlbumDeleteFiles() throws {
let root = try makeTempDirectory() let root = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: root) } defer { try? FileManager.default.removeItem(at: root) }
let store = makeStore(root: root) let store = makeStore(root: root)
let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33) let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33)
try Data([1]).write(to: file) try Data([1]).write(to: file)
let retouchedFile = try store.writeRetouchedImage(Data([2]), filename: "A.JPG", albumId: 33)
let record = TravelAlbumOTGPhotoRecord( let record = TravelAlbumOTGPhotoRecord(
id: "1", id: "1",
fileName: "A.JPG", fileName: "A.JPG",
@@ -74,13 +106,17 @@ final class TravelAlbumOTGStorageTests: XCTestCase {
fileSizeBytes: 1, fileSizeBytes: 1,
status: .pending, status: .pending,
albumId: 33, albumId: 33,
userId: "u1" userId: "u1",
autoRetouchState: .completed,
autoRetouchTemplateId: "brocade",
retouchedPath: retouchedFile.path
) )
store.save([record], albumId: 33) store.save([record], albumId: 33)
store.remove(albumId: 33, photoId: "1") store.remove(albumId: 33, photoId: "1")
XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: file.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: retouchedFile.path))
XCTAssertTrue(store.load(albumId: 33).isEmpty) XCTAssertTrue(store.load(albumId: 33).isEmpty)
let another = try store.uniqueOriginalFileURL(filename: "B.JPG", albumId: 33) let another = try store.uniqueOriginalFileURL(filename: "B.JPG", albumId: 33)
+535 -6
View File
@@ -4,6 +4,7 @@
// //
import XCTest import XCTest
import UIKit
@testable import suixinkan @testable import suixinkan
@MainActor @MainActor
@@ -58,6 +59,55 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.isCreateSheetVisible) XCTAssertFalse(viewModel.isCreateSheetVisible)
} }
func testCreateSuccessPersistsSelectedAutoRetouchTemplate() async {
let api = TravelAlbumMockAPI()
api.createResponse = TravelAlbumCreateResponse(id: 19)
let defaults = makeOTGTestDefaults()
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
let viewModel = TravelAlbumEntryViewModel(
currentScenicIdProvider: { 1 },
autoRetouchConfigurationStore: configurationStore
)
await viewModel.confirmCreate(
mode: .preShoot,
freeCount: "2",
singlePrice: "12.5",
packagePrice: "88",
autoRetouchConfiguration: .enabled(templateID: "brocade"),
order: nil,
api: api
)
XCTAssertEqual(
configurationStore.configuration(albumID: 19),
.enabled(templateID: "brocade")
)
}
func testCreateRejectsEnabledAutoRetouchWithoutValidTemplate() async {
let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
var message = ""
viewModel.onShowMessage = { message = $0 }
await viewModel.confirmCreate(
mode: .preShoot,
freeCount: "",
singlePrice: "12.5",
packagePrice: "",
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
isEnabled: true,
templateID: "missing-template"
),
order: nil,
api: api
)
XCTAssertEqual(message, "请选择修图模板")
XCTAssertTrue(api.createRequests.isEmpty)
}
func testOrderAlreadyBoundReloadsOrders() async { func testOrderAlreadyBoundReloadsOrders() async {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
api.createError = APIError.serverCode(100001, "订单已关联") api.createError = APIError.serverCode(100001, "订单已关联")
@@ -72,9 +122,236 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
} }
} }
@MainActor
/// 自动修图选择弹窗的界面状态测试。
final class TravelAlbumAutoRetouchSettingSheetTests: XCTestCase {
func testSelectingTemplateImmediatelyRefreshesSelectedAppearance() throws {
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
configuration: .disabled,
startsWithModeSelection: false
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 402, height: 874)
controller.view.layoutIfNeeded()
let tableView = try XCTUnwrap(
controller.view.subviews.compactMap { $0 as? UITableView }.first
)
let selectedIndexPath = IndexPath(row: 1, section: 0)
tableView.delegate?.tableView?(tableView, didSelectRowAt: selectedIndexPath)
controller.view.layoutIfNeeded()
XCTAssertEqual(tableView.cellForRow(at: selectedIndexPath)?.accessibilityValue, "已选择")
XCTAssertEqual(tableView.cellForRow(at: IndexPath(row: 0, section: 0))?.accessibilityValue, "未选择")
}
}
@MainActor @MainActor
/// 旅拍相册详情 ViewModel 测试。 /// 旅拍相册详情 ViewModel 测试。
final class TravelAlbumDetailViewModelTests: XCTestCase { final class TravelAlbumDetailViewModelTests: XCTestCase {
func testAIEditResultDisablesDuringProcessingAndOverwritesPreviousResult() {
var state = TravelAlbumAIEditResultState()
XCTAssertTrue(state.canStartEditing)
XCTAssertFalse(state.hasEditedResult)
XCTAssertEqual(state.thumbnailStatus, .none)
state.startProcessing()
XCTAssertFalse(state.canStartEditing)
XCTAssertEqual(state.thumbnailStatus, .processing)
state.complete(presetID: "portrait")
XCTAssertTrue(state.canStartEditing)
XCTAssertEqual(state.appliedPresetID, "portrait")
XCTAssertEqual(state.displayMode, .edited)
XCTAssertEqual(state.thumbnailStatus, .edited)
state.selectDisplayMode(.original)
XCTAssertEqual(state.displayMode, .original)
state.startProcessing()
XCTAssertEqual(state.appliedPresetID, "portrait")
state.complete(presetID: "mist")
XCTAssertEqual(state.appliedPresetID, "mist")
XCTAssertEqual(state.displayMode, .edited)
}
func testAIEditFailureShowsFailureStatusAndKeepsPreviousResult() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait")
state.startProcessing()
state.failProcessing()
XCTAssertTrue(state.canStartEditing)
XCTAssertTrue(state.hasEditedResult)
XCTAssertEqual(state.appliedPresetID, "portrait")
XCTAssertEqual(state.thumbnailStatus, .failed)
state.startProcessing()
XCTAssertEqual(state.thumbnailStatus, .processing)
}
func testRestoreOriginalClearsEditedThumbnailStatus() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait")
state.startProcessing()
state.restoreOriginal()
XCTAssertTrue(state.canStartEditing)
XCTAssertFalse(state.hasEditedResult)
XCTAssertEqual(state.displayMode, .original)
XCTAssertEqual(state.thumbnailStatus, .none)
}
func testCoverResultUsesDistinctThumbnailStatusAndCanBeOverwritten() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait", coverTemplateID: "travel_memoir")
XCTAssertTrue(state.isCover)
XCTAssertEqual(state.appliedCoverTemplateID, "travel_memoir")
XCTAssertEqual(state.displayMode, .cover)
XCTAssertEqual(state.thumbnailStatus, .cover)
state.complete(presetID: "mist")
XCTAssertFalse(state.isCover)
XCTAssertNil(state.appliedCoverTemplateID)
XCTAssertEqual(state.thumbnailStatus, .edited)
}
func testAtmosphereResultCanBeSelectedAndIsClearedByNextSingleResult() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait", atmosphereOptionID: "atmosphere")
XCTAssertTrue(state.hasAtmosphereResult)
state.selectDisplayMode(.atmosphere)
XCTAssertEqual(state.displayMode, .atmosphere)
state.complete(presetID: "mist")
XCTAssertFalse(state.hasAtmosphereResult)
XCTAssertNil(state.appliedAtmosphereOptionID)
state.selectDisplayMode(.atmosphere)
XCTAssertEqual(state.displayMode, .edited)
}
func testAIEditResultStoreSharesAndOverwritesLatestImage() {
let materialID = 990_071
let store = TravelAlbumAIEditResultStore.shared
store.remove(materialID: materialID)
store.startProcessing(materialIDs: [materialID])
XCTAssertFalse(store.state(for: materialID).canStartEditing)
let firstImage = UIImage()
store.complete(materialID: materialID, presetID: "portrait", editedImage: firstImage)
XCTAssertTrue(store.editedImage(for: materialID) === firstImage)
let replacementImage = UIImage()
let atmosphereImage = UIImage()
let coverImage = UIImage()
store.startProcessing(materialIDs: [materialID])
store.complete(
materialID: materialID,
presetID: "mist",
atmosphereOptionID: "atmosphere",
coverTemplateID: "travel_memoir",
editedImage: replacementImage,
atmosphereImage: atmosphereImage,
coverImage: coverImage
)
XCTAssertEqual(store.state(for: materialID).appliedPresetID, "mist")
XCTAssertTrue(store.editedImage(for: materialID) === replacementImage)
XCTAssertTrue(store.atmosphereImage(for: materialID) === atmosphereImage)
XCTAssertTrue(store.coverImage(for: materialID) === coverImage)
store.remove(materialID: materialID)
}
func testSelectionToolbarReplacesUploadWithAIEditAndDelete() {
let normalState = TravelAlbumSelectionToolbarState(isSelectionMode: false, selectedCount: 0)
XCTAssertTrue(normalState.showsUpload)
XCTAssertFalse(normalState.showsSelectionActions)
let emptySelectionState = TravelAlbumSelectionToolbarState(isSelectionMode: true, selectedCount: 0)
XCTAssertFalse(emptySelectionState.showsUpload)
XCTAssertTrue(emptySelectionState.showsSelectionActions)
XCTAssertFalse(emptySelectionState.selectionActionsEnabled)
let selectedState = TravelAlbumSelectionToolbarState(isSelectionMode: true, selectedCount: 2)
XCTAssertTrue(selectedState.showsSelectionActions)
XCTAssertTrue(selectedState.selectionActionsEnabled)
}
func testPhotoPreviewCanSwipeAndKeepsValidIndexAfterDelete() {
let materials = [
TravelAlbumMaterial(id: 1, fileName: "A.JPG"),
TravelAlbumMaterial(id: 2, fileName: "B.JPG"),
TravelAlbumMaterial(id: 3, fileName: "C.JPG"),
]
var state = TravelAlbumPhotoPreviewState(materials: materials, initialIndex: 1)
XCTAssertEqual(state.currentMaterial?.id, 2)
XCTAssertTrue(state.moveNext())
XCTAssertEqual(state.currentMaterial?.id, 3)
XCTAssertFalse(state.moveNext())
XCTAssertTrue(state.removeCurrent())
XCTAssertEqual(state.currentMaterial?.id, 2)
XCTAssertTrue(state.movePrevious())
XCTAssertEqual(state.currentMaterial?.id, 1)
}
func testAIEditPresetsContainScrollableChoices() {
let presets = TravelAlbumEditPreset.defaultOptions
XCTAssertEqual(presets.count, 8)
XCTAssertEqual(Set(presets.map(\.id)).count, presets.count)
XCTAssertEqual(presets.first?.effect, .original)
XCTAssertTrue(presets.contains { $0.effect == .mist })
}
func testAtmosphereOptionsContainScrollableSingleChoiceStyles() {
let options = TravelAlbumAtmosphereEditOption.defaultOptions
XCTAssertGreaterThanOrEqual(options.count, 4)
XCTAssertEqual(Set(options.map(\.id)).count, options.count)
XCTAssertTrue(options.allSatisfy { $0.effect != .original })
XCTAssertEqual(TravelAlbumAtmosphereEditOption.defaultOption, options.first)
}
func testAIEditSelectionCountsOptionalAtmosphereAndCoverStyles() {
let refinedPreset = TravelAlbumEditPreset.defaultOptions[1]
let baseSelection = TravelAlbumAIEditSelection(
refinedPreset: refinedPreset,
atmosphereOption: nil,
coverTemplate: nil
)
XCTAssertEqual(baseSelection.resultCountPerPhoto, 1)
XCTAssertEqual(baseSelection.selectedStyleCount, 1)
let fullSelection = TravelAlbumAIEditSelection(
refinedPreset: refinedPreset,
atmosphereOption: .defaultOption,
coverTemplate: TravelAlbumCoverTemplate.defaultOptions[0]
)
XCTAssertEqual(fullSelection.resultCountPerPhoto, 2)
XCTAssertEqual(fullSelection.selectedStyleCount, 3)
}
func testCoverTemplatesAppearFromFourSelectedPhotos() {
XCTAssertFalse(TravelAlbumCoverTemplate.shouldShow(for: 3))
XCTAssertTrue(TravelAlbumCoverTemplate.shouldShow(for: 4))
XCTAssertTrue(TravelAlbumCoverTemplate.shouldShow(for: 8))
}
func testCoverTemplatesAreFreeAndReadyForFutureQuotaPricing() {
let templates = TravelAlbumCoverTemplate.defaultOptions
XCTAssertEqual(templates.count, 4)
XCTAssertEqual(Set(templates.map(\.id)).count, templates.count)
XCTAssertEqual(templates.first?.id, "travel_memoir")
XCTAssertTrue(templates.allSatisfy(\.isFreeGift))
XCTAssertTrue(templates.allSatisfy { $0.quotaCost == 0 })
}
func testRefreshLoadsInfoAndMaterials() async { func testRefreshLoadsInfoAndMaterials() async {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "详情") api.infoResponse = TravelAlbum(id: 2, name: "详情")
@@ -106,6 +383,53 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(api.materialRequests.last?.orderBy, 4) XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
} }
func testPurchasedTabUsesLocalDemoMaterialsWhenServerIsEmpty() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [TravelAlbumListResponse(total: 0, list: [])]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
XCTAssertTrue(viewModel.isUsingPurchasedDemoData)
XCTAssertEqual(viewModel.materials.count, 4)
XCTAssertTrue(viewModel.materials.allSatisfy(\.isPurchased))
XCTAssertTrue(viewModel.materials.allSatisfy { $0.id < 0 })
XCTAssertTrue(viewModel.materials.allSatisfy { $0.bundledImageAssetName != nil })
}
func testPurchasedTabKeepsRealServerMaterialsWhenAvailable() async {
let api = TravelAlbumMockAPI()
let serverMaterial = TravelAlbumMaterial(
id: 88,
fileName: "SERVER_0088.JPG",
fileUrl: "https://cdn.example.com/server-88.jpg",
isPurchased: true
)
api.materialListResponses = [
TravelAlbumListResponse(total: 1, list: [serverMaterial]),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
XCTAssertFalse(viewModel.isUsingPurchasedDemoData)
XCTAssertEqual(viewModel.materials, [serverMaterial])
}
func testPurchasedDemoMaterialsFollowCurrentSortOption() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
await viewModel.setSortOption(.fileNameAsc, api: api)
XCTAssertEqual(viewModel.materials.map(\.fileName), viewModel.materials.map(\.fileName).sorted())
}
func testOnlyUnpurchasedMaterialCanBeSelected() { func testOnlyUnpurchasedMaterialCanBeSelected() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2) let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode() viewModel.toggleSelectionMode()
@@ -170,6 +494,29 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(secondViewModel.transferModeOption, "拍后传输") XCTAssertEqual(secondViewModel.transferModeOption, "拍后传输")
} }
func testInitialTransferModeOverridesPersistedModeAndBecomesLatestSelection() {
let defaults = makeOTGTestDefaults()
defaults.set(TravelAlbumOTGTransferMode.liveUpload.rawValue, forKey: "travelAlbum.otg.transferMode")
let firstViewModel = WiredCameraTransferViewModel(
albumId: 9,
albumTitle: "相册",
headerPhone: "13800138000",
initialTransferMode: .postTransfer,
userDefaults: defaults
)
XCTAssertEqual(firstViewModel.transferMode, .postTransfer)
XCTAssertEqual(firstViewModel.transferModeOption, "拍后传输")
let secondViewModel = WiredCameraTransferViewModel(
albumId: 18,
albumTitle: "另一个相册",
headerPhone: "13800138000",
userDefaults: defaults
)
XCTAssertEqual(secondViewModel.transferMode, .postTransfer)
}
func testConnectedStatusIncludesCameraModelName() { func testConnectedStatusIncludesCameraModelName() {
let context = makeOTGTestContext() let context = makeOTGTestContext()
let viewModel = makeWiredViewModel( let viewModel = makeWiredViewModel(
@@ -210,6 +557,31 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(groups.first?.slots.last?.photoCount, 2) XCTAssertEqual(groups.first?.slots.last?.photoCount, 2)
} }
func testPhotoPreviewContextPreservesListOrderAndStartsAtTappedPhoto() {
let photos = [
makeOTGPhotoItem(
id: "a",
capturedAt: "2026-07-08 12:30:00",
thumbnailURL: URL(fileURLWithPath: "/tmp/a.jpg")
),
makeOTGPhotoItem(
id: "unavailable",
capturedAt: "2026-07-08 12:20:00"
),
makeOTGPhotoItem(
id: "c",
capturedAt: "2026-07-08 12:10:00",
thumbnailURL: URL(string: "https://cdn.example.com/c.jpg")
),
]
let context = photos.previewContext(startingWith: "c")
XCTAssertEqual(context?.photos.map(\.id), ["a", "c"])
XCTAssertEqual(context?.initialIndex, 1)
XCTAssertNil(photos.previewContext(startingWith: "unavailable"))
}
func testSelectedTimeSlotFallsBackWhenTabChanges() throws { func testSelectedTimeSlotFallsBackWhenTabChanges() throws {
let context = makeOTGTestContext() let context = makeOTGTestContext()
context.store.save([ context.store.save([
@@ -487,6 +859,134 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertTrue(records.allSatisfy { UUID(uuidString: $0.clientPhotoId) != nil }) XCTAssertTrue(records.allSatisfy { UUID(uuidString: $0.clientPhotoId) != nil })
} }
func testAutoRetouchConfigurationLoadsAndUpdatesUploadChip() {
let context = makeOTGTestContext()
let defaults = makeOTGTestDefaults()
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
configurationStore.save(.enabled(templateID: "brocade"), albumID: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
userDefaults: defaults,
autoRetouchConfigurationStore: configurationStore
)
XCTAssertEqual(viewModel.retouchOption, "AI 修图")
viewModel.updateAutoRetouchConfiguration(.disabled)
XCTAssertEqual(viewModel.retouchOption, "不修图")
XCTAssertEqual(configurationStore.configuration(albumID: 9), .disabled)
}
func testAutoRetouchFirstLevelOnlyContainsNoRetouchAndAIRetouch() {
XCTAssertEqual(TravelAlbumRetouchMode.allCases, [.disabled, .aiRetouch])
XCTAssertEqual(TravelAlbumRetouchMode.disabled.title, "不修图")
XCTAssertEqual(TravelAlbumRetouchMode.aiRetouch.title, "AI 修图")
}
func testUploadAutomaticallyRetouchesAndPersistsEditedResult() async throws {
let context = makeOTGTestContext()
defer { try? FileManager.default.removeItem(at: context.root) }
let defaults = makeOTGTestDefaults()
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
configurationStore.save(.enabled(templateID: "brocade"), albumID: 9)
let materialID = 990_901
let uploader = MockTravelAlbumOTGUploader(materialID: materialID)
let processor = MockTravelAlbumAutoRetouchProcessor()
let resultStore = TravelAlbumAIEditResultStore.shared
resultStore.remove(materialID: materialID)
defer { resultStore.remove(materialID: materialID) }
let record = try makePersistedOTGRecord(
context: context,
id: "auto-retouch",
capturedAt: "2026-07-08 12:05:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
userDefaults: defaults,
autoRetouchConfigurationStore: configurationStore,
autoRetouchProcessor: processor
)
viewModel.reloadLocalPhotos()
viewModel.onSpecifyUploadOptionSelected(.allPending)
await waitUntil(timeout: 2) {
context.store.load(albumId: 9).first?.autoRetouchState == .completed
}
let saved = try XCTUnwrap(context.store.load(albumId: 9).first)
XCTAssertEqual(saved.status, .uploaded)
XCTAssertEqual(saved.materialId, materialID)
XCTAssertEqual(saved.autoRetouchState, .completed)
XCTAssertEqual(saved.autoRetouchTemplateId, "brocade")
XCTAssertTrue(context.store.fileExists(relativePath: saved.retouchedPath, albumId: 9))
XCTAssertEqual(processor.processedPresetIDs, ["brocade"])
XCTAssertEqual(resultStore.state(for: materialID).appliedPresetID, "brocade")
XCTAssertEqual(viewModel.photos.first?.autoRetouchState, .completed)
viewModel.deletePhoto(photoId: saved.id)
XCTAssertFalse(context.store.fileExists(relativePath: saved.retouchedPath, albumId: 9))
XCTAssertFalse(resultStore.state(for: materialID).hasEditedResult)
}
func testAutoRetouchFailureKeepsUploadSuccessfulAndCanRetryWithoutReupload() async throws {
let context = makeOTGTestContext()
defer { try? FileManager.default.removeItem(at: context.root) }
let defaults = makeOTGTestDefaults()
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
configurationStore.save(.enabled(templateID: "mist"), albumID: 9)
let materialID = 990_902
let resultStore = TravelAlbumAIEditResultStore.shared
resultStore.remove(materialID: materialID)
defer { resultStore.remove(materialID: materialID) }
let uploader = MockTravelAlbumOTGUploader(materialID: materialID)
let processor = MockTravelAlbumAutoRetouchProcessor()
processor.shouldFail = true
let record = try makePersistedOTGRecord(
context: context,
id: "retry-retouch",
capturedAt: "2026-07-08 12:05:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
userDefaults: defaults,
autoRetouchConfigurationStore: configurationStore,
autoRetouchProcessor: processor
)
viewModel.reloadLocalPhotos()
viewModel.onSpecifyUploadOptionSelected(.allPending)
await waitUntil(timeout: 2) {
context.store.load(albumId: 9).first?.autoRetouchState == .failed
}
var saved = try XCTUnwrap(context.store.load(albumId: 9).first)
XCTAssertEqual(saved.status, .uploaded)
XCTAssertEqual(viewModel.tabCounts[TravelAlbumOTGTransferTab.failed.rawValue], 0)
XCTAssertEqual(uploader.uploadCallCount, 1)
processor.shouldFail = false
viewModel.retryAutoRetouch(photoId: saved.id)
await waitUntil(timeout: 2) {
context.store.load(albumId: 9).first?.autoRetouchState == .completed
}
saved = try XCTUnwrap(context.store.load(albumId: 9).first)
XCTAssertEqual(saved.autoRetouchState, .completed)
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(processor.processedPresetIDs, ["mist", "mist"])
}
func testStartSynchronizesPersistedUploadStatusesFromServerClientPhotoIds() async { func testStartSynchronizesPersistedUploadStatusesFromServerClientPhotoIds() async {
let context = makeOTGTestContext() let context = makeOTGTestContext()
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
@@ -715,9 +1215,13 @@ private func makeWiredViewModel(
manager: MockWiredCameraConnectionManager, manager: MockWiredCameraConnectionManager,
uploader: MockTravelAlbumOTGUploader? = nil, uploader: MockTravelAlbumOTGUploader? = nil,
api: TravelAlbumMockAPI? = nil, api: TravelAlbumMockAPI? = nil,
userDefaults: UserDefaults = makeOTGTestDefaults() userDefaults: UserDefaults = makeOTGTestDefaults(),
autoRetouchConfigurationStore: (any TravelAlbumAutoRetouchConfigurationStoring)? = nil,
autoRetouchProcessor: (any TravelAlbumAutoRetouchProcessing)? = nil
) -> WiredCameraTransferViewModel { ) -> WiredCameraTransferViewModel {
prepareAppStoreForOTGTests() prepareAppStoreForOTGTests()
let configurationStore = autoRetouchConfigurationStore
?? TravelAlbumAutoRetouchConfigurationStore(userDefaults: userDefaults)
return WiredCameraTransferViewModel( return WiredCameraTransferViewModel(
albumId: albumId, albumId: albumId,
albumTitle: "相册", albumTitle: "相册",
@@ -726,7 +1230,9 @@ private func makeWiredViewModel(
storage: context.store, storage: context.store,
uploader: uploader ?? MockTravelAlbumOTGUploader(), uploader: uploader ?? MockTravelAlbumOTGUploader(),
api: api ?? TravelAlbumMockAPI(), api: api ?? TravelAlbumMockAPI(),
userDefaults: userDefaults userDefaults: userDefaults,
autoRetouchConfigurationStore: configurationStore,
autoRetouchProcessor: autoRetouchProcessor
) )
} }
@@ -741,13 +1247,14 @@ private extension TravelAlbumOTGPhotoRecord {
private func makeOTGPhotoItem( private func makeOTGPhotoItem(
id: String, id: String,
capturedAt: String, capturedAt: String,
status: TravelAlbumOTGUploadStatus = .pending status: TravelAlbumOTGUploadStatus = .pending,
thumbnailURL: URL? = nil
) -> TravelAlbumOTGPhotoItem { ) -> TravelAlbumOTGPhotoItem {
TravelAlbumOTGPhotoItem( TravelAlbumOTGPhotoItem(
id: id, id: id,
sourceId: id, sourceId: id,
fileName: "\(id).JPG", fileName: "\(id).JPG",
thumbnailURL: nil, thumbnailURL: thumbnailURL,
capturedAt: capturedAt, capturedAt: capturedAt,
fileSizeText: "1 KB", fileSizeText: "1 KB",
fileSizeBytes: 1024, fileSizeBytes: 1024,
@@ -755,7 +1262,9 @@ private func makeOTGPhotoItem(
progress: 0, progress: 0,
errorMessage: nil, errorMessage: nil,
localPath: "originals/\(id).JPG", localPath: "originals/\(id).JPG",
remoteUrl: "" remoteUrl: "",
autoRetouchState: .none,
autoRetouchTemplateId: nil
) )
} }
@@ -926,6 +1435,11 @@ private final class MockCameraDriver: CameraDriver {
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading { private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
private(set) var uploadCallCount = 0 private(set) var uploadCallCount = 0
private(set) var uploadedRecordIds: [String] = [] private(set) var uploadedRecordIds: [String] = []
private let materialID: Int
init(materialID: Int = 1) {
self.materialID = materialID
}
func upload( func upload(
record: TravelAlbumOTGPhotoRecord, record: TravelAlbumOTGPhotoRecord,
@@ -934,7 +1448,22 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
) async throws -> TravelAlbumMaterial { ) async throws -> TravelAlbumMaterial {
uploadCallCount += 1 uploadCallCount += 1
uploadedRecordIds.append(record.id) uploadedRecordIds.append(record.id)
return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)") return TravelAlbumMaterial(id: materialID, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)")
}
}
/// 自动修图测试替身,可切换失败以验证重试闭环。
@MainActor
private final class MockTravelAlbumAutoRetouchProcessor: TravelAlbumAutoRetouchProcessing {
var shouldFail = false
private(set) var processedPresetIDs: [String] = []
func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data {
processedPresetIDs.append(preset.id)
if shouldFail {
throw TravelAlbumAutoRetouchError.resultEncodingFailed
}
return Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6rE8AAAAASUVORK5CYII=")!
} }
} }
@@ -15,6 +15,15 @@ final class WiredTransferSettingChipButtonTests: XCTestCase {
XCTAssertEqual(button.accessibilityLabel, "AI修图") XCTAssertEqual(button.accessibilityLabel, "AI修图")
} }
@MainActor
func testAppliedValueKeepsPrimaryTextColorWhenInteractionIsDisabled() {
let button = WiredTransferSettingChipButton()
button.apply(title: "JPG")
button.isUserInteractionEnabled = false
XCTAssertEqual(button.displayedTitleColor, AppColor.textPrimary)
}
@MainActor @MainActor
func testShowsChevronDefaultsToFalse() { func testShowsChevronDefaultsToFalse() {
let button = WiredTransferSettingChipButton() let button = WiredTransferSettingChipButton()