Skip to main content

yggdrasil/pages/admin/system/
sql_console.rs

1//! SQL 控制台 tab。
2
3use dioxus::prelude::*;
4
5use crate::components::ui::LoadingButton;
6
7/// SQL 控制台 tab:CodeMirror 编辑器(SQL 高亮/补全/Vim)+ 4 道护栏 + 结果表 + EXPLAIN。
8///
9/// 护栏 4(前端二次确认):提交写操作前弹窗确认。
10#[allow(non_snake_case)]
11#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
12pub(super) fn SqlConsoleTab() -> Element {
13    #[cfg(target_arch = "wasm32")]
14    use crate::api::database::schema::get_db_schema;
15    use crate::api::database::sql_console::SqlResult;
16    #[cfg(target_arch = "wasm32")]
17    use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts};
18    #[cfg(target_arch = "wasm32")]
19    use crate::codemirror_bridge;
20    use crate::components::sql_result_table::SqlResultTable;
21    use crate::components::ui::ADMIN_TABLE_CLASS;
22    // use_resolved_theme 两种构建都用:resolved() 在 wasm 块内消费,非 wasm 仅引用避免警告。
23    use crate::theme::use_resolved_theme;
24    #[cfg(target_arch = "wasm32")]
25    use crate::theme::ResolvedTheme;
26
27    let resolved = use_resolved_theme();
28    let sql_text = use_signal(String::new);
29    let mut result = use_signal(|| Option::<SqlResult>::None);
30    let mut error = use_signal(|| Option::<String>::None);
31    let mut running = use_signal(|| false);
32    // 选项 toggles
33    let mut with_explain = use_signal(|| false);
34    let mut allow_multi = use_signal(|| false);
35    let mut confirm_dangerous = use_signal(|| false);
36    // resolved/sql_text 仅在 wasm32 块内使用;server 构建时显式引用避免 unused 警告。
37    #[cfg(not(target_arch = "wasm32"))]
38    let _ = (&resolved, &sql_text);
39
40    // CodeMirror 实例句柄(仅 WASM)。
41    #[cfg(target_arch = "wasm32")]
42    let mut editor_handle: Signal<Option<codemirror_bridge::EditorHandle>> = use_signal(|| None);
43
44    // 执行 SQL 的核心逻辑:抽成独立闭包,按钮 onclick 与 Ctrl+Enter 快捷键共用。
45    // 所有捕获都是 Copy 的 Signal,故每次调用读取最新值,闭包本身是 Fn 可重复调用。
46    // 用两个独立闭包(execute_for_editor / run_sql)避免 move 单一所有权冲突——
47    // 它们捕获相同的 Copy signal,行为完全等价。
48    #[cfg(target_arch = "wasm32")]
49    let mut execute_for_editor = move || {
50        running.set(true);
51        error.set(None);
52        let sql = sql_text.read().clone();
53        let opts = ExecuteSqlOpts {
54            allow_multi: allow_multi(),
55            confirm_dangerous: confirm_dangerous(),
56            with_explain: with_explain(),
57        };
58        // 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
59        let lower = sql.to_lowercase();
60        let looks_write = [
61            "update ",
62            "delete ",
63            "insert ",
64            "alter ",
65            "drop ",
66            "truncate ",
67            "create ",
68        ]
69        .iter()
70        .any(|k| lower.contains(k));
71        if looks_write && !confirm_dangerous() {
72            // 简单提示;真正的高危放行靠 confirm_dangerous 开关
73            let confirmed = web_sys::window().and_then(|w| {
74                w.confirm_with_message(
75                    "这是写操作(修改数据/结构),确认执行?\n\n高危操作(DROP/TRUNCATE/ALTER)还需勾选「我了解后果」。",
76                )
77                .ok()
78            });
79            if confirmed != Some(true) {
80                running.set(false);
81                return;
82            }
83        }
84        spawn(async move {
85            match execute_sql(sql, opts).await {
86                Ok(r) => result.set(Some(r)),
87                Err(e) => error.set(Some(e.to_string())),
88            }
89            running.set(false);
90        });
91    };
92
93    // 捕获当前 scope id:Ctrl+Enter 从 CodeMirror 的 JS 事件处理中触发时,
94    // dioxus scope stack 为空,spawn() 内部 current_scope_id().unwrap() 会 panic。
95    // 在 effect 里用 Runtime::in_scope 重建 scope 上下文,保证 spawn 找得到 origin。
96    #[cfg(target_arch = "wasm32")]
97    let scope_id = dioxus::core::Runtime::current()
98        .try_current_scope_id()
99        .unwrap_or(dioxus::core::ScopeId::ROOT);
100
101    // 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。
102    #[cfg(target_arch = "wasm32")]
103    {
104        use wasm_bindgen::closure::Closure;
105        use_effect(move || {
106            if editor_handle.read().is_some() {
107                return;
108            }
109            let mut text = sql_text;
110            let on_change = Closure::new(move |v: String| {
111                text.set(v);
112            });
113            let on_ready = Closure::new(|| {});
114            // Ctrl/Cmd+Enter 触发执行(与按钮共用同一套资源/护栏逻辑)。
115            // 从 CodeMirror JS 事件触发时无 dioxus scope,必须用 in_scope 重建。
116            // 刻意用闭包 `|| execute_for_editor()` 而非直接传 `execute_for_editor`:
117            // 后者会 move 闭包值进 in_scope,导致第二次快捷键触发时 use-after-move
118            // (Closure::new 要求 Fn 可重复调用)。闭包包装走借用调用,每次都读最新值。
119            #[allow(clippy::redundant_closure)]
120            let on_run_shortcut = Closure::new(move || {
121                dioxus::core::Runtime::current().in_scope(scope_id, || execute_for_editor());
122            });
123
124            let theme_name = if resolved() == ResolvedTheme::Dark {
125                "dark"
126            } else {
127                "light"
128            };
129            let opts = codemirror_bridge::EditorOptions::new();
130            opts.set_language("sql");
131            opts.set_theme(theme_name);
132            opts.set_vim(true);
133            opts.set_on_change(&on_change);
134            opts.set_on_ready(&on_ready);
135            opts.set_on_run_shortcut(&on_run_shortcut);
136
137            if let Ok(Some(inst)) = codemirror_bridge::get_module().create("sql-editor", &opts) {
138                let handle = codemirror_bridge::EditorHandle::new(
139                    inst,
140                    on_change,
141                    on_ready,
142                    on_run_shortcut,
143                );
144                editor_handle.set(Some(handle));
145            }
146
147            // 异步拉取 schema 注入补全
148            spawn(async move {
149                if let Ok(schema) = get_db_schema().await {
150                    // SqlSchema 是 serde 类型:先用 serde_wasm_bindgen 转 JsValue,
151                    // 再传给 CodeMirror(extern set_schema 只接受 &JsValue)。
152                    if let Ok(js) = serde_wasm_bindgen::to_value(&schema) {
153                        if let Some(h) = editor_handle.read().as_ref() {
154                            h.instance().set_schema(&js);
155                        }
156                    }
157                }
158            });
159        });
160
161        // 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。
162        // resolved 是 theme + system_dark 的派生 memo,任一变化都自动触发此 effect。
163        use_effect(move || {
164            let r = resolved();
165            if let Some(h) = editor_handle.read().as_ref() {
166                h.instance().set_theme(if r == ResolvedTheme::Dark {
167                    "dark"
168                } else {
169                    "light"
170                });
171            }
172        });
173    }
174
175    // 执行 SQL:按钮 onclick 用的闭包。wasm 下复制 execute_for_editor 的逻辑,
176    // server 下仅复位 running(无网络层)。两处闭包捕获相同的 Copy signal。
177    let mut run_sql = move || {
178        running.set(true);
179        error.set(None);
180        #[cfg(target_arch = "wasm32")]
181        {
182            let sql = sql_text.read().clone();
183            let opts = ExecuteSqlOpts {
184                allow_multi: allow_multi(),
185                confirm_dangerous: confirm_dangerous(),
186                with_explain: with_explain(),
187            };
188            // 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
189            let lower = sql.to_lowercase();
190            let looks_write = [
191                "update ",
192                "delete ",
193                "insert ",
194                "alter ",
195                "drop ",
196                "truncate ",
197                "create ",
198            ]
199            .iter()
200            .any(|k| lower.contains(k));
201            if looks_write && !confirm_dangerous() {
202                // 简单提示;真正的高危放行靠 confirm_dangerous 开关
203                let confirmed = web_sys::window().and_then(|w| {
204                    w.confirm_with_message(
205                        "这是写操作(修改数据/结构),确认执行?\n\n高危操作(DROP/TRUNCATE/ALTER)还需勾选「我了解后果」。",
206                    )
207                    .ok()
208                });
209                if confirmed != Some(true) {
210                    running.set(false);
211                    return;
212                }
213            }
214            spawn(async move {
215                match execute_sql(sql, opts).await {
216                    Ok(r) => result.set(Some(r)),
217                    Err(e) => error.set(Some(e.to_string())),
218                }
219                running.set(false);
220            });
221        }
222        #[cfg(not(target_arch = "wasm32"))]
223        {
224            running.set(false);
225        }
226    };
227
228    let current_result = result.read().clone();
229    let current_error = error.read().clone();
230    let elapsed = current_result.as_ref().map(|r| r.elapsed_ms).unwrap_or(0);
231    let affected = current_result
232        .as_ref()
233        .map(|r| r.affected_rows)
234        .unwrap_or(0);
235    let stmt_type = current_result
236        .as_ref()
237        .map(|r| r.statement_type.clone())
238        .unwrap_or_default();
239    let truncated = current_result
240        .as_ref()
241        .map(|r| r.truncated)
242        .unwrap_or(false);
243    let explain = current_result.as_ref().and_then(|r| r.explain.clone());
244
245    rsx! {
246        div { class: "space-y-5",
247            // 编辑器卡片:标题栏 + CodeMirror 一体化。
248            div { class: "rounded-2xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]",
249                // 标题栏
250                div { class: "flex justify-between items-center px-4 py-2.5 border-b border-[var(--color-paper-border)] bg-[var(--color-paper-theme)]",
251                    div { class: "flex items-center gap-2",
252                        span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" }
253                        span { class: "font-mono text-sm font-semibold text-[var(--color-paper-primary)]",
254                            "SQL"
255                        }
256                    }
257                    span { class: "text-xs text-[var(--color-paper-tertiary)] font-mono",
258                        "⌘↵ 执行"
259                    }
260                }
261                // CodeMirror 容器:用 flex 让 .cm-editor(flex:1) 填满整个高度,
262                // 避免编辑器塌缩到内容高度、底部透出容器背景造成上下色差。
263                div {
264                    id: "sql-editor",
265                    style: "min-height: 280px; display: flex; flex-direction: column",
266                }
267            }
268
269            // 工具条:执行按钮 + 普通/危险选项分层
270            div { class: "flex flex-wrap items-center gap-x-4 gap-y-3",
271                LoadingButton {
272                    label: "执行".to_string(),
273                    loading: running(),
274                    onclick: move |_| run_sql(),
275                }
276                // 普通选项
277                label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer",
278                    input {
279                        r#type: "checkbox",
280                        class: "rounded border-[var(--color-paper-border)]",
281                        checked: with_explain(),
282                        onchange: move |e| with_explain.set(e.checked()),
283                    }
284                    "EXPLAIN"
285                }
286                label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer",
287                    input {
288                        r#type: "checkbox",
289                        class: "rounded border-[var(--color-paper-border)]",
290                        checked: allow_multi(),
291                        onchange: move |e| allow_multi.set(e.checked()),
292                    }
293                    "允许多语句"
294                }
295                // 危险选项:视觉分隔 + 红色语义
296                span { class: "w-px h-4 bg-[var(--color-paper-border)] mx-1" }
297                label { class: "flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 cursor-pointer",
298                    input {
299                        r#type: "checkbox",
300                        class: "rounded border-red-300 dark:border-red-700",
301                        checked: confirm_dangerous(),
302                        onchange: move |e| confirm_dangerous.set(e.checked()),
303                    }
304                    "我了解后果(DROP/TRUNCATE/ALTER)"
305                }
306            }
307
308            // 错误
309            if let Some(err) = current_error {
310                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",
311                    "{err}"
312                }
313            }
314
315            // 结果摘要(徽章式)
316            if current_result.is_some() {
317                div { class: "flex flex-wrap items-center gap-2",
318                    if !stmt_type.is_empty() {
319                        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",
320                            "{stmt_type}"
321                        }
322                    }
323                    if affected > 0 {
324                        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",
325                            "影响 {affected} 行"
326                        }
327                    }
328                    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",
329                        "{elapsed}ms"
330                    }
331                    if truncated {
332                        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",
333                            "结果超过 500 行,已截断"
334                        }
335                    }
336                }
337            }
338
339            // 结果表格
340            if let Some(res) = &current_result {
341                if !res.rows.is_empty() {
342                    SqlResultTable { result: res.clone() }
343                } else if res.statement_type.to_uppercase().contains("SELECT") {
344                    // 有结果但无行:SELECT 返回空集的友好提示
345                    div { class: "{ADMIN_TABLE_CLASS} px-4 py-8 text-center text-sm text-[var(--color-paper-tertiary)]",
346                        "查询成功,无返回行"
347                    }
348                }
349            }
350
351            // EXPLAIN 输出
352            if let Some(explain) = explain {
353                div { class: "{ADMIN_TABLE_CLASS}",
354                    div { class: "px-4 py-2 border-b border-paper-border text-sm font-medium text-paper-primary",
355                        "执行计划"
356                    }
357                    pre { class: "p-4 text-xs font-mono text-paper-secondary overflow-x-auto whitespace-pre m-0",
358                        "{explain}"
359                    }
360                }
361            }
362        }
363    }
364}