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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user