# Jointo(jointo)前端技术架构文档 > **项目名称**:Jointo(jointo) > **项目域名**:https://www.jointo.ai > **文档版本**:v1.0 > **创建日期**:2025-01-08 --- ## 目录 1. [技术栈概览](#1-技术栈概览) 2. [项目结构](#2-项目结构) 3. [构建与开发环境](#3-构建与开发环境) 4. [状态管理](#4-状态管理) 5. [路由设计](#5-路由设计) 6. [API 层设计](#6-api-层设计) 7. [样式方案](#7-样式方案) 8. [国际化(i18n)](#8-国际化i18n) 9. [主题系统](#9-主题系统) 10. [性能优化](#10-性能优化) 11. [测试策略](#11-测试策略) 12. [部署方案](#12-部署方案) 13. [开发规范](#13-开发规范) 14. [工具链配置](#14-工具链配置) --- ## 1. 技术栈概览 ### 1.1 核心技术栈 | 类别 | 技术选型 | 版本 | 说明 | | --------------- | -------------------- | ---------------- | --------------- | | **框架** | React | ^19.2.0 | UI 框架 | | **语言** | TypeScript | ~5.9.3 | 类型安全 | | **构建工具** | Vite | ^7.2.4 | 快速构建、HMR | | **状态管理** | Zustand | ^5.0.5 | 轻量级状态管理 | | **样式方案** | Tailwind CSS | ^4.1.18 | 原子化 CSS (v4) | | **UI 组件库** | shadcn/ui + Radix UI | latest | 可定制组件 | | **图标库** | lucide-react | ^0.562.0 | 图标集 | | **HTTP 客户端** | Axios | ^1.10.0 | API 请求 | | **数据请求** | TanStack Query | ^5.80.7 | 服务端状态管理 | | **表单处理** | React Hook Form | ^7.57.0 | 表单验证 | | **表单验证** | Zod | ^3.25.64 | Schema 验证 | | **日期处理** | date-fns | ^4.1.0 | 日期工具 | | **路由** | React Router | ^7.6.1 | 客户端路由 | | **国际化** | react-i18next | ^15.5.2 | 多语言支持 | | **拖拽** | @dnd-kit | ^6.3.1 / ^10.0.0 | 拖拽排序 | ### 1.2 开发工具 | 工具 | 用途 | | --------------- | -------------------------- | | **ESLint** | 代码质量检查 (Flat Config) | | **Prettier** | 代码格式化 | | **Husky** | Git Hooks | | **lint-staged** | 暂存文件检查 | ### 1.3 特殊功能依赖 | 依赖 | 版本 | 用途 | | ------------------ | -------- | ---------------- | | **fountain-js** | ^1.2.4 | 剧本格式解析 | | **mammoth** | ^1.11.0 | Word 文档解析 | | **pdfjs-dist** | ^5.4.530 | PDF 文件解析 | | **turndown** | ^7.2.2 | HTML 转 Markdown | | **react-markdown** | ^10.1.0 | Markdown 渲染 | ### 1.4 技术选型理由 #### React 19 - 并发渲染支持,提升大型列表和复杂交互的性能 - Suspense 支持异步数据加载 - 自动批处理减少渲染次数 - 成熟的生态系统 #### Vite 7 - 基于 ESM 的极速开发服务器 - 快速的 HMR(热模块替换) - 优化的生产构建 - 开箱即用的 TypeScript 支持 - 原生支持 Tailwind v4 #### Zustand 5 - 极简 API,学习成本低 - 无 Provider 包装,使用灵活 - TypeScript 原生支持 - 支持中间件扩展(devtools、persist) #### Tailwind CSS v4 + shadcn/ui - **Tailwind v4 新特性**:使用 `@theme` 指令配置,无需 JS 配置文件 - 原子化 CSS 减少样式冲突 - shadcn/ui 组件基于 Radix UI,可完全自定义 - 统一的设计系统(Pro Studio 主题) - 优秀的开发体验 #### @dnd-kit - 现代化的拖拽库 - 支持键盘无障碍访问 - 高性能,支持虚拟列表 - 用于分镜排序、时间轴编辑 --- ## 2. 项目结构 ### 2.1 目录结构 ``` src/ ├── app/ # 应用入口层 │ ├── App.tsx # 根组件 │ ├── Router.tsx # 路由配置 │ └── providers/ # Provider 组件 │ ├── index.tsx │ ├── QueryProvider.tsx │ └── ThemeProvider.tsx │ ├── components/ # 组件层 │ ├── ui/ # 基础 UI 组件 (shadcn/ui) │ │ ├── button.tsx │ │ ├── input.tsx │ │ ├── dialog.tsx │ │ └── ... │ │ │ ├── layout/ # 布局组件 │ │ ├── AppLayout.tsx # 应用主布局 │ │ ├── TopBar.tsx # 顶部导航栏 │ │ ├── LeftSidebar.tsx # 左侧边栏(项目/素材库) │ │ ├── CenterArea.tsx # 中间区域(分镜列表+预览+AI输入) │ │ └── RightSidebar.tsx # 右侧边栏(图片提示词/角度变换/镜头调整) │ │ │ ├── features/ # 功能组件 │ │ ├── storyboard/ # 分镜相关 │ │ │ ├── StoryboardPanel.tsx │ │ │ ├── StoryboardItem.tsx │ │ │ ├── StoryboardEditor.tsx │ │ │ └── index.ts │ │ │ │ │ ├── timeline/ # 时间轴相关 │ │ │ ├── TimelinePanel.tsx │ │ │ ├── TimelineTrack.tsx │ │ │ ├── TimelineItem.tsx │ │ │ ├── TimelineControls.tsx │ │ │ └── index.ts │ │ │ │ │ ├── preview/ # 预览相关 │ │ │ ├── PreviewPanel.tsx │ │ │ ├── VideoPlayer.tsx │ │ │ ├── PlaybackControls.tsx │ │ │ └── index.ts │ │ │ │ │ ├── ai/ # AI 功能 │ │ │ ├── AIPromptPanel.tsx │ │ │ ├── AIGenerateForm.tsx │ │ │ ├── AIPreviewModal.tsx │ │ │ └── index.ts │ │ │ │ │ ├── resources/ # 资源管理 │ │ │ ├── ResourcePanel.tsx │ │ │ ├── ResourceGrid.tsx │ │ │ ├── ResourceUploader.tsx │ │ │ └── index.ts │ │ │ │ │ ├── project/ # 项目管理 │ │ │ ├── ProjectList.tsx │ │ │ ├── ProjectCard.tsx │ │ │ ├── CreateProjectModal.tsx │ │ │ └── index.ts │ │ │ │ │ └── settings/ # 设置 │ │ ├── SettingsPanel.tsx │ │ ├── ExportSettings.tsx │ │ └── index.ts │ │ │ └── common/ # 通用组件 │ ├── LoadingSpinner.tsx │ ├── ErrorBoundary.tsx │ ├── EmptyState.tsx │ ├── Tooltip.tsx │ └── index.ts │ ├── hooks/ # 自定义 Hooks │ ├── api/ # API 相关 Hooks │ │ ├── useProjects.ts │ │ ├── useStoryboards.ts │ │ ├── useResources.ts │ │ └── useAIGenerate.ts │ │ │ ├── useDebounce.ts │ ├── useLocalStorage.ts │ ├── useMediaQuery.ts │ ├── useKeyboardShortcuts.ts │ └── index.ts │ ├── stores/ # Zustand 状态管理 │ ├── appStore.ts # 全局应用状态 │ ├── projectStore.ts # 项目状态 │ ├── editorStore.ts # 编辑器状态 │ ├── uiStore.ts # UI 状态 │ └── index.ts │ ├── services/ # 服务层 │ ├── api/ # API 服务 │ │ ├── client.ts # Axios 客户端配置 │ │ ├── projects.ts # 项目 API │ │ ├── storyboards.ts # 分镜 API │ │ ├── resources.ts # 资源 API │ │ ├── ai.ts # AI 生成 API │ │ └── index.ts │ │ │ └── mock/ # Mock 数据服务 │ ├── mockClient.ts │ ├── mockData.ts │ └── handlers.ts │ ├── types/ # TypeScript 类型定义 │ ├── project.ts │ ├── storyboard.ts │ ├── resource.ts │ ├── timeline.ts │ ├── ai.ts │ ├── api.ts │ └── index.ts │ ├── utils/ # 工具函数 │ ├── format.ts # 格式化工具 │ ├── validation.ts # 验证工具 │ ├── storage.ts # 存储工具 │ ├── time.ts # 时间处理 │ └── index.ts │ ├── constants/ # 常量定义 │ ├── config.ts # 配置常量 │ ├── routes.ts # 路由常量 │ ├── keys.ts # 键名常量 │ └── index.ts │ ├── styles/ # 全局样式 │ ├── globals.css # 全局 CSS │ └── animations.css # 动画定义 │ ├── assets/ # 静态资源 │ ├── images/ │ └── fonts/ │ ├── lib/ # 第三方库配置 │ └── utils.ts # shadcn/ui 工具 │ ├── main.tsx # 应用入口 └── vite-env.d.ts # Vite 类型声明 ``` ### 2.2 模块职责说明 | 目录 | 职责 | 原则 | | ---------------------- | ------------------------ | ------------------ | | `app/` | 应用配置、Provider、路由 | 只放应用级配置 | | `components/ui/` | 基础 UI 组件 | 无业务逻辑,纯展示 | | `components/layout/` | 布局组件 | 定义页面结构 | | `components/features/` | 功能组件 | 按功能模块组织 | | `components/common/` | 通用组件 | 跨模块复用 | | `hooks/` | 自定义 Hooks | 逻辑复用 | | `stores/` | 全局状态 | Zustand stores | | `services/` | 外部服务交互 | API 调用、Mock | | `types/` | 类型定义 | 所有 TS 类型 | | `utils/` | 工具函数 | 纯函数,无副作用 | | `constants/` | 常量 | 配置、枚举、键名 | ### 2.3 导入别名配置 ```typescript // vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import path from "path"; export default defineConfig({ plugins: [react()], resolve: { alias: { "@": path.resolve(__dirname, "./src"), "@components": path.resolve(__dirname, "./src/components"), "@hooks": path.resolve(__dirname, "./src/hooks"), "@stores": path.resolve(__dirname, "./src/stores"), "@services": path.resolve(__dirname, "./src/services"), "@types": path.resolve(__dirname, "./src/types"), "@utils": path.resolve(__dirname, "./src/utils"), "@constants": path.resolve(__dirname, "./src/constants"), "@assets": path.resolve(__dirname, "./src/assets"), }, }, }); ``` ```json // tsconfig.json { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@components/*": ["src/components/*"], "@hooks/*": ["src/hooks/*"], "@stores/*": ["src/stores/*"], "@services/*": ["src/services/*"], "@types/*": ["src/types/*"], "@utils/*": ["src/utils/*"], "@constants/*": ["src/constants/*"], "@assets/*": ["src/assets/*"] } } } ``` --- ## 3. 构建与开发环境 ### 3.1 环境变量 ```bash # .env.development VITE_API_BASE_URL=http://localhost:8000/api/v1 VITE_USE_MOCK=true VITE_APP_TITLE=Jointo(开发) # .env.production VITE_API_BASE_URL=https://api.jointo.ai/api/v1 VITE_USE_MOCK=false VITE_APP_TITLE=Jointo # .env.staging VITE_API_BASE_URL=https://staging-api.jointo.ai/api/v1 VITE_USE_MOCK=false VITE_APP_TITLE=Jointo(测试) ``` ### 3.2 环境变量类型定义 ```typescript // src/vite-env.d.ts /// interface ImportMetaEnv { readonly VITE_API_BASE_URL: string; readonly VITE_USE_MOCK: string; readonly VITE_APP_TITLE: string; } interface ImportMeta { readonly env: ImportMetaEnv; } ``` ### 3.3 开发脚本 ```json // package.json { "scripts": { "dev": "vite", "build": "tsc -b && vite build", "build:staging": "tsc -b && vite build --mode staging", "preview": "vite preview", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", "type-check": "tsc --noEmit" } } ``` --- ## 4. 状态管理 ### 4.1 状态分层策略 ``` ┌─────────────────────────────────────────────────┐ │ 服务端状态 │ │ (TanStack Query 管理) │ │ - 项目列表、分镜数据、资源数据等 │ └─────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ 客户端状态 │ │ (Zustand 管理) │ │ - 当前选中项、UI 状态、编辑器状态 │ └─────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ 组件本地状态 │ │ (useState 管理) │ │ - 表单输入、临时 UI 状态 │ └─────────────────────────────────────────────────┘ ``` ### 4.2 Zustand Store 设计 #### 4.2.1 应用全局状态 ```typescript // src/stores/appStore.ts import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; interface AppState { // 状态 isInitialized: boolean; currentProjectId: string | null; user: User | null; // 操作 setInitialized: (value: boolean) => void; setCurrentProject: (projectId: string | null) => void; setUser: (user: User | null) => void; reset: () => void; } export const useAppStore = create()( devtools( persist( (set) => ({ // 初始状态 isInitialized: false, currentProjectId: null, user: null, // 操作 setInitialized: (value) => set({ isInitialized: value }), setCurrentProject: (projectId) => set({ currentProjectId: projectId }), setUser: (user) => set({ user }), reset: () => set({ isInitialized: false, currentProjectId: null, user: null, }), }), { name: "app-store", partialize: (state) => ({ currentProjectId: state.currentProjectId, }), }, ), { name: "AppStore" }, ), ); ``` #### 4.2.2 编辑器状态 ```typescript // src/stores/editorStore.ts import { create } from "zustand"; import { devtools } from "zustand/middleware"; interface EditorState { // 选中状态 selectedStoryboardId: string | null; selectedTimelineItems: string[]; // 播放状态 isPlaying: boolean; currentTime: number; duration: number; // 时间轴状态 timelineZoom: number; timelineScrollPosition: number; // 操作 selectStoryboard: (id: string | null) => void; selectTimelineItems: (ids: string[]) => void; toggleTimelineItem: (id: string) => void; setPlaying: (playing: boolean) => void; setCurrentTime: (time: number) => void; setDuration: (duration: number) => void; setTimelineZoom: (zoom: number) => void; setTimelineScrollPosition: (position: number) => void; reset: () => void; } const initialState = { selectedStoryboardId: null, selectedTimelineItems: [], isPlaying: false, currentTime: 0, duration: 0, timelineZoom: 1, timelineScrollPosition: 0, }; export const useEditorStore = create()( devtools( (set, get) => ({ ...initialState, selectStoryboard: (id) => set({ selectedStoryboardId: id }), selectTimelineItems: (ids) => set({ selectedTimelineItems: ids }), toggleTimelineItem: (id) => { const current = get().selectedTimelineItems; const isSelected = current.includes(id); set({ selectedTimelineItems: isSelected ? current.filter((i) => i !== id) : [...current, id], }); }, setPlaying: (playing) => set({ isPlaying: playing }), setCurrentTime: (time) => set({ currentTime: time }), setDuration: (duration) => set({ duration }), setTimelineZoom: (zoom) => set({ timelineZoom: Math.max(0.1, Math.min(5, zoom)) }), setTimelineScrollPosition: (position) => set({ timelineScrollPosition: position }), reset: () => set(initialState), }), { name: "EditorStore" }, ), ); ``` #### 4.2.3 UI 状态 ```typescript // src/stores/uiStore.ts import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; type LeftPanelTab = "project" | "library"; type RightPanelTab = "imagePrompt" | "angleTransform" | "cameraAdjust"; interface UIState { // 侧边栏状态 leftSidebarCollapsed: boolean; rightSidebarCollapsed: boolean; leftPanelActiveTab: LeftPanelTab; rightPanelActiveTab: RightPanelTab; // 时间轴状态 timelineExpanded: boolean; // AI 面板 aiPanelVisible: boolean; // 弹窗状态 createProjectModalOpen: boolean; exportModalOpen: boolean; settingsModalOpen: boolean; // 引导状态 showOnboarding: boolean; // 操作 toggleLeftSidebar: () => void; toggleRightSidebar: () => void; setLeftPanelTab: (tab: LeftPanelTab) => void; setRightPanelTab: (tab: RightPanelTab) => void; toggleTimeline: () => void; toggleAIPanel: () => void; setCreateProjectModalOpen: (open: boolean) => void; setExportModalOpen: (open: boolean) => void; setSettingsModalOpen: (open: boolean) => void; setShowOnboarding: (show: boolean) => void; } export const useUIStore = create()( devtools( persist( (set, get) => ({ // 初始状态 leftSidebarCollapsed: false, rightSidebarCollapsed: false, leftPanelActiveTab: "project", rightPanelActiveTab: "imagePrompt", timelineExpanded: true, aiPanelVisible: false, createProjectModalOpen: false, exportModalOpen: false, settingsModalOpen: false, showOnboarding: true, // 操作 toggleLeftSidebar: () => set({ leftSidebarCollapsed: !get().leftSidebarCollapsed }), toggleRightSidebar: () => set({ rightSidebarCollapsed: !get().rightSidebarCollapsed }), setLeftPanelTab: (tab) => set({ leftPanelActiveTab: tab }), setRightPanelTab: (tab) => set({ rightPanelActiveTab: tab }), toggleTimeline: () => set({ timelineExpanded: !get().timelineExpanded }), toggleAIPanel: () => set({ aiPanelVisible: !get().aiPanelVisible }), setCreateProjectModalOpen: (open) => set({ createProjectModalOpen: open }), setExportModalOpen: (open) => set({ exportModalOpen: open }), setSettingsModalOpen: (open) => set({ settingsModalOpen: open }), setShowOnboarding: (show) => set({ showOnboarding: show }), }), { name: "ui-store", partialize: (state) => ({ leftSidebarCollapsed: state.leftSidebarCollapsed, rightSidebarCollapsed: state.rightSidebarCollapsed, leftPanelActiveTab: state.leftPanelActiveTab, timelineExpanded: state.timelineExpanded, showOnboarding: state.showOnboarding, }), }, ), { name: "UIStore" }, ), ); ``` ### 4.3 TanStack Query 配置 ```typescript // src/app/providers/QueryProvider.tsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 分钟 gcTime: 1000 * 60 * 30, // 30 分钟 (原 cacheTime) retry: 3, refetchOnWindowFocus: false, }, mutations: { retry: 1, }, }, }); export function QueryProvider({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ### 4.4 自定义 Query Hook 示例 ```typescript // src/hooks/api/useStoryboards.ts import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { storyboardApi } from "@services/api/storyboards"; import type { Storyboard, CreateStoryboardDto, UpdateStoryboardDto, } from "@types"; // 查询键 export const storyboardKeys = { all: ["storyboards"] as const, lists: () => [...storyboardKeys.all, "list"] as const, list: (projectId: string) => [...storyboardKeys.lists(), projectId] as const, details: () => [...storyboardKeys.all, "detail"] as const, detail: (id: string) => [...storyboardKeys.details(), id] as const, }; // 获取分镜列表 export function useStoryboards(projectId: string) { return useQuery({ queryKey: storyboardKeys.list(projectId), queryFn: () => storyboardApi.getList(projectId), enabled: !!projectId, }); } // 获取单个分镜 export function useStoryboard(id: string) { return useQuery({ queryKey: storyboardKeys.detail(id), queryFn: () => storyboardApi.getById(id), enabled: !!id, }); } // 创建分镜 export function useCreateStoryboard() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: CreateStoryboardDto) => storyboardApi.create(data), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: storyboardKeys.list(variables.projectId), }); }, }); } // 更新分镜 export function useUpdateStoryboard() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, data }: { id: string; data: UpdateStoryboardDto }) => storyboardApi.update(id, data), onSuccess: (result) => { queryClient.setQueryData(storyboardKeys.detail(result.id), result); queryClient.invalidateQueries({ queryKey: storyboardKeys.lists(), }); }, }); } // 删除分镜 export function useDeleteStoryboard() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: string) => storyboardApi.delete(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: storyboardKeys.lists(), }); }, }); } ``` --- ## 5. 路由设计 ### 5.1 路由结构 ```typescript // src/constants/routes.ts export const ROUTES = { // 公开页面 HOME: "/", LOGIN: "/login", REGISTER: "/register", // 项目管理 PROJECTS: "/projects", PROJECT_DETAIL: "/projects/:projectId", // 编辑器 EDITOR: "/editor/:projectId", // 设置 SETTINGS: "/settings", PROFILE: "/settings/profile", PREFERENCES: "/settings/preferences", // 其他 NOT_FOUND: "/404", } as const; // 路由辅助函数 export const getEditorPath = (projectId: string) => `/editor/${projectId}`; export const getProjectPath = (projectId: string) => `/projects/${projectId}`; ``` ### 5.2 路由配置 ```typescript // src/app/Router.tsx import { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom'; import { lazy, Suspense } from 'react'; import { ROUTES } from '@constants/routes'; import { LoadingSpinner } from '@components/common'; import { ProtectedRoute } from './ProtectedRoute'; import { AppLayout } from '@components/layout/AppLayout'; // 懒加载页面 const HomePage = lazy(() => import('@/pages/HomePage')); const LoginPage = lazy(() => import('@/pages/LoginPage')); const ProjectsPage = lazy(() => import('@/pages/ProjectsPage')); const ProjectPage = lazy(() => import('@/pages/ProjectPage')); const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')); // 页面加载 Wrapper function PageLoader({ children }: { children: React.ReactNode }) { return ( }> {children} ); } const router = createBrowserRouter([ { path: ROUTES.HOME, element: , }, { path: ROUTES.LOGIN, element: , }, { path: ROUTES.PROJECTS, element: ( ), }, { path: ROUTES.PROJECT, element: ( ), }, { path: '*', element: , }, ]); export function Router() { return ; } ``` ### 5.3 路由守卫 ```typescript // src/app/ProtectedRoute.tsx import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { useAppStore } from '@stores/appStore'; import { ROUTES } from '@constants/routes'; export function ProtectedRoute() { const user = useAppStore((state) => state.user); const location = useLocation(); if (!user) { return ; } return ; } ``` --- ## 6. API 层设计 ### 6.1 API 客户端配置 ```typescript // src/services/api/client.ts import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, } from "axios"; import { useAppStore } from "@stores/appStore"; // 创建 axios 实例 const apiClient: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 30000, headers: { "Content-Type": "application/json", }, }); // 请求拦截器 apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // 添加 token const token = localStorage.getItem("token"); if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error), ); // 响应拦截器 apiClient.interceptors.response.use( (response) => response.data, (error: AxiosError) => { // 统一错误处理 if (error.response?.status === 401) { // token 过期,清除用户状态 useAppStore.getState().setUser(null); localStorage.removeItem("token"); window.location.href = "/login"; } return Promise.reject(error); }, ); export default apiClient; ``` ### 6.2 API 服务模块示例 ```typescript // src/services/api/storyboards.ts import apiClient from "./client"; import type { Storyboard, CreateStoryboardDto, UpdateStoryboardDto, ApiResponse, PaginatedResponse, } from "@types"; export const storyboardApi = { // 获取分镜列表 getList: async (projectId: string): Promise => { const response = await apiClient.get>( `/projects/${projectId}/storyboards`, ); return response.data; }, // 获取单个分镜 getById: async (id: string): Promise => { const response = await apiClient.get>( `/storyboards/${id}`, ); return response.data; }, // 创建分镜 create: async (data: CreateStoryboardDto): Promise => { const response = await apiClient.post>( `/projects/${data.projectId}/storyboards`, data, ); return response.data; }, // 更新分镜 update: async ( id: string, data: UpdateStoryboardDto, ): Promise => { const response = await apiClient.patch>( `/storyboards/${id}`, data, ); return response.data; }, // 删除分镜 delete: async (id: string): Promise => { await apiClient.delete(`/storyboards/${id}`); }, // 调整分镜顺序 reorder: async ( projectId: string, storyboardIds: string[], ): Promise => { await apiClient.put(`/projects/${projectId}/storyboards/reorder`, { storyboardIds, }); }, }; ``` ### 6.3 Mock API 服务 ```typescript // src/services/mock/mockClient.ts import { mockData } from "./mockData"; import type { Storyboard, CreateStoryboardDto, UpdateStoryboardDto, } from "@types"; // 模拟延迟 const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export const mockStoryboardApi = { getList: async (projectId: string): Promise => { await delay(300); return mockData.storyboards.filter((s) => s.projectId === projectId); }, getById: async (id: string): Promise => { await delay(200); const storyboard = mockData.storyboards.find((s) => s.id === id); if (!storyboard) throw new Error("Storyboard not found"); return storyboard; }, create: async (data: CreateStoryboardDto): Promise => { await delay(500); const newStoryboard: Storyboard = { id: `storyboard-${Date.now()}`, ...data, order: mockData.storyboards.filter((s) => s.projectId === data.projectId) .length, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; mockData.storyboards.push(newStoryboard); return newStoryboard; }, update: async ( id: string, data: UpdateStoryboardDto, ): Promise => { await delay(300); const index = mockData.storyboards.findIndex((s) => s.id === id); if (index === -1) throw new Error("Storyboard not found"); mockData.storyboards[index] = { ...mockData.storyboards[index], ...data, updatedAt: new Date().toISOString(), }; return mockData.storyboards[index]; }, delete: async (id: string): Promise => { await delay(300); const index = mockData.storyboards.findIndex((s) => s.id === id); if (index !== -1) { mockData.storyboards.splice(index, 1); } }, }; ``` ### 6.4 API/Mock 自动切换 ```typescript // src/services/api/index.ts import { storyboardApi } from "./storyboards"; import { mockStoryboardApi } from "../mock/mockClient"; const useMock = import.meta.env.VITE_USE_MOCK === "true"; export const api = { storyboards: useMock ? mockStoryboardApi : storyboardApi, // ... 其他模块 }; ``` --- ## 7. 样式方案 ### 7.1 Tailwind CSS v4 配置 **重要变化**:Tailwind v4 不再使用 `tailwind.config.js`,改用 CSS 中的 `@theme` 指令配置。 #### 7.1.1 Vite 插件配置 ```typescript // vite.config.ts import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [react(), tailwindcss()], }); ``` #### 7.1.2 主题配置(在 CSS 中) ```css /* src/styles/globals.css */ @import "tailwindcss"; @theme { /* 文字体系 */ --color-text-primary: var(--text-primary); --color-text-regular: var(--text-regular); --color-text-secondary: var(--text-secondary); --color-text-tertiary: var(--text-tertiary); --color-text-placeholder: var(--text-placeholder); /* 边框体系 */ --color-border-dark: var(--border-dark); --color-border-base: var(--border-base); --color-border-light: var(--border-light); --color-border-lighter: var(--border-lighter); /* 背景体系 */ --color-bg-page: var(--bg-page); --color-bg-overlay: var(--bg-overlay); --color-bg-element: var(--bg-element); --color-fill-default: var(--fill-default); --color-fill-darker: var(--fill-darker); --color-fill-lighter: var(--fill-lighter); /* 品牌色 */ --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-primary-hover: var(--primary-hover); --color-primary-active: var(--primary-active); /* 功能色 */ --color-success: var(--success); --color-warning: var(--warning); --color-error: var(--error); --color-info: var(--info); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); /* 轨道颜色 */ --color-track-storyboard: var(--track-storyboard); --color-track-resource: var(--track-resource); --color-track-video: var(--track-video); --color-track-sound: var(--track-sound); --color-track-subtitle: var(--track-subtitle); --color-track-voice: var(--track-voice); /* 圆角 */ --radius-sm: 0.125rem; --radius: 0.5rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem; /* 动画 */ --animate-fade-in: fadeIn 0.3s ease-in-out; --animate-slide-in-up: slideInUp 0.4s ease-out; --animate-scale-in: scaleIn 0.2s ease-out; @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideInUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } } ``` ### 7.2 Pro Studio 主题(深色/亮色) ```css /* 深色模式 (默认) */ :root { color-scheme: dark; /* 文字体系 */ --text-primary: #fafafc; --text-regular: #e4e4e7; --text-secondary: #a1a1aa; --text-tertiary: #71717a; --text-placeholder: #71717a; /* 边框体系 */ --border-dark: #52525b; --border-base: #3f3f46; --border-light: #27272a; --border-lighter: #18181b; /* 背景体系 */ --bg-page: #09090b; --bg-overlay: #18181b; --bg-element: #18181b; --fill-default: #27272a; --fill-darker: #121214; --fill-lighter: #3f3f46; /* 品牌色 */ --primary: #3b82f6; --primary-foreground: #ffffff; --primary-hover: #2563eb; --primary-active: #1d4ed8; /* 功能色 */ --success: #22c55e; --warning: #f59e0b; --error: #ef4444; --info: #3b82f6; --destructive: #7f1d1d; --destructive-foreground: #fef2f2; /* 轨道颜色 */ --track-storyboard: #60a5fa; --track-resource: #a78bfa; --track-video: #34d399; --track-sound: #fbbf24; --track-subtitle: #f87171; --track-voice: #22d3ee; } /* 亮色模式 */ .light { color-scheme: light; --text-primary: #09090b; --text-regular: #18181b; --text-secondary: #71717a; --text-placeholder: #a1a1aa; --border-dark: #a1a1aa; --border-base: #e4e4e7; --border-light: #f4f4f5; --border-lighter: #fafafa; --bg-page: #f4f4f5; --bg-overlay: #ffffff; --bg-element: #ffffff; --fill-default: #f4f4f5; --fill-darker: #fafafa; --fill-lighter: #e4e4e7; --primary: #2563eb; --primary-foreground: #ffffff; --primary-hover: #1d4ed8; --primary-active: #1e40af; --destructive: #ef4444; --destructive-foreground: #ffffff; } ``` } @layer base { - { @apply border-border; } body { @apply bg-background text-foreground; font-feature-settings: "rlig" 1, "calt" 1; } } /_ 自定义滚动条 _/ @layer utilities { .scrollbar-thin { scrollbar-width: thin; scrollbar-color: hsl(var(--muted)) transparent; } .scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; } .scrollbar-thin::-webkit-scrollbar-track { background: transparent; } .scrollbar-thin::-webkit-scrollbar-thumb { background-color: hsl(var(--muted)); border-radius: 3px; } .scrollbar-thin::-webkit-scrollbar-thumb:hover { background-color: hsl(var(--muted-foreground)); } } ```` ### 7.3 CSS 工具类规范 ```typescript // src/lib/utils.ts import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ```` 使用示例: ```tsx import { cn } from "@/lib/utils"; function Button({ className, variant, ...props }) { return ( ); } // 使用多个命名空间 function EditorComponent() { const { t } = useTranslation(['editor', 'common']); return (

