|
|
@@ -1,122 +1,126 @@
|
|
|
<script setup lang="ts">
|
|
|
-import {computed, onMounted, onUnmounted, reactive} from 'vue'
|
|
|
+import {computed, onMounted, onUnmounted, ref} from 'vue'
|
|
|
import {useSSE} from '@/composables/useSSE'
|
|
|
-import {getClients} from '@/api/client'
|
|
|
-import type {PortState, StatusMessage} from '@/types'
|
|
|
-import PortStatusCard from '@/components/PortStatusCard.vue'
|
|
|
+import type {SseMessage, StatusCode} from '@/types'
|
|
|
+import {STATUS_CONFIG} from '@/types'
|
|
|
import StatusCard from '@/components/StatusCard.vue'
|
|
|
+import http from '@/api/index'
|
|
|
|
|
|
-const { connected, onMessage, onDisconnect } = useSSE('/api/events')
|
|
|
+const {connected, onMessage, onDisconnect} = useSSE('/api/events')
|
|
|
|
|
|
-const ports = reactive<Map<number, PortState>>(new Map())
|
|
|
+const currentCode = ref<StatusCode>('idle')
|
|
|
+const clock = ref(formatTime())
|
|
|
+const eventLog = ref<Array<{ code: StatusCode; time: string; ts: number }>>([])
|
|
|
|
|
|
-let notified = new Set<number>()
|
|
|
+let clockTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
|
-function sendNotification(port: number, status: string) {
|
|
|
- if (!('Notification' in window)) return
|
|
|
- if (Notification.permission === 'granted') {
|
|
|
- new Notification(`端口 ${port}`, { body: status })
|
|
|
- } else if (Notification.permission === 'default') {
|
|
|
- Notification.requestPermission().then((perm) => {
|
|
|
- if (perm === 'granted') new Notification(`端口 ${port}`, { body: status })
|
|
|
- })
|
|
|
- }
|
|
|
+function formatTime(): string {
|
|
|
+ const d = new Date()
|
|
|
+ const h = d.getHours().toString().padStart(2, '0')
|
|
|
+ const m = d.getMinutes().toString().padStart(2, '0')
|
|
|
+ const s = d.getSeconds().toString().padStart(2, '0')
|
|
|
+ return `${h}:${m}:${s}`
|
|
|
}
|
|
|
|
|
|
-const removeListener = onMessage((msg: StatusMessage) => {
|
|
|
- if (msg.code === 'idle') {
|
|
|
- notified.delete(msg.port)
|
|
|
+function formatEventTime(ts: string): string {
|
|
|
+ try {
|
|
|
+ const d = new Date(ts)
|
|
|
+ return d.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit', second: '2-digit'})
|
|
|
+ } catch {
|
|
|
+ return ts
|
|
|
}
|
|
|
- ports.set(msg.port, {
|
|
|
- port: msg.port,
|
|
|
- status: msg.status,
|
|
|
+}
|
|
|
+
|
|
|
+const removeListener = onMessage((msg: SseMessage) => {
|
|
|
+ currentCode.value = msg.code
|
|
|
+ eventLog.value.unshift({
|
|
|
code: msg.code,
|
|
|
- timestamp: msg.timestamp,
|
|
|
+ time: formatEventTime(msg.timestamp),
|
|
|
+ ts: Date.now(),
|
|
|
})
|
|
|
+ if (eventLog.value.length > 50) eventLog.value.pop()
|
|
|
})
|
|
|
|
|
|
-onMounted(async () => {
|
|
|
- try {
|
|
|
- const { data: res } = await getClients()
|
|
|
- if (res.data) {
|
|
|
- for (const client of res.data) {
|
|
|
- ports.set(client.port, client)
|
|
|
- }
|
|
|
- }
|
|
|
- } catch {
|
|
|
- // 接口失败不影响 WebSocket 实时推送
|
|
|
- }
|
|
|
+const removeDisconnectListener = onDisconnect(() => {
|
|
|
+ currentCode.value = 'idle'
|
|
|
})
|
|
|
|
|
|
-const removeDisconnectListener = onDisconnect(() => {
|
|
|
- ports.clear()
|
|
|
+onMounted(() => {
|
|
|
+ clockTimer = setInterval(() => {
|
|
|
+ clock.value = formatTime()
|
|
|
+ }, 1000)
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ removeListener()
|
|
|
+ removeDisconnectListener()
|
|
|
+ if (clockTimer) clearInterval(clockTimer)
|
|
|
})
|
|
|
|
|
|
-const portList = computed(() =>
|
|
|
- Array.from(ports.values()).sort((a, b) => a.port - b.port),
|
|
|
-)
|
|
|
-
|
|
|
-const totalCount = computed(() => portList.value.length)
|
|
|
-const idleCount = computed(() => portList.value.filter((p) => p.code === 'idle').length)
|
|
|
-const activeCount = computed(() =>
|
|
|
- portList.value.filter((p) =>
|
|
|
- ['busy', 'reasoning', 'using_tool', 'running', 'pending', 'retry'].includes(p.code),
|
|
|
- ).length,
|
|
|
-)
|
|
|
-const errorCount = computed(() => portList.value.filter((p) => p.code === 'error').length)
|
|
|
-const permissionCount = computed(() => portList.value.filter((p) => p.code === 'permission').length)
|
|
|
-
|
|
|
-onUnmounted(removeListener)
|
|
|
-onUnmounted(removeDisconnectListener)
|
|
|
+const statusLabel = computed(() => STATUS_CONFIG[currentCode.value]?.label ?? currentCode.value)
|
|
|
+
|
|
|
+const controlCodes: StatusCode[] = [
|
|
|
+ 'idle', 'busy', 'reasoning', 'using_tool', 'running',
|
|
|
+ 'pending', 'retry', 'completed', 'permission', 'error',
|
|
|
+]
|
|
|
+
|
|
|
+async function sendCode(code: string) {
|
|
|
+ try {
|
|
|
+ await http.post('/event', {code})
|
|
|
+ } catch {
|
|
|
+ }
|
|
|
+}
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
<div class="dashboard">
|
|
|
- <div class="stats-bar">
|
|
|
- <div class="stat-item">
|
|
|
- <div class="stat-num">{{ totalCount }}</div>
|
|
|
- <div class="stat-label">总端口</div>
|
|
|
- </div>
|
|
|
- <div class="stat-item">
|
|
|
- <div class="stat-num idle">{{ idleCount }}</div>
|
|
|
- <div class="stat-label">空闲</div>
|
|
|
- </div>
|
|
|
- <div class="stat-item">
|
|
|
- <div class="stat-num active">{{ activeCount }}</div>
|
|
|
- <div class="stat-label">运行中</div>
|
|
|
- </div>
|
|
|
- <div class="stat-item">
|
|
|
- <div class="stat-num permission">{{ permissionCount }}</div>
|
|
|
- <div class="stat-label">等待权限</div>
|
|
|
- </div>
|
|
|
- <div class="stat-item">
|
|
|
- <div class="stat-num error">{{ errorCount }}</div>
|
|
|
- <div class="stat-label">错误</div>
|
|
|
- </div>
|
|
|
- <div class="stat-item ws-status">
|
|
|
- <a-badge :status="connected ? 'success' : 'error'" />
|
|
|
- <span :class="connected ? 'ws-on' : 'ws-off'">
|
|
|
- {{ connected ? '已连接' : '未连接' }}
|
|
|
+ <div class="top-bar">
|
|
|
+ <div class="sse-status">
|
|
|
+ <a-badge :status="connected ? 'success' : 'error'"/>
|
|
|
+ <span :class="connected ? 'sse-on' : 'sse-off'">
|
|
|
+ {{ connected ? 'SSE 已连接' : 'SSE 未连接' }}
|
|
|
</span>
|
|
|
</div>
|
|
|
+ <div class="clock">{{ clock }}</div>
|
|
|
</div>
|
|
|
|
|
|
- <a-empty v-if="portList.length === 0" description="等待端口状态推送..." />
|
|
|
+ <div class="main-row">
|
|
|
+ <div class="main-status">
|
|
|
+ <StatusCard :code="currentCode" class="main-card"/>
|
|
|
+ <div class="status-info">
|
|
|
+ <div :style="{ color: STATUS_CONFIG[currentCode]?.color === 'red' ? '#ff4d4f' : undefined }"
|
|
|
+ class="status-label">
|
|
|
+ {{ statusLabel }}
|
|
|
+ </div>
|
|
|
+ <div class="status-code">{{ currentCode }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
|
|
|
- <div class="port-grid">
|
|
|
- <div v-for="port in portList" :key="port.port" class="port-item">
|
|
|
- <StatusCard :code="port.code" class="port-anim"/>
|
|
|
- <PortStatusCard :state="port" class="port-info"/>
|
|
|
+ <div class="control-section">
|
|
|
+ <div class="section-title">快速控制</div>
|
|
|
+ <div class="control-grid">
|
|
|
+ <a-button
|
|
|
+ v-for="code in controlCodes"
|
|
|
+ :key="code"
|
|
|
+ :type="currentCode === code ? 'primary' : 'default'"
|
|
|
+ class="control-btn"
|
|
|
+ @click="sendCode(code)"
|
|
|
+ >
|
|
|
+ {{ STATUS_CONFIG[code]?.label ?? code }}
|
|
|
+ </a-button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div style="margin-top: 24px;">
|
|
|
- <h3 style="margin-bottom: 12px; color: var(--text-secondary);">动画卡片预览</h3>
|
|
|
- <div class="preview-grid">
|
|
|
- <div
|
|
|
- v-for="code in (['reasoning','busy','idle','retry','pending','using_tool','running','completed','permission','error'] as const)"
|
|
|
- :key="code" class="preview-item">
|
|
|
- <StatusCard :code="code"/>
|
|
|
+ <div class="log-section">
|
|
|
+ <div class="section-title">最近事件</div>
|
|
|
+ <div v-if="eventLog.length === 0" class="log-empty">等待事件推送...</div>
|
|
|
+ <div v-else class="log-list">
|
|
|
+ <div v-for="(item, i) in eventLog" :key="item.ts + '-' + i" class="log-item">
|
|
|
+ <span class="log-time">{{ item.time }}</span>
|
|
|
+ <a-tag :color="STATUS_CONFIG[item.code]?.color" class="log-tag" size="small">
|
|
|
+ {{ STATUS_CONFIG[item.code]?.label ?? item.code }}
|
|
|
+ </a-tag>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -126,112 +130,209 @@ onUnmounted(removeDisconnectListener)
|
|
|
<style scoped>
|
|
|
.dashboard {
|
|
|
color: var(--text-color);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 20px;
|
|
|
}
|
|
|
|
|
|
-.stats-bar {
|
|
|
+.top-bar {
|
|
|
display: flex;
|
|
|
- gap: 32px;
|
|
|
- padding: 20px 24px;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 16px 24px;
|
|
|
background: var(--card-bg);
|
|
|
border: 1px solid var(--border-color);
|
|
|
border-radius: 8px;
|
|
|
- margin-bottom: 24px;
|
|
|
+}
|
|
|
+
|
|
|
+.sse-status {
|
|
|
+ display: flex;
|
|
|
align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ font-size: 14px;
|
|
|
}
|
|
|
|
|
|
-.stat-item {
|
|
|
- text-align: center;
|
|
|
+.sse-on {
|
|
|
+ color: #52c41a;
|
|
|
+}
|
|
|
+
|
|
|
+.sse-off {
|
|
|
+ color: #ff4d4f;
|
|
|
}
|
|
|
|
|
|
-.stat-num {
|
|
|
- font-size: 36px;
|
|
|
+.clock {
|
|
|
+ font-size: 48px;
|
|
|
font-weight: 700;
|
|
|
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
+ letter-spacing: 2px;
|
|
|
color: var(--text-color);
|
|
|
line-height: 1;
|
|
|
- margin-bottom: 4px;
|
|
|
}
|
|
|
|
|
|
-.stat-num.idle { color: #52c41a; }
|
|
|
-.stat-num.active { color: #1890ff; }
|
|
|
-.stat-num.permission { color: #faad14; }
|
|
|
-.stat-num.error { color: #ff4d4f; }
|
|
|
-
|
|
|
-.stat-label {
|
|
|
- font-size: 13px;
|
|
|
- color: var(--text-secondary);
|
|
|
+.main-row {
|
|
|
+ display: flex;
|
|
|
+ gap: 20px;
|
|
|
+ align-items: stretch;
|
|
|
}
|
|
|
|
|
|
-.ws-status {
|
|
|
- margin-left: auto;
|
|
|
+.main-status {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
display: flex;
|
|
|
+ gap: 24px;
|
|
|
align-items: center;
|
|
|
- gap: 8px;
|
|
|
- font-size: 14px;
|
|
|
+ padding: 24px;
|
|
|
+ background: var(--card-bg);
|
|
|
+ border: 1px solid var(--border-color);
|
|
|
+ border-radius: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.main-card {
|
|
|
+ width: 100%;
|
|
|
+ max-width: 480px;
|
|
|
+ flex-shrink: 0;
|
|
|
}
|
|
|
|
|
|
-.ws-on { color: #52c41a; }
|
|
|
-.ws-off { color: #ff4d4f; }
|
|
|
+.main-card :deep(> *) {
|
|
|
+ aspect-ratio: 2 / 1;
|
|
|
+}
|
|
|
|
|
|
-.port-grid {
|
|
|
- display: grid;
|
|
|
- grid-template-columns: 1fr;
|
|
|
- gap: 16px;
|
|
|
+.main-card :deep(.orb) {
|
|
|
+ width: 36px;
|
|
|
+ height: 36px;
|
|
|
}
|
|
|
|
|
|
-.port-item {
|
|
|
+.status-info {
|
|
|
display: flex;
|
|
|
- gap: 12px;
|
|
|
- align-items: stretch;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
}
|
|
|
|
|
|
-.port-anim {
|
|
|
- aspect-ratio: 3 / 1;
|
|
|
- flex-shrink: 0;
|
|
|
+.status-label {
|
|
|
+ font-size: 32px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: var(--text-color);
|
|
|
+ line-height: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.status-code {
|
|
|
+ font-size: 14px;
|
|
|
+ color: var(--text-secondary);
|
|
|
+ font-family: monospace;
|
|
|
}
|
|
|
|
|
|
-.port-info {
|
|
|
+.section-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--text-secondary);
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.control-section {
|
|
|
flex: 1;
|
|
|
min-width: 0;
|
|
|
- overflow: hidden;
|
|
|
+ padding: 20px 24px;
|
|
|
+ background: var(--card-bg);
|
|
|
+ border: 1px solid var(--border-color);
|
|
|
+ border-radius: 8px;
|
|
|
}
|
|
|
|
|
|
-.preview-grid {
|
|
|
+.control-grid {
|
|
|
display: grid;
|
|
|
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
|
- gap: 16px;
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.control-btn {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.log-section {
|
|
|
+ padding: 20px 24px;
|
|
|
+ background: var(--card-bg);
|
|
|
+ border: 1px solid var(--border-color);
|
|
|
+ border-radius: 8px;
|
|
|
+ max-height: 300px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
}
|
|
|
|
|
|
-.preview-item {
|
|
|
- aspect-ratio: 3 / 1;
|
|
|
+.log-list {
|
|
|
+ overflow-y: auto;
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.log-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.log-time {
|
|
|
+ font-family: monospace;
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
+ color: var(--text-secondary);
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.log-tag {
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.log-empty {
|
|
|
+ color: var(--text-secondary);
|
|
|
+ font-size: 13px;
|
|
|
+ text-align: center;
|
|
|
+ padding: 16px 0;
|
|
|
}
|
|
|
|
|
|
@media (max-width: 767px) {
|
|
|
- .stats-bar {
|
|
|
- flex-wrap: wrap;
|
|
|
- gap: 16px;
|
|
|
+ .top-bar {
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+ padding: 12px 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .clock {
|
|
|
+ font-size: 36px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .main-row {
|
|
|
+ flex-direction: column;
|
|
|
+ }
|
|
|
+
|
|
|
+ .main-status {
|
|
|
+ flex-direction: column;
|
|
|
padding: 16px;
|
|
|
}
|
|
|
|
|
|
- .stat-item {
|
|
|
- flex: 1 1 calc(33% - 16px);
|
|
|
- min-width: 80px;
|
|
|
+ .main-card {
|
|
|
+ width: 100%;
|
|
|
+ max-width: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .status-label {
|
|
|
+ font-size: 24px;
|
|
|
+ text-align: center;
|
|
|
}
|
|
|
|
|
|
- .stat-num {
|
|
|
- font-size: 28px;
|
|
|
+ .status-code {
|
|
|
+ text-align: center;
|
|
|
}
|
|
|
|
|
|
- .ws-status {
|
|
|
+ .control-section {
|
|
|
width: 100%;
|
|
|
- justify-content: center;
|
|
|
- margin-left: 0;
|
|
|
- padding-top: 8px;
|
|
|
- border-top: 1px solid var(--border-color);
|
|
|
+ padding: 16px;
|
|
|
}
|
|
|
|
|
|
- .port-grid {
|
|
|
- grid-template-columns: 1fr;
|
|
|
- gap: 12px;
|
|
|
+ .log-section {
|
|
|
+ padding: 16px;
|
|
|
+ max-height: 240px;
|
|
|
}
|
|
|
}
|
|
|
</style>
|