fix(data-center): populate SQL data source options and normalize dataset source id mapping

This commit is contained in:
plum 2026-04-15 23:34:52 +08:00
parent 27b4b720ed
commit fbfaa59b14
4 changed files with 318 additions and 168 deletions

View File

@ -17,7 +17,7 @@ export interface DataSetPayload {
type: "API" | "SQL" | "JSON" | string; type: "API" | "SQL" | "JSON" | string;
method?: IDataSet.Item["method"]; method?: IDataSet.Item["method"];
api?: string; api?: string;
dataSourceId?: string; dataSourceId?: IDataSet.Item["id"];
sql?: string; sql?: string;
json?: string; json?: string;
} }

View File

@ -65,7 +65,7 @@ const defaultDataSet: IDataSet.Item = {
type: 'API', type: 'API',
method: 'GET', method: 'GET',
api: '', api: '',
dataSource: '', dataSourceId: '',
sql: '', sql: '',
json: '' json: ''
}; };
@ -179,7 +179,7 @@ async function editDataSet(item) {
const detail = (res.data || item) as any; const detail = (res.data || item) as any;
resetCurrentDataSet({ resetCurrentDataSet({
...detail, ...detail,
dataSource: detail.dataSourceId || detail.dataSource || "" dataSourceId: detail.dataSourceId || detail.dataSource ? String(detail.dataSourceId || detail.dataSource) : ""
}); });
showDataSetModal.value = true showDataSetModal.value = true
} }

View File

