From b2c1042c5c53401fab8956b2ba2f5d083917de6a Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Wed, 4 Feb 2026 18:04:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/DevLogs/Day12.md | 4 +- Docs/DevLogs/Day14.md | 4 +- Docs/DevLogs/Day15.md | 4 +- Docs/DevLogs/Day16.md | 2 +- Docs/DevLogs/Day17.md | 49 +- Docs/DevLogs/Day9.md | 2 +- Docs/FRONTEND_DEV.md | 35 +- Docs/FRONTEND_README.md | 23 +- Docs/implementation_plan.md | 2 +- Docs/task_complete.md | 3 + frontend/src/app/admin/page.tsx | 4 +- frontend/src/app/login/page.tsx | 2 +- frontend/src/app/page.tsx | 867 +----------------- frontend/src/app/publish/page.tsx | 672 +------------- frontend/src/app/register/page.tsx | 2 +- .../components/AccountSettingsDropdown.tsx | 2 +- .../src/components/ScriptExtractionModal.tsx | 2 +- frontend/src/contexts/AuthContext.tsx | 2 +- frontend/src/contexts/TaskContext.tsx | 2 +- .../{hooks => features/home/model}/useBgm.ts | 2 +- .../home/model}/useGeneratedVideos.ts | 2 +- .../features/home/model/useHomeController.ts | 743 +++++++++++++++ .../home/model}/useHomePersistence.ts | 2 +- .../home/model}/useMaterials.ts | 2 +- .../home/model}/useMediaPlayers.ts | 2 +- .../home/model}/useRefAudios.ts | 2 +- .../home/model}/useTitleSubtitleStyles.ts | 2 +- .../home => features/home/ui}/BgmPanel.tsx | 0 .../home/ui}/GenerateActionBar.tsx | 0 .../home => features/home/ui}/HistoryList.tsx | 0 .../home => features/home/ui}/HomeHeader.tsx | 0 frontend/src/features/home/ui/HomePage.tsx | 295 ++++++ .../home/ui}/MaterialSelector.tsx | 0 .../home/ui}/PreviewPanel.tsx | 0 .../home/ui}/RefAudioPanel.tsx | 0 .../home/ui}/ScriptEditor.tsx | 0 .../home/ui}/TitleSubtitlePanel.tsx | 0 .../home/ui}/VoiceSelector.tsx | 0 .../publish/model/usePublishController.ts | 323 +++++++ .../src/features/publish/ui/PublishPage.tsx | 381 ++++++++ frontend/src/{lib => shared/api}/axios.ts | 0 .../src/{ => shared}/hooks/useTitleInput.ts | 2 +- frontend/src/{ => shared}/lib/auth.ts | 0 frontend/src/{ => shared}/lib/media.ts | 0 frontend/src/{ => shared}/lib/title.ts | 0 45 files changed, 1849 insertions(+), 1592 deletions(-) rename frontend/src/{hooks => features/home/model}/useBgm.ts (97%) rename frontend/src/{hooks => features/home/model}/useGeneratedVideos.ts (98%) create mode 100644 frontend/src/features/home/model/useHomeController.ts rename frontend/src/{hooks => features/home/model}/useHomePersistence.ts (99%) rename frontend/src/{hooks => features/home/model}/useMaterials.ts (98%) rename frontend/src/{hooks => features/home/model}/useMediaPlayers.ts (98%) rename frontend/src/{hooks => features/home/model}/useRefAudios.ts (98%) rename frontend/src/{hooks => features/home/model}/useTitleSubtitleStyles.ts (98%) rename frontend/src/{components/home => features/home/ui}/BgmPanel.tsx (100%) rename frontend/src/{components/home => features/home/ui}/GenerateActionBar.tsx (100%) rename frontend/src/{components/home => features/home/ui}/HistoryList.tsx (100%) rename frontend/src/{components/home => features/home/ui}/HomeHeader.tsx (100%) create mode 100644 frontend/src/features/home/ui/HomePage.tsx rename frontend/src/{components/home => features/home/ui}/MaterialSelector.tsx (100%) rename frontend/src/{components/home => features/home/ui}/PreviewPanel.tsx (100%) rename frontend/src/{components/home => features/home/ui}/RefAudioPanel.tsx (100%) rename frontend/src/{components/home => features/home/ui}/ScriptEditor.tsx (100%) rename frontend/src/{components/home => features/home/ui}/TitleSubtitlePanel.tsx (100%) rename frontend/src/{components/home => features/home/ui}/VoiceSelector.tsx (100%) create mode 100644 frontend/src/features/publish/model/usePublishController.ts create mode 100644 frontend/src/features/publish/ui/PublishPage.tsx rename frontend/src/{lib => shared/api}/axios.ts (100%) rename frontend/src/{ => shared}/hooks/useTitleInput.ts (95%) rename frontend/src/{ => shared}/lib/auth.ts (100%) rename frontend/src/{ => shared}/lib/media.ts (100%) rename frontend/src/{ => shared}/lib/title.ts (100%) diff --git a/Docs/DevLogs/Day12.md b/Docs/DevLogs/Day12.md index d3998f5..88a85ff 100644 --- a/Docs/DevLogs/Day12.md +++ b/Docs/DevLogs/Day12.md @@ -9,7 +9,7 @@ ### 背景 统一处理 API 请求的认证失败场景,避免各页面重复处理 401/403 错误。 -### 实现 (`frontend/src/lib/axios.ts`) +### 实现 (`frontend/src/shared/api/axios.ts`) ```typescript import axios from 'axios'; @@ -325,7 +325,7 @@ models/Qwen3-TTS/ | 文件 | 变更类型 | 说明 | |------|----------|------| -| `frontend/src/lib/axios.ts` | 修改 | Axios 全局拦截器 (401/403 自动跳转) | +| `frontend/src/shared/api/axios.ts` | 修改 | Axios 全局拦截器 (401/403 自动跳转) | | `frontend/src/app/layout.tsx` | 修改 | viewport 配置 + body 渐变背景 | | `frontend/src/app/globals.css` | 修改 | 安全区域 CSS 支持 | | `frontend/src/app/page.tsx` | 修改 | 移除独立渐变 + Header 响应式 | diff --git a/Docs/DevLogs/Day14.md b/Docs/DevLogs/Day14.md index 8778c0a..b020d18 100644 --- a/Docs/DevLogs/Day14.md +++ b/Docs/DevLogs/Day14.md @@ -358,7 +358,7 @@ const storageKey = userId || 'guest'; ### 解决方案 -**文件**: `frontend/src/lib/axios.ts` +**文件**: `frontend/src/shared/api/axios.ts` 在拦截器中对公开路由跳过重定向,仅在受保护页面触发登录跳转: @@ -391,7 +391,7 @@ if ((status === 401 || status === 403) && !isRedirecting && !isPublicPath) { | `backend/app/main.py` | 修改 | 注册 ai 路由 | | `frontend/src/app/page.tsx` | 修改 | AI 生成按钮 + localStorage 修复 | | `frontend/src/app/publish/page.tsx` | 修改 | 恢复 AI 生成的标签 | -| `frontend/src/lib/axios.ts` | 修改 | 公开路由跳过 401/403 登录重定向 | +| `frontend/src/shared/api/axios.ts` | 修改 | 公开路由跳过 401/403 登录重定向 | --- diff --git a/Docs/DevLogs/Day15.md b/Docs/DevLogs/Day15.md index 278f088..696ceca 100644 --- a/Docs/DevLogs/Day15.md +++ b/Docs/DevLogs/Day15.md @@ -161,7 +161,7 @@ if (!/^\d{11}$/.test(phone)) { ### 3. Auth 工具函数 (`auth.ts`) -**文件**: `frontend/src/lib/auth.ts` +**文件**: `frontend/src/shared/lib/auth.ts` ```typescript export interface User { @@ -304,7 +304,7 @@ pm2 restart vigent2-backend vigent2-frontend | `backend/.env` | 修改 | ADMIN_PHONE=15549380526 | | `frontend/src/app/login/page.tsx` | 修改 | 手机号登录 + 11位验证 | | `frontend/src/app/register/page.tsx` | 修改 | 手机号注册 + 11位验证 | -| `frontend/src/lib/auth.ts` | 修改 | phone 参数 + changePassword 函数 | +| `frontend/src/shared/lib/auth.ts` | 修改 | phone 参数 + changePassword 函数 | | `frontend/src/app/page.tsx` | 修改 | AccountSettingsDropdown 组件 | | `frontend/src/app/admin/page.tsx` | 修改 | 用户列表显示手机号 | | `frontend/src/contexts/AuthContext.tsx` | 修改 | 存储完整用户信息含 expires_at | diff --git a/Docs/DevLogs/Day16.md b/Docs/DevLogs/Day16.md index e63e945..ace01cf 100644 --- a/Docs/DevLogs/Day16.md +++ b/Docs/DevLogs/Day16.md @@ -127,7 +127,7 @@ if service["failures"] >= service['threshold']: - 交互按钮保持一致尺寸与对齐 ### 涉及文件 -- `frontend/src/components/home/` +- `frontend/src/features/home/ui/` - `frontend/src/app/publish/page.tsx` --- diff --git a/Docs/DevLogs/Day17.md b/Docs/DevLogs/Day17.md index f425022..6b14004 100644 --- a/Docs/DevLogs/Day17.md +++ b/Docs/DevLogs/Day17.md @@ -4,7 +4,7 @@ ### 内容 - 首页 `page.tsx` 拆分为独立 UI 组件,状态与逻辑仍集中在页面 -- 新增首页组件目录 `frontend/src/components/home/` +- 新增首页组件目录 `frontend/src/features/home/ui/` ### 组件列表 - `HomeHeader` @@ -27,7 +27,7 @@ - 首页与发布页统一调用,消除重复逻辑 ### 涉及文件 -- `frontend/src/lib/media.ts` +- `frontend/src/shared/lib/media.ts` - `frontend/src/app/page.tsx` - `frontend/src/app/publish/page.tsx` @@ -102,12 +102,12 @@ - `useGeneratedVideos`:历史作品列表获取 + 选择逻辑抽取 ### 涉及文件 -- `frontend/src/hooks/useTitleSubtitleStyles.ts` -- `frontend/src/hooks/useMaterials.ts` -- `frontend/src/hooks/useRefAudios.ts` -- `frontend/src/hooks/useBgm.ts` -- `frontend/src/hooks/useMediaPlayers.ts` -- `frontend/src/hooks/useGeneratedVideos.ts` +- `frontend/src/features/home/model/useTitleSubtitleStyles.ts` +- `frontend/src/features/home/model/useMaterials.ts` +- `frontend/src/features/home/model/useRefAudios.ts` +- `frontend/src/features/home/model/useBgm.ts` +- `frontend/src/features/home/model/useMediaPlayers.ts` +- `frontend/src/features/home/model/useGeneratedVideos.ts` - `frontend/src/app/page.tsx` --- @@ -120,7 +120,7 @@ ### 涉及文件 - `frontend/src/app/page.tsx` -- `frontend/src/hooks/useHomePersistence.ts` +- `frontend/src/features/home/model/useHomePersistence.ts` --- @@ -134,10 +134,10 @@ ### 涉及文件 - `frontend/src/app/publish/page.tsx` -- `frontend/src/hooks/useMediaPlayers.ts` -- `frontend/src/hooks/useBgm.ts` -- `frontend/src/hooks/useMaterials.ts` -- `frontend/src/components/home/RefAudioPanel.tsx` +- `frontend/src/features/home/model/useMediaPlayers.ts` +- `frontend/src/features/home/model/useBgm.ts` +- `frontend/src/features/home/model/useMaterials.ts` +- `frontend/src/features/home/ui/RefAudioPanel.tsx` - `frontend/src/components/VideoPreviewModal.tsx` - `frontend/src/app/layout.tsx` @@ -150,6 +150,27 @@ - 标题输入兼容中文输入法,限制 15 字(发布信息同规则) ### 涉及文件 +- `frontend/src/features/home/model/useHomeController.ts` +- `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` +- `frontend/src/features/publish/model/usePublishController.ts` + +--- + +## 🧱 轻量 FSD 迁移 (16:20) + +### 内容 +- 页面瘦身:`app` 仅保留入口组件,业务逻辑集中到 Controller Hook +- 引入 `features/*` 分层:UI 与 model 分离,Home/Publish 按功能聚合 +- 通用能力下沉到 `shared/*`(lib/hooks/api) + +### 涉及文件 +- `frontend/src/features/home/ui/HomePage.tsx` +- `frontend/src/features/home/model/useHomeController.ts` +- `frontend/src/features/publish/ui/PublishPage.tsx` +- `frontend/src/features/publish/model/usePublishController.ts` +- `frontend/src/shared/lib/media.ts` +- `frontend/src/shared/lib/title.ts` +- `frontend/src/shared/api/axios.ts` +- `frontend/src/shared/hooks/useTitleInput.ts` - `frontend/src/app/page.tsx` -- `frontend/src/components/home/TitleSubtitlePanel.tsx` - `frontend/src/app/publish/page.tsx` diff --git a/Docs/DevLogs/Day9.md b/Docs/DevLogs/Day9.md index ca758a6..fd61a75 100644 --- a/Docs/DevLogs/Day9.md +++ b/Docs/DevLogs/Day9.md @@ -228,7 +228,7 @@ else: | 文件 | 说明 | 状态 | |------|------|------| -| `src/lib/auth.ts` | 认证工具函数 | ✅ | +| `src/shared/lib/auth.ts` | 认证工具函数 | ✅ | | `src/app/login/page.tsx` | 登录页 | ✅ | | `src/app/register/page.tsx` | 注册页 | ✅ | | `src/app/admin/page.tsx` | 管理后台 | ✅ | diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md index 69107c9..ddeed5a 100644 --- a/Docs/FRONTEND_DEV.md +++ b/Docs/FRONTEND_DEV.md @@ -104,14 +104,14 @@ body { ### 必须使用 `api` (axios 实例) -所有需要认证的 API 请求**必须**使用 `@/lib/axios` 导出的 axios 实例。该实例已配置: +所有需要认证的 API 请求**必须**使用 `@/shared/api/axios` 导出的 axios 实例。该实例已配置: - 自动携带 `credentials: include` - 遇到 401/403 时自动清除 cookie 并跳转登录页 **使用方式:** ```typescript -import api from '@/lib/axios'; +import api from '@/shared/api/axios'; // GET 请求 const { data } = await api.get('/api/materials'); @@ -140,7 +140,7 @@ await api.post('/api/materials', formData, { ### SWR 配合使用 ```typescript -import api from '@/lib/axios'; +import api from '@/shared/api/axios'; // SWR fetcher 使用 axios const fetcher = (url: string) => api.get(url).then(res => res.data); @@ -153,10 +153,10 @@ const { data } = useSWR('/api/xxx', fetcher, { refreshInterval: 2000 }); ## 通用工具函数 (media.ts) ### 统一 API Base / URL 解析 -使用 `@/lib/media` 统一处理服务端/客户端 API Base 与资源地址,避免硬编码: +使用 `@/shared/lib/media` 统一处理服务端/客户端 API Base 与资源地址,避免硬编码: ```typescript -import { getApiBaseUrl, resolveMediaUrl, resolveAssetUrl, formatDate } from '@/lib/media'; +import { getApiBaseUrl, resolveMediaUrl, resolveAssetUrl, formatDate } from '@/shared/lib/media'; const apiBase = getApiBaseUrl(); // SSR: http://localhost:8006 / Client: '' const playableUrl = resolveMediaUrl(video.path); // 兼容签名 URL 与相对路径 @@ -186,18 +186,28 @@ new Date(timestamp * 1000).toLocaleString('zh-CN') **正确做法:** ```typescript // ✅ 使用固定格式 -import { formatDate } from '@/lib/media'; +import { formatDate } from '@/shared/lib/media'; ``` --- ## 组件拆分规范 -当页面组件超过 300-500 行,建议拆分到 `components/`: +当页面组件超过 300-500 行,建议按功能拆分到 `features/*/ui`: -- `page.tsx` 负责状态与业务逻辑 -- 组件只接受 props 与回调,尽量不直接发 API -- 首页拆分组件统一放在 `components/home/` +- `page.tsx` 仅做组合与布局 +- 业务逻辑集中在 `features/*/model` 的 Controller Hook +- UI 组件只接受 props 与回调,尽量不直接发 API +- 首页拆分组件统一放在 `features/home/ui/` + +--- + +## 轻量 FSD 结构 + +- `app/`:页面入口,保持轻量 +- `features/*/model`:业务逻辑与状态 (hooks) +- `features/*/ui`:功能 UI 组件 +- `shared/`:通用工具、通用 hooks、API 实例 --- @@ -226,14 +236,15 @@ import { formatDate } from '@/lib/media'; - 中文输入法合成阶段不截断,合成结束后才校验长度。 - 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`。 - 避免使用 `maxLength` 强制截断输入法合成态。 +- 推荐使用 `@/shared/hooks/useTitleInput` 统一处理输入逻辑。 --- ## 新增页面 Checklist -1. [ ] 导入 `import api from '@/lib/axios'` +1. [ ] 导入 `import api from '@/shared/api/axios'` 2. [ ] 所有 API 请求使用 `api.get/post/delete()` 而非原生 `fetch` -3. [ ] 日期格式化使用 `@/lib/media` 的 `formatDate` +3. [ ] 日期格式化使用 `@/shared/lib/media` 的 `formatDate` 4. [ ] 资源 URL 使用 `resolveMediaUrl`/`resolveAssetUrl` 5. [ ] 添加 `'use client'` 指令(如需客户端交互) diff --git a/Docs/FRONTEND_README.md b/Docs/FRONTEND_README.md index 6e03f3a..772ce3a 100644 --- a/Docs/FRONTEND_README.md +++ b/Docs/FRONTEND_README.md @@ -62,7 +62,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 - **样式**: TailwindCSS - **图标**: Lucide React - **组件**: 自定义现代化组件 (Glassmorphism 风格) -- **API**: Axios 实例 `@/lib/axios` (对接后端 FastAPI :8006) +- **API**: Axios 实例 `@/shared/api/axios` (对接后端 FastAPI :8006) ## 🚀 开发指南 @@ -85,22 +85,29 @@ npm run dev ``` src/ -├── app/ +├── app/ # 页面入口 (轻量) │ ├── page.tsx # 视频生成主页 │ ├── publish/ # 发布管理页 │ │ └── page.tsx │ └── layout.tsx # 全局布局 (导航栏) -├── components/ # UI 组件 -│ ├── home/ # 首页拆分组件 -│ └── ... -└── lib/ # 工具函数 - └── media.ts # API Base / URL / 日期等通用工具 +├── features/ +│ ├── home/ +│ │ ├── model/ # Home 业务逻辑 (hooks) +│ │ └── ui/ # Home UI 组件 +│ └── publish/ +│ ├── model/ # Publish 业务逻辑 (hooks) +│ └── ui/ # Publish UI 组件 +├── shared/ +│ ├── api/ # API 实例 +│ ├── hooks/ # 通用 hooks +│ └── lib/ # 工具函数 +└── components/ # 跨页面复用 UI ``` ## 🔌 后端对接 - **Base URL**: `http://localhost:8006` (SSR) / 相对路径 (Client) -- **URL 统一工具**: `@/lib/media` 提供 `resolveMediaUrl` / `resolveAssetUrl` +- **URL 统一工具**: `@/shared/lib/media` 提供 `resolveMediaUrl` / `resolveAssetUrl` - **代理配置**: Next.js Rewrites (如需) 或直接 CORS。 ## 🎨 设计规范 diff --git a/Docs/implementation_plan.md b/Docs/implementation_plan.md index ea07c8d..b02a6a9 100644 --- a/Docs/implementation_plan.md +++ b/Docs/implementation_plan.md @@ -60,7 +60,7 @@ ## ✅ 现状补充 (Day 17) -- 前端已拆分为组件化结构(`components/home/`),主页面逻辑集中。 +- 前端已拆分为组件化结构(`features/home/ui/`),主页面逻辑集中。 - 通用工具 `media.ts` 统一处理 API Base / 资源 URL / 日期格式化。 - 作品预览弹窗统一样式,并支持素材/发布预览复用。 - 标题/字幕预览按素材分辨率缩放,效果更接近成片。 diff --git a/Docs/task_complete.md b/Docs/task_complete.md index 8e85e9c..ff0ea77 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -12,11 +12,14 @@ ### Day 17: 前端重构与体验优化 (Current) 🚀 - [x] **UI 组件拆分**: 首页拆分为独立组件,降低 `page.tsx` 复杂度。 +- [x] **轻量 FSD 迁移**: `app` 页面轻量化,逻辑集中到 `features/*/model`,通用能力下沉 `shared/*`。 +- [x] **Controller Hooks**: Home/Publish 页面逻辑集中到 Controller Hook,Page 仅组合渲染。 - [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化。 - [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览。 - [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗。 - [x] **预览体验**: 预览弹窗统一头部样式与提示文案。 - [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放。 +- [x] **标题同步与限制**: 片头标题同步发布标题,输入法合成态兼容,限制 15 字。 - [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。 - [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。 - [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。 diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 1790188..376c70b 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -2,8 +2,8 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { getCurrentUser, User } from '@/lib/auth'; -import api from '@/lib/axios'; +import { getCurrentUser, User } from "@/shared/lib/auth"; +import api from "@/shared/api/axios"; interface UserListItem { id: string; diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 7e610f1..745a87f 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { login } from '@/lib/auth'; +import { login } from "@/shared/lib/auth"; export default function LoginPage() { const router = useRouter(); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index a75503d..785283b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,866 +1,5 @@ +import { HomePage } from "@/features/home/ui/HomePage"; -"use client"; - -import { useState, useEffect, useRef } from "react"; -import api from "@/lib/axios"; -import { - getApiBaseUrl, - resolveMediaUrl, - resolveAssetUrl, - resolveBgmUrl, - getFontFormat, - buildTextShadow, - formatDate, -} from "@/lib/media"; -import { clampTitle } from "@/lib/title"; -import { useTitleSubtitleStyles } from "@/hooks/useTitleSubtitleStyles"; -import { useMaterials } from "@/hooks/useMaterials"; -import { useRefAudios } from "@/hooks/useRefAudios"; -import { useBgm } from "@/hooks/useBgm"; -import { useMediaPlayers } from "@/hooks/useMediaPlayers"; -import { useGeneratedVideos } from "@/hooks/useGeneratedVideos"; -import { useHomePersistence } from "@/hooks/useHomePersistence"; -import { useTitleInput } from "@/hooks/useTitleInput"; -import { useAuth } from "@/contexts/AuthContext"; -import { useTask } from "@/contexts/TaskContext"; -import VideoPreviewModal from "@/components/VideoPreviewModal"; -import ScriptExtractionModal from "@/components/ScriptExtractionModal"; -import { HomeHeader } from "@/components/home/HomeHeader"; -import { MaterialSelector } from "@/components/home/MaterialSelector"; -import { ScriptEditor } from "@/components/home/ScriptEditor"; -import { TitleSubtitlePanel } from "@/components/home/TitleSubtitlePanel"; -import { VoiceSelector } from "@/components/home/VoiceSelector"; -import { RefAudioPanel } from "@/components/home/RefAudioPanel"; -import { BgmPanel } from "@/components/home/BgmPanel"; -import { GenerateActionBar } from "@/components/home/GenerateActionBar"; -import { PreviewPanel } from "@/components/home/PreviewPanel"; -import { HistoryList } from "@/components/home/HistoryList"; - -const API_BASE = getApiBaseUrl(); - -const VOICES = [ - { id: "zh-CN-YunxiNeural", name: "云溪 (男声-年轻)" }, - { id: "zh-CN-YunjianNeural", name: "云健 (男声-新闻)" }, - { id: "zh-CN-YunyangNeural", name: "云扬 (男声-专业)" }, - { id: "zh-CN-XiaoxiaoNeural", name: "晓晓 (女声-活泼)" }, - { id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" }, -]; - -const FIXED_REF_TEXT = "其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。"; - -const scrollContainerToItem = (container: HTMLDivElement, item: HTMLDivElement) => { - const containerRect = container.getBoundingClientRect(); - const itemRect = item.getBoundingClientRect(); - const itemTop = itemRect.top - containerRect.top + container.scrollTop; - const itemBottom = itemTop + itemRect.height; - const viewTop = container.scrollTop; - const viewBottom = viewTop + container.clientHeight; - - if (itemTop < viewTop) { - container.scrollTo({ top: Math.max(itemTop - 8, 0), behavior: 'smooth' }); - } else if (itemBottom > viewBottom) { - container.scrollTo({ top: itemBottom - container.clientHeight + 8, behavior: 'smooth' }); - } -}; - -// 类型定义 -interface Material { - id: string; - name: string; - scene: string; - size_mb: number; - path: string; -} - -interface Task { - task_id: string; - status: string; - progress: number; - message: string; - download_url?: string; -} - -interface GeneratedVideo { - id: string; - name: string; - path: string; - size_mb: number; - created_at: number; -} - -interface RefAudio { - id: string; - name: string; - path: string; - ref_text: string; - duration_sec: number; - created_at: number; -} - - - - - -export default function Home() { - const [selectedMaterial, setSelectedMaterial] = useState(""); - const [previewMaterial, setPreviewMaterial] = useState(null); - - const [text, setText] = useState(""); - const [voice, setVoice] = useState("zh-CN-YunxiNeural"); - - // 使用全局任务状态 - const { currentTask, isGenerating, startTask } = useTask(); - - const [generatedVideo, setGeneratedVideo] = useState(null); - const [selectedVideoId, setSelectedVideoId] = useState(null); - - // 字幕和标题相关状态 - const [videoTitle, setVideoTitle] = useState(""); - const [enableSubtitles, setEnableSubtitles] = useState(true); - const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState(""); - const [selectedTitleStyleId, setSelectedTitleStyleId] = useState(""); - const [subtitleFontSize, setSubtitleFontSize] = useState(60); - const [titleFontSize, setTitleFontSize] = useState(90); - const [subtitleSizeLocked, setSubtitleSizeLocked] = useState(false); - const [titleSizeLocked, setTitleSizeLocked] = useState(false); - const [showStylePreview, setShowStylePreview] = useState(false); - const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null); - const [previewContainerWidth, setPreviewContainerWidth] = useState(0); - - // 背景音乐相关状态 - const [selectedBgmId, setSelectedBgmId] = useState(""); - const [enableBgm, setEnableBgm] = useState(false); - const [bgmVolume, setBgmVolume] = useState(0.2); - - // 声音克隆相关状态 - const [ttsMode, setTtsMode] = useState<'edgetts' | 'voiceclone'>('edgetts'); - const [selectedRefAudio, setSelectedRefAudio] = useState(null); - const [refText, setRefText] = useState('其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。'); - - // 音频预览与重命名状态 - const [editingAudioId, setEditingAudioId] = useState(null); - const [editName, setEditName] = useState(""); - const bgmItemRefs = useRef>({}); - const bgmListContainerRef = useRef(null); - const titlePreviewContainerRef = useRef(null); - const materialItemRefs = useRef>({}); - const videoItemRefs = useRef>({}); - - // 重命名参考音频 - const startEditing = (audio: RefAudio, e: React.MouseEvent) => { - e.stopPropagation(); - setEditingAudioId(audio.id); - // 去掉后缀名进行编辑 (体验更好) - const nameWithoutExt = audio.name.substring(0, audio.name.lastIndexOf('.')); - setEditName(nameWithoutExt || audio.name); - }; - - const cancelEditing = (e: React.MouseEvent) => { - e.stopPropagation(); - setEditingAudioId(null); - setEditName(""); - }; - - const saveEditing = async (audioId: string, e: React.MouseEvent) => { - e.stopPropagation(); - if (!editName.trim()) return; - - try { - await api.put(`/api/ref-audios/${encodeURIComponent(audioId)}`, { new_name: editName }); - setEditingAudioId(null); - fetchRefAudios(); // 刷新列表 - } catch (err: any) { - alert("重命名失败: " + err); - } - }; - - // AI 生成标题标签 - const [isGeneratingMeta, setIsGeneratingMeta] = useState(false); - - // 在线录音相关 - const [isRecording, setIsRecording] = useState(false); - const [recordedBlob, setRecordedBlob] = useState(null); - const [recordingTime, setRecordingTime] = useState(0); - const mediaRecorderRef = useRef(null); - const recordingIntervalRef = useRef(null); - - // 使用全局认证状态 - const { userId, isLoading: isAuthLoading } = useAuth(); - - // 文案提取模态框 - const [extractModalOpen, setExtractModalOpen] = useState(false); - - - - // 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest) - const storageKey = userId || 'guest'; - - const { - materials, - fetchError, - isUploading, - uploadProgress, - uploadError, - setUploadError, - fetchMaterials, - deleteMaterial, - handleUpload, - } = useMaterials({ - selectedMaterial, - setSelectedMaterial, - }); - - const { - subtitleStyles, - titleStyles, - refreshSubtitleStyles, - refreshTitleStyles, - } = useTitleSubtitleStyles({ - isAuthLoading, - storageKey, - setSelectedSubtitleStyleId, - setSelectedTitleStyleId, - }); - - const { - refAudios, - isUploadingRef, - uploadRefError, - setUploadRefError, - fetchRefAudios, - uploadRefAudio, - deleteRefAudio, - } = useRefAudios({ - fixedRefText: FIXED_REF_TEXT, - selectedRefAudio, - setSelectedRefAudio, - setRefText, - }); - - const { - bgmList, - bgmLoading, - bgmError, - fetchBgmList, - } = useBgm({ - storageKey, - selectedBgmId, - setSelectedBgmId, - }); - - const { - playingAudioId, - playingBgmId, - togglePlayPreview, - toggleBgmPreview, - } = useMediaPlayers({ - bgmVolume, - resolveBgmUrl, - resolveMediaUrl, - setSelectedBgmId, - setEnableBgm, - }); - - const { - generatedVideos, - fetchGeneratedVideos, - deleteVideo, - } = useGeneratedVideos({ - storageKey, - selectedVideoId, - setSelectedVideoId, - setGeneratedVideo, - resolveMediaUrl, - }); - - const { isRestored } = useHomePersistence({ - isAuthLoading, - storageKey, - text, - setText, - videoTitle, - setVideoTitle, - enableSubtitles, - setEnableSubtitles, - ttsMode, - setTtsMode, - voice, - setVoice, - selectedMaterial, - setSelectedMaterial, - selectedSubtitleStyleId, - setSelectedSubtitleStyleId, - selectedTitleStyleId, - setSelectedTitleStyleId, - subtitleFontSize, - setSubtitleFontSize, - titleFontSize, - setTitleFontSize, - setSubtitleSizeLocked, - setTitleSizeLocked, - selectedBgmId, - setSelectedBgmId, - bgmVolume, - setBgmVolume, - enableBgm, - setEnableBgm, - selectedVideoId, - setSelectedVideoId, - selectedRefAudio, - }); - - const syncTitleToPublish = (value: string) => { - if (typeof window !== 'undefined') { - localStorage.setItem(`vigent_${storageKey}_publish_title`, value); - } - }; - - const titleInput = useTitleInput({ - value: videoTitle, - onChange: setVideoTitle, - onCommit: syncTitleToPublish, - }); - - // 加载素材列表和历史视频 - useEffect(() => { - if (isAuthLoading) return; - void Promise.allSettled([ - fetchMaterials(), - fetchGeneratedVideos(), - fetchRefAudios(), - refreshSubtitleStyles(), - refreshTitleStyles(), - fetchBgmList(), - ]); - }, [isAuthLoading]); - - useEffect(() => { - const material = materials.find((item) => item.id === selectedMaterial); - if (!material?.path) { - setMaterialDimensions(null); - return; - } - const url = resolveMediaUrl(material.path); - if (!url) { - setMaterialDimensions(null); - return; - } - - let isActive = true; - const video = document.createElement('video'); - video.crossOrigin = 'anonymous'; - video.preload = 'metadata'; - video.src = url; - video.load(); - - const handleLoaded = () => { - if (!isActive) return; - if (video.videoWidth && video.videoHeight) { - setMaterialDimensions({ width: video.videoWidth, height: video.videoHeight }); - } else { - setMaterialDimensions(null); - } - }; - - const handleError = () => { - if (!isActive) return; - setMaterialDimensions(null); - }; - - video.addEventListener('loadedmetadata', handleLoaded); - video.addEventListener('error', handleError); - - return () => { - isActive = false; - video.removeEventListener('loadedmetadata', handleLoaded); - video.removeEventListener('error', handleError); - }; - }, [selectedMaterial, materials]); - - useEffect(() => { - if (!showStylePreview) return; - const container = titlePreviewContainerRef.current; - if (!container) return; - - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (entry) { - setPreviewContainerWidth(entry.contentRect.width); - } - }); - - observer.observe(container); - return () => observer.disconnect(); - }, [showStylePreview]); - - useEffect(() => { - if (typeof window === 'undefined') return; - if ('scrollRestoration' in window.history) { - window.history.scrollRestoration = 'manual'; - } - window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); - }, []); - - // 监听任务完成,自动显示视频 - useEffect(() => { - if (currentTask?.status === 'completed' && currentTask.download_url) { - const resolvedUrl = resolveMediaUrl(currentTask.download_url); - const completedVideoId = currentTask.task_id ? `${currentTask.task_id}_output` : null; - if (resolvedUrl) { - setGeneratedVideo(resolvedUrl); - } - if (completedVideoId) { - setSelectedVideoId(completedVideoId); - } - fetchGeneratedVideos(completedVideoId || undefined); // 刷新历史视频列表 - } - }, [currentTask?.status, currentTask?.download_url, currentTask?.task_id, storageKey]); - - useEffect(() => { - if (subtitleSizeLocked || subtitleStyles.length === 0) return; - const active = subtitleStyles.find(s => s.id === selectedSubtitleStyleId) - || subtitleStyles.find(s => s.is_default) - || subtitleStyles[0]; - if (active?.font_size) { - setSubtitleFontSize(active.font_size); - } - }, [subtitleStyles, selectedSubtitleStyleId, subtitleSizeLocked]); - - useEffect(() => { - if (titleSizeLocked || titleStyles.length === 0) return; - const active = titleStyles.find(s => s.id === selectedTitleStyleId) - || titleStyles.find(s => s.is_default) - || titleStyles[0]; - if (active?.font_size) { - setTitleFontSize(active.font_size); - } - }, [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]); - - useEffect(() => { - if (!selectedBgmId) return; - const container = bgmListContainerRef.current; - const target = bgmItemRefs.current[selectedBgmId]; - if (container && target) { - scrollContainerToItem(container, target); - } - }, [selectedBgmId, bgmList]); - - useEffect(() => { - if (!selectedMaterial) return; - const target = materialItemRefs.current[selectedMaterial]; - if (target) { - target.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } - }, [selectedMaterial, materials]); - - useEffect(() => { - if (!selectedVideoId) return; - const target = videoItemRefs.current[selectedVideoId]; - if (target) { - target.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } - }, [selectedVideoId, generatedVideos]); - - // 自动选择参考音频 (恢复上次选择 或 默认最新的) - useEffect(() => { - // 只有在数据加载完成且尚未选择时才执行 - if (refAudios.length > 0 && !selectedRefAudio && isRestored) { - const savedId = localStorage.getItem(`vigent_${storageKey}_refAudioId`); - let targetAudio = null; - - if (savedId) { - targetAudio = refAudios.find(a => a.id === savedId); - } - - // 如果没找到保存的,或者没有保存,则默认选第一个(最新的) - if (!targetAudio) { - targetAudio = refAudios[0]; - } - - if (targetAudio) { - console.log("[Home] Auto selecting ref audio:", targetAudio.name); - setSelectedRefAudio(targetAudio); - setRefText(targetAudio.ref_text); - } - } - }, [refAudios, isRestored, storageKey]); // 移除 selectedRefAudio 避免循环,但在逻辑中检查 !selectedRefAudio - - // 保存参考音频选择 - useEffect(() => { - if (isRestored && selectedRefAudio) { - localStorage.setItem(`vigent_${storageKey}_refAudioId`, selectedRefAudio.id); - } - }, [selectedRefAudio, storageKey, isRestored]); - - // 开始录音 - const startRecording = async () => { - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); - const chunks: BlobPart[] = []; - - mediaRecorder.ondataavailable = (e) => chunks.push(e.data); - mediaRecorder.onstop = () => { - const blob = new Blob(chunks, { type: 'audio/webm' }); - setRecordedBlob(blob); - stream.getTracks().forEach(track => track.stop()); - }; - - mediaRecorder.start(); - setIsRecording(true); - setRecordingTime(0); - mediaRecorderRef.current = mediaRecorder; - - // 计时器 - recordingIntervalRef.current = setInterval(() => { - setRecordingTime(prev => prev + 1); - }, 1000); - } catch (err) { - alert('无法访问麦克风,请检查权限设置'); - console.error(err); - } - }; - - // 停止录音 - const stopRecording = () => { - mediaRecorderRef.current?.stop(); - setIsRecording(false); - if (recordingIntervalRef.current) { - clearInterval(recordingIntervalRef.current); - recordingIntervalRef.current = null; - } - }; - - // 使用录音(上传到后端,使用固定参考文字) - const useRecording = async () => { - if (!recordedBlob) return; - - // 回归:使用固定文件名,依靠后端自动重命名 (recording(1).webm) - const filename = 'recording.webm'; - - const file = new File([recordedBlob], filename, { type: 'audio/webm' }); - await uploadRefAudio(file); - setRecordedBlob(null); - setRecordingTime(0); - }; - - // 格式化录音时长 - const formatRecordingTime = (seconds: number) => { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - - // AI 生成标题和标签 - const handleGenerateMeta = async () => { - if (!text.trim()) { - alert("请先输入口播文案"); - return; - } - - console.log("[Home] AI生成标题 - userId:", userId, "isRestored:", isRestored); - - setIsGeneratingMeta(true); - try { - const { data } = await api.post('/api/ai/generate-meta', { text: text.trim() }); - - console.log("[Home] AI生成结果:", data); - - // 更新首页标题 - const nextTitle = clampTitle(data.title || ""); - titleInput.commitValue(nextTitle); - - // 同步到发布页 localStorage - console.log("[Home] 保存到 publish localStorage - title:", data.title, "tags:", data.tags); - localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(data.tags || [])); - - } catch (err: any) { - console.error("AI generate meta failed:", err); - const errorMsg = err.response?.data?.detail || err.message || String(err); - alert(`AI 生成失败: ${errorMsg}`); - } finally { - setIsGeneratingMeta(false); - } - }; - - // 生成视频 - const handleGenerate = async () => { - if (!selectedMaterial || !text.trim()) { - alert("请选择素材并输入文案"); - return; - } - - // 声音克隆模式校验 - if (ttsMode === 'voiceclone') { - if (!selectedRefAudio) { - alert("请选择或上传参考音频"); - return; - } - } - - if (enableBgm && !selectedBgmId) { - alert("请选择背景音乐"); - return; - } - - setGeneratedVideo(null); - - try { - // 查找选中的素材对象以获取路径 - const materialObj = materials.find(m => m.id === selectedMaterial); - if (!materialObj) { - alert("素材数据异常"); - return; - } - - // 构建请求参数 - const payload: Record = { - material_path: materialObj.path, - text: text, - tts_mode: ttsMode, - title: videoTitle.trim() || undefined, - enable_subtitles: enableSubtitles, - }; - - if (enableSubtitles && selectedSubtitleStyleId) { - payload.subtitle_style_id = selectedSubtitleStyleId; - } - - if (enableSubtitles && subtitleFontSize) { - payload.subtitle_font_size = Math.round(subtitleFontSize); - } - - if (videoTitle.trim() && selectedTitleStyleId) { - payload.title_style_id = selectedTitleStyleId; - } - - if (videoTitle.trim() && titleFontSize) { - payload.title_font_size = Math.round(titleFontSize); - } - - if (enableBgm && selectedBgmId) { - payload.bgm_id = selectedBgmId; - payload.bgm_volume = bgmVolume; - } - - if (ttsMode === 'edgetts') { - payload.voice = voice; - } else { - payload.ref_audio_id = selectedRefAudio!.id; - payload.ref_text = refText; - } - - // 创建生成任务 - const { data } = await api.post('/api/videos/generate', payload); - - const taskId = data.task_id; - - // 保存任务ID到 localStorage,以便页面切换后恢复 - localStorage.setItem(`vigent_${storageKey}_current_task`, taskId); - - // 使用全局 TaskContext 开始任务 - startTask(taskId); - } catch (error) { - console.error("生成失败:", error); - } - }; - - return ( -
- - -
-
- {/* 左侧: 输入区域 */} -
- {/* 素材选择 */} - { - setPreviewMaterial(resolveMediaUrl(path)); - }} - onDeleteMaterial={deleteMaterial} - onClearUploadError={() => setUploadError(null)} - registerMaterialRef={(id, el) => { - materialItemRefs.current[id] = el; - }} - /> - - {/* 文案输入 */} - setExtractModalOpen(true)} - onGenerateMeta={handleGenerateMeta} - isGeneratingMeta={isGeneratingMeta} - /> - - {/* 标题和字幕设置 */} - setShowStylePreview((prev) => !prev)} - videoTitle={videoTitle} - onTitleChange={titleInput.handleChange} - onTitleCompositionStart={titleInput.handleCompositionStart} - onTitleCompositionEnd={titleInput.handleCompositionEnd} - titleStyles={titleStyles} - selectedTitleStyleId={selectedTitleStyleId} - onSelectTitleStyle={setSelectedTitleStyleId} - titleFontSize={titleFontSize} - onTitleFontSizeChange={(value) => { - setTitleFontSize(value); - setTitleSizeLocked(true); - }} - subtitleStyles={subtitleStyles} - selectedSubtitleStyleId={selectedSubtitleStyleId} - onSelectSubtitleStyle={setSelectedSubtitleStyleId} - subtitleFontSize={subtitleFontSize} - onSubtitleFontSizeChange={(value) => { - setSubtitleFontSize(value); - setSubtitleSizeLocked(true); - }} - enableSubtitles={enableSubtitles} - onToggleSubtitles={setEnableSubtitles} - resolveAssetUrl={resolveAssetUrl} - getFontFormat={getFontFormat} - buildTextShadow={buildTextShadow} - previewScale={previewContainerWidth && (materialDimensions?.width || 1280) - ? previewContainerWidth / (materialDimensions?.width || 1280) - : 1} - previewAspectRatio={materialDimensions - ? `${materialDimensions.width} / ${materialDimensions.height}` - : '16 / 9'} - previewBaseWidth={materialDimensions?.width || 1280} - previewBaseHeight={materialDimensions?.height || 720} - previewContainerRef={titlePreviewContainerRef} - /> - - {/* 配音方式选择 */} - { - setSelectedRefAudio(audio); - setRefText(audio.ref_text); - }} - isUploadingRef={isUploadingRef} - uploadRefError={uploadRefError} - onClearUploadRefError={() => setUploadRefError(null)} - onUploadRefAudio={uploadRefAudio} - onFetchRefAudios={fetchRefAudios} - playingAudioId={playingAudioId} - onTogglePlayPreview={togglePlayPreview} - editingAudioId={editingAudioId} - editName={editName} - onEditNameChange={setEditName} - onStartEditing={startEditing} - onSaveEditing={saveEditing} - onCancelEditing={cancelEditing} - onDeleteRefAudio={deleteRefAudio} - recordedBlob={recordedBlob} - isRecording={isRecording} - recordingTime={recordingTime} - onStartRecording={startRecording} - onStopRecording={stopRecording} - onUseRecording={useRecording} - formatRecordingTime={formatRecordingTime} - fixedRefText={FIXED_REF_TEXT} - /> - )} - /> - - {/* 背景音乐 */} - { - bgmItemRefs.current[id] = el; - }} - /> - - {/* 生成按钮 */} - -
- - {/* 右侧: 预览区域 */} -
- - - { - setSelectedVideoId(video.id); - setGeneratedVideo(resolveMediaUrl(video.path)); - }} - onDeleteVideo={deleteVideo} - onRefresh={() => fetchGeneratedVideos()} - registerVideoRef={(id, el) => { - videoItemRefs.current[id] = el; - }} - formatDate={formatDate} - /> -
-
-
- setPreviewMaterial(null)} - videoUrl={previewMaterial} - title="素材预览" - /> - - setExtractModalOpen(false)} - onApply={(text) => setText(text)} - /> -
- ); +export default function Page() { + return ; } diff --git a/frontend/src/app/publish/page.tsx b/frontend/src/app/publish/page.tsx index 5ee7ef8..70d14a1 100644 --- a/frontend/src/app/publish/page.tsx +++ b/frontend/src/app/publish/page.tsx @@ -1,671 +1,5 @@ -"use client"; - -import { useState, useEffect, useMemo } from "react"; -import useSWR from 'swr'; -import Link from "next/link"; -import api from "@/lib/axios"; -import { getApiBaseUrl, formatDate, resolveMediaUrl, isAbsoluteUrl } from "@/lib/media"; -import { clampTitle } from "@/lib/title"; -import { useAuth } from "@/contexts/AuthContext"; -import AccountSettingsDropdown from "@/components/AccountSettingsDropdown"; -import VideoPreviewModal from "@/components/VideoPreviewModal"; -import { useTitleInput } from "@/hooks/useTitleInput"; -import { - ArrowLeft, - RotateCcw, - LogOut, - QrCode, - Rocket, - Clock, - RefreshCw, - Search, - Eye, -} from "lucide-react"; - -// SWR fetcher 使用 axios(自动处理 401/403) -const fetcher = (url: string) => api.get(url).then((res) => res.data); - -// 动态获取 API 地址:服务端使用 localhost,客户端使用当前域名 -const API_BASE = getApiBaseUrl(); - -interface Account { - platform: string; - name: string; - logged_in: boolean; - enabled: boolean; -} - -interface Video { - name: string; - path: string; -} - -export default function PublishPage() { - const [accounts, setAccounts] = useState([]); - const [videos, setVideos] = useState([]); - const [selectedVideo, setSelectedVideo] = useState(""); - const [videoFilter, setVideoFilter] = useState(""); - const [previewVideoUrl, setPreviewVideoUrl] = useState(null); - const [selectedPlatforms, setSelectedPlatforms] = useState([]); - const [title, setTitle] = useState(""); - const [tags, setTags] = useState(""); - const [isPublishing, setIsPublishing] = useState(false); - const [publishResults, setPublishResults] = useState([]); - const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now"); - const [publishTime, setPublishTime] = useState(""); - const [qrCodeImage, setQrCodeImage] = useState(null); - const [qrPlatform, setQrPlatform] = useState(null); - const [isLoadingQR, setIsLoadingQR] = useState(false); - - // 使用全局认证状态 - const { userId, isLoading: isAuthLoading } = useAuth(); - // 是否已从 localStorage 恢复完成 - const [isRestored, setIsRestored] = useState(false); +import { PublishPage } from "@/features/publish/ui/PublishPage"; - const titleInput = useTitleInput({ - value: title, - onChange: setTitle, - }); - - // 加载账号和视频列表 - useEffect(() => { - void Promise.allSettled([ - fetchAccounts(), - fetchVideos(), - ]); - }, []); - - useEffect(() => { - if (typeof window === 'undefined') return; - 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; - - // 从 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); - } - } 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]); - - const fetchAccounts = async () => { - try { - const { data } = await api.get('/api/publish/accounts'); - setAccounts(data.accounts || []); - } catch (error) { - console.error("获取账号失败:", error); - } - }; - - const fetchVideos = async () => { - try { - const { data } = await api.get('/api/videos/generated'); - - const videos = (data.videos || []).map((v: any) => ({ - name: formatDate(v.created_at) + ` (${v.size_mb.toFixed(1)}MB)`, - path: v.path.startsWith('/') ? v.path.slice(1) : v.path, - })); - - setVideos(videos); - if (videos.length > 0) { - setSelectedVideo(videos[0].path); - } - } catch (error) { - console.error("获取视频失败:", error); - } - }; - - const togglePlatform = (platform: string) => { - if (selectedPlatforms.includes(platform)) { - setSelectedPlatforms(selectedPlatforms.filter((p) => p !== platform)); - } else { - setSelectedPlatforms([...selectedPlatforms, platform]); - } - }; - - const handlePublish = async () => { - if (!selectedVideo || !title || selectedPlatforms.length === 0) { - alert("请选择视频、填写标题并选择至少一个平台"); - return; - } - - setIsPublishing(true); - setPublishResults([]); - - const tagList = tags.split(/[,,\s]+/).filter((t) => t.trim()); - - for (const platform of selectedPlatforms) { - try { - const { data: result } = await api.post('/api/publish', { - video_path: selectedVideo, - platform, - title, - tags: tagList, - description: "", - publish_time: scheduleMode === "scheduled" && publishTime - ? new Date(publishTime).toISOString() - : null - }); - - setPublishResults((prev) => [...prev, result]); - // 发布成功后10秒自动清除结果 - if (result.success) { - setTimeout(() => { - setPublishResults((prev) => prev.filter((r) => r !== result)); - }, 10000); - } - } catch (error: any) { - const message = error.response?.data?.detail || String(error); - setPublishResults((prev) => [ - ...prev, - { platform, success: false, message }, - ]); - } - } - - setIsPublishing(false); - }; - - // SWR Polling for Login Status - const { data: loginStatus } = useSWR( - qrPlatform ? `${API_BASE}/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) { // Double check active - setQrPlatform(null); - setQrCodeImage(null); - alert('登录超时,请重试'); - } - }, 120000); - } - return () => clearTimeout(timer); - }, [qrPlatform]); - - const handleLogin = async (platform: string) => { - setIsLoadingQR(true); - setQrPlatform(platform); // 立即显示加载弹窗 - setQrCodeImage(null); // 清空旧二维码 - try { - const { data: result } = await api.post(`/api/publish/login/${platform}`); - - if (result.success && result.qr_code) { - setQrCodeImage(result.qr_code); - } else { - setQrPlatform(null); - alert(result.message || '登录失败'); - } - } catch (error: any) { - setQrPlatform(null); - alert(`登录失败: ${error.response?.data?.detail || error.message}`); - } finally { - setIsLoadingQR(false); - } - }; - - const handleLogout = async (platform: string) => { - if (!confirm('确定要注销登录吗?')) return; - try { - const { data: result } = await api.post(`/api/publish/logout/${platform}`); - if (result.success) { - alert('已注销'); - fetchAccounts(); - } else { - alert(result.message || '注销失败'); - } - } catch (error: any) { - alert(`注销失败: ${error.response?.data?.detail || error.message}`); - } - }; - - const platformIcons: Record = { - douyin: "🎵", - xiaohongshu: "📕", - weixin: "💬", - kuaishou: "⚡", - bilibili: "📺", - }; - - const filteredVideos = useMemo(() => { - const query = videoFilter.trim().toLowerCase(); - if (!query) return videos; - return videos.filter((v) => v.name.toLowerCase().includes(query)); - }, [videos, videoFilter]); - - return ( -
- setPreviewVideoUrl(null)} - videoUrl={previewVideoUrl} - title="发布视频预览" - /> - {/* QR码弹窗 */} - {qrPlatform && ( -
-
-

🔐 扫码登录 {qrPlatform}

- {isLoadingQR ? ( -
-
-

正在获取二维码...

-
- ) : qrCodeImage ? ( - <> - QR Code -

- 请使用手机扫码登录 -

- - ) : null} - -
-
- )} - - {/* Header - 统一样式 */} -
-
- - 🎬 - IPAgent - -
- - - 返回创作 - - - 发布管理 - - -
-
-
- -
-
- {/* 左侧: 账号管理 */} -
-
-

