feat: 重构数据中枢工作台与接入管理
- 新增统一的数据源、目录、纳管表与 Excel 处理后端能力 - 重建管理端数据中枢工作台并替换旧表管理页面 - 补充数据中枢迁移脚本、连接器底座与说明字段支持
This commit is contained in:
@@ -23,6 +23,31 @@ import { useAuthStore } from '#/store';
|
||||
import { refreshTokenApi } from './core';
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
const ERROR_MESSAGE_DEDUP_WINDOW = 800;
|
||||
const SILENT_ERROR_MESSAGES = new Set([
|
||||
'当前连接不可用,请检查连接配置后重试',
|
||||
'连接不存在',
|
||||
]);
|
||||
let lastErrorMessage = '';
|
||||
let lastErrorTimestamp = 0;
|
||||
|
||||
function showErrorOnce(message?: string) {
|
||||
const nextMessage = String(message || '').trim();
|
||||
if (!nextMessage) return;
|
||||
if (SILENT_ERROR_MESSAGES.has(nextMessage)) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (
|
||||
nextMessage === lastErrorMessage &&
|
||||
now - lastErrorTimestamp < ERROR_MESSAGE_DEDUP_WINDOW
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastErrorMessage = nextMessage;
|
||||
lastErrorTimestamp = now;
|
||||
ElMessage.error(nextMessage);
|
||||
}
|
||||
|
||||
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
const client = new RequestClient({
|
||||
@@ -80,7 +105,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
codeField: 'errorCode',
|
||||
dataField: 'data',
|
||||
showErrorMessage: (message) => {
|
||||
ElMessage.error(message);
|
||||
showErrorOnce(message);
|
||||
},
|
||||
successCode: 0,
|
||||
}),
|
||||
@@ -105,7 +130,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
const responseData = error?.response?.data ?? {};
|
||||
const errorMessage = responseData?.error ?? responseData?.message ?? '';
|
||||
// 如果没有错误信息,则会根据状态码进行提示
|
||||
ElMessage.error(errorMessage || msg);
|
||||
showErrorOnce(errorMessage || msg);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="#e12229"><path d="M27.556 45.503c.07-.057.1-.154.053-.235C22.815 34.98 16.84 25.286 9.8 16.382c0 0-5.597 5.3-5.198 10.632.2 2.482 1.356 4.787 3.243 6.4 4.88 4.757 16.697 10.76 19.445 12.12.087.048.196.032.264-.04m-1.824 4.08c-.037-.108-.138-.18-.253-.18v.006l-19.674.685c2.133 3.805 5.726 6.758 9.47 5.854 2.583-.646 8.437-4.728 10.364-6.106l-.002-.008c.147-.133.094-.24.094-.24m.297-1.77c.094-.155-.074-.288-.074-.288l.002-.008C17.313 41.682.565 32.733.565 32.733c-1.883 5.822 1.06 12.108 6.74 14.388a11.97 11.97 0 0 0 3.762.828c.296.055 11.7.006 14.756-.008.086-.01.162-.06.204-.137m1.305-39.92c-.858.074-3.17.6-3.17.6a9.13 9.13 0 0 0-6.441 6.075c-.54 2.046-.53 4.198.027 6.24 1.737 7.714 10.295 20.393 12.13 23.052.13.13.237.082.237.082.112-.027.188-.13.182-.245h.004c2.828-28.303-2.97-35.805-2.97-35.805m6.513 36.044c.108.043.23-.002.284-.106h.002c1.9-2.732 10.393-15.34 12.122-23.02a13.02 13.02 0 0 0 .033-6.239 9.13 9.13 0 0 0-6.502-6.073s-1.504-.38-3.098-.603c0 0-5.832 7.506-2.996 35.827h.004c.001.096.06.18.15.215M38.5 49.4s-.172.023-.22.15c-.023.097.006.198.076.268h-.002c1.882 1.35 7.6 5.344 10.344 6.118 0 0 5.086 1.73 9.504-5.856l-19.7-.687zm24.94-16.708S46.715 41.665 38.066 47.5h.002c-.1.065-.13.18-.1.288 0 0 .082.147.204.147v.004l15.1-.04a11.78 11.78 0 0 0 3.388-.78 11.54 11.54 0 0 0 6.333-6.057 11.89 11.89 0 0 0 .439-8.37m-26.954 12.8a.25.25 0 0 0 .266.02v.004c2.818-1.406 14.547-7.37 19.404-12.103a9.48 9.48 0 0 0 3.24-6.44c.354-5.5-5.192-10.605-5.192-10.605a146.15 146.15 0 0 0-17.778 28.8h.006a.27.27 0 0 0 .053.313"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 25.6 25.6"><path d="M179.076 94.886c-3.568-.1-6.336.268-8.656 1.25-.668.27-1.74.27-1.828 1.116.357.355.4.936.713 1.428.535.893 1.473 2.096 2.32 2.72l2.855 2.053c1.74 1.07 3.703 1.695 5.398 2.766.982.625 1.963 1.428 2.945 2.098.5.357.803.938 1.428 1.16v-.135c-.312-.4-.402-.98-.713-1.428l-1.34-1.293c-1.293-1.74-2.9-3.258-4.64-4.506-1.428-.982-4.55-2.32-5.13-3.97l-.088-.1c.98-.1 2.14-.447 3.078-.715 1.518-.4 2.9-.312 4.46-.713l2.143-.625v-.4c-.803-.803-1.383-1.874-2.23-2.632-2.275-1.963-4.775-3.882-7.363-5.488-1.383-.892-3.168-1.473-4.64-2.23-.537-.268-1.428-.402-1.74-.848-.805-.98-1.25-2.275-1.83-3.436l-3.658-7.763c-.803-1.74-1.295-3.48-2.275-5.086-4.596-7.585-9.594-12.18-17.268-16.687-1.65-.937-3.613-1.34-5.7-1.83l-3.346-.18c-.715-.312-1.428-1.16-2.053-1.562-2.543-1.606-9.102-5.086-10.977-.5-1.205 2.9 1.785 5.755 2.8 7.228.76 1.026 1.74 2.186 2.277 3.346.3.758.4 1.562.713 2.365.713 1.963 1.383 4.15 2.32 5.98.5.937 1.025 1.92 1.65 2.767.357.5.982.714 1.115 1.517-.625.893-.668 2.23-1.025 3.347-1.607 5.042-.982 11.288 1.293 15 .715 1.115 2.4 3.57 4.686 2.632 2.008-.803 1.56-3.346 2.14-5.577.135-.535.045-.892.312-1.25v.1l1.83 3.703c1.383 2.186 3.793 4.462 5.8 5.98 1.07.803 1.918 2.187 3.256 2.677v-.135h-.088c-.268-.4-.67-.58-1.027-.892-.803-.803-1.695-1.785-2.32-2.677-1.873-2.498-3.523-5.265-4.996-8.12-.715-1.383-1.34-2.9-1.918-4.283-.27-.536-.27-1.34-.715-1.606-.67.98-1.65 1.83-2.143 3.034-.848 1.918-.936 4.283-1.248 6.737-.18.045-.1 0-.18.1-1.426-.356-1.918-1.83-2.453-3.078-1.338-3.168-1.562-8.254-.402-11.913.312-.937 1.652-3.882 1.117-4.774-.27-.848-1.16-1.338-1.652-2.008-.58-.848-1.203-1.918-1.605-2.855-1.07-2.5-1.605-5.265-2.766-7.764-.537-1.16-1.473-2.365-2.232-3.435-.848-1.205-1.783-2.053-2.453-3.48-.223-.5-.535-1.294-.178-1.83.088-.357.268-.5.623-.58.58-.5 2.232.134 2.812.4 1.65.67 3.033 1.294 4.416 2.23.625.446 1.295 1.294 2.098 1.518h.938c1.428.312 3.033.1 4.37.5 2.365.76 4.506 1.874 6.426 3.08 5.844 3.703 10.664 8.968 13.92 15.26.535 1.026.758 1.963 1.25 3.034.938 2.187 2.098 4.417 3.033 6.56.938 2.097 1.83 4.24 3.168 5.98.67.937 3.346 1.427 4.55 1.918.893.4 2.275.76 3.08 1.25 1.516.937 3.033 2.008 4.46 3.034.713.534 2.945 1.65 3.078 2.54zm-45.5-38.772a7.09 7.09 0 0 0-1.828.223v.1h.088c.357.714.982 1.205 1.428 1.83l1.027 2.142.088-.1c.625-.446.938-1.16.938-2.23-.268-.312-.312-.625-.535-.937-.268-.446-.848-.67-1.206-1.026z" transform="matrix(.390229 0 0 .38781 -46.300037 -16.856717)" fill-rule="evenodd" fill="#00678c"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><path d="M0 0h64v64H0z" fill="#e30613"/><path d="M20.2 52.2C8.93 52.2 0 43.056 0 32c0-11.27 9.143-20.2 20.2-20.2h23.6C55.07 11.8 64 20.944 64 32c0 11.27-9.143 20.2-20.2 20.2zm23.176-7.23c7.23 0 13.183-5.953 13.183-13.183s-5.953-13.183-13.183-13.183H20.837c-7.23 0-13.183 5.953-13.183 13.183S13.608 44.97 20.837 44.97z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 401 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="64" viewBox="0 0 25.6 25.6" width="64"><style><![CDATA[.B{stroke-linecap:round}.C{stroke-linejoin:round}.D{stroke-linejoin:miter}.E{stroke-width:.716}]]></style><g fill="none" stroke="#fff"><path d="M18.983 18.636c.163-1.357.114-1.555 1.124-1.336l.257.023c.777.035 1.793-.125 2.4-.402 1.285-.596 2.047-1.592.78-1.33-2.89.596-3.1-.383-3.1-.383 3.053-4.53 4.33-10.28 3.227-11.687-3.004-3.84-8.205-2.024-8.292-1.976l-.028.005c-.57-.12-1.2-.19-1.93-.2-1.308-.02-2.3.343-3.054.914 0 0-9.277-3.822-8.846 4.807.092 1.836 2.63 13.9 5.66 10.25C8.29 15.987 9.36 14.86 9.36 14.86c.53.353 1.167.533 1.834.468l.052-.044a2.01 2.01 0 0 0 .021.518c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.162l-.046.183c.306.245.285 1.76.33 2.842s.116 2.093.337 2.688.48 2.13 2.53 1.7c1.713-.367 3.023-.896 3.143-5.81" fill="#000" stroke="#000" stroke-linecap="butt" stroke-width="2.149" class="D"/><path d="M23.535 15.6c-2.89.596-3.1-.383-3.1-.383 3.053-4.53 4.33-10.28 3.228-11.687-3.004-3.84-8.205-2.023-8.292-1.976l-.028.005a10.31 10.31 0 0 0-1.929-.201c-1.308-.02-2.3.343-3.054.914 0 0-9.278-3.822-8.846 4.807.092 1.836 2.63 13.9 5.66 10.25C8.29 15.987 9.36 14.86 9.36 14.86c.53.353 1.167.533 1.834.468l.052-.044a2.02 2.02 0 0 0 .021.518c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.162l-.046.183c.306.245.52 1.593.484 2.815s-.06 2.06.18 2.716.48 2.13 2.53 1.7c1.713-.367 2.6-1.32 2.725-2.906.088-1.128.286-.962.3-1.97l.16-.478c.183-1.53.03-2.023 1.085-1.793l.257.023c.777.035 1.794-.125 2.39-.402 1.285-.596 2.047-1.592.78-1.33z" fill="#336791" stroke="none"/><g class="E"><g class="B"><path d="M12.814 16.467c-.08 2.846.02 5.712.298 6.4s.875 2.05 2.926 1.612c1.713-.367 2.337-1.078 2.607-2.647l.633-5.017M10.356 2.2S1.072-1.596 1.504 7.033c.092 1.836 2.63 13.9 5.66 10.25C8.27 15.95 9.27 14.907 9.27 14.907m6.1-13.4c-.32.1 5.164-2.005 8.282 1.978 1.1 1.407-.175 7.157-3.228 11.687" class="C"/><path d="M20.425 15.17s.2.98 3.1.382c1.267-.262.504.734-.78 1.33-1.054.49-3.418.615-3.457-.06-.1-1.745 1.244-1.215 1.147-1.652-.088-.394-.69-.78-1.086-1.744-.347-.84-4.76-7.29 1.224-6.333.22-.045-1.56-5.7-7.16-5.782S7.99 8.196 7.99 8.196" stroke-linejoin="bevel"/></g><g class="C"><path d="M11.247 15.768c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.163.35-.49-.002-1.27-.482-1.468-.232-.096-.542-.216-.94.23z"/><path d="M11.196 15.753c-.08-.513.168-1.122.433-1.836.398-1.07 1.316-2.14.582-5.537-.547-2.53-4.22-.527-4.22-.184s.166 1.74-.06 3.365c-.297 2.122 1.35 3.916 3.246 3.733" class="B"/></g></g><g fill="#fff" class="D"><path d="M10.322 8.145c-.017.117.215.43.516.472s.558-.202.575-.32-.215-.246-.516-.288-.56.02-.575.136z" stroke-width=".239"/><path d="M19.486 7.906c.016.117-.215.43-.516.472s-.56-.202-.575-.32.215-.246.516-.288.56.02.575.136z" stroke-width=".119"/></g><path d="M20.562 7.095c.05.92-.198 1.545-.23 2.524-.046 1.422.678 3.05-.413 4.68" class="B C E"/></g></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -6,14 +6,39 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
meta: {
|
||||
icon: 'clarity:database',
|
||||
title: $t('datacenterTable.title'),
|
||||
title: $t('menus.ai.datacenter'),
|
||||
hideInMenu: true,
|
||||
activePath: '/datacenter',
|
||||
},
|
||||
name: 'DatacenterWorkspace',
|
||||
path: '/datacenter',
|
||||
component: () => import('#/views/datacenter/DatacenterWorkspace.vue'),
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'clarity:database',
|
||||
title: $t('menus.ai.datacenter'),
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
hideInBreadcrumb: true,
|
||||
activePath: '/datacenter',
|
||||
},
|
||||
name: 'TableDetail',
|
||||
path: '/datacenter/table/tableDetail',
|
||||
component: () => import('#/views/datacenter/DatacenterTableDetail.vue'),
|
||||
name: 'DatacenterSourceAccessLegacy',
|
||||
path: '/datacenter/source',
|
||||
redirect: '/datacenter',
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'clarity:database',
|
||||
title: $t('menus.ai.datacenter'),
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
hideInBreadcrumb: true,
|
||||
activePath: '/datacenter',
|
||||
},
|
||||
name: 'DatacenterDatasetManageLegacy',
|
||||
path: '/datacenter/dataset',
|
||||
redirect: '/datacenter',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadFile } from 'element-plus';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
||||
import { downloadFileFromBlob } from '@easyflow/utils';
|
||||
|
||||
import { ElButton, ElMessage, ElMessageBox, ElUpload } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import uploadIcon from '#/assets/datacenter/upload.png';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const props = withDefaults(defineProps<BatchImportModalProps>(), {
|
||||
title: 'title',
|
||||
});
|
||||
const emit = defineEmits(['reload']);
|
||||
export interface BatchImportModalProps {
|
||||
tableId: any;
|
||||
title?: string;
|
||||
}
|
||||
// vue
|
||||
onMounted(() => {});
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
// variables
|
||||
const dialogVisible = ref(false);
|
||||
const downloadLoading = ref(false);
|
||||
const fileList = ref<any[]>([]);
|
||||
const currentFile = ref<File | null>();
|
||||
const btnLoading = ref(false);
|
||||
// functions
|
||||
function openDialog() {
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
function closeDialog() {
|
||||
fileList.value = [];
|
||||
currentFile.value = null;
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
function onFileChange(uploadFile: UploadFile) {
|
||||
currentFile.value = uploadFile.raw;
|
||||
return false;
|
||||
}
|
||||
function downloadTemplate() {
|
||||
downloadLoading.value = true;
|
||||
api
|
||||
.download(`/api/v1/datacenterTable/getTemplate?tableId=${props.tableId}`)
|
||||
.then((res) => {
|
||||
downloadLoading.value = false;
|
||||
downloadFileFromBlob({
|
||||
fileName: 'template.xlsx',
|
||||
source: res,
|
||||
});
|
||||
});
|
||||
}
|
||||
function handleUpload() {
|
||||
if (!currentFile.value) {
|
||||
ElMessage.warning($t('datacenterTable.uploadDesc'));
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', currentFile.value);
|
||||
formData.append('tableId', props.tableId);
|
||||
btnLoading.value = true;
|
||||
api.postFile('/api/v1/datacenterTable/importData', formData).then((res) => {
|
||||
btnLoading.value = false;
|
||||
if (res.errorCode === 0) {
|
||||
const arr: any[] = res.data.errorRows;
|
||||
let html = '';
|
||||
for (const element of arr) {
|
||||
html += `<p>${JSON.stringify(element)}</p><br>`;
|
||||
}
|
||||
closeDialog();
|
||||
ElMessageBox.alert(
|
||||
`<strong>${$t('datacenterTable.totalNum')}:</strong>${res.data.totalCount}
|
||||
<strong>${$t('datacenterTable.successNum')}:</strong>${res.data.successCount}
|
||||
<strong>${$t('datacenterTable.failNum')}:</strong>${res.data.errorCount}<br>
|
||||
<strong>${$t('datacenterTable.failList')}:</strong>${html}`,
|
||||
$t('datacenterTable.importComplete'),
|
||||
{
|
||||
confirmButtonText: $t('message.ok'),
|
||||
dangerouslyUseHTMLString: true,
|
||||
callback: () => {
|
||||
emit('reload');
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowPanelModal
|
||||
v-model:open="dialogVisible"
|
||||
:closable="!btnLoading"
|
||||
:title="props.title"
|
||||
:before-close="closeDialog"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<ElUpload
|
||||
:file-list="fileList"
|
||||
drag
|
||||
action="#"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
:auto-upload="false"
|
||||
:on-change="onFileChange"
|
||||
:limit="1"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<img alt="" :src="uploadIcon" class="h-12 w-12" />
|
||||
<div class="text-base font-medium">
|
||||
{{ $t('datacenterTable.uploadTitle') }}
|
||||
</div>
|
||||
<div class="desc text-[13px]">
|
||||
{{ $t('datacenterTable.uploadDesc') }}
|
||||
</div>
|
||||
</div>
|
||||
</ElUpload>
|
||||
<ElButton
|
||||
:disabled="downloadLoading"
|
||||
type="primary"
|
||||
link
|
||||
@click="downloadTemplate"
|
||||
>
|
||||
{{ $t('datacenterTable.downloadTemplate') }}
|
||||
</ElButton>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="closeDialog">
|
||||
{{ $t('button.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
:disabled="btnLoading"
|
||||
:loading="btnLoading"
|
||||
type="primary"
|
||||
@click="handleUpload"
|
||||
>
|
||||
{{ $t('button.confirm') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</EasyFlowPanelModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desc {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,272 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Delete,
|
||||
MoreFilled,
|
||||
Plus,
|
||||
Upload,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElIcon,
|
||||
ElImage,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import tableIcon from '#/assets/datacenter/table2x.png';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import { router } from '#/router';
|
||||
import { useDictStore } from '#/store';
|
||||
import BatchImportModal from '#/views/datacenter/BatchImportModal.vue';
|
||||
import RecordModal from '#/views/datacenter/RecordModal.vue';
|
||||
|
||||
const pageDataRef = ref();
|
||||
const route = useRoute();
|
||||
const tableId = ref(route.query.tableId);
|
||||
onMounted(() => {
|
||||
initDict();
|
||||
getDetailInfo(tableId.value);
|
||||
getHeaders(tableId.value);
|
||||
});
|
||||
const detailInfo = ref<any>({});
|
||||
const fieldList = ref<any[]>([]);
|
||||
const headers = ref<any[]>([]);
|
||||
const activeMenu = ref('2');
|
||||
const recordModal = ref();
|
||||
const batchImportModal = ref();
|
||||
const dictStore = useDictStore();
|
||||
const categoryData = [
|
||||
{ key: '2', name: $t('datacenterTable.data') },
|
||||
{ key: '1', name: $t('datacenterTable.structure') },
|
||||
];
|
||||
function initDict() {
|
||||
dictStore.fetchDictionary('fieldType');
|
||||
dictStore.fetchDictionary('yesOrNo');
|
||||
}
|
||||
function getDetailInfo(id: any) {
|
||||
api.get(`/api/v1/datacenterTable/detailInfo?tableId=${id}`).then((res) => {
|
||||
detailInfo.value = res.data;
|
||||
fieldList.value = res.data.fields;
|
||||
});
|
||||
}
|
||||
function getHeaders(id: any) {
|
||||
api.get(`/api/v1/datacenterTable/getHeaders?tableId=${id}`).then((res) => {
|
||||
headers.value = res.data;
|
||||
});
|
||||
}
|
||||
function showDialog(row: any) {
|
||||
recordModal.value.openDialog({ ...row });
|
||||
}
|
||||
function remove(row: any) {
|
||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('message.ok'),
|
||||
cancelButtonText: $t('message.cancel'),
|
||||
type: 'warning',
|
||||
beforeClose: (action, instance, done) => {
|
||||
if (action === 'confirm') {
|
||||
instance.confirmButtonLoading = true;
|
||||
api
|
||||
.post(
|
||||
`/api/v1/datacenterTable/removeValue`,
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
tableId: tableId.value,
|
||||
id: row.id,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((res) => {
|
||||
instance.confirmButtonLoading = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
refresh();
|
||||
done();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
instance.confirmButtonLoading = false;
|
||||
});
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
function refresh() {
|
||||
pageDataRef.value.setQuery({});
|
||||
}
|
||||
function openImportModal() {
|
||||
batchImportModal.value.openDialog();
|
||||
}
|
||||
function changeTab(category: any) {
|
||||
activeMenu.value = category.key;
|
||||
if (category.key === '2' && pageDataRef.value) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background-deep flex h-full flex-col gap-6 p-6">
|
||||
<BatchImportModal
|
||||
:table-id="tableId"
|
||||
:title="$t('button.batchImport')"
|
||||
ref="batchImportModal"
|
||||
@reload="refresh"
|
||||
/>
|
||||
<RecordModal
|
||||
ref="recordModal"
|
||||
:form-items="headers"
|
||||
:table-id="tableId"
|
||||
@reload="refresh"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<ElIcon class="cursor-pointer" @click="router.back()">
|
||||
<ArrowLeft />
|
||||
</ElIcon>
|
||||
<div class="flex items-center gap-3">
|
||||
<ElImage :src="tableIcon" class="h-9 w-9 rounded-full" />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="text-base font-medium">{{ detailInfo.tableName }}</div>
|
||||
<div class="desc text-sm">{{ detailInfo.tableDesc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center" v-if="activeMenu === '2'">
|
||||
<ElButton type="primary" @click="showDialog({})">
|
||||
<ElIcon class="mr-1">
|
||||
<Plus />
|
||||
</ElIcon>
|
||||
{{ $t('button.addLine') }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="openImportModal">
|
||||
<ElIcon class="mr-1">
|
||||
<Upload />
|
||||
</ElIcon>
|
||||
{{ $t('button.batchImport') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full max-h-[calc(100vh-191px)] gap-3">
|
||||
<PageSide
|
||||
label-key="name"
|
||||
value-key="key"
|
||||
:menus="categoryData"
|
||||
default-selected="2"
|
||||
@change="changeTab"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="bg-background border-border flex-1 overflow-auto rounded-lg border p-5"
|
||||
>
|
||||
<ElTable v-show="activeMenu === '1'" :data="fieldList">
|
||||
<ElTableColumn
|
||||
prop="fieldName"
|
||||
:label="$t('datacenterTableFields.fieldName')"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="fieldDesc"
|
||||
:label="$t('datacenterTableFields.fieldDesc')"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="fieldType"
|
||||
:label="$t('datacenterTableFields.fieldType')"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ dictStore.getDictLabel('fieldType', row.fieldType) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
prop="required"
|
||||
:label="$t('datacenterTableFields.required')"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ dictStore.getDictLabel('yesOrNo', row.required) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<PageData
|
||||
v-show="activeMenu === '2'"
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/datacenterTable/getPageData"
|
||||
:extra-query-params="{ tableId }"
|
||||
:page-size="10"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable :data="pageList">
|
||||
<ElTableColumn
|
||||
v-for="item in headers"
|
||||
:key="item.key"
|
||||
:prop="item.key"
|
||||
:label="item.title"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div v-if="item.fieldType === 5">
|
||||
{{
|
||||
1 === row[item.key] ? $t('common.yes') : $t('common.no')
|
||||
}}
|
||||
</div>
|
||||
<div v-else-if="item.fieldType === 4">
|
||||
{{ parseFloat(row[item.key]) }}
|
||||
</div>
|
||||
<div v-else>{{ row[item.key] }}</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('common.handle')"
|
||||
width="90"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElButton link type="primary" @click="showDialog(row)">
|
||||
{{ $t('button.edit') }}
|
||||
</ElButton>
|
||||
|
||||
<ElDropdown>
|
||||
<ElButton link :icon="MoreFilled" />
|
||||
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem @click="remove(row)">
|
||||
<ElButton link :icon="Delete" type="danger">
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</template>
|
||||
</PageData>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desc {
|
||||
color: #75808d;
|
||||
}
|
||||
</style>
|
||||
@@ -1,204 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type {FormInstance} from 'element-plus';
|
||||
import {
|
||||
ElButton,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
} from 'element-plus';
|
||||
|
||||
import {markRaw, onMounted, ref} from 'vue';
|
||||
|
||||
import {Delete, MoreFilled, Plus} from '@element-plus/icons-vue';
|
||||
|
||||
import {api} from '#/api/request';
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import ListPageShell from '#/components/page/ListPageShell.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import {$t} from '#/locales';
|
||||
import {router} from '#/router';
|
||||
import {useDictStore} from '#/store';
|
||||
|
||||
import DatacenterTableModal from './DatacenterTableModal.vue';
|
||||
|
||||
onMounted(() => {
|
||||
initDict();
|
||||
});
|
||||
|
||||
const pageDataRef = ref();
|
||||
const saveDialog = ref();
|
||||
const dictStore = useDictStore();
|
||||
const headerButtons = [
|
||||
{
|
||||
key: 'create',
|
||||
text: $t('button.add'),
|
||||
icon: markRaw(Plus),
|
||||
type: 'primary',
|
||||
data: { action: 'create' },
|
||||
permission: '/api/v1/datacenterTable/save',
|
||||
},
|
||||
];
|
||||
|
||||
function initDict() {
|
||||
dictStore.fetchDictionary('dataStatus');
|
||||
}
|
||||
const handleSearch = (params: string) => {
|
||||
pageDataRef.value.setQuery({ tableName: params, isQueryOr: true });
|
||||
};
|
||||
function reset(formEl?: FormInstance) {
|
||||
formEl?.resetFields();
|
||||
pageDataRef.value.setQuery({});
|
||||
}
|
||||
function showDialog(row: any) {
|
||||
saveDialog.value.openDialog({ ...row });
|
||||
}
|
||||
function remove(row: any) {
|
||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('message.ok'),
|
||||
cancelButtonText: $t('message.cancel'),
|
||||
type: 'warning',
|
||||
beforeClose: (action, instance, done) => {
|
||||
if (action === 'confirm') {
|
||||
instance.confirmButtonLoading = true;
|
||||
api
|
||||
.get(`/api/v1/datacenterTable/removeTable?tableId=${row.id}`)
|
||||
.then((res) => {
|
||||
instance.confirmButtonLoading = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
reset();
|
||||
done();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
instance.confirmButtonLoading = false;
|
||||
});
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
function toDetailPage(row: any) {
|
||||
router.push({
|
||||
name: 'TableDetail',
|
||||
query: {
|
||||
tableId: row.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="datacenter-table-page flex h-full flex-col gap-6 p-6">
|
||||
<DatacenterTableModal ref="saveDialog" @reload="reset" />
|
||||
<ListPageShell>
|
||||
<template #filters>
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
@search="handleSearch"
|
||||
@button-click="showDialog({})"
|
||||
/>
|
||||
</template>
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/datacenterTable/page"
|
||||
:page-size="10"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable :data="pageList" border>
|
||||
<ElTableColumn
|
||||
prop="tableName"
|
||||
:label="$t('datacenterTable.tableName')"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ row.tableName }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
prop="tableDesc"
|
||||
:label="$t('datacenterTable.tableDesc')"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ row.tableDesc }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
prop="created"
|
||||
:label="$t('datacenterTable.created')"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ row.created }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('common.handle')"
|
||||
width="140"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center">
|
||||
<ElButton link type="primary" @click="toDetailPage(row)">
|
||||
{{ $t('button.view') }}
|
||||
</ElButton>
|
||||
<ElButton link type="primary" @click="showDialog(row)">
|
||||
{{ $t('button.edit') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElDropdown>
|
||||
<ElButton link :icon="MoreFilled" />
|
||||
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem @click="remove(row)">
|
||||
<ElButton link :icon="Delete" type="danger">
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</template>
|
||||
</PageData>
|
||||
</ListPageShell>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.datacenter-table-page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.datacenter-table-page::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 76% 34% at 2% 0%,
|
||||
hsl(var(--nav-ambient) / 0.16) 0%,
|
||||
transparent 64%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 56% 22% at 24% 12%,
|
||||
hsl(var(--nav-flow-core) / 0.08) 0%,
|
||||
transparent 68%
|
||||
);
|
||||
}
|
||||
|
||||
.datacenter-table-page > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,272 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
// vue
|
||||
onMounted(() => {
|
||||
getFieldType();
|
||||
getYesOrNo();
|
||||
});
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
const saveForm = ref<FormInstance>();
|
||||
// variables
|
||||
const dialogVisible = ref(false);
|
||||
const isAdd = ref(true);
|
||||
const entity = ref<any>({
|
||||
deptId: '',
|
||||
tableName: '',
|
||||
tableDesc: '',
|
||||
actualTable: '',
|
||||
status: '',
|
||||
options: '',
|
||||
});
|
||||
const btnLoading = ref(false);
|
||||
const rules = ref({
|
||||
tableName: [
|
||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||
{
|
||||
pattern: /^[a-z][a-z0-9_]*$/,
|
||||
message: $t('datacenterTable.nameRegx'),
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_: any, value: any, callback: any) => {
|
||||
if (!value || value.length === 0) {
|
||||
callback(new Error($t('datacenterTable.noFieldError')));
|
||||
} else {
|
||||
// 检查字段数组中的fieldName和fieldDesc字段
|
||||
value.forEach((field: any) => {
|
||||
if (!field.fieldName || !field.fieldDesc) {
|
||||
callback(new Error($t('datacenterTable.fieldInfoError')));
|
||||
}
|
||||
if (!/^[a-z][a-z0-9_]*$/.test(field.fieldName)) {
|
||||
callback(new Error($t('datacenterTable.nameRegx')));
|
||||
}
|
||||
});
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
});
|
||||
const fieldsData = ref();
|
||||
const removeFields = ref<any[]>([]);
|
||||
const loadFields = ref(false);
|
||||
const fieldTypes = ref<any>([]);
|
||||
const yesOrNoDict = ref<any>([]);
|
||||
watch(
|
||||
() => fieldsData.value,
|
||||
(newVal) => {
|
||||
entity.value.fields = newVal;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// functions
|
||||
function getDetailInfo(tableId: any) {
|
||||
loadFields.value = true;
|
||||
api
|
||||
.get(`/api/v1/datacenterTable/detailInfo?tableId=${tableId}`)
|
||||
.then((res) => {
|
||||
loadFields.value = false;
|
||||
fieldsData.value = res.data.fields;
|
||||
});
|
||||
}
|
||||
function openDialog(row: any) {
|
||||
fieldsData.value = [];
|
||||
removeFields.value = [];
|
||||
if (row.id) {
|
||||
getDetailInfo(row.id);
|
||||
isAdd.value = false;
|
||||
}
|
||||
entity.value = row;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
function save() {
|
||||
saveForm.value?.validate((valid) => {
|
||||
if (valid) {
|
||||
if (fieldsData.value.length === 0) {
|
||||
ElMessage.error($t('message.required'));
|
||||
return;
|
||||
}
|
||||
const obj = {
|
||||
...entity.value,
|
||||
fields: [...fieldsData.value, ...removeFields.value],
|
||||
};
|
||||
btnLoading.value = true;
|
||||
api
|
||||
.post('/api/v1/datacenterTable/saveTable', obj)
|
||||
.then((res) => {
|
||||
btnLoading.value = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
emit('reload');
|
||||
closeDialog();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btnLoading.value = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
function closeDialog() {
|
||||
saveForm.value?.resetFields();
|
||||
isAdd.value = true;
|
||||
entity.value = {};
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
function addField() {
|
||||
fieldsData.value.push({
|
||||
fieldName: '',
|
||||
fieldDesc: '',
|
||||
fieldType: 1,
|
||||
required: 0,
|
||||
handleDelete: false,
|
||||
rowKey: Date.now().toString(),
|
||||
});
|
||||
}
|
||||
function deleteField(row: any, $index: number) {
|
||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('message.ok'),
|
||||
cancelButtonText: $t('message.cancel'),
|
||||
type: 'warning',
|
||||
beforeClose: (action, _, done) => {
|
||||
if (action === 'confirm') {
|
||||
if (row.id) {
|
||||
row.handleDelete = true;
|
||||
removeFields.value.push(row);
|
||||
}
|
||||
fieldsData.value.splice($index, 1);
|
||||
done();
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
function getFieldType() {
|
||||
api.get('/api/v1/dict/items/fieldType').then((res) => {
|
||||
fieldTypes.value = res.data;
|
||||
});
|
||||
}
|
||||
function getYesOrNo() {
|
||||
api.get('/api/v1/dict/items/yesOrNo').then((res) => {
|
||||
yesOrNoDict.value = res.data;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowFormModal
|
||||
v-model:open="dialogVisible"
|
||||
:closable="!btnLoading"
|
||||
:title="isAdd ? $t('button.add') : $t('button.edit')"
|
||||
:before-close="closeDialog"
|
||||
width="800px"
|
||||
:confirm-loading="btnLoading"
|
||||
:confirm-text="$t('button.save')"
|
||||
:submitting="btnLoading"
|
||||
@confirm="save"
|
||||
>
|
||||
<ElForm
|
||||
ref="saveForm"
|
||||
:model="entity"
|
||||
status-icon
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="easyflow-modal-form easyflow-modal-form--compact"
|
||||
>
|
||||
<ElFormItem prop="tableName" :label="$t('datacenterTable.tableName')">
|
||||
<ElInput :disabled="!isAdd" v-model.trim="entity.tableName" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="tableDesc" :label="$t('datacenterTable.tableDesc')">
|
||||
<ElInput v-model.trim="entity.tableDesc" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="fields" :label="$t('datacenterTable.fields')">
|
||||
<div v-loading="loadFields" class="w-full">
|
||||
<ElTable :data="fieldsData">
|
||||
<ElTableColumn :label="$t('datacenterTable.fieldName')">
|
||||
<template #default="{ row }">
|
||||
<ElInput v-model.trim="row.fieldName" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('datacenterTable.fieldDesc')">
|
||||
<template #default="{ row }">
|
||||
<ElInput v-model.trim="row.fieldDesc" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('datacenterTable.fieldType')">
|
||||
<template #default="{ row }">
|
||||
<ElSelect v-model.trim="row.fieldType" :disabled="!!row.id">
|
||||
<ElOption
|
||||
v-for="item in fieldTypes"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('datacenterTable.required')">
|
||||
<template #default="{ row }">
|
||||
<ElSelect v-model.trim="row.required">
|
||||
<ElOption
|
||||
v-for="item in yesOrNoDict"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('common.handle')" width="80">
|
||||
<template #default="{ row, $index }">
|
||||
<ElButton link type="danger" @click="deleteField(row, $index)">
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<div class="mt-3">
|
||||
<ElButton plain type="primary" @click="addField">
|
||||
<ElIcon class="mr-1">
|
||||
<Plus />
|
||||
</ElIcon>
|
||||
{{ $t('button.add') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,539 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeNode } from './composables/use-connection-tree';
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
EasyFlowButton,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@easyflow-core/shadcn-ui';
|
||||
|
||||
import {
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElEmpty,
|
||||
ElMessageBox,
|
||||
} from 'element-plus';
|
||||
|
||||
import ConnectionTree from './components/ConnectionTree.vue';
|
||||
import ExcelActionDrawer from './components/ExcelActionDrawer.vue';
|
||||
import SourceFormDrawer from './components/SourceFormDrawer.vue';
|
||||
import TableDetailView from './components/TableDetailView.vue';
|
||||
import TableListView from './components/TableListView.vue';
|
||||
import { useConnectionTree } from './composables/use-connection-tree';
|
||||
import { useDatacenterExcel } from './composables/use-datacenter-excel';
|
||||
import { useDatacenterSources } from './composables/use-datacenter-sources';
|
||||
import { useDatacenterTables } from './composables/use-datacenter-tables';
|
||||
|
||||
const sourceFormRef = ref<InstanceType<typeof SourceFormDrawer>>();
|
||||
const excelActionRef = ref<InstanceType<typeof ExcelActionDrawer>>();
|
||||
const sourceFormVisible = ref(false);
|
||||
const excelActionVisible = ref(false);
|
||||
const workspaceRef = ref<HTMLElement>();
|
||||
const workspaceHeight = ref('100%');
|
||||
|
||||
const {
|
||||
sources,
|
||||
selectedSourceId,
|
||||
selectedSource,
|
||||
loading,
|
||||
saving,
|
||||
testing,
|
||||
loadSources,
|
||||
removeSource,
|
||||
saveSource,
|
||||
testConnection,
|
||||
markSourceRuntimeUnavailable,
|
||||
clearSourceRuntimeUnavailable,
|
||||
} = useDatacenterSources();
|
||||
|
||||
const {
|
||||
sourceTables,
|
||||
managedTables,
|
||||
schema,
|
||||
previewRows,
|
||||
jobs,
|
||||
sourceUnavailable,
|
||||
selectedCatalogId,
|
||||
selectedTableId,
|
||||
selectedTable,
|
||||
previewLoading,
|
||||
loadTableRuntime,
|
||||
syncSourceContext,
|
||||
batchRegisterTables,
|
||||
batchRemoveTables,
|
||||
saveDescriptions,
|
||||
} = useDatacenterTables(selectedSourceId, {
|
||||
markSourceRuntimeUnavailable,
|
||||
clearSourceRuntimeUnavailable,
|
||||
});
|
||||
|
||||
async function reloadAll(options?: {
|
||||
focus?: 'source' | 'table';
|
||||
resetTable?: boolean;
|
||||
}) {
|
||||
await loadSources();
|
||||
if (!selectedSourceId.value) {
|
||||
selectedCatalogId.value = null;
|
||||
selectedTableId.value = null;
|
||||
selectedNodeKey.value = '';
|
||||
viewMode.value = 'empty';
|
||||
await syncSourceContext();
|
||||
return;
|
||||
}
|
||||
if (options?.resetTable) {
|
||||
selectedTableId.value = null;
|
||||
}
|
||||
await syncSourceContext();
|
||||
if (options?.focus === 'table' && selectedTable.value) {
|
||||
selectedNodeKey.value = `table-${selectedTable.value.id}`;
|
||||
viewMode.value = 'detail';
|
||||
return;
|
||||
}
|
||||
selectedNodeKey.value = `source-${selectedSourceId.value}`;
|
||||
viewMode.value = 'list';
|
||||
}
|
||||
|
||||
const {
|
||||
actionLoading,
|
||||
pendingUploadFile,
|
||||
splitForm,
|
||||
mergeForm,
|
||||
deriveForm,
|
||||
exportForm,
|
||||
resetSplitForm,
|
||||
resetMergeForm,
|
||||
resetDeriveForm,
|
||||
resetExportForm,
|
||||
handleImport,
|
||||
handleSplit,
|
||||
handleMerge,
|
||||
handleDerive,
|
||||
handleExport,
|
||||
} = useDatacenterExcel(
|
||||
selectedSourceId,
|
||||
selectedCatalogId,
|
||||
selectedTableId,
|
||||
reloadAll,
|
||||
);
|
||||
|
||||
// 视图状态:empty | list | detail
|
||||
const selectedNodeKey = ref('');
|
||||
const viewMode = ref<'detail' | 'empty' | 'list'>('empty');
|
||||
|
||||
const isExcelContext = computed(() => {
|
||||
const st =
|
||||
selectedSource.value?.sourceType || schema.value?.source?.sourceType;
|
||||
return st === 'EXCEL' || st === 'EXCEL_MATERIALIZED';
|
||||
});
|
||||
const canImportExcel = computed(
|
||||
() => selectedSource.value?.sourceType === 'EXCEL',
|
||||
);
|
||||
const canExport = computed(() =>
|
||||
Boolean(selectedTable.value || selectedSource.value),
|
||||
);
|
||||
const canShowMoreActions = computed(
|
||||
() =>
|
||||
isExcelContext.value &&
|
||||
(Boolean(selectedTable.value) ||
|
||||
managedTables.value.length > 1 ||
|
||||
canExport.value),
|
||||
);
|
||||
const showToolbar = computed(
|
||||
() => canImportExcel.value || canShowMoreActions.value,
|
||||
);
|
||||
const { treeData, parseNodeKey } = useConnectionTree(sources);
|
||||
const selectedFieldOptions = computed(() =>
|
||||
(schema.value?.fields || []).map((f: any) => ({
|
||||
label: f.fieldName,
|
||||
value: f.fieldName,
|
||||
})),
|
||||
);
|
||||
|
||||
async function handleNodeSelect(node: TreeNode) {
|
||||
selectedNodeKey.value = node.id;
|
||||
|
||||
switch (node.type) {
|
||||
case 'source': {
|
||||
const { id } = parseNodeKey(node.id);
|
||||
if (id !== selectedSourceId.value) {
|
||||
selectedSourceId.value = id;
|
||||
selectedCatalogId.value = null;
|
||||
selectedTableId.value = null;
|
||||
await syncSourceContext();
|
||||
}
|
||||
viewMode.value = 'list';
|
||||
|
||||
break;
|
||||
}
|
||||
case 'table': {
|
||||
const { id } = parseNodeKey(node.id);
|
||||
selectedTableId.value = id;
|
||||
await loadTableRuntime();
|
||||
viewMode.value = 'detail';
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchRegister(rows: any[]) {
|
||||
await batchRegisterTables(rows);
|
||||
}
|
||||
|
||||
async function handleBatchRemove(rows: any[]) {
|
||||
const removedIds = rows.map((row) => row?.id).filter(Boolean);
|
||||
await batchRemoveTables(rows);
|
||||
if (selectedTableId.value && removedIds.includes(selectedTableId.value)) {
|
||||
viewMode.value = 'list';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveTableDescription(
|
||||
tableId: number | string,
|
||||
tableDesc: string,
|
||||
) {
|
||||
await saveDescriptions({
|
||||
tableId,
|
||||
tableDesc,
|
||||
fields: [],
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelectTable(row: any) {
|
||||
selectedTableId.value = row.id;
|
||||
selectedNodeKey.value = `table-${row.id}`;
|
||||
viewMode.value = 'detail';
|
||||
loadTableRuntime();
|
||||
}
|
||||
|
||||
function handleBackToList() {
|
||||
viewMode.value = 'list';
|
||||
if (selectedSource.value) {
|
||||
selectedNodeKey.value = `source-${selectedSource.value.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
sourceFormRef.value?.open();
|
||||
sourceFormVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(node: TreeNode) {
|
||||
if (node.type !== 'source') return;
|
||||
sourceFormRef.value?.open(node.meta);
|
||||
sourceFormVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleRemoveSource(node: TreeNode) {
|
||||
if (node.type !== 'source' || !node.meta?.id) return;
|
||||
await ElMessageBox.confirm(`确认删除“${node.label}”吗?`, '删除连接', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
});
|
||||
const removingCurrent = selectedSourceId.value === node.meta.id;
|
||||
await removeSource(node.meta.id);
|
||||
if (removingCurrent) {
|
||||
selectedTableId.value = null;
|
||||
}
|
||||
await reloadAll({ focus: 'source', resetTable: true });
|
||||
}
|
||||
|
||||
async function handleSaveSource(formData: Record<string, any>) {
|
||||
await saveSource(formData);
|
||||
sourceFormRef.value?.close();
|
||||
sourceFormVisible.value = false;
|
||||
await nextTick();
|
||||
await reloadAll({ focus: 'source', resetTable: true });
|
||||
}
|
||||
|
||||
async function handleTestConnection(formData: Record<string, any>) {
|
||||
const result = await testConnection(formData);
|
||||
sourceFormRef.value?.setTestResult(result);
|
||||
if (formData.id) await loadSources();
|
||||
}
|
||||
|
||||
function openExcelAction(
|
||||
action: 'derive' | 'export' | 'import' | 'merge' | 'split',
|
||||
) {
|
||||
switch (action) {
|
||||
case 'derive': {
|
||||
resetDeriveForm();
|
||||
break;
|
||||
}
|
||||
case 'export': {
|
||||
resetExportForm();
|
||||
break;
|
||||
}
|
||||
case 'merge': {
|
||||
resetMergeForm();
|
||||
break;
|
||||
}
|
||||
case 'split': {
|
||||
resetSplitForm();
|
||||
break;
|
||||
}
|
||||
}
|
||||
excelActionRef.value?.open(action);
|
||||
excelActionVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleExcelAction(action: string) {
|
||||
let success = false;
|
||||
switch (action) {
|
||||
case 'derive': {
|
||||
success = await handleDerive();
|
||||
break;
|
||||
}
|
||||
case 'export': {
|
||||
success = await handleExport(loadTableRuntime);
|
||||
break;
|
||||
}
|
||||
case 'import': {
|
||||
success = await handleImport();
|
||||
break;
|
||||
}
|
||||
case 'merge': {
|
||||
success = await handleMerge();
|
||||
break;
|
||||
}
|
||||
case 'split': {
|
||||
success = await handleSplit();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (success) excelActionVisible.value = false;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await reloadAll({ focus: 'source', resetTable: true });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateWorkspaceHeight() {
|
||||
const element = workspaceRef.value;
|
||||
if (!element) return;
|
||||
const top = element.getBoundingClientRect().top;
|
||||
workspaceHeight.value = `${Math.max(window.innerHeight - top, 480)}px`;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll();
|
||||
await nextTick();
|
||||
updateWorkspaceHeight();
|
||||
window.addEventListener('resize', updateWorkspaceHeight);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateWorkspaceHeight);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="workspaceRef"
|
||||
class="datacenter-workspace"
|
||||
:style="{ height: workspaceHeight }"
|
||||
v-loading="loading"
|
||||
>
|
||||
<!-- 顶部工具栏 -->
|
||||
<div v-if="showToolbar" class="workspace-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<EasyFlowButton
|
||||
v-if="canImportExcel"
|
||||
class="toolbar-button"
|
||||
variant="outline"
|
||||
@click="openExcelAction('import')"
|
||||
>
|
||||
导入 Excel
|
||||
</EasyFlowButton>
|
||||
<ElDropdown v-if="canShowMoreActions">
|
||||
<EasyFlowButton class="toolbar-button" variant="outline">
|
||||
更多操作
|
||||
</EasyFlowButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-if="canExport"
|
||||
@click="openExcelAction('export')"
|
||||
>
|
||||
导出
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="selectedTable"
|
||||
@click="openExcelAction('split')"
|
||||
>
|
||||
拆分
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="managedTables.length > 1"
|
||||
@click="openExcelAction('merge')"
|
||||
>
|
||||
合并
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="selectedTable"
|
||||
@click="openExcelAction('derive')"
|
||||
>
|
||||
生成新表
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 双栏主体 -->
|
||||
<ResizablePanelGroup direction="horizontal" class="workspace-body">
|
||||
<ResizablePanel :default-size="20" :min-size="16" :max-size="30">
|
||||
<ConnectionTree
|
||||
:tree-data="treeData"
|
||||
:selected-key="selectedNodeKey"
|
||||
@create="openCreate"
|
||||
@edit="openEdit"
|
||||
@refresh="loadAll"
|
||||
@remove="handleRemoveSource"
|
||||
@select="handleNodeSelect"
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
<ResizablePanel :default-size="80" :min-size="44">
|
||||
<TableListView
|
||||
v-if="viewMode === 'list'"
|
||||
:source-tables="sourceTables"
|
||||
:managed-tables="managedTables"
|
||||
:save-table-description="handleSaveTableDescription"
|
||||
:source-label="selectedSource?.sourceName || ''"
|
||||
:source-unavailable="sourceUnavailable"
|
||||
@register-tables="handleBatchRegister"
|
||||
@remove-tables="handleBatchRemove"
|
||||
@select-table="handleSelectTable"
|
||||
/>
|
||||
<TableDetailView
|
||||
v-else-if="viewMode === 'detail' && selectedTable"
|
||||
:table="selectedTable"
|
||||
:schema="schema"
|
||||
:preview-rows="previewRows"
|
||||
:jobs="jobs"
|
||||
:loading="previewLoading"
|
||||
:save-descriptions="saveDescriptions"
|
||||
:source-unavailable="sourceUnavailable"
|
||||
@back="handleBackToList"
|
||||
/>
|
||||
<div v-else class="empty-state">
|
||||
<ElEmpty description="从左侧选择连接或表开始浏览" />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<!-- Drawers -->
|
||||
<SourceFormDrawer
|
||||
ref="sourceFormRef"
|
||||
v-model:visible="sourceFormVisible"
|
||||
:saving="saving"
|
||||
:testing="testing"
|
||||
@save="handleSaveSource"
|
||||
@test="handleTestConnection"
|
||||
/>
|
||||
|
||||
<ExcelActionDrawer
|
||||
ref="excelActionRef"
|
||||
v-model:visible="excelActionVisible"
|
||||
v-model:split-form="splitForm"
|
||||
v-model:merge-form="mergeForm"
|
||||
v-model:derive-form="deriveForm"
|
||||
v-model:export-form="exportForm"
|
||||
:action-loading="actionLoading"
|
||||
:managed-tables="managedTables"
|
||||
:field-options="selectedFieldOptions"
|
||||
:pending-upload-file="pendingUploadFile"
|
||||
@update:pending-upload-file="(f) => (pendingUploadFile = f)"
|
||||
@import="handleExcelAction('import')"
|
||||
@split="handleExcelAction('split')"
|
||||
@merge="handleExcelAction('merge')"
|
||||
@derive="handleExcelAction('derive')"
|
||||
@export="handleExcelAction('export')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.datacenter-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 12px;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
min-width: 84px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.workspace-body {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workspace-body :deep([data-panel-group-direction='horizontal']) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.workspace-body :deep([data-panel-resize-handle-enabled]) {
|
||||
position: relative;
|
||||
width: 10px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workspace-body :deep([data-panel-resize-handle-enabled]::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: hsl(var(--border) / 0.32);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -1,145 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
ElDatePicker,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import DictSelect from '#/components/dict/DictSelect.vue';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const props = withDefaults(defineProps<RecordModalProps>(), {});
|
||||
const emit = defineEmits(['reload']);
|
||||
// vue
|
||||
export interface RecordModalProps {
|
||||
formItems: any[];
|
||||
tableId: any;
|
||||
}
|
||||
onMounted(() => {});
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
const saveForm = ref<FormInstance>();
|
||||
// variables
|
||||
const dialogVisible = ref(false);
|
||||
const isAdd = ref(true);
|
||||
const entity = ref<any>({});
|
||||
const btnLoading = ref(false);
|
||||
const rules = ref({});
|
||||
|
||||
// functions
|
||||
function openDialog(row: any) {
|
||||
if (row.id) {
|
||||
isAdd.value = false;
|
||||
}
|
||||
entity.value = row;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
function save() {
|
||||
saveForm.value?.validate((valid) => {
|
||||
if (valid) {
|
||||
const data: Record<string, any> = {};
|
||||
for (const formItem of props.formItems) {
|
||||
data[formItem.key] = entity.value[formItem.key];
|
||||
}
|
||||
data.tableId = props.tableId;
|
||||
data.id = entity.value.id;
|
||||
btnLoading.value = true;
|
||||
api
|
||||
.postForm('/api/v1/datacenterTable/saveValue', data)
|
||||
.then((res) => {
|
||||
btnLoading.value = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
emit('reload');
|
||||
closeDialog();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btnLoading.value = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
function closeDialog() {
|
||||
saveForm.value?.resetFields();
|
||||
isAdd.value = true;
|
||||
entity.value = {};
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowFormModal
|
||||
v-model:open="dialogVisible"
|
||||
:closable="!btnLoading"
|
||||
:title="isAdd ? $t('button.add') : $t('button.edit')"
|
||||
:before-close="closeDialog"
|
||||
width="800px"
|
||||
:confirm-loading="btnLoading"
|
||||
:confirm-text="$t('button.save')"
|
||||
:submitting="btnLoading"
|
||||
@confirm="save"
|
||||
>
|
||||
<ElForm
|
||||
ref="saveForm"
|
||||
:model="entity"
|
||||
status-icon
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="easyflow-modal-form easyflow-modal-form--compact"
|
||||
>
|
||||
<ElFormItem
|
||||
v-for="item in props.formItems"
|
||||
:rules="
|
||||
item.required
|
||||
? [
|
||||
{
|
||||
required: true,
|
||||
message: `${item.title}${$t('message.notEmpty')}`,
|
||||
},
|
||||
]
|
||||
: []
|
||||
"
|
||||
:key="item.key"
|
||||
:prop="item.key"
|
||||
:label="item.title"
|
||||
>
|
||||
<ElInput v-if="item.fieldType === 1" v-model.trim="entity[item.key]" />
|
||||
<ElInputNumber
|
||||
v-if="item.fieldType === 2"
|
||||
v-model.number="entity[item.key]"
|
||||
:precision="0"
|
||||
/>
|
||||
<ElDatePicker
|
||||
v-if="item.fieldType === 3"
|
||||
v-model="entity[item.key]"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
<ElInputNumber
|
||||
v-if="item.fieldType === 4"
|
||||
v-model="entity[item.key]"
|
||||
:precision="2"
|
||||
/>
|
||||
<DictSelect
|
||||
v-if="item.fieldType === 5"
|
||||
v-model="entity[item.key]"
|
||||
dict-code="yesOrNo"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,474 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeNode } from '../composables/use-connection-tree';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
|
||||
|
||||
import { Plus, RefreshRight, Search } from '@element-plus/icons-vue';
|
||||
import { ElIcon, ElInput, ElTooltip } from 'element-plus';
|
||||
|
||||
import { formatSourceType } from '../composables/datacenter-constants';
|
||||
import SourceBrandIcon from './SourceBrandIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
selectedKey: string;
|
||||
treeData: TreeNode[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
edit: [node: TreeNode];
|
||||
refresh: [];
|
||||
remove: [node: TreeNode];
|
||||
select: [node: TreeNode];
|
||||
}>();
|
||||
|
||||
const searchText = ref('');
|
||||
const expandedKeys = ref<Set<string>>(new Set());
|
||||
const contextMenuVisible = ref(false);
|
||||
const contextMenuNode = ref<null | TreeNode>(null);
|
||||
const contextMenuX = ref(0);
|
||||
const contextMenuY = ref(0);
|
||||
|
||||
const filteredTree = computed(() => {
|
||||
const keyword = searchText.value.trim().toLowerCase();
|
||||
if (!keyword) return props.treeData;
|
||||
return props.treeData.filter((node) =>
|
||||
node.label.toLowerCase().includes(keyword),
|
||||
);
|
||||
});
|
||||
|
||||
function toggleExpand(nodeId: string) {
|
||||
if (expandedKeys.value.has(nodeId)) {
|
||||
expandedKeys.value.delete(nodeId);
|
||||
} else {
|
||||
expandedKeys.value.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(node: TreeNode) {
|
||||
if (node.children && node.children.length > 0) {
|
||||
toggleExpand(node.id);
|
||||
}
|
||||
emit('select', node);
|
||||
}
|
||||
|
||||
function handleContextMenu(event: MouseEvent, node: TreeNode) {
|
||||
event.preventDefault();
|
||||
if (node.type !== 'source' || node.meta?.builtinFlag) return;
|
||||
emit('select', node);
|
||||
contextMenuNode.value = node;
|
||||
contextMenuX.value = Math.min(event.clientX, window.innerWidth - 156);
|
||||
contextMenuY.value = Math.min(event.clientY, window.innerHeight - 108);
|
||||
contextMenuVisible.value = true;
|
||||
}
|
||||
|
||||
function isExpanded(nodeId: string) {
|
||||
return expandedKeys.value.has(nodeId);
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenuVisible.value = false;
|
||||
contextMenuNode.value = null;
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
if (contextMenuNode.value) {
|
||||
emit('edit', contextMenuNode.value);
|
||||
}
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
if (contextMenuNode.value) {
|
||||
emit('remove', contextMenuNode.value);
|
||||
}
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
function getNodeIcon(node: TreeNode): string {
|
||||
if (node.type === 'source') {
|
||||
return node.icon === 'file'
|
||||
? 'i-lucide-file-spreadsheet'
|
||||
: 'i-lucide-database';
|
||||
}
|
||||
return 'i-lucide-table';
|
||||
}
|
||||
|
||||
function getSourceMetaLabel(node: TreeNode) {
|
||||
const sourceType = node.meta?.sourceType;
|
||||
const sourceName = (node.label || '').trim();
|
||||
const typeLabel = formatSourceType(sourceType);
|
||||
if (!typeLabel || typeLabel === sourceName) {
|
||||
return '';
|
||||
}
|
||||
return typeLabel;
|
||||
}
|
||||
|
||||
function isSourceUnavailable(node: TreeNode) {
|
||||
return Boolean(node.type === 'source' && node.meta?.runtimeUnavailable);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', closeContextMenu);
|
||||
window.addEventListener('resize', closeContextMenu);
|
||||
window.addEventListener('scroll', closeContextMenu, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', closeContextMenu);
|
||||
window.removeEventListener('resize', closeContextMenu);
|
||||
window.removeEventListener('scroll', closeContextMenu, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="connection-tree">
|
||||
<div class="tree-search">
|
||||
<div class="search-actions">
|
||||
<EasyFlowButton size="sm" class="create-action" @click="emit('create')">
|
||||
<ElIcon class="action-icon"><Plus /></ElIcon>
|
||||
新增连接
|
||||
</EasyFlowButton>
|
||||
<EasyFlowButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="refresh-action"
|
||||
aria-label="刷新"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<ElIcon class="action-icon"><RefreshRight /></ElIcon>
|
||||
</EasyFlowButton>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<ElInput v-model="searchText" placeholder="搜索连接..." clearable>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tree-scroll">
|
||||
<div v-if="filteredTree.length > 0" class="tree-list">
|
||||
<template v-for="source in filteredTree" :key="source.id">
|
||||
<div
|
||||
class="tree-node tree-node--source"
|
||||
:class="{ 'is-selected': selectedKey === source.id }"
|
||||
@click="handleClick(source)"
|
||||
@contextmenu="handleContextMenu($event, source)"
|
||||
>
|
||||
<span class="node-media">
|
||||
<SourceBrandIcon :source-type="source.meta?.sourceType" />
|
||||
</span>
|
||||
<span class="node-content">
|
||||
<span class="node-label">{{ source.label }}</span>
|
||||
<span v-if="getSourceMetaLabel(source)" class="node-meta">
|
||||
{{ getSourceMetaLabel(source) }}
|
||||
</span>
|
||||
</span>
|
||||
<ElTooltip
|
||||
v-if="isSourceUnavailable(source)"
|
||||
content="连接不可用"
|
||||
placement="top"
|
||||
>
|
||||
<span class="node-status-dot" aria-label="连接不可用"></span>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
|
||||
<!-- 表节点 -->
|
||||
<template v-if="isExpanded(source.id) && source.children">
|
||||
<div
|
||||
v-for="table in source.children"
|
||||
:key="table.id"
|
||||
class="tree-node tree-node--table"
|
||||
:class="{ 'is-selected': selectedKey === table.id }"
|
||||
@click="handleClick(table)"
|
||||
@contextmenu="handleContextMenu($event, table)"
|
||||
>
|
||||
<span class="node-icon" :class="[getNodeIcon(table)]"></span>
|
||||
<span class="node-label">{{ table.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else class="tree-empty">
|
||||
{{ searchText ? '无匹配连接' : '还没有数据连接' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contextMenuVisible && contextMenuNode"
|
||||
class="connection-context-menu"
|
||||
:style="{ left: `${contextMenuX}px`, top: `${contextMenuY}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<button type="button" class="context-menu-item" @click="handleEdit">
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="context-menu-item context-menu-item--danger"
|
||||
@click="handleRemove"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.connection-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0 12px 0 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tree-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.search-row :deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-row :deep(.el-input__wrapper) {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.create-action {
|
||||
flex: 1;
|
||||
padding-inline: 12px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.refresh-action {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding-inline: 0;
|
||||
border-color: hsl(var(--border) / 0.9);
|
||||
background: hsl(var(--card));
|
||||
color: hsl(var(--text-strong));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.refresh-action:hover {
|
||||
background: hsl(var(--surface-subtle));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.tree-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 62px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid hsl(var(--border) / 0.52);
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--card) / 0.92);
|
||||
box-shadow: 0 10px 22px -22px hsl(var(--foreground) / 0.22);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: hsl(var(--foreground));
|
||||
transition:
|
||||
transform 0.16s,
|
||||
background-color 0.16s,
|
||||
color 0.16s,
|
||||
border-color 0.16s,
|
||||
box-shadow 0.16s;
|
||||
}
|
||||
|
||||
.tree-node:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: hsl(var(--border) / 0.74);
|
||||
background: hsl(var(--card));
|
||||
box-shadow: 0 16px 30px -24px hsl(var(--foreground) / 0.22);
|
||||
}
|
||||
|
||||
.tree-node.is-selected {
|
||||
border-color: hsl(var(--primary) / 0.24);
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
color: hsl(var(--foreground));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px hsl(var(--primary) / 0.12),
|
||||
0 18px 34px -28px hsl(var(--primary) / 0.24);
|
||||
}
|
||||
|
||||
.tree-node--source {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tree-node--table {
|
||||
margin-left: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.node-arrow.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.node-media {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
border-radius: 11px;
|
||||
background: hsl(var(--surface-subtle) / 0.92);
|
||||
}
|
||||
|
||||
.tree-node.is-selected .node-media {
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.node-media :deep(svg) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.tree-node.is-selected .node-icon {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: normal;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.node-meta {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.node-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--destructive));
|
||||
box-shadow: 0 0 0 3px hsl(var(--destructive) / 0.12);
|
||||
}
|
||||
|
||||
.tree-empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.connection-context-menu {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
min-width: 136px;
|
||||
padding: 6px;
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--popover));
|
||||
box-shadow: 0 18px 34px -28px hsl(var(--foreground) / 0.26);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: hsl(var(--accent) / 0.86);
|
||||
}
|
||||
|
||||
.context-menu-item--danger {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.context-menu-item--danger:hover {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadUserFile } from 'element-plus';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElDrawer,
|
||||
ElInput,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElUpload,
|
||||
} from 'element-plus';
|
||||
|
||||
defineProps<{
|
||||
actionLoading: boolean;
|
||||
managedTables: any[];
|
||||
fieldOptions: { label: string; value: string }[];
|
||||
pendingUploadFile: File | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'import'): void;
|
||||
(e: 'split'): void;
|
||||
(e: 'merge'): void;
|
||||
(e: 'derive'): void;
|
||||
(e: 'export'): void;
|
||||
(e: 'update:pendingUploadFile', file: File | null): void;
|
||||
}>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
const activeAction = ref<'derive' | 'export' | 'import' | 'merge' | 'split'>('import');
|
||||
|
||||
const splitForm = defineModel<any>('splitForm', { required: true });
|
||||
const mergeForm = defineModel<any>('mergeForm', { required: true });
|
||||
const deriveForm = defineModel<any>('deriveForm', { required: true });
|
||||
const exportForm = defineModel<any>('exportForm', { required: true });
|
||||
|
||||
const drawerTitle = computed(() => {
|
||||
const titles: Record<string, string> = {
|
||||
import: '导入 Excel',
|
||||
split: '拆分数据',
|
||||
merge: '合并数据',
|
||||
derive: '生成新表',
|
||||
export: '导出数据',
|
||||
};
|
||||
return titles[activeAction.value] || '数据操作';
|
||||
});
|
||||
|
||||
function open(action: 'derive' | 'export' | 'import' | 'merge' | 'split') {
|
||||
activeAction.value = action;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function handleUploadChange(file: UploadUserFile) {
|
||||
emit('update:pendingUploadFile', file.raw || null);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
const action = activeAction.value;
|
||||
if (action === 'import') emit('import');
|
||||
else if (action === 'split') emit('split');
|
||||
else if (action === 'merge') emit('merge');
|
||||
else if (action === 'derive') emit('derive');
|
||||
else if (action === 'export') emit('export');
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
size="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="action-form">
|
||||
<!-- 导入 -->
|
||||
<template v-if="activeAction === 'import'">
|
||||
<ElUpload
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:show-file-list="true"
|
||||
:limit="1"
|
||||
:on-change="handleUploadChange"
|
||||
>
|
||||
<div class="upload-hint">拖拽或点击上传 Excel 文件</div>
|
||||
</ElUpload>
|
||||
</template>
|
||||
|
||||
<!-- 拆分 -->
|
||||
<template v-if="activeAction === 'split'">
|
||||
<ElSelect v-model="splitForm.splitMode">
|
||||
<ElOption label="按行数" value="BY_ROW_COUNT" />
|
||||
<ElOption label="按字段值" value="BY_FIELD_VALUE" />
|
||||
<ElOption label="按工作表" value="BY_SHEET" />
|
||||
</ElSelect>
|
||||
<ElInput v-model="splitForm.targetNamePrefix" placeholder="新表名前缀" />
|
||||
<ElInput
|
||||
v-if="splitForm.splitMode === 'BY_ROW_COUNT'"
|
||||
v-model="splitForm.rowBatchSize"
|
||||
placeholder="每份行数"
|
||||
/>
|
||||
<ElSelect
|
||||
v-if="splitForm.splitMode === 'BY_FIELD_VALUE'"
|
||||
v-model="splitForm.fieldName"
|
||||
placeholder="选择拆分字段"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in fieldOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<!-- 合并 -->
|
||||
<template v-if="activeAction === 'merge'">
|
||||
<ElSelect v-model="mergeForm.mergeMode">
|
||||
<ElOption label="纵向合并" value="VERTICAL" />
|
||||
<ElOption label="横向合并" value="HORIZONTAL" />
|
||||
</ElSelect>
|
||||
<ElSelect
|
||||
v-model="mergeForm.tableIds"
|
||||
multiple
|
||||
collapse-tags
|
||||
placeholder="选择要合并的表"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in managedTables"
|
||||
:key="item.id"
|
||||
:label="item.tableName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElInput v-model="mergeForm.targetTableName" placeholder="新表名称" />
|
||||
<ElInput
|
||||
v-if="mergeForm.mergeMode === 'HORIZONTAL'"
|
||||
v-model="mergeForm.joinKey"
|
||||
placeholder="关联字段"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 生成新表 -->
|
||||
<template v-if="activeAction === 'derive'">
|
||||
<ElInput v-model="deriveForm.targetTableName" placeholder="新表名称" />
|
||||
<ElInput
|
||||
v-model="deriveForm.selectedColumnsText"
|
||||
placeholder="保留字段,多个字段用逗号分隔"
|
||||
/>
|
||||
<ElInput
|
||||
v-model="deriveForm.renameMappingsText"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder='字段重命名 JSON,例如 {"old":"new"}'
|
||||
/>
|
||||
<ElInput
|
||||
v-model="deriveForm.filtersText"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder='筛选条件 JSON,例如 [{"column":"status","operator":"EQ","value":"OPEN"}]'
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 导出 -->
|
||||
<template v-if="activeAction === 'export'">
|
||||
<ElSelect v-model="exportForm.exportScope">
|
||||
<ElOption label="当前表" value="TABLE" />
|
||||
<ElOption label="当前库" value="WORKBOOK" />
|
||||
</ElSelect>
|
||||
<ElInput v-model="exportForm.fileName" placeholder="导出文件名,可选" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<ElButton @click="visible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="actionLoading" @click="handleConfirm">
|
||||
{{ activeAction === 'import' ? '开始导入' :
|
||||
activeAction === 'split' ? '开始拆分' :
|
||||
activeAction === 'merge' ? '开始合并' :
|
||||
activeAction === 'derive' ? '开始生成' :
|
||||
'生成并下载' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
padding: 24px;
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import huaweiIcon from '#/assets/datacenter/huawei-icon.svg';
|
||||
import mysqlIcon from '#/assets/datacenter/mysql-icon.svg';
|
||||
import postgresqlIcon from '#/assets/datacenter/postgresql-icon.svg';
|
||||
|
||||
const props = defineProps<{
|
||||
sourceType?: string;
|
||||
}>();
|
||||
|
||||
const sourceLogoMap: Record<string, string> = {
|
||||
EXCEL: 'excel',
|
||||
EXCEL_MATERIALIZED: 'excel',
|
||||
GAUSSDB_NATIVE: 'gaussdb',
|
||||
GBASE_8A: 'gbase',
|
||||
GBASE_8S: 'gbase',
|
||||
MYSQL: 'mysql',
|
||||
ORACLE: 'oracle',
|
||||
POSTGRESQL: 'postgresql',
|
||||
PROJECT_MYSQL: 'mysql',
|
||||
};
|
||||
|
||||
const logoType = computed(
|
||||
() => sourceLogoMap[props.sourceType || ''] || 'default',
|
||||
);
|
||||
const assetLogoMap: Record<string, string> = {
|
||||
gaussdb: huaweiIcon,
|
||||
mysql: mysqlIcon,
|
||||
postgresql: postgresqlIcon,
|
||||
};
|
||||
const assetLogo = computed(() => assetLogoMap[logoType.value] || '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
v-if="assetLogo"
|
||||
class="brand-logo-image"
|
||||
:src="assetLogo"
|
||||
:alt="`${props.sourceType || 'database'} logo`"
|
||||
/>
|
||||
|
||||
<svg
|
||||
v-else-if="logoType === 'oracle'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4.6" y="7.2" width="14.8" height="9.6" rx="4.8" fill="#ea4335" />
|
||||
<rect x="7.1" y="9.35" width="9.8" height="5.3" rx="2.65" fill="white" />
|
||||
<rect x="8.4" y="10.65" width="7.2" height="2.7" rx="1.35" fill="#ea4335" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else-if="logoType === 'gbase'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="8" fill="#10b981" />
|
||||
<path
|
||||
d="M15.85 9.2C15.1 8.02 13.8 7.3 12.38 7.3C9.94 7.3 8.1 9.14 8.1 11.98C8.1 14.87 10.05 16.7 12.56 16.7C13.88 16.7 15.03 16.18 15.88 15.15V12.55H12.45"
|
||||
stroke="white"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.7"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else-if="logoType === 'excel'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4.4" y="4.2" width="15.2" height="15.6" rx="3.2" fill="#16a34a" />
|
||||
<path
|
||||
d="M9 9L11.1 12L9 15M15 9L12.9 12L15 15"
|
||||
stroke="white"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.65"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<ellipse cx="12" cy="6.5" rx="5.75" ry="2.5" fill="#94a3b8" />
|
||||
<path
|
||||
d="M6.25 6.5V12.2C6.25 13.58 8.82 14.7 12 14.7C15.18 14.7 17.75 13.58 17.75 12.2V6.5"
|
||||
fill="#cbd5e1"
|
||||
/>
|
||||
<path
|
||||
d="M6.25 12.2V17.35C6.25 18.72 8.82 19.85 12 19.85C15.18 19.85 17.75 18.72 17.75 17.35V12.2"
|
||||
fill="#e2e8f0"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brand-logo-image {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,364 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
|
||||
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
sourceTypeLabels,
|
||||
sourceTypeOptions,
|
||||
} from '../composables/datacenter-constants';
|
||||
import { useSourceForm } from '../composables/use-source-form';
|
||||
import SourceBrandIcon from './SourceBrandIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
saving: boolean;
|
||||
testing: boolean;
|
||||
visible: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [form: Record<string, any>];
|
||||
test: [form: Record<string, any>];
|
||||
'update:visible': [value: boolean];
|
||||
}>();
|
||||
const formRef = ref<FormInstance>();
|
||||
const testResult = ref<any>(null);
|
||||
const showAdvanced = ref(false);
|
||||
|
||||
const {
|
||||
form,
|
||||
rules,
|
||||
supportsExternalConnection,
|
||||
resetForm: baseResetForm,
|
||||
} = useSourceForm();
|
||||
|
||||
const modalTitle = computed(() => (form.id ? '编辑连接' : '新建连接'));
|
||||
const testButtonLabel = computed(() => {
|
||||
if (props.testing) return '测试中...';
|
||||
if (testResult.value?.success === true) return '连接成功';
|
||||
if (testResult.value?.success === false) return '连接失败';
|
||||
return '测试连接';
|
||||
});
|
||||
const testButtonClass = computed(() => ({
|
||||
'is-test-success': testResult.value?.success === true,
|
||||
'is-test-failed': testResult.value?.success === false,
|
||||
}));
|
||||
const selectedSourceTypeLabel = computed(
|
||||
() => sourceTypeLabels[form.sourceType] || form.sourceType,
|
||||
);
|
||||
|
||||
function resetDialogState() {
|
||||
testResult.value = null;
|
||||
showAdvanced.value = false;
|
||||
}
|
||||
|
||||
function open(row?: any) {
|
||||
baseResetForm(row);
|
||||
resetDialogState();
|
||||
emit('update:visible', true);
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false);
|
||||
resetDialogState();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await formRef.value?.validate();
|
||||
emit('save', { ...form });
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
await formRef.value?.validate();
|
||||
testResult.value = null;
|
||||
emit('test', { ...form });
|
||||
}
|
||||
|
||||
function handleBeforeClose() {
|
||||
resetDialogState();
|
||||
}
|
||||
|
||||
function setTestResult(result: any) {
|
||||
testResult.value = result;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
form.sourceName,
|
||||
form.sourceType,
|
||||
form.host,
|
||||
form.port,
|
||||
form.databaseName,
|
||||
form.username,
|
||||
form.password,
|
||||
form.jdbcUrl,
|
||||
form.driverClassName,
|
||||
form.configJson?.serviceName,
|
||||
form.configJson?.informixServer,
|
||||
],
|
||||
() => {
|
||||
if (!props.testing && testResult.value) {
|
||||
testResult.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({ close, open, setTestResult });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowFormModal
|
||||
:open="visible"
|
||||
:title="modalTitle"
|
||||
:before-close="handleBeforeClose"
|
||||
:confirm-loading="saving"
|
||||
confirm-text="保存"
|
||||
:submitting="saving"
|
||||
width="800px"
|
||||
@update:open="emit('update:visible', $event)"
|
||||
@confirm="handleSave"
|
||||
>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="easyflow-modal-form easyflow-modal-form--compact source-form"
|
||||
>
|
||||
<div class="form-grid">
|
||||
<ElFormItem label="连接名称" prop="sourceName">
|
||||
<ElInput v-model="form.sourceName" placeholder="例如:华东 MySQL" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="连接类型" prop="sourceType">
|
||||
<ElSelect
|
||||
v-model="form.sourceType"
|
||||
class="w-full"
|
||||
:disabled="Boolean(form.builtinFlag)"
|
||||
>
|
||||
<template #label>
|
||||
<span class="source-type-select-label">
|
||||
<span class="source-type-select-icon">
|
||||
<SourceBrandIcon :source-type="form.sourceType" />
|
||||
</span>
|
||||
<span>{{ selectedSourceTypeLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElOption
|
||||
v-for="item in sourceTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
>
|
||||
<div class="source-type-option">
|
||||
<span class="source-type-option__icon">
|
||||
<SourceBrandIcon :source-type="item.value" />
|
||||
</span>
|
||||
<span class="source-type-option__label">{{ item.label }}</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<template v-if="supportsExternalConnection">
|
||||
<div class="form-grid">
|
||||
<ElFormItem label="主机">
|
||||
<ElInput v-model="form.host" placeholder="db.example.com" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="端口">
|
||||
<ElInput v-model="form.port" placeholder="5432" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="库名" prop="databaseName">
|
||||
<ElInput v-model="form.databaseName" placeholder="例如:easyflow" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="用户名">
|
||||
<ElInput v-model="form.username" placeholder="username" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="密码" class="span-2">
|
||||
<ElInput
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="password"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-advanced"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
{{ showAdvanced ? '收起高级设置' : '高级设置' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showAdvanced" class="form-grid form-grid--advanced">
|
||||
<ElFormItem label="连接地址" class="span-2">
|
||||
<ElInput v-model="form.jdbcUrl" placeholder="留空由系统自动生成" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="驱动类" class="span-2">
|
||||
<ElInput
|
||||
v-model="form.driverClassName"
|
||||
placeholder="留空由系统自动生成"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="form.sourceType === 'ORACLE'"
|
||||
label="Oracle 服务名"
|
||||
class="span-2"
|
||||
>
|
||||
<ElInput
|
||||
v-model="form.configJson.serviceName"
|
||||
placeholder="可选,未填写时默认使用数据库名"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="form.sourceType === 'GBASE_8S'"
|
||||
label="GBase 实例名"
|
||||
class="span-2"
|
||||
>
|
||||
<ElInput
|
||||
v-model="form.configJson.informixServer"
|
||||
placeholder="默认 gbasedbt_server"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</template>
|
||||
</ElForm>
|
||||
|
||||
<template #footer-extra>
|
||||
<EasyFlowButton
|
||||
variant="outline"
|
||||
:loading="testing"
|
||||
:disabled="saving"
|
||||
class="test-button"
|
||||
:class="testButtonClass"
|
||||
@click="handleTest"
|
||||
>
|
||||
<ElIcon v-if="!testing && testResult?.success === true">
|
||||
<CircleCheckFilled />
|
||||
</ElIcon>
|
||||
<ElIcon v-else-if="!testing && testResult?.success === false">
|
||||
<CircleCloseFilled />
|
||||
</ElIcon>
|
||||
{{ testButtonLabel }}
|
||||
</EasyFlowButton>
|
||||
</template>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.form-grid--advanced {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.source-type-select-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-type-select-icon,
|
||||
.source-type-option__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.source-type-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-type-option__label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.toggle-advanced {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: hsl(var(--primary));
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-advanced:hover {
|
||||
color: hsl(var(--primary) / 0.86);
|
||||
}
|
||||
|
||||
.test-button {
|
||||
min-width: 112px;
|
||||
transition:
|
||||
color 0.16s,
|
||||
border-color 0.16s,
|
||||
background-color 0.16s;
|
||||
}
|
||||
|
||||
.test-button :deep(.el-icon) {
|
||||
margin-right: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-button.is-test-success {
|
||||
color: hsl(var(--success));
|
||||
border-color: hsl(var(--success) / 0.24);
|
||||
background: hsl(var(--success) / 0.08);
|
||||
}
|
||||
|
||||
.test-button.is-test-success:hover {
|
||||
color: hsl(var(--success));
|
||||
border-color: hsl(var(--success) / 0.28);
|
||||
background: hsl(var(--success) / 0.12);
|
||||
}
|
||||
|
||||
.test-button.is-test-failed {
|
||||
color: hsl(var(--destructive));
|
||||
border-color: hsl(var(--destructive) / 0.22);
|
||||
background: hsl(var(--destructive) / 0.08);
|
||||
}
|
||||
|
||||
.test-button.is-test-failed:hover {
|
||||
color: hsl(var(--destructive));
|
||||
border-color: hsl(var(--destructive) / 0.28);
|
||||
background: hsl(var(--destructive) / 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,422 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { ArrowLeft, EditPen } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
formatJobStatus,
|
||||
formatJobType,
|
||||
formatRelationType,
|
||||
} from '../composables/datacenter-constants';
|
||||
|
||||
const props = defineProps<{
|
||||
jobs: any[];
|
||||
loading: boolean;
|
||||
previewRows: any[];
|
||||
saveDescriptions: (payload: {
|
||||
fields?: Array<{ fieldDesc: string; fieldId: number | string }>;
|
||||
tableDesc?: string;
|
||||
tableId: number | string;
|
||||
}) => Promise<any>;
|
||||
schema: any;
|
||||
sourceUnavailable?: boolean;
|
||||
table: any;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const activeTab = ref('data');
|
||||
const editingFieldId = ref<null | number | string>(null);
|
||||
const editingFieldDesc = ref('');
|
||||
const savingFieldId = ref<null | number | string>(null);
|
||||
|
||||
function startFieldEdit(field: any) {
|
||||
editingFieldId.value = field.id;
|
||||
editingFieldDesc.value = field.fieldDesc || '';
|
||||
}
|
||||
|
||||
function cancelFieldEdit() {
|
||||
editingFieldId.value = null;
|
||||
editingFieldDesc.value = '';
|
||||
}
|
||||
|
||||
async function saveFieldDescription(field: any) {
|
||||
const tableId = props.schema?.table?.id;
|
||||
if (!tableId || !field?.id || savingFieldId.value) return;
|
||||
savingFieldId.value = field.id;
|
||||
try {
|
||||
await props.saveDescriptions({
|
||||
tableDesc: props.schema?.table?.tableDesc || '',
|
||||
tableId,
|
||||
fields: [
|
||||
{
|
||||
fieldDesc: editingFieldDesc.value,
|
||||
fieldId: field.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
cancelFieldEdit();
|
||||
} finally {
|
||||
savingFieldId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-detail-view" v-loading="loading">
|
||||
<!-- 表头信息 -->
|
||||
<div class="detail-header">
|
||||
<div class="header-main">
|
||||
<button class="back-btn" @click="emit('back')">
|
||||
<ElIcon class="back-icon"><ArrowLeft /></ElIcon>
|
||||
</button>
|
||||
<div class="header-info">
|
||||
<h3 class="header-title">{{ table?.tableName }}</h3>
|
||||
<p v-if="schema?.table?.tableDesc" class="header-desc">
|
||||
{{ schema.table.tableDesc }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs 内容 -->
|
||||
<ElTabs v-model="activeTab" class="detail-tabs">
|
||||
<ElTabPane label="数据信息" name="data">
|
||||
<div class="detail-scroll-region">
|
||||
<ElEmpty v-if="previewRows.length === 0" description="暂无数据" />
|
||||
<ElTable v-else :data="previewRows" size="small" class="flat-table">
|
||||
<ElTableColumn
|
||||
v-for="field in schema?.fields || []"
|
||||
:key="field.fieldName"
|
||||
:prop="field.fieldName"
|
||||
:label="field.fieldName"
|
||||
min-width="140"
|
||||
/>
|
||||
</ElTable>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane label="字段结构" name="schema">
|
||||
<div class="detail-scroll-region">
|
||||
<ElTable :data="schema?.fields || []" size="small" class="flat-table">
|
||||
<ElTableColumn label="字段名" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="field-name-cell">
|
||||
<span class="field-name-text">{{ row.fieldName }}</span>
|
||||
<template v-if="editingFieldId === row.id">
|
||||
<ElInput
|
||||
v-model="editingFieldDesc"
|
||||
size="small"
|
||||
clearable
|
||||
maxlength="200"
|
||||
class="description-input"
|
||||
/>
|
||||
<div class="inline-actions">
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="savingFieldId === row.id"
|
||||
@click="saveFieldDescription(row)"
|
||||
>
|
||||
保存
|
||||
</ElButton>
|
||||
<ElButton
|
||||
link
|
||||
size="small"
|
||||
:disabled="savingFieldId === row.id"
|
||||
@click="cancelFieldEdit"
|
||||
>
|
||||
取消
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="description-inline">{{
|
||||
row.fieldDesc || ''
|
||||
}}</span>
|
||||
<ElButton
|
||||
class="icon-action icon-action--edit"
|
||||
link
|
||||
size="small"
|
||||
@click="startFieldEdit(row)"
|
||||
>
|
||||
<ElIcon><EditPen /></ElIcon>
|
||||
</ElButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="jdbcType" label="类型" width="120" />
|
||||
</ElTable>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane label="版本记录" name="version">
|
||||
<div class="detail-scroll-region">
|
||||
<div class="tag-flow">
|
||||
<ElTag
|
||||
v-for="version in schema?.versions || []"
|
||||
:key="version.id"
|
||||
effect="plain"
|
||||
>
|
||||
#{{ version.versionNo }}
|
||||
{{ version.versionLabel || '未命名版本' }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<ElEmpty
|
||||
v-if="(schema?.versions || []).length === 0"
|
||||
description="暂无版本记录"
|
||||
/>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane label="来源关系" name="lineage">
|
||||
<div class="detail-scroll-region">
|
||||
<div class="tag-flow">
|
||||
<ElTag
|
||||
v-for="item in schema?.upstreamLineage || []"
|
||||
:key="`up-${item.id}`"
|
||||
effect="plain"
|
||||
type="info"
|
||||
>
|
||||
上游 {{ formatRelationType(item.relationType) }} /
|
||||
{{ item.sourceTableId }}
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-for="item in schema?.downstreamLineage || []"
|
||||
:key="`down-${item.id}`"
|
||||
effect="plain"
|
||||
type="success"
|
||||
>
|
||||
下游 {{ formatRelationType(item.relationType) }} /
|
||||
{{ item.derivedTableId }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<ElEmpty
|
||||
v-if="
|
||||
(schema?.upstreamLineage || []).length === 0 &&
|
||||
(schema?.downstreamLineage || []).length === 0
|
||||
"
|
||||
description="暂无来源关系"
|
||||
/>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane label="处理记录" name="job">
|
||||
<div class="detail-scroll-region">
|
||||
<ElEmpty v-if="jobs.length === 0" description="暂无处理记录" />
|
||||
<ElTable v-else :data="jobs" size="small" class="flat-table">
|
||||
<ElTableColumn label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatJobType(row.jobType) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<ElTag
|
||||
size="small"
|
||||
:type="
|
||||
row.status === 'SUCCESS'
|
||||
? 'success'
|
||||
: row.status === 'FAILED'
|
||||
? 'danger'
|
||||
: 'info'
|
||||
"
|
||||
effect="plain"
|
||||
>
|
||||
{{ formatJobStatus(row.status) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="created" label="时间" min-width="180" />
|
||||
</ElTable>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-detail-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0 0 0 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
margin-bottom: 10px;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--surface-subtle) / 0.75);
|
||||
cursor: pointer;
|
||||
color: hsl(var(--text-muted));
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--text-muted));
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.detail-tabs :deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.42);
|
||||
}
|
||||
|
||||
.detail-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.detail-tabs :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detail-tabs :deep(.el-tab-pane) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
|
||||
.detail-scroll-region {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.tag-flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.flat-table {
|
||||
--el-table-bg-color: transparent;
|
||||
--el-table-tr-bg-color: transparent;
|
||||
--el-table-header-bg-color: hsl(var(--surface-subtle) / 0.72);
|
||||
--el-table-border-color: hsl(var(--table-row-border) / 0.7);
|
||||
--el-table-current-row-bg-color: hsl(var(--table-row-hover));
|
||||
--el-fill-color-blank: transparent;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.field-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-name-text {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.description-inline {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.description-input {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-action {
|
||||
min-width: auto;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.icon-action :deep(.el-icon) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.icon-action--edit {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,414 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Delete, EditPen, Search } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
|
||||
const props = defineProps<{
|
||||
managedTables: any[];
|
||||
saveTableDescription: (
|
||||
tableId: number | string,
|
||||
tableDesc: string,
|
||||
) => Promise<any>;
|
||||
sourceLabel: string;
|
||||
sourceTables: any[];
|
||||
sourceUnavailable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
registerTables: [rows: any[]];
|
||||
removeTables: [rows: any[]];
|
||||
selectTable: [row: any];
|
||||
}>();
|
||||
|
||||
const tableKeyword = ref('');
|
||||
const selectedRows = ref<any[]>([]);
|
||||
const editingTableId = ref<null | number | string>(null);
|
||||
const editingTableDesc = ref('');
|
||||
const savingTableId = ref<null | number | string>(null);
|
||||
|
||||
const managedTableMap = computed(
|
||||
() => new Map(props.managedTables.map((item) => [item.tableName, item])),
|
||||
);
|
||||
|
||||
function compareTableName(a?: string, b?: string) {
|
||||
return String(a || '').localeCompare(String(b || ''), 'en', {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
}
|
||||
|
||||
const tableRows = computed(() => {
|
||||
const keyword = tableKeyword.value.trim().toLowerCase();
|
||||
return props.sourceTables
|
||||
.filter((item) => {
|
||||
if (!keyword) return true;
|
||||
return item.tableName?.toLowerCase().includes(keyword);
|
||||
})
|
||||
.map((item) => {
|
||||
const managedTable = managedTableMap.value.get(item.tableName) || null;
|
||||
return {
|
||||
...item,
|
||||
managedTable,
|
||||
managedTableId: managedTable?.id || null,
|
||||
managedStatus: managedTable ? 'MANAGED' : 'UNMANAGED',
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const managedDiff =
|
||||
Number(Boolean(b.managedTable)) - Number(Boolean(a.managedTable));
|
||||
if (managedDiff !== 0) {
|
||||
return managedDiff;
|
||||
}
|
||||
return compareTableName(a.tableName, b.tableName);
|
||||
});
|
||||
});
|
||||
|
||||
const selectedManagedRows = computed(() =>
|
||||
selectedRows.value.map((row) => row.managedTable).filter(Boolean),
|
||||
);
|
||||
|
||||
const selectedPendingRows = computed(() =>
|
||||
selectedRows.value.filter((row) => !row.managedTable),
|
||||
);
|
||||
|
||||
const canBatchRegister = computed(() => selectedPendingRows.value.length > 0);
|
||||
const canBatchRemove = computed(() => selectedManagedRows.value.length > 0);
|
||||
|
||||
function handleSelectionChange(rows: any[]) {
|
||||
selectedRows.value = rows;
|
||||
}
|
||||
|
||||
function handleBatchRegister() {
|
||||
emit('registerTables', selectedPendingRows.value);
|
||||
}
|
||||
|
||||
function handleBatchRemove() {
|
||||
emit('removeTables', selectedManagedRows.value);
|
||||
}
|
||||
|
||||
function handleOpenDetail(row: any) {
|
||||
if (row.managedTable) {
|
||||
emit('selectTable', row.managedTable);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(row: any) {
|
||||
if (!row?.managedTable) return;
|
||||
editingTableId.value = row.managedTable.id;
|
||||
editingTableDesc.value = row.managedTable.tableDesc || '';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingTableId.value = null;
|
||||
editingTableDesc.value = '';
|
||||
}
|
||||
|
||||
async function handleSaveTableDescription(row: any) {
|
||||
if (!row?.managedTable || savingTableId.value) return;
|
||||
savingTableId.value = row.managedTable.id;
|
||||
try {
|
||||
await props.saveTableDescription(
|
||||
row.managedTable.id,
|
||||
editingTableDesc.value,
|
||||
);
|
||||
cancelEdit();
|
||||
} finally {
|
||||
savingTableId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-list-view">
|
||||
<div class="view-header">
|
||||
<h3 class="view-title">{{ sourceLabel }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="view-toolbar">
|
||||
<div class="table-search-row">
|
||||
<ElInput v-model="tableKeyword" placeholder="搜索表" clearable>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<div class="batch-actions">
|
||||
<span v-if="selectedRows.length > 0" class="selection-text">
|
||||
已选 {{ selectedRows.length }} 项
|
||||
</span>
|
||||
<ElButton
|
||||
size="small"
|
||||
:disabled="!canBatchRegister"
|
||||
@click="handleBatchRegister"
|
||||
>
|
||||
批量接入
|
||||
</ElButton>
|
||||
<ElButton
|
||||
size="small"
|
||||
:disabled="!canBatchRemove"
|
||||
@click="handleBatchRemove"
|
||||
>
|
||||
批量去除
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll-region">
|
||||
<ElEmpty
|
||||
v-if="sourceTables.length === 0"
|
||||
description="当前连接下还没有可浏览的表"
|
||||
/>
|
||||
<ElEmpty v-else-if="tableRows.length === 0" description="没有匹配的表" />
|
||||
<ElTable
|
||||
v-else
|
||||
:data="tableRows"
|
||||
height="100%"
|
||||
size="small"
|
||||
row-key="tableName"
|
||||
class="flat-table"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<ElTableColumn type="selection" width="52" />
|
||||
<ElTableColumn
|
||||
label="名称"
|
||||
min-width="360"
|
||||
class-name="table-name-column"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="name-cell">
|
||||
<span class="table-name-text">{{ row.tableName }}</span>
|
||||
<template v-if="row.managedTable">
|
||||
<template v-if="editingTableId === row.managedTable.id">
|
||||
<ElInput
|
||||
v-model="editingTableDesc"
|
||||
size="small"
|
||||
clearable
|
||||
maxlength="200"
|
||||
class="description-input"
|
||||
/>
|
||||
<div class="inline-actions">
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="savingTableId === row.managedTable.id"
|
||||
@click="handleSaveTableDescription(row)"
|
||||
>
|
||||
保存
|
||||
</ElButton>
|
||||
<ElButton
|
||||
link
|
||||
size="small"
|
||||
:disabled="savingTableId === row.managedTable.id"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
取消
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="description-inline">{{
|
||||
row.managedTable.tableDesc || ''
|
||||
}}</span>
|
||||
<ElButton
|
||||
class="icon-action icon-action--edit"
|
||||
link
|
||||
size="small"
|
||||
@click="startEdit(row)"
|
||||
>
|
||||
<ElIcon><EditPen /></ElIcon>
|
||||
</ElButton>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<ElTag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="row.managedTable ? 'primary' : 'info'"
|
||||
>
|
||||
{{ row.managedTable ? '已接入' : '未接入' }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<ElButton
|
||||
v-if="row.managedTable"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleOpenDetail(row)"
|
||||
>
|
||||
查看
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="row.managedTable"
|
||||
class="icon-action icon-action--danger"
|
||||
link
|
||||
size="small"
|
||||
@click="emit('removeTables', [row.managedTable])"
|
||||
>
|
||||
<ElIcon><Delete /></ElIcon>
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-else
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="emit('registerTables', [row])"
|
||||
>
|
||||
接入
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0 0 0 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 10px;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
|
||||
.view-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.view-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.table-search-row {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.table-search-row :deep(.el-input__wrapper) {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.batch-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selection-text {
|
||||
font-size: 13px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.table-scroll-region {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flat-table {
|
||||
--el-table-bg-color: transparent;
|
||||
--el-table-tr-bg-color: transparent;
|
||||
--el-table-header-bg-color: hsl(var(--surface-subtle) / 0.72);
|
||||
--el-table-border-color: hsl(var(--table-row-border) / 0.7);
|
||||
--el-table-current-row-bg-color: hsl(var(--table-row-hover));
|
||||
--el-fill-color-blank: transparent;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.flat-table :deep(.table-name-column .cell) {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-name-text {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.description-inline {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.description-input {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-action {
|
||||
min-width: auto;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.icon-action :deep(.el-icon) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.icon-action--edit {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.icon-action--danger {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
export const sourceTypeOptions = [
|
||||
{ label: 'Excel 文件', value: 'EXCEL' },
|
||||
{ label: 'MySQL', value: 'MYSQL' },
|
||||
{ label: 'PostgreSQL', value: 'POSTGRESQL' },
|
||||
{ label: 'Oracle', value: 'ORACLE' },
|
||||
{ label: 'GaussDB', value: 'GAUSSDB_NATIVE' },
|
||||
{ label: 'GBase 8a', value: 'GBASE_8A' },
|
||||
{ label: 'GBase 8s', value: 'GBASE_8S' },
|
||||
];
|
||||
|
||||
export const sourceConnectionDefaults: Record<
|
||||
string,
|
||||
{
|
||||
buildJdbcUrl: (form: Record<string, any>) => string;
|
||||
defaultDriver: string;
|
||||
defaultPort: number;
|
||||
}
|
||||
> = {
|
||||
MYSQL: {
|
||||
defaultPort: 3306,
|
||||
defaultDriver: 'com.mysql.cj.jdbc.Driver',
|
||||
buildJdbcUrl: (p) =>
|
||||
p.host && p.port && p.databaseName
|
||||
? `jdbc:mysql://${p.host}:${p.port}/${p.databaseName}?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false`
|
||||
: '',
|
||||
},
|
||||
POSTGRESQL: {
|
||||
defaultPort: 5432,
|
||||
defaultDriver: 'org.postgresql.Driver',
|
||||
buildJdbcUrl: (p) =>
|
||||
p.host && p.port && p.databaseName
|
||||
? `jdbc:postgresql://${p.host}:${p.port}/${p.databaseName}`
|
||||
: '',
|
||||
},
|
||||
ORACLE: {
|
||||
defaultPort: 1521,
|
||||
defaultDriver: 'oracle.jdbc.OracleDriver',
|
||||
buildJdbcUrl: (p) => {
|
||||
const serviceName = p?.configJson?.serviceName || p.databaseName;
|
||||
return p.host && p.port && serviceName
|
||||
? `jdbc:oracle:thin:@//${p.host}:${p.port}/${serviceName}`
|
||||
: '';
|
||||
},
|
||||
},
|
||||
GAUSSDB_NATIVE: {
|
||||
defaultPort: 5432,
|
||||
defaultDriver: 'org.postgresql.Driver',
|
||||
buildJdbcUrl: (p) =>
|
||||
p.host && p.port && p.databaseName
|
||||
? `jdbc:postgresql://${p.host}:${p.port}/${p.databaseName}`
|
||||
: '',
|
||||
},
|
||||
GBASE_8A: {
|
||||
defaultPort: 5258,
|
||||
defaultDriver: 'com.gbase.jdbc.Driver',
|
||||
buildJdbcUrl: (p) =>
|
||||
p.host && p.port && p.databaseName
|
||||
? `jdbc:gbase://${p.host}:${p.port}/${p.databaseName}`
|
||||
: '',
|
||||
},
|
||||
GBASE_8S: {
|
||||
defaultPort: 9088,
|
||||
defaultDriver: 'com.gbasedbt.jdbc.Driver',
|
||||
buildJdbcUrl: (p) => {
|
||||
const informixServer = p?.configJson?.informixServer || 'gbasedbt_server';
|
||||
return p.host && p.port && p.databaseName
|
||||
? `jdbc:gbasedbt-sqli://${p.host}:${p.port}/${p.databaseName}:INFORMIXSERVER=${informixServer}`
|
||||
: '';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const sourceTypeLabels: Record<string, string> = {
|
||||
PROJECT_MYSQL: '项目 MySQL',
|
||||
EXCEL: 'Excel 文件',
|
||||
MYSQL: 'MySQL',
|
||||
POSTGRESQL: 'PostgreSQL',
|
||||
ORACLE: 'Oracle',
|
||||
GAUSSDB_NATIVE: 'GaussDB',
|
||||
GBASE_8A: 'GBase 8a',
|
||||
GBASE_8S: 'GBase 8s',
|
||||
};
|
||||
|
||||
export const accessModeLabels: Record<string, string> = {
|
||||
READ_ONLY: '只读',
|
||||
READ_WRITE: '可编辑',
|
||||
};
|
||||
|
||||
export const tableKindLabels: Record<string, string> = {
|
||||
SOURCE_TABLE: '表',
|
||||
SOURCE_VIEW: '视图',
|
||||
TABLE: '表',
|
||||
VIEW: '视图',
|
||||
EXCEL_SHEET: '工作表',
|
||||
DERIVED_TABLE: '生成表',
|
||||
MATERIALIZED_TABLE: '缓存表',
|
||||
};
|
||||
|
||||
export const jobTypeLabels: Record<string, string> = {
|
||||
IMPORT: '导入',
|
||||
SPLIT: '拆分',
|
||||
MERGE: '合并',
|
||||
DERIVE: '生成新表',
|
||||
EXPORT: '导出',
|
||||
};
|
||||
|
||||
export const jobStatusLabels: Record<string, string> = {
|
||||
SUCCESS: '完成',
|
||||
FAILED: '失败',
|
||||
RUNNING: '处理中',
|
||||
PENDING: '等待中',
|
||||
CREATED: '已创建',
|
||||
};
|
||||
|
||||
export const relationTypeLabels: Record<string, string> = {
|
||||
SPLIT: '拆分生成',
|
||||
MERGE: '合并生成',
|
||||
DERIVE: '生成新表',
|
||||
};
|
||||
|
||||
export function formatSourceType(value?: string) {
|
||||
return value ? sourceTypeLabels[value] || value : '-';
|
||||
}
|
||||
|
||||
export function formatAccessMode(value?: string) {
|
||||
return value ? accessModeLabels[value] || value : '-';
|
||||
}
|
||||
|
||||
export function formatTestStatus(value?: string) {
|
||||
if (!value) return '未测试';
|
||||
return value === 'SUCCESS' ? '连接正常' : '连接失败';
|
||||
}
|
||||
|
||||
export function formatTableKind(value?: string) {
|
||||
return value ? tableKindLabels[value] || value : '-';
|
||||
}
|
||||
|
||||
export function formatJobType(value?: string) {
|
||||
return value ? jobTypeLabels[value] || value : value || '-';
|
||||
}
|
||||
|
||||
export function formatJobStatus(value?: string) {
|
||||
return value ? jobStatusLabels[value] || value : '-';
|
||||
}
|
||||
|
||||
export function formatRelationType(value?: string) {
|
||||
return value ? relationTypeLabels[value] || value : '关联';
|
||||
}
|
||||
|
||||
export function formatFieldFlag(value?: null | number) {
|
||||
return value === null || value === undefined || value === 1 ? '是' : '否';
|
||||
}
|
||||
|
||||
export function mergeConfigJsonBySourceType(
|
||||
sourceType: string,
|
||||
configJson?: Record<string, any>,
|
||||
) {
|
||||
const merged = { ...configJson };
|
||||
if (sourceType === 'GBASE_8S' && !merged.informixServer) {
|
||||
merged.informixServer = 'gbasedbt_server';
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { formatSourceType, formatTestStatus } from './datacenter-constants';
|
||||
|
||||
export interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'source' | 'table';
|
||||
icon?: string;
|
||||
meta?: any;
|
||||
children?: TreeNode[];
|
||||
isLeaf?: boolean;
|
||||
}
|
||||
|
||||
export function useConnectionTree(sources: Ref<any[]>) {
|
||||
const treeData = computed<TreeNode[]>(() => {
|
||||
return sources.value.map((source) => {
|
||||
return {
|
||||
id: `source-${source.id}`,
|
||||
label: source.sourceName,
|
||||
type: 'source' as const,
|
||||
icon: getSourceIcon(source.sourceType),
|
||||
meta: {
|
||||
...source,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function getSourceIcon(sourceType: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
MYSQL: 'db',
|
||||
POSTGRESQL: 'db',
|
||||
ORACLE: 'db',
|
||||
GAUSSDB_NATIVE: 'db',
|
||||
GBASE_8A: 'db',
|
||||
GBASE_8S: 'db',
|
||||
EXCEL: 'file',
|
||||
PROJECT_MYSQL: 'db',
|
||||
};
|
||||
return iconMap[sourceType] || 'db';
|
||||
}
|
||||
|
||||
function parseNodeKey(key: string) {
|
||||
const [type, ...idParts] = key.split('-');
|
||||
const id = idParts.join('-');
|
||||
return {
|
||||
type: type as 'source' | 'table',
|
||||
id: Number(id),
|
||||
};
|
||||
}
|
||||
|
||||
function getStatusColor(source: any): string {
|
||||
if (!source.lastTestStatus) return 'muted';
|
||||
return source.lastTestStatus === 'SUCCESS' ? 'success' : 'danger';
|
||||
}
|
||||
|
||||
return {
|
||||
treeData,
|
||||
parseNodeKey,
|
||||
getSourceIcon,
|
||||
getStatusColor,
|
||||
formatSourceType,
|
||||
formatTestStatus,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { downloadFileFromBlob } from '@easyflow/utils';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
export function useDatacenterExcel(
|
||||
selectedSourceId: Ref<null | number>,
|
||||
selectedCatalogId: Ref<null | number>,
|
||||
selectedTableId: Ref<null | number>,
|
||||
reloadAll: () => Promise<void>,
|
||||
) {
|
||||
const actionLoading = ref(false);
|
||||
const pendingUploadFile = ref<File | null>(null);
|
||||
|
||||
const splitForm = reactive({
|
||||
splitMode: 'BY_ROW_COUNT',
|
||||
rowBatchSize: 1000,
|
||||
fieldName: '',
|
||||
targetNamePrefix: '',
|
||||
});
|
||||
|
||||
const mergeForm = reactive({
|
||||
mergeMode: 'VERTICAL',
|
||||
tableIds: [] as number[],
|
||||
targetTableName: '',
|
||||
joinKey: '',
|
||||
});
|
||||
|
||||
const deriveForm = reactive({
|
||||
targetTableName: '',
|
||||
selectedColumnsText: '',
|
||||
renameMappingsText: '{}',
|
||||
filtersText: '[]',
|
||||
});
|
||||
|
||||
const exportForm = reactive({
|
||||
fileName: '',
|
||||
exportScope: 'TABLE',
|
||||
});
|
||||
|
||||
function resetSplitForm() {
|
||||
splitForm.splitMode = 'BY_ROW_COUNT';
|
||||
splitForm.rowBatchSize = 1000;
|
||||
splitForm.fieldName = '';
|
||||
splitForm.targetNamePrefix = '';
|
||||
}
|
||||
|
||||
function resetMergeForm() {
|
||||
mergeForm.mergeMode = 'VERTICAL';
|
||||
mergeForm.tableIds = [];
|
||||
mergeForm.targetTableName = '';
|
||||
mergeForm.joinKey = '';
|
||||
}
|
||||
|
||||
function resetDeriveForm() {
|
||||
deriveForm.targetTableName = '';
|
||||
deriveForm.selectedColumnsText = '';
|
||||
deriveForm.renameMappingsText = '{}';
|
||||
deriveForm.filtersText = '[]';
|
||||
}
|
||||
|
||||
function resetExportForm() {
|
||||
exportForm.fileName = '';
|
||||
exportForm.exportScope = 'TABLE';
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!pendingUploadFile.value) {
|
||||
ElMessage.warning('请先选择 Excel 文件');
|
||||
return false;
|
||||
}
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', pendingUploadFile.value);
|
||||
await api.postFile('/api/v1/datacenterExcel/import', formData, {
|
||||
timeout: 10 * 60 * 1000,
|
||||
});
|
||||
ElMessage.success('Excel 已导入');
|
||||
pendingUploadFile.value = null;
|
||||
await reloadAll();
|
||||
return true;
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSplit() {
|
||||
if (!selectedTableId.value) return false;
|
||||
if (
|
||||
splitForm.splitMode === 'BY_ROW_COUNT' &&
|
||||
Number(splitForm.rowBatchSize) <= 0
|
||||
) {
|
||||
ElMessage.warning('每份行数必须大于 0');
|
||||
return false;
|
||||
}
|
||||
if (splitForm.splitMode === 'BY_FIELD_VALUE' && !splitForm.fieldName) {
|
||||
ElMessage.warning('请选择拆分字段');
|
||||
return false;
|
||||
}
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
await api.post('/api/v1/datacenterExcel/split', {
|
||||
datasetRef: { tableId: selectedTableId.value },
|
||||
sourceId: selectedSourceId.value,
|
||||
catalogId: selectedCatalogId.value,
|
||||
splitMode: splitForm.splitMode,
|
||||
rowBatchSize: Number(splitForm.rowBatchSize),
|
||||
fieldName: splitForm.fieldName,
|
||||
targetNamePrefix: splitForm.targetNamePrefix,
|
||||
});
|
||||
ElMessage.success('拆分已完成');
|
||||
await reloadAll();
|
||||
return true;
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMerge() {
|
||||
if (mergeForm.tableIds.length < 2) {
|
||||
ElMessage.warning('请至少选择两个已接入表');
|
||||
return false;
|
||||
}
|
||||
if (!mergeForm.targetTableName.trim()) {
|
||||
ElMessage.warning('请输入新表名称');
|
||||
return false;
|
||||
}
|
||||
if (mergeForm.mergeMode === 'HORIZONTAL' && !mergeForm.joinKey.trim()) {
|
||||
ElMessage.warning('横向合并需要填写关联字段');
|
||||
return false;
|
||||
}
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
await api.post('/api/v1/datacenterExcel/merge', {
|
||||
datasetRefs: mergeForm.tableIds.map((tableId) => ({ tableId })),
|
||||
mergeMode: mergeForm.mergeMode,
|
||||
targetTableName: mergeForm.targetTableName.trim(),
|
||||
joinKey: mergeForm.joinKey.trim(),
|
||||
});
|
||||
ElMessage.success('合并已完成');
|
||||
await reloadAll();
|
||||
return true;
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDerive() {
|
||||
if (!selectedTableId.value) return false;
|
||||
if (!deriveForm.targetTableName.trim()) {
|
||||
ElMessage.warning('请输入新表名称');
|
||||
return false;
|
||||
}
|
||||
const selectedColumns = deriveForm.selectedColumnsText
|
||||
? deriveForm.selectedColumnsText
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
let renameMappings: Record<string, string>;
|
||||
let filters: any[];
|
||||
try {
|
||||
renameMappings = deriveForm.renameMappingsText
|
||||
? JSON.parse(deriveForm.renameMappingsText)
|
||||
: {};
|
||||
filters = deriveForm.filtersText
|
||||
? JSON.parse(deriveForm.filtersText)
|
||||
: [];
|
||||
} catch {
|
||||
ElMessage.error('生成参数格式不正确,请检查输入内容');
|
||||
return false;
|
||||
}
|
||||
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
await api.post('/api/v1/datacenterExcel/derive', {
|
||||
datasetRef: { tableId: selectedTableId.value },
|
||||
targetTableName: deriveForm.targetTableName.trim(),
|
||||
selectedColumns,
|
||||
renameMappings,
|
||||
filters,
|
||||
});
|
||||
ElMessage.success('新表已生成');
|
||||
await reloadAll();
|
||||
return true;
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(loadTableRuntime: () => Promise<void>) {
|
||||
if (exportForm.exportScope === 'TABLE' && !selectedTableId.value) {
|
||||
ElMessage.warning('请先选择已接入表');
|
||||
return false;
|
||||
}
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
const res = await api.post('/api/v1/datacenterExcel/export', {
|
||||
sourceId:
|
||||
exportForm.exportScope === 'WORKBOOK'
|
||||
? selectedSourceId.value
|
||||
: undefined,
|
||||
catalogId:
|
||||
exportForm.exportScope === 'WORKBOOK'
|
||||
? selectedCatalogId.value
|
||||
: undefined,
|
||||
datasetRefs:
|
||||
exportForm.exportScope === 'TABLE' && selectedTableId.value
|
||||
? [{ tableId: selectedTableId.value }]
|
||||
: [],
|
||||
fileName: exportForm.fileName.trim(),
|
||||
});
|
||||
const blob = await api.download('/api/v1/datacenterExcel/download', {
|
||||
params: { jobId: res.data.id },
|
||||
});
|
||||
downloadFileFromBlob({
|
||||
fileName:
|
||||
res.data.fileName || exportForm.fileName || 'datacenter-export.xlsx',
|
||||
source: blob,
|
||||
});
|
||||
ElMessage.success('导出文件已生成');
|
||||
await loadTableRuntime();
|
||||
return true;
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actionLoading,
|
||||
pendingUploadFile,
|
||||
splitForm,
|
||||
mergeForm,
|
||||
deriveForm,
|
||||
exportForm,
|
||||
resetSplitForm,
|
||||
resetMergeForm,
|
||||
resetDeriveForm,
|
||||
resetExportForm,
|
||||
handleImport,
|
||||
handleSplit,
|
||||
handleMerge,
|
||||
handleDerive,
|
||||
handleExport,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
import { mergeConfigJsonBySourceType } from './datacenter-constants';
|
||||
|
||||
export function useDatacenterSources() {
|
||||
const sources = ref<any[]>([]);
|
||||
const selectedSourceId = ref<null | number>(null);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const testing = ref(false);
|
||||
|
||||
const selectedSource = computed(
|
||||
() =>
|
||||
sources.value.find((item) => item.id === selectedSourceId.value) || null,
|
||||
);
|
||||
|
||||
function normalizeSource(record: any, previous?: any) {
|
||||
return {
|
||||
...previous,
|
||||
...record,
|
||||
runtimeUnavailable: Boolean(
|
||||
record?.runtimeUnavailable ?? previous?.runtimeUnavailable,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function syncSelectedSourceAfterMutation() {
|
||||
if (sources.value.length === 0) {
|
||||
selectedSourceId.value = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!selectedSourceId.value ||
|
||||
!sources.value.some((item) => item.id === selectedSourceId.value)
|
||||
) {
|
||||
selectedSourceId.value = sources.value[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
function upsertSource(record?: any) {
|
||||
if (!record?.id) return;
|
||||
const previous = sources.value.find((item) => item.id === record.id);
|
||||
const next = dedupeBuiltinSources(
|
||||
sources.value.some((item) => item.id === record.id)
|
||||
? sources.value.map((item) =>
|
||||
item.id === record.id ? normalizeSource(record, previous) : item,
|
||||
)
|
||||
: [normalizeSource(record), ...sources.value],
|
||||
);
|
||||
sources.value = next;
|
||||
selectedSourceId.value = record.id;
|
||||
}
|
||||
|
||||
function markSourceRuntimeUnavailable(sourceId?: null | number | string) {
|
||||
if (!sourceId) return;
|
||||
sources.value = sources.value.map((item) =>
|
||||
String(item.id) === String(sourceId)
|
||||
? { ...item, runtimeUnavailable: true }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function clearSourceRuntimeUnavailable(sourceId?: null | number | string) {
|
||||
if (!sourceId) return;
|
||||
sources.value = sources.value.map((item) =>
|
||||
String(item.id) === String(sourceId)
|
||||
? { ...item, runtimeUnavailable: false }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function removeSourceFromState(sourceId: number) {
|
||||
sources.value = sources.value.filter((item) => item.id !== sourceId);
|
||||
syncSelectedSourceAfterMutation();
|
||||
}
|
||||
|
||||
function dedupeBuiltinSources(records: any[]) {
|
||||
const builtinSourceTypes = new Set(['PROJECT_MYSQL']);
|
||||
const seen = new Set<string>();
|
||||
|
||||
return (records || []).filter((item) => {
|
||||
const uniqueKey = `${item.sourceType}:${item.sourceName}`;
|
||||
if (!item.builtinFlag || !builtinSourceTypes.has(item.sourceType)) {
|
||||
return true;
|
||||
}
|
||||
if (seen.has(uniqueKey)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(uniqueKey);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
const res = await api.get('/api/v1/datacenterSource/page', {
|
||||
params: { pageNumber: 1, pageSize: 200 },
|
||||
});
|
||||
const previousMap = new Map(
|
||||
sources.value.map((item) => [String(item.id), item]),
|
||||
);
|
||||
sources.value = dedupeBuiltinSources(
|
||||
(res.data?.records || []).map((record: any) =>
|
||||
normalizeSource(record, previousMap.get(String(record.id))),
|
||||
),
|
||||
);
|
||||
syncSelectedSourceAfterMutation();
|
||||
}
|
||||
|
||||
async function saveSource(form: Record<string, any>) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
configJson: {
|
||||
...mergeConfigJsonBySourceType(form.sourceType, form.configJson),
|
||||
password: form.password || undefined,
|
||||
},
|
||||
};
|
||||
const res = await api.post('/api/v1/datacenterSource/save', payload);
|
||||
upsertSource(res.data);
|
||||
return res.data;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(target: Record<string, any>) {
|
||||
testing.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
...target,
|
||||
configJson: {
|
||||
...mergeConfigJsonBySourceType(target.sourceType, target.configJson),
|
||||
password: target.password || undefined,
|
||||
},
|
||||
};
|
||||
const res = await api.post(
|
||||
'/api/v1/datacenterSource/testConnection',
|
||||
payload,
|
||||
);
|
||||
return res.data;
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function quickTest(row: any) {
|
||||
testing.value = true;
|
||||
try {
|
||||
const res = await api.post(
|
||||
'/api/v1/datacenterSource/testConnection',
|
||||
row,
|
||||
);
|
||||
clearSourceRuntimeUnavailable(row?.id);
|
||||
await loadSources();
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
markSourceRuntimeUnavailable(row?.id);
|
||||
throw error;
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSource(sourceId: number) {
|
||||
await api.post('/api/v1/datacenterSource/remove', { sourceId });
|
||||
removeSourceFromState(sourceId);
|
||||
}
|
||||
|
||||
return {
|
||||
sources,
|
||||
selectedSourceId,
|
||||
selectedSource,
|
||||
loading,
|
||||
saving,
|
||||
testing,
|
||||
loadSources,
|
||||
removeSource,
|
||||
removeSourceFromState,
|
||||
saveSource,
|
||||
syncSelectedSourceAfterMutation,
|
||||
testConnection,
|
||||
quickTest,
|
||||
upsertSource,
|
||||
markSourceRuntimeUnavailable,
|
||||
clearSourceRuntimeUnavailable,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
interface DatacenterTableRuntimeOptions {
|
||||
clearSourceRuntimeUnavailable?: (sourceId?: null | number | string) => void;
|
||||
markSourceRuntimeUnavailable?: (sourceId?: null | number | string) => void;
|
||||
}
|
||||
|
||||
export function useDatacenterTables(
|
||||
selectedSourceId: Ref<null | number>,
|
||||
options: DatacenterTableRuntimeOptions = {},
|
||||
) {
|
||||
const SOURCE_MISSING_MESSAGE = '连接不存在';
|
||||
const SOURCE_UNAVAILABLE_MESSAGE = '当前连接不可用,请检查连接配置后重试';
|
||||
const catalogs = ref<any[]>([]);
|
||||
const sourceTables = ref<any[]>([]);
|
||||
const managedTables = ref<any[]>([]);
|
||||
const schema = ref<any>(null);
|
||||
const previewRows = ref<any[]>([]);
|
||||
const jobs = ref<any[]>([]);
|
||||
const sourceUnavailable = ref(false);
|
||||
const selectedCatalogId = ref<null | number>(null);
|
||||
const selectedTableId = ref<null | number>(null);
|
||||
const previewLoading = ref(false);
|
||||
|
||||
const selectedCatalog = computed(
|
||||
() =>
|
||||
catalogs.value.find((item) => item.id === selectedCatalogId.value) ||
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedTable = computed(
|
||||
() =>
|
||||
managedTables.value.find((item) => item.id === selectedTableId.value) ||
|
||||
null,
|
||||
);
|
||||
|
||||
function isSourceUnavailableError(error: any) {
|
||||
const responseData = error?.response?.data ?? {};
|
||||
const message = String(responseData?.message ?? error?.message ?? '');
|
||||
return message.includes(SOURCE_UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
|
||||
function isSourceMissingError(error: any) {
|
||||
const responseData = error?.response?.data ?? {};
|
||||
const message = String(responseData?.message ?? error?.message ?? '');
|
||||
return message.includes(SOURCE_MISSING_MESSAGE);
|
||||
}
|
||||
|
||||
function markSourceUnavailable() {
|
||||
options.markSourceRuntimeUnavailable?.(selectedSourceId.value);
|
||||
catalogs.value = [];
|
||||
sourceTables.value = [];
|
||||
managedTables.value = [];
|
||||
schema.value = null;
|
||||
previewRows.value = [];
|
||||
jobs.value = [];
|
||||
selectedCatalogId.value = null;
|
||||
selectedTableId.value = null;
|
||||
sourceUnavailable.value = true;
|
||||
}
|
||||
|
||||
async function loadCatalogs() {
|
||||
if (!selectedSourceId.value) {
|
||||
catalogs.value = [];
|
||||
selectedCatalogId.value = null;
|
||||
sourceUnavailable.value = false;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const data = await requestClient.get(
|
||||
'/api/v1/datacenterSource/catalogs',
|
||||
{
|
||||
params: { sourceId: selectedSourceId.value },
|
||||
},
|
||||
);
|
||||
options.clearSourceRuntimeUnavailable?.(selectedSourceId.value);
|
||||
catalogs.value = data || [];
|
||||
sourceUnavailable.value = false;
|
||||
if (catalogs.value.length === 0) {
|
||||
selectedCatalogId.value = null;
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!selectedCatalogId.value ||
|
||||
!catalogs.value.some((item) => item.id === selectedCatalogId.value)
|
||||
) {
|
||||
selectedCatalogId.value = catalogs.value[0].id;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isSourceMissingError(error)) {
|
||||
markSourceUnavailable();
|
||||
return false;
|
||||
}
|
||||
if (!isSourceUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
markSourceUnavailable();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSourceTables() {
|
||||
if (!selectedSourceId.value) {
|
||||
sourceTables.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await requestClient.get('/api/v1/datacenterSource/tables', {
|
||||
params: {
|
||||
sourceId: selectedSourceId.value,
|
||||
catalogName: selectedCatalog.value?.catalogName,
|
||||
},
|
||||
});
|
||||
options.clearSourceRuntimeUnavailable?.(selectedSourceId.value);
|
||||
sourceTables.value = data || [];
|
||||
sourceUnavailable.value = false;
|
||||
} catch (error) {
|
||||
if (!isSourceUnavailableError(error) && !isSourceMissingError(error)) {
|
||||
throw error;
|
||||
}
|
||||
markSourceUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManagedTables() {
|
||||
if (!selectedSourceId.value) {
|
||||
managedTables.value = [];
|
||||
selectedTableId.value = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await requestClient.get(
|
||||
'/api/v1/datacenterDataset/managedTables',
|
||||
{
|
||||
params: {
|
||||
sourceId: selectedSourceId.value,
|
||||
catalogId: selectedCatalogId.value,
|
||||
},
|
||||
},
|
||||
);
|
||||
managedTables.value = data || [];
|
||||
} catch (error) {
|
||||
if (!isSourceUnavailableError(error) && !isSourceMissingError(error)) {
|
||||
throw error;
|
||||
}
|
||||
markSourceUnavailable();
|
||||
return;
|
||||
}
|
||||
if (managedTables.value.length === 0) {
|
||||
selectedTableId.value = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!selectedTableId.value ||
|
||||
!managedTables.value.some((item) => item.id === selectedTableId.value)
|
||||
) {
|
||||
selectedTableId.value = managedTables.value[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTableRuntime() {
|
||||
if (!selectedTableId.value) {
|
||||
schema.value = null;
|
||||
previewRows.value = [];
|
||||
jobs.value = [];
|
||||
return;
|
||||
}
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const [schemaRes, previewRes, jobsRes] = await Promise.allSettled([
|
||||
requestClient.get('/api/v1/datacenterDataset/schema', {
|
||||
params: { tableId: selectedTableId.value },
|
||||
}),
|
||||
requestClient.post('/api/v1/datacenterDataset/queryPage', {
|
||||
datasetRef: { tableId: selectedTableId.value },
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
}),
|
||||
requestClient.get('/api/v1/datacenterExcel/job/list', {
|
||||
params: {
|
||||
sourceId: selectedSourceId.value,
|
||||
tableId: selectedTableId.value,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
if (schemaRes.status === 'fulfilled') {
|
||||
schema.value = schemaRes.value;
|
||||
} else if (
|
||||
isSourceUnavailableError(schemaRes.reason) ||
|
||||
isSourceMissingError(schemaRes.reason)
|
||||
) {
|
||||
markSourceUnavailable();
|
||||
return;
|
||||
} else {
|
||||
throw schemaRes.reason;
|
||||
}
|
||||
if (jobsRes.status === 'fulfilled') {
|
||||
jobs.value = jobsRes.value || [];
|
||||
} else if (
|
||||
isSourceUnavailableError(jobsRes.reason) ||
|
||||
isSourceMissingError(jobsRes.reason)
|
||||
) {
|
||||
markSourceUnavailable();
|
||||
return;
|
||||
} else {
|
||||
throw jobsRes.reason;
|
||||
}
|
||||
if (previewRes.status === 'fulfilled') {
|
||||
options.clearSourceRuntimeUnavailable?.(selectedSourceId.value);
|
||||
previewRows.value = previewRes.value?.records || [];
|
||||
sourceUnavailable.value = false;
|
||||
} else if (
|
||||
isSourceUnavailableError(previewRes.reason) ||
|
||||
isSourceMissingError(previewRes.reason)
|
||||
) {
|
||||
markSourceUnavailable();
|
||||
} else {
|
||||
throw previewRes.reason;
|
||||
}
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSourceContext() {
|
||||
const catalogAvailable = await loadCatalogs();
|
||||
if (catalogAvailable) {
|
||||
await Promise.all([loadSourceTables(), loadManagedTables()]);
|
||||
} else {
|
||||
sourceTables.value = [];
|
||||
await loadManagedTables();
|
||||
}
|
||||
await loadTableRuntime();
|
||||
}
|
||||
|
||||
async function registerTable(row: any) {
|
||||
const data = await requestClient.get(
|
||||
'/api/v1/datacenterSource/tableDetail',
|
||||
{
|
||||
params: {
|
||||
sourceId: selectedSourceId.value,
|
||||
catalogName: selectedCatalog.value?.catalogName,
|
||||
tableName: row.tableName,
|
||||
register: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
ElMessage.success('已接入数据中心');
|
||||
await loadManagedTables();
|
||||
selectedTableId.value = data?.table?.id || selectedTableId.value;
|
||||
await loadTableRuntime();
|
||||
}
|
||||
|
||||
async function batchRegisterTables(rows: any[]) {
|
||||
const tableNames = (rows || [])
|
||||
.map((row) => row?.tableName)
|
||||
.filter(Boolean);
|
||||
if (tableNames.length === 0) return;
|
||||
await requestClient.post('/api/v1/datacenterSource/registerBatch', {
|
||||
sourceId: selectedSourceId.value,
|
||||
catalogName: selectedCatalog.value?.catalogName,
|
||||
tableNames,
|
||||
});
|
||||
ElMessage.success(`已接入 ${tableNames.length} 张表`);
|
||||
await loadManagedTables();
|
||||
}
|
||||
|
||||
async function batchRemoveTables(rows: any[]) {
|
||||
const tableIds = (rows || []).map((row) => row?.id).filter(Boolean);
|
||||
if (tableIds.length === 0) return;
|
||||
await requestClient.post('/api/v1/datacenterDataset/removeBatch', {
|
||||
tableIds,
|
||||
});
|
||||
ElMessage.success(`已去除 ${tableIds.length} 张表`);
|
||||
if (selectedTableId.value && tableIds.includes(selectedTableId.value)) {
|
||||
selectedTableId.value = null;
|
||||
schema.value = null;
|
||||
previewRows.value = [];
|
||||
jobs.value = [];
|
||||
}
|
||||
await Promise.all([loadSourceTables(), loadManagedTables()]);
|
||||
}
|
||||
|
||||
async function saveDescriptions(payload: {
|
||||
fields?: Array<{ fieldDesc: string; fieldId: number | string }>;
|
||||
tableDesc?: string;
|
||||
tableId: number | string;
|
||||
}) {
|
||||
const data = await requestClient.post(
|
||||
'/api/v1/datacenterDataset/saveDescriptions',
|
||||
{
|
||||
fields: payload.fields || [],
|
||||
tableDesc: payload.tableDesc ?? '',
|
||||
tableId: payload.tableId,
|
||||
},
|
||||
);
|
||||
await loadManagedTables();
|
||||
if (selectedTableId.value === payload.tableId) {
|
||||
schema.value = data || null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
catalogs,
|
||||
sourceTables,
|
||||
managedTables,
|
||||
schema,
|
||||
previewRows,
|
||||
jobs,
|
||||
sourceUnavailable,
|
||||
selectedCatalogId,
|
||||
selectedTableId,
|
||||
selectedCatalog,
|
||||
selectedTable,
|
||||
previewLoading,
|
||||
loadCatalogs,
|
||||
loadSourceTables,
|
||||
loadManagedTables,
|
||||
loadTableRuntime,
|
||||
syncSourceContext,
|
||||
registerTable,
|
||||
batchRegisterTables,
|
||||
batchRemoveTables,
|
||||
saveDescriptions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { FormRules } from 'element-plus';
|
||||
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
mergeConfigJsonBySourceType,
|
||||
sourceConnectionDefaults,
|
||||
sourceTypeLabels,
|
||||
} from './datacenter-constants';
|
||||
|
||||
export function useSourceForm() {
|
||||
const lastGeneratedJdbcUrl = ref('');
|
||||
const lastGeneratedDriverClassName = ref('');
|
||||
|
||||
const form = reactive<Record<string, any>>({
|
||||
id: undefined,
|
||||
sourceName: '',
|
||||
sourceCode: '',
|
||||
sourceType: 'MYSQL',
|
||||
accessMode: 'READ_ONLY',
|
||||
driverClassName: '',
|
||||
jdbcUrl: '',
|
||||
host: '',
|
||||
port: undefined,
|
||||
databaseName: '',
|
||||
schemaName: '',
|
||||
username: '',
|
||||
password: '',
|
||||
builtinFlag: 0,
|
||||
configJson: {},
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
sourceName: [
|
||||
{ required: true, message: '请输入连接名称', trigger: 'blur' },
|
||||
],
|
||||
sourceType: [
|
||||
{ required: true, message: '请选择连接类型', trigger: 'change' },
|
||||
],
|
||||
databaseName: [
|
||||
{
|
||||
trigger: ['blur', 'change'],
|
||||
validator: (_rule, value, callback) => {
|
||||
if (!sourceConnectionDefaults[form.sourceType]) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
if (String(value || '').trim()) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
callback(new Error('请输入库名'));
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const selectedTypeDefaults = computed(
|
||||
() => sourceConnectionDefaults[form.sourceType] || null,
|
||||
);
|
||||
|
||||
const supportsExternalConnection = computed(() =>
|
||||
Boolean(selectedTypeDefaults.value),
|
||||
);
|
||||
|
||||
const connectionTypeLabel = computed(() =>
|
||||
form.sourceType ? sourceTypeLabels[form.sourceType] || form.sourceType : '',
|
||||
);
|
||||
|
||||
const connectionHelpText = computed(() => {
|
||||
if (form.sourceType === 'EXCEL') {
|
||||
return 'Excel 连接只需要名称,文件导入后会自动生成表。';
|
||||
}
|
||||
if (!supportsExternalConnection.value) {
|
||||
return '系统内置连接由平台自动维护,无需填写地址。';
|
||||
}
|
||||
return `填写主机、库名和账号即可,端口与连接地址会按 ${connectionTypeLabel.value} 自动补全。`;
|
||||
});
|
||||
|
||||
function generateJdbcUrl(payload: Record<string, any>) {
|
||||
return (
|
||||
sourceConnectionDefaults[payload.sourceType]?.buildJdbcUrl(payload) || ''
|
||||
);
|
||||
}
|
||||
|
||||
function syncGeneratedJdbcUrl(force = false) {
|
||||
const generated = generateJdbcUrl(form);
|
||||
if (!generated) {
|
||||
if (
|
||||
force ||
|
||||
!form.jdbcUrl ||
|
||||
form.jdbcUrl === lastGeneratedJdbcUrl.value
|
||||
) {
|
||||
form.jdbcUrl = '';
|
||||
}
|
||||
lastGeneratedJdbcUrl.value = '';
|
||||
return;
|
||||
}
|
||||
if (
|
||||
force ||
|
||||
!form.jdbcUrl ||
|
||||
form.jdbcUrl === lastGeneratedJdbcUrl.value
|
||||
) {
|
||||
form.jdbcUrl = generated;
|
||||
lastGeneratedJdbcUrl.value = generated;
|
||||
} else if (form.jdbcUrl === generated) {
|
||||
lastGeneratedJdbcUrl.value = generated;
|
||||
}
|
||||
}
|
||||
|
||||
function applySourceTypeDefaults(force = false) {
|
||||
const defaults = selectedTypeDefaults.value;
|
||||
form.configJson = mergeConfigJsonBySourceType(
|
||||
form.sourceType,
|
||||
form.configJson,
|
||||
);
|
||||
if (!defaults) {
|
||||
if (force) {
|
||||
form.port = undefined;
|
||||
form.driverClassName = '';
|
||||
form.jdbcUrl = '';
|
||||
lastGeneratedDriverClassName.value = '';
|
||||
lastGeneratedJdbcUrl.value = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (force || !form.port) {
|
||||
form.port = defaults.defaultPort;
|
||||
}
|
||||
if (
|
||||
force ||
|
||||
!form.driverClassName ||
|
||||
form.driverClassName === lastGeneratedDriverClassName.value
|
||||
) {
|
||||
form.driverClassName = defaults.defaultDriver;
|
||||
lastGeneratedDriverClassName.value = defaults.defaultDriver;
|
||||
}
|
||||
syncGeneratedJdbcUrl(force);
|
||||
}
|
||||
|
||||
function resetForm(row?: any) {
|
||||
form.id = row?.id;
|
||||
form.sourceName = row?.sourceName || '';
|
||||
form.sourceCode = row?.sourceCode || '';
|
||||
form.sourceType = row?.sourceType || 'MYSQL';
|
||||
form.accessMode = row?.accessMode || 'READ_ONLY';
|
||||
form.driverClassName = row?.driverClassName || '';
|
||||
form.jdbcUrl = row?.jdbcUrl || '';
|
||||
form.host = row?.host || '';
|
||||
form.port = row?.port;
|
||||
form.databaseName = row?.databaseName || '';
|
||||
form.schemaName = row?.schemaName || '';
|
||||
form.username = row?.username || '';
|
||||
form.password = '';
|
||||
form.builtinFlag = row?.builtinFlag || 0;
|
||||
form.configJson = mergeConfigJsonBySourceType(
|
||||
form.sourceType,
|
||||
row?.configJson || {},
|
||||
);
|
||||
lastGeneratedDriverClassName.value = '';
|
||||
lastGeneratedJdbcUrl.value = '';
|
||||
applySourceTypeDefaults(false);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.sourceType,
|
||||
() => applySourceTypeDefaults(true),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [
|
||||
form.host,
|
||||
form.port,
|
||||
form.databaseName,
|
||||
form.configJson?.informixServer,
|
||||
form.configJson?.serviceName,
|
||||
],
|
||||
() => syncGeneratedJdbcUrl(false),
|
||||
);
|
||||
|
||||
return {
|
||||
form,
|
||||
rules,
|
||||
supportsExternalConnection,
|
||||
connectionHelpText,
|
||||
resetForm,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user