Ver código fonte

[Mod 0000]数据点选功能修改

wangkeyi 1 dia atrás
pai
commit
71edce2f46

+ 10 - 48
src/views/vent/home/configurable/components/preset/SysWindCard.vue

@@ -2,26 +2,8 @@
 <template>
   <!-- 基准的画廊模块,通过不同的 type 展示不同的样式 -->
   <div ref="container" class="syswind__container w-full h-full">
-    <!-- 画廊项的具体内容填充剩余宽度 -->
-    <div
-      v-for="item in data"
-      :key="item.deviceID"
-      class="syswind__card"
-      @mouseenter="getTrigger(item.deviceID).onEnter($event)"
-      @mousemove="getTrigger(item.deviceID).onMove($event)"
-      @mouseleave="getTrigger(item.deviceID).onLeave()"
-    >
-      <!-- 数据解读悬浮按钮(公共组件) -->
-      <DataInterpretationBtn
-        v-if="getTrigger(item.deviceID).state.visible"
-        :x="getTrigger(item.deviceID).state.x"
-        :y="getTrigger(item.deviceID).state.y"
-        @click="handleInterpret(item)"
-        @btn-enter="getTrigger(item.deviceID).onBtnEnter()"
-        @btn-leave="getTrigger(item.deviceID).onBtnLeave()"
-      />
-
-      <!-- 卡片内容 -->
+    <div v-for="item in data" :key="item.deviceID" v-data-picker="item" class="syswind__card">
+      <!-- 画廊项的具体内容填充剩余宽度 -->
       <div class="syswind__card__title font-bold" :title="getFormattedText(item, config.title)">{{ getFormattedText(item, config.title) }}</div>
       <div class="flex items-center justify-between syswind__card__vol_wrapper">
         <div class="syswind__card__vol_image" :class="{ overlimit: isVolumnOverLimit(item) }"></div>
@@ -49,35 +31,23 @@
             }"
           >
             <span>{{ getFormattedText(item, ctx.label) }}</span>
-            <span class="font-bold">{{ getFormattedText(item, ctx.value) }}</span>
+            <span class=".font-bold">{{ getFormattedText(item, ctx.value) }}</span>
           </div>
         </div>
       </div>
     </div>
-
-    <!-- 数据解读弹框(公共组件) -->
-    <DataInterpretationModal
-      :visible="modalVisible"
-      :loading="loading"
-      :streaming-text="streamingText"
-      :error="error"
-      :thinking="thinking"
-      :todos="todos"
-      :executing-tools="executingTools"
-      :tool-calls="toolCalls"
-      @close="handleClose"
-    />
   </div>
 </template>
 <script lang="ts" setup>
   import { defaultTo, inRange, first } from 'lodash-es';
   import { getFormattedText } from '../../hooks/helper';
-  // import { onMounted, ref } from 'vue';
-  // import { useEventListener, useScroll } from '@vueuse/core';
+  import { useDataPicker } from '/@/views/ventAI/dataPicker';
 
