Skip to main content

yggdrasil/pages/admin/system/
sql_console.rs

1//! SQL 控制台 tab:CodeMirror 编辑器(SQL 高亮、补全、Vim)、执行选项与结果。
2
3use leptos::prelude::*;
4
5#[cfg(target_arch = "wasm32")]
6use crate::api::database::schema::get_db_schema;
7use crate::api::database::sql_console::SqlResult;
8#[cfg(target_arch = "wasm32")]
9use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts};
10#[cfg(target_arch = "wasm32")]
11use crate::bridges::codemirror;
12use crate::bridges::library::{
13    library_ready, use_browser_library, BrowserLibrary, LibraryLoadError,
14};
15// SQL 报错本身就是原因(如解析失败),不再加「执行失败」前缀。
16use crate::components::sql_result_table::SqlResultTable;
17use crate::components::ui::{button_class, ButtonSize, ButtonVariant, Checkbox};
18use crate::theme::use_resolved_theme;
19#[cfg(target_arch = "wasm32")]
20use crate::theme::ResolvedTheme;
21#[cfg(target_arch = "wasm32")]
22use crate::utils::error::error_reason;
23
24/// 前端判定为写操作的关键词(护栏 4:提交前二次确认)。
25#[cfg(target_arch = "wasm32")]
26const WRITE_KEYWORDS: [&str; 7] = [
27    "update ",
28    "delete ",
29    "insert ",
30    "alter ",
31    "drop ",
32    "truncate ",
33    "create ",
34];
35
36/// SQL 控制台 tab:CodeMirror 编辑器(SQL 高亮/补全/Vim)+ 4 道护栏 + 结果表 + EXPLAIN。
37///
38/// 护栏 4(前端二次确认):提交写操作前弹窗确认。
39#[component]
40#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
41pub(super) fn SqlConsoleTab() -> impl IntoView {
42    let editor_library = use_browser_library(BrowserLibrary::CodeMirror, || true);
43    let editor_mount = NodeRef::<leptos::html::Div>::new();
44    let resolved = use_resolved_theme();
45    let sql_text = RwSignal::new(String::new());
46    let result = RwSignal::new(None::<SqlResult>);
47    let error = RwSignal::new(None::<String>);
48    let running = RwSignal::new(false);
49    // 选项 toggles
50    let with_explain = RwSignal::new(false);
51    let allow_multi = RwSignal::new(false);
52    let confirm_dangerous = RwSignal::new(false);
53
54    // CodeMirror 实例句柄(仅 WASM):JS 句柄不是 Send,放进本地存储;
55    // 卸载时丢弃即触发 Drop → destroy。
56    #[cfg(target_arch = "wasm32")]
57    let editor_handle: StoredValue<Option<codemirror::EditorHandle>, LocalStorage> =
58        StoredValue::new_local(None);
59
60    // 执行 SQL 的核心逻辑:执行按钮与 Ctrl/Cmd+Enter 快捷键共用。闭包只捕获 Copy 的
61    // 信号,本身也是 Copy,每次调用读取最新值。入口的 `running` 检查负责防止快捷键
62    // 在执行中重入(按钮禁用只挡得住点击)。
63    let run_sql = move || {
64        // 执行中直接忽略:按钮禁用挡不住 Ctrl/Cmd+Enter,重入检查放在共用入口。
65        if running.get_untracked() || !editor_library.ready_untracked() {
66            return;
67        }
68        running.set(true);
69        error.set(None);
70        #[cfg(target_arch = "wasm32")]
71        {
72            let sql = sql_text.get_untracked();
73            let opts = ExecuteSqlOpts {
74                allow_multi: allow_multi.get_untracked(),
75                confirm_dangerous: confirm_dangerous.get_untracked(),
76                with_explain: with_explain.get_untracked(),
77            };
78            // 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
79            let lower = sql.to_lowercase();
80            let looks_write = WRITE_KEYWORDS.iter().any(|k| lower.contains(k));
81            if looks_write && !opts.confirm_dangerous {
82                // 简单提示;真正的高危放行靠 confirm_dangerous 开关
83                let confirmed = web_sys::window().and_then(|w| {
84                    w.confirm_with_message(
85                        "这是写操作(修改数据/结构),确认执行?\n\n高危操作(DROP/TRUNCATE/ALTER)还需勾选「我了解后果」。",
86                    )
87                    .ok()
88                });
89                if confirmed != Some(true) {
90                    running.set(false);
91                    return;
92                }
93            }
94            leptos::task::spawn_local(async move {
95                match execute_sql(sql, opts).await {
96                    Ok(r) => {
97                        let _ = result.try_set(Some(r));
98                    }
99                    Err(e) => {
100                        let _ = error.try_set(Some(
101                            error_reason(&e).unwrap_or_else(|| "执行失败,请稍后再试".into()),
102                        ));
103                    }
104                }
105                let _ = running.try_set(false);
106            });
107        }
108        #[cfg(not(target_arch = "wasm32"))]
109        running.set(false);
110    };
111
112    // 初始化 CodeMirror + 拉取 schema 注入补全。Effect 只在浏览器执行。
113    Effect::new(move |_| {
114        // 在 effect 顶层读取以便库就绪时重跑。
115        let ready = library_ready(editor_library);
116        #[cfg(target_arch = "wasm32")]
117        {
118            use wasm_bindgen::closure::Closure;
119
120            if !ready || editor_handle.with_value(Option::is_some) {
121                return;
122            }
123            // 跟踪读取:容器挂载后本 Effect 会重跑。
124            let Some(container) = editor_mount.get() else {
125                return;
126            };
127            let on_change = Closure::new(move |v: String| {
128                let _ = sql_text.try_set(v);
129            });
130            let on_ready = Closure::new(|| {});
131            // Ctrl/Cmd+Enter 触发执行(与按钮共用同一套护栏逻辑)。
132            let on_run_shortcut = Closure::new(run_sql);
133
134            let theme_name = if resolved.get_untracked() == ResolvedTheme::Dark {
135                "dark"
136            } else {
137                "light"
138            };
139            let opts = codemirror::EditorOptions::default();
140            opts.set_language("sql");
141            opts.set_theme(theme_name);
142            opts.set_vim(true);
143            opts.set_on_change(&on_change);
144            opts.set_on_ready(&on_ready);
145            opts.set_on_run_shortcut(&on_run_shortcut);
146
147            if let Ok(inst) = codemirror::create(&container, &opts) {
148                let handle =
149                    codemirror::EditorHandle::new(inst, on_change, on_ready, on_run_shortcut);
150                editor_handle.set_value(Some(handle));
151            }
152
153            // 异步拉取 schema 注入补全
154            leptos::task::spawn_local(async move {
155                if let Ok(schema) = get_db_schema().await {
156                    // SqlSchema 是 serde 类型:先用 serde_wasm_bindgen 转 JsValue,
157                    // 再传给 CodeMirror(extern set_schema 只接受 &JsValue)。
158                    if let Ok(js) = serde_wasm_bindgen::to_value(&schema) {
159                        let _ = editor_handle.try_with_value(|handle| {
160                            if let Some(handle) = handle.as_ref() {
161                                handle.instance().set_schema(&js);
162                            }
163                        });
164                    }
165                }
166            });
167        }
168        #[cfg(not(target_arch = "wasm32"))]
169        let _ = ready;
170    });
171
172    // 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。
173    // resolved 是 theme + system_dark 的派生 memo,任一变化都自动触发此 effect。
174    Effect::new(move |_| {
175        let r = resolved.get();
176        #[cfg(target_arch = "wasm32")]
177        editor_handle.with_value(|handle| {
178            if let Some(handle) = handle.as_ref() {
179                handle.instance().set_theme(if r == ResolvedTheme::Dark {
180                    "dark"
181                } else {
182                    "light"
183                });
184            }
185        });
186    });
187
188    on_cleanup(move || {
189        #[cfg(target_arch = "wasm32")]
190        let _ = editor_handle.try_set_value(None);
191    });
192
193    view! {
194        <section class="admin-panel sys-sql" aria-label="SQL 编辑器">
195            <LibraryLoadError library=editor_library/>
196            // CodeMirror 容器:flex 让 .cm-editor(flex:1) 填满高度,避免底部透出背景。
197            <div class="sys-sql-editor" node_ref=editor_mount></div>
198            <div class="sys-sql-bar">
199                <label class="sys-sql-option">
200                    <Checkbox
201                        checked=with_explain
202                        onchange=move |checked: bool| with_explain.set(checked)
203                    />
204                    "附带执行计划"
205                </label>
206                <label class="sys-sql-option">
207                    <Checkbox
208                        checked=allow_multi
209                        onchange=move |checked: bool| allow_multi.set(checked)
210                    />
211                    "允许多语句"
212                </label>
213                <label class="sys-sql-option" data-danger="true">
214                    <Checkbox
215                        danger=true
216                        checked=confirm_dangerous
217                        onchange=move |checked: bool| confirm_dangerous.set(checked)
218                    />
219                    "我了解后果(DROP / TRUNCATE / ALTER)"
220                </label>
221                <button
222                    class=format!("{} sys-sql-run", button_class(ButtonVariant::Primary, ButtonSize::Sm))
223                    type="button"
224                    disabled=move || running.get() || !library_ready(editor_library)
225                    on:click=move |_| run_sql()
226                >
227                    {move || if running.get() { "执行中…" } else { "执行" }}
228                    <kbd class="kbd">"⌘↵"</kbd>
229                </button>
230            </div>
231        </section>
232
233        {move || {
234            error.get().map(|err| view! { <p class="sys-error" role="alert">{err}</p> })
235        }}
236
237        {move || {
238            result
239                .get()
240                .map(|res| {
241                    let heading = if res.statement_type.is_empty() {
242                        "结果".to_string()
243                    } else {
244                        res.statement_type.clone()
245                    };
246                    let mut summary = String::new();
247                    if !res.rows.is_empty() {
248                        summary.push_str(&format!("{} 行 · ", res.rows.len()));
249                    }
250                    if res.affected_rows > 0 {
251                        summary.push_str(&format!("影响 {} 行 · ", res.affected_rows));
252                    }
253                    summary.push_str(&format!("{} ms", res.elapsed_ms));
254                    if res.truncated {
255                        summary.push_str(" · 超过 500 行,已截断");
256                    }
257                    let body = if !res.rows.is_empty() {
258                        view! { <SqlResultTable result=res.clone()/> }.into_any()
259                    } else if res.statement_type.to_uppercase().contains("SELECT") {
260                        view! { <p class="sys-empty">"查询成功,没有返回行。"</p> }.into_any()
261                    } else {
262                        ().into_any()
263                    };
264                    let explain = res.explain.clone();
265                    view! {
266                        <section class="admin-panel sys-section sys-result">
267                            <div class="sys-section-head">
268                                <h2>{heading}</h2>
269                                <span>{summary}</span>
270                            </div>
271                            {body}
272                        </section>
273                        {explain
274                            .map(|explain| {
275                                view! {
276                                    <section class="admin-panel sys-section">
277                                        <div class="sys-section-head">
278                                            <h2>"执行计划"</h2>
279                                        </div>
280                                        <pre class="sys-explain">{explain}</pre>
281                                    </section>
282                                }
283                            })}
284                    }
285                })
286        }}
287    }
288}