yggdrasil/src/pages/admin/system/sql_console.rs
xfy c6cb401522
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
refactor(admin): 拆分 system.rs 为 system/ 目录(按 tab 分文件)
pages/admin/system.rs 单文件 1792 行装 5 个 tab(数据库状态/服务器状态/SQL控制台/导出/备份恢复),按已有 api/database/ 的对称模式拆成 system/ 目录。

关键前提(已验证):5 个 tab 状态完全独立、互不共享,切换时父组件用 key 强制卸载,零跨文件状态耦合。

拆分结构(与 api/database/mod.rs 全路径风格对称):
- system/mod.rs: SystemTab enum + System 入口 + format_bytes(pub(super),3 tab 共用) + tests
- system/db_status.rs: DbStatusTab
- system/server_status.rs: ServerStatusTab + format_uptime(仅它用)
- system/sql_console.rs: SqlConsoleTab(codemirror_bridge 依赖)
- system/export.rs: ExportTab + urlencode(wasm32)
- system/backup.rs: BackupTab + BackupRow + urlencode_dl(自包含,不再跨文件依赖 export.rs)

路由侧零改动: admin/mod.rs 的 pub mod system + pub use system::System 不变(Rust 自动认 system/mod.rs)。双目标编译 + clippy 严格 + dx check + SystemTab 测试全部通过。
2026-07-15 18:28:39 +08:00

365 lines
16 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.

