|
|
@@ -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;
|