feat(admin): SQL 控制台接通 Ctrl+Enter 并重做视觉

接通上一提交打通的快捷键通道,同时重做编辑器/工具条/结果区视觉。

功能(接通 Ctrl+Enter):
- 把 run_sql 逻辑拆出 execute_for_editor 闭包(仅捕获 Copy 的 Signal),
  注入 CodeMirror onRunShortcut,按钮 onclick 仍走 run_sql
- 两条路径共用同一套 4 道护栏 + 资源逻辑,行为完全等价

视觉(编辑器标题栏一体化):
- 编辑器加标题栏:左 accent 状态点 + SQL 标识,右 ⌘↵ 快捷键提示
- 容器升至 rounded-2xl + 280px 最小高度,留呼吸
- 执行按钮改胶囊式(rounded-full)+ active:scale 触觉反馈 +
  SPINNER_SVG 替代纯文字「执行中...」
- 危险选项(我了解后果)用竖线分隔 + 红色语义,与普通选项分层
- 结果摘要从裸文本改徽章式(accent-soft 底 + 圆角胶囊)
- 加空结果友好态:SELECT 返回空集时显示「查询成功,无返回行」
- EXPLAIN 输出 pre 补 m-0,消除默认外边距
This commit is contained in:
xfy 2026-07-05 21:31:16 +08:00
parent 4239602f3d
commit fe6934ddd5

View File

