Compare commits

...
17 Commits
Author SHA1 Message Date
plum ecd24acf19 feat(all): 修复html复制报错 2026-04-22 17:47:28 +08:00
plum 37e1e58883 feat(all): 修改分页 2026-04-17 12:44:21 +08:00
plum a8947696af chore(editor): commit pending workspace changes 2026-04-17 10:42:54 +08:00
plum 9662f41eec fix(data-center): clear and remount SQL datasource select on type switch 2026-04-16 11:42:35 +08:00
plum fbfaa59b14 fix(data-center): populate SQL data source options and normalize dataset source id mapping 2026-04-15 23:34:52 +08:00
plum 27b4b720ed fix(editor): correct object precision and dataset method binding 2026-04-14 11:08:49 +08:00
plum e62f2b54d5 chore(editor): warm up circular water pool after viewer init 2026-04-13 16:25:36 +08:00
plum d7c36e31f7 fix(sdk): keep dynamic path flowing when scene is static 2026-04-13 15:25:06 +08:00
plum 985c8d39fc fix(editor): keep html panel identity on upload refresh 2026-04-13 15:04:32 +08:00
plum 5368e52f0d chore(sdk): remove diagnostic console.error logs from unpackGroup 2026-04-10 11:24:40 +08:00
plum 83684b4c7b fix(sdk): fix loader lifecycle for consecutive offline pack imports 2026-04-10 11:22:28 +08:00
plum 6720a3e730 fix(sdk): capture loader ref in unpackGroup to prevent race condition on done() cleanup 2026-04-10 02:09:08 +08:00
plum e78afc73f3 fix(editor): copy sdk resource to assets/resource in build 2026-04-10 01:42:51 +08:00
plum 2c62987281 feat(all): 修改ws路径 2026-04-10 00:40:45 +08:00
plum ecddca00c9 fix editor data component refresh and transition update loop 2026-04-10 00:01:02 +08:00
plum 8b363e8eb4 chore(editor): 更新生产域名并调整 Devtool 配置 2026-04-09 19:49:03 +08:00
plum 43c2be9525 feat(editor): 对齐 Astral 完成数据中心接口接入与分组选择修复 2026-04-09 13:53:10 +08:00
29 changed files with 775 additions and 975 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ VITE_ENABLE_ANALYZE=true
VITE_ENABLE_CONFIG_GENERATE=true VITE_ENABLE_CONFIG_GENERATE=true
# 当前站点URL # 当前站点URL
VITE_GLOB_ORIGIN=https://editor.astraljs.com/ VITE_GLOB_ORIGIN=https://tk.astraltwin.cn/
#ws接口 #ws接口
VITE_GLOB_SOCKET_URL=wss://editor.astraljs.com/socket VITE_GLOB_SOCKET_URL=wss://tk.astraltwin.cn/api/sys/ws
Binary file not shown.
@@ -3,7 +3,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'; import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import * as monaco from 'monaco-editor'; import * as monaco from 'monaco-editor';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
@@ -74,6 +74,17 @@ onBeforeUnmount(() => {
} }
}); });
watch(
() => props.source,
value => {
if (!editor) return;
const currentValue = editor.getValue();
if (value !== currentValue) {
editor.setValue(value);
}
}
);
async function initMonaco() { async function initMonaco() {
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false, noSemanticValidation: false,
@@ -18,7 +18,7 @@ import {onMounted, ref} from "vue";
import {Copy} from "@vicons/carbon"; import {Copy} from "@vicons/carbon";
import {NIcon, NTooltip} from "naive-ui"; import {NIcon, NTooltip} from "naive-ui";
import {t} from "@/language"; import {t} from "@/language";
import {App,Hooks,AddObjectCommand} from "@astral3d/engine"; import {App,Hooks,Loader,Utils,AddObjectCommand} from "@astral3d/engine";
const disabled = ref(true); const disabled = ref(true);
@@ -36,6 +36,15 @@ function handleClone() {
//避免复制相机或场景 //避免复制相机或场景
if (object === null || object.parent === null) return; if (object === null || object.parent === null) return;
if (Utils.isHtmlPanelObject(object)) {
const _json = object.toJSON() as any;
Loader.objectLoader.parseAsync(_json).then(newObject3D => {
App.execute(new AddObjectCommand(newObject3D));
}).catch((e: Error) => window.$message?.error(e.message));
return;
}
object = object.clone(); object = object.clone();
App.execute(new AddObjectCommand(object)); App.execute(new AddObjectCommand(object));
@@ -19,7 +19,7 @@ import {
ChoroplethMap ChoroplethMap
} from '@vicons/carbon'; } from '@vicons/carbon';
import {t} from "@/language"; import {t} from "@/language";
import {App,Hooks, MoveObjectCommand, RemoveObjectCommand, AddObjectCommand} from "@astral3d/engine"; import {App,Hooks,Loader,Utils, MoveObjectCommand, RemoveObjectCommand, AddObjectCommand} from "@astral3d/engine";
import {escapeHTML, findSiblingsAndIndex} from "@/utils/common/utils"; import {escapeHTML, findSiblingsAndIndex} from "@/utils/common/utils";
import {getMaterialName} from "@/utils/common/scenes"; import {getMaterialName} from "@/utils/common/scenes";
import EsContextmenu from "@/components/es/EsContextmenu.vue"; import EsContextmenu from "@/components/es/EsContextmenu.vue";
@@ -367,6 +367,15 @@ function handleContextmenuSelect(key: string) {
if (parent !== null) App.execute(new RemoveObjectCommand(object)); if (parent !== null) App.execute(new RemoveObjectCommand(object));
break; break;
case "clone": case "clone":
if (Utils.isHtmlPanelObject(object)) {
const _json = object.toJSON() as any;
Loader.objectLoader.parseAsync(_json).then(newObject3D => {
App.execute(new AddObjectCommand(newObject3D));
}).catch((e: Error) => window.$message?.error(e.message));
break;
}
const _object = object.clone(); const _object = object.clone();
App.execute(new AddObjectCommand(_object)); App.execute(new AddObjectCommand(_object));
+1 -1
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;
} }
@@ -0,0 +1,23 @@
import {request} from "@/http/request";
export interface DataSetGroupPayload {
id?: IDataSet.IGroup["id"];
name: string;
pid: IDataSet.IGroup["pid"];
}
export function fetchDataSetGroupTree() {
return request.get<IDataSet.IGroup[]>(`/data-set-group/tree`);
}
export function fetchCreateDataSetGroup(data: DataSetGroupPayload) {
return request.post(`/data-set-group`, data);
}
export function fetchUpdateDataSetGroup(data: DataSetGroupPayload) {
return request.put(`/data-set-group`, data);
}
export function fetchDeleteDataSetGroup(id: IDataSet.IGroup["id"]) {
return request.delete(`/data-set-group/${id}`, {});
}
@@ -0,0 +1,30 @@
import {request} from "@/http/request";
export interface DataSourcePayload {
id?: IDataSource.Item["id"];
name: string;
type: string;
connectionString: string;
username?: string;
password?: string;
}
export function fetchDataSourceList() {
return request.get<IDataSource.Item[]>(`/data-source/list`);
}
export function fetchCreateDataSource(data: DataSourcePayload) {
return request.post<IDataSource.Item>(`/data-source`, data);
}
export function fetchUpdateDataSource(data: DataSourcePayload) {
return request.put<IDataSource.Item>(`/data-source`, data);
}
export function fetchDeleteDataSource(id: IDataSource.Item["id"]) {
return request.delete(`/data-source/${id}`, {});
}
export function fetchTestDataSource(data: DataSourcePayload) {
return request.post(`/data-source/test`, data);
}
+4 -3
View File
@@ -27,9 +27,10 @@ async function main() {
app.mount('#app'); app.mount('#app');
if (import.meta.env.PROD){ if (import.meta.env.PROD){
DisableDevtool({ // DisableDevtool({
clearLog:true // md5: "32981a13284db7a021131df49e6cd203",
}); // clearLog: true,
// });
} }
} }
@@ -1,8 +0,0 @@
import { injectWasm } from "@/utils/wasm/inject";
export function clearBuffer() {
injectWasm({ wasmUrl: "/wasm/Astral3DEditor.wasm" }).then(() => {
// 加载完wasm后自动注册了清除缓存的函数调用
window.clearBuffer();
});
}
@@ -4,20 +4,16 @@
<SceneTree /> <SceneTree />
</div> </div>
<n-tabs default-value="bim" animated type="line" justify-content="space-around" class="!h-60%" <n-tabs default-value="cad" animated type="line" justify-content="space-around" class="!h-60%"
pane-wrapper-class="layout-assets-tab-pane-wrapper"> pane-wrapper-class="layout-assets-tab-pane-wrapper">
<n-tab-pane name="cad" tab="CAD" display-directive="show"> <n-tab-pane name="cad" tab="CAD" display-directive="show">
<CadLibrary /> <CadLibrary />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="bim" tab="BIM" display-directive="show">
<BIMLibrary />
</n-tab-pane>
</n-tabs> </n-tabs>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import BIMLibrary from "./assets/BIMLibrary.vue";
import SceneTree from "@/components/tree/SceneTree.vue"; import SceneTree from "@/components/tree/SceneTree.vue";
import CadLibrary from "./assets/CadLibrary.vue"; import CadLibrary from "./assets/CadLibrary.vue";
</script> </script>
@@ -1,339 +0,0 @@
<template>
<div id="bim-library" class="h-full flex flex-col">
<n-alert type="info" :show-icon="false" :bordered="false" class="mb-2 mx-2">
<n-button quaternary round type="primary" @click="showHistoryModal = true">
{{ t("layout.sider.History") }}
</n-button>
<n-button quaternary circle type="primary" @click="showBIMUpload = true">
<template #icon>
<n-icon>
<CloudUpload/>
</n-icon>
</template>
</n-button>
</n-alert>
<div class="flex-1 overflow-y-auto px-0.3rem">
<div class="grid grid-cols-[repeat(auto-fill,minmax(80px,1fr))] gap-2">
<n-card size="small" hoverable v-for="item in objectList" :key="item.id" @dblclick="addToScene(item)"
draggable="true" @dragstart="dragStart($event, item)" @dragend="dragEnd">
<template #cover>
<n-spin :show="item.conversionStatus === 0">
<template #description>
正在轻量化...
</template>
<img :src="item.thumbnail ? item.thumbnail : '/static/images/placeholder/占位图.png'"
:alt="item.fileName" draggable="false">
<n-tag :color="{ color: '#F1C3CC', textColor: '#D03050' }" :bordered="false" size="small"
class="absolute top-33px w-full" v-if="item.conversionStatus === 2">
轻量化失败
<template #icon>
<n-icon>
<CloseCircleSharp/>
</n-icon>
</template>
</n-tag>
</n-spin>
</template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger> {{ item.fileName }}</template>
<span> {{ item.fileName }} </span>
</n-tooltip>
</n-card>
</div>
</div>
<div class="flex justify-center mt-0.5rem">
<n-pagination v-bind="paginationReactive"></n-pagination>
</div>
<n-modal v-model:show="showHistoryModal" class="!w-60vw" preset="dialog" display-directive="show"
:title="t('bim[\'BIM lightweight\']') + t('layout.sider.History')">
<n-data-table class="mt-20px" size="small" :loading="tableLoading" :columns="columns"
:data="objectList"></n-data-table>
<div class="flex justify-end mt-0.5rem">
<n-pagination v-bind="paginationReactive"></n-pagination>
</div>
</n-modal>
<!-- BIM文件上传 -->
<BimUploadDialog v-model:show="showBIMUpload" ref="uploadDialogRef"/>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, onMounted, h, onBeforeUnmount} from "vue";
import type {DataTableColumns} from 'naive-ui';
import {NButton, NTag, NIcon} from 'naive-ui'
import {CloudUpload, Reload, CheckmarkCircle, CloseOutline, CloseCircleSharp} from '@vicons/ionicons5';
import {t} from "@/language";
import {useDragStore} from "@/store/modules/drag";
import {fetchGetBim2GltfList} from "@/http/api/bim";
import {Bim2GltfWsData, WebSocketMessage} from "~/network";
import {Loader,Hooks} from "@astral3d/engine";
import {filterSize} from "@/utils/common/file";
import {onWebSocket, offWebSocket} from "@/hooks/useWebSocket";
import {useWebsocketStore} from "@/store/modules/websocket";
import {dateTimeFormat} from "@/utils/common/dateTime";
import BimUploadDialog from "./bimLibrary/BimUploadDialog.vue";
const websocketStore = useWebsocketStore();
let objectList = ref<IBIMData[]>([]);
const showHistoryModal = ref(false);
const tableLoading = ref(false);
const columns: DataTableColumns<IBIMData> = [
{
title: '文件名',
key: 'fileName'
},
{
title: 'bim文件体积',
key: 'bimFileSize',
render(row) {
return filterSize(row.bimFileSize);
}
},
{
title: 'gltf文件体积',
key: 'gltfFileSize',
render(row) {
return filterSize(row.gltfFileSize);
}
},
{
title: '转换时长',
key: 'conversionDuration',
render(row) {
return row.conversionDuration.toFixed(2) + "s";
}
},
{
title: '状态',
key: 'conversionStatus',
render(row) {
return h(
NTag,
{
bordered: false,
type: row.conversionStatus === 0 ? "warning" : row.conversionStatus === 1 ? "success" : "error",
},
{
default: () => row.conversionStatus === 0 ? "转换中" : row.conversionStatus === 1 ? "成功" : "失败",
icon: () => h(
NIcon, {
component: row.conversionStatus === 0 ? Reload : row.conversionStatus === 1 ? CheckmarkCircle : CloseOutline
}
)
}
)
}
},
{
title: '操作',
key: 'actions',
render(row) {
if (row.conversionStatus !== 1) return "";
return h(
NButton,
{
size: 'small',
onClick: () => addToScene(row)
},
{default: () => t("other.Load")}
)
}
}
];
let paginationReactive = reactive({
page: 1,
pageSize: 10,
pageCount: 1,
"on-update:page": (page: number) => {
paginationReactive.page = page;
getBim2GltfList();
}
})
const showBIMUpload = ref(false);
const uploadDialogRef = ref();
// 获取BIM转换列表
async function getBim2GltfList() {
const res = await fetchGetBim2GltfList({
offset: (paginationReactive.page - 1) * paginationReactive.pageSize,
limit: paginationReactive.pageSize
});
objectList.value = res.data?.items || [];
paginationReactive.pageCount = res.data?.pages || 1;
}
async function addToScene(item) {
showHistoryModal.value = false;
let notice = window.$notification.info({
title: window.$t("scene['Get the scene data']") + "...",
content: window.$t("other.Loading") + "...",
closable: false,
})
// 从gltfFilePath字段去服务器获取gltf文件
fetch(`file/static/${item.gltfFilePath}`)
.then(res => res.blob())
.then(data => {
const file = new File([data as Blob], `${item.fileName}.glb`, {type: 'model/gltf-binary'});
notice.content = window.$t("scene['Parsing to editor']");
Loader.loadFiles([file], undefined).finally(() => {
setTimeout(() => {
notice.destroy();
Hooks.useDispatchSignal("sceneGraphChanged");
}, 800)
Hooks.useDispatchSignal("sceneGraphChanged");
})
})
.catch(() => {
notice.content = window.$t("scene['Failed to get scene data']");
setTimeout(() => {
notice.destroy();
}, 500)
return null;
})
}
// 开始拖拽事件
const dragStore = useDragStore();
function dragStart(_, item) {
if (item.conversionStatus !== 1) return;
dragStore.setData(item)
}
function dragEnd() {
if (dragStore.getActionTarget !== "addToScene") return;
const data = dragStore.getData
if (data.conversionStatus !== 1) return;
addToScene(data);
dragStore.setActionTarget("");
}
// websocket监听 bim2gltf 消息
function Bim2GltfWsHandle(data: WebSocketMessage<Bim2GltfWsData>) {
if (data.type === "bim2gltf") {
// 判断订阅者,以确定是自己还是别人轻量化的结果
if (data.subscriber === websocketStore.uname) {
let wsNotice = uploadDialogRef.value?.getNotice();
// 如果是自己的轻量化结果,使用用户登录结果作为uname,则用户可能关闭页面再打开时接收到消息,应继续显示
if (!wsNotice) {
wsNotice = window.$notification.info({
title: t("bim['BIM lightweight']"),
content: "",
closable: false,
})
}
if (data.data.conversionStatus === "progress") {
wsNotice.content = t("bim['BIM lightweight is in progress']") + "...";
} else if (data.data.conversionStatus === "completed") {
wsNotice.content = `${t("bim['BIM lightweight completed']")},${t("bim.In")} ${data.data.item.conversionDuration} ${t("bim.seconds")}`;
setTimeout(() => {
wsNotice?.destroy();
window.$dialog.info({
title: t("bim['BIM lightweight completed']"),
content: t("bim['Whether to load the BIM model into the scene?']"),
positiveText: window.$t('other.Load'),
negativeText: window.$t('other.Cancel'),
onPositiveClick: () => {
addToScene(data.data.item);
}
})
}, 800)
getBim2GltfList();
} else if (data.data.conversionStatus === "failed") {
wsNotice.content = t("bim['BIM lightweight failed']");
setTimeout(() => {
wsNotice?.destroy();
}, 1500)
getBim2GltfList();
}
} else {
if (data.data.conversionStatus !== "completed") return;
// 别人的轻量化结果
const n = window.$notification.info({
title: t("bim['BIM lightweight']"),
content: t("bim['New lightweight BIM model received, do you want to view it?']"),
duration: 5000,
closable: true,
meta: dateTimeFormat("yyyy-MM-dd HH:mm:ss"),
action: () =>
h(NButton, {
text: true,
type: 'primary',
onClick: () => {
addToScene(data.data.item);
n.destroy();
}
},
{
default: () => t("other.Load")
}
),
})
}
}
}
onMounted(() => {
getBim2GltfList();
// 注册websocket消息监听
onWebSocket(Bim2GltfWsHandle);
})
onBeforeUnmount(() => {
offWebSocket(Bim2GltfWsHandle)
})
</script>
<style scoped lang="less">
#bim-library {
overflow-x: hidden;
.n-alert {
:deep(.n-alert-body) {
padding: 10px;
&__content {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}
.n-card {
cursor: pointer;
:deep(.n-card-cover) {
img {
height: 89px;
object-fit: cover;
}
}
:deep(.n-card__content) {
padding: 3px 5px;
font-size: 13px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>
@@ -1,277 +0,0 @@
<template>
<n-modal :show="show" @update:show="(b) => emits('update:show',b)" class="!w-500px" preset="card" :title="t('bim.BIM lightweight')">
<n-form label-placement="left" :model="BIMModel" :rules="BIMRules"
label-width="100px" label-align="right" ref="formRef"
require-mark-placement="right-hanging">
<!-- <n-form-item :label="t('bim[\'File name\']')" path="fileName">-->
<!-- <n-input v-model:value="BIMModel.fileName"-->
<!-- :placeholder="t('bim[\'Please enter the BIM file name\']')"/>-->
<!-- </n-form-item>-->
<n-form-item :label="t('bim.Thumbnail')">
<n-upload :default-upload="false" list-type="image-card" :max="1"
@change="thumbnailChange"/>
</n-form-item>
<n-form-item :label="t('bim[\'BIM file\']')" path="bimFile">
<n-upload ref="uploadBIMRef" :default-upload="false" directory-dnd :max="1"
:accept="'.' + NEED_CONVERT_BIM_MODEL.join(',.')" @change="bimChange">
<n-upload-dragger>
<div>
<n-icon size="48" :depth="3">
<ArchiveOutline/>
</n-icon>
</div>
<n-text style="font-size: 14px">
{{
t("bim['Click or drag the file to this area.Supported formats are:']") + ` ${NEED_CONVERT_BIM_MODEL.join("、")}`
}}
</n-text>
</n-upload-dragger>
</n-upload>
</n-form-item>
<n-collapse>
<template #arrow>
<n-icon>
<CaretForwardOutline />
</n-icon>
</template>
<n-collapse-item :title="t('bim[\'Conversion configuration\']')">
<!-- 极致轻量化 -->
<n-form-item :label="t('bim[\'Extreme lightweight\']')">
<n-switch v-model:value="BIMModel.options.Optimize" @change="handleOptimizeChange" />
</n-form-item>
<!-- 导出属性 -->
<n-form-item :label="t('bim[\'Export Property\']')">
<n-switch v-model:value="BIMModel.options.ExportProperty" :disabled="exportPropertyDisabled" />
</n-form-item>
<!-- 转换视图 -->
<n-form-item :label="t('bim[\'Conversion view\']')">
<n-radio-group v-model:value="BIMModel.options.View" name="View">
<n-radio-button value="Default">默认3D视图</n-radio-button>
<n-radio-button value="Name">按名称</n-radio-button>
</n-radio-group>
</n-form-item>
<!-- 视图名称 -->
<n-form-item :label="t('bim[\'View name\']')" v-if="BIMModel.options.View === 'Name'">
<n-input v-model:value="BIMModel.options.ViewName" :placeholder="t('bim[\'Please enter the conversion view name\']')"/>
</n-form-item>
<!-- 视觉样式 -->
<n-form-item :label="t('bim[\'Display style\']')">
<n-radio-group v-model:value="BIMModel.options.DisplayStyle" name="DisplayStyle">
<n-radio-button value="Colour">{{t("bim.Colour")}}</n-radio-button>
<n-radio-button value="Realistic">{{t("bim.Realistic")}}</n-radio-button>
<n-radio-button value="ViewDefault">{{t("bim['View default']")}}</n-radio-button>
</n-radio-group>
</n-form-item>
<!-- 坐标参考 -->
<n-form-item :label="t('bim[\'Coordinate reference\']')">
<n-radio-group v-model:value="BIMModel.options.CoordinateReference" name="CoordinateReference">
<n-radio-button value="Origin">{{t("bim.Origin")}}</n-radio-button>
<n-radio-button value="ProjectBasePoint">{{t("bim['Project base point']")}}</n-radio-button>
<n-radio-button value="MeasuringPoint">{{t("bim['Measuring point']")}}</n-radio-button>
</n-radio-group>
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-form>
<div class="flex justify-end">
<n-button round type="primary" @click="submit">{{ t("bim['Upload and lightweight']") }}</n-button>
</div>
</n-modal>
</template>
<script setup lang="ts">
import {reactive, ref} from "vue";
import {NotificationReactive} from "naive-ui";
import {t} from "@/language";
import {ArchiveOutline, CaretForwardOutline} from "@vicons/ionicons5";
import {fetchUpload} from "@/http/api/sys";
import {fetchAddBim2Gltf, fetchUploadRvt} from "@/http/api/bim";
import {NEED_CONVERT_BIM_MODEL} from "@/utils/common/constant";
withDefaults(defineProps<{
show:boolean
}>(),{
show:false
})
const emits = defineEmits(["update:show"]);
const formRef = ref();
const uploadBIMRef = ref();
const exportPropertyDisabled = ref(false);
/* bim文件上传转换 */
const BIMModel = reactive<{
fileName: string,
thumbnail: File | null,
bimFile: File | null,
options:{
// 极致压缩率(会执行实例化网格,破坏模型结构)
Optimize:boolean,
// 是否导出属性
ExportProperty:boolean,
// 转换视图 - Default:默认3D视图 | Name:按传入名称查找视图
View:string,
// 转换视图名称 - View = "Name"时输入有效
ViewName:string,
// 转换视觉样式 - Colour:着色模式 | Realistic-真实模式 | ViewDefault-视图默认
DisplayStyle:string,
// 坐标参考 - Origin:默认原点 | ProjectBasePoint:项目基点 | MeasuringPoint:测量点
CoordinateReference:string
}
}>({
fileName: "",
thumbnail: null,
bimFile: null,
options:{
Optimize:false,
ExportProperty:true,
View:"Default",
ViewName:"",
DisplayStyle:"Colour",
CoordinateReference:"Origin"
}
})
const BIMRules = {
bimFile: {
required: true,
trigger: 'change',
validator: (_, value) => {
return new Promise<void>((resolve, reject) => {
// 判断value是否是File类型
if (value === '' || !(value instanceof File)) {
reject(Error(t("bim['Please upload the BIM file']")))
} else {
resolve()
}
})
}
}
}
// 预览图变化
function thumbnailChange({file}) {
if (file.status === "removed") {
BIMModel.thumbnail = null;
return;
}
BIMModel.thumbnail = file.file as File;
}
// bim文件选择变化
function bimChange({file}) {
if (file.status === "removed") {
BIMModel.bimFile = null;
formRef.value?.validate();
return;
}
if (!NEED_CONVERT_BIM_MODEL.includes(file.name.split(".").at(-1).toLowerCase())) {
window.$message?.error(t("prompt['This format is not supported, please upload again! Supported formats are:']") + ` ${NEED_CONVERT_BIM_MODEL.join("、")}`);
uploadBIMRef.value.clear();
return false
}
BIMModel.bimFile = file.file as File;
BIMModel.fileName = BIMModel.bimFile.name;
formRef.value?.validate();
}
// 极致轻量化选项变化(导出属性选项跟随变化)
function handleOptimizeChange() {
if(BIMModel.options.Optimize){
BIMModel.options.ExportProperty = false;
exportPropertyDisabled.value = true;
}else{
exportPropertyDisabled.value = false;
}
}
// ws 监听使用的notice
let wsNotice: null | NotificationReactive = null;
const getNotice = () => wsNotice;
// 提交
function submit(e) {
if (import.meta.env.PROD) {
window.$message?.error(window.$t("prompt['Disable this function in the demonstration environment!']"));
return;
}
e.preventDefault();
formRef.value?.validate(async (errors) => {
if (!errors) {
emits("update:show",false);
wsNotice = window.$notification.info({
title: t("bim['BIM lightweight']"),
content: t("prompt.Uploading") + "...",
closable: false,
})
// 1. 上传缩略图
let thumbnail = "";
if (BIMModel.thumbnail) {
const res = await fetchUpload({
file: BIMModel.thumbnail,
biz: "upload/bim/thumbnail"
})
if (res.data === null) {
window.$message?.error(t("bim['Failed to upload thumbnail']"));
} else {
thumbnail = res.data as string;
}
}
// 2. 上传bim文件
const bimRes = await fetchUploadRvt({
file: BIMModel.bimFile,
});
if (bimRes.data === null) {
window.$message?.error(t("bim['Failed to upload BIM file']"));
wsNotice.content = `${t("bim['Failed to upload BIM file']")},${t("prompt['Please try again later!']")}`;
setTimeout(() => {
wsNotice?.destroy();
}, 1000)
return;
}
//3 启动Revit轻量化服务
wsNotice.content = t("bim['BIM lightweight is in progress']") + "...";
await fetchAddBim2Gltf({
bimFilePath: bimRes.data,
bimFileSize: (BIMModel.bimFile as File).size,
fileName: BIMModel.fileName,
fileSourceIp: "",
thumbnail,
conversionStatus: 0,
// 转换配置
options:BIMModel.options
})
// reset
BIMModel.fileName = "";
BIMModel.thumbnail = null;
BIMModel.bimFile = null;
BIMModel.options = {
Optimize:false,
ExportProperty:true,
View:"Default",
ViewName:"",
DisplayStyle:"Colour",
CoordinateReference:"Origin"
}
}
})
}
defineExpose({getNotice})
</script>
<style scoped lang="less">
.n-form-item{
margin-bottom: 10px;
}
</style>
@@ -29,6 +29,22 @@ onBeforeUnmount(() => {
Hooks.useRemoveSignal("objectSelected", updateUI); Hooks.useRemoveSignal("objectSelected", updateUI);
}) })
function ensureHtmlPanelOptions(object: any) {
const options = object?.options || {};
const codes = Array.isArray(options.codes) ? options.codes : [];
const isSingleHtml = typeof options.isSingleHtml === 'boolean' ? options.isSingleHtml : true;
const isSprite = typeof options.isSprite === 'boolean' ? options.isSprite : !!object?.isHtmlSprite;
object.options = {
...options,
codes,
isSingleHtml,
isSprite
};
return object.options;
}
function updateUI() { function updateUI() {
const object = App.selected; const object = App.selected;
if(!object) return; if(!object) return;
@@ -39,7 +55,8 @@ function updateUI() {
} }
data.type = object.isHtmlPanel? 'panel' :'sprite'; data.type = object.isHtmlPanel? 'panel' :'sprite';
data.codes = object.options.codes; const options = ensureHtmlPanelOptions(object);
data.codes = options.codes;
} }
function update(method: string){ function update(method: string){
@@ -53,8 +70,9 @@ function update(method: string){
if(data.type === 'panel' && object.isHtmlPanel) return; if(data.type === 'panel' && object.isHtmlPanel) return;
if(data.type === 'sprite' && object.isHtmlSprite) return; if(data.type === 'sprite' && object.isHtmlSprite) return;
const _json = object.toJSON(); const _json = object.toJSON() as any;
_json.object.type = data.type === 'panel' ? 'HtmlSprite' : 'HtmlPanel'; _json.object.type = data.type === 'panel' ? 'HtmlSprite' : 'HtmlPanel';
_json.object.options = _json.object.options || {};
_json.object.options.isSprite = !_json.object.options.isSprite; _json.object.options.isSprite = !_json.object.options.isSprite;
Loader.objectLoader.parseAsync(_json).then(newObject3D => { Loader.objectLoader.parseAsync(_json).then(newObject3D => {
@@ -73,9 +91,11 @@ function update(method: string){
_code.content = editorCode.code; _code.content = editorCode.code;
const options = ensureHtmlPanelOptions(object);
const htmlPanelOption = { const htmlPanelOption = {
isSprite: object.options.isSprite, isSprite: options.isSprite,
isSingleHtml:object.options.isSingleHtml, isSingleHtml: options.isSingleHtml,
codes: data.codes codes: data.codes
} }
@@ -135,19 +155,26 @@ function updateFileList(fList: UploadFileInfo[]) {
isSprite: data.type ==='sprite', isSprite: data.type ==='sprite',
fileName: file.name fileName: file.name
}).then(htmlPlaneObj => { }).then(htmlPlaneObj => {
data.codes = htmlPlaneObj.options.codes; const options = ensureHtmlPanelOptions(object);
const _json = object.toJSON(); options.codes = htmlPlaneObj.options.codes;
Loader.objectLoader.copyAttrByData(htmlPlaneObj, _json.object) options.isSingleHtml = htmlPlaneObj.options.isSingleHtml;
object.parent?.add(htmlPlaneObj); data.codes = options.codes;
object.parent?.remove(object); // Keep object identity (id/uuid) so scene tree and context menu references stay valid.
while (object.element.firstChild) {
object.element.removeChild(object.element.firstChild);
}
App.selected = htmlPlaneObj; while (htmlPlaneObj.element.firstChild) {
object.element.appendChild(htmlPlaneObj.element.firstChild);
}
Hooks.useDispatchSignal("objectSelected", htmlPlaneObj); App.selected = object;
}).catch((e:Error) => window.$message?.error(e.message)); Hooks.useDispatchSignal("objectSelected", object);
}).catch((e:Error) => window.$message?.error(e.message))
.finally(() => URL.revokeObjectURL(tempURL));
} }
function getCanEdit(name: string) { function getCanEdit(name: string) {
@@ -121,15 +121,15 @@ const updateUI = Utils.throttle(function(object) {
objectData.type = object.type; objectData.type = object.type;
objectData.uuid = object.uuid; objectData.uuid = object.uuid;
objectData.name = object.name; objectData.name = object.name;
objectData.position.x = Number(object.position.x.toFixed(3)); objectData.position.x = Number(object.position.x.toFixed(6));
objectData.position.y = Number(object.position.y.toFixed(3)); objectData.position.y = Number(object.position.y.toFixed(6));
objectData.position.z = Number(object.position.z.toFixed(3)); objectData.position.z = Number(object.position.z.toFixed(6));
objectData.rotation.x = Number((object.rotation.x * THREE.MathUtils.RAD2DEG).toFixed(3)); objectData.rotation.x = Number((object.rotation.x * THREE.MathUtils.RAD2DEG).toFixed(6));
objectData.rotation.y = Number((object.rotation.y * THREE.MathUtils.RAD2DEG).toFixed(3)); objectData.rotation.y = Number((object.rotation.y * THREE.MathUtils.RAD2DEG).toFixed(6));
objectData.rotation.z = Number((object.rotation.z * THREE.MathUtils.RAD2DEG).toFixed(3)); objectData.rotation.z = Number((object.rotation.z * THREE.MathUtils.RAD2DEG).toFixed(6));
objectData.scale.x = Number(object.scale.x.toFixed(3)); objectData.scale.x = Number(object.scale.x.toFixed(6));
objectData.scale.y = Number(object.scale.y.toFixed(3)); objectData.scale.y = Number(object.scale.y.toFixed(6));
objectData.scale.z = Number(object.scale.z.toFixed(3)); objectData.scale.z = Number(object.scale.z.toFixed(6));
if (object.fov !== undefined) { if (object.fov !== undefined) {
objectData.fov = object.fov; objectData.fov = object.fov;
@@ -225,19 +225,19 @@ const update = (method: string) => {
}, },
position: () => { position: () => {
const newPosition = new THREE.Vector3(objectData.position.x, objectData.position.y, objectData.position.z); const newPosition = new THREE.Vector3(objectData.position.x, objectData.position.y, objectData.position.z);
if (object.position.distanceTo(newPosition) >= 0.01) { if (object.position.distanceTo(newPosition) >= 0.0001) {
App.execute(new SetPositionCommand(object, newPosition)); App.execute(new SetPositionCommand(object, newPosition));
} }
}, },
rotation: () => { rotation: () => {
const newRotation = new THREE.Euler(objectData.rotation.x * THREE.MathUtils.DEG2RAD, objectData.rotation.y * THREE.MathUtils.DEG2RAD, objectData.rotation.z * THREE.MathUtils.DEG2RAD); const newRotation = new THREE.Euler(objectData.rotation.x * THREE.MathUtils.DEG2RAD, objectData.rotation.y * THREE.MathUtils.DEG2RAD, objectData.rotation.z * THREE.MathUtils.DEG2RAD);
if (new THREE.Vector3().setFromEuler(object.rotation).distanceTo(new THREE.Vector3().setFromEuler(newRotation)) >= 0.01) { if (new THREE.Vector3().setFromEuler(object.rotation).distanceTo(new THREE.Vector3().setFromEuler(newRotation)) >= 0.0001) {
App.execute(new SetRotationCommand(object, newRotation, undefined)); App.execute(new SetRotationCommand(object, newRotation, undefined));
} }
}, },
scale: () => { scale: () => {
const newScale = new THREE.Vector3(objectData.scale.x, objectData.scale.y, objectData.scale.z); const newScale = new THREE.Vector3(objectData.scale.x, objectData.scale.y, objectData.scale.z);
if (object.scale.distanceTo(newScale) >= 0.01) { if (object.scale.distanceTo(newScale) >= 0.0001) {
App.execute(new SetScaleCommand(object, newScale, undefined)); App.execute(new SetScaleCommand(object, newScale, undefined));
} }
}, },
@@ -433,11 +433,11 @@ const handleUserDataClick = () => {
<div class="sider-scene-attr-item"> <div class="sider-scene-attr-item">
<EsKeyFrame :label="t('layout.sider.object.position')" attr="position" /> <EsKeyFrame :label="t('layout.sider.object.position')" attr="position" />
<div class="flex"> <div class="flex">
<EsInputNumber v-model:value="objectData.position.x" size="tiny" :show-button="false" :decimal="3" :step="1" <EsInputNumber v-model:value="objectData.position.x" size="tiny" :show-button="false" :decimal="6" :step="1"
@change="update('position')" /> @change="update('position')" />
<EsInputNumber v-model:value="objectData.position.y" size="tiny" :show-button="false" :decimal="3" :step="1" <EsInputNumber v-model:value="objectData.position.y" size="tiny" :show-button="false" :decimal="6" :step="1"
@change="update('position')" /> @change="update('position')" />
<EsInputNumber v-model:value="objectData.position.z" size="tiny" :show-button="false" :decimal="3" :step="1" <EsInputNumber v-model:value="objectData.position.z" size="tiny" :show-button="false" :decimal="6" :step="1"
@change="update('position')" /> @change="update('position')" />
</div> </div>
</div> </div>
@@ -445,11 +445,11 @@ const handleUserDataClick = () => {
<div class="sider-scene-attr-item" v-if="transformRowsVisible.rotation"> <div class="sider-scene-attr-item" v-if="transformRowsVisible.rotation">
<EsKeyFrame :label="t('layout.sider.object.rotation')" attr="quaternion" /> <EsKeyFrame :label="t('layout.sider.object.rotation')" attr="quaternion" />
<div class="flex"> <div class="flex">
<EsInputNumber v-model:value="objectData.rotation.x" size="tiny" :decimal="2" :step="1" :show-button="false" <EsInputNumber v-model:value="objectData.rotation.x" size="tiny" :decimal="6" :step="1" :show-button="false"
@change="update('rotation')" unit="°" /> @change="update('rotation')" unit="°" />
<EsInputNumber v-model:value="objectData.rotation.y" size="tiny" :decimal="2" :step="1" :show-button="false" <EsInputNumber v-model:value="objectData.rotation.y" size="tiny" :decimal="6" :step="1" :show-button="false"
@change="update('rotation')" unit="°" /> @change="update('rotation')" unit="°" />
<EsInputNumber v-model:value="objectData.rotation.z" size="tiny" :decimal="2" :step="1" :show-button="false" <EsInputNumber v-model:value="objectData.rotation.z" size="tiny" :decimal="6" :step="1" :show-button="false"
@change="update('rotation')" unit="°" /> @change="update('rotation')" unit="°" />
</div> </div>
</div> </div>
@@ -1,65 +0,0 @@
<template>
<n-card v-if="visible" class="absolute top-40px right-1 max-w-300px" content-style="padding: 5px 10px;">
<n-collapse accordion default-expanded-names="bim">
<template #arrow="{ collapsed }">
<n-icon v-if="collapsed">
<PlanetOutline/>
</n-icon>
<n-icon v-else>
<SunnyOutline/>
</n-icon>
</template>
<n-collapse-item title="&nbsp;BIM 构件信息" name="bim">
<div class="max-h-360px overflow-y-auto">
<n-descriptions v-for="(item, index) in info.parameters" :key="index" label-placement="left"
bordered size="small" :column="1">
<template #header>
<p class="py-2">{{ item.GroupName }}</p>
</template>
<n-descriptions-item v-for="(it, i) in item.Parameters" :key="i + it.name" :label="it.name">
{{ it.value }}
</n-descriptions-item>
</n-descriptions>
</div>
</n-collapse-item>
</n-collapse>
</n-card>
</template>
<script setup lang="ts">
import {PlanetOutline, SunnyOutline} from "@vicons/ionicons5";
import {ref, onBeforeUnmount, onMounted} from "vue";
import {Hooks} from "@astral3d/engine";
const visible = ref(false);
const info = ref({
parameters: [{GroupName: "", Parameters: [{name: "", value: ""}]}]
})
function objectSelected(object) {
if (!object) {
visible.value = false;
return;
}
if (!object.userData.BIM) {
if (!object.parent || !object.parent.userData.BIM) {
visible.value = false;
} else {
visible.value = true;
info.value = object.parent.userData.BIM;
}
} else {
visible.value = true;
info.value = object.userData.BIM;
}
}
onMounted(() => {
Hooks.useAddSignal("objectSelected", objectSelected);
})
onBeforeUnmount(() => {
Hooks.useRemoveSignal("objectSelected", objectSelected);
})
</script>
@@ -6,9 +6,6 @@
<ViewportInfo/> <ViewportInfo/>
</div> </div>
<!-- RVT BIM 构件信息悬浮框 -->
<BIMProperties/>
<!-- IFC BIM 构件信息悬浮框 --> <!-- IFC BIM 构件信息悬浮框 -->
<IFCProperties/> <IFCProperties/>
@@ -18,15 +15,13 @@
<script setup lang="ts"> <script setup lang="ts">
import {onMounted, ref, nextTick, onBeforeUnmount} from 'vue'; import {onMounted, ref, nextTick, onBeforeUnmount} from 'vue';
import {App,Viewer,Hooks} from "@astral3d/engine"; import {App,Viewer,Hooks,WaterPool} from "@astral3d/engine";
import Toolbar from "./Toolbar.vue"; import Toolbar from "./Toolbar.vue";
import PathDrawingOverlay from "./PathDrawingOverlay.vue"; import PathDrawingOverlay from "./PathDrawingOverlay.vue";
import {useGlobalConfigStore} from "@/store/modules/globalConfig"; import {useGlobalConfigStore} from "@/store/modules/globalConfig";
import {usePluginStore} from "@/store/modules/plugin"; import {usePluginStore} from "@/store/modules/plugin";
import {installBuiltinPlugin} from "@/plugin"; import {installBuiltinPlugin} from "@/plugin";
import { clearBuffer } from "@/utils/wasm/optimize";
import ViewportInfo from "./ViewportInfo.vue"; import ViewportInfo from "./ViewportInfo.vue";
import BIMProperties from "./BIMProperties.vue";
import IFCProperties from "./IFCProperties.vue"; import IFCProperties from "./IFCProperties.vue";
const globalStore = useGlobalConfigStore(); const globalStore = useGlobalConfigStore();
@@ -34,6 +29,43 @@ const pluginStore = usePluginStore();
const viewportRef = ref(); const viewportRef = ref();
function bootstrapWaterPoolWarmup(viewer: Viewer) {
const warmupPool = new WaterPool({
sky: viewer.scene.environment,
name: '__water_pool_warmup__',
type: 'cylinder',
light: [0.7, 1, -0.3],
diameter: 5,
height: 5,
wallMode: 'none',
wallOpacity: 0,
useSceneRefraction: 1,
surfaceTransmittance: 0.6,
normalStrength: 0.5,
refractionStrength: 0.035,
});
warmupPool.visible = false;
// Use scene.add/remove directly to avoid objectAdded/objectRemoved signals and scene tree refresh.
viewer.scene.add(warmupPool);
const removeWarmupPool = () => {
if (warmupPool.parent) {
warmupPool.parent.remove(warmupPool);
}
warmupPool.dispose?.();
};
const loaded = (warmupPool as any).loaded;
if (loaded && typeof loaded.then === 'function') {
loaded.finally(removeWarmupPool);
return;
}
removeWarmupPool();
}
onMounted(async () => { onMounted(async () => {
App.setConfig({ App.setConfig({
theme: globalStore.theme.replace("Theme", ""), theme: globalStore.theme.replace("Theme", ""),
@@ -52,6 +84,8 @@ onMounted(async () => {
await nextTick(); await nextTick();
bootstrapWaterPoolWarmup(window.viewer);
// 添加astral engine内置插件并监听插件注册 // 添加astral engine内置插件并监听插件注册
pluginStore.setPlugins(Array.from(window.viewer.modules.plugin.plugins.values())); pluginStore.setPlugins(Array.from(window.viewer.modules.plugin.plugins.values()));
Hooks.useAddSignal("pluginInstall",pluginStore.addPlugin); Hooks.useAddSignal("pluginInstall",pluginStore.addPlugin);
@@ -59,9 +93,6 @@ onMounted(async () => {
// 注册astral editor内置插件 // 注册astral editor内置插件
installBuiltinPlugin(window.viewer); installBuiltinPlugin(window.viewer);
// 清理wasm缓存
clearBuffer();
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -21,7 +21,7 @@
</n-split> --> </n-split> -->
<n-flex class="h-full w-full"> <n-flex class="h-full w-full">
<DataSetGroup class="w-50 min-w-300px max-w-500px" /> <DataSetGroup class="w-50 min-w-300px max-w-500px" @select="handleGroupSelect" />
<n-divider vertical class="!h-full" /> <n-divider vertical class="!h-full" />
@@ -34,9 +34,9 @@
</template> </template>
</n-input> </n-input>
<n-button type="primary" @click="showDataSetModal = true">{{ t('home.Add data set') }}</n-button> <n-button type="primary" @click="handleAddDataSet">{{ t('home.Add data set') }}</n-button>
</div> </div>
<n-data-table ref="tableRef" :columns="dataSetColumns" :data="dataSets" :pagination="pagination" /> <n-data-table :columns="dataSetColumns" :data="dataSets" :pagination="pagination" :loading="tableLoading" />
</div> </div>
</n-flex> </n-flex>
@@ -45,29 +45,32 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, h, onMounted, useTemplateRef } from 'vue'; import { ref, reactive, h, onMounted } from 'vue';
import { NButton, NPopconfirm } from 'naive-ui'; import { NButton, NPopconfirm } from 'naive-ui';
import type { DataTableInst } from 'naive-ui';
import { Search } from '@vicons/carbon'; import { Search } from '@vicons/carbon';
import { t } from "@/language"; import { t } from "@/language";
import { fetchDataSetDetail, fetchDataSetPage, fetchDeleteDataSet } from "@/http/api/dataSet";
import DataSetModal from "./DataSetModal.vue"; import DataSetModal from "./DataSetModal.vue";
import DataSetGroup from "./DataSetGroup.vue"; import DataSetGroup from "./DataSetGroup.vue";
const tableRef = useTemplateRef<DataTableInst>("tableRef"); const dataSets = ref<IDataSet.Item[]>([])
const dataSets = ref<IDataSet.Item[]>([ const tableLoading = ref(false);
{
id: '',
groupId:"1",
name: '323324234',
type: 'SQL'
}
])
const showDataSetModal = ref(false) const showDataSetModal = ref(false)
const currentDataSet = reactive<IDataSet.Item>({ const searchName = ref("");
const selectedGroupId = ref<IDataSet.IGroup["id"] | null>(null);
const defaultDataSet: IDataSet.Item = {
id: '', id: '',
groupId:"1", groupId: '',
name: '', name: '',
type: 'SQL' type: 'API',
method: 'GET',
api: '',
dataSourceId: '',
sql: '',
json: ''
};
const currentDataSet = reactive<IDataSet.Item>({
...defaultDataSet
}) })
const dataSetColumns = [ const dataSetColumns = [
{ {
@@ -119,23 +122,75 @@ const dataSetColumns = [
} }
] ]
// 分页配置 // 分页配置
const pagination = { pageSize: 10 } const pagination = reactive({
page: 1,
pageSize: 10,
pageCount: 1,
"on-update:page": (page: number) => {
pagination.page = page;
getList();
}
})
function getList() { } function resetCurrentDataSet(data?: Partial<IDataSet.Item>) {
Object.assign(currentDataSet, defaultDataSet, data || {});
function handleSearch(searchText: string) {
tableRef.value?.filter({
name: [searchText]
})
} }
function editDataSet(item) { async function getList() {
Object.assign(currentDataSet, item) tableLoading.value = true;
const res = await fetchDataSetPage({
page: pagination.page,
pageSize: pagination.pageSize,
name: searchName.value || undefined,
groupId: selectedGroupId.value ?? undefined
});
tableLoading.value = false;
dataSets.value = res.data?.items || [];
pagination.pageCount = res.data?.pages || 1;
if (res.data?.current) {
pagination.page = res.data.current;
}
}
function handleSearch(searchText: string) {
searchName.value = searchText;
pagination.page = 1;
getList();
}
function handleGroupSelect(group: IDataSet.IGroup | null) {
selectedGroupId.value = group?.id ?? null;
pagination.page = 1;
getList();
}
function handleAddDataSet() {
resetCurrentDataSet({
groupId: selectedGroupId.value ?? ""
});
showDataSetModal.value = true;
}
async function editDataSet(item) {
const res = await fetchDataSetDetail(item.id);
if (res.error) {
return;
}
const detail = (res.data || item) as any;
resetCurrentDataSet({
...detail,
dataSourceId: detail.dataSourceId || detail.dataSource ? String(detail.dataSourceId || detail.dataSource) : ""
});
showDataSetModal.value = true showDataSetModal.value = true
} }
function deleteDataSet(item) { async function deleteDataSet(item) {
dataSets.value = dataSets.value.filter(ds => ds.id !== item.id) const res = await fetchDeleteDataSet(item.id);
if (res.error) {
return;
}
window.$message?.success(t("prompt.Success to delete"));
getList();
} }
onMounted(() => { onMounted(() => {
@@ -34,49 +34,20 @@ import { ref, reactive,onMounted } from "vue";
import type { DropdownOption, TreeOption, TreeOverrideNodeClickBehaviorReturn } from 'naive-ui'; import type { DropdownOption, TreeOption, TreeOverrideNodeClickBehaviorReturn } from 'naive-ui';
import { Search, Add } from '@vicons/carbon'; import { Search, Add } from '@vicons/carbon';
import { t } from "@/language"; import { t } from "@/language";
import { fetchDataSetGroupTree, fetchDeleteDataSetGroup } from "@/http/api/dataSetGroup";
import DataSetGroupModal from "./DataSetGroupModal.vue"; import DataSetGroupModal from "./DataSetGroupModal.vue";
const pattern = ref(""); const emits = defineEmits(["select"]);
const data: IDataSet.IGroup[] = [
{
name: '0',
id: '0',
children: [
{
name: '0-0',
id: '0-0',
children: [
{ name: '0-0-0', id: '0-0-0' },
{ name: '0-0-1', id: '0-0-1' }
]
},
{
name: '0-1',
id: '0-1',
children: [
{ name: '0-1-0', id: '0-1-0' },
{ name: '0-1-1', id: '0-1-1' }
]
}
]
},
{
name: '1',
id: '1',
children: [
{
name: '1-0',
id: '1-0'
},
{
name: '1-1',
id: '1-1'
}
]
}
]
function getTreeData(){} const pattern = ref("");
const data = ref<IDataSet.IGroup[]>([]);
const selectedGroup = ref<IDataSet.IGroup | null>(null);
const activeNode = ref<IDataSet.IGroup | null>(null);
async function getTreeData(){
const res = await fetchDataSetGroupTree();
data.value = res.data || [];
}
// 子节点选中,含子节点则展开 // 子节点选中,含子节点则展开
function handleOverride({ option }): TreeOverrideNodeClickBehaviorReturn { function handleOverride({ option }): TreeOverrideNodeClickBehaviorReturn {
@@ -96,7 +67,7 @@ const currentGroup = reactive<IDataSet.IGroup>({
function handleAddGroup(){ function handleAddGroup(){
currentGroup.id = ""; currentGroup.id = "";
currentGroup.pid = ""; currentGroup.pid = selectedGroup.value?.id ?? "";
currentGroup.name = ""; currentGroup.name = "";
showGroupModal.value = true; showGroupModal.value = true;
@@ -107,8 +78,7 @@ const showDropdown = ref(false);
const options = ref<DropdownOption[]>([ const options = ref<DropdownOption[]>([
{ {
label: t("home.Delete"), label: t("home.Delete"),
key: 'delete', key: 'delete'
disabled: true
}, },
{ {
label: t("layout.sider.script.Edit"), label: t("layout.sider.script.Edit"),
@@ -120,9 +90,15 @@ const y = ref(0)
// 绑定节点属性以实现右键菜单 // 绑定节点属性以实现右键菜单
function handleNodeProps({ option }: { option: TreeOption }) { function handleNodeProps({ option }: { option: TreeOption }) {
const group = option as IDataSet.IGroup;
return { return {
onClick() { }, onClick() {
selectedGroup.value = group;
emits("select", group);
},
onContextmenu(e: MouseEvent): void { onContextmenu(e: MouseEvent): void {
activeNode.value = group;
selectedGroup.value = group;
showDropdown.value = true; showDropdown.value = true;
x.value = e.clientX; x.value = e.clientX;
y.value = e.clientY; y.value = e.clientY;
@@ -132,8 +108,34 @@ function handleNodeProps({ option }: { option: TreeOption }) {
} }
} }
function handleDropdownSelect() { async function handleDropdownSelect(key: string) {
showDropdown.value = false; showDropdown.value = false;
if (!activeNode.value) {
return;
}
if (key === "edit") {
Object.assign(currentGroup, {
id: activeNode.value.id,
pid: activeNode.value.pid ?? "",
name: activeNode.value.name
});
showGroupModal.value = true;
return;
}
if (key === "delete") {
const res = await fetchDeleteDataSetGroup(activeNode.value.id);
if (res.error) {
return;
}
window.$message?.success(t("prompt.Success to delete"));
if (selectedGroup.value?.id === activeNode.value.id) {
selectedGroup.value = null;
emits("select", null);
}
getTreeData();
}
} }
function handleDropdownClickoutside() { function handleDropdownClickoutside() {
@@ -1,18 +1,18 @@
<template> <template>
<n-modal :show="show" @mask-click="handleClose"> <n-modal :show="show" @mask-click="handleClose">
<n-card class="w-120 max-w-600px" :title="t('home.Data set group config')"> <n-card class="w-120 max-w-600px" :title="t('home.Data set group config')">
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto"> <n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto" :disabled="submitLoading">
<n-form-item :label="t('home.Parent group')"> <n-form-item :label="t('home.Parent group')">
<n-cascader v-model:value="model.pid" expand-trigger="hover" :options="treeData" <n-cascader v-model:value="model.pid" expand-trigger="hover" :options="treeData"
check-strategy="child" show-path filterable clearable label-field="name" value-field="id" /> check-strategy="all" show-path filterable clearable label-field="name" value-field="id" />
</n-form-item> </n-form-item>
<n-form-item :label="t('home.Group name')" path="name"> <n-form-item :label="t('home.Group name')" path="name">
<n-input v-model:value="model.name" /> <n-input v-model:value="model.name" />
</n-form-item> </n-form-item>
<div class="flex justify-end mt-4"> <div class="flex justify-end mt-4">
<n-button @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" @click="saveDataSet">{{ t("other.Ok") }}</n-button> <n-button type="primary" :loading="submitLoading" @click="saveDataSet">{{ t("other.Ok") }}</n-button>
</div> </div>
</n-form> </n-form>
</n-card> </n-card>
@@ -20,9 +20,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useTemplateRef,computed } from "vue"; import { useTemplateRef, ref } from "vue";
import type { FormInst } from 'naive-ui' import type { FormInst } from 'naive-ui'
import { t } from "@/language"; import { t } from "@/language";
import { DataSetGroupPayload, fetchCreateDataSetGroup, fetchUpdateDataSetGroup } from "@/http/api/dataSetGroup";
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
show: boolean, show: boolean,
@@ -43,6 +44,7 @@ const formRef = useTemplateRef<FormInst>("formRef");
const rules = { const rules = {
name: { required: true, message: t("prompt.Please enter a grouping name for the dataset"), trigger: 'blur' } name: { required: true, message: t("prompt.Please enter a grouping name for the dataset"), trigger: 'blur' }
}; };
const submitLoading = ref(false);
function handleClose() { function handleClose() {
emits("update:show", false); emits("update:show", false);
@@ -51,15 +53,21 @@ function handleClose() {
function saveDataSet(e: MouseEvent) { function saveDataSet(e: MouseEvent) {
e.preventDefault() e.preventDefault()
formRef.value?.validate((errors) => { formRef.value?.validate(async (errors) => {
if (!errors) { if (!errors) {
if (!props.model.id) { const payload: DataSetGroupPayload = {
// 新增 id: props.model.id || undefined,
pid: props.model.pid || "",
} else { name: props.model.name?.trim() || ""
// 更新 };
submitLoading.value = true;
const res = props.model.id ? await fetchUpdateDataSetGroup(payload) : await fetchCreateDataSetGroup(payload);
submitLoading.value = false;
if (res.error) {
return;
} }
window.$message?.success(props.model.id ? t("prompt.Success to update") : t("prompt.Saved successfully!"));
// 刷新父页面表格 // 刷新父页面表格
emits("refresh"); emits("refresh");
@@ -1,121 +1,330 @@
<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.Data set config')">
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto"> <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.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.Data set group')" path="groupId">
<n-cascader v-model:value="model.groupId" expand-trigger="hover" :options="groupOptions" <n-cascader
check-strategy="child" 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.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.Method')" path="method">
<n-select v-model:value="model.type" :options="[ <n-select
{ label: 'GET', value: 'GET' }, :key="'dataset-method-select'"
{ label: 'POST', value: 'POST' }, v-model:value="model.method"
]" /> :options="[
</n-form-item-gi> { label: 'GET', value: 'GET' },
<n-form-item-gi :span="24" :label="t('home.API interface')"> { label: 'POST', value: 'POST' },
<n-input v-model:value="model.api" type="textarea" /> ]"
</n-form-item-gi> />
</template> </n-form-item-gi>
<n-form-item-gi :span="24" :label="t('home.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.Data sources')" path="dataSourceId">
<n-select v-model:value="model.dataSource" :options="dataSourceOptions" /> <n-select :key="'dataset-datasource-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.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.Parameter is passed as ') }}
'${id}' , '${id}' ,
{{ t('home.For example:') }} {{ t('home.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 @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" @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 { 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 SQLEditor from "@/components/code/SQLEditor.vue"; import { DataSetPayload, fetchCreateDataSet, fetchUpdateDataSet } from "@/http/api/dataSet";
import JSONEditor from "@/components/code/JSONEditor.vue"; import { fetchDataSetGroupTree } from "@/http/api/dataSetGroup";
import { fetchDataSourceList } from "@/http/api/dataSource";
import SQLEditor from "@/components/code/SQLEditor.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("prompt.Please enter a name for the dataset"), trigger: ["input", "blur"] },
}; groupId: createRequiredRule("请选择数据集分组", ["change", "blur"]),
const groupOptions = []; type: { required: true, message: "请选择数据集类型", 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 = []; message: "请选择请求方式",
trigger: ["change", "blur"],
};
baseRules.api = {
required: true,
message: "请输入接口地址",
trigger: ["input", "blur"],
};
} else if (type === "SQL") {
baseRules.dataSourceId = createRequiredRule("请选择数据源", ["change", "blur"]);
baseRules.sql = { required: true, message: "请输入 SQL 语句", trigger: ["blur"] };
} else if (type === "JSON") {
baseRules.json = { required: true, message: "请输入 JSON 数据", 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));
function handleClose() { function normalizeDataSourceId(value: unknown): IDataSource.Item["id"] | undefined {
emits("update:show", false); if (value === undefined || value === null || value === "") {
} return undefined;
}
return String(value);
}
function saveDataSet(e: MouseEvent) { function extractList<T>(value: unknown): T[] {
e.preventDefault() if (Array.isArray(value)) {
return value as T[];
}
if (value && typeof value === "object") {
const nested = (value as Record<string, unknown>).result;
if (Array.isArray(nested)) {
return nested as T[];
}
}
return [];
}
formRef.value?.validate((errors) => { async function loadOptions() {
if (!errors) { const [groupRes, dataSourceRes] = await Promise.allSettled([fetchDataSetGroupTree(), fetchDataSourceList()]);
if (!props.model.id) {
// 新增
} else { if (groupRes.status === "fulfilled") {
// 更新 groupOptions.value = extractList<IDataSet.IGroup>(groupRes.value.data);
} else {
groupOptions.value = [];
}
} if (dataSourceRes.status === "fulfilled") {
const list = extractList<IDataSource.Item>(dataSourceRes.value.data);
dataSourceOptions.value = list.map(item => ({
label: item.name,
value: String(item.id),
}));
} else {
dataSourceOptions.value = [];
}
}
// 刷新父页面表格 function normalizeJsonValue(value?: IDataSet.Item["json"]) {
emits("refresh"); if (!value) {
return "";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return "";
}
}
handleClose(); function resetFieldsForType(type: string, prevType?: string) {
} if (!props.model) {
}) return;
} }
if (type !== "API") {
props.model.method = undefined;
props.model.api = "";
}
if (type === "API" && !props.model.method) {
props.model.method = "GET";
}
if (type !== "SQL") {
props.model.dataSourceId = undefined;
props.model.sql = "";
} else if (prevType && prevType !== "SQL") {
props.model.dataSourceId = undefined;
props.model.sql = "";
}
if (type !== "JSON") {
props.model.json = "";
}
}
function validateSql(sql: string) {
const trimmed = sql.trim();
if (!/^(SELECT|WITH)\b/i.test(trimmed)) {
window.$message?.error("SQL 必须以 SELECT 或 WITH 开头");
return false;
}
if (/[;]|--|\/\*/.test(trimmed)) {
window.$message?.error("SQL 不允许包含危险语句标记");
return false;
}
return true;
}
function buildPayload(): DataSetPayload {
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;
}
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, prev);
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>
@@ -7,9 +7,9 @@
</template> </template>
</n-input> </n-input>
<n-button type="primary" @click="showDataSourceModal = true">{{ t('home.Add data source') }}</n-button> <n-button type="primary" @click="handleAddDataSource">{{ t('home.Add data source') }}</n-button>
</div> </div>
<n-data-table ref="tableRef" :columns="dataSourceColumns" :data="dataSources" :pagination="pagination" striped /> <n-data-table ref="tableRef" :columns="dataSourceColumns" :data="dataSources" :pagination="pagination" :loading="tableLoading" striped />
<!-- 数据源模态框 --> <!-- 数据源模态框 -->
<DataSourceModal v-model:show="showDataSourceModal" :model="currentDataSource" @refresh="getList" /> <DataSourceModal v-model:show="showDataSourceModal" :model="currentDataSource" @refresh="getList" />
@@ -21,18 +21,23 @@ import { NButton, NPopconfirm } from 'naive-ui';
import type { DataTableInst } from 'naive-ui'; import type { DataTableInst } from 'naive-ui';
import { Search } from '@vicons/carbon'; import { Search } from '@vicons/carbon';
import { t } from "@/language"; import { t } from "@/language";
import { fetchDataSourceList, fetchDeleteDataSource } from "@/http/api/dataSource";
import DataSourceModal from "./DataSourceModal.vue"; import DataSourceModal from "./DataSourceModal.vue";
const tableRef = useTemplateRef<DataTableInst>("tableRef"); const tableRef = useTemplateRef<DataTableInst>("tableRef");
const dataSources = ref<IDataSource.Item[]>([]) const dataSources = ref<IDataSource.Item[]>([])
const showDataSourceModal = ref(false) const showDataSourceModal = ref(false)
const currentDataSource = reactive({ const tableLoading = ref(false);
const defaultDataSource = {
id: '', id: '',
name: '', name: '',
type: 'MySQL', type: 'MySQL',
connectionString: '', connectionString: '',
username: "", username: "",
password: "" password: ""
}
const currentDataSource = reactive({
...defaultDataSource
}) })
const dataSourceColumns = [ const dataSourceColumns = [
{ {
@@ -106,7 +111,21 @@ const dataSourceColumns = [
// 分页配置 // 分页配置
const pagination = { pageSize: 10 } const pagination = { pageSize: 10 }
function getList() { } async function getList() {
tableLoading.value = true;
const res = await fetchDataSourceList();
tableLoading.value = false;
dataSources.value = res.data || [];
}
function resetCurrentDataSource(data?: Partial<IDataSource.Item>) {
Object.assign(currentDataSource, defaultDataSource, data || {});
}
function handleAddDataSource() {
resetCurrentDataSource();
showDataSourceModal.value = true;
}
function handleSearch(searchText: string) { function handleSearch(searchText: string) {
tableRef.value?.filter({ tableRef.value?.filter({
@@ -115,12 +134,17 @@ function handleSearch(searchText: string) {
} }
function editDataSource(item) { function editDataSource(item) {
Object.assign(currentDataSource, item) resetCurrentDataSource(item)
showDataSourceModal.value = true showDataSourceModal.value = true
} }
function deleteDataSource(item) { async function deleteDataSource(item) {
dataSources.value = dataSources.value.filter(ds => ds.id !== item.id) const res = await fetchDeleteDataSource(item.id);
if (res.error) {
return;
}
window.$message?.success(t("prompt.Success to delete"));
getList();
} }
onMounted(() => { onMounted(() => {
@@ -1,7 +1,7 @@
<template> <template>
<n-modal :show="show" @mask-click="handleClose"> <n-modal :show="show" @mask-click="handleClose">
<n-card class="w-100 max-w-600px" :title="t('home.Data source config')"> <n-card class="w-100 max-w-600px" :title="t('home.Data source config')">
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto"> <n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto" :disabled="testLinkLoading || submitLoading">
<n-form-item :label="t('home.Data source name')" path="name"> <n-form-item :label="t('home.Data source name')" path="name">
<n-input v-model:value="model.name" :disabled="testLinkLoading" /> <n-input v-model:value="model.name" :disabled="testLinkLoading" />
</n-form-item> </n-form-item>
@@ -22,8 +22,8 @@
<n-button type="primary" :loading="testLinkLoading" @click="handleTestLink">{{ t("home.Test the connection") }}</n-button> <n-button type="primary" :loading="testLinkLoading" @click="handleTestLink">{{ t("home.Test the connection") }}</n-button>
<div> <div>
<n-button @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button> <n-button :disabled="testLinkLoading || submitLoading" @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button>
<n-button type="primary" @click="saveDataSource">{{ t("other.Ok") }}</n-button> <n-button type="primary" :loading="submitLoading" @click="saveDataSource">{{ t("other.Ok") }}</n-button>
</div> </div>
</div> </div>
@@ -33,9 +33,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, useTemplateRef } from "vue"; import { ref, 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 { DataSourcePayload, fetchCreateDataSource, fetchTestDataSource, fetchUpdateDataSource } from "@/http/api/dataSource";
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
show: boolean, show: boolean,
@@ -65,6 +66,8 @@ const dbTypes = [
{ label: 'Oracle', value: 'Oracle' } { label: 'Oracle', value: 'Oracle' }
] ]
const testLinkLoading = ref(false); const testLinkLoading = ref(false);
const submitLoading = ref(false);
const isEdit = computed(() => Boolean(props.model.id));
function handleClose() { function handleClose() {
emits("update:show", false); emits("update:show", false);
@@ -72,25 +75,52 @@ function handleClose() {
function handleTestLink(e: MouseEvent) { function handleTestLink(e: MouseEvent) {
e.preventDefault() e.preventDefault()
formRef.value?.validate((errors) => { formRef.value?.validate(async (errors) => {
if (!errors) { if (!errors) {
testLinkLoading.value = true; testLinkLoading.value = true;
const res = await fetchTestDataSource(buildPayload(false));
testLinkLoading.value = false;
if (res.error) {
return;
}
window.$message?.success(t("prompt.Operation successful"));
} }
}) })
} }
function buildPayload(includeId: boolean): DataSourcePayload {
const payload: DataSourcePayload = {
name: props.model.name?.trim() || "",
type: props.model.type,
connectionString: props.model.connectionString?.trim() || ""
};
if (props.model.username) {
payload.username = props.model.username;
}
if (props.model.password) {
payload.password = props.model.password;
}
if (includeId && props.model.id) {
payload.id = props.model.id;
}
return payload;
}
function saveDataSource(e: MouseEvent) { function saveDataSource(e: MouseEvent) {
e.preventDefault() e.preventDefault()
formRef.value?.validate((errors) => { formRef.value?.validate(async (errors) => {
if (!errors) { if (!errors) {
if (!props.model.id) { submitLoading.value = true;
// 新增 const payload = buildPayload(true);
const res = isEdit.value ? await fetchUpdateDataSource(payload) : await fetchCreateDataSource(payload);
} else { submitLoading.value = false;
// 更新 if (res.error) {
return;
} }
window.$message?.success(isEdit.value ? t("prompt.Success to update") : t("prompt.Saved successfully!"));
// 刷新父页面表格 // 刷新父页面表格
emits("refresh"); emits("refresh");
@@ -19,26 +19,12 @@ const options = [
key: 'details', key: 'details',
icon: renderIcon(Information) icon: renderIcon(Information)
}, },
{
label: () => cpt("home.Rename").value,
key: 'rename',
icon: renderIcon(Edit)
},
{ {
label: () => cpt("home.Delete").value, label: () => cpt("home.Delete").value,
key: 'delete', key: 'delete',
icon: renderIcon(Delete) icon: renderIcon(Delete)
}, },
{
label: () => cpt("home.Release").value,
key: 'release',
icon: renderIcon(SendAlt)
},
{
label: () => cpt("layout.header.Export").value,
key: 'export',
icon: renderIcon(Export)
}
] ]
const detail = ref(); const detail = ref();
const detailVisible = ref(false); const detailVisible = ref(false);
+10 -6
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[];
} }
+2 -1
View File
@@ -158,7 +158,8 @@ export default defineConfig(async ({mode, command}) => {
// }), // }),
viteStaticCopy({ viteStaticCopy({
targets: [ targets: [
{ src: 'node_modules/@astral3d/engine/dist/libs/*', dest: 'assets/libs' } { src: 'node_modules/@astral3d/engine/dist/libs/*', dest: 'assets/libs' },
{ src: 'node_modules/@astral3d/engine/dist/resource/**/*', dest: 'assets/resource' }
], ],
}), }),
...plugins ...plugins
+20 -4
View File
@@ -964,6 +964,11 @@ export class Package {
unpackConfig.onProgress && unpackConfig.onProgress(0); unpackConfig.onProgress && unpackConfig.onProgress(0);
let totalZipNumber = 0, progress = 0; let totalZipNumber = 0, progress = 0;
// 每次解包前确保 loader 可用
if (!this.loader) {
this.loader = new ObjectLoader();
}
await this.initOfflineSource(unpackConfig); await this.initOfflineSource(unpackConfig);
if (!this.offlineZipMap && !this.offlineFlatPackages) { if (!this.offlineZipMap && !this.offlineFlatPackages) {
@@ -998,8 +1003,7 @@ export class Package {
that.offlineFlatPackages = null; that.offlineFlatPackages = null;
that.offlineFlatEntryBase = null; that.offlineFlatEntryBase = null;
that.dataComponentMap = null; that.dataComponentMap = null;
// @ts-ignore 清除loader // 清除 loader,避免连续导入时被上一轮异步收尾误清理
that.loader = undefined;
} }
const complete = () => { const complete = () => {
@@ -1337,6 +1341,9 @@ export class Package {
//const db = window.VIEWPORT.modules["db"]; //const db = window.VIEWPORT.modules["db"];
//const dbTable = `${useProjectState.getState().sceneId}-${useProjectState.getState().info.id}`; //const dbTable = `${useProjectState.getState().sceneId}-${useProjectState.getState().info.id}`;
// 在调用时捕获 loader 引用,防止 done() 清除 this.loader 后重试路径报错
const loader = this.loader;
// map 存储 json 解析完成后执行的 function; key 为 uuid // map 存储 json 解析完成后执行的 function; key 为 uuid
const funcMap = new Map<string, Function>(); const funcMap = new Map<string, Function>();
@@ -1433,7 +1440,13 @@ export class Package {
const parse = (json) => { const parse = (json) => {
if (check(json.object, json)) { if (check(json.object, json)) {
this.loader.parse(json, (group) => { if (!loader) {
// 防止早退导致 callFunNum 无法归零,进度卡住
this.callFunNum.value--;
return;
}
try {
loader.parse(json, (group) => {
const bones: Bone[] = []; const bones: Bone[] = [];
group.getObjectsByProperty("type", "Bone", bones); group.getObjectsByProperty("type", "Bone", bones);
if (bones.length > 0) { if (bones.length > 0) {
@@ -1465,7 +1478,10 @@ export class Package {
funcMap.forEach((func, uuid) => { funcMap.forEach((func, uuid) => {
func.call(this, uuid, group, rootGroupUuid); func.call(this, uuid, group, rootGroupUuid);
}) })
}) })
} catch (err) {
throw err;
}
} else { } else {
const timer = setTimeout(() => { const timer = setTimeout(() => {
clearTimeout(timer); clearTimeout(timer);
+2
View File
@@ -65,6 +65,8 @@ export default class Path extends THREE.Mesh {
if (Path.flowSignalBound) return; if (Path.flowSignalBound) return;
Path.flowSignalBound = true; Path.flowSignalBound = true;
useAddSignal("sceneRendered", Path.handleFlowTick); useAddSignal("sceneRendered", Path.handleFlowTick);
// 立即请求一帧渲染,防止场景静止时 sceneRendered 永远不触发导致流动停止
(App.viewer as any)?.pluginRequestRender?.(true);
} }
private static unbindFlowSignal() { private static unbindFlowSignal() {
+15
View File
@@ -4,6 +4,7 @@ import {Timer} from 'three/examples/jsm/misc/Timer.js';
import {CSS2DRenderer} from "three/examples/jsm/renderers/CSS2DRenderer"; import {CSS2DRenderer} from "three/examples/jsm/renderers/CSS2DRenderer";
import {CSS3DRenderer} from "three/examples/jsm/renderers/CSS3DRenderer.js"; import {CSS3DRenderer} from "three/examples/jsm/renderers/CSS3DRenderer.js";
import {TransformControls} from "three/examples/jsm/controls/TransformControls.js"; import {TransformControls} from "three/examples/jsm/controls/TransformControls.js";
import TWEEN from "three/examples/jsm/libs/tween.module.js";
import App from "../app/App"; import App from "../app/App";
import {ViewerOptions} from "./ViewerOptions"; import {ViewerOptions} from "./ViewerOptions";
import {PluginManager} from "@/core/plugin/plugin"; import {PluginManager} from "@/core/plugin/plugin";
@@ -205,6 +206,12 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
//整个主场景的box3 //整个主场景的box3
public sceneBox3 = new THREE.Box3(); public sceneBox3 = new THREE.Box3();
public package: Package; public package: Package;
private _pluginNeedsRender = false;
/** 供插件/内部模块请求下一帧强制渲染,传 true 标记,animate() 检测后自动清除 */
pluginRequestRender(value: boolean) {
this._pluginNeedsRender = value;
}
constructor(options: IViewerSetting) { constructor(options: IViewerSetting) {
super(); super();
@@ -1060,6 +1067,9 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
this.dispatchEvent({type: 'beforeAnimation', delta: timeStamp}); this.dispatchEvent({type: 'beforeAnimation', delta: timeStamp});
let needRender = App.animationManager.update(timeStamp); let needRender = App.animationManager.update(timeStamp);
if (TWEEN.update()) {
needRender = true;
}
if (needRender) { if (needRender) {
if (App.selected !== null && App.selected.animations.length > 0) { if (App.selected !== null && App.selected.animations.length > 0) {
// 避免某些蒙皮网格的帧延迟效应(e.g. Michelle.glb) // 避免某些蒙皮网格的帧延迟效应(e.g. Michelle.glb)
@@ -1103,6 +1113,11 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
needRender = true; needRender = true;
} }
if (this._pluginNeedsRender) {
needRender = true;
this._pluginNeedsRender = false;
}
this.dispatchEvent({type: 'afterAnimation', delta: timeStamp, toBeRender: (_needRender:boolean = false) => { this.dispatchEvent({type: 'afterAnimation', delta: timeStamp, toBeRender: (_needRender:boolean = false) => {
needRender = _needRender; needRender = _needRender;
} }