moki 3 дней назад
Родитель
Сommit
bd1c2b4bad
3 измененных файлов с 247 добавлено и 1 удалено
  1. 32 0
      package-lock.json
  2. 1 0
      package.json
  3. 214 1
      src/views/Dashboard.vue

+ 32 - 0
package-lock.json

@@ -11,6 +11,7 @@
         "@ant-design/icons-vue": "^7.0.1",
         "ant-design-vue": "^4.2.6",
         "axios": "^1.17.0",
+        "echarts": "^6.1.0",
         "vue": "^3.5.34",
         "vue-router": "^4.6.4"
       },
@@ -1229,6 +1230,22 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/echarts": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
+      "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "6.1.0"
+      }
+    },
+    "node_modules/echarts/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
     "node_modules/entities": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -2318,6 +2335,21 @@
       "dependencies": {
         "loose-envify": "^1.0.0"
       }
+    },
+    "node_modules/zrender": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
+      "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    },
+    "node_modules/zrender/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
     }
   }
 }

+ 1 - 0
package.json

@@ -12,6 +12,7 @@
     "@ant-design/icons-vue": "^7.0.1",
     "ant-design-vue": "^4.2.6",
     "axios": "^1.17.0",
+    "echarts": "^6.1.0",
     "vue": "^3.5.34",
     "vue-router": "^4.6.4"
   },

+ 214 - 1
src/views/Dashboard.vue

@@ -1,12 +1,15 @@
 <script setup lang="ts">
-import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
+import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
+import * as echarts from 'echarts'
 import {useSSE} from '@/composables/useSSE'
+import {useTheme} from '@/composables/useTheme'
 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 {theme} = useTheme()
 
 const currentCode = ref<StatusCode>('idle')
 const clock = ref(formatTime())
@@ -24,6 +27,27 @@ const deviceMqtt = ref('')
 let clockTimer: ReturnType<typeof setInterval> | null = null
 let durationTimer: ReturnType<typeof setInterval> | null = null
 
+const pieChartRef = ref<HTMLDivElement | null>(null)
+const timelineRef = ref<HTMLDivElement | null>(null)
+let pieChart: echarts.ECharts | null = null
+let timelineChart: echarts.ECharts | null = null
+
+const COLOR_MAP: Record<string, string> = {
+  green: '#52c41a',
+  blue: '#1890ff',
+  orange: '#fa8c16',
+  gold: '#faad14',
+  purple: '#722ed1',
+  cyan: '#13c2c2',
+  lime: '#a0d911',
+  yellow: '#fadb14',
+  red: '#ff4d4f',
+}
+
+function getColor(code: StatusCode): string {
+  return COLOR_MAP[STATUS_CONFIG[code]?.color] ?? '#595959'
+}
+
 function formatTime(): string {
   const d = new Date()
   const h = d.getHours().toString().padStart(2, '0')
@@ -73,6 +97,117 @@ const todayStats = computed(() => {
       }))
 })
 
