Private
Public Access
1
0

新增:数据库 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:
2026-02-13 01:20:52 +08:00
parent 4a1f0213df
commit 22f5862f15
8 changed files with 1615 additions and 39 deletions

View 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>

View 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>

View 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>

View File

@@ -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,

View 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
}
}

View 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
}
}

View 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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
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
}
}

View 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()
}