Skip to main content

yggdrasil/pages/admin/system/
sql_console.rs

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