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 测试全部通过。
365 lines
16 KiB
Rust
365 lines
16 KiB
Rust
//! SQL 控制台 tab。
|
||
|
||
use dioxus::prelude::*;
|
||
|
||
use crate::components::ui::LoadingButton;
|
||
|
||
/// SQL 控制台 tab:CodeMirror 编辑器(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 id:Ctrl+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,
|
||
// 再传给 CodeMirror(extern 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) = ¤t_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}"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|