feat(代码块): 集成代码运行功能
- 添加运行/停止按钮交互 - 显示 Pyodide 加载进度(Python) - 集成执行结果展示组件 - 添加更多语言的 Prism 语法高亮支持 - 优化高亮失败的降级处理
This commit is contained in:
parent
70902a2541
commit
269fc798aa
@ -1,10 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { Copy, Check, Eye, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Copy, Check, Eye, ChevronDown, ChevronUp, Play, Square, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Prism from 'prismjs';
|
||||
import { HtmlPreviewModal } from '@/components/ui/HtmlPreviewModal';
|
||||
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
|
||||
import {
|
||||
executeCode,
|
||||
stopExecution,
|
||||
isRunnableLanguage,
|
||||
isCodeExecutable,
|
||||
getLanguageConfig,
|
||||
setPyodideLoadingCallbacks,
|
||||
type ExecutionResult,
|
||||
} from '@/lib/code-runner';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
@ -24,6 +34,10 @@ import 'prismjs/components/prism-markdown';
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/components/prism-scss';
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-kotlin';
|
||||
import 'prismjs/components/prism-swift';
|
||||
import 'prismjs/components/prism-ruby';
|
||||
import 'prismjs/components/prism-php';
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
@ -62,6 +76,15 @@ export function CodeBlock({
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// 代码执行相关状态
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null);
|
||||
const [pyodideStatus, setPyodideStatus] = useState<{
|
||||
stage: 'loading' | 'ready' | 'error';
|
||||
message: string;
|
||||
progress?: number;
|
||||
} | null>(null);
|
||||
|
||||
// 规范化语言名称
|
||||
const normalizedLanguage = languageAliases[language.toLowerCase()] || language.toLowerCase();
|
||||
|
||||
@ -69,6 +92,10 @@ export function CodeBlock({
|
||||
const isHtmlPreviewable = ['html', 'htm', 'markup'].includes(normalizedLanguage) ||
|
||||
['html', 'htm'].includes(language.toLowerCase());
|
||||
|
||||
// 判断是否可执行:语言支持 + 代码满足执行条件
|
||||
const canRun = isRunnableLanguage(language) && isCodeExecutable(code, language);
|
||||
const languageConfig = canRun ? getLanguageConfig(language) : null;
|
||||
|
||||
const lines = code.split('\n');
|
||||
const totalLines = lines.length;
|
||||
const shouldCollapse = totalLines > maxCollapsedLines;
|
||||
@ -86,11 +113,20 @@ export function CodeBlock({
|
||||
|
||||
// 使用 useMemo 缓存高亮后的 HTML,避免频繁重新高亮
|
||||
const highlightedCode = useMemo(() => {
|
||||
try {
|
||||
const grammar = Prism.languages[normalizedLanguage];
|
||||
if (grammar) {
|
||||
return Prism.highlight(displayCode, grammar, normalizedLanguage);
|
||||
}
|
||||
return displayCode;
|
||||
} catch (error) {
|
||||
// 某些语言的 grammar 可能不完整,fallback 到纯文本
|
||||
console.warn(`Prism highlight failed for ${normalizedLanguage}:`, error);
|
||||
}
|
||||
// 对纯文本进行 HTML 转义
|
||||
return displayCode
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}, [displayCode, normalizedLanguage]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
@ -108,6 +144,67 @@ export function CodeBlock({
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
// 运行代码
|
||||
const handleRun = useCallback(async () => {
|
||||
if (isRunning || !canRun) return;
|
||||
|
||||
setIsRunning(true);
|
||||
setExecutionResult(null);
|
||||
|
||||
// 如果是 Python,设置 Pyodide 加载回调
|
||||
if (languageConfig?.engine === 'pyodide') {
|
||||
setPyodideLoadingCallbacks({
|
||||
onLoadingStart: () => {
|
||||
setPyodideStatus({
|
||||
stage: 'loading',
|
||||
message: '正在加载 Python 运行时...',
|
||||
progress: 0,
|
||||
});
|
||||
},
|
||||
onLoadingProgress: (message, progress) => {
|
||||
setPyodideStatus({
|
||||
stage: 'loading',
|
||||
message,
|
||||
progress,
|
||||
});
|
||||
},
|
||||
onLoadingComplete: () => {
|
||||
setPyodideStatus(null);
|
||||
},
|
||||
onLoadingError: (error) => {
|
||||
setPyodideStatus({
|
||||
stage: 'error',
|
||||
message: error,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeCode(code, language);
|
||||
setExecutionResult(result);
|
||||
} catch (error) {
|
||||
setExecutionResult({
|
||||
success: false,
|
||||
output: '',
|
||||
error: error instanceof Error ? error.message : '执行失败',
|
||||
executionTime: 0,
|
||||
engine: languageConfig?.engine || 'sandbox',
|
||||
});
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
setPyodideStatus(null);
|
||||
setPyodideLoadingCallbacks(null);
|
||||
}
|
||||
}, [code, language, isRunning, canRun, languageConfig]);
|
||||
|
||||
// 停止执行
|
||||
const handleStop = useCallback(() => {
|
||||
stopExecution();
|
||||
setIsRunning(false);
|
||||
setPyodideStatus(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn('relative group rounded overflow-hidden my-4', className)}
|
||||
style={{ boxShadow: 'var(--color-code-shadow)' }}>
|
||||
@ -141,6 +238,35 @@ export function CodeBlock({
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 运行按钮 */}
|
||||
{canRun && (
|
||||
<button
|
||||
onClick={isRunning ? handleStop : handleRun}
|
||||
disabled={!canRun}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2 py-1 rounded transition-colors',
|
||||
isRunning
|
||||
? 'hover:bg-red-500/20'
|
||||
: 'hover:bg-white/10'
|
||||
)}
|
||||
style={{
|
||||
color: isRunning ? '#ef4444' : 'var(--color-primary)',
|
||||
}}
|
||||
title={isRunning ? '停止执行' : `运行 ${languageConfig?.label || language}`}
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Square size={14} />
|
||||
<span>停止</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={14} />
|
||||
<span>运行</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* HTML 预览按钮 */}
|
||||
{isHtmlPreviewable && (
|
||||
<button
|
||||
@ -265,6 +391,47 @@ export function CodeBlock({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pyodide 加载状态(Python) */}
|
||||
{pyodideStatus && (
|
||||
<PyodideLoading
|
||||
stage={pyodideStatus.stage}
|
||||
message={pyodideStatus.message}
|
||||
progress={pyodideStatus.progress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 代码执行结果 */}
|
||||
{(executionResult || isRunning) && (
|
||||
<div className="px-0">
|
||||
{isRunning && !pyodideStatus ? (
|
||||
<div
|
||||
className="mt-2 rounded border overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-code-bg)',
|
||||
borderColor: 'var(--color-code-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-4 py-3 flex items-center gap-2"
|
||||
style={{ color: 'var(--color-code-toolbar-text)' }}
|
||||
>
|
||||
<Loader2 size={16} className="animate-spin" style={{ color: 'var(--color-primary)' }} />
|
||||
<span className="text-sm">正在执行 {languageConfig?.label || language} 代码...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : executionResult && (
|
||||
<CodeExecutionResult
|
||||
output={executionResult.output}
|
||||
error={executionResult.error}
|
||||
language={languageConfig?.label || language}
|
||||
engine={executionResult.engine}
|
||||
executionTime={executionResult.executionTime}
|
||||
success={executionResult.success}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HTML 预览模态框 */}
|
||||
{isHtmlPreviewable && (
|
||||
<HtmlPreviewModal
|
||||
|
||||
Loading…
Reference in New Issue
Block a user