feat(ui): create CodeRunner Dioxus UI component with async polling

- runner.rs: CodeRunner 组件(源码展示 + Run 按钮 + 阶段/输出/错误区)
  spawn 提交 StartExec 后用 sleep_ms(WASM 友好) 轮询 GetExecResult,MAX_POLLS 兜底
  mut 信号加 cfg_attr 放行 server 的 unused_mut(AGENTS.md 约定)
  提前克隆 source/language 给 move 闭包,避免 rsx! 二次借用 (E0382)
- execute.rs: 修正双目标可见性——server fn 模块不能 cfg-gate
  共享类型 use 双目标可见,server-only 辅助 use 单独 gate(对齐 posts 模块约定)
- mod.rs: execute 不再 gate,languages/progress 仍 gate
This commit is contained in:
xfy 2026-07-03 18:28:52 +08:00
parent 576803302d
commit 6b875e6ba5
5 changed files with 230 additions and 14 deletions

View File

@ -6,31 +6,46 @@
//! //!
//! 错误脱敏:匿名可见错误(不支持的语言、超限、限流)返回中文消息;系统内部 //! 错误脱敏:匿名可见错误(不支持的语言、超限、限流)返回中文消息;系统内部
//! 异常(容器拉起失败等)记服务端日志,对前端返回统一「系统暂时不可用」。 //! 异常(容器拉起失败等)记服务端日志,对前端返回统一「系统暂时不可用」。
//!
//! 双目标可见性:本模块**不** cfg-gateserver function 需对 WASM 可见以便客户端
//! 调用),但所有 server-only 的 `use` 与全局静态量都单独 gate使 WASM 侧仅保留
//! 函数签名body 被 server 宏剥离)。与 posts 模块的约定一致。
// 与 posts / settings 模块一致Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。 // 与 posts / settings 模块一致Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。
#![allow(clippy::unused_unit, deprecated)] #![allow(clippy::unused_unit, deprecated)]
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use chrono::Utc;
use dioxus::prelude::*; use dioxus::prelude::*;
use tokio::sync::Semaphore;
// 共享数据类型在函数签名中出现,双目标可见,不 gate。
use crate::api::code_runner::{ExecRequest, ExecResult, ExecStatus, ExecTask};
// server-only 辅助模块与依赖:仅在 server function body被宏剥离到 WASM 之外)内使用。
#[cfg(feature = "server")]
use crate::api::code_runner::languages::{is_supported_lang, LANGUAGES}; use crate::api::code_runner::languages::{is_supported_lang, LANGUAGES};
#[cfg(feature = "server")]
use crate::api::code_runner::progress::{ use crate::api::code_runner::progress::{
gc_old_tasks, insert_task, update_task_result, update_task_stage, EXEC_TASKS, gc_old_tasks, insert_task, update_task_result, update_task_stage, EXEC_TASKS,
}; };
use crate::api::code_runner::{ExecRequest, ExecResult, ExecStatus, ExecTask}; #[cfg(feature = "server")]
use crate::api::rate_limit::{check_code_exec_limit, get_client_ip}; use crate::api::rate_limit::{check_code_exec_limit, get_client_ip};
#[cfg(feature = "server")]
use crate::infra::docker::run_in_container; use crate::infra::docker::run_in_container;
#[cfg(feature = "server")]
use crate::infra::runner_config::{clamp_limits, RUNNER_CONFIG}; use crate::infra::runner_config::{clamp_limits, RUNNER_CONFIG};
#[cfg(feature = "server")]
use std::sync::{Arc, LazyLock};
#[cfg(feature = "server")]
use std::time::Duration;
#[cfg(feature = "server")]
use tokio::sync::Semaphore;
/// 并发容器控制信号量,限制同时在跑的容器数量(`CODE_RUNNER_MAX_CONCURRENT`)。 /// 并发容器控制信号量,限制同时在跑的容器数量(`CODE_RUNNER_MAX_CONCURRENT`)。
#[cfg(feature = "server")]
pub static RUNNER_SEMAPHORE: LazyLock<Arc<Semaphore>> = pub static RUNNER_SEMAPHORE: LazyLock<Arc<Semaphore>> =
LazyLock::new(|| Arc::new(Semaphore::new(RUNNER_CONFIG.max_concurrent))); LazyLock::new(|| Arc::new(Semaphore::new(RUNNER_CONFIG.max_concurrent)));
/// 从 FullstackContext 提取客户端 IP无上下文时退回 "unknown")。 /// 从 FullstackContext 提取客户端 IP无上下文时退回 "unknown")。
#[cfg(feature = "server")]
fn client_ip() -> String { fn client_ip() -> String {
match dioxus::fullstack::FullstackContext::current() { match dioxus::fullstack::FullstackContext::current() {
Some(ctx) => { Some(ctx) => {
@ -107,8 +122,7 @@ pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
.unwrap_or_else(|| lang_def.default_limits.clone()); .unwrap_or_else(|| lang_def.default_limits.clone());
let final_limits = clamp_limits(base_limits, lang_def.allow_network); let final_limits = clamp_limits(base_limits, lang_def.allow_network);
let start_time = Utc::now(); let start_time = chrono::Utc::now();
let res = run_in_container( let res = run_in_container(
&lang_def.image, &lang_def.image,
&lang_def.run_cmd, &lang_def.run_cmd,
@ -117,8 +131,7 @@ pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
final_limits, final_limits,
) )
.await; .await;
let duration_ms = (chrono::Utc::now() - start_time)
let duration_ms = (Utc::now() - start_time)
.num_milliseconds() .num_milliseconds()
.max(0) as u64; .max(0) as u64;
@ -155,7 +168,7 @@ pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
}; };
let exec_res = ExecResult { let exec_res = ExecResult {
status: status.clone(), status: status.clone(),
stdout: "".to_string(), stdout: String::new(),
stderr: if is_timeout { stderr: if is_timeout {
"执行超时".to_string() "执行超时".to_string()
} else { } else {

View File

@ -71,10 +71,10 @@ pub struct ExecTask {
pub result: Option<ExecResult>, pub result: Option<ExecResult>,
} }
// 子模块在后续任务中实现: // execute.rs 含 server function需对双目标可见不能 cfg-gate其 server-only
#[cfg(feature = "server")] // 依赖在文件内单独 gate。languages / progress 是纯 server 辅助,整体 gate。
pub mod execute;
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub mod languages; pub mod languages;
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub mod progress; pub mod progress;
pub mod execute;

View File

@ -0,0 +1,10 @@
//! 代码运行器Code Runner组件。
//!
//! 提供可运行代码块的交互 UI显示源码、运行按钮、运行阶段、输出区。
//! 点击 Run 时调用 `StartExec` server function 提交,轮询 `GetExecResult` 直到终态。
//!
//! CodeMirror 编辑器实例由调用方(阅读器扫描 / 后台试运行)在 WASM 端按容器 id
//! 挂载;本组件只负责布局、状态机与轮询,与编辑器桥接保持解耦。
pub mod runner;
pub use runner::CodeRunner;

View File

@ -0,0 +1,191 @@
//! CodeRunner 组件实现:源码 + 运行按钮 + 轮询 + 输出区。
use dioxus::prelude::*;
use crate::api::code_runner::execute::{get_exec_result, start_exec};
use crate::api::code_runner::{ExecRequest, ExecStatus};
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());
// 为每个实例生成稳定的容器 idCodeMirror 容器,由调用方在 WASM 端挂载)。
// use_id 在 Dioxus 0.7 提供 scope 内稳定的 id退回 use_hook 保证只算一次。
let container_id = use_hook(|| {
format!(
"code-runner-{}",
// 简单用一次性随机后缀;非安全场景,避免额外依赖。
now_pseudo_unique()
)
});
// 提前克隆一份给 run 闭包使用,避免 move 闭包抢走 rsx! 仍要读的 source/language。
let run_source = source.clone();
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());
let req = ExecRequest {
language: run_language.clone(),
source: run_source.clone(),
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: "code-runner-container border border-base-300 rounded-xl overflow-hidden my-4 bg-base-100",
div { class: "flex justify-between items-center bg-base-200 px-3 py-1 text-sm border-b border-base-300",
span { class: "font-mono font-bold text-base-content/80", "{language}" }
button {
class: "btn btn-xs btn-primary rounded-lg",
disabled: running(),
onclick: run_code,
if running() {
span { class: "loading loading-spinner loading-xs mr-1" }
"{stage()}"
} else {
"Run"
}
}
}
// CodeMirror 容器:调用方(阅读器扫描 / 后台试运行)在 WASM 端按此 id 挂载编辑器。
// 服务端渲染时仅展示源码文本,避免在 SSR 阶段拉起 JS 编辑器。
div {
id: "{container_id}",
class: "code-runner-editor min-h-[100px] font-mono text-sm",
// 源码以 data 属性承载,供挂载脚本读取初始化(避免 SSR 时把源码当 HTML 解析)。
"data-source": "{source}",
}
if !output().is_empty() {
div { class: "output-area bg-neutral text-neutral-content p-3 rounded-lg m-2 font-mono text-xs whitespace-pre-wrap break-all",
div { class: "text-gray-400 border-b border-gray-600 pb-1 mb-1 flex justify-between items-center",
span { "Console Output" }
span { class: "text-gray-500", "{exit_info()}" }
}
{output()}
}
}
if !error_msg().is_empty() {
div { class: "px-3 pb-2 text-xs text-error",
{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)
}

View File

@ -9,6 +9,8 @@
pub mod admin_layout; pub mod admin_layout;
/// 后台页面骨架屏组件。 /// 后台页面骨架屏组件。
pub mod admin_skeleton; pub mod admin_skeleton;
/// 代码运行器组件(可运行代码块 UI
pub mod code_runner;
/// 评论相关组件。 /// 评论相关组件。
pub mod comments; pub mod comments;
/// 空状态组件(无数据时的插画配图 + 文案)。 /// 空状态组件(无数据时的插画配图 + 文案)。