| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363 |
- <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>
|