This commit is contained in:
Kevin Wong
2026-02-08 10:46:08 +08:00
parent 1e52346eb4
commit 1a291a03b8
49 changed files with 1032 additions and 736 deletions

View File

@@ -19,7 +19,6 @@
- **repositories/**数据读写Supabase不包含业务逻辑。 - **repositories/**数据读写Supabase不包含业务逻辑。
- **services/**外部依赖与基础能力TTS、Storage、Remotion 等)。 - **services/**外部依赖与基础能力TTS、Storage、Remotion 等)。
- **core/**:配置、安全、依赖注入、统一响应。 - **core/**:配置、安全、依赖注入、统一响应。
- **api/**:仅做 router 透传,保持 `/api/*` 路由稳定。
--- ---
@@ -28,9 +27,8 @@
``` ```
backend/ backend/
├── app/ ├── app/
│ ├── api/ # 兼容路由入口,透传到 modules
│ ├── core/ # config、deps、security、response │ ├── core/ # config、deps、security、response
│ ├── modules/ # 业务模块 │ ├── modules/ # 业务模块(路由 + 逻辑)
│ │ ├── videos/ │ │ ├── videos/
│ │ ├── materials/ │ │ ├── materials/
│ │ ├── publish/ │ │ ├── publish/

View File

@@ -13,7 +13,6 @@
``` ```
backend/ backend/
├── app/ ├── app/
│ ├── api/ # 兼容路由入口 (透传到 modules)
│ ├── core/ # 核心配置 (config.py, security.py, response.py) │ ├── core/ # 核心配置 (config.py, security.py, response.py)
│ ├── modules/ # 业务模块 (router/service/workflow/schemas) │ ├── modules/ # 业务模块 (router/service/workflow/schemas)
│ ├── repositories/ # Supabase 数据访问 │ ├── repositories/ # Supabase 数据访问
@@ -148,7 +147,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8006 --reload
1.`app/services/` 下创建新的 Service 类 (如 `NewTTSService`)。 1.`app/services/` 下创建新的 Service 类 (如 `NewTTSService`)。
2. 实现 `generate` 方法,可以使用 subprocess 调用,也可以是 HTTP 请求。 2. 实现 `generate` 方法,可以使用 subprocess 调用,也可以是 HTTP 请求。
3. **重要**: 如果模型占用 GPU请务必使用 `asyncio.Lock` 进行并发控制,防止 OOM。 3. **重要**: 如果模型占用 GPU请务必使用 `asyncio.Lock` 进行并发控制,防止 OOM。
4.`app/api/` 中添加对应的路由调用 4.`app/modules/` 下创建对应模块,添加 router/service/schemas并在 `main.py` 注册路由
### 添加定时任务 ### 添加定时任务

View File

@@ -64,3 +64,27 @@ pm2 restart vigent2-backend
pm2 restart vigent2-latentsync pm2 restart vigent2-latentsync
# Remotion 已自动编译 # 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` 中的闭包陷阱(使用函数式更新),防止覆盖已恢复的状态。

92
Docs/DevLogs/Day21.md Normal file
View File

@@ -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 # 前端持久化修复
```

View File

@@ -15,7 +15,7 @@ frontend/src/
│ └── ... │ └── ...
├── lib/ # 公共工具函数 ├── lib/ # 公共工具函数
│ ├── axios.ts # Axios 实例(含 401/403 拦截器) │ ├── axios.ts # Axios 实例(含 401/403 拦截器)
│ ├── auth.ts # 认证相关函数 │ ├── auth.ts # 认证相关函数(统一使用 axios
│ └── media.ts # API Base / URL / 日期等通用工具 │ └── media.ts # API Base / URL / 日期等通用工具
└── proxy.ts # 路由代理(原 middleware └── proxy.ts # 路由代理(原 middleware
``` ```
@@ -256,6 +256,7 @@ import { formatDate } from '@/shared/lib/media';
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。 - **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
- 避免默认值覆盖用户选择(优先读取已保存值)。 - 避免默认值覆盖用户选择(优先读取已保存值)。
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。 - 优先使用 `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` 发送请求。
--- ---

View File

@@ -17,6 +17,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。 - **作品预览**: 生成完成后直接播放下载(作品预览 + 历史作品)。
- **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。 - **预览优化**: 预览视频 `metadata` 预取,首帧加载更快。
- **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。 - **本地保存**: 文案/标题/偏好由 `useHomePersistence` 统一持久化,刷新后恢复 (Day 14/17)。
- **选择持久化**: 首页/发布页作品选择均使用稳定 `id` 持久化,刷新保持用户选择;新视频生成后自动选中最新 (Day 21)。
### 2. 全自动发布 (`/publish`) [Day 7 新增] ### 2. 全自动发布 (`/publish`) [Day 7 新增]
- **多平台管理**: 统一管理抖音、微信视频号、B站、小红书账号状态。 - **多平台管理**: 统一管理抖音、微信视频号、B站、小红书账号状态。
@@ -26,6 +27,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- Cookie 自动保存与状态同步。 - Cookie 自动保存与状态同步。
- **发布配置**: 设置视频标题、标签、简介。 - **发布配置**: 设置视频标题、标签、简介。
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。 - **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
- **选择持久化**: 使用稳定 `video.id` 持久化选择,刷新保持;新视频生成自动选中最新 (Day 21)。
- **预览兼容**: 签名 URL / 相对路径均可直接预览。 - **预览兼容**: 签名 URL / 相对路径均可直接预览。
- **发布方式**: 仅支持 "立即发布"。 - **发布方式**: 仅支持 "立即发布"。

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log) # ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统 **项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 20 - 代码质量与安全优化) **进度**: 100% (Day 21 - 缺陷修复与持久化回归治理)
**更新时间**: 2026-02-07 **更新时间**: 2026-02-08
--- ---
@@ -10,12 +10,19 @@
> 这里记录了每一天的核心开发内容与 milestone。 > 这里记录了每一天的核心开发内容与 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] **功能性修复**: LatentSync 回退逻辑、任务状态接口认证、User 类型统一。
- [x] **性能优化**: N+1 查询修复、视频上传流式处理、httpx 异步替换、GLM 异步包装。 - [x] **性能优化**: N+1 查询修复、视频上传流式处理、httpx 异步替换、GLM 异步包装。
- [x] **安全修复**: 硬编码 Cookie 配置化、日志敏感信息脱敏、ffprobe 安全调用、CORS 配置化。 - [x] **安全修复**: 硬编码 Cookie 配置化、日志敏感信息脱敏、ffprobe 安全调用、CORS 配置化。
- [x] **配置优化**: 存储路径环境变量化、Remotion 预编译加速、LatentSync 绝对路径。 - [x] **配置优化**: 存储路径环境变量化、Remotion 预编译加速、LatentSync 绝对路径。
- [x] **文档更新**: 更新 Doc_Rules.md 清单,补齐后端与部署文档;更新 SUBTITLE_DEPLOY.md, FRONTEND_DEV.md, implementation_plan.md。 - [x] **文档更新**: 更新 Doc_Rules.md 清单,补齐后端与部署文档;更新 SUBTITLE_DEPLOY.md, FRONTEND_DEV.md, implementation_plan.md。
- [x] **缺陷修复**: 修复 Remotion 路径解析、发布页持久化竞态、首页选中回归、素材闭包陷阱。
### Day 19: 自动发布稳定性与发布体验优化 🚀 ### Day 19: 自动发布稳定性与发布体验优化 🚀
- [x] **抖音发布稳定性**: 上传入口、封面流程、发布重试、登录失效识别与网络失败快速返回全面增强。 - [x] **抖音发布稳定性**: 上传入口、封面流程、发布重试、登录失效识别与网络失败快速返回全面增强。

View File

@@ -15,7 +15,6 @@ DEFAULT_TTS_VOICE=zh-CN-YunxiNeural
# GPU 选择 (0=第一块GPU, 1=第二块GPU) # GPU 选择 (0=第一块GPU, 1=第二块GPU)
LATENTSYNC_GPU_ID=1 LATENTSYNC_GPU_ID=1
# 使用本地模式 (true) 或远程 API (false)
# 使用本地模式 (true) 或远程 API (false) # 使用本地模式 (true) 或远程 API (false)
LATENTSYNC_LOCAL=true LATENTSYNC_LOCAL=true
@@ -67,6 +66,10 @@ ADMIN_PASSWORD=lam1988324
GLM_API_KEY=32440cd3f3444d1f8fe721304acea8bd.YXNLrk7eIJMKcg4t GLM_API_KEY=32440cd3f3444d1f8fe721304acea8bd.YXNLrk7eIJMKcg4t
GLM_MODEL=glm-4.7-flash GLM_MODEL=glm-4.7-flash
# =============== Supabase Storage 本地路径 ===============
# 确保存储卷映射正确,避免硬编码路径
SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub
# =============== 抖音视频下载 Cookie =============== # =============== 抖音视频下载 Cookie ===============
# 用于从抖音 URL 提取视频文案功能,会过期需要定期更新 # 用于从抖音 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 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

View File

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

View File

@@ -1 +0,0 @@
from app.modules.admin.router import router

View File

@@ -1 +0,0 @@
from app.modules.ai.router import router

View File

@@ -1 +0,0 @@
from app.modules.assets.router import router

View File

@@ -1 +0,0 @@
from app.modules.auth.router import router

View File

@@ -1 +0,0 @@
from app.modules.login_helper.router import router

View File

@@ -1 +0,0 @@
from app.modules.materials.router import router

View File

@@ -1 +0,0 @@
from app.modules.publish.router import router

View File

@@ -1 +0,0 @@
from app.modules.ref_audios.router import router

View File

@@ -1 +0,0 @@
from app.modules.tools.router import router

View File

@@ -1 +0,0 @@
from app.modules.videos.router import router

View File

@@ -4,7 +4,17 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.core import config from app.core import config
from app.core.response import error_response 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 from loguru import logger
import os 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.mount("/assets", StaticFiles(directory=str(settings.ASSETS_DIR)), name="assets")
# 注册路由 # 注册路由
app.include_router(materials.router, prefix="/api/materials", tags=["Materials"]) app.include_router(materials_router, prefix="/api/materials", tags=["Materials"])
app.include_router(videos.router, prefix="/api/videos", tags=["Videos"]) app.include_router(videos_router, prefix="/api/videos", tags=["Videos"])
app.include_router(publish.router, prefix="/api/publish", tags=["Publish"]) app.include_router(publish_router, prefix="/api/publish", tags=["Publish"])
app.include_router(login_helper.router, prefix="/api", tags=["LoginHelper"]) app.include_router(login_helper_router, prefix="/api", tags=["LoginHelper"])
app.include_router(auth.router) # /api/auth app.include_router(auth_router) # /api/auth
app.include_router(admin.router) # /api/admin app.include_router(admin_router) # /api/admin
app.include_router(ref_audios.router, prefix="/api/ref-audios", tags=["RefAudios"]) app.include_router(ref_audios_router, prefix="/api/ref-audios", tags=["RefAudios"])
app.include_router(ai.router) # /api/ai app.include_router(ai_router) # /api/ai
app.include_router(tools.router, prefix="/api/tools", tags=["Tools"]) app.include_router(tools_router, prefix="/api/tools", tags=["Tools"])
app.include_router(assets.router, prefix="/api/assets", tags=["Assets"]) app.include_router(assets_router, prefix="/api/assets", tags=["Assets"])
@app.on_event("startup") @app.on_event("startup")

View File

@@ -5,6 +5,7 @@ Remotion 视频渲染服务
import asyncio import asyncio
import json import json
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -114,6 +115,16 @@ class RemotionService:
process.wait() process.wait()
if process.returncode != 0: 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 行 error_msg = "\n".join(output_lines[-20:]) # 最后 20 行
raise RuntimeError(f"Remotion render failed (code {process.returncode}):\n{error_msg}") raise RuntimeError(f"Remotion render failed (code {process.returncode}):\n{error_msg}")

View File

@@ -10,9 +10,9 @@ import os
import shutil import shutil
# Supabase Storage 本地存储根目录(从环境变量读取,支持不同部署环境) # Supabase Storage 本地存储根目录(从环境变量读取,支持不同部署环境)
SUPABASE_STORAGE_LOCAL_PATH = Path( # Supabase Storage 本地存储根目录(从环境变量读取,支持不同部署环境)
os.getenv("SUPABASE_STORAGE_LOCAL_PATH", "/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub") _default_storage_path = "/var/lib/supabase/storage" # 生产环境默认路径
) SUPABASE_STORAGE_LOCAL_PATH = Path(os.getenv("SUPABASE_STORAGE_LOCAL_PATH", _default_storage_path))
class StorageService: class StorageService:
def __init__(self): def __init__(self):

View File

@@ -14,6 +14,7 @@
"next": "16.1.1", "next": "16.1.1",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"sonner": "^2.0.7",
"swr": "^2.3.8" "swr": "^2.3.8"
}, },
"devDependencies": { "devDependencies": {
@@ -6006,6 +6007,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -15,6 +15,7 @@
"next": "16.1.1", "next": "16.1.1",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"sonner": "^2.0.7",
"swr": "^2.3.8" "swr": "^2.3.8"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,10 +1,12 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { getCurrentUser, User } from "@/shared/lib/auth"; import Link from 'next/link';
import api from "@/shared/api/axios"; import { getCurrentUser, User } from "@/shared/lib/auth";
import { ApiResponse, unwrap } from "@/shared/api/types"; import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface UserListItem { interface UserListItem {
id: string; id: string;
@@ -18,7 +20,7 @@ interface UserListItem {
export default function AdminPage() { export default function AdminPage() {
const router = useRouter(); const router = useRouter();
const [currentUser, setCurrentUser] = useState<User | null>(null); const [, setCurrentUser] = useState<User | null>(null);
const [users, setUsers] = useState<UserListItem[]>([]); const [users, setUsers] = useState<UserListItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -28,6 +30,7 @@ export default function AdminPage() {
useEffect(() => { useEffect(() => {
checkAdmin(); checkAdmin();
fetchUsers(); fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const checkAdmin = async () => { const checkAdmin = async () => {
@@ -41,9 +44,9 @@ export default function AdminPage() {
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
const { data: res } = await api.get<ApiResponse<UserListItem[]>>('/api/admin/users'); const { data: res } = await api.get<ApiResponse<UserListItem[]>>('/api/admin/users');
setUsers(unwrap(res)); setUsers(unwrap(res));
} catch (err) { } catch {
setError('获取用户列表失败'); setError('获取用户列表失败');
} finally { } finally {
setLoading(false); setLoading(false);
@@ -57,7 +60,7 @@ export default function AdminPage() {
expires_days: expireDays || null expires_days: expireDays || null
}); });
fetchUsers(); fetchUsers();
} catch (err) { } catch {
// axios interceptor handles 401/403 // axios interceptor handles 401/403
} finally { } finally {
setActivatingId(null); setActivatingId(null);
@@ -70,8 +73,8 @@ export default function AdminPage() {
try { try {
await api.post(`/api/admin/users/${userId}/deactivate`); await api.post(`/api/admin/users/${userId}/deactivate`);
fetchUsers(); fetchUsers();
} catch (err) { } catch {
alert('操作失败'); toast.error('操作失败');
} }
}; };
@@ -106,9 +109,9 @@ export default function AdminPage() {
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-white"></h1> <h1 className="text-3xl font-bold text-white"></h1>
<a href="/" className="text-purple-300 hover:text-purple-200"> <Link href="/" className="text-purple-300 hover:text-purple-200">
</a> </Link>
</div> </div>
{error && ( {error && (

View File

@@ -3,7 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext"; import { AuthProvider } from "@/contexts/AuthContext";
import { TaskProvider } from "@/contexts/TaskContext"; import { TaskProvider } from "@/contexts/TaskContext";
import GlobalTaskIndicator from "@/components/GlobalTaskIndicator";
import { Toaster } from "sonner";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -39,10 +40,18 @@ export default function RootLayout({
> >
<AuthProvider> <AuthProvider>
<TaskProvider> <TaskProvider>
<GlobalTaskIndicator />
{children} {children}
</TaskProvider> </TaskProvider>
</AuthProvider> </AuthProvider>
<Toaster
position="top-center"
richColors
closeButton
toastOptions={{
duration: 3000,
className: "text-sm",
}}
/>
</body> </body>
</html> </html>
); );

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { login } from "@/shared/lib/auth"; import { login } from "@/shared/lib/auth";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
@@ -30,7 +30,7 @@ export default function LoginPage() {
} else { } else {
setError(result.message || '登录失败'); setError(result.message || '登录失败');
} }
} catch (err) { } catch {
setError('网络错误,请稍后重试'); setError('网络错误,请稍后重试');
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -2,10 +2,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { register } from "@/shared/lib/auth"; import { register } from "@/shared/lib/auth";
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter(); useRouter(); // 保留以便后续扩展
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
@@ -43,7 +43,7 @@ export default function RegisterPage() {
} else { } else {
setError(result.message || '注册失败'); setError(result.message || '注册失败');
} }
} catch (err) { } catch {
setError('网络错误,请稍后重试'); setError('网络错误,请稍后重试');
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -44,7 +44,7 @@ export default function AccountSettingsDropdown() {
if (confirm('确定要退出登录吗?')) { if (confirm('确定要退出登录吗?')) {
try { try {
await api.post('/api/auth/logout'); await api.post('/api/auth/logout');
} catch (e) { } } catch { }
window.location.href = '/login'; window.location.href = '/login';
} }
}; };
@@ -76,14 +76,15 @@ export default function AccountSettingsDropdown() {
setTimeout(async () => { setTimeout(async () => {
try { try {
await api.post('/api/auth/logout'); await api.post('/api/auth/logout');
} catch (e) { } } catch { }
window.location.href = '/login'; window.location.href = '/login';
}, 1500); }, 1500);
} else { } else {
setError(res.message || '修改失败'); setError(res.message || '修改失败');
} }
} catch (err: any) { } catch (err: unknown) {
setError(err.response?.data?.message || '修改失败,请重试'); const axiosErr = err as { response?: { data?: { message?: string } } };
setError(axiosErr.response?.data?.message || '修改失败,请重试');
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -2,11 +2,14 @@
import { useTask } from "@/contexts/TaskContext"; import { useTask } from "@/contexts/TaskContext";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation";
export default function GlobalTaskIndicator() { export default function GlobalTaskIndicator() {
const { currentTask, isGenerating } = useTask(); const { currentTask, isGenerating } = useTask();
const pathname = usePathname();
if (!isGenerating) return null; // 首页已有专门的进度条展示,因此在首页不显示顶部全局进度条
if (!isGenerating || pathname === "/") return null;
return ( return (
<div className="fixed top-0 left-0 right-0 z-50 bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg"> <div className="fixed top-0 left-0 right-0 z-50 bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg">

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useEffect, useCallback } from "react";
import api from "@/shared/api/axios"; import { Loader2 } from "lucide-react";
import { ApiResponse, unwrap } from "@/shared/api/types"; import { useScriptExtraction } from "./script-extraction/useScriptExtraction";
interface ScriptExtractionModalProps { interface ScriptExtractionModalProps {
isOpen: boolean; isOpen: boolean;
@@ -13,177 +13,66 @@ interface ScriptExtractionModalProps {
export default function ScriptExtractionModal({ export default function ScriptExtractionModal({
isOpen, isOpen,
onClose, onClose,
onApply onApply,
}: ScriptExtractionModalProps) { }: ScriptExtractionModalProps) {
const [isLoading, setIsLoading] = useState(false); const {
const [script, setScript] = useState(""); isLoading,
const [rewrittenScript, setRewrittenScript] = useState(""); script,
const [error, setError] = useState<string | null>(null); rewrittenScript,
const [doRewrite, setDoRewrite] = useState(true); error,
const [step, setStep] = useState<'config' | 'processing' | 'result'>('config'); doRewrite,
const [dragActive, setDragActive] = useState(false); step,
const [selectedFile, setSelectedFile] = useState<File | null>(null); dragActive,
selectedFile,
activeTab,
inputUrl,
setDoRewrite,
setActiveTab,
setInputUrl,
handleDrag,
handleDrop,
handleFileChange,
handleExtract,
copyToClipboard,
resetToConfig,
clearSelectedFile,
clearInputUrl,
} = useScriptExtraction({ isOpen });
// New state for URL mode // 快捷键ESC 关闭Enter 提交(仅在 config 步骤)
const [activeTab, setActiveTab] = useState<'file' | 'url'>('url'); const canExtract = (activeTab === "file" && selectedFile) || (activeTab === "url" && inputUrl.trim());
const [inputUrl, setInputUrl] = useState("");
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(() => { useEffect(() => {
if (isOpen) { if (!isOpen) return;
setStep('config'); document.addEventListener("keydown", handleKeyDown);
setScript(""); return () => document.removeEventListener("keydown", handleKeyDown);
setRewrittenScript(""); }, [isOpen, handleKeyDown]);
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<HTMLInputElement>) => {
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<ApiResponse<{ original_script: string; rewritten_script?: string }>>(
'/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<HTMLDivElement>(null);
// const handleBackdropClick = (e: React.MouseEvent) => {
// if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
// onClose();
// }
// };
if (!isOpen) return null; if (!isOpen) return null;
const handleApplyAndClose = (text: string) => {
onApply?.(text);
onClose();
};
const handleExtractNext = () => {
resetToConfig();
clearSelectedFile();
clearInputUrl();
};
return ( return (
<div <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200" <div className="bg-[#1a1a1a] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col shadow-2xl">
>
<div
// ref={modalRef}
className="bg-[#1a1a1a] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col shadow-2xl"
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-white/5"> <div className="flex items-center justify-between p-4 border-b border-white/10 bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> <h3 className="text-lg font-semibold text-white flex items-center gap-2">
@@ -199,25 +88,24 @@ export default function ScriptExtractionModal({
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
{step === 'config' && ( {step === "config" && (
<div className="space-y-6"> <div className="space-y-6">
{/* Tabs */} {/* Tabs */}
<div className="flex p-1 bg-white/5 rounded-xl border border-white/10"> <div className="flex p-1 bg-white/5 rounded-xl border border-white/10">
<button <button
onClick={() => setActiveTab('url')} onClick={() => setActiveTab("url")}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'url' className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === "url"
? 'bg-purple-600 text-white shadow-lg' ? "bg-purple-600 text-white shadow-lg"
: 'text-gray-400 hover:text-white hover:bg-white/5' : "text-gray-400 hover:text-white hover:bg-white/5"
}`} }`}
> >
🔗 🔗
</button> </button>
<button <button
onClick={() => setActiveTab('file')} onClick={() => setActiveTab("file")}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'file' className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === "file"
? 'bg-purple-600 text-white shadow-lg' ? "bg-purple-600 text-white shadow-lg"
: 'text-gray-400 hover:text-white hover:bg-white/5' : "text-gray-400 hover:text-white hover:bg-white/5"
}`} }`}
> >
📂 📂
@@ -225,7 +113,7 @@ export default function ScriptExtractionModal({
</div> </div>
{/* URL Input Area */} {/* URL Input Area */}
{activeTab === 'url' && ( {activeTab === "url" && (
<div className="space-y-2 py-4"> <div className="space-y-2 py-4">
<div className="relative"> <div className="relative">
<input <input
@@ -237,119 +125,150 @@ export default function ScriptExtractionModal({
/> />
{inputUrl && ( {inputUrl && (
<button <button
onClick={() => setInputUrl("")} onClick={clearInputUrl}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white p-1" className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
> >
</button> </button>
)} )}
</div> </div>
<p className="text-xs text-gray-400 px-1"> <p className="text-xs text-gray-500 pl-1">
B站等主流平台分享链接 B站
</p> </p>
</div> </div>
)} )}
{/* File Upload Area */} {/* File Upload Area */}
{activeTab === 'file' && ( {activeTab === "file" && (
<div <div
className={` className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${dragActive
relative border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer ? "border-purple-500 bg-purple-500/10"
${dragActive ? 'border-purple-500 bg-purple-500/10' : 'border-white/20 hover:border-white/40 hover:bg-white/5'} : "border-white/10 hover:border-white/20"
${selectedFile ? 'bg-purple-900/10 border-purple-500/50' : ''} }`}
`}
onDragEnter={handleDrag} onDragEnter={handleDrag}
onDragLeave={handleDrag} onDragLeave={handleDrag}
onDragOver={handleDrag} onDragOver={handleDrag}
onDrop={handleDrop} onDrop={handleDrop}
> >
<input
type="file"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileChange}
accept=".mp4,.mov,.avi,.mp3,.wav,.m4a"
/>
{selectedFile ? ( {selectedFile ? (
<div className="flex flex-col items-center"> <div className="space-y-2">
<div className="text-4xl mb-2">📄</div> <p className="text-white">{selectedFile.name}</p>
<div className="font-medium text-white break-all max-w-xs">{selectedFile.name}</div> <p className="text-sm text-gray-400">
<div className="text-sm text-gray-400 mt-1">{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB</div> {(selectedFile.size / 1024 / 1024).toFixed(2)} MB
<div className="mt-4 text-xs text-purple-400"></div> </p>
<button
onClick={clearSelectedFile}
className="text-xs text-purple-400 hover:text-purple-300"
>
</button>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center"> <div className="space-y-4">
<div className="text-4xl mb-2">📤</div> <div className="text-4xl">📁</div>
<div className="font-medium text-white"></div> <p className="text-gray-400">
<div className="text-sm text-gray-400 mt-2"> MP4, MOV, MP3, WAV </div> /
<label className="text-purple-400 hover:text-purple-300 cursor-pointer">
<input
type="file"
accept=".mp4,.mov,.avi,.mp3,.wav,.m4a"
onChange={handleFileChange}
className="hidden"
/>
</label>
</p>
<p className="text-xs text-gray-500">
MP4, MOV, AVI, MP3, WAV, M4A
</p>
</div> </div>
)} )}
</div> </div>
)} )}
{/* Options */} {/* Options */}
<div className="bg-white/5 rounded-xl p-4 border border-white/10"> <div className="flex items-center gap-3 bg-white/5 rounded-xl p-4 border border-white/10">
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={doRewrite} checked={doRewrite}
onChange={e => setDoRewrite(e.target.checked)} onChange={(e) => setDoRewrite(e.target.checked)}
className="w-5 h-5 accent-purple-600 rounded" className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
/> />
<div> <span className="text-sm text-gray-300">
<div className="text-white font-medium"> AI 稿</div> AI
<div className="text-xs text-gray-400">稿</div> </span>
</div>
</label> </label>
</div> </div>
{/* Error */}
{error && ( {error && (
<div className="p-3 bg-red-500/20 text-red-200 rounded-lg text-sm text-center"> <div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
{error} <p className="text-red-400 text-sm">{error}</p>
</div> </div>
)} )}
<div className="flex justify-center pt-2"> {/* Action Button */}
<div className="flex gap-3 pt-2">
<button
onClick={onClose}
className="flex-1 py-3 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
>
</button>
<button <button
onClick={handleExtract} onClick={handleExtract}
className="w-full sm:w-auto px-10 py-3 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-bold hover:shadow-lg hover:from-purple-500 hover:to-pink-500 transition-all transform hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed" disabled={
disabled={activeTab === 'file' ? !selectedFile : !inputUrl.trim()} (activeTab === "file" && !selectedFile) ||
(activeTab === "url" && !inputUrl.trim()) ||
isLoading
}
className="flex-1 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition-all font-medium shadow-lg flex items-center justify-center gap-2"
> >
{activeTab === 'url' ? '🔗 解析并提取' : '🚀 开始提取'} {isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : null}
</button> </button>
</div> </div>
</div> </div>
)} )}
{step === 'processing' && ( {step === "processing" && (
<div className="flex flex-col items-center justify-center py-20"> <div className="flex flex-col items-center justify-center py-20">
<div className="relative w-20 h-20 mb-6"> <div className="relative w-20 h-20 mb-6">
<div className="absolute inset-0 border-4 border-purple-500/30 rounded-full"></div> <div className="absolute inset-0 border-4 border-purple-500/30 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-purple-500 rounded-full animate-spin"></div> <div className="absolute inset-0 border-4 border-t-purple-500 rounded-full animate-spin"></div>
</div> </div>
<h4 className="text-xl font-medium text-white mb-2">...</h4> <h4 className="text-xl font-medium text-white mb-2">
...
</h4>
<p className="text-sm text-gray-400 text-center max-w-sm px-4"> <p className="text-sm text-gray-400 text-center max-w-sm px-4">
{activeTab === 'url' && "正在下载视频..."}<br /> {activeTab === "url" && "正在下载视频..."}
{doRewrite ? "正在进行语音识别和 AI 智能改写..." : "正在进行语音识别..."}<br /> <br />
<span className="opacity-75"></span> {doRewrite
? "正在进行语音识别和 AI 智能改写..."
: "正在进行语音识别..."}
<br />
<span className="opacity-75">
</span>
</p> </p>
</div> </div>
)} )}
{step === 'result' && ( {step === "result" && (
<div className="space-y-6"> <div className="space-y-6">
{rewrittenScript && ( {rewrittenScript && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h4 className="font-semibold text-purple-300 flex items-center gap-2"> <h4 className="font-semibold text-purple-300 flex items-center gap-2">
AI 稿 <span className="text-xs font-normal text-purple-400/70">()</span> AI 稿{" "}
<span className="text-xs font-normal text-purple-400/70">
()
</span>
</h4> </h4>
{onApply && ( {onApply && (
<button <button
onClick={() => { onClick={() => handleApplyAndClose(rewrittenScript)}
onApply(rewrittenScript);
onClose();
}}
className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1 shadow-sm" className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1 shadow-sm"
> >
📥 📥
@@ -377,10 +296,7 @@ export default function ScriptExtractionModal({
</h4> </h4>
{onApply && ( {onApply && (
<button <button
onClick={() => { onClick={() => handleApplyAndClose(script)}
onApply(script);
onClose();
}}
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1" className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
> >
📥 📥
@@ -402,14 +318,7 @@ export default function ScriptExtractionModal({
<div className="flex justify-center pt-4"> <div className="flex justify-center pt-4">
<button <button
onClick={() => { onClick={handleExtractNext}
setStep('config');
setScript("");
setRewrittenScript("");
setSelectedFile(null);
setInputUrl("");
// Keep current tab active
}}
className="px-6 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors" className="px-6 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
> >

View File

@@ -0,0 +1,210 @@
import { useState, useEffect, useCallback } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
export type ExtractionStep = "config" | "processing" | "result";
export type InputTab = "file" | "url";
const VALID_FILE_TYPES = [".mp4", ".mov", ".avi", ".mp3", ".wav", ".m4a"];
interface UseScriptExtractionOptions {
isOpen: boolean;
}
export const useScriptExtraction = ({ isOpen }: UseScriptExtractionOptions) => {
const [isLoading, setIsLoading] = useState(false);
const [script, setScript] = useState("");
const [rewrittenScript, setRewrittenScript] = useState("");
const [error, setError] = useState<string | null>(null);
const [doRewrite, setDoRewrite] = useState(true);
const [step, setStep] = useState<ExtractionStep>("config");
const [dragActive, setDragActive] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [activeTab, setActiveTab] = useState<InputTab>("url");
const [inputUrl, setInputUrl] = useState("");
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setStep("config");
setScript("");
setRewrittenScript("");
setError(null);
setIsLoading(false);
setSelectedFile(null);
setInputUrl("");
setActiveTab("url");
}
}, [isOpen]);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
}, []);
const handleFile = useCallback((file: File) => {
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf("."));
if (!VALID_FILE_TYPES.includes(ext)) {
setError(`不支持的文件格式 ${ext},请上传视频或音频文件`);
return;
}
setSelectedFile(file);
setError(null);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files?.[0]) {
handleFile(e.dataTransfer.files[0]);
}
},
[handleFile]
);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.[0]) {
handleFile(e.target.files[0]);
}
},
[handleFile]
);
const handleExtract = useCallback(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<
ApiResponse<{ original_script: string; rewritten_script?: string }>
>("/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: unknown) {
console.error(err);
const axiosErr = err as {
response?: { data?: { message?: string } };
message?: string;
};
const msg =
axiosErr.response?.data?.message || axiosErr.message || "请求失败";
setError(msg);
setStep("config");
} finally {
setIsLoading(false);
}
}, [activeTab, selectedFile, inputUrl, doRewrite]);
const copyToClipboard = useCallback((text: string) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => {
toast.success("已复制到剪贴板");
})
.catch(() => {
fallbackCopyTextToClipboard(text);
});
} else {
fallbackCopyTextToClipboard(text);
}
}, []);
const fallbackCopyTextToClipboard = (text: string) => {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand("copy");
if (successful) {
toast.success("已复制到剪贴板");
} else {
toast.error("复制失败,请手动复制");
}
} catch {
toast.error("复制失败,请手动复制");
}
document.body.removeChild(textArea);
};
const resetToConfig = useCallback(() => {
setStep("config");
}, []);
const clearSelectedFile = useCallback(() => {
setSelectedFile(null);
}, []);
const clearInputUrl = useCallback(() => {
setInputUrl("");
}, []);
return {
// State
isLoading,
script,
rewrittenScript,
error,
doRewrite,
step,
dragActive,
selectedFile,
activeTab,
inputUrl,
// Setters
setDoRewrite,
setActiveTab,
setInputUrl,
// Handlers
handleDrag,
handleDrop,
handleFileChange,
handleExtract,
copyToClipboard,
resetToConfig,
clearSelectedFile,
clearInputUrl,
};
};

View File

@@ -87,7 +87,9 @@ export function TaskProvider({ children }: { children: ReactNode }) {
const savedTaskId = localStorage.getItem(taskKey); const savedTaskId = localStorage.getItem(taskKey);
if (savedTaskId) { if (savedTaskId) {
console.log("[TaskContext] 恢复任务:", savedTaskId); console.log("[TaskContext] 恢复任务:", savedTaskId);
// eslint-disable-next-line react-hooks/set-state-in-effect
setTaskId(savedTaskId); setTaskId(savedTaskId);
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsGenerating(true); setIsGenerating(true);
} }
} }

View File

@@ -9,13 +9,15 @@ export interface BgmItem {
} }
interface UseBgmOptions { interface UseBgmOptions {
storageKey: string;
selectedBgmId: string; selectedBgmId: string;
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>; setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
} }
export const useBgm = ({ export const useBgm = ({
storageKey,
// selectedBgmId 用于参数类型推断,不在此 hook 内部直接使用
// eslint-disable-next-line @typescript-eslint/no-unused-vars
selectedBgmId, selectedBgmId,
setSelectedBgmId, setSelectedBgmId,
}: UseBgmOptions) => { }: UseBgmOptions) => {
@@ -32,21 +34,20 @@ export const useBgm = ({
const items: BgmItem[] = Array.isArray(payload.bgm) ? payload.bgm : []; const items: BgmItem[] = Array.isArray(payload.bgm) ? payload.bgm : [];
setBgmList(items); setBgmList(items);
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
setSelectedBgmId((prev) => { setSelectedBgmId((prev) => {
if (prev && items.some((item) => item.id === prev)) return prev; if (prev && items.some((item) => item.id === prev)) return prev;
if (savedBgmId && items.some((item) => item.id === savedBgmId)) return savedBgmId;
return items[0]?.id || ""; return items[0]?.id || "";
}); });
} catch (error: any) { } catch (error: unknown) {
const message = error?.response?.data?.message || error?.message || '加载失败'; const axiosErr = error as { response?: { data?: { message?: string } }; message?: string };
const message = axiosErr?.response?.data?.message || axiosErr?.message || '加载失败';
setBgmError(message); setBgmError(message);
setBgmList([]); setBgmList([]);
console.error("获取背景音乐失败:", error); console.error("获取背景音乐失败:", error);
} finally { } finally {
setBgmLoading(false); setBgmLoading(false);
} }
}, [setSelectedBgmId, storageKey]); }, [setSelectedBgmId]);
return { return {
bgmList, bgmList,

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import api from "@/shared/api/axios"; import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types"; import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface GeneratedVideo { interface GeneratedVideo {
id: string; id: string;
@@ -11,7 +12,7 @@ interface GeneratedVideo {
} }
interface UseGeneratedVideosOptions { interface UseGeneratedVideosOptions {
storageKey: string;
selectedVideoId: string | null; selectedVideoId: string | null;
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>; setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
setGeneratedVideo: React.Dispatch<React.SetStateAction<string | null>>; setGeneratedVideo: React.Dispatch<React.SetStateAction<string | null>>;
@@ -19,7 +20,7 @@ interface UseGeneratedVideosOptions {
} }
export const useGeneratedVideos = ({ export const useGeneratedVideos = ({
storageKey,
selectedVideoId, selectedVideoId,
setSelectedVideoId, setSelectedVideoId,
setGeneratedVideo, setGeneratedVideo,
@@ -36,32 +37,42 @@ export const useGeneratedVideos = ({
const videos: GeneratedVideo[] = payload.videos || []; const videos: GeneratedVideo[] = payload.videos || [];
setGeneratedVideos(videos); setGeneratedVideos(videos);
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`); // 只在明确指定 preferVideoId 时才自动选中
const currentId = preferVideoId || selectedVideoId || savedSelectedVideoId || null; // "__latest__" 表示选中最新的(第一个),用于新视频生成完成后
let nextId: string | null = null; // 其他值表示选中指定 ID 的视频
let nextUrl: string | null = null; // 不传则不设置选中项,由 useHomePersistence 恢复
if (preferVideoId && videos.length > 0) {
if (currentId) { if (preferVideoId === "__latest__") {
const found = videos.find(v => v.id === currentId); setSelectedVideoId(videos[0].id);
if (found) { setGeneratedVideo(resolveMediaUrl(videos[0].path));
nextId = found.id; } else {
nextUrl = resolveMediaUrl(found.path); const found = videos.find(v => v.id === preferVideoId);
if (found) {
setSelectedVideoId(found.id);
setGeneratedVideo(resolveMediaUrl(found.path));
}
} }
} }
if (!nextId && videos.length > 0) {
nextId = videos[0].id;
nextUrl = resolveMediaUrl(videos[0].path);
}
if (nextId) {
setSelectedVideoId(nextId);
setGeneratedVideo(nextUrl);
}
} catch (error) { } catch (error) {
console.error("获取历史视频失败:", error); console.error("获取历史视频失败:", error);
} }
}, [resolveMediaUrl, selectedVideoId, setGeneratedVideo, setSelectedVideoId, storageKey]); }, [resolveMediaUrl, setGeneratedVideo, setSelectedVideoId]);
// 【核心修复】当 selectedVideoId 变化时(例如从持久化恢复),自动同步 generatedVideo (URL)
// 之前的逻辑只在 fetch 时设置,导致外部恢复 ID 后 URL 不同步
useEffect(() => {
if (!selectedVideoId || generatedVideos.length === 0) {
// 如果没有选中 ID或者列表为空不要轻易置空 URL除非明确需要
// 这里保持现状,由 fetchGeneratedVideos 或 deleteVideo 处理置空
return;
}
const video = generatedVideos.find(v => v.id === selectedVideoId);
if (video) {
const url = resolveMediaUrl(video.path);
setGeneratedVideo(url);
}
}, [selectedVideoId, generatedVideos, resolveMediaUrl, setGeneratedVideo]);
const deleteVideo = useCallback(async (videoId: string) => { const deleteVideo = useCallback(async (videoId: string) => {
if (!confirm("确定要删除这个视频吗?")) return; if (!confirm("确定要删除这个视频吗?")) return;
@@ -73,7 +84,7 @@ export const useGeneratedVideos = ({
} }
fetchGeneratedVideos(); fetchGeneratedVideos();
} catch (error) { } catch (error) {
alert("删除失败: " + error); toast.error("删除失败: " + error);
} }
}, [fetchGeneratedVideos, selectedVideoId, setGeneratedVideo, setSelectedVideoId]); }, [fetchGeneratedVideos, selectedVideoId, setGeneratedVideo, setSelectedVideoId]);

