新增ai修图
47
docs/assets/travel-album-ai/01-photo-preview-flow.svg
Normal file
@ -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 |
44
docs/assets/travel-album-ai/02-multi-select-flow.svg
Normal file
@ -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 |
31
docs/assets/travel-album-ai/02-upload-transfer-mode-flow.svg
Normal file
@ -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 |
56
docs/assets/travel-album-ai/03-ai-edit-sheet-flow.svg
Normal file
@ -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 |
49
docs/assets/travel-album-ai/04-ai-task-state-flow.svg
Normal file
@ -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 |
52
docs/assets/travel-album-ai/05-cover-template-flow.svg
Normal file
@ -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 |
51
docs/assets/travel-album-ai/06-thumbnail-state-flow.svg
Normal file
@ -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 |
261
docs/相册管理与AI修图功能说明.md
Normal file
@ -0,0 +1,261 @@
|
||||
# 相册管理与 AI 修图功能说明
|
||||
|
||||
> 文档日期:2026-07-29
|
||||
> 适用范围:新增相册 → 相册任务 → 相册管理 → 照片上传与传输 → 照片预览与 AI 修图
|
||||
> 数据说明:相册和照片列表、有线相机连接及照片传输继续使用项目已有真实数据能力;传输模式选择保存在本地。原图精修结果、氛围感结果、封面模板、剩余 9 张额度及状态角标目前为本地可演示数据,尚未接入服务端持久化,App 进程重启后会清空。
|
||||
|
||||
## 1. 照片预览、左右滑动与底部操作
|
||||
|
||||
### 功能说明
|
||||
|
||||
用户在“相册管理”照片网格中点击任意缩略图,进入单张照片预览页。页面只保留一个返回按钮,导航区域展示当前照片文件名和文件大小;照片区域支持左右滑动查看同一相册内的上一张或下一张照片。
|
||||
|
||||
预览页统一使用黑色沉浸式自定义头部,不再混用系统导航栏:左侧为圆形返回按钮,中间展示文件名和文件大小,右侧展示“当前张数/总张数”。切换照片时三处信息同步更新。照片预览与原图精修、氛围感、封面模板预览都直接使用同一个全屏模态容器,不进入项目导航栈,因此不会叠加系统白色导航栏或出现两个返回按钮;关闭后回到进入前的相册管理页或修图弹窗。
|
||||
|
||||
底部固定展示以下三个操作,不展示打印按钮:
|
||||
|
||||
| 操作 | 点击结果 | 操作期间或异常处理 |
|
||||
| --- | --- | --- |
|
||||
| AI修图 | 打开当前照片的 AI 修图弹窗 | 当前照片处于“修图中”时按钮置灰,避免重复提交 |
|
||||
| 删除 | 二次确认后删除当前照片 | 删除成功后自动定位到相邻照片;删除最后一张后返回相册管理 |
|
||||
| 刷新 | 重新加载当前照片及最新修图结果 | 刷新失败时不清空当前已显示内容,可再次点击 |
|
||||
|
||||
左右滑动时同步刷新文件名、文件大小、原图/修图后状态和底部按钮状态。第一张继续向右滑、最后一张继续向左滑时保持当前照片,不循环跳转。
|
||||
|
||||
页面底部图标使用项目内 `TravelAlbumPreviewIcons` SVG 资源,确保 AI 修图、删除、刷新三个入口视觉一致。
|
||||
|
||||
### 状态流转图
|
||||
|
||||

