Files
JARVIS/docs/superpowers/plans/2026-03-20-stats-dashboard-implementation.md

1011 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** - 在当前会话中按批次执行任务
选择哪种方式?