- 👤 平台账号 -

- -
- {accounts.map((account) => ( -
-
- - {platformIcons[account.platform]} - -
-
- {account.name} -
-
- {account.logged_in ? "✓ 已登录" : "未登录"} -
-
-
-
- {account.logged_in ? ( - <> - - - - ) : ( - - )} -
-
- ))} -
-
-
- - {/* 右侧: 发布表单 */} -
- {/* 选择视频 */} -
-

- 🎥 选择要发布的作品 -

- - {videos.length === 0 ? ( -

- 暂无已生成的视频,请先 - - 生成视频 - -

- ) : ( - <> -
-
- - setVideoFilter(e.target.value)} - placeholder="搜索视频..." - className="w-full pl-9 pr-3 py-2 bg-black/30 border border-white/10 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors" - /> -
- -
- - {filteredVideos.length === 0 ? ( -
- 没有匹配的视频 -
- ) : ( -
- {filteredVideos.map((v) => ( -
- -
- - {selectedVideo === v.path && ( - 已选 - )} -
-
- ))} -
- )} - - )} -
- - {/* 填写信息 */} -
-

✍️ 发布信息

- -
-
- - titleInput.handleChange(e.target.value)} - onCompositionStart={titleInput.handleCompositionStart} - onCompositionEnd={(e) => titleInput.handleCompositionEnd(e.currentTarget.value)} - placeholder="输入视频标题..." - className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500" - /> -
-
- - setTags(e.target.value)} - placeholder="AI, 数字人, 口播..." - className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500" - /> -
-
-
- - {/* 选择平台 */} -
-

