Răsfoiți Sursa

[Mod 0000]修改数据点选渲染逻辑

wangkeyi 1 zi în urmă
părinte
comite
d0f532d9a1

+ 132 - 17
src/views/ventAI/dataPicker/components/DataPickerModal.vue

@@ -13,21 +13,34 @@
       <span class="di-header__title">数据解读</span>
       <span class="di-header__close" @click="$emit('close')">✕</span>
     </div>
-    <div class="di-body">
+    <div ref="bodyRef" 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>
-        <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">
-              <span v-if="todo.status === 'completed'" class="di-todo-check">✓</span>
-              <a-spin v-else-if="todo.status === 'in_progress'" size="small" />
-              <span v-else class="di-todo-dot"></span>
-            </span>
-            <span class="di-todo-item__text">{{ todo.content }}</span>
-          </div>
+        <div v-if="processCollapsed" class="di-process__summary" @click="processCollapsed = false">
+          <span class="di-process__summary-icon">▸</span>
+          <span>查看处理过程</span>
         </div>
+        <template v-else>
+          <div v-if="!props.loading" class="di-process__summary" @click="processCollapsed = true">
+            <span class="di-process__summary-icon">▾</span>
+            <span>收起处理过程</span>
+          </div>
+          <div v-if="thinking" class="di-process__row di-process__thinking"><a-spin size="small" /><span>AI 正在思考...</span></div>
+          <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">
+                <span v-if="todo.status === 'completed'" class="di-todo-check">✓</span>
+                <span v-else-if="todo.status === 'in_progress'" class="di-todo-spinner"></span>
+                <span v-else class="di-todo-dot"></span>
+              </span>
+              <span class="di-todo-item__text">{{ todo.content }}</span>
+            </div>
+          </div>
+          <div v-if="props.latestMessage && loading" class="di-process__row di-process__message">
+            <span class="di-process__dot"></span><span>{{ props.latestMessage }}</span>
+          </div>
+        </template>
       </div>
-      <div v-if="loading && !hasContent && !hasProcess" class="di-loading"><a-spin tip="正在分析数据..." /></div>
+      <div v-if="loading && !hasContent" 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>
@@ -36,7 +49,7 @@
 </template>
 
 <script lang="ts" setup>
-  import { computed } from 'vue';
+  import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
   import { marked } from 'marked';
   import type { TodoItem, ToolCallRecord } from '../types';
 
@@ -49,13 +62,66 @@
     todos: TodoItem[];
     executingTools: string[];
     toolCalls: ToolCallRecord[];
+    latestMessage: string;
   }>();
 
   defineEmits<{ close: [] }>();
 
+  const bodyRef = ref<HTMLDivElement>();
+  const isNearBottom = ref(true);
+
+  function checkNearBottom() {
+    const el = bodyRef.value;
+    if (!el) return;
+    isNearBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
+  }
+
+  function scrollToBottom() {
+    const el = bodyRef.value;
+    if (!el) return;
+    el.scrollTop = el.scrollHeight;
+  }
+
+  let scrollListenerAttached = false;
+
+  onMounted(() => {
+    const el = bodyRef.value;
+    if (el) {
+      el.addEventListener('scroll', checkNearBottom, { passive: true });
+      scrollListenerAttached = true;
+    }
+  });
+
+  onBeforeUnmount(() => {
+    if (scrollListenerAttached && bodyRef.value) {
+      bodyRef.value.removeEventListener('scroll', checkNearBottom);
+    }
+  });
+
   const hasContent = computed(() => !!props.streamingText);
-  const hasProcess = computed(() => props.thinking || props.todos.length > 0 || props.executingTools.length > 0 || props.toolCalls.length > 0);
+  const hasProcess = computed(
+    () => props.thinking || props.todos.length > 0 || props.executingTools.length > 0 || props.toolCalls.length > 0 || !!props.latestMessage
+  );
   const showProcess = computed(() => hasProcess.value);
+  const processCollapsed = ref(false);
+
+  watch(
+    () => props.loading,
+    (val, oldVal) => {
+      if (oldVal && !val && hasProcess.value) processCollapsed.value = true;
+    }
+  );
+
+  watch(
+    () => [props.streamingText, props.latestMessage, props.todos],
+    () => {
+      if (isNearBottom.value) {
+        nextTick(scrollToBottom);
+      }
+    },
+    { deep: true }
+  );
+
   const renderedText = computed(() => {
     if (!props.streamingText) return '';
     return marked.parse(props.streamingText.replace(/<br\s*\/?>/gi, '  \n')) as string;
@@ -84,7 +150,7 @@
       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);
+      backdrop-filter: blur(30px);
     }
     .ant-modal-body {
       padding: 0;
@@ -153,6 +219,42 @@
     color: #c8a853;
     font-size: 13px;
   }
