index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. <script setup lang="ts">
  2. import { markRaw, nextTick, onActivated, onMounted, reactive, ref, watch, computed } 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. DataZoomComponent
  11. } from "echarts/components";
  12. import { CanvasRenderer } from "echarts/renderers";
  13. echarts.use([
  14. PieChart,
  15. BarChart,
  16. LineChart,
  17. GridComponent,
  18. TitleComponent,
  19. LegendComponent,
  20. TooltipComponent,
  21. DataZoomComponent,
  22. CanvasRenderer
  23. ]);
  24. import { getDashboard, getTrend, getWashDeviceStatus } from "@/api/stat";
  25. import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
  26. import { useRenderIcon } from "@/components/ReIcon/src/hooks";
  27. defineOptions({
  28. name: "Dashboard"
  29. });
  30. const homeLineRef = ref();
  31. const homePieRef = ref();
  32. const { dataTheme } = useDataThemeChange();
  33. const end = new Date();
  34. const start = new Date();
  35. start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
  36. const fmtMoney = (value: number) => {
  37. if (!value) return "0";
  38. return (value / 100).toFixed(2);
  39. };
  40. const formatDate = (date: Date) => {
  41. const year = date.getFullYear();
  42. const month = String(date.getMonth() + 1).padStart(2, "0");
  43. const day = String(date.getDate()).padStart(2, "0");
  44. return `${year}-${month}-${day}`;
  45. };
  46. const dateDiff = (start: Date, end: Date) => {
  47. const diffTime = Math.abs(end.getTime() - start.getTime());
  48. return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
  49. };
  50. const Session = {
  51. get: (key: string) => {
  52. const value = sessionStorage.getItem(key);
  53. return value ? JSON.parse(value) : null;
  54. },
  55. set: (key: string, value: any) => {
  56. sessionStorage.setItem(key, JSON.stringify(value));
  57. }
  58. };
  59. // 品牌衍生图表色板
  60. const CHART_BAR = "#4DA89D";
  61. const CHART_LINE = "#C83A35";
  62. const CHART_GRID = "#EBEBEB";
  63. const PIE_COLORS = [
  64. "#C83A35",
  65. "#4DA89D",
  66. "#E5A350",
  67. "#8B7EC8",
  68. "#5BA0D9",
  69. "#8E8E8E"
  70. ];
  71. const state = reactive({
  72. currentStationId: null as string | null,
  73. dateRange: [formatDate(start), formatDate(end)] as [string, string],
  74. global: {
  75. homeChartOne: null as any,
  76. homeChartTwo: null as any,
  77. dispose: [null, "", undefined]
  78. },
  79. metrics: {
  80. registeredMembers: { value: "0", label: "今日注册会员", icon: "ri/user-add-line" },
  81. todayIncome: { value: "0", label: "今日收益金额", icon: "ri/money-dollar-circle-line" },
  82. consumptionAmount: { value: "0", label: "今日消费总额", icon: "ri/shopping-cart-2-line" },
  83. avgOrderPrice: { value: "0", label: "订单均价", icon: "ri/calculator-line" },
  84. todayOrders: { value: "0", label: "今日订单数量", icon: "ri/file-list-3-line" },
  85. avgDuration: { value: "0", label: "洗车平均时长", icon: "ri/timer-line" }
  86. },
  87. myCharts: [] as any[],
  88. charts: {
  89. theme: "",
  90. bgColor: "",
  91. color: "#303133"
  92. },
  93. homeOneExtra: {
  94. totalIncome: 0,
  95. totalWashOrders: 0
  96. }
  97. });
  98. const shortcuts = [
  99. {
  100. text: "近7天",
  101. value: () => {
  102. const end = new Date();
  103. const start = new Date();
  104. start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
  105. return [start, end];
  106. }
  107. },
  108. {
  109. text: "近30天",
  110. value: () => {
  111. const end = new Date();
  112. const start = new Date();
  113. start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
  114. return [start, end];
  115. }
  116. },
  117. {
  118. text: "近90天",
  119. value: () => {
  120. const end = new Date();
  121. const start = new Date();
  122. start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
  123. return [start, end];
  124. }
  125. }
  126. ];
  127. const initLineChart = (dataList: Array<any>) => {
  128. if (!state.global.dispose.some((b: any) => b === state.global.homeChartOne)) {
  129. state.global.homeChartOne?.dispose();
  130. }
  131. state.global.homeChartOne = markRaw(echarts.init(homeLineRef.value, state.charts.theme));
  132. dataList.forEach(item => {
  133. item.startTime = item.statTime.slice(0, 3).join("-");
  134. item.seq = Number(item.statTime.join(""));
  135. });
  136. state.homeOneExtra.totalIncome = dataList.reduce((k, v) => k + v.totalAmount, 0);
  137. state.homeOneExtra.totalWashOrders = dataList.reduce((k, v) => k + v.totalOrders, 0);
  138. dataList.sort((a, b) => a.seq - b.seq);
  139. const xAxis = dataList.map(k => k.startTime);
  140. const option = {
  141. backgroundColor: state.charts.bgColor,
  142. title: {
  143. text: "洗车数据走势图",
  144. x: "left",
  145. textStyle: { fontSize: "15", color: state.charts.color }
  146. },
  147. grid: { top: 70, right: 0, bottom: 30, left: 50 },
  148. tooltip: { trigger: "axis" },
  149. legend: { data: ["洗车量", "总金额"], right: 20 },
  150. xAxis: {
  151. data: xAxis
  152. },
  153. yAxis: [
  154. {
  155. type: "value",
  156. name: "费用/元 洗车量/次",
  157. position: "left",
  158. splitLine: { show: true, lineStyle: { type: "dashed", color: CHART_GRID } }
  159. }
  160. ],
  161. series: [
  162. {
  163. name: "洗车量",
  164. type: "bar",
  165. barWidth: 10,
  166. symbolSize: 6,
  167. symbol: "circle",
  168. smooth: true,
  169. data: dataList.map(k => k.totalOrders),
  170. lineStyle: { color: CHART_BAR },
  171. itemStyle: { color: CHART_BAR, borderColor: CHART_BAR, barBorderRadius: 5 }
  172. },
  173. {
  174. name: "总金额",
  175. type: "line",
  176. symbolSize: 6,
  177. symbol: "circle",
  178. smooth: true,
  179. data: dataList.map(k => fmtMoney(k.totalAmount)),
  180. lineStyle: { color: CHART_LINE },
  181. itemStyle: { color: CHART_LINE, borderColor: CHART_LINE }
  182. }
  183. ]
  184. };
  185. state.global.homeChartOne.setOption(option);
  186. state.myCharts.push(state.global.homeChartOne);
  187. };
  188. const initPieChart = (dataMap: any) => {
  189. if (!state.global.dispose.some((b: any) => b === state.global.homeChartTwo)) {
  190. state.global.homeChartTwo?.dispose();
  191. }
  192. state.global.homeChartTwo = markRaw(echarts.init(homePieRef.value, state.charts.theme));
  193. const sessionDicts = Session.get("dicts");
  194. let dicts: any[] = [];
  195. if (sessionDicts) {
  196. dicts = sessionDicts["WashDevice.state"] || [];
  197. }
  198. if (!dicts.length) return;
  199. const getname = dicts.map(k => k.name);
  200. const data: any[] = [];
  201. for (let i = 0; i < getname.length; i++) {
  202. const dict = dicts.find(k => k.name === getname[i]);
  203. data.push({ name: getname[i], value: dataMap[`${dict?.value}`] || 0 });
  204. }
  205. const option = {
  206. backgroundColor: state.charts.bgColor,
  207. title: {
  208. text: "洗车设备状态",
  209. x: "left",
  210. textStyle: { fontSize: "15", color: state.charts.color }
  211. },
  212. tooltip: { trigger: "item", formatter: "{b} <br/> {c}" },
  213. legend: {
  214. type: "scroll",
  215. orient: "vertical",
  216. right: "0%",
  217. left: "65%",
  218. top: "center",
  219. itemWidth: 14,
  220. itemHeight: 14,
  221. data: getname,
  222. textStyle: { color: state.charts.color }
  223. },
  224. series: [
  225. {
  226. type: "pie",
  227. radius: ["82", dataTheme.value ? "50" : "102"],
  228. center: ["32%", "50%"],
  229. itemStyle: {
  230. color: (params: any) => PIE_COLORS[params.dataIndex % PIE_COLORS.length]
  231. },
  232. label: { show: false },
  233. labelLine: { show: false },
  234. data: data
  235. }
  236. ]
  237. };
  238. state.global.homeChartTwo.setOption(option);
  239. state.myCharts.push(state.global.homeChartTwo);
  240. };
  241. const initEchartsResizeFun = () => {
  242. nextTick(() => {
  243. for (let i = 0; i < state.myCharts.length; i++) {
  244. setTimeout(() => {
  245. state.myCharts[i]?.resize();
  246. }, i * 200);
  247. }
  248. });
  249. };
  250. const initEchartsResize = () => {
  251. window.addEventListener("resize", initEchartsResizeFun);
  252. };
  253. const loadCurrentEquipmentStatus = () => {
  254. getWashDeviceStatus(state.currentStationId || undefined).then((res: any) => {
  255. initPieChart(res?.data || res || {});
  256. });
  257. };
  258. const loadStationStat = () => {
  259. const start = state.dateRange[0];
  260. const end = state.dateRange[1];
  261. const size = dateDiff(new Date(start), new Date(end)) + 1;
  262. getTrend({
  263. startTime: start,
  264. endTime: end,
  265. type: "day",
  266. pageSize: size,
  267. stationId: state.currentStationId || undefined
  268. }).then((res: any) => {
  269. initLineChart(res?.data || res);
  270. });
  271. };
  272. const loadStationStatToday = () => {
  273. getDashboard(state.currentStationId || undefined).then((res: any) => {
  274. const data = res?.data || res;
  275. if (data) {
  276. state.metrics.registeredMembers.value = String(data.todayRegisteredMembers || 0);
  277. state.metrics.todayIncome.value = fmtMoney(data.todayIncome || 0);
  278. state.metrics.consumptionAmount.value = fmtMoney(data.todayConsumptionAmount || 0);
  279. state.metrics.avgOrderPrice.value = fmtMoney(data.avgOrderPrice || 0);
  280. state.metrics.todayOrders.value = String(data.todayWashOrders || 0);
  281. state.metrics.avgDuration.value = ((data.avgOrderDuration || 0) / 60).toFixed(1);
  282. }
  283. });
  284. };
  285. onMounted(() => {
  286. const currentStationId = Session.get("currentStationId");
  287. if (currentStationId) {
  288. state.currentStationId = currentStationId;
  289. initEchartsResize();
  290. loadStationStat();
  291. loadStationStatToday();
  292. loadCurrentEquipmentStatus();
  293. } else {
  294. initEchartsResize();
  295. }
  296. });
  297. onActivated(() => {
  298. initEchartsResizeFun();
  299. });
  300. watch(
  301. () => dataTheme.value,
  302. (isDark) => {
  303. nextTick(() => {
  304. state.charts.theme = isDark ? "dark" : "";
  305. state.charts.bgColor = isDark ? "transparent" : "";
  306. state.charts.color = isDark ? "#C8C8C8" : "#303133";
  307. setTimeout(() => loadStationStat(), 500);
  308. setTimeout(() => loadCurrentEquipmentStatus(), 700);
  309. });
  310. }
  311. );
  312. const featuredMetrics = computed(() => [
  313. state.metrics.todayIncome,
  314. state.metrics.todayOrders
  315. ]);
  316. const secondaryMetrics = computed(() => [
  317. state.metrics.consumptionAmount,
  318. state.metrics.avgOrderPrice,
  319. state.metrics.registeredMembers,
  320. state.metrics.avgDuration
  321. ]);
  322. const isMoneyMetric = (label: string) => {
  323. return label.includes("金额") || label.includes("收益") || label.includes("均价") || label.includes("消费");
  324. };
  325. </script>
  326. <template>
  327. <div class="dashboard-container">
  328. <!-- 未选择站点提醒 -->
  329. <el-alert
  330. v-if="!state.currentStationId"
  331. title="未选择站点"
  332. type="warning"
  333. description="请通过右上角站点选择器切换到要查看的门店,选择后将自动加载统计数据"
  334. show-icon
  335. closable
  336. class="mb-4"
  337. />
  338. <!-- 核心指标 -->
  339. <div class="featured-row">
  340. <div
  341. v-for="(metric, idx) in featuredMetrics"
  342. :key="idx"
  343. class="featured-card"
  344. :class="idx === 0 ? 'featured-primary' : 'featured-secondary'"
  345. >
  346. <div class="featured-icon">
  347. <component :is="useRenderIcon(metric.icon)" />
  348. </div>
  349. <div class="featured-body">
  350. <div class="featured-value">
  351. {{ metric.value }}
  352. <span v-if="isMoneyMetric(metric.label)" class="featured-unit">元</span>
  353. <span v-else class="featured-unit">笔</span>
  354. </div>
  355. <div class="featured-label">{{ metric.label }}</div>
  356. </div>
  357. </div>
  358. </div>
  359. <!-- 次要指标 -->
  360. <div class="secondary-row">
  361. <div
  362. v-for="(metric, idx) in secondaryMetrics"
  363. :key="idx"
  364. class="secondary-card"
  365. >
  366. <div class="secondary-icon">
  367. <component :is="useRenderIcon(metric.icon)" />
  368. </div>
  369. <div class="secondary-body">
  370. <div class="secondary-value">
  371. {{ metric.value }}
  372. <span v-if="isMoneyMetric(metric.label)" class="secondary-unit">元</span>
  373. <span v-else-if="metric.label.includes('时长')" class="secondary-unit">分钟</span>
  374. <span v-else class="secondary-unit">人</span>
  375. </div>
  376. <div class="secondary-label">{{ metric.label }}</div>
  377. </div>
  378. </div>
  379. </div>
  380. <!-- 图表区域 -->
  381. <el-row :gutter="15">
  382. <el-col :xs="24" :sm="14" :md="14" :lg="16" :xl="16">
  383. <el-card class="chart-card">
  384. <template #header>
  385. <div class="chart-header">
  386. <el-date-picker
  387. @change="loadStationStat"
  388. value-format="YYYY-MM-DD"
  389. v-model="state.dateRange"
  390. type="daterange"
  391. unlink-panels
  392. range-separator="至"
  393. start-placeholder="开始时间"
  394. end-placeholder="结束时间"
  395. :shortcuts="shortcuts"
  396. />
  397. <div class="chart-summary">
  398. 总收益:
  399. <el-tag type="success" size="small">{{ fmtMoney(state.homeOneExtra.totalIncome) }}元</el-tag>
  400. 总订单:
  401. <el-tag type="danger" size="small">{{ state.homeOneExtra.totalWashOrders }}笔</el-tag>
  402. </div>
  403. </div>
  404. </template>
  405. <div class="chart-wrapper" ref="homeLineRef" />
  406. </el-card>
  407. </el-col>
  408. <el-col :xs="24" :sm="10" :md="10" :lg="8" :xl="8">
  409. <el-card class="chart-card">
  410. <div class="chart-wrapper" ref="homePieRef" />
  411. </el-card>
  412. </el-col>
  413. </el-row>
  414. </div>
  415. </template>
  416. <style scoped lang="scss">
  417. .dashboard-container {
  418. padding: 15px;
  419. }
  420. // 核心指标行
  421. .featured-row {
  422. display: grid;
  423. grid-template-columns: 3fr 2fr;
  424. gap: 15px;
  425. margin-bottom: 15px;
  426. @media (max-width: 768px) {
  427. grid-template-columns: 1fr;
  428. }
  429. }
  430. .featured-card {
  431. display: flex;
  432. align-items: center;
  433. gap: 20px;
  434. padding: 24px 28px;
  435. border-radius: 4px;
  436. background: var(--el-bg-color);
  437. &.featured-primary {
  438. box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
  439. border-top: 3px solid var(--el-color-primary);
  440. }
  441. &.featured-secondary {
  442. box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
  443. }
  444. .featured-icon {
  445. font-size: 40px;
  446. color: var(--el-color-primary);
  447. opacity: 0.85;
  448. flex-shrink: 0;
  449. }
  450. .featured-body {
  451. .featured-value {
  452. font-size: 36px;
  453. font-weight: 700;
  454. line-height: 1.2;
  455. color: var(--el-text-color-primary);
  456. letter-spacing: -0.5px;
  457. }
  458. .featured-unit {
  459. font-size: 16px;
  460. font-weight: 500;
  461. color: var(--el-text-color-secondary);
  462. margin-left: 4px;
  463. }
  464. .featured-label {
  465. margin-top: 6px;
  466. font-size: 14px;
  467. font-weight: 500;
  468. color: var(--el-text-color-secondary);
  469. }
  470. }
  471. }
  472. // 次要指标行
  473. .secondary-row {
  474. display: grid;
  475. grid-template-columns: repeat(4, 1fr);
  476. gap: 15px;
  477. margin-bottom: 15px;
  478. @media (max-width: 992px) {
  479. grid-template-columns: repeat(2, 1fr);
  480. }
  481. @media (max-width: 640px) {
  482. grid-template-columns: 1fr;
  483. }
  484. }
  485. .secondary-card {
  486. display: flex;
  487. align-items: center;
  488. gap: 14px;
  489. padding: 18px 20px;
  490. border-radius: 4px;
  491. background: var(--el-bg-color);
  492. box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
  493. .secondary-icon {
  494. font-size: 28px;
  495. color: var(--el-text-color-placeholder);
  496. flex-shrink: 0;
  497. }
  498. .secondary-body {
  499. min-width: 0;
  500. .secondary-value {
  501. font-size: 22px;
  502. font-weight: 600;
  503. line-height: 1.3;
  504. color: var(--el-text-color-primary);
  505. }
  506. .secondary-unit {
  507. font-size: 12px;
  508. font-weight: 500;
  509. color: var(--el-text-color-secondary);
  510. margin-left: 2px;
  511. }
  512. .secondary-label {
  513. margin-top: 2px;
  514. font-size: 13px;
  515. color: var(--el-text-color-secondary);
  516. }
  517. }
  518. }
  519. // 图表
  520. .chart-card {
  521. margin-bottom: 15px;
  522. .chart-header {
  523. display: flex;
  524. justify-content: space-between;
  525. align-items: center;
  526. flex-wrap: wrap;
  527. gap: 10px;
  528. .chart-summary {
  529. font-size: 14px;
  530. color: var(--el-text-color-secondary);
  531. .el-tag {
  532. margin: 0 4px;
  533. }
  534. }
  535. }
  536. .chart-wrapper {
  537. height: 350px;
  538. width: 100%;
  539. }
  540. }
  541. @media (max-width: 768px) {
  542. .chart-header {
  543. flex-direction: column;
  544. align-items: flex-start;
  545. }
  546. .featured-card {
  547. padding: 18px 20px;
  548. .featured-value {
  549. font-size: 28px;
  550. }
  551. }
  552. }
  553. </style>