This commit is contained in:
Kevin Wong
2026-02-04 18:04:17 +08:00
parent aaa8088c82
commit b2c1042c5c
45 changed files with 1849 additions and 1592 deletions

View File

@@ -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 响应式 |

View File

@@ -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 登录重定向 |
---

View File

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

View File

@@ -127,7 +127,7 @@ if service["failures"] >= service['threshold']:
- 交互按钮保持一致尺寸与对齐
### 涉及文件
- `frontend/src/components/home/`
- `frontend/src/features/home/ui/`
- `frontend/src/app/publish/page.tsx`
---

View File

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

View File

@@ -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` | 管理后台 | ✅ |

View File

@@ -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'` 指令(如需客户端交互)

View File

@@ -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。
## 🎨 设计规范

View File

@@ -60,7 +60,7 @@
## ✅ 现状补充 (Day 17)
- 前端已拆分为组件化结构(`components/home/`),主页面逻辑集中。
- 前端已拆分为组件化结构(`features/home/ui/`),主页面逻辑集中。
- 通用工具 `media.ts` 统一处理 API Base / 资源 URL / 日期格式化。
- 作品预览弹窗统一样式,并支持素材/发布预览复用。
- 标题/字幕预览按素材分辨率缩放,效果更接近成片。

View File

@@ -12,11 +12,14 @@
### Day 17: 前端重构与体验优化 (Current) 🚀
- [x] **UI 组件拆分**: 首页拆分为独立组件,降低 `page.tsx` 复杂度。
- [x] **轻量 FSD 迁移**: `app` 页面轻量化,逻辑集中到 `features/*/model`,通用能力下沉 `shared/*`
- [x] **Controller Hooks**: Home/Publish 页面逻辑集中到 Controller HookPage 仅组合渲染。
- [x] **通用工具抽取**: `media.ts` 统一 API Base / URL / 日期格式化。
- [x] **交互优化**: 选择项持久化、列表内定位、刷新回顶部、最新作品优先预览。
- [x] **发布页改造**: 作品列表卡片化 + 搜索 + 预览弹窗。
- [x] **预览体验**: 预览弹窗统一头部样式与提示文案。
- [x] **预览一致性**: 标题/字幕预览按素材分辨率缩放。
- [x] **标题同步与限制**: 片头标题同步发布标题,输入法合成态兼容,限制 15 字。
- [x] **样式默认与持久化**: 默认样式与字号调整,刷新保留用户选择。
- [x] **性能微优化**: 列表渲染优化 + 并行请求 + localStorage 防抖。
- [x] **资源能力**: 字体/BGM 资源库 + `/api/assets` 接入。

View File

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

View File

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

View File