📱 选择发布平台

- -
- {accounts - .filter((a) => a.logged_in) - .map((account) => ( - - ))} -
- - {accounts.filter((a) => a.logged_in).length === 0 && ( -

- 请先登录至少一个平台账号 -

- )} -
- - {/* 发布按钮区域 */} -
-
- {/* 立即发布 - 占 3/4 */} - - {/* 定时发布 - 占 1/4 */} - -
- - {/* 定时发布时间选择器 */} - {scheduleMode === "scheduled" && ( -
- setPublishTime(e.target.value)} - min={new Date().toISOString().slice(0, 16)} - className="flex-1 p-3 bg-black/30 border border-white/10 rounded-xl text-white" - /> - -
- )} -
- - {/* 发布结果 */} - {publishResults.length > 0 && ( -
-

- 发布结果 -

-
- {publishResults.map((result, i) => ( -
- - {platformIcons[result.platform]} {result.message} - - {result.success && ( -

- ⏳ 审核一般需要几分钟,请耐心等待 -

- )} -
- ))} -
-
- )} -
-
-
-
- ); +export default function Page() { + return ; } diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index 7b5bdee..5ae017d 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { register } from '@/lib/auth'; +import { register } from "@/shared/lib/auth"; export default function RegisterPage() { const router = useRouter(); diff --git a/frontend/src/components/AccountSettingsDropdown.tsx b/frontend/src/components/AccountSettingsDropdown.tsx index 799525b..bbe1cd6 100644 --- a/frontend/src/components/AccountSettingsDropdown.tsx +++ b/frontend/src/components/AccountSettingsDropdown.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { useAuth } from "@/contexts/AuthContext"; -import api from "@/lib/axios"; +import api from "@/shared/api/axios"; // 账户设置下拉菜单组件 export default function AccountSettingsDropdown() { diff --git a/frontend/src/components/ScriptExtractionModal.tsx b/frontend/src/components/ScriptExtractionModal.tsx index 5980af3..4324524 100644 --- a/frontend/src/components/ScriptExtractionModal.tsx +++ b/frontend/src/components/ScriptExtractionModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import api from "@/lib/axios"; +import api from "@/shared/api/axios"; interface ScriptExtractionModalProps { isOpen: boolean; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 512461b..eff65f5 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,7 +1,7 @@ "use client"; import { createContext, useContext, useState, useEffect, ReactNode } from "react"; -import api from "@/lib/axios"; +import api from "@/shared/api/axios"; interface User { id: string; diff --git a/frontend/src/contexts/TaskContext.tsx b/frontend/src/contexts/TaskContext.tsx index 3200e36..9598095 100644 --- a/frontend/src/contexts/TaskContext.tsx +++ b/frontend/src/contexts/TaskContext.tsx @@ -1,7 +1,7 @@ "use client"; import { createContext, useContext, useState, useEffect, ReactNode } from "react"; -import api from "@/lib/axios"; +import api from "@/shared/api/axios"; interface Task { task_id: string; diff --git a/frontend/src/hooks/useBgm.ts b/frontend/src/features/home/model/useBgm.ts similarity index 97% rename from frontend/src/hooks/useBgm.ts rename to frontend/src/features/home/model/useBgm.ts index aab09a3..9ebab54 100644 --- a/frontend/src/hooks/useBgm.ts +++ b/frontend/src/features/home/model/useBgm.ts @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import api from "@/lib/axios"; +import api from "@/shared/api/axios"; export interface BgmItem { id: string; diff --git a/frontend/src/hooks/useGeneratedVideos.ts b/frontend/src/features/home/model/useGeneratedVideos.ts similarity index 98% rename from frontend/src/hooks/useGeneratedVideos.ts rename to frontend/src/features/home/model/useGeneratedVideos.ts index 9560fa6..52a5161 100644 --- a/frontend/src/hooks/useGeneratedVideos.ts +++ b/frontend/src/features/home/model/useGeneratedVideos.ts @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import api from "@/lib/axios"; +import api from "@/shared/api/axios"; interface GeneratedVideo { id: string; diff --git a/frontend/src/features/home/model/useHomeController.ts b/frontend/src/features/home/model/useHomeController.ts new file mode 100644 index 0000000..ebcba36 --- /dev/null +++ b/frontend/src/features/home/model/useHomeController.ts @@ -0,0 +1,743 @@ +import { useEffect, useRef, useState } from "react"; +import api from "@/shared/api/axios"; +import { + buildTextShadow, + formatDate, + getApiBaseUrl, + getFontFormat, + resolveAssetUrl, + resolveBgmUrl, + resolveMediaUrl, +} from "@/shared/lib/media"; +import { clampTitle } from "@/shared/lib/title"; +import { useTitleInput } from "@/shared/hooks/useTitleInput"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTask } from "@/contexts/TaskContext"; +import { useBgm } from "@/features/home/model/useBgm"; +import { useGeneratedVideos } from "@/features/home/model/useGeneratedVideos"; +import { useHomePersistence } from "@/features/home/model/useHomePersistence"; +import { useMaterials } from "@/features/home/model/useMaterials"; +import { useMediaPlayers } from "@/features/home/model/useMediaPlayers"; +import { useRefAudios } from "@/features/home/model/useRefAudios"; +import { useTitleSubtitleStyles } from "@/features/home/model/useTitleSubtitleStyles"; + +const VOICES = [ + { id: "zh-CN-YunxiNeural", name: "云溪 (男声-年轻)" }, + { id: "zh-CN-YunjianNeural", name: "云健 (男声-新闻)" }, + { id: "zh-CN-YunyangNeural", name: "云扬 (男声-专业)" }, + { id: "zh-CN-XiaoxiaoNeural", name: "晓晓 (女声-活泼)" }, + { id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" }, +]; + +const FIXED_REF_TEXT = + "其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。"; + +const scrollContainerToItem = (container: HTMLDivElement, item: HTMLDivElement) => { + const containerRect = container.getBoundingClientRect(); + const itemRect = item.getBoundingClientRect(); + const itemTop = itemRect.top - containerRect.top + container.scrollTop; + const itemBottom = itemTop + itemRect.height; + const viewTop = container.scrollTop; + const viewBottom = viewTop + container.clientHeight; + + if (itemTop < viewTop) { + container.scrollTo({ top: Math.max(itemTop - 8, 0), behavior: "smooth" }); + } else if (itemBottom > viewBottom) { + container.scrollTo({ top: itemBottom - container.clientHeight + 8, behavior: "smooth" }); + } +}; + +interface GeneratedVideo { + id: string; + name: string; + path: string; + size_mb: number; + created_at: number; +} + +interface RefAudio { + id: string; + name: string; + path: string; + ref_text: string; + duration_sec: number; + created_at: number; +} + +export const useHomeController = () => { + const apiBase = getApiBaseUrl(); + + const [selectedMaterial, setSelectedMaterial] = useState(""); + const [previewMaterial, setPreviewMaterial] = useState(null); + + const [text, setText] = useState(""); + const [voice, setVoice] = useState("zh-CN-YunxiNeural"); + + // 使用全局任务状态 + const { currentTask, isGenerating, startTask } = useTask(); + + const [generatedVideo, setGeneratedVideo] = useState(null); + const [selectedVideoId, setSelectedVideoId] = useState(null); + + // 字幕和标题相关状态 + const [videoTitle, setVideoTitle] = useState(""); + const [enableSubtitles, setEnableSubtitles] = useState(true); + const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState(""); + const [selectedTitleStyleId, setSelectedTitleStyleId] = useState(""); + const [subtitleFontSize, setSubtitleFontSize] = useState(60); + const [titleFontSize, setTitleFontSize] = useState(90); + const [subtitleSizeLocked, setSubtitleSizeLocked] = useState(false); + const [titleSizeLocked, setTitleSizeLocked] = useState(false); + const [showStylePreview, setShowStylePreview] = useState(false); + const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null); + const [previewContainerWidth, setPreviewContainerWidth] = useState(0); + + // 背景音乐相关状态 + const [selectedBgmId, setSelectedBgmId] = useState(""); + const [enableBgm, setEnableBgm] = useState(false); + const [bgmVolume, setBgmVolume] = useState(0.2); + + // 声音克隆相关状态 + const [ttsMode, setTtsMode] = useState<"edgetts" | "voiceclone">("edgetts"); + const [selectedRefAudio, setSelectedRefAudio] = useState(null); + const [refText, setRefText] = useState(FIXED_REF_TEXT); + + // 音频预览与重命名状态 + const [editingAudioId, setEditingAudioId] = useState(null); + const [editName, setEditName] = useState(""); + const bgmItemRefs = useRef>({}); + const bgmListContainerRef = useRef(null); + const titlePreviewContainerRef = useRef(null); + const materialItemRefs = useRef>({}); + const videoItemRefs = useRef>({}); + + // 重命名参考音频 + const startEditing = (audio: RefAudio, e: React.MouseEvent) => { + e.stopPropagation(); + setEditingAudioId(audio.id); + // 去掉后缀名进行编辑 (体验更好) + const nameWithoutExt = audio.name.substring(0, audio.name.lastIndexOf(".")); + setEditName(nameWithoutExt || audio.name); + }; + + const cancelEditing = (e: React.MouseEvent) => { + e.stopPropagation(); + setEditingAudioId(null); + setEditName(""); + }; + + const saveEditing = async (audioId: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (!editName.trim()) return; + + try { + await api.put(`/api/ref-audios/${encodeURIComponent(audioId)}`, { new_name: editName }); + setEditingAudioId(null); + fetchRefAudios(); // 刷新列表 + } catch (err: any) { + alert("重命名失败: " + err); + } + }; + + // AI 生成标题标签 + const [isGeneratingMeta, setIsGeneratingMeta] = useState(false); + + // 在线录音相关 + const [isRecording, setIsRecording] = useState(false); + const [recordedBlob, setRecordedBlob] = useState(null); + const [recordingTime, setRecordingTime] = useState(0); + const mediaRecorderRef = useRef(null); + const recordingIntervalRef = useRef(null); + + // 使用全局认证状态 + const { userId, isLoading: isAuthLoading } = useAuth(); + + // 文案提取模态框 + const [extractModalOpen, setExtractModalOpen] = useState(false); + + // 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest) + const storageKey = userId || "guest"; + + const { + materials, + fetchError, + isUploading, + uploadProgress, + uploadError, + setUploadError, + fetchMaterials, + deleteMaterial, + handleUpload, + } = useMaterials({ + selectedMaterial, + setSelectedMaterial, + }); + + const { + subtitleStyles, + titleStyles, + refreshSubtitleStyles, + refreshTitleStyles, + } = useTitleSubtitleStyles({ + isAuthLoading, + storageKey, + setSelectedSubtitleStyleId, + setSelectedTitleStyleId, + }); + + const { + refAudios, + isUploadingRef, + uploadRefError, + setUploadRefError, + fetchRefAudios, + uploadRefAudio, + deleteRefAudio, + } = useRefAudios({ + fixedRefText: FIXED_REF_TEXT, + selectedRefAudio, + setSelectedRefAudio, + setRefText, + }); + + const { + bgmList, + bgmLoading, + bgmError, + fetchBgmList, + } = useBgm({ + storageKey, + selectedBgmId, + setSelectedBgmId, + }); + + const { + playingAudioId, + playingBgmId, + togglePlayPreview, + toggleBgmPreview, + } = useMediaPlayers({ + bgmVolume, + resolveBgmUrl, + resolveMediaUrl, + setSelectedBgmId, + setEnableBgm, + }); + + const { + generatedVideos, + fetchGeneratedVideos, + deleteVideo, + } = useGeneratedVideos({ + storageKey, + selectedVideoId, + setSelectedVideoId, + setGeneratedVideo, + resolveMediaUrl, + }); + + const { isRestored } = useHomePersistence({ + isAuthLoading, + storageKey, + text, + setText, + videoTitle, + setVideoTitle, + enableSubtitles, + setEnableSubtitles, + ttsMode, + setTtsMode, + voice, + setVoice, + selectedMaterial, + setSelectedMaterial, + selectedSubtitleStyleId, + setSelectedSubtitleStyleId, + selectedTitleStyleId, + setSelectedTitleStyleId, + subtitleFontSize, + setSubtitleFontSize, + titleFontSize, + setTitleFontSize, + setSubtitleSizeLocked, + setTitleSizeLocked, + selectedBgmId, + setSelectedBgmId, + bgmVolume, + setBgmVolume, + enableBgm, + setEnableBgm, + selectedVideoId, + setSelectedVideoId, + selectedRefAudio, + }); + + const syncTitleToPublish = (value: string) => { + if (typeof window !== "undefined") { + localStorage.setItem(`vigent_${storageKey}_publish_title`, value); + } + }; + + const titleInput = useTitleInput({ + value: videoTitle, + onChange: setVideoTitle, + onCommit: syncTitleToPublish, + }); + + // 加载素材列表和历史视频 + useEffect(() => { + if (isAuthLoading) return; + void Promise.allSettled([ + fetchMaterials(), + fetchGeneratedVideos(), + fetchRefAudios(), + refreshSubtitleStyles(), + refreshTitleStyles(), + fetchBgmList(), + ]); + }, [isAuthLoading]); + + useEffect(() => { + const material = materials.find((item) => item.id === selectedMaterial); + if (!material?.path) { + setMaterialDimensions(null); + return; + } + const url = resolveMediaUrl(material.path); + if (!url) { + setMaterialDimensions(null); + return; + } + + let isActive = true; + const video = document.createElement("video"); + video.crossOrigin = "anonymous"; + video.preload = "metadata"; + video.src = url; + video.load(); + + const handleLoaded = () => { + if (!isActive) return; + if (video.videoWidth && video.videoHeight) { + setMaterialDimensions({ width: video.videoWidth, height: video.videoHeight }); + } else { + setMaterialDimensions(null); + } + }; + + const handleError = () => { + if (!isActive) return; + setMaterialDimensions(null); + }; + + video.addEventListener("loadedmetadata", handleLoaded); + video.addEventListener("error", handleError); + + return () => { + isActive = false; + video.removeEventListener("loadedmetadata", handleLoaded); + video.removeEventListener("error", handleError); + }; + }, [materials, selectedMaterial]); + + useEffect(() => { + if (!titlePreviewContainerRef.current) return; + const container = titlePreviewContainerRef.current; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setPreviewContainerWidth(entry.contentRect.width); + } + }); + + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + useEffect(() => { + if (subtitleSizeLocked || subtitleStyles.length === 0) return; + const active = subtitleStyles.find((s) => s.id === selectedSubtitleStyleId) + || subtitleStyles.find((s) => s.is_default) + || subtitleStyles[0]; + if (active?.font_size) { + setSubtitleFontSize(active.font_size); + } + }, [subtitleStyles, selectedSubtitleStyleId, subtitleSizeLocked]); + + useEffect(() => { + if (titleSizeLocked || titleStyles.length === 0) return; + const active = titleStyles.find((s) => s.id === selectedTitleStyleId) + || titleStyles.find((s) => s.is_default) + || titleStyles[0]; + if (active?.font_size) { + setTitleFontSize(active.font_size); + } + }, [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]); + + useEffect(() => { + if (!selectedBgmId) return; + const container = bgmListContainerRef.current; + const target = bgmItemRefs.current[selectedBgmId]; + if (container && target) { + scrollContainerToItem(container, target); + } + }, [selectedBgmId, bgmList]); + + useEffect(() => { + if (!selectedMaterial) return; + const target = materialItemRefs.current[selectedMaterial]; + if (target) { + target.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, [selectedMaterial, materials]); + + useEffect(() => { + if (!selectedVideoId) return; + const target = videoItemRefs.current[selectedVideoId]; + if (target) { + target.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, [selectedVideoId, generatedVideos]); + + // 自动选择参考音频 (恢复上次选择 或 默认最新的) + useEffect(() => { + // 只有在数据加载完成且尚未选择时才执行 + if (refAudios.length > 0 && !selectedRefAudio && isRestored) { + const savedId = localStorage.getItem(`vigent_${storageKey}_refAudioId`); + let targetAudio = null; + + if (savedId) { + targetAudio = refAudios.find((a) => a.id === savedId); + } + + // 如果没找到保存的,或者没有保存,则默认选第一个(最新的) + if (!targetAudio) { + targetAudio = refAudios[0]; + } + + setSelectedRefAudio(targetAudio); + setRefText(targetAudio.ref_text); + } + }, [refAudios, selectedRefAudio, isRestored, storageKey, setSelectedRefAudio, setRefText]); + + useEffect(() => { + if (!selectedRefAudio || !isRestored) return; + localStorage.setItem(`vigent_${storageKey}_refAudioId`, selectedRefAudio.id); + }, [selectedRefAudio, storageKey, isRestored]); + + useEffect(() => { + if (!selectedRefAudio) return; + setRefText(selectedRefAudio.ref_text); + }, [selectedRefAudio]); + + // 开始录音 + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm" }); + const chunks: BlobPart[] = []; + + mediaRecorder.ondataavailable = (e) => chunks.push(e.data); + mediaRecorder.onstop = () => { + const blob = new Blob(chunks, { type: "audio/webm" }); + setRecordedBlob(blob); + stream.getTracks().forEach((track) => track.stop()); + }; + + mediaRecorder.start(); + setIsRecording(true); + setRecordingTime(0); + mediaRecorderRef.current = mediaRecorder; + + // 计时器 + recordingIntervalRef.current = setInterval(() => { + setRecordingTime((prev) => prev + 1); + }, 1000); + } catch (err) { + alert("无法访问麦克风,请检查权限设置"); + console.error(err); + } + }; + + // 停止录音 + const stopRecording = () => { + mediaRecorderRef.current?.stop(); + setIsRecording(false); + if (recordingIntervalRef.current) { + clearInterval(recordingIntervalRef.current); + recordingIntervalRef.current = null; + } + }; + + // 使用录音(上传到后端,使用固定参考文字) + const useRecording = async () => { + if (!recordedBlob) return; + + // 回归:使用固定文件名,依靠后端自动重命名 (recording(1).webm) + const filename = "recording.webm"; + + const file = new File([recordedBlob], filename, { type: "audio/webm" }); + await uploadRefAudio(file); + setRecordedBlob(null); + setRecordingTime(0); + }; + + // 格式化录音时长 + const formatRecordingTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + // AI 生成标题和标签 + const handleGenerateMeta = async () => { + if (!text.trim()) { + alert("请先输入口播文案"); + return; + } + + setIsGeneratingMeta(true); + try { + const { data } = await api.post("/api/ai/generate-meta", { text: text.trim() }); + + // 更新首页标题 + const nextTitle = clampTitle(data.title || ""); + titleInput.commitValue(nextTitle); + + // 同步到发布页 localStorage + localStorage.setItem(`vigent_${storageKey}_publish_tags`, JSON.stringify(data.tags || [])); + } catch (err: any) { + console.error("AI generate meta failed:", err); + const errorMsg = err.response?.data?.detail || err.message || String(err); + alert(`AI 生成失败: ${errorMsg}`); + } finally { + setIsGeneratingMeta(false); + } + }; + + // 生成视频 + const handleGenerate = async () => { + if (!selectedMaterial || !text.trim()) { + alert("请先选择素材并填写文案"); + return; + } + + // 声音克隆模式校验 + if (ttsMode === "voiceclone") { + if (!selectedRefAudio) { + alert("请选择或上传参考音频"); + return; + } + } + + if (enableBgm && !selectedBgmId) { + alert("请选择背景音乐"); + return; + } + + setGeneratedVideo(null); + + try { + // 查找选中的素材对象以获取路径 + const materialObj = materials.find((m) => m.id === selectedMaterial); + if (!materialObj) { + alert("素材数据异常"); + return; + } + + // 构建请求参数 + const payload: Record = { + material_path: materialObj.path, + text: text, + tts_mode: ttsMode, + title: videoTitle.trim() || undefined, + enable_subtitles: enableSubtitles, + }; + + if (enableSubtitles && selectedSubtitleStyleId) { + payload.subtitle_style_id = selectedSubtitleStyleId; + } + + if (enableSubtitles && subtitleFontSize) { + payload.subtitle_font_size = Math.round(subtitleFontSize); + } + + if (videoTitle.trim() && selectedTitleStyleId) { + payload.title_style_id = selectedTitleStyleId; + } + + if (videoTitle.trim() && titleFontSize) { + payload.title_font_size = Math.round(titleFontSize); + } + + if (enableBgm && selectedBgmId) { + payload.bgm_id = selectedBgmId; + payload.bgm_volume = bgmVolume; + } + + if (ttsMode === "edgetts") { + payload.voice = voice; + } else { + payload.ref_audio_id = selectedRefAudio!.id; + payload.ref_text = refText; + } + + // 创建生成任务 + const { data } = await api.post("/api/videos/generate", payload); + + const taskId = data.task_id; + + // 保存任务ID到 localStorage,以便页面切换后恢复 + localStorage.setItem(`vigent_${storageKey}_current_task`, taskId); + + // 使用全局 TaskContext 开始任务 + startTask(taskId); + } catch (error) { + console.error("生成失败:", error); + } + }; + + const handleSelectRefAudio = (audio: RefAudio) => { + setSelectedRefAudio(audio); + setRefText(audio.ref_text); + }; + + const handlePreviewMaterial = (path: string) => { + setPreviewMaterial(resolveMediaUrl(path)); + }; + + const handleSelectVideo = (video: GeneratedVideo) => { + setSelectedVideoId(video.id); + setGeneratedVideo(resolveMediaUrl(video.path)); + }; + + const registerMaterialRef = (id: string, el: HTMLDivElement | null) => { + materialItemRefs.current[id] = el; + }; + + const registerBgmItemRef = (id: string, el: HTMLDivElement | null) => { + bgmItemRefs.current[id] = el; + }; + + const registerVideoRef = (id: string, el: HTMLDivElement | null) => { + videoItemRefs.current[id] = el; + }; + + return { + apiBase, + registerMaterialRef, + previewMaterial, + setPreviewMaterial, + materials, + fetchError, + isUploading, + uploadProgress, + uploadError, + setUploadError, + fetchMaterials, + deleteMaterial, + handleUpload, + selectedMaterial, + setSelectedMaterial, + handlePreviewMaterial, + text, + setText, + extractModalOpen, + setExtractModalOpen, + handleGenerateMeta, + isGeneratingMeta, + showStylePreview, + setShowStylePreview, + videoTitle, + titleInput, + titleStyles, + selectedTitleStyleId, + setSelectedTitleStyleId, + titleFontSize, + setTitleFontSize, + setTitleSizeLocked, + subtitleStyles, + selectedSubtitleStyleId, + setSelectedSubtitleStyleId, + subtitleFontSize, + setSubtitleFontSize, + setSubtitleSizeLocked, + enableSubtitles, + setEnableSubtitles, + resolveAssetUrl, + getFontFormat, + buildTextShadow, + previewContainerWidth, + materialDimensions, + titlePreviewContainerRef, + ttsMode, + setTtsMode, + voices: VOICES, + voice, + setVoice, + refAudios, + selectedRefAudio, + handleSelectRefAudio, + isUploadingRef, + uploadRefError, + setUploadRefError, + uploadRefAudio, + fetchRefAudios, + playingAudioId, + togglePlayPreview, + editingAudioId, + editName, + setEditName, + startEditing, + saveEditing, + cancelEditing, + deleteRefAudio, + recordedBlob, + isRecording, + recordingTime, + startRecording, + stopRecording, + useRecording, + formatRecordingTime, + fixedRefText: FIXED_REF_TEXT, + bgmList, + bgmLoading, + bgmError, + enableBgm, + setEnableBgm, + fetchBgmList, + selectedBgmId, + setSelectedBgmId, + playingBgmId, + toggleBgmPreview, + bgmVolume, + setBgmVolume, + bgmListContainerRef, + registerBgmItemRef, + currentTask, + isGenerating, + handleGenerate, + generatedVideo, + generatedVideos, + selectedVideoId, + handleSelectVideo, + deleteVideo, + fetchGeneratedVideos, + registerVideoRef, + formatDate, + }; +}; diff --git a/frontend/src/hooks/useHomePersistence.ts b/frontend/src/features/home/model/useHomePersistence.ts similarity index 99% rename from frontend/src/hooks/useHomePersistence.ts rename to frontend/src/features/home/model/useHomePersistence.ts index 3cb73ac..f9b2d5d 100644 --- a/frontend/src/hooks/useHomePersistence.ts +++ b/frontend/src/features/home/model/useHomePersistence.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { clampTitle } from "@/lib/title"; +import { clampTitle } from "@/shared/lib/title"; interface RefAudio { id: string; diff --git a/frontend/src/hooks/useMaterials.ts b/frontend/src/features/home/model/useMaterials.ts similarity index 98% rename from frontend/src/hooks/useMaterials.ts rename to frontend/src/features/home/model/useMaterials.ts index 31677b7..ec8c949 100644 --- a/frontend/src/hooks/useMaterials.ts +++ b/frontend/src/features/home/model/useMaterials.ts @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import api from "@/lib/axios"; +import api from "@/shared/api/axios"; interface Material { id: string; diff --git a/frontend/src/hooks/useMediaPlayers.ts b/frontend/src/features/home/model/useMediaPlayers.ts similarity index 98% rename from frontend/src/hooks/useMediaPlayers.ts rename to frontend/src/features/home/model/useMediaPlayers.ts index 9bd7f90..132b25e 100644 --- a/frontend/src/hooks/useMediaPlayers.ts +++ b/frontend/src/features/home/model/useMediaPlayers.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { BgmItem } from "@/hooks/useBgm"; +import type { BgmItem } from "@/features/home/model/useBgm"; interface RefAudio { id: string; diff --git a/frontend/src/hooks/useRefAudios.ts b/frontend/src/features/home/model/useRefAudios.ts similarity index 98% rename from frontend/src/hooks/useRefAudios.ts rename to frontend/src/features/home/model/useRefAudios.ts index fb70fce..66dfa27 100644 --- a/frontend/src/hooks/useRefAudios.ts +++ b/frontend/src/features/home/model/useRefAudios.ts @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import api from "@/lib/axios"; +import api from "@/shared/api/axios"; interface RefAudio { id: string; diff --git a/frontend/src/hooks/useTitleSubtitleStyles.ts b/frontend/src/features/home/model/useTitleSubtitleStyles.ts similarity index 98% rename from frontend/src/hooks/useTitleSubtitleStyles.ts rename to frontend/src/features/home/model/useTitleSubtitleStyles.ts index 79328f0..a159fca 100644 --- a/frontend/src/hooks/useTitleSubtitleStyles.ts +++ b/frontend/src/features/home/model/useTitleSubtitleStyles.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import api from "@/lib/axios"; +import api from "@/shared/api/axios"; export interface SubtitleStyleOption { id: string; diff --git a/frontend/src/components/home/BgmPanel.tsx b/frontend/src/features/home/ui/BgmPanel.tsx similarity index 100% rename from frontend/src/components/home/BgmPanel.tsx rename to frontend/src/features/home/ui/BgmPanel.tsx diff --git a/frontend/src/components/home/GenerateActionBar.tsx b/frontend/src/features/home/ui/GenerateActionBar.tsx similarity index 100% rename from frontend/src/components/home/GenerateActionBar.tsx rename to frontend/src/features/home/ui/GenerateActionBar.tsx diff --git a/frontend/src/components/home/HistoryList.tsx b/frontend/src/features/home/ui/HistoryList.tsx similarity index 100% rename from frontend/src/components/home/HistoryList.tsx rename to frontend/src/features/home/ui/HistoryList.tsx diff --git a/frontend/src/components/home/HomeHeader.tsx b/frontend/src/features/home/ui/HomeHeader.tsx similarity index 100% rename from frontend/src/components/home/HomeHeader.tsx rename to frontend/src/features/home/ui/HomeHeader.tsx diff --git a/frontend/src/features/home/ui/HomePage.tsx b/frontend/src/features/home/ui/HomePage.tsx new file mode 100644 index 0000000..6ae53f2 --- /dev/null +++ b/frontend/src/features/home/ui/HomePage.tsx @@ -0,0 +1,295 @@ +"use client"; + +import VideoPreviewModal from "@/components/VideoPreviewModal"; +import ScriptExtractionModal from "@/components/ScriptExtractionModal"; +import { useHomeController } from "@/features/home/model/useHomeController"; +import { BgmPanel } from "@/features/home/ui/BgmPanel"; +import { GenerateActionBar } from "@/features/home/ui/GenerateActionBar"; +import { HistoryList } from "@/features/home/ui/HistoryList"; +import { HomeHeader } from "@/features/home/ui/HomeHeader"; +import { MaterialSelector } from "@/features/home/ui/MaterialSelector"; +import { PreviewPanel } from "@/features/home/ui/PreviewPanel"; +import { RefAudioPanel } from "@/features/home/ui/RefAudioPanel"; +import { ScriptEditor } from "@/features/home/ui/ScriptEditor"; +import { TitleSubtitlePanel } from "@/features/home/ui/TitleSubtitlePanel"; +import { VoiceSelector } from "@/features/home/ui/VoiceSelector"; + +export function HomePage() { + const { + apiBase, + registerMaterialRef, + previewMaterial, + setPreviewMaterial, + materials, + fetchError, + isUploading, + uploadProgress, + uploadError, + setUploadError, + fetchMaterials, + deleteMaterial, + handleUpload, + selectedMaterial, + setSelectedMaterial, + handlePreviewMaterial, + text, + setText, + extractModalOpen, + setExtractModalOpen, + handleGenerateMeta, + isGeneratingMeta, + showStylePreview, + setShowStylePreview, + videoTitle, + titleInput, + titleStyles, + selectedTitleStyleId, + setSelectedTitleStyleId, + titleFontSize, + setTitleFontSize, + setTitleSizeLocked, + subtitleStyles, + selectedSubtitleStyleId, + setSelectedSubtitleStyleId, + subtitleFontSize, + setSubtitleFontSize, + setSubtitleSizeLocked, + enableSubtitles, + setEnableSubtitles, + resolveAssetUrl, + getFontFormat, + buildTextShadow, + previewContainerWidth, + materialDimensions, + titlePreviewContainerRef, + ttsMode, + setTtsMode, + voices, + voice, + setVoice, + refAudios, + selectedRefAudio, + handleSelectRefAudio, + isUploadingRef, + uploadRefError, + setUploadRefError, + uploadRefAudio, + fetchRefAudios, + playingAudioId, + togglePlayPreview, + editingAudioId, + editName, + setEditName, + startEditing, + saveEditing, + cancelEditing, + deleteRefAudio, + recordedBlob, + isRecording, + recordingTime, + startRecording, + stopRecording, + useRecording, + formatRecordingTime, + fixedRefText, + bgmList, + bgmLoading, + bgmError, + enableBgm, + setEnableBgm, + fetchBgmList, + selectedBgmId, + setSelectedBgmId, + playingBgmId, + toggleBgmPreview, + bgmVolume, + setBgmVolume, + bgmListContainerRef, + registerBgmItemRef, + currentTask, + isGenerating, + handleGenerate, + generatedVideo, + generatedVideos, + selectedVideoId, + handleSelectVideo, + deleteVideo, + fetchGeneratedVideos, + registerVideoRef, + formatDate, + } = useHomeController(); + + return ( +
+ + +
+
+ {/* 左侧: 输入区域 */} +
+ {/* 素材选择 */} + setUploadError(null)} + registerMaterialRef={registerMaterialRef} + /> + + {/* 文案输入 */} + setExtractModalOpen(true)} + onGenerateMeta={handleGenerateMeta} + isGeneratingMeta={isGeneratingMeta} + /> + + {/* 标题和字幕设置 */} + setShowStylePreview((prev) => !prev)} + videoTitle={videoTitle} + onTitleChange={titleInput.handleChange} + onTitleCompositionStart={titleInput.handleCompositionStart} + onTitleCompositionEnd={titleInput.handleCompositionEnd} + titleStyles={titleStyles} + selectedTitleStyleId={selectedTitleStyleId} + onSelectTitleStyle={setSelectedTitleStyleId} + titleFontSize={titleFontSize} + onTitleFontSizeChange={(value) => { + setTitleFontSize(value); + setTitleSizeLocked(true); + }} + subtitleStyles={subtitleStyles} + selectedSubtitleStyleId={selectedSubtitleStyleId} + onSelectSubtitleStyle={setSelectedSubtitleStyleId} + subtitleFontSize={subtitleFontSize} + onSubtitleFontSizeChange={(value) => { + setSubtitleFontSize(value); + setSubtitleSizeLocked(true); + }} + enableSubtitles={enableSubtitles} + onToggleSubtitles={setEnableSubtitles} + resolveAssetUrl={resolveAssetUrl} + getFontFormat={getFontFormat} + buildTextShadow={buildTextShadow} + previewScale={previewContainerWidth && (materialDimensions?.width || 1280) + ? previewContainerWidth / (materialDimensions?.width || 1280) + : 1} + previewAspectRatio={materialDimensions + ? `${materialDimensions.width} / ${materialDimensions.height}` + : "16 / 9"} + previewBaseWidth={materialDimensions?.width || 1280} + previewBaseHeight={materialDimensions?.height || 720} + previewContainerRef={titlePreviewContainerRef} + /> + + {/* 配音方式选择 */} + setUploadRefError(null)} + onUploadRefAudio={uploadRefAudio} + onFetchRefAudios={fetchRefAudios} + playingAudioId={playingAudioId} + onTogglePlayPreview={togglePlayPreview} + editingAudioId={editingAudioId} + editName={editName} + onEditNameChange={setEditName} + onStartEditing={startEditing} + onSaveEditing={saveEditing} + onCancelEditing={cancelEditing} + onDeleteRefAudio={deleteRefAudio} + recordedBlob={recordedBlob} + isRecording={isRecording} + recordingTime={recordingTime} + onStartRecording={startRecording} + onStopRecording={stopRecording} + onUseRecording={useRecording} + formatRecordingTime={formatRecordingTime} + fixedRefText={fixedRefText} + /> + )} + /> + + {/* 背景音乐 */} + + + {/* 生成按钮 */} + +
+ + {/* 右侧: 预览区域 */} +
+ + + fetchGeneratedVideos()} + registerVideoRef={registerVideoRef} + formatDate={formatDate} + /> +
+
+
+ setPreviewMaterial(null)} + videoUrl={previewMaterial} + title="素材预览" + /> + + setExtractModalOpen(false)} + onApply={(nextText) => setText(nextText)} + /> +
+ ); +} diff --git a/frontend/src/components/home/MaterialSelector.tsx b/frontend/src/features/home/ui/MaterialSelector.tsx similarity index 100% rename from frontend/src/components/home/MaterialSelector.tsx rename to frontend/src/features/home/ui/MaterialSelector.tsx diff --git a/frontend/src/components/home/PreviewPanel.tsx b/frontend/src/features/home/ui/PreviewPanel.tsx similarity index 100% rename from frontend/src/components/home/PreviewPanel.tsx rename to frontend/src/features/home/ui/PreviewPanel.tsx diff --git a/frontend/src/components/home/RefAudioPanel.tsx b/frontend/src/features/home/ui/RefAudioPanel.tsx similarity index 100% rename from frontend/src/components/home/RefAudioPanel.tsx rename to frontend/src/features/home/ui/RefAudioPanel.tsx diff --git a/frontend/src/components/home/ScriptEditor.tsx b/frontend/src/features/home/ui/ScriptEditor.tsx similarity index 100% rename from frontend/src/components/home/ScriptEditor.tsx rename to frontend/src/features/home/ui/ScriptEditor.tsx diff --git a/frontend/src/components/home/TitleSubtitlePanel.tsx b/frontend/src/features/home/ui/TitleSubtitlePanel.tsx similarity index 100% rename from frontend/src/components/home/TitleSubtitlePanel.tsx rename to frontend/src/features/home/ui/TitleSubtitlePanel.tsx diff --git a/frontend/src/components/home/VoiceSelector.tsx b/frontend/src/features/home/ui/VoiceSelector.tsx similarity index 100% rename from frontend/src/components/home/VoiceSelector.tsx rename to frontend/src/features/home/ui/VoiceSelector.tsx diff --git a/frontend/src/features/publish/model/usePublishController.ts b/frontend/src/features/publish/model/usePublishController.ts new file mode 100644 index 0000000..6ecbe3a --- /dev/null +++ b/frontend/src/features/publish/model/usePublishController.ts @@ -0,0 +1,323 @@ +import { useEffect, useMemo, useState } from "react"; +import useSWR from "swr"; +import api from "@/shared/api/axios"; +import { formatDate, getApiBaseUrl, isAbsoluteUrl, resolveMediaUrl } from "@/shared/lib/media"; +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; +} + +const fetcher = (url: string) => api.get(url).then((res) => res.data); + +export const usePublishController = () => { + const apiBase = getApiBaseUrl(); + + const [accounts, setAccounts] = useState([]); + const [videos, setVideos] = useState([]); + const [selectedVideo, setSelectedVideo] = useState(""); + const [videoFilter, setVideoFilter] = useState(""); + const [previewVideoUrl, setPreviewVideoUrl] = useState(null); + const [selectedPlatforms, setSelectedPlatforms] = useState([]); + const [title, setTitle] = useState(""); + const [tags, setTags] = useState(""); + const [isPublishing, setIsPublishing] = useState(false); + const [publishResults, setPublishResults] = useState([]); + const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now"); + const [publishTime, setPublishTime] = useState(""); + const [qrCodeImage, setQrCodeImage] = useState(null); + const [qrPlatform, setQrPlatform] = useState(null); + const [isLoadingQR, setIsLoadingQR] = useState(false); + + // 使用全局认证状态 + const { userId, isLoading: isAuthLoading } = useAuth(); + // 是否已从 localStorage 恢复完成 + const [isRestored, setIsRestored] = useState(false); + + const titleInput = useTitleInput({ + value: title, + onChange: setTitle, + }); + + const fetchAccounts = async () => { + try { + const { data } = await api.get("/api/publish/accounts"); + setAccounts(data.accounts || []); + } catch (error) { + console.error("获取账号失败:", error); + } + }; + + const fetchVideos = async () => { + try { + const { data } = await api.get("/api/videos/generated"); + + const nextVideos = (data.videos || []).map((v: any) => ({ + 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); + } + } catch (error) { + console.error("获取视频失败:", error); + } + }; + + useEffect(() => { + void Promise.allSettled([ + fetchAccounts(), + fetchVideos(), + ]); + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + 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; + + // 从 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); + } + } 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]); + + const togglePlatform = (platform: string) => { + if (selectedPlatforms.includes(platform)) { + setSelectedPlatforms(selectedPlatforms.filter((p) => p !== platform)); + } else { + setSelectedPlatforms([...selectedPlatforms, platform]); + } + }; + + const handlePublish = async () => { + if (!selectedVideo || !title || selectedPlatforms.length === 0) { + alert("请选择视频、填写标题并选择至少一个平台"); + return; + } + + setIsPublishing(true); + setPublishResults([]); + + const tagList = tags.split(/[,,\s]+/).filter((t) => t.trim()); + + for (const platform of selectedPlatforms) { + try { + const { data: result } = await api.post("/api/publish", { + video_path: selectedVideo, + platform, + title, + tags: tagList, + description: "", + publish_time: scheduleMode === "scheduled" && publishTime + ? new Date(publishTime).toISOString() + : null, + }); + + setPublishResults((prev) => [...prev, result]); + // 发布成功后10秒自动清除结果 + if (result.success) { + setTimeout(() => { + setPublishResults((prev) => prev.filter((r) => r !== result)); + }, 10000); + } + } catch (error: any) { + const message = error.response?.data?.detail || String(error); + setPublishResults((prev) => [ + ...prev, + { platform, success: false, message }, + ]); + } + } + + setIsPublishing(false); + }; + + // 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); + setQrCodeImage(null); + try { + const { data: result } = await api.post(`/api/publish/login/${platform}`); + + if (result.success && result.qr_code) { + setQrCodeImage(result.qr_code); + } else { + setQrPlatform(null); + alert(result.message || "登录失败"); + } + } catch (error: any) { + setQrPlatform(null); + alert(`登录失败: ${error.response?.data?.detail || error.message}`); + } finally { + setIsLoadingQR(false); + } + }; + + const handleLogout = async (platform: string) => { + if (!confirm("确定要注销登录吗?")) return; + try { + const { data: result } = await api.post(`/api/publish/logout/${platform}`); + if (result.success) { + alert("已注销"); + fetchAccounts(); + } else { + alert(result.message || "注销失败"); + } + } catch (error: any) { + alert(`注销失败: ${error.response?.data?.detail || error.message}`); + } + }; + + const platformIcons: Record = { + douyin: "🎵", + xiaohongshu: "📕", + weixin: "💬", + kuaishou: "⚡", + bilibili: "📺", + }; + + const filteredVideos = useMemo(() => { + const query = videoFilter.trim().toLowerCase(); + if (!query) return videos; + return videos.filter((v) => v.name.toLowerCase().includes(query)); + }, [videos, videoFilter]); + + const handlePreviewVideo = (path: string) => { + const previewPath = isAbsoluteUrl(path) + ? path + : path.startsWith("/") + ? path + : `/${path}`; + setPreviewVideoUrl(resolveMediaUrl(previewPath) || previewPath); + }; + + const closeQrModal = () => { + setQrCodeImage(null); + setQrPlatform(null); + }; + + return { + apiBase, + accounts, + videos, + selectedVideo, + setSelectedVideo, + videoFilter, + setVideoFilter, + previewVideoUrl, + setPreviewVideoUrl, + selectedPlatforms, + title, + titleInput, + tags, + setTags, + isPublishing, + publishResults, + scheduleMode, + setScheduleMode, + publishTime, + setPublishTime, + qrCodeImage, + qrPlatform, + isLoadingQR, + fetchAccounts, + fetchVideos, + togglePlatform, + handlePublish, + handleLogin, + handleLogout, + platformIcons, + filteredVideos, + handlePreviewVideo, + closeQrModal, + }; +}; diff --git a/frontend/src/features/publish/ui/PublishPage.tsx b/frontend/src/features/publish/ui/PublishPage.tsx new file mode 100644 index 0000000..e68b62d --- /dev/null +++ b/frontend/src/features/publish/ui/PublishPage.tsx @@ -0,0 +1,381 @@ +"use client"; + +import Link from "next/link"; +import VideoPreviewModal from "@/components/VideoPreviewModal"; +import AccountSettingsDropdown from "@/components/AccountSettingsDropdown"; +import { usePublishController } from "@/features/publish/model/usePublishController"; +import { + ArrowLeft, + RotateCcw, + LogOut, + QrCode, + Rocket, + Clock, + Search, + Eye, +} from "lucide-react"; + +export function PublishPage() { + const { + accounts, + selectedVideo, + setSelectedVideo, + videoFilter, + setVideoFilter, + previewVideoUrl, + setPreviewVideoUrl, + selectedPlatforms, + title, + titleInput, + tags, + setTags, + isPublishing, + publishResults, + scheduleMode, + setScheduleMode, + publishTime, + setPublishTime, + qrCodeImage, + qrPlatform, + isLoadingQR, + togglePlatform, + handlePublish, + handleLogin, + handleLogout, + platformIcons, + filteredVideos, + handlePreviewVideo, + closeQrModal, + } = usePublishController(); + + return ( +
+ setPreviewVideoUrl(null)} + videoUrl={previewVideoUrl} + title="发布视频预览" + /> + {/* QR码弹窗 */} + {qrPlatform && ( +
+
+

🔐 扫码登录 {qrPlatform}

+ {isLoadingQR ? ( +
+
+

正在获取二维码...

+
+ ) : qrCodeImage ? ( + <> + QR Code +

+ 请使用手机扫码登录 +

+ + ) : null} + +
+
+ )} + + {/* Header - 统一样式 */} +
+
+ + 🎬 + IPAgent + +
+ + + 返回创作 + + + 发布管理 + + +
+
+
+ +
+
+ {/* 左侧: 账号管理 */} +
+
+

