feat(C-03):代码在线运行+课程练一节

新增CodeRunner组件(JS worker沙箱+Python pyodide)与/utility/code-runner独立页;CourseLearn新增练习Tab(首代码块自动提取装载,无示例可直接练习)。
This commit is contained in:
chunyu
2026-09-15 15:26:44 +08:00
parent abea2caa87
commit e90b787730
8 changed files with 562 additions and 1 deletions
+88
View File
@@ -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;
}
}
+226
View File
@@ -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<CodeRunnerProps> = ({ initialCode, height = 360 }) => {
const { t } = useTranslation();
const [lang, setLang] = useState<"javascript" | "python">("javascript");
const [code, setCode] = useState(initialCode ?? DEFAULT_JS);
const [output, setOutput] = useState<string[]>([]);
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 (
<div className="code-runner" style={{ height }}>
<div className="code-runner-toolbar">
<Space>
<CodeOutlined />
<Select
size="small"
value={lang}
style={{ width: 150 }}
onChange={switchLang}
options={[
{ value: "javascript", label: "JavaScript" },
{ value: "python", label: "Python (Pyodide)" },
]}
/>
<span className="code-runner-hint">沙箱本地运行 · 超时3s终止</span>
</Space>
<Space>
<Button size="small" icon={<ClearOutlined />} onClick={() => setOutput([])}>
{t("codeRunner.clear")}
</Button>
<Button
type="primary"
size="small"
icon={running ? <LoadingOutlined /> : <PlayCircleOutlined />}
disabled={running}
onClick={run}
>
{pyLoading && lang === "python" ? "加载Python运行时…" : t("codeRunner.run")}
</Button>
</Space>
</div>
<div className="code-runner-body">
<textarea
className="code-runner-editor"
value={code}
onChange={(e) => setCode(e.target.value)}
spellCheck={false}
/>
<div className="code-runner-output">
{output.length === 0 ? (
<span className="code-runner-output-empty">{t("codeRunner.outputEmpty")}</span>
) : (
output.map((line, i) => (
<pre key={i} className={line.startsWith("[Error]") ? "err" : ""}>{line}</pre>
))
)}
</div>
</div>
<div className="code-runner-status">
{running ? "运行中…" : "就绪"} · {lang === "javascript" ? "Web Worker 沙箱" : "Pyodide WASM(懒加载)"} · 无服务端执行
</div>
</div>
);
};
export default CodeRunner;
@@ -0,0 +1,63 @@
/**
* C-03 Pyodide 懒加载运行器(浏览器内 WASM,无服务端执行)。
* 安全约束:
* - 默认断网:不 loadPackage、不拉取额外包;只运行解释器自带能力。
* - 超时由调用方(CodeRunner.tsx)经 Promise.race + runId 丢弃过期结果保证;
* Pyodide 本体不支持强制 terminate,超时后输出"执行超时"并忽略迟到结果。
*/
export const PYODIDE_VERSION = "0.26.4";
export const PYODIDE_CDN = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
type PyodideRuntime = {
runPython: (code: string) => unknown;
setStdout: (opts: { batched: (s: string) => void }) => void;
setStderr: (opts: { batched: (s: string) => void }) => void;
};
declare global {
interface Window {
__pyodidePromise?: Promise<PyodideRuntime> | null;
}
}
function loadPyodideScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (document.querySelector('script[data-pyodide="1"]')) {
resolve();
return;
}
const s = document.createElement("script");
s.dataset.pyodide = "1";
s.src = `${PYODIDE_CDN}pyodide.js`;
s.onload = () => resolve();
s.onerror = () => reject(new Error("Pyodide 运行时加载失败,请检查网络后重试"));
document.head.appendChild(s);
});
}
async function bootPyodide(
onStdout: (s: string) => void,
onStderr: (s: string) => void,
): Promise<PyodideRuntime> {
await loadPyodideScript();
const factory = (window as unknown as { loadPyodide?: (opts: { indexURL: string }) => Promise<PyodideRuntime> }).loadPyodide;
if (!factory) throw new Error("Pyodide 运行时初始化失败");
const py = await factory({ indexURL: PYODIDE_CDN });
py.setStdout({ batched: onStdout });
py.setStderr({ batched: onStderr });
return py;
}
export function getPyodide(
onStdout: (s: string) => void,
onStderr: (s: string) => void,
): Promise<PyodideRuntime> {
if (!window.__pyodidePromise) {
window.__pyodidePromise = bootPyodide(onStdout, onStderr).catch((e) => {
window.__pyodidePromise = null;
throw e;
});
}
return window.__pyodidePromise;
}
+73
View File
@@ -0,0 +1,73 @@
/**
* C-03 JS 执行 Worker(内联 Blob 版)。
* 安全约束:
* - Worker 内无 DOM / 无 importScripts / 无网络(fetch/XHR/importScripts 全部禁用)。
* - 主线程无 eval / new Function;执行只发生在 Worker 线程。
* - 硬超时由主线程 terminate() 保证(见 CodeRunner.tsx,RUN_TIMEOUT_MS = 3000)。
*/
const WORKER_SOURCE = `
"use strict";
// ---- 危险面封堵 ----
try {
self.importScripts = function () { throw new Error("importScripts disabled"); };
self.fetch = function () { return Promise.reject(new Error("network disabled")); };
self.XMLHttpRequest = function () { throw new Error("network disabled"); };
self.WebSocket = function () { throw new Error("network disabled"); };
self.EventSource = function () { throw new Error("network disabled"); };
} catch (e) { /* 冻结失败也不影响继续 */ }
function fmt(v) {
if (typeof v === "string") return v;
if (typeof v === "undefined") return "undefined";
try {
var s = JSON.stringify(v);
return typeof s === "undefined" ? String(v) : s;
} catch (e) { return String(v); }
}
var lines = [];
function emit(type) {
var batch = lines.splice(0, lines.length);
self.postMessage({ type: type, lines: batch });
}
["log", "info", "warn", "debug"].forEach(function (m) {
var orig = console[m].bind(console);
console[m] = function () {
var args = Array.prototype.slice.call(arguments).map(fmt);
lines.push(args.join(" "));
emit("runner-log");
try { orig.apply(null, []); } catch (e) {}
};
});
console.error = function () {
var args = Array.prototype.slice.call(arguments).map(fmt);
lines.push("[Error] " + args.join(" "));
emit("runner-error");
};
self.onmessage = function (event) {
var data = event.data || {};
if (data.type !== "run") return;
var source = String(data.code || "");
try {
// indirect eval → 全局作用域执行,拿不到 Worker 闭包;同时禁用 return 语句外的 escape
(0, eval)(source + "\\n");
} catch (e) {
lines.push("[Error] " + (e && e.message ? e.message : String(e)));
emit("runner-error");
}
emit("runner-done");
};
`;
export function createJsWorker(): Worker {
const blob = new Blob([WORKER_SOURCE], { type: "text/javascript" });
return new Worker(URL.createObjectURL(blob));
}
/** 硬超时:3 秒(规格 C-03)。 */
export const RUN_TIMEOUT_MS = 3000;
/** 输出行上限,防止刷屏炸内存。 */
export const MAX_OUTPUT_LINES = 500;
@@ -0,0 +1,23 @@
.code-runner-page {
min-height: 100vh;
background: #f8fafc;
padding: 32px 16px;
}
.code-runner-page-inner {
max-width: 1000px;
margin: 0 auto;
}
.code-runner-page-title {
font-size: 24px;
font-weight: 700;
color: #1e293b;
margin: 0 0 6px;
}
.code-runner-page-desc {
font-size: 14px;
color: #64748b;
margin: 0 0 20px;
}
@@ -0,0 +1,29 @@
import React from "react";
import { useTranslation } from "react-i18next";
import CodeRunner from "@/components/CodeRunner/CodeRunner";
import "./CodeRunnerPage.css";
const CodeRunnerPage: React.FC = () => {
const { t } = useTranslation();
return (
<div className="code-runner-page">
<div className="code-runner-page-inner">
<h1 className="code-runner-page-title">
<CodeRunnerIcon /> {t("codeRunner.pageTitle")}
</h1>
<p className="code-runner-page-desc">{t("codeRunner.pageDesc")}</p>
<CodeRunner height={520} />
</div>
</div>
);
};
const CodeRunnerIcon: React.FC = () => (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" style={{ verticalAlign: "-4px", marginRight: 8 }}>
<path d="M8 6L3 12L8 18" stroke="#667eea" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
<path d="M16 6L21 12L16 18" stroke="#764ba2" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
<path d="M13.5 4L10.5 20" stroke="#f59e0b" strokeWidth="2.4" strokeLinecap="round" />
</svg>
);
export default CodeRunnerPage;
+20
View File
@@ -529,6 +529,26 @@
font-size: 14px;
}
.practice-snippet-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
background: rgba(102, 126, 234, 0.08);
border: 1px solid rgba(102, 126, 234, 0.25);
border-radius: 10px;
}
.practice-snippet-bar--empty {
justify-content: center;
color: var(--text-secondary);
background: transparent;
border-style: dashed;
}
.empty-video {
text-align: center;
padding: 60px 20px;
+40 -1
View File
@@ -22,6 +22,7 @@ import { useNavigate, useSearchParams } from "react-router-dom";
import { api_request } from "@/utils/request";
import { useRecordHistory } from "@/hooks/useRecordHistory";
import { BDCloudVideoView } from "@/components/BDCloudVideoView";
import CodeRunner, { extractFirstCodeBlock } from "@/components/CodeRunner/CodeRunner";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
@@ -84,6 +85,7 @@ const CourseLearn: React.FC = () => {
const [chapters, setChapters] = useState<ChapterData[]>([]);
const [currentChapterIndex, setCurrentChapterIndex] = useState(0);
const [chapterContent, setChapterContent] = useState("");
const [chapterSnippet, setChapterSnippet] = useState<{ lang: "javascript" | "python"; code: string } | null>(null);
const [completedChapters, setCompletedChapters] = useState<number[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [loading, setLoading] = useState(true);
@@ -161,15 +163,19 @@ const CourseLearn: React.FC = () => {
if (response.ok) {
const md = await response.text();
setChapterContent(md);
setChapterSnippet(extractFirstCodeBlock(md));
return;
}
} catch {}
try {
const res = await api_request.learn.get_chapter_content(chapterId);
const data = (res as any)?.data;
setChapterContent(data?.content_md || "");
const md = data?.content_md || "";
setChapterContent(md);
setChapterSnippet(extractFirstCodeBlock(md));
} catch {
setChapterContent("");
setChapterSnippet(null);
}
};
@@ -452,6 +458,39 @@ const CourseLearn: React.FC = () => {
</div>
),
},
{
key: "practice",
label: (
<span>
<PlayCircleOutlined /> {t('codeRunner.practice')}
</span>
),
children: (
<div>
{chapterSnippet ? (
<div className="practice-snippet-bar">
<span>本章检测到可运行代码示例({chapterSnippet.lang})</span>
<Button
size="small"
type="primary"
onClick={() => setChapterSnippet({ ...chapterSnippet })}
>
装载本章代码
</Button>
</div>
) : (
<div className="practice-snippet-bar practice-snippet-bar--empty">
本章暂无代码示例,可直接在下方练习
</div>
)}
<CodeRunner
key={`${currentChapter?.id}-${chapterSnippet?.code?.length ?? 0}`}
height={420}
initialCode={chapterSnippet?.code}
/>
</div>
),
},
]}
/>
</div>