Private
Public Access
1
0

重构:移除数据库客户端模块 v0.4.0(-17,885行,专注文件管理)

- 删除全部 MySQL/Redis/MongoDB 客户端代码(dbclient/api/service/storage)
- 清理 4 个驱动依赖(mysql/redis/mongo/gorm-mysql),构建体积 -10MB
- 前端移除 db-cli 整个目录(40 文件)+ 7 个 API/工具文件
- 版本号升级至 v0.4.0,顶部 Tab 仅保留文件管理
This commit is contained in:
2026-04-26 00:03:22 +08:00
parent 742581c5d6
commit 4f1d5f885f
92 changed files with 29 additions and 17889 deletions

View File

@@ -57,7 +57,7 @@
<a-layout-content class="content">
<!-- 动态渲染 Tab 内容 -->
<!-- 使用 KeepAlive 缓存组件状态避免切换时重新加载 -->
<KeepAlive include="FileSystem,DbCli">
<KeepAlive include="FileSystem">
<component :is="getComponent(activeTab)"/>
</KeepAlive>
</a-layout-content>
@@ -94,7 +94,6 @@
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import {IconSettings, IconPushpin} from '@arco-design/web-vue/es/icon'
import MarkdownEditor from './views/markdown-editor/index.vue'
import DbCli from './views/db-cli/index.vue'
import VersionHistory from './views/version/index.vue'
import ThemeToggle from './components/ThemeToggle.vue'
import FileSystem from './components/FileSystem/index.vue'
@@ -152,7 +151,6 @@ const loadConfig = async () => {
const getComponent = (key: string) => {
const components = {
'file-system': FileSystem,
'db-cli': DbCli,
'markdown-editor': MarkdownEditor
}
return components[key] || null

View File

@@ -1,25 +0,0 @@
/**
* 连接相关 API
*/
import type { Connection } from './types'
/**
* 获取连接列表
*/
export async function listConnections(): Promise<Connection[]> {
if (!window.go?.main?.App?.ListDbConnections) {
throw new Error('ListDbConnections API 不可用')
}
return await window.go.main.App.ListDbConnections()
}
/**
* 删除连接
*/
export async function deleteConnection(id: number): Promise<void> {
if (!window.go?.main?.App?.DeleteDbConnection) {
throw new Error('DeleteDbConnection API 不可用')
}
await window.go.main.App.DeleteDbConnection(id)
}

View File

@@ -1,25 +0,0 @@
/**
* 数据库和表相关 API
*/
import type { Database, Table } from './types'
/**
* 获取数据库列表
*/
export async function getDatabases(connectionId: number): Promise<Database[]> {
if (!window.go?.main?.App?.GetDatabases) {
throw new Error('GetDatabases API 不可用')
}
return await window.go.main.App.GetDatabases(connectionId)
}
/**
* 获取表列表
*/
export async function getTables(connectionId: number, database: string): Promise<Table[]> {
if (!window.go?.main?.App?.GetTables) {
throw new Error('GetTables API 不可用')
}
return await window.go.main.App.GetTables(connectionId, database)
}

View File

@@ -3,9 +3,4 @@
*/
export * from './types'
export * from './connection'
export * from './database'
export * from './structure'
export * from './query'
export * from './tab'
export * from './system'

View File

@@ -1,21 +0,0 @@
/**
* SQL 查询相关 API
*/
import type { QueryResult } from './types'
/**
* 执行 SQL 查询
*/
export async function executeQuery(
connectionId: number,
sql: string,
database?: string,
page?: number,
pageSize?: number
): Promise<QueryResult> {
if (!window.go?.main?.App?.ExecuteSQL) {
throw new Error('ExecuteSQL API 不可用')
}
return await window.go.main.App.ExecuteSQL(connectionId, sql, database, page, pageSize)
}

View File

@@ -1,19 +0,0 @@
/**
* 表结构相关 API
*/
import type { Structure } from './types'
/**
* 获取表结构
*/
export async function getTableStructure(
connectionId: number,
database: string,
table: string
): Promise<Structure> {
if (!window.go?.main?.App?.GetTableStructure) {
throw new Error('GetTableStructure API 不可用')
}
return await window.go.main.App.GetTableStructure(connectionId, database, table)
}

View File

@@ -1,25 +0,0 @@
/**
* 标签页相关 API
*/
import type { Tab } from './types'
/**
* 保存标签页
*/
export async function saveTabs(tabs: Tab[]): Promise<void> {
if (!window.go?.main?.App?.SaveSqlTabs) {
throw new Error('SaveSqlTabs API 不可用')
}
await window.go.main.App.SaveSqlTabs(tabs)
}
/**
* 获取标签页列表
*/
export async function listTabs(): Promise<Tab[]> {
if (!window.go?.main?.App?.ListSqlTabs) {
throw new Error('ListSqlTabs API 不可用')
}
return await window.go.main.App.ListSqlTabs()
}

View File

@@ -2,75 +2,6 @@
* API 类型定义
*/
// 连接
export interface Connection {
id: number
name: string
dbType: string
host: string
port: number
username: string
database?: string
createdAt?: string
}
// 数据库和表
export interface Database {
name: string
tableCount?: number
}
export interface Table {
name: string
type?: string
}
// 表结构
export interface Column {
Field: string
Type: string
Null: string
Key: string
Default: string | null
Comment: string
Extra?: string
}
export interface Index {
Key_name: string
Column_name: string
Non_unique: number
Seq_in_index: number
Index_type: string
}
export interface Structure {
database: string
table: string
type: 'mysql' | 'mongo' | 'redis'
columns?: Column[]
indexes?: Index[]
structure?: any
info?: any
}
// SQL 查询
export interface QueryResult {
columns: string[]
data: any[]
rowsAffected: number
executionTime: number
}
// 标签页
export interface Tab {
id?: number
title: string
content: string
connectionId?: number | null
order?: number
}
// 系统信息
export interface SystemInfo {
os: string

View File

@@ -1,50 +0,0 @@
/**
* 可见数据库管理 Composable
* 封装 visible_databases 字段的解析和过滤逻辑
*/
/**
* 解析可见数据库 JSON 字符串
* @param jsonStr - JSON 字符串或 null
* @returns 解析后的数据库数组,解析失败返回空数组
*/
export function parseVisibleDatabases(jsonStr: string | null): string[] {
if (!jsonStr) return []
try {
const parsed = JSON.parse(jsonStr)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
/**
* 根据可见数据库配置过滤数据库列表
* @param databases - 完整的数据库列表
* @param visibleJson - 可见数据库 JSON 字符串
* @returns 过滤后的数据库列表(如果未配置过滤则返回全部)
*/
export function filterDatabases(databases: string[], visibleJson: string | null): string[] {
const visible = parseVisibleDatabases(visibleJson)
return visible.length > 0 ? databases.filter(db => visible.includes(db)) : databases
}
/**
* 将数据库数组序列化为 JSON 字符串(空数组返回空字符串)
* @param databases - 数据库数组
* @returns JSON 字符串或空字符串
*/
export function serializeVisibleDatabases(databases: string[]): string {
return databases.length > 0 ? JSON.stringify(databases) : ''
}
/**
* 可见数据库管理 Composable
*/
export function useVisibleDatabases() {
return {
parse: parseVisibleDatabases,
filter: filterDatabases,
serialize: serializeVisibleDatabases,
}
}

View File

@@ -44,8 +44,7 @@ export const useConfigStore = defineStore('config', () => {
if (!tabs?.length) {
return [
{ key: 'file-system', title: '文件管理' },
{ key: 'db-cli', title: '数据库' }
{ key: 'file-system', title: '文件管理' }
]
}
@@ -93,8 +92,8 @@ export const useConfigStore = defineStore('config', () => {
const { tabs = [], visibleTabs = [], defaultTab = 'file-system' } = result.data
// 一级 Tab 只有文件管理和数据库其他功能Markdown、版本历史不作为独立 Tab
const allKeys = ['file-system', 'db-cli']
const tabTitles: Record<string, string> = { 'file-system': '文件管理', 'db-cli': '数据库' }
const allKeys = ['file-system']
const tabTitles: Record<string, string> = { 'file-system': '文件管理' }
const mergedTabs = allKeys.map(key => tabs.find(t => t.key === key) || { key, title: tabTitles[key] || key, enabled: true })
const mergedVisible = visibleTabs.length
? visibleTabs.filter(k => allKeys.includes(k))
@@ -119,10 +118,9 @@ export const useConfigStore = defineStore('config', () => {
const useDefaultConfig = () => {
appConfig.value = {
tabs: [
{ key: 'file-system', title: '文件管理', visible: true, enabled: true },
{ key: 'db-cli', title: '数据库', visible: true, enhanced: true }
{ key: 'file-system', title: '文件管理', visible: true, enabled: true }
],
visibleTabs: ['file-system', 'db-cli'],
visibleTabs: ['file-system'],
defaultTab: 'file-system'
}
}

View File

@@ -1,114 +0,0 @@
/**
* 数据库错误提示工具
* 将技术性错误信息转换为用户友好的提示
*/
/**
* 解析数据库连接错误,返回友好的提示信息
*/
export function getFriendlyDatabaseError(error: Error | string | unknown): string {
const errorMsg = typeof error === 'string' ? error : error instanceof Error ? error.message : String(error)
// MySQL 错误码处理
if (errorMsg.includes('Error 1045') || errorMsg.includes('28000')) {
return '用户名或密码错误,请检查连接配置'
}
if (errorMsg.includes('Error 2002') || errorMsg.includes('Error 2003')) {
return '无法连接到数据库服务器,请检查主机地址和端口是否正确'
}
if (errorMsg.includes('Error 2003')) {
return '无法连接到 MySQL 服务器,请检查:\n1. 主机地址和端口是否正确\n2. MySQL 服务是否已启动\n3. 防火墙是否允许连接'
}
if (errorMsg.includes('Error 1045') || errorMsg.includes('Access denied')) {
return '认证失败,请检查:\n1. 用户名是否正确\n2. 密码是否正确\n3. 用户是否有访问权限'
}
if (errorMsg.includes('Error 1130') || errorMsg.includes('host is not allowed')) {
return '当前 IP 不被允许连接,请检查 MySQL 用户的主机访问权限配置'
}
if (errorMsg.includes('Error 10061') || errorMsg.includes('Connection refused')) {
return '连接被拒绝,请检查:\n1. 端口是否正确\n2. 数据库服务是否已启动'
}
if (errorMsg.includes('Error 10060') || errorMsg.includes('timeout')) {
return '连接超时,请检查:\n1. 网络连接是否正常\n2. 主机地址是否正确\n3. 防火墙设置'
}
if (errorMsg.includes('No connection') || errorMsg.includes('closed')) {
return '数据库连接已断开,请尝试重新连接'
}
// MongoDB 错误处理
if (errorMsg.includes('Authentication failed')) {
return 'MongoDB 认证失败,请检查用户名和密码'
}
if (errorMsg.includes('connection refused') || errorMsg.includes('ECONNREFUSED')) {
return '无法连接到 MongoDB 服务器,请检查服务是否已启动'
}
if (errorMsg.includes('ENOTFOUND') || errorMsg.includes('no such host')) {
return '主机地址无法解析,请检查主机名是否正确'
}
// Redis 错误处理
if (errorMsg.includes('NOAUTH')) {
return 'Redis 需要密码认证'
}
if (errorMsg.includes('WRONGPASS')) {
return 'Redis 密码错误'
}
if (errorMsg.includes('connection')) {
return '无法连接到 Redis 服务器,请检查:\n1. 主机地址和端口是否正确\n2. Redis 服务是否已启动'
}
// 网络相关
if (errorMsg.includes('network') || errorMsg.includes('ENETUNREACH')) {
return '网络连接失败,请检查网络设置'
}
// 通用错误
if (errorMsg.includes('获取 MySQL 客户端失败')) {
return '数据库连接初始化失败,请检查连接配置'
}
if (errorMsg.includes('连接.*失败')) {
return '数据库连接失败,请检查连接配置'
}
// 返回原始错误信息(去除技术性前缀)
const friendly = errorMsg
.replace(/^获取 MySQL 客户端失败:\s*/, '')
.replace(/^连接 MySQL 失败:\s*/, '')
.replace(/^连接 MongoDB 失败:\s*/, '')
.replace(/^连接 Redis 失败:\s*/, '')
.replace(/^Error \d+:\s*/, '')
return friendly || '未知错误,请检查连接配置'
}
/**
* 获取连接失败的友好提示
*/
export function getConnectionFailedTip(error: Error | string | unknown, dbType: string = 'mysql'): string {
const friendlyError = getFriendlyDatabaseError(error)
const dbTypeName = dbType === 'mysql' ? 'MySQL' : dbType === 'mongo' ? 'MongoDB' : 'Redis'
return `${dbTypeName} 连接失败:${friendlyError}`
}
/**
* 获取加载数据失败的友好提示
*/
export function getLoadFailedTip(error: Error | string | unknown, loadType: 'databases' | 'tables' | 'keys'): string {
const friendlyError = getFriendlyDatabaseError(error)
const typeText = loadType === 'databases' ? '数据库列表' : loadType === 'tables' ? '表列表' : '键列表'
return `加载${typeText}失败:${friendlyError}`
}

View File

@@ -1,713 +0,0 @@
<template>
<a-modal
v-model:visible="visible"
title="数据库连接配置"
width="600px"
:body-style="{ padding: '16px 20px' }"
@cancel="handleCancel"
>
<!-- 错误提示区域 -->
<a-alert
v-if="errorMessage"
type="error"
show-icon
closable
@close="errorMessage = ''"
class="error-alert"
>
{{ errorMessage }}
</a-alert>
<a-form :model="form" :rules="rules" ref="formRef" layout="horizontal" :label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 18 }" size="small">
<a-form-item label="连接名称" field="name">
<a-input v-model="form.name" placeholder="请输入连接名称" size="small"/>
</a-form-item>
<a-form-item label="数据库类型" field="type">
<a-select v-model="form.type" placeholder="请选择数据库类型" @change="handleTypeChange" size="small">
<a-option value="mysql">MySQL</a-option>
<a-option value="redis">Redis</a-option>
<a-option value="mongo">MongoDB</a-option>
</a-select>
</a-form-item>
<a-form-item label="主机地址" field="host">
<a-input v-model="form.host" placeholder="请输入主机地址" size="small"/>
</a-form-item>
<a-form-item label="端口" field="port">
<a-input-number v-model="form.port" :min="1" :max="65535" placeholder="请输入端口" style="width: 100%"
size="small"/>
</a-form-item>
<a-form-item label="用户名" field="username" v-if="form.type !== 'redis'">
<a-input v-model="form.username" placeholder="请输入用户名" size="small"/>
</a-form-item>
<a-form-item label="密码" field="password">
<div v-if="props.connectionId && !isPasswordChanged" class="password-display">
<a-input
value="已保存的密码"
disabled
class="password-input"
size="small"
/>
<a-button type="text" size="mini" @click="isPasswordChanged = true">
修改密码
</a-button>
</div>
<a-input-password
v-else
v-model="form.password"
:placeholder="getPasswordPlaceholder()"
size="small"
/>
</a-form-item>
<a-form-item :label="form.type === 'redis' ? '数据库编号' : '数据库名'" field="database">
<a-input v-model="form.database"
:placeholder="form.type === 'redis' ? 'Redis DB 编号 (0-15默认为0)' : '可选,留空则连接所有数据库'"
:max-length="100"
size="small"/>
</a-form-item>
<!-- 数据库过滤选项 MySQL MongoDB -->
<template v-if="form.type === 'mysql' || form.type === 'mongo'">
<a-form-item label="可见数据库" field="visibleDatabases">
<div class="database-list-container">
<!-- 顶部工具栏 -->
<div class="list-toolbar">
<a-button
type="outline"
size="small"
@click="loadAllDatabases"
:loading="loadingDatabases"
:disabled="!canLoadDatabases"
>
<template #icon>
<icon-refresh />
</template>
{{ allDatabases.length > 0 ? '重新加载' : '加载数据库列表' }}
</a-button>
<template v-if="allDatabases.length > 0">
<div class="toolbar-stats">
已选 {{ selectedDatabases.length }} / {{ allDatabases.length }}
</div>
<a-button size="small" @click="handleSelectAll(true)">全选</a-button>
<a-button size="small" @click="handleInvertSelection">反选</a-button>
<a-button size="small" @click="handleSelectAll(false)">清空</a-button>
</template>
</div>
<!-- 空状态 -->
<div v-if="!loadingDatabases && allDatabases.length === 0" class="list-empty">
<icon-storage />
<span>点击上方按钮加载数据库列表</span>
</div>
<!-- 数据库列表复选框 -->
<div v-else class="database-checkbox-list">
<div
v-for="db in allDatabases"
:key="db"
class="database-checkbox-item"
>
<a-checkbox
:model-value="selectedDatabases.includes(db)"
:disabled="loadingDatabases"
@change="(checked: boolean) => toggleDatabase(db, checked)"
>
{{ db }}
</a-checkbox>
</div>
</div>
<!-- 提示信息 -->
<div v-if="allDatabases.length > 0" class="list-tip">
<icon-info-circle />
未选择任何数据库时将展示所有数据库
</div>
</div>
</a-form-item>
</template>
<!-- MongoDB 专用选项 -->
<template v-if="form.type === 'mongo'">
<a-form-item label="认证数据库" field="options.authSource">
<a-input v-model="optionsForm.authSource" placeholder="留空则使用 admin" size="small"/>
<template #extra>
<span class="form-item-extra">MongoDB 用户所在的数据库通常为 admin可选</span>
</template>
</a-form-item>
</template>
</a-form>
<template #footer>
<a-space size="small">
<a-button @click="handleTest" :loading="testing" size="small">测试连接</a-button>
<a-button @click="handleCancel" size="small">取消</a-button>
<a-button type="primary" @click="handleSubmit" :loading="saving" size="small">保存</a-button>
</a-space>
</template>
</a-modal>
</template>
<script setup lang="ts">
import {reactive, ref, watch, computed, nextTick} from 'vue'
import {Message} from '@arco-design/web-vue'
import {
IconCheckCircle,
IconClose,
IconInfoCircle,
IconRefresh,
IconStorage
} from '@arco-design/web-vue/es/icon'
import {
ListDbConnections,
SaveDbConnection
} from '../../../wailsjs/wailsjs/go/main/App'
import { getConnectionFailedTip, getLoadFailedTip } from '@/utils/database-error'
import { useVisibleDatabases } from '@/composables/useVisibleDatabases'
// 使用 defineModel 简化 v-model:visible 双向绑定Vue 3.5+
const visible = defineModel('visible', { type: Boolean, default: false })
// 使用 TypeScript 泛型语法Vue 3.5+
const props = defineProps<{
connectionId?: number | null
}>()
const emit = defineEmits<{
success: []
}>()
const formRef = ref<any>(null)
const saving = ref(false)
const testing = ref(false)
const errorMessage = ref('')
// 是否修改密码(编辑模式下)
const isPasswordChanged = ref(false)
// 数据库过滤相关
const { parse: parseVisibleDatabases, filter: filterVisibleDatabases } = useVisibleDatabases()
const loadingDatabases = ref(false)
const allDatabases = ref<string[]>([])
const selectedDatabases = ref<string[]>([])
// 是否可以加载数据库列表
const canLoadDatabases = computed(() =>
!!(form.host && form.port && form.username && (form.password || props.connectionId))
)
const form = reactive({
name: '',
type: 'mysql',
host: '127.0.0.1',
port: 3306,
username: '',
password: '',
database: '',
options: '',
visibleDatabases: ''
})
// 选项表单(用于表单输入)
const optionsForm = reactive({
authSource: ''
})
// 将 options JSON 字符串解析为 optionsForm
const parseOptionsToForm = (optionsStr: string) => {
if (!optionsStr || optionsStr.trim() === '') {
optionsForm.authSource = ''
return
}
try {
const opts = JSON.parse(optionsStr)
optionsForm.authSource = opts.authSource || ''
// 认证机制使用自动检测,不需要从选项读取
} catch (error) {
console.warn('解析 Options JSON 失败:', error)
// 解析失败时,清空表单选项
optionsForm.authSource = ''
}
}
// 将 optionsForm 和 form.options 合并为 JSON 字符串
const mergeOptionsToJson = (): string => {
let customOptions: any = {}
// 先解析已有的 JSON可能包含其他自定义选项
if (form.options && form.options.trim() !== '') {
try {
customOptions = JSON.parse(form.options)
} catch (error) {
console.warn('解析自定义 Options JSON 失败:', error)
}
}
// 根据数据库类型合并表单选项(仅 MongoDB
if (form.type === 'mongo') {
// 只有认证数据库不为空时才添加
if (optionsForm.authSource && optionsForm.authSource.trim() !== '') {
customOptions.authSource = optionsForm.authSource.trim()
}
// 认证机制使用自动检测,不需要添加到选项
}
// 如果没有任何选项,返回空字符串
if (Object.keys(customOptions).length === 0) {
return ''
}
return JSON.stringify(customOptions)
}
// 表单验证规则
const rules = {
name: [
{required: true, message: '请输入连接名称'},
{maxLength: 100, message: '连接名称长度不能超过100个字符'}
],
type: [{required: true, message: '请选择数据库类型'}],
host: [
{required: true, message: '请输入主机地址'},
{maxLength: 255, message: '主机地址长度不能超过255个字符'}
],
port: [
{required: true, message: '请输入端口'},
{
validator: (value, callback) => {
if (!value || value < 1 || value > 65535) {
callback('端口号必须在1-65535之间')
} else {
callback()
}
}
}
],
database: [
{
validator: (value, callback) => {
// MySQL 类型时数据库名为可选(允许为空)
// MongoDB 和 Redis 也为可选
callback()
}
}
]
}
// 获取密码输入框的占位符
const getPasswordPlaceholder = () => {
if (props.connectionId) return '请输入新密码'
const placeholders = { redis: '可选,留空则无密码连接', mongo: '可选,留空则无认证连接' }
return placeholders[form.type] || '请输入密码'
}
// 监听类型变化,设置默认端口、主机和用户名
const handleTypeChange = (type) => {
// 如果主机为空,设置默认值
if (!form.host || form.host.trim() === '') {
form.host = '127.0.0.1'
}
// 根据类型设置默认端口和用户名
switch (type) {
case 'mysql':
form.port = 3306
if (!form.username) {
form.username = 'root'
}
// 清空 MongoDB 专用选项
optionsForm.authSource = ''
form.options = ''
break
case 'redis':
form.port = 6379
form.username = '' // Redis 不需要用户名
if (!form.database) {
form.database = '0' // Redis 默认 DB 0
}
// 清空其他数据库的选项
optionsForm.authSource = ''
form.options = ''
break
case 'mongo':
case 'mongodb':
form.port = 27017
if (!form.username) {
form.username = 'admin' // MongoDB 常用默认用户名
}
break
}
// 类型变化时,同步更新 options JSON
form.options = mergeOptionsToJson()
}
// 加载连接详情
const loadConnection = async () => {
if (!props.connectionId) {
resetForm()
// 新建模式:不自动加载,等用户手动点击
return
}
isLoading.value = true
try {
if (!(window as any).go?.main?.App?.ListDbConnections) {
return
}
const connections = await (window as any).go.main.App.ListDbConnections()
const conn = connections.find(c => c.id === props.connectionId)
if (conn) {
form.name = conn.name
form.type = conn.type
form.host = conn.host || '127.0.0.1'
form.port = conn.port || (conn.type === 'mysql' ? 3306 : conn.type === 'redis' ? 6379 : 27017)
form.username = conn.username || ''
form.database = conn.database || ''
// 先解析 options 到表单
parseOptionsToForm(conn.options || '')
// 然后设置 form.options这样不会触发 watch
form.options = conn.options || ''
// 设置可见数据库
form.visibleDatabases = conn.visible_databases || ''
// 编辑模式下,默认不修改密码
form.password = ''
isPasswordChanged.value = false
// 恢复数据库选择
selectedDatabases.value = parseVisibleDatabases(conn.visible_databases || null)
// 编辑模式:自动加载数据库列表
nextTick(() => {
loadAllDatabases()
})
}
} catch (error) {
console.error('加载连接详情失败:', error)
} finally {
isLoading.value = false
}
}
// 获取要使用的密码(编辑模式下未修改密码时为空)
const getPasswordToUse = () =>
(props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
// 表单验证(返回 true 表示验证通过)
const validateForm = async () => {
if (!formRef.value) {
console.error('formRef 未初始化')
return false
}
errorMessage.value = ''
try {
await formRef.value.validate()
return true
} catch (error) {
const errorMsg = error?.fields?.[Object.keys(error.fields)[0]]?.[0]?.message || '请检查表单填写是否正确'
errorMessage.value = errorMsg
Message.warning(errorMsg)
return false
}
}
// 重置表单
const resetForm = () => {
form.name = ''
form.type = 'mysql'
form.host = '127.0.0.1'
form.port = 3306
form.username = 'root' // MySQL 默认用户名
form.password = ''
form.database = ''
form.options = ''
form.visibleDatabases = ''
optionsForm.authSource = ''
isPasswordChanged.value = false
loadingDatabases.value = false
allDatabases.value = []
selectedDatabases.value = []
}
// 测试连接(不保存数据)
const handleTest = async () => {
if (!(await validateForm())) return
// 检查 Go 后端是否可用
if (!(window as any).go?.main?.App) {
const msg = 'Go 后端未就绪,请确保应用已启动'
errorMessage.value = msg
Message.error(msg)
return
}
testing.value = true
try {
await (window as any).go.main.App.TestDbConnectionWithParams({
id: props.connectionId || 0,
type: form.type,
host: form.host,
port: form.port,
username: form.username || '',
password: getPasswordToUse(),
database: form.database || '',
options: mergeOptionsToJson()
})
Message.success('连接测试成功')
errorMessage.value = ''
} catch (error) {
const friendlyMsg = getConnectionFailedTip(error, form.type)
errorMessage.value = friendlyMsg
Message.error(friendlyMsg)
} finally {
testing.value = false
}
}
// 提交表单
const handleSubmit = async () => {
if (!(await validateForm())) return
// 检查 Go 后端是否可用
if (!(window as any).go?.main?.App) {
const msg = 'Go 后端未就绪,请确保应用已启动'
errorMessage.value = msg
Message.error(msg)
return
}
saving.value = true
try {
await (window as any).go.main.App.SaveDbConnection({
id: props.connectionId || 0,
name: form.name,
type: form.type,
host: form.host,
port: form.port,
username: form.username || '',
password: getPasswordToUse(),
database: form.database || '',
options: mergeOptionsToJson(),
visible_databases: form.visibleDatabases || ''
})
Message.success(props.connectionId ? '更新成功' : '保存成功')
errorMessage.value = ''
emit('success')
visible.value = false
} catch (error) {
const errorMsg = error?.message || error?.toString() || '未知错误'
errorMessage.value = '保存失败: ' + errorMsg
Message.error('保存失败: ' + errorMsg)
} finally {
saving.value = false
}
}
// 取消
const handleCancel = () => {
errorMessage.value = ''
visible.value = false
resetForm()
}
// 是否正在加载连接(用于避免加载时触发 watch
const isLoading = ref(false)
// 监听 optionsForm 变化,自动同步到 form.options仅 MongoDB
watch(
() => [optionsForm.authSource, form.type],
() => {
// 如果正在加载,不触发更新
if (isLoading.value) {
return
}
// 仅 MongoDB 需要同步选项
if (visible.value && form.type === 'mongo') {
form.options = mergeOptionsToJson()
}
},
{ deep: true }
)
// 加载全部数据库列表
const loadAllDatabases = async () => {
if (!canLoadDatabases.value) {
Message.warning('请先填写连接信息')
return
}
loadingDatabases.value = true
try {
const databases = await (window as any).go.main.App.LoadAllDatabases({
id: props.connectionId || 0,
type: form.type,
host: form.host,
port: form.port,
username: form.username || '',
password: getPasswordToUse(),
database: form.database || '',
options: mergeOptionsToJson()
})
allDatabases.value = databases || []
// 从已保存的 visibleDatabases 中恢复选择(使用 composable
selectedDatabases.value = filterVisibleDatabases(databases, form.visibleDatabases || null)
Message.success(`成功加载 ${databases.length} 个数据库`)
} catch (error) {
Message.error(getLoadFailedTip(error, 'databases'))
} finally {
loadingDatabases.value = false
}
}
// 切换单个数据库选择
const toggleDatabase = (dbName: string, checked: boolean) => {
const list = selectedDatabases.value
if (checked && !list.includes(dbName)) {
list.push(dbName)
} else if (!checked) {
selectedDatabases.value = list.filter(db => db !== dbName)
}
}
// 全选/全不选
const handleSelectAll = (selectAll: boolean) => {
if (selectAll) {
selectedDatabases.value = [...allDatabases.value]
} else {
selectedDatabases.value = []
}
}
// 反选
const handleInvertSelection = () => {
const selectedSet = new Set(selectedDatabases.value)
selectedDatabases.value = allDatabases.value.filter(db => !selectedSet.has(db))
}
// 监听数据库选择变化,同步到表单
watch(selectedDatabases, (newVal) => {
if (newVal.length === 0) {
form.visibleDatabases = ''
} else {
form.visibleDatabases = JSON.stringify(newVal)
}
}, { deep: true })
// 监听 visible 变化
watch(visible, (val) => {
if (val) {
errorMessage.value = ''
loadConnection()
} else {
errorMessage.value = ''
resetForm()
}
})
</script>
<style scoped>
.error-alert {
margin-bottom: 12px;
}
.password-display {
display: flex;
align-items: center;
gap: 8px;
}
.password-input {
flex: 1;
}
.options-item {
margin-bottom: 8px;
}
.form-item-extra {
font-size: 12px;
color: var(--color-text-3);
margin-top: 4px;
display: block;
}
.database-list-container {
width: 100%;
}
/* 顶部工具栏 */
.list-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar-stats {
font-size: 13px;
color: var(--color-text-2);
margin: 0 8px;
}
/* 空状态 */
.list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 20px;
color: var(--color-text-3);
font-size: 13px;
}
.list-empty svg {
font-size: 32px;
}
/* 复选框列表 */
.database-checkbox-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
max-height: 240px;
overflow-y: auto;
padding: 12px;
background: var(--color-fill-2);
border: 1px solid var(--color-border-2);
border-radius: 6px;
}
.database-checkbox-item {
display: flex;
align-items: center;
}
.database-checkbox-item :deep(.arco-checkbox) {
width: 100%;
font-size: 13px;
}
/* 底部提示 */
.list-tip {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
background: var(--color-fill-1);
border-radius: 4px;
font-size: 12px;
color: var(--color-text-3);
}
.list-tip svg {
color: var(--color-text-3);
font-size: 14px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,183 +0,0 @@
<template>
<teleport to="body">
<div
v-if="visible"
class="context-menu-overlay"
@click="handleOverlayClick"
@contextmenu.prevent="handleOverlayClick"
>
<div
class="context-menu"
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
@click.stop
>
<template v-for="(item, index) in processedItems" :key="item.key || index">
<div
v-if="item.divider"
class="context-menu-divider"
></div>
<div
v-else
class="context-menu-item"
:class="{ disabled: item.disabled }"
@click="handleMenuItemClick(item)"
>
<span v-if="item.icon" class="context-menu-item-icon">
<component :is="item.icon"/>
</span>
<span class="context-menu-item-label">{{ item.label }}</span>
</div>
</template>
</div>
</div>
</teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Component } from 'vue'
/**
* 菜单项配置
*/
export interface MenuItem {
key: string
label: string
icon?: Component
disabled?: boolean
divider?: boolean
handler?: () => void
}
/**
* 使用 defineModel 简化 v-model:visible 双向绑定Vue 3.5+
*/
const visible = defineModel<boolean>('visible', { default: false })
/**
* Props
*/
const props = defineProps<{
position: { x: number; y: number }
items: MenuItem[]
}>()
/**
* Emits
*/
const emit = defineEmits<{
'menu-item-click': [item: MenuItem]
}>()
/**
* 点击遮罩层关闭菜单
*/
const handleOverlayClick = () => {
visible.value = false
}
/**
* 处理菜单项点击
*/
const handleMenuItemClick = (item: MenuItem) => {
if (item.disabled) return
emit('menu-item-click', item)
if (item.handler) {
item.handler()
}
// 点击后关闭菜单
visible.value = false
}
/**
* 处理菜单项(处理分隔线)
* divider: true 表示在该菜单项之后添加分隔线
*/
const processedItems = computed(() => {
const result: MenuItem[] = []
props.items.forEach((item, index) => {
// 添加菜单项本身(不包含 divider 标记)
const menuItem = { ...item }
const hasDivider = menuItem.divider
delete menuItem.divider // 移除 divider 标记,避免在渲染时被当作分隔线
result.push(menuItem)
// 如果该项标记了 divider在其后添加分隔线
if (hasDivider) {
result.push({
key: `divider-${index}`,
label: '',
divider: true
})
}
})
return result
})
</script>
<style scoped>
.context-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
.context-menu {
position: fixed;
min-width: 160px;
padding: 4px 0;
background: var(--color-bg-popup, #fff);
border: 1px solid var(--color-border-2, #e5e6eb);
border-radius: var(--border-radius-medium, 4px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
z-index: 10000;
}
.context-menu-item {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
color: var(--color-text-1, #1d2129);
font-size: 14px;
transition: background-color 0.2s;
}
.context-menu-item:hover:not(.disabled) {
background: var(--color-fill-2, #f2f3f5);
}
.context-menu-item.disabled {
color: var(--color-text-4, #c9cdd4);
cursor: not-allowed;
}
.context-menu-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 8px;
font-size: 14px;
}
.context-menu-item-label {
flex: 1;
}
.context-menu-divider {
height: 1px;
margin: 4px 12px;
background: var(--color-border-2, #e5e6eb);
}
</style>

View File

@@ -1,529 +0,0 @@
<template>
<div class="mysql-create">
<a-tabs
v-model:active-key="activeTab"
type="line"
class="create-tabs"
>
<!-- 基本信息 -->
<a-tab-pane key="basic" title="基本信息">
<div class="tab-content basic-info-content">
<a-form :model="formData" layout="vertical" :label-col-props="{ span: 6 }">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="数据库" field="database">
<a-input v-model="formData.database" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="表名" field="tableName" :rules="[{ required: true, message: '请输入表名' }]">
<a-input v-model="formData.tableName" placeholder="请输入表名" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="字符集" field="charset">
<a-select v-model="formData.charset" placeholder="选择字符集">
<a-option value="utf8mb4">utf8mb4</a-option>
<a-option value="utf8">utf8</a-option>
<a-option value="latin1">latin1</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序规则" field="collation">
<a-select v-model="formData.collation" placeholder="选择排序规则">
<a-option value="utf8mb4_general_ci">utf8mb4_general_ci</a-option>
<a-option value="utf8mb4_unicode_ci">utf8mb4_unicode_ci</a-option>
<a-option value="utf8_general_ci">utf8_general_ci</a-option>
<a-option value="utf8_unicode_ci">utf8_unicode_ci</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</a-tab-pane>
<!-- 字段列表 -->
<a-tab-pane key="fields" title="字段列表">
<div class="tab-content fields-content">
<MySQLFieldList
mode="create"
:fields="formData.fields"
@add-field="handleAddField"
@remove-field="handleRemoveField"
@move-field="handleMoveField"
@update-field="handleUpdateField"
/>
</div>
</a-tab-pane>
<!-- 索引列表 -->
<a-tab-pane key="indexes" title="索引列表">
<div class="tab-content indexes-content">
<div class="section-header">
<a-button type="primary" size="small" @click="showIndexDialog">
<template #icon>
<icon-plus />
</template>
添加索引
</a-button>
</div>
<div v-if="formData.indexes.length === 0" class="empty-tip">
<a-empty description="暂无索引(可选)" :image="false" />
</div>
<a-table
v-else
:columns="indexColumns"
:data="formData.indexes"
:pagination="false"
size="small"
:bordered="true"
>
<template #unique="{ record }">
<a-tag :color="record.unique ? 'blue' : 'default'">
{{ record.unique ? '是' : '否' }}
</a-tag>
</template>
<template #fields="{ record }">
{{ record.fields.map((f: any) => f.name).join(', ') }}
</template>
<template #operations="{ record, rowIndex }">
<a-button type="text" size="small" status="danger" @click="removeIndex(rowIndex)">
<template #icon>
<icon-delete />
</template>
</a-button>
</template>
</a-table>
</div>
</a-tab-pane>
<!-- SQL预览 -->
<a-tab-pane key="sql" title="SQL预览">
<div class="tab-content sql-preview-content">
<div class="sql-preview-header">
<a-button type="text" size="small" @click="copySQL">
<template #icon>
<icon-copy />
</template>
复制
</a-button>
</div>
<div class="sql-preview-wrapper">
<pre class="sql-code">{{ sqlPreview }}</pre>
</div>
</div>
</a-tab-pane>
</a-tabs>
<!-- 索引定义对话框 -->
<a-modal
v-model:visible="indexDialogVisible"
title="添加索引"
:width="600"
@ok="handleIndexDialogOk"
@cancel="handleIndexDialogCancel"
>
<a-form :model="indexForm" layout="vertical" ref="indexFormRef">
<a-form-item label="索引名" field="name" :rules="[{ required: true, message: '请输入索引名' }]">
<a-input v-model="indexForm.name" placeholder="请输入索引名" />
</a-form-item>
<a-form-item label="唯一索引" field="unique">
<a-checkbox v-model="indexForm.unique">唯一索引</a-checkbox>
</a-form-item>
<a-form-item label="索引字段" field="fields" :rules="[{ required: true, message: '请至少选择一个字段' }]">
<a-select
v-model="indexForm.fields"
mode="multiple"
placeholder="选择索引字段"
:max-tag-count="3"
>
<a-option
v-for="field in formData.fields"
:key="field.name"
:value="field.name"
>
{{ field.name }} ({{ field.type }}{{ field.length ? `(${field.length})` : '' }})
</a-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
IconCopy
} from '@arco-design/web-vue/es/icon'
import MySQLFieldList from './MySQLFieldList.vue'
// Props
interface Props {
connectionId: number
database: string
}
const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'create', data: any): void
}>()
// 当前激活的 tab
const activeTab = ref<string>('basic')
// 表单数据
const formData = reactive({
database: props.database,
tableName: '',
charset: 'utf8mb4',
collation: 'utf8mb4_general_ci',
fields: [] as any[],
indexes: [] as any[]
})
// SQL 预览
const sqlPreview = computed(() => {
if (formData.fields.length === 0) {
return '-- 请先添加字段'
}
return generateSQL()
})
// 索引表格列
const indexColumns = [
{ title: '索引名', dataIndex: 'name', width: 150 },
{ title: '唯一', dataIndex: 'unique', slotName: 'unique', width: 80 },
{ title: '字段', slotName: 'fields', width: 200 },
{ title: '操作', slotName: 'operations', width: 100, fixed: 'right' }
]
// 索引对话框
const indexDialogVisible = ref(false)
const indexFormRef = ref()
const indexForm = reactive({
name: '',
unique: false,
fields: [] as string[]
})
// 字段列表事件处理
const handleAddField = (field: any) => {
formData.fields.push(field)
// 自动切换到字段列表 tab
if (activeTab.value !== 'fields') {
activeTab.value = 'fields'
}
}
const handleUpdateField = (index: number, field: string, value: any) => {
if (formData.fields[index]) {
formData.fields[index] = { ...formData.fields[index], [field]: value }
}
}
const handleRemoveField = (index: number) => {
formData.fields.splice(index, 1)
// 同时移除相关索引
formData.indexes = formData.indexes.filter((idx: any) => {
return idx.fields.every((f: any) => {
const fieldName = typeof f === 'string' ? f : f.name
return fieldName !== formData.fields[index]?.name
})
})
}
const handleMoveField = (index: number, direction: 'up' | 'down') => {
if (direction === 'up' && index > 0) {
const temp = formData.fields[index]
formData.fields[index] = formData.fields[index - 1]
formData.fields[index - 1] = temp
} else if (direction === 'down' && index < formData.fields.length - 1) {
const temp = formData.fields[index]
formData.fields[index] = formData.fields[index + 1]
formData.fields[index + 1] = temp
}
}
const showIndexDialog = () => {
if (formData.fields.length === 0) {
Message.warning('请先添加字段')
return
}
// 重置表单
Object.assign(indexForm, {
name: '',
unique: false,
fields: []
})
indexDialogVisible.value = true
}
const handleIndexDialogOk = async () => {
try {
// 验证表单
await indexFormRef.value?.validate()
// 检查索引名是否重复
if (formData.indexes.some((idx: any) => idx.name === indexForm.name)) {
Message.error('索引名已存在')
return false // 阻止对话框关闭
}
// 添加索引(深拷贝避免引用问题)
const newIndex = {
name: indexForm.name,
unique: indexForm.unique,
fields: indexForm.fields.map((name: string) => ({ name, order: 'ASC' }))
}
formData.indexes.push(newIndex)
Message.success('索引添加成功')
indexDialogVisible.value = false
// 自动切换到索引列表 tab
if (activeTab.value !== 'indexes') {
activeTab.value = 'indexes'
}
} catch (error) {
// 表单验证失败时会抛出错误
console.error('索引表单验证失败:', error)
return false // 阻止对话框关闭
}
}
const handleIndexDialogCancel = () => {
indexDialogVisible.value = false
}
const removeIndex = (index: number) => {
formData.indexes.splice(index, 1)
}
// 复制 SQL
const copySQL = async () => {
try {
await navigator.clipboard.writeText(sqlPreview.value)
Message.success('SQL已复制到剪贴板')
} catch (error) {
Message.error('复制失败')
}
}
// 验证表单
const validate = (): boolean => {
if (!formData.tableName) {
Message.error('请输入表名')
return false
}
if (formData.fields.length === 0) {
Message.error('请至少添加一个字段')
return false
}
// 检查是否有主键
const hasPrimaryKey = formData.fields.some((f: any) => f.primaryKey)
if (!hasPrimaryKey) {
Message.warning('建议设置主键')
}
return true
}
// 转义 SQL 字符串(转义单引号)
const escapeSQLString = (str: string): string => {
return str.replace(/'/g, "''")
}
// 生成 SQL
const generateSQL = (): string => {
const fieldsSQL = formData.fields.map((field: any) => {
let sql = `\`${field.name}\` ${field.type}`
if (field.length) {
sql += `(${field.length})`
}
if (!field.nullable) {
sql += ' NOT NULL'
}
// 处理默认值
if (field.defaultValue !== null && field.defaultValue !== undefined) {
if (field.defaultValue === '') {
// 空字符串默认值
sql += ` DEFAULT ''`
} else {
// 转义单引号
const escapedDefault = escapeSQLString(String(field.defaultValue))
sql += ` DEFAULT '${escapedDefault}'`
}
}
if (field.autoIncrement) {
sql += ' AUTO_INCREMENT'
}
if (field.comment) {
// 转义注释中的单引号
const escapedComment = escapeSQLString(field.comment)
sql += ` COMMENT '${escapedComment}'`
}
return sql
}).join(',\n ')
// 主键
const primaryKeys = formData.fields.filter((f: any) => f.primaryKey).map((f: any) => `\`${f.name}\``)
let primaryKeySQL = ''
if (primaryKeys.length > 0) {
primaryKeySQL = `,\n PRIMARY KEY (${primaryKeys.join(', ')})`
}
// 索引
const indexesSQL = formData.indexes.map((idx: any) => {
const fields = idx.fields.map((f: any) => `\`${typeof f === 'string' ? f : f.name}\``).join(', ')
const unique = idx.unique ? 'UNIQUE ' : ''
return ` ${unique}KEY \`${idx.name}\` (${fields})`
}).join(',\n')
const sql = `CREATE TABLE \`${formData.database}\`.\`${formData.tableName}\` (
${fieldsSQL}${primaryKeySQL}${indexesSQL ? ',\n' + indexesSQL : ''}
) ENGINE=InnoDB DEFAULT CHARSET=${formData.charset} COLLATE=${formData.collation};`
return sql
}
// 暴露方法给父组件
defineExpose({
validate,
generateSQL,
getFormData: () => formData
})
</script>
<style scoped>
.mysql-create {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Tabs 容器 */
.create-tabs {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.create-tabs :deep(.arco-tabs) {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.create-tabs :deep(.arco-tabs-content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.create-tabs :deep(.arco-tabs-content-list) {
height: 100%;
}
.create-tabs :deep(.arco-tabs-content-item) {
height: 100%;
overflow: hidden;
}
/* Tab 内容通用样式 */
.tab-content {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--spacing-md, 12px);
overflow: hidden;
min-height: 0;
}
/* 基本信息内容 */
.basic-info-content {
overflow-y: auto;
}
.basic-info-content :deep(.arco-form-item) {
margin-bottom: 16px;
}
/* 字段列表和索引列表内容 */
.fields-content,
.indexes-content {
overflow-y: auto;
}
.section-header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 12px;
flex-shrink: 0;
}
.empty-tip {
padding: 20px;
text-align: center;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.fields-content :deep(.arco-table),
.indexes-content :deep(.arco-table) {
margin-top: 12px;
}
/* SQL预览内容 */
.sql-preview-content {
overflow: hidden;
}
.sql-preview-header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 12px;
flex-shrink: 0;
}
.sql-preview-wrapper {
flex: 1;
min-height: 0;
overflow: auto;
background: var(--color-fill-2, #f2f3f5);
border: 1px solid var(--color-border-2, #e5e6eb);
border-radius: var(--border-radius-small, 2px);
padding: var(--spacing-md, 12px);
}
.sql-code {
margin: 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
color: var(--color-text-1, #1d2129);
white-space: pre-wrap;
word-wrap: break-word;
background: transparent;
}
</style>

View File

@@ -1,446 +0,0 @@
<template>
<div class="mysql-field-list">
<!-- 创建模式可编辑表格 + 添加按钮 -->
<template v-if="mode === 'create'">
<div class="section-header">
<a-button type="primary" size="small" @click="handleAddField">
<template #icon>
<icon-plus />
</template>
添加字段
</a-button>
</div>
<div v-if="fields.length === 0" class="empty-tip">
<a-empty description="暂无字段,请添加字段" :image="false" />
</div>
<a-table
v-else
:columns="createModeColumns"
:data="fields"
:pagination="false"
size="mini"
:bordered="true"
:scroll="{ x: 'max-content' }"
/>
</template>
<!-- 编辑模式可编辑表格 -->
<template v-else-if="mode === 'edit'">
<a-table
:columns="editModeColumns"
:data="fields"
:pagination="false"
size="mini"
:bordered="true"
:scroll="{ y: scrollHeight, x: 'max-content' }"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
IconPlus,
IconUp,
IconDown,
IconDelete
} from '@arco-design/web-vue/es/icon'
import { Input, Select, Option, Optgroup, InputGroup, Checkbox, Button } from '@arco-design/web-vue'
import { mysqlDataTypeOptions, typesNeedLength, parseType, formatType } from '../utils/mysqlFieldUtils'
// Props
interface Props {
mode: 'create' | 'edit'
fields: any[]
scrollHeight?: number
}
const props = withDefaults(defineProps<Props>(), {
scrollHeight: 400
})
// Emits
const emit = defineEmits<{
'update:fields': [fields: any[]]
'add-field': [field: any]
'remove-field': [index: number]
'move-field': [index: number, direction: 'up' | 'down']
'update-field': [index: number, field: string, value: any]
}>()
// 更新字段值
const updateFieldValue = (rowIndex: number, field: string, value: any) => {
emit('update-field', rowIndex, field, value)
}
// 创建模式:表格列定义(可编辑)
const createModeColumns = computed(() => [
{
title: '序号',
width: 80,
fixed: 'left',
render: ({ rowIndex }: { rowIndex: number }) => rowIndex + 1
},
{
title: '字段名',
dataIndex: 'name',
width: 150,
fixed: 'left',
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
return h(Input, {
modelValue: record.name || '',
'onUpdate:modelValue': (val: string) => updateFieldValue(rowIndex, 'name', val),
size: 'mini',
placeholder: '字段名',
style: { width: '100%' }
})
}
},
{
title: '类型',
dataIndex: 'type',
width: 250,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
const currentType = record.type || ''
const typeStr = currentType + (record.length ? `(${record.length})` : '')
const { baseType, length } = parseType(typeStr)
// 判断当前类型是否需要长度参数
const needsLen = baseType && typesNeedLength.includes(baseType.toUpperCase())
// 检查是否是自定义输入
const isCustomInput = typeStr && /[()]/.test(typeStr) && !mysqlDataTypeOptions.some(group =>
group.options.some(opt => {
const parsed = parseType(typeStr)
return parsed.baseType.toUpperCase() === opt.value.toUpperCase()
})
)
return h('div', {
style: {
display: 'flex',
gap: '4px',
width: '100%',
alignItems: 'center'
}
}, [
// 类型选择下拉框
h(Select, {
modelValue: baseType || currentType,
'onUpdate:modelValue': (val: string) => {
if (val) {
const isCustom = /[()]/.test(val) || !mysqlDataTypeOptions.some(group =>
group.options.some(opt => opt.value.toUpperCase() === val.toUpperCase())
)
if (isCustom) {
const parsed = parseType(val)
updateFieldValue(rowIndex, 'type', parsed.baseType)
if (parsed.length) {
updateFieldValue(rowIndex, 'length', parsed.length)
}
} else {
const upperVal = val.toUpperCase()
const needsLenParam = typesNeedLength.includes(upperVal)
if (needsLenParam) {
const keepLength = baseType.toUpperCase() === upperVal && length
const newType = keepLength ? formatType(upperVal, length) : upperVal
const parsed = parseType(newType)
updateFieldValue(rowIndex, 'type', parsed.baseType)
if (parsed.length) {
updateFieldValue(rowIndex, 'length', parsed.length)
} else {
updateFieldValue(rowIndex, 'length', undefined)
}
} else {
updateFieldValue(rowIndex, 'type', upperVal)
updateFieldValue(rowIndex, 'length', undefined)
}
}
} else {
updateFieldValue(rowIndex, 'type', '')
updateFieldValue(rowIndex, 'length', undefined)
}
},
allowSearch: true,
allowCreate: true,
size: 'mini',
placeholder: '选择类型',
style: { flex: needsLen ? '1' : '1 1 auto', minWidth: '100px' },
filterOption: (inputValue: string, option: any) => {
return option.label?.toLowerCase().includes(inputValue.toLowerCase()) || false
}
}, {
default: () => mysqlDataTypeOptions.map(group =>
h(Optgroup, { label: group.label, key: group.label }, {
default: () => group.options.map(opt =>
h(Option, {
label: opt.label,
value: opt.value,
key: opt.value
})
)
})
)
}),
// 长度输入框(仅当类型需要长度参数时显示)
needsLen && !isCustomInput ? h(InputGroup, {
style: { flex: '0 0 auto', width: '100px' }
}, {
prepend: () => h('span', {
style: {
padding: '0 2px',
color: 'var(--color-text-2)',
fontSize: '12px'
}
}, '('),
default: () => h(Input, {
modelValue: length || '',
'onUpdate:modelValue': (val: string) => {
const trimmedVal = val.trim()
updateFieldValue(rowIndex, 'length', trimmedVal || undefined)
},
size: 'mini',
placeholder: '32',
style: {
textAlign: 'center',
padding: '0 2px',
width: '60px'
}
}),
append: () => h('span', {
style: {
padding: '0 2px',
color: 'var(--color-text-2)',
fontSize: '12px'
}
}, ')')
}) : null
])
}
},
{
title: '允许NULL',
dataIndex: 'nullable',
width: 100,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
return h(Checkbox, {
modelValue: record.nullable !== false,
'onUpdate:modelValue': (checked: boolean) => {
updateFieldValue(rowIndex, 'nullable', checked)
},
style: { display: 'flex', justifyContent: 'center' }
})
}
},
{
title: '默认值',
dataIndex: 'defaultValue',
width: 200,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
const defaultValue = record.defaultValue
const isNull = defaultValue === null || defaultValue === undefined
const isEmptyString = defaultValue === ''
let currentType: 'NULL' | 'EMPTY' | 'VALUE' = 'VALUE'
if (isNull) {
currentType = 'NULL'
} else if (isEmptyString) {
currentType = 'EMPTY'
}
return h('div', { style: { display: 'flex', gap: '4px', width: '100%', alignItems: 'center' } }, [
h(Select, {
modelValue: currentType,
'onUpdate:modelValue': (val: 'NULL' | 'EMPTY' | 'VALUE') => {
if (val === 'NULL') {
updateFieldValue(rowIndex, 'defaultValue', null)
} else if (val === 'EMPTY') {
updateFieldValue(rowIndex, 'defaultValue', '')
} else if (val === 'VALUE') {
if (currentType === 'NULL' || currentType === 'EMPTY') {
updateFieldValue(rowIndex, 'defaultValue', '')
}
}
},
size: 'mini',
style: { width: '70px', flexShrink: 0 },
options: [
{ label: 'NULL', value: 'NULL' },
{ label: "''", value: 'EMPTY' },
{ label: '值', value: 'VALUE' }
]
}),
currentType === 'NULL' ? null : h(Input, {
modelValue: currentType === 'EMPTY' ? '' : String(defaultValue || ''),
'onUpdate:modelValue': (val: string) => {
if (currentType === 'EMPTY') {
if (val !== '') {
updateFieldValue(rowIndex, 'defaultValue', val)
}
} else {
updateFieldValue(rowIndex, 'defaultValue', val)
}
},
size: 'mini',
placeholder: currentType === 'EMPTY' ? "空字符串(不可编辑)" : '输入默认值',
style: { flex: 1 },
disabled: currentType === 'EMPTY'
})
])
}
},
{
title: '主键',
dataIndex: 'primaryKey',
width: 80,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
return h(Checkbox, {
modelValue: record.primaryKey || false,
'onUpdate:modelValue': (checked: boolean) => {
updateFieldValue(rowIndex, 'primaryKey', checked)
// 如果取消主键,同时取消自增
if (!checked && record.autoIncrement) {
updateFieldValue(rowIndex, 'autoIncrement', false)
}
},
style: { display: 'flex', justifyContent: 'center' }
})
}
},
{
title: '自增',
dataIndex: 'autoIncrement',
width: 80,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
const isIntegerType = ['TINYINT', 'SMALLINT', 'MEDIUMINT', 'INT', 'BIGINT'].includes(record.type?.toUpperCase())
return h(Checkbox, {
modelValue: record.autoIncrement || false,
disabled: !isIntegerType,
'onUpdate:modelValue': (checked: boolean) => {
if (checked && !record.primaryKey) {
Message.warning('自增字段必须设置为主键')
updateFieldValue(rowIndex, 'primaryKey', true)
}
updateFieldValue(rowIndex, 'autoIncrement', checked)
},
style: { display: 'flex', justifyContent: 'center' }
})
}
},
{
title: '注释',
dataIndex: 'comment',
width: 200,
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
return h(Input, {
modelValue: record.comment || '',
'onUpdate:modelValue': (val: string) => updateFieldValue(rowIndex, 'comment', val),
size: 'mini',
placeholder: '字段注释',
style: { width: '100%' }
})
}
},
{
title: '操作',
width: 120,
fixed: 'right',
render: ({ rowIndex }: { rowIndex: number }) => {
const totalRows = props.fields.length
return h('div', { style: { display: 'flex', gap: '4px', alignItems: 'center' } }, [
h(Button, {
type: 'text',
size: 'mini',
disabled: rowIndex === 0,
onClick: () => handleMoveField(rowIndex, 'up'),
style: { padding: '0 4px' }
}, {
default: () => h(IconUp, { style: { fontSize: '12px' } })
}),
h(Button, {
type: 'text',
size: 'mini',
disabled: rowIndex === totalRows - 1,
onClick: () => handleMoveField(rowIndex, 'down'),
style: { padding: '0 4px' }
}, {
default: () => h(IconDown, { style: { fontSize: '12px' } })
}),
h(Button, {
type: 'text',
size: 'mini',
status: 'danger',
onClick: () => handleRemoveField(rowIndex),
style: { padding: '0 4px' }
}, {
default: () => h(IconDelete, { style: { fontSize: '12px' } })
})
])
}
}
])
// 编辑模式:表格列定义(需要从 ResultPanel 中提取相关逻辑)
// 这里先简化,后续可以完善
const editModeColumns = computed(() => {
// TODO: 从 ResultPanel 中提取可编辑列定义
// 暂时返回基本列
return [
{ title: '字段名', dataIndex: 'Field', width: 150 },
{ title: '类型', dataIndex: 'Type', width: 200 },
{ title: '允许NULL', dataIndex: 'Null', width: 100 },
{ title: '默认值', dataIndex: 'Default', width: 150 },
{ title: '注释', dataIndex: 'Comment', width: 200 }
]
})
// 创建模式:添加字段
const handleAddField = () => {
const newField = {
name: '',
type: 'VARCHAR',
length: 50,
nullable: true,
defaultValue: null,
primaryKey: false,
autoIncrement: false,
comment: ''
}
emit('add-field', newField)
}
const handleRemoveField = (index: number) => {
emit('remove-field', index)
}
const handleMoveField = (index: number, direction: 'up' | 'down') => {
emit('move-field', index, direction)
}
</script>
<style scoped>
.mysql-field-list {
width: 100%;
}
.section-header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 12px;
flex-shrink: 0;
}
.empty-tip {
padding: 20px;
text-align: center;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -1,247 +0,0 @@
<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

@@ -1,330 +0,0 @@
<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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,267 +0,0 @@
<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,534 +0,0 @@
<template>
<div class="sql-editor-wrapper">
<!-- 增强工具栏 -->
<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>
</div>
</template>
<script setup>
import {nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
import {Message} from '@arco-design/web-vue'
import {
EditorView, keymap, lineNumbers,
EditorState,
defaultKeymap, history, historyKeymap,
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', 'export'])
// 常量配置
const STORAGE_KEY_EDITOR_CONTENT = 'db-cli:editor-content'
// 标签页持久化
const tabPersistence = useTabPersistence()
// 数据库类型配置
const DB_CONFIG = {
mysql: {
language: async () => (await import('@codemirror/lang-sql')).sql(),
defaultContent: 'select 1;',
executeText: '执行'
},
redis: {
language: async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: false, typescript: false }),
defaultContent: 'GET key\nSET key value\nHGET hash field',
executeText: '执行命令'
},
mongo: {
language: async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: false, typescript: false }),
defaultContent: 'db.collection.find({})\n// 示例db.users.find({name: "John"})',
executeText: '执行查询'
},
mongodb: {
language: async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: false, typescript: false }),
defaultContent: 'db.collection.find({})\n// 示例db.users.find({name: "John"})',
executeText: '执行查询'
}
}
// ==================== 工具函数 ====================
const getDbType = () => props.currentConnection?.type?.toLowerCase() || 'mysql'
const getDbConfig = (dbType = null) => DB_CONFIG[dbType || getDbType()] || DB_CONFIG.mysql
const getLanguageMode = async (dbType = null) => getDbConfig(dbType).language()
const getDefaultContent = (dbType = null) => getDbConfig(dbType).defaultContent
const getExecuteButtonText = () => getDbConfig().executeText
// ==================== 编辑器管理 ====================
const editorContainerRef = ref(null)
const toolbarRef = ref(null)
let editorView = null
let saveTimer = null
const lastExecutionTime = ref(null)
// 创建编辑器扩展
const createEditorExtensions = async () => {
const dbType = getDbType()
const languageMode = await getLanguageMode(dbType)
return [
EditorState.lineSeparator.of('\n'),
lineNumbers(),
history(),
languageMode,
syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const content = update.state.doc.toString()
localStorage.setItem(STORAGE_KEY_EDITOR_CONTENT, content)
}
}),
keymap.of([
...defaultKeymap,
...historyKeymap,
{
key: 'Mod-Enter',
run: () => {
handleExecuteSelected()
return true
}
},
{
key: 'F5',
run: () => {
handleExecute()
return true
}
}
]),
EditorView.theme({
'&': {
fontSize: '13px',
fontFamily: "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'Courier New', monospace",
height: '100%',
backgroundColor: 'var(--color-bg-1)',
color: 'var(--color-text-1)'
},
'.cm-content': {
padding: '12px',
backgroundColor: 'var(--color-bg-1)',
color: 'var(--color-text-1)',
caretColor: 'var(--color-text-1)'
},
'.cm-editor': {
height: '100%',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
backgroundColor: 'var(--color-bg-1)'
},
'&.cm-focused': { outline: 'none' },
'&.cm-focused .cm-cursor': {
borderLeftColor: 'var(--color-text-1)',
borderLeftWidth: '2px'
},
'&.cm-focused .cm-cursor-primary': {
borderLeftColor: 'var(--color-text-1)',
borderLeftWidth: '2px'
},
'.cm-scroller': {
overflow: 'auto',
flex: 1,
minHeight: 0,
maxHeight: '100%',
backgroundColor: 'var(--color-bg-1)'
},
'.cm-gutters': {
backgroundColor: 'var(--color-bg-2)',
border: 'none',
color: 'var(--color-text-3)'
},
'.cm-lineNumbers': { color: 'var(--color-text-3)' },
'.cm-line': { color: 'var(--color-text-1)' },
'.cm-activeLine': { backgroundColor: 'var(--color-fill-2)' },
'.cm-selectionMatch': { backgroundColor: 'var(--color-primary-light-4)' },
'.cm-dropCursor': {
borderLeftColor: 'var(--color-text-1)',
borderLeftWidth: '2px'
}
}, { dark: false }),
EditorView.lineWrapping,
EditorView.contentAttributes.of({contenteditable: 'true'}),
]
}
// 初始化编辑器
const initEditor = async () => {
if (!editorContainerRef.value) return false
// 销毁旧编辑器
if (editorView) {
editorView.destroy()
editorView = null
}
await nextTick()
await new Promise(resolve => requestAnimationFrame(resolve))
const container = editorContainerRef.value
if (!container) return false
const savedContent = localStorage.getItem(STORAGE_KEY_EDITOR_CONTENT)
const initialContent = savedContent || getDefaultContent()
const state = EditorState.create({
doc: initialContent,
extensions: await createEditorExtensions()
})
editorView = new EditorView({
state,
parent: container
})
return true
}
// 获取编辑器实例
const getEditor = () => editorView
// ==================== 事件处理 ====================
const validateEditor = () => {
if (!props.currentConnection) {
Message.warning('请先选择数据库连接')
return null
}
if (!editorView) {
Message.warning('编辑器未初始化')
return null
}
return editorView
}
const handleExecute = (contentOverride = null) => {
const editor = validateEditor()
if (!editor) return
const content = contentOverride || editor.state.doc.toString().trim()
if (!content) {
Message.warning('SQL 语句不能为空')
return
}
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 = () => {
const editor = validateEditor()
if (!editor) return
const selection = editor.state.selection.main
if (!selection || selection.empty) {
Message.warning('请先选中要执行的 SQL 语句')
return
}
const content = editor.state.doc.sliceString(selection.from, selection.to).trim()
if (!content) {
Message.warning('选中的内容为空')
return
}
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) => {
const editor = getEditor()
if (!editor) {
await nextTick()
await new Promise(resolve => requestAnimationFrame(resolve))
const retryEditor = getEditor()
if (!retryEditor) {
await initEditor()
const newEditor = getEditor()
if (newEditor) {
insertSQL(sql)
}
return
}
insertSQL(sql)
return
}
const transaction = editor.state.update({
changes: {
from: 0,
to: editor.state.doc.length,
insert: sql
}
})
editor.dispatch(transaction)
editor.focus()
}
// ==================== 监听器 ====================
watch(() => props.currentConnection, async (newConn, oldConn) => {
// 只有数据库类型改变时才重新初始化编辑器
if (oldConn && newConn && oldConn.type !== newConn.type) {
const currentContent = editorView ? editorView.state.doc.toString() : ''
await initEditor()
// 恢复内容
if (editorView && currentContent) {
const transaction = editorView.state.update({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: currentContent
}
})
editorView.dispatch(transaction)
}
}
}, {deep: true})
// ==================== 生命周期 ====================
onBeforeUnmount(() => {
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
}
if (editorView) {
editorView.destroy()
editorView = null
}
})
onMounted(async () => {
await nextTick()
await new Promise(resolve => requestAnimationFrame(resolve))
await new Promise(resolve => setTimeout(resolve, 100))
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,
getTabs: () => [], // 兼容父组件,但不再支持多标签页
/**
* 保存当前编辑器状态为单个标签页
* 可用于应用关闭前保存状态
*/
saveCurrentTab: async () => {
if (!editorView) return null
const content = editorView.state.doc.toString()
const tabData = [{
id: 0, // 新标签页
title: props.currentConnection?.name ? `${props.currentConnection.name} - 查询` : '未命名查询',
content: content,
connectionId: props.currentConnection?.id || null,
order: 0
}]
const success = await tabPersistence.saveTabs(tabData)
return success ? tabData[0] : null
},
/**
* 加载保存的标签页
* 可用于应用启动时恢复状态
*/
loadSavedTabs: async () => {
const savedTabs = await tabPersistence.loadTabs()
if (savedTabs && savedTabs.length > 0) {
// 恢复第一个标签页的内容
const firstTab = savedTabs[0]
if (firstTab.content && editorView) {
const transaction = editorView.state.update({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: firstTab.content
}
})
editorView.dispatch(transaction)
}
}
return savedTabs
}
})
</script>
<style scoped>
.sql-editor-wrapper {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-container {
flex: 1;
min-height: 0;
padding: var(--spacing-md, 12px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.code-editor {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-2);
border-radius: var(--border-radius-medium, 4px);
}
/* CodeMirror 编辑器滚动支持 */
.code-editor :deep(.cm-editor) {
height: 100%;
display: flex;
flex-direction: column;
}
.code-editor :deep(.cm-scroller) {
overflow: auto;
flex: 1;
min-height: 0;
}
.editor-toolbar {
flex-shrink: 0;
padding: var(--spacing-sm, 8px) var(--spacing-md, 12px);
border-bottom: 1px solid var(--color-border-2);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-md, 12px);
background: var(--color-bg-1);
z-index: 10;
position: relative;
}
.connection-info {
font-size: var(--font-size-xs, 12px);
font-family: var(--font-family-mono, monospace);
color: var(--color-text-1);
}
.database-name {
margin-left: var(--spacing-sm, 8px);
color: var(--color-text-2);
}
.connection-info-empty {
font-size: var(--font-size-xs, 12px);
color: var(--color-text-3);
font-style: italic;
}
</style>

View File

@@ -1,185 +0,0 @@
<template>
<div class="sql-preview-dialog">
<div class="sql-preview-header">
<span class="sql-preview-title">将执行 {{ statements.length }} {{ dbType === 'mysql' ? 'SQL' : 'MongoDB' }}语句</span>
<a-button type="text" size="small" @click="handleCopy">
<template #icon>
<icon-copy />
</template>
复制
</a-button>
</div>
<div class="sql-preview-content" ref="editorContainerRef"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconCopy } from '@arco-design/web-vue/es/icon'
import {
EditorView, lineNumbers,
EditorState,
defaultHighlightStyle, syntaxHighlighting
} from '@/utils/codemirrorExports'
interface Props {
statements: string[]
dbType?: 'mysql' | 'mongo' | 'redis'
}
const props = withDefaults(defineProps<Props>(), {
dbType: 'mysql'
})
const editorContainerRef = ref<HTMLElement | null>(null)
let editorView: EditorView | null = null
// 格式化 SQL 语句(添加分号,分离编号)
const formatStatements = (statements: string[]): string => {
return statements.map((stmt, index) => {
// 确保语句末尾有分号
const trimmedStmt = stmt.trim()
const sql = trimmedStmt.endsWith(';') ? trimmedStmt : trimmedStmt + ';'
return sql
}).join('\n\n')
}
// 获取所有 SQL用于复制
const getAllSQL = (): string => {
return formatStatements(props.statements)
}
// 初始化编辑器
const initEditor = async () => {
if (!editorContainerRef.value) return
// 销毁旧编辑器
if (editorView) {
editorView.destroy()
editorView = null
}
await nextTick()
const sqlText = formatStatements(props.statements)
// 检测是否为暗色主题
const isDark = document.body.hasAttribute('arco-theme')
const { sql } = await import('@codemirror/lang-sql')
const state = EditorState.create({
doc: sqlText,
extensions: [
lineNumbers(),
sql(),
syntaxHighlighting(defaultHighlightStyle),
EditorView.theme({
'&': {
fontSize: '13px',
fontFamily: "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace",
height: '100%'
},
'.cm-content': {
padding: '12px',
backgroundColor: 'var(--color-bg-1)',
color: 'var(--color-text-1)'
},
'.cm-editor': {
height: '100%',
backgroundColor: 'var(--color-bg-1)'
},
'.cm-scroller': {
overflow: 'auto'
},
'.cm-gutters': {
backgroundColor: 'var(--color-bg-2)',
border: 'none',
color: 'var(--color-text-3)'
},
'.cm-lineNumbers': {
color: 'var(--color-text-3)'
},
'&.cm-focused': {
outline: 'none'
}
}, { dark: isDark }),
EditorView.editable.of(false), // 只读
EditorView.lineWrapping
]
})
editorView = new EditorView({
state,
parent: editorContainerRef.value
})
}
// 复制 SQL
const handleCopy = async () => {
try {
const sqlText = getAllSQL()
await navigator.clipboard.writeText(sqlText)
Message.success('SQL已复制到剪贴板')
} catch (error) {
Message.error('复制失败')
}
}
watch(() => props.statements, () => {
initEditor()
}, { deep: true })
onMounted(() => {
nextTick(() => {
initEditor()
})
})
onBeforeUnmount(() => {
if (editorView) {
editorView.destroy()
editorView = null
}
})
</script>
<style scoped>
.sql-preview-dialog {
width: 100%;
display: flex;
flex-direction: column;
}
.sql-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
color: var(--color-text-2);
}
.sql-preview-title {
font-size: 14px;
}
.sql-preview-content {
flex: 1;
min-height: 200px;
max-height: 400px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
overflow: hidden;
background: var(--color-bg-1);
}
.sql-preview-content :deep(.cm-editor) {
height: 100%;
}
.sql-preview-content :deep(.cm-scroller) {
overflow: auto;
height: 100%;
}
</style>

