moki 3 дней назад
Родитель
Сommit
2c7d73769b
1 измененных файлов с 209 добавлено и 1 удалено
  1. 209 1
      src/views/Dashboard.vue

+ 209 - 1
src/views/Dashboard.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import {computed, onMounted, onUnmounted, ref} from 'vue'
+import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
 import {useSSE} from '@/composables/useSSE'
 import type {SseMessage, StatusCode} from '@/types'
 import {STATUS_CONFIG} from '@/types'
@@ -12,7 +12,17 @@ const currentCode = ref<StatusCode>('idle')
 const clock = ref(formatTime())
 const eventLog = ref<Array<{ code: StatusCode; time: string; ts: number }>>([])
 
+const statusSince = ref(Date.now())
+const statusDuration = ref('0s')
+const connectSince = ref<number | null>(null)
+const connectDuration = ref('')
+
+const deviceName = ref('')
+const deviceWifi = ref('')
+const deviceMqtt = ref('')
+
 let clockTimer: ReturnType<typeof setInterval> | null = null
+let durationTimer: ReturnType<typeof setInterval> | null = null
 
 function formatTime(): string {
   const d = new Date()
@@ -31,8 +41,41 @@ function formatEventTime(ts: string): string {
   }
 }
 
+function formatElapsed(ms: number): string {
+  const totalSec = Math.floor(ms / 1000)
+  if (totalSec < 60) return `${totalSec}s`
+  const min = Math.floor(totalSec / 60)
+  const sec = totalSec % 60
+  if (min < 60) return `${min}m ${sec}s`
+  const hr = Math.floor(min / 60)
+  return `${hr}h ${min % 60}m`
+}
+
+function updateDurations() {
+  statusDuration.value = formatElapsed(Date.now() - statusSince.value)
+  if (connectSince.value !== null) {
+    connectDuration.value = formatElapsed(Date.now() - connectSince.value)
+  }
+}
+
+const todayStats = computed(() => {
+  const counts: Partial<Record<StatusCode, number>> = {}
+  for (const item of eventLog.value) {
+    counts[item.code] = (counts[item.code] || 0) + 1
+  }
+  return Object.entries(counts)
+      .sort((a, b) => (b[1] as number) - (a[1] as number))
+      .map(([code, count]) => ({
+        code: code as StatusCode,
+        count: count as number,
+        label: STATUS_CONFIG[code as StatusCode]?.label ?? code,
+        color: STATUS_CONFIG[code as StatusCode]?.color ?? 'default',
+      }))
+})
+
 const removeListener = onMessage((msg: SseMessage) => {
   currentCode.value = msg.code
+  statusSince.value = Date.now()
   eventLog.value.unshift({
     code: msg.code,
     time: formatEventTime(msg.timestamp),
@@ -41,22 +84,50 @@ const removeListener = onMessage((msg: SseMessage) => {
   if (eventLog.value.length > 50) eventLog.value.pop()
 })
 
+watch(connected, (val) => {
+  if (val) {
+    connectSince.value = Date.now()
+  } else {
+    connectSince.value = null
+    connectDuration.value = ''
+  }
+})
+
 const removeDisconnectListener = onDisconnect(() => {
   currentCode.value = 'idle'
+  statusSince.value = Date.now()
 })
 
 onMounted(() => {
   clockTimer = setInterval(() => {
     clock.value = formatTime()
   }, 1000)
+  durationTimer = setInterval(updateDurations, 1000)
+
+  fetchDeviceConfig()
 })
 
 onUnmounted(() => {
   removeListener()
   removeDisconnectListener()
   if (clockTimer) clearInterval(clockTimer)
+  if (durationTimer) clearInterval(durationTimer)
 })
 
+async function fetchDeviceConfig() {
+  try {
+    const res = await http.get('/device/config')
+    const data = res.data
+    if (data.code === 0 && data.data?.length > 0) {
+      const cfg = data.data[0]
+      deviceName.value = cfg.device_name || ''
+      deviceWifi.value = cfg.wifi_ssid || ''
+      deviceMqtt.value = cfg.mqtt_broker || ''
+    }
+  } catch {
+  }
+}
+
 const statusLabel = computed(() => STATUS_CONFIG[currentCode.value]?.label ?? currentCode.value)
 
 const controlCodes: StatusCode[] = [
@@ -80,6 +151,9 @@ async function sendCode(code: string) {
         <span :class="connected ? 'sse-on' : 'sse-off'">
           {{ connected ? 'SSE 已连接' : 'SSE 未连接' }}
         </span>
+        <span v-if="connected && connectDuration" class="uptime">
+          运行 {{ connectDuration }}
+        </span>
       </div>
       <div class="clock">{{ clock }}</div>
     </div>
@@ -93,6 +167,7 @@ async function sendCode(code: string) {
             {{ statusLabel }}
           </div>
           <div class="status-code">{{ currentCode }}</div>
+          <div class="status-duration">持续 {{ statusDuration }}</div>
         </div>
       </div>
 
@@ -112,6 +187,38 @@ async function sendCode(code: string) {
       </div>
     </div>
 
+    <div class="info-row">
+      <div class="stats-section">
+        <div class="section-title">今日统计</div>
+        <div v-if="todayStats.length === 0" class="stats-empty">暂无数据</div>
+        <div v-else class="stats-grid">
+          <div v-for="item in todayStats" :key="item.code" class="stats-item">
+            <a-tag :color="item.color" size="small">{{ item.label }}</a-tag>
+            <span class="stats-count">{{ item.count }}</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="device-section">
+        <div class="section-title">设备配置</div>
+        <div v-if="!deviceName && !deviceWifi && !deviceMqtt" class="device-empty">未找到设备配置</div>
+        <div v-else class="device-info">
+          <div v-if="deviceName" class="device-row">
+            <span class="device-label">设备</span>
+            <span class="device-value">{{ deviceName }}</span>
+          </div>
+          <div v-if="deviceWifi" class="device-row">
+            <span class="device-label">WiFi</span>
+            <a-tag color="success" size="small">{{ deviceWifi }}</a-tag>
+          </div>
+          <div v-if="deviceMqtt" class="device-row">
+            <span class="device-label">MQTT</span>
+            <a-tag color="processing" size="small">{{ deviceMqtt }}</a-tag>
+          </div>
+        </div>
+      </div>
+    </div>
+
     <div class="log-section">
       <div class="section-title">最近事件</div>
       <div v-if="eventLog.length === 0" class="log-empty">等待事件推送...</div>
@@ -161,6 +268,12 @@ async function sendCode(code: string) {
   color: #ff4d4f;
 }
 
+.uptime {
+  color: var(--text-secondary);
+  font-size: 13px;
+  margin-left: 4px;
+}
+
 .clock {
   font-size: 48px;
   font-weight: 700;
@@ -221,6 +334,11 @@ async function sendCode(code: string) {
   font-family: monospace;
 }
 
+.status-duration {
+  font-size: 13px;
+  color: var(--text-secondary);
+}
+
 .section-title {
   font-size: 14px;
   font-weight: 600;
@@ -248,6 +366,84 @@ async function sendCode(code: string) {
   width: 100%;
 }
 
+.info-row {
+  display: flex;
+  gap: 10px;
+}
+
+.stats-section {
+  flex: 1;
+  min-width: 0;
+  padding: 16px 20px;
+  background: var(--card-bg);
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.stats-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px 16px;
+}
+
+.stats-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.stats-count {
+  font-size: 18px;
+  font-weight: 700;
+  color: var(--text-color);
+  font-variant-numeric: tabular-nums;
+}
+
+.stats-empty {
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.device-section {
+  width: 320px;
+  flex-shrink: 0;
+  padding: 16px 20px;
+  background: var(--card-bg);
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.device-info {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.device-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+}
+
+.device-label {
+  color: var(--text-secondary);
+  width: 40px;
+  flex-shrink: 0;
+}
+
+.device-value {
+  color: var(--text-color);
+  font-weight: 500;
+}
+
+.device-empty {
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
 .log-section {
   padding: 20px 24px;
   background: var(--card-bg);
@@ -326,11 +522,23 @@ async function sendCode(code: string) {
     text-align: center;
   }
 
+  .status-duration {
+    text-align: center;
+  }
+
   .control-section {
     width: 100%;
     padding: 16px;
   }
 
+  .info-row {
+    flex-direction: column;
+  }
+
+  .device-section {
+    width: 100%;
+  }
+
   .log-section {
     padding: 16px;
     max-height: 240px;