diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md index 7b48e4b..2f8e6ea 100644 --- a/Docs/BACKEND_DEV.md +++ b/Docs/BACKEND_DEV.md @@ -93,6 +93,16 @@ backend/ - `secondary_title_style_id` / `secondary_title_font_size` / `secondary_title_top_margin`: 副标题样式配置 - workflow/remotion 侧需保持字段透传一致,避免前后端语义漂移。 +### `/api/videos/cleanup` 行为约定 + +- 仅清理当前用户在 Storage 中的生成产物: + - `outputs` bucket(生成视频) + - `generated-audios` bucket(预生成配音 `.wav/.json`) +- 清理接口采用严格成功语义: + - 全部删除成功才返回 success + - 任一删除失败返回错误,前端应保留清理弹窗并允许重试 +- 下载接口约定:`GET /api/videos/generated/{video_id}/download` 必须返回 `Content-Disposition: attachment`,用于前端一键下载,避免浏览器改为在线播放。 + --- ## 4. 认证与权限 @@ -115,6 +125,8 @@ backend/ - 所有文件上传/下载/删除/移动通过 `services/storage.py`。 - 需要重命名时使用 `move_file`,避免直接读写 Storage。 +- `delete_file` 必须向上抛出异常,不允许静默吞错(避免清理接口出现“假成功”)。 +- `list_files` 默认容错返回空列表;清理等强一致场景应使用 `strict=True`。 ### Cookie 存储(用户隔离) diff --git a/Docs/BACKEND_README.md b/Docs/BACKEND_README.md index f57fb59..4d38063 100644 --- a/Docs/BACKEND_README.md +++ b/Docs/BACKEND_README.md @@ -65,11 +65,15 @@ backend/ 2. **视频生成 (Videos)** * `POST /api/videos/generate`: 提交生成任务 * `GET/POST /api/videos/voice-preview`: 生成音色试听短音频(返回二进制音频流) + * `POST /api/videos/cleanup`: 清理当前用户工作区生成产物(outputs + generated-audios) * `GET /api/videos/tasks/{task_id}`: 查询单个任务状态 * `GET /api/videos/tasks`: 获取用户所有任务列表 * `GET /api/videos/generated`: 获取历史视频列表 + * `GET /api/videos/generated/{video_id}/download`: 下载历史视频(`Content-Disposition: attachment`) * `DELETE /api/videos/generated/{video_id}`: 删除历史视频 +> `POST /api/videos/cleanup` 采用严格成功语义:仅当目标文件删除全部成功时返回 success;存在删除失败会返回错误并提示重试。 + 3. **素材管理 (Materials)** * `POST /api/materials`: 上传素材 * `GET /api/materials`: 获取素材列表 diff --git a/Docs/DevLogs/Day31.md b/Docs/DevLogs/Day31.md index b71cd63..57fb417 100644 --- a/Docs/DevLogs/Day31.md +++ b/Docs/DevLogs/Day31.md @@ -344,7 +344,7 @@ async def preview_voice_get(voice: str, current_user: dict = Depends(get_current - 在「一、文案提取与编辑」主输入框右下角新增角标按钮(点击后打开扩展编辑器) - 扩展编辑器使用 `AppModal`,提供更大编辑空间(高约 `66vh`) - 主输入框与弹窗内输入框共享同一份 `text` 状态,双向实时同步 -- 为避免角标遮挡正文,主输入框增加右下内边距(`pr-9 pb-8`) +- 为避免角标遮挡正文,主输入框增加右下内边距(`pr-6 pb-6`) - 角标样式进一步极简化:仅保留双箭头图标,去掉外框容器并贴近输入框边缘 - 角标位置微调为更协调的“上移+右移”:`right-0.5 bottom-2`,并固定点击区域 `h-5 w-5` - 修复扩展编辑输入焦点丢失:`AppModal` 改为使用 `onCloseRef` 处理 ESC,避免父组件重渲染时 effect 误清理导致 textarea 失焦 @@ -357,11 +357,70 @@ async def preview_voice_get(voice: str, current_user: dict = Depends(get_current --- +## ✅ 13) 站点 Icon 替换(使用 `Temp/video.png`) + +### 变更 + +- 将提供的 `Temp/video.png` 转换并替换为站点图标资源 +- 新增 `frontend/src/app/icon.png`(Next App Router icon 资源) +- 更新 `frontend/src/app/favicon.ico`(16/32/48/64 多尺寸) + +### 验证 + +- `npm run build`(frontend)✅ +- 构建产物包含 `/icon.png` 路由 ✅ +- `pm2 restart vigent2-frontend` ✅ + +--- + +## ✅ 14) 发布后工作区清理链路加固(CleanupContext + `/api/videos/cleanup`) + +### 14.1 功能落地 + +- 发布页新增“全平台发布成功后清理引导”链路: + - 全平台成功:触发 `CleanupModal` + - 任一平台失败:保持原内联结果展示 +- `CleanupModal` 支持展示:成功平台列表、成功截图、下载视频备份、一键清理 +- 清理状态 `cleanup_pending` 持久化到 localStorage,刷新/跳转后可恢复 + +### 14.2 稳定性与防锁死优化 + +- 后端删除能力改为“异常上抛”,避免静默吞错导致前端误判清理成功 +- 清理接口改为严格成功语义: + - 视频和配音删除都成功才返回 success + - 任一删除失败直接返回错误,前端保留弹窗并允许重试 +- 前端清理动作改为“先后端、后本地”: + - 后端失败:不清本地、不关弹窗 + - 后端成功:再清理本地输入字段并关闭弹窗 +- 后端成功清理后前端派发 `vigent:workspace-cleared` 事件,发布页就地重置标题/标签输入态(无需手动刷新) +- 连续失败达到阈值(3 次)后显示“暂不清理,继续使用”,避免异常环境下永久阻塞 +- 清理弹窗增加 24h 过期,避免跨天残留状态 +- 用户切换/登出时重置 cleanup 状态,避免旧账号状态串扰 + +### 14.3 清理范围口径 + +- 仅清理输入内容字段: + - 首页:文案/标题/副标题 + - 发布页:标题/标签 +- 保留用户偏好字段(样式、字号、边距、模型、BGM 等) + +### 验证 + +- `python -m py_compile backend/app/services/storage.py backend/app/modules/videos/service.py backend/app/modules/generated_audios/service.py backend/app/modules/videos/router.py` ✅ +- `npm run build`(frontend)✅ +- `pm2 restart vigent2-backend && pm2 restart vigent2-frontend` ✅ +- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}` ✅ + +--- + ## 📁 今日主要修改文件 | 文件 | 改动 | |------|------| -| `backend/app/modules/videos/router.py` | 新增/增强 `voice-preview` GET+POST,试听文本 locale 路由,临时文件清理 | +| `backend/app/modules/videos/router.py` | 新增/增强 `voice-preview` GET+POST,试听文本 locale 路由,临时文件清理;新增 `POST /api/videos/cleanup` 严格成功语义 | +| `backend/app/modules/videos/service.py` | 新增批量删除生成视频能力;返回 `(deleted, failed)` 供 cleanup 路由判定 | +| `backend/app/modules/generated_audios/service.py` | 新增批量删除预生成配音能力;返回 `(deleted, failed)` 供 cleanup 路由判定 | +| `backend/app/services/storage.py` | `delete_file()` 改为异常上抛,避免删除失败静默吞错造成“假成功” | | `backend/app/modules/videos/schemas.py` | 新增 `VoicePreviewRequest` | | `frontend/src/features/home/ui/VoiceSelector.tsx` | 音色下拉增加试听按钮,改为 GET 音频流播放 | | `frontend/src/features/home/model/useHomeController.ts` | 录音状态重置、`discardRecording` | @@ -371,7 +430,9 @@ async def preview_voice_get(voice: str, current_user: dict = Depends(get_current | `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 标题区改为“首行标题+AI、次行右对齐设置+预览”;AI按钮外观对齐在线录音按钮(软圆角) | | `frontend/src/features/home/ui/RefAudioPanel.tsx` | 录音完成试听条改为自定义深色播放器(替换原生白色控制条) | | `frontend/src/features/home/ui/RefAudioPanel.tsx` | 使用录音后弹窗立即关闭,上传识别后台进行(提升交互流畅度) | -| `frontend/src/features/publish/model/usePublishController.ts` | 发布改为受限并发(并发度=2),缩短多平台发布总耗时 | +| `frontend/src/features/publish/model/usePublishController.ts` | 发布改为受限并发(并发度=2);全平台发布成功时触发 `triggerCleanup()`,失败保持内联结果;监听 `workspace-cleared` 事件就地清空发布输入态 | +| `frontend/src/shared/contexts/CleanupContext.tsx` | 新增发布后清理弹窗与持久化状态;失败不关闭/不清本地、3 次失败可跳过、24h 过期、用户切换复位;清理范围收敛为输入内容字段;成功清理后派发 `workspace-cleared` 事件 | +| `frontend/src/app/layout.tsx` | 在 `TaskProvider` 内挂载 `CleanupProvider`,确保全局可触发发布后清理弹窗 | | `backend/app/core/config.py` | 新增小红书 Playwright 配置(headless/UA/locale/timezone/chrome/debug) | | `backend/app/services/uploader/xiaohongshu_uploader.py` | 按抖音/微信模式重构;补充上传启动容错窗口、无后缀文件兜底(hardlink/copy)、后缀一致性校验、空转超时保护与上传诊断日志 | | `backend/app/services/publish_service.py` | `save_cookie_string` 非 bilibili 统一存储为 Playwright `storage_state`;小红书 uploader 透传 `user_id` | @@ -383,15 +444,17 @@ async def preview_voice_get(voice: str, current_user: dict = Depends(get_current | `frontend/src/features/home/ui/RewriteModal.tsx` | 迁移到 `AppModal` | | `frontend/src/features/home/ui/ClipTrimmer.tsx` | 迁移到 `AppModal` | | `frontend/src/components/AccountSettingsDropdown.tsx` | 修改密码弹窗迁移到 `AppModal` | +| `frontend/src/app/icon.png` | 新增站点 icon 资源(来自 `Temp/video.png`) | +| `frontend/src/app/favicon.ico` | 替换站点 favicon(由 `video.png` 转换为多尺寸 ico) | | `frontend/src/features/publish/ui/PublishPage.tsx` | 扫码登录(QR)弹窗迁移到 `AppModal` + 二维码白底留白容器优化(避免边缘观感被裁) | -| `Docs/FRONTEND_DEV.md` | 新增统一弹窗规范(AppModal)和录音交互规范;补充文案扩展编辑也统一走 AppModal | -| `Docs/FRONTEND_README.md` | 增补录音入口与弹窗交互说明;明确“标题常驻显示”对主/副标题同时生效;补充文案输入框扩展编辑器说明 | -| `Docs/BACKEND_README.md` | 增补 `voice-preview` 接口说明;更新发布 API 路径(`/login/{platform}` 等)并链接发布专项文档;补充 `title_display_mode` 对主/副标题统一生效说明 | -| `Docs/BACKEND_DEV.md` | 更新后端规范中的发布器覆盖范围与小红书配置项;补充发布专项文档指引;补充 `title_display_mode` 主/副标题统一生效约定 | -| `Docs/PUBLISH_DEPLOY.md` | 新增多平台发布专项文档(登录实现、自动化发布流程、部署要点与排障) | +| `Docs/FRONTEND_DEV.md` | 新增统一弹窗规范(AppModal)和录音交互规范;补充文案扩展编辑也统一走 AppModal;新增 CleanupContext 清理策略规范 | +| `Docs/FRONTEND_README.md` | 增补录音入口与弹窗交互说明;明确“标题常驻显示”对主/副标题同时生效;补充文案输入框扩展编辑器说明;补充发布后清理弹窗失败兜底说明 | +| `Docs/BACKEND_README.md` | 增补 `voice-preview` 接口说明;更新发布 API 路径(`/login/{platform}` 等)并链接发布专项文档;补充 `title_display_mode` 对主/副标题统一生效说明;新增 `/api/videos/cleanup` 接口说明 | +| `Docs/BACKEND_DEV.md` | 更新后端规范中的发布器覆盖范围与小红书配置项;补充发布专项文档指引;补充 `title_display_mode` 主/副标题统一生效约定;新增 cleanup 严格成功语义约定 | +| `Docs/PUBLISH_DEPLOY.md` | 新增多平台发布专项文档(登录实现、自动化发布流程、部署要点与排障);补充“发布成功后清理联动”说明 | | `Docs/DEPLOY_MANUAL.md` | 部署参数与扫码说明补充小红书要点;新增发布专项文档入口 | -| `README.md` | 文档中心新增 `PUBLISH_DEPLOY.md` 入口;发布结果可视化描述补齐小红书 | -| `Docs/TASK_COMPLETE.md` | 新增 Day31 任务汇总,更新 Current 标签与更新时间 | +| `README.md` | 文档中心新增 `PUBLISH_DEPLOY.md` 入口;发布结果可视化描述补齐小红书;补充发布成功后工作区清理引导说明 | +| `Docs/TASK_COMPLETE.md` | 新增 Day31 任务汇总,更新 Current 标签与更新时间;补充发布后清理链路加固条目 | | `Docs/DOC_RULES.md` | 增补“发布相关三检”(路由真值/专项文档/入口回写)、敏感信息处理规范,更新工具规范为 `Read/Grep/apply_patch`,并对齐 TASK_COMPLETE 检查清单 | | `Docs/SUBTITLE_DEPLOY.md` | 与当前阈值/参数说明对齐 | | `Docs/LATENTSYNC_DEPLOY.md` | 与当前阈值/参数说明对齐 | @@ -414,6 +477,12 @@ async def preview_voice_get(voice: str, current_user: dict = Depends(get_current - 小红书发布实测:`POST /api/publish` 返回 `200`(`Duration: 45.77s`)且成功截图接口返回 `200` ✅ - 新增 `Docs/PUBLISH_DEPLOY.md`(抖音/微信/B站/小红书登录与发布实现说明)✅ - `npm run build`(frontend)✅ +- 站点 icon 替换后构建通过,产物包含 `/icon.png` 路由 ✅ +- `pm2 restart vigent2-frontend`(icon 替换后)✅ +- `python -m py_compile backend/app/services/storage.py backend/app/modules/videos/service.py backend/app/modules/generated_audios/service.py backend/app/modules/videos/router.py`(cleanup 链路加固后)✅ +- `npm run build`(CleanupContext 优化后)✅ +- `pm2 restart vigent2-backend && pm2 restart vigent2-frontend`(cleanup 链路加固后)✅ +- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}`(cleanup 链路加固后)✅ - `POST /api/publish/login/weixin` 冒烟返回 `success=true` + `qr_code` ✅ - `npx eslint` 定向检查以下文件通过: - `VoiceSelector.tsx` @@ -441,11 +510,15 @@ async def preview_voice_get(voice: str, current_user: dict = Depends(get_current - 小红书“上传阶段卡住”二次定位与加固(文件名后缀一致性 + 空转超时)并完成实测发布成功 - 形成发布专项文档 `Docs/PUBLISH_DEPLOY.md`,沉淀四平台登录与自动化发布实现 - 回写 `Docs/BACKEND_README.md` / `Docs/BACKEND_DEV.md` / `Docs/DEPLOY_MANUAL.md`,统一发布 API 与部署说明口径 +- 回写 `Docs/FRONTEND_README.md` / `Docs/FRONTEND_DEV.md` / `Docs/PUBLISH_DEPLOY.md`,补齐发布后清理弹窗与 cleanup 接口联动说明 - 回写 `README.md`,补充发布专项文档入口与小红书发布成功截图能力描述 - 回写 `Docs/TASK_COMPLETE.md`,补齐 Day31 任务完成记录 - 回写 `Docs/DOC_RULES.md`,同步文档更新规则到当前文档结构与工具链 - 首页「AI生成标题标签」按钮迁移到「四、标题与字幕」并固定标题同层最右;显示方式与预览下沉到下一行右侧 - 文案输入框右下角新增扩展角标,支持弹出大编辑器进行长文案编辑 +- 站点 icon 已替换为 `Temp/video.png` 对应资源(`app/icon.png` + `app/favicon.ico`) +- 发布后工作区清理链路落地(CleanupModal + `/api/videos/cleanup`)并补齐失败兜底(失败不关弹窗、不清本地) +- 清理链路防锁死优化:3 次失败可跳过、24h 过期、用户切换复位 - 文档补充:`标题短暂显示/常驻显示` 对主标题与副标题统一生效(常驻=主/副标题全程显示) - 非 bilibili 平台 cookie 保存为 `storage_state` 格式 - 小红书登录二维码自动切换(短信登录 -> 扫码登录)与提取修复 diff --git a/Docs/DevLogs/Day32.md b/Docs/DevLogs/Day32.md new file mode 100644 index 0000000..7c29de7 --- /dev/null +++ b/Docs/DevLogs/Day32.md @@ -0,0 +1,71 @@ +## 视频下载同源修复 + Day 日志拆分归档 (Day 32) + +### 概述 + +今天主要处理“下载行为不符合预期”的问题: + +1. 修复首页与发布成功弹窗点击下载时被浏览器当作在线播放(新开标签页)的问题。 +2. 将下载修复开始后的开发内容从 `Day31` 拆分到 `Day32`,保持日志按天清晰归档。 + +--- + +## ✅ 1) 视频下载链路修复(避免新开标签页播放) + +### 问题现象 + +- 首页“下载视频”与发布成功弹窗“下载视频备份”在部分浏览器会打开新标签页播放视频,而不是直接触发下载。 +- 根因是跨域签名 URL 场景下,浏览器可能忽略 ``。 + +### 修复方案 + +- 后端新增同源下载接口:`GET /api/videos/generated/{video_id}/download` + - 使用 `FileResponse` 返回本地视频文件 + - 显式返回 `Content-Disposition: attachment` + - 浏览器直接进入保存文件流程 +- 发布成功弹窗下载改为传 `videoId`,不再依赖签名 URL。 +- 首页作品预览下载同步改为同源下载接口,下载行为与发布弹窗统一。 +- 兼容旧清理状态:`CleanupContext` 对旧 `videoDownloadUrl` 持久化字段做 `videoId` 解析回填。 + +--- + +## ✅ 2) 配套调整与文档拆分 + +### 前端联动 + +- `CleanupContext` 继续沿用“清理失败不关弹窗、不清本地”的逻辑,下载链路仅替换为同源接口。 +- 首页 `PreviewPanel` 支持传入 `generatedVideoId`,下载按钮优先走 `/api/videos/generated/{id}/download`。 + +### 日志归档 + +- 将“下载修复开始后的内容”从 `Day31` 移出并归档到 `Day32`。 +- `Day31` 保留 Day31 当日核心内容(到 cleanup 链路加固为止)。 + +--- + +## 📁 今日主要修改文件 + +| 文件 | 改动 | +|------|------| +| `backend/app/modules/videos/router.py` | 新增 `GET /api/videos/generated/{video_id}/download`,返回 `attachment` 下载响应 | +| `frontend/src/features/publish/model/usePublishController.ts` | 发布成功后 `triggerCleanup()` 传 `video.id`(替换签名 URL) | +| `frontend/src/shared/contexts/CleanupContext.tsx` | 下载字段改为 `videoId`;兼容旧 `videoDownloadUrl` 回填;下载按钮改同源路径 | +| `frontend/src/features/home/ui/PreviewPanel.tsx` | 首页下载改为同源下载接口 | +| `frontend/src/features/home/ui/HomePage.tsx` | 透传 `generatedVideoId` 给 `PreviewPanel` | +| `Docs/DevLogs/Day31.md` | 移除下载修复章节与对应验证/覆盖项(迁入 Day32) | +| `Docs/TASK_COMPLETE.md` | 新增 Day32 Current 区块,Day31 取消 Current | +| `Docs/BACKEND_README.md` | 补充 `/api/videos/generated/{video_id}/download` 接口说明 | +| `Docs/BACKEND_DEV.md` | 补充下载接口 `attachment` 约定 | +| `Docs/FRONTEND_README.md` | 补充首页/发布弹窗下载统一同源接口说明 | +| `Docs/FRONTEND_DEV.md` | 补充 CleanupContext 下载策略规范 | +| `Docs/PUBLISH_DEPLOY.md` | 补充发布成功后同源下载联动说明 | +| `README.md` | 补充“一键下载直达(同源 attachment)”能力描述 | + +--- + +## 🔍 验证记录 + +- `python -m py_compile backend/app/modules/videos/router.py` ✅ +- `npm run build`(frontend)✅ +- `pm2 restart vigent2-frontend` ✅ +- `pm2 restart vigent2-backend` ✅ +- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}` ✅ diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md index 975bf05..ad283a5 100644 --- a/Docs/FRONTEND_DEV.md +++ b/Docs/FRONTEND_DEV.md @@ -73,9 +73,10 @@ frontend/src/ │ ├── types/ │ │ ├── user.ts # User 类型定义 │ │ └── publish.ts # 发布相关类型 -│ └── contexts/ # 全局 Context(Auth、Task) +│ └── contexts/ # 全局 Context(Auth、Task、Cleanup) │ ├── AuthContext.tsx -│ └── TaskContext.tsx +│ ├── TaskContext.tsx +│ └── CleanupContext.tsx ├── components/ # 遗留通用组件 │ └── VideoPreviewModal.tsx └── proxy.ts # Next.js middleware(路由保护) @@ -223,6 +224,20 @@ body { --- +## 发布后清理弹窗规范 (CleanupContext) + +发布页由 `CleanupContext` 统一承接“全部平台发布成功后的清理引导”,规则如下: + +- 触发条件:仅当本次发布结果 **全部成功** 才触发弹窗;有任一失败则走原内联结果展示。 +- 持久化恢复:`cleanup_pending` 写入 localStorage,支持刷新/跳转后恢复;带 `createdAt`,24 小时自动过期。 +- 清理顺序:必须先调用 `POST /api/videos/cleanup`;仅在接口成功后才清本地输入字段并关闭弹窗。 +- 状态同步:清理成功后派发 `vigent:workspace-cleared` 事件,当前发布页输入态需就地重置(避免“localStorage 已清空但页面仍显示旧值”)。 +- 失败处理:接口失败时保留弹窗和输入数据,允许重试;连续失败达到阈值后显示“暂不清理,继续使用”。 +- 本地清理范围:仅输入内容(文案/标题/副标题/发布标题/标签),不清用户偏好(样式、字号、边距、模型、BGM 等)。 +- 下载策略:弹窗“下载视频备份”必须使用同源下载接口(`/api/videos/generated/{id}/download`),不要直接使用签名 URL 作为 `href`。 + +--- + ## API 请求规范 ### 必须使用 `api` (axios 实例) @@ -391,7 +406,7 @@ useEffect(() => { - `shared/hooks`:跨功能通用 hooks - `shared/ui`:跨功能通用 UI(如 SelectPopover) - `shared/types`:跨功能实体类型(User / PublishVideo 等) -- `shared/contexts`:全局 Context(AuthContext / TaskContext) +- `shared/contexts`:全局 Context(AuthContext / TaskContext / CleanupContext) - `components/`:遗留通用组件(VideoPreviewModal) ## 类型定义规范 diff --git a/Docs/FRONTEND_README.md b/Docs/FRONTEND_README.md index d2eba3e..9a5d8fa 100644 --- a/Docs/FRONTEND_README.md +++ b/Docs/FRONTEND_README.md @@ -19,6 +19,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 - **六、作品**(右栏): 作品列表 + 作品预览合并为一个板块。 - **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。 - **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。 +- **下载直达**: 首页作品下载与发布成功弹窗下载统一走同源下载接口(`/api/videos/generated/{id}/download`),避免新标签页在线播放。 - **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。 - **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复。 - **历史文案**: 手动保存/加载/删除历史文案,独立 localStorage 持久化。 @@ -37,6 +38,9 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 - **选择持久化**: 使用稳定 `video.id` 持久化选择,刷新保持;新视频生成自动选中最新。 - **预览兼容**: 签名 URL / 相对路径均可直接预览。 - **发布方式**: 仅支持 "立即发布"。 +- **发布成功清理弹窗**: 全平台发布成功后触发 `CleanupModal`(展示成功平台、截图、下载备份、清理按钮),刷新/跳转后可恢复。 +- **清理失败兜底**: 清理接口失败时弹窗不关闭且不清本地输入;连续失败达到阈值后可“暂不清理,继续使用”。 +- **清理范围**: 仅清理输入内容字段(文案/标题/副标题/发布标题/标签),保留样式、字号、边距、模型等用户偏好。 ### 3. 声音克隆 - **TTS 模式选择**: EdgeTTS / 声音克隆切换,音色选择统一下拉(显示音色名 + 语言)。 diff --git a/Docs/PUBLISH_DEPLOY.md b/Docs/PUBLISH_DEPLOY.md index 1784721..8e730e9 100644 --- a/Docs/PUBLISH_DEPLOY.md +++ b/Docs/PUBLISH_DEPLOY.md @@ -24,6 +24,7 @@ - `POST /api/publish/cookies/save/{platform}`:手动保存浏览器 `document.cookie` - `GET /api/publish/accounts`:查询各平台是否已登录 - `GET /api/publish/screenshot/{filename}`:读取发布成功截图(需登录) +- `POST /api/videos/cleanup`:清理当前用户工作区生成产物(发布成功后前端触发) 核心路由文件:`backend/app/modules/publish/router.py`。 @@ -33,6 +34,14 @@ - `QRLoginService`:Playwright 获取二维码、监控扫码结果、保存 Cookie - `*Uploader`:平台发布自动化(抖音/微信/小红书基于 Playwright,B站基于 biliup) +### 2.3 发布成功后的清理联动 + +- 前端 `CleanupContext` 在“本次所选平台全部发布成功”时触发清理弹窗。 +- 用户点击清理时先调用 `POST /api/videos/cleanup`,仅接口成功后才清本地输入并关闭弹窗。 +- 清理成功后前端派发 `vigent:workspace-cleared` 事件,当前发布页会就地重置标题/标签输入态。 +- 接口失败时弹窗保持打开并允许重试;连续失败达到阈值后可“暂不清理,继续使用”。 +- 弹窗“下载视频备份”走同源下载接口:`GET /api/videos/generated/{video_id}/download`,确保浏览器直接保存文件而非新标签页播放。 + --- ## 3. Cookie 与账号隔离 diff --git a/Docs/task_complete.md b/Docs/task_complete.md index bb0cab5..9f0d637 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -1,8 +1,8 @@ # ViGent2 开发任务清单 (Task Log) **项目**: ViGent2 数字人口播视频生成系统 -**进度**: 100% (Day 31 - 发布登录稳定性修复 + 文档体系补齐) -**更新时间**: 2026-03-03 +**进度**: 100% (Day 32 - 视频下载同源修复 + 清理链路体验收敛) +**更新时间**: 2026-03-04 --- @@ -10,7 +10,14 @@ > 这里记录了每一天的核心开发内容与 milestone。 -### Day 31: 文档体系收敛 + 音色试听 + 录音弹窗重构 + 发布登录稳定性修复 (Current) +### Day 32: 视频下载同源修复 + Day 日志拆分归档 (Current) +- [x] **下载链路修复**: 新增 `GET /api/videos/generated/{video_id}/download`,统一返回 `Content-Disposition: attachment`,修复“点击下载却新开标签页播放”问题。 +- [x] **发布成功弹窗下载改造**: `CleanupContext` 从传 URL 改为传 `videoId`,下载按钮改走同源接口,去掉 `target="_blank"`。 +- [x] **首页下载改造**: `PreviewPanel` 同步切换到同源下载接口,首页与发布页下载行为一致。 +- [x] **兼容旧持久化状态**: `CleanupContext` 对旧 `videoDownloadUrl` 做 `videoId` 解析回填,避免旧 pending 状态失效。 +- [x] **文档拆分归档**: 将“下载修复开始后的今日内容”归档到 `Docs/DevLogs/Day32.md`,并从 `Day31.md` 移除对应章节与验证记录。 + +### Day 31: 文档体系收敛 + 音色试听 + 录音弹窗重构 + 发布登录稳定性修复 - [x] **文档体系收敛**: README/DEV 职责边界明确,部署参数与代码对齐,Qwen3-TTS 文档归档至历史状态。 - [x] **音色试听能力**: 新增并启用 `GET/POST /api/videos/voice-preview`,前端改为直接播放 GET 音频流,修复线上 404(重启后端生效)。 - [x] **录音交互重构**: 录音入口迁移到参考音频区底部,流程改为弹窗;支持录音后即时关闭弹窗、后台上传识别。 @@ -28,6 +35,8 @@ - [x] **文档规则对齐**: 更新 `Docs/DOC_RULES.md`,补充发布相关“三检”与敏感信息处理规范,加入 `PUBLISH_DEPLOY.md` 检查项,工具规范改为 `Read/Grep/apply_patch`,并对齐 TASK_COMPLETE 检查清单。 - [x] **首页交互微调**: `AI生成标题标签` 按钮迁移到“四、标题与字幕”标题同层最右;`标题显示方式 + 预览样式` 下沉到下一行右侧;AI按钮圆角/尺寸对齐“在线录音”,配色保留原蓝色渐变;文档明确 `title_display_mode` 对主/副标题统一生效。 - [x] **文案编辑扩展**: 在文案输入框右下角新增扩展角标,点击后弹出大编辑器,主框与弹窗内文案实时同步;角标样式改为双箭头极简贴边并微调到 `right-0.5 bottom-2`;修复扩展输入框打字后失焦问题,移除紫色聚焦边框。 +- [x] **站点图标更新**: 使用 `Temp/video.png` 替换网站 icon,生成并更新 `frontend/src/app/icon.png` 与多尺寸 `frontend/src/app/favicon.ico`。 +- [x] **发布后清理链路加固**: 新增/优化 `CleanupContext` + `/api/videos/cleanup` 全链路;后端删除异常不再吞错、清理接口严格成功语义;前端失败不清本地/不关弹窗,3 次失败可暂不清理,清理状态 24h 过期并支持用户切换复位;清理范围收敛为输入内容字段并保留用户偏好。 ### Day 30: Remotion 缓存修复 + 编码流水线质量优化 + 唇形同步容错 + 统一下拉交互 - [x] **Remotion 缓存 404 修复**: bundle 缓存命中时,新生成的视频/字体文件不在旧缓存 `public/` 目录 → 404 → 回退 FFmpeg(无标题字幕)。改为硬链接(`fs.linkSync`)当前渲染所需文件到缓存目录。 diff --git a/README.md b/README.md index 55eca58..09b1a33 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ ### 平台化功能 - 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。 -- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。 +- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。 - 📸 **发布结果可视化** - 抖音/微信视频号/小红书发布成功后返回截图,发布页结果卡片可直接查看。 -- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。 +- 🧹 **发布后工作区清理引导** - 全平台发布成功后弹出不可误关清理弹窗(失败可重试,达到阈值可暂不清理),仅清输入内容并保留用户偏好。 +- ⬇️ **一键下载直达** - 首页与发布成功弹窗下载统一走同源 `attachment` 接口,不再新开标签页播放视频。 +- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。 - 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。 - 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。 - 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。 diff --git a/backend/app/modules/generated_audios/service.py b/backend/app/modules/generated_audios/service.py index dc6e88a..20ca8bd 100644 --- a/backend/app/modules/generated_audios/service.py +++ b/backend/app/modules/generated_audios/service.py @@ -215,6 +215,30 @@ async def list_generated_audios(user_id: str) -> dict: return GeneratedAudioListResponse(items=items).model_dump() +async def delete_all_generated_audios(user_id: str) -> tuple[int, int]: + """删除用户所有生成的配音(.wav + .json),返回 (删除数量, 失败数量)""" + try: + files = await storage_service.list_files(BUCKET, user_id, strict=True) + deleted_count = 0 + failed_count = 0 + for f in files: + name = f.get("name", "") + if not name or name == ".emptyFolderPlaceholder": + continue + if name.endswith("_audio.wav") or name.endswith("_audio.json"): + full_path = f"{user_id}/{name}" + try: + await storage_service.delete_file(BUCKET, full_path) + deleted_count += 1 + except Exception as e: + failed_count += 1 + logger.warning(f"Delete audio file failed: {full_path}, {e}") + return deleted_count, failed_count + except Exception as e: + logger.error(f"Delete all generated audios failed: {e}") + return 0, 1 + + async def delete_generated_audio(audio_id: str, user_id: str) -> None: if not audio_id.startswith(f"{user_id}/"): raise PermissionError("无权删除此文件") diff --git a/backend/app/modules/videos/router.py b/backend/app/modules/videos/router.py index 067a6ae..d47e98b 100644 --- a/backend/app/modules/videos/router.py +++ b/backend/app/modules/videos/router.py @@ -14,7 +14,9 @@ from app.services.tts_service import TTSService from .schemas import GenerateRequest, VoicePreviewRequest from .task_store import create_task, get_task, list_tasks from .workflow import process_video_generation, get_lipsync_health, get_voiceclone_health -from .service import list_generated_videos, delete_generated_video +from .service import list_generated_videos, delete_generated_video, delete_all_generated_videos +from app.modules.generated_audios.service import delete_all_generated_audios +from app.services.storage import storage_service router = APIRouter() @@ -113,11 +115,51 @@ async def voiceclone_health(): return success_response(await get_voiceclone_health()) +@router.post("/cleanup") +async def cleanup_workspace(current_user: dict = Depends(get_current_user)): + user_id = current_user["id"] + + videos_deleted, videos_failed = await delete_all_generated_videos(user_id) + audios_deleted, audios_failed = await delete_all_generated_audios(user_id) + + if videos_failed > 0 or audios_failed > 0: + raise HTTPException( + status_code=500, + detail=( + f"工作区清理不完整:视频删除失败 {videos_failed} 个," + f"配音删除失败 {audios_failed} 个,请重试" + ), + ) + + return success_response({ + "videos_deleted": videos_deleted, + "audios_deleted": audios_deleted, + }, message="工作区已清理") + + @router.get("/generated") async def list_generated(current_user: dict = Depends(get_current_user)): return success_response(await list_generated_videos(current_user["id"])) +@router.get("/generated/{video_id}/download") +async def download_generated(video_id: str, current_user: dict = Depends(get_current_user)): + user_id = current_user["id"] + storage_path = f"{user_id}/{video_id}.mp4" + local_path = storage_service.get_local_file_path( + bucket=storage_service.BUCKET_OUTPUTS, + path=storage_path, + ) + if not local_path or not os.path.exists(local_path): + raise HTTPException(status_code=404, detail="视频文件不存在") + return FileResponse( + path=local_path, + media_type="video/mp4", + filename=f"{video_id}.mp4", + headers={"Content-Disposition": f'attachment; filename="{video_id}.mp4"'}, + ) + + @router.delete("/generated/{video_id}") async def delete_generated(video_id: str, current_user: dict = Depends(get_current_user)): result = await delete_generated_video(current_user["id"], video_id) diff --git a/backend/app/modules/videos/service.py b/backend/app/modules/videos/service.py index 31de99b..e7b0c12 100644 --- a/backend/app/modules/videos/service.py +++ b/backend/app/modules/videos/service.py @@ -73,6 +73,36 @@ async def list_generated_videos(user_id: str) -> dict: return {"videos": []} +async def delete_all_generated_videos(user_id: str) -> tuple[int, int]: + """删除用户所有生成的视频,返回 (删除数量, 失败数量)""" + try: + files = await storage_service.list_files( + bucket=storage_service.BUCKET_OUTPUTS, + path=user_id, + strict=True, + ) + deleted_count = 0 + failed_count = 0 + for f in files: + name = f.get("name") + if not name or name == ".emptyFolderPlaceholder": + continue + full_path = f"{user_id}/{name}" + try: + await storage_service.delete_file( + bucket=storage_service.BUCKET_OUTPUTS, + path=full_path + ) + deleted_count += 1 + except Exception as e: + failed_count += 1 + logger.warning(f"Delete file failed: {full_path}, {e}") + return deleted_count, failed_count + except Exception as e: + logger.error(f"Delete all generated videos failed: {e}") + return 0, 1 + + async def delete_generated_video(user_id: str, video_id: str) -> dict: """删除生成的视频""" try: diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index d089c28..10b6f5e 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -182,18 +182,18 @@ class StorageService: logger.error(f"Get public URL failed: {e}") return "" - async def delete_file(self, bucket: str, path: str): - """异步删除文件""" - try: - loop = asyncio.get_running_loop() - await loop.run_in_executor( - None, - lambda: self.supabase.storage.from_(bucket).remove([path]) - ) - logger.info(f"Deleted file: {bucket}/{path}") - except Exception as e: - logger.error(f"Delete file failed: {e}") - pass + async def delete_file(self, bucket: str, path: str): + """异步删除文件""" + try: + loop = asyncio.get_running_loop() + await loop.run_in_executor( + None, + lambda: self.supabase.storage.from_(bucket).remove([path]) + ) + logger.info(f"Deleted file: {bucket}/{path}") + except Exception as e: + logger.error(f"Delete file failed: {e}") + raise e async def move_file(self, bucket: str, from_path: str, to_path: str): """异步移动/重命名文件""" @@ -208,17 +208,19 @@ class StorageService: logger.error(f"Move file failed: {e}") raise e - async def list_files(self, bucket: str, path: str) -> List[Any]: - """异步列出文件""" - try: - loop = asyncio.get_running_loop() - res = await loop.run_in_executor( - None, - lambda: self.supabase.storage.from_(bucket).list(path) - ) - return res or [] - except Exception as e: - logger.error(f"List files failed: {e}") - return [] + async def list_files(self, bucket: str, path: str, strict: bool = False) -> List[Any]: + """异步列出文件""" + try: + loop = asyncio.get_running_loop() + res = await loop.run_in_executor( + None, + lambda: self.supabase.storage.from_(bucket).list(path) + ) + return res or [] + except Exception as e: + logger.error(f"List files failed: {e}") + if strict: + raise e + return [] storage_service = StorageService() diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico index 718d6fe..fa0ed75 100644 Binary files a/frontend/src/app/favicon.ico and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/icon.png b/frontend/src/app/icon.png new file mode 100644 index 0000000..5e6279a Binary files /dev/null and b/frontend/src/app/icon.png differ diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 96b9c82..a4bf7e5 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { AuthProvider } from "@/shared/contexts/AuthContext"; import { TaskProvider } from "@/shared/contexts/TaskContext"; +import { CleanupProvider } from "@/shared/contexts/CleanupContext"; import { Toaster } from "sonner"; @@ -40,7 +41,9 @@ export default function RootLayout({ > - {children} + + {children} + diff --git a/frontend/src/features/home/ui/PreviewPanel.tsx b/frontend/src/features/home/ui/PreviewPanel.tsx index b7c3c68..00c5fe4 100644 --- a/frontend/src/features/home/ui/PreviewPanel.tsx +++ b/frontend/src/features/home/ui/PreviewPanel.tsx @@ -12,6 +12,7 @@ interface PreviewPanelProps { currentTask: Task | null; isGenerating: boolean; generatedVideo: string | null; + generatedVideoId?: string | null; embedded?: boolean; } @@ -19,8 +20,13 @@ export function PreviewPanel({ currentTask, isGenerating, generatedVideo, + generatedVideoId = null, embedded = false, }: PreviewPanelProps) { + const downloadHref = generatedVideoId + ? `/api/videos/generated/${encodeURIComponent(generatedVideoId)}/download` + : generatedVideo; + const content = ( <> {currentTask && isGenerating && ( @@ -51,10 +57,10 @@ export function PreviewPanel({ )} - {generatedVideo && ( + {generatedVideo && downloadHref && ( <> diff --git a/frontend/src/features/publish/model/usePublishController.ts b/frontend/src/features/publish/model/usePublishController.ts index a06ea4e..7e93f09 100644 --- a/frontend/src/features/publish/model/usePublishController.ts +++ b/frontend/src/features/publish/model/usePublishController.ts @@ -7,6 +7,7 @@ import { clampTitle } from "@/shared/lib/title"; import { useTitleInput } from "@/shared/hooks/useTitleInput"; import { useAuth } from "@/shared/contexts/AuthContext"; import { useTask } from "@/shared/contexts/TaskContext"; +import { useCleanup } from "@/shared/contexts/CleanupContext"; import { toast } from "sonner"; import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch"; import { @@ -40,6 +41,7 @@ export const usePublishController = () => { const { userId, isLoading: isAuthLoading } = useAuth(); const { isGenerating } = useTask(); + const { triggerCleanup } = useCleanup(); const prevIsGenerating = useRef(isGenerating); const { readPrefetch, updatePrefetch } = usePublishPrefetch(); @@ -183,6 +185,23 @@ export const usePublishController = () => { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); }, []); + // ---- 工作区清理事件(清理后同步重置当前页输入态) ---- + useEffect(() => { + if (typeof window === "undefined") return; + + const handleWorkspaceCleared = (event: Event) => { + const detail = (event as CustomEvent<{ userId?: string }>).detail; + if (!detail?.userId || detail.userId !== userId) return; + + setTitle(""); + setTags(""); + setPublishResults([]); + }; + + window.addEventListener("vigent:workspace-cleared", handleWorkspaceCleared); + return () => window.removeEventListener("vigent:workspace-cleared", handleWorkspaceCleared); + }, [userId]); + // ---- 发布防误操作 ---- useEffect(() => { if (!isPublishing) return; @@ -300,7 +319,12 @@ export const usePublishController = () => { try { const taskFactories = selectedPlatforms.map((platform) => () => publishOnePlatform(platform)); const results = await runWithConcurrency(taskFactories, 2); - setPublishResults(results); + const allSuccess = results.length > 0 && results.every(r => r.success); + if (allSuccess) { + triggerCleanup(results, video.id); + } else { + setPublishResults(results); + } } finally { setIsPublishing(false); } diff --git a/frontend/src/shared/contexts/CleanupContext.tsx b/frontend/src/shared/contexts/CleanupContext.tsx new file mode 100644 index 0000000..7cb051a --- /dev/null +++ b/frontend/src/shared/contexts/CleanupContext.tsx @@ -0,0 +1,414 @@ +"use client"; + +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + type ReactNode, +} from "react"; +import Image from "next/image"; +import { AppModal, AppModalHeader } from "@/shared/ui/AppModal"; +import { useAuth } from "@/shared/contexts/AuthContext"; +import api from "@/shared/api/axios"; +import type { ApiResponse } from "@/shared/api/types"; +import { Download, Trash2, Loader2, CheckCircle2 } from "lucide-react"; +import type { PublishResult } from "@/shared/types/publish"; + +/* ────────── types ────────── */ + +const CLEANUP_EXPIRE_MS = 24 * 60 * 60 * 1000; // 24h +const MAX_FAIL_BEFORE_SKIP = 3; + +interface CleanupState { + required: boolean; + publishResults: PublishResult[]; + videoId?: string; + createdAt?: number; // timestamp for expiry check + failCount?: number; +} + +interface CleanupContextType { + triggerCleanup: (results: PublishResult[], videoId?: string) => void; +} + +const EMPTY_STATE: CleanupState = { required: false, publishResults: [] }; + +const CleanupContext = createContext({ + triggerCleanup: () => {}, +}); + +/* ────────── helpers ────────── */ + +function storageKey(userId: string) { + return `vigent_${userId}_cleanup_pending`; +} + +function normalizeVideoId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const raw = value.trim(); + if (!raw) return undefined; + + const decoded = (() => { + try { + return decodeURIComponent(raw); + } catch { + return raw; + } + })(); + + const routeMatch = decoded.match(/\/generated\/([^/?#]+)\/download/i); + if (routeMatch?.[1]) return routeMatch[1]; + + const outputMatch = decoded.match(/\/([^/?#]+_output)\.mp4(?:[?#]|$)/i); + if (outputMatch?.[1]) return outputMatch[1]; + + if (!decoded.includes("/") && !decoded.includes(".") && !decoded.includes("?")) { + return decoded; + } + + return undefined; +} + +function readPersistedState(userId: string): CleanupState { + try { + const raw = localStorage.getItem(storageKey(userId)); + if (!raw) return EMPTY_STATE; + const parsed = JSON.parse(raw) as CleanupState; + const normalized: CleanupState = { + required: Boolean(parsed.required), + publishResults: Array.isArray(parsed.publishResults) ? parsed.publishResults : [], + videoId: normalizeVideoId(parsed.videoId) + || normalizeVideoId((parsed as unknown as Record).videoDownloadUrl), + createdAt: typeof parsed.createdAt === "number" ? parsed.createdAt : Date.now(), + failCount: typeof parsed.failCount === "number" && parsed.failCount > 0 ? parsed.failCount : 0, + }; + + if (!normalized.required) return EMPTY_STATE; + + // 24h expiry check + if (normalized.createdAt && Date.now() - normalized.createdAt > CLEANUP_EXPIRE_MS) { + localStorage.removeItem(storageKey(userId)); + return EMPTY_STATE; + } + return normalized; + } catch { + return EMPTY_STATE; + } +} + +function persistState(userId: string, state: CleanupState) { + localStorage.setItem(storageKey(userId), JSON.stringify(state)); +} + +function clearPersistedState(userId: string) { + localStorage.removeItem(storageKey(userId)); +} + +/* ────────── localStorage keys to clear ────────── */ + +function clearWorkspaceLocalStorage(userId: string) { + const key = userId; + const keysToRemove = [ + // home page content + `vigent_${key}_text`, + `vigent_${key}_title`, + `vigent_${key}_secondaryTitle`, + // publish page + `vigent_${key}_publish_title`, + `vigent_${key}_publish_tags`, + ]; + keysToRemove.forEach((k) => localStorage.removeItem(k)); +} + +/* ────────── platform icons ────────── */ + +const platformIcons: Record = { + douyin: { src: "/platforms/douyin.svg", alt: "抖音" }, + weixin: { src: "/platforms/wechat.svg", alt: "微信视频号" }, + bilibili: { src: "/platforms/bilibili.svg", alt: "B站" }, + xiaohongshu: { src: "/platforms/xiaohongshu.svg", alt: "小红书" }, +}; + +/* ────────── CleanupModal ────────── */ + +function CleanupModal({ + isOpen, + publishResults, + videoId, + cleanupError, + failCount, + onCleanup, + onSkip, +}: { + isOpen: boolean; + publishResults: PublishResult[]; + videoId?: string; + cleanupError?: string | null; + failCount: number; + onCleanup: () => Promise; + onSkip: () => void; +}) { + const [isCleaning, setIsCleaning] = useState(false); + + const handleCleanup = async () => { + setIsCleaning(true); + try { + await onCleanup(); + } catch { + // keep modal open for retry + } finally { + setIsCleaning(false); + } + }; + + const canSkip = failCount >= MAX_FAIL_BEFORE_SKIP; + + return ( + {}} + closeOnOverlay={false} + zIndexClassName="z-[300]" + panelClassName="w-full max-w-lg rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden max-h-[90vh] flex flex-col" + > + } + /> + +
+ {/* Success results */} +
+ {publishResults.map((r, i) => ( +
+ {platformIcons[r.platform] ? ( + {platformIcons[r.platform].alt} + ) : ( + 🌐 + )} + + {platformIcons[r.platform]?.alt || r.platform} - 发布成功 + +
+ ))} +
+ + {/* Download button */} + {videoId && ( +
+ + 下载视频备份(可选) + + )} + + {cleanupError && ( +
+ 清理失败:{cleanupError} +
+ )} + + {/* Cleanup button */} + + + {canSkip && ( + + )} + +

