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.
 

74 KiB

Jointo(jointo)组件设计文档

项目名称:Jointo(jointo)
项目域名https://www.jointo.ai
文档版本:v1.0
创建日期:2025-01-08
参考设计稿https://www.figma.com/make/xgPuMR3GZzHpYh0RZJdiYK/kaidong.ai


目录

  1. 组件架构总览
  2. 布局组件
  3. 分镜面板组件
  4. 预览面板组件
  5. 分镜看板组件
  6. AI 提示词面板组件
  7. 右侧边栏组件
  8. 通用组件
  9. 弹窗组件
  10. 引导组件
  11. 组件通信模式

1. 组件架构总览

1.1 整体布局结构

┌─────────────────────────────────────────────────────────────────────────────┐
│  TopBar(应用顶部栏)                                                        │
│  菜单 | 超级视频工作台 | 新建项目/导出项目/AI工具箱 | 协作者/通知/备注/设置  │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌────────────┬─────────────────────────────────────────┬──────────────────┐ │
│ │LeftSidebar │           CenterArea                    │   RightSidebar   │ │
│ │            │ ┌────────────┬────────────────────────┐ │                  │ │
│ │ [项目]     │ │ 分镜描述   │      预览窗口          │ │ [图片提示词]     │ │
│ │ [素材库]   │ │            │                        │ │ [角度变换]       │ │
│ │            │ │ 分镜1      │   ┌────────┬───────┐   │ │ [镜头调整]       │ │
│ │ 我的项目   │ │ 文字描述   │   │主预览图│ 预览1 │   │ │                  │ │
│ │ > 项目1    │ │            │   │        ├───────┤   │ │ 角色/场景描述    │ │
│ │ > 项目2    │ │ 分镜2      │   │        │ 预览2 │   │ │                  │ │
│ │ > ...      │ │ 文字描述   │   └────────┴───────┘   │ │ AI输入区         │ │
│ │            │ │            │                        │ │ @角色 #场景      │ │
│ │ 协同项目   │ │ [调整]     │   播放控制 | 时间码    │ │ [对话改图 ▼] [➤] │ │
│ │            │ └────────────┴────────────────────────┘ │                  │ │
│ └────────────┴─────────────────────────────────────────┴──────────────────┘ │
├─────────────────────────────────────────────────────────────────────────────┤
│  TimelinePanel(分镜看板)                                                     │
│  分镜看板 | - 100% + | 0s ─────────────────────────────────────────────── 60s │
│  轨道: 分镜 | 资源 | 视频 | 音效 | 台词 | 配音                              │
│  + 添加轨道                                                                  │
└─────────────────────────────────────────────────────────────────────────────┘

1.2 React 组件层次结构

<App>
  <Providers>
    <Router>
      <EditorPage>
        <AppLayout>
          {/* 顶部导航 */}
          <TopBar />
          
          <div className="flex flex-1">
            {/* 左侧边栏 - 项目/素材库 */}
            <LeftSidebar>
              <LeftSidebarTabs>
                <ProjectPanel />    {/* 项目列表 */}
                <LibraryPanel />    {/* 素材库 */}
              </LeftSidebarTabs>
            </LeftSidebar>
            
            {/* 中间区域 - 分镜描述 + 预览窗口 */}
            <CenterArea>
              {/* 分镜描述面板 */}
              <StoryboardDescPanel />
              
              {/* 预览窗口 */}
              <PreviewSection>
                <PreviewPanel />
                <PlaybackControls />
              </PreviewSection>
            </CenterArea>
            
            {/* 右侧边栏 - 图片提示词/角度变换/镜头调整 + AI输入 */}
            <RightSidebar>
              <RightPanelTabs>
                <ImagePromptPanel />    {/* 图片提示词 */}
                <AngleTransformPanel /> {/* 角度变换 */}
                <CameraAdjustPanel />   {/* 镜头调整 */}
              </RightPanelTabs>
              <AIPromptPanel />         {/* AI 输入区 */}
            </RightSidebar>
          </div>
          
          {/* 分镜看板 */}
          <TimelinePanel />
        </AppLayout>
        
        {/* 弹窗层 */}
        <OnboardingOverlay />
        <CookieConsent />
        <Modals />
      </EditorPage>
    </Router>
  </Providers>
</App>

1.3 组件分类

类别 说明 示例
布局组件 定义页面结构 AppLayout, TopBar, LeftSidebar
功能组件 实现业务功能 StoryboardPanel, TimelinePanel
UI 组件 基础 UI 元素 Button, Input, Dialog
通用组件 跨模块复用 LoadingSpinner, EmptyState
弹窗组件 模态交互 CreateProjectModal, ExportModal

2. 布局组件

2.1 AppLayout

应用主布局组件,定义整体页面结构。

// src/components/layout/AppLayout.tsx
interface AppLayoutProps {
  children: React.ReactNode;
}

export function AppLayout({ children }: AppLayoutProps) {
  const { leftSidebarCollapsed, rightSidebarCollapsed, timelineExpanded } = useUIStore();
  
  return (
    <div className="flex flex-col h-screen bg-background-primary overflow-hidden">
      {/* 顶部导航栏 - 固定 48px */}
      <TopBar className="h-12 flex-shrink-0" />
      
      {/* 主体区域 */}
      <div className="flex flex-1 overflow-hidden">
        {/* 左侧边栏 - 项目/素材库 */}
        <LeftSidebar 
          collapsed={leftSidebarCollapsed}
          className={cn(
            'flex-shrink-0 transition-all duration-300',
            leftSidebarCollapsed ? 'w-[60px]' : 'w-[240px]'
          )}
        />
        
        {/* 中间区域 - 分镜列表 + 预览 + AI输入 */}
        <CenterArea className="flex-1 overflow-hidden" />
        
        {/* 右侧边栏 - 资源/视频/音效/字幕/配音 */}
        <RightSidebar 
          collapsed={rightSidebarCollapsed}
          className={cn(
            'flex-shrink-0 transition-all duration-300',
            rightSidebarCollapsed ? 'w-0' : 'w-[320px]'
          )}
        />
      </div>
      
      {/* 分镜看板面板 */}
      <TimelinePanel 
        expanded={timelineExpanded}
        className={cn(
          'flex-shrink-0 transition-all duration-300',
          timelineExpanded ? 'h-[300px]' : 'h-[48px]'
        )}
      />
    </div>
  );
}

2.2 TopBar

顶部导航栏组件。

// src/components/layout/TopBar.tsx
interface TopBarProps {
  className?: string;
}

export function TopBar({ className }: TopBarProps) {
  return (
    <header className={cn(
      'flex items-center justify-between px-4',
      'bg-background-secondary border-b border-border',
      className
    )}>
      {/* 左侧 - Logo & 项目信息 */}
      <div className="flex items-center gap-4">
        <Logo />
        <ProjectSelector />
        <UndoRedoButtons />
      </div>
      
      {/* 中间 - 工具栏 */}
      <div className="flex items-center gap-2">
        <ZoomControls />
        <ViewModeToggle />
      </div>
      
      {/* 右侧 - 用户操作 */}
      <div className="flex items-center gap-3">
        <CollaboratorsAvatars />
        <ExportButton />
        <NotificationBell />
        <UserMenu />
      </div>
    </header>
  );
}

子组件:

组件 功能
Logo 产品 Logo,点击返回项目列表
ProjectSelector 项目名称下拉选择器
UndoRedoButtons 撤销/重做按钮组
ZoomControls 预览区缩放控制
ViewModeToggle 视图模式切换(单分镜/多分镜)
CollaboratorsAvatars 协作者头像组
ExportButton 导出按钮
NotificationBell 通知铃铛
UserMenu 用户菜单下拉

2.3 LeftSidebar

左侧边栏容器,包含项目列表和素材库 Tab 切换。

// src/components/layout/LeftSidebar.tsx
interface LeftSidebarProps {
  collapsed: boolean;
  className?: string;
}

