GlobalList.vue 13 KB

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