xfy 886eeca915
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
style: 全项目 rsx! 格式规范化(cargo fmt)
2026-07-15 17:49:03 +08:00

805 lines
36 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 组件实现:源码 + 运行按钮 + SSE 流式输出 + xterm.js 终端。
//!
//! 编辑器挂载:组件在 WASM 端按自身 `container_id` 调用
//! `codemirror_bridge::get_module().create(...)` 挂载 CodeMirror`onChange`
//! 回写到内部 `source_signal``use_drop` 时销毁实例。范式镜像 SQL 控制台
//!`src/pages/admin/system.rs`)与 Tiptap 编辑器(`src/pages/admin/write.rs`)。
//!
//! 输出渲染WASM 端按 `output_container_id` 调用 `xterm_bridge::get_module().create(...)`
//! 挂载 xterm.js 终端(输出专用,无 stdinSSE stdout/stderr 事件实时写入。
//! SSE 不可用时降级到轮询 get_exec_result整段写入终端writeAll
use dioxus::prelude::*;
#[cfg(target_arch = "wasm32")]
use crate::api::code_runner::execute::start_exec_stream;
#[cfg(not(target_arch = "wasm32"))]
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;
#[cfg(not(target_arch = "wasm32"))]
use crate::utils::time::sleep_ms;
/// 轮询间隔(毫秒)。仅 server 端 run_code 占位路径使用。
#[cfg(not(target_arch = "wasm32"))]
const POLL_INTERVAL_MS: u32 = 500;
/// 轮询最大次数兜底,避免任务卡在 Running 状态时无限轮询。
#[cfg(not(target_arch = "wasm32"))]
const MAX_POLLS: u32 = 240; // 500ms * 240 = 120s 上限
/// 代码运行器组件。
///
/// Props
/// - `source`:初始源码(首次挂载用于初始化编辑器;之后编辑器内容是唯一真源)。
/// - `language`语言标识python / node 等)。
/// - `overrides`:可选资源限制覆盖。
/// - `instance_id`:实例在父级片段序列中的索引,用作 CodeMirror 容器 id 后缀。
/// 必须是 **SSR/hydration 确定性**的值(如父组件 `for (i, ..)` 的索引 `i`)——
/// Dioxus hydration 不传递 use_hook 状态,任何在 use_hook 内基于运行时状态
/// (时间戳 / 随机 / ScopeId生成的 id在 SSR 与 hydration 两端会不一致,
/// 导致 CodeMirror `create()` 在 hydration 时找不到 SSR 渲染的容器元素。
///
/// `mut` 信号在 WASM 与 server 两套 run_code#[cfg] 分支)中的 .set() 调用点不同,
/// 导致任一目标构建都有一组 mut binding 被判为 unused。两端都 allow 放行。
#[component]
#[allow(unused_mut)]
pub fn CodeRunner(
source: String,
language: String,
overrides: Option<ResourceLimits>,
instance_id: usize,
) -> 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);
// 输出区可见性:点运行后置 true控制输出区按需出现而非页面加载就显示空区
let mut show_output = use_signal(|| false);
// 是否已收到首个输出 chunk收到后骨架屏消失露出终端实时渲染。
let mut has_output = use_signal(|| false);
// 编辑器内容的唯一真源;初始化为 prop 值。
let mut source_signal = use_signal(|| source.clone());
// 监听 prop source 的变更。
// 由于 props 不是 signal用辅助 signal 记录并同步它的最新值,以响应外部(如切换语言)的主动更新。
let mut source_prop_signal = use_signal(|| source.clone());
if source != *source_prop_signal.read() {
source_prop_signal.set(source.clone());
}
// CodeMirror 容器 id直接由确定性 prop 派生(不进 use_hook
// instance_id 由父组件从纯函数片段解析的索引传入SSR 与 hydration 同一 content_html
// → 同一片段序列 → 同一索引 → 同一 id故 hydration 时 create() 能找到 SSR 渲染的容器。
let container_id = format!("code-runner-{instance_id}");
// xterm.js 输出终端容器 id同样的确定性派生逻辑SSR 与 hydration 一致。
let output_container_id = format!("code-runner-output-{instance_id}");
// xterm.js 终端实例句柄(仅 WASM声明在 cfg block 外的组件作用域,
// 使 run_code 的 WASM 版闭包能捕获它。server 构建整行不存在。
#[cfg(target_arch = "wasm32")]
let mut term_handle: Signal<Option<crate::xterm_bridge::TerminalHandle>> = use_signal(|| None);
// 编辑器是否已挂载就绪。声明在 cfg 块外,使 SSR 端也能读取:
// SSR 与 hydration 完成前为 false → 容器内渲染骨架屏CodeMirror 挂载后置 true
// → 骨架屏从 DOM 移除,露出真实编辑器。
let mut editor_ready = use_signal(|| false);
// Vim 模式状态(通过 localStorage 持久化偏好,默认开启)
let mut vim_enabled = use_signal(|| {
#[cfg(target_arch = "wasm32")]
{
if let Some(window) = web_sys::window() {
if let Ok(Some(storage)) = window.local_storage() {
if let Ok(Some(val)) = storage.get_item("yggdrasil-code-runner-vim") {
return val == "true";
}
}
}
}
true
});
let toggle_vim = move |_| {
let next = !vim_enabled();
vim_enabled.set(next);
#[cfg(target_arch = "wasm32")]
{
if let Some(window) = web_sys::window() {
if let Ok(Some(storage)) = window.local_storage() {
let _ = storage.set_item("yggdrasil-code-runner-vim", &next.to_string());
}
}
}
};
// —— 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);
// 在 cfg 块顶层(渲染路径)取主题 memomove 进下方 effect 闭包;闭包内只
// 调用 memoresolved())读值——调用 memo 本身不是 hookuse_resolved_theme
// 这个 hook 必须在组件体顶层调用,不能放进 use_effect 闭包dx check 会报)。
let resolved_theme = use_resolved_theme();
// 首次挂载:构造 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(|| {});
// CodeRunner 不使用 Ctrl+Enter 运行快捷键(它有自己的运行按钮),
// 但 EditorHandle 签名要求该闭包,传 no-op 满足生命周期。
let on_run_shortcut = Closure::new(|| {});
let theme_name = if resolved_theme() == ResolvedTheme::Dark {
"dark"
} else {
"light"
};
let opts = codemirror_bridge::EditorOptions::new();
opts.set_language(&mount_language);
opts.set_theme(theme_name);
opts.set_vim(*vim_enabled.read());
opts.set_value(&source_signal.read());
opts.set_on_change(&on_change);
opts.set_on_ready(&on_ready);
opts.set_on_run_shortcut(&on_run_shortcut);
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,
on_run_shortcut,
);
editor_handle.set(Some(handle));
editor_ready.set(true);
}
});
// 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。
//
// VT 动画期间跳过:手动点击主题按钮时,__startThemeTransition 已在 VT 回调内
// 通过 'yggdrasil:theme-change' 事件同步调了 setTheme(出现在 NEW 快照里)。
// 但 use_effect 在 theme.set(next) 后立即触发——早于 VT 回调(异步),会直接改
// 实时 DOM 的编辑器背景。VT 动画播的是伪元素快照,实时 DOM 改动会穿透伪元素,
// 表现为「圆形还没展开到代码块,代码块就整体瞬切」。is-theme-transitioning
// 期间跳过,让 VT 事件负责换肤;动画结束后此 effect 会因 resolved 信号变化重跑
// (此时 is-theme-transitioning 已移除),做一次幂等的兜底同步。
use_effect(move || {
#[cfg(target_arch = "wasm32")]
{
let transitioning = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.document_element())
.map(|el| el.class_list().contains("is-theme-transitioning"))
.unwrap_or(false);
if transitioning {
return;
}
}
if let Some(h) = editor_handle.read().as_ref() {
h.instance()
.set_theme(if resolved_theme() == ResolvedTheme::Dark {
"dark"
} else {
"light"
});
}
});
// 监听 vim 模式切换并同步。
use_effect(move || {
let enabled = vim_enabled();
if let Some(h) = editor_handle.read().as_ref() {
h.instance().set_vim(enabled);
}
});
// source prop 外部变更(如 admin 页面切换语言重置示例代码)同步到
// signal + 编辑器。通过 source_prop_signal 订阅其变化。
// 此 effect 仅读取 source_prop_signal不读取 source_signal避免用户编辑时误触重置回环。
use_effect(move || {
let new_val = source_prop_signal.read().clone();
source_signal.set(new_val.clone());
if let Some(h) = editor_handle.read().as_ref() {
h.instance().set_value(&new_val);
}
});
// 组件卸载时销毁 CodeMirror 实例EditorHandle::drop → instance.destroy
use_drop(move || {
editor_handle.set(None);
editor_ready.set(false);
});
}
// —— xterm.js 终端挂载(仅 WASM输出专用——
// 范式镜像 CodeMirror 挂载get_module().create() → TerminalHandleuse_drop 销毁。
#[cfg(target_arch = "wasm32")]
{
use crate::theme::{use_resolved_theme, ResolvedTheme};
use crate::xterm_bridge;
use wasm_bindgen::closure::Closure;
// 在 cfg 块顶层(渲染路径)取主题 memomove 进下方 effect 闭包;闭包内只
// 调用 memoresolved_theme())读值——调用 memo 本身不是 hook。
let resolved_theme = use_resolved_theme();
// 首次挂载:构造 onReady 闭包 + XtermOptionscreate 后存进 term_handle。
// 订阅 show_output输出区在 show_output 变 true用户点运行后才渲染进 DOM
// 容器此前不存在;读 show_output 建立订阅,使其变 true 时重跑本 effect 完成挂载。
let mount_container_id = output_container_id.clone();
use_effect(move || {
if term_handle.read().is_some() {
return; // 防重复 init
}
if !show_output() {
return; // 输出区未显示,容器不在 DOM等 show_output 变 true 再挂载
}
let on_ready = Closure::new(|| {});
let theme_name = if resolved_theme() == ResolvedTheme::Dark {
"dark"
} else {
"light"
};
let opts = xterm_bridge::XtermOptions::new();
opts.set_theme(theme_name);
opts.set_font_size(13);
opts.set_on_ready(&on_ready);
if let Ok(Some(inst)) = xterm_bridge::get_module().create(&mount_container_id, &opts) {
let handle = xterm_bridge::TerminalHandle::new(inst, on_ready);
term_handle.set(Some(handle));
}
});
// 主题切换时同步终端主题。
// VT 动画期间跳过(同 CodeMirror 的 use_effect,见上方注释)。
use_effect(move || {
#[cfg(target_arch = "wasm32")]
{
let transitioning = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.document_element())
.map(|el| el.class_list().contains("is-theme-transitioning"))
.unwrap_or(false);
if transitioning {
return;
}
}
if let Some(h) = term_handle.read().as_ref() {
h.instance()
.set_theme(if resolved_theme() == ResolvedTheme::Dark {
"dark"
} else {
"light"
});
}
});
// 组件卸载时销毁终端TerminalHandle::drop → instance.destroy
use_drop(move || {
term_handle.set(None);
});
}
// —— run_code同步段取 signal 当前值move 进 spawn ——
let run_language = language.clone();
let run_overrides = overrides.clone();
// WASM 版start_exec_stream → EventSource SSE 实时写入 xterm.js 终端;
// SSE 不可用时降级到轮询 get_exec_resultwriteAll 整段写入)。
#[cfg(target_arch = "wasm32")]
let run_code = {
let mut running = running;
let mut stage = stage;
let mut output = output;
let mut exit_info = exit_info;
let mut error_msg = error_msg;
let mut source_signal = source_signal;
let mut term_handle = term_handle;
let mut show_output = show_output;
let mut has_output = has_output;
let run_language = run_language.clone();
let run_overrides = run_overrides.clone();
move |_| {
if running() {
return;
}
running.set(true);
show_output.set(true);
has_output.set(false);
stage.set("提交中...".to_string());
error_msg.set(String::new());
// 清空终端,准备新一轮输出。
if let Some(h) = term_handle.read().as_ref() {
h.instance().clear();
}
let run_source = source_signal.read().clone();
let req = ExecRequest {
language: run_language.clone(),
source: run_source,
overrides: run_overrides.clone(),
};
spawn(async move {
let task_id = match start_exec_stream(req).await {
Ok(id) => id,
Err(e) => {
running.set(false);
let msg = e.to_string();
stage.set(msg.clone());
error_msg.set(msg);
return;
}
};
stage.set("运行中".to_string());
// 启动 SSE用原生 EventSource 消费流,回调写入终端与 signal。
// 若 EventSource 创建失败,降级到轮询。
if sse_consumer::start_sse(
&task_id,
&term_handle,
&mut running,
&mut exit_info,
&mut error_msg,
&mut has_output,
)
.is_err()
{
// 降级轮询
sse_consumer::poll_result(
&task_id,
&mut running,
&mut stage,
&mut output,
&mut exit_info,
&mut error_msg,
&term_handle,
&mut has_output,
)
.await;
}
});
}
};
// Server 版(占位,组件在前端运行):保留轮询逻辑使双目标都能编译。
#[cfg(not(target_arch = "wasm32"))]
let run_code = {
let mut running = running;
let mut stage = stage;
let mut output = output;
let mut exit_info = exit_info;
let mut error_msg = error_msg;
let mut source_signal = source_signal;
let mut show_output = show_output;
let run_language = run_language.clone();
let run_overrides = run_overrides.clone();
move |_| {
if running() {
return;
}
running.set(true);
show_output.set(true);
stage.set("提交中...".to_string());
output.set(String::new());
exit_info.set(String::new());
error_msg.set(String::new());
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)] my-[var(--content-gap-paper)]",
// 顶栏:语言标签 + 运行按钮
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-3",
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: format!(
"text-[10px] px-1.5 py-0.5 rounded border transition cursor-pointer {}",
if vim_enabled() {
"bg-[var(--color-paper-accent)]/15 text-[var(--color-paper-accent)] border-[var(--color-paper-accent)]/30 font-semibold"
} else {
"bg-transparent text-[var(--color-paper-tertiary)] border-[var(--color-paper-border)] hover:text-[var(--color-paper-primary)]"
},
),
onclick: toggle_vim,
"Vim"
}
}
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 {
"运行"
}
}
}
div {
id: "{container_id}",
class: "code-runner-editor font-mono text-sm relative",
// 骨架屏CodeMirror 尚未挂载就绪时SSR + hydration 完成前)显示。
// editor_ready 由挂载 effect 置 true 后,此处 if 分支消失,骨架屏从 DOM 移除。
// 用绝对定位覆盖在(始终存在的)容器上方,不影响 CodeMirror 的 getElementById 挂载。
if !editor_ready() {
div { class: "absolute inset-0 flex flex-col justify-center gap-2.5 px-4 py-4 bg-[var(--color-paper-code-block)]",
// 代码行占位条:递减宽度模拟代码缩进,贴合等宽字体语境。
div {
class: "h-3 rounded bg-[var(--color-paper-tertiary)]/25 dark:bg-gray-600/50 animate-pulse",
style: "width: 90%",
}
div {
class: "h-3 rounded bg-[var(--color-paper-tertiary)]/25 dark:bg-gray-600/50 animate-pulse",
style: "width: 70%",
}
div {
class: "h-3 rounded bg-[var(--color-paper-tertiary)]/25 dark:bg-gray-600/50 animate-pulse",
style: "width: 55%",
}
div {
class: "h-3 rounded bg-[var(--color-paper-tertiary)]/25 dark:bg-gray-600/50 animate-pulse",
style: "width: 85%",
}
div {
class: "h-3 rounded bg-[var(--color-paper-tertiary)]/25 dark:bg-gray-600/50 animate-pulse",
style: "width: 40%",
}
}
}
}
// 输出区用户点运行后才显示show_output
// running 时显示骨架屏占位(等首个 chunk 到达);有内容后 xterm 终端渲染。
if show_output() {
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()}" }
}
div {
id: "{output_container_id}",
class: "px-2 py-2 bg-[var(--color-paper-code-block)] max-h-80 overflow-hidden text-xs relative min-h-24",
// running 且尚未收到首个 chunk 时,显示骨架屏占位。
if running() && !has_output() {
div { class: "absolute inset-0 flex flex-col justify-center gap-2.5 px-4 py-4 bg-[var(--color-paper-code-block)]",
div {
class: "h-3 rounded bg-[var(--color-paper-tertiary)]/25 dark:bg-gray-600/50 animate-pulse",
style: "width: 70%",
}
div {
class: "h-3 rounded bg-[var(--color-paper-tertiary)]/25 dark:bg-gray-600/50 animate-pulse",
style: "width: 55%",
}
div {
class: "h-3 rounded bg-[var(--color-paper-tertiary)]/25 dark:bg-gray-600/50 animate-pulse",
style: "width: 85%",
}
}
}
}
}
}
// 错误提示
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(),
}
}
// —— WASM-only 辅助函数SSE 消费 + 轮询兜底 ——
// start_sse 用原生 EventSource 消费 SSE 流,回调写入 xterm.js 终端与 signal。
// poll_result 是降级路径:轮询 get_exec_result拿到完整结果后 writeAll 写入终端。
#[cfg(target_arch = "wasm32")]
mod sse_consumer {
use dioxus::prelude::*;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{EventSource, MessageEvent};
use crate::api::code_runner::execute::get_exec_result;
use crate::api::code_runner::ExecStatus;
use crate::utils::time::sleep_ms;
use crate::xterm_bridge::TerminalHandle;
/// SSE done 事件的 JSON payload。
#[derive(serde::Deserialize)]
struct DonePayload {
exit_code: Option<i64>,
oom_killed: bool,
timed_out: bool,
duration_ms: u64,
}
/// 启动 EventSource 消费 SSE 流。
///
/// 回调写入 xterm.js 终端stdout/stderr与 signalexit_info/error_msg
/// 返回后 spawn block 的 future 即结束——EventSource 自行维持连接直到 done/error。
///
/// 返回 Err 表示 EventSource 创建失败(如 URL 非法),调用方应降级到轮询。
pub fn start_sse(
task_id: &str,
term_handle: &Signal<Option<TerminalHandle>>,
running: &mut Signal<bool>,
exit_info: &mut Signal<String>,
error_msg: &mut Signal<String>,
has_output: &mut Signal<bool>,
) -> Result<(), JsValue> {
let url = format!("/api/exec/stream?task_id={task_id}");
let es = EventSource::new(&url)?;
// stdout 事件 → 终端 writeStdout + 标记已有输出(骨架屏消失)
let term_for_stdout = *term_handle;
let mut has_output_for_stdout = *has_output;
let on_stdout = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if let Some(s) = e.data().as_string() {
if let Some(h) = term_for_stdout.read().as_ref() {
h.instance().write_stdout(&s);
}
has_output_for_stdout.set(true);
}
});
es.add_event_listener_with_callback("stdout", on_stdout.as_ref().unchecked_ref())?;
on_stdout.forget();
// stderr 事件 → 终端 writeStderr红色+ 标记已有输出
let term_for_stderr = *term_handle;
let mut has_output_for_stderr = *has_output;
let on_stderr = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if let Some(s) = e.data().as_string() {
if let Some(h) = term_for_stderr.read().as_ref() {
h.instance().write_stderr(&s);
}
has_output_for_stderr.set(true);
}
});
es.add_event_listener_with_callback("stderr", on_stderr.as_ref().unchecked_ref())?;
on_stderr.forget();
// done 事件 → 解析终态,设 signal关闭连接
let mut running_clone = *running;
let mut exit_info_clone = *exit_info;
let mut error_msg_clone = *error_msg;
let es_for_done = es.clone();
let on_done = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
let payload: DonePayload = e
.data()
.as_string()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(DonePayload {
exit_code: None,
oom_killed: false,
timed_out: false,
duration_ms: 0,
});
let (info, err) = if payload.timed_out {
(
format!("耗时: {}ms · 状态: 超时", payload.duration_ms),
"超时".to_string(),
)
} else if payload.oom_killed {
(
format!("耗时: {}ms · 状态: 内存超限", payload.duration_ms),
"内存超限".to_string(),
)
} else if payload.exit_code == Some(0) {
(
format!("耗时: {}ms · 状态: 成功", payload.duration_ms),
String::new(),
)
} else {
(
format!(
"耗时: {}ms · 退出码: {} · 运行错误",
payload.duration_ms,
payload.exit_code.unwrap_or(-1)
),
"运行错误".to_string(),
)
};
exit_info_clone.set(info);
error_msg_clone.set(err);
running_clone.set(false);
es_for_done.close();
});
es.add_event_listener_with_callback("done", on_done.as_ref().unchecked_ref())?;
on_done.forget();
// error 事件 → EventSource 连接异常,关闭并标记错误
let mut running_clone = *running;
let mut error_msg_clone = *error_msg;
let es_for_error = es.clone();
let on_error = Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
error_msg_clone.set("连接异常,请重试".to_string());
running_clone.set(false);
es_for_error.close();
});
es.add_event_listener_with_callback("error", on_error.as_ref().unchecked_ref())?;
on_error.forget();
Ok(())
}
/// 轮询兜底路径SSE 不可用时用 get_exec_result 轮询,
/// 拿到完整结果后 writeAll 整段写入终端。
pub async fn poll_result(
task_id: &str,
running: &mut Signal<bool>,
stage: &mut Signal<String>,
output: &mut Signal<String>,
exit_info: &mut Signal<String>,
error_msg: &mut Signal<String>,
term_handle: &Signal<Option<TerminalHandle>>,
has_output: &mut Signal<bool>,
) {
let mut polls = 0u32;
loop {
polls += 1;
sleep_ms(500).await;
match get_exec_result(task_id.to_string()).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,
super::status_label(&res.status)
));
if res.status == ExecStatus::Success {
error_msg.set(String::new());
} else {
error_msg.set(super::status_label(&res.status));
}
// 整段写入终端
if let Some(h) = term_handle.read().as_ref() {
h.instance().write_all(&res.stdout, &res.stderr);
}
has_output.set(true);
}
break;
}
if polls >= 240 {
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;
}
}
}
}
}