页面与 CodeRunner 组件此前用的是 daisyUI 类(base-100/btn-primary/ input-bordered/bg-neutral 等),但项目根本没装 daisyUI——这些类在 编译产物里 0 匹配,导致页面渲染成裸结构,是「半成品」观感的根因。 全部换成项目真实的 --color-paper-* 语义 token,视觉与 dashboard / posts / system 等后台页面统一: 页面壳(runner.rs): - h1 升至 text-4xl,与其它 admin 页一致;加底部分割线 - 外层对齐 max-w-5xl space-y-8 - 语言切换改为胶囊按钮组(选中态 accent 绿实心) - overrides 输入框套 system.rs 的表单范式,加 focus 态 - 错误/提示用 red-500 dark: red-400,解析成功时显示字段说明 CodeRunner 组件(runner.rs): - 容器改 rounded-2xl + paper-border + paper-entry - 顶栏加状态点(accent)+ 胶囊运行按钮(active 缩放反馈) - 运行中用 SPINNER_SVG 替代 daisyUI loading-spinner - 输出区改 paper-code-block 底 + 语义色文字,随主题切 - 错误区改 red-50 dark: red-900/10 柔光块
286 lines
13 KiB
Rust
286 lines
13 KiB
Rust
//! CodeRunner 组件实现:源码 + 运行按钮 + 轮询 + 输出区。
|
||
//!
|
||
//! 编辑器挂载:组件在 WASM 端按自身 `container_id` 调用
|
||
//! [`crate::codemirror_bridge::get_module().create`] 挂载 CodeMirror,`onChange`
|
||
//! 回写到内部 `source_signal`,`use_drop` 时销毁实例。范式镜像 SQL 控制台
|
||
//!(`src/pages/admin/system.rs`)与 Tiptap 编辑器(`src/pages/admin/write.rs`)。
|
||
|
||
use dioxus::prelude::*;
|
||
|
||
use crate::api::code_runner::execute::{get_exec_result, start_exec};
|
||
use crate::api::code_runner::{ExecRequest, ExecStatus};
|
||
use crate::components::ui::SPINNER_SVG;
|
||
use crate::infra::runner_config::ResourceLimits;
|
||
use crate::utils::time::sleep_ms;
|
||
|
||
/// 轮询间隔(毫秒)。
|
||
const POLL_INTERVAL_MS: u32 = 500;
|
||
/// 轮询最大次数兜底,避免任务卡在 Running 状态时无限轮询。
|
||
const MAX_POLLS: u32 = 240; // 500ms * 240 = 120s 上限
|
||
|
||
/// 代码运行器组件。
|
||
///
|
||
/// Props:
|
||
/// - `source`:初始源码(首次挂载用于初始化编辑器;之后编辑器内容是唯一真源)。
|
||
/// - `language`:语言标识(python / node 等)。
|
||
/// - `overrides`:可选资源限制覆盖。
|
||
///
|
||
/// `mut` 信号仅在 WASM 的 spawn 闭包内被 `.set()`,server 构建会触发 unused_mut,
|
||
/// 故按项目惯例加 `cfg_attr` 放行(参见 AGENTS.md「mut bindings needed only on WASM」)。
|
||
#[component]
|
||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
|
||
pub fn CodeRunner(
|
||
source: String,
|
||
language: String,
|
||
overrides: Option<ResourceLimits>,
|
||
) -> Element {
|
||
let mut running = use_signal(|| false);
|
||
let mut stage = use_signal(String::new);
|
||
let mut output = use_signal(String::new);
|
||
let mut exit_info = use_signal(String::new);
|
||
let mut error_msg = use_signal(String::new);
|
||
|
||
// 编辑器内容的唯一真源;初始化为 prop 值。
|
||
let mut source_signal = use_signal(|| source.clone());
|
||
|
||
// 为每个实例生成稳定的容器 id(CodeMirror 容器,由本组件在 WASM 端挂载)。
|
||
// use_hook 保证只算一次;后缀用时间戳+原子计数避免同页多实例 id 冲突。
|
||
let container_id = use_hook(|| format!("code-runner-{}", now_pseudo_unique()));
|
||
|
||
// —— CodeMirror 挂载(仅 WASM)——
|
||
// 范式镜像 src/pages/admin/system.rs 的 SQL 控制台与 src/pages/admin/write.rs 的 Tiptap。
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
use crate::codemirror_bridge;
|
||
use crate::theme::{use_resolved_theme, ResolvedTheme};
|
||
use wasm_bindgen::closure::Closure;
|
||
|
||
let mut editor_handle: Signal<Option<codemirror_bridge::EditorHandle>> =
|
||
use_signal(|| None);
|
||
|
||
// 首次挂载:构造 closure + options,create 后存进 editor_handle。
|
||
// 用 resolved() 读取主题作为初始值(同时订阅,但主题切换由下方独立 effect 处理)。
|
||
let mount_language = language.clone();
|
||
let mount_container_id = container_id.clone();
|
||
use_effect(move || {
|
||
if editor_handle.read().is_some() {
|
||
return; // 防重复 init
|
||
}
|
||
|
||
// onChange 回写到 source_signal(编辑器内容 = 唯一真源)。
|
||
let on_change = Closure::new({
|
||
let mut sig = source_signal;
|
||
move |v: String| sig.set(v)
|
||
});
|
||
let on_ready = Closure::new(|| {});
|
||
|
||
let resolved = use_resolved_theme();
|
||
let theme_name = if resolved() == ResolvedTheme::Dark {
|
||
"dark"
|
||
} else {
|
||
"light"
|
||
};
|
||
|
||
let opts = codemirror_bridge::EditorOptions::new();
|
||
opts.set_language(&mount_language);
|
||
opts.set_theme(theme_name);
|
||
opts.set_value(&source_signal.read());
|
||
opts.set_on_change(&on_change);
|
||
opts.set_on_ready(&on_ready);
|
||
|
||
if let Ok(Some(inst)) =
|
||
codemirror_bridge::get_module().create(&mount_container_id, &opts)
|
||
{
|
||
let handle =
|
||
codemirror_bridge::EditorHandle::new(inst, on_change, on_ready);
|
||
editor_handle.set(Some(handle));
|
||
}
|
||
});
|
||
|
||
// 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。
|
||
use_effect(move || {
|
||
let r = use_resolved_theme();
|
||
if let Some(h) = editor_handle.read().as_ref() {
|
||
h.instance()
|
||
.set_theme(if r() == ResolvedTheme::Dark {
|
||
"dark"
|
||
} else {
|
||
"light"
|
||
});
|
||
}
|
||
});
|
||
|
||
// source prop 外部变更(如 admin 页面切换语言重置示例代码)同步到
|
||
// signal + 编辑器。读取 source() 订阅其变化;编辑器用户输入经 on_change
|
||
// 写回 signal,但不会改变 prop(单向),故不会形成回环。
|
||
let sync_source = source.clone();
|
||
use_effect(move || {
|
||
if sync_source != *source_signal.read() {
|
||
source_signal.set(sync_source.clone());
|
||
if let Some(h) = editor_handle.read().as_ref() {
|
||
h.instance().set_value(&sync_source);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 组件卸载时销毁 CodeMirror 实例(EditorHandle::drop → instance.destroy)。
|
||
use_drop(move || {
|
||
editor_handle.set(None);
|
||
});
|
||
}
|
||
|
||
// —— run_code:同步段取 signal 当前值,move 进 spawn ——
|
||
let run_language = language.clone();
|
||
let run_overrides = overrides.clone();
|
||
let run_code = move |_| {
|
||
if running() {
|
||
return;
|
||
}
|
||
running.set(true);
|
||
stage.set("提交中...".to_string());
|
||
output.set(String::new());
|
||
exit_info.set(String::new());
|
||
error_msg.set(String::new());
|
||
|
||
// 不能跨 await 持有 signal borrow,先 clone 出 owned 值。
|
||
let run_source = source_signal.read().clone();
|
||
let req = ExecRequest {
|
||
language: run_language.clone(),
|
||
source: run_source,
|
||
overrides: run_overrides.clone(),
|
||
};
|
||
|
||
spawn(async move {
|
||
match start_exec(req).await {
|
||
Ok(task_id) => {
|
||
let mut polls = 0u32;
|
||
loop {
|
||
polls += 1;
|
||
sleep_ms(POLL_INTERVAL_MS).await;
|
||
match get_exec_result(task_id.clone()).await {
|
||
Ok(task) => {
|
||
stage.set(task.stage.clone());
|
||
let terminal = task.status != ExecStatus::Queued
|
||
&& task.status != ExecStatus::Running;
|
||
if terminal {
|
||
running.set(false);
|
||
if let Some(res) = task.result {
|
||
let out = format!(
|
||
"Stdout:\n{}\nStderr:\n{}",
|
||
res.stdout, res.stderr
|
||
);
|
||
output.set(out);
|
||
exit_info.set(format!(
|
||
"耗时: {}ms · 状态: {}",
|
||
res.duration_ms,
|
||
status_label(&res.status)
|
||
));
|
||
if res.status == ExecStatus::Success {
|
||
error_msg.set(String::new());
|
||
} else {
|
||
error_msg
|
||
.set(status_label(&res.status));
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
if polls >= MAX_POLLS {
|
||
running.set(false);
|
||
stage.set("查询超时".to_string());
|
||
error_msg.set("轮询超时,请重试".to_string());
|
||
break;
|
||
}
|
||
}
|
||
Err(_) => {
|
||
running.set(false);
|
||
stage.set("结果获取异常".to_string());
|
||
error_msg.set("结果获取异常".to_string());
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Err(e) => {
|
||
running.set(false);
|
||
let msg = e.to_string();
|
||
stage.set(msg.clone());
|
||
error_msg.set(msg);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
rsx! {
|
||
div { class: "rounded-2xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]",
|
||
// 顶栏:语言标签 + 运行按钮
|
||
div { class: "flex justify-between items-center px-4 py-2.5 border-b border-[var(--color-paper-border)] bg-[var(--color-paper-theme)]",
|
||
div { class: "flex items-center gap-2",
|
||
span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" }
|
||
span { class: "font-mono text-sm font-semibold text-[var(--color-paper-primary)]", "{language}" }
|
||
}
|
||
button {
|
||
class: "inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium rounded-full text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] hover:brightness-110 active:scale-[0.98] transition disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer",
|
||
disabled: running(),
|
||
onclick: run_code,
|
||
if running() {
|
||
span { class: "inline-block w-3.5 h-3.5 text-[var(--color-paper-theme)]",
|
||
dangerous_inner_html: SPINNER_SVG,
|
||
}
|
||
"{stage()}"
|
||
} else {
|
||
"运行"
|
||
}
|
||
}
|
||
}
|
||
// CodeMirror 容器:组件自身在 WASM 端按此 id 挂载编辑器。
|
||
// 服务端渲染时仅展示空容器,避免在 SSR 阶段拉起 JS 编辑器。
|
||
div {
|
||
id: "{container_id}",
|
||
class: "code-runner-editor font-mono text-sm",
|
||
style: "min-height: 160px",
|
||
}
|
||
// 输出区
|
||
if !output().is_empty() {
|
||
div { class: "border-t border-[var(--color-paper-border)]",
|
||
div { class: "flex justify-between items-center px-4 py-2 text-xs text-[var(--color-paper-tertiary)] border-b border-[var(--color-paper-border)] bg-[var(--color-paper-code-block)]",
|
||
span { class: "font-medium uppercase tracking-wide", "输出" }
|
||
span { "{exit_info()}" }
|
||
}
|
||
pre { class: "px-4 py-3 m-0 text-xs font-mono text-[var(--color-paper-secondary)] bg-[var(--color-paper-code-block)] overflow-x-auto whitespace-pre-wrap break-words",
|
||
{output()}
|
||
}
|
||
}
|
||
}
|
||
// 错误提示
|
||
if !error_msg().is_empty() {
|
||
div { class: "px-4 py-2.5 border-t border-[var(--color-paper-border)] text-xs text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-900/10",
|
||
{error_msg()}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 把 ExecStatus 映射成中文标签。
|
||
fn status_label(status: &ExecStatus) -> String {
|
||
match status {
|
||
ExecStatus::Queued => "排队中".to_string(),
|
||
ExecStatus::Running => "运行中".to_string(),
|
||
ExecStatus::Success => "成功".to_string(),
|
||
ExecStatus::Timeout => "超时".to_string(),
|
||
ExecStatus::OomKilled => "内存超限".to_string(),
|
||
ExecStatus::Error => "运行错误".to_string(),
|
||
ExecStatus::Failed => "系统失败".to_string(),
|
||
ExecStatus::RateLimited => "请求过频".to_string(),
|
||
}
|
||
}
|
||
|
||
/// 生成一个伪唯一后缀(基于时间戳 + 计数器),用于容器 id。
|
||
/// 非安全用途,仅避免同页多实例 id 冲突。
|
||
fn now_pseudo_unique() -> String {
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||
format!("{}-{}", crate::utils::time::now_millis(), n)
|
||
}
|