yggdrasil/pages/admin/system/
sql_console.rs1use dioxus::prelude::*;
4
5use crate::components::ui::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 #[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 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 let mut with_explain = use_signal(|| false);
34 let mut allow_multi = use_signal(|| false);
35 let mut confirm_dangerous = use_signal(|| false);
36 #[cfg(not(target_arch = "wasm32"))]
38 let _ = (&resolved, &sql_text);
39
40 #[cfg(target_arch = "wasm32")]
42 let mut editor_handle: Signal<Option<codemirror_bridge::EditorHandle>> = use_signal(|| None);
43
44 #[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 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 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 #[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 #[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 #[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 spawn(async move {
149 if let Ok(schema) = get_db_schema().await {
150 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 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 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 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 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 div { class: "rounded-2xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]",
249 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 div {
264 id: "sql-editor",
265 style: "min-height: 280px; display: flex; flex-direction: column",
266 }
267 }
268
269 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 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 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 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 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 if let Some(res) = ¤t_result {
341 if !res.rows.is_empty() {
342 SqlResultTable { result: res.clone() }
343 } else if res.statement_type.to_uppercase().contains("SELECT") {
344 div { class: "{ADMIN_TABLE_CLASS} px-4 py-8 text-center text-sm text-[var(--color-paper-tertiary)]",
346 "查询成功,无返回行"
347 }
348 }
349 }
350
351 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}