moki 3 дней назад
Родитель
Сommit
2cbc76def7
3 измененных файлов с 257 добавлено и 151 удалено
  1. 6 6
      src/composables/useSSE.ts
  2. 5 0
      src/types/index.ts
  3. 246 145
      src/views/Dashboard.vue

+ 6 - 6
src/composables/useSSE.ts

@@ -1,17 +1,17 @@
-import { ref, onUnmounted } from 'vue'
-import type { StatusMessage } from '@/types'
+import {onUnmounted, ref} from 'vue'
+import type {SseMessage} from '@/types'
 
 
 export function useSSE(url: string) {
 export function useSSE(url: string) {
   const connected = ref(false)
   const connected = ref(false)
-  const messages = ref<StatusMessage[]>([])
+    const messages = ref<SseMessage[]>([])
   let eventSource: EventSource | null = null
   let eventSource: EventSource | null = null
   let reconnectTimer: ReturnType<typeof setTimeout> | null = null
   let reconnectTimer: ReturnType<typeof setTimeout> | null = null
   let reconnectDelay = 1000
   let reconnectDelay = 1000
 
 
-  const listeners = new Set<(msg: StatusMessage) => void>()
+    const listeners = new Set<(msg: SseMessage) => void>()
   const disconnectListeners = new Set<() => void>()
   const disconnectListeners = new Set<() => void>()
 
 
-  function onMessage(cb: (msg: StatusMessage) => void) {
+    function onMessage(cb: (msg: SseMessage) => void) {
     listeners.add(cb)
     listeners.add(cb)
     return () => listeners.delete(cb)
     return () => listeners.delete(cb)
   }
   }
@@ -33,7 +33,7 @@ export function useSSE(url: string) {
 
 
     eventSource.addEventListener('status', (event) => {
     eventSource.addEventListener('status', (event) => {
       try {
       try {
-        const data: StatusMessage = JSON.parse(event.data)
+          const data: SseMessage = JSON.parse(event.data)
         messages.value.push(data)
         messages.value.push(data)
         if (messages.value.length > 200) messages.value.shift()
         if (messages.value.length > 200) messages.value.shift()
         listeners.forEach((cb) => cb(data))
         listeners.forEach((cb) => cb(data))

+ 5 - 0
src/types/index.ts

@@ -52,6 +52,11 @@ export type StatusCode =
   | 'permission'
   | 'permission'
   | 'error'
   | 'error'
 
 
+export interface SseMessage {
+    code: StatusCode
+    timestamp: string
+}
+
 export interface StatusMessage {
 export interface StatusMessage {
   port: number
   port: number
   status: string
   status: string

+ 246 - 145
src/views/Dashboard.vue

@@ -1,122 +1,126 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import {computed, onMounted, onUnmounted, reactive} from 'vue'
+import {computed, onMounted, onUnmounted, ref} from 'vue'
 import {useSSE} from '@/composables/useSSE'
 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 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,
     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>
 </script>
 
 
 <template>
 <template>
   <div class="dashboard">
   <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>
         </span>
       </div>
       </div>
+      <div class="clock">{{ clock }}</div>
     </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>
     </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>
       </div>
     </div>
     </div>
@@ -126,112 +130,209 @@ onUnmounted(removeDisconnectListener)
 <style scoped>
 <style scoped>
 .dashboard {
 .dashboard {
   color: var(--text-color);
   color: var(--text-color);
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
 }
 }
 
 
-.stats-bar {
+.top-bar {
   display: flex;
   display: flex;
-  gap: 32px;
-  padding: 20px 24px;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16px 24px;
   background: var(--card-bg);
   background: var(--card-bg);
   border: 1px solid var(--border-color);
   border: 1px solid var(--border-color);
   border-radius: 8px;
   border-radius: 8px;
-  margin-bottom: 24px;
+}
+
+.sse-status {
+  display: flex;
   align-items: center;
   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-weight: 700;
+  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
+  font-variant-numeric: tabular-nums;
+  letter-spacing: 2px;
   color: var(--text-color);
   color: var(--text-color);
   line-height: 1;
   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;
   display: flex;
+  gap: 24px;
   align-items: center;
   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;
   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;
   flex: 1;
   min-width: 0;
   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;
   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) {
 @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;
     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%;
     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>
 </style>