+function getOrCreatePieChart(): echarts.ECharts {
+  if (!pieChart && pieChartRef.value) {
+    pieChart = echarts.init(pieChartRef.value)
+  }
+  return pieChart!
+}
+
+function getOrCreateTimelineChart(): echarts.ECharts {
+  if (!timelineChart && timelineRef.value) {
+    timelineChart = echarts.init(timelineRef.value)
+  }
+  return timelineChart!
+}
+
+function updatePieChart() {
+  if (!pieChart) return
+  const data = todayStats.value.map((item) => ({
+    name: item.label,
+    value: item.count,
+    itemStyle: {color: getColor(item.code)},
+  }))
+  const isDark = theme.value === 'dark'
+  pieChart.setOption({
+    tooltip: {trigger: 'item', formatter: '{b}: {c} ({d}%})'},
+    series: [{
+      type: 'pie',
+      radius: ['40%', '70%'],
+      center: ['50%', '50%'],
+      label: {
+        color: isDark ? '#d9d9d9' : '#1f1f1f',
+        fontSize: 12,
+      },
+      labelLine: {lineStyle: {color: isDark ? '#555' : '#ccc'}},
+      data,
+      emphasis: {itemStyle: {shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)'}},
+    }],
+  })
+}
+
+function updateTimelineChart() {
+  if (!timelineChart) return
+  const items = eventLog.value.slice().reverse()
+  if (items.length === 0) return
+
+  const times = items.map((item) => item.time)
+  const codes = items.map((item) => item.code)
+  const labels = codes.map((code) => STATUS_CONFIG[code]?.label ?? code)
+
+  const allCodes: StatusCode[] = ['idle', 'busy', 'reasoning', 'using_tool', 'running', 'pending', 'retry', 'completed', 'permission', 'error']
+  const codeIndex = Object.fromEntries(allCodes.map((c, i) => [c, i]))
+
+  const isDark = theme.value === 'dark'
+  timelineChart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      formatter: (params: any) => {
+        const p = params[0]
+        return `${p.name}<br/>${labels[p.dataIndex]}`
+      },
+    },
+    grid: {left: 10, right: 20, top: 10, bottom: 24, containLabel: true},
+    xAxis: {
+      type: 'category',
+      data: times,
+      axisLabel: {color: isDark ? '#8c8c8c' : '#666', fontSize: 10, rotate: 30},
+      axisLine: {lineStyle: {color: isDark ? '#303030' : '#e8e8e8'}},
+      boundaryGap: false,
+    },
+    yAxis: {
+      type: 'category',
+      data: allCodes.map((c) => STATUS_CONFIG[c]?.label ?? c),
+      axisLabel: {color: isDark ? '#8c8c8c' : '#666', fontSize: 10},
+      axisLine: {lineStyle: {color: isDark ? '#303030' : '#e8e8e8'}},
+      splitLine: {lineStyle: {color: isDark ? '#1a1a1a' : '#f0f0f0'}},
+    },
+    series: [{
+      type: 'line',
+      data: codes.map((code) => codeIndex[code]),
+      smooth: true,
+      symbol: 'circle',
+      symbolSize: 8,
+      lineStyle: {width: 2, color: '#1890ff'},
+      itemStyle: {
+        color: (params: any) => getColor(codes[params.dataIndex]),
+        borderWidth: 2,
+      },
+      areaStyle: {
+        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+          {offset: 0, color: 'rgba(24,144,255,0.25)'},
+          {offset: 1, color: 'rgba(24,144,255,0.02)'},
+        ]),
+      },
+    }],
+  })
+}
+
+watch(todayStats, () => {
+  nextTick(updatePieChart)
+})
+
+watch(eventLog, () => {
+  nextTick(updateTimelineChart)
+})
+
+watch(theme, () => {
+  nextTick(() => {
+    updatePieChart()
+    updateTimelineChart()
+  })
+})
+
 const removeListener = onMessage((msg: SseMessage) => {
   currentCode.value = msg.code
   statusSince.value = Date.now()
@@ -105,6 +240,15 @@ onMounted(() => {
   durationTimer = setInterval(updateDurations, 1000)
 
   fetchDeviceConfig()
+
+  if (pieChartRef.value) {
+    pieChart = echarts.init(pieChartRef.value)
+  }
+  if (timelineRef.value) {
+    timelineChart = echarts.init(timelineRef.value)
+  }
+
+  window.addEventListener('resize', handleResize)
 })
 
 onUnmounted(() => {
@@ -112,8 +256,16 @@ onUnmounted(() => {
   removeDisconnectListener()
   if (clockTimer) clearInterval(clockTimer)
   if (durationTimer) clearInterval(durationTimer)
+  pieChart?.dispose()
+  timelineChart?.dispose()
+  window.removeEventListener('resize', handleResize)
 })
 
+function handleResize() {
+  pieChart?.resize()
+  timelineChart?.resize()
+}
+
 async function fetchDeviceConfig() {
   try {
     const res = await http.get('/device/config')
@@ -187,6 +339,23 @@ async function sendCode(code: string) {
       </div>
     </div>
 
+    <div class="chart-row">
+      <div class="chart-card">
+        <div class="section-title">状态分布</div>
+        <div class="chart-wrap">
+          <div ref="pieChartRef" class="chart-container"/>
+          <div v-if="todayStats.length === 0" class="chart-overlay">暂无数据</div>
+        </div>
+      </div>
+      <div class="chart-card chart-card-wide">
+        <div class="section-title">状态时间线</div>
+        <div class="chart-wrap">
+          <div ref="timelineRef" class="chart-container"/>
+          <div v-if="eventLog.length === 0" class="chart-overlay">等待事件推送...</div>
+        </div>
+      </div>
+    </div>
+
     <div class="info-row">
       <div class="stats-section">
         <div class="section-title">今日统计</div>
@@ -366,6 +535,46 @@ async function sendCode(code: string) {
   width: 100%;
 }
 
+.chart-row {
+  display: flex;
+  gap: 10px;
+}
+
+.chart-card {
+  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);
+}
+
+.chart-card-wide {
+  flex: 2;
+}
+
+.chart-wrap {
+  position: relative;
+}
+
+.chart-container {
+  width: 100%;
+  height: 200px;
+}
+
+.chart-overlay {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--text-secondary);
+  font-size: 13px;
+  background: var(--card-bg);
+  pointer-events: none;
+}
+
 .info-row {
   display: flex;
   gap: 10px;
@@ -531,6 +740,10 @@ async function sendCode(code: string) {
     padding: 16px;
   }
 
+  .chart-row {
+    flex-direction: column;
+  }
+
   .info-row {
     flex-direction: column;
   }