fix(presentation): 修复 presentation 模块类型错误和语法问题
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
- 创建 types.ts 定义完整的类型系统 - 重写 DocumentRenderer.tsx 修复语法错误 - 重写 QuizRenderer.tsx 修复语法错误 - 重写 PresentationContainer.tsx 添加类型守卫 - 重写 TypeSwitcher.tsx 修复类型引用 - 更新 index.ts 移除不存在的 ChartRenderer 导出 审计结果: - 类型检查: 通过 - 单元测试: 222 passed - 构建: 成功
This commit is contained in:
148
desktop/src/components/presentation/PresentationContainer.tsx
Normal file
148
desktop/src/components/presentation/PresentationContainer.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Presentation Container
|
||||
*
|
||||
* Main container for smart presentation rendering.
|
||||
*
|
||||
* Features:
|
||||
* - Auto-detects presentation type from data structure
|
||||
* - Supports manual type switching
|
||||
* - Manages presentation state
|
||||
* - Provides export functionality
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { PresentationType, PresentationAnalysis } from './types';
|
||||
import { TypeSwitcher } from './TypeSwitcher';
|
||||
import { QuizRenderer } from './renderers/QuizRenderer';
|
||||
|
||||
const SlideshowRenderer = React.lazy(() => import('./renderers/SlideshowRenderer').then(m => ({ default: m.SlideshowRenderer })));
|
||||
const DocumentRenderer = React.lazy(() => import('./renderers/DocumentRenderer').then(m => ({ default: m.DocumentRenderer })));
|
||||
|
||||
interface PresentationContainerProps {
|
||||
/** Pipeline output data */
|
||||
data: unknown;
|
||||
/** Pipeline ID (reserved for future use) */
|
||||
pipelineId?: string;
|
||||
/** Supported presentation types (from pipeline config) */
|
||||
supportedTypes?: PresentationType[];
|
||||
/** Default presentation type */
|
||||
defaultType?: PresentationType;
|
||||
/** Allow user to switch types */
|
||||
allowSwitch?: boolean;
|
||||
/** Called when export is triggered (reserved for future use) */
|
||||
onExport?: (format: string) => void;
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PresentationContainer({
|
||||
data,
|
||||
supportedTypes,
|
||||
defaultType,
|
||||
allowSwitch = true,
|
||||
className = '',
|
||||
}: PresentationContainerProps) {
|
||||
const [analysis, setAnalysis] = useState<PresentationAnalysis | null>(null);
|
||||
const [currentType, setCurrentType] = useState<PresentationType | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(true);
|
||||
|
||||
useMemo(() => {
|
||||
const runAnalysis = async () => {
|
||||
setIsAnalyzing(true);
|
||||
try {
|
||||
const result = await invoke<PresentationAnalysis>('analyze_presentation', { data });
|
||||
setAnalysis(result);
|
||||
|
||||
if (defaultType) {
|
||||
setCurrentType(defaultType);
|
||||
} else if (result) {
|
||||
setCurrentType(result.recommendedType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to analyze presentation:', error);
|
||||
setCurrentType('document');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
runAnalysis();
|
||||
}, [data, defaultType]);
|
||||
|
||||
const handleTypeChange = useCallback((type: PresentationType) => {
|
||||
setCurrentType(type);
|
||||
}, []);
|
||||
|
||||
const availableTypes = useMemo(() => {
|
||||
if (supportedTypes && supportedTypes.length > 0) {
|
||||
return supportedTypes.filter((t): t is PresentationType => t !== 'auto');
|
||||
}
|
||||
return (['quiz', 'slideshow', 'document', 'whiteboard'] as PresentationType[]);
|
||||
}, [supportedTypes]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isAnalyzing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
<p className="ml-3 text-gray-500">分析数据中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentType) {
|
||||
case 'quiz':
|
||||
return <QuizRenderer data={data as Parameters<typeof QuizRenderer>[0]['data']} />;
|
||||
|
||||
case 'slideshow':
|
||||
return (
|
||||
<React.Suspense fallback={<div className="h-64 animate-pulse bg-gray-100" />}>
|
||||
<SlideshowRenderer data={data as Parameters<typeof SlideshowRenderer>[0]['data']} />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
case 'document':
|
||||
return (
|
||||
<React.Suspense fallback={<div className="h-64 animate-pulse bg-gray-100" />}>
|
||||
<DocumentRenderer data={data as Parameters<typeof DocumentRenderer>[0]['data']} />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
case 'whiteboard':
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-gray-50">
|
||||
<p className="text-gray-500">白板渲染器开发中...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-gray-50">
|
||||
<p className="text-gray-500">选择展示类型</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
{allowSwitch && (
|
||||
<div className="border-b border-gray-200 bg-gray-50 p-3">
|
||||
<TypeSwitcher
|
||||
availableTypes={availableTypes}
|
||||
currentType={currentType || 'document'}
|
||||
analysis={analysis || undefined}
|
||||
onTypeChange={handleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PresentationContainer;
|
||||
113
desktop/src/components/presentation/TypeSwitcher.tsx
Normal file
113
desktop/src/components/presentation/TypeSwitcher.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Type Switcher Component
|
||||
*
|
||||
* Allows users to switch between presentation types.
|
||||
*/
|
||||
|
||||
import {
|
||||
BarChart3,
|
||||
FileText,
|
||||
Presentation,
|
||||
CheckCircle,
|
||||
PenTool,
|
||||
} from 'lucide-react';
|
||||
import type { PresentationType, PresentationAnalysis } from './types';
|
||||
|
||||
interface TypeSwitcherProps {
|
||||
/** Available types */
|
||||
availableTypes: PresentationType[];
|
||||
/** Current type */
|
||||
currentType: PresentationType;
|
||||
/** Analysis result (optional) */
|
||||
analysis?: PresentationAnalysis;
|
||||
/** Called when type is changed */
|
||||
onTypeChange: (type: PresentationType) => void;
|
||||
/** Disabled types */
|
||||
disabledTypes?: PresentationType[];
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const typeConfig: Record<PresentationType, { icon: React.ReactNode; label: string; description: string }> = {
|
||||
chart: {
|
||||
icon: <BarChart3 className="w-4 h-4" />,
|
||||
label: '图表',
|
||||
description: '数据可视化',
|
||||
},
|
||||
slideshow: {
|
||||
icon: <Presentation className="w-4 h-4" />,
|
||||
label: '幻灯片',
|
||||
description: '演示文稿风格',
|
||||
},
|
||||
quiz: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
label: '测验',
|
||||
description: '互动问答',
|
||||
},
|
||||
document: {
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: '文档',
|
||||
description: 'Markdown 文档',
|
||||
},
|
||||
whiteboard: {
|
||||
icon: <PenTool className="w-4 h-4" />,
|
||||
label: '白板',
|
||||
description: '交互式画布',
|
||||
},
|
||||
auto: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
label: '自动',
|
||||
description: '自动检测类型',
|
||||
},
|
||||
};
|
||||
|
||||
export function TypeSwitcher({
|
||||
availableTypes,
|
||||
currentType,
|
||||
analysis,
|
||||
onTypeChange,
|
||||
disabledTypes = [],
|
||||
className = '',
|
||||
}: TypeSwitcherProps) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{availableTypes.map((type) => {
|
||||
const config = typeConfig[type];
|
||||
if (!config) return null;
|
||||
|
||||
const isActive = currentType === type;
|
||||
const isDisabled = disabledTypes.includes(type);
|
||||
const recommendation = analysis?.recommendedType === type;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => onTypeChange(type)}
|
||||
disabled={isDisabled}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-lg transition-all
|
||||
${isActive
|
||||
? 'bg-blue-100 text-blue-700 border-2 border-blue-500'
|
||||
: 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-100'
|
||||
}
|
||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
title={config.description}
|
||||
>
|
||||
<span className="text-lg">{config.icon}</span>
|
||||
<span className="text-sm font-medium">{config.label}</span>
|
||||
{recommendation && (
|
||||
<span className="text-xs text-blue-500">推荐</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{analysis && (
|
||||
<div className="ml-4 text-xs text-gray-500">
|
||||
<p>置信度: {(analysis.confidence * 100).toFixed(0)}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
desktop/src/components/presentation/index.ts
Normal file
33
desktop/src/components/presentation/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Presentation Components
|
||||
*
|
||||
* Smart presentation layer for Pipeline output rendering.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { PresentationContainer } from '@/components/presentation';
|
||||
*
|
||||
* <PresentationContainer
|
||||
* data={pipelineOutput}
|
||||
* pipelineId="course-generator"
|
||||
* supportedTypes={['slideshow', 'quiz', 'document']}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { PresentationContainer } from './PresentationContainer';
|
||||
export { TypeSwitcher } from './TypeSwitcher';
|
||||
export { QuizRenderer } from './renderers/QuizRenderer';
|
||||
export { DocumentRenderer } from './renderers/DocumentRenderer';
|
||||
export { SlideshowRenderer } from './renderers/SlideshowRenderer';
|
||||
export type {
|
||||
PresentationType,
|
||||
PresentationAnalysis,
|
||||
ChartData,
|
||||
QuizData,
|
||||
QuizQuestion,
|
||||
QuestionType,
|
||||
SlideshowData,
|
||||
DocumentData,
|
||||
WhiteboardData,
|
||||
} from './types';
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Document Renderer
|
||||
*
|
||||
* Renders content as a scrollable document with Markdown support.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Download, ExternalLink, Copy } from 'lucide-react';
|
||||
import type { DocumentData } from '../types';
|
||||
|
||||
interface DocumentRendererProps {
|
||||
/** Document data */
|
||||
data: DocumentData;
|
||||
/** Enable markdown rendering */
|
||||
enableMarkdown?: boolean;
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DocumentRenderer({
|
||||
data,
|
||||
enableMarkdown = true,
|
||||
className = '',
|
||||
}: DocumentRendererProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
const textToCopy = typeof data === 'string' ? data : (data.content || JSON.stringify(data, null, 2));
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (data.downloadUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = data.downloadUrl;
|
||||
link.download = data.downloadFilename || 'document.md';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMarkdown = (content: string): React.ReactNode => {
|
||||
const lines = content.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (trimmed.startsWith('# ')) {
|
||||
elements.push(
|
||||
<h1 key={trimmed} className="text-2xl font-bold mb-4">
|
||||
{trimmed.substring(2)}
|
||||
</h1>
|
||||
);
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
elements.push(
|
||||
<h2 key={trimmed} className="text-xl font-semibold mb-3">
|
||||
{trimmed.substring(3)}
|
||||
</h2>
|
||||
);
|
||||
} else if (trimmed.startsWith('### ')) {
|
||||
elements.push(
|
||||
<h3 key={trimmed} className="text-lg font-medium mb-2">
|
||||
{trimmed.substring(4)}
|
||||
</h3>
|
||||
);
|
||||
} else if (trimmed.startsWith('- ')) {
|
||||
elements.push(
|
||||
<li key={trimmed} className="ml-4 list-disc">
|
||||
{trimmed.substring(2)}
|
||||
</li>
|
||||
);
|
||||
} else if (trimmed.startsWith('```')) {
|
||||
elements.push(
|
||||
<pre key={trimmed} className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm my-2">
|
||||
<code>{trimmed.substring(3, trimmed.length - 3)}</code>
|
||||
</pre>
|
||||
);
|
||||
} else {
|
||||
elements.push(
|
||||
<p key={trimmed} className="mb-2">{trimmed}</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className={className}>{elements}</div>;
|
||||
};
|
||||
|
||||
if (!enableMarkdown) {
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
<pre className="whitespace-pre-wrap text-sm">{JSON.stringify(data, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
{data.title && (
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<h1 className="text-xl font-semibold text-gray-900">{data.title}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 flex items-center gap-1"
|
||||
title="复制"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
{copied && <span className="text-xs text-green-500">已复制</span>}
|
||||
</button>
|
||||
{data.downloadUrl && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{data.url && (
|
||||
<button
|
||||
onClick={() => window.open(data.url, '_blank')}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
title="在新窗口打开"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{typeof data === 'string'
|
||||
? renderMarkdown(data)
|
||||
: renderMarkdown(data.content || JSON.stringify(data))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentRenderer;
|
||||
354
desktop/src/components/presentation/renderers/QuizRenderer.tsx
Normal file
354
desktop/src/components/presentation/renderers/QuizRenderer.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Quiz Renderer
|
||||
*
|
||||
* Renders interactive quizzes with support for:
|
||||
* - Single choice
|
||||
* - Multiple choice
|
||||
* - True/False
|
||||
* - Fill in blank
|
||||
* - Short answer
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Award,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import type { QuizData, QuizQuestion } from '../types';
|
||||
|
||||
interface QuizRendererProps {
|
||||
data: QuizData;
|
||||
onComplete?: (score: number, correct: number, total: number) => void;
|
||||
onAnswer?: (questionId: string, answer: unknown) => void;
|
||||
showAnswers?: boolean;
|
||||
allowRetry?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface UserAnswer {
|
||||
questionId: string;
|
||||
answer: unknown;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
export function QuizRenderer({
|
||||
data,
|
||||
onComplete,
|
||||
onAnswer,
|
||||
showAnswers = true,
|
||||
allowRetry = true,
|
||||
className = '',
|
||||
}: QuizRendererProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, UserAnswer>>({});
|
||||
const [showResults, setShowResults] = useState(showAnswers ?? false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [correctCount, setCorrectCount] = useState(0);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
|
||||
const checkAnswer = (answer: unknown, question: QuizQuestion): boolean => {
|
||||
if (question.questionType === 'singleChoice' || question.questionType === 'trueFalse') {
|
||||
return answer === question.correctAnswer;
|
||||
}
|
||||
if (question.questionType === 'multipleChoice') {
|
||||
const answerArr = answer as string[];
|
||||
const correctArr = question.correctAnswer as string[];
|
||||
if (!Array.isArray(answerArr) || !Array.isArray(correctArr)) return false;
|
||||
return JSON.stringify([...answerArr].sort()) === JSON.stringify([...correctArr].sort());
|
||||
}
|
||||
if (question.questionType === 'fillBlank' || question.questionType === 'shortAnswer') {
|
||||
return String(answer).toLowerCase().trim() === String(question.correctAnswer).toLowerCase().trim();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useMemo(() => {
|
||||
if (!data.questions || data.questions.length === 0) return;
|
||||
|
||||
const total = data.questions.length;
|
||||
const correct = data.questions.filter((q: QuizQuestion) => {
|
||||
const userAnswer = answers[q.id];
|
||||
return userAnswer?.isCorrect ?? false;
|
||||
}).length;
|
||||
|
||||
setScore(Math.round((correct / total) * 100));
|
||||
setCorrectCount(correct);
|
||||
}, [answers, data.questions]);
|
||||
|
||||
const handleSelectAnswer = (questionId: string, answer: unknown) => {
|
||||
const question = data.questions.find((q: QuizQuestion) => q.id === questionId);
|
||||
if (!question) return;
|
||||
|
||||
const isCorrect = checkAnswer(answer, question);
|
||||
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
[questionId]: { questionId, answer, isCorrect },
|
||||
}));
|
||||
|
||||
if (onAnswer) {
|
||||
onAnswer(questionId, answer);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentIndex < data.questions.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
setShowResults(true);
|
||||
setIsCompleted(true);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(score, correctCount, data.questions.length);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setAnswers({});
|
||||
setShowResults(false);
|
||||
setIsCompleted(false);
|
||||
setScore(0);
|
||||
setCorrectCount(0);
|
||||
setCurrentIndex(0);
|
||||
};
|
||||
|
||||
const question = data.questions[currentIndex];
|
||||
if (!question) return null;
|
||||
|
||||
const progressPercent = ((currentIndex + 1) / data.questions.length) * 100;
|
||||
|
||||
const renderQuestionOptions = () => {
|
||||
const qType = question.questionType;
|
||||
|
||||
if (qType === 'singleChoice' || qType === 'trueFalse') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{question.options.map((option) => {
|
||||
const isSelected = answers[question.id]?.answer === option.id;
|
||||
const showCorrect = showResults && question.correctAnswer === option.id;
|
||||
const showIncorrect = showResults && isSelected && !showCorrect;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => !showResults && handleSelectAnswer(question.id, option.id)}
|
||||
disabled={showResults}
|
||||
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
|
||||
isSelected && !showResults ? 'border-blue-500 bg-blue-50' : ''
|
||||
} ${showCorrect ? 'border-green-500 bg-green-50' : ''} ${
|
||||
showIncorrect ? 'border-red-500 bg-red-50' : ''
|
||||
} ${!isSelected && !showCorrect ? 'border-gray-200' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex-1">{option.text}</span>
|
||||
{showCorrect && <CheckCircle className="w-5 h-5 text-green-500" />}
|
||||
{showIncorrect && <XCircle className="w-5 h-5 text-red-500" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (qType === 'multipleChoice') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{question.options.map((option) => {
|
||||
const selectedAnswers = (answers[question.id]?.answer as string[]) || [];
|
||||
const isSelected = selectedAnswers.includes(option.id);
|
||||
const showCorrect = showResults && (question.correctAnswer as string[]).includes(option.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
if (showResults) return;
|
||||
const newAnswers = isSelected
|
||||
? selectedAnswers.filter(a => a !== option.id)
|
||||
: [...selectedAnswers, option.id];
|
||||
handleSelectAnswer(question.id, newAnswers);
|
||||
}}
|
||||
disabled={showResults}
|
||||
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
|
||||
isSelected && !showResults ? 'border-blue-500 bg-blue-50' : ''
|
||||
} ${showCorrect ? 'border-green-500 bg-green-50' : ''} ${
|
||||
!isSelected && !showCorrect && showResults ? 'border-gray-200 opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
className="w-4 h-4 rounded"
|
||||
disabled={showResults}
|
||||
/>
|
||||
<span className="flex-1">{option.text}</span>
|
||||
{showCorrect && <CheckCircle className="w-5 h-5 text-green-500" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (qType === 'fillBlank') {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入答案..."
|
||||
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(e) => handleSelectAnswer(question.id, e.target.value)}
|
||||
disabled={showResults}
|
||||
/>
|
||||
{showResults && (
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
正确答案: {question.correctAnswer}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (qType === 'shortAnswer') {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<textarea
|
||||
placeholder="请输入你的答案..."
|
||||
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 min-h-32"
|
||||
onChange={(e) => handleSelectAnswer(question.id, e.target.value)}
|
||||
disabled={showResults}
|
||||
/>
|
||||
{showResults && (
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
参考答案: {question.correctAnswer}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
<div className="bg-white border-b border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{data.title && (
|
||||
<h2 className="text-lg font-semibold text-gray-900">{data.title}</h2>
|
||||
)}
|
||||
{data.description && (
|
||||
<p className="text-sm text-gray-500">{data.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="h-2 bg-blue-500 transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{currentIndex + 1} / {data.questions.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 flex-1">
|
||||
<div className="mb-4">
|
||||
<p className="text-lg font-medium text-gray-900">{question.text}</p>
|
||||
{question.hint && !showResults && (
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
💡 {question.hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderQuestionOptions()}
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={currentIndex === 0}
|
||||
className="p-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
上一题
|
||||
</button>
|
||||
|
||||
<span className="text-sm text-gray-500">
|
||||
{currentIndex + 1} / {data.questions.length}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex === data.questions.length - 1 || showResults}
|
||||
className="p-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
||||
>
|
||||
下一题
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showResults ? (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="w-full py-3 bg-blue-500 text-white rounded-lg font-medium hover:bg-blue-600 transition-colors mt-4"
|
||||
>
|
||||
提交答案
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-center gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{score}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{correctCount} / {data.questions.length} 正确
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{allowRetry && (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="w-full py-2 text-blue-600 hover:bg-blue-50 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
重新测试
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCompleted && (
|
||||
<div className="bg-green-50 p-4 text-center">
|
||||
<Award className="w-8 h-8 text-green-500 mx-auto mb-2" />
|
||||
<p className="text-lg font-semibold text-green-700">
|
||||
测验完成! 🎉
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
你的得分: {score}% ({correctCount}/{data.questions.length} 正确)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Slideshow Renderer
|
||||
*
|
||||
* Renders presentation as a slideshow with slide navigation.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Play,
|
||||
Pause,
|
||||
} from 'lucide-react';
|
||||
import type { SlideshowData } from '../types';
|
||||
|
||||
interface SlideshowRendererProps {
|
||||
data: SlideshowData;
|
||||
/** Auto-play interval in seconds (0 = disabled) */
|
||||
autoPlayInterval?: number;
|
||||
/** Show progress indicator */
|
||||
showProgress?: boolean;
|
||||
/** Show speaker notes */
|
||||
showNotes?: boolean;
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SlideshowRenderer({
|
||||
data,
|
||||
autoPlayInterval = 0,
|
||||
showProgress = true,
|
||||
showNotes = true,
|
||||
className = '',
|
||||
}: SlideshowRendererProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const slides = data.slides || [];
|
||||
const totalSlides = slides.length;
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||
handleNext();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
handlePrev();
|
||||
} else if (e.key === 'f') {
|
||||
toggleFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Auto-play
|
||||
useEffect(() => {
|
||||
if (isPlaying && autoPlayInterval > 0) {
|
||||
const timer = setInterval(handleNext, autoPlayInterval * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [isPlaying, autoPlayInterval]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % totalSlides);
|
||||
}, [totalSlides]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev - 1 + totalSlides) % totalSlides);
|
||||
}, [totalSlides]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const currentSlide = slides[currentIndex];
|
||||
|
||||
if (!currentSlide) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center h-64 bg-gray-50 ${className}`}>
|
||||
<p className="text-gray-500">没有幻灯片数据</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''} ${className}`}>
|
||||
{/* Slide Content */}
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="max-w-4xl w-full">
|
||||
{/* Title */}
|
||||
{currentSlide.title && (
|
||||
<h2 className="text-3xl font-bold text-center mb-6">
|
||||
{currentSlide.title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Content rendering would go here */}
|
||||
<div className="text-gray-700">
|
||||
{/* This is simplified - real implementation would render based on content type */}
|
||||
{typeof currentSlide.content === 'string' ? (
|
||||
<p>{currentSlide.content}</p>
|
||||
) : (
|
||||
<div>Complex content rendering</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={totalSlides <= 1}
|
||||
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
disabled={autoPlayInterval === 0}
|
||||
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={totalSlides <= 1}
|
||||
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{showProgress && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{currentIndex + 1} / {totalSlides}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 hover:bg-gray-200 rounded"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-5 h-5" />
|
||||
) : (
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Speaker Notes */}
|
||||
{showNotes && currentSlide.notes && (
|
||||
<div className="p-4 bg-yellow-50 border-t text-sm text-gray-600">
|
||||
📝 {currentSlide.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlideshowRenderer;
|
||||
145
desktop/src/components/presentation/types.ts
Normal file
145
desktop/src/components/presentation/types.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Presentation Types
|
||||
*
|
||||
* Type definitions for the presentation layer.
|
||||
* Used by renderers and container components.
|
||||
*/
|
||||
|
||||
export type PresentationType =
|
||||
| 'chart'
|
||||
| 'quiz'
|
||||
| 'slideshow'
|
||||
| 'document'
|
||||
| 'whiteboard'
|
||||
| 'auto';
|
||||
|
||||
export interface PresentationAnalysis {
|
||||
recommendedType: PresentationType;
|
||||
confidence: number;
|
||||
detectedFeatures: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
type: 'line' | 'bar' | 'pie' | 'scatter' | 'area';
|
||||
title?: string;
|
||||
labels?: string[];
|
||||
datasets: ChartDataset[];
|
||||
options?: ChartOptions;
|
||||
}
|
||||
|
||||
export interface ChartDataset {
|
||||
label: string;
|
||||
data: number[];
|
||||
backgroundColor?: string | string[];
|
||||
borderColor?: string | string[];
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
export interface ChartOptions {
|
||||
responsive?: boolean;
|
||||
maintainAspectRatio?: boolean;
|
||||
plugins?: {
|
||||
legend?: {
|
||||
display?: boolean;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
};
|
||||
title?: {
|
||||
display?: boolean;
|
||||
text?: string;
|
||||
};
|
||||
};
|
||||
scales?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QuizData {
|
||||
title?: string;
|
||||
description?: string;
|
||||
questions: QuizQuestion[];
|
||||
timeLimit?: number;
|
||||
passingScore?: number;
|
||||
}
|
||||
|
||||
export interface QuizQuestion {
|
||||
id: string;
|
||||
text: string;
|
||||
questionType: QuestionType;
|
||||
options: QuizOption[];
|
||||
correctAnswer: string | string[];
|
||||
hint?: string;
|
||||
explanation?: string;
|
||||
points?: number;
|
||||
}
|
||||
|
||||
export type QuestionType =
|
||||
| 'singleChoice'
|
||||
| 'multipleChoice'
|
||||
| 'trueFalse'
|
||||
| 'fillBlank'
|
||||
| 'shortAnswer';
|
||||
|
||||
export interface QuizOption {
|
||||
id: string;
|
||||
text: string;
|
||||
isCorrect?: boolean;
|
||||
}
|
||||
|
||||
export interface SlideshowData {
|
||||
title?: string;
|
||||
slides: Slide[];
|
||||
theme?: SlideshowTheme;
|
||||
autoPlay?: boolean;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export interface Slide {
|
||||
id: string;
|
||||
type: 'title' | 'content' | 'image' | 'code' | 'twoColumn';
|
||||
title?: string;
|
||||
content?: string;
|
||||
image?: string;
|
||||
code?: string;
|
||||
language?: string;
|
||||
leftContent?: string;
|
||||
rightContent?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface SlideshowTheme {
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
accentColor?: string;
|
||||
fontFamily?: string;
|
||||
}
|
||||
|
||||
export interface DocumentData {
|
||||
title?: string;
|
||||
content?: string;
|
||||
format?: 'markdown' | 'html' | 'plain';
|
||||
downloadUrl?: string;
|
||||
downloadFilename?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface WhiteboardData {
|
||||
title?: string;
|
||||
elements: WhiteboardElement[];
|
||||
background?: string;
|
||||
gridSize?: number;
|
||||
}
|
||||
|
||||
export interface WhiteboardElement {
|
||||
id: string;
|
||||
type: 'rect' | 'circle' | 'line' | 'text' | 'image' | 'path';
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
src?: string;
|
||||
points?: number[];
|
||||
}
|
||||
Reference in New Issue
Block a user