Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23ff4ff86e | ||
|
|
091f78174e |
@@ -86,13 +86,23 @@ backend/
|
||||
- `custom_assignments` 每项使用 `material_path/start/end/source_start/source_end?`,并以时间轴可见段为准。
|
||||
- `output_aspect_ratio` 仅允许 `9:16` / `16:9`,默认 `9:16`。
|
||||
- 标题显示模式参数:
|
||||
- `title_display_mode`: `short` / `persistent`(默认 `short`)
|
||||
- `title_display_mode`: `short` / `persistent`(默认 `short`,对主标题与副标题统一生效)
|
||||
- `title_duration`: 默认 `4.0`(秒),仅 `short` 模式生效
|
||||
- 片头副标题参数:
|
||||
- `secondary_title`: 副标题文字(可选,限 20 字),仅在视频画面中显示,不参与发布标题
|
||||
- `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 存储(用户隔离)
|
||||
|
||||
|
||||
@@ -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`: 获取素材列表
|
||||
@@ -156,7 +160,7 @@ backend/
|
||||
- `advanced`: 强制 LatentSync
|
||||
- `language`: TTS 语言区域(默认 `zh-CN`;会映射为 Whisper 的 `zh/en/...` 与 CosyVoice 的 `Chinese/English/Auto`)
|
||||
- `title`: 片头标题文字
|
||||
- `title_display_mode`: 标题显示模式(`short` / `persistent`,默认 `short`)
|
||||
- `title_display_mode`: 标题显示模式(`short` / `persistent`,默认 `short`;该模式对主标题与副标题统一生效)
|
||||
- `title_duration`: 标题显示时长(秒,默认 `4.0`;`short` 模式生效)
|
||||
- `subtitle_style_id`: 字幕样式 ID
|
||||
- `title_style_id`: 标题样式 ID
|
||||
|
||||
@@ -312,40 +312,149 @@ async def preview_voice_get(voice: str, current_user: dict = Depends(get_current
|
||||
|
||||
---
|
||||
|
||||
## ✅ 11) 首页「AI生成标题标签」按钮位置优化(迁移到四、标题与字幕)
|
||||
|
||||
### 设计结论
|
||||
|
||||
- 将 `AI生成标题标签` 从「一、文案提取与编辑」迁移到「四、标题与字幕」
|
||||
- 标题区改为两行:
|
||||
- 第一行:`四、标题与字幕` 标题 + 右侧 `AI生成标题标签`
|
||||
- 第二行:右对齐放置 `标题短暂显示/常驻显示` + `预览样式`
|
||||
- 显示语义补充:`标题短暂显示/常驻显示` 对主标题与副标题统一生效(常驻=主/副标题都常驻)
|
||||
- 不额外增加提示文案,保持界面简洁
|
||||
- `AI生成标题标签` 外观对齐 `在线录音` 按钮的圆角与尺寸(`rounded-lg` + 同级按钮尺寸),颜色保留原蓝色渐变
|
||||
|
||||
### 结果
|
||||
|
||||
- 标题相关动作集中到同一板块,避免用户在「一」和「四」之间来回跳转
|
||||
- 行内层级更明确:AI 动作在标题同层,配置项与预览在下一行
|
||||
- AI 按钮圆角与尺寸更柔和,配色仍保持原蓝色渐变,视觉更统一
|
||||
|
||||
### 验证
|
||||
|
||||
- `npm run build`(frontend)✅
|
||||
- `pm2 restart vigent2-frontend` ✅
|
||||
|
||||
---
|
||||
|
||||
## ✅ 12) 文案编辑框右下角扩展角标(弹出大编辑器)
|
||||
|
||||
### 设计与实现
|
||||
|
||||
- 在「一、文案提取与编辑」主输入框右下角新增角标按钮(点击后打开扩展编辑器)
|
||||
- 扩展编辑器使用 `AppModal`,提供更大编辑空间(高约 `66vh`)
|
||||
- 主输入框与弹窗内输入框共享同一份 `text` 状态,双向实时同步
|
||||
- 为避免角标遮挡正文,主输入框增加右下内边距(`pr-6 pb-6`)
|
||||
- 角标样式进一步极简化:仅保留双箭头图标,去掉外框容器并贴近输入框边缘
|
||||
- 角标位置微调为更协调的“上移+右移”:`right-0.5 bottom-2`,并固定点击区域 `h-5 w-5`
|
||||
- 修复扩展编辑输入焦点丢失:`AppModal` 改为使用 `onCloseRef` 处理 ESC,避免父组件重渲染时 effect 误清理导致 textarea 失焦
|
||||
- 移除扩展编辑输入框紫色聚焦边框,改为中性边框高亮(`focus:border-white/25`)
|
||||
|
||||
### 验证
|
||||
|
||||
- `npm run build`(frontend)✅
|
||||
- `pm2 restart vigent2-frontend` ✅
|
||||
|
||||
---
|
||||
|
||||
## ✅ 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` |
|
||||
| `frontend/src/features/home/ui/HomePage.tsx` | 透传录音弃用动作 |
|
||||
| `frontend/src/features/home/ui/HomePage.tsx` | 透传录音弃用动作;将 `AI生成标题标签` 事件改为传入 `TitleSubtitlePanel` |
|
||||
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 上传/录音入口重排;录音改弹窗;使用/弃用流程 |
|
||||
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 文案编辑区按钮视觉统一(含 AI智能改写/保存文案) |
|
||||
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 文案编辑区按钮视觉统一;移除 `AI生成标题标签`(职责回归标题板块);新增输入框右下角扩展角标与大编辑弹窗;角标改为双箭头极简贴边样式并微调到 `right-0.5 bottom-2`;输入框去除紫色聚焦边框 |
|
||||
| `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` |
|
||||
| `backend/app/services/qr_login_service.py` | 抖音导航超时容错 + 微信二维码提取增强 + 小红书登录自动切换到扫码模式并提取二维码 |
|
||||
| `backend/app/services/uploader/weixin_uploader.py` | `file_input empty` 告警策略优化:先检测上传信号,非最后一次重试降级为 info |
|
||||
| `frontend/src/shared/ui/AppModal.tsx` | 统一弹窗组件 + 无障碍语义 + 焦点管理 + 多弹窗滚动锁计数 |
|
||||
| `frontend/src/shared/ui/AppModal.tsx` | 统一弹窗组件 + 无障碍语义 + 焦点管理 + 多弹窗滚动锁计数;新增 `onCloseRef` 避免回调引用变化引发的意外失焦 |
|
||||
| `frontend/src/components/VideoPreviewModal.tsx` | 迁移到 `AppModal` |
|
||||
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | 迁移到 `AppModal` |
|
||||
| `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)和录音交互规范 |
|
||||
| `Docs/FRONTEND_README.md` | 增补录音入口与弹窗交互说明 |
|
||||
| `Docs/BACKEND_README.md` | 增补 `voice-preview` 接口说明;更新发布 API 路径(`/login/{platform}` 等)并链接发布专项文档 |
|
||||
| `Docs/BACKEND_DEV.md` | 更新后端规范中的发布器覆盖范围与小红书配置项;补充发布专项文档指引 |
|
||||
| `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` | 与当前阈值/参数说明对齐 |
|
||||
@@ -368,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`
|
||||
@@ -395,9 +510,16 @@ 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` 格式
|
||||
- 小红书登录二维码自动切换(短信登录 -> 扫码登录)与提取修复
|
||||
- 对应构建/重启/冒烟验证记录
|
||||
|
||||
71
Docs/DevLogs/Day32.md
Normal file
71
Docs/DevLogs/Day32.md
Normal file
@@ -0,0 +1,71 @@
|
||||
## 视频下载同源修复 + Day 日志拆分归档 (Day 32)
|
||||
|
||||
### 概述
|
||||
|
||||
今天主要处理“下载行为不符合预期”的问题:
|
||||
|
||||
1. 修复首页与发布成功弹窗点击下载时被浏览器当作在线播放(新开标签页)的问题。
|
||||
2. 将下载修复开始后的开发内容从 `Day31` 拆分到 `Day32`,保持日志按天清晰归档。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 1) 视频下载链路修复(避免新开标签页播放)
|
||||
|
||||
### 问题现象
|
||||
|
||||
- 首页“下载视频”与发布成功弹窗“下载视频备份”在部分浏览器会打开新标签页播放视频,而不是直接触发下载。
|
||||
- 根因是跨域签名 URL 场景下,浏览器可能忽略 `<a download>`。
|
||||
|
||||
### 修复方案
|
||||
|
||||
- 后端新增同源下载接口:`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"}` ✅
|
||||
@@ -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(路由保护)
|
||||
@@ -212,7 +213,7 @@ body {
|
||||
|
||||
## 统一弹窗规范 (AppModal)
|
||||
|
||||
所有居中弹窗(如视频预览、文案提取、AI 改写、录音、密码修改)统一使用 `@/shared/ui/AppModal` + `AppModalHeader`:
|
||||
所有居中弹窗(如视频预览、文案提取、AI 改写、文案扩展编辑、录音、密码修改)统一使用 `@/shared/ui/AppModal` + `AppModalHeader`:
|
||||
|
||||
- 统一遮罩与层级:`fixed inset-0` + `bg-black/80` + `backdrop-blur-sm` + 明确 `z-index`
|
||||
- 统一挂载位置:通过 Portal 挂载到 `document.body`,避免局部容器/层叠上下文影响,确保是全页面弹窗
|
||||
@@ -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)
|
||||
|
||||
## 类型定义规范
|
||||
|
||||
@@ -11,7 +11,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
## ✨ 核心功能
|
||||
|
||||
### 1. 视频生成 (`/`)
|
||||
- **一、文案提取与编辑**: 文案输入/提取/翻译/保存。
|
||||
- **一、文案提取与编辑**: 文案输入/提取/翻译/保存;输入框右下角支持一键扩展到大编辑器。
|
||||
- **二、配音**: 配音方式(EdgeTTS/声音克隆)+ 配音列表(生成/试听/管理)合并为一个板块。
|
||||
- **三、素材编辑**: 视频素材(上传/选择/管理)+ 时间轴编辑(波形/色块/拖拽排序)合并为一个板块。
|
||||
- **四、标题与字幕**: 片头标题/副标题/字幕样式配置;短暂显示/常驻显示;样式预览使用视频片头帧作为真实背景。
|
||||
@@ -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 / 声音克隆切换,音色选择统一下拉(显示音色名 + 语言)。
|
||||
@@ -60,7 +64,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。
|
||||
|
||||
### 5. 字幕与标题
|
||||
- **片头标题**: 可选输入,限制 15 字;支持”短暂显示 / 常驻显示”,默认短暂显示(4 秒),对标题和副标题同时生效。
|
||||
- **片头标题**: 可选输入,限制 15 字;支持”短暂显示 / 常驻显示”,默认短暂显示(4 秒);`常驻显示` 时主标题与副标题都会全程显示。
|
||||
- **片头副标题**: 可选输入,限制 20 字;显示在主标题下方,用于补充说明或悬念引导;独立样式配置(字体/字号/颜色/间距),可由 AI 同时生成;与标题共享显示模式设定;仅在视频画面中显示,不参与发布标题。
|
||||
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
|
||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启。
|
||||
|
||||
@@ -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 与账号隔离
|
||||
|
||||
@@ -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] **录音交互重构**: 录音入口迁移到参考音频区底部,流程改为弹窗;支持录音后即时关闭弹窗、后台上传识别。
|
||||
@@ -26,6 +33,10 @@
|
||||
- [x] **实测闭环**: 小红书 `POST /api/publish` 实测成功(45.77s)并可访问成功截图接口。
|
||||
- [x] **文档补齐**: 新增 `Docs/PUBLISH_DEPLOY.md`,并回写 `README.md`、`BACKEND_README.md`、`BACKEND_DEV.md`、`DEPLOY_MANUAL.md`。
|
||||
- [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`)当前渲染所需文件到缓存目录。
|
||||
|
||||
@@ -32,9 +32,11 @@
|
||||
|
||||
### 平台化功能
|
||||
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
|
||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||
- 📸 **发布结果可视化** - 抖音/微信视频号/小红书发布成功后返回截图,发布页结果卡片可直接查看。
|
||||
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
||||
- 🧹 **发布后工作区清理引导** - 全平台发布成功后弹出不可误关清理弹窗(失败可重试,达到阈值可暂不清理),仅清输入内容并保留用户偏好。
|
||||
- ⬇️ **一键下载直达** - 首页与发布成功弹窗下载统一走同源 `attachment` 接口,不再新开标签页播放视频。
|
||||
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
||||
- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。
|
||||
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||
|
||||
@@ -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("无权删除此文件")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/src/app/icon.png
Normal file
BIN
frontend/src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
@@ -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({
|
||||
>
|
||||
<AuthProvider>
|
||||
<TaskProvider>
|
||||
{children}
|
||||
<CleanupProvider>
|
||||
{children}
|
||||
</CleanupProvider>
|
||||
</TaskProvider>
|
||||
</AuthProvider>
|
||||
<Toaster
|
||||
|
||||
@@ -223,8 +223,6 @@ export function HomePage() {
|
||||
onChangeText={setText}
|
||||
onOpenExtractModal={() => setExtractModalOpen(true)}
|
||||
onOpenRewriteModal={() => setRewriteModalOpen(true)}
|
||||
onGenerateMeta={handleGenerateMeta}
|
||||
isGeneratingMeta={isGeneratingMeta}
|
||||
onTranslate={handleTranslate}
|
||||
isTranslating={isTranslating}
|
||||
hasOriginalText={originalText !== null}
|
||||
@@ -362,6 +360,9 @@ export function HomePage() {
|
||||
<TitleSubtitlePanel
|
||||
showStylePreview={showStylePreview}
|
||||
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
|
||||
onGenerateMeta={handleGenerateMeta}
|
||||
isGeneratingMeta={isGeneratingMeta}
|
||||
canGenerateMeta={!!text.trim()}
|
||||
videoTitle={videoTitle}
|
||||
onTitleChange={titleInput.handleChange}
|
||||
onTitleCompositionStart={titleInput.handleCompositionStart}
|
||||
@@ -488,6 +489,7 @@ export function HomePage() {
|
||||
currentTask={null}
|
||||
isGenerating={false}
|
||||
generatedVideo={generatedVideo}
|
||||
generatedVideoId={selectedVideoId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generatedVideo && (
|
||||
{generatedVideo && downloadHref && (
|
||||
<>
|
||||
<a
|
||||
href={generatedVideo}
|
||||
href={downloadHref}
|
||||
download
|
||||
className="mt-4 w-full py-3 rounded-xl bg-green-600 hover:bg-green-700 text-white font-medium flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FileText, History, Languages, Loader2, RotateCcw, Save, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FileText, History, Languages, Loader2, Maximize2, RotateCcw, Save, Sparkles, Trash2 } from "lucide-react";
|
||||
import type { SavedScript } from "@/features/home/model/useSavedScripts";
|
||||
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "English", label: "英语 English" },
|
||||
@@ -19,8 +20,6 @@ interface ScriptEditorProps {
|
||||
onChangeText: (value: string) => void;
|
||||
onOpenExtractModal: () => void;
|
||||
onOpenRewriteModal: () => void;
|
||||
onGenerateMeta: () => void;
|
||||
isGeneratingMeta: boolean;
|
||||
onTranslate: (targetLang: string) => void;
|
||||
isTranslating: boolean;
|
||||
hasOriginalText: boolean;
|
||||
@@ -36,8 +35,6 @@ export function ScriptEditor({
|
||||
onChangeText,
|
||||
onOpenExtractModal,
|
||||
onOpenRewriteModal,
|
||||
onGenerateMeta,
|
||||
isGeneratingMeta,
|
||||
onTranslate,
|
||||
isTranslating,
|
||||
hasOriginalText,
|
||||
@@ -54,6 +51,10 @@ export function ScriptEditor({
|
||||
const langMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [showHistoryMenu, setShowHistoryMenu] = useState(false);
|
||||
const historyMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [isExpandedEditorOpen, setIsExpandedEditorOpen] = useState(false);
|
||||
const handleCloseExpandedEditor = useCallback(() => {
|
||||
setIsExpandedEditorOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showLangMenu) return;
|
||||
@@ -193,34 +194,25 @@ export function ScriptEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onGenerateMeta}
|
||||
disabled={isGeneratingMeta || !text.trim()}
|
||||
className={`${actionBtnBase} ${isGeneratingMeta || !text.trim()
|
||||
? actionBtnDisabled
|
||||
: "bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{isGeneratingMeta ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI生成标题标签
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => onChangeText(e.target.value)}
|
||||
placeholder="请输入你想说的话..."
|
||||
className="w-full h-40 bg-black/30 border border-white/10 rounded-xl p-4 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-purple-500 transition-colors hide-scrollbar"
|
||||
/>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => onChangeText(e.target.value)}
|
||||
placeholder="请输入你想说的话..."
|
||||
className="w-full h-40 bg-black/30 border border-white/10 rounded-xl p-4 pr-6 pb-6 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-white/25 transition-colors hide-scrollbar"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpandedEditorOpen(true)}
|
||||
className="absolute right-0.5 bottom-2 h-5 w-5 text-gray-400/85 hover:text-white focus:outline-none transition-colors inline-flex items-center justify-center"
|
||||
aria-label="扩展文案编辑器"
|
||||
title="扩展编辑"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 text-sm text-gray-400">
|
||||
<span>{text.length} 字</span>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -250,6 +242,27 @@ export function ScriptEditor({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppModal
|
||||
isOpen={isExpandedEditorOpen}
|
||||
onClose={handleCloseExpandedEditor}
|
||||
panelClassName="w-full max-w-5xl max-h-[92vh] rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
|
||||
>
|
||||
<AppModalHeader
|
||||
title="扩展文案编辑"
|
||||
subtitle="在更大空间里编写与调整文案"
|
||||
onClose={handleCloseExpandedEditor}
|
||||
actions={<span className="text-xs text-gray-400 tabular-nums">{text.length} 字</span>}
|
||||
/>
|
||||
<div className="flex-1 p-4 sm:p-5">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => onChangeText(e.target.value)}
|
||||
placeholder="请输入你想说的话..."
|
||||
className="w-full h-[66vh] min-h-[320px] bg-black/30 border border-white/10 rounded-xl p-4 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-white/25 transition-colors hide-scrollbar"
|
||||
/>
|
||||
</div>
|
||||
</AppModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, Eye, Check } from "lucide-react";
|
||||
import { ChevronDown, Eye, Check, Loader2, Sparkles } from "lucide-react";
|
||||
import { FloatingStylePreview } from "@/features/home/ui/FloatingStylePreview";
|
||||
import { SelectPopover } from "@/shared/ui/SelectPopover";
|
||||
|
||||
@@ -35,6 +35,9 @@ interface TitleStyleOption {
|
||||
interface TitleSubtitlePanelProps {
|
||||
showStylePreview: boolean;
|
||||
onTogglePreview: () => void;
|
||||
onGenerateMeta: () => void;
|
||||
isGeneratingMeta: boolean;
|
||||
canGenerateMeta: boolean;
|
||||
videoTitle: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onTitleCompositionStart?: () => void;
|
||||
@@ -76,6 +79,9 @@ interface TitleSubtitlePanelProps {
|
||||
export function TitleSubtitlePanel({
|
||||
showStylePreview,
|
||||
onTogglePreview,
|
||||
onGenerateMeta,
|
||||
isGeneratingMeta,
|
||||
canGenerateMeta,
|
||||
videoTitle,
|
||||
onTitleChange,
|
||||
onTitleCompositionStart,
|
||||
@@ -125,63 +131,88 @@ export function TitleSubtitlePanel({
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-4 gap-2">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
四、标题与字幕
|
||||
</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="shrink-0">
|
||||
<SelectPopover
|
||||
sheetTitle="标题显示方式"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="min-w-[146px] rounded-lg border border-white/10 bg-black/25 px-2.5 py-1.5 text-left text-xs text-gray-200 transition-colors hover:border-white/30"
|
||||
aria-label="标题显示方式"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="whitespace-nowrap">{currentTitleDisplay.label}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{titleDisplayOptions.map((opt) => {
|
||||
const isSelected = opt.value === titleDisplayMode;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onTitleDisplayModeChange(opt.value);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-white whitespace-nowrap">{opt.label}</span>
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
</div>
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
四、标题与字幕
|
||||
</h2>
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
|
||||
onClick={onGenerateMeta}
|
||||
disabled={isGeneratingMeta || !canGenerateMeta}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg transition-colors inline-flex items-center gap-1.5 ${
|
||||
isGeneratingMeta || !canGenerateMeta
|
||||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||||
: "bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white"
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{showStylePreview ? "收起预览" : "预览样式"}
|
||||
{isGeneratingMeta ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI生成标题标签
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<div className="shrink-0">
|
||||
<SelectPopover
|
||||
sheetTitle="标题显示方式"
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="min-w-[146px] rounded-lg border border-white/10 bg-black/25 px-2.5 py-1.5 text-left text-xs text-gray-200 transition-colors hover:border-white/30"
|
||||
aria-label="标题显示方式"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="whitespace-nowrap">{currentTitleDisplay.label}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="space-y-1">
|
||||
{titleDisplayOptions.map((opt) => {
|
||||
const isSelected = opt.value === titleDisplayMode;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
data-popover-selected={isSelected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onTitleDisplayModeChange(opt.value);
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-colors ${isSelected
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-white whitespace-nowrap">{opt.label}</span>
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SelectPopover>
|
||||
</div>
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{showStylePreview ? "收起预览" : "预览样式"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showStylePreview && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
414
frontend/src/shared/contexts/CleanupContext.tsx
Normal file
414
frontend/src/shared/contexts/CleanupContext.tsx
Normal file
@@ -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<CleanupContextType>({
|
||||
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<string, unknown>).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<string, { src: string; alt: string }> = {
|
||||
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<void>;
|
||||
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 (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {}}
|
||||
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"
|
||||
>
|
||||
<AppModalHeader
|
||||
title="发布完成"
|
||||
subtitle="所有平台发布成功"
|
||||
icon={<CheckCircle2 className="h-5 w-5 text-green-400" />}
|
||||
/>
|
||||
|
||||
<div className="p-5 space-y-4 overflow-y-auto flex-1">
|
||||
{/* Success results */}
|
||||
<div className="space-y-2">
|
||||
{publishResults.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 p-3 rounded-xl border border-green-500/30 bg-green-500/10"
|
||||
>
|
||||
{platformIcons[r.platform] ? (
|
||||
<Image
|
||||
src={platformIcons[r.platform].src}
|
||||
alt={platformIcons[r.platform].alt}
|
||||
width={20}
|
||||
height={20}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-lg">🌐</span>
|
||||
)}
|
||||
<span className="text-green-400 font-medium text-sm">
|
||||
{platformIcons[r.platform]?.alt || r.platform} - 发布成功
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Download button */}
|
||||
{videoId && (
|
||||
<a
|
||||
href={`/api/videos/generated/${encodeURIComponent(videoId)}/download`}
|
||||
download
|
||||
className="flex items-center justify-center gap-2 w-full py-3 rounded-xl border border-blue-500/30 bg-blue-500/10 text-blue-300 hover:bg-blue-500/20 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
下载视频备份(可选)
|
||||
</a>
|
||||
)}
|
||||
|
||||
{cleanupError && (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
||||
清理失败:{cleanupError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cleanup button */}
|
||||
<button
|
||||
onClick={handleCleanup}
|
||||
disabled={isCleaning}
|
||||
className="flex items-center justify-center gap-2 w-full py-3 rounded-xl bg-gradient-to-r from-purple-600 to-pink-600 text-white font-semibold hover:from-purple-500 hover:to-pink-500 transition-all disabled:opacity-60"
|
||||
>
|
||||
{isCleaning ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在清理...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
清理工作区 & 开始新作品
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{canSkip && (
|
||||
<button
|
||||
onClick={onSkip}
|
||||
disabled={isCleaning}
|
||||
className="flex items-center justify-center w-full py-2.5 rounded-xl border border-white/10 bg-white/5 text-gray-400 hover:bg-white/10 hover:text-gray-300 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
暂不清理,继续使用
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 text-center leading-relaxed">
|
||||
清理成功后弹窗关闭;若失败将保留弹窗以便重试。清理将删除所有生成的视频和配音,
|
||||
<br />
|
||||
并清空标题、文案和标签(已保存的历史文案不受影响)。
|
||||
</p>
|
||||
|
||||
{/* Screenshots */}
|
||||
{publishResults.some((r) => r.screenshot_url) && (
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<p className="text-xs text-gray-400 mb-3">发布成功截图</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{publishResults
|
||||
.filter((r) => r.screenshot_url)
|
||||
.map((r, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<p className="text-xs text-gray-500">
|
||||
{platformIcons[r.platform]?.alt || r.platform}
|
||||
</p>
|
||||
<a
|
||||
href={r.screenshot_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-lg border border-white/10 bg-black/20 overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
src={r.screenshot_url!}
|
||||
alt={`${r.platform} 截图`}
|
||||
width={400}
|
||||
height={300}
|
||||
className="w-full"
|
||||
unoptimized
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────── Provider ────────── */
|
||||
|
||||
export function CleanupProvider({ children }: { children: ReactNode }) {
|
||||
const { userId, isLoading: isAuthLoading } = useAuth();
|
||||
const [cleanupState, setCleanupState] = useState<CleanupState>(EMPTY_STATE);
|
||||
const [cleanupError, setCleanupError] = useState<string | null>(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<ApiResponse<{ videos_deleted: number; audios_deleted: number }>>(
|
||||
"/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 (
|
||||
<CleanupContext.Provider value={{ triggerCleanup }}>
|
||||
{children}
|
||||
<CleanupModal
|
||||
isOpen={cleanupState.required}
|
||||
publishResults={cleanupState.publishResults}
|
||||
videoId={cleanupState.videoId}
|
||||
cleanupError={cleanupError}
|
||||
failCount={cleanupState.failCount || 0}
|
||||
onCleanup={executeCleanup}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</CleanupContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCleanup() {
|
||||
return useContext(CleanupContext);
|
||||
}
|
||||
@@ -24,12 +24,17 @@ export function AppModal({
|
||||
lockBodyScroll = true,
|
||||
}: AppModalProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEsc = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
if (event.key === "Escape") onCloseRef.current();
|
||||
};
|
||||
|
||||
const previousActiveElement = document.activeElement as HTMLElement | null;
|
||||
@@ -64,7 +69,7 @@ export function AppModal({
|
||||
|
||||
previousActiveElement?.focus?.();
|
||||
};
|
||||
}, [isOpen, lockBodyScroll, onClose]);
|
||||
}, [isOpen, lockBodyScroll]);
|
||||
|
||||
if (!isOpen || typeof document === "undefined") return null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user