|
|
@@ -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 };
|
|
|
+}
|