64 KiB
Jointo(jointo)前端技术架构文档
项目名称:Jointo(jointo)
项目域名:https://www.jointo.ai
文档版本:v1.0
创建日期:2025-01-08
目录
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 导入别名配置
// 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"),
},
},
});
// 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 环境变量
# .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 环境变量类型定义
// src/vite-env.d.ts
/// <reference types="vite/client" />
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 开发脚本
// 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 应用全局状态
// 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<AppState>()(
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 编辑器状态
// 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<EditorState>()(
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 状态
// 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<UIState>()(
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 配置
// 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 (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
4.4 自定义 Query Hook 示例
// 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 路由结构
// 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 路由配置
// 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 (
<Suspense fallback={<LoadingSpinner fullScreen />}>
{children}
</Suspense>
);
}
const router = createBrowserRouter([
{
path: ROUTES.HOME,
element: <PageLoader><HomePage /></PageLoader>,
},
{
path: ROUTES.LOGIN,
element: <PageLoader><LoginPage /></PageLoader>,
},
{
path: ROUTES.PROJECTS,
element: (
<ProtectedRoute>
<PageLoader><ProjectsPage /></PageLoader>
</ProtectedRoute>
),
},
{
path: ROUTES.PROJECT,
element: (
<ProtectedRoute>
<PageLoader><ProjectPage /></PageLoader>
</ProtectedRoute>
),
},
{
path: '*',
element: <PageLoader><NotFoundPage /></PageLoader>,
},
]);
export function Router() {
return <RouterProvider router={router} />;
}
5.3 路由守卫
// 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 <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace />;
}
return <Outlet />;
}
6. API 层设计
6.1 API 客户端配置
// 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 服务模块示例
// 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<Storyboard[]> => {
const response = await apiClient.get<ApiResponse<Storyboard[]>>(
`/projects/${projectId}/storyboards`,
);
return response.data;
},
// 获取单个分镜
getById: async (id: string): Promise<Storyboard> => {
const response = await apiClient.get<ApiResponse<Storyboard>>(
`/storyboards/${id}`,
);
return response.data;
},
// 创建分镜
create: async (data: CreateStoryboardDto): Promise<Storyboard> => {
const response = await apiClient.post<ApiResponse<Storyboard>>(
`/projects/${data.projectId}/storyboards`,
data,
);
return response.data;
},
// 更新分镜
update: async (
id: string,
data: UpdateStoryboardDto,
): Promise<Storyboard> => {
const response = await apiClient.patch<ApiResponse<Storyboard>>(
`/storyboards/${id}`,
data,
);
return response.data;
},
// 删除分镜
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/storyboards/${id}`);
},
// 调整分镜顺序
reorder: async (
projectId: string,
storyboardIds: string[],
): Promise<void> => {
await apiClient.put(`/projects/${projectId}/storyboards/reorder`, {
storyboardIds,
});
},
};
6.3 Mock API 服务
// 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<Storyboard[]> => {
await delay(300);
return mockData.storyboards.filter((s) => s.projectId === projectId);
},
getById: async (id: string): Promise<Storyboard> => {
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<Storyboard> => {
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<Storyboard> => {
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<void> => {
await delay(300);
const index = mockData.storyboards.findIndex((s) => s.id === id);
if (index !== -1) {
mockData.storyboards.splice(index, 1);
}
},
};
6.4 API/Mock 自动切换
// 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 插件配置
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
7.1.2 主题配置(在 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 主题(深色/亮色)
/* 深色模式 (默认) */
: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));
}
使用示例:
import { cn } from "@/lib/utils";
function Button({ className, variant, ...props }) {
return (
<button
className={cn(
"px-4 py-2 rounded-md font-medium",
variant === "primary" && "bg-primary text-white",
variant === "secondary" && "bg-secondary text-foreground",
className,
)}
{...props}
/>
);
}
8. 国际化(i18n)
8.1 技术方案
使用 react-i18next 实现多语言支持。
npm install react-i18next i18next i18next-browser-languagedetector i18next-http-backend
8.2 i18n 配置
// src/lib/i18n.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
// 支持的语言
supportedLngs: ["zh-CN", "en-US", "zh-TW"],
// 默认语言
fallbackLng: "zh-CN",
// 默认命名空间
defaultNS: "common",
// 命名空间列表
ns: ["common", "editor", "settings", "errors"],
// 语言检测配置
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
lookupLocalStorage: "i18nextLng",
},
// 后端配置
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
// React 配置
react: {
useSuspense: true,
},
// 插值配置
interpolation: {
escapeValue: false,
},
});
export default i18n;
8.3 语言文件结构
public/
└── locales/
├── zh-CN/ # 简体中文(默认)
│ ├── common.json
│ ├── editor.json
│ ├── settings.json
│ └── errors.json
├── en-US/ # 英文
│ ├── common.json
│ ├── editor.json
│ ├── settings.json
│ └── errors.json
└── zh-TW/ # 繁体中文
├── common.json
├── editor.json
├── settings.json
└── errors.json
8.4 语言文件示例
// public/locales/zh-CN/common.json
{
"app": {
"name": "Jointo",
"tagline": "AI 驱动的视频制作平台"
},
"nav": {
"home": "首页",
"projects": "项目",
"settings": "设置",
"help": "帮助"
},
"actions": {
"save": "保存",
"cancel": "取消",
"delete": "删除",
"edit": "编辑",
"create": "创建",
"export": "导出",
"import": "导入"
},
"messages": {
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"confirmDelete": "确定要删除吗?"
}
}
// public/locales/en-US/common.json
{
"app": {
"name": "Jointo",
"tagline": "AI-Powered Video Production Platform"
},
"nav": {
"home": "Home",
"projects": "Projects",
"settings": "Settings",
"help": "Help"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"export": "Export",
"import": "Import"
},
"messages": {
"saveSuccess": "Saved successfully",
"saveFailed": "Failed to save",
"confirmDelete": "Are you sure you want to delete?"
}
}
8.5 使用方式
// 在组件中使用
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation();
return (
<div>
<h1>{t('app.name')}</h1>
<button>{t('actions.save')}</button>
</div>
);
}
// 使用多个命名空间
function EditorComponent() {
const { t } = useTranslation(['editor', 'common']);
return (
<div>
<h1>{t('editor:title')}</h1>
<button>{t('common:actions.save')}</button>
</div>
);
}
// 带参数的翻译
// JSON: "welcome": "欢迎, {{name}}!"
<p>{t('welcome', { name: 'John' })}</p>
// 复数处理
// JSON: "items": "{{count}} 个项目", "items_plural": "{{count}} 个项目"
<p>{t('items', { count: 5 })}</p>
8.6 语言切换
// 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<SettingsState>()(
persist(
(set) => ({
language: "zh-CN",
setLanguage: (lang) => {
i18n.changeLanguage(lang);
set({ language: lang });
},
}),
{
name: "settings-store",
},
),
);
9. 主题系统
9.1 技术方案
使用 CSS 变量 + class 切换实现主题系统,支持深色、亮色和跟随系统。
9.2 主题 Store
// 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<ThemeState>()(
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
// 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 的 <head> 中添加内联脚本:
<!-- index.html -->
<head>
<!-- ... -->
<script>
(function () {
try {
const stored = localStorage.getItem("theme-store");
const parsed = stored ? JSON.parse(stored) : null;
const theme = parsed?.state?.theme || "dark";
let resolvedTheme = theme;
if (theme === "system") {
resolvedTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
}
document.documentElement.classList.add(resolvedTheme);
} catch (e) {
document.documentElement.classList.add("dark");
}
})();
</script>
</head>
9.5 主题切换组件
// 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
{resolvedTheme === "dark" ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)}
<span className="sr-only">{t("settings.theme.toggle")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
{t("settings.theme.light")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
{t("settings.theme.dark")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
{t("settings.theme.system")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
9.6 Tailwind 深色模式配置
确保 tailwind.config.js 中配置了 class 策略:
// tailwind.config.js
module.exports = {
darkMode: ["class"], // 使用 class 策略
// ...
};
10. 性能优化
8.1 代码分割
// 路由级代码分割
const EditorPage = lazy(() => import('@/pages/EditorPage'));
// 组件级代码分割
const HeavyComponent = lazy(() => import('@components/features/HeavyComponent'));
// 使用时包裹 Suspense
<Suspense fallback={<LoadingSpinner />}>
<HeavyComponent />
</Suspense>
8.2 虚拟列表
使用 @tanstack/react-virtual 处理长列表:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // 预估每项高度
overscan: 5, // 预渲染数量
});
return (
<div ref={parentRef} className="h-full overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ItemComponent item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
8.3 组件优化
// 使用 memo 避免不必要渲染
const StoryboardItem = memo(function StoryboardItem({
storyboard,
isSelected,
onSelect
}: StoryboardItemProps) {
return (
<div
className={cn(
'p-3 rounded-md cursor-pointer',
isSelected && 'bg-primary/10 border-l-2 border-primary'
)}
onClick={() => onSelect(storyboard.id)}
>
{/* ... */}
</div>
);
});
// 使用 useMemo 缓存计算结果
const sortedStoryboards = useMemo(
() => storyboards.sort((a, b) => a.order - b.order),
[storyboards]
);
// 使用 useCallback 缓存回调
const handleSelect = useCallback((id: string) => {
selectStoryboard(id);
}, [selectStoryboard]);
8.4 图片优化
// 图片懒加载组件
function LazyImage({ src, alt, className }: LazyImageProps) {
const [loaded, setLoaded] = useState(false);
const imgRef = useRef<HTMLImageElement>(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 (
<div className={cn('relative', className)}>
{!loaded && <div className="absolute inset-0 bg-muted animate-pulse" />}
<img
ref={imgRef}
alt={alt}
className={cn(
'w-full h-full object-cover transition-opacity',
loaded ? 'opacity-100' : 'opacity-0'
)}
onLoad={() => setLoaded(true)}
/>
</div>
);
}
8.5 Debounce 和 Throttle
// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(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 <input value={search} onChange={(e) => setSearch(e.target.value)} />;
}
11. 测试策略
注意:测试工具配置尚未在项目中实现,以下内容作为未来规划参考。
11.1 测试金字塔(规划)
┌──────────┐
│ E2E │ 少量关键流程
│ Tests │
┌┴──────────┴┐
│Integration │ 主要业务流程
│ Tests │
┌┴────────────┴┐
│ Unit Tests │ 工具函数、Hooks、组件
└──────────────┘
9.2 单元测试(Vitest)
// 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 组件测试
// 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(
<StoryboardItem
storyboard={mockStoryboard}
isSelected={false}
onSelect={() => {}}
/>
);
expect(screen.getByText('Test Storyboard')).toBeInTheDocument();
expect(screen.getByText('Test description')).toBeInTheDocument();
});
it('calls onSelect when clicked', () => {
const onSelect = vi.fn();
render(
<StoryboardItem
storyboard={mockStoryboard}
isSelected={false}
onSelect={onSelect}
/>
);
fireEvent.click(screen.getByRole('button'));
expect(onSelect).toHaveBeenCalledWith('1');
});
it('applies selected styles', () => {
render(
<StoryboardItem
storyboard={mockStoryboard}
isSelected={true}
onSelect={() => {}}
/>
);
expect(screen.getByRole('button')).toHaveClass('border-primary');
});
});
9.4 Hook 测试
// 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)
// 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 构建产物
# 生产构建
npm run build
# 产物目录结构
dist/
├── index.html
├── assets/
│ ├── index-[hash].js
│ ├── index-[hash].css
│ └── images/
└── favicon.ico
10.2 部署配置
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
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
# .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 组件编写规范
// 组件文件结构
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 (
<div
className={cn(
'p-3 rounded-md cursor-pointer transition-colors',
isSelected && 'bg-primary/10 border-l-2 border-primary',
isHovered && 'bg-muted',
className
)}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<h3 className="font-medium">{storyboard.title}</h3>
<p className="text-sm text-muted-foreground">{storyboard.description}</p>
</div>
);
});
11.3 导入顺序规范
// 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 规范
<type>(<scope>): <subject>
<body>
<footer>
类型:
feat: 新功能fix: 修复docs: 文档style: 格式refactor: 重构perf: 性能test: 测试chore: 构建/工具
示例:
feat(storyboard): 添加分镜拖拽排序功能
- 使用 dnd-kit 实现拖拽
- 支持跨分镜组拖拽
- 添加拖拽动画效果
Closes #123
14. 工具链配置
14.1 ESLint 配置(Flat Config)
// eslint.config.js
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
export default tseslint.config(
{ ignores: ["dist", "node_modules"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
},
},
eslintConfigPrettier,
);
14.2 Prettier 配置
// .prettierrc
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"jsxSingleQuote": false,
"bracketSameLine": false
}
14.3 Husky + lint-staged
// package.json
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,css,md}": ["prettier --write"]
}
}
# .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
export default defineConfig({ plugins: [react()], test: { globals: true, environment: "jsdom", setupFiles: ["./src/test/setup.ts"], include: ["src/**/*.{test,spec}.{ts,tsx}"], coverage: { reporter: ["text", "json", "html"], exclude: ["node_modules/", "src/test/"], }, }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, });
---
## 附录
### A. 依赖版本清单
```json
{
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.6.1",
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-query-devtools": "^5.80.7",
"zustand": "^5.0.5",
"axios": "^1.10.0",
"zod": "^3.25.64",
"react-hook-form": "^7.57.0",
"@hookform/resolvers": "^5.1.0",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"clsx": "^2.1.1",
"tailwind-merge": "^3.4.0",
"class-variance-authority": "^0.7.1",
"@radix-ui/react-*": "^1.0.0+",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"i18next": "^25.2.1",
"react-i18next": "^15.5.2",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-http-backend": "^3.0.2",
"fountain-js": "^1.2.4",
"mammoth": "^1.11.0",
"pdfjs-dist": "^5.4.530",
"turndown": "^7.2.2",
"react-markdown": "^10.1.0",
"remark-breaks": "^4.0.0"
},
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^7.2.4",
"@vitejs/plugin-react": "^5.1.1",
"@tailwindcss/vite": "^4.1.18",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"@tailwindcss/typography": "^0.5.19",
"postcss": "^8.5.6",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"@eslint/js": "^9.39.2",
"typescript-eslint": "^8.52.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"prettier": "^3.7.4",
"globals": "^16.5.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/turndown": "^5.0.6"
}
}
```
### B. 变更记录
- **v1.1** (2025-01-18):更新至实际实现版本
- 更新所有依赖版本至实际使用版本
- 更新 Tailwind CSS v4 配置方式
- 更新 ESLint Flat Config 配置
- 修正路由结构(移除独立编辑器页面)
- 补充特殊功能依赖说明
- 标注测试工具为未来规划
- **v1.0** (2025-01-08):初始版本,从 PRD 文档中拆分
---
**文档结束**
> 本文档定义Jointo产品的前端技术架构,所有前端开发应遵循本规范进行开发。
>
> **最后更新**:2025-01-18 | **版本**:v1.1