+  .di-process__message {
+    color: #8899aa;
+    font-size: 13px;
+  }
+  .di-process__dot {
+    width: 6px;
+    height: 6px;
+    border-radius: 50%;
+    background: #4da6ff;
+    animation: di-pulse 1.4s ease-in-out infinite;
+  }
+  @keyframes di-pulse {
+    0%,
+    100% {
+      opacity: 0.3;
+    }
+    50% {
+      opacity: 1;
+    }
+  }
+  .di-process__summary {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    padding: 6px 0;
+    font-size: 13px;
+    color: #8899aa;
+    cursor: pointer;
+    user-select: none;
+    &:hover {
+      color: #4da6ff;
+    }
+  }
+  .di-process__summary-icon {
+    font-size: 12px;
+  }
   .di-process__todos {
     margin: 6px 0;
   }
@@ -193,9 +295,22 @@
     background: #4a5568;
     display: inline-block;
   }
+  .di-todo-spinner {
+    width: 12px;
+    height: 12px;
+    border: 2px solid rgba(77, 166, 255, 0.25);
+    border-top-color: #4da6ff;
+    border-radius: 50%;
+    animation: di-spin 0.8s linear infinite;
+  }
+  @keyframes di-spin {
+    to {
+      transform: rotate(360deg);
+    }
+  }
   .di-markdown {
     line-height: 1.8;
-    color: #c8d6e5;
+    color: #fff;
     word-break: break-word;
     :deep(h1),
     :deep(h2),
@@ -245,7 +360,7 @@
       }
       th {
         background: rgba(30, 144, 255, 0.1);
-        color: #4da6ff;
+        color: #000;
       }
     }
   }

+ 9 - 1
src/views/ventAI/dataPicker/useDataPicker.ts

@@ -184,6 +184,7 @@ export function useDataPicker(
   const todos = ref<TodoItem[]>([]);
   const executingTools = ref<string[]>([]);
   const toolCalls = ref<ToolCallRecord[]>([]);
+  const latestMessage = ref('');
   const modalVisible = ref(false);
 
   // 弹框容器 + 独立 Vue 应用
@@ -212,6 +213,7 @@ export function useDataPicker(
         todos,
         executingTools,
         toolCalls,
+        latestMessage,
         onClose: () => {
           modalVisible.value = false;
           streamingText.value = '';
@@ -220,6 +222,7 @@ export function useDataPicker(
           todos.value = [];
           executingTools.value = [];
           toolCalls.value = [];
+          latestMessage.value = '';
         },
       }),
       render() {
@@ -233,6 +236,7 @@ export function useDataPicker(
             todos: this.todos,
             executingTools: this.executingTools,
             toolCalls: this.toolCalls,
+            latestMessage: this.latestMessage,
             onClose: this.onClose,
           }),
         ]);
@@ -253,16 +257,19 @@ export function useDataPicker(
       case 'executing':
         thinking.value = false;
         executingTools.value = event.tools || [];
+        if (event.message) latestMessage.value = event.message;
         break;
       case 'tool_call':
         executingTools.value = [];
         toolCalls.value = [...toolCalls.value, { tool: event.tool || '', source: event.source || 'main', status: 'call' as const }];
+        if (event.message) latestMessage.value = event.message;
         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';
         }
+        if (event.message) latestMessage.value = event.message;
         break;
       case 'updated_todo_list':
         thinking.value = false;
@@ -285,6 +292,7 @@ export function useDataPicker(
     todos.value = [];
     executingTools.value = [];
     toolCalls.value = [];
+    latestMessage.value = '';
 
     const params = mapParams(item);
     const request = 'tun_id' in params ? fetchTunnelInterpretation(params, handleEvent) : fetchDeviceInterpretation(params, handleEvent);
@@ -395,5 +403,5 @@ export function useDataPicker(
     modalContainer.remove();
   }
 
-  return { directive, cleanup, loading, streamingText, error, thinking, todos, executingTools, toolCalls, modalVisible };
+  return { directive, cleanup, loading, streamingText, error, thinking, todos, executingTools, toolCalls, latestMessage, modalVisible };
 }