Forráskód Böngészése

[Mod 0000] AI助手功能优化

hongrunxia 21 órája
szülő
commit
45eb383ed7

+ 363 - 0
src/views/ventAI/manageAssistent/components/resource/ModalPreview.vue

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

+ 193 - 0
src/views/ventAI/manageAssistent/lib/modalPreviewBridge.ts

@@ -0,0 +1,193 @@
+/**
+ * 二维模型弹窗(iframe)父子通信 。
+ *
+ * 设计要点:
+ * 1. 单例:ModalPreview 挂载时注册目标 iframe,其它页面/组件直接 import 三个 locate 函数即可发送,
+ *    不需要拿到组件实例、也不用事件总线。
+ * 2. 握手:子应用挂载完成后回 `ready`,在此之前发出的消息进队列,ready 后按顺序补发。
+ *    这样"打开弹窗后立刻点定位"不会丢消息。
+ * 3. 校验:接收端同时校验 e.source(必须是当前 iframe 的 contentWindow)与 e.origin,
+ *    发送端用具体 origin,不用 '*'(消息里可能带业务数据)。
+ *
+ */
+
+export type LocateTunnelPayload = {
+  /**
+   * 模型 id,子应用用它校验当前加载的模型。
+   * 请传字符串:模型 id 是 19 位雪花号(如 1894646048929054721),
+   * 转成 number 会丢精度,子应用会拒绝处理。
+   */
+  model_id: string | number;
+  /** 巷道 id(子应用 tunsMap 的 ntunid) */
+  tunid: string | number;
+  deviceType: string;
+};
+
+export type LocateDevicePayload = {
+  model_id: string | number;
+  deviceType: string;
+  /** 基座侧设备 deviceID,子应用按 parentid 匹配虚拟设备 */
+  locationId: string | number;
+};
+
+export type LocateDeviceReportPayload = {
+  model_id: string | number;
+  deviceType: string;
+  /** 设备安装位置(基座 record.strInstallPos) */
+  locationName: string;
+};
+
+export type SetTokenMessage = { from: 'mky-base'; type: 'set-token'; payload: { token: string } };
+export type LocateTunnelMessage = { from: 'mky-base'; type: 'locate-tunnel'; payload: LocateTunnelPayload };
+export type LocateDeviceMessage = { from: 'mky-base'; type: 'locate-device'; payload: LocateDevicePayload };
+export type LocateDeviceReportMessage = { from: 'mky-base'; type: 'locate-device-report'; payload: LocateDeviceReportPayload };
+export type BaseToPreviewMessage = SetTokenMessage | LocateTunnelMessage | LocateDeviceMessage | LocateDeviceReportMessage;
+
+/** 子应用对一次定位请求的执行回执(联调测试用) */
+export type LocateResult = {
+  request: string;
+  ok: boolean;
+  message?: string;
+  /** 父级收到回执的时间戳 */
+  at: number;
+};
+
+const SOURCE_BASE = 'mky-base';
+const SOURCE_SUB = 'micro-vent-2dModal';
+// 子应用迟迟不 ready 时,队列不能无限增长(正常也就几条)
+const MAX_PENDING = 50;
+
+let targetGetter: (() => Window | null) | null = null;
+let targetOrigin = '';
+let tokenGetter: (() => string | undefined) | null = null;
+let ready = false;
+let pending: BaseToPreviewMessage[] = [];
+let listening = false;
+let lastResult: LocateResult | null = null;
+
+const getTargetWindow = (): Window | null => {
+  try {
+    return targetGetter?.() ?? null;
+  } catch {
+    return null;
+  }
+};
+
+const post = (message: BaseToPreviewMessage) => {
+  const win = getTargetWindow();
+  if (!win || !targetOrigin) return false;
+  win.postMessage(message, targetOrigin);
+  return true;
+};
+
+const flush = () => {
+  if (!ready) return;
+  const queue = pending;
+  pending = [];
+  queue.forEach(post);
+};
+
+/**
+ * 把基座的登录 token 补给子应用。
+ * iframe 与基座是两份独立的 localStorage(键名也不同),子应用自己取不到;
+ * 而按 model_id 切模型要请求需要鉴权的 Java 接口,缺 token 会被后端转成 500。
+ */
+const postToken = () => {
+  // tokenGetter 在 register 之后才会被赋值,这里显式放宽类型,避免 TS 把模块级可变变量
+  // 按声明时的初始值 null 收窄成 never
+  const getter = tokenGetter as (() => string | undefined) | null;
+  const token = getter?.();
+  if (!token) {
+    console.warn('[modalPreviewBridge] 当前没有登录 token,子应用切换模型可能因鉴权失败而报 500');
+    return;
+  }
+  post({ from: SOURCE_BASE, type: 'set-token', payload: { token } });
+};
+
+const handleMessage = (event: MessageEvent) => {
+  if (!targetGetter) return;
+  // 只认当前 iframe 发来的消息:同一页面里可能嵌着别的 iframe
+  if (event.source !== getTargetWindow()) return;
+  if (targetOrigin && event.origin !== targetOrigin) return;
+  const data = event.data;
+  if (!data || data.from !== SOURCE_SUB) return;
+  if (data.type === 'ready') {
+    ready = true;
+    // 先补 token,再放行排队的定位消息:切模型接口需要鉴权
+    postToken();
+    flush();
+    return;
+  }
+  // 定位执行回执:只在控制台与测试面板展示,不参与业务逻辑
+  if (data.type === 'locate-result' && data.payload) {
+    lastResult = { ...data.payload, at: Date.now() };
+    // eslint-disable-next-line no-console
+    console.info('[modalPreviewBridge] 子应用定位结果', lastResult);
+  }
+};
+
+/**
+ * 由 ModalPreview 在挂载时调用。
+ * @param getTargetWindow 返回 iframe 的 contentWindow(iframe 会被 v-if 重建,所以传函数而不是 window 引用)
+ * @param origin iframe 的来源,如 http://localhost:8088
+ * @param getToken 返回基座当前登录 token;子应用 ready 后会补发过去
+ */
+export const registerPreviewBridge = (getTargetWindow: () => Window | null, origin: string, getToken?: () => string | undefined) => {
+  targetGetter = getTargetWindow;
+  targetOrigin = origin;
+  tokenGetter = getToken ?? null;
+  ready = false;
+  lastResult = null;
+  if (!origin) console.warn('[modalPreviewBridge] 未解析到子应用 origin,定位消息将无法发送');
+  if (!listening) {
+    window.addEventListener('message', handleMessage);
+    listening = true;
+  }
+};
+
+/** 由 ModalPreview 在卸载时调用。 */
+export const unregisterPreviewBridge = () => {
+  targetGetter = null;
+  targetOrigin = '';
+  tokenGetter = null;
+  ready = false;
+  pending = [];
+  lastResult = null;
+  if (listening) {
+    window.removeEventListener('message', handleMessage);
+    listening = false;
+  }
+};
+
+/**
+ * iframe 被重建(重试加载、src 变更)时调用:先置回未就绪,
+ * 子应用重新挂载后会再发一次 ready。待发队列保留,下一次 ready 时补发。
+ */
+export const markPreviewNotReady = () => {
+  ready = false;
+};
+
+export const isPreviewReady = () => ready;
+
+/** 最近一次定位请求的执行回执(联调测试用) */
+export const getLastLocateResult = () => lastResult;
+
+const send = (message: BaseToPreviewMessage) => {
+  // 弹窗没打开(或已卸载)时直接丢弃:不要把旧定位消息留给下一次打开的弹窗
+  if (!targetGetter) return false;
+  if (!ready) {
+    if (pending.length >= MAX_PENDING) pending.shift();
+    pending.push(message);
+    return false;
+  }
+  return post(message);
+};
+
+/** 定位巷道:{ model_id, tunid, deviceType } */
+export const locateTunnel = (payload: LocateTunnelPayload) => send({ from: SOURCE_BASE, type: 'locate-tunnel', payload });
+
+/** 定位设备:{ model_id, deviceType, locationId } */
+export const locateDevice = (payload: LocateDevicePayload) => send({ from: SOURCE_BASE, type: 'locate-device', payload });
+
+/** 定位设备报表(按安装位置名称):{ model_id, deviceType, locationName } */
+export const locateDeviceReport = (payload: LocateDeviceReportPayload) => send({ from: SOURCE_BASE, type: 'locate-device-report', payload });