# 前端开发规范 ## 目录结构 采用轻量 FSD(Feature-Sliced Design)结构: ``` frontend/src/ ├── app/ # Next.js App Router 页面入口 │ ├── page.tsx # 首页(视频生成) │ ├── publish/ # 发布管理页 │ ├── admin/ # 管理员页面 │ ├── login/ # 登录 │ └── register/ # 注册 ├── 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/ # 全局 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 {children} ``` #### 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 // 移动端紧凑,桌面端宽松
``` ### 常用响应式断点 | 断点 | 宽度 | 用途 | |------|------|------| | 默认 | < 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/` --- ## ⚡️ 体验优化规范 ### 刷新回顶部(统一体验) - 长页面(如首页/发布页)在首次挂载时统一回到顶部,避免浏览器恢复旧滚动位置导致进入即跳到中部。 - 推荐实现:`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/api`:Axios 实例与统一响应类型 - `shared/lib`:通用工具函数(media.ts / auth.ts / title.ts) - `shared/hooks`:跨功能通用 hooks - `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`) - 背景音乐选择 / 音量 / 开关状态 - 输出画面比例(`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/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 音色** - 预设音色 2x3 网格 - **声音克隆** - 参考音频列表 + 在线录音 + 语速下拉菜单 (5 档: 较慢/稍慢/正常/稍快/较快)