| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311 |
- <template>
- <div class="ai-graph-viewer">
- <div class="relation-stage">
- <div class="relation-stage__canvas">
- <div ref="stageRef" class="relation-stage__graph"></div>
- <div v-if="building" class="relation-stage__layout-loading">
- <div class="relation-stage__layout-loading-card">
- <div class="relation-stage__layout-loading-spinner"></div>
- <div class="relation-stage__layout-loading-title">正在渲染图谱</div>
- <div class="relation-stage__layout-loading-desc">正在计算节点位置并渲染图谱。</div>
- </div>
- </div>
- <div class="control-tools-box">
- <div v-if="activePanel" class="tool-panel">
- <div class="tool-panel__header">
- <div class="tool-panel__title">{{ activePanelLabel }}</div>
- <Button type="text" size="small" class="tool-panel__close" @click="activePanel = null">收起</Button>
- </div>
- <template v-if="activePanel === 'find'">
- <div class="tool-panel__row">
- <Input
- v-if="searchForm.scope === 'entity'"
- v-model:value="searchForm.keyword"
- placeholder="请输入查找内容"
- allow-clear
- @press-enter="runSearch"
- />
- <div v-else class="tool-panel__range-row">
- <InputNumber
- v-model:value="searchForm.min"
- :min="0"
- :precision="0"
- class="tool-panel__number"
- placeholder="最小值"
- @press-enter="runSearch"
- />
- <span class="tool-panel__range-separator">至</span>
- <InputNumber
- v-model:value="searchForm.max"
- :min="0"
- :precision="0"
- class="tool-panel__number"
- placeholder="最大值"
- @press-enter="runSearch"
- />
- </div>
- </div>
- <div v-if="searchForm.scope === 'entity'" class="tool-panel__switch-row">
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': searchForm.manner === 'obscure' }"
- @click="searchForm.manner = 'obscure'"
- >
- 模糊查找
- </button>
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': searchForm.manner === 'precise' }"
- @click="searchForm.manner = 'precise'"
- >
- 精确查找
- </button>
- </div>
- <div class="tool-panel__switch-row">
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': searchForm.scope === 'entity' }"
- @click="searchForm.scope = 'entity'"
- >
- 查找实体
- </button>
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': searchForm.scope === 'relation' }"
- @click="searchForm.scope = 'relation'"
- >
- 查找关系
- </button>
- </div>
- <div class="tool-panel__footer tool-panel__footer--find">
- <div v-if="searchResults.length" class="tool-panel__result">
- 共 {{ searchResults.length }} 个结果,当前第 {{ searchIndex + 1 }} 个
- </div>
- <div class="tool-panel__actions tool-panel__actions--find">
- <Button v-if="searchResults.length > 1" @click="selectAllSearchResults">全选结果</Button>
- <Button v-if="searchResults.length > 1" @click="prevSearchResult">上一个</Button>
- <Button type="primary" @click="runSearch">查找</Button>
- <Button v-if="searchResults.length > 1" @click="nextSearchResult">下一个</Button>
- </div>
- </div>
- </template>
- <template v-else-if="activePanel === 'filter'">
- <div class="tool-panel__row">
- <Select
- v-model:value="filterForm.kinds"
- class="tool-panel__entity-select"
- mode="multiple"
- allow-clear
- placeholder="请选择过滤实体"
- :options="kindOptions"
- />
- </div>
- <div class="tool-panel__switch-row">
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': filterForm.condition === 'neighborTotal' }"
- @click="filterForm.condition = 'neighborTotal'"
- >
- 实体相邻个数
- </button>
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': filterForm.condition === 'edgeValue' }"
- @click="filterForm.condition = 'edgeValue'"
- >
- 关系数值
- </button>
- </div>
- <div class="tool-panel__switch-row">
- <button
- v-for="operator in filterOperators"
- :key="operator"
- type="button"
- class="tool-panel__switch tool-panel__switch--compact"
- :class="{ 'is-active': filterForm.operator === operator }"
- @click="filterForm.operator = operator"
- >
- {{ operator }}
- </button>
- <InputNumber v-model:value="filterForm.value" :min="0" :precision="0" class="tool-panel__number" />
- </div>
- <div class="tool-panel__footer">
- <div class="tool-panel__actions">
- <Button type="primary" @click="executeFilter">开始过滤</Button>
- <Button @click="cancelFilter">取消过滤</Button>
- <Button v-if="filterMatchedNodeIds.length" @click="selectFiltered">全选结果</Button>
- <Button v-if="filterMatchedNodeIds.length" @click="toggleFilterHidden">
- {{ filterHidden ? '显示过滤' : '隐藏过滤' }}
- </Button>
- </div>
- </div>
- </template>
- </div>
- <div class="canvas-toolbar">
- <Tooltip title="查找">
- <Button
- type="text"
- class="canvas-toolbar__button"
- :class="{ 'is-active': activePanel === 'find' }"
- @click="togglePanel('find')"
- >
- <SearchOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="过滤">
- <Button
- type="text"
- class="canvas-toolbar__button"
- :class="{ 'is-active': activePanel === 'filter' }"
- @click="togglePanel('filter')"
- >
- <FilterOutlined />
- </Button>
- </Tooltip>
- <span class="canvas-toolbar__divider"></span>
- <Tooltip title="缩小">
- <Button type="text" class="canvas-toolbar__button" @click="zoomOut">
- <MinusOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="放大">
- <Button type="text" class="canvas-toolbar__button" @click="zoomIn">
- <PlusOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="适配画布">
- <Button type="text" class="canvas-toolbar__button" @click="fitView">
- <FullscreenOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="居中内容">
- <Button type="text" class="canvas-toolbar__button" @click="centerView">
- <AimOutlined />
- </Button>
- </Tooltip>
- <span class="canvas-toolbar__divider"></span>
- <Tooltip title="扩散关系">
- <Button type="text" class="canvas-toolbar__button" @click="selectNeighborElements">
- <ShareAltOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="框选">
- <Button type="text" class="canvas-toolbar__button" @click="showBoxSelectTip">
- <BorderOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="重新布局">
- <Button type="text" class="canvas-toolbar__button" @click="relayout">
- <ApartmentOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="全屏查看">
- <Button type="text" class="canvas-toolbar__button" @click="emit('toggle-fullscreen')">
- <FullscreenOutlined />
- </Button>
- </Tooltip>
- </div>
- </div>
- <div class="canvas-status">
- <span class="canvas-status__item">提示:拖动画布可平移,滚轮可缩放</span>
- <span class="canvas-status__item">框选:按住 Shift 拖动画布</span>
- <span v-if="activeElementLabel" class="canvas-status__item">选中:{{ activeElementLabel }}</span>
- </div>
- </div>
- </div>
- <QueryTableModal
- :open="detailTableModalOpen"
- :title="detailTitle"
- :width="960"
- :loading="detailLoading"
- :filters="detailFilters"
- :filter-schema="detailFilterSchema"
- :columns="detailTableColumns"
- :data-source="detailTableRows"
- :pagination="detailPagination"
- :total-pages="detailTotalPages"
- :page-size-options="detailPageSizeOptions"
- :show-index-column="true"
- :show-table-setting="false"
- :table-setting="{ size: true, setting: true, redo: false, fullScreen: false }"
- :can-fullscreen="true"
- wrap-class-name="graph-analysis-detail-table-modal"
- @update:open="handleDetailVisibleChange"
- @search="handleDetailSearch"
- @reset="handleDetailReset"
- @prev-page="handleDetailPrevPage"
- @next-page="handleDetailNextPage"
- @page-size-change="handleDetailPageSizeChange"
- @page-change="handleDetailPageChange"
- @sort-change="handleDetailSortChange"
- />
- <BasicModal
- :open="detailSummaryModalOpen"
- :title="detailTitle"
- :width="960"
- :show-ok-btn="false"
- :show-cancel-btn="false"
- :can-fullscreen="true"
- :centered="true"
- wrap-class-name="graph-analysis-detail-modal"
- @update:open="handleDetailVisibleChange"
- @cancel="handleDetailVisibleChange(false)"
- >
- <div class="analysis-detail">
- <div v-if="detailEntries.length" class="analysis-detail__summary">
- <div v-for="item in detailEntries" :key="item.label" class="analysis-detail__entry">
- <div class="analysis-detail__entry-label">{{ item.label }}</div>
- <div class="analysis-detail__entry-value">{{ item.value }}</div>
- </div>
- </div>
- <Empty v-else description="暂无详情信息" />
- </div>
- </BasicModal>
- <TransRecordModal
- v-model:open="transDetailVisible"
- :title="transDetailTitle"
- :base-query="transDetailBaseQuery"
- :fetch-api="fetchTransDetailRecords"
- :filter-fields="['startDate', 'endDate', 'transAmount']"
- :trans-amount-operator-options="transAmountOperatorOptions"
- />
- <CallRecordModal
- v-model:open="callDetailVisible"
- :title="callDetailTitle"
- :base-query="callDetailBaseQuery"
- :fetch-api="fetchCallDetailRecords"
- :filter-fields="['startDate', 'endDate']"
- />
- </div>
- </template>
- <script lang="ts" setup>
- /**
- * AI 聊天关系图谱查看器(独立简版,不复用对象关系分析页面的组件/布局/API 封装)
- *
- * 保留:查找、过滤、缩放/适配/居中、节点/关系点击明细(节点详情、通话明细、交易明细)。
- * 移除:工具箱、画布 Tab、历史记录、保存、撤销/重做、复制/粘贴/删除、组合/拆分、
- * 新建画布、拖入实体、新建连线、标记色、右键编辑菜单、节点拖动。
- */
- import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue';
- import { Button, Empty, Input, InputNumber, Select, Tooltip } from 'ant-design-vue';
- import {
- AimOutlined,
- ApartmentOutlined,
- BorderOutlined,
- FilterOutlined,
- FullscreenOutlined,
- MinusOutlined,
- PlusOutlined,
- SearchOutlined,
- ShareAltOutlined,
- } from '@ant-design/icons-vue';
- import { CanvasEvent, EdgeEvent, Graph, GraphEvent, NodeEvent, type IElementEvent } from '@antv/g6';
- import { BasicModal } from '@/core/components/Modal';
- import componentSetting from '@/core/settings/componentSetting';
- import type { CompareOperator } from '@/core/utils/compareOperator';
- import { useMessage } from '@/core/hooks/web/useMessage';
- import QueryTableModal from '@/case/views/dm/components/QueryTableModal.vue';
- import CallRecordModal from '@/call/views/components/CallRecordModal.vue';
- import TransRecordModal from '@/trans/views/components/TransRecordModal.vue';
- import {
- buildGraphCallDetailQuery,
- buildGraphTransDetailQuery,
- fetchGraphCallDetail,
- fetchGraphNodeDetail,
- fetchGraphTransDetail,
- type GraphCallDetailQuery,
- type GraphNodeDetailQuery,
- type GraphTransDetailQuery,
- } from '@/graph/api/analysis';
- import { buildG6Data, type GraphModelEdge, type GraphModelNode, type ParsedGraph } from './graphData';
- import { ENTITY_META_LIST, getEntityMeta, registerAIGraphElements } from './graphElements';
- import { layoutGraph } from './graphLayout';
- const props = defineProps<{
- model: ParsedGraph;
- }>();
- const emit = defineEmits<{
- 'toggle-fullscreen': [];
- }>();
- const { showMessage } = useMessage();
- const FILTER_OPERATORS = ['>', '>=', '=', '<', '<='] as const;
- const filterOperators = FILTER_OPERATORS;
- const stageRef = ref<HTMLDivElement>();
- const graphRef = shallowRef<Graph | null>(null);
- let stageResizeObserver: ResizeObserver | null = null;
- const building = ref(false);
- const zoomPercent = ref(100);
- const performanceMode = ref(false);
- const contextMenuElementId = ref('');
- const selectedNode = ref<GraphModelNode | null>(null);
- const selectedEdge = ref<GraphModelEdge | null>(null);
- const nodeById = computed(() => new Map(props.model.nodes.map((node) => [node.id, node])));
- const edgeById = computed(() => new Map(props.model.edges.map((edge) => [edge.id, edge])));
- /* ---------------- 查找 ---------------- */
- const activePanel = ref<'find' | 'filter' | null>(null);
- const activePanelLabel = computed(() => (activePanel.value === 'find' ? '查找' : '过滤'));
- const searchForm = reactive<{
- scope: 'entity' | 'relation';
- manner: 'obscure' | 'precise';
- keyword: string;
- min?: number;
- max?: number;
- }>({ scope: 'entity', manner: 'obscure', keyword: '', min: undefined, max: undefined });
- const searchResults = ref<string[]>([]);
- const searchIndex = ref(-1);
- /* ---------------- 过滤 ---------------- */
- const filterForm = reactive<{
- kinds: string[];
- condition: 'neighborTotal' | 'edgeValue';
- operator: (typeof FILTER_OPERATORS)[number];
- value?: number;
- }>({ kinds: [], condition: 'neighborTotal', operator: '>', value: undefined });
- const filterMatchedNodeIds = ref<string[]>([]);
- const filterMutedNodeIds = ref<string[]>([]);
- const filterMatchedEdgeIds = ref<string[]>([]);
- const filterHidden = ref(false);
- const kindOptions = ENTITY_META_LIST.map((meta) => ({ label: meta.label, value: meta.kind }));
- const activeElementLabel = computed(() => selectedNode.value?.label || selectedEdge.value?.label || '');
- /* ---------------- 明细弹框(与原对象关系分析页一致) ---------------- */
- const detailVisible = ref(false);
- const detailLoading = ref(false);
- const detailTitle = ref('图谱详情');
- const detailEntries = ref<Array<{ label: string; value: string }>>([]);
- const detailTableColumns = ref<any[]>([]);
- const detailTableRows = ref<Record<string, any>[]>([]);
- const detailFilters = reactive({
- startDate: '',
- endDate: '',
- transAmount: '',
- transAmountSymbol: 'GE',
- orderKey: '',
- sort: '' as '' | 'ASC' | 'DESC',
- });
- const detailPagination = ref({
- page: 1,
- limit: componentSetting.table.defaultPageSize,
- total: 0,
- });
- const detailPageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
- label: value,
- value,
- }));
- const detailQueryBase = ref<GraphNodeDetailQuery | null>(null);
- const transDetailVisible = ref(false);
- const transDetailTitle = ref('交易详情');
- const transDetailBaseQuery = ref<Record<string, any>>({});
- const transAmountOperatorOptions: Array<{ label: string; value: CompareOperator }> = [
- { label: '>=', value: 'GE' },
- { label: '<=', value: 'LE' },
- ];
- const callDetailVisible = ref(false);
- const callDetailTitle = ref('通信详情');
- const callDetailBaseQuery = ref<Record<string, any>>({});
- const detailTableModalOpen = computed(
- () => detailVisible.value && (detailLoading.value || detailTableColumns.value.length > 0),
- );
- const detailSummaryModalOpen = computed(
- () => detailVisible.value && !detailLoading.value && !detailTableColumns.value.length,
- );
- const detailTotalPages = computed(() =>
- Math.max(1, Math.ceil(detailPagination.value.total / detailPagination.value.limit)),
- );
- /* ---------------- 详情查询:沿用对象关系分析页的原口径 ---------------- */
- const normalizeDetailText = (value: unknown) => String(value ?? '').trim();
- const getAnalysisClassByKind = (kind: string): string => {
- 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 isDetailDateColumn = (column: any) => {
- const text = `${column?.dataIndex ?? ''} ${column?.title ?? ''}`.toLowerCase();
- return /date|time|日期|时间/.test(text);
- };
- const detailDateField = computed(() => {
- const column = detailTableColumns.value.find((item) => isDetailDateColumn(item));
- return column ? String(column.dataIndex || '') : '';
- });
- const detailDateFilterLabel = computed(() => {
- const column = detailTableColumns.value.find((item) => String(item?.dataIndex ?? '') === detailDateField.value);
- return String(column?.title || '日期');
- });
- const detailDateFilterVisible = computed(() => Boolean(detailDateField.value));
- const detailAmountField = computed(() => {
- const column = detailTableColumns.value.find((item) => {
- const text = `${item?.dataIndex ?? ''} ${item?.title ?? ''}`.toLowerCase();
- return /amount|money|金额|交易额/.test(text);
- });
- return column ? String(column.dataIndex || '') : '';
- });
- const detailAmountFilterVisible = computed(() => Boolean(detailAmountField.value));
- const detailFilterSchema = computed(() => {
- const schema: any[] = [];
- if (detailDateFilterVisible.value) {
- schema.push({
- field: 'analysisDetailDateRange',
- label: detailDateFilterLabel.value,
- type: 'date-range',
- startField: 'startDate',
- endField: 'endDate',
- width: 320,
- maxWidth: 360,
- rangePlaceholder: ['开始日期', '结束日期'],
- valueFormat: 'YYYY-MM-DD',
- });
- }
- if (detailAmountFilterVisible.value) {
- schema.push({
- field: 'transAmount',
- label: '交易金额',
- type: 'compare-input',
- operatorField: 'transAmountSymbol',
- options: transAmountOperatorOptions,
- operatorWidth: 72,
- inputWidth: 180,
- inputMaxWidth: 180,
- placeholder: '请输入交易金额',
- });
- }
- return schema;
- });
- const appendDetailEntry = (entries: Array<{ label: string; value: string }>, label: string, value: unknown) => {
- if (value === undefined || value === null || value === '') return;
- const text =
- typeof value === 'object'
- ? (() => {
- try {
- return JSON.stringify(value);
- } catch {
- return String(value);
- }
- })()
- : String(value);
- if (!text.trim()) return;
- entries.push({ label: normalizeDetailText(label) || '字段', value: text });
- };
- const buildNodeDetailEntriesFromPayload = (payload: unknown) => {
- const entries: Array<{ label: string; value: string }> = [];
- if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return entries;
- Object.entries(payload as Record<string, unknown>).forEach(([key, value]) => {
- appendDetailEntry(entries, key, value);
- });
- return entries;
- };
- const resolveNodeDetailPayload = (response: unknown) => {
- const root = response && typeof response === 'object' ? (response as Record<string, unknown>) : {};
- const data = root?.data && typeof root.data === 'object' ? (root.data as Record<string, unknown>) : root;
- const head = Array.isArray(data?.head) ? (data.head as Record<string, unknown>[]) : [];
- const pages = data?.pages && typeof data.pages === 'object' ? (data.pages as Record<string, unknown>) : undefined;
- const records = Array.isArray(pages?.records) ? (pages.records as Record<string, unknown>[]) : [];
- return { data, head, records, pages };
- };
- const isNodeDetailSequenceColumn = (item: Record<string, unknown>) => {
- const title = (normalizeDetailText(item.title) || '').toLowerCase();
- const dataIndex = (normalizeDetailText(item.dataIndex) || '').toLowerCase();
- const key = (normalizeDetailText(item.key) || '').toLowerCase();
- return (
- ['序号', 'index', 'xh'].includes(title) || ['index', 'xh'].includes(dataIndex) || ['index', 'xh'].includes(key)
- );
- };
- const normalizeNodeDetailColumnWidth = (value: unknown, title: string) => {
- const width = Number.parseFloat(String(value ?? ''));
- if (Number.isFinite(width) && width > 0) return width;
- if (title === '序号') return 72;
- if (title.includes('时间') || title.includes('日期')) return 180;
- if (title.includes('地址') || title.includes('内容') || title.includes('摘要') || title.includes('名称')) {
- return 220;
- }
- return 160;
- };
- const buildNodeDetailTableColumns = (head: Record<string, unknown>[]) =>
- head
- .filter((item) => !isNodeDetailSequenceColumn(item))
- .map((item, index) => {
- const title = normalizeDetailText(item.title) || normalizeDetailText(item.key) || `字段${index + 1}`;
- const dataIndex = normalizeDetailText(item.dataIndex) || normalizeDetailText(item.key) || `field_${index}`;
- const key = normalizeDetailText(item.key) || dataIndex;
- const width = normalizeNodeDetailColumnWidth(item.width, title);
- return { title, dataIndex, key, width };
- })
- .filter((item) => item.dataIndex);
- const buildNodeDetailTableRows = (records: Record<string, unknown>[]) =>
- records.map((item, index) => ({
- rowKey: String(item.id ?? item.rowId ?? item.fileId ?? index),
- ...item,
- }));
- const resetDetailFilters = () => {
- detailFilters.startDate = '';
- detailFilters.endDate = '';
- detailFilters.transAmount = '';
- detailFilters.transAmountSymbol = 'GE';
- detailFilters.orderKey = '';
- detailFilters.sort = '';
- };
- const buildDetailNodeQuery = () => {
- const baseQuery = detailQueryBase.value;
- if (!baseQuery) return null;
- return {
- ...baseQuery,
- page: detailPagination.value.page,
- limit: detailPagination.value.limit,
- startDate: detailFilters.startDate || undefined,
- endDate: detailFilters.endDate || undefined,
- transAmountSymbol: detailFilters.transAmount ? detailFilters.transAmountSymbol : undefined,
- transAmount: detailFilters.transAmount || undefined,
- orderKey: detailFilters.orderKey || undefined,
- sort: detailFilters.sort || undefined,
- } as GraphNodeDetailQuery;
- };
- const fetchDetailTable = async () => {
- const query = buildDetailNodeQuery();
- if (!query) return;
- detailLoading.value = true;
- try {
- const response = await fetchGraphNodeDetail(query);
- const { data, head, records, pages } = resolveNodeDetailPayload(response);
- if (head.length) {
- detailTableColumns.value = buildNodeDetailTableColumns(head);
- detailTableRows.value = buildNodeDetailTableRows(records);
- const current = Number(pages?.current ?? detailPagination.value.page);
- const size = Number((pages?.size ?? records.length) || detailPagination.value.limit);
- const total = Number(pages?.total ?? records.length);
- detailPagination.value.page = Number.isFinite(current) && current > 0 ? current : detailPagination.value.page;
- detailPagination.value.limit = Number.isFinite(size) && size > 0 ? size : detailPagination.value.limit;
- detailPagination.value.total = Number.isFinite(total) && total >= 0 ? total : records.length;
- detailEntries.value = [];
- return;
- }
- detailEntries.value = buildNodeDetailEntriesFromPayload(data);
- detailTableColumns.value = [];
- detailTableRows.value = [];
- detailPagination.value.total = 0;
- } catch (error) {
- console.error('load ai graph node detail failed', error);
- detailTableColumns.value = [];
- detailTableRows.value = [];
- detailPagination.value.total = 0;
- showMessage('节点详情加载失败', 'error');
- } finally {
- detailLoading.value = false;
- }
- };
- const handleDetailSearch = async () => {
- detailPagination.value.page = 1;
- await fetchDetailTable();
- };
- const handleDetailReset = async () => {
- resetDetailFilters();
- detailPagination.value.page = 1;
- await fetchDetailTable();
- };
- const handleDetailPrevPage = () => {
- if (detailPagination.value.page <= 1) return;
- detailPagination.value.page -= 1;
- void fetchDetailTable();
- };
- const handleDetailNextPage = () => {
- if (detailPagination.value.page >= detailTotalPages.value) return;
- detailPagination.value.page += 1;
- void fetchDetailTable();
- };
- const handleDetailPageChange = (page: number, pageSize: number) => {
- if (detailLoading.value) return;
- if (pageSize !== detailPagination.value.limit) {
- handleDetailPageSizeChange(pageSize);
- return;
- }
- if (page === detailPagination.value.page) return;
- detailPagination.value.page = page;
- void fetchDetailTable();
- };
- const handleDetailPageSizeChange = (size: number) => {
- detailPagination.value.limit = Number(size);
- detailPagination.value.page = 1;
- void fetchDetailTable();
- };
- const handleDetailSortChange = (payload: { orderKey: string; sort: 'ASC' | 'DESC' }) => {
- detailFilters.orderKey = payload.orderKey;
- detailFilters.sort = payload.sort;
- detailPagination.value.page = 1;
- void fetchDetailTable();
- };
- const handleDetailVisibleChange = (open: boolean) => {
- detailVisible.value = open;
- };
- const isNodeDetailFetchable = (node: GraphModelNode) => node.kind !== 'person';
- const buildNodeDetailQuery = (node: GraphModelNode): GraphNodeDetailQuery | null => {
- const raw = node.raw || {};
- const clazz = normalizeDetailText(raw.clazz ?? raw.class) || getAnalysisClassByKind(node.kind);
- const nodeToken =
- normalizeDetailText(raw.node) ||
- normalizeDetailText(raw.bm) ||
- normalizeDetailText(raw.id) ||
- normalizeDetailText(raw.dxId);
- if (!nodeToken || !clazz) return null;
- const lxValue = Number(raw.lx);
- const fxValue = Number(raw.fx);
- return {
- node: nodeToken,
- clazz,
- lx: Number.isFinite(lxValue) ? lxValue : 0,
- fx: Number.isFinite(fxValue) ? fxValue : 0,
- };
- };
- const edgeRaw = (edge: GraphModelEdge): Record<string, any> => {
- const raw = edge.raw || {};
- return {
- ...raw,
- dx1: raw.dx1 ?? raw.node1 ?? edge.source,
- dx2: raw.dx2 ?? raw.node2 ?? edge.target,
- clazz: raw.clazz ?? edge.clazz,
- id: raw.id ?? edge.id,
- };
- };
- const isCommunicationEdge = (edge: GraphModelEdge) =>
- normalizeDetailText(edgeRaw(edge).clazz).toLowerCase() === 'tel_tel';
- const isTradeEdge = (edge: GraphModelEdge) => normalizeDetailText(edgeRaw(edge).clazz).toLowerCase() === 'card_card';
- const canOpenAnalysisDetail = (elementId: string, kind: 'node' | 'edge' | null) => {
- if (!elementId || !kind) return false;
- if (kind === 'node') return Boolean(nodeById.value.get(elementId));
- const edge = edgeById.value.get(elementId);
- if (!edge) return false;
- const raw = edgeRaw(edge);
- const relationTexts = [edge.label, raw.mc, raw.sm, raw.clazz]
- .map((item) => normalizeDetailText(item))
- .filter(Boolean);
- return !relationTexts.some((text) => text.includes('持卡人') || text.includes('机主'));
- };
- const getEdgeEndpointName = (edge: GraphModelEdge, endpoint: 'source' | 'target') => {
- const endpointNode = nodeById.value.get(endpoint === 'source' ? edge.source : edge.target);
- const raw = edgeRaw(edge);
- return endpoint === 'source'
- ? endpointNode?.label || raw.obj1_name || raw.dx1 || '未知源节点'
- : endpointNode?.label || raw.obj2_name || raw.dx2 || '未知目标节点';
- };
- const openCommunicationCallDetail = (target: GraphModelNode | GraphModelEdge, kind: 'node' | 'edge') => {
- if (kind === 'node') {
- const node = target as GraphModelNode;
- if (node.kind !== 'phone') return false;
- const raw = node.raw || {};
- const phoneNo = normalizeDetailText(raw.bm) || normalizeDetailText(raw.node) || node.id;
- if (!phoneNo) {
- showMessage('当前通信节点缺少可查询的号码', 'warning');
- return false;
- }
- transDetailVisible.value = false;
- detailVisible.value = false;
- callDetailTitle.value = `${node.fullLabel || node.label || phoneNo} 通信详情`;
- callDetailBaseQuery.value = buildGraphCallDetailQuery({}, {}, {
- ...raw,
- bm: phoneNo,
- id: node.id,
- } as any) as GraphCallDetailQuery;
- callDetailVisible.value = true;
- return true;
- }
- const edge = target as GraphModelEdge;
- if (!isCommunicationEdge(edge)) return false;
- transDetailVisible.value = false;
- detailVisible.value = false;
- callDetailTitle.value = `${getEdgeEndpointName(edge, 'source')} -> ${getEdgeEndpointName(edge, 'target')} 通信详情`;
- callDetailBaseQuery.value = buildGraphCallDetailQuery({}, {}, null, edgeRaw(edge) as any) as GraphCallDetailQuery;
- callDetailVisible.value = true;
- return true;
- };
- const openTradeEdgeTransDetail = (edge: GraphModelEdge) => {
- if (!isTradeEdge(edge)) return false;
- const raw = edgeRaw(edge);
- const personCardNo = normalizeDetailText(raw.dx1);
- const otherCardNo = normalizeDetailText(raw.dx2);
- if (!personCardNo || !otherCardNo) return false;
- callDetailVisible.value = false;
- detailVisible.value = false;
- transDetailTitle.value = `${getEdgeEndpointName(edge, 'source')} -> ${getEdgeEndpointName(edge, 'target')} 交易详情`;
- transDetailBaseQuery.value = buildGraphTransDetailQuery(
- {},
- {
- personCardNo,
- otherCardNo,
- lx:
- raw.lx === undefined || raw.lx === null || raw.lx === ''
- ? undefined
- : Number.isFinite(Number(raw.lx))
- ? Number(raw.lx)
- : undefined,
- },
- ) as GraphTransDetailQuery;
- transDetailVisible.value = true;
- return true;
- };
- const fetchTransDetailRecords = (query: Record<string, any>) => fetchGraphTransDetail(query as GraphTransDetailQuery);
- const normalizeGraphCallDetailQuery = (query: Record<string, any>): GraphCallDetailQuery => {
- const normalized = { ...query } as GraphCallDetailQuery;
- const startDate = normalizeDetailText(query.startDate ?? query.date1);
- const endDate = normalizeDetailText(query.endDate ?? query.date2);
- normalized.startDate = startDate || undefined;
- normalized.endDate = endDate || undefined;
- normalized.date1 = startDate || normalized.date1;
- normalized.date2 = endDate || normalized.date2;
- return normalized;
- };
- const fetchCallDetailRecords = (query: Record<string, any>) =>
- fetchGraphCallDetail(normalizeGraphCallDetailQuery(query));
- const openDetail = async (elementId: string, kind: 'node' | 'edge') => {
- if (kind === 'edge') {
- const edge = edgeById.value.get(elementId);
- if (!edge) return;
- if (openCommunicationCallDetail(edge, 'edge')) return;
- if (openTradeEdgeTransDetail(edge)) return;
- detailTitle.value = `${getEdgeEndpointName(edge, 'source')} -> ${getEdgeEndpointName(edge, 'target')}`;
- const raw = edgeRaw(edge);
- detailEntries.value = [
- { label: 'ID', value: String(edge.id || '-') },
- { label: '名称', value: edge.label || '-' },
- ...Object.entries(raw)
- .filter(([, value]) => value !== undefined && value !== null && value !== '')
- .slice(0, 24)
- .map(([label, value]) => {
- const text =
- typeof value === 'object'
- ? (() => {
- try {
- return JSON.stringify(value);
- } catch {
- return String(value);
- }
- })()
- : String(value);
- return { label, value: text };
- }),
- ];
- detailQueryBase.value = null;
- detailTableColumns.value = [];
- detailTableRows.value = [];
- detailPagination.value.total = 0;
- detailLoading.value = false;
- detailVisible.value = true;
- return;
- }
- const node = nodeById.value.get(elementId);
- if (!node) return;
- if (openCommunicationCallDetail(node, 'node')) return;
- callDetailVisible.value = false;
- transDetailVisible.value = false;
- detailTitle.value = String(node.fullLabel || node.label || '实体详情');
- detailLoading.value = true;
- detailEntries.value = [];
- detailTableColumns.value = [];
- detailTableRows.value = [];
- resetDetailFilters();
- detailPagination.value = { page: 1, limit: componentSetting.table.defaultPageSize, total: 0 };
- if (isNodeDetailFetchable(node)) {
- const query = buildNodeDetailQuery(node);
- if (!query) {
- detailLoading.value = false;
- showMessage('当前节点缺少详情查询参数', 'warning');
- return;
- }
- detailQueryBase.value = query;
- detailVisible.value = true;
- await fetchDetailTable();
- return;
- }
- detailQueryBase.value = null;
- detailEntries.value = buildNodeDetailEntriesFromPayload(node.raw || {});
- detailTableColumns.value = [];
- detailTableRows.value = [];
- detailPagination.value.total = 0;
- detailLoading.value = false;
- detailVisible.value = true;
- };
- /* ---------------- 右键菜单(只读项,与原页面同一 G6 插件) ---------------- */
- const contextMenuId = (event: any) =>
- String(event?.target?.id || event?.target?.getAttribute?.('id') || event?.target?.attributes?.id || '');
- const getContextMenuItems = (event: any) => {
- const id = contextMenuId(event);
- contextMenuElementId.value = id;
- const kind: 'node' | 'edge' | null = nodeById.value.has(id) ? 'node' : edgeById.value.has(id) ? 'edge' : null;
- if (!id || !kind) {
- return [{ name: '适配视图', value: 'fitView' }];
- }
- // 右键即选中当前元素,与原对象关系分析页行为一致
- selectedNode.value = kind === 'node' ? nodeById.value.get(id) || null : null;
- selectedEdge.value = kind === 'edge' ? edgeById.value.get(id) || null : null;
- void markSelected(id);
- if (kind === 'edge') {
- return [
- ...(canOpenAnalysisDetail(id, kind) ? [{ name: '查看详情', value: 'analysisDetail' }] : []),
- { name: '适配视图', value: 'fitView' },
- ];
- }
- const incoming = graphRef.value?.getEdgeData().filter((item) => String(item.target) === id).length || 0;
- const outgoing = graphRef.value?.getEdgeData().filter((item) => String(item.source) === id).length || 0;
- return [
- ...(canOpenAnalysisDetail(id, kind) ? [{ name: '查看详情', value: 'analysisDetail' }] : []),
- ...(incoming ? [{ name: '仅看流入', value: 'onlyLookFlowIn' }] : []),
- ...(outgoing ? [{ name: '仅看流出', value: 'onlyLookFlowOut' }] : []),
- ];
- };
- const createContextMenuPlugin = () => ({
- type: 'contextmenu',
- key: 'ai-graph-contextmenu',
- className: 'g6-editor-contextmenu',
- getItems: (event: any) => getContextMenuItems(event),
- onClick: (value: string, _target: HTMLElement, current: any) => {
- void handleContextMenuClick(value, current);
- },
- });
- const handleContextMenuClick = async (value: string, current: any) => {
- const id = String(current?.id || contextMenuElementId.value || '');
- const kind: 'node' | 'edge' | null = nodeById.value.has(id) ? 'node' : edgeById.value.has(id) ? 'edge' : null;
- if (value === 'fitView') {
- await fitView();
- return;
- }
- if (value === 'analysisDetail' && id && kind && canOpenAnalysisDetail(id, kind)) {
- await openDetail(id, kind);
- return;
- }
- if ((value === 'onlyLookFlowIn' || value === 'onlyLookFlowOut') && id && kind === 'node') {
- await handleLookNodeFlow(id, value === 'onlyLookFlowIn' ? 'incoming' : 'outgoing');
- }
- };
- const handleLookNodeFlow = async (nodeId: string, direction: 'incoming' | 'outgoing') => {
- const graph = graphRef.value;
- if (!graph || !nodeById.value.has(nodeId)) return;
- const flowEdges = graph
- .getEdgeData()
- .filter((item) => (direction === 'incoming' ? String(item.target) === nodeId : String(item.source) === nodeId));
- if (!flowEdges.length) {
- showMessage(`当前节点没有${direction === 'incoming' ? '流入' : '流出'}关系`, 'warning');
- return;
- }
- const ids = new Set<string>([nodeId]);
- flowEdges.forEach((item) => {
- ids.add(String(item.source));
- ids.add(String(item.target));
- if (item.id) ids.add(String(item.id));
- });
- await clearElementStates();
- for (const id of ids) {
- await graph.setElementState(id, ['selected'], false);
- }
- await graph.focusElement(nodeId, { animation: { duration: 240 } } as any);
- showMessage(`已选中 ${flowEdges.length} 条${direction === 'incoming' ? '流入' : '流出'}关系`, 'success');
- };
- /* ---------------- 工具条行为 ---------------- */
- const togglePanel = (panel: 'find' | 'filter') => {
- activePanel.value = activePanel.value === panel ? null : panel;
- };
- const showBoxSelectTip = () => {
- showMessage('按住 Shift 拖动画布即可框选', 'info');
- };
- const selectNeighborElements = () => {
- const graph = graphRef.value;
- const node = selectedNode.value;
- if (!graph || !node) {
- showMessage('请先选择一个实体', 'warning');
- return;
- }
- const ids = new Set<string>([node.id]);
- graph.getEdgeData().forEach((edge) => {
- if (String(edge.source) === node.id || String(edge.target) === node.id) {
- ids.add(String(edge.source));
- ids.add(String(edge.target));
- if (edge.id) ids.add(String(edge.id));
- }
- });
- void (async () => {
- await clearElementStates();
- for (const id of ids) await graph.setElementState(id, ['selected'], false);
- })();
- };
- /* ---------------- 生命周期 ---------------- */
- onMounted(() => {
- registerAIGraphElements();
- void rebuild();
- });
- onBeforeUnmount(() => {
- destroyGraph();
- });
- watch(
- () => props.model,
- () => {
- clearSelected();
- activePanel.value = null;
- resetFilterState();
- void rebuild();
- },
- );
- /* ---------------- G6 初始化与数据 ---------------- */
- function getCanvasSize() {
- const element = stageRef.value;
- return {
- width: element?.clientWidth || 800,
- height: element?.clientHeight || 460,
- };
- }
- function destroyGraph() {
- stageResizeObserver?.disconnect();
- stageResizeObserver = null;
- const graph = graphRef.value;
- graphRef.value = null;
- try {
- graph?.destroy();
- } catch {
- /* 忽略销毁异常 */
- }
- }
- /**
- * G6 自带 autoResize 在聊天流式布局/全屏切换时偶尔拿不到最终尺寸,
- * 这里再用 ResizeObserver 显式 resize,保证画布始终铺满容器,缩放后不留白。
- */
- function observeStageSize() {
- if (typeof ResizeObserver === 'undefined' || !stageRef.value) return;
- stageResizeObserver?.disconnect();
- stageResizeObserver = new ResizeObserver(() => {
- const element = stageRef.value;
- const graph = graphRef.value;
- if (!element || !graph) return;
- const width = Math.max(1, element.clientWidth);
- const height = Math.max(1, element.clientHeight);
- try {
- graph.resize(width, height);
- } catch {
- /* 忽略尺寸同步异常 */
- }
- });
- stageResizeObserver.observe(stageRef.value);
- }
- function createNodeOptions() {
- return {
- type: performanceMode.value ? 'ai-graph-performance-node' : 'ai-graph-node',
- style: (node: any) => {
- const meta = getEntityMeta(String(node.data?.kind || 'unknown'));
- const hidden = Boolean(node.data?.filterHidden);
- const dimmed = Boolean(node.data?.dimmed);
- if (performanceMode.value) {
- return {
- size: 22,
- fill: meta.color,
- fillOpacity: hidden ? 0 : dimmed ? 0.18 : 0.95,
- stroke: 'transparent',
- strokeOpacity: hidden ? 0 : 1,
- lineWidth: 0,
- cursor: 'pointer',
- iconSrc: meta.icon,
- iconOpacity: hidden ? 0 : dimmed ? 0.18 : 0.96,
- labelText: String(node.data?.label || node.id),
- labelFill: '#334155',
- labelFontSize: 11,
- labelOpacity: hidden ? 0 : dimmed ? 0.18 : 0.82,
- };
- }
- return {
- size: 96,
- iconBoxSize: 40,
- iconBoxRadius: 11,
- iconSize: 32,
- fill: meta.color,
- fillOpacity: hidden ? 0 : dimmed ? 0.18 : 1,
- stroke: 'rgba(255,255,255,0.22)',
- strokeOpacity: hidden ? 0 : 1,
- lineWidth: 1,
- opacity: hidden ? 0 : 1,
- cursor: 'pointer',
- iconSrc: meta.icon,
- iconOpacity: hidden ? 0 : dimmed ? 0.24 : 1,
- labelText: String(node.data?.label || node.id),
- labelFill: '#334155',
- labelFontSize: 13,
- labelFontWeight: 600,
- labelOpacity: hidden ? 0 : dimmed ? 0.24 : 1,
- secondaryLabelText: String(node.data?.secondaryLabel || ''),
- secondaryLabelFill: '#94a3b8',
- secondaryLabelFontSize: 11,
- secondaryLabelOpacity: hidden ? 0 : dimmed ? 0.2 : 1,
- };
- },
- state: {
- selected: {
- halo: true,
- haloStroke: '#1677ff',
- haloLineWidth: 2,
- stroke: '#1677ff',
- strokeOpacity: 1,
- lineWidth: 3,
- fillOpacity: 1,
- },
- },
- };
- }
- function createEdgeOptions() {
- return {
- type: (edge: any) => (performanceMode.value ? 'ai-graph-performance-edge' : String(edge.type || 'line')),
- style: (edge: any) => {
- const hidden = Boolean(edge.data?.filterHidden);
- const dimmed = Boolean(edge.data?.dimmed);
- if (performanceMode.value) {
- return {
- stroke: '#d9d9df',
- lineWidth: 0.8,
- opacity: hidden ? 0 : dimmed ? 0.12 : 0.78,
- cursor: 'pointer',
- labelText: String(edge.data?.label || ''),
- labelFill: '#516076',
- labelFontSize: 9,
- labelOpacity: hidden ? 0 : dimmed ? 0.12 : 0.62,
- };
- }
- return {
- stroke: '#d9d9df',
- lineWidth: 1.6,
- opacity: hidden ? 0 : dimmed ? 0.18 : 1,
- cursor: 'pointer',
- startArrow: false,
- endArrow: true,
- endArrowOffset: 12,
- endArrowFill: '#d9d9df',
- label: true,
- labelText: String(edge.data?.label || ''),
- labelPlacement: 'center',
- labelFill: '#516076',
- labelFontSize: 10,
- labelOpacity: hidden ? 0 : dimmed ? 0.18 : 1,
- labelBackground: true,
- labelBackgroundFill: 'rgba(255,255,255,0.92)',
- labelBackgroundRadius: 4,
- };
- },
- state: {
- selected: {
- stroke: '#2563eb',
- lineWidth: 3,
- endArrowFill: '#2563eb',
- },
- },
- };
- }
- async function rebuild() {
- const element = stageRef.value;
- if (!element || !props.model.nodes.length) return;
- building.value = true;
- await nextTick();
- if (!stageRef.value) {
- building.value = false;
- return;
- }
- try {
- destroyGraph();
- const size = getCanvasSize();
- const data = buildG6Data(props.model);
- const positions = layoutGraph(props.model, size);
- (data.nodes || []).forEach((node) => {
- const position = positions.get(String(node.id));
- if (!position) return;
- node.style = {
- ...(node.style || {}),
- x: position.x,
- y: position.y,
- } as any;
- });
- performanceMode.value = props.model.nodes.length > 800 || props.model.edges.length > 1000;
- const graph = new Graph({
- container: element,
- width: size.width,
- height: size.height,
- autoResize: true,
- animation: false,
- data,
- node: createNodeOptions() as any,
- edge: createEdgeOptions() as any,
- behaviors: [
- {
- type: 'drag-canvas',
- enable: (event: any) => event?.targetType === 'canvas',
- },
- 'zoom-canvas',
- ],
- plugins: performanceMode.value
- ? [createContextMenuPlugin()]
- : [{ type: 'grid-line', size: 24, stroke: '#e2e8f0', lineWidth: 1 }, createContextMenuPlugin()],
- } as any);
- graphRef.value = graph;
- bindGraphEvents(graph);
- await graph.render();
- observeStageSize();
- await graph.fitView({ when: 'always' }, false);
- refreshZoom();
- } catch (error) {
- console.error('render ai graph failed', error);
- showMessage('图谱渲染失败,请稍后重试', 'warning');
- } finally {
- building.value = false;
- }
- }
- let lastElementClickAt = 0;
- function bindGraphEvents(graph: Graph) {
- graph.on<IElementEvent>(NodeEvent.CLICK, (event: IElementEvent) => {
- const id = String(event.target?.id || '');
- if (!id) return;
- lastElementClickAt = Date.now();
- const node = nodeById.value.get(id) || null;
- selectedNode.value = node;
- selectedEdge.value = null;
- void markSelected(id);
- });
- graph.on<IElementEvent>(EdgeEvent.CLICK, (event: IElementEvent) => {
- const id = String(event.target?.id || '');
- if (!id) return;
- lastElementClickAt = Date.now();
- const edge = edgeById.value.get(id) || null;
- selectedEdge.value = edge;
- selectedNode.value = null;
- void markSelected(id);
- });
- graph.on(CanvasEvent.CLICK, () => {
- window.setTimeout(() => {
- // 部分 G6 版本里节点/边点击也会冒泡到 canvas;刚点过元素时不要清掉侧栏
- if (Date.now() - lastElementClickAt < 120) return;
- clearSelected();
- }, 0);
- });
- graph.on(GraphEvent.AFTER_TRANSFORM, refreshZoom);
- }
- async function clearElementStates() {
- const graph = graphRef.value;
- if (!graph) return;
- try {
- graph.getElementDataByState('node', 'selected').forEach((node) => {
- void graph.setElementState(String(node.id), [], false);
- });
- graph.getElementDataByState('edge', 'selected').forEach((edge) => {
- void graph.setElementState(String(edge.id), [], false);
- });
- } catch {
- /* 忽略状态异常 */
- }
- }
- async function markSelected(id: string) {
- const graph = graphRef.value;
- if (!graph) return;
- await clearElementStates();
- try {
- await graph.setElementState(id, ['selected'], false);
- } catch {
- /* 忽略状态异常 */
- }
- }
- function clearSelected() {
- selectedNode.value = null;
- selectedEdge.value = null;
- void clearElementStates();
- }
- /* ---------------- 工具栏 ---------------- */
- function refreshZoom() {
- const graph = graphRef.value;
- if (!graph) return;
- try {
- const zoom = Number((graph as any).getZoom?.() ?? 1);
- zoomPercent.value = Math.round((Number.isFinite(zoom) && zoom > 0 ? zoom : 1) * 100);
- } catch {
- zoomPercent.value = 100;
- }
- }
- async function zoomIn() {
- try {
- await graphRef.value?.zoomBy(1.12, { duration: 180 });
- refreshZoom();
- } catch {
- /* 忽略 */
- }
- }
- async function zoomOut() {
- try {
- await graphRef.value?.zoomBy(0.88, { duration: 180 });
- refreshZoom();
- } catch {
- /* 忽略 */
- }
- }
- async function fitView() {
- try {
- await graphRef.value?.fitView({ when: 'always' }, false);
- refreshZoom();
- } catch {
- /* 忽略 */
- }
- }
- async function centerView() {
- try {
- await graphRef.value?.fitCenter(false);
- refreshZoom();
- } catch {
- /* 忽略 */
- }
- }
- async function relayout() {
- const graph = graphRef.value;
- if (!graph) return;
- try {
- const positions = layoutGraph(props.model, getCanvasSize());
- graph.updateNodeData(
- graph.getNodeData().map((node) => {
- const position = positions.get(String(node.id));
- return {
- id: node.id,
- style: {
- ...(node.style || {}),
- x: position?.x,
- y: position?.y,
- },
- };
- }) as any,
- );
- await graph.draw();
- await graph.fitView({ when: 'always' }, false);
- refreshZoom();
- } catch (error) {
- console.error('relayout ai graph failed', error);
- }
- }
- async function resizeAndFit() {
- await nextTick();
- // 等一帧让 autoResize 完成尺寸测量,否则全屏后会按旧尺寸 fit
- await new Promise<void>((resolve) => {
- if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => resolve());
- else window.setTimeout(resolve, 0);
- });
- await fitView();
- }
- defineExpose({ fitView, relayout, resizeAndFit });
- /* ---------------- 查找实现 ---------------- */
- function nodeSearchTexts(node: any): string[] {
- return [
- String(node.data?.label || ''),
- String(node.data?.secondaryLabel || ''),
- String(node.data?.fullLabel || ''),
- String(node.id || ''),
- ];
- }
- function edgeNumericValues(edge: any): number[] {
- const raw = (edge.data?.rawData || {}) as Record<string, any>;
- const candidates = [raw.money, raw.num, raw.nums, raw.value, edge.data?.label];
- const values: number[] = [];
- candidates.forEach((candidate) => {
- if (candidate === undefined || candidate === null || candidate === '') return;
- const matches = String(candidate).match(/-?\d+(?:\.\d+)?/g);
- (matches || []).forEach((text) => {
- const numeric = Number(text);
- if (Number.isFinite(numeric)) values.push(numeric);
- });
- });
- return Array.from(new Set(values));
- }
- function hasNumericValue(value: number | null | undefined): value is number {
- return value !== undefined && value !== null && Number.isFinite(Number(value));
- }
- function findElements(): string[] {
- const graph = graphRef.value;
- if (!graph) return [];
- if (searchForm.scope === 'entity') {
- const keyword = searchForm.keyword.trim();
- if (!keyword) return [];
- const normalizedKeyword = keyword.toLowerCase();
- return graph
- .getNodeData()
- .filter((node) =>
- nodeSearchTexts(node).some((text) => {
- const value = String(text || '').trim();
- if (!value) return false;
- return searchForm.manner === 'precise'
- ? value === keyword
- : value.toLowerCase().includes(normalizedKeyword);
- }),
- )
- .map((node) => String(node.id));
- }
- const min = hasNumericValue(searchForm.min) ? Number(searchForm.min) : undefined;
- const max = hasNumericValue(searchForm.max) ? Number(searchForm.max) : undefined;
- if (min === undefined && max === undefined) return [];
- return graph
- .getEdgeData()
- .filter((edge) => {
- const values = edgeNumericValues(edge);
- if (!values.length) return false;
- return values.some((value) => {
- if (min !== undefined && value < min) return false;
- if (max !== undefined && value > max) return false;
- return true;
- });
- })
- .map((edge) => String(edge.id || ''))
- .filter(Boolean);
- }
- async function focusSearchResult(index: number) {
- const id = searchResults.value[index];
- const graph = graphRef.value;
- if (!id || !graph) return;
- await clearElementStates();
- try {
- await graph.setElementState(id, ['selected'], false);
- await graph.focusElement(id, { animation: { duration: 240 } } as any);
- } catch {
- /* 忽略 */
- }
- if (searchForm.scope === 'entity') {
- selectedNode.value = nodeById.value.get(id) || null;
- selectedEdge.value = null;
- } else {
- selectedEdge.value = edgeById.value.get(id) || null;
- selectedNode.value = null;
- }
- }
- async function runSearch() {
- if (searchForm.scope === 'entity') {
- if (!searchForm.keyword.trim()) {
- showMessage('请输入查找内容', 'warning');
- return;
- }
- } else {
- const min = hasNumericValue(searchForm.min) ? Number(searchForm.min) : undefined;
- const max = hasNumericValue(searchForm.max) ? Number(searchForm.max) : undefined;
- if (min === undefined && max === undefined) {
- showMessage('请输入关系数值范围', 'warning');
- return;
- }
- if (min !== undefined && max !== undefined && min > max) {
- showMessage('最小值不能大于最大值', 'warning');
- return;
- }
- }
- searchResults.value = findElements();
- searchIndex.value = searchResults.value.length ? 0 : -1;
- if (!searchResults.value.length) {
- showMessage('未找到匹配结果', 'warning');
- return;
- }
- await focusSearchResult(0);
- }
- async function prevSearchResult() {
- if (!searchResults.value.length) return;
- searchIndex.value = (searchIndex.value - 1 + searchResults.value.length) % searchResults.value.length;
- await focusSearchResult(searchIndex.value);
- }
- async function nextSearchResult() {
- if (!searchResults.value.length) return;
- searchIndex.value = (searchIndex.value + 1) % searchResults.value.length;
- await focusSearchResult(searchIndex.value);
- }
- async function selectAllSearchResults() {
- const graph = graphRef.value;
- if (!graph || !searchResults.value.length) return;
- await clearElementStates();
- for (const id of searchResults.value) {
- try {
- await graph.setElementState(id, ['selected'], false);
- } catch {
- /* 忽略 */
- }
- }
- }
- /* ---------------- 过滤实现 ---------------- */
- function compareValue(value: number, operator: string, target: number): boolean {
- if (operator === '>') return value > target;
- if (operator === '>=') return value >= target;
- if (operator === '=') return value === target;
- if (operator === '<') return value < target;
- return value <= target;
- }
- function resetFilterState() {
- filterMatchedNodeIds.value = [];
- filterMutedNodeIds.value = [];
- filterMatchedEdgeIds.value = [];
- filterHidden.value = false;
- filterForm.kinds = [];
- filterForm.condition = 'neighborTotal';
- filterForm.operator = '>';
- filterForm.value = undefined;
- }
- async function applyFilterPresentation() {
- const graph = graphRef.value;
- if (!graph) return;
- const mutedNodeSet = new Set(filterMutedNodeIds.value);
- const matchedNodeSet = new Set(filterMatchedNodeIds.value);
- const matchedEdgeSet = new Set(filterMatchedEdgeIds.value);
- const hiddenNodeSet = filterHidden.value ? mutedNodeSet : new Set<string>();
- graph.updateNodeData(
- graph.getNodeData().map((node) => ({
- id: node.id,
- data: {
- ...(node.data || {}),
- dimmed: mutedNodeSet.has(String(node.id)),
- filterHidden: hiddenNodeSet.has(String(node.id)),
- },
- })) as any,
- );
- graph.updateEdgeData(
- graph.getEdgeData().map((edge) => ({
- id: edge.id,
- data: {
- ...(edge.data || {}),
- dimmed: matchedNodeSet.size ? !matchedEdgeSet.has(String(edge.id)) : false,
- filterHidden:
- filterHidden.value &&
- (!matchedEdgeSet.has(String(edge.id)) ||
- hiddenNodeSet.has(String(edge.source)) ||
- hiddenNodeSet.has(String(edge.target))),
- },
- })) as any,
- );
- graph.setNode(createNodeOptions() as any);
- graph.setEdge(createEdgeOptions() as any);
- await graph.draw();
- if (matchedNodeSet.size) {
- await clearElementStates();
- matchedNodeSet.forEach((id) => {
- void graph.setElementState(id, ['selected'], false);
- });
- }
- }
- async function executeFilter() {
- const graph = graphRef.value;
- if (!graph) return;
- if (!filterForm.kinds.length) {
- showMessage('请选择过滤实体', 'warning');
- return;
- }
- if (!hasNumericValue(filterForm.value)) {
- showMessage('请输入设定结果', 'warning');
- return;
- }
- const targetKinds = new Set(filterForm.kinds);
- const targetValue = Number(filterForm.value);
- const edgeData = graph.getEdgeData();
- const matchedNodeIds: string[] = [];
- const mutedNodeIds: string[] = [];
- const matchedEdgeIds = new Set<string>();
- graph.getNodeData().forEach((node) => {
- const nodeId = String(node.id);
- if (!targetKinds.has(String(node.data?.kind || 'unknown'))) {
- mutedNodeIds.push(nodeId);
- return;
- }
- const connectedEdges = edgeData.filter(
- (edge) => String(edge.source) === nodeId || String(edge.target) === nodeId,
- );
- const matched =
- filterForm.condition === 'neighborTotal'
- ? compareValue(connectedEdges.length, filterForm.operator, targetValue)
- : connectedEdges.some((edge) =>
- edgeNumericValues(edge).some((value) => compareValue(value, filterForm.operator, targetValue)),
- );
- if (matched) {
- matchedNodeIds.push(nodeId);
- connectedEdges.forEach((edge) => {
- if (edge.id) matchedEdgeIds.add(String(edge.id));
- });
- } else {
- mutedNodeIds.push(nodeId);
- }
- });
- filterMatchedNodeIds.value = matchedNodeIds;
- filterMutedNodeIds.value = mutedNodeIds;
- filterMatchedEdgeIds.value = [...matchedEdgeIds];
- filterHidden.value = false;
- await applyFilterPresentation();
- showMessage(`过滤完成:匹配 ${matchedNodeIds.length} 个实体`, 'success');
- }
- async function cancelFilter() {
- filterMatchedNodeIds.value = [];
- filterMutedNodeIds.value = [];
- filterMatchedEdgeIds.value = [];
- filterHidden.value = false;
- await applyFilterPresentation();
- }
- async function selectFiltered() {
- const graph = graphRef.value;
- if (!graph || !filterMatchedNodeIds.value.length) return;
- await clearElementStates();
- filterMatchedNodeIds.value.forEach((id) => {
- void graph.setElementState(id, ['selected'], false);
- });
- }
- async function toggleFilterHidden() {
- filterHidden.value = !filterHidden.value;
- await applyFilterPresentation();
- }
- </script>
- <style lang="less" scoped>
- .ai-graph-viewer {
- display: flex;
- flex-direction: column;
- width: 100%;
- height: 100%;
- min-height: 0;
- background: var(--ai-bg, #fff);
- &__toolbar {
- position: relative;
- flex: 0 0 auto;
- padding: 6px 8px;
- border-bottom: 1px solid var(--ai-border, #e5e7eb);
- }
- &__tools {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 6px;
- }
- &__divider {
- width: 1px;
- height: 16px;
- margin: 0 2px;
- background: var(--ai-border, #e5e7eb);
- }
- &__zoom {
- min-width: 40px;
- font-size: 11px;
- color: var(--ai-text-2, #64748b);
- font-variant-numeric: tabular-nums;
- text-align: center;
- }
- &__filter-count {
- margin-left: auto;
- font-size: 11px;
- color: var(--ai-primary, #2a50ec);
- }
- &__panel {
- position: absolute;
- top: 38px;
- left: 8px;
- z-index: 6;
- width: min(380px, calc(100% - 16px));
- padding: 10px;
- background: var(--ai-bg, #fff);
- border: 1px solid var(--ai-border, #e5e7eb);
- border-radius: 8px;
- box-shadow: var(--ai-shadow-lg, 0 8px 24px rgba(15, 23, 42, 0.12));
- }
- &__input {
- width: 100%;
- margin: 8px 0;
- }
- &__select {
- width: 100%;
- margin-bottom: 8px;
- }
- &__range-row {
- display: flex;
- align-items: center;
- gap: 6px;
- margin: 8px 0;
- }
- &__range-sep {
- font-size: 12px;
- color: var(--ai-text-2, #64748b);
- }
- &__number {
- flex: 1 1 0;
- min-width: 0;
- }
- &__switch-row {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 6px;
- margin: 6px 0;
- }
- &__switch {
- padding: 3px 8px;
- font-size: 12px;
- color: var(--ai-text-2, #64748b);
- background: transparent;
- border: 1px solid var(--ai-border, #e5e7eb);
- border-radius: 6px;
- cursor: pointer;
- &.is-active {
- color: var(--ai-primary, #2a50ec);
- border-color: var(--ai-primary, #2a50ec);
- background: var(--ai-primary-soft, rgba(42, 80, 236, 0.08));
- }
- &--compact {
- min-width: 34px;
- padding: 3px 6px;
- text-align: center;
- }
- }
- &__panel-footer {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 6px;
- margin-top: 8px;
- }
- &__panel-result {
- margin-right: auto;
- font-size: 11px;
- color: var(--ai-text-2, #64748b);
- }
- &__body {
- position: relative;
- display: flex;
- flex: 1 1 auto;
- min-height: 0;
- }
- &__stage {
- flex: 1 1 auto;
- min-width: 0;
- min-height: 320px;
- background: var(--ai-bg-subtle, #f7f8fa);
- }
- &__overlay {
- position: absolute;
- inset: 0;
- z-index: 4;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- font-size: 12px;
- color: var(--ai-text-2, #64748b);
- background: rgba(255, 255, 255, 0.72);
- pointer-events: none;
- }
- &__spinner {
- width: 14px;
- height: 14px;
- border: 2px solid var(--ai-border, #e5e7eb);
- border-top-color: var(--ai-primary, #2a50ec);
- border-radius: 50%;
- animation: ai-graph-spin 0.9s linear infinite;
- }
- &__aside {
- flex: 0 0 240px;
- width: 240px;
- padding: 10px 12px;
- overflow-y: auto;
- background: var(--ai-bg, #fff);
- border-left: 1px solid var(--ai-border, #e5e7eb);
- }
- &__aside-head {
- display: flex;
- align-items: flex-start;
- gap: 8px;
- margin-bottom: 8px;
- }
- &__aside-name {
- flex: 1 1 auto;
- min-width: 0;
- font-size: 13px;
- font-weight: 600;
- color: var(--ai-text, #1e293b);
- word-break: break-all;
- }
- &__link {
- flex: 0 0 auto;
- padding: 0;
- font-size: 11px;
- color: var(--ai-text-2, #64748b);
- background: transparent;
- border: 0;
- cursor: pointer;
- &:hover {
- color: var(--ai-primary, #2a50ec);
- }
- }
- &__attrs {
- margin: 0;
- font-size: 12px;
- dt {
- margin-top: 8px;
- color: var(--ai-text-2, #64748b);
- }
- dd {
- margin: 2px 0 0;
- color: var(--ai-text, #1e293b);
- word-break: break-all;
- }
- }
- &__actions {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-top: 12px;
- padding-top: 8px;
- border-top: 1px dashed var(--ai-border, #e5e7eb);
- }
- }
- /* ---------- 原对象关系分析页的只读工具栏 / 画布 / 状态栏样式 ---------- */
- .relation-stage {
- flex: 1 1 auto;
- width: 100%;
- min-height: 0;
- overflow: hidden;
- }
- .relation-stage__canvas {
- position: relative;
- width: 100%;
- height: 100%;
- min-height: 0;
- overflow: hidden;
- border: 1px solid rgba(148, 163, 184, 0.24);
- background: #f4f7fb;
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.75),
- 0 18px 36px rgba(15, 23, 42, 0.06);
- }
- .relation-stage__graph {
- position: relative;
- z-index: 1;
- width: 100%;
- height: 100%;
- contain: layout;
- :deep(.g6-canvas),
- :deep(canvas) {
- transform: translateZ(0);
- backface-visibility: hidden;
- }
- }
- .relation-stage__layout-loading {
- position: absolute;
- inset: 0;
- z-index: 60;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(244, 247, 251, 0.82);
- }
- .relation-stage__layout-loading-card {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
- padding: 18px 24px;
- border: 1px solid rgba(148, 163, 184, 0.22);
- border-radius: 14px;
- background: rgba(255, 255, 255, 0.96);
- box-shadow: 0 16px 34px rgba(15, 23, 42, 0.1);
- }
- .relation-stage__layout-loading-spinner {
- width: 22px;
- height: 22px;
- border: 2px solid rgba(37, 99, 235, 0.18);
- border-top-color: #2563eb;
- border-radius: 50%;
- animation: ai-graph-spin 0.8s linear infinite;
- }
- .relation-stage__layout-loading-title {
- font-size: 13px;
- font-weight: 600;
- color: #334155;
- }
- .relation-stage__layout-loading-desc {
- font-size: 12px;
- color: #94a3b8;
- }
- .control-tools-box {
- position: absolute;
- top: 24px;
- right: 24px;
- z-index: 40;
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 12px;
- max-width: calc(100% - 48px);
- pointer-events: none;
- }
- .custom-atlas-tools-container {
- position: relative;
- overflow: visible;
- border-radius: 10px;
- pointer-events: auto;
- max-width: min(760px, calc(100vw - 360px));
- ul {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- flex-wrap: wrap;
- margin: 0;
- padding: 0;
- list-style: none;
- }
- }
- .tool-item {
- box-sizing: border-box;
- width: 40px;
- height: 44px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: rgba(255, 255, 255, 0.96);
- border: 1px solid rgba(148, 163, 184, 0.18);
- box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08);
- cursor: pointer;
- transition:
- background-color 0.2s ease,
- box-shadow 0.2s ease,
- transform 0.2s ease;
- &:first-child {
- border-top-left-radius: 8px;
- border-bottom-left-radius: 8px;
- }
- &:last-child {
- border-top-right-radius: 8px;
- border-bottom-right-radius: 8px;
- }
- &:hover {
- background-color: #e8ebfb;
- box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12);
- transform: translateY(-1px);
- .icon-style {
- color: #2563eb;
- }
- }
- }
- .tool-item.is-active {
- background-color: #e8ebfb;
- box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12);
- .icon-style {
- color: #2563eb;
- }
- }
- .tool-item-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- }
- .gap-item {
- margin-right: 16px;
- border-top-right-radius: 8px;
- border-bottom-right-radius: 8px;
- & + .tool-item {
- border-top-left-radius: 8px;
- border-bottom-left-radius: 8px;
- }
- }
- .icon-style {
- font-size: 20px;
- color: #303133;
- transition: color 0.2s ease;
- }
- .tool-panel {
- width: min(360px, calc(100vw - 48px));
- padding: 14px;
- border-radius: 16px;
- border: 1px solid rgba(148, 163, 184, 0.2);
- background: rgba(255, 255, 255, 0.96);
- box-shadow: 0 16px 34px rgba(15, 23, 42, 0.12);
- pointer-events: auto;
- }
- .tool-panel :deep(.ant-input),
- .tool-panel :deep(.ant-select),
- .tool-panel :deep(.ant-input-number) {
- width: 100%;
- }
- .tool-panel__row {
- width: 100%;
- display: block;
- }
- .tool-panel__entity-select {
- display: block;
- width: 100%;
- min-width: 0;
- }
- .tool-panel__header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 10px;
- }
- .tool-panel__title {
- font-size: 13px;
- font-weight: 600;
- color: #334155;
- }
- .tool-panel__row + .tool-panel__row,
- .tool-panel__row + .tool-panel__switch-row,
- .tool-panel__switch-row + .tool-panel__row,
- .tool-panel__switch-row + .tool-panel__switch-row {
- margin-top: 10px;
- }
- .tool-panel__switch-row {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 8px;
- }
- .tool-panel__range-row {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .tool-panel__range-separator {
- color: #94a3b8;
- font-size: 12px;
- }
- .tool-panel__switch {
- min-width: 76px;
- height: 32px;
- padding: 0 12px;
- border-radius: 8px;
- border: 1px solid rgba(148, 163, 184, 0.28);
- background: #fff;
- color: #475569;
- font-size: 12px;
- cursor: pointer;
- &.is-active {
- border-color: #2563eb;
- background: #eff6ff;
- color: #1d4ed8;
- }
- }
- .tool-panel__switch--compact {
- min-width: 42px;
- padding: 0 8px;
- }
- .tool-panel__number {
- width: 100px;
- }
- .tool-panel__footer {
- margin-top: 12px;
- }
- .tool-panel__result {
- margin-bottom: 8px;
- font-size: 12px;
- color: #64748b;
- }
- .tool-panel__actions {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- }
- .canvas-toolbar {
- position: relative;
- z-index: 1;
- display: flex;
- align-items: center;
- justify-content: flex-end;
- flex-wrap: wrap;
- gap: 4px;
- max-width: calc(100vw - 48px);
- padding: 7px;
- border-radius: 16px;
- border: 1px solid rgba(148, 163, 184, 0.2);
- background: rgba(255, 255, 255, 0.97);
- box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08);
- pointer-events: auto;
- }
- .canvas-toolbar__button {
- width: 32px;
- height: 32px;
- padding: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border-radius: 10px;
- color: #334155;
- &:hover:not(:disabled) {
- color: #2563eb;
- background: rgba(59, 130, 246, 0.1);
- }
- &.is-active {
- color: #2563eb;
- background: rgba(59, 130, 246, 0.12);
- }
- }
- .canvas-toolbar__divider {
- width: 1px;
- height: 18px;
- background: rgba(148, 163, 184, 0.35);
- margin: 0 2px;
- }
- .canvas-status {
- position: absolute;
- left: 18px;
- bottom: 14px;
- z-index: 20;
- display: flex;
- flex-wrap: wrap;
- gap: 14px;
- font-size: 11px;
- color: #64748b;
- pointer-events: none;
- }
- .analysis-detail__summary {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
- gap: 12px;
- }
- .analysis-detail__entry {
- padding: 10px 12px;
- border: 1px solid rgba(148, 163, 184, 0.2);
- border-radius: 10px;
- background: #f8fafc;
- }
- .analysis-detail__entry-label {
- font-size: 11px;
- color: #94a3b8;
- }
- .analysis-detail__entry-value {
- margin-top: 4px;
- font-size: 13px;
- color: #334155;
- word-break: break-all;
- }
- @keyframes ai-graph-spin {
- to {
- transform: rotate(360deg);
- }
- }
- </style>
|