export function LeftSidebar({ collapsed, className }: LeftSidebarProps) {
  const { leftPanelActiveTab, setLeftPanelTab, toggleLeftSidebar } = useUIStore();
  
  return (
    <aside className={cn(
      'flex flex-col',
      'bg-background-secondary border-r border-border',
      className
    )}>
      {/* 折叠按钮 */}
      <div className="flex items-center justify-between p-2 border-b border-border">
        {!collapsed && (
          <Tabs value={leftPanelActiveTab} onValueChange={setLeftPanelTab}>
            <TabsList>
              <TabsTrigger value="project">项目</TabsTrigger>
              <TabsTrigger value="library">素材库</TabsTrigger>
            </TabsList>
          </Tabs>
        )}
        <Button
          variant="ghost"
          size="icon"
          onClick={toggleLeftSidebar}
          aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
        >
          {collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
        </Button>
      </div>
      
      {/* 项目/素材库面板 */}
      {!collapsed && (
        <>
          {leftPanelActiveTab === 'project' && <ProjectPanel />}
          {leftPanelActiveTab === 'library' && <LibraryPanel />}
        </>
      )}
    </aside>
  );
}

2.4 CenterArea

中间区域容器,包含分镜列表、预览窗口和 AI 输入区三列布局。

// src/components/layout/CenterArea.tsx
interface CenterAreaProps {
  className?: string;
}

export function CenterArea({ className }: CenterAreaProps) {
  return (
    <div className={cn(
      'flex flex-1 overflow-hidden',
      className
    )}>
      {/* 分镜描述面板 */}
      <div className="w-[280px] flex-shrink-0 border-r border-border">
        <StoryboardDescPanel />
      </div>
      
      {/* 预览窗口 */}
      <div className="flex-1 flex flex-col min-w-0">
        <PreviewPanel />
        <PlaybackControls />
      </div>
    </div>
  );
}

子组件说明

组件 说明
StoryboardDescPanel 分镜描述面板,显示分镜文字描述和调整按钮
PreviewPanel 预览窗口,显示主预览图和小预览图
PlaybackControls 播放控制,时间码显示

2.5 RightSidebar

右侧边栏容器,包含图片提示词/角度变换/镜头调整 Tab 和 AI 输入区。

// src/components/layout/RightSidebar.tsx
interface RightSidebarProps {
  collapsed: boolean;
  className?: string;
}

export function RightSidebar({ collapsed, className }: RightSidebarProps) {
  const { rightPanelActiveTab, setRightPanelTab, toggleRightSidebar } = useUIStore();
  
  if (collapsed) {
    return (
      <Button
        variant="ghost"
        size="icon"
        className="fixed right-2 top-1/2 -translate-y-1/2"
        onClick={toggleRightSidebar}
      >
        <ChevronLeft className="h-4 w-4" />
      </Button>
    );
  }
  
  return (
    <aside className={cn(
      'flex flex-col',
      'bg-background-secondary border-l border-border',
      className
    )}>
      {/* Tab 导航 */}
      <div className="flex items-center border-b border-border">
        <Tabs value={rightPanelActiveTab} onValueChange={setRightPanelTab}>
          <TabsList className="w-full justify-start gap-0">
            <TabsTrigger value="imagePrompt" className="flex-1">
              <ImageIcon className="h-4 w-4" />
              <span className="ml-1 text-xs">图片提示词</span>
            </TabsTrigger>
            <TabsTrigger value="angleTransform" className="flex-1">
              <RotateCwIcon className="h-4 w-4" />
              <span className="ml-1 text-xs">角度变换</span>
            </TabsTrigger>
            <TabsTrigger value="cameraAdjust" className="flex-1">
              <CameraIcon className="h-4 w-4" />
              <span className="ml-1 text-xs">镜头调整</span>
            </TabsTrigger>
          </TabsList>
        </Tabs>
        
        <Button variant="ghost" size="icon" onClick={toggleRightSidebar}>
          <ChevronRight className="h-4 w-4" />
        </Button>
      </div>
      
      {/* Tab 内容 - 角色/场景描述等 */}
      <div className="flex-1 overflow-auto p-4">
        {rightPanelActiveTab === 'imagePrompt' && <ImagePromptPanel />}
        {rightPanelActiveTab === 'angleTransform' && <AngleTransformPanel />}
        {rightPanelActiveTab === 'cameraAdjust' && <CameraAdjustPanel />}
      </div>
      
      {/* AI 输入区 - 固定在底部 */}
      <AIPromptPanel />
    </aside>
  );
}

3. 分镜面板组件

3.1 StoryboardPanel

分镜列表面板,支持新建、排序、选择分镜。

// src/components/features/storyboard/StoryboardPanel.tsx
interface StoryboardPanelProps {
  collapsed?: boolean;
}

export function StoryboardPanel({ collapsed = false }: StoryboardPanelProps) {
  const { currentProjectId } = useAppStore();
  const { selectedStoryboardId, selectStoryboard } = useEditorStore();
  const { data: storyboards, isLoading } = useStoryboards(currentProjectId!);
  const createStoryboard = useCreateStoryboard();
  
  // 拖拽排序
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor)
  );
  
  const handleDragEnd = (event: DragEndEvent) => {
    // 处理拖拽排序
  };
  
  const handleAddStoryboard = () => {
    createStoryboard.mutate({
      projectId: currentProjectId!,
      title: `分镜 ${(storyboards?.length || 0) + 1}`,
      description: '',
      duration: 5,
    });
  };
  
  if (collapsed) {
    return (
      <div className="flex flex-col items-center gap-2 p-2">
        {storyboards?.map((storyboard, index) => (
          <StoryboardThumbnail
            key={storyboard.id}
            storyboard={storyboard}
            index={index + 1}
            isSelected={selectedStoryboardId === storyboard.id}
            onClick={() => selectStoryboard(storyboard.id)}
          />
        ))}
        <Button
          variant="ghost"
          size="icon"
          onClick={handleAddStoryboard}
        >
          <Plus className="h-4 w-4" />
        </Button>
      </div>
    );
  }
  
  return (
    <div className="flex flex-col h-full">
      {/* 头部 */}
      <div className="flex items-center justify-between p-3 border-b border-border">
        <span className="text-sm font-medium">分镜列表</span>
        <Button size="sm" onClick={handleAddStoryboard}>
          <Plus className="h-4 w-4 mr-1" />
          添加
        </Button>
      </div>
      
      {/* 分镜列表 */}
      <div className="flex-1 overflow-y-auto scrollbar-thin">
        {isLoading ? (
          <div className="flex items-center justify-center h-32">
            <LoadingSpinner />
          </div>
        ) : storyboards?.length === 0 ? (
          <EmptyState
            icon={<FilmIcon className="h-12 w-12" />}
            title="暂无分镜"
            description="点击上方按钮创建第一个分镜"
          />
        ) : (
          <DndContext
            sensors={sensors}
            collisionDetection={closestCenter}
            onDragEnd={handleDragEnd}
          >
            <SortableContext items={storyboards!.map((s) => s.id)}>
              <div className="p-2 space-y-2">
                {storyboards?.map((storyboard, index) => (
                  <StoryboardItem
                    key={storyboard.id}
                    storyboard={storyboard}
                    index={index + 1}
                    isSelected={selectedStoryboardId === storyboard.id}
                    onSelect={selectStoryboard}
                  />
                ))}
              </div>
            </SortableContext>
          </DndContext>
        )}
      </div>
    </div>
  );
}

3.2 StoryboardItem

单个分镜项组件。

// src/components/features/storyboard/StoryboardItem.tsx
interface StoryboardItemProps {
  storyboard: Storyboard;
  index: number;
  isSelected: boolean;
  onSelect: (id: string) => void;
}

