TransactionList.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. <template>
  2. <cwg-load-more-wrapper ref="loadMoreWrapperRef" :loading="loading" :finished="finished" :height='108'
  3. :refresher-enabled="pageSize != 4" @reach-bottom="loadMore" @refresh="handleRefresh">
  4. <view v-if="records.length > 0" :class="{
  5. 'records-list': true,
  6. 'records-list1': pageSize === 4
  7. }">
  8. <view v-for="record in records" :key="record.id" class="record-card" @click="goToTransactionDetail(record)">
  9. <view class="record-main">
  10. <view class="record-left">
  11. <view class="type-icon transaction-icon">
  12. <cwg-icon class="icons" :name="getTransactionIcon(record.type)" :size="20" color="#2563eb" />
  13. </view>
  14. <view class="record-info">
  15. <view class="info-header">
  16. <text class="record-type">{{ getTransactionTypeText(record.type) }}</text>
  17. <view :class="['status-badge', getStatusBadgeClass(record.status)]">
  18. <cwg-icon class="icons" :name="getStatusIcon(record.status)" :size="12"
  19. :color="getStatusColor(record.status)" />
  20. <text :class="['status-text', getStatusTextClass(record.status)]">
  21. {{ getStatusText(record.status) }}
  22. </text>
  23. </view>
  24. </view>
  25. <text class="record-detail">{{ record.remark || record.merchant || '--' }}</text>
  26. </view>
  27. </view>
  28. <view class="record-right">
  29. <text class="amount-transaction">{{ Number(record.amount || 0) >= 0 ? '+' : '-' }}{{
  30. Math.abs(Number(record.amount || 0)).toFixed(2) }} {{ record.currency || 'USD' }}</text>
  31. <text class="fee-text">{{ t('global.p17') }} {{ Number(record.fee || 0).toFixed(2) }}</text>
  32. </view>
  33. </view>
  34. <view class="record-footer">
  35. <text class="footer-time">{{ formatDateTime(record.transactionTime) }}</text>
  36. <view class="footer-actions">
  37. <text class="footer-order">
  38. {{ t('global.p15') }}: {{ formatOrderNo(record.tradeNo) }}
  39. </text>
  40. <cwg-icon class="footer-order-icon" name="copy" :size="14" color="#9ca3af"
  41. @click.stop="copyOrderNo(record.tradeNo)" />
  42. </view>
  43. </view>
  44. </view>
  45. </view>
  46. <cwg-empty-state v-else />
  47. </cwg-load-more-wrapper>
  48. </template>
  49. <script setup lang="ts">
  50. import { ref, computed, watch, onMounted } from 'vue';
  51. import dayjs from 'dayjs';
  52. import { useI18n } from 'vue-i18n';
  53. import { showToast } from '@/utils/toast';
  54. import { ucardApi, TransactionInfo } from '@/api/ucard';
  55. import { transactionTypeMap, transactionStatusMap } from '@/utils/dataMap';
  56. import useCardStore from '@/stores/use-card-store';
  57. interface RecordItem extends TransactionInfo {
  58. type?: string;
  59. typeStr?: string;
  60. remark?: string;
  61. status?: string | number;
  62. transactionTime?: string | number;
  63. fee?: number;
  64. currency?: string;
  65. tradeNo?: string;
  66. merchant?: string;
  67. }
  68. type NormalizedStatus = 'success' | 'processing' | 'failed';
  69. const props = defineProps<{
  70. cardNumber: string;
  71. pageSize: number;
  72. typeIndex: number;
  73. statusIndex: number;
  74. dateFilter: string;
  75. typeOptions: string[];
  76. }>();
  77. const { t } = useI18n();
  78. const records = ref<RecordItem[]>([]);
  79. const page = ref(1);
  80. const pageSize = computed(() => props.pageSize || 10);
  81. const loading = ref(false);
  82. const finished = ref(false);
  83. const cardStore = useCardStore();
  84. const normalizeStatus = (status?: string | number): NormalizedStatus => {
  85. if (!status) return 'success';
  86. const statusStr = String(status).toLowerCase();
  87. if (statusStr === 'processing' || statusStr === 'wait_process') return 'processing';
  88. if (statusStr === 'fail' || statusStr === 'failed') return 'failed';
  89. if (statusStr === 'succeed' || statusStr === 'success') return 'success';
  90. return 'success';
  91. };
  92. const getStatusText = (status?: string | number): string => {
  93. if (!status) return '';
  94. const statusKey = String(status).toLowerCase();
  95. if (transactionStatusMap[statusKey as keyof typeof transactionStatusMap]) {
  96. return t(transactionStatusMap[statusKey as keyof typeof transactionStatusMap]);
  97. }
  98. const normalized = normalizeStatus(status);
  99. if (normalized === 'success') return t('card.Status.t1');
  100. if (normalized === 'processing') return t('card.Status.t3');
  101. return t('card.Status.t2');
  102. };
  103. const getStatusIcon = (status?: string | number): string => {
  104. const normalized = normalizeStatus(status);
  105. if (normalized === 'success') return 'checkmarkempty1';
  106. if (normalized === 'processing') return 'info1';
  107. return 'closeempty1';
  108. };
  109. const getStatusColor = (status?: string | number): string => {
  110. const normalized = normalizeStatus(status);
  111. if (normalized === 'success') return '#22c55e';
  112. if (normalized === 'processing') return '#eab308';
  113. return '#ef4444';
  114. };
  115. const getStatusBadgeClass = (status?: string | number) => `status-${normalizeStatus(status)}`;
  116. const getStatusTextClass = (status?: string | number) => `status-text-${normalizeStatus(status)}`;
  117. const getStatusValue = (index: number): NormalizedStatus | null => {
  118. if (index === 0) return null;
  119. const statusMap: NormalizedStatus[] = ['success', 'processing', 'failed'];
  120. return statusMap[index - 1];
  121. };
  122. const getTransactionTypeText = (type?: string): string => {
  123. if (!type) return '--';
  124. const key = type.toLowerCase();
  125. if (transactionTypeMap[key as keyof typeof transactionTypeMap]) {
  126. return t(transactionTypeMap[key as keyof typeof transactionTypeMap]);
  127. }
  128. return type;
  129. };
  130. const getTransactionIcon = (type = ''): string => {
  131. if (type.includes('购买') || type === 'refund') return 'cart';
  132. if (type.includes('提现') || type === 'auth') return 'minus-filled';
  133. if (type.includes('转账')) return 'redo';
  134. if (type.includes('话费')) return 'phone';
  135. if (type.includes('缴费')) return 'flame';
  136. if (type === 'maintain_fee') return 'servicefee';
  137. return 'servicefee';
  138. };
  139. const formatDateTime = (time?: string | number): string => {
  140. if (!time) return '--';
  141. try {
  142. let date: dayjs.Dayjs;
  143. if (typeof time === 'number') {
  144. if (time.toString().length === 10) {
  145. date = dayjs.unix(time);
  146. } else {
  147. date = dayjs(time);
  148. }
  149. } else {
  150. date = dayjs(time);
  151. }
  152. if (!date.isValid()) return '--';
  153. return date.format('YYYY-MM-DD HH:mm:ss');
  154. } catch (error) {
  155. return '--';
  156. }
  157. };
  158. const getDatePart = (time?: string | number): string => {
  159. if (!time) return '';
  160. try {
  161. let date: dayjs.Dayjs;
  162. if (typeof time === 'number') {
  163. if (time.toString().length === 10) {
  164. date = dayjs.unix(time);
  165. } else {
  166. date = dayjs(time);
  167. }
  168. } else {
  169. date = dayjs(time);
  170. }
  171. if (!date.isValid()) return '';
  172. return date.format('YYYY-MM-DD');
  173. } catch (error) {
  174. return '';
  175. }
  176. };
  177. const formatOrderNo = (orderNo?: string) => {
  178. if (!orderNo) return '--';
  179. if (orderNo.length <= 20) return orderNo;
  180. return orderNo.slice(0, 6) + '...' + orderNo.slice(-4);
  181. };
  182. const copyOrderNo = (orderNo?: string) => {
  183. if (!orderNo) return;
  184. uni.setClipboardData({
  185. data: orderNo,
  186. success: () => {
  187. uni.showToast({
  188. title: t('card.Msg.m8') || '复制成功',
  189. icon: 'success'
  190. });
  191. }
  192. });
  193. };
  194. const fetchRecords = async (isLoadMore = false) => {
  195. if (!props.cardNumber || loading.value) return;
  196. if (isLoadMore && finished.value) return;
  197. loading.value = true;
  198. try {
  199. const res = await ucardApi.transactionsList({
  200. cardNumber: props.cardNumber,
  201. type: props.typeIndex == -1 ? undefined : props.typeIndex,
  202. status: props.statusIndex == -1 ? undefined : props.statusIndex,
  203. beginDate: props.dateFilter?.[0] ? dayjs(props.dateFilter[0]).format('YYYY-MM-DD') : undefined,
  204. endDate: props.dateFilter?.[1] ? dayjs(props.dateFilter[1]).format('YYYY-MM-DD') : undefined,
  205. page: { current: page.value, row: pageSize.value },
  206. });
  207. const data = res.code === 200 && Array.isArray(res.data) ? res.data : [];
  208. if (isLoadMore) {
  209. records.value.push(...data);
  210. } else {
  211. records.value = data;
  212. }
  213. if (data.length < pageSize.value) {
  214. finished.value = true;
  215. } else {
  216. finished.value = false;
  217. }
  218. } catch (error: any) {
  219. if (!isLoadMore) {
  220. records.value = [];
  221. }
  222. showToast(error?.message || String(error));
  223. } finally {
  224. loading.value = false;
  225. }
  226. };
  227. const goToTransactionDetail = (record: RecordItem) => {
  228. const amount = Number(record.amount || 0);
  229. const fee = Number(record.fee || 0);
  230. const normalizedStatus = normalizeStatus(record.status);
  231. const detailPayload = {
  232. category: 'transaction' as const,
  233. orderNo: record.tradeNo || '',
  234. type: getTransactionTypeText(record.type),
  235. amount,
  236. fee,
  237. actualAmount: amount - fee,
  238. currency: record.currency || 'USD',
  239. orderStatus: normalizedStatus,
  240. statusMessage: getStatusText(record.status),
  241. createTime: formatDateTime(record.transactionTime),
  242. completeTime: '',
  243. merchant: record.merchant || '',
  244. bankCard: '',
  245. remark: record.remark || '',
  246. approvalSteps: [] as any[]
  247. };
  248. cardStore.saveOrderDetail(detailPayload);
  249. uni.navigateTo({
  250. url: '/pages/recharge-record/detail'
  251. });
  252. };
  253. const loadMore = () => {
  254. if (finished.value || loading.value || props.pageSize == 4) return;
  255. page.value++;
  256. fetchRecords(true);
  257. };
  258. watch([() => props.dateFilter], () => {
  259. page.value = 1;
  260. finished.value = false;
  261. fetchRecords();
  262. }, { immediate: false });
  263. watch([() => props.typeIndex], () => {
  264. page.value = 1;
  265. finished.value = false;
  266. fetchRecords();
  267. }, { immediate: false });
  268. watch([() => props.statusIndex], () => {
  269. page.value = 1;
  270. finished.value = false;
  271. fetchRecords();
  272. }, { immediate: false });
  273. watch([() => props.cardNumber], () => {
  274. page.value = 1;
  275. finished.value = false;
  276. fetchRecords();
  277. }, { immediate: false });
  278. const loadMoreWrapperRef = ref<any>(null);
  279. const refresh = async () => {
  280. page.value = 1;
  281. finished.value = false;
  282. await fetchRecords();
  283. };
  284. const handleRefresh = async () => {
  285. await refresh();
  286. // 停止下拉刷新动画
  287. if (loadMoreWrapperRef.value) {
  288. loadMoreWrapperRef.value.stopRefresh();
  289. }
  290. };
  291. onMounted(() => {
  292. fetchRecords();
  293. });
  294. defineExpose({
  295. refresh
  296. });
  297. </script>
  298. <style scoped lang="scss">
  299. @import "@/uni.scss";
  300. .records-list {
  301. display: flex;
  302. flex-direction: column;
  303. gap: px2rpx(12);
  304. padding: px2rpx(16);
  305. }
  306. .records-list1 {
  307. flex-direction: row;
  308. flex-wrap: wrap;
  309. gap: px2rpx(12);
  310. padding: px2rpx(16) 0;
  311. }
  312. .record-card {
  313. background-color: #ffffff;
  314. border-radius: px2rpx(12);
  315. border: 1px solid #e5e7eb;
  316. overflow: hidden;
  317. transition: box-shadow 0.3s;
  318. padding: px2rpx(16);
  319. }
  320. .record-card:active {
  321. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  322. }
  323. .record-main {
  324. display: flex;
  325. align-items: flex-start;
  326. justify-content: space-between;
  327. padding-bottom: px2rpx(16);
  328. }
  329. .record-left {
  330. display: flex;
  331. align-items: flex-start;
  332. gap: px2rpx(12);
  333. flex: 1;
  334. min-width: 0;
  335. }
  336. .type-icon {
  337. width: px2rpx(40);
  338. height: px2rpx(40);
  339. border-radius: px2rpx(10);
  340. display: flex;
  341. align-items: center;
  342. justify-content: center;
  343. flex-shrink: 0;
  344. }
  345. .transaction-icon {
  346. background-color: #eff6ff;
  347. }
  348. .icons {
  349. width: px2rpx(20);
  350. height: px2rpx(20);
  351. }
  352. .record-info {
  353. flex: 1;
  354. min-width: 0;
  355. display: flex;
  356. flex-direction: column;
  357. gap: px2rpx(6);
  358. }
  359. .info-header {
  360. display: flex;
  361. align-items: center;
  362. gap: px2rpx(8);
  363. flex-wrap: wrap;
  364. }
  365. .record-type {
  366. font-size: px2rpx(15);
  367. color: #111827;
  368. }
  369. .status-success {
  370. background-color: #f0fdf4;
  371. }
  372. .status-processing {
  373. background-color: #fefce8;
  374. }
  375. .status-failed {
  376. background-color: #fef2f2;
  377. }
  378. .status-text {
  379. font-size: px2rpx(11);
  380. }
  381. .status-text-success {
  382. color: #22c55e;
  383. }
  384. .status-text-processing {
  385. color: #eab308;
  386. }
  387. .status-text-failed {
  388. color: #ef4444;
  389. }
  390. .record-detail {
  391. font-size: px2rpx(13);
  392. color: #6b7280;
  393. overflow: hidden;
  394. text-overflow: ellipsis;
  395. white-space: nowrap;
  396. }
  397. .record-right {
  398. display: flex;
  399. flex-direction: column;
  400. align-items: flex-end;
  401. gap: px2rpx(4);
  402. margin-left: px2rpx(12);
  403. flex-shrink: 0;
  404. }
  405. .amount-transaction {
  406. font-size: px2rpx(18);
  407. color: #111827;
  408. }
  409. .fee-text {
  410. font-size: px2rpx(11);
  411. color: #9ca3af;
  412. }
  413. .record-footer {
  414. display: flex;
  415. align-items: center;
  416. justify-content: space-between;
  417. padding-top: px2rpx(16);
  418. border-top: 1px solid #f3f4f6;
  419. }
  420. .footer-actions {
  421. display: flex;
  422. align-items: center;
  423. gap: px2rpx(8);
  424. }
  425. .footer-time {
  426. font-size: px2rpx(11);
  427. color: #9ca3af;
  428. }
  429. .footer-actions {
  430. display: flex;
  431. align-items: center;
  432. gap: px2rpx(2);
  433. }
  434. .footer-order {
  435. font-size: px2rpx(11);
  436. color: #9ca3af;
  437. }
  438. .footer-detail {
  439. font-size: px2rpx(11);
  440. color: #2563eb;
  441. }
  442. </style>