GraphAnalysisViewer.vue 71 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311
  1. <template>
  2. <div class="ai-graph-viewer">
  3. <div class="relation-stage">
  4. <div class="relation-stage__canvas">
  5. <div ref="stageRef" class="relation-stage__graph"></div>
  6. <div v-if="building" class="relation-stage__layout-loading">
  7. <div class="relation-stage__layout-loading-card">
  8. <div class="relation-stage__layout-loading-spinner"></div>
  9. <div class="relation-stage__layout-loading-title">正在渲染图谱</div>
  10. <div class="relation-stage__layout-loading-desc">正在计算节点位置并渲染图谱。</div>
  11. </div>
  12. </div>
  13. <div class="control-tools-box">
  14. <div v-if="activePanel" class="tool-panel">
  15. <div class="tool-panel__header">
  16. <div class="tool-panel__title">{{ activePanelLabel }}</div>
  17. <Button type="text" size="small" class="tool-panel__close" @click="activePanel = null">收起</Button>
  18. </div>
  19. <template v-if="activePanel === 'find'">
  20. <div class="tool-panel__row">
  21. <Input
  22. v-if="searchForm.scope === 'entity'"
  23. v-model:value="searchForm.keyword"
  24. placeholder="请输入查找内容"
  25. allow-clear
  26. @press-enter="runSearch"
  27. />
  28. <div v-else class="tool-panel__range-row">
  29. <InputNumber
  30. v-model:value="searchForm.min"
  31. :min="0"
  32. :precision="0"
  33. class="tool-panel__number"
  34. placeholder="最小值"
  35. @press-enter="runSearch"
  36. />
  37. <span class="tool-panel__range-separator">至</span>
  38. <InputNumber
  39. v-model:value="searchForm.max"
  40. :min="0"
  41. :precision="0"
  42. class="tool-panel__number"
  43. placeholder="最大值"
  44. @press-enter="runSearch"
  45. />
  46. </div>
  47. </div>
  48. <div v-if="searchForm.scope === 'entity'" class="tool-panel__switch-row">
  49. <button
  50. type="button"
  51. class="tool-panel__switch"
  52. :class="{ 'is-active': searchForm.manner === 'obscure' }"
  53. @click="searchForm.manner = 'obscure'"
  54. >
  55. 模糊查找
  56. </button>
  57. <button
  58. type="button"
  59. class="tool-panel__switch"
  60. :class="{ 'is-active': searchForm.manner === 'precise' }"
  61. @click="searchForm.manner = 'precise'"
  62. >
  63. 精确查找
  64. </button>
  65. </div>
  66. <div class="tool-panel__switch-row">
  67. <button
  68. type="button"
  69. class="tool-panel__switch"
  70. :class="{ 'is-active': searchForm.scope === 'entity' }"
  71. @click="searchForm.scope = 'entity'"
  72. >
  73. 查找实体
  74. </button>
  75. <button
  76. type="button"
  77. class="tool-panel__switch"
  78. :class="{ 'is-active': searchForm.scope === 'relation' }"
  79. @click="searchForm.scope = 'relation'"
  80. >
  81. 查找关系
  82. </button>
  83. </div>
  84. <div class="tool-panel__footer tool-panel__footer--find">
  85. <div v-if="searchResults.length" class="tool-panel__result">
  86. 共 {{ searchResults.length }} 个结果,当前第 {{ searchIndex + 1 }} 个
  87. </div>
  88. <div class="tool-panel__actions tool-panel__actions--find">
  89. <Button v-if="searchResults.length > 1" @click="selectAllSearchResults">全选结果</Button>
  90. <Button v-if="searchResults.length > 1" @click="prevSearchResult">上一个</Button>
  91. <Button type="primary" @click="runSearch">查找</Button>
  92. <Button v-if="searchResults.length > 1" @click="nextSearchResult">下一个</Button>
  93. </div>
  94. </div>
  95. </template>
  96. <template v-else-if="activePanel === 'filter'">
  97. <div class="tool-panel__row">
  98. <Select
  99. v-model:value="filterForm.kinds"
  100. class="tool-panel__entity-select"
  101. mode="multiple"
  102. allow-clear
  103. placeholder="请选择过滤实体"
  104. :options="kindOptions"
  105. />
  106. </div>
  107. <div class="tool-panel__switch-row">
  108. <button
  109. type="button"
  110. class="tool-panel__switch"
  111. :class="{ 'is-active': filterForm.condition === 'neighborTotal' }"
  112. @click="filterForm.condition = 'neighborTotal'"
  113. >
  114. 实体相邻个数
  115. </button>
  116. <button
  117. type="button"
  118. class="tool-panel__switch"
  119. :class="{ 'is-active': filterForm.condition === 'edgeValue' }"
  120. @click="filterForm.condition = 'edgeValue'"
  121. >
  122. 关系数值
  123. </button>
  124. </div>
  125. <div class="tool-panel__switch-row">
  126. <button
  127. v-for="operator in filterOperators"
  128. :key="operator"
  129. type="button"
  130. class="tool-panel__switch tool-panel__switch--compact"
  131. :class="{ 'is-active': filterForm.operator === operator }"
  132. @click="filterForm.operator = operator"
  133. >
  134. {{ operator }}
  135. </button>
  136. <InputNumber v-model:value="filterForm.value" :min="0" :precision="0" class="tool-panel__number" />
  137. </div>
  138. <div class="tool-panel__footer">
  139. <div class="tool-panel__actions">
  140. <Button type="primary" @click="executeFilter">开始过滤</Button>
  141. <Button @click="cancelFilter">取消过滤</Button>
  142. <Button v-if="filterMatchedNodeIds.length" @click="selectFiltered">全选结果</Button>
  143. <Button v-if="filterMatchedNodeIds.length" @click="toggleFilterHidden">
  144. {{ filterHidden ? '显示过滤' : '隐藏过滤' }}
  145. </Button>
  146. </div>
  147. </div>
  148. </template>
  149. </div>
  150. <div class="canvas-toolbar">
  151. <Tooltip title="查找">
  152. <Button
  153. type="text"
  154. class="canvas-toolbar__button"
  155. :class="{ 'is-active': activePanel === 'find' }"
  156. @click="togglePanel('find')"
  157. >
  158. <SearchOutlined />
  159. </Button>
  160. </Tooltip>
  161. <Tooltip title="过滤">
  162. <Button
  163. type="text"
  164. class="canvas-toolbar__button"
  165. :class="{ 'is-active': activePanel === 'filter' }"
  166. @click="togglePanel('filter')"
  167. >
  168. <FilterOutlined />
  169. </Button>
  170. </Tooltip>
  171. <span class="canvas-toolbar__divider"></span>
  172. <Tooltip title="缩小">
  173. <Button type="text" class="canvas-toolbar__button" @click="zoomOut">
  174. <MinusOutlined />
  175. </Button>
  176. </Tooltip>
  177. <Tooltip title="放大">
  178. <Button type="text" class="canvas-toolbar__button" @click="zoomIn">
  179. <PlusOutlined />
  180. </Button>
  181. </Tooltip>
  182. <Tooltip title="适配画布">
  183. <Button type="text" class="canvas-toolbar__button" @click="fitView">
  184. <FullscreenOutlined />
  185. </Button>
  186. </Tooltip>
  187. <Tooltip title="居中内容">
  188. <Button type="text" class="canvas-toolbar__button" @click="centerView">
  189. <AimOutlined />
  190. </Button>
  191. </Tooltip>
  192. <span class="canvas-toolbar__divider"></span>
  193. <Tooltip title="扩散关系">
  194. <Button type="text" class="canvas-toolbar__button" @click="selectNeighborElements">
  195. <ShareAltOutlined />
  196. </Button>
  197. </Tooltip>
  198. <Tooltip title="框选">
  199. <Button type="text" class="canvas-toolbar__button" @click="showBoxSelectTip">
  200. <BorderOutlined />
  201. </Button>
  202. </Tooltip>
  203. <Tooltip title="重新布局">
  204. <Button type="text" class="canvas-toolbar__button" @click="relayout">
  205. <ApartmentOutlined />
  206. </Button>
  207. </Tooltip>
  208. <Tooltip title="全屏查看">
  209. <Button type="text" class="canvas-toolbar__button" @click="emit('toggle-fullscreen')">
  210. <FullscreenOutlined />
  211. </Button>
  212. </Tooltip>
  213. </div>
  214. </div>
  215. <div class="canvas-status">
  216. <span class="canvas-status__item">提示:拖动画布可平移,滚轮可缩放</span>
  217. <span class="canvas-status__item">框选:按住 Shift 拖动画布</span>
  218. <span v-if="activeElementLabel" class="canvas-status__item">选中:{{ activeElementLabel }}</span>
  219. </div>
  220. </div>
  221. </div>
  222. <QueryTableModal
  223. :open="detailTableModalOpen"
  224. :title="detailTitle"
  225. :width="960"
  226. :loading="detailLoading"
  227. :filters="detailFilters"
  228. :filter-schema="detailFilterSchema"
  229. :columns="detailTableColumns"
  230. :data-source="detailTableRows"
  231. :pagination="detailPagination"
  232. :total-pages="detailTotalPages"
  233. :page-size-options="detailPageSizeOptions"
  234. :show-index-column="true"
  235. :show-table-setting="false"
  236. :table-setting="{ size: true, setting: true, redo: false, fullScreen: false }"
  237. :can-fullscreen="true"
  238. wrap-class-name="graph-analysis-detail-table-modal"
  239. @update:open="handleDetailVisibleChange"
  240. @search="handleDetailSearch"
  241. @reset="handleDetailReset"
  242. @prev-page="handleDetailPrevPage"
  243. @next-page="handleDetailNextPage"
  244. @page-size-change="handleDetailPageSizeChange"
  245. @page-change="handleDetailPageChange"
  246. @sort-change="handleDetailSortChange"
  247. />
  248. <BasicModal
  249. :open="detailSummaryModalOpen"
  250. :title="detailTitle"
  251. :width="960"
  252. :show-ok-btn="false"
  253. :show-cancel-btn="false"
  254. :can-fullscreen="true"
  255. :centered="true"
  256. wrap-class-name="graph-analysis-detail-modal"
  257. @update:open="handleDetailVisibleChange"
  258. @cancel="handleDetailVisibleChange(false)"
  259. >
  260. <div class="analysis-detail">
  261. <div v-if="detailEntries.length" class="analysis-detail__summary">
  262. <div v-for="item in detailEntries" :key="item.label" class="analysis-detail__entry">
  263. <div class="analysis-detail__entry-label">{{ item.label }}</div>
  264. <div class="analysis-detail__entry-value">{{ item.value }}</div>
  265. </div>
  266. </div>
  267. <Empty v-else description="暂无详情信息" />
  268. </div>
  269. </BasicModal>
  270. <TransRecordModal
  271. v-model:open="transDetailVisible"
  272. :title="transDetailTitle"
  273. :base-query="transDetailBaseQuery"
  274. :fetch-api="fetchTransDetailRecords"
  275. :filter-fields="['startDate', 'endDate', 'transAmount']"
  276. :trans-amount-operator-options="transAmountOperatorOptions"
  277. />
  278. <CallRecordModal
  279. v-model:open="callDetailVisible"
  280. :title="callDetailTitle"
  281. :base-query="callDetailBaseQuery"
  282. :fetch-api="fetchCallDetailRecords"
  283. :filter-fields="['startDate', 'endDate']"
  284. />
  285. </div>
  286. </template>
  287. <script lang="ts" setup>
  288. /**
  289. * AI 聊天关系图谱查看器(独立简版,不复用对象关系分析页面的组件/布局/API 封装)
  290. *
  291. * 保留:查找、过滤、缩放/适配/居中、节点/关系点击明细(节点详情、通话明细、交易明细)。
  292. * 移除:工具箱、画布 Tab、历史记录、保存、撤销/重做、复制/粘贴/删除、组合/拆分、
  293. * 新建画布、拖入实体、新建连线、标记色、右键编辑菜单、节点拖动。
  294. */
  295. import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue';
  296. import { Button, Empty, Input, InputNumber, Select, Tooltip } from 'ant-design-vue';
  297. import {
  298. AimOutlined,
  299. ApartmentOutlined,
  300. BorderOutlined,
  301. FilterOutlined,
  302. FullscreenOutlined,
  303. MinusOutlined,
  304. PlusOutlined,
  305. SearchOutlined,
  306. ShareAltOutlined,
  307. } from '@ant-design/icons-vue';
  308. import { CanvasEvent, EdgeEvent, Graph, GraphEvent, NodeEvent, type IElementEvent } from '@antv/g6';
  309. import { BasicModal } from '@/core/components/Modal';
  310. import componentSetting from '@/core/settings/componentSetting';
  311. import type { CompareOperator } from '@/core/utils/compareOperator';
  312. import { useMessage } from '@/core/hooks/web/useMessage';
  313. import QueryTableModal from '@/case/views/dm/components/QueryTableModal.vue';
  314. import CallRecordModal from '@/call/views/components/CallRecordModal.vue';
  315. import TransRecordModal from '@/trans/views/components/TransRecordModal.vue';
  316. import {
  317. buildGraphCallDetailQuery,
  318. buildGraphTransDetailQuery,
  319. fetchGraphCallDetail,
  320. fetchGraphNodeDetail,
  321. fetchGraphTransDetail,
  322. type GraphCallDetailQuery,
  323. type GraphNodeDetailQuery,
  324. type GraphTransDetailQuery,
  325. } from '@/graph/api/analysis';
  326. import { buildG6Data, type GraphModelEdge, type GraphModelNode, type ParsedGraph } from './graphData';
  327. import { ENTITY_META_LIST, getEntityMeta, registerAIGraphElements } from './graphElements';
  328. import { layoutGraph } from './graphLayout';
  329. const props = defineProps<{
  330. model: ParsedGraph;
  331. }>();
  332. const emit = defineEmits<{
  333. 'toggle-fullscreen': [];
  334. }>();
  335. const { showMessage } = useMessage();
  336. const FILTER_OPERATORS = ['>', '>=', '=', '<', '<='] as const;
  337. const filterOperators = FILTER_OPERATORS;
  338. const stageRef = ref<HTMLDivElement>();
  339. const graphRef = shallowRef<Graph | null>(null);
  340. let stageResizeObserver: ResizeObserver | null = null;
  341. const building = ref(false);
  342. const zoomPercent = ref(100);
  343. const performanceMode = ref(false);
  344. const contextMenuElementId = ref('');
  345. const selectedNode = ref<GraphModelNode | null>(null);
  346. const selectedEdge = ref<GraphModelEdge | null>(null);
  347. const nodeById = computed(() => new Map(props.model.nodes.map((node) => [node.id, node])));
  348. const edgeById = computed(() => new Map(props.model.edges.map((edge) => [edge.id, edge])));
  349. /* ---------------- 查找 ---------------- */
  350. const activePanel = ref<'find' | 'filter' | null>(null);
  351. const activePanelLabel = computed(() => (activePanel.value === 'find' ? '查找' : '过滤'));
  352. const searchForm = reactive<{
  353. scope: 'entity' | 'relation';
  354. manner: 'obscure' | 'precise';
  355. keyword: string;
  356. min?: number;
  357. max?: number;
  358. }>({ scope: 'entity', manner: 'obscure', keyword: '', min: undefined, max: undefined });
  359. const searchResults = ref<string[]>([]);
  360. const searchIndex = ref(-1);
  361. /* ---------------- 过滤 ---------------- */
  362. const filterForm = reactive<{
  363. kinds: string[];
  364. condition: 'neighborTotal' | 'edgeValue';
  365. operator: (typeof FILTER_OPERATORS)[number];
  366. value?: number;
  367. }>({ kinds: [], condition: 'neighborTotal', operator: '>', value: undefined });
  368. const filterMatchedNodeIds = ref<string[]>([]);
  369. const filterMutedNodeIds = ref<string[]>([]);
  370. const filterMatchedEdgeIds = ref<string[]>([]);
  371. const filterHidden = ref(false);
  372. const kindOptions = ENTITY_META_LIST.map((meta) => ({ label: meta.label, value: meta.kind }));
  373. const activeElementLabel = computed(() => selectedNode.value?.label || selectedEdge.value?.label || '');
  374. /* ---------------- 明细弹框(与原对象关系分析页一致) ---------------- */
  375. const detailVisible = ref(false);
  376. const detailLoading = ref(false);
  377. const detailTitle = ref('图谱详情');
  378. const detailEntries = ref<Array<{ label: string; value: string }>>([]);
  379. const detailTableColumns = ref<any[]>([]);
  380. const detailTableRows = ref<Record<string, any>[]>([]);
  381. const detailFilters = reactive({
  382. startDate: '',
  383. endDate: '',
  384. transAmount: '',
  385. transAmountSymbol: 'GE',
  386. orderKey: '',
  387. sort: '' as '' | 'ASC' | 'DESC',
  388. });
  389. const detailPagination = ref({
  390. page: 1,
  391. limit: componentSetting.table.defaultPageSize,
  392. total: 0,
  393. });
  394. const detailPageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
  395. label: value,
  396. value,
  397. }));
  398. const detailQueryBase = ref<GraphNodeDetailQuery | null>(null);
  399. const transDetailVisible = ref(false);
  400. const transDetailTitle = ref('交易详情');
  401. const transDetailBaseQuery = ref<Record<string, any>>({});
  402. const transAmountOperatorOptions: Array<{ label: string; value: CompareOperator }> = [
  403. { label: '>=', value: 'GE' },
  404. { label: '<=', value: 'LE' },
  405. ];
  406. const callDetailVisible = ref(false);
  407. const callDetailTitle = ref('通信详情');
  408. const callDetailBaseQuery = ref<Record<string, any>>({});
  409. const detailTableModalOpen = computed(
  410. () => detailVisible.value && (detailLoading.value || detailTableColumns.value.length > 0),
  411. );
  412. const detailSummaryModalOpen = computed(
  413. () => detailVisible.value && !detailLoading.value && !detailTableColumns.value.length,
  414. );
  415. const detailTotalPages = computed(() =>
  416. Math.max(1, Math.ceil(detailPagination.value.total / detailPagination.value.limit)),
  417. );
  418. /* ---------------- 详情查询:沿用对象关系分析页的原口径 ---------------- */
  419. const normalizeDetailText = (value: unknown) => String(value ?? '').trim();
  420. const getAnalysisClassByKind = (kind: string): string => {
  421. switch (kind) {
  422. case 'person':
  423. return 'sfz';
  424. case 'phone':
  425. return 'tel';
  426. case 'bank':
  427. return 'card';
  428. case 'house':
  429. return 'dz_fc';
  430. case 'car':
  431. return 'car';
  432. case 'group':
  433. return 'obj';
  434. case 'other':
  435. return 'other';
  436. default:
  437. return 'x';
  438. }
  439. };
  440. const isDetailDateColumn = (column: any) => {
  441. const text = `${column?.dataIndex ?? ''} ${column?.title ?? ''}`.toLowerCase();
  442. return /date|time|日期|时间/.test(text);
  443. };
  444. const detailDateField = computed(() => {
  445. const column = detailTableColumns.value.find((item) => isDetailDateColumn(item));
  446. return column ? String(column.dataIndex || '') : '';
  447. });
  448. const detailDateFilterLabel = computed(() => {
  449. const column = detailTableColumns.value.find((item) => String(item?.dataIndex ?? '') === detailDateField.value);
  450. return String(column?.title || '日期');
  451. });
  452. const detailDateFilterVisible = computed(() => Boolean(detailDateField.value));
  453. const detailAmountField = computed(() => {
  454. const column = detailTableColumns.value.find((item) => {
  455. const text = `${item?.dataIndex ?? ''} ${item?.title ?? ''}`.toLowerCase();
  456. return /amount|money|金额|交易额/.test(text);
  457. });
  458. return column ? String(column.dataIndex || '') : '';
  459. });
  460. const detailAmountFilterVisible = computed(() => Boolean(detailAmountField.value));
  461. const detailFilterSchema = computed(() => {
  462. const schema: any[] = [];
  463. if (detailDateFilterVisible.value) {
  464. schema.push({
  465. field: 'analysisDetailDateRange',
  466. label: detailDateFilterLabel.value,
  467. type: 'date-range',
  468. startField: 'startDate',
  469. endField: 'endDate',
  470. width: 320,
  471. maxWidth: 360,
  472. rangePlaceholder: ['开始日期', '结束日期'],
  473. valueFormat: 'YYYY-MM-DD',
  474. });
  475. }
  476. if (detailAmountFilterVisible.value) {
  477. schema.push({
  478. field: 'transAmount',
  479. label: '交易金额',
  480. type: 'compare-input',
  481. operatorField: 'transAmountSymbol',
  482. options: transAmountOperatorOptions,
  483. operatorWidth: 72,
  484. inputWidth: 180,
  485. inputMaxWidth: 180,
  486. placeholder: '请输入交易金额',
  487. });
  488. }
  489. return schema;
  490. });
  491. const appendDetailEntry = (entries: Array<{ label: string; value: string }>, label: string, value: unknown) => {
  492. if (value === undefined || value === null || value === '') return;
  493. const text =
  494. typeof value === 'object'
  495. ? (() => {
  496. try {
  497. return JSON.stringify(value);
  498. } catch {
  499. return String(value);
  500. }
  501. })()
  502. : String(value);
  503. if (!text.trim()) return;
  504. entries.push({ label: normalizeDetailText(label) || '字段', value: text });
  505. };
  506. const buildNodeDetailEntriesFromPayload = (payload: unknown) => {
  507. const entries: Array<{ label: string; value: string }> = [];
  508. if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return entries;
  509. Object.entries(payload as Record<string, unknown>).forEach(([key, value]) => {
  510. appendDetailEntry(entries, key, value);
  511. });
  512. return entries;
  513. };
  514. const resolveNodeDetailPayload = (response: unknown) => {
  515. const root = response && typeof response === 'object' ? (response as Record<string, unknown>) : {};
  516. const data = root?.data && typeof root.data === 'object' ? (root.data as Record<string, unknown>) : root;
  517. const head = Array.isArray(data?.head) ? (data.head as Record<string, unknown>[]) : [];
  518. const pages = data?.pages && typeof data.pages === 'object' ? (data.pages as Record<string, unknown>) : undefined;
  519. const records = Array.isArray(pages?.records) ? (pages.records as Record<string, unknown>[]) : [];
  520. return { data, head, records, pages };
  521. };
  522. const isNodeDetailSequenceColumn = (item: Record<string, unknown>) => {
  523. const title = (normalizeDetailText(item.title) || '').toLowerCase();
  524. const dataIndex = (normalizeDetailText(item.dataIndex) || '').toLowerCase();
  525. const key = (normalizeDetailText(item.key) || '').toLowerCase();
  526. return (
  527. ['序号', 'index', 'xh'].includes(title) || ['index', 'xh'].includes(dataIndex) || ['index', 'xh'].includes(key)
  528. );
  529. };
  530. const normalizeNodeDetailColumnWidth = (value: unknown, title: string) => {
  531. const width = Number.parseFloat(String(value ?? ''));
  532. if (Number.isFinite(width) && width > 0) return width;
  533. if (title === '序号') return 72;
  534. if (title.includes('时间') || title.includes('日期')) return 180;
  535. if (title.includes('地址') || title.includes('内容') || title.includes('摘要') || title.includes('名称')) {
  536. return 220;
  537. }
  538. return 160;
  539. };
  540. const buildNodeDetailTableColumns = (head: Record<string, unknown>[]) =>
  541. head
  542. .filter((item) => !isNodeDetailSequenceColumn(item))
  543. .map((item, index) => {
  544. const title = normalizeDetailText(item.title) || normalizeDetailText(item.key) || `字段${index + 1}`;
  545. const dataIndex = normalizeDetailText(item.dataIndex) || normalizeDetailText(item.key) || `field_${index}`;
  546. const key = normalizeDetailText(item.key) || dataIndex;
  547. const width = normalizeNodeDetailColumnWidth(item.width, title);
  548. return { title, dataIndex, key, width };
  549. })
  550. .filter((item) => item.dataIndex);
  551. const buildNodeDetailTableRows = (records: Record<string, unknown>[]) =>
  552. records.map((item, index) => ({
  553. rowKey: String(item.id ?? item.rowId ?? item.fileId ?? index),
  554. ...item,
  555. }));
  556. const resetDetailFilters = () => {
  557. detailFilters.startDate = '';
  558. detailFilters.endDate = '';
  559. detailFilters.transAmount = '';
  560. detailFilters.transAmountSymbol = 'GE';
  561. detailFilters.orderKey = '';
  562. detailFilters.sort = '';
  563. };
  564. const buildDetailNodeQuery = () => {
  565. const baseQuery = detailQueryBase.value;
  566. if (!baseQuery) return null;
  567. return {
  568. ...baseQuery,
  569. page: detailPagination.value.page,
  570. limit: detailPagination.value.limit,
  571. startDate: detailFilters.startDate || undefined,
  572. endDate: detailFilters.endDate || undefined,
  573. transAmountSymbol: detailFilters.transAmount ? detailFilters.transAmountSymbol : undefined,
  574. transAmount: detailFilters.transAmount || undefined,
  575. orderKey: detailFilters.orderKey || undefined,
  576. sort: detailFilters.sort || undefined,
  577. } as GraphNodeDetailQuery;
  578. };
  579. const fetchDetailTable = async () => {
  580. const query = buildDetailNodeQuery();
  581. if (!query) return;
  582. detailLoading.value = true;
  583. try {
  584. const response = await fetchGraphNodeDetail(query);
  585. const { data, head, records, pages } = resolveNodeDetailPayload(response);
  586. if (head.length) {
  587. detailTableColumns.value = buildNodeDetailTableColumns(head);
  588. detailTableRows.value = buildNodeDetailTableRows(records);
  589. const current = Number(pages?.current ?? detailPagination.value.page);
  590. const size = Number((pages?.size ?? records.length) || detailPagination.value.limit);
  591. const total = Number(pages?.total ?? records.length);
  592. detailPagination.value.page = Number.isFinite(current) && current > 0 ? current : detailPagination.value.page;
  593. detailPagination.value.limit = Number.isFinite(size) && size > 0 ? size : detailPagination.value.limit;
  594. detailPagination.value.total = Number.isFinite(total) && total >= 0 ? total : records.length;
  595. detailEntries.value = [];
  596. return;
  597. }
  598. detailEntries.value = buildNodeDetailEntriesFromPayload(data);
  599. detailTableColumns.value = [];
  600. detailTableRows.value = [];
  601. detailPagination.value.total = 0;
  602. } catch (error) {
  603. console.error('load ai graph node detail failed', error);
  604. detailTableColumns.value = [];
  605. detailTableRows.value = [];
  606. detailPagination.value.total = 0;
  607. showMessage('节点详情加载失败', 'error');
  608. } finally {
  609. detailLoading.value = false;
  610. }
  611. };
  612. const handleDetailSearch = async () => {
  613. detailPagination.value.page = 1;
  614. await fetchDetailTable();
  615. };
  616. const handleDetailReset = async () => {
  617. resetDetailFilters();
  618. detailPagination.value.page = 1;
  619. await fetchDetailTable();
  620. };
  621. const handleDetailPrevPage = () => {
  622. if (detailPagination.value.page <= 1) return;
  623. detailPagination.value.page -= 1;
  624. void fetchDetailTable();
  625. };
  626. const handleDetailNextPage = () => {
  627. if (detailPagination.value.page >= detailTotalPages.value) return;
  628. detailPagination.value.page += 1;
  629. void fetchDetailTable();
  630. };
  631. const handleDetailPageChange = (page: number, pageSize: number) => {
  632. if (detailLoading.value) return;
  633. if (pageSize !== detailPagination.value.limit) {
  634. handleDetailPageSizeChange(pageSize);
  635. return;
  636. }
  637. if (page === detailPagination.value.page) return;
  638. detailPagination.value.page = page;
  639. void fetchDetailTable();
  640. };
  641. const handleDetailPageSizeChange = (size: number) => {
  642. detailPagination.value.limit = Number(size);
  643. detailPagination.value.page = 1;
  644. void fetchDetailTable();
  645. };
  646. const handleDetailSortChange = (payload: { orderKey: string; sort: 'ASC' | 'DESC' }) => {
  647. detailFilters.orderKey = payload.orderKey;
  648. detailFilters.sort = payload.sort;
  649. detailPagination.value.page = 1;
  650. void fetchDetailTable();
  651. };
  652. const handleDetailVisibleChange = (open: boolean) => {
  653. detailVisible.value = open;
  654. };
  655. const isNodeDetailFetchable = (node: GraphModelNode) => node.kind !== 'person';
  656. const buildNodeDetailQuery = (node: GraphModelNode): GraphNodeDetailQuery | null => {
  657. const raw = node.raw || {};
  658. const clazz = normalizeDetailText(raw.clazz ?? raw.class) || getAnalysisClassByKind(node.kind);
  659. const nodeToken =
  660. normalizeDetailText(raw.node) ||
  661. normalizeDetailText(raw.bm) ||
  662. normalizeDetailText(raw.id) ||
  663. normalizeDetailText(raw.dxId);
  664. if (!nodeToken || !clazz) return null;
  665. const lxValue = Number(raw.lx);
  666. const fxValue = Number(raw.fx);
  667. return {
  668. node: nodeToken,
  669. clazz,
  670. lx: Number.isFinite(lxValue) ? lxValue : 0,
  671. fx: Number.isFinite(fxValue) ? fxValue : 0,
  672. };
  673. };
  674. const edgeRaw = (edge: GraphModelEdge): Record<string, any> => {
  675. const raw = edge.raw || {};
  676. return {
  677. ...raw,
  678. dx1: raw.dx1 ?? raw.node1 ?? edge.source,
  679. dx2: raw.dx2 ?? raw.node2 ?? edge.target,
  680. clazz: raw.clazz ?? edge.clazz,
  681. id: raw.id ?? edge.id,
  682. };
  683. };
  684. const isCommunicationEdge = (edge: GraphModelEdge) =>
  685. normalizeDetailText(edgeRaw(edge).clazz).toLowerCase() === 'tel_tel';
  686. const isTradeEdge = (edge: GraphModelEdge) => normalizeDetailText(edgeRaw(edge).clazz).toLowerCase() === 'card_card';
  687. const canOpenAnalysisDetail = (elementId: string, kind: 'node' | 'edge' | null) => {
  688. if (!elementId || !kind) return false;
  689. if (kind === 'node') return Boolean(nodeById.value.get(elementId));
  690. const edge = edgeById.value.get(elementId);
  691. if (!edge) return false;
  692. const raw = edgeRaw(edge);
  693. const relationTexts = [edge.label, raw.mc, raw.sm, raw.clazz]
  694. .map((item) => normalizeDetailText(item))
  695. .filter(Boolean);
  696. return !relationTexts.some((text) => text.includes('持卡人') || text.includes('机主'));
  697. };
  698. const getEdgeEndpointName = (edge: GraphModelEdge, endpoint: 'source' | 'target') => {
  699. const endpointNode = nodeById.value.get(endpoint === 'source' ? edge.source : edge.target);
  700. const raw = edgeRaw(edge);
  701. return endpoint === 'source'
  702. ? endpointNode?.label || raw.obj1_name || raw.dx1 || '未知源节点'
  703. : endpointNode?.label || raw.obj2_name || raw.dx2 || '未知目标节点';
  704. };
  705. const openCommunicationCallDetail = (target: GraphModelNode | GraphModelEdge, kind: 'node' | 'edge') => {
  706. if (kind === 'node') {
  707. const node = target as GraphModelNode;
  708. if (node.kind !== 'phone') return false;
  709. const raw = node.raw || {};
  710. const phoneNo = normalizeDetailText(raw.bm) || normalizeDetailText(raw.node) || node.id;
  711. if (!phoneNo) {
  712. showMessage('当前通信节点缺少可查询的号码', 'warning');
  713. return false;
  714. }
  715. transDetailVisible.value = false;
  716. detailVisible.value = false;
  717. callDetailTitle.value = `${node.fullLabel || node.label || phoneNo} 通信详情`;
  718. callDetailBaseQuery.value = buildGraphCallDetailQuery({}, {}, {
  719. ...raw,
  720. bm: phoneNo,
  721. id: node.id,
  722. } as any) as GraphCallDetailQuery;
  723. callDetailVisible.value = true;
  724. return true;
  725. }
  726. const edge = target as GraphModelEdge;
  727. if (!isCommunicationEdge(edge)) return false;
  728. transDetailVisible.value = false;
  729. detailVisible.value = false;
  730. callDetailTitle.value = `${getEdgeEndpointName(edge, 'source')} -> ${getEdgeEndpointName(edge, 'target')} 通信详情`;
  731. callDetailBaseQuery.value = buildGraphCallDetailQuery({}, {}, null, edgeRaw(edge) as any) as GraphCallDetailQuery;
  732. callDetailVisible.value = true;
  733. return true;
  734. };
  735. const openTradeEdgeTransDetail = (edge: GraphModelEdge) => {
  736. if (!isTradeEdge(edge)) return false;
  737. const raw = edgeRaw(edge);
  738. const personCardNo = normalizeDetailText(raw.dx1);
  739. const otherCardNo = normalizeDetailText(raw.dx2);
  740. if (!personCardNo || !otherCardNo) return false;
  741. callDetailVisible.value = false;
  742. detailVisible.value = false;
  743. transDetailTitle.value = `${getEdgeEndpointName(edge, 'source')} -> ${getEdgeEndpointName(edge, 'target')} 交易详情`;
  744. transDetailBaseQuery.value = buildGraphTransDetailQuery(
  745. {},
  746. {
  747. personCardNo,
  748. otherCardNo,
  749. lx:
  750. raw.lx === undefined || raw.lx === null || raw.lx === ''
  751. ? undefined
  752. : Number.isFinite(Number(raw.lx))
  753. ? Number(raw.lx)
  754. : undefined,
  755. },
  756. ) as GraphTransDetailQuery;
  757. transDetailVisible.value = true;
  758. return true;
  759. };
  760. const fetchTransDetailRecords = (query: Record<string, any>) => fetchGraphTransDetail(query as GraphTransDetailQuery);
  761. const normalizeGraphCallDetailQuery = (query: Record<string, any>): GraphCallDetailQuery => {
  762. const normalized = { ...query } as GraphCallDetailQuery;
  763. const startDate = normalizeDetailText(query.startDate ?? query.date1);
  764. const endDate = normalizeDetailText(query.endDate ?? query.date2);
  765. normalized.startDate = startDate || undefined;
  766. normalized.endDate = endDate || undefined;
  767. normalized.date1 = startDate || normalized.date1;
  768. normalized.date2 = endDate || normalized.date2;
  769. return normalized;
  770. };
  771. const fetchCallDetailRecords = (query: Record<string, any>) =>
  772. fetchGraphCallDetail(normalizeGraphCallDetailQuery(query));
  773. const openDetail = async (elementId: string, kind: 'node' | 'edge') => {
  774. if (kind === 'edge') {
  775. const edge = edgeById.value.get(elementId);
  776. if (!edge) return;
  777. if (openCommunicationCallDetail(edge, 'edge')) return;
  778. if (openTradeEdgeTransDetail(edge)) return;
  779. detailTitle.value = `${getEdgeEndpointName(edge, 'source')} -> ${getEdgeEndpointName(edge, 'target')}`;
  780. const raw = edgeRaw(edge);
  781. detailEntries.value = [
  782. { label: 'ID', value: String(edge.id || '-') },
  783. { label: '名称', value: edge.label || '-' },
  784. ...Object.entries(raw)
  785. .filter(([, value]) => value !== undefined && value !== null && value !== '')
  786. .slice(0, 24)
  787. .map(([label, value]) => {
  788. const text =
  789. typeof value === 'object'
  790. ? (() => {
  791. try {
  792. return JSON.stringify(value);
  793. } catch {
  794. return String(value);
  795. }
  796. })()
  797. : String(value);
  798. return { label, value: text };
  799. }),
  800. ];
  801. detailQueryBase.value = null;
  802. detailTableColumns.value = [];
  803. detailTableRows.value = [];
  804. detailPagination.value.total = 0;
  805. detailLoading.value = false;
  806. detailVisible.value = true;
  807. return;
  808. }
  809. const node = nodeById.value.get(elementId);
  810. if (!node) return;
  811. if (openCommunicationCallDetail(node, 'node')) return;
  812. callDetailVisible.value = false;
  813. transDetailVisible.value = false;
  814. detailTitle.value = String(node.fullLabel || node.label || '实体详情');
  815. detailLoading.value = true;
  816. detailEntries.value = [];
  817. detailTableColumns.value = [];
  818. detailTableRows.value = [];
  819. resetDetailFilters();
  820. detailPagination.value = { page: 1, limit: componentSetting.table.defaultPageSize, total: 0 };
  821. if (isNodeDetailFetchable(node)) {
  822. const query = buildNodeDetailQuery(node);
  823. if (!query) {
  824. detailLoading.value = false;
  825. showMessage('当前节点缺少详情查询参数', 'warning');
  826. return;
  827. }
  828. detailQueryBase.value = query;
  829. detailVisible.value = true;
  830. await fetchDetailTable();
  831. return;
  832. }
  833. detailQueryBase.value = null;
  834. detailEntries.value = buildNodeDetailEntriesFromPayload(node.raw || {});
  835. detailTableColumns.value = [];
  836. detailTableRows.value = [];
  837. detailPagination.value.total = 0;
  838. detailLoading.value = false;
  839. detailVisible.value = true;
  840. };
  841. /* ---------------- 右键菜单(只读项,与原页面同一 G6 插件) ---------------- */
  842. const contextMenuId = (event: any) =>
  843. String(event?.target?.id || event?.target?.getAttribute?.('id') || event?.target?.attributes?.id || '');
  844. const getContextMenuItems = (event: any) => {
  845. const id = contextMenuId(event);
  846. contextMenuElementId.value = id;
  847. const kind: 'node' | 'edge' | null = nodeById.value.has(id) ? 'node' : edgeById.value.has(id) ? 'edge' : null;
  848. if (!id || !kind) {
  849. return [{ name: '适配视图', value: 'fitView' }];
  850. }
  851. // 右键即选中当前元素,与原对象关系分析页行为一致
  852. selectedNode.value = kind === 'node' ? nodeById.value.get(id) || null : null;
  853. selectedEdge.value = kind === 'edge' ? edgeById.value.get(id) || null : null;
  854. void markSelected(id);
  855. if (kind === 'edge') {
  856. return [
  857. ...(canOpenAnalysisDetail(id, kind) ? [{ name: '查看详情', value: 'analysisDetail' }] : []),
  858. { name: '适配视图', value: 'fitView' },
  859. ];
  860. }
  861. const incoming = graphRef.value?.getEdgeData().filter((item) => String(item.target) === id).length || 0;
  862. const outgoing = graphRef.value?.getEdgeData().filter((item) => String(item.source) === id).length || 0;
  863. return [
  864. ...(canOpenAnalysisDetail(id, kind) ? [{ name: '查看详情', value: 'analysisDetail' }] : []),
  865. ...(incoming ? [{ name: '仅看流入', value: 'onlyLookFlowIn' }] : []),
  866. ...(outgoing ? [{ name: '仅看流出', value: 'onlyLookFlowOut' }] : []),
  867. ];
  868. };
  869. const createContextMenuPlugin = () => ({
  870. type: 'contextmenu',
  871. key: 'ai-graph-contextmenu',
  872. className: 'g6-editor-contextmenu',
  873. getItems: (event: any) => getContextMenuItems(event),
  874. onClick: (value: string, _target: HTMLElement, current: any) => {
  875. void handleContextMenuClick(value, current);
  876. },
  877. });
  878. const handleContextMenuClick = async (value: string, current: any) => {
  879. const id = String(current?.id || contextMenuElementId.value || '');
  880. const kind: 'node' | 'edge' | null = nodeById.value.has(id) ? 'node' : edgeById.value.has(id) ? 'edge' : null;
  881. if (value === 'fitView') {
  882. await fitView();
  883. return;
  884. }
  885. if (value === 'analysisDetail' && id && kind && canOpenAnalysisDetail(id, kind)) {
  886. await openDetail(id, kind);
  887. return;
  888. }
  889. if ((value === 'onlyLookFlowIn' || value === 'onlyLookFlowOut') && id && kind === 'node') {
  890. await handleLookNodeFlow(id, value === 'onlyLookFlowIn' ? 'incoming' : 'outgoing');
  891. }
  892. };
  893. const handleLookNodeFlow = async (nodeId: string, direction: 'incoming' | 'outgoing') => {
  894. const graph = graphRef.value;
  895. if (!graph || !nodeById.value.has(nodeId)) return;
  896. const flowEdges = graph
  897. .getEdgeData()
  898. .filter((item) => (direction === 'incoming' ? String(item.target) === nodeId : String(item.source) === nodeId));
  899. if (!flowEdges.length) {
  900. showMessage(`当前节点没有${direction === 'incoming' ? '流入' : '流出'}关系`, 'warning');
  901. return;
  902. }
  903. const ids = new Set<string>([nodeId]);
  904. flowEdges.forEach((item) => {
  905. ids.add(String(item.source));
  906. ids.add(String(item.target));
  907. if (item.id) ids.add(String(item.id));
  908. });
  909. await clearElementStates();
  910. for (const id of ids) {
  911. await graph.setElementState(id, ['selected'], false);
  912. }
  913. await graph.focusElement(nodeId, { animation: { duration: 240 } } as any);
  914. showMessage(`已选中 ${flowEdges.length} 条${direction === 'incoming' ? '流入' : '流出'}关系`, 'success');
  915. };
  916. /* ---------------- 工具条行为 ---------------- */
  917. const togglePanel = (panel: 'find' | 'filter') => {
  918. activePanel.value = activePanel.value === panel ? null : panel;
  919. };
  920. const showBoxSelectTip = () => {
  921. showMessage('按住 Shift 拖动画布即可框选', 'info');
  922. };
  923. const selectNeighborElements = () => {
  924. const graph = graphRef.value;
  925. const node = selectedNode.value;
  926. if (!graph || !node) {
  927. showMessage('请先选择一个实体', 'warning');
  928. return;
  929. }
  930. const ids = new Set<string>([node.id]);
  931. graph.getEdgeData().forEach((edge) => {
  932. if (String(edge.source) === node.id || String(edge.target) === node.id) {
  933. ids.add(String(edge.source));
  934. ids.add(String(edge.target));
  935. if (edge.id) ids.add(String(edge.id));
  936. }
  937. });
  938. void (async () => {
  939. await clearElementStates();
  940. for (const id of ids) await graph.setElementState(id, ['selected'], false);
  941. })();
  942. };
  943. /* ---------------- 生命周期 ---------------- */
  944. onMounted(() => {
  945. registerAIGraphElements();
  946. void rebuild();
  947. });
  948. onBeforeUnmount(() => {
  949. destroyGraph();
  950. });
  951. watch(
  952. () => props.model,
  953. () => {
  954. clearSelected();
  955. activePanel.value = null;
  956. resetFilterState();
  957. void rebuild();
  958. },
  959. );
  960. /* ---------------- G6 初始化与数据 ---------------- */
  961. function getCanvasSize() {
  962. const element = stageRef.value;
  963. return {
  964. width: element?.clientWidth || 800,
  965. height: element?.clientHeight || 460,
  966. };
  967. }
  968. function destroyGraph() {
  969. stageResizeObserver?.disconnect();
  970. stageResizeObserver = null;
  971. const graph = graphRef.value;
  972. graphRef.value = null;
  973. try {
  974. graph?.destroy();
  975. } catch {
  976. /* 忽略销毁异常 */
  977. }
  978. }
  979. /**
  980. * G6 自带 autoResize 在聊天流式布局/全屏切换时偶尔拿不到最终尺寸,
  981. * 这里再用 ResizeObserver 显式 resize,保证画布始终铺满容器,缩放后不留白。
  982. */
  983. function observeStageSize() {
  984. if (typeof ResizeObserver === 'undefined' || !stageRef.value) return;
  985. stageResizeObserver?.disconnect();
  986. stageResizeObserver = new ResizeObserver(() => {
  987. const element = stageRef.value;
  988. const graph = graphRef.value;
  989. if (!element || !graph) return;
  990. const width = Math.max(1, element.clientWidth);
  991. const height = Math.max(1, element.clientHeight);
  992. try {
  993. graph.resize(width, height);
  994. } catch {
  995. /* 忽略尺寸同步异常 */
  996. }
  997. });
  998. stageResizeObserver.observe(stageRef.value);
  999. }
  1000. function createNodeOptions() {
  1001. return {
  1002. type: performanceMode.value ? 'ai-graph-performance-node' : 'ai-graph-node',
  1003. style: (node: any) => {
  1004. const meta = getEntityMeta(String(node.data?.kind || 'unknown'));
  1005. const hidden = Boolean(node.data?.filterHidden);
  1006. const dimmed = Boolean(node.data?.dimmed);
  1007. if (performanceMode.value) {
  1008. return {
  1009. size: 22,
  1010. fill: meta.color,
  1011. fillOpacity: hidden ? 0 : dimmed ? 0.18 : 0.95,
  1012. stroke: 'transparent',
  1013. strokeOpacity: hidden ? 0 : 1,
  1014. lineWidth: 0,
  1015. cursor: 'pointer',
  1016. iconSrc: meta.icon,
  1017. iconOpacity: hidden ? 0 : dimmed ? 0.18 : 0.96,
  1018. labelText: String(node.data?.label || node.id),
  1019. labelFill: '#334155',
  1020. labelFontSize: 11,
  1021. labelOpacity: hidden ? 0 : dimmed ? 0.18 : 0.82,
  1022. };
  1023. }
  1024. return {
  1025. size: 96,
  1026. iconBoxSize: 40,
  1027. iconBoxRadius: 11,
  1028. iconSize: 32,
  1029. fill: meta.color,
  1030. fillOpacity: hidden ? 0 : dimmed ? 0.18 : 1,
  1031. stroke: 'rgba(255,255,255,0.22)',
  1032. strokeOpacity: hidden ? 0 : 1,
  1033. lineWidth: 1,
  1034. opacity: hidden ? 0 : 1,
  1035. cursor: 'pointer',
  1036. iconSrc: meta.icon,
  1037. iconOpacity: hidden ? 0 : dimmed ? 0.24 : 1,
  1038. labelText: String(node.data?.label || node.id),
  1039. labelFill: '#334155',
  1040. labelFontSize: 13,
  1041. labelFontWeight: 600,
  1042. labelOpacity: hidden ? 0 : dimmed ? 0.24 : 1,
  1043. secondaryLabelText: String(node.data?.secondaryLabel || ''),
  1044. secondaryLabelFill: '#94a3b8',
  1045. secondaryLabelFontSize: 11,
  1046. secondaryLabelOpacity: hidden ? 0 : dimmed ? 0.2 : 1,
  1047. };
  1048. },
  1049. state: {
  1050. selected: {
  1051. halo: true,
  1052. haloStroke: '#1677ff',
  1053. haloLineWidth: 2,
  1054. stroke: '#1677ff',
  1055. strokeOpacity: 1,
  1056. lineWidth: 3,
  1057. fillOpacity: 1,
  1058. },
  1059. },
  1060. };
  1061. }
  1062. function createEdgeOptions() {
  1063. return {
  1064. type: (edge: any) => (performanceMode.value ? 'ai-graph-performance-edge' : String(edge.type || 'line')),
  1065. style: (edge: any) => {
  1066. const hidden = Boolean(edge.data?.filterHidden);
  1067. const dimmed = Boolean(edge.data?.dimmed);
  1068. if (performanceMode.value) {
  1069. return {
  1070. stroke: '#d9d9df',
  1071. lineWidth: 0.8,
  1072. opacity: hidden ? 0 : dimmed ? 0.12 : 0.78,
  1073. cursor: 'pointer',
  1074. labelText: String(edge.data?.label || ''),
  1075. labelFill: '#516076',
  1076. labelFontSize: 9,
  1077. labelOpacity: hidden ? 0 : dimmed ? 0.12 : 0.62,
  1078. };
  1079. }
  1080. return {
  1081. stroke: '#d9d9df',
  1082. lineWidth: 1.6,
  1083. opacity: hidden ? 0 : dimmed ? 0.18 : 1,
  1084. cursor: 'pointer',
  1085. startArrow: false,
  1086. endArrow: true,
  1087. endArrowOffset: 12,
  1088. endArrowFill: '#d9d9df',
  1089. label: true,
  1090. labelText: String(edge.data?.label || ''),
  1091. labelPlacement: 'center',
  1092. labelFill: '#516076',
  1093. labelFontSize: 10,
  1094. labelOpacity: hidden ? 0 : dimmed ? 0.18 : 1,
  1095. labelBackground: true,
  1096. labelBackgroundFill: 'rgba(255,255,255,0.92)',
  1097. labelBackgroundRadius: 4,
  1098. };
  1099. },
  1100. state: {
  1101. selected: {
  1102. stroke: '#2563eb',
  1103. lineWidth: 3,
  1104. endArrowFill: '#2563eb',
  1105. },
  1106. },
  1107. };
  1108. }
  1109. async function rebuild() {
  1110. const element = stageRef.value;
  1111. if (!element || !props.model.nodes.length) return;
  1112. building.value = true;
  1113. await nextTick();
  1114. if (!stageRef.value) {
  1115. building.value = false;
  1116. return;
  1117. }
  1118. try {
  1119. destroyGraph();
  1120. const size = getCanvasSize();
  1121. const data = buildG6Data(props.model);
  1122. const positions = layoutGraph(props.model, size);
  1123. (data.nodes || []).forEach((node) => {
  1124. const position = positions.get(String(node.id));
  1125. if (!position) return;
  1126. node.style = {
  1127. ...(node.style || {}),
  1128. x: position.x,
  1129. y: position.y,
  1130. } as any;
  1131. });
  1132. performanceMode.value = props.model.nodes.length > 800 || props.model.edges.length > 1000;
  1133. const graph = new Graph({
  1134. container: element,
  1135. width: size.width,
  1136. height: size.height,
  1137. autoResize: true,
  1138. animation: false,
  1139. data,
  1140. node: createNodeOptions() as any,
  1141. edge: createEdgeOptions() as any,
  1142. behaviors: [
  1143. {
  1144. type: 'drag-canvas',
  1145. enable: (event: any) => event?.targetType === 'canvas',
  1146. },
  1147. 'zoom-canvas',
  1148. ],
  1149. plugins: performanceMode.value
  1150. ? [createContextMenuPlugin()]
  1151. : [{ type: 'grid-line', size: 24, stroke: '#e2e8f0', lineWidth: 1 }, createContextMenuPlugin()],
  1152. } as any);
  1153. graphRef.value = graph;
  1154. bindGraphEvents(graph);
  1155. await graph.render();
  1156. observeStageSize();
  1157. await graph.fitView({ when: 'always' }, false);
  1158. refreshZoom();
  1159. } catch (error) {
  1160. console.error('render ai graph failed', error);
  1161. showMessage('图谱渲染失败,请稍后重试', 'warning');
  1162. } finally {
  1163. building.value = false;
  1164. }
  1165. }
  1166. let lastElementClickAt = 0;
  1167. function bindGraphEvents(graph: Graph) {
  1168. graph.on<IElementEvent>(NodeEvent.CLICK, (event: IElementEvent) => {
  1169. const id = String(event.target?.id || '');
  1170. if (!id) return;
  1171. lastElementClickAt = Date.now();
  1172. const node = nodeById.value.get(id) || null;
  1173. selectedNode.value = node;
  1174. selectedEdge.value = null;
  1175. void markSelected(id);
  1176. });
  1177. graph.on<IElementEvent>(EdgeEvent.CLICK, (event: IElementEvent) => {
  1178. const id = String(event.target?.id || '');
  1179. if (!id) return;
  1180. lastElementClickAt = Date.now();
  1181. const edge = edgeById.value.get(id) || null;
  1182. selectedEdge.value = edge;
  1183. selectedNode.value = null;
  1184. void markSelected(id);
  1185. });
  1186. graph.on(CanvasEvent.CLICK, () => {
  1187. window.setTimeout(() => {
  1188. // 部分 G6 版本里节点/边点击也会冒泡到 canvas;刚点过元素时不要清掉侧栏
  1189. if (Date.now() - lastElementClickAt < 120) return;
  1190. clearSelected();
  1191. }, 0);
  1192. });
  1193. graph.on(GraphEvent.AFTER_TRANSFORM, refreshZoom);
  1194. }
  1195. async function clearElementStates() {
  1196. const graph = graphRef.value;
  1197. if (!graph) return;
  1198. try {
  1199. graph.getElementDataByState('node', 'selected').forEach((node) => {
  1200. void graph.setElementState(String(node.id), [], false);
  1201. });
  1202. graph.getElementDataByState('edge', 'selected').forEach((edge) => {
  1203. void graph.setElementState(String(edge.id), [], false);
  1204. });
  1205. } catch {
  1206. /* 忽略状态异常 */
  1207. }
  1208. }
  1209. async function markSelected(id: string) {
  1210. const graph = graphRef.value;
  1211. if (!graph) return;
  1212. await clearElementStates();
  1213. try {
  1214. await graph.setElementState(id, ['selected'], false);
  1215. } catch {
  1216. /* 忽略状态异常 */
  1217. }
  1218. }
  1219. function clearSelected() {
  1220. selectedNode.value = null;
  1221. selectedEdge.value = null;
  1222. void clearElementStates();
  1223. }
  1224. /* ---------------- 工具栏 ---------------- */
  1225. function refreshZoom() {
  1226. const graph = graphRef.value;
  1227. if (!graph) return;
  1228. try {
  1229. const zoom = Number((graph as any).getZoom?.() ?? 1);
  1230. zoomPercent.value = Math.round((Number.isFinite(zoom) && zoom > 0 ? zoom : 1) * 100);
  1231. } catch {
  1232. zoomPercent.value = 100;
  1233. }
  1234. }
  1235. async function zoomIn() {
  1236. try {
  1237. await graphRef.value?.zoomBy(1.12, { duration: 180 });
  1238. refreshZoom();
  1239. } catch {
  1240. /* 忽略 */
  1241. }
  1242. }
  1243. async function zoomOut() {
  1244. try {
  1245. await graphRef.value?.zoomBy(0.88, { duration: 180 });
  1246. refreshZoom();
  1247. } catch {
  1248. /* 忽略 */
  1249. }
  1250. }
  1251. async function fitView() {
  1252. try {
  1253. await graphRef.value?.fitView({ when: 'always' }, false);
  1254. refreshZoom();
  1255. } catch {
  1256. /* 忽略 */
  1257. }
  1258. }
  1259. async function centerView() {
  1260. try {
  1261. await graphRef.value?.fitCenter(false);
  1262. refreshZoom();
  1263. } catch {
  1264. /* 忽略 */
  1265. }
  1266. }
  1267. async function relayout() {
  1268. const graph = graphRef.value;
  1269. if (!graph) return;
  1270. try {
  1271. const positions = layoutGraph(props.model, getCanvasSize());
  1272. graph.updateNodeData(
  1273. graph.getNodeData().map((node) => {
  1274. const position = positions.get(String(node.id));
  1275. return {
  1276. id: node.id,
  1277. style: {
  1278. ...(node.style || {}),
  1279. x: position?.x,
  1280. y: position?.y,
  1281. },
  1282. };
  1283. }) as any,
  1284. );
  1285. await graph.draw();
  1286. await graph.fitView({ when: 'always' }, false);
  1287. refreshZoom();
  1288. } catch (error) {
  1289. console.error('relayout ai graph failed', error);
  1290. }
  1291. }
  1292. async function resizeAndFit() {
  1293. await nextTick();
  1294. // 等一帧让 autoResize 完成尺寸测量,否则全屏后会按旧尺寸 fit
  1295. await new Promise<void>((resolve) => {
  1296. if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => resolve());
  1297. else window.setTimeout(resolve, 0);
  1298. });
  1299. await fitView();
  1300. }
  1301. defineExpose({ fitView, relayout, resizeAndFit });
  1302. /* ---------------- 查找实现 ---------------- */
  1303. function nodeSearchTexts(node: any): string[] {
  1304. return [
  1305. String(node.data?.label || ''),
  1306. String(node.data?.secondaryLabel || ''),
  1307. String(node.data?.fullLabel || ''),
  1308. String(node.id || ''),
  1309. ];
  1310. }
  1311. function edgeNumericValues(edge: any): number[] {
  1312. const raw = (edge.data?.rawData || {}) as Record<string, any>;
  1313. const candidates = [raw.money, raw.num, raw.nums, raw.value, edge.data?.label];
  1314. const values: number[] = [];
  1315. candidates.forEach((candidate) => {
  1316. if (candidate === undefined || candidate === null || candidate === '') return;
  1317. const matches = String(candidate).match(/-?\d+(?:\.\d+)?/g);
  1318. (matches || []).forEach((text) => {
  1319. const numeric = Number(text);
  1320. if (Number.isFinite(numeric)) values.push(numeric);
  1321. });
  1322. });
  1323. return Array.from(new Set(values));
  1324. }
  1325. function hasNumericValue(value: number | null | undefined): value is number {
  1326. return value !== undefined && value !== null && Number.isFinite(Number(value));
  1327. }
  1328. function findElements(): string[] {
  1329. const graph = graphRef.value;
  1330. if (!graph) return [];
  1331. if (searchForm.scope === 'entity') {
  1332. const keyword = searchForm.keyword.trim();
  1333. if (!keyword) return [];
  1334. const normalizedKeyword = keyword.toLowerCase();
  1335. return graph
  1336. .getNodeData()
  1337. .filter((node) =>
  1338. nodeSearchTexts(node).some((text) => {
  1339. const value = String(text || '').trim();
  1340. if (!value) return false;
  1341. return searchForm.manner === 'precise'
  1342. ? value === keyword
  1343. : value.toLowerCase().includes(normalizedKeyword);
  1344. }),
  1345. )
  1346. .map((node) => String(node.id));
  1347. }
  1348. const min = hasNumericValue(searchForm.min) ? Number(searchForm.min) : undefined;
  1349. const max = hasNumericValue(searchForm.max) ? Number(searchForm.max) : undefined;
  1350. if (min === undefined && max === undefined) return [];
  1351. return graph
  1352. .getEdgeData()
  1353. .filter((edge) => {
  1354. const values = edgeNumericValues(edge);
  1355. if (!values.length) return false;
  1356. return values.some((value) => {
  1357. if (min !== undefined && value < min) return false;
  1358. if (max !== undefined && value > max) return false;
  1359. return true;
  1360. });
  1361. })
  1362. .map((edge) => String(edge.id || ''))
  1363. .filter(Boolean);
  1364. }
  1365. async function focusSearchResult(index: number) {
  1366. const id = searchResults.value[index];
  1367. const graph = graphRef.value;
  1368. if (!id || !graph) return;
  1369. await clearElementStates();
  1370. try {
  1371. await graph.setElementState(id, ['selected'], false);
  1372. await graph.focusElement(id, { animation: { duration: 240 } } as any);
  1373. } catch {
  1374. /* 忽略 */
  1375. }
  1376. if (searchForm.scope === 'entity') {
  1377. selectedNode.value = nodeById.value.get(id) || null;
  1378. selectedEdge.value = null;
  1379. } else {
  1380. selectedEdge.value = edgeById.value.get(id) || null;
  1381. selectedNode.value = null;
  1382. }
  1383. }
  1384. async function runSearch() {
  1385. if (searchForm.scope === 'entity') {
  1386. if (!searchForm.keyword.trim()) {
  1387. showMessage('请输入查找内容', 'warning');
  1388. return;
  1389. }
  1390. } else {
  1391. const min = hasNumericValue(searchForm.min) ? Number(searchForm.min) : undefined;
  1392. const max = hasNumericValue(searchForm.max) ? Number(searchForm.max) : undefined;
  1393. if (min === undefined && max === undefined) {
  1394. showMessage('请输入关系数值范围', 'warning');
  1395. return;
  1396. }
  1397. if (min !== undefined && max !== undefined && min > max) {
  1398. showMessage('最小值不能大于最大值', 'warning');
  1399. return;
  1400. }
  1401. }
  1402. searchResults.value = findElements();
  1403. searchIndex.value = searchResults.value.length ? 0 : -1;
  1404. if (!searchResults.value.length) {
  1405. showMessage('未找到匹配结果', 'warning');
  1406. return;
  1407. }
  1408. await focusSearchResult(0);
  1409. }
  1410. async function prevSearchResult() {
  1411. if (!searchResults.value.length) return;
  1412. searchIndex.value = (searchIndex.value - 1 + searchResults.value.length) % searchResults.value.length;
  1413. await focusSearchResult(searchIndex.value);
  1414. }
  1415. async function nextSearchResult() {
  1416. if (!searchResults.value.length) return;
  1417. searchIndex.value = (searchIndex.value + 1) % searchResults.value.length;
  1418. await focusSearchResult(searchIndex.value);
  1419. }
  1420. async function selectAllSearchResults() {
  1421. const graph = graphRef.value;
  1422. if (!graph || !searchResults.value.length) return;
  1423. await clearElementStates();
  1424. for (const id of searchResults.value) {
  1425. try {
  1426. await graph.setElementState(id, ['selected'], false);
  1427. } catch {
  1428. /* 忽略 */
  1429. }
  1430. }
  1431. }
  1432. /* ---------------- 过滤实现 ---------------- */
  1433. function compareValue(value: number, operator: string, target: number): boolean {
  1434. if (operator === '>') return value > target;
  1435. if (operator === '>=') return value >= target;
  1436. if (operator === '=') return value === target;
  1437. if (operator === '<') return value < target;
  1438. return value <= target;
  1439. }
  1440. function resetFilterState() {
  1441. filterMatchedNodeIds.value = [];
  1442. filterMutedNodeIds.value = [];
  1443. filterMatchedEdgeIds.value = [];
  1444. filterHidden.value = false;
  1445. filterForm.kinds = [];
  1446. filterForm.condition = 'neighborTotal';
  1447. filterForm.operator = '>';
  1448. filterForm.value = undefined;
  1449. }
  1450. async function applyFilterPresentation() {
  1451. const graph = graphRef.value;
  1452. if (!graph) return;
  1453. const mutedNodeSet = new Set(filterMutedNodeIds.value);
  1454. const matchedNodeSet = new Set(filterMatchedNodeIds.value);
  1455. const matchedEdgeSet = new Set(filterMatchedEdgeIds.value);
  1456. const hiddenNodeSet = filterHidden.value ? mutedNodeSet : new Set<string>();
  1457. graph.updateNodeData(
  1458. graph.getNodeData().map((node) => ({
  1459. id: node.id,
  1460. data: {
  1461. ...(node.data || {}),
  1462. dimmed: mutedNodeSet.has(String(node.id)),
  1463. filterHidden: hiddenNodeSet.has(String(node.id)),
  1464. },
  1465. })) as any,
  1466. );
  1467. graph.updateEdgeData(
  1468. graph.getEdgeData().map((edge) => ({
  1469. id: edge.id,
  1470. data: {
  1471. ...(edge.data || {}),
  1472. dimmed: matchedNodeSet.size ? !matchedEdgeSet.has(String(edge.id)) : false,
  1473. filterHidden:
  1474. filterHidden.value &&
  1475. (!matchedEdgeSet.has(String(edge.id)) ||
  1476. hiddenNodeSet.has(String(edge.source)) ||
  1477. hiddenNodeSet.has(String(edge.target))),
  1478. },
  1479. })) as any,
  1480. );
  1481. graph.setNode(createNodeOptions() as any);
  1482. graph.setEdge(createEdgeOptions() as any);
  1483. await graph.draw();
  1484. if (matchedNodeSet.size) {
  1485. await clearElementStates();
  1486. matchedNodeSet.forEach((id) => {
  1487. void graph.setElementState(id, ['selected'], false);
  1488. });
  1489. }
  1490. }
  1491. async function executeFilter() {
  1492. const graph = graphRef.value;
  1493. if (!graph) return;
  1494. if (!filterForm.kinds.length) {
  1495. showMessage('请选择过滤实体', 'warning');
  1496. return;
  1497. }
  1498. if (!hasNumericValue(filterForm.value)) {
  1499. showMessage('请输入设定结果', 'warning');
  1500. return;
  1501. }
  1502. const targetKinds = new Set(filterForm.kinds);
  1503. const targetValue = Number(filterForm.value);
  1504. const edgeData = graph.getEdgeData();
  1505. const matchedNodeIds: string[] = [];
  1506. const mutedNodeIds: string[] = [];
  1507. const matchedEdgeIds = new Set<string>();
  1508. graph.getNodeData().forEach((node) => {
  1509. const nodeId = String(node.id);
  1510. if (!targetKinds.has(String(node.data?.kind || 'unknown'))) {
  1511. mutedNodeIds.push(nodeId);
  1512. return;
  1513. }
  1514. const connectedEdges = edgeData.filter(
  1515. (edge) => String(edge.source) === nodeId || String(edge.target) === nodeId,
  1516. );
  1517. const matched =
  1518. filterForm.condition === 'neighborTotal'
  1519. ? compareValue(connectedEdges.length, filterForm.operator, targetValue)
  1520. : connectedEdges.some((edge) =>
  1521. edgeNumericValues(edge).some((value) => compareValue(value, filterForm.operator, targetValue)),
  1522. );
  1523. if (matched) {
  1524. matchedNodeIds.push(nodeId);
  1525. connectedEdges.forEach((edge) => {
  1526. if (edge.id) matchedEdgeIds.add(String(edge.id));
  1527. });
  1528. } else {
  1529. mutedNodeIds.push(nodeId);
  1530. }
  1531. });
  1532. filterMatchedNodeIds.value = matchedNodeIds;
  1533. filterMutedNodeIds.value = mutedNodeIds;
  1534. filterMatchedEdgeIds.value = [...matchedEdgeIds];
  1535. filterHidden.value = false;
  1536. await applyFilterPresentation();
  1537. showMessage(`过滤完成:匹配 ${matchedNodeIds.length} 个实体`, 'success');
  1538. }
  1539. async function cancelFilter() {
  1540. filterMatchedNodeIds.value = [];
  1541. filterMutedNodeIds.value = [];
  1542. filterMatchedEdgeIds.value = [];
  1543. filterHidden.value = false;
  1544. await applyFilterPresentation();
  1545. }
  1546. async function selectFiltered() {
  1547. const graph = graphRef.value;
  1548. if (!graph || !filterMatchedNodeIds.value.length) return;
  1549. await clearElementStates();
  1550. filterMatchedNodeIds.value.forEach((id) => {
  1551. void graph.setElementState(id, ['selected'], false);
  1552. });
  1553. }
  1554. async function toggleFilterHidden() {
  1555. filterHidden.value = !filterHidden.value;
  1556. await applyFilterPresentation();
  1557. }
  1558. </script>
  1559. <style lang="less" scoped>
  1560. .ai-graph-viewer {
  1561. display: flex;
  1562. flex-direction: column;
  1563. width: 100%;
  1564. height: 100%;
  1565. min-height: 0;
  1566. background: var(--ai-bg, #fff);
  1567. &__toolbar {
  1568. position: relative;
  1569. flex: 0 0 auto;
  1570. padding: 6px 8px;
  1571. border-bottom: 1px solid var(--ai-border, #e5e7eb);
  1572. }
  1573. &__tools {
  1574. display: flex;
  1575. align-items: center;
  1576. flex-wrap: wrap;
  1577. gap: 6px;
  1578. }
  1579. &__divider {
  1580. width: 1px;
  1581. height: 16px;
  1582. margin: 0 2px;
  1583. background: var(--ai-border, #e5e7eb);
  1584. }
  1585. &__zoom {
  1586. min-width: 40px;
  1587. font-size: 11px;
  1588. color: var(--ai-text-2, #64748b);
  1589. font-variant-numeric: tabular-nums;
  1590. text-align: center;
  1591. }
  1592. &__filter-count {
  1593. margin-left: auto;
  1594. font-size: 11px;
  1595. color: var(--ai-primary, #2a50ec);
  1596. }
  1597. &__panel {
  1598. position: absolute;
  1599. top: 38px;
  1600. left: 8px;
  1601. z-index: 6;
  1602. width: min(380px, calc(100% - 16px));
  1603. padding: 10px;
  1604. background: var(--ai-bg, #fff);
  1605. border: 1px solid var(--ai-border, #e5e7eb);
  1606. border-radius: 8px;
  1607. box-shadow: var(--ai-shadow-lg, 0 8px 24px rgba(15, 23, 42, 0.12));
  1608. }
  1609. &__input {
  1610. width: 100%;
  1611. margin: 8px 0;
  1612. }
  1613. &__select {
  1614. width: 100%;
  1615. margin-bottom: 8px;
  1616. }
  1617. &__range-row {
  1618. display: flex;
  1619. align-items: center;
  1620. gap: 6px;
  1621. margin: 8px 0;
  1622. }
  1623. &__range-sep {
  1624. font-size: 12px;
  1625. color: var(--ai-text-2, #64748b);
  1626. }
  1627. &__number {
  1628. flex: 1 1 0;
  1629. min-width: 0;
  1630. }
  1631. &__switch-row {
  1632. display: flex;
  1633. align-items: center;
  1634. flex-wrap: wrap;
  1635. gap: 6px;
  1636. margin: 6px 0;
  1637. }
  1638. &__switch {
  1639. padding: 3px 8px;
  1640. font-size: 12px;
  1641. color: var(--ai-text-2, #64748b);
  1642. background: transparent;
  1643. border: 1px solid var(--ai-border, #e5e7eb);
  1644. border-radius: 6px;
  1645. cursor: pointer;
  1646. &.is-active {
  1647. color: var(--ai-primary, #2a50ec);
  1648. border-color: var(--ai-primary, #2a50ec);
  1649. background: var(--ai-primary-soft, rgba(42, 80, 236, 0.08));
  1650. }
  1651. &--compact {
  1652. min-width: 34px;
  1653. padding: 3px 6px;
  1654. text-align: center;
  1655. }
  1656. }
  1657. &__panel-footer {
  1658. display: flex;
  1659. align-items: center;
  1660. flex-wrap: wrap;
  1661. gap: 6px;
  1662. margin-top: 8px;
  1663. }
  1664. &__panel-result {
  1665. margin-right: auto;
  1666. font-size: 11px;
  1667. color: var(--ai-text-2, #64748b);
  1668. }
  1669. &__body {
  1670. position: relative;
  1671. display: flex;
  1672. flex: 1 1 auto;
  1673. min-height: 0;
  1674. }
  1675. &__stage {
  1676. flex: 1 1 auto;
  1677. min-width: 0;
  1678. min-height: 320px;
  1679. background: var(--ai-bg-subtle, #f7f8fa);
  1680. }
  1681. &__overlay {
  1682. position: absolute;
  1683. inset: 0;
  1684. z-index: 4;
  1685. display: flex;
  1686. align-items: center;
  1687. justify-content: center;
  1688. gap: 8px;
  1689. font-size: 12px;
  1690. color: var(--ai-text-2, #64748b);
  1691. background: rgba(255, 255, 255, 0.72);
  1692. pointer-events: none;
  1693. }
  1694. &__spinner {
  1695. width: 14px;
  1696. height: 14px;
  1697. border: 2px solid var(--ai-border, #e5e7eb);
  1698. border-top-color: var(--ai-primary, #2a50ec);
  1699. border-radius: 50%;
  1700. animation: ai-graph-spin 0.9s linear infinite;
  1701. }
  1702. &__aside {
  1703. flex: 0 0 240px;
  1704. width: 240px;
  1705. padding: 10px 12px;
  1706. overflow-y: auto;
  1707. background: var(--ai-bg, #fff);
  1708. border-left: 1px solid var(--ai-border, #e5e7eb);
  1709. }
  1710. &__aside-head {
  1711. display: flex;
  1712. align-items: flex-start;
  1713. gap: 8px;
  1714. margin-bottom: 8px;
  1715. }
  1716. &__aside-name {
  1717. flex: 1 1 auto;
  1718. min-width: 0;
  1719. font-size: 13px;
  1720. font-weight: 600;
  1721. color: var(--ai-text, #1e293b);
  1722. word-break: break-all;
  1723. }
  1724. &__link {
  1725. flex: 0 0 auto;
  1726. padding: 0;
  1727. font-size: 11px;
  1728. color: var(--ai-text-2, #64748b);
  1729. background: transparent;
  1730. border: 0;
  1731. cursor: pointer;
  1732. &:hover {
  1733. color: var(--ai-primary, #2a50ec);
  1734. }
  1735. }
  1736. &__attrs {
  1737. margin: 0;
  1738. font-size: 12px;
  1739. dt {
  1740. margin-top: 8px;
  1741. color: var(--ai-text-2, #64748b);
  1742. }
  1743. dd {
  1744. margin: 2px 0 0;
  1745. color: var(--ai-text, #1e293b);
  1746. word-break: break-all;
  1747. }
  1748. }
  1749. &__actions {
  1750. display: flex;
  1751. flex-wrap: wrap;
  1752. gap: 6px;
  1753. margin-top: 12px;
  1754. padding-top: 8px;
  1755. border-top: 1px dashed var(--ai-border, #e5e7eb);
  1756. }
  1757. }
  1758. /* ---------- 原对象关系分析页的只读工具栏 / 画布 / 状态栏样式 ---------- */
  1759. .relation-stage {
  1760. flex: 1 1 auto;
  1761. width: 100%;
  1762. min-height: 0;
  1763. overflow: hidden;
  1764. }
  1765. .relation-stage__canvas {
  1766. position: relative;
  1767. width: 100%;
  1768. height: 100%;
  1769. min-height: 0;
  1770. overflow: hidden;
  1771. border: 1px solid rgba(148, 163, 184, 0.24);
  1772. background: #f4f7fb;
  1773. box-shadow:
  1774. inset 0 1px 0 rgba(255, 255, 255, 0.75),
  1775. 0 18px 36px rgba(15, 23, 42, 0.06);
  1776. }
  1777. .relation-stage__graph {
  1778. position: relative;
  1779. z-index: 1;
  1780. width: 100%;
  1781. height: 100%;
  1782. contain: layout;
  1783. :deep(.g6-canvas),
  1784. :deep(canvas) {
  1785. transform: translateZ(0);
  1786. backface-visibility: hidden;
  1787. }
  1788. }
  1789. .relation-stage__layout-loading {
  1790. position: absolute;
  1791. inset: 0;
  1792. z-index: 60;
  1793. display: flex;
  1794. align-items: center;
  1795. justify-content: center;
  1796. background: rgba(244, 247, 251, 0.82);
  1797. }
  1798. .relation-stage__layout-loading-card {
  1799. display: flex;
  1800. flex-direction: column;
  1801. align-items: center;
  1802. gap: 8px;
  1803. padding: 18px 24px;
  1804. border: 1px solid rgba(148, 163, 184, 0.22);
  1805. border-radius: 14px;
  1806. background: rgba(255, 255, 255, 0.96);
  1807. box-shadow: 0 16px 34px rgba(15, 23, 42, 0.1);
  1808. }
  1809. .relation-stage__layout-loading-spinner {
  1810. width: 22px;
  1811. height: 22px;
  1812. border: 2px solid rgba(37, 99, 235, 0.18);
  1813. border-top-color: #2563eb;
  1814. border-radius: 50%;
  1815. animation: ai-graph-spin 0.8s linear infinite;
  1816. }
  1817. .relation-stage__layout-loading-title {
  1818. font-size: 13px;
  1819. font-weight: 600;
  1820. color: #334155;
  1821. }
  1822. .relation-stage__layout-loading-desc {
  1823. font-size: 12px;
  1824. color: #94a3b8;
  1825. }
  1826. .control-tools-box {
  1827. position: absolute;
  1828. top: 24px;
  1829. right: 24px;
  1830. z-index: 40;
  1831. display: flex;
  1832. flex-direction: column;
  1833. align-items: flex-end;
  1834. gap: 12px;
  1835. max-width: calc(100% - 48px);
  1836. pointer-events: none;
  1837. }
  1838. .custom-atlas-tools-container {
  1839. position: relative;
  1840. overflow: visible;
  1841. border-radius: 10px;
  1842. pointer-events: auto;
  1843. max-width: min(760px, calc(100vw - 360px));
  1844. ul {
  1845. display: flex;
  1846. align-items: center;
  1847. justify-content: flex-end;
  1848. flex-wrap: wrap;
  1849. margin: 0;
  1850. padding: 0;
  1851. list-style: none;
  1852. }
  1853. }
  1854. .tool-item {
  1855. box-sizing: border-box;
  1856. width: 40px;
  1857. height: 44px;
  1858. display: inline-flex;
  1859. align-items: center;
  1860. justify-content: center;
  1861. background: rgba(255, 255, 255, 0.96);
  1862. border: 1px solid rgba(148, 163, 184, 0.18);
  1863. box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08);
  1864. cursor: pointer;
  1865. transition:
  1866. background-color 0.2s ease,
  1867. box-shadow 0.2s ease,
  1868. transform 0.2s ease;
  1869. &:first-child {
  1870. border-top-left-radius: 8px;
  1871. border-bottom-left-radius: 8px;
  1872. }
  1873. &:last-child {
  1874. border-top-right-radius: 8px;
  1875. border-bottom-right-radius: 8px;
  1876. }
  1877. &:hover {
  1878. background-color: #e8ebfb;
  1879. box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12);
  1880. transform: translateY(-1px);
  1881. .icon-style {
  1882. color: #2563eb;
  1883. }
  1884. }
  1885. }
  1886. .tool-item.is-active {
  1887. background-color: #e8ebfb;
  1888. box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12);
  1889. .icon-style {
  1890. color: #2563eb;
  1891. }
  1892. }
  1893. .tool-item-icon {
  1894. display: inline-flex;
  1895. align-items: center;
  1896. justify-content: center;
  1897. width: 24px;
  1898. height: 24px;
  1899. }
  1900. .gap-item {
  1901. margin-right: 16px;
  1902. border-top-right-radius: 8px;
  1903. border-bottom-right-radius: 8px;
  1904. & + .tool-item {
  1905. border-top-left-radius: 8px;
  1906. border-bottom-left-radius: 8px;
  1907. }
  1908. }
  1909. .icon-style {
  1910. font-size: 20px;
  1911. color: #303133;
  1912. transition: color 0.2s ease;
  1913. }
  1914. .tool-panel {
  1915. width: min(360px, calc(100vw - 48px));
  1916. padding: 14px;
  1917. border-radius: 16px;
  1918. border: 1px solid rgba(148, 163, 184, 0.2);
  1919. background: rgba(255, 255, 255, 0.96);
  1920. box-shadow: 0 16px 34px rgba(15, 23, 42, 0.12);
  1921. pointer-events: auto;
  1922. }
  1923. .tool-panel :deep(.ant-input),
  1924. .tool-panel :deep(.ant-select),
  1925. .tool-panel :deep(.ant-input-number) {
  1926. width: 100%;
  1927. }
  1928. .tool-panel__row {
  1929. width: 100%;
  1930. display: block;
  1931. }
  1932. .tool-panel__entity-select {
  1933. display: block;
  1934. width: 100%;
  1935. min-width: 0;
  1936. }
  1937. .tool-panel__header {
  1938. display: flex;
  1939. align-items: center;
  1940. justify-content: space-between;
  1941. margin-bottom: 10px;
  1942. }
  1943. .tool-panel__title {
  1944. font-size: 13px;
  1945. font-weight: 600;
  1946. color: #334155;
  1947. }
  1948. .tool-panel__row + .tool-panel__row,
  1949. .tool-panel__row + .tool-panel__switch-row,
  1950. .tool-panel__switch-row + .tool-panel__row,
  1951. .tool-panel__switch-row + .tool-panel__switch-row {
  1952. margin-top: 10px;
  1953. }
  1954. .tool-panel__switch-row {
  1955. display: flex;
  1956. align-items: center;
  1957. flex-wrap: wrap;
  1958. gap: 8px;
  1959. }
  1960. .tool-panel__range-row {
  1961. display: flex;
  1962. align-items: center;
  1963. gap: 8px;
  1964. }
  1965. .tool-panel__range-separator {
  1966. color: #94a3b8;
  1967. font-size: 12px;
  1968. }
  1969. .tool-panel__switch {
  1970. min-width: 76px;
  1971. height: 32px;
  1972. padding: 0 12px;
  1973. border-radius: 8px;
  1974. border: 1px solid rgba(148, 163, 184, 0.28);
  1975. background: #fff;
  1976. color: #475569;
  1977. font-size: 12px;
  1978. cursor: pointer;
  1979. &.is-active {
  1980. border-color: #2563eb;
  1981. background: #eff6ff;
  1982. color: #1d4ed8;
  1983. }
  1984. }
  1985. .tool-panel__switch--compact {
  1986. min-width: 42px;
  1987. padding: 0 8px;
  1988. }
  1989. .tool-panel__number {
  1990. width: 100px;
  1991. }
  1992. .tool-panel__footer {
  1993. margin-top: 12px;
  1994. }
  1995. .tool-panel__result {
  1996. margin-bottom: 8px;
  1997. font-size: 12px;
  1998. color: #64748b;
  1999. }
  2000. .tool-panel__actions {
  2001. display: flex;
  2002. flex-wrap: wrap;
  2003. gap: 8px;
  2004. }
  2005. .canvas-toolbar {
  2006. position: relative;
  2007. z-index: 1;
  2008. display: flex;
  2009. align-items: center;
  2010. justify-content: flex-end;
  2011. flex-wrap: wrap;
  2012. gap: 4px;
  2013. max-width: calc(100vw - 48px);
  2014. padding: 7px;
  2015. border-radius: 16px;
  2016. border: 1px solid rgba(148, 163, 184, 0.2);
  2017. background: rgba(255, 255, 255, 0.97);
  2018. box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08);
  2019. pointer-events: auto;
  2020. }
  2021. .canvas-toolbar__button {
  2022. width: 32px;
  2023. height: 32px;
  2024. padding: 0;
  2025. display: inline-flex;
  2026. align-items: center;
  2027. justify-content: center;
  2028. border-radius: 10px;
  2029. color: #334155;
  2030. &:hover:not(:disabled) {
  2031. color: #2563eb;
  2032. background: rgba(59, 130, 246, 0.1);
  2033. }
  2034. &.is-active {
  2035. color: #2563eb;
  2036. background: rgba(59, 130, 246, 0.12);
  2037. }
  2038. }
  2039. .canvas-toolbar__divider {
  2040. width: 1px;
  2041. height: 18px;
  2042. background: rgba(148, 163, 184, 0.35);
  2043. margin: 0 2px;
  2044. }
  2045. .canvas-status {
  2046. position: absolute;
  2047. left: 18px;
  2048. bottom: 14px;
  2049. z-index: 20;
  2050. display: flex;
  2051. flex-wrap: wrap;
  2052. gap: 14px;
  2053. font-size: 11px;
  2054. color: #64748b;
  2055. pointer-events: none;
  2056. }
  2057. .analysis-detail__summary {
  2058. display: grid;
  2059. grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  2060. gap: 12px;
  2061. }
  2062. .analysis-detail__entry {
  2063. padding: 10px 12px;
  2064. border: 1px solid rgba(148, 163, 184, 0.2);
  2065. border-radius: 10px;
  2066. background: #f8fafc;
  2067. }
  2068. .analysis-detail__entry-label {
  2069. font-size: 11px;
  2070. color: #94a3b8;
  2071. }
  2072. .analysis-detail__entry-value {
  2073. margin-top: 4px;
  2074. font-size: 13px;
  2075. color: #334155;
  2076. word-break: break-all;
  2077. }
  2078. @keyframes ai-graph-spin {
  2079. to {
  2080. transform: rotate(360deg);
  2081. }
  2082. }
  2083. </style>