From 1a291a03b88051bd7897e4d6e1ea8d349817e523 Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Sun, 8 Feb 2026 10:46:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/BACKEND_DEV.md | 4 +- Docs/BACKEND_README.md | 3 +- Docs/DevLogs/Day20.md | 24 ++ Docs/DevLogs/Day21.md | 92 ++++ Docs/FRONTEND_DEV.md | 6 +- Docs/FRONTEND_README.md | 2 + Docs/task_complete.md | 15 +- backend/.env.example | 5 +- backend/app/api/__init__.py | 10 - backend/app/api/admin.py | 1 - backend/app/api/ai.py | 1 - backend/app/api/assets.py | 1 - backend/app/api/auth.py | 1 - backend/app/api/login_helper.py | 1 - backend/app/api/materials.py | 1 - backend/app/api/publish.py | 1 - backend/app/api/ref_audios.py | 1 - backend/app/api/tools.py | 1 - backend/app/api/videos.py | 1 - backend/app/main.py | 32 +- backend/app/services/remotion_service.py | 11 + backend/app/services/storage.py | 6 +- frontend/package-lock.json | 11 + frontend/package.json | 1 + frontend/src/app/admin/page.tsx | 29 +- frontend/src/app/layout.tsx | 13 +- frontend/src/app/login/page.tsx | 4 +- frontend/src/app/register/page.tsx | 6 +- .../components/AccountSettingsDropdown.tsx | 9 +- .../src/components/GlobalTaskIndicator.tsx | 5 +- .../src/components/ScriptExtractionModal.tsx | 389 +++++++---------- .../script-extraction/useScriptExtraction.ts | 210 ++++++++++ frontend/src/contexts/TaskContext.tsx | 2 + frontend/src/features/home/model/useBgm.ts | 15 +- .../features/home/model/useGeneratedVideos.ts | 61 +-- .../features/home/model/useHomeController.ts | 128 +++--- .../features/home/model/useHomePersistence.ts | 1 + .../src/features/home/model/useMaterials.ts | 23 +- .../features/home/model/useMediaPlayers.ts | 9 +- .../src/features/home/model/useRefAudios.ts | 8 +- .../home/model/useTitleSubtitleStyles.ts | 14 +- .../src/features/home/ui/RefAudioPanel.tsx | 5 +- .../publish/model/usePublishController.ts | 393 +++++++----------- .../src/features/publish/ui/PublishPage.tsx | 57 ++- frontend/src/shared/api/axios.ts | 2 +- .../src/shared/hooks/usePublishPrefetch.ts | 53 +++ frontend/src/shared/lib/auth.ts | 57 +-- frontend/src/shared/types/publish.ts | 35 ++ remotion/render.ts | 8 +- 49 files changed, 1032 insertions(+), 736 deletions(-) create mode 100644 Docs/DevLogs/Day21.md delete mode 100644 backend/app/api/__init__.py delete mode 100644 backend/app/api/admin.py delete mode 100644 backend/app/api/ai.py delete mode 100644 backend/app/api/assets.py delete mode 100644 backend/app/api/auth.py delete mode 100644 backend/app/api/login_helper.py delete mode 100644 backend/app/api/materials.py delete mode 100644 backend/app/api/publish.py delete mode 100644 backend/app/api/ref_audios.py delete mode 100644 backend/app/api/tools.py delete mode 100644 backend/app/api/videos.py create mode 100644 frontend/src/components/script-extraction/useScriptExtraction.ts create mode 100644 frontend/src/shared/hooks/usePublishPrefetch.ts create mode 100644 frontend/src/shared/types/publish.ts diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md index 5994777..68a71c6 100644 --- a/Docs/BACKEND_DEV.md +++ b/Docs/BACKEND_DEV.md @@ -19,7 +19,6 @@ - **repositories/**:数据读写(Supabase),不包含业务逻辑。 - **services/**:外部依赖与基础能力(TTS、Storage、Remotion 等)。 - **core/**:配置、安全、依赖注入、统一响应。 -- **api/**:仅做 router 透传,保持 `/api/*` 路由稳定。 --- @@ -28,9 +27,8 @@ ``` backend/ ├── app/ -│ ├── api/ # 兼容路由入口,透传到 modules │ ├── core/ # config、deps、security、response -│ ├── modules/ # 业务模块 +│ ├── modules/ # 业务模块(路由 + 逻辑) │ │ ├── videos/ │ │ ├── materials/ │ │ ├── publish/ diff --git a/Docs/BACKEND_README.md b/Docs/BACKEND_README.md index 2114b5f..2fea4ad 100644 --- a/Docs/BACKEND_README.md +++ b/Docs/BACKEND_README.md @@ -13,7 +13,6 @@ ``` backend/ ├── app/ -│ ├── api/ # 兼容路由入口 (透传到 modules) │ ├── core/ # 核心配置 (config.py, security.py, response.py) │ ├── modules/ # 业务模块 (router/service/workflow/schemas) │ ├── repositories/ # Supabase 数据访问 @@ -148,7 +147,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8006 --reload 1. 在 `app/services/` 下创建新的 Service 类 (如 `NewTTSService`)。 2. 实现 `generate` 方法,可以使用 subprocess 调用,也可以是 HTTP 请求。 3. **重要**: 如果模型占用 GPU,请务必使用 `asyncio.Lock` 进行并发控制,防止 OOM。 -4. 在 `app/api/` 中添加对应的路由调用。 +4. 在 `app/modules/` 下创建对应模块,添加 router/service/schemas,并在 `main.py` 注册路由。 ### 添加定时任务 diff --git a/Docs/DevLogs/Day20.md b/Docs/DevLogs/Day20.md index 9d82d0a..571d56b 100644 --- a/Docs/DevLogs/Day20.md +++ b/Docs/DevLogs/Day20.md @@ -64,3 +64,27 @@ pm2 restart vigent2-backend pm2 restart vigent2-latentsync # Remotion 已自动编译 ``` + +### 🐛 缺陷修复与回归治理 (17:30) + +#### 严重缺陷修复 +- [x] **BUG-1**: Remotion 渲染脚本路径解析错误 (导致标题字幕丢失) + - *原因*: `render.js` 预编译后使用了 `__dirname`,在 `dist` 目录下寻找源码失败。 + - *修复*: 修改 `render.ts` 使用 `process.cwd()` 动态解析路径,并重新编译。 + +- [x] **BUG-2**: 发布页视频选择持久化失效 (Auth 异步竞态) + - *原因*: 页面加载时 `useAuth` 尚未返回用户 ID,导致使用 `guest` Key 读取不到记录,随后被默认值覆盖。 + - *修复*: 引入 `isVideoRestored` 状态机,强制等待 Auth 完成且 Video 列表加载完毕后,才执行恢复逻辑。 + +#### 回归问题治理 +- [x] **REG-1**: 首页历史作品 ID 恢复后内容不显示 + - *原因*: 持久化模块恢复了 ID,但 `useGeneratedVideos` 未监听 ID 变化同步 URL。 + - *修复*: 新增 `useEffect` 监听 `selectedVideoId` 变化并同步 `generatedVideo` URL。 + +- [x] **REG-2**: 首页/发布页“默认选中第一个”逻辑丢失 + - *原因*: 重构移除旧逻辑后,新用户或无缓存用户进入页面无默认选中。 + - *修复*: 在 `isRestored` 且无选中时,增加兜底逻辑自动选中列表第一项。 + +- [x] **REF-1**: 持久化逻辑全站收敛 + - *优化*: 清理 `useBgm`, `useGeneratedVideos`, `useTitleSubtitleStyles` 中的冗余 `localStorage` 读取。 + - *优化*: 修复 `useMaterials` 中的闭包陷阱(使用函数式更新),防止覆盖已恢复的状态。 diff --git a/Docs/DevLogs/Day21.md b/Docs/DevLogs/Day21.md new file mode 100644 index 0000000..5751270 --- /dev/null +++ b/Docs/DevLogs/Day21.md @@ -0,0 +1,92 @@ +## 🐛 缺陷修复:视频生成与持久化回归 (Day 21) + +### 概述 +本日修复 Day 20 优化后引入的 3 个回归缺陷:Remotion 渲染崩溃容错、首页作品选择持久化、发布页作品选择持久化。 + +--- + +### 已完成修复 + +#### BUG-1: Remotion 渲染进程崩溃导致标题/字幕丢失 +- **现象**: 视频生成后没有标题和字幕,回退到纯 FFmpeg 合成。 +- **根因**: Remotion Node.js 进程在渲染完成(100%)后以 SIGABRT (code -6) 退出,Python 端将其视为失败。 +- **修复**: `remotion_service.py` 在进程非零退出时,先检查输出文件是否存在且大小合理(>1KB),若存在则视为成功。 +- **文件**: `backend/app/services/remotion_service.py` + +```python +if process.returncode != 0: + output_file = Path(output_path) + if output_file.exists() and output_file.stat().st_size > 1024: + logger.warning( + f"Remotion process exited with code {process.returncode}, " + f"but output file exists ({output_file.stat().st_size} bytes). Treating as success." + ) + return output_path + raise RuntimeError(...) +``` + +#### BUG-2: 首页历史作品选择刷新后不保持 +- **现象**: 用户选择某个历史作品后刷新页面,总是回到第一个视频。 +- **根因**: `fetchGeneratedVideos()` 在初始加载时无条件自动选中第一个视频,覆盖了 `useHomePersistence` 的恢复值。 +- **修复**: `fetchGeneratedVideos` 增加 `preferVideoId` 参数,仅在明确指定时才自动选中;新增 `"__latest__"` 哨兵值用于生成完成后选中最新。 +- **文件**: `frontend/src/features/home/model/useGeneratedVideos.ts`, `frontend/src/features/home/model/useHomeController.ts` + +```typescript +// 任务完成 → 自动选中最新 +useEffect(() => { + if (prevIsGenerating.current && !isGenerating) { + if (currentTask?.status === "completed") { + void fetchGeneratedVideos("__latest__"); + } else { + void fetchGeneratedVideos(); + } + } + prevIsGenerating.current = isGenerating; +}, [isGenerating, currentTask, fetchGeneratedVideos]); +``` + +#### BUG-3: 发布页作品选择刷新后不保持(根因:签名 URL 不稳定) +- **现象**: 发布管理页选择视频后刷新,选择丢失(无任何视频被选中)。 +- **根因**: 后端 `/api/videos/generated` 返回的 `path` 是 Supabase 签名 URL,每次请求都会变化。发布页用 `path` 作为选择标识存入 localStorage,刷新后新的 `path` 与保存值永远不匹配。首页不受影响是因为使用稳定的 `video.id`。 +- **修复**: 发布页全面改用 `id`(稳定标识)替代 `path`(签名 URL)进行选择、持久化和比较。 +- **文件**: + - `frontend/src/shared/types/publish.ts` — `PublishVideo` 新增 `id` 字段 + - `frontend/src/features/publish/model/usePublishController.ts` — `selectedVideo` 存储 `id`,发布时根据 `id` 查找 `path` + - `frontend/src/features/publish/ui/PublishPage.tsx` — `key`/`onClick`/选中比较改用 `v.id` + - `frontend/src/features/home/model/useHomeController.ts` — 预取缓存加入 `id` 字段 + +```typescript +// 类型定义新增 id +export interface PublishVideo { + id: string; // 稳定标识符 + name: string; + path: string; // 签名 URL(仅用于播放/发布) +} + +// 发布时根据 id 查找 path +const video = videos.find(v => v.id === selectedVideo); +await api.post('/api/publish', { video_path: video.path, ... }); +``` + +--- + +### 涉及文件汇总 + +| 文件 | 变更 | +|------|------| +| `backend/app/services/remotion_service.py` | Remotion 崩溃容错 | +| `frontend/src/features/home/model/useGeneratedVideos.ts` | 首页视频选择不自动覆盖 | +| `frontend/src/features/home/model/useHomeController.ts` | 任务完成监听 + 预取缓存加 id | +| `frontend/src/shared/types/publish.ts` | PublishVideo 新增 id 字段 | +| `frontend/src/features/publish/model/usePublishController.ts` | 选择/持久化/发布改用 id | +| `frontend/src/features/publish/ui/PublishPage.tsx` | UI 选择比较改用 id | + +### 关键教训 + +> **签名 URL 不可作为持久化标识**。Supabase Storage 的签名 URL 包含时间戳和签名参数,每次请求都不同。任何需要跨请求/跨刷新保持的标识,必须使用后端返回的稳定 `id` 字段。 + +### 重启要求 +```bash +pm2 restart vigent2-backend # Remotion 容错 +npm run build && pm2 restart vigent2-frontend # 前端持久化修复 +``` diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md index 82130c1..0d94ffd 100644 --- a/Docs/FRONTEND_DEV.md +++ b/Docs/FRONTEND_DEV.md @@ -15,7 +15,7 @@ frontend/src/ │ └── ... ├── lib/ # 公共工具函数 │ ├── axios.ts # Axios 实例(含 401/403 拦截器) -│ ├── auth.ts # 认证相关函数 +│ ├── auth.ts # 认证相关函数(统一使用 axios) │ └── media.ts # API Base / URL / 日期等通用工具 └── proxy.ts # 路由代理(原 middleware) ``` @@ -256,6 +256,7 @@ import { formatDate } from '@/shared/lib/media'; - **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。 - 避免默认值覆盖用户选择(优先读取已保存值)。 - 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。 +- **禁止使用签名 URL 作为持久化标识**:Supabase Storage 签名 URL 每次请求都变化,必须使用后端返回的稳定 `id` 字段。 - 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。 --- @@ -273,7 +274,8 @@ import { formatDate } from '@/shared/lib/media'; ## 发布页交互规则 - 发布按钮在未选择任何平台时禁用 -- 仅保留“立即发布”,不再提供定时发布 UI/参数 +- 仅保留"立即发布",不再提供定时发布 UI/参数 +- **作品选择持久化**:使用 `video.id`(稳定标识)而非 `video.path`(签名 URL)进行选择、比较和 localStorage 存储。发布时根据 `id` 查找对应 `path` 发送请求。 --- diff --git a/Docs/FRONTEND_README.md b/Docs/FRONTEND_README.md index 6f13e64..955bcdf 100644 --- a/Docs/FRONTEND_README.md +++ b/Docs/FRONTEND_README.md @@ -17,6 +17,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 - **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。 - **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。 - **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。 +- **选择持久化**: 首页/发布页作品选择均使用稳定 `id` 持久化,刷新保持用户选择;新视频生成后自动选中最新 (Day 21)。 ### 2. 全自动发布 (`/publish`) [Day 7 新增] - **多平台管理**: 统一管理抖音、微信视频号、B站、小红书账号状态。 @@ -26,6 +27,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 - Cookie 自动保存与状态同步。 - **发布配置**: 设置视频标题、标签、简介。 - **作品选择**: 卡片列表 + 搜索 + 预览弹窗。 +- **选择持久化**: 使用稳定 `video.id` 持久化选择,刷新保持;新视频生成自动选中最新 (Day 21)。 - **预览兼容**: 签名 URL / 相对路径均可直接预览。 - **发布方式**: 仅支持 "立即发布"。 diff --git a/Docs/task_complete.md b/Docs/task_complete.md index 77e9166..a9a47d9 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -1,8 +1,8 @@ # ViGent2 开发任务清单 (Task Log) -**项目**: ViGent2 数字人口播视频生成系统 -**进度**: 100% (Day 20 - 代码质量与安全优化) -**更新时间**: 2026-02-07 +**项目**: ViGent2 数字人口播视频生成系统 +**进度**: 100% (Day 21 - 缺陷修复与持久化回归治理) +**更新时间**: 2026-02-08 --- @@ -10,12 +10,19 @@ > 这里记录了每一天的核心开发内容与 milestone。 -### Day 20: 代码质量与安全优化 (Current) +### Day 21: 缺陷修复与持久化回归治理 (Current) +- [x] **Remotion 崩溃容错**: 渲染进程 SIGABRT 退出时检查输出文件,避免误判失败导致标题/字幕丢失。 +- [x] **首页作品选择持久化**: 修复 `fetchGeneratedVideos` 无条件覆盖恢复值的问题,新增 `preferVideoId` 参数控制选中逻辑。 +- [x] **发布页作品选择持久化**: 根因为签名 URL 不稳定,全面改用 `video.id` 替代 `path` 进行选择/持久化/比较。 +- [x] **预取缓存补全**: 首页预取发布页数据时加入 `id` 字段,确保缓存数据可用于持久化匹配。 + +### Day 20: 代码质量与安全优化 - [x] **功能性修复**: LatentSync 回退逻辑、任务状态接口认证、User 类型统一。 - [x] **性能优化**: N+1 查询修复、视频上传流式处理、httpx 异步替换、GLM 异步包装。 - [x] **安全修复**: 硬编码 Cookie 配置化、日志敏感信息脱敏、ffprobe 安全调用、CORS 配置化。 - [x] **配置优化**: 存储路径环境变量化、Remotion 预编译加速、LatentSync 绝对路径。 - [x] **文档更新**: 更新 Doc_Rules.md 清单,补齐后端与部署文档;更新 SUBTITLE_DEPLOY.md, FRONTEND_DEV.md, implementation_plan.md。 +- [x] **缺陷修复**: 修复 Remotion 路径解析、发布页持久化竞态、首页选中回归、素材闭包陷阱。 ### Day 19: 自动发布稳定性与发布体验优化 🚀 - [x] **抖音发布稳定性**: 上传入口、封面流程、发布重试、登录失效识别与网络失败快速返回全面增强。 diff --git a/backend/.env.example b/backend/.env.example index f410b07..bf98eb7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,7 +15,6 @@ DEFAULT_TTS_VOICE=zh-CN-YunxiNeural # GPU 选择 (0=第一块GPU, 1=第二块GPU) LATENTSYNC_GPU_ID=1 -# 使用本地模式 (true) 或远程 API (false) # 使用本地模式 (true) 或远程 API (false) LATENTSYNC_LOCAL=true @@ -67,6 +66,10 @@ ADMIN_PASSWORD=lam1988324 GLM_API_KEY=32440cd3f3444d1f8fe721304acea8bd.YXNLrk7eIJMKcg4t GLM_MODEL=glm-4.7-flash +# =============== Supabase Storage 本地路径 =============== +# 确保存储卷映射正确,避免硬编码路径 +SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub + # =============== 抖音视频下载 Cookie =============== # 用于从抖音 URL 提取视频文案功能,会过期需要定期更新 DOUYIN_COOKIE=douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py deleted file mode 100644 index 5ebf548..0000000 --- a/backend/app/api/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from . import admin -from . import ai -from . import assets -from . import auth -from . import login_helper -from . import materials -from . import publish -from . import ref_audios -from . import tools -from . import videos diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py deleted file mode 100644 index 8ddc9b3..0000000 --- a/backend/app/api/admin.py +++ /dev/null @@ -1 +0,0 @@ -from app.modules.admin.router import router diff --git a/backend/app/api/ai.py b/backend/app/api/ai.py deleted file mode 100644 index 50256d9..0000000 --- a/backend/app/api/ai.py +++ /dev/null @@ -1 +0,0 @@ -from app.modules.ai.router import router diff --git a/backend/app/api/assets.py b/backend/app/api/assets.py deleted file mode 100644 index 69cb5ca..0000000 --- a/backend/app/api/assets.py +++ /dev/null @@ -1 +0,0 @@ -from app.modules.assets.router import router diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py deleted file mode 100644 index a373b03..0000000 --- a/backend/app/api/auth.py +++ /dev/null @@ -1 +0,0 @@ -from app.modules.auth.router import router diff --git a/backend/app/api/login_helper.py b/backend/app/api/login_helper.py deleted file mode 100644 index e12b9ff..0000000 --- a/backend/app/api/login_helper.py +++ /dev/null @@ -1 +0,0 @@ -from app.modules.login_helper.router import router diff --git a/backend/app/api/materials.py b/backend/app/api/materials.py deleted file mode 100644 index 83e7fb9..0000000 --- a/backend/app/api/materials.py +++ /dev/null @@ -1 +0,0 @@ -from app.modules.materials.router import router diff --git a/backend/app/api/publish.py b/backend/app/api/publish.py deleted file mode 100644 index d0ae3b3..0000000 --- a/backend/app/api/publish.py +++ /dev/null @@ -1 +0,0 @@ -from app.modules.publish.router import router diff --git a/backend/app/api/ref_audios.py b/backend/app/api/ref_audios.py deleted file mode 100644 index e3f45ab..0000000 --- a/backend/app/api/ref_audios.py +++ /dev/null @@ -1 +0,0 @@ -from app.modules.ref_audios.router import router diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py deleted file mode 100644 index dca97c7..0000000 --- a/backend/app/api/tools.py +++ /dev/null @@ -1 +0,0 @@ -from app.modules.tools.router import router diff --git a/backend/app/api/videos.py b/backend/app/api/videos.py deleted file mode 100644 index 853a278..0000000 --- a/backend/app/api/videos.py +++ /dev/null @@ -1 +0,0 @@ -from app.modules.videos.router import router diff --git a/backend/app/main.py b/backend/app/main.py index 0d868ad..6bb48ff 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,17 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from app.core import config from app.core.response import error_response -from app.api import materials, videos, publish, login_helper, auth, admin, ref_audios, ai, tools, assets +# 直接从 modules 导入路由,消除 api 转发层 +from app.modules.materials.router import router as materials_router +from app.modules.videos.router import router as videos_router +from app.modules.publish.router import router as publish_router +from app.modules.login_helper.router import router as login_helper_router +from app.modules.auth.router import router as auth_router +from app.modules.admin.router import router as admin_router +from app.modules.ref_audios.router import router as ref_audios_router +from app.modules.ai.router import router as ai_router +from app.modules.tools.router import router as tools_router +from app.modules.assets.router import router as assets_router from loguru import logger import os @@ -104,16 +114,16 @@ app.mount("/uploads", StaticFiles(directory=str(settings.UPLOAD_DIR)), name="upl app.mount("/assets", StaticFiles(directory=str(settings.ASSETS_DIR)), name="assets") # 注册路由 -app.include_router(materials.router, prefix="/api/materials", tags=["Materials"]) -app.include_router(videos.router, prefix="/api/videos", tags=["Videos"]) -app.include_router(publish.router, prefix="/api/publish", tags=["Publish"]) -app.include_router(login_helper.router, prefix="/api", tags=["LoginHelper"]) -app.include_router(auth.router) # /api/auth -app.include_router(admin.router) # /api/admin -app.include_router(ref_audios.router, prefix="/api/ref-audios", tags=["RefAudios"]) -app.include_router(ai.router) # /api/ai -app.include_router(tools.router, prefix="/api/tools", tags=["Tools"]) -app.include_router(assets.router, prefix="/api/assets", tags=["Assets"]) +app.include_router(materials_router, prefix="/api/materials", tags=["Materials"]) +app.include_router(videos_router, prefix="/api/videos", tags=["Videos"]) +app.include_router(publish_router, prefix="/api/publish", tags=["Publish"]) +app.include_router(login_helper_router, prefix="/api", tags=["LoginHelper"]) +app.include_router(auth_router) # /api/auth +app.include_router(admin_router) # /api/admin +app.include_router(ref_audios_router, prefix="/api/ref-audios", tags=["RefAudios"]) +app.include_router(ai_router) # /api/ai +app.include_router(tools_router, prefix="/api/tools", tags=["Tools"]) +app.include_router(assets_router, prefix="/api/assets", tags=["Assets"]) @app.on_event("startup") diff --git a/backend/app/services/remotion_service.py b/backend/app/services/remotion_service.py index bfc5730..b05e5cc 100644 --- a/backend/app/services/remotion_service.py +++ b/backend/app/services/remotion_service.py @@ -5,6 +5,7 @@ Remotion 视频渲染服务 import asyncio import json +import os import subprocess from pathlib import Path from typing import Optional @@ -114,6 +115,16 @@ class RemotionService: process.wait() if process.returncode != 0: + # Remotion 渲染可能在完成输出后进程崩溃 (如 SIGABRT code -6) + # 如果输出文件已存在且大小合理,视为成功 + output_file = Path(output_path) + if output_file.exists() and output_file.stat().st_size > 1024: + logger.warning( + f"Remotion process exited with code {process.returncode}, " + f"but output file exists ({output_file.stat().st_size} bytes). Treating as success." + ) + return output_path + error_msg = "\n".join(output_lines[-20:]) # 最后 20 行 raise RuntimeError(f"Remotion render failed (code {process.returncode}):\n{error_msg}") diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index c5edc82..eeb2171 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -10,9 +10,9 @@ import os import shutil # Supabase Storage 本地存储根目录(从环境变量读取,支持不同部署环境) -SUPABASE_STORAGE_LOCAL_PATH = Path( - os.getenv("SUPABASE_STORAGE_LOCAL_PATH", "/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub") -) +# Supabase Storage 本地存储根目录(从环境变量读取,支持不同部署环境) +_default_storage_path = "/var/lib/supabase/storage" # 生产环境默认路径 +SUPABASE_STORAGE_LOCAL_PATH = Path(os.getenv("SUPABASE_STORAGE_LOCAL_PATH", _default_storage_path)) class StorageService: def __init__(self): diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3a8de1d..cf7cd42 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "next": "16.1.1", "react": "19.2.3", "react-dom": "19.2.3", + "sonner": "^2.0.7", "swr": "^2.3.8" }, "devDependencies": { @@ -6006,6 +6007,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 213ba71..476aadb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "next": "16.1.1", "react": "19.2.3", "react-dom": "19.2.3", + "sonner": "^2.0.7", "swr": "^2.3.8" }, "devDependencies": { diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 965643f..6d82dfe 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,10 +1,12 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { getCurrentUser, User } from "@/shared/lib/auth"; -import api from "@/shared/api/axios"; -import { ApiResponse, unwrap } from "@/shared/api/types"; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { getCurrentUser, User } from "@/shared/lib/auth"; +import api from "@/shared/api/axios"; +import { ApiResponse, unwrap } from "@/shared/api/types"; +import { toast } from "sonner"; interface UserListItem { id: string; @@ -18,7 +20,7 @@ interface UserListItem { export default function AdminPage() { const router = useRouter(); - const [currentUser, setCurrentUser] = useState(null); + const [, setCurrentUser] = useState(null); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -28,6 +30,7 @@ export default function AdminPage() { useEffect(() => { checkAdmin(); fetchUsers(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const checkAdmin = async () => { @@ -41,9 +44,9 @@ export default function AdminPage() { const fetchUsers = async () => { try { - const { data: res } = await api.get>('/api/admin/users'); - setUsers(unwrap(res)); - } catch (err) { + const { data: res } = await api.get>('/api/admin/users'); + setUsers(unwrap(res)); + } catch { setError('获取用户列表失败'); } finally { setLoading(false); @@ -57,7 +60,7 @@ export default function AdminPage() { expires_days: expireDays || null }); fetchUsers(); - } catch (err) { + } catch { // axios interceptor handles 401/403 } finally { setActivatingId(null); @@ -70,8 +73,8 @@ export default function AdminPage() { try { await api.post(`/api/admin/users/${userId}/deactivate`); fetchUsers(); - } catch (err) { - alert('操作失败'); + } catch { + toast.error('操作失败'); } }; @@ -106,9 +109,9 @@ export default function AdminPage() {

用户管理

- + ← 返回首页 - +
{error && ( diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index e0ca062..b0db1e0 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,7 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { AuthProvider } from "@/contexts/AuthContext"; import { TaskProvider } from "@/contexts/TaskContext"; -import GlobalTaskIndicator from "@/components/GlobalTaskIndicator"; + +import { Toaster } from "sonner"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -39,10 +40,18 @@ export default function RootLayout({ > - {children} + ); diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 745a87f..65855bf 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { login } from "@/shared/lib/auth"; +import { login } from "@/shared/lib/auth"; export default function LoginPage() { const router = useRouter(); @@ -30,7 +30,7 @@ export default function LoginPage() { } else { setError(result.message || '登录失败'); } - } catch (err) { + } catch { setError('网络错误,请稍后重试'); } finally { setLoading(false); diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index 5ae017d..e9a3e25 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -2,10 +2,10 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { register } from "@/shared/lib/auth"; +import { register } from "@/shared/lib/auth"; export default function RegisterPage() { - const router = useRouter(); + useRouter(); // 保留以便后续扩展 const [phone, setPhone] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -43,7 +43,7 @@ export default function RegisterPage() { } else { setError(result.message || '注册失败'); } - } catch (err) { + } catch { setError('网络错误,请稍后重试'); } finally { setLoading(false); diff --git a/frontend/src/components/AccountSettingsDropdown.tsx b/frontend/src/components/AccountSettingsDropdown.tsx index c44c6c8..f834e30 100644 --- a/frontend/src/components/AccountSettingsDropdown.tsx +++ b/frontend/src/components/AccountSettingsDropdown.tsx @@ -44,7 +44,7 @@ export default function AccountSettingsDropdown() { if (confirm('确定要退出登录吗?')) { try { await api.post('/api/auth/logout'); - } catch (e) { } + } catch { } window.location.href = '/login'; } }; @@ -76,14 +76,15 @@ export default function AccountSettingsDropdown() { setTimeout(async () => { try { await api.post('/api/auth/logout'); - } catch (e) { } + } catch { } window.location.href = '/login'; }, 1500); } else { setError(res.message || '修改失败'); } - } catch (err: any) { - setError(err.response?.data?.message || '修改失败,请重试'); + } catch (err: unknown) { + const axiosErr = err as { response?: { data?: { message?: string } } }; + setError(axiosErr.response?.data?.message || '修改失败,请重试'); } finally { setLoading(false); } diff --git a/frontend/src/components/GlobalTaskIndicator.tsx b/frontend/src/components/GlobalTaskIndicator.tsx index bc5a59d..6163329 100644 --- a/frontend/src/components/GlobalTaskIndicator.tsx +++ b/frontend/src/components/GlobalTaskIndicator.tsx @@ -2,11 +2,14 @@ import { useTask } from "@/contexts/TaskContext"; import Link from "next/link"; +import { usePathname } from "next/navigation"; export default function GlobalTaskIndicator() { const { currentTask, isGenerating } = useTask(); + const pathname = usePathname(); - if (!isGenerating) return null; + // 首页已有专门的进度条展示,因此在首页不显示顶部全局进度条 + if (!isGenerating || pathname === "/") return null; return (
diff --git a/frontend/src/components/ScriptExtractionModal.tsx b/frontend/src/components/ScriptExtractionModal.tsx index 6ac8abb..5545355 100644 --- a/frontend/src/components/ScriptExtractionModal.tsx +++ b/frontend/src/components/ScriptExtractionModal.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useEffect } from "react"; -import api from "@/shared/api/axios"; -import { ApiResponse, unwrap } from "@/shared/api/types"; +import { useEffect, useCallback } from "react"; +import { Loader2 } from "lucide-react"; +import { useScriptExtraction } from "./script-extraction/useScriptExtraction"; interface ScriptExtractionModalProps { isOpen: boolean; @@ -13,177 +13,66 @@ interface ScriptExtractionModalProps { export default function ScriptExtractionModal({ isOpen, onClose, - onApply + onApply, }: ScriptExtractionModalProps) { - const [isLoading, setIsLoading] = useState(false); - const [script, setScript] = useState(""); - const [rewrittenScript, setRewrittenScript] = useState(""); - const [error, setError] = useState(null); - const [doRewrite, setDoRewrite] = useState(true); - const [step, setStep] = useState<'config' | 'processing' | 'result'>('config'); - const [dragActive, setDragActive] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); + const { + isLoading, + script, + rewrittenScript, + error, + doRewrite, + step, + dragActive, + selectedFile, + activeTab, + inputUrl, + setDoRewrite, + setActiveTab, + setInputUrl, + handleDrag, + handleDrop, + handleFileChange, + handleExtract, + copyToClipboard, + resetToConfig, + clearSelectedFile, + clearInputUrl, + } = useScriptExtraction({ isOpen }); - // New state for URL mode - const [activeTab, setActiveTab] = useState<'file' | 'url'>('url'); - const [inputUrl, setInputUrl] = useState(""); + // 快捷键:ESC 关闭,Enter 提交(仅在 config 步骤) + const canExtract = (activeTab === "file" && selectedFile) || (activeTab === "url" && inputUrl.trim()); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } else if (e.key === "Enter" && !e.shiftKey && step === "config" && canExtract && !isLoading) { + e.preventDefault(); + handleExtract(); + } + }, [onClose, step, canExtract, isLoading, handleExtract]); - // Reset state when modal opens useEffect(() => { - if (isOpen) { - setStep('config'); - setScript(""); - setRewrittenScript(""); - setError(null); - setIsLoading(false); - setSelectedFile(null); - setInputUrl(""); - setActiveTab('url'); - } - }, [isOpen]); - - const handleDrag = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.type === "dragenter" || e.type === "dragover") { - setDragActive(true); - } else if (e.type === "dragleave") { - setDragActive(false); - } - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setDragActive(false); - if (e.dataTransfer.files && e.dataTransfer.files[0]) { - handleFile(e.dataTransfer.files[0]); - } - }; - - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - handleFile(e.target.files[0]); - } - }; - - const handleFile = (file: File) => { - const validTypes = ['.mp4', '.mov', '.avi', '.mp3', '.wav', '.m4a']; - const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.')); - if (!validTypes.includes(ext)) { - setError(`不支持的文件格式 ${ext},请上传视频或音频文件`); - return; - } - setSelectedFile(file); - setError(null); - }; - - const handleExtract = async () => { - if (activeTab === 'file' && !selectedFile) { - setError("请先上传文件"); - return; - } - if (activeTab === 'url' && !inputUrl.trim()) { - setError("请先输入视频链接"); - return; - } - - setIsLoading(true); - setStep('processing'); - setError(null); - - try { - const formData = new FormData(); - if (activeTab === 'file' && selectedFile) { - formData.append('file', selectedFile); - } else if (activeTab === 'url') { - formData.append('url', inputUrl.trim()); - } - formData.append('rewrite', doRewrite ? 'true' : 'false'); - - const { data: res } = await api.post>( - '/api/tools/extract-script', - formData, - { - headers: { 'Content-Type': 'multipart/form-data' }, - timeout: 180000 // 3 minutes timeout - }); - - const payload = unwrap(res); - setScript(payload.original_script); - setRewrittenScript(payload.rewritten_script || ""); - setStep('result'); - } catch (err: any) { - console.error(err); - const msg = err.response?.data?.message || err.message || "请求失败"; - setError(msg); - setStep('config'); - } finally { - setIsLoading(false); - } - }; - - const copyToClipboard = (text: string) => { - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(text).then(() => { - alert("已复制到剪贴板"); - }).catch(err => { - console.error('Async: Could not copy text: ', err); - fallbackCopyTextToClipboard(text); - }); - } else { - fallbackCopyTextToClipboard(text); - } - }; - - const fallbackCopyTextToClipboard = (text: string) => { - var textArea = document.createElement("textarea"); - textArea.value = text; - - // Avoid scrolling to bottom - 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 { - var successful = document.execCommand('copy'); - var msg = successful ? 'successful' : 'unsuccessful'; - if (successful) { - alert("已复制到剪贴板"); - } else { - alert("复制失败,请手动复制"); - } - } catch (err) { - console.error('Fallback: Oops, unable to copy', err); - alert("复制失败,请手动复制"); - } - - document.body.removeChild(textArea); - }; - - // Close when clicking outside - DISABLED as per user request - // const modalRef = useRef(null); - // const handleBackdropClick = (e: React.MouseEvent) => { - // if (modalRef.current && !modalRef.current.contains(e.target as Node)) { - // onClose(); - // } - // }; + if (!isOpen) return; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, handleKeyDown]); if (!isOpen) return null; + const handleApplyAndClose = (text: string) => { + onApply?.(text); + onClose(); + }; + + const handleExtractNext = () => { + resetToConfig(); + clearSelectedFile(); + clearInputUrl(); + }; + return ( -
-
+
+
{/* Header */}

