| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531 |
- <template>
- <div class="relation-graph-test-page">
- <div class="relation-graph-test-page__header">
- <div class="relation-graph-test-page__title-wrap">
- <div class="relation-graph-test-page__title">Relation Graph 图谱性能测试</div>
- <div class="relation-graph-test-page__subtitle"
- >使用对象关系分析接口和节点样式验证 Relation Graph 大图性能。</div
- >
- </div>
- <div class="relation-graph-test-page__meta">
- <span>节点 {{ graphStats.nodes }}</span>
- <span>关系 {{ graphStats.lines }}</span>
- <span>层级 {{ graphStats.depth }}</span>
- <span>生成 {{ lastGenerateCost }}ms</span>
- <span>渲染 {{ lastRenderCost }}ms</span>
- </div>
- <div class="relation-graph-test-page__actions">
- <Button @click="handleRelayout">重新布局</Button>
- <Button @click="handleFitView">适配画布</Button>
- <Button @click="handleGenerate">生成模拟数据</Button>
- <Button type="primary" :loading="analysisLoading" @click="handleOpenAnalysisCondition">设置分析条件</Button>
- </div>
- </div>
- <div class="relation-graph-test-page__filter-bar">
- <div class="filter-item">
- <span class="filter-item__label">规模</span>
- <Select
- v-model:value="targetNodeCount"
- class="filter-item__control"
- :options="nodeCountOptions"
- @change="handleGenerate"
- />
- </div>
- <div class="filter-item">
- <span class="filter-item__label">分支</span>
- <InputNumber
- v-model:value="branchFactor"
- class="filter-item__number"
- :min="2"
- :max="30"
- :precision="0"
- @pressEnter="handleGenerate"
- />
- </div>
- <div class="filter-item">
- <span class="filter-item__label">深度</span>
- <InputNumber
- v-model:value="maxDepth"
- class="filter-item__number"
- :min="2"
- :max="12"
- :precision="0"
- @pressEnter="handleGenerate"
- />
- </div>
- <div class="filter-item">
- <span class="filter-item__label">层级距离</span>
- <InputNumber
- v-model:value="layoutLevelGap"
- class="filter-item__number"
- :min="80"
- :max="320"
- :precision="0"
- @change="handleLayoutControlChange"
- @pressEnter="handleLayoutControlChange"
- />
- </div>
- <div class="filter-item">
- <span class="filter-item__label">节点距离</span>
- <InputNumber
- v-model:value="layoutNodeDistance"
- class="filter-item__number"
- :min="24"
- :max="220"
- :precision="0"
- @change="handleLayoutControlChange"
- @pressEnter="handleLayoutControlChange"
- />
- </div>
- <div class="filter-item">
- <span class="filter-item__label">扩散系数</span>
- <InputNumber
- v-model:value="layoutSpreadCoefficient"
- class="filter-item__number"
- :min="0.6"
- :max="2"
- :precision="1"
- :step="0.1"
- @change="handleLayoutControlChange"
- @pressEnter="handleLayoutControlChange"
- />
- </div>
- <div class="filter-item filter-item--switch">
- <span class="filter-item__label">线文本</span>
- <Switch v-model:checked="showLineText" @change="handleGenerate" />
- </div>
- <div class="filter-item filter-item--switch">
- <span class="filter-item__label">工具栏</span>
- <Switch v-model:checked="showGraphToolbar" />
- </div>
- </div>
- <div class="relation-graph-test-page__body">
- <aside class="relation-graph-test-page__summary">
- <div class="panel-title">数据概览</div>
- <div class="summary-grid">
- <div class="summary-grid__item">
- <span class="summary-grid__label">数据来源</span>
- <span class="summary-grid__value">{{ dataSourceLabel }}</span>
- </div>
- <div class="summary-grid__item">
- <span class="summary-grid__label">叶子节点</span>
- <span class="summary-grid__value">{{ graphStats.leafs }}</span>
- </div>
- <div class="summary-grid__item">
- <span class="summary-grid__label">线文本</span>
- <span class="summary-grid__value">{{ showLineText ? '开启' : '关闭' }}</span>
- </div>
- <div class="summary-grid__item">
- <span class="summary-grid__label">布局</span>
- <span class="summary-grid__value">关系图谱</span>
- </div>
- </div>
- <div class="panel-title panel-title--sub">实体类型</div>
- <div class="entity-kind-list">
- <div v-for="item in entityKinds" :key="item.key" class="entity-kind-list__item">
- <span class="entity-kind-list__dot" :style="{ backgroundColor: item.color }"></span>
- <span class="entity-kind-list__label">{{ item.label }}</span>
- <Tag>{{ item.count }}</Tag>
- </div>
- </div>
- <div class="panel-title panel-title--sub">当前选择</div>
- <div v-if="activeNode" class="active-node-card">
- <div class="active-node-card__name">{{ activeNode.text }}</div>
- <div class="active-node-card__row">
- <span>类型</span>
- <span>{{ activeNode.typeLabel }}</span>
- </div>
- <div class="active-node-card__row">
- <span>层级</span>
- <span>{{ activeNode.level }}</span>
- </div>
- <div class="active-node-card__row">
- <span>ID</span>
- <span>{{ activeNode.id }}</span>
- </div>
- </div>
- <Empty v-else :image="Empty.PRESENTED_IMAGE_SIMPLE" description="未选择节点" />
- </aside>
- <main class="relation-graph-test-page__stage">
- <RelationGraph
- class="relation-graph-test-page__graph"
- :options="graphOptions"
- @on-ready="handleGraphReady"
- @on-node-click="handleNodeClick"
- @on-line-click="handleLineClick"
- >
- <template #node="{ node }">
- <div
- class="relation-node"
- :class="{
- 'relation-node--lite': graphStats.nodes >= LITE_GRAPH_NODE_THRESHOLD,
- 'relation-node--large': graphStats.nodes >= LARGE_GRAPH_NODE_THRESHOLD,
- }"
- :style="{
- '--node-accent-color': getRelationNodeColor(node),
- '--node-text-color': getRelationNodeTextColor(node),
- }"
- :title="getRelationNodeData(node).fullLabel || node.text || node.id"
- >
- <div v-if="graphStats.nodes < LARGE_GRAPH_NODE_THRESHOLD" class="relation-node__content">
- <span class="relation-node__label">{{ getRelationNodeData(node).label || node.text }}</span>
- <span
- v-if="graphStats.nodes < LITE_GRAPH_NODE_THRESHOLD && getRelationNodeData(node).secondaryLabel"
- class="relation-node__secondary"
- >
- {{ getRelationNodeData(node).secondaryLabel }}
- </span>
- </div>
- </div>
- </template>
- </RelationGraph>
- <div v-if="graphLoading" class="relation-graph-test-page__loading">
- <div class="relation-graph-test-page__loading-card">
- <div class="relation-graph-test-page__loading-spinner"></div>
- <div class="relation-graph-test-page__loading-title">布局渲染中</div>
- <div class="relation-graph-test-page__loading-desc"
- >当前 {{ graphStats.nodes || targetNodeCount }} 个节点。</div
- >
- </div>
- </div>
- <div v-else-if="!hasRenderedGraph" class="relation-graph-test-page__empty">
- <div class="relation-graph-test-page__empty-title">请选择数据来源</div>
- <div class="relation-graph-test-page__empty-desc"
- >点击“设置分析条件”加载接口数据,或点击“生成模拟数据”压测 2w 节点。</div
- >
- </div>
- </main>
- </div>
- <AnalysisConditionModal
- v-model:open="analysisConditionVisible"
- :loading="analysisLoading"
- :respondent-options="allRespondentOptions"
- :default-selected-ids="routeGraphRespondents.length ? routeGraphDefaultSelectedIds : undefined"
- @start="handleStartAnalysis"
- />
- </div>
- </template>
- <script lang="ts" setup name="ViewsGraphRelationGraphTest">
- import { computed, nextTick, onBeforeUnmount, reactive, ref, shallowRef } from 'vue';
- import { useRoute } from 'vue-router';
- import { Button, Empty, InputNumber, Select, Switch, Tag, message } from 'ant-design-vue';
- import RelationGraph, {
- RGLineShape,
- RGNodeShape,
- type JsonLine,
- type JsonNode,
- type RGJsonData,
- type RGLine,
- type RGLink,
- type RGNode,
- type RGOptions,
- type RGUserEvent,
- type RelationGraphInstance,
- } from '@relation-graph/vue';
- import '@relation-graph/vue/style.css';
- import type {
- AnalysisConditionPayload,
- AnalysisRespondent,
- GraphAnalysisEdge,
- GraphAnalysisNode,
- GraphAnalysisResponse,
- } from '../../api/analysis';
- import { buildGraphViewQuery, fetchGraphAnalysis, resolveGraphRespondentsFromRouteQuery } from '../../api/analysis';
- import { getPersons } from '@jeesite/person/api/personApi';
- import AnalysisConditionModal from '../graph/components/AnalysisConditionModal.vue';
- type EntityKindKey = 'person' | 'group' | 'house' | 'car' | 'bank' | 'phone' | 'unknown' | 'other';
- interface EntityKind {
- key: EntityKindKey;
- label: string;
- relation: string;
- color: string;
- borderColor: string;
- count: number;
- }
- interface ActiveNode {
- id: string;
- text: string;
- typeLabel: string;
- level: number;
- }
- interface GeneratedGraph {
- data: RGJsonData;
- rootId: string;
- depth: number;
- leafs: number;
- kindCounts: Record<EntityKindKey, number>;
- }
- interface RelationGraphNodeData {
- entityKind: EntityKindKey;
- typeLabel: string;
- label: string;
- secondaryLabel?: string;
- fullLabel?: string;
- level: number;
- rawData?: GraphAnalysisNode | null;
- }
- interface RenderableAnalysisNode {
- key: string;
- kind: EntityKindKey;
- label: string;
- secondaryLabel: string;
- fullLabel: string;
- rawData: GraphAnalysisNode | null;
- }
- interface RenderableAnalysisEdge {
- id: string;
- sourceKey: string;
- targetKey: string;
- label: string;
- rawData: GraphAnalysisEdge;
- }
- interface AnalysisNodeRegistry {
- nodeMap: Map<string, RenderableAnalysisNode>;
- exactTokenToKey: Map<string, string>;
- }
- const ROOT_ID = 'object-root';
- const route = useRoute();
- const graphInstance = shallowRef<RelationGraphInstance | null>(null);
- const activeNode = ref<ActiveNode | null>(null);
- const targetNodeCount = ref(20000);
- const branchFactor = ref(8);
- const maxDepth = ref(5);
- const layoutLevelGap = ref(150);
- const layoutNodeDistance = ref(80);
- const layoutSpreadCoefficient = ref(1.1);
- const showLineText = ref(true);
- const showGraphToolbar = ref(true);
- const graphLoading = ref(false);
- const analysisLoading = ref(false);
- const analysisConditionVisible = ref(false);
- const currentRootId = ref(ROOT_ID);
- const dataSource = ref<'mock' | 'analysis'>('mock');
- const hasRenderedGraph = ref(false);
- const lastGenerateCost = ref(0);
- const lastRenderCost = ref(0);
- const loadedRespondentOptions = ref<AnalysisRespondent[]>([]);
- let layoutRelayoutTimer: ReturnType<typeof setTimeout> | null = null;
- const routeGraphQuery = computed(() => buildGraphViewQuery(route.query));
- const routeGraphRespondents = computed(() => resolveGraphRespondentsFromRouteQuery(route.query));
- const routeGraphDefaultSelectedIds = computed(() => routeGraphRespondents.value.map((item) => item.id));
- const dataSourceLabel = computed(() =>
- dataSource.value === 'analysis' ? '分析接口' : `模拟${targetNodeCount.value}`,
- );
- const allRespondentOptions = computed(() => {
- const loaded = loadedRespondentOptions.value;
- const routeRespondents = routeGraphRespondents.value;
- if (!loaded.length) return routeRespondents;
- if (!routeRespondents.length) return loaded;
- const seenKeys = new Set(loaded.map((item) => item.id));
- const merged = [...loaded];
- routeRespondents.forEach((item) => {
- if (!seenKeys.has(item.id)) {
- seenKeys.add(item.id);
- merged.push(item);
- }
- });
- return merged;
- });
- const graphStats = reactive({
- nodes: 0,
- lines: 0,
- depth: 0,
- leafs: 0,
- });
- const entityKinds = reactive<EntityKind[]>([
- {
- key: 'person',
- label: '人物',
- relation: '关联人物',
- color: '#0f4c8a',
- borderColor: '#0b3d6f',
- count: 0,
- },
- {
- key: 'group',
- label: '机构',
- relation: '关联机构',
- color: '#0f9f68',
- borderColor: '#0b8055',
- count: 0,
- },
- {
- key: 'house',
- label: '房产',
- relation: '关联房产',
- color: '#b92a31',
- borderColor: '#991f25',
- count: 0,
- },
- {
- key: 'car',
- label: '车辆',
- relation: '关联车辆',
- color: '#f29a1f',
- borderColor: '#c9780a',
- count: 0,
- },
- {
- key: 'bank',
- label: '银行卡',
- relation: '资金账户',
- color: '#8441ae',
- borderColor: '#6b328f',
- count: 0,
- },
- {
- key: 'phone',
- label: '手机',
- relation: '通信号码',
- color: '#0aa0b4',
- borderColor: '#087f8f',
- count: 0,
- },
- {
- key: 'unknown',
- label: '未知',
- relation: '未知关系',
- color: '#434c55',
- borderColor: '#303840',
- count: 0,
- },
- {
- key: 'other',
- label: '其他',
- relation: '其他关系',
- color: '#d4ac2b',
- borderColor: '#a98718',
- count: 0,
- },
- ]);
- const entityKindMap = entityKinds.reduce<Record<string, EntityKind>>((accumulator, item) => {
- accumulator[item.key] = item;
- return accumulator;
- }, {});
- const nodeCountOptions = [
- { label: '500 节点', value: 500 },
- { label: '2000 节点', value: 2000 },
- { label: '5000 节点', value: 5000 },
- { label: '10000 节点', value: 10000 },
- { label: '20000 节点', value: 20000 },
- ];
- const LITE_GRAPH_NODE_THRESHOLD = 1000;
- const LARGE_GRAPH_NODE_THRESHOLD = 5000;
- const NORMAL_NODE_SIZE = 64;
- const LITE_NODE_SIZE = 44;
- const LARGE_NODE_SIZE = 24;
- const getNodeRenderSize = (nodeCount: number) => {
- if (nodeCount >= LARGE_GRAPH_NODE_THRESHOLD) return LARGE_NODE_SIZE;
- if (nodeCount >= LITE_GRAPH_NODE_THRESHOLD) return LITE_NODE_SIZE;
- return NORMAL_NODE_SIZE;
- };
- const graphOptions = computed<RGOptions>(() => {
- const nodeCount = graphStats.nodes || targetNodeCount.value;
- const largeGraph = nodeCount >= 1000;
- const veryLargeGraph = nodeCount >= LARGE_GRAPH_NODE_THRESHOLD;
- const nodeSize = getNodeRenderSize(nodeCount);
- const baseLevelGap = Math.max(80, Number(layoutLevelGap.value) || 150);
- const nodeDistance = Math.max(24, Number(layoutNodeDistance.value) || 80);
- const nodeDistanceCoefficient = nodeDistance / 80;
- const spreadCoefficient = Number(layoutSpreadCoefficient.value) || 1.1;
- const effectiveLevelGap = Math.round(baseLevelGap * nodeDistanceCoefficient * spreadCoefficient);
- return {
- debug: false,
- showToolBar: showGraphToolbar.value,
- performanceMode: true,
- backgroundColor: '#f8fafc',
- defaultNodeShape: RGNodeShape.circle,
- defaultNodeWidth: nodeSize,
- defaultNodeHeight: nodeSize,
- defaultNodeColor: '#ffffff',
- defaultNodeBorderColor: '#d6dee9',
- defaultNodeBorderWidth: veryLargeGraph ? 0 : 1,
- defaultNodeBorderRadius: nodeSize / 2,
- defaultLineShape: veryLargeGraph ? RGLineShape.StandardCurve : RGLineShape.StandardStraight,
- defaultLineColor: '#94a3b8',
- defaultLineWidth: 1,
- defaultLineTextOffsetY: -8,
- defaultLineTextOnPath: showLineText.value,
- lineTextMaxLength: 14,
- dragEventAction: 'move',
- wheelEventAction: 'zoom',
- defaultExpandHolderPosition: 'right',
- layout: {
- layoutName: 'center',
- distanceCoefficient: spreadCoefficient * nodeDistanceCoefficient,
- levelGaps: [
- effectiveLevelGap,
- Math.round(effectiveLevelGap * 1.12),
- Math.round(effectiveLevelGap * 1.22),
- Math.round(effectiveLevelGap * 1.32),
- Math.round(effectiveLevelGap * 1.42),
- ],
- disableAsForceLayout: largeGraph,
- maxLayoutTimes: largeGraph ? 0 : 120,
- force_node_repulsion: Math.min(2.4, Math.max(0.45, nodeDistanceCoefficient * 0.85)),
- force_line_elastic: 1.08,
- },
- };
- });
- const ensureEntityKind = (kind?: string | null): EntityKindKey => {
- const nextKind = (kind || '') as EntityKindKey;
- return entityKindMap[nextKind] ? nextKind : 'unknown';
- };
- const getEntityKind = (index: number, level: number) => entityKinds[(index + level) % entityKinds.length];
- const normalizeLookupKey = (value?: string | null) => (value || '').trim().toUpperCase();
- const uniqueValues = <T,>(items: T[]) => Array.from(new Set(items));
- const cloneSerializable = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
- const getEntityKindByClass = (value?: string | null): EntityKindKey | null => {
- const normalized = (value || '').trim().toLowerCase();
- if (!normalized) return null;
- if (normalized.includes('sfz')) return 'person';
- if (normalized.includes('tel')) return 'phone';
- if (normalized.includes('card')) return 'bank';
- if (normalized.includes('fc') || normalized.includes('house')) return 'house';
- if (normalized.includes('car')) return 'car';
- if (normalized.includes('ins') || normalized.includes('org')) return 'group';
- return null;
- };
- const getEntityKindByClazzPositionToken = (value?: string | null): EntityKindKey | null => {
- const normalized = (value || '').trim().toLowerCase();
- if (!normalized) return null;
- if (normalized === 'obj') return 'person';
- if (normalized === 'org' || normalized === 'ins') return 'group';
- return getEntityKindByClass(normalized);
- };
- const getEdgeEndpointKindsByClazz = (clazz?: string | null) => {
- const tokens = String(clazz || '')
- .trim()
- .toLowerCase()
- .split(/[^a-z0-9]+/)
- .filter(Boolean);
- return {
- sourceKind: tokens.length ? getEntityKindByClazzPositionToken(tokens[0]) : null,
- targetKind: tokens.length ? getEntityKindByClazzPositionToken(tokens[tokens.length - 1]) : null,
- };
- };
- const inferEntityKindFromToken = (value?: string | null): EntityKindKey => {
- const token = (value || '').trim();
- if (!token) return 'unknown';
- if (/公司|超市|商店|经营部|企业|集团|合作社|银行/.test(token)) return 'group';
- if (/^[\u4e00-\u9fa5][A-Z0-9]{5,}$/i.test(token)) return 'car';
- if (/^\d{1,6}$/.test(token)) return 'house';
- const digitOnly = token.replace(/\D/g, '');
- if (/^\d{17}[0-9X*]$/i.test(token) || token.includes('****')) return 'person';
- if (digitOnly.length >= 7 && digitOnly.length <= 11) return 'phone';
- if (digitOnly.length >= 12) return 'bank';
- return 'other';
- };
- const getAnalysisClassByKind = (kind: EntityKindKey) => {
- switch (kind) {
- case 'person':
- return 'sfz';
- case 'phone':
- return 'tel';
- case 'bank':
- return 'card';
- case 'house':
- return 'dz_fc';
- case 'car':
- return 'car';
- case 'group':
- return 'obj';
- case 'other':
- return 'other';
- default:
- return 'x';
- }
- };
- const getAnalysisNodeFullLabel = (node: GraphAnalysisNode, kind: EntityKindKey) => {
- const name = (node.mc || '').trim();
- const nodeCn = (node.nodecn || '').trim();
- const code = (node.bm || node.sfz || '').trim();
- if (kind === 'person' || kind === 'group') return name || nodeCn || code || '未命名实体';
- if (kind === 'bank' || kind === 'phone' || kind === 'car' || kind === 'house') {
- return code || name || '未命名实体';
- }
- return name || nodeCn || code || '未命名实体';
- };
- const getAnalysisNodeSecondaryLabel = (node: GraphAnalysisNode, kind: EntityKindKey) => {
- const nodeCn = (node.nodecn || '').trim();
- const code = (node.bm || node.sfz || '').trim();
- const fullLabel = getAnalysisNodeFullLabel(node, kind);
- if (!nodeCn || nodeCn === code || nodeCn === fullLabel) return '';
- return nodeCn;
- };
- const normalizeAnalysisEdgeClazz = (clazz?: string | null) =>
- String(clazz || '')
- .trim()
- .toLowerCase();
- const isCommunicationAnalysisEdgeClazz = (clazz?: string | null) => normalizeAnalysisEdgeClazz(clazz) === 'tel_tel';
- const shouldShowAnalysisEdgeCount = (clazz?: string | null) => {
- const normalizedClazz = normalizeAnalysisEdgeClazz(clazz);
- return normalizedClazz === 'card_card' || normalizedClazz === 'tel_tel';
- };
- const formatAmountLabel = (value: number) => value.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
- const getAnalysisEdgeCountText = (edge: {
- clazz?: string | null;
- nums?: string | number | null;
- money?: number | null;
- }) => {
- if (!shouldShowAnalysisEdgeCount(edge.clazz)) return '';
- const countValue =
- edge.nums !== null && edge.nums !== undefined && edge.nums !== ''
- ? edge.nums
- : isCommunicationAnalysisEdgeClazz(edge.clazz) && typeof edge.money === 'number'
- ? edge.money
- : undefined;
- return countValue !== undefined ? `${String(countValue).replace(/\.0$/, '')}次` : '';
- };
- const buildAnalysisEdgeLabel = (edge: GraphAnalysisEdge) => {
- const baseLabel = (edge.mc || edge.sm || '').trim();
- const relationText = [baseLabel, edge.clazz, edge.sm, edge.mc]
- .map((item) => String(item || '').trim())
- .filter(Boolean)
- .join(' ');
- const shouldHideAmount =
- relationText.includes('持卡人') ||
- relationText.includes('机主') ||
- relationText.includes('开户人') ||
- relationText.includes('户主');
- const countLabel = getAnalysisEdgeCountText(edge);
- const amountLabel =
- !shouldHideAmount && !isCommunicationAnalysisEdgeClazz(edge.clazz) && typeof edge.money === 'number'
- ? `¥${formatAmountLabel(edge.money)}`
- : '';
- return [baseLabel, amountLabel || countLabel].filter(Boolean).join(' ');
- };
- const createVirtualAnalysisNode = (token: string, kind: EntityKindKey): GraphAnalysisNode => ({
- id: normalizeLookupKey(token) || token,
- bm: token.trim(),
- mc: token.trim(),
- mc1: token.trim(),
- class: getAnalysisClassByKind(kind),
- });
- const createNodeData = (
- nodeId: string,
- kind: EntityKindKey,
- label: string,
- options: {
- secondaryLabel?: string;
- fullLabel?: string;
- level?: number;
- rawData?: GraphAnalysisNode | null;
- } = {},
- ): JsonNode => {
- const entity = entityKindMap[kind] || entityKindMap.unknown;
- return {
- id: nodeId,
- text: label,
- type: kind,
- nodeShape: RGNodeShape.circle,
- color: entity.color,
- borderColor: entity.borderColor,
- fontColor: kind === 'car' || kind === 'other' ? '#111827' : '#ffffff',
- data: {
- entityKind: kind,
- typeLabel: entity.label,
- label,
- secondaryLabel: options.secondaryLabel || '',
- fullLabel: options.fullLabel || label,
- level: options.level || 0,
- rawData: options.rawData || null,
- } satisfies RelationGraphNodeData,
- };
- };
- const createNodeText = (kind: EntityKind, index: number) => {
- const serial = String(index).padStart(5, '0');
- const nameMap: Record<EntityKindKey, string> = {
- person: `对象-${serial}`,
- phone: `13****${serial.slice(-4)}`,
- bank: `卡号-${serial}`,
- car: `车辆-${serial}`,
- house: `房产-${serial}`,
- group: `机构-${serial}`,
- unknown: `未知-${serial}`,
- other: `其他-${serial}`,
- };
- return nameMap[kind.key];
- };
- const resetKindCounts = () => {
- entityKinds.forEach((item) => {
- item.count = 0;
- });
- };
- const createEmptyKindCounts = () =>
- entityKinds.reduce(
- (counts, item) => ({
- ...counts,
- [item.key]: 0,
- }),
- {} as Record<EntityKindKey, number>,
- );
- const syncKindCounts = (kindCounts: Record<EntityKindKey, number>) => {
- entityKinds.forEach((item) => {
- item.count = kindCounts[item.key] || 0;
- });
- };
- const getRelationNodeData = (node: RGNode): RelationGraphNodeData => (node.data || {}) as RelationGraphNodeData;
- const getRelationNodeKind = (node: RGNode) => ensureEntityKind(getRelationNodeData(node).entityKind || node.type);
- const getRelationNodeColor = (node: RGNode) =>
- entityKindMap[getRelationNodeKind(node)]?.color || entityKindMap.unknown.color;
- const getRelationNodeTextColor = (node: RGNode) => {
- const kind = getRelationNodeKind(node);
- return kind === 'car' || kind === 'other' ? '#111827' : '#ffffff';
- };
- const registerAnalysisNode = (
- registry: AnalysisNodeRegistry,
- key: string,
- kind: EntityKindKey,
- fullLabel: string,
- exactTokens: string[],
- rawData?: GraphAnalysisNode | null,
- ) => {
- if (!key) return;
- const currentNode = registry.nodeMap.get(key);
- if (!currentNode) {
- registry.nodeMap.set(key, {
- key,
- kind,
- label: fullLabel,
- secondaryLabel: rawData ? getAnalysisNodeSecondaryLabel(rawData, kind) : '',
- fullLabel,
- rawData: rawData || null,
- });
- } else if (rawData && !currentNode.rawData) {
- currentNode.rawData = rawData;
- }
- exactTokens.forEach((token) => {
- if (token && !registry.exactTokenToKey.has(token)) {
- registry.exactTokenToKey.set(token, key);
- }
- });
- };
- const resolveRegisteredAnalysisNodeKey = (registry: AnalysisNodeRegistry, value?: string | null) => {
- const token = normalizeLookupKey(value);
- if (!token) return '';
- return registry.exactTokenToKey.get(token) || '';
- };
- const registerAnalysisResponseNodes = (registry: AnalysisNodeRegistry, nodes: GraphAnalysisNode[]) => {
- nodes.forEach((rawNode) => {
- const primaryToken = rawNode.bm || rawNode.sfz || rawNode.mc || rawNode.id;
- const key = normalizeLookupKey(primaryToken);
- if (!key) return;
- const kind =
- getEntityKindByClazzPositionToken(rawNode.class) ||
- inferEntityKindFromToken(rawNode.mc || rawNode.bm || rawNode.sfz || rawNode.id);
- const exactTokens = uniqueValues(
- [rawNode.bm, rawNode.sfz, rawNode.id, rawNode.dxId, rawNode.dxId0, rawNode.fzId]
- .map((item) => normalizeLookupKey(item))
- .filter(Boolean),
- );
- registerAnalysisNode(registry, key, kind, getAnalysisNodeFullLabel(rawNode, kind), exactTokens, rawNode);
- });
- };
- const ensureAnalysisNodeFromEdge = (
- registry: AnalysisNodeRegistry,
- token: string,
- preferredKind?: EntityKindKey | null,
- ) => {
- const key = normalizeLookupKey(token);
- if (!key) return '';
- const existingKey = resolveRegisteredAnalysisNodeKey(registry, token);
- if (existingKey) return existingKey;
- const kind = preferredKind || inferEntityKindFromToken(token);
- registerAnalysisNode(registry, key, kind, token.trim(), [key], createVirtualAnalysisNode(token, kind));
- return key;
- };
- const resolveAnalysisEdgeEndpointKey = (
- registry: AnalysisNodeRegistry,
- token: string,
- expectedKind?: EntityKindKey | null,
- ) => resolveRegisteredAnalysisNodeKey(registry, token) || ensureAnalysisNodeFromEdge(registry, token, expectedKind);
- const buildRenderableAnalysisEdges = (rawEdges: GraphAnalysisEdge[], registry: AnalysisNodeRegistry) =>
- rawEdges
- .map((edge, index): RenderableAnalysisEdge | null => {
- const { sourceKind, targetKind } = getEdgeEndpointKindsByClazz(edge.clazz);
- const sourceKey = resolveAnalysisEdgeEndpointKey(registry, edge.dx1, sourceKind);
- const targetKey = resolveAnalysisEdgeEndpointKey(registry, edge.dx2, targetKind);
- if (!sourceKey || !targetKey || sourceKey === targetKey) return null;
- return {
- id: edge.id || `analysis-line-${index}`,
- sourceKey,
- targetKey,
- label: buildAnalysisEdgeLabel(edge),
- rawData: edge,
- };
- })
- .filter((item): item is RenderableAnalysisEdge => item !== null);
- const buildAnalysisLevelMap = (nodes: RenderableAnalysisNode[], edges: RenderableAnalysisEdge[]) => {
- const adjacency = new Map<string, string[]>();
- nodes.forEach((node) => adjacency.set(node.key, []));
- edges.forEach((edge) => {
- adjacency.get(edge.sourceKey)?.push(edge.targetKey);
- adjacency.get(edge.targetKey)?.push(edge.sourceKey);
- });
- const seedKey = nodes.find((node) => node.kind === 'person')?.key || nodes[0]?.key || '';
- const levelMap = new Map<string, number>();
- const queue = seedKey ? [seedKey] : [];
- if (seedKey) levelMap.set(seedKey, 0);
- while (queue.length) {
- const currentKey = queue.shift() || '';
- const currentLevel = levelMap.get(currentKey) ?? 0;
- (adjacency.get(currentKey) || []).forEach((nextKey) => {
- if (levelMap.has(nextKey)) return;
- levelMap.set(nextKey, currentLevel + 1);
- queue.push(nextKey);
- });
- }
- nodes.forEach((node) => {
- if (!levelMap.has(node.key)) levelMap.set(node.key, 99);
- });
- return { rootId: seedKey || ROOT_ID, levelMap };
- };
- const buildAnalysisGraphData = (
- payload: AnalysisConditionPayload,
- response: GraphAnalysisResponse,
- ): GeneratedGraph => {
- const registry: AnalysisNodeRegistry = {
- nodeMap: new Map<string, RenderableAnalysisNode>(),
- exactTokenToKey: new Map<string, string>(),
- };
- const graphData = (response as any).data ?? response;
- const rawNodes: GraphAnalysisNode[] = Array.isArray(graphData.dx) ? graphData.dx : [];
- const rawEdges: GraphAnalysisEdge[] = Array.isArray(graphData.gx) ? graphData.gx : [];
- registerAnalysisResponseNodes(registry, rawNodes);
- const renderableEdges = buildRenderableAnalysisEdges(rawEdges, registry);
- const renderableNodes = Array.from(registry.nodeMap.values());
- const { rootId, levelMap } = buildAnalysisLevelMap(renderableNodes, renderableEdges);
- const kindCounts = createEmptyKindCounts();
- const nodes = renderableNodes.map((node) => {
- const kind = ensureEntityKind(node.kind);
- kindCounts[kind] += 1;
- return createNodeData(node.key, kind, node.label, {
- secondaryLabel: node.secondaryLabel,
- fullLabel: node.fullLabel,
- level: levelMap.get(node.key) ?? 99,
- rawData: node.rawData,
- });
- });
- const visibleNodeKeys = new Set(nodes.map((node) => node.id));
- const veryLargeGraph = nodes.length >= LARGE_GRAPH_NODE_THRESHOLD;
- const lines: JsonLine[] = renderableEdges
- .filter((edge) => visibleNodeKeys.has(edge.sourceKey) && visibleNodeKeys.has(edge.targetKey))
- .map((edge) => ({
- id: edge.id,
- from: edge.sourceKey,
- to: edge.targetKey,
- text: showLineText.value ? edge.label : undefined,
- color: '#9aa8bd',
- fontColor: '#64748b',
- lineShape: veryLargeGraph ? RGLineShape.StandardCurve : RGLineShape.StandardStraight,
- useTextOnPath: showLineText.value,
- data: {
- rawData: edge.rawData,
- },
- }));
- const parentIds = new Set(lines.map((line) => line.from));
- const leafs = nodes.filter((node) => !parentIds.has(node.id)).length;
- const maxDepthValue = Math.max(0, ...Array.from(levelMap.values()).filter((level) => level < 99));
- const fallbackRootId =
- rootId ||
- payload.respondents
- .map((item) => resolveRegisteredAnalysisNodeKey(registry, item.objId || item.identityCode || item.name))
- .find(Boolean) ||
- nodes[0]?.id ||
- ROOT_ID;
- return {
- data: {
- rootId: fallbackRootId,
- nodes,
- lines,
- },
- rootId: fallbackRootId,
- depth: maxDepthValue,
- leafs,
- kindCounts,
- };
- };
- const createObjectRelationData = (): GeneratedGraph => {
- const total = Math.max(1, Number(targetNodeCount.value) || 1);
- const branch = Math.max(2, Number(branchFactor.value) || 2);
- const depthLimit = Math.max(2, Number(maxDepth.value) || 2);
- const kindCounts = createEmptyKindCounts();
- const nodes: JsonNode[] = [createNodeData(ROOT_ID, 'person', '重点对象')];
- const lines: JsonLine[] = [];
- const queue: Array<{ id: string; level: number; seed: number }> = [{ id: ROOT_ID, level: 0, seed: 1 }];
- const veryLargeGraph = total >= LARGE_GRAPH_NODE_THRESHOLD;
- let nodeIndex = 1;
- let maxActualDepth = 0;
- while (queue.length && nodeIndex < total) {
- const parent = queue.shift();
- if (!parent || parent.level >= depthLimit) {
- continue;
- }
- const nextLevel = parent.level + 1;
- const dynamicBranch = Math.max(1, branch - Math.floor(parent.level / 2));
- const remain = total - nodeIndex;
- const childCount = Math.min(dynamicBranch, remain);
- for (let i = 0; i < childCount && nodeIndex < total; i += 1) {
- const kind = getEntityKind(nodeIndex + parent.seed + i, nextLevel);
- const nodeId = `${kind.key}-${nodeIndex}`;
- kindCounts[kind.key] += 1;
- nodes.push(createNodeData(nodeId, kind.key, createNodeText(kind, nodeIndex), { level: nextLevel }));
- lines.push({
- id: `line-${parent.id}-${nodeId}`,
- from: parent.id,
- to: nodeId,
- text: showLineText.value ? kind.relation : undefined,
- color: '#9aa8bd',
- fontColor: '#64748b',
- lineShape: veryLargeGraph ? RGLineShape.StandardCurve : RGLineShape.StandardStraight,
- useTextOnPath: showLineText.value,
- });
- queue.push({ id: nodeId, level: nextLevel, seed: nodeIndex });
- maxActualDepth = Math.max(maxActualDepth, nextLevel);
- nodeIndex += 1;
- }
- }
- const parentIds = new Set(lines.map((line) => line.from));
- const leafs = nodes.filter((node) => !parentIds.has(node.id)).length;
- return {
- data: {
- rootId: ROOT_ID,
- nodes,
- lines,
- },
- rootId: ROOT_ID,
- depth: maxActualDepth,
- leafs,
- kindCounts,
- };
- };
- const handleFitView = () => {
- const graph = graphInstance.value;
- if (!graph) {
- return;
- }
- graph.moveToCenter();
- graph.zoomToFit();
- };
- const applyGraphData = async (graphData: GeneratedGraph) => {
- const graph = graphInstance.value;
- if (!graph) {
- return;
- }
- hasRenderedGraph.value = true;
- currentRootId.value = graphData.rootId;
- graphStats.nodes = graphData.data.nodes.length;
- graphStats.lines = graphData.data.lines.length;
- graphStats.depth = graphData.depth;
- graphStats.leafs = graphData.leafs;
- syncKindCounts(graphData.kindCounts);
- const renderStart = performance.now();
- graph.setOptions(graphOptions.value);
- await graph.setJsonData(graphData.data);
- lastRenderCost.value = Math.round(performance.now() - renderStart);
- requestAnimationFrame(() => {
- handleFitView();
- graphLoading.value = false;
- });
- };
- const renderGraph = async () => {
- graphLoading.value = true;
- activeNode.value = null;
- resetKindCounts();
- await nextTick();
- const generateStart = performance.now();
- const graphData = createObjectRelationData();
- lastGenerateCost.value = Math.round(performance.now() - generateStart);
- dataSource.value = 'mock';
- await applyGraphData(graphData);
- };
- const handleGenerate = () => {
- void renderGraph();
- };
- const handleRelayout = async () => {
- const graph = graphInstance.value;
- if (!graph) {
- return;
- }
- graphLoading.value = true;
- graph.setOptions(graphOptions.value);
- await graph.doLayout(currentRootId.value);
- requestAnimationFrame(() => {
- handleFitView();
- graphLoading.value = false;
- });
- };
- const handleLayoutControlChange = () => {
- if (!hasRenderedGraph.value) return;
- if (layoutRelayoutTimer) {
- clearTimeout(layoutRelayoutTimer);
- }
- layoutRelayoutTimer = setTimeout(() => {
- layoutRelayoutTimer = null;
- void handleRelayout();
- }, 250);
- };
- const loadAllRespondentOptions = async () => {
- if (loadedRespondentOptions.value.length) return;
- try {
- const list = await getPersons();
- const persons = Array.isArray(list) ? list : [];
- loadedRespondentOptions.value = persons
- .filter((item) => item.personName || item.id)
- .map((item) => ({
- id: String(item.objId ?? item.id ?? item.personName ?? ''),
- name: item.personName || String(item.id || ''),
- objId: String(item.objId ?? item.id ?? ''),
- identityCode: item.personCertNo || undefined,
- photograph: item.photograph || '',
- }));
- } catch {
- loadedRespondentOptions.value = [];
- }
- };
- const handleOpenAnalysisCondition = () => {
- analysisConditionVisible.value = true;
- void loadAllRespondentOptions();
- };
- const handleStartAnalysis = async (payload: AnalysisConditionPayload) => {
- if (analysisLoading.value) return;
- analysisLoading.value = true;
- graphLoading.value = true;
- activeNode.value = null;
- resetKindCounts();
- await nextTick();
- try {
- const generateStart = performance.now();
- const response = await fetchGraphAnalysis(payload, routeGraphQuery.value);
- const graphData = buildAnalysisGraphData(payload, response);
- lastGenerateCost.value = Math.round(performance.now() - generateStart);
- dataSource.value = 'analysis';
- analysisConditionVisible.value = false;
- await applyGraphData(graphData);
- message.success(`已加载分析图谱:${graphData.data.nodes.length} 个节点,${graphData.data.lines.length} 条关系`);
- } catch (error) {
- graphLoading.value = false;
- console.error('load relation graph analysis failed', error);
- message.error('加载对象关系分析失败');
- } finally {
- analysisLoading.value = false;
- }
- };
- const handleGraphReady = (graph: RelationGraphInstance) => {
- graphInstance.value = graph;
- };
- const handleNodeClick = (node: RGNode, _event: RGUserEvent) => {
- const nodeData = getRelationNodeData(node);
- activeNode.value = {
- id: node.id,
- text: nodeData.fullLabel || nodeData.label || node.text || node.id,
- typeLabel: String(nodeData.typeLabel || node.type || '-'),
- level: Number(nodeData.level || 0),
- };
- return true;
- };
- const handleLineClick = (line: RGLine, _link: RGLink, _event: RGUserEvent) => {
- if (line.text) {
- message.info(`关系:${line.text}`);
- }
- return true;
- };
- onBeforeUnmount(() => {
- if (layoutRelayoutTimer) {
- clearTimeout(layoutRelayoutTimer);
- layoutRelayoutTimer = null;
- }
- graphInstance.value?.beforeUnmount();
- graphInstance.value = null;
- });
- </script>
- <style lang="less">
- .relation-graph-test-page {
- display: flex;
- flex-direction: column;
- height: calc(100vh - 96px);
- min-height: 620px;
- padding: 16px;
- overflow: hidden;
- background: #f4f7fb;
- box-sizing: border-box;
- &__header {
- display: flex;
- flex: 0 0 auto;
- align-items: center;
- gap: 16px;
- padding: 14px 16px;
- background: #ffffff;
- border: 1px solid #e5e7eb;
- border-radius: 8px;
- }
- &__title-wrap {
- flex: 1 1 auto;
- min-width: 220px;
- }
- &__title {
- color: #111827;
- font-size: 18px;
- font-weight: 600;
- line-height: 26px;
- }
- &__subtitle {
- margin-top: 2px;
- color: #6b7280;
- font-size: 12px;
- line-height: 20px;
- }
- &__meta,
- &__actions,
- &__filter-bar {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- &__meta {
- flex: 0 0 auto;
- span {
- padding: 4px 8px;
- color: #475569;
- font-size: 12px;
- background: #f8fafc;
- border: 1px solid #e2e8f0;
- border-radius: 6px;
- }
- }
- &__actions {
- flex: 0 0 auto;
- }
- &__filter-bar {
- flex: 0 0 auto;
- flex-wrap: wrap;
- margin-top: 12px;
- padding: 10px 12px;
- background: #ffffff;
- border: 1px solid #e5e7eb;
- border-radius: 8px;
- }
- &__body {
- display: grid;
- grid-template-columns: 280px minmax(420px, 1fr);
- flex: 1 1 auto;
- gap: 12px;
- min-height: 0;
- margin-top: 12px;
- }
- &__summary,
- &__stage {
- min-height: 0;
- background: #ffffff;
- border: 1px solid #e5e7eb;
- border-radius: 8px;
- }
- &__summary {
- display: flex;
- flex-direction: column;
- padding: 14px;
- overflow: auto;
- }
- &__stage {
- position: relative;
- overflow: hidden;
- }
- &__graph {
- width: 100%;
- height: 100%;
- }
- &__loading {
- position: absolute;
- inset: 0;
- z-index: 5;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(248, 250, 252, 0.68);
- backdrop-filter: blur(2px);
- }
- &__loading-card {
- min-width: 180px;
- padding: 18px 20px;
- text-align: center;
- background: #ffffff;
- border: 1px solid #e5e7eb;
- border-radius: 10px;
- box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
- }
- &__loading-spinner {
- width: 28px;
- height: 28px;
- margin: 0 auto 10px;
- border: 3px solid #dbeafe;
- border-top-color: #2563eb;
- border-radius: 50%;
- animation: relation-graph-test-spin 0.8s linear infinite;
- }
- &__loading-title {
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 22px;
- }
- &__loading-desc {
- margin-top: 2px;
- color: #6b7280;
- font-size: 12px;
- line-height: 20px;
- }
- &__empty {
- position: absolute;
- inset: 0;
- z-index: 2;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- pointer-events: none;
- color: #64748b;
- background: rgba(248, 250, 252, 0.72);
- }
- &__empty-title {
- color: #111827;
- font-size: 16px;
- font-weight: 600;
- line-height: 24px;
- }
- &__empty-desc {
- margin-top: 6px;
- color: #64748b;
- font-size: 13px;
- line-height: 22px;
- }
- .filter-item {
- display: flex;
- align-items: center;
- gap: 6px;
- &__label {
- color: #6b7280;
- font-size: 12px;
- white-space: nowrap;
- }
- &__control {
- width: 132px;
- }
- &__number {
- width: 92px;
- }
- &--switch {
- padding-left: 6px;
- }
- }
- .panel-title {
- flex: 0 0 auto;
- margin-bottom: 12px;
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 22px;
- &--sub {
- margin-top: 16px;
- }
- }
- .summary-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
- &__item {
- padding: 10px;
- background: #f8fafc;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- }
- &__label {
- display: block;
- color: #6b7280;
- font-size: 12px;
- line-height: 18px;
- }
- &__value {
- display: block;
- margin-top: 4px;
- color: #111827;
- font-size: 16px;
- font-weight: 600;
- line-height: 24px;
- }
- }
- .entity-kind-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
- &__item {
- display: flex;
- align-items: center;
- gap: 8px;
- color: #374151;
- font-size: 13px;
- }
- &__dot {
- width: 9px;
- height: 9px;
- border-radius: 50%;
- }
- &__label {
- flex: 1 1 auto;
- min-width: 0;
- }
- }
- .active-node-card {
- padding: 12px;
- background: #f8fafc;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- &__name {
- margin-bottom: 10px;
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 22px;
- }
- &__row {
- display: flex;
- justify-content: space-between;
- gap: 12px;
- color: #4b5563;
- font-size: 12px;
- line-height: 22px;
- span:last-child {
- color: #111827;
- text-align: right;
- word-break: break-all;
- }
- }
- }
- .relation-node {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- width: 64px;
- height: 64px;
- padding: 8px;
- box-sizing: border-box;
- background: var(--node-accent-color);
- border: 1px solid rgba(15, 23, 42, 0.08);
- border-radius: 50%;
- box-shadow: 0 6px 14px rgba(15, 23, 42, 0.12);
- user-select: none;
- }
- .relation-node__content {
- display: flex;
- flex-direction: column;
- align-items: center;
- width: 100%;
- min-width: 0;
- gap: 2px;
- text-align: center;
- }
- .relation-node__label,
- .relation-node__secondary {
- width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .relation-node__label {
- color: var(--node-text-color);
- font-size: 12px;
- font-weight: 600;
- line-height: 18px;
- }
- .relation-node__secondary {
- color: var(--node-text-color);
- font-size: 11px;
- line-height: 16px;
- opacity: 0.82;
- }
- .relation-node--lite {
- width: 44px;
- height: 44px;
- padding: 4px;
- box-shadow: none;
- }
- .relation-node--large {
- width: 24px;
- height: 24px;
- padding: 0;
- border: 0;
- box-shadow: none;
- }
- .rel-node-shape-1 {
- box-shadow: none;
- }
- }
- @keyframes relation-graph-test-spin {
- to {
- transform: rotate(360deg);
- }
- }
- </style>
|