|
||||
|
||||
### 本功能验收
|
||||
|
||||
- [ ] 从任意缩略图进入时只显示一个返回入口。
|
||||
- [ ] 文件名和文件大小与当前照片一致。
|
||||
- [ ] 导航头部保持黑色沉浸样式,右侧张数随照片切换同步变化。
|
||||
- [ ] 左右滑动后照片和文件信息同步变化。
|
||||
- [ ] 首尾照片不会越界或循环。
|
||||
- [ ] 底部仅显示 AI修图、删除、刷新,不显示打印。
|
||||
- [ ] 删除当前照片后能正确定位或退出预览。
|
||||
|
||||
## 2. 上传照片与传输模式选择
|
||||
|
||||
### 功能说明
|
||||
|
||||
相册管理处于普通状态时,用户点击底部“上传照片”,先从底部弹出“选择传输模式”,展示“边拍边传”和“拍后传输”两个选项。点击任一选项后弹窗关闭,并进入当前相册的有线传输页面;点击“取消”或向下关闭弹窗时停留在相册管理,不改变现有照片和选择状态。
|
||||
|
||||
| 传输模式 | 弹窗说明 | 下一页初始状态 | 后续行为 |
|
||||
| --- | --- | --- | --- |
|
||||
| 边拍边传 | 相机拍摄后,照片自动传输并上传到当前相册 | 传输模式显示“边拍边传” | 新传入 App 的照片自动上传 |
|
||||
| 拍后传输 | 拍摄完成后,再选择相机照片批量传输 | 传输模式显示“拍后传输” | 新照片先保留,用户后续选择并上传 |
|
||||
|
||||
本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据,不创建新的相册或假照片。
|
||||
|
||||
进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。
|
||||
|
||||
### 状态流转图
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 本功能验收
|
||||
|
||||
- [ ] 点击“上传照片”后先显示底部传输模式弹窗,不直接进入下一页。
|
||||
- [ ] 弹窗同时显示“边拍边传”和“拍后传输”及对应说明。
|
||||
- [ ] 选择“边拍边传”后,下一页模式显示为“边拍边传”。
|
||||
- [ ] 选择“拍后传输”后,下一页模式显示为“拍后传输”。
|
||||
- [ ] 本次选择不会被上一次保存的模式覆盖。
|
||||
- [ ] 取消或关闭弹窗后仍停留在相册管理。
|
||||
- [ ] 非批量选择状态点击缩略图后,从被点击照片进入全屏预览。
|
||||
- [ ] 预览可左右滑动查看当前 Tab 中的上一张和下一张,页码同步变化且首尾不循环。
|
||||
- [ ] 批量选择状态点击照片只改变选择状态,不打开预览。
|
||||
|
||||
## 3. 相册管理多选与底部操作栏切换
|
||||
|
||||
### 功能说明
|
||||
|
||||
相册管理页普通状态下,底部显示“上传照片”。用户点击右上角选择按钮后进入多选状态,每张缩略图右上角显示选择圆圈,底部“上传照片”隐藏,替换为“AI修图”和“删除”。
|
||||
|
||||
| 多选状态 | 页面表现 | 可用操作 | 下一状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 未进入多选 | 不显示选择圆圈,底部显示上传照片 | 点击照片进入预览;点击右上角选择 | 进入多选 |
|
||||
| 已进入但未选照片 | 显示未选圆圈,AI修图和删除置灰 | 选择照片;再次点击右上角选择退出 | 已选择或退出 |
|
||||
| 已选择照片 | 选中照片显示勾选,AI修图和删除可用 | 发起批量修图或批量删除 | 弹窗、处理中或删除结果 |
|
||||
| 选中项含修图中的照片 | 保留当前选择,AI修图置灰 | 等待任务完成;删除仍按现有删除规则处理 | 任务结束后恢复 |
|
||||
| 退出多选 | 清空本次选择,恢复上传照片 | 上传或进入照片预览 | 普通状态 |
|
||||
|
||||
批量删除必须二次确认;用户取消时不删除任何照片并保留当前选择。批量 AI 修图复用与单张预览相同的预设、任务状态和结果覆盖逻辑。
|
||||
|
||||
### 状态流转图
|
||||
|
||||