@@ -199,25 +88,24 @@ export default function ScriptExtractionModal({ {/* Content */}
- {step === 'config' && ( + {step === "config" && (
- {/* Tabs */}
{/* URL Input Area */} - {activeTab === 'url' && ( + {activeTab === "url" && (
{inputUrl && ( )}
-

- 支持抖音、B站等主流平台分享链接,自动解析下载并提取文案。 +

+ 支持抖音、B站、微博、小红书等主流平台视频链接

)} {/* File Upload Area */} - {activeTab === 'file' && ( + {activeTab === "file" && (
- - {selectedFile ? ( -
-
📄
-
{selectedFile.name}
-
{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB
-
点击更换文件
+
+

{selectedFile.name}

+

+ {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +

+
) : ( -
-
📤
-
点击上传或拖拽文件到此处
-
支持 MP4, MOV, MP3, WAV 等音视频格式
+
+
📁
+

+ 拖放视频/音频文件到此处,或 + +

+

+ 支持 MP4, MOV, AVI, MP3, WAV, M4A +

)}
)} {/* Options */} -
-

{onApply && (
) : qrCodeImage ? ( <> - QR Code

请使用手机扫码登录 @@ -145,9 +150,11 @@ export function PublishPage() { >

{platformIcons[account.platform] ? ( - {platformIcons[account.platform].alt} ) : ( @@ -239,9 +246,9 @@ export function PublishPage() {
{filteredVideos.map((v) => (
setSelectedVideo(v.path)} - className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.path + key={v.id} + onClick={() => setSelectedVideo(v.id)} + className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.id ? "border-purple-500 bg-purple-500/20" : "border-white/10 bg-white/5 hover:border-white/30" }`} @@ -253,7 +260,7 @@ export function PublishPage() { - {selectedVideo === v.path && ( + {selectedVideo === v.id && ( 已选 )}
@@ -331,9 +338,11 @@ export function PublishPage() { > {platformIcons[account.platform] ? ( - {platformIcons[account.platform].alt} ) : ( @@ -352,7 +361,12 @@ export function PublishPage() { disabled={isPublishing || selectedPlatforms.length === 0} className="w-full py-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-bold text-lg hover:shadow-lg hover:from-purple-500 hover:to-pink-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed" > - {isPublishing ? "正在发布...请勿刷新或关闭网页" : "立即发布"} + {isPublishing ? ( + + + 正在发布...请勿刷新或关闭网页 + + ) : "立即发布"} {/* 发布结果 */} @@ -367,15 +381,17 @@ export function PublishPage() { }`} >
- {platformIcons[result.platform] ? ( - {platformIcons[result.platform].alt} - ) : ( - 🌐 - )} + {platformIcons[result.platform] ? ( + {platformIcons[result.platform].alt} + ) : ( + 🌐 + )} {result.success ? "发布成功" : "发布失败"} @@ -390,10 +406,13 @@ export function PublishPage() { rel="noreferrer" className="block" > - 发布成功截图
diff --git a/frontend/src/shared/api/axios.ts b/frontend/src/shared/api/axios.ts index c39a73b..5a85ec0 100644 --- a/frontend/src/shared/api/axios.ts +++ b/frontend/src/shared/api/axios.ts @@ -37,7 +37,7 @@ api.interceptors.response.use( // 调用 logout API 清除 HttpOnly cookie try { await fetch('/api/auth/logout', { method: 'POST' }); - } catch (e) { + } catch { // 忽略错误 } diff --git a/frontend/src/shared/hooks/usePublishPrefetch.ts b/frontend/src/shared/hooks/usePublishPrefetch.ts new file mode 100644 index 0000000..63c5073 --- /dev/null +++ b/frontend/src/shared/hooks/usePublishPrefetch.ts @@ -0,0 +1,53 @@ +import { useCallback } from "react"; +import { PublishPrefetchCache } from "@/shared/types/publish"; + +const PREFETCH_KEY = "vigent_publish_prefetch_v1"; +const PREFETCH_TTL = 2 * 60 * 1000; // 2 分钟 + +/** + * 发布预加载缓存 hook + * 用于在首页预加载发布页所需的账号和视频数据 + */ +export const usePublishPrefetch = () => { + const readPrefetch = useCallback((): PublishPrefetchCache | null => { + if (typeof window === "undefined") return null; + const raw = sessionStorage.getItem(PREFETCH_KEY); + if (!raw) return null; + try { + const cache = JSON.parse(raw) as PublishPrefetchCache; + if (!cache?.ts) return null; + if (Date.now() - cache.ts > PREFETCH_TTL) return null; + return cache; + } catch { + return null; + } + }, []); + + const updatePrefetch = useCallback((patch: Partial) => { + if (typeof window === "undefined") return; + const existing = (() => { + const raw = sessionStorage.getItem(PREFETCH_KEY); + if (!raw) return { ts: Date.now() }; + try { + const cache = JSON.parse(raw) as PublishPrefetchCache; + if (!cache?.ts || Date.now() - cache.ts > PREFETCH_TTL) return { ts: Date.now() }; + return cache; + } catch { + return { ts: Date.now() }; + } + })(); + const next = { ...existing, ...patch, ts: Date.now() }; + sessionStorage.setItem(PREFETCH_KEY, JSON.stringify(next)); + }, []); + + const clearPrefetch = useCallback(() => { + if (typeof window === "undefined") return; + sessionStorage.removeItem(PREFETCH_KEY); + }, []); + + return { + readPrefetch, + updatePrefetch, + clearPrefetch, + }; +}; diff --git a/frontend/src/shared/lib/auth.ts b/frontend/src/shared/lib/auth.ts index f7fc6b1..c9ab28e 100644 --- a/frontend/src/shared/lib/auth.ts +++ b/frontend/src/shared/lib/auth.ts @@ -1,15 +1,13 @@ /** * 认证工具函数 + * 统一使用 axios 实例,与其他 API 调用保持一致的错误处理 */ +import api from "@/shared/api/axios"; import { User } from "@/shared/types/user"; // Re-export User 类型以保持向后兼容 export type { User }; -const API_BASE = typeof window === 'undefined' - ? (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8006') - : ''; - export interface AuthResponse { success: boolean; message: string; @@ -27,58 +25,38 @@ interface ApiResponse { * 用户注册 */ export async function register(phone: string, password: string, username?: string): Promise { - const res = await fetch(`${API_BASE}/api/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ phone, password, username }) + const { data: payload } = await api.post>('/api/auth/register', { + phone, password, username }); - const payload = await res.json(); - const data = payload as ApiResponse; - return { success: data.success, message: data.message }; + return { success: payload.success, message: payload.message }; } /** * 用户登录 */ export async function login(phone: string, password: string): Promise { - const res = await fetch(`${API_BASE}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ phone, password }) + const { data: payload } = await api.post>('/api/auth/login', { + phone, password }); - const payload = await res.json(); - const data = payload as ApiResponse<{ user?: User }>; - return { success: data.success, message: data.message, user: data.data?.user }; + return { success: payload.success, message: payload.message, user: payload.data?.user }; } /** * 用户登出 */ export async function logout(): Promise { - const res = await fetch(`${API_BASE}/api/auth/logout`, { - method: 'POST', - credentials: 'include' - }); - const payload = await res.json(); - const data = payload as ApiResponse; - return { success: data.success, message: data.message }; + const { data: payload } = await api.post>('/api/auth/logout'); + return { success: payload.success, message: payload.message }; } /** * 修改密码 */ export async function changePassword(oldPassword: string, newPassword: string): Promise { - const res = await fetch(`${API_BASE}/api/auth/change-password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }) + const { data: payload } = await api.post>('/api/auth/change-password', { + old_password: oldPassword, new_password: newPassword }); - const payload = await res.json(); - const data = payload as ApiResponse; - return { success: data.success, message: data.message }; + return { success: payload.success, message: payload.message }; } /** @@ -86,13 +64,8 @@ export async function changePassword(oldPassword: string, newPassword: string): */ export async function getCurrentUser(): Promise { try { - const res = await fetch(`${API_BASE}/api/auth/me`, { - credentials: 'include' - }); - if (!res.ok) return null; - const payload = await res.json(); - const data = payload as ApiResponse; - return data.data || null; + const { data: payload } = await api.get>('/api/auth/me'); + return payload.data || null; } catch { return null; } diff --git a/frontend/src/shared/types/publish.ts b/frontend/src/shared/types/publish.ts new file mode 100644 index 0000000..1dc78aa --- /dev/null +++ b/frontend/src/shared/types/publish.ts @@ -0,0 +1,35 @@ +/** + * 发布相关共用类型定义 + * 用于 useHomeController 和 usePublishController 之间共享 + */ + +/** 发布平台账号 */ +export interface PublishAccount { + platform: string; + name: string; + logged_in: boolean; + enabled: boolean; +} + +/** 可发布的视频 */ +export interface PublishVideo { + id: string; + name: string; + path: string; +} + +/** 发布预加载缓存 */ +export interface PublishPrefetchCache { + ts: number; + accounts?: PublishAccount[]; + videos?: PublishVideo[]; +} + +/** 发布结果 */ +export interface PublishResult { + platform: string; + success: boolean; + message: string; + url?: string | null; + screenshot_url?: string; +} diff --git a/remotion/render.ts b/remotion/render.ts index 8aee1e7..e3e12fe 100644 --- a/remotion/render.ts +++ b/remotion/render.ts @@ -134,8 +134,14 @@ async function main() { // Bundle the Remotion project console.log('Bundling Remotion project...'); + + // 修复: 使用 process.cwd() 解析 src/index.ts,确保在 dist/render.js 和 ts-node 下都能找到 + // 假设脚本总是在 remotion 根目录下运行 (由 python service 保证) + const entryPoint = path.resolve(process.cwd(), 'src/index.ts'); + console.log(`Entry point: ${entryPoint}`); + const bundleLocation = await bundle({ - entryPoint: path.resolve(__dirname, './src/index.ts'), + entryPoint, webpackOverride: (config) => config, publicDir, });