feat(pipeline): implement Pipeline DSL system for automated workflows
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Add complete Pipeline DSL system including:
- Rust backend (zclaw-pipeline crate) with parser, executor, and state management
- Frontend components: PipelinesPanel, PipelineResultPreview, ClassroomPreviewer
- Pipeline recommender for Agent conversation integration
- 5 pipeline templates: education, marketing, legal, research, productivity
- Documentation for Pipeline DSL architecture
Pipeline DSL enables declarative workflow definitions with:
- YAML-based configuration
- Expression resolution (${inputs.topic}, ${steps.step1.output})
- LLM integration, parallel execution, file export
- Agent smart recommendations in conversations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
534
desktop/src/components/ClassroomPreviewer.tsx
Normal file
534
desktop/src/components/ClassroomPreviewer.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* ClassroomPreviewer - 课堂预览器组件
|
||||
*
|
||||
* 预览 classroom-generator Pipeline 生成的课堂内容:
|
||||
* - 幻灯片导航
|
||||
* - 大纲视图
|
||||
* - 场景切换
|
||||
* - 全屏播放模式
|
||||
* - AI 教师讲解展示
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Play,
|
||||
Pause,
|
||||
Maximize,
|
||||
Minimize,
|
||||
List,
|
||||
Grid,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Settings,
|
||||
Download,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from './ui/Toast';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface ClassroomScene {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'title' | 'content' | 'quiz' | 'summary' | 'interactive';
|
||||
content: {
|
||||
heading?: string;
|
||||
bullets?: string[];
|
||||
image?: string;
|
||||
explanation?: string;
|
||||
quiz?: {
|
||||
question: string;
|
||||
options: string[];
|
||||
answer: number;
|
||||
};
|
||||
};
|
||||
narration?: string;
|
||||
duration?: number; // seconds
|
||||
}
|
||||
|
||||
export interface ClassroomData {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
difficulty: '初级' | '中级' | '高级';
|
||||
duration: number; // minutes
|
||||
scenes: ClassroomScene[];
|
||||
outline: {
|
||||
sections: {
|
||||
title: string;
|
||||
scenes: string[];
|
||||
}[];
|
||||
};
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ClassroomPreviewerProps {
|
||||
data: ClassroomData;
|
||||
onClose?: () => void;
|
||||
onExport?: (format: 'pptx' | 'html' | 'pdf') => void;
|
||||
}
|
||||
|
||||
// === Sub-Components ===
|
||||
|
||||
interface SceneRendererProps {
|
||||
scene: ClassroomScene;
|
||||
isPlaying: boolean;
|
||||
showNarration: boolean;
|
||||
}
|
||||
|
||||
function SceneRenderer({ scene, isPlaying, showNarration }: SceneRendererProps) {
|
||||
const renderContent = () => {
|
||||
switch (scene.type) {
|
||||
case 'title':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-4">
|
||||
{scene.content.heading || scene.title}
|
||||
</h1>
|
||||
{scene.content.bullets && (
|
||||
<p className="text-xl text-white/80">
|
||||
{scene.content.bullets[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'content':
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-3xl font-bold text-white mb-6">
|
||||
{scene.content.heading || scene.title}
|
||||
</h2>
|
||||
{scene.content.bullets && (
|
||||
<ul className="space-y-4">
|
||||
{scene.content.bullets.map((bullet, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-start gap-3 text-lg text-white/90"
|
||||
>
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center text-sm font-medium">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{bullet}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{scene.content.image && (
|
||||
<div className="mt-6">
|
||||
<img
|
||||
src={scene.content.image}
|
||||
alt={scene.title}
|
||||
className="max-h-48 rounded-lg shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'quiz':
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
📝 小测验
|
||||
</h2>
|
||||
{scene.content.quiz && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xl text-white">
|
||||
{scene.content.quiz.question}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4">
|
||||
{scene.content.quiz.options.map((option, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="p-4 bg-white/10 hover:bg-white/20 rounded-lg text-left text-white transition-colors"
|
||||
>
|
||||
<span className="font-medium mr-2">
|
||||
{String.fromCharCode(65 + index)}.
|
||||
</span>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'summary':
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-3xl font-bold text-white mb-6">
|
||||
📋 总结
|
||||
</h2>
|
||||
{scene.content.bullets && (
|
||||
<ul className="space-y-3">
|
||||
{scene.content.bullets.map((bullet, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center gap-2 text-lg text-white/90"
|
||||
>
|
||||
<span className="text-green-400">✓</span>
|
||||
{bullet}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{scene.title}
|
||||
</h2>
|
||||
<p className="text-white/80 mt-4">{scene.content.explanation}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full bg-gradient-to-br from-blue-600 via-purple-600 to-indigo-700">
|
||||
{/* Scene Content */}
|
||||
<div className="h-full overflow-auto">
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* Narration Overlay */}
|
||||
{showNarration && scene.narration && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/70 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<Volume2 className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<p className="text-white/90 text-sm leading-relaxed">
|
||||
{scene.narration}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OutlinePanelProps {
|
||||
outline: ClassroomData['outline'];
|
||||
scenes: ClassroomScene[];
|
||||
currentIndex: number;
|
||||
onSelectScene: (index: number) => void;
|
||||
}
|
||||
|
||||
function OutlinePanel({
|
||||
outline,
|
||||
scenes,
|
||||
currentIndex,
|
||||
onSelectScene,
|
||||
}: OutlinePanelProps) {
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-gray-50 dark:bg-gray-800 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
课程大纲
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{outline.sections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex}>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{section.title}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{section.scenes.map((sceneId, sceneIndex) => {
|
||||
const globalIndex = scenes.findIndex(s => s.id === sceneId);
|
||||
const isActive = globalIndex === currentIndex;
|
||||
const scene = scenes.find(s => s.id === sceneId);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={sceneId}
|
||||
onClick={() => onSelectScene(globalIndex)}
|
||||
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{scene?.title || sceneId}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function ClassroomPreviewer({
|
||||
data,
|
||||
onClose,
|
||||
onExport,
|
||||
}: ClassroomPreviewerProps) {
|
||||
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [showNarration, setShowNarration] = useState(true);
|
||||
const [showOutline, setShowOutline] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'slides' | 'grid'>('slides');
|
||||
|
||||
const { showToast } = useToast();
|
||||
const currentScene = data.scenes[currentSceneIndex];
|
||||
const totalScenes = data.scenes.length;
|
||||
|
||||
// Navigation
|
||||
const goToScene = useCallback((index: number) => {
|
||||
if (index >= 0 && index < totalScenes) {
|
||||
setCurrentSceneIndex(index);
|
||||
}
|
||||
}, [totalScenes]);
|
||||
|
||||
const nextScene = useCallback(() => {
|
||||
goToScene(currentSceneIndex + 1);
|
||||
}, [currentSceneIndex, goToScene]);
|
||||
|
||||
const prevScene = useCallback(() => {
|
||||
goToScene(currentSceneIndex - 1);
|
||||
}, [currentSceneIndex, goToScene]);
|
||||
|
||||
// Auto-play
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const duration = currentScene?.duration ? currentScene.duration * 1000 : 5000;
|
||||
const timer = setTimeout(() => {
|
||||
if (currentSceneIndex < totalScenes - 1) {
|
||||
nextScene();
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
showToast('课堂播放完成', 'success');
|
||||
}
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isPlaying, currentSceneIndex, currentScene, totalScenes, nextScene, showToast]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
nextScene();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
prevScene();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (isFullscreen) {
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [nextScene, prevScene, isFullscreen]);
|
||||
|
||||
// Fullscreen toggle
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// Export handler
|
||||
const handleExport = (format: 'pptx' | 'html' | 'pdf') => {
|
||||
if (onExport) {
|
||||
onExport(format);
|
||||
} else {
|
||||
showToast(`导出 ${format.toUpperCase()} 功能开发中...`, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden ${
|
||||
isFullscreen ? 'fixed inset-0 z-50 rounded-none' : 'max-w-5xl w-full'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{data.title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{data.subject} · {data.difficulty} · {data.duration} 分钟
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleExport('pptx')}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-md hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
PPTX
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('html')}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
HTML
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex h-[500px]">
|
||||
{/* Outline Panel */}
|
||||
{showOutline && (
|
||||
<div className="w-64 border-r border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<OutlinePanel
|
||||
outline={data.outline}
|
||||
scenes={data.scenes}
|
||||
currentIndex={currentSceneIndex}
|
||||
onSelectScene={goToScene}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slide Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Scene Renderer */}
|
||||
<div className="flex-1 relative">
|
||||
{viewMode === 'slides' ? (
|
||||
<SceneRenderer
|
||||
scene={currentScene}
|
||||
isPlaying={isPlaying}
|
||||
showNarration={showNarration}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-auto p-4 bg-gray-100 dark:bg-gray-800">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{data.scenes.map((scene, index) => (
|
||||
<button
|
||||
key={scene.id}
|
||||
onClick={() => goToScene(index)}
|
||||
className={`aspect-video rounded-lg overflow-hidden border-2 transition-colors ${
|
||||
index === currentSceneIndex
|
||||
? 'border-blue-500'
|
||||
: 'border-transparent hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="h-full bg-gradient-to-br from-blue-600 to-purple-600 p-2">
|
||||
<p className="text-xs text-white font-medium truncate">
|
||||
{scene.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control Bar */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
{/* Left Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowOutline(!showOutline)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
showOutline
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
title="大纲"
|
||||
>
|
||||
<List className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === 'slides' ? 'grid' : 'slides')}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md text-gray-600 dark:text-gray-300"
|
||||
title={viewMode === 'slides' ? '网格视图' : '幻灯片视图'}
|
||||
>
|
||||
<Grid className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={prevScene}
|
||||
disabled={currentSceneIndex === 0}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md text-gray-600 dark:text-gray-300 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="p-3 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-5 h-5" />
|
||||
) : (
|
||||
<Play className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={nextScene}
|
||||
disabled={currentSceneIndex === totalScenes - 1}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md text-gray-600 dark:text-gray-300 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 min-w-[60px] text-center">
|
||||
{currentSceneIndex + 1} / {totalScenes}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowNarration(!showNarration)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
showNarration
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
title={showNarration ? '隐藏讲解' : '显示讲解'}
|
||||
>
|
||||
{showNarration ? (
|
||||
<Volume2 className="w-5 h-5" />
|
||||
) : (
|
||||
<VolumeX className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md text-gray-600 dark:text-gray-300"
|
||||
title={isFullscreen ? '退出全屏' : '全屏'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="w-5 h-5" />
|
||||
) : (
|
||||
<Maximize className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClassroomPreviewer;
|
||||
Reference in New Issue
Block a user