+ 👤 平台账号 +

+ +
+ {accounts.map((account) => ( +
+
+ + {platformIcons[account.platform]} + +
+
+ {account.name} +
+
+ {account.logged_in ? "✓ 已登录" : "未登录"} +
+
+
+
+ {account.logged_in ? ( + <> + + + + ) : ( + + )} +
+
+ ))} +
+
+
+ + {/* 右侧: 发布设置 */} +
+ {/* 选择视频 */} +
+

📹 选择发布作品

+ +
+ + setVideoFilter(e.target.value)} + placeholder="搜索视频名称..." + className="flex-1 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-purple-500" + /> +
+ + {filteredVideos.length === 0 ? ( +
+ 暂无可发布的视频 +
+ ) : ( +
+ {filteredVideos.map((v) => ( +
setSelectedVideo(v.path)} + className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.path + ? "border-purple-500 bg-purple-500/20" + : "border-white/10 bg-white/5 hover:border-white/30" + }`} + > +
+ {v.name} +
+
+ + {selectedVideo === v.path && ( + 已选 + )} +
+
+ ))} +
+ )} +
+ + {/* 填写信息 */} +
+

✍️ 发布信息

+ +
+
+ + titleInput.handleChange(e.target.value)} + onCompositionStart={titleInput.handleCompositionStart} + onCompositionEnd={(e) => titleInput.handleCompositionEnd(e.currentTarget.value)} + placeholder="输入视频标题..." + className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500" + /> +
+
+ + setTags(e.target.value)} + placeholder="AI, 数字人, 口播..." + className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500" + /> +
+
+
+ + {/* 选择平台 */} +
+

📱 选择发布平台

+ +
+ {accounts + .filter((a) => a.logged_in) + .map((account) => ( + + ))} +
+
+ + {/* 定时发布 */} +
+

+ ⏰ 发布设置 +

+ +
+
+ + +
+ + {scheduleMode === "scheduled" && ( + setPublishTime(e.target.value)} + className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white" + /> + )} +
+
+ + {/* 发布按钮 */} + + + {/* 发布结果 */} + {publishResults.length > 0 && ( +
+ {publishResults.map((result, index) => ( +
+
+ + {platformIcons[result.platform]} + + + {result.success ? "发布成功" : "发布失败"} + +
+

{result.message}

+
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/lib/axios.ts b/frontend/src/shared/api/axios.ts similarity index 100% rename from frontend/src/lib/axios.ts rename to frontend/src/shared/api/axios.ts diff --git a/frontend/src/hooks/useTitleInput.ts b/frontend/src/shared/hooks/useTitleInput.ts similarity index 95% rename from frontend/src/hooks/useTitleInput.ts rename to frontend/src/shared/hooks/useTitleInput.ts index 66205e0..e6f75e4 100644 --- a/frontend/src/hooks/useTitleInput.ts +++ b/frontend/src/shared/hooks/useTitleInput.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; -import { applyTitleLimit, TITLE_MAX_LENGTH } from "@/lib/title"; +import { applyTitleLimit, TITLE_MAX_LENGTH } from "@/shared/lib/title"; interface UseTitleInputOptions { value: string; diff --git a/frontend/src/lib/auth.ts b/frontend/src/shared/lib/auth.ts similarity index 100% rename from frontend/src/lib/auth.ts rename to frontend/src/shared/lib/auth.ts diff --git a/frontend/src/lib/media.ts b/frontend/src/shared/lib/media.ts similarity index 100% rename from frontend/src/lib/media.ts rename to frontend/src/shared/lib/media.ts diff --git a/frontend/src/lib/title.ts b/frontend/src/shared/lib/title.ts similarity index 100% rename from frontend/src/lib/title.ts rename to frontend/src/shared/lib/title.ts