feat(admin): add SQL console tab (read-write + 4 guards + sqlparser)
新增 sqlparser 依赖(optional+server),AppError 加 BadRequest 变体(护栏 返回动态用户可见消息)。execute_sql server function 全读写执行,4 道护栏: (1) sqlparser AST 高危语句闸门——DROP DATABASE/SCHEMA 字符串预检绝禁、 DROP/TRUNCATE/ALTER 需勾选「我了解后果」;(2) 无 WHERE 的 UPDATE/DELETE 拒绝; (3) 复用 STATEMENT_TIMEOUT_SECS 超时上限;(4) 前端写操作二次确认。 默认禁多语句,结果 500 行截断。get_db_schema 拉表/列供 CodeMirror 补全。 前端 SqlConsoleTab:CodeMirror 编辑器(Vim + Catppuccin 主题跟随站点 + 实时 schema 补全)+ 选项 toggles + 结果表格 + EXPLAIN 输出。
This commit is contained in:
parent
c4c490b881
commit
a49f47c8a6
10
Cargo.lock
generated
10
Cargo.lock
generated
@ -4017,6 +4017,15 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparser"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7bbffee862a796d67959a89859d6b1046bb5016d63e23835ad0da182777bbe0"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@ -5489,6 +5498,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"sha2 0.10.9",
|
||||
"sqlparser",
|
||||
"syntect",
|
||||
"sysinfo",
|
||||
"tokio",
|
||||
|
||||
@ -36,6 +36,7 @@ zenwebp = { version = "0.3", optional = true }
|
||||
moka = { version = "0.12", features = ["future", "sync"], optional = true }
|
||||
governor = { version = "0.8", optional = true }
|
||||
sysinfo = { version = "0.34", optional = true }
|
||||
sqlparser = { version = "0.45", optional = true }
|
||||
md-5 = { version = "0.10", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
@ -89,6 +90,7 @@ server = [
|
||||
"dep:moka",
|
||||
"dep:governor",
|
||||
"dep:sysinfo",
|
||||
"dep:sqlparser",
|
||||
"dep:md-5",
|
||||
"dep:futures",
|
||||
"dep:bytes",
|
||||
|
||||
@ -10,3 +10,7 @@
|
||||
pub mod status;
|
||||
/// 服务器状态聚合查询(应用内 + 主机层)。
|
||||
pub mod system_status;
|
||||
/// SQL 控制台执行(全读写 + 4 护栏)。
|
||||
pub mod sql_console;
|
||||
/// SQL 补全用 schema 拉取。
|
||||
pub mod schema;
|
||||
|
||||
48
src/api/database/schema.rs
Normal file
48
src/api/database/schema.rs
Normal file
@ -0,0 +1,48 @@
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
//! SQL 补全用 schema 拉取(供 CodeMirror lang-sql 表/列补全)。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
use crate::api::error::AppError;
|
||||
use crate::codemirror_bridge::SqlSchema;
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
/// 拉取数据库 schema(表名 + 列名),供 CodeMirror SQL 补全。
|
||||
#[server(GetDbSchema, "/api")]
|
||||
pub async fn get_db_schema() -> Result<SqlSchema, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT t.table_name, \
|
||||
string_agg(c.column_name, ',' ORDER BY c.ordinal_position) \
|
||||
FROM information_schema.tables t \
|
||||
JOIN information_schema.columns c USING (table_schema, table_name) \
|
||||
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' \
|
||||
GROUP BY t.table_name ORDER BY t.table_name",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let tables = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let cols: String = r.get(1);
|
||||
crate::codemirror_bridge::SqlTable {
|
||||
name: r.get(0),
|
||||
columns: cols.split(',').map(|s| s.to_string()).collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(SqlSchema { tables })
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(SqlSchema::default())
|
||||
}
|
||||
}
|
||||
320
src/api/database/sql_console.rs
Normal file
320
src/api/database/sql_console.rs
Normal file
@ -0,0 +1,320 @@
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
//! SQL 控制台执行(全读写 + 4 道护栏)。
|
||||
//!
|
||||
//! 护栏:
|
||||
//! 1. 高危语句闸门:`DROP DATABASE`/`DROP SCHEMA`(字符串预检)绝禁;
|
||||
//! `DROP`/`TRUNCATE`/`ALTER` 需 `confirm_dangerous`。
|
||||
//! 2. 无 WHERE 拦截:`UPDATE`/`DELETE` 无 `selection` 拒绝。
|
||||
//! 3. 查询超时上限:复用 `STATEMENT_TIMEOUT_SECS`(pool 层已注入 GUC)。
|
||||
//! 4. 前端二次确认(前端实现)。
|
||||
//!
|
||||
//! 默认禁止多语句(`allow_multi` 放开)。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
use crate::api::error::AppError;
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Copy)]
|
||||
pub struct ExecuteSqlOpts {
|
||||
/// 是否允许多语句(`;` 分隔),默认 false。
|
||||
pub allow_multi: bool,
|
||||
/// 是否勾选「我了解后果」(放开 DROP/TRUNCATE/ALTER 等高危)。
|
||||
pub confirm_dangerous: bool,
|
||||
/// 是否带 EXPLAIN 执行计划。
|
||||
pub with_explain: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||
pub struct SqlResult {
|
||||
pub columns: Vec<String>,
|
||||
/// 每格用 JSON 表示(text/int/timestamp/bool/null)。
|
||||
pub rows: Vec<Vec<serde_json::Value>>,
|
||||
pub affected_rows: u64,
|
||||
pub elapsed_ms: u64,
|
||||
/// 语句类型(来自 AST,如 "Select"/"Update"/"CreateTable")。
|
||||
pub statement_type: String,
|
||||
pub explain: Option<String>,
|
||||
/// 是否因 500 行上限截断。
|
||||
pub truncated: bool,
|
||||
/// 截断时的估算总行数(重查用)。
|
||||
pub total_estimate: Option<i64>,
|
||||
}
|
||||
|
||||
/// 结果行数上限(超出截断 + 提示)。
|
||||
const MAX_ROWS: usize = 500;
|
||||
|
||||
/// 绝对禁止的语句关键词(字符串预检,sqlparser 无 ObjectType::Database/Schema)。
|
||||
/// 命中即拒,不可放行。
|
||||
const ABSOLUTELY_FORBIDDEN: &[&str] = &["drop database", "drop schema", "create database"];
|
||||
|
||||
/// 护栏检查返回值。
|
||||
#[derive(Debug)]
|
||||
enum GuardResult {
|
||||
Allowed,
|
||||
/// 需 confirm_dangerous 才放行。
|
||||
NeedsConfirm,
|
||||
/// 不可放行(附带原因)。
|
||||
Forbidden(String),
|
||||
}
|
||||
|
||||
/// 护栏 1+2:sqlparser 解析后遍历 AST,检查高危语句与无 WHERE 的 UPDATE/DELETE。
|
||||
#[cfg(feature = "server")]
|
||||
fn check_guards(
|
||||
asts: &[sqlparser::ast::Statement],
|
||||
confirm_dangerous: bool,
|
||||
) -> GuardResult {
|
||||
use sqlparser::ast::Statement;
|
||||
|
||||
for stmt in asts {
|
||||
match stmt {
|
||||
// 护栏 1:需确认的高危语句
|
||||
Statement::Drop { .. } | Statement::Truncate { .. } | Statement::AlterTable { .. } => {
|
||||
if !confirm_dangerous {
|
||||
return GuardResult::NeedsConfirm;
|
||||
}
|
||||
}
|
||||
// 护栏 2:UPDATE 无 WHERE
|
||||
Statement::Update { selection: None, .. } => {
|
||||
return GuardResult::Forbidden(
|
||||
"UPDATE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
|
||||
);
|
||||
}
|
||||
// 护栏 2:DELETE 无 WHERE
|
||||
Statement::Delete { selection: None, .. } => {
|
||||
return GuardResult::Forbidden(
|
||||
"DELETE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
GuardResult::Allowed
|
||||
}
|
||||
|
||||
/// 提取语句类型名(AST 变体名,如 "Select"/"Insert"/"Update")。
|
||||
#[cfg(feature = "server")]
|
||||
fn statement_type_name(stmt: &sqlparser::ast::Statement) -> String {
|
||||
use sqlparser::ast::Statement;
|
||||
let name = match stmt {
|
||||
Statement::Query(_) => "Select",
|
||||
Statement::Insert { .. } => "Insert",
|
||||
Statement::Update { .. } => "Update",
|
||||
Statement::Delete { .. } => "Delete",
|
||||
Statement::CreateTable { .. } => "CreateTable",
|
||||
Statement::AlterTable { .. } => "AlterTable",
|
||||
Statement::Drop { .. } => "Drop",
|
||||
Statement::Truncate { .. } => "Truncate",
|
||||
Statement::Explain { .. } => "Explain",
|
||||
_ => "Other",
|
||||
};
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// 判断语句是否只读(SELECT/EXPLAIN/SHOW/WITH...SELECT)。
|
||||
#[cfg(feature = "server")]
|
||||
fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool {
|
||||
use sqlparser::ast::Statement;
|
||||
matches!(
|
||||
stmt,
|
||||
Statement::Query(_) | Statement::Explain { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// 把一列的值转成 JSON(按 PG 类型名分发)。
|
||||
#[cfg(feature = "server")]
|
||||
fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
|
||||
use serde_json::json;
|
||||
let ty = row.columns().get(idx).map(|c| c.type_().name()).unwrap_or("");
|
||||
match ty {
|
||||
"int2" => row
|
||||
.try_get::<_, Option<i16>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"int4" => row
|
||||
.try_get::<_, Option<i32>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"int8" => row
|
||||
.try_get::<_, Option<i64>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"float4" => row
|
||||
.try_get::<_, Option<f32>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"float8" => row
|
||||
.try_get::<_, Option<f64>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"bool" => row
|
||||
.try_get::<_, Option<bool>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
// 其余(text/varchar/timestamp/jsonb/...)一律按字符串取,失败则 null
|
||||
_ => row
|
||||
.try_get::<_, Option<String>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 SQL(全读写,管理员)。护栏见模块文档。
|
||||
#[server(ExecuteSql, "/api")]
|
||||
pub async fn execute_sql(sql: String, opts: ExecuteSqlOpts) -> Result<SqlResult, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use sqlparser::dialect::PostgreSqlDialect;
|
||||
use sqlparser::parser::Parser;
|
||||
|
||||
// 护栏 1(绝禁):字符串预检 DROP/CREATE DATABASE、DROP SCHEMA
|
||||
let normalized = sql.to_lowercase();
|
||||
for forbidden in ABSOLUTELY_FORBIDDEN {
|
||||
if normalized.contains(forbidden) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"禁止的操作:{}",
|
||||
forbidden.to_uppercase()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 SQL
|
||||
let dialect = PostgreSqlDialect {};
|
||||
let asts = Parser::parse_sql(&dialect, &sql)
|
||||
.map_err(|e| AppError::BadRequest(format!("SQL 解析失败:{e}")))?;
|
||||
if asts.is_empty() {
|
||||
return Err(AppError::BadRequest("空的 SQL 语句".into()).into());
|
||||
}
|
||||
|
||||
// 多语句检查(默认禁止)
|
||||
if asts.len() > 1 && !opts.allow_multi {
|
||||
return Err(AppError::BadRequest(
|
||||
"检测到多条语句,请勾选「允许多语句」后再执行".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// 护栏 1+2:AST 检查
|
||||
match check_guards(&asts, opts.confirm_dangerous) {
|
||||
GuardResult::Forbidden(msg) => {
|
||||
return Err(AppError::BadRequest(msg).into());
|
||||
}
|
||||
GuardResult::NeedsConfirm => {
|
||||
return Err(AppError::BadRequest(
|
||||
"高危操作(DROP/TRUNCATE/ALTER),需勾选「我了解后果」".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
GuardResult::Allowed => {}
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let start = std::time::Instant::now;
|
||||
|
||||
// 逐条执行(allow_multi 时多条,否则单条)
|
||||
let mut last_result = SqlResult::default();
|
||||
for stmt in &asts {
|
||||
last_result = execute_one(&client, stmt, &sql, opts.with_explain, start).await?;
|
||||
}
|
||||
Ok(last_result)
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
let _ = (sql, opts);
|
||||
Ok(SqlResult::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行单条语句,返回结果。
|
||||
#[cfg(feature = "server")]
|
||||
async fn execute_one(
|
||||
client: &deadpool_postgres::Object,
|
||||
stmt: &sqlparser::ast::Statement,
|
||||
sql: &str,
|
||||
with_explain: bool,
|
||||
start: impl Fn() -> std::time::Instant + Copy,
|
||||
) -> Result<SqlResult, ServerFnError> {
|
||||
let statement_type = statement_type_name(stmt);
|
||||
let read_only = is_read_only(stmt);
|
||||
|
||||
if with_explain && read_only {
|
||||
// EXPLAIN 模式:包裹原 SQL 执行计划,取首列文本拼接
|
||||
let explain_sql = format!("EXPLAIN {}", sql.trim_end_matches(';'));
|
||||
let rows = client
|
||||
.query(&explain_sql, &[])
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let explain = rows
|
||||
.iter()
|
||||
.filter_map(|r| r.try_get::<_, String>(0).ok())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
return Ok(SqlResult {
|
||||
statement_type,
|
||||
explain: Some(explain),
|
||||
elapsed_ms: start().elapsed().as_millis() as u64,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
if read_only {
|
||||
// 只读:取结果集。列名从第一行取(空结果集时无列名,前端容错)。
|
||||
let rows = client.query(sql, &[]).await.map_err(AppError::query)?;
|
||||
let columns: Vec<String> = rows
|
||||
.first()
|
||||
.map(|r| {
|
||||
r.columns()
|
||||
.iter()
|
||||
.map(|c| c.name().to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut data: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||
let mut truncated = false;
|
||||
for r in &rows {
|
||||
if data.len() >= MAX_ROWS {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let row: Vec<serde_json::Value> = (0..r.len()).map(|i| col_to_json(r, i)).collect();
|
||||
data.push(row);
|
||||
}
|
||||
Ok(SqlResult {
|
||||
columns,
|
||||
rows: data,
|
||||
truncated,
|
||||
statement_type,
|
||||
elapsed_ms: start().elapsed().as_millis() as u64,
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
// 写操作:返回影响行数
|
||||
let affected = client.execute(sql, &[]).await.map_err(AppError::query)?;
|
||||
Ok(SqlResult {
|
||||
affected_rows: affected,
|
||||
statement_type,
|
||||
elapsed_ms: start().elapsed().as_millis() as u64,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,8 @@ pub enum AppError {
|
||||
Forbidden(&'static str),
|
||||
/// 资源不存在(404)。
|
||||
NotFound(&'static str),
|
||||
/// 客户端请求错误(400)——业务规则拒绝,消息原样透传给用户。
|
||||
BadRequest(String),
|
||||
/// 数据库连接失败。
|
||||
DbConn(String),
|
||||
/// SQL 查询执行失败。
|
||||
@ -54,6 +56,8 @@ impl From<AppError> for ServerFnError {
|
||||
AppError::Unauthorized(m) => m.to_string(),
|
||||
AppError::Forbidden(m) => m.to_string(),
|
||||
AppError::NotFound(m) => m.to_string(),
|
||||
// BadRequest 是业务规则拒绝(如 SQL 护栏拦截),消息原样透传给用户。
|
||||
AppError::BadRequest(m) => m.to_string(),
|
||||
AppError::DbConn(_) => "服务暂时不可用".to_string(),
|
||||
AppError::Query(_) => "操作失败".to_string(),
|
||||
AppError::Transaction(_) => "操作失败".to_string(),
|
||||
|
||||
@ -61,7 +61,7 @@ pub fn System() -> Element {
|
||||
match active_tab() {
|
||||
SystemTab::DbStatus => rsx! { DbStatusTab {} },
|
||||
SystemTab::ServerStatus => rsx! { ServerStatusTab {} },
|
||||
SystemTab::SqlConsole => rsx! { div { class: "text-paper-secondary py-8", "SQL 控制台(待实现)" } },
|
||||
SystemTab::SqlConsole => rsx! { SqlConsoleTab {} },
|
||||
SystemTab::Export => rsx! { div { class: "text-paper-secondary py-8", "数据导出(待实现)" } },
|
||||
SystemTab::Backup => rsx! { div { class: "text-paper-secondary py-8", "备份恢复(待实现)" } },
|
||||
}
|
||||
@ -634,3 +634,269 @@ fn ServerStatusTab() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SQL 控制台 tab:CodeMirror 编辑器(SQL 高亮/补全/Vim)+ 4 道护栏 + 结果表 + EXPLAIN。
|
||||
///
|
||||
/// 护栏 4(前端二次确认):提交写操作前弹窗确认。
|
||||
#[allow(non_snake_case)]
|
||||
fn SqlConsoleTab() -> Element {
|
||||
use crate::api::database::sql_console::SqlResult;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::database::schema::get_db_schema;
|
||||
use crate::components::ui::ADMIN_TABLE_CLASS;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::codemirror_bridge;
|
||||
use crate::theme::use_theme;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::theme::Theme;
|
||||
|
||||
let theme = use_theme();
|
||||
let sql_text = use_signal(String::new);
|
||||
let result = use_signal(|| Option::<SqlResult>::None);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
let mut running = use_signal(|| false);
|
||||
// 选项 toggles
|
||||
let mut with_explain = use_signal(|| false);
|
||||
let mut allow_multi = use_signal(|| false);
|
||||
let mut confirm_dangerous = use_signal(|| false);
|
||||
// theme/sql_text 仅在 wasm32 块内使用;server 构建时显式引用避免 unused 警告。
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = (&theme, &sql_text);
|
||||
|
||||
// CodeMirror 实例句柄(仅 WASM)。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let mut editor_handle: Signal<Option<codemirror_bridge::EditorHandle>> = use_signal(|| None);
|
||||
|
||||
// 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use dioxus::prelude::wasm_bindgen::closure::Closure;
|
||||
use_effect(move || {
|
||||
if editor_handle().is_some() {
|
||||
return;
|
||||
}
|
||||
let mut text = sql_text;
|
||||
let on_change = Closure::new(move |v: String| {
|
||||
text.set(v);
|
||||
});
|
||||
let on_ready = Closure::new(|| {});
|
||||
|
||||
let theme_name = if theme() == Theme::Dark { "dark" } else { "light" };
|
||||
let opts = codemirror_bridge::EditorOptions::new();
|
||||
opts.set_language("sql");
|
||||
opts.set_theme(theme_name);
|
||||
opts.set_vim(true);
|
||||
opts.set_on_change(&on_change);
|
||||
opts.set_on_ready(&on_ready);
|
||||
|
||||
match codemirror_bridge::get_module().create("sql-editor", &opts) {
|
||||
Ok(Some(inst)) => {
|
||||
let handle = codemirror_bridge::EditorHandle::new(inst, on_change, on_ready);
|
||||
editor_handle.set(Some(handle));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 异步拉取 schema 注入补全
|
||||
spawn(async move {
|
||||
if let Ok(schema) = get_db_schema().await {
|
||||
if let Some(h) = editor_handle.read().as_ref() {
|
||||
h.instance().set_schema(&schema);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 主题切换时同步编辑器主题
|
||||
use_effect(move || {
|
||||
let t = theme();
|
||||
if let Some(h) = editor_handle.read().as_ref() {
|
||||
h.instance().set_theme(if t == Theme::Dark { "dark" } else { "light" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 执行 SQL
|
||||
let mut run_sql = move || {
|
||||
running.set(true);
|
||||
error.set(None);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
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);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
running.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
let current_result = result.read().clone();
|
||||
let current_error = error.read().clone();
|
||||
let elapsed = current_result.as_ref().map(|r| r.elapsed_ms).unwrap_or(0);
|
||||
let affected = current_result.as_ref().map(|r| r.affected_rows).unwrap_or(0);
|
||||
let stmt_type = current_result
|
||||
.as_ref()
|
||||
.map(|r| r.statement_type.clone())
|
||||
.unwrap_or_default();
|
||||
let truncated = current_result.as_ref().map(|r| r.truncated).unwrap_or(false);
|
||||
// 结果行预格式化(避免在 rsx for 循环体内格式化)
|
||||
let result_rows: Vec<Vec<String>> = current_result
|
||||
.as_ref()
|
||||
.map(|r| {
|
||||
r.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| match cell {
|
||||
serde_json::Value::Null => "NULL".to_string(),
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let result_columns: Vec<String> = current_result
|
||||
.as_ref()
|
||||
.map(|r| r.columns.clone())
|
||||
.unwrap_or_default();
|
||||
let explain = current_result.as_ref().and_then(|r| r.explain.clone());
|
||||
|
||||
rsx! {
|
||||
div { class: "space-y-4",
|
||||
// 编辑器容器
|
||||
div {
|
||||
class: "border border-paper-border rounded-lg overflow-hidden bg-paper-entry",
|
||||
id: "sql-editor",
|
||||
style: "min-height: 200px"
|
||||
}
|
||||
|
||||
// 选项 + 执行按钮
|
||||
div { class: "flex flex-wrap items-center gap-3",
|
||||
button {
|
||||
class: "px-4 py-1.5 text-sm bg-paper-accent text-paper-theme rounded hover:brightness-110 transition disabled:opacity-50",
|
||||
disabled: running(),
|
||||
onclick: move |_| run_sql(),
|
||||
if running() { "执行中..." } else { "执行 (Ctrl+Enter)" }
|
||||
}
|
||||
label { class: "flex items-center gap-1 text-sm text-paper-secondary",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "mr-1",
|
||||
checked: with_explain(),
|
||||
onchange: move |e| with_explain.set(e.checked()),
|
||||
}
|
||||
"EXPLAIN"
|
||||
}
|
||||
label { class: "flex items-center gap-1 text-sm text-paper-secondary",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "mr-1",
|
||||
checked: allow_multi(),
|
||||
onchange: move |e| allow_multi.set(e.checked()),
|
||||
}
|
||||
"允许多语句"
|
||||
}
|
||||
label { class: "flex items-center gap-1 text-sm text-red-600 dark:text-red-400",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "mr-1",
|
||||
checked: confirm_dangerous(),
|
||||
onchange: move |e| confirm_dangerous.set(e.checked()),
|
||||
}
|
||||
"我了解后果(放开 DROP/TRUNCATE/ALTER)"
|
||||
}
|
||||
}
|
||||
|
||||
// 错误
|
||||
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",
|
||||
"{err}"
|
||||
}
|
||||
}
|
||||
|
||||
// 结果摘要
|
||||
if current_result.is_some() {
|
||||
div { class: "flex flex-wrap gap-4 text-sm text-paper-secondary",
|
||||
span { "类型:{stmt_type}" }
|
||||
if affected > 0 {
|
||||
span { "影响行数:{affected}" }
|
||||
}
|
||||
span { "耗时:{elapsed}ms" }
|
||||
if truncated {
|
||||
span { class: "text-amber-600 dark:text-amber-400", "结果超过 500 行,已截断" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 结果表格
|
||||
if !result_rows.is_empty() {
|
||||
div { class: "{ADMIN_TABLE_CLASS}",
|
||||
div { class: "overflow-x-auto",
|
||||
table { class: "w-full text-sm",
|
||||
thead {
|
||||
tr { class: "border-b border-paper-border text-left text-paper-secondary",
|
||||
for col in result_columns.iter() {
|
||||
th { class: "px-4 py-2 font-medium whitespace-nowrap", "{col}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for row in result_rows.iter() {
|
||||
tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
|
||||
for cell in row.iter() {
|
||||
td { class: "px-4 py-2 font-mono text-xs text-paper-secondary", "{cell}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EXPLAIN 输出
|
||||
if let Some(explain) = explain {
|
||||
div { class: "{ADMIN_TABLE_CLASS}",
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user