330 lines
9.0 KiB
Markdown
330 lines
9.0 KiB
Markdown
# 前端开发规范
|
||
|
||
## 目录结构
|
||
|
||
```
|
||
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 网格
|
||
- **声音克隆** - 参考音频列表 + 在线录音 + 参考文字输入
|