feat(组件): 添加代码执行结果展示组件

- 新增 CodeExecutionResult 组件展示代码执行输出和图形
- 支持 Base64 图片渲染和点击放大查看
- 显示执行引擎(Pyodide/Piston)和执行时间
- 新增 PyodideLoading 组件显示 Python 环境加载进度
- 支持暗色主题
This commit is contained in:
gaoziman 2025-12-19 20:20:11 +08:00
parent 5fe0552338
commit 58d288637a

View 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;