index.vue 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150
  1. <template>
  2. <div :class="prefixCls" class="large-amount-page">
  3. <div class="page-header">
  4. <div class="page-title">大额交易</div>
  5. <div class="page-subtitle">按对象、金额和日期筛选大额交易,并联动查看交易趋势与对应明细。</div>
  6. </div>
  7. <div class="filter-bar">
  8. <div class="filter-item">
  9. <span class="filter-label">选择对象:</span>
  10. <Select
  11. v-model:value="filters.target"
  12. mode="multiple"
  13. allow-clear
  14. max-tag-count="responsive"
  15. class="filter-input"
  16. :options="targetOptions"
  17. placeholder="全部"
  18. />
  19. </div>
  20. <div class="filter-item">
  21. <span class="filter-label">交易日期:</span>
  22. <RangePicker v-model:value="filters.dateRange" class="filter-date" />
  23. </div>
  24. <div class="filter-item">
  25. <span class="filter-label">交易次数:</span>
  26. <Select v-model:value="filters.countOp" class="filter-op" :options="countOpOptions" />
  27. <Input v-model:value="filters.countValue" class="filter-mini" :status="countValueStatus" />
  28. </div>
  29. <div class="filter-item">
  30. <span class="filter-label">大额范围:</span>
  31. <Select v-model:value="filters.amountOp" class="filter-op" :options="countOpOptions" />
  32. <Input v-model:value="filters.amountValue" class="filter-mini" :status="amountValueStatus" />
  33. </div>
  34. <div class="filter-item">
  35. <span class="filter-label">对方姓名:</span>
  36. <Input v-model:value="filters.otherName" class="filter-input" placeholder="请输入对方姓名" />
  37. </div>
  38. <div class="filter-item">
  39. <span class="filter-label">对方卡号:</span>
  40. <Input v-model:value="filters.otherCardNo" class="filter-input" placeholder="请输入对方卡号" />
  41. </div>
  42. <div class="filter-actions">
  43. <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
  44. <Button @click="handleReset">重置</Button>
  45. </div>
  46. </div>
  47. <div class="content-panel">
  48. <div class="left-panel">
  49. <div class="panel-header">
  50. <div>
  51. <div class="panel-title">大额交易汇总</div>
  52. <div class="panel-desc">找出与对象有大额交易记录的人员或卡,点击左侧记录可查看右侧趋势。</div>
  53. </div>
  54. </div>
  55. <div v-if="!hasSearched" class="query-state-panel">
  56. <EmptyState
  57. variant="query"
  58. title="请点击查询"
  59. description="设置条件精细查询,也可点击查询展示所有信息"
  60. min-height="100%"
  61. description-width="min(100%, 420px)"
  62. />
  63. </div>
  64. <div v-else class="table-panel">
  65. <div class="table-wrapper">
  66. <BasicTable
  67. :columns="summaryColumns"
  68. :data-source="summaryData"
  69. :pagination="false"
  70. :show-table-setting="false"
  71. :table-setting="{ setting: true, size: true, redo: false, fullScreen: false }"
  72. :can-resize="true"
  73. :is-can-resize-parent="true"
  74. :resize-height-offset="32"
  75. :rowClassName="summaryRowClassName"
  76. row-key="id"
  77. @change="handleTableChange"
  78. @row-click="handleSummaryRowClick"
  79. />
  80. </div>
  81. </div>
  82. <div v-if="hasSearched" class="pagination-bar">
  83. <AnalysisPagination
  84. :total="pagination.total"
  85. :current="pagination.current"
  86. :page-size="pagination.pageSize"
  87. :page-size-options="pageSizeOptions"
  88. :loading="queryLoading"
  89. @change="handlePaginationChange"
  90. />
  91. </div>
  92. </div>
  93. <div class="right-panel">
  94. <div class="panel-header">
  95. <div>
  96. <div class="panel-title">大额交易趋势</div>
  97. <div class="panel-desc">
  98. {{
  99. selectedSummaryRaw
  100. ? `查看 ${selectedSummaryRaw.personName || '-'} 的大额交易时间分布`
  101. : '请选择左侧汇总数据查看大额交易趋势。'
  102. }}
  103. </div>
  104. </div>
  105. </div>
  106. <div class="timeline-panel">
  107. <div v-if="timelineData.length" class="timeline-summary">
  108. <div class="timeline-stat-card">
  109. <div class="timeline-stat-label">活跃日期</div>
  110. <div class="timeline-stat-value">{{ timelineSummary.days }}</div>
  111. </div>
  112. <div class="timeline-stat-card">
  113. <div class="timeline-stat-label">交易总次数</div>
  114. <div class="timeline-stat-value">{{ timelineSummary.count }}</div>
  115. </div>
  116. <div class="timeline-stat-card emphasis">
  117. <div class="timeline-stat-label">交易总金额</div>
  118. <div class="timeline-stat-value">{{ timelineSummary.amount }}</div>
  119. </div>
  120. </div>
  121. <div class="timeline-wrapper">
  122. <template v-if="timelineData.length">
  123. <div class="timeline-line"></div>
  124. <div class="timeline-content">
  125. <div v-for="item in timelineData" :key="item.key" class="timeline-item">
  126. <div class="timeline-date-block">
  127. <div class="timeline-date">{{ item.date }}</div>
  128. </div>
  129. <button type="button" class="timeline-dot-button" @click="handleTimelineClick(item)">
  130. <span class="timeline-dot"></span>
  131. </button>
  132. <div class="timeline-card" @click="handleTimelineClick(item)">
  133. <div class="timeline-card-header">
  134. <div class="timeline-card-main">
  135. <div class="timeline-card-title">当日交易汇总</div>
  136. <div class="timeline-card-subtitle">
  137. {{ item.otherName || '-' }}
  138. <template v-if="item.otherCardNo"> / {{ item.otherCardNo }}</template>
  139. </div>
  140. </div>
  141. <div class="timeline-card-action">查看明细</div>
  142. </div>
  143. <div class="timeline-tags">
  144. <span class="timeline-tag count">交易次数: {{ item.count }}</span>
  145. <span class="timeline-tag amount">交易金额: {{ item.amountText }}</span>
  146. </div>
  147. </div>
  148. </div>
  149. </div>
  150. </template>
  151. <div v-else class="timeline-empty">
  152. <EmptyState
  153. variant="compact"
  154. title="暂无趋势数据"
  155. :description="timelineEmptyDescription"
  156. :min-height="260"
  157. />
  158. </div>
  159. </div>
  160. </div>
  161. </div>
  162. </div>
  163. <TransRecordModal v-model:open="detailOpen" title="交易记录" :base-query="detailBaseQuery" />
  164. </div>
  165. </template>
  166. <script lang="ts" setup name="ViewsLargeAmountIndex">
  167. import { computed, onMounted, ref, watch } from 'vue';
  168. import { useRoute } from 'vue-router';
  169. import dayjs, { type Dayjs } from 'dayjs';
  170. import { Select, Input, Button, DatePicker } from 'ant-design-vue';
  171. import AnalysisPagination from '@jeesite/core/components/AnalysisPagination/src/AnalysisPagination.vue';
  172. import { BasicTable } from '@jeesite/core/components/Table';
  173. import { EmptyState } from '@jeesite/core/components/EmptyState';
  174. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  175. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  176. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  177. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  178. import componentSetting from '@jeesite/core/settings/componentSetting';
  179. import {
  180. COMPARE_OPERATOR_OPTIONS,
  181. DEFAULT_COMPARE_OPERATOR,
  182. normalizeCompareOperator,
  183. } from '@jeesite/core/utils/compareOperator';
  184. import { mergeDetailDateRange, pickDetailValue } from '@jeesite/core/utils/detailQuery';
  185. import { statFirstTransBig, statSecondTransBig } from '@jeesite/trans/api/transApi';
  186. import { getPersonOptions } from '@jeesite/person/api/personApi';
  187. import type { TransBigDTO } from '@jeesite/trans/api/types/dto';
  188. import type { TransBigQuery, TransRecordQuery } from '@jeesite/trans/api/types/query';
  189. import TransRecordModal from '../components/TransRecordModal.vue';
  190. const { prefixCls } = useDesign('large-amount');
  191. const { queryLoading, runWithQueryLoading } = useTableSkill();
  192. const { createMessage } = useMessage();
  193. const route = useRoute();
  194. const PAGE_PATH = '/largeAmount/index';
  195. const RangePicker = DatePicker.RangePicker;
  196. const DEFAULT_SUMMARY_SORT_KEY = 'transCount';
  197. const DEFAULT_SUMMARY_SORT_ORDER = 'DESC' as const;
  198. type RangeValue = [Dayjs, Dayjs] | null;
  199. type TimelineRow = {
  200. key: string;
  201. date: string;
  202. raw: TransBigDTO;
  203. count: number;
  204. amount: number;
  205. amountText: string;
  206. otherName: string;
  207. otherCardNo: string;
  208. };
  209. const createDefaultFilters = () => ({
  210. target: [] as string[],
  211. dateRange: null as RangeValue,
  212. countOp: DEFAULT_COMPARE_OPERATOR,
  213. countValue: '',
  214. amountOp: DEFAULT_COMPARE_OPERATOR,
  215. amountValue: '10000',
  216. otherName: '',
  217. otherCardNo: '',
  218. orderKey: DEFAULT_SUMMARY_SORT_KEY,
  219. sort: DEFAULT_SUMMARY_SORT_ORDER as '' | 'ASC' | 'DESC',
  220. });
  221. const filters = ref(createDefaultFilters());
  222. const selectedRow = ref<number | null>(null);
  223. const targetOptions = ref<Array<{ label: string; value: string }>>([]);
  224. const countValuePattern = /^[1-9]\d*$/;
  225. const normalizeSelectedTargets = (value?: string[] | string | null) => {
  226. if (Array.isArray(value)) {
  227. const list = value.map((item) => String(item).trim()).filter(Boolean);
  228. return list.length ? Array.from(new Set(list)) : [];
  229. }
  230. if (!value) return [];
  231. const list = String(value)
  232. .split(',')
  233. .map((item) => item.trim())
  234. .filter(Boolean);
  235. return list.length ? Array.from(new Set(list)) : [];
  236. };
  237. const countOpOptions = COMPARE_OPERATOR_OPTIONS.filter((o) => o.value === 'GE' || o.value === 'LE');
  238. const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
  239. label: value,
  240. value: Number(value),
  241. }));
  242. const summaryColumns = [
  243. { title: '本方姓名', dataIndex: 'selfName', key: 'selfName', width: 120 },
  244. { title: '对方姓名/卡号', dataIndex: 'otherName', key: 'otherName', width: 200 },
  245. {
  246. title: '大额交易次数',
  247. dataIndex: 'count',
  248. key: 'transCount',
  249. width: 130,
  250. sorter: true,
  251. defaultSortOrder: 'descend',
  252. },
  253. { title: '交易合计', dataIndex: 'total', key: 'totalTransAmount', width: 140, sorter: true },
  254. ];
  255. const summaryData = ref<any[]>([]);
  256. const timelineData = ref<TimelineRow[]>([]);
  257. const pagination = ref({
  258. current: 1,
  259. pageSize: componentSetting.table.defaultPageSize,
  260. total: 0,
  261. });
  262. const jumpPage = ref('1');
  263. const hasSearched = ref(false);
  264. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  265. refresh: () => fetchSummaryTable(),
  266. hasData: () => hasSearched.value && summaryData.value.length > 0,
  267. });
  268. const detailOpen = ref(false);
  269. const detailBaseQuery = ref<Partial<TransRecordQuery>>({});
  270. const selectedSummaryRaw = ref<TransBigDTO | null>(null);
  271. const normalizeList = (resp: any): TransBigDTO[] => {
  272. if (Array.isArray(resp)) return resp;
  273. if (Array.isArray(resp?.list)) return resp.list;
  274. if (Array.isArray(resp?.data)) return resp.data;
  275. if (Array.isArray(resp?.data?.list)) return resp.data.list;
  276. if (Array.isArray(resp?.records)) return resp.records;
  277. if (Array.isArray(resp?.data?.records)) return resp.data.records;
  278. return [];
  279. };
  280. const normalizeTotal = (resp: any, listLen: number) => {
  281. const total = resp?.total ?? resp?.data?.total ?? resp?.count ?? resp?.data?.count ?? listLen;
  282. return Number.isFinite(Number(total)) ? Number(total) : listLen;
  283. };
  284. const totalPages = computed(() => {
  285. if (!pagination.value.total) return 1;
  286. return Math.max(1, Math.ceil(pagination.value.total / pagination.value.pageSize));
  287. });
  288. const normalizedCountValue = computed(() => String(filters.value.countValue ?? '').trim());
  289. const normalizedAmountValue = computed(() => String(filters.value.amountValue ?? '').trim());
  290. const countValueStatus = computed<'error' | undefined>(() => {
  291. if (!normalizedCountValue.value) return undefined;
  292. return countValuePattern.test(normalizedCountValue.value) ? undefined : 'error';
  293. });
  294. const amountValueStatus = computed<'error' | undefined>(() => {
  295. if (!normalizedAmountValue.value) return undefined;
  296. const parsed = toAmountNumber(normalizedAmountValue.value);
  297. return parsed > 0 ? undefined : 'error';
  298. });
  299. const timelineSummary = computed(() => {
  300. const count = timelineData.value.reduce((sum, item) => sum + item.count, 0);
  301. const amount = timelineData.value.reduce((sum, item) => sum + item.amount, 0);
  302. return {
  303. days: timelineData.value.length,
  304. count,
  305. amount: formatAmount(amount),
  306. };
  307. });
  308. const timelineEmptyDescription = computed(() => {
  309. if (!summaryData.value.length) {
  310. return '当前筛选条件下暂无大额交易记录,请调整筛选条件后重试';
  311. }
  312. if (selectedSummaryRaw.value) {
  313. return '当前选中对象暂无按日期聚合的大额交易趋势,可切换左侧其他汇总记录查看';
  314. }
  315. return '请先从左侧列表选择一条汇总记录';
  316. });
  317. const mapSummaryRow = (item: TransBigDTO, index: number) => ({
  318. id: (pagination.value.current - 1) * pagination.value.pageSize + index + 1,
  319. selfName: item.personName || '-',
  320. otherName: `${item.otherName || '-'}${item.otherCardNo ? ` / ${item.otherCardNo}` : ''}`,
  321. count: item.transCount ?? 0,
  322. total: item.totalTransAmount ?? 0,
  323. raw: item,
  324. });
  325. const sortSummaryList = (list: TransBigDTO[]) =>
  326. [...list].sort((left, right) => {
  327. const leftCount = Number(left.transCount ?? 0) || 0;
  328. const rightCount = Number(right.transCount ?? 0) || 0;
  329. if (rightCount !== leftCount) return rightCount - leftCount;
  330. const leftAmount = toAmountNumber(left.totalTransAmount);
  331. const rightAmount = toAmountNumber(right.totalTransAmount);
  332. if (rightAmount !== leftAmount) return rightAmount - leftAmount;
  333. return String(left.otherName || left.otherCardNo || '').localeCompare(
  334. String(right.otherName || right.otherCardNo || ''),
  335. );
  336. });
  337. const toAmountNumber = (value?: string | number | null) => {
  338. if (value === null || value === undefined || value === '') return 0;
  339. const normalized = typeof value === 'string' ? value.replace(/,/g, '').trim() : value;
  340. const parsed = Number(normalized);
  341. return Number.isFinite(parsed) ? parsed : 0;
  342. };
  343. const formatAmount = (value?: string | number | null) => {
  344. const amount = toAmountNumber(value);
  345. return amount.toLocaleString('zh-CN', {
  346. minimumFractionDigits: 2,
  347. maximumFractionDigits: 2,
  348. });
  349. };
  350. const parseTransAgg = (transAgg?: string | null) => {
  351. if (!transAgg) return { count: 0, totalAmount: 0 };
  352. return String(transAgg)
  353. .split(',')
  354. .map((item) => item.trim())
  355. .filter(Boolean)
  356. .reduce(
  357. (acc, item) => {
  358. const segments = item.split('_');
  359. const amountText = segments[segments.length - 1];
  360. acc.count += 1;
  361. acc.totalAmount += toAmountNumber(amountText);
  362. return acc;
  363. },
  364. { count: 0, totalAmount: 0 },
  365. );
  366. };
  367. const resolveFilterTransCount = (showMessage = false) => {
  368. if (!normalizedCountValue.value) return undefined;
  369. if (countValuePattern.test(normalizedCountValue.value)) {
  370. return Number(normalizedCountValue.value);
  371. }
  372. if (showMessage) {
  373. createMessage.warning('交易次数请输入大于 0 的整数');
  374. }
  375. return null;
  376. };
  377. const resolveFilterTransAmount = (showMessage = false) => {
  378. if (!normalizedAmountValue.value) return undefined;
  379. const rawValue = normalizedAmountValue.value;
  380. const parsed = toAmountNumber(rawValue);
  381. if (parsed > 0) {
  382. return rawValue;
  383. }
  384. if (showMessage) {
  385. createMessage.warning('交易总额请输入大于 0 的数字');
  386. }
  387. return null;
  388. };
  389. const buildSummaryQuery = (transCount?: number, transAmount?: string | number): TransBigQuery => {
  390. const startDate =
  391. Array.isArray(filters.value.dateRange) && filters.value.dateRange[0]
  392. ? dayjs(filters.value.dateRange[0]).format('YYYY-MM-DD')
  393. : undefined;
  394. const endDate =
  395. Array.isArray(filters.value.dateRange) && filters.value.dateRange[1]
  396. ? dayjs(filters.value.dateRange[1]).format('YYYY-MM-DD')
  397. : undefined;
  398. const otherName = String(filters.value.otherName || '').trim();
  399. const otherCardNo = String(filters.value.otherCardNo || '').trim();
  400. return {
  401. page: pagination.value.current,
  402. limit: pagination.value.pageSize,
  403. startDate,
  404. endDate,
  405. personNames: (() => {
  406. const personNames = normalizeSelectedTargets(filters.value.target);
  407. return personNames.length ? personNames : undefined;
  408. })(),
  409. transCount: Number.isFinite(transCount as number) ? transCount : undefined,
  410. transCountSymbol:
  411. transCount !== undefined
  412. ? normalizeCompareOperator(filters.value.countOp, DEFAULT_COMPARE_OPERATOR)
  413. : undefined,
  414. transAmount: transAmount ?? undefined,
  415. transAmountSymbol:
  416. transAmount !== undefined
  417. ? normalizeCompareOperator(filters.value.amountOp, DEFAULT_COMPARE_OPERATOR)
  418. : undefined,
  419. otherName: otherName || undefined,
  420. otherCardNo: otherCardNo || undefined,
  421. orderKey: filters.value.orderKey || undefined,
  422. sort: filters.value.sort || undefined,
  423. };
  424. };
  425. const summaryRowClassName = (record: any) => {
  426. return selectedRow.value === record.id ? 'active-row' : '';
  427. };
  428. const handleSummaryRowClick = (record: any) => {
  429. selectedRow.value = record?.id;
  430. selectedSummaryRaw.value = record?.raw || null;
  431. void fetchTimelineData(record?.raw);
  432. };
  433. const fetchTimelineData = async (row?: TransBigDTO) => {
  434. if (!row) {
  435. timelineData.value = [];
  436. return;
  437. }
  438. const transCount = resolveFilterTransCount(true);
  439. if (transCount === null) {
  440. timelineData.value = [];
  441. return;
  442. }
  443. const transAmount = resolveFilterTransAmount(true);
  444. if (transAmount === null) {
  445. timelineData.value = [];
  446. return;
  447. }
  448. const startDate =
  449. Array.isArray(filters.value.dateRange) && filters.value.dateRange[0]
  450. ? dayjs(filters.value.dateRange[0]).format('YYYY-MM-DD')
  451. : undefined;
  452. const endDate =
  453. Array.isArray(filters.value.dateRange) && filters.value.dateRange[1]
  454. ? dayjs(filters.value.dateRange[1]).format('YYYY-MM-DD')
  455. : undefined;
  456. const personName = row.personName || normalizeSelectedTargets(filters.value.target)[0];
  457. try {
  458. const query: TransBigQuery = {
  459. page: 1,
  460. limit: 200,
  461. startDate,
  462. endDate,
  463. personName,
  464. personNames: personName ? [personName] : undefined,
  465. transCount: Number.isFinite(transCount as number) ? transCount : undefined,
  466. transCountSymbol:
  467. transCount !== undefined
  468. ? normalizeCompareOperator(filters.value.countOp, DEFAULT_COMPARE_OPERATOR)
  469. : undefined,
  470. transAmount: transAmount ?? undefined,
  471. transAmountSymbol:
  472. transAmount !== undefined
  473. ? normalizeCompareOperator(filters.value.amountOp, DEFAULT_COMPARE_OPERATOR)
  474. : undefined,
  475. otherName: pickDetailValue(row.otherName, String(filters.value.otherName || '').trim()),
  476. otherCardNo: pickDetailValue(row.otherCardNo, String(filters.value.otherCardNo || '').trim()),
  477. };
  478. const resp = await statSecondTransBig(query);
  479. const list = normalizeList(resp);
  480. timelineData.value = list.map((item, index) => {
  481. const aggSummary = parseTransAgg(item.transAgg);
  482. const currentTransCount = aggSummary.count || Number(item.transCount ?? 0);
  483. const currentTransAmount = aggSummary.totalAmount || toAmountNumber(item.totalTransAmount);
  484. const otherName = item.otherName || row.otherName || '-';
  485. const otherCardNo = item.otherCardNo || row.otherCardNo || '';
  486. return {
  487. key: `${item.transDate || 'unknown'}-${otherName}-${otherCardNo}-${index}`,
  488. date: item.transDate || '-',
  489. raw: item,
  490. count: currentTransCount,
  491. amount: currentTransAmount,
  492. amountText: formatAmount(currentTransAmount),
  493. otherName,
  494. otherCardNo,
  495. };
  496. });
  497. } catch (error) {
  498. timelineData.value = [];
  499. createMessage.warning('大额交易第二层接口调用失败');
  500. }
  501. };
  502. const handleTimelineClick = async (item: TimelineRow) => {
  503. if (!item?.date) return;
  504. const transCount = resolveFilterTransCount(true);
  505. if (transCount === null) return;
  506. const transAmount = resolveFilterTransAmount(true);
  507. if (transAmount === null) return;
  508. const selectedDate = dayjs(item.date).isValid() ? dayjs(item.date).format('YYYY-MM-DD') : undefined;
  509. const currentQuery = buildSummaryQuery(transCount, transAmount);
  510. const { startDate, endDate } = mergeDetailDateRange(
  511. {
  512. startDate: currentQuery.startDate,
  513. endDate: currentQuery.endDate,
  514. },
  515. {
  516. startDate: selectedDate,
  517. endDate: selectedDate,
  518. },
  519. );
  520. detailBaseQuery.value = {
  521. personName: pickDetailValue(selectedSummaryRaw.value?.personName, currentQuery.personNames?.[0]),
  522. personCardNo: pickDetailValue(selectedSummaryRaw.value?.personCardNo),
  523. otherName: pickDetailValue(item.raw?.otherName, selectedSummaryRaw.value?.otherName),
  524. otherCardNo: pickDetailValue(item.raw?.otherCardNo, selectedSummaryRaw.value?.otherCardNo),
  525. startDate,
  526. endDate,
  527. transAmount: currentQuery.transAmount,
  528. transAmountSymbol: currentQuery.transAmountSymbol,
  529. };
  530. detailOpen.value = true;
  531. };
  532. const fetchSummaryTable = async () => {
  533. const transCount = resolveFilterTransCount(true);
  534. if (transCount === null) return;
  535. const transAmount = resolveFilterTransAmount(true);
  536. if (transAmount === null) return;
  537. await runWithQueryLoading(async () => {
  538. const resp = await statFirstTransBig(buildSummaryQuery(transCount, transAmount));
  539. const list = sortSummaryList(normalizeList(resp));
  540. const current = Number((resp as any)?.current ?? (resp as any)?.data?.current ?? pagination.value.current);
  541. const size = Number((resp as any)?.size ?? (resp as any)?.data?.size ?? pagination.value.pageSize);
  542. if (Number.isFinite(current) && current > 0) pagination.value.current = current;
  543. if (Number.isFinite(size) && size > 0) pagination.value.pageSize = size;
  544. summaryData.value = list.map(mapSummaryRow);
  545. pagination.value.total = normalizeTotal(resp, list.length);
  546. hasSearched.value = true;
  547. syncAnalysisDataRefreshState(list.length > 0);
  548. jumpPage.value = String(pagination.value.current);
  549. if (summaryData.value.length) {
  550. selectedRow.value = summaryData.value[0].id;
  551. selectedSummaryRaw.value = summaryData.value[0].raw;
  552. await fetchTimelineData(summaryData.value[0].raw);
  553. } else {
  554. selectedRow.value = null;
  555. selectedSummaryRaw.value = null;
  556. timelineData.value = [];
  557. }
  558. });
  559. };
  560. const handleSearch = async () => {
  561. pagination.value.current = 1;
  562. await fetchSummaryTable();
  563. };
  564. const handleReset = async () => {
  565. filters.value = createDefaultFilters();
  566. pagination.value.current = 1;
  567. await fetchSummaryTable();
  568. };
  569. const handlePrevPage = async () => {
  570. if (queryLoading.value || pagination.value.current <= 1) return;
  571. pagination.value.current -= 1;
  572. await fetchSummaryTable();
  573. };
  574. const handleNextPage = async () => {
  575. if (queryLoading.value || pagination.value.current >= totalPages.value) return;
  576. pagination.value.current += 1;
  577. await fetchSummaryTable();
  578. };
  579. const handlePaginationChange = async (page: number, pageSize: number) => {
  580. if (queryLoading.value) return;
  581. if (pageSize !== pagination.value.pageSize) {
  582. pagination.value.pageSize = pageSize;
  583. await handlePageSizeChange();
  584. return;
  585. }
  586. if (page === pagination.value.current - 1) {
  587. await handlePrevPage();
  588. return;
  589. }
  590. if (page === pagination.value.current + 1) {
  591. await handleNextPage();
  592. return;
  593. }
  594. if (page === pagination.value.current) return;
  595. jumpPage.value = String(page);
  596. await handleJumpPage();
  597. };
  598. const handlePageSizeChange = async () => {
  599. pagination.value.current = 1;
  600. await fetchSummaryTable();
  601. };
  602. const handleJumpPage = async () => {
  603. const next = Number(jumpPage.value);
  604. if (!Number.isInteger(next) || next < 1) {
  605. createMessage.warning('请输入正确页码');
  606. jumpPage.value = String(pagination.value.current);
  607. return;
  608. }
  609. pagination.value.current = Math.min(next, totalPages.value);
  610. await fetchSummaryTable();
  611. };
  612. const resolveSorterValue = (sorter: any) => {
  613. if (Array.isArray(sorter)) return sorter[0];
  614. return sorter;
  615. };
  616. const handleTableChange = async (_pagination: any, _filters: any, sorter: any) => {
  617. const currentSorter = resolveSorterValue(sorter);
  618. const order = currentSorter?.order;
  619. const columnKey = String(currentSorter?.columnKey || '');
  620. filters.value.orderKey = order && columnKey ? columnKey : DEFAULT_SUMMARY_SORT_KEY;
  621. filters.value.sort = order === 'ascend' ? 'ASC' : order === 'descend' ? 'DESC' : DEFAULT_SUMMARY_SORT_ORDER;
  622. pagination.value.current = 1;
  623. await fetchSummaryTable();
  624. };
  625. const fetchTargetOptions = async () => {
  626. try {
  627. targetOptions.value = await getPersonOptions(false);
  628. } catch (error) {
  629. targetOptions.value = [];
  630. }
  631. };
  632. const applyRouteQuery = (targetOptions: Array<{ label: string; value: string }>) => {
  633. filters.value = createDefaultFilters();
  634. const personNames = normalizeSelectedTargets(
  635. (route.query.personNames as string | string[] | null | undefined) ??
  636. (route.query.personName as string | string[] | null | undefined),
  637. );
  638. const otherName = String(route.query.otherName || '').trim();
  639. const otherCardNo = String(route.query.otherCardNo || '').trim();
  640. const routeCountValue = String(route.query.countValue || '').trim();
  641. const routeAmountValue = String(route.query.amountValue || '').trim();
  642. if (personNames.length) {
  643. filters.value.target = personNames.map((personName) => {
  644. const matched = targetOptions.find((item) => String(item.label).trim() === personName);
  645. return matched ? matched.value : personName;
  646. });
  647. }
  648. if (countValuePattern.test(routeCountValue)) {
  649. filters.value.countValue = routeCountValue;
  650. filters.value.countOp = normalizeCompareOperator(String(route.query.countOp || ''), DEFAULT_COMPARE_OPERATOR);
  651. }
  652. if (toAmountNumber(routeAmountValue) > 0) {
  653. filters.value.amountValue = routeAmountValue;
  654. filters.value.amountOp = normalizeCompareOperator(String(route.query.amountOp || ''), DEFAULT_COMPARE_OPERATOR);
  655. }
  656. if (otherName) {
  657. filters.value.otherName = otherName;
  658. }
  659. if (otherCardNo) {
  660. filters.value.otherCardNo = otherCardNo;
  661. }
  662. return !!(personNames.length || otherName || otherCardNo || routeCountValue || routeAmountValue);
  663. };
  664. const syncFromRoute = async () => {
  665. if (route.path !== PAGE_PATH) return;
  666. if (applyRouteQuery(targetOptions.value)) {
  667. pagination.value.current = 1;
  668. await fetchSummaryTable();
  669. }
  670. };
  671. onMounted(async () => {
  672. await fetchTargetOptions();
  673. await syncFromRoute();
  674. });
  675. watch(
  676. () => route.fullPath,
  677. async (next, prev) => {
  678. if (next === prev) return;
  679. await syncFromRoute();
  680. },
  681. );
  682. </script>
  683. <style lang="less">
  684. @import '../styles/trans-analysis-table-ui.less';
  685. @prefix-cls: ~'jeesite-large-amount';
  686. .@{prefix-cls}.large-amount-page {
  687. .analysis-page-frame();
  688. .filter-input {
  689. width: 180px;
  690. }
  691. .filter-mini {
  692. width: 80px;
  693. }
  694. .filter-op {
  695. width: 84px;
  696. min-width: 84px;
  697. }
  698. .filter-date {
  699. width: 360px;
  700. }
  701. .content-panel {
  702. display: flex;
  703. gap: 2px;
  704. }
  705. .left-panel {
  706. flex: 0 0 46%;
  707. max-width: 46%;
  708. min-width: 0;
  709. .analysis-card-shell();
  710. }
  711. .right-panel {
  712. flex: 1;
  713. min-width: 0;
  714. .analysis-card-shell();
  715. }
  716. .query-state-panel {
  717. flex: 1 1 auto;
  718. min-height: 0;
  719. display: flex;
  720. align-items: center;
  721. justify-content: center;
  722. padding: 12px 0 0;
  723. }
  724. .table-wrapper .jeesite-basic-table {
  725. height: 100%;
  726. display: flex;
  727. flex-direction: column;
  728. min-height: 0;
  729. }
  730. .table-wrapper .ant-table-wrapper,
  731. .table-wrapper .ant-spin-nested-loading,
  732. .table-wrapper .ant-spin-container,
  733. .table-wrapper .ant-table,
  734. .table-wrapper .ant-table-container {
  735. height: 100%;
  736. min-height: 0;
  737. }
  738. .table-wrapper .ant-spin-container,
  739. .table-wrapper .ant-table,
  740. .table-wrapper .ant-table-container {
  741. display: flex;
  742. flex-direction: column;
  743. }
  744. .table-wrapper .ant-table-body {
  745. flex: 1 1 auto;
  746. min-height: 0;
  747. max-height: 100% !important;
  748. overflow: auto !important;
  749. }
  750. .table-wrapper .ant-table-body::-webkit-scrollbar {
  751. width: 8px;
  752. height: 8px;
  753. }
  754. .table-wrapper .ant-table-body::-webkit-scrollbar-thumb {
  755. background: rgba(148, 163, 184, 0.6);
  756. border-radius: 999px;
  757. }
  758. .table-wrapper .ant-table-body::-webkit-scrollbar-track {
  759. background: transparent;
  760. }
  761. .table-wrapper .ant-table-thead > tr > th {
  762. background: #f8fafc;
  763. font-weight: 600;
  764. color: #374151;
  765. }
  766. .table-wrapper .ant-table-tbody > tr > td {
  767. color: #374151;
  768. }
  769. .table-wrapper .ant-table-tbody > tr:hover > td {
  770. background: #f5f7ff;
  771. }
  772. .pagination-input {
  773. width: 48px;
  774. }
  775. .timeline-panel {
  776. flex: 1 1 auto;
  777. min-height: 0;
  778. display: flex;
  779. flex-direction: column;
  780. }
  781. .timeline-summary {
  782. display: grid;
  783. grid-template-columns: repeat(3, minmax(0, 1fr));
  784. gap: 8px;
  785. flex: 0 0 auto;
  786. }
  787. .timeline-stat-card {
  788. min-width: 0;
  789. padding: 10px 12px;
  790. border: 1px solid #e5e7eb;
  791. border-radius: 6px;
  792. background: transparent;
  793. box-shadow: none;
  794. &.emphasis {
  795. border-color: #d7e6fb;
  796. background: #f8fbff;
  797. }
  798. }
  799. .timeline-stat-label {
  800. color: #6b7280;
  801. font-size: 12px;
  802. line-height: 18px;
  803. }
  804. .timeline-stat-value {
  805. margin-top: 6px;
  806. color: #111827;
  807. font-size: 18px;
  808. font-weight: 600;
  809. line-height: 26px;
  810. word-break: break-all;
  811. }
  812. .timeline-wrapper {
  813. flex: 1 1 auto;
  814. min-height: 0;
  815. position: relative;
  816. padding: 10px 0 0 132px;
  817. overflow: auto;
  818. }
  819. .timeline-empty {
  820. height: 100%;
  821. min-height: 260px;
  822. display: flex;
  823. align-items: center;
  824. justify-content: center;
  825. }
  826. .timeline-line {
  827. position: absolute;
  828. left: 132px;
  829. top: 10px;
  830. bottom: 0;
  831. width: 2px;
  832. background: linear-gradient(180deg, #dbeafe 0%, #d7dee8 100%);
  833. }
  834. .timeline-content {
  835. display: flex;
  836. flex-direction: column;
  837. gap: 10px;
  838. padding: 2px 0;
  839. }
  840. .timeline-item {
  841. position: relative;
  842. min-height: 96px;
  843. }
  844. .timeline-date-block {
  845. position: absolute;
  846. left: -116px;
  847. top: 18px;
  848. width: 88px;
  849. text-align: right;
  850. }
  851. .timeline-date {
  852. font-size: 12px;
  853. font-weight: 600;
  854. color: #475569;
  855. line-height: 20px;
  856. word-break: break-word;
  857. }
  858. .timeline-dot-button {
  859. position: absolute;
  860. left: -14px;
  861. top: 22px;
  862. width: 24px;
  863. height: 24px;
  864. padding: 0;
  865. border: none;
  866. background: transparent;
  867. display: inline-flex;
  868. align-items: center;
  869. justify-content: center;
  870. cursor: pointer;
  871. z-index: 2;
  872. }
  873. .timeline-dot {
  874. width: 12px;
  875. height: 12px;
  876. background: #ffffff;
  877. border: 3px solid #3b82f6;
  878. border-radius: 50%;
  879. box-shadow:
  880. 0 0 0 2px #dbeafe,
  881. 0 6px 14px rgba(59, 130, 246, 0.16);
  882. transition:
  883. transform 0.2s ease,
  884. box-shadow 0.2s ease,
  885. background 0.2s ease,
  886. border-color 0.2s ease;
  887. }
  888. .timeline-item:hover .timeline-dot {
  889. transform: scale(1.04);
  890. background: #eff6ff;
  891. border-color: #2563eb;
  892. box-shadow:
  893. 0 0 0 2px #bfdbfe,
  894. 0 8px 18px rgba(59, 130, 246, 0.2);
  895. }
  896. .timeline-card {
  897. min-height: 96px;
  898. margin-left: 20px;
  899. padding: 10px 0 10px 12px;
  900. border: 0;
  901. border-left: 1px solid #dbe3ee;
  902. border-radius: 0;
  903. background: transparent;
  904. box-shadow: none;
  905. cursor: pointer;
  906. transition:
  907. box-shadow 0.2s ease,
  908. border-color 0.2s ease,
  909. background-color 0.2s ease;
  910. }
  911. .timeline-item:hover .timeline-card {
  912. border-color: #c8d6e5;
  913. background: rgba(251, 253, 255, 0.72);
  914. box-shadow: none;
  915. }
  916. .timeline-card-header {
  917. display: flex;
  918. align-items: flex-start;
  919. justify-content: space-between;
  920. gap: 12px;
  921. }
  922. .timeline-card-main {
  923. min-width: 0;
  924. flex: 1 1 auto;
  925. }
  926. .timeline-card-title {
  927. color: #111827;
  928. font-size: 14px;
  929. font-weight: 600;
  930. line-height: 22px;
  931. }
  932. .timeline-card-subtitle {
  933. margin-top: 4px;
  934. color: #6b7280;
  935. font-size: 12px;
  936. line-height: 18px;
  937. word-break: break-all;
  938. }
  939. .timeline-card-action {
  940. flex: 0 0 auto;
  941. padding: 4px 10px;
  942. border-radius: 999px;
  943. border: 1px solid #dbe3ee;
  944. background: #f8fafc;
  945. color: #475569;
  946. font-size: 12px;
  947. font-weight: 600;
  948. line-height: 18px;
  949. white-space: nowrap;
  950. }
  951. .timeline-tags {
  952. display: flex;
  953. gap: 10px;
  954. margin-top: 14px;
  955. flex-wrap: wrap;
  956. }
  957. .timeline-tag {
  958. padding: 8px 12px;
  959. border-radius: 999px;
  960. font-size: 12px;
  961. font-weight: 600;
  962. border: 1px solid #e5e7eb;
  963. &.count {
  964. background: #fffaf5;
  965. border-color: #f5d7bd;
  966. color: #c2410c;
  967. }
  968. &.amount {
  969. background: #f8fbff;
  970. border-color: #d4e3fb;
  971. color: #1d4ed8;
  972. }
  973. }
  974. @media screen and (max-width: 900px) {
  975. .content-panel {
  976. flex-direction: column;
  977. }
  978. .left-panel {
  979. flex-basis: auto;
  980. max-width: none;
  981. }
  982. }
  983. @media screen and (max-width: 992px) {
  984. .filter-input,
  985. .filter-date {
  986. width: 100%;
  987. }
  988. .timeline-summary {
  989. grid-template-columns: 1fr;
  990. }
  991. .timeline-wrapper {
  992. padding-left: 96px;
  993. padding-right: 16px;
  994. }
  995. .timeline-line {
  996. left: 96px;
  997. }
  998. .timeline-date-block {
  999. left: -78px;
  1000. width: 56px;
  1001. }
  1002. .timeline-card {
  1003. margin-left: 14px;
  1004. padding: 14px;
  1005. }
  1006. .timeline-card-header {
  1007. flex-direction: column;
  1008. align-items: flex-start;
  1009. }
  1010. .pagination-bar {
  1011. justify-content: flex-start;
  1012. }
  1013. }
  1014. }
  1015. </style>