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