@@ -245,12 +247,13 @@
首次打开后保持挂载,用 v-show 收起以保留会话;关闭所有标签才彻底卸载 -->
-
+
@@ -368,6 +371,7 @@ import YamlView from "./components/YamlView.vue";
import SqlTableView from "./components/SqlTableView.vue";
import DataTableView from "./components/DataTableView.vue";
import SqlSourceSelect from "./components/SqlSourceSelect.vue";
+import SchemaBrowser from "./components/SchemaBrowser.vue";
import StatusBar from './components/StatusBar.vue'
import About from './components/About.vue'
import Settings from './components/Settings.vue'
@@ -1341,6 +1345,29 @@ const runSql = async (sqlOverride?: string) => {
}
}
+// 表结构浏览器:把预览 SQL 写入编辑器并运行(编辑器与运行保持一致)
+const previewTable = (sql: string) => {
+ const view = editorView.value
+ if (view) {
+ view.dispatch({changes: {from: 0, to: view.state.doc.length, insert: sql}})
+ }
+ runSql(sql)
+}
+
+// 表结构浏览器:在光标处插入表名/列名
+const insertAtCursor = (text: string) => {
+ const view = editorView.value
+ if (!view) {
+ return
+ }
+ const {from, to} = view.state.selection.main
+ view.dispatch({
+ changes: {from, to, insert: text},
+ selection: {anchor: from + text.length}
+ })
+ view.focus()
+}
+
const runSelection = () => {
const view = editorView.value
if (!view) {
diff --git a/src/components/DataTableView.vue b/src/components/DataTableView.vue
index b0ed49a..3646e61 100644
--- a/src/components/DataTableView.vue
+++ b/src/components/DataTableView.vue
@@ -5,7 +5,7 @@
数据表
-
解析中…
+
解析中{{ parsing && percent > 0 ? ` ${percent}%` : '' }}…
{{ parsed.columns.length }} 列 · {{ parsed.rows.length }} 行
@@ -28,39 +28,25 @@
-
+
-
-
-
运行后在此查看数据表(支持 CSV / TSV)
-
-
-
-
- | # |
- {{ c }} |
-
-
-
-
- | {{ i + 1 }} |
- {{ row[ci] }} |
-
-
-
-
共 {{ parsed.rows.length }} 行,表格仅显示前 {{ displayLimit }} 行(图表使用全部数据)
-
+
+
+
运行后在此查看数据表(支持 CSV / TSV)
+
diff --git a/src/components/ResizablePanels.vue b/src/components/ResizablePanels.vue
index ab27d3d..fe8773d 100644
--- a/src/components/ResizablePanels.vue
+++ b/src/components/ResizablePanels.vue
@@ -1,7 +1,7 @@
-
-
diff --git a/src/components/SchemaBrowser.vue b/src/components/SchemaBrowser.vue
new file mode 100644
index 0000000..3ab0241
--- /dev/null
+++ b/src/components/SchemaBrowser.vue
@@ -0,0 +1,249 @@
+
+
+
+
+
+
+
+
+
+
+ 结构 · {{ activeLabel() }}
+
+
+
+
+
+
+
+
加载中…
+
{{ error }}
+
无数据库
+
无表
+
+
+
+
+
+
+
+ {{ db.name }}
+
+
+
+
+
+
+
+
{{ t.name }}
+
{{ t.columns.length }}
+
+
+
+
+ {{ col.name }}
+ {{ col.type }}
+
+
+
+
无表
+
+
+
+
+
+
+
+
+
+
+
{{ t.name }}
+
{{ t.columns.length }}
+
+
+
+
+ {{ col.name }}
+ {{ col.type }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/SqlResultTable.vue b/src/components/SqlResultTable.vue
index ff4053e..8d1cd57 100644
--- a/src/components/SqlResultTable.vue
+++ b/src/components/SqlResultTable.vue
@@ -11,26 +11,11 @@
✓ {{ m }}
-
+
结果集 {{ ri + 1 }} · {{ rs.rows.length }} 行
-
-
-
-
- | {{ c }} |
-
-
-
-
- | {{ fmt(row[ci]) }} |
-
-
- | (0 行) |
-
-
-
+
+
@@ -44,6 +29,7 @@
diff --git a/src/components/SqlTableView.vue b/src/components/SqlTableView.vue
index 4154e3b..8a32f9b 100644
--- a/src/components/SqlTableView.vue
+++ b/src/components/SqlTableView.vue
@@ -30,7 +30,7 @@
-
+
diff --git a/src/components/VirtualTable.vue b/src/components/VirtualTable.vue
new file mode 100644
index 0000000..b742080
--- /dev/null
+++ b/src/components/VirtualTable.vue
@@ -0,0 +1,172 @@
+
+
+
+
+
+ | # |
+
+ {{ c }}{{ sortDir === 1 ? ' ▲' : ' ▼' }}
+
+ |
+
+
+
+ |
+
+ | {{ start + i + 1 }} |
+ {{ fmt(row[ci]) }} |
+
+ |
+
+ | (0 行) |
+
+
+
+
+
+
+
diff --git a/src/components/charts/ChartPanel.vue b/src/components/charts/ChartPanel.vue
index 4808ab8..85c44e0 100644
--- a/src/components/charts/ChartPanel.vue
+++ b/src/components/charts/ChartPanel.vue
@@ -1,7 +1,7 @@
-
+
-
+
图表类型
@@ -10,12 +10,14 @@
-
字段(拖拽 / 双击)
+
字段(单击选择 / 双击快速添加 / 可拖拽)
+ @click="openFieldMenu(f, $event)"
+ @dblclick="dblAdd(f)">
{{ f.name }}
@@ -99,7 +101,7 @@
-
+
+
+
+
+
+
+
+
{{ fieldMenu.name }}
+
+
+
+
+
+
+
+
+
+
+
+
@@ -464,7 +486,7 @@ const onDropScatter = (zone: 'x' | 'y' | 'group') => {
dragField = ''
}
-// 双击快速添加
+// 双击快速添加:数值列→指标,文本列→维度(散点:依次 X/Y/分组)
const quickAdd = (f: { name: string; numeric: boolean }) => {
if (meta.value.layout === 'scatter') {
if (f.numeric) {
@@ -490,6 +512,54 @@ const quickAdd = (f: { name: string; numeric: boolean }) => {
}
}
+// 单击字段:在其右侧弹出菜单(延迟以便与双击区分);双击则快速添加
+const fieldMenu = ref<{ name: string; left: number; top: number } | null>(null)
+let clickTimer: ReturnType
| null = null
+const openFieldMenu = (f: { name: string }, e: MouseEvent) => {
+ const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
+ const left = Math.min(r.right + 6, window.innerWidth - 140)
+ if (clickTimer) {
+ clearTimeout(clickTimer)
+ }
+ clickTimer = setTimeout(() => {
+ fieldMenu.value = {name: f.name, left, top: r.top}
+ clickTimer = null
+ }, 220)
+}
+const dblAdd = (f: { name: string; numeric: boolean }) => {
+ if (clickTimer) {
+ clearTimeout(clickTimer)
+ clickTimer = null
+ }
+ quickAdd(f)
+}
+const pickTarget = (target: 'dim' | 'metric' | 'x' | 'y' | 'group') => {
+ const name = fieldMenu.value?.name
+ if (!name) {
+ return
+ }
+ if (target === 'dim') {
+ if (!dimensions.value.includes(name)) {
+ dimensions.value = [...dimensions.value, name]
+ }
+ }
+ else if (target === 'metric') {
+ if (!metrics.value.includes(name)) {
+ metrics.value = [...metrics.value, name]
+ }
+ }
+ else if (target === 'x') {
+ xField.value = name
+ }
+ else if (target === 'y') {
+ yField.value = name
+ }
+ else if (target === 'group') {
+ groupField.value = name
+ }
+ fieldMenu.value = null
+}
+
const removeDim = (d: string) => {
dimensions.value = dimensions.value.filter(x => x !== d)
}
diff --git a/src/utils/delimited.ts b/src/utils/delimited.ts
new file mode 100644
index 0000000..9cfac67
--- /dev/null
+++ b/src/utils/delimited.ts
@@ -0,0 +1,94 @@
+// CSV / TSV 等分隔文本解析(纯函数,供主线程与 Web Worker 复用)
+
+export interface DelimitedTable {
+ columns: string[]
+ rows: string[][]
+}
+
+/** 根据首行推断分隔符(制表符优先支持 TSV,其次分号,默认逗号) */
+export function detectDelimiter(text: string): string {
+ const firstLine = text.split('\n', 1)[0] || ''
+ const tabs = (firstLine.match(/\t/g) || []).length
+ const commas = (firstLine.match(/,/g) || []).length
+ const semis = (firstLine.match(/;/g) || []).length
+ if (tabs > 0 && tabs >= commas) {
+ return '\t'
+ }
+ if (semis > commas) {
+ return ';'
+ }
+ return ','
+}
+
+/** 支持引号、转义引号("")、字段内换行的分隔解析,返回二维数组;onProgress 报告 0~1 进度 */
+export function parseDelimited(text: string, delim: string, onProgress?: (p: number) => void): string[][] {
+ const rows: string[][] = []
+ let field = ''
+ let row: string[] = []
+ let inQuotes = false
+ const len = text.length
+ const step = onProgress ? Math.max(1, Math.floor(len / 100)) : 0
+ for (let i = 0; i < len; i++) {
+ if (step && i % step === 0) {
+ onProgress!(i / len)
+ }
+ const c = text[i]
+ if (inQuotes) {
+ if (c === '"') {
+ if (text[i + 1] === '"') {
+ field += '"'
+ i++
+ }
+ else {
+ inQuotes = false
+ }
+ }
+ else {
+ field += c
+ }
+ }
+ else if (c === '"') {
+ inQuotes = true
+ }
+ else if (c === delim) {
+ row.push(field)
+ field = ''
+ }
+ else if (c === '\n') {
+ row.push(field)
+ rows.push(row)
+ row = []
+ field = ''
+ }
+ else if (c !== '\r') {
+ field += c
+ }
+ }
+ if (field.length > 0 || row.length > 0) {
+ row.push(field)
+ rows.push(row)
+ }
+ return rows
+}
+
+/** 解析为表格:首行作列名,其余对齐为等长行;onProgress 报告 0~1 进度 */
+export function parseTable(text: string, onProgress?: (p: number) => void): DelimitedTable {
+ const trimmed = text.trim()
+ if (!trimmed) {
+ return {columns: [], rows: []}
+ }
+ const delim = detectDelimiter(trimmed)
+ const all = parseDelimited(trimmed, delim, onProgress).filter(r => r.length > 1 || (r.length === 1 && r[0] !== ''))
+ if (all.length === 0) {
+ return {columns: [], rows: []}
+ }
+ const columns = all[0].map((c, i) => c.trim() || `列${i + 1}`)
+ const rows = all.slice(1).map(r => {
+ const out: string[] = new Array(columns.length).fill('')
+ for (let i = 0; i < columns.length; i++) {
+ out[i] = r[i] ?? ''
+ }
+ return out
+ })
+ return {columns, rows}
+}
diff --git a/src/workers/delimited.worker.ts b/src/workers/delimited.worker.ts
new file mode 100644
index 0000000..5a3409b
--- /dev/null
+++ b/src/workers/delimited.worker.ts
@@ -0,0 +1,27 @@
+// CSV / TSV 解析 Worker:后台解析并上报进度,避免超大文件阻塞 UI
+import {parseTable} from '../utils/delimited'
+
+interface ParseRequest {
+ id: number
+ text: string
+}
+
+const post = (msg: any) => (self as unknown as Worker).postMessage(msg)
+
+self.onmessage = (e: MessageEvent) => {
+ const {id, text} = e.data
+ try {
+ let lastPercent = -1
+ const table = parseTable(text, (p) => {
+ const percent = Math.floor(p * 100)
+ if (percent !== lastPercent) {
+ lastPercent = percent
+ post({id, type: 'progress', percent})
+ }
+ })
+ post({id, type: 'done', columns: table.columns, rows: table.rows})
+ }
+ catch {
+ post({id, type: 'done', columns: [], rows: []})
+ }
+}