Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b45852bf | ||
|
|
23ff4ff86e |
@@ -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. 认证与权限
|
||||
@@ -100,6 +110,8 @@ backend/
|
||||
- 认证方式:**HttpOnly Cookie** (`access_token`)。
|
||||
- `get_current_user` / `get_current_user_optional` 位于 `core/deps.py`。
|
||||
- Session 单设备校验使用 `repositories/sessions.py`。
|
||||
- AI/Tools 等高成本接口必须强制鉴权(`Depends(get_current_user)`),禁止匿名调用消耗外部 API 配额。
|
||||
- 生产环境要求 `DEBUG=false` + 非默认 `JWT_SECRET_KEY`;默认密钥在生产模式下必须阻止服务启动。
|
||||
|
||||
---
|
||||
|
||||
@@ -115,6 +127,16 @@ backend/
|
||||
|
||||
- 所有文件上传/下载/删除/移动通过 `services/storage.py`。
|
||||
- 需要重命名时使用 `move_file`,避免直接读写 Storage。
|
||||
- `delete_file` 必须向上抛出异常,不允许静默吞错(避免清理接口出现“假成功”)。
|
||||
- `list_files` 默认容错返回空列表;清理等强一致场景应使用 `strict=True`。
|
||||
- 所有用户输入的文件路径/ID 必须做防御校验:
|
||||
- `material_id` 拒绝 `..` 序列,避免路径穿越
|
||||
- `video_id` 等资源 ID 使用白名单(如 `^[A-Za-z0-9_-]+$`)
|
||||
- 上传/下载链路必须有体积上限:
|
||||
- 素材上传遵循 `MAX_UPLOAD_SIZE_MB`
|
||||
- 参考音频上限 5MB
|
||||
- 文案提取工具文件上传与 URL 下载结果均上限 500MB
|
||||
- 面向前端的错误返回默认使用通用文案;内部堆栈只写服务端日志,避免泄露路径/实现细节。
|
||||
|
||||
### Cookie 存储(用户隔离)
|
||||
|
||||
|
||||
@@ -65,16 +65,20 @@ 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`: 获取素材列表
|
||||
* `PUT /api/materials/{material_id}`: 重命名素材
|
||||
* `GET /api/materials/stream/{material_id}`: 同源流式返回素材文件(用于前端 canvas 截帧,避免跨域 CORS taint)
|
||||
* `GET /api/materials/stream/{material_id}`: 同源流式返回素材文件(用于前端 canvas 截帧,避免跨域 CORS taint;服务端会拒绝 `..` 路径)
|
||||
|
||||
4. **社交发布 (Publish)**
|
||||
* `POST /api/publish`: 发布视频到 抖音/微信视频号/B站/小红书
|
||||
@@ -100,8 +104,9 @@ backend/
|
||||
* `POST /api/ref-audios/{id}/retranscribe`: 重新识别参考音频文字(Whisper 转写 + 超 10s 自动截取)
|
||||
|
||||
7. **AI 功能 (AI)**
|
||||
* `POST /api/ai/generate-meta`: AI 生成标题和标签
|
||||
* `POST /api/ai/translate`: AI 多语言翻译(支持 9 种目标语言)
|
||||
* `POST /api/ai/generate-meta`: AI 生成标题和标签(需登录)
|
||||
* `POST /api/ai/translate`: AI 多语言翻译(支持 9 种目标语言,需登录)
|
||||
* `POST /api/ai/rewrite`: AI 改写文案(需登录)
|
||||
|
||||
8. **预生成配音 (Generated Audios)**
|
||||
* `POST /api/generated-audios/generate`: 异步生成配音(返回 task_id)
|
||||
@@ -111,11 +116,11 @@ backend/
|
||||
* `PUT /api/generated-audios/{audio_id}`: 重命名配音
|
||||
|
||||
9. **工具 (Tools)**
|
||||
* `POST /api/tools/extract-script`: 从视频链接提取文案
|
||||
* `POST /api/tools/extract-script`: 从视频链接提取文案(需登录)
|
||||
|
||||
10. **健康检查**
|
||||
* `GET /api/lipsync/health`: 唇形同步服务健康状态(含 LatentSync + MuseTalk + 混合路由阈值)
|
||||
* `GET /api/voiceclone/health`: CosyVoice 3.0 服务健康状态
|
||||
* `GET /api/videos/lipsync/health`: 唇形同步服务健康状态(含 LatentSync + MuseTalk + 混合路由阈值)
|
||||
* `GET /api/videos/voiceclone/health`: CosyVoice 3.0 服务健康状态
|
||||
|
||||
11. **支付 (Payment)**
|
||||
* `POST /api/payment/create-order`: 创建支付宝电脑网站支付订单(需 payment_token)
|
||||
@@ -124,6 +129,16 @@ backend/
|
||||
|
||||
> 登录时若账号未激活或已过期,返回 403 + `payment_token`,前端跳转 `/pay` 页面完成付费。详见 [支付宝部署指南](ALIPAY_DEPLOY.md)。
|
||||
|
||||
### 安全基线(生产环境)
|
||||
|
||||
- `DEBUG` 必须设为 `false`:认证 Cookie 会带 `Secure`,仅在 HTTPS 下发送。
|
||||
- `JWT_SECRET_KEY` 必须是强随机值且不能使用默认值;当 `DEBUG=false` 且仍为默认值时,后端会在启动阶段直接拒绝启动。
|
||||
- 上传体积限制:
|
||||
- `POST /api/materials`:受 `MAX_UPLOAD_SIZE_MB` 限制(默认 500MB)
|
||||
- `POST /api/ref-audios`:5MB
|
||||
- `POST /api/tools/extract-script`:文件上传与 URL 下载结果均限制 500MB
|
||||
- `video_id` 在下载/删除接口使用白名单校验(`^[A-Za-z0-9_-]+$`),非法值直接返回 400。
|
||||
|
||||
### 统一响应结构
|
||||
|
||||
```json
|
||||
|
||||
@@ -154,12 +154,12 @@ playwright install chromium
|
||||
|
||||
---
|
||||
|
||||
### 可选:AI 标题/标签生成
|
||||
### 可选:AI 标题/标签生成
|
||||
|
||||
> ✅ 如需启用“AI 标题/标签生成”功能,请确保后端可访问外网 API。
|
||||
|
||||
- 需要可访问 `https://open.bigmodel.cn`
|
||||
- API Key 配置在 `backend/app/services/glm_service.py`(建议替换为自己的密钥)
|
||||
- 需要可访问 `https://open.bigmodel.cn`
|
||||
- API Key 配置在 `backend/.env` 的 `GLM_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
@@ -214,10 +214,11 @@ cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
| `LATENTSYNC_USE_SERVER` | true | 设为 true 以启用常驻服务加速 |
|
||||
| `LATENTSYNC_INFERENCE_STEPS` | 30 | 推理步数 (16-50) |
|
||||
| `LATENTSYNC_GUIDANCE_SCALE` | 1.9 | 引导系数 (1.0-3.0) |
|
||||
| `LATENTSYNC_ENABLE_DEEPCACHE` | true | DeepCache 推理加速 |
|
||||
| `LATENTSYNC_SEED` | 1247 | 固定随机种子(可复现) |
|
||||
| `DEBUG` | true | 生产环境改为 false |
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
|
||||
| `LATENTSYNC_ENABLE_DEEPCACHE` | true | DeepCache 推理加速 |
|
||||
| `LATENTSYNC_SEED` | 1247 | 固定随机种子(可复现) |
|
||||
| `DEBUG` | false | 生产环境必须为 false(仅开发环境可设 true) |
|
||||
| `JWT_SECRET_KEY` | 强随机值 | 生产环境禁止默认值;默认值在 `DEBUG=false` 下会阻止后端启动 |
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
|
||||
| `WEIXIN_HEADLESS_MODE` | headless-new | 视频号 Playwright 模式 (headful/headless-new) |
|
||||
| `WEIXIN_CHROME_PATH` | `/usr/bin/google-chrome` | 系统 Chrome 路径 |
|
||||
| `WEIXIN_BROWSER_CHANNEL` | | Chromium 通道 (可选) |
|
||||
@@ -258,7 +259,9 @@ cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
| `PAYMENT_AMOUNT` | `999.00` | 会员价格 (元) |
|
||||
| `PAYMENT_EXPIRE_DAYS` | `365` | 会员有效天数 |
|
||||
|
||||
> 支付宝完整配置步骤(密钥生成、PEM 格式、产品开通等)请参考 **[支付宝部署指南](ALIPAY_DEPLOY.md)**。
|
||||
> 支付宝完整配置步骤(密钥生成、PEM 格式、产品开通等)请参考 **[支付宝部署指南](ALIPAY_DEPLOY.md)**。
|
||||
|
||||
> 认证相关强约束:当 `DEBUG=false` 时,后端登录 Cookie 会带 `Secure`,前端必须通过 HTTPS 域名访问,HTTP 端口直连无法保持登录态。
|
||||
|
||||
---
|
||||
|
||||
@@ -316,11 +319,11 @@ cd /home/rongye/ProgramFiles/ViGent2/models/MuseTalk
|
||||
/home/rongye/ProgramFiles/miniconda3/envs/musetalk/bin/python scripts/server.py
|
||||
```
|
||||
|
||||
### 验证
|
||||
|
||||
1. 访问 http://服务器IP:3002 查看前端
|
||||
2. 访问 http://服务器IP:8006/docs 查看 API 文档
|
||||
3. 上传测试视频,生成口播视频
|
||||
### 验证
|
||||
|
||||
1. 访问 `https://你的前端域名` 查看前端(生产环境不要用 HTTP 端口直连)
|
||||
2. 访问 `http://服务器IP:8006/docs` 查看 API 文档(仅内网/运维调试)
|
||||
3. 上传测试视频,生成口播视频
|
||||
|
||||
---
|
||||
|
||||
@@ -540,8 +543,8 @@ server {
|
||||
GLM_API_KEY=your_zhipu_api_key
|
||||
```
|
||||
|
||||
3. **验证**:
|
||||
访问 `http://localhost:8006/docs`,测试 `/api/tools/extract-script` 接口。
|
||||
3. **验证**:
|
||||
访问 `http://localhost:8006/docs`,在已登录会话下测试 `/api/tools/extract-script`(该接口需认证)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` 格式
|
||||
- 小红书登录二维码自动切换(短信登录 -> 扫码登录)与提取修复
|
||||
|
||||
158
Docs/DevLogs/Day32.md
Normal file
158
Docs/DevLogs/Day32.md
Normal file
@@ -0,0 +1,158 @@
|
||||
## 视频下载同源修复 + 安全漏洞第一批修复 (Day 32)
|
||||
|
||||
### 概述
|
||||
|
||||
今天的工作聚焦四件事:
|
||||
|
||||
1. 修复首页与发布成功弹窗点击下载时被浏览器当作在线播放(新开标签页)的问题。
|
||||
2. 将下载修复开始后的开发内容从 `Day31` 拆分到 `Day32`,保持日志按天清晰归档。
|
||||
3. 根据安全审计报告(`Temp/安全审计报告.md`),实施第一批 6 项无功能风险的安全修复。
|
||||
4. 统一弹窗关闭交互:默认支持点空白关闭,发布成功清理弹窗保持强制留存。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 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 链路加固为止)。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 3) 安全漏洞第一批修复(6 项,无功能风险)
|
||||
|
||||
根据安全审计报告,实施第一批 6 项可直接修复的安全加固项。
|
||||
|
||||
### 3.1 JWT 默认密钥启动拦截
|
||||
|
||||
- **文件**:`backend/app/main.py`
|
||||
- 新增 `check_jwt_secret` startup 事件(在 `init_admin` 之前)
|
||||
- 当 `JWT_SECRET_KEY` 仍为默认值 `"your-secret-key-change-in-production"` 时:
|
||||
- **生产环境**(`DEBUG=False`):`raise RuntimeError` 直接阻止服务启动
|
||||
- **开发环境**(`DEBUG=True`):输出 `CRITICAL` 级别日志告警,不阻止启动
|
||||
|
||||
### 3.2 AI / Tools 接口加认证
|
||||
|
||||
- **文件**:`backend/app/modules/ai/router.py`、`backend/app/modules/tools/router.py`
|
||||
- AI 路由 3 个端点(`/translate`、`/generate-meta`、`/rewrite`)均增加 `Depends(get_current_user)`
|
||||
- Tools 路由 1 个端点(`/extract-script`)增加 `Depends(get_current_user)`
|
||||
- 前端 axios 已有 `withCredentials: true`,401 自动跳登录页,无需前端改动
|
||||
|
||||
### 3.3 素材路径穿越修复
|
||||
|
||||
- **文件**:`backend/app/modules/materials/router.py`、`backend/app/modules/materials/service.py`
|
||||
- `stream`、`delete_material`、`rename_material` 三处在 `startswith(user_id)` 校验之前新增 `..` 拒绝
|
||||
- 含 `..` 的 `material_id` 直接返回 400
|
||||
- `delete_material` 路由补充 `except ValueError` → 400(原先仅 catch `PermissionError`,`ValueError` 会被 `Exception` 兜底返回 500)
|
||||
|
||||
### 3.4 video_id 白名单校验
|
||||
|
||||
- **文件**:`backend/app/modules/videos/router.py`
|
||||
- `download_generated` 和 `delete_generated` 两个端点在函数开头增加正则校验
|
||||
- 仅允许 `^[A-Za-z0-9_-]+$`,不符合直接返回 400
|
||||
|
||||
### 3.5 上传/下载大小限制
|
||||
|
||||
- **materials/service.py**(流式上传):在 chunk 累加后检查 `MAX_UPLOAD_SIZE_MB`(默认 500MB),超限抛 `ValueError`
|
||||
- **ref_audios/service.py**(参考音频):`await file.read()` 后检查 5MB 上限
|
||||
- **tools/service.py**(文案提取文件上传):将 `shutil.copyfileobj` 替换为分块拷贝 + 500MB 限制
|
||||
- **tools/service.py**(URL 下载分支):`_download_video` 返回后检查文件体积,超 500MB 删除临时文件并拒绝
|
||||
|
||||
### 3.6 错误信息通用化
|
||||
|
||||
- **ai/router.py**:3 处 `detail=str(e)` 分别改为"翻译服务暂时不可用"、"生成标题标签失败"、"改写服务暂时不可用"
|
||||
- **tools/router.py**:保留 "Fresh cookies" 特定分支提示,fallback 改为"文案提取失败,请稍后重试"
|
||||
- **generated_audios/service.py**:任务失败 `error` 字段从 `traceback.format_exc()` 改为 `str(e)`,traceback 仅写入服务端日志
|
||||
|
||||
---
|
||||
|
||||
## ✅ 4) 弹窗关闭交互统一(UX)
|
||||
|
||||
### 目标
|
||||
|
||||
- 保持统一交互预期:业务弹窗默认可通过 `X` 与点击遮罩关闭。
|
||||
- 保留关键流程保护:发布成功清理弹窗继续禁止遮罩关闭,避免误触导致流程中断。
|
||||
|
||||
### 调整内容
|
||||
|
||||
- 文案提取弹窗(`ScriptExtractionModal`)支持点击遮罩关闭。
|
||||
- AI 改写弹窗(`RewriteModal`)支持点击遮罩关闭。
|
||||
- 发布页扫码登录弹窗支持点击遮罩关闭。
|
||||
- 修改密码弹窗支持点击遮罩关闭。
|
||||
- 录音弹窗采用动态策略:`closeOnOverlay={!isRecording}`
|
||||
- 未录音:允许遮罩关闭
|
||||
- 录音中:禁止遮罩关闭(防误触);`X` 关闭仍可用,且会先停止录音再关闭
|
||||
- 发布成功清理弹窗维持 `closeOnOverlay=false`,并且不提供 `onClose`(无右上角关闭按钮)。
|
||||
|
||||
---
|
||||
|
||||
## 📁 今日主要修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/app/modules/videos/router.py` | 新增 `GET /api/videos/generated/{video_id}/download`,返回 `attachment` 下载响应;新增 `video_id` 白名单正则校验(`^[A-Za-z0-9_-]+$`) |
|
||||
| `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` |
|
||||
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | 弹窗支持点击遮罩关闭(`closeOnOverlay`) |
|
||||
| `frontend/src/features/home/ui/RewriteModal.tsx` | 弹窗支持点击遮罩关闭(`closeOnOverlay`) |
|
||||
| `frontend/src/features/publish/ui/PublishPage.tsx` | 扫码登录弹窗支持点击遮罩关闭 |
|
||||
| `frontend/src/components/AccountSettingsDropdown.tsx` | 修改密码弹窗支持点击遮罩关闭 |
|
||||
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 录音弹窗改为 `closeOnOverlay={!isRecording}`(录音中禁遮罩关闭) |
|
||||
| `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)”能力描述 |
|
||||
| `backend/app/main.py` | `check_jwt_secret` startup 事件:生产环境(`DEBUG=False`)强拦截启动,开发环境 `CRITICAL` 告警 |
|
||||
| `backend/app/modules/ai/router.py` | 3 个端点加 `Depends(get_current_user)` 认证;错误返回改为通用消息 |
|
||||
| `backend/app/modules/tools/router.py` | `extract-script` 端点加 `Depends(get_current_user)` 认证;错误返回改为通用消息 |
|
||||
| `backend/app/modules/materials/router.py` | `stream` 端点新增 `..` 路径穿越拒绝;`delete` 端点补充 `except ValueError` → 400 |
|
||||
| `backend/app/modules/materials/service.py` | `delete_material` / `rename_material` 新增 `..` 路径穿越拒绝;流式上传增加 `MAX_UPLOAD_SIZE_MB` 大小限制 |
|
||||
| `backend/app/modules/ref_audios/service.py` | 参考音频上传增加 5MB 大小限制 |
|
||||
| `backend/app/modules/tools/service.py` | 文案提取文件上传替换为限大小分块拷贝(500MB);URL 下载分支增加下载后体积检查(500MB) |
|
||||
| `backend/app/modules/generated_audios/service.py` | 任务失败错误字段从 `traceback.format_exc()` 改为 `str(e)`,避免泄露内部路径 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证记录
|
||||
|
||||
- `python -m py_compile backend/app/modules/videos/router.py` ✅
|
||||
- `npm run build`(frontend)✅
|
||||
- `npm run build`(frontend,弹窗关闭策略调整后复验)✅
|
||||
- `pm2 restart vigent2-frontend` ✅
|
||||
- `pm2 restart vigent2-backend` ✅
|
||||
- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}` ✅
|
||||
- 安全修复第一批语法验证:`python -m py_compile backend/app/main.py backend/app/modules/materials/router.py backend/app/modules/tools/service.py backend/app/modules/ai/router.py backend/app/modules/tools/router.py backend/app/modules/materials/service.py backend/app/modules/ref_audios/service.py backend/app/modules/videos/router.py backend/app/modules/generated_audios/service.py` ✅
|
||||
- 未登录调用 `/api/ai/translate` → 返回 401 ✅
|
||||
- 未登录调用 `/api/tools/extract-script` → 返回 401 ✅
|
||||
- 收尾三刀语法验证:`python -m py_compile backend/app/main.py backend/app/modules/materials/router.py backend/app/modules/tools/service.py` ✅
|
||||
@@ -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(路由保护)
|
||||
@@ -218,11 +219,28 @@ body {
|
||||
- 统一挂载位置:通过 Portal 挂载到 `document.body`,避免局部容器/层叠上下文影响,确保是全页面弹窗
|
||||
- 统一容器风格:`border-white/10`、深色半透明背景、圆角 `rounded-2xl`、重阴影
|
||||
- 统一关闭行为:支持 `ESC`;是否允许点击遮罩关闭通过 `closeOnOverlay` 显式配置
|
||||
- 默认策略:除关键流程外,`closeOnOverlay` 默认应为 `true`,并通过 `AppModalHeader onClose` 提供右上角 `X` 关闭入口
|
||||
- 关键流程例外:发布成功清理弹窗(`CleanupContext`)必须保持 `closeOnOverlay=false`,且不提供右上角关闭按钮
|
||||
- 录音弹窗例外:使用 `closeOnOverlay={!isRecording}`,录音中禁止遮罩关闭,避免误触中断
|
||||
- 统一滚动策略:弹窗打开时锁定背景滚动(`lockBodyScroll`),内容区自行滚动
|
||||
- 特殊层级场景(例如视频预览压过下拉)使用更高 `z-index`(如 `z-[320]`)
|
||||
|
||||
---
|
||||
|
||||
## 发布后清理弹窗规范 (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 实例)
|
||||
@@ -230,6 +248,7 @@ body {
|
||||
所有需要认证的 API 请求**必须**使用 `@/shared/api/axios` 导出的 axios 实例。该实例已配置:
|
||||
- 自动携带 `credentials: include`
|
||||
- 遇到 401/403 时自动清除 cookie 并跳转登录页
|
||||
- AI/Tools 接口(如 `/api/ai/*`、`/api/tools/extract-script`)现为强制鉴权,禁止匿名 `fetch` 直调
|
||||
|
||||
**使用方式:**
|
||||
|
||||
@@ -391,7 +410,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)
|
||||
|
||||
## 类型定义规范
|
||||
@@ -508,6 +527,7 @@ await api.post('/api/videos/generate', {
|
||||
- 录音入口放在“我的参考音频”区域底部右侧(与“上传音频”并排)。
|
||||
- 录音交互使用弹窗:开始/停止 -> 试听 -> 使用此录音 / 弃用本次录音。
|
||||
- 关闭录音弹窗时如仍在录制,会先停止录音再关闭。
|
||||
- 录音中禁止点击遮罩关闭(`closeOnOverlay={!isRecording}`);未录音时允许遮罩关闭。
|
||||
|
||||
```typescript
|
||||
// 录音需要用户授权麦克风
|
||||
|
||||
@@ -19,6 +19,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **六、作品**(右栏): 作品列表 + 作品预览合并为一个板块。
|
||||
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
||||
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
|
||||
- **下载直达**: 首页作品下载与发布成功弹窗下载统一走同源下载接口(`/api/videos/generated/{id}/download`),避免新标签页在线播放。
|
||||
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
|
||||
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复。
|
||||
- **历史文案**: 手动保存/加载/删除历史文案,独立 localStorage 持久化。
|
||||
@@ -37,12 +38,16 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **选择持久化**: 使用稳定 `video.id` 持久化选择,刷新保持;新视频生成自动选中最新。
|
||||
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
|
||||
- **发布方式**: 仅支持 "立即发布"。
|
||||
- **发布成功清理弹窗**: 全平台发布成功后触发 `CleanupModal`(展示成功平台、截图、下载备份、清理按钮),刷新/跳转后可恢复。
|
||||
- **清理失败兜底**: 清理接口失败时弹窗不关闭且不清本地输入;连续失败达到阈值后可“暂不清理,继续使用”。
|
||||
- **清理范围**: 仅清理输入内容字段(文案/标题/副标题/发布标题/标签),保留样式、字号、边距、模型等用户偏好。
|
||||
|
||||
### 3. 声音克隆
|
||||
- **TTS 模式选择**: EdgeTTS / 声音克隆切换,音色选择统一下拉(显示音色名 + 语言)。
|
||||
- **音色试听**: EdgeTTS 音色列表支持一键试听,按音色 locale 自动选择对应语言的固定示例文案。
|
||||
- **参考音频管理**: 上传/列表/重命名/删除参考音频,上传后自动 Whisper 转写 ref_text + 超 10s 自动截取。
|
||||
- **录音入口**: 参考音频区域底部右侧提供“上传音频 / 录音”入口;录音采用弹窗流程(录制 -> 试听 -> 使用/弃用)。
|
||||
- **录音防误触**: 录音中禁用遮罩关闭(避免误触中断);未录音时可点空白关闭。
|
||||
- **重新识别**: 旧参考音频可重新转写并截取 (RotateCw 按钮)。
|
||||
- **一键克隆**: 选择参考音频后自动调用 CosyVoice 3.0 服务。
|
||||
- **语速控制**: 声音克隆模式下支持 5 档语速 (0.8-1.2),统一下拉,选择持久化。
|
||||
@@ -89,6 +94,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
### 9. 文案提取助手 (`ScriptExtractionModal`)
|
||||
- **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。
|
||||
- **AI 智能改写**: 集成 GLM-4.7-Flash,自动改写为口播文案。
|
||||
- **登录鉴权**: 依赖后端受保护接口(`/api/tools/extract-script`),未登录会触发全局 401 跳转登录。
|
||||
- **自定义提示词**: 可自定义改写提示词,留空使用默认;设置持久化到 localStorage。
|
||||
- **一键填入**: 提取结果直接填充至视频生成输入框。
|
||||
- **智能交互**: 实时进度展示,防误触设计。
|
||||
@@ -154,6 +160,7 @@ src/
|
||||
|
||||
- 业务选择器统一使用 `SelectPopover`(桌面 Popover / 移动端 BottomSheet);`ScriptEditor` 的“历史文案 / AI多语言”保留原轻量菜单。
|
||||
- 业务弹窗统一使用 `AppModal`(统一遮罩、头部、关闭行为与滚动策略)。
|
||||
- 弹窗关闭策略:默认支持 `ESC` / `X` / 点击空白关闭;仅发布成功清理弹窗为强制流程(不允许空白关闭,也不显示 `X`)。
|
||||
- 视频预览弹窗层级高于下拉菜单;下拉内支持连续预览。
|
||||
- 页面同时适配桌面端与移动端;长列表统一隐藏滚动条。
|
||||
- 详细 UI 规范、持久化规范与交互约束请查看 `Docs/FRONTEND_DEV.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 与账号隔离
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ViGent2 开发任务清单 (Task Log)
|
||||
|
||||
**项目**: ViGent2 数字人口播视频生成系统
|
||||
**进度**: 100% (Day 31 - 发布登录稳定性修复 + 文档体系补齐)
|
||||
**更新时间**: 2026-03-03
|
||||
**进度**: 100% (Day 32 - 视频下载同源修复 + 安全整改第一批)
|
||||
**更新时间**: 2026-03-04
|
||||
|
||||
---
|
||||
|
||||
@@ -10,7 +10,17 @@
|
||||
|
||||
> 这里记录了每一天的核心开发内容与 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` 移除对应章节与验证记录。
|
||||
- [x] **安全第一批修复**: JWT 默认密钥生产拦截、AI/Tools 接口强制鉴权、materials 路径穿越拦截、video_id 白名单、上传体积限制、错误信息通用化。
|
||||
- [x] **安全收尾三刀**: `delete_material` 的 `ValueError -> 400`、`tools` URL 下载分支 500MB 限制、`DEBUG=false` 下默认 JWT 密钥阻断启动。
|
||||
- [x] **弹窗关闭策略收敛**: 默认支持 `ESC/X/遮罩` 关闭;发布成功清理弹窗保持强制流程不允许遮罩关闭;录音弹窗录音中禁遮罩关闭(防误触)。
|
||||
|
||||
### Day 31: 文档体系收敛 + 音色试听 + 录音弹窗重构 + 发布登录稳定性修复
|
||||
- [x] **文档体系收敛**: README/DEV 职责边界明确,部署参数与代码对齐,Qwen3-TTS 文档归档至历史状态。
|
||||
- [x] **音色试听能力**: 新增并启用 `GET/POST /api/videos/voice-preview`,前端改为直接播放 GET 音频流,修复线上 404(重启后端生效)。
|
||||
- [x] **录音交互重构**: 录音入口迁移到参考音频区底部,流程改为弹窗;支持录音后即时关闭弹窗、后台上传识别。
|
||||
@@ -28,6 +38,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`)当前渲染所需文件到缓存目录。
|
||||
|
||||
13
README.md
13
README.md
@@ -32,12 +32,15 @@
|
||||
|
||||
### 平台化功能
|
||||
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
|
||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||
- 📸 **发布结果可视化** - 抖音/微信视频号/小红书发布成功后返回截图,发布页结果卡片可直接查看。
|
||||
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
||||
- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。
|
||||
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||
- 🧹 **发布后工作区清理引导** - 全平台发布成功后弹出不可误关清理弹窗(失败可重试,达到阈值可暂不清理),仅清输入内容并保留用户偏好。
|
||||
- ⬇️ **一键下载直达** - 首页与发布成功弹窗下载统一走同源 `attachment` 接口,不再新开标签页播放视频。
|
||||
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
||||
- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。
|
||||
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||
- 🛡️ **安全基线** - AI/Tools 接口强制登录鉴权、关键上传链路体积限制、生产环境默认密钥启动拦截。
|
||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||
- 🚀 **性能优化** - 编码流水线从 5-6 次有损编码精简至 3 次(prepare_segment → 模型输出 → Remotion)、compose 流复制免重编码、同分辨率跳过 scale、FFmpeg 超时保护、全局视频生成并发限制 (Semaphore(2))、Remotion 4 并发渲染、MuseTalk rawvideo 管道直编码(消除中间有损文件)、模型常驻服务、双 GPU 流水线并发、Redis 任务 TTL 自动清理、workflow 阻塞调用线程池化。
|
||||
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# 复制此文件为 .env 并填入实际值
|
||||
|
||||
# 调试模式
|
||||
DEBUG=true
|
||||
DEBUG=false
|
||||
|
||||
# Redis 配置 (Celery 任务队列)
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
@@ -130,6 +130,20 @@ app.include_router(generated_audios_router, prefix="/api/generated-audios", tags
|
||||
app.include_router(payment_router) # /api/payment
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def check_jwt_secret():
|
||||
if settings.JWT_SECRET_KEY == "your-secret-key-change-in-production":
|
||||
if not settings.DEBUG:
|
||||
raise RuntimeError(
|
||||
"JWT_SECRET_KEY is still the default value! "
|
||||
"Set a strong random secret in .env before running in production (DEBUG=False)."
|
||||
)
|
||||
logger.critical(
|
||||
"JWT_SECRET_KEY is still the default value! "
|
||||
"Set a strong random secret in .env for production."
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def init_admin():
|
||||
"""
|
||||
|
||||
@@ -4,11 +4,12 @@ AI 相关 API 路由
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
|
||||
from app.services.glm_service import glm_service
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.response import success_response
|
||||
|
||||
|
||||
@@ -40,7 +41,7 @@ class TranslateRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/translate")
|
||||
async def translate_text(req: TranslateRequest):
|
||||
async def translate_text(req: TranslateRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
AI 翻译文案
|
||||
|
||||
@@ -57,11 +58,11 @@ async def translate_text(req: TranslateRequest):
|
||||
return success_response({"translated_text": translated})
|
||||
except Exception as e:
|
||||
logger.error(f"Translate failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail="翻译服务暂时不可用,请稍后重试")
|
||||
|
||||
|
||||
@router.post("/generate-meta")
|
||||
async def generate_meta(req: GenerateMetaRequest):
|
||||
async def generate_meta(req: GenerateMetaRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
AI 生成视频标题和标签
|
||||
|
||||
@@ -80,11 +81,11 @@ async def generate_meta(req: GenerateMetaRequest):
|
||||
).model_dump())
|
||||
except Exception as e:
|
||||
logger.error(f"Generate meta failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail="生成标题标签失败,请稍后重试")
|
||||
|
||||
|
||||
@router.post("/rewrite")
|
||||
async def rewrite_script(req: RewriteRequest):
|
||||
async def rewrite_script(req: RewriteRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""AI 改写文案"""
|
||||
if not req.text or not req.text.strip():
|
||||
raise HTTPException(status_code=400, detail="文案不能为空")
|
||||
@@ -95,4 +96,4 @@ async def rewrite_script(req: RewriteRequest):
|
||||
return success_response({"rewritten_text": rewritten})
|
||||
except Exception as e:
|
||||
logger.error(f"Rewrite failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail="改写服务暂时不可用,请稍后重试")
|
||||
|
||||
@@ -152,9 +152,9 @@ async def generate_audio_task(task_id: str, req: GenerateAudioRequest, user_id:
|
||||
task_store.update(task_id, {
|
||||
"status": "failed",
|
||||
"message": f"配音生成失败: {str(e)}",
|
||||
"error": traceback.format_exc(),
|
||||
"error": str(e),
|
||||
})
|
||||
logger.error(f"Generate audio failed: {e}")
|
||||
logger.error(f"Generate audio failed: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
async def list_generated_audios(user_id: str) -> dict:
|
||||
@@ -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,6 +14,8 @@ router = APIRouter()
|
||||
@router.get("/stream/{material_id:path}")
|
||||
async def stream_material(material_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""直接流式返回素材文件(同源,避免 CORS canvas taint)"""
|
||||
if ".." in material_id:
|
||||
raise HTTPException(400, "非法素材ID")
|
||||
user_id = current_user["id"]
|
||||
if not material_id.startswith(f"{user_id}/"):
|
||||
raise HTTPException(403, "无权访问此素材")
|
||||
@@ -52,6 +54,8 @@ async def delete_material(material_id: str, current_user: dict = Depends(get_cur
|
||||
try:
|
||||
await service.delete_material(material_id, user_id)
|
||||
return success_response(message="素材已删除")
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except PermissionError as e:
|
||||
raise HTTPException(403, str(e))
|
||||
except Exception as e:
|
||||
|
||||
@@ -7,6 +7,7 @@ import aiofiles
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
from app.core.config import settings as app_settings
|
||||
from app.services.storage import storage_service
|
||||
|
||||
|
||||
@@ -123,6 +124,9 @@ async def upload_material(request, user_id: str) -> dict:
|
||||
async for chunk in request.stream():
|
||||
await f.write(chunk)
|
||||
total_size += len(chunk)
|
||||
max_bytes = app_settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
if total_size > max_bytes:
|
||||
raise ValueError(f"文件大小超过限制 ({app_settings.MAX_UPLOAD_SIZE_MB}MB)")
|
||||
|
||||
if total_size - last_log > 20 * 1024 * 1024:
|
||||
logger.info(f"Receiving stream... Processed {total_size / (1024*1024):.2f} MB")
|
||||
@@ -239,6 +243,8 @@ async def list_materials(user_id: str) -> list[dict]:
|
||||
|
||||
async def delete_material(material_id: str, user_id: str) -> None:
|
||||
"""删除素材"""
|
||||
if ".." in material_id:
|
||||
raise ValueError("非法素材ID")
|
||||
if not material_id.startswith(f"{user_id}/"):
|
||||
raise PermissionError("无权删除此素材")
|
||||
await storage_service.delete_file(
|
||||
@@ -249,6 +255,8 @@ async def delete_material(material_id: str, user_id: str) -> None:
|
||||
|
||||
async def rename_material(material_id: str, new_name_raw: str, user_id: str) -> dict:
|
||||
"""重命名素材,返回更新后的素材信息"""
|
||||
if ".." in material_id:
|
||||
raise ValueError("非法素材ID")
|
||||
if not material_id.startswith(f"{user_id}/"):
|
||||
raise PermissionError("无权重命名此素材")
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ async def upload_ref_audio(file, ref_text: str, user_id: str) -> dict:
|
||||
# 创建临时文件
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_input:
|
||||
content = await file.read()
|
||||
if len(content) > 5 * 1024 * 1024:
|
||||
raise ValueError("参考音频文件大小不能超过 5MB")
|
||||
tmp_input.write(content)
|
||||
tmp_input_path = tmp_input.name
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||
from typing import Optional
|
||||
import traceback
|
||||
from loguru import logger
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.response import success_response
|
||||
from app.modules.tools import service
|
||||
|
||||
@@ -14,7 +15,8 @@ async def extract_script_tool(
|
||||
file: Optional[UploadFile] = File(None),
|
||||
url: Optional[str] = Form(None),
|
||||
rewrite: bool = Form(True),
|
||||
custom_prompt: Optional[str] = Form(None)
|
||||
custom_prompt: Optional[str] = Form(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""独立文案提取工具"""
|
||||
try:
|
||||
@@ -29,5 +31,5 @@ async def extract_script_tool(
|
||||
logger.error(traceback.format_exc())
|
||||
msg = str(e)
|
||||
if "Fresh cookies" in msg:
|
||||
msg = "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。"
|
||||
raise HTTPException(500, f"提取失败: {msg}")
|
||||
raise HTTPException(500, "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。")
|
||||
raise HTTPException(500, "文案提取失败,请稍后重试")
|
||||
|
||||
@@ -41,7 +41,19 @@ async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = T
|
||||
raise ValueError("文件名无效")
|
||||
safe_filename = Path(filename).name.replace(" ", "_")
|
||||
temp_path = temp_dir / f"tool_extract_{timestamp}_{safe_filename}"
|
||||
await loop.run_in_executor(None, lambda: shutil.copyfileobj(file.file, open(temp_path, "wb")))
|
||||
max_bytes = 500 * 1024 * 1024 # 500MB
|
||||
total_written = 0
|
||||
with open(temp_path, "wb") as dst:
|
||||
while True:
|
||||
chunk = file.file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total_written += len(chunk)
|
||||
if total_written > max_bytes:
|
||||
dst.close()
|
||||
os.remove(temp_path)
|
||||
raise ValueError("上传文件大小不能超过 500MB")
|
||||
dst.write(chunk)
|
||||
logger.info(f"Tool processing upload file: {temp_path}")
|
||||
else:
|
||||
temp_path = await _download_video(url, temp_dir, timestamp)
|
||||
@@ -49,6 +61,13 @@ async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = T
|
||||
if not temp_path or not temp_path.exists():
|
||||
raise ValueError("文件获取失败")
|
||||
|
||||
# 下载文件体积检查(500MB 上限)
|
||||
max_download_bytes = 500 * 1024 * 1024
|
||||
file_size = temp_path.stat().st_size
|
||||
if file_size > max_download_bytes:
|
||||
os.remove(temp_path)
|
||||
raise ValueError(f"下载的文件过大({file_size / (1024*1024):.0f}MB),上限 500MB")
|
||||
|
||||
# 1.5 安全转换: 强制转为 WAV (16k)
|
||||
audio_path = temp_dir / f"extract_audio_{timestamp}.wav"
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
@@ -14,7 +15,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,13 +116,57 @@ 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)):
|
||||
if not re.match(r'^[A-Za-z0-9_-]+$', video_id):
|
||||
raise HTTPException(status_code=400, detail="非法 video_id")
|
||||
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)):
|
||||
if not re.match(r'^[A-Za-z0-9_-]+$', video_id):
|
||||
raise HTTPException(status_code=400, detail="非法 video_id")
|
||||
result = await delete_generated_video(current_user["id"], video_id)
|
||||
return success_response(result, message="视频已删除")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function AccountSettingsDropdown() {
|
||||
onClose={closePasswordModal}
|
||||
zIndexClassName="z-[200]"
|
||||
panelClassName="w-full max-w-md rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden"
|
||||
closeOnOverlay={false}
|
||||
closeOnOverlay
|
||||
>
|
||||
<AppModalHeader
|
||||
title="修改密码"
|
||||
|
||||
@@ -489,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"
|
||||
>
|
||||
|
||||
@@ -398,7 +398,7 @@ export function RefAudioPanel({
|
||||
isOpen={recordingModalOpen}
|
||||
onClose={closeRecordingModal}
|
||||
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"
|
||||
closeOnOverlay={false}
|
||||
closeOnOverlay={!isRecording}
|
||||
>
|
||||
<AppModalHeader
|
||||
title="🎤 在线录音"
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function RewriteModal({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
panelClassName="w-full max-w-2xl max-h-[90vh] rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
|
||||
closeOnOverlay={false}
|
||||
closeOnOverlay
|
||||
>
|
||||
<AppModalHeader
|
||||
title="AI 智能改写"
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function ScriptExtractionModal({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
panelClassName="w-full max-w-2xl max-h-[90vh] rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
|
||||
closeOnOverlay={false}
|
||||
closeOnOverlay
|
||||
>
|
||||
<AppModalHeader title="📜 文案提取助手" onClose={onClose} />
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function PublishPage() {
|
||||
isOpen={Boolean(qrPlatform)}
|
||||
onClose={closeQrModal}
|
||||
panelClassName="w-full max-w-md rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden"
|
||||
closeOnOverlay={false}
|
||||
closeOnOverlay
|
||||
>
|
||||
<AppModalHeader
|
||||
title={`🔐 扫码登录 ${qrPlatform}`}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user