|
|
@@ -0,0 +1,363 @@
|
|
|
+<template>
|
|
|
+ <div class="modal-preview">
|
|
|
+ <MicroLoadError v-if="loadFailed" @retry="reload" />
|
|
|
+ <iframe v-else ref="iframeRef" class="modal-preview-frame" :src="iframeSrc" title="二维模型预览" @load="handleLoad" @error="handleError"></iframe>
|
|
|
+ <a-spin v-if="loading" class="modal-preview-loading" size="large" :spinning="loading" tip="正在加载,请稍等。。。" />
|
|
|
+
|
|
|
+ <!-- 父子通信联调测试:开发环境或 URL 带 bridgeTest 参数时显示,生产构建默认不渲染 -->
|
|
|
+ <div v-if="bridgeTestEnabled" class="modal-preview-test" :class="{ 'is-open': testPanelOpen }">
|
|
|
+ <button type="button" class="test-toggle" @click="testPanelOpen = !testPanelOpen">
|
|
|
+ {{ testPanelOpen ? '收起通信测试' : '通信测试' }}
|
|
|
+ </button>
|
|
|
+ <div v-if="testPanelOpen" class="test-body">
|
|
|
+ <label class="test-field"><span>model_id</span><input v-model="testForm.model_id" placeholder="如 8014" /></label>
|
|
|
+ <label class="test-field"><span>deviceType</span><input v-model="testForm.deviceType" placeholder="如 fanlocal / gate / tun" /></label>
|
|
|
+ <label class="test-field"><span>tunid</span><input v-model="testForm.tunid" placeholder="定位巷道用" /></label>
|
|
|
+ <label class="test-field"><span>locationId</span><input v-model="testForm.locationId" placeholder="定位设备用 device.deviceID" /></label>
|
|
|
+ <label class="test-field"
|
|
|
+ ><span>locationName</span><input v-model="testForm.locationName" placeholder="定位报表用 device.strInstallPos"
|
|
|
+ /></label>
|
|
|
+ <div class="test-actions">
|
|
|
+ <button type="button" @click="sendBridgeTest('tunnel')">定位巷道</button>
|
|
|
+ <button type="button" @click="sendBridgeTest('device')">定位设备</button>
|
|
|
+ <!-- <button type="button" @click="sendBridgeTest('report')">定位报表</button> -->
|
|
|
+ </div>
|
|
|
+ <div class="test-log">{{ testLog || '尚未发送' }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
|
|
+ import { apps } from '/@/qiankun/apps';
|
|
|
+ import MicroLoadError from '/@/components/vent/micro/MicroLoadError.vue';
|
|
|
+ import { useErrorLogStoreWithOut } from '/@/store/modules/errorLog';
|
|
|
+ import { useUserStore } from '/@/store/modules/user';
|
|
|
+ import { ErrorTypeEnum } from '/@/enums/exceptionEnum';
|
|
|
+ import { isDevMode } from '/@/utils/env';
|
|
|
+ import {
|
|
|
+ getLastLocateResult,
|
|
|
+ isPreviewReady,
|
|
|
+ locateDevice,
|
|
|
+ locateDeviceReport,
|
|
|
+ locateTunnel,
|
|
|
+ markPreviewNotReady,
|
|
|
+ registerPreviewBridge,
|
|
|
+ unregisterPreviewBridge,
|
|
|
+ } from '/@/views/ventAI/manageAssistent/lib/modalPreviewBridge';
|
|
|
+
|
|
|
+ const APP_NAME = 'micro-vent-2dModal';
|
|
|
+ // 子应用里专为弹窗准备的独立视图(D:\mky\node\Vent2D\tube3d\src\router\route.ts 的 /modal-preview)
|
|
|
+ const DEFAULT_ROUTE = '/modal-preview';
|
|
|
+ // const DEFAULT_ROUTE = '';
|
|
|
+ // iframe 的 load 事件是文档级加载完成,不等待 Three.js 模型资源;60s 足够,避免加载失败时长时间转圈
|
|
|
+ const LOAD_TIMEOUT_MS = 60000;
|
|
|
+
|
|
|
+ type SubAppRoute = { path: string; query?: Record<string, any> };
|
|
|
+ const props = withDefaults(defineProps<{ subAppRoute?: SubAppRoute | null }>(), {
|
|
|
+ subAppRoute: () => ({ path: DEFAULT_ROUTE }),
|
|
|
+ });
|
|
|
+
|
|
|
+ const loading = ref(true);
|
|
|
+ const loadFailed = ref(false);
|
|
|
+ const iframeRef = ref<HTMLIFrameElement>();
|
|
|
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
+ // iframe 是独立文档,子应用取不到基座的 token(存储键与作用域都不同);
|
|
|
+ // 子应用 ready 后由桥统一补发,切模型请求才不会 401→500。
|
|
|
+ const userStore = useUserStore();
|
|
|
+
|
|
|
+ // 与 qiankun/apps.ts 共用同一份入口解析:dev 为 //localhost:8088,prod 为 //host/tun2D/
|
|
|
+ const entry = apps.find((item) => item.name === APP_NAME)?.entry as string | undefined;
|
|
|
+
|
|
|
+ const joinUrl = (base: string, path: string) => `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
|
+
|
|
|
+ const iframeSrc = computed(() => {
|
|
|
+ if (!entry) return '';
|
|
|
+ const route = props.subAppRoute;
|
|
|
+ const path = route?.path || DEFAULT_ROUTE;
|
|
|
+ let url = joinUrl(entry, path);
|
|
|
+ const query = route?.query;
|
|
|
+ if (query && Object.keys(query).length) {
|
|
|
+ const qs = Object.entries(query)
|
|
|
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
|
|
+ .join('&');
|
|
|
+ url += `${url.includes('?') ? '&' : '?'}${qs}`;
|
|
|
+ }
|
|
|
+ return url;
|
|
|
+ });
|
|
|
+
|
|
|
+ const clearLoadTimeout = () => {
|
|
|
+ if (timeoutTimer) {
|
|
|
+ clearTimeout(timeoutTimer);
|
|
|
+ timeoutTimer = undefined;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const logLoadError = (name: string, message: string, detail: string) => {
|
|
|
+ useErrorLogStoreWithOut().addErrorLogInfo({
|
|
|
+ type: ErrorTypeEnum.RESOURCE,
|
|
|
+ file: 'views/ventAI/manageAssistent/components/resource/ModalPreview.vue',
|
|
|
+ name,
|
|
|
+ message,
|
|
|
+ detail,
|
|
|
+ url: window.location.href,
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const startLoadTimeout = () => {
|
|
|
+ clearLoadTimeout();
|
|
|
+ timeoutTimer = setTimeout(() => {
|
|
|
+ timeoutTimer = undefined;
|
|
|
+ if (!loading.value) return;
|
|
|
+ loading.value = false;
|
|
|
+ loadFailed.value = true;
|
|
|
+ logLoadError('MicroApp Load Timeout', `子应用 ${APP_NAME} 加载超时`, `iframe 在 ${LOAD_TIMEOUT_MS}ms 内未触发 load:${iframeSrc.value}`);
|
|
|
+ }, LOAD_TIMEOUT_MS);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleLoad = () => {
|
|
|
+ clearLoadTimeout();
|
|
|
+ loading.value = false;
|
|
|
+ };
|
|
|
+
|
|
|
+ // iframe 的 error 事件只在网络层失败时触发(HTTP 404 也会触发 load),所以仍保留超时兜底
|
|
|
+ const handleError = () => {
|
|
|
+ clearLoadTimeout();
|
|
|
+ loading.value = false;
|
|
|
+ loadFailed.value = true;
|
|
|
+ logLoadError('MicroApp Load Error', `子应用 ${APP_NAME} 加载失败`, iframeSrc.value);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 子应用的实际来源:entry 形如 //localhost:8088 或 //host/tun2D/,按当前页面协议补全
|
|
|
+ const resolveOrigin = (url?: string) => {
|
|
|
+ if (!url) return '';
|
|
|
+ try {
|
|
|
+ return new URL(url, window.location.href).origin;
|
|
|
+ } catch {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // ===== 父子通信联调测试 =====
|
|
|
+ // 开发环境默认显示;生产构建可用 URL 追加 ?bridgeTest(hash 路由则加 #...&bridgeTest)打开
|
|
|
+ const bridgeTestEnabled = isDevMode() || /[?&#]bridgeTest\b/.test(window.location.href);
|
|
|
+ const testPanelOpen = ref(false);
|
|
|
+ const bridgeReady = ref(false);
|
|
|
+ const bridgeOrigin = ref('');
|
|
|
+ const testLog = ref('');
|
|
|
+ let lastResultAt = 0;
|
|
|
+ let testTimer: ReturnType<typeof setInterval> | undefined;
|
|
|
+ const testForm = reactive({
|
|
|
+ model_id: '',
|
|
|
+ deviceType: 'fanlocal',
|
|
|
+ tunid: '',
|
|
|
+ locationId: '',
|
|
|
+ locationName: '',
|
|
|
+ });
|
|
|
+
|
|
|
+ const toPayloadValue = (value: string) => value.trim();
|
|
|
+
|
|
|
+ /** 轮询通道状态与子应用回执(桥本身不是响应式的,用 1s 轮询最省事) */
|
|
|
+ const refreshBridgeState = () => {
|
|
|
+ bridgeReady.value = isPreviewReady();
|
|
|
+ const result = getLastLocateResult();
|
|
|
+ if (result && result.at !== lastResultAt) {
|
|
|
+ lastResultAt = result.at;
|
|
|
+ const tail = result.message ? `:${result.message}` : '';
|
|
|
+ testLog.value = `${new Date(result.at).toLocaleTimeString()} ${result.request} → ${result.ok ? '成功' : '失败'}${tail}`;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const sendBridgeTest = (kind: 'tunnel' | 'device' | 'report') => {
|
|
|
+ const form = testForm;
|
|
|
+ if (!form.model_id.trim()) {
|
|
|
+ testLog.value = '请先填写 model_id(子应用用它校验当前模型)';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const model_id = toPayloadValue(form.model_id);
|
|
|
+ const deviceType = form.deviceType.trim();
|
|
|
+ const time = new Date().toLocaleTimeString();
|
|
|
+ let accepted: boolean;
|
|
|
+ let summary: string;
|
|
|
+
|
|
|
+ if (kind === 'tunnel') {
|
|
|
+ if (!form.tunid.trim()) {
|
|
|
+ testLog.value = '定位巷道需要填写 tunid';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const payload = { model_id, tunid: toPayloadValue(form.tunid), deviceType };
|
|
|
+ accepted = locateTunnel(payload);
|
|
|
+ summary = `locate-tunnel ${JSON.stringify(payload)}`;
|
|
|
+ } else if (kind === 'device') {
|
|
|
+ if (!form.locationId.trim()) {
|
|
|
+ testLog.value = '定位设备需要填写 locationId';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const payload = { model_id, deviceType, locationId: toPayloadValue(form.locationId) };
|
|
|
+ accepted = locateDevice(payload);
|
|
|
+ summary = `locate-device ${JSON.stringify(payload)}`;
|
|
|
+ } else {
|
|
|
+ if (!form.locationName.trim()) {
|
|
|
+ testLog.value = '定位报表需要填写 locationName';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const payload = { model_id, deviceType, locationName: form.locationName.trim() };
|
|
|
+ accepted = locateDeviceReport(payload);
|
|
|
+ summary = `locate-device-report ${JSON.stringify(payload)}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ testLog.value = accepted ? `${time} 已发送:${summary}` : `${time} 已入队(等待子应用 ready):${summary}`;
|
|
|
+ };
|
|
|
+
|
|
|
+ const reload = () => {
|
|
|
+ // iframe 会被 v-if 重建,子应用会重新走一遍 main.ts 并再次回 ready
|
|
|
+ markPreviewNotReady();
|
|
|
+ loadFailed.value = false;
|
|
|
+ loading.value = true;
|
|
|
+ startLoadTimeout();
|
|
|
+ };
|
|
|
+
|
|
|
+ onMounted(() => {
|
|
|
+ if (!entry) {
|
|
|
+ // VITE_APP_SUB_APP 中没配该子应用:直接进错误态,不必空等超时
|
|
|
+ loading.value = false;
|
|
|
+ loadFailed.value = true;
|
|
|
+ logLoadError('MicroApp Not Configured', `子应用 ${APP_NAME} 未在 VITE_APP_SUB_APP 中配置`, 'apps.ts 未解析到该子应用的 entry');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 传函数而非 window 引用:iframe 会被 v-if 重建,contentWindow 会变
|
|
|
+ bridgeOrigin.value = resolveOrigin(entry);
|
|
|
+ registerPreviewBridge(
|
|
|
+ () => iframeRef.value?.contentWindow ?? null,
|
|
|
+ bridgeOrigin.value,
|
|
|
+ () => userStore.getToken
|
|
|
+ );
|
|
|
+ startLoadTimeout();
|
|
|
+
|
|
|
+ if (bridgeTestEnabled) {
|
|
|
+ refreshBridgeState();
|
|
|
+ testTimer = setInterval(refreshBridgeState, 1000);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ onBeforeUnmount(() => {
|
|
|
+ if (testTimer) clearInterval(testTimer);
|
|
|
+ unregisterPreviewBridge();
|
|
|
+ clearLoadTimeout();
|
|
|
+ });
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="less">
|
|
|
+ .modal-preview {
|
|
|
+ position: relative;
|
|
|
+ display: flex;
|
|
|
+ flex: 1;
|
|
|
+ flex-direction: column;
|
|
|
+ width: 100%;
|
|
|
+ min-height: 0;
|
|
|
+
|
|
|
+ &-loading {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ z-index: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ &-frame {
|
|
|
+ display: block;
|
|
|
+ flex: 1;
|
|
|
+ width: 100%;
|
|
|
+ min-height: 0;
|
|
|
+ border: 0;
|
|
|
+ pointer-events: auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ &-test {
|
|
|
+ position: absolute;
|
|
|
+ top: 8px;
|
|
|
+ left: 8px;
|
|
|
+ z-index: 6;
|
|
|
+ max-width: calc(100% - 16px);
|
|
|
+ font-size: 12px;
|
|
|
+ color: #fff;
|
|
|
+
|
|
|
+ .test-toggle {
|
|
|
+ padding: 4px 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ border: 1px solid rgb(255 255 255 / 35%);
|
|
|
+ border-radius: 4px;
|
|
|
+ background: rgb(0 0 0 / 65%);
|
|
|
+ color: #fff;
|
|
|
+ cursor: pointer;
|
|
|
+ }
|
|
|
+
|
|
|
+ .test-body {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+ width: 340px;
|
|
|
+ max-width: 100%;
|
|
|
+ padding: 8px;
|
|
|
+ margin-top: 6px;
|
|
|
+ border: 1px solid rgb(255 255 255 / 25%);
|
|
|
+ border-radius: 4px;
|
|
|
+ background: rgb(0 0 0 / 80%);
|
|
|
+ }
|
|
|
+
|
|
|
+ .test-status {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ color: #9be89b;
|
|
|
+ }
|
|
|
+
|
|
|
+ .test-field {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+
|
|
|
+ span {
|
|
|
+ flex: 0 0 84px;
|
|
|
+ text-align: right;
|
|
|
+ }
|
|
|
+
|
|
|
+ input {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ padding: 2px 6px;
|
|
|
+ border: 1px solid rgb(255 255 255 / 25%);
|
|
|
+ border-radius: 3px;
|
|
|
+ background: rgb(255 255 255 / 10%);
|
|
|
+ color: #fff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .test-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 6px;
|
|
|
+
|
|
|
+ button {
|
|
|
+ flex: 1;
|
|
|
+ padding: 4px 0;
|
|
|
+ border: 0;
|
|
|
+ border-radius: 3px;
|
|
|
+ background: rgb(64 120 220 / 75%);
|
|
|
+ color: #fff;
|
|
|
+ cursor: pointer;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .test-log {
|
|
|
+ max-height: 64px;
|
|
|
+ color: #ffd591;
|
|
|
+ overflow: auto;
|
|
|
+ word-break: break-all;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|