| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977 |
- <template>
- <div :class="prefixCls" class="card-holding-page">
- <div class="main-content">
- <div class="page-header">
- <div class="page-title">银行卡代持分析</div>
- </div>
- <div class="filter-bar">
- <template v-if="activeMenu === 'consistentAction'">
- <div class="filter-item">
- <span class="filter-label">调查分组:</span>
- <Select
- v-model:value="consistentFilters.groupId"
- class="filter-group"
- :options="consistentGroupOptions"
- placeholder="请选择调查分组"
- />
- </div>
- <div class="filter-item">
- <span class="filter-label">时间间隔:</span>
- <span class="filter-fixed-op"><=</span>
- <Input
- v-model:value="consistentFilters.dayGap"
- class="filter-micro"
- :status="dayGapStatus"
- placeholder="请输入"
- />
- <span class="filter-unit">天</span>
- </div>
- <div class="filter-item">
- <span class="filter-label">大额金额:</span>
- <Select v-model:value="consistentFilters.moneyOp" class="filter-op" :options="cardHoldCompareOptions" />
- <Input
- v-model:value="consistentFilters.money"
- class="filter-mini"
- :status="consistentMoneyStatus"
- placeholder="请输入金额"
- />
- </div>
- <div class="filter-item">
- <span class="filter-label">共同交易卡数量:</span>
- <Select v-model:value="consistentFilters.cardNumOp" class="filter-op" :options="cardHoldCompareOptions" />
- <Input
- v-model:value="consistentFilters.cardNum"
- class="filter-mini"
- :status="cardNumStatus"
- placeholder="请输入数量"
- />
- </div>
- </template>
- <template v-else>
- <div class="filter-item">
- <span class="filter-label">调查分组:</span>
- <Select
- v-model:value="sameFeatureFilters.group"
- class="filter-group"
- :options="sameFeatureGroupOptions"
- placeholder="请选择调查分组"
- />
- </div>
- <div class="filter-item">
- <span class="filter-label">大额金额:</span>
- <Select v-model:value="sameFeatureFilters.amountOp" class="filter-op" :options="cardHoldCompareOptions" />
- <Input
- v-model:value="sameFeatureFilters.amountValue"
- class="filter-mini"
- :status="amountValueStatus"
- placeholder="请输入金额"
- />
- </div>
- <div class="filter-item">
- <span class="filter-label">交易日期:</span>
- <RangePicker
- v-model:value="sameFeatureFilters.dateRange"
- class="filter-date"
- :format="DATE_FORMAT"
- :value-format="DATE_FORMAT"
- :placeholder="['开始日期', '结束日期']"
- />
- </div>
- </template>
- <div class="filter-actions">
- <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
- <Button @click="handleReset">重置</Button>
- </div>
- </div>
- <div class="view-panel">
- <div class="left-sidebar">
- <div class="menu-list">
- <button
- v-for="menu in menuOptions"
- :key="menu.key"
- type="button"
- class="menu-item"
- :class="{ active: activeMenu === menu.key }"
- @click="handleMenuChange(menu.key)"
- >
- <span class="menu-item__label">{{ menu.label }}</span>
- <span class="menu-item__desc">{{ menu.description }}</span>
- </button>
- </div>
- </div>
- <div class="view-main">
- <div v-if="activeMenu === 'sameFeature'" class="analysis-card">
- <div class="panel-header">
- <div>
- <div class="panel-title">异卡同特征关系视图</div>
- <div class="panel-desc">{{ sameFeatureDescription }}</div>
- </div>
- <div class="same-feature-header-extra">
- <div v-if="sameFeatureStats.length" class="panel-stat-list same-feature-stat-list">
- <div v-for="item in sameFeatureStats" :key="item.label" class="panel-stat">
- <div class="panel-stat__label">{{ item.label }}</div>
- <div class="panel-stat__value">{{ item.value }}</div>
- </div>
- </div>
- <div class="same-feature-legend">
- <span class="same-feature-legend__item">
- <span class="same-feature-legend__dot same-feature-legend__dot--subject"></span>
- 主体
- </span>
- <span class="same-feature-legend__item">
- <span class="same-feature-legend__dot same-feature-legend__dot--feature"></span>
- 特征银行卡
- </span>
- <span class="same-feature-legend__item">
- <span class="same-feature-legend__dot same-feature-legend__dot--holder"></span>
- 持卡人
- </span>
- </div>
- </div>
- </div>
- <div v-if="!hasQueryResult || !activeSameFeatureScene?.links.length" class="empty-panel">
- <EmptyState
- :variant="hasQueryResult ? 'default' : 'query'"
- :title="hasQueryResult ? '暂无数据' : '请选择调查分组后查询'"
- :description="sameFeatureEmptyDescription"
- :min-height="hasQueryResult ? 220 : 320"
- :description-width="360"
- />
- </div>
- <template v-else>
- <div class="board-scroll board-scroll--plain same-feature-board-scroll">
- <div
- ref="sameFeatureBoardRef"
- class="same-feature-board"
- :style="{ height: `${sameFeatureBoardHeight}px` }"
- >
- <svg class="relation-svg" :width="sameFeatureBoardWidth" :height="sameFeatureBoardHeight">
- <path :d="sameFeatureSubjectPath" class="same-feature-path same-feature-path--subject" />
- <path
- v-for="path in sameFeatureFeaturePaths"
- :key="path.key"
- :d="path.d"
- class="same-feature-path same-feature-path--subject"
- />
- <path
- v-for="path in sameFeatureHolderPaths"
- :key="path.key"
- :d="path.d"
- class="same-feature-path same-feature-path--holder"
- />
- </svg>
- <div class="same-feature-main" :style="toRectStyle(sameFeatureSubjectNode)">
- <div class="same-feature-main__tag">主体</div>
- <div class="same-feature-main__body">
- <PersonAvatar class="same-feature-main__avatar" :size="54" />
- <div class="same-feature-main__content">
- <div class="same-feature-main__name">{{ activeSameFeatureScene.subject.name }}</div>
- <div class="same-feature-main__hint">异卡同特征分析对象</div>
- </div>
- </div>
- </div>
- <div
- v-for="group in sameFeatureFeatureLabelGroups"
- :key="group.key"
- class="same-feature-edge-tags"
- :style="{ left: `${group.x}px`, top: `${group.y}px`, maxWidth: `${group.maxWidth}px` }"
- >
- <span
- v-for="feature in group.features"
- :key="`${group.key}-${feature}`"
- class="same-feature-edge-tags__tag"
- >
- {{ feature }}
- </span>
- </div>
- <button
- v-for="node in sameFeatureFeatureNodes"
- :key="node.link.id"
- type="button"
- class="same-feature-card-node"
- :style="toRectStyle(node)"
- @click="handleFeatureCardClick(node.link)"
- >
- <div class="same-feature-card-node__header">
- <div class="same-feature-card-node__icon">
- <CreditCardOutlined />
- </div>
- <div class="same-feature-card-node__bank" :title="node.link.bankName || '特征银行卡'">
- {{ node.link.bankName || '特征银行卡' }}
- </div>
- </div>
- <div class="same-feature-card-node__number" :title="node.link.cardNo">
- {{ formatCardDisplay(node.link.cardNo) }}
- </div>
- <div class="same-feature-card-node__footer">
- <div class="same-feature-card-node__meta">命中特征 {{ node.link.features.length }} 项</div>
- <div v-if="node.link.amount !== undefined" class="same-feature-card-node__amount">
- 金额 {{ formatMoneyText(node.link.amount) }}
- </div>
- </div>
- </button>
- <div
- v-for="node in sameFeatureHolderNodes"
- :key="node.holder.id"
- class="same-feature-member"
- :style="toRectStyle(node)"
- >
- <PersonAvatar class="same-feature-member__avatar" :size="44" />
- <div class="same-feature-member__info">
- <div class="same-feature-member__name">{{ node.holder.name }}</div>
- <div v-if="node.holder.alias" class="same-feature-member__alias">{{ node.holder.alias }}</div>
- </div>
- </div>
- <div
- v-for="label in sameFeatureHolderLabels"
- :key="label.key"
- class="same-feature-cardholder-label"
- :style="{ left: `${label.x}px`, top: `${label.y}px` }"
- >
- {{ label.text }}
- </div>
- </div>
- </div>
- </template>
- </div>
- <div v-else-if="activeMenu === 'consistentAction'" class="analysis-card consistent-action-card">
- <div class="panel-header">
- <div>
- <div class="panel-title">多卡汇集关系视图</div>
- <div class="panel-desc">{{ consistentActionDescription }}</div>
- </div>
- </div>
- <div v-if="!consistentActionQueried || !consistentActionLayouts.length" class="empty-panel">
- <EmptyState
- :variant="consistentActionQueried ? 'default' : 'query'"
- :title="consistentActionQueried ? '暂无数据' : '请选择调查分组后查询'"
- :description="consistentActionEmptyDescription"
- :min-height="consistentActionQueried ? 220 : 320"
- :description-width="360"
- />
- </div>
- <template v-else>
- <div ref="consistentActionBodyRef" class="consistent-action-body">
- <section v-for="period in consistentActionLayouts" :key="period.id" class="consistent-action-section">
- <div class="consistent-action-section__title">被调查对象分组 · {{ consistentActionGroupLabel }}</div>
- <div class="board-scroll board-scroll--plain consistent-action-board-scroll">
- <div
- class="consistent-action-chart"
- :style="{ height: `${period.height}px`, width: `${consistentActionMetrics.chartWidth}px` }"
- >
- <svg
- class="consistent-action-svg"
- :width="consistentActionMetrics.chartWidth"
- :height="period.height"
- >
- <path
- v-for="path in period.rowPaths"
- :key="path.key"
- :d="path.d"
- class="consistent-action-path"
- />
- <path
- v-for="path in period.personPaths"
- :key="path.key"
- :d="path.d"
- class="consistent-action-path"
- />
- </svg>
- <template v-for="person in period.people" :key="person.id">
- <button
- v-for="row in person.rows"
- :key="row.id"
- type="button"
- class="consistent-action-row"
- :style="toRectStyle(row)"
- @click="handleConsistentActionRowClick(row.row)"
- >
- <div class="consistent-action-row__name-box">
- <span class="consistent-action-row__name">{{ row.row.objName }}</span>
- <span class="consistent-action-row__card">({{ row.row.selfCardNo }})</span>
- <span v-if="row.row.objFlag" class="consistent-action-row__flag">{{
- row.row.objFlag
- }}</span>
- </div>
- <div class="consistent-action-row__bank" :title="row.row.targetBankName">
- {{ row.row.targetBankName }}
- </div>
- <div class="consistent-action-row__money">支出: {{ row.row.dealMoneyText }}</div>
- <span class="consistent-action-row__block" :style="{ backgroundColor: row.row.color }"></span>
- </button>
- <button
- type="button"
- class="consistent-action-person"
- :style="toRectStyle(person)"
- @click="handleConsistentActionPersonClick(person.person)"
- >
- <PersonAvatar class="consistent-action-person__avatar" :size="44" />
- <div class="consistent-action-person__info">
- <div class="consistent-action-person__name">{{ person.person.name }}</div>
- <div class="consistent-action-person__card">{{ person.person.card }}</div>
- </div>
- </button>
- </template>
- <div
- class="consistent-action-date"
- :style="{ left: `${consistentActionMetrics.dateX}px`, top: `${period.dateY - 12}px` }"
- >
- {{ period.period.dateLabel }}
- </div>
- </div>
- </div>
- </section>
- </div>
- </template>
- </div>
- </div>
- </div>
- <TransRecordModal v-model:open="transRecordModalOpen" title="交易记录" :base-query="transRecordBaseQuery" />
- </div>
- </div>
- </template>
- <script lang="ts" setup name="ViewsCardHoldingIndex">
- import { CreditCardOutlined } from '@ant-design/icons-vue';
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
- import { Button, DatePicker, Input, Modal, Select } from 'ant-design-vue';
- import { useRouter } from 'vue-router';
- import { EmptyState } from '@jeesite/core/components/EmptyState';
- import { PersonAvatar } from '@jeesite/core/components/PersonAvatar';
- import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
- import { useDesign } from '@jeesite/core/hooks/web/useDesign';
- import { useMessage } from '@jeesite/core/hooks/web/useMessage';
- import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
- import {
- COMPARE_OPERATOR_OPTIONS,
- DEFAULT_COMPARE_OPERATOR,
- DEFAULT_LE_COMPARE_OPERATOR,
- normalizeCompareOperator,
- type CompareOperator,
- } from '@jeesite/core/utils/compareOperator';
- import { listPersonGroups } from '@jeesite/person/api/personApi';
- import { statTogetherDoTrans, statTransCardHold } from '@jeesite/trans/api/transApi';
- import type {
- TransCardHoldDTO,
- TransCardHoldFeatureDTO,
- TransCardHoldTogetherDTO,
- TransCardHoldTogetherItemDTO,
- TransCardHoldTogetherSummaryDTO,
- } from '@jeesite/trans/api/types/dto';
- import type {
- TransCardHoldQuery,
- TransCardHoldTogetherQuery,
- TransRecordQuery,
- } from '@jeesite/trans/api/types/query';
- import TransRecordModal from '../components/TransRecordModal.vue';
- type RangeValue = [string, string] | undefined;
- type MenuKey = 'consistentAction' | 'sameFeature';
- type StatusType = 'error' | undefined;
- interface OptionItem {
- label: string;
- value: string;
- }
- interface SameFeatureHolder {
- id: string;
- name: string;
- alias: string;
- }
- interface SameFeatureLink {
- id: string;
- cardNo: string;
- holderId: string;
- bankName?: string;
- features: string[];
- amount?: number | string;
- transDate?: string;
- ids: Array<string>;
- }
- interface SameFeatureScene {
- group: string;
- focus: string;
- subject: {
- name: string;
- };
- holders: SameFeatureHolder[];
- links: SameFeatureLink[];
- }
- interface SameFeatureQuerySnapshot {
- group: string;
- amountOp: CompareOperator;
- amountValue?: string;
- dateRange: RangeValue;
- }
- interface RectNode {
- x: number;
- y: number;
- width: number;
- height: number;
- }
- interface PathDefinition {
- key: string;
- d: string;
- }
- interface ConsistentActionFilters {
- groupId: string;
- dayGap: string;
- moneyOp: CompareOperator;
- money: string;
- cardNumOp: CompareOperator;
- cardNum: string;
- }
- interface ConsistentActionQuerySnapshot {
- groupId: string;
- groupLabel: string;
- dayGap: number;
- moneyOp: CompareOperator;
- money?: string;
- cardNumOp: CompareOperator;
- cardNum?: number;
- }
- interface ConsistentActionRowData {
- id: string;
- objName: string;
- selfCardNo: string;
- objFlag: string;
- targetBankName: string;
- dealMoneyText: string;
- ids: Array<string>;
- color: string;
- tag: string;
- }
- interface ConsistentActionPersonData {
- id: string;
- name: string;
- card: string;
- ids: Array<string>;
- rows: ConsistentActionRowData[];
- }
- interface ConsistentActionPeriodData {
- id: string;
- minDate: string;
- maxDate: string;
- dateLabel: string;
- people: ConsistentActionPersonData[];
- }
- interface ConsistentActionRowNode extends RectNode {
- id: string;
- row: ConsistentActionRowData;
- }
- interface ConsistentActionPersonNode extends RectNode {
- id: string;
- person: ConsistentActionPersonData;
- rows: ConsistentActionRowNode[];
- }
- interface ConsistentActionLayout {
- id: string;
- period: ConsistentActionPeriodData;
- people: ConsistentActionPersonNode[];
- height: number;
- dateY: number;
- rowPaths: PathDefinition[];
- personPaths: PathDefinition[];
- }
- interface ConsistentActionMetrics {
- chartWidth: number;
- dateX: number;
- personX: number;
- rowWidth: number;
- rowJoinX: number;
- dateJoinX: number;
- dateLineEndX: number;
- }
- type ConsistentActionResponseLike =
- | TransCardHoldTogetherDTO[]
- | {
- list?: TransCardHoldTogetherDTO[];
- records?: TransCardHoldTogetherDTO[];
- data?:
- | TransCardHoldTogetherDTO[]
- | {
- list?: TransCardHoldTogetherDTO[];
- records?: TransCardHoldTogetherDTO[];
- };
- }
- | null
- | undefined;
- type TransCardHoldResponseLike =
- | TransCardHoldDTO
- | {
- data?: TransCardHoldDTO;
- }
- | null
- | undefined;
- interface TextLabel {
- key: string;
- x: number;
- y: number;
- text: string;
- }
- interface FeatureTagGroup {
- key: string;
- x: number;
- y: number;
- maxWidth: number;
- features: string[];
- }
- const RangePicker = DatePicker.RangePicker;
- const DATE_FORMAT = 'YYYY-MM-DD';
- const SAME_FEATURE_MIN_WIDTH = 980;
- const SAME_FEATURE_MAIN_WIDTH = 176;
- const SAME_FEATURE_MAIN_HEIGHT = 112;
- const SAME_FEATURE_CARD_WIDTH = 228;
- const SAME_FEATURE_CARD_HEIGHT = 144;
- const SAME_FEATURE_CARD_GAP = 24;
- const SAME_FEATURE_MEMBER_WIDTH = 188;
- const SAME_FEATURE_MEMBER_HEIGHT = 76;
- const SAME_FEATURE_MEMBER_RIGHT_GAP = 44;
- const SAME_FEATURE_MAIN_X = 28;
- const SAME_FEATURE_CONTENT_TOP = 68;
- const SAME_FEATURE_BUS_GAP = 152;
- const CONSISTENT_ACTION_ROW_X = 24;
- const CONSISTENT_ACTION_ROW_MIN_WIDTH = 644;
- const CONSISTENT_ACTION_ROW_HEIGHT = 54;
- const CONSISTENT_ACTION_ROW_GAP = 16;
- const CONSISTENT_ACTION_ROW_PERSON_GAP = 48;
- const CONSISTENT_ACTION_PERSON_WIDTH = 208;
- const CONSISTENT_ACTION_PERSON_HEIGHT = 72;
- const CONSISTENT_ACTION_PERSON_DATE_GAP = 28;
- const CONSISTENT_ACTION_DATE_MIN_WIDTH = 196;
- const CONSISTENT_ACTION_CHART_RIGHT_PADDING = 24;
- const CONSISTENT_ACTION_GROUP_GAP = 40;
- const CONSISTENT_ACTION_SECTION_PADDING_TOP = 30;
- const CONSISTENT_ACTION_SECTION_PADDING_BOTTOM = 24;
- const CONSISTENT_ACTION_MIN_HEIGHT = 180;
- const CONSISTENT_ACTION_MIN_WIDTH =
- CONSISTENT_ACTION_ROW_X +
- CONSISTENT_ACTION_ROW_MIN_WIDTH +
- CONSISTENT_ACTION_ROW_PERSON_GAP +
- CONSISTENT_ACTION_PERSON_WIDTH +
- CONSISTENT_ACTION_PERSON_DATE_GAP +
- CONSISTENT_ACTION_DATE_MIN_WIDTH +
- CONSISTENT_ACTION_CHART_RIGHT_PADDING;
- const CONSISTENT_ACTION_ROW_COLORS = ['#4d79f6', '#2fb9a7', '#f59e0b', '#ef7d6b', '#7c91ff', '#49a5ff'];
- const menuOptions: Array<{ key: MenuKey; label: string; description: string }> = [
- { key: 'consistentAction', label: '多卡汇集', description: '查看多节点协同行为与共同窗口。' },
- { key: 'sameFeature', label: '异卡同特征', description: '按同IP、同MAC、同号等特征归并。' },
- ];
- const sameFeatureGroupOptions = computed(() => consistentGroupOptions.value);
- const { prefixCls } = useDesign('card-holding');
- const { createMessage } = useMessage();
- const { queryLoading, runWithQueryLoading } = useTableSkill();
- const router = useRouter();
- const activeMenu = ref<MenuKey>('consistentAction');
- const consistentGroupOptions = ref<OptionItem[]>([]);
- const emptyGroupPrompted = ref(false);
- const transRecordModalOpen = ref(false);
- const transRecordBaseQuery = ref<Partial<TransRecordQuery>>({});
- const sameFeatureScene = ref<SameFeatureScene | null>(null);
- const sameFeatureBoardRef = ref<HTMLDivElement | null>(null);
- const sameFeatureBoardWidth = ref(SAME_FEATURE_MIN_WIDTH);
- const consistentActionBodyRef = ref<HTMLDivElement | null>(null);
- const consistentActionBoardWidth = ref(CONSISTENT_ACTION_MIN_WIDTH);
- const createSameFeatureFilters = () => ({
- group: '',
- amountOp: DEFAULT_COMPARE_OPERATOR as CompareOperator,
- amountValue: '10000',
- dateRange: undefined as RangeValue,
- });
- const createConsistentActionFilters = (): ConsistentActionFilters => ({
- groupId: '',
- dayGap: '7',
- moneyOp: DEFAULT_COMPARE_OPERATOR,
- money: '10000',
- cardNumOp: DEFAULT_COMPARE_OPERATOR,
- cardNum: '2',
- });
- const sameFeatureFilters = ref(createSameFeatureFilters());
- const sameFeatureQuerySnapshot = ref<SameFeatureQuerySnapshot | null>(null);
- const consistentFilters = ref<ConsistentActionFilters>(createConsistentActionFilters());
- const consistentActionQueried = ref(false);
- const consistentActionLastQuery = ref<ConsistentActionQuerySnapshot | null>(null);
- const consistentActionPeriods = ref<ConsistentActionPeriodData[]>([]);
- const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
- refresh: async () => {
- if (activeMenu.value === 'consistentAction') {
- await handleConsistentActionSearch();
- return;
- }
- await handleSameFeatureSearch();
- },
- hasData: () =>
- activeMenu.value === 'consistentAction'
- ? consistentActionQueried.value && consistentActionPeriods.value.length > 0
- : !!sameFeatureQuerySnapshot.value && !!sameFeatureScene.value?.links.length,
- });
- const amountOpOptions = [...COMPARE_OPERATOR_OPTIONS];
- const cardHoldCompareOptions = amountOpOptions.filter((item) => item.value === 'GE' || item.value === 'LE');
- const amountPattern = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/;
- const positiveIntegerPattern = /^[1-9]\d*$/;
- const normalizeDateRange = (dateRange: RangeValue): RangeValue => {
- if (!Array.isArray(dateRange)) return undefined;
- return [dateRange[0], dateRange[1]];
- };
- const parseAmountValue = (value?: string): string | undefined | null => {
- const next = String(value ?? '').trim();
- const amountForCheck = next.replace(/,/g, '');
- if (!amountForCheck) return undefined;
- if (!amountPattern.test(amountForCheck) || /^0(?:\.0{1,2})?$/.test(amountForCheck)) return null;
- return next;
- };
- const parsePositiveInteger = (value?: string) => {
- const next = String(value ?? '').trim();
- if (!next) return undefined;
- if (!positiveIntegerPattern.test(next)) return null;
- const parsed = Number(next);
- return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
- };
- const toComparableAmount = (value?: number | string | null) => {
- if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
- const text = String(value ?? '')
- .trim()
- .replace(/,/g, '');
- if (!text) return undefined;
- const parsed = Number(text);
- return Number.isFinite(parsed) ? parsed : undefined;
- };
- const matchesAmount = (
- amount: number | string | undefined,
- threshold?: number | string,
- operator?: CompareOperator,
- ) => {
- if (threshold === undefined) return true;
- const currentAmount = toComparableAmount(amount);
- const targetAmount = toComparableAmount(threshold);
- if (currentAmount === undefined || targetAmount === undefined) return false;
- switch (normalizeCompareOperator(operator, DEFAULT_COMPARE_OPERATOR)) {
- case 'EQ':
- return currentAmount === targetAmount;
- case 'GT':
- return currentAmount > targetAmount;
- case 'LT':
- return currentAmount < targetAmount;
- case 'LE':
- return currentAmount <= targetAmount;
- case 'GE':
- default:
- return currentAmount >= targetAmount;
- }
- };
- const matchesDateRange = (date: string, dateRange: RangeValue) => {
- if (!Array.isArray(dateRange)) return true;
- const currentDate = String(date || '').trim();
- if (!currentDate) return false;
- const [startDate, endDate] = dateRange;
- if (startDate && currentDate < startDate) return false;
- if (endDate && currentDate > endDate) return false;
- return true;
- };
- const isLegacyCardHoldCompareOperator = (operator?: CompareOperator) => {
- const normalized = normalizeCompareOperator(operator, DEFAULT_COMPARE_OPERATOR);
- return normalized === 'GE' || normalized === 'LE';
- };
- const toLegacyCardHoldCompareSymbol = (operator?: CompareOperator) => {
- return operator;
- };
- const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
- const toRectStyle = (node: RectNode) => ({
- left: `${node.x}px`,
- top: `${node.y}px`,
- width: `${node.width}px`,
- height: `${node.height}px`,
- });
- const createConsistentActionMetrics = (boardWidth: number): ConsistentActionMetrics => {
- const chartWidth = Math.max(boardWidth, CONSISTENT_ACTION_MIN_WIDTH);
- const dateX = chartWidth - CONSISTENT_ACTION_CHART_RIGHT_PADDING - CONSISTENT_ACTION_DATE_MIN_WIDTH;
- const personX = dateX - CONSISTENT_ACTION_PERSON_DATE_GAP - CONSISTENT_ACTION_PERSON_WIDTH;
- const rowWidth = Math.max(
- CONSISTENT_ACTION_ROW_MIN_WIDTH,
- personX - CONSISTENT_ACTION_ROW_PERSON_GAP - CONSISTENT_ACTION_ROW_X,
- );
- return {
- chartWidth,
- dateX,
- personX,
- rowWidth,
- rowJoinX: CONSISTENT_ACTION_ROW_X + rowWidth + Math.round(CONSISTENT_ACTION_ROW_PERSON_GAP / 2),
- dateJoinX: personX + CONSISTENT_ACTION_PERSON_WIDTH + Math.round(CONSISTENT_ACTION_PERSON_DATE_GAP / 2),
- dateLineEndX: dateX - 8,
- };
- };
- const resolveSameFeatureGroupLabel = (group?: string) => {
- if (!group) return '当前分组';
- return sameFeatureGroupOptions.value.find((item) => item.value === group)?.label || group;
- };
- const resolveConsistentGroupLabel = (groupId?: string) => {
- if (!groupId) return '';
- return consistentGroupOptions.value.find((item) => item.value === groupId)?.label || '';
- };
- const buildDateLabel = (minDate?: string, maxDate?: string) => {
- const start = String(minDate || '').trim();
- const end = String(maxDate || '').trim();
- if (start && end) return `${start} 至 ${end}`;
- return start || end || '时间未知';
- };
- const formatMoneyText = (value?: number | string | null) => {
- const amount = Number(value ?? '');
- if (!Number.isFinite(amount)) {
- const text = String(value ?? '').trim();
- return text || '-';
- }
- const hasDecimal = !Number.isInteger(amount);
- return new Intl.NumberFormat('zh-CN', {
- minimumFractionDigits: hasDecimal ? 2 : 0,
- maximumFractionDigits: 2,
- }).format(amount);
- };
- const formatCardDisplay = (value?: string | null) => {
- const text = String(value ?? '')
- .trim()
- .replace(/\s+/g, '');
- if (!text) return '-';
- return text.replace(/(.{4})(?=.+)/g, '$1 ');
- };
- const normalizeIdList = (value: unknown): string[] => {
- const rawList = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
- const seen = new Set<string>();
- const ids: Array<string> = [];
- rawList.forEach((item) => {
- if (item === null || item === undefined) return;
- const normalized = String(item).trim();
- if (normalized === '') return;
- if (seen.has(normalized)) return;
- seen.add(normalized);
- ids.push(normalized);
- });
- return ids;
- };
- const mergeNormalizedIds = (...values: unknown[]): string[] => {
- return normalizeIdList(values.flatMap((value) => normalizeIdList(value)));
- };
- const resolveConsistentActionPeriodStart = (period: TransCardHoldTogetherDTO) => {
- return String(period.minDate || period.startDate || '').trim();
- };
- const resolveConsistentActionPeriodEnd = (period: TransCardHoldTogetherDTO) => {
- return String(period.maxDate || period.endDate || '').trim();
- };
- const resolveConsistentActionSummaryName = (summary: TransCardHoldTogetherSummaryDTO) => {
- return String(summary.dealOtherName || summary.otherName || '未知对象');
- };
- const resolveConsistentActionSummaryCard = (summary: TransCardHoldTogetherSummaryDTO) => {
- const card = String(summary.dealOtherCard || summary.otherCardNo || '').trim();
- return card || '-';
- };
- const resolveConsistentActionRows = (summary: TransCardHoldTogetherSummaryDTO): TransCardHoldTogetherItemDTO[] => {
- if (Array.isArray(summary.yzxdObjList) && summary.yzxdObjList.length) return summary.yzxdObjList;
- if (Array.isArray(summary.transPersonDTOList)) return summary.transPersonDTOList;
- return Array.isArray(summary.yzxdObjList) ? summary.yzxdObjList : [];
- };
- const buildSameFeatureScene = (_target: string, _group: string): SameFeatureScene => {
- return {
- group: _group,
- focus: '',
- subject: { name: _target },
- holders: [],
- links: [],
- };
- };
- const normalizeTransCardHoldResponse = (resp: TransCardHoldResponseLike): TransCardHoldDTO | null => {
- if (!resp) return null;
- if (
- Array.isArray(resp.bankCardObjList) ||
- Array.isArray(resp.bankCardPersonList) ||
- resp.subjectObjName ||
- resp.mainPersonName ||
- resp.ztPhotograph
- ) {
- return resp;
- }
- if (resp.data) return resp.data;
- return null;
- };
- const buildSameFeatureSceneFromApi = (
- resp: TransCardHoldResponseLike,
- target: string,
- group: string,
- ): SameFeatureScene | null => {
- const data = normalizeTransCardHoldResponse(resp);
- const source = Array.isArray(data?.bankCardObjList)
- ? data.bankCardObjList
- : Array.isArray(data?.bankCardPersonList)
- ? data.bankCardPersonList
- : [];
- const cardMapping = source.reduce<
- Record<
- string,
- {
- cardNo: string;
- holderId: string;
- holderName: string;
- holderAlias: string;
- bankName?: string;
- amount?: number | string;
- transDate?: string;
- featureMap: Record<string, string[]>;
- }
- >
- >((acc, item, index) => {
- const cardNo = String(item.otherCard || item.otherCardNo || '').trim();
- const holderName = String(item.otherName || '').trim();
- const holderId = String(item.otherObjId || `${holderName || cardNo || 'holder'}-${index}`);
- const mapKey = `${cardNo || 'unknown-card'}-${holderId}`;
- const featureName = String(item.type || '').trim() || '未知特征';
- const current = acc[mapKey] || {
- cardNo,
- holderId,
- holderName: holderName || '未知持卡人',
- holderAlias: String(item.gx || '').trim(),
- bankName: '',
- amount: undefined,
- transDate: '',
- featureMap: {},
- };
- current.featureMap[featureName] = mergeNormalizedIds(current.featureMap[featureName], item.ids);
- if (!current.holderAlias && item.gx) current.holderAlias = String(item.gx);
- if (!current.bankName && item.bankName) current.bankName = String(item.bankName).trim();
- if (
- current.amount === undefined &&
- item.transAmount !== undefined &&
- item.transAmount !== null &&
- item.transAmount !== ''
- ) {
- current.amount = item.transAmount;
- }
- if (!current.transDate) {
- current.transDate = String(item.transDate || item.startDate || item.endDate || '').trim();
- }
- acc[mapKey] = current;
- return acc;
- }, {});
- const holdersMap = new Map<string, SameFeatureHolder>();
- const links = Object.values(cardMapping).map((item, index) => {
- if (!holdersMap.has(item.holderId)) {
- holdersMap.set(item.holderId, {
- id: item.holderId,
- name: item.holderName,
- alias: item.holderAlias,
- });
- }
- return {
- id: `same-feature-api-link-${index}`,
- cardNo: item.cardNo || '-',
- holderId: item.holderId,
- bankName: item.bankName || '',
- features: Object.keys(item.featureMap),
- amount: item.amount,
- transDate: item.transDate || undefined,
- ids: mergeNormalizedIds(...Object.values(item.featureMap)),
- };
- });
- return {
- group,
- focus: '',
- subject: {
- name: String(data?.subjectObjName || data?.mainPersonName || target || '未知对象'),
- },
- holders: Array.from(holdersMap.values()),
- links,
- };
- };
- const normalizeConsistentActionList = (resp: ConsistentActionResponseLike): TransCardHoldTogetherDTO[] => {
- if (Array.isArray(resp)) return resp;
- if (!resp) return [];
- if (Array.isArray(resp.list)) return resp.list;
- if (Array.isArray(resp.records)) return resp.records;
- if (Array.isArray(resp.data)) return resp.data;
- if (resp.data && Array.isArray(resp.data.list)) return resp.data.list;
- if (resp.data && Array.isArray(resp.data.records)) return resp.data.records;
- return [];
- };
- const normalizeConsistentActionRow = (
- item: TransCardHoldTogetherItemDTO,
- periodIndex: number,
- personIndex: number,
- rowIndex: number,
- ): ConsistentActionRowData => ({
- id: `period-${periodIndex}-person-${personIndex}-row-${rowIndex}`,
- objName: String(item.objName || item.personName || '未知对象'),
- selfCardNo: String(item.selfCardNo || item.personCardNo || '-'),
- objFlag: String(item.objFlag || item.mainDesc || ''),
- targetBankName: String(item.targetBankName || item.bankName || '-'),
- dealMoneyText: formatMoneyText(item.dealMoney ?? item.transAmount),
- ids: mergeNormalizedIds(item.idList, item.ids),
- color: CONSISTENT_ACTION_ROW_COLORS[(periodIndex + personIndex + rowIndex) % CONSISTENT_ACTION_ROW_COLORS.length],
- tag: String(item.tag || ''),
- });
- const normalizeConsistentActionPerson = (
- summary: TransCardHoldTogetherSummaryDTO,
- periodIndex: number,
- personIndex: number,
- ): ConsistentActionPersonData => {
- const rows = resolveConsistentActionRows(summary).map((item, rowIndex) =>
- normalizeConsistentActionRow(item, periodIndex, personIndex, rowIndex),
- );
- return {
- id: `period-${periodIndex}-person-${personIndex}`,
- name: resolveConsistentActionSummaryName(summary),
- card: resolveConsistentActionSummaryCard(summary),
- ids: mergeNormalizedIds(...rows.map((item) => item.ids)),
- rows,
- };
- };
- const normalizeConsistentActionPeriods = (source: TransCardHoldTogetherDTO[]): ConsistentActionPeriodData[] => {
- return source
- .map((period, periodIndex) => {
- const minDate = resolveConsistentActionPeriodStart(period);
- const maxDate = resolveConsistentActionPeriodEnd(period);
- const people = (Array.isArray(period.otherSummaryList) ? period.otherSummaryList : [])
- .map((summary, personIndex) => normalizeConsistentActionPerson(summary, periodIndex, personIndex))
- .filter((item) => item.rows.length > 0);
- return {
- id: `period-${periodIndex}`,
- minDate,
- maxDate,
- dateLabel: buildDateLabel(minDate, maxDate),
- people,
- };
- })
- .filter((item) => item.people.length > 0);
- };
- const buildConsistentActionLayouts = (
- periods: ConsistentActionPeriodData[],
- metrics: ConsistentActionMetrics,
- ): ConsistentActionLayout[] => {
- return periods.map((period) => {
- let cursorY = CONSISTENT_ACTION_SECTION_PADDING_TOP;
- const people = period.people.map((person, personIndex) => {
- const rowsHeight =
- person.rows.length * CONSISTENT_ACTION_ROW_HEIGHT +
- Math.max(person.rows.length - 1, 0) * CONSISTENT_ACTION_ROW_GAP;
- const groupHeight = Math.max(CONSISTENT_ACTION_PERSON_HEIGHT, rowsHeight);
- const groupTop = cursorY;
- const personY = Math.round(groupTop + groupHeight / 2 - CONSISTENT_ACTION_PERSON_HEIGHT / 2);
- const rowStartY = Math.round(groupTop + groupHeight / 2 - rowsHeight / 2);
- const rows = person.rows.map((row, rowIndex) => ({
- id: `${person.id}-node-${rowIndex}`,
- x: CONSISTENT_ACTION_ROW_X,
- y: rowStartY + rowIndex * (CONSISTENT_ACTION_ROW_HEIGHT + CONSISTENT_ACTION_ROW_GAP),
- width: metrics.rowWidth,
- height: CONSISTENT_ACTION_ROW_HEIGHT,
- row,
- }));
- cursorY += groupHeight + CONSISTENT_ACTION_GROUP_GAP;
- return {
- id: `${period.id}-person-node-${personIndex}`,
- x: metrics.personX,
- y: personY,
- width: CONSISTENT_ACTION_PERSON_WIDTH,
- height: CONSISTENT_ACTION_PERSON_HEIGHT,
- person,
- rows,
- };
- });
- const height = Math.max(
- CONSISTENT_ACTION_MIN_HEIGHT,
- cursorY - CONSISTENT_ACTION_GROUP_GAP + CONSISTENT_ACTION_SECTION_PADDING_BOTTOM,
- );
- const personCenterYs = people.map((person) => person.y + person.height / 2);
- const dateY = personCenterYs.length
- ? Math.round((personCenterYs[0] + personCenterYs[personCenterYs.length - 1]) / 2)
- : Math.round(height / 2);
- const rowPaths = people.flatMap((person) => {
- const personCenterY = person.y + person.height / 2;
- return person.rows.map((row) => ({
- key: `row-path-${row.id}`,
- d: `M ${row.x + row.width} ${row.y + row.height / 2} H ${metrics.rowJoinX} V ${personCenterY} H ${person.x}`,
- }));
- });
- const personPaths = people.map((person) => ({
- key: `person-path-${person.id}`,
- d: `M ${person.x + person.width} ${person.y + person.height / 2} H ${metrics.dateJoinX} V ${dateY} H ${
- metrics.dateLineEndX
- }`,
- }));
- return {
- id: period.id,
- period,
- people,
- height,
- dateY,
- rowPaths,
- personPaths,
- };
- });
- };
- const openTransRecordModal = (baseQuery: Partial<TransRecordQuery>) => {
- transRecordBaseQuery.value = baseQuery;
- transRecordModalOpen.value = true;
- };
- const normalizedSameFeatureAmount = computed(() => String(sameFeatureFilters.value.amountValue ?? '').trim());
- const normalizedDayGap = computed(() => String(consistentFilters.value.dayGap ?? '').trim());
- const normalizedConsistentMoney = computed(() => String(consistentFilters.value.money ?? '').trim());
- const normalizedCardNum = computed(() => String(consistentFilters.value.cardNum ?? '').trim());
- const amountValueStatus = computed<StatusType>(() => {
- if (!normalizedSameFeatureAmount.value) return undefined;
- return parseAmountValue(normalizedSameFeatureAmount.value) === null ? 'error' : undefined;
- });
- const dayGapStatus = computed<StatusType>(() => {
- if (!normalizedDayGap.value) return undefined;
- return parsePositiveInteger(normalizedDayGap.value) === null ? 'error' : undefined;
- });
- const consistentMoneyStatus = computed<StatusType>(() => {
- if (!normalizedConsistentMoney.value) return undefined;
- return parseAmountValue(normalizedConsistentMoney.value) === null ? 'error' : undefined;
- });
- const cardNumStatus = computed<StatusType>(() => {
- if (!normalizedCardNum.value) return undefined;
- return parsePositiveInteger(normalizedCardNum.value) === null ? 'error' : undefined;
- });
- const activeMenuMeta = computed(() => {
- return menuOptions.find((item) => item.key === activeMenu.value) || menuOptions[0];
- });
- const hasQueryResult = computed(() => !!sameFeatureQuerySnapshot.value);
- const selectedSameFeatureGroupLabel = computed(() => {
- return resolveSameFeatureGroupLabel(sameFeatureQuerySnapshot.value?.group || sameFeatureFilters.value.group);
- });
- const activeSameFeatureScene = computed<SameFeatureScene | null>(() => {
- const snapshot = sameFeatureQuerySnapshot.value;
- if (!snapshot) return null;
- return sameFeatureScene.value;
- });
- const sameFeatureSubjectName = computed(() => {
- return activeSameFeatureScene.value?.subject.name || selectedSameFeatureGroupLabel.value || '当前分组';
- });
- const sameFeatureDescription = computed(() => {
- if (!sameFeatureQuerySnapshot.value) {
- return '按"主体 -> 特征银行卡 -> 持卡人"展示疑似代持关系,点击银行卡节点可下钻交易明细。';
- }
- if (!activeSameFeatureScene.value?.links.length) {
- return `${sameFeatureSubjectName.value} 在 ${selectedSameFeatureGroupLabel.value} 下暂无命中特征卡,可调整金额或日期后重试。`;
- }
- return `${sameFeatureSubjectName.value} 在 ${selectedSameFeatureGroupLabel.value} 中命中 ${activeSameFeatureScene.value.links.length} 张疑似特征卡,涉及 ${activeSameFeatureScene.value.holders.length} 名持卡人,点击银行卡节点可触发交易底层公共接口。`;
- });
- const sameFeatureEmptyDescription = computed(() => {
- if (!sameFeatureQuerySnapshot.value) return '请选择调查分组后查询异卡同特征';
- return `${sameFeatureSubjectName.value} 在当前筛选条件下未命中异卡同特征`;
- });
- const sameFeatureStats = computed<Array<{ label: string; value: number }>>(() => {
- const scene = activeSameFeatureScene.value;
- if (!sameFeatureQuerySnapshot.value || !scene) return [];
- return [
- { label: '疑似特征卡', value: scene.links.length },
- { label: '涉及持卡人', value: scene.holders.length },
- { label: '命中特征', value: new Set(scene.links.flatMap((item) => item.features)).size },
- ];
- });
- const sameFeatureBoardHeight = computed(() => {
- const count = activeSameFeatureScene.value?.links.length || 0;
- if (!count) return 500;
- return (
- SAME_FEATURE_CONTENT_TOP + count * SAME_FEATURE_CARD_HEIGHT + Math.max(count - 1, 0) * SAME_FEATURE_CARD_GAP + 96
- );
- });
- const sameFeatureSubjectNode = computed<RectNode>(() => {
- const featureCenterYs = sameFeatureFeatureNodes.value.map((item) => item.centerY);
- const preferredCenterY = featureCenterYs.length
- ? featureCenterYs.reduce((sum, value) => sum + value, 0) / featureCenterYs.length
- : sameFeatureBoardHeight.value / 2;
- return {
- x: SAME_FEATURE_MAIN_X,
- y: clamp(
- preferredCenterY - SAME_FEATURE_MAIN_HEIGHT / 2,
- 40,
- sameFeatureBoardHeight.value - SAME_FEATURE_MAIN_HEIGHT - 40,
- ),
- width: SAME_FEATURE_MAIN_WIDTH,
- height: SAME_FEATURE_MAIN_HEIGHT,
- };
- });
- const sameFeatureFeatureNodes = computed<
- Array<RectNode & { link: SameFeatureLink; lineY: number; centerX: number; centerY: number }>
- >(() => {
- const scene = activeSameFeatureScene.value;
- if (!scene?.links.length) return [];
- const boardWidth = Math.max(sameFeatureBoardWidth.value, SAME_FEATURE_MIN_WIDTH);
- const cardX = Math.max(420, Math.round(boardWidth * 0.46));
- return scene.links.map((link, index) => {
- const y = SAME_FEATURE_CONTENT_TOP + index * (SAME_FEATURE_CARD_HEIGHT + SAME_FEATURE_CARD_GAP);
- const centerY = y + SAME_FEATURE_CARD_HEIGHT / 2;
- return {
- x: cardX,
- y,
- width: SAME_FEATURE_CARD_WIDTH,
- height: SAME_FEATURE_CARD_HEIGHT,
- lineY: centerY,
- centerX: cardX + SAME_FEATURE_CARD_WIDTH / 2,
- centerY,
- link,
- };
- });
- });
- const sameFeatureHolderNodes = computed<Array<RectNode & { holder: SameFeatureHolder }>>(() => {
- const scene = activeSameFeatureScene.value;
- if (!scene?.holders.length) return [];
- const boardWidth = Math.max(sameFeatureBoardWidth.value, SAME_FEATURE_MIN_WIDTH);
- const holderX = boardWidth - SAME_FEATURE_MEMBER_WIDTH - SAME_FEATURE_MEMBER_RIGHT_GAP;
- const orderedHolders = [...scene.holders].sort(
- (a, b) =>
- scene.links.findIndex((item) => item.holderId === a.id) -
- scene.links.findIndex((item) => item.holderId === b.id),
- );
- return orderedHolders.map((holder, index) => {
- const featureGroup = sameFeatureFeatureNodes.value.filter((item) => item.link.holderId === holder.id);
- const averageY = featureGroup.length
- ? featureGroup.reduce((sum, item) => sum + item.centerY, 0) / featureGroup.length
- : 160 + index * 104;
- return {
- x: holderX,
- y: clamp(
- averageY - SAME_FEATURE_MEMBER_HEIGHT / 2,
- 40,
- sameFeatureBoardHeight.value - SAME_FEATURE_MEMBER_HEIGHT - 40,
- ),
- width: SAME_FEATURE_MEMBER_WIDTH,
- height: SAME_FEATURE_MEMBER_HEIGHT,
- holder,
- };
- });
- });
- const sameFeatureBusX = computed(() => {
- if (!sameFeatureFeatureNodes.value.length) {
- return sameFeatureSubjectNode.value.x + sameFeatureSubjectNode.value.width + 92;
- }
- return sameFeatureFeatureNodes.value[0].x - SAME_FEATURE_BUS_GAP;
- });
- const sameFeatureSubjectPath = computed(() => {
- if (!sameFeatureFeatureNodes.value.length) return '';
- const subjectNode = sameFeatureSubjectNode.value;
- const subjectY = subjectNode.y + subjectNode.height / 2;
- const lineYs = [subjectY, ...sameFeatureFeatureNodes.value.map((item) => item.lineY)];
- const topY = Math.min(...lineYs);
- const bottomY = Math.max(...lineYs);
- return `M ${subjectNode.x + subjectNode.width} ${subjectY} H ${sameFeatureBusX.value} M ${sameFeatureBusX.value} ${topY} V ${bottomY}`;
- });
- const sameFeatureFeaturePaths = computed<PathDefinition[]>(() => {
- if (!sameFeatureFeatureNodes.value.length) return [];
- return sameFeatureFeatureNodes.value.map((node) => ({
- key: `same-feature-left-${node.link.id}`,
- d: `M ${sameFeatureBusX.value} ${node.lineY} H ${node.x}`,
- }));
- });
- const sameFeatureFeatureLabelGroups = computed<FeatureTagGroup[]>(() => {
- return sameFeatureFeatureNodes.value.map((node) => ({
- key: `same-feature-tags-${node.link.id}`,
- x: sameFeatureBusX.value + 18,
- y: node.y + 4,
- maxWidth: Math.max(120, node.x - sameFeatureBusX.value - 36),
- features: node.link.features,
- }));
- });
- const sameFeatureHolderPaths = computed<PathDefinition[]>(() => {
- const holderNodeMap = new Map(sameFeatureHolderNodes.value.map((item) => [item.holder.id, item]));
- return sameFeatureFeatureNodes.value.flatMap((node) => {
- const holderNode = holderNodeMap.get(node.link.holderId);
- if (!holderNode) return [];
- const joinX = holderNode.x - 28;
- const holderY = holderNode.y + holderNode.height / 2;
- const pathD =
- Math.abs(holderY - node.centerY) <= 1
- ? `M ${node.x + node.width} ${node.centerY} H ${holderNode.x}`
- : `M ${node.x + node.width} ${node.centerY} H ${joinX} V ${holderY} H ${holderNode.x}`;
- return [
- {
- key: `same-feature-right-${node.link.id}`,
- d: pathD,
- },
- ];
- });
- });
- const sameFeatureHolderLabels = computed<TextLabel[]>(() => {
- const holderNodeMap = new Map(sameFeatureHolderNodes.value.map((item) => [item.holder.id, item]));
- return sameFeatureFeatureNodes.value.flatMap((node) => {
- const holderNode = holderNodeMap.get(node.link.holderId);
- if (!holderNode) return [];
- const holderY = holderNode.y + holderNode.height / 2;
- const labelX = Math.min(node.x + node.width + 34, holderNode.x - 78);
- const labelY =
- Math.abs(holderY - node.centerY) <= 1
- ? node.centerY - 18
- : clamp((node.centerY + holderY) / 2 - 10, 16, sameFeatureBoardHeight.value - 36);
- return [
- {
- key: `same-feature-holder-label-${node.link.id}`,
- x: labelX,
- y: labelY,
- text: '持卡人',
- },
- ];
- });
- });
- const consistentActionGroupLabel = computed(() => {
- return (
- consistentActionLastQuery.value?.groupLabel ||
- resolveConsistentGroupLabel(consistentFilters.value.groupId) ||
- '当前分组'
- );
- });
- const consistentActionMetrics = computed(() => createConsistentActionMetrics(consistentActionBoardWidth.value));
- const consistentActionLayouts = computed(() =>
- buildConsistentActionLayouts(consistentActionPeriods.value, consistentActionMetrics.value),
- );
- const consistentActionPeopleCount = computed(() => {
- return consistentActionPeriods.value.reduce((sum, period) => sum + period.people.length, 0);
- });
- const consistentActionRowCount = computed(() => {
- return consistentActionPeriods.value.reduce(
- (sum, period) => sum + period.people.reduce((personSum, person) => personSum + person.rows.length, 0),
- 0,
- );
- });
- const consistentActionDescription = computed(() => {
- if (!consistentActionQueried.value) {
- return '按"交易行 -> 共同交易对象 -> 时间窗口"展示协同行为,点击节点可查看交易明细。';
- }
- if (!consistentActionLayouts.value.length) {
- return `${consistentActionGroupLabel.value} 在当前筛选条件下暂无多卡汇集结果,可调整时间间隔或门槛后重试。`;
- }
- return `${consistentActionGroupLabel.value} 命中 ${consistentActionLayouts.value.length} 个时间窗口、${consistentActionPeopleCount.value} 个共同交易对象、${consistentActionRowCount.value} 条协同行为记录,点击节点可查看详情。`;
- });
- const consistentActionEmptyDescription = computed(() => {
- if (!consistentActionQueried.value) return '请选择调查分组后查询多卡汇集';
- return `${consistentActionGroupLabel.value} 在当前筛选条件下暂无多卡汇集结果`;
- });
- const resolveHolderName = (holderId: string) => {
- return activeSameFeatureScene.value?.holders.find((item) => item.id === holderId)?.name || '未知持卡人';
- };
- const promptCreateGroupWhenEmpty = () => {
- if (emptyGroupPrompted.value) return;
- emptyGroupPrompted.value = true;
- Modal.confirm({
- title: '暂无对象分组',
- content: '当前案件还没有对象分组,是否前往新建分组?',
- okText: '新建分组',
- cancelText: '稍后再说',
- centered: true,
- onOk: () =>
- router.push({
- path: '/dm/portrait',
- query: {
- openGroupModal: '1',
- openCreateGroupModal: '1',
- },
- }),
- });
- };
- const loadConsistentGroupOptions = async () => {
- try {
- const groups = await listPersonGroups();
- consistentGroupOptions.value = (Array.isArray(groups) ? groups : [])
- .filter((item) => item?.id)
- .map((item) => ({
- label: item.groupName || item.id || '未命名分组',
- value: item.id || '',
- }))
- .filter((item) => item.value);
- if (!consistentGroupOptions.value.length) {
- promptCreateGroupWhenEmpty();
- }
- } catch {
- consistentGroupOptions.value = [];
- }
- };
- const syncBoardSize = () => {
- nextTick(() => {
- sameFeatureBoardWidth.value = Math.max(sameFeatureBoardRef.value?.clientWidth || 0, SAME_FEATURE_MIN_WIDTH);
- const consistentActionBoardScroll = consistentActionBodyRef.value?.querySelector<HTMLElement>(
- '.consistent-action-board-scroll',
- );
- consistentActionBoardWidth.value = Math.max(
- consistentActionBoardScroll?.clientWidth || (consistentActionBodyRef.value?.clientWidth || 0) - 36,
- CONSISTENT_ACTION_MIN_WIDTH,
- );
- });
- };
- const buildFeatureRecordBaseQuery = (link: SameFeatureLink): Partial<TransRecordQuery> => {
- if (link.ids.length) {
- return {
- ids: link.ids,
- };
- }
- const threshold = sameFeatureQuerySnapshot.value?.amountValue;
- return {
- personName: activeSameFeatureScene.value?.subject.name || undefined,
- otherName: resolveHolderName(link.holderId),
- otherCardNo: link.cardNo,
- startDate: link.transDate,
- endDate: link.transDate,
- transAmount: threshold,
- transAmountSymbol:
- threshold !== undefined
- ? normalizeCompareOperator(sameFeatureQuerySnapshot.value?.amountOp, DEFAULT_COMPARE_OPERATOR)
- : undefined,
- };
- };
- const handleFeatureCardClick = (link: SameFeatureLink) => {
- openTransRecordModal(buildFeatureRecordBaseQuery(link));
- };
- const handleConsistentActionRowClick = (row: ConsistentActionRowData) => {
- if (!row.ids.length) {
- createMessage.warning(`${row.objName} 暂无可下钻的交易记录`);
- return;
- }
- openTransRecordModal({ ids: row.ids });
- };
- const handleConsistentActionPersonClick = (person: ConsistentActionPersonData) => {
- if (!person.ids.length) {
- createMessage.warning(`${person.name} 暂无可下钻的交易记录`);
- return;
- }
- openTransRecordModal({ ids: person.ids });
- };
- const handleMenuChange = (menu: MenuKey) => {
- activeMenu.value = menu;
- syncBoardSize();
- };
- watch(
- () => consistentFilters.value.groupId,
- (value) => {
- const next = String(value || '');
- if (sameFeatureFilters.value.group === next) return;
- sameFeatureFilters.value.group = next;
- },
- );
- watch(
- () => sameFeatureFilters.value.group,
- (value) => {
- const next = String(value || '');
- if (consistentFilters.value.groupId === next) return;
- consistentFilters.value.groupId = next;
- },
- );
- const handleSameFeatureSearch = async () => {
- if (!sameFeatureFilters.value.group) {
- createMessage.warning('请选择调查分组后再查询');
- return;
- }
- const amountValue = parseAmountValue(sameFeatureFilters.value.amountValue);
- if (amountValue === null) {
- createMessage.warning('大额金额请输入合法数值');
- return;
- }
- const amountOp = normalizeCompareOperator(sameFeatureFilters.value.amountOp, DEFAULT_COMPARE_OPERATOR);
- if (amountValue !== undefined && !isLegacyCardHoldCompareOperator(amountOp)) {
- createMessage.warning('异卡同特征金额条件仅支持 >= 或 <=');
- return;
- }
- const snapshot: SameFeatureQuerySnapshot = {
- group: sameFeatureFilters.value.group,
- amountOp,
- amountValue: amountValue ?? undefined,
- dateRange: normalizeDateRange(sameFeatureFilters.value.dateRange),
- };
- const [startDateText = '', endDateText = ''] = snapshot.dateRange || [];
- const query: TransCardHoldQuery = {
- groupId: snapshot.group,
- transAmount: snapshot.amountValue,
- transAmountSymbol:
- snapshot.amountValue !== undefined
- ? normalizeCompareOperator(snapshot.amountOp, DEFAULT_COMPARE_OPERATOR)
- : undefined,
- startDate: startDateText ? `${startDateText} 00:00:00` : undefined,
- endDate: endDateText ? `${endDateText} 23:59:59` : undefined,
- };
- await runWithQueryLoading(async () => {
- const resp = await statTransCardHold(query);
- const scene = buildSameFeatureSceneFromApi(resp, snapshot.group, snapshot.group);
- sameFeatureScene.value = scene;
- sameFeatureQuerySnapshot.value = snapshot;
- syncAnalysisDataRefreshState(scene.links.length > 0);
- transRecordModalOpen.value = false;
- transRecordBaseQuery.value = {};
- await nextTick();
- syncBoardSize();
- });
- };
- const handleConsistentActionSearch = async () => {
- if (!consistentFilters.value.groupId) {
- createMessage.warning('请选择调查分组后再查询');
- return;
- }
- const dayGap = parsePositiveInteger(consistentFilters.value.dayGap);
- if (dayGap === undefined || dayGap === null) {
- createMessage.warning('时间间隔请输入正整数');
- return;
- }
- const money = parseAmountValue(consistentFilters.value.money);
- if (money === null) {
- createMessage.warning('大额金额请输入合法数值');
- return;
- }
- const cardNum = parsePositiveInteger(consistentFilters.value.cardNum);
- if (cardNum === null) {
- createMessage.warning('共同交易卡数量请输入正整数');
- return;
- }
- const moneyOp = normalizeCompareOperator(consistentFilters.value.moneyOp, DEFAULT_COMPARE_OPERATOR);
- const cardNumOp = normalizeCompareOperator(consistentFilters.value.cardNumOp, DEFAULT_COMPARE_OPERATOR);
- if (money !== undefined && !isLegacyCardHoldCompareOperator(moneyOp)) {
- createMessage.warning('多卡汇集金额条件仅支持 >= 或 <=');
- return;
- }
- if (cardNum !== undefined && !isLegacyCardHoldCompareOperator(cardNumOp)) {
- createMessage.warning('共同交易卡数量条件仅支持 >= 或 <=');
- return;
- }
- const groupLabel = resolveConsistentGroupLabel(consistentFilters.value.groupId) || '当前分组';
- const query: TransCardHoldTogetherQuery = {
- groupId: consistentFilters.value.groupId,
- dayGap,
- daySymbol: toLegacyCardHoldCompareSymbol(DEFAULT_LE_COMPARE_OPERATOR),
- transAmount: money,
- transAmountSymbol: money !== undefined ? normalizeCompareOperator(moneyOp, DEFAULT_COMPARE_OPERATOR) : undefined,
- cardNum,
- cardNumSymbol: cardNum !== undefined ? toLegacyCardHoldCompareSymbol(cardNumOp) : undefined,
- };
- await runWithQueryLoading(async () => {
- const resp = await statTogetherDoTrans(query);
- consistentActionPeriods.value = normalizeConsistentActionPeriods(normalizeConsistentActionList(resp));
- consistentActionQueried.value = true;
- consistentActionLastQuery.value = {
- groupId: consistentFilters.value.groupId,
- groupLabel,
- dayGap,
- moneyOp,
- money,
- cardNumOp,
- cardNum,
- };
- syncAnalysisDataRefreshState(consistentActionPeriods.value.length > 0);
- transRecordModalOpen.value = false;
- transRecordBaseQuery.value = {};
- await nextTick();
- syncBoardSize();
- });
- };
- const handleSearch = async () => {
- if (activeMenu.value === 'consistentAction') {
- await handleConsistentActionSearch();
- return;
- }
- await handleSameFeatureSearch();
- };
- const handleReset = () => {
- if (activeMenu.value === 'consistentAction') {
- consistentFilters.value = createConsistentActionFilters();
- consistentActionQueried.value = false;
- consistentActionLastQuery.value = null;
- consistentActionPeriods.value = [];
- } else {
- sameFeatureFilters.value = createSameFeatureFilters();
- sameFeatureQuerySnapshot.value = null;
- sameFeatureScene.value = null;
- syncBoardSize();
- }
- transRecordModalOpen.value = false;
- transRecordBaseQuery.value = {};
- };
- watch(
- () => [activeMenu.value, sameFeatureBoardHeight.value, consistentActionLayouts.value.length],
- () => {
- syncBoardSize();
- },
- { immediate: true },
- );
- onMounted(async () => {
- await loadConsistentGroupOptions();
- syncBoardSize();
- window.addEventListener('resize', syncBoardSize);
- });
- onBeforeUnmount(() => {
- window.removeEventListener('resize', syncBoardSize);
- });
- </script>
- <style lang="less">
- @prefix-cls: ~'jeesite-card-holding';
- .@{prefix-cls}.card-holding-page {
- .trans-analysis-table-ui();
- background: #fff;
- padding: 0;
- min-height: 100%;
- height: 100%;
- display: flex;
- box-sizing: border-box;
- overflow: hidden;
- .left-sidebar {
- width: 196px;
- flex: 0 0 196px;
- border: 0;
- border-radius: 0;
- background: transparent;
- box-shadow: none;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .sidebar-top {
- padding: 16px 16px 14px;
- background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
- }
- .sidebar-title {
- font-size: 15px;
- font-weight: 600;
- color: #111827;
- line-height: 22px;
- }
- .sidebar-desc {
- margin-top: 8px;
- font-size: 12px;
- line-height: 20px;
- color: #6b7280;
- }
- .menu-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 0;
- }
- .menu-item {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 4px;
- text-align: left;
- padding: 12px 12px 10px;
- border: 1px solid transparent;
- border-radius: 12px;
- background: #fff;
- cursor: pointer;
- transition: all 0.2s ease;
- appearance: none;
- &:hover {
- border-color: #dbeafe;
- background: #f8fbff;
- }
- &.active {
- border-color: #bfd4f5;
- background: #f0f7ff;
- box-shadow: inset 0 0 0 1px rgba(22, 119, 255, 0.08);
- }
- }
- .menu-item__label {
- font-size: 13px;
- font-weight: 600;
- color: #111827;
- line-height: 20px;
- }
- .menu-item__desc {
- font-size: 12px;
- color: #6b7280;
- line-height: 18px;
- }
- .main-content {
- flex: 1;
- min-height: 0;
- min-width: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .page-header {
- margin-bottom: 0;
- flex-shrink: 0;
- }
- .page-title {
- font-size: 16px;
- font-weight: 600;
- color: #111827;
- padding-bottom: 8px;
- }
- .page-subtitle {
- margin-top: 10px;
- color: #6b7280;
- font-size: 12px;
- line-height: 20px;
- }
- .filter-bar {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px;
- min-height: 40px;
- border: 0;
- flex-wrap: wrap;
- flex-shrink: 0;
- background: transparent;
- box-sizing: border-box;
- }
- .filter-item {
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .filter-label {
- color: #6b7280;
- font-size: 12px;
- white-space: nowrap;
- }
- .filter-input {
- width: 180px;
- }
- .filter-group {
- width: 180px;
- }
- .filter-op {
- width: 64px;
- min-width: 64px;
- }
- .filter-mini {
- width: 96px;
- }
- .filter-fixed-op {
- width: 64px;
- height: 32px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border: 1px solid #d9e1ec;
- border-radius: 8px;
- background: #f8fafc;
- color: #475569;
- font-size: 12px;
- line-height: 1;
- box-sizing: border-box;
- }
- .filter-micro {
- width: 86px;
- }
- .filter-unit {
- color: #6b7280;
- font-size: 12px;
- white-space: nowrap;
- }
- .filter-date {
- width: 360px;
- }
- .filter-actions {
- display: flex;
- gap: 8px;
- margin-left: auto;
- }
- .filter-actions :deep(.ant-btn) {
- min-width: 68px;
- }
- .view-panel {
- flex: 1;
- min-height: 0;
- margin-top: 0;
- display: flex;
- gap: 2px;
- overflow: hidden;
- padding: 2px;
- background: #edf2f7;
- box-sizing: border-box;
- }
- .view-main {
- .analysis-card-shell();
- flex: 1;
- min-width: 0;
- min-height: 0;
- overflow: hidden;
- }
- .left-sidebar {
- .analysis-card-shell();
- }
- .analysis-card,
- .placeholder-card {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .consistent-action-card {
- flex: 1 1 auto;
- min-height: 0;
- }
- .panel-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- padding: 0 0 8px;
- }
- .panel-title {
- color: #111827;
- font-size: 15px;
- font-weight: 600;
- line-height: 22px;
- }
- .panel-desc {
- margin-top: 4px;
- color: #6b7280;
- font-size: 12px;
- line-height: 20px;
- max-width: 680px;
- }
- .panel-stat-list {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- justify-content: flex-end;
- }
- .panel-stat {
- min-width: 112px;
- padding: 10px 12px;
- border: 1px solid #dbe5f1;
- border-radius: 12px;
- background: #f8fbff;
- }
- .panel-stat__label {
- color: #6b7280;
- font-size: 11px;
- line-height: 18px;
- }
- .panel-stat__value {
- margin-top: 2px;
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 22px;
- }
- .same-feature-header-extra {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 10px;
- }
- .same-feature-stat-list {
- gap: 8px;
- }
- .same-feature-legend {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 12px;
- justify-content: flex-end;
- }
- .same-feature-legend__item {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- color: #6b7280;
- font-size: 12px;
- line-height: 18px;
- }
- .same-feature-legend__dot {
- width: 10px;
- height: 10px;
- border-radius: 999px;
- }
- .same-feature-legend__dot--subject {
- background: linear-gradient(180deg, #4666e5 0%, #2f4de1 100%);
- }
- .same-feature-legend__dot--feature {
- background: linear-gradient(180deg, #ffd56d 0%, #ffb800 100%);
- }
- .same-feature-legend__dot--holder {
- background: #dbe6ff;
- border: 1px solid #4666e5;
- box-sizing: border-box;
- }
- .board-scroll {
- overflow: auto;
- background: transparent;
- }
- .board-scroll--plain {
- border: 0;
- border-radius: 0;
- background: transparent;
- }
- .consistent-action-board-scroll {
- overflow-x: auto;
- overflow-y: hidden;
- scrollbar-width: thin;
- scrollbar-color: rgba(148, 163, 184, 0.7) transparent;
- padding-bottom: 4px;
- &::-webkit-scrollbar {
- width: 8px;
- height: 8px;
- }
- &::-webkit-scrollbar-thumb {
- background: rgba(148, 163, 184, 0.7);
- border-radius: 999px;
- }
- &::-webkit-scrollbar-track {
- background: transparent;
- }
- }
- .same-feature-board-scroll {
- flex: 1 1 auto;
- min-height: 0;
- padding: 0;
- overflow: auto;
- scrollbar-width: thin;
- scrollbar-color: rgba(148, 163, 184, 0.7) transparent;
- &::-webkit-scrollbar {
- width: 8px;
- height: 8px;
- }
- &::-webkit-scrollbar-thumb {
- background: rgba(148, 163, 184, 0.7);
- border-radius: 999px;
- }
- &::-webkit-scrollbar-track {
- background: transparent;
- }
- }
- .same-feature-board,
- .consistent-board {
- position: relative;
- min-width: 100%;
- }
- .same-feature-board {
- min-height: 520px;
- background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
- }
- .relation-svg {
- position: absolute;
- top: 0;
- left: 0;
- z-index: 1;
- pointer-events: none;
- }
- .same-feature-path {
- fill: none;
- stroke-linecap: round;
- stroke-linejoin: round;
- }
- .same-feature-path--subject {
- stroke: #6577a7;
- stroke-width: 2;
- }
- .same-feature-path--holder {
- stroke: #6577a7;
- stroke-width: 1.8;
- }
- .same-feature-main,
- .same-feature-card-node,
- .same-feature-member,
- .same-feature-edge-tags,
- .same-feature-cardholder-label {
- position: absolute;
- }
- .same-feature-main {
- z-index: 3;
- overflow: hidden;
- border-radius: 14px;
- background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
- box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
- border: 1px solid #dbe5f1;
- box-sizing: border-box;
- }
- .same-feature-main__tag {
- position: absolute;
- top: 12px;
- left: 12px;
- z-index: 2;
- padding: 2px 8px;
- border-radius: 999px;
- background: #eaf2ff;
- color: #2f4de1;
- font-size: 11px;
- line-height: 18px;
- font-weight: 600;
- }
- .same-feature-main__body {
- height: 100%;
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 18px 16px 16px;
- box-sizing: border-box;
- }
- .same-feature-main__content {
- min-width: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- flex: 1;
- }
- .same-feature-main__avatar,
- .same-feature-member__avatar {
- position: relative;
- z-index: 1;
- display: flex;
- flex: 0 0 auto;
- }
- .same-feature-main__avatar {
- width: 54px;
- height: 54px;
- }
- .same-feature-main__name {
- width: 100%;
- color: #111827;
- font-size: 15px;
- font-weight: 600;
- line-height: 22px;
- text-align: center;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .same-feature-main__hint {
- margin-top: 4px;
- color: #94a3b8;
- font-size: 12px;
- line-height: 18px;
- text-align: center;
- }
- .same-feature-edge-tags {
- z-index: 3;
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- align-items: center;
- }
- .same-feature-edge-tags__tag {
- padding: 2px 8px;
- border-radius: 999px;
- background: #edf4ff;
- border: 1px solid #d4e5ff;
- color: #3571d9;
- font-size: 11px;
- line-height: 18px;
- white-space: nowrap;
- }
- .same-feature-card-node {
- z-index: 4;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 10px;
- padding: 14px 16px;
- border: 1px solid #dbe5f1;
- border-radius: 16px;
- background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
- box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
- cursor: pointer;
- box-sizing: border-box;
- appearance: none;
- text-align: left;
- transition:
- transform 0.2s ease,
- box-shadow 0.2s ease,
- border-color 0.2s ease;
- &:hover {
- transform: translateY(-1px);
- border-color: #bfd4f5;
- box-shadow: 0 14px 28px rgba(37, 99, 235, 0.1);
- }
- }
- .same-feature-card-node__header {
- width: 100%;
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
- }
- .same-feature-card-node__icon {
- width: 38px;
- height: 38px;
- flex: 0 0 auto;
- border-radius: 12px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: linear-gradient(180deg, #ffd86c 0%, #ffb700 100%);
- color: #fff;
- font-size: 18px;
- box-shadow: 0 8px 18px rgba(255, 183, 0, 0.2);
- transition:
- transform 0.2s ease,
- box-shadow 0.2s ease;
- .anticon {
- line-height: 1;
- }
- .anticon,
- .anticon svg {
- width: 18px;
- height: 18px;
- }
- }
- .same-feature-card-node__bank {
- max-width: 100%;
- flex: 1 1 auto;
- min-width: 0;
- padding: 4px 10px;
- border-radius: 999px;
- border: 1px solid #f9df9b;
- background: #fff8e8;
- color: #a16207;
- font-size: 12px;
- line-height: 18px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .same-feature-card-node__number {
- width: 100%;
- min-height: 40px;
- color: #0f172a;
- font-size: 14px;
- line-height: 20px;
- font-weight: 600;
- letter-spacing: 0.03em;
- word-break: break-all;
- overflow: hidden;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- }
- .same-feature-card-node__footer {
- width: 100%;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- }
- .same-feature-card-node__meta {
- padding: 2px 8px;
- border-radius: 999px;
- background: #eef4ff;
- color: #5b74b1;
- font-size: 12px;
- line-height: 18px;
- white-space: nowrap;
- }
- .same-feature-card-node__amount {
- color: #2563eb;
- font-size: 12px;
- line-height: 18px;
- font-weight: 600;
- text-align: right;
- white-space: nowrap;
- }
- .same-feature-card-node:hover {
- .same-feature-card-node__icon {
- transform: translateY(-1px);
- box-shadow: 0 10px 20px rgba(255, 183, 0, 0.28);
- }
- .same-feature-card-node__number,
- .same-feature-card-node__amount {
- color: #2f4de1;
- }
- .same-feature-card-node__meta {
- background: #e8f0ff;
- color: #3d61b3;
- }
- }
- .same-feature-member {
- z-index: 4;
- display: flex;
- align-items: center;
- gap: 14px;
- padding: 0 18px;
- border-radius: 12px;
- background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
- box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
- border: 1px solid #dbe5f1;
- box-sizing: border-box;
- }
- .same-feature-member__avatar {
- flex: 0 0 auto;
- }
- .same-feature-member__info {
- min-width: 0;
- flex: 1;
- }
- .same-feature-member__name {
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 22px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .same-feature-member__alias {
- margin-top: 4px;
- color: #94a3b8;
- font-size: 12px;
- line-height: 18px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .same-feature-cardholder-label {
- z-index: 3;
- padding: 2px 8px;
- border: 1px solid #d9e7ff;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.92);
- color: #64748b;
- font-size: 11px;
- line-height: 18px;
- white-space: nowrap;
- box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04);
- }
- .consistent-action-card > .empty-panel {
- flex: 1 1 auto;
- min-height: 0;
- overflow: auto;
- }
- .consistent-action-body {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- flex-direction: column;
- overflow-x: hidden;
- overflow-y: auto;
- padding: 8px 0 0;
- scrollbar-width: thin;
- scrollbar-color: rgba(148, 163, 184, 0.7) transparent;
- &::-webkit-scrollbar {
- width: 8px;
- height: 8px;
- }
- &::-webkit-scrollbar-thumb {
- background: rgba(148, 163, 184, 0.7);
- border-radius: 999px;
- }
- &::-webkit-scrollbar-track {
- background: transparent;
- }
- }
- .consistent-action-section {
- padding: 0 18px 22px;
- & + .consistent-action-section {
- border-top: 1px dashed #e5ebf3;
- }
- }
- .consistent-action-section__title {
- padding: 10px 4px 14px;
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 22px;
- }
- .consistent-action-chart {
- position: relative;
- background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
- }
- .consistent-action-svg {
- position: absolute;
- top: 0;
- left: 0;
- z-index: 1;
- pointer-events: none;
- }
- .consistent-action-path {
- fill: none;
- stroke: #6b7499;
- stroke-width: 2;
- stroke-linecap: round;
- stroke-linejoin: round;
- opacity: 0.7;
- }
- .consistent-action-row,
- .consistent-action-person,
- .consistent-action-date {
- position: absolute;
- z-index: 2;
- }
- .consistent-action-row {
- display: flex;
- align-items: center;
- gap: 16px;
- padding: 0 18px;
- border: 1px solid #e6ebf2;
- border-radius: 12px;
- background: #fff;
- box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
- box-sizing: border-box;
- cursor: pointer;
- text-align: left;
- appearance: none;
- transition:
- transform 0.2s ease,
- box-shadow 0.2s ease,
- border-color 0.2s ease;
- color: #334155;
- &:hover {
- transform: translateY(-1px);
- border-color: #bfd4f5;
- box-shadow: 0 14px 28px rgba(37, 99, 235, 0.12);
- }
- }
- .consistent-action-row__name-box {
- display: flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
- flex: 0 0 260px;
- }
- .consistent-action-row__name {
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 22px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .consistent-action-row__card {
- color: #94a3b8;
- font-size: 12px;
- line-height: 20px;
- white-space: nowrap;
- }
- .consistent-action-row__flag {
- flex: 0 0 auto;
- padding: 2px 8px;
- border: 1px solid #ffe2a8;
- border-radius: 999px;
- background: #fff7e6;
- color: #d97706;
- font-size: 11px;
- line-height: 16px;
- }
- .consistent-action-row__bank {
- flex: 1;
- min-width: 0;
- color: #475569;
- font-size: 12px;
- line-height: 20px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .consistent-action-row__money {
- flex: 0 0 140px;
- color: #1677ff;
- font-size: 12px;
- font-weight: 600;
- line-height: 20px;
- text-align: right;
- white-space: nowrap;
- }
- .consistent-action-row__block {
- width: 36px;
- height: 16px;
- border-radius: 4px;
- flex: 0 0 auto;
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28);
- }
- .consistent-action-person {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 0 16px;
- border: 1px solid #dbe5f1;
- border-radius: 12px;
- background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
- box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
- box-sizing: border-box;
- cursor: pointer;
- appearance: none;
- text-align: left;
- transition:
- transform 0.2s ease,
- box-shadow 0.2s ease,
- border-color 0.2s ease;
- &:hover {
- transform: translateY(-1px);
- border-color: #bfd4f5;
- box-shadow: 0 14px 30px rgba(22, 119, 255, 0.12);
- }
- }
- .consistent-action-person__avatar {
- width: 44px;
- height: 44px;
- display: flex;
- flex: 0 0 auto;
- }
- .consistent-action-person__info {
- min-width: 0;
- flex: 1;
- }
- .consistent-action-person__name {
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 22px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .consistent-action-person__card {
- margin-top: 4px;
- color: #94a3b8;
- font-size: 12px;
- line-height: 18px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .consistent-action-date {
- width: 196px;
- padding: 0 8px 0 0;
- box-sizing: border-box;
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 24px;
- white-space: nowrap;
- text-align: right;
- }
- .subject-node,
- .holder-node,
- .consistent-target-node,
- .consistent-center-node,
- .feature-node,
- .consistent-source-node {
- position: absolute;
- z-index: 2;
- }
- .subject-node,
- .consistent-target-node {
- padding: 14px 16px;
- border: 1px solid #c6dbff;
- border-radius: 16px;
- background: linear-gradient(135deg, #f8fbff 0%, #eef5ff 100%);
- box-shadow: 0 12px 30px rgba(37, 99, 235, 0.08);
- display: flex;
- flex-direction: column;
- justify-content: center;
- }
- .subject-node__label,
- .consistent-target-node__label,
- .holder-node__label {
- color: #6b7280;
- font-size: 11px;
- line-height: 18px;
- }
- .subject-node__name,
- .consistent-target-node__name,
- .holder-node__name {
- margin-top: 4px;
- color: #111827;
- font-size: 18px;
- font-weight: 600;
- line-height: 24px;
- }
- .subject-node__desc,
- .consistent-target-node__desc,
- .holder-node__desc {
- margin-top: 6px;
- color: #64748b;
- font-size: 12px;
- line-height: 18px;
- }
- .subject-node__chips {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-top: 10px;
- }
- .subject-chip {
- display: inline-flex;
- align-items: center;
- padding: 2px 8px;
- border-radius: 999px;
- border: 1px solid #dbeafe;
- background: #fff;
- color: #2563eb;
- font-size: 10px;
- line-height: 16px;
- }
- .feature-node,
- .consistent-source-node {
- padding: 14px 16px;
- border: 1px solid #d7e3f3;
- border-radius: 16px;
- background: #fff;
- box-shadow: 0 12px 30px rgba(37, 99, 235, 0.08);
- text-align: left;
- cursor: pointer;
- transition:
- transform 0.2s ease,
- box-shadow 0.2s ease,
- border-color 0.2s ease;
- appearance: none;
- }
- .feature-node:hover,
- .consistent-source-node:hover {
- transform: translateY(-1px);
- border-color: #93c5fd;
- box-shadow: 0 16px 34px rgba(37, 99, 235, 0.12);
- }
- .feature-node__head {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 8px;
- }
- .feature-node__label {
- color: #6b7280;
- font-size: 11px;
- line-height: 18px;
- }
- .feature-node__amount {
- color: #2563eb;
- font-size: 12px;
- font-weight: 600;
- line-height: 18px;
- white-space: nowrap;
- }
- .feature-node__card,
- .consistent-source-node__card {
- margin-top: 6px;
- color: #111827;
- font-size: 15px;
- font-weight: 600;
- line-height: 22px;
- }
- .feature-node__meta,
- .consistent-source-node__meta {
- margin-top: 4px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- color: #64748b;
- font-size: 12px;
- line-height: 18px;
- }
- .feature-tag-list {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-top: 10px;
- }
- .feature-tag {
- display: inline-flex;
- align-items: center;
- padding: 3px 8px;
- border-radius: 999px;
- border: 1px solid #dbeafe;
- background: #eff6ff;
- color: #2563eb;
- font-size: 11px;
- line-height: 16px;
- }
- .feature-node__footer {
- margin-top: 12px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- color: #64748b;
- font-size: 12px;
- line-height: 18px;
- }
- .feature-node__action {
- color: #1677ff;
- font-weight: 600;
- }
- .holder-node {
- padding: 14px 16px;
- border: 1px solid #dbe5f1;
- border-radius: 14px;
- background: #fff;
- box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
- display: flex;
- flex-direction: column;
- justify-content: center;
- }
- .holder-node__metrics {
- margin-top: 10px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- color: #475569;
- font-size: 12px;
- line-height: 18px;
- }
- .consistent-source-node__name {
- color: #111827;
- font-size: 15px;
- font-weight: 600;
- line-height: 22px;
- }
- .consistent-source-node__tag {
- margin-top: 10px;
- display: inline-flex;
- align-items: center;
- padding: 3px 10px;
- border-radius: 999px;
- border: 1px solid #ffe2a8;
- background: #fff7e6;
- color: #d46b08;
- font-size: 11px;
- line-height: 16px;
- }
- .consistent-center-node {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 4px;
- border: 1px solid #bfd4f5;
- border-radius: 18px;
- background: linear-gradient(135deg, #eef5ff 0%, #f8fbff 100%);
- box-shadow: 0 12px 28px rgba(22, 119, 255, 0.12);
- color: #2563eb;
- text-align: center;
- }
- .consistent-center-node__label {
- font-size: 12px;
- line-height: 18px;
- }
- .consistent-center-node__value {
- font-size: 16px;
- font-weight: 600;
- line-height: 24px;
- }
- .insight-strip {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 12px;
- padding: 16px 18px 18px;
- background: #fff;
- }
- .insight-card {
- padding: 14px 16px;
- border: 1px solid #edf2f7;
- border-radius: 12px;
- background: #fafcff;
- }
- .insight-card__label {
- color: #6b7280;
- font-size: 11px;
- line-height: 18px;
- }
- .insight-card__value {
- margin-top: 6px;
- color: #111827;
- font-size: 13px;
- line-height: 22px;
- }
- .empty-panel {
- min-height: 420px;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 20px 16px;
- background: transparent;
- }
- :deep(.ant-select-selector),
- :deep(.ant-input),
- :deep(.ant-picker),
- :deep(.ant-btn) {
- border-radius: 8px;
- }
- @media screen and (max-width: 900px) {
- flex-direction: column;
- .left-sidebar {
- width: 100%;
- flex: none;
- }
- .menu-list {
- flex-direction: row;
- flex-wrap: wrap;
- }
- .menu-item {
- flex: 1 1 calc(50% - 4px);
- min-width: 180px;
- }
- .panel-header {
- flex-direction: column;
- }
- .panel-stat-list {
- width: 100%;
- justify-content: flex-start;
- }
- .insight-strip {
- grid-template-columns: 1fr;
- }
- }
- @media screen and (max-width: 992px) {
- .filter-item {
- width: 100%;
- align-items: flex-start;
- flex-wrap: wrap;
- }
- .filter-input,
- .filter-group,
- .filter-mini,
- .filter-micro,
- .filter-date {
- width: 100%;
- }
- .filter-actions {
- width: 100%;
- margin-left: 0;
- }
- }
- }
- @import '../styles/trans-analysis-table-ui.less';
- </style>
|