Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 39 additions & 12 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
</div>
</div>

<div class="flex-1 overflow-hidden flex">
<div class="flex-1 min-h-0 overflow-hidden flex">
<!-- 左侧文件树侧栏 -->
<template v-if="sidebarVisible">
<Sidebar :root-dir="rootDir"
Expand All @@ -67,7 +67,7 @@
@mousedown="startSidebarResize"></div>
</template>

<div class="flex-1 overflow-hidden">
<div class="flex-1 min-h-0 overflow-hidden">
<!-- 编辑器代码片段 -->
<template v-if="showConsole">
<ResizablePanels :direction="effectiveDirection" :min-primary="minPrimary" :min-secondary="minSecondary">
Expand All @@ -90,6 +90,7 @@
<span v-if="isDirty" class="ml-1 text-amber-500" title="有未保存的修改">●</span>
</span>
<SqlSourceSelect v-if="currentLanguage === 'sql'" class="flex-shrink-0"/>
<SchemaBrowser v-if="currentLanguage === 'sql'" class="flex-shrink-0" @preview="previewTable" @insert="insertAtCursor"/>
</div>

<div class="flex items-center space-x-2 text-xs text-gray-500 whitespace-nowrap flex-shrink-0 pl-3">
Expand Down Expand Up @@ -117,7 +118,7 @@

<template #secondary>
<!-- 输出 -->
<div class="h-full flex flex-col" :class="effectiveDirection === 'vertical' ? 'border-t border-gray-200' : 'border-l border-gray-200'">
<div class="h-full min-h-0 flex flex-col overflow-hidden" :class="effectiveDirection === 'vertical' ? 'border-t border-gray-200' : 'border-l border-gray-200'">
<!-- 仅编辑器模式下提供收起控制台的入口 -->
<div v-if="layoutMode === 'editor'" class="bg-gray-100 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-shrink-0">
<h2 class="text-sm font-medium text-gray-700 dark:text-gray-200">控制台</h2>
Expand All @@ -127,7 +128,7 @@
</div>

<ConsoleOutput v-if="consoleType === 'console'"
class="flex-1"
class="flex-1 min-h-0"
:output="output"
:is-running="isRunning"
:is-success="isSuccess"
Expand All @@ -137,7 +138,7 @@

<!-- Web输出组件 -->
<WebOutput v-else-if="consoleType === 'web'"
class="flex-1"
class="flex-1 min-h-0"
:web-content="output"
:is-running="isRunning"
:execution-time="lastExecutionTime"
Expand All @@ -146,47 +147,47 @@

<!-- JSON 视图 -->
<JsonView v-else-if="consoleType === 'json'"
class="flex-1"
class="flex-1 min-h-0"
:output="output"
:is-running="isRunning"
:execution-time="lastExecutionTime"
@clear="clearOutput"/>

<!-- Markdown 预览 -->
<MarkdownView v-else-if="consoleType === 'markdown'"
class="flex-1"
class="flex-1 min-h-0"
:output="output"
:is-running="isRunning"
:execution-time="lastExecutionTime"
@clear="clearOutput"/>

<!-- XML 视图 -->
<XmlView v-else-if="consoleType === 'xml'"
class="flex-1"
class="flex-1 min-h-0"
:output="output"
:is-running="isRunning"
:execution-time="lastExecutionTime"
@clear="clearOutput"/>

<!-- YAML 视图 -->
<YamlView v-else-if="consoleType === 'yaml'"
class="flex-1"
class="flex-1 min-h-0"
:output="output"
:is-running="isRunning"
:execution-time="lastExecutionTime"
@clear="clearOutput"/>

<!-- SQL 表格 -->
<SqlTableView v-else-if="consoleType === 'sqltable'"
class="flex-1"
class="flex-1 min-h-0"
:output="output"
:is-running="isRunning"
:execution-time="lastExecutionTime"
@clear="clearOutput"/>

<!-- 数据表 / 图表(CSV / TSV) -->
<DataTableView v-else-if="consoleType === 'table'"
class="flex-1"
class="flex-1 min-h-0"
:output="output"
:is-running="isRunning"
:execution-time="lastExecutionTime"
Expand Down Expand Up @@ -215,6 +216,7 @@
<span v-if="isDirty" class="ml-1 text-amber-500" title="有未保存的修改">●</span>
</span>
<SqlSourceSelect v-if="currentLanguage === 'sql'" class="flex-shrink-0"/>
<SchemaBrowser v-if="currentLanguage === 'sql'" class="flex-shrink-0" @preview="previewTable" @insert="insertAtCursor"/>
</div>

