Files
ViGent2/Docs/FRONTEND_DEV.md
Kevin Wong 1717635bfd 更新
2026-02-25 17:51:58 +08:00

477 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前端开发规范
## 目录结构
采用轻量 FSDFeature-Sliced Design结构
```
frontend/src/
├── app/ # Next.js App Router 页面入口
│ ├── page.tsx # 首页(视频生成)
│ ├── publish/ # 发布管理页
│ ├── admin/ # 管理员页面
│ ├── login/ # 登录
│ ├── register/ # 注册
│ └── pay/ # 付费开通会员
├── features/ # 功能模块(按业务拆分)
│ ├── home/
│ │ ├── model/ # 业务逻辑 hooks
│ │ │ ├── useHomeController.ts # 主控制器
│ │ │ ├── useHomePersistence.ts # 持久化管理
│ │ │ ├── useBgm.ts
│ │ │ ├── useGeneratedVideos.ts
│ │ │ ├── useGeneratedAudios.ts
│ │ │ ├── useMaterials.ts
│ │ │ ├── useMediaPlayers.ts
│ │ │ ├── useRefAudios.ts
│ │ │ ├── useSavedScripts.ts
│ │ │ ├── useTimelineEditor.ts
│ │ │ └── useTitleSubtitleStyles.ts
│ │ └── ui/ # UI 组件(纯 props + 回调)
│ │ ├── HomePage.tsx
│ │ ├── HomeHeader.tsx
│ │ ├── MaterialSelector.tsx
│ │ ├── ScriptEditor.tsx
│ │ ├── ScriptExtractionModal.tsx
│ │ ├── script-extraction/
│ │ │ └── useScriptExtraction.ts
│ │ ├── TitleSubtitlePanel.tsx
│ │ ├── FloatingStylePreview.tsx
│ │ ├── VoiceSelector.tsx
│ │ ├── RefAudioPanel.tsx
│ │ ├── GeneratedAudiosPanel.tsx
│ │ ├── TimelineEditor.tsx
│ │ ├── ClipTrimmer.tsx
│ │ ├── BgmPanel.tsx
│ │ ├── GenerateActionBar.tsx
│ │ ├── PreviewPanel.tsx
│ │ └── HistoryList.tsx
│ └── publish/
│ ├── model/
│ │ └── usePublishController.ts
│ └── ui/
│ └── PublishPage.tsx
├── shared/ # 跨功能共享
│ ├── api/
│ │ ├── axios.ts # Axios 实例(含 401/403 拦截器)
│ │ └── types.ts # 统一响应类型
│ ├── lib/
│ │ ├── media.ts # API Base / URL / 日期等通用工具
│ │ ├── auth.ts # 认证相关函数
│ │ └── title.ts # 标题输入处理
│ ├── hooks/
│ │ ├── useTitleInput.ts
│ │ └── usePublishPrefetch.ts
│ ├── types/
│ │ ├── user.ts # User 类型定义
│ │ └── publish.ts # 发布相关类型
│ └── contexts/ # 全局 ContextAuth、Task
│ ├── AuthContext.tsx
│ └── TaskContext.tsx
├── components/ # 遗留通用组件
│ └── VideoPreviewModal.tsx
└── proxy.ts # Next.js middleware路由保护
```
---
## iOS Safari 安全区域兼容
### 问题
iPhone Safari 浏览器顶部(刘海/灵动岛和底部Home 指示条)有安全区域,默认情况下页面背景不会延伸到这些区域,导致白边。
### 解决方案(三层配合)
#### 1. Viewport 配置 (`layout.tsx`)
```typescript
import type { Viewport } from "next";
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover', // 允许内容延伸到安全区域
themeColor: '#0f172a', // 顶部状态栏颜色(与背景一致)
};
```
#### 2. 全局背景统一到 body (`layout.tsx`)
```tsx
<html lang="en" style={{ backgroundColor: '#0f172a' }}>
<body
style={{
margin: 0,
minHeight: '100dvh', // 使用 dvh 而非 vh
background: 'linear-gradient(to bottom, #0f172a 0%, #0f172a 5%, #581c87 50%, #0f172a 95%, #0f172a 100%)',
}}
>
{children}
</body>
</html>
```
#### 3. CSS 安全区域支持 (`globals.css`)
```css
html {
background-color: #0f172a !important;
min-height: 100%;
}
body {
margin: 0 !important;
min-height: 100dvh;
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
```
### 关键要点
- **渐变背景放 body不放页面 div** - 安全区域在 div 之外
- **使用 `100dvh` 而非 `100vh`** - dvh 是动态视口高度,适配移动端
- **themeColor 与背景边缘色一致** - 避免状态栏色差
- **页面 div 移除独立背景** - 使用透明,继承 body 渐变
---
## 移动端响应式规范
### Header 按钮布局
```tsx
// 移动端紧凑,桌面端宽松
<div className="flex items-center gap-1 sm:gap-4">
<button className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base ...">
</button>
</div>
```
### 常用响应式断点
| 断点 | 宽度 | 用途 |
|------|------|------|
| 默认 | < 640px | 移动端 |
| `sm:` | ≥ 640px | 平板/桌面 |
| `lg:` | ≥ 1024px | 大屏桌面 |
### embedded 组件模式
合并板块时,子组件通过 `embedded?: boolean` prop 控制是否渲染外层卡片容器和主标题。
```tsx
// embedded=false独立使用渲染完整卡片
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
<h2></h2>
{content}
</div>
// embedded=true嵌入父卡片只渲染内容
{content}
```
- 子标题使用 `<h3 className="text-sm font-medium text-gray-400">`
- 分隔线使用 `<div className="border-t border-white/10 my-4" />`
- 移动端标题行避免 `whitespace-nowrap`,长描述文字可用 `hidden sm:inline` 在移动端隐藏
### 按钮视觉层级
| 层级 | 样式 | 用途 |
|------|------|------|
| 主操作 | `px-4 py-2 text-sm font-medium bg-gradient-to-r from-purple-600 to-pink-600 shadow-sm` | 生成配音、立即发布 |
| 辅助操作 | `px-2 py-1 text-xs bg-white/10 rounded` | 刷新、上传、语速 |
| 触屏可见 | `opacity-40 group-hover:opacity-100` | 列表行内操作(编辑/删除) |
---
## API 请求规范
### 必须使用 `api` (axios 实例)
所有需要认证的 API 请求**必须**使用 `@/shared/api/axios` 导出的 axios 实例。该实例已配置:
- 自动携带 `credentials: include`
- 遇到 401/403 时自动清除 cookie 并跳转登录页
**使用方式:**
```typescript
import api from '@/shared/api/axios';
// GET 请求
const { data } = await api.get('/api/materials');
// POST 请求
const { data } = await api.post('/api/videos/generate', {
text: '...',
voice: '...',
});
// DELETE 请求
await api.delete(`/api/materials/${id}`);
// 带上传进度的文件上传
await api.post('/api/materials', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total) {
const progress = Math.round((e.loaded / e.total) * 100);
setProgress(progress);
}
},
});
```
### SWR 配合使用
```typescript
import api from '@/shared/api/axios';
// SWR fetcher 使用 axios
const fetcher = (url: string) => api.get(url).then(res => res.data);
const { data } = useSWR('/api/xxx', fetcher, { refreshInterval: 2000 });
```
---
## 通用工具函数 (media.ts)
### 统一 API Base / URL 解析
使用 `@/shared/lib/media` 统一处理服务端/客户端 API Base 与资源地址,避免硬编码:
```typescript
import { getApiBaseUrl, resolveMediaUrl, resolveAssetUrl, formatDate } from '@/shared/lib/media';
const apiBase = getApiBaseUrl(); // SSR: http://localhost:8006 / Client: ''
const playableUrl = resolveMediaUrl(video.path); // 兼容签名 URL 与相对路径
const fontUrl = resolveAssetUrl(`fonts/${fontFile}`);
const timeText = formatDate(video.created_at);
```
### 资源路径规则
- 视频/音频:优先用 `resolveMediaUrl()`
- 字体/BGM使用 `resolveAssetUrl()`(自动编码中文路径)
- 预览前若已有签名 URL先用 `isAbsoluteUrl()` 判定,避免再次拼接
---
## 日期格式化规范
### 禁止使用 `toLocaleString()`
`toLocaleString()` 在服务端和客户端可能返回不同格式,导致 Hydration 错误。
**错误示例:**
```typescript
// ❌ 会导致 Hydration 错误
new Date(timestamp * 1000).toLocaleString('zh-CN')
```
**正确做法:**
```typescript
// ✅ 使用固定格式
import { formatDate } from '@/shared/lib/media';
```
---
## 组件拆分规范
当页面组件超过 300-500 行,建议按功能拆分到 `features/*/ui`
- `page.tsx` 仅做组合与布局
- 业务逻辑集中在 `features/*/model` 的 Controller Hook
- UI 组件只接受 props 与回调,尽量不直接发 API
- 首页拆分组件统一放在 `features/home/ui/`
---
## ⚡️ 体验优化规范
### 刷新回顶部(统一体验)
- 长页面(如首页/发布页)在首次挂载时统一回到顶部。
- **必须**在页面级 `useEffect` 中设置 `history.scrollRestoration = "manual"` 禁用浏览器原生滚动恢复。
- 调用 `window.scrollTo({ top: 0, left: 0, behavior: "auto" })` 并追加 200ms 延迟兜底(防止异步 effect 覆盖)。
- **列表自动滚动必须使用时间门控**:页面加载后 1 秒内禁止所有列表自动滚动效果(`scrollEffectsEnabled` ref防止持久化恢复 + 异步数据加载触发 `scrollIntoView` 导致页面跳动。
- 推荐模式:
```typescript
// 页面级HomePage / PublishPage
useEffect(() => {
if (typeof window === "undefined") return;
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
const timer = setTimeout(() => window.scrollTo({ top: 0, left: 0, behavior: "auto" }), 200);
return () => clearTimeout(timer);
}, []);
// Controller 级(列表滚动时间门控)
const scrollEffectsEnabled = useRef(false);
useEffect(() => {
const timer = setTimeout(() => { scrollEffectsEnabled.current = true; }, 1000);
return () => clearTimeout(timer);
}, []);
// 列表滚动 effectBGM/素材/视频等)
useEffect(() => {
if (!selectedId || !scrollEffectsEnabled.current) return;
target?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [selectedId, list]);
```
### 路由预取
- 首页进入发布管理时使用 `router.prefetch("/publish")`
- 只预取路由,不在首页渲染发布页组件
### 发布页数据预取缓存
- 使用 `sessionStorage` 保存最近的 `accounts/videos`
- 缓存 TTL 2 分钟,进入发布页先读缓存,随后后台刷新
### 骨架屏
- 账号列表、作品列表、素材列表在加载时显示骨架
- 骨架数量应与历史数据数量相近(避免加载时数量跳变)
### 预览加载优化
- 预览 `video` 使用 `preload="metadata"`
- 发布页预览按钮可进行短时 `preload` 预取
---
## 轻量 FSD 结构
- `app/`:页面入口,保持轻量,只做组合与布局
- `features/*/model`业务逻辑与状态Controller Hook + 子 Hook
- `features/*/ui`:功能 UI 组件(纯 props + 回调,不直接发 API
- `shared/api`Axios 实例与统一响应类型
- `shared/lib`通用工具函数media.ts / auth.ts / title.ts
- `shared/hooks`:跨功能通用 hooks
- `shared/types`跨功能实体类型User / PublishVideo 等)
- `shared/contexts`:全局 ContextAuthContext / TaskContext
- `components/`遗留通用组件VideoPreviewModal
## 类型定义规范
- 通用实体类型(如 User, Account, Video统一放置在 `src/shared/types/`
- 特定业务类型放在 feature 目录下的 types.ts 或 model 中。
- **禁止**在多个地方重复定义 User 接口,统一引用 `import { User } from '@/shared/types/user';`
---
## 用户偏好持久化
首页涉及样式与字号等用户偏好时,需持久化并在刷新后恢复:
- **必须持久化**
- 标题样式 ID / 字幕样式 ID
- 标题字号 / 字幕字号
- 标题显示模式(`short` / `persistent`
- 背景音乐选择 / 音量 / 开关状态
- 输出画面比例(`9:16` / `16:9`
- 素材选择 / 历史作品选择
- 选中配音 ID (`selectedAudioId`)
- 语速 (`speed`,声音克隆模式)
- 时间轴段信息 (`useTimelineEditor` 的 localStorage)
### 历史文案(独立持久化)
`useSavedScripts` hook 独立管理历史文案的 localStorage 持久化:
- key: `vigent_{storageKey}_savedScripts`
- 仅在用户手动保存/删除时写入 localStorage不使用自动持久化 effect
-`useHomePersistence` 完全独立,互不影响
### 实施规范
- 使用 `storageKey = userId || 'guest'`,按用户隔离。
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
- 避免默认值覆盖用户选择(优先读取已保存值)。
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
- **禁止使用签名 URL 作为持久化标识**Supabase Storage 签名 URL 每次请求都变化,必须使用后端返回的稳定 `id` 字段。
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
---
## 标题输入规则
- 片头标题与发布信息标题统一限制 15 字。
- 中文输入法合成阶段不截断,合成结束后才校验长度。
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`
- 标题显示模式使用 `short` / `persistent` 两个固定值;默认 `short`(短暂显示 4 秒)。
- 避免使用 `maxLength` 强制截断输入法合成态。
- 推荐使用 `@/shared/hooks/useTitleInput` 统一处理输入逻辑。
---
## 发布页交互规则
- 发布按钮在未选择任何平台时禁用
- 仅保留"立即发布",不再提供定时发布 UI/参数
- **作品选择持久化**:使用 `video.id`(稳定标识)而非 `video.path`(签名 URL进行选择、比较和 localStorage 存储。发布时根据 `id` 查找对应 `path` 发送请求。
---
## 新增页面 Checklist
1. [ ] 导入 `import api from '@/shared/api/axios'`
2. [ ] 所有 API 请求使用 `api.get/post/delete()` 而非原生 `fetch`
3. [ ] 日期格式化使用 `@/shared/lib/media``formatDate`
4. [ ] 资源 URL 使用 `resolveMediaUrl`/`resolveAssetUrl`
5. [ ] 添加 `'use client'` 指令(如需客户端交互)
---
## 声音克隆 (Voice Clone) 功能
### API 端点
| 接口 | 方法 | 功能 |
|------|------|------|
| `/api/ref-audios` | POST | 上传参考音频 (multipart/form-data: fileref_text 可选,后端自动 Whisper 转写) |
| `/api/ref-audios` | GET | 列出用户的参考音频 |
| `/api/ref-audios/{id}` | PUT | 重命名参考音频 |
| `/api/ref-audios/{id}` | DELETE | 删除参考音频 (id 需 encodeURIComponent) |
| `/api/ref-audios/{id}/retranscribe` | POST | 重新识别参考音频文字Whisper 转写 + 超 10s 自动截取) |
### 视频生成 API 扩展
```typescript
// EdgeTTS 模式 (默认)
await api.post('/api/videos/generate', {
material_path: '...',
text: '口播文案',
tts_mode: 'edgetts',
voice: 'zh-CN-YunxiNeural',
});
// 声音克隆模式
await api.post('/api/videos/generate', {
material_path: '...',
text: '口播文案',
tts_mode: 'voiceclone',
ref_audio_id: 'user_id/timestamp_name.wav',
ref_text: '参考音频对应文字', // 从参考音频 metadata 自动获取
speed: 1.0, // 语速 (0.8-1.2)
});
```
### 在线录音
使用 `MediaRecorder` API 录制音频,格式为 `audio/webm`,上传后后端自动转换为 WAV (16kHz mono)。
```typescript
// 录音需要用户授权麦克风
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
```
### 参考音频自动处理
- **自动转写**: 上传参考音频时后端自动调用 Whisper 转写内容作为 `ref_text`,无需用户手动输入
- **自动截取**: 参考音频超过 10 秒时自动在静音点截取前 10 秒CosyVoice 建议 3-10 秒)
- **重新识别**: 旧参考音频可通过 retranscribe 端点重新转写并截取
### UI 结构
配音方式使用 Tab 切换:
- **EdgeTTS 音色** - 预设音色 2x3 网格
- **声音克隆** - 参考音频列表 + 在线录音 + 语速下拉菜单 (5 档: 较慢/稍慢/正常/稍快/较快)