Dashboard.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. <template>
  2. <div class="dashboard-container">
  3. <el-card class="section-card" v-loading="statLoading">
  4. <div slot="header" class="card-header">
  5. <span><i class="el-icon-data-analysis"></i> 数据概览</span>
  6. </div>
  7. <el-row :gutter="16">
  8. <el-col :xs="12" :sm="8" :md="4" v-for="(card, idx) in statCards" :key="idx">
  9. <div class="stat-card" :class="'stat-' + card.color">
  10. <div class="stat-icon-wrap">
  11. <i :class="card.icon"></i>
  12. </div>
  13. <div class="stat-body">
  14. <div class="stat-value">{{ card.value }}</div>
  15. <div class="stat-label">{{ card.label }}</div>
  16. </div>
  17. </div>
  18. </el-col>
  19. </el-row>
  20. </el-card>
  21. <el-row :gutter="16" class="section-row">
  22. <el-col :xs="24" :md="16">
  23. <el-card class="chart-card" v-loading="trendLoading">
  24. <div slot="header" class="card-header">
  25. <span><i class="el-icon-trend"></i> 用户与家庭增长趋势(近30天)</span>
  26. </div>
  27. <div ref="trendChart" class="echarts-container"></div>
  28. </el-card>
  29. </el-col>
  30. <el-col :xs="24" :md="8">
  31. <el-card class="chart-card" v-loading="memberLoading">
  32. <div slot="header" class="card-header">
  33. <span><i class="el-icon-pie-chart"></i> 会员等级分布</span>
  34. </div>
  35. <div ref="memberPieChart" class="echarts-container"></div>
  36. </el-card>
  37. </el-col>
  38. </el-row>
  39. <el-row :gutter="16" class="section-row">
  40. <el-col :xs="24" :md="8" v-for="(item, idx) in revenueCards" :key="idx">
  41. <el-card class="revenue-card" v-loading="revenueLoading">
  42. <div slot="header" class="card-header">
  43. <span><i :class="item.icon"></i> {{ item.label }}</span>
  44. </div>
  45. <div class="revenue-body">
  46. <div class="revenue-value" :class="'revenue-' + item.color">{{ item.value }}</div>
  47. <div v-if="item.sub" class="revenue-sub">{{ item.sub }}</div>
  48. </div>
  49. </el-card>
  50. </el-col>
  51. </el-row>
  52. <el-card class="section-card" v-loading="statLoading">
  53. <div slot="header" class="card-header">
  54. <span><i class="el-icon-s-order"></i> 最近任务</span>
  55. </div>
  56. <el-table :data="recentTasks" border stripe empty-text="暂无任务" size="small">
  57. <el-table-column prop="id" label="ID" width="60" />
  58. <el-table-column prop="title" label="任务名称" min-width="160" show-overflow-tooltip />
  59. <el-table-column prop="type" label="类型" width="80">
  60. <template slot-scope="{ row }">
  61. <el-tag :type="taskTypeTag(row.type)" size="mini">{{ row.type }}</el-tag>
  62. </template>
  63. </el-table-column>
  64. <el-table-column prop="points" label="积分" width="60" />
  65. <el-table-column prop="status" label="状态" width="80">
  66. <template slot-scope="{ row }">
  67. <el-tag :type="taskStatusTag(row.status)" size="mini">{{ taskStatusLabel(row.status) }}</el-tag>
  68. </template>
  69. </el-table-column>
  70. <el-table-column label="创建时间" width="150">
  71. <template slot-scope="{ row }">{{ row.createdAt || '-' }}</template>
  72. </el-table-column>
  73. </el-table>
  74. </el-card>
  75. </div>
  76. </template>
  77. <script>
  78. import * as echarts from 'echarts'
  79. import { getDashboardStats, getRevenueStats, getMembershipStats, getTrendStats } from '@/api/admin'
  80. export default {
  81. name: 'AdminDashboard',
  82. data() {
  83. return {
  84. statLoading: false,
  85. trendLoading: false,
  86. memberLoading: false,
  87. revenueLoading: false,
  88. statCards: [
  89. { label: '家庭总数', value: '-', icon: 'el-icon-s-home', color: 'orange' },
  90. { label: '家长总数', value: '-', icon: 'el-icon-user', color: 'blue' },
  91. { label: '孩子总数', value: '-', icon: 'el-icon-s-custom', color: 'cyan' },
  92. { label: '规划师总数', value: '-', icon: 'el-icon-school', color: 'green' },
  93. { label: '待审核规划师', value: '-', icon: 'el-icon-time', color: 'red' },
  94. { label: '待审核套餐', value: '-', icon: 'el-icon-document', color: 'purple' }
  95. ],
  96. revenueCards: [
  97. { label: '本月佣金', value: '-', icon: 'el-icon-money', color: 'orange', sub: '' },
  98. { label: '累计佣金', value: '-', icon: 'el-icon-coin', color: 'green', sub: '' },
  99. { label: '本月会员升级', value: '-', icon: 'el-icon-top', color: 'blue', sub: '' }
  100. ],
  101. recentTasks: [],
  102. trendData: { familyTrend: {}, userTrend: {} },
  103. membershipData: {},
  104. trendChart: null,
  105. memberChart: null,
  106. resizeHandler: null
  107. }
  108. },
  109. mounted() {
  110. this.loadDashboard()
  111. this.loadRevenue()
  112. this.loadMembership()
  113. this.loadTrend()
  114. this.resizeHandler = () => {
  115. this.trendChart && this.trendChart.resize()
  116. this.memberChart && this.memberChart.resize()
  117. }
  118. window.addEventListener('resize', this.resizeHandler)
  119. },
  120. beforeDestroy() {
  121. this.trendChart && this.trendChart.dispose()
  122. this.memberChart && this.memberChart.dispose()
  123. this.resizeHandler && window.removeEventListener('resize', this.resizeHandler)
  124. },
  125. methods: {
  126. async loadDashboard() {
  127. this.statLoading = true
  128. try {
  129. const res = await getDashboardStats()
  130. if (res.code === 200 && res.data) {
  131. const d = res.data
  132. this.statCards[0].value = d.totalFamilies || 0
  133. this.statCards[1].value = d.totalParents || 0
  134. this.statCards[2].value = d.totalChildren || 0
  135. this.statCards[3].value = d.totalTeachers || 0
  136. this.statCards[4].value = d.pendingGuideCount || 0
  137. this.statCards[5].value = d.pendingPackageCount || 0
  138. this.recentTasks = d.recentTasks || []
  139. }
  140. } catch (e) {
  141. this.$message.error('加载概览数据失败')
  142. } finally {
  143. this.statLoading = false
  144. }
  145. },
  146. async loadRevenue() {
  147. this.revenueLoading = true
  148. try {
  149. const res = await getRevenueStats()
  150. if (res.code === 200 && res.data) {
  151. const d = res.data
  152. this.revenueCards[0].value = '\u00a5' + (d.monthCommission || 0)
  153. this.revenueCards[0].sub = '\u5f85\u7ed3\u7b97: \u00a5' + (d.pendingCommission || 0)
  154. this.revenueCards[1].value = '\u00a5' + (d.totalCommission || 0)
  155. }
  156. } catch (e) {
  157. this.$message.error('\u52a0\u8f7d\u6536\u5165\u6570\u636e\u5931\u8d25')
  158. } finally {
  159. this.revenueLoading = false
  160. }
  161. },
  162. async loadMembership() {
  163. this.memberLoading = true
  164. try {
  165. const res = await getMembershipStats()
  166. if (res.code === 200 && res.data) {
  167. this.membershipData = res.data
  168. this.revenueCards[2].value = res.data.monthUpgrades || 0
  169. this.revenueCards[2].sub = '\u8bd5\u7528\u4f1a\u5458: ' + (res.data.trialCount || 0)
  170. this.$nextTick(() => this.initMemberPieChart())
  171. }
  172. } catch (e) {
  173. this.$message.error('\u52a0\u8f7d\u4f1a\u5458\u6570\u636e\u5931\u8d25')
  174. } finally {
  175. this.memberLoading = false
  176. }
  177. },
  178. async loadTrend() {
  179. this.trendLoading = true
  180. try {
  181. const res = await getTrendStats()
  182. if (res.code === 200 && res.data) {
  183. this.trendData = res.data
  184. this.$nextTick(() => this.initTrendChart())
  185. }
  186. } catch (e) {
  187. this.$message.error('\u52a0\u8f7d\u8d8b\u52bf\u6570\u636e\u5931\u8d25')
  188. } finally {
  189. this.trendLoading = false
  190. }
  191. },
  192. initTrendChart() {
  193. if (this.trendChart) this.trendChart.dispose()
  194. this.trendChart = echarts.init(this.$refs.trendChart)
  195. const familyData = Object.values(this.trendData.familyTrend || {})
  196. const userData = Object.values(this.trendData.userTrend || {})
  197. const xAxis = Object.keys(this.trendData.familyTrend || {})
  198. this.trendChart.setOption({
  199. tooltip: { trigger: 'axis' },
  200. legend: { data: ['\u65b0\u589e\u5bb6\u5ead', '\u65b0\u589e\u7528\u6237'] },
  201. grid: { left: 50, right: 20, bottom: 30, top: 40 },
  202. xAxis: { type: 'category', data: xAxis, axisLabel: { rotate: xAxis.length > 15 ? 45 : 0 } },
  203. yAxis: { type: 'value', minInterval: 1 },
  204. series: [
  205. { name: '\u65b0\u589e\u5bb6\u5ead', type: 'bar', barWidth: '40%', data: familyData, itemStyle: { color: '#F97316', borderRadius: [4, 4, 0, 0] } },
  206. { name: '\u65b0\u589e\u7528\u6237', type: 'line', smooth: true, data: userData, lineStyle: { color: '#0EA5E9', width: 2 }, itemStyle: { color: '#0EA5E9' } }
  207. ]
  208. })
  209. },
  210. initMemberPieChart() {
  211. if (this.memberChart) this.memberChart.dispose()
  212. this.memberChart = echarts.init(this.$refs.memberPieChart)
  213. this.memberChart.setOption({
  214. tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
  215. legend: { orient: 'vertical', left: 'left', top: 'center' },
  216. series: [{
  217. type: 'pie',
  218. radius: ['40%', '65%'],
  219. center: ['55%', '50%'],
  220. avoidLabelOverlap: true,
  221. label: { show: false },
  222. emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
  223. data: [
  224. { value: this.membershipData.freeCount || 0, name: '\u514d\u8d39\u7528\u6237', itemStyle: { color: '#94A3B8' } },
  225. { value: this.membershipData.familyCount || 0, name: '\u5bb6\u5ead\u4f1a\u5458', itemStyle: { color: '#F97316' } },
  226. { value: this.membershipData.providerCount || 0, name: '\u670d\u52a1\u5546', itemStyle: { color: '#10B981' } }
  227. ]
  228. }]
  229. })
  230. },
  231. taskTypeTag(type) {
  232. const map = { daily: '', weekly: 'success', challenge: 'warning', custom: 'info' }
  233. return map[type] || ''
  234. },
  235. taskStatusTag(status) {
  236. const map = { pending: 'warning', approved: 'success', completed: '', rejected: 'danger' }
  237. return map[status] || 'info'
  238. },
  239. taskStatusLabel(status) {
  240. const map = { pending: '\u5f85\u5ba1\u6838', approved: '\u5df2\u901a\u8fc7', completed: '\u5df2\u5b8c\u6210', rejected: '\u5df2\u62d2\u7edd' }
  241. return map[status] || status
  242. }
  243. }
  244. }
  245. </script>
  246. <style scoped>
  247. .dashboard-container { padding: 20px; }
  248. .section-row { margin-top: 16px; }
  249. .section-card { margin-bottom: 0; }
  250. .card-header { font-weight: 600; font-size: 15px; color: #1E293B; }
  251. .card-header i { margin-right: 6px; }
  252. .stat-card {
  253. display: flex; align-items: center; padding: 16px 12px;
  254. background: #fff; border-radius: 8px; border: 1px solid #f0f0f0;
  255. transition: all 0.25s ease; margin-bottom: 12px;
  256. }
  257. .stat-card:hover {
  258. transform: translateY(-2px);
  259. box-shadow: 0 4px 12px rgba(0,0,0,0.08);
  260. }
  261. .stat-icon-wrap {
  262. width: 44px; height: 44px; border-radius: 10px;
  263. display: flex; align-items: center; justify-content: center;
  264. font-size: 22px; margin-right: 12px; flex-shrink: 0;
  265. }
  266. .stat-orange .stat-icon-wrap { background: #FEF3C7; color: #F97316; }
  267. .stat-blue .stat-icon-wrap { background: #DBEAFE; color: #3B82F6; }
  268. .stat-cyan .stat-icon-wrap { background: #CFFAFE; color: #06B6D4; }
  269. .stat-green .stat-icon-wrap { background: #D1FAE5; color: #10B981; }
  270. .stat-red .stat-icon-wrap { background: #FEE2E2; color: #EF4444; }
  271. .stat-purple .stat-icon-wrap { background: #EDE9FE; color: #8B5CF6; }
  272. .stat-body { flex: 1; min-width: 0; }
  273. .stat-value { font-size: 24px; font-weight: 700; color: #1E293B; line-height: 1.2; }
  274. .stat-label { font-size: 13px; color: #94A3B8; margin-top: 2px; }
  275. .chart-card { min-height: 320px; }
  276. .echarts-container { height: 260px; width: 100%; }
  277. .revenue-card { min-height: 140px; }
  278. .revenue-body { text-align: center; padding: 8px 0; }
  279. .revenue-value { font-size: 28px; font-weight: 700; line-height: 1.3; }
  280. .revenue-orange { color: #F97316; }
  281. .revenue-green { color: #10B981; }
  282. .revenue-blue { color: #3B82F6; }
  283. .revenue-sub { font-size: 13px; color: #94A3B8; margin-top: 6px; }
  284. @media (max-width: 768px) {
  285. .dashboard-container { padding: 12px; }
  286. .stat-card { padding: 12px 10px; }
  287. .stat-value { font-size: 20px; }
  288. .echarts-container { height: 200px; }
  289. }
  290. </style>