xfy 1dc02561a3 style(code-runner): 重做 admin/runner 视觉,对齐项目 token 体系
页面与 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 柔光块
2026-07-05 14:49:24 +08:00

286 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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());
// 为每个实例生成稳定的容器 idCodeMirror 容器,由本组件在 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 + optionscreate 后存进 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)
}