| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618 |
- <script setup lang="ts">
- import { markRaw, nextTick, onActivated, onMounted, reactive, ref, watch, computed } from "vue";
- import * as echarts from "echarts/core";
- import { PieChart, BarChart, LineChart } from "echarts/charts";
- import {
- GridComponent,
- TitleComponent,
- LegendComponent,
- TooltipComponent,
- DataZoomComponent
- } from "echarts/components";
- import { CanvasRenderer } from "echarts/renderers";
- echarts.use([
- PieChart,
- BarChart,
- LineChart,
- GridComponent,
- TitleComponent,
- LegendComponent,
- TooltipComponent,
- DataZoomComponent,
- CanvasRenderer
- ]);
- import { getDashboard, getTrend, getWashDeviceStatus } from "@/api/stat";
- import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
- import { useRenderIcon } from "@/components/ReIcon/src/hooks";
- defineOptions({
- name: "Dashboard"
- });
- const homeLineRef = ref();
- const homePieRef = ref();
- const { dataTheme } = useDataThemeChange();
- const end = new Date();
- const start = new Date();
- start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
- const fmtMoney = (value: number) => {
- if (!value) return "0";
- return (value / 100).toFixed(2);
- };
- const formatDate = (date: Date) => {
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, "0");
- const day = String(date.getDate()).padStart(2, "0");
- return `${year}-${month}-${day}`;
- };
- const dateDiff = (start: Date, end: Date) => {
- const diffTime = Math.abs(end.getTime() - start.getTime());
- return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
- };
- const Session = {
- get: (key: string) => {
- const value = sessionStorage.getItem(key);
- return value ? JSON.parse(value) : null;
- },
- set: (key: string, value: any) => {
- sessionStorage.setItem(key, JSON.stringify(value));
- }
- };
- // 品牌衍生图表色板
- const CHART_BAR = "#4DA89D";
- const CHART_LINE = "#C83A35";
- const CHART_GRID = "#EBEBEB";
- const PIE_COLORS = [
- "#C83A35",
- "#4DA89D",
- "#E5A350",
- "#8B7EC8",
- "#5BA0D9",
- "#8E8E8E"
- ];
- const state = reactive({
- currentStationId: null as string | null,
- dateRange: [formatDate(start), formatDate(end)] as [string, string],
- global: {
- homeChartOne: null as any,
- homeChartTwo: null as any,
- dispose: [null, "", undefined]
- },
- metrics: {
- registeredMembers: { value: "0", label: "今日注册会员", icon: "ri/user-add-line" },
- todayIncome: { value: "0", label: "今日收益金额", icon: "ri/money-dollar-circle-line" },
- consumptionAmount: { value: "0", label: "今日消费总额", icon: "ri/shopping-cart-2-line" },
- avgOrderPrice: { value: "0", label: "订单均价", icon: "ri/calculator-line" },
- todayOrders: { value: "0", label: "今日订单数量", icon: "ri/file-list-3-line" },
- avgDuration: { value: "0", label: "洗车平均时长", icon: "ri/timer-line" }
- },
- myCharts: [] as any[],
- charts: {
- theme: "",
- bgColor: "",
- color: "#303133"
- },
- homeOneExtra: {
- totalIncome: 0,
- totalWashOrders: 0
- }
- });
- const shortcuts = [
- {
- text: "近7天",
- value: () => {
- const end = new Date();
- const start = new Date();
- start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
- return [start, end];
- }
- },
- {
- text: "近30天",
- value: () => {
- const end = new Date();
- const start = new Date();
- start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
- return [start, end];
- }
- },
- {
- text: "近90天",
- value: () => {
- const end = new Date();
- const start = new Date();
- start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
- return [start, end];
- }
- }
- ];
- const initLineChart = (dataList: Array<any>) => {
- if (!state.global.dispose.some((b: any) => b === state.global.homeChartOne)) {
- state.global.homeChartOne?.dispose();
- }
- state.global.homeChartOne = markRaw(echarts.init(homeLineRef.value, state.charts.theme));
- dataList.forEach(item => {
- item.startTime = item.statTime.slice(0, 3).join("-");
- item.seq = Number(item.statTime.join(""));
- });
- state.homeOneExtra.totalIncome = dataList.reduce((k, v) => k + v.totalAmount, 0);
- state.homeOneExtra.totalWashOrders = dataList.reduce((k, v) => k + v.totalOrders, 0);
- dataList.sort((a, b) => a.seq - b.seq);
- const xAxis = dataList.map(k => k.startTime);
- const option = {
- backgroundColor: state.charts.bgColor,
- title: {
- text: "洗车数据走势图",
- x: "left",
- textStyle: { fontSize: "15", color: state.charts.color }
- },
- grid: { top: 70, right: 0, bottom: 30, left: 50 },
- tooltip: { trigger: "axis" },
- legend: { data: ["洗车量", "总金额"], right: 20 },
- xAxis: {
- data: xAxis
- },
- yAxis: [
- {
- type: "value",
- name: "费用/元 洗车量/次",
- position: "left",
- splitLine: { show: true, lineStyle: { type: "dashed", color: CHART_GRID } }
- }
- ],
- series: [
- {
- name: "洗车量",
- type: "bar",
- barWidth: 10,
- symbolSize: 6,
- symbol: "circle",
- smooth: true,
- data: dataList.map(k => k.totalOrders),
- lineStyle: { color: CHART_BAR },
- itemStyle: { color: CHART_BAR, borderColor: CHART_BAR, barBorderRadius: 5 }
- },
- {
- name: "总金额",
- type: "line",
- symbolSize: 6,
- symbol: "circle",
- smooth: true,
- data: dataList.map(k => fmtMoney(k.totalAmount)),
- lineStyle: { color: CHART_LINE },
- itemStyle: { color: CHART_LINE, borderColor: CHART_LINE }
- }
- ]
- };
- state.global.homeChartOne.setOption(option);
- state.myCharts.push(state.global.homeChartOne);
- };
- const initPieChart = (dataMap: any) => {
- if (!state.global.dispose.some((b: any) => b === state.global.homeChartTwo)) {
- state.global.homeChartTwo?.dispose();
- }
- state.global.homeChartTwo = markRaw(echarts.init(homePieRef.value, state.charts.theme));
- const sessionDicts = Session.get("dicts");
- let dicts: any[] = [];
- if (sessionDicts) {
- dicts = sessionDicts["WashDevice.state"] || [];
- }
- if (!dicts.length) return;
- const getname = dicts.map(k => k.name);
- const data: any[] = [];
- for (let i = 0; i < getname.length; i++) {
- const dict = dicts.find(k => k.name === getname[i]);
- data.push({ name: getname[i], value: dataMap[`${dict?.value}`] || 0 });
- }
- const option = {
- backgroundColor: state.charts.bgColor,
- title: {
- text: "洗车设备状态",
- x: "left",
- textStyle: { fontSize: "15", color: state.charts.color }
- },
- tooltip: { trigger: "item", formatter: "{b} <br/> {c}" },
- legend: {
- type: "scroll",
- orient: "vertical",
- right: "0%",
- left: "65%",
- top: "center",
- itemWidth: 14,
- itemHeight: 14,
- data: getname,
- textStyle: { color: state.charts.color }
- },
- series: [
- {
- type: "pie",
- radius: ["82", dataTheme.value ? "50" : "102"],
- center: ["32%", "50%"],
- itemStyle: {
- color: (params: any) => PIE_COLORS[params.dataIndex % PIE_COLORS.length]
- },
- label: { show: false },
- labelLine: { show: false },
- data: data
- }
- ]
- };
- state.global.homeChartTwo.setOption(option);
- state.myCharts.push(state.global.homeChartTwo);
- };
- const initEchartsResizeFun = () => {
- nextTick(() => {
- for (let i = 0; i < state.myCharts.length; i++) {
- setTimeout(() => {
- state.myCharts[i]?.resize();
- }, i * 200);
- }
- });
- };
- const initEchartsResize = () => {
- window.addEventListener("resize", initEchartsResizeFun);
- };
- const loadCurrentEquipmentStatus = () => {
- getWashDeviceStatus(state.currentStationId || undefined).then((res: any) => {
- initPieChart(res?.data || res || {});
- });
- };
- const loadStationStat = () => {
- const start = state.dateRange[0];
- const end = state.dateRange[1];
- const size = dateDiff(new Date(start), new Date(end)) + 1;
- getTrend({
- startTime: start,
- endTime: end,
- type: "day",
- pageSize: size,
- stationId: state.currentStationId || undefined
- }).then((res: any) => {
- initLineChart(res?.data || res);
- });
- };
- const loadStationStatToday = () => {
- getDashboard(state.currentStationId || undefined).then((res: any) => {
- const data = res?.data || res;
- if (data) {
- state.metrics.registeredMembers.value = String(data.todayRegisteredMembers || 0);
- state.metrics.todayIncome.value = fmtMoney(data.todayIncome || 0);
- state.metrics.consumptionAmount.value = fmtMoney(data.todayConsumptionAmount || 0);
- state.metrics.avgOrderPrice.value = fmtMoney(data.avgOrderPrice || 0);
- state.metrics.todayOrders.value = String(data.todayWashOrders || 0);
- state.metrics.avgDuration.value = ((data.avgOrderDuration || 0) / 60).toFixed(1);
- }
- });
- };
- onMounted(() => {
- const currentStationId = Session.get("currentStationId");
- if (currentStationId) {
- state.currentStationId = currentStationId;
- initEchartsResize();
- loadStationStat();
- loadStationStatToday();
- loadCurrentEquipmentStatus();
- } else {
- initEchartsResize();
- }
- });
- onActivated(() => {
- initEchartsResizeFun();
- });
- watch(
- () => dataTheme.value,
- (isDark) => {
- nextTick(() => {
- state.charts.theme = isDark ? "dark" : "";
- state.charts.bgColor = isDark ? "transparent" : "";
- state.charts.color = isDark ? "#C8C8C8" : "#303133";
- setTimeout(() => loadStationStat(), 500);
- setTimeout(() => loadCurrentEquipmentStatus(), 700);
- });
- }
- );
- const featuredMetrics = computed(() => [
- state.metrics.todayIncome,
- state.metrics.todayOrders
- ]);
- const secondaryMetrics = computed(() => [
- state.metrics.consumptionAmount,
- state.metrics.avgOrderPrice,
- state.metrics.registeredMembers,
- state.metrics.avgDuration
- ]);
- const isMoneyMetric = (label: string) => {
- return label.includes("金额") || label.includes("收益") || label.includes("均价") || label.includes("消费");
- };
- </script>
- <template>
- <div class="dashboard-container">
- <!-- 未选择站点提醒 -->
- <el-alert
- v-if="!state.currentStationId"
- title="未选择站点"
- type="warning"
- description="请通过右上角站点选择器切换到要查看的门店,选择后将自动加载统计数据"
- show-icon
- closable
- class="mb-4"
- />
- <!-- 核心指标 -->
- <div class="featured-row">
- <div
- v-for="(metric, idx) in featuredMetrics"
- :key="idx"
- class="featured-card"
- :class="idx === 0 ? 'featured-primary' : 'featured-secondary'"
- >
- <div class="featured-icon">
- <component :is="useRenderIcon(metric.icon)" />
- </div>
- <div class="featured-body">
- <div class="featured-value">
- {{ metric.value }}
- <span v-if="isMoneyMetric(metric.label)" class="featured-unit">元</span>
- <span v-else class="featured-unit">笔</span>
- </div>
- <div class="featured-label">{{ metric.label }}</div>
- </div>
- </div>
- </div>
- <!-- 次要指标 -->
- <div class="secondary-row">
- <div
- v-for="(metric, idx) in secondaryMetrics"
- :key="idx"
- class="secondary-card"
- >
- <div class="secondary-icon">
- <component :is="useRenderIcon(metric.icon)" />
- </div>
- <div class="secondary-body">
- <div class="secondary-value">
- {{ metric.value }}
- <span v-if="isMoneyMetric(metric.label)" class="secondary-unit">元</span>
- <span v-else-if="metric.label.includes('时长')" class="secondary-unit">分钟</span>
- <span v-else class="secondary-unit">人</span>
- </div>
- <div class="secondary-label">{{ metric.label }}</div>
- </div>
- </div>
- </div>
- <!-- 图表区域 -->
- <el-row :gutter="15">
- <el-col :xs="24" :sm="14" :md="14" :lg="16" :xl="16">
- <el-card class="chart-card">
- <template #header>
- <div class="chart-header">
- <el-date-picker
- @change="loadStationStat"
- value-format="YYYY-MM-DD"
- v-model="state.dateRange"
- type="daterange"
- unlink-panels
- range-separator="至"
- start-placeholder="开始时间"
- end-placeholder="结束时间"
- :shortcuts="shortcuts"
- />
- <div class="chart-summary">
- 总收益:
- <el-tag type="success" size="small">{{ fmtMoney(state.homeOneExtra.totalIncome) }}元</el-tag>
- 总订单:
- <el-tag type="danger" size="small">{{ state.homeOneExtra.totalWashOrders }}笔</el-tag>
- </div>
- </div>
- </template>
- <div class="chart-wrapper" ref="homeLineRef" />
- </el-card>
- </el-col>
- <el-col :xs="24" :sm="10" :md="10" :lg="8" :xl="8">
- <el-card class="chart-card">
- <div class="chart-wrapper" ref="homePieRef" />
- </el-card>
- </el-col>
- </el-row>
- </div>
- </template>
- <style scoped lang="scss">
- .dashboard-container {
- padding: 15px;
- }
- // 核心指标行
- .featured-row {
- display: grid;
- grid-template-columns: 3fr 2fr;
- gap: 15px;
- margin-bottom: 15px;
- @media (max-width: 768px) {
- grid-template-columns: 1fr;
- }
- }
- .featured-card {
- display: flex;
- align-items: center;
- gap: 20px;
- padding: 24px 28px;
- border-radius: 4px;
- background: var(--el-bg-color);
- &.featured-primary {
- box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
- border-top: 3px solid var(--el-color-primary);
- }
- &.featured-secondary {
- box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
- }
- .featured-icon {
- font-size: 40px;
- color: var(--el-color-primary);
- opacity: 0.85;
- flex-shrink: 0;
- }
- .featured-body {
- .featured-value {
- font-size: 36px;
- font-weight: 700;
- line-height: 1.2;
- color: var(--el-text-color-primary);
- letter-spacing: -0.5px;
- }
- .featured-unit {
- font-size: 16px;
- font-weight: 500;
- color: var(--el-text-color-secondary);
- margin-left: 4px;
- }
- .featured-label {
- margin-top: 6px;
- font-size: 14px;
- font-weight: 500;
- color: var(--el-text-color-secondary);
- }
- }
- }
- // 次要指标行
- .secondary-row {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 15px;
- margin-bottom: 15px;
- @media (max-width: 992px) {
- grid-template-columns: repeat(2, 1fr);
- }
- @media (max-width: 640px) {
- grid-template-columns: 1fr;
- }
- }
- .secondary-card {
- display: flex;
- align-items: center;
- gap: 14px;
- padding: 18px 20px;
- border-radius: 4px;
- background: var(--el-bg-color);
- box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
- .secondary-icon {
- font-size: 28px;
- color: var(--el-text-color-placeholder);
- flex-shrink: 0;
- }
- .secondary-body {
- min-width: 0;
- .secondary-value {
- font-size: 22px;
- font-weight: 600;
- line-height: 1.3;
- color: var(--el-text-color-primary);
- }
- .secondary-unit {
- font-size: 12px;
- font-weight: 500;
- color: var(--el-text-color-secondary);
- margin-left: 2px;
- }
- .secondary-label {
- margin-top: 2px;
- font-size: 13px;
- color: var(--el-text-color-secondary);
- }
- }
- }
- // 图表
- .chart-card {
- margin-bottom: 15px;
- .chart-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- flex-wrap: wrap;
- gap: 10px;
- .chart-summary {
- font-size: 14px;
- color: var(--el-text-color-secondary);
- .el-tag {
- margin: 0 4px;
- }
- }
- }
- .chart-wrapper {
- height: 350px;
- width: 100%;
- }
- }
- @media (max-width: 768px) {
- .chart-header {
- flex-direction: column;
- align-items: flex-start;
- }
- .featured-card {
- padding: 18px 20px;
- .featured-value {
- font-size: 28px;
- }
- }
- }
- </style>
|