You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

64 KiB

Jointo(jointo)前端技术架构文档

项目名称:Jointo(jointo)
项目域名https://www.jointo.ai
文档版本:v1.0
创建日期:2025-01-08


目录

  1. 技术栈概览
  2. 项目结构
  3. 构建与开发环境
  4. 状态管理
  5. 路由设计
  6. API 层设计
  7. 样式方案
  8. 国际化(i18n)
  9. 主题系统
  10. 性能优化
  11. 测试策略
  12. 部署方案
  13. 开发规范
  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 导入别名配置

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