# 前端开发规范

## 目录结构

```
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 请求**必须**使用 `@/lib/axios` 导出的 axios 实例。该实例已配置：
- 自动携带 `credentials: include`
- 遇到 401/403 时自动清除 cookie 并跳转登录页

**使用方式：**

```typescript
import api from '@/lib/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 '@/lib/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 解析
使用 `@/lib/media` 统一处理服务端/客户端 API Base 与资源地址，避免硬编码：

```typescript
import { getApiBaseUrl, resolveMediaUrl, resolveAssetUrl, formatDate } from '@/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 '@/lib/media';
```

---

## 组件拆分规范

当页面组件超过 300-500 行，建议拆分到 `components/`：

- `page.tsx` 负责状态与业务逻辑
- 组件只接受 props 与回调，尽量不直接发 API
- 首页拆分组件统一放在 `components/home/`

---

## 用户偏好持久化

首页涉及样式与字号等用户偏好时，需持久化并在刷新后恢复：

- **必须持久化**：
  - 标题样式 ID / 字幕样式 ID
  - 标题字号 / 字幕字号
  - 背景音乐选择 / 音量 / 开关状态
  - 素材选择 / 历史作品选择

### 实施规范
- 使用 `storageKey = userId || 'guest'`，按用户隔离。
- **恢复先于保存**：恢复完成前禁止写入（`isRestored` 保护）。
- 避免默认值覆盖用户选择（优先读取已保存值）。
- 优先使用 `useHomePersistence` 集中管理恢复/保存，页面内避免分散的 localStorage 读写。
- 如需新增持久化字段，必须加入恢复与保存逻辑，并更新本节。

---

## 标题输入规则

- 片头标题与发布信息标题统一限制 15 字。
- 中文输入法合成阶段不截断，合成结束后才校验长度。
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`。
- 避免使用 `maxLength` 强制截断输入法合成态。

---

## 新增页面 Checklist

1. [ ] 导入 `import api from '@/lib/axios'`
2. [ ] 所有 API 请求使用 `api.get/post/delete()` 而非原生 `fetch`
3. [ ] 日期格式化使用 `@/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 网格
- **声音克隆** - 参考音频列表 + 在线录音 + 参考文字输入
