useDataPicker.ts 12 KB

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