Files
ViGent2/Docs/FRONTEND_DEV.md
Kevin Wong 945262a7fc 更新
2026-02-06 16:02:58 +08:00

330 lines
9.0 KiB
Markdown
Raw 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.
# 前端开发规范
## 目录结构
```
frontend/src/
├── app/ # Next.js App Router 页面
│ ├── page.tsx # 首页(视频生成)
│ ├── publish/ # 发布页面
│ ├── admin/ # 管理员页面
│ ├── login/ # 登录页面
│ └── register/ # 注册页面
├── components/ # 可复用组件
│ ├── home/ # 首页拆分组件
│ └── ...
├── lib/ # 公共工具函数
│ ├── axios.ts # Axios 实例(含 401/403 拦截器)
│ ├── auth.ts # 认证相关函数
│ └── media.ts # API Base / URL / 日期等通用工具
└── proxy.ts # 路由代理(原 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 | 大屏桌面 |
---
## 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/`
---
## ⚡️ 体验优化规范
### 路由预取
- 首页进入发布管理时使用 `router.prefetch("/publish")`
- 只预取路由,不在首页渲染发布页组件
### 发布页数据预取缓存
- 使用 `sessionStorage` 保存最近的 `accounts/videos`
- 缓存 TTL 2 分钟,进入发布页先读缓存,随后后台刷新
### 骨架屏
- 账号列表、作品列表、素材列表在加载时显示骨架
- 骨架数量应与历史数据数量相近(避免加载时数量跳变)
### 预览加载优化
- 预览 `video` 使用 `preload="metadata"`
- 发布页预览按钮可进行短时 `preload` 预取
---
## 轻量 FSD 结构
- `app/`:页面入口,保持轻量
- `features/*/model`:业务逻辑与状态 (hooks)
- `features/*/ui`:功能 UI 组件
- `shared/`:通用工具、通用 hooks、API 实例
---
## 用户偏好持久化
首页涉及样式与字号等用户偏好时,需持久化并在刷新后恢复:
- **必须持久化**
- 标题样式 ID / 字幕样式 ID
- 标题字号 / 字幕字号
- 背景音乐选择 / 音量 / 开关状态
- 素材选择 / 历史作品选择
### 实施规范
- 使用 `storageKey = userId || 'guest'`,按用户隔离。
- **恢复先于保存**:恢复完成前禁止写入(`isRestored` 保护)。
- 避免默认值覆盖用户选择(优先读取已保存值)。
- 优先使用 `useHomePersistence` 集中管理恢复/保存,页面内避免分散的 localStorage 读写。
- 如需新增持久化字段,必须加入恢复与保存逻辑,并更新本节。
---
## 标题输入规则
- 片头标题与发布信息标题统一限制 15 字。
- 中文输入法合成阶段不截断,合成结束后才校验长度。
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`
- 避免使用 `maxLength` 强制截断输入法合成态。
- 推荐使用 `@/shared/hooks/useTitleInput` 统一处理输入逻辑。
---
## 发布页交互规则
- 发布按钮在未选择任何平台时禁用
- 仅保留“立即发布”,不再提供定时发布 UI/参数
---
## 新增页面 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: file + ref_text) |
| `/api/ref-audios` | GET | 列出用户的参考音频 |
| `/api/ref-audios/{id}` | DELETE | 删除参考音频 (id 需 encodeURIComponent) |
### 视频生成 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: '参考音频对应文字',
});
```
### 在线录音
使用 `MediaRecorder` API 录制音频,格式为 `audio/webm`,上传后后端自动转换为 WAV (16kHz mono)。
```typescript
// 录音需要用户授权麦克风
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
```
### UI 结构
配音方式使用 Tab 切换:
- **EdgeTTS 音色** - 预设音色 2x3 网格
- **声音克隆** - 参考音频列表 + 在线录音 + 参考文字输入