Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin Wong
b289006844 更新 2026-03-05 17:23:22 +08:00
Kevin Wong
71b45852bf 更新 2026-03-04 17:35:59 +08:00
Kevin Wong
23ff4ff86e 更新 2026-03-04 14:07:54 +08:00
41 changed files with 3594 additions and 234 deletions

View File

@@ -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 存储(用户隔离)
@@ -139,6 +161,8 @@ backend/user_data/{user_uuid}/cookies/
- 业务逻辑写在 service/workflow。
- 数据库访问写在 repositories。
- 统一使用 `loguru` 打日志。
- GLM SDK 调用统一收口到 `services/glm_service.py`(通过统一入口方法),避免在模块内重复拼装 `chat.completions.create` 调用代码。
- 涉及文案深度学习的抓取调用router 侧应透传 `current_user.id``creator_scraper`,以便复用用户 Cookie 上下文并保持 `analysis_id` 用户隔离。
---

View File

@@ -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,18 @@ backend/
* `PUT /api/generated-audios/{audio_id}`: 重命名配音
9. **工具 (Tools)**
* `POST /api/tools/extract-script`: 从视频链接提取文案
* `POST /api/tools/extract-script`: 从视频链接提取文案(需登录)
* `POST /api/tools/analyze-creator`: 分析博主标题并返回热门话题(需登录)
* `POST /api/tools/generate-topic-script`: 基于选中话题生成文案(需登录)
> 文案深度学习说明:
> - 平台支持:抖音 / B站博主主页链接。
> - 抓取策略:当前统一使用 Playwright 主链路抓取标题(抖音/B站并结合用户登录态 Cookie 上下文增强成功率。
> - `analysis_id` 绑定 `user_id` 且有 TTL默认 20 分钟),用于后续“生成文案”阶段安全读取标题上下文。
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 +136,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
@@ -226,7 +248,7 @@ pip install -r requirements.txt
SUPABASE_URL=http://localhost:8008
SUPABASE_KEY=your_service_role_key
# GLM API (用于 AI 标题生成)
# GLM API (用于 AI 标题/改写/翻译/文案深度学习)
GLM_API_KEY=your_glm_api_key
# LatentSync 配置

View File

@@ -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`(该接口需认证)
---

View File

@@ -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` 格式
- 小红书登录二维码自动切换(短信登录 -> 扫码登录)与提取修复

159
Docs/DevLogs/Day32.md Normal file
View File

