From 23ff4ff86e462c2885332a89bb00db6c62021767 Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Wed, 4 Mar 2026 14:07:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/BACKEND_DEV.md | 12 + Docs/BACKEND_README.md | 4 + Docs/DevLogs/Day31.md | 93 +++- Docs/DevLogs/Day32.md | 71 +++ Docs/FRONTEND_DEV.md | 21 +- Docs/FRONTEND_README.md | 4 + Docs/PUBLISH_DEPLOY.md | 9 + Docs/task_complete.md | 15 +- README.md | 6 +- .../app/modules/generated_audios/service.py | 24 + backend/app/modules/videos/router.py | 44 +- backend/app/modules/videos/service.py | 30 ++ backend/app/services/storage.py | 50 ++- frontend/src/app/favicon.ico | Bin 25931 -> 3658 bytes frontend/src/app/icon.png | Bin 0 -> 9829 bytes frontend/src/app/layout.tsx | 5 +- frontend/src/features/home/ui/HomePage.tsx | 1 + .../src/features/home/ui/PreviewPanel.tsx | 10 +- .../publish/model/usePublishController.ts | 26 +- .../src/shared/contexts/CleanupContext.tsx | 414 ++++++++++++++++++ 20 files changed, 792 insertions(+), 47 deletions(-) create mode 100644 Docs/DevLogs/Day32.md create mode 100644 frontend/src/app/icon.png create mode 100644 frontend/src/shared/contexts/CleanupContext.tsx 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 718d6fea4835ec2d246af9800eddb7ffb276240c..fa0ed75ea5a26800f13f76bdd7be25a5f87c9917 100644 GIT binary patch literal 3658 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 LrJugUA?W`A8`#=m diff --git a/frontend/src/app/icon.png b/frontend/src/app/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5e6279af067341e4e173b75615354b937260e5cf GIT binary patch literal 9829 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 z%0Aej>@SUBT6xicJrbt;s6i_%FM2OIbL!O!W?R1Ta8ORaL5q~qz`g4YtITf zX~1i?xEZwTQzHO{u!H5y8Kqbs5eCxnQvSxl@rl=-WG(BlXC>!euzKPRSU*gHr|zc~ zx}bQ1FcTZ}7)>;(ks4%s9nMEgO%UbuX)a8Z|868c4zr|O1z&-EG=pp2*XdgIA*oX7 zZf7bHmRL8yntU2mCtZeKy@q1G2P#S5Lle8}PUs$<@Jc21#G5B(bU%?Q;a3CX2V)Km z6nbqj)0+nUc4kG$oHr5Ac+G)+L`ENG2;YO4mN{7xDkh@?o#ZC#QUqxs^fwDC=055c zD#iLFq{Whcq>BHk40vKk(5?&`{BH>u;*At)I;f$eW&B-D>ea6L07j}r5K4D`83E1+ z2w+&fJ_OzXgEY2Gg`jO!wcVbM0`sAoTHP6jG{J@v&~+873Td59;PO|4HW-Qf6OSO- zlAzGf?n3IDpUDW-X6(@nc%K}LUM=8ml~8|JC|d`!9DWRc$Cj(VnZfmqvnjvnrxNZ1 z0rBkBS@@P_wP$d$&|@|XD%!9-Ndou574b}VlGL=**0)2eI=IWJkub z8b+T7_^|70xChq%ls|$|I;%*&_6e!)16AvVPunl2<`x}sLOQ^5;fkq{&rBirnvb#d zLtPK2lw6nNw#Sfri$ouCow28;FwijghKde!+jqKPvJG*DbUogKRjKn;Lasf23D9>A z1{$u3`yXOlpcC-EzPadI2cw31BO&(vdgC#H4X#EsubHATs;2_&{B$Nl*`H@-3URcJl z=&nl=w$cuPPUhVQ6a>7_r~wzC^B-&Dv46F8S|3L~3t5L;h}nNy5(}M$xCII50Q`UE zvHWvb7PPj>)`F~#Bc=C{`0!u=XXTRLi+Ofm0eY9Q|1LpySJ=56>P>XkA&klLr$v=u z{AE!8mjI@>Ak5D_r%0qgdJ6u)R9xT6~8;}ccZ0lf{Pb`PH0MHd|NE^*ECL@>E!TU7H`ddZl`f-GF4@MFv zM1JJY@OwPu3IMj)43v|mRy@`N%!fPO6>e2E@=ykVBp53n5?AH8_=k4NWs_Xkf15V+ zHku?BXZn{9e~I8?r+U5%4QkLE!NW8I=o^JQsA?I8`)v%`Dj^g2*T)vj&di2PP;beN zX!%Z`HkMHb0B%^z%tI@(b+#brL^YrEd^RKu5U-QQfe;r|3KkYnx3Nf6sEu|1&*U8n zZ%_u;V^{0LNZ~|-l}`U=bwd4+eA!;$ZwvPXOyd|NanOhh6&-_Np7>md1n2>HPDb<( zSp%Hax+|hULT!6BzdXZzNQTH>fr=zG;VjA4PrQhmFgrKYWj|FPBtE*4H^*WLv8$>1 z2&yvw2DOM40Svz)*#V%}54)<-eVH0(Lsn5yhjk*{c&@NKOWvpeexe6Wos8RM^Dh$+l_3F55{>V`BkDQt{RU4b8wEDw-@t1XjiNFHyXTa z1oPs%j;T7Jh)_(=HYS&GM7<>wFJSl$I27NvT;4WC4 KuRMpp`+ookLKRm4 literal 0 HcmV?d00001 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); +}