useDataPicker.ts 14 KB

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