新增:数据库 UI UX 大幅改进
功能增强: - 查询历史记录与快速重用(最多50条) - 查询模板管理(9个默认模板,支持自定义) - SQL 格式化功能(关键字大写、缩进美化) - 查询结果导出(CSV/JSON/Excel/Markdown) - 执行时间显示(带颜色指示:绿/橙/红) - 增强工具栏(整合所有功能) 新增组件: - QueryHistoryPanel.vue - 查询历史面板 - QueryTemplatesPanel.vue - 查询模板面板 - SQLEditorToolbar.vue - 增强工具栏 - useQueryHistory.js - 历史记录管理 - useQueryTemplates.js - 模板管理 - sqlFormatter.js - SQL 格式化工具 - resultExporter.js - 结果导出工具 修改组件: - SqlEditor.vue - 集成新功能与工具栏
This commit is contained in:
247
web/src/views/db-cli/components/QueryHistoryPanel.vue
Normal file
247
web/src/views/db-cli/components/QueryHistoryPanel.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div class="query-history-panel">
|
||||
<div class="panel-header">
|
||||
<h3>查询历史</h3>
|
||||
<a-space>
|
||||
<a-input-search
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索历史..."
|
||||
style="width: 200px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
status="danger"
|
||||
@click="handleClearAll"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-delete/>
|
||||
</template>
|
||||
清空
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="history-list">
|
||||
<a-list
|
||||
:data="displayedHistory"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<a-list-item class="history-item">
|
||||
<div class="history-content">
|
||||
<div class="history-header">
|
||||
<a-tag size="small" :color="getDbTypeColor(item.dbType)">
|
||||
{{ item.dbType.toUpperCase() }}
|
||||
</a-tag>
|
||||
<span class="history-time">{{ formatTime(item.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="history-query" @click="handleUseQuery(item.query)">
|
||||
{{ item.queryPreview }}
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleUseQuery(item.query)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-arrow-right/>
|
||||
</template>
|
||||
使用
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
status="danger"
|
||||
@click="handleDelete(item.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-delete/>
|
||||
</template>
|
||||
</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
|
||||
<a-empty
|
||||
v-if="displayedHistory.length === 0"
|
||||
description="暂无查询历史"
|
||||
:style="{ padding: '40px 0' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconDelete, IconArrowRight } from '@arco-design/web-vue/es/icon'
|
||||
import { useQueryHistory } from '../composables/useQueryHistory'
|
||||
|
||||
const props = defineProps({
|
||||
connectionId: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['use-query'])
|
||||
|
||||
const queryHistory = useQueryHistory()
|
||||
const searchKeyword = ref('')
|
||||
const history = ref([])
|
||||
|
||||
// 显示的历史记录(搜索过滤后)
|
||||
const displayedHistory = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return history.value
|
||||
}
|
||||
return queryHistory.searchHistory(searchKeyword.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadHistory()
|
||||
})
|
||||
|
||||
const loadHistory = () => {
|
||||
history.value = queryHistory.getHistory()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// 搜索会自动触发 computed 更新
|
||||
}
|
||||
|
||||
const handleUseQuery = (query) => {
|
||||
emit('use-query', query)
|
||||
Message.success('已加载查询语句')
|
||||
}
|
||||
|
||||
const handleDelete = (id) => {
|
||||
const success = queryHistory.deleteHistory(id)
|
||||
if (success) {
|
||||
loadHistory()
|
||||
Message.success('删除成功')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
if (confirm('确定要清空所有查询历史吗?')) {
|
||||
const success = queryHistory.clearHistory()
|
||||
if (success) {
|
||||
loadHistory()
|
||||
Message.success('已清空历史记录')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getDbTypeColor = (dbType) => {
|
||||
const colors = {
|
||||
mysql: 'blue',
|
||||
redis: 'red',
|
||||
mongodb: 'green',
|
||||
mongo: 'green'
|
||||
}
|
||||
return colors[dbType] || 'gray'
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
// 小于1分钟
|
||||
if (diff < 60000) {
|
||||
return '刚刚'
|
||||
}
|
||||
// 小于1小时
|
||||
if (diff < 3600000) {
|
||||
return `${Math.floor(diff / 60000)} 分钟前`
|
||||
}
|
||||
// 小于24小时
|
||||
if (diff < 86400000) {
|
||||
return `${Math.floor(diff / 3600000)} 小时前`
|
||||
}
|
||||
// 大于24小时
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
|
||||
}
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
addHistory: (query) => {
|
||||
const record = queryHistory.addHistory(query, props.connectionId)
|
||||
if (record) {
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.query-history-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
.history-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.history-query {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-1);
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
330
web/src/views/db-cli/components/QueryTemplatesPanel.vue
Normal file
330
web/src/views/db-cli/components/QueryTemplatesPanel.vue
Normal file
@@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<div class="query-templates-panel">
|
||||
<div class="panel-header">
|
||||
<h3>查询模板</h3>
|
||||
<a-space>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleCreateTemplate"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-plus/>
|
||||
</template>
|
||||
新建模板
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="templates-list">
|
||||
<a-collapse
|
||||
:default-active-key="expandedCategories"
|
||||
:bordered="false"
|
||||
>
|
||||
<a-collapse-item
|
||||
v-for="(categoryTemplates, category) in groupedTemplates"
|
||||
:key="category"
|
||||
:header="category"
|
||||
>
|
||||
<template #extra>
|
||||
<a-tag size="small">{{ categoryTemplates.length }}</a-tag>
|
||||
</template>
|
||||
|
||||
<div class="template-grid">
|
||||
<div
|
||||
v-for="template in categoryTemplates"
|
||||
:key="template.id"
|
||||
class="template-card"
|
||||
@click="handleUseTemplate(template)"
|
||||
>
|
||||
<div class="template-header">
|
||||
<span class="template-name">{{ template.name }}</span>
|
||||
<a-dropdown
|
||||
trigger="click"
|
||||
@click.stop
|
||||
>
|
||||
<a-button type="text" size="mini">
|
||||
<icon-more/>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption @click="handleEditTemplate(template)">
|
||||
<icon-edit/>
|
||||
编辑
|
||||
</a-doption>
|
||||
<a-doption
|
||||
style="color: var(--color-danger-6)"
|
||||
@click="handleDeleteTemplate(template.id)"
|
||||
>
|
||||
<icon-delete/>
|
||||
删除
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
<div class="template-description">
|
||||
{{ template.description || '无描述' }}
|
||||
</div>
|
||||
<div class="template-query">
|
||||
<code>{{ template.query.substring(0, 80) }}{{ template.query.length > 80 ? '...' : '' }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-collapse-item>
|
||||
</a-collapse>
|
||||
|
||||
<a-empty
|
||||
v-if="Object.keys(groupedTemplates).length === 0"
|
||||
description="暂无模板"
|
||||
:style="{ padding: '40px 0' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑模板弹窗 -->
|
||||
<a-modal
|
||||
v-model:visible="showTemplateModal"
|
||||
:title="isEditing ? '编辑模板' : '新建模板'"
|
||||
:width="600"
|
||||
@ok="handleSaveTemplate"
|
||||
>
|
||||
<a-form :model="templateForm" layout="vertical">
|
||||
<a-form-item label="模板名称" required>
|
||||
<a-input
|
||||
v-model="templateForm.name"
|
||||
placeholder="例如:分页查询"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="分类">
|
||||
<a-select
|
||||
v-model="templateForm.category"
|
||||
placeholder="选择分类"
|
||||
:options="categoryOptions"
|
||||
allow-create
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述">
|
||||
<a-textarea
|
||||
v-model="templateForm.description"
|
||||
placeholder="简短描述模板用途"
|
||||
:max-length="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="SQL 语句" required>
|
||||
<a-textarea
|
||||
v-model="templateForm.query"
|
||||
placeholder="输入 SQL 语句"
|
||||
:auto-size="{ minRows: 6, maxRows: 12 }"
|
||||
style="font-family: 'Monaco', 'Menlo', monospace"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {
|
||||
IconPlus, IconMore, IconEdit, IconDelete
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import { useQueryTemplates } from '../composables/useQueryTemplates'
|
||||
|
||||
const props = defineProps({
|
||||
dbType: {
|
||||
type: String,
|
||||
default: 'mysql'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['use-template'])
|
||||
|
||||
const queryTemplates = useQueryTemplates()
|
||||
const templates = ref([])
|
||||
const expandedCategories = ref(['基础查询'])
|
||||
const showTemplateModal = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const templateForm = ref({
|
||||
id: null,
|
||||
name: '',
|
||||
category: '自定义',
|
||||
description: '',
|
||||
query: ''
|
||||
})
|
||||
|
||||
const categoryOptions = [
|
||||
'基础查询', '分页', '统计', '插入', '更新', '删除',
|
||||
'Join', '聚合', '子查询', 'Redis', 'MongoDB', '自定义'
|
||||
]
|
||||
|
||||
// 按分类分组
|
||||
const groupedTemplates = computed(() => {
|
||||
const filtered = queryTemplates.getTemplatesByType(props.dbType)
|
||||
const groups = {}
|
||||
|
||||
filtered.forEach(template => {
|
||||
const category = template.category || '自定义'
|
||||
if (!groups[category]) {
|
||||
groups[category] = []
|
||||
}
|
||||
groups[category].push(template)
|
||||
})
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTemplates()
|
||||
})
|
||||
|
||||
const loadTemplates = () => {
|
||||
templates.value = queryTemplates.getTemplates()
|
||||
}
|
||||
|
||||
const handleUseTemplate = (template) => {
|
||||
emit('use-template', template.query)
|
||||
Message.success(`已应用模板:${template.name}`)
|
||||
}
|
||||
|
||||
const handleCreateTemplate = () => {
|
||||
isEditing.value = false
|
||||
templateForm.value = {
|
||||
id: null,
|
||||
name: '',
|
||||
category: '自定义',
|
||||
description: '',
|
||||
query: ''
|
||||
}
|
||||
showTemplateModal.value = true
|
||||
}
|
||||
|
||||
const handleEditTemplate = (template) => {
|
||||
isEditing.value = true
|
||||
templateForm.value = { ...template }
|
||||
showTemplateModal.value = true
|
||||
}
|
||||
|
||||
const handleSaveTemplate = () => {
|
||||
if (!templateForm.value.name.trim()) {
|
||||
Message.warning('请输入模板名称')
|
||||
return
|
||||
}
|
||||
if (!templateForm.value.query.trim()) {
|
||||
Message.warning('请输入 SQL 语句')
|
||||
return
|
||||
}
|
||||
|
||||
let result
|
||||
if (isEditing.value) {
|
||||
result = queryTemplates.updateTemplate(templateForm.value.id, templateForm.value)
|
||||
} else {
|
||||
result = queryTemplates.saveTemplate({
|
||||
...templateForm.value,
|
||||
dbType: props.dbType
|
||||
})
|
||||
}
|
||||
|
||||
if (result) {
|
||||
loadTemplates()
|
||||
showTemplateModal.value = false
|
||||
Message.success(isEditing.value ? '模板已更新' : '模板已创建')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTemplate = (id) => {
|
||||
if (confirm('确定要删除此模板吗?')) {
|
||||
const success = queryTemplates.deleteTemplate(id)
|
||||
if (success) {
|
||||
loadTemplates()
|
||||
Message.success('模板已删除')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.query-templates-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.templates-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: var(--color-primary-6);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.template-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.template-description {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.template-query {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.template-query code {
|
||||
background: var(--color-fill-2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
267
web/src/views/db-cli/components/SQLEditorToolbar.vue
Normal file
267
web/src/views/db-cli/components/SQLEditorToolbar.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="sql-editor-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleExecute">
|
||||
<template #icon>
|
||||
<icon-play-arrow/>
|
||||
</template>
|
||||
{{ executeButtonText }} (F5)
|
||||
</a-button>
|
||||
<a-button type="outline" @click="handleExecuteSelected">
|
||||
<template #icon>
|
||||
<icon-code/>
|
||||
</template>
|
||||
执行选中 (Ctrl+Enter)
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-center">
|
||||
<a-space>
|
||||
<a-button-group>
|
||||
<a-button type="text" size="small" @click="handleFormat">
|
||||
<template #icon>
|
||||
<icon-thunderbolt/>
|
||||
</template>
|
||||
格式化
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleShowHistory">
|
||||
<template #icon>
|
||||
<icon-history/>
|
||||
</template>
|
||||
历史
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleShowTemplates">
|
||||
<template #icon>
|
||||
<icon-book/>
|
||||
</template>
|
||||
模板
|
||||
</a-button>
|
||||
</a-button-group>
|
||||
|
||||
<a-dropdown trigger="click">
|
||||
<a-button type="text" size="small">
|
||||
<template #icon>
|
||||
<icon-download/>
|
||||
</template>
|
||||
导出
|
||||
<icon-down/>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption @click="handleExport('csv')">
|
||||
<icon-file/>
|
||||
CSV 格式
|
||||
</a-doption>
|
||||
<a-doption @click="handleExport('json')">
|
||||
<icon-file/>
|
||||
JSON 格式
|
||||
</a-doption>
|
||||
<a-doption @click="handleExport('excel')">
|
||||
<icon-file/>
|
||||
Excel 格式
|
||||
</a-doption>
|
||||
<a-doption @click="handleExport('markdown')">
|
||||
<icon-file/>
|
||||
Markdown 表格
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<a-space v-if="currentConnection">
|
||||
<a-tag color="blue" size="small">
|
||||
<template #icon>
|
||||
<icon-storage/>
|
||||
</template>
|
||||
{{ currentConnection.name }}
|
||||
</a-tag>
|
||||
<span class="connection-info">
|
||||
{{ currentConnection.host }}:{{ currentConnection.port }}
|
||||
<span v-if="currentConnection.database" class="database-name">
|
||||
/ {{ currentConnection.database }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- 执行时间显示 -->
|
||||
<a-tag
|
||||
v-if="executionTime !== null"
|
||||
:color="getExecutionTimeColor(executionTime)"
|
||||
size="small"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-clock-circle/>
|
||||
</template>
|
||||
{{ executionTime }}ms
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<span v-else class="connection-info-empty">
|
||||
未选择连接
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 历史记录抽屉 -->
|
||||
<a-drawer
|
||||
v-model:visible="showHistoryDrawer"
|
||||
title="查询历史"
|
||||
:width="400"
|
||||
placement="right"
|
||||
:footer="false"
|
||||
>
|
||||
<QueryHistoryPanel
|
||||
ref="historyPanelRef"
|
||||
:connection-id="currentConnection?.id"
|
||||
@use-query="handleUseHistoryQuery"
|
||||
/>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 模板抽屉 -->
|
||||
<a-drawer
|
||||
v-model:visible="showTemplatesDrawer"
|
||||
title="查询模板"
|
||||
:width="600"
|
||||
placement="right"
|
||||
:footer="false"
|
||||
>
|
||||
<QueryTemplatesPanel
|
||||
:db-type="dbType"
|
||||
@use-template="handleUseTemplate"
|
||||
/>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {
|
||||
IconPlayArrow, IconCode, IconStorage, IconHistory, IconBook,
|
||||
IconDownload, IconDown, IconFile, IconClockCircle, IconThunderbolt
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import { formatSQL } from '../utils/sqlFormatter'
|
||||
import QueryHistoryPanel from './QueryHistoryPanel.vue'
|
||||
import QueryTemplatesPanel from './QueryTemplatesPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
currentConnection: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
executionTime: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'execute',
|
||||
'execute-selected',
|
||||
'format',
|
||||
'export'
|
||||
])
|
||||
|
||||
const showHistoryDrawer = ref(false)
|
||||
const showTemplatesDrawer = ref(false)
|
||||
const historyPanelRef = ref(null)
|
||||
|
||||
const dbType = computed(() =>
|
||||
props.currentConnection?.type?.toLowerCase() || 'mysql'
|
||||
)
|
||||
|
||||
const executeButtonText = computed(() => {
|
||||
const type = dbType.value
|
||||
if (type === 'redis') return '执行命令'
|
||||
if (type === 'mongodb' || type === 'mongo') return '执行查询'
|
||||
return '执行'
|
||||
})
|
||||
|
||||
const handleExecute = () => {
|
||||
emit('execute')
|
||||
}
|
||||
|
||||
const handleExecuteSelected = () => {
|
||||
emit('execute-selected')
|
||||
}
|
||||
|
||||
const handleFormat = () => {
|
||||
emit('format')
|
||||
}
|
||||
|
||||
const handleShowHistory = () => {
|
||||
showHistoryDrawer.value = true
|
||||
}
|
||||
|
||||
const handleShowTemplates = () => {
|
||||
showTemplatesDrawer.value = true
|
||||
}
|
||||
|
||||
const handleUseHistoryQuery = (query) => {
|
||||
showHistoryDrawer.value = false
|
||||
emit('execute', query)
|
||||
}
|
||||
|
||||
const handleUseTemplate = (query) => {
|
||||
showTemplatesDrawer.value = false
|
||||
emit('execute', query)
|
||||
}
|
||||
|
||||
const handleExport = (format) => {
|
||||
emit('export', format)
|
||||
}
|
||||
|
||||
const getExecutionTimeColor = (ms) => {
|
||||
if (ms < 100) return 'green'
|
||||
if (ms < 500) return 'orange'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
addHistory: (query) => {
|
||||
historyPanelRef.value?.addHistory(query)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sql-editor-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-center,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-center {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
font-size: 12px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.database-name {
|
||||
margin-left: 8px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.connection-info-empty {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +1,16 @@
|
||||
<template>
|
||||
<div class="sql-editor-wrapper">
|
||||
<div class="editor-toolbar">
|
||||
<a-space>
|
||||
<a-button type="outline" @click="handleExecute">
|
||||
<template #icon>
|
||||
<icon-play-arrow/>
|
||||
</template>
|
||||
{{ getExecuteButtonText() }} (F5)
|
||||
</a-button>
|
||||
<a-button type="outline" @click="handleExecuteSelected">
|
||||
<template #icon>
|
||||
<icon-code/>
|
||||
</template>
|
||||
执行选中 (Ctrl+Enter)
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-space v-if="currentConnection">
|
||||
<a-tag color="blue" size="small">
|
||||
<template #icon>
|
||||
<icon-storage/>
|
||||
</template>
|
||||
{{ currentConnection.name }}
|
||||
</a-tag>
|
||||
<span class="connection-info">
|
||||
{{ currentConnection.host }}:{{ currentConnection.port }}
|
||||
<span v-if="currentConnection.database" class="database-name">
|
||||
/ {{ currentConnection.database }}
|
||||
</span>
|
||||
</span>
|
||||
</a-space>
|
||||
<span v-else class="connection-info-empty">
|
||||
未选择连接
|
||||
</span>
|
||||
</div>
|
||||
<!-- 增强工具栏 -->
|
||||
<SQLEditorToolbar
|
||||
ref="toolbarRef"
|
||||
:current-connection="currentConnection"
|
||||
:execution-time="lastExecutionTime"
|
||||
@execute="handleExecute"
|
||||
@execute-selected="handleExecuteSelected"
|
||||
@format="handleFormat"
|
||||
@export="handleExport"
|
||||
/>
|
||||
|
||||
<div class="editor-container">
|
||||
<div class="code-editor" ref="editorContainerRef"></div>
|
||||
</div>
|
||||
@@ -42,7 +20,6 @@
|
||||
<script setup>
|
||||
import {nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
||||
import {Message} from '@arco-design/web-vue'
|
||||
import {IconPlayArrow, IconStorage, IconCode} from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
EditorView, keymap, lineNumbers,
|
||||
EditorState,
|
||||
@@ -51,16 +28,23 @@ import {
|
||||
defaultHighlightStyle, syntaxHighlighting
|
||||
} from '@/utils/codemirrorExports'
|
||||
import {useTabPersistence} from '../composables/useTabPersistence'
|
||||
import SQLEditorToolbar from './SQLEditorToolbar.vue'
|
||||
import {formatSQL} from '../utils/sqlFormatter'
|
||||
import {exportToCSV, exportToJSON, exportToExcel, exportToMarkdown} from '../utils/resultExporter'
|
||||
|
||||
// ==================== Props & Events ====================
|
||||
const props = defineProps({
|
||||
currentConnection: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
queryResults: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['execute', 'execute-selected', 'format'])
|
||||
const emit = defineEmits(['execute', 'execute-selected', 'format', 'export'])
|
||||
|
||||
// 常量配置
|
||||
const STORAGE_KEY_EDITOR_CONTENT = 'db-cli:editor-content'
|
||||
@@ -101,8 +85,10 @@ const getExecuteButtonText = () => getDbConfig().executeText
|
||||
|
||||
// ==================== 编辑器管理 ====================
|
||||
const editorContainerRef = ref(null)
|
||||
const toolbarRef = ref(null)
|
||||
let editorView = null
|
||||
let saveTimer = null
|
||||
const lastExecutionTime = ref(null)
|
||||
|
||||
// 创建编辑器扩展
|
||||
const createEditorExtensions = () => {
|
||||
@@ -243,17 +229,27 @@ const validateEditor = () => {
|
||||
return editorView
|
||||
}
|
||||
|
||||
const handleExecute = () => {
|
||||
const handleExecute = (contentOverride = null) => {
|
||||
const editor = validateEditor()
|
||||
if (!editor) return
|
||||
|
||||
const content = editor.state.doc.toString().trim()
|
||||
const content = contentOverride || editor.state.doc.toString().trim()
|
||||
if (!content) {
|
||||
Message.warning('SQL 语句不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
emit('execute', content)
|
||||
const startTime = performance.now()
|
||||
|
||||
emit('execute', content, (error) => {
|
||||
const endTime = performance.now()
|
||||
lastExecutionTime.value = Math.round(endTime - startTime)
|
||||
|
||||
if (!error) {
|
||||
// 成功执行后添加到历史
|
||||
toolbarRef.value?.addHistory(content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleExecuteSelected = () => {
|
||||
@@ -272,7 +268,16 @@ const handleExecuteSelected = () => {
|
||||
return
|
||||
}
|
||||
|
||||
emit('execute-selected', content)
|
||||
const startTime = performance.now()
|
||||
|
||||
emit('execute-selected', content, (error) => {
|
||||
const endTime = performance.now()
|
||||
lastExecutionTime.value = Math.round(endTime - startTime)
|
||||
|
||||
if (!error) {
|
||||
toolbarRef.value?.addHistory(content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const insertSQL = async (sql) => {
|
||||
@@ -343,6 +348,75 @@ onMounted(async () => {
|
||||
await initEditor()
|
||||
})
|
||||
|
||||
// ==================== 新增:格式化与导出 ====================
|
||||
const handleFormat = () => {
|
||||
const editor = getEditor()
|
||||
if (!editor) {
|
||||
Message.warning('编辑器未初始化')
|
||||
return
|
||||
}
|
||||
|
||||
const currentContent = editor.state.doc.toString()
|
||||
if (!currentContent.trim()) {
|
||||
Message.warning('没有可格式化的内容')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const formatted = formatSQL(currentContent, {
|
||||
indent: ' ',
|
||||
uppercase: true
|
||||
})
|
||||
|
||||
const transaction = editor.state.update({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editor.state.doc.length,
|
||||
insert: formatted
|
||||
}
|
||||
})
|
||||
editor.dispatch(transaction)
|
||||
Message.success('SQL 已格式化')
|
||||
} catch (e) {
|
||||
Message.error('格式化失败:' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = (format) => {
|
||||
if (!props.queryResults || props.queryResults.length === 0) {
|
||||
Message.warning('没有可导出的查询结果')
|
||||
return
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19)
|
||||
const filename = `query-result-${timestamp}`
|
||||
|
||||
let success = false
|
||||
switch (format) {
|
||||
case 'csv':
|
||||
success = exportToCSV(props.queryResults, [], `${filename}.csv`)
|
||||
break
|
||||
case 'json':
|
||||
success = exportToJSON(props.queryResults, `${filename}.json`, true)
|
||||
break
|
||||
case 'excel':
|
||||
success = exportToExcel(props.queryResults, [], `${filename}.xls`)
|
||||
break
|
||||
case 'markdown':
|
||||
success = exportToMarkdown(props.queryResults, [], `${filename}.md`)
|
||||
break
|
||||
default:
|
||||
Message.warning('不支持的导出格式')
|
||||
return
|
||||
}
|
||||
|
||||
if (success) {
|
||||
Message.success(`已导出为 ${format.toUpperCase()} 格式`)
|
||||
} else {
|
||||
Message.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 暴露方法 ====================
|
||||
defineExpose({
|
||||
insertSQL,
|
||||
|
||||
108
web/src/views/db-cli/composables/useQueryHistory.js
Normal file
108
web/src/views/db-cli/composables/useQueryHistory.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 查询历史管理
|
||||
* 用于存储和快速重用之前的查询
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'db-cli:query-history'
|
||||
const MAX_HISTORY = 50 // 最多保存50条历史
|
||||
|
||||
export function useQueryHistory() {
|
||||
/**
|
||||
* 获取查询历史
|
||||
*/
|
||||
const getHistory = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) return []
|
||||
return JSON.parse(stored)
|
||||
} catch (e) {
|
||||
console.error('Failed to load query history:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加查询到历史
|
||||
*/
|
||||
const addHistory = (query, connectionId = null, dbType = 'mysql') => {
|
||||
if (!query || !query.trim()) return
|
||||
|
||||
const history = getHistory()
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
// 移除重复项
|
||||
const filtered = history.filter(
|
||||
item => item.query !== trimmedQuery || item.connectionId !== connectionId
|
||||
)
|
||||
|
||||
// 添加新记录到开头
|
||||
const newRecord = {
|
||||
id: Date.now(),
|
||||
query: trimmedQuery,
|
||||
connectionId,
|
||||
dbType,
|
||||
timestamp: new Date().toISOString(),
|
||||
queryPreview: trimmedQuery.substring(0, 100) + (trimmedQuery.length > 100 ? '...' : '')
|
||||
}
|
||||
|
||||
const updated = [newRecord, ...filtered].slice(0, MAX_HISTORY)
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
|
||||
return newRecord
|
||||
} catch (e) {
|
||||
console.error('Failed to save query history:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除历史
|
||||
*/
|
||||
const clearHistory = () => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to clear query history:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条历史
|
||||
*/
|
||||
const deleteHistory = (id) => {
|
||||
const history = getHistory()
|
||||
const filtered = history.filter(item => item.id !== id)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to delete query history:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索历史
|
||||
*/
|
||||
const searchHistory = (keyword) => {
|
||||
const history = getHistory()
|
||||
if (!keyword) return history
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
return history.filter(item =>
|
||||
item.query.toLowerCase().includes(lowerKeyword) ||
|
||||
item.queryPreview.toLowerCase().includes(lowerKeyword)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
getHistory,
|
||||
addHistory,
|
||||
clearHistory,
|
||||
deleteHistory,
|
||||
searchHistory
|
||||
}
|
||||
}
|
||||
195
web/src/views/db-cli/composables/useQueryTemplates.js
Normal file
195
web/src/views/db-cli/composables/useQueryTemplates.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 查询模板管理
|
||||
* 用于保存常用查询模板
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'db-cli:query-templates'
|
||||
|
||||
// 默认模板
|
||||
const DEFAULT_TEMPLATES = [
|
||||
{
|
||||
id: 'template-1',
|
||||
name: '查询所有数据',
|
||||
description: '查询表中所有数据',
|
||||
query: 'SELECT * FROM table_name WHERE 1=1;',
|
||||
category: '基础查询'
|
||||
},
|
||||
{
|
||||
id: 'template-2',
|
||||
name: '分页查询',
|
||||
description: '带分页的数据查询',
|
||||
query: 'SELECT * FROM table_name WHERE 1=1 LIMIT 10 OFFSET 0;',
|
||||
category: '分页'
|
||||
},
|
||||
{
|
||||
id: 'template-3',
|
||||
name: '统计查询',
|
||||
description: '统计数据行数',
|
||||
query: 'SELECT COUNT(*) as total FROM table_name WHERE 1=1;',
|
||||
category: '统计'
|
||||
},
|
||||
{
|
||||
id: 'template-4',
|
||||
name: '插入数据',
|
||||
description: '插入单条数据',
|
||||
query: 'INSERT INTO table_name (column1, column2) VALUES (value1, value2);',
|
||||
category: '插入'
|
||||
},
|
||||
{
|
||||
id: 'template-5',
|
||||
name: '更新数据',
|
||||
description: '更新指定条件的数据',
|
||||
query: 'UPDATE table_name SET column1 = value1 WHERE id = 1;',
|
||||
category: '更新'
|
||||
},
|
||||
{
|
||||
id: 'template-6',
|
||||
name: '删除数据',
|
||||
description: '删除指定条件的数据',
|
||||
query: 'DELETE FROM table_name WHERE id = 1;',
|
||||
category: '删除'
|
||||
},
|
||||
{
|
||||
id: 'template-redis-1',
|
||||
name: 'Redis - 设置键值',
|
||||
description: 'SET 命令',
|
||||
query: 'SET key value',
|
||||
category: 'Redis',
|
||||
dbType: 'redis'
|
||||
},
|
||||
{
|
||||
id: 'template-redis-2',
|
||||
name: 'Redis - 获取键值',
|
||||
description: 'GET 命令',
|
||||
query: 'GET key',
|
||||
category: 'Redis',
|
||||
dbType: 'redis'
|
||||
},
|
||||
{
|
||||
id: 'template-mongo-1',
|
||||
name: 'MongoDB - 查询数据',
|
||||
description: 'find 查询',
|
||||
query: 'db.collection.find({ field: value })',
|
||||
category: 'MongoDB',
|
||||
dbType: 'mongodb'
|
||||
}
|
||||
]
|
||||
|
||||
export function useQueryTemplates() {
|
||||
/**
|
||||
* 获取模板列表
|
||||
*/
|
||||
const getTemplates = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) {
|
||||
// 首次使用,保存默认模板
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_TEMPLATES))
|
||||
return DEFAULT_TEMPLATES
|
||||
}
|
||||
return JSON.parse(stored)
|
||||
} catch (e) {
|
||||
console.error('Failed to load query templates:', e)
|
||||
return DEFAULT_TEMPLATES
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存模板
|
||||
*/
|
||||
const saveTemplate = (template) => {
|
||||
const templates = getTemplates()
|
||||
const newTemplate = {
|
||||
id: `template-${Date.now()}`,
|
||||
name: template.name || '未命名模板',
|
||||
description: template.description || '',
|
||||
query: template.query,
|
||||
category: template.category || '自定义',
|
||||
dbType: template.dbType || 'mysql',
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
const updated = [...templates, newTemplate]
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
|
||||
return newTemplate
|
||||
} catch (e) {
|
||||
console.error('Failed to save query template:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板
|
||||
*/
|
||||
const updateTemplate = (id, updates) => {
|
||||
const templates = getTemplates()
|
||||
const index = templates.findIndex(t => t.id === id)
|
||||
|
||||
if (index === -1) return null
|
||||
|
||||
templates[index] = {
|
||||
...templates[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates))
|
||||
return templates[index]
|
||||
} catch (e) {
|
||||
console.error('Failed to update query template:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模板
|
||||
*/
|
||||
const deleteTemplate = (id) => {
|
||||
const templates = getTemplates()
|
||||
const filtered = templates.filter(t => t.id !== id)
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to delete query template:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据数据库类型筛选模板
|
||||
*/
|
||||
const getTemplatesByType = (dbType) => {
|
||||
const templates = getTemplates()
|
||||
if (!dbType) return templates
|
||||
|
||||
// 通用模板(无 dbType) + 匹配当前 dbType 的模板
|
||||
return templates.filter(t => !t.dbType || t.dbType === dbType.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置为默认模板
|
||||
*/
|
||||
const resetToDefaults = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_TEMPLATES))
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to reset query templates:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getTemplates,
|
||||
saveTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
getTemplatesByType,
|
||||
resetToDefaults
|
||||
}
|
||||
}
|
||||
231
web/src/views/db-cli/utils/resultExporter.js
Normal file
231
web/src/views/db-cli/utils/resultExporter.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 查询结果导出工具
|
||||
* 支持 CSV、JSON、Excel 格式
|
||||
*/
|
||||
|
||||
/**
|
||||
* 导出为 CSV
|
||||
*/
|
||||
export function exportToCSV(data, columns = [], filename = 'query-result.csv') {
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
console.warn('No data to export')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 自动检测列名
|
||||
let headers = columns
|
||||
if (headers.length === 0) {
|
||||
headers = Object.keys(data[0])
|
||||
}
|
||||
|
||||
// 构建 CSV 内容
|
||||
const rows = []
|
||||
|
||||
// 表头
|
||||
rows.push(headers.join(','))
|
||||
|
||||
// 数据行
|
||||
data.forEach(row => {
|
||||
const values = headers.map(header => {
|
||||
const value = row[header]
|
||||
// 处理 null/undefined
|
||||
if (value === null || value === undefined) return ''
|
||||
// 处理包含逗号或引号的值
|
||||
const strValue = String(value)
|
||||
if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
|
||||
return `"${strValue.replace(/"/g, '""')}"`
|
||||
}
|
||||
return strValue
|
||||
})
|
||||
rows.push(values.join(','))
|
||||
})
|
||||
|
||||
// 添加 BOM 使 Excel 正确识别 UTF-8
|
||||
const BOM = '\uFEFF'
|
||||
const csvContent = BOM + rows.join('\n')
|
||||
|
||||
// 创建下载
|
||||
downloadFile(csvContent, filename, 'text/csv;charset=utf-8')
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to export CSV:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为 JSON
|
||||
*/
|
||||
export function exportToJSON(data, filename = 'query-result.json', pretty = true) {
|
||||
if (!data || !Array.isArray(data)) {
|
||||
console.warn('No data to export')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonContent = pretty
|
||||
? JSON.stringify(data, null, 2)
|
||||
: JSON.stringify(data)
|
||||
|
||||
downloadFile(jsonContent, filename, 'application/json;charset=utf-8')
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to export JSON:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为 Markdown 表格
|
||||
*/
|
||||
export function exportToMarkdown(data, columns = [], filename = 'query-result.md') {
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
console.warn('No data to export')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 自动检测列名
|
||||
let headers = columns
|
||||
if (headers.length === 0) {
|
||||
headers = Object.keys(data[0])
|
||||
}
|
||||
|
||||
// 构建 Markdown 表格
|
||||
const rows = []
|
||||
|
||||
// 表头
|
||||
rows.push('| ' + headers.join(' | ') + ' |')
|
||||
rows.push('| ' + headers.map(() => '---').join(' | ') + ' |')
|
||||
|
||||
// 数据行
|
||||
data.forEach(row => {
|
||||
const values = headers.map(header => {
|
||||
const value = row[header]
|
||||
if (value === null || value === undefined) return ''
|
||||
// 转义管道符
|
||||
return String(value).replace(/\|/g, '\\|')
|
||||
})
|
||||
rows.push('| ' + values.join(' | ') + ' |')
|
||||
})
|
||||
|
||||
const mdContent = rows.join('\n')
|
||||
downloadFile(mdContent, filename, 'text/markdown;charset=utf-8')
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to export Markdown:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为 Excel (HTML 表格格式)
|
||||
*/
|
||||
export function exportToExcel(data, columns = [], filename = 'query-result.xls') {
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
console.warn('No data to export')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 自动检测列名
|
||||
let headers = columns
|
||||
if (headers.length === 0) {
|
||||
headers = Object.keys(data[0])
|
||||
}
|
||||
|
||||
// 构建 HTML 表格
|
||||
let html = '<table>\n'
|
||||
|
||||
// 表头
|
||||
html += ' <thead>\n <tr>\n'
|
||||
headers.forEach(header => {
|
||||
html += ` <th><b>${escapeHtml(header)}</b></th>\n`
|
||||
})
|
||||
html += ' </tr>\n </thead>\n'
|
||||
|
||||
// 表体
|
||||
html += ' <tbody>\n'
|
||||
data.forEach(row => {
|
||||
html += ' <tr>\n'
|
||||
headers.forEach(header => {
|
||||
const value = row[header]
|
||||
const displayValue = value === null || value === undefined ? '' : String(value)
|
||||
html += ` <td>${escapeHtml(displayValue)}</td>\n`
|
||||
})
|
||||
html += ' </tr>\n'
|
||||
})
|
||||
html += ' </tbody>\n'
|
||||
|
||||
html += '</table>'
|
||||
|
||||
downloadFile(html, filename, 'application/vnd.ms-excel;charset=utf-8')
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to export Excel:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
function downloadFile(content, filename, mimeType) {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, m => map[m])
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制到剪贴板
|
||||
*/
|
||||
export async function copyToClipboard(data, format = 'json') {
|
||||
if (!data) return false
|
||||
|
||||
try {
|
||||
let content = ''
|
||||
|
||||
switch (format) {
|
||||
case 'json':
|
||||
content = JSON.stringify(data, null, 2)
|
||||
break
|
||||
case 'csv':
|
||||
if (data.length === 0) return false
|
||||
const headers = Object.keys(data[0])
|
||||
content = headers.join(',') + '\n'
|
||||
data.forEach(row => {
|
||||
content += headers.map(h => row[h] ?? '').join(',') + '\n'
|
||||
})
|
||||
break
|
||||
default:
|
||||
content = String(data)
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(content)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to copy to clipboard:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
124
web/src/views/db-cli/utils/sqlFormatter.js
Normal file
124
web/src/views/db-cli/utils/sqlFormatter.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* SQL 格式化工具
|
||||
* 简单的 SQL 美化工具
|
||||
*/
|
||||
|
||||
/**
|
||||
* SQL 关键字列表
|
||||
*/
|
||||
const SQL_KEYWORDS = [
|
||||
'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN',
|
||||
'OUTER JOIN', 'ON', 'AND', 'OR', 'NOT', 'IN', 'LIKE', 'BETWEEN',
|
||||
'ORDER BY', 'GROUP BY', 'HAVING', 'LIMIT', 'OFFSET',
|
||||
'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM',
|
||||
'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE',
|
||||
'UNION', 'UNION ALL', 'DISTINCT', 'AS',
|
||||
'ASC', 'DESC', 'NULL', 'IS NULL', 'IS NOT NULL',
|
||||
'PRIMARY KEY', 'FOREIGN KEY', 'REFERENCES', 'UNIQUE',
|
||||
'INDEX', 'CASCADE', 'RESTRICT', 'NO ACTION',
|
||||
'CASE', 'WHEN', 'THEN', 'ELSE', 'END'
|
||||
]
|
||||
|
||||
/**
|
||||
* 简单的 SQL 格式化
|
||||
* @param {string} sql - 原始 SQL
|
||||
* @param {object} options - 格式化选项
|
||||
* @returns {string} - 格式化后的 SQL
|
||||
*/
|
||||
export function formatSQL(sql, options = {}) {
|
||||
const {
|
||||
indent = ' ', // 缩进字符串
|
||||
uppercase = true, // 关键字大写
|
||||
linesBetweenQueries = 2 // 查询之间的空行数
|
||||
} = options
|
||||
|
||||
if (!sql || typeof sql !== 'string') return ''
|
||||
|
||||
// 移除多余的空白
|
||||
let formatted = sql.trim()
|
||||
|
||||
// 分割成多个查询
|
||||
const queries = formatted.split(';').filter(q => q.trim())
|
||||
|
||||
const formattedQueries = queries.map(query => {
|
||||
return formatSingleQuery(query.trim(), { indent, uppercase })
|
||||
})
|
||||
|
||||
// 用空行连接查询
|
||||
return formattedQueries.join('\n'.repeat(linesBetweenQueries + 1)) +
|
||||
(formattedQueries.length > 0 ? ';\n' : '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单个查询
|
||||
*/
|
||||
function formatSingleQuery(query, { indent, uppercase }) {
|
||||
if (!query) return ''
|
||||
|
||||
// 关键字转大写/小写
|
||||
let result = query
|
||||
if (uppercase) {
|
||||
SQL_KEYWORDS.forEach(keyword => {
|
||||
const regex = new RegExp(`\\b${keyword}\\b`, 'gi')
|
||||
result = result.replace(regex, keyword)
|
||||
})
|
||||
}
|
||||
|
||||
// 在关键字前后添加换行
|
||||
const lineBreakKeywords = [
|
||||
'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN',
|
||||
'OUTER JOIN', 'ON', 'AND', 'OR', 'ORDER BY', 'GROUP BY', 'HAVING',
|
||||
'LIMIT', 'OFFSET', 'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM',
|
||||
'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE', 'UNION', 'UNION ALL'
|
||||
]
|
||||
|
||||
lineBreakKeywords.forEach(keyword => {
|
||||
const regex = new RegExp(`\\s+${keyword}\\s+`, 'gi')
|
||||
result = result.replace(regex, `\n${keyword} `)
|
||||
})
|
||||
|
||||
// 处理逗号
|
||||
result = result.replace(/,\s*/g, ',\n' + indent)
|
||||
|
||||
// 移除开头的换行
|
||||
result = result.replace(/^\n+/, '')
|
||||
|
||||
// 按行分割并处理缩进
|
||||
const lines = result.split('\n')
|
||||
let indentLevel = 0
|
||||
const formattedLines = lines.map(line => {
|
||||
line = line.trim()
|
||||
if (!line) return ''
|
||||
|
||||
// 减少缩进的关键字
|
||||
if (/^(FROM|WHERE|ORDER BY|GROUP BY|HAVING|LIMIT|OFFSET)/i.test(line)) {
|
||||
indentLevel = 0
|
||||
}
|
||||
|
||||
// 添加缩进
|
||||
let formattedLine = indent.repeat(indentLevel) + line
|
||||
|
||||
// 增加缩进的关键字
|
||||
if (/^(FROM|WHERE|JOIN|ON|AND|OR|ORDER BY|GROUP BY|HAVING|VALUES|SET)/i.test(line)) {
|
||||
// 保持当前缩进级别
|
||||
} else if (/^(INSERT INTO|UPDATE|DELETE FROM|CREATE TABLE)/i.test(line)) {
|
||||
indentLevel = 1
|
||||
}
|
||||
|
||||
return formattedLine
|
||||
})
|
||||
|
||||
return formattedLines.filter(line => line.trim()).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单格式化(单行压缩)
|
||||
*/
|
||||
export function minifySQL(sql) {
|
||||
if (!sql || typeof sql !== 'string') return ''
|
||||
return sql
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\s*,\s*/g, ',')
|
||||
.replace(/\s*;\s*/g, ';')
|
||||
.trim()
|
||||
}
|
||||
Reference in New Issue
Block a user