yggdrasil/pages/admin/system/
sql_console.rs1use dioxus::prelude::*;
4
5use crate::components::ui::{Checkbox, LoadingButton};
6
7#[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 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 let mut with_explain = use_signal(|| false);
36 let mut allow_multi = use_signal(|| false);
37 let mut confirm_dangerous = use_signal(|| false);
38 #[cfg(not(target_arch = "wasm32"))]
40 let _ = (&resolved, &sql_text);
41
42 #[cfg(target_arch = "wasm32")]
44 let mut editor_handle: Signal<Option<codemirror::EditorHandle>> = use_signal(|| None);
45
46 #[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 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 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 #[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 #[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 #[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 spawn(async move {
153 if let Ok(schema) = get_db_schema().await {
154 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 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 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 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 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 div { class: "rounded-2xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]",
256 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 div {
272 id: "sql-editor",
273 style: "min-height: 280px; display: flex; flex-direction: column",
274 }
275 }
276
277 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 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 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 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 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 if let Some(res) = ¤t_result {
345 if !res.rows.is_empty() {
346 SqlResultTable { result: res.clone() }
347 } else if res.statement_type.to_uppercase().contains("SELECT") {
348 div { class: "{ADMIN_TABLE_CLASS} px-4 py-8 text-center text-sm text-[var(--color-paper-tertiary)]",
350 "查询成功,无返回行"
351 }
352 }
353 }
354
355 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}