Pārlūkot izejas kodu

feat: admin-h5 新增数据统计页面 & 修复 admin-web 编译错误

admin-h5: 新增 pages/statistics/index.vue,支持日/月统计切换,
卡片式展示关键指标(总收入/消费/退款/订单数),含汇总卡片、
详情弹窗和滚动加载。首页快捷入口及财务管理页均添加导航入口。

admin-web: 修复 index.vue 中未闭合的 /* 注释导致 vite 编译失败。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 2 dienas atpakaļ
vecāks
revīzija
afc23226e0

+ 18 - 0
admin-h5/src/api/stat.js

@@ -34,3 +34,21 @@ export const getDeviceStatus = (stationId, params = {}) => {
 export const getTrendData = (params) => {
   return post('/stat/trend', params)
 }
+
+/**
+ * 获取日统计列表
+ * @param {Object} params - 查询参数
+ * @returns {Promise} 日统计分页数据
+ */
+export const getDailyStatList = (params) => {
+  return post('/daily-stat/list', params)
+}
+
+/**
+ * 获取月统计列表
+ * @param {Object} params - 查询参数
+ * @returns {Promise} 月统计分页数据
+ */
+export const getMonthlyStatList = (params) => {
+  return post('/month-stat/list', params)
+}

+ 6 - 0
admin-h5/src/pages.json

@@ -77,6 +77,12 @@
         "navigationBarTitleText": "分账记录"
       }
     },