export const StoryboardItem = memo(function StoryboardItem({
  storyboard,
  index,
  isSelected,
  onSelect,
}: StoryboardItemProps) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id: storyboard.id,
  });
  
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };
  
  return (
    <div
      ref={setNodeRef}
      style={style}
      className={cn(
        'group relative flex gap-3 p-2 rounded-md cursor-pointer',
        'border border-transparent transition-colors',
        isSelected && 'bg-primary/10 border-primary/50',
        !isSelected && 'hover:bg-muted'
      )}
      onClick={() => onSelect(storyboard.id)}
      {...attributes}
      {...listeners}
    >
      {/* 序号 */}
      <div className="flex-shrink-0 w-6 h-6 rounded bg-muted flex items-center justify-center">
        <span className="text-xs font-medium text-muted-foreground">{index}</span>
      </div>
      
      {/* 缩略图 */}
      <div className="flex-shrink-0 w-16 h-9 rounded overflow-hidden bg-muted">
        {storyboard.thumbnailUrl ? (
          <img
            src={storyboard.thumbnailUrl}
            alt={storyboard.title}
            className="w-full h-full object-cover"
          />
        ) : (
          <div className="w-full h-full flex items-center justify-center">
            <ImageIcon className="h-4 w-4 text-muted-foreground" />
          </div>
        )}
      </div>
      
      {/* 信息 */}
      <div className="flex-1 min-w-0">
        <h4 className="text-sm font-medium truncate">{storyboard.title}</h4>
        <p className="text-xs text-muted-foreground truncate">
          {storyboard.description || '无描述'}
        </p>
        <span className="text-xs text-muted-foreground">
          {formatDuration(storyboard.duration)}
        </span>
      </div>
      
      {/* 操作按钮 */}
      <div className={cn(
        'absolute right-2 top-2 opacity-0 transition-opacity',
        'group-hover:opacity-100'
      )}>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" size="icon" className="h-6 w-6">
              <MoreHorizontal className="h-4 w-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem>
              <Pencil className="h-4 w-4 mr-2" />
              编辑
            </DropdownMenuItem>
            <DropdownMenuItem>
              <Copy className="h-4 w-4 mr-2" />
              复制
            </DropdownMenuItem>
            <DropdownMenuSeparator />
            <DropdownMenuItem className="text-destructive">
              <Trash2 className="h-4 w-4 mr-2" />
              删除
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </div>
    </div>
  );
});

3.3 StoryboardEditor

分镜详情编辑组件(在预览区或弹窗中使用)。

// src/components/features/storyboard/StoryboardEditor.tsx
interface StoryboardEditorProps {
  storyboard: Storyboard;
  onSave: (data: UpdateStoryboardDto) => void;
}

