| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773 |
- <script setup lang="ts">
- import { ref, onMounted, computed, watch, nextTick } from "vue";
- import * as echarts from "echarts/core";
- import type { EChartsOption } from "echarts";
- import { BarChart, LineChart, PieChart } from "echarts/charts";
- import {
- GridComponent,
- TooltipComponent,
- LegendComponent,
- TitleComponent
- } from "echarts/components";
- import { CanvasRenderer } from "echarts/renderers";
- import {
- getOverview,
- getStatistics,
- getQuickLinks,
- getDeviceStatus
- } from "@/api/dashboard";
- import RefreshIcon from "~icons/ep/refresh";
- import DeviceIcon from "~icons/ri/device-line";
- import OrderIcon from "~icons/ri/shopping-cart-line";
- import UserIcon from "~icons/ri/user-3-line";
- import StackIcon from "~icons/ri/stack-line";
- import GiftIcon from "~icons/ri/gift-line";
- import CouponIcon from "~icons/ri/coupon-line";
- import MoneyIcon from "~icons/ri/money-cny-circle-line";
- echarts.use([
- BarChart,
- LineChart,
- PieChart,
- GridComponent,
- TooltipComponent,
- LegendComponent,
- TitleComponent,
- CanvasRenderer
- ]);
- defineOptions({
- name: "Dashboard"
- });
- const loading = ref(false);
- const overviewData = ref<Record<string, any>>({});
- const statisticsRawData = ref<Record<string, any>>({});
- const quickLinksRawData = ref<Record<string, any>>({});
- const deviceStatusRawList = ref<{ name: string; value: number; color: string }[]>([]);
- const statisticsType = ref("week");
- const statisticsData = computed(() => {
- const raw = statisticsRawData.value;
- const dates = raw.dates || [];
- const salesData = raw.salesData || [];
- const ordersData = raw.ordersData || [];
- return dates.map((date: string, index: number) => ({
- date,
- orders: ordersData[index] || 0,
- revenue: salesData[index] || 0,
- users: 0
- }));
- });
- const quickLinks = computed(() => {
- const raw = quickLinksRawData.value;
- return [
- { id: 1, title: "设备管理", icon: DeviceIcon, path: "/device/list", count: raw.needRestock || 0 },
- { id: 2, title: "订单管理", icon: OrderIcon, path: "/order/list", count: raw.unpaidOrders || 0 },
- { id: 3, title: "用户管理", icon: UserIcon, path: "/user/list" },
- { id: 4, title: "库存管理", icon: StackIcon, path: "/inventory/list" },
- { id: 5, title: "营销活动", icon: GiftIcon, path: "/marketing/activity" },
- { id: 6, title: "优惠券", icon: CouponIcon, path: "/marketing/coupon" }
- ];
- });
- const deviceStatusList = computed(() => {
- const list = deviceStatusRawList.value;
- if (!list || list.length === 0) return [];
- return list.map((item, index) => {
- const isOnline = item.name === "在线" || item.name?.includes("在线");
- return {
- deviceId: String(index + 1),
- deviceName: `设备 ${index + 1}`,
- status: isOnline ? 1 : 0
- };
- });
- });
- const deviceOnlineCount = computed(() => {
- return deviceStatusList.value.filter(d => d.status === 1).length;
- });
- const deviceOfflineCount = computed(() => {
- return deviceStatusList.value.filter(d => d.status === 0).length;
- });
- const formatMoney = (value: number) => {
- const num = Number(value) || 0;
- if (num >= 10000) {
- return (num / 10000).toFixed(2) + "万";
- }
- return num.toFixed(2);
- };
- const getValue = (key: string, isMoney: boolean = false) => {
- const value = overviewData.value?.[key] ?? 0;
- return isMoney ? formatMoney(value) : value;
- };
- const overviewCards = [
- { key: "totalUsers", label: "总用户数", icon: UserIcon, color: "#3B82F6", bgColor: "#EFF6FF", extraKey: "todayNewUsers", extraLabel: "今日新增" },
- { key: "totalOrders", label: "总订单数", icon: OrderIcon, color: "#8B5CF6", bgColor: "#F5F3FF", extraKey: "todayOrders", extraLabel: "今日订单" },
- { key: "totalRevenue", label: "总收入(元)", icon: MoneyIcon, color: "#10B981", bgColor: "#ECFDF5", extraKey: "todayRevenue", extraLabel: "今日收入", isMoney: true },
- { key: "totalDevices", label: "设备总数", icon: DeviceIcon, color: "#F59E0B", bgColor: "#FFFBEB", extraKey: "onlineDevices", extraLabel: "在线设备" }
- ];
- let orderChart: echarts.ECharts | null = null;
- let revenueChart: echarts.ECharts | null = null;
- let deviceChart: echarts.ECharts | null = null;
- const initCharts = () => {
- nextTick(() => {
- initOrderChart();
- initRevenueChart();
- initDeviceChart();
- });
- };
- const initOrderChart = () => {
- const chartDom = document.getElementById("order-chart");
- if (!chartDom) return;
-
- if (orderChart) {
- orderChart.dispose();
- }
-
- orderChart = echarts.init(chartDom);
-
- const option: EChartsOption = {
- tooltip: {
- trigger: "axis",
- backgroundColor: "rgba(255, 255, 255, 0.95)",
- borderColor: "#e5e7eb",
- borderWidth: 1,
- textStyle: { color: "#374151" }
- },
- grid: {
- left: "3%",
- right: "4%",
- bottom: "3%",
- top: "10%",
- containLabel: true
- },
- xAxis: {
- type: "category",
- data: statisticsData.value.map(item => item.date),
- axisLine: { lineStyle: { color: "#e5e7eb" } },
- axisLabel: { color: "#6b7280", fontSize: 11 }
- },
- yAxis: {
- type: "value",
- axisLine: { show: false },
- axisTick: { show: false },
- splitLine: { lineStyle: { color: "#f3f4f6" } },
- axisLabel: { color: "#6b7280", fontSize: 11 }
- },
- series: [{
- name: "订单数",
- type: "bar",
- data: statisticsData.value.map(item => item.orders),
- itemStyle: {
- color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
- { offset: 0, color: "#8B5CF6" },
- { offset: 1, color: "#A78BFA" }
- ]),
- borderRadius: [4, 4, 0, 0]
- },
- barWidth: 20
- }]
- };
-
- orderChart.setOption(option);
- };
- const initRevenueChart = () => {
- const chartDom = document.getElementById("revenue-chart");
- if (!chartDom) return;
-
- if (revenueChart) {
- revenueChart.dispose();
- }
-
- revenueChart = echarts.init(chartDom);
-
- const option: EChartsOption = {
- tooltip: {
- trigger: "axis",
- backgroundColor: "rgba(255, 255, 255, 0.95)",
- borderColor: "#e5e7eb",
- borderWidth: 1,
- textStyle: { color: "#374151" },
- formatter: (params: any) => {
- const data = params[0];
- return `${data.name}<br/>收入: ¥${formatMoney(data.value)}`;
- }
- },
- grid: {
- left: "3%",
- right: "4%",
- bottom: "3%",
- top: "10%",
- containLabel: true
- },
- xAxis: {
- type: "category",
- data: statisticsData.value.map(item => item.date),
- axisLine: { lineStyle: { color: "#e5e7eb" } },
- axisLabel: { color: "#6b7280", fontSize: 11 },
- boundaryGap: false
- },
- yAxis: {
- type: "value",
- axisLine: { show: false },
- axisTick: { show: false },
- splitLine: { lineStyle: { color: "#f3f4f6" } },
- axisLabel: { color: "#6b7280", fontSize: 11 }
- },
- series: [{
- name: "收入",
- type: "line",
- data: statisticsData.value.map(item => item.revenue),
- smooth: true,
- symbol: "circle",
- symbolSize: 6,
- lineStyle: { color: "#10B981", width: 2 },
- itemStyle: { color: "#10B981" },
- areaStyle: {
- color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
- { offset: 0, color: "rgba(16, 185, 129, 0.3)" },
- { offset: 1, color: "rgba(16, 185, 129, 0.05)" }
- ])
- }
- }]
- };
-
- revenueChart.setOption(option);
- };
- const initDeviceChart = () => {
- const chartDom = document.getElementById("device-chart");
- if (!chartDom) return;
-
- if (deviceChart) {
- deviceChart.dispose();
- }
-
- deviceChart = echarts.init(chartDom);
-
- const total = deviceOnlineCount.value + deviceOfflineCount.value;
- if (total === 0) {
- chartDom.style.display = "none";
- return;
- }
-
- const option: EChartsOption = {
- tooltip: {
- trigger: "item",
- backgroundColor: "rgba(255, 255, 255, 0.95)",
- borderColor: "#e5e7eb",
- borderWidth: 1,
- textStyle: { color: "#374151" },
- formatter: "{b}: {c}台 ({d}%)"
- },
- legend: {
- orient: "vertical",
- right: "5%",
- top: "center",
- textStyle: { color: "#6b7280", fontSize: 12 }
- },
- series: [{
- name: "设备状态",
- type: "pie",
- radius: ["50%", "70%"],
- center: ["35%", "50%"],
- avoidLabelOverlap: false,
- label: { show: false },
- emphasis: {
- label: { show: true, fontSize: 14, fontWeight: "bold" }
- },
- labelLine: { show: false },
- data: [
- { value: deviceOnlineCount.value, name: "在线", itemStyle: { color: "#10B981" } },
- { value: deviceOfflineCount.value, name: "离线", itemStyle: { color: "#EF4444" } }
- ]
- }]
- };
-
- deviceChart.setOption(option);
- };
- const fetchData = async () => {
- loading.value = true;
- try {
- const [overviewRes, statisticsRes, quickLinksRes, deviceRes] = await Promise.all([
- getOverview(),
- getStatistics(statisticsType.value),
- getQuickLinks(),
- getDeviceStatus()
- ]);
- overviewData.value = overviewRes.data || {};
- statisticsRawData.value = statisticsRes.data || {};
- quickLinksRawData.value = quickLinksRes.data || {};
- deviceStatusRawList.value = deviceRes.data || [];
-
- initCharts();
- } catch (error) {
- console.error("获取数据失败:", error);
- } finally {
- loading.value = false;
- }
- };
- const changeStatisticsType = (type: string) => {
- statisticsType.value = type;
- fetchData();
- };
- const refreshAll = () => {
- fetchData();
- };
- watch(() => statisticsData.value, () => {
- nextTick(() => {
- if (orderChart) initOrderChart();
- if (revenueChart) initRevenueChart();
- });
- }, { deep: true });
- onMounted(() => {
- fetchData();
- window.addEventListener("resize", () => {
- orderChart?.resize();
- revenueChart?.resize();
- deviceChart?.resize();
- });
- });
- </script>
- <template>
- <div class="dashboard-container">
- <div class="dashboard-header">
- <div class="header-left">
- <h1 class="page-title">数据概览</h1>
- <p class="page-subtitle">实时掌握业务运营状况</p>
- </div>
- <el-button type="primary" :loading="loading" @click="refreshAll">
- <el-icon><IconifyIconOffline :icon="RefreshIcon" /></el-icon>
- 刷新数据
- </el-button>
- </div>
- <div class="overview-cards">
- <div v-for="card in overviewCards" :key="card.key" class="stat-card">
- <div class="card-header">
- <div class="card-icon" :style="{ backgroundColor: card.bgColor, color: card.color }">
- <IconifyIconOffline :icon="card.icon" width="22" />
- </div>
- <div class="card-info">
- <div class="card-value">{{ getValue(card.key, card.isMoney) }}</div>
- <div class="card-label">{{ card.label }}</div>
- </div>
- </div>
- <div class="card-footer">
- <span class="footer-label">{{ card.extraLabel }}</span>
- <span class="footer-value" :style="{ color: card.color }">
- {{ getValue(card.extraKey, card.isMoney) }}
- </span>
- </div>
- </div>
- </div>
- <div class="quick-links-section">
- <div class="section-title">快捷入口</div>
- <div class="quick-links">
- <router-link v-for="link in quickLinks" :key="link.id" :to="link.path" class="quick-link-item">
- <div class="link-icon">
- <IconifyIconOffline :icon="link.icon" width="20" />
- </div>
- <span class="link-title">{{ link.title }}</span>
- <span v-if="link.count !== undefined && link.count > 0" class="link-badge">{{ link.count }}</span>
- </router-link>
- </div>
- </div>
- <div class="charts-row">
- <div class="chart-panel">
- <div class="panel-header">
- <span class="section-title">订单趋势</span>
- <div class="tab-buttons">
- <el-button :type="statisticsType === 'week' ? 'primary' : 'default'" size="small" @click="changeStatisticsType('week')">近7天</el-button>
- <el-button :type="statisticsType === 'month' ? 'primary' : 'default'" size="small" @click="changeStatisticsType('month')">近30天</el-button>
- <el-button :type="statisticsType === 'year' ? 'primary' : 'default'" size="small" @click="changeStatisticsType('year')">近一年</el-button>
- </div>
- </div>
- <div id="order-chart" class="chart-container"></div>
- </div>
- <div class="chart-panel">
- <div class="panel-header">
- <span class="section-title">收入趋势</span>
- </div>
- <div id="revenue-chart" class="chart-container"></div>
- </div>
- </div>
- <div class="bottom-row">
- <div class="chart-panel wide">
- <div class="panel-header">
- <span class="section-title">数据明细</span>
- </div>
- <el-table :data="statisticsData" style="width: 100%" size="small" max-height="300">
- <el-table-column prop="date" label="日期" width="100" />
- <el-table-column prop="orders" label="订单数" width="100">
- <template #default="{ row }">
- <span class="text-purple">{{ row.orders }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="revenue" label="收入(元)">
- <template #default="{ row }">
- <span class="text-green">¥{{ formatMoney(row.revenue) }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="users" label="新增用户" width="100">
- <template #default="{ row }">
- <span class="text-blue">{{ row.users }}</span>
- </template>
- </el-table-column>
- </el-table>
- </div>
- <div class="device-panel">
- <div class="panel-header">
- <span class="section-title">设备状态</span>
- <span class="device-count">{{ deviceStatusList.length }} 台设备</span>
- </div>
- <div class="device-content">
- <div id="device-chart" class="device-chart"></div>
- <div v-if="deviceStatusList.length === 0" class="empty-device">
- <el-empty description="暂无设备" :image-size="60" />
- </div>
- <div v-else class="device-stats">
- <div class="stat-item online">
- <span class="stat-dot"></span>
- <span class="stat-label">在线</span>
- <span class="stat-num">{{ deviceOnlineCount }}</span>
- </div>
- <div class="stat-item offline">
- <span class="stat-dot"></span>
- <span class="stat-label">离线</span>
- <span class="stat-num">{{ deviceOfflineCount }}</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </template>
- <style lang="scss" scoped>
- .dashboard-container {
- padding: 20px;
- background: #f5f7fa;
- min-height: calc(100vh - 100px);
- }
- .dashboard-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- }
- .header-left {
- .page-title {
- font-size: 22px;
- font-weight: 600;
- color: #303133;
- margin: 0 0 4px 0;
- }
- .page-subtitle {
- font-size: 13px;
- color: #909399;
- margin: 0;
- }
- }
- .overview-cards {
- display: flex;
- gap: 16px;
- margin-bottom: 20px;
- }
- .stat-card {
- flex: 1;
- background: #fff;
- border-radius: 8px;
- padding: 16px;
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
- }
- .card-header {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 12px;
- }
- .card-icon {
- width: 44px;
- height: 44px;
- border-radius: 8px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .card-info {
- .card-value {
- font-size: 24px;
- font-weight: 700;
- color: #303133;
- line-height: 1.2;
- }
- .card-label {
- font-size: 13px;
- color: #909399;
- margin-top: 2px;
- }
- }
- .card-footer {
- padding-top: 12px;
- border-top: 1px solid #ebeef5;
- display: flex;
- align-items: center;
- gap: 8px;
- .footer-label {
- font-size: 12px;
- color: #909399;
- }
- .footer-value {
- font-size: 13px;
- font-weight: 600;
- }
- }
- .quick-links-section {
- background: #fff;
- border-radius: 8px;
- padding: 16px;
- margin-bottom: 20px;
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
- }
- .section-title {
- font-size: 15px;
- font-weight: 600;
- color: #303133;
- margin-bottom: 12px;
- }
- .quick-links {
- display: flex;
- gap: 12px;
- flex-wrap: wrap;
- }
- .quick-link-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px 16px;
- background: #f5f7fa;
- border-radius: 6px;
- text-decoration: none;
- color: #606266;
- transition: all 0.2s;
- min-width: 140px;
- &:hover {
- background: #e6e8eb;
- color: #409eff;
- }
- .link-icon {
- width: 32px;
- height: 32px;
- border-radius: 6px;
- background: #409eff;
- color: #fff;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .link-title {
- font-size: 14px;
- }
- .link-badge {
- background: #f56c6c;
- color: #fff;
- font-size: 11px;
- padding: 2px 6px;
- border-radius: 10px;
- min-width: 18px;
- text-align: center;
- }
- }
- .charts-row {
- display: flex;
- gap: 20px;
- margin-bottom: 20px;
- }
- .chart-panel {
- flex: 1;
- background: #fff;
- border-radius: 8px;
- padding: 16px;
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
-
- &.wide {
- flex: 2;
- }
- }
- .panel-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 12px;
- .section-title {
- margin-bottom: 0;
- }
- .device-count {
- font-size: 12px;
- color: #909399;
- background: #f5f7fa;
- padding: 4px 8px;
- border-radius: 4px;
- }
- }
- .tab-buttons {
- display: flex;
- gap: 8px;
- }
- .chart-container {
- height: 280px;
- width: 100%;
- }
- .bottom-row {
- display: flex;
- gap: 20px;
- }
- .device-panel {
- width: 320px;
- background: #fff;
- border-radius: 8px;
- padding: 16px;
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
- }
- .device-content {
- display: flex;
- flex-direction: column;
- align-items: center;
- }
- .device-chart {
- width: 100%;
- height: 180px;
- }
- .empty-device {
- width: 100%;
- display: flex;
- justify-content: center;
- }
- .device-stats {
- display: flex;
- justify-content: center;
- gap: 40px;
- margin-top: 16px;
- }
- .stat-item {
- display: flex;
- align-items: center;
- gap: 8px;
-
- .stat-dot {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- }
-
- &.online .stat-dot {
- background: #10B981;
- }
-
- &.offline .stat-dot {
- background: #EF4444;
- }
-
- .stat-label {
- font-size: 13px;
- color: #606266;
- }
-
- .stat-num {
- font-size: 16px;
- font-weight: 600;
- color: #303133;
- }
- }
- .text-purple { color: #8B5CF6; }
- .text-green { color: #10B981; }
- .text-blue { color: #3B82F6; }
- @media screen and (max-width: 1400px) {
- .charts-row {
- flex-direction: column;
- }
- .bottom-row {
- flex-direction: column;
- }
- .device-panel {
- width: 100%;
- }
- }
- @media screen and (max-width: 1200px) {
- .overview-cards {
- flex-wrap: wrap;
- }
- .stat-card {
- flex: 1 1 calc(50% - 8px);
- min-width: 200px;
- }
- }
- @media screen and (max-width: 768px) {
- .dashboard-container {
- padding: 12px;
- }
- .overview-cards {
- flex-direction: column;
- }
- .stat-card {
- flex: 1 1 100%;
- }
- .quick-links {
- flex-direction: column;
- }
- .quick-link-item {
- width: 100%;
- }
- }
- </style>
|