|
||||
|
||||
### 本功能验收
|
||||
|
||||
- [ ] 进入多选后上传按钮消失,AI修图和删除出现。
|
||||
- [ ] 未选择照片时两个批量按钮不可点击。
|
||||
- [ ] 选择至少一张后两个批量按钮恢复可用。
|
||||
- [ ] 包含修图中照片时不能再次发起 AI 修图。
|
||||
- [ ] 退出多选后选择状态清空并恢复上传按钮。
|
||||
|
||||
## 4. AI 修图弹窗、原图精修、氛围感与按张收费模式
|
||||
|
||||
### 功能说明
|
||||
|
||||
用户从单张预览或相册多选操作栏点击“AI修图”后,以同一个底部弹窗展示 AI 修图配置。标题与弹窗顶部保留足够间距,标题下方展示“已选择 N 张照片”。弹窗内容可纵向滚动,取消和确定按钮固定在底部,照片较多并出现封面模块时也不会遮挡选择项。
|
||||
|
||||
“原图精修”是基础修图模块,使用横向滚动单选列表,每个预设同时展示名称和对应效果图:
|
||||
|
||||
| 预设 | 当前效果用途 |
|
||||
| --- | --- |
|
||||
| 还原为原图 | 清除该照片已有修图结果和封面类型 |
|
||||
| 写真-简约肖像 | 低饱和、提亮的人像效果 |
|
||||
| 写真-清冷古风 | 清冷复古效果 |
|
||||
| 旅拍-锦绣 | 提升饱和度和对比度 |
|
||||
| 旅拍-远山 | 低饱和、柔和远景效果 |
|
||||
| 旅拍-薄雾 | 提亮并降低对比度 |
|
||||
| 油画-夏日 | 偏暖夏日效果 |
|
||||
| 油画-浓郁 | 高饱和、高对比效果 |
|
||||
|
||||
原图精修默认选中“还原为原图”,用户点击其他预设后仅保留一个选中项。没有可用原图时,效果图位置显示稳定占位图,不影响弹窗选择。每张样式效果图右下角都有“预览”按钮,点击后全屏查看当前照片应用该样式的效果,不改变当前选中项。
|
||||
|
||||
“氛围感修图”位于原图精修下方,并明确标记为“选填”。该模块与原图精修保持一致,使用横向滚动效果图单选,当前本地演示包含日落暖阳、复古胶片、清透蓝调、森系薄雾、浓郁故事五种样式。默认不选中;点击一种样式后仅保留该项,再次点击当前样式即可取消。每个样式同样提供“预览”按钮。选中后不是把氛围效果叠加并合并到精修图中,而是为每张照片额外生成一张可独立查看的“氛围感”结果。
|
||||
|
||||
原图精修、氛围感、封面模板和“查看高清图”统一进入同一个沉浸式大图预览页。大图页不再套用系统导航栏,只显示一个返回按钮、当前样式名称和“双指缩放查看细节”提示;关闭后回到原弹窗或照片页,并保留关闭前的选择状态。
|
||||
|
||||
弹窗内的 Tips 会随当前组合实时变化:
|
||||
|
||||
| 当前选择 | 确认后的结果 |
|
||||
| --- | --- |
|
||||
| 仅原图精修 | 每张照片生成 1 个“精修后”结果 |
|
||||
| 原图精修 + 氛围感修图 | 每张照片生成“精修后 + 氛围感”2 个独立结果 |
|
||||
| 有封面、未选氛围感 | 每张照片生成精修结果,第一张选中照片额外生成封面 |
|
||||
| 有封面、已选氛围感 | 每张照片生成精修和氛围感 2 个结果,第一张选中照片再额外生成封面 |
|
||||
|
||||
“修图模式”区域只读展示“AI精修 · 按张收费”和“剩余 9 张”,不再显示勾选圆圈,也不能点击选择。当前剩余数量为本地演示数据,不会真实扣减额度;未来接入服务端时由接口返回剩余额度,并以任务创建成功或服务端明确返回的扣减结果为准。
|
||||
|
||||
点击“确定”提交当前原图精修、氛围感和封面组合并关闭弹窗;点击“取消”或关闭弹窗时不创建任务、不改变旧修图结果,也不改变额度。
|
||||
|
||||
### 状态流转图
|
||||
|
||||

|
||||
|
||||
### 本功能验收
|
||||
|
||||
- [ ] 标题与弹窗顶部间距自然,不贴近顶部。
|
||||
- [ ] 已选照片数量准确。
|
||||
- [ ] 原图精修预设同时展示文字和效果图,并可横向滚动、保持单选。
|
||||
- [ ] 原图精修、氛围感和封面模板效果图均可通过小按钮全屏预览,预览不改变选择。
|
||||
- [ ] 所有大图预览使用同一黑色头部、单一返回入口,并支持双指缩放。
|
||||
- [ ] 氛围感修图横向展示多个效果图,默认不选中、保持单选,可再次点击当前样式取消。
|
||||
- [ ] 同时选择原图精修和氛围感时,每张照片生成两个独立结果。
|
||||
- [ ] Tips 能根据氛围感和封面选择动态说明实际输出结果。
|
||||
- [ ] 修图模式只展示按张收费与剩余张数,不提供选择操作。
|
||||
- [ ] 取消弹窗不会创建任务或覆盖旧结果。
|
||||
|
||||
## 5. AI 修图任务状态、失败重试与结果覆盖
|
||||
|
||||
### 功能说明
|
||||
|
||||
用户确认修图后,所选照片立即进入“修图中”。任务未结束前,相关 AI 修图入口置灰;即使用户离开照片预览页,本地演示任务仍会继续完成,并通过共享结果状态刷新相册缩略图。
|
||||
|
||||
| 任务状态 | 进入条件 | 页面表现 | 可用操作 | 后续状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 可发起 | 未修图、已有成功结果或上次任务已结束 | AI修图按钮可用 | 选择预设并确认 | 修图中 |
|
||||
| 修图中 | 已提交预设,等待结果 | 按钮置灰;缩略图显示“修图中” | 查看其他照片、离开页面 | 成功或失败 |
|
||||
| 成功 | 获取原图并完成本地效果处理 | 分别保存精修、氛围感和封面结果;默认展示“精修后”;按钮恢复 | 在已有结果间切换、再次修图、还原原图 | 新任务或已上传 |
|
||||
| 失败 | 原图无法获取或处理失败 | 缩略图显示“修图失败”;按钮恢复 | 再次修图 | 修图中 |
|
||||
|
||||
成功后,照片预览页至少出现“原图 / 精修后”切换;本次选择了氛围感时增加“氛围感”,存在封面结果时增加“封面”。各结果互相独立,不把两个效果合成同一张图;有修图结果时还可以点击“查看高清图”。再次修图时,旧结果在新任务处理期间继续保留,新任务成功后整组替换为本次最新组合:本次未选择氛围感时会清除旧氛围感结果;新任务失败时保留旧成功图片,但状态优先显示“修图失败”,提醒用户本次任务未成功。
|
||||
|
||||
选择“还原为原图”会同时清除修图结果、失败状态和封面类型,缩略图恢复为“已上传”。删除照片时同步清理该照片的本地修图记录,避免已删除照片的结果残留。
|
||||
|
||||
### 状态流转图
|
||||
|
||||

