index.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. <script setup lang="ts">
  2. import { ref, onMounted, onUnmounted, computed } from "vue";
  3. import * as echarts from "echarts";
  4. import { getStatisticsOverview } from "@/api/statistics";
  5. import type { EChartsOption } from "echarts";
  6. defineOptions({
  7. name: "StatisticsOverview"
  8. });
  9. // ==================== 类型定义 ====================
  10. interface CategoryStat {
  11. category: string;
  12. quantity: number;
  13. salesAmount: number;
  14. costAmount: number;
  15. profitAmount: number;
  16. profitRate: number;
  17. orderCount: number;
  18. percentage: number;
  19. }
  20. interface TrendData {
  21. dates: string[];
  22. label?: string;
  23. value?: number;
  24. series: { name: string; data: number[] }[];
  25. }
  26. interface OverviewData {
  27. totalSales: number;
  28. totalProfit: number;
  29. avgProfitRate: number;
  30. totalOrders: number;
  31. totalUsers: number;
  32. newUsers: number;
  33. totalDevices: number;
  34. onlineDevices: number;
  35. totalShops: number;
  36. repurchaseRate: number;
  37. avgOrderAmount: number;
  38. categoryList: CategoryStat[];
  39. salesTrend: TrendData[];
  40. profitTrend: TrendData[];
  41. }
  42. // ==================== 状态 ====================
  43. const loading = ref(false);
  44. // 日期范围: 默认近30天
  45. const dateRange = ref<[Date, Date]>([
  46. new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
  47. new Date()
  48. ]);
  49. const overviewData = ref<OverviewData>({
  50. totalSales: 0,
  51. totalProfit: 0,
  52. avgProfitRate: 0,
  53. totalOrders: 0,
  54. totalUsers: 0,
  55. newUsers: 0,
  56. totalDevices: 0,
  57. onlineDevices: 0,
  58. totalShops: 0,
  59. repurchaseRate: 0,
  60. avgOrderAmount: 0,
  61. categoryList: [],
  62. salesTrend: [{ dates: [], series: [] }],
  63. profitTrend: [{ dates: [], series: [] }]
  64. });
  65. // 图表引用
  66. const salesTrendRef = ref<HTMLElement | null>(null);
  67. const categoryPieRef = ref<HTMLElement | null>(null);
  68. const profitTrendRef = ref<HTMLElement | null>(null);
  69. let salesTrendChart: echarts.ECharts | null = null;
  70. let categoryPieChart: echarts.ECharts | null = null;
  71. let profitTrendChart: echarts.ECharts | null = null;
  72. // 日期快捷选择
  73. const shortcuts = [
  74. { key: "today", label: "今日" },
  75. { key: "week", label: "本周" },
  76. { key: "month", label: "本月" },
  77. { key: "30d", label: "近30天" }
  78. ];
  79. const activeShortcut = ref("30d");
  80. // ==================== 计算属性 ====================
  81. const onlineRate = computed(() => {
  82. const { totalDevices, onlineDevices } = overviewData.value;
  83. if (!totalDevices || totalDevices === 0) return "0%";
  84. return ((onlineDevices / totalDevices) * 100).toFixed(1) + "%";
  85. });
  86. const newUserRate = computed(() => {
  87. const { totalUsers, newUsers } = overviewData.value;
  88. if (!totalUsers || totalUsers === 0) return "0%";
  89. return ((newUsers / totalUsers) * 100).toFixed(1) + "%";
  90. });
  91. // ==================== 日期工具函数 ====================
  92. function formatDate(date: Date): string {
  93. return date.toISOString().split("T")[0];
  94. }
  95. function setDateShortcut(type: string) {
  96. activeShortcut.value = type;
  97. const today = new Date();
  98. today.setHours(23, 59, 59, 999);
  99. let start: Date;
  100. switch (type) {
  101. case "today":
  102. start = new Date();
  103. start.setHours(0, 0, 0, 0);
  104. break;
  105. case "week":
  106. start = new Date(today);
  107. start.setDate(today.getDate() - 6);
  108. start.setHours(0, 0, 0, 0);
  109. break;
  110. case "month":
  111. start = new Date(today.getFullYear(), today.getMonth(), 1);
  112. break;
  113. case "30d":
  114. default:
  115. start = new Date(today);
  116. start.setDate(today.getDate() - 29);
  117. start.setHours(0, 0, 0, 0);
  118. break;
  119. }
  120. dateRange.value = [start, today];
  121. fetchOverviewData();
  122. }
  123. // ==================== 数据获取 ====================
  124. async function fetchOverviewData() {
  125. loading.value = true;
  126. try {
  127. const params = {
  128. startDate: formatDate(dateRange.value[0]),
  129. endDate: formatDate(dateRange.value[1])
  130. };
  131. const res = await getStatisticsOverview(params);
  132. if (res.code === 200 && res.data) {
  133. overviewData.value = res.data;
  134. }
  135. initCharts();
  136. } catch (error) {
  137. console.error("获取统计概览数据失败:", error);
  138. } finally {
  139. loading.value = false;
  140. }
  141. }
  142. function handleDateChange() {
  143. activeShortcut.value = "";
  144. fetchOverviewData();
  145. }
  146. // ==================== 图表初始化 ====================
  147. function initCharts() {
  148. setTimeout(() => {
  149. initSalesTrendChart();
  150. initCategoryPieChart();
  151. initProfitTrendChart();
  152. }, 100);
  153. }
  154. // 销售趋势折线图
  155. function initSalesTrendChart() {
  156. if (!salesTrendRef.value) return;
  157. salesTrendChart?.dispose();
  158. salesTrendChart = echarts.init(salesTrendRef.value);
  159. const trend = overviewData.value.salesTrend?.[0];
  160. const dates = trend?.dates || [];
  161. const series = (trend?.series || []).map((s: any) => ({
  162. name: s.name,
  163. type: "line" as const,
  164. smooth: true,
  165. data: s.data,
  166. symbol: "circle",
  167. symbolSize: 4
  168. }));
  169. const option: EChartsOption = {
  170. tooltip: { trigger: "axis", backgroundColor: "#fff", borderColor: "#e5e7eb", textStyle: { color: "#333" } },
  171. legend: { data: series.map(s => s.name), bottom: 0 },
  172. grid: { left: "3%", right: "4%", bottom: "12%", top: "10%", containLabel: true },
  173. xAxis: { type: "category", data: dates, axisLabel: { rotate: 45, fontSize: 11 } },
  174. yAxis: { type: "value", name: "金额(元)", axisLabel: { formatter: (v: number) => v >= 10000 ? (v / 10000).toFixed(1) + "万" : String(v) } },
  175. series
  176. };
  177. salesTrendChart.setOption(option);
  178. }
  179. // 品类销售分布饼图
  180. function initCategoryPieChart() {
  181. if (!categoryPieRef.value) return;
  182. categoryPieChart?.dispose();
  183. categoryPieChart = echarts.init(categoryPieRef.value);
  184. const list = overviewData.value.categoryList || [];
  185. const COLORS = ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de", "#3ba272", "#fc8452", "#9a60b4"];
  186. const option: EChartsOption = {
  187. title: { text: "品类销售占比", left: "center", top: 10, textStyle: { fontSize: 14, fontWeight: "bold" } },
  188. tooltip: { trigger: "item", formatter: (p: any) => `${p.name}: ¥${p.value?.toLocaleString() || 0} (${p.percent}%)` },
  189. legend: { orient: "vertical", left: 10, top: "middle", itemWidth: 12, itemHeight: 12 },
  190. series: [{
  191. type: "pie",
  192. radius: ["45%", "72%"],
  193. center: ["58%", "55%"],
  194. avoidLabelOverlap: false,
  195. itemStyle: { borderRadius: 6, borderColor: "#fff", borderWidth: 2 },
  196. label: { show: false },
  197. emphasis: { label: { show: true, fontSize: 16, fontWeight: "bold" } },
  198. labelLine: { show: false },
  199. data: list.slice(0, 8).map((item, i) => ({
  200. value: item.salesAmount,
  201. name: item.category,
  202. itemStyle: { color: COLORS[i % COLORS.length] }
  203. }))
  204. }]
  205. };
  206. categoryPieChart.setOption(option);
  207. }
  208. // 利润趋势面积图
  209. function initProfitTrendChart() {
  210. if (!profitTrendRef.value) return;
  211. profitTrendChart?.dispose();
  212. profitTrendChart = echarts.init(profitTrendRef.value);
  213. const trend = overviewData.value.profitTrend?.[0];
  214. const dates = trend?.dates || [];
  215. const data = trend?.series?.[0]?.data || [];
  216. const option: EChartsOption = {
  217. title: { text: "利润趋势", left: "center", top: 10, textStyle: { fontSize: 14, fontWeight: "bold" } },
  218. tooltip: { trigger: "axis", backgroundColor: "#fff", borderColor: "#e5e7eb", textStyle: { color: "#333" } },
  219. grid: { left: "3%", right: "4%", bottom: "8%", top: "15%", containLabel: true },
  220. xAxis: { type: "category", data: dates, axisLabel: { rotate: 45, fontSize: 11 } },
  221. yAxis: { type: "value", name: "利润(元)", axisLabel: { formatter: (v: number) => v >= 10000 ? (v / 10000).toFixed(1) + "万" : String(v) } },
  222. series: [{
  223. name: "利润",
  224. type: "line",
  225. smooth: true,
  226. data,
  227. symbol: "circle",
  228. symbolSize: 4,
  229. lineStyle: { color: "#67c23a", width: 2 },
  230. areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: "rgba(103,194,58,0.25)" }, { offset: 1, color: "rgba(103,194,58,0.02)" }]) }
  231. }]
  232. };
  233. profitTrendChart.setOption(option);
  234. }
  235. // ==================== 响应式 ====================
  236. function resizeCharts() {
  237. salesTrendChart?.resize();
  238. categoryPieChart?.resize();
  239. profitTrendChart?.resize();
  240. }
  241. onMounted(() => {
  242. fetchOverviewData();
  243. window.addEventListener("resize", resizeCharts);
  244. });
  245. onUnmounted(() => {
  246. window.removeEventListener("resize", resizeCharts);
  247. salesTrendChart?.dispose();
  248. categoryPieChart?.dispose();
  249. profitTrendChart?.dispose();
  250. });
  251. </script>
  252. <template>
  253. <div class="statistics-overview">
  254. <!-- 顶部工具栏 -->
  255. <div class="toolbar">
  256. <h2 class="page-title">统计概览</h2>
  257. <div class="toolbar-right">
  258. <div class="date-shortcuts">
  259. <el-button
  260. v-for="s in shortcuts"
  261. :key="s.key"
  262. :type="activeShortcut === s.key ? 'primary' : 'default'"
  263. size="small"
  264. @click="setDateShortcut(s.key)"
  265. >{{ s.label }}</el-button>
  266. </div>
  267. <el-date-picker
  268. v-model="dateRange"
  269. type="daterange"
  270. range-separator="至"
  271. start-placeholder="开始日期"
  272. end-placeholder="结束日期"
  273. format="YYYY-MM-DD"
  274. value-format="YYYY-MM-DD"
  275. size="small"
  276. @change="handleDateChange"
  277. />
  278. </div>
  279. </div>
  280. <div v-loading="loading" class="page-body">
  281. <!-- 第一行: 核心经营指标 -->
  282. <el-row :gutter="16" class="kpi-row">
  283. <el-col :xs="12" :sm="6" :lg="3">
  284. <div class="kpi-card kpi-blue">
  285. <div class="kpi-label">总销售额</div>
  286. <div class="kpi-value">¥{{ (overviewData.totalSales || 0).toLocaleString() }}</div>
  287. </div>
  288. </el-col>
  289. <el-col :xs="12" :sm="6" :lg="3">
  290. <div class="kpi-card kpi-green">
  291. <div class="kpi-label">总利润</div>
  292. <div class="kpi-value">¥{{ (overviewData.totalProfit || 0).toLocaleString() }}</div>
  293. </div>
  294. </el-col>
  295. <el-col :xs="12" :sm="6" :lg="3">
  296. <div class="kpi-card kpi-purple">
  297. <div class="kpi-label">总订单数</div>
  298. <div class="kpi-value">{{ (overviewData.totalOrders || 0).toLocaleString() }}</div>
  299. </div>
  300. </el-col>
  301. <el-col :xs="12" :sm="6" :lg="3">
  302. <div class="kpi-card kpi-orange">
  303. <div class="kpi-label">利润率</div>
  304. <div class="kpi-value">{{ overviewData.avgProfitRate || 0 }}%</div>
  305. </div>
  306. </el-col>
  307. </el-row>
  308. <!-- 第二行: 辅助指标 -->
  309. <el-row :gutter="16" class="kpi-row">
  310. <el-col :xs="12" :sm="6" :lg="3">
  311. <div class="kpi-card kpi-cyan">
  312. <div class="kpi-label">购买用户</div>
  313. <div class="kpi-value">{{ (overviewData.totalUsers || 0).toLocaleString() }}</div>
  314. <div class="kpi-sub">新用户 {{ (overviewData.newUsers || 0).toLocaleString() }} ({{ newUserRate }})</div>
  315. </div>
  316. </el-col>
  317. <el-col :xs="12" :sm="6" :lg="3">
  318. <div class="kpi-card kpi-teal">
  319. <div class="kpi-label">设备在线率</div>
  320. <div class="kpi-value">{{ onlineRate }}</div>
  321. <div class="kpi-sub">{{ overviewData.onlineDevices || 0 }}/{{ overviewData.totalDevices || 0 }} 台在线</div>
  322. </div>
  323. </el-col>
  324. <el-col :xs="12" :sm="6" :lg="3">
  325. <div class="kpi-card kpi-indigo">
  326. <div class="kpi-label">门店数</div>
  327. <div class="kpi-value">{{ (overviewData.totalShops || 0).toLocaleString() }}</div>
  328. <div class="kpi-sub">门店</div>
  329. </div>
  330. </el-col>
  331. <el-col :xs="12" :sm="6" :lg="3">
  332. <div class="kpi-card kpi-pink">
  333. <div class="kpi-label">客单价 / 复购率</div>
  334. <div class="kpi-value">¥{{ (overviewData.avgOrderAmount || 0).toLocaleString() }}</div>
  335. <div class="kpi-sub">复购率 {{ overviewData.repurchaseRate || 0 }}%</div>
  336. </div>
  337. </el-col>
  338. </el-row>
  339. <!-- 第三行: 图表区 -->
  340. <el-row :gutter="16" class="chart-row">
  341. <el-col :span="16">
  342. <div class="chart-box">
  343. <div ref="salesTrendRef" class="chart-container"></div>
  344. </div>
  345. </el-col>
  346. <el-col :span="8">
  347. <div class="chart-box">
  348. <div ref="categoryPieRef" class="chart-container"></div>
  349. </div>
  350. </el-col>
  351. </el-row>
  352. <el-row :gutter="16" class="chart-row">
  353. <el-col :span="24">
  354. <div class="chart-box">
  355. <div ref="profitTrendRef" class="chart-container"></div>
  356. </div>
  357. </el-col>
  358. </el-row>
  359. </div>
  360. </div>
  361. </template>
  362. <style lang="scss" scoped>
  363. .statistics-overview {
  364. padding: 16px 20px;
  365. background-color: #f5f7fa;
  366. min-height: calc(100vh - 120px);
  367. }
  368. // 工具栏
  369. .toolbar {
  370. display: flex;
  371. align-items: center;
  372. justify-content: space-between;
  373. margin-bottom: 16px;
  374. flex-wrap: wrap;
  375. gap: 12px;
  376. .page-title {
  377. margin: 0;
  378. font-size: 18px;
  379. font-weight: 600;
  380. color: #1d2129;
  381. }
  382. .toolbar-right {
  383. display: flex;
  384. align-items: center;
  385. gap: 12px;
  386. flex-wrap: wrap;
  387. }
  388. .date-shortcuts {
  389. display: flex;
  390. gap: 4px;
  391. }
  392. }
  393. .page-body {
  394. min-height: 400px;
  395. }
  396. // KPI 指标卡片
  397. .kpi-row {
  398. margin-bottom: 12px;
  399. }
  400. .kpi-card {
  401. background: #fff;
  402. border-radius: 8px;
  403. padding: 16px 20px;
  404. height: 100%;
  405. border-left: 4px solid #409eff;
  406. transition: box-shadow 0.2s;
  407. &:hover {
  408. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  409. }
  410. &.kpi-blue { border-left-color: #409eff; }
  411. &.kpi-green { border-left-color: #67c23a; }
  412. &.kpi-purple { border-left-color: #722ed1; }
  413. &.kpi-orange { border-left-color: #fa8c16; }
  414. &.kpi-cyan { border-left-color: #13c2c2; }
  415. &.kpi-teal { border-left-color: #52c41a; }
  416. &.kpi-indigo { border-left-color: #597ef7; }
  417. &.kpi-pink { border-left-color: #eb2f96; }
  418. .kpi-label {
  419. font-size: 13px;
  420. color: #86909c;
  421. margin-bottom: 6px;
  422. }
  423. .kpi-value {
  424. font-size: 22px;
  425. font-weight: 700;
  426. color: #1d2129;
  427. line-height: 1.2;
  428. }
  429. .kpi-sub {
  430. font-size: 12px;
  431. color: #86909c;
  432. margin-top: 4px;
  433. }
  434. }
  435. // 图表区
  436. .chart-row {
  437. margin-bottom: 12px;
  438. }
  439. .chart-box {
  440. background: #fff;
  441. border-radius: 8px;
  442. padding: 16px;
  443. height: 360px;
  444. .chart-container {
  445. width: 100%;
  446. height: 100%;
  447. }
  448. }
  449. @media (max-width: 768px) {
  450. .kpi-value {
  451. font-size: 18px !important;
  452. }
  453. .chart-box {
  454. height: 280px;
  455. }
  456. }
  457. </style>