Files
ViGent2/Docs/FRONTEND_DEV.md
Kevin Wong bc0fe9326a 更新
2026-02-11 17:48:38 +08:00

14 KiB
Raw Blame History

前端开发规范

目录结构

采用轻量 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)

import type { Viewport } from "next";

export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
  viewportFit: 'cover',    // 允许内容延伸到安全区域
  themeColor: '#0f172a',   // 顶部状态栏颜色(与背景一致)
};

2. 全局背景统一到 body (layout.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)

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 按钮布局

// 移动端紧凑,桌面端宽松
<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 并跳转登录页

使用方式:

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 配合使用

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 与资源地址,避免硬编码:

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 错误。

错误示例:

// ❌ 会导致 Hydration 错误
new Date(timestamp * 1000).toLocaleString('zh-CN')

正确做法:

// ✅ 使用固定格式
import { formatDate } from '@/shared/lib/media';

组件拆分规范

当页面组件超过 300-500 行,建议按功能拆分到 features/*/ui

  • page.tsx 仅做组合与布局
  • 业务逻辑集中在 features/*/model 的 Controller Hook
  • UI 组件只接受 props 与回调,尽量不直接发 API
  • 首页拆分组件统一放在 features/home/ui/

体验优化规范

刷新回顶部(统一体验)

  • 长页面(如首页/发布页)在首次挂载时统一回到顶部,避免浏览器恢复旧滚动位置导致进入即跳到中部。
  • 推荐实现:useEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); }, [])
  • 列表内自动定位(素材/历史记录)应跳过恢复后的首次触发,防止刷新后页面二次跳动。

路由预取

  • 首页进入发布管理时使用 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/apiAxios 实例与统一响应类型
  • 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/mediaformatDate
  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 扩展

// 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)。

// 录音需要用户授权麦克风
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 档: 较慢/稍慢/正常/稍快/较快)