index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. <script setup lang="ts">
  2. import { ref, onMounted, computed, watch, nextTick } from "vue";
  3. import * as echarts from "echarts/core";
  4. import type { EChartsOption } from "echarts";
  5. import { BarChart, LineChart, PieChart } from "echarts/charts";
  6. import {
  7. GridComponent,
  8. TooltipComponent,
  9. LegendComponent,
  10. TitleComponent
  11. } from "echarts/components";
  12. import { CanvasRenderer } from "echarts/renderers";
  13. import {
  14. getOverview,
  15. getStatistics,
  16. getQuickLinks,
  17. getDeviceStatus
  18. } from "@/api/dashboard";
  19. import RefreshIcon from "~icons/ep/refresh";
  20. import DeviceIcon from "~icons/ri/device-line";
  21. import OrderIcon from "~icons/ri/shopping-cart-line";
  22. import UserIcon from "~icons/ri/user-3-line";
  23. import StackIcon from "~icons/ri/stack-line";
  24. import GiftIcon from "~icons/ri/gift-line";
  25. import CouponIcon from "~icons/ri/coupon-line";
  26. import MoneyIcon from "~icons/ri/money-cny-circle-line";
  27. echarts.use([
  28. BarChart,
  29. LineChart,
  30. PieChart,
  31. GridComponent,
  32. TooltipComponent,
  33. LegendComponent,
  34. TitleComponent,
  35. CanvasRenderer
  36. ]);
  37. defineOptions({
  38. name: "Dashboard"
  39. });
  40. const loading = ref(false);
  41. const overviewData = ref<Record<string, any>>({});
  42. const statisticsRawData = ref<Record<string, any>>({});
  43. const quickLinksRawData = ref<Record<string, any>>({});
  44. const deviceStatusRawList = ref<{ name: string; value: number; color: string }[]>([]);
  45. const statisticsType = ref("week");
  46. const statisticsData = computed(() => {
  47. const raw = statisticsRawData.value;
  48. const dates = raw.dates || [];
  49. const salesData = raw.salesData || [];
  50. const ordersData = raw.ordersData || [];
  51. return dates.map((date: string, index: number) => ({
  52. date,
  53. orders: ordersData[index] || 0,
  54. revenue: salesData[index] || 0,
  55. users: 0
  56. }));
  57. });
  58. const quickLinks = computed(() => {
  59. const raw = quickLinksRawData.value;
  60. return [
  61. { id: 1, title: "设备管理", icon: DeviceIcon, path: "/device/list", count: raw.needRestock || 0 },
  62. { id: 2, title: "订单管理", icon: OrderIcon, path: "/order/list", count: raw.unpaidOrders || 0 },
  63. { id: 3, title: "用户管理", icon: UserIcon, path: "/user/list" },
  64. { id: 4, title: "库存管理", icon: StackIcon, path: "/inventory/list" },
  65. { id: 5, title: "营销活动", icon: GiftIcon, path: "/marketing/activity" },
  66. { id: 6, title: "优惠券", icon: CouponIcon, path: "/marketing/coupon" }
  67. ];
  68. });
  69. const deviceStatusList = computed(() => {
  70. const list = deviceStatusRawList.value;
  71. if (!list || list.length === 0) return [];
  72. return list.map((item, index) => {
  73. const isOnline = item.name === "在线" || item.name?.includes("在线");
  74. return {
  75. deviceId: String(index + 1),
  76. deviceName: `设备 ${index + 1}`,
  77. status: isOnline ? 1 : 0
  78. };
  79. });
  80. });
  81. const deviceOnlineCount = computed(() => {
  82. return deviceStatusList.value.filter(d => d.status === 1).length;
  83. });
  84. const deviceOfflineCount = computed(() => {
  85. return deviceStatusList.value.filter(d => d.status === 0).length;
  86. });
  87. const formatMoney = (value: number) => {
  88. const num = Number(value) || 0;
  89. if (num >= 10000) {
  90. return (num / 10000).toFixed(2) + "万";
  91. }
  92. return num.toFixed(2);
  93. };
  94. const getValue = (key: string, isMoney: boolean = false) => {
  95. const value = overviewData.value?.[key] ?? 0;
  96. return isMoney ? formatMoney(value) : value;
  97. };
  98. const overviewCards = [
  99. { key: "totalUsers", label: "总用户数", icon: UserIcon, color: "#3B82F6", bgColor: "#EFF6FF", extraKey: "todayNewUsers", extraLabel: "今日新增" },
  100. { key: "totalOrders", label: "总订单数", icon: OrderIcon, color: "#8B5CF6", bgColor: "#F5F3FF", extraKey: "todayOrders", extraLabel: "今日订单" },
  101. { key: "totalRevenue", label: "总收入(元)", icon: MoneyIcon, color: "#10B981", bgColor: "#ECFDF5", extraKey: "todayRevenue", extraLabel: "今日收入", isMoney: true },
  102. { key: "totalDevices", label: "设备总数", icon: DeviceIcon, color: "#F59E0B", bgColor: "#FFFBEB", extraKey: "onlineDevices", extraLabel: "在线设备" }
  103. ];
  104. let orderChart: echarts.ECharts | null = null;
  105. let revenueChart: echarts.ECharts | null = null;
  106. let deviceChart: echarts.ECharts | null = null;
  107. const initCharts = () => {
  108. nextTick(() => {
  109. initOrderChart();
  110. initRevenueChart();
  111. initDeviceChart();
  112. });
  113. };
  114. const initOrderChart = () => {
  115. const chartDom = document.getElementById("order-chart");
  116. if (!chartDom) return;
  117. if (orderChart) {
  118. orderChart.dispose();
  119. }
  120. orderChart = echarts.init(chartDom);
  121. const option: EChartsOption = {
  122. tooltip: {
  123. trigger: "axis",
  124. backgroundColor: "rgba(255, 255, 255, 0.95)",
  125. borderColor: "#e5e7eb",
  126. borderWidth: 1,
  127. textStyle: { color: "#374151" }
  128. },
  129. grid: {
  130. left: "3%",
  131. right: "4%",
  132. bottom: "3%",
  133. top: "10%",
  134. containLabel: true
  135. },
  136. xAxis: {
  137. type: "category",
  138. data: statisticsData.value.map(item => item.date),
  139. axisLine: { lineStyle: { color: "#e5e7eb" } },
  140. axisLabel: { color: "#6b7280", fontSize: 11 }
  141. },
  142. yAxis: {
  143. type: "value",
  144. axisLine: { show: false },
  145. axisTick: { show: false },
  146. splitLine: { lineStyle: { color: "#f3f4f6" } },
  147. axisLabel: { color: "#6b7280", fontSize: 11 }
  148. },
  149. series: [{
  150. name: "订单数",
  151. type: "bar",
  152. data: statisticsData.value.map(item => item.orders),
  153. itemStyle: {
  154. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  155. { offset: 0, color: "#8B5CF6" },
  156. { offset: 1, color: "#A78BFA" }
  157. ]),
  158. borderRadius: [4, 4, 0, 0]
  159. },
  160. barWidth: 20
  161. }]
  162. };
  163. orderChart.setOption(option);
  164. };
  165. const initRevenueChart = () => {
  166. const chartDom = document.getElementById("revenue-chart");
  167. if (!chartDom) return;
  168. if (revenueChart) {
  169. revenueChart.dispose();
  170. }
  171. revenueChart = echarts.init(chartDom);
  172. const option: EChartsOption = {
  173. tooltip: {
  174. trigger: "axis",
  175. backgroundColor: "rgba(255, 255, 255, 0.95)",
  176. borderColor: "#e5e7eb",
  177. borderWidth: 1,
  178. textStyle: { color: "#374151" },
  179. formatter: (params: any) => {
  180. const data = params[0];
  181. return `${data.name}<br/>收入: ¥${formatMoney(data.value)}`;
  182. }
  183. },
  184. grid: {
  185. left: "3%",
  186. right: "4%",
  187. bottom: "3%",
  188. top: "10%",
  189. containLabel: true
  190. },
  191. xAxis: {
  192. type: "category",
  193. data: statisticsData.value.map(item => item.date),
  194. axisLine: { lineStyle: { color: "#e5e7eb" } },
  195. axisLabel: { color: "#6b7280", fontSize: 11 },
  196. boundaryGap: false
  197. },
  198. yAxis: {
  199. type: "value",
  200. axisLine: { show: false },
  201. axisTick: { show: false },
  202. splitLine: { lineStyle: { color: "#f3f4f6" } },
  203. axisLabel: { color: "#6b7280", fontSize: 11 }
  204. },
  205. series: [{
  206. name: "收入",
  207. type: "line",
  208. data: statisticsData.value.map(item => item.revenue),
  209. smooth: true,
  210. symbol: "circle",
  211. symbolSize: 6,
  212. lineStyle: { color: "#10B981", width: 2 },
  213. itemStyle: { color: "#10B981" },
  214. areaStyle: {
  215. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  216. { offset: 0, color: "rgba(16, 185, 129, 0.3)" },
  217. { offset: 1, color: "rgba(16, 185, 129, 0.05)" }
  218. ])
  219. }
  220. }]
  221. };
  222. revenueChart.setOption(option);
  223. };
  224. const initDeviceChart = () => {
  225. const chartDom = document.getElementById("device-chart");
  226. if (!chartDom) return;
  227. if (deviceChart) {
  228. deviceChart.dispose();
  229. }
  230. deviceChart = echarts.init(chartDom);
  231. const total = deviceOnlineCount.value + deviceOfflineCount.value;
  232. if (total === 0) {
  233. chartDom.style.display = "none";
  234. return;
  235. }
  236. const option: EChartsOption = {
  237. tooltip: {
  238. trigger: "item",
  239. backgroundColor: "rgba(255, 255, 255, 0.95)",
  240. borderColor: "#e5e7eb",
  241. borderWidth: 1,
  242. textStyle: { color: "#374151" },
  243. formatter: "{b}: {c}台 ({d}%)"
  244. },
  245. legend: {
  246. orient: "vertical",
  247. right: "5%",
  248. top: "center",
  249. textStyle: { color: "#6b7280", fontSize: 12 }
  250. },
  251. series: [{
  252. name: "设备状态",
  253. type: "pie",
  254. radius: ["50%", "70%"],
  255. center: ["35%", "50%"],
  256. avoidLabelOverlap: false,
  257. label: { show: false },
  258. emphasis: {
  259. label: { show: true, fontSize: 14, fontWeight: "bold" }
  260. },
  261. labelLine: { show: false },
  262. data: [
  263. { value: deviceOnlineCount.value, name: "在线", itemStyle: { color: "#10B981" } },
  264. { value: deviceOfflineCount.value, name: "离线", itemStyle: { color: "#EF4444" } }
  265. ]
  266. }]
  267. };
  268. deviceChart.setOption(option);
  269. };
  270. const fetchData = async () => {
  271. loading.value = true;
  272. try {
  273. const [overviewRes, statisticsRes, quickLinksRes, deviceRes] = await Promise.all([
  274. getOverview(),
  275. getStatistics(statisticsType.value),
  276. getQuickLinks(),
  277. getDeviceStatus()
  278. ]);
  279. overviewData.value = overviewRes.data || {};
  280. statisticsRawData.value = statisticsRes.data || {};
  281. quickLinksRawData.value = quickLinksRes.data || {};
  282. deviceStatusRawList.value = deviceRes.data || [];
  283. initCharts();
  284. } catch (error) {
  285. console.error("获取数据失败:", error);
  286. } finally {
  287. loading.value = false;
  288. }
  289. };
  290. const changeStatisticsType = (type: string) => {
  291. statisticsType.value = type;
  292. fetchData();
  293. };
  294. const refreshAll = () => {
  295. fetchData();
  296. };
  297. watch(() => statisticsData.value, () => {
  298. nextTick(() => {
  299. if (orderChart) initOrderChart();
  300. if (revenueChart) initRevenueChart();
  301. });
  302. }, { deep: true });
  303. onMounted(() => {
  304. fetchData();
  305. window.addEventListener("resize", () => {
  306. orderChart?.resize();
  307. revenueChart?.resize();
  308. deviceChart?.resize();
  309. });
  310. });
  311. </script>
  312. <template>
  313. <div class="dashboard-container">
  314. <div class="dashboard-header">
  315. <div class="header-left">
  316. <h1 class="page-title">数据概览</h1>
  317. <p class="page-subtitle">实时掌握业务运营状况</p>
  318. </div>
  319. <el-button type="primary" :loading="loading" @click="refreshAll">
  320. <el-icon><IconifyIconOffline :icon="RefreshIcon" /></el-icon>
  321. 刷新数据
  322. </el-button>
  323. </div>
  324. <div class="overview-cards">
  325. <div v-for="card in overviewCards" :key="card.key" class="stat-card">
  326. <div class="card-header">
  327. <div class="card-icon" :style="{ backgroundColor: card.bgColor, color: card.color }">
  328. <IconifyIconOffline :icon="card.icon" width="22" />
  329. </div>
  330. <div class="card-info">
  331. <div class="card-value">{{ getValue(card.key, card.isMoney) }}</div>
  332. <div class="card-label">{{ card.label }}</div>
  333. </div>
  334. </div>
  335. <div class="card-footer">
  336. <span class="footer-label">{{ card.extraLabel }}</span>
  337. <span class="footer-value" :style="{ color: card.color }">
  338. {{ getValue(card.extraKey, card.isMoney) }}
  339. </span>
  340. </div>
  341. </div>
  342. </div>
  343. <div class="quick-links-section">
  344. <div class="section-title">快捷入口</div>
  345. <div class="quick-links">
  346. <router-link v-for="link in quickLinks" :key="link.id" :to="link.path" class="quick-link-item">
  347. <div class="link-icon">
  348. <IconifyIconOffline :icon="link.icon" width="20" />
  349. </div>
  350. <span class="link-title">{{ link.title }}</span>
  351. <span v-if="link.count !== undefined && link.count > 0" class="link-badge">{{ link.count }}</span>
  352. </router-link>
  353. </div>
  354. </div>
  355. <div class="charts-row">
  356. <div class="chart-panel">
  357. <div class="panel-header">
  358. <span class="section-title">订单趋势</span>
  359. <div class="tab-buttons">
  360. <el-button :type="statisticsType === 'week' ? 'primary' : 'default'" size="small" @click="changeStatisticsType('week')">近7天</el-button>
  361. <el-button :type="statisticsType === 'month' ? 'primary' : 'default'" size="small" @click="changeStatisticsType('month')">近30天</el-button>
  362. <el-button :type="statisticsType === 'year' ? 'primary' : 'default'" size="small" @click="changeStatisticsType('year')">近一年</el-button>
  363. </div>
  364. </div>
  365. <div id="order-chart" class="chart-container"></div>
  366. </div>
  367. <div class="chart-panel">
  368. <div class="panel-header">
  369. <span class="section-title">收入趋势</span>
  370. </div>
  371. <div id="revenue-chart" class="chart-container"></div>
  372. </div>
  373. </div>
  374. <div class="bottom-row">
  375. <div class="chart-panel wide">
  376. <div class="panel-header">
  377. <span class="section-title">数据明细</span>
  378. </div>
  379. <el-table :data="statisticsData" style="width: 100%" size="small" max-height="300">
  380. <el-table-column prop="date" label="日期" width="100" />
  381. <el-table-column prop="orders" label="订单数" width="100">
  382. <template #default="{ row }">
  383. <span class="text-purple">{{ row.orders }}</span>
  384. </template>
  385. </el-table-column>
  386. <el-table-column prop="revenue" label="收入(元)">
  387. <template #default="{ row }">
  388. <span class="text-green">¥{{ formatMoney(row.revenue) }}</span>
  389. </template>
  390. </el-table-column>
  391. <el-table-column prop="users" label="新增用户" width="100">
  392. <template #default="{ row }">
  393. <span class="text-blue">{{ row.users }}</span>
  394. </template>
  395. </el-table-column>
  396. </el-table>
  397. </div>
  398. <div class="device-panel">
  399. <div class="panel-header">
  400. <span class="section-title">设备状态</span>
  401. <span class="device-count">{{ deviceStatusList.length }} 台设备</span>
  402. </div>
  403. <div class="device-content">
  404. <div id="device-chart" class="device-chart"></div>
  405. <div v-if="deviceStatusList.length === 0" class="empty-device">
  406. <el-empty description="暂无设备" :image-size="60" />
  407. </div>
  408. <div v-else class="device-stats">
  409. <div class="stat-item online">
  410. <span class="stat-dot"></span>
  411. <span class="stat-label">在线</span>
  412. <span class="stat-num">{{ deviceOnlineCount }}</span>
  413. </div>
  414. <div class="stat-item offline">
  415. <span class="stat-dot"></span>
  416. <span class="stat-label">离线</span>
  417. <span class="stat-num">{{ deviceOfflineCount }}</span>
  418. </div>
  419. </div>
  420. </div>
  421. </div>
  422. </div>
  423. </div>
  424. </template>
  425. <style lang="scss" scoped>
  426. .dashboard-container {
  427. padding: 20px;
  428. background: #f5f7fa;
  429. min-height: calc(100vh - 100px);
  430. }
  431. .dashboard-header {
  432. display: flex;
  433. justify-content: space-between;
  434. align-items: center;
  435. margin-bottom: 20px;
  436. }
  437. .header-left {
  438. .page-title {
  439. font-size: 22px;
  440. font-weight: 600;
  441. color: #303133;
  442. margin: 0 0 4px 0;
  443. }
  444. .page-subtitle {
  445. font-size: 13px;
  446. color: #909399;
  447. margin: 0;
  448. }
  449. }
  450. .overview-cards {
  451. display: flex;
  452. gap: 16px;
  453. margin-bottom: 20px;
  454. }
  455. .stat-card {
  456. flex: 1;
  457. background: #fff;
  458. border-radius: 8px;
  459. padding: 16px;
  460. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
  461. }
  462. .card-header {
  463. display: flex;
  464. align-items: center;
  465. gap: 12px;
  466. margin-bottom: 12px;
  467. }
  468. .card-icon {
  469. width: 44px;
  470. height: 44px;
  471. border-radius: 8px;
  472. display: flex;
  473. align-items: center;
  474. justify-content: center;
  475. }
  476. .card-info {
  477. .card-value {
  478. font-size: 24px;
  479. font-weight: 700;
  480. color: #303133;
  481. line-height: 1.2;
  482. }
  483. .card-label {
  484. font-size: 13px;
  485. color: #909399;
  486. margin-top: 2px;
  487. }
  488. }
  489. .card-footer {
  490. padding-top: 12px;
  491. border-top: 1px solid #ebeef5;
  492. display: flex;
  493. align-items: center;
  494. gap: 8px;
  495. .footer-label {
  496. font-size: 12px;
  497. color: #909399;
  498. }
  499. .footer-value {
  500. font-size: 13px;
  501. font-weight: 600;
  502. }
  503. }
  504. .quick-links-section {
  505. background: #fff;
  506. border-radius: 8px;
  507. padding: 16px;
  508. margin-bottom: 20px;
  509. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
  510. }
  511. .section-title {
  512. font-size: 15px;
  513. font-weight: 600;
  514. color: #303133;
  515. margin-bottom: 12px;
  516. }
  517. .quick-links {
  518. display: flex;
  519. gap: 12px;
  520. flex-wrap: wrap;
  521. }
  522. .quick-link-item {
  523. display: flex;
  524. align-items: center;
  525. gap: 8px;
  526. padding: 10px 16px;
  527. background: #f5f7fa;
  528. border-radius: 6px;
  529. text-decoration: none;
  530. color: #606266;
  531. transition: all 0.2s;
  532. min-width: 140px;
  533. &:hover {
  534. background: #e6e8eb;
  535. color: #409eff;
  536. }
  537. .link-icon {
  538. width: 32px;
  539. height: 32px;
  540. border-radius: 6px;
  541. background: #409eff;
  542. color: #fff;
  543. display: flex;
  544. align-items: center;
  545. justify-content: center;
  546. }
  547. .link-title {
  548. font-size: 14px;
  549. }
  550. .link-badge {
  551. background: #f56c6c;
  552. color: #fff;
  553. font-size: 11px;
  554. padding: 2px 6px;
  555. border-radius: 10px;
  556. min-width: 18px;
  557. text-align: center;
  558. }
  559. }
  560. .charts-row {
  561. display: flex;
  562. gap: 20px;
  563. margin-bottom: 20px;
  564. }
  565. .chart-panel {
  566. flex: 1;
  567. background: #fff;
  568. border-radius: 8px;
  569. padding: 16px;
  570. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
  571. &.wide {
  572. flex: 2;
  573. }
  574. }
  575. .panel-header {
  576. display: flex;
  577. justify-content: space-between;
  578. align-items: center;
  579. margin-bottom: 12px;
  580. .section-title {
  581. margin-bottom: 0;
  582. }
  583. .device-count {
  584. font-size: 12px;
  585. color: #909399;
  586. background: #f5f7fa;
  587. padding: 4px 8px;
  588. border-radius: 4px;
  589. }
  590. }
  591. .tab-buttons {
  592. display: flex;
  593. gap: 8px;
  594. }
  595. .chart-container {
  596. height: 280px;
  597. width: 100%;
  598. }
  599. .bottom-row {
  600. display: flex;
  601. gap: 20px;
  602. }
  603. .device-panel {
  604. width: 320px;
  605. background: #fff;
  606. border-radius: 8px;
  607. padding: 16px;
  608. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
  609. }
  610. .device-content {
  611. display: flex;
  612. flex-direction: column;
  613. align-items: center;
  614. }
  615. .device-chart {
  616. width: 100%;
  617. height: 180px;
  618. }
  619. .empty-device {
  620. width: 100%;
  621. display: flex;
  622. justify-content: center;
  623. }
  624. .device-stats {
  625. display: flex;
  626. justify-content: center;
  627. gap: 40px;
  628. margin-top: 16px;
  629. }
  630. .stat-item {
  631. display: flex;
  632. align-items: center;
  633. gap: 8px;
  634. .stat-dot {
  635. width: 10px;
  636. height: 10px;
  637. border-radius: 50%;
  638. }
  639. &.online .stat-dot {
  640. background: #10B981;
  641. }
  642. &.offline .stat-dot {
  643. background: #EF4444;
  644. }
  645. .stat-label {
  646. font-size: 13px;
  647. color: #606266;
  648. }
  649. .stat-num {
  650. font-size: 16px;
  651. font-weight: 600;
  652. color: #303133;
  653. }
  654. }
  655. .text-purple { color: #8B5CF6; }
  656. .text-green { color: #10B981; }
  657. .text-blue { color: #3B82F6; }
  658. @media screen and (max-width: 1400px) {
  659. .charts-row {
  660. flex-direction: column;
  661. }
  662. .bottom-row {
  663. flex-direction: column;
  664. }
  665. .device-panel {
  666. width: 100%;
  667. }
  668. }
  669. @media screen and (max-width: 1200px) {
  670. .overview-cards {
  671. flex-wrap: wrap;
  672. }
  673. .stat-card {
  674. flex: 1 1 calc(50% - 8px);
  675. min-width: 200px;
  676. }
  677. }
  678. @media screen and (max-width: 768px) {
  679. .dashboard-container {
  680. padding: 12px;
  681. }
  682. .overview-cards {
  683. flex-direction: column;
  684. }
  685. .stat-card {
  686. flex: 1 1 100%;
  687. }
  688. .quick-links {
  689. flex-direction: column;
  690. }
  691. .quick-link-item {
  692. width: 100%;
  693. }
  694. }
  695. </style>