Skip to main content

yggdrasil/pages/admin/
runner.rs

1//! 管理后台「代码试运行」页面。
2//!
3//! 作者在写作时可在此沙箱快速试运行代码(验证围栏 ` ```lang runnable ` 的预期输出),
4//! 而无需进入文章渲染后才能运行。沙箱使用与读者相同的 StartExec / GetExecResult
5//! 接口,受同一套资源钳制约束(admin 跳过速率限制,见 `start_exec`)。
6//!
7//! 仅 WASM 前端交互;语言在受支持集合内切换。
8
9use dioxus::prelude::*;
10
11use crate::components::code_runner::CodeRunner;
12use crate::components::forms::FormInput;
13use crate::infra::runner_config::ResourceLimits;
14/// 受支持的语言集合(与 LANGUAGES 注册表 / CODE_RUNNER_LANGUAGES 对齐)。
15const SUPPORTED_LANGS: &[(&str, &str)] = &[
16    ("python", "Python"),
17    ("node", "Node.js"),
18    ("go", "Go"),
19    ("rust", "Rust"),
20    ("bun", "Bun (TS)"),
21];
22/// 默认示例源码(按语言)。
23fn default_source(lang: &str) -> String {
24    match lang {
25        "python" => "print('Hello from author sandbox')\nfor i in range(3):\n    print(f'line {i}')\n".to_string(),
26        "node" => "console.log('Hello from author sandbox');\n[0,1,2].forEach(i => console.log(`line ${i}`));\n".to_string(),
27        "go" => "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello from author sandbox\")\n\tfor i := 0; i < 3; i++ {\n\t\tfmt.Printf(\"line %d\\n\", i)\n\t}\n}\n".to_string(),
28        "rust" => "fn main() {\n    println!(\"Hello from author sandbox\");\n    for i in 0..3 {\n        println!(\"line {}\", i);\n    }\n}\n".to_string(),
29        // bun 跑 TypeScript:示例用 TS 类型注解体现语言特性。
30        "bun" => "const greeting: string = 'Hello from author sandbox';\nconsole.log(greeting);\n[0, 1, 2].forEach((i: number) => console.log(`line ${i}`));\n".to_string(),
31        _ => String::new(),
32    }
33}
34
35/// 管理后台代码试运行页面。
36#[component]
37#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
38pub fn Runner() -> Element {
39    let mut lang = use_signal(|| "python".to_string());
40    // 语言切换时刷新示例源码(首次进入也有默认值)。
41    let mut source = use_signal(|| default_source("python"));
42    let mut overrides_json = use_signal(String::new);
43
44    // overrides 解析用 use_memo 承载:render 体只读不写(Dioxus render purity),
45    // 避免 render 期间 .set() override_error。畸形 JSON 标记在 memo 返回值里。
46    let parsed = use_memo(move || {
47        let raw = overrides_json();
48        match serde_json::from_str::<ResourceLimits>(raw.trim()) {
49            Ok(o) => (Some(o), String::new()),
50            Err(_) => {
51                if raw.trim().is_empty() {
52                    (None, String::new())
53                } else {
54                    (None, "overrides JSON 格式错误,已忽略".to_string())
55                }
56            }
57        }
58    });
59    let (overrides, override_error) = (parsed.read().0.clone(), parsed.read().1.clone());
60
61    rsx! {
62        div { class: "animate-page-enter w-full max-w-7xl mx-auto space-y-8",
63            // 页头:标题 + 副标题 + 运行引擎指示胶囊
64            div { class: "flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-[var(--color-paper-border)]/70",
65                div {
66                    h1 { class: "text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
67                        "代码试运行沙箱"
68                    }
69                    p { class: "text-sm text-[var(--color-paper-secondary)] mt-1.5",
70                        "在线运行与调试多语言代码,实时验证文章内交互代码块的预期输出"
71                    }
72                }
73                div { class: "flex items-center gap-2.5",
74                    div { class: "inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-mono bg-[var(--color-paper-entry)] text-[var(--color-paper-secondary)] border border-[var(--color-paper-border)]/70 shadow-2xs",
75                        span { class: "w-1.5 h-1.5 rounded-full bg-[var(--color-paper-accent)]" }
76                        span { "Docker 沙箱环境" }
77                    }
78                }
79            }
80
81            // 配置卡片:语言切换 + 资源覆盖
82            div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 p-6 sm:p-8 flex flex-col gap-6",
83                // 卡片标题
84                div { class: "flex items-center gap-2.5 border-b border-[var(--color-paper-border)]/60 pb-4",
85                    svg {
86                        class: "w-5 h-5 text-[var(--color-paper-accent)]",
87                        xmlns: "http://www.w3.org/2000/svg",
88                        view_box: "0 0 24 24",
89                        fill: "none",
90                        stroke: "currentColor",
91                        stroke_width: "2",
92                        stroke_linecap: "round",
93                        stroke_linejoin: "round",
94                        path { d: "M4 17l6-6-6-6" }
95                        path { d: "M12 19h8" }
96                    }
97                    h2 { class: "text-lg sm:text-xl font-bold text-[var(--color-paper-primary)]",
98                        "运行环境与配置"
99                    }
100                }
101
102                // 语言选择胶囊群
103                div { class: "flex flex-col gap-2.5",
104                    label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
105                        "编程语言"
106                    }
107                    div { class: "flex flex-wrap gap-2.5",
108                        for (idx, (l, label_text)) in SUPPORTED_LANGS.iter().enumerate() {
109                            button {
110                                key: "{l}",
111                                class: format!(
112                                    "animate-row-enter inline-flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium rounded-full transition-all cursor-pointer {}",
113                                    if lang() == *l {
114                                        "bg-[var(--color-paper-accent)] text-[var(--color-paper-theme)] shadow-2xs font-semibold"
115                                    } else {
116                                        "text-[var(--color-paper-secondary)] bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-theme)] hover:text-[var(--color-paper-primary)] border border-[var(--color-paper-border)]/70"
117                                    },
118                                ),
119                                style: "animation-delay: {idx * 40}ms",
120                                onclick: {
121                                    let ll = (*l).to_string();
122                                    move |_| {
123                                        if ll != lang() {
124                                            lang.set(ll.clone());
125                                            source.set(default_source(&ll));
126                                        }
127                                    }
128                                },
129                                span { "{label_text}" }
130                            }
131                        }
132                    }
133                }
134
135                // 资源覆盖(JSON)
136                div { class: "flex flex-col gap-2",
137                    div { class: "flex items-center justify-between",
138                        label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
139                            "资源限制覆盖 (JSON, 可选)"
140                        }
141                        span { class: "text-[11px] text-[var(--color-paper-tertiary)] font-mono",
142                            "timeout_secs / memory_mb / cpu_cores"
143                        }
144                    }
145                    FormInput {
146                        r#type: "text",
147                        placeholder: "如 {{\"timeout_secs\":10,\"memory_mb\":512}}",
148                        value: overrides_json(),
149                        mono: true,
150                        oninput: move |v: String| overrides_json.set(v),
151                    }
152                    if !override_error.is_empty() {
153                        p { class: "text-xs text-red-500 dark:text-red-400 font-medium", "{override_error}" }
154                    } else {
155                        p { class: "text-xs text-[var(--color-paper-tertiary)] leading-normal",
156                            "支持覆盖 cpu_cores / memory_mb / timeout_secs / output_bytes / allow_network;最终仍受服务端全局上限约束"
157                        }
158                    }
159                }
160            }
161            // 强制 remount 切换语言:CodeRunner 挂载 use_effect 的「防重复 init」守卫
162            // (editor_handle.is_some() → return)阻止 CodeMirror 重建到新语言;且 plain
163            // String prop 非响应式,内部 use_effect 不会因 prop 变化重跑(与 post_detail 翻页
164            // remount 同根因,见 src/pages/post_detail.rs:110-115 注释)。key 绑定语言 →
165            // keyed diff 卸载旧实例(use_drop 销毁 CodeMirror/xterm)→ 挂载新实例,mount effect
166            // 以新 language + 新 source 初始化编辑器。输出区随 remount 重置(切语言本应清空旧输出)。
167            // 切语言时 CodeRunner 经 std::iter::once keyed remount 卸载/重建(详见上注释),
168            // 包一层 animate-section-enter div:随 remount 重挂载,每次切语言重播
169            // 300ms 淡入位移(与设置页分区切换同款动画)。
170            for lang_key in std::iter::once(lang().clone()) {
171                div { key: "{lang_key}", class: "animate-section-enter",
172                    CodeRunner {
173                        source: source(),
174                        language: lang(),
175                        overrides: overrides.clone(),
176                        instance_id: 0,
177                    }
178                }
179            }
180        }
181    }
182}