feat(组件): 添加代码执行结果展示组件
- 新增 CodeExecutionResult 组件展示代码执行输出和图形 - 支持 Base64 图片渲染和点击放大查看 - 显示执行引擎(Pyodide/Piston)和执行时间 - 新增 PyodideLoading 组件显示 Python 环境加载进度 - 支持暗色主题
This commit is contained in:
parent
5fe0552338
commit
58d288637a
206
src/components/features/CodeExecutionResult.tsx
Normal file
206
src/components/features/CodeExecutionResult.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
interface CodeExecutionResultProps {
|
||||||
|
/** 执行输出文本 */
|
||||||
|
output?: string;
|
||||||
|
/** 错误信息 */
|
||||||
|
error?: string;
|
||||||
|
/** Base64 编码的图片数组 */
|
||||||
|
images?: string[];
|
||||||
|
/** 执行语言 */
|
||||||
|
language?: string;
|
||||||
|
/** 执行引擎 */
|
||||||
|
engine?: 'pyodide' | 'piston';
|
||||||
|
/** 执行时间 (ms) */
|
||||||
|
executionTime?: number;
|
||||||
|
/** 是否执行成功 */
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代码执行结果组件
|
||||||
|
* 支持显示文本输出和图形输出
|
||||||
|
*/
|
||||||
|
export function CodeExecutionResult({
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
images,
|
||||||
|
language,
|
||||||
|
engine,
|
||||||
|
executionTime,
|
||||||
|
success = true,
|
||||||
|
}: CodeExecutionResultProps) {
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const hasOutput = output && output.trim().length > 0;
|
||||||
|
const hasError = error && error.trim().length > 0;
|
||||||
|
const hasImages = images && images.length > 0;
|
||||||
|
|
||||||
|
// 如果没有任何内容,不渲染
|
||||||
|
if (!hasOutput && !hasError && !hasImages) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="code-execution-result mt-3 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden bg-gray-50 dark:bg-gray-800">
|
||||||
|
{/* 头部信息 */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${success ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{success ? '执行成功' : '执行失败'}
|
||||||
|
</span>
|
||||||
|
{language && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{language}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{engine && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-600">
|
||||||
|
{engine === 'pyodide' ? 'Pyodide' : 'Piston'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{executionTime && (
|
||||||
|
<span>{executionTime}ms</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图片输出 */}
|
||||||
|
{hasImages && (
|
||||||
|
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{images.map((img, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="relative cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
onClick={() => setSelectedImage(img)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={`data:image/png;base64,${img}`}
|
||||||
|
alt={`Chart ${index + 1}`}
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
className="rounded-lg shadow-md max-w-full h-auto"
|
||||||
|
style={{ maxHeight: '300px', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 right-2 px-2 py-1 bg-black/50 text-white text-xs rounded">
|
||||||
|
图表 {index + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 文本输出 */}
|
||||||
|
{hasOutput && (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">输出</div>
|
||||||
|
<pre className="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap font-mono bg-white dark:bg-gray-900 p-2 rounded border border-gray-200 dark:border-gray-700 max-h-60 overflow-auto">
|
||||||
|
{output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{hasError && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/20">
|
||||||
|
<div className="text-xs text-red-600 dark:text-red-400 mb-1">错误</div>
|
||||||
|
<pre className="text-sm text-red-700 dark:text-red-300 whitespace-pre-wrap font-mono">
|
||||||
|
{error}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图片放大模态框 */}
|
||||||
|
{selectedImage && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
>
|
||||||
|
<div className="relative max-w-[90vw] max-h-[90vh]">
|
||||||
|
<Image
|
||||||
|
src={`data:image/png;base64,${selectedImage}`}
|
||||||
|
alt="Chart enlarged"
|
||||||
|
width={1200}
|
||||||
|
height={900}
|
||||||
|
className="rounded-lg shadow-2xl"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '90vh', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="absolute top-2 right-2 w-8 h-8 flex items-center justify-center bg-white/90 dark:bg-gray-800/90 rounded-full shadow-lg hover:bg-white dark:hover:bg-gray-700 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedImage(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-700 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pyodide 加载状态组件
|
||||||
|
*/
|
||||||
|
interface PyodideLoadingProps {
|
||||||
|
stage: 'loading' | 'ready' | 'error';
|
||||||
|
message: string;
|
||||||
|
progress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PyodideLoading({ stage, message, progress }: PyodideLoadingProps) {
|
||||||
|
if (stage === 'ready') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||||
|
{stage === 'loading' && (
|
||||||
|
<>
|
||||||
|
<div className="relative w-5 h-5">
|
||||||
|
<div className="absolute inset-0 border-2 border-blue-200 dark:border-blue-700 rounded-full" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 border-2 border-blue-500 rounded-full animate-spin"
|
||||||
|
style={{ borderTopColor: 'transparent', borderRightColor: 'transparent' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm text-blue-700 dark:text-blue-300">{message}</div>
|
||||||
|
{progress !== undefined && (
|
||||||
|
<div className="mt-1 h-1.5 bg-blue-200 dark:bg-blue-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{stage === 'error' && (
|
||||||
|
<>
|
||||||
|
<div className="w-5 h-5 text-red-500">
|
||||||
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-red-700 dark:text-red-300">{message}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CodeExecutionResult;
|
||||||
Loading…
Reference in New Issue
Block a user