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