index.vue 87 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977
  1. <template>
  2. <div :class="prefixCls" class="card-holding-page">
  3. <div class="main-content">
  4. <div class="page-header">
  5. <div class="page-title">银行卡代持分析</div>
  6. </div>
  7. <div class="filter-bar">
  8. <template v-if="activeMenu === 'consistentAction'">
  9. <div class="filter-item">
  10. <span class="filter-label">调查分组:</span>
  11. <Select
  12. v-model:value="consistentFilters.groupId"
  13. class="filter-group"
  14. :options="consistentGroupOptions"
  15. placeholder="请选择调查分组"
  16. />
  17. </div>
  18. <div class="filter-item">
  19. <span class="filter-label">时间间隔:</span>
  20. <span class="filter-fixed-op">&lt;=</span>
  21. <Input
  22. v-model:value="consistentFilters.dayGap"
  23. class="filter-micro"
  24. :status="dayGapStatus"
  25. placeholder="请输入"
  26. />
  27. <span class="filter-unit">天</span>
  28. </div>
  29. <div class="filter-item">
  30. <span class="filter-label">大额金额:</span>
  31. <Select v-model:value="consistentFilters.moneyOp" class="filter-op" :options="cardHoldCompareOptions" />
  32. <Input
  33. v-model:value="consistentFilters.money"
  34. class="filter-mini"
  35. :status="consistentMoneyStatus"
  36. placeholder="请输入金额"
  37. />
  38. </div>
  39. <div class="filter-item">
  40. <span class="filter-label">共同交易卡数量:</span>
  41. <Select v-model:value="consistentFilters.cardNumOp" class="filter-op" :options="cardHoldCompareOptions" />
  42. <Input
  43. v-model:value="consistentFilters.cardNum"
  44. class="filter-mini"
  45. :status="cardNumStatus"
  46. placeholder="请输入数量"
  47. />
  48. </div>
  49. </template>
  50. <template v-else>
  51. <div class="filter-item">
  52. <span class="filter-label">调查分组:</span>
  53. <Select
  54. v-model:value="sameFeatureFilters.group"
  55. class="filter-group"
  56. :options="sameFeatureGroupOptions"
  57. placeholder="请选择调查分组"
  58. />
  59. </div>
  60. <div class="filter-item">
  61. <span class="filter-label">大额金额:</span>
  62. <Select v-model:value="sameFeatureFilters.amountOp" class="filter-op" :options="cardHoldCompareOptions" />
  63. <Input
  64. v-model:value="sameFeatureFilters.amountValue"
  65. class="filter-mini"
  66. :status="amountValueStatus"
  67. placeholder="请输入金额"
  68. />
  69. </div>
  70. <div class="filter-item">
  71. <span class="filter-label">交易日期:</span>
  72. <RangePicker
  73. v-model:value="sameFeatureFilters.dateRange"
  74. class="filter-date"
  75. :format="DATE_FORMAT"
  76. :value-format="DATE_FORMAT"
  77. :placeholder="['开始日期', '结束日期']"
  78. />
  79. </div>
  80. </template>
  81. <div class="filter-actions">
  82. <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
  83. <Button @click="handleReset">重置</Button>
  84. </div>
  85. </div>
  86. <div class="view-panel">
  87. <div class="left-sidebar">
  88. <div class="menu-list">
  89. <button
  90. v-for="menu in menuOptions"
  91. :key="menu.key"
  92. type="button"
  93. class="menu-item"
  94. :class="{ active: activeMenu === menu.key }"
  95. @click="handleMenuChange(menu.key)"
  96. >
  97. <span class="menu-item__label">{{ menu.label }}</span>
  98. <span class="menu-item__desc">{{ menu.description }}</span>
  99. </button>
  100. </div>
  101. </div>
  102. <div class="view-main">
  103. <div v-if="activeMenu === 'sameFeature'" class="analysis-card">
  104. <div class="panel-header">
  105. <div>
  106. <div class="panel-title">异卡同特征关系视图</div>
  107. <div class="panel-desc">{{ sameFeatureDescription }}</div>
  108. </div>
  109. <div class="same-feature-header-extra">
  110. <div v-if="sameFeatureStats.length" class="panel-stat-list same-feature-stat-list">
  111. <div v-for="item in sameFeatureStats" :key="item.label" class="panel-stat">
  112. <div class="panel-stat__label">{{ item.label }}</div>
  113. <div class="panel-stat__value">{{ item.value }}</div>
  114. </div>
  115. </div>
  116. <div class="same-feature-legend">
  117. <span class="same-feature-legend__item">
  118. <span class="same-feature-legend__dot same-feature-legend__dot--subject"></span>
  119. 主体
  120. </span>
  121. <span class="same-feature-legend__item">
  122. <span class="same-feature-legend__dot same-feature-legend__dot--feature"></span>
  123. 特征银行卡
  124. </span>
  125. <span class="same-feature-legend__item">
  126. <span class="same-feature-legend__dot same-feature-legend__dot--holder"></span>
  127. 持卡人
  128. </span>
  129. </div>
  130. </div>
  131. </div>
  132. <div v-if="!hasQueryResult || !activeSameFeatureScene?.links.length" class="empty-panel">
  133. <EmptyState
  134. :variant="hasQueryResult ? 'default' : 'query'"
  135. :title="hasQueryResult ? '暂无数据' : '请选择调查分组后查询'"
  136. :description="sameFeatureEmptyDescription"
  137. :min-height="hasQueryResult ? 220 : 320"
  138. :description-width="360"
  139. />
  140. </div>
  141. <template v-else>
  142. <div class="board-scroll board-scroll--plain same-feature-board-scroll">
  143. <div
  144. ref="sameFeatureBoardRef"
  145. class="same-feature-board"
  146. :style="{ height: `${sameFeatureBoardHeight}px` }"
  147. >
  148. <svg class="relation-svg" :width="sameFeatureBoardWidth" :height="sameFeatureBoardHeight">
  149. <path :d="sameFeatureSubjectPath" class="same-feature-path same-feature-path--subject" />
  150. <path
  151. v-for="path in sameFeatureFeaturePaths"
  152. :key="path.key"
  153. :d="path.d"
  154. class="same-feature-path same-feature-path--subject"
  155. />
  156. <path
  157. v-for="path in sameFeatureHolderPaths"
  158. :key="path.key"
  159. :d="path.d"
  160. class="same-feature-path same-feature-path--holder"
  161. />
  162. </svg>
  163. <div class="same-feature-main" :style="toRectStyle(sameFeatureSubjectNode)">
  164. <div class="same-feature-main__tag">主体</div>
  165. <div class="same-feature-main__body">
  166. <PersonAvatar class="same-feature-main__avatar" :size="54" />
  167. <div class="same-feature-main__content">
  168. <div class="same-feature-main__name">{{ activeSameFeatureScene.subject.name }}</div>
  169. <div class="same-feature-main__hint">异卡同特征分析对象</div>
  170. </div>
  171. </div>
  172. </div>
  173. <div
  174. v-for="group in sameFeatureFeatureLabelGroups"
  175. :key="group.key"
  176. class="same-feature-edge-tags"
  177. :style="{ left: `${group.x}px`, top: `${group.y}px`, maxWidth: `${group.maxWidth}px` }"
  178. >
  179. <span
  180. v-for="feature in group.features"
  181. :key="`${group.key}-${feature}`"
  182. class="same-feature-edge-tags__tag"
  183. >
  184. {{ feature }}
  185. </span>
  186. </div>
  187. <button
  188. v-for="node in sameFeatureFeatureNodes"
  189. :key="node.link.id"
  190. type="button"
  191. class="same-feature-card-node"
  192. :style="toRectStyle(node)"
  193. @click="handleFeatureCardClick(node.link)"
  194. >
  195. <div class="same-feature-card-node__header">
  196. <div class="same-feature-card-node__icon">
  197. <CreditCardOutlined />
  198. </div>
  199. <div class="same-feature-card-node__bank" :title="node.link.bankName || '特征银行卡'">
  200. {{ node.link.bankName || '特征银行卡' }}
  201. </div>
  202. </div>
  203. <div class="same-feature-card-node__number" :title="node.link.cardNo">
  204. {{ formatCardDisplay(node.link.cardNo) }}
  205. </div>
  206. <div class="same-feature-card-node__footer">
  207. <div class="same-feature-card-node__meta">命中特征 {{ node.link.features.length }} 项</div>
  208. <div v-if="node.link.amount !== undefined" class="same-feature-card-node__amount">
  209. 金额 {{ formatMoneyText(node.link.amount) }}
  210. </div>
  211. </div>
  212. </button>
  213. <div
  214. v-for="node in sameFeatureHolderNodes"
  215. :key="node.holder.id"
  216. class="same-feature-member"
  217. :style="toRectStyle(node)"
  218. >
  219. <PersonAvatar class="same-feature-member__avatar" :size="44" />
  220. <div class="same-feature-member__info">
  221. <div class="same-feature-member__name">{{ node.holder.name }}</div>
  222. <div v-if="node.holder.alias" class="same-feature-member__alias">{{ node.holder.alias }}</div>
  223. </div>
  224. </div>
  225. <div
  226. v-for="label in sameFeatureHolderLabels"
  227. :key="label.key"
  228. class="same-feature-cardholder-label"
  229. :style="{ left: `${label.x}px`, top: `${label.y}px` }"
  230. >
  231. {{ label.text }}
  232. </div>
  233. </div>
  234. </div>
  235. </template>
  236. </div>
  237. <div v-else-if="activeMenu === 'consistentAction'" class="analysis-card consistent-action-card">
  238. <div class="panel-header">
  239. <div>
  240. <div class="panel-title">多卡汇集关系视图</div>
  241. <div class="panel-desc">{{ consistentActionDescription }}</div>
  242. </div>
  243. </div>
  244. <div v-if="!consistentActionQueried || !consistentActionLayouts.length" class="empty-panel">
  245. <EmptyState
  246. :variant="consistentActionQueried ? 'default' : 'query'"
  247. :title="consistentActionQueried ? '暂无数据' : '请选择调查分组后查询'"
  248. :description="consistentActionEmptyDescription"
  249. :min-height="consistentActionQueried ? 220 : 320"
  250. :description-width="360"
  251. />
  252. </div>
  253. <template v-else>
  254. <div ref="consistentActionBodyRef" class="consistent-action-body">
  255. <section v-for="period in consistentActionLayouts" :key="period.id" class="consistent-action-section">
  256. <div class="consistent-action-section__title">被调查对象分组 · {{ consistentActionGroupLabel }}</div>
  257. <div class="board-scroll board-scroll--plain consistent-action-board-scroll">
  258. <div
  259. class="consistent-action-chart"
  260. :style="{ height: `${period.height}px`, width: `${consistentActionMetrics.chartWidth}px` }"
  261. >
  262. <svg
  263. class="consistent-action-svg"
  264. :width="consistentActionMetrics.chartWidth"
  265. :height="period.height"
  266. >
  267. <path
  268. v-for="path in period.rowPaths"
  269. :key="path.key"
  270. :d="path.d"
  271. class="consistent-action-path"
  272. />
  273. <path
  274. v-for="path in period.personPaths"
  275. :key="path.key"
  276. :d="path.d"
  277. class="consistent-action-path"
  278. />
  279. </svg>
  280. <template v-for="person in period.people" :key="person.id">
  281. <button
  282. v-for="row in person.rows"
  283. :key="row.id"
  284. type="button"
  285. class="consistent-action-row"
  286. :style="toRectStyle(row)"
  287. @click="handleConsistentActionRowClick(row.row)"
  288. >
  289. <div class="consistent-action-row__name-box">
  290. <span class="consistent-action-row__name">{{ row.row.objName }}</span>
  291. <span class="consistent-action-row__card">({{ row.row.selfCardNo }})</span>
  292. <span v-if="row.row.objFlag" class="consistent-action-row__flag">{{
  293. row.row.objFlag
  294. }}</span>
  295. </div>
  296. <div class="consistent-action-row__bank" :title="row.row.targetBankName">
  297. {{ row.row.targetBankName }}
  298. </div>
  299. <div class="consistent-action-row__money">支出: {{ row.row.dealMoneyText }}</div>
  300. <span class="consistent-action-row__block" :style="{ backgroundColor: row.row.color }"></span>
  301. </button>
  302. <button
  303. type="button"
  304. class="consistent-action-person"
  305. :style="toRectStyle(person)"
  306. @click="handleConsistentActionPersonClick(person.person)"
  307. >
  308. <PersonAvatar class="consistent-action-person__avatar" :size="44" />
  309. <div class="consistent-action-person__info">
  310. <div class="consistent-action-person__name">{{ person.person.name }}</div>
  311. <div class="consistent-action-person__card">{{ person.person.card }}</div>
  312. </div>
  313. </button>
  314. </template>
  315. <div
  316. class="consistent-action-date"
  317. :style="{ left: `${consistentActionMetrics.dateX}px`, top: `${period.dateY - 12}px` }"
  318. >
  319. {{ period.period.dateLabel }}
  320. </div>
  321. </div>
  322. </div>
  323. </section>
  324. </div>
  325. </template>
  326. </div>
  327. </div>
  328. </div>
  329. <TransRecordModal v-model:open="transRecordModalOpen" title="交易记录" :base-query="transRecordBaseQuery" />
  330. </div>
  331. </div>
  332. </template>
  333. <script lang="ts" setup name="ViewsCardHoldingIndex">
  334. import { CreditCardOutlined } from '@ant-design/icons-vue';
  335. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
  336. import { Button, DatePicker, Input, Modal, Select } from 'ant-design-vue';
  337. import { useRouter } from 'vue-router';
  338. import { EmptyState } from '@jeesite/core/components/EmptyState';
  339. import { PersonAvatar } from '@jeesite/core/components/PersonAvatar';
  340. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  341. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  342. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  343. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  344. import {
  345. COMPARE_OPERATOR_OPTIONS,
  346. DEFAULT_COMPARE_OPERATOR,
  347. DEFAULT_LE_COMPARE_OPERATOR,
  348. normalizeCompareOperator,
  349. type CompareOperator,
  350. } from '@jeesite/core/utils/compareOperator';
  351. import { listPersonGroups } from '@jeesite/person/api/personApi';
  352. import { statTogetherDoTrans, statTransCardHold } from '@jeesite/trans/api/transApi';
  353. import type {
  354. TransCardHoldDTO,
  355. TransCardHoldFeatureDTO,
  356. TransCardHoldTogetherDTO,
  357. TransCardHoldTogetherItemDTO,
  358. TransCardHoldTogetherSummaryDTO,
  359. } from '@jeesite/trans/api/types/dto';
  360. import type {
  361. TransCardHoldQuery,
  362. TransCardHoldTogetherQuery,
  363. TransRecordQuery,
  364. } from '@jeesite/trans/api/types/query';
  365. import TransRecordModal from '../components/TransRecordModal.vue';
  366. type RangeValue = [string, string] | undefined;
  367. type MenuKey = 'consistentAction' | 'sameFeature';
  368. type StatusType = 'error' | undefined;
  369. interface OptionItem {
  370. label: string;
  371. value: string;
  372. }
  373. interface SameFeatureHolder {
  374. id: string;
  375. name: string;
  376. alias: string;
  377. }
  378. interface SameFeatureLink {
  379. id: string;
  380. cardNo: string;
  381. holderId: string;
  382. bankName?: string;
  383. features: string[];
  384. amount?: number | string;
  385. transDate?: string;
  386. ids: Array<string>;
  387. }
  388. interface SameFeatureScene {
  389. group: string;
  390. focus: string;
  391. subject: {
  392. name: string;
  393. };
  394. holders: SameFeatureHolder[];
  395. links: SameFeatureLink[];
  396. }
  397. interface SameFeatureQuerySnapshot {
  398. group: string;
  399. amountOp: CompareOperator;
  400. amountValue?: string;
  401. dateRange: RangeValue;
  402. }
  403. interface RectNode {
  404. x: number;
  405. y: number;
  406. width: number;
  407. height: number;
  408. }
  409. interface PathDefinition {
  410. key: string;
  411. d: string;
  412. }
  413. interface ConsistentActionFilters {
  414. groupId: string;
  415. dayGap: string;
  416. moneyOp: CompareOperator;
  417. money: string;
  418. cardNumOp: CompareOperator;
  419. cardNum: string;
  420. }
  421. interface ConsistentActionQuerySnapshot {
  422. groupId: string;
  423. groupLabel: string;
  424. dayGap: number;
  425. moneyOp: CompareOperator;
  426. money?: string;
  427. cardNumOp: CompareOperator;
  428. cardNum?: number;
  429. }
  430. interface ConsistentActionRowData {
  431. id: string;
  432. objName: string;
  433. selfCardNo: string;
  434. objFlag: string;
  435. targetBankName: string;
  436. dealMoneyText: string;
  437. ids: Array<string>;
  438. color: string;
  439. tag: string;
  440. }
  441. interface ConsistentActionPersonData {
  442. id: string;
  443. name: string;
  444. card: string;
  445. ids: Array<string>;
  446. rows: ConsistentActionRowData[];
  447. }
  448. interface ConsistentActionPeriodData {
  449. id: string;
  450. minDate: string;
  451. maxDate: string;
  452. dateLabel: string;
  453. people: ConsistentActionPersonData[];
  454. }
  455. interface ConsistentActionRowNode extends RectNode {
  456. id: string;
  457. row: ConsistentActionRowData;
  458. }
  459. interface ConsistentActionPersonNode extends RectNode {
  460. id: string;
  461. person: ConsistentActionPersonData;
  462. rows: ConsistentActionRowNode[];
  463. }
  464. interface ConsistentActionLayout {
  465. id: string;
  466. period: ConsistentActionPeriodData;
  467. people: ConsistentActionPersonNode[];
  468. height: number;
  469. dateY: number;
  470. rowPaths: PathDefinition[];
  471. personPaths: PathDefinition[];
  472. }
  473. interface ConsistentActionMetrics {
  474. chartWidth: number;
  475. dateX: number;
  476. personX: number;
  477. rowWidth: number;
  478. rowJoinX: number;
  479. dateJoinX: number;
  480. dateLineEndX: number;
  481. }
  482. type ConsistentActionResponseLike =
  483. | TransCardHoldTogetherDTO[]
  484. | {
  485. list?: TransCardHoldTogetherDTO[];
  486. records?: TransCardHoldTogetherDTO[];
  487. data?:
  488. | TransCardHoldTogetherDTO[]
  489. | {
  490. list?: TransCardHoldTogetherDTO[];
  491. records?: TransCardHoldTogetherDTO[];
  492. };
  493. }
  494. | null
  495. | undefined;
  496. type TransCardHoldResponseLike =
  497. | TransCardHoldDTO
  498. | {
  499. data?: TransCardHoldDTO;
  500. }
  501. | null
  502. | undefined;
  503. interface TextLabel {
  504. key: string;
  505. x: number;
  506. y: number;
  507. text: string;
  508. }
  509. interface FeatureTagGroup {
  510. key: string;
  511. x: number;
  512. y: number;
  513. maxWidth: number;
  514. features: string[];
  515. }
  516. const RangePicker = DatePicker.RangePicker;
  517. const DATE_FORMAT = 'YYYY-MM-DD';
  518. const SAME_FEATURE_MIN_WIDTH = 980;
  519. const SAME_FEATURE_MAIN_WIDTH = 176;
  520. const SAME_FEATURE_MAIN_HEIGHT = 112;
  521. const SAME_FEATURE_CARD_WIDTH = 228;
  522. const SAME_FEATURE_CARD_HEIGHT = 144;
  523. const SAME_FEATURE_CARD_GAP = 24;
  524. const SAME_FEATURE_MEMBER_WIDTH = 188;
  525. const SAME_FEATURE_MEMBER_HEIGHT = 76;
  526. const SAME_FEATURE_MEMBER_RIGHT_GAP = 44;
  527. const SAME_FEATURE_MAIN_X = 28;
  528. const SAME_FEATURE_CONTENT_TOP = 68;
  529. const SAME_FEATURE_BUS_GAP = 152;
  530. const CONSISTENT_ACTION_ROW_X = 24;
  531. const CONSISTENT_ACTION_ROW_MIN_WIDTH = 644;
  532. const CONSISTENT_ACTION_ROW_HEIGHT = 54;
  533. const CONSISTENT_ACTION_ROW_GAP = 16;
  534. const CONSISTENT_ACTION_ROW_PERSON_GAP = 48;
  535. const CONSISTENT_ACTION_PERSON_WIDTH = 208;
  536. const CONSISTENT_ACTION_PERSON_HEIGHT = 72;
  537. const CONSISTENT_ACTION_PERSON_DATE_GAP = 28;
  538. const CONSISTENT_ACTION_DATE_MIN_WIDTH = 196;
  539. const CONSISTENT_ACTION_CHART_RIGHT_PADDING = 24;
  540. const CONSISTENT_ACTION_GROUP_GAP = 40;
  541. const CONSISTENT_ACTION_SECTION_PADDING_TOP = 30;
  542. const CONSISTENT_ACTION_SECTION_PADDING_BOTTOM = 24;
  543. const CONSISTENT_ACTION_MIN_HEIGHT = 180;
  544. const CONSISTENT_ACTION_MIN_WIDTH =
  545. CONSISTENT_ACTION_ROW_X +
  546. CONSISTENT_ACTION_ROW_MIN_WIDTH +
  547. CONSISTENT_ACTION_ROW_PERSON_GAP +
  548. CONSISTENT_ACTION_PERSON_WIDTH +
  549. CONSISTENT_ACTION_PERSON_DATE_GAP +
  550. CONSISTENT_ACTION_DATE_MIN_WIDTH +
  551. CONSISTENT_ACTION_CHART_RIGHT_PADDING;
  552. const CONSISTENT_ACTION_ROW_COLORS = ['#4d79f6', '#2fb9a7', '#f59e0b', '#ef7d6b', '#7c91ff', '#49a5ff'];
  553. const menuOptions: Array<{ key: MenuKey; label: string; description: string }> = [
  554. { key: 'consistentAction', label: '多卡汇集', description: '查看多节点协同行为与共同窗口。' },
  555. { key: 'sameFeature', label: '异卡同特征', description: '按同IP、同MAC、同号等特征归并。' },
  556. ];
  557. const sameFeatureGroupOptions = computed(() => consistentGroupOptions.value);
  558. const { prefixCls } = useDesign('card-holding');
  559. const { createMessage } = useMessage();
  560. const { queryLoading, runWithQueryLoading } = useTableSkill();
  561. const router = useRouter();
  562. const activeMenu = ref<MenuKey>('consistentAction');
  563. const consistentGroupOptions = ref<OptionItem[]>([]);
  564. const emptyGroupPrompted = ref(false);
  565. const transRecordModalOpen = ref(false);
  566. const transRecordBaseQuery = ref<Partial<TransRecordQuery>>({});
  567. const sameFeatureScene = ref<SameFeatureScene | null>(null);
  568. const sameFeatureBoardRef = ref<HTMLDivElement | null>(null);
  569. const sameFeatureBoardWidth = ref(SAME_FEATURE_MIN_WIDTH);
  570. const consistentActionBodyRef = ref<HTMLDivElement | null>(null);
  571. const consistentActionBoardWidth = ref(CONSISTENT_ACTION_MIN_WIDTH);
  572. const createSameFeatureFilters = () => ({
  573. group: '',
  574. amountOp: DEFAULT_COMPARE_OPERATOR as CompareOperator,
  575. amountValue: '10000',
  576. dateRange: undefined as RangeValue,
  577. });
  578. const createConsistentActionFilters = (): ConsistentActionFilters => ({
  579. groupId: '',
  580. dayGap: '7',
  581. moneyOp: DEFAULT_COMPARE_OPERATOR,
  582. money: '10000',
  583. cardNumOp: DEFAULT_COMPARE_OPERATOR,
  584. cardNum: '2',
  585. });
  586. const sameFeatureFilters = ref(createSameFeatureFilters());
  587. const sameFeatureQuerySnapshot = ref<SameFeatureQuerySnapshot | null>(null);
  588. const consistentFilters = ref<ConsistentActionFilters>(createConsistentActionFilters());
  589. const consistentActionQueried = ref(false);
  590. const consistentActionLastQuery = ref<ConsistentActionQuerySnapshot | null>(null);
  591. const consistentActionPeriods = ref<ConsistentActionPeriodData[]>([]);
  592. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  593. refresh: async () => {
  594. if (activeMenu.value === 'consistentAction') {
  595. await handleConsistentActionSearch();
  596. return;
  597. }
  598. await handleSameFeatureSearch();
  599. },
  600. hasData: () =>
  601. activeMenu.value === 'consistentAction'
  602. ? consistentActionQueried.value && consistentActionPeriods.value.length > 0
  603. : !!sameFeatureQuerySnapshot.value && !!sameFeatureScene.value?.links.length,
  604. });
  605. const amountOpOptions = [...COMPARE_OPERATOR_OPTIONS];
  606. const cardHoldCompareOptions = amountOpOptions.filter((item) => item.value === 'GE' || item.value === 'LE');
  607. const amountPattern = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/;
  608. const positiveIntegerPattern = /^[1-9]\d*$/;
  609. const normalizeDateRange = (dateRange: RangeValue): RangeValue => {
  610. if (!Array.isArray(dateRange)) return undefined;
  611. return [dateRange[0], dateRange[1]];
  612. };
  613. const parseAmountValue = (value?: string): string | undefined | null => {
  614. const next = String(value ?? '').trim();
  615. const amountForCheck = next.replace(/,/g, '');
  616. if (!amountForCheck) return undefined;
  617. if (!amountPattern.test(amountForCheck) || /^0(?:\.0{1,2})?$/.test(amountForCheck)) return null;
  618. return next;
  619. };
  620. const parsePositiveInteger = (value?: string) => {
  621. const next = String(value ?? '').trim();
  622. if (!next) return undefined;
  623. if (!positiveIntegerPattern.test(next)) return null;
  624. const parsed = Number(next);
  625. return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
  626. };
  627. const toComparableAmount = (value?: number | string | null) => {
  628. if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
  629. const text = String(value ?? '')
  630. .trim()
  631. .replace(/,/g, '');
  632. if (!text) return undefined;
  633. const parsed = Number(text);
  634. return Number.isFinite(parsed) ? parsed : undefined;
  635. };
  636. const matchesAmount = (
  637. amount: number | string | undefined,
  638. threshold?: number | string,
  639. operator?: CompareOperator,
  640. ) => {
  641. if (threshold === undefined) return true;
  642. const currentAmount = toComparableAmount(amount);
  643. const targetAmount = toComparableAmount(threshold);
  644. if (currentAmount === undefined || targetAmount === undefined) return false;
  645. switch (normalizeCompareOperator(operator, DEFAULT_COMPARE_OPERATOR)) {
  646. case 'EQ':
  647. return currentAmount === targetAmount;
  648. case 'GT':
  649. return currentAmount > targetAmount;
  650. case 'LT':
  651. return currentAmount < targetAmount;
  652. case 'LE':
  653. return currentAmount <= targetAmount;
  654. case 'GE':
  655. default:
  656. return currentAmount >= targetAmount;
  657. }
  658. };
  659. const matchesDateRange = (date: string, dateRange: RangeValue) => {
  660. if (!Array.isArray(dateRange)) return true;
  661. const currentDate = String(date || '').trim();
  662. if (!currentDate) return false;
  663. const [startDate, endDate] = dateRange;
  664. if (startDate && currentDate < startDate) return false;
  665. if (endDate && currentDate > endDate) return false;
  666. return true;
  667. };
  668. const isLegacyCardHoldCompareOperator = (operator?: CompareOperator) => {
  669. const normalized = normalizeCompareOperator(operator, DEFAULT_COMPARE_OPERATOR);
  670. return normalized === 'GE' || normalized === 'LE';
  671. };
  672. const toLegacyCardHoldCompareSymbol = (operator?: CompareOperator) => {
  673. return operator;
  674. };
  675. const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
  676. const toRectStyle = (node: RectNode) => ({
  677. left: `${node.x}px`,
  678. top: `${node.y}px`,
  679. width: `${node.width}px`,
  680. height: `${node.height}px`,
  681. });
  682. const createConsistentActionMetrics = (boardWidth: number): ConsistentActionMetrics => {
  683. const chartWidth = Math.max(boardWidth, CONSISTENT_ACTION_MIN_WIDTH);
  684. const dateX = chartWidth - CONSISTENT_ACTION_CHART_RIGHT_PADDING - CONSISTENT_ACTION_DATE_MIN_WIDTH;
  685. const personX = dateX - CONSISTENT_ACTION_PERSON_DATE_GAP - CONSISTENT_ACTION_PERSON_WIDTH;
  686. const rowWidth = Math.max(
  687. CONSISTENT_ACTION_ROW_MIN_WIDTH,
  688. personX - CONSISTENT_ACTION_ROW_PERSON_GAP - CONSISTENT_ACTION_ROW_X,
  689. );
  690. return {
  691. chartWidth,
  692. dateX,
  693. personX,
  694. rowWidth,
  695. rowJoinX: CONSISTENT_ACTION_ROW_X + rowWidth + Math.round(CONSISTENT_ACTION_ROW_PERSON_GAP / 2),
  696. dateJoinX: personX + CONSISTENT_ACTION_PERSON_WIDTH + Math.round(CONSISTENT_ACTION_PERSON_DATE_GAP / 2),
  697. dateLineEndX: dateX - 8,
  698. };
  699. };
  700. const resolveSameFeatureGroupLabel = (group?: string) => {
  701. if (!group) return '当前分组';
  702. return sameFeatureGroupOptions.value.find((item) => item.value === group)?.label || group;
  703. };
  704. const resolveConsistentGroupLabel = (groupId?: string) => {
  705. if (!groupId) return '';
  706. return consistentGroupOptions.value.find((item) => item.value === groupId)?.label || '';
  707. };
  708. const buildDateLabel = (minDate?: string, maxDate?: string) => {
  709. const start = String(minDate || '').trim();
  710. const end = String(maxDate || '').trim();
  711. if (start && end) return `${start} 至 ${end}`;
  712. return start || end || '时间未知';
  713. };
  714. const formatMoneyText = (value?: number | string | null) => {
  715. const amount = Number(value ?? '');
  716. if (!Number.isFinite(amount)) {
  717. const text = String(value ?? '').trim();
  718. return text || '-';
  719. }
  720. const hasDecimal = !Number.isInteger(amount);
  721. return new Intl.NumberFormat('zh-CN', {
  722. minimumFractionDigits: hasDecimal ? 2 : 0,
  723. maximumFractionDigits: 2,
  724. }).format(amount);
  725. };
  726. const formatCardDisplay = (value?: string | null) => {
  727. const text = String(value ?? '')
  728. .trim()
  729. .replace(/\s+/g, '');
  730. if (!text) return '-';
  731. return text.replace(/(.{4})(?=.+)/g, '$1 ');
  732. };
  733. const normalizeIdList = (value: unknown): string[] => {
  734. const rawList = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
  735. const seen = new Set<string>();
  736. const ids: Array<string> = [];
  737. rawList.forEach((item) => {
  738. if (item === null || item === undefined) return;
  739. const normalized = String(item).trim();
  740. if (normalized === '') return;
  741. if (seen.has(normalized)) return;
  742. seen.add(normalized);
  743. ids.push(normalized);
  744. });
  745. return ids;
  746. };
  747. const mergeNormalizedIds = (...values: unknown[]): string[] => {
  748. return normalizeIdList(values.flatMap((value) => normalizeIdList(value)));
  749. };
  750. const resolveConsistentActionPeriodStart = (period: TransCardHoldTogetherDTO) => {
  751. return String(period.minDate || period.startDate || '').trim();
  752. };
  753. const resolveConsistentActionPeriodEnd = (period: TransCardHoldTogetherDTO) => {
  754. return String(period.maxDate || period.endDate || '').trim();
  755. };
  756. const resolveConsistentActionSummaryName = (summary: TransCardHoldTogetherSummaryDTO) => {
  757. return String(summary.dealOtherName || summary.otherName || '未知对象');
  758. };
  759. const resolveConsistentActionSummaryCard = (summary: TransCardHoldTogetherSummaryDTO) => {
  760. const card = String(summary.dealOtherCard || summary.otherCardNo || '').trim();
  761. return card || '-';
  762. };
  763. const resolveConsistentActionRows = (summary: TransCardHoldTogetherSummaryDTO): TransCardHoldTogetherItemDTO[] => {
  764. if (Array.isArray(summary.yzxdObjList) && summary.yzxdObjList.length) return summary.yzxdObjList;
  765. if (Array.isArray(summary.transPersonDTOList)) return summary.transPersonDTOList;
  766. return Array.isArray(summary.yzxdObjList) ? summary.yzxdObjList : [];
  767. };
  768. const buildSameFeatureScene = (_target: string, _group: string): SameFeatureScene => {
  769. return {
  770. group: _group,
  771. focus: '',
  772. subject: { name: _target },
  773. holders: [],
  774. links: [],
  775. };
  776. };
  777. const normalizeTransCardHoldResponse = (resp: TransCardHoldResponseLike): TransCardHoldDTO | null => {
  778. if (!resp) return null;
  779. if (
  780. Array.isArray(resp.bankCardObjList) ||
  781. Array.isArray(resp.bankCardPersonList) ||
  782. resp.subjectObjName ||
  783. resp.mainPersonName ||
  784. resp.ztPhotograph
  785. ) {
  786. return resp;
  787. }
  788. if (resp.data) return resp.data;
  789. return null;
  790. };
  791. const buildSameFeatureSceneFromApi = (
  792. resp: TransCardHoldResponseLike,
  793. target: string,
  794. group: string,
  795. ): SameFeatureScene | null => {
  796. const data = normalizeTransCardHoldResponse(resp);
  797. const source = Array.isArray(data?.bankCardObjList)
  798. ? data.bankCardObjList
  799. : Array.isArray(data?.bankCardPersonList)
  800. ? data.bankCardPersonList
  801. : [];
  802. const cardMapping = source.reduce<
  803. Record<
  804. string,
  805. {
  806. cardNo: string;
  807. holderId: string;
  808. holderName: string;
  809. holderAlias: string;
  810. bankName?: string;
  811. amount?: number | string;
  812. transDate?: string;
  813. featureMap: Record<string, string[]>;
  814. }
  815. >
  816. >((acc, item, index) => {
  817. const cardNo = String(item.otherCard || item.otherCardNo || '').trim();
  818. const holderName = String(item.otherName || '').trim();
  819. const holderId = String(item.otherObjId || `${holderName || cardNo || 'holder'}-${index}`);
  820. const mapKey = `${cardNo || 'unknown-card'}-${holderId}`;
  821. const featureName = String(item.type || '').trim() || '未知特征';
  822. const current = acc[mapKey] || {
  823. cardNo,
  824. holderId,
  825. holderName: holderName || '未知持卡人',
  826. holderAlias: String(item.gx || '').trim(),
  827. bankName: '',
  828. amount: undefined,
  829. transDate: '',
  830. featureMap: {},
  831. };
  832. current.featureMap[featureName] = mergeNormalizedIds(current.featureMap[featureName], item.ids);
  833. if (!current.holderAlias && item.gx) current.holderAlias = String(item.gx);
  834. if (!current.bankName && item.bankName) current.bankName = String(item.bankName).trim();
  835. if (
  836. current.amount === undefined &&
  837. item.transAmount !== undefined &&
  838. item.transAmount !== null &&
  839. item.transAmount !== ''
  840. ) {
  841. current.amount = item.transAmount;
  842. }
  843. if (!current.transDate) {
  844. current.transDate = String(item.transDate || item.startDate || item.endDate || '').trim();
  845. }
  846. acc[mapKey] = current;
  847. return acc;
  848. }, {});
  849. const holdersMap = new Map<string, SameFeatureHolder>();
  850. const links = Object.values(cardMapping).map((item, index) => {
  851. if (!holdersMap.has(item.holderId)) {
  852. holdersMap.set(item.holderId, {
  853. id: item.holderId,
  854. name: item.holderName,
  855. alias: item.holderAlias,
  856. });
  857. }
  858. return {
  859. id: `same-feature-api-link-${index}`,
  860. cardNo: item.cardNo || '-',
  861. holderId: item.holderId,
  862. bankName: item.bankName || '',
  863. features: Object.keys(item.featureMap),
  864. amount: item.amount,
  865. transDate: item.transDate || undefined,
  866. ids: mergeNormalizedIds(...Object.values(item.featureMap)),
  867. };
  868. });
  869. return {
  870. group,
  871. focus: '',
  872. subject: {
  873. name: String(data?.subjectObjName || data?.mainPersonName || target || '未知对象'),
  874. },
  875. holders: Array.from(holdersMap.values()),
  876. links,
  877. };
  878. };
  879. const normalizeConsistentActionList = (resp: ConsistentActionResponseLike): TransCardHoldTogetherDTO[] => {
  880. if (Array.isArray(resp)) return resp;
  881. if (!resp) return [];
  882. if (Array.isArray(resp.list)) return resp.list;
  883. if (Array.isArray(resp.records)) return resp.records;
  884. if (Array.isArray(resp.data)) return resp.data;
  885. if (resp.data && Array.isArray(resp.data.list)) return resp.data.list;
  886. if (resp.data && Array.isArray(resp.data.records)) return resp.data.records;
  887. return [];
  888. };
  889. const normalizeConsistentActionRow = (
  890. item: TransCardHoldTogetherItemDTO,
  891. periodIndex: number,
  892. personIndex: number,
  893. rowIndex: number,
  894. ): ConsistentActionRowData => ({
  895. id: `period-${periodIndex}-person-${personIndex}-row-${rowIndex}`,
  896. objName: String(item.objName || item.personName || '未知对象'),
  897. selfCardNo: String(item.selfCardNo || item.personCardNo || '-'),
  898. objFlag: String(item.objFlag || item.mainDesc || ''),
  899. targetBankName: String(item.targetBankName || item.bankName || '-'),
  900. dealMoneyText: formatMoneyText(item.dealMoney ?? item.transAmount),
  901. ids: mergeNormalizedIds(item.idList, item.ids),
  902. color: CONSISTENT_ACTION_ROW_COLORS[(periodIndex + personIndex + rowIndex) % CONSISTENT_ACTION_ROW_COLORS.length],
  903. tag: String(item.tag || ''),
  904. });
  905. const normalizeConsistentActionPerson = (
  906. summary: TransCardHoldTogetherSummaryDTO,
  907. periodIndex: number,
  908. personIndex: number,
  909. ): ConsistentActionPersonData => {
  910. const rows = resolveConsistentActionRows(summary).map((item, rowIndex) =>
  911. normalizeConsistentActionRow(item, periodIndex, personIndex, rowIndex),
  912. );
  913. return {
  914. id: `period-${periodIndex}-person-${personIndex}`,
  915. name: resolveConsistentActionSummaryName(summary),
  916. card: resolveConsistentActionSummaryCard(summary),
  917. ids: mergeNormalizedIds(...rows.map((item) => item.ids)),
  918. rows,
  919. };
  920. };
  921. const normalizeConsistentActionPeriods = (source: TransCardHoldTogetherDTO[]): ConsistentActionPeriodData[] => {
  922. return source
  923. .map((period, periodIndex) => {
  924. const minDate = resolveConsistentActionPeriodStart(period);
  925. const maxDate = resolveConsistentActionPeriodEnd(period);
  926. const people = (Array.isArray(period.otherSummaryList) ? period.otherSummaryList : [])
  927. .map((summary, personIndex) => normalizeConsistentActionPerson(summary, periodIndex, personIndex))
  928. .filter((item) => item.rows.length > 0);
  929. return {
  930. id: `period-${periodIndex}`,
  931. minDate,
  932. maxDate,
  933. dateLabel: buildDateLabel(minDate, maxDate),
  934. people,
  935. };
  936. })
  937. .filter((item) => item.people.length > 0);
  938. };
  939. const buildConsistentActionLayouts = (
  940. periods: ConsistentActionPeriodData[],
  941. metrics: ConsistentActionMetrics,
  942. ): ConsistentActionLayout[] => {
  943. return periods.map((period) => {
  944. let cursorY = CONSISTENT_ACTION_SECTION_PADDING_TOP;
  945. const people = period.people.map((person, personIndex) => {
  946. const rowsHeight =
  947. person.rows.length * CONSISTENT_ACTION_ROW_HEIGHT +
  948. Math.max(person.rows.length - 1, 0) * CONSISTENT_ACTION_ROW_GAP;
  949. const groupHeight = Math.max(CONSISTENT_ACTION_PERSON_HEIGHT, rowsHeight);
  950. const groupTop = cursorY;
  951. const personY = Math.round(groupTop + groupHeight / 2 - CONSISTENT_ACTION_PERSON_HEIGHT / 2);
  952. const rowStartY = Math.round(groupTop + groupHeight / 2 - rowsHeight / 2);
  953. const rows = person.rows.map((row, rowIndex) => ({
  954. id: `${person.id}-node-${rowIndex}`,
  955. x: CONSISTENT_ACTION_ROW_X,
  956. y: rowStartY + rowIndex * (CONSISTENT_ACTION_ROW_HEIGHT + CONSISTENT_ACTION_ROW_GAP),
  957. width: metrics.rowWidth,
  958. height: CONSISTENT_ACTION_ROW_HEIGHT,
  959. row,
  960. }));
  961. cursorY += groupHeight + CONSISTENT_ACTION_GROUP_GAP;
  962. return {
  963. id: `${period.id}-person-node-${personIndex}`,
  964. x: metrics.personX,
  965. y: personY,
  966. width: CONSISTENT_ACTION_PERSON_WIDTH,
  967. height: CONSISTENT_ACTION_PERSON_HEIGHT,
  968. person,
  969. rows,
  970. };
  971. });
  972. const height = Math.max(
  973. CONSISTENT_ACTION_MIN_HEIGHT,
  974. cursorY - CONSISTENT_ACTION_GROUP_GAP + CONSISTENT_ACTION_SECTION_PADDING_BOTTOM,
  975. );
  976. const personCenterYs = people.map((person) => person.y + person.height / 2);
  977. const dateY = personCenterYs.length
  978. ? Math.round((personCenterYs[0] + personCenterYs[personCenterYs.length - 1]) / 2)
  979. : Math.round(height / 2);
  980. const rowPaths = people.flatMap((person) => {
  981. const personCenterY = person.y + person.height / 2;
  982. return person.rows.map((row) => ({
  983. key: `row-path-${row.id}`,
  984. d: `M ${row.x + row.width} ${row.y + row.height / 2} H ${metrics.rowJoinX} V ${personCenterY} H ${person.x}`,
  985. }));
  986. });
  987. const personPaths = people.map((person) => ({
  988. key: `person-path-${person.id}`,
  989. d: `M ${person.x + person.width} ${person.y + person.height / 2} H ${metrics.dateJoinX} V ${dateY} H ${
  990. metrics.dateLineEndX
  991. }`,
  992. }));
  993. return {
  994. id: period.id,
  995. period,
  996. people,
  997. height,
  998. dateY,
  999. rowPaths,
  1000. personPaths,
  1001. };
  1002. });
  1003. };
  1004. const openTransRecordModal = (baseQuery: Partial<TransRecordQuery>) => {
  1005. transRecordBaseQuery.value = baseQuery;
  1006. transRecordModalOpen.value = true;
  1007. };
  1008. const normalizedSameFeatureAmount = computed(() => String(sameFeatureFilters.value.amountValue ?? '').trim());
  1009. const normalizedDayGap = computed(() => String(consistentFilters.value.dayGap ?? '').trim());
  1010. const normalizedConsistentMoney = computed(() => String(consistentFilters.value.money ?? '').trim());
  1011. const normalizedCardNum = computed(() => String(consistentFilters.value.cardNum ?? '').trim());
  1012. const amountValueStatus = computed<StatusType>(() => {
  1013. if (!normalizedSameFeatureAmount.value) return undefined;
  1014. return parseAmountValue(normalizedSameFeatureAmount.value) === null ? 'error' : undefined;
  1015. });
  1016. const dayGapStatus = computed<StatusType>(() => {
  1017. if (!normalizedDayGap.value) return undefined;
  1018. return parsePositiveInteger(normalizedDayGap.value) === null ? 'error' : undefined;
  1019. });
  1020. const consistentMoneyStatus = computed<StatusType>(() => {
  1021. if (!normalizedConsistentMoney.value) return undefined;
  1022. return parseAmountValue(normalizedConsistentMoney.value) === null ? 'error' : undefined;
  1023. });
  1024. const cardNumStatus = computed<StatusType>(() => {
  1025. if (!normalizedCardNum.value) return undefined;
  1026. return parsePositiveInteger(normalizedCardNum.value) === null ? 'error' : undefined;
  1027. });
  1028. const activeMenuMeta = computed(() => {
  1029. return menuOptions.find((item) => item.key === activeMenu.value) || menuOptions[0];
  1030. });
  1031. const hasQueryResult = computed(() => !!sameFeatureQuerySnapshot.value);
  1032. const selectedSameFeatureGroupLabel = computed(() => {
  1033. return resolveSameFeatureGroupLabel(sameFeatureQuerySnapshot.value?.group || sameFeatureFilters.value.group);
  1034. });
  1035. const activeSameFeatureScene = computed<SameFeatureScene | null>(() => {
  1036. const snapshot = sameFeatureQuerySnapshot.value;
  1037. if (!snapshot) return null;
  1038. return sameFeatureScene.value;
  1039. });
  1040. const sameFeatureSubjectName = computed(() => {
  1041. return activeSameFeatureScene.value?.subject.name || selectedSameFeatureGroupLabel.value || '当前分组';
  1042. });
  1043. const sameFeatureDescription = computed(() => {
  1044. if (!sameFeatureQuerySnapshot.value) {
  1045. return '按"主体 -> 特征银行卡 -> 持卡人"展示疑似代持关系,点击银行卡节点可下钻交易明细。';
  1046. }
  1047. if (!activeSameFeatureScene.value?.links.length) {
  1048. return `${sameFeatureSubjectName.value} 在 ${selectedSameFeatureGroupLabel.value} 下暂无命中特征卡,可调整金额或日期后重试。`;
  1049. }
  1050. return `${sameFeatureSubjectName.value} 在 ${selectedSameFeatureGroupLabel.value} 中命中 ${activeSameFeatureScene.value.links.length} 张疑似特征卡,涉及 ${activeSameFeatureScene.value.holders.length} 名持卡人,点击银行卡节点可触发交易底层公共接口。`;
  1051. });
  1052. const sameFeatureEmptyDescription = computed(() => {
  1053. if (!sameFeatureQuerySnapshot.value) return '请选择调查分组后查询异卡同特征';
  1054. return `${sameFeatureSubjectName.value} 在当前筛选条件下未命中异卡同特征`;
  1055. });
  1056. const sameFeatureStats = computed<Array<{ label: string; value: number }>>(() => {
  1057. const scene = activeSameFeatureScene.value;
  1058. if (!sameFeatureQuerySnapshot.value || !scene) return [];
  1059. return [
  1060. { label: '疑似特征卡', value: scene.links.length },
  1061. { label: '涉及持卡人', value: scene.holders.length },
  1062. { label: '命中特征', value: new Set(scene.links.flatMap((item) => item.features)).size },
  1063. ];
  1064. });
  1065. const sameFeatureBoardHeight = computed(() => {
  1066. const count = activeSameFeatureScene.value?.links.length || 0;
  1067. if (!count) return 500;
  1068. return (
  1069. SAME_FEATURE_CONTENT_TOP + count * SAME_FEATURE_CARD_HEIGHT + Math.max(count - 1, 0) * SAME_FEATURE_CARD_GAP + 96
  1070. );
  1071. });
  1072. const sameFeatureSubjectNode = computed<RectNode>(() => {
  1073. const featureCenterYs = sameFeatureFeatureNodes.value.map((item) => item.centerY);
  1074. const preferredCenterY = featureCenterYs.length
  1075. ? featureCenterYs.reduce((sum, value) => sum + value, 0) / featureCenterYs.length
  1076. : sameFeatureBoardHeight.value / 2;
  1077. return {
  1078. x: SAME_FEATURE_MAIN_X,
  1079. y: clamp(
  1080. preferredCenterY - SAME_FEATURE_MAIN_HEIGHT / 2,
  1081. 40,
  1082. sameFeatureBoardHeight.value - SAME_FEATURE_MAIN_HEIGHT - 40,
  1083. ),
  1084. width: SAME_FEATURE_MAIN_WIDTH,
  1085. height: SAME_FEATURE_MAIN_HEIGHT,
  1086. };
  1087. });
  1088. const sameFeatureFeatureNodes = computed<
  1089. Array<RectNode & { link: SameFeatureLink; lineY: number; centerX: number; centerY: number }>
  1090. >(() => {
  1091. const scene = activeSameFeatureScene.value;
  1092. if (!scene?.links.length) return [];
  1093. const boardWidth = Math.max(sameFeatureBoardWidth.value, SAME_FEATURE_MIN_WIDTH);
  1094. const cardX = Math.max(420, Math.round(boardWidth * 0.46));
  1095. return scene.links.map((link, index) => {
  1096. const y = SAME_FEATURE_CONTENT_TOP + index * (SAME_FEATURE_CARD_HEIGHT + SAME_FEATURE_CARD_GAP);
  1097. const centerY = y + SAME_FEATURE_CARD_HEIGHT / 2;
  1098. return {
  1099. x: cardX,
  1100. y,
  1101. width: SAME_FEATURE_CARD_WIDTH,
  1102. height: SAME_FEATURE_CARD_HEIGHT,
  1103. lineY: centerY,
  1104. centerX: cardX + SAME_FEATURE_CARD_WIDTH / 2,
  1105. centerY,
  1106. link,
  1107. };
  1108. });
  1109. });
  1110. const sameFeatureHolderNodes = computed<Array<RectNode & { holder: SameFeatureHolder }>>(() => {
  1111. const scene = activeSameFeatureScene.value;
  1112. if (!scene?.holders.length) return [];
  1113. const boardWidth = Math.max(sameFeatureBoardWidth.value, SAME_FEATURE_MIN_WIDTH);
  1114. const holderX = boardWidth - SAME_FEATURE_MEMBER_WIDTH - SAME_FEATURE_MEMBER_RIGHT_GAP;
  1115. const orderedHolders = [...scene.holders].sort(
  1116. (a, b) =>
  1117. scene.links.findIndex((item) => item.holderId === a.id) -
  1118. scene.links.findIndex((item) => item.holderId === b.id),
  1119. );
  1120. return orderedHolders.map((holder, index) => {
  1121. const featureGroup = sameFeatureFeatureNodes.value.filter((item) => item.link.holderId === holder.id);
  1122. const averageY = featureGroup.length
  1123. ? featureGroup.reduce((sum, item) => sum + item.centerY, 0) / featureGroup.length
  1124. : 160 + index * 104;
  1125. return {
  1126. x: holderX,
  1127. y: clamp(
  1128. averageY - SAME_FEATURE_MEMBER_HEIGHT / 2,
  1129. 40,
  1130. sameFeatureBoardHeight.value - SAME_FEATURE_MEMBER_HEIGHT - 40,
  1131. ),
  1132. width: SAME_FEATURE_MEMBER_WIDTH,
  1133. height: SAME_FEATURE_MEMBER_HEIGHT,
  1134. holder,
  1135. };
  1136. });
  1137. });
  1138. const sameFeatureBusX = computed(() => {
  1139. if (!sameFeatureFeatureNodes.value.length) {
  1140. return sameFeatureSubjectNode.value.x + sameFeatureSubjectNode.value.width + 92;
  1141. }
  1142. return sameFeatureFeatureNodes.value[0].x - SAME_FEATURE_BUS_GAP;
  1143. });
  1144. const sameFeatureSubjectPath = computed(() => {
  1145. if (!sameFeatureFeatureNodes.value.length) return '';
  1146. const subjectNode = sameFeatureSubjectNode.value;
  1147. const subjectY = subjectNode.y + subjectNode.height / 2;
  1148. const lineYs = [subjectY, ...sameFeatureFeatureNodes.value.map((item) => item.lineY)];
  1149. const topY = Math.min(...lineYs);
  1150. const bottomY = Math.max(...lineYs);
  1151. return `M ${subjectNode.x + subjectNode.width} ${subjectY} H ${sameFeatureBusX.value} M ${sameFeatureBusX.value} ${topY} V ${bottomY}`;
  1152. });
  1153. const sameFeatureFeaturePaths = computed<PathDefinition[]>(() => {
  1154. if (!sameFeatureFeatureNodes.value.length) return [];
  1155. return sameFeatureFeatureNodes.value.map((node) => ({
  1156. key: `same-feature-left-${node.link.id}`,
  1157. d: `M ${sameFeatureBusX.value} ${node.lineY} H ${node.x}`,
  1158. }));
  1159. });
  1160. const sameFeatureFeatureLabelGroups = computed<FeatureTagGroup[]>(() => {
  1161. return sameFeatureFeatureNodes.value.map((node) => ({
  1162. key: `same-feature-tags-${node.link.id}`,
  1163. x: sameFeatureBusX.value + 18,
  1164. y: node.y + 4,
  1165. maxWidth: Math.max(120, node.x - sameFeatureBusX.value - 36),
  1166. features: node.link.features,
  1167. }));
  1168. });
  1169. const sameFeatureHolderPaths = computed<PathDefinition[]>(() => {
  1170. const holderNodeMap = new Map(sameFeatureHolderNodes.value.map((item) => [item.holder.id, item]));
  1171. return sameFeatureFeatureNodes.value.flatMap((node) => {
  1172. const holderNode = holderNodeMap.get(node.link.holderId);
  1173. if (!holderNode) return [];
  1174. const joinX = holderNode.x - 28;
  1175. const holderY = holderNode.y + holderNode.height / 2;
  1176. const pathD =
  1177. Math.abs(holderY - node.centerY) <= 1
  1178. ? `M ${node.x + node.width} ${node.centerY} H ${holderNode.x}`
  1179. : `M ${node.x + node.width} ${node.centerY} H ${joinX} V ${holderY} H ${holderNode.x}`;
  1180. return [
  1181. {
  1182. key: `same-feature-right-${node.link.id}`,
  1183. d: pathD,
  1184. },
  1185. ];
  1186. });
  1187. });
  1188. const sameFeatureHolderLabels = computed<TextLabel[]>(() => {
  1189. const holderNodeMap = new Map(sameFeatureHolderNodes.value.map((item) => [item.holder.id, item]));
  1190. return sameFeatureFeatureNodes.value.flatMap((node) => {
  1191. const holderNode = holderNodeMap.get(node.link.holderId);
  1192. if (!holderNode) return [];
  1193. const holderY = holderNode.y + holderNode.height / 2;
  1194. const labelX = Math.min(node.x + node.width + 34, holderNode.x - 78);
  1195. const labelY =
  1196. Math.abs(holderY - node.centerY) <= 1
  1197. ? node.centerY - 18
  1198. : clamp((node.centerY + holderY) / 2 - 10, 16, sameFeatureBoardHeight.value - 36);
  1199. return [
  1200. {
  1201. key: `same-feature-holder-label-${node.link.id}`,
  1202. x: labelX,
  1203. y: labelY,
  1204. text: '持卡人',
  1205. },
  1206. ];
  1207. });
  1208. });
  1209. const consistentActionGroupLabel = computed(() => {
  1210. return (
  1211. consistentActionLastQuery.value?.groupLabel ||
  1212. resolveConsistentGroupLabel(consistentFilters.value.groupId) ||
  1213. '当前分组'
  1214. );
  1215. });
  1216. const consistentActionMetrics = computed(() => createConsistentActionMetrics(consistentActionBoardWidth.value));
  1217. const consistentActionLayouts = computed(() =>
  1218. buildConsistentActionLayouts(consistentActionPeriods.value, consistentActionMetrics.value),
  1219. );
  1220. const consistentActionPeopleCount = computed(() => {
  1221. return consistentActionPeriods.value.reduce((sum, period) => sum + period.people.length, 0);
  1222. });
  1223. const consistentActionRowCount = computed(() => {
  1224. return consistentActionPeriods.value.reduce(
  1225. (sum, period) => sum + period.people.reduce((personSum, person) => personSum + person.rows.length, 0),
  1226. 0,
  1227. );
  1228. });
  1229. const consistentActionDescription = computed(() => {
  1230. if (!consistentActionQueried.value) {
  1231. return '按"交易行 -> 共同交易对象 -> 时间窗口"展示协同行为,点击节点可查看交易明细。';
  1232. }
  1233. if (!consistentActionLayouts.value.length) {
  1234. return `${consistentActionGroupLabel.value} 在当前筛选条件下暂无多卡汇集结果,可调整时间间隔或门槛后重试。`;
  1235. }
  1236. return `${consistentActionGroupLabel.value} 命中 ${consistentActionLayouts.value.length} 个时间窗口、${consistentActionPeopleCount.value} 个共同交易对象、${consistentActionRowCount.value} 条协同行为记录,点击节点可查看详情。`;
  1237. });
  1238. const consistentActionEmptyDescription = computed(() => {
  1239. if (!consistentActionQueried.value) return '请选择调查分组后查询多卡汇集';
  1240. return `${consistentActionGroupLabel.value} 在当前筛选条件下暂无多卡汇集结果`;
  1241. });
  1242. const resolveHolderName = (holderId: string) => {
  1243. return activeSameFeatureScene.value?.holders.find((item) => item.id === holderId)?.name || '未知持卡人';
  1244. };
  1245. const promptCreateGroupWhenEmpty = () => {
  1246. if (emptyGroupPrompted.value) return;
  1247. emptyGroupPrompted.value = true;
  1248. Modal.confirm({
  1249. title: '暂无对象分组',
  1250. content: '当前案件还没有对象分组,是否前往新建分组?',
  1251. okText: '新建分组',
  1252. cancelText: '稍后再说',
  1253. centered: true,
  1254. onOk: () =>
  1255. router.push({
  1256. path: '/dm/portrait',
  1257. query: {
  1258. openGroupModal: '1',
  1259. openCreateGroupModal: '1',
  1260. },
  1261. }),
  1262. });
  1263. };
  1264. const loadConsistentGroupOptions = async () => {
  1265. try {
  1266. const groups = await listPersonGroups();
  1267. consistentGroupOptions.value = (Array.isArray(groups) ? groups : [])
  1268. .filter((item) => item?.id)
  1269. .map((item) => ({
  1270. label: item.groupName || item.id || '未命名分组',
  1271. value: item.id || '',
  1272. }))
  1273. .filter((item) => item.value);
  1274. if (!consistentGroupOptions.value.length) {
  1275. promptCreateGroupWhenEmpty();
  1276. }
  1277. } catch {
  1278. consistentGroupOptions.value = [];
  1279. }
  1280. };
  1281. const syncBoardSize = () => {
  1282. nextTick(() => {
  1283. sameFeatureBoardWidth.value = Math.max(sameFeatureBoardRef.value?.clientWidth || 0, SAME_FEATURE_MIN_WIDTH);
  1284. const consistentActionBoardScroll = consistentActionBodyRef.value?.querySelector<HTMLElement>(
  1285. '.consistent-action-board-scroll',
  1286. );
  1287. consistentActionBoardWidth.value = Math.max(
  1288. consistentActionBoardScroll?.clientWidth || (consistentActionBodyRef.value?.clientWidth || 0) - 36,
  1289. CONSISTENT_ACTION_MIN_WIDTH,
  1290. );
  1291. });
  1292. };
  1293. const buildFeatureRecordBaseQuery = (link: SameFeatureLink): Partial<TransRecordQuery> => {
  1294. if (link.ids.length) {
  1295. return {
  1296. ids: link.ids,
  1297. };
  1298. }
  1299. const threshold = sameFeatureQuerySnapshot.value?.amountValue;
  1300. return {
  1301. personName: activeSameFeatureScene.value?.subject.name || undefined,
  1302. otherName: resolveHolderName(link.holderId),
  1303. otherCardNo: link.cardNo,
  1304. startDate: link.transDate,
  1305. endDate: link.transDate,
  1306. transAmount: threshold,
  1307. transAmountSymbol:
  1308. threshold !== undefined
  1309. ? normalizeCompareOperator(sameFeatureQuerySnapshot.value?.amountOp, DEFAULT_COMPARE_OPERATOR)
  1310. : undefined,
  1311. };
  1312. };
  1313. const handleFeatureCardClick = (link: SameFeatureLink) => {
  1314. openTransRecordModal(buildFeatureRecordBaseQuery(link));
  1315. };
  1316. const handleConsistentActionRowClick = (row: ConsistentActionRowData) => {
  1317. if (!row.ids.length) {
  1318. createMessage.warning(`${row.objName} 暂无可下钻的交易记录`);
  1319. return;
  1320. }
  1321. openTransRecordModal({ ids: row.ids });
  1322. };
  1323. const handleConsistentActionPersonClick = (person: ConsistentActionPersonData) => {
  1324. if (!person.ids.length) {
  1325. createMessage.warning(`${person.name} 暂无可下钻的交易记录`);
  1326. return;
  1327. }
  1328. openTransRecordModal({ ids: person.ids });
  1329. };
  1330. const handleMenuChange = (menu: MenuKey) => {
  1331. activeMenu.value = menu;
  1332. syncBoardSize();
  1333. };
  1334. watch(
  1335. () => consistentFilters.value.groupId,
  1336. (value) => {
  1337. const next = String(value || '');
  1338. if (sameFeatureFilters.value.group === next) return;
  1339. sameFeatureFilters.value.group = next;
  1340. },
  1341. );
  1342. watch(
  1343. () => sameFeatureFilters.value.group,
  1344. (value) => {
  1345. const next = String(value || '');
  1346. if (consistentFilters.value.groupId === next) return;
  1347. consistentFilters.value.groupId = next;
  1348. },
  1349. );
  1350. const handleSameFeatureSearch = async () => {
  1351. if (!sameFeatureFilters.value.group) {
  1352. createMessage.warning('请选择调查分组后再查询');
  1353. return;
  1354. }
  1355. const amountValue = parseAmountValue(sameFeatureFilters.value.amountValue);
  1356. if (amountValue === null) {
  1357. createMessage.warning('大额金额请输入合法数值');
  1358. return;
  1359. }
  1360. const amountOp = normalizeCompareOperator(sameFeatureFilters.value.amountOp, DEFAULT_COMPARE_OPERATOR);
  1361. if (amountValue !== undefined && !isLegacyCardHoldCompareOperator(amountOp)) {
  1362. createMessage.warning('异卡同特征金额条件仅支持 >= 或 <=');
  1363. return;
  1364. }
  1365. const snapshot: SameFeatureQuerySnapshot = {
  1366. group: sameFeatureFilters.value.group,
  1367. amountOp,
  1368. amountValue: amountValue ?? undefined,
  1369. dateRange: normalizeDateRange(sameFeatureFilters.value.dateRange),
  1370. };
  1371. const [startDateText = '', endDateText = ''] = snapshot.dateRange || [];
  1372. const query: TransCardHoldQuery = {
  1373. groupId: snapshot.group,
  1374. transAmount: snapshot.amountValue,
  1375. transAmountSymbol:
  1376. snapshot.amountValue !== undefined
  1377. ? normalizeCompareOperator(snapshot.amountOp, DEFAULT_COMPARE_OPERATOR)
  1378. : undefined,
  1379. startDate: startDateText ? `${startDateText} 00:00:00` : undefined,
  1380. endDate: endDateText ? `${endDateText} 23:59:59` : undefined,
  1381. };
  1382. await runWithQueryLoading(async () => {
  1383. const resp = await statTransCardHold(query);
  1384. const scene = buildSameFeatureSceneFromApi(resp, snapshot.group, snapshot.group);
  1385. sameFeatureScene.value = scene;
  1386. sameFeatureQuerySnapshot.value = snapshot;
  1387. syncAnalysisDataRefreshState(scene.links.length > 0);
  1388. transRecordModalOpen.value = false;
  1389. transRecordBaseQuery.value = {};
  1390. await nextTick();
  1391. syncBoardSize();
  1392. });
  1393. };
  1394. const handleConsistentActionSearch = async () => {
  1395. if (!consistentFilters.value.groupId) {
  1396. createMessage.warning('请选择调查分组后再查询');
  1397. return;
  1398. }
  1399. const dayGap = parsePositiveInteger(consistentFilters.value.dayGap);
  1400. if (dayGap === undefined || dayGap === null) {
  1401. createMessage.warning('时间间隔请输入正整数');
  1402. return;
  1403. }
  1404. const money = parseAmountValue(consistentFilters.value.money);
  1405. if (money === null) {
  1406. createMessage.warning('大额金额请输入合法数值');
  1407. return;
  1408. }
  1409. const cardNum = parsePositiveInteger(consistentFilters.value.cardNum);
  1410. if (cardNum === null) {
  1411. createMessage.warning('共同交易卡数量请输入正整数');
  1412. return;
  1413. }
  1414. const moneyOp = normalizeCompareOperator(consistentFilters.value.moneyOp, DEFAULT_COMPARE_OPERATOR);
  1415. const cardNumOp = normalizeCompareOperator(consistentFilters.value.cardNumOp, DEFAULT_COMPARE_OPERATOR);
  1416. if (money !== undefined && !isLegacyCardHoldCompareOperator(moneyOp)) {
  1417. createMessage.warning('多卡汇集金额条件仅支持 >= 或 <=');
  1418. return;
  1419. }
  1420. if (cardNum !== undefined && !isLegacyCardHoldCompareOperator(cardNumOp)) {
  1421. createMessage.warning('共同交易卡数量条件仅支持 >= 或 <=');
  1422. return;
  1423. }
  1424. const groupLabel = resolveConsistentGroupLabel(consistentFilters.value.groupId) || '当前分组';
  1425. const query: TransCardHoldTogetherQuery = {
  1426. groupId: consistentFilters.value.groupId,
  1427. dayGap,
  1428. daySymbol: toLegacyCardHoldCompareSymbol(DEFAULT_LE_COMPARE_OPERATOR),
  1429. transAmount: money,
  1430. transAmountSymbol: money !== undefined ? normalizeCompareOperator(moneyOp, DEFAULT_COMPARE_OPERATOR) : undefined,
  1431. cardNum,
  1432. cardNumSymbol: cardNum !== undefined ? toLegacyCardHoldCompareSymbol(cardNumOp) : undefined,
  1433. };
  1434. await runWithQueryLoading(async () => {
  1435. const resp = await statTogetherDoTrans(query);
  1436. consistentActionPeriods.value = normalizeConsistentActionPeriods(normalizeConsistentActionList(resp));
  1437. consistentActionQueried.value = true;
  1438. consistentActionLastQuery.value = {
  1439. groupId: consistentFilters.value.groupId,
  1440. groupLabel,
  1441. dayGap,
  1442. moneyOp,
  1443. money,
  1444. cardNumOp,
  1445. cardNum,
  1446. };
  1447. syncAnalysisDataRefreshState(consistentActionPeriods.value.length > 0);
  1448. transRecordModalOpen.value = false;
  1449. transRecordBaseQuery.value = {};
  1450. await nextTick();
  1451. syncBoardSize();
  1452. });
  1453. };
  1454. const handleSearch = async () => {
  1455. if (activeMenu.value === 'consistentAction') {
  1456. await handleConsistentActionSearch();
  1457. return;
  1458. }
  1459. await handleSameFeatureSearch();
  1460. };
  1461. const handleReset = () => {
  1462. if (activeMenu.value === 'consistentAction') {
  1463. consistentFilters.value = createConsistentActionFilters();
  1464. consistentActionQueried.value = false;
  1465. consistentActionLastQuery.value = null;
  1466. consistentActionPeriods.value = [];
  1467. } else {
  1468. sameFeatureFilters.value = createSameFeatureFilters();
  1469. sameFeatureQuerySnapshot.value = null;
  1470. sameFeatureScene.value = null;
  1471. syncBoardSize();
  1472. }
  1473. transRecordModalOpen.value = false;
  1474. transRecordBaseQuery.value = {};
  1475. };
  1476. watch(
  1477. () => [activeMenu.value, sameFeatureBoardHeight.value, consistentActionLayouts.value.length],
  1478. () => {
  1479. syncBoardSize();
  1480. },
  1481. { immediate: true },
  1482. );
  1483. onMounted(async () => {
  1484. await loadConsistentGroupOptions();
  1485. syncBoardSize();
  1486. window.addEventListener('resize', syncBoardSize);
  1487. });
  1488. onBeforeUnmount(() => {
  1489. window.removeEventListener('resize', syncBoardSize);
  1490. });
  1491. </script>
  1492. <style lang="less">
  1493. @prefix-cls: ~'jeesite-card-holding';
  1494. .@{prefix-cls}.card-holding-page {
  1495. .trans-analysis-table-ui();
  1496. background: #fff;
  1497. padding: 0;
  1498. min-height: 100%;
  1499. height: 100%;
  1500. display: flex;
  1501. box-sizing: border-box;
  1502. overflow: hidden;
  1503. .left-sidebar {
  1504. width: 196px;
  1505. flex: 0 0 196px;
  1506. border: 0;
  1507. border-radius: 0;
  1508. background: transparent;
  1509. box-shadow: none;
  1510. display: flex;
  1511. flex-direction: column;
  1512. overflow: hidden;
  1513. }
  1514. .sidebar-top {
  1515. padding: 16px 16px 14px;
  1516. background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
  1517. }
  1518. .sidebar-title {
  1519. font-size: 15px;
  1520. font-weight: 600;
  1521. color: #111827;
  1522. line-height: 22px;
  1523. }
  1524. .sidebar-desc {
  1525. margin-top: 8px;
  1526. font-size: 12px;
  1527. line-height: 20px;
  1528. color: #6b7280;
  1529. }
  1530. .menu-list {
  1531. display: flex;
  1532. flex-direction: column;
  1533. gap: 8px;
  1534. padding: 0;
  1535. }
  1536. .menu-item {
  1537. width: 100%;
  1538. display: flex;
  1539. flex-direction: column;
  1540. gap: 4px;
  1541. text-align: left;
  1542. padding: 12px 12px 10px;
  1543. border: 1px solid transparent;
  1544. border-radius: 12px;
  1545. background: #fff;
  1546. cursor: pointer;
  1547. transition: all 0.2s ease;
  1548. appearance: none;
  1549. &:hover {
  1550. border-color: #dbeafe;
  1551. background: #f8fbff;
  1552. }
  1553. &.active {
  1554. border-color: #bfd4f5;
  1555. background: #f0f7ff;
  1556. box-shadow: inset 0 0 0 1px rgba(22, 119, 255, 0.08);
  1557. }
  1558. }
  1559. .menu-item__label {
  1560. font-size: 13px;
  1561. font-weight: 600;
  1562. color: #111827;
  1563. line-height: 20px;
  1564. }
  1565. .menu-item__desc {
  1566. font-size: 12px;
  1567. color: #6b7280;
  1568. line-height: 18px;
  1569. }
  1570. .main-content {
  1571. flex: 1;
  1572. min-height: 0;
  1573. min-width: 0;
  1574. display: flex;
  1575. flex-direction: column;
  1576. overflow: hidden;
  1577. }
  1578. .page-header {
  1579. margin-bottom: 0;
  1580. flex-shrink: 0;
  1581. }
  1582. .page-title {
  1583. font-size: 16px;
  1584. font-weight: 600;
  1585. color: #111827;
  1586. padding-bottom: 8px;
  1587. }
  1588. .page-subtitle {
  1589. margin-top: 10px;
  1590. color: #6b7280;
  1591. font-size: 12px;
  1592. line-height: 20px;
  1593. }
  1594. .filter-bar {
  1595. display: flex;
  1596. align-items: center;
  1597. gap: 8px;
  1598. padding: 6px;
  1599. min-height: 40px;
  1600. border: 0;
  1601. flex-wrap: wrap;
  1602. flex-shrink: 0;
  1603. background: transparent;
  1604. box-sizing: border-box;
  1605. }
  1606. .filter-item {
  1607. display: flex;
  1608. align-items: center;
  1609. gap: 6px;
  1610. }
  1611. .filter-label {
  1612. color: #6b7280;
  1613. font-size: 12px;
  1614. white-space: nowrap;
  1615. }
  1616. .filter-input {
  1617. width: 180px;
  1618. }
  1619. .filter-group {
  1620. width: 180px;
  1621. }
  1622. .filter-op {
  1623. width: 64px;
  1624. min-width: 64px;
  1625. }
  1626. .filter-mini {
  1627. width: 96px;
  1628. }
  1629. .filter-fixed-op {
  1630. width: 64px;
  1631. height: 32px;
  1632. display: inline-flex;
  1633. align-items: center;
  1634. justify-content: center;
  1635. border: 1px solid #d9e1ec;
  1636. border-radius: 8px;
  1637. background: #f8fafc;
  1638. color: #475569;
  1639. font-size: 12px;
  1640. line-height: 1;
  1641. box-sizing: border-box;
  1642. }
  1643. .filter-micro {
  1644. width: 86px;
  1645. }
  1646. .filter-unit {
  1647. color: #6b7280;
  1648. font-size: 12px;
  1649. white-space: nowrap;
  1650. }
  1651. .filter-date {
  1652. width: 360px;
  1653. }
  1654. .filter-actions {
  1655. display: flex;
  1656. gap: 8px;
  1657. margin-left: auto;
  1658. }
  1659. .filter-actions :deep(.ant-btn) {
  1660. min-width: 68px;
  1661. }
  1662. .view-panel {
  1663. flex: 1;
  1664. min-height: 0;
  1665. margin-top: 0;
  1666. display: flex;
  1667. gap: 2px;
  1668. overflow: hidden;
  1669. padding: 2px;
  1670. background: #edf2f7;
  1671. box-sizing: border-box;
  1672. }
  1673. .view-main {
  1674. .analysis-card-shell();
  1675. flex: 1;
  1676. min-width: 0;
  1677. min-height: 0;
  1678. overflow: hidden;
  1679. }
  1680. .left-sidebar {
  1681. .analysis-card-shell();
  1682. }
  1683. .analysis-card,
  1684. .placeholder-card {
  1685. flex: 1 1 auto;
  1686. min-height: 0;
  1687. display: flex;
  1688. flex-direction: column;
  1689. overflow: hidden;
  1690. }
  1691. .consistent-action-card {
  1692. flex: 1 1 auto;
  1693. min-height: 0;
  1694. }
  1695. .panel-header {
  1696. display: flex;
  1697. align-items: flex-start;
  1698. justify-content: space-between;
  1699. gap: 16px;
  1700. padding: 0 0 8px;
  1701. }
  1702. .panel-title {
  1703. color: #111827;
  1704. font-size: 15px;
  1705. font-weight: 600;
  1706. line-height: 22px;
  1707. }
  1708. .panel-desc {
  1709. margin-top: 4px;
  1710. color: #6b7280;
  1711. font-size: 12px;
  1712. line-height: 20px;
  1713. max-width: 680px;
  1714. }
  1715. .panel-stat-list {
  1716. display: flex;
  1717. flex-wrap: wrap;
  1718. gap: 10px;
  1719. justify-content: flex-end;
  1720. }
  1721. .panel-stat {
  1722. min-width: 112px;
  1723. padding: 10px 12px;
  1724. border: 1px solid #dbe5f1;
  1725. border-radius: 12px;
  1726. background: #f8fbff;
  1727. }
  1728. .panel-stat__label {
  1729. color: #6b7280;
  1730. font-size: 11px;
  1731. line-height: 18px;
  1732. }
  1733. .panel-stat__value {
  1734. margin-top: 2px;
  1735. color: #111827;
  1736. font-size: 14px;
  1737. font-weight: 600;
  1738. line-height: 22px;
  1739. }
  1740. .same-feature-header-extra {
  1741. display: flex;
  1742. flex-direction: column;
  1743. align-items: flex-end;
  1744. gap: 10px;
  1745. }
  1746. .same-feature-stat-list {
  1747. gap: 8px;
  1748. }
  1749. .same-feature-legend {
  1750. display: flex;
  1751. flex-wrap: wrap;
  1752. align-items: center;
  1753. gap: 12px;
  1754. justify-content: flex-end;
  1755. }
  1756. .same-feature-legend__item {
  1757. display: inline-flex;
  1758. align-items: center;
  1759. gap: 6px;
  1760. color: #6b7280;
  1761. font-size: 12px;
  1762. line-height: 18px;
  1763. }
  1764. .same-feature-legend__dot {
  1765. width: 10px;
  1766. height: 10px;
  1767. border-radius: 999px;
  1768. }
  1769. .same-feature-legend__dot--subject {
  1770. background: linear-gradient(180deg, #4666e5 0%, #2f4de1 100%);
  1771. }
  1772. .same-feature-legend__dot--feature {
  1773. background: linear-gradient(180deg, #ffd56d 0%, #ffb800 100%);
  1774. }
  1775. .same-feature-legend__dot--holder {
  1776. background: #dbe6ff;
  1777. border: 1px solid #4666e5;
  1778. box-sizing: border-box;
  1779. }
  1780. .board-scroll {
  1781. overflow: auto;
  1782. background: transparent;
  1783. }
  1784. .board-scroll--plain {
  1785. border: 0;
  1786. border-radius: 0;
  1787. background: transparent;
  1788. }
  1789. .consistent-action-board-scroll {
  1790. overflow-x: auto;
  1791. overflow-y: hidden;
  1792. scrollbar-width: thin;
  1793. scrollbar-color: rgba(148, 163, 184, 0.7) transparent;
  1794. padding-bottom: 4px;
  1795. &::-webkit-scrollbar {
  1796. width: 8px;
  1797. height: 8px;
  1798. }
  1799. &::-webkit-scrollbar-thumb {
  1800. background: rgba(148, 163, 184, 0.7);
  1801. border-radius: 999px;
  1802. }
  1803. &::-webkit-scrollbar-track {
  1804. background: transparent;
  1805. }
  1806. }
  1807. .same-feature-board-scroll {
  1808. flex: 1 1 auto;
  1809. min-height: 0;
  1810. padding: 0;
  1811. overflow: auto;
  1812. scrollbar-width: thin;
  1813. scrollbar-color: rgba(148, 163, 184, 0.7) transparent;
  1814. &::-webkit-scrollbar {
  1815. width: 8px;
  1816. height: 8px;
  1817. }
  1818. &::-webkit-scrollbar-thumb {
  1819. background: rgba(148, 163, 184, 0.7);
  1820. border-radius: 999px;
  1821. }
  1822. &::-webkit-scrollbar-track {
  1823. background: transparent;
  1824. }
  1825. }
  1826. .same-feature-board,
  1827. .consistent-board {
  1828. position: relative;
  1829. min-width: 100%;
  1830. }
  1831. .same-feature-board {
  1832. min-height: 520px;
  1833. background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
  1834. }
  1835. .relation-svg {
  1836. position: absolute;
  1837. top: 0;
  1838. left: 0;
  1839. z-index: 1;
  1840. pointer-events: none;
  1841. }
  1842. .same-feature-path {
  1843. fill: none;
  1844. stroke-linecap: round;
  1845. stroke-linejoin: round;
  1846. }
  1847. .same-feature-path--subject {
  1848. stroke: #6577a7;
  1849. stroke-width: 2;
  1850. }
  1851. .same-feature-path--holder {
  1852. stroke: #6577a7;
  1853. stroke-width: 1.8;
  1854. }
  1855. .same-feature-main,
  1856. .same-feature-card-node,
  1857. .same-feature-member,
  1858. .same-feature-edge-tags,
  1859. .same-feature-cardholder-label {
  1860. position: absolute;
  1861. }
  1862. .same-feature-main {
  1863. z-index: 3;
  1864. overflow: hidden;
  1865. border-radius: 14px;
  1866. background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
  1867. box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
  1868. border: 1px solid #dbe5f1;
  1869. box-sizing: border-box;
  1870. }
  1871. .same-feature-main__tag {
  1872. position: absolute;
  1873. top: 12px;
  1874. left: 12px;
  1875. z-index: 2;
  1876. padding: 2px 8px;
  1877. border-radius: 999px;
  1878. background: #eaf2ff;
  1879. color: #2f4de1;
  1880. font-size: 11px;
  1881. line-height: 18px;
  1882. font-weight: 600;
  1883. }
  1884. .same-feature-main__body {
  1885. height: 100%;
  1886. display: flex;
  1887. align-items: center;
  1888. gap: 12px;
  1889. padding: 18px 16px 16px;
  1890. box-sizing: border-box;
  1891. }
  1892. .same-feature-main__content {
  1893. min-width: 0;
  1894. display: flex;
  1895. flex-direction: column;
  1896. align-items: center;
  1897. justify-content: center;
  1898. flex: 1;
  1899. }
  1900. .same-feature-main__avatar,
  1901. .same-feature-member__avatar {
  1902. position: relative;
  1903. z-index: 1;
  1904. display: flex;
  1905. flex: 0 0 auto;
  1906. }
  1907. .same-feature-main__avatar {
  1908. width: 54px;
  1909. height: 54px;
  1910. }
  1911. .same-feature-main__name {
  1912. width: 100%;
  1913. color: #111827;
  1914. font-size: 15px;
  1915. font-weight: 600;
  1916. line-height: 22px;
  1917. text-align: center;
  1918. white-space: nowrap;
  1919. overflow: hidden;
  1920. text-overflow: ellipsis;
  1921. }
  1922. .same-feature-main__hint {
  1923. margin-top: 4px;
  1924. color: #94a3b8;
  1925. font-size: 12px;
  1926. line-height: 18px;
  1927. text-align: center;
  1928. }
  1929. .same-feature-edge-tags {
  1930. z-index: 3;
  1931. display: flex;
  1932. flex-wrap: wrap;
  1933. gap: 8px;
  1934. align-items: center;
  1935. }
  1936. .same-feature-edge-tags__tag {
  1937. padding: 2px 8px;
  1938. border-radius: 999px;
  1939. background: #edf4ff;
  1940. border: 1px solid #d4e5ff;
  1941. color: #3571d9;
  1942. font-size: 11px;
  1943. line-height: 18px;
  1944. white-space: nowrap;
  1945. }
  1946. .same-feature-card-node {
  1947. z-index: 4;
  1948. display: flex;
  1949. flex-direction: column;
  1950. align-items: flex-start;
  1951. gap: 10px;
  1952. padding: 14px 16px;
  1953. border: 1px solid #dbe5f1;
  1954. border-radius: 16px;
  1955. background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
  1956. box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
  1957. cursor: pointer;
  1958. box-sizing: border-box;
  1959. appearance: none;
  1960. text-align: left;
  1961. transition:
  1962. transform 0.2s ease,
  1963. box-shadow 0.2s ease,
  1964. border-color 0.2s ease;
  1965. &:hover {
  1966. transform: translateY(-1px);
  1967. border-color: #bfd4f5;
  1968. box-shadow: 0 14px 28px rgba(37, 99, 235, 0.1);
  1969. }
  1970. }
  1971. .same-feature-card-node__header {
  1972. width: 100%;
  1973. display: flex;
  1974. align-items: center;
  1975. gap: 10px;
  1976. min-width: 0;
  1977. }
  1978. .same-feature-card-node__icon {
  1979. width: 38px;
  1980. height: 38px;
  1981. flex: 0 0 auto;
  1982. border-radius: 12px;
  1983. display: flex;
  1984. align-items: center;
  1985. justify-content: center;
  1986. background: linear-gradient(180deg, #ffd86c 0%, #ffb700 100%);
  1987. color: #fff;
  1988. font-size: 18px;
  1989. box-shadow: 0 8px 18px rgba(255, 183, 0, 0.2);
  1990. transition:
  1991. transform 0.2s ease,
  1992. box-shadow 0.2s ease;
  1993. .anticon {
  1994. line-height: 1;
  1995. }
  1996. .anticon,
  1997. .anticon svg {
  1998. width: 18px;
  1999. height: 18px;
  2000. }
  2001. }
  2002. .same-feature-card-node__bank {
  2003. max-width: 100%;
  2004. flex: 1 1 auto;
  2005. min-width: 0;
  2006. padding: 4px 10px;
  2007. border-radius: 999px;
  2008. border: 1px solid #f9df9b;
  2009. background: #fff8e8;
  2010. color: #a16207;
  2011. font-size: 12px;
  2012. line-height: 18px;
  2013. white-space: nowrap;
  2014. overflow: hidden;
  2015. text-overflow: ellipsis;
  2016. }
  2017. .same-feature-card-node__number {
  2018. width: 100%;
  2019. min-height: 40px;
  2020. color: #0f172a;
  2021. font-size: 14px;
  2022. line-height: 20px;
  2023. font-weight: 600;
  2024. letter-spacing: 0.03em;
  2025. word-break: break-all;
  2026. overflow: hidden;
  2027. display: -webkit-box;
  2028. -webkit-line-clamp: 2;
  2029. -webkit-box-orient: vertical;
  2030. }
  2031. .same-feature-card-node__footer {
  2032. width: 100%;
  2033. display: flex;
  2034. align-items: center;
  2035. justify-content: space-between;
  2036. gap: 10px;
  2037. }
  2038. .same-feature-card-node__meta {
  2039. padding: 2px 8px;
  2040. border-radius: 999px;
  2041. background: #eef4ff;
  2042. color: #5b74b1;
  2043. font-size: 12px;
  2044. line-height: 18px;
  2045. white-space: nowrap;
  2046. }
  2047. .same-feature-card-node__amount {
  2048. color: #2563eb;
  2049. font-size: 12px;
  2050. line-height: 18px;
  2051. font-weight: 600;
  2052. text-align: right;
  2053. white-space: nowrap;
  2054. }
  2055. .same-feature-card-node:hover {
  2056. .same-feature-card-node__icon {
  2057. transform: translateY(-1px);
  2058. box-shadow: 0 10px 20px rgba(255, 183, 0, 0.28);
  2059. }
  2060. .same-feature-card-node__number,
  2061. .same-feature-card-node__amount {
  2062. color: #2f4de1;
  2063. }
  2064. .same-feature-card-node__meta {
  2065. background: #e8f0ff;
  2066. color: #3d61b3;
  2067. }
  2068. }
  2069. .same-feature-member {
  2070. z-index: 4;
  2071. display: flex;
  2072. align-items: center;
  2073. gap: 14px;
  2074. padding: 0 18px;
  2075. border-radius: 12px;
  2076. background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
  2077. box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
  2078. border: 1px solid #dbe5f1;
  2079. box-sizing: border-box;
  2080. }
  2081. .same-feature-member__avatar {
  2082. flex: 0 0 auto;
  2083. }
  2084. .same-feature-member__info {
  2085. min-width: 0;
  2086. flex: 1;
  2087. }
  2088. .same-feature-member__name {
  2089. color: #111827;
  2090. font-size: 14px;
  2091. font-weight: 600;
  2092. line-height: 22px;
  2093. white-space: nowrap;
  2094. overflow: hidden;
  2095. text-overflow: ellipsis;
  2096. }
  2097. .same-feature-member__alias {
  2098. margin-top: 4px;
  2099. color: #94a3b8;
  2100. font-size: 12px;
  2101. line-height: 18px;
  2102. white-space: nowrap;
  2103. overflow: hidden;
  2104. text-overflow: ellipsis;
  2105. }
  2106. .same-feature-cardholder-label {
  2107. z-index: 3;
  2108. padding: 2px 8px;
  2109. border: 1px solid #d9e7ff;
  2110. border-radius: 999px;
  2111. background: rgba(255, 255, 255, 0.92);
  2112. color: #64748b;
  2113. font-size: 11px;
  2114. line-height: 18px;
  2115. white-space: nowrap;
  2116. box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04);
  2117. }
  2118. .consistent-action-card > .empty-panel {
  2119. flex: 1 1 auto;
  2120. min-height: 0;
  2121. overflow: auto;
  2122. }
  2123. .consistent-action-body {
  2124. flex: 1 1 auto;
  2125. min-height: 0;
  2126. display: flex;
  2127. flex-direction: column;
  2128. overflow-x: hidden;
  2129. overflow-y: auto;
  2130. padding: 8px 0 0;
  2131. scrollbar-width: thin;
  2132. scrollbar-color: rgba(148, 163, 184, 0.7) transparent;
  2133. &::-webkit-scrollbar {
  2134. width: 8px;
  2135. height: 8px;
  2136. }
  2137. &::-webkit-scrollbar-thumb {
  2138. background: rgba(148, 163, 184, 0.7);
  2139. border-radius: 999px;
  2140. }
  2141. &::-webkit-scrollbar-track {
  2142. background: transparent;
  2143. }
  2144. }
  2145. .consistent-action-section {
  2146. padding: 0 18px 22px;
  2147. & + .consistent-action-section {
  2148. border-top: 1px dashed #e5ebf3;
  2149. }
  2150. }
  2151. .consistent-action-section__title {
  2152. padding: 10px 4px 14px;
  2153. color: #111827;
  2154. font-size: 14px;
  2155. font-weight: 600;
  2156. line-height: 22px;
  2157. }
  2158. .consistent-action-chart {
  2159. position: relative;
  2160. background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
  2161. }
  2162. .consistent-action-svg {
  2163. position: absolute;
  2164. top: 0;
  2165. left: 0;
  2166. z-index: 1;
  2167. pointer-events: none;
  2168. }
  2169. .consistent-action-path {
  2170. fill: none;
  2171. stroke: #6b7499;
  2172. stroke-width: 2;
  2173. stroke-linecap: round;
  2174. stroke-linejoin: round;
  2175. opacity: 0.7;
  2176. }
  2177. .consistent-action-row,
  2178. .consistent-action-person,
  2179. .consistent-action-date {
  2180. position: absolute;
  2181. z-index: 2;
  2182. }
  2183. .consistent-action-row {
  2184. display: flex;
  2185. align-items: center;
  2186. gap: 16px;
  2187. padding: 0 18px;
  2188. border: 1px solid #e6ebf2;
  2189. border-radius: 12px;
  2190. background: #fff;
  2191. box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
  2192. box-sizing: border-box;
  2193. cursor: pointer;
  2194. text-align: left;
  2195. appearance: none;
  2196. transition:
  2197. transform 0.2s ease,
  2198. box-shadow 0.2s ease,
  2199. border-color 0.2s ease;
  2200. color: #334155;
  2201. &:hover {
  2202. transform: translateY(-1px);
  2203. border-color: #bfd4f5;
  2204. box-shadow: 0 14px 28px rgba(37, 99, 235, 0.12);
  2205. }
  2206. }
  2207. .consistent-action-row__name-box {
  2208. display: flex;
  2209. align-items: center;
  2210. gap: 6px;
  2211. min-width: 0;
  2212. flex: 0 0 260px;
  2213. }
  2214. .consistent-action-row__name {
  2215. color: #111827;
  2216. font-size: 14px;
  2217. font-weight: 600;
  2218. line-height: 22px;
  2219. white-space: nowrap;
  2220. overflow: hidden;
  2221. text-overflow: ellipsis;
  2222. }
  2223. .consistent-action-row__card {
  2224. color: #94a3b8;
  2225. font-size: 12px;
  2226. line-height: 20px;
  2227. white-space: nowrap;
  2228. }
  2229. .consistent-action-row__flag {
  2230. flex: 0 0 auto;
  2231. padding: 2px 8px;
  2232. border: 1px solid #ffe2a8;
  2233. border-radius: 999px;
  2234. background: #fff7e6;
  2235. color: #d97706;
  2236. font-size: 11px;
  2237. line-height: 16px;
  2238. }
  2239. .consistent-action-row__bank {
  2240. flex: 1;
  2241. min-width: 0;
  2242. color: #475569;
  2243. font-size: 12px;
  2244. line-height: 20px;
  2245. white-space: nowrap;
  2246. overflow: hidden;
  2247. text-overflow: ellipsis;
  2248. }
  2249. .consistent-action-row__money {
  2250. flex: 0 0 140px;
  2251. color: #1677ff;
  2252. font-size: 12px;
  2253. font-weight: 600;
  2254. line-height: 20px;
  2255. text-align: right;
  2256. white-space: nowrap;
  2257. }
  2258. .consistent-action-row__block {
  2259. width: 36px;
  2260. height: 16px;
  2261. border-radius: 4px;
  2262. flex: 0 0 auto;
  2263. box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28);
  2264. }
  2265. .consistent-action-person {
  2266. display: flex;
  2267. align-items: center;
  2268. gap: 12px;
  2269. padding: 0 16px;
  2270. border: 1px solid #dbe5f1;
  2271. border-radius: 12px;
  2272. background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
  2273. box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
  2274. box-sizing: border-box;
  2275. cursor: pointer;
  2276. appearance: none;
  2277. text-align: left;
  2278. transition:
  2279. transform 0.2s ease,
  2280. box-shadow 0.2s ease,
  2281. border-color 0.2s ease;
  2282. &:hover {
  2283. transform: translateY(-1px);
  2284. border-color: #bfd4f5;
  2285. box-shadow: 0 14px 30px rgba(22, 119, 255, 0.12);
  2286. }
  2287. }
  2288. .consistent-action-person__avatar {
  2289. width: 44px;
  2290. height: 44px;
  2291. display: flex;
  2292. flex: 0 0 auto;
  2293. }
  2294. .consistent-action-person__info {
  2295. min-width: 0;
  2296. flex: 1;
  2297. }
  2298. .consistent-action-person__name {
  2299. color: #111827;
  2300. font-size: 14px;
  2301. font-weight: 600;
  2302. line-height: 22px;
  2303. white-space: nowrap;
  2304. overflow: hidden;
  2305. text-overflow: ellipsis;
  2306. }
  2307. .consistent-action-person__card {
  2308. margin-top: 4px;
  2309. color: #94a3b8;
  2310. font-size: 12px;
  2311. line-height: 18px;
  2312. white-space: nowrap;
  2313. overflow: hidden;
  2314. text-overflow: ellipsis;
  2315. }
  2316. .consistent-action-date {
  2317. width: 196px;
  2318. padding: 0 8px 0 0;
  2319. box-sizing: border-box;
  2320. color: #111827;
  2321. font-size: 14px;
  2322. font-weight: 600;
  2323. line-height: 24px;
  2324. white-space: nowrap;
  2325. text-align: right;
  2326. }
  2327. .subject-node,
  2328. .holder-node,
  2329. .consistent-target-node,
  2330. .consistent-center-node,
  2331. .feature-node,
  2332. .consistent-source-node {
  2333. position: absolute;
  2334. z-index: 2;
  2335. }
  2336. .subject-node,
  2337. .consistent-target-node {
  2338. padding: 14px 16px;
  2339. border: 1px solid #c6dbff;
  2340. border-radius: 16px;
  2341. background: linear-gradient(135deg, #f8fbff 0%, #eef5ff 100%);
  2342. box-shadow: 0 12px 30px rgba(37, 99, 235, 0.08);
  2343. display: flex;
  2344. flex-direction: column;
  2345. justify-content: center;
  2346. }
  2347. .subject-node__label,
  2348. .consistent-target-node__label,
  2349. .holder-node__label {
  2350. color: #6b7280;
  2351. font-size: 11px;
  2352. line-height: 18px;
  2353. }
  2354. .subject-node__name,
  2355. .consistent-target-node__name,
  2356. .holder-node__name {
  2357. margin-top: 4px;
  2358. color: #111827;
  2359. font-size: 18px;
  2360. font-weight: 600;
  2361. line-height: 24px;
  2362. }
  2363. .subject-node__desc,
  2364. .consistent-target-node__desc,
  2365. .holder-node__desc {
  2366. margin-top: 6px;
  2367. color: #64748b;
  2368. font-size: 12px;
  2369. line-height: 18px;
  2370. }
  2371. .subject-node__chips {
  2372. display: flex;
  2373. flex-wrap: wrap;
  2374. gap: 6px;
  2375. margin-top: 10px;
  2376. }
  2377. .subject-chip {
  2378. display: inline-flex;
  2379. align-items: center;
  2380. padding: 2px 8px;
  2381. border-radius: 999px;
  2382. border: 1px solid #dbeafe;
  2383. background: #fff;
  2384. color: #2563eb;
  2385. font-size: 10px;
  2386. line-height: 16px;
  2387. }
  2388. .feature-node,
  2389. .consistent-source-node {
  2390. padding: 14px 16px;
  2391. border: 1px solid #d7e3f3;
  2392. border-radius: 16px;
  2393. background: #fff;
  2394. box-shadow: 0 12px 30px rgba(37, 99, 235, 0.08);
  2395. text-align: left;
  2396. cursor: pointer;
  2397. transition:
  2398. transform 0.2s ease,
  2399. box-shadow 0.2s ease,
  2400. border-color 0.2s ease;
  2401. appearance: none;
  2402. }
  2403. .feature-node:hover,
  2404. .consistent-source-node:hover {
  2405. transform: translateY(-1px);
  2406. border-color: #93c5fd;
  2407. box-shadow: 0 16px 34px rgba(37, 99, 235, 0.12);
  2408. }
  2409. .feature-node__head {
  2410. display: flex;
  2411. align-items: flex-start;
  2412. justify-content: space-between;
  2413. gap: 8px;
  2414. }
  2415. .feature-node__label {
  2416. color: #6b7280;
  2417. font-size: 11px;
  2418. line-height: 18px;
  2419. }
  2420. .feature-node__amount {
  2421. color: #2563eb;
  2422. font-size: 12px;
  2423. font-weight: 600;
  2424. line-height: 18px;
  2425. white-space: nowrap;
  2426. }
  2427. .feature-node__card,
  2428. .consistent-source-node__card {
  2429. margin-top: 6px;
  2430. color: #111827;
  2431. font-size: 15px;
  2432. font-weight: 600;
  2433. line-height: 22px;
  2434. }
  2435. .feature-node__meta,
  2436. .consistent-source-node__meta {
  2437. margin-top: 4px;
  2438. display: flex;
  2439. align-items: center;
  2440. justify-content: space-between;
  2441. gap: 10px;
  2442. color: #64748b;
  2443. font-size: 12px;
  2444. line-height: 18px;
  2445. }
  2446. .feature-tag-list {
  2447. display: flex;
  2448. flex-wrap: wrap;
  2449. gap: 6px;
  2450. margin-top: 10px;
  2451. }
  2452. .feature-tag {
  2453. display: inline-flex;
  2454. align-items: center;
  2455. padding: 3px 8px;
  2456. border-radius: 999px;
  2457. border: 1px solid #dbeafe;
  2458. background: #eff6ff;
  2459. color: #2563eb;
  2460. font-size: 11px;
  2461. line-height: 16px;
  2462. }
  2463. .feature-node__footer {
  2464. margin-top: 12px;
  2465. display: flex;
  2466. align-items: center;
  2467. justify-content: space-between;
  2468. gap: 8px;
  2469. color: #64748b;
  2470. font-size: 12px;
  2471. line-height: 18px;
  2472. }
  2473. .feature-node__action {
  2474. color: #1677ff;
  2475. font-weight: 600;
  2476. }
  2477. .holder-node {
  2478. padding: 14px 16px;
  2479. border: 1px solid #dbe5f1;
  2480. border-radius: 14px;
  2481. background: #fff;
  2482. box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
  2483. display: flex;
  2484. flex-direction: column;
  2485. justify-content: center;
  2486. }
  2487. .holder-node__metrics {
  2488. margin-top: 10px;
  2489. display: flex;
  2490. align-items: center;
  2491. justify-content: space-between;
  2492. gap: 10px;
  2493. color: #475569;
  2494. font-size: 12px;
  2495. line-height: 18px;
  2496. }
  2497. .consistent-source-node__name {
  2498. color: #111827;
  2499. font-size: 15px;
  2500. font-weight: 600;
  2501. line-height: 22px;
  2502. }
  2503. .consistent-source-node__tag {
  2504. margin-top: 10px;
  2505. display: inline-flex;
  2506. align-items: center;
  2507. padding: 3px 10px;
  2508. border-radius: 999px;
  2509. border: 1px solid #ffe2a8;
  2510. background: #fff7e6;
  2511. color: #d46b08;
  2512. font-size: 11px;
  2513. line-height: 16px;
  2514. }
  2515. .consistent-center-node {
  2516. display: flex;
  2517. flex-direction: column;
  2518. align-items: center;
  2519. justify-content: center;
  2520. gap: 4px;
  2521. border: 1px solid #bfd4f5;
  2522. border-radius: 18px;
  2523. background: linear-gradient(135deg, #eef5ff 0%, #f8fbff 100%);
  2524. box-shadow: 0 12px 28px rgba(22, 119, 255, 0.12);
  2525. color: #2563eb;
  2526. text-align: center;
  2527. }
  2528. .consistent-center-node__label {
  2529. font-size: 12px;
  2530. line-height: 18px;
  2531. }
  2532. .consistent-center-node__value {
  2533. font-size: 16px;
  2534. font-weight: 600;
  2535. line-height: 24px;
  2536. }
  2537. .insight-strip {
  2538. display: grid;
  2539. grid-template-columns: repeat(3, minmax(0, 1fr));
  2540. gap: 12px;
  2541. padding: 16px 18px 18px;
  2542. background: #fff;
  2543. }
  2544. .insight-card {
  2545. padding: 14px 16px;
  2546. border: 1px solid #edf2f7;
  2547. border-radius: 12px;
  2548. background: #fafcff;
  2549. }
  2550. .insight-card__label {
  2551. color: #6b7280;
  2552. font-size: 11px;
  2553. line-height: 18px;
  2554. }
  2555. .insight-card__value {
  2556. margin-top: 6px;
  2557. color: #111827;
  2558. font-size: 13px;
  2559. line-height: 22px;
  2560. }
  2561. .empty-panel {
  2562. min-height: 420px;
  2563. display: flex;
  2564. align-items: center;
  2565. justify-content: center;
  2566. padding: 20px 16px;
  2567. background: transparent;
  2568. }
  2569. :deep(.ant-select-selector),
  2570. :deep(.ant-input),
  2571. :deep(.ant-picker),
  2572. :deep(.ant-btn) {
  2573. border-radius: 8px;
  2574. }
  2575. @media screen and (max-width: 900px) {
  2576. flex-direction: column;
  2577. .left-sidebar {
  2578. width: 100%;
  2579. flex: none;
  2580. }
  2581. .menu-list {
  2582. flex-direction: row;
  2583. flex-wrap: wrap;
  2584. }
  2585. .menu-item {
  2586. flex: 1 1 calc(50% - 4px);
  2587. min-width: 180px;
  2588. }
  2589. .panel-header {
  2590. flex-direction: column;
  2591. }
  2592. .panel-stat-list {
  2593. width: 100%;
  2594. justify-content: flex-start;
  2595. }
  2596. .insight-strip {
  2597. grid-template-columns: 1fr;
  2598. }
  2599. }
  2600. @media screen and (max-width: 992px) {
  2601. .filter-item {
  2602. width: 100%;
  2603. align-items: flex-start;
  2604. flex-wrap: wrap;
  2605. }
  2606. .filter-input,
  2607. .filter-group,
  2608. .filter-mini,
  2609. .filter-micro,
  2610. .filter-date {
  2611. width: 100%;
  2612. }
  2613. .filter-actions {
  2614. width: 100%;
  2615. margin-left: 0;
  2616. }
  2617. }
  2618. }
  2619. @import '../styles/trans-analysis-table-ui.less';
  2620. </style>