@ -1,180 +1,326 @@
<template> <template>
<n-modal :show="show" @mask-click="handleClose"> <n-modal :show="show" @mask-click="handleClose">
<n-card class="w-200 max-w-1200px" :title="t('home.Data set config')"> <n-card class="w-200 max-w-1200px" :title="t('home.dataCenter.Data set config')">
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto" :disabled="submitLoading"> <n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto" :disabled="submitLoading">
<n-grid :cols="24" :x-gap="24"> <n-grid :cols="24" :x-gap="24">
<n-form-item-gi :span="12" :label="t('home.Data set name')" path="name"> <n-form-item-gi :span="12" :label="t('home.dataCenter.Data set name')" path="name">
<n-input v-model:value="model.name" /> <n-input v-model:value="model.name" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :span="12" :label="t('home.Data set group')"> <n-form-item-gi :span="12" :label="t('home.dataCenter.Data set group')" path="groupId">
<n-cascader v-model:value="model.groupId" expand-trigger="hover" :options="groupOptions" <n-cascader
check-strategy="all" show-path filterable clearable label-field="name" value-field="id" /> v-model:value="model.groupId"
</n-form-item-gi> expand-trigger="hover"
<n-form-item-gi :span="12" :label="t('home.Data set type')"> :options="groupOptions"
<n-select v-model:value="model.type" :options="setTypes" /> check-strategy="all"
</n-form-item-gi> show-path
filterable
clearable
label-field="name"
value-field="id"
/>
</n-form-item-gi>
<n-form-item-gi :span="12" :label="t('home.dataCenter.Data set type')" path="type">
<n-select v-model:value="model.type" :options="setTypes" />
</n-form-item-gi>
<template v-if="model.type === 'API'"> <template v-if="model.type === 'API'">
<n-form-item-gi :span="12" :label="t('home.Method')"> <n-form-item-gi :span="12" :label="t('home.dataCenter.Method')" path="method">
<n-select v-model:value="model.method" :options="[ <n-select
{ label: 'GET', value: 'GET' }, v-model:value="model.method"
{ label: 'POST', value: 'POST' }, :options="[
]" /> { label: 'GET', value: 'GET' },
</n-form-item-gi> { label: 'POST', value: 'POST' },
<n-form-item-gi :span="24" :label="t('home.API interface')"> ]"
<n-input v-model:value="model.api" type="textarea" /> />
</n-form-item-gi> </n-form-item-gi>
</template> <n-form-item-gi :span="24" :label="t('home.dataCenter.API interface')" path="api">
<n-input v-model:value="model.api" type="textarea" />
</n-form-item-gi>
</template>
<template v-else-if="model.type === 'SQL'"> <template v-else-if="model.type === 'SQL'">
<n-form-item-gi :span="12" :label="t('home.Data sources')"> <n-form-item-gi :span="12" :label="t('home.dataCenter.Data sources')" path="dataSourceId">
<n-select v-model:value="model.dataSource" :options="dataSourceOptions" /> <n-select v-model:value="model.dataSourceId" :options="dataSourceOptions" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :span="24" :label="`SQL(${t('home.Query only')})`"> <n-form-item-gi :span="24" :label="`SQL(${t('home.dataCenter.Query only')})`" path="sql">
<SQLEditor v-model:value="model.sql as string" /> <SQLEditor v-model:value="model.sql as string" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :span="24" label=" "> <n-form-item-gi :span="24" label=" ">
<n-blockquote> <n-blockquote>
{{ t('home.Parameter is passed as ') }} {{ t('home.dataCenter["Parameter is passed as:"]') }}
'${id}' , '${id}' ,
{{ t('home.For example:') }} {{ t('home.dataCenter["For example:"]') }}
SELECT * FROM table WHERE id='${id}' SELECT * FROM table WHERE id='${id}'
</n-blockquote> </n-blockquote>
</n-form-item-gi> </n-form-item-gi>
</template> </template>
<template v-else-if="model.type === 'JSON'"> <template v-else-if="model.type === 'JSON'">
<n-form-item-gi :span="24" label="JSON"> <n-form-item-gi :span="24" label="JSON" path="json">
<JSONEditor v-model:value="model.json as string" /> <JSONEditor v-model:value="model.json as string" />
</n-form-item-gi> </n-form-item-gi>
</template> </template>
<n-gi :span="24"> <n-gi :span="24">
<div class="flex justify-end mt-4"> <div class="flex justify-end mt-4">
<n-button :disabled="submitLoading" @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button> <n-button :disabled="submitLoading" @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button>
<n-button type="primary" :loading="submitLoading" @click="saveDataSet">{{ t("other.Ok") }}</n-button> <n-button type="primary" :loading="submitLoading" @click="saveDataSet">{{ t("other.Ok") }}</n-button>
</div> </div>
</n-gi> </n-gi>
</n-grid> </n-grid>
</n-form> </n-form>
</n-card> </n-card>
</n-modal> </n-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch, useTemplateRef } from "vue"; import { ref, watch, computed, useTemplateRef } from "vue";
import type { FormInst } from 'naive-ui' import type { FormInst } from "naive-ui";
import { t } from "@/language"; import { t } from "@/language";
import { DataSetPayload, fetchCreateDataSet, fetchUpdateDataSet } from "@/http/api/dataSet"; import { DataSetPayload, fetchCreateDataSet, fetchUpdateDataSet } from "@/http/api/dataSet";
import { fetchDataSetGroupTree } from "@/http/api/dataSetGroup"; import { fetchDataSetGroupTree } from "@/http/api/dataSetGroup";
import { fetchDataSourceList } from "@/http/api/dataSource"; import { fetchDataSourceList } from "@/http/api/dataSource";
import SQLEditor from "@/components/code/SQLEditor.vue"; import SQLEditor from "@/components/code/SQLEditor.vue";
import JSONEditor from "@/components/code/JSONEditor.vue"; import JSONEditor from "@/components/code/JSONEditor.vue";
const props = withDefaults(
defineProps<{
show: boolean;
model?: IDataSet.Item;
}>(),
{
show: false,
model: () => ({
id: "",
groupId: "",
name: "",
type: "API",
method: "GET",
api: "",
dataSourceId: "",
sql: "",
json: "",
createTime: "",
}),
}
);
const emits = defineEmits(["update:show", "refresh"]);
const props = withDefaults(defineProps<{ const formRef = useTemplateRef<FormInst>("formRef");
show: boolean, function createRequiredRule(message: string, trigger: Array<"input" | "blur" | "change">) {
model: IDataSet.Item return {
}>(), { trigger,
show: false, validator(_: unknown, value: unknown) {
model: () => ({ if (value === undefined || value === null || value === "") {
id: '', return new Error(message);
groupId: "", }
name: '', if (Array.isArray(value) && value.length === 0) {
type: 'SQL', return new Error(message);
}) }
}) return true;
const emits = defineEmits(["update:show", "refresh"]); },
};
}
const formRef = useTemplateRef<FormInst>("formRef"); const rules = computed(() => {
const rules = { const baseRules: Record<string, any> = {
name: { required: true, message: t("prompt.Please enter a name for the dataset"), trigger: 'blur' } name: { required: true, message: t("home.dataCenter.Please enter a name for the dataset"), trigger: ["input", "blur"] },
}; groupId: createRequiredRule(t("home.dataCenter.Please select a grouping for the dataset"), ["change", "blur"]),
const groupOptions = ref<IDataSet.IGroup[]>([]); type: { required: true, message: t("home.dataCenter.Please select a dataset type"), trigger: ["change", "blur"] },
const setTypes = [ };
{ label: 'API', value: 'API' }, const type = props.model?.type;
{ label: 'SQL', value: 'SQL' }, if (type === "API") {
{ label: 'JSON', value: 'JSON' }, baseRules.method = {
]; required: true,
const dataSourceOptions = ref<{ label: string; value: IDataSource.Item["id"] }[]>([]); message: t("home.dataCenter.Please select a method"),
const submitLoading = ref(false); trigger: ["change", "blur"],
};
baseRules.api = {
required: true,
message: t("home.dataCenter.Please enter the interface url"),
trigger: ["input", "blur"],
};
} else if (type === "SQL") {
baseRules.dataSourceId = createRequiredRule(t("home.dataCenter.Please select a data source"), ["change", "blur"]);
baseRules.sql = { required: true, message: t("home.dataCenter.Please enter a SQL string"), trigger: ["blur"] };
} else if (type === "JSON") {
baseRules.json = { required: true, message: t("home.dataCenter.Please enter the JSON data"), trigger: ["blur"] };
}
return baseRules;
});
const groupOptions = ref<IDataSet.IGroup[]>([]);
const setTypes = [
{ label: "API", value: "API" },
{ label: "SQL", value: "SQL" },
{ label: "JSON", value: "JSON" },
];
const dataSourceOptions = ref<{ label: string; value: IDataSource.Item["id"] }[]>([]);
const submitLoading = ref(false);
const isEdit = computed(() => Boolean(props.model?.id));
watch(() => props.show, (show) => { function normalizeDataSourceId(value: unknown): IDataSource.Item["id"] | undefined {
if (!show) { if (value === undefined || value === null || value === "") {
return; return undefined;
} }
loadOptions(); return String(value);
}); }
watch(() => props.model.type, (nextType, oldType) => { function extractList<T>(value: unknown): T[] {
if (!nextType || nextType === oldType) { if (Array.isArray(value)) {
return; return value as T[];
} }
if (nextType !== "API") { if (value && typeof value === "object") {
props.model.method = undefined; const nested = (value as Record<string, unknown>).result;
props.model.api = ""; if (Array.isArray(nested)) {
} else if (!props.model.method) { return nested as T[];
props.model.method = "GET"; }
} }
if (nextType !== "SQL") { return [];
props.model.dataSource = ""; }
props.model.sql = "";
}
if (nextType !== "JSON") {
props.model.json = "";
}
});
async function loadOptions() { async function loadOptions() {
const [groupRes, dataSourceRes] = await Promise.all([ const [groupRes, dataSourceRes] = await Promise.allSettled([fetchDataSetGroupTree(), fetchDataSourceList()]);
fetchDataSetGroupTree(),
fetchDataSourceList()
]);
groupOptions.value = groupRes.data || [];
dataSourceOptions.value = (dataSourceRes.data || []).map(item => ({
label: item.name,
value: item.id
}));
}
function handleClose() { if (groupRes.status === "fulfilled") {
emits("update:show", false); groupOptions.value = extractList<IDataSet.IGroup>(groupRes.value.data);
} } else {
groupOptions.value = [];
}
function saveDataSet(e: MouseEvent) { if (dataSourceRes.status === "fulfilled") {
e.preventDefault() const list = extractList<IDataSource.Item>(dataSourceRes.value.data);
dataSourceOptions.value = list.map(item => ({
label: item.name,
value: String(item.id),
}));
} else {
dataSourceOptions.value = [];
}
}
formRef.value?.validate(async (errors) => { function normalizeJsonValue(value?: IDataSet.Item["json"]) {
if (!errors) { if (!value) {
submitLoading.value = true; return "";
const payload: DataSetPayload = { }
id: props.model.id || undefined, if (typeof value === "string") {
name: props.model.name?.trim() || "", return value;
groupId: props.model.groupId, }
type: props.model.type try {
}; return JSON.stringify(value, null, 2);
} catch {
return "";
}
}
if (props.model.type === "API") { function resetFieldsForType(type: string) {
payload.method = props.model.method; if (!props.model) {
payload.api = props.model.api?.trim(); return;
} else if (props.model.type === "SQL") { }
payload.dataSourceId = props.model.dataSource; if (type !== "API") {
payload.sql = props.model.sql?.trim(); props.model.method = undefined;
} else if (props.model.type === "JSON") { props.model.api = "";
payload.json = props.model.json; }
} if (type === "API" && !props.model.method) {
props.model.method = "GET";
}
if (type !== "SQL") {
props.model.dataSourceId = undefined;
props.model.sql = "";
}
if (type !== "JSON") {
props.model.json = "";
}
}
const res = props.model.id ? await fetchUpdateDataSet(payload) : await fetchCreateDataSet(payload); function validateSql(sql: string) {
submitLoading.value = false; const trimmed = sql.trim();
if (res.error) { if (!/^(SELECT|WITH)\b/i.test(trimmed)) {
return; window.$message?.error(t("home.dataCenter.SQL must start with SELECT or WITH"));
} return false;
window.$message?.success(props.model.id ? t("prompt.Success to update") : t("prompt.Saved successfully!")); }
if (/[;]|--|\/\*/.test(trimmed)) {
window.$message?.error(t("home.dataCenter.SQL must not include unsafe tokens"));
return false;
}
return true;
}
// function buildPayload(): DataSetPayload {
emits("refresh"); const payload: DataSetPayload = {
name: props.model?.name?.trim() || "",
groupId: props.model?.groupId ?? "",
type: props.model?.type || "API",
};
if (props.model?.id) {
payload.id = props.model.id;
}
if (payload.type === "API") {
payload.method = props.model?.method;
payload.api = props.model?.api?.trim();
} else if (payload.type === "SQL") {
payload.dataSourceId = normalizeDataSourceId(props.model?.dataSourceId);
payload.sql = props.model?.sql?.trim();
} else if (payload.type === "JSON") {
payload.json = normalizeJsonValue(props.model?.json);
}
return payload;
}
handleClose(); watch(
} () => props.show,
}) show => {
} if (!show) {
return;
}
loadOptions();
if (props.model) {
props.model.dataSourceId = normalizeDataSourceId(props.model.dataSourceId);
}
if (props.model?.type === "API" && !props.model.method) {
props.model.method = "GET";
}
if (props.model?.type === "JSON") {
props.model.json = normalizeJsonValue(props.model.json);
}
}
);
watch(
() => props.model?.type,
(next, prev) => {
if (!next || next === prev) {
return;
}
resetFieldsForType(next);
formRef.value?.restoreValidation();
}
);
function handleClose() {
emits("update:show", false);
}
function saveDataSet(e: MouseEvent) {
e.preventDefault();
formRef.value?.validate(async errors => {
if (errors || !props.model) {
return;
}
if (props.model.type === "SQL" && !validateSql(props.model.sql || "")) {
return;
}
submitLoading.value = true;
const payload = buildPayload();
const res = isEdit.value ? await fetchUpdateDataSet(payload) : await fetchCreateDataSet(payload);
submitLoading.value = false;
if (res.error) {
return;
}
window.$message?.success(isEdit.value ? t("prompt.Success to update") : t("prompt.Saved successfully!"));
emits("refresh");
handleClose();
});
}
</script> </script>

View File

@ -1,6 +1,7 @@
declare namespace IDataSource { declare namespace IDataSource {
type Id = string | number;
interface Item { interface Item {
id: string; id: Id;
name: string; name: string;
type: string; type: string;
connectionString: string; connectionString: string;
@ -10,21 +11,24 @@ declare namespace IDataSource {
} }
declare namespace IDataSet { declare namespace IDataSet {
type Id = string | number;
interface Item { interface Item {
id: string; id: Id;
groupId: string; groupId: Id;
name: string; name: string;
type: string; type: string;
method?: "GET" | "POST"; method?: "GET" | "POST";
api?: string; api?: string;
dataSource?: string; dataSourceId?: Id;
dataSource?: Id;
sql?: string; sql?: string;
json?: string; json?: string;
createTime?: string;
} }
interface IGroup { interface IGroup {
id: string; id: Id;
pid?: string; pid?: Id;
name: string; name: string;
children?: IGroup[]; children?: IGroup[];
} }