feat(all): 数据面板迁移

This commit is contained in:
plum
2026-04-08 17:25:44 +08:00
parent 4cc239d660
commit 8385c08ec7
8 changed files with 1446 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
import {request} from "@/http/request";
export interface DataSetQueryParams {
page?: number;
pageSize?: number;
limit?: number;
offset?: number;
name?: string;
type?: string;
groupId?: IDataSet.Item["groupId"];
}
export interface DataSetPayload {
id?: IDataSet.Item["id"];
name: string;
groupId: IDataSet.Item["groupId"];
type: "API" | "SQL" | "JSON" | string;
method?: IDataSet.Item["method"];
api?: string;
dataSourceId?: string;
sql?: string;
json?: string;
}
export interface DataSetExecutePayload {
params?: any[];
query?: Record<string, any>;
body?: Record<string, any>;
}
export function fetchDataSetPage(params: DataSetQueryParams) {
return request.get<Service.ListPageResult<IDataSet.Item>>(`/data-set/page`, {params});
}
export function fetchDataSetDetail(id: IDataSet.Item["id"]) {
return request.get<IDataSet.Item>(`/data-set/${id}`);
}
export function fetchCreateDataSet(data: DataSetPayload) {
return request.post(`/data-set`, data);
}
export function fetchUpdateDataSet(data: DataSetPayload) {
return request.put(`/data-set`, data);
}
export function fetchDeleteDataSet(id: IDataSet.Item["id"]) {
return request.delete(`/data-set/${id}`, {});
}
export function fetchExecuteDataSet(id: IDataSet.Item["id"], data?: DataSetExecutePayload) {
return request.post(`/data-set/${id}/execute`, data);
}
@@ -32,6 +32,25 @@ export default {
"Data source name":"数据源名称",
"Data source type":"数据源类型",
"Connection string":"连接字符串",
"Data component": "数据组件",
dataComponent: {
"Data set": "数据集",
"Select data set": "选择数据集",
"Data filter": "数据过滤器",
"Enable filter": "启用过滤",
"Disabled filter": "禁用过滤",
"Popup editor": "弹出编辑",
"Filter editor": "过滤器编辑",
"Auto refresh": "自动刷新",
"Seconds once": "秒一次",
"Apply to model": "应用于模型",
"Transition": "过渡时间",
"Milliseconds": "毫秒",
"Data result": "数据结果",
"Result editor": "结果编辑",
"Manual refresh": "手动刷新",
"Result error": "结果错误",
},
"Username":"用户名",
"Password":"密码",
"Test the connection":"测试连接",
@@ -14,6 +14,7 @@ import {
MagicWandFilled,
CloudSnow,
HeatMap,
DataBase,
Opacity,
ImageReference,
LocationHeart,
@@ -45,6 +46,7 @@ import SidebarHeatmap from "./sidebar/SidebarHeatmap.vue";
import SidebarPath from "./sidebar/SidebarPath.vue";
import SidebarUIPanel from "./sidebar/SidebarUIPanel.vue";
import SidebarWaterPool from "./sidebar/SidebarWaterPool.vue";
import SidebarDataComponent from "./sidebar/SidebarDataComponent.vue";
const tabsInstRef = ref<TabsInst | null>(null);
const tabs = ref<Array<any>>([]);
@@ -74,6 +76,7 @@ function setTabs(object){
{ name: "geometry", icon: { text: 'Geometry',color:"#6287D1", component: markRaw(GroupObjects) }, component: markRaw(SidebarGeometry) },
{ name: "material", icon: { text: 'Material',color:"#6287D1", component: markRaw(Opacity) }, component: markRaw(SidebarMaterial) },
{ name: "animations", icon: { text: 'Animations',color:"#06AF88", component: markRaw(Draw) }, component: markRaw(SidebarAnimations) },
{ name: "dataComponent", icon: { text: 'Data component', color:"#0bbc14", component: markRaw(DataBase) }, component: markRaw(SidebarDataComponent) },
{ name: "script", icon: { text: 'Script',color:"#06AF88", component: markRaw(Script) }, component: markRaw(SidebarScript) },
]
@@ -0,0 +1,598 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import type { SelectOption } from "naive-ui";
import { Popup } from "@vicons/carbon";
import { t } from "@/language";
import { App, Hooks, Utils, DataBindingManager } from "@astral3d/engine";
import { fetchDataSetPage, fetchExecuteDataSet } from "@/http/api/dataSet";
import CodeEditor from "@/components/code/CodeEditor.vue";
import EsInputNumber from "@/components/es/EsInputNumber.vue";
type DataSetId = IDataSet.Item["id"];
type DataComponentConfig = {
dataSetId?: DataSetId | null;
filterEnabled?: boolean;
filterBody?: string;
autoRefreshEnabled?: boolean;
autoRefreshInterval?: number;
applyToModel?: boolean;
transition?: number;
};
const defaultFilterBody = [" // Editable area", " // Example: return data.filter(item => item.value > 100);", " return data;"].join("\n");
const defaultConfig = {
dataSetId: null as DataSetId | null,
filterEnabled: false,
filterBody: defaultFilterBody,
autoRefreshEnabled: false,
autoRefreshInterval: 5,
applyToModel: false,
transition: 0,
};
const isSelectObject3D = ref(false);
const dataSetOptions = ref<SelectOption[]>([]);
const dataSetLoading = ref(false);
const resultSource = ref("");
const resultLoading = ref(false);
const fetchError = ref("");
const filterError = ref("");
const applyError = ref("");
const showFilterModal = ref(false);
const showResultModal = ref(false);
const resultError = computed(() => [fetchError.value, filterError.value, applyError.value].filter(Boolean).join(" | "));
const rawResult = ref<unknown>(undefined);
const processedResult = ref<unknown>(undefined);
let selectedObject: any = null;
const config = reactive({ ...defaultConfig });
const filterEditorKey = computed(() => (config.filterEnabled ? "filter-enabled" : "filter-disabled"));
const filterEditorConfig = computed(() => ({
readOnly: !config.filterEnabled,
minimap: { enabled: false },
renderValidationDecorations: "off",
automaticLayout: true,
lineNumbers: "off",
}));
const filterModalEditorConfig = computed(() => ({
readOnly: !config.filterEnabled,
minimap: { enabled: false },
renderValidationDecorations: "off",
automaticLayout: true,
lineNumbers: "on",
}));
const resultEditorConfig = {
readOnly: true,
minimap: { enabled: false },
renderValidationDecorations: "off",
automaticLayout: true,
lineNumbers: "off",
};
const resultModalEditorConfig = {
readOnly: true,
minimap: { enabled: false },
renderValidationDecorations: "off",
automaticLayout: true,
lineNumbers: "on",
};
let isHydrating = false;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
let executeToken = 0;
async function loadDataSets() {
dataSetLoading.value = true;
const res = await fetchDataSetPage({ page: 1, pageSize: 1000 });
dataSetLoading.value = false;
if (res.error) return;
dataSetOptions.value =
res.data?.items?.map(item => ({
label: item.name,
value: item.id,
})) || [];
}
function clearResult() {
executeToken += 1;
rawResult.value = undefined;
processedResult.value = undefined;
resultSource.value = "";
resultLoading.value = false;
fetchError.value = "";
filterError.value = "";
applyError.value = "";
}
function normalizeInterval(value: number | null | undefined) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return defaultConfig.autoRefreshInterval;
}
return Math.min(600, Math.max(1, Math.round(numeric)));
}
function normalizeResult(value: unknown) {
if (typeof value !== "string") {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return value;
}
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function formatResult(value: unknown) {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value ?? "");
}
}
function applyFilter(value: unknown) {
if (!config.filterEnabled) {
return { value, error: "" };
}
const body = (config.filterBody || "").trim();
if (!body) {
return { value, error: "" };
}
try {
const filterFn = new Function("data", body);
return { value: filterFn(value), error: "" };
} catch (error) {
const message = (error as Error).message || t("prompt.Parse failed");
window.$message?.error(message);
return { value, error: message };
}
}
function writeComponentData(value: unknown, options: { applyToModel?: boolean } = {}) {
const object = selectedObject;
if (!object) return;
const result = DataBindingManager.setData(object, value, {
index: 0,
applyToModel: options.applyToModel,
transition: config.transition,
onUpdate: () => Hooks.useDispatchSignal("objectChanged", object),
});
if (options.applyToModel && result.errors.length > 0) {
applyError.value = result.errors.join(" | ");
} else {
applyError.value = "";
}
}
function syncProcessedData(options: { applyToModel?: boolean } = {}) {
if (rawResult.value === undefined) {
processedResult.value = undefined;
resultSource.value = "";
filterError.value = "";
applyError.value = "";
return;
}
const { value, error } = applyFilter(rawResult.value);
processedResult.value = value;
filterError.value = error;
resultSource.value = formatResult(value);
writeComponentData(value, { applyToModel: options.applyToModel });
}
async function executeDataSet() {
if (!config.dataSetId) {
clearResult();
return;
}
const dataSetId = config.dataSetId;
const token = ++executeToken;
resultLoading.value = true;
fetchError.value = "";
filterError.value = "";
applyError.value = "";
const res = await fetchExecuteDataSet(dataSetId);
if (token !== executeToken) {
return;
}
resultLoading.value = false;
if (res.error) {
fetchError.value = res.error.msg || t("other['Query failed']");
return;
}
if (dataSetId !== config.dataSetId) {
return;
}
rawResult.value = normalizeResult(res.data);
syncProcessedData({ applyToModel: config.applyToModel });
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
function syncAutoRefresh() {
stopAutoRefresh();
if (!config.autoRefreshEnabled || !config.dataSetId) {
return;
}
const interval = normalizeInterval(config.autoRefreshInterval);
refreshTimer = setInterval(() => {
executeDataSet();
}, interval * 1000);
}
function buildConfig(): DataComponentConfig {
return {
dataSetId: config.dataSetId ?? null,
filterEnabled: Boolean(config.filterEnabled),
filterBody: config.filterBody ?? "",
autoRefreshEnabled: Boolean(config.autoRefreshEnabled),
autoRefreshInterval: normalizeInterval(config.autoRefreshInterval),
applyToModel: Boolean(config.applyToModel),
transition: Number.isFinite(Number(config.transition)) ? Number(config.transition) : defaultConfig.transition,
};
}
function isSameConfig(left: unknown, right: unknown) {
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch {
return false;
}
}
function saveConfigToObject() {
const object = selectedObject;
if (!object) return;
const nextConfig = buildConfig();
const currentList = Array.isArray(object.dataComponent) ? object.dataComponent : [];
const currentConfig = currentList[0]?.config;
if (isSameConfig(currentConfig, nextConfig)) {
return;
}
DataBindingManager.setConfig(object, nextConfig, { index: 0 });
}
const scheduleSave = Utils.debounce(() => {
if (isHydrating) return;
saveConfigToObject();
}, 200);
const schedulePreview = Utils.debounce(() => {
syncProcessedData({ applyToModel: config.applyToModel });
}, 150);
function applyConfigFromObject(object: any) {
const saved = Array.isArray(object?.dataComponent) ? (object.dataComponent[0]?.config as DataComponentConfig | undefined) : undefined;
isHydrating = true;
config.dataSetId = saved?.dataSetId ?? defaultConfig.dataSetId;
config.filterEnabled = saved?.filterEnabled ?? defaultConfig.filterEnabled;
config.filterBody = typeof saved?.filterBody === "string" ? saved.filterBody : defaultConfig.filterBody;
config.autoRefreshEnabled = saved?.autoRefreshEnabled ?? defaultConfig.autoRefreshEnabled;
config.autoRefreshInterval = normalizeInterval(saved?.autoRefreshInterval ?? defaultConfig.autoRefreshInterval);
config.applyToModel = saved?.applyToModel ?? defaultConfig.applyToModel;
config.transition = Number.isFinite(Number(saved?.transition)) ? Number(saved?.transition) : defaultConfig.transition;
nextTick(() => {
isHydrating = false;
});
}
function handleSelectObject(object: any) {
stopAutoRefresh();
selectedObject = object || null;
if (object) {
isSelectObject3D.value = true;
applyConfigFromObject(object);
if (config.dataSetId) {
executeDataSet();
} else {
clearResult();
}
syncAutoRefresh();
} else {
isSelectObject3D.value = false;
clearResult();
}
}
watch(
() => config.dataSetId,
value => {
if (isHydrating) return;
scheduleSave();
if (value) {
executeDataSet();
} else {
clearResult();
stopAutoRefresh();
}
syncAutoRefresh();
}
);
watch(
() => config.filterEnabled,
() => {
if (isHydrating) return;
scheduleSave();
syncProcessedData({ applyToModel: config.applyToModel });
}
);
watch(
() => config.filterBody,
() => {
if (isHydrating) return;
scheduleSave();
schedulePreview();
}
);
watch(
() => config.autoRefreshEnabled,
value => {
if (isHydrating) return;
scheduleSave();
syncAutoRefresh();
if (value) {
executeDataSet();
}
}
);
watch(
() => config.autoRefreshInterval,
() => {
if (isHydrating) return;
scheduleSave();
syncAutoRefresh();
}
);
watch(
() => config.transition,
() => {
if (isHydrating) return;
scheduleSave();
}
);
watch(
() => config.applyToModel,
value => {
if (isHydrating) return;
scheduleSave();
if (!value) {
applyError.value = "";
return;
}
if (processedResult.value !== undefined) {
writeComponentData(processedResult.value, { applyToModel: true });
}
}
);
onMounted(() => {
Hooks.useAddSignal("objectSelected", handleSelectObject);
loadDataSets();
handleSelectObject(App.selected);
});
onBeforeUnmount(() => {
Hooks.useRemoveSignal("objectSelected", handleSelectObject);
stopAutoRefresh();
});
</script>
<template>
<div v-if="isSelectObject3D" class="data-component-panel">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Data set") }}</span>
<n-select
v-model:value="config.dataSetId"
size="small"
:options="dataSetOptions"
:loading="dataSetLoading"
:placeholder="t('layout.sider.dataComponent.Select data set')"
filterable
clearable
/>
</div>
<n-divider class="!mt-2 !mb-4" />
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Data filter") }}</span>
<div class="flex justify-end gap-2">
<n-switch v-model:value="config.filterEnabled">
<template #checked>
{{ t("layout.sider.dataComponent.Enable filter") }}
</template>
<template #unchecked>
{{ t("layout.sider.dataComponent.Disabled filter") }}
</template>
</n-switch>
</div>
</div>
<div class="filter-editor relative" :class="{ 'is-disabled': !config.filterEnabled }">
<div class="filter-editor-line">function filter(data) {</div>
<CodeEditor :key="filterEditorKey" v-model:source="config.filterBody" mode="javascript" :config="filterEditorConfig" class="filter-editor-code" />
<div class="filter-editor-line">}</div>
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="small" tertiary @click="showFilterModal = true" class="absolute top-1 right-1">
<template #icon>
<n-icon><Popup /></n-icon>
</template>
</n-button>
</template>
{{ t("layout.sider.dataComponent.Popup editor") }}
</n-tooltip>
</div>
<n-divider class="!mt-2 !mb-4" />
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Apply to model") }}</span>
<div class="apply-controls">
<n-checkbox v-model:checked="config.applyToModel" size="small" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Auto refresh") }}</span>
<div class="flex justify-end gap-2 items-center">
<n-checkbox v-model:checked="config.autoRefreshEnabled" size="small" />
<EsInputNumber
v-model:value="config.autoRefreshInterval"
size="small"
:min="1"
:max="86400"
:decimal="0"
:step="1"
:show-button="false"
:disabled="!config.autoRefreshEnabled"
:unit="t('layout.sider.dataComponent.Seconds once')"
class="!w-60%"
/>
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Transition") }}</span>
<div class="flex justify-end items-center">
<EsInputNumber
v-model:value="config.transition"
size="small"
:min="0"
:max="600000"
:step="100"
:decimal="0"
:show-button="false"
:unit="t('layout.sider.dataComponent.Milliseconds')"
class="!w-60%"
/>
</div>
</div>
<n-divider class="!mt-2 !mb-4" />
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Data result") }}</span>
<div class="flex items-center justify-end gap-2">
<n-text depth="3" v-if="resultLoading">{{ t("other.Loading") }}</n-text>
<n-button size="tiny" tertiary :disabled="!config.dataSetId" :loading="resultLoading" @click="executeDataSet">
{{ t("layout.sider.dataComponent.Manual refresh") }}
</n-button>
</div>
</div>
<div class="filter-editor relative">
<n-alert v-if="resultError" type="error" :title="t('layout.sider.dataComponent.Result error')" class="mb-2">
{{ resultError }}
</n-alert>
<CodeEditor v-model:source="resultSource" mode="json" :config="resultEditorConfig" class="flex-1 min-h-200px" />
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="small" tertiary @click="showResultModal = true" class="absolute top-1 right-1">
<template #icon>
<n-icon><Popup /></n-icon>
</template>
</n-button>
</template>
{{ t("layout.sider.dataComponent.Popup editor") }}
</n-tooltip>
</div>
</div>
<n-result v-else status="418" title="Empty" :description="t('prompt[\'No object selected.\']')" />
<n-modal v-model:show="showFilterModal" preset="dialog" :showIcon="false" :title="t('layout.sider.dataComponent.Filter editor')" class="!w-90vw">
<div class="filter-editor filter-editor-modal" :class="{ 'is-disabled': !config.filterEnabled }">
<div class="filter-editor-line">function filter(data) {</div>
<CodeEditor
:key="`modal-${filterEditorKey}`"
v-model:source="config.filterBody"
mode="javascript"
:config="filterModalEditorConfig"
class="filter-editor-code filter-editor-code--modal"
/>
<div class="filter-editor-line">}</div>
</div>
<div class="flex justify-end mt-3">
<n-button size="small" @click="showFilterModal = false">{{ t("other.Close") }}</n-button>
</div>
</n-modal>
<n-modal v-model:show="showResultModal" preset="dialog" :showIcon="false" :title="t('layout.sider.dataComponent.Result editor')" class="!w-90vw">
<CodeEditor v-model:source="resultSource" mode="json" :config="resultModalEditorConfig" class="flex-1 min-h-200px !h-70vh" />
<div class="flex justify-end mt-3">
<n-button size="small" @click="showResultModal = false">{{ t("other.Close") }}</n-button>
</div>
</n-modal>
</template>
<style scoped lang="less">
.data-component-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.filter-editor {
margin-bottom: 0.5rem;
width: 100%;
border: 1px solid var(--n-border-color);
border-radius: 6px;
overflow: hidden;
background: #1e1e1e;
}
.filter-editor.is-disabled {
opacity: 0.6;
}
.filter-editor-line {
padding: 4px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
color: #c5c5c5;
background: #1e1e1e;
}
.filter-editor-code {
height: 180px;
}
.filter-editor-code--modal {
height: 60vh;
}
.apply-controls {
display: flex;
justify-content: flex-end;
}
</style>