Compare commits

..

2 Commits

Author SHA1 Message Date
Kevin Wong
23ff4ff86e 更新 2026-03-04 14:07:54 +08:00
Kevin Wong
091f78174e 更新 2026-03-03 15:16:38 +08:00
23 changed files with 990 additions and 144 deletions

View File

@@ -86,13 +86,23 @@ backend/
- `custom_assignments` 每项使用 `material_path/start/end/source_start/source_end?`,并以时间轴可见段为准。
- `output_aspect_ratio` 仅允许 `9:16` / `16:9`,默认 `9:16`
- 标题显示模式参数:
- `title_display_mode`: `short` / `persistent`(默认 `short`
- `title_display_mode`: `short` / `persistent`(默认 `short`,对主标题与副标题统一生效
- `title_duration`: 默认 `4.0`(秒),仅 `short` 模式生效
- 片头副标题参数:
- `secondary_title`: 副标题文字(可选,限 20 字),仅在视频画面中显示,不参与发布标题
- `secondary_title_style_id` / `secondary_title_font_size` / `secondary_title_top_margin`: 副标题样式配置
- workflow/remotion 侧需保持字段透传一致,避免前后端语义漂移。
### `/api/videos/cleanup` 行为约定
- 仅清理当前用户在 Storage 中的生成产物:
- `outputs` bucket生成视频
- `generated-audios` bucket预生成配音 `.wav/.json`
- 清理接口采用严格成功语义:
- 全部删除成功才返回 success
- 任一删除失败返回错误,前端应保留清理弹窗并允许重试
- 下载接口约定:`GET /api/videos/generated/{video_id}/download` 必须返回 `Content-Disposition: attachment`,用于前端一键下载,避免浏览器改为在线播放。
---
## 4. 认证与权限
@@ -115,6 +125,8 @@ backend/
- 所有文件上传/下载/删除/移动通过 `services/storage.py`
- 需要重命名时使用 `move_file`,避免直接读写 Storage。
- `delete_file` 必须向上抛出异常,不允许静默吞错(避免清理接口出现“假成功”)。
- `list_files` 默认容错返回空列表;清理等强一致场景应使用 `strict=True`
### Cookie 存储(用户隔离)

View File

@@ -65,11 +65,15 @@ backend/
2. **视频生成 (Videos)**
* `POST /api/videos/generate`: 提交生成任务
* `GET/POST /api/videos/voice-preview`: 生成音色试听短音频(返回二进制音频流)
* `POST /api/videos/cleanup`: 清理当前用户工作区生成产物outputs + generated-audios
* `GET /api/videos/tasks/{task_id}`: 查询单个任务状态
* `GET /api/videos/tasks`: 获取用户所有任务列表
* `GET /api/videos/generated`: 获取历史视频列表
* `GET /api/videos/generated/{video_id}/download`: 下载历史视频(`Content-Disposition: attachment`
* `DELETE /api/videos/generated/{video_id}`: 删除历史视频
> `POST /api/videos/cleanup` 采用严格成功语义:仅当目标文件删除全部成功时返回 success存在删除失败会返回错误并提示重试。
3. **素材管理 (Materials)**
* `POST /api/materials`: 上传素材
* `GET /api/materials`: 获取素材列表
@@ -156,7 +160,7 @@ backend/
- `advanced`: 强制 LatentSync
- `language`: TTS 语言区域(默认 `zh-CN`;会映射为 Whisper 的 `zh/en/...` 与 CosyVoice 的 `Chinese/English/Auto`
- `title`: 片头标题文字
- `title_display_mode`: 标题显示模式(`short` / `persistent`,默认 `short`
- `title_display_mode`: 标题显示模式(`short` / `persistent`,默认 `short`;该模式对主标题与副标题统一生效
- `title_duration`: 标题显示时长(秒,默认 `4.0``short` 模式生效)
- `subtitle_style_id`: 字幕样式 ID
- `title_style_id`: 标题样式 ID

View File

@@ -312,40 +312,149 @@ async def preview_voice_get(voice: str, current_user: dict = Depends(get_current
---
## ✅ 11) 首页「AI生成标题标签」按钮位置优化迁移到四、标题与字幕
### 设计结论
-`AI生成标题标签` 从「一、文案提取与编辑」迁移到「四、标题与字幕」
- 标题区改为两行:
- 第一行:`四、标题与字幕` 标题 + 右侧 `AI生成标题标签`
- 第二行:右对齐放置 `标题短暂显示/常驻显示` + `预览样式`
- 显示语义补充:`标题短暂显示/常驻显示` 对主标题与副标题统一生效(常驻=主/副标题都常驻)
- 不额外增加提示文案,保持界面简洁
- `AI生成标题标签` 外观对齐 `在线录音` 按钮的圆角与尺寸(`rounded-lg` + 同级按钮尺寸),颜色保留原蓝色渐变
### 结果
- 标题相关动作集中到同一板块,避免用户在「一」和「四」之间来回跳转
- 行内层级更明确AI 动作在标题同层,配置项与预览在下一行
- AI 按钮圆角与尺寸更柔和,配色仍保持原蓝色渐变,视觉更统一
### 验证
- `npm run build`frontend
- `pm2 restart vigent2-frontend`
---
## ✅ 12) 文案编辑框右下角扩展角标(弹出大编辑器)
### 设计与实现
- 在「一、文案提取与编辑」主输入框右下角新增角标按钮(点击后打开扩展编辑器)
- 扩展编辑器使用 `AppModal`,提供更大编辑空间(高约 `66vh`
- 主输入框与弹窗内输入框共享同一份 `text` 状态,双向实时同步
- 为避免角标遮挡正文,主输入框增加右下内边距(`pr-6 pb-6`
- 角标样式进一步极简化:仅保留双箭头图标,去掉外框容器并贴近输入框边缘
- 角标位置微调为更协调的“上移+右移”:`right-0.5 bottom-2`,并固定点击区域 `h-5 w-5`
- 修复扩展编辑输入焦点丢失:`AppModal` 改为使用 `onCloseRef` 处理 ESC避免父组件重渲染时 effect 误清理导致 textarea 失焦
- 移除扩展编辑输入框紫色聚焦边框,改为中性边框高亮(`focus:border-white/25`
### 验证
- `npm run build`frontend
- `pm2 restart vigent2-frontend`
---
## ✅ 13) 站点 Icon 替换(使用 `Temp/video.png`
### 变更
- 将提供的 `Temp/video.png` 转换并替换为站点图标资源
- 新增 `frontend/src/app/icon.png`Next App Router icon 资源)
- 更新 `frontend/src/app/favicon.ico`16/32/48/64 多尺寸)
### 验证
- `npm run build`frontend
- 构建产物包含 `/icon.png` 路由 ✅
- `pm2 restart vigent2-frontend`
---
## ✅ 14) 发布后工作区清理链路加固CleanupContext + `/api/videos/cleanup`
### 14.1 功能落地
- 发布页新增“全平台发布成功后清理引导”链路:
- 全平台成功:触发 `CleanupModal`
- 任一平台失败:保持原内联结果展示
- `CleanupModal` 支持展示:成功平台列表、成功截图、下载视频备份、一键清理
- 清理状态 `cleanup_pending` 持久化到 localStorage刷新/跳转后可恢复
### 14.2 稳定性与防锁死优化
- 后端删除能力改为“异常上抛”,避免静默吞错导致前端误判清理成功
- 清理接口改为严格成功语义:
- 视频和配音删除都成功才返回 success
- 任一删除失败直接返回错误,前端保留弹窗并允许重试
- 前端清理动作改为“先后端、后本地”:
- 后端失败:不清本地、不关弹窗
- 后端成功:再清理本地输入字段并关闭弹窗
- 后端成功清理后前端派发 `vigent:workspace-cleared` 事件,发布页就地重置标题/标签输入态(无需手动刷新)
- 连续失败达到阈值3 次)后显示“暂不清理,继续使用”,避免异常环境下永久阻塞
- 清理弹窗增加 24h 过期,避免跨天残留状态
- 用户切换/登出时重置 cleanup 状态,避免旧账号状态串扰
### 14.3 清理范围口径
- 仅清理输入内容字段:
- 首页:文案/标题/副标题
- 发布页:标题/标签
- 保留用户偏好字段样式、字号、边距、模型、BGM 等)
### 验证
- `python -m py_compile backend/app/services/storage.py backend/app/modules/videos/service.py backend/app/modules/generated_audios/service.py backend/app/modules/videos/router.py`
- `npm run build`frontend
- `pm2 restart vigent2-backend && pm2 restart vigent2-frontend`
- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}`
---
## 📁 今日主要修改文件
| 文件 | 改动 |
|------|------|
| `backend/app/modules/videos/router.py` | 新增/增强 `voice-preview` GET+POST试听文本 locale 路由,临时文件清理 |
| `backend/app/modules/videos/router.py` | 新增/增强 `voice-preview` GET+POST试听文本 locale 路由,临时文件清理;新增 `POST /api/videos/cleanup` 严格成功语义 |
| `backend/app/modules/videos/service.py` | 新增批量删除生成视频能力;返回 `(deleted, failed)` 供 cleanup 路由判定 |
| `backend/app/modules/generated_audios/service.py` | 新增批量删除预生成配音能力;返回 `(deleted, failed)` 供 cleanup 路由判定 |
| `backend/app/services/storage.py` | `delete_file()` 改为异常上抛,避免删除失败静默吞错造成“假成功” |
| `backend/app/modules/videos/schemas.py` | 新增 `VoicePreviewRequest` |
| `frontend/src/features/home/ui/VoiceSelector.tsx` | 音色下拉增加试听按钮,改为 GET 音频流播放 |
| `frontend/src/features/home/model/useHomeController.ts` | 录音状态重置、`discardRecording` |
| `frontend/src/features/home/ui/HomePage.tsx` | 透传录音弃用动作 |
| `frontend/src/features/home/ui/HomePage.tsx` | 透传录音弃用动作;将 `AI生成标题标签` 事件改为传入 `TitleSubtitlePanel` |
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 上传/录音入口重排;录音改弹窗;使用/弃用流程 |
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 文案编辑区按钮视觉统一(含 AI智能改写/保存文案) |
| `frontend/src/features/home/ui/ScriptEditor.tsx` | 文案编辑区按钮视觉统一;移除 `AI生成标题标签`(职责回归标题板块);新增输入框右下角扩展角标与大编辑弹窗;角标改为双箭头极简贴边样式并微调到 `right-0.5 bottom-2`;输入框去除紫色聚焦边框 |
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 标题区改为“首行标题+AI、次行右对齐设置+预览”AI按钮外观对齐在线录音按钮软圆角 |
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 录音完成试听条改为自定义深色播放器(替换原生白色控制条) |
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 使用录音后弹窗立即关闭,上传识别后台进行(提升交互流畅度) |
| `frontend/src/features/publish/model/usePublishController.ts` | 发布改为受限并发(并发度=2,缩短多平台发布总耗时 |
| `frontend/src/features/publish/model/usePublishController.ts` | 发布改为受限并发(并发度=2;全平台发布成功时触发 `triggerCleanup()`,失败保持内联结果;监听 `workspace-cleared` 事件就地清空发布输入态 |
| `frontend/src/shared/contexts/CleanupContext.tsx` | 新增发布后清理弹窗与持久化状态;失败不关闭/不清本地、3 次失败可跳过、24h 过期、用户切换复位;清理范围收敛为输入内容字段;成功清理后派发 `workspace-cleared` 事件 |
| `frontend/src/app/layout.tsx` | 在 `TaskProvider` 内挂载 `CleanupProvider`,确保全局可触发发布后清理弹窗 |
| `backend/app/core/config.py` | 新增小红书 Playwright 配置headless/UA/locale/timezone/chrome/debug |
| `backend/app/services/uploader/xiaohongshu_uploader.py` | 按抖音/微信模式重构补充上传启动容错窗口、无后缀文件兜底hardlink/copy、后缀一致性校验、空转超时保护与上传诊断日志 |
| `backend/app/services/publish_service.py` | `save_cookie_string` 非 bilibili 统一存储为 Playwright `storage_state`;小红书 uploader 透传 `user_id` |
| `backend/app/services/qr_login_service.py` | 抖音导航超时容错 + 微信二维码提取增强 + 小红书登录自动切换到扫码模式并提取二维码 |
| `backend/app/services/uploader/weixin_uploader.py` | `file_input empty` 告警策略优化:先检测上传信号,非最后一次重试降级为 info |
| `frontend/src/shared/ui/AppModal.tsx` | 统一弹窗组件 + 无障碍语义 + 焦点管理 + 多弹窗滚动锁计数 |
| `frontend/src/shared/ui/AppModal.tsx` | 统一弹窗组件 + 无障碍语义 + 焦点管理 + 多弹窗滚动锁计数;新增 `onCloseRef` 避免回调引用变化引发的意外失焦 |
| `frontend/src/components/VideoPreviewModal.tsx` | 迁移到 `AppModal` |
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | 迁移到 `AppModal` |
| `frontend/src/features/home/ui/RewriteModal.tsx` | 迁移到 `AppModal` |
| `frontend/src/features/home/ui/ClipTrimmer.tsx` | 迁移到 `AppModal` |
| `frontend/src/components/AccountSettingsDropdown.tsx` | 修改密码弹窗迁移到 `AppModal` |
| `frontend/src/app/icon.png` | 新增站点 icon 资源(来自 `Temp/video.png` |
| `frontend/src/app/favicon.ico` | 替换站点 favicon`video.png` 转换为多尺寸 ico |
| `frontend/src/features/publish/ui/PublishPage.tsx` | 扫码登录QR弹窗迁移到 `AppModal` + 二维码白底留白容器优化(避免边缘观感被裁) |
| `Docs/FRONTEND_DEV.md` | 新增统一弹窗规范AppModal和录音交互规范 |
| `Docs/FRONTEND_README.md` | 增补录音入口与弹窗交互说明 |
| `Docs/BACKEND_README.md` | 增补 `voice-preview` 接口说明;更新发布 API 路径(`/login/{platform}` 等)并链接发布专项文档 |
| `Docs/BACKEND_DEV.md` | 更新后端规范中的发布器覆盖范围与小红书配置项;补充发布专项文档指引 |
| `Docs/PUBLISH_DEPLOY.md` | 新增多平台发布专项文档(登录实现、自动化发布流程、部署要点与排障) |
| `Docs/FRONTEND_DEV.md` | 新增统一弹窗规范AppModal和录音交互规范;补充文案扩展编辑也统一走 AppModal新增 CleanupContext 清理策略规范 |
| `Docs/FRONTEND_README.md` | 增补录音入口与弹窗交互说明;明确“标题常驻显示”对主/副标题同时生效;补充文案输入框扩展编辑器说明;补充发布后清理弹窗失败兜底说明 |
| `Docs/BACKEND_README.md` | 增补 `voice-preview` 接口说明;更新发布 API 路径(`/login/{platform}` 等)并链接发布专项文档;补充 `title_display_mode` 对主/副标题统一生效说明;新增 `/api/videos/cleanup` 接口说明 |
| `Docs/BACKEND_DEV.md` | 更新后端规范中的发布器覆盖范围与小红书配置项;补充发布专项文档指引;补充 `title_display_mode` 主/副标题统一生效约定;新增 cleanup 严格成功语义约定 |
| `Docs/PUBLISH_DEPLOY.md` | 新增多平台发布专项文档(登录实现、自动化发布流程、部署要点与排障);补充“发布成功后清理联动”说明 |
| `Docs/DEPLOY_MANUAL.md` | 部署参数与扫码说明补充小红书要点;新增发布专项文档入口 |
| `README.md` | 文档中心新增 `PUBLISH_DEPLOY.md` 入口;发布结果可视化描述补齐小红书 |
| `Docs/TASK_COMPLETE.md` | 新增 Day31 任务汇总,更新 Current 标签与更新时间 |
| `README.md` | 文档中心新增 `PUBLISH_DEPLOY.md` 入口;发布结果可视化描述补齐小红书;补充发布成功后工作区清理引导说明 |
| `Docs/TASK_COMPLETE.md` | 新增 Day31 任务汇总,更新 Current 标签与更新时间;补充发布后清理链路加固条目 |
| `Docs/DOC_RULES.md` | 增补“发布相关三检”(路由真值/专项文档/入口回写)、敏感信息处理规范,更新工具规范为 `Read/Grep/apply_patch`,并对齐 TASK_COMPLETE 检查清单 |
| `Docs/SUBTITLE_DEPLOY.md` | 与当前阈值/参数说明对齐 |
| `Docs/LATENTSYNC_DEPLOY.md` | 与当前阈值/参数说明对齐 |
@@ -368,6 +477,12 @@ async def preview_voice_get(voice: str, current_user: dict = Depends(get_current
- 小红书发布实测:`POST /api/publish` 返回 `200``Duration: 45.77s`)且成功截图接口返回 `200`
- 新增 `Docs/PUBLISH_DEPLOY.md`(抖音/微信/B站/小红书登录与发布实现说明)✅
- `npm run build`frontend
- 站点 icon 替换后构建通过,产物包含 `/icon.png` 路由 ✅
- `pm2 restart vigent2-frontend`icon 替换后)✅
- `python -m py_compile backend/app/services/storage.py backend/app/modules/videos/service.py backend/app/modules/generated_audios/service.py backend/app/modules/videos/router.py`cleanup 链路加固后)✅
- `npm run build`CleanupContext 优化后)✅
- `pm2 restart vigent2-backend && pm2 restart vigent2-frontend`cleanup 链路加固后)✅
- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}`cleanup 链路加固后)✅
- `POST /api/publish/login/weixin` 冒烟返回 `success=true` + `qr_code`
- `npx eslint` 定向检查以下文件通过:
- `VoiceSelector.tsx`
@@ -395,9 +510,16 @@ async def preview_voice_get(voice: str, current_user: dict = Depends(get_current
- 小红书“上传阶段卡住”二次定位与加固(文件名后缀一致性 + 空转超时)并完成实测发布成功
- 形成发布专项文档 `Docs/PUBLISH_DEPLOY.md`,沉淀四平台登录与自动化发布实现
- 回写 `Docs/BACKEND_README.md` / `Docs/BACKEND_DEV.md` / `Docs/DEPLOY_MANUAL.md`,统一发布 API 与部署说明口径
- 回写 `Docs/FRONTEND_README.md` / `Docs/FRONTEND_DEV.md` / `Docs/PUBLISH_DEPLOY.md`,补齐发布后清理弹窗与 cleanup 接口联动说明
- 回写 `README.md`,补充发布专项文档入口与小红书发布成功截图能力描述
- 回写 `Docs/TASK_COMPLETE.md`,补齐 Day31 任务完成记录
- 回写 `Docs/DOC_RULES.md`,同步文档更新规则到当前文档结构与工具链
- 首页「AI生成标题标签」按钮迁移到「四、标题与字幕」并固定标题同层最右显示方式与预览下沉到下一行右侧
- 文案输入框右下角新增扩展角标,支持弹出大编辑器进行长文案编辑
- 站点 icon 已替换为 `Temp/video.png` 对应资源(`app/icon.png` + `app/favicon.ico`
- 发布后工作区清理链路落地CleanupModal + `/api/videos/cleanup`)并补齐失败兜底(失败不关弹窗、不清本地)
- 清理链路防锁死优化3 次失败可跳过、24h 过期、用户切换复位
- 文档补充:`标题短暂显示/常驻显示` 对主标题与副标题统一生效(常驻=主/副标题全程显示)
- 非 bilibili 平台 cookie 保存为 `storage_state` 格式
- 小红书登录二维码自动切换(短信登录 -> 扫码登录)与提取修复
- 对应构建/重启/冒烟验证记录

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

@@ -0,0 +1,71 @@
## 视频下载同源修复 + Day 日志拆分归档 (Day 32)
### 概述
今天主要处理“下载行为不符合预期”的问题:
1. 修复首页与发布成功弹窗点击下载时被浏览器当作在线播放(新开标签页)的问题。
2. 将下载修复开始后的开发内容从 `Day31` 拆分到 `Day32`,保持日志按天清晰归档。
---
## ✅ 1) 视频下载链路修复(避免新开标签页播放)
### 问题现象
- 首页“下载视频”与发布成功弹窗“下载视频备份”在部分浏览器会打开新标签页播放视频,而不是直接触发下载。
- 根因是跨域签名 URL 场景下,浏览器可能忽略 `<a download>`
### 修复方案
- 后端新增同源下载接口:`GET /api/videos/generated/{video_id}/download`
- 使用 `FileResponse` 返回本地视频文件
- 显式返回 `Content-Disposition: attachment`
- 浏览器直接进入保存文件流程
- 发布成功弹窗下载改为传 `videoId`,不再依赖签名 URL。
- 首页作品预览下载同步改为同源下载接口,下载行为与发布弹窗统一。
- 兼容旧清理状态:`CleanupContext` 对旧 `videoDownloadUrl` 持久化字段做 `videoId` 解析回填。
---
## ✅ 2) 配套调整与文档拆分
### 前端联动
- `CleanupContext` 继续沿用“清理失败不关弹窗、不清本地”的逻辑,下载链路仅替换为同源接口。
- 首页 `PreviewPanel` 支持传入 `generatedVideoId`,下载按钮优先走 `/api/videos/generated/{id}/download`
### 日志归档
- 将“下载修复开始后的内容”从 `Day31` 移出并归档到 `Day32`
- `Day31` 保留 Day31 当日核心内容(到 cleanup 链路加固为止)。
---
## 📁 今日主要修改文件
| 文件 | 改动 |
|------|------|
| `backend/app/modules/videos/router.py` | 新增 `GET /api/videos/generated/{video_id}/download`,返回 `attachment` 下载响应 |
| `frontend/src/features/publish/model/usePublishController.ts` | 发布成功后 `triggerCleanup()``video.id`(替换签名 URL |
| `frontend/src/shared/contexts/CleanupContext.tsx` | 下载字段改为 `videoId`;兼容旧 `videoDownloadUrl` 回填;下载按钮改同源路径 |
| `frontend/src/features/home/ui/PreviewPanel.tsx` | 首页下载改为同源下载接口 |
| `frontend/src/features/home/ui/HomePage.tsx` | 透传 `generatedVideoId``PreviewPanel` |
| `Docs/DevLogs/Day31.md` | 移除下载修复章节与对应验证/覆盖项(迁入 Day32 |
| `Docs/TASK_COMPLETE.md` | 新增 Day32 Current 区块Day31 取消 Current |
| `Docs/BACKEND_README.md` | 补充 `/api/videos/generated/{video_id}/download` 接口说明 |
| `Docs/BACKEND_DEV.md` | 补充下载接口 `attachment` 约定 |
| `Docs/FRONTEND_README.md` | 补充首页/发布弹窗下载统一同源接口说明 |
| `Docs/FRONTEND_DEV.md` | 补充 CleanupContext 下载策略规范 |
| `Docs/PUBLISH_DEPLOY.md` | 补充发布成功后同源下载联动说明 |
| `README.md` | 补充“一键下载直达(同源 attachment”能力描述 |
---
## 🔍 验证记录
- `python -m py_compile backend/app/modules/videos/router.py`
- `npm run build`frontend
- `pm2 restart vigent2-frontend`
- `pm2 restart vigent2-backend`
- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}`

View File

@@ -73,9 +73,10 @@ frontend/src/
│ ├── 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,7 +213,7 @@ body {
## 统一弹窗规范 (AppModal)
所有居中弹窗如视频预览、文案提取、AI 改写、录音、密码修改)统一使用 `@/shared/ui/AppModal` + `AppModalHeader`
所有居中弹窗如视频预览、文案提取、AI 改写、文案扩展编辑、录音、密码修改)统一使用 `@/shared/ui/AppModal` + `AppModalHeader`
- 统一遮罩与层级:`fixed inset-0` + `bg-black/80` + `backdrop-blur-sm` + 明确 `z-index`
- 统一挂载位置:通过 Portal 挂载到 `document.body`,避免局部容器/层叠上下文影响,确保是全页面弹窗
@@ -223,6 +224,20 @@ body {
---
## 发布后清理弹窗规范 (CleanupContext)
发布页由 `CleanupContext` 统一承接“全部平台发布成功后的清理引导”,规则如下:
- 触发条件:仅当本次发布结果 **全部成功** 才触发弹窗;有任一失败则走原内联结果展示。
- 持久化恢复:`cleanup_pending` 写入 localStorage支持刷新/跳转后恢复;带 `createdAt`24 小时自动过期。
- 清理顺序:必须先调用 `POST /api/videos/cleanup`;仅在接口成功后才清本地输入字段并关闭弹窗。
- 状态同步:清理成功后派发 `vigent:workspace-cleared` 事件当前发布页输入态需就地重置避免“localStorage 已清空但页面仍显示旧值”)。
- 失败处理:接口失败时保留弹窗和输入数据,允许重试;连续失败达到阈值后显示“暂不清理,继续使用”。
- 本地清理范围:仅输入内容(文案/标题/副标题/发布标题/标签不清用户偏好样式、字号、边距、模型、BGM 等)。
- 下载策略:弹窗“下载视频备份”必须使用同源下载接口(`/api/videos/generated/{id}/download`),不要直接使用签名 URL 作为 `href`
---
## API 请求规范
### 必须使用 `api` (axios 实例)
@@ -391,7 +406,7 @@ useEffect(() => {
- `shared/hooks`:跨功能通用 hooks
- `shared/ui`:跨功能通用 UI如 SelectPopover
- `shared/types`跨功能实体类型User / PublishVideo 等)
- `shared/contexts`:全局 ContextAuthContext / TaskContext
- `shared/contexts`:全局 ContextAuthContext / TaskContext / CleanupContext
- `components/`遗留通用组件VideoPreviewModal
## 类型定义规范

View File

@@ -11,7 +11,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
## ✨ 核心功能
### 1. 视频生成 (`/`)
- **一、文案提取与编辑**: 文案输入/提取/翻译/保存。
- **一、文案提取与编辑**: 文案输入/提取/翻译/保存;输入框右下角支持一键扩展到大编辑器
- **二、配音**: 配音方式EdgeTTS/声音克隆)+ 配音列表(生成/试听/管理)合并为一个板块。
- **三、素材编辑**: 视频素材(上传/选择/管理)+ 时间轴编辑(波形/色块/拖拽排序)合并为一个板块。
- **四、标题与字幕**: 片头标题/副标题/字幕样式配置;短暂显示/常驻显示;样式预览使用视频片头帧作为真实背景。
@@ -19,6 +19,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **六、作品**(右栏): 作品列表 + 作品预览合并为一个板块。
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
- **下载直达**: 首页作品下载与发布成功弹窗下载统一走同源下载接口(`/api/videos/generated/{id}/download`),避免新标签页在线播放。
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复。
- **历史文案**: 手动保存/加载/删除历史文案,独立 localStorage 持久化。
@@ -37,6 +38,9 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **选择持久化**: 使用稳定 `video.id` 持久化选择,刷新保持;新视频生成自动选中最新。
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
- **发布方式**: 仅支持 "立即发布"。
- **发布成功清理弹窗**: 全平台发布成功后触发 `CleanupModal`(展示成功平台、截图、下载备份、清理按钮),刷新/跳转后可恢复。
- **清理失败兜底**: 清理接口失败时弹窗不关闭且不清本地输入;连续失败达到阈值后可“暂不清理,继续使用”。
- **清理范围**: 仅清理输入内容字段(文案/标题/副标题/发布标题/标签),保留样式、字号、边距、模型等用户偏好。
### 3. 声音克隆
- **TTS 模式选择**: EdgeTTS / 声音克隆切换,音色选择统一下拉(显示音色名 + 语言)。
@@ -60,7 +64,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。
### 5. 字幕与标题
- **片头标题**: 可选输入,限制 15 字;支持”短暂显示 / 常驻显示”默认短暂显示4 秒),对标题副标题同时生效
- **片头标题**: 可选输入,限制 15 字;支持”短暂显示 / 常驻显示”默认短暂显示4 秒)`常驻显示` 时主标题副标题都会全程显示
- **片头副标题**: 可选输入,限制 20 字;显示在主标题下方,用于补充说明或悬念引导;独立样式配置(字体/字号/颜色/间距),可由 AI 同时生成;与标题共享显示模式设定;仅在视频画面中显示,不参与发布标题。
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
- **逐字高亮字幕**: 卡拉OK效果默认开启。

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 32 - 视频下载同源修复 + 清理链路体验收敛)
**更新时间**: 2026-03-04
---
@@ -10,7 +10,14 @@
> 这里记录了每一天的核心开发内容与 milestone。
### Day 31: 文档体系收敛 + 音色试听 + 录音弹窗重构 + 发布登录稳定性修复 (Current)
### Day 32: 视频下载同源修复 + Day 日志拆分归档 (Current)
- [x] **下载链路修复**: 新增 `GET /api/videos/generated/{video_id}/download`,统一返回 `Content-Disposition: attachment`,修复“点击下载却新开标签页播放”问题。
- [x] **发布成功弹窗下载改造**: `CleanupContext` 从传 URL 改为传 `videoId`,下载按钮改走同源接口,去掉 `target="_blank"`
- [x] **首页下载改造**: `PreviewPanel` 同步切换到同源下载接口,首页与发布页下载行为一致。
- [x] **兼容旧持久化状态**: `CleanupContext` 对旧 `videoDownloadUrl``videoId` 解析回填,避免旧 pending 状态失效。
- [x] **文档拆分归档**: 将“下载修复开始后的今日内容”归档到 `Docs/DevLogs/Day32.md`,并从 `Day31.md` 移除对应章节与验证记录。
### Day 31: 文档体系收敛 + 音色试听 + 录音弹窗重构 + 发布登录稳定性修复
- [x] **文档体系收敛**: README/DEV 职责边界明确部署参数与代码对齐Qwen3-TTS 文档归档至历史状态。
- [x] **音色试听能力**: 新增并启用 `GET/POST /api/videos/voice-preview`,前端改为直接播放 GET 音频流,修复线上 404重启后端生效
- [x] **录音交互重构**: 录音入口迁移到参考音频区底部,流程改为弹窗;支持录音后即时关闭弹窗、后台上传识别。
@@ -26,6 +33,10 @@
- [x] **实测闭环**: 小红书 `POST /api/publish` 实测成功45.77s)并可访问成功截图接口。
- [x] **文档补齐**: 新增 `Docs/PUBLISH_DEPLOY.md`,并回写 `README.md``BACKEND_README.md``BACKEND_DEV.md``DEPLOY_MANUAL.md`
- [x] **文档规则对齐**: 更新 `Docs/DOC_RULES.md`,补充发布相关“三检”与敏感信息处理规范,加入 `PUBLISH_DEPLOY.md` 检查项,工具规范改为 `Read/Grep/apply_patch`,并对齐 TASK_COMPLETE 检查清单。
- [x] **首页交互微调**: `AI生成标题标签` 按钮迁移到“四、标题与字幕”标题同层最右;`标题显示方式 + 预览样式` 下沉到下一行右侧AI按钮圆角/尺寸对齐“在线录音”,配色保留原蓝色渐变;文档明确 `title_display_mode` 对主/副标题统一生效。
- [x] **文案编辑扩展**: 在文案输入框右下角新增扩展角标,点击后弹出大编辑器,主框与弹窗内文案实时同步;角标样式改为双箭头极简贴边并微调到 `right-0.5 bottom-2`;修复扩展输入框打字后失焦问题,移除紫色聚焦边框。
- [x] **站点图标更新**: 使用 `Temp/video.png` 替换网站 icon生成并更新 `frontend/src/app/icon.png` 与多尺寸 `frontend/src/app/favicon.ico`
- [x] **发布后清理链路加固**: 新增/优化 `CleanupContext` + `/api/videos/cleanup` 全链路;后端删除异常不再吞错、清理接口严格成功语义;前端失败不清本地/不关弹窗3 次失败可暂不清理,清理状态 24h 过期并支持用户切换复位;清理范围收敛为输入内容字段并保留用户偏好。
### Day 30: Remotion 缓存修复 + 编码流水线质量优化 + 唇形同步容错 + 统一下拉交互
- [x] **Remotion 缓存 404 修复**: bundle 缓存命中时,新生成的视频/字体文件不在旧缓存 `public/` 目录 → 404 → 回退 FFmpeg无标题字幕。改为硬链接`fs.linkSync`)当前渲染所需文件到缓存目录。

View File

@@ -32,9 +32,11 @@
### 平台化功能
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
- 📸 **发布结果可视化** - 抖音/微信视频号/小红书发布成功后返回截图,发布页结果卡片可直接查看。
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认
- 🧹 **发布后工作区清理引导** - 全平台发布成功后弹出不可误关清理弹窗(失败可重试,达到阈值可暂不清理),仅清输入内容并保留用户偏好
- ⬇️ **一键下载直达** - 首页与发布成功弹窗下载统一走同源 `attachment` 接口,不再新开标签页播放视频。
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。

View File

@@ -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,7 +14,9 @@ from app.services.tts_service import TTSService
from .schemas import GenerateRequest, VoicePreviewRequest
from .task_store import create_task, get_task, list_tasks
from .workflow import process_video_generation, get_lipsync_health, get_voiceclone_health
from .service import list_generated_videos, delete_generated_video
from .service import list_generated_videos, delete_generated_video, delete_all_generated_videos
from app.modules.generated_audios.service import delete_all_generated_audios
from app.services.storage import storage_service
router = APIRouter()
@@ -113,11 +115,51 @@ async def voiceclone_health():
return success_response(await get_voiceclone_health())
@router.post("/cleanup")
async def cleanup_workspace(current_user: dict = Depends(get_current_user)):
user_id = current_user["id"]
videos_deleted, videos_failed = await delete_all_generated_videos(user_id)
audios_deleted, audios_failed = await delete_all_generated_audios(user_id)
if videos_failed > 0 or audios_failed > 0:
raise HTTPException(
status_code=500,
detail=(
f"工作区清理不完整:视频删除失败 {videos_failed} 个,"
f"配音删除失败 {audios_failed} 个,请重试"
),
)
return success_response({
"videos_deleted": videos_deleted,
"audios_deleted": audios_deleted,
}, message="工作区已清理")
@router.get("/generated")
async def list_generated(current_user: dict = Depends(get_current_user)):
return success_response(await list_generated_videos(current_user["id"]))
@router.get("/generated/{video_id}/download")
async def download_generated(video_id: str, current_user: dict = Depends(get_current_user)):
user_id = current_user["id"]
storage_path = f"{user_id}/{video_id}.mp4"
local_path = storage_service.get_local_file_path(
bucket=storage_service.BUCKET_OUTPUTS,
path=storage_path,
)
if not local_path or not os.path.exists(local_path):
raise HTTPException(status_code=404, detail="视频文件不存在")
return FileResponse(
path=local_path,
media_type="video/mp4",
filename=f"{video_id}.mp4",
headers={"Content-Disposition": f'attachment; filename="{video_id}.mp4"'},
)
@router.delete("/generated/{video_id}")
async def delete_generated(video_id: str, current_user: dict = Depends(get_current_user)):
result = await delete_generated_video(current_user["id"], video_id)

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:

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

@@ -223,8 +223,6 @@ export function HomePage() {
onChangeText={setText}
onOpenExtractModal={() => setExtractModalOpen(true)}
onOpenRewriteModal={() => setRewriteModalOpen(true)}
onGenerateMeta={handleGenerateMeta}
isGeneratingMeta={isGeneratingMeta}
onTranslate={handleTranslate}
isTranslating={isTranslating}
hasOriginalText={originalText !== null}
@@ -362,6 +360,9 @@ export function HomePage() {
<TitleSubtitlePanel
showStylePreview={showStylePreview}
onTogglePreview={() => setShowStylePreview((prev) => !prev)}
onGenerateMeta={handleGenerateMeta}
isGeneratingMeta={isGeneratingMeta}
canGenerateMeta={!!text.trim()}
videoTitle={videoTitle}
onTitleChange={titleInput.handleChange}
onTitleCompositionStart={titleInput.handleCompositionStart}
@@ -488,6 +489,7 @@ export function HomePage() {
currentTask={null}
isGenerating={false}
generatedVideo={generatedVideo}
generatedVideoId={selectedVideoId}
/>
</div>
</div>

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

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { FileText, History, Languages, Loader2, RotateCcw, Save, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { FileText, History, Languages, Loader2, Maximize2, RotateCcw, Save, Sparkles, Trash2 } from "lucide-react";
import type { SavedScript } from "@/features/home/model/useSavedScripts";
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
const LANGUAGES = [
{ code: "English", label: "英语 English" },
@@ -19,8 +20,6 @@ interface ScriptEditorProps {
onChangeText: (value: string) => void;
onOpenExtractModal: () => void;
onOpenRewriteModal: () => void;
onGenerateMeta: () => void;
isGeneratingMeta: boolean;
onTranslate: (targetLang: string) => void;
isTranslating: boolean;
hasOriginalText: boolean;
@@ -36,8 +35,6 @@ export function ScriptEditor({
onChangeText,
onOpenExtractModal,
onOpenRewriteModal,
onGenerateMeta,
isGeneratingMeta,
onTranslate,
isTranslating,
hasOriginalText,
@@ -54,6 +51,10 @@ export function ScriptEditor({
const langMenuRef = useRef<HTMLDivElement>(null);
const [showHistoryMenu, setShowHistoryMenu] = useState(false);
const historyMenuRef = useRef<HTMLDivElement>(null);
const [isExpandedEditorOpen, setIsExpandedEditorOpen] = useState(false);
const handleCloseExpandedEditor = useCallback(() => {
setIsExpandedEditorOpen(false);
}, []);
useEffect(() => {
if (!showLangMenu) return;
@@ -193,34 +194,25 @@ export function ScriptEditor({
</div>
)}
</div>
<button
onClick={onGenerateMeta}
disabled={isGeneratingMeta || !text.trim()}
className={`${actionBtnBase} ${isGeneratingMeta || !text.trim()
? actionBtnDisabled
: "bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white"
}`}
>
{isGeneratingMeta ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
...
</>
) : (
<>
<Sparkles className="h-3.5 w-3.5" />
AI生成标题标签
</>
)}
</button>
</div>
</div>
<textarea
value={text}
onChange={(e) => onChangeText(e.target.value)}
placeholder="请输入你想说的话..."
className="w-full h-40 bg-black/30 border border-white/10 rounded-xl p-4 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-purple-500 transition-colors hide-scrollbar"
/>
<div className="relative">
<textarea
value={text}
onChange={(e) => onChangeText(e.target.value)}
placeholder="请输入你想说的话..."
className="w-full h-40 bg-black/30 border border-white/10 rounded-xl p-4 pr-6 pb-6 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-white/25 transition-colors hide-scrollbar"
/>
<button
type="button"
onClick={() => setIsExpandedEditorOpen(true)}
className="absolute right-0.5 bottom-2 h-5 w-5 text-gray-400/85 hover:text-white focus:outline-none transition-colors inline-flex items-center justify-center"
aria-label="扩展文案编辑器"
title="扩展编辑"
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between mt-2 text-sm text-gray-400">
<span>{text.length} </span>
<div className="flex items-center gap-2">
@@ -250,6 +242,27 @@ export function ScriptEditor({
</button>
</div>
</div>
<AppModal
isOpen={isExpandedEditorOpen}
onClose={handleCloseExpandedEditor}
panelClassName="w-full max-w-5xl max-h-[92vh] rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
>
<AppModalHeader
title="扩展文案编辑"
subtitle="在更大空间里编写与调整文案"
onClose={handleCloseExpandedEditor}
actions={<span className="text-xs text-gray-400 tabular-nums">{text.length} </span>}
/>
<div className="flex-1 p-4 sm:p-5">
<textarea
value={text}
onChange={(e) => onChangeText(e.target.value)}
placeholder="请输入你想说的话..."
className="w-full h-[66vh] min-h-[320px] bg-black/30 border border-white/10 rounded-xl p-4 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-white/25 transition-colors hide-scrollbar"
/>
</div>
</AppModal>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { ChevronDown, Eye, Check } from "lucide-react";
import { ChevronDown, Eye, Check, Loader2, Sparkles } from "lucide-react";
import { FloatingStylePreview } from "@/features/home/ui/FloatingStylePreview";
import { SelectPopover } from "@/shared/ui/SelectPopover";
@@ -35,6 +35,9 @@ interface TitleStyleOption {
interface TitleSubtitlePanelProps {
showStylePreview: boolean;
onTogglePreview: () => void;
onGenerateMeta: () => void;
isGeneratingMeta: boolean;
canGenerateMeta: boolean;
videoTitle: string;
onTitleChange: (value: string) => void;
onTitleCompositionStart?: () => void;
@@ -76,6 +79,9 @@ interface TitleSubtitlePanelProps {
export function TitleSubtitlePanel({
showStylePreview,
onTogglePreview,
onGenerateMeta,
isGeneratingMeta,
canGenerateMeta,
videoTitle,
onTitleChange,
onTitleCompositionStart,
@@ -125,63 +131,88 @@ export function TitleSubtitlePanel({
return (
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
<div className="flex items-center justify-between mb-4 gap-2">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
</h2>
<div className="flex items-center gap-1.5">
<div className="shrink-0">
<SelectPopover
sheetTitle="标题显示方式"
trigger={({ open, toggle }) => (
<button
type="button"
onClick={toggle}
className="min-w-[146px] rounded-lg border border-white/10 bg-black/25 px-2.5 py-1.5 text-left text-xs text-gray-200 transition-colors hover:border-white/30"
aria-label="标题显示方式"
>
<span className="flex items-center justify-between gap-2">
<span className="whitespace-nowrap">{currentTitleDisplay.label}</span>
<ChevronDown className={`h-3.5 w-3.5 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
</span>
</button>
)}
>
{({ close }) => (
<div className="space-y-1">
{titleDisplayOptions.map((opt) => {
const isSelected = opt.value === titleDisplayMode;
return (
<button
key={opt.value}
type="button"
data-popover-selected={isSelected ? "true" : undefined}
onClick={() => {
onTitleDisplayModeChange(opt.value);
close();
}}
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-colors ${isSelected
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<span className="text-xs text-white whitespace-nowrap">{opt.label}</span>
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
</button>
);
})}
</div>
)}
</SelectPopover>
</div>
<div className="mb-4 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
</h2>
<button
onClick={onTogglePreview}
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
onClick={onGenerateMeta}
disabled={isGeneratingMeta || !canGenerateMeta}
className={`px-3 py-1.5 text-xs rounded-lg transition-colors inline-flex items-center gap-1.5 ${
isGeneratingMeta || !canGenerateMeta
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white"
}`}
>
<Eye className="h-3.5 w-3.5" />
{showStylePreview ? "收起预览" : "预览样式"}
{isGeneratingMeta ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
...
</>
) : (
<>
<Sparkles className="h-3.5 w-3.5" />
AI生成标题标签
</>
)}
</button>
</div>
<div className="flex justify-end">
<div className="flex flex-wrap items-center justify-end gap-1.5">
<div className="shrink-0">
<SelectPopover
sheetTitle="标题显示方式"
trigger={({ open, toggle }) => (
<button
type="button"
onClick={toggle}
className="min-w-[146px] rounded-lg border border-white/10 bg-black/25 px-2.5 py-1.5 text-left text-xs text-gray-200 transition-colors hover:border-white/30"
aria-label="标题显示方式"
>
<span className="flex items-center justify-between gap-2">
<span className="whitespace-nowrap">{currentTitleDisplay.label}</span>
<ChevronDown className={`h-3.5 w-3.5 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
</span>
</button>
)}
>
{({ close }) => (
<div className="space-y-1">
{titleDisplayOptions.map((opt) => {
const isSelected = opt.value === titleDisplayMode;
return (
<button
key={opt.value}
type="button"
data-popover-selected={isSelected ? "true" : undefined}
onClick={() => {
onTitleDisplayModeChange(opt.value);
close();
}}
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-colors ${isSelected
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<span className="text-xs text-white whitespace-nowrap">{opt.label}</span>
{isSelected && <Check className="h-3.5 w-3.5 text-purple-300" />}
</button>
);
})}
</div>
)}
</SelectPopover>
</div>
<button
onClick={onTogglePreview}
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
>
<Eye className="h-3.5 w-3.5" />
{showStylePreview ? "收起预览" : "预览样式"}
</button>
</div>
</div>
</div>
{showStylePreview && (

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

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

View File

@@ -24,12 +24,17 @@ export function AppModal({
lockBodyScroll = true,
}: AppModalProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
if (!isOpen) return;
const handleEsc = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key === "Escape") onCloseRef.current();
};
const previousActiveElement = document.activeElement as HTMLElement | null;
@@ -64,7 +69,7 @@ export function AppModal({
previousActiveElement?.focus?.();
};
}, [isOpen, lockBodyScroll, onClose]);
}, [isOpen, lockBodyScroll]);
if (!isOpen || typeof document === "undefined") return null;