@@ -0,0 +1,159 @@
## 视频下载同源修复 + 安全漏洞第一批修复 (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` 与点击遮罩关闭。
- 保留关键流程保护:发布成功清理弹窗继续禁止遮罩关闭,避免误触导致流程中断。
- 说明:按钮位置与视觉样式统一属于 Day33 范畴,本日志仅记录关闭策略统一。
### 调整内容
- 文案提取弹窗(`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后续由 Day33 接棒 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` | 文案提取文件上传替换为限大小分块拷贝500MBURL 下载分支增加下载后体积检查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`

290
Docs/DevLogs/Day33.md Normal file
View File

@@ -0,0 +1,290 @@
## 抖音短链文案提取稳健性修复 (Day 33)
### 概述
今天聚焦修复「文案提取助手」里抖音分享短链/口令文本偶发提取失败的问题,并补齐多种抖音落地 URL 形态的兼容。
---
## ✅ 1) 问题复盘
### 现象
- 复制抖音分享口令文本(含 `v.douyin.com` 短链)时,文案提取偶发失败。
- 直接粘贴地址栏链接(如 `jingxuan?modal_id=...`)时,提取成功。
### 根因
- `backend/app/modules/tools/service.py``_download_douyin_manual` 原先只按 `/video/{id}` 提取视频 ID。
- 短链重定向结果并不总是 `/video/{id}`,常见还包括:
- `/share/video/{id}`
- `/user/...?...&vid={id}`
- `/follow/search?...&modal_id={id}`
- 当落到上述形态时会出现 `Could not extract video_id`,导致 fallback 失败。
---
## ✅ 2) 修复方案
### 2.1 抽取统一解析函数
- 新增 `_extract_douyin_video_id(candidate_url)`,统一解析以下 ID 形态:
- 路径:`/video/{id}``/share/video/{id}`
- Query 参数:`modal_id``vid``video_id``aweme_id``item_id`
- 解码后的整串 URL 兜底正则匹配
### 2.2 fallback 提取链路增强
- `_download_douyin_manual` 改为:
1. 优先从重定向后的 `final_url` 提取 `video_id`
2. 若失败,再从原始输入 `url` 提取 `video_id`
- 保持后续下载链路不变:访问 `m.douyin.com/share/video/{video_id}` 提取 `play_addr` 并下载。
---
## 📁 今日修改文件
| 文件 | 改动 |
|------|------|
| `backend/app/modules/tools/service.py` | 新增 `_extract_douyin_video_id`;增强抖音 fallback 的 `video_id` 提取策略(兼容 `share/video``modal_id``vid` 等) |
| `Docs/DevLogs/Day33.md` | 新增 Day33 开发日志,记录问题、根因、修复与验证 |
---
## 🔍 验证记录
- `python -m py_compile backend/app/modules/tools/service.py`
- URL 解析冒烟(函数级):
- `jingxuan?modal_id=...` 可提取 ✅
- `user?...&vid=...` 可提取 ✅
- `follow/search?...&modal_id=...` 可提取 ✅
- 下载链路冒烟(服务级):
- 用户提供的短链口令文本可成功下载临时视频 ✅
- 历史失败样例 `user?...&vid=...` 可成功走通 fallback 下载 ✅
---
## ✅ 3) 文案深度学习:抖音抓取 Playwright 降级增强
### 3.1 问题复盘
- 在「文案深度学习」博主分析链路里,抖音用户页有时返回 JS 壳页(含 `byted_acrawler`),静态 HTML 提取拿不到 `desc`
- 表现为:短链可解析 `sec_uid`,但标题抓取报错“页面结构可能已变更”。
### 3.2 修复方案
-`backend/app/services/creator_scraper.py` 中新增 Playwright 降级抓取:
1. 保留原 HTTP + `ttwid` 抓取作为首选(轻量、快)。
2. 当 HTTP 提取不到标题时,自动切换 Playwright。
3. 监听页面网络响应,定向捕获:
- `/aweme/v1/web/aweme/post/`
- `/aweme/v1/web/user/profile/other/`
4. 解析响应 JSON 中 `desc` 作为视频标题来源,并提取博主昵称。
- 仅在确实失败时返回更准确提示:
- `抖音触发风控验证,暂时无法抓取标题,请稍后重试`
### 3.3 结果
- 给定短链 `https://v.douyin.com/hmFXdx5PvzQ/` 可稳定识别并完成标题抓取。
- 抓取结果可获得有效博主昵称与约 50 条标题(受平台返回数据影响)。
### 3.4 本次新增/更新文件
| 文件 | 改动 |
|------|------|
| `backend/app/services/creator_scraper.py` | 新增抖音 Playwright 降级抓取、网络响应采集、标题/昵称解析优化、错误提示优化 |
| `Docs/DevLogs/Day33.md` | 增补文案深度学习抖音抓取增强记录 |
### 3.5 验证记录
- `python -m py_compile backend/app/services/creator_scraper.py`
- 冒烟验证:
- 短链重定向 + `sec_uid` 提取 ✅
- HTTP 首选链路失败时自动切换 Playwright ✅
- Playwright 网络响应中抓取到 `aweme/post` 数据并提取标题 ✅
---
## ✅ 4) 文案深度学习功能首版落地
### 4.1 后端实现
- 新增博主抓取服务:`backend/app/services/creator_scraper.py`
- `scrape_creator_titles(url)`:平台识别 + 标题抓取统一入口
- `validate_url(url)``https` 强制、域名白名单、DNS 全记录公网校验、逐跳重定向校验
- `cache_titles(titles, user_id)` / `get_cached_titles(analysis_id, user_id)`20 分钟 TTL + 用户绑定
- GLM 服务扩展:`backend/app/services/glm_service.py`
- `analyze_topics(titles)`从标题归纳热门话题≤10
- `generate_script_from_topic(topic, word_count, titles)`:按话题与风格生成文案
- 工具路由新增接口:`backend/app/modules/tools/router.py`
- `POST /api/tools/analyze-creator`
- `POST /api/tools/generate-topic-script`
- 使用 Pydantic JSON 请求模型 + 登录态校验 + 统一 `success_response`
### 4.2 前端实现
- 新增状态逻辑 Hook`frontend/src/features/home/ui/script-learning/useScriptLearning.ts`
- 流程状态:`input -> analyzing -> topics -> generating -> result`
- 管理分析请求、生成请求、错误态、复制、重新生成
- 新增弹窗组件:`frontend/src/features/home/ui/ScriptLearningModal.tsx`
- 步骤式 UI输入链接、话题单选、字数输入、结果展示、填入文案/复制
- 接入首页交互:
- `frontend/src/features/home/ui/ScriptEditor.tsx`:新增「文案深度学习」按钮
- `frontend/src/features/home/model/useHomeController.ts`:新增 `learningModalOpen` 状态
- `frontend/src/features/home/ui/HomePage.tsx`:挂载弹窗并支持回填主编辑器
### 4.3 交互位置与规则
- 按钮位置已按约定落位:
- `历史文案``文案提取助手``文案深度学习``AI多语言`
- 弹窗遵循当前统一策略:支持遮罩点击关闭(非关键流程弹窗)。
### 4.4 验证记录
- 后端语法检查:
- `python -m py_compile backend/app/services/creator_scraper.py backend/app/services/glm_service.py backend/app/modules/tools/router.py`
- 前端构建:
- `cd frontend && npm run build`
- 抖音短链样例联调:
- `https://v.douyin.com/hmFXdx5PvzQ/` 可解析、可抓取标题(触发降级时可自动走 Playwright
---
## ✅ 5) 抖音 Cookie 依赖澄清与 B站频率限制增强
### 5.1 抖音 Cookie 依赖澄清
- 文案深度学习的抖音抓取**不依赖发布管理页登录 Cookie**。
- 当前链路使用:
- 短链解析 + `sec_uid` 提取
- 公共访问链路(`ttwid` + 页面/接口抓取)
- 必要时 Playwright 降级
- 因此用户即使未登录抖音,也可使用该功能(但仍可能受平台风控影响)。
### 5.2 B站“请求过于频繁”优化
-`backend/app/services/creator_scraper.py` 增强 B站抓取稳健性
- 对频率限制场景增加自动重试(指数退避 + 随机抖动)
- 频率限制识别HTTP 412/429、错误码/错误文案)
- HTTP 链路失败后自动切换 Playwright 降级抓取
- 最终报错文案统一为更可理解的提示
- `mid` 提取兼容根路径与子路径(如 `/upload/video`
### 5.3 验证记录
- B站样例联调`https://space.bilibili.com/8047632` 可抓取 50 条标题 ✅
- 抖音短链复测:`https://v.douyin.com/hmFXdx5PvzQ/` 仍可抓取 50 条标题 ✅
---
## ✅ 6) 抖音 + B站 抓取可靠性二次增强
### 6.1 抖音增强
- `backend/app/services/creator_scraper.py`
- `scrape_creator_titles(..., user_id)` 透传用户 ID支持读取用户已登录平台 Cookie 作为增强上下文。
- 抖音抓取新增可选用户 Cookie 注入HTTP 请求 + Playwright 上下文)。
- Playwright 降级抓取轮次从 4 次提升到 8 次,目标改为尽量补齐 `MAX_TITLES=50`
- 保留网络响应抓取主链路(`aweme/post` + `profile/other`),优先 `desc` 提取标题。
### 6.2 B站增强
- 新增 WBI 签名链路(主链路):
- 获取 `wbi_img` key兼容 `nav` 返回 `-101` 但携带 `wbi_img` 的场景)
- 计算 `w_rid/wts` 后调用 `x/space/wbi/arc/search`
- 多页拉取(分页累加)+ 标题去重,尽量补齐 50 条
- 新增 B站会话预热
- `x/frontend/finger/spi` 获取并注入 `buvid3/buvid4`
- 支持读取用户已登录 B站 Cookie若存在提升命中率
- Playwright 降级增强:
- 监听 `x/space/*/arc/search` 响应并解析有效 payload
- 对捕获的 arc URL 进行 `context.request` 二次回放尝试
### 6.3 路由联动
- `backend/app/modules/tools/router.py`
- `/api/tools/analyze-creator` 调用抓取时传入 `current_user.id`,用于平台 Cookie 增强。
### 6.4 结果说明
- 抖音:短链场景稳定性进一步提升,风控页下优先走 Playwright 降级抓取。
- B站已补齐签名链路与降级链路但在平台强风控窗口仍可能返回“请求过于频繁/风控校验失败”,属于平台侧限制。
---
## ✅ 7) 抓取策略最终调整:抖音/B站改为 Playwright 直连
根据产品决策,将文案深度学习的博主标题抓取策略统一为 **Playwright 直连主链路**不再使用“HTTP 主链路 + Playwright 降级”。
### 7.1 调整内容
- `backend/app/services/creator_scraper.py`
- `_scrape_douyin()` 改为直接调用 `_scrape_douyin_with_playwright()`
- `_scrape_bilibili()` 改为直接调用 `_scrape_bilibili_with_playwright()`
- 两个平台均保留 2 次 Playwright 抓取重试。
- 支持优先读取用户隔离 Cookie若缺失再尝试旧版全局 Cookie。
- `backend/app/modules/tools/router.py`
- `analyze-creator` 继续传入 `current_user.id`,用于匹配用户 Cookie 上下文。
### 7.2 影响评估
- 影响范围仅限「文案深度学习」抓取链路。
- **不影响**视频自动化发布、文案提取助手extract-script现有流程。
### 7.3 验证
- 抖音短链样例:`https://v.douyin.com/hmFXdx5PvzQ/` 抓取成功50 条。
- B站样例
- `https://space.bilibili.com/256237759?spm_id_from=...` 抓取成功40 条。
- `https://space.bilibili.com/1140672573` 抓取成功40 条。
---
## ✅ 8) GLM 调用链统一与超时体验优化
### 8.1 现象
- 文案深度学习“生成文案”偶发前端报错:`timeout of 30000ms exceeded`
### 8.2 原因
- 主要是前端请求超时阈值过短30s在模型排队或长文本生成时容易超时。
- 后端虽然统一走 `glm_service`,但各方法内部仍重复编写 SDK 调用代码,维护成本高。
### 8.3 调整
- 前端:`generate-topic-script` 超时从 30s 提升到 90s并优化超时提示文案。
- 后端:`backend/app/services/glm_service.py`
- 新增 `_call_glm(...)` 作为统一调用入口(统一 model / thinking / to_thread / timeout
- `generate_title_tags / rewrite_script / analyze_topics / generate_script_from_topic / translate_text`
全部改为复用该入口
- 保持 `settings.GLM_MODEL` 单点配置,避免多处散落调用
### 8.4 结果
- GLM 调用标准统一,后续参数调整只需改一处。
- 前端超时报错显著减少;如确实超时会给出可理解提示。
---
## ✅ 9) 三个文案弹窗操作按钮统一
### 9.1 目标
- 统一「文案提取助手」「AI 智能改写」「文案深度学习」结果页操作按钮的位置、样式与主次关系。
### 9.2 调整
- `frontend/src/features/home/ui/ScriptExtractionModal.tsx`
- 结果页按钮从“分散在标题右侧 + 底部单独按钮”改为统一底部 Action Grid。
- 按钮统一为:`填入文案``复制``提取下一个``关闭`
- `frontend/src/features/home/ui/RewriteModal.tsx`
- 结果页按钮改为统一底部 Action Grid。
- 新增复制按钮(含 clipboard fallback
- 按钮统一为:`填入文案``复制``重新生成``保留原文`
- `frontend/src/features/home/ui/ScriptLearningModal.tsx`
- 维持同一 Action Grid 风格:`填入文案``复制``重新生成``换个话题`
### 9.3 验证
- `cd frontend && npm run build`

View File

@@ -39,8 +39,12 @@ frontend/src/
│ │ ├── MaterialSelector.tsx
│ │ ├── ScriptEditor.tsx
│ │ ├── ScriptExtractionModal.tsx
│ │ ├── RewriteModal.tsx
│ │ ├── ScriptLearningModal.tsx
│ │ ├── script-extraction/
│ │ │ └── useScriptExtraction.ts
│ │ ├── script-learning/
│ │ │ └── useScriptLearning.ts
│ │ ├── TitleSubtitlePanel.tsx
│ │ ├── FloatingStylePreview.tsx
│ │ ├── VoiceSelector.tsx
@@ -69,13 +73,15 @@ frontend/src/
│ │ ├── useTitleInput.ts
│ │ └── usePublishPrefetch.ts
│ ├── ui/
│ │ ── SelectPopover.tsx # 统一下拉/BottomSheet 选择器
│ │ ── SelectPopover.tsx # 统一下拉/BottomSheet 选择器
│ │ └── AppModal.tsx # 统一弹窗基座
│ ├── types/
│ │ ├── user.ts # User 类型定义
│ │ └── publish.ts # 发布相关类型
│ └── contexts/ # 全局 ContextAuth、Task
│ └── contexts/ # 全局 ContextAuth、Task、Cleanup
│ ├── AuthContext.tsx
── TaskContext.tsx
── TaskContext.tsx
│ └── CleanupContext.tsx
├── components/ # 遗留通用组件
│ └── VideoPreviewModal.tsx
└── proxy.ts # Next.js middleware路由保护
@@ -212,15 +218,45 @@ 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`,避免局部容器/层叠上下文影响,确保是全页面弹窗
- 统一容器风格:`border-white/10`、深色半透明背景、圆角 `rounded-2xl`、重阴影
- 统一关闭行为:支持 `ESC`;是否允许点击遮罩关闭通过 `closeOnOverlay` 显式配置
- 默认策略:除关键流程外,`closeOnOverlay` 默认应为 `true`,并通过 `AppModalHeader onClose` 提供右上角 `X` 关闭入口
- 关键流程例外:发布成功清理弹窗(`CleanupContext`)必须保持 `closeOnOverlay=false`,且不提供右上角关闭按钮
- 录音弹窗例外:使用 `closeOnOverlay={!isRecording}`,录音中禁止遮罩关闭,避免误触中断
- 统一滚动策略:弹窗打开时锁定背景滚动(`lockBodyScroll`),内容区自行滚动
- 特殊层级场景(例如视频预览压过下拉)使用更高 `z-index`(如 `z-[320]`
### 文案类弹窗结果操作栏规范
适用组件:
- `ScriptExtractionModal`
- `RewriteModal`
- `ScriptLearningModal`
统一要求:
- 结果页操作按钮统一放在内容底部Action Grid避免“标题右上角按钮 + 底部按钮”混排。
- 主按钮统一为高亮渐变(如「填入文案」),其余按钮统一次级样式(`bg-white/10`)。
- 动作文案尽量统一:`填入文案` / `复制` / `重新生成`(或与当前流程等价的返回动作)。
- 按钮尺寸、圆角、间距保持一致(推荐 `py-2.5 px-3 rounded-lg text-sm`)。
---
## 发布后清理弹窗规范 (CleanupContext)
发布页由 `CleanupContext` 统一承接“全部平台发布成功后的清理引导”,规则如下:
- 触发条件:仅当本次发布结果 **全部成功** 才触发弹窗;有任一失败则走原内联结果展示。
- 持久化恢复:`cleanup_pending` 写入 localStorage支持刷新/跳转后恢复;带 `createdAt`24 小时自动过期。
- 清理顺序:必须先调用 `POST /api/videos/cleanup`;仅在接口成功后才清本地输入字段并关闭弹窗。
- 状态同步:清理成功后派发 `vigent:workspace-cleared` 事件当前发布页输入态需就地重置避免“localStorage 已清空但页面仍显示旧值”)。
- 失败处理:接口失败时保留弹窗和输入数据,允许重试;连续失败达到阈值后显示“暂不清理,继续使用”。
- 本地清理范围:仅输入内容(文案/标题/副标题/发布标题/标签不清用户偏好样式、字号、边距、模型、BGM 等)。
- 下载策略:弹窗“下载视频备份”必须使用同源下载接口(`/api/videos/generated/{id}/download`),不要直接使用签名 URL 作为 `href`
---
## API 请求规范
@@ -230,6 +266,7 @@ body {
所有需要认证的 API 请求**必须**使用 `@/shared/api/axios` 导出的 axios 实例。该实例已配置:
- 自动携带 `credentials: include`
- 遇到 401/403 时自动清除 cookie 并跳转登录页
- AI/Tools 接口(如 `/api/ai/*``/api/tools/extract-script``/api/tools/analyze-creator``/api/tools/generate-topic-script`)现为强制鉴权,禁止匿名 `fetch` 直调
**使用方式:**
@@ -391,7 +428,7 @@ useEffect(() => {
- `shared/hooks`:跨功能通用 hooks
- `shared/ui`:跨功能通用 UI如 SelectPopover
- `shared/types`跨功能实体类型User / PublishVideo 等)
- `shared/contexts`:全局 ContextAuthContext / TaskContext
- `shared/contexts`:全局 ContextAuthContext / TaskContext / CleanupContext
- `components/`遗留通用组件VideoPreviewModal
## 类型定义规范
@@ -508,6 +545,7 @@ await api.post('/api/videos/generate', {
- 录音入口放在“我的参考音频”区域底部右侧(与“上传音频”并排)。
- 录音交互使用弹窗:开始/停止 -> 试听 -> 使用此录音 / 弃用本次录音。
- 关闭录音弹窗时如仍在录制,会先停止录音再关闭。
- 录音中禁止点击遮罩关闭(`closeOnOverlay={!isRecording}`);未录音时允许遮罩关闭。
```typescript
// 录音需要用户授权麦克风

View File

@@ -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),统一下拉,选择持久化。
@@ -86,12 +91,12 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **到期续费**: 会员到期后登录自动跳转付费页续费,流程与首次开通一致。
- **管理员激活**: 管理员手动激活功能并存,两种方式互不影响。
### 9. 文案提取助手 (`ScriptExtractionModal`)
- **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)
- **AI 智能改写**: 集成 GLM-4.7-Flash自动改写为口播文案
- **自定义提示词**: 可自定义改写提示词,留空使用默认;设置持久化到 localStorage
- **一键填入**: 提取结果直接填充至视频生成输入框
- **智能交互**: 实时进度展示,防误触设计
### 9. 文案创作助手3 个弹窗)
- **文案提取助手** (`ScriptExtractionModal`): 支持文件上传与 URL 提取(需登录),提取结果可一键填入主编辑器
- **AI 智能改写** (`RewriteModal`): 基于 GLM-4.7-Flash 改写文案,支持自定义提示词持久化
- **文案深度学习** (`ScriptLearningModal`): 输入抖音/B站博主主页分析热门话题并生成口播文案需登录
- **统一结果操作栏**: 三个弹窗结果页统一底部 Action Grid 风格,主按钮为「填入文案」,次按钮统一「复制 / 重新生成(或等价返回操作)」
- **登录鉴权**: 依赖受保护接口(`/api/tools/*``/api/ai/*`),未登录会触发全局 401 跳转登录
## 🛠️ 技术栈
@@ -154,6 +159,8 @@ src/
- 业务选择器统一使用 `SelectPopover`(桌面 Popover / 移动端 BottomSheet`ScriptEditor` 的“历史文案 / AI多语言”保留原轻量菜单。
- 业务弹窗统一使用 `AppModal`(统一遮罩、头部、关闭行为与滚动策略)。
- 弹窗关闭策略:默认支持 `ESC` / `X` / 点击空白关闭;仅发布成功清理弹窗为强制流程(不允许空白关闭,也不显示 `X`)。
- 文案类弹窗结果页按钮统一:底部 Action Grid、主次按钮层级一致、文案动作命名一致填入/复制/重新生成)。
- 视频预览弹窗层级高于下拉菜单;下拉内支持连续预览。
- 页面同时适配桌面端与移动端;长列表统一隐藏滚动条。
- 详细 UI 规范、持久化规范与交互约束请查看 `Docs/FRONTEND_DEV.md`

View File

@@ -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`:平台发布自动化(抖音/微信/小红书基于 PlaywrightB站基于 biliup
### 2.3 发布成功后的清理联动
- 前端 `CleanupContext` 在“本次所选平台全部发布成功”时触发清理弹窗。
- 用户点击清理时先调用 `POST /api/videos/cleanup`,仅接口成功后才清本地输入并关闭弹窗。
- 清理成功后前端派发 `vigent:workspace-cleared` 事件,当前发布页会就地重置标题/标签输入态。
- 接口失败时弹窗保持打开并允许重试;连续失败达到阈值后可“暂不清理,继续使用”。
- 弹窗“下载视频备份”走同源下载接口:`GET /api/videos/generated/{video_id}/download`,确保浏览器直接保存文件而非新标签页播放。
---
## 3. Cookie 与账号隔离

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 31 - 发布登录稳定性修复 + 文档体系补齐)
**更新时间**: 2026-03-03
**进度**: 100% (Day 33 - 文案深度学习落地 + 抓取稳定性增强 + 弹窗操作统一)
**更新时间**: 2026-03-05
---
@@ -10,7 +10,27 @@
> 这里记录了每一天的核心开发内容与 milestone。
### Day 31: 文档体系收敛 + 音色试听 + 录音弹窗重构 + 发布登录稳定性修复 (Current)
### Day 33: 文案深度学习落地 + 抓取稳定性增强 + 交互统一 (Current)
- [x] **文案深度学习功能上线**: 新增 `ScriptLearningModal`(输入主页链接 -> 话题分析 -> 生成文案 -> 填入编辑器)与首页入口接入。
- [x] **Tools 新接口**: 新增 `POST /api/tools/analyze-creator``POST /api/tools/generate-topic-script`,并接入登录鉴权。
- [x] **抖音/B站抓取增强**: 博主标题抓取统一升级为 Playwright 直连主链路,支持用户 Cookie 上下文增强与失败重试。
- [x] **GLM 调用统一收口**: `glm_service` 新增统一调用入口,标题生成/改写/翻译/话题分析/话题文案生成全部复用,减少重复代码。
- [x] **超时体验优化**: 文案深度学习“生成文案”前端超时从 30s 提升到 90s并补充超时提示文案。
- [x] **文案弹窗交互统一**: 文案提取/AI 改写/文案深度学习结果页按钮统一为底部 Action Grid主次按钮层级与文案动作统一。
- [x] **依赖升级**: 后端 venv 升级 `yt-dlp``playwright``biliup` 并完成兼容性冒烟验证。
- [x] **文档同步**: 回写 `Day33``FRONTEND_README``FRONTEND_DEV``BACKEND_README``BACKEND_DEV``TASK_COMPLETE`
### Day 32: 视频下载同源修复 + 安全整改第一批 + Day 日志拆分归档
- [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 +48,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`)当前渲染所需文件到缓存目录。

View File

@@ -28,16 +28,19 @@
- 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置;新作品生成后优先选中最新,后续用户手动选择持续持久化。
- 🎵 **背景音乐** - 试听 + 搜索选择 + 混音(当前前端固定混音系数,保持配音音量稳定)。
- 🧩 **统一选择器交互** - 首页/发布页业务选择项统一 SelectPopover桌面 Popover / 移动端 BottomSheet支持自动上拉、已选定位与连续预览。
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 智能改写(支持自定义提示词)、标题/标签自动生成、9 语言翻译。
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 智能改写(支持自定义提示词)、文案深度学习(博主话题分析+文案生成)、标题/标签自动生成、9 语言翻译。
### 平台化功能
- 📱 **全自动发布** - 支持抖音/微信视频号/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 阻塞调用线程池化。
---

View File

@@ -2,7 +2,7 @@
# 复制此文件为 .env 并填入实际值
# 调试模式
DEBUG=true
DEBUG=false
# Redis 配置 (Celery 任务队列)
REDIS_URL=redis://localhost:6379/0

View File

@@ -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():
"""

View File

@@ -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="改写服务暂时不可用,请稍后重试")

View File

@@ -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("无权删除此文件")

View File

@@ -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:

View File

@@ -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("无权重命名此素材")

View File

@@ -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

View File

@@ -1,20 +1,54 @@
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from typing import Optional
from urllib.parse import urlparse
import traceback
from loguru import logger
from pydantic import BaseModel, Field, field_validator
from app.core.deps import get_current_user
from app.core.response import success_response
from app.modules.tools import service
from app.services import creator_scraper
from app.services.creator_scraper import ALLOWED_INPUT_DOMAINS
from app.services.glm_service import glm_service
router = APIRouter()
class AnalyzeCreatorRequest(BaseModel):
url: str = Field(..., description="博主主页链接(仅支持抖音/B站 https 链接)")
@field_validator("url")
@classmethod
def validate_url_format(cls, value: str) -> str:
candidate = value.strip()
if len(candidate) > 500:
raise ValueError("链接过长")
parsed = urlparse(candidate)
if parsed.scheme != "https":
raise ValueError("仅支持 https 链接")
hostname = (parsed.hostname or "").lower()
if hostname not in ALLOWED_INPUT_DOMAINS:
raise ValueError(f"不支持的域名: {hostname}仅支持抖音和B站")
return candidate
class GenerateTopicScriptRequest(BaseModel):
analysis_id: str = Field(..., min_length=8, max_length=80, description="分析结果ID")
topic: str = Field(..., min_length=2, max_length=30, description="选中的话题2-30字")
word_count: int = Field(..., ge=80, le=1000, description="目标字数80-1000")
@router.post("/extract-script")
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 +63,64 @@ 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, "文案提取失败,请稍后重试")
@router.post("/analyze-creator")
async def analyze_creator(
req: AnalyzeCreatorRequest,
current_user: dict = Depends(get_current_user),
):
"""分析博主内容并返回热门话题"""
try:
user_id = str(current_user.get("id") or "").strip()
if not user_id:
raise HTTPException(401, "登录状态无效,请重新登录")
creator_result = await creator_scraper.scrape_creator_titles(req.url, user_id=user_id)
titles = creator_result.get("titles") or []
topics = await glm_service.analyze_topics(titles)
analysis_id = creator_scraper.cache_titles(titles, user_id)
return success_response({
"platform": creator_result.get("platform", ""),
"creator_name": creator_result.get("creator_name", ""),
"topics": topics,
"analysis_id": analysis_id,
"fetched_count": creator_result.get("fetched_count", len(titles)),
})
except ValueError as e:
raise HTTPException(400, str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Analyze creator failed: {e}")
logger.error(traceback.format_exc())
raise HTTPException(500, "博主内容分析失败,请稍后重试")
@router.post("/generate-topic-script")
async def generate_topic_script(
req: GenerateTopicScriptRequest,
current_user: dict = Depends(get_current_user),
):
"""根据话题生成文案"""
try:
user_id = str(current_user.get("id") or "").strip()
if not user_id:
raise HTTPException(401, "登录状态无效,请重新登录")
titles = creator_scraper.get_cached_titles(req.analysis_id, user_id)
script = await glm_service.generate_script_from_topic(req.topic, req.word_count, titles)
return success_response({"script": script})
except ValueError as e:
raise HTTPException(400, str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Generate topic script failed: {e}")
logger.error(traceback.format_exc())
raise HTTPException(500, "文案生成失败,请稍后重试")

View File

@@ -8,7 +8,7 @@ import subprocess
import traceback
from pathlib import Path
from typing import Optional, Any
from urllib.parse import unquote
from urllib.parse import unquote, parse_qs, urlparse
import httpx
from loguru import logger
@@ -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:
@@ -193,10 +212,9 @@ async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> O
logger.info(f"[douyin-fallback] Final URL: {final_url}")
video_id = None
match = re.search(r'/video/(\d+)', final_url)
if match:
video_id = match.group(1)
video_id = _extract_douyin_video_id(final_url)
if not video_id:
video_id = _extract_douyin_video_id(url)
if not video_id:
logger.error("[douyin-fallback] Could not extract video_id")
@@ -217,7 +235,8 @@ async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> O
"cbUrlProtocol": "https", "union": True,
}
)
ttwid = ttwid_resp.cookies.get("ttwid", "")
fresh_ttwid = ttwid_resp.cookies.get("ttwid")
ttwid = str(fresh_ttwid) if fresh_ttwid else ""
logger.info(f"[douyin-fallback] Got fresh ttwid (len={len(ttwid)})")
except Exception as e:
logger.warning(f"[douyin-fallback] Failed to get ttwid: {e}")
@@ -277,6 +296,39 @@ async def _download_douyin_manual(url: str, temp_dir: Path, timestamp: int) -> O
return None
def _extract_douyin_video_id(candidate_url: str) -> Optional[str]:
"""从抖音 URL 中提取视频 ID兼容 video/share/video/modal_id/vid 等形态"""
if not candidate_url:
return None
decoded_url = unquote(candidate_url)
parsed = urlparse(decoded_url)
for source in (decoded_url, parsed.path):
for pattern in (r"/video/(\d+)", r"/share/video/(\d+)"):
match = re.search(pattern, source)
if match:
return match.group(1)
id_keys = ("modal_id", "vid", "video_id", "aweme_id", "item_id")
for pairs in (parse_qs(parsed.query), parse_qs(parsed.fragment)):
for key in id_keys:
values = pairs.get(key, [])
for value in values:
match = re.search(r"(\d+)", value)
if match:
return match.group(1)
inline_match = re.search(
r"(?:[?&#](?:modal_id|vid|video_id|aweme_id|item_id)=)(\d+)",
decoded_url,
)
if inline_match:
return inline_match.group(1)
return None
async def _download_bilibili_manual(url: str, temp_dir: Path, timestamp: int) -> Optional[Path]:
"""手动下载 Bilibili 视频 (Playwright Fallback)"""
from playwright.async_api import async_playwright

View File

@@ -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="视频已删除")

View File

@@ -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:

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,10 @@ GLM AI 服务
使用智谱 GLM 生成标题和标签
"""
import asyncio
import json
import re
from typing import Any, Optional, cast
from loguru import logger
from zai import ZhipuAiClient
@@ -25,6 +27,48 @@ class GLMService:
self.client = ZhipuAiClient(api_key=settings.GLM_API_KEY)
return self.client
async def _call_glm(
self,
*,
prompt: str,
max_tokens: int,
temperature: float,
action: str,
timeout_seconds: float = 85.0,
) -> str:
"""统一 GLM 调用入口,避免重复调用代码"""
client = self._get_client()
logger.info(
f"{action} | model={settings.GLM_MODEL} | max_tokens={max_tokens} | temperature={temperature}"
)
try:
response = await asyncio.wait_for(
asyncio.to_thread(
client.chat.completions.create,
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"},
max_tokens=max_tokens,
temperature=temperature,
),
timeout=timeout_seconds,
)
except asyncio.TimeoutError as exc:
raise Exception("GLM 请求超时,请稍后重试") from exc
completion = cast(Any, response)
choices = getattr(completion, "choices", None)
if not choices:
raise Exception("AI 返回内容为空")
message = getattr(choices[0], "message", None)
content = getattr(message, "content", "")
text = content.strip() if isinstance(content, str) else str(content or "").strip()
if not text:
raise Exception("AI 返回内容为空")
return text
async def generate_title_tags(self, text: str) -> dict:
"""
根据口播文案生成标题和标签
@@ -50,22 +94,13 @@ class GLMService:
{{"title": "标题", "secondary_title": "副标题", "tags": ["标签1", "标签2", "标签3"]}}"""
try:
client = self._get_client()
logger.info(f"Calling GLM API with model: {settings.GLM_MODEL}")
# 使用 asyncio.to_thread 包装同步 SDK 调用,避免阻塞事件循环
import asyncio
response = await asyncio.to_thread(
client.chat.completions.create,
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"}, # 禁用思考模式,加快响应
content = await self._call_glm(
prompt=prompt,
max_tokens=500,
temperature=0.7
temperature=0.7,
action="生成标题与标签",
timeout_seconds=75.0,
)
# 提取生成的内容
content = response.choices[0].message.content
logger.info(f"GLM response (model: {settings.GLM_MODEL}): {content}")
# 解析 JSON
@@ -76,7 +111,7 @@ class GLMService:
logger.error(f"GLM service error: {e}")
raise Exception(f"AI 生成失败: {str(e)}")
async def rewrite_script(self, text: str, custom_prompt: str = None) -> str:
async def rewrite_script(self, text: str, custom_prompt: Optional[str] = None) -> str:
"""
AI 改写文案
@@ -105,28 +140,126 @@ class GLMService:
4. 不要返回多余的解释,只返回改写后的正文"""
try:
client = self._get_client()
logger.info(f"Using GLM to rewrite script")
# 使用 asyncio.to_thread 包装同步 SDK 调用,避免阻塞事件循环
import asyncio
response = await asyncio.to_thread(
client.chat.completions.create,
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"},
content = await self._call_glm(
prompt=prompt,
max_tokens=2000,
temperature=0.8
temperature=0.8,
action="改写文案",
timeout_seconds=85.0,
)
content = response.choices[0].message.content
logger.info("GLM rewrite completed")
return content.strip()
return content
except Exception as e:
logger.error(f"GLM rewrite error: {e}")
raise Exception(f"AI 改写失败: {str(e)}")
async def analyze_topics(self, titles: list[str]) -> list[str]:
"""
分析视频标题列表并归纳热门话题(最多 10 个)
"""
cleaned_titles = [str(title).strip() for title in titles if str(title).strip()]
if not cleaned_titles:
raise Exception("标题列表为空")
limited_titles = cleaned_titles[:50]
titles_text = "\n".join(f"{idx + 1}. {title}" for idx, title in enumerate(limited_titles))
prompt = f"""以下是某短视频博主最近发布的视频标题列表:
{titles_text}
请分析这些标题,归纳总结出该博主内容中最热门的话题方向。
要求:
1. 提取不超过10个话题方向
2. 每个话题用简短短语描述(建议 5-15 字)
3. 按热门程度排序(出现频率高的在前)
4. 只返回话题列表,每行一个,不要编号、解释或多余内容"""
try:
content = await self._call_glm(
prompt=prompt,
max_tokens=500,
temperature=0.5,
action="分析博主话题",
timeout_seconds=85.0,
)
topics = self._parse_topic_lines(content)
if not topics:
raise Exception("未识别到有效话题")
logger.info(f"GLM topic analysis completed: {len(topics)} topics")
return topics[:10]
except Exception as e:
logger.error(f"GLM topic analysis error: {e}")
raise Exception(f"话题分析失败: {str(e)}")
async def generate_script_from_topic(self, topic: str, word_count: int, titles: list[str]) -> str:
"""
根据选中话题与博主标题风格生成文案
"""
topic_value = str(topic or "").strip()
if not topic_value:
raise Exception("话题不能为空")
cleaned_titles = [str(title).strip() for title in titles if str(title).strip()]
if not cleaned_titles:
raise Exception("参考标题为空")
word_count_value = max(80, min(int(word_count), 1000))
sample_titles = "\n".join(f"{idx + 1}. {title}" for idx, title in enumerate(cleaned_titles[:10]))
prompt = f"""请围绕「{topic_value}」这个话题,生成一段短视频口播文案。
参考该博主的标题风格:
{sample_titles}
要求:
1. 文案字数约 {word_count_value}
2. 适合短视频口播,语气自然、有吸引力
3. 开头要有钩子吸引观众
4. 只返回文案正文,不要标题和其他说明"""
try:
content = await self._call_glm(
prompt=prompt,
max_tokens=min(word_count_value * 3, 4000),
temperature=0.8,
action=f"按话题生成文案(topic={topic_value})",
timeout_seconds=88.0,
)
logger.info("GLM topic script generation completed")
return content
except Exception as e:
logger.error(f"GLM topic script generation error: {e}")
raise Exception(f"文案生成失败: {str(e)}")
def _parse_topic_lines(self, content: str) -> list[str]:
lines = [line.strip() for line in str(content or "").splitlines()]
topics: list[str] = []
seen: set[str] = set()
for line in lines:
if not line:
continue
cleaned = re.sub(r"^\s*(?:[-*•]+|\d+[.)、\s]+)", "", line).strip()
cleaned = cleaned.strip('"“”')
if not cleaned:
continue
if cleaned in seen:
continue
seen.add(cleaned)
topics.append(cleaned)
if len(topics) >= 10:
break
return topics
async def translate_text(self, text: str, target_lang: str) -> str:
@@ -151,22 +284,15 @@ class GLMService:
3. 翻译要自然流畅,符合目标语言的表达习惯"""
try:
client = self._get_client()
logger.info(f"Using GLM to translate text to {target_lang}")
import asyncio
response = await asyncio.to_thread(
client.chat.completions.create,
model=settings.GLM_MODEL,
messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"},
content = await self._call_glm(
prompt=prompt,
max_tokens=2000,
temperature=0.3
temperature=0.3,
action=f"翻译文案(target={target_lang})",
timeout_seconds=75.0,
)
content = response.choices[0].message.content
logger.info("GLM translation completed")
return content.strip()
return content
except Exception as e:
logger.error(f"GLM translate error: {e}")

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -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

View File

@@ -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="修改密码"

View File

@@ -287,6 +287,9 @@ export const useHomeController = () => {
// 文案提取模态框
const [extractModalOpen, setExtractModalOpen] = useState(false);
// 文案深度学习模态框
const [learningModalOpen, setLearningModalOpen] = useState(false);
// AI 改写模态框
const [rewriteModalOpen, setRewriteModalOpen] = useState(false);
@@ -1123,6 +1126,8 @@ export const useHomeController = () => {
setText,
extractModalOpen,
setExtractModalOpen,
learningModalOpen,
setLearningModalOpen,
rewriteModalOpen,
setRewriteModalOpen,
handleGenerateMeta,

View File

@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react";
import VideoPreviewModal from "@/components/VideoPreviewModal";
import ScriptExtractionModal from "./ScriptExtractionModal";
import ScriptLearningModal from "./ScriptLearningModal";
import RewriteModal from "./RewriteModal";
import { useHomeController } from "@/features/home/model/useHomeController";
import { resolveMediaUrl } from "@/shared/lib/media";
@@ -53,6 +54,8 @@ export function HomePage() {
setText,
extractModalOpen,
setExtractModalOpen,
learningModalOpen,
setLearningModalOpen,
rewriteModalOpen,
setRewriteModalOpen,
handleGenerateMeta,
@@ -222,6 +225,7 @@ export function HomePage() {
text={text}
onChangeText={setText}
onOpenExtractModal={() => setExtractModalOpen(true)}
onOpenLearningModal={() => setLearningModalOpen(true)}
onOpenRewriteModal={() => setRewriteModalOpen(true)}
onTranslate={handleTranslate}
isTranslating={isTranslating}
@@ -489,6 +493,7 @@ export function HomePage() {
currentTask={null}
isGenerating={false}
generatedVideo={generatedVideo}
generatedVideoId={selectedVideoId}
/>
</div>
</div>
@@ -513,6 +518,12 @@ export function HomePage() {
onApply={(newText) => setText(newText)}
/>
<ScriptLearningModal
isOpen={learningModalOpen}
onClose={() => setLearningModalOpen(false)}
onApply={(nextText) => setText(nextText)}
/>
<ClipTrimmer
isOpen={clipTrimmerOpen}
segment={clipTrimmerSegment}

View File

@@ -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"
>

View File

@@ -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="🎤 在线录音"

View File

@@ -3,6 +3,7 @@ import { Loader2, Sparkles } from "lucide-react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
import { toast } from "sonner";
const CUSTOM_PROMPT_KEY = "vigent_rewriteCustomPrompt";
@@ -78,10 +79,52 @@ export default function RewriteModal({
onClose();
};
const handleRetry = () => {
setRewrittenText("");
setError(null);
};
const handleRetry = () => {
setRewrittenText("");
setError(null);
};
const fallbackCopyTextToClipboard = useCallback((text: string) => {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand("copy");
if (successful) {
toast.success("已复制到剪贴板");
} else {
toast.error("复制失败,请手动复制");
}
} catch {
toast.error("复制失败,请手动复制");
}
document.body.removeChild(textArea);
}, []);
const handleCopy = useCallback((text: string) => {
if (!text.trim()) return;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => {
toast.success("已复制到剪贴板");
})
.catch(() => {
fallbackCopyTextToClipboard(text);
});
} else {
fallbackCopyTextToClipboard(text);
}
}, [fallbackCopyTextToClipboard]);
if (!isOpen) return null;
@@ -90,7 +133,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 智能改写"
@@ -143,56 +186,63 @@ export default function RewriteModal({
</div>
)}
{/* Rewritten result */}
{rewrittenText && (
<>
<div className="space-y-2">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
<Sparkles className="h-4 w-4" />
AI
</h4>
<button
onClick={handleApply}
className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors shadow-sm"
>
使
</button>
</div>
<div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto hide-scrollbar">
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
{rewrittenText}
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-gray-400 flex items-center gap-2">
📝
</h4>
<button
onClick={onClose}
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors"
>
</button>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto hide-scrollbar">
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
{originalText}
</p>
</div>
</div>
<button
onClick={handleRetry}
className="w-full py-2.5 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
>
</button>
</>
)}
{/* Rewritten result */}
{rewrittenText && (
<>
<div className="space-y-2">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
<Sparkles className="h-4 w-4" />
AI
</h4>
<span className="text-xs text-gray-400">{rewrittenText.length} </span>
</div>
<div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto hide-scrollbar">
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
{rewrittenText}
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="font-semibold text-gray-400 flex items-center gap-2">
📝
</h4>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto hide-scrollbar">
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
{originalText}
</p>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<button
onClick={handleApply}
className="py-2.5 px-3 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white rounded-lg transition-colors text-sm"
>
</button>
<button
onClick={() => handleCopy(rewrittenText)}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
<button
onClick={handleRetry}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
<button
onClick={onClose}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
</div>
</>
)}
</div>
</AppModal>
);

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FileText, History, Languages, Loader2, Maximize2, RotateCcw, Save, Sparkles, Trash2 } from "lucide-react";
import { FileText, GraduationCap, 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";
@@ -19,6 +19,7 @@ interface ScriptEditorProps {
text: string;
onChangeText: (value: string) => void;
onOpenExtractModal: () => void;
onOpenLearningModal: () => void;
onOpenRewriteModal: () => void;
onTranslate: (targetLang: string) => void;
isTranslating: boolean;
@@ -34,6 +35,7 @@ export function ScriptEditor({
text,
onChangeText,
onOpenExtractModal,
onOpenLearningModal,
onOpenRewriteModal,
onTranslate,
isTranslating,
@@ -146,6 +148,13 @@ export function ScriptEditor({
<FileText className="h-3.5 w-3.5" />
</button>
<button
onClick={onOpenLearningModal}
className={`${actionBtnBase} bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 text-white`}
>
<GraduationCap className="h-3.5 w-3.5" />
</button>
<div className="relative" ref={langMenuRef}>
<button
onClick={() => setShowLangMenu((prev) => !prev)}

View File

@@ -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} />
@@ -228,43 +228,47 @@ export default function ScriptExtractionModal({
)}
{step === "result" && (
<div className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-gray-300 flex items-center gap-2">
🎙
</h4>
<div className="flex items-center gap-2">
{onApply && (
<button
onClick={() => handleApplyAndClose(script)}
className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1 shadow-sm"
>
📥
</button>
)}
<button
onClick={() => copyToClipboard(script)}
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors"
>
</button>
</div>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-60 overflow-y-auto hide-scrollbar">
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
{script}
</p>
</div>
<div className="space-y-5">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-gray-300 flex items-center gap-2">
🎙
</h4>
<span className="text-xs text-gray-400">{script.length} </span>
</div>
<div className="flex justify-center pt-4">
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-72 overflow-y-auto hide-scrollbar">
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
{script}
</p>
</div>
<div className={`grid ${onApply ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3"} gap-2`}>
{onApply && (
<button
onClick={() => handleApplyAndClose(script)}
className="py-2.5 px-3 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white rounded-lg transition-colors text-sm"
>
</button>
)}
<button
onClick={() => copyToClipboard(script)}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
<button
onClick={handleExtractNext}
className="px-6 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
<button
onClick={onClose}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
</div>
</div>
)}

View File

@@ -0,0 +1,241 @@
"use client";
import { BookOpen, Sparkles } from "lucide-react";
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
import { useScriptLearning } from "./script-learning/useScriptLearning";
interface ScriptLearningModalProps {
isOpen: boolean;
onClose: () => void;
onApply?: (text: string) => void;
}
const WORD_COUNT_MIN = 80;
const WORD_COUNT_MAX = 1000;
export default function ScriptLearningModal({ isOpen, onClose, onApply }: ScriptLearningModalProps) {
const {
step,
inputUrl,
setInputUrl,
topics,
selectedTopic,
setSelectedTopic,
wordCount,
setWordCount,
generatedScript,
error,
analysisId,
handleAnalyze,
handleGenerate,
handleRegenerate,
backToInput,
backToTopics,
copyToClipboard,
} = useScriptLearning({ isOpen });
if (!isOpen) return null;
const wordCountNum = Number(wordCount);
const wordCountValid = Number.isInteger(wordCountNum)
&& wordCountNum >= WORD_COUNT_MIN
&& wordCountNum <= WORD_COUNT_MAX;
const canGenerate = !!analysisId && !!selectedTopic && wordCountValid;
const handleApplyAndClose = () => {
if (!generatedScript.trim()) return;
onApply?.(generatedScript);
onClose();
};
return (
<AppModal
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
>
<AppModalHeader
title="文案深度学习"
icon={<BookOpen className="h-5 w-5 text-cyan-300" />}
subtitle="分析博主近期选题风格并快速生成文案"
onClose={onClose}
/>
<div className="flex-1 overflow-y-auto p-6">
{step === "input" && (
<div className="space-y-5">
<div className="space-y-2">
<label className="text-sm text-gray-300"></label>
<input
type="text"
value={inputUrl}
onChange={(event) => setInputUrl(event.target.value)}
placeholder="请粘贴抖音或B站博主主页链接..."
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-cyan-500 transition-colors"
/>
<p className="text-xs text-gray-500"> https 使</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
>
</button>
<button
type="button"
onClick={() => void handleAnalyze()}
disabled={!inputUrl.trim()}
className="flex-1 py-3 px-4 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition-all font-medium shadow-lg"
>
</button>
</div>
</div>
)}
{(step === "analyzing" || step === "generating") && (
<div className="flex flex-col items-center justify-center py-20">
<div className="relative w-20 h-20 mb-6">
<div className="absolute inset-0 border-4 border-cyan-500/30 rounded-full" />
<div className="absolute inset-0 border-4 border-t-cyan-500 rounded-full animate-spin" />
</div>
<h4 className="text-xl font-medium text-white mb-2">
{step === "analyzing" ? "正在分析中..." : "正在生成中..."}
</h4>
</div>
)}
{step === "topics" && (
<div className="space-y-5">
<div className="bg-cyan-500/10 border border-cyan-500/30 rounded-xl p-3">
<p className="text-cyan-200 text-sm"></p>
</div>
<div className="space-y-2">
<p className="text-sm text-gray-300"></p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{topics.map((topic) => {
const active = selectedTopic === topic;
return (
<button
key={topic}
type="button"
onClick={() => setSelectedTopic(topic)}
className={`text-left rounded-lg border px-3 py-2.5 text-sm transition-colors ${
active
? "border-cyan-400 bg-cyan-500/20 text-cyan-100"
: "border-white/10 bg-white/5 text-gray-200 hover:border-white/20 hover:bg-white/10"
}`}
>
{topic}
</button>
);
})}
</div>
</div>
<div className="space-y-2">
<label className="text-sm text-gray-300"></label>
<input
type="number"
min={WORD_COUNT_MIN}
max={WORD_COUNT_MAX}
value={wordCount}
onChange={(event) => setWordCount(event.target.value)}
placeholder="请输入目标字数80-1000如 300"
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-cyan-500 transition-colors"
/>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={backToInput}
className="flex-1 py-3 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
>
</button>
<button
type="button"
onClick={() => void handleGenerate()}
disabled={!canGenerate}
className="flex-1 py-3 px-4 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition-all font-medium shadow-lg"
>
</button>
</div>
</div>
)}
{step === "result" && (
<div className="space-y-5">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-cyan-200 flex items-center gap-2">
<Sparkles className="h-4 w-4" />
</h4>
<span className="text-xs text-gray-400">{generatedScript.length} </span>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-72 overflow-y-auto hide-scrollbar">
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">{generatedScript}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<button
type="button"
onClick={handleApplyAndClose}
className="py-2.5 px-3 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 text-white rounded-lg transition-colors text-sm"
>
</button>
<button
type="button"
onClick={() => copyToClipboard(generatedScript)}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
<button
type="button"
onClick={() => void handleRegenerate()}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
<button
type="button"
onClick={backToTopics}
className="py-2.5 px-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm"
>
</button>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
</div>
)}
</div>
</AppModal>
);
}

View File

@@ -0,0 +1,239 @@
import { useCallback, useEffect, useState } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
export type ScriptLearningStep = "input" | "analyzing" | "topics" | "generating" | "result";
const WORD_COUNT_MIN = 80;
const WORD_COUNT_MAX = 1000;
const DEFAULT_WORD_COUNT = "300";
interface UseScriptLearningOptions {
isOpen: boolean;
}
interface AnalyzeCreatorPayload {
topics: string[];
analysis_id: string;
fetched_count: number;
}
interface GenerateTopicScriptPayload {
script: string;
}
export const useScriptLearning = ({ isOpen }: UseScriptLearningOptions) => {
const [step, setStep] = useState<ScriptLearningStep>("input");
const [inputUrl, setInputUrl] = useState("");
const [topics, setTopics] = useState<string[]>([]);
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
const [wordCount, setWordCount] = useState(DEFAULT_WORD_COUNT);
const [generatedScript, setGeneratedScript] = useState("");
const [error, setError] = useState<string | null>(null);
const [analysisId, setAnalysisId] = useState<string | null>(null);
const [fetchedCount, setFetchedCount] = useState(0);
const resetAll = useCallback(() => {
setStep("input");
setInputUrl("");
setTopics([]);
setSelectedTopic(null);
setWordCount(DEFAULT_WORD_COUNT);
setGeneratedScript("");
setError(null);
setAnalysisId(null);
setFetchedCount(0);
}, []);
useEffect(() => {
if (isOpen) {
resetAll();
}
}, [isOpen, resetAll]);
const parseWordCount = useCallback((value: string): number | null => {
const num = Number(value);
if (!Number.isInteger(num)) {
return null;
}
if (num < WORD_COUNT_MIN || num > WORD_COUNT_MAX) {
return null;
}
return num;
}, []);
const handleAnalyze = useCallback(async () => {
const urlValue = inputUrl.trim();
if (!urlValue) {
setError("请先输入博主主页链接");
return;
}
setError(null);
setStep("analyzing");
try {
const { data: res } = await api.post<ApiResponse<AnalyzeCreatorPayload>>(
"/api/tools/analyze-creator",
{ url: urlValue },
{ timeout: 60000 }
);
const payload = unwrap(res);
const topicList = payload.topics || [];
if (topicList.length === 0) {
throw new Error("未识别到可用话题,请更换链接重试");
}
setTopics(topicList);
setSelectedTopic(topicList[0]);
setAnalysisId(payload.analysis_id || null);
setFetchedCount(payload.fetched_count || 0);
setGeneratedScript("");
setStep("topics");
} catch (err: unknown) {
const axiosErr = err as {
response?: { data?: { message?: string } };
message?: string;
};
const msg = axiosErr.response?.data?.message || axiosErr.message || "分析失败,请稍后重试";
setError(msg);
setStep("input");
}
}, [inputUrl]);
const handleGenerate = useCallback(async () => {
if (!analysisId) {
setError("分析结果已失效,请重新分析");
setStep("input");
return;
}
if (!selectedTopic) {
setError("请先选择一个话题");
return;
}
const count = parseWordCount(wordCount.trim());
if (count === null) {
setError(`目标字数需在 ${WORD_COUNT_MIN}-${WORD_COUNT_MAX} 之间`);
return;
}
setError(null);
setStep("generating");
try {
const { data: res } = await api.post<ApiResponse<GenerateTopicScriptPayload>>(
"/api/tools/generate-topic-script",
{
analysis_id: analysisId,
topic: selectedTopic,
word_count: count,
},
{ timeout: 90000 }
);
const payload = unwrap(res);
const script = (payload.script || "").trim();
if (!script) {
throw new Error("生成内容为空,请重试");
}
setGeneratedScript(script);
setStep("result");
} catch (err: unknown) {
const axiosErr = err as {
response?: { data?: { message?: string } };
message?: string;
code?: string;
};
let msg = axiosErr.response?.data?.message || axiosErr.message || "生成失败,请稍后重试";
if (axiosErr.code === "ECONNABORTED" || /timeout/i.test(axiosErr.message || "")) {
msg = "生成超时,请稍后重试(可适当减少目标字数)";
}
setError(msg);
setStep("topics");
}
}, [analysisId, parseWordCount, selectedTopic, wordCount]);
const handleRegenerate = useCallback(async () => {
await handleGenerate();
}, [handleGenerate]);
const backToInput = useCallback(() => {
setError(null);
setStep("input");
}, []);
const backToTopics = useCallback(() => {
setError(null);
setStep("topics");
}, []);
const fallbackCopyTextToClipboard = useCallback((text: string) => {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand("copy");
if (successful) {
toast.success("已复制到剪贴板");
} else {
toast.error("复制失败,请手动复制");
}
} catch {
toast.error("复制失败,请手动复制");
}
document.body.removeChild(textArea);
}, []);
const copyToClipboard = useCallback(
(text: string) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => {
toast.success("已复制到剪贴板");
})
.catch(() => {
fallbackCopyTextToClipboard(text);
});
} else {
fallbackCopyTextToClipboard(text);
}
},
[fallbackCopyTextToClipboard]
);
return {
step,
inputUrl,
setInputUrl,
topics,
selectedTopic,
setSelectedTopic,
wordCount,
setWordCount,
generatedScript,
error,
analysisId,
fetchedCount,
handleAnalyze,
handleGenerate,
handleRegenerate,
backToInput,
backToTopics,
resetAll,
copyToClipboard,
};
};

View File

@@ -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);
}

View File

@@ -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}`}

View 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" />
&amp;
</>
)}
</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);
}