<div class="flex items-center space-x-2 text-xs text-gray-500 whitespace-nowrap flex-shrink-0 pl-3">
Expand Down Expand Up @@ -245,12 +247,13 @@
首次打开后保持挂载,用 v-show 收起以保留会话;关闭所有标签才彻底卸载 -->
<Terminal v-if="terminalMounted"
v-show="showTerminal"
class="flex-shrink-0"
:root-dir="rootDir"
@collapse="showTerminal = false"
@close="showTerminal = false; terminalMounted = false"/>

<!-- 状态栏 -->
<StatusBar :env-info="envInfo" :is-loading="isLoadingEnvInfo" :execution-time="lastExecutionTime" :code-length="(code || '').length" @check-environment="refreshEnvInfo" @toggle-terminal="toggleTerminal"/>
<StatusBar class="flex-shrink-0" :env-info="envInfo" :is-loading="isLoadingEnvInfo" :execution-time="lastExecutionTime" :code-length="(code || '').length" @check-environment="refreshEnvInfo" @toggle-terminal="toggleTerminal"/>

<!-- 关于组件 -->
<About v-if="showAbout" @close="closeAbout"/>
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
160 changes: 53 additions & 107 deletions src/components/DataTableView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 min-w-0">
<Table2 class="w-3.5 h-3.5 flex-shrink-0"/>
<span>数据表</span>
<span v-if="isRunning" class="text-blue-500">解析中…</span>
<span v-if="isRunning || parsing" class="text-blue-500">解析中{{ parsing && percent > 0 ? ` ${percent}%` : '' }}…</span>
<span v-else-if="parsed.rows.length" class="text-gray-400">{{ parsed.columns.length }} 列 · {{ parsed.rows.length }} 行</span>
</div>
<div class="flex items-center gap-1">
Expand All @@ -28,39 +28,25 @@
</div>