|
||||
|
||||
### 本功能验收
|
||||
|
||||
- [ ] 修图中不能重复点击 AI修图。
|
||||
- [ ] 离开预览页后任务仍能结束并刷新缩略图。
|
||||
- [ ] 成功后可切换查看原图、精修后以及本次实际生成的氛围感或封面。
|
||||
- [ ] 同选原图精修和氛围感时两个结果均可独立查看。
|
||||
- [ ] 再次修图成功后以本次结果组合覆盖旧结果。
|
||||
- [ ] 失败后按钮恢复并允许重试。
|
||||
- [ ] 还原原图后所有本地修图类型和结果被清除。
|
||||
|
||||
## 6. 选中 4 张及以上时的封面模板选择
|
||||
|
||||
### 功能说明
|
||||
|
||||
从相册管理多选照片并点击“AI修图”时,系统根据选中照片数量决定是否展示“封面风格模板”:
|
||||
|
||||
| 选中数量 | 弹窗表现 |
|
||||
| --- | --- |
|
||||
| 1~3 张 | 展示“原图精修 + 氛围感修图(选填)”两个模块,不展示封面模板 |
|
||||
| ≥ 4 张 | 展示“原图精修 + 氛围感修图(选填)+ 封面模板”三个模块,并提高弹窗高度 |
|
||||
|
||||
封面模板为横向滚动单选,默认选中“旅行纪念册”。每个模板效果图右下角提供“预览”按钮,点击后全屏查看完整排版,不改变当前模板选择。当前提供:
|
||||
|
||||
- 旅行纪念册
|
||||
- 简约留白
|
||||
- 景区故事
|
||||
- 电影胶片
|
||||
|
||||
每个模板展示名称和以第一张选中照片生成的排版效果图。用户确认后,所有选中照片先生成原图精修结果;选择了氛围感时,所有照片再各自生成一个氛围感结果;第一张选中照片另外生成独立封面并作为封面承载图。封面不会替代精修或氛围感结果,用户进入预览后可分别切换查看。
|
||||
|
||||
当前所有封面模板 `quotaCost = 0`,弹窗显示“赠送 · 不占精修张数”,不会减少剩余修图额度。后续收费时保留同一选择流程,由服务端为模板返回大于 0 的 `quotaCost`,客户端在确认前显示实际消耗,并以服务端成功结果为准扣减;收费能力目前尚未实现。
|
||||
|
||||
再次对不少于 4 张照片选择新封面模板并修图时,新封面效果覆盖旧封面效果。封面承载图处理失败时不标记为封面成功,缩略图显示“修图失败”并允许重试。选择“还原为原图”时清除封面效果和封面类型。
|
||||
|
||||
### 状态流转图
|
||||
|
||||

