| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075 |
- <template>
- <div :class="prefixCls" class="capital-flow-page">
- <div class="page-header">
- <div class="page-title">资金流向分析</div>
- <div class="page-subtitle">
- 左侧按对象筛选交易记录,右侧按旧系统“左表右图”的思路展示向前/向后追溯资金流向,并统一为当前项目蓝灰分析页风格。
- </div>
- </div>
- <div class="filter-bar">
- <div class="filter-item">
- <span class="filter-label">选择对象:</span>
- <Select
- v-model:value="filters.target"
- class="filter-input"
- :options="targetOptions"
- placeholder="请选择对象"
- allow-clear
- />
- </div>
- <div class="filter-item">
- <span class="filter-label">交易金额:</span>
- <Select v-model:value="filters.amountOp" class="filter-op" :options="amountOpOptions" />
- <Input
- v-model:value="filters.amountValue"
- class="filter-mini"
- :status="amountValueStatus"
- placeholder="请输入"
- />
- </div>
- <div class="filter-item">
- <span class="filter-label">交易日期:</span>
- <RangePicker
- v-model:value="filters.dateRange"
- class="filter-date"
- :format="DATE_FORMAT"
- :value-format="DATE_FORMAT"
- :placeholder="['开始日期', '结束日期']"
- />
- </div>
- <div class="filter-item">
- <span class="filter-label">对方卡号:</span>
- <Input v-model:value="filters.otherCard" class="filter-input" placeholder="请输入对方卡号" />
- </div>
- <div class="filter-actions">
- <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
- <Button @click="handleReset">重置</Button>
- </div>
- </div>
- <div class="content-panel">
- <div class="left-panel">
- <div class="panel-header">
- <div>
- <div class="panel-title">交易记录</div>
- <div class="panel-desc">{{ leftPanelDescription }}</div>
- </div>
- </div>
- <div v-if="!hasSearched" class="query-state-panel">
- <EmptyState
- variant="query"
- title="请选择对象后查询"
- description="该页面必须先选择对象后,再点击查询展示交易记录"
- min-height="100%"
- description-width="min(100%, 420px)"
- />
- </div>
- <div v-else class="table-panel">
- <div class="table-wrapper">
- <BasicTable
- :columns="columns"
- :data-source="tableData"
- :loading="queryLoading"
- :pagination="false"
- :show-table-setting="false"
- :table-setting="{ setting: true, size: true, redo: false, fullScreen: false }"
- :can-resize="true"
- :is-can-resize-parent="true"
- :resize-height-offset="32"
- :rowClassName="summaryRowClassName"
- row-key="rowKey"
- @row-click="handleSummaryRowClick"
- @change="handleTableChange"
- />
- </div>
- </div>
- <div v-if="hasSearched" class="pagination-bar">
- <AnalysisPagination
- :total="pagination.total"
- :current="pagination.current"
- :page-size="pagination.pageSize"
- :page-size-options="pageSizeOptions"
- :loading="queryLoading"
- @change="handlePaginationChange"
- />
- </div>
- </div>
- <div ref="graphPanelRef" class="right-panel" :class="{ 'is-graph-fullscreen': isGraphFullscreen }">
- <div class="panel-header panel-header--graph">
- <div>
- <div class="panel-title">资金流向视图</div>
- <div class="panel-desc">{{ rightPanelDescription }}</div>
- </div>
- <div class="panel-header-actions">
- <Tooltip :title="isGraphFullscreen ? '退出全屏' : '全屏查看'" placement="left">
- <Button size="small" class="graph-fullscreen-btn" @click="handleToggleGraphFullscreen">
- <template #icon>
- <FullscreenExitOutlined v-if="isGraphFullscreen" />
- <FullscreenOutlined v-else />
- </template>
- {{ isGraphFullscreen ? '退出全屏' : '全屏查看' }}
- </Button>
- </Tooltip>
- </div>
- </div>
- <div class="flow-toolbar">
- <div class="filter-item">
- <span class="filter-label">层级:</span>
- <span class="filter-fixed-op"><=</span>
- <Input v-model:value="graphFilters.levelValue" class="filter-micro" :status="levelValueStatus" />
- </div>
- <div class="filter-item">
- <span class="filter-label">时间间隔:</span>
- <Select v-model:value="graphFilters.intervalOp" class="filter-op-small" :options="binaryCompareOptions" />
- <Input v-model:value="graphFilters.intervalValue" class="filter-micro" :status="intervalValueStatus" />
- <span class="filter-unit">天</span>
- </div>
- <div class="filter-item">
- <span class="filter-label">金额:</span>
- <Select v-model:value="graphFilters.amountOp" class="filter-op-small" :options="binaryCompareOptions" />
- <Input v-model:value="graphFilters.amountValue" class="filter-mini" :status="graphAmountValueStatus" />
- </div>
- <div class="filter-item filter-item--checkbox">
- <Checkbox v-model:checked="graphFilters.cashOnly">只看现金</Checkbox>
- </div>
- <div class="filter-actions-small">
- <Button type="primary" size="small" :loading="graphLoading" @click="handleGraphSearch">查询</Button>
- <Button size="small" @click="handleGraphReset">重置</Button>
- </div>
- </div>
- <div class="trace-direction-group">
- <RadioGroup
- v-model:value="traceDirection"
- button-style="solid"
- option-type="button"
- :options="traceDirectionOptions"
- />
- </div>
- <div class="graph-body">
- <Spin :spinning="graphLoading" tip="资金流向加载中...">
- <div v-if="graphRoot" class="graph-scroll">
- <MoneyFlowGraph
- :data="graphRoot"
- :direction="traceDirection"
- @node-click="handleGraphNodeClick"
- @edge-click="handleGraphEdgeClick"
- />
- </div>
- <div v-else class="graph-empty">
- <EmptyState
- variant="compact"
- title="暂无资金流向视图"
- :description="graphEmptyDescription"
- :min-height="320"
- />
- </div>
- </Spin>
- </div>
- </div>
- </div>
- <TransRecordModal
- v-model:open="detailModalOpen"
- title="交易记录"
- :base-query="detailBaseQuery"
- :show-detail-action="true"
- :traceable="Boolean(detailEdgePayload)"
- :trace-direction="detailTraceDirection"
- :trace-loading="detailTraceLoading"
- @update:traceDirection="handleDetailTraceDirectionChange"
- @trace="handleDetailTrace"
- />
- </div>
- </template>
- <script lang="ts" setup name="ViewsCapitalFlowIndex">
- import { FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons-vue';
- import { useFullscreen } from '@vueuse/core';
- import { computed, h, markRaw, onMounted, ref, shallowRef, watch } from 'vue';
- import { Button, Checkbox, DatePicker, Input, RadioGroup, Select, Spin, Tooltip } from 'ant-design-vue';
- import AnalysisPagination from '@jeesite/core/components/AnalysisPagination/src/AnalysisPagination.vue';
- import MoneyFlowGraph from '@jeesite/graph/MoneyFlowGraph.vue';
- import { BasicTable, type BasicColumn } from '@jeesite/core/components/Table';
- import { EmptyState } from '@jeesite/core/components/EmptyState';
- import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
- import { useDesign } from '@jeesite/core/hooks/web/useDesign';
- import { useMessage } from '@jeesite/core/hooks/web/useMessage';
- import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
- import componentSetting from '@jeesite/core/settings/componentSetting';
- import {
- COMPARE_OPERATOR_OPTIONS,
- DEFAULT_COMPARE_OPERATOR,
- DEFAULT_LE_COMPARE_OPERATOR,
- normalizeCompareOperator,
- type CompareOperator,
- } from '@jeesite/core/utils/compareOperator';
- import { getPersonOptions } from '@jeesite/person/api/personApi';
- import { getTransFundFlowPage, moneyFlowGraph, moneyFlowTrace } from '@jeesite/trans/api/transApi';
- import type { MoneyFlowGraphDTO, TransFundFlowPageItemDTO, TransRecord } from '@jeesite/trans/api/types/dto';
- import type { MoneyFlowGraphQuery, TransFundFlowQuery, TransRecordQuery } from '@jeesite/trans/api/types/query';
- import TransRecordModal from '../components/TransRecordModal.vue';
- type RangeValue = [string, string] | undefined;
- type TraceDirection = 'forward' | 'backward';
- type BinaryCompareOperator = 'GE' | 'LE';
- type RecordFlagClass = 'income' | 'expense' | 'other';
- interface FundFlowTableRow {
- rowKey: string;
- id: number;
- personCardNosText: string;
- otherCardNosText: string;
- otherName: string;
- transAmount: string;
- transAmountText: string;
- transDateTime: string;
- debitCreditFlagText: string;
- debitCreditFlagClass: RecordFlagClass;
- raw: TransFundFlowPageItemDTO;
- }
- interface CapitalFlowNode {
- key: string;
- name: string;
- photo?: string;
- personCardNos: string[];
- otherCardNos: string[];
- transCount: number;
- totalTransAmount: string;
- lineData: string;
- ids: Array<string>;
- maxTime: string;
- minDate: string;
- maxDate: string;
- otherName: string;
- dataLevel: number;
- debitCreditFlag: string;
- state?: 'left' | 'right';
- children: CapitalFlowNode[];
- depth: number;
- }
- type GraphSelectionNode = {
- name?: string;
- otherName?: string;
- };
- interface GraphEdgeSelectionPayload {
- ids?: Array<string>;
- edgeId?: string;
- sourceId?: string;
- targetId?: string;
- raw?: Record<string, any> | null;
- sourceNode?: Record<string, any> | null;
- targetNode?: Record<string, any> | null;
- }
- interface TransRecordTracePayload {
- direction: TraceDirection;
- records: TransRecord[];
- }
- const { prefixCls } = useDesign('capital-flow');
- const { createMessage } = useMessage();
- const { queryLoading, runWithQueryLoading } = useTableSkill();
- const RangePicker = DatePicker.RangePicker;
- const DATE_FORMAT = 'YYYY-MM-DD';
- const CARD_WIDTH = 228;
- const CARD_HEIGHT = 122;
- const H_GAP = 148;
- const V_GAP = 44;
- const PADDING_X = 28;
- const PADDING_Y = 24;
- const MIN_BOARD_WIDTH = 460;
- const MIN_BOARD_HEIGHT = 300;
- const positiveIntegerPattern = /^[1-9]\d*$/;
- const positiveAmountPattern = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/;
- const amountOpOptions: Array<{ label: string; value: CompareOperator }> = [
- { label: '>', value: 'GT' },
- { label: '<', value: 'LT' },
- { label: '=', value: 'EQ' },
- ];
- const binaryCompareOptions: Array<{ label: string; value: BinaryCompareOperator }> = [
- { label: '>=', value: 'GE' },
- { label: '<=', value: 'LE' },
- ];
- const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
- label: value,
- value: Number(value),
- }));
- const filters = ref({
- target: '',
- amountOp: 'GT' as CompareOperator,
- amountValue: '',
- dateRange: undefined as RangeValue,
- otherCard: '',
- orderKey: '',
- sort: '' as '' | 'ASC' | 'DESC',
- });
- const graphFilters = ref({
- levelValue: '3',
- intervalOp: DEFAULT_LE_COMPARE_OPERATOR as BinaryCompareOperator,
- intervalValue: '7',
- amountOp: DEFAULT_COMPARE_OPERATOR as BinaryCompareOperator,
- amountValue: '',
- cashOnly: false,
- });
- const traceDirection = ref<TraceDirection>('backward');
- const traceDirectionOptions = [
- { label: '向前追溯', value: 'forward' },
- { label: '向后追溯', value: 'backward' },
- ];
- const targetOptions = ref<Array<{ label: string; value: string }>>([]);
- const tableData = ref<FundFlowTableRow[]>([]);
- const hasSearched = ref(false);
- const pagination = ref({
- current: 1,
- pageSize: componentSetting.table.defaultPageSize,
- total: 0,
- });
- const jumpPage = ref('1');
- const selectedRowKey = ref('');
- const graphLoading = ref(false);
- const graphQueried = ref(false);
- const graphRoot = shallowRef<CapitalFlowNode | null>(null);
- const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
- refresh: () => fetchTable(true),
- hasData: () => hasSearched.value && tableData.value.length > 0,
- });
- let graphRequestSequence = 0;
- const detailModalOpen = ref(false);
- const detailBaseQuery = ref<Partial<TransRecordQuery>>({});
- const detailEdgePayload = shallowRef<GraphEdgeSelectionPayload | null>(null);
- const detailTraceDirection = ref<TraceDirection>('backward');
- const detailTraceLoading = ref(false);
- const graphPanelRef = ref<HTMLElement | null>(null);
- const {
- isFullscreen: isGraphFullscreen,
- isSupported: isGraphFullscreenSupported,
- toggle: toggleGraphFullscreen,
- } = useFullscreen(graphPanelRef);
- const DETAIL_EDGE_CLICK_INTERVAL = 350;
- const SAME_DETAIL_EDGE_CLICK_INTERVAL = 800;
- let activeDetailEdgeKey = '';
- let lastDetailEdgeOpenTime = 0;
- const handleToggleGraphFullscreen = async () => {
- if (!isGraphFullscreenSupported.value) {
- createMessage.warning('当前环境不支持全屏查看');
- return;
- }
- try {
- await toggleGraphFullscreen();
- } catch (error) {
- console.error('[capitalFlow] toggle graph fullscreen failed:', error);
- createMessage.error('切换图谱全屏失败,请稍后重试');
- }
- };
- const toRawText = (value: unknown) => {
- if (value === 0) return '0';
- if (value === null || value === undefined) return '';
- return String(value).trim();
- };
- const withFallback = (value: unknown, fallback = '-') => {
- const text = toRawText(value);
- return text || fallback;
- };
- const toNumber = (value: unknown) => {
- if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
- const text = toRawText(value).replace(/,/g, '');
- if (!text) return 0;
- const parsed = Number(text);
- return Number.isFinite(parsed) ? parsed : 0;
- };
- const formatAmount = (value: unknown) => {
- if (typeof value === 'number' && Number.isFinite(value)) {
- return value.toLocaleString('zh-CN', {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- });
- }
- return toRawText(value);
- };
- const normalizeStringArray = (value: unknown): string[] => {
- if (Array.isArray(value)) {
- return value.map((item) => toRawText(item)).filter(Boolean);
- }
- const text = toRawText(value);
- if (!text) return [];
- if (text.includes(',')) {
- return text
- .split(',')
- .map((item) => item.trim())
- .filter(Boolean);
- }
- return [text];
- };
- const formatCardText = (cards: string[]) => {
- return cards.length ? cards.join(' / ') : '-';
- };
- const parseIds = (value: unknown): Array<string> => {
- if (Array.isArray(value)) {
- return value.map((item) => toRawText(item)).filter(Boolean);
- }
- const text = toRawText(value);
- if (!text) return [];
- return text
- .split(',')
- .map((item) => item.trim())
- .filter(Boolean);
- };
- const buildBranchSignature = (node?: Record<string, any> | null) => {
- if (!node) return '';
- return [
- toRawText(node.otherName || node.name),
- JSON.stringify(normalizeStringArray(node.personCardNos ?? node.selfCardNo ?? node.personCardNo)),
- String(node.dataLevel ?? node.level ?? ''),
- ].join('|');
- };
- const buildTraceChildSignature = (node?: Record<string, any> | null) => {
- if (!node) return '';
- return [buildBranchSignature(node), toRawText(node.lineData)].join('|');
- };
- const findTraceBranchNode = (source: any, targetSignature: string): any => {
- if (!source || !targetSignature) return null;
- if (Array.isArray(source)) {
- for (const item of source) {
- const matched = findTraceBranchNode(item, targetSignature);
- if (matched) return matched;
- }
- return null;
- }
- if (typeof source !== 'object') return null;
- if (buildBranchSignature(source) === targetSignature) {
- return source;
- }
- const nestedSources = [source.children, source.list, source.records, source.data];
- for (const nestedSource of nestedSources) {
- const matched = findTraceBranchNode(nestedSource, targetSignature);
- if (matched) return matched;
- }
- return null;
- };
- const resolveLegacyTraceBranchSource = (source: any): any => {
- if (!source) return null;
- if (Array.isArray(source)) {
- return source.length ? source[0] : null;
- }
- if (typeof source !== 'object') return null;
- if (Array.isArray(source.children) && source.children.length) {
- return source.children[0];
- }
- if (Array.isArray(source.list) && source.list.length) {
- return source.list[0];
- }
- if (Array.isArray(source.records) && source.records.length) {
- return source.records[0];
- }
- if (source.data) {
- return resolveLegacyTraceBranchSource(source.data);
- }
- return source;
- };
- const cloneCapitalFlowNode = (node: CapitalFlowNode): CapitalFlowNode => ({
- ...node,
- personCardNos: [...node.personCardNos],
- otherCardNos: [...node.otherCardNos],
- children: node.children.map((child) => cloneCapitalFlowNode(child)),
- });
- const applyTraceDirectionState = (node: CapitalFlowNode, directionValue: TraceDirection): CapitalFlowNode => ({
- ...node,
- state: directionValue === 'forward' ? 'left' : 'right',
- children: node.children.map((child) => applyTraceDirectionState(child, directionValue)),
- });
- const mergeTraceChildren = (sourceChildren: CapitalFlowNode[], incomingChildren: CapitalFlowNode[]) => {
- const nextChildren = sourceChildren.map((child) => cloneCapitalFlowNode(child));
- incomingChildren.forEach((incomingChild) => {
- const matchedIndex = nextChildren.findIndex(
- (child) => buildTraceChildSignature(child) === buildTraceChildSignature(incomingChild),
- );
- if (matchedIndex >= 0) {
- nextChildren[matchedIndex] = {
- ...nextChildren[matchedIndex],
- state: incomingChild.state || nextChildren[matchedIndex].state,
- children: mergeTraceChildren(nextChildren[matchedIndex].children, incomingChild.children),
- };
- return;
- }
- nextChildren.push(cloneCapitalFlowNode(incomingChild));
- });
- return nextChildren;
- };
- const mergeTraceBranchByKey = (
- node: CapitalFlowNode,
- targetKey: string,
- incomingBranch: CapitalFlowNode,
- ): [CapitalFlowNode, boolean] => {
- if (node.key === targetKey) {
- return [
- {
- ...cloneCapitalFlowNode(node),
- state: incomingBranch.state || node.state,
- children: mergeTraceChildren(node.children, incomingBranch.children),
- },
- true,
- ];
- }
- let merged = false;
- const nextChildren = node.children.map((child) => {
- const [nextChild, childMerged] = mergeTraceBranchByKey(child, targetKey, incomingBranch);
- if (childMerged) merged = true;
- return nextChild;
- });
- return [{ ...cloneCapitalFlowNode(node), children: nextChildren }, merged];
- };
- const mergeTraceBranchBySignature = (
- node: CapitalFlowNode,
- incomingBranch: CapitalFlowNode,
- ): [CapitalFlowNode, boolean] => {
- if (buildBranchSignature(node) === buildBranchSignature(incomingBranch)) {
- return [
- {
- ...cloneCapitalFlowNode(node),
- state: incomingBranch.state || node.state,
- children: mergeTraceChildren(node.children, incomingBranch.children),
- },
- true,
- ];
- }
- let merged = false;
- const nextChildren = node.children.map((child) => {
- const [nextChild, childMerged] = mergeTraceBranchBySignature(child, incomingBranch);
- if (childMerged) merged = true;
- return nextChild;
- });
- return [{ ...cloneCapitalFlowNode(node), children: nextChildren }, merged];
- };
- const resolveDebitCreditInfo = (flag?: string): { text: string; className: RecordFlagClass } => {
- const normalized = toRawText(flag);
- if (!normalized) {
- return { text: '-', className: 'other' };
- }
- if (/[收入贷]/.test(normalized)) {
- return { text: normalized, className: 'income' };
- }
- if (/[支出借]/.test(normalized)) {
- return { text: normalized, className: 'expense' };
- }
- return { text: normalized, className: 'other' };
- };
- const isOtherFlag = (flag?: string) => {
- const normalized = toRawText(flag);
- if (!normalized) return false;
- return normalized.includes('其他') || normalized.toLowerCase() === 'other';
- };
- const getRowFlag = (row?: Partial<TransFundFlowPageItemDTO> | null) => {
- return toRawText(row?.debitCreditFlag || row?.loanFlag);
- };
- const resolvePositiveIntegerInput = (
- value: string,
- label: string,
- showMessage = false,
- min?: number,
- max?: number,
- ): number | undefined | null => {
- const normalized = toRawText(value);
- if (!normalized) return undefined;
- if (!positiveIntegerPattern.test(normalized)) {
- if (showMessage) createMessage.warning(`${label}请输入大于 0 的整数`);
- return null;
- }
- const parsed = Number(normalized);
- if ((min !== undefined && parsed < min) || (max !== undefined && parsed > max)) {
- if (showMessage) {
- const rangeText = min !== undefined && max !== undefined ? `${min}-${max}` : `不小于 ${min}`;
- createMessage.warning(`${label}请输入 ${rangeText} 的整数`);
- }
- return null;
- }
- return parsed;
- };
- const resolvePositiveAmountInput = (value: string, label: string, showMessage = false): string | undefined | null => {
- const normalized = toRawText(value);
- const amountForCheck = normalized.replace(/,/g, '');
- if (!amountForCheck) return undefined;
- if (!positiveAmountPattern.test(amountForCheck) || /^0(?:\.0{1,2})?$/.test(amountForCheck)) {
- if (showMessage) createMessage.warning(`${label}请输入大于 0 的数字`);
- return null;
- }
- return normalized;
- };
- const unwrapResponseData = (resp: any) => {
- if (resp?.data !== undefined) return resp.data;
- return resp;
- };
- const normalizeTableList = (resp: any): TransFundFlowPageItemDTO[] => {
- if (Array.isArray(resp)) return resp;
- if (Array.isArray(resp?.records)) return resp.records;
- if (Array.isArray(resp?.list)) return resp.list;
- if (Array.isArray(resp?.data)) return resp.data;
- if (Array.isArray(resp?.data?.records)) return resp.data.records;
- if (Array.isArray(resp?.data?.list)) return resp.data.list;
- return [];
- };
- const normalizeTotal = (resp: any, listLength: number) => {
- const total = resp?.total ?? resp?.data?.total ?? resp?.count ?? resp?.data?.count ?? listLength;
- return Number.isFinite(Number(total)) ? Number(total) : listLength;
- };
- const normalizeCurrent = (resp: any, fallback: number) => {
- const current = resp?.current ?? resp?.data?.current ?? fallback;
- return Number.isFinite(Number(current)) && Number(current) > 0 ? Number(current) : fallback;
- };
- const normalizeSize = (resp: any, fallback: number) => {
- const size = resp?.size ?? resp?.data?.size ?? fallback;
- return Number.isFinite(Number(size)) && Number(size) > 0 ? Number(size) : fallback;
- };
- const mapTableRow = (item: TransFundFlowPageItemDTO, index: number): FundFlowTableRow => {
- const flagInfo = resolveDebitCreditInfo(getRowFlag(item));
- return {
- rowKey: String(item.id ?? `${pagination.value.current}-${index}`),
- id: (pagination.value.current - 1) * pagination.value.pageSize + index + 1,
- personCardNosText: formatCardText(
- normalizeStringArray(item.personCardNo ?? item.personCardNos ?? item.selfCardNo),
- ),
- otherCardNosText: formatCardText(
- normalizeStringArray(item.otherCardNo ?? item.otherCardNos ?? item.dealOtherCard),
- ),
- otherName: withFallback(item.otherName),
- transAmount: toRawText(item.transAmount),
- transAmountText: formatAmount(item.transAmount),
- transDateTime: withFallback(item.transDateTime),
- debitCreditFlagText: flagInfo.text,
- debitCreditFlagClass: flagInfo.className,
- raw: item,
- };
- };
- const buildNodeTimeText = (node: CapitalFlowNode) => {
- if (node.minDate && node.maxDate && node.minDate !== node.maxDate) {
- return `${node.minDate} 至 ${node.maxDate}`;
- }
- return withFallback(node.maxDate || node.maxTime);
- };
- const buildEdgeTitle = (node: CapitalFlowNode) => {
- if (node.minDate && node.maxDate && node.minDate !== node.maxDate) {
- return `${node.minDate} 至 ${node.maxDate}`;
- }
- return withFallback(node.maxDate || node.maxTime, '交易关系');
- };
- const buildEdgeValue = (node: CapitalFlowNode) => {
- const lineData = toRawText(node.lineData);
- if (lineData) return lineData;
- return `共${node.transCount || 0}笔 / ${formatAmount(node.totalTransAmount)}`;
- };
- const normalizeCapitalNode = (
- raw: Partial<MoneyFlowGraphDTO & TransFundFlowPageItemDTO> & { children?: any[] },
- key: string,
- depth = 0,
- ): CapitalFlowNode => {
- const children = Array.isArray(raw.children)
- ? raw.children.map((item, index) => normalizeCapitalNode(item || {}, `${key}-${index}`, depth + 1))
- : [];
- return {
- key,
- name: withFallback(raw.name || raw.otherName, `节点${key}`),
- photo: toRawText(raw.photo) || undefined,
- personCardNos: normalizeStringArray(raw.personCardNos ?? raw.selfCardNo),
- otherCardNos: normalizeStringArray(raw.otherCardNo ?? raw.otherCardNos ?? raw.dealOtherCard),
- transCount: toNumber(raw.transCount ?? raw.dealTotalCount),
- totalTransAmount: toRawText(raw.totalTransAmount ?? raw.dealTotalMoney ?? raw.transAmount),
- lineData: toRawText(raw.lineData),
- ids: parseIds(raw.ids),
- maxTime: toRawText(raw.maxTime),
- minDate: toRawText(raw.minDate),
- maxDate: toRawText(raw.maxDate),
- otherName: toRawText(raw.otherName),
- dataLevel: raw.dataLevel ?? raw.level ?? depth,
- debitCreditFlag: getRowFlag(raw),
- children,
- depth,
- };
- };
- const buildGraphRoot = (resp: any, row?: TransFundFlowPageItemDTO | null): CapitalFlowNode | null => {
- const payload = unwrapResponseData(resp);
- if (!payload) return null;
- const graphSource = Array.isArray(payload?.list) ? payload.list : payload;
- if (Array.isArray(graphSource)) {
- if (!graphSource.length) return null;
- return normalizeCapitalNode(
- {
- name: row?.otherName || filters.value.target || '当前节点',
- otherName: row?.otherName,
- personCardNos: normalizeStringArray(row?.personCardNo ?? row?.personCardNos ?? row?.selfCardNo),
- otherCardNo: row?.otherCardNo ?? row?.otherCardNos ?? row?.dealOtherCard,
- transAmount: row?.transAmount,
- transDateTime: row?.transDateTime,
- debitCreditFlag: getRowFlag(row),
- children: graphSource,
- },
- 'root',
- );
- }
- return normalizeCapitalNode(graphSource || {}, 'root');
- };
- const cloneGraphSelectionNode = (node?: Record<string, any> | null) => {
- if (!node) return null;
- return {
- key: node.key || node.id,
- name: node.name,
- otherName: node.otherName,
- personCardNos: normalizeStringArray(node.personCardNos ?? node.selfCardNo),
- otherCardNos: normalizeStringArray(node.otherCardNos ?? node.otherCardNo ?? node.dealOtherCard),
- transCount: node.transCount ?? node.dealTotalCount,
- totalTransAmount: node.totalTransAmount ?? node.dealTotalMoney ?? node.transAmount,
- lineData: node.lineData,
- ids: parseIds(node.ids),
- minDate: node.minDate,
- maxDate: node.maxDate,
- maxTime: node.maxTime,
- debitCreditFlag: node.debitCreditFlag ?? node.loanFlag,
- dataLevel: node.dataLevel ?? node.level ?? 0,
- };
- };
- const normalizeEdgeSelectionPayload = (edgePayload: GraphEdgeSelectionPayload): GraphEdgeSelectionPayload => ({
- ids: parseIds(edgePayload.ids),
- edgeId: edgePayload.edgeId,
- sourceId: edgePayload.sourceId,
- targetId: edgePayload.targetId,
- raw: cloneGraphSelectionNode(edgePayload.raw),
- sourceNode: cloneGraphSelectionNode(edgePayload.sourceNode),
- targetNode: cloneGraphSelectionNode(edgePayload.targetNode),
- });
- const buildEdgeDetailKey = (edgePayload: GraphEdgeSelectionPayload) => {
- return [
- edgePayload.edgeId || '',
- edgePayload.sourceId || '',
- edgePayload.targetId || '',
- [...(edgePayload.ids || [])].sort().join(','),
- ].join('|');
- };
- const buildTracePageVO = (edgePayload: GraphEdgeSelectionPayload, directionValue: TraceDirection) => {
- const targetNode = edgePayload.targetNode || edgePayload.raw;
- const sourceName = toRawText(edgePayload.sourceNode?.otherName || edgePayload.sourceNode?.name);
- const targetName = toRawText(targetNode?.otherName || targetNode?.name);
- const personCardNos = normalizeStringArray(targetNode?.personCardNos ?? targetNode?.selfCardNo);
- const otherCardNos = normalizeStringArray(
- targetNode?.otherCardNos ?? targetNode?.otherCardNo ?? targetNode?.dealOtherCard,
- );
- const dealTime = toRawText(targetNode?.maxTime || targetNode?.maxDate);
- const loanFlag = toRawText(targetNode?.debitCreditFlag ?? targetNode?.loanFlag);
- const totalTransAmount = toRawText(targetNode?.totalTransAmount ?? targetNode?.transAmount) || undefined;
- return {
- ...targetNode,
- name: targetName,
- otherName: toRawText(targetNode?.otherName) || targetName,
- personCardNos,
- selfCardNo: personCardNos,
- otherCardNo: otherCardNos,
- otherCardNos,
- dealOtherCard: otherCardNos,
- transAmount: totalTransAmount,
- dealMoney: totalTransAmount,
- transDateTime: dealTime || undefined,
- dealTime: dealTime || undefined,
- debitCreditFlag: loanFlag || undefined,
- loanFlag: loanFlag || undefined,
- parentName: directionValue === 'forward' ? targetName : sourceName,
- childName: directionValue === 'forward' ? sourceName : targetName,
- dataLevel: Number(targetNode?.dataLevel) || 0,
- };
- };
- const buildTraceParamItem = (
- record: TransRecord,
- edgePayload: GraphEdgeSelectionPayload,
- directionValue: TraceDirection,
- ) => {
- const targetNode = edgePayload.targetNode || edgePayload.raw;
- const recordPayload = record as TransRecord & Partial<TransFundFlowPageItemDTO> & { loanFlag?: string };
- const sourceName = toRawText(edgePayload.sourceNode?.otherName || edgePayload.sourceNode?.name);
- const targetName = toRawText(targetNode?.otherName || targetNode?.name);
- const personCardNos = normalizeStringArray(
- recordPayload.personCardNo ?? recordPayload.personCardNos ?? recordPayload.selfCardNo,
- );
- const otherCardNos = normalizeStringArray(
- recordPayload.otherCardNo ?? recordPayload.otherCardNos ?? recordPayload.dealOtherCard,
- );
- const transDateTime = toRawText(
- record.transDateTime || [record.transDate, record.transTime].filter(Boolean).join(' '),
- );
- const transAmount = toRawText(record.transAmount) || undefined;
- const loanFlag = toRawText(recordPayload.debitCreditFlag ?? recordPayload.loanFlag) || undefined;
- const otherName = toRawText(record.otherName) || undefined;
- return {
- id: toRawText(record.id) || undefined,
- personCardNos,
- selfCardNo: personCardNos,
- otherCardNo: otherCardNos,
- otherCardNos,
- dealOtherCard: otherCardNos,
- otherName,
- dealOtherName: otherName,
- transAmount,
- dealMoney: transAmount,
- transDateTime,
- dealTime: transDateTime,
- debitCreditFlag: loanFlag,
- loanFlag,
- parentName: directionValue === 'forward' ? targetName : sourceName,
- childName: directionValue === 'forward' ? sourceName : targetName,
- };
- };
- const buildTraceGraphQuery = (
- records: TransRecord[],
- edgePayload: GraphEdgeSelectionPayload,
- directionValue: TraceDirection,
- showMessage = false,
- ): MoneyFlowGraphQuery | null => {
- const target = toRawText(filters.value.target);
- if (!target) {
- if (showMessage) createMessage.warning('请先选择对象后再查询');
- return null;
- }
- const level = resolvePositiveIntegerInput(graphFilters.value.levelValue, '层级', showMessage, 2, 4);
- if (level === null) return null;
- const timeGap = resolvePositiveIntegerInput(graphFilters.value.intervalValue, '时间间隔', showMessage, 1);
- if (timeGap === null) return null;
- const money = resolvePositiveAmountInput(graphFilters.value.amountValue, '金额', showMessage);
- if (money === null) return null;
- return {
- personName: target,
- flowPageDTO: buildTracePageVO(edgePayload, directionValue) as any,
- level: level ?? 3,
- timeGap: timeGap ?? 7,
- timeGapSymbol: graphFilters.value.intervalOp === 'GE' ? 1 : 2,
- money: money ?? undefined,
- moneySymbol: money !== undefined ? (graphFilters.value.amountOp === 'GE' ? 1 : 2) : undefined,
- cashOnly: graphFilters.value.cashOnly ? 1 : 0,
- direction: traceDirection.value === 'forward' ? 1 : 2,
- traceDirection: directionValue === 'forward' ? 1 : 2,
- traceParamList: records.map((record) => buildTraceParamItem(record, edgePayload, directionValue) as any),
- };
- };
- const resolveTraceBranchSource = (resp: any, edgePayload: GraphEdgeSelectionPayload) => {
- const payload = unwrapResponseData(resp);
- if (!payload) return null;
- const targetSignature = buildBranchSignature(edgePayload.targetNode || edgePayload.raw || null);
- if (targetSignature) {
- const matched = findTraceBranchNode(payload, targetSignature);
- if (matched) return matched;
- }
- return resolveLegacyTraceBranchSource(payload);
- };
- const normalizeTraceBranch = (
- raw: Partial<MoneyFlowGraphDTO & TransFundFlowPageItemDTO> & { children?: any[] },
- targetKey: string,
- directionValue: TraceDirection,
- ) => {
- const tracePrefix = `${targetKey}::trace-${Date.now()}`;
- const normalized = normalizeCapitalNode(raw, tracePrefix);
- normalized.key = targetKey;
- return applyTraceDirectionState(normalized, directionValue);
- };
- const selectedTradeRow = computed(() => {
- return tableData.value.find((item) => item.rowKey === selectedRowKey.value) || null;
- });
- const totalPages = computed(() => {
- if (!pagination.value.total) return 1;
- return Math.max(1, Math.ceil(pagination.value.total / pagination.value.pageSize));
- });
- const amountValueStatus = computed<'error' | undefined>(() => {
- const normalized = toRawText(filters.value.amountValue).replace(/,/g, '');
- if (!normalized) return undefined;
- return positiveAmountPattern.test(normalized) && Number(normalized) > 0 ? undefined : 'error';
- });
- const levelValueStatus = computed<'error' | undefined>(() => {
- const normalized = toRawText(graphFilters.value.levelValue);
- if (!normalized) return undefined;
- const parsed = Number(normalized);
- return positiveIntegerPattern.test(normalized) && parsed >= 2 && parsed <= 4 ? undefined : 'error';
- });
- const intervalValueStatus = computed<'error' | undefined>(() => {
- const normalized = toRawText(graphFilters.value.intervalValue);
- if (!normalized) return undefined;
- return positiveIntegerPattern.test(normalized) ? undefined : 'error';
- });
- const graphAmountValueStatus = computed<'error' | undefined>(() => {
- const normalized = toRawText(graphFilters.value.amountValue).replace(/,/g, '');
- if (!normalized) return undefined;
- return positiveAmountPattern.test(normalized) && Number(normalized) > 0 ? undefined : 'error';
- });
- const leftPanelDescription = computed(() => {
- if (!tableData.value.length) {
- return '按对象、金额、交易日期和对方卡号筛选交易记录,点击左侧列表可联动右侧资金流向视图。';
- }
- return '汇总所选对象的交易记录,右侧追溯某条记录前后交易关系';
- });
- const rightPanelDescription = computed(() => {
- if (!selectedTradeRow.value) {
- return '请选择左侧交易记录查看对应的资金流向关系。';
- }
- if (isOtherFlag(getRowFlag(selectedTradeRow.value.raw))) {
- return '当前选中交易借贷类型为“其他”,暂不支持判断资金流向。';
- }
- return `${selectedTradeRow.value.otherName} 的资金流向关系图,支持点击关系线或节点查看交易明细。`;
- });
- const graphEmptyDescription = computed(() => {
- if (!filters.value.target) return '该页面必须先选择对象后才能查询资金流向';
- if (!tableData.value.length) return '当前筛选条件下暂无交易记录';
- if (!selectedTradeRow.value) return '请选择左侧交易记录查看资金流向';
- if (isOtherFlag(getRowFlag(selectedTradeRow.value.raw))) {
- return '借贷类型为“其他”,无法判断当前交易的资金流向';
- }
- if (graphQueried.value) return '当前选中交易暂无可展示的资金流向关系';
- return '点击左侧交易记录后,右侧展示对应的资金流向视图';
- });
- const columns: BasicColumn[] = [
- { title: '序号', dataIndex: 'id', key: 'id', width: 70, align: 'center' },
- { title: '本方卡(账)号', dataIndex: 'personCardNosText', key: 'personCardNosText', width: 220, align: 'center' },
- { title: '对方卡(账)号', dataIndex: 'otherCardNosText', key: 'otherCardNosText', width: 220, align: 'center' },
- { title: '对方姓名', dataIndex: 'otherName', key: 'otherName', width: 140, align: 'center' },
- {
- title: '交易金额',
- dataIndex: 'transAmountText',
- key: 'transAmount',
- width: 140,
- align: 'center',
- sorter: true,
- customRender: (opt: any) => {
- const record = opt.record as FundFlowTableRow;
- return h(
- 'span',
- {
- class: ['amount-text', `is-${record.debitCreditFlagClass}`],
- },
- `¥${record.transAmountText}`,
- );
- },
- },
- {
- title: '交易时间',
- dataIndex: 'transDateTime',
- key: 'transDateTime',
- width: 180,
- align: 'center',
- sorter: true,
- },
- {
- title: '借贷标志',
- dataIndex: 'debitCreditFlagText',
- key: 'debitCreditFlag',
- width: 110,
- align: 'center',
- customRender: (opt: any) => {
- const record = opt.record as FundFlowTableRow;
- return h(
- 'span',
- {
- class: ['flag-chip', `is-${record.debitCreditFlagClass}`],
- },
- record.debitCreditFlagText,
- );
- },
- },
- ];
- const summaryRowClassName = (record: any, _index: number) => {
- return selectedRowKey.value === record.rowKey ? 'active-row' : '';
- };
- const buildTableQuery = (showMessage = false): TransFundFlowQuery | null => {
- const target = toRawText(filters.value.target);
- if (!target) {
- if (showMessage) createMessage.warning('请选择对象后再查询');
- return null;
- }
- const transAmount = resolvePositiveAmountInput(filters.value.amountValue, '交易金额', showMessage);
- if (transAmount === null) return null;
- const otherCardNo = toRawText(filters.value.otherCard);
- return {
- page: pagination.value.current,
- limit: pagination.value.pageSize,
- personName: target,
- personNames: [target],
- startDate: filters.value.dateRange?.[0],
- endDate: filters.value.dateRange?.[1],
- otherCardNo: otherCardNo || undefined,
- transAmount: transAmount ?? undefined,
- transAmountSymbol: transAmount !== undefined ? normalizeCompareOperator(filters.value.amountOp) : undefined,
- orderKey: filters.value.orderKey || undefined,
- sort: filters.value.sort || undefined,
- };
- };
- const buildGraphQuery = (row: TransFundFlowPageItemDTO, showMessage = false): MoneyFlowGraphQuery | null => {
- const target = toRawText(filters.value.target);
- if (!target) {
- if (showMessage) createMessage.warning('请先选择对象后再查询');
- return null;
- }
- const level = resolvePositiveIntegerInput(graphFilters.value.levelValue, '层级', showMessage, 2, 4);
- if (level === null) return null;
- const timeGap = resolvePositiveIntegerInput(graphFilters.value.intervalValue, '时间间隔', showMessage, 1);
- if (timeGap === null) return null;
- const money = resolvePositiveAmountInput(graphFilters.value.amountValue, '金额', showMessage);
- if (money === null) return null;
- const personCardNos = normalizeStringArray(row.personCardNo ?? row.personCardNos ?? row.selfCardNo);
- const otherCardNos = normalizeStringArray(row.otherCardNo ?? row.otherCardNos ?? row.dealOtherCard);
- const flowPageDTO = {
- id: toRawText(row.id) || undefined,
- personCardNos,
- selfCardNo: personCardNos,
- otherCardNo: otherCardNos,
- otherCardNos,
- otherName: row.otherName,
- transAmount: toRawText(row.transAmount) || undefined,
- transDateTime: row.transDateTime,
- debitCreditFlag: row.debitCreditFlag,
- parentName: row.parentName,
- childName: row.childName,
- dataLevel: row.dataLevel,
- };
- return {
- personName: target,
- flowPageDTO,
- level: level ?? 3,
- timeGap: timeGap ?? 7,
- timeGapSymbol: graphFilters.value.intervalOp === 'GE' ? 1 : 2,
- money: money ?? undefined,
- moneySymbol: money !== undefined ? (graphFilters.value.amountOp === 'GE' ? 1 : 2) : undefined,
- cashOnly: graphFilters.value.cashOnly ? 1 : 0,
- direction: traceDirection.value === 'forward' ? 1 : 2,
- traceDirection: traceDirection.value === 'forward' ? 1 : 2,
- traceParamList: flowPageDTO ? [flowPageDTO] : undefined,
- };
- };
- const resetGraphState = (resetQueried = false) => {
- graphRoot.value = null;
- if (resetQueried) {
- graphQueried.value = false;
- }
- };
- const fetchGraphData = async (row?: TransFundFlowPageItemDTO | null, showMessage = true) => {
- if (!row) {
- if (showMessage) createMessage.warning('请选择一条交易记录');
- resetGraphState(true);
- return;
- }
- if (isOtherFlag(getRowFlag(row))) {
- resetGraphState(true);
- if (showMessage) createMessage.warning('借贷类型为“其他”,无法判断交易流向');
- return;
- }
- const query = buildGraphQuery(row, showMessage);
- if (!query) return;
- const requestSequence = ++graphRequestSequence;
- graphLoading.value = true;
- graphQueried.value = true;
- try {
- const resp = await moneyFlowGraph(query);
- if (requestSequence !== graphRequestSequence) return;
- const nextGraphRoot = buildGraphRoot(resp, row);
- graphRoot.value = nextGraphRoot ? markRaw(nextGraphRoot) : null;
- } catch (error) {
- if (requestSequence !== graphRequestSequence) return;
- graphRoot.value = null;
- } finally {
- if (requestSequence === graphRequestSequence) {
- graphLoading.value = false;
- }
- }
- };
- const fetchTable = async (showMessage = true) => {
- const query = buildTableQuery(showMessage);
- if (!query) return;
- await runWithQueryLoading(async () => {
- const resp = await getTransFundFlowPage(query);
- const payload = unwrapResponseData(resp);
- const list = normalizeTableList(payload);
- pagination.value.current = normalizeCurrent(payload, pagination.value.current);
- pagination.value.pageSize = normalizeSize(payload, pagination.value.pageSize);
- pagination.value.total = normalizeTotal(payload, list.length);
- jumpPage.value = String(pagination.value.current);
- tableData.value = list.map(mapTableRow);
- hasSearched.value = true;
- syncAnalysisDataRefreshState(list.length > 0);
- if (tableData.value.length) {
- selectedRowKey.value = tableData.value[0].rowKey;
- await fetchGraphData(tableData.value[0].raw, false);
- } else {
- selectedRowKey.value = '';
- resetGraphState(true);
- }
- });
- };
- const handleSearch = async () => {
- pagination.value.current = 1;
- await fetchTable(true);
- };
- const handleReset = () => {
- filters.value = {
- target: '',
- amountOp: 'GT' as CompareOperator,
- amountValue: '',
- dateRange: undefined,
- otherCard: '',
- orderKey: '',
- sort: '',
- };
- graphFilters.value = {
- levelValue: '3',
- intervalOp: DEFAULT_LE_COMPARE_OPERATOR as BinaryCompareOperator,
- intervalValue: '7',
- amountOp: DEFAULT_COMPARE_OPERATOR as BinaryCompareOperator,
- amountValue: '',
- cashOnly: false,
- };
- traceDirection.value = 'backward';
- tableData.value = [];
- pagination.value.current = 1;
- pagination.value.total = 0;
- pagination.value.pageSize = componentSetting.table.defaultPageSize;
- jumpPage.value = '1';
- selectedRowKey.value = '';
- hasSearched.value = false;
- resetGraphState(true);
- detailModalOpen.value = false;
- detailBaseQuery.value = {};
- detailEdgePayload.value = null;
- detailTraceDirection.value = 'backward';
- detailTraceLoading.value = false;
- };
- const handlePrevPage = async () => {
- if (queryLoading.value || pagination.value.current <= 1) return;
- pagination.value.current -= 1;
- await fetchTable(false);
- };
- const handleNextPage = async () => {
- if (queryLoading.value || pagination.value.current >= totalPages.value) return;
- pagination.value.current += 1;
- await fetchTable(false);
- };
- const handlePaginationChange = async (page: number, pageSize: number) => {
- if (queryLoading.value) return;
- if (pageSize !== pagination.value.pageSize) {
- handlePageSizeChange(pageSize);
- return;
- }
- if (page === pagination.value.current - 1) {
- await handlePrevPage();
- return;
- }
- if (page === pagination.value.current + 1) {
- await handleNextPage();
- return;
- }
- if (page === pagination.value.current) return;
- jumpPage.value = String(page);
- await handleJumpPage();
- };
- const handlePageSizeChange = (pageSize: any) => {
- pagination.value.pageSize = Number(pageSize) || componentSetting.table.defaultPageSize;
- pagination.value.current = 1;
- void fetchTable(false);
- };
- const handleJumpPage = async () => {
- const page = Number(jumpPage.value);
- if (!Number.isFinite(page) || page < 1) {
- createMessage.warning('请输入正确的页码');
- jumpPage.value = String(pagination.value.current);
- return;
- }
- pagination.value.current = Math.min(Math.max(1, Math.trunc(page)), totalPages.value);
- jumpPage.value = String(pagination.value.current);
- await fetchTable(false);
- };
- const resolveSorterValue = (sorter: any) => {
- if (Array.isArray(sorter)) return sorter[0];
- return sorter;
- };
- const handleTableChange = async (_pagination: any, _filters: any, sorter: any) => {
- const currentSorter = resolveSorterValue(sorter);
- const order = currentSorter?.order;
- const columnKey = toRawText(currentSorter?.columnKey);
- filters.value.orderKey = order && columnKey ? columnKey : '';
- filters.value.sort = order === 'ascend' ? 'ASC' : order === 'descend' ? 'DESC' : '';
- pagination.value.current = 1;
- await fetchTable(false);
- };
- const handleSummaryRowClick = async (record: FundFlowTableRow) => {
- selectedRowKey.value = record.rowKey;
- await fetchGraphData(record.raw, true);
- };
- const handleGraphSearch = async () => {
- await fetchGraphData(selectedTradeRow.value?.raw, true);
- };
- const handleGraphReset = async () => {
- graphFilters.value = {
- levelValue: '3',
- intervalOp: DEFAULT_LE_COMPARE_OPERATOR as BinaryCompareOperator,
- intervalValue: '7',
- amountOp: DEFAULT_COMPARE_OPERATOR as BinaryCompareOperator,
- amountValue: '',
- cashOnly: false,
- };
- if (selectedTradeRow.value) {
- await fetchGraphData(selectedTradeRow.value.raw, false);
- } else {
- resetGraphState(true);
- }
- };
- const handleDetailTraceDirectionChange = (value: TraceDirection) => {
- detailTraceDirection.value = value;
- };
- const handleEdgeDetailClick = (edgePayload: GraphEdgeSelectionPayload) => {
- const nextPayload = normalizeEdgeSelectionPayload(edgePayload);
- const nextIds = nextPayload.ids || [];
- if (!nextIds.length) {
- createMessage.warning('当前节点暂无可查看的交易明细');
- return;
- }
- const detailEdgeKey = buildEdgeDetailKey(nextPayload);
- const now = Date.now();
- if (now - lastDetailEdgeOpenTime < DETAIL_EDGE_CLICK_INTERVAL) return;
- if (detailModalOpen.value && detailEdgeKey === activeDetailEdgeKey) return;
- if (detailEdgeKey === activeDetailEdgeKey && now - lastDetailEdgeOpenTime < SAME_DETAIL_EDGE_CLICK_INTERVAL) return;
- activeDetailEdgeKey = detailEdgeKey;
- lastDetailEdgeOpenTime = now;
- detailEdgePayload.value = markRaw(nextPayload);
- detailTraceDirection.value = 'backward';
- detailBaseQuery.value = { ids: [...nextIds] };
- detailModalOpen.value = true;
- };
- const handleGraphNodeClick = (node: GraphSelectionNode) => {
- const matchedRow = tableData.value.find((item) => item.otherName === (node.otherName || node.name));
- if (matchedRow) {
- selectedRowKey.value = matchedRow.rowKey;
- }
- };
- const handleDetailTrace = async (payload: TransRecordTracePayload) => {
- if (!detailEdgePayload.value) {
- createMessage.warning('当前没有可追溯的关系线');
- return;
- }
- if (!payload.records.length) {
- createMessage.warning('请选择追溯的数据');
- return;
- }
- const traceQuery = buildTraceGraphQuery(payload.records, detailEdgePayload.value, payload.direction, true);
- if (!traceQuery) return;
- detailTraceLoading.value = true;
- try {
- const resp = await moneyFlowTrace(traceQuery);
- const rawBranch = resolveTraceBranchSource(resp, detailEdgePayload.value);
- if (!rawBranch) {
- createMessage.warning('当前选中交易暂无可继续追溯的数据');
- return;
- }
- const targetKey = detailEdgePayload.value.targetId || detailEdgePayload.value.raw?.key || '';
- if (!targetKey) {
- createMessage.warning('未识别到当前追溯节点');
- return;
- }
- const incomingBranch = normalizeTraceBranch(rawBranch, String(targetKey), payload.direction);
- if (!graphRoot.value) {
- graphRoot.value = markRaw(incomingBranch);
- detailModalOpen.value = false;
- return;
- }
- const [nextGraphRoot, mergedByKey] = mergeTraceBranchByKey(graphRoot.value, String(targetKey), incomingBranch);
- if (mergedByKey) {
- graphRoot.value = markRaw(nextGraphRoot);
- detailModalOpen.value = false;
- return;
- }
- const [fallbackGraphRoot, mergedBySignature] = mergeTraceBranchBySignature(graphRoot.value, incomingBranch);
- if (mergedBySignature) {
- graphRoot.value = markRaw(fallbackGraphRoot);
- detailModalOpen.value = false;
- return;
- }
- createMessage.warning('已收到追溯结果,但未匹配到图中的落点节点');
- detailModalOpen.value = false;
- } catch (error) {
- createMessage.error('追溯失败,请稍后重试');
- } finally {
- detailTraceLoading.value = false;
- }
- };
- const handleGraphEdgeClick = (edgePayload: GraphEdgeSelectionPayload) => {
- try {
- handleEdgeDetailClick(edgePayload);
- } catch (error) {
- console.error('[capitalFlow] open edge detail failed:', error);
- createMessage.error('打开交易明细失败,请稍后重试');
- }
- };
- const fetchTargetOptions = async () => {
- try {
- targetOptions.value = await getPersonOptions(false);
- } catch (error) {
- targetOptions.value = [];
- }
- };
- onMounted(async () => {
- await fetchTargetOptions();
- });
- watch(traceDirection, async (nextValue, previousValue) => {
- if (nextValue === previousValue || !selectedTradeRow.value) return;
- await fetchGraphData(selectedTradeRow.value.raw, false);
- });
- watch(detailModalOpen, (open) => {
- if (open) return;
- detailTraceLoading.value = false;
- activeDetailEdgeKey = '';
- });
- </script>
- <style lang="less">
- @import '../styles/trans-analysis-table-ui.less';
- @prefix-cls: ~'jeesite-capital-flow';
- .@{prefix-cls}.capital-flow-page {
- .analysis-page-frame();
- .filter-item--checkbox {
- margin-left: 4px;
- }
- .filter-unit {
- color: #6b7280;
- font-size: 12px;
- white-space: nowrap;
- }
- .filter-input {
- width: 180px;
- }
- .filter-date {
- width: 360px;
- }
- .filter-mini {
- width: 92px;
- }
- .filter-micro {
- width: 72px;
- }
- .filter-op {
- width: 84px;
- min-width: 84px;
- }
- .filter-op-small {
- width: 72px;
- min-width: 72px;
- }
- .filter-fixed-op {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 40px;
- height: 32px;
- border: 1px solid #d9e1ec;
- border-radius: 8px;
- background: #fff;
- color: #4b5563;
- font-size: 12px;
- box-sizing: border-box;
- }
- .filter-actions,
- .filter-actions-small {
- display: flex;
- gap: 6px;
- margin-left: auto;
- }
- .content-panel {
- display: flex;
- gap: 2px;
- }
- .left-panel,
- .right-panel {
- .analysis-card-shell();
- }
- .left-panel {
- flex: 0 0 42%;
- }
- .right-panel {
- flex: 1 1 auto;
- min-width: 0;
- position: relative;
- }
- .right-panel.is-graph-fullscreen,
- .right-panel:fullscreen {
- width: 100%;
- height: 100%;
- max-width: none;
- padding: 16px 18px 18px;
- border: none;
- border-radius: 0;
- background: #fff;
- box-sizing: border-box;
- }
- .panel-header--graph {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 12px;
- }
- .panel-header-actions {
- display: flex;
- align-items: center;
- gap: 8px;
- flex: 0 0 auto;
- }
- .graph-fullscreen-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- }
- .graph-fullscreen-btn :deep(.ant-btn-icon) {
- display: inline-flex;
- align-items: center;
- font-size: 14px;
- }
- .query-state-panel {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 12px 0 0;
- }
- .table-wrapper .jeesite-basic-table {
- height: 100%;
- display: flex;
- flex-direction: column;
- min-height: 0;
- }
- .table-wrapper .ant-table-wrapper,
- .table-wrapper .ant-spin-nested-loading,
- .table-wrapper .ant-spin-container,
- .table-wrapper .ant-table,
- .table-wrapper .ant-table-container {
- height: 100%;
- min-height: 0;
- }
- .table-wrapper .ant-spin-container,
- .table-wrapper .ant-table,
- .table-wrapper .ant-table-container {
- display: flex;
- flex-direction: column;
- }
- .table-wrapper .ant-table-body {
- flex: 1 1 auto;
- min-height: 0;
- overflow: auto;
- }
- .table-wrapper .ant-table-body::-webkit-scrollbar {
- width: 8px;
- height: 8px;
- }
- .table-wrapper .ant-table-body::-webkit-scrollbar-thumb {
- background: rgb(148 163 184 / 60%);
- border-radius: 999px;
- }
- .table-wrapper .ant-table-body::-webkit-scrollbar-track {
- background: transparent;
- }
- .table-wrapper .ant-table-tbody > .ant-table-placeholder {
- height: 100%;
- }
- .table-wrapper .ant-table-tbody > .ant-table-placeholder > td {
- height: 100%;
- padding: 0 !important;
- }
- .table-wrapper .ant-table-placeholder .ant-empty {
- min-height: 100%;
- height: 100%;
- margin: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-direction: column;
- }
- .pagination-size .ant-select {
- width: 86px;
- }
- .pagination-input {
- width: 54px;
- }
- .flow-toolbar {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 0;
- background: transparent;
- border-bottom: 1px solid #eef2f7;
- flex-wrap: wrap;
- flex: 0 0 auto;
- }
- .trace-direction-group {
- padding: 0;
- flex: 0 0 auto;
- }
- .trace-direction-group :deep(.ant-radio-group) {
- display: inline-flex;
- gap: 8px;
- padding: 6px;
- border: 1px solid #dbe3ee;
- border-radius: 999px;
- background: #fff;
- }
- .trace-direction-group :deep(.ant-radio-button-wrapper) {
- min-width: 96px;
- height: 34px;
- line-height: 32px;
- text-align: center;
- border-radius: 999px;
- border: none;
- background: transparent;
- color: #6b7280;
- box-shadow: none;
- }
- .trace-direction-group :deep(.ant-radio-button-wrapper::before) {
- display: none;
- }
- .trace-direction-group :deep(.ant-radio-button-wrapper-checked) {
- color: #1677ff;
- background: #f0f6ff;
- box-shadow: inset 0 0 0 1px #bfd4f5;
- }
- .graph-body {
- flex: 1 1 auto;
- min-height: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- .ant-spin-nested-loading,
- .ant-spin-container {
- flex: 1 1 auto;
- min-height: 0;
- height: 100%;
- display: flex;
- flex-direction: column;
- }
- }
- .graph-scroll {
- flex: 1 1 auto;
- min-height: 0;
- overflow: auto;
- border-top: 0;
- border-radius: 0;
- background: transparent;
- }
- .graph-scroll::-webkit-scrollbar {
- width: 8px;
- height: 8px;
- }
- .graph-scroll::-webkit-scrollbar-thumb {
- background: rgb(148 163 184 / 55%);
- border-radius: 999px;
- }
- .graph-board {
- position: relative;
- min-height: 100%;
- }
- .graph-svg {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- pointer-events: none;
- }
- .graph-edge {
- fill: none;
- stroke: #9db7d6;
- stroke-width: 2.5;
- stroke-linecap: round;
- stroke-linejoin: round;
- filter: drop-shadow(0 1px 0 rgb(255 255 255 / 75%));
- }
- .graph-edge-label {
- position: absolute;
- display: flex;
- flex-direction: column;
- gap: 2px;
- width: 148px;
- padding: 8px 10px;
- border: 1px solid #dbe5f2;
- border-radius: 10px;
- background: #fff;
- box-shadow: 0 8px 18px rgb(15 23 42 / 8%);
- color: #334155;
- font-size: 11px;
- text-align: left;
- cursor: default;
- transition: all 0.2s ease;
- box-sizing: border-box;
- z-index: 1;
- &:disabled {
- opacity: 1;
- }
- &.is-clickable {
- cursor: pointer;
- &:hover {
- border-color: #93c5fd;
- box-shadow: 0 10px 22px rgb(59 130 246 / 12%);
- }
- }
- }
- .graph-edge-label__range {
- color: #94a3b8;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .graph-edge-label__value {
- color: #334155;
- font-weight: 500;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .graph-node {
- position: absolute;
- width: 228px;
- min-height: 122px;
- padding: 14px 14px 12px;
- border: 1px solid #dbe5f2;
- border-radius: 14px;
- background: #fff;
- box-shadow: 0 12px 24px rgb(15 23 42 / 6%);
- display: flex;
- gap: 12px;
- box-sizing: border-box;
- transition:
- box-shadow 0.2s ease,
- border-color 0.2s ease;
- z-index: 2;
- &:hover {
- border-color: #bfd4f5;
- box-shadow: 0 14px 28px rgb(59 130 246 / 8%);
- }
- }
- .graph-node--root {
- border-color: #bfdbfe;
- background: #fff;
- }
- .graph-node__badge {
- position: absolute;
- top: -10px;
- left: 14px;
- padding: 2px 8px;
- border-radius: 999px;
- background: #eff6ff;
- border: 1px solid #bfdbfe;
- color: #2563eb;
- font-size: 11px;
- line-height: 18px;
- }
- .graph-node__avatar {
- flex: 0 0 44px;
- width: 44px;
- height: 44px;
- border-radius: 50%;
- background: #eff6ff;
- color: #2563eb;
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- font-size: 18px;
- margin-top: 6px;
- img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
- }
- .graph-node__content {
- flex: 1 1 auto;
- min-width: 0;
- display: flex;
- flex-direction: column;
- }
- .graph-node__title-row {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 8px;
- margin-top: 4px;
- }
- .graph-node__name {
- flex: 1 1 auto;
- min-width: 0;
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 20px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .graph-node__meta {
- margin-top: 4px;
- color: #6b7280;
- font-size: 12px;
- line-height: 18px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .graph-node__stats {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-top: 8px;
- }
- .graph-node__stat {
- display: inline-flex;
- align-items: center;
- height: 22px;
- padding: 0 8px;
- border-radius: 999px;
- background: #f3f7ff;
- color: #3b82f6;
- font-size: 11px;
- white-space: nowrap;
- }
- .graph-node__footer {
- margin-top: auto;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- padding-top: 10px;
- }
- .graph-node__time {
- color: #94a3b8;
- font-size: 11px;
- line-height: 16px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .graph-node__detail {
- border: none;
- background: transparent;
- color: #1677ff;
- font-size: 12px;
- line-height: 18px;
- cursor: pointer;
- padding: 0;
- flex-shrink: 0;
- }
- .graph-empty {
- flex: 1 1 auto;
- min-height: 0;
- border-top: 0;
- border-radius: 0;
- background: transparent;
- }
- .amount-text {
- font-weight: 600;
- &.is-income {
- color: #e11d48;
- }
- &.is-expense {
- color: #334155;
- }
- &.is-other {
- color: #059669;
- }
- }
- .flag-chip {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- height: 22px;
- padding: 0 8px;
- border-radius: 999px;
- font-size: 11px;
- line-height: 20px;
- white-space: nowrap;
- border: 1px solid transparent;
- box-sizing: border-box;
- &.is-income {
- background: #fff1f2;
- border-color: #fecdd3;
- color: #e11d48;
- }
- &.is-expense {
- background: #f3f4f6;
- border-color: #d1d5db;
- color: #4b5563;
- }
- &.is-other {
- background: #ecfdf5;
- border-color: #bbf7d0;
- color: #047857;
- }
- }
- :deep(.ant-select-selector),
- :deep(.ant-input),
- :deep(.ant-picker),
- :deep(.ant-btn) {
- border-radius: 8px;
- }
- @media screen and (max-width: 900px) {
- .content-panel {
- flex-direction: column;
- }
- .left-panel {
- flex-basis: auto;
- }
- }
- @media screen and (max-width: 992px) {
- .filter-actions,
- .filter-actions-small {
- width: 100%;
- margin-left: 0;
- }
- .filter-input,
- .filter-date {
- width: 100%;
- }
- .pagination-bar {
- justify-content: flex-start;
- }
- }
- }
- </style>
|