useDataPicker.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. import { ref, h, createApp, Teleport, type Directive, type App } from 'vue';
  2. import Antd from 'ant-design-vue';
  3. import { fetchTunnelInterpretation, fetchDeviceInterpretation } from './api';
  4. import type { TodoItem, ToolCallRecord, SSERawEvent } from './types';
  5. import DataPickerModal from './components/DataPickerModal.vue';
  6. import aiIcon from '@/assets/images/ventAI/dataPicker/AI-icon.svg';
  7. // ─── 全局开关:控制悬停模式浮动按钮是否启用 ─────────────────────────────────
  8. const STORAGE_KEY = 'dataPickerHoverEnabled';
  9. export const dataPickerHoverEnabled = ref(localStorage.getItem(STORAGE_KEY) !== 'false');
  10. /** 记录所有活跃的悬停实例,以便全局开关关闭时立即隐藏按钮 */
  11. const allHoverInstances = new Set<HoverInstance>();
  12. export function setDataPickerHoverEnabled(val: boolean) {
  13. dataPickerHoverEnabled.value = val;
  14. localStorage.setItem(STORAGE_KEY, String(val));
  15. if (!val) {
  16. // 关闭时立即隐藏所有浮动按钮并清除定时器
  17. allHoverInstances.forEach((inst) => {
  18. inst.cancelLeave();
  19. inst.clearHideTimer();
  20. const btn = (inst as any)._btn as HTMLElement;
  21. if (btn) {
  22. btn.style.display = 'none';
  23. btn.style.transform = 'scale(0.9)';
  24. btn.style.opacity = '0';
  25. }
  26. // 重新挂载 mousemove 监听,以便开启后能正常触发
  27. inst.attachMove();
  28. });
  29. }
  30. }
  31. // ─── 按钮 DOM 构建(悬停模式专用)──────────────────────────────────────────
  32. const BUTTON_CLASS = 'di-floating-btn';
  33. function createButtonEl(): HTMLElement {
  34. const btn = document.createElement('div');
  35. btn.className = BUTTON_CLASS;
  36. btn.title = '数据解读';
  37. btn.innerHTML = `<img src="${aiIcon}" width="24" height="24" alt="数据解读" />`;
  38. Object.assign(btn.style, {
  39. position: 'fixed',
  40. zIndex: '99999',
  41. padding: '8px',
  42. background: 'linear-gradient(135deg, #1e90ff, #4da6ff)',
  43. borderRadius: '50%',
  44. cursor: 'pointer',
  45. display: 'none',
  46. pointerEvents: 'auto',
  47. transition: 'transform 0.15s ease-out, opacity 0.15s ease-out',
  48. transform: 'scale(0.9)',
  49. opacity: '0',
  50. boxShadow: '0 0 10px rgba(30,144,255,0.4)',
  51. });
  52. btn.addEventListener('mouseenter', () => {
  53. const owner = (btn as any).__di_owner;
  54. if (owner) {
  55. owner.cancelLeave();
  56. owner.clearHideTimer();
  57. }
  58. });
  59. btn.addEventListener('mouseleave', () => {
  60. const owner = (btn as any).__di_owner;
  61. if (owner) owner.startHideTimer();
  62. });
  63. return btn;
  64. }
  65. // ─── 悬停触发器实例 ────────────────────────────────────────────────────────
  66. const PAUSE_DELAY = 400;
  67. const HIDE_DELAY = 3000;
  68. function createHoverInstance(btn: HTMLElement) {
  69. let pauseTimer: ReturnType<typeof setTimeout> | null = null;
  70. let hideTimer: ReturnType<typeof setTimeout> | null = null;
  71. let leaveTimer: ReturnType<typeof setTimeout> | null = null;
  72. let visible = false;
  73. let raw: any = null;
  74. let hostEl: HTMLElement | null = null;
  75. function setPosition(x: number, y: number) {
  76. btn.style.left = x + 'px';
  77. btn.style.top = y + 'px';
  78. }
  79. function detachMove() {
  80. if (hostEl && inst.onMove) hostEl.removeEventListener('mousemove', inst.onMove);
  81. }
  82. function attachMove() {
  83. if (hostEl && inst.onMove) hostEl.addEventListener('mousemove', inst.onMove);
  84. }
  85. function show() {
  86. clearPauseTimer();
  87. detachMove();
  88. visible = true;
  89. btn.style.display = 'block';
  90. requestAnimationFrame(() => {
  91. btn.style.transform = 'scale(1)';
  92. btn.style.opacity = '1';
  93. });
  94. startHideTimer();
  95. }
  96. function hide() {
  97. visible = false;
  98. attachMove();
  99. btn.style.display = 'none';
  100. btn.style.transform = 'scale(0.9)';
  101. btn.style.opacity = '0';
  102. }
  103. function clearPauseTimer() {
  104. if (pauseTimer !== null) {
  105. clearTimeout(pauseTimer);
  106. pauseTimer = null;
  107. }
  108. }
  109. function clearHideTimer() {
  110. if (hideTimer !== null) {
  111. clearTimeout(hideTimer);
  112. hideTimer = null;
  113. }
  114. }
  115. function startHideTimer() {
  116. clearHideTimer();
  117. hideTimer = setTimeout(hide, HIDE_DELAY);
  118. }
  119. const inst = {
  120. get visible() {
  121. return visible;
  122. },
  123. get raw() {
  124. return raw;
  125. },
  126. setHost(el: HTMLElement) {
  127. hostEl = el;
  128. },
  129. onEnter(e: MouseEvent) {
  130. if (!dataPickerHoverEnabled.value) return;
  131. clearHideTimer();
  132. if (!visible) {
  133. setPosition(e.clientX - 36, e.clientY - 34);
  134. clearPauseTimer();
  135. pauseTimer = setTimeout(show, PAUSE_DELAY);
  136. } else {
  137. startHideTimer();
  138. }
  139. },
  140. onMove(e: MouseEvent) {
  141. if (!dataPickerHoverEnabled.value) return;
  142. setPosition(e.clientX - 36, e.clientY - 34);
  143. clearPauseTimer();
  144. pauseTimer = setTimeout(show, PAUSE_DELAY);
  145. },
  146. onLeave() {
  147. clearPauseTimer();
  148. clearHideTimer();
  149. leaveTimer = setTimeout(hide, 150);
  150. },
  151. cancelLeave() {
  152. if (leaveTimer !== null) {
  153. clearTimeout(leaveTimer);
  154. leaveTimer = null;
  155. }
  156. },
  157. setRaw(val: any) {
  158. raw = val;
  159. },
  160. clearHideTimer,
  161. startHideTimer,
  162. attachMove,
  163. destroy() {
  164. clearPauseTimer();
  165. clearHideTimer();
  166. if (leaveTimer !== null) {
  167. clearTimeout(leaveTimer);
  168. leaveTimer = null;
  169. }
  170. btn.remove();
  171. },
  172. };
  173. return inst;
  174. }
  175. type HoverInstance = ReturnType<typeof createHoverInstance>;
  176. // ─── 钩子主函数 ─────────────────────────────────────────────────────────────
  177. export interface DataPickerOptions {
  178. /** hover: 悬停出现浮动按钮;click: 点击元素直接触发 */
  179. mode?: 'hover' | 'click';
  180. }
  181. export function useDataPicker(
  182. mapParams: (
  183. item: any
  184. ) => { tun_id: string; tun_name: string; mode?: string } | { device_id: string; device_name: string; device_type: string; mode?: string },
  185. options: DataPickerOptions = {}
  186. ) {
  187. console.log('useDataPicker', mapParams);
  188. const { mode = 'hover' } = options;
  189. const loading = ref(false);
  190. const streamingText = ref('');
  191. const error = ref<string | null>(null);
  192. const thinking = ref(false);
  193. const todos = ref<TodoItem[]>([]);
  194. const executingTools = ref<string[]>([]);
  195. const toolCalls = ref<ToolCallRecord[]>([]);
  196. const latestMessage = ref('');
  197. const modalVisible = ref(false);
  198. const itemName = ref('');
  199. // 弹框容器 + 独立 Vue 应用
  200. const modalContainer = document.createElement('div');
  201. Object.assign(modalContainer.style, {
  202. position: 'fixed',
  203. top: '0',
  204. left: '0',
  205. width: '100%',
  206. height: '100%',
  207. pointerEvents: 'none',
  208. zIndex: '1000',
  209. });
  210. document.body.appendChild(modalContainer);
  211. let modalApp: App | null = null;
  212. function mountModal() {
  213. modalApp = createApp({
  214. setup: () => ({
  215. visible: modalVisible,
  216. loading,
  217. streamingText,
  218. error,
  219. thinking,
  220. todos,
  221. executingTools,
  222. toolCalls,
  223. latestMessage,
  224. itemName,
  225. onClose: () => {
  226. modalVisible.value = false;
  227. streamingText.value = '';
  228. error.value = null;
  229. thinking.value = false;
  230. todos.value = [];
  231. executingTools.value = [];
  232. toolCalls.value = [];
  233. latestMessage.value = '';
  234. },
  235. }),
  236. render() {
  237. return h(Teleport, { to: 'body' }, [
  238. h(DataPickerModal, {
  239. visible: this.visible,
  240. loading: this.loading,
  241. streamingText: this.streamingText,
  242. error: this.error,
  243. thinking: this.thinking,
  244. todos: this.todos,
  245. executingTools: this.executingTools,
  246. toolCalls: this.toolCalls,
  247. latestMessage: this.latestMessage,
  248. itemName: this.itemName,
  249. onClose: this.onClose,
  250. }),
  251. ]);
  252. },
  253. });
  254. modalApp.use(Antd);
  255. modalApp.mount(modalContainer);
  256. }
  257. mountModal();
  258. // SSE 事件处理
  259. function handleEvent(event: SSERawEvent) {
  260. switch (event.type) {
  261. case 'thinking':
  262. thinking.value = true;
  263. break;
  264. case 'executing':
  265. thinking.value = false;
  266. executingTools.value = event.tools || [];
  267. if (event.message) latestMessage.value = event.message;
  268. break;
  269. case 'tool_call':
  270. executingTools.value = [];
  271. toolCalls.value = [...toolCalls.value, { tool: event.tool || '', source: event.source || 'main', status: 'call' as const }];
  272. if (event.message) latestMessage.value = event.message;
  273. break;
  274. case 'tool_result':
  275. if (toolCalls.value.length > 0) {
  276. const last = toolCalls.value[toolCalls.value.length - 1];
  277. if (last.tool === event.tool && last.status === 'call') last.status = 'result';
  278. }
  279. if (event.message) latestMessage.value = event.message;
  280. break;
  281. case 'updated_todo_list':
  282. thinking.value = false;
  283. todos.value = event.todos || [];
  284. break;
  285. case 'token':
  286. thinking.value = false;
  287. if (event.content) streamingText.value += event.content;
  288. break;
  289. }
  290. }
  291. // 点击触发
  292. function handleClick(item: any) {
  293. modalVisible.value = true;
  294. loading.value = true;
  295. error.value = null;
  296. streamingText.value = '';
  297. thinking.value = false;
  298. todos.value = [];
  299. executingTools.value = [];
  300. toolCalls.value = [];
  301. latestMessage.value = '';
  302. const params = mapParams(item);
  303. itemName.value = 'tun_name' in params ? params.tun_name : params.device_name;
  304. const request = 'tun_id' in params ? fetchTunnelInterpretation(params, handleEvent) : fetchDeviceInterpretation(params, handleEvent);
  305. request
  306. .catch((e: any) => {
  307. error.value = e?.message || '请求失败';
  308. })
  309. .finally(() => {
  310. loading.value = false;
  311. thinking.value = false;
  312. executingTools.value = [];
  313. });
  314. }
  315. // ── 悬停模式:按钮容器 + 触发器 ──
  316. let btnContainer: HTMLDivElement | null = null;
  317. const hoverMap = new Map<Element, HoverInstance>();
  318. if (mode === 'hover') {
  319. btnContainer = document.createElement('div');
  320. Object.assign(btnContainer.style, { position: 'fixed', top: '0', left: '0', zIndex: '99999', pointerEvents: 'none' });
  321. document.body.appendChild(btnContainer);
  322. }
  323. function createHoverTrigger(): HoverInstance {
  324. const btn = createButtonEl();
  325. btnContainer!.appendChild(btn);
  326. const inst = createHoverInstance(btn);
  327. (btn as any).__di_owner = inst;
  328. (inst as any)._btn = btn;
  329. allHoverInstances.add(inst);
  330. btn.addEventListener('click', (e) => {
  331. e.stopPropagation();
  332. const item = (inst as any).raw;
  333. if (item) handleClick(item);
  334. });
  335. return inst;
  336. }
  337. // ── 点击模式:元素映射 ──
  338. const clickRawMap = new Map<Element, any>();
  339. const clickHandlerMap = new Map<Element, (e: Event) => void>();
  340. // ── Vue 指令 ──
  341. const directive: Directive = {
  342. mounted(el, binding) {
  343. if (mode === 'hover') {
  344. const inst = createHoverTrigger();
  345. inst.setHost(el);
  346. inst.setRaw(binding.value);
  347. hoverMap.set(el, inst);
  348. el.addEventListener('mouseenter', inst.onEnter);
  349. el.addEventListener('mousemove', inst.onMove);
  350. el.addEventListener('mouseleave', inst.onLeave);
  351. } else {
  352. clickRawMap.set(el, binding.value);
  353. const handler = (e: Event) => {
  354. e.stopPropagation();
  355. const item = clickRawMap.get(el);
  356. if (item) handleClick(item);
  357. };
  358. clickHandlerMap.set(el, handler);
  359. el.addEventListener('click', handler);
  360. }
  361. },
  362. updated(el, binding) {
  363. if (mode === 'hover') {
  364. const inst = hoverMap.get(el);
  365. if (inst) inst.setRaw(binding.value);
  366. } else {
  367. clickRawMap.set(el, binding.value);
  368. }
  369. },
  370. unmounted(el) {
  371. if (mode === 'hover') {
  372. const inst = hoverMap.get(el);
  373. if (inst) {
  374. allHoverInstances.delete(inst);
  375. inst.destroy();
  376. hoverMap.delete(el);
  377. }
  378. } else {
  379. const handler = clickHandlerMap.get(el);
  380. if (handler) {
  381. el.removeEventListener('click', handler);
  382. clickHandlerMap.delete(el);
  383. }
  384. clickRawMap.delete(el);
  385. }
  386. },
  387. };
  388. function cleanup() {
  389. if (mode === 'hover') {
  390. hoverMap.forEach((inst) => inst.destroy());
  391. hoverMap.clear();
  392. if (btnContainer) {
  393. btnContainer.remove();
  394. btnContainer = null;
  395. }
  396. } else {
  397. clickRawMap.clear();
  398. clickHandlerMap.forEach((handler, el) => el.removeEventListener('click', handler));
  399. clickHandlerMap.clear();
  400. }
  401. if (modalApp) {
  402. modalApp.unmount();
  403. modalApp = null;
  404. }
  405. modalContainer.remove();
  406. }
  407. return { directive, cleanup, loading, streamingText, error, thinking, todos, executingTools, toolCalls, latestMessage, modalVisible };
  408. }