feat: 完善数据中枢联邦查询闭环

- 重构数据源生命周期、元数据纳管与运行时切换

- 增加只读 SQL、查询审计、跨节点取消与工作流联动

- 完善管理端连接配置、元数据浏览与 SQL 工作台
This commit is contained in:
2026-08-26 18:14:34 +08:00
parent 3e79e99925
commit e38821e48a
113 changed files with 17032 additions and 1151 deletions

View File

@@ -90,6 +90,7 @@ function createSourceOnlyDatasetRef(
source: ManagedDatasetSourceOption,
): DatasetRefPayload {
return {
tenantId: source.tables[0]?.datasetRef.tenantId ?? null,
sourceId: source.sourceId,
catalogId: null,
catalogName: '',

View File

@@ -1,6 +1,7 @@
import { api } from '#/api/request';
export interface DatasetRefPayload {
tenantId: null | number | string;
sourceId: null | number | string;
catalogId?: null | number | string;
catalogName?: string;
@@ -110,6 +111,7 @@ export async function loadManagedDatasetOptions(): Promise<
catalogName: catalog.catalogName,
tableName: table.tableName,
datasetRef: {
tenantId: table.tenantId,
sourceId: source.id,
catalogId: catalog.id,
catalogName: catalog.catalogName,

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { TreeNode } from './composables/use-connection-tree';
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import {
EasyFlowButton,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
@@ -13,6 +14,7 @@ import { ElEmpty, ElMessage, ElMessageBox } from 'element-plus';
import ConnectionTree from './components/ConnectionTree.vue';
import SourceFormDrawer from './components/SourceFormDrawer.vue';
import SqlConsoleView from './components/SqlConsoleView.vue';
import TableDetailView from './components/TableDetailView.vue';
import TableListView from './components/TableListView.vue';
import { useConnectionTree } from './composables/use-connection-tree';
@@ -23,19 +25,36 @@ const sourceFormRef = ref<InstanceType<typeof SourceFormDrawer>>();
const sourceFormVisible = ref(false);
const workspaceRef = ref<HTMLElement>();
const workspaceHeight = ref('100%');
const isCompactLayout = ref(false);
const pendingSourceActionIds = ref<Set<string>>(new Set());
const pendingSourceIds = computed(() => pendingSourceActionIds.value);
const {
sources,
selectedSourceId,
selectedSource,
hasMoreSources,
loading,
loadingMoreSources,
loadMoreSourcesFailed,
saving,
sourceLoadError,
testing,
activateSource,
changeSourceState,
importExcelSource,
loadCandidateCatalogs,
loadCandidateTables,
loadBindableCatalogs,
loadBindableTables,
loadManagedScopeTables,
loadSources,
loadMoreSources,
probeCandidate,
probeSource,
reconfigureSource,
removeSource,
saveSource,
testConnection,
saveDraft,
markSourceRuntimeUnavailable,
clearSourceRuntimeUnavailable,
} = useDatacenterSources();
@@ -43,15 +62,29 @@ const {
const {
sourceTables,
managedTables,
catalogs,
schema,
previewRows,
jobs,
sourceTableHasMore,
sourceTableLoadingMore,
sourceUnavailable,
tableRuntimeError,
selectedCatalogId,
selectedTableId,
selectedTable,
previewLoading,
fieldPageNumber,
fieldHasMore,
fieldLoading,
tableMutationKind,
contextError,
contextLoading,
loadTableRuntime,
loadFieldPage,
loadMoreSourceTables,
retrySourceContext,
searchSourceTables,
syncSourceContext,
batchRegisterTables,
batchRemoveTables,
@@ -66,6 +99,9 @@ async function reloadAll(options?: {
resetTable?: boolean;
}) {
await loadSources();
if (selectedSource.value?.status !== 'READY') {
sourceViewMode.value = 'tables';
}
if (selectedSourceId.value === null) {
selectedCatalogId.value = null;
selectedTableId.value = null;
@@ -87,9 +123,31 @@ async function reloadAll(options?: {
viewMode.value = 'list';
}
async function refreshSelectedSourceContext() {
sourceViewMode.value = 'tables';
selectedCatalogId.value = null;
selectedTableId.value = null;
if (selectedSourceId.value === null) {
selectedNodeKey.value = '';
viewMode.value = 'empty';
await syncSourceContext();
return;
}
selectedNodeKey.value = `source-${selectedSourceId.value}`;
viewMode.value = 'list';
await syncSourceContext();
}
async function refreshLifecycleTarget(sourceId: number | string) {
if (String(selectedSourceId.value) !== String(sourceId)) return;
await refreshSelectedSourceContext();
}
// 视图状态empty | list | detail
const selectedNodeKey = ref('');
const viewMode = ref<'detail' | 'empty' | 'list'>('empty');
const sourceViewMode = ref<'sql' | 'tables'>('tables');
let loadAllInFlight = 0;
const { treeData, parseNodeKey } = useConnectionTree(sources);
@@ -99,13 +157,14 @@ async function handleNodeSelect(node: TreeNode) {
switch (node.type) {
case 'source': {
const { id } = parseNodeKey(node.id);
sourceViewMode.value = 'tables';
viewMode.value = 'list';
if (id !== selectedSourceId.value) {
selectedSourceId.value = id;
selectedCatalogId.value = null;
selectedTableId.value = null;
await syncSourceContext();
}
viewMode.value = 'list';
break;
}
@@ -147,11 +206,11 @@ async function handleSaveTableDescription(
});
}
function handleSelectTable(row: any) {
async function handleSelectTable(row: any) {
selectedTableId.value = row.id;
selectedNodeKey.value = `table-${row.id}`;
viewMode.value = 'detail';
loadTableRuntime();
await loadTableRuntime();
}
function handleBackToList() {
@@ -168,64 +227,133 @@ function openCreate() {
function openEdit(node: TreeNode) {
if (node.type !== 'source') return;
if (node.meta?.sourceType === 'EXCEL') {
ElMessage.info('Excel 连接通过重新导入文件更新');
return;
}
if (!['DEGRADED', 'DRAFT', 'READY'].includes(node.meta?.status)) {
ElMessage.info('当前连接状态不支持编辑');
return;
}
sourceFormRef.value?.open(node.meta);
sourceFormVisible.value = true;
}
async function handleDisableSource(node: TreeNode) {
if (node.type !== 'source' || !node.meta?.id) return;
if (pendingSourceActionIds.value.has(String(node.meta.id))) return;
try {
await ElMessageBox.confirm(
`停用“${node.label}”后将拒绝新查询。`,
'停用连接',
{
type: 'warning',
confirmButtonText: '停用',
cancelButtonText: '取消',
},
);
} catch (error) {
if (error === 'cancel' || error === 'close') return;
throw error;
}
await runSourceAction(node.meta.id, async () => {
await changeSourceState(node.meta.id, 'disable');
await refreshLifecycleTarget(node.meta.id);
ElMessage.success('连接已停用');
});
}
async function handleEnableSource(node: TreeNode) {
if (node.type !== 'source' || !node.meta?.id) return;
await runSourceAction(node.meta.id, async () => {
await changeSourceState(node.meta.id, 'enable');
await refreshLifecycleTarget(node.meta.id);
ElMessage.success('连接已启用');
});
}
async function handleRefreshMetadata(node: TreeNode) {
if (node.type !== 'source' || !node.meta?.id) return;
await runSourceAction(node.meta.id, async () => {
await changeSourceState(node.meta.id, 'metadata/refresh');
await refreshLifecycleTarget(node.meta.id);
ElMessage.success('元数据已刷新');
});
}
async function runSourceAction(
sourceId: number | string,
action: () => Promise<void>,
) {
const key = String(sourceId);
if (pendingSourceActionIds.value.has(key)) return;
pendingSourceActionIds.value = new Set(pendingSourceActionIds.value).add(key);
try {
await action();
} finally {
const next = new Set(pendingSourceActionIds.value);
next.delete(key);
pendingSourceActionIds.value = next;
}
}
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',
});
try {
await ElMessageBox.confirm(`确认删除“${node.label}”吗?`, '删除连接', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger',
});
} catch (error) {
if (error === 'cancel' || error === 'close') return;
throw error;
}
const removingCurrent = selectedSourceId.value === node.meta.id;
await removeSource(node.meta.id);
if (removingCurrent) {
selectedTableId.value = null;
await refreshSelectedSourceContext();
}
await reloadAll({ focus: 'source', resetTable: true });
}
async function handleSaveSource(
formData: Record<string, any>,
excelFile?: File,
) {
if (formData.sourceType === 'EXCEL' && !formData.id) {
if (!excelFile) return;
await importExcelSource(excelFile, formData.sourceName);
ElMessage.success('Excel 已导入');
} else {
await saveSource(formData);
ElMessage.success(formData.id ? '连接已更新' : '连接已创建');
}
sourceFormRef.value?.close();
sourceFormVisible.value = false;
async function handleSourceCompleted(source?: any) {
ElMessage.success(source ? '数据连接已激活' : 'Excel 已导入');
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();
await refreshSelectedSourceContext();
}
async function loadAll() {
loadAllInFlight += 1;
loading.value = true;
try {
await reloadAll({ focus: 'source', resetTable: true });
} catch (error) {
console.error('数据连接加载失败', error);
} finally {
loading.value = false;
loadAllInFlight = Math.max(0, loadAllInFlight - 1);
loading.value = loadAllInFlight > 0;
}
}
async function handleLoadMoreSources() {
try {
await loadMoreSources();
} catch (error) {
console.error('更多数据连接加载失败', error);
}
}
function updateWorkspaceHeight() {
const element = workspaceRef.value;
if (!element) return;
isCompactLayout.value = window.innerWidth < 900;
const top = element.getBoundingClientRect().top;
workspaceHeight.value = `${Math.max(window.innerHeight - top, 480)}px`;
workspaceHeight.value = `${Math.max(
window.innerHeight - top,
isCompactLayout.value ? 640 : 480,
)}px`;
}
onMounted(async () => {
@@ -248,13 +376,29 @@ onBeforeUnmount(() => {
v-loading="loading"
>
<!-- 双栏主体 -->
<ResizablePanelGroup direction="horizontal" class="workspace-body">
<ResizablePanel :default-size="20" :min-size="16" :max-size="30">
<ResizablePanelGroup
:direction="isCompactLayout ? 'vertical' : 'horizontal'"
class="workspace-body"
>
<ResizablePanel
:default-size="isCompactLayout ? 34 : 20"
:min-size="isCompactLayout ? 24 : 16"
:max-size="isCompactLayout ? 54 : 30"
>
<ConnectionTree
:has-more="hasMoreSources"
:load-error="sourceLoadError"
:load-more-failed="loadMoreSourcesFailed"
:loading-more="loadingMoreSources"
:pending-source-ids="pendingSourceIds"
:tree-data="treeData"
:selected-key="selectedNodeKey"
@create="openCreate"
@edit="openEdit"
@disable="handleDisableSource"
@enable="handleEnableSource"
@metadata-refresh="handleRefreshMetadata"
@load-more="handleLoadMoreSources"
@refresh="loadAll"
@remove="handleRemoveSource"
@select="handleNodeSelect"
@@ -263,28 +407,92 @@ onBeforeUnmount(() => {
<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"
/>
<ResizablePanel
:default-size="isCompactLayout ? 66 : 80"
:min-size="isCompactLayout ? 40 : 44"
>
<div v-if="viewMode === 'list'" class="source-workspace">
<div v-if="selectedSource" class="source-mode-bar">
<div
class="source-mode-switch"
role="tablist"
aria-label="数据源视图"
>
<EasyFlowButton
size="sm"
:variant="sourceViewMode === 'tables' ? 'default' : 'ghost'"
role="tab"
:aria-selected="sourceViewMode === 'tables'"
@click="sourceViewMode = 'tables'"
>
数据表
</EasyFlowButton>
<EasyFlowButton
v-if="
['MYSQL', 'POSTGRESQL'].includes(selectedSource.sourceType) &&
selectedSource.status === 'READY'
"
size="sm"
:variant="sourceViewMode === 'sql' ? 'default' : 'ghost'"
role="tab"
:aria-selected="sourceViewMode === 'sql'"
@click="sourceViewMode = 'sql'"
>
SQL 查询
</EasyFlowButton>
</div>
<span
class="source-runtime-status"
:class="`is-${String(selectedSource.status || '').toLowerCase()}`"
>
{{ selectedSource.status || 'READY' }}
</span>
</div>
<TableListView
v-if="sourceViewMode === 'tables'"
class="source-view-content"
:source-tables="sourceTables"
:has-more="sourceTableHasMore"
:managed-tables="managedTables"
:load-error="contextError"
:loading="contextLoading"
:loading-more="sourceTableLoadingMore"
:mutation-kind="tableMutationKind"
:save-table-description="handleSaveTableDescription"
:source-id="String(selectedSourceId)"
:source-label="selectedSource?.sourceName || ''"
:source-unavailable="sourceUnavailable"
@register-tables="handleBatchRegister"
@load-more="loadMoreSourceTables"
@remove-tables="handleBatchRemove"
@retry="retrySourceContext"
@search="searchSourceTables"
@select-table="handleSelectTable"
/>
<SqlConsoleView
v-else
class="source-view-content"
:source="selectedSource"
:catalogs="catalogs"
:tables="managedTables"
/>
</div>
<TableDetailView
v-else-if="viewMode === 'detail' && selectedTable"
:table="selectedTable"
:schema="schema"
:preview-rows="previewRows"
:jobs="jobs"
:field-has-more="fieldHasMore"
:field-loading="fieldLoading"
:field-page-number="fieldPageNumber"
:load-error="tableRuntimeError"
:loading="previewLoading"
:save-descriptions="saveDescriptions"
:source-unavailable="sourceUnavailable"
@back="handleBackToList"
@field-page-change="loadFieldPage"
@retry="loadTableRuntime"
/>
<div v-else class="empty-state">
<ElEmpty description="从左侧选择连接或表开始浏览" />
@@ -298,8 +506,18 @@ onBeforeUnmount(() => {
v-model:visible="sourceFormVisible"
:saving="saving"
:testing="testing"
@save="handleSaveSource"
@test="handleTestConnection"
:save-draft="saveDraft"
:probe-source="probeSource"
:load-catalogs="loadBindableCatalogs"
:load-tables="loadBindableTables"
:load-candidate-catalogs="loadCandidateCatalogs"
:load-candidate-tables="loadCandidateTables"
:load-managed-tables="loadManagedScopeTables"
:probe-candidate="probeCandidate"
:activate-source="activateSource"
:reconfigure-source="reconfigureSource"
:import-excel-source="importExcelSource"
@completed="handleSourceCompleted"
/>
</div>
</template>
@@ -345,6 +563,85 @@ onBeforeUnmount(() => {
transform: translateX(-50%);
}
.workspace-body :deep([data-panel-group-direction='vertical']) {
width: 100%;
}
.workspace-body
:deep(
[data-panel-group-direction='vertical'] [data-panel-resize-handle-enabled]
) {
width: 100%;
height: 10px;
}
.workspace-body
:deep(
[data-panel-group-direction='vertical']
[data-panel-resize-handle-enabled]::before
) {
top: 50%;
right: 20px;
bottom: auto;
left: 20px;
width: auto;
height: 1px;
transform: translateY(-50%);
}
.source-workspace {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.source-mode-bar {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
min-height: 44px;
padding: 0 16px 8px 20px;
}
.source-mode-switch {
display: inline-flex;
gap: 4px;
padding: 3px;
background: hsl(var(--surface-subtle));
border-radius: var(--radius-control);
}
.source-mode-switch :deep(button) {
min-height: 30px;
box-shadow: none;
}
.source-runtime-status {
padding: 4px 8px;
font-size: 11px;
font-weight: 600;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted));
border-radius: 999px;
}
.source-runtime-status.is-ready {
color: hsl(var(--success));
background: hsl(var(--success) / 9%);
}
.source-runtime-status.is-degraded {
color: hsl(var(--warning));
background: hsl(var(--warning) / 9%);
}
.source-view-content {
flex: 1;
min-height: 0;
}
.empty-state {
display: flex;
align-items: center;

View File

@@ -5,20 +5,34 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
import { Plus, RefreshRight, Search } from '@element-plus/icons-vue';
import {
MoreFilled,
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<{
hasMore?: boolean;
loadError?: string;
loadingMore?: boolean;
loadMoreFailed?: boolean;
pendingSourceIds?: Set<string>;
selectedKey: string;
treeData: TreeNode[];
}>();
const emit = defineEmits<{
create: [];
disable: [node: TreeNode];
edit: [node: TreeNode];
enable: [node: TreeNode];
loadMore: [];
metadataRefresh: [node: TreeNode];
refresh: [];
remove: [node: TreeNode];
select: [node: TreeNode];
@@ -54,16 +68,53 @@ function handleClick(node: TreeNode) {
emit('select', node);
}
function handleLoadMoreAction() {
if (props.loadError && !props.loadMoreFailed) {
emit('refresh');
return;
}
emit('loadMore');
}
function handleContextMenu(event: MouseEvent, node: TreeNode) {
event.preventDefault();
if (node.type !== 'source' || node.meta?.builtinFlag) return;
emit('select', node);
openContextMenu(node, event.clientX, event.clientY);
}
function openContextMenu(node: TreeNode, x: number, y: number) {
contextMenuNode.value = node;
contextMenuX.value = Math.min(event.clientX, window.innerWidth - 156);
contextMenuY.value = Math.min(event.clientY, window.innerHeight - 108);
contextMenuX.value = Math.min(x, window.innerWidth - 156);
contextMenuY.value = Math.min(y, window.innerHeight - 224);
contextMenuVisible.value = true;
}
function isSourcePending(node: TreeNode) {
return props.pendingSourceIds?.has(String(node.meta?.id)) ?? false;
}
function handleMenuButton(event: MouseEvent, node: TreeNode) {
const target = event.currentTarget as HTMLElement;
const bounds = target.getBoundingClientRect();
openContextMenu(node, bounds.right - 156, bounds.bottom + 4);
}
function canEdit(node: TreeNode) {
return ['DEGRADED', 'DRAFT', 'READY'].includes(node.meta?.status);
}
function canDisable(node: TreeNode) {
return ['DEGRADED', 'READY'].includes(node.meta?.status);
}
function canEnable(node: TreeNode) {
return node.meta?.status === 'DISABLED';
}
function canRefreshMetadata(node: TreeNode) {
return ['DEGRADED', 'READY'].includes(node.meta?.status);
}
function isExpanded(nodeId: string) {
return expandedKeys.value.has(nodeId);
}
@@ -87,6 +138,15 @@ function handleRemove() {
closeContextMenu();
}
function handleSourceAction(action: 'disable' | 'enable' | 'metadataRefresh') {
if (contextMenuNode.value) {
if (action === 'disable') emit('disable', contextMenuNode.value);
else if (action === 'enable') emit('enable', contextMenuNode.value);
else emit('metadataRefresh', contextMenuNode.value);
}
closeContextMenu();
}
function getNodeIcon(node: TreeNode): string {
if (node.type === 'source') {
return node.icon === 'file'
@@ -156,7 +216,12 @@ onBeforeUnmount(() => {
<div
class="tree-node tree-node--source"
:class="{ 'is-selected': selectedKey === source.id }"
role="button"
tabindex="0"
:aria-expanded="isExpanded(source.id)"
@click="handleClick(source)"
@keydown.enter.prevent="handleClick(source)"
@keydown.space.prevent="handleClick(source)"
@contextmenu="handleContextMenu($event, source)"
>
<span class="node-media">
@@ -175,6 +240,17 @@ onBeforeUnmount(() => {
>
<span class="node-status-dot" aria-label="连接不可用"></span>
</ElTooltip>
<button
v-if="!source.meta?.builtinFlag"
type="button"
class="node-menu-button"
:aria-label="`${source.label}操作`"
:disabled="isSourcePending(source)"
@click.stop="handleMenuButton($event, source)"
@keydown.stop
>
<ElIcon><MoreFilled /></ElIcon>
</button>
</div>
<!-- 表节点 -->
@@ -184,7 +260,11 @@ onBeforeUnmount(() => {
:key="table.id"
class="tree-node tree-node--table"
:class="{ 'is-selected': selectedKey === table.id }"
role="button"
tabindex="0"
@click="handleClick(table)"
@keydown.enter.prevent="handleClick(table)"
@keydown.space.prevent="handleClick(table)"
@contextmenu="handleContextMenu($event, table)"
>
<span class="node-icon" :class="[getNodeIcon(table)]"></span>
@@ -195,8 +275,34 @@ onBeforeUnmount(() => {
</div>
<div v-else class="tree-empty">
{{ searchText ? '无匹配连接' : '还没有数据连接' }}
<template v-if="loadError">
<span>{{ loadError }}</span>
<EasyFlowButton
v-if="!searchText"
size="sm"
variant="outline"
@click="emit('refresh')"
>
重试
</EasyFlowButton>
</template>
<template v-else>
{{ searchText ? '无匹配连接' : '还没有数据连接' }}
</template>
</div>
<EasyFlowButton
v-if="(hasMore || loadError) && (filteredTree.length > 0 || searchText)"
class="load-more-action"
size="sm"
variant="ghost"
:loading="loadingMore"
:disabled="loadingMore"
@click="handleLoadMoreAction"
>
{{
loadError ? '重试加载' : searchText ? '继续加载并搜索' : '加载更多'
}}
</EasyFlowButton>
</div>
<div
@@ -205,12 +311,46 @@ onBeforeUnmount(() => {
:style="{ left: `${contextMenuX}px`, top: `${contextMenuY}px` }"
@click.stop
>
<button type="button" class="context-menu-item" @click="handleEdit">
<button
v-if="canEdit(contextMenuNode)"
type="button"
class="context-menu-item"
:disabled="isSourcePending(contextMenuNode)"
@click="handleEdit"
>
编辑
</button>
<button
v-if="canRefreshMetadata(contextMenuNode)"
type="button"
class="context-menu-item"
:disabled="isSourcePending(contextMenuNode)"
@click="handleSourceAction('metadataRefresh')"
>
刷新元数据
</button>
<button
v-if="canDisable(contextMenuNode)"
type="button"
class="context-menu-item"
:disabled="isSourcePending(contextMenuNode)"
@click="handleSourceAction('disable')"
>
停用
</button>
<button
v-if="canEnable(contextMenuNode)"
type="button"
class="context-menu-item"
:disabled="isSourcePending(contextMenuNode)"
@click="handleSourceAction('enable')"
>
启用
</button>
<button
type="button"
class="context-menu-item context-menu-item--danger"
:disabled="isSourcePending(contextMenuNode)"
@click="handleRemove"
>
删除
@@ -272,6 +412,29 @@ onBeforeUnmount(() => {
box-shadow: none;
}
.node-menu-button {
display: inline-flex;
flex: 0 0 28px;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: hsl(var(--muted-foreground));
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--radius-control);
}
.node-menu-button:hover,
.node-menu-button:focus-visible {
color: hsl(var(--foreground));
background: hsl(var(--muted) / 70%);
outline: 2px solid hsl(var(--ring));
outline-offset: 1px;
}
.refresh-action:hover {
color: hsl(var(--foreground));
background: hsl(var(--surface-subtle));
@@ -319,6 +482,11 @@ onBeforeUnmount(() => {
transform: translateY(-1px);
}
.tree-node:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
.tree-node.is-selected {
color: hsl(var(--foreground));
background: hsl(var(--primary) / 8%);
@@ -427,12 +595,22 @@ onBeforeUnmount(() => {
}
.tree-empty {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
padding: 32px 16px;
font-size: 13px;
color: hsl(var(--text-muted));
text-align: center;
}
.load-more-action {
width: 100%;
min-height: 36px;
color: hsl(var(--muted-foreground));
}
.connection-context-menu {
position: fixed;
z-index: 30;

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
import type {
DatacenterCatalogView,
DatacenterSourceView,
DatacenterTableView,
} from '../composables/datacenter-types';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
import { VideoPlay } from '@element-plus/icons-vue';
import {
ElEmpty,
ElIcon,
ElInput,
ElOption,
ElSelect,
ElTable,
ElTableColumn,
} from 'element-plus';
import { buildDatacenterExampleSql } from '../composables/datacenter-example-sql';
import { useDatacenterSqlConsole } from '../composables/use-datacenter-sql-console';
const props = defineProps<{
catalogs: DatacenterCatalogView[];
source: DatacenterSourceView | null;
tables: DatacenterTableView[];
}>();
const sql = ref('');
const maxRows = ref(200);
const lastGeneratedSql = ref('');
const {
cancelQuery,
cancelling,
clearResult,
errorMessage,
executeSql,
executing,
result,
} = useDatacenterSqlConsole();
const canExecute = computed(
() =>
Boolean(props.source?.id) &&
props.source?.status === 'READY' &&
['MYSQL', 'POSTGRESQL'].includes(props.source?.sourceType) &&
Boolean(sql.value.trim()) &&
!executing.value,
);
function buildExampleSql() {
return buildDatacenterExampleSql(props.tables, props.catalogs);
}
async function runQuery() {
const sourceId = props.source?.id;
if (!canExecute.value || sourceId === undefined || sourceId === null) return;
await executeSql(sourceId, sql.value.trim(), maxRows.value);
}
watch(
() => [props.source?.id, props.tables, props.catalogs],
() => {
const example = buildExampleSql();
if (!sql.value || sql.value === lastGeneratedSql.value) {
sql.value = example;
lastGeneratedSql.value = example;
}
void clearResult();
},
{ deep: true, immediate: true },
);
onBeforeUnmount(() => {
void clearResult();
});
</script>
<template>
<div class="sql-console">
<header class="console-header">
<div>
<h2>SQL 查询</h2>
<p>仅执行当前数据源已纳管表的只读 SQL结果最多返回 500 </p>
</div>
<div class="console-actions">
<ElSelect v-model="maxRows" class="row-limit" aria-label="最大返回行数">
<ElOption :value="100" label="100 行" />
<ElOption :value="200" label="200 行" />
<ElOption :value="500" label="500 行" />
</ElSelect>
<EasyFlowButton
:disabled="!canExecute"
:loading="executing"
@click="runQuery"
>
<ElIcon><VideoPlay /></ElIcon>
执行
</EasyFlowButton>
<EasyFlowButton
v-if="executing"
variant="outline"
:disabled="cancelling"
:loading="cancelling"
@click="cancelQuery"
>
取消
</EasyFlowButton>
</div>
</header>
<div v-if="source?.status !== 'READY'" class="console-notice">
请先完成连接测试和数据范围激活
</div>
<ElInput
v-model="sql"
type="textarea"
:rows="9"
resize="vertical"
spellcheck="false"
class="sql-editor"
placeholder="输入 SELECT 或 WITH 查询"
@keydown.meta.enter.prevent="runQuery"
@keydown.ctrl.enter.prevent="runQuery"
/>
<section class="result-panel">
<div v-if="result" class="result-summary">
<span>{{ result.returnedRows }} </span>
<span>{{ result.durationMs }} ms</span>
<span v-if="result.truncated">已按上限截断</span>
<span class="query-id">{{ result.queryId }}</span>
</div>
<ElTable
v-if="result?.columns?.length"
:data="result.rows"
height="100%"
stripe
class="result-table"
>
<ElTableColumn
v-for="column in result.columns"
:key="column.key"
:prop="column.key"
:label="column.label"
min-width="160"
show-overflow-tooltip
/>
</ElTable>
<div v-else-if="errorMessage" class="result-error" role="alert">
<p>{{ errorMessage }}</p>
<EasyFlowButton size="sm" variant="outline" @click="runQuery">
重试
</EasyFlowButton>
</div>
<ElEmpty v-else description="执行查询后在这里查看结果" />
</section>
</div>
</template>
<style scoped>
.sql-console {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
padding: 4px 16px 16px 20px;
overflow: hidden;
}
.console-header {
display: flex;
gap: 24px;
align-items: center;
justify-content: space-between;
}
.console-header h2 {
margin: 0 0 4px;
font-size: 18px;
color: hsl(var(--foreground));
}
.console-header p {
margin: 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.console-actions {
display: flex;
gap: 8px;
align-items: center;
}
.row-limit {
width: 112px;
}
.console-notice {
padding: 10px 12px;
font-size: 13px;
color: hsl(var(--warning));
background: hsl(var(--warning) / 8%);
border-radius: var(--radius-control);
}
.sql-editor :deep(.el-textarea__inner) {
padding: 14px 16px;
font-family: monospace;
line-height: 1.65;
color: hsl(var(--foreground));
background: hsl(var(--surface-subtle) / 54%);
border-radius: var(--radius-panel);
box-shadow: 0 0 0 1px hsl(var(--border)) inset;
}
.sql-editor :deep(.el-textarea__inner:focus) {
box-shadow: 0 0 0 1px hsl(var(--ring)) inset;
}
.result-panel {
display: flex;
flex: 1;
flex-direction: column;
min-height: 220px;
overflow: hidden;
background: hsl(var(--card));
border: 1px solid hsl(var(--border) / 72%);
border-radius: var(--radius-panel);
}
.result-error {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
padding: 24px;
color: hsl(var(--destructive));
text-align: center;
}
.result-error p {
margin: 0;
}
.result-summary {
display: flex;
gap: 16px;
align-items: center;
min-height: 40px;
padding: 0 14px;
font-size: 12px;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border) / 60%);
}
.query-id {
margin-left: auto;
font-family: monospace;
}
.result-table {
flex: 1;
}
.result-panel :deep(.el-empty) {
flex: 1;
}
@media (max-width: 760px) {
.console-header {
align-items: stretch;
flex-direction: column;
}
.console-actions {
justify-content: flex-end;
}
}
</style>

View File

@@ -7,6 +7,9 @@ import {
ElEmpty,
ElIcon,
ElInput,
ElOption,
ElSelect,
ElSwitch,
ElTable,
ElTableColumn,
ElTabPane,
@@ -20,11 +23,20 @@ import {
} from '../composables/datacenter-constants';
const props = defineProps<{
fieldHasMore?: boolean;
fieldLoading?: boolean;
fieldPageNumber?: number;
jobs: any[];
loadError?: string;
loading: boolean;
previewRows: any[];
saveDescriptions: (payload: {
fields?: Array<{ fieldDesc: string; fieldId: number | string }>;
fields?: Array<{
fieldDesc: string;
fieldId: number | string;
queryable?: number;
sensitivityLevel?: string;
}>;
tableDesc?: string;
tableId: number | string;
}) => Promise<any>;
@@ -35,21 +47,46 @@ const props = defineProps<{
const emit = defineEmits<{
back: [];
fieldPageChange: [pageNumber: number];
retry: [];
}>();
const activeTab = ref('data');
const editingFieldId = ref<null | number | string>(null);
const editingFieldDesc = ref('');
const editingFieldQueryable = ref(false);
const editingSensitivityLevel = ref('PUBLIC');
const savingFieldId = ref<null | number | string>(null);
const sensitivityLabels: Record<string, string> = {
INTERNAL: '内部',
PUBLIC: '公开',
RESTRICTED: '受限',
SENSITIVE: '敏感',
};
function startFieldEdit(field: any) {
editingFieldId.value = field.id;
editingFieldDesc.value = field.fieldDesc || '';
editingFieldQueryable.value = Number(field.queryable) === 1;
editingSensitivityLevel.value = field.sensitivityLevel || 'PUBLIC';
}
function cancelFieldEdit() {
editingFieldId.value = null;
editingFieldDesc.value = '';
editingFieldQueryable.value = false;
editingSensitivityLevel.value = 'PUBLIC';
}
function handleSensitivityChange(value: string) {
if (value !== 'PUBLIC') {
editingFieldQueryable.value = false;
}
}
function formatSensitivityLevel(value?: string) {
return sensitivityLabels[value || 'PUBLIC'] || '公开';
}
async function saveFieldDescription(field: any) {
@@ -64,6 +101,8 @@ async function saveFieldDescription(field: any) {
{
fieldDesc: editingFieldDesc.value,
fieldId: field.id,
queryable: editingFieldQueryable.value ? 1 : 0,
sensitivityLevel: editingSensitivityLevel.value,
},
],
});
@@ -75,7 +114,7 @@ async function saveFieldDescription(field: any) {
</script>
<template>
<div class="table-detail-view" v-loading="loading">
<div class="table-detail-view" v-loading="loading || fieldLoading">
<!-- 表头信息 -->
<div class="detail-header">
<div class="header-main">
@@ -91,8 +130,19 @@ async function saveFieldDescription(field: any) {
</div>
</div>
<ElEmpty v-if="loadError" :description="loadError">
<ElButton size="small" @click="emit('retry')">重试</ElButton>
</ElEmpty>
<ElEmpty
v-else-if="sourceUnavailable"
description="连接不可用,请检查配置或网络"
>
<ElButton size="small" @click="emit('retry')">重试</ElButton>
</ElEmpty>
<!-- Tabs 内容 -->
<ElTabs v-model="activeTab" class="detail-tabs">
<ElTabs v-else v-model="activeTab" class="detail-tabs">
<ElTabPane label="数据信息" name="data">
<div class="detail-scroll-region">
<ElEmpty v-if="previewRows.length === 0" description="暂无数据" />
@@ -160,6 +210,37 @@ async function saveFieldDescription(field: any) {
</template>
</ElTableColumn>
<ElTableColumn prop="jdbcType" label="类型" width="120" />
<ElTableColumn label="敏感级别" width="150">
<template #default="{ row }">
<ElSelect
v-if="editingFieldId === row.id"
v-model="editingSensitivityLevel"
size="small"
@change="handleSensitivityChange"
>
<ElOption label="公开" value="PUBLIC" />
<ElOption label="内部" value="INTERNAL" />
<ElOption label="敏感" value="SENSITIVE" />
<ElOption label="受限" value="RESTRICTED" />
</ElSelect>
<span v-else>{{
formatSensitivityLevel(row.sensitivityLevel)
}}</span>
</template>
</ElTableColumn>
<ElTableColumn label="可查询" width="100">
<template #default="{ row }">
<ElSwitch
v-if="editingFieldId === row.id"
v-model="editingFieldQueryable"
:disabled="editingSensitivityLevel !== 'PUBLIC'"
aria-label="允许查询该字段"
/>
<span v-else>
{{ Number(row.queryable) === 1 ? '' : '' }}
</span>
</template>
</ElTableColumn>
</ElTable>
</div>
</ElTabPane>
@@ -246,6 +327,30 @@ async function saveFieldDescription(field: any) {
</div>
</ElTabPane>
</ElTabs>
<div
v-if="
['data', 'schema'].includes(activeTab) &&
((fieldPageNumber || 1) > 1 || fieldHasMore)
"
class="field-page-controls"
>
<ElButton
size="small"
:disabled="(fieldPageNumber || 1) <= 1 || fieldLoading"
@click="emit('fieldPageChange', (fieldPageNumber || 1) - 1)"
>
上一页
</ElButton>
<span>第 {{ fieldPageNumber || 1 }} 页</span>
<ElButton
size="small"
:disabled="!fieldHasMore || fieldLoading"
@click="emit('fieldPageChange', (fieldPageNumber || 1) + 1)"
>
下一页
</ElButton>
</div>
</div>
</template>
@@ -358,6 +463,17 @@ async function saveFieldDescription(field: any) {
overflow: auto;
}
.field-page-controls {
display: flex;
flex: 0 0 44px;
gap: 8px;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
font-size: 13px;
color: hsl(var(--text-muted));
}
.tag-flow {
display: flex;
flex-wrap: wrap;

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { Delete, EditPen, Search } from '@element-plus/icons-vue';
import {
@@ -7,25 +7,35 @@ import {
ElEmpty,
ElIcon,
ElInput,
ElSkeleton,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
const props = defineProps<{
hasMore?: boolean;
loadError?: string;
loading?: boolean;
loadingMore?: boolean;
managedTables: any[];
mutationKind?: 'register' | 'remove' | null;
saveTableDescription: (
tableId: number | string,
tableDesc: string,
) => Promise<any>;
sourceId?: null | string;
sourceLabel: string;
sourceTables: any[];
sourceUnavailable?: boolean;
}>();
const emit = defineEmits<{
loadMore: [];
registerTables: [rows: any[]];
removeTables: [rows: any[]];
retry: [];
search: [keyword: string];
selectTable: [row: any];
}>();
@@ -34,6 +44,16 @@ const selectedRows = ref<any[]>([]);
const editingTableId = ref<null | number | string>(null);
const editingTableDesc = ref('');
const savingTableId = ref<null | number | string>(null);
let searchTimer: ReturnType<typeof setTimeout> | undefined;
watch(
() => props.sourceId,
() => {
if (searchTimer) clearTimeout(searchTimer);
tableKeyword.value = '';
selectedRows.value = [];
},
);
const managedTableMap = computed(
() => new Map(props.managedTables.map((item) => [item.tableName, item])),
@@ -48,7 +68,16 @@ function compareTableName(a?: string, b?: string) {
const tableRows = computed(() => {
const keyword = tableKeyword.value.trim().toLowerCase();
return props.sourceTables
const sourceTableNames = new Set(
props.sourceTables.map((item) => String(item.tableName || '')),
);
const allVisibleTables = [
...props.sourceTables,
...props.managedTables.filter(
(item) => !sourceTableNames.has(String(item.tableName || '')),
),
];
return allVisibleTables
.filter((item) => {
if (!keyword) return true;
return item.tableName?.toLowerCase().includes(keyword);
@@ -82,16 +111,28 @@ const selectedPendingRows = computed(() =>
const canBatchRegister = computed(() => selectedPendingRows.value.length > 0);
const canBatchRemove = computed(() => selectedManagedRows.value.length > 0);
const isMutating = computed(() => Boolean(props.mutationKind));
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows;
}
function handleSearchInput() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => emit('search', tableKeyword.value), 300);
}
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer);
});
function handleBatchRegister() {
if (isMutating.value) return;
emit('registerTables', selectedPendingRows.value);
}
function handleBatchRemove() {
if (isMutating.value) return;
emit('removeTables', selectedManagedRows.value);
}
@@ -135,7 +176,12 @@ async function handleSaveTableDescription(row: any) {
<div class="view-toolbar">
<div class="table-search-row">
<ElInput v-model="tableKeyword" placeholder="搜索表" clearable>
<ElInput
v-model="tableKeyword"
placeholder="搜索表"
clearable
@input="handleSearchInput"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
@@ -148,14 +194,16 @@ async function handleSaveTableDescription(row: any) {
</span>
<ElButton
size="small"
:disabled="!canBatchRegister"
:disabled="isMutating || !canBatchRegister"
:loading="mutationKind === 'register'"
@click="handleBatchRegister"
>
批量接入
</ElButton>
<ElButton
size="small"
:disabled="!canBatchRemove"
:disabled="isMutating || !canBatchRemove"
:loading="mutationKind === 'remove'"
@click="handleBatchRemove"
>
批量去除
@@ -164,120 +212,147 @@ async function handleSaveTableDescription(row: any) {
</div>
<div class="table-scroll-region">
<ElSkeleton v-if="loading" :rows="6" animated />
<ElEmpty v-else-if="loadError" :description="loadError">
<ElButton size="small" @click="emit('retry')">重试</ElButton>
</ElEmpty>
<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"
v-else-if="sourceUnavailable"
description="连接不可用,请检查配置或网络"
>
<ElTableColumn type="selection" width="52" />
<ElTableColumn
label="名称"
min-width="360"
class-name="table-name-column"
<ElButton size="small" @click="emit('retry')">重试</ElButton>
</ElEmpty>
<ElEmpty
v-else-if="tableRows.length === 0"
:description="
tableKeyword ? '没有匹配的表' : '当前连接下还没有可浏览的表'
"
/>
<template v-else>
<ElTable
v-loading="isMutating"
:data="tableRows"
height="100%"
size="small"
row-key="tableName"
class="flat-table"
@selection-change="handleSelectionChange"
>
<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"
<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"
:loading="savingTableId === row.managedTable.id"
@click="handleSaveTableDescription(row)"
>
保存
</ElButton>
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"
:disabled="savingTableId === row.managedTable.id"
@click="cancelEdit"
:disabled="isMutating"
@click="startEdit(row)"
>
取消
<ElIcon><EditPen /></ElIcon>
</ElButton>
</div>
</template>
</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"
</div>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100">
<template #default="{ row }">
<ElTag
size="small"
@click="handleOpenDetail(row)"
effect="plain"
:type="row.managedTable ? 'primary' : 'info'"
>
查看
</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>
{{ 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"
:disabled="isMutating"
@click="handleOpenDetail(row)"
>
查看
</ElButton>
<ElButton
v-if="row.managedTable"
class="icon-action icon-action--danger"
link
size="small"
:disabled="isMutating"
@click="emit('removeTables', [row.managedTable])"
>
<ElIcon><Delete /></ElIcon>
</ElButton>
<ElButton
v-else
link
type="primary"
size="small"
:disabled="isMutating"
@click="emit('registerTables', [row])"
>
接入
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<div v-if="hasMore" class="load-more-row">
<ElButton
link
size="small"
:loading="loadingMore"
@click="emit('loadMore')"
>
加载更多
</ElButton>
</div>
</template>
</div>
</div>
</template>
@@ -335,11 +410,20 @@ async function handleSaveTableDescription(row: any) {
}
.table-scroll-region {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.load-more-row {
display: flex;
flex: 0 0 40px;
align-items: center;
justify-content: center;
}
.flat-table {
--el-table-bg-color: transparent;
--el-table-tr-bg-color: transparent;

View File

@@ -1,11 +1,7 @@
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' },
{ label: 'Excel 文件', value: 'EXCEL' },
];
export const sourceConnectionDefaults: Record<
@@ -21,7 +17,7 @@ export const sourceConnectionDefaults: Record<
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`
? `jdbc:mysql://${p.host}:${p.port}/${p.databaseName}?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false&useCursorFetch=true&useServerPrepStmts=true`
: '',
},
POSTGRESQL: {

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { buildDatacenterExampleSql } from './datacenter-example-sql';
describe('datacenter SQL example', () => {
it('uses the active logical schema and quotes only the table identifier', () => {
expect(
buildDatacenterExampleSql(
[{ catalogId: '2', tableName: 'order"items' } as never],
[{ id: '2', logicalSchemaName: 'public' } as never],
),
).toBe('SELECT *\nFROM "order""items"\nFETCH NEXT 100 ROWS ONLY');
});
it('returns an empty example before table metadata is available', () => {
expect(buildDatacenterExampleSql([], [])).toBe('');
});
});

View File

@@ -0,0 +1,21 @@
import type {
DatacenterCatalogView,
DatacenterTableView,
} from './datacenter-types';
function quoteIdentifier(value: string) {
return `"${String(value).replaceAll('"', '""')}"`;
}
export function buildDatacenterExampleSql(
tables: DatacenterTableView[],
catalogs: DatacenterCatalogView[],
) {
const table = tables[0];
if (!table) return '';
const catalog =
catalogs.find((item) => String(item.id) === String(table.catalogId)) ||
catalogs[0];
if (!catalog) return '';
return `SELECT *\nFROM ${quoteIdentifier(table.tableName)}\nFETCH NEXT 100 ROWS ONLY`;
}

View File

@@ -0,0 +1,37 @@
import type { DatacenterId } from './datacenter-id';
export interface DatacenterCatalogView {
catalogName: string;
id: DatacenterId;
logicalSchemaName?: string;
}
export interface DatacenterSourceView {
id: DatacenterId;
passwordConfigured?: boolean;
sourceName: string;
sourceType: string;
status: string;
[key: string]: unknown;
}
export interface DatacenterTableView {
catalogId?: DatacenterId;
tableKind?: string;
tableName: string;
[key: string]: unknown;
}
export interface DatacenterSqlColumnView {
key: string;
label: string;
}
export interface DatacenterSqlConsoleResult {
columns: DatacenterSqlColumnView[];
durationMs: number;
queryId: string;
returnedRows: number;
rows: Record<string, unknown>[];
truncated: boolean;
}

View File

@@ -7,17 +7,44 @@ import { api } from '#/api/request';
import { mergeConfigJsonBySourceType } from './datacenter-constants';
export function useDatacenterSources() {
const SOURCE_PAGE_SIZE = 100;
const sources = ref<any[]>([]);
const selectedSourceId = ref<DatacenterId | null>(null);
const loading = ref(false);
const loadingMoreSources = ref(false);
const saving = ref(false);
const testing = ref(false);
const sourceLoadError = ref('');
const loadMoreSourcesFailed = ref(false);
const loadedSourceRowCount = ref(0);
const sourceTotalRows = ref<null | number>(null);
const sourcePageNumber = ref(0);
const lastSourcePageSize = ref(0);
let sourceLoadEpoch = 0;
let sourcePaginationDirty = false;
let savingOperations = 0;
function beginSaving() {
savingOperations += 1;
saving.value = true;
}
function endSaving() {
savingOperations = Math.max(0, savingOperations - 1);
saving.value = savingOperations > 0;
}
const selectedSource = computed(
() =>
sources.value.find((item) => item.id === selectedSourceId.value) || null,
);
const hasMoreSources = computed(() =>
sourceTotalRows.value === null
? lastSourcePageSize.value === SOURCE_PAGE_SIZE
: loadedSourceRowCount.value < sourceTotalRows.value,
);
function normalizeSource(record: any, previous?: any) {
return {
...previous,
@@ -41,7 +68,7 @@ export function useDatacenterSources() {
}
}
function upsertSource(record?: any) {
function upsertSource(record?: any, select = true) {
if (!record?.id) return;
const previous = sources.value.find((item) => item.id === record.id);
const next = dedupeBuiltinSources(
@@ -52,7 +79,9 @@ export function useDatacenterSources() {
: [normalizeSource(record), ...sources.value],
);
sources.value = next;
selectedSourceId.value = record.id;
if (select) {
selectedSourceId.value = record.id;
}
}
function markSourceRuntimeUnavailable(sourceId?: null | number | string) {
@@ -74,62 +103,161 @@ export function useDatacenterSources() {
}
function removeSourceFromState(sourceId: DatacenterId) {
const existed = sources.value.some((item) => item.id === sourceId);
sources.value = sources.value.filter((item) => item.id !== sourceId);
if (existed) {
loadedSourceRowCount.value = Math.max(0, loadedSourceRowCount.value - 1);
if (sourceTotalRows.value !== null) {
sourceTotalRows.value = Math.max(0, sourceTotalRows.value - 1);
}
}
syncSelectedSourceAfterMutation();
}
function dedupeBuiltinSources(records: any[]) {
const builtinSourceTypes = new Set(['PROJECT_MYSQL']);
const seen = new Set<string>();
const seenBuiltin = new Set<string>();
const seenIds = new Set<string>();
return (records || []).filter((item) => {
const id = String(item?.id ?? '');
if (id && seenIds.has(id)) return false;
if (id) seenIds.add(id);
const uniqueKey = `${item.sourceType}:${item.sourceName}`;
if (!item.builtinFlag || !builtinSourceTypes.has(item.sourceType)) {
return true;
}
if (seen.has(uniqueKey)) {
if (seenBuiltin.has(uniqueKey)) {
return false;
}
seen.add(uniqueKey);
seenBuiltin.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;
const epoch = ++sourceLoadEpoch;
loading.value = true;
loadingMoreSources.value = false;
sourceLoadError.value = '';
loadMoreSourcesFailed.value = false;
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;
const res = await api.get('/api/v1/datacenterSource/page', {
params: { pageNumber: 1, pageSize: SOURCE_PAGE_SIZE },
});
if (epoch !== sourceLoadEpoch) return;
const records = res.data?.records || [];
const previousMap = new Map(
sources.value.map((item) => [String(item.id), item]),
);
sources.value = dedupeBuiltinSources(
records.map((record: any) =>
normalizeSource(record, previousMap.get(String(record.id))),
),
);
sourcePageNumber.value = 1;
loadedSourceRowCount.value = records.length;
lastSourcePageSize.value = records.length;
sourceTotalRows.value = Number.isFinite(Number(res.data?.totalRow))
? Number(res.data.totalRow)
: null;
sourcePaginationDirty = false;
syncSelectedSourceAfterMutation();
} catch (error) {
if (epoch === sourceLoadEpoch) {
sourceLoadError.value = '连接加载失败,请重试';
loadMoreSourcesFailed.value = false;
}
throw error;
} finally {
saving.value = false;
if (epoch === sourceLoadEpoch) loading.value = false;
}
}
async function loadMoreSources() {
if (loading.value || loadingMoreSources.value) {
return;
}
if (sourcePaginationDirty) {
const selectedId = selectedSourceId.value;
await loadSources();
if (sources.value.some((item) => item.id === selectedId)) {
selectedSourceId.value = selectedId;
}
return;
}
if (!hasMoreSources.value) return;
const epoch = sourceLoadEpoch;
const nextPage = sourcePageNumber.value + 1;
loadingMoreSources.value = true;
sourceLoadError.value = '';
loadMoreSourcesFailed.value = false;
try {
const res = await api.get('/api/v1/datacenterSource/page', {
params: { pageNumber: nextPage, pageSize: SOURCE_PAGE_SIZE },
});
if (epoch !== sourceLoadEpoch) return;
const records = res.data?.records || [];
const previousMap = new Map(
sources.value.map((item) => [String(item.id), item]),
);
sources.value = dedupeBuiltinSources([
...sources.value,
...records.map((record: any) =>
normalizeSource(record, previousMap.get(String(record.id))),
),
]);
sourcePageNumber.value = nextPage;
loadedSourceRowCount.value += records.length;
lastSourcePageSize.value = records.length;
sourceTotalRows.value = Number.isFinite(Number(res.data?.totalRow))
? Number(res.data.totalRow)
: sourceTotalRows.value;
} catch (error) {
if (epoch === sourceLoadEpoch) {
sourceLoadError.value = '更多连接加载失败,请重试';
loadMoreSourcesFailed.value = true;
}
throw error;
} finally {
if (epoch === sourceLoadEpoch) loadingMoreSources.value = false;
}
}
async function saveDraft(form: Record<string, any>) {
beginSaving();
try {
const payload = {
...form,
adapterOptions: form.adapterOptions || {},
configJson: mergeConfigJsonBySourceType(
form.sourceType,
form.configJson,
),
password: form.password || undefined,
};
const res = await api.post('/api/v1/datacenterSource/draft', payload);
if (!form.id) {
sourcePaginationDirty = true;
}
upsertSource(res.data);
return res.data;
} finally {
endSaving();
}
}
function candidatePayload(form: Record<string, any>) {
return {
...form,
adapterOptions: form.adapterOptions || {},
configJson: mergeConfigJsonBySourceType(form.sourceType, form.configJson),
password: form.password || undefined,
};
}
async function importExcelSource(file: File, sourceName: string) {
saving.value = true;
beginSaving();
try {
const formData = new FormData();
formData.append('file', file);
@@ -141,26 +269,72 @@ export function useDatacenterSources() {
timeout: 10 * 60 * 1000,
},
);
selectedSourceId.value = res.data?.sourceId || null;
const sourceId = res.data?.sourceId;
if (sourceId) {
const resolvedSourceName =
sourceName.trim() || file.name.replace(/\.[^.]+$/, '');
sourcePaginationDirty = true;
upsertSource({
accessMode: 'READ_WRITE',
builtinFlag: false,
id: sourceId,
sourceName: resolvedSourceName,
sourceType: 'EXCEL',
status: 'DRAFT',
});
}
return res.data;
} finally {
saving.value = false;
endSaving();
}
}
async function testConnection(target: Record<string, any>) {
async function probeSource(sourceId: DatacenterId) {
testing.value = true;
try {
const res = await api.post(`/api/v1/datacenterSource/${sourceId}/probe`);
return res.data;
} finally {
testing.value = false;
}
}
async function loadBindableCatalogs(sourceId: DatacenterId) {
const res = await api.get('/api/v1/datacenterSource/catalogs/page', {
params: { sourceId, pageNumber: 1, pageSize: 200 },
});
return res.data?.records || [];
}
async function loadBindableTables(
sourceId: DatacenterId,
catalogName: string,
keyword = '',
pageNumber = 1,
pageSize = 100,
) {
const res = await api.get('/api/v1/datacenterSource/tables', {
params: { sourceId, catalogName, keyword, pageNumber, pageSize },
});
return res.data || { records: [], pageNumber, pageSize, hasMore: false };
}
async function loadManagedScopeTables(
sourceId: DatacenterId,
catalogId?: DatacenterId,
) {
const res = await api.get('/api/v1/datacenterDataset/managedTables', {
params: { sourceId, catalogId },
});
return res.data || [];
}
async function probeCandidate(form: 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,
'/api/v1/datacenterSource/candidate/probe',
candidatePayload(form),
);
return res.data;
} finally {
@@ -168,13 +342,76 @@ export function useDatacenterSources() {
}
}
async function loadCandidateCatalogs(form: Record<string, any>) {
const res = await api.post(
'/api/v1/datacenterSource/candidate/catalogs/page',
{
definition: candidatePayload(form),
pageNumber: 1,
pageSize: 200,
},
);
return res.data?.records || [];
}
async function loadCandidateTables(
form: Record<string, any>,
catalogName: string,
keyword = '',
pageNumber = 1,
pageSize = 100,
) {
const res = await api.post('/api/v1/datacenterSource/candidate/tables', {
definition: candidatePayload(form),
catalogName,
keyword,
pageNumber,
pageSize,
});
return res.data || { records: [], pageNumber, pageSize, hasMore: false };
}
async function reconfigureSource(payload: {
catalogName: string;
definition: Record<string, any>;
expectedDefinitionRevision: number;
expectedScopeRevision: number;
prewarm: boolean;
tableNames: string[];
}) {
beginSaving();
try {
const res = await api.post('/api/v1/datacenterSource/reconfigure', {
...payload,
definition: candidatePayload(payload.definition),
});
upsertSource(res.data);
return res.data;
} finally {
endSaving();
}
}
async function activateSource(payload: {
catalogName: string;
prewarm: boolean;
sourceId: DatacenterId;
tableNames: string[];
}) {
beginSaving();
try {
const res = await api.post('/api/v1/datacenterSource/activate', payload);
upsertSource(res.data);
return res.data;
} finally {
endSaving();
}
}
async function quickTest(row: any) {
testing.value = true;
try {
const res = await api.post(
'/api/v1/datacenterSource/testConnection',
row,
);
const res = await api.post(`/api/v1/datacenterSource/${row.id}/probe`);
clearSourceRuntimeUnavailable(row?.id);
await loadSources();
return res.data;
@@ -189,23 +426,54 @@ export function useDatacenterSources() {
async function removeSource(sourceId: DatacenterId) {
await api.post('/api/v1/datacenterSource/remove', { sourceId });
removeSourceFromState(sourceId);
sourcePaginationDirty = true;
}
async function changeSourceState(
sourceId: DatacenterId,
action: 'disable' | 'enable' | 'metadata/refresh',
) {
beginSaving();
try {
const res = await api.post(
`/api/v1/datacenterSource/${sourceId}/${action}`,
);
upsertSource(res.data, false);
return res.data;
} finally {
endSaving();
}
}
return {
sources,
selectedSourceId,
selectedSource,
hasMoreSources,
loading,
loadingMoreSources,
loadMoreSourcesFailed,
saving,
sourceLoadError,
testing,
importExcelSource,
loadSources,
loadMoreSources,
removeSource,
removeSourceFromState,
saveSource,
saveDraft,
syncSelectedSourceAfterMutation,
testConnection,
activateSource,
changeSourceState,
loadCandidateCatalogs,
loadCandidateTables,
loadBindableCatalogs,
loadManagedScopeTables,
loadBindableTables,
probeCandidate,
probeSource,
quickTest,
reconfigureSource,
upsertSource,
markSourceRuntimeUnavailable,
clearSourceRuntimeUnavailable,

View File

@@ -0,0 +1,128 @@
import type { DatacenterId } from './datacenter-id';
import type { DatacenterSqlConsoleResult } from './datacenter-types';
import { ref } from 'vue';
import { requestClient } from '#/api/request';
/**
* 数据中枢只读 SQL 控制台状态。
*/
export function useDatacenterSqlConsole() {
const executing = ref(false);
const cancelling = ref(false);
const activeQueryId = ref<null | string>(null);
const errorMessage = ref('');
const result = ref<DatacenterSqlConsoleResult | null>(null);
let requestEpoch = 0;
const cancellationTasks = new Map<string, Promise<boolean>>();
function createQueryId() {
const nativeRandomUuid = globalThis.crypto?.randomUUID?.bind(
globalThis.crypto,
);
if (nativeRandomUuid) return nativeRandomUuid();
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
bytes[6] = ((bytes[6] ?? 0) & 15) | 64;
bytes[8] = ((bytes[8] ?? 0) & 63) | 128;
const hex = [...bytes].map((value) => value.toString(16).padStart(2, '0'));
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
}
async function executeSql(
sourceId: DatacenterId,
sql: string,
maxRows: number,
) {
const epoch = ++requestEpoch;
const queryId = createQueryId();
executing.value = true;
activeQueryId.value = queryId;
errorMessage.value = '';
result.value = null;
try {
const response = await requestClient.post<DatacenterSqlConsoleResult>(
'/api/v1/datacenterQuery/execute',
{ sourceId, queryId, sql, maxRows },
);
if (epoch === requestEpoch) {
result.value = response;
}
return response;
} catch (error) {
if (epoch === requestEpoch) {
errorMessage.value =
error instanceof Error && error.message
? error.message
: '查询失败,请检查 SQL 或数据范围后重试';
}
return null;
} finally {
if (activeQueryId.value === queryId) {
executing.value = false;
activeQueryId.value = null;
}
}
}
async function requestCancellation(queryId: string) {
const pendingTask = cancellationTasks.get(queryId);
if (pendingTask) return pendingTask;
cancelling.value = true;
const task = requestClient
.post<boolean>('/api/v1/datacenterQuery/cancel', { queryId })
.catch(() => false)
.finally(() => {
cancellationTasks.delete(queryId);
cancelling.value = cancellationTasks.size > 0;
});
cancellationTasks.set(queryId, task);
return task;
}
async function cancelQuery() {
const queryId = activeQueryId.value;
if (!queryId) return false;
const cancelled = await requestCancellation(queryId);
if (!cancelled && activeQueryId.value === queryId) {
errorMessage.value = '查询取消失败,请稍后重试';
}
return cancelled;
}
async function clearResult() {
const queryId = activeQueryId.value;
requestEpoch += 1;
errorMessage.value = '';
result.value = null;
if (!queryId) {
executing.value = false;
cancelling.value = false;
activeQueryId.value = null;
return;
}
// 切源或卸载控制台时先取消旧查询,避免丢失 queryId 后产生孤儿请求。
let cancellationCompleted = await requestCancellation(queryId);
if (!cancellationCompleted && activeQueryId.value === queryId) {
// 手动取消可能恰好与生命周期清理并发;失败时补偿一次,避免旧查询成为孤儿。
cancellationCompleted = await requestCancellation(queryId);
}
if (cancellationCompleted && activeQueryId.value === queryId) {
activeQueryId.value = null;
executing.value = false;
} else if (activeQueryId.value === queryId) {
errorMessage.value = '查询取消失败,请重试取消后再执行新查询';
}
}
return {
cancelQuery,
cancelling,
clearResult,
errorMessage,
executeSql,
executing,
result,
};
}

View File

@@ -17,6 +17,8 @@ export function useDatacenterTables(
selectedSourceId: Ref<DatacenterId | null>,
options: DatacenterTableRuntimeOptions = {},
) {
const FIELD_PAGE_SIZE = 50;
const SOURCE_TABLE_PAGE_SIZE = 100;
const SOURCE_MISSING_MESSAGE = '连接不存在';
const SOURCE_UNAVAILABLE_MESSAGE = '当前连接不可用,请检查连接配置后重试';
const catalogs = ref<any[]>([]);
@@ -26,9 +28,26 @@ export function useDatacenterTables(
const previewRows = ref<any[]>([]);
const jobs = ref<any[]>([]);
const sourceUnavailable = ref(false);
const sourceTablePageNumber = ref(1);
const sourceTableHasMore = ref(false);
const sourceTableKeyword = ref('');
const sourceTableLoadingMore = ref(false);
const tableRuntimeError = ref('');
const fieldPageNumber = ref(1);
const fieldHasMore = ref(false);
const fieldLoading = ref(false);
const tableMutationKind = ref<'register' | 'remove' | null>(null);
const selectedCatalogId = ref<DatacenterId | null>(null);
const selectedTableId = ref<DatacenterId | null>(null);
const previewLoading = ref(false);
const contextLoading = ref(false);
const contextError = ref('');
let sourceSyncEpoch = 0;
let catalogLoadEpoch = 0;
let sourceTableLoadEpoch = 0;
let managedTableLoadEpoch = 0;
let previewLoadEpoch = 0;
let fieldLoadEpoch = 0;
const selectedCatalog = computed(
() =>
@@ -54,21 +73,61 @@ export function useDatacenterTables(
return message.includes(SOURCE_MISSING_MESSAGE);
}
function markSourceUnavailable() {
options.markSourceRuntimeUnavailable?.(selectedSourceId.value);
function markSourceUnavailable(sourceId: DatacenterId | null) {
if (selectedSourceId.value !== sourceId) return;
catalogLoadEpoch += 1;
sourceTableLoadEpoch += 1;
managedTableLoadEpoch += 1;
previewLoadEpoch += 1;
fieldLoadEpoch += 1;
options.markSourceRuntimeUnavailable?.(sourceId);
catalogs.value = [];
sourceTables.value = [];
sourceTablePageNumber.value = 1;
sourceTableHasMore.value = false;
sourceTableLoadingMore.value = false;
schema.value = null;
previewRows.value = [];
jobs.value = [];
selectedCatalogId.value = null;
sourceUnavailable.value = true;
tableRuntimeError.value = '';
fieldPageNumber.value = 1;
fieldHasMore.value = false;
fieldLoading.value = false;
}
function resetSourceContext() {
// 立即清空上一连接的快照,避免新连接标题与旧表数据短暂组合展示。
catalogLoadEpoch += 1;
sourceTableLoadEpoch += 1;
managedTableLoadEpoch += 1;
previewLoadEpoch += 1;
fieldLoadEpoch += 1;
catalogs.value = [];
sourceTables.value = [];
sourceTablePageNumber.value = 1;
sourceTableHasMore.value = false;
sourceTableKeyword.value = '';
sourceTableLoadingMore.value = false;
managedTables.value = [];
schema.value = null;
previewRows.value = [];
jobs.value = [];
selectedCatalogId.value = null;
selectedTableId.value = null;
sourceUnavailable.value = true;
sourceUnavailable.value = false;
previewLoading.value = false;
tableRuntimeError.value = '';
fieldPageNumber.value = 1;
fieldHasMore.value = false;
fieldLoading.value = false;
}
async function loadCatalogs() {
if (selectedSourceId.value === null) {
const sourceId = selectedSourceId.value;
const loadEpoch = ++catalogLoadEpoch;
if (sourceId === null) {
catalogs.value = [];
selectedCatalogId.value = null;
sourceUnavailable.value = false;
@@ -76,13 +135,19 @@ export function useDatacenterTables(
}
try {
const data = await requestClient.get(
'/api/v1/datacenterSource/catalogs',
'/api/v1/datacenterSource/catalogs/page',
{
params: { sourceId: selectedSourceId.value },
params: { sourceId, pageNumber: 1, pageSize: 200 },
},
);
options.clearSourceRuntimeUnavailable?.(selectedSourceId.value);
catalogs.value = data || [];
if (
loadEpoch !== catalogLoadEpoch ||
selectedSourceId.value !== sourceId
) {
return false;
}
options.clearSourceRuntimeUnavailable?.(sourceId);
catalogs.value = data?.records || [];
sourceUnavailable.value = false;
if (catalogs.value.length === 0) {
selectedCatalogId.value = null;
@@ -96,43 +161,126 @@ export function useDatacenterTables(
}
return true;
} catch (error) {
if (
loadEpoch !== catalogLoadEpoch ||
selectedSourceId.value !== sourceId
) {
return false;
}
if (isSourceMissingError(error)) {
markSourceUnavailable();
markSourceUnavailable(sourceId);
return false;
}
if (!isSourceUnavailableError(error)) {
throw error;
}
markSourceUnavailable();
markSourceUnavailable(sourceId);
return false;
}
}
async function loadSourceTables() {
if (selectedSourceId.value === null) {
async function loadSourceTables(
requestOptions: {
append?: boolean;
keyword?: string;
} = {},
) {
const sourceId = selectedSourceId.value;
const catalogId = selectedCatalogId.value;
const catalogName = selectedCatalog.value?.catalogName;
const append = Boolean(requestOptions.append);
if (requestOptions.keyword !== undefined) {
sourceTableKeyword.value = requestOptions.keyword.trim();
}
if (append && !sourceTableHasMore.value) return;
const keyword = sourceTableKeyword.value;
const pageNumber = append ? sourceTablePageNumber.value + 1 : 1;
const loadEpoch = ++sourceTableLoadEpoch;
if (sourceId === null) {
sourceTables.value = [];
sourceTablePageNumber.value = 1;
sourceTableHasMore.value = false;
return;
}
if (append) sourceTableLoadingMore.value = true;
try {
const data = await requestClient.get('/api/v1/datacenterSource/tables', {
params: {
sourceId: selectedSourceId.value,
catalogName: selectedCatalog.value?.catalogName,
sourceId,
catalogName,
keyword: keyword || undefined,
pageNumber,
pageSize: SOURCE_TABLE_PAGE_SIZE,
},
});
options.clearSourceRuntimeUnavailable?.(selectedSourceId.value);
sourceTables.value = data || [];
if (
loadEpoch !== sourceTableLoadEpoch ||
selectedSourceId.value !== sourceId ||
selectedCatalogId.value !== catalogId
) {
return;
}
options.clearSourceRuntimeUnavailable?.(sourceId);
const records = data?.records || [];
if (append) {
const existingNames = new Set(
sourceTables.value.map((item) => String(item.tableName || '')),
);
sourceTables.value = [
...sourceTables.value,
...records.filter(
(item: any) => !existingNames.has(String(item.tableName || '')),
),
];
} else {
sourceTables.value = records;
}
sourceTablePageNumber.value = Number(data?.pageNumber || pageNumber);
sourceTableHasMore.value = Boolean(data?.hasMore);
sourceUnavailable.value = false;
} catch (error) {
if (
loadEpoch !== sourceTableLoadEpoch ||
selectedSourceId.value !== sourceId ||
selectedCatalogId.value !== catalogId
) {
return;
}
if (!isSourceUnavailableError(error) && !isSourceMissingError(error)) {
throw error;
}
markSourceUnavailable();
markSourceUnavailable(sourceId);
} finally {
if (loadEpoch === sourceTableLoadEpoch) {
sourceTableLoadingMore.value = false;
}
}
}
async function searchSourceTables(keyword: string) {
contextError.value = '';
try {
await loadSourceTables({ keyword });
} catch (error) {
contextError.value = '数据表搜索失败,请重试';
console.error('数据表搜索失败', error);
}
}
async function loadMoreSourceTables() {
try {
await loadSourceTables({ append: true });
} catch (error) {
ElMessage.error('更多数据表加载失败');
console.error('更多数据表加载失败', error);
}
}
async function loadManagedTables() {
if (selectedSourceId.value === null) {
const sourceId = selectedSourceId.value;
const catalogId = selectedCatalogId.value;
const loadEpoch = ++managedTableLoadEpoch;
if (sourceId === null) {
managedTables.value = [];
selectedTableId.value = null;
return;
@@ -142,17 +290,31 @@ export function useDatacenterTables(
'/api/v1/datacenterDataset/managedTables',
{
params: {
sourceId: selectedSourceId.value,
catalogId: selectedCatalogId.value,
sourceId,
catalogId,
},
},
);
if (
loadEpoch !== managedTableLoadEpoch ||
selectedSourceId.value !== sourceId ||
selectedCatalogId.value !== catalogId
) {
return;
}
managedTables.value = data || [];
} catch (error) {
if (
loadEpoch !== managedTableLoadEpoch ||
selectedSourceId.value !== sourceId ||
selectedCatalogId.value !== catalogId
) {
return;
}
if (!isSourceUnavailableError(error) && !isSourceMissingError(error)) {
throw error;
}
markSourceUnavailable();
markSourceUnavailable(sourceId);
return;
}
if (managedTables.value.length === 0) {
@@ -168,37 +330,65 @@ export function useDatacenterTables(
}
async function loadTableRuntime() {
if (selectedTableId.value === null) {
const sourceId = selectedSourceId.value;
const tableId = selectedTableId.value;
const loadEpoch = ++previewLoadEpoch;
// 切表会使所有旧字段分页响应失效,包括 A→B→A 的同 ID 回切场景。
fieldLoadEpoch += 1;
fieldLoading.value = false;
if (tableId === null) {
schema.value = null;
previewRows.value = [];
jobs.value = [];
previewLoading.value = false;
tableRuntimeError.value = '';
fieldPageNumber.value = 1;
fieldHasMore.value = false;
return;
}
// 新表请求开始时不保留上一张表的 schema、预览和任务信息。
schema.value = null;
previewRows.value = [];
jobs.value = [];
previewLoading.value = true;
tableRuntimeError.value = '';
fieldPageNumber.value = 1;
fieldHasMore.value = false;
try {
const [schemaRes, previewRes, jobsRes] = await Promise.allSettled([
requestClient.get('/api/v1/datacenterDataset/schema', {
params: { tableId: selectedTableId.value },
params: {
tableId,
fieldPageNumber: 1,
fieldPageSize: FIELD_PAGE_SIZE,
},
}),
requestClient.post('/api/v1/datacenterDataset/queryPage', {
datasetRef: { tableId: selectedTableId.value },
datasetRef: { tableId },
pageNumber: 1,
pageSize: 10,
}),
requestClient.get('/api/v1/datacenterExcel/job/list', {
params: {
sourceId: selectedSourceId.value,
tableId: selectedTableId.value,
sourceId,
tableId,
},
}),
]);
if (
loadEpoch !== previewLoadEpoch ||
selectedSourceId.value !== sourceId ||
selectedTableId.value !== tableId
) {
return;
}
if (schemaRes.status === 'fulfilled') {
schema.value = schemaRes.value;
applySchemaPage(schemaRes.value);
} else if (
isSourceUnavailableError(schemaRes.reason) ||
isSourceMissingError(schemaRes.reason)
) {
markSourceUnavailable();
markSourceUnavailable(sourceId);
return;
} else {
throw schemaRes.reason;
@@ -209,92 +399,181 @@ export function useDatacenterTables(
isSourceUnavailableError(jobsRes.reason) ||
isSourceMissingError(jobsRes.reason)
) {
markSourceUnavailable();
markSourceUnavailable(sourceId);
return;
} else {
throw jobsRes.reason;
}
if (previewRes.status === 'fulfilled') {
options.clearSourceRuntimeUnavailable?.(selectedSourceId.value);
options.clearSourceRuntimeUnavailable?.(sourceId);
previewRows.value = previewRes.value?.records || [];
sourceUnavailable.value = false;
} else if (
isSourceUnavailableError(previewRes.reason) ||
isSourceMissingError(previewRes.reason)
) {
markSourceUnavailable();
markSourceUnavailable(sourceId);
} else {
throw previewRes.reason;
}
} catch (error) {
if (
loadEpoch === previewLoadEpoch &&
selectedSourceId.value === sourceId &&
selectedTableId.value === tableId &&
!sourceUnavailable.value
) {
tableRuntimeError.value = '表详情加载失败,请重试';
console.error('表详情加载失败', error);
}
} finally {
previewLoading.value = false;
if (loadEpoch === previewLoadEpoch) previewLoading.value = false;
}
}
function applySchemaPage(data: any) {
schema.value = data || null;
fieldPageNumber.value = Number(data?.fieldPageNumber || 1);
fieldHasMore.value = Boolean(data?.hasMoreFields);
}
async function loadFieldPage(pageNumber: number) {
const sourceId = selectedSourceId.value;
const tableId = selectedTableId.value;
const requestedPage = Math.max(1, pageNumber);
if (tableId === null || requestedPage === fieldPageNumber.value) return;
const loadEpoch = ++fieldLoadEpoch;
fieldLoading.value = true;
tableRuntimeError.value = '';
try {
const data = await requestClient.get('/api/v1/datacenterDataset/schema', {
params: {
tableId,
fieldPageNumber: requestedPage,
fieldPageSize: FIELD_PAGE_SIZE,
},
});
if (
loadEpoch !== fieldLoadEpoch ||
selectedSourceId.value !== sourceId ||
selectedTableId.value !== tableId
) {
return;
}
applySchemaPage(data);
} catch (error) {
if (
loadEpoch === fieldLoadEpoch &&
selectedSourceId.value === sourceId &&
selectedTableId.value === tableId
) {
if (isSourceUnavailableError(error) || isSourceMissingError(error)) {
markSourceUnavailable(sourceId);
return;
}
tableRuntimeError.value = '字段加载失败,请重试';
console.error('字段加载失败', error);
}
} finally {
if (loadEpoch === fieldLoadEpoch) fieldLoading.value = false;
}
}
async function syncSourceContext() {
const catalogAvailable = await loadCatalogs();
if (catalogAvailable) {
await Promise.all([loadSourceTables(), loadManagedTables()]);
} else {
sourceTables.value = [];
await loadManagedTables();
const sourceId = selectedSourceId.value;
const syncEpoch = ++sourceSyncEpoch;
resetSourceContext();
contextLoading.value = true;
contextError.value = '';
try {
const catalogAvailable = await loadCatalogs();
if (
syncEpoch !== sourceSyncEpoch ||
selectedSourceId.value !== sourceId
) {
return;
}
if (catalogAvailable) {
await Promise.all([loadSourceTables(), loadManagedTables()]);
} else {
sourceTables.value = [];
await loadManagedTables();
}
} catch (error) {
if (
syncEpoch === sourceSyncEpoch &&
selectedSourceId.value === sourceId
) {
contextError.value = '数据表加载失败,请重试';
}
throw error;
} finally {
if (syncEpoch === sourceSyncEpoch) {
contextLoading.value = false;
}
}
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 retrySourceContext() {
if (sourceTableKeyword.value) {
await searchSourceTables(sourceTableKeyword.value);
return;
}
await syncSourceContext();
}
async function batchRegisterTables(rows: any[]) {
if (tableMutationKind.value !== null) return;
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();
tableMutationKind.value = 'register';
try {
await requestClient.post('/api/v1/datacenterSource/registerBatch', {
sourceId: selectedSourceId.value,
catalogName: selectedCatalog.value?.catalogName,
tableNames,
});
ElMessage.success(`已接入 ${tableNames.length} 张表`);
await loadManagedTables();
} finally {
tableMutationKind.value = null;
}
}
async function batchRemoveTables(rows: any[]) {
if (tableMutationKind.value !== null) return;
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 !== null &&
tableIds.includes(selectedTableId.value)
) {
selectedTableId.value = null;
schema.value = null;
previewRows.value = [];
jobs.value = [];
tableMutationKind.value = 'remove';
try {
await requestClient.post('/api/v1/datacenterDataset/removeBatch', {
tableIds,
});
ElMessage.success(`已去除 ${tableIds.length} 张表`);
if (
selectedTableId.value !== null &&
tableIds.includes(selectedTableId.value)
) {
selectedTableId.value = null;
schema.value = null;
previewRows.value = [];
jobs.value = [];
}
await Promise.all([loadSourceTables(), loadManagedTables()]);
} finally {
tableMutationKind.value = null;
}
await Promise.all([loadSourceTables(), loadManagedTables()]);
}
async function saveDescriptions(payload: {
fields?: Array<{ fieldDesc: string; fieldId: number | string }>;
fields?: Array<{
fieldDesc: string;
fieldId: number | string;
queryable?: number;
sensitivityLevel?: string;
}>;
tableDesc?: string;
tableId: number | string;
}) {
@@ -302,13 +581,15 @@ export function useDatacenterTables(
'/api/v1/datacenterDataset/saveDescriptions',
{
fields: payload.fields || [],
fieldPageNumber: fieldPageNumber.value,
fieldPageSize: FIELD_PAGE_SIZE,
tableDesc: payload.tableDesc ?? '',
tableId: payload.tableId,
},
);
await loadManagedTables();
if (selectedTableId.value === payload.tableId) {
schema.value = data || null;
applySchemaPage(data);
}
return data;
}
@@ -316,22 +597,35 @@ export function useDatacenterTables(
return {
catalogs,
sourceTables,
sourceTableHasMore,
sourceTableKeyword,
sourceTableLoadingMore,
managedTables,
schema,
previewRows,
jobs,
contextError,
contextLoading,
sourceUnavailable,
tableRuntimeError,
selectedCatalogId,
selectedTableId,
selectedCatalog,
selectedTable,
previewLoading,
fieldPageNumber,
fieldHasMore,
fieldLoading,
tableMutationKind,
loadCatalogs,
loadSourceTables,
searchSourceTables,
loadMoreSourceTables,
loadManagedTables,
loadTableRuntime,
loadFieldPage,
syncSourceContext,
registerTable,
retrySourceContext,
batchRegisterTables,
batchRemoveTables,
saveDescriptions,

View File

@@ -11,12 +11,13 @@ import {
export function useSourceForm() {
const lastGeneratedJdbcUrl = ref('');
const lastGeneratedDriverClassName = ref('');
let resettingForm = false;
const form = reactive<Record<string, any>>({
id: undefined,
sourceName: '',
sourceCode: '',
sourceType: 'EXCEL',
sourceType: 'MYSQL',
accessMode: 'READ_ONLY',
driverClassName: '',
jdbcUrl: '',
@@ -27,6 +28,10 @@ export function useSourceForm() {
username: '',
password: '',
builtinFlag: 0,
passwordConfigured: false,
status: 'DRAFT',
definitionRevision: 0,
scopeRevision: 1,
configJson: {},
});
@@ -58,6 +63,24 @@ export function useSourceForm() {
},
},
],
host: [{ required: true, message: '请输入主机地址', trigger: 'blur' }],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{
trigger: 'blur',
validator: (_rule, value, callback) => {
if (form.id && form.passwordConfigured) {
callback();
return;
}
if (String(value || '').trim()) {
callback();
return;
}
callback(new Error('请输入密码'));
},
},
],
};
const selectedTypeDefaults = computed(
@@ -140,32 +163,44 @@ export function useSourceForm() {
}
function resetForm(row?: any) {
form.id = row?.id;
form.sourceName = row?.sourceName || '';
form.sourceCode = row?.sourceCode || '';
form.sourceType = row?.sourceType || 'EXCEL';
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 = '';
resettingForm = true;
try {
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.passwordConfigured = Boolean(row?.passwordConfigured);
form.status = row?.status || 'DRAFT';
form.definitionRevision = Number(row?.definitionRevision || 0);
form.scopeRevision = Number(row?.scopeRevision || 1);
form.configJson = mergeConfigJsonBySourceType(
form.sourceType,
row?.configJson || {},
);
lastGeneratedDriverClassName.value = '';
lastGeneratedJdbcUrl.value = '';
} finally {
resettingForm = false;
}
applySourceTypeDefaults(false);
}
watch(
() => form.sourceType,
() => applySourceTypeDefaults(true),
() => {
if (!resettingForm) applySourceTypeDefaults(true);
},
{ flush: 'sync' },
);
watch(