<!-- 图表视图 -->
<div v-if="viewMode === 'chart' && parsed.columns.length" class="flex-1 min-h-0">
<div v-if="viewMode === 'chart' && parsed.columns.length" class="flex-1 min-h-0 overflow-hidden">
<ChartPanel :columns="parsed.columns" :rows="parsed.rows"/>
</div>
<!-- 表格视图 -->
<div v-else class="flex-1 overflow-auto p-2 text-xs">
<div v-if="!parsed.columns.length" class="text-gray-400 px-2 py-4 text-center">运行后在此查看数据表(支持 CSV / TSV)</div>
<div v-else class="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded">
<table class="w-full border-collapse">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800">
<th class="text-left font-semibold px-2 py-1.5 border-b border-gray-200 dark:border-gray-700 text-gray-400 w-10">#</th>
<th v-for="(c, ci) in parsed.columns" :key="ci" class="text-left font-semibold px-3 py-1.5 border-b border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-300 whitespace-nowrap">{{ c }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in displayRows" :key="i" class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-2 py-1 border-b border-gray-100 dark:border-gray-800 text-gray-400">{{ i + 1 }}</td>
<td v-for="(_c, ci) in parsed.columns" :key="ci" class="px-3 py-1 border-b border-gray-100 dark:border-gray-800 font-mono whitespace-nowrap text-gray-700 dark:text-gray-300">{{ row[ci] }}</td>
</tr>
</tbody>
</table>
<div v-if="parsed.rows.length > displayLimit" class="px-2 py-2 text-center text-gray-400">共 {{ parsed.rows.length }} 行,表格仅显示前 {{ displayLimit }} 行(图表使用全部数据)</div>
</div>
<!-- 表格视图(虚拟滚动,支持大文件) -->
<div v-if="!parsed.columns.length" class="flex-1 overflow-auto p-2 text-xs">
<div class="text-gray-400 px-2 py-4 text-center">运行后在此查看数据表(支持 CSV / TSV)</div>
</div>
<VirtualTable v-else class="flex-1" :columns="parsed.columns" :rows="parsed.rows"/>
</div>
</template>

<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import {onBeforeUnmount, ref, shallowRef, watch} from 'vue'
import {debounce} from 'lodash-es'
import {BarChart3, FileDown, Table2, Trash2} from 'lucide-vue-next'
import ChartPanel from './charts/ChartPanel.vue'
import VirtualTable from './VirtualTable.vue'
import {downloadCsv} from '../utils/csv'
import {type DelimitedTable, parseTable} from '../utils/delimited'

const props = defineProps<{
output: string
Expand All @@ -70,102 +56,62 @@ const props = defineProps<{
const emit = defineEmits<{ clear: [] }>()

const viewMode = ref<'table' | 'chart'>('table')
const displayLimit = 500

const stable = ref(props.output)
const applyOutput = debounce((v: string) => { stable.value = v }, 150)
watch(() => props.output, (v) => applyOutput(v))

// 根据首行推断分隔符(制表符优先支持 TSV,否则逗号)
const 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 ','
}
// shallowRef:大数组不做深度响应,避免开销
const parsed = shallowRef<DelimitedTable>({columns: [], rows: []})
const parsing = ref(false)
const percent = ref(0)

// 支持引号、转义引号("")、字段内换行的分隔解析
const parseDelimited = (text: string, delim: string): string[][] => {
const rows: string[][] = []
let field = ''
let row: string[] = []
let inQuotes = false
for (let i = 0; i < text.length; i++) {
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
// 解析放到 Web Worker,超大文件不阻塞 UI;创建失败则主线程兜底
let worker: Worker | null = null
let reqId = 0
type WorkerMsg = { id: number; type: 'progress'; percent: number } | { id: number; type: 'done' } & DelimitedTable
try {
worker = new Worker(new URL('../workers/delimited.worker.ts', import.meta.url), {type: 'module'})
worker.onmessage = (e: MessageEvent<WorkerMsg>) => {
const msg = e.data
if (msg.id !== reqId) {
return // 丢弃过期结果
}
else if (c === delim) {
row.push(field)
field = ''
if (msg.type === 'progress') {
percent.value = msg.percent
return
}
else if (c === '\n') {
row.push(field)
rows.push(row)
row = []
field = ''
parsed.value = {columns: msg.columns, rows: msg.rows}
parsing.value = false
if (!parsed.value.columns.length && viewMode.value === 'chart') {
viewMode.value = 'table'
}
else if (c !== '\r') {
field += c
}
}
if (field.length > 0 || row.length > 0) {
row.push(field)
rows.push(row)
}
return rows
}
catch {
worker = null
}

const parsed = computed<{ columns: string[]; rows: string[][] }>(() => {
const text = stable.value.trim()
if (!text) {
return {columns: [], rows: []}
const doParse = (text: string) => {
reqId++
percent.value = 0
if (!text.trim()) {
parsed.value = {columns: [], rows: []}
parsing.value = false
return
}
const delim = detectDelimiter(text)
const all = parseDelimited(text, delim).filter(r => r.length > 1 || (r.length === 1 && r[0] !== ''))
if (all.length === 0) {
return {columns: [], rows: []}
if (worker) {
parsing.value = true
worker.postMessage({id: reqId, text})
}
const columns = all[0].map((c, i) => c.trim() || `列${i + 1}`)
const rows = all.slice(1).map(r => {
const out = new Array(columns.length).fill('')
for (let i = 0; i < columns.length; i++) {
out[i] = r[i] ?? ''
else {
parsed.value = parseTable(text)
if (!parsed.value.columns.length && viewMode.value === 'chart') {
viewMode.value = 'table'
}
return out
})
return {columns, rows}
})
}
}

const displayRows = computed(() => parsed.value.rows.slice(0, displayLimit))
const applyOutput = debounce((v: string) => doParse(v), 150)
watch(() => props.output, (v) => applyOutput(v))
doParse(props.output)

const exportCsv = () => downloadCsv(parsed.value.columns, parsed.value.rows, `data-${Date.now()}.csv`)

// 数据为空时回退表格视图
watch(() => parsed.value.columns.length, (n) => {
if (!n && viewMode.value === 'chart') {
viewMode.value = 'table'
}
})
onBeforeUnmount(() => worker?.terminate())
</script>
4 changes: 2 additions & 2 deletions src/components/ResizablePanels.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div ref="containerRef" class="overflow-hidden h-full w-full flex" :class="isVertical ? 'flex-col' : 'flex-row'">
<!-- 主面板(编辑器) -->
<div :style="primaryStyle" class="overflow-hidden">
<div :style="primaryStyle" class="overflow-hidden min-h-0 min-w-0 flex-shrink-0">
<slot name="primary"></slot>
</div>

Expand All @@ -18,7 +18,7 @@
</div>

<!-- 副面板(控制台) -->
<div class="flex-1 overflow-hidden">
<div class="flex-1 min-h-0 min-w-0 overflow-hidden">
<slot name="secondary"></slot>
</div>
</div>
Expand Down
Loading
Loading