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

View File

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

View File

@@ -64,3 +64,27 @@ pm2 restart vigent2-backend
pm2 restart vigent2-latentsync
# Remotion 已自动编译
```
### 🐛 缺陷修复与回归治理 (17:30)
#### 严重缺陷修复
- [x] **BUG-1**: Remotion 渲染脚本路径解析错误 (导致标题字幕丢失)
- *原因*: `render.js` 预编译后使用了 `__dirname`,在 `dist` 目录下寻找源码失败。
- *修复*: 修改 `render.ts` 使用 `process.cwd()` 动态解析路径,并重新编译。
- [x] **BUG-2**: 发布页视频选择持久化失效 (Auth 异步竞态)
- *原因*: 页面加载时 `useAuth` 尚未返回用户 ID导致使用 `guest` Key 读取不到记录,随后被默认值覆盖。
- *修复*: 引入 `isVideoRestored` 状态机,强制等待 Auth 完成且 Video 列表加载完毕后,才执行恢复逻辑。
#### 回归问题治理
- [x] **REG-1**: 首页历史作品 ID 恢复后内容不显示
- *原因*: 持久化模块恢复了 ID`useGeneratedVideos` 未监听 ID 变化同步 URL。
- *修复*: 新增 `useEffect` 监听 `selectedVideoId` 变化并同步 `generatedVideo` URL。
- [x] **REG-2**: 首页/发布页“默认选中第一个”逻辑丢失
- *原因*: 重构移除旧逻辑后,新用户或无缓存用户进入页面无默认选中。
- *修复*: 在 `isRestored` 且无选中时,增加兜底逻辑自动选中列表第一项。
- [x] **REF-1**: 持久化逻辑全站收敛
- *优化*: 清理 `useBgm`, `useGeneratedVideos`, `useTitleSubtitleStyles` 中的冗余 `localStorage` 读取。
- *优化*: 修复 `useMaterials` 中的闭包陷阱(使用函数式更新),防止覆盖已恢复的状态。

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/ # 公共工具函数
│ ├── axios.ts # Axios 实例(含 401/403 拦截器)
│ ├── auth.ts # 认证相关函数
│ ├── auth.ts # 认证相关函数(统一使用 axios
│ └── media.ts # API Base / URL / 日期等通用工具
└── proxy.ts # 路由代理(原 middleware
```
@@ -256,6 +256,7 @@ import { formatDate } from '@/shared/lib/media';
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
- 避免默认值覆盖用户选择(优先读取已保存值)。
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
- **禁止使用签名 URL 作为持久化标识**Supabase Storage 签名 URL 每次请求都变化,必须使用后端返回的稳定 `id` 字段。
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
---
@@ -273,7 +274,8 @@ import { formatDate } from '@/shared/lib/media';
## 发布页交互规则
- 发布按钮在未选择任何平台时禁用
- 仅保留立即发布,不再提供定时发布 UI/参数
- 仅保留"立即发布",不再提供定时发布 UI/参数
- **作品选择持久化**:使用 `video.id`(稳定标识)而非 `video.path`(签名 URL进行选择、比较和 localStorage 存储。发布时根据 `id` 查找对应 `path` 发送请求。
---

View File

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

View File

@@ -1,8 +1,8 @@
# ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 20 - 代码质量与安全优化)
**更新时间**: 2026-02-07
**进度**: 100% (Day 21 - 缺陷修复与持久化回归治理)
**更新时间**: 2026-02-08
---
@@ -10,12 +10,19 @@
> 这里记录了每一天的核心开发内容与 milestone。
### Day 20: 代码质量与安全优化 (Current)
### Day 21: 缺陷修复与持久化回归治理 (Current)
- [x] **Remotion 崩溃容错**: 渲染进程 SIGABRT 退出时检查输出文件,避免误判失败导致标题/字幕丢失。
- [x] **首页作品选择持久化**: 修复 `fetchGeneratedVideos` 无条件覆盖恢复值的问题,新增 `preferVideoId` 参数控制选中逻辑。
- [x] **发布页作品选择持久化**: 根因为签名 URL 不稳定,全面改用 `video.id` 替代 `path` 进行选择/持久化/比较。
- [x] **预取缓存补全**: 首页预取发布页数据时加入 `id` 字段,确保缓存数据可用于持久化匹配。
### Day 20: 代码质量与安全优化
- [x] **功能性修复**: LatentSync 回退逻辑、任务状态接口认证、User 类型统一。
- [x] **性能优化**: N+1 查询修复、视频上传流式处理、httpx 异步替换、GLM 异步包装。
- [x] **安全修复**: 硬编码 Cookie 配置化、日志敏感信息脱敏、ffprobe 安全调用、CORS 配置化。
- [x] **配置优化**: 存储路径环境变量化、Remotion 预编译加速、LatentSync 绝对路径。
- [x] **文档更新**: 更新 Doc_Rules.md 清单,补齐后端与部署文档;更新 SUBTITLE_DEPLOY.md, FRONTEND_DEV.md, implementation_plan.md。
- [x] **缺陷修复**: 修复 Remotion 路径解析、发布页持久化竞态、首页选中回归、素材闭包陷阱。
### Day 19: 自动发布稳定性与发布体验优化 🚀
- [x] **抖音发布稳定性**: 上传入口、封面流程、发布重试、登录失效识别与网络失败快速返回全面增强。

View File

@@ -15,7 +15,6 @@ DEFAULT_TTS_VOICE=zh-CN-YunxiNeural
# GPU 选择 (0=第一块GPU, 1=第二块GPU)
LATENTSYNC_GPU_ID=1
# 使用本地模式 (true) 或远程 API (false)
# 使用本地模式 (true) 或远程 API (false)
LATENTSYNC_LOCAL=true
@@ -67,6 +66,10 @@ ADMIN_PASSWORD=lam1988324
GLM_API_KEY=32440cd3f3444d1f8fe721304acea8bd.YXNLrk7eIJMKcg4t
GLM_MODEL=glm-4.7-flash
# =============== Supabase Storage 本地路径 ===============
# 确保存储卷映射正确,避免硬编码路径
SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/stub/stub
# =============== 抖音视频下载 Cookie ===============
# 用于从抖音 URL 提取视频文案功能,会过期需要定期更新
DOUYIN_COOKIE=douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false

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 app.core import config
from app.core.response import error_response
from app.api import materials, videos, publish, login_helper, auth, admin, ref_audios, ai, tools, assets
# 直接从 modules 导入路由,消除 api 转发层
from app.modules.materials.router import router as materials_router
from app.modules.videos.router import router as videos_router
from app.modules.publish.router import router as publish_router
from app.modules.login_helper.router import router as login_helper_router
from app.modules.auth.router import router as auth_router
from app.modules.admin.router import router as admin_router
from app.modules.ref_audios.router import router as ref_audios_router
from app.modules.ai.router import router as ai_router
from app.modules.tools.router import router as tools_router
from app.modules.assets.router import router as assets_router
from loguru import logger
import os
@@ -104,16 +114,16 @@ app.mount("/uploads", StaticFiles(directory=str(settings.UPLOAD_DIR)), name="upl
app.mount("/assets", StaticFiles(directory=str(settings.ASSETS_DIR)), name="assets")
# 注册路由
app.include_router(materials.router, prefix="/api/materials", tags=["Materials"])
app.include_router(videos.router, prefix="/api/videos", tags=["Videos"])
app.include_router(publish.router, prefix="/api/publish", tags=["Publish"])
app.include_router(login_helper.router, prefix="/api", tags=["LoginHelper"])
app.include_router(auth.router) # /api/auth
app.include_router(admin.router) # /api/admin
app.include_router(ref_audios.router, prefix="/api/ref-audios", tags=["RefAudios"])
app.include_router(ai.router) # /api/ai
app.include_router(tools.router, prefix="/api/tools", tags=["Tools"])
app.include_router(assets.router, prefix="/api/assets", tags=["Assets"])
app.include_router(materials_router, prefix="/api/materials", tags=["Materials"])
app.include_router(videos_router, prefix="/api/videos", tags=["Videos"])
app.include_router(publish_router, prefix="/api/publish", tags=["Publish"])
app.include_router(login_helper_router, prefix="/api", tags=["LoginHelper"])
app.include_router(auth_router) # /api/auth
app.include_router(admin_router) # /api/admin
app.include_router(ref_audios_router, prefix="/api/ref-audios", tags=["RefAudios"])
app.include_router(ai_router) # /api/ai
app.include_router(tools_router, prefix="/api/tools", tags=["Tools"])
app.include_router(assets_router, prefix="/api/assets", tags=["Assets"])
@app.on_event("startup")

View File

@@ -5,6 +5,7 @@ Remotion 视频渲染服务
import asyncio
import json
import os
import subprocess
from pathlib import Path
from typing import Optional
@@ -114,6 +115,16 @@ class RemotionService:
process.wait()
if process.returncode != 0:
# Remotion 渲染可能在完成输出后进程崩溃 (如 SIGABRT code -6)
# 如果输出文件已存在且大小合理,视为成功
output_file = Path(output_path)
if output_file.exists() and output_file.stat().st_size > 1024:
logger.warning(
f"Remotion process exited with code {process.returncode}, "
f"but output file exists ({output_file.stat().st_size} bytes). Treating as success."
)
return output_path
error_msg = "\n".join(output_lines[-20:]) # 最后 20 行
raise RuntimeError(f"Remotion render failed (code {process.returncode}):\n{error_msg}")

View File

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

View File

@@ -14,6 +14,7 @@
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"swr": "^2.3.8"
},
"devDependencies": {
@@ -6006,6 +6007,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ export default function LoginPage() {
} else {
setError(result.message || '登录失败');
}
} catch (err) {
} catch {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);

View File

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

View File

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

View File

@@ -2,11 +2,14 @@
import { useTask } from "@/contexts/TaskContext";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function GlobalTaskIndicator() {
const { currentTask, isGenerating } = useTask();
const pathname = usePathname();
if (!isGenerating) return null;
// 首页已有专门的进度条展示,因此在首页不显示顶部全局进度条
if (!isGenerating || pathname === "/") return null;
return (
<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";
import { useState, useEffect } from "react";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { useEffect, useCallback } from "react";
import { Loader2 } from "lucide-react";
import { useScriptExtraction } from "./script-extraction/useScriptExtraction";
interface ScriptExtractionModalProps {
isOpen: boolean;
@@ -13,177 +13,66 @@ interface ScriptExtractionModalProps {
export default function ScriptExtractionModal({
isOpen,
onClose,
onApply
onApply,
}: ScriptExtractionModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [script, setScript] = useState("");
const [rewrittenScript, setRewrittenScript] = useState("");
const [error, setError] = useState<string | null>(null);
const [doRewrite, setDoRewrite] = useState(true);
const [step, setStep] = useState<'config' | 'processing' | 'result'>('config');
const [dragActive, setDragActive] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const {
isLoading,
script,
rewrittenScript,
error,
doRewrite,
step,
dragActive,
selectedFile,
activeTab,
inputUrl,
setDoRewrite,
setActiveTab,
setInputUrl,
handleDrag,
handleDrop,
handleFileChange,
handleExtract,
copyToClipboard,
resetToConfig,
clearSelectedFile,
clearInputUrl,
} = useScriptExtraction({ isOpen });
// New state for URL mode
const [activeTab, setActiveTab] = useState<'file' | 'url'>('url');
const [inputUrl, setInputUrl] = useState("");
// 快捷键ESC 关闭Enter 提交(仅在 config 步骤)
const canExtract = (activeTab === "file" && selectedFile) || (activeTab === "url" && inputUrl.trim());
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
} else if (e.key === "Enter" && !e.shiftKey && step === "config" && canExtract && !isLoading) {
e.preventDefault();
handleExtract();
}
}, [onClose, step, canExtract, isLoading, handleExtract]);
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setStep('config');
setScript("");
setRewrittenScript("");
setError(null);
setIsLoading(false);
setSelectedFile(null);
setInputUrl("");
setActiveTab('url');
}
}, [isOpen]);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
};
const handleFileChange = (e: React.ChangeEvent<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;
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
const handleApplyAndClose = (text: string) => {
onApply?.(text);
onClose();
};
const handleExtractNext = () => {
resetToConfig();
clearSelectedFile();
clearInputUrl();
};
return (
<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"
>
<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"
>
<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">
<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">
{/* Header */}
<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">
@@ -199,25 +88,24 @@ export default function ScriptExtractionModal({
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{step === 'config' && (
{step === "config" && (
<div className="space-y-6">
{/* Tabs */}
<div className="flex p-1 bg-white/5 rounded-xl border border-white/10">
<button
onClick={() => setActiveTab('url')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'url'
? 'bg-purple-600 text-white shadow-lg'
: 'text-gray-400 hover:text-white hover:bg-white/5'
onClick={() => setActiveTab("url")}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === "url"
? "bg-purple-600 text-white shadow-lg"
: "text-gray-400 hover:text-white hover:bg-white/5"
}`}
>
🔗
</button>
<button
onClick={() => setActiveTab('file')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'file'
? 'bg-purple-600 text-white shadow-lg'
: 'text-gray-400 hover:text-white hover:bg-white/5'
onClick={() => setActiveTab("file")}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === "file"
? "bg-purple-600 text-white shadow-lg"
: "text-gray-400 hover:text-white hover:bg-white/5"
}`}
>
📂
@@ -225,7 +113,7 @@ export default function ScriptExtractionModal({
</div>
{/* URL Input Area */}
{activeTab === 'url' && (
{activeTab === "url" && (
<div className="space-y-2 py-4">
<div className="relative">
<input
@@ -237,119 +125,150 @@ export default function ScriptExtractionModal({
/>
{inputUrl && (
<button
onClick={() => setInputUrl("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white p-1"
onClick={clearInputUrl}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
</button>
)}
</div>
<p className="text-xs text-gray-400 px-1">
B站等主流平台分享链接
<p className="text-xs text-gray-500 pl-1">
B站
</p>
</div>
)}
{/* File Upload Area */}
{activeTab === 'file' && (
{activeTab === "file" && (
<div
className={`
relative border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer
${dragActive ? 'border-purple-500 bg-purple-500/10' : 'border-white/20 hover:border-white/40 hover:bg-white/5'}
${selectedFile ? 'bg-purple-900/10 border-purple-500/50' : ''}
`}
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${dragActive
? "border-purple-500 bg-purple-500/10"
: "border-white/10 hover:border-white/20"
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
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 ? (
<div className="flex flex-col items-center">
<div className="text-4xl mb-2">📄</div>
<div className="font-medium text-white break-all max-w-xs">{selectedFile.name}</div>
<div className="text-sm text-gray-400 mt-1">{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB</div>
<div className="mt-4 text-xs text-purple-400"></div>
<div className="space-y-2">
<p className="text-white">{selectedFile.name}</p>
<p className="text-sm text-gray-400">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
<button
onClick={clearSelectedFile}
className="text-xs text-purple-400 hover:text-purple-300"
>
</button>
</div>
) : (
<div className="flex flex-col items-center">
<div className="text-4xl mb-2">📤</div>
<div className="font-medium text-white"></div>
<div className="text-sm text-gray-400 mt-2"> MP4, MOV, MP3, WAV </div>
<div className="space-y-4">
<div className="text-4xl">📁</div>
<p className="text-gray-400">
/
<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>
)}
{/* Options */}
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
<label className="flex items-center gap-3 cursor-pointer">
<div className="flex items-center gap-3 bg-white/5 rounded-xl p-4 border border-white/10">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={doRewrite}
onChange={e => setDoRewrite(e.target.checked)}
className="w-5 h-5 accent-purple-600 rounded"
onChange={(e) => setDoRewrite(e.target.checked)}
className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
/>
<div>
<div className="text-white font-medium"> AI 稿</div>
<div className="text-xs text-gray-400">稿</div>
</div>
<span className="text-sm text-gray-300">
AI
</span>
</label>
</div>
{/* Error */}
{error && (
<div className="p-3 bg-red-500/20 text-red-200 rounded-lg text-sm text-center">
{error}
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<div className="flex 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
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={activeTab === 'file' ? !selectedFile : !inputUrl.trim()}
disabled={
(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>
</div>
</div>
)}
{step === 'processing' && (
{step === "processing" && (
<div className="flex flex-col items-center justify-center py-20">
<div className="relative w-20 h-20 mb-6">
<div className="absolute inset-0 border-4 border-purple-500/30 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-purple-500 rounded-full animate-spin"></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">
{activeTab === 'url' && "正在下载视频..."}<br />
{doRewrite ? "正在进行语音识别和 AI 智能改写..." : "正在进行语音识别..."}<br />
<span className="opacity-75"></span>
{activeTab === "url" && "正在下载视频..."}
<br />
{doRewrite
? "正在进行语音识别和 AI 智能改写..."
: "正在进行语音识别..."}
<br />
<span className="opacity-75">
</span>
</p>
</div>
)}
{step === 'result' && (
{step === "result" && (
<div className="space-y-6">
{rewrittenScript && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<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>
{onApply && (
<button
onClick={() => {
onApply(rewrittenScript);
onClose();
}}
onClick={() => handleApplyAndClose(rewrittenScript)}
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>
{onApply && (
<button
onClick={() => {
onApply(script);
onClose();
}}
onClick={() => handleApplyAndClose(script)}
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">
<button
onClick={() => {
setStep('config');
setScript("");
setRewrittenScript("");
setSelectedFile(null);
setInputUrl("");
// Keep current tab active
}}
onClick={handleExtractNext}
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);
if (savedTaskId) {
console.log("[TaskContext] 恢复任务:", savedTaskId);
// eslint-disable-next-line react-hooks/set-state-in-effect
setTaskId(savedTaskId);
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsGenerating(true);
}
}

View File

@@ -9,13 +9,15 @@ export interface BgmItem {
}
interface UseBgmOptions {
storageKey: string;
selectedBgmId: string;
setSelectedBgmId: React.Dispatch<React.SetStateAction<string>>;
}
export const useBgm = ({
storageKey,
// selectedBgmId 用于参数类型推断,不在此 hook 内部直接使用
// eslint-disable-next-line @typescript-eslint/no-unused-vars
selectedBgmId,
setSelectedBgmId,
}: UseBgmOptions) => {
@@ -32,21 +34,20 @@ export const useBgm = ({
const items: BgmItem[] = Array.isArray(payload.bgm) ? payload.bgm : [];
setBgmList(items);
const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`);
setSelectedBgmId((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 || "";
});
} catch (error: any) {
const message = error?.response?.data?.message || error?.message || '加载失败';
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { message?: string } }; message?: string };
const message = axiosErr?.response?.data?.message || axiosErr?.message || '加载失败';
setBgmError(message);
setBgmList([]);
console.error("获取背景音乐失败:", error);
} finally {
setBgmLoading(false);
}
}, [setSelectedBgmId, storageKey]);
}, [setSelectedBgmId]);
return {
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 { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface GeneratedVideo {
id: string;
@@ -11,7 +12,7 @@ interface GeneratedVideo {
}
interface UseGeneratedVideosOptions {
storageKey: string;
selectedVideoId: string | null;
setSelectedVideoId: React.Dispatch<React.SetStateAction<string | null>>;
setGeneratedVideo: React.Dispatch<React.SetStateAction<string | null>>;
@@ -19,7 +20,7 @@ interface UseGeneratedVideosOptions {
}
export const useGeneratedVideos = ({
storageKey,
selectedVideoId,
setSelectedVideoId,
setGeneratedVideo,
@@ -36,32 +37,42 @@ export const useGeneratedVideos = ({
const videos: GeneratedVideo[] = payload.videos || [];
setGeneratedVideos(videos);
const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`);
const currentId = preferVideoId || selectedVideoId || savedSelectedVideoId || null;
let nextId: string | null = null;
let nextUrl: string | null = null;
if (currentId) {
const found = videos.find(v => v.id === currentId);
// 只在明确指定 preferVideoId 时才自动选中
// "__latest__" 表示选中最新的(第一个),用于新视频生成完成后
// 其他值表示选中指定 ID 的视频
// 不传则不设置选中项,由 useHomePersistence 恢复
if (preferVideoId && videos.length > 0) {
if (preferVideoId === "__latest__") {
setSelectedVideoId(videos[0].id);
setGeneratedVideo(resolveMediaUrl(videos[0].path));
} else {
const found = videos.find(v => v.id === preferVideoId);
if (found) {
nextId = found.id;
nextUrl = resolveMediaUrl(found.path);
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) {
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) => {
if (!confirm("确定要删除这个视频吗?")) return;
@@ -73,7 +84,7 @@ export const useGeneratedVideos = ({
}
fetchGeneratedVideos();
} catch (error) {
alert("删除失败: " + error);
toast.error("删除失败: " + error);
}
}, [fetchGeneratedVideos, selectedVideoId, setGeneratedVideo, setSelectedVideoId]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -70,6 +70,7 @@ export function RefAudioPanel({
useEffect(() => {
if (!recordedBlob) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setRecordedUrl(null);
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"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') onSaveEditing(audio.id, e as any);
if (e.key === 'Escape') onCancelEditing(e as any);
if (e.key === 'Enter') onSaveEditing(audio.id, e as unknown as MouseEvent);
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">

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 api from "@/shared/api/axios";
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 { useTitleInput } from "@/shared/hooks/useTitleInput";
import { useAuth } from "@/contexts/AuthContext";
interface Account {
platform: string;
name: string;
logged_in: boolean;
enabled: boolean;
}
interface Video {
name: string;
path: string;
}
export interface PublishResult {
platform: string;
success: boolean;
message: string;
url?: string | null;
screenshot_url?: string;
}
import { useTask } from "@/contexts/TaskContext";
import { toast } from "sonner";
import { usePublishPrefetch } from "@/shared/hooks/usePublishPrefetch";
import {
PublishAccount as Account,
PublishVideo as Video,
PublishResult,
} from "@/shared/types/publish";
const fetcher = (url: string) =>
api.get<ApiResponse<any>>(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[];
};
api.get<ApiResponse<{ success?: boolean }>>(url).then((res) => unwrap(res.data));
export const usePublishController = () => {
const apiBase = getApiBaseUrl();
@@ -58,36 +37,23 @@ export const usePublishController = () => {
const [qrPlatform, setQrPlatform] = useState<string | null>(null);
const [isLoadingQR, setIsLoadingQR] = useState(false);
// 使用全局认证状态
const { userId, isLoading: isAuthLoading } = useAuth();
// 是否已从 localStorage 恢复完成
const [isRestored, setIsRestored] = useState(false);
const { isGenerating } = useTask();
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({
value: title,
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 () => {
try {
@@ -102,21 +68,20 @@ export const usePublishController = () => {
}
};
const fetchVideos = async () => {
const fetchVideos = async (autoSelectLatest = false) => {
try {
const { data: res } = await api.get<ApiResponse<{ videos: any[] }>>(
"/api/videos/generated"
);
const payload = unwrap(res);
const nextVideos = (payload.videos || []).map((v: any) => ({
id: v.id as string,
name: formatDate(v.created_at) + ` (${v.size_mb.toFixed(1)}MB)`,
path: v.path.startsWith("/") ? v.path.slice(1) : v.path,
}));
setVideos(nextVideos);
if (nextVideos.length > 0) {
setSelectedVideo(nextVideos[0].path);
if (nextVideos.length > 0 && autoSelectLatest) {
setSelectedVideo(nextVideos[0].id);
}
updatePrefetch({ videos: nextVideos });
} catch (error) {
@@ -124,91 +89,133 @@ export const usePublishController = () => {
}
};
// 初始加载
useEffect(() => {
const cache = readPrefetch();
if (cache?.accounts) {
setAccounts(cache.accounts);
setIsAccountsLoading(false);
}
if (cache?.videos) {
setVideos(cache.videos);
if (!selectedVideo && cache.videos.length > 0) {
setSelectedVideo(cache.videos[0].path);
}
setIsVideosLoading(false);
}
if (cache?.accounts) { setAccounts(cache.accounts); setIsAccountsLoading(false); }
if (cache?.videos) { setVideos(cache.videos); setIsVideosLoading(false); }
if (!cache?.accounts) setIsAccountsLoading(true);
if (!cache?.videos) setIsVideosLoading(true);
let active = true;
void Promise.allSettled([
fetchAccounts(),
fetchVideos(),
]).finally(() => {
void Promise.allSettled([fetchAccounts(), fetchVideos(false)]).finally(() => {
if (!active) return;
setIsAccountsLoading(false);
setIsVideosLoading(false);
});
return () => {
active = false;
};
return () => { 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(() => {
if (typeof window === "undefined") return;
if ("scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual";
}
if ("scrollRestoration" in window.history) window.history.scrollRestoration = "manual";
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}, []);
// 获取存储 key 的前缀(登录用户使用 userId未登录使用 guest
const storageKey = userId || "guest";
// 从 localStorage 恢复用户输入(等待认证完成后)
// ---- 发布防误操作 ----
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
const savedTitle = localStorage.getItem(`vigent_${storageKey}_publish_title`);
const savedTags = localStorage.getItem(`vigent_${storageKey}_publish_tags`);
if (savedTitle) setTitle(clampTitle(savedTitle));
if (savedTags) {
// 兼容 JSON 数组格式AI 生成)和字符串格式(手动输入)
try {
const parsed = JSON.parse(savedTags);
if (Array.isArray(parsed)) {
setTags(parsed.join(", "));
} else {
setTags(savedTags);
// ---- 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);
toast.success("✅ 登录成功!");
fetchAccounts();
}
} 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(() => {
if (!isRestored) return;
const timeout = setTimeout(() => {
localStorage.setItem(`vigent_${storageKey}_publish_tags`, tags);
}, 300);
return () => clearTimeout(timeout);
}, [tags, storageKey, isRestored]);
let timer: NodeJS.Timeout;
if (qrPlatform) {
timer = setTimeout(() => {
if (qrPlatform) {
setQrPlatform(null);
setQrCodeImage(null);
toast.error("登录超时,请重试");
}
}, 120000);
}
return () => clearTimeout(timer);
}, [qrPlatform]);
// ---- 操作函数 ----
const togglePlatform = (platform: string) => {
if (selectedPlatforms.includes(platform)) {
@@ -220,96 +227,40 @@ export const usePublishController = () => {
const handlePublish = async () => {
if (!selectedVideo || !title || selectedPlatforms.length === 0) {
alert("请选择视频、填写标题并选择至少一个平台");
toast.error("请选择视频、填写标题并选择至少一个平台");
return;
}
const video = videos.find(v => v.id === selectedVideo);
if (!video) {
toast.error("未找到选中的视频");
return;
}
setIsPublishing(true);
setPublishResults([]);
const tagList = tags.split(/[,\s]+/).filter((t) => t.trim());
for (const platform of selectedPlatforms) {
try {
const { data: res } = await api.post<ApiResponse<any>>("/api/publish", {
video_path: selectedVideo,
platform,
title,
tags: tagList,
description: "",
video_path: video.path, platform, title, tags: tagList, description: "",
});
const result = unwrap(res);
const screenshotUrl =
typeof result.screenshot_url === "string"
? resolveMediaUrl(result.screenshot_url) || result.screenshot_url
: undefined;
const nextResult: PublishResult = {
const screenshotUrl = typeof result.screenshot_url === "string"
? resolveMediaUrl(result.screenshot_url) || result.screenshot_url : undefined;
setPublishResults((prev) => [...prev, {
platform: result.platform || platform,
success: Boolean(result.success),
message: result.message || "",
url: result.url,
screenshot_url: screenshotUrl,
};
setPublishResults((prev) => [...prev, nextResult]);
}]);
} catch (error: any) {
const message = error.response?.data?.message || String(error);
setPublishResults((prev) => [
...prev,
{ platform, success: false, message },
]);
setPublishResults((prev) => [...prev, { platform, success: false, message }]);
}
}
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) => {
setIsLoadingQR(true);
setQrPlatform(platform);
@@ -317,16 +268,15 @@ export const usePublishController = () => {
try {
const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/login/${platform}`);
const result = unwrap(res);
if (result.success && result.qr_code) {
setQrCodeImage(result.qr_code);
} else {
setQrPlatform(null);
alert(result.message || "登录失败");
toast.error(result.message || "登录失败");
}
} catch (error: any) {
setQrPlatform(null);
alert(`登录失败: ${error.response?.data?.message || error.message}`);
toast.error(`登录失败: ${error.response?.data?.message || error.message}`);
} finally {
setIsLoadingQR(false);
}
@@ -337,14 +287,10 @@ export const usePublishController = () => {
try {
const { data: res } = await api.post<ApiResponse<any>>(`/api/publish/logout/${platform}`);
const result = unwrap(res);
if (result.success) {
alert("注销");
fetchAccounts();
} else {
alert(result.message || "注销失败");
}
if (result.success) { toast.success("已注销"); fetchAccounts(); }
else { toast.error(result.message || "注销失败"); }
} 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));
}, [videos, videoFilter]);
const handlePreviewVideo = (path: string) => {
const previewPath = isAbsoluteUrl(path)
? path
: path.startsWith("/")
? path
: `/${path}`;
const handlePreviewVideo = (videoId: string) => {
const video = videos.find(v => v.id === videoId);
if (!video) return;
const previewPath = isAbsoluteUrl(video.path) ? video.path : video.path.startsWith("/") ? video.path : `/${video.path}`;
setPreviewVideoUrl(resolveMediaUrl(previewPath) || previewPath);
};
@@ -376,36 +320,13 @@ export const usePublishController = () => {
};
return {
apiBase,
accounts,
videos,
isAccountsLoading,
isVideosLoading,
selectedVideo,
setSelectedVideo,
videoFilter,
setVideoFilter,
previewVideoUrl,
setPreviewVideoUrl,
selectedPlatforms,
title,
titleInput,
tags,
setTags,
isPublishing,
publishResults,
qrCodeImage,
qrPlatform,
isLoadingQR,
fetchAccounts,
fetchVideos,
togglePlatform,
handlePublish,
handleLogin,
handleLogout,
platformIcons,
filteredVideos,
handlePreviewVideo,
closeQrModal,
apiBase, accounts, videos, isAccountsLoading, isVideosLoading,
selectedVideo, setSelectedVideo, videoFilter, 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";
import Link from "next/link";
import Image from "next/image";
import VideoPreviewModal from "@/components/VideoPreviewModal";
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
import { usePublishController } from "@/features/publish/model/usePublishController";
@@ -11,6 +12,7 @@ import {
QrCode,
Search,
Eye,
Loader2,
} from "lucide-react";
export function PublishPage() {
@@ -63,10 +65,13 @@ export function PublishPage() {
</div>
) : qrCodeImage ? (
<>
<img
<Image
src={`data:image/png;base64,${qrCodeImage}`}
alt="QR Code"
width={280}
height={280}
className="w-full h-auto"
unoptimized
/>
<p className="text-center text-gray-600 mt-4">
使
@@ -145,9 +150,11 @@ export function PublishPage() {
>
<div className="flex items-center gap-3">
{platformIcons[account.platform] ? (
<img
<Image
src={platformIcons[account.platform].src}
alt={platformIcons[account.platform].alt}
width={28}
height={28}
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" }}>
{filteredVideos.map((v) => (
<div
key={v.path}
onClick={() => setSelectedVideo(v.path)}
className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.path
key={v.id}
onClick={() => setSelectedVideo(v.id)}
className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
@@ -253,7 +260,7 @@ export function PublishPage() {
<button
onClick={(e) => {
e.stopPropagation();
handlePreviewVideo(v.path);
handlePreviewVideo(v.id);
}}
onMouseEnter={() => {
const src = v.path.startsWith("/") ? v.path : `/${v.path}`;
@@ -269,7 +276,7 @@ export function PublishPage() {
>
<Eye className="h-4 w-4" />
</button>
{selectedVideo === v.path && (
{selectedVideo === v.id && (
<span className="text-xs text-purple-300"></span>
)}
</div>
@@ -331,9 +338,11 @@ export function PublishPage() {
>
<span className="block mb-1">
{platformIcons[account.platform] ? (
<img
<Image
src={platformIcons[account.platform].src}
alt={platformIcons[account.platform].alt}
width={28}
height={28}
className="h-7 w-7 mx-auto"
/>
) : (
@@ -352,7 +361,12 @@ export function PublishPage() {
disabled={isPublishing || selectedPlatforms.length === 0}
className="w-full py-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-bold text-lg hover:shadow-lg hover:from-purple-500 hover:to-pink-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPublishing ? "正在发布...请勿刷新或关闭网页" : "立即发布"}
{isPublishing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
...
</span>
) : "立即发布"}
</button>
{/* 发布结果 */}
@@ -368,9 +382,11 @@ export function PublishPage() {
>
<div className="flex items-center gap-2 mb-1">
{platformIcons[result.platform] ? (
<img
<Image
src={platformIcons[result.platform].src}
alt={platformIcons[result.platform].alt}
width={20}
height={20}
className="h-5 w-5"
/>
) : (
@@ -390,10 +406,13 @@ export function PublishPage() {
rel="noreferrer"
className="block"
>
<img
<Image
src={result.screenshot_url}
alt="发布成功截图"
width={400}
height={300}
className="w-full rounded-md border border-white/10"
unoptimized
/>
</a>
</div>

View File

@@ -37,7 +37,7 @@ api.interceptors.response.use(
// 调用 logout API 清除 HttpOnly cookie
try {
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";
// Re-export User 类型以保持向后兼容
export type { User };
const API_BASE = typeof window === 'undefined'
? (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8006')
: '';
export interface AuthResponse {
success: boolean;
message: string;
@@ -27,58 +25,38 @@ interface ApiResponse<T> {
* 用户注册
*/
export async function register(phone: string, password: string, username?: string): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ phone, password, username })
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/register', {
phone, password, username
});
const payload = await res.json();
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
return { success: payload.success, message: payload.message };
}
/**
* 用户登录
*/
export async function login(phone: string, password: string): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ phone, password })
const { data: payload } = await api.post<ApiResponse<{ user?: User }>>('/api/auth/login', {
phone, password
});
const payload = await res.json();
const data = payload as ApiResponse<{ user?: User }>;
return { success: data.success, message: data.message, user: data.data?.user };
return { success: payload.success, message: payload.message, user: payload.data?.user };
}
/**
* 用户登出
*/
export async function logout(): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
credentials: 'include'
});
const payload = await res.json();
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/logout');
return { success: payload.success, message: payload.message };
}
/**
* 修改密码
*/
export async function changePassword(oldPassword: string, newPassword: string): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/change-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword })
const { data: payload } = await api.post<ApiResponse<null>>('/api/auth/change-password', {
old_password: oldPassword, new_password: newPassword
});
const payload = await res.json();
const data = payload as ApiResponse<null>;
return { success: data.success, message: data.message };
return { success: payload.success, message: payload.message };
}
/**
@@ -86,13 +64,8 @@ export async function changePassword(oldPassword: string, newPassword: string):
*/
export async function getCurrentUser(): Promise<User | null> {
try {
const res = await fetch(`${API_BASE}/api/auth/me`, {
credentials: 'include'
});
if (!res.ok) return null;
const payload = await res.json();
const data = payload as ApiResponse<User>;
return data.data || null;
const { data: payload } = await api.get<ApiResponse<User>>('/api/auth/me');
return payload.data || null;
} catch {
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
console.log('Bundling Remotion project...');
// 修复: 使用 process.cwd() 解析 src/index.ts确保在 dist/render.js 和 ts-node 下都能找到
// 假设脚本总是在 remotion 根目录下运行 (由 python service 保证)
const entryPoint = path.resolve(process.cwd(), 'src/index.ts');
console.log(`Entry point: ${entryPoint}`);
const bundleLocation = await bundle({
entryPoint: path.resolve(__dirname, './src/index.ts'),
entryPoint,
webpackOverride: (config) => config,
publicDir,
});