Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 

673 linhas
14 KiB

  1. <template>
  2. <view class="page">
  3. <view class="wrap">
  4. <view class="wallet-card">
  5. <view class="wallet-copy">
  6. <text class="wallet-kicker">我的点数</text>
  7. <view class="wallet-main">
  8. <view class="balance-line">
  9. <text class="balance-number">{{ balanceText }}</text>
  10. <text v-if="balanceUnit" class="balance-unit">{{ balanceUnit }}</text>
  11. </view>
  12. <view class="recharge-action" @click="goRecharge">充值</view>
  13. </view>
  14. </view>
  15. <view class="wallet-stats">
  16. <view v-for="item in summaryStats" :key="item.key" class="stat-item">
  17. <text class="stat-value" :class="item.key">{{ item.value }}</text>
  18. <text class="stat-label">{{ item.label }}</text>
  19. </view>
  20. </view>
  21. </view>
  22. <view class="filter-panel">
  23. <view
  24. v-for="item in tabs"
  25. :key="item.key"
  26. class="filter-item"
  27. :class="{ active: activeTab === item.key }"
  28. @click="changeTab(item.key)"
  29. >
  30. <text class="filter-name">{{ item.name }}</text>
  31. <text class="filter-count">{{ tabCounts[item.key] || 0 }}条</text>
  32. </view>
  33. </view>
  34. <view class="list-head">
  35. <text class="list-title">流水明细</text>
  36. <text class="list-sub">{{ activeTabName }}</text>
  37. </view>
  38. <view v-if="list.length" class="ledger-panel">
  39. <view v-for="item in list" :key="item.id" class="ledger-row">
  40. <view class="type-mark" :class="item.type">
  41. <text>{{ getTypeMark(item.type) }}</text>
  42. </view>
  43. <view class="ledger-main">
  44. <view class="title-row">
  45. <text class="ledger-title">{{ item.title }}</text>
  46. <text class="ledger-status">已完成</text>
  47. </view>
  48. <text class="ledger-desc">{{ item.desc }}</text>
  49. <view class="ledger-meta">
  50. <text class="ledger-type" :class="item.type">{{ item.type_text }}</text>
  51. <text>{{ item.time }}</text>
  52. </view>
  53. </view>
  54. <text class="ledger-amount" :class="item.amount > 0 ? 'plus' : 'minus'">
  55. {{ formatAmount(item.amount) }}
  56. </text>
  57. </view>
  58. </view>
  59. <view v-if="!list.length && !loading" class="empty-state">
  60. <view class="empty-icon">账</view>
  61. <text class="empty-title">暂无账单记录</text>
  62. <text class="empty-desc">{{ activeTab === 'all' ? '充值或创作后,这里会展示点数变化。' : '当前分类还没有记录。' }}</text>
  63. </view>
  64. <view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view>
  65. <view v-if="list.length && list.length >= total && !loading" class="end-text">没有更多了</view>
  66. </view>
  67. </view>
  68. </template>
  69. <script setup>
  70. import { computed, ref } from 'vue'
  71. import { onLoad, onShow, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
  72. import { pointApi } from '@/api/index.js'
  73. import { useUserStore } from '@/store/user.js'
  74. import { useApp } from '@/utils/useApp.js'
  75. const userStore = useUserStore()
  76. const { toast } = useApp()
  77. const activeTab = ref('all')
  78. const list = ref([])
  79. const page = ref(1)
  80. const pageSize = 10
  81. const total = ref(0)
  82. const loading = ref(false)
  83. const refreshing = ref(false)
  84. const balance = ref({ points: 0, total_recharge: 0, total_consume: 0 })
  85. const summary = ref({
  86. recharge: 0,
  87. consume: 0,
  88. reward: 0,
  89. refund: 0,
  90. adjust: 0,
  91. counts: {
  92. all: 0,
  93. recharge: 0,
  94. consume: 0,
  95. reward: 0,
  96. other: 0
  97. }
  98. })
  99. let firstShow = true
  100. const tabs = [
  101. { key: 'all', name: '全部' },
  102. { key: 'recharge', name: '充值' },
  103. { key: 'consume', name: '消费' },
  104. { key: 'reward', name: '奖励' },
  105. { key: 'other', name: '其他' }
  106. ]
  107. const typeMark = {
  108. all: '',
  109. recharge: '+',
  110. consume: '-',
  111. reward: '奖',
  112. refund: '退',
  113. adjust: '调'
  114. }
  115. const balanceText = computed(() => {
  116. const points = balance.value.points !== undefined ? balance.value.points : userStore.balance
  117. return Number(points) < 0 ? '不限' : String(Number(points) || 0)
  118. })
  119. const balanceUnit = computed(() => {
  120. return ['不限', '无限'].includes(String(balanceText.value)) ? '' : '点'
  121. })
  122. const tabCounts = computed(() => {
  123. return summary.value.counts || {}
  124. })
  125. const activeTabName = computed(() => {
  126. const item = tabs.find((tab) => tab.key === activeTab.value)
  127. return item ? `${item.name}记录` : '全部记录'
  128. })
  129. const summaryStats = computed(() => {
  130. return [
  131. { key: 'recharge', label: '累计充值', value: formatSigned(summary.value.recharge, true) },
  132. { key: 'consume', label: '累计消费', value: summary.value.consume ? `-${summary.value.consume}` : '0' },
  133. { key: 'reward', label: '奖励获得', value: formatSigned(summary.value.reward, true) }
  134. ]
  135. })
  136. onLoad((options = {}) => {
  137. if (options.type && tabs.some((item) => item.key === options.type)) {
  138. activeTab.value = options.type
  139. }
  140. refreshSilent()
  141. })
  142. onShow(() => {
  143. if (firstShow) {
  144. firstShow = false
  145. return
  146. }
  147. refreshSilent()
  148. })
  149. onPullDownRefresh(() => {
  150. refreshing.value = true
  151. refreshSilent()
  152. })
  153. onReachBottom(() => loadMore())
  154. async function fetchList(reset = false) {
  155. if (loading.value) {
  156. if (refreshing.value) uni.stopPullDownRefresh()
  157. return
  158. }
  159. loading.value = true
  160. if (reset) page.value = 1
  161. try {
  162. const res = await pointApi.getLogList({
  163. type: activeTab.value,
  164. page_no: page.value,
  165. page_size: pageSize
  166. })
  167. const rows = res.list || []
  168. const data = res.data || {}
  169. list.value = reset ? rows : list.value.concat(rows)
  170. total.value = Number(data.total || rows.length)
  171. setBalance(data.balance || {})
  172. summary.value = normalizeSummary(data.summary || {})
  173. } catch (e) {
  174. toast(e.msg || '账单加载失败')
  175. } finally {
  176. loading.value = false
  177. refreshing.value = false
  178. uni.stopPullDownRefresh()
  179. }
  180. }
  181. function refreshSilent() {
  182. fetchList(true)
  183. }
  184. function loadMore() {
  185. if (loading.value || list.value.length >= total.value) return
  186. page.value += 1
  187. fetchList()
  188. }
  189. function changeTab(key) {
  190. if (activeTab.value === key) return
  191. activeTab.value = key
  192. refreshSilent()
  193. }
  194. function normalizeSummary(data = {}) {
  195. const counts = Object.assign({
  196. all: 0,
  197. recharge: 0,
  198. consume: 0,
  199. reward: 0,
  200. other: 0
  201. }, data.counts || {})
  202. Object.keys(counts).forEach((key) => {
  203. counts[key] = Number(counts[key] || 0)
  204. })
  205. return {
  206. recharge: Number(data.recharge || 0),
  207. consume: Number(data.consume || 0),
  208. reward: Number(data.reward || 0),
  209. refund: Number(data.refund || 0),
  210. adjust: Number(data.adjust || 0),
  211. counts
  212. }
  213. }
  214. function setBalance(data = {}) {
  215. balance.value = data
  216. if (data.points !== undefined || data.point_balance !== undefined) {
  217. userStore.balance = Number(data.points !== undefined ? data.points : data.point_balance) || 0
  218. userStore.balanceDetail = data
  219. }
  220. }
  221. function formatSigned(value, plus = false) {
  222. const num = Number(value || 0)
  223. if (num > 0 && plus) return `+${num}`
  224. return String(num)
  225. }
  226. function formatAmount(value) {
  227. const num = Number(value || 0)
  228. return num > 0 ? `+${num}` : String(num)
  229. }
  230. function getTypeMark(type) {
  231. return typeMark[type] || '记'
  232. }
  233. function goRecharge() {
  234. uni.navigateTo({ url: '/pages/recharge/recharge' })
  235. }
  236. </script>
  237. <style lang="scss" scoped>
  238. .page {
  239. min-height: 100vh;
  240. background: #f3f7fb;
  241. }
  242. .wrap {
  243. padding: 30rpx 30rpx calc(44rpx + env(safe-area-inset-bottom));
  244. box-sizing: border-box;
  245. }
  246. .wallet-card {
  247. position: relative;
  248. min-height: 308rpx;
  249. padding: 34rpx 32rpx 26rpx;
  250. border-radius: 30rpx;
  251. color: #ffffff;
  252. background: linear-gradient(135deg, #1167e8 0%, #0b8cff 58%, #41c7ff 100%);
  253. box-shadow: 0 18rpx 40rpx rgba(11, 140, 255, 0.22);
  254. overflow: hidden;
  255. box-sizing: border-box;
  256. }
  257. .wallet-card::before {
  258. content: '';
  259. position: absolute;
  260. right: -96rpx;
  261. top: -130rpx;
  262. width: 330rpx;
  263. height: 330rpx;
  264. border-radius: 50%;
  265. background: rgba(255, 255, 255, 0.16);
  266. }
  267. .wallet-card::after {
  268. content: '';
  269. position: absolute;
  270. left: 42rpx;
  271. right: 42rpx;
  272. bottom: 118rpx;
  273. height: 1rpx;
  274. background: rgba(255, 255, 255, 0.2);
  275. }
  276. .wallet-copy,
  277. .wallet-stats {
  278. position: relative;
  279. z-index: 1;
  280. }
  281. .wallet-kicker {
  282. display: block;
  283. color: rgba(255, 255, 255, 0.74);
  284. font-size: 24rpx;
  285. font-weight: 800;
  286. line-height: 1;
  287. }
  288. .wallet-main {
  289. margin-top: 20rpx;
  290. display: flex;
  291. align-items: center;
  292. justify-content: space-between;
  293. gap: 24rpx;
  294. }
  295. .balance-line {
  296. min-width: 0;
  297. display: flex;
  298. align-items: flex-end;
  299. }
  300. .balance-number {
  301. color: #ffffff;
  302. font-size: 70rpx;
  303. font-weight: 900;
  304. line-height: 0.95;
  305. }
  306. .balance-unit {
  307. margin-left: 10rpx;
  308. margin-bottom: 8rpx;
  309. color: rgba(255, 255, 255, 0.78);
  310. font-size: 26rpx;
  311. font-weight: 800;
  312. }
  313. .recharge-action {
  314. height: 68rpx;
  315. min-width: 136rpx;
  316. padding: 0 30rpx;
  317. border-radius: 999rpx;
  318. color: #0b74ea;
  319. background: #ffffff;
  320. font-size: 27rpx;
  321. font-weight: 900;
  322. line-height: 68rpx;
  323. text-align: center;
  324. box-sizing: border-box;
  325. flex-shrink: 0;
  326. }
  327. .wallet-stats {
  328. margin-top: 46rpx;
  329. display: grid;
  330. grid-template-columns: repeat(3, minmax(0, 1fr));
  331. gap: 12rpx;
  332. }
  333. .stat-item {
  334. min-width: 0;
  335. }
  336. .stat-value {
  337. display: block;
  338. color: #ffffff;
  339. font-size: 28rpx;
  340. font-weight: 900;
  341. line-height: 1;
  342. }
  343. .stat-value.consume {
  344. color: #ffe2c7;
  345. }
  346. .stat-label {
  347. display: block;
  348. margin-top: 10rpx;
  349. color: rgba(255, 255, 255, 0.68);
  350. font-size: 21rpx;
  351. line-height: 1;
  352. }
  353. .filter-panel {
  354. margin-top: 24rpx;
  355. padding: 8rpx;
  356. border-radius: 24rpx;
  357. background: #ffffff;
  358. box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
  359. display: grid;
  360. grid-template-columns: repeat(5, minmax(0, 1fr));
  361. gap: 8rpx;
  362. }
  363. .filter-item {
  364. height: 88rpx;
  365. border-radius: 20rpx;
  366. display: flex;
  367. flex-direction: column;
  368. align-items: center;
  369. justify-content: center;
  370. color: #7f8896;
  371. }
  372. .filter-item.active {
  373. color: #0b8cff;
  374. background: #eef7ff;
  375. }
  376. .filter-name {
  377. font-size: 27rpx;
  378. font-weight: 900;
  379. line-height: 1;
  380. }
  381. .filter-count {
  382. margin-top: 8rpx;
  383. font-size: 20rpx;
  384. font-weight: 700;
  385. line-height: 1;
  386. opacity: 0.72;
  387. }
  388. .list-head {
  389. margin-top: 30rpx;
  390. margin-bottom: 16rpx;
  391. display: flex;
  392. align-items: center;
  393. justify-content: space-between;
  394. }
  395. .list-title {
  396. color: #172033;
  397. font-size: 32rpx;
  398. font-weight: 900;
  399. }
  400. .list-sub {
  401. color: #8a95a6;
  402. font-size: 24rpx;
  403. }
  404. .ledger-panel {
  405. border-radius: 26rpx;
  406. background: #ffffff;
  407. box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
  408. overflow: hidden;
  409. }
  410. .ledger-row {
  411. position: relative;
  412. min-height: 156rpx;
  413. padding: 26rpx 28rpx;
  414. display: flex;
  415. align-items: center;
  416. gap: 22rpx;
  417. box-sizing: border-box;
  418. }
  419. .ledger-row::after {
  420. content: '';
  421. position: absolute;
  422. left: 112rpx;
  423. right: 28rpx;
  424. bottom: 0;
  425. height: 1rpx;
  426. background: #edf2f7;
  427. }
  428. .ledger-row:last-child::after {
  429. display: none;
  430. }
  431. .type-mark {
  432. width: 62rpx;
  433. height: 62rpx;
  434. border-radius: 20rpx;
  435. display: flex;
  436. align-items: center;
  437. justify-content: center;
  438. font-size: 29rpx;
  439. font-weight: 900;
  440. flex-shrink: 0;
  441. }
  442. .type-mark.recharge {
  443. color: #0b8cff;
  444. background: #eef7ff;
  445. }
  446. .type-mark.consume {
  447. color: #ff681f;
  448. background: #fff4ec;
  449. }
  450. .type-mark.reward {
  451. color: #17a65a;
  452. background: #edfdf4;
  453. }
  454. .type-mark.refund,
  455. .type-mark.adjust {
  456. color: #7a5af8;
  457. background: #f3f0ff;
  458. }
  459. .ledger-main {
  460. min-width: 0;
  461. flex: 1;
  462. }
  463. .title-row {
  464. display: flex;
  465. align-items: center;
  466. gap: 14rpx;
  467. }
  468. .ledger-title {
  469. min-width: 0;
  470. color: #172033;
  471. font-size: 29rpx;
  472. font-weight: 900;
  473. line-height: 1.25;
  474. overflow: hidden;
  475. text-overflow: ellipsis;
  476. white-space: nowrap;
  477. }
  478. .ledger-status {
  479. height: 32rpx;
  480. padding: 0 12rpx;
  481. border-radius: 999rpx;
  482. color: #17a65a;
  483. background: #edfdf4;
  484. font-size: 19rpx;
  485. font-weight: 800;
  486. line-height: 32rpx;
  487. flex-shrink: 0;
  488. }
  489. .ledger-desc {
  490. display: block;
  491. margin-top: 10rpx;
  492. color: #7f8896;
  493. font-size: 24rpx;
  494. line-height: 1.35;
  495. overflow: hidden;
  496. text-overflow: ellipsis;
  497. white-space: nowrap;
  498. }
  499. .ledger-meta {
  500. margin-top: 14rpx;
  501. display: flex;
  502. align-items: center;
  503. gap: 14rpx;
  504. color: #9aa4b2;
  505. font-size: 22rpx;
  506. }
  507. .ledger-type {
  508. height: 34rpx;
  509. padding: 0 14rpx;
  510. border-radius: 999rpx;
  511. font-size: 20rpx;
  512. font-weight: 900;
  513. line-height: 34rpx;
  514. }
  515. .ledger-type.recharge {
  516. color: #0b8cff;
  517. background: #eef7ff;
  518. }
  519. .ledger-type.consume {
  520. color: #ff681f;
  521. background: #fff4ec;
  522. }
  523. .ledger-type.reward {
  524. color: #17a65a;
  525. background: #edfdf4;
  526. }
  527. .ledger-type.refund,
  528. .ledger-type.adjust {
  529. color: #7a5af8;
  530. background: #f3f0ff;
  531. }
  532. .ledger-amount {
  533. color: #172033;
  534. font-size: 34rpx;
  535. font-weight: 900;
  536. line-height: 1;
  537. flex-shrink: 0;
  538. }
  539. .ledger-amount.plus {
  540. color: #17a65a;
  541. }
  542. .ledger-amount.minus {
  543. color: #ff681f;
  544. }
  545. .empty-state {
  546. min-height: 430rpx;
  547. margin-top: 0;
  548. border-radius: 26rpx;
  549. background: #ffffff;
  550. box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
  551. display: flex;
  552. flex-direction: column;
  553. align-items: center;
  554. justify-content: center;
  555. text-align: center;
  556. }
  557. .empty-icon {
  558. width: 92rpx;
  559. height: 92rpx;
  560. border-radius: 28rpx;
  561. color: #0b8cff;
  562. background: #eef7ff;
  563. font-size: 34rpx;
  564. font-weight: 900;
  565. line-height: 92rpx;
  566. }
  567. .empty-title {
  568. margin-top: 24rpx;
  569. color: #172033;
  570. font-size: 30rpx;
  571. font-weight: 900;
  572. line-height: 1;
  573. }
  574. .empty-desc {
  575. width: 500rpx;
  576. max-width: 80%;
  577. margin-top: 14rpx;
  578. color: #8a95a6;
  579. font-size: 24rpx;
  580. line-height: 1.45;
  581. }
  582. .loading,
  583. .end-text {
  584. padding: 28rpx 0 12rpx;
  585. color: #9aa4b2;
  586. font-size: 24rpx;
  587. line-height: 1;
  588. text-align: center;
  589. }
  590. </style>