feat(代码块): 集成代码运行功能
- 添加运行/停止按钮交互 - 显示 Pyodide 加载进度(Python) - 集成执行结果展示组件 - 添加更多语言的 Prism 语法高亮支持 - 优化高亮失败的降级处理
This commit is contained in:
parent
70902a2541
commit
269fc798aa
@ -1,10 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { Copy, Check, Eye, ChevronDown, ChevronUp } from 'lucide-react';
|
import { Copy, Check, Eye, ChevronDown, ChevronUp, Play, Square, Loader2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import Prism from 'prismjs';
|
import Prism from 'prismjs';
|
||||||
import { HtmlPreviewModal } from '@/components/ui/HtmlPreviewModal';
|
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-javascript';
|
||||||
import 'prismjs/components/prism-typescript';
|
import 'prismjs/components/prism-typescript';
|
||||||
import 'prismjs/components/prism-jsx';
|
import 'prismjs/components/prism-jsx';
|
||||||
@ -24,6 +34,10 @@ import 'prismjs/components/prism-markdown';
|
|||||||
import 'prismjs/components/prism-css';
|
import 'prismjs/components/prism-css';
|
||||||
import 'prismjs/components/prism-scss';
|
import 'prismjs/components/prism-scss';
|
||||||
import 'prismjs/components/prism-markup';
|
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 {
|
interface CodeBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
@ -62,6 +76,15 @@ export function CodeBlock({
|
|||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = 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();
|
const normalizedLanguage = languageAliases[language.toLowerCase()] || language.toLowerCase();
|
||||||
|
|
||||||
@ -69,6 +92,10 @@ export function CodeBlock({
|
|||||||
const isHtmlPreviewable = ['html', 'htm', 'markup'].includes(normalizedLanguage) ||
|
const isHtmlPreviewable = ['html', 'htm', 'markup'].includes(normalizedLanguage) ||
|
||||||
['html', 'htm'].includes(language.toLowerCase());
|
['html', 'htm'].includes(language.toLowerCase());
|
||||||
|
|
||||||
|
// 判断是否可执行:语言支持 + 代码满足执行条件
|
||||||
|
const canRun = isRunnableLanguage(language) && isCodeExecutable(code, language);
|
||||||
|
const languageConfig = canRun ? getLanguageConfig(language) : null;
|
||||||
|
|
||||||
const lines = code.split('\n');
|
const lines = code.split('\n');
|
||||||
const totalLines = lines.length;
|
const totalLines = lines.length;
|
||||||
const shouldCollapse = totalLines > maxCollapsedLines;
|
const shouldCollapse = totalLines > maxCollapsedLines;
|
||||||
@ -86,11 +113,20 @@ export function CodeBlock({
|
|||||||
|
|
||||||
// 使用 useMemo 缓存高亮后的 HTML,避免频繁重新高亮
|
// 使用 useMemo 缓存高亮后的 HTML,避免频繁重新高亮
|
||||||
const highlightedCode = useMemo(() => {
|
const highlightedCode = useMemo(() => {
|
||||||
const grammar = Prism.languages[normalizedLanguage];
|
try {
|
||||||
if (grammar) {
|
const grammar = Prism.languages[normalizedLanguage];
|
||||||
return Prism.highlight(displayCode, grammar, normalizedLanguage);
|
if (grammar) {
|
||||||
|
return Prism.highlight(displayCode, grammar, normalizedLanguage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 某些语言的 grammar 可能不完整,fallback 到纯文本
|
||||||
|
console.warn(`Prism highlight failed for ${normalizedLanguage}:`, error);
|
||||||
}
|
}
|
||||||
return displayCode;
|
// 对纯文本进行 HTML 转义
|
||||||
|
return displayCode
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
}, [displayCode, normalizedLanguage]);
|
}, [displayCode, normalizedLanguage]);
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
@ -108,6 +144,67 @@ export function CodeBlock({
|
|||||||
setIsExpanded(!isExpanded);
|
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 (
|
return (
|
||||||
<div className={cn('relative group rounded overflow-hidden my-4', className)}
|
<div className={cn('relative group rounded overflow-hidden my-4', className)}
|
||||||
style={{ boxShadow: 'var(--color-code-shadow)' }}>
|
style={{ boxShadow: 'var(--color-code-shadow)' }}>
|
||||||
@ -141,6 +238,35 @@ export function CodeBlock({
|
|||||||
|
|
||||||
{/* 右侧:操作按钮 */}
|
{/* 右侧:操作按钮 */}
|
||||||
<div className="flex items-center gap-2">
|
<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 预览按钮 */}
|
{/* HTML 预览按钮 */}
|
||||||
{isHtmlPreviewable && (
|
{isHtmlPreviewable && (
|
||||||
<button
|
<button
|
||||||
@ -265,6 +391,47 @@ export function CodeBlock({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 预览模态框 */}
|
{/* HTML 预览模态框 */}
|
||||||
{isHtmlPreviewable && (
|
{isHtmlPreviewable && (
|
||||||
<HtmlPreviewModal
|
<HtmlPreviewModal
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user