ChatArea retry button uses setInput instead of direct sendToGateway, fix bootstrap spinner stuck for non-logged-in users, remove dead CSS (aurora-title/sidebar-open/quick-action-chips), add ai components (ReasoningBlock/StreamingText/ChatMode/ModelSelector/TaskProgress), add ClassroomPlayer + ResizableChatLayout + artifact panel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
705 lines
23 KiB
TypeScript
705 lines
23 KiB
TypeScript
/**
|
||
* ClassroomPreviewer - 课堂预览器组件
|
||
*
|
||
* 预览 classroom-generator Pipeline 生成的课堂内容:
|
||
* - 幻灯片导航
|
||
* - 大纲视图
|
||
* - 场景切换
|
||
* - 全屏播放模式
|
||
* - AI 教师讲解展示
|
||
*/
|
||
|
||
import { useState, useCallback, useEffect } from 'react';
|
||
import {
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Play,
|
||
Pause,
|
||
Maximize,
|
||
Minimize,
|
||
List,
|
||
Grid,
|
||
Volume2,
|
||
VolumeX,
|
||
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;
|
||
onOpenFullPlayer?: () => void;
|
||
}
|
||
|
||
// === Sub-Components ===
|
||
|
||
interface SceneRendererProps {
|
||
scene: ClassroomScene;
|
||
isPlaying: boolean;
|
||
showNarration: boolean;
|
||
}
|
||
|
||
function SceneRenderer({ scene, 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) => {
|
||
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,
|
||
onExport,
|
||
onOpenFullPlayer,
|
||
}: 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 { toast } = 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);
|
||
toast('课堂播放完成', 'success');
|
||
}
|
||
}, duration);
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [isPlaying, currentSceneIndex, currentScene, totalScenes, nextScene, toast]);
|
||
|
||
// 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);
|
||
return;
|
||
}
|
||
|
||
// Default export implementation
|
||
toast(`正在导出 ${format.toUpperCase()} 格式...`, 'info');
|
||
|
||
setTimeout(() => {
|
||
try {
|
||
if (format === 'html') {
|
||
const htmlContent = generateClassroomHTML(data);
|
||
downloadFile(htmlContent, `${data.title}.html`, 'text/html');
|
||
toast('HTML 导出成功', 'success');
|
||
} else if (format === 'pptx') {
|
||
// Export as JSON for conversion
|
||
const pptxData = JSON.stringify(data, null, 2);
|
||
downloadFile(pptxData, `${data.title}.slides.json`, 'application/json');
|
||
toast('幻灯片数据已导出(JSON格式)', 'success');
|
||
} else if (format === 'pdf') {
|
||
const htmlContent = generatePrintableHTML(data);
|
||
const printWindow = window.open('', '_blank');
|
||
if (printWindow) {
|
||
printWindow.document.write(htmlContent);
|
||
printWindow.document.close();
|
||
printWindow.print();
|
||
toast('已打开打印预览', 'success');
|
||
}
|
||
}
|
||
} catch (err) {
|
||
const errorMsg = err instanceof Error ? err.message : '导出失败';
|
||
toast(`导出失败: ${errorMsg}`, 'error');
|
||
}
|
||
}, 300);
|
||
};
|
||
|
||
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">
|
||
{onOpenFullPlayer && (
|
||
<button
|
||
onClick={onOpenFullPlayer}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 rounded-md hover:bg-indigo-200 dark:hover:bg-indigo-900/50 transition-colors"
|
||
>
|
||
<Play className="w-4 h-4" />
|
||
完整播放器
|
||
</button>
|
||
)}
|
||
<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;
|
||
|
||
// === Helper Functions ===
|
||
|
||
function downloadFile(content: string, filename: string, mimeType: string) {
|
||
const blob = new Blob([content], { type: mimeType });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function generateClassroomHTML(data: ClassroomData): string {
|
||
const scenesHTML = data.scenes.map((scene, index) => `
|
||
<section class="slide" data-index="${index}">
|
||
<div class="slide-content ${scene.type}">
|
||
<h2>${scene.content.heading || scene.title}</h2>
|
||
${scene.content.bullets ? `
|
||
<ul>
|
||
${scene.content.bullets.map((b: string) => `<li>${b}</li>`).join('')}
|
||
</ul>
|
||
` : ''}
|
||
${scene.narration ? `<p class="narration">${scene.narration}</p>` : ''}
|
||
</div>
|
||
</section>
|
||
`).join('');
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${data.title}</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #6366f1);
|
||
min-height: 100vh;
|
||
color: white;
|
||
}
|
||
.presentation { max-width: 1200px; margin: 0 auto; padding: 2rem; }
|
||
header { text-align: center; padding: 2rem 0; border-bottom: 1px solid rgba(255,255,255,0.2); margin-bottom: 2rem; }
|
||
h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
||
.meta { opacity: 0.8; font-size: 0.9rem; }
|
||
.slide {
|
||
background: rgba(255,255,255,0.1);
|
||
border-radius: 1rem;
|
||
padding: 2rem;
|
||
margin-bottom: 1.5rem;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
.slide h2 { font-size: 1.8rem; margin-bottom: 1rem; }
|
||
.slide ul { list-style: none; padding-left: 1rem; }
|
||
.slide li { margin-bottom: 0.75rem; font-size: 1.1rem; }
|
||
.slide li::before { content: '•'; color: #60a5fa; margin-right: 0.5rem; }
|
||
.narration {
|
||
background: rgba(0,0,0,0.3);
|
||
padding: 1rem;
|
||
border-radius: 0.5rem;
|
||
margin-top: 1rem;
|
||
font-style: italic;
|
||
opacity: 0.9;
|
||
}
|
||
.title .slide-content { text-align: center; min-height: 200px; display: flex; flex-direction: column; justify-content: center; }
|
||
.quiz { background: rgba(34, 197, 94, 0.2); }
|
||
.summary { background: rgba(168, 85, 247, 0.2); }
|
||
footer { text-align: center; padding: 2rem 0; border-top: 1px solid rgba(255,255,255,0.2); margin-top: 2rem; opacity: 0.6; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="presentation">
|
||
<header>
|
||
<h1>${data.title}</h1>
|
||
<p class="meta">${data.subject} · ${data.difficulty} · ${data.duration} 分钟</p>
|
||
</header>
|
||
<main>${scenesHTML}</main>
|
||
<footer><p>由 ZCLAW 课堂生成器创建</p></footer>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
function generatePrintableHTML(data: ClassroomData): string {
|
||
const scenesHTML = data.scenes.map((scene, index) => `
|
||
<div class="page" style="page-break-after: always;">
|
||
<div class="slide-print">
|
||
<h1 style="font-size: 24pt; margin-bottom: 20pt;">${scene.content.heading || scene.title}</h1>
|
||
${scene.content.bullets ? `
|
||
<ul style="font-size: 14pt; line-height: 1.8;">
|
||
${scene.content.bullets.map((b: string) => `<li>${b}</li>`).join('')}
|
||
</ul>
|
||
` : ''}
|
||
${scene.narration ? `<p style="background: #f0f0f0; padding: 10pt; margin-top: 20pt; font-style: italic;">${scene.narration}</p>` : ''}
|
||
<p style="position: absolute; bottom: 20pt; right: 20pt; color: #999; font-size: 10pt;">${index + 1} / ${data.scenes.length}</p>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>${data.title} - 打印版</title>
|
||
<style>
|
||
@media print {
|
||
body { margin: 0; }
|
||
.page { page-break-after: always; }
|
||
}
|
||
body { font-family: 'Microsoft YaHei', sans-serif; }
|
||
.slide-print {
|
||
width: 100%;
|
||
height: 100vh;
|
||
padding: 40pt;
|
||
position: relative;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="document">
|
||
<header style="text-align: center; margin-bottom: 30pt;">
|
||
<h1 style="font-size: 32pt;">${data.title}</h1>
|
||
<p style="color: #666;">${data.subject} · ${data.difficulty} · ${data.duration} 分钟</p>
|
||
</header>
|
||
${scenesHTML}
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
}
|