index.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834
  1. <template>
  2. <div ref="monthRegularPageRef" :class="prefixCls" class="month-regular-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="qj-filter-head">
  9. <div class="qj-card-title">查询条件</div>
  10. <div class="filter-actions">
  11. <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
  12. <Button @click="handleReset">重置</Button>
  13. </div>
  14. </div>
  15. <div class="qj-filter-grid">
  16. <div class="filter-item">
  17. <span class="filter-label">选择对象:</span>
  18. <Select
  19. v-model:value="filters.target"
  20. mode="multiple"
  21. allow-clear
  22. max-tag-count="responsive"
  23. class="filter-input"
  24. :options="targetOptions"
  25. placeholder="全部"
  26. />
  27. </div>
  28. <div class="filter-item">
  29. <span class="filter-label">对方姓名:</span>
  30. <Input v-model:value="filters.otherName" class="filter-input" placeholder="请输入对方姓名" allow-clear />
  31. </div>
  32. <div class="filter-item">
  33. <span class="filter-label">对方卡号:</span>
  34. <Input v-model:value="filters.otherCardNo" class="filter-input" placeholder="请输入对方卡号" allow-clear />
  35. </div>
  36. <div class="filter-item">
  37. <span class="filter-label">连续转账月数:</span>
  38. <Input v-model:value="filters.monthValue" class="filter-input" :status="monthValueStatus" />
  39. </div>
  40. <div class="filter-item">
  41. <span class="filter-label">金额范围:</span>
  42. <Input v-model:value="filters.amountMin" class="filter-mini" placeholder="" :status="amountMinStatus" />
  43. <span class="filter-label">至</span>
  44. <Input v-model:value="filters.amountMax" class="filter-mini" placeholder="" :status="amountMaxStatus" />
  45. </div>
  46. </div>
  47. </div>
  48. <div class="content-panel">
  49. <div class="summary-panel">
  50. <div class="panel-header">
  51. <div>
  52. <div class="panel-title">每月规律交易对象汇总</div>
  53. <div class="panel-desc">找出与对象每月规律性转账的人员或卡号,点击列表行可查看每月交易趋势。</div>
  54. </div>
  55. </div>
  56. <div v-if="!hasSearched" class="query-state-panel">
  57. <EmptyState
  58. variant="query"
  59. title="请点击查询"
  60. description="设置条件精细查询,也可点击查询展示所有信息"
  61. min-height="100%"
  62. description-width="min(100%, 420px)"
  63. />
  64. </div>
  65. <div v-else class="table-panel">
  66. <div class="table-wrapper">
  67. <BasicTable
  68. class="month-regular-summary-table"
  69. :columns="columns"
  70. :data-source="tableData"
  71. :pagination="false"
  72. :show-table-setting="false"
  73. :can-resize="true"
  74. :is-can-resize-parent="true"
  75. :resize-height-offset="32"
  76. :rowClassName="rowClassName"
  77. row-key="id"
  78. @register="handleMonthRegularTableRegister"
  79. @row-click="handleRowClick"
  80. />
  81. </div>
  82. </div>
  83. <div v-if="hasSearched" class="pagination-bar">
  84. <AnalysisPagination
  85. :total="total"
  86. :current="pagination.current"
  87. :page-size="pagination.pageSize"
  88. :page-size-options="pageSizeOptions"
  89. :loading="queryLoading"
  90. @change="handlePaginationChange"
  91. />
  92. </div>
  93. </div>
  94. <div class="trend-panel">
  95. <div class="panel-header">
  96. <div>
  97. <div class="panel-title">每月转账趋势</div>
  98. <div class="panel-desc">查看当前选中对象的每月交易趋势,点击柱状图可查看当月交易记录。</div>
  99. </div>
  100. </div>
  101. <div class="trend-chart-shell">
  102. <div v-show="!trendVisible" class="trend-empty">
  103. <EmptyState variant="compact" title="暂无趋势数据" :description="trendEmptyDescription" :min-height="220" />
  104. </div>
  105. <div v-show="trendVisible" ref="chartRef" class="trend-chart"></div>
  106. </div>
  107. </div>
  108. </div>
  109. <TransRecordModal v-model:open="recordModalOpen" :base-query="recordModalBaseQuery" />
  110. </div>
  111. </template>
  112. <script lang="ts" setup name="ViewsMonthRegularIndex">
  113. import { computed, nextTick, onMounted, onUnmounted, ref, Ref } from 'vue';
  114. import { Select, Input, Button } from 'ant-design-vue';
  115. import AnalysisPagination from '@/core/components/AnalysisPagination/src/AnalysisPagination.vue';
  116. import dayjs from 'dayjs';
  117. import { BasicTable } from '@/core/components/Table';
  118. import { createTableContext } from '@/core/components/Table/src/hooks/useTableContext';
  119. import { EmptyState } from '@/core/components/EmptyState';
  120. import { useDesign } from '@/core/hooks/web/useDesign';
  121. import { useECharts } from '@/core/hooks/web/useECharts';
  122. import { useAnalysisRefreshPrompt } from '@/core/hooks/web/useAnalysisRefreshPrompt';
  123. import TransRecordModal from '../components/TransRecordModal.vue';
  124. import { useTableSkill } from '@/core/hooks/web/useTableSkill';
  125. import { useMessage } from '@/core/hooks/web/useMessage';
  126. import { pickDetailValue } from '@/core/utils/detailQuery';
  127. import componentSetting from '@/core/settings/componentSetting';
  128. import { statFirstTransContinuous, statSecondTransContinuous } from '@/trans/api/transApi';
  129. import { getPersonOptions } from '@/person/api/personApi';
  130. import type { EChartsOption } from 'echarts';
  131. import type { TransContinuousDTO } from '@/trans/api/types/dto';
  132. import type { TransContinuousQuery, TransRecordQuery } from '@/trans/api/types/query';
  133. const { prefixCls } = useDesign('month-regular');
  134. const { queryLoading, runWithQueryLoading } = useTableSkill();
  135. const { createMessage } = useMessage();
  136. const monthRegularPageRef = ref<HTMLElement | null>(null);
  137. const monthRegularTableAction = ref<any>(null);
  138. const createDefaultFilters = () => ({
  139. target: [] as string[],
  140. otherName: '',
  141. otherCardNo: '',
  142. monthValue: '2',
  143. amountMin: '1000',
  144. amountMax: '10000',
  145. });
  146. const filters = ref(createDefaultFilters());
  147. const pagination = ref({
  148. current: 1,
  149. pageSize: componentSetting.table.defaultPageSize,
  150. });
  151. const total = ref(0);
  152. const jumpPage = ref('1');
  153. const tableData = ref<any[]>([]);
  154. const hasSearched = ref(false);
  155. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  156. refresh: () => fetchTable(),
  157. hasData: () => hasSearched.value && tableData.value.length > 0,
  158. });
  159. const recordModalOpen = ref(false);
  160. const recordModalBaseQuery = ref<Partial<TransRecordQuery>>({});
  161. const lastSearchQuery = ref<TransContinuousQuery | null>(null);
  162. const trendRawList = ref<TransContinuousDTO[]>([]);
  163. const trendVisible = ref(false);
  164. const selectedRow = ref<any | null>(null);
  165. const DEFAULT_SORT_KEY = 'pairTotalTrans';
  166. const DEFAULT_SORT_ORDER = 'DESC' as const;
  167. const targetOptions = ref<Array<{ label: string; value: string }>>([]);
  168. const positiveIntegerPattern = /^[1-9]\d*$/;
  169. const normalizeSelectedTargets = (value?: string[] | string | null) => {
  170. if (Array.isArray(value)) {
  171. const list = value.map((item) => String(item).trim()).filter(Boolean);
  172. return list.length ? Array.from(new Set(list)) : [];
  173. }
  174. if (!value) return [];
  175. const list = String(value)
  176. .split(',')
  177. .map((item) => item.trim())
  178. .filter(Boolean);
  179. return list.length ? Array.from(new Set(list)) : [];
  180. };
  181. const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
  182. label: value,
  183. value,
  184. }));
  185. const totalPages = computed(() => {
  186. if (!total.value) return 1;
  187. return Math.max(1, Math.ceil(total.value / pagination.value.pageSize));
  188. });
  189. const normalizedMonthValue = computed(() => String(filters.value.monthValue ?? '').trim());
  190. const normalizedAmountMin = computed(() => String(filters.value.amountMin ?? '').trim());
  191. const normalizedAmountMax = computed(() => String(filters.value.amountMax ?? '').trim());
  192. const monthValueStatus = computed<'error' | undefined>(() => {
  193. if (!normalizedMonthValue.value) return undefined;
  194. return positiveIntegerPattern.test(normalizedMonthValue.value) ? undefined : 'error';
  195. });
  196. const amountMinStatus = computed<'error' | undefined>(() => {
  197. if (!normalizedAmountMin.value) return undefined;
  198. return normalizePositiveAmountText(normalizedAmountMin.value) === null ? 'error' : undefined;
  199. });
  200. const amountMaxStatus = computed<'error' | undefined>(() => {
  201. if (!normalizedAmountMax.value) return undefined;
  202. return normalizePositiveAmountText(normalizedAmountMax.value) === null ? 'error' : undefined;
  203. });
  204. const trendEmptyDescription = computed(() => {
  205. if (!tableData.value.length) {
  206. return '当前筛选条件下暂无定期规律记录,请调整筛选条件后重试';
  207. }
  208. if (selectedRow.value) {
  209. return '当前选中记录暂无每月转账趋势';
  210. }
  211. return '请选择上方列表中的一行记录查看趋势';
  212. });
  213. const columns = [
  214. { title: '持续月数', dataIndex: 'months', key: 'months', width: 120 },
  215. { title: '本方姓名', dataIndex: 'selfName', key: 'selfName', width: 140 },
  216. { title: '对方姓名', dataIndex: 'otherName', key: 'otherName', width: 140 },
  217. { title: '对方卡号', dataIndex: 'otherCard', key: 'otherCard', width: 200 },
  218. { title: '共交易次数', dataIndex: 'count', key: 'count', width: 140 },
  219. ];
  220. const handleMonthRegularTableRegister = (action: any) => {
  221. monthRegularTableAction.value = action;
  222. };
  223. const getMonthRegularTableColumns = (...args: any[]) => monthRegularTableAction.value?.getColumns?.(...args) || [];
  224. createTableContext({
  225. wrapRef: monthRegularPageRef,
  226. getBindValues: computed(() => ({
  227. columns,
  228. showIndexColumn: true,
  229. rowSelection: undefined,
  230. isTreeTable: false,
  231. })),
  232. getDefaultRowSelection: () => monthRegularTableAction.value?.getDefaultRowSelection?.(),
  233. getColumns: getMonthRegularTableColumns,
  234. getCacheColumns: () => monthRegularTableAction.value?.getCacheColumns?.() || [],
  235. setColumns: (nextColumns: any) => monthRegularTableAction.value?.setColumns?.(nextColumns),
  236. setProps: (props: any) => monthRegularTableAction.value?.setProps?.(props),
  237. } as any);
  238. const normalizeList = (resp: any): TransContinuousDTO[] => {
  239. if (Array.isArray(resp)) return resp;
  240. if (Array.isArray(resp?.list)) return resp.list;
  241. if (Array.isArray(resp?.data)) return resp.data;
  242. if (Array.isArray(resp?.data?.list)) return resp.data.list;
  243. if (Array.isArray(resp?.records)) return resp.records;
  244. if (Array.isArray(resp?.data?.records)) return resp.data.records;
  245. return [];
  246. };
  247. const normalizeTotal = (resp: any, listLen: number) => {
  248. const t = resp?.total ?? resp?.data?.total ?? resp?.count ?? resp?.data?.count ?? resp?.data?.records?.length;
  249. return Number.isFinite(Number(t)) ? Number(t) : listLen;
  250. };
  251. const normalizeMonthDate = (monthLike?: string, isEnd = false) => {
  252. if (!monthLike) return undefined;
  253. const normalized = String(monthLike).slice(0, 7);
  254. if (!/^\d{4}-\d{2}$/.test(normalized)) return undefined;
  255. const date = dayjs(`${normalized}-01`);
  256. return isEnd ? date.endOf('month').format('YYYY-MM-DD') : date.startOf('month').format('YYYY-MM-DD');
  257. };
  258. const mapRow = (item: TransContinuousDTO, index: number) => ({
  259. id: (pagination.value.current - 1) * pagination.value.pageSize + index + 1,
  260. months: `${item.pairContMonthCount ?? 0}个月`,
  261. selfName: item.personName || '-',
  262. otherName: item.otherName || '-',
  263. otherCard: item.otherCardNo || '-',
  264. count: String(item.pairTotalTrans ?? 0),
  265. countValue: Number(item.pairTotalTrans ?? 0) || 0,
  266. raw: item,
  267. });
  268. const sortSummaryList = (list: TransContinuousDTO[]) =>
  269. [...list].sort((left, right) => {
  270. const leftCount = Number(left.pairTotalTrans ?? 0) || 0;
  271. const rightCount = Number(right.pairTotalTrans ?? 0) || 0;
  272. if (rightCount !== leftCount) return rightCount - leftCount;
  273. const leftMonthCount = Number(left.pairContMonthCount ?? 0) || 0;
  274. const rightMonthCount = Number(right.pairContMonthCount ?? 0) || 0;
  275. if (rightMonthCount !== leftMonthCount) return rightMonthCount - leftMonthCount;
  276. return String(left.otherName || left.otherCardNos || '').localeCompare(
  277. String(right.otherName || right.otherCardNos || ''),
  278. );
  279. });
  280. const normalizePositiveIntegerInput = (value?: string) => {
  281. const next = String(value ?? '').trim();
  282. if (!next) return undefined;
  283. if (!positiveIntegerPattern.test(next)) return null;
  284. const parsed = Number(next);
  285. return Number.isFinite(parsed) ? parsed : null;
  286. };
  287. const normalizePositiveAmountText = (value?: string): string | undefined | null => {
  288. const next = String(value ?? '').trim();
  289. const amountForCheck = next.replace(/,/g, '');
  290. if (!amountForCheck) return undefined;
  291. if (!positiveIntegerPattern.test(amountForCheck)) return null;
  292. return next;
  293. };
  294. const getActiveSearchQuery = () => {
  295. if (lastSearchQuery.value) return { ...lastSearchQuery.value };
  296. return buildQuery();
  297. };
  298. const buildRecordModalBaseQuery = (item?: Partial<TransContinuousDTO>): Partial<TransRecordQuery> | null => {
  299. const query = getActiveSearchQuery();
  300. if (!query) return null;
  301. return {
  302. personName: pickDetailValue(
  303. item?.personName,
  304. selectedRow.value?.raw?.personName,
  305. normalizeSelectedTargets(filters.value.target)[0],
  306. ),
  307. otherName: pickDetailValue(item?.otherName, selectedRow.value?.raw?.otherName, query.otherName),
  308. otherCardNo: pickDetailValue(
  309. item?.otherCardNo,
  310. selectedRow.value?.raw?.otherCardNo,
  311. selectedRow.value?.raw?.otherCardNos,
  312. query.otherCardNo,
  313. ),
  314. personCardNo: pickDetailValue(item?.personCardNo),
  315. startDate: normalizeMonthDate(item?.continuousStartMonth, false),
  316. endDate: normalizeMonthDate(item?.continuousEndMonth, true),
  317. minTransMoney: query.minTransMoney,
  318. maxTransMoney: query.maxTransMoney,
  319. };
  320. };
  321. const buildQuery = (): TransContinuousQuery | null => {
  322. const monthVal = normalizePositiveIntegerInput(filters.value.monthValue);
  323. const minTransMoney = normalizePositiveAmountText(filters.value.amountMin);
  324. const maxTransMoney = normalizePositiveAmountText(filters.value.amountMax);
  325. if (monthVal === null || monthVal === undefined) {
  326. createMessage.warning('连续转账月数请输入大于 0 的整数');
  327. return null;
  328. }
  329. if (minTransMoney === null || maxTransMoney === null) {
  330. createMessage.warning('金额范围请输入大于 0 的整数');
  331. return null;
  332. }
  333. if (
  334. minTransMoney !== undefined &&
  335. maxTransMoney !== undefined &&
  336. Number(minTransMoney.replace(/,/g, '')) > Number(maxTransMoney.replace(/,/g, ''))
  337. ) {
  338. createMessage.warning('金额范围起始值不能大于结束值');
  339. return null;
  340. }
  341. const query: TransContinuousQuery = {
  342. page: pagination.value.current,
  343. limit: pagination.value.pageSize,
  344. monthVal,
  345. minTransMoney,
  346. maxTransMoney,
  347. orderKey: DEFAULT_SORT_KEY,
  348. sort: DEFAULT_SORT_ORDER,
  349. };
  350. const personNames = normalizeSelectedTargets(filters.value.target);
  351. const otherName = String(filters.value.otherName ?? '').trim();
  352. const otherCardNo = String(filters.value.otherCardNo ?? '').trim();
  353. query.personNames = personNames.length ? personNames : undefined;
  354. query.otherName = otherName || undefined;
  355. query.otherCardNo = otherCardNo || undefined;
  356. return query;
  357. };
  358. const fetchTable = async () => {
  359. const query = buildQuery();
  360. if (!query) return;
  361. await runWithQueryLoading(async () => {
  362. const resp = await statFirstTransContinuous(query);
  363. const list = sortSummaryList(normalizeList(resp));
  364. tableData.value = list.map(mapRow);
  365. total.value = normalizeTotal(resp, list.length);
  366. hasSearched.value = true;
  367. lastSearchQuery.value = { ...query };
  368. syncAnalysisDataRefreshState(list.length > 0);
  369. const current = Number((resp as any)?.data?.current || pagination.value.current);
  370. const size = Number((resp as any)?.data?.size || pagination.value.pageSize);
  371. if (Number.isFinite(current) && current > 0) pagination.value.current = current;
  372. if (Number.isFinite(size) && size > 0) pagination.value.pageSize = size;
  373. jumpPage.value = String(pagination.value.current);
  374. if (tableData.value.length) {
  375. selectedRow.value = tableData.value[0];
  376. await handleViewDetail(selectedRow.value, false);
  377. } else {
  378. selectedRow.value = null;
  379. trendRawList.value = [];
  380. trendVisible.value = false;
  381. clearTrendChart();
  382. }
  383. });
  384. };
  385. const chartRef = ref<HTMLDivElement | null>(null);
  386. const { setOptions, getInstance } = useECharts(chartRef as Ref<HTMLDivElement>);
  387. let bindChartClickTimer: ReturnType<typeof setTimeout> | null = null;
  388. const clearChartClickTimer = () => {
  389. if (bindChartClickTimer) {
  390. clearTimeout(bindChartClickTimer);
  391. bindChartClickTimer = null;
  392. }
  393. };
  394. const clearTrendChart = () => {
  395. clearChartClickTimer();
  396. const instance = getInstance();
  397. instance?.off('click');
  398. instance?.clear();
  399. };
  400. const bindChartClick = (retry = 0) => {
  401. const instance = getInstance();
  402. if (!instance) {
  403. if (retry < 6) {
  404. clearChartClickTimer();
  405. bindChartClickTimer = setTimeout(() => bindChartClick(retry + 1), 80);
  406. }
  407. return;
  408. }
  409. instance.off('click');
  410. instance.on('click', (params: any) => {
  411. const idx = Number(params?.dataIndex);
  412. if (!Number.isInteger(idx) || idx < 0 || idx >= trendRawList.value.length) return;
  413. const item = trendRawList.value[idx];
  414. const baseQuery = buildRecordModalBaseQuery(item);
  415. if (!baseQuery) return;
  416. recordModalBaseQuery.value = baseQuery;
  417. recordModalOpen.value = true;
  418. });
  419. };
  420. const renderTrendChart = (list: TransContinuousDTO[]) => {
  421. trendRawList.value = list;
  422. if (!list.length) {
  423. trendVisible.value = false;
  424. clearTrendChart();
  425. return;
  426. }
  427. const labels = list.map((item) => {
  428. const startMonth = String(item.continuousStartMonth || '')
  429. .slice(0, 7)
  430. .replace('-', '.');
  431. const endMonth = String(item.continuousEndMonth || '')
  432. .slice(0, 7)
  433. .replace('-', '.');
  434. return `${startMonth} ~ ${endMonth}`;
  435. });
  436. const options: EChartsOption = {
  437. tooltip: {
  438. trigger: 'axis',
  439. confine: true,
  440. // 外层透明:只保留 formatter 里的白卡,避免默认灰边 + 10px 内边距形成双层框
  441. backgroundColor: 'transparent',
  442. borderWidth: 0,
  443. borderColor: 'transparent',
  444. padding: 0,
  445. shadowBlur: 0,
  446. shadowColor: 'transparent',
  447. transitionDuration: 0.15,
  448. extraCssText: 'max-width: 320px; white-space: normal; word-break: break-word;',
  449. axisPointer: {
  450. type: 'shadow',
  451. shadowStyle: { color: 'rgba(42, 80, 236, 0.06)' },
  452. // 提示卡里已经有区间文字,不再显示坐标轴上的深色小标签
  453. label: { show: false },
  454. },
  455. formatter: (params: any) => {
  456. const index = params[0].dataIndex;
  457. const item = list[index];
  458. return `
  459. <div style="padding: 10px 12px; min-width: 176px; background: #fff; border: 1px solid #e7ebf3; border-radius: 10px; box-shadow: 0 8px 24px rgba(16,24,40,0.12);">
  460. <div style="font-size: 12px; font-weight: 600; color: #111827; margin-bottom: 6px;">${labels[index]}</div>
  461. <div style="font-size: 12px; line-height: 20px; color: #5b6577;">交易次数:<b style="color:#111827;">${item.pairTotalTrans || 0}</b></div>
  462. <div style="font-size: 12px; line-height: 20px; color: #5b6577;">交易总金额:<b style="color:#111827;">${item.pairTotalAmount || 0}</b></div>
  463. <div style="font-size: 12px; line-height: 20px; color: #5b6577;">持续月数:<b style="color:#111827;">${item.pairContMonthCount || 0}</b></div>
  464. </div>
  465. `;
  466. },
  467. },
  468. xAxis: {
  469. type: 'category',
  470. data: labels,
  471. axisTick: { show: false },
  472. axisLine: { lineStyle: { color: '#e7ebf3' } },
  473. axisLabel: { color: '#6b7280', fontSize: 12 },
  474. },
  475. yAxis: {
  476. type: 'value',
  477. axisTick: { show: false },
  478. axisLabel: { color: '#6b7280', fontSize: 12 },
  479. splitLine: { lineStyle: { color: '#eef2f7', type: 'dashed' } },
  480. },
  481. grid: {
  482. left: '2%',
  483. right: '2%',
  484. top: '12%',
  485. bottom: '8%',
  486. outerBoundsMode: 'same',
  487. outerBoundsContain: 'axisLabel',
  488. },
  489. series: [
  490. {
  491. data: list.length ? list.map((item) => Number(item.pairTotalTrans || 0)) : [0],
  492. type: 'bar',
  493. barWidth: 18,
  494. cursor: 'pointer',
  495. itemStyle: { color: '#2a50ec', borderRadius: [6, 6, 0, 0] },
  496. emphasis: {
  497. itemStyle: {
  498. color: '#4b6bf0',
  499. shadowBlur: 12,
  500. shadowColor: 'rgba(42, 80, 236, 0.28)',
  501. },
  502. },
  503. label: {
  504. show: true,
  505. position: 'top',
  506. color: '#1f2937',
  507. fontSize: 12,
  508. fontWeight: 600,
  509. },
  510. },
  511. ],
  512. };
  513. setOptions(options);
  514. clearChartClickTimer();
  515. bindChartClickTimer = setTimeout(() => bindChartClick(), 80);
  516. };
  517. const handleViewDetail = async (record: any, showMessage = true) => {
  518. const query = getActiveSearchQuery();
  519. if (!query) return;
  520. try {
  521. selectedRow.value = record;
  522. const personName = record?.raw?.personName || normalizeSelectedTargets(filters.value.target)[0];
  523. const selectedOtherName = pickDetailValue(record?.raw?.otherName, query.otherName);
  524. const selectedOtherCardNo = pickDetailValue(
  525. record?.raw?.otherCardNo,
  526. record?.raw?.otherCardNos,
  527. query.otherCardNo,
  528. );
  529. const resp = await statSecondTransContinuous({
  530. ...query,
  531. page: 1,
  532. limit: 200,
  533. personName,
  534. personNames: personName ? [personName] : undefined,
  535. otherName: selectedOtherName,
  536. otherCardNo: selectedOtherCardNo,
  537. });
  538. const list = normalizeList(resp);
  539. trendRawList.value = list.map((item) => ({
  540. ...item,
  541. personName: record?.raw?.personName || item.personName,
  542. otherName: selectedOtherName || item.otherName,
  543. otherCardNo: selectedOtherCardNo || item.otherCardNo,
  544. }));
  545. if (!trendRawList.value.length) {
  546. trendVisible.value = false;
  547. clearTrendChart();
  548. return;
  549. }
  550. const wasTrendHidden = !trendVisible.value;
  551. trendVisible.value = true;
  552. if (wasTrendHidden) {
  553. await nextTick();
  554. }
  555. renderTrendChart(trendRawList.value);
  556. if (showMessage) {
  557. createMessage.success('已更新每月转账趋势,点击柱状图可查看交易记录');
  558. }
  559. } catch (error) {
  560. trendRawList.value = [];
  561. trendVisible.value = false;
  562. clearTrendChart();
  563. createMessage.warning('连续交易第二层接口调用失败');
  564. }
  565. };
  566. const handleRowClick = async (record: any) => {
  567. await handleViewDetail(record, false);
  568. };
  569. const rowClassName = (record: any) => {
  570. return selectedRow.value && selectedRow.value.id === record.id ? 'summary-row-selected' : '';
  571. };
  572. const handleSearch = async () => {
  573. pagination.value.current = 1;
  574. await fetchTable();
  575. };
  576. const handleReset = async () => {
  577. filters.value = createDefaultFilters();
  578. pagination.value.current = 1;
  579. await fetchTable();
  580. };
  581. const handlePrevPage = async () => {
  582. if (queryLoading.value || pagination.value.current <= 1) return;
  583. pagination.value.current -= 1;
  584. await fetchTable();
  585. };
  586. const handleNextPage = async () => {
  587. if (queryLoading.value || pagination.value.current >= totalPages.value) return;
  588. pagination.value.current += 1;
  589. await fetchTable();
  590. };
  591. const handlePaginationChange = async (page: number, pageSize: number) => {
  592. if (queryLoading.value) return;
  593. if (pageSize !== pagination.value.pageSize) {
  594. pagination.value.pageSize = pageSize;
  595. await handlePageSizeChange();
  596. return;
  597. }
  598. if (page === pagination.value.current - 1) {
  599. await handlePrevPage();
  600. return;
  601. }
  602. if (page === pagination.value.current + 1) {
  603. await handleNextPage();
  604. return;
  605. }
  606. if (page === pagination.value.current) return;
  607. jumpPage.value = String(page);
  608. await handleJumpPage();
  609. };
  610. const handlePageSizeChange = async () => {
  611. pagination.value.current = 1;
  612. await fetchTable();
  613. };
  614. const handleJumpPage = async () => {
  615. const next = Number(jumpPage.value);
  616. if (!Number.isInteger(next) || next < 1) {
  617. createMessage.warning('请输入正确页码');
  618. jumpPage.value = String(pagination.value.current);
  619. return;
  620. }
  621. pagination.value.current = Math.min(next, totalPages.value);
  622. await fetchTable();
  623. };
  624. const fetchTargetOptions = async () => {
  625. try {
  626. targetOptions.value = await getPersonOptions(false);
  627. } catch (error) {
  628. targetOptions.value = [];
  629. }
  630. };
  631. onMounted(async () => {
  632. await fetchTargetOptions();
  633. });
  634. onUnmounted(() => {
  635. clearChartClickTimer();
  636. const instance = getInstance();
  637. instance?.off('click');
  638. });
  639. </script>
  640. <style lang="less">
  641. @import '../styles/trans-analysis-table-ui.less';
  642. @import (reference) '../styles/analysis-card-ui.less';
  643. @prefix-cls: ~'jeesite-month-regular';
  644. .@{prefix-cls}.month-regular-page {
  645. .analysis-page-frame();
  646. .filter-input {
  647. width: 180px;
  648. }
  649. .filter-mini {
  650. width: 88px;
  651. }
  652. .filter-actions {
  653. display: flex;
  654. align-items: center;
  655. gap: 8px;
  656. margin-left: auto;
  657. }
  658. .content-panel {
  659. display: flex;
  660. flex-direction: column;
  661. gap: 2px;
  662. }
  663. .summary-panel,
  664. .trend-panel {
  665. .analysis-card-shell();
  666. }
  667. .summary-panel {
  668. flex: 0 0 calc((100% - 2px) * 0.56);
  669. max-height: calc((100% - 2px) * 0.56);
  670. }
  671. .trend-panel {
  672. flex: 1 1 calc((100% - 2px) * 0.44);
  673. max-height: calc((100% - 2px) * 0.44);
  674. }
  675. .summary-panel .month-regular-summary-table .ant-table-wrapper {
  676. padding: 0;
  677. }
  678. .query-state-panel {
  679. flex: 1 1 auto;
  680. min-height: 0;
  681. display: flex;
  682. align-items: center;
  683. justify-content: center;
  684. padding: 12px 0 0;
  685. }
  686. .table-wrapper .month-regular-summary-table > div:first-child {
  687. display: none;
  688. }
  689. .table-wrapper .month-regular-summary-table > .ant-table-wrapper {
  690. flex: 1 1 auto;
  691. min-height: 0;
  692. height: auto;
  693. }
  694. .trend-chart-shell {
  695. flex: 1 1 auto;
  696. min-height: 0;
  697. width: 100%;
  698. display: flex;
  699. flex-direction: column;
  700. overflow: hidden;
  701. }
  702. .trend-empty {
  703. height: 100%;
  704. display: flex;
  705. align-items: center;
  706. justify-content: center;
  707. border: 1px dashed #d7e3f4;
  708. border-radius: 12px;
  709. background: #fafcff;
  710. }
  711. .trend-chart {
  712. flex: 1 1 auto;
  713. min-height: 0;
  714. width: 100%;
  715. }
  716. :deep(.ant-select-selector),
  717. :deep(.ant-input),
  718. :deep(.ant-btn) {
  719. border-radius: 8px;
  720. }
  721. @media screen and (max-width: 992px) {
  722. .summary-panel,
  723. .trend-panel {
  724. flex: 1 1 auto;
  725. max-height: none;
  726. }
  727. .filter-actions {
  728. width: 100%;
  729. margin-left: 0;
  730. }
  731. .filter-input {
  732. width: 100%;
  733. }
  734. }
  735. }
  736. /* 统一卡片视觉:对齐「持续联系 / 夜间联系 / 敏感日期联系」 */
  737. .@{prefix-cls}.month-regular-page {
  738. .analysis-card-ui();
  739. }
  740. </style>