+    {
+      "path": "pages/statistics/index",
+      "style": {
+        "navigationBarTitleText": "数据统计"
+      }
+    },
     {
       "path": "pages/setting/index",
       "style": {

+ 5 - 0
admin-h5/src/pages/finance/index.vue

@@ -51,6 +51,11 @@
         <text class="menu-title">分账记录</text>
         <AppIcon name="chevron-right" :size="28" color="#B0B0B0" />
       </view>
+      <view class="menu-item" @click="navigateTo('/pages/statistics/index')">
+        <AppIcon name="bar-chart" :size="40" color="#C6171E" class="menu-icon" />
+        <text class="menu-title">数据统计</text>
+        <AppIcon name="chevron-right" :size="28" color="#B0B0B0" />
+      </view>
     </view>
 
     <view class="finance-tabs">

+ 6 - 0
admin-h5/src/pages/index/index.vue

@@ -128,6 +128,12 @@
           </view>
           <text class="action-label">用户管理</text>
         </view>
+        <view class="action-item" @click="navigateTo('/pages/statistics/index')">
+          <view class="action-icon-wrap">
+            <AppIcon name="bar-chart" :size="26" color="#C6171E" />
+          </view>
+          <text class="action-label">数据统计</text>
+        </view>
         <view class="action-item" @click="navigateTo('/pages/setting/device-config')">
           <view class="action-icon-wrap">
             <AppIcon name="settings" :size="26" color="#C6171E" />

+ 612 - 0
admin-h5/src/pages/statistics/index.vue

@@ -0,0 +1,612 @@
+<template>
+  <view class="statistics-container">
+    <!-- 日/月切换 -->
+    <view class="segment-section">
+      <view class="segmented-control">
+        <view
+          v-for="(option, index) in tabOptions"
+          :key="index"
+          class="segment-item"
+          :class="{ active: activeTab === index }"
+          @click="handleTabChange(index)">
+          <text>{{ option }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 筛选栏 -->
+    <view class="filter-section">
+      <view class="filter-row">
+        <view class="filter-item">
+          <text class="filter-label">站点</text>
+          <picker mode="selector" :range="stationList" range-key="stationName" @change="handleStationChange">
+            <view class="picker-value">
+              <text :class="{ placeholder: !selectedStationName }">{{ selectedStationName || '全部站点' }}</text>
+              <AppIcon name="chevron-down" :size="20" color="#999999" class="picker-arrow" />
+            </view>
+          </picker>
+        </view>
+        <view class="filter-item">
+          <text class="filter-label">{{ activeTab === 0 ? '日期' : '月份' }}</text>
+          <input
+            type="text"
+            :placeholder="activeTab === 0 ? '如 2026-06-10' : '如 2026-06'"
+            v-model="filterKey"
+            @confirm="handleSearch" />
+        </view>
+      </view>
+      <button class="search-btn" @click="handleSearch">查询</button>
+    </view>
+
+    <!-- 汇总卡片 -->
+    <view class="summary-section" v-if="list.length > 0">
+      <view class="summary-card">
+        <text class="summary-label">总收入</text>
+        <text class="summary-value highlight">¥{{ formatAmount(summary.totalIncome) }}</text>
+      </view>
+      <view class="summary-card">
+        <text class="summary-label">订单数</text>
+        <text class="summary-value">¥{{ formatAmount(summary.totalConsumption) }}</text>
+      </view>
+      <view class="summary-card">
+        <text class="summary-label">注册用户</text>
+        <text class="summary-value">{{ summary.totalRegistrations }}</text>
+      </view>
+      <view class="summary-card">
+        <text class="summary-label">活跃用户</text>
+        <text class="summary-value">{{ summary.totalActiveUsers }}</text>
+      </view>
+    </view>
+
+    <!-- 数据列表 -->
+    <view class="stats-list" v-if="list.length > 0">
+      <view
+        class="stats-item"
+        v-for="(item, index) in list"
+        :key="index"
+        @click="viewDetail(item)">
+        <view class="item-header">
+          <view class="item-left">
+            <text class="item-period">{{ activeTab === 0 ? item.statDate : item.statMonth }}</text>
+            <text class="item-station">{{ item.stationName || '-' }}</text>
+          </view>
+          <AppIcon name="chevron-right" :size="24" color="#B0B0B0" />
+        </view>
+        <view class="item-content">
+          <view class="info-grid">
+            <view class="info-item">
+              <text class="info-label">总收入</text>
+              <text class="info-value highlight">¥{{ formatAmount(item.totalIncome) }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">消费金额</text>
+              <text class="info-value">¥{{ formatAmount(item.consumption) }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">退款金额</text>
+              <text class="info-value">¥{{ formatAmount(item.totalRefund) }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">订单数</text>
+              <text class="info-value">{{ item.ordersCount || 0 }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 加载更多 -->
+    <view class="load-more" v-if="list.length > 0">
+      <text class="load-more-text" v-if="loadMoreStatus === 'more'">上拉加载更多</text>
+      <text class="load-more-text" v-else-if="loadMoreStatus === 'loading'">正在加载...</text>
+      <text class="load-more-text" v-else>没有更多数据了</text>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty-state" v-if="list.length === 0 && !loading">
+      <view class="empty-icon-wrapper">
+        <AppIcon name="bar-chart" :size="80" color="#999999" />
+      </view>
+      <text class="empty-text">暂无统计数据</text>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading-state" v-if="loading && list.length === 0">
+      <view class="loading-spinner"></view>
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <!-- 详情弹窗 -->
+    <view class="detail-overlay" v-if="showDetail" @click="closeDetail">
+      <view class="detail-card" @click.stop>
+        <view class="detail-header">
+          <text class="detail-title">{{ activeTab === 0 ? '日统计详情' : '月统计详情' }}</text>
+          <view class="detail-close" @click="closeDetail">
+            <AppIcon name="x" :size="32" color="#999999" />
+          </view>
+        </view>
+        <view class="detail-content" v-if="detailItem">
+          <view class="detail-row">
+            <text class="d-label">{{ activeTab === 0 ? '日期' : '月份' }}</text>
+            <text class="d-value">{{ activeTab === 0 ? detailItem.statDate : detailItem.statMonth }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="d-label">站点名称</text>
+            <text class="d-value">{{ detailItem.stationName || '-' }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="d-label">总收入</text>
+            <text class="d-value amount">¥{{ formatAmount(detailItem.totalIncome) }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="d-label">退款金额</text>
+            <text class="d-value">¥{{ formatAmount(detailItem.totalRefund) }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="d-label">跨店收入</text>
+            <text class="d-value">¥{{ formatAmount(detailItem.crossIncome) }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="d-label">跨店支出</text>
+            <text class="d-value">¥{{ formatAmount(detailItem.crossExpend) }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="d-label">消费金额</text>
+            <text class="d-value">¥{{ formatAmount(detailItem.consumption) }}</text>
+          </view>
+          <view class="detail-row" v-if="activeTab === 1 && detailItem.platformFee !== undefined">
+            <text class="d-label">平台服务费</text>
+            <text class="d-value">¥{{ formatAmount(detailItem.platformFee) }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="d-label">充值笔数</text>
+            <text class="d-value">{{ detailItem.rechargeCount || 0 }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="d-label">退款笔数</text>
+            <text class="d-value">{{ detailItem.refundCount || 0 }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="d-label">注册人数</text>
+            <text class="d-value">{{ detailItem.registrations || 0 }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="d-label">活跃用户数</text>
+            <text class="d-value">{{ detailItem.activeUserCount || 0 }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="d-label">订单数量</text>
+            <text class="d-value">{{ detailItem.ordersCount || 0 }}</text>
+          </view>
+          <view class="detail-row" v-if="detailItem.createTime">
+            <text class="d-label">创建时间</text>
+            <text class="d-value">{{ formatTime(detailItem.createTime) }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { getDailyStatList, getMonthlyStatList } from '../../api/stat.js'
+import { getStationList } from '../../api/station.js'
+import { formatTime, showToast, formatAmount } from '../../utils/index.js'
+import { loadDicts } from '../../utils/dict.js'
+
+const activeTab = ref(0)
+const tabOptions = ['日统计', '月统计']
+
+const list = ref([])
+const loading = ref(true)
+const page = ref(1)
+const pageSize = ref(10)
+const hasMore = ref(true)
+const loadMoreStatus = ref('more')
+const filterKey = ref('')
+const stationList = ref([])
+const selectedStationId = ref('')
+const selectedStationName = ref('')
+const showDetail = ref(false)
+const detailItem = ref(null)
+
+const summary = computed(() => {
+  const totalIncome = list.value.reduce((sum, item) => sum + (item.totalIncome || 0), 0)
+  const totalConsumption = list.value.reduce((sum, item) => sum + (item.consumption || 0), 0)
+  const totalRegistrations = list.value.reduce((sum, item) => sum + (item.registrations || 0), 0)
+  const totalActiveUsers = list.value.reduce((sum, item) => sum + (item.activeUserCount || 0), 0)
+  return { totalIncome, totalConsumption, totalRegistrations, totalActiveUsers }
+})
+
+const loadStations = async () => {
+  try {
+    const res = await getStationList({ pageSize: 1024 })
+    if (res && res.code === 200) {
+      const data = res.data
+      stationList.value = data.records || data.list || data || []
+      stationList.value.unshift({ stationId: '', stationName: '全部站点' })
+    }
+  } catch (error) {
+    showToast('加载站点列表失败')
+  }
+}
+
+const handleStationChange = (e) => {
+  const index = e.detail.value
+  const selected = stationList.value[index]
+  if (selected) {
+    selectedStationId.value = selected.stationId || ''
+    selectedStationName.value = selected.stationName || ''
+  }
+}
+
+const handleTabChange = (index) => {
+  activeTab.value = index
+  filterKey.value = ''
+  loadData()
+}
+
+const loadData = async (isLoadMore = false) => {
+  if (!isLoadMore) {
+    page.value = 1
+    list.value = []
+    hasMore.value = true
+    loadMoreStatus.value = 'more'
+  } else {
+    loadMoreStatus.value = 'loading'
+  }
+
+  loading.value = true
+  try {
+    const params = {
+      pageNum: page.value,
+      pageSize: pageSize.value
+    }
+    if (selectedStationId.value) params.stationId = selectedStationId.value
+
+    const fetcher = activeTab.value === 0 ? getDailyStatList : getMonthlyStatList
+
+    // 日期筛选
+    if (filterKey.value) {
+      if (activeTab.value === 0) {
+        params.startDate = filterKey.value
+        params.endDate = filterKey.value
+      } else {
+        params.statMonth = filterKey.value
+      }
+    }
+
+    const res = await fetcher(params)
+    if (res && res.code === 200) {
+      const data = res.data
+      const records = data.records || data.list || data
+      const total = data.total || 0
+
+      const totalPages = Math.ceil(total / pageSize.value)
+      hasMore.value = page.value < totalPages
+      loadMoreStatus.value = hasMore.value ? 'more' : 'noMore'
+
+      if (isLoadMore) {
+        list.value = [...list.value, ...records]
+        page.value++
+      } else {
+        list.value = records
+        page.value = 2
+      }
+    }
+  } catch (error) {
+    showToast('加载统计数据失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const loadMore = () => {
+  if (hasMore.value && !loading.value && loadMoreStatus.value !== 'loading') {
+    loadData(true)
+  }
+}
+
+onReachBottom(() => {
+  loadMore()
+})
+
+const handleSearch = () => {
+  loadData()
+}
+
+const viewDetail = (item) => {
+  detailItem.value = item
+  showDetail.value = true
+}
+
+const closeDetail = () => {
+  showDetail.value = false
+  detailItem.value = null
+}
+
+onMounted(async () => {
+  await loadDicts()
+  loadStations()
+  loadData()
+})
+</script>
+
+<style scoped>
+.statistics-container {
+  min-height: 100vh;
+  background-color: #F5F7FA;
+  padding-bottom: 60rpx;
+}
+
+.segment-section {
+  background-color: #FFFFFF;
+  padding: 20rpx 30rpx;
+}
+
+.segmented-control {
+  display: flex;
+  width: 100%;
+  border-radius: 16rpx;
+  overflow: hidden;
+  background-color: #F5F5F5;
+  padding: 6rpx;
+  gap: 6rpx;
+}
+
+.segment-item {
+  flex: 1;
+  text-align: center;
+  padding: 16rpx 0;
+  font-size: 26rpx;
+  color: #666666;
+  transition: all 0.3s;
+  border-radius: 12rpx;
+  font-weight: 500;
+}
+
+.segment-item.active {
+  color: #FFFFFF;
+  font-weight: 600;
+  background: #C6171E;
+  box-shadow: 0 4rpx 12rpx rgba(198, 23, 30, 0.3);
+}
+
+.filter-section {
+  background-color: #FFFFFF;
+  padding: 0 20rpx 20rpx;
+  margin-bottom: 16rpx;
+}
+.filter-row {
+  display: flex;
+  gap: 16rpx;
+  margin-bottom: 12rpx;
+}
+.filter-item {
+  flex: 1;
+}
+.filter-label {
+  font-size: 24rpx;
+  color: #999999;
+  margin-bottom: 8rpx;
+  display: block;
+}
+.picker-value {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  background-color: #F5F5F5;
+  border-radius: 16rpx;
+  padding: 14rpx 20rpx;
+  font-size: 26rpx;
+}
+.picker-value .placeholder {
+  color: #B0B0B0;
+}
+.picker-arrow {
+  flex-shrink: 0;
+}
+.filter-item input {
+  background-color: #F5F5F5;
+  border-radius: 16rpx;
+  padding: 14rpx 20rpx;
+  font-size: 26rpx;
+  height: 56rpx;
+}
+.search-btn {
+  width: 100%;
+  padding: 16rpx 0;
+  background: #C6171E;
+  color: #FFFFFF;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  border: none;
+}
+
+.summary-section {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 16rpx;
+  padding: 0 20rpx 16rpx;
+}
+.summary-card {
+  background-color: #FFFFFF;
+  padding: 24rpx;
+  border-radius: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+.summary-label {
+  font-size: 24rpx;
+  color: #999999;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.summary-value {
+  font-size: 34rpx;
+  font-weight: 700;
+  color: #1A1A1A;
+  display: block;
+}
+.summary-value.highlight {
+  color: #C6171E;
+}
+
+.stats-list {
+  padding: 0 20rpx;
+}
+.stats-item {
+  background-color: #FFFFFF;
+  border-radius: 24rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06);
+}
+.stats-item:active {
+  transform: translateY(-4rpx);
+  box-shadow: 0 2px 6px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.08);
+}
+.item-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding-bottom: 16rpx;
+  border-bottom: 1rpx solid #F0F0F0;
+  margin-bottom: 16rpx;
+}
+.item-period {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1A1A1A;
+  display: block;
+}
+.item-station {
+  font-size: 24rpx;
+  color: #999999;
+  margin-top: 4rpx;
+  display: block;
+}
+.info-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 12rpx;
+}
+.info-item {
+  padding: 8rpx 0;
+}
+.info-label {
+  font-size: 24rpx;
+  color: #999999;
+  display: block;
+  margin-bottom: 4rpx;
+}
+.info-value {
+  font-size: 26rpx;
+  color: #1A1A1A;
+  font-weight: 500;
+}
+.info-value.highlight {
+  color: #C6171E;
+  font-size: 28rpx;
+}
+
+.load-more {
+  display: flex;
+  justify-content: center;
+  padding: 24rpx 0;
+}
+.load-more-text {
+  font-size: 24rpx;
+  color: #999999;
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 0;
+}
+.empty-text {
+  font-size: 28rpx;
+  color: #999999;
+  margin-top: 20rpx;
+}
+
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 0;
+}
+.loading-text {
+  font-size: 28rpx;
+  color: #999999;
+  margin-top: 16rpx;
+}
+.loading-spinner {
+  width: 60rpx;
+  height: 60rpx;
+  border: 4rpx solid rgba(198, 23, 30, 0.2);
+  border-top-color: #C6171E;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+/* 详情弹窗 */
+.detail-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: flex-end;
+  z-index: 1000;
+}
+.detail-card {
+  width: 100%;
+  max-height: 80vh;
+  background-color: #FFFFFF;
+  border-radius: 32rpx 32rpx 0 0;
+  padding: 30rpx;
+  overflow-y: auto;
+}
+.detail-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 30rpx;
+}
+.detail-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1A1A1A;
+}
+.detail-close {
+  padding: 10rpx;
+}
+.detail-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #F5F5F5;
+}
+.d-label {
+  font-size: 26rpx;
+  color: #999999;
+}
+.d-value {
+  font-size: 26rpx;
+  color: #1A1A1A;
+  font-weight: 500;
+}
+.d-value.amount {
+  color: #C6171E;
+}
+</style>