|
||||
|
||||
### 本功能验收
|
||||
|
||||
- [ ] 选中 3 张时不显示封面模板。
|
||||
- [ ] 选中 4 张或更多时显示封面模板。
|
||||
- [ ] 有封面模块时,弹窗同时完整显示原图精修、氛围感和封面三个风格模块。
|
||||
- [ ] 封面模板可横向滚动且始终单选。
|
||||
- [ ] 每个封面模板均可独立预览,关闭预览后保留原选择。
|
||||
- [ ] 默认选中旅行纪念册。
|
||||
- [ ] 当前显示赠送并且不消耗精修额度。
|
||||
- [ ] 成功后第一张选中照片成为封面承载图。
|
||||
- [ ] 再次生成封面时以新结果覆盖旧结果。
|
||||
|
||||
## 7. 相册缩略图结果类型与显示优先级
|
||||
|
||||
### 功能说明
|
||||
|
||||
相册管理缩略图左上角复用同一状态位,根据照片当前结果显示不同类型,让用户无需进入预览即可区分未修、处理中、已修、封面和失败照片。
|
||||
|
||||
| 缩略图状态 | 角标文案与颜色 | 缩略图内容 | 进入条件 | 可用操作 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 普通原图 | 绿色“已上传” | 服务端原图或缩略图 | 没有本地修图结果 | 预览、选择、AI修图、删除 |
|
||||
| 处理中 | 蓝色“修图中” | 有旧成功结果时继续显示旧结果,否则显示原图 | AI 任务未结束 | 预览、删除;不能再次修图 |
|
||||
| 普通修图结果 | 紫色“AI已修” | 最新精修图片 | 原图精修成功,可同时存在氛围感结果 | 预览原图/精修后/氛围感、再次修图、还原 |
|
||||
| 封面结果 | 橙色“AI封面” | 所选封面模板排版图 | 封面承载图生成成功 | 预览、再次修图、重新生成封面、还原 |
|
||||
| 修图失败 | 红色“修图失败” | 有旧成功结果时保留旧图,否则显示原图 | 最近一次任务失败 | 再次修图、删除 |
|
||||
|
||||
状态显示优先级为:
|
||||
|
||||
`修图中 > 修图失败 > AI封面 > AI已修 > 已上传`
|
||||
|
||||
该优先级保证处理中或失败状态不会被历史成功结果遮盖。普通修图成功后显示“AI已修”;封面模板成功后,第一张选中照片显示封面排版图和“AI封面”,其他照片继续显示“AI已修”。再次修图成功后缩略图切换为最新结果;还原原图后回到“已上传”;删除照片后对应本地状态一起删除。
|
||||
|
||||
当前状态和结果由内存数据源在相册管理与照片预览之间共享,尚不支持 App 重启后的恢复。服务端接入时至少需要返回素材 ID、任务状态、结果类型、结果图地址、原图精修预设 ID、是否生成氛围感、封面模板 ID、失败原因和额度消耗,客户端才能恢复完全一致的缩略图状态。
|
||||
|
||||
### 状态流转图
|
||||
|
||||