export function StoryboardEditor({ storyboard, onSave }: StoryboardEditorProps) {
  const form = useForm<UpdateStoryboardDto>({
    resolver: zodResolver(updateStoryboardSchema),
    defaultValues: {
      title: storyboard.title,
      description: storyboard.description,
      duration: storyboard.duration,
      prompt: storyboard.prompt,
    },
  });
  
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSave)} className="space-y-4">
        <FormField
          control={form.control}
          name="title"
          render={({ field }) => (
            <FormItem>
              <FormLabel>标题</FormLabel>
              <FormControl>
                <Input {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="description"
          render={({ field }) => (
            <FormItem>
              <FormLabel>描述</FormLabel>
              <FormControl>
                <Textarea {...field} rows={3} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="duration"
          render={({ field }) => (
            <FormItem>
              <FormLabel>时长(秒)</FormLabel>
              <FormControl>
                <Input type="number" {...field} min={1} max={60} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="prompt"
          render={({ field }) => (
            <FormItem>
              <FormLabel>AI 提示词</FormLabel>
              <FormControl>
                <Textarea {...field} rows={4} placeholder="输入 AI 生成提示词..." />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <div className="flex justify-end gap-2">
          <Button type="submit">保存</Button>
        </div>
      </form>
    </Form>
  );
}

4. 预览面板组件

4.1 PreviewPanel

预览面板,展示当前分镜的预览内容。

// src/components/features/preview/PreviewPanel.tsx
export function PreviewPanel() {
  const { selectedStoryboardId } = useEditorStore();
  const { data: storyboard } = useStoryboard(selectedStoryboardId!);
  
  if (!selectedStoryboardId) {
    return (
      <div className="flex-1 flex items-center justify-center bg-background-primary">
        <EmptyState
          icon={<PlayCircle className="h-16 w-16" />}
          title="选择分镜"
          description="从左侧选择一个分镜进行预览"
        />
      </div>
    );
  }
  
  return (
    <div className="flex-1 flex flex-col bg-background-primary">
      {/* 预览区域 */}
      <div className="flex-1 flex items-center justify-center p-4">
        <div className="relative w-full max-w-4xl aspect-video bg-black rounded-lg overflow-hidden">
          {storyboard?.videoUrl ? (
            <VideoPlayer
              src={storyboard.videoUrl}
              poster={storyboard.thumbnailUrl}
            />
          ) : storyboard?.thumbnailUrl ? (
            <img
              src={storyboard.thumbnailUrl}
              alt={storyboard.title}
              className="w-full h-full object-contain"
            />
          ) : (
            <div className="w-full h-full flex items-center justify-center">
              <div className="text-center">
                <ImageIcon className="h-16 w-16 mx-auto text-muted-foreground mb-2" />
                <p className="text-muted-foreground">暂无预览内容</p>
              </div>
            </div>
          )}
          
          {/* 分镜信息叠加层 */}
          <div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent">
            <h3 className="text-white font-medium">{storyboard?.title}</h3>
            <p className="text-white/70 text-sm">{storyboard?.description}</p>
          </div>
        </div>
      </div>
    </div>
  );
}

4.2 VideoPlayer

视频播放器组件。

// src/components/features/preview/VideoPlayer.tsx
interface VideoPlayerProps {
  src: string;
  poster?: string;
  onTimeUpdate?: (time: number) => void;
  onDurationChange?: (duration: number) => void;
  onEnded?: () => void;
}

export function VideoPlayer({
  src,
  poster,
  onTimeUpdate,
  onDurationChange,
  onEnded,
}: VideoPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const { isPlaying, setPlaying, setCurrentTime, setDuration } = useEditorStore();
  
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    
    if (isPlaying) {
      video.play();
    } else {
      video.pause();
    }
  }, [isPlaying]);
  
  const handleTimeUpdate = () => {
    const video = videoRef.current;
    if (!video) return;
    
    setCurrentTime(video.currentTime);
    onTimeUpdate?.(video.currentTime);
  };
  
  const handleLoadedMetadata = () => {
    const video = videoRef.current;
    if (!video) return;
    
    setDuration(video.duration);
    onDurationChange?.(video.duration);
  };
  
  const handleEnded = () => {
    setPlaying(false);
    onEnded?.();
  };
  
  return (
    <video
      ref={videoRef}
      src={src}
      poster={poster}
      className="w-full h-full object-contain"
      onTimeUpdate={handleTimeUpdate}
      onLoadedMetadata={handleLoadedMetadata}
      onEnded={handleEnded}
      onClick={() => setPlaying(!isPlaying)}
    />
  );
}

4.3 PlaybackControls

播放控制条组件。

// src/components/features/preview/PlaybackControls.tsx
export function PlaybackControls() {
  const {
    isPlaying,
    currentTime,
    duration,
    setPlaying,
    setCurrentTime,
  } = useEditorStore();
  
  const handleSeek = (value: number[]) => {
    setCurrentTime(value[0]);
    // 同步到视频播放器
  };
  
  const handleSkipBack = () => {
    setCurrentTime(Math.max(0, currentTime - 5));
  };
  
  const handleSkipForward = () => {
    setCurrentTime(Math.min(duration, currentTime + 5));
  };
  
  return (
    <div className="flex items-center gap-4 px-4 py-2 bg-background-secondary border-t border-border">
      {/* 播放控制按钮 */}
      <div className="flex items-center gap-1">
        <Button variant="ghost" size="icon" onClick={handleSkipBack}>
          <SkipBack className="h-4 w-4" />
        </Button>
        
        <Button
          variant="ghost"
          size="icon"
          onClick={() => setPlaying(!isPlaying)}
        >
          {isPlaying ? (
            <Pause className="h-5 w-5" />
          ) : (
            <Play className="h-5 w-5" />
          )}
        </Button>
        
        <Button variant="ghost" size="icon" onClick={handleSkipForward}>
          <SkipForward className="h-4 w-4" />
        </Button>
      </div>
      
      {/* 时间显示 */}
      <span className="text-sm font-mono text-muted-foreground w-24">
        {formatTimecode(currentTime)} / {formatTimecode(duration)}
      </span>
      
      {/* 进度条 */}
      <div className="flex-1">
        <Slider
          value={[currentTime]}
          min={0}
          max={duration || 100}
          step={0.1}
          onValueChange={handleSeek}
        />
      </div>
      
      {/* 音量控制 */}
      <VolumeControl />
      
      {/* 全屏按钮 */}
      <Button variant="ghost" size="icon">
        <Maximize className="h-4 w-4" />
      </Button>
    </div>
  );
}

5. 分镜看板组件

5.1 TimelinePanel

分镜看板面板,展示所有轨道和时间块。

// src/components/features/timeline/TimelinePanel.tsx
interface TimelinePanelProps {
  expanded: boolean;
  className?: string;
}

export function TimelinePanel({ expanded, className }: TimelinePanelProps) {
  const { toggleTimeline } = useUIStore();
  const { timelineZoom, setTimelineZoom } = useEditorStore();
  const { currentProjectId } = useAppStore();
  const { data: tracks } = useTimelineTracks(currentProjectId!);
  
  if (!expanded) {
    return (
      <div className={cn(
        'flex items-center justify-between px-4',
        'bg-background-secondary border-t border-border',
        className
      )}>
        <span className="text-sm font-medium">分镜看板</span>
        <Button variant="ghost" size="icon" onClick={toggleTimeline}>
          <ChevronUp className="h-4 w-4" />
        </Button>
      </div>
    );
  }
  
  return (
    <div className={cn(
      'flex flex-col',
      'bg-background-secondary border-t border-border',
      className
    )}>
      {/* 分镜看板头部 */}
      <div className="flex items-center justify-between px-4 py-2 border-b border-border">
        <span className="text-sm font-medium">分镜看板</span>
        
        <div className="flex items-center gap-2">
          {/* 缩放控制 */}
          <Button
            variant="ghost"
            size="icon"
            onClick={() => setTimelineZoom(timelineZoom - 0.2)}
          >
            <ZoomOut className="h-4 w-4" />
          </Button>
          <span className="text-xs text-muted-foreground w-12 text-center">
            {Math.round(timelineZoom * 100)}%
          </span>
          <Button
            variant="ghost"
            size="icon"
            onClick={() => setTimelineZoom(timelineZoom + 0.2)}
          >
            <ZoomIn className="h-4 w-4" />
          </Button>
          
          <div className="w-px h-4 bg-border mx-2" />
          
          <Button variant="ghost" size="icon" onClick={toggleTimeline}>
            <ChevronDown className="h-4 w-4" />
          </Button>
        </div>
      </div>
      
      {/* 分镜看板内容 */}
      <div className="flex-1 flex overflow-hidden">
        {/* 轨道标签 */}
        <div className="w-32 flex-shrink-0 border-r border-border">
          {tracks?.map((track) => (
            <TrackLabel key={track.id} track={track} />
          ))}
        </div>
        
        {/* 分镜看板主体 */}
        <div className="flex-1 overflow-x-auto overflow-y-hidden scrollbar-thin">
          <div style={{ width: `${100 * timelineZoom}%`, minWidth: '100%' }}>
            {/* 时间刻度 */}
            <TimeRuler zoom={timelineZoom} />
            
            {/* 轨道内容 */}
            {tracks?.map((track) => (
              <TimelineTrack key={track.id} track={track} zoom={timelineZoom} />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

5.2 TimelineTrack

单个轨道组件。

// src/components/features/timeline/TimelineTrack.tsx
interface TimelineTrackProps {
  track: TimelineTrack;
  zoom: number;
}

export function TimelineTrack({ track, zoom }: TimelineTrackProps) {
  const { selectedTimelineItems, toggleTimelineItem } = useEditorStore();
  
  // 轨道颜色映射
  const trackColors: Record<string, string> = {
    storyboard: 'bg-track-storyboard',
    resource: 'bg-track-resource',
    video: 'bg-track-video',
    sound: 'bg-track-sound',
    subtitle: 'bg-track-subtitle',
    voice: 'bg-track-voice',
  };
  
  return (
    <div className="h-12 relative border-b border-border">
      {track.items.map((item) => {
        const isSelected = selectedTimelineItems.includes(item.id);
        const left = (item.startTime / track.duration) * 100;
        const width = ((item.endTime - item.startTime) / track.duration) * 100;
        
        return (
          <TimelineItem
            key={item.id}
            item={item}
            trackType={track.type}
            isSelected={isSelected}
            style={{
              left: `${left}%`,
              width: `${width}%`,
            }}
            onClick={() => toggleTimelineItem(item.id)}
          />
        );
      })}
    </div>
  );
}

5.3 TimelineItem

分镜看板块组件。

// src/components/features/timeline/TimelineItem.tsx
interface TimelineItemProps {
  item: TimelineItem;
  trackType: string;
  isSelected: boolean;
  style: React.CSSProperties;
  onClick: () => void;
}

export const TimelineItem = memo(function TimelineItem({
  item,
  trackType,
  isSelected,
  style,
  onClick,
}: TimelineItemProps) {
  // 拖拽调整位置和时长
  const { attributes, listeners, setNodeRef, transform } = useDraggable({
    id: item.id,
  });
  
  const trackColors: Record<string, string> = {
    storyboard: 'bg-track-storyboard',
    resource: 'bg-track-resource',
    video: 'bg-track-video',
    sound: 'bg-track-sound',
    subtitle: 'bg-track-subtitle',
    voice: 'bg-track-voice',
  };
  
  return (
    <div
      ref={setNodeRef}
      className={cn(
        'absolute top-1 bottom-1 rounded cursor-pointer',
        'flex items-center px-2 overflow-hidden',
        trackColors[trackType],
        isSelected && 'ring-2 ring-primary ring-offset-1'
      )}
      style={{
        ...style,
        transform: transform ? `translateX(${transform.x}px)` : undefined,
      }}
      onClick={onClick}
      {...attributes}
      {...listeners}
    >
      <span className="text-xs text-white truncate">{item.title}</span>
      
      {/* 左右拖拽手柄 */}
      <div className="absolute left-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-white/30" />
      <div className="absolute right-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-white/30" />
    </div>
  );
});

5.4 TimeRuler

时间刻度尺组件。

// src/components/features/timeline/TimeRuler.tsx
interface TimeRulerProps {
  zoom: number;
  duration?: number;
}

export function TimeRuler({ zoom, duration = 60 }: TimeRulerProps) {
  const { currentTime } = useEditorStore();
  
  // 计算刻度
  const tickInterval = zoom > 1 ? 1 : zoom > 0.5 ? 5 : 10; // 秒
  const ticks = [];
  
  for (let i = 0; i <= duration; i += tickInterval) {
    ticks.push(i);
  }
  
  return (
    <div className="h-6 relative bg-background-tertiary border-b border-border">
      {/* 刻度标记 */}
      {ticks.map((tick) => (
        <div
          key={tick}
          className="absolute top-0 bottom-0 border-l border-border"
          style={{ left: `${(tick / duration) * 100}%` }}
        >
          <span className="absolute top-0 left-1 text-xs text-muted-foreground">
            {formatTimecode(tick)}
          </span>
        </div>
      ))}
      
      {/* 播放头 */}
      <div
        className="absolute top-0 bottom-0 w-0.5 bg-primary z-10"
        style={{ left: `${(currentTime / duration) * 100}%` }}
      >
        <div className="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-primary rounded-full" />
      </div>
    </div>
  );
}

6. AI 提示词面板组件

6.1 AIPromptPanel

AI 提示词输入和生成面板。

// src/components/features/ai/AIPromptPanel.tsx
export function AIPromptPanel() {
  const { aiPanelVisible, toggleAIPanel } = useUIStore();
  const { selectedStoryboardId } = useEditorStore();
  const { data: storyboard } = useStoryboard(selectedStoryboardId!);
  const generateAI = useAIGenerate();
  
  const [prompt, setPrompt] = useState(storyboard?.prompt || '');
  const [generateType, setGenerateType] = useState<'image' | 'video'>('image');
  
  if (!aiPanelVisible) {
    return null;
  }
  
  const handleGenerate = () => {
    generateAI.mutate({
      storyboardId: selectedStoryboardId!,
      type: generateType,
      prompt,
    });
  };
  
  return (
    <div className="border-t border-border bg-background-secondary">
      <div className="flex items-center justify-between px-4 py-2 border-b border-border">
        <div className="flex items-center gap-2">
          <Sparkles className="h-4 w-4 text-accent" />
          <span className="text-sm font-medium">AI 生成</span>
        </div>
        <Button variant="ghost" size="icon" onClick={toggleAIPanel}>
          <X className="h-4 w-4" />
        </Button>
      </div>
      
      <div className="p-4 space-y-4">
        {/* 生成类型选择 */}
        <div className="flex gap-2">
          <Button
            variant={generateType === 'image' ? 'default' : 'outline'}
            size="sm"
            onClick={() => setGenerateType('image')}
          >
            <ImageIcon className="h-4 w-4 mr-1" />
            生成图片
          </Button>
          <Button
            variant={generateType === 'video' ? 'default' : 'outline'}
            size="sm"
            onClick={() => setGenerateType('video')}
          >
            <VideoIcon className="h-4 w-4 mr-1" />
            生成视频
          </Button>
        </div>
        
        {/* 提示词输入 */}
        <div className="space-y-2">
          <Label htmlFor="ai-prompt">提示词</Label>
          <Textarea
            id="ai-prompt"
            value={prompt}
            onChange={(e) => setPrompt(e.target.value)}
            placeholder="描述你想要生成的内容..."
            rows={4}
          />
        </div>
        
        {/* 高级选项 */}
        <Collapsible>
          <CollapsibleTrigger className="flex items-center gap-1 text-sm text-muted-foreground">
            <ChevronRight className="h-4 w-4" />
            高级选项
          </CollapsibleTrigger>
          <CollapsibleContent className="pt-2 space-y-2">
            <div className="grid grid-cols-2 gap-4">
              <div>
                <Label>风格</Label>
                <Select defaultValue="realistic">
                  <SelectTrigger>
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="realistic">写实</SelectItem>
                    <SelectItem value="anime">动漫</SelectItem>
                    <SelectItem value="cartoon">卡通</SelectItem>
                    <SelectItem value="3d">3D</SelectItem>
                  </SelectContent>
                </Select>
              </div>
              <div>
                <Label>比例</Label>
                <Select defaultValue="16:9">
                  <SelectTrigger>
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="16:9">16:9</SelectItem>
                    <SelectItem value="9:16">9:16</SelectItem>
                    <SelectItem value="1:1">1:1</SelectItem>
                    <SelectItem value="4:3">4:3</SelectItem>
                  </SelectContent>
                </Select>
              </div>
            </div>
          </CollapsibleContent>
        </Collapsible>
        
        {/* 生成按钮 */}
        <Button
          className="w-full"
          onClick={handleGenerate}
          disabled={!prompt || generateAI.isPending}
        >
          {generateAI.isPending ? (
            <>
              <LoadingSpinner className="h-4 w-4 mr-2" />
              生成中...
            </>
          ) : (
            <>
              <Sparkles className="h-4 w-4 mr-2" />
              开始生成
            </>
          )}
        </Button>
      </div>
    </div>
  );
}

6.2 AIGeneratePreview

AI 生成结果预览弹窗。

// src/components/features/ai/AIGeneratePreview.tsx
interface AIGeneratePreviewProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  result: AIGenerateResult;
  onApply: () => void;
  onRegenerate: () => void;
}

export function AIGeneratePreview({
  open,
  onOpenChange,
  result,
  onApply,
  onRegenerate,
}: AIGeneratePreviewProps) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-2xl">
        <DialogHeader>
          <DialogTitle>AI 生成结果</DialogTitle>
        </DialogHeader>
        
        <div className="space-y-4">
          {/* 预览 */}
          <div className="aspect-video bg-black rounded-lg overflow-hidden">
            {result.type === 'image' ? (
              <img
                src={result.url}
                alt="AI 生成结果"
                className="w-full h-full object-contain"
              />
            ) : (
              <video
                src={result.url}
                controls
                className="w-full h-full object-contain"
              />
            )}
          </div>
          
          {/* 生成信息 */}
          <div className="text-sm text-muted-foreground">
            <p>提示词:{result.prompt}</p>
            <p>生成时间:{formatDate(result.createdAt)}</p>
          </div>
        </div>
        
        <DialogFooter>
          <Button variant="outline" onClick={onRegenerate}>
            <RefreshCw className="h-4 w-4 mr-2" />
            重新生成
          </Button>
          <Button onClick={onApply}>
            <Check className="h-4 w-4 mr-2" />
            应用到分镜
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

7. 右侧边栏组件

7.1 ResourcePanel

资源管理面板。

// src/components/features/resources/ResourcePanel.tsx
export function ResourcePanel() {
  const { currentProjectId } = useAppStore();
  const { data: resources, isLoading } = useResources(currentProjectId!);
  const [filter, setFilter] = useState<'all' | 'image' | 'video' | 'audio'>('all');
  const [search, setSearch] = useState('');
  
  const filteredResources = useMemo(() => {
    return resources?.filter((r) => {
      if (filter !== 'all' && r.type !== filter) return false;
      if (search && !r.name.toLowerCase().includes(search.toLowerCase())) return false;
      return true;
    });
  }, [resources, filter, search]);
  
  return (
    <div className="flex flex-col h-full">
      {/* 头部 */}
      <div className="p-3 space-y-3 border-b border-border">
        {/* 搜索 */}
        <div className="relative">
          <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
          <Input
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            placeholder="搜索资源..."
            className="pl-8"
          />
        </div>
        
        {/* 筛选 */}
        <div className="flex gap-1">
          {(['all', 'image', 'video', 'audio'] as const).map((type) => (
            <Button
              key={type}
              variant={filter === type ? 'default' : 'ghost'}
              size="sm"
              onClick={() => setFilter(type)}
            >
              {type === 'all' ? '全部' : type === 'image' ? '图片' : type === 'video' ? '视频' : '音频'}
            </Button>
          ))}
        </div>
      </div>
      
      {/* 资源网格 */}
      <div className="flex-1 overflow-y-auto scrollbar-thin p-3">
        {isLoading ? (
          <div className="grid grid-cols-2 gap-2">
            {Array.from({ length: 6 }).map((_, i) => (
              <Skeleton key={i} className="aspect-square rounded" />
            ))}
          </div>
        ) : filteredResources?.length === 0 ? (
          <EmptyState
            icon={<FolderOpen className="h-12 w-12" />}
            title="暂无资源"
            description="上传资源或使用 AI 生成"
          />
        ) : (
          <div className="grid grid-cols-2 gap-2">
            {filteredResources?.map((resource) => (
              <ResourceCard key={resource.id} resource={resource} />
            ))}
          </div>
        )}
      </div>
      
      {/* 上传按钮 */}
      <div className="p-3 border-t border-border">
        <ResourceUploader />
      </div>
    </div>
  );
}

7.2 VideoPanel / SoundPanel / SubtitlePanel / VoicePanel

这些面板结构类似,展示对应类型的素材列表。

// src/components/features/panels/VideoPanel.tsx
export function VideoPanel() {
  const { selectedStoryboardId } = useEditorStore();
  const { data: videos } = useStoryboardVideos(selectedStoryboardId!);
  
  return (
    <div className="flex flex-col h-full">
      <div className="p-3 border-b border-border">
        <h3 className="text-sm font-medium">视频素材</h3>
      </div>
      
      <div className="flex-1 overflow-y-auto scrollbar-thin p-3">
        {videos?.length === 0 ? (
          <EmptyState
            icon={<Video className="h-12 w-12" />}
            title="暂无视频"
            description="为当前分镜添加视频素材"
          />
        ) : (
          <div className="space-y-2">
            {videos?.map((video) => (
              <VideoItem key={video.id} video={video} />
            ))}
          </div>
        )}
      </div>
      
      <div className="p-3 border-t border-border">
        <Button className="w-full" size="sm">
          <Plus className="h-4 w-4 mr-1" />
          添加视频
        </Button>
      </div>
    </div>
  );
}

// SoundPanel, SubtitlePanel, VoicePanel 结构类似

7.3 SettingsPanel

设置面板。

// src/components/features/settings/SettingsPanel.tsx
export function SettingsPanel() {
  const { currentProjectId } = useAppStore();
  const { data: project } = useProject(currentProjectId!);
  const updateProject = useUpdateProject();
  
  return (
    <div className="flex flex-col h-full">
      <div className="p-3 border-b border-border">
        <h3 className="text-sm font-medium">项目设置</h3>
      </div>
      
      <div className="flex-1 overflow-y-auto scrollbar-thin p-3 space-y-6">
        {/* 基本设置 */}
        <section className="space-y-3">
          <h4 className="text-sm font-medium text-muted-foreground">基本设置</h4>
          
          <div className="space-y-2">
            <Label>项目名称</Label>
            <Input defaultValue={project?.name} />
          </div>
          
          <div className="space-y-2">
            <Label>分辨率</Label>
            <Select defaultValue={project?.resolution || '1920x1080'}>
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="1920x1080">1080p (1920x1080)</SelectItem>
                <SelectItem value="1280x720">720p (1280x720)</SelectItem>
                <SelectItem value="3840x2160">4K (3840x2160)</SelectItem>
                <SelectItem value="1080x1920">竖屏 (1080x1920)</SelectItem>
              </SelectContent>
            </Select>
          </div>
          
          <div className="space-y-2">
            <Label>帧率</Label>
            <Select defaultValue={String(project?.frameRate || 30)}>
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="24">24 fps</SelectItem>
                <SelectItem value="25">25 fps</SelectItem>
                <SelectItem value="30">30 fps</SelectItem>
                <SelectItem value="60">60 fps</SelectItem>
              </SelectContent>
            </Select>
          </div>
        </section>
        
        {/* 导出设置 */}
        <section className="space-y-3">
          <h4 className="text-sm font-medium text-muted-foreground">导出设置</h4>
          
          <div className="space-y-2">
            <Label>输出格式</Label>
            <Select defaultValue="mp4">
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="mp4">MP4</SelectItem>
                <SelectItem value="webm">WebM</SelectItem>
                <SelectItem value="mov">MOV</SelectItem>
              </SelectContent>
            </Select>
          </div>
          
          <div className="space-y-2">
            <Label>质量</Label>
            <Select defaultValue="high">
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="low">低质量 (快速)</SelectItem>
                <SelectItem value="medium">中等质量</SelectItem>
                <SelectItem value="high">高质量</SelectItem>
                <SelectItem value="ultra">超高质量</SelectItem>
              </SelectContent>
            </Select>
          </div>
        </section>
      </div>
      
      <div className="p-3 border-t border-border">
        <Button className="w-full">保存设置</Button>
      </div>
    </div>
  );
}

8. 通用组件

8.1 LoadingSpinner

加载动画组件。

// src/components/common/LoadingSpinner.tsx
interface LoadingSpinnerProps {
  size?: 'sm' | 'md' | 'lg';
  fullScreen?: boolean;
  className?: string;
}

export function LoadingSpinner({
  size = 'md',
  fullScreen = false,
  className,
}: LoadingSpinnerProps) {
  const sizes = {
    sm: 'h-4 w-4',
    md: 'h-8 w-8',
    lg: 'h-12 w-12',
  };
  
  const spinner = (
    <Loader2 className={cn('animate-spin text-primary', sizes[size], className)} />
  );
  
  if (fullScreen) {
    return (
      <div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-50">
        {spinner}
      </div>
    );
  }
  
  return spinner;
}

8.2 EmptyState

空状态组件。

// src/components/common/EmptyState.tsx
interface EmptyStateProps {
  icon?: React.ReactNode;
  title: string;
  description?: string;
  action?: {
    label: string;
    onClick: () => void;
  };
  className?: string;
}

export function EmptyState({
  icon,
  title,
  description,
  action,
  className,
}: EmptyStateProps) {
  return (
    <div className={cn(
      'flex flex-col items-center justify-center py-12 text-center',
      className
    )}>
      {icon && (
        <div className="mb-4 text-muted-foreground">
          {icon}
        </div>
      )}
      <h3 className="text-lg font-medium">{title}</h3>
      {description && (
        <p className="mt-1 text-sm text-muted-foreground max-w-sm">
          {description}
        </p>
      )}
      {action && (
        <Button className="mt-4" onClick={action.onClick}>
          {action.label}
        </Button>
      )}
    </div>
  );
}

8.3 ErrorBoundary

错误边界组件。

// src/components/common/ErrorBoundary.tsx
interface ErrorBoundaryState {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends Component<
  { children: React.ReactNode; fallback?: React.ReactNode },
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { hasError: false };
  
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }
  
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('ErrorBoundary caught an error:', error, errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="flex flex-col items-center justify-center p-8">
          <AlertCircle className="h-12 w-12 text-destructive mb-4" />
          <h2 className="text-lg font-medium">出错了</h2>
          <p className="text-sm text-muted-foreground mt-1">
            {this.state.error?.message || '发生未知错误'}
          </p>
          <Button
            className="mt-4"
            onClick={() => this.setState({ hasError: false })}
          >
            重试
          </Button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

8.4 Tooltip

工具提示组件(基于 shadcn/ui)。

// src/components/common/Tooltip.tsx
// 使用 shadcn/ui 的 Tooltip 组件
import {
  Tooltip as TooltipPrimitive,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from '@/components/ui/tooltip';

interface TooltipProps {
  content: string;
  children: React.ReactNode;
  side?: 'top' | 'right' | 'bottom' | 'left';
  delayDuration?: number;
}

export function Tooltip({
  content,
  children,
  side = 'top',
  delayDuration = 200,
}: TooltipProps) {
  return (
    <TooltipProvider delayDuration={delayDuration}>
      <TooltipPrimitive>
        <TooltipTrigger asChild>{children}</TooltipTrigger>
        <TooltipContent side={side}>
          <p>{content}</p>
        </TooltipContent>
      </TooltipPrimitive>
    </TooltipProvider>
  );
}

8.5 ThemeToggle

主题切换组件,支持深色、亮色和跟随系统。

// 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>
  );
}

使用位置:顶部导航栏 TopBar 右侧用户操作区域。

8.6 LanguageSelector

语言切换组件,支持简体中文、英文和繁体中文。

// src/components/common/LanguageSelector.tsx
import { Languages, Check } from 'lucide-react';
import { useSettingsStore } from '@/stores/settingsStore';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';

type Language = 'zh-CN' | 'en-US' | 'zh-TW';

const languages: { code: Language; label: string; nativeLabel: string }[] = [
  { code: 'zh-CN', label: 'Chinese (Simplified)', nativeLabel: '简体中文' },
  { code: 'en-US', label: 'English', nativeLabel: 'English' },
  { code: 'zh-TW', label: 'Chinese (Traditional)', nativeLabel: '繁體中文' },
];

export function LanguageSelector() {
  const { t } = useTranslation();
  const { language, setLanguage } = useSettingsStore();
  
  const currentLanguage = languages.find((l) => l.code === language);
  
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          <Languages className="h-4 w-4" />
          <span className="sr-only">{t('settings.language.toggle')}</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-48">
        {languages.map((lang) => (
          <DropdownMenuItem
            key={lang.code}
            onClick={() => setLanguage(lang.code)}
            className="flex items-center justify-between"
          >
            <span>{lang.nativeLabel}</span>
            {language === lang.code && (
              <Check className="h-4 w-4 text-primary" />
            )}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

使用位置:顶部导航栏 TopBar 右侧用户操作区域,或设置面板中。

8.7 LanguageSelector(下拉选择器版本)

用于设置面板中的完整语言选择器。

// src/components/common/LanguageSelect.tsx
import { useSettingsStore } from '@/stores/settingsStore';
import { useTranslation } from 'react-i18next';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';

type Language = 'zh-CN' | 'en-US' | 'zh-TW';

const languages: { code: Language; nativeLabel: string }[] = [
  { code: 'zh-CN', nativeLabel: '简体中文' },
  { code: 'en-US', nativeLabel: 'English' },
  { code: 'zh-TW', nativeLabel: '繁體中文' },
];

export function LanguageSelect() {
  const { t } = useTranslation();
  const { language, setLanguage } = useSettingsStore();
  
  return (
    <div className="space-y-2">
      <Label>{t('settings.language.label')}</Label>
      <Select value={language} onValueChange={(v) => setLanguage(v as Language)}>
        <SelectTrigger>
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {languages.map((lang) => (
            <SelectItem key={lang.code} value={lang.code}>
              {lang.nativeLabel}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  );
}

8.8 ThemeSelect

用于设置面板中的完整主题选择器。

// src/components/common/ThemeSelect.tsx
import { useThemeStore } from '@/stores/themeStore';
import { useTranslation } from 'react-i18next';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Moon, Sun, Monitor } from 'lucide-react';

type Theme = 'dark' | 'light' | 'system';

const themes: { value: Theme; icon: React.ReactNode }[] = [
  { value: 'dark', icon: <Moon className="h-4 w-4" /> },
  { value: 'light', icon: <Sun className="h-4 w-4" /> },
  { value: 'system', icon: <Monitor className="h-4 w-4" /> },
];

export function ThemeSelect() {
  const { t } = useTranslation();
  const { theme, setTheme } = useThemeStore();
  
  return (
    <div className="space-y-2">
      <Label>{t('settings.theme.label')}</Label>
      <Select value={theme} onValueChange={(v) => setTheme(v as Theme)}>
        <SelectTrigger>
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {themes.map((themeOption) => (
            <SelectItem key={themeOption.value} value={themeOption.value}>
              <div className="flex items-center gap-2">
                {themeOption.icon}
                <span>{t(`settings.theme.${themeOption.value}`)}</span>
              </div>
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  );
}

9. 弹窗组件

9.1 CreateProjectModal

创建项目弹窗。

// src/components/features/project/CreateProjectModal.tsx
export function CreateProjectModal() {
  const { createProjectModalOpen, setCreateProjectModalOpen } = useUIStore();
  const createProject = useCreateProject();
  
  const form = useForm<CreateProjectDto>({
    resolver: zodResolver(createProjectSchema),
    defaultValues: {
      name: '',
      description: '',
      resolution: '1920x1080',
      frameRate: 30,
    },
  });
  
  const onSubmit = (data: CreateProjectDto) => {
    createProject.mutate(data, {
      onSuccess: () => {
        setCreateProjectModalOpen(false);
        form.reset();
      },
    });
  };
  
  return (
    <Dialog open={createProjectModalOpen} onOpenChange={setCreateProjectModalOpen}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>新建项目</DialogTitle>
          <DialogDescription>
            创建一个新的视频项目,开始你的创作之旅
          </DialogDescription>
        </DialogHeader>
        
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
            <FormField
              control={form.control}
              name="name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>项目名称</FormLabel>
                  <FormControl>
                    <Input placeholder="输入项目名称" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            
            <FormField
              control={form.control}
              name="description"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>项目描述(可选)</FormLabel>
                  <FormControl>
                    <Textarea placeholder="描述你的项目" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            
            <div className="grid grid-cols-2 gap-4">
              <FormField
                control={form.control}
                name="resolution"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>分辨率</FormLabel>
                    <Select onValueChange={field.onChange} defaultValue={field.value}>
                      <FormControl>
                        <SelectTrigger>
                          <SelectValue />
                        </SelectTrigger>
                      </FormControl>
                      <SelectContent>
                        <SelectItem value="1920x1080">1080p</SelectItem>
                        <SelectItem value="1280x720">720p</SelectItem>
                        <SelectItem value="3840x2160">4K</SelectItem>
                      </SelectContent>
                    </Select>
                    <FormMessage />
                  </FormItem>
                )}
              />
              
              <FormField
                control={form.control}
                name="frameRate"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>帧率</FormLabel>
                    <Select onValueChange={(v) => field.onChange(Number(v))} defaultValue={String(field.value)}>
                      <FormControl>
                        <SelectTrigger>
                          <SelectValue />
                        </SelectTrigger>
                      </FormControl>
                      <SelectContent>
                        <SelectItem value="24">24 fps</SelectItem>
                        <SelectItem value="30">30 fps</SelectItem>
                        <SelectItem value="60">60 fps</SelectItem>
                      </SelectContent>
                    </Select>
                    <FormMessage />
                  </FormItem>
                )}
              />
            </div>
            
            <DialogFooter>
              <Button type="button" variant="outline" onClick={() => setCreateProjectModalOpen(false)}>
                取消
              </Button>
              <Button type="submit" disabled={createProject.isPending}>
                {createProject.isPending ? '创建中...' : '创建项目'}
              </Button>
            </DialogFooter>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  );
}

9.2 ExportModal

导出弹窗。

// src/components/features/project/ExportModal.tsx
export function ExportModal() {
  const { exportModalOpen, setExportModalOpen } = useUIStore();
  const { currentProjectId } = useAppStore();
  const exportProject = useExportProject();
  
  const [exportOptions, setExportOptions] = useState({
    format: 'mp4',
    quality: 'high',
    includeSubtitles: true,
    includeVoiceover: true,
  });
  
  const handleExport = () => {
    exportProject.mutate({
      projectId: currentProjectId!,
      ...exportOptions,
    });
  };
  
  return (
    <Dialog open={exportModalOpen} onOpenChange={setExportModalOpen}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>导出视频</DialogTitle>
          <DialogDescription>
            选择导出设置,生成最终视频文件
          </DialogDescription>
        </DialogHeader>
        
        <div className="space-y-4">
          {/* 导出选项 */}
          <div className="space-y-2">
            <Label>输出格式</Label>
            <Select
              value={exportOptions.format}
              onValueChange={(v) => setExportOptions((o) => ({ ...o, format: v }))}
            >
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="mp4">MP4</SelectItem>
                <SelectItem value="webm">WebM</SelectItem>
                <SelectItem value="mov">MOV</SelectItem>
              </SelectContent>
            </Select>
          </div>
          
          <div className="space-y-2">
            <Label>质量</Label>
            <Select
              value={exportOptions.quality}
              onValueChange={(v) => setExportOptions((o) => ({ ...o, quality: v }))}
            >
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="low">低质量 (快速)</SelectItem>
                <SelectItem value="medium">中等质量</SelectItem>
                <SelectItem value="high">高质量</SelectItem>
              </SelectContent>
            </Select>
          </div>
          
          <div className="space-y-2">
            <div className="flex items-center justify-between">
              <Label>包含字幕</Label>
              <Switch
                checked={exportOptions.includeSubtitles}
                onCheckedChange={(v) => setExportOptions((o) => ({ ...o, includeSubtitles: v }))}
              />
            </div>
            <div className="flex items-center justify-between">
              <Label>包含配音</Label>
              <Switch
                checked={exportOptions.includeVoiceover}
                onCheckedChange={(v) => setExportOptions((o) => ({ ...o, includeVoiceover: v }))}
              />
            </div>
          </div>
        </div>
        
        <DialogFooter>
          <Button variant="outline" onClick={() => setExportModalOpen(false)}>
            取消
          </Button>
          <Button onClick={handleExport} disabled={exportProject.isPending}>
            {exportProject.isPending ? (
              <>
                <LoadingSpinner className="h-4 w-4 mr-2" />
                导出中...
              </>
            ) : (
              <>
                <Download className="h-4 w-4 mr-2" />
                开始导出
              </>
            )}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

10. 引导组件

10.1 OnboardingOverlay

新用户引导遮罩。

// src/components/features/onboarding/OnboardingOverlay.tsx
export function OnboardingOverlay() {
  const { showOnboarding, setShowOnboarding } = useUIStore();
  const [step, setStep] = useState(0);
  
  const steps = [
    {
      target: '[data-onboarding="storyboard"]',
      title: '分镜面板',
      description: '在这里管理你的视频分镜,支持拖拽排序',
      position: 'right',
    },
    {
      target: '[data-onboarding="preview"]',
      title: '预览区域',
      description: '预览当前分镜的效果,支持播放控制',
      position: 'bottom',
    },
    {
      target: '[data-onboarding="ai-panel"]',
      title: 'AI 生成',
      description: '使用 AI 快速生成图片和视频',
      position: 'top',
    },
    {
      target: '[data-onboarding="timeline"]',
      title: '分镜看板',
      description: '精确控制每个素材的时间和位置',
      position: 'top',
    },
  ];
  
  if (!showOnboarding) return null;
  
  const currentStep = steps[step];
  
  const handleNext = () => {
    if (step < steps.length - 1) {
      setStep(step + 1);
    } else {
      setShowOnboarding(false);
    }
  };
  
  const handleSkip = () => {
    setShowOnboarding(false);
  };
  
  return (
    <div className="fixed inset-0 z-50">
      {/* 半透明遮罩 */}
      <div className="absolute inset-0 bg-black/60" />
      
      {/* 高亮目标区域(通过 CSS 实现) */}
      
      {/* 提示卡片 */}
      <div
        className={cn(
          'absolute bg-background rounded-lg shadow-xl p-4 w-72',
          // 根据 position 定位
        )}
      >
        <h3 className="font-medium">{currentStep.title}</h3>
        <p className="text-sm text-muted-foreground mt-1">
          {currentStep.description}
        </p>
        
        <div className="flex items-center justify-between mt-4">
          <Button variant="ghost" size="sm" onClick={handleSkip}>
            跳过
          </Button>
          <div className="flex items-center gap-2">
            <span className="text-xs text-muted-foreground">
              {step + 1} / {steps.length}
            </span>
            <Button size="sm" onClick={handleNext}>
              {step < steps.length - 1 ? '下一步' : '完成'}
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}

10.2 CookieConsent

Cookie 同意提示。

// src/components/features/onboarding/CookieConsent.tsx
export function CookieConsent() {
  const [visible, setVisible] = useState(false);
  
  useEffect(() => {
    const consent = localStorage.getItem('cookie-consent');
    if (!consent) {
      setVisible(true);
    }
  }, []);
  
  const handleAccept = () => {
    localStorage.setItem('cookie-consent', 'accepted');
    setVisible(false);
  };
  
  const handleDecline = () => {
    localStorage.setItem('cookie-consent', 'declined');
    setVisible(false);
  };
  
  if (!visible) return null;
  
  return (
    <div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-background border border-border rounded-lg shadow-lg p-4 z-50">
      <p className="text-sm">
        我们使用 Cookie 来改善您的体验。继续使用即表示您同意我们的{' '}
        <a href="/privacy" className="text-primary underline">
          隐私政策
        </a>
        
      </p>
      <div className="flex gap-2 mt-3">
        <Button size="sm" variant="outline" onClick={handleDecline}>
          拒绝
        </Button>
        <Button size="sm" onClick={handleAccept}>
          接受
        </Button>
      </div>
    </div>
  );
}

11. 组件通信模式

11.1 Props 向下传递

父组件通过 Props 向子组件传递数据和回调。

// 父组件
<StoryboardItem
  storyboard={storyboard}
  isSelected={selectedId === storyboard.id}
  onSelect={handleSelect}
/>

// 子组件
function StoryboardItem({ storyboard, isSelected, onSelect }) {
  return (
    <div onClick={() => onSelect(storyboard.id)}>
      {/* ... */}
    </div>
  );
}

11.2 全局状态(Zustand)

跨组件共享状态。

// Store
const useEditorStore = create((set) => ({
  selectedId: null,
  setSelectedId: (id) => set({ selectedId: id }),
}));

// 组件 A
function StoryboardPanel() {
  const selectedId = useEditorStore((s) => s.selectedId);
  // 使用 selectedId
}

// 组件 B
function PreviewPanel() {
  const selectedId = useEditorStore((s) => s.selectedId);
  // 使用同一个 selectedId
}

11.3 事件总线(可选)

用于松散耦合的组件通信。

// src/utils/eventBus.ts
type EventCallback = (...args: any[]) => void;

class EventBus {
  private events: Map<string, EventCallback[]> = new Map();
  
  on(event: string, callback: EventCallback) {
    const callbacks = this.events.get(event) || [];
    callbacks.push(callback);
    this.events.set(event, callbacks);
    
    return () => this.off(event, callback);
  }
  
  off(event: string, callback: EventCallback) {
    const callbacks = this.events.get(event) || [];
    this.events.set(event, callbacks.filter((cb) => cb !== callback));
  }
  
  emit(event: string, ...args: any[]) {
    const callbacks = this.events.get(event) || [];
    callbacks.forEach((cb) => cb(...args));
  }
}

export const eventBus = new EventBus();

// 使用
// 发送
eventBus.emit('storyboard:created', newStoryboard);

// 监听
useEffect(() => {
  return eventBus.on('storyboard:created', (storyboard) => {
    // 处理
  });
}, []);

11.4 Context(局部共享)

用于特定子树的状态共享。

// TimelineContext.tsx
const TimelineContext = createContext<TimelineContextValue | null>(null);

export function TimelineProvider({ children }: { children: React.ReactNode }) {
  const [zoom, setZoom] = useState(1);
  const [scrollPosition, setScrollPosition] = useState(0);
  
  return (
    <TimelineContext.Provider value={{ zoom, setZoom, scrollPosition, setScrollPosition }}>
      {children}
    </TimelineContext.Provider>
  );
}

export function useTimeline() {
  const context = useContext(TimelineContext);
  if (!context) {
    throw new Error('useTimeline must be used within TimelineProvider');
  }
  return context;
}

// 使用
<TimelineProvider>
  <TimelinePanel />
</TimelineProvider>

附录

A. 组件清单

类别 组件名 状态
布局 AppLayout
布局 TopBar
布局 LeftSidebar
布局 RightSidebar
分镜 StoryboardPanel
分镜 StoryboardItem
分镜 StoryboardEditor
预览 PreviewPanel
预览 VideoPlayer
预览 PlaybackControls
分镜看板 TimelinePanel
分镜看板 TimelineTrack
分镜看板 TimelineItem
分镜看板 TimeRuler
AI AIPromptPanel
AI AIGeneratePreview
右侧栏 ResourcePanel
右侧栏 VideoPanel
右侧栏 SoundPanel
右侧栏 SubtitlePanel
右侧栏 VoicePanel
右侧栏 SettingsPanel
通用 LoadingSpinner
通用 EmptyState
通用 ErrorBoundary
通用 Tooltip
通用 ThemeToggle
通用 LanguageSelector
通用 LanguageSelect
通用 ThemeSelect
弹窗 CreateProjectModal
弹窗 ExportModal
引导 OnboardingOverlay
引导 CookieConsent

B. 变更记录

  • v1.0 (2025-01-08):初始版本,从 PRD 文档中拆分
  • v1.1 (2025-01-08):添加主题切换和语言切换组件(ThemeToggle、LanguageSelector、ThemeSelect、LanguageSelect)

文档结束

本文档定义Jointo产品的组件设计,所有前端开发应参考本文档进行组件开发。