更新数据库管理
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
李琦
2026-01-23 17:13:48 +08:00
parent 82fb4e78e3
commit eae4896e48
22 changed files with 3635 additions and 82 deletions

View File

@@ -39,6 +39,14 @@ export async function generationApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}generation`, data);
}
/**
* 批量代码生成
* @param data 数组格式的代码生成数据
*/
export async function batchGenerationApi(data: Record<string, any>[]) {
return requestClient.post<any>(`${prefix}batch-generation`, data);
}
/**
* 下载压缩包信息
*/
@@ -52,3 +60,26 @@ export async function downloadInfoApi(id: any) {
export async function codeGenerationDownloadApi(id: any) {
return requestClient.download(`${prefix}download?id=${id}`);
}
/**
* 获取数据库表列表
*/
export async function getTablesApi() {
return requestClient.get<any>(`${prefix}get-tables`);
}
/**
* 获取表结构
* @param tableName 表名
*/
export async function getTableStructureApi(tableName: string) {
return requestClient.get<any>(`${prefix}get-table-structure`, { params: { table_name: tableName } });
}
/**
* 从数据库表生成代码生成数据
* @param data 包含表名和其他配置的数据
*/
export async function generateFromTableApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}generate-from-table`, data);
}

View File

@@ -0,0 +1,422 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { Button, Card, Input, message, Select, Table } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { codeGenerationForm } from '#/views/code-generation/config/form';
import type { CodeGenData } from '../utils/data-transformer';
import { validateData } from '../utils/data-transformer';
import {
generateFromTableApi,
getTableStructureApi,
getTablesApi,
} from '../api';
const emit = defineEmits<{
generate: [data: CodeGenData];
back: [];
}>();
// 表单相关
const [Form, formApi] = useVbenForm(codeGenerationForm);
const formData = ref<Record<string, any>>({
class_name: '',
class_comment: '',
icon: '',
sort: 9999,
pid: 0,
});
// 表列表
const tableList = ref<any[]>([]);
const tableLoading = ref(false);
const searchKeyword = ref('');
const selectedTable = ref<string>('');
// 表结构数据
const tableStructure = ref<CodeGenData | null>(null);
const structureLoading = ref(false);
// 过滤后的表列表
const filteredTableList = computed(() => {
if (!searchKeyword.value) {
return tableList.value;
}
return tableList.value.filter(
(table) =>
table.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
(table.comment && table.comment.toLowerCase().includes(searchKeyword.value.toLowerCase())),
);
});
// 加载表列表
const loadTableList = async () => {
tableLoading.value = true;
try {
const res = await getTablesApi();
tableList.value = res || [];
} catch (error: any) {
message.error(error.message || '加载表列表失败');
tableList.value = [];
} finally {
tableLoading.value = false;
}
};
// 选择表
const handleSelectTable = async (tableName: string) => {
if (!tableName) {
selectedTable.value = '';
tableStructure.value = null;
formApi.resetFields();
return;
}
selectedTable.value = tableName;
structureLoading.value = true;
try {
// 获取表结构
const structureRes = await getTableStructureApi(tableName);
const fields = structureRes || [];
// 生成类名(从表名转换)
const prefix = 'nl_';
let className = tableName.replace(prefix, '');
className = className
.split('_')
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
// 获取表注释作为中文名称
const tableInfo = tableList.value.find((t) => t.name === tableName);
const classComment = tableInfo?.comment || tableName;
// 构建代码生成数据
tableStructure.value = {
class_name: className,
class_comment: classComment,
icon: '',
sort: 9999,
pid: 0,
field: fields,
};
// 更新表单数据
formData.value = {
class_name: className,
class_comment: classComment,
icon: '',
sort: 9999,
pid: 0,
};
formApi.setValues(formData.value);
} catch (error: any) {
message.error(error.message || '获取表结构失败');
tableStructure.value = null;
} finally {
structureLoading.value = false;
}
};
// 生成代码
const handleGenerate = async () => {
if (!selectedTable.value) {
message.warning('请先选择数据库表');
return;
}
if (!tableStructure.value) {
message.warning('表结构数据未加载,请重试');
return;
}
// 验证数据
const validation = validateData(tableStructure.value);
if (!validation.valid) {
message.error(validation.message);
return;
}
// 合并表单数据(用户可能修改了基础信息)
const formValues = await formApi.getValues();
const finalData: CodeGenData = {
...tableStructure.value,
class_name: formValues.class_name || tableStructure.value.class_name,
class_comment: formValues.class_comment || tableStructure.value.class_comment,
icon: formValues.icon || tableStructure.value.icon,
sort: formValues.sort ?? tableStructure.value.sort,
pid: formValues.pid ?? tableStructure.value.pid,
};
emit('generate', finalData);
};
// 表列配置
const tableColumns = [
{
title: '表名',
dataIndex: 'name',
key: 'name',
},
{
title: '注释',
dataIndex: 'comment',
key: 'comment',
},
{
title: '记录数',
dataIndex: 'rows',
key: 'rows',
width: 100,
},
];
onMounted(() => {
loadTableList();
});
</script>
<template>
<div class="database-select">
<!-- 顶部操作栏 -->
<Card class="mb-4">
<template #title>
<div class="flex items-center gap-2">
<Button type="text" @click="emit('back')">
<svg
class="mr-2 inline-block"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.75 3.5L5.25 7L8.75 10.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
返回
</Button>
<span class="text-lg font-semibold">数据库表模式</span>
</div>
</template>
<div class="mb-4">
<Input
v-model:value="searchKeyword"
placeholder="搜索表名或注释..."
allow-clear
class="mb-4"
>
<template #prefix>
<i class="fa-solid fa-search"></i>
</template>
</Input>
<div class="mb-4">
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
选择数据库表
</div>
<Select
v-model:value="selectedTable"
placeholder="请选择数据库表"
show-search
:filter-option="false"
style="width: 100%"
:loading="tableLoading"
@change="handleSelectTable"
>
<Select.Option
v-for="table in filteredTableList"
:key="table.name"
:value="table.name"
>
<div class="flex items-center justify-between">
<span>{{ table.name }}</span>
<span class="ml-2 text-xs text-gray-400">
{{ table.comment || '-' }}
</span>
</div>
</Select.Option>
</Select>
</div>
<div v-if="selectedTable" class="rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-400">
<i class="fa-solid fa-info-circle mr-2"></i>
已选择表<strong>{{ selectedTable }}</strong>
<span v-if="tableStructure">
{{ tableStructure.field?.length || 0 }} 个字段
</span>
</div>
</div>
</Card>
<!-- 内容区域 -->
<div v-if="tableStructure" class="content-area mb-24 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="基础信息" class="shadow-sm">
<Form />
</Card>
<Card title="字段预览" class="shadow-sm">
<div v-if="structureLoading" class="flex h-64 items-center justify-center">
<div class="text-center">
<i class="fa-solid fa-spinner fa-spin text-2xl text-primary mb-2"></i>
<p class="text-sm text-gray-500 dark:text-gray-400">加载表结构中...</p>
</div>
</div>
<div v-else class="max-h-[500px] overflow-auto">
<Table
:columns="[
{ title: '字段名', dataIndex: 'name', key: 'name' },
{ title: '类型', dataIndex: 'type', key: 'type' },
{ title: '长度', dataIndex: 'type_length', key: 'type_length' },
{ title: '注释', dataIndex: 'comment', key: 'comment' },
{ title: '必填', dataIndex: 'not_null', key: 'not_null', customRender: (text: any) => text ? '是' : '否' },
]"
:data-source="tableStructure.field"
:pagination="false"
size="small"
/>
</div>
<div class="mt-4 flex items-center gap-2 text-sm text-gray-500">
<i class="fa-solid fa-list mr-1"></i>
<span> {{ tableStructure.field?.length || 0 }} 个字段</span>
</div>
</Card>
</div>
<!-- 固定底部操作栏 -->
<div class="fixed-bottom-bar">
<div class="fixed-bottom-bar-wrapper">
<div class="fixed-bottom-bar-content">
<!-- 左侧状态信息 -->
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full transition-all"
:class="
tableStructure
? 'bg-primary/10 text-primary'
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600'
"
>
<i
:class="tableStructure ? 'fa-solid fa-check-circle' : 'fa-solid fa-circle-info'"
></i>
</div>
<div>
<div
class="text-sm font-medium"
:class="
tableStructure
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-500 dark:text-gray-400'
"
>
<span v-if="tableStructure">
已加载 <span class="text-primary font-semibold">{{ tableStructure.field?.length || 0 }}</span> 个字段
</span>
<span v-else>请先选择数据库表</span>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
{{ tableStructure ? '数据已就绪,可以生成代码' : '等待选择表...' }}
</div>
</div>
</div>
<!-- 右侧操作按钮 -->
<div class="flex items-center gap-2">
<Button
type="primary"
size="large"
class="action-btn-primary"
:disabled="!tableStructure"
@click="handleGenerate"
>
<i class="fa-solid fa-code mr-2"></i>生成代码
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.database-select {
animation: fade-slide-enter 0.3s ease-out;
padding-bottom: 120px;
}
.content-area {
min-height: 400px;
}
/* 固定底部操作栏 */
.fixed-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
padding: 0 24px 24px;
pointer-events: none;
}
.fixed-bottom-bar-wrapper {
max-width: 1400px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 16px;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: all;
}
.dark .fixed-bottom-bar-wrapper {
background: rgba(31, 41, 55, 0.98);
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.fixed-bottom-bar-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
gap: 24px;
}
.action-btn-primary {
border-radius: 8px;
font-weight: 600;
padding: 0 24px;
height: 44px;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
transition: all 0.2s ease;
}
.action-btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.4);
}
@keyframes fade-slide-enter {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>

View File

@@ -3,7 +3,7 @@ import type { UploadChangeParam, UploadFile } from 'ant-design-vue';
import { computed, onMounted, ref, watch } from 'vue';
import { Button, Card, Input, message, Select, Tabs, Upload } from 'ant-design-vue';
import { Button, Card, Checkbox, Input, message, Select, Tabs, Upload } from 'ant-design-vue';
import { JsonViewer } from '@vben/common-ui';
@@ -23,7 +23,7 @@ import {
const mode: GenerationMode = 'json';
const emit = defineEmits<{
generate: [data: CodeGenData];
generate: [data: CodeGenData | CodeGenData[]];
back: [];
}>();
@@ -34,7 +34,9 @@ const formData = ref<Record<string, any>>({});
// JSON输入
const jsonText = ref('');
const jsonError = ref('');
const parsedData = ref<CodeGenData | null>(null);
const parsedData = ref<CodeGenData | CodeGenData[] | null>(null);
const isBatchMode = ref(false); // 是否为批量模式
const selectedModules = ref<number[]>([]); // 批量模式下选中的模块索引
const activeTab = ref('text');
// 文件上传相关
@@ -49,6 +51,9 @@ const selectedDraft = ref<string>('');
// 预览数据
const previewData = computed(() => {
if (parsedData.value) {
if (Array.isArray(parsedData.value)) {
return parsedData.value;
}
return {
...parsedData.value,
field: parsedData.value.field || [],
@@ -57,6 +62,19 @@ const previewData = computed(() => {
return null;
});
// 批量模式下的模块列表
const batchModules = computed(() => {
if (Array.isArray(parsedData.value)) {
return parsedData.value.map((item, index) => ({
index,
class_name: item.class_name,
class_comment: item.class_comment,
fieldCount: item.field?.length || 0,
}));
}
return [];
});
// 加载草稿列表
const loadDraftList = () => {
drafts.value = listDrafts(mode);
@@ -96,26 +114,42 @@ const parseJson = () => {
jsonError.value = '';
if (!jsonText.value.trim()) {
parsedData.value = null;
isBatchMode.value = false;
selectedModules.value = [];
return;
}
try {
const json = JSON.parse(jsonText.value);
const transformed = transformJsonToStandard(json);
parsedData.value = transformed;
// 更新表单数据
formData.value = {
class_name: transformed.class_name,
class_comment: transformed.class_comment,
icon: transformed.icon,
sort: transformed.sort,
pid: transformed.pid,
};
formApi.setValues(formData.value);
// 判断是否为数组(批量模式)
if (Array.isArray(transformed)) {
isBatchMode.value = true;
parsedData.value = transformed;
// 默认全选
selectedModules.value = transformed.map((_, index) => index);
formApi.resetFields();
} else {
isBatchMode.value = false;
parsedData.value = transformed;
selectedModules.value = [];
// 更新表单数据(单个模式)
formData.value = {
class_name: transformed.class_name,
class_comment: transformed.class_comment,
icon: transformed.icon,
sort: transformed.sort,
pid: transformed.pid,
};
formApi.setValues(formData.value);
}
} catch (error: any) {
jsonError.value = error.message || 'JSON格式错误';
parsedData.value = null;
isBatchMode.value = false;
selectedModules.value = [];
}
};
@@ -223,6 +257,8 @@ const formatJson = () => {
const clearJson = () => {
jsonText.value = '';
parsedData.value = null;
isBatchMode.value = false;
selectedModules.value = [];
formData.value = {};
formApi.resetFields();
draftId.value = null;
@@ -238,25 +274,49 @@ const handleGenerate = async () => {
return;
}
// 验证数据
const validation = validateData(parsedData.value);
if (!validation.valid) {
message.error(validation.message);
// 批量模式
if (isBatchMode.value && Array.isArray(parsedData.value)) {
if (selectedModules.value.length === 0) {
message.warning('请至少选择一个模块进行生成');
return;
}
// 验证选中的模块
const selectedData = selectedModules.value.map(index => parsedData.value![index]);
for (const item of selectedData) {
const validation = validateData(item);
if (!validation.valid) {
message.error(`模块 "${item.class_comment || item.class_name}" 验证失败:${validation.message}`);
return;
}
}
emit('generate', selectedData);
return;
}
// 合并表单数据(用户可能修改了基础信息)
const formValues = await formApi.getValues();
const finalData: CodeGenData = {
...parsedData.value,
class_name: formValues.class_name || parsedData.value.class_name,
class_comment: formValues.class_comment || parsedData.value.class_comment,
icon: formValues.icon || parsedData.value.icon,
sort: formValues.sort ?? parsedData.value.sort,
pid: formValues.pid ?? parsedData.value.pid,
};
// 单个模式
if (!isBatchMode.value && !Array.isArray(parsedData.value)) {
// 验证数据
const validation = validateData(parsedData.value);
if (!validation.valid) {
message.error(validation.message);
return;
}
emit('generate', finalData);
// 合并表单数据(用户可能修改了基础信息)
const formValues = await formApi.getValues();
const finalData: CodeGenData = {
...parsedData.value,
class_name: formValues.class_name || parsedData.value.class_name,
class_comment: formValues.class_comment || parsedData.value.class_comment,
icon: formValues.icon || parsedData.value.icon,
sort: formValues.sort ?? parsedData.value.sort,
pid: formValues.pid ?? parsedData.value.pid,
};
emit('generate', finalData);
}
};
// 删除草稿
@@ -387,7 +447,68 @@ onMounted(() => {
</Card>
<!-- 内容区域 -->
<div v-if="parsedData" class="content-area mb-24 grid grid-cols-1 gap-4 lg:grid-cols-2">
<!-- 批量模式 -->
<div v-if="isBatchMode && Array.isArray(parsedData)" class="content-area mb-24">
<Card title="批量生成模块列表" class="shadow-sm mb-4">
<div class="mb-4 flex items-center justify-between">
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ batchModules.length }} 个模块已选择 {{ selectedModules.length }}
</div>
<div class="flex gap-2">
<Button size="small" @click="selectedModules = batchModules.map(m => m.index)">
全选
</Button>
<Button size="small" @click="selectedModules = []">
取消全选
</Button>
</div>
</div>
<div class="space-y-2 max-h-[500px] overflow-auto">
<div
v-for="module in batchModules"
:key="module.index"
class="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
:class="selectedModules.includes(module.index) ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-700'"
>
<div class="flex items-center gap-3 flex-1">
<Checkbox
:checked="selectedModules.includes(module.index)"
@change="(e: any) => {
if (e.target.checked) {
if (!selectedModules.includes(module.index)) {
selectedModules.push(module.index);
}
} else {
selectedModules = selectedModules.filter(i => i !== module.index);
}
}"
/>
<div class="flex-1">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ module.class_comment || module.class_name || `模块 ${module.index + 1}` }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1">
类名: {{ module.class_name }} | 字段数: {{ module.fieldCount }}
</div>
</div>
</div>
</div>
</div>
</Card>
<Card title="模块预览" class="shadow-sm">
<div class="max-h-[500px] overflow-auto">
<JsonViewer
:value="previewData"
:expand-depth="2"
copyable
boxed
/>
</div>
</Card>
</div>
<!-- 单个模式 -->
<div v-else-if="parsedData && !isBatchMode" class="content-area mb-24 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="基础信息" class="shadow-sm">
<Form />
</Card>
@@ -403,7 +524,7 @@ onMounted(() => {
</div>
<div class="mt-4 flex items-center gap-2 text-sm text-gray-500">
<i class="fa-solid fa-list mr-1"></i>
<span> {{ parsedData.field?.length || 0 }} 个字段</span>
<span> {{ (parsedData as CodeGenData).field?.length || 0 }} 个字段</span>
</div>
</Card>
</div>
@@ -436,12 +557,30 @@ onMounted(() => {
"
>
<span v-if="parsedData">
已解析 <span class="text-primary font-semibold">{{ parsedData.field?.length || 0 }}</span> 个字段
<template v-if="isBatchMode">
已解析 <span class="text-primary font-semibold">{{ batchModules.length }}</span> 个模块
<span v-if="selectedModules.length > 0" class="ml-2">
(已选择 {{ selectedModules.length }} )
</span>
</template>
<template v-else>
已解析 <span class="text-primary font-semibold">{{ (parsedData as CodeGenData).field?.length || 0 }}</span> 个字段
</template>
</span>
<span v-else>请先导入JSON数据</span>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
{{ parsedData ? '数据已就绪,可以生成代码' : '等待数据导入...' }}
<template v-if="parsedData">
<template v-if="isBatchMode">
{{ selectedModules.length > 0 ? `已选择 ${selectedModules.length} 个模块,可以批量生成` : '请选择要生成的模块' }}
</template>
<template v-else>
数据已就绪可以生成代码
</template>
</template>
<template v-else>
等待数据导入...
</template>
</div>
</div>
</div>
@@ -467,10 +606,11 @@ onMounted(() => {
type="primary"
size="large"
class="action-btn-primary"
:disabled="!parsedData"
:disabled="!parsedData || (isBatchMode && selectedModules.length === 0)"
@click="handleGenerate"
>
<i class="fa-solid fa-code mr-2"></i>生成代码
<i class="fa-solid fa-code mr-2"></i>
{{ isBatchMode ? `批量生成 (${selectedModules.length})` : '生成代码' }}
</Button>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
export type GenerationMode = 'json' | 'manual' | 'ai';
export type GenerationMode = 'json' | 'manual' | 'ai' | 'database';
interface ModeOption {
mode: GenerationMode;
@@ -26,6 +26,14 @@ const modes: ModeOption[] = [
borderGradient:
'linear-gradient(90deg, #f59e0b, #f97316, #fb923c, #f97316, #f59e0b)',
},
{
mode: 'database',
title: '数据库表',
description: '从现有数据库表自动生成CRUD代码',
iconColor: '#3b82f6',
borderGradient:
'linear-gradient(90deg, #3b82f6, #2563eb, #1d4ed8, #2563eb, #3b82f6)',
},
{
mode: 'ai',
title: 'AI提示词',
@@ -47,7 +55,7 @@ const handleSelect = (mode: GenerationMode) => {
<template>
<div class="mode-selector">
<div class="grid grid-cols-1 gap-8 md:grid-cols-3">
<div class="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-4">
<div
v-for="(item, index) in modes"
:key="item.mode"
@@ -221,6 +229,57 @@ const handleSelect = (mode: GenerationMode) => {
/>
</svg>
<!-- 数据库表图标 -->
<svg
v-else-if="item.mode === 'database'"
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
:style="{ color: item.iconColor }"
>
<rect
x="8"
y="12"
width="32"
height="24"
rx="2"
fill="currentColor"
fill-opacity="0.1"
/>
<rect
x="8"
y="12"
width="32"
height="24"
rx="2"
stroke="currentColor"
stroke-width="2.5"
/>
<line
x1="8"
y1="20"
x2="40"
y2="20"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
<line
x1="8"
y1="28"
x2="40"
y2="28"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
<circle cx="16" cy="16" r="2" fill="currentColor" />
<circle cx="16" cy="24" r="2" fill="currentColor" />
<circle cx="16" cy="32" r="2" fill="currentColor" />
</svg>
<!-- AI图标 -->
<svg
v-else-if="item.mode === 'ai'"

View File

@@ -9,6 +9,7 @@ import { JsonViewer } from '@vben/common-ui';
import { downloadByData } from '#/util/tool';
import {
batchGenerationApi,
codeGenerationDownloadApi,
generationApi,
getCodeGenerationListApi,
@@ -18,6 +19,7 @@ import type { CodeGenData } from './utils/data-transformer';
import type { GenerationMode } from './utils/draft-manager';
import AiPrompt from './components/ai-prompt.vue';
import DatabaseSelect from './components/database-select.vue';
import JsonImport from './components/json-import.vue';
import ManualForm from './components/manual-form.vue';
import ModeSelector from './components/mode-selector.vue';
@@ -51,6 +53,9 @@ const JsonImportComponent = defineAsyncComponent(() =>
const ManualFormComponent = defineAsyncComponent(() =>
Promise.resolve(ManualForm),
);
const DatabaseSelectComponent = defineAsyncComponent(() =>
Promise.resolve(DatabaseSelect),
);
const AiPromptComponent = defineAsyncComponent(() =>
Promise.resolve(AiPrompt),
);
@@ -62,6 +67,7 @@ const currentMode = ref<GenerationMode | null>(null);
const modeTitles: Record<GenerationMode, string> = {
json: 'JSON导入模式',
manual: '手动填写模式',
database: '数据库表模式',
ai: 'AI提示词模式',
};
@@ -75,25 +81,51 @@ const handleBack = () => {
currentMode.value = null;
};
// 生成代码
const handleGenerate = async (data: CodeGenData) => {
// 生成代码(支持单个或批量)
const handleGenerate = async (data: CodeGenData | CodeGenData[]) => {
try {
const res = await generationApi(data);
confirm({
cancelText: '迟点下载',
confirmText: '是的,马上下载',
content:
'您的模块已经生成成功,是否立即下载?\r\n' +
'后端代码已经自动生成:\r\n' +
'Controller、Service、Model、Route、Mysql数据表无需您操作。\r\n' +
'您只需要把代码黏贴至 "您的根目录/apps/web-antd/src/views/my-gen"文件夹下即可使用',
icon: 'success',
title: '代码生成成功!',
}).then(() => {
codeGenerationDownloadApi(res.id).then((blob) => {
downloadByData(blob, res.file_name, 'application/zip');
// 判断是否为数组(批量生成)
if (Array.isArray(data)) {
const res = await batchGenerationApi(data);
confirm({
cancelText: '迟点下载',
confirmText: '查看生成结果',
content:
`已成功生成 ${res.length} 个模块!\r\n` +
'后端代码已经自动生成:\r\n' +
'Controller、Service、Model、Route、Mysql数据表无需您操作。\r\n' +
'您只需要把代码黏贴至 "您的根目录/apps/web-antd/src/views/my-gen"文件夹下即可使用',
icon: 'success',
title: '批量代码生成成功!',
}).then(() => {
// 批量下载所有生成的模块
res.forEach((item: any, index: number) => {
setTimeout(() => {
codeGenerationDownloadApi(item.id).then((blob) => {
downloadByData(blob, item.file_name, 'application/zip');
});
}, index * 500); // 延迟下载避免浏览器阻止
});
});
});
} else {
// 单个生成
const res = await generationApi(data);
confirm({
cancelText: '迟点下载',
confirmText: '是的,马上下载',
content:
'您的模块已经生成成功,是否立即下载?\r\n' +
'后端代码已经自动生成:\r\n' +
'Controller、Service、Model、Route、Mysql数据表无需您操作。\r\n' +
'您只需要把代码黏贴至 "您的根目录/apps/web-antd/src/views/my-gen"文件夹下即可使用',
icon: 'success',
title: '代码生成成功!',
}).then(() => {
codeGenerationDownloadApi(res.id).then((blob) => {
downloadByData(blob, res.file_name, 'application/zip');
});
});
}
// 生成成功后刷新历史记录
if (historyDrawerVisible.value) {
loadHistoryList();
@@ -201,6 +233,14 @@ const handleViewJson = (item: GenerationHistory) => {
@back="handleBack"
/>
<!-- 数据库表模式 -->
<DatabaseSelectComponent
v-else-if="currentMode === 'database'"
key="database"
@generate="handleGenerate"
@back="handleBack"
/>
<!-- AI提示词模式 -->
<AiPromptComponent
v-else-if="currentMode === 'ai'"

View File

@@ -45,9 +45,62 @@ const createDefaultField = (): FieldType => ({
/**
* JSON模式数据转换
* 支持单个对象或对象数组(批量生成)
*/
export function transformJsonToStandard(json: any): CodeGenData {
// 如果JSON包含完整结构直接使用
export function transformJsonToStandard(json: any): CodeGenData | CodeGenData[] {
// 如果输入是数组(批量生成多个模块)
if (Array.isArray(json) && json.length > 0) {
// 检查第一个元素是否是完整的模块配置
if (json[0] && json[0].class_name && json[0].field) {
// 数组中的每个元素都是完整的模块配置
return json.map((item: any) => ({
class_name: item.class_name || '',
class_comment: item.class_comment || '',
icon: item.icon || '',
sort: item.sort ?? 9999,
pid: item.pid ?? 0,
field: Array.isArray(item.field)
? item.field.map((f: any) => ({
name: f.name || '',
type: f.type || 'varchar',
type_length: f.type_length || '',
default: f.default || '',
comment: f.comment || '',
not_null: f.not_null !== undefined ? f.not_null : true,
formShow: f.formShow !== undefined ? f.formShow : true,
tableShow: f.tableShow !== undefined ? f.tableShow : true,
formType: f.formType || 'VbenInput',
search: f.search !== undefined ? f.search : true,
searchValue: f.searchValue || '=',
}))
: [],
}));
} else {
// 数组中的元素只是字段数组,需要用户补充基础信息
return {
class_name: '',
class_comment: '',
icon: '',
sort: 9999,
pid: 0,
field: json.map((f: any) => ({
name: f.name || '',
type: f.type || 'varchar',
type_length: f.type_length || '',
default: f.default || '',
comment: f.comment || '',
not_null: f.not_null !== undefined ? f.not_null : true,
formShow: f.formShow !== undefined ? f.formShow : true,
tableShow: f.tableShow !== undefined ? f.tableShow : true,
formType: f.formType || 'VbenInput',
search: f.search !== undefined ? f.search : true,
searchValue: f.searchValue || '=',
})),
};
}
}
// 如果JSON包含完整结构直接使用单个模块
if (json.class_name && json.field) {
return {
class_name: json.class_name || '',
@@ -73,30 +126,6 @@ export function transformJsonToStandard(json: any): CodeGenData {
};
}
// 如果只有字段数组,需要用户补充基础信息
if (Array.isArray(json)) {
return {
class_name: '',
class_comment: '',
icon: '',
sort: 9999,
pid: 0,
field: json.map((f: any) => ({
name: f.name || '',
type: f.type || 'varchar',
type_length: f.type_length || '',
default: f.default || '',
comment: f.comment || '',
not_null: f.not_null !== undefined ? f.not_null : true,
formShow: f.formShow !== undefined ? f.formShow : true,
tableShow: f.tableShow !== undefined ? f.tableShow : true,
formType: f.formType || 'VbenInput',
search: f.search !== undefined ? f.search : true,
searchValue: f.searchValue || '=',
})),
};
}
// 默认返回空结构
return {
class_name: '',

View File

@@ -0,0 +1,114 @@
import { requestClient } from '#/api/request';
const prefix = 'database/';
/**
* 获取表列表
*/
export async function getTableListApi() {
return requestClient.get<any>(`${prefix}list-tables`);
}
/**
* 获取表详细信息
* @param tableName 表名
*/
export async function getTableInfoApi(tableName: string) {
return requestClient.get<any>(`${prefix}get-table-info`, { params: { table_name: tableName } });
}
/**
* 获取表数据(分页)
* @param data 查询参数
*/
export async function getTableDataApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}get-table-data`, data);
}
/**
* 更新表数据
* @param data 更新参数
*/
export async function updateTableDataApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update-table-data`, data);
}
/**
* 删除表数据
* @param data 删除参数
*/
export async function deleteTableDataApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete-table-data`, data);
}
/**
* 插入表数据
* @param data 插入参数
*/
export async function insertTableDataApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}insert-table-data`, data);
}
/**
* 更新表注释
* @param data 更新参数
*/
export async function updateTableCommentApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update-table-comment`, data);
}
/**
* 更新字段注释
* @param data 更新参数
*/
export async function updateColumnCommentApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update-column-comment`, data);
}
/**
* 添加索引
* @param data 添加参数
*/
export async function addIndexApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}add-index`, data);
}
/**
* 删除索引
* @param data 删除参数
*/
export async function dropIndexApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}drop-index`, data);
}
/**
* 修改表结构
* @param data 修改参数
*/
export async function updateTableStructureApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update-table-structure`, data);
}
/**
* 批量删除表数据
* @param data 删除参数
*/
export async function batchDeleteTableDataApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}batch-delete-table-data`, data);
}
/**
* 执行SQL查询仅SELECT
* @param data 查询参数
*/
export async function executeSqlQueryApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}execute-sql-query`, data);
}
/**
* 获取变更记录列表
* @param params 查询参数
*/
export async function getChangeLogApi(params: Record<string, any>) {
return requestClient.get<any>(`${prefix}get-change-log`, { params });
}

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { updateTableStructureApi } from '../api';
defineOptions({
name: 'AddColumnModal',
});
const tableName = ref('');
const columns = ref<any[]>([]);
const reloadCallback = ref<(() => void) | null>(null);
// 字段类型选项
const fieldTypeOptions = [
{ label: 'VARCHAR', value: 'VARCHAR' },
{ label: 'CHAR', value: 'CHAR' },
{ label: 'TEXT', value: 'TEXT' },
{ label: 'LONGTEXT', value: 'LONGTEXT' },
{ label: 'INT', value: 'INT' },
{ label: 'BIGINT', value: 'BIGINT' },
{ label: 'TINYINT', value: 'TINYINT' },
{ label: 'SMALLINT', value: 'SMALLINT' },
{ label: 'DECIMAL', value: 'DECIMAL' },
{ label: 'FLOAT', value: 'FLOAT' },
{ label: 'DOUBLE', value: 'DOUBLE' },
{ label: 'DATETIME', value: 'DATETIME' },
{ label: 'DATE', value: 'DATE' },
{ label: 'TIMESTAMP', value: 'TIMESTAMP' },
{ label: 'TIME', value: 'TIME' },
{ label: 'YEAR', value: 'YEAR' },
{ label: 'BOOLEAN', value: 'TINYINT' },
];
// 需要长度的类型
const typesNeedLength = ['VARCHAR', 'CHAR', 'INT', 'BIGINT', 'TINYINT', 'SMALLINT', 'DECIMAL', 'FLOAT', 'DOUBLE'];
// 位置选项AFTER字段
const positionOptions = computed(() => {
if (!columns.value) return [];
return columns.value.map((col) => ({
label: `${col.name}${col.comment ? ` (${col.comment})` : ''}`,
value: col.name,
}));
});
// 表单schema
const formSchema = computed(() => {
const baseSchema = [
{
component: 'Input',
fieldName: 'name',
label: '字段名',
formItemClass: 'col-span-12',
componentProps: {
placeholder: '请输入字段名(小写字母、数字、下划线)',
},
rules: 'required',
},
{
component: 'Select',
fieldName: 'type',
label: '字段类型',
formItemClass: 'col-span-12',
componentProps: {
options: fieldTypeOptions,
placeholder: '请选择字段类型',
},
rules: 'required',
},
];
// 根据类型动态添加长度字段
const lengthField = {
component: 'Input',
fieldName: 'length',
label: '长度/精度',
formItemClass: 'col-span-12',
componentProps: {
placeholder: '例如: 255 或 10,2 (DECIMAL类型)',
},
dependencies: {
show: {
triggerFields: ['type'],
condition: (values: any) => {
const type = values.type || '';
return typesNeedLength.includes(type.toUpperCase());
},
},
},
};
const afterField = {
component: 'Select',
fieldName: 'after',
label: '位置AFTER字段',
formItemClass: 'col-span-12',
componentProps: {
options: positionOptions.value,
placeholder: '选择字段位置,留空则添加到表末尾',
allowClear: true,
},
};
return [
...baseSchema,
lengthField,
{
component: 'Input',
fieldName: 'default_value',
label: '默认值',
formItemClass: 'col-span-12',
componentProps: {
placeholder: '请输入默认值,留空表示无默认值',
},
},
{
component: 'Switch',
fieldName: 'nullable',
label: '允许NULL',
formItemClass: 'col-span-12',
},
{
component: 'Textarea',
fieldName: 'comment',
label: '字段注释',
formItemClass: 'col-span-12',
componentProps: {
rows: 3,
placeholder: '请输入字段注释',
},
},
afterField,
];
});
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: formSchema,
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const valid = await formApi.validate();
if (valid.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
// 构建column_info
const columnInfo: any = {
name: values.name,
type: values.type,
};
// 处理长度
if (values.length && typesNeedLength.includes(values.type.toUpperCase())) {
columnInfo.length = values.length;
}
// 处理默认值
if (values.default_value !== null && values.default_value !== undefined && values.default_value !== '') {
columnInfo.default_value = values.default_value;
}
// 处理NULL
columnInfo.nullable = values.nullable || false;
// 处理注释
if (values.comment) {
columnInfo.comment = values.comment;
}
// 处理位置
if (values.after) {
columnInfo.after = values.after;
}
await updateTableStructureApi({
table_name: tableName.value,
action: 'add',
column_info: columnInfo,
});
message.success('添加成功');
reloadCallback.value?.();
modalApi.close();
} catch (error: any) {
message.error(error.message || '添加失败');
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{
tableName: string;
columns: any[];
reload: () => void;
}>();
if (data) {
tableName.value = data.tableName;
columns.value = data.columns;
reloadCallback.value = data.reload;
formApi.resetFields();
}
}
},
});
</script>
<template>
<Modal title="添加字段" class="w-[600px]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { addIndexApi } from '../api';
defineOptions({
name: 'AddIndexModal',
});
const tableName = ref('');
const columns = ref<any[]>([]);
const reloadCallback = ref<(() => void) | null>(null);
const columnOptions = computed(() => {
return columns.value.map((col) => ({
label: col.comment || col.name,
value: col.name,
}));
});
// 索引类型选项
const indexTypeOptions = [
{ label: 'BTREE', value: 'BTREE' },
{ label: 'HASH', value: 'HASH' },
{ label: 'FULLTEXT', value: 'FULLTEXT' },
{ label: 'SPATIAL', value: 'SPATIAL' },
];
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'Input',
fieldName: 'name',
label: '索引名',
formItemClass: 'col-span-12',
componentProps: {
placeholder: '请输入索引名',
},
rules: 'required',
},
{
component: 'Select',
fieldName: 'columns',
label: '选择字段',
formItemClass: 'col-span-12',
componentProps: {
mode: 'multiple',
placeholder: '请选择字段',
options: columnOptions,
},
rules: 'required',
},
{
component: 'Switch',
fieldName: 'unique',
label: '唯一索引',
formItemClass: 'col-span-12',
},
{
component: 'Select',
fieldName: 'type',
label: '索引类型',
formItemClass: 'col-span-12',
componentProps: {
options: indexTypeOptions,
placeholder: '请选择索引类型默认BTREE',
},
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const valid = await formApi.validate();
if (valid.valid) {
const values = await formApi.getValues();
if (!values.columns || values.columns.length === 0) {
message.warning('请选择字段');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
await addIndexApi({
table_name: tableName.value,
index_name: values.name,
columns: values.columns,
unique: values.unique || false,
type: values.type || 'BTREE',
});
message.success('添加成功');
reloadCallback.value?.();
modalApi.close();
} catch (error: any) {
message.error(error.message || '添加失败');
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{
tableName: string;
columns: any[];
reload: () => void;
}>();
if (data) {
tableName.value = data.tableName;
columns.value = data.columns;
reloadCallback.value = data.reload;
formApi.resetFields();
}
}
},
});
</script>
<template>
<Modal title="添加索引" class="w-[500px]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,265 @@
<script setup lang="ts">
import { h, onMounted, ref } from 'vue';
import { Button, Card, message, Table, Tag } from 'ant-design-vue';
import { getChangeLogApi } from '../api';
defineOptions({
name: 'ChangeLog',
});
const loading = ref(false);
const changeLogList = ref<any[]>([]);
const pagination = ref({
current: 1,
pageSize: 20,
total: 0,
});
const filters = ref({
table_name: '',
operation_type: '',
start_time: '',
end_time: '',
});
// 操作类型选项
const operationTypeOptions = [
{ label: '全部', value: '' },
{ label: '结构变更', value: 'structure_change' },
{ label: '数据变更', value: 'data_change' },
{ label: 'SQL查询', value: 'sql_query' },
];
// 表列表从筛选条件获取实际应该从API获取
const tableList = ref<any[]>([]);
// 加载变更记录
const loadChangeLog = async (page = 1, pageSize = 20) => {
loading.value = true;
try {
const params: any = {
page,
page_size: pageSize,
};
if (filters.value.table_name) {
params.table_name = filters.value.table_name;
}
if (filters.value.operation_type) {
params.operation_type = filters.value.operation_type;
}
if (filters.value.start_time) {
params.start_time = filters.value.start_time;
}
if (filters.value.end_time) {
params.end_time = filters.value.end_time;
}
const res = await getChangeLogApi(params);
changeLogList.value = res.items || [];
pagination.value = {
current: res.page || 1,
pageSize: res.size || 20,
total: res.total || 0,
};
} catch (error: any) {
message.error(error.message || '加载变更记录失败');
changeLogList.value = [];
} finally {
loading.value = false;
}
};
// 操作类型标签颜色
const getOperationTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
structure_change: 'blue',
data_change: 'green',
sql_query: 'orange',
};
return colorMap[type] || 'default';
};
// 操作类型标签文本
const getOperationTypeText = (type: string) => {
const textMap: Record<string, string> = {
structure_change: '结构变更',
data_change: '数据变更',
sql_query: 'SQL查询',
};
return textMap[type] || type;
};
// 格式化时间
const formatTime = (timestamp: number) => {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString('zh-CN');
};
// 搜索
const handleSearch = () => {
loadChangeLog(1, pagination.value.pageSize);
};
// 重置
const handleReset = () => {
filters.value = {
table_name: '',
operation_type: '',
start_time: '',
end_time: '',
};
loadChangeLog(1, pagination.value.pageSize);
};
// 展开详情
const expandedRowKeys = ref<number[]>([]);
const getActionText = (action: string) => {
const actionText: Record<string, string> = {
update_table_comment: '更新表注释',
update_column_comment: '更新字段注释',
add_index: '添加索引',
drop_index: '删除索引',
add_column: '添加字段',
modify_column: '修改字段',
drop_column: '删除字段',
insert: '插入数据',
update: '更新数据',
delete: '删除数据',
batch_delete: '批量删除',
select_query: 'SELECT查询',
};
return actionText[action] || action;
};
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '表名', dataIndex: 'table_name', key: 'table_name', width: 150 },
{
title: '操作类型',
dataIndex: 'operation_type',
key: 'operation_type',
width: 120,
customRender: ({ text }: any) =>
h(Tag, { color: getOperationTypeColor(text) }, () => getOperationTypeText(text)),
},
{
title: '操作详情',
dataIndex: 'operation_detail',
key: 'operation_detail',
customRender: ({ record }: any) => {
const detail = record.operation_detail || {};
const action = detail.action || '';
return getActionText(action);
},
},
{ title: '操作用户', dataIndex: 'user_id', key: 'user_id', width: 100 },
{
title: '操作时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
customRender: ({ text }: any) => formatTime(text),
},
];
onMounted(() => {
loadChangeLog();
});
</script>
<template>
<div class="change-log">
<Card title="变更记录" class="mb-4">
<div class="mb-4 flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm">表名</span>
<input
v-model="filters.table_name"
type="text"
placeholder="请输入表名"
class="w-40 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<div class="flex items-center gap-2">
<span class="text-sm">操作类型</span>
<select
v-model="filters.operation_type"
class="w-32 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
>
<option v-for="option in operationTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="flex items-center gap-2">
<span class="text-sm">开始时间</span>
<input
v-model="filters.start_time"
type="date"
class="w-40 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<div class="flex items-center gap-2">
<span class="text-sm">结束时间</span>
<input
v-model="filters.end_time"
type="date"
class="w-40 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<div class="flex items-center gap-2">
<Button type="primary" @click="handleSearch">搜索</Button>
<Button @click="handleReset">重置</Button>
</div>
</div>
</Card>
<Table
:columns="columns"
:data-source="changeLogList"
:loading="loading"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
}"
:expanded-row-keys="expandedRowKeys"
@change="(p: any) => loadChangeLog(p.current, p.pageSize)"
>
<template #expandedRowRender="{ record }">
<div class="p-4 space-y-4">
<div v-if="record.sql_statement">
<div class="mb-2 font-semibold">SQL语句</div>
<pre class="rounded bg-gray-100 p-3 text-sm dark:bg-gray-800">{{ record.sql_statement }}</pre>
</div>
<div v-if="record.operation_detail">
<div class="mb-2 font-semibold">操作详情</div>
<pre class="rounded bg-gray-100 p-3 text-sm dark:bg-gray-800">{{ JSON.stringify(record.operation_detail, null, 2) }}</pre>
</div>
<div v-if="record.before_data" class="grid grid-cols-2 gap-4">
<div>
<div class="mb-2 font-semibold">变更前</div>
<pre class="max-h-64 overflow-auto rounded bg-gray-100 p-3 text-sm dark:bg-gray-800">{{ JSON.stringify(record.before_data, null, 2) }}</pre>
</div>
<div>
<div class="mb-2 font-semibold">变更后</div>
<pre class="max-h-64 overflow-auto rounded bg-gray-100 p-3 text-sm dark:bg-gray-800">{{ JSON.stringify(record.after_data, null, 2) }}</pre>
</div>
</div>
</div>
</template>
</Table>
</div>
</template>
<style scoped>
.space-y-4 > * + * {
margin-top: 1rem;
}
</style>

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Button, Card, Space } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
defineOptions({
name: 'DataSearch',
});
const props = defineProps<{
columns: any[];
modelValue: any[];
}>();
const emit = defineEmits<{
'update:modelValue': [value: any[]];
'search': [];
'reset': [];
}>();
const searchConditions = ref<any[]>([]);
// 操作符选项
const operatorOptions = [
{ label: '等于', value: '=' },
{ label: '不等于', value: '!=' },
{ label: '大于', value: '>' },
{ label: '大于等于', value: '>=' },
{ label: '小于', value: '<' },
{ label: '小于等于', value: '<=' },
{ label: '包含', value: 'like' },
{ label: '不包含', value: 'not like' },
{ label: '为空', value: 'is null' },
{ label: '不为空', value: 'is not null' },
{ label: '在范围内', value: 'in' },
{ label: '不在范围内', value: 'not in' },
{ label: '之间', value: 'between' },
];
// 字段选项
const fieldOptions = computed(() => {
return props.columns.map((col) => ({
label: `${col.name}${col.comment ? ` (${col.comment})` : ''}`,
value: col.name,
}));
});
// 添加查询条件
const addCondition = () => {
searchConditions.value.push({
field: '',
operator: '=',
value: '',
value2: '', // for BETWEEN
logic: searchConditions.value.length > 0 ? 'AND' : 'AND',
});
};
// 删除查询条件
const removeCondition = (index: number) => {
searchConditions.value.splice(index, 1);
};
// 搜索
const handleSearch = () => {
const conditions = searchConditions.value
.filter((cond) => cond.field && cond.operator)
.map((cond) => {
const condition: any = {
field: cond.field,
operator: cond.operator,
};
// 处理不需要值的操作符
if (['is null', 'is not null'].includes(cond.operator)) {
// 这些操作符不需要值
} else if (cond.operator === 'between') {
condition.value = [cond.value, cond.value2];
} else if (['in', 'not in'].includes(cond.operator)) {
// 将逗号分隔的字符串转换为数组
condition.value = cond.value
? cond.value
.split(',')
.map((v: string) => v.trim())
.filter((v: string) => v)
: [];
} else {
condition.value = cond.value;
}
if (cond.logic) {
condition.logic = cond.logic;
}
return condition;
});
emit('update:modelValue', conditions);
emit('search');
};
// 重置
const handleReset = () => {
searchConditions.value = [];
emit('update:modelValue', []);
emit('reset');
};
// 判断操作符是否需要值
const needsValue = (operator: string) => {
return !['is null', 'is not null'].includes(operator);
};
// 判断操作符是否需要两个值BETWEEN
const needsTwoValues = (operator: string) => {
return operator === 'between';
};
// 判断操作符是否需要数组值IN
const needsArrayValue = (operator: string) => {
return ['in', 'not in'].includes(operator);
};
</script>
<template>
<Card title="高级搜索" class="mb-4" size="small">
<div class="space-y-3">
<div v-for="(condition, index) in searchConditions" :key="index" class="flex items-start gap-2">
<!-- 逻辑连接符 -->
<div v-if="index > 0" class="w-16 pt-1">
<select
v-model="condition.logic"
class="w-full rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
>
<option value="AND">AND</option>
<option value="OR">OR</option>
</select>
</div>
<!-- 字段选择 -->
<div class="flex-1">
<select
v-model="condition.field"
class="w-full rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
>
<option value="">请选择字段</option>
<option v-for="field in fieldOptions" :key="field.value" :value="field.value">
{{ field.label }}
</option>
</select>
</div>
<!-- 操作符选择 -->
<div class="w-32">
<select
v-model="condition.operator"
class="w-full rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
>
<option v-for="op in operatorOptions" :key="op.value" :value="op.value">
{{ op.label }}
</option>
</select>
</div>
<!-- 值输入 -->
<div v-if="needsValue(condition.operator)" class="flex-1">
<template v-if="needsTwoValues(condition.operator)">
<div class="flex gap-2">
<input
v-model="condition.value"
type="text"
placeholder="起始值"
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
<span class="py-1 text-sm"></span>
<input
v-model="condition.value2"
type="text"
placeholder="结束值"
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</div>
</template>
<template v-else-if="needsArrayValue(condition.operator)">
<input
v-model="condition.value"
type="text"
placeholder="多个值用逗号分隔,如: 1,2,3"
class="w-full rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</template>
<template v-else>
<input
v-model="condition.value"
type="text"
placeholder="请输入值"
class="w-full rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</template>
</div>
<!-- 删除按钮 -->
<Button type="text" danger size="small" @click="removeCondition(index)">
<i class="fa-solid fa-trash"></i>
</Button>
</div>
<!-- 操作按钮 -->
<div class="flex items-center justify-between">
<Button type="dashed" size="small" @click="addCondition">
<i class="fa-solid fa-plus mr-2"></i>添加条件
</Button>
<Space>
<Button size="small" @click="handleReset">重置</Button>
<Button type="primary" size="small" @click="handleSearch">搜索</Button>
</Space>
</div>
</div>
</Card>
</template>
<style scoped>
.space-y-3 > * + * {
margin-top: 0.75rem;
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { updateColumnCommentApi } from '../api';
defineOptions({
name: 'EditColumnCommentModal',
});
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'Input',
fieldName: 'columnName',
label: '字段名',
formItemClass: 'col-span-12',
componentProps: {
disabled: true,
},
},
{
component: 'Textarea',
fieldName: 'comment',
label: '字段注释',
formItemClass: 'col-span-12',
componentProps: {
rows: 4,
placeholder: '请输入字段注释',
},
},
],
showDefaultActions: false,
});
const tableName = ref('');
const columnName = ref('');
const reloadCallback = ref<(() => void) | null>(null);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const valid = await formApi.validate();
if (valid.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
await updateColumnCommentApi({
table_name: tableName.value,
column_name: columnName.value,
comment: values.comment || '',
});
message.success('更新成功');
reloadCallback.value?.();
modalApi.close();
} catch (error: any) {
message.error(error.message || '更新失败');
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{
tableName: string;
column: any;
reload: () => void;
}>();
if (data) {
tableName.value = data.tableName;
columnName.value = data.column.name;
reloadCallback.value = data.reload;
formApi.setValues({
columnName: data.column.name,
comment: data.column.comment || '',
});
}
}
},
});
</script>
<template>
<Modal title="编辑字段注释" class="w-[500px]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,263 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { updateTableStructureApi } from '../api';
defineOptions({
name: 'EditColumnModal',
});
const tableName = ref('');
const columns = ref<any[]>([]);
const currentColumn = ref<any>(null);
const reloadCallback = ref<(() => void) | null>(null);
// 字段类型选项
const fieldTypeOptions = [
{ label: 'VARCHAR', value: 'VARCHAR' },
{ label: 'CHAR', value: 'CHAR' },
{ label: 'TEXT', value: 'TEXT' },
{ label: 'LONGTEXT', value: 'LONGTEXT' },
{ label: 'INT', value: 'INT' },
{ label: 'BIGINT', value: 'BIGINT' },
{ label: 'TINYINT', value: 'TINYINT' },
{ label: 'SMALLINT', value: 'SMALLINT' },
{ label: 'DECIMAL', value: 'DECIMAL' },
{ label: 'FLOAT', value: 'FLOAT' },
{ label: 'DOUBLE', value: 'DOUBLE' },
{ label: 'DATETIME', value: 'DATETIME' },
{ label: 'DATE', value: 'DATE' },
{ label: 'TIMESTAMP', value: 'TIMESTAMP' },
{ label: 'TIME', value: 'TIME' },
{ label: 'YEAR', value: 'YEAR' },
{ label: 'BOOLEAN', value: 'TINYINT' },
];
// 需要长度的类型
const typesNeedLength = ['VARCHAR', 'CHAR', 'INT', 'BIGINT', 'TINYINT', 'SMALLINT', 'DECIMAL', 'FLOAT', 'DOUBLE'];
// 位置选项AFTER字段
const positionOptions = computed(() => {
if (!columns.value || !currentColumn.value) return [];
return columns.value
.filter((col) => col.name !== currentColumn.value.name)
.map((col) => ({
label: `${col.name}${col.comment ? ` (${col.comment})` : ''}`,
value: col.name,
}));
});
// 解析字段类型和长度
const parseColumnType = (columnType: string) => {
const match = columnType.match(/^(\w+)(?:\((\d+(?:,\d+)?)\))?/i);
if (match) {
return {
type: match[1].toUpperCase(),
length: match[2] || '',
};
}
return { type: columnType.toUpperCase(), length: '' };
};
// 表单schema
const formSchema = computed(() => {
const baseSchema = [
{
component: 'Input',
fieldName: 'name',
label: '字段名',
formItemClass: 'col-span-12',
componentProps: {
disabled: true,
},
},
{
component: 'Select',
fieldName: 'type',
label: '字段类型',
formItemClass: 'col-span-12',
componentProps: {
options: fieldTypeOptions,
placeholder: '请选择字段类型',
},
rules: 'required',
},
];
// 根据类型动态添加长度字段
const lengthField = {
component: 'Input',
fieldName: 'length',
label: '长度/精度',
formItemClass: 'col-span-12',
componentProps: {
placeholder: '例如: 255 或 10,2 (DECIMAL类型)',
},
dependencies: {
show: {
triggerFields: ['type'],
condition: (values: any) => {
const type = values.type || '';
return typesNeedLength.includes(type.toUpperCase());
},
},
},
};
const afterField = {
component: 'Select',
fieldName: 'after',
label: '位置AFTER字段',
formItemClass: 'col-span-12',
componentProps: {
options: positionOptions.value,
placeholder: '选择字段位置,留空则保持当前位置',
allowClear: true,
},
};
return [
...baseSchema,
lengthField,
{
component: 'Input',
fieldName: 'default_value',
label: '默认值',
formItemClass: 'col-span-12',
componentProps: {
placeholder: '请输入默认值,留空表示无默认值',
},
},
{
component: 'Switch',
fieldName: 'nullable',
label: '允许NULL',
formItemClass: 'col-span-12',
},
{
component: 'Textarea',
fieldName: 'comment',
label: '字段注释',
formItemClass: 'col-span-12',
componentProps: {
rows: 3,
placeholder: '请输入字段注释',
},
},
afterField,
];
});
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: formSchema,
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const valid = await formApi.validate();
if (valid.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
// 构建column_info
const columnInfo: any = {
name: values.name,
type: values.type,
};
// 处理长度
if (values.length && typesNeedLength.includes(values.type.toUpperCase())) {
columnInfo.length = values.length;
}
// 处理默认值
if (values.default_value !== null && values.default_value !== undefined && values.default_value !== '') {
columnInfo.default_value = values.default_value;
}
// 处理NULL
columnInfo.nullable = values.nullable || false;
// 处理注释
if (values.comment) {
columnInfo.comment = values.comment;
}
// 处理位置
if (values.after) {
columnInfo.after = values.after;
}
await updateTableStructureApi({
table_name: tableName.value,
action: 'modify',
column_info: columnInfo,
});
message.success('更新成功');
reloadCallback.value?.();
modalApi.close();
} catch (error: any) {
message.error(error.message || '更新失败');
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{
tableName: string;
column: any;
columns: any[];
reload: () => void;
}>();
if (data) {
tableName.value = data.tableName;
currentColumn.value = data.column;
columns.value = data.columns;
reloadCallback.value = data.reload;
// 解析字段类型和长度
const { type, length } = parseColumnType(data.column.column_type || data.column.type || 'VARCHAR');
// 设置表单值
formApi.setValues({
name: data.column.name,
type: type,
length: length || '',
default_value: data.column.default_value ?? '',
nullable: data.column.nullable || false,
comment: data.column.comment || '',
after: '', // 位置需要用户重新选择
});
}
}
},
});
</script>
<template>
<Modal title="编辑字段" class="w-[600px]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { insertTableDataApi, updateTableDataApi } from '../api';
defineOptions({
name: 'EditDataModal',
});
const tableName = ref('');
const columns = ref<any[]>([]);
const isUpdate = ref(false);
const primaryKey = ref('');
const reloadCallback = ref<(() => void) | null>(null);
const formSchema = computed(() => {
return columns.value.map((col) => {
const isTextType = col.type === 'text' || col.type === 'longtext';
return {
component: isTextType ? 'Textarea' : 'Input',
fieldName: col.name,
label: col.comment || col.name,
formItemClass: 'col-span-12',
componentProps: {
placeholder: `请输入${col.comment || col.name}`,
...(isTextType ? { rows: 3 } : {}),
...(col.name === primaryKey.value && isUpdate.value ? { disabled: true } : {}),
},
};
});
});
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: formSchema,
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const valid = await formApi.validate();
if (valid.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
if (isUpdate.value) {
// 构建WHERE条件
const where = [
{
field: primaryKey.value,
operator: '=',
value: values[primaryKey.value],
},
];
const data = { ...values };
delete data[primaryKey.value];
await updateTableDataApi({
table_name: tableName.value,
data,
where,
});
message.success('更新成功');
} else {
await insertTableDataApi({
table_name: tableName.value,
data: values,
});
message.success('添加成功');
}
reloadCallback.value?.();
modalApi.close();
} catch (error: any) {
message.error(error.message || '操作失败');
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{
tableName: string;
columns: any[];
values?: any;
update?: boolean;
reload: () => void;
}>();
if (data) {
tableName.value = data.tableName;
columns.value = data.columns;
isUpdate.value = data.update || false;
reloadCallback.value = data.reload;
// 查找主键
const pkColumn = data.columns.find((col) => col.key_type === 'PRI');
primaryKey.value = pkColumn ? pkColumn.name : data.columns[0]?.name || 'id';
// 设置表单值
if (data.values) {
formApi.setValues(data.values);
} else {
formApi.resetFields();
}
}
}
},
});
</script>
<template>
<Modal :title="`${isUpdate ? '编辑' : '添加'}数据`" class="w-[800px]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { updateTableCommentApi } from '../api';
defineOptions({
name: 'EditTableCommentModal',
});
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'Textarea',
fieldName: 'comment',
label: '表注释',
formItemClass: 'col-span-12',
componentProps: {
rows: 4,
placeholder: '请输入表注释',
},
},
],
showDefaultActions: false,
});
const tableName = ref('');
const reloadCallback = ref<(() => void) | null>(null);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const valid = await formApi.validate();
if (valid.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
await updateTableCommentApi({
table_name: tableName.value,
comment: values.comment || '',
});
message.success('更新成功');
reloadCallback.value?.();
modalApi.close();
} catch (error: any) {
message.error(error.message || '更新失败');
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{
tableName: string;
comment: string;
reload: () => void;
}>();
if (data) {
tableName.value = data.tableName;
reloadCallback.value = data.reload;
formApi.setValues({ comment: data.comment || '' });
}
}
},
});
</script>
<template>
<Modal title="编辑表注释" class="w-[500px]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,273 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Button, Card, message, Select } from 'ant-design-vue';
import { executeSqlQueryApi } from '../api';
defineOptions({
name: 'SqlQuery',
});
// 接收外部传递的表名和表列表
const props = defineProps<{
tableName?: string;
tableList?: any[];
}>();
const sqlText = ref('');
const queryResult = ref<any[]>([]);
const queryColumns = ref<string[]>([]);
const loading = ref(false);
const queryHistory = ref<string[]>([]);
const selectedTableSelect = ref<string | undefined>(undefined);
// 验证SQL是否为SELECT语句
const validateSql = (sql: string): boolean => {
const trimmedSql = sql.trim().toUpperCase();
// 检查是否以SELECT开头
if (!trimmedSql.startsWith('SELECT')) {
return false;
}
// 禁止的危险关键字
const dangerousKeywords = [
'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'TRUNCATE',
'REPLACE', 'GRANT', 'REVOKE', 'EXEC', 'EXECUTE', 'CALL', 'PROCEDURE',
];
for (const keyword of dangerousKeywords) {
if (trimmedSql.includes(keyword)) {
return false;
}
}
return true;
};
// 执行SQL查询
const handleExecute = async () => {
if (!sqlText.value.trim()) {
message.warning('请输入SQL语句');
return;
}
if (!validateSql(sqlText.value)) {
message.error('仅支持SELECT查询语句且不能包含危险关键字');
return;
}
loading.value = true;
try {
const res = await executeSqlQueryApi({
sql: sqlText.value,
});
// 直接判断 res 是否为数组
if (Array.isArray(res)) {
queryResult.value = res;
if (res.length > 0) {
// 动态获取列名
const firstRow = res[0];
queryColumns.value = Object.keys(firstRow);
message.success(`查询成功,返回 ${res.length} 条记录`);
} else {
queryColumns.value = [];
message.info('查询成功,但未返回数据');
}
// 添加到历史记录
if (!queryHistory.value.includes(sqlText.value)) {
queryHistory.value.unshift(sqlText.value);
if (queryHistory.value.length > 10) {
queryHistory.value = queryHistory.value.slice(0, 10);
}
}
} else {
console.error('数据格式错误,期望数组,实际返回:', res);
queryResult.value = [];
queryColumns.value = [];
message.warning('查询返回了非数组格式的数据');
}
} catch (error: any) {
console.error('SQL执行错误:', error);
message.error(error.message || '查询失败');
queryResult.value = [];
queryColumns.value = [];
} finally {
loading.value = false;
}
};
// 监听 tableName 变化,设置默认值(从详情页进入时)
watch(
() => props.tableName,
(newVal) => {
if (newVal) {
selectedTableSelect.value = newVal;
// 自动生成查询语句
sqlText.value = `SELECT * FROM ${newVal} LIMIT 20;`;
// 自动执行查询
handleExecute();
}
},
{ immediate: true }
);
// 处理下拉框切换表名
const handleTableSelectChange = (value: string) => {
selectedTableSelect.value = value;
if (value) {
sqlText.value = `SELECT * FROM ${value} LIMIT 20;`;
handleExecute();
}
};
// 清空SQL
const handleClear = () => {
sqlText.value = '';
queryResult.value = [];
queryColumns.value = [];
selectedTableSelect.value = undefined;
};
// 选择历史记录
const handleSelectHistory = (sql: string) => {
sqlText.value = sql;
};
</script>
<template>
<div class="sql-query h-full flex flex-col">
<Card title="SQL查询" class="mb-4 flex-shrink-0">
<div class="mb-4 space-y-3">
<!-- 顶部工具栏选择表 -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 flex-1">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">快捷查询表:</span>
<Select
v-model:value="selectedTableSelect"
show-search
placeholder="选择数据表自动生成查询..."
class="w-64"
:options="props.tableList?.map(t => ({ label: t.name + (t.comment ? ` (${t.comment})` : ''), value: t.name }))"
@change="handleTableSelectChange"
:filter-option="(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())"
/>
</div>
<div v-if="queryHistory.length > 0" class="flex items-center gap-2">
<span class="text-sm text-gray-500">历史记录:</span>
<select
class="rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 outline-none focus:border-primary max-w-[200px]"
@change="(e: any) => handleSelectHistory(e.target.value)"
>
<option value="">选择历史记录</option>
<option v-for="(sql, index) in queryHistory" :key="index" :value="sql">
{{ sql.substring(0, 30) }}{{ sql.length > 30 ? '...' : '' }}
</option>
</select>
</div>
</div>
<textarea
v-model="sqlText"
class="w-full rounded border border-gray-300 p-3 font-mono text-sm dark:border-gray-600 dark:bg-gray-800 focus:ring-2 focus:ring-primary/50 focus:border-primary outline-none transition-all"
rows="5"
placeholder="请输入SELECT查询语句例如SELECT * FROM nl_admin LIMIT 10;"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Button type="primary" :loading="loading" @click="handleExecute">
<i class="fa-solid fa-play mr-2"></i>执行查询
</Button>
<Button @click="handleClear">
<i class="fa-solid fa-eraser mr-2"></i>清空
</Button>
</div>
</div>
</Card>
<!-- 自定义表格区域 -->
<Card
v-if="queryResult.length > 0 || loading"
title="查询结果"
class="flex-1 overflow-hidden flex flex-col"
:body-style="{ padding: 0, flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }"
>
<div class="flex-1 overflow-auto">
<table class="w-full border-collapse text-left text-sm whitespace-nowrap">
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-10 shadow-sm">
<tr>
<!-- 序号列 -->
<th class="px-4 py-3 font-semibold text-gray-700 dark:text-gray-200 border-b border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 w-16 text-center">
#
</th>
<!-- 动态列头 -->
<th
v-for="col in queryColumns"
:key="col"
class="px-4 py-3 font-semibold text-gray-700 dark:text-gray-200 border-b border-gray-200 dark:border-gray-700"
>
{{ col }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900">
<tr
v-for="(row, rowIndex) in queryResult"
:key="rowIndex"
class="hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-colors"
>
<!-- 序号 -->
<td class="px-4 py-3 text-gray-500 border-r border-gray-100 dark:border-gray-800 text-center font-mono text-xs bg-gray-50/30">
{{ rowIndex + 1 }}
</td>
<!-- 数据单元格 -->
<td
v-for="col in queryColumns"
:key="col"
class="px-4 py-3 text-gray-600 dark:text-gray-300"
>
<div class="max-w-xs truncate" :title="String(row[col])">
{{ row[col] !== null ? row[col] : '(NULL)' }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 底部统计栏 -->
<div class="border-t border-gray-200 dark:border-gray-700 p-2 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 text-right">
共显示 {{ queryResult.length }} 条记录
</div>
</Card>
</div>
</template>
<style scoped>
/* 滚动条美化 */
.overflow-auto {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.overflow-auto::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.overflow-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-auto::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
.overflow-auto::-webkit-scrollbar-corner {
background: transparent;
}
</style>

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
import { computed, h, ref } from 'vue';
import { Button, Popconfirm, Table } from 'ant-design-vue';
import DataSearch from './DataSearch.vue';
const props = defineProps<{
tableInfo: any;
tableData: any[];
loading: boolean;
pagination: {
current: number;
pageSize: number;
total: number;
};
}>();
const emit = defineEmits<{
'add-data': [];
'edit-data': [record: any];
'delete-data': [record: any];
'batch-delete': [records: any[]];
'page-change': [page: number, pageSize: number];
'search': [conditions: any[]];
}>();
const selectedRowKeys = ref<any[]>([]);
const searchConditions = ref<any[]>([]);
const tableColumns = computed(() => {
if (!props.tableInfo?.columns) return [];
return [
{
title: '',
key: 'selection',
width: 50,
fixed: 'left',
customRender: ({ record }: any) => {
const key = getRowKey(record);
return h('input', {
type: 'checkbox',
checked: selectedRowKeys.value.includes(key),
onChange: (e: any) => {
if (e.target.checked) {
selectedRowKeys.value.push(key);
} else {
const index = selectedRowKeys.value.indexOf(key);
if (index > -1) {
selectedRowKeys.value.splice(index, 1);
}
}
},
});
},
},
...props.tableInfo.columns.map((col: any) => ({
title: col.comment || col.name,
dataIndex: col.name,
key: col.name,
})),
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right',
customRender: ({ record }: any) =>
h('div', { class: 'flex gap-2' }, [
h(Button, {
type: 'link',
size: 'small',
onClick: () => emit('edit-data', record),
}, () => '编辑'),
h(Popconfirm, {
title: '确定删除这条数据吗?',
onConfirm: () => emit('delete-data', record),
}, {
default: () =>
h(Button, {
type: 'link',
danger: true,
size: 'small',
}, () => '删除'),
}),
]),
},
];
});
// 获取行的唯一标识
const getRowKey = (record: any) => {
// 尝试使用id字段如果没有则使用第一个字段
if (record.id !== undefined) {
return record.id;
}
const firstKey = Object.keys(record)[0];
return record[firstKey];
};
// 全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (checked) {
selectedRowKeys.value = props.tableData.map((record) => getRowKey(record));
} else {
selectedRowKeys.value = [];
}
};
// 批量删除
const handleBatchDelete = () => {
if (selectedRowKeys.value.length === 0) {
return;
}
const selectedRecords = props.tableData.filter((record) =>
selectedRowKeys.value.includes(getRowKey(record)),
);
emit('batch-delete', selectedRecords);
};
// 搜索
const handleSearch = () => {
emit('search', searchConditions.value);
};
// 重置搜索
const handleResetSearch = () => {
searchConditions.value = [];
emit('search', []);
};
</script>
<template>
<div>
<!-- 高级搜索 -->
<DataSearch
:columns="tableInfo?.columns || []"
v-model="searchConditions"
@search="handleSearch"
@reset="handleResetSearch"
/>
<!-- 操作栏 -->
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<Button type="primary" @click="emit('add-data')">
<i class="fa-solid fa-plus mr-2"></i>添加数据
</Button>
<Popconfirm
v-if="selectedRowKeys.length > 0"
:title="`确定删除选中的 ${selectedRowKeys.length} 条数据吗?`"
@confirm="handleBatchDelete"
>
<Button type="primary" danger>
<i class="fa-solid fa-trash mr-2"></i>批量删除 ({{ selectedRowKeys.length }})
</Button>
</Popconfirm>
</div>
<div v-if="selectedRowKeys.length > 0" class="text-sm text-gray-500">
已选择 {{ selectedRowKeys.length }}
</div>
</div>
<!-- 数据表格 -->
<Table
:columns="tableColumns"
:data-source="tableData"
:loading="loading"
:row-key="getRowKey"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
}"
@change="(p: any) => emit('page-change', p.current, p.pageSize)"
/>
</div>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { h } from 'vue';
import { Button, Popconfirm, Table, Tag } from 'ant-design-vue';
defineProps<{
tableInfo: any;
}>();
const emit = defineEmits<{
'add-index': [];
'delete-index': [indexName: string];
}>();
const columns = [
{ title: '索引名', dataIndex: 'name', key: 'name' },
{
title: '字段',
dataIndex: 'columns',
key: 'columns',
customRender: ({ text }: any) => text.join(', '),
},
{
title: '唯一',
dataIndex: 'unique',
key: 'unique',
customRender: ({ text }: any) =>
h(Tag, { color: text ? 'green' : 'default' }, () => text ? '是' : '否'),
},
{ title: '类型', dataIndex: 'type', key: 'type' },
{
title: '操作',
key: 'action',
width: 120,
customRender: ({ record }: any) =>
h(Popconfirm, {
title: '确定删除此索引吗?',
onConfirm: () => emit('delete-index', record.name),
}, {
default: () =>
h(Button, {
type: 'link',
danger: true,
size: 'small',
}, () => '删除'),
}),
},
];
</script>
<template>
<div>
<div class="mb-4">
<Button type="primary" @click="emit('add-index')">
<i class="fa-solid fa-plus mr-2"></i>添加索引
</Button>
</div>
<Table
:columns="columns"
:data-source="tableInfo?.indexes || []"
:pagination="false"
size="small"
/>
</div>
</template>

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
import { Input, Empty, Spin, Tooltip } from 'ant-design-vue';
defineProps<{
tableList: any[];
loading: boolean;
selectedTable: string;
searchKeyword: string;
}>();
const emit = defineEmits<{
'update:searchKeyword': [value: string];
'select-table': [tableName: string];
}>();
// 根据表名生成一个固定的装饰色,让界面不那么单调
const getIconColor = (name: string) => {
const colors = ['text-blue-500', 'text-green-500', 'text-purple-500', 'text-orange-500', 'text-pink-500', 'text-cyan-500'];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
// 格式化文件大小
const formatSize = (bytes: number) => {
if (!bytes && bytes !== 0) return '-';
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
// 格式化日期,只显示年月日
const formatDate = (dateStr: string) => {
if (!dateStr) return '';
return dateStr.split(' ')[0];
};
</script>
<template>
<div class="h-full flex flex-col bg-white dark:bg-gray-900 rounded-lg shadow-sm">
<!-- 头部区域标题与搜索 -->
<div class="p-5 border-b border-gray-100 dark:border-gray-800 flex flex-col sm:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-3 self-start sm:self-center">
<div class="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center">
<!-- 替换原有 fa-database 图标 -->
<span class="icon-[material-symbols--database] text-primary text-xl"></span>
</div>
<div>
<h2 class="text-lg font-bold m-0 leading-tight text-gray-800 dark:text-gray-100">数据库表管理</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 m-0 mt-1"> {{ tableList.length }} 张数据表</p>
</div>
</div>
<div class="w-full sm:w-80">
<Input
:value="searchKeyword"
placeholder="搜索表名、注释..."
allow-clear
size="large"
class="!rounded-lg"
@update:value="(val) => emit('update:searchKeyword', val)"
>
<template #prefix>
<i class="fa-solid fa-search text-gray-400 mr-1"></i>
</template>
</Input>
</div>
</div>
<!-- 内容区域 -->
<div class="flex-1 overflow-hidden relative bg-gray-50/50 dark:bg-gray-900">
<!-- 加载中 -->
<div v-if="loading" class="absolute inset-0 z-10 flex justify-center items-center bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
<div class="text-center">
<Spin size="large" />
<p class="mt-4 text-gray-500">正在加载数据表...</p>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="tableList.length === 0" class="h-full flex flex-col justify-center items-center text-gray-400">
<Empty :image="Empty.PRESENTED_IMAGE_SIMPLE" description="未找到匹配的数据表" />
</div>
<!-- 卡片网格列表 -->
<div v-else class="h-full overflow-y-auto p-4 sm:p-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-5">
<div
v-for="table in tableList"
:key="table.name"
class="group relative flex flex-col bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30"
@click="emit('select-table', table.name)"
>
<!-- 卡片顶部图标与名称 -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3 overflow-hidden flex-1">
<div class="w-10 h-10 rounded-lg bg-gray-50 dark:bg-gray-700 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/5 transition-colors">
<span class="icon-[material-symbols--database] text-primary text-xl"></span>
</div>
<div class="overflow-hidden flex-1">
<h3 class="font-bold text-gray-800 dark:text-gray-100 truncate text-base mb-0.5" :title="table.name">
{{ table.name }}
</h3>
<!-- 标签行显示引擎和字符集 -->
<div class="flex items-center gap-2 overflow-hidden">
<Tooltip :title="`存储引擎: ${table.engine}`">
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 font-mono whitespace-nowrap">
{{ table.engine || 'InnoDB' }}
</span>
</Tooltip>
<Tooltip v-if="table.collation" :title="`字符集: ${table.collation}`">
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 font-mono truncate max-w-[80px]">
{{ table.collation.split('_')[0] }}
</span>
</Tooltip>
</div>
</div>
</div>
</div>
<!-- 卡片中间注释 -->
<div class="flex-1 mb-4 min-h-[2.5rem]">
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 m-0 leading-relaxed" :title="table.comment">
{{ table.comment || '暂无表注释信息...' }}
</p>
</div>
<!-- 卡片底部统计与操作 -->
<div class="pt-3 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between text-xs text-gray-400">
<div class="flex items-center gap-3">
<!-- 行数 -->
<Tooltip title="数据行数">
<div class="flex items-center gap-1.5 hover:text-gray-600 dark:hover:text-gray-300">
<i class="fa-solid fa-list-ol"></i>
<span>{{ (table.rows || 0).toLocaleString() }}</span>
</div>
</Tooltip>
<!-- 大小 (数据+索引) -->
<Tooltip title="数据+索引大小">
<div class="flex items-center gap-1.5 hover:text-gray-600 dark:hover:text-gray-300">
<i class="fa-solid fa-hard-drive"></i>
<span>{{ formatSize((table.data_length || 0) + (table.index_length || 0)) }}</span>
</div>
</Tooltip>
</div>
<!-- 右下角默认显示时间Hover显示管理按钮 -->
<div class="relative">
<Tooltip :title="`创建时间: ${table.create_time || '-'}`">
<div class="group-hover:opacity-0 transition-opacity duration-300 flex items-center gap-1">
<i class="fa-regular fa-clock"></i>
<span>{{ formatDate(table.create_time) }}</span>
</div>
</Tooltip>
<div class="absolute right-0 top-0 flex items-center gap-1 text-primary opacity-0 group-hover:opacity-100 transform translate-x-2 group-hover:translate-x-0 transition-all duration-300 font-medium whitespace-nowrap bg-white dark:bg-gray-800 pl-2">
<span>管理</span>
<i class="fa-solid fa-arrow-right"></i>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 优化滚动条样式 */
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.1) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.1);
border-radius: 3px;
}
</style>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { h } from 'vue';
import { Button, Popconfirm, Table } from 'ant-design-vue';
defineProps<{
tableInfo: any;
loading: boolean;
}>();
const emit = defineEmits<{
'edit-table-comment': [];
'edit-column-comment': [column: any];
'edit-column': [column: any];
'add-column': [];
'delete-column': [column: any];
}>();
const columns = [
{ title: '字段名', dataIndex: 'name', key: 'name' },
{ title: '类型', dataIndex: 'column_type', key: 'column_type' },
{
title: '允许NULL',
dataIndex: 'nullable',
key: 'nullable',
customRender: ({ text }: any) => (text ? '是' : '否'),
},
{ title: '默认值', dataIndex: 'default_value', key: 'default_value' },
{ title: '键', dataIndex: 'key_type', key: 'key_type' },
{ title: '注释', dataIndex: 'comment', key: 'comment' },
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
customRender: ({ record }: any) =>
h('div', { class: 'flex gap-2' }, [
h(Button, {
type: 'link',
size: 'small',
onClick: () => emit('edit-column', record),
}, () => '编辑'),
h(Button, {
type: 'link',
size: 'small',
onClick: () => emit('edit-column-comment', record),
}, () => '注释'),
h(Popconfirm, {
title: '确定删除此字段吗?此操作不可恢复!',
onConfirm: () => emit('delete-column', record),
}, {
default: () =>
h(Button, {
type: 'link',
danger: true,
size: 'small',
}, () => '删除'),
}),
]),
},
];
</script>
<template>
<div v-if="loading" class="flex h-64 items-center justify-center">
<i class="fa-solid fa-spinner fa-spin text-2xl text-primary"></i>
</div>
<div v-else-if="tableInfo">
<div class="mb-4 flex items-center justify-between">
<div>
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
表注释
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ tableInfo.comment || '无' }}
</span>
<Button type="link" size="small" @click="emit('edit-table-comment')">
编辑
</Button>
</div>
<div class="text-xs text-gray-500">
引擎: {{ tableInfo.engine }} | 字符集: {{ tableInfo.collation }} | 记录数: {{ tableInfo.rows }}
</div>
</div>
<Button type="primary" @click="emit('add-column')">
<i class="fa-solid fa-plus mr-2"></i>添加字段
</Button>
</div>
<Table
:columns="columns"
:data-source="tableInfo.columns"
:pagination="false"
size="small"
:scroll="{ x: 1000 }"
/>
</div>
</template>

View File

@@ -0,0 +1,455 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Card, message, Tabs } from 'ant-design-vue';
import {
batchDeleteTableDataApi,
deleteTableDataApi,
dropIndexApi,
getTableDataApi,
getTableInfoApi,
getTableListApi,
updateTableStructureApi,
} from './api';
import AddColumnModal from './components/AddColumnModal.vue';
import AddIndexModal from './components/AddIndexModal.vue';
import EditColumnCommentModal from './components/EditColumnCommentModal.vue';
import EditColumnModal from './components/EditColumnModal.vue';
import EditDataModal from './components/EditDataModal.vue';
import EditTableCommentModal from './components/EditTableCommentModal.vue';
import ChangeLog from './components/ChangeLog.vue';
import SqlQuery from './components/SqlQuery.vue';
import TableData from './components/TableData.vue';
import TableIndex from './components/TableIndex.vue';
import TableList from './components/TableList.vue';
import TableStructure from './components/TableStructure.vue';
// 表列表
const tableList = ref<any[]>([]);
const tableLoading = ref(false);
const searchKeyword = ref('');
const selectedTable = ref<string>('');
// 主页标签页 (列表/SQL)
const activeMainTab = ref('list');
// 表信息
const tableInfo = ref<any>(null);
const tableInfoLoading = ref(false);
// 表数据
const tableData = ref<any[]>([]);
const tableDataLoading = ref(false);
const tableDataPagination = ref({
current: 1,
pageSize: 20,
total: 0,
});
// 当前标签页
const activeTab = ref('structure');
// Modal
const [EditTableCommentModalComponent, editTableCommentModalApi] = useVbenModal({
connectedComponent: EditTableCommentModal,
});
const [EditColumnCommentModalComponent, editColumnCommentModalApi] = useVbenModal({
connectedComponent: EditColumnCommentModal,
});
const [EditColumnModalComponent, editColumnModalApi] = useVbenModal({
connectedComponent: EditColumnModal,
});
const [AddColumnModalComponent, addColumnModalApi] = useVbenModal({
connectedComponent: AddColumnModal,
});
const [EditDataModalComponent, editDataModalApi] = useVbenModal({
connectedComponent: EditDataModal,
});
const [AddIndexModalComponent, addIndexModalApi] = useVbenModal({
connectedComponent: AddIndexModal,
});
// 过滤后的表列表
const filteredTableList = computed(() => {
if (!searchKeyword.value) {
return tableList.value;
}
return tableList.value.filter(
(table) =>
table.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
(table.comment && table.comment.toLowerCase().includes(searchKeyword.value.toLowerCase())),
);
});
// 加载表列表
const loadTableList = async () => {
tableLoading.value = true;
try {
const res = await getTableListApi();
tableList.value = res || [];
} catch (error: any) {
message.error(error.message || '加载表列表失败');
tableList.value = [];
} finally {
tableLoading.value = false;
}
};
// 选择表
const handleSelectTable = async (tableName: string) => {
if (!tableName) {
selectedTable.value = '';
tableInfo.value = null;
tableData.value = [];
return;
}
selectedTable.value = tableName;
activeTab.value = 'structure';
await loadTableInfo();
};
// 返回列表
const handleBack = () => {
selectedTable.value = '';
};
// 加载表信息
const loadTableInfo = async () => {
if (!selectedTable.value) return;
tableInfoLoading.value = true;
try {
const res = await getTableInfoApi(selectedTable.value);
tableInfo.value = res;
} catch (error: any) {
message.error(error.message || '加载表信息失败');
tableInfo.value = null;
} finally {
tableInfoLoading.value = false;
}
};
// 搜索条件
const searchConditions = ref<any[]>([]);
// 加载表数据
const loadTableData = async (page = 1, pageSize = 20, where: any[] = []) => {
if (!selectedTable.value) return;
tableDataLoading.value = true;
try {
const res = await getTableDataApi({
table_name: selectedTable.value,
page,
page_size: pageSize,
where: where.length > 0 ? where : searchConditions.value,
});
tableData.value = res.items || [];
tableDataPagination.value = {
current: res.page || 1,
pageSize: res.size || 20,
total: res.total || 0,
};
} catch (error: any) {
message.error(error.message || '加载表数据失败');
tableData.value = [];
} finally {
tableDataLoading.value = false;
}
};
// 处理搜索
const handleSearch = (conditions: any[]) => {
searchConditions.value = conditions;
loadTableData(1, tableDataPagination.value.pageSize, conditions);
};
// 切换标签页
const handleTabChange = (key: string) => {
if (key === 'data' && tableData.value.length === 0) {
loadTableData();
}
};
// 编辑表注释
const handleEditTableComment = () => {
if (!tableInfo.value) return;
editTableCommentModalApi.setData({
tableName: selectedTable.value,
comment: tableInfo.value.comment || '',
reload: loadTableInfo,
}).open();
};
// 编辑字段
const handleEditColumn = (column: any) => {
editColumnModalApi.setData({
tableName: selectedTable.value,
column,
columns: tableInfo.value?.columns || [],
reload: loadTableInfo,
}).open();
};
// 编辑字段注释
const handleEditColumnComment = (column: any) => {
editColumnCommentModalApi.setData({
tableName: selectedTable.value,
column,
reload: loadTableInfo,
}).open();
};
// 添加字段
const handleAddColumn = () => {
addColumnModalApi.setData({
tableName: selectedTable.value,
columns: tableInfo.value?.columns || [],
reload: loadTableInfo,
}).open();
};
// 删除字段
const handleDeleteColumn = async (column: any) => {
try {
await updateTableStructureApi({
table_name: selectedTable.value,
action: 'drop',
column_info: {
name: column.name,
},
});
message.success('删除成功');
await loadTableInfo();
} catch (error: any) {
message.error(error.message || '删除失败');
}
};
// 添加数据
const handleAddData = () => {
editDataModalApi.setData({
tableName: selectedTable.value,
columns: tableInfo.value?.columns || [],
values: {},
update: false,
reload: () => loadTableData(tableDataPagination.value.current, tableDataPagination.value.pageSize),
}).open();
};
// 编辑数据
const handleEditData = (record: any) => {
editDataModalApi.setData({
tableName: selectedTable.value,
columns: tableInfo.value?.columns || [],
values: record,
update: true,
reload: () => loadTableData(tableDataPagination.value.current, tableDataPagination.value.pageSize),
}).open();
};
// 删除数据
const handleDeleteData = async (record: any) => {
try {
const firstKey = Object.keys(record)[0];
await deleteTableDataApi({
table_name: selectedTable.value,
where: [
{
field: firstKey,
operator: '=',
value: record[firstKey],
},
],
});
message.success('删除成功');
await loadTableData(tableDataPagination.value.current, tableDataPagination.value.pageSize);
} catch (error: any) {
message.error(error.message || '删除失败');
}
};
// 批量删除数据
const handleBatchDeleteData = async (records: any[]) => {
try {
// 构建批量删除的WHERE条件
const whereConditions = records.map((record) => {
const firstKey = Object.keys(record)[0];
return {
field: firstKey,
operator: '=',
value: record[firstKey],
};
});
// 使用OR连接多个条件
await batchDeleteTableDataApi({
table_name: selectedTable.value,
where: whereConditions,
});
message.success(`成功删除 ${records.length} 条数据`);
await loadTableData(tableDataPagination.value.current, tableDataPagination.value.pageSize);
} catch (error: any) {
message.error(error.message || '批量删除失败');
}
};
// 添加索引
const handleAddIndex = () => {
addIndexModalApi.setData({
tableName: selectedTable.value,
columns: tableInfo.value?.columns || [],
reload: loadTableInfo,
}).open();
};
// 删除索引
const handleDeleteIndex = async (indexName: string) => {
try {
await dropIndexApi({
table_name: selectedTable.value,
index_name: indexName,
});
message.success('删除成功');
await loadTableInfo();
} catch (error: any) {
message.error(error.message || '删除失败');
}
};
onMounted(() => {
loadTableList();
});
</script>
<template>
<Page auto-content-height title="数据库管理">
<div class="database-management">
<!-- 列表页 / 主页 -->
<div v-show="!selectedTable" class="h-full bg-white dark:bg-gray-900 rounded-lg p-2">
<!-- 增加 Tabs 切换数据表列表 / 全局 SQL 查询 -->
<Tabs v-model:activeKey="activeMainTab" class="h-full flex flex-col [&>.ant-tabs-content]:flex-1 [&>.ant-tabs-content]:overflow-auto">
<Tabs.TabPane key="list" tab="数据表列表">
<TableList
:table-list="filteredTableList"
:loading="tableLoading"
:selected-table="selectedTable"
:search-keyword="searchKeyword"
@update:search-keyword="searchKeyword = $event"
@select-table="handleSelectTable"
/>
</Tabs.TabPane>
<Tabs.TabPane key="sql" tab="全局 SQL 查询">
<!-- 主页面的 SQL 查询组件传入表列表 -->
<div class="h-full p-2">
<SqlQuery :table-list="tableList" />
</div>
</Tabs.TabPane>
</Tabs>
</div>
<!-- 详情页选择表后显示 -->
<div v-if="selectedTable" class="h-full">
<Card class="h-full flex flex-col" :body-style="{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }">
<template #title>
<div class="flex items-center gap-2">
<Button type="link" size="small" @click="handleBack" class="!px-0 !mr-2 text-gray-600 hover:text-primary">
<i class="fa-solid fa-arrow-left mr-1"></i>
返回
</Button>
<span class="font-bold text-lg">{{ selectedTable }}</span>
</div>
</template>
<div class="flex-1 overflow-hidden flex flex-col">
<Tabs v-model:activeKey="activeTab" @change="handleTabChange" class="h-full flex flex-col [&>.ant-tabs-content]:flex-1 [&>.ant-tabs-content]:overflow-auto">
<!-- 表结构 -->
<Tabs.TabPane key="structure" tab="表结构">
<TableStructure
:table-info="tableInfo"
:loading="tableInfoLoading"
@edit-table-comment="handleEditTableComment"
@edit-column-comment="handleEditColumnComment"
@edit-column="handleEditColumn"
@add-column="handleAddColumn"
@delete-column="handleDeleteColumn"
/>
</Tabs.TabPane>
<!-- 表数据 -->
<Tabs.TabPane key="data" tab="表数据">
<TableData
:table-info="tableInfo"
:table-data="tableData"
:loading="tableDataLoading"
:pagination="tableDataPagination"
@add-data="handleAddData"
@edit-data="handleEditData"
@delete-data="handleDeleteData"
@batch-delete="handleBatchDeleteData"
@search="handleSearch"
@page-change="loadTableData"
/>
</Tabs.TabPane>
<!-- 索引管理 -->
<Tabs.TabPane key="index" tab="索引管理">
<TableIndex
:table-info="tableInfo"
@add-index="handleAddIndex"
@delete-index="handleDeleteIndex"
/>
</Tabs.TabPane>
<!-- SQL查询 -->
<Tabs.TabPane key="sql" tab="SQL查询">
<!-- 详情页 SQL 组件同时传入 tableName tableList -->
<SqlQuery :table-name="selectedTable" :table-list="tableList" />
</Tabs.TabPane>
<!-- 变更记录 -->
<Tabs.TabPane key="changelog" tab="变更记录">
<ChangeLog />
</Tabs.TabPane>
</Tabs>
</div>
</Card>
</div>
</div>
<!-- Modals -->
<EditTableCommentModalComponent />
<EditColumnCommentModalComponent />
<EditColumnModalComponent />
<AddColumnModalComponent />
<EditDataModalComponent />
<AddIndexModalComponent />
</Page>
</template>
<style scoped>
.database-management {
min-height: calc(100vh - 200px);
}
/* 确保 Tabs 内容区域能撑满剩余高度,防止滚动条问题 */
:deep(.ant-tabs) {
display: flex;
flex-direction: column;
height: 100%;
}
:deep(.ant-tabs-content) {
flex: 1;
height: 100%;
overflow-y: auto;
}
</style>

View File

@@ -5,14 +5,14 @@ export default defineConfig(async () => {
application: {},
vite: {
server: {
port: 8866,
port: 14002,
proxy: {
'/api': {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
// mock代理目标地址
// target: 'http://localhost:5320/api',
target: 'http://127.0.0.1:18009/api/',
target: 'http://127.0.0.1:15002/api/',
ws: true,
},
},