From e90b78773066e363a3d027f06c6b165605b98bbc Mon Sep 17 00:00:00 2001 From: chunyu <12914@example.com> Date: Tue, 15 Sep 2026 15:26:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(C-03)=EF=BC=9A=E4=BB=A3=E7=A0=81=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E8=BF=90=E8=A1=8C+=E8=AF=BE=E7=A8=8B=E7=BB=83?= =?UTF-8?q?=E4=B8=80=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增CodeRunner组件(JS worker沙箱+Python pyodide)与/utility/code-runner独立页;CourseLearn新增练习Tab(首代码块自动提取装载,无示例可直接练习)。 --- src/components/CodeRunner/CodeRunner.css | 88 ++++++++ src/components/CodeRunner/CodeRunner.tsx | 226 ++++++++++++++++++++ src/components/CodeRunner/pyodide-runner.ts | 63 ++++++ src/components/CodeRunner/worker-js.ts | 73 +++++++ src/pages/CodeRunnerPage/CodeRunnerPage.css | 23 ++ src/pages/CodeRunnerPage/CodeRunnerPage.tsx | 29 +++ src/pages/CourseLearn/CourseLearn.css | 20 ++ src/pages/CourseLearn/CourseLearn.tsx | 41 +++- 8 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 src/components/CodeRunner/CodeRunner.css create mode 100644 src/components/CodeRunner/CodeRunner.tsx create mode 100644 src/components/CodeRunner/pyodide-runner.ts create mode 100644 src/components/CodeRunner/worker-js.ts create mode 100644 src/pages/CodeRunnerPage/CodeRunnerPage.css create mode 100644 src/pages/CodeRunnerPage/CodeRunnerPage.tsx diff --git a/src/components/CodeRunner/CodeRunner.css b/src/components/CodeRunner/CodeRunner.css new file mode 100644 index 0000000..c52682d --- /dev/null +++ b/src/components/CodeRunner/CodeRunner.css @@ -0,0 +1,88 @@ +.code-runner { + display: flex; + flex-direction: column; + border: 1px solid #e2e8f0; + border-radius: 14px; + overflow: hidden; + background: #fff; +} + +.code-runner-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.code-runner-body { + flex: 1; + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 0; +} + +.code-runner-editor { + border: none; + resize: none; + padding: 12px 14px; + font-family: "JetBrains Mono", "Fira Code", Consolas, monospace; + font-size: 13px; + line-height: 1.6; + color: #1e293b; + background: #fff; + border-right: 1px solid #e2e8f0; + outline: none; + tab-size: 2; +} + +.code-runner-output { + padding: 12px 14px; + overflow: auto; + background: #0f172a; + font-family: "JetBrains Mono", "Fira Code", Consolas, monospace; + font-size: 13px; + line-height: 1.6; +} + +.code-runner-output pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + color: #e2e8f0; +} + +.code-runner-output pre.err { + color: #f87171; +} + +.code-runner-output-empty { + color: #64748b; + font-size: 12px; +} + +.code-runner-hint { + font-size: 11px; + color: #64748b; +} + +.code-runner-status { + padding: 6px 12px; + font-size: 11px; + color: #64748b; + background: #f8fafc; + border-top: 1px solid #e2e8f0; +} + +@media (max-width: 768px) { + .code-runner-body { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } + + .code-runner-editor { + border-right: none; + border-bottom: 1px solid #e2e8f0; + } +} diff --git a/src/components/CodeRunner/CodeRunner.tsx b/src/components/CodeRunner/CodeRunner.tsx new file mode 100644 index 0000000..8663695 --- /dev/null +++ b/src/components/CodeRunner/CodeRunner.tsx @@ -0,0 +1,226 @@ +import "./CodeRunner.css"; +import React, { useEffect, useRef, useState } from "react"; +import { Button, Select, Space } from "antd"; +import { + PlayCircleOutlined, + ClearOutlined, + LoadingOutlined, + CodeOutlined, +} from "@ant-design/icons"; +import { useTranslation } from "react-i18next"; +import { createJsWorker, RUN_TIMEOUT_MS, MAX_OUTPUT_LINES } from "./worker-js"; + +interface CodeRunnerProps { + /** 初始代码(受控可选) */ + initialCode?: string; + /** 高度(px),默认 360 */ + height?: number; +} + +export const DEFAULT_JS = `// JavaScript 在线运行 +function greet(name) { + return \`Hello, \${name}!\`; +} + +for (let i = 1; i <= 3; i++) { + console.log(greet("可乐工具 " + i)); +} + +console.log("sum =", [1, 2, 3].reduce((a, b) => a + b, 0));`; + +export const DEFAULT_PY = `# Python 在线运行(Pyodide / WebAssembly) +def greet(name): + return f"Hello, {name}!" + +for i in range(1, 4): + print(greet("可乐工具 " + str(i))) + +print("sum =", sum([1, 2, 3]))`; + +/** 从 Markdown 里抽第一个 ```js/python 代码块(章节"随堂运行"装载用)。 */ +export function extractFirstCodeBlock(md: string): { lang: "javascript" | "python"; code: string } | null { + const m = md.match(/```(\w*)\n([\s\S]*?)```/); + if (!m) return null; + const tag = (m[1] || "").toLowerCase(); + const lang = tag.includes("py") ? "python" : "javascript"; + return { lang, code: m[2].trim() }; +} + +/** + * C-03 学练一体代码运行器(纯前端沙箱,零服务端执行): + * - JS:Web Worker 执行,importScripts/网络禁用,主线程无 eval,硬超时 3s terminate。 + * - Python:Pyodide WASM 懒加载(独立 chunk,见 pyodide-runner.ts),默认断网不拉包。 + */ +const CodeRunner: React.FC = ({ initialCode, height = 360 }) => { + const { t } = useTranslation(); + const [lang, setLang] = useState<"javascript" | "python">("javascript"); + const [code, setCode] = useState(initialCode ?? DEFAULT_JS); + const [output, setOutput] = useState([]); + const [running, setRunning] = useState(false); + const [pyLoading, setPyLoading] = useState(false); + const runId = useRef(0); + + // 外部传入初始代码变化时同步(如章节切换) + useEffect(() => { + if (initialCode !== undefined) { + setCode(initialCode); + setOutput([]); + } + }, [initialCode]); + + useEffect(() => () => { runId.current += 1; }, []); + + const pushLines = (lines: string[]) => { + if (!lines.length) return; + setOutput((prev) => [...prev, ...lines].slice(-MAX_OUTPUT_LINES)); + }; + + const switchLang = (value: "javascript" | "python") => { + setLang(value); + setCode(value === "javascript" ? DEFAULT_JS : DEFAULT_PY); + setOutput([]); + }; + + const runJs = (source: string, id: number) => { + let worker: Worker | null = null; + try { + worker = createJsWorker(); + } catch (e) { + pushLines(["[Error] Worker 创建失败,当前浏览器不支持"]); + setRunning(false); + return; + } + const timer = window.setTimeout(() => { + if (runId.current !== id) return; + try { worker?.terminate(); } catch { /* ignore */ } + pushLines(["[Error] 执行超时(>3s),已终止"]); + setRunning(false); + }, RUN_TIMEOUT_MS); + worker.onmessage = (event: MessageEvent) => { + if (runId.current !== id) return; + const data = event.data || {}; + if (data.type === "runner-log" || data.type === "runner-error") { + pushLines(Array.isArray(data.lines) ? data.lines : []); + } else if (data.type === "runner-done") { + window.clearTimeout(timer); + try { worker?.terminate(); } catch { /* ignore */ } + setRunning(false); + } + }; + worker.onerror = () => { + if (runId.current !== id) return; + window.clearTimeout(timer); + pushLines(["[Error] Worker 运行异常,已终止"]); + try { worker?.terminate(); } catch { /* ignore */ } + setRunning(false); + }; + worker.postMessage({ type: "run", code: source }); + }; + + const runPython = async (source: string, id: number) => { + setPyLoading(true); + const timer = window.setTimeout(() => { + if (runId.current !== id) return; + runId.current += 1; // 丢弃迟到结果(Pyodide 不可 terminate) + pushLines(["[Error] 执行超时(>3s),结果已丢弃"]); + setRunning(false); + setPyLoading(false); + }, RUN_TIMEOUT_MS); + try { + const mod = await import("./pyodide-runner"); + if (runId.current !== id) return; + const py = await mod.getPyodide( + (s) => { if (runId.current === id) pushLines(s.split("\n")); }, + (s) => { if (runId.current === id) pushLines(s.split("\n").map((x) => `[Error] ${x}`)); }, + ); + if (runId.current !== id) return; + setPyLoading(false); + try { + py.runPython(source); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + pushLines([`[Error] ${msg}`]); + } + if (runId.current !== id) return; + window.clearTimeout(timer); + setRunning(false); + } catch (e: unknown) { + if (runId.current !== id) return; + window.clearTimeout(timer); + const msg = e instanceof Error ? e.message : String(e); + pushLines([`[Error] ${msg}`]); + setRunning(false); + setPyLoading(false); + } + }; + + const run = () => { + if (running) return; + setOutput([]); + setRunning(true); + const id = runId.current + 1; + runId.current = id; + if (lang === "javascript") { + runJs(code, id); + } else { + void runPython(code, id); + } + }; + + return ( +
+
+ + +