{t('editor:title')}

); } // 带参数的翻译 // JSON: "welcome": "欢迎, {{name}}!"

{t('welcome', { name: 'John' })}

// 复数处理 // JSON: "items": "{{count}} 个项目", "items_plural": "{{count}} 个项目"

{t('items', { count: 5 })}

``` ### 8.6 语言切换 ```typescript // src/stores/settingsStore.ts import { create } from "zustand"; import { persist } from "zustand/middleware"; import i18n from "@/lib/i18n"; type Language = "zh-CN" | "en-US" | "zh-TW"; interface SettingsState { language: Language; setLanguage: (lang: Language) => void; } export const useSettingsStore = create()( persist( (set) => ({ language: "zh-CN", setLanguage: (lang) => { i18n.changeLanguage(lang); set({ language: lang }); }, }), { name: "settings-store", }, ), ); ``` --- ## 9. 主题系统 ### 9.1 技术方案 使用 CSS 变量 + class 切换实现主题系统,支持深色、亮色和跟随系统。 ### 9.2 主题 Store ```typescript // src/stores/themeStore.ts import { create } from "zustand"; import { persist } from "zustand/middleware"; type Theme = "dark" | "light" | "system"; type ResolvedTheme = "dark" | "light"; interface ThemeState { theme: Theme; resolvedTheme: ResolvedTheme; setTheme: (theme: Theme) => void; } // 获取系统主题偏好 const getSystemTheme = (): ResolvedTheme => { if (typeof window === "undefined") return "dark"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; // 应用主题到 DOM const applyTheme = (theme: ResolvedTheme) => { const root = document.documentElement; root.classList.remove("light", "dark"); root.classList.add(theme); }; export const useThemeStore = create()( persist( (set, get) => ({ theme: "dark", resolvedTheme: "dark", setTheme: (theme) => { const resolvedTheme = theme === "system" ? getSystemTheme() : theme; applyTheme(resolvedTheme); set({ theme, resolvedTheme }); }, }), { name: "theme-store", onRehydrateStorage: () => (state) => { if (state) { const resolvedTheme = state.theme === "system" ? getSystemTheme() : state.theme; applyTheme(resolvedTheme); state.resolvedTheme = resolvedTheme; } }, }, ), ); // 监听系统主题变化 if (typeof window !== "undefined") { window .matchMedia("(prefers-color-scheme: dark)") .addEventListener("change", (e) => { const { theme, setTheme } = useThemeStore.getState(); if (theme === "system") { setTheme("system"); // 重新计算 resolvedTheme } }); } ``` ### 9.3 Theme Provider ```tsx // src/app/providers/ThemeProvider.tsx import { useEffect } from "react"; import { useThemeStore } from "@/stores/themeStore"; interface ThemeProviderProps { children: React.ReactNode; defaultTheme?: "dark" | "light" | "system"; } export function ThemeProvider({ children, defaultTheme = "dark", }: ThemeProviderProps) { const { theme, setTheme } = useThemeStore(); // 初始化主题 useEffect(() => { // 防止首次加载时的闪烁 const savedTheme = localStorage.getItem("theme-store"); if (!savedTheme) { setTheme(defaultTheme); } }, []); // 监听系统主题变化 useEffect(() => { if (theme !== "system") return; const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleChange = () => setTheme("system"); mediaQuery.addEventListener("change", handleChange); return () => mediaQuery.removeEventListener("change", handleChange); }, [theme, setTheme]); return <>{children}; } ``` ### 9.4 防止主题闪烁(FOUC) 在 `index.html` 的 `` 中添加内联脚本: ```html ``` ### 9.5 主题切换组件 ```tsx // src/components/common/ThemeToggle.tsx import { Moon, Sun, Monitor } from "lucide-react"; import { useThemeStore } from "@/stores/themeStore"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useTranslation } from "react-i18next"; export function ThemeToggle() { const { t } = useTranslation(); const { theme, setTheme, resolvedTheme } = useThemeStore(); return ( setTheme("light")}> {t("settings.theme.light")} setTheme("dark")}> {t("settings.theme.dark")} setTheme("system")}> {t("settings.theme.system")} ); } ``` ### 9.6 Tailwind 深色模式配置 确保 `tailwind.config.js` 中配置了 class 策略: ```javascript // tailwind.config.js module.exports = { darkMode: ["class"], // 使用 class 策略 // ... }; ``` --- ## 10. 性能优化 ### 8.1 代码分割 ```typescript // 路由级代码分割 const EditorPage = lazy(() => import('@/pages/EditorPage')); // 组件级代码分割 const HeavyComponent = lazy(() => import('@components/features/HeavyComponent')); // 使用时包裹 Suspense }> ``` ### 8.2 虚拟列表 使用 `@tanstack/react-virtual` 处理长列表: ```typescript import { useVirtualizer } from '@tanstack/react-virtual'; function VirtualList({ items }: { items: Item[] }) { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 80, // 预估每项高度 overscan: 5, // 预渲染数量 }); return (
{virtualizer.getVirtualItems().map((virtualItem) => (
))}
); } ``` ### 8.3 组件优化 ```typescript // 使用 memo 避免不必要渲染 const StoryboardItem = memo(function StoryboardItem({ storyboard, isSelected, onSelect }: StoryboardItemProps) { return (
onSelect(storyboard.id)} > {/* ... */}
); }); // 使用 useMemo 缓存计算结果 const sortedStoryboards = useMemo( () => storyboards.sort((a, b) => a.order - b.order), [storyboards] ); // 使用 useCallback 缓存回调 const handleSelect = useCallback((id: string) => { selectStoryboard(id); }, [selectStoryboard]); ``` ### 8.4 图片优化 ```typescript // 图片懒加载组件 function LazyImage({ src, alt, className }: LazyImageProps) { const [loaded, setLoaded] = useState(false); const imgRef = useRef(null); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && imgRef.current) { imgRef.current.src = src; observer.disconnect(); } }, { rootMargin: '100px' } ); if (imgRef.current) { observer.observe(imgRef.current); } return () => observer.disconnect(); }, [src]); return (
{!loaded &&
} {alt} setLoaded(true)} />
); } ``` ### 8.5 Debounce 和 Throttle ```typescript // src/hooks/useDebounce.ts import { useState, useEffect } from 'react'; export function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(handler); }, [value, delay]); return debouncedValue; } // 使用示例 function SearchInput() { const [search, setSearch] = useState(''); const debouncedSearch = useDebounce(search, 300); useEffect(() => { if (debouncedSearch) { // 执行搜索 } }, [debouncedSearch]); return setSearch(e.target.value)} />; } ``` --- ## 11. 测试策略 > **注意**:测试工具配置尚未在项目中实现,以下内容作为未来规划参考。 ### 11.1 测试金字塔(规划) ``` ┌──────────┐ │ E2E │ 少量关键流程 │ Tests │ ┌┴──────────┴┐ │Integration │ 主要业务流程 │ Tests │ ┌┴────────────┴┐ │ Unit Tests │ 工具函数、Hooks、组件 └──────────────┘ ``` ### 9.2 单元测试(Vitest) ```typescript // src/utils/format.test.ts import { describe, it, expect } from "vitest"; import { formatDuration, formatFileSize } from "./format"; describe("formatDuration", () => { it("formats seconds to mm:ss", () => { expect(formatDuration(65)).toBe("01:05"); expect(formatDuration(3661)).toBe("01:01:01"); }); it("handles zero", () => { expect(formatDuration(0)).toBe("00:00"); }); }); describe("formatFileSize", () => { it("formats bytes correctly", () => { expect(formatFileSize(1024)).toBe("1 KB"); expect(formatFileSize(1048576)).toBe("1 MB"); }); }); ``` ### 9.3 组件测试 ```typescript // src/components/features/storyboard/StoryboardItem.test.tsx import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { StoryboardItem } from './StoryboardItem'; const mockStoryboard = { id: '1', title: 'Test Storyboard', description: 'Test description', thumbnailUrl: '/test.jpg', duration: 5, order: 0, }; describe('StoryboardItem', () => { it('renders storyboard info', () => { render( {}} /> ); expect(screen.getByText('Test Storyboard')).toBeInTheDocument(); expect(screen.getByText('Test description')).toBeInTheDocument(); }); it('calls onSelect when clicked', () => { const onSelect = vi.fn(); render( ); fireEvent.click(screen.getByRole('button')); expect(onSelect).toHaveBeenCalledWith('1'); }); it('applies selected styles', () => { render( {}} /> ); expect(screen.getByRole('button')).toHaveClass('border-primary'); }); }); ``` ### 9.4 Hook 测试 ```typescript // src/hooks/useDebounce.test.ts import { describe, it, expect, vi } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useDebounce } from "./useDebounce"; describe("useDebounce", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it("returns initial value immediately", () => { const { result } = renderHook(() => useDebounce("hello", 500)); expect(result.current).toBe("hello"); }); it("debounces value changes", () => { const { result, rerender } = renderHook( ({ value }) => useDebounce(value, 500), { initialProps: { value: "hello" } }, ); rerender({ value: "world" }); expect(result.current).toBe("hello"); act(() => { vi.advanceTimersByTime(500); }); expect(result.current).toBe("world"); }); }); ``` ### 9.5 E2E 测试(Playwright) ```typescript // e2e/editor.spec.ts import { test, expect } from "@playwright/test"; test.describe("Editor Page", () => { test.beforeEach(async ({ page }) => { await page.goto("/editor/project-1"); }); test("displays storyboard list", async ({ page }) => { await expect(page.getByTestId("storyboard-panel")).toBeVisible(); await expect(page.getByTestId("storyboard-item")).toHaveCount(5); }); test("can select a storyboard", async ({ page }) => { const firstItem = page.getByTestId("storyboard-item").first(); await firstItem.click(); await expect(firstItem).toHaveClass(/selected/); }); test("plays video on play button click", async ({ page }) => { await page.getByRole("button", { name: "播放" }).click(); await expect(page.getByRole("button", { name: "暂停" })).toBeVisible(); }); test("timeline zoom works", async ({ page }) => { const zoomIn = page.getByRole("button", { name: "放大" }); await zoomIn.click(); // 验证缩放效果 }); }); ``` --- ## 12. 部署方案 ### 10.1 构建产物 ```bash # 生产构建 npm run build # 产物目录结构 dist/ ├── index.html ├── assets/ │ ├── index-[hash].js │ ├── index-[hash].css │ └── images/ └── favicon.ico ``` ### 10.2 部署配置 #### Nginx 配置 ```nginx server { listen 80; server_name www.jointo.ai; root /var/www/jointo/dist; index index.html; # Gzip 压缩 gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml; gzip_min_length 1000; # 静态资源缓存 location /assets { expires 1y; add_header Cache-Control "public, immutable"; } # SPA 路由支持 location / { try_files $uri $uri/ /index.html; } # API 代理 location /api { proxy_pass http://backend:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } ``` #### Docker 配置 ```dockerfile # Dockerfile FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] ``` ### 10.3 CI/CD Pipeline ```yaml # .github/workflows/deploy.yml name: Deploy on: push: branches: [main] jobs: build-and-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Build run: npm run build env: VITE_API_BASE_URL: ${{ secrets.API_BASE_URL }} - name: Deploy to server uses: appleboy/scp-action@master with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} key: ${{ secrets.SSH_KEY }} source: "dist/*" target: "/var/www/jointo/" ``` --- ## 13. 开发规范 ### 11.1 文件命名规范 | 类型 | 命名规范 | 示例 | | --------- | ------------- | ------------------------- | | 组件文件 | PascalCase | `StoryboardItem.tsx` | | Hook 文件 | camelCase | `useStoryboards.ts` | | 工具文件 | camelCase | `formatDuration.ts` | | 类型文件 | camelCase | `storyboard.ts` | | 常量文件 | camelCase | `routes.ts` | | 样式文件 | kebab-case | `globals.css` | | 测试文件 | 原文件名.test | `StoryboardItem.test.tsx` | ### 11.2 组件编写规范 ```typescript // 组件文件结构 import { useState, useCallback, memo } from 'react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import type { Storyboard } from '@types'; // Props 类型定义 interface StoryboardItemProps { storyboard: Storyboard; isSelected: boolean; onSelect: (id: string) => void; className?: string; } // 组件实现 export const StoryboardItem = memo(function StoryboardItem({ storyboard, isSelected, onSelect, className, }: StoryboardItemProps) { // Hooks const [isHovered, setIsHovered] = useState(false); // 事件处理 const handleClick = useCallback(() => { onSelect(storyboard.id); }, [storyboard.id, onSelect]); // 渲染 return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} >

{storyboard.title}

{storyboard.description}

); }); ``` ### 11.3 导入顺序规范 ```typescript // 1. React 相关 import { useState, useEffect, useCallback } from "react"; // 2. 第三方库 import { useQuery } from "@tanstack/react-query"; import { z } from "zod"; // 3. 内部别名导入 - 组件 import { Button } from "@components/ui/button"; import { LoadingSpinner } from "@components/common"; // 4. 内部别名导入 - 其他 import { useStoryboards } from "@hooks/api/useStoryboards"; import { useEditorStore } from "@stores/editorStore"; import { cn } from "@/lib/utils"; // 5. 类型导入 import type { Storyboard } from "@types"; // 6. 相对路径导入 import { StoryboardItem } from "./StoryboardItem"; import "./StoryboardPanel.css"; ``` ### 11.4 Git Commit 规范 ``` ():