1011 lines
25 KiB
Markdown
1011 lines
25 KiB
Markdown
|
|
# Stats Dashboard Implementation Plan
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** 重新设计 StatsView.vue 数据统计页面,采用赛博朋克风格的迷你图表布局
|
|||
|
|
|
|||
|
|
**Architecture:** 单页垂直滚动布局,6个模块垂直排列,每个模块包含汇总数字卡片和迷你趋势图。复用现有 CSS 变量和 ChatView.vue 的设计模式。使用纯 CSS 实现迷你图表(条形图/折线图),避免引入新的图表依赖。
|
|||
|
|
|
|||
|
|
**Tech Stack:** Vue 3 + TypeScript + Pinia + lucide-vue-next(已有)+ echarts(已有,但优先用CSS实现)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## File Structure
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
frontend/src/
|
|||
|
|
├── views/
|
|||
|
|
│ └── StatsView.vue # 完全重写
|
|||
|
|
├── components/stats/
|
|||
|
|
│ ├── MetricCard.vue # 新建 - 指标卡片组件
|
|||
|
|
│ ├── MiniLineChart.vue # 新建 - 迷你折线图
|
|||
|
|
│ ├── MiniBarChart.vue # 新建 - 迷你柱状图
|
|||
|
|
│ ├── SectionHeader.vue # 新建 - 区块标题
|
|||
|
|
│ └── SummaryRow.vue # 新建 - 汇总行
|
|||
|
|
└── style.css # 可能需要新增CSS变量
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 1: 创建 SectionHeader 组件
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/src/components/stats/SectionHeader.vue`
|
|||
|
|
- Modify: `frontend/src/views/StatsView.vue`(引入组件)
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 SectionHeader.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
defineProps<{
|
|||
|
|
title: string
|
|||
|
|
tag?: 'cyan' | 'purple' | 'amber'
|
|||
|
|
}>()
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="section-header">
|
|||
|
|
<div class="section-title">
|
|||
|
|
<span class="section-slash">//</span>
|
|||
|
|
<span class="section-name">{{ title }}</span>
|
|||
|
|
</div>
|
|||
|
|
<span v-if="tag" class="section-tag" :class="tag">
|
|||
|
|
{{ title.split(' ')[0] }}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.section-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 16px 0 12px;
|
|||
|
|
border-bottom: 1px solid var(--border-dim);
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-title {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 12px;
|
|||
|
|
letter-spacing: 0.1em;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-slash {
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
margin-right: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-name {
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-tag {
|
|||
|
|
padding: 3px 10px;
|
|||
|
|
border-radius: 3px;
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 9px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
letter-spacing: 0.12em;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-tag.cyan {
|
|||
|
|
background: var(--accent-cyan-dim);
|
|||
|
|
color: var(--accent-cyan);
|
|||
|
|
border: 1px solid rgba(0, 245, 212, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-tag.purple {
|
|||
|
|
background: var(--accent-purple-dim);
|
|||
|
|
color: var(--accent-purple);
|
|||
|
|
border: 1px solid rgba(123, 44, 191, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-tag.amber {
|
|||
|
|
background: var(--accent-amber-dim);
|
|||
|
|
color: var(--accent-amber);
|
|||
|
|
border: 1px solid rgba(249, 168, 37, 0.2);
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 在 StatsView.vue 中引入并使用 SectionHeader**
|
|||
|
|
|
|||
|
|
在 `<script setup>` 中添加:
|
|||
|
|
```typescript
|
|||
|
|
import SectionHeader from '@/components/stats/SectionHeader.vue'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
在模板中使用:
|
|||
|
|
```vue
|
|||
|
|
<SectionHeader title="SYSTEM HEALTH" tag="cyan" />
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 2: 创建 MetricCard 组件
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/src/components/stats/MetricCard.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 MetricCard.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import type { Component } from 'vue'
|
|||
|
|
|
|||
|
|
defineProps<{
|
|||
|
|
icon: Component
|
|||
|
|
label: string
|
|||
|
|
value: string | number
|
|||
|
|
accentColor?: string
|
|||
|
|
}>()
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="metric-card" :style="{ '--card-accent': accentColor || 'var(--accent-cyan)' }">
|
|||
|
|
<div class="metric-icon">
|
|||
|
|
<component :is="icon" :size="16" />
|
|||
|
|
</div>
|
|||
|
|
<div class="metric-info">
|
|||
|
|
<span class="metric-value">{{ value }}</span>
|
|||
|
|
<span class="metric-label">{{ label }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.metric-card {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
padding: 14px 16px;
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-dim);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
transition: all var(--transition-mid);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.metric-card:hover {
|
|||
|
|
border-color: var(--card-accent);
|
|||
|
|
box-shadow: 0 0 12px color-mix(in srgb, var(--card-accent) 30%, transparent);
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.metric-icon {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 36px;
|
|||
|
|
height: 36px;
|
|||
|
|
border-radius: var(--radius-sm);
|
|||
|
|
background: color-mix(in srgb, var(--card-accent) 12%, transparent);
|
|||
|
|
color: var(--card-accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.metric-info {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.metric-value {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 20px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--card-accent);
|
|||
|
|
text-shadow: 0 0 10px color-mix(in srgb, var(--card-accent) 40%, transparent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.metric-label {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 10px;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
letter-spacing: 0.08em;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 3: 创建 SummaryRow 组件
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/src/components/stats/SummaryRow.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 SummaryRow.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
defineProps<{
|
|||
|
|
items: { label: string; value: string | number }[]
|
|||
|
|
columns?: number
|
|||
|
|
}>()
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="summary-row" :style="{ '--cols': columns || 4 }">
|
|||
|
|
<div v-for="item in items" :key="item.label" class="summary-item">
|
|||
|
|
<span class="summary-value">{{ item.value }}</span>
|
|||
|
|
<span class="summary-label">{{ item.label }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.summary-row {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(var(--cols), 1fr);
|
|||
|
|
gap: 16px;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.summary-item {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 4px;
|
|||
|
|
padding: 12px 16px;
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-dim);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.summary-item:hover {
|
|||
|
|
border-color: var(--border-mid);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.summary-value {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 18px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--accent-cyan);
|
|||
|
|
text-shadow: 0 0 8px var(--accent-cyan-glow);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.summary-label {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 9px;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
letter-spacing: 0.1em;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 4: 创建 MiniBarChart 组件(CSS实现)
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/src/components/stats/MiniBarChart.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 MiniBarChart.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
const props = withDefaults(defineProps<{
|
|||
|
|
data: number[]
|
|||
|
|
color?: string
|
|||
|
|
height?: number
|
|||
|
|
maxBars?: number
|
|||
|
|
}>(), {
|
|||
|
|
color: 'var(--accent-cyan)',
|
|||
|
|
height: 32,
|
|||
|
|
maxBars: 7
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const displayData = computed(() => {
|
|||
|
|
if (props.data.length <= props.maxBars) return props.data
|
|||
|
|
// 采样:取最后 N 个
|
|||
|
|
return props.data.slice(-props.maxBars)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const maxValue = computed(() => Math.max(...displayData.value, 1))
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="mini-bar-chart" :style="{ '--chart-height': height + 'px', '--chart-color': color }">
|
|||
|
|
<div class="bars">
|
|||
|
|
<div
|
|||
|
|
v-for="(value, i) in displayData"
|
|||
|
|
:key="i"
|
|||
|
|
class="bar"
|
|||
|
|
:style="{ height: (value / maxValue * 100) + '%' }"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.mini-bar-chart {
|
|||
|
|
width: 100%;
|
|||
|
|
height: var(--chart-height);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.bars {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: flex-end;
|
|||
|
|
gap: 2px;
|
|||
|
|
height: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.bar {
|
|||
|
|
flex: 1;
|
|||
|
|
background: var(--chart-color);
|
|||
|
|
border-radius: 2px 2px 0 0;
|
|||
|
|
opacity: 0.7;
|
|||
|
|
transition: opacity var(--transition-fast);
|
|||
|
|
min-height: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.bar:hover {
|
|||
|
|
opacity: 1;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 5: 创建 MiniLineChart 组件(CSS实现)
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/src/components/stats/MiniLineChart.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 MiniLineChart.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { computed } from 'vue'
|
|||
|
|
|
|||
|
|
const props = withDefaults(defineProps<{
|
|||
|
|
data: { date: string; value: number }[]
|
|||
|
|
color?: string
|
|||
|
|
height?: number
|
|||
|
|
maxPoints?: number
|
|||
|
|
}>(), {
|
|||
|
|
color: 'var(--accent-cyan)',
|
|||
|
|
height: 60,
|
|||
|
|
maxPoints: 30
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const displayData = computed(() => {
|
|||
|
|
if (props.data.length <= props.maxPoints) return props.data
|
|||
|
|
// 采样:均匀抽取
|
|||
|
|
const step = Math.ceil(props.data.length / props.maxPoints)
|
|||
|
|
return props.data.filter((_, i) => i % step === 0)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const maxValue = computed(() => Math.max(...displayData.value.map(d => d.value), 1))
|
|||
|
|
|
|||
|
|
const points = computed(() => {
|
|||
|
|
return displayData.value.map((d, i) => {
|
|||
|
|
const x = (i / (displayData.value.length - 1)) * 100
|
|||
|
|
const y = 100 - (d.value / maxValue.value * 100)
|
|||
|
|
return `${x},${y}`
|
|||
|
|
}).join(' ')
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="mini-line-chart" :style="{ '--chart-height': height + 'px', '--chart-color': color }">
|
|||
|
|
<svg :viewBox="`0 0 100 100`" preserveAspectRatio="none" class="chart-svg">
|
|||
|
|
<!-- 背景网格 -->
|
|||
|
|
<line x1="0" y1="50" x2="100" y2="50" class="grid-line" />
|
|||
|
|
<!-- 折线 -->
|
|||
|
|
<polyline :points="points" class="line" />
|
|||
|
|
<!-- 数据点 -->
|
|||
|
|
<circle
|
|||
|
|
v-for="(d, i) in displayData"
|
|||
|
|
:key="i"
|
|||
|
|
:cx="(i / (displayData.length - 1)) * 100"
|
|||
|
|
:cy="100 - (d.value / maxValue * 100)"
|
|||
|
|
r="2"
|
|||
|
|
class="dot"
|
|||
|
|
/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.mini-line-chart {
|
|||
|
|
width: 100%;
|
|||
|
|
height: var(--chart-height);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chart-svg {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.grid-line {
|
|||
|
|
stroke: var(--border-dim);
|
|||
|
|
stroke-width: 0.5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.line {
|
|||
|
|
fill: none;
|
|||
|
|
stroke: var(--chart-color);
|
|||
|
|
stroke-width: 1.5;
|
|||
|
|
stroke-linecap: round;
|
|||
|
|
stroke-linejoin: round;
|
|||
|
|
opacity: 0.8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dot {
|
|||
|
|
fill: var(--chart-color);
|
|||
|
|
opacity: 0;
|
|||
|
|
transition: opacity var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mini-line-chart:hover .dot {
|
|||
|
|
opacity: 1;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 6: 重写 StatsView.vue
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `frontend/src/views/StatsView.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 重写 script setup 部分**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { ref, onMounted, computed } from 'vue'
|
|||
|
|
import * as statsApi from '@/api/stats'
|
|||
|
|
import { Cpu, HardDrive, MemoryStick, Clock, MessageSquare, BookOpen, CheckSquare, TrendingUp, Tag } from 'lucide-vue-next'
|
|||
|
|
import SectionHeader from '@/components/stats/SectionHeader.vue'
|
|||
|
|
import MetricCard from '@/components/stats/MetricCard.vue'
|
|||
|
|
import SummaryRow from '@/components/stats/SummaryRow.vue'
|
|||
|
|
import MiniLineChart from '@/components/stats/MiniLineChart.vue'
|
|||
|
|
import MiniBarChart from '@/components/stats/MiniBarChart.vue'
|
|||
|
|
|
|||
|
|
const isLoading = ref(true)
|
|||
|
|
const hasError = ref(false)
|
|||
|
|
|
|||
|
|
// 数据状态
|
|||
|
|
const systemHealth = ref<any>(null)
|
|||
|
|
const conversationStats = ref<any>(null)
|
|||
|
|
const knowledgeStats = ref<any>(null)
|
|||
|
|
const kanbanStats = ref<any>(null)
|
|||
|
|
const communityStats = ref<any>(null)
|
|||
|
|
const personalInsights = ref<any>(null)
|
|||
|
|
|
|||
|
|
function formatUptime(seconds: number) {
|
|||
|
|
const days = Math.floor(seconds / 86400)
|
|||
|
|
const hours = Math.floor((seconds % 86400) / 3600)
|
|||
|
|
const mins = Math.floor((seconds % 3600) / 60)
|
|||
|
|
if (days > 0) return `${days}d ${hours}h`
|
|||
|
|
if (hours > 0) return `${hours}h ${mins}m`
|
|||
|
|
return `${mins}m`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatNumber(num: number): string {
|
|||
|
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
|||
|
|
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
|||
|
|
return num.toString()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
try {
|
|||
|
|
const promises = [
|
|||
|
|
statsApi.getSystemHealth().catch(() => null),
|
|||
|
|
]
|
|||
|
|
const [sys] = await Promise.all(promises)
|
|||
|
|
systemHealth.value = sys?.data || null
|
|||
|
|
|
|||
|
|
// 尝试加载用户相关数据(需要认证)
|
|||
|
|
const userPromises = [
|
|||
|
|
statsApi.getConversationStats().catch(() => null),
|
|||
|
|
statsApi.getKnowledgeStats().catch(() => null),
|
|||
|
|
statsApi.getKanbanStats().catch(() => null),
|
|||
|
|
statsApi.getCommunityStats().catch(() => null),
|
|||
|
|
statsApi.getPersonalInsights().catch(() => null),
|
|||
|
|
]
|
|||
|
|
const [conv, know, kanban, community, insights] = await Promise.all(userPromises)
|
|||
|
|
conversationStats.value = conv?.data || null
|
|||
|
|
knowledgeStats.value = know?.data || null
|
|||
|
|
kanbanStats.value = kanban?.data || null
|
|||
|
|
communityStats.value = community?.data || null
|
|||
|
|
personalInsights.value = insights?.data || null
|
|||
|
|
} catch (e) {
|
|||
|
|
hasError.value = true
|
|||
|
|
console.error('Failed to load stats:', e)
|
|||
|
|
} finally {
|
|||
|
|
isLoading.value = false
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 图表数据转换
|
|||
|
|
const convChartData = computed(() =>
|
|||
|
|
conversationStats.value?.daily_conversations?.map((d: any) => ({ date: d.date, value: d.count })) || []
|
|||
|
|
)
|
|||
|
|
const msgChartData = computed(() =>
|
|||
|
|
conversationStats.value?.daily_messages?.map((d: any) => ({ date: d.date, value: d.count })) || []
|
|||
|
|
)
|
|||
|
|
const inputTokenData = computed(() =>
|
|||
|
|
conversationStats.value?.daily_input_tokens?.map((d: any) => ({ date: d.date, value: d.input_tokens })) || []
|
|||
|
|
)
|
|||
|
|
const outputTokenData = computed(() =>
|
|||
|
|
conversationStats.value?.daily_output_tokens?.map((d: any) => ({ date: d.date, value: d.output_tokens })) || []
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const knowChartData = computed(() =>
|
|||
|
|
knowledgeStats.value?.daily_new_tags?.map((d: any) => ({ date: d.date, value: d.count })) || []
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const kanbanNewData = computed(() =>
|
|||
|
|
kanbanStats.value?.daily_new_tasks?.map((d: any) => d.count) || []
|
|||
|
|
)
|
|||
|
|
const kanbanDoneData = computed(() =>
|
|||
|
|
kanbanStats.value?.daily_completed_tasks?.map((d: any) => d.count) || []
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const communityChartData = computed(() =>
|
|||
|
|
communityStats.value?.daily_posts?.map((d: any) => ({ date: d.date, value: d.count })) || []
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const hourlyActivityData = computed(() =>
|
|||
|
|
personalInsights.value?.hourly_activity?.map((h: any) => h.count) || []
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 重写 template 部分**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<template>
|
|||
|
|
<div class="stats-view">
|
|||
|
|
<div class="stats-header">
|
|||
|
|
<h1>// DATA METRICS</h1>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="isLoading" class="loading-state">
|
|||
|
|
<div class="loading-spinner"></div>
|
|||
|
|
<span>Loading metrics...</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else-if="hasError" class="error-state">
|
|||
|
|
<span>Failed to load stats</span>
|
|||
|
|
<button @click="() => window.location.reload()">Refresh</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else class="stats-content">
|
|||
|
|
<!-- SYSTEM HEALTH -->
|
|||
|
|
<section class="stats-section">
|
|||
|
|
<SectionHeader title="SYSTEM HEALTH" tag="cyan" />
|
|||
|
|
<div class="metrics-grid">
|
|||
|
|
<MetricCard
|
|||
|
|
:icon="Cpu"
|
|||
|
|
label="CPU Usage"
|
|||
|
|
:value="systemHealth ? systemHealth.cpu_percent + '%' : '--'"
|
|||
|
|
accentColor="var(--accent-cyan)"
|
|||
|
|
/>
|
|||
|
|
<MetricCard
|
|||
|
|
:icon="MemoryStick"
|
|||
|
|
label="Memory"
|
|||
|
|
:value="systemHealth ? systemHealth.memory_percent + '%' : '--'"
|
|||
|
|
accentColor="var(--accent-purple)"
|
|||
|
|
/>
|
|||
|
|
<MetricCard
|
|||
|
|
:icon="HardDrive"
|
|||
|
|
label="Disk"
|
|||
|
|
:value="systemHealth ? systemHealth.disk_percent + '%' : '--'"
|
|||
|
|
accentColor="var(--accent-amber)"
|
|||
|
|
/>
|
|||
|
|
<MetricCard
|
|||
|
|
:icon="Clock"
|
|||
|
|
label="Uptime"
|
|||
|
|
:value="systemHealth ? formatUptime(systemHealth.uptime_seconds) : '--'"
|
|||
|
|
accentColor="var(--accent-green)"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<!-- CONVERSATIONS -->
|
|||
|
|
<section class="stats-section">
|
|||
|
|
<SectionHeader title="CONVERSATIONS" tag="cyan" />
|
|||
|
|
<SummaryRow
|
|||
|
|
v-if="conversationStats"
|
|||
|
|
:items="[
|
|||
|
|
{ label: 'Total Conversations', value: formatNumber(conversationStats.totals?.conversations || 0) },
|
|||
|
|
{ label: 'Total Messages', value: formatNumber(conversationStats.totals?.messages || 0) },
|
|||
|
|
{ label: 'Input Tokens', value: formatNumber(conversationStats.totals?.input_tokens || 0) },
|
|||
|
|
{ label: 'Output Tokens', value: formatNumber(conversationStats.totals?.output_tokens || 0) },
|
|||
|
|
]"
|
|||
|
|
/>
|
|||
|
|
<div class="chart-box" v-if="convChartData.length > 0">
|
|||
|
|
<div class="chart-label">30-Day Trend</div>
|
|||
|
|
<MiniLineChart :data="convChartData" color="var(--accent-cyan)" :height="80" />
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="empty-state">
|
|||
|
|
<span>No conversation data yet</span>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<!-- KNOWLEDGE -->
|
|||
|
|
<section class="stats-section">
|
|||
|
|
<SectionHeader title="KNOWLEDGE BASE" tag="purple" />
|
|||
|
|
<SummaryRow
|
|||
|
|
v-if="knowledgeStats"
|
|||
|
|
:items="[
|
|||
|
|
{ label: 'New Tags', value: formatNumber(knowledgeStats.totals?.new_tags || 0) },
|
|||
|
|
{ label: 'Documents', value: formatNumber(knowledgeStats.totals?.documents || 0) },
|
|||
|
|
{ label: 'Tag Relations', value: formatNumber(knowledgeStats.totals?.tag_relations || 0) },
|
|||
|
|
]"
|
|||
|
|
:columns="3"
|
|||
|
|
/>
|
|||
|
|
<div class="chart-box" v-if="knowChartData.length > 0">
|
|||
|
|
<div class="chart-label">Tag Growth</div>
|
|||
|
|
<MiniLineChart :data="knowChartData" color="var(--accent-purple)" :height="80" />
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="empty-state">
|
|||
|
|
<span>No knowledge data yet</span>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<!-- KANBAN -->
|
|||
|
|
<section class="stats-section">
|
|||
|
|
<SectionHeader title="KANBAN" tag="cyan" />
|
|||
|
|
<SummaryRow
|
|||
|
|
v-if="kanbanStats"
|
|||
|
|
:items="[
|
|||
|
|
{ label: 'Pending Tasks', value: kanbanStats.current_pending_tasks || 0 },
|
|||
|
|
{ label: 'New (30d)', value: formatNumber(kanbanStats.totals?.new_tasks || 0) },
|
|||
|
|
{ label: 'Done (30d)', value: formatNumber(kanbanStats.totals?.completed_tasks || 0) },
|
|||
|
|
]"
|
|||
|
|
:columns="3"
|
|||
|
|
/>
|
|||
|
|
<div class="chart-box" v-if="kanbanNewData.length > 0">
|
|||
|
|
<div class="chart-label">Tasks: New vs Completed</div>
|
|||
|
|
<div class="bar-chart-group">
|
|||
|
|
<MiniBarChart :data="kanbanNewData" color="var(--accent-cyan)" :height="60" />
|
|||
|
|
<MiniBarChart :data="kanbanDoneData" color="var(--accent-green)" :height="60" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="empty-state">
|
|||
|
|
<span>No kanban data yet</span>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<!-- COMMUNITY -->
|
|||
|
|
<section class="stats-section">
|
|||
|
|
<SectionHeader title="COMMUNITY" tag="amber" />
|
|||
|
|
<SummaryRow
|
|||
|
|
v-if="communityStats"
|
|||
|
|
:items="[
|
|||
|
|
{ label: 'Posts', value: formatNumber(communityStats.totals?.posts || 0) },
|
|||
|
|
{ label: 'Replies', value: formatNumber(communityStats.totals?.replies || 0) },
|
|||
|
|
{ label: 'AI Executions', value: formatNumber(communityStats.totals?.ai_executions || 0) },
|
|||
|
|
]"
|
|||
|
|
:columns="3"
|
|||
|
|
/>
|
|||
|
|
<div class="chart-box" v-if="communityChartData.length > 0">
|
|||
|
|
<div class="chart-label">Activity Trend</div>
|
|||
|
|
<MiniLineChart :data="communityChartData" color="var(--accent-amber)" :height="80" />
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="empty-state">
|
|||
|
|
<span>No community data yet</span>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<!-- INSIGHTS -->
|
|||
|
|
<section class="stats-section">
|
|||
|
|
<SectionHeader title="PERSONAL INSIGHTS" tag="cyan" />
|
|||
|
|
<div class="insights-grid" v-if="personalInsights">
|
|||
|
|
<div class="insight-card">
|
|||
|
|
<h4>Hourly Activity</h4>
|
|||
|
|
<MiniBarChart
|
|||
|
|
v-if="hourlyActivityData.length > 0"
|
|||
|
|
:data="hourlyActivityData"
|
|||
|
|
color="var(--accent-cyan)"
|
|||
|
|
:height="80"
|
|||
|
|
:maxBars="24"
|
|||
|
|
/>
|
|||
|
|
<div v-else class="empty-state small">No activity data</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="insight-card">
|
|||
|
|
<h4>Top Tags</h4>
|
|||
|
|
<ul class="tag-list" v-if="personalInsights.top_tags?.length">
|
|||
|
|
<li v-for="tag in personalInsights.top_tags" :key="tag.tag_path">
|
|||
|
|
<Tag :size="12" />
|
|||
|
|
<span class="tag-name">{{ tag.tag_path }}</span>
|
|||
|
|
<span class="tag-count">{{ tag.usage_count }}</span>
|
|||
|
|
</li>
|
|||
|
|
</ul>
|
|||
|
|
<div v-else class="empty-state small">No tags yet</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="insight-card">
|
|||
|
|
<h4>Token Trend</h4>
|
|||
|
|
<div class="token-trend">
|
|||
|
|
<span class="trend-value" :class="personalInsights.token_trend_percent > 0 ? 'up' : 'down'">
|
|||
|
|
<TrendingUp :size="16" />
|
|||
|
|
{{ personalInsights.token_trend_percent }}%
|
|||
|
|
</span>
|
|||
|
|
<span class="trend-label">vs last month</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="empty-state">
|
|||
|
|
<span>Login to see personal insights</span>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 重写 style 部分**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<style scoped>
|
|||
|
|
.stats-view {
|
|||
|
|
height: 100%;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
padding: 24px;
|
|||
|
|
background: var(--bg-void);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stats-header {
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stats-header h1 {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
letter-spacing: 0.15em;
|
|||
|
|
color: var(--accent-cyan);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stats-content {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stats-section {
|
|||
|
|
background: rgba(10, 15, 26, 0.6);
|
|||
|
|
border: 1px solid var(--border-dim);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
padding: 20px;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.metrics-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(4, 1fr);
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 1199px) {
|
|||
|
|
.metrics-grid { grid-template-columns: repeat(2, 1fr); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 767px) {
|
|||
|
|
.metrics-grid { grid-template-columns: 1fr; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chart-box {
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-dim);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
padding: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chart-label {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 9px;
|
|||
|
|
letter-spacing: 0.1em;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.bar-chart-group {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 1fr 1fr;
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.insights-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(3, 1fr);
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 1199px) {
|
|||
|
|
.insights-grid { grid-template-columns: 1fr 1fr; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 767px) {
|
|||
|
|
.insights-grid { grid-template-columns: 1fr; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.insight-card {
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-dim);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
padding: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.insight-card h4 {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 10px;
|
|||
|
|
letter-spacing: 0.1em;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-list {
|
|||
|
|
list-style: none;
|
|||
|
|
padding: 0;
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-list li {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 6px 0;
|
|||
|
|
border-bottom: 1px solid var(--border-dim);
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-list li:last-child {
|
|||
|
|
border-bottom: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-name {
|
|||
|
|
flex: 1;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-count {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
color: var(--accent-cyan);
|
|||
|
|
font-size: 11px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.token-trend {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 16px 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.trend-value {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 24px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.trend-value.up {
|
|||
|
|
color: var(--accent-red);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.trend-value.down {
|
|||
|
|
color: var(--accent-green);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.trend-label {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 9px;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
letter-spacing: 0.08em;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-state,
|
|||
|
|
.error-state,
|
|||
|
|
.empty-state {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
padding: 40px;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 12px;
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-spinner {
|
|||
|
|
width: 24px;
|
|||
|
|
height: 24px;
|
|||
|
|
border: 2px solid var(--border-dim);
|
|||
|
|
border-top-color: var(--accent-cyan);
|
|||
|
|
border-radius: 50%;
|
|||
|
|
animation: spin 1s linear infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes spin {
|
|||
|
|
to { transform: rotate(360deg); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.empty-state.small {
|
|||
|
|
padding: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
button {
|
|||
|
|
padding: 8px 16px;
|
|||
|
|
background: var(--accent-cyan-dim);
|
|||
|
|
border: 1px solid var(--border-mid);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
color: var(--accent-cyan);
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 11px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
button:hover {
|
|||
|
|
background: rgba(0, 245, 212, 0.2);
|
|||
|
|
box-shadow: var(--glow-cyan);
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 7: 添加缺少的 CSS 变量(如需要)
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `frontend/src/style.css`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 检查并添加缺失的 CSS 变量**
|
|||
|
|
|
|||
|
|
如果 `--accent-purple` 不存在,添加:
|
|||
|
|
```css
|
|||
|
|
--accent-purple: #a855f7;
|
|||
|
|
--accent-purple-dim: rgba(123, 44, 191, 0.15);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 8: 验证与测试
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Test: `frontend/src/views/StatsView.vue`
|
|||
|
|
- Test: `frontend/src/components/stats/*.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 运行 TypeScript 检查**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd frontend && npx vue-tsc --noEmit
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 运行开发服务器测试**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd frontend && npm run dev
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 验证页面渲染**
|
|||
|
|
- 打开 http://localhost:5173/stats
|
|||
|
|
- 确认无 console errors
|
|||
|
|
- 确认页面布局正确
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 执行选项
|
|||
|
|
|
|||
|
|
**1. Subagent-Driven (推荐)** - 我为每个任务派遣独立的子代理,任务间进行审查,快速迭代
|
|||
|
|
|
|||
|
|
**2. Inline Execution** - 在当前会话中按批次执行任务
|
|||
|
|
|
|||
|
|
选择哪种方式?
|