|
||||
|
||||
### 本功能验收
|
||||
|
||||
- [ ] 未修图照片显示“已上传”。
|
||||
- [ ] 修图中照片显示“修图中”且不能重复提交。
|
||||
- [ ] 普通成功结果显示“AI已修”和最新效果图。
|
||||
- [ ] 封面成功结果显示“AI封面”和封面排版图。
|
||||
- [ ] 最近一次任务失败时优先显示“修图失败”。
|
||||
- [ ] 还原、覆盖和删除后缩略图状态同步更新。
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
21
suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_alpine_lake.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
@ -116,6 +116,8 @@ struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable {
|
||||
|
||||
/// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。
|
||||
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
private static let bundledImageURLPrefix = "asset://"
|
||||
|
||||
let id: Int
|
||||
let userEquityTravelId: Int
|
||||
let status: Int
|
||||
@ -175,6 +177,18 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
||||
self.createdAt = createdAt
|
||||
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`。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -52,6 +52,12 @@ struct TravelAlbumOTGPhotoSection: Hashable, Sendable {
|
||||
let photos: [TravelAlbumOTGPhotoItem]
|
||||
}
|
||||
|
||||
/// 有线传输照片预览上下文,保存可预览照片顺序和点击照片位置。
|
||||
struct TravelAlbumOTGPhotoPreviewContext: Equatable, Sendable {
|
||||
let photos: [TravelAlbumOTGPhotoItem]
|
||||
let initialIndex: Int
|
||||
}
|
||||
|
||||
/// OTG 传输页 Tab。
|
||||
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
|
||||
case all
|
||||
@ -80,6 +86,16 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 模式选择弹窗中的业务说明。
|
||||
var detailText: String {
|
||||
switch self {
|
||||
case .liveUpload:
|
||||
return "相机拍摄后,照片自动传输并上传到当前相册"
|
||||
case .postTransfer:
|
||||
return "拍摄完成后,再选择照片批量传输"
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否在新照片导入 App 后自动上传。
|
||||
var shouldAutoUploadNewImports: Bool {
|
||||
self == .liveUpload
|
||||
@ -113,6 +129,18 @@ extension TravelAlbumOTGPhotoItem {
|
||||
}
|
||||
|
||||
extension Array where Element == TravelAlbumOTGPhotoItem {
|
||||
/// 按当前列表顺序生成预览上下文,不可预览照片不会占用左右滑动页码。
|
||||
func previewContext(startingWith photoID: String) -> TravelAlbumOTGPhotoPreviewContext? {
|
||||
let previewablePhotos = filter { $0.thumbnailURL != nil }
|
||||
guard let initialIndex = previewablePhotos.firstIndex(where: { $0.id == photoID }) else {
|
||||
return nil
|
||||
}
|
||||
return TravelAlbumOTGPhotoPreviewContext(
|
||||
photos: previewablePhotos,
|
||||
initialIndex: initialIndex
|
||||
)
|
||||
}
|
||||
|
||||
/// 按 Android OTG 页规则构造半小时照片分段。
|
||||
func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] {
|
||||
let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in
|
||||
|
||||
@ -0,0 +1,308 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// 修图失败时恢复按钮可用状态,并保留之前成功的结果。
|
||||
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,
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,7 @@ final class TravelAlbumDetailViewModel {
|
||||
private(set) var sortOption: SortOption = .createdDesc
|
||||
private(set) var isLoading = true
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var isUsingPurchasedDemoData = false
|
||||
private(set) var isSelectionMode = false
|
||||
private(set) var selectedMaterialIds: Set<Int> = []
|
||||
|
||||
@ -126,6 +127,7 @@ final class TravelAlbumDetailViewModel {
|
||||
if reset {
|
||||
currentPage = 1
|
||||
canLoadMore = false
|
||||
isUsingPurchasedDemoData = false
|
||||
} else {
|
||||
guard canLoadMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
@ -140,8 +142,15 @@ final class TravelAlbumDetailViewModel {
|
||||
orderBy: sortOption.rawValue,
|
||||
isPurchased: selectedTab == .purchased ? 1 : nil
|
||||
)
|
||||
if reset,
|
||||
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 {
|
||||
allPhotoCount = response.total
|
||||
}
|
||||
@ -154,6 +163,12 @@ final class TravelAlbumDetailViewModel {
|
||||
currentPage -= 1
|
||||
}
|
||||
isLoadingMore = false
|
||||
if reset, selectedTab == .purchased {
|
||||
// 已购接口暂不可用时继续提供稳定的本地演示数据,保证产品流程可演示。
|
||||
applyPurchasedDemoMaterials()
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
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() {
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
@ -94,6 +94,7 @@ final class WiredCameraTransferViewModel {
|
||||
albumTitle: String,
|
||||
headerPhone: String,
|
||||
scenicSpotLabel: String? = nil,
|
||||
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
|
||||
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
||||
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
||||
uploader: (any TravelAlbumOTGUploading)? = nil,
|
||||
@ -112,7 +113,11 @@ final class WiredCameraTransferViewModel {
|
||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||
self.appStore = appStore
|
||||
self.userDefaults = userDefaults
|
||||
self.transferMode = Self.persistedTransferMode(in: userDefaults)
|
||||
// 从相册管理选择模式进入时,以本次选择为准;其他旧入口继续沿用上次记录。
|
||||
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
|
||||
if let initialTransferMode {
|
||||
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机状态文案。
|
||||
|
||||
@ -11,6 +11,9 @@ import UIKit
|
||||
final class TravelAlbumDetailViewController: BaseViewController {
|
||||
private let viewModel: TravelAlbumDetailViewModel
|
||||
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 infoCard = TravelAlbumInfoCard()
|
||||
@ -24,9 +27,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
|
||||
private let bottomBar = UIView()
|
||||
private let bottomActionStack = UIStackView()
|
||||
private let aiEditSelectedButton = UIButton(type: .system)
|
||||
private let deleteSelectedButton = UIButton(type: .system)
|
||||
private let uploadButton = UIButton(type: .system)
|
||||
private var isDeleteSelectedButtonVisible = false
|
||||
|
||||
init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
|
||||
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
|
||||
@ -41,6 +44,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "相册管理"
|
||||
navigationItem.backButtonDisplayMode = .minimal
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "ellipsis"),
|
||||
menu: UIMenu(children: [
|
||||
@ -78,21 +82,39 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) 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(
|
||||
material: material,
|
||||
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
|
||||
}
|
||||
|
||||
bottomBar.backgroundColor = AppColor.pageBackground
|
||||
bottomActionStack.axis = .vertical
|
||||
bottomActionStack.spacing = 8
|
||||
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935))
|
||||
bottomActionStack.axis = .horizontal
|
||||
bottomActionStack.spacing = 12
|
||||
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)
|
||||
aiEditSelectedButton.isHidden = true
|
||||
deleteSelectedButton.isHidden = true
|
||||
deleteSelectedButton.alpha = 0
|
||||
|
||||
view.addSubview(scrollContainer)
|
||||
scrollContainer.addSubview(infoCard)
|
||||
@ -105,6 +127,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
manageCard.addSubview(collectionView)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(bottomActionStack)
|
||||
bottomActionStack.addArrangedSubview(aiEditSelectedButton)
|
||||
bottomActionStack.addArrangedSubview(deleteSelectedButton)
|
||||
bottomActionStack.addArrangedSubview(uploadButton)
|
||||
}
|
||||
@ -118,11 +141,10 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
make.leading.trailing.equalToSuperview().inset(20)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
deleteSelectedButton.snp.makeConstraints { make in
|
||||
make.height.equalTo(48).priority(.high)
|
||||
[aiEditSelectedButton, deleteSelectedButton, uploadButton].forEach { button in
|
||||
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
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
@ -163,8 +185,15 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
|
||||
sortButton.addTarget(self, action: #selector(sortTapped), 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)
|
||||
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
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
@ -195,8 +224,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
selectButton.isHidden = viewModel.selectedTab != .all
|
||||
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal)
|
||||
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary
|
||||
deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal)
|
||||
setDeleteSelectedButtonVisible(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
|
||||
updateBottomToolbar()
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
|
||||
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) {
|
||||
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
button.layer.cornerRadius = 16
|
||||
@ -242,27 +292,20 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
button.layer.cornerRadius = 10
|
||||
}
|
||||
|
||||
private func setDeleteSelectedButtonVisible(_ visible: Bool) {
|
||||
guard visible != isDeleteSelectedButtonVisible else { return }
|
||||
isDeleteSelectedButtonVisible = visible
|
||||
|
||||
let changes = {
|
||||
self.deleteSelectedButton.isHidden = !visible
|
||||
self.deleteSelectedButton.alpha = visible ? 1 : 0
|
||||
self.view.layoutIfNeeded()
|
||||
/// 配置多选模式底部的图标操作按钮,图标复用照片预览页的 Iconfont 资源。
|
||||
private func configureSelectionActionButton(_ button: UIButton, title: String, image: UIImage?) {
|
||||
var configuration = UIButton.Configuration.plain()
|
||||
configuration.title = title
|
||||
configuration.image = image
|
||||
configuration.imagePlacement = .top
|
||||
configuration.imagePadding = 5
|
||||
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 {
|
||||
changes()
|
||||
return
|
||||
}
|
||||
|
||||
view.layoutIfNeeded()
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
delay: 0,
|
||||
options: [.curveEaseInOut, .beginFromCurrentState],
|
||||
animations: changes
|
||||
)
|
||||
button.configuration = configuration
|
||||
}
|
||||
|
||||
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
||||
@ -319,6 +362,157 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
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() {
|
||||
let count = viewModel.selectedMaterialIds.count
|
||||
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
|
||||
@ -331,22 +525,60 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
}
|
||||
|
||||
@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 controller = WiredCameraTransferViewController(
|
||||
viewModel: WiredCameraTransferViewModel(
|
||||
albumId: album?.id ?? viewModel.albumId,
|
||||
albumTitle: album?.name ?? "",
|
||||
headerPhone: album?.displayPhone ?? ""
|
||||
headerPhone: album?.displayPhone ?? "",
|
||||
initialTransferMode: mode
|
||||
)
|
||||
)
|
||||
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 {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
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) {
|
||||
@ -499,7 +731,29 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
imageView.kf.cancelDownloadTask()
|
||||
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 {
|
||||
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
|
||||
if let url = URL(string: urlString), !urlString.isEmpty {
|
||||
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
|
||||
@ -508,10 +762,39 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
imageView.tintColor = AppColor.primary
|
||||
imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
|
||||
}
|
||||
}
|
||||
applyAIEditStatus(aiEditState.thumbnailStatus, isPurchased: material.isPurchased)
|
||||
checkImageView.isHidden = !selectionMode
|
||||
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
checkImageView.tintColor = selected ? AppColor.primary : .white
|
||||
nameLabel.text = material.fileName
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1351
suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -788,14 +788,23 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
}
|
||||
|
||||
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("暂无可预览图片")
|
||||
return
|
||||
}
|
||||
let previewItem: MediaPreviewItem = url.isFileURL
|
||||
let previewItems = context.photos.compactMap { photo -> MediaPreviewItem? in
|
||||
guard let url = photo.thumbnailURL else { return nil }
|
||||
return url.isFileURL
|
||||
? .localImage(url: url)
|
||||
: MediaPreviewItem(source: .remoteImage(url))
|
||||
MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0)
|
||||
}
|
||||
MediaPreviewViewController.present(
|
||||
from: self,
|
||||
items: previewItems,
|
||||
startIndex: context.initialIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,11 @@ final class WiredTransferSettingChipButton: UIButton {
|
||||
/// 当前展示的标题文案。
|
||||
private(set) var titleText: String = ""
|
||||
|
||||
/// 当前值的展示颜色,供状态测试确认不可交互时仍保持清晰。
|
||||
var displayedTitleColor: UIColor? {
|
||||
titleLabelView.textColor
|
||||
}
|
||||
|
||||
/// 创建设置 chip 按钮。
|
||||
/// - Parameter showsChevron: 是否展示下拉箭头,默认不展示。
|
||||
init(showsChevron: Bool = false) {
|
||||
@ -49,13 +54,14 @@ final class WiredTransferSettingChipButton: UIButton {
|
||||
layer.borderColor = AppColor.border.cgColor
|
||||
|
||||
titleLabelView.font = .systemFont(ofSize: 11)
|
||||
titleLabelView.textColor = AppColor.textTertiary
|
||||
// 当前设置值即使不可点击也代表已经生效,使用主文字色避免呈现为禁用状态。
|
||||
titleLabelView.textColor = AppColor.textPrimary
|
||||
titleLabelView.lineBreakMode = .byTruncatingTail
|
||||
titleLabelView.isUserInteractionEnabled = false
|
||||
|
||||
chevronView.image = UIImage(systemName: "chevron.down")?
|
||||
.withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold))
|
||||
chevronView.tintColor = AppColor.textTertiary
|
||||
chevronView.tintColor = AppColor.textSecondary
|
||||
chevronView.contentMode = .scaleAspectFit
|
||||
chevronView.isUserInteractionEnabled = false
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import UIKit
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
@ -75,6 +76,208 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
|
||||
@MainActor
|
||||
/// 旅拍相册详情 ViewModel 测试。
|
||||
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 {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.infoResponse = TravelAlbum(id: 2, name: "详情")
|
||||
@ -106,6 +309,53 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
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() {
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||
viewModel.toggleSelectionMode()
|
||||
@ -170,6 +420,29 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
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() {
|
||||
let context = makeOTGTestContext()
|
||||
let viewModel = makeWiredViewModel(
|
||||
@ -210,6 +483,31 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
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 {
|
||||
let context = makeOTGTestContext()
|
||||
context.store.save([
|
||||
@ -741,13 +1039,14 @@ private extension TravelAlbumOTGPhotoRecord {
|
||||
private func makeOTGPhotoItem(
|
||||
id: String,
|
||||
capturedAt: String,
|
||||
status: TravelAlbumOTGUploadStatus = .pending
|
||||
status: TravelAlbumOTGUploadStatus = .pending,
|
||||
thumbnailURL: URL? = nil
|
||||
) -> TravelAlbumOTGPhotoItem {
|
||||
TravelAlbumOTGPhotoItem(
|
||||
id: id,
|
||||
sourceId: id,
|
||||
fileName: "\(id).JPG",
|
||||
thumbnailURL: nil,
|
||||
thumbnailURL: thumbnailURL,
|
||||
capturedAt: capturedAt,
|
||||
fileSizeText: "1 KB",
|
||||
fileSizeBytes: 1024,
|
||||
|
||||
@ -15,6 +15,15 @@ final class WiredTransferSettingChipButtonTests: XCTestCase {
|
||||
XCTAssertEqual(button.accessibilityLabel, "AI修图")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testAppliedValueKeepsPrimaryTextColorWhenInteractionIsDisabled() {
|
||||
let button = WiredTransferSettingChipButton()
|
||||
button.apply(title: "JPG")
|
||||
button.isUserInteractionEnabled = false
|
||||
|
||||
XCTAssertEqual(button.displayedTitleColor, AppColor.textPrimary)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testShowsChevronDefaultsToFalse() {
|
||||
let button = WiredTransferSettingChipButton()
|
||||
|
||||