useDataPicker.ts 12 KB

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