settlement.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. <template>
  2. <view class="settlement-container">
  3. <!-- 筛选栏 -->
  4. <view class="filter-section">
  5. <view class="filter-row">
  6. <view class="filter-item">
  7. <text class="filter-label">站点</text>
  8. <picker mode="selector" :range="stationList" range-key="stationName" @change="handleStationChange">
  9. <view class="picker-value">
  10. <text :class="{ placeholder: !selectedStationName }">{{ selectedStationName || '全部站点' }}</text>
  11. <AppIcon name="chevron-down" :size="20" color="#999999" class="picker-arrow" />
  12. </view>
  13. </picker>
  14. </view>
  15. <view class="filter-item">
  16. <text class="filter-label">结算周期</text>
  17. <input type="text" placeholder="如 2026-05" v-model="settlementPeriod" @confirm="handleSearch" />
  18. </view>
  19. </view>
  20. <view class="filter-row">
  21. <view class="filter-segments">
  22. <view
  23. v-for="(option, index) in statusOptions"
  24. :key="index"
  25. class="segment-item"
  26. :class="{ active: activeStatus === option.value }"
  27. @click="handleStatusChange(option.value)">
  28. <text>{{ option.label }}</text>
  29. </view>
  30. </view>
  31. </view>
  32. <button class="search-btn" @click="handleSearch">查询</button>
  33. </view>
  34. <!-- 结算列表 -->
  35. <view class="settlement-list" v-if="list.length > 0">
  36. <view
  37. class="settlement-item"
  38. v-for="(item, index) in list"
  39. :key="index"
  40. @click="viewDetail(item)">
  41. <view class="item-header">
  42. <view class="item-left">
  43. <text class="item-station">{{ item.stationName || '-' }}</text>
  44. <text class="item-period">{{ item.settlementPeriod || '-' }}</text>
  45. </view>
  46. <text class="status-tag" :style="getStatusStyle(item.status)">{{ fmtDictName('Settlement.status', item.status) }}</text>
  47. </view>
  48. <view class="item-content">
  49. <view class="info-grid">
  50. <view class="info-item">
  51. <text class="info-label">期初余额</text>
  52. <text class="info-value">{{ formatAmount(item.openingPendingBalance) }}</text>
  53. </view>
  54. <view class="info-item">
  55. <text class="info-label">结算金额</text>
  56. <text class="info-value highlight">{{ formatAmount(item.settlementAmount) }}</text>
  57. </view>
  58. <view class="info-item">
  59. <text class="info-label">总充值</text>
  60. <text class="info-value">{{ formatAmount(item.totalRecharge) }}</text>
  61. </view>
  62. <view class="info-item">
  63. <text class="info-label">总退款</text>
  64. <text class="info-value">{{ formatAmount(item.totalRefund) }}</text>
  65. </view>
  66. </view>
  67. <view class="item-time">
  68. <text class="time-text">创建: {{ formatTime(item.createTime) }}</text>
  69. </view>
  70. </view>
  71. </view>
  72. </view>
  73. <!-- 加载更多 -->
  74. <view class="load-more" v-if="list.length > 0">
  75. <text class="load-more-text" v-if="loadMoreStatus === 'more'">上拉加载更多</text>
  76. <text class="load-more-text" v-else-if="loadMoreStatus === 'loading'">正在加载...</text>
  77. <text class="load-more-text" v-else>没有更多数据了</text>
  78. </view>
  79. <!-- 空状态 -->
  80. <view class="empty-state" v-if="list.length === 0 && !loading">
  81. <view class="empty-icon-wrapper">
  82. <AppIcon name="bar-chart" :size="80" color="#999999" />
  83. </view>
  84. <text class="empty-text">暂无结算记录</text>
  85. </view>
  86. <!-- 加载状态 -->
  87. <view class="loading-state" v-if="loading && list.length === 0">
  88. <view class="loading-spinner"></view>
  89. <text class="loading-text">加载中...</text>
  90. </view>
  91. <!-- 详情弹窗 -->
  92. <view class="detail-overlay" v-if="showDetail" @click="closeDetail">
  93. <view class="detail-card" @click.stop>
  94. <view class="detail-header">
  95. <text class="detail-title">结算详情</text>
  96. <view class="detail-close" @click="closeDetail">
  97. <AppIcon name="x" :size="32" color="#999999" />
  98. </view>
  99. </view>
  100. <view class="detail-content" v-if="detailItem">
  101. <view class="detail-row">
  102. <text class="d-label">站点名称</text>
  103. <text class="d-value">{{ detailItem.stationName || '-' }}</text>
  104. </view>
  105. <view class="detail-row">
  106. <text class="d-label">结算周期</text>
  107. <text class="d-value">{{ detailItem.settlementPeriod || '-' }}</text>
  108. </view>
  109. <view class="detail-row">
  110. <text class="d-label">期初余额</text>
  111. <text class="d-value amount">{{ formatAmount(detailItem.openingPendingBalance) }}</text>
  112. </view>
  113. <view class="detail-row">
  114. <text class="d-label">总充值</text>
  115. <text class="d-value">{{ formatAmount(detailItem.totalRecharge) }}</text>
  116. </view>
  117. <view class="detail-row">
  118. <text class="d-label">总退款</text>
  119. <text class="d-value">{{ formatAmount(detailItem.totalRefund) }}</text>
  120. </view>
  121. <view class="detail-row">
  122. <text class="d-label">跨店收入</text>
  123. <text class="d-value">{{ formatAmount(detailItem.totalCrossIncome) }}</text>
  124. </view>
  125. <view class="detail-row">
  126. <text class="d-label">跨店支出</text>
  127. <text class="d-value">{{ formatAmount(detailItem.totalCrossExpend) }}</text>
  128. </view>
  129. <view class="detail-row">
  130. <text class="d-label">平台费基数</text>
  131. <text class="d-value">{{ formatAmount(detailItem.platformFeeBase) }}</text>
  132. </view>
  133. <view class="detail-row">
  134. <text class="d-label">平台费</text>
  135. <text class="d-value">{{ formatAmount(detailItem.platformFee) }}</text>
  136. </view>
  137. <view class="detail-row highlight-row">
  138. <text class="d-label">结算金额</text>
  139. <text class="d-value settlement-amount">{{ formatAmount(detailItem.settlementAmount) }}</text>
  140. </view>
  141. <view class="detail-row">
  142. <text class="d-label">期末余额</text>
  143. <text class="d-value">{{ formatAmount(detailItem.closingPendingBalance) }}</text>
  144. </view>
  145. <view class="detail-row">
  146. <text class="d-label">状态</text>
  147. <text class="d-value" :style="getStatusStyle(detailItem.status)">{{ fmtDictName('Settlement.status', detailItem.status) }}</text>
  148. </view>
  149. <view class="detail-row" v-if="detailItem.remark">
  150. <text class="d-label">备注</text>
  151. <text class="d-value">{{ detailItem.remark }}</text>
  152. </view>
  153. </view>
  154. </view>
  155. </view>
  156. </view>
  157. </template>
  158. <script setup>
  159. import { ref, onMounted, computed } from 'vue'
  160. import { onReachBottom } from '@dcloudio/uni-app'
  161. import { getSettlementRecords } from '../../api/finance.js'
  162. import { getStationList } from '../../api/station.js'
  163. import { formatTime, showToast, formatAmount, fmtDictName, getDictColor } from '../../utils/index.js'
  164. import dictUtil, { loadDicts } from '../../utils/dict.js'
  165. const list = ref([])
  166. const loading = ref(true)
  167. const page = ref(1)
  168. const pageSize = ref(10)
  169. const hasMore = ref(true)
  170. const loadMoreStatus = ref('more')
  171. const activeStatus = ref('')
  172. const settlementPeriod = ref('')
  173. const stationList = ref([])
  174. const selectedStationId = ref('')
  175. const selectedStationName = ref('')
  176. const showDetail = ref(false)
  177. const detailItem = ref(null)
  178. const statusOptions = computed(() => dictUtil.getDictFilterOptions('Settlement.status'))
  179. const getStatusStyle = (status) => {
  180. const color = getDictColor('Settlement.status', status)
  181. if (color) return { color, backgroundColor: `${color}1A` }
  182. return {}
  183. }
  184. const loadStations = async () => {
  185. try {
  186. const res = await getStationList({ pageSize: 1024 })
  187. if (res && res.code === 200) {
  188. const data = res.data
  189. stationList.value = data.records || data.list || data || []
  190. stationList.value.unshift({ stationId: '', stationName: '全部站点' })
  191. }
  192. } catch (error) {
  193. showToast('加载站点列表失败')
  194. }
  195. }
  196. const handleStationChange = (e) => {
  197. const index = e.detail.value
  198. const selected = stationList.value[index]
  199. if (selected) {
  200. selectedStationId.value = selected.stationId || ''
  201. selectedStationName.value = selected.stationName || ''
  202. }
  203. }
  204. const loadData = async (isLoadMore = false) => {
  205. if (!isLoadMore) {
  206. page.value = 1
  207. list.value = []
  208. hasMore.value = true
  209. loadMoreStatus.value = 'more'
  210. } else {
  211. loadMoreStatus.value = 'loading'
  212. }
  213. loading.value = true
  214. try {
  215. const params = {
  216. pageNum: page.value,
  217. pageSize: pageSize.value
  218. }
  219. if (selectedStationId.value) params.stationId = selectedStationId.value
  220. if (settlementPeriod.value) params.settlementPeriod = settlementPeriod.value
  221. if (activeStatus.value !== '') params.status = activeStatus.value
  222. const res = await getSettlementRecords(params)
  223. if (res && res.code === 200) {
  224. const data = res.data
  225. const records = data.records || data.list || data
  226. const total = data.total || 0
  227. const totalPages = Math.ceil(total / pageSize.value)
  228. hasMore.value = page.value < totalPages
  229. loadMoreStatus.value = hasMore.value ? 'more' : 'noMore'
  230. if (isLoadMore) {
  231. list.value = [...list.value, ...records]
  232. page.value++
  233. } else {
  234. list.value = records
  235. page.value = 2
  236. }
  237. }
  238. } catch (error) {
  239. showToast('加载结算记录失败')
  240. } finally {
  241. loading.value = false
  242. }
  243. }
  244. const loadMore = () => {
  245. if (hasMore.value && !loading.value && loadMoreStatus.value !== 'loading') {
  246. loadData(true)
  247. }
  248. }
  249. onReachBottom(() => {
  250. loadMore()
  251. })
  252. const handleSearch = () => {
  253. loadData()
  254. }
  255. const handleStatusChange = (status) => {
  256. activeStatus.value = status
  257. loadData()
  258. }
  259. const viewDetail = (item) => {
  260. detailItem.value = item
  261. showDetail.value = true
  262. }
  263. const closeDetail = () => {
  264. showDetail.value = false
  265. detailItem.value = null
  266. }
  267. onMounted(async () => {
  268. await loadDicts()
  269. loadStations()
  270. loadData()
  271. })
  272. </script>
  273. <style scoped>
  274. .settlement-container {
  275. min-height: 100vh;
  276. background-color: #F5F7FA;
  277. padding-bottom: 60rpx;
  278. }
  279. .filter-section {
  280. background-color: #FFFFFF;
  281. padding: 24rpx 20rpx 20rpx;
  282. margin-bottom: 16rpx;
  283. }
  284. .filter-row {
  285. display: flex;
  286. gap: 16rpx;
  287. margin-bottom: 12rpx;
  288. }
  289. .filter-item {
  290. flex: 1;
  291. }
  292. .filter-label {
  293. font-size: 24rpx;
  294. color: #999999;
  295. margin-bottom: 8rpx;
  296. display: block;
  297. }
  298. .picker-value {
  299. display: flex;
  300. justify-content: space-between;
  301. align-items: center;
  302. background-color: #F5F5F5;
  303. border-radius: 16rpx;
  304. padding: 14rpx 20rpx;
  305. font-size: 26rpx;
  306. }
  307. .picker-value .placeholder {
  308. color: #B0B0B0;
  309. }
  310. .picker-arrow {
  311. flex-shrink: 0;
  312. }
  313. .filter-item input {
  314. background-color: #F5F5F5;
  315. border-radius: 16rpx;
  316. padding: 14rpx 20rpx;
  317. font-size: 26rpx;
  318. height: 56rpx;
  319. }
  320. .filter-segments {
  321. display: flex;
  322. gap: 12rpx;
  323. flex-wrap: wrap;
  324. }
  325. .segment-item {
  326. padding: 8rpx 24rpx;
  327. background-color: #F5F5F5;
  328. border-radius: 100rpx;
  329. font-size: 24rpx;
  330. color: #666666;
  331. }
  332. .segment-item.active {
  333. background: #C6171E;
  334. color: #FFFFFF;
  335. }
  336. .search-btn {
  337. width: 100%;
  338. padding: 16rpx 0;
  339. background: #C6171E;
  340. color: #FFFFFF;
  341. border-radius: 16rpx;
  342. font-size: 28rpx;
  343. border: none;
  344. }
  345. .settlement-list {
  346. padding: 0 20rpx;
  347. }
  348. .settlement-item {
  349. background-color: #FFFFFF;
  350. border-radius: 24rpx;
  351. padding: 24rpx;
  352. margin-bottom: 16rpx;
  353. box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06);
  354. }
  355. .settlement-item:active {
  356. transform: translateY(-4rpx);
  357. box-shadow: 0 2px 6px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.08);
  358. }
  359. .item-header {
  360. display: flex;
  361. justify-content: space-between;
  362. align-items: flex-start;
  363. padding-bottom: 16rpx;
  364. border-bottom: 1rpx solid #F0F0F0;
  365. margin-bottom: 16rpx;
  366. }
  367. .item-station {
  368. font-size: 30rpx;
  369. font-weight: 600;
  370. color: #1A1A1A;
  371. display: block;
  372. }
  373. .item-period {
  374. font-size: 24rpx;
  375. color: #999999;
  376. margin-top: 4rpx;
  377. display: block;
  378. }
  379. .status-tag {
  380. font-size: 22rpx;
  381. padding: 6rpx 24rpx;
  382. border-radius: 100rpx;
  383. font-weight: 500;
  384. }
  385. .info-grid {
  386. display: grid;
  387. grid-template-columns: repeat(2, 1fr);
  388. gap: 12rpx;
  389. }
  390. .info-item {
  391. padding: 8rpx 0;
  392. }
  393. .info-label {
  394. font-size: 24rpx;
  395. color: #999999;
  396. display: block;
  397. margin-bottom: 4rpx;
  398. }
  399. .info-value {
  400. font-size: 26rpx;
  401. color: #1A1A1A;
  402. font-weight: 500;
  403. }
  404. .info-value.highlight {
  405. color: #C6171E;
  406. font-size: 28rpx;
  407. }
  408. .item-time {
  409. margin-top: 12rpx;
  410. padding-top: 12rpx;
  411. border-top: 1rpx solid #F0F0F0;
  412. }
  413. .time-text {
  414. font-size: 24rpx;
  415. color: #999999;
  416. }
  417. .load-more {
  418. display: flex;
  419. justify-content: center;
  420. padding: 24rpx 0;
  421. }
  422. .load-more-text {
  423. font-size: 24rpx;
  424. color: #999999;
  425. }
  426. .empty-state {
  427. display: flex;
  428. flex-direction: column;
  429. align-items: center;
  430. padding: 80rpx 0;
  431. }
  432. .empty-text {
  433. font-size: 28rpx;
  434. color: #999999;
  435. margin-top: 20rpx;
  436. }
  437. .loading-state {
  438. display: flex;
  439. flex-direction: column;
  440. align-items: center;
  441. padding: 80rpx 0;
  442. }
  443. .loading-text {
  444. font-size: 28rpx;
  445. color: #999999;
  446. margin-top: 16rpx;
  447. }
  448. /* 详情弹窗 */
  449. .detail-overlay {
  450. position: fixed;
  451. top: 0;
  452. left: 0;
  453. right: 0;
  454. bottom: 0;
  455. background-color: rgba(0, 0, 0, 0.5);
  456. display: flex;
  457. align-items: flex-end;
  458. z-index: 1000;
  459. }
  460. .detail-card {
  461. width: 100%;
  462. max-height: 80vh;
  463. background-color: #FFFFFF;
  464. border-radius: 32rpx 32rpx 0 0;
  465. padding: 30rpx;
  466. overflow-y: auto;
  467. }
  468. .detail-header {
  469. display: flex;
  470. justify-content: space-between;
  471. align-items: center;
  472. margin-bottom: 30rpx;
  473. }
  474. .detail-title {
  475. font-size: 32rpx;
  476. font-weight: 600;
  477. color: #1A1A1A;
  478. }
  479. .detail-close {
  480. padding: 10rpx;
  481. }
  482. .detail-row {
  483. display: flex;
  484. justify-content: space-between;
  485. align-items: center;
  486. padding: 16rpx 0;
  487. border-bottom: 1rpx solid #F5F5F5;
  488. }
  489. .detail-row.highlight-row {
  490. background-color: rgba(198, 23, 30, 0.04);
  491. padding: 16rpx 20rpx;
  492. border-radius: 16rpx;
  493. margin: 8rpx 0;
  494. }
  495. .d-label {
  496. font-size: 26rpx;
  497. color: #999999;
  498. }
  499. .d-value {
  500. font-size: 26rpx;
  501. color: #1A1A1A;
  502. font-weight: 500;
  503. }
  504. .d-value.amount {
  505. color: #C6171E;
  506. }
  507. .d-value.settlement-amount {
  508. color: #C6171E;
  509. font-size: 28rpx;
  510. font-weight: 700;
  511. }
  512. </style>