index.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. <script setup lang="ts">
  2. import { markRaw, nextTick, onActivated, onMounted, onBeforeUnmount, reactive, ref, watch } from "vue";
  3. import * as echarts from "echarts/core";
  4. import { PieChart, BarChart, LineChart } from "echarts/charts";
  5. import {
  6. GridComponent,
  7. TitleComponent,
  8. LegendComponent,
  9. TooltipComponent
  10. } from "echarts/components";
  11. import { CanvasRenderer } from "echarts/renderers";
  12. echarts.use([
  13. PieChart, BarChart, LineChart,
  14. GridComponent, TitleComponent, LegendComponent, TooltipComponent,
  15. CanvasRenderer
  16. ]);
  17. import { getDashboard, getTrend, getWashDeviceStatus } from "@/api/stat";
  18. import { emitter } from "@/utils/mitt";
  19. defineOptions({ name: "Dashboard" });
  20. const homeLineRef = ref();
  21. const homePieRef = ref();
  22. const end = new Date();
  23. const start = new Date();
  24. start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
  25. const fmtMoney = (value: number) => {
  26. if (!value) return "¥0.00";
  27. return `¥${(value / 100).toFixed(2)}`;
  28. };
  29. const fmtNumber = (value: any) => {
  30. if (value === null || value === undefined) return "0";
  31. return String(value);
  32. };
  33. const formatDate = (date: Date) => {
  34. const year = date.getFullYear();
  35. const month = String(date.getMonth() + 1).padStart(2, "0");
  36. const day = String(date.getDate()).padStart(2, "0");
  37. return `${year}-${month}-${day}`;
  38. };
  39. const dateDiff = (start: Date, end: Date) => {
  40. return Math.ceil(Math.abs(end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
  41. };
  42. const Session = {
  43. get: (key: string) => {
  44. const value = sessionStorage.getItem(key);
  45. return value ? JSON.parse(value) : null;
  46. }
  47. };
  48. const state = reactive({
  49. currentStationId: null as string | null,
  50. dateRange: [formatDate(start), formatDate(end)] as [string, string],
  51. global: {
  52. homeChartOne: null as any,
  53. homeChartTwo: null as any
  54. },
  55. metrics: [
  56. { key: "registeredMembers", value: "0", label: "今日注册会员数", unit: "人", color: "#5470C6" },
  57. { key: "todayIncome", value: "0", label: "今日收益金额", unit: "元", color: "#91CC75" },
  58. { key: "consumptionAmount", value: "0", label: "今日消费总额", unit: "元", color: "#FAC858" },
  59. { key: "avgOrderPrice", value: "0", label: "订单平均消费", unit: "元", color: "#EE6666" },
  60. { key: "todayOrders", value: "0", label: "今日订单数量", unit: "笔", color: "#73C0DE" },
  61. { key: "avgDuration", value: "0", label: "洗车平均时长", unit: "分钟", color: "#8B7EC8" }
  62. ] as { key: string; value: string; label: string; unit: string; color: string }[],
  63. myCharts: [] as any[],
  64. charts: {
  65. theme: "",
  66. bgColor: "",
  67. color: "#303133"
  68. },
  69. homeOneExtra: {
  70. totalIncome: 0,
  71. totalWashOrders: 0
  72. }
  73. });
  74. const shortcuts = [
  75. {
  76. text: "近7天",
  77. value: () => {
  78. const end = new Date();
  79. const start = new Date();
  80. start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
  81. return [start, end];
  82. }
  83. },
  84. {
  85. text: "近30天",
  86. value: () => {
  87. const end = new Date();
  88. const start = new Date();
  89. start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
  90. return [start, end];
  91. }
  92. },
  93. {
  94. text: "近90天",
  95. value: () => {
  96. const end = new Date();
  97. const start = new Date();
  98. start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
  99. return [start, end];
  100. }
  101. }
  102. ];
  103. const initLineChart = (dataList: Array<any>) => {
  104. if (!state.myCharts.includes(state.global.homeChartOne)) {
  105. state.global.homeChartOne?.dispose();
  106. }
  107. state.global.homeChartOne = markRaw(echarts.init(homeLineRef.value, state.charts.theme));
  108. dataList.forEach(item => {
  109. item.startTime = item.statTime.slice(0, 3).join("-");
  110. item.seq = Number(item.statTime.join(""));
  111. });
  112. state.homeOneExtra.totalIncome = dataList.reduce((k, v) => k + v.totalAmount, 0);
  113. state.homeOneExtra.totalWashOrders = dataList.reduce((k, v) => k + v.totalOrders, 0);
  114. dataList.sort((a, b) => a.seq - b.seq);
  115. const option = {
  116. backgroundColor: state.charts.bgColor,
  117. title: {
  118. text: "洗车数据走势图",
  119. left: 0,
  120. top: 0,
  121. textStyle: { fontSize: 15, color: state.charts.color }
  122. },
  123. grid: { top: 70, right: 80, bottom: 30, left: 60 },
  124. tooltip: {
  125. trigger: "axis",
  126. formatter: (params: any) => {
  127. const bar = params.find((p: any) => p.seriesName === "洗车量");
  128. const line = params.find((p: any) => p.seriesName === "总金额");
  129. const barVal = bar ? `${bar.value} 次` : "";
  130. const lineVal = line ? `¥${line.value}` : "";
  131. return `${params[0].axisValue}<br/>${barVal}<br/>${lineVal}`;
  132. }
  133. },
  134. legend: { data: ["洗车量", "总金额"], right: 10, top: 5 },
  135. xAxis: {
  136. data: dataList.map(k => k.startTime),
  137. axisLine: { lineStyle: { color: state.charts.color } }
  138. },
  139. yAxis: [
  140. {
  141. type: "value",
  142. name: "洗车量/次",
  143. position: "left",
  144. axisLabel: { color: "#68a7a0" },
  145. splitLine: { show: true, lineStyle: { type: "dashed", color: "#f0f0f0" } }
  146. },
  147. {
  148. type: "value",
  149. name: "费用/元",
  150. position: "right",
  151. axisLabel: { color: "#409EFF" },
  152. splitLine: { show: false }
  153. }
  154. ],
  155. series: [
  156. {
  157. name: "洗车量",
  158. type: "bar",
  159. barWidth: 10,
  160. yAxisIndex: 0,
  161. data: dataList.map(k => k.totalOrders),
  162. itemStyle: {
  163. color: "#68a7a0",
  164. borderColor: "#68a7a0",
  165. barBorderRadius: 5
  166. }
  167. },
  168. {
  169. name: "总金额",
  170. type: "line",
  171. symbolSize: 6,
  172. symbol: "circle",
  173. smooth: true,
  174. yAxisIndex: 1,
  175. data: dataList.map(k => (k.totalAmount / 100).toFixed(2)),
  176. lineStyle: { color: "#409EFF" },
  177. itemStyle: { color: "#409EFF", borderColor: "#409EFF" }
  178. }
  179. ]
  180. };
  181. state.global.homeChartOne.setOption(option);
  182. state.myCharts.push(state.global.homeChartOne);
  183. };
  184. const initPieChart = (dataMap: any) => {
  185. if (!state.myCharts.includes(state.global.homeChartTwo)) {
  186. state.global.homeChartTwo?.dispose();
  187. }
  188. state.global.homeChartTwo = markRaw(echarts.init(homePieRef.value, state.charts.theme));
  189. const sessionDicts = Session.get("dicts");
  190. let dicts: any[] = [];
  191. if (sessionDicts) {
  192. dicts = sessionDicts["WashDevice.state"] || [];
  193. }
  194. if (!dicts.length) return;
  195. const getname = dicts.map(k => k.name);
  196. const colorList = ["#6B6F75", "#36C78B", "#e9ee8e", "#ffa496", "#E790E8", "#363638"];
  197. const data: any[] = [];
  198. for (let i = 0; i < getname.length; i++) {
  199. const dict = dicts.find(k => k.name === getname[i]);
  200. data.push({ name: getname[i], value: dataMap[`${dict?.value}`] || 0 });
  201. }
  202. const option = {
  203. backgroundColor: state.charts.bgColor,
  204. title: {
  205. text: "洗车设备状态",
  206. left: 0,
  207. textStyle: { fontSize: 15, color: state.charts.color }
  208. },
  209. tooltip: { trigger: "item", formatter: "{b}<br/>{c} 台" },
  210. legend: {
  211. type: "scroll",
  212. orient: "vertical",
  213. right: 0,
  214. left: "62%",
  215. top: "center",
  216. itemWidth: 14,
  217. itemHeight: 14,
  218. data: getname,
  219. textStyle: { color: state.charts.color }
  220. },
  221. series: [
  222. {
  223. type: "pie",
  224. radius: ["82", "102"],
  225. center: ["30%", "50%"],
  226. itemStyle: {
  227. color: (params: any) => colorList[params.dataIndex % colorList.length]
  228. },
  229. label: { show: false },
  230. labelLine: { show: false },
  231. data
  232. }
  233. ]
  234. };
  235. state.global.homeChartTwo.setOption(option);
  236. state.myCharts.push(state.global.homeChartTwo);
  237. };
  238. const initEchartsResize = () => {
  239. nextTick(() => {
  240. for (let i = 0; i < state.myCharts.length; i++) {
  241. state.myCharts[i]?.resize();
  242. }
  243. });
  244. };
  245. const loadCurrentEquipmentStatus = () => {
  246. getWashDeviceStatus(state.currentStationId || undefined).then((res: any) => {
  247. initPieChart(res?.data || res || {});
  248. });
  249. };
  250. const loadStationStat = () => {
  251. const start = state.dateRange[0];
  252. const end = state.dateRange[1];
  253. const size = dateDiff(new Date(start), new Date(end)) + 1;
  254. getTrend({
  255. startTime: start,
  256. endTime: end,
  257. type: "day",
  258. pageSize: size,
  259. stationId: state.currentStationId || undefined
  260. }).then((res: any) => {
  261. initLineChart(res?.data || res);
  262. });
  263. };
  264. const loadStationStatToday = () => {
  265. getDashboard(state.currentStationId || undefined).then((res: any) => {
  266. const data = res?.data || res;
  267. if (data) {
  268. state.metrics[0].value = fmtNumber(data.todayRegisteredMembers);
  269. state.metrics[1].value = fmtMoney(data.todayIncome);
  270. state.metrics[2].value = fmtMoney(data.todayConsumptionAmount);
  271. state.metrics[3].value = fmtMoney(data.avgOrderPrice);
  272. state.metrics[4].value = fmtNumber(data.todayWashOrders);
  273. state.metrics[5].value = ((data.avgOrderDuration || 0) / 60).toFixed(1);
  274. }
  275. });
  276. };
  277. const loadAll = () => {
  278. loadStationStat();
  279. loadStationStatToday();
  280. loadCurrentEquipmentStatus();
  281. };
  282. onMounted(() => {
  283. emitter.on("stationChangeRefresh", (stationId) => {
  284. state.currentStationId = stationId;
  285. state.myCharts = [];
  286. nextTick(() => loadAll());
  287. });
  288. const currentStationId = Session.get("currentStationId");
  289. if (currentStationId) {
  290. state.currentStationId = currentStationId;
  291. loadAll();
  292. }
  293. window.addEventListener("resize", initEchartsResize);
  294. });
  295. onBeforeUnmount(() => {
  296. emitter.off("stationChangeRefresh");
  297. window.removeEventListener("resize", initEchartsResize);
  298. });
  299. onActivated(() => {
  300. initEchartsResize();
  301. });
  302. watch(
  303. () => state.charts.theme,
  304. () => {
  305. nextTick(() => {
  306. state.myCharts = [];
  307. setTimeout(() => loadStationStat(), 300);
  308. setTimeout(() => loadCurrentEquipmentStatus(), 500);
  309. });
  310. }
  311. );
  312. </script>
  313. <template>
  314. <div class="dashboard-container">
  315. <!-- 未选择站点 -->
  316. <el-alert
  317. v-if="!state.currentStationId"
  318. title="未选择站点"
  319. type="warning"
  320. description="请通过右上角站点选择器切换到要查看的门店"
  321. show-icon
  322. closable
  323. class="mb15"
  324. />
  325. <!-- 6指标卡片 -->
  326. <el-row :gutter="15" class="metrics-row">
  327. <el-col
  328. v-for="(m, idx) in state.metrics"
  329. :key="m.key"
  330. :xs="12" :sm="8" :md="4" :lg="4" :xl="4"
  331. >
  332. <div class="metric-card">
  333. <div class="metric-value" :style="{ color: m.color }">{{ m.value }}</div>
  334. <div class="metric-label">{{ m.label }}</div>
  335. </div>
  336. </el-col>
  337. </el-row>
  338. <!-- 走势图 + 饼图 -->
  339. <el-row :gutter="15">
  340. <el-col :xs="24" :sm="14" :md="14" :lg="16" :xl="16">
  341. <div class="chart-panel">
  342. <div class="chart-toolbar">
  343. <el-date-picker
  344. @change="loadStationStat"
  345. value-format="YYYY-MM-DD"
  346. v-model="state.dateRange"
  347. type="daterange"
  348. unlink-panels
  349. range-separator="至"
  350. start-placeholder="开始日期"
  351. end-placeholder="结束日期"
  352. :shortcuts="shortcuts"
  353. style="width: 280px"
  354. />
  355. <div class="chart-summary">
  356. 总收益:
  357. <el-tag type="success" effect="plain" size="small">{{ fmtMoney(state.homeOneExtra.totalIncome) }}元</el-tag>
  358. 总订单:
  359. <el-tag type="danger" effect="plain" size="small">{{ state.homeOneExtra.totalWashOrders }}笔</el-tag>
  360. </div>
  361. </div>
  362. <div class="chart-body" ref="homeLineRef" />
  363. </div>
  364. </el-col>
  365. <el-col :xs="24" :sm="10" :md="10" :lg="8" :xl="8">
  366. <div class="chart-panel">
  367. <div class="chart-body" ref="homePieRef" />
  368. </div>
  369. </el-col>
  370. </el-row>
  371. </div>
  372. </template>
  373. <style scoped lang="scss">
  374. .dashboard-container {
  375. padding: 20px;
  376. }
  377. // 指标卡片
  378. .metrics-row {
  379. margin-bottom: 15px;
  380. }
  381. .metric-card {
  382. height: 120px;
  383. border-radius: 6px;
  384. padding: 20px;
  385. margin-bottom: 0;
  386. background: #fff;
  387. border: 1px solid var(--el-border-color-lighter);
  388. transition: box-shadow 0.3s;
  389. &:hover {
  390. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
  391. }
  392. .metric-value {
  393. font-size: 30px;
  394. font-weight: 700;
  395. line-height: 1.3;
  396. letter-spacing: -0.5px;
  397. }
  398. .metric-label {
  399. margin-top: 8px;
  400. font-size: 14px;
  401. color: var(--el-text-color-secondary);
  402. }
  403. }
  404. // 图表面板
  405. .chart-panel {
  406. width: 100%;
  407. background: #fff;
  408. border: 1px solid var(--el-border-color-lighter);
  409. border-radius: 6px;
  410. padding: 20px;
  411. margin-bottom: 15px;
  412. transition: box-shadow 0.3s;
  413. &:hover {
  414. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
  415. }
  416. .chart-toolbar {
  417. display: flex;
  418. justify-content: space-between;
  419. align-items: center;
  420. flex-wrap: wrap;
  421. gap: 10px;
  422. margin-bottom: 10px;
  423. .chart-summary {
  424. font-size: 14px;
  425. color: var(--el-text-color-secondary);
  426. }
  427. }
  428. .chart-body {
  429. height: 380px;
  430. width: 100%;
  431. }
  432. }
  433. </style>