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

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