@ -680,7 +680,7 @@ fn SqlConsoleTab() -> Element {
use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts}; use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::database::schema::get_db_schema; use crate::api::database::schema::get_db_schema;
use crate::components::ui::ADMIN_TABLE_CLASS; use crate::components::ui::{ADMIN_TABLE_CLASS, SPINNER_SVG};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::codemirror_bridge; use crate::codemirror_bridge;
// use_resolved_theme 两种构建都用resolved() 在 wasm 块内消费,非 wasm 仅引用避免警告。 // use_resolved_theme 两种构建都用resolved() 在 wasm 块内消费,非 wasm 仅引用避免警告。
@ -705,6 +705,47 @@ fn SqlConsoleTab() -> Element {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
let mut editor_handle: Signal<Option<codemirror_bridge::EditorHandle>> = use_signal(|| None); let mut editor_handle: Signal<Option<codemirror_bridge::EditorHandle>> = use_signal(|| None);
// 执行 SQL 的核心逻辑:抽成独立闭包,按钮 onclick 与 Ctrl+Enter 快捷键共用。
// 所有捕获都是 Copy 的 Signal故每次调用读取最新值闭包本身是 Fn 可重复调用。
// 用两个独立闭包execute_for_editor / run_sql避免 move 单一所有权冲突——
// 它们捕获相同的 Copy signal行为完全等价。
#[cfg(target_arch = "wasm32")]
let execute_for_editor = move || {
running.set(true);
error.set(None);
let sql = sql_text.read().clone();
let opts = ExecuteSqlOpts {
allow_multi: allow_multi(),
confirm_dangerous: confirm_dangerous(),
with_explain: with_explain(),
};
// 护栏 4写操作前端二次确认简单判断含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
let lower = sql.to_lowercase();
let looks_write = ["update ", "delete ", "insert ", "alter ", "drop ", "truncate ", "create "]
.iter()
.any(|k| lower.contains(k));
if looks_write && !confirm_dangerous() {
// 简单提示;真正的高危放行靠 confirm_dangerous 开关
let confirmed = web_sys::window().and_then(|w| {
w.confirm_with_message(
"这是写操作(修改数据/结构),确认执行?\n\n高危操作DROP/TRUNCATE/ALTER还需勾选「我了解后果」。",
)
.ok()
});
if confirmed != Some(true) {
running.set(false);
return;
}
}
spawn(async move {
match execute_sql(sql, opts).await {
Ok(r) => result.set(Some(r)),
Err(e) => error.set(Some(e.to_string())),
}
running.set(false);
});
};
// 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。 // 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
@ -718,6 +759,8 @@ fn SqlConsoleTab() -> Element {
text.set(v); text.set(v);
}); });
let on_ready = Closure::new(|| {}); let on_ready = Closure::new(|| {});
// Ctrl/Cmd+Enter 触发执行(与按钮共用同一套资源/护栏逻辑)。
let on_run_shortcut = Closure::new(execute_for_editor);
let theme_name = if resolved() == ResolvedTheme::Dark { "dark" } else { "light" }; let theme_name = if resolved() == ResolvedTheme::Dark { "dark" } else { "light" };
let opts = codemirror_bridge::EditorOptions::new(); let opts = codemirror_bridge::EditorOptions::new();
@ -726,9 +769,15 @@ fn SqlConsoleTab() -> Element {
opts.set_vim(true); opts.set_vim(true);
opts.set_on_change(&on_change); opts.set_on_change(&on_change);
opts.set_on_ready(&on_ready); opts.set_on_ready(&on_ready);
opts.set_on_run_shortcut(&on_run_shortcut);
if let Ok(Some(inst)) = codemirror_bridge::get_module().create("sql-editor", &opts) { if let Ok(Some(inst)) = codemirror_bridge::get_module().create("sql-editor", &opts) {
let handle = codemirror_bridge::EditorHandle::new(inst, on_change, on_ready); let handle = codemirror_bridge::EditorHandle::new(
inst,
on_change,
on_ready,
on_run_shortcut,
);
editor_handle.set(Some(handle)); editor_handle.set(Some(handle));
} }
@ -756,7 +805,8 @@ fn SqlConsoleTab() -> Element {
}); });
} }
// 执行 SQL // 执行 SQL按钮 onclick 用的闭包。wasm 下复制 execute_for_editor 的逻辑,
// server 下仅复位 running无网络层。两处闭包捕获相同的 Copy signal。
let mut run_sql = move || { let mut run_sql = move || {
running.set(true); running.set(true);
error.set(None); error.set(None);
@ -834,68 +884,98 @@ fn SqlConsoleTab() -> Element {
let explain = current_result.as_ref().and_then(|r| r.explain.clone()); let explain = current_result.as_ref().and_then(|r| r.explain.clone());
rsx! { rsx! {
div { class: "space-y-4", div { class: "space-y-5",
// 编辑器容器 // 编辑器卡片:标题栏 + CodeMirror 一体化
div { div { class: "rounded-2xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]",
class: "border border-paper-border rounded-lg overflow-hidden bg-paper-entry", // 标题栏
id: "sql-editor", div { class: "flex justify-between items-center px-4 py-2.5 border-b border-[var(--color-paper-border)] bg-[var(--color-paper-theme)]",
style: "min-height: 200px" div { class: "flex items-center gap-2",
span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" }
span { class: "font-mono text-sm font-semibold text-[var(--color-paper-primary)]", "SQL" }
}
span { class: "text-xs text-[var(--color-paper-tertiary)] font-mono", "⌘↵ 执行" }
}
// CodeMirror 容器
div {
id: "sql-editor",
style: "min-height: 280px",
}
} }
// 选项 + 执行按钮 // 工具条:执行按钮 + 普通/危险选项分层
div { class: "flex flex-wrap items-center gap-3", div { class: "flex flex-wrap items-center gap-x-4 gap-y-3",
button { button {
class: "px-4 py-1.5 text-sm bg-paper-accent text-paper-theme rounded hover:brightness-110 transition disabled:opacity-50", class: "inline-flex items-center gap-1.5 px-5 py-2 text-sm font-medium rounded-full text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] hover:brightness-110 active:scale-[0.98] transition disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer",
disabled: running(), disabled: running(),
onclick: move |_| run_sql(), onclick: move |_| run_sql(),
if running() { "执行中..." } else { "执行 (Ctrl+Enter)" } if running() {
span { class: "inline-block w-3.5 h-3.5 text-[var(--color-paper-theme)]",
dangerous_inner_html: SPINNER_SVG,
}
"执行中"
} else {
"执行"
}
} }
label { class: "flex items-center gap-1 text-sm text-paper-secondary", // 普通选项
label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer",
input { input {
r#type: "checkbox", r#type: "checkbox",
class: "mr-1", class: "rounded border-[var(--color-paper-border)]",
checked: with_explain(), checked: with_explain(),
onchange: move |e| with_explain.set(e.checked()), onchange: move |e| with_explain.set(e.checked()),
} }
"EXPLAIN" "EXPLAIN"
} }
label { class: "flex items-center gap-1 text-sm text-paper-secondary", label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer",
input { input {
r#type: "checkbox", r#type: "checkbox",
class: "mr-1", class: "rounded border-[var(--color-paper-border)]",
checked: allow_multi(), checked: allow_multi(),
onchange: move |e| allow_multi.set(e.checked()), onchange: move |e| allow_multi.set(e.checked()),
} }
"允许多语句" "允许多语句"
} }
label { class: "flex items-center gap-1 text-sm text-red-600 dark:text-red-400", // 危险选项:视觉分隔 + 红色语义
span { class: "w-px h-4 bg-[var(--color-paper-border)] mx-1" }
label { class: "flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 cursor-pointer",
input { input {
r#type: "checkbox", r#type: "checkbox",
class: "mr-1", class: "rounded border-red-300 dark:border-red-700",
checked: confirm_dangerous(), checked: confirm_dangerous(),
onchange: move |e| confirm_dangerous.set(e.checked()), onchange: move |e| confirm_dangerous.set(e.checked()),
} }
"我了解后果(放开 DROP/TRUNCATE/ALTER" "我了解后果(DROP/TRUNCATE/ALTER"
} }
} }
// 错误 // 错误
if let Some(err) = current_error { if let Some(err) = current_error {
div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-sm text-red-700 dark:text-red-300", 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",
"{err}" "{err}"
} }
} }
// 结果摘要 // 结果摘要(徽章式)
if current_result.is_some() { if current_result.is_some() {
div { class: "flex flex-wrap gap-4 text-sm text-paper-secondary", div { class: "flex flex-wrap items-center gap-2",
span { "类型:{stmt_type}" } if !stmt_type.is_empty() {
if affected > 0 { 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",
span { "影响行数:{affected}" } "{stmt_type}"
}
}
if affected > 0 {
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",
"影响 {affected} 行"
}
}
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",
"{elapsed}ms"
} }
span { "耗时:{elapsed}ms" }
if truncated { if truncated {
span { class: "text-amber-600 dark:text-amber-400", "结果超过 500 行,已截断" } 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",
"结果超过 500 行,已截断"
}
} }
} }
} }
@ -924,6 +1004,11 @@ fn SqlConsoleTab() -> Element {
} }
} }
} }
} else if current_result.is_some() && stmt_type.to_uppercase().contains("SELECT") {
// 有结果但无行SELECT 返回空集的友好提示
div { class: "{ADMIN_TABLE_CLASS} px-4 py-8 text-center text-sm text-[var(--color-paper-tertiary)]",
"查询成功,无返回行"
}
} }
// EXPLAIN 输出 // EXPLAIN 输出
@ -932,7 +1017,7 @@ fn SqlConsoleTab() -> Element {
div { class: "px-4 py-2 border-b border-paper-border text-sm font-medium text-paper-primary", div { class: "px-4 py-2 border-b border-paper-border text-sm font-medium text-paper-primary",
"执行计划" "执行计划"
} }
pre { class: "p-4 text-xs font-mono text-paper-secondary overflow-x-auto whitespace-pre", "{explain}" } pre { class: "p-4 text-xs font-mono text-paper-secondary overflow-x-auto whitespace-pre m-0", "{explain}" }
} }
} }
} }