index.vue 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531
  1. <template>
  2. <div class="relation-graph-test-page">
  3. <div class="relation-graph-test-page__header">
  4. <div class="relation-graph-test-page__title-wrap">
  5. <div class="relation-graph-test-page__title">Relation Graph 图谱性能测试</div>
  6. <div class="relation-graph-test-page__subtitle"
  7. >使用对象关系分析接口和节点样式验证 Relation Graph 大图性能。</div
  8. >
  9. </div>
  10. <div class="relation-graph-test-page__meta">
  11. <span>节点 {{ graphStats.nodes }}</span>
  12. <span>关系 {{ graphStats.lines }}</span>
  13. <span>层级 {{ graphStats.depth }}</span>
  14. <span>生成 {{ lastGenerateCost }}ms</span>
  15. <span>渲染 {{ lastRenderCost }}ms</span>
  16. </div>
  17. <div class="relation-graph-test-page__actions">
  18. <Button @click="handleRelayout">重新布局</Button>
  19. <Button @click="handleFitView">适配画布</Button>
  20. <Button @click="handleGenerate">生成模拟数据</Button>
  21. <Button type="primary" :loading="analysisLoading" @click="handleOpenAnalysisCondition">设置分析条件</Button>
  22. </div>
  23. </div>
  24. <div class="relation-graph-test-page__filter-bar">
  25. <div class="filter-item">
  26. <span class="filter-item__label">规模</span>
  27. <Select
  28. v-model:value="targetNodeCount"
  29. class="filter-item__control"
  30. :options="nodeCountOptions"
  31. @change="handleGenerate"
  32. />
  33. </div>
  34. <div class="filter-item">
  35. <span class="filter-item__label">分支</span>
  36. <InputNumber
  37. v-model:value="branchFactor"
  38. class="filter-item__number"
  39. :min="2"
  40. :max="30"
  41. :precision="0"
  42. @pressEnter="handleGenerate"
  43. />
  44. </div>
  45. <div class="filter-item">
  46. <span class="filter-item__label">深度</span>
  47. <InputNumber
  48. v-model:value="maxDepth"
  49. class="filter-item__number"
  50. :min="2"
  51. :max="12"
  52. :precision="0"
  53. @pressEnter="handleGenerate"
  54. />
  55. </div>
  56. <div class="filter-item">
  57. <span class="filter-item__label">层级距离</span>
  58. <InputNumber
  59. v-model:value="layoutLevelGap"
  60. class="filter-item__number"
  61. :min="80"
  62. :max="320"
  63. :precision="0"
  64. @change="handleLayoutControlChange"
  65. @pressEnter="handleLayoutControlChange"
  66. />
  67. </div>
  68. <div class="filter-item">
  69. <span class="filter-item__label">节点距离</span>
  70. <InputNumber
  71. v-model:value="layoutNodeDistance"
  72. class="filter-item__number"
  73. :min="24"
  74. :max="220"
  75. :precision="0"
  76. @change="handleLayoutControlChange"
  77. @pressEnter="handleLayoutControlChange"
  78. />
  79. </div>
  80. <div class="filter-item">
  81. <span class="filter-item__label">扩散系数</span>
  82. <InputNumber
  83. v-model:value="layoutSpreadCoefficient"
  84. class="filter-item__number"
  85. :min="0.6"
  86. :max="2"
  87. :precision="1"
  88. :step="0.1"
  89. @change="handleLayoutControlChange"
  90. @pressEnter="handleLayoutControlChange"
  91. />
  92. </div>
  93. <div class="filter-item filter-item--switch">
  94. <span class="filter-item__label">线文本</span>
  95. <Switch v-model:checked="showLineText" @change="handleGenerate" />
  96. </div>
  97. <div class="filter-item filter-item--switch">
  98. <span class="filter-item__label">工具栏</span>
  99. <Switch v-model:checked="showGraphToolbar" />
  100. </div>
  101. </div>
  102. <div class="relation-graph-test-page__body">
  103. <aside class="relation-graph-test-page__summary">
  104. <div class="panel-title">数据概览</div>
  105. <div class="summary-grid">
  106. <div class="summary-grid__item">
  107. <span class="summary-grid__label">数据来源</span>
  108. <span class="summary-grid__value">{{ dataSourceLabel }}</span>
  109. </div>
  110. <div class="summary-grid__item">
  111. <span class="summary-grid__label">叶子节点</span>
  112. <span class="summary-grid__value">{{ graphStats.leafs }}</span>
  113. </div>
  114. <div class="summary-grid__item">
  115. <span class="summary-grid__label">线文本</span>
  116. <span class="summary-grid__value">{{ showLineText ? '开启' : '关闭' }}</span>
  117. </div>
  118. <div class="summary-grid__item">
  119. <span class="summary-grid__label">布局</span>
  120. <span class="summary-grid__value">关系图谱</span>
  121. </div>
  122. </div>
  123. <div class="panel-title panel-title--sub">实体类型</div>
  124. <div class="entity-kind-list">
  125. <div v-for="item in entityKinds" :key="item.key" class="entity-kind-list__item">
  126. <span class="entity-kind-list__dot" :style="{ backgroundColor: item.color }"></span>
  127. <span class="entity-kind-list__label">{{ item.label }}</span>
  128. <Tag>{{ item.count }}</Tag>
  129. </div>
  130. </div>
  131. <div class="panel-title panel-title--sub">当前选择</div>
  132. <div v-if="activeNode" class="active-node-card">
  133. <div class="active-node-card__name">{{ activeNode.text }}</div>
  134. <div class="active-node-card__row">
  135. <span>类型</span>
  136. <span>{{ activeNode.typeLabel }}</span>
  137. </div>
  138. <div class="active-node-card__row">
  139. <span>层级</span>
  140. <span>{{ activeNode.level }}</span>
  141. </div>
  142. <div class="active-node-card__row">
  143. <span>ID</span>
  144. <span>{{ activeNode.id }}</span>
  145. </div>
  146. </div>
  147. <Empty v-else :image="Empty.PRESENTED_IMAGE_SIMPLE" description="未选择节点" />
  148. </aside>
  149. <main class="relation-graph-test-page__stage">
  150. <RelationGraph
  151. class="relation-graph-test-page__graph"
  152. :options="graphOptions"
  153. @on-ready="handleGraphReady"
  154. @on-node-click="handleNodeClick"
  155. @on-line-click="handleLineClick"
  156. >
  157. <template #node="{ node }">
  158. <div
  159. class="relation-node"
  160. :class="{
  161. 'relation-node--lite': graphStats.nodes >= LITE_GRAPH_NODE_THRESHOLD,
  162. 'relation-node--large': graphStats.nodes >= LARGE_GRAPH_NODE_THRESHOLD,
  163. }"
  164. :style="{
  165. '--node-accent-color': getRelationNodeColor(node),
  166. '--node-text-color': getRelationNodeTextColor(node),
  167. }"
  168. :title="getRelationNodeData(node).fullLabel || node.text || node.id"
  169. >
  170. <div v-if="graphStats.nodes < LARGE_GRAPH_NODE_THRESHOLD" class="relation-node__content">
  171. <span class="relation-node__label">{{ getRelationNodeData(node).label || node.text }}</span>
  172. <span
  173. v-if="graphStats.nodes < LITE_GRAPH_NODE_THRESHOLD && getRelationNodeData(node).secondaryLabel"
  174. class="relation-node__secondary"
  175. >
  176. {{ getRelationNodeData(node).secondaryLabel }}
  177. </span>
  178. </div>
  179. </div>
  180. </template>
  181. </RelationGraph>
  182. <div v-if="graphLoading" class="relation-graph-test-page__loading">
  183. <div class="relation-graph-test-page__loading-card">
  184. <div class="relation-graph-test-page__loading-spinner"></div>
  185. <div class="relation-graph-test-page__loading-title">布局渲染中</div>
  186. <div class="relation-graph-test-page__loading-desc"
  187. >当前 {{ graphStats.nodes || targetNodeCount }} 个节点。</div
  188. >
  189. </div>
  190. </div>
  191. <div v-else-if="!hasRenderedGraph" class="relation-graph-test-page__empty">
  192. <div class="relation-graph-test-page__empty-title">请选择数据来源</div>
  193. <div class="relation-graph-test-page__empty-desc"
  194. >点击“设置分析条件”加载接口数据,或点击“生成模拟数据”压测 2w 节点。</div
  195. >
  196. </div>
  197. </main>
  198. </div>
  199. <AnalysisConditionModal
  200. v-model:open="analysisConditionVisible"
  201. :loading="analysisLoading"
  202. :respondent-options="allRespondentOptions"
  203. :default-selected-ids="routeGraphRespondents.length ? routeGraphDefaultSelectedIds : undefined"
  204. @start="handleStartAnalysis"
  205. />
  206. </div>
  207. </template>
  208. <script lang="ts" setup name="ViewsGraphRelationGraphTest">
  209. import { computed, nextTick, onBeforeUnmount, reactive, ref, shallowRef } from 'vue';
  210. import { useRoute } from 'vue-router';
  211. import { Button, Empty, InputNumber, Select, Switch, Tag, message } from 'ant-design-vue';
  212. import RelationGraph, {
  213. RGLineShape,
  214. RGNodeShape,
  215. type JsonLine,
  216. type JsonNode,
  217. type RGJsonData,
  218. type RGLine,
  219. type RGLink,
  220. type RGNode,
  221. type RGOptions,
  222. type RGUserEvent,
  223. type RelationGraphInstance,
  224. } from '@relation-graph/vue';
  225. import '@relation-graph/vue/style.css';
  226. import type {
  227. AnalysisConditionPayload,
  228. AnalysisRespondent,
  229. GraphAnalysisEdge,
  230. GraphAnalysisNode,
  231. GraphAnalysisResponse,
  232. } from '../../api/analysis';
  233. import { buildGraphViewQuery, fetchGraphAnalysis, resolveGraphRespondentsFromRouteQuery } from '../../api/analysis';
  234. import { getPersons } from '@jeesite/person/api/personApi';
  235. import AnalysisConditionModal from '../graph/components/AnalysisConditionModal.vue';
  236. type EntityKindKey = 'person' | 'group' | 'house' | 'car' | 'bank' | 'phone' | 'unknown' | 'other';
  237. interface EntityKind {
  238. key: EntityKindKey;
  239. label: string;
  240. relation: string;
  241. color: string;
  242. borderColor: string;
  243. count: number;
  244. }
  245. interface ActiveNode {
  246. id: string;
  247. text: string;
  248. typeLabel: string;
  249. level: number;
  250. }
  251. interface GeneratedGraph {
  252. data: RGJsonData;
  253. rootId: string;
  254. depth: number;
  255. leafs: number;
  256. kindCounts: Record<EntityKindKey, number>;
  257. }
  258. interface RelationGraphNodeData {
  259. entityKind: EntityKindKey;
  260. typeLabel: string;
  261. label: string;
  262. secondaryLabel?: string;
  263. fullLabel?: string;
  264. level: number;
  265. rawData?: GraphAnalysisNode | null;
  266. }
  267. interface RenderableAnalysisNode {
  268. key: string;
  269. kind: EntityKindKey;
  270. label: string;
  271. secondaryLabel: string;
  272. fullLabel: string;
  273. rawData: GraphAnalysisNode | null;
  274. }
  275. interface RenderableAnalysisEdge {
  276. id: string;
  277. sourceKey: string;
  278. targetKey: string;
  279. label: string;
  280. rawData: GraphAnalysisEdge;
  281. }
  282. interface AnalysisNodeRegistry {
  283. nodeMap: Map<string, RenderableAnalysisNode>;
  284. exactTokenToKey: Map<string, string>;
  285. }
  286. const ROOT_ID = 'object-root';
  287. const route = useRoute();
  288. const graphInstance = shallowRef<RelationGraphInstance | null>(null);
  289. const activeNode = ref<ActiveNode | null>(null);
  290. const targetNodeCount = ref(20000);
  291. const branchFactor = ref(8);
  292. const maxDepth = ref(5);
  293. const layoutLevelGap = ref(150);
  294. const layoutNodeDistance = ref(80);
  295. const layoutSpreadCoefficient = ref(1.1);
  296. const showLineText = ref(true);
  297. const showGraphToolbar = ref(true);
  298. const graphLoading = ref(false);
  299. const analysisLoading = ref(false);
  300. const analysisConditionVisible = ref(false);
  301. const currentRootId = ref(ROOT_ID);
  302. const dataSource = ref<'mock' | 'analysis'>('mock');
  303. const hasRenderedGraph = ref(false);
  304. const lastGenerateCost = ref(0);
  305. const lastRenderCost = ref(0);
  306. const loadedRespondentOptions = ref<AnalysisRespondent[]>([]);
  307. let layoutRelayoutTimer: ReturnType<typeof setTimeout> | null = null;
  308. const routeGraphQuery = computed(() => buildGraphViewQuery(route.query));
  309. const routeGraphRespondents = computed(() => resolveGraphRespondentsFromRouteQuery(route.query));
  310. const routeGraphDefaultSelectedIds = computed(() => routeGraphRespondents.value.map((item) => item.id));
  311. const dataSourceLabel = computed(() =>
  312. dataSource.value === 'analysis' ? '分析接口' : `模拟${targetNodeCount.value}`,
  313. );
  314. const allRespondentOptions = computed(() => {
  315. const loaded = loadedRespondentOptions.value;
  316. const routeRespondents = routeGraphRespondents.value;
  317. if (!loaded.length) return routeRespondents;
  318. if (!routeRespondents.length) return loaded;
  319. const seenKeys = new Set(loaded.map((item) => item.id));
  320. const merged = [...loaded];
  321. routeRespondents.forEach((item) => {
  322. if (!seenKeys.has(item.id)) {
  323. seenKeys.add(item.id);
  324. merged.push(item);
  325. }
  326. });
  327. return merged;
  328. });
  329. const graphStats = reactive({
  330. nodes: 0,
  331. lines: 0,
  332. depth: 0,
  333. leafs: 0,
  334. });
  335. const entityKinds = reactive<EntityKind[]>([
  336. {
  337. key: 'person',
  338. label: '人物',
  339. relation: '关联人物',
  340. color: '#0f4c8a',
  341. borderColor: '#0b3d6f',
  342. count: 0,
  343. },
  344. {
  345. key: 'group',
  346. label: '机构',
  347. relation: '关联机构',
  348. color: '#0f9f68',
  349. borderColor: '#0b8055',
  350. count: 0,
  351. },
  352. {
  353. key: 'house',
  354. label: '房产',
  355. relation: '关联房产',
  356. color: '#b92a31',
  357. borderColor: '#991f25',
  358. count: 0,
  359. },
  360. {
  361. key: 'car',
  362. label: '车辆',
  363. relation: '关联车辆',
  364. color: '#f29a1f',
  365. borderColor: '#c9780a',
  366. count: 0,
  367. },
  368. {
  369. key: 'bank',
  370. label: '银行卡',
  371. relation: '资金账户',
  372. color: '#8441ae',
  373. borderColor: '#6b328f',
  374. count: 0,
  375. },
  376. {
  377. key: 'phone',
  378. label: '手机',
  379. relation: '通信号码',
  380. color: '#0aa0b4',
  381. borderColor: '#087f8f',
  382. count: 0,
  383. },
  384. {
  385. key: 'unknown',
  386. label: '未知',
  387. relation: '未知关系',
  388. color: '#434c55',
  389. borderColor: '#303840',
  390. count: 0,
  391. },
  392. {
  393. key: 'other',
  394. label: '其他',
  395. relation: '其他关系',
  396. color: '#d4ac2b',
  397. borderColor: '#a98718',
  398. count: 0,
  399. },
  400. ]);
  401. const entityKindMap = entityKinds.reduce<Record<string, EntityKind>>((accumulator, item) => {
  402. accumulator[item.key] = item;
  403. return accumulator;
  404. }, {});
  405. const nodeCountOptions = [
  406. { label: '500 节点', value: 500 },
  407. { label: '2000 节点', value: 2000 },
  408. { label: '5000 节点', value: 5000 },
  409. { label: '10000 节点', value: 10000 },
  410. { label: '20000 节点', value: 20000 },
  411. ];
  412. const LITE_GRAPH_NODE_THRESHOLD = 1000;
  413. const LARGE_GRAPH_NODE_THRESHOLD = 5000;
  414. const NORMAL_NODE_SIZE = 64;
  415. const LITE_NODE_SIZE = 44;
  416. const LARGE_NODE_SIZE = 24;
  417. const getNodeRenderSize = (nodeCount: number) => {
  418. if (nodeCount >= LARGE_GRAPH_NODE_THRESHOLD) return LARGE_NODE_SIZE;
  419. if (nodeCount >= LITE_GRAPH_NODE_THRESHOLD) return LITE_NODE_SIZE;
  420. return NORMAL_NODE_SIZE;
  421. };
  422. const graphOptions = computed<RGOptions>(() => {
  423. const nodeCount = graphStats.nodes || targetNodeCount.value;
  424. const largeGraph = nodeCount >= 1000;
  425. const veryLargeGraph = nodeCount >= LARGE_GRAPH_NODE_THRESHOLD;
  426. const nodeSize = getNodeRenderSize(nodeCount);
  427. const baseLevelGap = Math.max(80, Number(layoutLevelGap.value) || 150);
  428. const nodeDistance = Math.max(24, Number(layoutNodeDistance.value) || 80);
  429. const nodeDistanceCoefficient = nodeDistance / 80;
  430. const spreadCoefficient = Number(layoutSpreadCoefficient.value) || 1.1;
  431. const effectiveLevelGap = Math.round(baseLevelGap * nodeDistanceCoefficient * spreadCoefficient);
  432. return {
  433. debug: false,
  434. showToolBar: showGraphToolbar.value,
  435. performanceMode: true,
  436. backgroundColor: '#f8fafc',
  437. defaultNodeShape: RGNodeShape.circle,
  438. defaultNodeWidth: nodeSize,
  439. defaultNodeHeight: nodeSize,
  440. defaultNodeColor: '#ffffff',
  441. defaultNodeBorderColor: '#d6dee9',
  442. defaultNodeBorderWidth: veryLargeGraph ? 0 : 1,
  443. defaultNodeBorderRadius: nodeSize / 2,
  444. defaultLineShape: veryLargeGraph ? RGLineShape.StandardCurve : RGLineShape.StandardStraight,
  445. defaultLineColor: '#94a3b8',
  446. defaultLineWidth: 1,
  447. defaultLineTextOffsetY: -8,
  448. defaultLineTextOnPath: showLineText.value,
  449. lineTextMaxLength: 14,
  450. dragEventAction: 'move',
  451. wheelEventAction: 'zoom',
  452. defaultExpandHolderPosition: 'right',
  453. layout: {
  454. layoutName: 'center',
  455. distanceCoefficient: spreadCoefficient * nodeDistanceCoefficient,
  456. levelGaps: [
  457. effectiveLevelGap,
  458. Math.round(effectiveLevelGap * 1.12),
  459. Math.round(effectiveLevelGap * 1.22),
  460. Math.round(effectiveLevelGap * 1.32),
  461. Math.round(effectiveLevelGap * 1.42),
  462. ],
  463. disableAsForceLayout: largeGraph,
  464. maxLayoutTimes: largeGraph ? 0 : 120,
  465. force_node_repulsion: Math.min(2.4, Math.max(0.45, nodeDistanceCoefficient * 0.85)),
  466. force_line_elastic: 1.08,
  467. },
  468. };
  469. });
  470. const ensureEntityKind = (kind?: string | null): EntityKindKey => {
  471. const nextKind = (kind || '') as EntityKindKey;
  472. return entityKindMap[nextKind] ? nextKind : 'unknown';
  473. };
  474. const getEntityKind = (index: number, level: number) => entityKinds[(index + level) % entityKinds.length];
  475. const normalizeLookupKey = (value?: string | null) => (value || '').trim().toUpperCase();
  476. const uniqueValues = <T,>(items: T[]) => Array.from(new Set(items));
  477. const cloneSerializable = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
  478. const getEntityKindByClass = (value?: string | null): EntityKindKey | null => {
  479. const normalized = (value || '').trim().toLowerCase();
  480. if (!normalized) return null;
  481. if (normalized.includes('sfz')) return 'person';
  482. if (normalized.includes('tel')) return 'phone';
  483. if (normalized.includes('card')) return 'bank';
  484. if (normalized.includes('fc') || normalized.includes('house')) return 'house';
  485. if (normalized.includes('car')) return 'car';
  486. if (normalized.includes('ins') || normalized.includes('org')) return 'group';
  487. return null;
  488. };
  489. const getEntityKindByClazzPositionToken = (value?: string | null): EntityKindKey | null => {
  490. const normalized = (value || '').trim().toLowerCase();
  491. if (!normalized) return null;
  492. if (normalized === 'obj') return 'person';
  493. if (normalized === 'org' || normalized === 'ins') return 'group';
  494. return getEntityKindByClass(normalized);
  495. };
  496. const getEdgeEndpointKindsByClazz = (clazz?: string | null) => {
  497. const tokens = String(clazz || '')
  498. .trim()
  499. .toLowerCase()
  500. .split(/[^a-z0-9]+/)
  501. .filter(Boolean);
  502. return {
  503. sourceKind: tokens.length ? getEntityKindByClazzPositionToken(tokens[0]) : null,
  504. targetKind: tokens.length ? getEntityKindByClazzPositionToken(tokens[tokens.length - 1]) : null,
  505. };
  506. };
  507. const inferEntityKindFromToken = (value?: string | null): EntityKindKey => {
  508. const token = (value || '').trim();
  509. if (!token) return 'unknown';
  510. if (/公司|超市|商店|经营部|企业|集团|合作社|银行/.test(token)) return 'group';
  511. if (/^[\u4e00-\u9fa5][A-Z0-9]{5,}$/i.test(token)) return 'car';
  512. if (/^\d{1,6}$/.test(token)) return 'house';
  513. const digitOnly = token.replace(/\D/g, '');
  514. if (/^\d{17}[0-9X*]$/i.test(token) || token.includes('****')) return 'person';
  515. if (digitOnly.length >= 7 && digitOnly.length <= 11) return 'phone';
  516. if (digitOnly.length >= 12) return 'bank';
  517. return 'other';
  518. };
  519. const getAnalysisClassByKind = (kind: EntityKindKey) => {
  520. switch (kind) {
  521. case 'person':
  522. return 'sfz';
  523. case 'phone':
  524. return 'tel';
  525. case 'bank':
  526. return 'card';
  527. case 'house':
  528. return 'dz_fc';
  529. case 'car':
  530. return 'car';
  531. case 'group':
  532. return 'obj';
  533. case 'other':
  534. return 'other';
  535. default:
  536. return 'x';
  537. }
  538. };
  539. const getAnalysisNodeFullLabel = (node: GraphAnalysisNode, kind: EntityKindKey) => {
  540. const name = (node.mc || '').trim();
  541. const nodeCn = (node.nodecn || '').trim();
  542. const code = (node.bm || node.sfz || '').trim();
  543. if (kind === 'person' || kind === 'group') return name || nodeCn || code || '未命名实体';
  544. if (kind === 'bank' || kind === 'phone' || kind === 'car' || kind === 'house') {
  545. return code || name || '未命名实体';
  546. }
  547. return name || nodeCn || code || '未命名实体';
  548. };
  549. const getAnalysisNodeSecondaryLabel = (node: GraphAnalysisNode, kind: EntityKindKey) => {
  550. const nodeCn = (node.nodecn || '').trim();
  551. const code = (node.bm || node.sfz || '').trim();
  552. const fullLabel = getAnalysisNodeFullLabel(node, kind);
  553. if (!nodeCn || nodeCn === code || nodeCn === fullLabel) return '';
  554. return nodeCn;
  555. };
  556. const normalizeAnalysisEdgeClazz = (clazz?: string | null) =>
  557. String(clazz || '')
  558. .trim()
  559. .toLowerCase();
  560. const isCommunicationAnalysisEdgeClazz = (clazz?: string | null) => normalizeAnalysisEdgeClazz(clazz) === 'tel_tel';
  561. const shouldShowAnalysisEdgeCount = (clazz?: string | null) => {
  562. const normalizedClazz = normalizeAnalysisEdgeClazz(clazz);
  563. return normalizedClazz === 'card_card' || normalizedClazz === 'tel_tel';
  564. };
  565. const formatAmountLabel = (value: number) => value.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
  566. const getAnalysisEdgeCountText = (edge: {
  567. clazz?: string | null;
  568. nums?: string | number | null;
  569. money?: number | null;
  570. }) => {
  571. if (!shouldShowAnalysisEdgeCount(edge.clazz)) return '';
  572. const countValue =
  573. edge.nums !== null && edge.nums !== undefined && edge.nums !== ''
  574. ? edge.nums
  575. : isCommunicationAnalysisEdgeClazz(edge.clazz) && typeof edge.money === 'number'
  576. ? edge.money
  577. : undefined;
  578. return countValue !== undefined ? `${String(countValue).replace(/\.0$/, '')}次` : '';
  579. };
  580. const buildAnalysisEdgeLabel = (edge: GraphAnalysisEdge) => {
  581. const baseLabel = (edge.mc || edge.sm || '').trim();
  582. const relationText = [baseLabel, edge.clazz, edge.sm, edge.mc]
  583. .map((item) => String(item || '').trim())
  584. .filter(Boolean)
  585. .join(' ');
  586. const shouldHideAmount =
  587. relationText.includes('持卡人') ||
  588. relationText.includes('机主') ||
  589. relationText.includes('开户人') ||
  590. relationText.includes('户主');
  591. const countLabel = getAnalysisEdgeCountText(edge);
  592. const amountLabel =
  593. !shouldHideAmount && !isCommunicationAnalysisEdgeClazz(edge.clazz) && typeof edge.money === 'number'
  594. ? `¥${formatAmountLabel(edge.money)}`
  595. : '';
  596. return [baseLabel, amountLabel || countLabel].filter(Boolean).join(' ');
  597. };
  598. const createVirtualAnalysisNode = (token: string, kind: EntityKindKey): GraphAnalysisNode => ({
  599. id: normalizeLookupKey(token) || token,
  600. bm: token.trim(),
  601. mc: token.trim(),
  602. mc1: token.trim(),
  603. class: getAnalysisClassByKind(kind),
  604. });
  605. const createNodeData = (
  606. nodeId: string,
  607. kind: EntityKindKey,
  608. label: string,
  609. options: {
  610. secondaryLabel?: string;
  611. fullLabel?: string;
  612. level?: number;
  613. rawData?: GraphAnalysisNode | null;
  614. } = {},
  615. ): JsonNode => {
  616. const entity = entityKindMap[kind] || entityKindMap.unknown;
  617. return {
  618. id: nodeId,
  619. text: label,
  620. type: kind,
  621. nodeShape: RGNodeShape.circle,
  622. color: entity.color,
  623. borderColor: entity.borderColor,
  624. fontColor: kind === 'car' || kind === 'other' ? '#111827' : '#ffffff',
  625. data: {
  626. entityKind: kind,
  627. typeLabel: entity.label,
  628. label,
  629. secondaryLabel: options.secondaryLabel || '',
  630. fullLabel: options.fullLabel || label,
  631. level: options.level || 0,
  632. rawData: options.rawData || null,
  633. } satisfies RelationGraphNodeData,
  634. };
  635. };
  636. const createNodeText = (kind: EntityKind, index: number) => {
  637. const serial = String(index).padStart(5, '0');
  638. const nameMap: Record<EntityKindKey, string> = {
  639. person: `对象-${serial}`,
  640. phone: `13****${serial.slice(-4)}`,
  641. bank: `卡号-${serial}`,
  642. car: `车辆-${serial}`,
  643. house: `房产-${serial}`,
  644. group: `机构-${serial}`,
  645. unknown: `未知-${serial}`,
  646. other: `其他-${serial}`,
  647. };
  648. return nameMap[kind.key];
  649. };
  650. const resetKindCounts = () => {
  651. entityKinds.forEach((item) => {
  652. item.count = 0;
  653. });
  654. };
  655. const createEmptyKindCounts = () =>
  656. entityKinds.reduce(
  657. (counts, item) => ({
  658. ...counts,
  659. [item.key]: 0,
  660. }),
  661. {} as Record<EntityKindKey, number>,
  662. );
  663. const syncKindCounts = (kindCounts: Record<EntityKindKey, number>) => {
  664. entityKinds.forEach((item) => {
  665. item.count = kindCounts[item.key] || 0;
  666. });
  667. };
  668. const getRelationNodeData = (node: RGNode): RelationGraphNodeData => (node.data || {}) as RelationGraphNodeData;
  669. const getRelationNodeKind = (node: RGNode) => ensureEntityKind(getRelationNodeData(node).entityKind || node.type);
  670. const getRelationNodeColor = (node: RGNode) =>
  671. entityKindMap[getRelationNodeKind(node)]?.color || entityKindMap.unknown.color;
  672. const getRelationNodeTextColor = (node: RGNode) => {
  673. const kind = getRelationNodeKind(node);
  674. return kind === 'car' || kind === 'other' ? '#111827' : '#ffffff';
  675. };
  676. const registerAnalysisNode = (
  677. registry: AnalysisNodeRegistry,
  678. key: string,
  679. kind: EntityKindKey,
  680. fullLabel: string,
  681. exactTokens: string[],
  682. rawData?: GraphAnalysisNode | null,
  683. ) => {
  684. if (!key) return;
  685. const currentNode = registry.nodeMap.get(key);
  686. if (!currentNode) {
  687. registry.nodeMap.set(key, {
  688. key,
  689. kind,
  690. label: fullLabel,
  691. secondaryLabel: rawData ? getAnalysisNodeSecondaryLabel(rawData, kind) : '',
  692. fullLabel,
  693. rawData: rawData || null,
  694. });
  695. } else if (rawData && !currentNode.rawData) {
  696. currentNode.rawData = rawData;
  697. }
  698. exactTokens.forEach((token) => {
  699. if (token && !registry.exactTokenToKey.has(token)) {
  700. registry.exactTokenToKey.set(token, key);
  701. }
  702. });
  703. };
  704. const resolveRegisteredAnalysisNodeKey = (registry: AnalysisNodeRegistry, value?: string | null) => {
  705. const token = normalizeLookupKey(value);
  706. if (!token) return '';
  707. return registry.exactTokenToKey.get(token) || '';
  708. };
  709. const registerAnalysisResponseNodes = (registry: AnalysisNodeRegistry, nodes: GraphAnalysisNode[]) => {
  710. nodes.forEach((rawNode) => {
  711. const primaryToken = rawNode.bm || rawNode.sfz || rawNode.mc || rawNode.id;
  712. const key = normalizeLookupKey(primaryToken);
  713. if (!key) return;
  714. const kind =
  715. getEntityKindByClazzPositionToken(rawNode.class) ||
  716. inferEntityKindFromToken(rawNode.mc || rawNode.bm || rawNode.sfz || rawNode.id);
  717. const exactTokens = uniqueValues(
  718. [rawNode.bm, rawNode.sfz, rawNode.id, rawNode.dxId, rawNode.dxId0, rawNode.fzId]
  719. .map((item) => normalizeLookupKey(item))
  720. .filter(Boolean),
  721. );
  722. registerAnalysisNode(registry, key, kind, getAnalysisNodeFullLabel(rawNode, kind), exactTokens, rawNode);
  723. });
  724. };
  725. const ensureAnalysisNodeFromEdge = (
  726. registry: AnalysisNodeRegistry,
  727. token: string,
  728. preferredKind?: EntityKindKey | null,
  729. ) => {
  730. const key = normalizeLookupKey(token);
  731. if (!key) return '';
  732. const existingKey = resolveRegisteredAnalysisNodeKey(registry, token);
  733. if (existingKey) return existingKey;
  734. const kind = preferredKind || inferEntityKindFromToken(token);
  735. registerAnalysisNode(registry, key, kind, token.trim(), [key], createVirtualAnalysisNode(token, kind));
  736. return key;
  737. };
  738. const resolveAnalysisEdgeEndpointKey = (
  739. registry: AnalysisNodeRegistry,
  740. token: string,
  741. expectedKind?: EntityKindKey | null,
  742. ) => resolveRegisteredAnalysisNodeKey(registry, token) || ensureAnalysisNodeFromEdge(registry, token, expectedKind);
  743. const buildRenderableAnalysisEdges = (rawEdges: GraphAnalysisEdge[], registry: AnalysisNodeRegistry) =>
  744. rawEdges
  745. .map((edge, index): RenderableAnalysisEdge | null => {
  746. const { sourceKind, targetKind } = getEdgeEndpointKindsByClazz(edge.clazz);
  747. const sourceKey = resolveAnalysisEdgeEndpointKey(registry, edge.dx1, sourceKind);
  748. const targetKey = resolveAnalysisEdgeEndpointKey(registry, edge.dx2, targetKind);
  749. if (!sourceKey || !targetKey || sourceKey === targetKey) return null;
  750. return {
  751. id: edge.id || `analysis-line-${index}`,
  752. sourceKey,
  753. targetKey,
  754. label: buildAnalysisEdgeLabel(edge),
  755. rawData: edge,
  756. };
  757. })
  758. .filter((item): item is RenderableAnalysisEdge => item !== null);
  759. const buildAnalysisLevelMap = (nodes: RenderableAnalysisNode[], edges: RenderableAnalysisEdge[]) => {
  760. const adjacency = new Map<string, string[]>();
  761. nodes.forEach((node) => adjacency.set(node.key, []));
  762. edges.forEach((edge) => {
  763. adjacency.get(edge.sourceKey)?.push(edge.targetKey);
  764. adjacency.get(edge.targetKey)?.push(edge.sourceKey);
  765. });
  766. const seedKey = nodes.find((node) => node.kind === 'person')?.key || nodes[0]?.key || '';
  767. const levelMap = new Map<string, number>();
  768. const queue = seedKey ? [seedKey] : [];
  769. if (seedKey) levelMap.set(seedKey, 0);
  770. while (queue.length) {
  771. const currentKey = queue.shift() || '';
  772. const currentLevel = levelMap.get(currentKey) ?? 0;
  773. (adjacency.get(currentKey) || []).forEach((nextKey) => {
  774. if (levelMap.has(nextKey)) return;
  775. levelMap.set(nextKey, currentLevel + 1);
  776. queue.push(nextKey);
  777. });
  778. }
  779. nodes.forEach((node) => {
  780. if (!levelMap.has(node.key)) levelMap.set(node.key, 99);
  781. });
  782. return { rootId: seedKey || ROOT_ID, levelMap };
  783. };
  784. const buildAnalysisGraphData = (
  785. payload: AnalysisConditionPayload,
  786. response: GraphAnalysisResponse,
  787. ): GeneratedGraph => {
  788. const registry: AnalysisNodeRegistry = {
  789. nodeMap: new Map<string, RenderableAnalysisNode>(),
  790. exactTokenToKey: new Map<string, string>(),
  791. };
  792. const graphData = (response as any).data ?? response;
  793. const rawNodes: GraphAnalysisNode[] = Array.isArray(graphData.dx) ? graphData.dx : [];
  794. const rawEdges: GraphAnalysisEdge[] = Array.isArray(graphData.gx) ? graphData.gx : [];
  795. registerAnalysisResponseNodes(registry, rawNodes);
  796. const renderableEdges = buildRenderableAnalysisEdges(rawEdges, registry);
  797. const renderableNodes = Array.from(registry.nodeMap.values());
  798. const { rootId, levelMap } = buildAnalysisLevelMap(renderableNodes, renderableEdges);
  799. const kindCounts = createEmptyKindCounts();
  800. const nodes = renderableNodes.map((node) => {
  801. const kind = ensureEntityKind(node.kind);
  802. kindCounts[kind] += 1;
  803. return createNodeData(node.key, kind, node.label, {
  804. secondaryLabel: node.secondaryLabel,
  805. fullLabel: node.fullLabel,
  806. level: levelMap.get(node.key) ?? 99,
  807. rawData: node.rawData,
  808. });
  809. });
  810. const visibleNodeKeys = new Set(nodes.map((node) => node.id));
  811. const veryLargeGraph = nodes.length >= LARGE_GRAPH_NODE_THRESHOLD;
  812. const lines: JsonLine[] = renderableEdges
  813. .filter((edge) => visibleNodeKeys.has(edge.sourceKey) && visibleNodeKeys.has(edge.targetKey))
  814. .map((edge) => ({
  815. id: edge.id,
  816. from: edge.sourceKey,
  817. to: edge.targetKey,
  818. text: showLineText.value ? edge.label : undefined,
  819. color: '#9aa8bd',
  820. fontColor: '#64748b',
  821. lineShape: veryLargeGraph ? RGLineShape.StandardCurve : RGLineShape.StandardStraight,
  822. useTextOnPath: showLineText.value,
  823. data: {
  824. rawData: edge.rawData,
  825. },
  826. }));
  827. const parentIds = new Set(lines.map((line) => line.from));
  828. const leafs = nodes.filter((node) => !parentIds.has(node.id)).length;
  829. const maxDepthValue = Math.max(0, ...Array.from(levelMap.values()).filter((level) => level < 99));
  830. const fallbackRootId =
  831. rootId ||
  832. payload.respondents
  833. .map((item) => resolveRegisteredAnalysisNodeKey(registry, item.objId || item.identityCode || item.name))
  834. .find(Boolean) ||
  835. nodes[0]?.id ||
  836. ROOT_ID;
  837. return {
  838. data: {
  839. rootId: fallbackRootId,
  840. nodes,
  841. lines,
  842. },
  843. rootId: fallbackRootId,
  844. depth: maxDepthValue,
  845. leafs,
  846. kindCounts,
  847. };
  848. };
  849. const createObjectRelationData = (): GeneratedGraph => {
  850. const total = Math.max(1, Number(targetNodeCount.value) || 1);
  851. const branch = Math.max(2, Number(branchFactor.value) || 2);
  852. const depthLimit = Math.max(2, Number(maxDepth.value) || 2);
  853. const kindCounts = createEmptyKindCounts();
  854. const nodes: JsonNode[] = [createNodeData(ROOT_ID, 'person', '重点对象')];
  855. const lines: JsonLine[] = [];
  856. const queue: Array<{ id: string; level: number; seed: number }> = [{ id: ROOT_ID, level: 0, seed: 1 }];
  857. const veryLargeGraph = total >= LARGE_GRAPH_NODE_THRESHOLD;
  858. let nodeIndex = 1;
  859. let maxActualDepth = 0;
  860. while (queue.length && nodeIndex < total) {
  861. const parent = queue.shift();
  862. if (!parent || parent.level >= depthLimit) {
  863. continue;
  864. }
  865. const nextLevel = parent.level + 1;
  866. const dynamicBranch = Math.max(1, branch - Math.floor(parent.level / 2));
  867. const remain = total - nodeIndex;
  868. const childCount = Math.min(dynamicBranch, remain);
  869. for (let i = 0; i < childCount && nodeIndex < total; i += 1) {
  870. const kind = getEntityKind(nodeIndex + parent.seed + i, nextLevel);
  871. const nodeId = `${kind.key}-${nodeIndex}`;
  872. kindCounts[kind.key] += 1;
  873. nodes.push(createNodeData(nodeId, kind.key, createNodeText(kind, nodeIndex), { level: nextLevel }));
  874. lines.push({
  875. id: `line-${parent.id}-${nodeId}`,
  876. from: parent.id,
  877. to: nodeId,
  878. text: showLineText.value ? kind.relation : undefined,
  879. color: '#9aa8bd',
  880. fontColor: '#64748b',
  881. lineShape: veryLargeGraph ? RGLineShape.StandardCurve : RGLineShape.StandardStraight,
  882. useTextOnPath: showLineText.value,
  883. });
  884. queue.push({ id: nodeId, level: nextLevel, seed: nodeIndex });
  885. maxActualDepth = Math.max(maxActualDepth, nextLevel);
  886. nodeIndex += 1;
  887. }
  888. }
  889. const parentIds = new Set(lines.map((line) => line.from));
  890. const leafs = nodes.filter((node) => !parentIds.has(node.id)).length;
  891. return {
  892. data: {
  893. rootId: ROOT_ID,
  894. nodes,
  895. lines,
  896. },
  897. rootId: ROOT_ID,
  898. depth: maxActualDepth,
  899. leafs,
  900. kindCounts,
  901. };
  902. };
  903. const handleFitView = () => {
  904. const graph = graphInstance.value;
  905. if (!graph) {
  906. return;
  907. }
  908. graph.moveToCenter();
  909. graph.zoomToFit();
  910. };
  911. const applyGraphData = async (graphData: GeneratedGraph) => {
  912. const graph = graphInstance.value;
  913. if (!graph) {
  914. return;
  915. }
  916. hasRenderedGraph.value = true;
  917. currentRootId.value = graphData.rootId;
  918. graphStats.nodes = graphData.data.nodes.length;
  919. graphStats.lines = graphData.data.lines.length;
  920. graphStats.depth = graphData.depth;
  921. graphStats.leafs = graphData.leafs;
  922. syncKindCounts(graphData.kindCounts);
  923. const renderStart = performance.now();
  924. graph.setOptions(graphOptions.value);
  925. await graph.setJsonData(graphData.data);
  926. lastRenderCost.value = Math.round(performance.now() - renderStart);
  927. requestAnimationFrame(() => {
  928. handleFitView();
  929. graphLoading.value = false;
  930. });
  931. };
  932. const renderGraph = async () => {
  933. graphLoading.value = true;
  934. activeNode.value = null;
  935. resetKindCounts();
  936. await nextTick();
  937. const generateStart = performance.now();
  938. const graphData = createObjectRelationData();
  939. lastGenerateCost.value = Math.round(performance.now() - generateStart);
  940. dataSource.value = 'mock';
  941. await applyGraphData(graphData);
  942. };
  943. const handleGenerate = () => {
  944. void renderGraph();
  945. };
  946. const handleRelayout = async () => {
  947. const graph = graphInstance.value;
  948. if (!graph) {
  949. return;
  950. }
  951. graphLoading.value = true;
  952. graph.setOptions(graphOptions.value);
  953. await graph.doLayout(currentRootId.value);
  954. requestAnimationFrame(() => {
  955. handleFitView();
  956. graphLoading.value = false;
  957. });
  958. };
  959. const handleLayoutControlChange = () => {
  960. if (!hasRenderedGraph.value) return;
  961. if (layoutRelayoutTimer) {
  962. clearTimeout(layoutRelayoutTimer);
  963. }
  964. layoutRelayoutTimer = setTimeout(() => {
  965. layoutRelayoutTimer = null;
  966. void handleRelayout();
  967. }, 250);
  968. };
  969. const loadAllRespondentOptions = async () => {
  970. if (loadedRespondentOptions.value.length) return;
  971. try {
  972. const list = await getPersons();
  973. const persons = Array.isArray(list) ? list : [];
  974. loadedRespondentOptions.value = persons
  975. .filter((item) => item.personName || item.id)
  976. .map((item) => ({
  977. id: String(item.objId ?? item.id ?? item.personName ?? ''),
  978. name: item.personName || String(item.id || ''),
  979. objId: String(item.objId ?? item.id ?? ''),
  980. identityCode: item.personCertNo || undefined,
  981. photograph: item.photograph || '',
  982. }));
  983. } catch {
  984. loadedRespondentOptions.value = [];
  985. }
  986. };
  987. const handleOpenAnalysisCondition = () => {
  988. analysisConditionVisible.value = true;
  989. void loadAllRespondentOptions();
  990. };
  991. const handleStartAnalysis = async (payload: AnalysisConditionPayload) => {
  992. if (analysisLoading.value) return;
  993. analysisLoading.value = true;
  994. graphLoading.value = true;
  995. activeNode.value = null;
  996. resetKindCounts();
  997. await nextTick();
  998. try {
  999. const generateStart = performance.now();
  1000. const response = await fetchGraphAnalysis(payload, routeGraphQuery.value);
  1001. const graphData = buildAnalysisGraphData(payload, response);
  1002. lastGenerateCost.value = Math.round(performance.now() - generateStart);
  1003. dataSource.value = 'analysis';
  1004. analysisConditionVisible.value = false;
  1005. await applyGraphData(graphData);
  1006. message.success(`已加载分析图谱:${graphData.data.nodes.length} 个节点,${graphData.data.lines.length} 条关系`);
  1007. } catch (error) {
  1008. graphLoading.value = false;
  1009. console.error('load relation graph analysis failed', error);
  1010. message.error('加载对象关系分析失败');
  1011. } finally {
  1012. analysisLoading.value = false;
  1013. }
  1014. };
  1015. const handleGraphReady = (graph: RelationGraphInstance) => {
  1016. graphInstance.value = graph;
  1017. };
  1018. const handleNodeClick = (node: RGNode, _event: RGUserEvent) => {
  1019. const nodeData = getRelationNodeData(node);
  1020. activeNode.value = {
  1021. id: node.id,
  1022. text: nodeData.fullLabel || nodeData.label || node.text || node.id,
  1023. typeLabel: String(nodeData.typeLabel || node.type || '-'),
  1024. level: Number(nodeData.level || 0),
  1025. };
  1026. return true;
  1027. };
  1028. const handleLineClick = (line: RGLine, _link: RGLink, _event: RGUserEvent) => {
  1029. if (line.text) {
  1030. message.info(`关系:${line.text}`);
  1031. }
  1032. return true;
  1033. };
  1034. onBeforeUnmount(() => {
  1035. if (layoutRelayoutTimer) {
  1036. clearTimeout(layoutRelayoutTimer);
  1037. layoutRelayoutTimer = null;
  1038. }
  1039. graphInstance.value?.beforeUnmount();
  1040. graphInstance.value = null;
  1041. });
  1042. </script>
  1043. <style lang="less">
  1044. .relation-graph-test-page {
  1045. display: flex;
  1046. flex-direction: column;
  1047. height: calc(100vh - 96px);
  1048. min-height: 620px;
  1049. padding: 16px;
  1050. overflow: hidden;
  1051. background: #f4f7fb;
  1052. box-sizing: border-box;
  1053. &__header {
  1054. display: flex;
  1055. flex: 0 0 auto;
  1056. align-items: center;
  1057. gap: 16px;
  1058. padding: 14px 16px;
  1059. background: #ffffff;
  1060. border: 1px solid #e5e7eb;
  1061. border-radius: 8px;
  1062. }
  1063. &__title-wrap {
  1064. flex: 1 1 auto;
  1065. min-width: 220px;
  1066. }
  1067. &__title {
  1068. color: #111827;
  1069. font-size: 18px;
  1070. font-weight: 600;
  1071. line-height: 26px;
  1072. }
  1073. &__subtitle {
  1074. margin-top: 2px;
  1075. color: #6b7280;
  1076. font-size: 12px;
  1077. line-height: 20px;
  1078. }
  1079. &__meta,
  1080. &__actions,
  1081. &__filter-bar {
  1082. display: flex;
  1083. align-items: center;
  1084. gap: 8px;
  1085. }
  1086. &__meta {
  1087. flex: 0 0 auto;
  1088. span {
  1089. padding: 4px 8px;
  1090. color: #475569;
  1091. font-size: 12px;
  1092. background: #f8fafc;
  1093. border: 1px solid #e2e8f0;
  1094. border-radius: 6px;
  1095. }
  1096. }
  1097. &__actions {
  1098. flex: 0 0 auto;
  1099. }
  1100. &__filter-bar {
  1101. flex: 0 0 auto;
  1102. flex-wrap: wrap;
  1103. margin-top: 12px;
  1104. padding: 10px 12px;
  1105. background: #ffffff;
  1106. border: 1px solid #e5e7eb;
  1107. border-radius: 8px;
  1108. }
  1109. &__body {
  1110. display: grid;
  1111. grid-template-columns: 280px minmax(420px, 1fr);
  1112. flex: 1 1 auto;
  1113. gap: 12px;
  1114. min-height: 0;
  1115. margin-top: 12px;
  1116. }
  1117. &__summary,
  1118. &__stage {
  1119. min-height: 0;
  1120. background: #ffffff;
  1121. border: 1px solid #e5e7eb;
  1122. border-radius: 8px;
  1123. }
  1124. &__summary {
  1125. display: flex;
  1126. flex-direction: column;
  1127. padding: 14px;
  1128. overflow: auto;
  1129. }
  1130. &__stage {
  1131. position: relative;
  1132. overflow: hidden;
  1133. }
  1134. &__graph {
  1135. width: 100%;
  1136. height: 100%;
  1137. }
  1138. &__loading {
  1139. position: absolute;
  1140. inset: 0;
  1141. z-index: 5;
  1142. display: flex;
  1143. align-items: center;
  1144. justify-content: center;
  1145. background: rgba(248, 250, 252, 0.68);
  1146. backdrop-filter: blur(2px);
  1147. }
  1148. &__loading-card {
  1149. min-width: 180px;
  1150. padding: 18px 20px;
  1151. text-align: center;
  1152. background: #ffffff;
  1153. border: 1px solid #e5e7eb;
  1154. border-radius: 10px;
  1155. box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
  1156. }
  1157. &__loading-spinner {
  1158. width: 28px;
  1159. height: 28px;
  1160. margin: 0 auto 10px;
  1161. border: 3px solid #dbeafe;
  1162. border-top-color: #2563eb;
  1163. border-radius: 50%;
  1164. animation: relation-graph-test-spin 0.8s linear infinite;
  1165. }
  1166. &__loading-title {
  1167. color: #111827;
  1168. font-size: 14px;
  1169. font-weight: 600;
  1170. line-height: 22px;
  1171. }
  1172. &__loading-desc {
  1173. margin-top: 2px;
  1174. color: #6b7280;
  1175. font-size: 12px;
  1176. line-height: 20px;
  1177. }
  1178. &__empty {
  1179. position: absolute;
  1180. inset: 0;
  1181. z-index: 2;
  1182. display: flex;
  1183. flex-direction: column;
  1184. align-items: center;
  1185. justify-content: center;
  1186. pointer-events: none;
  1187. color: #64748b;
  1188. background: rgba(248, 250, 252, 0.72);
  1189. }
  1190. &__empty-title {
  1191. color: #111827;
  1192. font-size: 16px;
  1193. font-weight: 600;
  1194. line-height: 24px;
  1195. }
  1196. &__empty-desc {
  1197. margin-top: 6px;
  1198. color: #64748b;
  1199. font-size: 13px;
  1200. line-height: 22px;
  1201. }
  1202. .filter-item {
  1203. display: flex;
  1204. align-items: center;
  1205. gap: 6px;
  1206. &__label {
  1207. color: #6b7280;
  1208. font-size: 12px;
  1209. white-space: nowrap;
  1210. }
  1211. &__control {
  1212. width: 132px;
  1213. }
  1214. &__number {
  1215. width: 92px;
  1216. }
  1217. &--switch {
  1218. padding-left: 6px;
  1219. }
  1220. }
  1221. .panel-title {
  1222. flex: 0 0 auto;
  1223. margin-bottom: 12px;
  1224. color: #111827;
  1225. font-size: 14px;
  1226. font-weight: 600;
  1227. line-height: 22px;
  1228. &--sub {
  1229. margin-top: 16px;
  1230. }
  1231. }
  1232. .summary-grid {
  1233. display: grid;
  1234. grid-template-columns: repeat(2, minmax(0, 1fr));
  1235. gap: 8px;
  1236. &__item {
  1237. padding: 10px;
  1238. background: #f8fafc;
  1239. border: 1px solid #e2e8f0;
  1240. border-radius: 8px;
  1241. }
  1242. &__label {
  1243. display: block;
  1244. color: #6b7280;
  1245. font-size: 12px;
  1246. line-height: 18px;
  1247. }
  1248. &__value {
  1249. display: block;
  1250. margin-top: 4px;
  1251. color: #111827;
  1252. font-size: 16px;
  1253. font-weight: 600;
  1254. line-height: 24px;
  1255. }
  1256. }
  1257. .entity-kind-list {
  1258. display: flex;
  1259. flex-direction: column;
  1260. gap: 8px;
  1261. &__item {
  1262. display: flex;
  1263. align-items: center;
  1264. gap: 8px;
  1265. color: #374151;
  1266. font-size: 13px;
  1267. }
  1268. &__dot {
  1269. width: 9px;
  1270. height: 9px;
  1271. border-radius: 50%;
  1272. }
  1273. &__label {
  1274. flex: 1 1 auto;
  1275. min-width: 0;
  1276. }
  1277. }
  1278. .active-node-card {
  1279. padding: 12px;
  1280. background: #f8fafc;
  1281. border: 1px solid #e2e8f0;
  1282. border-radius: 8px;
  1283. &__name {
  1284. margin-bottom: 10px;
  1285. color: #111827;
  1286. font-size: 14px;
  1287. font-weight: 600;
  1288. line-height: 22px;
  1289. }
  1290. &__row {
  1291. display: flex;
  1292. justify-content: space-between;
  1293. gap: 12px;
  1294. color: #4b5563;
  1295. font-size: 12px;
  1296. line-height: 22px;
  1297. span:last-child {
  1298. color: #111827;
  1299. text-align: right;
  1300. word-break: break-all;
  1301. }
  1302. }
  1303. }
  1304. .relation-node {
  1305. display: flex;
  1306. flex-direction: column;
  1307. align-items: center;
  1308. justify-content: center;
  1309. width: 64px;
  1310. height: 64px;
  1311. padding: 8px;
  1312. box-sizing: border-box;
  1313. background: var(--node-accent-color);
  1314. border: 1px solid rgba(15, 23, 42, 0.08);
  1315. border-radius: 50%;
  1316. box-shadow: 0 6px 14px rgba(15, 23, 42, 0.12);
  1317. user-select: none;
  1318. }
  1319. .relation-node__content {
  1320. display: flex;
  1321. flex-direction: column;
  1322. align-items: center;
  1323. width: 100%;
  1324. min-width: 0;
  1325. gap: 2px;
  1326. text-align: center;
  1327. }
  1328. .relation-node__label,
  1329. .relation-node__secondary {
  1330. width: 100%;
  1331. overflow: hidden;
  1332. text-overflow: ellipsis;
  1333. white-space: nowrap;
  1334. }
  1335. .relation-node__label {
  1336. color: var(--node-text-color);
  1337. font-size: 12px;
  1338. font-weight: 600;
  1339. line-height: 18px;
  1340. }
  1341. .relation-node__secondary {
  1342. color: var(--node-text-color);
  1343. font-size: 11px;
  1344. line-height: 16px;
  1345. opacity: 0.82;
  1346. }
  1347. .relation-node--lite {
  1348. width: 44px;
  1349. height: 44px;
  1350. padding: 4px;
  1351. box-shadow: none;
  1352. }
  1353. .relation-node--large {
  1354. width: 24px;
  1355. height: 24px;
  1356. padding: 0;
  1357. border: 0;
  1358. box-shadow: none;
  1359. }
  1360. .rel-node-shape-1 {
  1361. box-shadow: none;
  1362. }
  1363. }
  1364. @keyframes relation-graph-test-spin {
  1365. to {
  1366. transform: rotate(360deg);
  1367. }
  1368. }
  1369. </style>