74 KiB
Jointo(jointo)组件设计文档
项目名称:Jointo(jointo)
项目域名:https://www.jointo.ai
文档版本:v1.0
创建日期:2025-01-08
参考设计稿:https://www.figma.com/make/xgPuMR3GZzHpYh0RZJdiYK/kaidong.ai
目录
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产品的组件设计,所有前端开发应参考本文档进行组件开发。