TransRecordModal.vue 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030
  1. <template>
  2. <QueryTableModal
  3. :open="open"
  4. :title="title"
  5. :width="width"
  6. :z-index="zIndex"
  7. :loading="loading"
  8. :filters="filters"
  9. :filter-schema="visibleFilterSchema"
  10. :columns="visibleColumns"
  11. :action-column="actionColumn"
  12. :data-source="tableData"
  13. :row-selection="rowSelection"
  14. :pagination="pagination"
  15. :total-pages="totalPages"
  16. :page-size-options="pageSizeOptions"
  17. :show-table-setting="true"
  18. :table-setting="{ size: true, setting: true, redo: false, fullScreen: false }"
  19. :can-fullscreen="true"
  20. wrap-class-name="trans-record-modal"
  21. @update:open="(v) => emit('update:open', v)"
  22. @search="handleSearch"
  23. @reset="handleReset"
  24. @prev-page="handlePrevPage"
  25. @next-page="handleNextPage"
  26. @page-size-change="handlePageSizeChange"
  27. @page-change="handlePageChange"
  28. @sort-change="handleSortChange"
  29. >
  30. <template v-if="traceable" #searchActions>
  31. <div class="trans-record-modal__trace-bar">
  32. <span class="trans-record-modal__trace-label">追溯方向:</span>
  33. <RadioGroup
  34. :value="traceDirection"
  35. @update:value="(value) => emit('update:traceDirection', value as TraceDirection)"
  36. >
  37. <Radio value="forward">向前</Radio>
  38. <Radio value="backward">向后</Radio>
  39. </RadioGroup>
  40. <Button type="primary" :loading="traceLoading" :disabled="!selectedRows.length" @click="handleTrace">
  41. 追溯
  42. </Button>
  43. </div>
  44. </template>
  45. </QueryTableModal>
  46. <TransRecordModal
  47. v-if="showDetailAction"
  48. v-model:open="detailModalOpen"
  49. title="交易记录详情"
  50. :z-index="detailModalZIndex"
  51. :base-query="detailBaseQuery"
  52. @jump="handleDetailModalJump"
  53. />
  54. </template>
  55. <script lang="ts" setup>
  56. import { computed, nextTick, reactive, ref, watch, h } from 'vue';
  57. import { Button, Radio } from 'ant-design-vue';
  58. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  59. import {
  60. getOpenOfficeFileErrorMessage,
  61. isOpenOfficeFileSuccess,
  62. openOfficeFileById,
  63. resolveOfficeFileId,
  64. } from '@jeesite/core/utils/openOfficeFile';
  65. import QueryTableModal from '@jeesite/case/views/dm/components/QueryTableModal.vue';
  66. import componentSetting from '@jeesite/core/settings/componentSetting';
  67. import {
  68. COMPARE_OPERATOR_OPTIONS,
  69. DEFAULT_COMPARE_OPERATOR,
  70. normalizeCompareOperator,
  71. } from '@jeesite/core/utils/compareOperator';
  72. import { maskCertNo } from '@jeesite/core/utils/sensitive';
  73. import { useTimeSeriesJump } from '@jeesite/otg/hooks/useTimeSeriesJump';
  74. import { getTransRecord } from '@jeesite/trans/api/transApi';
  75. import type { TransRecord } from '@jeesite/trans/api/types/dto';
  76. import type { TransRecordQuery } from '@jeesite/trans/api/types/query';
  77. import type { CompareOperator } from '@jeesite/core/utils/compareOperator';
  78. type TraceDirection = 'forward' | 'backward';
  79. type TransRecordModalQuery = Partial<TransRecordQuery> & Record<string, any>;
  80. type CompareOperatorOption = { label: string; value: CompareOperator };
  81. interface TracePayload {
  82. direction: TraceDirection;
  83. records: TransRecord[];
  84. }
  85. const RadioGroup = Radio.Group;
  86. const props = withDefaults(
  87. defineProps<{
  88. open: boolean;
  89. title?: string;
  90. width?: number;
  91. zIndex?: number;
  92. baseQuery?: TransRecordModalQuery;
  93. fetchApi?: (query: TransRecordModalQuery) => Promise<any>;
  94. hideTechnicalIdColumns?: boolean;
  95. filterFields?: string[];
  96. transAmountOperatorOptions?: CompareOperatorOption[];
  97. traceable?: boolean;
  98. traceDirection?: TraceDirection;
  99. traceLoading?: boolean;
  100. showDetailAction?: boolean;
  101. }>(),
  102. {
  103. title: '交易记录',
  104. width: 1240,
  105. zIndex: undefined,
  106. baseQuery: () => ({}),
  107. hideTechnicalIdColumns: true,
  108. filterFields: () => [
  109. 'personName',
  110. 'personCardNo',
  111. 'otherName',
  112. 'otherCardNo',
  113. 'debitCreditFlag',
  114. 'startDate',
  115. 'endDate',
  116. 'transAmount',
  117. ],
  118. transAmountOperatorOptions: () => [...COMPARE_OPERATOR_OPTIONS],
  119. traceable: false,
  120. traceDirection: 'backward',
  121. traceLoading: false,
  122. showDetailAction: false,
  123. },
  124. );
  125. const emit = defineEmits<{
  126. (e: 'update:open', value: boolean): void;
  127. (e: 'update:traceDirection', value: TraceDirection): void;
  128. (e: 'trace', payload: TracePayload): void;
  129. (e: 'jump'): void;
  130. (e: 'error', message: string): void;
  131. }>();
  132. const loading = ref(false);
  133. const tableData = ref<any[]>([]);
  134. const selectedRowKeys = ref<Array<string | number>>([]);
  135. const selectedRows = ref<TransRecord[]>([]);
  136. const detailModalOpen = ref(false);
  137. const detailBaseQuery = ref<TransRecordModalQuery>({});
  138. const { jumping, jumpToBehaviorSpacetime } = useTimeSeriesJump();
  139. const { createMessage } = useMessage();
  140. let fetchRequestSequence = 0;
  141. let activeFetchQueryKey = '';
  142. let pendingFetchRequested = false;
  143. let pendingFetchQueryKey = '';
  144. const filters = reactive({
  145. personName: '',
  146. personCardNo: '',
  147. otherName: '',
  148. otherCardNo: '',
  149. debitCreditFlag: '',
  150. startDate: '',
  151. endDate: '',
  152. transAmount: '',
  153. transAmountSymbol: DEFAULT_COMPARE_OPERATOR,
  154. orderKey: '',
  155. sort: '' as '' | 'ASC' | 'DESC',
  156. });
  157. const pagination = reactive({
  158. page: 1,
  159. limit: componentSetting.table.defaultPageSize,
  160. total: 0,
  161. });
  162. const totalPages = computed(() => {
  163. if (!pagination.total) return 1;
  164. return Math.max(1, Math.ceil(pagination.total / pagination.limit));
  165. });
  166. const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
  167. label: value,
  168. value,
  169. }));
  170. const compareOperatorOptions = computed(() =>
  171. props.transAmountOperatorOptions?.length ? props.transAmountOperatorOptions : [...COMPARE_OPERATOR_OPTIONS],
  172. );
  173. const filterSchema = [
  174. { field: 'personName', label: '本方姓名', type: 'input', placeholder: '请输入本方姓名', width: 180 },
  175. { field: 'personCardNo', label: '本方卡号', type: 'input', placeholder: '请输入本方卡号', width: 180 },
  176. { field: 'otherName', label: '对方名称', type: 'input', placeholder: '请输入对方名称', width: 180 },
  177. { field: 'otherCardNo', label: '对方卡号', type: 'input', placeholder: '请输入对方卡号', width: 180 },
  178. {
  179. field: 'debitCreditFlag',
  180. label: '借贷标志',
  181. type: 'select',
  182. width: 160,
  183. options: [
  184. { label: '全部', value: '' },
  185. { label: '收入', value: '收入' },
  186. { label: '支出', value: '支出' },
  187. ],
  188. },
  189. { field: 'startDate', label: '开始日期', type: 'date', width: 170 },
  190. { field: 'endDate', label: '结束日期', type: 'date', width: 170 },
  191. {
  192. field: 'transDateRange',
  193. label: '交易日期',
  194. type: 'date-range',
  195. startField: 'startDate',
  196. endField: 'endDate',
  197. width: 320,
  198. maxWidth: 360,
  199. rangePlaceholder: ['开始日期', '结束日期'] as [string, string],
  200. valueFormat: 'YYYY-MM-DD',
  201. },
  202. {
  203. field: 'transAmount',
  204. label: '交易金额',
  205. type: 'compare-input',
  206. operatorField: 'transAmountSymbol',
  207. options: compareOperatorOptions.value,
  208. operatorWidth: 72,
  209. inputWidth: 180,
  210. inputMaxWidth: 180,
  211. placeholder: '请输入交易金额',
  212. },
  213. ] as const;
  214. const visibleFilterSchema = computed(() => {
  215. const visibleFields = new Set(props.filterFields);
  216. const showDateRange = visibleFields.has('startDate') && visibleFields.has('endDate');
  217. return filterSchema.filter((item) => {
  218. if (item.field === 'transDateRange') return showDateRange;
  219. if (item.field === 'startDate' || item.field === 'endDate')
  220. return !showDateRange && visibleFields.has(item.field);
  221. return visibleFields.has(item.field);
  222. });
  223. });
  224. const technicalIdColumnKeys = ['recordId', 'fileId', 'sheetId', 'personLibId'];
  225. const columns = [
  226. { title: '本方姓名', dataIndex: 'personName', key: 'personName', width: 120 },
  227. { title: '本方卡号', dataIndex: 'personCardNo', key: 'personCardNo', width: 180 },
  228. { title: '对方名称', dataIndex: 'otherName', key: 'otherName', width: 140 },
  229. { title: '对方卡号', dataIndex: 'otherCardNo', key: 'otherCardNo', width: 180 },
  230. { title: '本方账号', dataIndex: 'personAccount', key: 'personAccount', width: 180 },
  231. { title: '对方账号', dataIndex: 'otherAccount', key: 'otherAccount', width: 180 },
  232. { title: '借贷标志', dataIndex: 'debitCreditFlag', key: 'debitCreditFlag', width: 100 },
  233. { title: '交易日期时间', dataIndex: 'transDateTime', key: 'transDateTime', width: 180, sorter: true },
  234. { title: '交易金额', dataIndex: 'transAmount', key: 'transAmount', width: 120, sorter: true },
  235. { title: '交易余额', dataIndex: 'transBalance', key: 'transBalance', width: 120, sorter: true },
  236. {
  237. title: '银行名称',
  238. dataIndex: 'bankName',
  239. key: 'bankName',
  240. width: 160,
  241. customRender: ({ text }: { text: string }) => h('span', { style: { color: '#1890ff', fontWeight: 500 } }, text),
  242. },
  243. { title: '交易类型', dataIndex: 'transType', key: 'transType', width: 120 },
  244. { title: '交易摘要', dataIndex: 'transSummary', key: 'transSummary', width: 220 },
  245. { title: '对方余额', dataIndex: 'otherBalance', key: 'otherBalance', width: 120, sorter: true },
  246. { title: '币种', dataIndex: 'currency', key: 'currency', width: 90 },
  247. { title: '本方证件号码', dataIndex: 'personCertNo', key: 'personCertNo', width: 180 },
  248. { title: '对方证件号码', dataIndex: 'otherCertNo', key: 'otherCertNo', width: 180 },
  249. { title: '交易流水号', dataIndex: 'transSerialNo', key: 'transSerialNo', width: 180 },
  250. { title: '交易摘要', dataIndex: 'transSummary', key: 'transSummary', width: 220 },
  251. { title: '交易网点', dataIndex: 'branchName', key: 'branchName', width: 220 },
  252. { title: '网点代码', dataIndex: 'branchCode', key: 'branchCode', width: 140 },
  253. { title: '对方开户行', dataIndex: 'otherAccountBank', key: 'otherAccountBank', width: 180 },
  254. { title: '现金标志', dataIndex: 'cashFlag', key: 'cashFlag', width: 100 },
  255. { title: '终端号', dataIndex: 'terminalNo', key: 'terminalNo', width: 140 },
  256. { title: '交易地点', dataIndex: 'transLocation', key: 'transLocation', width: 180 },
  257. { title: '商户名称', dataIndex: 'merchantName', key: 'merchantName', width: 180 },
  258. { title: '商户号', dataIndex: 'merchantNo', key: 'merchantNo', width: 160 },
  259. { title: 'IP地址', dataIndex: 'personIpAddress', key: 'personIpAddress', width: 150 },
  260. { title: 'MAC地址', dataIndex: 'personMacAddress', key: 'personMacAddress', width: 180 },
  261. { title: '柜员号', dataIndex: 'transClerkNo', key: 'transClerkNo', width: 140 },
  262. { title: '交易渠道', dataIndex: 'transChannel', key: 'transChannel', width: 140 },
  263. { title: '备注', dataIndex: 'remark', key: 'remark', width: 180 },
  264. ];
  265. const visibleColumns = computed(() => {
  266. if (!props.hideTechnicalIdColumns) return columns;
  267. return columns.filter((column) => !technicalIdColumnKeys.includes(String(column.key || column.dataIndex || '')));
  268. });
  269. const normalizeActionRecordId = (value: unknown) => {
  270. const text = String(value ?? '').trim();
  271. if (!text || text === '-' || text === 'null' || text === 'undefined') return undefined;
  272. return text;
  273. };
  274. const buildJumpQuery = (record?: TransRecord) => {
  275. const id = normalizeActionRecordId(record?.id);
  276. const personName = String(record?.personName || props.baseQuery?.personName || '').trim();
  277. return {
  278. hasLocal: 'N',
  279. tableName: 'trans_record',
  280. tableNameEn: 'trans_record',
  281. tableNameCn: '交易记录',
  282. id,
  283. personName: personName || undefined,
  284. personNames: personName ? [personName] : undefined,
  285. };
  286. };
  287. const handleJump = async (record?: TransRecord) => {
  288. await jumpToBehaviorSpacetime(buildJumpQuery(record), {
  289. onBeforeNavigate: () => {
  290. detailModalOpen.value = false;
  291. detailBaseQuery.value = {};
  292. emit('jump');
  293. emit('update:open', false);
  294. },
  295. });
  296. };
  297. const handleOpenFile = async (record?: TransRecord) => {
  298. const fileId = resolveOfficeFileId(record);
  299. if (!fileId) {
  300. createMessage.warning('未获取到文件ID,暂时无法打开文件');
  301. return;
  302. }
  303. try {
  304. const response = await openOfficeFileById(fileId);
  305. if (!isOpenOfficeFileSuccess(response)) {
  306. createMessage.warning((response as any)?.message || '打开文件失败');
  307. }
  308. } catch (error: any) {
  309. createMessage.error(getOpenOfficeFileErrorMessage(error, '打开文件失败'));
  310. }
  311. };
  312. const handleViewDetail = (record?: TransRecord) => {
  313. const detailId = normalizeActionRecordId(record?.id);
  314. if (!detailId) {
  315. createMessage.warning('未获取到交易记录ID,暂时无法查看详情');
  316. return;
  317. }
  318. detailBaseQuery.value = { id: detailId };
  319. detailModalOpen.value = true;
  320. };
  321. const detailModalZIndex = computed(() => {
  322. const baseZIndex = Number(props.zIndex);
  323. return Number.isFinite(baseZIndex) ? baseZIndex + 40 : 1300;
  324. });
  325. const handleDetailModalJump = () => {
  326. detailModalOpen.value = false;
  327. detailBaseQuery.value = {};
  328. emit('jump');
  329. emit('update:open', false);
  330. };
  331. const actionColumn = computed(() => ({
  332. title: '操作',
  333. dataIndex: 'action',
  334. key: 'action',
  335. width: props.showDetailAction ? 220 : 160,
  336. fixed: 'right',
  337. align: 'center',
  338. actions: (record: { raw?: TransRecord }) => {
  339. const rawRecord = record.raw;
  340. const query = buildJumpQuery(rawRecord);
  341. const fileId = resolveOfficeFileId(rawRecord);
  342. const detailId = normalizeActionRecordId(rawRecord?.id);
  343. const actions = [
  344. props.showDetailAction
  345. ? {
  346. label: '查看详情',
  347. disabled: !detailId,
  348. onClick: () => handleViewDetail(rawRecord),
  349. }
  350. : null,
  351. {
  352. label: '打开文件',
  353. disabled: !fileId,
  354. onClick: () => void handleOpenFile(rawRecord),
  355. },
  356. {
  357. label: '跳转',
  358. disabled: jumping.value || !query.id,
  359. onClick: () => void handleJump(rawRecord),
  360. },
  361. ];
  362. return actions.filter(Boolean);
  363. },
  364. }));
  365. const rowSelection = computed(() => {
  366. if (!props.traceable) return undefined;
  367. return {
  368. type: 'checkbox',
  369. selectedRowKeys: selectedRowKeys.value,
  370. columnWidth: 50,
  371. onChange: (keys: Array<string | number>, rows: Array<{ raw?: TransRecord }>) => {
  372. selectedRowKeys.value = keys;
  373. selectedRows.value = rows.map((row) => row.raw).filter((item): item is TransRecord => Boolean(item));
  374. },
  375. };
  376. });
  377. const resolveFiniteNumber = (...values: unknown[]) => {
  378. for (const value of values) {
  379. const numeric = Number(value);
  380. if (Number.isFinite(numeric)) return numeric;
  381. }
  382. return undefined;
  383. };
  384. const normalizeList = (resp: any): TransRecord[] => {
  385. const containers = [resp, resp?.data, resp?.page, resp?.result, resp?.data?.result, resp?.data?.data];
  386. for (const source of containers) {
  387. if (Array.isArray(source)) return source;
  388. const candidates = [source?.records, source?.list, source?.rows, source?.data];
  389. const matched = candidates.find((item) => Array.isArray(item));
  390. if (Array.isArray(matched)) return matched;
  391. }
  392. return [];
  393. };
  394. const normalizeTotal = (resp: any, listLen: number) => {
  395. const total = resolveFiniteNumber(
  396. resp?.total,
  397. resp?.count,
  398. resp?.data?.total,
  399. resp?.data?.count,
  400. resp?.page?.total,
  401. resp?.page?.count,
  402. resp?.result?.total,
  403. resp?.result?.count,
  404. resp?.data?.result?.total,
  405. resp?.data?.result?.count,
  406. resp?.data?.data?.total,
  407. resp?.data?.data?.count,
  408. );
  409. return total ?? listLen;
  410. };
  411. const normalizeCurrent = (resp: any, fallback: number) => {
  412. const current = resolveFiniteNumber(
  413. resp?.current,
  414. resp?.page,
  415. resp?.pageNo,
  416. resp?.pageNum,
  417. resp?.data?.current,
  418. resp?.data?.page,
  419. resp?.data?.pageNo,
  420. resp?.data?.pageNum,
  421. resp?.page?.current,
  422. resp?.page?.page,
  423. resp?.page?.pageNo,
  424. resp?.page?.pageNum,
  425. resp?.result?.current,
  426. resp?.result?.page,
  427. resp?.result?.pageNo,
  428. resp?.result?.pageNum,
  429. resp?.data?.result?.current,
  430. resp?.data?.result?.page,
  431. resp?.data?.result?.pageNo,
  432. resp?.data?.result?.pageNum,
  433. resp?.data?.data?.current,
  434. resp?.data?.data?.page,
  435. resp?.data?.data?.pageNo,
  436. resp?.data?.data?.pageNum,
  437. );
  438. return current && current > 0 ? current : fallback;
  439. };
  440. const normalizeSize = (resp: any, fallback: number) => {
  441. const size = resolveFiniteNumber(
  442. resp?.size,
  443. resp?.limit,
  444. resp?.pageSize,
  445. resp?.data?.size,
  446. resp?.data?.limit,
  447. resp?.data?.pageSize,
  448. resp?.page?.size,
  449. resp?.page?.limit,
  450. resp?.page?.pageSize,
  451. resp?.result?.size,
  452. resp?.result?.limit,
  453. resp?.result?.pageSize,
  454. resp?.data?.result?.size,
  455. resp?.data?.result?.limit,
  456. resp?.data?.result?.pageSize,
  457. resp?.data?.data?.size,
  458. resp?.data?.data?.limit,
  459. resp?.data?.data?.pageSize,
  460. );
  461. return size && size > 0 ? size : fallback;
  462. };
  463. const fallbackValue = (value: string | number | null | undefined) => {
  464. if (value === 0) return '0';
  465. if (value === null || value === undefined || value === '') return '-';
  466. return String(value);
  467. };
  468. const normalizeQueryId = (value: unknown) => {
  469. const text = String(value ?? '').trim();
  470. return text || undefined;
  471. };
  472. const normalizeQueryIds = (value: unknown): string[] | undefined => {
  473. if (Array.isArray(value)) {
  474. const ids = value.map((item) => String(item ?? '').trim()).filter(Boolean);
  475. return ids.length ? ids : undefined;
  476. }
  477. const text = String(value ?? '').trim();
  478. if (!text) return undefined;
  479. const ids = text
  480. .split(',')
  481. .map((item) => item.trim())
  482. .filter(Boolean);
  483. return ids.length ? ids : undefined;
  484. };
  485. const buildQuery = (): TransRecordModalQuery => {
  486. const baseQuery = { ...(props.baseQuery || {}) };
  487. delete baseQuery.keyword;
  488. const transAmountText = String(filters.transAmount || '').trim();
  489. return {
  490. ...baseQuery,
  491. page: pagination.page,
  492. limit: pagination.limit,
  493. personName: filters.personName || props.baseQuery?.personName,
  494. personCardNo: filters.personCardNo || props.baseQuery?.personCardNo,
  495. otherName: filters.otherName || props.baseQuery?.otherName,
  496. otherCardNo: filters.otherCardNo || props.baseQuery?.otherCardNo,
  497. debitCreditFlag: filters.debitCreditFlag || props.baseQuery?.debitCreditFlag,
  498. startDate: filters.startDate || props.baseQuery?.startDate,
  499. endDate: filters.endDate || props.baseQuery?.endDate,
  500. transAmount: transAmountText || props.baseQuery?.transAmount,
  501. transAmountSymbol: transAmountText
  502. ? normalizeCompareOperator(filters.transAmountSymbol, DEFAULT_COMPARE_OPERATOR)
  503. : props.baseQuery?.transAmountSymbol,
  504. orderKey: filters.orderKey || undefined,
  505. sort: filters.sort || undefined,
  506. id: normalizeQueryId(props.baseQuery?.id),
  507. ids: normalizeQueryIds(props.baseQuery?.ids),
  508. };
  509. };
  510. const normalizeQueryForKey = (value: unknown): unknown => {
  511. if (Array.isArray(value)) {
  512. return value.map((item) => normalizeQueryForKey(item)).sort();
  513. }
  514. if (value && typeof value === 'object') {
  515. return Object.keys(value as Record<string, unknown>)
  516. .sort()
  517. .reduce<Record<string, unknown>>((result, key) => {
  518. const item = (value as Record<string, unknown>)[key];
  519. if (item !== undefined && item !== '') {
  520. result[key] = normalizeQueryForKey(item);
  521. }
  522. return result;
  523. }, {});
  524. }
  525. return value;
  526. };
  527. const buildQueryKey = (query: TransRecordModalQuery) => {
  528. return JSON.stringify(normalizeQueryForKey(query));
  529. };
  530. const mapRow = (item: TransRecord, index: number) => ({
  531. rowKey: item.id ?? `${pagination.page}-${index}`,
  532. id: (pagination.page - 1) * pagination.limit + index + 1,
  533. recordId: fallbackValue(item.id),
  534. fileId: fallbackValue(item.fileId),
  535. sheetId: fallbackValue(item.sheetId),
  536. bankName: fallbackValue(item.bankName),
  537. personName: fallbackValue(item.personName),
  538. personCertNo: maskCertNo(item.personCertNo),
  539. personAccount: fallbackValue(item.personAccount),
  540. personCardNo: fallbackValue(item.personCardNo),
  541. otherName: fallbackValue(item.otherName),
  542. otherCertNo: maskCertNo(item.otherCertNo),
  543. otherAccount: fallbackValue(item.otherAccount),
  544. otherCardNo: fallbackValue(item.otherCardNo),
  545. transType: fallbackValue(item.transType),
  546. debitCreditFlag: fallbackValue(item.debitCreditFlag),
  547. currency: fallbackValue(item.currency),
  548. transAmount: fallbackValue(item.transAmount),
  549. transBalance: fallbackValue(item.transBalance),
  550. transDateTime: fallbackValue(item.transDateTime),
  551. transDate: fallbackValue(item.transDate),
  552. transTime: fallbackValue(item.transTime),
  553. transSerialNo: fallbackValue(item.transSerialNo),
  554. otherBalance: fallbackValue(item.otherBalance),
  555. otherAccountBank: fallbackValue(item.otherAccountBank),
  556. transSummary: fallbackValue(item.transSummary),
  557. branchName: fallbackValue(item.branchName),
  558. branchCode: fallbackValue(item.branchCode),
  559. cashFlag: fallbackValue(item.cashFlag),
  560. terminalNo: fallbackValue(item.terminalNo),
  561. transLocation: fallbackValue(item.transLocation),
  562. merchantName: fallbackValue(item.merchantName),
  563. merchantNo: fallbackValue(item.merchantNo),
  564. personIpAddress: fallbackValue(item.personIpAddress),
  565. personMacAddress: fallbackValue(item.personMacAddress),
  566. transClerkNo: fallbackValue(item.transClerkNo),
  567. transChannel: fallbackValue(item.transChannel),
  568. remark: fallbackValue(item.remark),
  569. personLibId: fallbackValue(item.personLibId),
  570. ext1: fallbackValue(item.ext1),
  571. festivalName: fallbackValue(item.festivalName),
  572. raw: item,
  573. });
  574. const clearTraceSelection = () => {
  575. selectedRowKeys.value = [];
  576. selectedRows.value = [];
  577. };
  578. const syncFiltersFromBaseQuery = () => {
  579. filters.personName = props.baseQuery?.personName || '';
  580. filters.personCardNo = props.baseQuery?.personCardNo || '';
  581. filters.otherName = props.baseQuery?.otherName || '';
  582. filters.otherCardNo = props.baseQuery?.otherCardNo || '';
  583. filters.debitCreditFlag = props.baseQuery?.debitCreditFlag || '';
  584. filters.startDate = props.baseQuery?.startDate || '';
  585. filters.endDate = props.baseQuery?.endDate || '';
  586. filters.transAmount = props.baseQuery?.transAmount ? String(props.baseQuery.transAmount) : '';
  587. filters.transAmountSymbol = normalizeCompareOperator(props.baseQuery?.transAmountSymbol, DEFAULT_COMPARE_OPERATOR);
  588. filters.orderKey = '';
  589. filters.sort = '';
  590. };
  591. const fetchTable = async () => {
  592. const query = buildQuery();
  593. const queryKey = buildQueryKey(query);
  594. if (loading.value) {
  595. if (queryKey === activeFetchQueryKey || queryKey === pendingFetchQueryKey) return;
  596. pendingFetchRequested = true;
  597. pendingFetchQueryKey = queryKey;
  598. fetchRequestSequence += 1;
  599. return;
  600. }
  601. const requestSequence = ++fetchRequestSequence;
  602. activeFetchQueryKey = queryKey;
  603. pendingFetchRequested = false;
  604. pendingFetchQueryKey = '';
  605. loading.value = true;
  606. try {
  607. const resp = props.fetchApi ? await props.fetchApi(query) : await getTransRecord(query as TransRecordQuery);
  608. if (requestSequence !== fetchRequestSequence) return;
  609. const list = normalizeList(resp);
  610. tableData.value = list.map(mapRow);
  611. pagination.total = normalizeTotal(resp, list.length);
  612. pagination.page = normalizeCurrent(resp, pagination.page);
  613. pagination.limit = normalizeSize(resp, pagination.limit);
  614. } catch (error) {
  615. if (requestSequence !== fetchRequestSequence) return;
  616. tableData.value = [];
  617. pagination.total = 0;
  618. const errMsg = error instanceof Error ? error.message : '查询失败,请检查接口或网络';
  619. console.error('查询交易记录失败', error);
  620. createMessage.error(errMsg);
  621. } finally {
  622. const shouldRunPendingFetch = pendingFetchRequested && props.open;
  623. if (requestSequence === fetchRequestSequence || shouldRunPendingFetch) {
  624. loading.value = false;
  625. }
  626. if (shouldRunPendingFetch) {
  627. pendingFetchRequested = false;
  628. pendingFetchQueryKey = '';
  629. activeFetchQueryKey = '';
  630. void fetchTable();
  631. }
  632. }
  633. };
  634. const handleTrace = () => {
  635. if (!selectedRows.value.length) return;
  636. emit('trace', {
  637. direction: props.traceDirection,
  638. records: [...selectedRows.value],
  639. });
  640. };
  641. const handleSearch = async () => {
  642. pagination.page = 1;
  643. clearTraceSelection();
  644. await fetchTable();
  645. };
  646. const handleReset = async () => {
  647. syncFiltersFromBaseQuery();
  648. pagination.page = 1;
  649. clearTraceSelection();
  650. await fetchTable();
  651. };
  652. const handlePrevPage = async () => {
  653. if (loading.value || pagination.page <= 1) return;
  654. pagination.page -= 1;
  655. clearTraceSelection();
  656. await fetchTable();
  657. };
  658. const handleNextPage = async () => {
  659. if (loading.value || pagination.page >= totalPages.value) return;
  660. pagination.page += 1;
  661. clearTraceSelection();
  662. await fetchTable();
  663. };
  664. const handlePageChange = async (page: number, pageSize: number) => {
  665. if (loading.value) return;
  666. if (pageSize !== pagination.limit) {
  667. await handlePageSizeChange(pageSize);
  668. return;
  669. }
  670. if (page === pagination.page - 1) {
  671. await handlePrevPage();
  672. return;
  673. }
  674. if (page === pagination.page + 1) {
  675. await handleNextPage();
  676. return;
  677. }
  678. if (page === pagination.page) return;
  679. pagination.page = page;
  680. clearTraceSelection();
  681. await fetchTable();
  682. };
  683. const handlePageSizeChange = async (size: number) => {
  684. pagination.limit = Number(size);
  685. pagination.page = 1;
  686. clearTraceSelection();
  687. await fetchTable();
  688. };
  689. const handleSortChange = async (payload: { orderKey: string; sort: 'ASC' | 'DESC' }) => {
  690. filters.orderKey = payload.orderKey;
  691. filters.sort = payload.sort;
  692. pagination.page = 1;
  693. clearTraceSelection();
  694. await fetchTable();
  695. };
  696. const initAndFetch = async () => {
  697. pagination.page = 1;
  698. syncFiltersFromBaseQuery();
  699. clearTraceSelection();
  700. await fetchTable();
  701. };
  702. let initFetchScheduled = false;
  703. const scheduleInitAndFetch = () => {
  704. if (!props.open || initFetchScheduled) return;
  705. initFetchScheduled = true;
  706. nextTick(async () => {
  707. initFetchScheduled = false;
  708. if (!props.open) return;
  709. await initAndFetch();
  710. });
  711. };
  712. watch(
  713. () => props.open,
  714. (open) => {
  715. if (!open) {
  716. initFetchScheduled = false;
  717. fetchRequestSequence += 1;
  718. activeFetchQueryKey = '';
  719. pendingFetchRequested = false;
  720. pendingFetchQueryKey = '';
  721. loading.value = false;
  722. clearTraceSelection();
  723. return;
  724. }
  725. scheduleInitAndFetch();
  726. },
  727. );
  728. watch(
  729. () => props.baseQuery,
  730. () => {
  731. if (!props.open) return;
  732. scheduleInitAndFetch();
  733. },
  734. { deep: true },
  735. );
  736. defineExpose({
  737. reload: fetchTable,
  738. });
  739. </script>
  740. <style lang="less">
  741. .trans-record-modal {
  742. .ant-modal {
  743. padding-bottom: 0;
  744. }
  745. .ant-modal-body > .scroll-container,
  746. .ant-modal-body > .scroll-container > .scrollbar__wrap,
  747. .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view,
  748. .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view > div {
  749. height: 100%;
  750. min-height: 0 !important;
  751. max-height: none !important;
  752. }
  753. .ant-modal-body > .scroll-container > .scrollbar__wrap {
  754. overflow: hidden;
  755. }
  756. .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view {
  757. margin: 0 !important;
  758. }
  759. .ant-modal-body > .scroll-container > .scrollbar__bar.is-vertical,
  760. .ant-modal-body > .scroll-container > .is-vertical {
  761. display: none !important;
  762. opacity: 0 !important;
  763. pointer-events: none;
  764. }
  765. .ant-modal-content {
  766. border-radius: 14px;
  767. background: #fff;
  768. box-shadow: 0 18px 46px rgb(15 23 42 / 16%);
  769. }
  770. .ant-modal-header {
  771. padding: 12px 16px;
  772. background: @modal-header-bg-color;
  773. border-bottom-color: #e5e7eb;
  774. }
  775. .ant-modal-title {
  776. color: #0f172a;
  777. font-size: 16px;
  778. font-weight: 600;
  779. line-height: 24px;
  780. letter-spacing: 0.2px;
  781. }
  782. .ant-modal-close {
  783. top: 14px;
  784. right: 16px;
  785. display: flex;
  786. align-items: center;
  787. justify-content: flex-end;
  788. width: auto !important;
  789. height: auto !important;
  790. border-radius: 8px;
  791. transition: background-color 0.2s ease;
  792. .jeesite-basic-modal-close {
  793. display: flex;
  794. align-items: center;
  795. gap: 2px;
  796. margin-right: 0;
  797. }
  798. &:hover {
  799. background: rgb(37 99 235 / 8%);
  800. }
  801. }
  802. .ant-modal-body {
  803. padding: 16px;
  804. }
  805. .ant-modal-footer {
  806. padding: 8px 16px 10px;
  807. border-top-color: #e5e7eb;
  808. background: #fff;
  809. }
  810. .query-table-wrap {
  811. gap: 12px;
  812. }
  813. .query-table-search {
  814. margin: 0;
  815. padding: 14px 16px;
  816. border: 1px solid #e2e8f0;
  817. border-radius: 12px;
  818. background: #fff;
  819. box-shadow: inset 0 1px 0 rgb(255 255 255 / 80%);
  820. .label {
  821. color: #475569;
  822. font-size: 12px;
  823. font-weight: 600;
  824. }
  825. .ant-input,
  826. .ant-input-affix-wrapper,
  827. .ant-picker,
  828. .ant-select-selector {
  829. border-radius: 8px !important;
  830. }
  831. .search-actions {
  832. gap: 12px;
  833. .ant-btn {
  834. height: 36px;
  835. min-width: 76px;
  836. padding: 0 18px;
  837. border-radius: 18px;
  838. font-size: 15px;
  839. font-weight: 500;
  840. box-shadow: none;
  841. }
  842. .ant-btn-primary {
  843. border-color: #315efb;
  844. background: #315efb;
  845. }
  846. .ant-btn:not(.ant-btn-primary) {
  847. color: #1f2937;
  848. border-color: #d6dbe6;
  849. background: #fff;
  850. }
  851. }
  852. .search-extra-actions {
  853. width: 100%;
  854. margin-left: 0;
  855. order: -1;
  856. }
  857. }
  858. .query-table-content {
  859. margin: 0;
  860. border-color: #dbe3ee;
  861. border-radius: 12px;
  862. background: #fff;
  863. box-shadow: 0 8px 20px rgb(15 23 42 / 4%);
  864. .ant-table-thead > tr > th {
  865. background: #f8fafc;
  866. color: #334155;
  867. font-weight: 600;
  868. }
  869. .ant-table-tbody > tr:nth-child(even) > td {
  870. background: #fcfdff;
  871. }
  872. .ant-table-tbody > tr:hover > td {
  873. background: #eff6ff;
  874. }
  875. }
  876. .query-table-empty {
  877. color: #94a3b8;
  878. background: #fff;
  879. }
  880. .pagination-bar {
  881. min-height: 32px;
  882. .pagination-total {
  883. color: #64748b;
  884. }
  885. .pagination-size .ant-select-selector,
  886. .pagination-actions .ant-btn {
  887. border-radius: 6px;
  888. }
  889. .pagination-actions .ant-btn-primary {
  890. min-width: 36px;
  891. }
  892. }
  893. .trans-record-modal__trace-bar {
  894. display: flex;
  895. align-items: center;
  896. gap: 10px;
  897. flex-wrap: wrap;
  898. width: auto;
  899. }
  900. .trans-record-modal__trace-label {
  901. color: #475569;
  902. font-size: 12px;
  903. font-weight: 600;
  904. white-space: nowrap;
  905. }
  906. .trans-record-modal__trace-bar .ant-radio-group {
  907. display: inline-flex;
  908. align-items: center;
  909. gap: 12px;
  910. }
  911. .trans-record-modal__trace-bar .ant-radio-wrapper {
  912. margin-right: 0;
  913. color: #334155;
  914. }
  915. .query-table-search {
  916. .search-utility-actions {
  917. width: 100%;
  918. justify-content: space-between;
  919. align-items: flex-end;
  920. }
  921. .search-extra-actions {
  922. width: auto;
  923. margin-left: 0;
  924. order: -1;
  925. }
  926. }
  927. }
  928. </style>