//! SQL 控制台 tab。
use dioxus::prelude::*;
use crate::components::ui::LoadingButton;
/// SQL 控制台 tabCodeMirror 编辑器SQL 高亮/补全/Vim+ 4 道护栏 + 结果表 + EXPLAIN。
///
/// 护栏 4前端二次确认提交写操作前弹窗确认。
#[allow(non_snake_case)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
pub(super) fn SqlConsoleTab() -> Element {
#[cfg(target_arch = "wasm32")]
use crate::api::database::schema::get_db_schema;
use crate::api::database::sql_console::SqlResult;
#[cfg(target_arch = "wasm32")]
use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts};
#[cfg(target_arch = "wasm32")]
use crate::codemirror_bridge;
use crate::components::sql_result_table::SqlResultTable;
use crate::components::ui::ADMIN_TABLE_CLASS;
// use_resolved_theme 两种构建都用resolved() 在 wasm 块内消费,非 wasm 仅引用避免警告。
use crate::theme::use_resolved_theme;
#[cfg(target_arch = "wasm32")]
use crate::theme::ResolvedTheme;
let resolved = use_resolved_theme();
let sql_text = use_signal(String::new);
let mut result = use_signal(|| Option::<SqlResult>::None);
let mut error = use_signal(|| Option::<String>::None);
let mut running = use_signal(|| false);
// 选项 toggles
let mut with_explain = use_signal(|| false);
let mut allow_multi = use_signal(|| false);
let mut confirm_dangerous = use_signal(|| false);
// resolved/sql_text 仅在 wasm32 块内使用server 构建时显式引用避免 unused 警告。
#[cfg(not(target_arch = "wasm32"))]
let _ = (&resolved, &sql_text);
// CodeMirror 实例句柄(仅 WASM
#[cfg(target_arch = "wasm32")]
let mut editor_handle: Signal<Option<codemirror_bridge::EditorHandle>> = use_signal(|| None);
// 执行 SQL 的核心逻辑:抽成独立闭包,按钮 onclick 与 Ctrl+Enter 快捷键共用。
// 所有捕获都是 Copy 的 Signal故每次调用读取最新值闭包本身是 Fn 可重复调用。
// 用两个独立闭包execute_for_editor / run_sql避免 move 单一所有权冲突——
// 它们捕获相同的 Copy signal行为完全等价。
#[cfg(target_arch = "wasm32")]
let mut execute_for_editor = move || {
running.set(true);
error.set(None);
let sql = sql_text.read().clone();
let opts = ExecuteSqlOpts {
allow_multi: allow_multi(),
confirm_dangerous: confirm_dangerous(),
with_explain: with_explain(),
};
// 护栏 4写操作前端二次确认简单判断含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
let lower = sql.to_lowercase();
let looks_write = [
"update ",
"delete ",
"insert ",
"alter ",
"drop ",
"truncate ",
"create ",
]
.iter()
.any(|k| lower.contains(k));
if looks_write && !confirm_dangerous() {
// 简单提示;真正的高危放行靠 confirm_dangerous 开关
let confirmed = web_sys::window().and_then(|w| {
w.confirm_with_message(
"这是写操作(修改数据/结构),确认执行?\n\n高危操作DROP/TRUNCATE/ALTER还需勾选「我了解后果」。",
)
.ok()
});
if confirmed != Some(true) {
running.set(false);
return;
}
}
spawn(async move {
match execute_sql(sql, opts).await {
Ok(r) => result.set(Some(r)),
Err(e) => error.set(Some(e.to_string())),
}
running.set(false);
});
};
// 捕获当前 scope idCtrl+Enter 从 CodeMirror 的 JS 事件处理中触发时,
// dioxus scope stack 为空spawn() 内部 current_scope_id().unwrap() 会 panic。
// 在 effect 里用 Runtime::in_scope 重建 scope 上下文,保证 spawn 找得到 origin。
#[cfg(target_arch = "wasm32")]
let scope_id = dioxus::core::Runtime::current()
.try_current_scope_id()
.unwrap_or(dioxus::core::ScopeId::ROOT);
// 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。
#[cfg(target_arch = "wasm32")]
{
use wasm_bindgen::closure::Closure;
use_effect(move || {
if editor_handle.read().is_some() {
return;
}
let mut text = sql_text;
let on_change = Closure::new(move |v: String| {
text.set(v);
});
let on_ready = Closure::new(|| {});
// Ctrl/Cmd+Enter 触发执行(与按钮共用同一套资源/护栏逻辑)。
// 从 CodeMirror JS 事件触发时无 dioxus scope必须用 in_scope 重建。
// 刻意用闭包 `|| execute_for_editor()` 而非直接传 `execute_for_editor`
// 后者会 move 闭包值进 in_scope导致第二次快捷键触发时 use-after-move
// Closure::new 要求 Fn 可重复调用)。闭包包装走借用调用,每次都读最新值。
#[allow(clippy::redundant_closure)]
let on_run_shortcut = Closure::new(move || {
dioxus::core::Runtime::current().in_scope(scope_id, || execute_for_editor());
});
let theme_name = if resolved() == ResolvedTheme::Dark {
"dark"
} else {
"light"
};
let opts = codemirror_bridge::EditorOptions::new();
opts.set_language("sql");
opts.set_theme(theme_name);
opts.set_vim(true);
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("sql-editor", &opts) {
let handle = codemirror_bridge::EditorHandle::new(
inst,
on_change,
on_ready,
on_run_shortcut,
);
editor_handle.set(Some(handle));
}
// 异步拉取 schema 注入补全
spawn(async move {
if let Ok(schema) = get_db_schema().await {
// SqlSchema 是 serde 类型:先用 serde_wasm_bindgen 转 JsValue
// 再传给 CodeMirrorextern set_schema 只接受 &JsValue
if let Ok(js) = serde_wasm_bindgen::to_value(&schema) {
if let Some(h) = editor_handle.read().as_ref() {
h.instance().set_schema(&js);
}
}
}
});
});
// 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。
// resolved 是 theme + system_dark 的派生 memo任一变化都自动触发此 effect。
use_effect(move || {
let r = resolved();
if let Some(h) = editor_handle.read().as_ref() {
h.instance().set_theme(if r == ResolvedTheme::Dark {
"dark"
} else {
"light"
});
}
});
}
// 执行 SQL按钮 onclick 用的闭包。wasm 下复制 execute_for_editor 的逻辑,
// server 下仅复位 running无网络层。两处闭包捕获相同的 Copy signal。
let mut run_sql = move || {
running.set(true);
error.set(None);
#[cfg(target_arch = "wasm32")]
{
let sql = sql_text.read().clone();
let opts = ExecuteSqlOpts {
allow_multi: allow_multi(),
confirm_dangerous: confirm_dangerous(),
with_explain: with_explain(),
};
// 护栏 4写操作前端二次确认简单判断含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
let lower = sql.to_lowercase();
let looks_write = [
"update ",
"delete ",
"insert ",
"alter ",
"drop ",
"truncate ",
"create ",
]
.iter()
.any(|k| lower.contains(k));
if looks_write && !confirm_dangerous() {
// 简单提示;真正的高危放行靠 confirm_dangerous 开关
let confirmed = web_sys::window().and_then(|w| {
w.confirm_with_message(
"这是写操作(修改数据/结构),确认执行?\n\n高危操作DROP/TRUNCATE/ALTER还需勾选「我了解后果」。",
)
.ok()
});
if confirmed != Some(true) {
running.set(false);
return;
}
}
spawn(async move {
match execute_sql(sql, opts).await {
Ok(r) => result.set(Some(r)),
Err(e) => error.set(Some(e.to_string())),
}
running.set(false);
});
}
#[cfg(not(target_arch = "wasm32"))]
{
running.set(false);
}
};
let current_result = result.read().clone();
let current_error = error.read().clone();
let elapsed = current_result.as_ref().map(|r| r.elapsed_ms).unwrap_or(0);
let affected = current_result
.as_ref()
.map(|r| r.affected_rows)
.unwrap_or(0);
let stmt_type = current_result
.as_ref()
.map(|r| r.statement_type.clone())
.unwrap_or_default();
let truncated = current_result
.as_ref()
.map(|r| r.truncated)
.unwrap_or(false);
let explain = current_result.as_ref().and_then(|r| r.explain.clone());
rsx! {
div { class: "space-y-5",
// 编辑器卡片:标题栏 + CodeMirror 一体化。
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)]",
"SQL"
}
}
span { class: "text-xs text-[var(--color-paper-tertiary)] font-mono",
"⌘↵ 执行"
}
}
// CodeMirror 容器:用 flex 让 .cm-editor(flex:1) 填满整个高度,
// 避免编辑器塌缩到内容高度、底部透出容器背景造成上下色差。
div {
id: "sql-editor",
style: "min-height: 280px; display: flex; flex-direction: column",
}
}
// 工具条:执行按钮 + 普通/危险选项分层
div { class: "flex flex-wrap items-center gap-x-4 gap-y-3",
LoadingButton {
label: "执行".to_string(),
loading: running(),
onclick: move |_| run_sql(),
}
// 普通选项
label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer",
input {
r#type: "checkbox",
class: "rounded border-[var(--color-paper-border)]",
checked: with_explain(),
onchange: move |e| with_explain.set(e.checked()),
}
"EXPLAIN"
}
label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer",
input {
r#type: "checkbox",
class: "rounded border-[var(--color-paper-border)]",
checked: allow_multi(),
onchange: move |e| allow_multi.set(e.checked()),
}
"允许多语句"
}
// 危险选项:视觉分隔 + 红色语义
span { class: "w-px h-4 bg-[var(--color-paper-border)] mx-1" }
label { class: "flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 cursor-pointer",
input {
r#type: "checkbox",
class: "rounded border-red-300 dark:border-red-700",
checked: confirm_dangerous(),
onchange: move |e| confirm_dangerous.set(e.checked()),
}
"我了解后果DROP/TRUNCATE/ALTER"
}
}
// 错误
if let Some(err) = current_error {
div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-2xl p-3 text-sm text-red-700 dark:text-red-300",
"{err}"
}
}
// 结果摘要(徽章式)
if current_result.is_some() {
div { class: "flex flex-wrap items-center gap-2",
if !stmt_type.is_empty() {
span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium",
"{stmt_type}"
}
}
if affected > 0 {
span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium",
"影响 {affected} 行"
}
}
span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium",
"{elapsed}ms"
}
if truncated {
span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs font-medium",
"结果超过 500 行,已截断"
}
}
}
}
// 结果表格
if let Some(res) = &current_result {
if !res.rows.is_empty() {
SqlResultTable { result: res.clone() }
} else if res.statement_type.to_uppercase().contains("SELECT") {
// 有结果但无行SELECT 返回空集的友好提示
div { class: "{ADMIN_TABLE_CLASS} px-4 py-8 text-center text-sm text-[var(--color-paper-tertiary)]",
"查询成功,无返回行"
}
}
}
// EXPLAIN 输出
if let Some(explain) = explain {
div { class: "{ADMIN_TABLE_CLASS}",
div { class: "px-4 py-2 border-b border-paper-border text-sm font-medium text-paper-primary",
"执行计划"
}
pre { class: "p-4 text-xs font-mono text-paper-secondary overflow-x-auto whitespace-pre m-0",
"{explain}"
}
}
}
}
}
}