Bläddra i källkod

feat: 监控大屏视觉升级——暗色控制室风格/LED读表字体/红色脉冲满位警告

lq 1 månad sedan
förälder
incheckning
bf8f58c093
1 ändrade filer med 206 tillägg och 63 borttagningar
  1. 206 63
      frontend/src/view/dashboard/index.vue

+ 206 - 63
frontend/src/view/dashboard/index.vue

@@ -1,60 +1,75 @@
 <template>
-  <div class="monitor-page">
-    <div class="monitor-header">
-      <h2>智慧停车监控</h2>
-      <span class="time">{{ now }}</span>
-    </div>
-
-    <el-row :gutter="16" class="stat-row">
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <el-statistic title="车位余量">
-            <template #default>{{ data.used_spaces }} / {{ data.total_spaces }}</template>
-            <template #suffix>
-              <span :style="{ color: data.total_spaces > 0 && data.used_spaces >= data.total_spaces ? '#f56c6c' : '#67c23a' }">车位</span>
-            </template>
-          </el-statistic>
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <el-statistic title="在场车辆" :value="data.in_park">
-            <template #suffix>台</template>
-          </el-statistic>
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <el-statistic title="今日收入" :value="data.today_income" prefix="¥" :precision="2" />
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card shadow="hover">
-          <el-statistic title="进 / 出" :value="data.today_entry">
-            <template #suffix>/ {{ data.today_exit }}</template>
-          </el-statistic>
-        </el-card>
-      </el-col>
-    </el-row>
-
-    <el-card class="device-card">
-      <template #header>设备状态</template>
-      <div class="device-list">
-        <div v-for="d in data.devices" :key="d.channel_name" class="device-item">
-          <el-tag :type="d.device_status === 'online' ? 'success' : 'danger'" size="small">
-            {{ d.device_status === 'online' ? '在线' : '离线' }}
-          </el-tag>
-          <span class="device-label">{{ d.channel_name }}</span>
-          <span class="device-name">{{ d.device_name }} ({{ d.direction === 'in' ? '入口' : '出口' }})</span>
+  <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 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>
-        <el-empty v-if="!data.devices || data.devices.length === 0" description="暂无设备" :image-size="60" />
       </div>
-    </el-card>
+    </section>
   </div>
 </template>
 
 <script setup>
-import { ref, reactive, onMounted, onUnmounted } from 'vue'
+import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
 import { getMonitor } from '@/api/dashboard'
 
 const data = reactive({
@@ -65,15 +80,21 @@ const data = reactive({
 const now = ref('')
 let timer = null
 
+const isFull = computed(() => data.total_spaces > 0 && data.used_spaces >= data.total_spaces)
+
+function formatNum(n) { return (n || 0).toFixed(2) }
+
 function updateTime() {
-  now.value = new Date().toLocaleString('zh-CN')
+  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 data */ }
+  } catch (e) { /* keep last state */ }
 }
 
 onMounted(() => {
@@ -86,15 +107,137 @@ onUnmounted(() => clearInterval(timer))
 </script>
 
 <style scoped>
-.monitor-page { padding: 16px; }
-.monitor-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
-.monitor-header h2 { margin: 0; font-size: 22px; }
-.time { color: #909399; font-size: 14px; }
-.stat-row { margin-bottom: 16px; }
-.stat-row .el-card { text-align: center; }
-.device-card { margin-top: 0; }
-.device-list { display: flex; flex-wrap: wrap; gap: 12px; }
-.device-item { display: flex; align-items: center; gap: 8px; padding: 8px 14px; background: #f5f7fa; border-radius: 6px; }
-.device-label { font-weight: 500; }
-.device-name { color: #909399; font-size: 13px; }
+/* === Theme tokens === */
+.monitor {
+  --bg:    #0c1922;
+  --panel: #111f2b;
+  --text:  #d4dfe8;
+  --muted: #6b7f94;
+  --green: #00d68f;
+  --amber: #fca33b;
+  --red:   #ff4757;
+  --cyber: #00d68f;
+
+  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-bottom: 20px; border-bottom: 1px solid #1e3240; margin-bottom: 28px;
+}
+.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: 16px; font-weight: 600; letter-spacing: .04em; }
+.bar-right { display: flex; gap: 10px; align-items: center; font-size: 13px; color: var(--muted); }
+.bar-status { color: var(--green); }
+.is-critical .bar-status { color: var(--red); }
+.bar-divider { color: #2a3d4e; }
+.bar-time { font-variant-numeric: tabular-nums; }
+
+/* === 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: 12px; text-transform: uppercase; letter-spacing: .1em;
+  color: var(--muted); margin: 0 0 10px; font-weight: 500;
+}
+
+/* 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: 12px; text-transform: uppercase;
+  letter-spacing: .1em; color: var(--muted); font-weight: 500;
+}
+.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; }
+.device-dir {
+  font-size: 11px; color: var(--muted); background: #162330;
+  padding: 2px 8px; border-radius: 3px;
+}
+.device-name { color: var(--muted); font-size: 13px; margin-left: auto; }
+.device-empty { color: var(--muted); font-size: 13px; font-style: italic; padding: 8px; }
+
+/* === Responsive === */
+@media (max-width: 900px) {
+  .readouts { grid-template-columns: repeat(2, 1fr); }
+  .readout-value { font-size: 28px; }
+}
 </style>