feat(C-03):代码在线运行+课程练一节
新增CodeRunner组件(JS worker沙箱+Python pyodide)与/utility/code-runner独立页;CourseLearn新增练习Tab(首代码块自动提取装载,无示例可直接练习)。
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -529,6 +529,26 @@
|
|||||||
font-size: 14px;
|
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 {
|
.empty-video {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useNavigate, useSearchParams } from "react-router-dom";
|
|||||||
import { api_request } from "@/utils/request";
|
import { api_request } from "@/utils/request";
|
||||||
import { useRecordHistory } from "@/hooks/useRecordHistory";
|
import { useRecordHistory } from "@/hooks/useRecordHistory";
|
||||||
import { BDCloudVideoView } from "@/components/BDCloudVideoView";
|
import { BDCloudVideoView } from "@/components/BDCloudVideoView";
|
||||||
|
import CodeRunner, { extractFirstCodeBlock } from "@/components/CodeRunner/CodeRunner";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import remarkMath from "remark-math";
|
import remarkMath from "remark-math";
|
||||||
@@ -84,6 +85,7 @@ const CourseLearn: React.FC = () => {
|
|||||||
const [chapters, setChapters] = useState<ChapterData[]>([]);
|
const [chapters, setChapters] = useState<ChapterData[]>([]);
|
||||||
const [currentChapterIndex, setCurrentChapterIndex] = useState(0);
|
const [currentChapterIndex, setCurrentChapterIndex] = useState(0);
|
||||||
const [chapterContent, setChapterContent] = useState("");
|
const [chapterContent, setChapterContent] = useState("");
|
||||||
|
const [chapterSnippet, setChapterSnippet] = useState<{ lang: "javascript" | "python"; code: string } | null>(null);
|
||||||
const [completedChapters, setCompletedChapters] = useState<number[]>([]);
|
const [completedChapters, setCompletedChapters] = useState<number[]>([]);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -161,15 +163,19 @@ const CourseLearn: React.FC = () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const md = await response.text();
|
const md = await response.text();
|
||||||
setChapterContent(md);
|
setChapterContent(md);
|
||||||
|
setChapterSnippet(extractFirstCodeBlock(md));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
const res = await api_request.learn.get_chapter_content(chapterId);
|
const res = await api_request.learn.get_chapter_content(chapterId);
|
||||||
const data = (res as any)?.data;
|
const data = (res as any)?.data;
|
||||||
setChapterContent(data?.content_md || "");
|
const md = data?.content_md || "";
|
||||||
|
setChapterContent(md);
|
||||||
|
setChapterSnippet(extractFirstCodeBlock(md));
|
||||||
} catch {
|
} catch {
|
||||||
setChapterContent("");
|
setChapterContent("");
|
||||||
|
setChapterSnippet(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -452,6 +458,39 @@ const CourseLearn: React.FC = () => {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user