View File

@@ -13,6 +13,9 @@ import { clampTitle } from "@/shared/lib/title";
import { useTitleInput } from "@/shared/hooks/useTitleInput"; import { useTitleInput } from "@/shared/hooks/useTitleInput";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useTask } from "@/contexts/TaskContext"; import { useTask } from "@/contexts/TaskContext";
import { toast } from "sonner";
import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch";
import { PublishAccount } from "@/shared/types/publish";
import { useBgm } from "@/features/home/model/useBgm"; import { useBgm } from "@/features/home/model/useBgm";
import { useGeneratedVideos } from "@/features/home/model/useGeneratedVideos"; import { useGeneratedVideos } from "@/features/home/model/useGeneratedVideos";
import { useHomePersistence } from "@/features/home/model/useHomePersistence"; import { useHomePersistence } from "@/features/home/model/useHomePersistence";
@@ -30,26 +33,7 @@ const VOICES = [
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" }, { id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
]; ];
const PUBLISH_PREFETCH_KEY = "vigent_publish_prefetch_v1";
const PUBLISH_PREFETCH_TTL = 2 * 60 * 1000;
interface PublishAccount {
platform: string;
name: string;
logged_in: boolean;
enabled: boolean;
}
interface PublishVideo {
name: string;
path: string;
}
interface PublishPrefetchCache {
ts: number;
accounts?: PublishAccount[];
videos?: PublishVideo[];
}
const FIXED_REF_TEXT = const FIXED_REF_TEXT =
"其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。"; "其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。";
@@ -105,6 +89,7 @@ export const useHomeController = () => {
// 使用全局任务状态 // 使用全局任务状态
const { currentTask, isGenerating, startTask } = useTask(); const { currentTask, isGenerating, startTask } = useTask();
const prevIsGenerating = useRef(isGenerating);
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null); const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null); const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
@@ -166,8 +151,8 @@ export const useHomeController = () => {
await api.put(`/api/ref-audios/${encodeURIComponent(audioId)}`, { new_name: editName }); await api.put(`/api/ref-audios/${encodeURIComponent(audioId)}`, { new_name: editName });
setEditingAudioId(null); setEditingAudioId(null);
fetchRefAudios(); // 刷新列表 fetchRefAudios(); // 刷新列表
} catch (err: any) { } catch (err: unknown) {
alert("重命名失败: " + err); toast.error("重命名失败: " + String(err));
} }
}; };
@@ -200,9 +185,10 @@ export const useHomeController = () => {
setEditingMaterialId(null); setEditingMaterialId(null);
setEditMaterialName(""); setEditMaterialName("");
fetchMaterials(); fetchMaterials();
} catch (err: any) { } catch (err: unknown) {
const errorMsg = err.response?.data?.message || err.message || String(err); const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
alert(`重命名失败: ${errorMsg}`); const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
toast.error(`重命名失败: ${errorMsg}`);
} }
}; };
@@ -225,26 +211,8 @@ export const useHomeController = () => {
// 获取存储 key 的前缀(登录用户使用 userId未登录使用 guest // 获取存储 key 的前缀(登录用户使用 userId未登录使用 guest
const storageKey = userId || "guest"; const storageKey = userId || "guest";
const readPublishPrefetch = () => { // 使用共用的发布预加载 hook
if (typeof window === "undefined") return null; const { updatePrefetch: updatePublishPrefetch } = usePublishPrefetch();
const raw = sessionStorage.getItem(PUBLISH_PREFETCH_KEY);
if (!raw) return null;
try {
const cache = JSON.parse(raw) as PublishPrefetchCache;
if (!cache?.ts) return null;
if (Date.now() - cache.ts > PUBLISH_PREFETCH_TTL) return null;
return cache;
} catch {
return null;
}
};
const updatePublishPrefetch = (patch: Partial<PublishPrefetchCache>) => {
if (typeof window === "undefined") return;
const existing = readPublishPrefetch() || { ts: Date.now() };
const next = { ...existing, ...patch, ts: Date.now() };
sessionStorage.setItem(PUBLISH_PREFETCH_KEY, JSON.stringify(next));
};
const { const {
materials, materials,
@@ -270,7 +238,7 @@ export const useHomeController = () => {
refreshTitleStyles, refreshTitleStyles,
} = useTitleSubtitleStyles({ } = useTitleSubtitleStyles({
isAuthLoading, isAuthLoading,
storageKey,
setSelectedSubtitleStyleId, setSelectedSubtitleStyleId,
setSelectedTitleStyleId, setSelectedTitleStyleId,
}); });
@@ -296,7 +264,7 @@ export const useHomeController = () => {
bgmError, bgmError,
fetchBgmList, fetchBgmList,
} = useBgm({ } = useBgm({
storageKey,
selectedBgmId, selectedBgmId,
setSelectedBgmId, setSelectedBgmId,
}); });
@@ -319,7 +287,7 @@ export const useHomeController = () => {
fetchGeneratedVideos, fetchGeneratedVideos,
deleteVideo, deleteVideo,
} = useGeneratedVideos({ } = useGeneratedVideos({
storageKey,
selectedVideoId, selectedVideoId,
setSelectedVideoId, setSelectedVideoId,
setGeneratedVideo, setGeneratedVideo,
@@ -347,15 +315,18 @@ export const useHomeController = () => {
return () => { return () => {
active = false; active = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthLoading, userId]); }, [isAuthLoading, userId]);
useEffect(() => { useEffect(() => {
if (generatedVideos.length === 0) return; if (generatedVideos.length === 0) return;
const prefetched = generatedVideos.map((video) => ({ const prefetched = generatedVideos.map((video) => ({
id: video.id,
name: formatDate(video.created_at) + ` (${video.size_mb.toFixed(1)}MB)`, name: formatDate(video.created_at) + ` (${video.size_mb.toFixed(1)}MB)`,
path: video.path.startsWith("/") ? video.path.slice(1) : video.path, path: video.path.startsWith("/") ? video.path.slice(1) : video.path,
})); }));
updatePublishPrefetch({ videos: prefetched }); updatePublishPrefetch({ videos: prefetched });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generatedVideos]); }, [generatedVideos]);
const { isRestored } = useHomePersistence({ const { isRestored } = useHomePersistence({
@@ -417,8 +388,21 @@ export const useHomeController = () => {
refreshTitleStyles(), refreshTitleStyles(),
fetchBgmList(), fetchBgmList(),
]); ]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthLoading]); }, [isAuthLoading]);
// 监听任务完成,自动刷新视频列表并选中最新
useEffect(() => {
if (prevIsGenerating.current && !isGenerating) {
if (currentTask?.status === "completed") {
void fetchGeneratedVideos("__latest__");
} else {
void fetchGeneratedVideos();
}
}
prevIsGenerating.current = isGenerating;
}, [isGenerating, currentTask, fetchGeneratedVideos]);
useEffect(() => { useEffect(() => {
const material = materials.find((item) => item.id === selectedMaterial); const material = materials.find((item) => item.id === selectedMaterial);
if (!material?.path) { if (!material?.path) {
@@ -502,16 +486,8 @@ export const useHomeController = () => {
} }
}, [titleStyles, selectedTitleStyleId, titleSizeLocked]); }, [titleStyles, selectedTitleStyleId, titleSizeLocked]);
useEffect(() => { // 移除重复的 BGM 持久化恢复逻辑 (已统一移动到 useHomePersistence 中)
if (!enableBgm || selectedBgmId || bgmList.length === 0) return; // useEffect(() => { ... })
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
const savedItem = savedBgmId && bgmList.find((item) => item.id === savedBgmId);
if (savedItem) {
setSelectedBgmId(savedBgmId);
return;
}
setSelectedBgmId(bgmList[0].id);
}, [enableBgm, selectedBgmId, bgmList, storageKey, setSelectedBgmId]);
useEffect(() => { useEffect(() => {
if (!selectedBgmId) return; if (!selectedBgmId) return;
@@ -530,6 +506,23 @@ export const useHomeController = () => {
} }
}, [selectedMaterial, materials]); }, [selectedMaterial, materials]);
// 【修复】历史视频默认选中逻辑
// 当持久化恢复完成,且列表加载完毕,如果没选中任何视频,默认选中第一个
useEffect(() => {
if (isRestored && generatedVideos.length > 0 && !selectedVideoId) {
const firstId = generatedVideos[0].id;
setSelectedVideoId(firstId);
setGeneratedVideo(resolveMediaUrl(generatedVideos[0].path));
}
}, [isRestored, generatedVideos, selectedVideoId, setSelectedVideoId, setGeneratedVideo, resolveMediaUrl]);
// 【修复】BGM 默认选中逻辑
useEffect(() => {
if (isRestored && bgmList.length > 0 && !selectedBgmId && enableBgm) {
setSelectedBgmId(bgmList[0].id);
}
}, [isRestored, bgmList, selectedBgmId, enableBgm, setSelectedBgmId]);
useEffect(() => { useEffect(() => {
if (!selectedVideoId) return; if (!selectedVideoId) return;
const target = videoItemRefs.current[selectedVideoId]; const target = videoItemRefs.current[selectedVideoId];
@@ -593,7 +586,7 @@ export const useHomeController = () => {
setRecordingTime((prev) => prev + 1); setRecordingTime((prev) => prev + 1);
}, 1000); }, 1000);
} catch (err) { } catch (err) {
alert("无法访问麦克风,请检查权限设置"); toast.error("无法访问麦克风,请检查权限设置");
console.error(err); console.error(err);
} }
}; };
@@ -631,7 +624,7 @@ export const useHomeController = () => {
// AI 生成标题和标签 // AI 生成标题和标签
const handleGenerateMeta = async () => { const handleGenerateMeta = async () => {
if (!text.trim()) { if (!text.trim()) {
alert("请先输入口播文案"); toast.error("请先输入口播文案");
return; return;
} }
@@ -649,10 +642,11 @@ export const useHomeController = () => {
// 同步到发布页 localStorage // 同步到发布页 localStorage
localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || [])); localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(payload.tags || []));
} catch (err: any) { } catch (err: unknown) {
console.error("AI generate meta failed:", err); console.error("AI generate meta failed:", err);
const errorMsg = err.response?.data?.message || err.message || String(err); const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
alert(`AI 生成失败: ${errorMsg}`); const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
toast.error(`AI 生成失败: ${errorMsg}`);
} finally { } finally {
setIsGeneratingMeta(false); setIsGeneratingMeta(false);
} }
@@ -661,20 +655,20 @@ export const useHomeController = () => {
// 生成视频 // 生成视频
const handleGenerate = async () => { const handleGenerate = async () => {
if (!selectedMaterial || !text.trim()) { if (!selectedMaterial || !text.trim()) {
alert("请先选择素材并填写文案"); toast.error("请先选择素材并填写文案");
return; return;
} }
// 声音克隆模式校验 // 声音克隆模式校验
if (ttsMode === "voiceclone") { if (ttsMode === "voiceclone") {
if (!selectedRefAudio) { if (!selectedRefAudio) {
alert("请选择或上传参考音频"); toast.error("请选择或上传参考音频");
return; return;
} }
} }
if (enableBgm && !selectedBgmId) { if (enableBgm && !selectedBgmId) {
alert("请选择背景音乐"); toast.error("请选择背景音乐");
return; return;
} }
@@ -684,12 +678,12 @@ export const useHomeController = () => {
// 查找选中的素材对象以获取路径 // 查找选中的素材对象以获取路径
const materialObj = materials.find((m) => m.id === selectedMaterial); const materialObj = materials.find((m) => m.id === selectedMaterial);
if (!materialObj) { if (!materialObj) {
alert("素材数据异常"); toast.error("素材数据异常");
return; return;
} }
// 构建请求参数 // 构建请求参数
const payload: Record<string, any> = { const payload: Record<string, unknown> = {
material_path: materialObj.path, material_path: materialObj.path,
text: text, text: text,
tts_mode: ttsMode, tts_mode: ttsMode,

View File

@@ -132,6 +132,7 @@ export const useHomePersistence = ({
if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true'); if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true');
if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId); if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId);
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsRestored(true); setIsRestored(true);
}, [ }, [
isAuthLoading, isAuthLoading,

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import api from "@/shared/api/axios"; import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types"; import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface Material { interface Material {
id: string; id: string;
@@ -40,19 +41,20 @@ export const useMaterials = ({
setMaterials(nextMaterials); setMaterials(nextMaterials);
setLastMaterialCount(nextMaterials.length); setLastMaterialCount(nextMaterials.length);
const nextSelected = nextMaterials.find((item: Material) => item.id === selectedMaterial)?.id setSelectedMaterial((prev) => {
|| nextMaterials[0]?.id // 如果当前选中的素材在列表中依然存在,保持选中
|| ""; const exists = nextMaterials.some((item) => item.id === prev);
if (nextSelected !== selectedMaterial) { if (exists) return prev;
setSelectedMaterial(nextSelected); // 否则默认选中第一个
} return nextMaterials[0]?.id || "";
});
} catch (error) { } catch (error) {
console.error("获取素材失败:", error); console.error("获取素材失败:", error);
setFetchError(String(error)); setFetchError(String(error));
} finally { } finally {
setIsFetching(false); setIsFetching(false);
} }
}, [selectedMaterial, setSelectedMaterial]); }, [setSelectedMaterial]);
const deleteMaterial = useCallback(async (materialId: string) => { const deleteMaterial = useCallback(async (materialId: string) => {
if (!confirm("确定要删除这个素材吗?")) return; if (!confirm("确定要删除这个素材吗?")) return;
@@ -63,7 +65,7 @@ export const useMaterials = ({
setSelectedMaterial(""); setSelectedMaterial("");
} }
} catch (error) { } catch (error) {
alert("删除失败: " + error); toast.error("删除失败: " + error);
} }
}, [fetchMaterials, selectedMaterial, setSelectedMaterial]); }, [fetchMaterials, selectedMaterial, setSelectedMaterial]);
@@ -99,10 +101,11 @@ export const useMaterials = ({
setUploadProgress(100); setUploadProgress(100);
setIsUploading(false); setIsUploading(false);
fetchMaterials(); fetchMaterials();
} catch (err: any) { } catch (err: unknown) {
console.error("Upload failed:", err); console.error("Upload failed:", err);
setIsUploading(false); setIsUploading(false);
const errorMsg = err.response?.data?.message || err.message || String(err); const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
setUploadError(`上传失败: ${errorMsg}`); setUploadError(`上传失败: ${errorMsg}`);
} }

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { BgmItem } from "@/features/home/model/useBgm"; import type { BgmItem } from "@/features/home/model/useBgm";
import { toast } from "sonner";
interface RefAudio { interface RefAudio {
id: string; id: string;
@@ -64,12 +65,12 @@ export const useMediaPlayers = ({
const audioUrl = resolveMediaUrl(audio.path) || audio.path; const audioUrl = resolveMediaUrl(audio.path) || audio.path;
if (!audioUrl) { if (!audioUrl) {
alert("无法播放该参考音频"); toast.error("无法播放该参考音频");
return; return;
} }
const player = new Audio(audioUrl); const player = new Audio(audioUrl);
player.onended = () => setPlayingAudioId(null); player.onended = () => setPlayingAudioId(null);
player.play().catch((err) => alert("播放失败: " + err)); player.play().catch((err) => toast.error("播放失败: " + err));
audioPlayerRef.current = player; audioPlayerRef.current = player;
setPlayingAudioId(audio.id); setPlayingAudioId(audio.id);
}, [playingAudioId, resolveMediaUrl, stopAudio, stopBgm]); }, [playingAudioId, resolveMediaUrl, stopAudio, stopBgm]);
@@ -81,7 +82,7 @@ export const useMediaPlayers = ({
const bgmUrl = resolveBgmUrl(bgm.id); const bgmUrl = resolveBgmUrl(bgm.id);
if (!bgmUrl) { if (!bgmUrl) {
alert("无法播放该背景音乐"); toast.error("无法播放该背景音乐");
return; return;
} }
@@ -96,7 +97,7 @@ export const useMediaPlayers = ({
const player = new Audio(bgmUrl); const player = new Audio(bgmUrl);
player.volume = Math.max(0, Math.min(bgmVolume, 1)); player.volume = Math.max(0, Math.min(bgmVolume, 1));
player.onended = () => setPlayingBgmId(null); player.onended = () => setPlayingBgmId(null);
player.play().catch((err) => alert("播放失败: " + err)); player.play().catch((err) => toast.error("播放失败: " + err));
bgmPlayerRef.current = player; bgmPlayerRef.current = player;
setPlayingBgmId(bgm.id); setPlayingBgmId(bgm.id);
}, [bgmVolume, playingBgmId, resolveBgmUrl, setEnableBgm, setSelectedBgmId, stopAudio, stopBgm]); }, [bgmVolume, playingBgmId, resolveBgmUrl, setEnableBgm, setSelectedBgmId, stopAudio, stopBgm]);

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import api from "@/shared/api/axios"; import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types"; import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface RefAudio { interface RefAudio {
id: string; id: string;
@@ -60,10 +61,11 @@ export const useRefAudios = ({
setSelectedRefAudio(payload); setSelectedRefAudio(payload);
setRefText(payload.ref_text); setRefText(payload.ref_text);
setIsUploadingRef(false); setIsUploadingRef(false);
} catch (err: any) { } catch (err: unknown) {
console.error("Upload ref audio failed:", err); console.error("Upload ref audio failed:", err);
setIsUploadingRef(false); setIsUploadingRef(false);
const errorMsg = err.response?.data?.message || err.message || String(err); const axiosErr = err as { response?: { data?: { message?: string } }; message?: string };
const errorMsg = axiosErr.response?.data?.message || axiosErr.message || String(err);
setUploadRefError(`上传失败: ${errorMsg}`); setUploadRefError(`上传失败: ${errorMsg}`);
} }
}, [fetchRefAudios, fixedRefText, setRefText, setSelectedRefAudio]); }, [fetchRefAudios, fixedRefText, setRefText, setSelectedRefAudio]);
@@ -78,7 +80,7 @@ export const useRefAudios = ({
setRefText(''); setRefText('');
} }
} catch (error) { } catch (error) {
alert("删除失败: " + error); toast.error("删除失败: " + error);
} }
}, [fetchRefAudios, selectedRefAudio, setRefText, setSelectedRefAudio]); }, [fetchRefAudios, selectedRefAudio, setRefText, setSelectedRefAudio]);

