feat: 完善数据中枢联邦查询闭环
- 重构数据源生命周期、元数据纳管与运行时切换 - 增加只读 SQL、查询审计、跨节点取消与工作流联动 - 完善管理端连接配置、元数据浏览与 SQL 工作台
This commit is contained in:
@@ -90,6 +90,7 @@ function createSourceOnlyDatasetRef(
|
||||
source: ManagedDatasetSourceOption,
|
||||
): DatasetRefPayload {
|
||||
return {
|
||||
tenantId: source.tables[0]?.datasetRef.tenantId ?? null,
|
||||
sourceId: source.sourceId,
|
||||
catalogId: null,
|
||||
catalogName: '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user