|
|
@@ -1,172 +1,249 @@
|
|
|
<template>
|
|
|
- <div class="page">
|
|
|
- <div class="gva-card-box">
|
|
|
- <div class="gva-card gva-top-card">
|
|
|
- <div class="gva-top-card-left">
|
|
|
- <div class="gva-top-card-left-title">早安,管理员,请开始一天的工作吧</div>
|
|
|
- <div class="gva-top-card-left-dot">{{ weatherInfo }}</div>
|
|
|
- </div>
|
|
|
- <img
|
|
|
- src="@/assets/dashboard.png"
|
|
|
- class="gva-top-card-right"
|
|
|
- alt
|
|
|
- >
|
|
|
+ <div class="monitor" :class="{ 'is-critical': isFull }">
|
|
|
+ <!-- 顶部状态栏 -->
|
|
|
+ <header class="monitor-bar">
|
|
|
+ <div class="bar-left">
|
|
|
+ <span class="bar-dot" />
|
|
|
+ <span class="bar-label">智慧停车监控</span>
|
|
|
+ </div>
|
|
|
+ <div class="bar-right">
|
|
|
+ <span class="bar-status">{{ isFull ? '已满' : '运营中' }}</span>
|
|
|
+ <span class="bar-divider">|</span>
|
|
|
+ <span class="bar-time">{{ now }}</span>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <!-- 核心读表区 -->
|
|
|
+ <section class="readouts">
|
|
|
+ <div class="readout-card spaces" :class="{ full: isFull }">
|
|
|
+ <p class="readout-label">车位余量</p>
|
|
|
+ <p class="readout-value">
|
|
|
+ <span class="readout-num used">{{ data.used_spaces }}</span>
|
|
|
+ <span class="readout-sep">/</span>
|
|
|
+ <span class="readout-num total">{{ data.total_spaces }}</span>
|
|
|
+ </p>
|
|
|
+ <p class="readout-meta" v-if="isFull">全场满</p>
|
|
|
+ <p class="readout-meta" v-else>{{ data.total_spaces - data.used_spaces }} 空位</p>
|
|
|
+ </div>
|
|
|
+ <div class="readout-card park">
|
|
|
+ <p class="readout-label">在场车辆</p>
|
|
|
+ <p class="readout-value">{{ data.in_park }} <span class="readout-unit">台</span></p>
|
|
|
</div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div class="gva-card-box">
|
|
|
- <div class="gva-card">
|
|
|
- <div class="gva-card-title">数据统计</div>
|
|
|
- <div class="p-4">
|
|
|
- <el-row :gutter="20">
|
|
|
- <el-col
|
|
|
- :xs="24"
|
|
|
- :sm="18"
|
|
|
- >
|
|
|
- <echarts-line />
|
|
|
- </el-col>
|
|
|
- <el-col
|
|
|
- :xs="24"
|
|
|
- :sm="6"
|
|
|
- >
|
|
|
- <dashboard-table />
|
|
|
- </el-col>
|
|
|
- </el-row>
|
|
|
+ <div class="readout-card income">
|
|
|
+ <p class="readout-label">今日收入</p>
|
|
|
+ <p class="readout-value">
|
|
|
+ <span class="readout-currency">¥</span>{{ formatNum(data.today_income) }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div class="readout-card flow">
|
|
|
+ <p class="readout-label">今日进出</p>
|
|
|
+ <p class="readout-value">
|
|
|
+ <span class="readout-num entry">{{ data.today_entry }}</span>
|
|
|
+ <span class="readout-arrow">→</span>
|
|
|
+ <span class="readout-num exit">{{ data.today_exit }}</span>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <!-- 设备状态 -->
|
|
|
+ <section class="devices-panel">
|
|
|
+ <h3 class="panel-title">设备状态</h3>
|
|
|
+ <div class="device-grid">
|
|
|
+ <div
|
|
|
+ v-for="d in data.devices"
|
|
|
+ :key="d.channel_name"
|
|
|
+ class="device-row"
|
|
|
+ :class="{ offline: d.device_status !== 'online' }"
|
|
|
+ >
|
|
|
+ <span class="device-indicator" :class="d.device_status" />
|
|
|
+ <span class="device-channel">{{ d.channel_name }}</span>
|
|
|
+ <span class="device-dir">{{ d.direction === 'in' ? '入口' : '出口' }}</span>
|
|
|
+ <span class="device-name">{{ d.device_name }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="!data.devices || data.devices.length === 0" class="device-empty">
|
|
|
+ 暂无设备连接
|
|
|
</div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ </section>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import EchartsLine from '@/view/dashboard/dashboardCharts/echartsLine.vue'
|
|
|
-import DashboardTable from '@/view/dashboard/dashboardTable/dashboardTable.vue'
|
|
|
-import { ref } from 'vue'
|
|
|
-import { useRouter } from 'vue-router'
|
|
|
-import { useWeatherInfo } from '@/view/dashboard/weather.js'
|
|
|
-import SelectImage from '@/components/selectImage/selectImage.vue'
|
|
|
-
|
|
|
-defineOptions({
|
|
|
- name: 'Dashboard'
|
|
|
+import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
|
|
+import { getMonitor } from '@/api/dashboard'
|
|
|
+
|
|
|
+const data = reactive({
|
|
|
+ total_spaces: 0, used_spaces: 0, in_park: 0,
|
|
|
+ today_income: 0, today_entry: 0, today_exit: 0,
|
|
|
+ devices: []
|
|
|
})
|
|
|
+const now = ref('')
|
|
|
+let dataTimer = null
|
|
|
+let clockTimer = null
|
|
|
+
|
|
|
+const isFull = computed(() => data.total_spaces > 0 && data.used_spaces >= data.total_spaces)
|
|
|
+
|
|
|
+function formatNum(n) { return (n || 0).toFixed(2) }
|
|
|
|
|
|
-const weatherInfo = useWeatherInfo()
|
|
|
-
|
|
|
-const toolCards = ref([
|
|
|
- {
|
|
|
- label: '用户管理',
|
|
|
- icon: 'monitor',
|
|
|
- name: 'user',
|
|
|
- color: '#ff9c6e',
|
|
|
- bg: 'rgba(255, 156, 110,.3)'
|
|
|
- },
|
|
|
- {
|
|
|
- label: '角色管理',
|
|
|
- icon: 'setting',
|
|
|
- name: 'authority',
|
|
|
- color: '#69c0ff',
|
|
|
- bg: 'rgba(105, 192, 255,.3)'
|
|
|
- },
|
|
|
- {
|
|
|
- label: '菜单管理',
|
|
|
- icon: 'menu',
|
|
|
- name: 'menu',
|
|
|
- color: '#b37feb',
|
|
|
- bg: 'rgba(179, 127, 235,.3)'
|
|
|
- },
|
|
|
- {
|
|
|
- label: '代码生成器',
|
|
|
- icon: 'cpu',
|
|
|
- name: 'autoCode',
|
|
|
- color: '#ffd666',
|
|
|
- bg: 'rgba(255, 214, 102,.3)'
|
|
|
- },
|
|
|
- {
|
|
|
- label: '表单生成器',
|
|
|
- icon: 'document-checked',
|
|
|
- name: 'formCreate',
|
|
|
- color: '#ff85c0',
|
|
|
- bg: 'rgba(255, 133, 192,.3)'
|
|
|
- },
|
|
|
- {
|
|
|
- label: '关于我们',
|
|
|
- icon: 'user',
|
|
|
- name: 'about',
|
|
|
- color: '#5cdbd3',
|
|
|
- bg: 'rgba(92, 219, 211,.3)'
|
|
|
- }
|
|
|
-])
|
|
|
-
|
|
|
-const router = useRouter()
|
|
|
-
|
|
|
-const toTarget = (name) => {
|
|
|
- router.push({ name })
|
|
|
+function updateClock() {
|
|
|
+ now.value = new Date().toLocaleString('zh-CN', {
|
|
|
+ hour: '2-digit', minute: '2-digit', second: '2-digit'
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
+async function fetchData() {
|
|
|
+ try {
|
|
|
+ const res = await getMonitor()
|
|
|
+ if (res.code === 0) Object.assign(data, res.data)
|
|
|
+ } catch (e) { /* keep last state */ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ updateClock()
|
|
|
+ fetchData()
|
|
|
+ clockTimer = setInterval(updateClock, 1000)
|
|
|
+ dataTimer = setInterval(fetchData, 10000)
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ clearInterval(clockTimer)
|
|
|
+ clearInterval(dataTimer)
|
|
|
+})
|
|
|
</script>
|
|
|
|
|
|
-<style lang="scss" scoped>
|
|
|
-.page {
|
|
|
- @apply p-0;
|
|
|
- .gva-card-box{
|
|
|
- @apply p-4;
|
|
|
- &+.gva-card-box{
|
|
|
- @apply pt-0;
|
|
|
- }
|
|
|
- }
|
|
|
- .gva-card {
|
|
|
- @apply box-border bg-white rounded h-auto px-6 py-8 overflow-hidden shadow-sm;
|
|
|
- .gva-card-title{
|
|
|
- @apply pb-5 border-t-0 border-l-0 border-r-0 border-b border-solid border-gray-100;
|
|
|
- }
|
|
|
- }
|
|
|
- .gva-top-card {
|
|
|
- @apply h-72 flex items-center justify-between text-gray-500;
|
|
|
- &-left {
|
|
|
- @apply h-full flex flex-col w-auto;
|
|
|
- &-title {
|
|
|
- @apply text-3xl text-gray-600;
|
|
|
- }
|
|
|
- &-dot {
|
|
|
- @apply mt-4 text-gray-600 text-lg;
|
|
|
- }
|
|
|
- &-item{
|
|
|
- +.gva-top-card-left-item{
|
|
|
- margin-top: 24px;
|
|
|
- }
|
|
|
- margin-top: 14px;
|
|
|
- }
|
|
|
- }
|
|
|
- &-right {
|
|
|
- height: 600px;
|
|
|
- width: 600px;
|
|
|
- margin-top: 28px;
|
|
|
- }
|
|
|
- }
|
|
|
- ::v-deep(.el-card__header){
|
|
|
- @apply p-0 border-gray-200;
|
|
|
- }
|
|
|
- .card-header{
|
|
|
- @apply pb-5 border-b border-solid border-gray-200 border-t-0 border-l-0 border-r-0;
|
|
|
- }
|
|
|
- .quick-entrance-items {
|
|
|
- @apply flex items-center justify-center text-center text-gray-800;
|
|
|
- .quick-entrance-item {
|
|
|
- @apply px-8 py-6 flex items-center flex-col transition-all duration-100 ease-in-out rounded-lg cursor-pointer;
|
|
|
- &:hover{
|
|
|
- @apply shadow-lg;
|
|
|
- }
|
|
|
- &-icon {
|
|
|
- @apply flex items-center h-16 w-16 rounded-lg justify-center mx-0 my-auto text-2xl;
|
|
|
- }
|
|
|
- p {
|
|
|
- @apply mt-2.5;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-.dashboard-icon {
|
|
|
- @apply flex items-center text-xl mr-2 text-blue-400;
|
|
|
+<style scoped>
|
|
|
+/* === Theme tokens === */
|
|
|
+.monitor {
|
|
|
+ --bg: #0c1922;
|
|
|
+ --panel: #152534;
|
|
|
+ --text: #e2ecf5;
|
|
|
+ --muted: #8ca8c4;
|
|
|
+ --green: #00e6a0;
|
|
|
+ --amber: #fca33b;
|
|
|
+ --red: #ff4757;
|
|
|
+
|
|
|
+ background: var(--bg);
|
|
|
+ min-height: 100vh;
|
|
|
+ padding: 24px 32px;
|
|
|
+ color: var(--text);
|
|
|
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
|
+ transition: background .6s;
|
|
|
+}
|
|
|
+.monitor.is-critical {
|
|
|
+ --bg: #1a0f0f;
|
|
|
+ --panel: #241414;
|
|
|
}
|
|
|
|
|
|
+/* === Bar === */
|
|
|
+.monitor-bar {
|
|
|
+ display: flex; justify-content: space-between; align-items: center;
|
|
|
+ padding: 16px 20px; margin: -24px -32px 28px;
|
|
|
+ background: var(--panel);
|
|
|
+ border-bottom: 1px solid #1e3240;
|
|
|
+}
|
|
|
+.bar-left { display: flex; align-items: center; gap: 10px; }
|
|
|
+.bar-dot {
|
|
|
+ width: 8px; height: 8px; border-radius: 50%; background: var(--green);
|
|
|
+ box-shadow: 0 0 6px var(--green);
|
|
|
+}
|
|
|
+.is-critical .bar-dot { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
|
|
+.bar-label { font-size: 18px; font-weight: 700; letter-spacing: .04em; color: #ffffff; }
|
|
|
+.bar-right { display: flex; gap: 12px; align-items: center; font-size: 13px; color: #b0c8de; }
|
|
|
+.bar-status { color: var(--green); }
|
|
|
+.is-critical .bar-status { color: var(--red); }
|
|
|
+.bar-divider { color: #2a3d4e; }
|
|
|
+.bar-time { font-variant-numeric: tabular-nums; color: #ffffff; font-size: 15px; font-weight: 600; }
|
|
|
+
|
|
|
+/* === Readouts === */
|
|
|
+.readouts {
|
|
|
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 28px;
|
|
|
+}
|
|
|
+.readout-card {
|
|
|
+ background: var(--panel);
|
|
|
+ border: 1px solid #1a2e3c;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 24px 20px 20px;
|
|
|
+ text-align: center;
|
|
|
+ transition: border-color .4s, box-shadow .4s;
|
|
|
+}
|
|
|
+.readout-card.full {
|
|
|
+ border-color: var(--red);
|
|
|
+ box-shadow: inset 0 0 40px rgba(255,71,87,.06), 0 0 20px rgba(255,71,87,.08);
|
|
|
+ animation: pulse-red 2s ease-in-out infinite;
|
|
|
+}
|
|
|
+@keyframes pulse-red {
|
|
|
+ 0%, 100% { box-shadow: inset 0 0 30px rgba(255,71,87,.04), 0 0 16px rgba(255,71,87,.06); }
|
|
|
+ 50% { box-shadow: inset 0 0 50px rgba(255,71,87,.08), 0 0 28px rgba(255,71,87,.1); }
|
|
|
+}
|
|
|
+
|
|
|
+/* Label */
|
|
|
+.readout-label {
|
|
|
+ font-size: 13px; text-transform: uppercase; letter-spacing: .08em;
|
|
|
+ color: #a0bedb; margin: 0 0 10px; font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+/* Value */
|
|
|
+.readout-value {
|
|
|
+ margin: 0;
|
|
|
+ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
|
+ font-size: 36px; font-weight: 600; letter-spacing: -.02em;
|
|
|
+ color: #eaf4fb;
|
|
|
+ line-height: 1.1;
|
|
|
+}
|
|
|
+.spaces .readout-num.used { color: #eaf4fb; }
|
|
|
+.full .spaces .readout-num.used { color: var(--red); text-shadow: 0 0 12px rgba(255,71,87,.4); }
|
|
|
+.readout-sep { font-size: 28px; color: #3a5068; margin: 0 4px; font-weight: 400; }
|
|
|
+.readout-num.total { color: var(--muted); font-weight: 400; }
|
|
|
+.readout-unit { font-size: 16px; color: var(--muted); font-weight: 400; letter-spacing: 0; }
|
|
|
+.readout-currency { font-size: 20px; color: var(--green); margin-right: 2px; font-weight: 400; }
|
|
|
+.readout-arrow { color: #3a5068; margin: 0 6px; font-size: 22px; }
|
|
|
+.readout-num.entry { color: var(--green); }
|
|
|
+.readout-num.exit { color: var(--amber); }
|
|
|
+.readout-meta { margin: 8px 0 0; font-size: 12px; color: var(--muted); }
|
|
|
+.full .readout-meta { color: var(--red); font-weight: 500; }
|
|
|
+
|
|
|
+/* Income glow */
|
|
|
+.income .readout-value {
|
|
|
+ color: var(--green);
|
|
|
+ text-shadow: 0 0 18px rgba(0,214,143,.15);
|
|
|
+}
|
|
|
+
|
|
|
+/* === Devices === */
|
|
|
+.devices-panel {
|
|
|
+ background: var(--panel); border: 1px solid #1a2e3c; border-radius: 10px;
|
|
|
+ padding: 20px 24px 16px;
|
|
|
+}
|
|
|
+.panel-title {
|
|
|
+ margin: 0 0 14px; font-size: 13px; text-transform: uppercase;
|
|
|
+ letter-spacing: .08em; color: #a0bedb; font-weight: 600;
|
|
|
+}
|
|
|
+.device-grid {
|
|
|
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px;
|
|
|
+}
|
|
|
+.device-row {
|
|
|
+ display: flex; align-items: center; gap: 10px;
|
|
|
+ padding: 10px 14px;
|
|
|
+ background: #0e1a23; border-radius: 6px;
|
|
|
+ border-left: 3px solid var(--green);
|
|
|
+ transition: border-color .3s;
|
|
|
+}
|
|
|
+.device-row.offline { border-left-color: var(--red); }
|
|
|
+.device-indicator {
|
|
|
+ width: 7px; height: 7px; border-radius: 50%; background: var(--green);
|
|
|
+ box-shadow: 0 0 5px var(--green); flex-shrink: 0;
|
|
|
+}
|
|
|
+.device-indicator.offline { background: var(--red); box-shadow: 0 0 5px var(--red); }
|
|
|
+.device-channel { font-weight: 600; font-size: 13px; min-width: 80px; color: #dce8f2; }
|
|
|
+.device-dir {
|
|
|
+ font-size: 11px; color: #8aa8c2; background: #162330;
|
|
|
+ padding: 2px 8px; border-radius: 3px;
|
|
|
+}
|
|
|
+.device-name { color: #849eb6; font-size: 13px; margin-left: auto; }
|
|
|
+.device-empty { color: #8aa8c2; font-size: 13px; padding: 8px; }
|
|
|
+
|
|
|
+/* === Responsive === */
|
|
|
+@media (max-width: 900px) {
|
|
|
+ .readouts { grid-template-columns: repeat(2, 1fr); }
|
|
|
+ .readout-value { font-size: 28px; }
|
|
|
+}
|
|
|
</style>
|