From 8537e0e1495e25f278b7f7f356c0d5b84303d8a8 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 6 Jun 2026 09:06:56 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20SQL=20=E6=9F=A5=E8=AF=A2=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E8=A1=A8=E6=A0=BC=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQL 插件改用 sqlite3 -json 输出,console_type=sqltable - 新增 SqlTableView:解析 sqlite3 -json 输出(支持多结果集),渲染为表格,可切换原始输出,无 SELECT 输出时提示「无结果集」,渲染防抖 - 配置页输出类型增加「SQL 表格」;App 按 consoleType==='sqltable' 渲染 --- src-tauri/src/plugins/sql.rs | 6 +- src/App.vue | 9 ++ src/components/SqlTableView.vue | 126 +++++++++++++++++++++++++ src/composables/useLanguageSettings.ts | 2 +- 4 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/components/SqlTableView.vue diff --git a/src-tauri/src/plugins/sql.rs b/src-tauri/src/plugins/sql.rs index bd353d3..148e0c4 100644 --- a/src-tauri/src/plugins/sql.rs +++ b/src-tauri/src/plugins/sql.rs @@ -37,14 +37,14 @@ impl LanguagePlugin for SqlPlugin { before_compile: None, extension: String::from("sql"), execute_home: None, - // 在内存 SQLite 中执行脚本(需要本机有 sqlite3) - run_command: Some(String::from("sqlite3 :memory: .read $filename")), + // 在内存 SQLite 中执行脚本并以 JSON 输出(前端渲染为表格;需要本机有 sqlite3) + run_command: Some(String::from("sqlite3 -json :memory: .read $filename")), after_compile: None, template: Some(String::from( "-- 在这里输入 SQL(默认在内存 SQLite 中执行)\nSELECT 'Hello, CodeForge' AS message;\n", )), timeout: Some(30), - console_type: Some(String::from("console")), + console_type: Some(String::from("sqltable")), icon_path: None, } } diff --git a/src/App.vue b/src/App.vue index 02d6127..44bf6f7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -174,6 +174,14 @@ :is-running="isRunning" :execution-time="lastExecutionTime" @clear="clearOutput"/> + + + @@ -347,6 +355,7 @@ import JsonView from "./components/JsonView.vue"; import MarkdownView from "./components/MarkdownView.vue"; import XmlView from "./components/XmlView.vue"; import YamlView from "./components/YamlView.vue"; +import SqlTableView from "./components/SqlTableView.vue"; import StatusBar from './components/StatusBar.vue' import About from './components/About.vue' import Settings from './components/Settings.vue' diff --git a/src/components/SqlTableView.vue b/src/components/SqlTableView.vue new file mode 100644 index 0000000..54cb95c --- /dev/null +++ b/src/components/SqlTableView.vue @@ -0,0 +1,126 @@ + + + diff --git a/src/composables/useLanguageSettings.ts b/src/composables/useLanguageSettings.ts index ce4f18c..598d4dc 100644 --- a/src/composables/useLanguageSettings.ts +++ b/src/composables/useLanguageSettings.ts @@ -33,7 +33,7 @@ export function useLanguageSettings(emit: any) } ] - const consoleTypes = [{label: '控制台', value: 'console'}, {label: 'Web', value: 'web'}, {label: 'JSON', value: 'json'}, {label: 'Markdown', value: 'markdown'}, {label: 'XML', value: 'xml'}, {label: 'YAML', value: 'yaml'}] + const consoleTypes = [{label: '控制台', value: 'console'}, {label: 'Web', value: 'web'}, {label: 'JSON', value: 'json'}, {label: 'Markdown', value: 'markdown'}, {label: 'XML', value: 'xml'}, {label: 'YAML', value: 'yaml'}, {label: 'SQL 表格', value: 'sqltable'}] const { activePlugin, From f10240ff301eb2d61f587301475c4c52344b98e5 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 6 Jun 2026 09:14:26 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20SQL=20=E6=94=B9=E7=94=A8=20rusqli?= =?UTF-8?q?te=20=E6=89=A7=E8=A1=8C=E2=80=94=E2=80=94=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=20+=20=E9=80=89=E6=8B=A9=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 run_sql 命令(rusqlite):逐条执行(UTF-8 安全的语句切分),查询返回结构化结果集,失败返回错误信息,支持指定数据库文件(空=内存) - App 对 SQL 走专用 runSql 路径(不再依赖外部 sqlite3 CLI) - SqlTableView:渲染错误框 + 非查询消息 + 多结果集表格(NULL 斜体),头部可选择/重置数据库文件(存 kv: sql-db-path) - SQL 插件标记为无需外部环境(始终可运行) --- src-tauri/src/main.rs | 6 +- src-tauri/src/plugins/sql.rs | 4 +- src-tauri/src/sql_exec.rs | 182 ++++++++++++++++++++++++++++++++ src/App.vue | 40 +++++++ src/components/SqlTableView.vue | 142 ++++++++++++------------- 5 files changed, 300 insertions(+), 74 deletions(-) create mode 100644 src-tauri/src/sql_exec.rs diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3f815d4..d3ce85b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -21,6 +21,7 @@ mod plugin; mod plugins; mod setup; mod snippets; +mod sql_exec; mod terminal; mod update; mod utils; @@ -59,6 +60,7 @@ use crate::kv::{KvStore, kv_delete, kv_get_all, kv_set}; use crate::plugin::{get_info, get_supported_languages}; use crate::setup::app::get_app_info; use crate::snippets::{Snippets, delete_snippet, get_snippets, save_snippet}; +use crate::sql_exec::run_sql; use crate::terminal::{ TerminalState, terminal_create, terminal_kill, terminal_resize, terminal_write, }; @@ -222,7 +224,9 @@ fn main() { terminal_create, terminal_write, terminal_resize, - terminal_kill + terminal_kill, + // SQL 执行 + run_sql ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/plugins/sql.rs b/src-tauri/src/plugins/sql.rs index 148e0c4..99cd738 100644 --- a/src-tauri/src/plugins/sql.rs +++ b/src-tauri/src/plugins/sql.rs @@ -23,11 +23,11 @@ impl LanguagePlugin for SqlPlugin { } fn get_version_args(&self) -> Vec<&'static str> { - vec!["--version"] + vec!["--"] } fn get_path_command(&self) -> String { - "sqlite3".to_string() + "--".to_string() } fn get_default_config(&self) -> PluginConfig { diff --git a/src-tauri/src/sql_exec.rs b/src-tauri/src/sql_exec.rs new file mode 100644 index 0000000..04c1731 --- /dev/null +++ b/src-tauri/src/sql_exec.rs @@ -0,0 +1,182 @@ +use rusqlite::Connection; +use serde::Serialize; +use serde_json::Value as JsonValue; + +#[derive(Serialize)] +pub struct SqlResultSet { + columns: Vec, + rows: Vec>, +} + +#[derive(Serialize)] +pub struct SqlRunResult { + result_sets: Vec, + messages: Vec, + error: Option, + elapsed_ms: u128, +} + +fn value_to_json(v: rusqlite::types::Value) -> JsonValue { + use rusqlite::types::Value::*; + match v { + Null => JsonValue::Null, + Integer(i) => JsonValue::from(i), + Real(f) => JsonValue::from(f), + Text(s) => JsonValue::from(s), + Blob(b) => JsonValue::from(format!("", b.len())), + } +} + +/// 把脚本按分号切成多条语句(处理字符串字面量与注释,UTF-8 安全) +fn split_sql(sql: &str) -> Vec { + #[derive(PartialEq)] + enum S { + Normal, + Single, + Double, + Line, + Block, + } + let chars: Vec = sql.chars().collect(); + let n = chars.len(); + let mut out = Vec::new(); + let mut cur = String::new(); + let mut state = S::Normal; + let mut i = 0; + while i < n { + let c = chars[i]; + let next = if i + 1 < n { Some(chars[i + 1]) } else { None }; + match state { + S::Normal => { + if c == '\'' { + state = S::Single; + cur.push(c); + } else if c == '"' { + state = S::Double; + cur.push(c); + } else if c == '-' && next == Some('-') { + state = S::Line; + cur.push(c); + } else if c == '/' && next == Some('*') { + state = S::Block; + cur.push(c); + } else if c == ';' { + let t = cur.trim().to_string(); + if !t.is_empty() { + out.push(t); + } + cur.clear(); + } else { + cur.push(c); + } + } + S::Single => { + cur.push(c); + if c == '\'' { + state = S::Normal; + } + } + S::Double => { + cur.push(c); + if c == '"' { + state = S::Normal; + } + } + S::Line => { + cur.push(c); + if c == '\n' { + state = S::Normal; + } + } + S::Block => { + cur.push(c); + if c == '*' && next == Some('/') { + cur.push('/'); + i += 1; + state = S::Normal; + } + } + } + i += 1; + } + let t = cur.trim().to_string(); + if !t.is_empty() { + out.push(t); + } + out +} + +/// 执行 SQL 脚本。db_path 为空则使用内存数据库;逐条执行,查询返回结果集,失败返回错误信息。 +#[tauri::command] +pub async fn run_sql(sql: String, db_path: Option) -> Result { + tokio::task::spawn_blocking(move || { + let start = std::time::Instant::now(); + let conn = match db_path.as_deref() { + Some(p) if !p.trim().is_empty() => Connection::open(p), + _ => Connection::open_in_memory(), + } + .map_err(|e| format!("打开数据库失败: {}", e))?; + + let mut result = SqlRunResult { + result_sets: Vec::new(), + messages: Vec::new(), + error: None, + elapsed_ms: 0, + }; + + 'stmts: for stmt_sql in split_sql(&sql) { + let mut stmt = match conn.prepare(&stmt_sql) { + Ok(s) => s, + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + }; + let ncol = stmt.column_count(); + if ncol > 0 { + let columns: Vec = + stmt.column_names().iter().map(|s| s.to_string()).collect(); + let rows_iter = stmt.query_map([], |row| { + let mut v = Vec::with_capacity(ncol); + for idx in 0..ncol { + let val: rusqlite::types::Value = row.get(idx)?; + v.push(value_to_json(val)); + } + Ok(v) + }); + match rows_iter { + Ok(it) => { + let mut rows = Vec::new(); + for r in it { + match r { + Ok(rw) => rows.push(rw), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + result.result_sets.push(SqlResultSet { columns, rows }); + } + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } else { + match stmt.execute([]) { + Ok(affected) => result.messages.push(format!("OK,影响 {} 行", affected)), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + } + + result.elapsed_ms = start.elapsed().as_millis(); + Ok(result) + }) + .await + .map_err(|e| format!("SQL 任务失败: {}", e))? +} diff --git a/src/App.vue b/src/App.vue index 44bf6f7..de2f86a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1295,6 +1295,38 @@ const showRunPrompt = ref(false) // 包装运行:仅编辑器模式下点击运行时自动展开控制台;关联文件则按策略就地运行 // 运行选中片段:以选中文本作为临时代码运行(不就地、不关联文件) +// SQL 走专用执行(rusqlite,结构化结果 + 错误 + 可选数据库文件) +const runSql = async (sqlOverride?: string) => { + const sql = sqlOverride ?? code.value + if (!sql.trim()) { + toast.info('没有可执行的 SQL') + return + } + if (layoutMode.value === 'editor') { + showConsole.value = true + } + isRunning.value = true + output.value = '' + isSuccess.value = false + try { + const dbPath = kvGet('sql-db-path') || null + const res = await invoke('run_sql', {sql, dbPath}) + output.value = JSON.stringify(res) + isSuccess.value = !res.error + lastExecutionTime.value = res.elapsed_ms || 0 + if (res.error) { + toast.error('SQL 执行失败') + } + } + catch (error) { + output.value = JSON.stringify({result_sets: [], messages: [], error: String(error)}) + toast.error('SQL 执行失败: ' + error) + } + finally { + isRunning.value = false + } +} + const runSelection = () => { const view = editorView.value if (!view) { @@ -1306,6 +1338,10 @@ const runSelection = () => { return } const selected = view.state.sliceDoc(from, to) + if (currentLanguage.value === 'sql') { + runSql(selected) + return + } if (layoutMode.value === 'editor') { showConsole.value = true } @@ -1313,6 +1349,10 @@ const runSelection = () => { } const handleRunCode = async () => { + if (currentLanguage.value === 'sql') { + runSql() + return + } if (layoutMode.value === 'editor') { showConsole.value = true } diff --git a/src/components/SqlTableView.vue b/src/components/SqlTableView.vue index 54cb95c..dcafc19 100644 --- a/src/components/SqlTableView.vue +++ b/src/components/SqlTableView.vue @@ -1,55 +1,64 @@ @@ -57,7 +66,9 @@ diff --git a/src/composables/useDbConnections.ts b/src/composables/useDbConnections.ts new file mode 100644 index 0000000..ddf4be1 --- /dev/null +++ b/src/composables/useDbConnections.ts @@ -0,0 +1,88 @@ +import {ref} from 'vue' +import {kvGet, kvGetJSON, kvSet, kvSetJSON} from './useKvStore' + +export interface DataSource +{ + kind: 'memory' | 'sqlite' | 'mysql' + file?: string + host?: string + port?: number + user?: string + password?: string + database?: string +} + +export interface DbConnection extends DataSource +{ + id: string + name: string +} + +const CONN_KEY = 'sql-connections' +const REF_KEY = 'sql-source-ref' + +const genId = () => `db-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` + +// 模块级共享:连接列表 + 当前数据源引用(token:memory / conn: / file:) +const connections = ref(kvGetJSON(CONN_KEY, [])) +const activeRef = ref(kvGet(REF_KEY) || 'memory') + +export function useDbConnections() +{ + const persist = () => kvSetJSON(CONN_KEY, connections.value) + + const add = (c: Omit) => { + connections.value.push({...c, id: genId()}) + persist() + } + const update = (id: string, patch: Partial) => { + const i = connections.value.findIndex(x => x.id === id) + if (i >= 0) { + connections.value[i] = {...connections.value[i], ...patch} + persist() + } + } + const remove = (id: string) => { + connections.value = connections.value.filter(x => x.id !== id) + persist() + if (activeRef.value === `conn:${id}`) { + setActiveRef('memory') + } + } + + const setActiveRef = (token: string) => { + activeRef.value = token + kvSet(REF_KEY, token) + } + + // 把当前引用解析为可执行的数据源 + const resolveActiveSource = (): DataSource => { + const t = activeRef.value + if (t.startsWith('conn:')) { + const conn = connections.value.find(c => c.id === t.slice(5)) + if (conn) { + const {id: _i, name: _n, ...rest} = conn + return rest + } + } + else if (t.startsWith('file:')) { + return {kind: 'sqlite', file: t.slice(5)} + } + return {kind: 'memory'} + } + + // 当前数据源展示名 + const activeLabel = (): string => { + const t = activeRef.value + if (t.startsWith('conn:')) { + return connections.value.find(c => c.id === t.slice(5))?.name || '(已删除)' + } + if (t.startsWith('file:')) { + const p = t.slice(5) + return p.split(/[\\/]/).pop() || p + } + return '内存数据库' + } + + return {connections, activeRef, add, update, remove, setActiveRef, resolveActiveSource, activeLabel} +} diff --git a/src/composables/useSettings.ts b/src/composables/useSettings.ts index 0925f98..3ec1e78 100644 --- a/src/composables/useSettings.ts +++ b/src/composables/useSettings.ts @@ -1,5 +1,5 @@ import { nextTick, ref } from 'vue' -import { BracesIcon, CodeIcon, Database, FileText, Globe, Keyboard, ShieldIcon, Sparkles } from 'lucide-vue-next' +import { BracesIcon, CodeIcon, Database, FileText, Globe, Keyboard, Server, ShieldIcon, Sparkles } from 'lucide-vue-next' export function useSettings(emit: any) { @@ -13,6 +13,7 @@ export function useSettings(emit: any) { key: 'editor', label: '编辑器', icon: CodeIcon }, { key: 'shortcut', label: '快捷键', icon: Keyboard }, { key: 'ai', label: 'AI', icon: Sparkles }, + { key: 'database', label: '数据库', icon: Server }, { key: 'language', label: '语言', icon: BracesIcon }, { key: 'network', label: '网络', icon: Globe }, { key: 'cache', label: '缓存', icon: Database }, From 2f76d274715e6d1940594fcf5d77c16df20a8f13 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 6 Jun 2026 09:32:29 +0800 Subject: [PATCH 05/13] =?UTF-8?q?refactor:=20SQL=20=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=99=A8=E6=8B=86=E5=88=86=E4=B8=BA=E6=8F=92=E4=BB=B6=E5=BC=8F?= =?UTF-8?q?=20db=20=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src-tauri/src/db/:mod.rs(共享 DataSource/SqlRunResult/split_sql + DbExecutor trait + 注册 + run_sql 命令),sqlite.rs / mysql.rs 各实现一个执行器 - 新增数据库类型只需加一个文件实现 DbExecutor 并在 executors() 注册一行 - 移除单文件 sql_exec.rs;main.rs 改用 crate::db::run_sql --- src-tauri/src/db/mod.rs | 181 +++++++++++++++++++++ src-tauri/src/db/mysql.rs | 98 ++++++++++++ src-tauri/src/db/sqlite.rs | 87 ++++++++++ src-tauri/src/main.rs | 4 +- src-tauri/src/sql_exec.rs | 316 ------------------------------------- 5 files changed, 368 insertions(+), 318 deletions(-) create mode 100644 src-tauri/src/db/mod.rs create mode 100644 src-tauri/src/db/mysql.rs create mode 100644 src-tauri/src/db/sqlite.rs delete mode 100644 src-tauri/src/sql_exec.rs diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs new file mode 100644 index 0000000..46b974f --- /dev/null +++ b/src-tauri/src/db/mod.rs @@ -0,0 +1,181 @@ +//! 数据库执行器:插件式架构。 +//! 每种数据库类型实现 `DbExecutor` 并在 `executors()` 中注册一行,新增类型互不影响。 + +mod mysql; +mod sqlite; + +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +#[derive(Serialize)] +pub(crate) struct SqlResultSet { + pub(crate) columns: Vec, + pub(crate) rows: Vec>, +} + +#[derive(Serialize)] +pub struct SqlRunResult { + pub(crate) result_sets: Vec, + pub(crate) messages: Vec, + pub(crate) error: Option, + pub(crate) elapsed_ms: u128, +} + +impl SqlRunResult { + pub(crate) fn new() -> Self { + Self { + result_sets: Vec::new(), + messages: Vec::new(), + error: None, + elapsed_ms: 0, + } + } +} + +/// 数据源描述:内存 / SQLite 文件 / MySQL(后续可扩展更多字段) +#[derive(Deserialize)] +pub struct DataSource { + pub kind: String, + #[serde(default)] + pub file: Option, + #[serde(default)] + pub host: Option, + #[serde(default)] + pub port: Option, + #[serde(default)] + pub user: Option, + #[serde(default)] + pub password: Option, + #[serde(default)] + pub database: Option, +} + +/// 数据库执行器接口:新增数据库类型只需实现本 trait 并在 executors() 注册。 +pub(crate) trait DbExecutor: Send + Sync { + /// 是否处理该数据源类型(如 sqlite 同时处理 "sqlite" 与 "memory") + fn handles(&self, kind: &str) -> bool; + /// 执行脚本,返回结构化结果(错误写入 result.error,不以 Err 形式返回) + fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult; +} + +/// 已注册的执行器。新增数据库类型:在此加一行。 +fn executors() -> Vec> { + vec![ + Box::new(sqlite::SqliteExecutor), + Box::new(mysql::MysqlExecutor), + ] +} + +/// 把脚本按分号切成多条语句(处理字符串字面量、反引号与注释,UTF-8 安全) +pub(crate) fn split_sql(sql: &str) -> Vec { + #[derive(PartialEq)] + enum S { + Normal, + Single, + Double, + Backtick, + Line, + Block, + } + let chars: Vec = sql.chars().collect(); + let n = chars.len(); + let mut out = Vec::new(); + let mut cur = String::new(); + let mut state = S::Normal; + let mut i = 0; + while i < n { + let c = chars[i]; + let next = if i + 1 < n { Some(chars[i + 1]) } else { None }; + match state { + S::Normal => match c { + '\'' => { + state = S::Single; + cur.push(c); + } + '"' => { + state = S::Double; + cur.push(c); + } + '`' => { + state = S::Backtick; + cur.push(c); + } + '-' if next == Some('-') => { + state = S::Line; + cur.push(c); + } + '/' if next == Some('*') => { + state = S::Block; + cur.push(c); + } + ';' => { + let t = cur.trim().to_string(); + if !t.is_empty() { + out.push(t); + } + cur.clear(); + } + _ => cur.push(c), + }, + S::Single => { + cur.push(c); + if c == '\'' { + state = S::Normal; + } + } + S::Double => { + cur.push(c); + if c == '"' { + state = S::Normal; + } + } + S::Backtick => { + cur.push(c); + if c == '`' { + state = S::Normal; + } + } + S::Line => { + cur.push(c); + if c == '\n' { + state = S::Normal; + } + } + S::Block => { + cur.push(c); + if c == '*' && next == Some('/') { + cur.push('/'); + i += 1; + state = S::Normal; + } + } + } + i += 1; + } + let t = cur.trim().to_string(); + if !t.is_empty() { + out.push(t); + } + out +} + +/// 执行 SQL 脚本,按 source.kind 派发到对应执行器。 +#[tauri::command] +pub async fn run_sql(sql: String, source: DataSource) -> Result { + tokio::task::spawn_blocking(move || { + let start = std::time::Instant::now(); + let execs = executors(); + let mut result = match execs.iter().find(|e| e.handles(&source.kind)) { + Some(exec) => exec.run(&sql, &source), + None => { + let mut r = SqlRunResult::new(); + r.error = Some(format!("不支持的数据源类型: {}", source.kind)); + r + } + }; + result.elapsed_ms = start.elapsed().as_millis(); + Ok(result) + }) + .await + .map_err(|e| format!("SQL 任务失败: {}", e))? +} diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs new file mode 100644 index 0000000..21e7e10 --- /dev/null +++ b/src-tauri/src/db/mysql.rs @@ -0,0 +1,98 @@ +use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, split_sql}; +use serde_json::Value as JsonValue; + +pub(crate) struct MysqlExecutor; + +fn value_to_json(v: &mysql::Value) -> JsonValue { + use mysql::Value::*; + match v { + NULL => JsonValue::Null, + Int(i) => JsonValue::from(*i), + UInt(u) => JsonValue::from(*u), + Float(f) => JsonValue::from(*f as f64), + Double(d) => JsonValue::from(*d), + Bytes(b) => match std::str::from_utf8(b) { + Ok(s) => JsonValue::from(s.to_string()), + Err(_) => JsonValue::from(format!("<{} bytes>", b.len())), + }, + Date(y, mo, d, h, mi, s, _us) => JsonValue::from(format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + y, mo, d, h, mi, s + )), + Time(neg, d, h, mi, s, _us) => JsonValue::from(format!( + "{}{} {:02}:{:02}:{:02}", + if *neg { "-" } else { "" }, + d, + h, + mi, + s + )), + } +} + +impl DbExecutor for MysqlExecutor { + fn handles(&self, kind: &str) -> bool { + kind == "mysql" + } + + fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { + use mysql::prelude::Queryable; + let mut result = SqlRunResult::new(); + + let opts = mysql::OptsBuilder::new() + .ip_or_hostname( + source + .host + .clone() + .or_else(|| Some("127.0.0.1".to_string())), + ) + .tcp_port(source.port.unwrap_or(3306)) + .user(source.user.clone()) + .pass(source.password.clone()) + .db_name(source.database.clone()); + + let mut conn = match mysql::Conn::new(opts) { + Ok(c) => c, + Err(e) => { + result.error = Some(format!("连接 MySQL 失败: {}", e)); + return result; + } + }; + + 'stmts: for stmt_sql in split_sql(sql) { + let mut qr = match conn.query_iter(&stmt_sql) { + Ok(q) => q, + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + }; + let columns: Vec = qr + .columns() + .as_ref() + .iter() + .map(|c| c.name_str().to_string()) + .collect(); + if columns.is_empty() { + let affected = qr.affected_rows(); + result.messages.push(format!("OK,影响 {} 行", affected)); + } else { + let mut rows = Vec::new(); + for r in qr.by_ref() { + match r { + Ok(row) => { + let vals = row.unwrap(); + rows.push(vals.iter().map(value_to_json).collect()); + } + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + result.result_sets.push(SqlResultSet { columns, rows }); + } + } + result + } +} diff --git a/src-tauri/src/db/sqlite.rs b/src-tauri/src/db/sqlite.rs new file mode 100644 index 0000000..9c563a5 --- /dev/null +++ b/src-tauri/src/db/sqlite.rs @@ -0,0 +1,87 @@ +use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, split_sql}; +use rusqlite::Connection; +use serde_json::Value as JsonValue; + +pub(crate) struct SqliteExecutor; + +fn value_to_json(v: rusqlite::types::Value) -> JsonValue { + use rusqlite::types::Value::*; + match v { + Null => JsonValue::Null, + Integer(i) => JsonValue::from(i), + Real(f) => JsonValue::from(f), + Text(s) => JsonValue::from(s), + Blob(b) => JsonValue::from(format!("", b.len())), + } +} + +impl DbExecutor for SqliteExecutor { + fn handles(&self, kind: &str) -> bool { + kind == "sqlite" || kind == "memory" + } + + fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { + let mut result = SqlRunResult::new(); + let conn = match source.file.as_deref() { + Some(p) if !p.trim().is_empty() => Connection::open(p), + _ => Connection::open_in_memory(), + }; + let conn = match conn { + Ok(c) => c, + Err(e) => { + result.error = Some(format!("打开数据库失败: {}", e)); + return result; + } + }; + + 'stmts: for stmt_sql in split_sql(sql) { + let mut stmt = match conn.prepare(&stmt_sql) { + Ok(s) => s, + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + }; + let ncol = stmt.column_count(); + if ncol > 0 { + let columns: Vec = + stmt.column_names().iter().map(|s| s.to_string()).collect(); + let rows_iter = stmt.query_map([], |row| { + let mut v = Vec::with_capacity(ncol); + for idx in 0..ncol { + v.push(value_to_json(row.get(idx)?)); + } + Ok(v) + }); + match rows_iter { + Ok(it) => { + let mut rows = Vec::new(); + for r in it { + match r { + Ok(rw) => rows.push(rw), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + result.result_sets.push(SqlResultSet { columns, rows }); + } + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } else { + match stmt.execute([]) { + Ok(affected) => result.messages.push(format!("OK,影响 {} 行", affected)), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + } + result + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d3ce85b..3f16a92 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,6 +8,7 @@ mod ai_history; mod cache; mod config; mod custom_plugin_commands; +mod db; mod env_commands; mod env_manager; mod env_providers; @@ -21,7 +22,6 @@ mod plugin; mod plugins; mod setup; mod snippets; -mod sql_exec; mod terminal; mod update; mod utils; @@ -36,6 +36,7 @@ use crate::custom_plugin_commands::{ add_custom_plugin, get_custom_plugins, remove_custom_plugin, save_custom_icon, update_custom_plugin, }; +use crate::db::run_sql; use crate::env_commands::{ EnvironmentManagerState, download_and_install_version, get_environment_info, get_supported_environment_languages, switch_environment_version, uninstall_environment_version, @@ -60,7 +61,6 @@ use crate::kv::{KvStore, kv_delete, kv_get_all, kv_set}; use crate::plugin::{get_info, get_supported_languages}; use crate::setup::app::get_app_info; use crate::snippets::{Snippets, delete_snippet, get_snippets, save_snippet}; -use crate::sql_exec::run_sql; use crate::terminal::{ TerminalState, terminal_create, terminal_kill, terminal_resize, terminal_write, }; diff --git a/src-tauri/src/sql_exec.rs b/src-tauri/src/sql_exec.rs deleted file mode 100644 index e15aa96..0000000 --- a/src-tauri/src/sql_exec.rs +++ /dev/null @@ -1,316 +0,0 @@ -use rusqlite::Connection; -use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; - -#[derive(Serialize)] -pub struct SqlResultSet { - columns: Vec, - rows: Vec>, -} - -#[derive(Serialize)] -pub struct SqlRunResult { - result_sets: Vec, - messages: Vec, - error: Option, - elapsed_ms: u128, -} - -impl SqlRunResult { - fn new() -> Self { - Self { - result_sets: Vec::new(), - messages: Vec::new(), - error: None, - elapsed_ms: 0, - } - } -} - -/// 数据源描述:内存 / SQLite 文件 / MySQL -#[derive(Deserialize)] -pub struct DataSource { - pub kind: String, - #[serde(default)] - pub file: Option, - #[serde(default)] - pub host: Option, - #[serde(default)] - pub port: Option, - #[serde(default)] - pub user: Option, - #[serde(default)] - pub password: Option, - #[serde(default)] - pub database: Option, -} - -/// 把脚本按分号切成多条语句(处理字符串字面量与注释,UTF-8 安全) -fn split_sql(sql: &str) -> Vec { - #[derive(PartialEq)] - enum S { - Normal, - Single, - Double, - Backtick, - Line, - Block, - } - let chars: Vec = sql.chars().collect(); - let n = chars.len(); - let mut out = Vec::new(); - let mut cur = String::new(); - let mut state = S::Normal; - let mut i = 0; - while i < n { - let c = chars[i]; - let next = if i + 1 < n { Some(chars[i + 1]) } else { None }; - match state { - S::Normal => match c { - '\'' => { - state = S::Single; - cur.push(c); - } - '"' => { - state = S::Double; - cur.push(c); - } - '`' => { - state = S::Backtick; - cur.push(c); - } - '-' if next == Some('-') => { - state = S::Line; - cur.push(c); - } - '/' if next == Some('*') => { - state = S::Block; - cur.push(c); - } - ';' => { - let t = cur.trim().to_string(); - if !t.is_empty() { - out.push(t); - } - cur.clear(); - } - _ => cur.push(c), - }, - S::Single => { - cur.push(c); - if c == '\'' { - state = S::Normal; - } - } - S::Double => { - cur.push(c); - if c == '"' { - state = S::Normal; - } - } - S::Backtick => { - cur.push(c); - if c == '`' { - state = S::Normal; - } - } - S::Line => { - cur.push(c); - if c == '\n' { - state = S::Normal; - } - } - S::Block => { - cur.push(c); - if c == '*' && next == Some('/') { - cur.push('/'); - i += 1; - state = S::Normal; - } - } - } - i += 1; - } - let t = cur.trim().to_string(); - if !t.is_empty() { - out.push(t); - } - out -} - -// ===== SQLite ===== -fn sqlite_value_to_json(v: rusqlite::types::Value) -> JsonValue { - use rusqlite::types::Value::*; - match v { - Null => JsonValue::Null, - Integer(i) => JsonValue::from(i), - Real(f) => JsonValue::from(f), - Text(s) => JsonValue::from(s), - Blob(b) => JsonValue::from(format!("", b.len())), - } -} - -fn run_sqlite(sql: &str, file: Option<&str>) -> SqlRunResult { - let mut result = SqlRunResult::new(); - let conn = match file { - Some(p) if !p.trim().is_empty() => Connection::open(p), - _ => Connection::open_in_memory(), - }; - let conn = match conn { - Ok(c) => c, - Err(e) => { - result.error = Some(format!("打开数据库失败: {}", e)); - return result; - } - }; - - 'stmts: for stmt_sql in split_sql(sql) { - let mut stmt = match conn.prepare(&stmt_sql) { - Ok(s) => s, - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - }; - let ncol = stmt.column_count(); - if ncol > 0 { - let columns: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); - let rows_iter = stmt.query_map([], |row| { - let mut v = Vec::with_capacity(ncol); - for idx in 0..ncol { - v.push(sqlite_value_to_json(row.get(idx)?)); - } - Ok(v) - }); - match rows_iter { - Ok(it) => { - let mut rows = Vec::new(); - for r in it { - match r { - Ok(rw) => rows.push(rw), - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } - result.result_sets.push(SqlResultSet { columns, rows }); - } - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } else { - match stmt.execute([]) { - Ok(affected) => result.messages.push(format!("OK,影响 {} 行", affected)), - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } - } - result -} - -// ===== MySQL ===== -fn mysql_value_to_json(v: &mysql::Value) -> JsonValue { - use mysql::Value::*; - match v { - NULL => JsonValue::Null, - Int(i) => JsonValue::from(*i), - UInt(u) => JsonValue::from(*u), - Float(f) => JsonValue::from(*f as f64), - Double(d) => JsonValue::from(*d), - Bytes(b) => match std::str::from_utf8(b) { - Ok(s) => JsonValue::from(s.to_string()), - Err(_) => JsonValue::from(format!("<{} bytes>", b.len())), - }, - Date(y, mo, d, h, mi, s, _us) => JsonValue::from(format!( - "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", - y, mo, d, h, mi, s - )), - Time(neg, d, h, mi, s, _us) => JsonValue::from(format!( - "{}{} {:02}:{:02}:{:02}", - if *neg { "-" } else { "" }, - d, - h, - mi, - s - )), - } -} - -fn run_mysql(sql: &str, src: &DataSource) -> SqlRunResult { - use mysql::prelude::Queryable; - let mut result = SqlRunResult::new(); - - let opts = mysql::OptsBuilder::new() - .ip_or_hostname(src.host.clone().or_else(|| Some("127.0.0.1".to_string()))) - .tcp_port(src.port.unwrap_or(3306)) - .user(src.user.clone()) - .pass(src.password.clone()) - .db_name(src.database.clone()); - - let mut conn = match mysql::Conn::new(opts) { - Ok(c) => c, - Err(e) => { - result.error = Some(format!("连接 MySQL 失败: {}", e)); - return result; - } - }; - - 'stmts: for stmt_sql in split_sql(sql) { - let mut qr = match conn.query_iter(&stmt_sql) { - Ok(q) => q, - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - }; - let columns: Vec = qr - .columns() - .as_ref() - .iter() - .map(|c| c.name_str().to_string()) - .collect(); - if columns.is_empty() { - let affected = qr.affected_rows(); - result.messages.push(format!("OK,影响 {} 行", affected)); - } else { - let mut rows = Vec::new(); - for r in qr.by_ref() { - match r { - Ok(row) => { - let vals = row.unwrap(); - rows.push(vals.iter().map(mysql_value_to_json).collect()); - } - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } - result.result_sets.push(SqlResultSet { columns, rows }); - } - } - result -} - -/// 执行 SQL 脚本。根据 source.kind 选择数据源(memory/sqlite/mysql)。 -#[tauri::command] -pub async fn run_sql(sql: String, source: DataSource) -> Result { - tokio::task::spawn_blocking(move || { - let start = std::time::Instant::now(); - let mut result = match source.kind.as_str() { - "mysql" => run_mysql(&sql, &source), - "sqlite" => run_sqlite(&sql, source.file.as_deref()), - _ => run_sqlite(&sql, None), - }; - result.elapsed_ms = start.elapsed().as_millis(); - Ok(result) - }) - .await - .map_err(|e| format!("SQL 任务失败: {}", e))? -} From ddd4f3a31622b74634a68a839902684ce6d9555a Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 6 Jun 2026 09:36:13 +0800 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20SQL=20=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=E7=A7=BB=E5=88=B0=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E9=A1=B6=E9=83=A8=EF=BC=8C=E8=BF=90=E8=A1=8C=E5=89=8D?= =?UTF-8?q?=E5=8D=B3=E5=8F=AF=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出 SqlSourceSelect 组件;打开 .sql 文件时编辑器头部即显示数据源下拉(内存/已配置连接/临时 SQLite 文件),不必等运行后在输出面板里选。SqlTableView 复用同一组件 --- src/App.vue | 3 +++ src/components/SqlSourceSelect.vue | 38 ++++++++++++++++++++++++++++++ src/components/SqlTableView.vue | 36 ++++------------------------ 3 files changed, 46 insertions(+), 31 deletions(-) create mode 100644 src/components/SqlSourceSelect.vue diff --git a/src/App.vue b/src/App.vue index 0c7d4e3..b6d2a77 100644 --- a/src/App.vue +++ b/src/App.vue @@ -89,6 +89,7 @@ · {{ currentFileName }} +
@@ -205,6 +206,7 @@ · {{ currentFileName }} +
@@ -356,6 +358,7 @@ import MarkdownView from "./components/MarkdownView.vue"; import XmlView from "./components/XmlView.vue"; import YamlView from "./components/YamlView.vue"; import SqlTableView from "./components/SqlTableView.vue"; +import SqlSourceSelect from "./components/SqlSourceSelect.vue"; import StatusBar from './components/StatusBar.vue' import About from './components/About.vue' import Settings from './components/Settings.vue' diff --git a/src/components/SqlSourceSelect.vue b/src/components/SqlSourceSelect.vue new file mode 100644 index 0000000..3aeccee --- /dev/null +++ b/src/components/SqlSourceSelect.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/SqlTableView.vue b/src/components/SqlTableView.vue index 4698212..9435a83 100644 --- a/src/components/SqlTableView.vue +++ b/src/components/SqlTableView.vue @@ -7,17 +7,7 @@ 运行中… {{ result.elapsed_ms }} ms -
- - -
+
+ @@ -22,7 +24,7 @@ diff --git a/src/components/SqlTableView.vue b/src/components/SqlTableView.vue index 9435a83..3729aad 100644 --- a/src/components/SqlTableView.vue +++ b/src/components/SqlTableView.vue @@ -5,7 +5,7 @@ SQL 结果 运行中… - {{ result.elapsed_ms }} ms + {{ executionTime }} ms @@ -14,53 +14,19 @@ -
-
运行后在此查看 SQL 结果(数据源:{{ activeLabel() }})
- - +
+
From 49497f589b9e7074c2cb80eaac3a00d191e418f4 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 6 Jun 2026 13:00:20 +0800 Subject: [PATCH 12/13] =?UTF-8?q?feat:=20SQL=20=E7=BB=93=E6=9E=9C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=9B=BE=E8=A1=A8=E6=B8=B2=E6=9F=93=EF=BC=8C=E6=8B=96?= =?UTF-8?q?=E6=8B=BD=E9=85=8D=E7=BD=AE=E7=BB=B4=E5=BA=A6/=E6=8C=87?= =?UTF-8?q?=E6=A0=87(=E6=9F=B1=E7=8A=B6=E5=9B=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 echarts(按需模块化引入) - 新增可复用图表组件 charts/(与数据源解耦,后续 CSV 等可复用): - shape.ts: 表格数据按维度聚合(求和/计数/平均/最大/最小) - ChartPanel.vue: 拖拽字段到维度/指标配置图表,数值列自动识别 - BarChart.vue: echarts 柱状图渲染,支持横向/堆叠,配色跟随主题 - SqlTableView 增加 表格/图表 视图切换,图表基于首个结果集 --- package.json | 1 + src/components/SqlTableView.vue | 54 ++++++++-- src/components/charts/BarChart.vue | 73 +++++++++++++ src/components/charts/ChartPanel.vue | 150 +++++++++++++++++++++++++++ src/components/charts/shape.ts | 119 +++++++++++++++++++++ 5 files changed, 391 insertions(+), 6 deletions(-) create mode 100644 src/components/charts/BarChart.vue create mode 100644 src/components/charts/ChartPanel.vue create mode 100644 src/components/charts/shape.ts diff --git a/package.json b/package.json index 50e2f35..e834eb8 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", "dompurify": "^3.4.8", + "echarts": "^6.1.0", "js-yaml": "^4.2.0", "lodash-es": "^4.17.21", "lucide-vue-next": "^0.539.0", diff --git a/src/components/SqlTableView.vue b/src/components/SqlTableView.vue index 3729aad..6ec43f3 100644 --- a/src/components/SqlTableView.vue +++ b/src/components/SqlTableView.vue @@ -9,24 +9,42 @@ - +
+ +
+ + +
+ +
-
+ +
+ +
+ +
diff --git a/src/components/charts/BarChart.vue b/src/components/charts/BarChart.vue new file mode 100644 index 0000000..2bc252c --- /dev/null +++ b/src/components/charts/BarChart.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/components/charts/ChartPanel.vue b/src/components/charts/ChartPanel.vue new file mode 100644 index 0000000..3dfc775 --- /dev/null +++ b/src/components/charts/ChartPanel.vue @@ -0,0 +1,150 @@ + + + diff --git a/src/components/charts/shape.ts b/src/components/charts/shape.ts new file mode 100644 index 0000000..8c36f6e --- /dev/null +++ b/src/components/charts/shape.ts @@ -0,0 +1,119 @@ +// 表格数据 → 图表数据的塑形工具(与具体数据源解耦:SQL 结果、CSV 等均可复用) + +export type AggKind = 'sum' | 'count' | 'avg' | 'max' | 'min' + +export interface TableData { + columns: string[] + rows: any[][] +} + +export interface ShapedSeries { + name: string + data: (number | null)[] +} + +export interface ShapedData { + categories: string[] + series: ShapedSeries[] +} + +export const AGG_LABELS: Record = { + sum: '求和', + count: '计数', + avg: '平均', + max: '最大', + min: '最小' +} + +/** 判断某列是否数值型(抽样前若干非空值) */ +export function isNumericColumn(rows: any[][], colIndex: number): boolean { + let seen = 0 + for (const row of rows) { + const v = row[colIndex] + if (v === null || v === undefined || v === '') { + continue + } + seen++ + if (typeof v !== 'number' && isNaN(Number(v))) { + return false + } + if (seen >= 20) { + break + } + } + return seen > 0 +} + +function aggregate(values: number[], kind: AggKind): number { + if (values.length === 0) { + return 0 + } + switch (kind) { + case 'sum': + return values.reduce((a, b) => a + b, 0) + case 'avg': + return values.reduce((a, b) => a + b, 0) / values.length + case 'max': + return Math.max(...values) + case 'min': + return Math.min(...values) + case 'count': + return values.length + } +} + +/** + * 按维度分组聚合指标。 + * - dimension:分类列;同一维度值的多行会被聚合 + * - metrics:一个或多个数值列,每个生成一条 series + * - agg:聚合方式;count 时统计非空行数(与具体数值无关) + */ +export function aggregateByDimension( + data: TableData, + dimension: string, + metrics: string[], + agg: AggKind +): ShapedData { + const dimIdx = data.columns.indexOf(dimension) + const metricIdx = metrics.map(m => data.columns.indexOf(m)) + if (dimIdx < 0 || metricIdx.some(i => i < 0)) { + return {categories: [], series: []} + } + + const order: string[] = [] + // 维度值 -> 每个指标的数值数组 + const buckets = new Map() + + for (const row of data.rows) { + const raw = row[dimIdx] + const key = raw === null || raw === undefined ? '(空)' : String(raw) + if (!buckets.has(key)) { + buckets.set(key, metricIdx.map(() => [])) + order.push(key) + } + const slot = buckets.get(key)! + metricIdx.forEach((mi, j) => { + const v = row[mi] + if (agg === 'count') { + if (v !== null && v !== undefined && v !== '') { + slot[j].push(1) + } + return + } + const num = typeof v === 'number' ? v : Number(v) + if (!isNaN(num)) { + slot[j].push(num) + } + }) + } + + const series: ShapedSeries[] = metrics.map((name, j) => ({ + name: agg === 'count' ? `${name}(计数)` : name, + data: order.map(key => { + const vals = buckets.get(key)![j] + return vals.length === 0 ? null : aggregate(vals, agg) + }) + })) + + return {categories: order, series} +} From c817768edbc1faa56cdd8107bdc73b7d120214cd Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 6 Jun 2026 13:05:54 +0800 Subject: [PATCH 13/13] =?UTF-8?q?feat:=20=E5=9B=BE=E8=A1=A8=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=A2=9E=E5=BC=BA=EF=BC=8C=E7=BB=B4=E5=BA=A6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E4=B8=AA=E5=B9=B6=E6=96=B0=E5=A2=9E=E6=8E=92?= =?UTF-8?q?=E5=BA=8F/TopN/=E6=95=B0=E5=80=BC=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 维度改为多选: 首个作分类轴, 其余作分组维度(透视拆分系列) - 多指标 × 多分组自动组合为多条系列 - 新增聚合后排序(升/降序按指标合计)、显示前 N 项、数值标签开关 - shape.ts 用通用 pivot + sortAndLimit 替换单维度聚合 --- src/components/charts/BarChart.vue | 4 +- src/components/charts/ChartPanel.vue | 87 ++++++++++++++-------- src/components/charts/shape.ts | 104 +++++++++++++++++++-------- 3 files changed, 135 insertions(+), 60 deletions(-) diff --git a/src/components/charts/BarChart.vue b/src/components/charts/BarChart.vue index 2bc252c..bcbdae0 100644 --- a/src/components/charts/BarChart.vue +++ b/src/components/charts/BarChart.vue @@ -17,6 +17,7 @@ const props = defineProps<{ series: { name: string; data: (number | null)[] }[] horizontal?: boolean stacked?: boolean + showLabel?: boolean }>() const {isDark} = useTheme() @@ -40,6 +41,7 @@ const buildOption = (): echarts.EChartsCoreOption => { stack: props.stacked ? 'total' : undefined, data: s.data, barMaxWidth: 48, + label: {show: props.showLabel, position: props.horizontal ? 'right' : 'top', color: text, fontSize: 10}, emphasis: {focus: 'series'} })) } @@ -63,7 +65,7 @@ onMounted(() => { ro.observe(el.value) }) -watch(() => [props.categories, props.series, props.horizontal, props.stacked, isDark.value], render, {deep: true}) +watch(() => [props.categories, props.series, props.horizontal, props.stacked, props.showLabel, isDark.value], render, {deep: true}) onBeforeUnmount(() => { ro?.disconnect() diff --git a/src/components/charts/ChartPanel.vue b/src/components/charts/ChartPanel.vue index 3dfc775..c0a3241 100644 --- a/src/components/charts/ChartPanel.vue +++ b/src/components/charts/ChartPanel.vue @@ -1,7 +1,7 @@ @@ -77,7 +92,7 @@ import {computed, ref, watch} from 'vue' import {BarChart3, Hash, Type, X} from 'lucide-vue-next' import Select from '../../ui/Select.vue' import BarChart from './BarChart.vue' -import {AGG_LABELS, type AggKind, aggregateByDimension, isNumericColumn} from './shape' +import {AGG_LABELS, type AggKind, isNumericColumn, pivot, sortAndLimit} from './shape' const props = defineProps<{ columns: string[] @@ -90,19 +105,26 @@ const chartType = ref('bar') const aggOptions = (Object.keys(AGG_LABELS) as AggKind[]).map(k => ({value: k, label: AGG_LABELS[k]})) const agg = ref('sum') -const dimension = ref('') +const sortOptions = [ + {value: 'none', label: '原始顺序'}, + {value: 'desc', label: '降序'}, + {value: 'asc', label: '升序'} +] +const sortOrder = ref<'none' | 'asc' | 'desc'>('none') +const topN = ref(0) + +const dimensions = ref([]) const metrics = ref([]) const horizontal = ref(false) const stacked = ref(false) +const showLabel = ref(false) const dragOver = ref('') const fields = computed(() => props.columns.map((name, i) => ({name, numeric: isNumericColumn(props.rows, i)}))) -// 列变化(新查询)时,清空已配置的、已不存在的字段 +// 列变化(新查询)时,剔除已不存在的字段 watch(() => props.columns, (cols) => { - if (dimension.value && !cols.includes(dimension.value)) { - dimension.value = '' - } + dimensions.value = dimensions.value.filter(d => cols.includes(d)) metrics.value = metrics.value.filter(m => cols.includes(m)) }) @@ -119,7 +141,9 @@ const onDrop = (zone: 'dim' | 'metric') => { return } if (zone === 'dim') { - dimension.value = name + if (!dimensions.value.includes(name)) { + dimensions.value = [...dimensions.value, name] + } } else if (!metrics.value.includes(name)) { metrics.value = [...metrics.value, name] @@ -127,24 +151,31 @@ const onDrop = (zone: 'dim' | 'metric') => { dragField = '' } -// 双击快速添加:数值列进指标,否则作维度 +// 双击快速添加:数值列进指标,否则进维度 const quickAdd = (f: { name: string; numeric: boolean }) => { if (f.numeric) { if (!metrics.value.includes(f.name)) { metrics.value = [...metrics.value, f.name] } } - else { - dimension.value = f.name + else if (!dimensions.value.includes(f.name)) { + dimensions.value = [...dimensions.value, f.name] } } +const removeDim = (d: string) => { + dimensions.value = dimensions.value.filter(x => x !== d) +} const removeMetric = (m: string) => { metrics.value = metrics.value.filter(x => x !== m) } -const ready = computed(() => !!dimension.value && metrics.value.length > 0) -const shaped = computed(() => ready.value - ? aggregateByDimension({columns: props.columns, rows: props.rows}, dimension.value, metrics.value, agg.value) - : {categories: [], series: []}) +const ready = computed(() => dimensions.value.length > 0 && metrics.value.length > 0) +const shaped = computed(() => { + if (!ready.value) { + return {categories: [] as string[], series: [] as { name: string; data: (number | null)[] }[]} + } + const base = pivot({columns: props.columns, rows: props.rows}, dimensions.value, metrics.value, agg.value) + return sortAndLimit(base, sortOrder.value, topN.value || 0) +}) diff --git a/src/components/charts/shape.ts b/src/components/charts/shape.ts index 8c36f6e..cdcf9cc 100644 --- a/src/components/charts/shape.ts +++ b/src/components/charts/shape.ts @@ -62,58 +62,100 @@ function aggregate(values: number[], kind: AggKind): number { } } +const norm = (v: any): string => (v === null || v === undefined || v === '' ? '(空)' : String(v)) + /** - * 按维度分组聚合指标。 - * - dimension:分类列;同一维度值的多行会被聚合 - * - metrics:一个或多个数值列,每个生成一条 series - * - agg:聚合方式;count 时统计非空行数(与具体数值无关) + * 多维度透视聚合: + * - dimensions[0]:分类轴(X) + * - dimensions[1..]:分组维度,每个不同组合拆成一条 series + * - metrics:一个或多个数值列;多指标时与分组组合,series 名为「组合 · 指标」 + * - agg:聚合方式;count 统计非空行数 */ -export function aggregateByDimension( +export function pivot( data: TableData, - dimension: string, + dimensions: string[], metrics: string[], agg: AggKind ): ShapedData { - const dimIdx = data.columns.indexOf(dimension) - const metricIdx = metrics.map(m => data.columns.indexOf(m)) - if (dimIdx < 0 || metricIdx.some(i => i < 0)) { + const dims = dimensions.filter(d => data.columns.includes(d)) + const mets = metrics.filter(m => data.columns.includes(m)) + if (dims.length === 0 || mets.length === 0) { return {categories: [], series: []} } - const order: string[] = [] - // 维度值 -> 每个指标的数值数组 - const buckets = new Map() + const catIdx = data.columns.indexOf(dims[0]) + const groupIdx = dims.slice(1).map(d => data.columns.indexOf(d)) + const metricIdx = mets.map(m => data.columns.indexOf(m)) + const multiMetric = mets.length > 1 + + const categories: string[] = [] + const catSeen = new Set() + // series 名 -> (分类值 -> 待聚合数值数组) + const seriesMap = new Map>() + const seriesOrder: string[] = [] for (const row of data.rows) { - const raw = row[dimIdx] - const key = raw === null || raw === undefined ? '(空)' : String(raw) - if (!buckets.has(key)) { - buckets.set(key, metricIdx.map(() => [])) - order.push(key) + const catVal = norm(row[catIdx]) + if (!catSeen.has(catVal)) { + catSeen.add(catVal) + categories.push(catVal) } - const slot = buckets.get(key)! - metricIdx.forEach((mi, j) => { - const v = row[mi] + const groupVal = groupIdx.map(i => norm(row[i])).join(' / ') + + mets.forEach((m, j) => { + let name: string + if (groupVal) { + name = multiMetric ? `${groupVal} · ${m}` : groupVal + } + else { + name = m + } + if (!seriesMap.has(name)) { + seriesMap.set(name, new Map()) + seriesOrder.push(name) + } + const cm = seriesMap.get(name)! + if (!cm.has(catVal)) { + cm.set(catVal, []) + } + const v = row[metricIdx[j]] if (agg === 'count') { if (v !== null && v !== undefined && v !== '') { - slot[j].push(1) + cm.get(catVal)!.push(1) } - return } - const num = typeof v === 'number' ? v : Number(v) - if (!isNaN(num)) { - slot[j].push(num) + else { + const num = typeof v === 'number' ? v : Number(v) + if (!isNaN(num)) { + cm.get(catVal)!.push(num) + } } }) } - const series: ShapedSeries[] = metrics.map((name, j) => ({ - name: agg === 'count' ? `${name}(计数)` : name, - data: order.map(key => { - const vals = buckets.get(key)![j] - return vals.length === 0 ? null : aggregate(vals, agg) + const series: ShapedSeries[] = seriesOrder.map(name => ({ + name, + data: categories.map(c => { + const vals = seriesMap.get(name)!.get(c) + return vals && vals.length > 0 ? aggregate(vals, agg) : null }) })) - return {categories: order, series} + return {categories, series} +} + +/** 按各分类的指标合计排序并截取前 N 项(topN<=0 表示不限制) */ +export function sortAndLimit(shaped: ShapedData, order: 'none' | 'asc' | 'desc', topN: number): ShapedData { + let idx = shaped.categories.map((_, i) => i) + if (order !== 'none') { + const totals = idx.map(i => shaped.series.reduce((s, ser) => s + (ser.data[i] || 0), 0)) + idx = [...idx].sort((a, b) => (order === 'asc' ? totals[a] - totals[b] : totals[b] - totals[a])) + } + if (topN > 0) { + idx = idx.slice(0, topN) + } + return { + categories: idx.map(i => shaped.categories[i]), + series: shaped.series.map(s => ({name: s.name, data: idx.map(i => s.data[i])})) + } }