fix(X-Pack): 修复同步数据源插件校验与新建流程

This commit is contained in:
jianneng-fit2cloud
2026-08-23 21:20:20 +08:00
parent 968da8ec0c
commit c3f44ad20f
9 changed files with 266 additions and 123 deletions

View File

@@ -4102,6 +4102,9 @@ export default {
please_input_fe_port: 'Please enter FE HTTP port',
modify: 'Edit Datasource',
validate_failed: 'Validation failed',
plugin_loading: 'The sync datasource plugin is loading. Please try again later',
plugin_load_failed:
'Failed to load the sync datasource plugin. Check the plugin installation and try again',
oracle_connection_type: 'Service name/SID',
oracle_sid: 'SID',
oracle_service_name: 'Service name',

View File

@@ -3967,6 +3967,8 @@ export default {
please_input_fe_port: '請輸入FE HTTP埠',
modify: '編輯資料來源',
validate_failed: '校驗失敗',
plugin_loading: '同步資料來源外掛正在載入,請稍後重試',
plugin_load_failed: '同步資料來源外掛載入失敗,請檢查外掛安裝狀態後重試',
oracle_connection_type: '服務名/SID',
oracle_sid: 'SID',
oracle_service_name: '服務名',

View File

@@ -3988,6 +3988,8 @@ export default {
please_input_fe_port: '请输入FE HTTP端口',
modify: '编辑数据源',
validate_failed: '校验失败',
plugin_loading: '同步数据源插件正在加载,请稍后重试',
plugin_load_failed: '同步数据源插件加载失败,请检查插件安装状态后重试',
oracle_connection_type: '服务名/SID',
oracle_sid: 'SID',
oracle_service_name: '服务名',

View File

@@ -192,10 +192,14 @@ const search = () => {
};
const drawerMainOpen = async () => {
filterOption[0].option = dsTypes.map((item) => ({
id: item.type,
name: item.name,
}));
await listSyncPlugin();
const datasourceRole = activeSource.value ? 1 : 2
const typeOptions = [...dsTypes, ...pluginDs.value]
.filter((item) => Number(item.datasourceRole) === datasourceRole)
.map((item) => [item.type, {id: item.type, name: item.name}] as const)
filterOption[0].option = Array.from(
new Map<string, {id: string; name: string}>(typeOptions).values()
);
drawerMainRef.value.init();
};
const drawerMainClose = () => {
@@ -207,7 +211,7 @@ const drawerMainClose = () => {
const findSyncPlugin = (data) => {
return pluginDs.value.find(
(ele) =>
ele.type === data.type &&
ele.type?.toLowerCase() === data.type?.toLowerCase() &&
Number(ele.datasourceRole) === Number(data.datasourceRole)
);
};
@@ -246,6 +250,9 @@ const getDsInfo = (data) => {
});
};
const edit = async (row) => {
// 插件列表和数据源详情是两个独立请求;必须先拿到插件元数据,
// 否则快速点击编辑会把 PostgreSQL 误判为内置数据源并打开错误表单。
await listSyncPlugin();
await getDsInfo(row);
// 当前组件只展示源数据库,编辑器的第二个参数必须保持为 true,
// 否则编辑器会按目标数据库初始化并使用错误的校验/保存接口。
@@ -307,16 +314,25 @@ const typeMap = dsTypes.reduce((pre, next) => {
return pre
}, {})
const pluginDs = ref([])
let syncPluginLoadPromise: Promise<void> | null = null
const listSyncPlugin = () => {
loadSyncPlugin().then(res=>{
pluginDs.value = res.data
pluginDs.value.forEach(ele => {
typeMap[ele.type] = ele.name
})
})
if (!syncPluginLoadPromise) {
syncPluginLoadPromise = loadSyncPlugin()
.then(res=>{
pluginDs.value = res.data || []
pluginDs.value.forEach(ele => {
typeMap[ele.type] = ele.name
})
})
.catch(error => {
syncPluginLoadPromise = null
throw error
})
}
return syncPluginLoadPromise
}
listSyncPlugin()
void listSyncPlugin().catch(() => undefined)
</script>

View File

@@ -195,10 +195,14 @@ const search = () => {
};
const drawerMainOpen = async () => {
filterOption[0].option = dsTypes.map((item) => ({
id: item.type,
name: item.name,
}));
await listSyncPlugin();
const datasourceRole = activeSource.value ? 1 : 2
const typeOptions = [...dsTypes, ...pluginDs.value]
.filter((item) => Number(item.datasourceRole) === datasourceRole)
.map((item) => [item.type, {id: item.type, name: item.name}] as const)
filterOption[0].option = Array.from(
new Map<string, {id: string; name: string}>(typeOptions).values()
);
drawerMainRef.value.init();
};
const drawerMainClose = () => {
@@ -208,7 +212,7 @@ const pluginDs = ref([])
// 同步插件由类型和数据源角色共同确定
const findSyncPlugin = data => {
return pluginDs.value.find(ele =>
ele.type === data.type &&
ele.type?.toLowerCase() === data.type?.toLowerCase() &&
Number(ele.datasourceRole) === Number(data.datasourceRole)
)
}
@@ -247,6 +251,8 @@ const getDsInfo = (data) => {
});
};
const edit = async (row) => {
// 等待插件元数据后再判断表单类型,避免 PostgreSQL 被误判为内置目标数据源。
await listSyncPlugin();
await getDsInfo(row);
datasourceEditor.value.init(dsInfo, false);
};
@@ -304,6 +310,7 @@ const timestampFormatDate = (value) => {
const syncFormDialogRef = ref();
const showSyncFormDialog = async (row) => {
syncFormDialogRef.value.syncTargetToDatasourceFormVisible = true;
await listSyncPlugin();
await getDsInfo(row);
syncFormDialogRef.value.init(dsInfo);
};
@@ -315,15 +322,24 @@ const typeMap = dsTypes.reduce((pre, next) => {
pre[next.type] = next.name
return pre
}, {})
let syncPluginLoadPromise: Promise<void> | null = null
const listSyncPlugin = () => {
loadSyncPlugin().then(res => {
pluginDs.value = res.data
pluginDs.value.forEach(ele => {
typeMap[ele.type] = ele.name
})
})
if (!syncPluginLoadPromise) {
syncPluginLoadPromise = loadSyncPlugin()
.then(res => {
pluginDs.value = res.data || []
pluginDs.value.forEach(ele => {
typeMap[ele.type] = ele.name
})
})
.catch(error => {
syncPluginLoadPromise = null
throw error
})
}
return syncPluginLoadPromise
}
listSyncPlugin()
void listSyncPlugin().catch(() => undefined)
</script>
<template>

View File

@@ -26,11 +26,13 @@ const props = withDefaults(
defineProps<{
currentType: string | PropType<DsType>;
filterText: string;
latestUseTypes: [];
latestUseTypes: string[];
datasourceRole: number;
}>(),
{
currentType: "OLAP",
filterText: "",
datasourceRole: 1,
}
);
@@ -54,51 +56,52 @@ const iconMap = {
};
const databaseList = shallowRef();
const isCurrentRoleDatasource = (datasource) => {
return !datasource.isPlugin ||
Number(datasource.datasourceRole) === Number(props.datasourceRole)
}
const filterByName = (datasource) => {
return datasource.name.toLowerCase().includes(props.filterText.trim())
}
const currentTypeList = computed(() => {
if (props.currentType == "all") {
return typeList.map((ele, index) => {
return {
name: nameMap[ele],
dbList: databaseList.value[index].filter((ele) =>
ele.name.toLowerCase().includes(props.filterText.trim())
),
dbList: databaseList.value[index]
.filter(isCurrentRoleDatasource)
.filter(filterByName),
};
});
}
if (props.currentType === "latestUse") {
let catalogList = [] as string[];
let dstypes = [] as any[];
props.latestUseTypes.forEach((type) => {
dsTypes.forEach((item) => {
if (item.type === type && catalogList.indexOf(item.catalog) === -1) {
catalogList.push(item.catalog);
}
});
});
let dbList = [] as any[];
catalogList.forEach((catalog) => {
props.latestUseTypes.forEach((type) => {
dsTypes.forEach((item) => {
if (item.type === type && item.catalog === catalog) {
dbList.push(item);
const latestTypeSet = new Set(props.latestUseTypes)
const latestDatasourceMap = new Map()
databaseList.value
.flat()
.filter(isCurrentRoleDatasource)
.filter((item) => latestTypeSet.has(item.type))
.filter(filterByName)
.forEach((item) => {
const current = latestDatasourceMap.get(item.type)
// 同 type 同时存在内置定义和插件定义时优先插件,确保 PostgreSQL 打开动态表单。
if (!current || item.isPlugin) {
latestDatasourceMap.set(item.type, item)
}
});
});
});
dbList = dbList.filter((ele) =>
ele.name.toLowerCase().includes(props.filterText.trim())
);
dstypes.push({name: t("sync_datasource.recently_created"), dbList});
return dstypes;
})
return [{
name: t("sync_datasource.recently_created"),
dbList: Array.from(latestDatasourceMap.values())
}]
}
const index = typeList.findIndex((ele) => props.currentType === ele);
return (
[
{
name: nameMap[props.currentType as string],
dbList: databaseList.value[index].filter((ele) =>
ele.name.toLowerCase().includes(props.filterText.trim())
),
dbList: databaseList.value[index]
.filter(isCurrentRoleDatasource)
.filter(filterByName),
},
] || []
);
@@ -159,7 +162,7 @@ const selectDs = dbInfo => {
<div class="item-container">
<div
v-for="db in ele.dbList"
:key="db.type"
:key="`${db.type}-${db.datasourceRole || 'builtin'}`"
class="db-card"
@click="selectDs(db)"
>

View File

@@ -29,7 +29,7 @@ import {
updateApi,
validateApi
} from "@/api/sync/syncDatasource";
import {DsType, dsTypes, nameMap, typeList} from "./option";
import {DsType, dsTypes, nameMap, SYNC_DATASOURCE_ROLE, typeList} from "./option";
import {cloneDeep} from "lodash-es";
import {useCache} from "@/hooks/web/useCache";
import {Base64} from "js-base64";
@@ -112,30 +112,66 @@ const state = reactive({
const activeStep = ref(0);
const detail = ref();
const xpack = ref()
const pluginReady = ref(false)
const pluginLoadFailed = ref(false)
const latestUseTypes = ref([]);
const currentType = ref<DsType>("OLTP");
const filterText = ref("");
const currentDsType = ref();
const sourceActive = ref();
const sourceActive = ref(true);
const activeDatasourceRole = computed(() => sourceActive.value
? SYNC_DATASOURCE_ROLE.SOURCE
: SYNC_DATASOURCE_ROLE.TARGET)
const isPlugin = ref(false)
const emits = defineEmits(["refresh"]);
const pluginComponentLoadDone = () => {
xpack?.value?.invokeMethod({methodName: 'initForm', args: [{edit: editDs.value, ...form}]})
try {
xpack?.value?.invokeMethod({methodName: 'initForm', args: [{edit: editDs.value, ...form}]})
pluginReady.value = true
pluginLoadFailed.value = false
} catch (error) {
pluginComponentLoadFail()
}
}
const pluginComponentLoadFail = () => {
pluginReady.value = false
pluginLoadFailed.value = true
loading.value = false
ElMessage.error(t("sync_datasource.plugin_load_failed"))
}
const selectDsType = (dsInfo) => {
const selectedDatasource = typeof dsInfo === "string"
? currentTypeList.value
.map((item) => item.dbList)
.flat()
.find((item) => item.type === dsInfo)
: dsInfo
const type = typeof dsInfo === "string" ? dsInfo : dsInfo.type;
// type 不能唯一标识 PostgreSQL:源端和目标端是两个独立插件。
// 在渲染表单前直接使用用户选中的节点确定插件身份,不能再按 type 取列表中的第一个元素。
isPlugin.value = !!selectedDatasource?.isPlugin
pluginIndex.value = isPlugin.value ? selectedDatasource?.staticMap?.index || '' : ''
pluginReady.value = false
pluginLoadFailed.value = false
// 动态插件初始化读取的是 form.type;新建场景若只更新 currentDsType,
// PostgreSQL 插件会继续拿到默认的 OLTP,从而不渲染 Schema 配置项。
form.type = type
form.datasourceRole = activeDatasourceRole.value
currentDsType.value = type;
activeStep.value = 1;
nextTick(() => {
detail.value.initForm(type)
if (!isPlugin.value) {
detail.value?.initForm(type)
}
if (!dsTree.value) return;
currentTypeList.value
.map((ele) => ele.dbList)
.flat()
.some((ele) => {
if (ele.type === currentDsType.value) {
const sameRole = !ele.isPlugin ||
Number(ele.datasourceRole) === activeDatasourceRole.value
if (ele.type === currentDsType.value && sameRole) {
dsTree.value.setCurrentNode(ele);
isPlugin.value = ele['isPlugin']
return true;
}
return false;
@@ -145,7 +181,6 @@ const selectDsType = (dsInfo) => {
const handleDsNodeClick = (data) => {
if (!data.type) return;
isPlugin.value = data['isPlugin']
selectDsType(data);
};
@@ -187,38 +222,34 @@ const databaseList = ref();
const currentTypeList = computed(() => {
return typeList
.map((ele, index) => {
return {name: nameMap[ele], dbList: databaseList.value[index]};
const dbList = (databaseList.value[index] || []).filter((item) => {
return !item.isPlugin || Number(item.datasourceRole) === activeDatasourceRole.value
})
return {name: nameMap[ele], dbList};
})
.filter((ele) => {
return filterTypeByActiveName(ele.name);
});
});
const getDatasourceTypes = () => {
const pluginIndex = ref('')
const pluginDs = ref([])
const rebuildDatasourceTypes = () => {
const arr = [[], [], [], [], []] as any[];
dsTypes.forEach((item) => {
if (Number(item.datasourceRole) !== activeDatasourceRole.value) return
const index = typeList.findIndex((ele) => ele === item.catalog);
if (index !== -1) {
arr[index].push(item);
}
});
databaseList.value = arr.map((ele) => {
return ele.sort((a: any, b: any) => {
return (
a.name.toLowerCase().charCodeAt(0) - b.name.toLowerCase().charCodeAt(0)
);
});
});
};
getDatasourceTypes();
const pluginIndex = ref('')
const pluginDs = ref([])
const listSyncPlugin = () => {
loadSyncPlugin().then(res => {
pluginDs.value = res.data
res.data?.forEach(item => {
const {name, category, type, icon, extraParams, staticMap, datasourceRole} = item
const node = {
pluginDs.value
.filter((item) => Number(item.datasourceRole) === activeDatasourceRole.value)
.forEach((item) => {
const {name, category, type, icon, extraParams, staticMap, datasourceRole} = item
const index = typeList.findIndex((catalog) => catalog === category)
if (index !== -1) {
arr[index].push({
name,
catalog: category,
type,
@@ -227,14 +258,22 @@ const listSyncPlugin = () => {
isPlugin: true,
staticMap,
datasourceRole
}
const index = typeList.findIndex(ele => ele === node.catalog)
if (index !== -1) {
let copiedArr = JSON.parse(JSON.stringify(databaseList.value))
copiedArr[index].push(node)
databaseList.value = copiedArr
}
})
})
}
})
databaseList.value = arr.map((ele) => {
return ele.sort((a: any, b: any) => {
return (
a.name.toLowerCase().charCodeAt(0) - b.name.toLowerCase().charCodeAt(0)
);
});
});
};
rebuildDatasourceTypes();
const listSyncPlugin = () => {
loadSyncPlugin().then(res => {
pluginDs.value = res.data || []
rebuildDatasourceTypes()
}
)
}
@@ -242,7 +281,8 @@ listSyncPlugin();
const getPluginStatic = type => {
const arr = pluginDs.value.filter(ele => {
return ele.type === type && ele.datasourceRole === (sourceActive?.value ? 1 : 2)
return ele.type?.toLowerCase() === type?.toLowerCase() &&
Number(ele.datasourceRole) === activeDatasourceRole.value
})
return pluginIndex.value
? pluginIndex.value
@@ -250,12 +290,26 @@ const getPluginStatic = type => {
? arr[0].staticMap?.index
: null
}
const currentPluginStatic = computed(() => getPluginStatic(currentDsType.value))
// Schema 请求统一由同步管理外层发起,源/目标数据源插件只接收这个固定函数,不接触 token 和 HTTP 客户端
const getSyncDatasourcePluginSchema = data => getSchemaApi(data)
// Schema 请求统一由同步管理外层发起,并覆盖当前页面角色;插件内部即使传入字符串 "2",
// 发往 V3 JPA 接口的 datasourceRole 仍保持数字 1/2,避免源目标插件匹配发生偏移。
const getSyncDatasourcePluginSchema = data => getSchemaApi({
...data,
datasourceRole: activeDatasourceRole.value
})
const finishPluginAction = () => {
loading.value = false
ElMessage.error(t("sync_datasource.plugin_load_failed"))
}
const handleSubmit = param => {
const validateFrom = param.validate
const validateFrom = param?.validate
if (typeof validateFrom !== 'function') {
finishPluginAction()
return
}
if (param.eventName === 'saveDs') {
validateFrom(val => {
if (val) {
@@ -268,6 +322,8 @@ const handleSubmit = param => {
validateFrom(val => {
if (val) {
doValidateDs(param.args)
} else {
loading.value = false
}
})
}
@@ -297,19 +353,44 @@ const prev = () => {
activeStep.value = activeStep.value - 1;
};
const validateDS = () => {
const buildDatasourceRequest = () => {
const request = JSON.parse(JSON.stringify(form)) as unknown as Omit<
Form,
"configuration"
> & {
configuration: string;
};
request.configuration = Base64.encode(JSON.stringify(request.configuration));
request.type = currentDsType.value || form.type
request.datasourceRole = activeDatasourceRole.value
request.configuration = Base64.encode(JSON.stringify(request.configuration || {}));
return request
}
const ensurePluginReady = () => {
if (!currentPluginStatic.value || pluginLoadFailed.value) {
ElMessage.error(t("sync_datasource.plugin_load_failed"))
return false
}
if (!pluginReady.value) {
ElMessage.warning(t("sync_datasource.plugin_loading"))
return false
}
return true
}
const validateDS = () => {
const request = buildDatasourceRequest()
if (isPlugin.value) {
xpack?.value?.invokeMethod({
methodName: 'submitForm',
args: [{eventName: 'validateDs', args: request}]
})
if (!ensurePluginReady()) return
loading.value = true
try {
xpack?.value?.invokeMethod({
methodName: 'submitForm',
args: [{eventName: 'validateDs', args: request}]
})
} catch (error) {
finishPluginAction()
}
} else {
loading.value = true;
const validateFrom = detail.value.submitForm();
@@ -348,21 +429,20 @@ const typeTitle = computed(() => {
});
const saveDS = () => {
console.log("saveDS");
loading.value = true;
const request = JSON.parse(JSON.stringify(form)) as unknown as Omit<
Form,
"configuration"
> & {
configuration: string;
};
request.configuration = Base64.encode(JSON.stringify(request.configuration));
const request = buildDatasourceRequest()
if (isPlugin.value) {
xpack?.value?.invokeMethod({
methodName: 'submitForm',
args: [{eventName: 'saveDs', args: request}]
})
if (!ensurePluginReady()) return
loading.value = true;
try {
xpack?.value?.invokeMethod({
methodName: 'submitForm',
args: [{eventName: 'saveDs', args: request}]
})
} catch (error) {
finishPluginAction()
}
} else {
loading.value = true;
const validate = detail.value.submitForm();
validate((val) => {
if (val) {
@@ -390,6 +470,9 @@ const doSaveDs = (request) => {
})
.catch(() => {
loading.value = false;
})
.finally(() => {
loading.value = false;
});
} else {
saveApi(request)
@@ -402,6 +485,9 @@ const doSaveDs = (request) => {
})
.catch(() => {
loading.value = false;
})
.finally(() => {
loading.value = false;
});
}
}
@@ -410,7 +496,7 @@ const doValidateDs = request => {
loading.value = true
validateApi(request)
.then(() => {
ElMessage.success(t('datasource.validate_success'))
ElMessage.success(t('sync_datasource.validate_success'))
})
.finally(() => {
loading.value = false
@@ -463,9 +549,12 @@ const init = (dsInfo: Form, source: boolean) => {
activeStep.value = Number(editDs.value);
visible.value = true;
sourceActive.value = source;
rebuildDatasourceTypes();
currentDsType.value = dsInfo?.type || "";
isPlugin.value = dsInfo?.isPlugin || false
pluginIndex.value = isPlugin.value ? dsInfo?.staticMap?.index : null
pluginReady.value = false
pluginLoadFailed.value = false
getLatestUseTypes();
if (!!dsInfo) {
nextTick(() => {
@@ -486,7 +575,7 @@ const init = (dsInfo: Form, source: boolean) => {
})
});
} else {
defaultForm.datasourceRole = source ? 1 : 2;
defaultForm.datasourceRole = activeDatasourceRole.value;
Object.assign(form, cloneDeep(defaultForm));
}
};
@@ -662,6 +751,7 @@ defineExpose({
@select-ds-type="selectDsType"
:current-type="currentType"
:latest-use-types="latestUseTypes"
:datasource-role="activeDatasourceRole"
></ds-type-list>
<editor-detail
ref="detail"
@@ -671,7 +761,7 @@ defineExpose({
v-show="activeStep !== 0 && currentDsType && !isPlugin"
></editor-detail>
<plugin-component
:jsname="getPluginStatic(currentDsType)"
:jsname="currentPluginStatic"
:get-sync-datasource-schema="getSyncDatasourcePluginSchema"
ref="xpack"
:form="form"
@@ -679,11 +769,13 @@ defineExpose({
:active-step="activeStep"
@submitForm="handleSubmit"
@pluginComponentLoadDone="pluginComponentLoadDone"
@loadFail="pluginComponentLoadFail"
v-if="
activeStep !== 0 &&
currentDsType &&
visible &&
isPlugin
isPlugin &&
currentPluginStatic
">
</plugin-component>
</div>

View File

@@ -2,6 +2,15 @@ import { useI18n } from "@/hooks/web/useI18n";
const { t } = useI18n()
/**
* 同步数据源角色必须与 V3 JPA 字段 per_sync_datasource.datasource_role、
* 后端插件元数据以及同步任务参数保持一致:1 为源端,2 为目标端。
*/
export const SYNC_DATASOURCE_ROLE = {
SOURCE: 1,
TARGET: 2
} as const
export type DsType =
| "OLTP"
| "OLAP"
@@ -16,7 +25,7 @@ export const dsTypes = [
type: 'mysql',
name: 'MySQL',
catalog: 'OLTP',
datasourceRole: 1,
datasourceRole: SYNC_DATASOURCE_ROLE.SOURCE,
extraParams:
'characterEncoding=UTF-8&connectTimeout=5000&useSSL=false&allowPublicKeyRetrieval=true'
},
@@ -24,7 +33,7 @@ export const dsTypes = [
type: 'doris',
name: 'Apache Doris',
catalog: 'OLAP',
datasourceRole: 2,
datasourceRole: SYNC_DATASOURCE_ROLE.TARGET,
extraParams:
'characterEncoding=UTF-8&connectTimeout=5000&useSSL=false&allowPublicKeyRetrieval=true'
},
@@ -32,7 +41,7 @@ export const dsTypes = [
type: 'sqlServer',
name: 'SQL Server',
catalog: 'OLTP',
datasourceRole: 1,
datasourceRole: SYNC_DATASOURCE_ROLE.SOURCE,
extraParams: 'encrypt=false'
},
{
@@ -40,7 +49,7 @@ export const dsTypes = [
name: 'Oracle',
catalog: 'OLTP',
extraParams: '',
datasourceRole: 1,
datasourceRole: SYNC_DATASOURCE_ROLE.SOURCE,
charset: [
'Default',
'GBK',
@@ -59,13 +68,13 @@ export const dsTypes = [
name: 'Db2',
catalog: 'OLTP',
extraParams: '',
datasourceRole: 1,
datasourceRole: SYNC_DATASOURCE_ROLE.SOURCE,
},
{
type: 'elasticsearch',
name: 'Elasticsearch',
catalog: 'OLTP',
datasourceRole: 1,
datasourceRole: SYNC_DATASOURCE_ROLE.SOURCE,
extraParams: ''
}
]