fix: persist uploaded canvas reference images

This commit is contained in:
2026-05-27 15:54:22 +08:00
parent 685a6c4d64
commit ec38215dd5
4 changed files with 57 additions and 21 deletions

View File

@@ -623,6 +623,7 @@
<p><strong>2026-05-26 AI 润色中性化:</strong>画布 <code>AI 润色</code> 不再复用 SKG 广告文案接口 <code>/creative/copy</code>。后端新增 <code>POST /prompt/polish</code>,前端 <code>useChat</code>、根画布输入框、文本节点和自动执行意图分析改走中性提示词/通用生成接口:只优化用户已经给出的主体、风格、镜头和细节,不主动添加 SKG、按摩产品、TikTok 广告话术或用户没有提到的品牌。当前润色链路会先清理上一次润色遗留的模板尾巴,再判断人物/无人/物体/场景/动物/未知主体;原文明确有人时才声明虚构 AI 角色,原文明确无人时才保留无人物约束,原文没写人时不主动造人但也不追加“必须无人物”的模板尾巴;当输入或参考图已经有人物时,按 AI 生成的虚拟角色继续描述,而不是把人物参考图判定为不可用。</p> <p><strong>2026-05-26 AI 润色中性化:</strong>画布 <code>AI 润色</code> 不再复用 SKG 广告文案接口 <code>/creative/copy</code>。后端新增 <code>POST /prompt/polish</code>,前端 <code>useChat</code>、根画布输入框、文本节点和自动执行意图分析改走中性提示词/通用生成接口:只优化用户已经给出的主体、风格、镜头和细节,不主动添加 SKG、按摩产品、TikTok 广告话术或用户没有提到的品牌。当前润色链路会先清理上一次润色遗留的模板尾巴,再判断人物/无人/物体/场景/动物/未知主体;原文明确有人时才声明虚构 AI 角色,原文明确无人时才保留无人物约束,原文没写人时不主动造人但也不追加“必须无人物”的模板尾巴;当输入或参考图已经有人物时,按 AI 生成的虚拟角色继续描述,而不是把人物参考图判定为不可用。</p>
<p><strong>2026-05-26 我的工作流云端版:</strong>工作流面板从只有公共模板扩展为“公共工作流 / 我的工作流”两类。当前画布可以保存成当前登录用户自己的云端工作流模板,后续在同一账号的其他电脑或浏览器打开后可插回画布;保存时只沉淀节点结构、连线、配置和提示词,主动清掉已生成图片、视频、任务进度、错误和运行态字段,避免把一次性生成结果误当模板复用。</p> <p><strong>2026-05-26 我的工作流云端版:</strong>工作流面板从只有公共模板扩展为“公共工作流 / 我的工作流”两类。当前画布可以保存成当前登录用户自己的云端工作流模板,后续在同一账号的其他电脑或浏览器打开后可插回画布;保存时只沉淀节点结构、连线、配置和提示词,主动清掉已生成图片、视频、任务进度、错误和运行态字段,避免把一次性生成结果误当模板复用。</p>
<p><strong>2026-05-27 画布点击响应优化:</strong>大画布启用 Vue Flow 可见节点渲染,并在载入旧项目时补齐节点尺寸,让几百个节点的项目不再一次性渲染全部节点;新增节点、批量插入节点/边和复制节点改为原地追加,避免单次点击触发整条节点数组重复替换。大项目右下角 MiniMap 只在 120 个节点以内显示,优先保证编辑响应速度。</p> <p><strong>2026-05-27 画布点击响应优化:</strong>大画布启用 Vue Flow 可见节点渲染,并在载入旧项目时补齐节点尺寸,让几百个节点的项目不再一次性渲染全部节点;新增节点、批量插入节点/边和复制节点改为原地追加,避免单次点击触发整条节点数组重复替换。大项目右下角 MiniMap 只在 120 个节点以内显示,优先保证编辑响应速度。</p>
<p><strong>2026-05-27 上传参考图持久化:</strong>画布图片节点上传本地文件时先写入后端 creative job再把 <code>/api/jobs/...</code> 资产 URL 保存到节点和服务端画布项目;不再把浏览器 <code>data:</code> base64 当作图片地址保存。项目自动保存增加内容签名去重和 2 秒防抖,减少连续点击或节点测量触发的重复 <code>PUT /canvas-projects</code></p>
<p><strong>2026-05-26 生图配置恢复版:</strong>按用户要求撤回后续“低/中/高画质、自定义尺寸、Gemini 官方 1K/2K/4K 尺寸、取消自动模型”的实验改动,恢复最初简单配置:图片模型为 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只保留 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>,画质回到单一标准项;<code>auto</code> 仍按后端既有策略优先 GPT Image 2必要时由熔断/兜底走 Gemini。</p> <p><strong>2026-05-26 生图配置恢复版:</strong>按用户要求撤回后续“低/中/高画质、自定义尺寸、Gemini 官方 1K/2K/4K 尺寸、取消自动模型”的实验改动,恢复最初简单配置:图片模型为 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只保留 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>,画质回到单一标准项;<code>auto</code> 仍按后端既有策略优先 GPT Image 2必要时由熔断/兜底走 Gemini。</p>
</div> </div>
<p>当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色、公共工作流或我的工作流创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 可把当前节点结构保存为我的工作流 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 <code>web/canvas-app/src/hooks/useApi.js</code> 适配到本项目 <code>/creative/jobs/image</code><code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code>AI 润色和通用 LLM 文本生成走 <code>/prompt/polish</code> 并保持中性专业:不主动套入 SKG不主动补产品、平台、广告语境或人物只扩写用户明确写出的主体、动作、场景、镜头、光线和质量细节视频提交若带参考图会在最终提示词中条件声明“参考图里若有人物应按 AI 生成的虚拟角色处理”,避免把 AI 人像素材误当成真实肖像。生成资产按当前登录用户写入个人 job。图片尺寸只显示 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频画幅只显示 <code>720x1280</code><code>1280x720</code><code>1024x1024</code><code>960x1280</code>;视频时长只显示 <code>5/8/10/12/15</code> 秒。多人互不影响依赖后端 <code>owner_id</code>、画布项目 owner、我的工作流 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。</p> <p>当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色、公共工作流或我的工作流创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 可把当前节点结构保存为我的工作流 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 <code>web/canvas-app/src/hooks/useApi.js</code> 适配到本项目 <code>/creative/jobs/image</code><code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code>AI 润色和通用 LLM 文本生成走 <code>/prompt/polish</code> 并保持中性专业:不主动套入 SKG不主动补产品、平台、广告语境或人物只扩写用户明确写出的主体、动作、场景、镜头、光线和质量细节视频提交若带参考图会在最终提示词中条件声明“参考图里若有人物应按 AI 生成的虚拟角色处理”,避免把 AI 人像素材误当成真实肖像。生成资产按当前登录用户写入个人 job。图片尺寸只显示 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频画幅只显示 <code>720x1280</code><code>1280x720</code><code>1024x1024</code><code>960x1280</code>;视频时长只显示 <code>5/8/10/12/15</code> 秒。多人互不影响依赖后端 <code>owner_id</code>、画布项目 owner、我的工作流 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。</p>
@@ -650,12 +651,12 @@
<tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。工作台在 <code>skg-board-theme</code> 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、<code>#383838</code> 胶囊侧栏、<code>rgba(255,255,255,.1)</code> 玻璃面、<code>backdrop-filter: blur(5px)</code><code>20px</code> 圆角、<code>10px 10px 10px rgba(0,0,0,.3)</code> 阴影和绿黄状态色;新增 <code>skg-board-shell</code><code>skg-board-rail</code><code>skg-glass-card</code><code>skg-glass-card--flat</code><code>skg-status-orb</code> 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token暗色压低灰雾和面板底色明亮模式改为暖白工作台避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。</td></tr> <tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。工作台在 <code>skg-board-theme</code> 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、<code>#383838</code> 胶囊侧栏、<code>rgba(255,255,255,.1)</code> 玻璃面、<code>backdrop-filter: blur(5px)</code><code>20px</code> 圆角、<code>10px 10px 10px rgba(0,0,0,.3)</code> 阴影和绿黄状态色;新增 <code>skg-board-shell</code><code>skg-board-rail</code><code>skg-glass-card</code><code>skg-glass-card--flat</code><code>skg-status-orb</code> 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token暗色压低灰雾和面板底色明亮模式改为暖白工作台避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。</td></tr>
<tr><td><code>web/app/page.tsx</code></td><td>旧 React 单对话框生成台源码仍保留,便于以后回滚或抽能力;当前生产根域名已经由 <code>web/canvas-app/</code> 画布产物覆盖,不再把这个 React 首页作为默认首屏。该页面里的模式也已收敛为文生图、文生视频、图生视频;图生视频只显示“上传图片”,不把“首帧/首尾帧”作为用户入口。旧 TK 复刻工作台组件仍保留在 <code>web/components/ad-recreation-board.tsx</code>,但不再作为默认首页渲染。</td></tr> <tr><td><code>web/app/page.tsx</code></td><td>旧 React 单对话框生成台源码仍保留,便于以后回滚或抽能力;当前生产根域名已经由 <code>web/canvas-app/</code> 画布产物覆盖,不再把这个 React 首页作为默认首屏。该页面里的模式也已收敛为文生图、文生视频、图生视频;图生视频只显示“上传图片”,不把“首帧/首尾帧”作为用户入口。旧 TK 复刻工作台组件仍保留在 <code>web/components/ad-recreation-board.tsx</code>,但不再作为默认首页渲染。</td></tr>
<tr><td><code>web/canvas-app/</code></td><td>SKG 内部画布应用:从 <code>chatfire-AI/huobao-canvas</code> 交互逻辑改造而来。当前策略是“保留成熟画布能力,替换品牌/路由/API”Vue Flow 节点画布、项目列表、推荐词、AI 润色、自动执行、公共工作流、我的工作流、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载都保留;可见品牌收敛为 SKG logo不展示上游注册链接或外部品牌。生产路径固定为根域名 <code>/</code>,内部路由用 <code>/p/:id?</code>;项目列表、画布 JSON 和个人工作流模板优先同步到服务端 Postgres浏览器本地存储只是缓存/导入来源;来源说明保存在 <code>THIRD_PARTY_NOTICES.md</code>,不展示给终端用户。</td></tr> <tr><td><code>web/canvas-app/</code></td><td>SKG 内部画布应用:从 <code>chatfire-AI/huobao-canvas</code> 交互逻辑改造而来。当前策略是“保留成熟画布能力,替换品牌/路由/API”Vue Flow 节点画布、项目列表、推荐词、AI 润色、自动执行、公共工作流、我的工作流、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载都保留;可见品牌收敛为 SKG logo不展示上游注册链接或外部品牌。生产路径固定为根域名 <code>/</code>,内部路由用 <code>/p/:id?</code>;项目列表、画布 JSON 和个人工作流模板优先同步到服务端 Postgres浏览器本地存储只是缓存/导入来源;来源说明保存在 <code>THIRD_PARTY_NOTICES.md</code>,不展示给终端用户。</td></tr>
<tr><td><code>web/canvas-app/src/stores/projects.js</code></td><td>画布项目 Pinia store启动时先读本地 <code>localStorage["ai-canvas-projects"]</code> 作为缓存,再调用 <code>GET /canvas-projects</code> 拉服务端项目;如果发现本地旧项目,会调用 <code>POST /canvas-projects/import</code> 导入到当前登录用户。新建、重命名、画布节点变更、复制和删除会同步到 <code>/canvas-projects</code>,本地缓存只用于快速打开和网络异常兜底。</td></tr> <tr><td><code>web/canvas-app/src/stores/projects.js</code></td><td>画布项目 Pinia store启动时先读本地 <code>localStorage["ai-canvas-projects"]</code> 作为缓存,再调用 <code>GET /canvas-projects</code> 拉服务端项目;如果发现本地旧项目,会调用 <code>POST /canvas-projects/import</code> 导入到当前登录用户。新建、重命名、画布节点变更、复制和删除会同步到 <code>/canvas-projects</code>,本地缓存只用于快速打开和网络异常兜底。远程保存用内容签名跳过重复 payload并以短防抖合并连续节点更新避免大画布频繁写入影响交互速度。</td></tr>
<tr><td><code>web/canvas-app/src/stores/workflows.js</code></td><td>我的工作流 store调用 <code>GET/POST/DELETE /canvas-workflows</code> 读取、保存和删除当前登录用户自己的云端工作流模板。保存前会清理节点里的 <code>base64</code>、生成 URL、任务进度、错误、视频结果和 LLM 输出等运行态字段,只保留可复用的节点结构、连线、配置和提示词。</td></tr> <tr><td><code>web/canvas-app/src/stores/workflows.js</code></td><td>我的工作流 store调用 <code>GET/POST/DELETE /canvas-workflows</code> 读取、保存和删除当前登录用户自己的云端工作流模板。保存前会清理节点里的 <code>base64</code>、生成 URL、任务进度、错误、视频结果和 LLM 输出等运行态字段,只保留可复用的节点结构、连线、配置和提示词。</td></tr>
<tr><td><code>web/canvas-app/src/views/Canvas.vue</code></td><td>画布主交互:恢复上游底部 prompt composer、<code>AI 润色</code><code>自动执行</code>、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 <code>useWorkflowOrchestrator</code> 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点。工作流面板支持公共模板和我的工作流:公共模板走本地 <code>createNodes()</code>,我的工作流从云端 <code>workflow_data</code> 插回当前画布,并重新生成节点 ID、按视口中心重排、按映射重连边。Vue Flow 开启可见节点渲染,大画布不再把所有节点同时挂载到 DOM节点数超过 120 时隐藏 MiniMap减少点击后的同步重绘压力。底部推荐词来自共享短词池4 个一组单行展示,刷新按钮在 30 组内轮换,不改变输入面板高度。</td></tr> <tr><td><code>web/canvas-app/src/views/Canvas.vue</code></td><td>画布主交互:恢复上游底部 prompt composer、<code>AI 润色</code><code>自动执行</code>、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 <code>useWorkflowOrchestrator</code> 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点。工作流面板支持公共模板和我的工作流:公共模板走本地 <code>createNodes()</code>,我的工作流从云端 <code>workflow_data</code> 插回当前画布,并重新生成节点 ID、按视口中心重排、按映射重连边。Vue Flow 开启可见节点渲染,大画布不再把所有节点同时挂载到 DOM节点数超过 120 时隐藏 MiniMap减少点击后的同步重绘压力。底部推荐词来自共享短词池4 个一组单行展示,刷新按钮在 30 组内轮换,不改变输入面板高度。</td></tr>
<tr><td><code>web/canvas-app/src/config/suggestions.js</code></td><td>首页和画布共用的推荐词配置:维护 <code>QUICK_SUGGESTION_GROUPS</code>,当前为 30 组 / 120 个短词,每组 4 个,控制刷新按钮的轮换范围;词条保持短小,避免推荐栏换行或顶起 composer。</td></tr> <tr><td><code>web/canvas-app/src/config/suggestions.js</code></td><td>首页和画布共用的推荐词配置:维护 <code>QUICK_SUGGESTION_GROUPS</code>,当前为 30 组 / 120 个短词,每组 4 个,控制刷新按钮的轮换范围;词条保持短小,避免推荐栏换行或顶起 composer。</td></tr>
<tr><td><code>web/canvas-app/src/config/models.js</code></td><td>画布媒体模型和规格的前端白名单:图片只内置 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只内置 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频只内置 <code>seedance</code> / <code>Seedance 2.0 Fast</code>,画幅和时长对齐后端 <code>/health</code> 能力边界。<code>useModelConfig.js</code> 和 Pinia 模型 store 会忽略浏览器本地自定义图片/视频模型,防止旧缓存把不可用模型带回生成下拉。</td></tr> <tr><td><code>web/canvas-app/src/config/models.js</code></td><td>画布媒体模型和规格的前端白名单:图片只内置 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只内置 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频只内置 <code>seedance</code> / <code>Seedance 2.0 Fast</code>,画幅和时长对齐后端 <code>/health</code> 能力边界。<code>useModelConfig.js</code> 和 Pinia 模型 store 会忽略浏览器本地自定义图片/视频模型,防止旧缓存把不可用模型带回生成下拉。</td></tr>
<tr><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布到本项目后端的适配层:不再读取浏览器 API Key而是使用当前登录会话 Cookie 调用 <code>/api</code>。文生图 / 图生图先创建轻量 creative job再调用 <code>/frames/0/generate</code>;文生视频 / 图生视频调用 <code>/storyboard/video</code> 并轮询 <code>/jobs/{id}</code>,完成后把图片或 mp4 URL 写回画布节点。<code>useChat</code> 已从 SKG 广告文案接口切到 <code>/prompt/polish</code>AI 润色显式使用 image/video prompt 模式LLM 节点使用通用 chat 模式,避免自动注入用户没有提到的 SKG、产品、平台或营销语境后端会清理旧润色模板尾巴、判断人物/无人/物体/场景意图,并在输出后检查“有人却禁止人物、无人却新增人物、未写 SKG 却出现 SKG”等冲突。图生视频实际提交到后端后后端会对参考图追加 AI 虚拟角色条件说明,不要求前端判断图片里是否有人脸。</td></tr> <tr><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布到本项目后端的适配层:不再读取浏览器 API Key而是使用当前登录会话 Cookie 调用 <code>/api</code>。文生图 / 图生图先创建轻量 creative job再调用 <code>/frames/0/generate</code>本地上传到图片节点的参考图也会先通过 <code>/creative/jobs/image</code> 写成后端资产,再把 <code>/api/jobs/...</code> URL 保存到节点,避免刷新后丢失。文生视频 / 图生视频调用 <code>/storyboard/video</code> 并轮询 <code>/jobs/{id}</code>,完成后把图片或 mp4 URL 写回画布节点。<code>useChat</code> 已从 SKG 广告文案接口切到 <code>/prompt/polish</code>AI 润色显式使用 image/video prompt 模式LLM 节点使用通用 chat 模式,避免自动注入用户没有提到的 SKG、产品、平台或营销语境后端会清理旧润色模板尾巴、判断人物/无人/物体/场景意图,并在输出后检查“有人却禁止人物、无人却新增人物、未写 SKG 却出现 SKG”等冲突。图生视频实际提交到后端后后端会对参考图追加 AI 虚拟角色条件说明,不要求前端判断图片里是否有人脸。</td></tr>
<tr><td><code>web/scripts/sync-canvas-root.mjs</code></td><td>构建桥接脚本:在 <code>next build</code> 静态导出完成后,把 Vite 画布产物 <code>web/canvas-app/dist</code> 覆盖到 <code>web/out</code> 根目录,使 <code>https://marketing.skg.com</code> 登录后直接进入画布;旧 <code>web/scripts/sync-canvas-dist.mjs</code> 保留但不再由生产构建调用。</td></tr> <tr><td><code>web/scripts/sync-canvas-root.mjs</code></td><td>构建桥接脚本:在 <code>next build</code> 静态导出完成后,把 Vite 画布产物 <code>web/canvas-app/dist</code> 覆盖到 <code>web/out</code> 根目录,使 <code>https://marketing.skg.com</code> 登录后直接进入画布;旧 <code>web/scripts/sync-canvas-dist.mjs</code> 保留但不再由生产构建调用。</td></tr>
<tr><td><code>web/app/detail/page.tsx</code></td><td>任务详情页:静态导出路由 <code>/detail/?job=&lt;id&gt;</code>,通过 query 读取 job id调用 <code>getJob</code> 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 <code>generateImage</code><code>generateStoryboardVideo</code><code>generateCreativeCopy</code>,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。</td></tr> <tr><td><code>web/app/detail/page.tsx</code></td><td>任务详情页:静态导出路由 <code>/detail/?job=&lt;id&gt;</code>,通过 query 读取 job id调用 <code>getJob</code> 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 <code>generateImage</code><code>generateStoryboardVideo</code><code>generateCreativeCopy</code>,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。</td></tr>
<tr><td><code>web/app/agent/page.tsx</code></td><td>新增一键出片终端页:只保留 TikTok 链接、产品图上传、实时 <code>Agent Terminal</code> 和最终成片播放器;通过 <code>POST /agent-runs</code> 创建受限后台状态机任务,通过 <code>GET /agent-runs/{id}</code> 轮询日志、进度、审片图和最终 mp4。该页不替代旧工作台深度编辑能力只承接“用户只看成品”的快速出片主路径。</td></tr> <tr><td><code>web/app/agent/page.tsx</code></td><td>新增一键出片终端页:只保留 TikTok 链接、产品图上传、实时 <code>Agent Terminal</code> 和最终成片播放器;通过 <code>POST /agent-runs</code> 创建受限后台状态机任务,通过 <code>GET /agent-runs/{id}</code> 轮询日志、进度、审片图和最终 mp4。该页不替代旧工作台深度编辑能力只承接“用户只看成品”的快速出片主路径。</td></tr>
@@ -1306,6 +1307,20 @@ ProductRefStateItem {
<h2>变更记录</h2> <h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p> <p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog"> <div class="changelog">
<article class="change">
<header>
<h3>2026-05-27 · 修复上传参考图刷新后丢失并降低保存频次</h3>
<span class="tag violet">Canvas</span>
<span class="tag blue">Data</span>
<span class="tag orange">Bugfix</span>
</header>
<div class="body">
<p><strong>问题:</strong>Dimi姚智恒反馈刷新后刚上传的参考图不见了。线上数据库显示其项目保存请求均为 200丢失节点是本地上传的 <code>参考图</code>,节点里只剩文件名和类型,没有 <code>url</code><code>base64</code></p>
<p><strong>根因:</strong><code>ImageNode.vue</code> 原来把上传文件转成 <code>data:</code> base64<code>cleanNodeForStorage</code> 为避免浏览器存储爆掉,会在保存到 localStorage 和 Postgres 前删除 <code>base64</code> 以及 <code>data:</code> URL所以刷新后无法恢复图片。这不是单纯网速问题。</p>
<p><strong>改动:</strong>图片节点上传时先调用 <code>/creative/jobs/image</code> 存成后端资产,再把 <code>/api/jobs/...</code> URL、<code>sourceJobId</code><code>sourceFrameIdx</code> 写入节点;项目远程保存增加内容签名去重,防抖从 800ms 调整到 2000ms减少连续重复 <code>PUT /canvas-projects</code></p>
<p><strong>影响:</strong><code>web/canvas-app/src/components/nodes/ImageNode.vue</code><code>web/canvas-app/src/hooks/useApi.js</code><code>web/canvas-app/src/stores/projects.js</code>。本地 Docker 中验证上传参考图后刷新,节点 URL 和页面图片都保留为同一个 <code>/api/jobs/...</code> 地址。</p>
</div>
</article>
<article class="change"> <article class="change">
<header> <header>
<h3>2026-05-27 · 固化最终产品和本地同构排查基线</h3> <h3>2026-05-27 · 固化最终产品和本地同构排查基线</h3>

View File

@@ -327,6 +327,7 @@ import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { NIcon, NTooltip, NSwitch, NImagePreview, NModal, NButton } from 'naive-ui' import { NIcon, NTooltip, NSwitch, NImagePreview, NModal, NButton } from 'naive-ui'
import { TrashOutline, ExpandOutline, ImageOutline, CloseCircleOutline, CopyOutline, VideocamOutline, DownloadOutline, EyeOutline, BrushOutline, RefreshOutline, ColorWandOutline, SwapHorizontalOutline } from '@vicons/ionicons5' import { TrashOutline, ExpandOutline, ImageOutline, CloseCircleOutline, CopyOutline, VideocamOutline, DownloadOutline, EyeOutline, BrushOutline, RefreshOutline, ColorWandOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas' import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
import { uploadCanvasImage } from '../../hooks/useApi'
import NodeHandleMenu from './NodeHandleMenu.vue' import NodeHandleMenu from './NodeHandleMenu.vue'
const props = defineProps({ const props = defineProps({
@@ -665,27 +666,17 @@ const createInpaintWorkflow = () => {
window.$message?.success('已创建局部重绘工作流') window.$message?.success('已创建局部重绘工作流')
} }
// Convert file to base64 | 将文件转换为 base64
const fileToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
// Handle file upload | 处理文件上传 // Handle file upload | 处理文件上传
const handleFileUpload = async (event) => { const handleFileUpload = async (event) => {
const file = event.target.files[0] const file = event.target.files[0]
if (file) { if (file) {
try { try {
// Convert to base64 | 转换为 base64 urlLoading.value = true
const base64 = await fileToBase64(file) const uploaded = await uploadCanvasImage(file)
// Store both display URL and base64 | 同时存储显示 URL 和 base64
updateNode(props.id, { updateNode(props.id, {
url: base64, // Use base64 as display URL | 使用 base64 作为显示 URL url: uploaded.url,
base64: base64, // Store base64 for API calls | 存储 base64 用于 API 调用 sourceJobId: uploaded.jobId,
sourceFrameIdx: uploaded.frameIdx,
fileName: file.name, fileName: file.name,
fileType: file.type, fileType: file.type,
label: '参考图', label: '参考图',
@@ -694,6 +685,9 @@ const handleFileUpload = async (event) => {
} catch (err) { } catch (err) {
console.error('File upload error:', err) console.error('File upload error:', err)
window.$message?.error('图片上传失败') window.$message?.error('图片上传失败')
} finally {
urlLoading.value = false
event.target.value = ''
} }
} }
} }
@@ -738,10 +732,12 @@ const handleReplaceFileUpload = async (event) => {
const file = event.target.files[0] const file = event.target.files[0]
if (file) { if (file) {
try { try {
const base64 = await fileToBase64(file) urlLoading.value = true
const uploaded = await uploadCanvasImage(file)
updateNode(props.id, { updateNode(props.id, {
url: base64, url: uploaded.url,
base64: base64, sourceJobId: uploaded.jobId,
sourceFrameIdx: uploaded.frameIdx,
fileName: file.name, fileName: file.name,
fileType: file.type, fileType: file.type,
label: '参考图', label: '参考图',
@@ -753,6 +749,9 @@ const handleReplaceFileUpload = async (event) => {
} catch (err) { } catch (err) {
console.error('File upload error:', err) console.error('File upload error:', err)
window.$message?.error('图片上传失败') window.$message?.error('图片上传失败')
} finally {
urlLoading.value = false
event.target.value = ''
} }
} }
} }

View File

@@ -70,6 +70,18 @@ const uploadReferenceFrame = async (jobId, file) => {
return requestJson(`/jobs/${jobId}/frames/upload`, { method: 'POST', body: form }) return requestJson(`/jobs/${jobId}/frames/upload`, { method: 'POST', body: form })
} }
export const uploadCanvasImage = async (file) => {
if (!file) throw new Error('请选择图片文件')
const job = await createCreativeImageJob(file)
const frame = job.frames?.[0]
if (!frame?.url) throw new Error('图片已上传但未返回可用地址')
return {
url: toAssetUrl(frame.url),
jobId: job.id,
frameIdx: frame.index ?? 0
}
}
const newestGeneratedImage = (job, frameIdx = 0) => { const newestGeneratedImage = (job, frameIdx = 0) => {
const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[0] const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[0]
return [...(frame?.generated_images || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0] return [...(frame?.generated_images || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]

View File

@@ -22,6 +22,7 @@ export const projectSyncError = ref('')
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api' const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}` const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
const remoteSaveTimers = new Map() const remoteSaveTimers = new Map()
const remoteSaveSignatures = new Map()
let initPromise = null let initPromise = null
let remoteAvailable = false let remoteAvailable = false
@@ -76,6 +77,12 @@ const projectToApi = (project) => ({
source: 'canvas' source: 'canvas'
}) })
const remoteProjectSignature = (project) => {
const payload = projectToApi(project)
delete payload.updated_at
return JSON.stringify(payload)
}
const requestJson = async (path, init = {}) => { const requestJson = async (path, init = {}) => {
const response = await fetch(apiUrl(path), { const response = await fetch(apiUrl(path), {
...init, ...init,
@@ -126,14 +133,17 @@ export const loadProjects = () => {
const saveRemoteProjectNow = async (project) => { const saveRemoteProjectNow = async (project) => {
if (!project?.id) return null if (!project?.id) return null
const signature = remoteProjectSignature(project)
if (remoteSaveSignatures.get(project.id) === signature) return null
const response = await requestJson(`/canvas-projects/${encodeURIComponent(project.id)}`, { const response = await requestJson(`/canvas-projects/${encodeURIComponent(project.id)}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(projectToApi(project)) body: JSON.stringify(projectToApi(project))
}) })
remoteSaveSignatures.set(project.id, signature)
return response.item ? projectFromApi(response.item) : null return response.item ? projectFromApi(response.item) : null
} }
const scheduleRemoteSave = (project, delay = 800) => { const scheduleRemoteSave = (project, delay = 2000) => {
if (!remoteAvailable || !project?.id) return if (!remoteAvailable || !project?.id) return
if (remoteSaveTimers.has(project.id)) { if (remoteSaveTimers.has(project.id)) {
clearTimeout(remoteSaveTimers.get(project.id)) clearTimeout(remoteSaveTimers.get(project.id))