feat: 重构数据中枢工作台与接入管理
- 新增统一的数据源、目录、纳管表与 Excel 处理后端能力 - 重建管理端数据中枢工作台并替换旧表管理页面 - 补充数据中枢迁移脚本、连接器底座与说明字段支持
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user