新增:连接管理、数据查询等功能
This commit is contained in:
511
web/src/views/db-cli/components/ConnectionForm.vue
Normal file
511
web/src/views/db-cli/components/ConnectionForm.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
title="数据库连接配置"
|
||||
width="560px"
|
||||
: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>
|
||||
|
||||
<!-- 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} from 'vue'
|
||||
import {Message} from '@arco-design/web-vue'
|
||||
import {
|
||||
ListDbConnections,
|
||||
SaveDbConnection
|
||||
} from '../../../wailsjs/wailsjs/go/main/App'
|
||||
|
||||
// 使用 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 form = reactive({
|
||||
name: '',
|
||||
type: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
username: '',
|
||||
password: '',
|
||||
database: '',
|
||||
options: ''
|
||||
})
|
||||
|
||||
// 选项表单(用于表单输入)
|
||||
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 '请输入新密码'
|
||||
}
|
||||
switch (form.type) {
|
||||
case 'redis':
|
||||
return '可选,留空则无密码连接'
|
||||
case 'mongo':
|
||||
return '可选,留空则无认证连接'
|
||||
default:
|
||||
return '请输入密码'
|
||||
}
|
||||
}
|
||||
|
||||
// 监听类型变化,设置默认端口、主机和用户名
|
||||
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.password = ''
|
||||
isPasswordChanged.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载连接详情失败:', error)
|
||||
} finally {
|
||||
isLoading.value = 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 = ''
|
||||
optionsForm.authSource = ''
|
||||
isPasswordChanged.value = false
|
||||
}
|
||||
|
||||
// 测试连接(不保存数据)
|
||||
const handleTest = async () => {
|
||||
if (!formRef.value) {
|
||||
console.error('formRef 未初始化')
|
||||
return
|
||||
}
|
||||
|
||||
// 清除之前的错误信息
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
// 验证通过,继续执行
|
||||
} catch (error) {
|
||||
// 表单验证失败
|
||||
const errorFields = error?.fields || {}
|
||||
const firstError = Object.values(errorFields)[0]
|
||||
const errorMsg = firstError?.[0]?.message || '请检查表单填写是否正确'
|
||||
errorMessage.value = errorMsg
|
||||
Message.warning(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 Go 后端是否可用
|
||||
if (!(window as any).go?.main?.App) {
|
||||
errorMessage.value = 'Go 后端未就绪,请确保应用已启动'
|
||||
Message.error('Go 后端未就绪,请确保应用已启动')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
// 编辑模式下,如果未修改密码,传递空字符串(后端会获取已保存的密码)
|
||||
const passwordToTest = (props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
|
||||
|
||||
// 合并选项为 JSON
|
||||
const optionsJson = mergeOptionsToJson()
|
||||
|
||||
// 直接测试连接,不保存数据
|
||||
await (window as any).go.main.App.TestDbConnectionWithParams({
|
||||
id: props.connectionId || 0, // 编辑模式下传递ID,用于获取已保存的密码
|
||||
type: form.type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username || '',
|
||||
password: passwordToTest,
|
||||
database: form.database || '',
|
||||
options: optionsJson
|
||||
})
|
||||
|
||||
Message.success('连接测试成功')
|
||||
errorMessage.value = ''
|
||||
} catch (error) {
|
||||
console.error('连接测试失败:', error)
|
||||
const errorMsg = error.message || error.toString() || '未知错误'
|
||||
errorMessage.value = '连接测试失败: ' + errorMsg
|
||||
Message.error('连接测试失败: ' + errorMsg)
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
console.error('formRef 未初始化')
|
||||
return
|
||||
}
|
||||
|
||||
// 清除之前的错误信息
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
// 验证通过,继续执行
|
||||
} catch (error) {
|
||||
// 表单验证失败
|
||||
const errorFields = error?.fields || {}
|
||||
const firstError = Object.values(errorFields)[0]
|
||||
const errorMsg = firstError?.[0]?.message || '请检查表单填写是否正确'
|
||||
errorMessage.value = errorMsg
|
||||
Message.warning(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 Go 后端是否可用
|
||||
if (!(window as any).go?.main?.App) {
|
||||
errorMessage.value = 'Go 后端未就绪,请确保应用已启动'
|
||||
Message.error('Go 后端未就绪,请确保应用已启动')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// 编辑模式下,如果未修改密码,传递空字符串(后端会保留原密码)
|
||||
const passwordToSave = (props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
|
||||
|
||||
// 合并选项为 JSON
|
||||
const optionsJson = mergeOptionsToJson()
|
||||
|
||||
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: passwordToSave,
|
||||
database: form.database || '',
|
||||
options: optionsJson
|
||||
})
|
||||
|
||||
Message.success(props.connectionId ? '更新成功' : '保存成功')
|
||||
errorMessage.value = ''
|
||||
emit('success')
|
||||
visible.value = false
|
||||
} catch (error) {
|
||||
console.error('保存失败:', 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 }
|
||||
)
|
||||
|
||||
// 监听 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;
|
||||
}
|
||||
</style>
|
||||
|
||||
1129
web/src/views/db-cli/components/ConnectionTree.vue
Normal file
1129
web/src/views/db-cli/components/ConnectionTree.vue
Normal file
File diff suppressed because it is too large
Load Diff
183
web/src/views/db-cli/components/ContextMenu.vue
Normal file
183
web/src/views/db-cli/components/ContextMenu.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<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>
|
||||
529
web/src/views/db-cli/components/MySQLCreate.vue
Normal file
529
web/src/views/db-cli/components/MySQLCreate.vue
Normal file
@@ -0,0 +1,529 @@
|
||||
<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>
|
||||
446
web/src/views/db-cli/components/MySQLFieldList.vue
Normal file
446
web/src/views/db-cli/components/MySQLFieldList.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<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>
|
||||
2437
web/src/views/db-cli/components/ResultPanel.vue
Normal file
2437
web/src/views/db-cli/components/ResultPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
460
web/src/views/db-cli/components/SqlEditor.vue
Normal file
460
web/src/views/db-cli/components/SqlEditor.vue
Normal file
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<div class="sql-editor-wrapper">
|
||||
<div class="editor-toolbar">
|
||||
<a-space>
|
||||
<a-button type="outline" @click="handleExecute">
|
||||
<template #icon>
|
||||
<icon-play-arrow/>
|
||||
</template>
|
||||
{{ getExecuteButtonText() }} (F5)
|
||||
</a-button>
|
||||
<a-button type="outline" @click="handleExecuteSelected">
|
||||
<template #icon>
|
||||
<icon-code/>
|
||||
</template>
|
||||
执行选中 (Ctrl+Enter)
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-space v-if="currentConnection">
|
||||
<a-tag color="blue" size="small">
|
||||
<template #icon>
|
||||
<icon-storage/>
|
||||
</template>
|
||||
{{ currentConnection.name }}
|
||||
</a-tag>
|
||||
<span class="connection-info">
|
||||
{{ currentConnection.host }}:{{ currentConnection.port }}
|
||||
<span v-if="currentConnection.database" class="database-name">
|
||||
/ {{ currentConnection.database }}
|
||||
</span>
|
||||
</span>
|
||||
</a-space>
|
||||
<span v-else class="connection-info-empty">
|
||||
未选择连接
|
||||
</span>
|
||||
</div>
|
||||
<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 {IconPlayArrow, IconStorage, IconCode} from '@arco-design/web-vue/es/icon'
|
||||
import {EditorView, keymap, lineNumbers} from '@codemirror/view'
|
||||
import {EditorState} from '@codemirror/state'
|
||||
import {sql} from '@codemirror/lang-sql'
|
||||
import {javascript} from '@codemirror/lang-javascript'
|
||||
import {defaultKeymap, history, historyKeymap} from '@codemirror/commands'
|
||||
import {defaultHighlightStyle, syntaxHighlighting} from '@codemirror/language'
|
||||
import {useTabPersistence} from '../composables/useTabPersistence'
|
||||
|
||||
// ==================== Props & Events ====================
|
||||
const props = defineProps({
|
||||
currentConnection: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['execute', 'execute-selected', 'format'])
|
||||
|
||||
// 常量配置
|
||||
const STORAGE_KEY_EDITOR_CONTENT = 'db-cli:editor-content'
|
||||
|
||||
// 标签页持久化
|
||||
const tabPersistence = useTabPersistence()
|
||||
|
||||
// 数据库类型配置
|
||||
const DB_CONFIG = {
|
||||
mysql: {
|
||||
language: () => sql(),
|
||||
defaultContent: 'select 1;',
|
||||
executeText: '执行'
|
||||
},
|
||||
redis: {
|
||||
language: () => javascript({ jsx: false, typescript: false }),
|
||||
defaultContent: 'GET key\nSET key value\nHGET hash field',
|
||||
executeText: '执行命令'
|
||||
},
|
||||
mongo: {
|
||||
language: () => javascript({ jsx: false, typescript: false }),
|
||||
defaultContent: 'db.collection.find({})\n// 示例:db.users.find({name: "John"})',
|
||||
executeText: '执行查询'
|
||||
},
|
||||
mongodb: {
|
||||
language: () => 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 = (dbType = null) => getDbConfig(dbType).language()
|
||||
const getDefaultContent = (dbType = null) => getDbConfig(dbType).defaultContent
|
||||
const getExecuteButtonText = () => getDbConfig().executeText
|
||||
|
||||
// ==================== 编辑器管理 ====================
|
||||
const editorContainerRef = ref(null)
|
||||
let editorView = null
|
||||
let saveTimer = null
|
||||
|
||||
// 创建编辑器扩展
|
||||
const createEditorExtensions = () => {
|
||||
const dbType = getDbType()
|
||||
const languageMode = 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: 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 = () => {
|
||||
const editor = validateEditor()
|
||||
if (!editor) return
|
||||
|
||||
const content = editor.state.doc.toString().trim()
|
||||
if (!content) {
|
||||
Message.warning('SQL 语句不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
emit('execute', 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
|
||||
}
|
||||
|
||||
emit('execute-selected', 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()
|
||||
})
|
||||
|
||||
// ==================== 暴露方法 ====================
|
||||
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>
|
||||
182
web/src/views/db-cli/components/SqlPreviewDialog.vue
Normal file
182
web/src/views/db-cli/components/SqlPreviewDialog.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<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 } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { sql } from '@codemirror/lang-sql'
|
||||
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
|
||||
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 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>
|
||||
39
web/src/views/db-cli/components/result/MessageLog.vue
Normal file
39
web/src/views/db-cli/components/result/MessageLog.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<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>
|
||||
77
web/src/views/db-cli/components/result/README.md
Normal file
77
web/src/views/db-cli/components/result/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 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 变成轻量的标签页容器
|
||||
- 只负责标签切换和状态管理
|
||||
73
web/src/views/db-cli/components/result/ResultJson.vue
Normal file
73
web/src/views/db-cli/components/result/ResultJson.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="result-json-container">
|
||||
<pre class="result-json" v-html="highlightedJson"></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
data: any[]
|
||||
}>()
|
||||
|
||||
// 转义 HTML
|
||||
const escapeHtml = (str: string) => str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// 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>
|
||||
41
web/src/views/db-cli/components/result/ResultStats.vue
Normal file
41
web/src/views/db-cli/components/result/ResultStats.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<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>
|
||||
126
web/src/views/db-cli/components/result/ResultTab.vue
Normal file
126
web/src/views/db-cli/components/result/ResultTab.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<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>
|
||||
227
web/src/views/db-cli/components/result/ResultTable.vue
Normal file
227
web/src/views/db-cli/components/result/ResultTable.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<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>
|
||||
10
web/src/views/db-cli/components/result/index.ts
Normal file
10
web/src/views/db-cli/components/result/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 结果展示组件导出
|
||||
*/
|
||||
|
||||
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'
|
||||
21
web/src/views/db-cli/components/result/types.ts
Normal file
21
web/src/views/db-cli/components/result/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 结果展示组件类型定义
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user