View File

@@ -1,39 +0,0 @@
<template>
<div class="messages-content">
<div v-for="(msg, index) in messages" :key="index" class="message-item">
<a-tag :color="msg.type === 'error' ? 'red' : 'blue'">{{ msg.time }}</a-tag>
{{ msg.content }}
</div>
<div v-if="messages.length === 0" class="messages-empty">
<a-empty description="暂无消息"/>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
messages: Array<{ type?: string; time: string; content: string }>
}>()
</script>
<style scoped>
.messages-content {
flex: 1;
padding: var(--spacing-md, 12px);
overflow-y: auto;
min-height: 0;
}
.messages-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.message-item {
margin-bottom: var(--spacing-sm, 8px);
font-size: var(--font-size-xs, 12px);
color: var(--color-text-1);
}
</style>

View File

@@ -1,77 +0,0 @@
# Result 组件重构
## 目录结构
```
result/
├── ResultTab.vue # 结果标签页容器(组合 Stats + Table/Json
├── ResultStats.vue # 统计信息栏
├── ResultTable.vue # 表格视图(含分页)
├── ResultJson.vue # JSON 视图
├── MessageLog.vue # 消息日志
├── types.ts # 类型定义
└── index.ts # 导出
```
## 组件职责
### ResultTab.vue
- 组合 ResultStats、ResultTable、ResultJson
- 管理视图模式切换(表格/JSON
- 处理加载和错误状态
### ResultStats.vue
- 显示行数、执行时间
- 视图模式切换按钮
### ResultTable.vue
- 表格展示
- 分页控制
- 高度自适应
- 单元格格式化和提示
### ResultJson.vue
- JSON 格式展示
- 语法高亮
### MessageLog.vue
- 消息列表展示
- 消息类型标识
## 使用示例
```vue
<template>
<ResultTab
:loading="loading"
:error="error"
:data="resultData"
:stats="stats"
:columns="columns"
:page="currentPage"
@re-execute-sql="handleReExecute"
/>
</template>
<script setup>
import { ResultTab } from './result'
</script>
```
## 迁移计划
### 阶段 1测试新组件
- 在 ResultPanel.vue 中引入并测试 ResultTab
- 验证功能完整性
### 阶段 2替换旧代码
- 用 ResultTab 替换 ResultPanel.vue 中的结果展示部分
- 用 MessageLog 替换消息日志部分
### 阶段 3拆分其他功能
- 将表结构相关功能拆分为 StructureTab 组件
- 将查询历史拆分为 QueryHistory 组件
### 阶段 4简化 ResultPanel.vue
- ResultPanel.vue 变成轻量的标签页容器
- 只负责标签切换和状态管理

View File

@@ -1,68 +0,0 @@
<template>
<div class="result-json-container">
<pre class="result-json" v-html="highlightedJson"></pre>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { escapeHtml } from '@/utils/fileUtils'
const props = defineProps<{
data: any[]
}>()
// JSON 高亮
const highlightedJson = computed(() => {
const json = JSON.stringify(props.data, null, 2)
if (!json) return ''
return escapeHtml(json)
.replace(/: ("(?:[^"\\]|\\.)*")/g, ': <span class="json-string">$1</span>')
.replace(/: (-?\d+\.?\d*(?:e[+-]?\d+)?)/gi, ': <span class="json-number">$1</span>')
.replace(/: (true|false|null)/g, ': <span class="json-boolean">$1</span>')
.replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:')
})
</script>
<style scoped>
.result-json-container {
flex: 1;
overflow: auto;
min-height: 0;
padding: var(--spacing-sm, 8px);
}
.result-json {
margin: 0;
padding: var(--spacing-md, 16px);
border-radius: var(--border-radius-medium, 6px);
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
background: linear-gradient(135deg, var(--color-bg-3) 0%, var(--color-bg-2) 100%);
color: var(--color-text-2);
border: 1px solid var(--color-border-2);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
}
.result-json :deep(.json-key) {
color: rgb(var(--arcoblue-6, 22, 93, 255));
font-weight: 500;
}
.result-json :deep(.json-string) {
color: rgb(var(--green-6, 0, 180, 42));
}
.result-json :deep(.json-number) {
color: rgb(var(--orange-6, 255, 125, 0));
}
.result-json :deep(.json-boolean) {
color: rgb(var(--purple-6, 114, 46, 209));
font-weight: 500;
}
</style>

View File

@@ -1,41 +0,0 @@
<template>
<div class="result-stats">
<a-space>
<span>{{ rowsLabel }}: {{ rowsAffected }}</span>
<a-divider type="vertical"/>
<span>执行时间: {{ executionTime }}ms</span>
<a-divider type="vertical"/>
<a-radio-group :model-value="viewMode" type="button" size="mini" @update:model-value="$emit('update:viewMode', $event)">
<a-radio value="table">表格</a-radio>
<a-radio value="json">JSON</a-radio>
</a-radio-group>
</a-space>
</div>
</template>
<script setup lang="ts">
defineProps<{
rowsLabel: string
rowsAffected: number
executionTime: number
viewMode: 'table' | 'json'
}>()
defineEmits<{
'update:viewMode': [mode: 'table' | 'json']
}>()
</script>
<style scoped>
.result-stats {
flex-shrink: 0;
margin-bottom: var(--spacing-xs, 4px);
padding: var(--spacing-xs, 4px) var(--spacing-md, 12px);
font-size: var(--font-size-xs, 12px);
color: var(--color-text-1);
}
.result-stats span {
color: var(--color-text-1);
}
</style>

View File

@@ -1,126 +0,0 @@
<template>
<div class="result-content">
<div v-if="loading" class="result-loading">
<a-spin/>
<span>执行中...</span>
</div>
<div v-else-if="error">
<a-alert type="error" show-icon>
{{ error }}
</a-alert>
</div>
<div v-else-if="data !== null" class="result-data-wrapper">
<ResultStats
v-if="stats"
:rows-label="rowsLabel"
:rows-affected="stats.rowsAffected"
:execution-time="stats.executionTime"
:view-mode="viewMode"
@update:viewMode="viewMode = $event"
/>
<ResultTable
v-if="viewMode === 'table' && data.length > 0"
:columns="tableColumns"
:data="pagedData"
:loading="loading"
:page="page"
:can-go-next="canGoNext"
@page-change="$emit('re-execute-sql', { page: $event, pageSize: 10 })"
/>
<div v-else-if="viewMode === 'table' && data.length === 0" class="result-empty-table">
<a-empty description="查询结果为空" :image="false"/>
</div>
<ResultJson v-else-if="viewMode === 'json'" :data="data" />
</div>
<div v-else class="result-empty">
<a-empty description="暂无执行结果"/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h, type ComputedRef } from 'vue'
import { Tooltip } from '@arco-design/web-vue'
import ResultStats from './ResultStats.vue'
import ResultTable from './ResultTable.vue'
import ResultJson from './ResultJson.vue'
import type { TableColumn } from './types'
const props = defineProps<{
loading: boolean
error: string
data: any[] | null
stats?: { rowsAffected: number; executionTime: number }
columns: string[]
page: number
}>()
defineEmits<{
're-execute-sql': [params: { page: number; pageSize: number }]
}>()
const viewMode = ref<'table' | 'json'>('table')
const rowsLabel = computed(() => {
if (!props.data || props.data.length === 0) return '影响行数'
return '返回行数'
})
// 表格列定义
const tableColumns: ComputedRef<TableColumn[]> = computed(() => {
if (props.columns?.length > 0) {
return props.columns.map(key => ({
title: key,
dataIndex: key,
width: 150
}))
}
if (!props.data?.length) return []
const firstRow = props.data[0] as Record<string, any>
return Object.keys(firstRow).map(key => ({
title: key,
dataIndex: key,
width: 150
}))
})
const pagedData = computed(() => props.data || [])
const canGoNext = computed(() => {
if (!props.data || props.data.length === 0) return false
return props.data.length >= 10
})
</script>
<style scoped>
.result-content {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
padding: var(--spacing-md, 12px);
overflow: hidden;
min-height: 0;
}
.result-loading,
.result-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.result-data-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.result-empty-table {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
</style>

View File

@@ -1,227 +0,0 @@
<template>
<div class="result-table-container" ref="containerRef">
<a-table
:columns="columns"
:data="data"
:pagination="false"
:loading="loading"
size="mini"
:scroll="{ x: 'max-content', y: tableScrollHeight }"
:bordered="true"
class="result-table"
column-resizable
/>
<div class="custom-pagination">
<a-space>
<a-button
size="small"
:disabled="page <= 1 || loading"
@click="$emit('page-change', page - 1)"
>
上一页
</a-button>
<span style="color: var(--color-text-3); font-size: 12px;">
{{ page }} {{ data.length }}
<span v-if="!canGoNext && !loading" style="color: var(--color-text-4);">已到最后一页</span>
</span>
<a-button
size="small"
:disabled="!canGoNext || loading"
@click="$emit('page-change', page + 1)"
>
下一页
</a-button>
</a-space>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted, h } from 'vue'
import { Tooltip } from '@arco-design/web-vue'
import type { TableColumn } from './types'
const props = defineProps<{
columns: TableColumn[]
data: any[]
loading: boolean
page: number
canGoNext: boolean
}>()
const emit = defineEmits<{
'page-change': [page: number]
}>()
const containerRef = ref<HTMLElement | null>(null)
const tableScrollHeight = ref(400)
// 格式化单元格值
const formatCellValue = (value: unknown): string => {
if (value === null) return 'null'
if (value === undefined) return ''
if (typeof value === 'object') {
return JSON.stringify(value)
}
return String(value)
}
// 渲染表格列
const renderedColumns = computed(() => {
return props.columns.map(col => ({
...col,
render: ({ record }: { record: Record<string, unknown> }) => {
const value = record[col.dataIndex]
const formattedValue = formatCellValue(value)
if (value !== null && typeof value === 'object') {
const jsonStr = JSON.stringify(value, null, 2)
return h(Tooltip, { content: jsonStr }, {
default: () => h('span', { class: 'cell-json cell-content' }, formattedValue)
})
}
return h(Tooltip, {
content: formattedValue,
disabled: !formattedValue
}, {
default: () => h('span', { class: 'cell-content' }, formattedValue)
})
}
}))
})
// 更新表格高度
const updateTableHeight = () => {
setTimeout(() => {
if (!containerRef.value) return
const container = containerRef.value
const containerHeight = container.offsetHeight
const paginationEl = container.querySelector('.custom-pagination') as HTMLElement
const paginationHeight = paginationEl ? paginationEl.offsetHeight : 40
const tableHeaderEl = container.querySelector('.arco-table-header') as HTMLElement
const tableHeaderHeight = tableHeaderEl ? tableHeaderEl.offsetHeight : 40
const availableHeight = containerHeight - paginationHeight - tableHeaderHeight - 8
tableScrollHeight.value = Math.max(100, availableHeight > 0 ? availableHeight : 400)
}, 150)
}
// 监听数据变化
watch(() => props.data, () => {
nextTick(updateTableHeight)
})
// 窗口调整
let resizeTimer: ReturnType<typeof setTimeout> | null = null
const handleResize = () => {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(updateTableHeight, 100)
}
onMounted(() => {
nextTick(updateTableHeight)
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
defineExpose({
updateHeight: updateTableHeight
})
</script>
<style scoped>
.result-table-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.result-table-container :deep(.arco-table) {
display: block;
overflow: hidden;
}
.result-table-container :deep(.arco-table-body) {
overflow-y: auto !important;
overflow-x: auto !important;
}
.custom-pagination {
flex-shrink: 0;
padding: var(--spacing-sm, 8px);
border-top: 1px solid var(--color-border-2);
display: flex;
justify-content: center;
align-items: center;
}
.result-table-container :deep(.result-table) {
font-size: var(--font-size-xs, 12px);
}
.result-table-container :deep(.result-table .arco-table-th) {
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-table-container :deep(.result-table .arco-table-td) {
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
max-width: 0;
}
.result-table-container :deep(.result-table .arco-table-td .cell-content),
.result-table-container :deep(.result-table .arco-table-td .cell-json) {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.result-table-container :deep(.result-table .arco-table-tr) {
height: 28px;
}
.result-table-container :deep(.result-table .arco-table-tbody .arco-table-tr) {
height: 28px;
}
.result-table-container :deep(.cell-json) {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 11px;
color: var(--color-text-3);
background: var(--color-fill-2);
padding: 2px 6px;
border-radius: 3px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
cursor: help;
}
.result-table-container :deep(.cell-json:hover) {
background: var(--color-fill-3);
color: var(--color-text-2);
}
.result-table-container :deep(.cell-content) {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -1,10 +0,0 @@
/**
* 结果展示组件导出
*/
export { default as ResultTab } from './ResultTab.vue'
export { default as ResultStats } from './ResultStats.vue'
export { default as ResultTable } from './ResultTable.vue'
export { default as ResultJson } from './ResultJson.vue'
export { default as MessageLog } from './MessageLog.vue'
export * from './types'

View File

@@ -1,21 +0,0 @@
/**
* 结果展示组件类型定义
*/
export interface TableColumn {
title: string
dataIndex: string
width?: number
render?: (params: { record: Record<string, unknown> }) => unknown
}
export interface ResultStats {
rowsAffected: number
executionTime: number
}
export interface Message {
type?: string
time: string
content: string
}

View File

@@ -1,118 +0,0 @@
# 架构迁移指南
## 新架构:事件驱动 + 单例 Store
### 核心改进
1. **事件总线 (`useEventBus.ts`)**
- 解耦组件通信
- 提供可追踪的事件流
- 支持类型安全的事件定义
2. **单例 Store (`useStructureStore.ts`)**
- 全局共享状态
- 统一状态管理
- 自动事件通知
3. **调试友好**
- 所有状态变化都有日志
- 事件触发可追踪
- 清晰的数据流
### 迁移步骤
#### 1. 旧方式(问题多多)
```ts
// ❌ 问题:状态分散,难以追踪
const structureState = useStructureState()
const { structureData, loadStructure } = structureState
// ❌ 问题:响应式传递复杂,容易丢失
<ResultPanel :structure-data="computedStructureData" />
// ❌ 问题:调试困难,不知道数据在哪里丢失
console.log('structureData:', structureData.value)
```
#### 2. 新方式(事件驱动)
```ts
// ✅ 优点:单例,全局共享
const structureStore = useStructureStore()
// ✅ 优点:直接访问,无需计算属性
<ResultPanel :structure-data="structureStore.data" />
// ✅ 优点:事件可追踪
structureStore.on('structure:data', ({ data, info }) => {
console.log('收到结构数据:', data, info)
})
```
#### 3. 组件中使用
```vue
<script setup>
import { useStructureStore } from '../composables/useStructureStore'
const store = useStructureStore()
// 直接使用 store 的状态
console.log('当前数据:', store.data.value)
console.log('当前信息:', store.info.value)
console.log('加载状态:', store.loading.value)
// 订阅事件变化(可选)
store.eventBus.on('structure:data', ({ data }) => {
console.log('数据已更新:', data)
})
</script>
<template>
<!-- 直接传递 store无需计算属性 -->
<ResultPanel
:structure-data="store.data"
:structure-info="store.info"
:structure-loading="store.loading"
:structure-error="store.error"
/>
</template>
```
### 对比
| 特性 | 旧方式 | 新方式 |
|------|--------------------------|--------------------------|
| 状态共享 | Composable 实例 | 单例 Store |
| 组件通信 | props/emit | 事件总线 |
| 响应式传递 | computed + props | 直接访问 ref |
| 调试 | 困难,日志分散 | 清晰,所有变化有日志 |
| 类型安全 | 部分 | 完全类型安全 |
| 可追踪性 | 低 | 高(事件流) |
| 解耦 | 低(依赖 props | 高(事件驱动) |
### 优势
1. **确定性**:单例确保全局只有一个实例,状态不会丢失
2. **可追踪**:所有状态变化都有日志,事件流清晰
3. **可调试**:事件总线提供完整的通信链路
4. **解耦**:组件通过事件通信,不依赖具体实现
5. **类型安全**:事件和状态都有完整的类型定义
### 适用场景
- ✅ 跨组件状态共享
- ✅ 复杂状态管理
- ✅ 需要调试的状态
- ✅ 频繁更新的状态
- ❌ 简单的本地状态(无需事件总线)
### 后续改进
1. 添加状态持久化localStorage
2. 添加状态回滚/撤销
3. 添加状态快照
4. 添加状态变更中间件
**时间:** 2026-01-03

View File

@@ -1,116 +0,0 @@
import { ref } from 'vue'
import type { Component } from 'vue'
import type { MenuItem } from '../components/ContextMenu.vue'
/**
* 右键菜单状态管理 Composable
*/
export function useContextMenu() {
const menuVisible = ref(false)
const menuPosition = ref({ x: 0, y: 0 })
const menuItems = ref<MenuItem[]>([])
const currentNodeData = ref<any>(null)
/**
* 显示菜单
*/
const showMenu = (event: MouseEvent, nodeData: any, items: MenuItem[]) => {
event.preventDefault()
event.stopPropagation()
menuPosition.value = {
x: event.clientX,
y: event.clientY
}
menuItems.value = items
currentNodeData.value = nodeData
menuVisible.value = true
}
/**
* 隐藏菜单
*/
const hideMenu = () => {
menuVisible.value = false
menuItems.value = []
currentNodeData.value = null
}
/**
* 处理菜单项点击
*/
const handleMenuItemClick = (item: MenuItem, emit: (event: string, data: any) => void) => {
if (item.disabled || !currentNodeData.value) return
// 根据菜单项key触发相应事件
switch (item.key) {
case 'view-structure':
emit('table-structure', {
connectionId: currentNodeData.value.connectionId,
database: currentNodeData.value.database || '',
tableName: currentNodeData.value.tableName || currentNodeData.value.keyName || currentNodeData.value.title || '',
dbType: currentNodeData.value.dbType || 'mysql',
nodeType: currentNodeData.value.type
})
break
case 'edit':
emit('connection-edit', {
connectionId: currentNodeData.value.connectionId
})
break
case 'delete':
emit('connection-delete', {
connectionId: currentNodeData.value.connectionId
})
break
case 'generate-sql':
emit('table-select', {
connectionId: currentNodeData.value.connectionId,
database: currentNodeData.value.database || '',
tableName: currentNodeData.value.tableName || currentNodeData.value.keyName || currentNodeData.value.title || '',
dbType: currentNodeData.value.dbType || 'mysql'
})
break
case 'copy-name':
// 复制名称到剪贴板
const name = currentNodeData.value.tableName || currentNodeData.value.keyName || currentNodeData.value.title || ''
navigator.clipboard.writeText(name)
break
case 'refresh':
// 刷新节点(通过重新加载实现)
emit('connection-refresh', {
connectionId: currentNodeData.value.connectionId,
nodeType: currentNodeData.value.type,
database: currentNodeData.value.database
})
break
case 'test':
// 测试连接
emit('connection-test', {
connectionId: currentNodeData.value.connectionId
})
break
case 'create-table':
// 创建表/集合/Key
emit('create-table', {
connectionId: currentNodeData.value.connectionId,
database: currentNodeData.value.database || '',
dbType: currentNodeData.value.dbType || 'mysql'
})
break
}
hideMenu()
}
return {
menuVisible,
menuPosition,
menuItems,
currentNodeData,
showMenu,
hideMenu,
handleMenuItemClick
}
}

View File

@@ -1,36 +0,0 @@
import { ref } from 'vue'
export function useCreateState() {
const createLoading = ref(false)
const createError = ref('')
const createInfo = ref<{
connectionId: number
database: string
dbType: 'mysql' | 'mongo' | 'redis'
} | null>(null)
const startCreate = (connectionId: number, database: string, dbType: 'mysql' | 'mongo' | 'redis') => {
createInfo.value = { connectionId, database, dbType }
createError.value = ''
}
const cancelCreate = () => {
createInfo.value = null
createError.value = ''
}
const clearCreate = () => {
createInfo.value = null
createError.value = ''
createLoading.value = false
}
return {
createLoading,
createError,
createInfo,
startCreate,
cancelCreate,
clearCreate
}
}

View File

@@ -1,62 +0,0 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { STORAGE_KEYS } from '../constants/storage'
/**
* 数据库连接管理 Composable
*/
export function useDbConnection() {
const currentConnection = ref<any>(null)
const selectedDatabase = ref('')
const showConnectionForm = ref(false)
const editingConnectionId = ref<number | null>(null)
const selectConnection = (conn: any, database?: string) => {
if (!conn?.id) return
currentConnection.value = conn
const dbName = database ?? conn.database ?? ''
selectedDatabase.value = dbName
localStorage.setItem(STORAGE_KEYS.CURRENT_CONNECTION, String(conn.id))
localStorage[dbName ? 'setItem' : 'removeItem'](STORAGE_KEYS.SELECTED_DATABASE, dbName)
}
const editConnection = (connectionId: number) => {
editingConnectionId.value = connectionId
showConnectionForm.value = true
}
const deleteConnection = (connectionId: number): boolean => {
const isCurrent = currentConnection.value?.id === connectionId
if (isCurrent) {
currentConnection.value = null
selectedDatabase.value = ''
}
return isCurrent
}
const newConnection = () => {
editingConnectionId.value = null
showConnectionForm.value = true
}
const onConnectionSuccess = (editedId: number | null) => {
showConnectionForm.value = false
editingConnectionId.value = null
if (editedId && currentConnection.value?.id === editedId) {
Message.info('连接已更新,请重新选择连接')
}
}
return {
currentConnection,
selectedDatabase,
showConnectionForm,
editingConnectionId,
selectConnection,
editConnection,
deleteConnection,
newConnection,
onConnectionSuccess
}
}

View File

@@ -1,19 +0,0 @@
import { ref } from 'vue'
import { STORAGE_KEYS } from '../constants/storage'
/**
* 编辑器状态管理 Composable
*/
export function useEditorState() {
const editorVisible = ref(
localStorage.getItem(STORAGE_KEYS.EDITOR_VISIBLE) !== 'false'
)
const toggleEditor = () => {
editorVisible.value = !editorVisible.value
localStorage.setItem(STORAGE_KEYS.EDITOR_VISIBLE, String(editorVisible.value))
}
return { editorVisible, toggleEditor }
}

View File

@@ -1,81 +0,0 @@
import { type Ref, type UnwrapRef } from 'vue'
export interface DbCliEvents {
'structure:loading': { loading: boolean }
'structure:data': { data: any; info: StructureInfo }
'structure:error': { error: string }
'structure:clear': {}
}
export interface StructureInfo {
connectionId: number
database: string
tableName: string
dbType: 'mysql' | 'mongo' | 'redis'
nodeType: string
}
type EventListener<T> = (payload: T) => void
class EventBus<T extends Record<string, any>> {
private listeners: Map<keyof T, Set<EventListener<any>>> = new Map()
on<K extends keyof T>(event: K, listener: EventListener<UnwrapRef<T[K]>>): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set())
}
this.listeners.get(event)!.add(listener)
return () => {
this.listeners.get(event)?.delete(listener)
}
}
once<K extends keyof T>(event: K, listener: EventListener<UnwrapRef<T[K]>>): () => void {
const onceWrapper: EventListener<any> = (payload) => {
listener(payload)
this.off(event, onceWrapper)
}
return this.on(event, onceWrapper)
}
off<K extends keyof T>(event: K, listener?: EventListener<UnwrapRef<T[K]>>): void {
if (listener) {
this.listeners.get(event)?.delete(listener)
} else {
this.listeners.delete(event)
}
}
emit<K extends keyof T>(event: K, payload: UnwrapRef<T[K]>): void {
this.listeners.get(event)?.forEach(listener => {
try {
listener(payload)
} catch (error) {
console.error(`事件处理错误 [${String(event)}]:`, error)
}
})
}
clear(): void {
this.listeners.clear()
}
}
const eventBus = new EventBus<DbCliEvents>()
export function useEventBus() {
return {
on: <K extends keyof DbCliEvents>(event: K, listener: EventListener<UnwrapRef<DbCliEvents[K]>>) =>
eventBus.on(event, listener),
once: <K extends keyof DbCliEvents>(event: K, listener: EventListener<UnwrapRef<DbCliEvents[K]>>) =>
eventBus.once(event, listener),
off: <K extends keyof DbCliEvents>(event: K, listener?: EventListener<UnwrapRef<DbCliEvents[K]>>) =>
eventBus.off(event, listener),
emit: <K extends keyof DbCliEvents>(event: K, payload: UnwrapRef<DbCliEvents[K]>) =>
eventBus.emit(event, payload)
}
}
export { eventBus }
export type { EventBus }

View File

@@ -1,100 +0,0 @@
import type { Component } from 'vue'
import type { MenuItem } from '../components/ContextMenu.vue'
import { IconEye, IconEdit, IconDelete, IconRefresh, IconCheck, IconCode, IconCopy, IconPlus } from '@arco-design/web-vue/es/icon'
/**
* 菜单项注册表
* 根据节点类型返回对应的菜单项配置
*/
export function useMenuRegistry() {
/**
* 获取连接节点菜单项
*/
const getConnectionMenuItems = (): MenuItem[] => {
return [
{ key: 'view-structure', label: '查看结构', icon: IconEye },
{ key: 'edit', label: '编辑连接', icon: IconEdit },
{ key: 'delete', label: '删除连接', icon: IconDelete, divider: true },
{ key: 'refresh', label: '刷新', icon: IconRefresh },
{ key: 'test', label: '测试连接', icon: IconCheck }
]
}
/**
* 获取数据库节点菜单项
*/
const getDatabaseMenuItems = (dbType: string): MenuItem[] => {
const items: MenuItem[] = []
// 新建表/集合/Key
if (dbType === 'mysql') {
items.push({ key: 'create-table', label: '新建表', icon: IconPlus })
} else if (dbType === 'mongo') {
items.push({ key: 'create-table', label: '新建集合', icon: IconPlus })
} else if (dbType === 'redis') {
items.push({ key: 'create-table', label: '新建Key', icon: IconPlus })
}
items.push({ key: 'view-structure', label: '查看结构', icon: IconEye, divider: true })
if (dbType === 'mysql' || dbType === 'mongo') {
items.push({ key: 'generate-sql', label: dbType === 'mysql' ? '生成SELECT语句' : '生成find语句', icon: IconCode })
} else if (dbType === 'redis') {
items.push({ key: 'generate-sql', label: '生成KEYS命令', icon: IconCode })
}
items.push({ key: 'refresh', label: '刷新', icon: IconRefresh, divider: true })
return items
}
/**
* 获取表节点菜单项
*/
const getTableMenuItems = (dbType: string): MenuItem[] => {
const items: MenuItem[] = [
{ key: 'view-structure', label: '查看结构', icon: IconEye }
]
if (dbType === 'mysql') {
items.push({ key: 'generate-sql', label: '生成SELECT语句', icon: IconCode })
items.push({ key: 'copy-name', label: '复制表名', icon: IconCopy, divider: true })
} else if (dbType === 'mongo') {
items.push({ key: 'generate-sql', label: '生成find语句', icon: IconCode })
items.push({ key: 'copy-name', label: '复制集合名', icon: IconCopy, divider: true })
} else if (dbType === 'redis') {
items.push({ key: 'generate-sql', label: '生成GET命令', icon: IconCode })
items.push({ key: 'copy-name', label: '复制Key名', icon: IconCopy, divider: true })
}
items.push({ key: 'refresh', label: '刷新', icon: IconRefresh })
return items
}
/**
* 根据节点类型获取菜单项
*/
const getMenuItems = (nodeType: string, dbType?: string): MenuItem[] => {
switch (nodeType) {
case 'connection':
return getConnectionMenuItems()
case 'database':
return getDatabaseMenuItems(dbType || 'mysql')
case 'table':
case 'collection':
case 'key':
return getTableMenuItems(dbType || 'mysql')
default:
return []
}
}
return {
getMenuItems,
getConnectionMenuItems,
getDatabaseMenuItems,
getTableMenuItems
}
}

View File

@@ -1,34 +0,0 @@
import { ref } from 'vue'
const MAX_MESSAGES = 100
export interface MessageItem {
type: 'info' | 'success' | 'error' | 'warning'
content: string
time: string
}
/**
* 消息日志管理 Composable
*/
export function useMessageLog() {
const messages = ref<MessageItem[]>([])
const addMessage = (type: MessageItem['type'], content: string) => {
messages.value.unshift({
type,
content,
time: new Date().toLocaleTimeString()
})
if (messages.value.length > MAX_MESSAGES) {
messages.value = messages.value.slice(0, MAX_MESSAGES)
}
}
const clearMessages = () => {
messages.value = []
}
return { messages, addMessage, clearMessages }
}

View File

@@ -1,108 +0,0 @@
/**
* 查询历史管理
* 用于存储和快速重用之前的查询
*/
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

@@ -1,195 +0,0 @@
/**
* 查询模板管理
* 用于保存常用查询模板
*/
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

@@ -1,92 +0,0 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
export interface ResultHistoryItem {
id: number
connection_id: number
database: string
sql: string
type: string
data?: any
columns?: string[]
rows_affected: number
execution_time: number
created_at: string
}
export interface ResultHistorySearchParams {
connectionId?: number
keyword?: string
limit?: number
offset?: number
}
const handleApiError = (error: unknown, action: string): never => {
const errorMsg = error instanceof Error ? error.message : String(error) || '操作失败'
Message.error(`${action}失败: ${errorMsg}`)
throw error
}
export function useResultHistory() {
const loading = ref(false)
const histories = ref<ResultHistoryItem[]>([])
const total = ref(0)
const searchHistory = async (params: ResultHistorySearchParams = {}) => {
if (!(window as any).go?.main?.App?.GetResultHistory) {
throw new Error('Go 后端未就绪')
}
loading.value = true
try {
const result = await (window as any).go.main.App.GetResultHistory(
params.connectionId || null,
params.keyword || '',
params.limit || 20,
params.offset || 0
)
histories.value = result.items || []
total.value = result.total || 0
} catch (error: unknown) {
handleApiError(error, '查询历史记录')
} finally {
loading.value = false
}
}
const getHistoryById = async (id: number): Promise<ResultHistoryItem | null> => {
if (!(window as any).go?.main?.App?.GetResultHistoryByID) {
throw new Error('Go 后端未就绪')
}
try {
const result = await (window as any).go.main.App.GetResultHistoryByID(id)
return result || null
} catch (error: unknown) {
handleApiError(error, '查询历史记录详情')
}
}
const deleteHistory = async (id: number): Promise<boolean> => {
if (!(window as any).go?.main?.App?.DeleteResultHistory) {
throw new Error('Go 后端未就绪')
}
try {
await (window as any).go.main.App.DeleteResultHistory(id)
Message.success('删除成功')
return true
} catch (error: unknown) {
handleApiError(error, '删除历史记录')
}
}
return {
loading,
histories,
total,
searchHistory,
getHistoryById,
deleteHistory
}
}

View File

@@ -1,112 +0,0 @@
import { ref } from 'vue'
interface ResultStats {
rowsAffected: number
executionTime: number
}
interface Column {
title: string
dataIndex: string
width: number
tooltip?: boolean
}
/**
* 结果状态管理 Composable
*/
export function useResultState() {
const resultLoading = ref(false)
const resultError = ref('')
const resultData = ref<unknown>(null)
const resultMode = ref<'table' | 'json'>('table')
const resultStats = ref<ResultStats | null>(null)
const resultColumns = ref<Column[]>([])
const buildColumn = (key: string): Column => ({
title: key,
dataIndex: key,
width: 120,
tooltip: true
})
const clearResults = () => {
resultData.value = null
resultError.value = ''
resultStats.value = null
resultColumns.value = []
}
const setQueryResult = (data: unknown[], stats: ResultStats, columns?: string[]) => {
const dataArray = data ?? []
resultData.value = dataArray
resultMode.value = 'table'
resultStats.value = stats
if (columns?.length) {
resultColumns.value = columns.map(buildColumn)
} else if (dataArray.length) {
resultColumns.value = Object.keys(dataArray[0] as Record<string, any>).map(buildColumn)
} else {
resultColumns.value = []
}
}
const setUpdateResult = (stats: ResultStats) => {
resultData.value = null
resultMode.value = 'table'
resultStats.value = stats
resultColumns.value = []
}
const setCommandResult = (data: unknown, stats: ResultStats) => {
resultData.value = data
resultMode.value = 'json'
resultStats.value = stats
resultColumns.value = []
}
const setError = (error: string) => {
resultError.value = error
resultData.value = null
resultStats.value = null
resultColumns.value = []
}
// 开始加载(清空数据,用于新查询)
const startLoading = () => {
resultLoading.value = true
resultError.value = ''
resultData.value = null
resultStats.value = null
resultColumns.value = []
}
// 开始加载但保留数据(用于翻页,避免闪烁)
const startLoadingKeepData = () => {
resultLoading.value = true
resultError.value = ''
}
const stopLoading = () => {
resultLoading.value = false
}
return {
resultLoading,
resultError,
resultData,
resultMode,
resultStats,
resultColumns,
clearResults,
setQueryResult,
setUpdateResult,
setCommandResult,
setError,
startLoading,
startLoadingKeepData,
stopLoading
}
}

View File

@@ -1,151 +0,0 @@
import { inject } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { useResultState } from './useResultState'
import type { useMessageLog } from './useMessageLog'
const RESULT_STATE_KEY = Symbol('resultState')
const MESSAGE_LOG_KEY = Symbol('messageLog')
export const DbCliKeys = {
resultState: RESULT_STATE_KEY,
messageLog: MESSAGE_LOG_KEY
}
export function useSqlExecution(
resultState?: ReturnType<typeof useResultState>,
messageLog?: ReturnType<typeof useMessageLog>
) {
const injectedResultState = inject<ReturnType<typeof useResultState>>(RESULT_STATE_KEY)
const injectedMessageLog = inject<ReturnType<typeof useMessageLog>>(MESSAGE_LOG_KEY)
const finalResultState = resultState ?? injectedResultState
const finalMessageLog = messageLog ?? injectedMessageLog
if (!finalResultState || !finalMessageLog) {
throw new Error('useSqlExecution: 缺少必需的依赖')
}
const parseResultData = (data: any): any[] => {
if (data == null) return []
if (Array.isArray(data)) return data
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
return Array.isArray(parsed) ? parsed : (parsed ? [parsed] : [])
} catch {
return []
}
}
if (typeof data === 'object') {
if (Array.isArray(data.rows)) return data.rows
if (Array.isArray(data.data)) return data.data
return [data]
}
return [data]
}
const truncateSql = (sql: string): string =>
sql.length > 100 ? sql.slice(0, 100) + '...' : sql
// 为 SQL 添加分页(仅对查询语句)
// page=1 且 SQL 已有 LIMIT 时保留用户的 LIMIT翻页时才覆盖
const addPaginationToSQL = (sql: string, page: number, pageSize: number): string => {
if (page <= 0 || pageSize <= 0) {
return sql
}
const sqlUpper = sql.trim().toUpperCase()
// 只对 SELECT、SHOW、DESCRIBE、DESC、EXPLAIN 查询添加分页
if (!sqlUpper.startsWith('SELECT') &&
!sqlUpper.startsWith('SHOW') &&
!sqlUpper.startsWith('DESCRIBE') &&
!sqlUpper.startsWith('DESC') &&
!sqlUpper.startsWith('EXPLAIN')) {
return sql
}
const hasLimit = /\s+LIMIT\s+\d+/i.test(sql)
// 第一页且用户已写 LIMIT保留用户的 SQL 不修改
if (page === 1 && hasLimit) {
return sql
}
// 移除已有 LIMIT支持 LIMIT n、LIMIT n OFFSET m、LIMIT m,n
const strippedSql = sql.replace(/\s+LIMIT\s+\d+(?:\s*,\s*\d+)?(?:\s+OFFSET\s+\d+)?\s*;?\s*$/i, '').trim()
// 添加 LIMIT 和 OFFSET
const offset = (page - 1) * pageSize
return `${strippedSql} LIMIT ${pageSize} OFFSET ${offset}`
}
const executeSQL = async (sql: string, connection: any, database: string = '', page: number = 0, pageSize: number = 0) => {
if (!connection) {
Message.warning('请先选择数据库连接')
return
}
if (!(window as any).go?.main?.App?.ExecuteSQL) {
throw new Error('Go 后端未就绪')
}
// 翻页时保留数据避免闪烁,新查询时清空
if (page > 1) {
finalResultState.startLoadingKeepData()
} else {
finalResultState.startLoading()
}
try {
const startTime = Date.now()
const dbParam = connection.type === 'mysql' ? database : ''
// 如果是查询且需要分页,自动添加 LIMIT 和 OFFSET
const finalSQL = addPaginationToSQL(sql, page, pageSize)
const result = await (window as any).go.main.App.ExecuteSQL(
connection.id,
finalSQL,
dbParam
)
const executionTime = Date.now() - startTime
if (result.type === 'query') {
const data = parseResultData(result.data)
const stats = {
rowsAffected: data.length || result.rowsAffected || 0,
executionTime: result.executionTime ?? executionTime
}
// 统一使用表格展示,避免大数据量 JSON 渲染性能问题
finalResultState.setQueryResult(data, stats, result.columns)
Message.success(`查询成功,返回 ${stats.rowsAffected} 行数据`)
finalMessageLog.addMessage('success', `执行成功: ${stats.rowsAffected} 行,耗时 ${stats.executionTime}ms - ${truncateSql(sql)}`)
} else if (result.type === 'update') {
const stats = {
rowsAffected: result.rowsAffected ?? 0,
executionTime: result.executionTime ?? executionTime
}
finalResultState.setUpdateResult(stats)
Message.success(`执行成功,影响 ${stats.rowsAffected}`)
finalMessageLog.addMessage('success', `执行成功: 影响 ${stats.rowsAffected} 行,耗时 ${stats.executionTime}ms - ${truncateSql(sql)}`)
} else if (result.type === 'command') {
const stats = {
rowsAffected: 1,
executionTime: result.executionTime ?? executionTime
}
finalResultState.setCommandResult(result.data, stats)
Message.success('命令执行成功')
finalMessageLog.addMessage('success', `执行成功,耗时 ${stats.executionTime}ms - ${truncateSql(sql)}`)
}
} catch (error: unknown) {
const errorMsg = error instanceof Error ? error.message : String(error)
finalResultState.setError(errorMsg)
Message.error('执行失败: ' + errorMsg)
finalMessageLog.addMessage('error', errorMsg)
} finally {
finalResultState.stopLoading()
}
}
return { executeSQL }
}

View File

@@ -1,154 +0,0 @@
import { ref, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
/**
* 表结构编辑状态管理 Composable
* 负责管理表结构编辑相关的状态和逻辑
*/
export function useStructureEdit() {
const isEditing = ref(false)
const editMode = ref<'view' | 'edit'>('view')
const editedColumns = ref<any[]>([])
const editedIndexes = ref<any[]>([])
const hasUnsavedChanges = computed(() => false)
const switchToViewMode = () => {
editMode.value = 'view'
isEditing.value = false
editedColumns.value = []
editedIndexes.value = []
}
const switchToEditMode = (originalColumns?: any[], originalIndexes?: any[]) => {
editMode.value = 'edit'
isEditing.value = true
editedColumns.value = originalColumns ? JSON.parse(JSON.stringify(originalColumns)) : []
editedIndexes.value = originalIndexes ? JSON.parse(JSON.stringify(originalIndexes)) : []
}
/**
* 更新表结构
*/
const updateTableStructure = async (
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo' | 'redis'
): Promise<string[]> => {
if (!(window as any).go?.main?.App?.UpdateTableStructure) {
throw new Error('Go 后端未就绪')
}
if (dbType === 'redis') {
throw new Error('Redis 不支持表结构修改')
}
const structure = dbType === 'mysql'
? { columns: editedColumns.value, indexes: editedIndexes.value }
: { indexes: editedIndexes.value }
return await (window as any).go.main.App.UpdateTableStructure(
connectionId, database, tableName, structure
)
}
/**
* 预览表结构变更
*/
const previewTableStructure = async (
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo' | 'redis'
): Promise<string[]> => {
if (!(window as any).go?.main?.App?.PreviewTableStructure) {
throw new Error('Go 后端未就绪')
}
if (dbType === 'redis') {
throw new Error('Redis 不支持表结构预览')
}
const structure = dbType === 'mysql'
? { columns: editedColumns.value, indexes: editedIndexes.value }
: { indexes: editedIndexes.value }
return await (window as any).go.main.App.PreviewTableStructure(
connectionId, database, tableName, structure
)
}
/**
* 保存结构修改
*/
const saveStructure = async (
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo'
) => {
try {
const sqlStatements = await updateTableStructure(connectionId, database, tableName, dbType)
Message.success('结构保存成功')
switchToViewMode()
return { success: true, sqlStatements }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
Message.error('保存表结构失败: ' + errorMessage)
return { success: false, sqlStatements: [] }
}
}
/**
* 取消编辑
*/
const cancelEdit = () => switchToViewMode()
const addColumn = () => {
editedColumns.value.push({
Field: '',
Type: 'varchar(255)',
Null: 'YES',
Key: '',
Default: null,
Extra: '',
Comment: ''
})
}
const removeColumn = (index: number) => editedColumns.value.splice(index, 1)
const addIndex = () => {
editedIndexes.value.push({
Key_name: '',
Column_name: '',
Non_unique: 0,
Index_type: 'BTREE'
})
}
const removeIndex = (index: number) => editedIndexes.value.splice(index, 1)
return {
isEditing,
editMode,
editedColumns,
editedIndexes,
hasUnsavedChanges,
switchToViewMode,
switchToEditMode,
previewTableStructure,
saveStructure,
cancelEdit,
addColumn,
removeColumn,
addIndex,
removeIndex
}
}
export interface SaveStructureResult {
success: boolean
sqlStatements: string[]
}

View File

@@ -1,135 +0,0 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { GetTableStructure } from '../../wailsjs/wailsjs/go/main/App'
/**
* 表结构状态管理 Composable
* 负责管理表结构查看相关的状态和数据
*/
export function useStructureState() {
// 状态
const structureLoading = ref(false)
const structureError = ref('')
const structureData = ref<any>(null)
const structureInfo = ref<{
connectionId: number
database: string
tableName: string
dbType: 'mysql' | 'mongo' | 'redis'
nodeType: string
} | null>(null)
/**
* 加载表结构
* @param connectionId 连接ID
* @param database 数据库名
* @param tableName 表名/集合名/Key名
* @param dbType 数据库类型
* @param nodeType 节点类型
*/
const loadStructure = async (
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo' | 'redis',
nodeType: string
) => {
// 对于连接和数据库节点,不需要加载结构
if (nodeType === 'connection' || nodeType === 'database') {
structureInfo.value = {
connectionId,
database,
tableName: '',
dbType,
nodeType
}
structureData.value = null
return
}
// 如果没有表名,不加载(但保留 structureInfo 用于显示提示)
if (!tableName) {
structureInfo.value = {
connectionId,
database,
tableName: '',
dbType,
nodeType
}
structureData.value = null
return
}
try {
structureLoading.value = true
structureError.value = ''
if (!window.go?.main?.App?.GetTableStructure) {
throw new Error('Go 后端未就绪')
}
const result = await window.go.main.App.GetTableStructure(
connectionId,
database,
tableName
)
structureData.value = result
// 确保 structureInfo 也设置了
structureInfo.value = {
connectionId,
database,
tableName,
dbType,
nodeType
}
} catch (error: unknown) {
console.error('加载表结构失败:', error)
const errorMessage = error instanceof Error ? error.message : '加载表结构失败'
structureError.value = errorMessage
Message.error('加载表结构失败: ' + errorMessage)
structureData.value = null
structureInfo.value = null
} finally {
structureLoading.value = false
}
}
/**
* 清空结构数据
*/
const clearStructure = () => {
structureData.value = null
structureInfo.value = null
structureError.value = ''
}
/**
* 刷新结构数据
*/
const refreshStructure = async () => {
if (!structureInfo.value) return
await loadStructure(
structureInfo.value.connectionId,
structureInfo.value.database,
structureInfo.value.tableName,
structureInfo.value.dbType,
structureInfo.value.nodeType
)
}
return {
// 状态
structureLoading,
structureError,
structureData,
structureInfo,
// 方法
loadStructure,
clearStructure,
refreshStructure
}
}

View File

@@ -1,123 +0,0 @@
import { ref } from 'vue'
import { useEventBus } from './useEventBus'
import type { StructureInfo } from './useEventBus'
import { STORAGE_KEYS } from '../constants/storage'
import { getTableStructure } from '@/api'
class StructureStore {
public readonly loading = ref(false)
public readonly error = ref('')
public readonly data = ref<any>(null)
public readonly info = ref<StructureInfo | null>(null)
private eventBus = useEventBus()
setLoading(loading: boolean): void {
this.loading.value = loading
this.eventBus.emit('structure:loading', { loading })
}
setError(error: string): void {
this.error.value = error
this.eventBus.emit('structure:error', { error })
}
setData(data: any, info: StructureInfo): void {
this.data.value = data
this.info.value = info
this.error.value = ''
this.loading.value = false
try {
localStorage.setItem(STORAGE_KEYS.STRUCTURE_INFO, JSON.stringify(info))
} catch {}
this.eventBus.emit('structure:data', { data, info })
}
clear(): void {
this.data.value = null
this.info.value = null
this.error.value = ''
this.loading.value = false
try {
localStorage.removeItem(STORAGE_KEYS.STRUCTURE_INFO)
} catch {}
this.eventBus.emit('structure:clear', {})
}
restoreStructureInfo(): StructureInfo | null {
try {
const saved = localStorage.getItem(STORAGE_KEYS.STRUCTURE_INFO)
return saved ? JSON.parse(saved) as StructureInfo : null
} catch {
return null
}
}
async loadStructure(
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo' | 'redis',
nodeType: string
): Promise<void> {
// 跳过非表节点
if (nodeType === 'connection' || nodeType === 'database' || !tableName) {
this.info.value = { connectionId, database, tableName: '', dbType, nodeType }
this.data.value = null
return
}
// 检查是否切换到不同的表
const currentInfo = this.info.value
const isDifferentTable = !currentInfo ||
currentInfo.connectionId !== connectionId ||
currentInfo.database !== database ||
currentInfo.tableName !== tableName
if (isDifferentTable) {
this.data.value = null
this.error.value = ''
}
try {
this.setLoading(true)
const result = await getTableStructure(
connectionId,
database,
tableName
)
this.setData(result, { connectionId, database, tableName, dbType, nodeType })
} catch (error: unknown) {
const errorMsg = error instanceof Error ? error.message : '加载表结构失败'
this.setError(errorMsg)
this.data.value = null
this.info.value = null
} finally {
this.setLoading(false)
}
}
async refreshStructure(): Promise<void> {
if (!this.info.value) return
await this.loadStructure(
this.info.value.connectionId,
this.info.value.database,
this.info.value.tableName,
this.info.value.dbType,
this.info.value.nodeType
)
}
}
let structureStoreInstance: StructureStore | null = null
export function useStructureStore(): StructureStore {
if (!structureStoreInstance) {
structureStoreInstance = new StructureStore()
}
return structureStoreInstance
}
export type { StructureInfo }

View File

@@ -1,81 +0,0 @@
/**
* @deprecated 请使用 useStructureStore
*/
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { GetTableStructure } from '../../wailsjs/wailsjs/go/main/App'
export function useStructureState() {
const structureLoading = ref(false)
const structureError = ref('')
const structureData = ref<any>(null)
const structureInfo = ref<{
connectionId: number
database: string
tableName: string
dbType: 'mysql' | 'mongo' | 'redis'
nodeType: string
} | null>(null)
const loadStructure = async (
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo' | 'redis',
nodeType: string
) => {
if (nodeType === 'connection' || nodeType === 'database' || !tableName) {
structureInfo.value = { connectionId, database, tableName: '', dbType, nodeType }
structureData.value = null
return
}
try {
structureLoading.value = true
structureError.value = ''
if (!window.go?.main?.App?.GetTableStructure) {
throw new Error('Go 后端未就绪')
}
const result = await window.go.main.App.GetTableStructure(connectionId, database, tableName)
structureData.value = result
structureInfo.value = { connectionId, database, tableName, dbType, nodeType }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '加载表结构失败'
structureError.value = errorMessage
Message.error('加载表结构失败: ' + errorMessage)
structureData.value = null
structureInfo.value = null
} finally {
structureLoading.value = false
}
}
const clearStructure = () => {
structureData.value = null
structureInfo.value = null
structureError.value = ''
}
const refreshStructure = async () => {
if (!structureInfo.value) return
await loadStructure(
structureInfo.value.connectionId,
structureInfo.value.database,
structureInfo.value.tableName,
structureInfo.value.dbType,
structureInfo.value.nodeType
)
}
return {
structureLoading,
structureError,
structureData,
structureInfo,
loadStructure,
clearStructure,
refreshStructure
}
}

View File

@@ -1,138 +0,0 @@
import { ref, nextTick } from 'vue'
import { EditorView, EditorState } from '@/utils/codemirrorExports'
export interface TabEditorTab {
id?: number
key: string
title: string
content: string
connectionId?: number
}
export interface TabEditorOptions {
findContainer: (tabKey: string, retryCount?: number) => Promise<{ container: HTMLElement; pane?: HTMLElement } | null>
checkContainerSize: (container: HTMLElement) => Promise<void>
createExtensions: (tab: TabEditorTab) => any[]
getInitialContent: (tab: TabEditorTab) => string
onContentChange?: (tabKey: string, content: string) => void
onEditorReady?: (tabKey: string, editor: EditorView) => void
}
const INIT_DELAY = 200
export function useTabEditor(options: TabEditorOptions) {
const { findContainer, checkContainerSize, createExtensions, getInitialContent, onContentChange, onEditorReady } = options
const editorViews = ref<Map<string, EditorView>>(new Map())
const getEditor = (tabKey: string): EditorView | null => {
return editorViews.value.get(tabKey) as EditorView || null
}
const destroyEditor = (tabKey: string): void => {
const editor = editorViews.value.get(tabKey)
if (!editor) return
if (onContentChange) {
onContentChange(tabKey, editor.state.doc.toString())
}
editor.destroy()
editorViews.value.delete(tabKey)
}
const focusEditor = (editor: EditorView, delay = 0): void => {
if (!editor) return
const focus = () => {
editor.requestMeasure?.()
editor.dispatch({ effects: [] })
requestAnimationFrame(() => editor.focus())
}
delay > 0 ? setTimeout(focus, delay) : requestAnimationFrame(focus)
}
const initEditor = async (tabKey: string, tab: any, isActive: boolean, forceInit = false): Promise<boolean> => {
if (!isActive && !forceInit) return false
const existingEditor = editorViews.value.get(tabKey)
if (existingEditor instanceof EditorView) {
if (isActive) focusEditor(existingEditor, 100)
return true
}
destroyEditor(tabKey)
await nextTick()
const containerResult = await findContainer(tabKey)
if (!containerResult) return false
const { container } = containerResult
await checkContainerSize(container)
const rect = container.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) {
if (isActive) {
setTimeout(() => initEditor(tabKey, tab, isActive, forceInit), 100)
}
return false
}
const state = EditorState.create({
doc: getInitialContent(tab),
extensions: createExtensions(tab)
})
container.innerHTML = ''
const editorView = new EditorView({ state, parent: container })
editorViews.value.set(tabKey, editorView)
if (onEditorReady) onEditorReady(tabKey, editorView)
if (isActive) focusEditor(editorView, INIT_DELAY)
return true
}
const destroyAll = (): void => {
editorViews.value.forEach((_, tabKey) => destroyEditor(tabKey))
editorViews.value.clear()
}
const updateEditorContent = (tabKey: string, content: string): boolean => {
const editor = editorViews.value.get(tabKey)
if (!editor) return false
const update = () => {
const state = editor.state
if (!state?.doc) return false
if (state.doc.toString() === content) return true
try {
editor.dispatch(state.update({ changes: { from: 0, to: state.doc.length, insert: content } }))
return true
} catch {
return false
}
}
try {
return update() || update()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : ''
if (errorMessage?.includes('doesn\'t start from the previous state')) {
try { return update() } catch {}
}
return false
}
}
return {
editorViews,
getEditor,
destroyEditor,
initEditor,
focusEditor,
destroyAll,
updateEditorContent
}
}

View File

@@ -1,67 +0,0 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { saveTabs as saveTabsApi, listTabs } from '@/api'
/**
* SQL 标签页持久化 Composable
*/
export function useTabPersistence() {
const loading = ref(false)
const tabs = ref([])
/**
* 保存标签页
*/
const saveTabs = async (tabsData) => {
try {
loading.value = true
const formattedTabs = tabsData.map(tab => ({
id: tab.id || 0,
title: tab.title || '未命名查询',
content: tab.content || '',
connectionId: tab.connectionId || null,
order: tab.order || 0
}))
await saveTabsApi(formattedTabs)
tabs.value = tabsData
return true
} catch (error) {
console.error('保存标签页失败:', error)
Message.error('保存标签页失败: ' + (error.message || error))
return false
} finally {
loading.value = false
}
}
/**
* 加载标签页
*/
const loadTabs = async () => {
try {
loading.value = true
const result = await listTabs()
tabs.value = result || []
return result || []
} catch (error) {
console.error('加载标签页失败:', error)
Message.error('加载标签页失败: ' + (error.message || error))
return []
} finally {
loading.value = false
}
}
const clearTabs = () => {
tabs.value = []
}
return {
loading,
tabs,
saveTabs,
loadTabs,
clearTabs
}
}

View File

@@ -1,24 +0,0 @@
/**
* localStorage 键常量
* 统一管理所有 localStorage 键,避免重复定义
*/
export const STORAGE_KEYS = {
// SQL编辑器
ACTIVE_TAB: 'db-cli-sql-editor-active-tab',
// 数据库连接
CURRENT_CONNECTION: 'db-cli-current-connection',
SELECTED_DATABASE: 'db-cli-selected-database',
TREE_EXPANDED_KEYS: 'db-cli-tree-expanded-keys',
TREE_SELECTED_KEYS: 'db-cli-tree-selected-keys',
// 编辑器状态
EDITOR_VISIBLE: 'db-cli-editor-visible',
EDITOR_AREA_HEIGHT: 'db-cli-editor-area-height',
// 结果面板
RESULT_TAB: 'db-cli-result-tab',
// 表结构状态
STRUCTURE_INFO: 'db-cli-structure-info',
// 搜索历史
TABLE_SEARCH_HISTORY: 'db-cli-table-search-history',
TABLE_SEARCH_TEXT: 'db-cli-table-search-text'
} as const

View File

@@ -1,975 +0,0 @@
<template>
<a-layout class="db-cli-layout">
<!-- 左侧数据库列表视图 -->
<a-layout-sider :width="280" class="sidebar">
<div class="sidebar-container">
<ConnectionTree
:current-connection-id="currentConnection?.id"
@connection-select="handleConnectionSelect"
@connection-edit="handleConnectionEdit"
@connection-delete="handleConnectionDelete"
@connection-refresh="handleConnectionRefresh"
@connection-test="handleConnectionTest"
@table-select="handleTableSelect"
@table-structure="handleTableStructure"
@create-table="handleCreateTable"
@new-connection="handleNewConnection"
ref="connectionTreeRef"
/>
</div>
</a-layout-sider>
<!-- 右侧编辑器区域和结果区域 -->
<a-layout ref="mainLayoutRef" class="main-layout">
<!-- SQL编辑器区域 -->
<a-layout-content
v-if="editorVisible"
ref="editorAreaRef"
class="editor-area"
:style="editorAreaStyle"
>
<SqlEditor
:current-connection="currentConnection"
@execute="handleExecuteSQL"
@execute-selected="handleExecuteSQL"
ref="sqlEditorRef"
/>
</a-layout-content>
<!-- 编辑器/结果分隔条 -->
<div v-if="editorVisible" class="editor-result-divider" @mousedown="handleEditorResultDividerMouseDown">
<a-button
type="text"
size="mini"
class="divider-toggle-btn"
@click.stop="toggleEditor"
@mousedown.stop
title="隐藏编辑器"
>
<template #icon>
<icon-down/>
</template>
</a-button>
</div>
<!-- 编辑器隐藏时的展开按钮 -->
<div v-if="!editorVisible" class="editor-result-divider collapsed">
<a-button type="text" size="mini" class="divider-toggle-btn" @click="toggleEditor" title="显示编辑器">
<template #icon>
<icon-up/>
</template>
</a-button>
</div>
<!-- 结果展示区域 -->
<a-layout-content class="result-area">
<ResultPanel
ref="resultPanelRef"
:loading="resultLoading"
:error="resultError"
:data="(resultData as unknown[] | undefined)"
:mode="resultMode"
@re-execute-sql="handleReExecuteSQL"
:stats="(resultStats as { rowsAffected: number; executionTime: number } | undefined)"
:columns="resultColumns"
:messages="messages"
:editor-visible="editorVisible"
:structure-loading="structureLoading"
:structure-error="structureError"
:structure-data="structureData"
:structure-info="structureInfo || undefined"
:edit-mode="structureEditMode"
:edited-columns="editedColumns"
:edited-indexes="editedIndexes"
@toggle-editor="toggleEditor"
@update-columns="handleUpdateColumns"
@update-indexes="handleUpdateIndexes"
@refresh-structure="structureStore.refreshStructure"
@switch-to-edit-mode="handleSwitchToEditMode"
@switch-to-view-mode="handleSwitchToViewMode"
@save-structure="handleSaveStructure"
@cancel-edit="handleCancelEdit"
@add-column="handleAddColumn"
:create-info="createInfo"
:create-loading="createLoading"
@cancel-create="handleCancelCreate"
@create-table="handleCreateTableSubmit"
@tab-change="handleTabChange"
@view-history="handleViewHistory"
/>
</a-layout-content>
</a-layout>
<!-- 连接管理表单 -->
<ConnectionForm
v-model:visible="showConnectionForm"
:connection-id="editingConnectionId || undefined"
@success="handleConnectionSuccess"
/>
<!-- SQL 预览确认对话框 -->
<a-modal
v-model:visible="showSqlPreviewModal"
title="确认执行表结构变更"
:width="800"
:mask-closable="false"
@cancel="showSqlPreviewModal = false"
@ok="handleConfirmSqlExecute"
okText="确定执行"
cancelText="取消"
>
<SqlPreviewDialog
v-if="sqlPreviewStatements.length > 0"
:statements="sqlPreviewStatements"
:db-type="sqlPreviewDbType"
/>
</a-modal>
</a-layout>
</template>
<script setup lang="ts">
// 定义组件名称,用于 KeepAlive 缓存
defineOptions({
name: 'DbCli'
})
import { ref, watch, provide, computed, nextTick, onMounted, onUnmounted, h, onBeforeUpdate } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconUp, IconDown, IconCopy } from '@arco-design/web-vue/es/icon'
import SqlPreviewDialog from './components/SqlPreviewDialog.vue'
import ConnectionTree from './components/ConnectionTree.vue'
import SqlEditor from './components/SqlEditor.vue'
import ResultPanel from './components/ResultPanel.vue'
import ConnectionForm from './components/ConnectionForm.vue'
import { useDbConnection } from './composables/useDbConnection'
import { useEditorState } from './composables/useEditorState'
import { useResultState } from './composables/useResultState'
import { useMessageLog } from './composables/useMessageLog'
import { useSqlExecution, DbCliKeys } from './composables/useSqlExecution'
import { useStructureStore } from './composables/useStructureStore'
import { useStructureEdit, type SaveStructureResult } from './composables/useStructureEdit'
import { useCreateState } from './composables/useCreateState'
import { createResizeHandler } from './utils/resize'
import { STORAGE_KEYS } from './constants/storage'
import { executeQuery } from '@/api'
import { getFriendlyDatabaseError } from '@/utils/database-error'
// 类型声明
declare global {
interface Window {
go?: {
main?: {
App?: {
GetTableStructure?: (connectionId: number, database: string, tableName: string) => Promise<any>
TestDbConnection?: (connectionId: number) => Promise<void>
ExecuteSQL?: (connectionId: number, sql: string, database?: string) => Promise<any>
}
}
}
runtime?: {
EventsOn?: (event: string, callback: () => void) => void
EventsOff?: (event: string) => void
}
}
}
// 使用 Composables
const {
currentConnection,
selectedDatabase,
showConnectionForm,
editingConnectionId,
selectConnection,
editConnection,
deleteConnection: deleteConnectionAction,
newConnection,
onConnectionSuccess
} = useDbConnection()
const { editorVisible, toggleEditor } = useEditorState()
const resultState = useResultState()
const {
resultLoading,
resultError,
resultData,
resultMode,
resultStats,
resultColumns,
clearResults
} = resultState
const messageLog = useMessageLog()
const { messages, addMessage } = messageLog
// 提供依赖注入(供子组件使用)
provide(DbCliKeys.resultState, resultState)
provide(DbCliKeys.messageLog, messageLog)
// 在当前组件中直接传递参数provide/inject 用于子组件,当前组件直接传参)
const { executeSQL } = useSqlExecution(resultState, messageLog)
// 新架构:使用单例 Store事件驱动
const structureStore = useStructureStore()
// 直接使用 Store 的状态Store 暴露的是 ref在模板中自动解包
// 为了类型安全,使用 computed 包装
const structureLoading = computed(() => structureStore.loading.value)
const structureError = computed(() => structureStore.error.value)
const structureData = computed(() => structureStore.data.value)
const structureInfo = computed(() => structureStore.info.value)
// 表结构编辑状态
const structureEdit = useStructureEdit()
const {
editMode: structureEditMode,
editedColumns,
editedIndexes,
switchToEditMode,
switchToViewMode,
previewTableStructure,
saveStructure: saveStructureEdit,
addColumn,
removeColumn
} = structureEdit
// 表创建状态
const createState = useCreateState()
const {
createInfo,
createLoading,
startCreate,
cancelCreate
} = createState
// 组件引用
const connectionTreeRef = ref<any>(null)
const sqlEditorRef = ref<any>(null)
const resultPanelRef = ref<any>(null)
const mainLayoutRef = ref<any>(null)
const editorAreaRef = ref<HTMLElement | null>(null)
// SQL 预览对话框状态
const showSqlPreviewModal = ref(false)
const sqlPreviewStatements = ref<string[]>([])
const sqlPreviewDbType = ref<'mysql' | 'mongo' | 'redis'>('mysql')
const sqlPreviewInfo = ref<{
connectionId: number
database: string
tableName: string
dbType: 'mysql' | 'mongo' | 'redis'
} | null>(null)
// 编辑器/结果区域高度调整
const loadEditorAreaHeight = (): number => {
const saved = localStorage.getItem(STORAGE_KEYS.EDITOR_AREA_HEIGHT)
if (saved) {
const val = Number(saved)
if (Number.isFinite(val) && val > 0 && val <= 100) return val
}
return 50
}
const editorAreaHeight = ref(loadEditorAreaHeight())
const editorAreaPixelHeight = ref<number | null>(null)
// 计算编辑器区域的样式
const editorAreaStyle = computed(() => {
if (!editorVisible.value) return {}
// 优先使用像素高度,否则使用百分比
if (editorAreaPixelHeight.value !== null) {
return { height: `${editorAreaPixelHeight.value}px` }
}
return { height: `${editorAreaHeight.value}%` }
})
// 更新编辑器区域的像素高度
const updateEditorPixelHeight = () => {
if (!mainLayoutRef.value || !editorVisible.value) {
editorAreaPixelHeight.value = null
return
}
nextTick(() => {
const mainLayoutEl = (mainLayoutRef.value as any)?.$el || mainLayoutRef.value
if (mainLayoutEl instanceof HTMLElement) {
const containerHeight = mainLayoutEl.getBoundingClientRect().height
if (containerHeight > 0) {
editorAreaPixelHeight.value = (containerHeight * editorAreaHeight.value) / 100
}
}
})
}
// 监听编辑器高度和可见性变化
watch(() => editorAreaHeight.value, updateEditorPixelHeight)
watch(() => editorVisible.value, (visible) => {
if (visible) {
updateEditorPixelHeight()
} else {
editorAreaPixelHeight.value = null
}
})
const handleEditorResultDividerMouseDown = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('.divider-toggle-btn')) return
e.preventDefault()
e.stopPropagation()
const mainLayoutEl = mainLayoutRef.value
? ((mainLayoutRef.value as any)?.$el || mainLayoutRef.value)
: (e.currentTarget as HTMLElement).closest('.main-layout')
if (!(mainLayoutEl instanceof HTMLElement)) return
const resizeHandler = createResizeHandler(() => mainLayoutEl, () => editorAreaHeight.value, {
minPercent: 20,
maxPercent: 80,
minPixels: 150,
onResize: (percentage) => {
editorAreaHeight.value = percentage
localStorage.setItem(STORAGE_KEYS.EDITOR_AREA_HEIGHT, String(percentage))
const containerHeight = mainLayoutEl.getBoundingClientRect().height
if (containerHeight > 0) {
editorAreaPixelHeight.value = (containerHeight * percentage) / 100
}
}
})
resizeHandler(e)
}
// 导入事件类型
import type {
ConnectionSelectEvent,
ConnectionEditEvent,
ConnectionDeleteEvent,
ConnectionTestEvent,
ConnectionRefreshEvent,
TableSelectEvent,
TableStructureEvent
} from './types/events'
// 恢复表结构状态(用于页面刷新或重新进入时的状态恢复)
const restoreStructureState = async () => {
const savedInfo = structureStore.restoreStructureInfo()
if (!savedInfo?.tableName) return
// 检查连接是否匹配
if (!currentConnection.value || currentConnection.value.id !== savedInfo.connectionId) return
// 避免重复加载
if (structureStore.loading.value) return
// 如果当前已经有不同表的信息,不恢复
const currentInfo = structureStore.info.value
if (currentInfo?.tableName && currentInfo.tableName !== savedInfo.tableName) return
const currentTab = resultPanelRef.value?.getCurrentTab() || 'result'
// 如果当前不是结果Tab需要切换到结构Tab
if (currentTab !== 'result' && resultPanelRef.value) {
(resultPanelRef.value as any).switchToStructureTab()
await nextTick()
await new Promise(resolve => setTimeout(resolve, 100))
}
// 再次检查加载状态切换Tab可能触发其他加载
if (structureStore.loading.value) return
// 如果当前是结果Tab不加载结构保持用户在结果Tab查看数据
if (currentTab === 'result') return
// 重新加载表结构
await structureStore.loadStructure(
savedInfo.connectionId,
savedInfo.database,
savedInfo.tableName,
savedInfo.dbType,
savedInfo.nodeType
)
}
// 连接选择
const handleConnectionSelect = async (data: ConnectionSelectEvent) => {
selectConnection(data.connection, data.database)
clearResults()
addMessage('info', `切换到连接: ${data.connection.name}${data.database ? ` (${data.database})` : ''}`)
// 连接切换后延迟恢复表结构状态(给 table-structure 事件处理时间)
await nextTick()
await new Promise(resolve => setTimeout(resolve, 150))
await restoreStructureState()
}
// 连接编辑
const handleConnectionEdit = (data: ConnectionEditEvent) => {
editConnection(data.connectionId)
}
const handleConnectionDelete = async (data: ConnectionDeleteEvent) => {
const isCurrent = deleteConnectionAction(data.connectionId)
if (isCurrent) clearResults()
await connectionTreeRef.value?.refresh?.()
}
const handleNewConnection = () => newConnection()
const handleConnectionRefresh = async (data: ConnectionRefreshEvent) => {
await connectionTreeRef.value?.refreshNode?.(data.connectionId, data.nodeType, data.database)
}
// 测试连接
const handleConnectionTest = async (data: ConnectionTestEvent) => {
try {
await window.go?.main?.App?.TestDbConnection?.(data.connectionId)
Message.success('连接测试成功')
} catch (error: unknown) {
Message.error(getFriendlyDatabaseError(error))
}
}
// 生成数据库查询命令
const generateQueryCommand = (dbType: string, database: string, tableName: string, pretty: boolean = false): string => {
if (dbType === 'mongo') {
const command = {
op: "find",
collection: tableName,
filter: {},
limit: 100
}
return pretty ? JSON.stringify(command, null, 2) : JSON.stringify(command)
} else if (dbType === 'redis') {
return `GET "${tableName}"`
} else {
return `SELECT * FROM \`${database}\`.\`${tableName}\` LIMIT 10;`
}
}
// 表选择生成SQL/命令)
const handleTableSelect = (data: TableSelectEvent) => {
const dbType = data.dbType || currentConnection.value?.type || 'mysql'
const sql = generateQueryCommand(dbType, data.database, data.tableName, true)
sqlEditorRef.value?.insertSQL?.(sql)
}
// 查询表数据(用于表节点点击时自动查询数据)
const queryTableData = async (connectionId: number, database: string, tableName: string, dbType: 'mysql' | 'mongo' | 'redis' = 'mysql', nodeType: string = 'table') => {
if (!currentConnection.value || currentConnection.value.id !== connectionId) return
// 保存表信息到 structureStore以便切换到"结构"Tab时能自动加载
structureStore.info.value = { connectionId, database, tableName, dbType, nodeType }
const sql = generateQueryCommand(dbType, database, tableName)
await handleExecuteSQL(sql) // 用 handleExecuteSQL 保存原始 SQL支持翻页
}
const handleTableStructure = async (data: TableStructureEvent) => {
if (!editorVisible.value) toggleEditor()
const currentTab = resultPanelRef.value?.getCurrentTab() || 'result'
if (currentTab === 'result') {
await queryTableData(data.connectionId, data.database, data.tableName, data.dbType, data.nodeType)
} else if (currentTab === 'structure') {
const currentInfo = structureStore.info.value
const isDifferentTable = !currentInfo ||
currentInfo.connectionId !== data.connectionId ||
currentInfo.database !== data.database ||
currentInfo.tableName !== data.tableName
if (isDifferentTable && structureEditMode.value === 'edit') switchToViewMode()
await structureStore.loadStructure(
data.connectionId,
data.database,
data.tableName,
data.dbType,
data.nodeType
)
}
}
// 查看历史记录(将历史记录加载到结果面板显示)
const handleViewHistory = (historyItem: any) => {
if (!historyItem) return
// 根据历史记录类型设置结果数据
if (historyItem.type === 'query') {
resultState.setQueryResult(
historyItem.data || [],
{
rowsAffected: historyItem.rows_affected || 0,
executionTime: historyItem.execution_time || 0
},
historyItem.columns || []
)
} else if (historyItem.type === 'update') {
resultState.setUpdateResult({
rowsAffected: historyItem.rows_affected || 0,
executionTime: historyItem.execution_time || 0
})
} else {
resultState.setCommandResult(
historyItem.data,
{
rowsAffected: historyItem.rows_affected || 0,
executionTime: historyItem.execution_time || 0
}
)
}
}
const handleTabChange = async (newTab: string, oldTab: string) => {
const structureInfo = structureStore.info.value
if (!structureInfo?.tableName) return
if (!currentConnection.value || currentConnection.value.id !== structureInfo.connectionId) return
if (newTab === 'result' && oldTab !== 'result') {
await queryTableData(
structureInfo.connectionId,
structureInfo.database,
structureInfo.tableName,
structureInfo.dbType
)
} else if (newTab === 'structure' && oldTab !== 'structure') {
const currentData = structureStore.data.value
if (!currentData || (currentData.type === 'mysql' && currentData.table !== structureInfo.tableName)) {
await structureStore.loadStructure(
structureInfo.connectionId,
structureInfo.database,
structureInfo.tableName,
structureInfo.dbType,
structureInfo.nodeType
)
}
}
}
// 开始创建表
const handleCreateTable = (data: { connectionId: number; database: string; dbType: 'mysql' | 'mongo' | 'redis' }) => {
// 如果结果面板隐藏,自动显示编辑器(这样结果面板也会显示)
if (!editorVisible.value) {
toggleEditor()
}
startCreate(data.connectionId, data.database, data.dbType)
}
// 取消创建
const handleCancelCreate = () => {
cancelCreate()
}
// 提交创建表
const handleCreateTableSubmit = async (data: { connectionId: number; database: string; tableName: string; sql: string }) => {
try {
createLoading.value = true
// 执行 CREATE TABLE SQL
const result = await executeQuery(
data.connectionId,
data.sql,
data.database
)
Message.success(`${data.tableName} 创建成功`)
addMessage('success', `${data.tableName} 创建成功`)
// 取消创建状态
cancelCreate()
// 刷新连接树(刷新表列表)
if (connectionTreeRef.value) {
await connectionTreeRef.value.refresh()
}
// 切换到结构 Tab 并加载新创建的表结构
if (resultPanelRef.value) {
(resultPanelRef.value as any).switchToStructureTab()
}
// 等待一下确保Tab切换完成
await new Promise(resolve => setTimeout(resolve, 100))
// 加载新创建的表结构
await structureStore.loadStructure(
data.connectionId,
data.database,
data.tableName,
'mysql',
'table'
)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
Message.error('创建表失败: ' + errorMessage)
addMessage('error', '创建表失败: ' + errorMessage)
} finally {
createLoading.value = false
}
}
// 保存当前执行的 SQL用于分页
const currentExecutedSQL = ref('')
// 执行SQL
const handleExecuteSQL = async (sql: string, page?: number, pageSize?: number) => {
// 保存原始 SQL不包含分页信息
if (page == null && pageSize == null) {
currentExecutedSQL.value = sql
}
const resolvedPage = page ?? 1
const resolvedPageSize = pageSize ?? 10
await executeSQL(sql, currentConnection.value, selectedDatabase.value, resolvedPage, resolvedPageSize)
// 执行完成后,等待一下确保结果已经设置,然后切换到结果 tab
await nextTick()
setTimeout(() => {
if (resultPanelRef.value && (resultData.value !== null || resultStats.value !== null)) {
resultPanelRef.value.switchToResultTab()
}
}, 100)
}
// 处理分页重新执行 SQL
const handleReExecuteSQL = async (pagination: { page: number; pageSize: number }) => {
if (!currentExecutedSQL.value) {
Message.warning('无法翻页:缺少原始 SQL 语句')
return
}
await handleExecuteSQL(currentExecutedSQL.value, pagination.page, pagination.pageSize)
}
// 连接表单成功回调
const handleConnectionSuccess = async () => {
const editedId = editingConnectionId.value
// 刷新连接列表
if (connectionTreeRef.value) {
await connectionTreeRef.value?.refresh()
}
onConnectionSuccess(editedId)
}
// 表结构编辑相关处理
const handleSwitchToEditMode = () => {
const data = structureStore.data.value
const info = structureStore.info.value
if (!data || !info) {
console.warn('切换到编辑模式失败:缺少数据或信息', { data, info })
return
}
if (info.dbType === 'mysql' && (data.type === 'mysql' || !data.type)) {
const columns = data.columns || []
const indexes = data.indexes || []
if (columns.length === 0) {
console.warn('切换到编辑模式失败:字段列表为空', data)
return
}
switchToEditMode(columns, indexes)
} else if (info.dbType === 'mongo' && data.type === 'mongo') {
switchToEditMode([], data.structure?.indexes || [])
}
}
const handleSwitchToViewMode = () => {
switchToViewMode()
}
// 表结构保存处理(包含预览和用户确认流程)
const handleSaveStructure = async () => {
const info = structureStore.info.value
if (!info) return
try {
// 第一步:预览生成 SQL 语句
const previewStatements = await previewTableStructure(
info.connectionId,
info.database,
info.tableName,
info.dbType
)
// 如果没有变更,直接返回
if (previewStatements.length === 0) {
Message.info('表结构未发生变化')
return
}
// 第二步:显示确认对话框,让用户确认执行
sqlPreviewStatements.value = previewStatements
sqlPreviewDbType.value = info.dbType
sqlPreviewInfo.value = {
connectionId: info.connectionId,
database: info.database,
tableName: info.tableName,
dbType: info.dbType
}
showSqlPreviewModal.value = true
} catch (error: unknown) {
console.error('预览表结构变更失败:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
Message.error('预览表结构变更失败: ' + errorMessage)
}
}
const handleCancelEdit = () => {
switchToViewMode()
}
// 确认执行 SQL
const handleConfirmSqlExecute = async () => {
if (!sqlPreviewInfo.value) return
const info = sqlPreviewInfo.value
showSqlPreviewModal.value = false
if (info.dbType === 'redis') {
Message.error('Redis 不支持表结构修改')
return
}
const result = await saveStructureEdit(
info.connectionId,
info.database,
info.tableName,
info.dbType as 'mysql' | 'mongo'
)
if (result && result.success) {
// 保存成功后刷新结构数据
await structureStore.refreshStructure()
// 在消息面板中展示生成的 SQL 语句
if (result.sqlStatements && result.sqlStatements.length > 0) {
addMessage('success', `表结构变更成功,执行了 ${result.sqlStatements.length} 条语句`)
// 为每条 SQL 语句添加消息
result.sqlStatements.forEach((sql: string, index: number) => {
addMessage('info', `[${index + 1}] ${sql}`)
})
}
}
}
// 更新编辑数据
const handleUpdateColumns = (columns: any[]) => {
editedColumns.value = columns
}
const handleUpdateIndexes = (indexes: any[]) => {
editedIndexes.value = indexes
}
// 添加字段
const handleAddColumn = () => {
addColumn()
}
// 清理本地缓存
const handleClearCache = () => {
Modal.confirm({
title: '清理本地缓存',
content: '确定要清理所有本地缓存数据吗?这将清除编辑器状态、连接状态、展开状态等所有缓存信息。',
onOk: () => {
try {
// 清理所有 localStorage 缓存
Object.values(STORAGE_KEYS).forEach(key => {
localStorage.removeItem(key)
})
Message.success('本地缓存已清理')
// 重置连接树状态
if (connectionTreeRef.value) {
connectionTreeRef.value.refresh()
}
// 重置编辑器状态
clearResults()
} catch (error) {
Message.error('清理缓存失败: ' + (error.message || error))
}
}
})
}
// 监听容器大小变化,更新编辑器区域高度
let mainLayoutResizeObserver: ResizeObserver | null = null
// 组件挂载时的初始化工作
onMounted(async () => {
// 监听 Wails 事件(来自窗口菜单的清理缓存功能)
if (window.runtime?.EventsOn) {
window.runtime.EventsOn('clear-cache', () => {
handleClearCache()
})
}
// 初始化编辑器像素高度并监听容器大小变化
nextTick(() => {
updateEditorPixelHeight()
const mainLayoutEl = mainLayoutRef.value
? ((mainLayoutRef.value as any)?.$el || mainLayoutRef.value)
: null
if (mainLayoutEl instanceof HTMLElement) {
mainLayoutResizeObserver = new ResizeObserver(updateEditorPixelHeight)
mainLayoutResizeObserver.observe(mainLayoutEl)
}
})
// 加载保存的标签页内容
await nextTick()
await new Promise(resolve => setTimeout(resolve, 200))
if (sqlEditorRef.value?.loadSavedTabs) {
try {
await sqlEditorRef.value.loadSavedTabs()
} catch (error) {
console.warn('加载保存的标签页失败:', error)
}
}
})
// 组件卸载时的清理工作
onUnmounted(() => {
// 取消 Wails 事件监听
if (window.runtime?.EventsOff) {
window.runtime.EventsOff('clear-cache')
}
// 清理 ResizeObserver 避免内存泄漏
if (mainLayoutResizeObserver) {
mainLayoutResizeObserver.disconnect()
mainLayoutResizeObserver = null
}
})
</script>
<style scoped>
/* 主布局容器 */
.db-cli-layout {
width: 100%;
height: 100%;
display: flex;
overflow: hidden;
}
/* 侧边栏 - 使用 Arco 设计令牌 */
.sidebar {
flex-shrink: 0;
width: 280px;
border-right: 1px solid var(--color-border-2);
overflow: hidden;
}
.sidebar-container {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 主布局容器 - 使用 Arco Layout */
.main-layout {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
/* 编辑器区域 - 使用 Arco Layout Content */
.editor-area {
flex: 0 0 auto !important; /* 覆盖 Arco 的 flex: auto使用固定高度 */
min-height: 150px;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.editor-area :deep(.sql-editor-wrapper) {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 编辑器/结果分隔条 - 使用 Arco 设计令牌 */
.editor-result-divider {
flex-shrink: 0;
height: 4px;
background: var(--color-border-2);
cursor: row-resize;
position: relative;
transition: background-color var(--transition-duration-2) var(--transition-timing-function-ease-out);
display: flex;
align-items: center;
justify-content: center;
user-select: none;
-webkit-user-select: none;
z-index: 10;
}
.editor-result-divider:hover {
background: var(--color-border-3);
}
.editor-result-divider.collapsed {
cursor: pointer;
height: 6px;
}
.editor-result-divider.collapsed:hover {
background: var(--color-primary-light-4);
}
.divider-toggle-btn {
position: absolute;
z-index: 10;
background: var(--color-bg-1);
border: 1px solid var(--color-border-2);
border-radius: var(--border-radius-small);
box-shadow: var(--shadow-1-down);
transition: all var(--transition-duration-2) var(--transition-timing-function-ease-out);
padding: 0;
min-width: 30px;
height: 15px;
cursor: pointer;
}
.divider-toggle-btn:hover {
background: var(--color-bg-2);
border-color: var(--color-primary-light-2);
box-shadow: var(--shadow-2-down);
transform: translateY(-1px);
}
.divider-toggle-btn:active {
transform: translateY(0);
box-shadow: var(--shadow-1-down);
}
/* 结果区域 - 使用 Arco Layout Content */
.result-area {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
border-top: 1px solid var(--color-border-2);
overflow: hidden;
padding: 0;
}
.result-area :deep(.result-panel-wrapper) {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
</style>

View File

@@ -1,149 +0,0 @@
/**
* 数据库客户端事件类型定义
* 所有事件参数使用对象格式,确保类型安全和易于扩展
*/
// ==================== 连接相关事件 ====================
/**
* 连接选择事件
*/
export interface ConnectionSelectEvent {
connection: {
id: number
name: string
type: 'mysql' | 'mongo' | 'redis'
host: string
port: number
username: string
database?: string
[key: string]: any
}
database?: string // 可选,选中的数据库
}
/**
* 连接编辑事件
*/
export interface ConnectionEditEvent {
connectionId: number
}
/**
* 连接删除事件
*/
export interface ConnectionDeleteEvent {
connectionId: number
}
/**
* 连接刷新事件
*/
export interface ConnectionRefreshEvent {
connectionId: number
nodeType?: 'connection' | 'database' | 'table' | 'collection' | 'key' // 节点类型
database?: string // 数据库名(如果是数据库或表节点)
}
/**
* 连接测试事件
*/
export interface ConnectionTestEvent {
connectionId: number
}
// ==================== 表结构相关事件 ====================
/**
* 查看表结构事件
*/
export interface TableStructureEvent {
connectionId: number
database: string
tableName: string // 表名/集合名/Key名对于连接和数据库节点可能为空
dbType: 'mysql' | 'mongo' | 'redis'
nodeType: 'table' | 'collection' | 'key' | 'database' | 'connection'
}
/**
* 表选择事件用于生成SQL
*/
export interface TableSelectEvent {
connectionId: number
database: string
tableName: string
dbType?: 'mysql' | 'mongo' | 'redis'
sql?: string // 可选预生成的SQL
}
// ==================== SQL执行相关事件 ====================
/**
* SQL执行事件
*/
export interface SqlExecuteEvent {
sql: string
connectionId: number
database?: string
}
/**
* SQL执行完成事件
*/
export interface SqlExecuteCompleteEvent {
result?: any
error?: string
}
// ==================== 编辑器相关事件 ====================
/**
* SQL插入事件
*/
export interface SqlInsertEvent {
sql: string
tabKey?: string // 可选指定Tab
}
/**
* Tab切换事件
*/
export interface TabSwitchEvent {
tabKey: string
}
/**
* Tab关闭事件
*/
export interface TabCloseEvent {
tabKey: string
}
// ==================== 组件事件映射 ====================
/**
* ConnectionTree 组件事件
*/
export interface ConnectionTreeEvents {
'connection-select': ConnectionSelectEvent
'connection-edit': ConnectionEditEvent
'connection-delete': ConnectionDeleteEvent
'connection-refresh': ConnectionRefreshEvent
'connection-test': ConnectionTestEvent
'table-select': TableSelectEvent
'table-structure': TableStructureEvent
'new-connection': void
}
/**
* SqlEditor 组件事件
*/
export interface SqlEditorEvents {
'execute': { sql: string }
'execute-selected': { sql: string }
'sql-insert': SqlInsertEvent
'tab-switch': TabSwitchEvent
'tab-close': TabCloseEvent
'toggle-editor': void
}

View File

@@ -1,88 +0,0 @@
// MySQL 数据类型选项
export const mysqlDataTypeOptions = [
{
label: '整数类型',
options: [
{ label: 'TINYINT', value: 'TINYINT' },
{ label: 'SMALLINT', value: 'SMALLINT' },
{ label: 'MEDIUMINT', value: 'MEDIUMINT' },
{ label: 'INT', value: 'INT' },
{ label: 'BIGINT', value: 'BIGINT' }
]
},
{
label: '浮点类型',
options: [
{ label: 'FLOAT', value: 'FLOAT' },
{ label: 'DOUBLE', value: 'DOUBLE' },
{ label: 'DECIMAL', value: 'DECIMAL' }
]
},
{
label: '字符串类型',
options: [
{ label: 'CHAR', value: 'CHAR' },
{ label: 'VARCHAR', value: 'VARCHAR' },
{ label: 'TEXT', value: 'TEXT' },
{ label: 'TINYTEXT', value: 'TINYTEXT' },
{ label: 'MEDIUMTEXT', value: 'MEDIUMTEXT' },
{ label: 'LONGTEXT', value: 'LONGTEXT' }
]
},
{
label: '日期时间类型',
options: [
{ label: 'DATE', value: 'DATE' },
{ label: 'TIME', value: 'TIME' },
{ label: 'DATETIME', value: 'DATETIME' },
{ label: 'TIMESTAMP', value: 'TIMESTAMP' },
{ label: 'YEAR', value: 'YEAR' }
]
},
{
label: '其他类型',
options: [
{ label: 'BLOB', value: 'BLOB' },
{ label: 'JSON', value: 'JSON' },
{ label: 'ENUM', value: 'ENUM' },
{ label: 'SET', value: 'SET' }
]
}
]
// 需要长度参数的类型
export const typesNeedLength = ['VARCHAR', 'CHAR', 'DECIMAL', 'FLOAT', 'DOUBLE']
// 解析类型字符串,提取基础类型和长度参数
export const parseType = (typeStr: string): { baseType: string; length: string | null } => {
if (!typeStr) return { baseType: '', length: null }
const match = typeStr.match(/^(\w+)(?:\((.+?)\))?$/i)
if (match) {
return {
baseType: match[1].toUpperCase(),
length: match[2] || null
}
}
return { baseType: typeStr.toUpperCase(), length: null }
}
// 格式化类型字符串
export const formatType = (baseType: string, length: string | null): string => {
if (!baseType) return ''
if (length) {
return `${baseType}(${length})`
}
return baseType
}
// 获取类型的默认长度
export const getDefaultLength = (baseType: string): string | null => {
const upperType = baseType.toUpperCase()
if (upperType === 'VARCHAR') return '255'
if (upperType === 'CHAR') return '10'
if (upperType === 'DECIMAL') return '10,2'
if (upperType === 'FLOAT') return ''
if (upperType === 'DOUBLE') return ''
return null
}

View File

@@ -1,2 +0,0 @@
// 保留向后兼容,内部使用通用工具
export { createResizeHandler, type ResizeOptions } from '../../../utils/resize'

View File

@@ -1,219 +0,0 @@
/**
* 查询结果导出工具
* 支持 CSV、JSON、Excel 格式
*/
import { escapeHtml } from '@/utils/fileUtils'
/**
* 导出为 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)
}
/**
* 复制到剪贴板
*/
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

@@ -1,124 +0,0 @@
/**
* 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()
}