-  // import { get } from 'lodash-es';
-  // import { computed } from 'vue';
-  import { useDataInterpretationList, DataInterpretationBtn, DataInterpretationModal } from '/@/views/ventAI/dataPicker';
+  const { directive: vDataPicker } = useDataPicker((item) => ({
+    device_id: item.deviceID,
+    device_name: item.strinstallpos,
+    device_type: 'windrect',
+  }));
 
   const props = defineProps<{
     config: {
@@ -89,15 +59,8 @@
     data: any[];
   }>();
   //   defineEmits(['click']);
-  // 判断val是否在low、high指定的范围内
-
-  const { modalVisible, loading, streamingText, error, thinking, todos, executingTools, toolCalls, getTrigger, handleInterpret, handleClose } =
-    useDataInterpretationList((item) => ({
-      device_id: item.deviceID,
-      device_name: item.strinstallpos,
-      device_type: 'windrect',
-    }));
 
+  // 判断val是否在low、high指定的范围内
   function isOverLimit(val: string, low: string = '', high: string = '') {
     const l = parseFloat(low);
     const h = parseFloat(high);
@@ -176,7 +139,6 @@
     --image-syswind-icon-spd: url(/@/assets/images/home-container/configurable/preset/syswind-icon-spd.svg);
     --image-syswind-icon-spd-red: url(/@/assets/images/home-container/configurable/preset/syswind-icon-spd-red.svg);
 
-    position: relative;
     background-image: var(--image-syswind-module);
     background-repeat: no-repeat;
     width: 320px;

+ 11 - 0
src/views/vent/monitorManager/mainFanMonitor/index.vue

@@ -330,6 +330,7 @@
                 >
                 <a class="table-action-link" @click="deviceEdit($event, 'deviceInfo', record)">设备编辑</a>
                 <a v-if="hasPermission('fan:exportDataBtn')" class="table-action-link" @click="exportData($event, record)">数据导出</a>
+                <a class="table-action-link" v-data-picker="record">数据解读</a>
               </template>
             </GroupMonitorTable>
           </a-tab-pane>
@@ -753,6 +754,7 @@
   import { useModal } from '/@/components/Modal';
   import type { BasicColumn } from '/@/components/Table/src/types/table';
   import { useCamera } from '/@/hooks/system/useCamera';
+  import { useDataPicker } from '/@/views/ventAI/dataPicker';
   import DeviceBaseInfo from '../comment/components/DeviceBaseInfo.vue';
   import { useGlobSetting } from '/@/hooks/setting';
   import { usePermission } from '/@/hooks/web/usePermission';
@@ -772,6 +774,15 @@
 
   const fanSwitchoverSimulation = VENT_PARAM['fanSwitchoverSimulation'];
 
+  const { directive: vDataPicker } = useDataPicker(
+    (record: any) => ({
+      device_id: record.deviceID,
+      device_name: record.strinstallpos,
+      device_type: 'fanmain',
+    }),
+    { mode: 'click' }
+  );
+
   const [registerModal, { openModal, closeModal }] = useModal();
   const [registerModalDeviceEdit, { openModal: openModalDeviceEdit }] = useModal();
   const [registerModalDeviceSet, { openModal: openModalDeviceSet }] = useModal();

+ 0 - 60
src/views/ventAI/dataPicker/components/DataInterpretationBtn.vue

@@ -1,60 +0,0 @@
-<template>
-  <div
-    class="di-floating-btn"
-    :style="{ left: x + 'px', top: y + 'px' }"
-    @click.stop="$emit('click')"
-    @mouseenter="$emit('btn-enter')"
-    @mouseleave="$emit('btn-leave')"
-  >
-    <span>数据解读</span>
-  </div>
-</template>
-
-<script lang="ts" setup>
-  defineProps<{
-    x: number;
-    y: number;
-  }>();
-
-  defineEmits<{
-    click: [];
-    'btn-enter': [];
-    'btn-leave': [];
-  }>();
-</script>
-
-<style lang="less" scoped>
-  .di-floating-btn {
-    position: absolute;
-    top: 0;
-    left: 0;
-    z-index: 10;
-    padding: 3px 12px;
-    background: linear-gradient(135deg, #1e90ff, #4da6ff);
-    border-radius: 3px;
-    cursor: pointer;
-    font-size: 12px;
-    color: #fff;
-    letter-spacing: 1px;
-    white-space: nowrap;
-    pointer-events: auto;
-    box-shadow: 0 0 10px rgba(30, 144, 255, 0.4);
-    animation: diFadeIn 0.15s ease-out;
-
-    &:hover {
-      background: linear-gradient(135deg, #4da6ff, #79c0ff);
-      box-shadow: 0 0 16px rgba(30, 144, 255, 0.6);
-    }
-  }
-
-  @keyframes diFadeIn {
-    from {
-      opacity: 0;
-      transform: scale(0.9);
-    }
-    to {
-      opacity: 1;
-      transform: scale(1);
-    }
-  }
-</style>

+ 25 - 112
src/views/ventAI/dataPicker/components/DataInterpretationModal.vue → src/views/ventAI/dataPicker/components/DataPickerModal.vue

@@ -9,20 +9,13 @@
     wrap-class-name="data-interpretation-modal"
     @cancel="$emit('close')"
   >
-    <!-- 标题栏 -->
     <div class="di-header">
       <span class="di-header__title">数据解读</span>
       <span class="di-header__close" @click="$emit('close')">✕</span>
     </div>
-
-    <!-- 内容区域 -->
     <div class="di-body">
-      <!-- 过程状态展示区 -->
       <div v-if="showProcess" class="di-process">
-        <!-- 思考中 -->
         <div v-if="thinking" class="di-process__row di-process__thinking"><a-spin size="small" /><span>AI 正在思考...</span></div>
-
-        <!-- Todo 列表 -->
         <div v-if="todos.length > 0" class="di-process__todos">
           <div v-for="(todo, i) in todos" :key="i" class="di-todo-item" :class="`di-todo-item--${todo.status}`">
             <span class="di-todo-item__icon">
@@ -33,46 +26,18 @@
             <span class="di-todo-item__text">{{ todo.content }}</span>
           </div>
         </div>
-
-        <!-- 执行工具中 -->
-        <!-- <div v-if="executingTools.length > 0" class="di-process__row di-process__executing">
-          <a-spin size="small" />
-          <span>正在执行:{{ executingTools.join(', ') }}</span>
-        </div> -->
-
-        <!-- 工具调用记录 -->
-        <!-- <div v-if="toolCalls.length > 0" class="di-process__tools">
-          <div v-for="(tc, i) in toolCalls" :key="i" class="di-tool-item">
-            <span class="di-tool-item__dot" :class="{ 'di-tool-item__dot--done': tc.status === 'result' }"></span>
-            <span class="di-tool-item__name">{{ tc.tool }}</span>
-            <span v-if="tc.status === 'call'" class="di-tool-item__tag">执行中</span>
-            <span v-else class="di-tool-item__tag di-tool-item__tag--done">完成</span>
-          </div>
-        </div> -->
-      </div>
-
-      <!-- 无过程且加载中 -->
-      <div v-if="loading && !hasContent && !hasProcess" class="di-loading">
-        <a-spin tip="正在分析数据..." />
       </div>
-
-      <!-- Markdown 正文 -->
+      <div v-if="loading && !hasContent && !hasProcess" class="di-loading"><a-spin tip="正在分析数据..." /></div>
       <div v-if="hasContent" class="di-markdown" v-html="renderedText"></div>
-
-      <!-- 错误 -->
       <div v-else-if="error && !loading" class="di-error">数据解读失败:{{ error }}</div>
     </div>
-
-    <!-- 流式加载指示条 -->
-    <div v-if="loading && hasContent" class="di-streaming-bar">
-      <a-spin size="small" /><span class="di-streaming-bar__tip">AI 正在生成...</span>
-    </div>
+    <div v-if="loading && hasContent" class="di-streaming-bar"><a-spin size="small" /><span class="di-streaming-bar__tip">AI 正在生成...</span></div>
   </a-modal>
 </template>
 
 <script lang="ts" setup>
   import { computed } from 'vue';
-  import markdownIt from 'markdown-it';
+  import { marked } from 'marked';
   import type { TodoItem, ToolCallRecord } from '../types';
 
   const props = defineProps<{
@@ -88,27 +53,38 @@
 
   defineEmits<{ close: [] }>();
 
-  const md = markdownIt();
-
   const hasContent = computed(() => !!props.streamingText);
   const hasProcess = computed(() => props.thinking || props.todos.length > 0 || props.executingTools.length > 0 || props.toolCalls.length > 0);
   const showProcess = computed(() => hasProcess.value);
-
   const renderedText = computed(() => {
     if (!props.streamingText) return '';
-    const processed = props.streamingText.replace(/<br\s*\/?>/gi, '  \n');
-    return md.render(processed);
+    return marked.parse(props.streamingText.replace(/<br\s*\/?>/gi, '  \n')) as string;
   });
 </script>
 
 <style lang="less">
   .data-interpretation-modal {
+    position: fixed !important;
+    top: 0 !important;
+    left: 0 !important;
+    width: 100vw !important;
+    height: 100vh !important;
+    display: flex !important;
+    align-items: flex-start !important;
+    justify-content: center !important;
+    padding-top: 10vh;
+    z-index: 1000 !important;
+    .ant-modal {
+      top: 0;
+      max-width: 90vw;
+    }
     .ant-modal-content {
-      background: linear-gradient(180deg, #0a1a3a 0%, #0d2450 100%);
-      border: 1px solid rgba(30, 144, 255, 0.3);
-      border-radius: 8px;
-      box-shadow: 0 0 30px rgba(0, 120, 255, 0.15);
-      color: #c8d6e5;
+      color: var(--vent-font-color) !important;
+      background-color: var(--vent-modal-bg) !important;
+      border: 1px solid var(--vent-modal-border) !important;
+      -webkit-box-shadow: 0px 0px 10px var(--vent-modal-box-shadow) inset !important;
+      box-shadow: 0px 0px 10px var(--vent-modal-box-shadow) inset !important;
+      backdrop-filter: blur(10px);
     }
     .ant-modal-body {
       padding: 0;
@@ -127,14 +103,12 @@
     justify-content: space-between;
     align-items: center;
     padding: 18px 24px;
-    background: linear-gradient(90deg, rgba(30, 144, 255, 0.15) 0%, transparent 100%);
     border-bottom: 1px solid rgba(30, 144, 255, 0.15);
     flex-shrink: 0;
-
     &__title {
       font-size: 18px;
       font-weight: bold;
-      color: #4da6ff;
+      color: #fff;
       letter-spacing: 2px;
     }
     &__close {
@@ -146,27 +120,22 @@
       }
     }
   }
-
   .di-body {
     padding: 18px 24px;
     min-height: 120px;
     max-height: 700px;
     overflow-y: auto;
   }
-
   .di-loading {
     display: flex;
     justify-content: center;
     align-items: center;
     padding: 40px 0;
   }
-
-  // ── 过程状态区 ──
   .di-process {
     margin-bottom: 16px;
     padding-bottom: 12px;
     border-bottom: 1px solid rgba(30, 144, 255, 0.12);
-
     &__row {
       display: flex;
       align-items: center;
@@ -176,22 +145,17 @@
       color: #8899aa;
     }
   }
-
   .di-process__thinking {
     color: #4da6ff;
     font-size: 13px;
   }
-
   .di-process__executing {
     color: #c8a853;
     font-size: 13px;
   }
-
-  // ── Todo 列表 ──
   .di-process__todos {
     margin: 6px 0;
   }
-
   .di-todo-item {
     display: flex;
     align-items: center;
@@ -199,7 +163,6 @@
     padding: 4px 0;
     font-size: 13px;
     line-height: 1.6;
-
     &__icon {
       flex-shrink: 0;
       width: 16px;
@@ -210,7 +173,6 @@
     &__text {
       color: #c8d6e5;
     }
-
     &--completed &__text {
       color: #6b7a8a;
       text-decoration: line-through;
@@ -219,13 +181,11 @@
       color: #4da6ff;
     }
   }
-
   .di-todo-check {
     color: #27ae60;
     font-size: 12px;
     font-weight: bold;
   }
-
   .di-todo-dot {
     width: 6px;
     height: 6px;
@@ -233,54 +193,10 @@
     background: #4a5568;
     display: inline-block;
   }
-
-  // ── 工具调用记录 ──
-  .di-process__tools {
-    margin-top: 4px;
-  }
-
-  .di-tool-item {
-    display: flex;
-    align-items: center;
-    gap: 6px;
-    padding: 2px 0;
-    font-size: 12px;
-    color: #6b7a8a;
-
-    &__dot {
-      width: 6px;
-      height: 6px;
-      border-radius: 50%;
-      background: #c8a853;
-      flex-shrink: 0;
-
-      &--done {
-        background: #27ae60;
-      }
-    }
-    &__name {
-      color: #8899aa;
-    }
-    &__tag {
-      font-size: 10px;
-      padding: 0 4px;
-      border-radius: 2px;
-      background: rgba(200, 168, 83, 0.2);
-      color: #c8a853;
-
-      &--done {
-        background: rgba(39, 174, 96, 0.15);
-        color: #27ae60;
-      }
-    }
-  }
-
-  // ── Markdown 正文 ──
   .di-markdown {
     line-height: 1.8;
     color: #c8d6e5;
     word-break: break-word;
-
     :deep(h1),
     :deep(h2),
     :deep(h3),
@@ -333,20 +249,17 @@
       }
     }
   }
-
   .di-streaming-bar {
     display: flex;
     align-items: center;
     gap: 8px;
     padding: 8px 24px 16px;
     border-top: 1px solid rgba(30, 144, 255, 0.1);
-
     &__tip {
       font-size: 12px;
       color: #8899aa;
     }
   }
-
   .di-error {
     padding: 40px;
     text-align: center;

+ 3 - 6
src/views/ventAI/dataPicker/index.ts

@@ -1,7 +1,4 @@
-export { useDataInterpretation, useDataInterpretationList } from './useDataInterpretation';
-export { createTrigger } from './useDataInterpretationTrigger';
+export { useDataPicker } from './useDataPicker';
+export type { DataPickerOptions } from './useDataPicker';
 export { fetchTunnelInterpretation, fetchDeviceInterpretation } from './api';
-export type { TriggerState, TriggerHandle, TriggerOptions } from './useDataInterpretationTrigger';
-export type { TunnelInterpretRequest, DeviceInterpretRequest, InterpretationResult, SSERawEvent, SSEEventType, TodoItem, ToolCallRecord } from './types';
-export { default as DataInterpretationModal } from './components/DataInterpretationModal.vue';
-export { default as DataInterpretationBtn } from './components/DataInterpretationBtn.vue';
+export type { SSERawEvent, SSEEventType, TodoItem, ToolCallRecord } from './types';

+ 0 - 20
src/views/ventAI/dataPicker/types.ts

@@ -1,16 +1,3 @@
-/** 巷道解读请求参数 */
-export interface TunnelInterpretRequest {
-  tun_id: string;
-  tun_name: string;
-}
-
-/** 设备解读请求参数 */
-export interface DeviceInterpretRequest {
-  device_id: string;
-  device_name: string;
-  device_type: string;
-}
-
 /** SSE 事件类型 */
 export type SSEEventType = 'thinking' | 'executing' | 'tool_call' | 'tool_result' | 'updated_todo_list' | 'token' | 'done' | 'error';
 
@@ -40,10 +27,3 @@ export interface ToolCallRecord {
   source: string;
   status: 'call' | 'result';
 }
-
-/** 解读完成后的结果 */
-export interface InterpretationResult {
-  content: string;
-  threadId: string;
-  sessionId: string;
-}

+ 0 - 185
src/views/ventAI/dataPicker/useDataInterpretation.ts

@@ -1,185 +0,0 @@
-import { ref } from 'vue';
-import { fetchTunnelInterpretation, fetchDeviceInterpretation } from './api';
-import { createTrigger, type TriggerHandle } from './useDataInterpretationTrigger';
-import type { TodoItem, ToolCallRecord } from './types';
-
-/** 数据解读 hook —— 调用后端 SSE 流式接口,实时追踪所有过程状态 */
-export function useDataInterpretation() {
-  const loading = ref(false);
-  const streamingText = ref('');
-  const error = ref<string | null>(null);
-  const threadId = ref('');
-  const sessionId = ref('');
-
-  // 过程状态
-  const thinking = ref(false);
-  const todos = ref<TodoItem[]>([]);
-  const executingTools = ref<string[]>([]);
-  const toolCalls = ref<ToolCallRecord[]>([]);
-
-  /** 统一的事件处理 */
-  function handleEvent(event: any) {
-    switch (event.type) {
-      case 'thinking':
-        thinking.value = true;
-        break;
-      case 'executing':
-        thinking.value = false;
-        executingTools.value = event.tools || [];
-        break;
-      case 'tool_call':
-        executingTools.value = [];
-        toolCalls.value = [...toolCalls.value, { tool: event.tool || '', source: event.source || 'main', status: 'call' as const }];
-        break;
-      case 'tool_result':
-        // 更新最近一条 tool_call 为 result 状态
-        if (toolCalls.value.length > 0) {
-          const last = toolCalls.value[toolCalls.value.length - 1];
-          if (last.tool === event.tool && last.status === 'call') {
-            last.status = 'result';
-          }
-        }
-        break;
-      case 'updated_todo_list':
-        thinking.value = false;
-        todos.value = event.todos || [];
-        break;
-      case 'token':
-        thinking.value = false;
-        if (event.content) {
-          streamingText.value += event.content;
-        }
-        break;
-    }
-  }
-
-  async function interpretTunnel(params: { tun_id: string; tun_name: string }) {
-    loading.value = true;
-    error.value = null;
-    streamingText.value = '';
-    thinking.value = false;
-    todos.value = [];
-    executingTools.value = [];
-    toolCalls.value = [];
-
-    try {
-      const result = await fetchTunnelInterpretation(params, handleEvent);
-      threadId.value = result.thread_id;
-      sessionId.value = result.session_id;
-    } catch (e: any) {
-      const msg = e?.message || '数据解读请求失败';
-      error.value = msg;
-      throw e;
-    } finally {
-      loading.value = false;
-      thinking.value = false;
-      executingTools.value = [];
-    }
-  }
-
-  async function interpretDevice(params: { device_id: string; device_name: string; device_type: string }) {
-    loading.value = true;
-    error.value = null;
-    streamingText.value = '';
-    thinking.value = false;
-    todos.value = [];
-    executingTools.value = [];
-    toolCalls.value = [];
-
-    try {
-      const result = await fetchDeviceInterpretation(params, handleEvent);
-      threadId.value = result.thread_id;
-      sessionId.value = result.session_id;
-    } catch (e: any) {
-      const msg = e?.message || '数据解读请求失败';
-      error.value = msg;
-      throw e;
-    } finally {
-      loading.value = false;
-      thinking.value = false;
-      executingTools.value = [];
-    }
-  }
-
-  function reset() {
-    streamingText.value = '';
-    error.value = null;
-    threadId.value = '';
-    sessionId.value = '';
-    thinking.value = false;
-    todos.value = [];
-    executingTools.value = [];
-    toolCalls.value = [];
-  }
-
-  return {
-    loading,
-    streamingText,
-    error,
-    threadId,
-    sessionId,
-    thinking,
-    todos,
-    executingTools,
-    toolCalls,
-    interpretTunnel,
-    interpretDevice,
-    reset,
-  };
-}
-
-/**
- * 数据解读列表级便捷 Hook。
- * 整合了弹框状态 + 每行数据的悬浮触发器管理 + SSE 流式解读。
- *
- * @param mapParams - 将列表行数据映射为接口请求参数
- *
- * 使用:在列表/表格/卡片组等 v-for 场景的组件中调用一次即可。
- */
-export function useDataInterpretationList(
-  mapParams: (item: any) => { tun_id: string; tun_name: string } | { device_id: string; device_name: string; device_type: string }
-) {
-  const { loading, streamingText, error, threadId, sessionId, thinking, todos, executingTools, toolCalls, interpretTunnel, interpretDevice, reset } =
-    useDataInterpretation();
-
-  const modalVisible = ref(false);
-  const triggerMap = new Map<string, TriggerHandle>();
-
-  function getTrigger(id: string): TriggerHandle {
-    if (!triggerMap.has(id)) {
-      triggerMap.set(id, createTrigger());
-    }
-    return triggerMap.get(id)!;
-  }
-
-  async function handleInterpret(item: any) {
-    modalVisible.value = true;
-    const params = mapParams(item);
-    if ('tun_id' in params) {
-      await interpretTunnel(params);
-    } else {
-      await interpretDevice(params);
-    }
-  }
-
-  function handleClose() {
-    modalVisible.value = false;
-    reset();
-  }
-
-  return {
-    modalVisible,
-    loading,
-    streamingText,
-    error,
-    threadId,
-    sessionId,
-    thinking,
-    todos,
-    executingTools,
-    toolCalls,
-    getTrigger,
-    handleInterpret,
-    handleClose,
-  };
-}

+ 0 - 124
src/views/ventAI/dataPicker/useDataInterpretationTrigger.ts

@@ -1,124 +0,0 @@
-import { reactive } from 'vue';
-
-export interface TriggerState {
-  visible: boolean;
-  x: number;
-  y: number;
-}
-
-export interface TriggerHandle {
-  state: TriggerState;
-  onEnter: (e: MouseEvent) => void;
-  onMove: (e: MouseEvent) => void;
-  onLeave: () => void;
-  onBtnEnter: () => void;
-  onBtnLeave: () => void;
-  destroy: () => void;
-}
-
-export interface TriggerOptions {
-  /** 鼠标停顿多久后显示按钮 (ms),默认 400 */
-  pauseDelay?: number;
-  /** 按钮显示后多久自动消失 (ms),默认 3000 */
-  hideDelay?: number;
-}
-
-/**
- * 创建一个悬浮按钮触发器实例。
- * 每个需要数据解读的 DOM 元素(卡片行、表格行、3D 标记等)对应一个 trigger。
- *
- * 使用方式:
- * 1. 在 v-for 中为每行数据 createTrigger()
- * 2. 将 onEnter/onMove/onLeave 绑定到容器元素的 mouseenter/mousemove/mouseleave
- * 3. 将 DataInterpretationBtn 放在容器内,绑定 state 和 onBtnEnter/onBtnLeave
- */
-export function createTrigger(options?: TriggerOptions): TriggerHandle {
-  const PAUSE_DELAY = options?.pauseDelay ?? 400;
-  const HIDE_DELAY = options?.hideDelay ?? 3000;
-
-  const state = reactive<TriggerState>({
-    visible: false,
-    x: 0,
-    y: 0,
-  });
-
-  let pauseTimer: ReturnType<typeof setTimeout> | null = null;
-  let hideTimer: ReturnType<typeof setTimeout> | null = null;
-
-  function trackMouse(e: MouseEvent) {
-    const target = e.currentTarget as HTMLElement;
-    const rect = target.getBoundingClientRect();
-    state.x = e.clientX - rect.left - 36;
-    state.y = e.clientY - rect.top - 34;
-  }
-
-  function startPauseTimer() {
-    pauseTimer = setTimeout(() => {
-      state.visible = true;
-      startHideTimer();
-    }, PAUSE_DELAY);
-  }
-
-  function resetPauseTimer() {
-    if (pauseTimer) {
-      clearTimeout(pauseTimer);
-      pauseTimer = null;
-    }
-  }
-
-  function startHideTimer() {
-    clearHideTimer();
-    hideTimer = setTimeout(() => {
-      state.visible = false;
-    }, HIDE_DELAY);
-  }
-
-  function clearHideTimer() {
-    if (hideTimer) {
-      clearTimeout(hideTimer);
-      hideTimer = null;
-    }
-  }
-
-  function onEnter(e: MouseEvent) {
-    clearHideTimer();
-    trackMouse(e);
-    startPauseTimer();
-  }
-
-  function onMove(e: MouseEvent) {
-    if (state.visible) return;
-    trackMouse(e);
-    resetPauseTimer();
-    startPauseTimer();
-  }
-
-  function onLeave() {
-    resetPauseTimer();
-    clearHideTimer();
-    state.visible = false;
-  }
-
-  function onBtnEnter() {
-    clearHideTimer();
-  }
-
-  function onBtnLeave() {
-    startHideTimer();
-  }
-
-  function destroy() {
-    resetPauseTimer();
-    clearHideTimer();
-  }
-
-  return {
-    state,
-    onEnter,
-    onMove,
-    onLeave,
-    onBtnEnter,
-    onBtnLeave,
-    destroy,
-  };
-}

+ 399 - 0
src/views/ventAI/dataPicker/useDataPicker.ts

@@ -0,0 +1,399 @@
+import { ref, h, createApp, Teleport, type Directive, type App } from 'vue';
+import Antd from 'ant-design-vue';
+import { fetchTunnelInterpretation, fetchDeviceInterpretation } from './api';
+import type { TodoItem, ToolCallRecord, SSERawEvent } from './types';
+import DataPickerModal from './components/DataPickerModal.vue';
+
+// ─── 按钮 DOM 构建(悬停模式专用)──────────────────────────────────────────
+const BUTTON_CLASS = 'di-floating-btn';
+
+function createButtonEl(): HTMLElement {
+  const btn = document.createElement('div');
+  btn.className = BUTTON_CLASS;
+  btn.innerHTML = '<span>数据解读</span>';
+  Object.assign(btn.style, {
+    position: 'fixed',
+    zIndex: '99999',
+    padding: '8px 12px',
+    background: 'linear-gradient(135deg, #1e90ff, #4da6ff)',
+    borderRadius: '3px',
+    cursor: 'pointer',
+    fontSize: '16px',
+    color: '#fff',
+    letterSpacing: '1px',
+    whiteSpace: 'nowrap',
+    boxShadow: '0 0 10px rgba(30,144,255,0.4)',
+    display: 'none',
+    pointerEvents: 'auto',
+    transition: 'transform 0.15s ease-out, opacity 0.15s ease-out',
+    transform: 'scale(0.9)',
+    opacity: '0',
+  });
+  btn.addEventListener('mouseenter', () => {
+    const owner = (btn as any).__di_owner;
+    if (owner) {
+      owner.cancelLeave();
+      owner.clearHideTimer();
+    }
+  });
+  btn.addEventListener('mouseleave', () => {
+    const owner = (btn as any).__di_owner;
+    if (owner) owner.startHideTimer();
+  });
+  return btn;
+}
+
+// ─── 悬停触发器实例 ────────────────────────────────────────────────────────
+const PAUSE_DELAY = 400;
+const HIDE_DELAY = 3000;
+
+function createHoverInstance(btn: HTMLElement) {
+  let pauseTimer: ReturnType<typeof setTimeout> | null = null;
+  let hideTimer: ReturnType<typeof setTimeout> | null = null;
+  let leaveTimer: ReturnType<typeof setTimeout> | null = null;
+  let visible = false;
+  let raw: any = null;
+  let hostEl: HTMLElement | null = null;
+
+  function setPosition(x: number, y: number) {
+    btn.style.left = x + 'px';
+    btn.style.top = y + 'px';
+  }
+
+  function detachMove() {
+    if (hostEl && inst.onMove) hostEl.removeEventListener('mousemove', inst.onMove);
+  }
+
+  function attachMove() {
+    if (hostEl && inst.onMove) hostEl.addEventListener('mousemove', inst.onMove);
+  }
+
+  function show() {
+    clearPauseTimer();
+    detachMove();
+    visible = true;
+    btn.style.display = 'block';
+    requestAnimationFrame(() => {
+      btn.style.transform = 'scale(1)';
+      btn.style.opacity = '1';
+    });
+    startHideTimer();
+  }
+
+  function hide() {
+    visible = false;
+    attachMove();
+    btn.style.display = 'none';
+    btn.style.transform = 'scale(0.9)';
+    btn.style.opacity = '0';
+  }
+
+  function clearPauseTimer() {
+    if (pauseTimer !== null) {
+      clearTimeout(pauseTimer);
+      pauseTimer = null;
+    }
+  }
+
+  function clearHideTimer() {
+    if (hideTimer !== null) {
+      clearTimeout(hideTimer);
+      hideTimer = null;
+    }
+  }
+
+  function startHideTimer() {
+    clearHideTimer();
+    hideTimer = setTimeout(hide, HIDE_DELAY);
+  }
+
+  const inst = {
+    get visible() {
+      return visible;
+    },
+    get raw() {
+      return raw;
+    },
+    setHost(el: HTMLElement) {
+      hostEl = el;
+    },
+    onEnter(e: MouseEvent) {
+      clearHideTimer();
+      if (!visible) {
+        setPosition(e.clientX - 36, e.clientY - 34);
+        clearPauseTimer();
+        pauseTimer = setTimeout(show, PAUSE_DELAY);
+      } else {
+        startHideTimer();
+      }
+    },
+    onMove(e: MouseEvent) {
+      setPosition(e.clientX - 36, e.clientY - 34);
+      clearPauseTimer();
+      pauseTimer = setTimeout(show, PAUSE_DELAY);
+    },
+    onLeave() {
+      clearPauseTimer();
+      clearHideTimer();
+      leaveTimer = setTimeout(hide, 150);
+    },
+    cancelLeave() {
+      if (leaveTimer !== null) {
+        clearTimeout(leaveTimer);
+        leaveTimer = null;
+      }
+    },
+    setRaw(val: any) {
+      raw = val;
+    },
+    clearHideTimer,
+    startHideTimer,
+    destroy() {
+      clearPauseTimer();
+      clearHideTimer();
+      if (leaveTimer !== null) {
+        clearTimeout(leaveTimer);
+        leaveTimer = null;
+      }
+      btn.remove();
+    },
+  };
+
+  return inst;
+}
+
+type HoverInstance = ReturnType<typeof createHoverInstance>;
+
+// ─── 钩子主函数 ─────────────────────────────────────────────────────────────
+export interface DataPickerOptions {
+  /** hover: 悬停出现浮动按钮;click: 点击元素直接触发 */
+  mode?: 'hover' | 'click';
+}
+
+export function useDataPicker(
+  mapParams: (item: any) => { tun_id: string; tun_name: string } | { device_id: string; device_name: string; device_type: string },
+  options: DataPickerOptions = {}
+) {
+  console.log('useDataPicker', mapParams);
+  const { mode = 'hover' } = options;
+
+  const loading = ref(false);
+  const streamingText = ref('');
+  const error = ref<string | null>(null);
+  const thinking = ref(false);
+  const todos = ref<TodoItem[]>([]);
+  const executingTools = ref<string[]>([]);
+  const toolCalls = ref<ToolCallRecord[]>([]);
+  const modalVisible = ref(false);
+
+  // 弹框容器 + 独立 Vue 应用
+  const modalContainer = document.createElement('div');
+  Object.assign(modalContainer.style, {
+    position: 'fixed',
+    top: '0',
+    left: '0',
+    width: '100%',
+    height: '100%',
+    pointerEvents: 'none',
+    zIndex: '1000',
+  });
+  document.body.appendChild(modalContainer);
+
+  let modalApp: App | null = null;
+
+  function mountModal() {
+    modalApp = createApp({
+      setup: () => ({
+        visible: modalVisible,
+        loading,
+        streamingText,
+        error,
+        thinking,
+        todos,
+        executingTools,
+        toolCalls,
+        onClose: () => {
+          modalVisible.value = false;
+          streamingText.value = '';
+          error.value = null;
+          thinking.value = false;
+          todos.value = [];
+          executingTools.value = [];
+          toolCalls.value = [];
+        },
+      }),
+      render() {
+        return h(Teleport, { to: 'body' }, [
+          h(DataPickerModal, {
+            visible: this.visible,
+            loading: this.loading,
+            streamingText: this.streamingText,
+            error: this.error,
+            thinking: this.thinking,
+            todos: this.todos,
+            executingTools: this.executingTools,
+            toolCalls: this.toolCalls,
+            onClose: this.onClose,
+          }),
+        ]);
+      },
+    });
+    modalApp.use(Antd);
+    modalApp.mount(modalContainer);
+  }
+
+  mountModal();
+
+  // SSE 事件处理
+  function handleEvent(event: SSERawEvent) {
+    switch (event.type) {
+      case 'thinking':
+        thinking.value = true;
+        break;
+      case 'executing':
+        thinking.value = false;
+        executingTools.value = event.tools || [];
+        break;
+      case 'tool_call':
+        executingTools.value = [];
+        toolCalls.value = [...toolCalls.value, { tool: event.tool || '', source: event.source || 'main', status: 'call' as const }];
+        break;
+      case 'tool_result':
+        if (toolCalls.value.length > 0) {
+          const last = toolCalls.value[toolCalls.value.length - 1];
+          if (last.tool === event.tool && last.status === 'call') last.status = 'result';
+        }
+        break;
+      case 'updated_todo_list':
+        thinking.value = false;
+        todos.value = event.todos || [];
+        break;
+      case 'token':
+        thinking.value = false;
+        if (event.content) streamingText.value += event.content;
+        break;
+    }
+  }
+
+  // 点击触发
+  function handleClick(item: any) {
+    modalVisible.value = true;
+    loading.value = true;
+    error.value = null;
+    streamingText.value = '';
+    thinking.value = false;
+    todos.value = [];
+    executingTools.value = [];
+    toolCalls.value = [];
+
+    const params = mapParams(item);
+    const request = 'tun_id' in params ? fetchTunnelInterpretation(params, handleEvent) : fetchDeviceInterpretation(params, handleEvent);
+
+    request
+      .catch((e: any) => {
+        error.value = e?.message || '请求失败';
+      })
+      .finally(() => {
+        loading.value = false;
+        thinking.value = false;
+        executingTools.value = [];
+      });
+  }
+
+  // ── 悬停模式:按钮容器 + 触发器 ──
+  let btnContainer: HTMLDivElement | null = null;
+  const hoverMap = new Map<Element, HoverInstance>();
+
+  if (mode === 'hover') {
+    btnContainer = document.createElement('div');
+    Object.assign(btnContainer.style, { position: 'fixed', top: '0', left: '0', zIndex: '99999', pointerEvents: 'none' });
+    document.body.appendChild(btnContainer);
+  }
+
+  function createHoverTrigger(): HoverInstance {
+    const btn = createButtonEl();
+    btnContainer!.appendChild(btn);
+    const inst = createHoverInstance(btn);
+    (btn as any).__di_owner = inst;
+    btn.addEventListener('click', (e) => {
+      e.stopPropagation();
+      const item = (inst as any).raw;
+      if (item) handleClick(item);
+    });
+    return inst;
+  }
+
+  // ── 点击模式:元素映射 ──
+  const clickRawMap = new Map<Element, any>();
+  const clickHandlerMap = new Map<Element, (e: Event) => void>();
+
+  // ── Vue 指令 ──
+  const directive: Directive = {
+    mounted(el, binding) {
+      if (mode === 'hover') {
+        const inst = createHoverTrigger();
+        inst.setHost(el);
+        inst.setRaw(binding.value);
+        hoverMap.set(el, inst);
+        el.addEventListener('mouseenter', inst.onEnter);
+        el.addEventListener('mousemove', inst.onMove);
+        el.addEventListener('mouseleave', inst.onLeave);
+      } else {
+        clickRawMap.set(el, binding.value);
+        const handler = (e: Event) => {
+          e.stopPropagation();
+          const item = clickRawMap.get(el);
+          if (item) handleClick(item);
+        };
+        clickHandlerMap.set(el, handler);
+        el.addEventListener('click', handler);
+      }
+    },
+    updated(el, binding) {
+      if (mode === 'hover') {
+        const inst = hoverMap.get(el);
+        if (inst) inst.setRaw(binding.value);
+      } else {
+        clickRawMap.set(el, binding.value);
+      }
+    },
+    unmounted(el) {
+      if (mode === 'hover') {
+        const inst = hoverMap.get(el);
+        if (inst) {
+          inst.destroy();
+          hoverMap.delete(el);
+        }
+      } else {
+        const handler = clickHandlerMap.get(el);
+        if (handler) {
+          el.removeEventListener('click', handler);
+          clickHandlerMap.delete(el);
+        }
+        clickRawMap.delete(el);
+      }
+    },
+  };
+
+  function cleanup() {
+    if (mode === 'hover') {
+      hoverMap.forEach((inst) => inst.destroy());
+      hoverMap.clear();
+      if (btnContainer) {
+        btnContainer.remove();
+        btnContainer = null;
+      }
+    } else {
+      clickRawMap.clear();
+      clickHandlerMap.forEach((handler, el) => el.removeEventListener('click', handler));
+      clickHandlerMap.clear();
+    }
+    if (modalApp) {
+      modalApp.unmount();
+      modalApp = null;
+    }
+    modalContainer.remove();
+  }
+
+  return { directive, cleanup, loading, streamingText, error, thinking, todos, executingTools, toolCalls, modalVisible };
+}