feat: 重构数据中枢工作台与接入管理

- 新增统一的数据源、目录、纳管表与 Excel 处理后端能力

- 重建管理端数据中枢工作台并替换旧表管理页面

- 补充数据中枢迁移脚本、连接器底座与说明字段支持
This commit is contained in:
2026-04-02 18:55:31 +08:00
parent b6213d0933
commit 798effbd5b
117 changed files with 9739 additions and 1824 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}