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

25 KiB
Raw Blame History

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

<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> 中添加:

import SectionHeader from '@/components/stats/SectionHeader.vue'

在模板中使用:

<SectionHeader title="SYSTEM HEALTH" tag="cyan" />

Task 2: 创建 MetricCard 组件

Files:

  • Create: frontend/src/components/stats/MetricCard.vue

  • Step 1: 创建 MetricCard.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

<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

<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

<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 部分

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 部分
<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 部分
<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 不存在,添加:

--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 检查

cd frontend && npx vue-tsc --noEmit
  • Step 2: 运行开发服务器测试
cd frontend && npm run dev

执行选项

1. Subagent-Driven (推荐) - 我为每个任务派遣独立的子代理,任务间进行审查,快速迭代

2. Inline Execution - 在当前会话中按批次执行任务

选择哪种方式?