index.vue 63 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075
  1. <template>
  2. <div :class="prefixCls" class="capital-flow-page">
  3. <div class="page-header">
  4. <div class="page-title">资金流向分析</div>
  5. <div class="page-subtitle">
  6. 左侧按对象筛选交易记录,右侧按旧系统“左表右图”的思路展示向前/向后追溯资金流向,并统一为当前项目蓝灰分析页风格。
  7. </div>
  8. </div>
  9. <div class="filter-bar">
  10. <div class="filter-item">
  11. <span class="filter-label">选择对象:</span>
  12. <Select
  13. v-model:value="filters.target"
  14. class="filter-input"
  15. :options="targetOptions"
  16. placeholder="请选择对象"
  17. allow-clear
  18. />
  19. </div>
  20. <div class="filter-item">
  21. <span class="filter-label">交易金额:</span>
  22. <Select v-model:value="filters.amountOp" class="filter-op" :options="amountOpOptions" />
  23. <Input
  24. v-model:value="filters.amountValue"
  25. class="filter-mini"
  26. :status="amountValueStatus"
  27. placeholder="请输入"
  28. />
  29. </div>
  30. <div class="filter-item">
  31. <span class="filter-label">交易日期:</span>
  32. <RangePicker
  33. v-model:value="filters.dateRange"
  34. class="filter-date"
  35. :format="DATE_FORMAT"
  36. :value-format="DATE_FORMAT"
  37. :placeholder="['开始日期', '结束日期']"
  38. />
  39. </div>
  40. <div class="filter-item">
  41. <span class="filter-label">对方卡号:</span>
  42. <Input v-model:value="filters.otherCard" class="filter-input" placeholder="请输入对方卡号" />
  43. </div>
  44. <div class="filter-actions">
  45. <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
  46. <Button @click="handleReset">重置</Button>
  47. </div>
  48. </div>
  49. <div class="content-panel">
  50. <div class="left-panel">
  51. <div class="panel-header">
  52. <div>
  53. <div class="panel-title">交易记录</div>
  54. <div class="panel-desc">{{ leftPanelDescription }}</div>
  55. </div>
  56. </div>
  57. <div v-if="!hasSearched" class="query-state-panel">
  58. <EmptyState
  59. variant="query"
  60. title="请选择对象后查询"
  61. description="该页面必须先选择对象后,再点击查询展示交易记录"
  62. min-height="100%"
  63. description-width="min(100%, 420px)"
  64. />
  65. </div>
  66. <div v-else class="table-panel">
  67. <div class="table-wrapper">
  68. <BasicTable
  69. :columns="columns"
  70. :data-source="tableData"
  71. :loading="queryLoading"
  72. :pagination="false"
  73. :show-table-setting="false"
  74. :table-setting="{ setting: true, size: true, redo: false, fullScreen: false }"
  75. :can-resize="true"
  76. :is-can-resize-parent="true"
  77. :resize-height-offset="32"
  78. :rowClassName="summaryRowClassName"
  79. row-key="rowKey"
  80. @row-click="handleSummaryRowClick"
  81. @change="handleTableChange"
  82. />
  83. </div>
  84. </div>
  85. <div v-if="hasSearched" class="pagination-bar">
  86. <AnalysisPagination
  87. :total="pagination.total"
  88. :current="pagination.current"
  89. :page-size="pagination.pageSize"
  90. :page-size-options="pageSizeOptions"
  91. :loading="queryLoading"
  92. @change="handlePaginationChange"
  93. />
  94. </div>
  95. </div>
  96. <div ref="graphPanelRef" class="right-panel" :class="{ 'is-graph-fullscreen': isGraphFullscreen }">
  97. <div class="panel-header panel-header--graph">
  98. <div>
  99. <div class="panel-title">资金流向视图</div>
  100. <div class="panel-desc">{{ rightPanelDescription }}</div>
  101. </div>
  102. <div class="panel-header-actions">
  103. <Tooltip :title="isGraphFullscreen ? '退出全屏' : '全屏查看'" placement="left">
  104. <Button size="small" class="graph-fullscreen-btn" @click="handleToggleGraphFullscreen">
  105. <template #icon>
  106. <FullscreenExitOutlined v-if="isGraphFullscreen" />
  107. <FullscreenOutlined v-else />
  108. </template>
  109. {{ isGraphFullscreen ? '退出全屏' : '全屏查看' }}
  110. </Button>
  111. </Tooltip>
  112. </div>
  113. </div>
  114. <div class="flow-toolbar">
  115. <div class="filter-item">
  116. <span class="filter-label">层级:</span>
  117. <span class="filter-fixed-op">&lt;=</span>
  118. <Input v-model:value="graphFilters.levelValue" class="filter-micro" :status="levelValueStatus" />
  119. </div>
  120. <div class="filter-item">
  121. <span class="filter-label">时间间隔:</span>
  122. <Select v-model:value="graphFilters.intervalOp" class="filter-op-small" :options="binaryCompareOptions" />
  123. <Input v-model:value="graphFilters.intervalValue" class="filter-micro" :status="intervalValueStatus" />
  124. <span class="filter-unit">天</span>
  125. </div>
  126. <div class="filter-item">
  127. <span class="filter-label">金额:</span>
  128. <Select v-model:value="graphFilters.amountOp" class="filter-op-small" :options="binaryCompareOptions" />
  129. <Input v-model:value="graphFilters.amountValue" class="filter-mini" :status="graphAmountValueStatus" />
  130. </div>
  131. <div class="filter-item filter-item--checkbox">
  132. <Checkbox v-model:checked="graphFilters.cashOnly">只看现金</Checkbox>
  133. </div>
  134. <div class="filter-actions-small">
  135. <Button type="primary" size="small" :loading="graphLoading" @click="handleGraphSearch">查询</Button>
  136. <Button size="small" @click="handleGraphReset">重置</Button>
  137. </div>
  138. </div>
  139. <div class="trace-direction-group">
  140. <RadioGroup
  141. v-model:value="traceDirection"
  142. button-style="solid"
  143. option-type="button"
  144. :options="traceDirectionOptions"
  145. />
  146. </div>
  147. <div class="graph-body">
  148. <Spin :spinning="graphLoading" tip="资金流向加载中...">
  149. <div v-if="graphRoot" class="graph-scroll">
  150. <MoneyFlowGraph
  151. :data="graphRoot"
  152. :direction="traceDirection"
  153. @node-click="handleGraphNodeClick"
  154. @edge-click="handleGraphEdgeClick"
  155. />
  156. </div>
  157. <div v-else class="graph-empty">
  158. <EmptyState
  159. variant="compact"
  160. title="暂无资金流向视图"
  161. :description="graphEmptyDescription"
  162. :min-height="320"
  163. />
  164. </div>
  165. </Spin>
  166. </div>
  167. </div>
  168. </div>
  169. <TransRecordModal
  170. v-model:open="detailModalOpen"
  171. title="交易记录"
  172. :base-query="detailBaseQuery"
  173. :show-detail-action="true"
  174. :traceable="Boolean(detailEdgePayload)"
  175. :trace-direction="detailTraceDirection"
  176. :trace-loading="detailTraceLoading"
  177. @update:traceDirection="handleDetailTraceDirectionChange"
  178. @trace="handleDetailTrace"
  179. />
  180. </div>
  181. </template>
  182. <script lang="ts" setup name="ViewsCapitalFlowIndex">
  183. import { FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons-vue';
  184. import { useFullscreen } from '@vueuse/core';
  185. import { computed, h, markRaw, onMounted, ref, shallowRef, watch } from 'vue';
  186. import { Button, Checkbox, DatePicker, Input, RadioGroup, Select, Spin, Tooltip } from 'ant-design-vue';
  187. import AnalysisPagination from '@jeesite/core/components/AnalysisPagination/src/AnalysisPagination.vue';
  188. import MoneyFlowGraph from '@jeesite/graph/MoneyFlowGraph.vue';
  189. import { BasicTable, type BasicColumn } from '@jeesite/core/components/Table';
  190. import { EmptyState } from '@jeesite/core/components/EmptyState';
  191. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  192. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  193. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  194. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  195. import componentSetting from '@jeesite/core/settings/componentSetting';
  196. import {
  197. COMPARE_OPERATOR_OPTIONS,
  198. DEFAULT_COMPARE_OPERATOR,
  199. DEFAULT_LE_COMPARE_OPERATOR,
  200. normalizeCompareOperator,
  201. type CompareOperator,
  202. } from '@jeesite/core/utils/compareOperator';
  203. import { getPersonOptions } from '@jeesite/person/api/personApi';
  204. import { getTransFundFlowPage, moneyFlowGraph, moneyFlowTrace } from '@jeesite/trans/api/transApi';
  205. import type { MoneyFlowGraphDTO, TransFundFlowPageItemDTO, TransRecord } from '@jeesite/trans/api/types/dto';
  206. import type { MoneyFlowGraphQuery, TransFundFlowQuery, TransRecordQuery } from '@jeesite/trans/api/types/query';
  207. import TransRecordModal from '../components/TransRecordModal.vue';
  208. type RangeValue = [string, string] | undefined;
  209. type TraceDirection = 'forward' | 'backward';
  210. type BinaryCompareOperator = 'GE' | 'LE';
  211. type RecordFlagClass = 'income' | 'expense' | 'other';
  212. interface FundFlowTableRow {
  213. rowKey: string;
  214. id: number;
  215. personCardNosText: string;
  216. otherCardNosText: string;
  217. otherName: string;
  218. transAmount: string;
  219. transAmountText: string;
  220. transDateTime: string;
  221. debitCreditFlagText: string;
  222. debitCreditFlagClass: RecordFlagClass;
  223. raw: TransFundFlowPageItemDTO;
  224. }
  225. interface CapitalFlowNode {
  226. key: string;
  227. name: string;
  228. photo?: string;
  229. personCardNos: string[];
  230. otherCardNos: string[];
  231. transCount: number;
  232. totalTransAmount: string;
  233. lineData: string;
  234. ids: Array<string>;
  235. maxTime: string;
  236. minDate: string;
  237. maxDate: string;
  238. otherName: string;
  239. dataLevel: number;
  240. debitCreditFlag: string;
  241. state?: 'left' | 'right';
  242. children: CapitalFlowNode[];
  243. depth: number;
  244. }
  245. type GraphSelectionNode = {
  246. name?: string;
  247. otherName?: string;
  248. };
  249. interface GraphEdgeSelectionPayload {
  250. ids?: Array<string>;
  251. edgeId?: string;
  252. sourceId?: string;
  253. targetId?: string;
  254. raw?: Record<string, any> | null;
  255. sourceNode?: Record<string, any> | null;
  256. targetNode?: Record<string, any> | null;
  257. }
  258. interface TransRecordTracePayload {
  259. direction: TraceDirection;
  260. records: TransRecord[];
  261. }
  262. const { prefixCls } = useDesign('capital-flow');
  263. const { createMessage } = useMessage();
  264. const { queryLoading, runWithQueryLoading } = useTableSkill();
  265. const RangePicker = DatePicker.RangePicker;
  266. const DATE_FORMAT = 'YYYY-MM-DD';
  267. const CARD_WIDTH = 228;
  268. const CARD_HEIGHT = 122;
  269. const H_GAP = 148;
  270. const V_GAP = 44;
  271. const PADDING_X = 28;
  272. const PADDING_Y = 24;
  273. const MIN_BOARD_WIDTH = 460;
  274. const MIN_BOARD_HEIGHT = 300;
  275. const positiveIntegerPattern = /^[1-9]\d*$/;
  276. const positiveAmountPattern = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/;
  277. const amountOpOptions: Array<{ label: string; value: CompareOperator }> = [
  278. { label: '>', value: 'GT' },
  279. { label: '<', value: 'LT' },
  280. { label: '=', value: 'EQ' },
  281. ];
  282. const binaryCompareOptions: Array<{ label: string; value: BinaryCompareOperator }> = [
  283. { label: '>=', value: 'GE' },
  284. { label: '<=', value: 'LE' },
  285. ];
  286. const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
  287. label: value,
  288. value: Number(value),
  289. }));
  290. const filters = ref({
  291. target: '',
  292. amountOp: 'GT' as CompareOperator,
  293. amountValue: '',
  294. dateRange: undefined as RangeValue,
  295. otherCard: '',
  296. orderKey: '',
  297. sort: '' as '' | 'ASC' | 'DESC',
  298. });
  299. const graphFilters = ref({
  300. levelValue: '3',
  301. intervalOp: DEFAULT_LE_COMPARE_OPERATOR as BinaryCompareOperator,
  302. intervalValue: '7',
  303. amountOp: DEFAULT_COMPARE_OPERATOR as BinaryCompareOperator,
  304. amountValue: '',
  305. cashOnly: false,
  306. });
  307. const traceDirection = ref<TraceDirection>('backward');
  308. const traceDirectionOptions = [
  309. { label: '向前追溯', value: 'forward' },
  310. { label: '向后追溯', value: 'backward' },
  311. ];
  312. const targetOptions = ref<Array<{ label: string; value: string }>>([]);
  313. const tableData = ref<FundFlowTableRow[]>([]);
  314. const hasSearched = ref(false);
  315. const pagination = ref({
  316. current: 1,
  317. pageSize: componentSetting.table.defaultPageSize,
  318. total: 0,
  319. });
  320. const jumpPage = ref('1');
  321. const selectedRowKey = ref('');
  322. const graphLoading = ref(false);
  323. const graphQueried = ref(false);
  324. const graphRoot = shallowRef<CapitalFlowNode | null>(null);
  325. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  326. refresh: () => fetchTable(true),
  327. hasData: () => hasSearched.value && tableData.value.length > 0,
  328. });
  329. let graphRequestSequence = 0;
  330. const detailModalOpen = ref(false);
  331. const detailBaseQuery = ref<Partial<TransRecordQuery>>({});
  332. const detailEdgePayload = shallowRef<GraphEdgeSelectionPayload | null>(null);
  333. const detailTraceDirection = ref<TraceDirection>('backward');
  334. const detailTraceLoading = ref(false);
  335. const graphPanelRef = ref<HTMLElement | null>(null);
  336. const {
  337. isFullscreen: isGraphFullscreen,
  338. isSupported: isGraphFullscreenSupported,
  339. toggle: toggleGraphFullscreen,
  340. } = useFullscreen(graphPanelRef);
  341. const DETAIL_EDGE_CLICK_INTERVAL = 350;
  342. const SAME_DETAIL_EDGE_CLICK_INTERVAL = 800;
  343. let activeDetailEdgeKey = '';
  344. let lastDetailEdgeOpenTime = 0;
  345. const handleToggleGraphFullscreen = async () => {
  346. if (!isGraphFullscreenSupported.value) {
  347. createMessage.warning('当前环境不支持全屏查看');
  348. return;
  349. }
  350. try {
  351. await toggleGraphFullscreen();
  352. } catch (error) {
  353. console.error('[capitalFlow] toggle graph fullscreen failed:', error);
  354. createMessage.error('切换图谱全屏失败,请稍后重试');
  355. }
  356. };
  357. const toRawText = (value: unknown) => {
  358. if (value === 0) return '0';
  359. if (value === null || value === undefined) return '';
  360. return String(value).trim();
  361. };
  362. const withFallback = (value: unknown, fallback = '-') => {
  363. const text = toRawText(value);
  364. return text || fallback;
  365. };
  366. const toNumber = (value: unknown) => {
  367. if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
  368. const text = toRawText(value).replace(/,/g, '');
  369. if (!text) return 0;
  370. const parsed = Number(text);
  371. return Number.isFinite(parsed) ? parsed : 0;
  372. };
  373. const formatAmount = (value: unknown) => {
  374. if (typeof value === 'number' && Number.isFinite(value)) {
  375. return value.toLocaleString('zh-CN', {
  376. minimumFractionDigits: 2,
  377. maximumFractionDigits: 2,
  378. });
  379. }
  380. return toRawText(value);
  381. };
  382. const normalizeStringArray = (value: unknown): string[] => {
  383. if (Array.isArray(value)) {
  384. return value.map((item) => toRawText(item)).filter(Boolean);
  385. }
  386. const text = toRawText(value);
  387. if (!text) return [];
  388. if (text.includes(',')) {
  389. return text
  390. .split(',')
  391. .map((item) => item.trim())
  392. .filter(Boolean);
  393. }
  394. return [text];
  395. };
  396. const formatCardText = (cards: string[]) => {
  397. return cards.length ? cards.join(' / ') : '-';
  398. };
  399. const parseIds = (value: unknown): Array<string> => {
  400. if (Array.isArray(value)) {
  401. return value.map((item) => toRawText(item)).filter(Boolean);
  402. }
  403. const text = toRawText(value);
  404. if (!text) return [];
  405. return text
  406. .split(',')
  407. .map((item) => item.trim())
  408. .filter(Boolean);
  409. };
  410. const buildBranchSignature = (node?: Record<string, any> | null) => {
  411. if (!node) return '';
  412. return [
  413. toRawText(node.otherName || node.name),
  414. JSON.stringify(normalizeStringArray(node.personCardNos ?? node.selfCardNo ?? node.personCardNo)),
  415. String(node.dataLevel ?? node.level ?? ''),
  416. ].join('|');
  417. };
  418. const buildTraceChildSignature = (node?: Record<string, any> | null) => {
  419. if (!node) return '';
  420. return [buildBranchSignature(node), toRawText(node.lineData)].join('|');
  421. };
  422. const findTraceBranchNode = (source: any, targetSignature: string): any => {
  423. if (!source || !targetSignature) return null;
  424. if (Array.isArray(source)) {
  425. for (const item of source) {
  426. const matched = findTraceBranchNode(item, targetSignature);
  427. if (matched) return matched;
  428. }
  429. return null;
  430. }
  431. if (typeof source !== 'object') return null;
  432. if (buildBranchSignature(source) === targetSignature) {
  433. return source;
  434. }
  435. const nestedSources = [source.children, source.list, source.records, source.data];
  436. for (const nestedSource of nestedSources) {
  437. const matched = findTraceBranchNode(nestedSource, targetSignature);
  438. if (matched) return matched;
  439. }
  440. return null;
  441. };
  442. const resolveLegacyTraceBranchSource = (source: any): any => {
  443. if (!source) return null;
  444. if (Array.isArray(source)) {
  445. return source.length ? source[0] : null;
  446. }
  447. if (typeof source !== 'object') return null;
  448. if (Array.isArray(source.children) && source.children.length) {
  449. return source.children[0];
  450. }
  451. if (Array.isArray(source.list) && source.list.length) {
  452. return source.list[0];
  453. }
  454. if (Array.isArray(source.records) && source.records.length) {
  455. return source.records[0];
  456. }
  457. if (source.data) {
  458. return resolveLegacyTraceBranchSource(source.data);
  459. }
  460. return source;
  461. };
  462. const cloneCapitalFlowNode = (node: CapitalFlowNode): CapitalFlowNode => ({
  463. ...node,
  464. personCardNos: [...node.personCardNos],
  465. otherCardNos: [...node.otherCardNos],
  466. children: node.children.map((child) => cloneCapitalFlowNode(child)),
  467. });
  468. const applyTraceDirectionState = (node: CapitalFlowNode, directionValue: TraceDirection): CapitalFlowNode => ({
  469. ...node,
  470. state: directionValue === 'forward' ? 'left' : 'right',
  471. children: node.children.map((child) => applyTraceDirectionState(child, directionValue)),
  472. });
  473. const mergeTraceChildren = (sourceChildren: CapitalFlowNode[], incomingChildren: CapitalFlowNode[]) => {
  474. const nextChildren = sourceChildren.map((child) => cloneCapitalFlowNode(child));
  475. incomingChildren.forEach((incomingChild) => {
  476. const matchedIndex = nextChildren.findIndex(
  477. (child) => buildTraceChildSignature(child) === buildTraceChildSignature(incomingChild),
  478. );
  479. if (matchedIndex >= 0) {
  480. nextChildren[matchedIndex] = {
  481. ...nextChildren[matchedIndex],
  482. state: incomingChild.state || nextChildren[matchedIndex].state,
  483. children: mergeTraceChildren(nextChildren[matchedIndex].children, incomingChild.children),
  484. };
  485. return;
  486. }
  487. nextChildren.push(cloneCapitalFlowNode(incomingChild));
  488. });
  489. return nextChildren;
  490. };
  491. const mergeTraceBranchByKey = (
  492. node: CapitalFlowNode,
  493. targetKey: string,
  494. incomingBranch: CapitalFlowNode,
  495. ): [CapitalFlowNode, boolean] => {
  496. if (node.key === targetKey) {
  497. return [
  498. {
  499. ...cloneCapitalFlowNode(node),
  500. state: incomingBranch.state || node.state,
  501. children: mergeTraceChildren(node.children, incomingBranch.children),
  502. },
  503. true,
  504. ];
  505. }
  506. let merged = false;
  507. const nextChildren = node.children.map((child) => {
  508. const [nextChild, childMerged] = mergeTraceBranchByKey(child, targetKey, incomingBranch);
  509. if (childMerged) merged = true;
  510. return nextChild;
  511. });
  512. return [{ ...cloneCapitalFlowNode(node), children: nextChildren }, merged];
  513. };
  514. const mergeTraceBranchBySignature = (
  515. node: CapitalFlowNode,
  516. incomingBranch: CapitalFlowNode,
  517. ): [CapitalFlowNode, boolean] => {
  518. if (buildBranchSignature(node) === buildBranchSignature(incomingBranch)) {
  519. return [
  520. {
  521. ...cloneCapitalFlowNode(node),
  522. state: incomingBranch.state || node.state,
  523. children: mergeTraceChildren(node.children, incomingBranch.children),
  524. },
  525. true,
  526. ];
  527. }
  528. let merged = false;
  529. const nextChildren = node.children.map((child) => {
  530. const [nextChild, childMerged] = mergeTraceBranchBySignature(child, incomingBranch);
  531. if (childMerged) merged = true;
  532. return nextChild;
  533. });
  534. return [{ ...cloneCapitalFlowNode(node), children: nextChildren }, merged];
  535. };
  536. const resolveDebitCreditInfo = (flag?: string): { text: string; className: RecordFlagClass } => {
  537. const normalized = toRawText(flag);
  538. if (!normalized) {
  539. return { text: '-', className: 'other' };
  540. }
  541. if (/[收入贷]/.test(normalized)) {
  542. return { text: normalized, className: 'income' };
  543. }
  544. if (/[支出借]/.test(normalized)) {
  545. return { text: normalized, className: 'expense' };
  546. }
  547. return { text: normalized, className: 'other' };
  548. };
  549. const isOtherFlag = (flag?: string) => {
  550. const normalized = toRawText(flag);
  551. if (!normalized) return false;
  552. return normalized.includes('其他') || normalized.toLowerCase() === 'other';
  553. };
  554. const getRowFlag = (row?: Partial<TransFundFlowPageItemDTO> | null) => {
  555. return toRawText(row?.debitCreditFlag || row?.loanFlag);
  556. };
  557. const resolvePositiveIntegerInput = (
  558. value: string,
  559. label: string,
  560. showMessage = false,
  561. min?: number,
  562. max?: number,
  563. ): number | undefined | null => {
  564. const normalized = toRawText(value);
  565. if (!normalized) return undefined;
  566. if (!positiveIntegerPattern.test(normalized)) {
  567. if (showMessage) createMessage.warning(`${label}请输入大于 0 的整数`);
  568. return null;
  569. }
  570. const parsed = Number(normalized);
  571. if ((min !== undefined && parsed < min) || (max !== undefined && parsed > max)) {
  572. if (showMessage) {
  573. const rangeText = min !== undefined && max !== undefined ? `${min}-${max}` : `不小于 ${min}`;
  574. createMessage.warning(`${label}请输入 ${rangeText} 的整数`);
  575. }
  576. return null;
  577. }
  578. return parsed;
  579. };
  580. const resolvePositiveAmountInput = (value: string, label: string, showMessage = false): string | undefined | null => {
  581. const normalized = toRawText(value);
  582. const amountForCheck = normalized.replace(/,/g, '');
  583. if (!amountForCheck) return undefined;
  584. if (!positiveAmountPattern.test(amountForCheck) || /^0(?:\.0{1,2})?$/.test(amountForCheck)) {
  585. if (showMessage) createMessage.warning(`${label}请输入大于 0 的数字`);
  586. return null;
  587. }
  588. return normalized;
  589. };
  590. const unwrapResponseData = (resp: any) => {
  591. if (resp?.data !== undefined) return resp.data;
  592. return resp;
  593. };
  594. const normalizeTableList = (resp: any): TransFundFlowPageItemDTO[] => {
  595. if (Array.isArray(resp)) return resp;
  596. if (Array.isArray(resp?.records)) return resp.records;
  597. if (Array.isArray(resp?.list)) return resp.list;
  598. if (Array.isArray(resp?.data)) return resp.data;
  599. if (Array.isArray(resp?.data?.records)) return resp.data.records;
  600. if (Array.isArray(resp?.data?.list)) return resp.data.list;
  601. return [];
  602. };
  603. const normalizeTotal = (resp: any, listLength: number) => {
  604. const total = resp?.total ?? resp?.data?.total ?? resp?.count ?? resp?.data?.count ?? listLength;
  605. return Number.isFinite(Number(total)) ? Number(total) : listLength;
  606. };
  607. const normalizeCurrent = (resp: any, fallback: number) => {
  608. const current = resp?.current ?? resp?.data?.current ?? fallback;
  609. return Number.isFinite(Number(current)) && Number(current) > 0 ? Number(current) : fallback;
  610. };
  611. const normalizeSize = (resp: any, fallback: number) => {
  612. const size = resp?.size ?? resp?.data?.size ?? fallback;
  613. return Number.isFinite(Number(size)) && Number(size) > 0 ? Number(size) : fallback;
  614. };
  615. const mapTableRow = (item: TransFundFlowPageItemDTO, index: number): FundFlowTableRow => {
  616. const flagInfo = resolveDebitCreditInfo(getRowFlag(item));
  617. return {
  618. rowKey: String(item.id ?? `${pagination.value.current}-${index}`),
  619. id: (pagination.value.current - 1) * pagination.value.pageSize + index + 1,
  620. personCardNosText: formatCardText(
  621. normalizeStringArray(item.personCardNo ?? item.personCardNos ?? item.selfCardNo),
  622. ),
  623. otherCardNosText: formatCardText(
  624. normalizeStringArray(item.otherCardNo ?? item.otherCardNos ?? item.dealOtherCard),
  625. ),
  626. otherName: withFallback(item.otherName),
  627. transAmount: toRawText(item.transAmount),
  628. transAmountText: formatAmount(item.transAmount),
  629. transDateTime: withFallback(item.transDateTime),
  630. debitCreditFlagText: flagInfo.text,
  631. debitCreditFlagClass: flagInfo.className,
  632. raw: item,
  633. };
  634. };
  635. const buildNodeTimeText = (node: CapitalFlowNode) => {
  636. if (node.minDate && node.maxDate && node.minDate !== node.maxDate) {
  637. return `${node.minDate} 至 ${node.maxDate}`;
  638. }
  639. return withFallback(node.maxDate || node.maxTime);
  640. };
  641. const buildEdgeTitle = (node: CapitalFlowNode) => {
  642. if (node.minDate && node.maxDate && node.minDate !== node.maxDate) {
  643. return `${node.minDate} 至 ${node.maxDate}`;
  644. }
  645. return withFallback(node.maxDate || node.maxTime, '交易关系');
  646. };
  647. const buildEdgeValue = (node: CapitalFlowNode) => {
  648. const lineData = toRawText(node.lineData);
  649. if (lineData) return lineData;
  650. return `共${node.transCount || 0}笔 / ${formatAmount(node.totalTransAmount)}`;
  651. };
  652. const normalizeCapitalNode = (
  653. raw: Partial<MoneyFlowGraphDTO & TransFundFlowPageItemDTO> & { children?: any[] },
  654. key: string,
  655. depth = 0,
  656. ): CapitalFlowNode => {
  657. const children = Array.isArray(raw.children)
  658. ? raw.children.map((item, index) => normalizeCapitalNode(item || {}, `${key}-${index}`, depth + 1))
  659. : [];
  660. return {
  661. key,
  662. name: withFallback(raw.name || raw.otherName, `节点${key}`),
  663. photo: toRawText(raw.photo) || undefined,
  664. personCardNos: normalizeStringArray(raw.personCardNos ?? raw.selfCardNo),
  665. otherCardNos: normalizeStringArray(raw.otherCardNo ?? raw.otherCardNos ?? raw.dealOtherCard),
  666. transCount: toNumber(raw.transCount ?? raw.dealTotalCount),
  667. totalTransAmount: toRawText(raw.totalTransAmount ?? raw.dealTotalMoney ?? raw.transAmount),
  668. lineData: toRawText(raw.lineData),
  669. ids: parseIds(raw.ids),
  670. maxTime: toRawText(raw.maxTime),
  671. minDate: toRawText(raw.minDate),
  672. maxDate: toRawText(raw.maxDate),
  673. otherName: toRawText(raw.otherName),
  674. dataLevel: raw.dataLevel ?? raw.level ?? depth,
  675. debitCreditFlag: getRowFlag(raw),
  676. children,
  677. depth,
  678. };
  679. };
  680. const buildGraphRoot = (resp: any, row?: TransFundFlowPageItemDTO | null): CapitalFlowNode | null => {
  681. const payload = unwrapResponseData(resp);
  682. if (!payload) return null;
  683. const graphSource = Array.isArray(payload?.list) ? payload.list : payload;
  684. if (Array.isArray(graphSource)) {
  685. if (!graphSource.length) return null;
  686. return normalizeCapitalNode(
  687. {
  688. name: row?.otherName || filters.value.target || '当前节点',
  689. otherName: row?.otherName,
  690. personCardNos: normalizeStringArray(row?.personCardNo ?? row?.personCardNos ?? row?.selfCardNo),
  691. otherCardNo: row?.otherCardNo ?? row?.otherCardNos ?? row?.dealOtherCard,
  692. transAmount: row?.transAmount,
  693. transDateTime: row?.transDateTime,
  694. debitCreditFlag: getRowFlag(row),
  695. children: graphSource,
  696. },
  697. 'root',
  698. );
  699. }
  700. return normalizeCapitalNode(graphSource || {}, 'root');
  701. };
  702. const cloneGraphSelectionNode = (node?: Record<string, any> | null) => {
  703. if (!node) return null;
  704. return {
  705. key: node.key || node.id,
  706. name: node.name,
  707. otherName: node.otherName,
  708. personCardNos: normalizeStringArray(node.personCardNos ?? node.selfCardNo),
  709. otherCardNos: normalizeStringArray(node.otherCardNos ?? node.otherCardNo ?? node.dealOtherCard),
  710. transCount: node.transCount ?? node.dealTotalCount,
  711. totalTransAmount: node.totalTransAmount ?? node.dealTotalMoney ?? node.transAmount,
  712. lineData: node.lineData,
  713. ids: parseIds(node.ids),
  714. minDate: node.minDate,
  715. maxDate: node.maxDate,
  716. maxTime: node.maxTime,
  717. debitCreditFlag: node.debitCreditFlag ?? node.loanFlag,
  718. dataLevel: node.dataLevel ?? node.level ?? 0,
  719. };
  720. };
  721. const normalizeEdgeSelectionPayload = (edgePayload: GraphEdgeSelectionPayload): GraphEdgeSelectionPayload => ({
  722. ids: parseIds(edgePayload.ids),
  723. edgeId: edgePayload.edgeId,
  724. sourceId: edgePayload.sourceId,
  725. targetId: edgePayload.targetId,
  726. raw: cloneGraphSelectionNode(edgePayload.raw),
  727. sourceNode: cloneGraphSelectionNode(edgePayload.sourceNode),
  728. targetNode: cloneGraphSelectionNode(edgePayload.targetNode),
  729. });
  730. const buildEdgeDetailKey = (edgePayload: GraphEdgeSelectionPayload) => {
  731. return [
  732. edgePayload.edgeId || '',
  733. edgePayload.sourceId || '',
  734. edgePayload.targetId || '',
  735. [...(edgePayload.ids || [])].sort().join(','),
  736. ].join('|');
  737. };
  738. const buildTracePageVO = (edgePayload: GraphEdgeSelectionPayload, directionValue: TraceDirection) => {
  739. const targetNode = edgePayload.targetNode || edgePayload.raw;
  740. const sourceName = toRawText(edgePayload.sourceNode?.otherName || edgePayload.sourceNode?.name);
  741. const targetName = toRawText(targetNode?.otherName || targetNode?.name);
  742. const personCardNos = normalizeStringArray(targetNode?.personCardNos ?? targetNode?.selfCardNo);
  743. const otherCardNos = normalizeStringArray(
  744. targetNode?.otherCardNos ?? targetNode?.otherCardNo ?? targetNode?.dealOtherCard,
  745. );
  746. const dealTime = toRawText(targetNode?.maxTime || targetNode?.maxDate);
  747. const loanFlag = toRawText(targetNode?.debitCreditFlag ?? targetNode?.loanFlag);
  748. const totalTransAmount = toRawText(targetNode?.totalTransAmount ?? targetNode?.transAmount) || undefined;
  749. return {
  750. ...targetNode,
  751. name: targetName,
  752. otherName: toRawText(targetNode?.otherName) || targetName,
  753. personCardNos,
  754. selfCardNo: personCardNos,
  755. otherCardNo: otherCardNos,
  756. otherCardNos,
  757. dealOtherCard: otherCardNos,
  758. transAmount: totalTransAmount,
  759. dealMoney: totalTransAmount,
  760. transDateTime: dealTime || undefined,
  761. dealTime: dealTime || undefined,
  762. debitCreditFlag: loanFlag || undefined,
  763. loanFlag: loanFlag || undefined,
  764. parentName: directionValue === 'forward' ? targetName : sourceName,
  765. childName: directionValue === 'forward' ? sourceName : targetName,
  766. dataLevel: Number(targetNode?.dataLevel) || 0,
  767. };
  768. };
  769. const buildTraceParamItem = (
  770. record: TransRecord,
  771. edgePayload: GraphEdgeSelectionPayload,
  772. directionValue: TraceDirection,
  773. ) => {
  774. const targetNode = edgePayload.targetNode || edgePayload.raw;
  775. const recordPayload = record as TransRecord & Partial<TransFundFlowPageItemDTO> & { loanFlag?: string };
  776. const sourceName = toRawText(edgePayload.sourceNode?.otherName || edgePayload.sourceNode?.name);
  777. const targetName = toRawText(targetNode?.otherName || targetNode?.name);
  778. const personCardNos = normalizeStringArray(
  779. recordPayload.personCardNo ?? recordPayload.personCardNos ?? recordPayload.selfCardNo,
  780. );
  781. const otherCardNos = normalizeStringArray(
  782. recordPayload.otherCardNo ?? recordPayload.otherCardNos ?? recordPayload.dealOtherCard,
  783. );
  784. const transDateTime = toRawText(
  785. record.transDateTime || [record.transDate, record.transTime].filter(Boolean).join(' '),
  786. );
  787. const transAmount = toRawText(record.transAmount) || undefined;
  788. const loanFlag = toRawText(recordPayload.debitCreditFlag ?? recordPayload.loanFlag) || undefined;
  789. const otherName = toRawText(record.otherName) || undefined;
  790. return {
  791. id: toRawText(record.id) || undefined,
  792. personCardNos,
  793. selfCardNo: personCardNos,
  794. otherCardNo: otherCardNos,
  795. otherCardNos,
  796. dealOtherCard: otherCardNos,
  797. otherName,
  798. dealOtherName: otherName,
  799. transAmount,
  800. dealMoney: transAmount,
  801. transDateTime,
  802. dealTime: transDateTime,
  803. debitCreditFlag: loanFlag,
  804. loanFlag,
  805. parentName: directionValue === 'forward' ? targetName : sourceName,
  806. childName: directionValue === 'forward' ? sourceName : targetName,
  807. };
  808. };
  809. const buildTraceGraphQuery = (
  810. records: TransRecord[],
  811. edgePayload: GraphEdgeSelectionPayload,
  812. directionValue: TraceDirection,
  813. showMessage = false,
  814. ): MoneyFlowGraphQuery | null => {
  815. const target = toRawText(filters.value.target);
  816. if (!target) {
  817. if (showMessage) createMessage.warning('请先选择对象后再查询');
  818. return null;
  819. }
  820. const level = resolvePositiveIntegerInput(graphFilters.value.levelValue, '层级', showMessage, 2, 4);
  821. if (level === null) return null;
  822. const timeGap = resolvePositiveIntegerInput(graphFilters.value.intervalValue, '时间间隔', showMessage, 1);
  823. if (timeGap === null) return null;
  824. const money = resolvePositiveAmountInput(graphFilters.value.amountValue, '金额', showMessage);
  825. if (money === null) return null;
  826. return {
  827. personName: target,
  828. flowPageDTO: buildTracePageVO(edgePayload, directionValue) as any,
  829. level: level ?? 3,
  830. timeGap: timeGap ?? 7,
  831. timeGapSymbol: graphFilters.value.intervalOp === 'GE' ? 1 : 2,
  832. money: money ?? undefined,
  833. moneySymbol: money !== undefined ? (graphFilters.value.amountOp === 'GE' ? 1 : 2) : undefined,
  834. cashOnly: graphFilters.value.cashOnly ? 1 : 0,
  835. direction: traceDirection.value === 'forward' ? 1 : 2,
  836. traceDirection: directionValue === 'forward' ? 1 : 2,
  837. traceParamList: records.map((record) => buildTraceParamItem(record, edgePayload, directionValue) as any),
  838. };
  839. };
  840. const resolveTraceBranchSource = (resp: any, edgePayload: GraphEdgeSelectionPayload) => {
  841. const payload = unwrapResponseData(resp);
  842. if (!payload) return null;
  843. const targetSignature = buildBranchSignature(edgePayload.targetNode || edgePayload.raw || null);
  844. if (targetSignature) {
  845. const matched = findTraceBranchNode(payload, targetSignature);
  846. if (matched) return matched;
  847. }
  848. return resolveLegacyTraceBranchSource(payload);
  849. };
  850. const normalizeTraceBranch = (
  851. raw: Partial<MoneyFlowGraphDTO & TransFundFlowPageItemDTO> & { children?: any[] },
  852. targetKey: string,
  853. directionValue: TraceDirection,
  854. ) => {
  855. const tracePrefix = `${targetKey}::trace-${Date.now()}`;
  856. const normalized = normalizeCapitalNode(raw, tracePrefix);
  857. normalized.key = targetKey;
  858. return applyTraceDirectionState(normalized, directionValue);
  859. };
  860. const selectedTradeRow = computed(() => {
  861. return tableData.value.find((item) => item.rowKey === selectedRowKey.value) || null;
  862. });
  863. const totalPages = computed(() => {
  864. if (!pagination.value.total) return 1;
  865. return Math.max(1, Math.ceil(pagination.value.total / pagination.value.pageSize));
  866. });
  867. const amountValueStatus = computed<'error' | undefined>(() => {
  868. const normalized = toRawText(filters.value.amountValue).replace(/,/g, '');
  869. if (!normalized) return undefined;
  870. return positiveAmountPattern.test(normalized) && Number(normalized) > 0 ? undefined : 'error';
  871. });
  872. const levelValueStatus = computed<'error' | undefined>(() => {
  873. const normalized = toRawText(graphFilters.value.levelValue);
  874. if (!normalized) return undefined;
  875. const parsed = Number(normalized);
  876. return positiveIntegerPattern.test(normalized) && parsed >= 2 && parsed <= 4 ? undefined : 'error';
  877. });
  878. const intervalValueStatus = computed<'error' | undefined>(() => {
  879. const normalized = toRawText(graphFilters.value.intervalValue);
  880. if (!normalized) return undefined;
  881. return positiveIntegerPattern.test(normalized) ? undefined : 'error';
  882. });
  883. const graphAmountValueStatus = computed<'error' | undefined>(() => {
  884. const normalized = toRawText(graphFilters.value.amountValue).replace(/,/g, '');
  885. if (!normalized) return undefined;
  886. return positiveAmountPattern.test(normalized) && Number(normalized) > 0 ? undefined : 'error';
  887. });
  888. const leftPanelDescription = computed(() => {
  889. if (!tableData.value.length) {
  890. return '按对象、金额、交易日期和对方卡号筛选交易记录,点击左侧列表可联动右侧资金流向视图。';
  891. }
  892. return '汇总所选对象的交易记录,右侧追溯某条记录前后交易关系';
  893. });
  894. const rightPanelDescription = computed(() => {
  895. if (!selectedTradeRow.value) {
  896. return '请选择左侧交易记录查看对应的资金流向关系。';
  897. }
  898. if (isOtherFlag(getRowFlag(selectedTradeRow.value.raw))) {
  899. return '当前选中交易借贷类型为“其他”,暂不支持判断资金流向。';
  900. }
  901. return `${selectedTradeRow.value.otherName} 的资金流向关系图,支持点击关系线或节点查看交易明细。`;
  902. });
  903. const graphEmptyDescription = computed(() => {
  904. if (!filters.value.target) return '该页面必须先选择对象后才能查询资金流向';
  905. if (!tableData.value.length) return '当前筛选条件下暂无交易记录';
  906. if (!selectedTradeRow.value) return '请选择左侧交易记录查看资金流向';
  907. if (isOtherFlag(getRowFlag(selectedTradeRow.value.raw))) {
  908. return '借贷类型为“其他”,无法判断当前交易的资金流向';
  909. }
  910. if (graphQueried.value) return '当前选中交易暂无可展示的资金流向关系';
  911. return '点击左侧交易记录后,右侧展示对应的资金流向视图';
  912. });
  913. const columns: BasicColumn[] = [
  914. { title: '序号', dataIndex: 'id', key: 'id', width: 70, align: 'center' },
  915. { title: '本方卡(账)号', dataIndex: 'personCardNosText', key: 'personCardNosText', width: 220, align: 'center' },
  916. { title: '对方卡(账)号', dataIndex: 'otherCardNosText', key: 'otherCardNosText', width: 220, align: 'center' },
  917. { title: '对方姓名', dataIndex: 'otherName', key: 'otherName', width: 140, align: 'center' },
  918. {
  919. title: '交易金额',
  920. dataIndex: 'transAmountText',
  921. key: 'transAmount',
  922. width: 140,
  923. align: 'center',
  924. sorter: true,
  925. customRender: (opt: any) => {
  926. const record = opt.record as FundFlowTableRow;
  927. return h(
  928. 'span',
  929. {
  930. class: ['amount-text', `is-${record.debitCreditFlagClass}`],
  931. },
  932. `¥${record.transAmountText}`,
  933. );
  934. },
  935. },
  936. {
  937. title: '交易时间',
  938. dataIndex: 'transDateTime',
  939. key: 'transDateTime',
  940. width: 180,
  941. align: 'center',
  942. sorter: true,
  943. },
  944. {
  945. title: '借贷标志',
  946. dataIndex: 'debitCreditFlagText',
  947. key: 'debitCreditFlag',
  948. width: 110,
  949. align: 'center',
  950. customRender: (opt: any) => {
  951. const record = opt.record as FundFlowTableRow;
  952. return h(
  953. 'span',
  954. {
  955. class: ['flag-chip', `is-${record.debitCreditFlagClass}`],
  956. },
  957. record.debitCreditFlagText,
  958. );
  959. },
  960. },
  961. ];
  962. const summaryRowClassName = (record: any, _index: number) => {
  963. return selectedRowKey.value === record.rowKey ? 'active-row' : '';
  964. };
  965. const buildTableQuery = (showMessage = false): TransFundFlowQuery | null => {
  966. const target = toRawText(filters.value.target);
  967. if (!target) {
  968. if (showMessage) createMessage.warning('请选择对象后再查询');
  969. return null;
  970. }
  971. const transAmount = resolvePositiveAmountInput(filters.value.amountValue, '交易金额', showMessage);
  972. if (transAmount === null) return null;
  973. const otherCardNo = toRawText(filters.value.otherCard);
  974. return {
  975. page: pagination.value.current,
  976. limit: pagination.value.pageSize,
  977. personName: target,
  978. personNames: [target],
  979. startDate: filters.value.dateRange?.[0],
  980. endDate: filters.value.dateRange?.[1],
  981. otherCardNo: otherCardNo || undefined,
  982. transAmount: transAmount ?? undefined,
  983. transAmountSymbol: transAmount !== undefined ? normalizeCompareOperator(filters.value.amountOp) : undefined,
  984. orderKey: filters.value.orderKey || undefined,
  985. sort: filters.value.sort || undefined,
  986. };
  987. };
  988. const buildGraphQuery = (row: TransFundFlowPageItemDTO, showMessage = false): MoneyFlowGraphQuery | null => {
  989. const target = toRawText(filters.value.target);
  990. if (!target) {
  991. if (showMessage) createMessage.warning('请先选择对象后再查询');
  992. return null;
  993. }
  994. const level = resolvePositiveIntegerInput(graphFilters.value.levelValue, '层级', showMessage, 2, 4);
  995. if (level === null) return null;
  996. const timeGap = resolvePositiveIntegerInput(graphFilters.value.intervalValue, '时间间隔', showMessage, 1);
  997. if (timeGap === null) return null;
  998. const money = resolvePositiveAmountInput(graphFilters.value.amountValue, '金额', showMessage);
  999. if (money === null) return null;
  1000. const personCardNos = normalizeStringArray(row.personCardNo ?? row.personCardNos ?? row.selfCardNo);
  1001. const otherCardNos = normalizeStringArray(row.otherCardNo ?? row.otherCardNos ?? row.dealOtherCard);
  1002. const flowPageDTO = {
  1003. id: toRawText(row.id) || undefined,
  1004. personCardNos,
  1005. selfCardNo: personCardNos,
  1006. otherCardNo: otherCardNos,
  1007. otherCardNos,
  1008. otherName: row.otherName,
  1009. transAmount: toRawText(row.transAmount) || undefined,
  1010. transDateTime: row.transDateTime,
  1011. debitCreditFlag: row.debitCreditFlag,
  1012. parentName: row.parentName,
  1013. childName: row.childName,
  1014. dataLevel: row.dataLevel,
  1015. };
  1016. return {
  1017. personName: target,
  1018. flowPageDTO,
  1019. level: level ?? 3,
  1020. timeGap: timeGap ?? 7,
  1021. timeGapSymbol: graphFilters.value.intervalOp === 'GE' ? 1 : 2,
  1022. money: money ?? undefined,
  1023. moneySymbol: money !== undefined ? (graphFilters.value.amountOp === 'GE' ? 1 : 2) : undefined,
  1024. cashOnly: graphFilters.value.cashOnly ? 1 : 0,
  1025. direction: traceDirection.value === 'forward' ? 1 : 2,
  1026. traceDirection: traceDirection.value === 'forward' ? 1 : 2,
  1027. traceParamList: flowPageDTO ? [flowPageDTO] : undefined,
  1028. };
  1029. };
  1030. const resetGraphState = (resetQueried = false) => {
  1031. graphRoot.value = null;
  1032. if (resetQueried) {
  1033. graphQueried.value = false;
  1034. }
  1035. };
  1036. const fetchGraphData = async (row?: TransFundFlowPageItemDTO | null, showMessage = true) => {
  1037. if (!row) {
  1038. if (showMessage) createMessage.warning('请选择一条交易记录');
  1039. resetGraphState(true);
  1040. return;
  1041. }
  1042. if (isOtherFlag(getRowFlag(row))) {
  1043. resetGraphState(true);
  1044. if (showMessage) createMessage.warning('借贷类型为“其他”,无法判断交易流向');
  1045. return;
  1046. }
  1047. const query = buildGraphQuery(row, showMessage);
  1048. if (!query) return;
  1049. const requestSequence = ++graphRequestSequence;
  1050. graphLoading.value = true;
  1051. graphQueried.value = true;
  1052. try {
  1053. const resp = await moneyFlowGraph(query);
  1054. if (requestSequence !== graphRequestSequence) return;
  1055. const nextGraphRoot = buildGraphRoot(resp, row);
  1056. graphRoot.value = nextGraphRoot ? markRaw(nextGraphRoot) : null;
  1057. } catch (error) {
  1058. if (requestSequence !== graphRequestSequence) return;
  1059. graphRoot.value = null;
  1060. } finally {
  1061. if (requestSequence === graphRequestSequence) {
  1062. graphLoading.value = false;
  1063. }
  1064. }
  1065. };
  1066. const fetchTable = async (showMessage = true) => {
  1067. const query = buildTableQuery(showMessage);
  1068. if (!query) return;
  1069. await runWithQueryLoading(async () => {
  1070. const resp = await getTransFundFlowPage(query);
  1071. const payload = unwrapResponseData(resp);
  1072. const list = normalizeTableList(payload);
  1073. pagination.value.current = normalizeCurrent(payload, pagination.value.current);
  1074. pagination.value.pageSize = normalizeSize(payload, pagination.value.pageSize);
  1075. pagination.value.total = normalizeTotal(payload, list.length);
  1076. jumpPage.value = String(pagination.value.current);
  1077. tableData.value = list.map(mapTableRow);
  1078. hasSearched.value = true;
  1079. syncAnalysisDataRefreshState(list.length > 0);
  1080. if (tableData.value.length) {
  1081. selectedRowKey.value = tableData.value[0].rowKey;
  1082. await fetchGraphData(tableData.value[0].raw, false);
  1083. } else {
  1084. selectedRowKey.value = '';
  1085. resetGraphState(true);
  1086. }
  1087. });
  1088. };
  1089. const handleSearch = async () => {
  1090. pagination.value.current = 1;
  1091. await fetchTable(true);
  1092. };
  1093. const handleReset = () => {
  1094. filters.value = {
  1095. target: '',
  1096. amountOp: 'GT' as CompareOperator,
  1097. amountValue: '',
  1098. dateRange: undefined,
  1099. otherCard: '',
  1100. orderKey: '',
  1101. sort: '',
  1102. };
  1103. graphFilters.value = {
  1104. levelValue: '3',
  1105. intervalOp: DEFAULT_LE_COMPARE_OPERATOR as BinaryCompareOperator,
  1106. intervalValue: '7',
  1107. amountOp: DEFAULT_COMPARE_OPERATOR as BinaryCompareOperator,
  1108. amountValue: '',
  1109. cashOnly: false,
  1110. };
  1111. traceDirection.value = 'backward';
  1112. tableData.value = [];
  1113. pagination.value.current = 1;
  1114. pagination.value.total = 0;
  1115. pagination.value.pageSize = componentSetting.table.defaultPageSize;
  1116. jumpPage.value = '1';
  1117. selectedRowKey.value = '';
  1118. hasSearched.value = false;
  1119. resetGraphState(true);
  1120. detailModalOpen.value = false;
  1121. detailBaseQuery.value = {};
  1122. detailEdgePayload.value = null;
  1123. detailTraceDirection.value = 'backward';
  1124. detailTraceLoading.value = false;
  1125. };
  1126. const handlePrevPage = async () => {
  1127. if (queryLoading.value || pagination.value.current <= 1) return;
  1128. pagination.value.current -= 1;
  1129. await fetchTable(false);
  1130. };
  1131. const handleNextPage = async () => {
  1132. if (queryLoading.value || pagination.value.current >= totalPages.value) return;
  1133. pagination.value.current += 1;
  1134. await fetchTable(false);
  1135. };
  1136. const handlePaginationChange = async (page: number, pageSize: number) => {
  1137. if (queryLoading.value) return;
  1138. if (pageSize !== pagination.value.pageSize) {
  1139. handlePageSizeChange(pageSize);
  1140. return;
  1141. }
  1142. if (page === pagination.value.current - 1) {
  1143. await handlePrevPage();
  1144. return;
  1145. }
  1146. if (page === pagination.value.current + 1) {
  1147. await handleNextPage();
  1148. return;
  1149. }
  1150. if (page === pagination.value.current) return;
  1151. jumpPage.value = String(page);
  1152. await handleJumpPage();
  1153. };
  1154. const handlePageSizeChange = (pageSize: any) => {
  1155. pagination.value.pageSize = Number(pageSize) || componentSetting.table.defaultPageSize;
  1156. pagination.value.current = 1;
  1157. void fetchTable(false);
  1158. };
  1159. const handleJumpPage = async () => {
  1160. const page = Number(jumpPage.value);
  1161. if (!Number.isFinite(page) || page < 1) {
  1162. createMessage.warning('请输入正确的页码');
  1163. jumpPage.value = String(pagination.value.current);
  1164. return;
  1165. }
  1166. pagination.value.current = Math.min(Math.max(1, Math.trunc(page)), totalPages.value);
  1167. jumpPage.value = String(pagination.value.current);
  1168. await fetchTable(false);
  1169. };
  1170. const resolveSorterValue = (sorter: any) => {
  1171. if (Array.isArray(sorter)) return sorter[0];
  1172. return sorter;
  1173. };
  1174. const handleTableChange = async (_pagination: any, _filters: any, sorter: any) => {
  1175. const currentSorter = resolveSorterValue(sorter);
  1176. const order = currentSorter?.order;
  1177. const columnKey = toRawText(currentSorter?.columnKey);
  1178. filters.value.orderKey = order && columnKey ? columnKey : '';
  1179. filters.value.sort = order === 'ascend' ? 'ASC' : order === 'descend' ? 'DESC' : '';
  1180. pagination.value.current = 1;
  1181. await fetchTable(false);
  1182. };
  1183. const handleSummaryRowClick = async (record: FundFlowTableRow) => {
  1184. selectedRowKey.value = record.rowKey;
  1185. await fetchGraphData(record.raw, true);
  1186. };
  1187. const handleGraphSearch = async () => {
  1188. await fetchGraphData(selectedTradeRow.value?.raw, true);
  1189. };
  1190. const handleGraphReset = async () => {
  1191. graphFilters.value = {
  1192. levelValue: '3',
  1193. intervalOp: DEFAULT_LE_COMPARE_OPERATOR as BinaryCompareOperator,
  1194. intervalValue: '7',
  1195. amountOp: DEFAULT_COMPARE_OPERATOR as BinaryCompareOperator,
  1196. amountValue: '',
  1197. cashOnly: false,
  1198. };
  1199. if (selectedTradeRow.value) {
  1200. await fetchGraphData(selectedTradeRow.value.raw, false);
  1201. } else {
  1202. resetGraphState(true);
  1203. }
  1204. };
  1205. const handleDetailTraceDirectionChange = (value: TraceDirection) => {
  1206. detailTraceDirection.value = value;
  1207. };
  1208. const handleEdgeDetailClick = (edgePayload: GraphEdgeSelectionPayload) => {
  1209. const nextPayload = normalizeEdgeSelectionPayload(edgePayload);
  1210. const nextIds = nextPayload.ids || [];
  1211. if (!nextIds.length) {
  1212. createMessage.warning('当前节点暂无可查看的交易明细');
  1213. return;
  1214. }
  1215. const detailEdgeKey = buildEdgeDetailKey(nextPayload);
  1216. const now = Date.now();
  1217. if (now - lastDetailEdgeOpenTime < DETAIL_EDGE_CLICK_INTERVAL) return;
  1218. if (detailModalOpen.value && detailEdgeKey === activeDetailEdgeKey) return;
  1219. if (detailEdgeKey === activeDetailEdgeKey && now - lastDetailEdgeOpenTime < SAME_DETAIL_EDGE_CLICK_INTERVAL) return;
  1220. activeDetailEdgeKey = detailEdgeKey;
  1221. lastDetailEdgeOpenTime = now;
  1222. detailEdgePayload.value = markRaw(nextPayload);
  1223. detailTraceDirection.value = 'backward';
  1224. detailBaseQuery.value = { ids: [...nextIds] };
  1225. detailModalOpen.value = true;
  1226. };
  1227. const handleGraphNodeClick = (node: GraphSelectionNode) => {
  1228. const matchedRow = tableData.value.find((item) => item.otherName === (node.otherName || node.name));
  1229. if (matchedRow) {
  1230. selectedRowKey.value = matchedRow.rowKey;
  1231. }
  1232. };
  1233. const handleDetailTrace = async (payload: TransRecordTracePayload) => {
  1234. if (!detailEdgePayload.value) {
  1235. createMessage.warning('当前没有可追溯的关系线');
  1236. return;
  1237. }
  1238. if (!payload.records.length) {
  1239. createMessage.warning('请选择追溯的数据');
  1240. return;
  1241. }
  1242. const traceQuery = buildTraceGraphQuery(payload.records, detailEdgePayload.value, payload.direction, true);
  1243. if (!traceQuery) return;
  1244. detailTraceLoading.value = true;
  1245. try {
  1246. const resp = await moneyFlowTrace(traceQuery);
  1247. const rawBranch = resolveTraceBranchSource(resp, detailEdgePayload.value);
  1248. if (!rawBranch) {
  1249. createMessage.warning('当前选中交易暂无可继续追溯的数据');
  1250. return;
  1251. }
  1252. const targetKey = detailEdgePayload.value.targetId || detailEdgePayload.value.raw?.key || '';
  1253. if (!targetKey) {
  1254. createMessage.warning('未识别到当前追溯节点');
  1255. return;
  1256. }
  1257. const incomingBranch = normalizeTraceBranch(rawBranch, String(targetKey), payload.direction);
  1258. if (!graphRoot.value) {
  1259. graphRoot.value = markRaw(incomingBranch);
  1260. detailModalOpen.value = false;
  1261. return;
  1262. }
  1263. const [nextGraphRoot, mergedByKey] = mergeTraceBranchByKey(graphRoot.value, String(targetKey), incomingBranch);
  1264. if (mergedByKey) {
  1265. graphRoot.value = markRaw(nextGraphRoot);
  1266. detailModalOpen.value = false;
  1267. return;
  1268. }
  1269. const [fallbackGraphRoot, mergedBySignature] = mergeTraceBranchBySignature(graphRoot.value, incomingBranch);
  1270. if (mergedBySignature) {
  1271. graphRoot.value = markRaw(fallbackGraphRoot);
  1272. detailModalOpen.value = false;
  1273. return;
  1274. }
  1275. createMessage.warning('已收到追溯结果,但未匹配到图中的落点节点');
  1276. detailModalOpen.value = false;
  1277. } catch (error) {
  1278. createMessage.error('追溯失败,请稍后重试');
  1279. } finally {
  1280. detailTraceLoading.value = false;
  1281. }
  1282. };
  1283. const handleGraphEdgeClick = (edgePayload: GraphEdgeSelectionPayload) => {
  1284. try {
  1285. handleEdgeDetailClick(edgePayload);
  1286. } catch (error) {
  1287. console.error('[capitalFlow] open edge detail failed:', error);
  1288. createMessage.error('打开交易明细失败,请稍后重试');
  1289. }
  1290. };
  1291. const fetchTargetOptions = async () => {
  1292. try {
  1293. targetOptions.value = await getPersonOptions(false);
  1294. } catch (error) {
  1295. targetOptions.value = [];
  1296. }
  1297. };
  1298. onMounted(async () => {
  1299. await fetchTargetOptions();
  1300. });
  1301. watch(traceDirection, async (nextValue, previousValue) => {
  1302. if (nextValue === previousValue || !selectedTradeRow.value) return;
  1303. await fetchGraphData(selectedTradeRow.value.raw, false);
  1304. });
  1305. watch(detailModalOpen, (open) => {
  1306. if (open) return;
  1307. detailTraceLoading.value = false;
  1308. activeDetailEdgeKey = '';
  1309. });
  1310. </script>
  1311. <style lang="less">
  1312. @import '../styles/trans-analysis-table-ui.less';
  1313. @prefix-cls: ~'jeesite-capital-flow';
  1314. .@{prefix-cls}.capital-flow-page {
  1315. .analysis-page-frame();
  1316. .filter-item--checkbox {
  1317. margin-left: 4px;
  1318. }
  1319. .filter-unit {
  1320. color: #6b7280;
  1321. font-size: 12px;
  1322. white-space: nowrap;
  1323. }
  1324. .filter-input {
  1325. width: 180px;
  1326. }
  1327. .filter-date {
  1328. width: 360px;
  1329. }
  1330. .filter-mini {
  1331. width: 92px;
  1332. }
  1333. .filter-micro {
  1334. width: 72px;
  1335. }
  1336. .filter-op {
  1337. width: 84px;
  1338. min-width: 84px;
  1339. }
  1340. .filter-op-small {
  1341. width: 72px;
  1342. min-width: 72px;
  1343. }
  1344. .filter-fixed-op {
  1345. display: inline-flex;
  1346. align-items: center;
  1347. justify-content: center;
  1348. width: 40px;
  1349. height: 32px;
  1350. border: 1px solid #d9e1ec;
  1351. border-radius: 8px;
  1352. background: #fff;
  1353. color: #4b5563;
  1354. font-size: 12px;
  1355. box-sizing: border-box;
  1356. }
  1357. .filter-actions,
  1358. .filter-actions-small {
  1359. display: flex;
  1360. gap: 6px;
  1361. margin-left: auto;
  1362. }
  1363. .content-panel {
  1364. display: flex;
  1365. gap: 2px;
  1366. }
  1367. .left-panel,
  1368. .right-panel {
  1369. .analysis-card-shell();
  1370. }
  1371. .left-panel {
  1372. flex: 0 0 42%;
  1373. }
  1374. .right-panel {
  1375. flex: 1 1 auto;
  1376. min-width: 0;
  1377. position: relative;
  1378. }
  1379. .right-panel.is-graph-fullscreen,
  1380. .right-panel:fullscreen {
  1381. width: 100%;
  1382. height: 100%;
  1383. max-width: none;
  1384. padding: 16px 18px 18px;
  1385. border: none;
  1386. border-radius: 0;
  1387. background: #fff;
  1388. box-sizing: border-box;
  1389. }
  1390. .panel-header--graph {
  1391. display: flex;
  1392. align-items: flex-start;
  1393. justify-content: space-between;
  1394. gap: 12px;
  1395. }
  1396. .panel-header-actions {
  1397. display: flex;
  1398. align-items: center;
  1399. gap: 8px;
  1400. flex: 0 0 auto;
  1401. }
  1402. .graph-fullscreen-btn {
  1403. display: inline-flex;
  1404. align-items: center;
  1405. gap: 4px;
  1406. }
  1407. .graph-fullscreen-btn :deep(.ant-btn-icon) {
  1408. display: inline-flex;
  1409. align-items: center;
  1410. font-size: 14px;
  1411. }
  1412. .query-state-panel {
  1413. flex: 1 1 auto;
  1414. min-height: 0;
  1415. display: flex;
  1416. align-items: center;
  1417. justify-content: center;
  1418. padding: 12px 0 0;
  1419. }
  1420. .table-wrapper .jeesite-basic-table {
  1421. height: 100%;
  1422. display: flex;
  1423. flex-direction: column;
  1424. min-height: 0;
  1425. }
  1426. .table-wrapper .ant-table-wrapper,
  1427. .table-wrapper .ant-spin-nested-loading,
  1428. .table-wrapper .ant-spin-container,
  1429. .table-wrapper .ant-table,
  1430. .table-wrapper .ant-table-container {
  1431. height: 100%;
  1432. min-height: 0;
  1433. }
  1434. .table-wrapper .ant-spin-container,
  1435. .table-wrapper .ant-table,
  1436. .table-wrapper .ant-table-container {
  1437. display: flex;
  1438. flex-direction: column;
  1439. }
  1440. .table-wrapper .ant-table-body {
  1441. flex: 1 1 auto;
  1442. min-height: 0;
  1443. overflow: auto;
  1444. }
  1445. .table-wrapper .ant-table-body::-webkit-scrollbar {
  1446. width: 8px;
  1447. height: 8px;
  1448. }
  1449. .table-wrapper .ant-table-body::-webkit-scrollbar-thumb {
  1450. background: rgb(148 163 184 / 60%);
  1451. border-radius: 999px;
  1452. }
  1453. .table-wrapper .ant-table-body::-webkit-scrollbar-track {
  1454. background: transparent;
  1455. }
  1456. .table-wrapper .ant-table-tbody > .ant-table-placeholder {
  1457. height: 100%;
  1458. }
  1459. .table-wrapper .ant-table-tbody > .ant-table-placeholder > td {
  1460. height: 100%;
  1461. padding: 0 !important;
  1462. }
  1463. .table-wrapper .ant-table-placeholder .ant-empty {
  1464. min-height: 100%;
  1465. height: 100%;
  1466. margin: 0;
  1467. display: flex;
  1468. align-items: center;
  1469. justify-content: center;
  1470. flex-direction: column;
  1471. }
  1472. .pagination-size .ant-select {
  1473. width: 86px;
  1474. }
  1475. .pagination-input {
  1476. width: 54px;
  1477. }
  1478. .flow-toolbar {
  1479. display: flex;
  1480. align-items: center;
  1481. gap: 8px;
  1482. padding: 0;
  1483. background: transparent;
  1484. border-bottom: 1px solid #eef2f7;
  1485. flex-wrap: wrap;
  1486. flex: 0 0 auto;
  1487. }
  1488. .trace-direction-group {
  1489. padding: 0;
  1490. flex: 0 0 auto;
  1491. }
  1492. .trace-direction-group :deep(.ant-radio-group) {
  1493. display: inline-flex;
  1494. gap: 8px;
  1495. padding: 6px;
  1496. border: 1px solid #dbe3ee;
  1497. border-radius: 999px;
  1498. background: #fff;
  1499. }
  1500. .trace-direction-group :deep(.ant-radio-button-wrapper) {
  1501. min-width: 96px;
  1502. height: 34px;
  1503. line-height: 32px;
  1504. text-align: center;
  1505. border-radius: 999px;
  1506. border: none;
  1507. background: transparent;
  1508. color: #6b7280;
  1509. box-shadow: none;
  1510. }
  1511. .trace-direction-group :deep(.ant-radio-button-wrapper::before) {
  1512. display: none;
  1513. }
  1514. .trace-direction-group :deep(.ant-radio-button-wrapper-checked) {
  1515. color: #1677ff;
  1516. background: #f0f6ff;
  1517. box-shadow: inset 0 0 0 1px #bfd4f5;
  1518. }
  1519. .graph-body {
  1520. flex: 1 1 auto;
  1521. min-height: 0;
  1522. padding: 0;
  1523. display: flex;
  1524. flex-direction: column;
  1525. overflow: hidden;
  1526. .ant-spin-nested-loading,
  1527. .ant-spin-container {
  1528. flex: 1 1 auto;
  1529. min-height: 0;
  1530. height: 100%;
  1531. display: flex;
  1532. flex-direction: column;
  1533. }
  1534. }
  1535. .graph-scroll {
  1536. flex: 1 1 auto;
  1537. min-height: 0;
  1538. overflow: auto;
  1539. border-top: 0;
  1540. border-radius: 0;
  1541. background: transparent;
  1542. }
  1543. .graph-scroll::-webkit-scrollbar {
  1544. width: 8px;
  1545. height: 8px;
  1546. }
  1547. .graph-scroll::-webkit-scrollbar-thumb {
  1548. background: rgb(148 163 184 / 55%);
  1549. border-radius: 999px;
  1550. }
  1551. .graph-board {
  1552. position: relative;
  1553. min-height: 100%;
  1554. }
  1555. .graph-svg {
  1556. position: absolute;
  1557. top: 0;
  1558. left: 0;
  1559. width: 100%;
  1560. height: 100%;
  1561. pointer-events: none;
  1562. }
  1563. .graph-edge {
  1564. fill: none;
  1565. stroke: #9db7d6;
  1566. stroke-width: 2.5;
  1567. stroke-linecap: round;
  1568. stroke-linejoin: round;
  1569. filter: drop-shadow(0 1px 0 rgb(255 255 255 / 75%));
  1570. }
  1571. .graph-edge-label {
  1572. position: absolute;
  1573. display: flex;
  1574. flex-direction: column;
  1575. gap: 2px;
  1576. width: 148px;
  1577. padding: 8px 10px;
  1578. border: 1px solid #dbe5f2;
  1579. border-radius: 10px;
  1580. background: #fff;
  1581. box-shadow: 0 8px 18px rgb(15 23 42 / 8%);
  1582. color: #334155;
  1583. font-size: 11px;
  1584. text-align: left;
  1585. cursor: default;
  1586. transition: all 0.2s ease;
  1587. box-sizing: border-box;
  1588. z-index: 1;
  1589. &:disabled {
  1590. opacity: 1;
  1591. }
  1592. &.is-clickable {
  1593. cursor: pointer;
  1594. &:hover {
  1595. border-color: #93c5fd;
  1596. box-shadow: 0 10px 22px rgb(59 130 246 / 12%);
  1597. }
  1598. }
  1599. }
  1600. .graph-edge-label__range {
  1601. color: #94a3b8;
  1602. white-space: nowrap;
  1603. overflow: hidden;
  1604. text-overflow: ellipsis;
  1605. }
  1606. .graph-edge-label__value {
  1607. color: #334155;
  1608. font-weight: 500;
  1609. white-space: nowrap;
  1610. overflow: hidden;
  1611. text-overflow: ellipsis;
  1612. }
  1613. .graph-node {
  1614. position: absolute;
  1615. width: 228px;
  1616. min-height: 122px;
  1617. padding: 14px 14px 12px;
  1618. border: 1px solid #dbe5f2;
  1619. border-radius: 14px;
  1620. background: #fff;
  1621. box-shadow: 0 12px 24px rgb(15 23 42 / 6%);
  1622. display: flex;
  1623. gap: 12px;
  1624. box-sizing: border-box;
  1625. transition:
  1626. box-shadow 0.2s ease,
  1627. border-color 0.2s ease;
  1628. z-index: 2;
  1629. &:hover {
  1630. border-color: #bfd4f5;
  1631. box-shadow: 0 14px 28px rgb(59 130 246 / 8%);
  1632. }
  1633. }
  1634. .graph-node--root {
  1635. border-color: #bfdbfe;
  1636. background: #fff;
  1637. }
  1638. .graph-node__badge {
  1639. position: absolute;
  1640. top: -10px;
  1641. left: 14px;
  1642. padding: 2px 8px;
  1643. border-radius: 999px;
  1644. background: #eff6ff;
  1645. border: 1px solid #bfdbfe;
  1646. color: #2563eb;
  1647. font-size: 11px;
  1648. line-height: 18px;
  1649. }
  1650. .graph-node__avatar {
  1651. flex: 0 0 44px;
  1652. width: 44px;
  1653. height: 44px;
  1654. border-radius: 50%;
  1655. background: #eff6ff;
  1656. color: #2563eb;
  1657. display: flex;
  1658. align-items: center;
  1659. justify-content: center;
  1660. overflow: hidden;
  1661. font-size: 18px;
  1662. margin-top: 6px;
  1663. img {
  1664. width: 100%;
  1665. height: 100%;
  1666. object-fit: cover;
  1667. }
  1668. }
  1669. .graph-node__content {
  1670. flex: 1 1 auto;
  1671. min-width: 0;
  1672. display: flex;
  1673. flex-direction: column;
  1674. }
  1675. .graph-node__title-row {
  1676. display: flex;
  1677. align-items: flex-start;
  1678. justify-content: space-between;
  1679. gap: 8px;
  1680. margin-top: 4px;
  1681. }
  1682. .graph-node__name {
  1683. flex: 1 1 auto;
  1684. min-width: 0;
  1685. color: #111827;
  1686. font-size: 14px;
  1687. font-weight: 600;
  1688. line-height: 20px;
  1689. white-space: nowrap;
  1690. overflow: hidden;
  1691. text-overflow: ellipsis;
  1692. }
  1693. .graph-node__meta {
  1694. margin-top: 4px;
  1695. color: #6b7280;
  1696. font-size: 12px;
  1697. line-height: 18px;
  1698. white-space: nowrap;
  1699. overflow: hidden;
  1700. text-overflow: ellipsis;
  1701. }
  1702. .graph-node__stats {
  1703. display: flex;
  1704. flex-wrap: wrap;
  1705. gap: 6px;
  1706. margin-top: 8px;
  1707. }
  1708. .graph-node__stat {
  1709. display: inline-flex;
  1710. align-items: center;
  1711. height: 22px;
  1712. padding: 0 8px;
  1713. border-radius: 999px;
  1714. background: #f3f7ff;
  1715. color: #3b82f6;
  1716. font-size: 11px;
  1717. white-space: nowrap;
  1718. }
  1719. .graph-node__footer {
  1720. margin-top: auto;
  1721. display: flex;
  1722. align-items: center;
  1723. justify-content: space-between;
  1724. gap: 8px;
  1725. padding-top: 10px;
  1726. }
  1727. .graph-node__time {
  1728. color: #94a3b8;
  1729. font-size: 11px;
  1730. line-height: 16px;
  1731. white-space: nowrap;
  1732. overflow: hidden;
  1733. text-overflow: ellipsis;
  1734. }
  1735. .graph-node__detail {
  1736. border: none;
  1737. background: transparent;
  1738. color: #1677ff;
  1739. font-size: 12px;
  1740. line-height: 18px;
  1741. cursor: pointer;
  1742. padding: 0;
  1743. flex-shrink: 0;
  1744. }
  1745. .graph-empty {
  1746. flex: 1 1 auto;
  1747. min-height: 0;
  1748. border-top: 0;
  1749. border-radius: 0;
  1750. background: transparent;
  1751. }
  1752. .amount-text {
  1753. font-weight: 600;
  1754. &.is-income {
  1755. color: #e11d48;
  1756. }
  1757. &.is-expense {
  1758. color: #334155;
  1759. }
  1760. &.is-other {
  1761. color: #059669;
  1762. }
  1763. }
  1764. .flag-chip {
  1765. display: inline-flex;
  1766. align-items: center;
  1767. justify-content: center;
  1768. height: 22px;
  1769. padding: 0 8px;
  1770. border-radius: 999px;
  1771. font-size: 11px;
  1772. line-height: 20px;
  1773. white-space: nowrap;
  1774. border: 1px solid transparent;
  1775. box-sizing: border-box;
  1776. &.is-income {
  1777. background: #fff1f2;
  1778. border-color: #fecdd3;
  1779. color: #e11d48;
  1780. }
  1781. &.is-expense {
  1782. background: #f3f4f6;
  1783. border-color: #d1d5db;
  1784. color: #4b5563;
  1785. }
  1786. &.is-other {
  1787. background: #ecfdf5;
  1788. border-color: #bbf7d0;
  1789. color: #047857;
  1790. }
  1791. }
  1792. :deep(.ant-select-selector),
  1793. :deep(.ant-input),
  1794. :deep(.ant-picker),
  1795. :deep(.ant-btn) {
  1796. border-radius: 8px;
  1797. }
  1798. @media screen and (max-width: 900px) {
  1799. .content-panel {
  1800. flex-direction: column;
  1801. }
  1802. .left-panel {
  1803. flex-basis: auto;
  1804. }
  1805. }
  1806. @media screen and (max-width: 992px) {
  1807. .filter-actions,
  1808. .filter-actions-small {
  1809. width: 100%;
  1810. margin-left: 0;
  1811. }
  1812. .filter-input,
  1813. .filter-date {
  1814. width: 100%;
  1815. }
  1816. .pagination-bar {
  1817. justify-content: flex-start;
  1818. }
  1819. }
  1820. }
  1821. </style>