+ 清理成功后弹窗关闭;若失败将保留弹窗以便重试。清理将删除所有生成的视频和配音, +
+ 并清空标题、文案和标签(已保存的历史文案不受影响)。 +

+ + {/* Screenshots */} + {publishResults.some((r) => r.screenshot_url) && ( +
+

发布成功截图

+
+ {publishResults + .filter((r) => r.screenshot_url) + .map((r, i) => ( +
+

+ {platformIcons[r.platform]?.alt || r.platform} +

+ + {`${r.platform} + +
+ ))} +
+
+ )} +
+ + ); +} + +/* ────────── Provider ────────── */ + +export function CleanupProvider({ children }: { children: ReactNode }) { + const { userId, isLoading: isAuthLoading } = useAuth(); + const [cleanupState, setCleanupState] = useState(EMPTY_STATE); + const [cleanupError, setCleanupError] = useState(null); + + // Restore from localStorage on mount / reset on user switch + useEffect(() => { + if (isAuthLoading) return; + + if (!userId) { + setCleanupState(EMPTY_STATE); + setCleanupError(null); + return; + } + + const persisted = readPersistedState(userId); + if (persisted.required) { + persistState(userId, persisted); + setCleanupState(persisted); + } else { + setCleanupState(EMPTY_STATE); + } + setCleanupError(null); + }, [isAuthLoading, userId]); + + const triggerCleanup = useCallback( + (results: PublishResult[], videoId?: string) => { + if (!userId) return; + setCleanupError(null); + const state: CleanupState = { + required: true, + publishResults: results, + videoId, + createdAt: Date.now(), + failCount: 0, + }; + persistState(userId, state); + setCleanupState(state); + }, + [userId] + ); + + const executeCleanup = useCallback(async () => { + if (!userId) return; + setCleanupError(null); + + // 1. Call backend to delete files + try { + const { data: res } = await api.post>( + "/api/videos/cleanup" + ); + + if (!res.success) { + throw new Error(res.message || "服务端清理失败"); + } + } catch (e) { + console.error("Cleanup API failed:", e); + const err = e as { response?: { data?: { message?: string; detail?: string } }; message?: string }; + const message = err.response?.data?.message || err.response?.data?.detail || err.message || "请稍后重试"; + setCleanupError(message); + setCleanupState((prev) => { + if (!prev.required) return prev; + const next: CleanupState = { + ...prev, + failCount: (prev.failCount || 0) + 1, + createdAt: prev.createdAt || Date.now(), + }; + persistState(userId, next); + return next; + }); + throw e; + } + + // 2. Clear workspace localStorage keys + clearWorkspaceLocalStorage(userId); + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("vigent:workspace-cleared", { detail: { userId } }) + ); + } + + // 3. Clear cleanup pending state + clearPersistedState(userId); + setCleanupState(EMPTY_STATE); + setCleanupError(null); + }, [userId]); + + // Skip: close modal and clear cleanup_pending immediately (user chose to skip) + const handleSkip = useCallback(() => { + if (!userId) return; + clearPersistedState(userId); + setCleanupState(EMPTY_STATE); + setCleanupError(null); + }, [userId]); + + return ( + + {children} + + + ); +} + +export function useCleanup() { + return useContext(CleanupContext); +}