530 lines
14 KiB
Vue
530 lines
14 KiB
Vue
<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>
|