View File

@@ -34,14 +34,14 @@ export interface TitleStyleOption {
interface UseTitleSubtitleStylesOptions { interface UseTitleSubtitleStylesOptions {
isAuthLoading: boolean; isAuthLoading: boolean;
storageKey: string;
setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>; setSelectedSubtitleStyleId: React.Dispatch<React.SetStateAction<string>>;
setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>; setSelectedTitleStyleId: React.Dispatch<React.SetStateAction<string>>;
} }
export const useTitleSubtitleStyles = ({ export const useTitleSubtitleStyles = ({
isAuthLoading, isAuthLoading,
storageKey,
setSelectedSubtitleStyleId, setSelectedSubtitleStyleId,
setSelectedTitleStyleId, setSelectedTitleStyleId,
}: UseTitleSubtitleStylesOptions) => { }: UseTitleSubtitleStylesOptions) => {
@@ -57,17 +57,15 @@ export const useTitleSubtitleStyles = ({
const styles: SubtitleStyleOption[] = payload.styles || []; const styles: SubtitleStyleOption[] = payload.styles || [];
setSubtitleStyles(styles); setSubtitleStyles(styles);
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`);
setSelectedSubtitleStyleId((prev) => { setSelectedSubtitleStyleId((prev) => {
if (prev && styles.some((s) => s.id === prev)) return prev; if (prev && styles.some((s) => s.id === prev)) return prev;
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
const defaultStyle = styles.find((s) => s.is_default) || styles[0]; const defaultStyle = styles.find((s) => s.is_default) || styles[0];
return defaultStyle?.id || ""; return defaultStyle?.id || "";
}); });
} catch (error) { } catch (error) {
console.error("获取字幕样式失败:", error); console.error("获取字幕样式失败:", error);
} }
}, [setSelectedSubtitleStyleId, storageKey]); }, [setSelectedSubtitleStyleId]);
const refreshTitleStyles = useCallback(async () => { const refreshTitleStyles = useCallback(async () => {
try { try {
@@ -78,21 +76,21 @@ export const useTitleSubtitleStyles = ({
const styles: TitleStyleOption[] = payload.styles || []; const styles: TitleStyleOption[] = payload.styles || [];
setTitleStyles(styles); setTitleStyles(styles);
const savedStyleId = localStorage.getItem(`vigent_${storageKey}_titleStyle`);
setSelectedTitleStyleId((prev) => { setSelectedTitleStyleId((prev) => {
if (prev && styles.some((s) => s.id === prev)) return prev; if (prev && styles.some((s) => s.id === prev)) return prev;
if (savedStyleId && styles.some((s) => s.id === savedStyleId)) return savedStyleId;
const defaultStyle = styles.find((s) => s.is_default) || styles[0]; const defaultStyle = styles.find((s) => s.is_default) || styles[0];
return defaultStyle?.id || ""; return defaultStyle?.id || "";
}); });
} catch (error) { } catch (error) {
console.error("获取标题样式失败:", error); console.error("获取标题样式失败:", error);
} }
}, [setSelectedTitleStyleId, storageKey]); }, [setSelectedTitleStyleId]);
useEffect(() => { useEffect(() => {
if (isAuthLoading) return; if (isAuthLoading) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshSubtitleStyles(); refreshSubtitleStyles();
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshTitleStyles(); refreshTitleStyles();
}, [isAuthLoading, refreshSubtitleStyles, refreshTitleStyles]); }, [isAuthLoading, refreshSubtitleStyles, refreshTitleStyles]);

View File

@@ -70,6 +70,7 @@ export function RefAudioPanel({
useEffect(() => { useEffect(() => {
if (!recordedBlob) { if (!recordedBlob) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setRecordedUrl(null); setRecordedUrl(null);
return; return;
} }
@@ -162,8 +163,8 @@ export function RefAudioPanel({
className="w-full bg-black/50 text-white text-xs px-1 py-0.5 rounded border border-purple-500 focus:outline-none" className="w-full bg-black/50 text-white text-xs px-1 py-0.5 rounded border border-purple-500 focus:outline-none"
autoFocus autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') onSaveEditing(audio.id, e as any); if (e.key === 'Enter') onSaveEditing(audio.id, e as unknown as MouseEvent);
if (e.key === 'Escape') onCancelEditing(e as any); if (e.key === 'Escape') onCancelEditing(e as unknown as MouseEvent);
}} }}
/> />
<button onClick={(e) => onSaveEditing(audio.id, e)} className="text-green-400 hover:text-green-300 text-xs"> <button onClick={(e) => onSaveEditing(audio.id, e)} className="text-green-400 hover:text-green-300 text-xs">

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import useSWR from "swr"; import useSWR from "swr";
import api from "@/shared/api/axios"; import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types"; import { ApiResponse, unwrap } from "@/shared/api/types";
@@ -6,38 +6,17 @@ import { formatDate, getApiBaseUrl, isAbsoluteUrl, resolveMediaUrl } from "@/sha
import { clampTitle } from "@/shared/lib/title"; import { clampTitle } from "@/shared/lib/title";
import { useTitleInput } from "@/shared/hooks/useTitleInput"; import { useTitleInput } from "@/shared/hooks/useTitleInput";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useTask } from "@/contexts/TaskContext";
interface Account { import { toast } from "sonner";
platform: string; import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch";
name: string; import {
logged_in: boolean; PublishAccount as Account,
enabled: boolean; PublishVideo as Video,
} PublishResult,
} from "@/shared/types/publish";
interface Video {
name: string;
path: string;
}
export interface PublishResult {
platform: string;
success: boolean;
message: string;
url?: string | null;
screenshot_url?: string;
}
const fetcher = (url: string) => const fetcher = (url: string) =>
api.get<ApiResponse<any>>(url).then((res) => unwrap(res.data)); api.get<ApiResponse<{ success?: boolean }>>(url).then((res) => unwrap(res.data));
const PREFETCH_KEY = "vigent_publish_prefetch_v1";
const PREFETCH_TTL = 2 * 60 * 1000;
type PublishPrefetchCache = {
ts: number;
accounts?: Account[];
videos?: Video[];
};
export const usePublishController = () => { export const usePublishController = () => {
const apiBase = getApiBaseUrl(); const apiBase = getApiBaseUrl();
@@ -58,36 +37,23 @@ export const usePublishController = () => {
const [qrPlatform, setQrPlatform] = useState<string | null>(null); const [qrPlatform, setQrPlatform] = useState<string | null>(null);
const [isLoadingQR, setIsLoadingQR] = useState(false); const [isLoadingQR, setIsLoadingQR] = useState(false);
// 使用全局认证状态
const { userId, isLoading: isAuthLoading } = useAuth(); const { userId, isLoading: isAuthLoading } = useAuth();
// 是否已从 localStorage 恢复完成 const { isGenerating } = useTask();
const [isRestored, setIsRestored] = useState(false); const prevIsGenerating = useRef(isGenerating);
const { readPrefetch, updatePrefetch } = usePublishPrefetch();
// ---- 视频选择持久化:用 ref 而非 state彻底避免 effect 竞态 ----
const videoRestoredRef = useRef(false);
const titleRestoredRef = useRef(false);
const getStorageKey = useCallback(() => userId || "guest", [userId]);
const titleInput = useTitleInput({ const titleInput = useTitleInput({
value: title, value: title,
onChange: setTitle, onChange: setTitle,
}); });
const readPrefetch = () => { // ---- 数据加载 ----
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 = (patch: Partial<PublishPrefetchCache>) => {
if (typeof window === "undefined") return;
const existing = readPrefetch() || { ts: Date.now() };
const next = { ...existing, ...patch, ts: Date.now() };
sessionStorage.setItem(PREFETCH_KEY, JSON.stringify(next));
};
const fetchAccounts = async () => { const fetchAccounts = async () => {
try { try {
@@ -102,21 +68,20 @@ export const usePublishController = () => {
} }
}; };
const fetchVideos = async () => { const fetchVideos = async (autoSelectLatest = false) => {
try { try {
const { data: res } = await api.get<ApiResponse<{ videos: any[] }>>( const { data: res } = await api.get<ApiResponse<{ videos: any[] }>>(
"/api/videos/generated" "/api/videos/generated"
); );
const payload = unwrap(res); const payload = unwrap(res);
const nextVideos = (payload.videos || []).map((v: any) => ({ const nextVideos = (payload.videos || []).map((v: any) => ({
id: v.id as string,
name: formatDate(v.created_at) + ` (${v.size_mb.toFixed(1)}MB)`, name: formatDate(v.created_at) + ` (${v.size_mb.toFixed(1)}MB)`,
path: v.path.startsWith("/") ? v.path.slice(1) : v.path, path: v.path.startsWith("/") ? v.path.slice(1) : v.path,
})); }));
setVideos(nextVideos); setVideos(nextVideos);
if (nextVideos.length > 0) { if (nextVideos.length > 0 && autoSelectLatest) {
setSelectedVideo(nextVideos[0].path); setSelectedVideo(nextVideos[0].id);
} }
updatePrefetch({ videos: nextVideos }); updatePrefetch({ videos: nextVideos });
} catch (error) { } catch (error) {
@@ -124,91 +89,133 @@ export const usePublishController = () => {
} }
}; };
// 初始加载
useEffect(() => { useEffect(() => {
const cache = readPrefetch(); const cache = readPrefetch();
if (cache?.accounts) { if (cache?.accounts) { setAccounts(cache.accounts); setIsAccountsLoading(false); }
setAccounts(cache.accounts); if (cache?.videos) { setVideos(cache.videos); setIsVideosLoading(false); }
setIsAccountsLoading(false);
}
if (cache?.videos) {
setVideos(cache.videos);
if (!selectedVideo && cache.videos.length > 0) {
setSelectedVideo(cache.videos[0].path);
}
setIsVideosLoading(false);
}
if (!cache?.accounts) setIsAccountsLoading(true); if (!cache?.accounts) setIsAccountsLoading(true);
if (!cache?.videos) setIsVideosLoading(true); if (!cache?.videos) setIsVideosLoading(true);
let active = true; let active = true;
void Promise.allSettled([ void Promise.allSettled([fetchAccounts(), fetchVideos(false)]).finally(() => {
fetchAccounts(),
fetchVideos(),
]).finally(() => {
if (!active) return; if (!active) return;
setIsAccountsLoading(false); setIsAccountsLoading(false);
setIsVideosLoading(false); setIsVideosLoading(false);
}); });
return () => { return () => { active = false; };
active = false;
};
}, []); }, []);
// ---- 视频选择恢复(唯一一个 effect条件极简 ----
// 等 auth 完成 + videos 有数据 → 恢复一次,之后再也不跑
useEffect(() => {
if (isAuthLoading || videos.length === 0 || videoRestoredRef.current) return;
videoRestoredRef.current = true;
const key = getStorageKey();
const saved = localStorage.getItem(`vigent_${key}_publish_selected_video`);
if (saved && videos.some(v => v.id === saved)) {
setSelectedVideo(saved);
} else {
setSelectedVideo(videos[0].id);
}
}, [isAuthLoading, videos, getStorageKey]);
// ---- 视频选择保存 ----
useEffect(() => {
if (!videoRestoredRef.current || !selectedVideo || isAuthLoading) return;
localStorage.setItem(`vigent_${getStorageKey()}_publish_selected_video`, selectedVideo);
}, [selectedVideo, isAuthLoading, getStorageKey]);
// ---- 任务完成 → 自动选中最新 ----
useEffect(() => {
if (prevIsGenerating.current && !isGenerating) {
void fetchVideos(true);
}
prevIsGenerating.current = isGenerating;
}, [isGenerating]);
// ---- 标题/标签恢复与保存 ----
useEffect(() => {
if (isAuthLoading || titleRestoredRef.current) return;
titleRestoredRef.current = true;
const key = getStorageKey();
const savedTitle = localStorage.getItem(`vigent_${key}_publish_title`);
const savedTags = localStorage.getItem(`vigent_${key}_publish_tags`);
if (savedTitle) setTitle(clampTitle(savedTitle));
if (savedTags) {
try {
const parsed = JSON.parse(savedTags);
setTags(Array.isArray(parsed) ? parsed.join(", ") : savedTags);
} catch { setTags(savedTags); }
}
}, [isAuthLoading, getStorageKey]);
useEffect(() => {
if (!titleRestoredRef.current || isAuthLoading) return;
const key = getStorageKey();
const t = setTimeout(() => localStorage.setItem(`vigent_${key}_publish_title`, title), 300);
return () => clearTimeout(t);
}, [title, isAuthLoading, getStorageKey]);
useEffect(() => {
if (!titleRestoredRef.current || isAuthLoading) return;
const key = getStorageKey();
const t = setTimeout(() => localStorage.setItem(`vigent_${key}_publish_tags`, tags), 300);
return () => clearTimeout(t);
}, [tags, isAuthLoading, getStorageKey]);
// ---- 页面滚动 ----
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if ("scrollRestoration" in window.history) { if ("scrollRestoration" in window.history) window.history.scrollRestoration = "manual";
window.history.scrollRestoration = "manual";
}
window.scrollTo({ top: 0, left: 0, behavior: "auto" }); window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}, []); }, []);
// 获取存储 key 的前缀(登录用户使用 userId未登录使用 guest // ---- 发布防误操作 ----
const storageKey = userId || "guest";
// 从 localStorage 恢复用户输入(等待认证完成后)
useEffect(() => { useEffect(() => {
if (isAuthLoading) return; if (!isPublishing) return;
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "发布进行中,请勿刷新页面";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isPublishing]);
// 从 localStorage 恢复用户输入(带用户隔离,未登录用户使用 guest // ---- SWR Polling for Login Status ----
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`); useSWR(
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`); qrPlatform ? `${apiBase}/api/publish/login/status/${qrPlatform}` : null,
fetcher,
if (savedTitle) setTitle(clampTitle(savedTitle)); {
if (savedTags) { refreshInterval: 2000,
// 兼容 JSON 数组格式AI 生成)和字符串格式(手动输入) onSuccess: (data) => {
try { if (data.success) {
const parsed = JSON.parse(savedTags); setQrCodeImage(null);
if (Array.isArray(parsed)) { setQrPlatform(null);
setTags(parsed.join(", ")); toast.success("✅ 登录成功!");
} else { fetchAccounts();
setTags(savedTags);
} }
} catch { },
setTags(savedTags);
}
} }
);
// 恢复完成后才允许保存
setIsRestored(true);
}, [storageKey, isAuthLoading]);
// 保存用户输入到 localStorage恢复完成后才保存未登录用户也可保存
useEffect(() => {
if (!isRestored) return;
const timeout = setTimeout(() => {
localStorage.setItem(`vigent_${storageKey}_publish_title`, title);
}, 300);
return () => clearTimeout(timeout);
}, [title, storageKey, isRestored]);
useEffect(() => { useEffect(() => {
if (!isRestored) return; let timer: NodeJS.Timeout;
const timeout = setTimeout(() => { if (qrPlatform) {
localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags); timer = setTimeout(() => {
}, 300); if (qrPlatform) {
return () => clearTimeout(timeout); setQrPlatform(null);
}, [tags, storageKey, isRestored]); setQrCodeImage(null);
toast.error("登录超时,请重试");
}
}, 120000);
}
return () => clearTimeout(timer);
}, [qrPlatform]);
// ---- 操作函数 ----
const togglePlatform = (platform: string) => { const togglePlatform = (platform: string) => {
if (selectedPlatforms.includes(platform)) { if (selectedPlatforms.includes(platform)) {
@@ -220,96 +227,40 @@ export const usePublishController = () => {
const handlePublish = async () => { const handlePublish = async () => {
if (!selectedVideo || !title || selectedPlatforms.length === 0) { if (!selectedVideo || !title || selectedPlatforms.length === 0) {
alert("请选择视频、填写标题并选择至少一个平台"); toast.error("请选择视频、填写标题并选择至少一个平台");
return;
}
const video = videos.find(v => v.id === selectedVideo);
if (!video) {
toast.error("未找到选中的视频");
return; return;
} }
setIsPublishing(true); setIsPublishing(true);
setPublishResults([]); setPublishResults([]);
const tagList = tags.split(/[,\s]+/).filter((t) => t.trim()); const tagList = tags.split(/[,\s]+/).filter((t) => t.trim());
for (const platform of selectedPlatforms) { for (const platform of selectedPlatforms) {
try { try {
const { data: res } = await api.post<ApiResponse<any>>("/api/publish", { const { data: res } = await api.post<ApiResponse<any>>("/api/publish", {
video_path: selectedVideo, video_path: video.path, platform, title, tags: tagList, description: "",
platform,
title,
tags: tagList,
description: "",
}); });
const result = unwrap(res); const result = unwrap(res);
const screenshotUrl = const screenshotUrl = typeof result.screenshot_url === "string"
typeof result.screenshot_url === "string" ? resolveMediaUrl(result.screenshot_url) || result.screenshot_url : undefined;
? resolveMediaUrl(result.screenshot_url) || result.screenshot_url setPublishResults((prev) => [...prev, {
: undefined;
const nextResult: PublishResult = {
platform: result.platform || platform, platform: result.platform || platform,
success: Boolean(result.success), success: Boolean(result.success),
message: result.message || "", message: result.message || "",
url: result.url, url: result.url,
screenshot_url: screenshotUrl, screenshot_url: screenshotUrl,
}; }]);
setPublishResults((prev) => [...prev, nextResult]);
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.message || String(error); const message = error.response?.data?.message || String(error);
setPublishResults((prev) => [ setPublishResults((prev) => [...prev, { platform, success: false, message }]);
...prev,
{ platform, success: false, message },
]);
} }
} }
setIsPublishing(false); setIsPublishing(false);
}; };
useEffect(() => {
if (!isPublishing) return;
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "发布进行中,请勿刷新页面";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [isPublishing]);
// SWR Polling for Login Status
useSWR(
qrPlatform ? `${apiBase}/api/publish/login/status/${qrPlatform}` : null,
fetcher,
{
refreshInterval: 2000,
onSuccess: (data) => {
if (data.success) {
setQrCodeImage(null);
setQrPlatform(null);
alert("✅ 登录成功!");
fetchAccounts();
}
},
}
);
// Timeout logic for QR code (business logic: stop after 2 mins)
useEffect(() => {
let timer: NodeJS.Timeout;
if (qrPlatform) {
timer = setTimeout(() => {
if (qrPlatform) {
setQrPlatform(null);
setQrCodeImage(null);
alert("登录超时,请重试");
}
}, 120000);
}
return () => clearTimeout(timer);
}, [qrPlatform]);
const handleLogin = async (platform: string) => { const handleLogin = async (platform: string) => {
setIsLoadingQR(true); setIsLoadingQR(true);
setQrPlatform(platform); setQrPlatform(platform);
@@ -317,16 +268,15 @@ export const usePublishController = () => {
try { try {
const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/login/${platform}`); const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/login/${platform}`);
const result = unwrap(res); const result = unwrap(res);
if (result.success && result.qr_code) { if (result.success && result.qr_code) {
setQrCodeImage(result.qr_code); setQrCodeImage(result.qr_code);
} else { } else {
setQrPlatform(null); setQrPlatform(null);
alert(result.message || "登录失败"); toast.error(result.message || "登录失败");
} }
} catch (error: any) { } catch (error: any) {
setQrPlatform(null); setQrPlatform(null);
alert(`登录失败: ${error.response?.data?.message || error.message}`); toast.error(`登录失败: ${error.response?.data?.message || error.message}`);
} finally { } finally {
setIsLoadingQR(false); setIsLoadingQR(false);
} }
@@ -337,14 +287,10 @@ export const usePublishController = () => {
try { try {
const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/logout/${platform}`); const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/logout/${platform}`);
const result = unwrap(res); const result = unwrap(res);
if (result.success) { if (result.success) { toast.success("已注销"); fetchAccounts(); }
alert("注销"); else { toast.error(result.message || "注销失败"); }
fetchAccounts();
} else {
alert(result.message || "注销失败");
}
} catch (error: any) { } catch (error: any) {
alert(`注销失败: ${error.response?.data?.message || error.message}`); toast.error(`注销失败: ${error.response?.data?.message || error.message}`);
} }
}; };
@@ -361,12 +307,10 @@ export const usePublishController = () => {
return videos.filter((v) => v.name.toLowerCase().includes(query)); return videos.filter((v) => v.name.toLowerCase().includes(query));
}, [videos, videoFilter]); }, [videos, videoFilter]);
const handlePreviewVideo = (path: string) => { const handlePreviewVideo = (videoId: string) => {
const previewPath = isAbsoluteUrl(path) const video = videos.find(v => v.id === videoId);
? path if (!video) return;
: path.startsWith("/") const previewPath = isAbsoluteUrl(video.path) ? video.path : video.path.startsWith("/") ? video.path : `/${video.path}`;
? path
: `/${path}`;
setPreviewVideoUrl(resolveMediaUrl(previewPath) || previewPath); setPreviewVideoUrl(resolveMediaUrl(previewPath) || previewPath);
}; };
@@ -376,36 +320,13 @@ export const usePublishController = () => {
}; };
return { return {
apiBase, apiBase, accounts, videos, isAccountsLoading, isVideosLoading,
accounts, selectedVideo, setSelectedVideo, videoFilter, setVideoFilter,
videos, previewVideoUrl, setPreviewVideoUrl, selectedPlatforms,
isAccountsLoading, title, titleInput, tags, setTags,
isVideosLoading, isPublishing, publishResults, qrCodeImage, qrPlatform, isLoadingQR,
selectedVideo, fetchAccounts, fetchVideos, togglePlatform, handlePublish,
setSelectedVideo, handleLogin, handleLogout, platformIcons, filteredVideos,
videoFilter, handlePreviewVideo, closeQrModal,
setVideoFilter,
previewVideoUrl,
setPreviewVideoUrl,
selectedPlatforms,
title,
titleInput,
tags,
setTags,
isPublishing,
publishResults,
qrCodeImage,
qrPlatform,
isLoadingQR,
fetchAccounts,
fetchVideos,
togglePlatform,
handlePublish,
handleLogin,
handleLogout,
platformIcons,
filteredVideos,
handlePreviewVideo,
closeQrModal,
}; };
}; };

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import VideoPreviewModal from "@/components/VideoPreviewModal"; import VideoPreviewModal from "@/components/VideoPreviewModal";
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown"; import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
import { usePublishController } from "@/features/publish/model/usePublishController"; import { usePublishController } from "@/features/publish/model/usePublishController";
@@ -11,6 +12,7 @@ import {
QrCode, QrCode,
Search, Search,
Eye, Eye,
Loader2,
} from "lucide-react"; } from "lucide-react";
export function PublishPage() { export function PublishPage() {
@@ -63,10 +65,13 @@ export function PublishPage() {
</div> </div>
) : qrCodeImage ? ( ) : qrCodeImage ? (
<> <>
<img <Image
src={`data:image/png;base64,${qrCodeImage}`} src={`data:image/png;base64,${qrCodeImage}`}
alt="QR Code" alt="QR Code"
width={280}
height={280}
className="w-full h-auto" className="w-full h-auto"
unoptimized
/> />
<p className="text-center text-gray-600 mt-4"> <p className="text-center text-gray-600 mt-4">
使 使
@@ -145,9 +150,11 @@ export function PublishPage() {
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{platformIcons[account.platform] ? ( {platformIcons[account.platform] ? (
<img <Image
src={platformIcons[account.platform].src} src={platformIcons[account.platform].src}
alt={platformIcons[account.platform].alt} alt={platformIcons[account.platform].alt}
width={28}
height={28}
className="h-7 w-7" className="h-7 w-7"
/> />
) : ( ) : (
@@ -239,9 +246,9 @@ export function PublishPage() {
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar" style={{ contentVisibility: "auto" }}> <div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar" style={{ contentVisibility: "auto" }}>
{filteredVideos.map((v) => ( {filteredVideos.map((v) => (
<div <div
key={v.path} key={v.id}
onClick={() => setSelectedVideo(v.path)} onClick={() => setSelectedVideo(v.id)}
className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.path 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-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30" : "border-white/10 bg-white/5 hover:border-white/30"
}`} }`}
@@ -253,7 +260,7 @@ export function PublishPage() {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handlePreviewVideo(v.path); handlePreviewVideo(v.id);
}} }}
onMouseEnter={() => { onMouseEnter={() => {
const src = v.path.startsWith("/") ? v.path : `/${v.path}`; const src = v.path.startsWith("/") ? v.path : `/${v.path}`;
@@ -269,7 +276,7 @@ export function PublishPage() {
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</button> </button>
{selectedVideo === v.path && ( {selectedVideo === v.id && (
<span className="text-xs text-purple-300"></span> <span className="text-xs text-purple-300"></span>
)} )}
</div> </div>
@@ -331,9 +338,11 @@ export function PublishPage() {
> >
<span className="block mb-1"> <span className="block mb-1">
{platformIcons[account.platform] ? ( {platformIcons[account.platform] ? (
<img <Image
src={platformIcons[account.platform].src} src={platformIcons[account.platform].src}
alt={platformIcons[account.platform].alt} alt={platformIcons[account.platform].alt}
width={28}
height={28}
className="h-7 w-7 mx-auto" className="h-7 w-7 mx-auto"
/> />
) : ( ) : (
@@ -352,7 +361,12 @@ export function PublishPage() {
disabled={isPublishing || selectedPlatforms.length === 0} 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" 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 ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
...
</span>
) : "立即发布"}
</button> </button>
{/* 发布结果 */} {/* 发布结果 */}
@@ -367,15 +381,17 @@ export function PublishPage() {
}`} }`}
> >
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
{platformIcons[result.platform] ? ( {platformIcons[result.platform] ? (
<img <Image
src={platformIcons[result.platform].src} src={platformIcons[result.platform].src}
alt={platformIcons[result.platform].alt} alt={platformIcons[result.platform].alt}
className="h-5 w-5" width={20}
/> height={20}
) : ( className="h-5 w-5"
<span className="text-lg">🌐</span> />
)} ) : (
<span className="text-lg">🌐</span>
)}
<span className={`font-medium ${result.success ? "text-green-400" : "text-red-400"}`}> <span className={`font-medium ${result.success ? "text-green-400" : "text-red-400"}`}>
{result.success ? "发布成功" : "发布失败"} {result.success ? "发布成功" : "发布失败"}
</span> </span>
@@ -390,10 +406,13 @@ export function PublishPage() {
rel="noreferrer" rel="noreferrer"
className="block" className="block"
> >
<img <Image
src={result.screenshot_url} src={result.screenshot_url}
alt="发布成功截图" alt="发布成功截图"
width={400}
height={300}
className="w-full rounded-md border border-white/10" className="w-full rounded-md border border-white/10"
unoptimized
/> />
</a> </a>
</div> </div>

View File

@@ -37,7 +37,7 @@ api.interceptors.response.use(
// 调用 logout API 清除 HttpOnly cookie // 调用 logout API 清除 HttpOnly cookie
try { try {
await fetch('/api/auth/logout', { method: 'POST' }); await fetch('/api/auth/logout', { method: 'POST' });
} catch (e) { } catch {
// 忽略错误 // 忽略错误
} }

View File

@@ -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<PublishPrefetchCache>) => {
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,
};
};

View File

@@ -1,15 +1,13 @@
/** /**
* 认证工具函数 * 认证工具函数
* 统一使用 axios 实例,与其他 API 调用保持一致的错误处理
*/ */
import api from "@/shared/api/axios";
import { User } from "@/shared/types/user"; import { User } from "@/shared/types/user";
// Re-export User 类型以保持向后兼容 // Re-export User 类型以保持向后兼容
export type { User }; export type { User };
const API_BASE = typeof window === 'undefined'
? (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8006')
: '';
export interface AuthResponse { export interface AuthResponse {
success: boolean; success: boolean;
message: string; message: string;
@@ -27,58 +25,38 @@ interface ApiResponse<T> {
* 用户注册 * 用户注册
*/ */
export async function register(phone: string, password: string, username?: string): Promise<AuthResponse> { export async function register(phone: string, password: string, username?: string): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/register`, { const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/register', {
method: 'POST', phone, password, username
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ phone, password, username })
}); });
const payload = await res.json(); return { success: payload.success, message: payload.message };
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
} }
/** /**
* 用户登录 * 用户登录
*/ */
export async function login(phone: string, password: string): Promise<AuthResponse> { export async function login(phone: string, password: string): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/login`, { const { data: payload } = await api.post<ApiResponse<{ user?: User }>>('/api/auth/login', {
method: 'POST', phone, password
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ phone, password })
}); });
const payload = await res.json(); return { success: payload.success, message: payload.message, user: payload.data?.user };
const data = payload as ApiResponse<{ user?: User }>;
return { success: data.success, message: data.message, user: data.data?.user };
} }
/** /**
* 用户登出 * 用户登出
*/ */
export async function logout(): Promise<AuthResponse> { export async function logout(): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/logout`, { const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/logout');
method: 'POST', return { success: payload.success, message: payload.message };
credentials: 'include'
});
const payload = await res.json();
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
} }
/** /**
* 修改密码 * 修改密码
*/ */
export async function changePassword(oldPassword: string, newPassword: string): Promise<AuthResponse> { export async function changePassword(oldPassword: string, newPassword: string): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/change-password`, { const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/change-password', {
method: 'POST', old_password: oldPassword, new_password: newPassword
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword })
}); });
const payload = await res.json(); return { success: payload.success, message: payload.message };
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
} }
/** /**
@@ -86,13 +64,8 @@ export async function changePassword(oldPassword: string, newPassword: string):
*/ */
export async function getCurrentUser(): Promise<User | null> { export async function getCurrentUser(): Promise<User | null> {
try { try {
const res = await fetch(`${API_BASE}/api/auth/me`, { const { data: payload } = await api.get<ApiResponse<User>>('/api/auth/me');
credentials: 'include' return payload.data || null;
});
if (!res.ok) return null;
const payload = await res.json();
const data = payload as ApiResponse<User>;
return data.data || null;
} catch { } catch {
return null; return null;
} }

View File

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

View File

@@ -134,8 +134,14 @@ async function main() {
// Bundle the Remotion project // Bundle the Remotion project
console.log('Bundling 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({ const bundleLocation = await bundle({
entryPoint: path.resolve(__dirname, './src/index.ts'), entryPoint,
webpackOverride: (config) => config, webpackOverride: (config) => config,
publicDir, publicDir,
}); });