yggdrasil/pages/admin/system/
sql_console.rs1use 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};
15use 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#[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#[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 let with_explain = RwSignal::new(false);
51 let allow_multi = RwSignal::new(false);
52 let confirm_dangerous = RwSignal::new(false);
53
54 #[cfg(target_arch = "wasm32")]
57 let editor_handle: StoredValue<Option<codemirror::EditorHandle>, LocalStorage> =
58 StoredValue::new_local(None);
59
60 let run_sql = move || {
64 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 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 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 Effect::new(move |_| {
114 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 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 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 leptos::task::spawn_local(async move {
155 if let Ok(schema) = get_db_schema().await {
156 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 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 <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}