# 前端开发规范

## 文档定位

- 本文档只定义前端开发规范与约束（结构、交互、持久化、接口调用、Checklist）。
- 功能说明与启动方式请查看 `Docs/FRONTEND_README.md`。
- 历史变更请记录在 `Docs/DevLogs/` 与 `Docs/TASK_COMPLETE.md`，不要写入本规范文档。

## 目录结构

采用轻量 FSD（Feature-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
│   ├── ui/
│   │   └── SelectPopover.tsx   # 统一下拉/BottomSheet 选择器
│   ├── types/
│   │   ├── user.ts            # User 类型定义
│   │   └── publish.ts         # 发布相关类型
│   └── contexts/              # 全局 Context（Auth、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` | 列表行内操作（编辑/删除） |

---

## 统一下拉选择器规范 (SelectPopover)

首页/发布页的业务选择项（音色、参考音频、配音、素材、BGM、作品、样式、模型、画面比例）统一使用 `@/shared/ui/SelectPopover`：

- 桌面端使用 Popover，移动端自动切换 BottomSheet
- 触发器与面板风格统一：`border-white/10 + bg-black/25`（或同级变体）
- 下拉项选中态统一：`border-purple-500 bg-purple-500/20`
- 选中项需添加 `data-popover-selected="true"`，确保再次打开时自动滚动定位到已选项
- 底部空间不足时自动上拉；滚动条隐藏但保留滚动能力

### 视频预览与下拉层级

- 下拉菜单层级应低于视频预览弹窗，避免遮挡预览内容
- 在下拉内点击“预览”时，不强制关闭下拉（便于连续预览）
- 关闭预览后，用户可继续在下拉内操作；点击外部时下拉正常收起

### 例外说明

- `ScriptEditor` 的“历史文案 / AI多语言”保持原有轻量菜单样式，不强制迁移到 `SelectPopover`

---

## 统一弹窗规范 (AppModal)

所有居中弹窗（如视频预览、文案提取、AI 改写、录音、密码修改）统一使用 `@/shared/ui/AppModal` + `AppModalHeader`：

- 统一遮罩与层级：`fixed inset-0` + `bg-black/80` + `backdrop-blur-sm` + 明确 `z-index`
- 统一挂载位置：通过 Portal 挂载到 `document.body`，避免局部容器/层叠上下文影响，确保是全页面弹窗
- 统一容器风格：`border-white/10`、深色半透明背景、圆角 `rounded-2xl`、重阴影
- 统一关闭行为：支持 `ESC`；是否允许点击遮罩关闭通过 `closeOnOverlay` 显式配置
- 统一滚动策略：弹窗打开时锁定背景滚动（`lockBodyScroll`），内容区自行滚动
- 特殊层级场景（例如视频预览压过下拉）使用更高 `z-index`（如 `z-[320]`）

---

## 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);
}, []);

// 列表滚动 effect（BGM/素材/视频等）
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/ui`：跨功能通用 UI（如 SelectPopover）
- `shared/types`：跨功能实体类型（User / PublishVideo 等）
- `shared/contexts`：全局 Context（AuthContext / TaskContext）
- `components/`：遗留通用组件（VideoPreviewModal）

## 类型定义规范

- 通用实体类型（如 User, Account, Video）统一放置在 `src/shared/types/`。
- 特定业务类型放在 feature 目录下的 types.ts 或 model 中。
- **禁止**在多个地方重复定义 User 接口，统一引用 `import { User } from '@/shared/types/user';`。

---

## 用户偏好持久化

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

- **必须持久化**：
  - 标题样式 ID / 字幕样式 ID
  - 标题字号 / 字幕字号
  - 标题显示模式（`short` / `persistent`）
  - 唇形模型模式（`default` / `fast` / `advanced`）
  - 背景音乐选择 / 开关状态（当前前端不提供音量滑杆，生成时使用固定音量）
  - 输出画面比例（`9:16` / `16:9`）
  - 素材选择 / 历史作品选择
  - 选中配音 ID (`selectedAudioId`)
  - 选中参考音频 ID (`selectedRefAudio` 对应 id)
  - 语速 (`speed`，声音克隆模式)
  - 语气 (`emotion`，声音克隆模式)
  - 时间轴段信息 (`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: file，ref_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 音色** - 统一下拉选择（显示“音色名 + 语言”）
- **声音克隆** - 参考音频选择器（含试听/重命名/删除/重识别）+ 底部右侧上传/录音入口（录音弹窗）+ 语速/语气下拉