@@ -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<string>("");
const [previewMaterial, setPreviewMaterial] = useState<string | null>(null);
const [text, setText] = useState<string>("");
const [voice, setVoice] = useState<string>("zh-CN-YunxiNeural");
// 使用全局任务状态
const { currentTask, isGenerating, startTask } = useTask();
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
// 字幕和标题相关状态
const [videoTitle, setVideoTitle] = useState<string>("");
const [enableSubtitles, setEnableSubtitles] = useState<boolean>(true);
const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState<string>("");
const [selectedTitleStyleId, setSelectedTitleStyleId] = useState<string>("");
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(60);
const [titleFontSize, setTitleFontSize] = useState<number>(90);
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState<boolean>(false);
const [titleSizeLocked, setTitleSizeLocked] = useState<boolean>(false);
const [showStylePreview, setShowStylePreview] = useState<boolean>(false);
const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null);
const [previewContainerWidth, setPreviewContainerWidth] = useState<number>(0);
// 背景音乐相关状态
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
const [enableBgm, setEnableBgm] = useState<boolean>(false);
const [bgmVolume, setBgmVolume] = useState<number>(0.2);
// 声音克隆相关状态
const [ttsMode, setTtsMode] = useState<'edgetts' | 'voiceclone'>('edgetts');
const [selectedRefAudio, setSelectedRefAudio] = useState<RefAudio | null>(null);
const [refText, setRefText] = useState('其实生活中有许多美好的瞬间,比如清晨的阳光,或者一杯温热的清茶。希望这次生成的音色能够自然、流畅,完美还原出我最真实的声音状态。');
// 音频预览与重命名状态
const [editingAudioId, setEditingAudioId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const bgmItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const bgmListContainerRef = useRef<HTMLDivElement | null>(null);
const titlePreviewContainerRef = useRef<HTMLDivElement | null>(null);
const materialItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const videoItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
// 重命名参考音频
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<Blob | null>(null);
const [recordingTime, setRecordingTime] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(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<string, any> = {
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 (
<div className="min-h-dvh">
<HomeHeader />
<main className="max-w-6xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 左侧: 输入区域 */}
<div className="space-y-6">
{/* 素材选择 */}
<MaterialSelector
materials={materials}
selectedMaterial={selectedMaterial}
isUploading={isUploading}
uploadProgress={uploadProgress}
uploadError={uploadError}
fetchError={fetchError}
apiBase={API_BASE}
onUploadChange={handleUpload}
onRefresh={fetchMaterials}
onSelectMaterial={setSelectedMaterial}
onPreviewMaterial={(path) => {
setPreviewMaterial(resolveMediaUrl(path));
}}
onDeleteMaterial={deleteMaterial}
onClearUploadError={() => setUploadError(null)}
registerMaterialRef={(id, el) => {
materialItemRefs.current[id] = el;
}}
/>
{/* 文案输入 */}
<ScriptEditor
text={text}
onChangeText={setText}
onOpenExtractModal={() => setExtractModalOpen(true)}
onGenerateMeta={handleGenerateMeta}
isGeneratingMeta={isGeneratingMeta}
/>
{/* 标题和字幕设置 */}
<TitleSubtitlePanel
showStylePreview={showStylePreview}
onTogglePreview={() => 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}
/>
{/* 配音方式选择 */}
<VoiceSelector
ttsMode={ttsMode}
onSelectTtsMode={setTtsMode}
voices={VOICES}
voice={voice}
onSelectVoice={setVoice}
voiceCloneSlot={(
<RefAudioPanel
refAudios={refAudios}
selectedRefAudio={selectedRefAudio}
onSelectRefAudio={(audio) => {
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}
/>
)}
/>
{/* 背景音乐 */}
<BgmPanel
bgmList={bgmList}
bgmLoading={bgmLoading}
bgmError={bgmError}
enableBgm={enableBgm}
onToggleEnable={setEnableBgm}
onRefresh={fetchBgmList}
selectedBgmId={selectedBgmId}
onSelectBgm={setSelectedBgmId}
playingBgmId={playingBgmId}
onTogglePreview={toggleBgmPreview}
bgmVolume={bgmVolume}
onVolumeChange={setBgmVolume}
bgmListContainerRef={bgmListContainerRef}
registerBgmItemRef={(id, el) => {
bgmItemRefs.current[id] = el;
}}
/>
{/* 生成按钮 */}
<GenerateActionBar
isGenerating={isGenerating}
progress={currentTask?.progress || 0}
disabled={isGenerating || !selectedMaterial || (ttsMode === 'voiceclone' && !selectedRefAudio)}
onGenerate={handleGenerate}
/>
</div>
{/* 右侧: 预览区域 */}
<div className="space-y-6">
<PreviewPanel
currentTask={currentTask}
isGenerating={isGenerating}
generatedVideo={generatedVideo}
/>
<HistoryList
generatedVideos={generatedVideos}
selectedVideoId={selectedVideoId}
onSelectVideo={(video) => {
setSelectedVideoId(video.id);
setGeneratedVideo(resolveMediaUrl(video.path));
}}
onDeleteVideo={deleteVideo}
onRefresh={() => fetchGeneratedVideos()}
registerVideoRef={(id, el) => {
videoItemRefs.current[id] = el;
}}
formatDate={formatDate}
/>
</div>
</div>
</main >
<VideoPreviewModal
onClose={() => setPreviewMaterial(null)}
videoUrl={previewMaterial}
title="素材预览"
/>
<ScriptExtractionModal
isOpen={extractModalOpen}
onClose={() => setExtractModalOpen(false)}
onApply={(text) => setText(text)}
/>
</div >
);
export default function Page() {
return <HomePage />;
}

View File

@@ -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<Account[]>([]);
const [videos, setVideos] = useState<Video[]>([]);
const [selectedVideo, setSelectedVideo] = useState<string>("");
const [videoFilter, setVideoFilter] = useState<string>("");
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [title, setTitle] = useState<string>("");
const [tags, setTags] = useState<string>("");
const [isPublishing, setIsPublishing] = useState(false);
const [publishResults, setPublishResults] = useState<any[]>([]);
const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
const [publishTime, setPublishTime] = useState<string>("");
const [qrCodeImage, setQrCodeImage] = useState<string | null>(null);
const [qrPlatform, setQrPlatform] = useState<string | null>(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<string, string> = {
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 (
<div className="min-h-dvh">
<VideoPreviewModal
onClose={() => setPreviewVideoUrl(null)}
videoUrl={previewVideoUrl}
title="发布视频预览"
/>
{/* QR码弹窗 */}
{qrPlatform && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-8 max-w-md min-w-[320px]">
<h2 className="text-2xl font-bold mb-4 text-center">🔐 {qrPlatform}</h2>
{isLoadingQR ? (
<div className="flex flex-col items-center py-8">
<div className="animate-spin w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full" />
<p className="text-gray-600 mt-4">...</p>
</div>
) : qrCodeImage ? (
<>
<img
src={`data:image/png;base64,${qrCodeImage}`}
alt="QR Code"
className="w-full h-auto"
/>
<p className="text-center text-gray-600 mt-4">
使
</p>
</>
) : null}
<button
onClick={() => { setQrCodeImage(null); setQrPlatform(null); }}
className="w-full mt-4 px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
>
</button>
</div>
</div>
)}
{/* Header - 统一样式 */}
<header className="border-b border-white/10 bg-black/20 backdrop-blur-sm relative z-[100]">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between">
<Link href="/" className="text-xl sm:text-2xl font-bold text-white flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity">
<span className="text-3xl sm:text-4xl">🎬</span>
IPAgent
</Link>
<div className="flex items-center gap-1 sm:gap-4">
<Link
href="/"
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors flex items-center gap-1"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<span className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
</span>
<AccountSettingsDropdown />
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 左侧: 账号管理 */}
<div className="space-y-6">
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
👤
</h2>
<div className="space-y-3">
{accounts.map((account) => (
<div
key={account.platform}
className="flex items-center justify-between p-4 bg-black/30 rounded-xl"
>
<div className="flex items-center gap-3">
<span className="text-2xl">
{platformIcons[account.platform]}
</span>
<div>
<div className="text-white font-medium">
{account.name}
</div>
<div
className={`text-sm ${account.logged_in
? "text-green-400"
: "text-gray-500"
}`}
>
{account.logged_in ? "✓ 已登录" : "未登录"}
</div>
</div>
</div>
<div className="flex gap-2">
{account.logged_in ? (
<>
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleLogout(account.platform)}
className="px-3 py-1 bg-red-500/80 hover:bg-red-600 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
>
<LogOut className="h-3.5 w-3.5" />
</button>
</>
) : (
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
>
<QrCode className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* 右侧: 发布表单 */}
<div className="space-y-6">
{/* 选择视频 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4">
🎥
</h2>
{videos.length === 0 ? (
<p className="text-gray-400">
<Link href="/" className="text-purple-400 hover:underline">
</Link>
</p>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
value={videoFilter}
onChange={(e) => 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"
/>
</div>
<button
onClick={fetchVideos}
className="px-2 py-2 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
{filteredVideos.length === 0 ? (
<div className="text-center py-4 text-gray-500 text-sm">
</div>
) : (
<div
className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar"
style={{ contentVisibility: 'auto' }}
>
{filteredVideos.map((v) => (
<div
key={v.path}
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedVideo === v.path
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<button
onClick={() => setSelectedVideo(v.path)}
className="flex-1 text-left"
>
<div className="text-white text-sm truncate">
{v.name}
</div>
</button>
<div className="flex items-center gap-2 pl-2">
<button
onClick={(e) => {
e.stopPropagation();
const previewPath = isAbsoluteUrl(v.path)
? v.path
: v.path.startsWith('/')
? v.path
: `/${v.path}`;
setPreviewVideoUrl(resolveMediaUrl(previewPath) || previewPath);
}}
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
title="预览"
>
<Eye className="h-4 w-4" />
</button>
{selectedVideo === v.path && (
<span className="text-xs text-purple-300"></span>
)}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
{/* 填写信息 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4"> </h2>
<div className="space-y-4">
<div>
<label className="block text-gray-400 text-sm mb-2">
</label>
<input
type="text"
value={title}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2">
()
</label>
<input
type="text"
value={tags}
onChange={(e) => 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"
/>
</div>
</div>
</div>
{/* 选择平台 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4">📱 </h2>
<div className="grid grid-cols-3 gap-3">
{accounts
.filter((a) => a.logged_in)
.map((account) => (
<button
key={account.platform}
onClick={() => togglePlatform(account.platform)}
className={`p-3 rounded-xl border-2 transition-all ${selectedPlatforms.includes(account.platform)
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<span className="text-2xl block mb-1">
{platformIcons[account.platform]}
</span>
<span className="text-white text-sm">{account.name}</span>
</button>
))}
</div>
{accounts.filter((a) => a.logged_in).length === 0 && (
<p className="text-gray-400 text-center py-4">
</p>
)}
</div>
{/* 发布按钮区域 */}
<div className="space-y-3">
<div className="flex gap-3">
{/* 立即发布 - 占 3/4 */}
<button
onClick={() => {
setScheduleMode("now");
handlePublish();
}}
disabled={isPublishing || selectedPlatforms.length === 0}
className={`flex-[3] py-4 rounded-xl font-bold text-lg transition-all flex items-center justify-center gap-2 ${isPublishing || selectedPlatforms.length === 0
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-gradient-to-r from-green-600 to-teal-600 hover:from-green-700 hover:to-teal-700 text-white"
}`}
>
{isPublishing && scheduleMode === "now" ? (
"发布中..."
) : (
<>
<Rocket className="h-5 w-5" />
</>
)}
</button>
{/* 定时发布 - 占 1/4 */}
<button
onClick={() => setScheduleMode(scheduleMode === "scheduled" ? "now" : "scheduled")}
disabled={isPublishing || selectedPlatforms.length === 0}
className={`flex-1 py-4 rounded-xl font-bold text-base transition-all flex items-center justify-center gap-2 ${isPublishing || selectedPlatforms.length === 0
? "bg-gray-600 cursor-not-allowed text-gray-400"
: scheduleMode === "scheduled"
? "bg-purple-600 text-white"
: "bg-white/10 hover:bg-white/20 text-white"
}`}
>
<Clock className="h-5 w-5" />
</button>
</div>
{/* 定时发布时间选择器 */}
{scheduleMode === "scheduled" && (
<div className="flex gap-3 items-center">
<input
type="datetime-local"
value={publishTime}
onChange={(e) => 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"
/>
<button
onClick={handlePublish}
disabled={isPublishing || selectedPlatforms.length === 0 || !publishTime}
className={`px-6 py-3 rounded-xl font-bold transition-all ${isPublishing || selectedPlatforms.length === 0 || !publishTime
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
}`}
>
{isPublishing && scheduleMode === "scheduled" ? "设置中..." : "确认定时"}
</button>
</div>
)}
</div>
{/* 发布结果 */}
{publishResults.length > 0 && (
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
<h2 className="text-lg font-semibold text-white mb-4">
</h2>
<div className="space-y-2">
{publishResults.map((result, i) => (
<div
key={i}
className={`p-3 rounded-lg ${result.success ? "bg-green-500/20" : "bg-red-500/20"
}`}
>
<span className="text-white">
{platformIcons[result.platform]} {result.message}
</span>
{result.success && (
<p className="text-green-400/80 text-sm mt-1">
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</main>
</div>
);
export default function Page() {
return <PublishPage />;
}

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import api from "@/lib/axios";
import api from "@/shared/api/axios";
interface GeneratedVideo {
id: string;

View File

@@ -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<string>("");
const [previewMaterial, setPreviewMaterial] = useState<string | null>(null);
const [text, setText] = useState<string>("");
const [voice, setVoice] = useState<string>("zh-CN-YunxiNeural");
// 使用全局任务状态
const { currentTask, isGenerating, startTask } = useTask();
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
// 字幕和标题相关状态
const [videoTitle, setVideoTitle] = useState<string>("");
const [enableSubtitles, setEnableSubtitles] = useState<boolean>(true);
const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState<string>("");
const [selectedTitleStyleId, setSelectedTitleStyleId] = useState<string>("");
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(60);
const [titleFontSize, setTitleFontSize] = useState<number>(90);
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState<boolean>(false);
const [titleSizeLocked, setTitleSizeLocked] = useState<boolean>(false);
const [showStylePreview, setShowStylePreview] = useState<boolean>(false);
const [materialDimensions, setMaterialDimensions] = useState<{ width: number; height: number } | null>(null);
const [previewContainerWidth, setPreviewContainerWidth] = useState<number>(0);
// 背景音乐相关状态
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
const [enableBgm, setEnableBgm] = useState<boolean>(false);
const [bgmVolume, setBgmVolume] = useState<number>(0.2);
// 声音克隆相关状态
const [ttsMode, setTtsMode] = useState<"edgetts" | "voiceclone">("edgetts");
const [selectedRefAudio, setSelectedRefAudio] = useState<RefAudio | null>(null);
const [refText, setRefText] = useState(FIXED_REF_TEXT);
// 音频预览与重命名状态
const [editingAudioId, setEditingAudioId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const bgmItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const bgmListContainerRef = useRef<HTMLDivElement | null>(null);
const titlePreviewContainerRef = useRef<HTMLDivElement | null>(null);
const materialItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const videoItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
// 重命名参考音频
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<Blob | null>(null);
const [recordingTime, setRecordingTime] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(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<string, any> = {
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,
};
};

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { clampTitle } from "@/lib/title";
import { clampTitle } from "@/shared/lib/title";
interface RefAudio {
id: string;

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import api from "@/lib/axios";
import api from "@/shared/api/axios";
interface Material {
id: string;

View File

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

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import api from "@/lib/axios";
import api from "@/shared/api/axios";
interface RefAudio {
id: string;

View File

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

View File

@@ -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 (
<div className="min-h-dvh">
<HomeHeader />
<main className="max-w-6xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 左侧: 输入区域 */}
<div className="space-y-6">
{/* 素材选择 */}
<MaterialSelector
materials={materials}
selectedMaterial={selectedMaterial}
isUploading={isUploading}
uploadProgress={uploadProgress}
uploadError={uploadError}
fetchError={fetchError}
apiBase={apiBase}
onUploadChange={handleUpload}
onRefresh={fetchMaterials}
onSelectMaterial={setSelectedMaterial}
onPreviewMaterial={handlePreviewMaterial}
onDeleteMaterial={deleteMaterial}
onClearUploadError={() => setUploadError(null)}
registerMaterialRef={registerMaterialRef}
/>
{/* 文案输入 */}
<ScriptEditor
text={text}
onChangeText={setText}
onOpenExtractModal={() => setExtractModalOpen(true)}
onGenerateMeta={handleGenerateMeta}
isGeneratingMeta={isGeneratingMeta}
/>
{/* 标题和字幕设置 */}
<TitleSubtitlePanel
showStylePreview={showStylePreview}
onTogglePreview={() => 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}
/>
{/* 配音方式选择 */}
<VoiceSelector
ttsMode={ttsMode}
onSelectTtsMode={setTtsMode}
voices={voices}
voice={voice}
onSelectVoice={setVoice}
voiceCloneSlot={(
<RefAudioPanel
refAudios={refAudios}
selectedRefAudio={selectedRefAudio}
onSelectRefAudio={handleSelectRefAudio}
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={fixedRefText}
/>
)}
/>
{/* 背景音乐 */}
<BgmPanel
bgmList={bgmList}
bgmLoading={bgmLoading}
bgmError={bgmError}
enableBgm={enableBgm}
onToggleEnable={setEnableBgm}
onRefresh={fetchBgmList}
selectedBgmId={selectedBgmId}
onSelectBgm={setSelectedBgmId}
playingBgmId={playingBgmId}
onTogglePreview={toggleBgmPreview}
bgmVolume={bgmVolume}
onVolumeChange={setBgmVolume}
bgmListContainerRef={bgmListContainerRef}
registerBgmItemRef={registerBgmItemRef}
/>
{/* 生成按钮 */}
<GenerateActionBar
isGenerating={isGenerating}
progress={currentTask?.progress || 0}
disabled={isGenerating || !selectedMaterial || (ttsMode === "voiceclone" && !selectedRefAudio)}
onGenerate={handleGenerate}
/>
</div>
{/* 右侧: 预览区域 */}
<div className="space-y-6">
<PreviewPanel
currentTask={currentTask}
isGenerating={isGenerating}
generatedVideo={generatedVideo}
/>
<HistoryList
generatedVideos={generatedVideos}
selectedVideoId={selectedVideoId}
onSelectVideo={handleSelectVideo}
onDeleteVideo={deleteVideo}
onRefresh={() => fetchGeneratedVideos()}
registerVideoRef={registerVideoRef}
formatDate={formatDate}
/>
</div>
</div>
</main>
<VideoPreviewModal
onClose={() => setPreviewMaterial(null)}
videoUrl={previewMaterial}
title="素材预览"
/>
<ScriptExtractionModal
isOpen={extractModalOpen}
onClose={() => setExtractModalOpen(false)}
onApply={(nextText) => setText(nextText)}
/>
</div>
);
}

View File

@@ -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<Account[]>([]);
const [videos, setVideos] = useState<Video[]>([]);
const [selectedVideo, setSelectedVideo] = useState<string>("");
const [videoFilter, setVideoFilter] = useState<string>("");
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [title, setTitle] = useState<string>("");
const [tags, setTags] = useState<string>("");
const [isPublishing, setIsPublishing] = useState(false);
const [publishResults, setPublishResults] = useState<any[]>([]);
const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
const [publishTime, setPublishTime] = useState<string>("");
const [qrCodeImage, setQrCodeImage] = useState<string | null>(null);
const [qrPlatform, setQrPlatform] = useState<string | null>(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<string, string> = {
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,
};
};

View File

@@ -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 (
<div className="min-h-dvh">
<VideoPreviewModal
onClose={() => setPreviewVideoUrl(null)}
videoUrl={previewVideoUrl}
title="发布视频预览"
/>
{/* QR码弹窗 */}
{qrPlatform && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-8 max-w-md min-w-[320px]">
<h2 className="text-2xl font-bold mb-4 text-center">🔐 {qrPlatform}</h2>
{isLoadingQR ? (
<div className="flex flex-col items-center py-8">
<div className="animate-spin w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full" />
<p className="text-gray-600 mt-4">...</p>
</div>
) : qrCodeImage ? (
<>
<img
src={`data:image/png;base64,${qrCodeImage}`}
alt="QR Code"
className="w-full h-auto"
/>
<p className="text-center text-gray-600 mt-4">
使
</p>
</>
) : null}
<button
onClick={closeQrModal}
className="w-full mt-4 px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
>
</button>
</div>
</div>
)}
{/* Header - 统一样式 */}
<header className="border-b border-white/10 bg-black/20 backdrop-blur-sm relative z-[100]">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between">
<Link
href="/"
className="text-xl sm:text-2xl font-bold text-white flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity"
>
<span className="text-3xl sm:text-4xl">🎬</span>
IPAgent
</Link>
<div className="flex items-center gap-1 sm:gap-4">
<Link
href="/"
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors flex items-center gap-1"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<span className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
</span>
<AccountSettingsDropdown />
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 左侧: 账号管理 */}
<div className="space-y-6">
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
👤
</h2>
<div className="space-y-3">
{accounts.map((account) => (
<div
key={account.platform}
className="flex items-center justify-between p-4 bg-black/30 rounded-xl"
>
<div className="flex items-center gap-3">
<span className="text-2xl">
{platformIcons[account.platform]}
</span>
<div>
<div className="text-white font-medium">
{account.name}
</div>
<div
className={`text-sm ${account.logged_in
? "text-green-400"
: "text-gray-500"
}`}
>
{account.logged_in ? "✓ 已登录" : "未登录"}
</div>
</div>
</div>
<div className="flex gap-2">
{account.logged_in ? (
<>
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleLogout(account.platform)}
className="px-3 py-1 bg-red-500/80 hover:bg-red-600 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
>
<LogOut className="h-3.5 w-3.5" />
</button>
</>
) : (
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-purple-500/80 hover:bg-purple-600 text-white text-sm rounded-lg transition-colors flex items-center gap-1"
>
<QrCode className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* 右侧: 发布设置 */}
<div className="space-y-6">
{/* 选择视频 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4">📹 </h2>
<div className="flex items-center gap-3 mb-4">
<Search className="text-gray-400 w-4 h-4" />
<input
type="text"
value={videoFilter}
onChange={(e) => 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"
/>
</div>
{filteredVideos.length === 0 ? (
<div className="text-center py-8 text-gray-400">
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar" style={{ contentVisibility: "auto" }}>
{filteredVideos.map((v) => (
<div
key={v.path}
onClick={() => setSelectedVideo(v.path)}
className={`p-3 rounded-lg border transition-all flex items-center justify-between group cursor-pointer ${selectedVideo === v.path
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<div className="flex flex-col">
<span className="text-sm text-white">{v.name}</span>
</div>
<div className="flex items-center gap-2 pl-2">
<button
onClick={(e) => {
e.stopPropagation();
handlePreviewVideo(v.path);
}}
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
title="预览"
>
<Eye className="h-4 w-4" />
</button>
{selectedVideo === v.path && (
<span className="text-xs text-purple-300"></span>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* 填写信息 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4"> </h2>
<div className="space-y-4">
<div>
<label className="block text-gray-400 text-sm mb-2">
</label>
<input
type="text"
value={title}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2">
()
</label>
<input
type="text"
value={tags}
onChange={(e) => 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"
/>
</div>
</div>
</div>
{/* 选择平台 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4">📱 </h2>
<div className="grid grid-cols-3 gap-3">
{accounts
.filter((a) => a.logged_in)
.map((account) => (
<button
key={account.platform}
onClick={() => togglePlatform(account.platform)}
className={`p-3 rounded-xl border-2 transition-all ${selectedPlatforms.includes(account.platform)
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<span className="text-2xl block mb-1">
{platformIcons[account.platform]}
</span>
<span className="text-white text-sm">{account.name}</span>
</button>
))}
</div>
</div>
{/* 定时发布 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
</h2>
<div className="space-y-4">
<div className="flex items-center gap-3">
<button
onClick={() => setScheduleMode("now")}
className={`flex-1 p-3 rounded-xl border-2 transition-all ${scheduleMode === "now"
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<Rocket className="h-5 w-5 mx-auto mb-1" />
<span className="text-white text-sm"></span>
</button>
<button
onClick={() => setScheduleMode("scheduled")}
className={`flex-1 p-3 rounded-xl border-2 transition-all ${scheduleMode === "scheduled"
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<Clock className="h-5 w-5 mx-auto mb-1" />
<span className="text-white text-sm"></span>
</button>
</div>
{scheduleMode === "scheduled" && (
<input
type="datetime-local"
value={publishTime}
onChange={(e) => setPublishTime(e.target.value)}
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white"
/>
)}
</div>
</div>
{/* 发布按钮 */}
<button
onClick={handlePublish}
disabled={isPublishing || (scheduleMode === "scheduled" && !publishTime)}
className="w-full py-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-bold text-lg hover:shadow-lg hover:from-purple-500 hover:to-pink-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPublishing
? "正在发布..."
: scheduleMode === "scheduled"
? "定时发布"
: "立即发布"}
</button>
{/* 发布结果 */}
{publishResults.length > 0 && (
<div className="space-y-3">
{publishResults.map((result, index) => (
<div
key={index}
className={`p-4 rounded-xl border ${result.success
? "border-green-500/50 bg-green-500/10"
: "border-red-500/50 bg-red-500/10"
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">
{platformIcons[result.platform]}
</span>
<span className={`font-medium ${result.success ? "text-green-400" : "text-red-400"}`}>
{result.success ? "发布成功" : "发布失败"}
</span>
</div>
<p className="text-sm text-gray-300">{result.message}</p>
</div>
))}
</div>
)}
</div>
</div>
</main>
</div>
);
}

View File

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