ModalPreview.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. <template>
  2. <div class="modal-preview">
  3. <MicroLoadError v-if="loadFailed" @retry="reload" />
  4. <iframe v-else ref="iframeRef" class="modal-preview-frame" :src="iframeSrc" title="二维模型预览" @load="handleLoad" @error="handleError"></iframe>
  5. <a-spin v-if="loading" class="modal-preview-loading" size="large" :spinning="loading" tip="正在加载,请稍等。。。" />
  6. <!-- 父子通信联调测试:开发环境或 URL 带 bridgeTest 参数时显示,生产构建默认不渲染 -->
  7. <div v-if="bridgeTestEnabled" class="modal-preview-test" :class="{ 'is-open': testPanelOpen }">
  8. <button type="button" class="test-toggle" @click="testPanelOpen = !testPanelOpen">
  9. {{ testPanelOpen ? '收起通信测试' : '通信测试' }}
  10. </button>
  11. <div v-if="testPanelOpen" class="test-body">
  12. <label class="test-field"><span>model_id</span><input v-model="testForm.model_id" placeholder="如 8014" /></label>
  13. <label class="test-field"><span>deviceType</span><input v-model="testForm.deviceType" placeholder="如 fanlocal / gate / tun" /></label>
  14. <label class="test-field"><span>tunid</span><input v-model="testForm.tunid" placeholder="定位巷道用" /></label>
  15. <label class="test-field"><span>locationId</span><input v-model="testForm.locationId" placeholder="定位设备用 device.deviceID" /></label>
  16. <label class="test-field"
  17. ><span>locationName</span><input v-model="testForm.locationName" placeholder="定位报表用 device.strInstallPos"
  18. /></label>
  19. <div class="test-actions">
  20. <button type="button" @click="sendBridgeTest('tunnel')">定位巷道</button>
  21. <button type="button" @click="sendBridgeTest('device')">定位设备</button>
  22. <!-- <button type="button" @click="sendBridgeTest('report')">定位报表</button> -->
  23. </div>
  24. <div class="test-log">{{ testLog || '尚未发送' }}</div>
  25. </div>
  26. </div>
  27. </div>
  28. </template>
  29. <script setup lang="ts">
  30. import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
  31. import { apps } from '/@/qiankun/apps';
  32. import MicroLoadError from '/@/components/vent/micro/MicroLoadError.vue';
  33. import { useErrorLogStoreWithOut } from '/@/store/modules/errorLog';
  34. import { useUserStore } from '/@/store/modules/user';
  35. import { ErrorTypeEnum } from '/@/enums/exceptionEnum';
  36. import { isDevMode } from '/@/utils/env';
  37. import {
  38. getLastLocateResult,
  39. isPreviewReady,
  40. locateDevice,
  41. locateDeviceReport,
  42. locateTunnel,
  43. markPreviewNotReady,
  44. registerPreviewBridge,
  45. unregisterPreviewBridge,
  46. } from '/@/views/ventAI/manageAssistent/lib/modalPreviewBridge';
  47. const APP_NAME = 'micro-vent-2dModal';
  48. // 子应用里专为弹窗准备的独立视图(D:\mky\node\Vent2D\tube3d\src\router\route.ts 的 /modal-preview)
  49. const DEFAULT_ROUTE = '/modal-preview';
  50. // const DEFAULT_ROUTE = '';
  51. // iframe 的 load 事件是文档级加载完成,不等待 Three.js 模型资源;60s 足够,避免加载失败时长时间转圈
  52. const LOAD_TIMEOUT_MS = 60000;
  53. type SubAppRoute = { path: string; query?: Record<string, any> };
  54. const props = withDefaults(defineProps<{ subAppRoute?: SubAppRoute | null }>(), {
  55. subAppRoute: () => ({ path: DEFAULT_ROUTE }),
  56. });
  57. const loading = ref(true);
  58. const loadFailed = ref(false);
  59. const iframeRef = ref<HTMLIFrameElement>();
  60. let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
  61. // iframe 是独立文档,子应用取不到基座的 token(存储键与作用域都不同);
  62. // 子应用 ready 后由桥统一补发,切模型请求才不会 401→500。
  63. const userStore = useUserStore();
  64. // 与 qiankun/apps.ts 共用同一份入口解析:dev 为 //localhost:8088,prod 为 //host/tun2D/
  65. const entry = apps.find((item) => item.name === APP_NAME)?.entry as string | undefined;
  66. const joinUrl = (base: string, path: string) => `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
  67. const iframeSrc = computed(() => {
  68. if (!entry) return '';
  69. const route = props.subAppRoute;
  70. const path = route?.path || DEFAULT_ROUTE;
  71. let url = joinUrl(entry, path);
  72. const query = route?.query;
  73. if (query && Object.keys(query).length) {
  74. const qs = Object.entries(query)
  75. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
  76. .join('&');
  77. url += `${url.includes('?') ? '&' : '?'}${qs}`;
  78. }
  79. return url;
  80. });
  81. const clearLoadTimeout = () => {
  82. if (timeoutTimer) {
  83. clearTimeout(timeoutTimer);
  84. timeoutTimer = undefined;
  85. }
  86. };
  87. const logLoadError = (name: string, message: string, detail: string) => {
  88. useErrorLogStoreWithOut().addErrorLogInfo({
  89. type: ErrorTypeEnum.RESOURCE,
  90. file: 'views/ventAI/manageAssistent/components/resource/ModalPreview.vue',
  91. name,
  92. message,
  93. detail,
  94. url: window.location.href,
  95. });
  96. };
  97. const startLoadTimeout = () => {
  98. clearLoadTimeout();
  99. timeoutTimer = setTimeout(() => {
  100. timeoutTimer = undefined;
  101. if (!loading.value) return;
  102. loading.value = false;
  103. loadFailed.value = true;
  104. logLoadError('MicroApp Load Timeout', `子应用 ${APP_NAME} 加载超时`, `iframe 在 ${LOAD_TIMEOUT_MS}ms 内未触发 load:${iframeSrc.value}`);
  105. }, LOAD_TIMEOUT_MS);
  106. };
  107. const handleLoad = () => {
  108. clearLoadTimeout();
  109. loading.value = false;
  110. };
  111. // iframe 的 error 事件只在网络层失败时触发(HTTP 404 也会触发 load),所以仍保留超时兜底
  112. const handleError = () => {
  113. clearLoadTimeout();
  114. loading.value = false;
  115. loadFailed.value = true;
  116. logLoadError('MicroApp Load Error', `子应用 ${APP_NAME} 加载失败`, iframeSrc.value);
  117. };
  118. // 子应用的实际来源:entry 形如 //localhost:8088 或 //host/tun2D/,按当前页面协议补全
  119. const resolveOrigin = (url?: string) => {
  120. if (!url) return '';
  121. try {
  122. return new URL(url, window.location.href).origin;
  123. } catch {
  124. return '';
  125. }
  126. };
  127. // ===== 父子通信联调测试 =====
  128. // 开发环境默认显示;生产构建可用 URL 追加 ?bridgeTest(hash 路由则加 #...&bridgeTest)打开
  129. const bridgeTestEnabled = isDevMode() || /[?&#]bridgeTest\b/.test(window.location.href);
  130. const testPanelOpen = ref(false);
  131. const bridgeReady = ref(false);
  132. const bridgeOrigin = ref('');
  133. const testLog = ref('');
  134. let lastResultAt = 0;
  135. let testTimer: ReturnType<typeof setInterval> | undefined;
  136. const testForm = reactive({
  137. model_id: '',
  138. deviceType: 'fanlocal',
  139. tunid: '',
  140. locationId: '',
  141. locationName: '',
  142. });
  143. const toPayloadValue = (value: string) => value.trim();
  144. /** 轮询通道状态与子应用回执(桥本身不是响应式的,用 1s 轮询最省事) */
  145. const refreshBridgeState = () => {
  146. bridgeReady.value = isPreviewReady();
  147. const result = getLastLocateResult();
  148. if (result && result.at !== lastResultAt) {
  149. lastResultAt = result.at;
  150. const tail = result.message ? `:${result.message}` : '';
  151. testLog.value = `${new Date(result.at).toLocaleTimeString()} ${result.request} → ${result.ok ? '成功' : '失败'}${tail}`;
  152. }
  153. };
  154. const sendBridgeTest = (kind: 'tunnel' | 'device' | 'report') => {
  155. const form = testForm;
  156. if (!form.model_id.trim()) {
  157. testLog.value = '请先填写 model_id(子应用用它校验当前模型)';
  158. return;
  159. }
  160. const model_id = toPayloadValue(form.model_id);
  161. const deviceType = form.deviceType.trim();
  162. const time = new Date().toLocaleTimeString();
  163. let accepted: boolean;
  164. let summary: string;
  165. if (kind === 'tunnel') {
  166. if (!form.tunid.trim()) {
  167. testLog.value = '定位巷道需要填写 tunid';
  168. return;
  169. }
  170. const payload = { model_id, tunid: toPayloadValue(form.tunid), deviceType };
  171. accepted = locateTunnel(payload);
  172. summary = `locate-tunnel ${JSON.stringify(payload)}`;
  173. } else if (kind === 'device') {
  174. if (!form.locationId.trim()) {
  175. testLog.value = '定位设备需要填写 locationId';
  176. return;
  177. }
  178. const payload = { model_id, deviceType, locationId: toPayloadValue(form.locationId) };
  179. accepted = locateDevice(payload);
  180. summary = `locate-device ${JSON.stringify(payload)}`;
  181. } else {
  182. if (!form.locationName.trim()) {
  183. testLog.value = '定位报表需要填写 locationName';
  184. return;
  185. }
  186. const payload = { model_id, deviceType, locationName: form.locationName.trim() };
  187. accepted = locateDeviceReport(payload);
  188. summary = `locate-device-report ${JSON.stringify(payload)}`;
  189. }
  190. testLog.value = accepted ? `${time} 已发送:${summary}` : `${time} 已入队(等待子应用 ready):${summary}`;
  191. };
  192. const reload = () => {
  193. // iframe 会被 v-if 重建,子应用会重新走一遍 main.ts 并再次回 ready
  194. markPreviewNotReady();
  195. loadFailed.value = false;
  196. loading.value = true;
  197. startLoadTimeout();
  198. };
  199. onMounted(() => {
  200. if (!entry) {
  201. // VITE_APP_SUB_APP 中没配该子应用:直接进错误态,不必空等超时
  202. loading.value = false;
  203. loadFailed.value = true;
  204. logLoadError('MicroApp Not Configured', `子应用 ${APP_NAME} 未在 VITE_APP_SUB_APP 中配置`, 'apps.ts 未解析到该子应用的 entry');
  205. return;
  206. }
  207. // 传函数而非 window 引用:iframe 会被 v-if 重建,contentWindow 会变
  208. bridgeOrigin.value = resolveOrigin(entry);
  209. registerPreviewBridge(
  210. () => iframeRef.value?.contentWindow ?? null,
  211. bridgeOrigin.value,
  212. () => userStore.getToken
  213. );
  214. startLoadTimeout();
  215. if (bridgeTestEnabled) {
  216. refreshBridgeState();
  217. testTimer = setInterval(refreshBridgeState, 1000);
  218. }
  219. });
  220. onBeforeUnmount(() => {
  221. if (testTimer) clearInterval(testTimer);
  222. unregisterPreviewBridge();
  223. clearLoadTimeout();
  224. });
  225. </script>
  226. <style scoped lang="less">
  227. .modal-preview {
  228. position: relative;
  229. display: flex;
  230. flex: 1;
  231. flex-direction: column;
  232. width: 100%;
  233. min-height: 0;
  234. &-loading {
  235. position: absolute;
  236. top: 0;
  237. left: 0;
  238. z-index: 0;
  239. display: flex;
  240. align-items: center;
  241. justify-content: center;
  242. width: 100%;
  243. height: 100%;
  244. }
  245. &-frame {
  246. display: block;
  247. flex: 1;
  248. width: 100%;
  249. min-height: 0;
  250. border: 0;
  251. pointer-events: auto;
  252. }
  253. &-test {
  254. position: absolute;
  255. top: 8px;
  256. left: 8px;
  257. z-index: 6;
  258. max-width: calc(100% - 16px);
  259. font-size: 12px;
  260. color: #fff;
  261. .test-toggle {
  262. padding: 4px 10px;
  263. font-size: 12px;
  264. border: 1px solid rgb(255 255 255 / 35%);
  265. border-radius: 4px;
  266. background: rgb(0 0 0 / 65%);
  267. color: #fff;
  268. cursor: pointer;
  269. }
  270. .test-body {
  271. display: flex;
  272. flex-direction: column;
  273. gap: 4px;
  274. width: 340px;
  275. max-width: 100%;
  276. padding: 8px;
  277. margin-top: 6px;
  278. border: 1px solid rgb(255 255 255 / 25%);
  279. border-radius: 4px;
  280. background: rgb(0 0 0 / 80%);
  281. }
  282. .test-status {
  283. display: flex;
  284. flex-direction: column;
  285. color: #9be89b;
  286. }
  287. .test-field {
  288. display: flex;
  289. align-items: center;
  290. gap: 6px;
  291. span {
  292. flex: 0 0 84px;
  293. text-align: right;
  294. }
  295. input {
  296. flex: 1;
  297. min-width: 0;
  298. padding: 2px 6px;
  299. border: 1px solid rgb(255 255 255 / 25%);
  300. border-radius: 3px;
  301. background: rgb(255 255 255 / 10%);
  302. color: #fff;
  303. }
  304. }
  305. .test-actions {
  306. display: flex;
  307. gap: 6px;
  308. button {
  309. flex: 1;
  310. padding: 4px 0;
  311. border: 0;
  312. border-radius: 3px;
  313. background: rgb(64 120 220 / 75%);
  314. color: #fff;
  315. cursor: pointer;
  316. }
  317. }
  318. .test-log {
  319. max-height: 64px;
  320. color: #ffd591;
  321. overflow: auto;
  322. word-break: break-all;
  323. }
  324. }
  325. }
  326. </style>