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

30 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: 实现统计页面展示系统健康、对话趋势、知识库、看板、社区和个人洞察等6个Tab的指标数据。

Architecture:

  • 后端6个统计API端点按模块分组
  • 前端StatsView.vue 包含 6 个 Tab使用 ECharts 渲染折线图
  • 数据聚合SQL GROUP BY date_trunc('day')

Tech Stack: FastAPI, SQLAlchemy, ECharts, Vue 3, Element Plus


File Structure

backend/app/
├── routers/
│   └── stats.py                    # 新建: 统计 API 路由
├── services/
│   └── stats_service.py            # 新建: 统计服务
└── schemas/
    └── stats.py                    # 新建: 统计 Schema

frontend/src/
├── api/
│   └── stats.ts                    # 新建: 统计 API
├── views/
│   └── StatsView.vue               # 新建: 统计页面
└── router/
    └── index.ts                    # 修改: 添加 /stats 路由

Task 1: Create Stats Schema

Files:

  • Create: backend/app/schemas/stats.py

  • Step 1: Create stats schemas

# backend/app/schemas/stats.py
from pydantic import BaseModel
from typing import Optional
from datetime import datetime


# ===== System Health =====
class SystemHealth(BaseModel):
    uptime_seconds: int
    cpu_percent: float
    memory_used_mb: float
    memory_total_mb: float
    memory_percent: float
    disk_used_gb: float
    disk_total_gb: float
    disk_percent: float
    active_users_24h: int


# ===== Daily Stats Base =====
class DailyStatItem(BaseModel):
    date: str
    count: int


class DailyTokenStatItem(BaseModel):
    date: str
    input_tokens: int
    output_tokens: int


# ===== Conversation Stats =====
class ConversationStats(BaseModel):
    daily_conversations: list[DailyStatItem]
    daily_messages: list[DailyStatItem]
    daily_input_tokens: list[DailyTokenStatItem]
    daily_output_tokens: list[DailyTokenStatItem]
    totals: dict


# ===== Knowledge Stats =====
class KnowledgeStats(BaseModel):
    daily_new_tags: list[DailyStatItem]
    daily_documents: list[DailyStatItem]
    daily_knowledge_queries: list[DailyStatItem]
    daily_tag_relations: list[DailyStatItem]
    totals: dict


# ===== Kanban Stats =====
class KanbanStats(BaseModel):
    daily_new_tasks: list[DailyStatItem]
    daily_completed_tasks: list[DailyStatItem]
    daily_completion_rate: list[DailyStatItem]
    current_pending_tasks: int
    totals: dict


# ===== Community Stats =====
class CommunityStats(BaseModel):
    daily_posts: list[DailyStatItem]
    daily_replies: list[DailyStatItem]
    daily_ai_executions: list[DailyStatItem]
    daily_agent_calls: list[DailyStatItem]
    totals: dict


# ===== Personal Insights =====
class HourlyActivity(BaseModel):
    hour: int
    count: int


class TagUsage(BaseModel):
    tag_path: str
    usage_count: int


class PersonalInsights(BaseModel):
    hourly_activity: list[HourlyActivity]
    top_tags: list[TagUsage]
    token_trend_percent: float
    this_month_tokens: int
    last_month_tokens: int

Task 2: Create Stats Service

Files:

  • Create: backend/app/services/stats_service.py

  • Step 1: Create stats service

# backend/app/services/stats_service.py
import psutil
import time
from datetime import datetime, timedelta
from sqlalchemy import select, func, and_
from sqlalchemy.orm import Session
from app.models.conversation import Conversation, Message
from app.models.knowledge_graph import KGNode, KGEdge
from app.models.task import Task, TaskStatus
from app.models.forum import ForumPost, ForumReply
from app.models.document import Document
from app.models.user import User


class StatsService:
    def __init__(self, db: Session):
        self.db = db

    def get_system_health(self) -> dict:
        """获取系统健康指标"""
        # Uptime (假设进程启动时间)
        uptime_seconds = int(time.time() - psutil.boot_time())

        # CPU
        cpu_percent = psutil.cpu_percent(interval=0.1)

        # Memory
        mem = psutil.virtual_memory()
        memory_used_mb = mem.used / (1024 * 1024)
        memory_total_mb = mem.total / (1024 * 1024)
        memory_percent = mem.percent

        # Disk
        disk = psutil.disk_usage('/')
        disk_used_gb = disk.used / (1024 * 1024 * 1024)
        disk_total_gb = disk.total / (1024 * 1024 * 1024)
        disk_percent = disk.percent

        # Active users (24h)
        yesterday = datetime.utcnow() - timedelta(days=1)
        active_users = self.db.query(func.count(func.distinct(User.id))).filter(
            User.updated_at >= yesterday
        ).scalar() or 0

        return {
            "uptime_seconds": uptime_seconds,
            "cpu_percent": cpu_percent,
            "memory_used_mb": round(memory_used_mb, 1),
            "memory_total_mb": round(memory_total_mb, 1),
            "memory_percent": memory_percent,
            "disk_used_gb": round(disk_used_gb, 1),
            "disk_total_gb": round(disk_total_gb, 1),
            "disk_percent": disk_percent,
            "active_users_24h": active_users,
        }

    def _get_daily_stats(self, model, date_column, user_id=None, days=30) -> list:
        """通用每日统计查询"""
        cutoff = datetime.utcnow() - timedelta(days=days)
        query = self.db.query(
            func.date(date_column).label('date'),
            func.count().label('count')
        ).filter(date_column >= cutoff)

        if user_id:
            query = query.filter(model.user_id == user_id)

        query = query.group_by(func.date(date_column)).order_by(func.date(date_column))
        results = query.all()
        return [{"date": str(r.date), "count": r.count} for r in results]

    def get_conversation_stats(self, user_id: str = None, days=30) -> dict:
        """获取对话统计数据"""
        cutoff = datetime.utcnow() - timedelta(days=days)

        # Daily conversations
        daily_conversations = self._get_daily_stats(
            Conversation, Conversation.created_at, user_id, days
        )

        # Daily messages
        daily_messages = self._get_daily_stats(
            Message, Message.created_at, user_id, days
        )

        # Daily tokens (input vs output - approximated by role)
        input_query = self.db.query(
            func.date(Message.created_at).label('date'),
            func.coalesce(func.sum(Message.tokens_used), 0).label('tokens')
        ).filter(
            Message.created_at >= cutoff,
            Message.role == 'user'
        )
        if user_id:
            input_query = input_query.join(Conversation).filter(Conversation.user_id == user_id)
        input_query = input_query.group_by(func.date(Message.created_at))
        input_results = input_query.all()

        output_query = self.db.query(
            func.date(Message.created_at).label('date'),
            func.coalesce(func.sum(Message.tokens_used), 0).label('tokens')
        ).filter(
            Message.created_at >= cutoff,
            Message.role == 'assistant'
        )
        if user_id:
            output_query = output_query.join(Conversation).filter(Conversation.user_id == user_id)
        output_query = output_query.group_by(func.date(Message.created_at))
        output_results = output_query.all()

        daily_input_tokens = [
            {"date": str(r.date), "input_tokens": r.tokens}
            for r in input_results
        ]
        daily_output_tokens = [
            {"date": str(r.date), "output_tokens": r.tokens}
            for r in output_results
        ]

        total_conversations = sum(c["count"] for c in daily_conversations)
        total_messages = sum(m["count"] for m in daily_messages)
        total_input = sum(t["input_tokens"] for t in daily_input_tokens)
        total_output = sum(t["output_tokens"] for t in daily_output_tokens)

        return {
            "daily_conversations": daily_conversations,
            "daily_messages": daily_messages,
            "daily_input_tokens": daily_input_tokens,
            "daily_output_tokens": daily_output_tokens,
            "totals": {
                "conversations": total_conversations,
                "messages": total_messages,
                "input_tokens": total_input,
                "output_tokens": total_output,
            }
        }

    def get_knowledge_stats(self, user_id: str = None, days=30) -> dict:
        """获取知识库统计数据"""
        cutoff = datetime.utcnow() - timedelta(days=days)

        # New tags
        daily_new_tags = self._get_daily_stats(
            KGNode, KGNode.created_at, user_id, days
        )
        # Filter by tag type if user_id provided
        if user_id:
            tag_query = self.db.query(
                func.date(KGNode.created_at).label('date'),
                func.count().label('count')
            ).filter(
                KGNode.created_at >= cutoff,
                KGNode.user_id == user_id,
                KGNode.entity_type == 'tag'
            ).group_by(func.date(KGNode.created_at))
            daily_new_tags = [{"date": str(r.date), "count": r.count} for r in tag_query.all()]

        # Documents
        daily_documents = self._get_daily_stats(
            Document, Document.created_at, user_id, days
        )

        # Tag relations
        daily_tag_relations = self._get_daily_stats(
            KGEdge, KGEdge.created_at, user_id, days
        )

        return {
            "daily_new_tags": daily_new_tags,
            "daily_documents": daily_documents,
            "daily_knowledge_queries": [],  # 需要 Chroma 查询日志
            "daily_tag_relations": daily_tag_relations,
            "totals": {
                "new_tags": sum(t["count"] for t in daily_new_tags),
                "documents": sum(d["count"] for d in daily_documents),
                "tag_relations": sum(r["count"] for r in daily_tag_relations),
            }
        }

    def get_kanban_stats(self, user_id: str = None, days=30) -> dict:
        """获取看板统计数据"""
        cutoff = datetime.utcnow() - timedelta(days=days)

        # New tasks
        daily_new_tasks = self._get_daily_stats(
            Task, Task.created_at, user_id, days
        )

        # Completed tasks
        daily_completed = []
        completed_query = self.db.query(
            func.date(Task.completed_at).label('date'),
            func.count().label('count')
        ).filter(
            Task.completed_at >= cutoff,
            Task.status == TaskStatus.DONE
        )
        if user_id:
            completed_query = completed_query.filter(Task.user_id == user_id)
        completed_query = completed_query.group_by(func.date(Task.completed_at))
        daily_completed = [{"date": str(r.date), "count": r.count} for r in completed_query.all()]

        # Current pending tasks
        pending_count = self.db.query(func.count(Task.id)).filter(
            Task.status == TaskStatus.TODO
        )
        if user_id:
            pending_count = pending_count.filter(Task.user_id == user_id)
        current_pending = pending_count.scalar() or 0

        # Completion rate (daily)
        daily_new_dict = {d["date"]: d["count"] for d in daily_new_tasks}
        daily_completed_dict = {d["date"]: d["count"] for d in daily_completed}
        all_dates = set(daily_new_dict.keys()) | set(daily_completed_dict.keys())
        daily_completion_rate = []
        for date in sorted(all_dates):
            new = daily_new_dict.get(date, 0)
            completed = daily_completed_dict.get(date, 0)
            rate = (completed / new * 100) if new > 0 else 0
            daily_completion_rate.append({"date": date, "rate": round(rate, 1)})

        return {
            "daily_new_tasks": daily_new_tasks,
            "daily_completed_tasks": daily_completed,
            "daily_completion_rate": daily_completion_rate,
            "current_pending_tasks": current_pending,
            "totals": {
                "new_tasks": sum(t["count"] for t in daily_new_tasks),
                "completed_tasks": sum(c["count"] for c in daily_completed),
            }
        }

    def get_community_stats(self, user_id: str = None, days=30) -> dict:
        """获取社区统计数据"""
        cutoff = datetime.utcnow() - timedelta(days=days)

        # Posts
        daily_posts = self._get_daily_stats(
            ForumPost, ForumPost.created_at, user_id, days
        )

        # Replies
        daily_replies = self._get_daily_stats(
            ForumReply, ForumReply.created_at, user_id, days
        )

        # AI executions
        daily_ai_executions = []
        ai_query = self.db.query(
            func.date(ForumPost.updated_at).label('date'),
            func.count().label('count')
        ).filter(
            ForumPost.updated_at >= cutoff,
            ForumPost.is_executed == True
        )
        if user_id:
            ai_query = ai_query.filter(ForumPost.user_id == user_id)
        ai_query = ai_query.group_by(func.date(ForumPost.updated_at))
        daily_ai_executions = [{"date": str(r.date), "count": r.count} for r in ai_query.all()]

        return {
            "daily_posts": daily_posts,
            "daily_replies": daily_replies,
            "daily_ai_executions": daily_ai_executions,
            "daily_agent_calls": [],  # 需要 AgentMessage 表
            "totals": {
                "posts": sum(p["count"] for p in daily_posts),
                "replies": sum(r["count"] for r in daily_replies),
                "ai_executions": sum(a["count"] for a in daily_ai_executions),
            }
        }

    def get_personal_insights(self, user_id: str) -> dict:
        """获取个人洞察"""
        # Hourly activity
        hourly_query = self.db.query(
            func.extract('hour', Conversation.created_at).label('hour'),
            func.count().label('count')
        ).filter(
            Conversation.user_id == user_id
        ).group_by(func.extract('hour', Conversation.created_at))
        hourly_results = hourly_query.all()
        hourly_activity = [{"hour": int(r.hour), "count": r.count} for r in hourly_results]

        # Top tags
        tag_query = self.db.query(
            KGNode.properties_["tag_path"].astext.label('tag_path'),
            func.count(KGEdge.id).label('usage_count')
        ).join(
            KGEdge, KGEdge.target_id == KGNode.id
        ).filter(
            KGNode.user_id == user_id,
            KGNode.entity_type == 'tag',
            KGEdge.relation_type == 'has_tag'
        ).group_by(
            KGNode.properties_["tag_path"].astext
        ).order_by(func.count(KGEdge.id).desc()).limit(5)
        top_tags = [{"tag_path": r.tag_path, "usage_count": r.usage_count} for r in tag_query.all()]

        # Token trend (this month vs last month)
        now = datetime.utcnow()
        this_month_start = datetime(now.year, now.month, 1)
        last_month_end = this_month_start - timedelta(days=1)
        last_month_start = datetime(last_month_end.year, last_month_end.month, 1)

        this_month_tokens = self.db.query(
            func.coalesce(func.sum(Message.tokens_used), 0)
        ).join(Conversation).filter(
            Conversation.user_id == user_id,
            Message.created_at >= this_month_start,
            Message.role == 'assistant'
        ).scalar() or 0

        last_month_tokens = self.db.query(
            func.coalesce(func.sum(Message.tokens_used), 0)
        ).join(Conversation).filter(
            Conversation.user_id == user_id,
            Message.created_at >= last_month_start,
            Message.created_at < this_month_start,
            Message.role == 'assistant'
        ).scalar() or 0

        token_trend = 0
        if last_month_tokens > 0:
            token_trend = round((this_month_tokens - last_month_tokens) / last_month_tokens * 100, 1)

        return {
            "hourly_activity": hourly_activity,
            "top_tags": top_tags,
            "token_trend_percent": token_trend,
            "this_month_tokens": this_month_tokens,
            "last_month_tokens": last_month_tokens,
        }

Task 3: Create Stats Router

Files:

  • Create: backend/app/routers/stats.py

  • Step 1: Create stats router

# backend/app/routers/stats.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.stats import (
    SystemHealth,
    ConversationStats,
    KnowledgeStats,
    KanbanStats,
    CommunityStats,
    PersonalInsights,
)
from app.services.stats_service import StatsService

router = APIRouter(prefix="/api/stats", tags=["统计"])


@router.get("/system", response_model=SystemHealth)
async def get_system_health(db: Session = Depends(get_db)):
    """获取系统健康指标"""
    svc = StatsService(db)
    return svc.get_system_health()


@router.get("/conversations", response_model=ConversationStats)
async def get_conversation_stats(
    days: int = 30,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """获取对话统计数据"""
    svc = StatsService(db)
    return svc.get_conversation_stats(user_id=current_user.id, days=days)


@router.get("/knowledge", response_model=KnowledgeStats)
async def get_knowledge_stats(
    days: int = 30,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """获取知识库统计数据"""
    svc = StatsService(db)
    return svc.get_knowledge_stats(user_id=current_user.id, days=days)


@router.get("/kanban", response_model=KanbanStats)
async def get_kanban_stats(
    days: int = 30,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """获取看板统计数据"""
    svc = StatsService(db)
    return svc.get_kanban_stats(user_id=current_user.id, days=days)


@router.get("/community", response_model=CommunityStats)
async def get_community_stats(
    days: int = 30,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """获取社区统计数据"""
    svc = StatsService(db)
    return svc.get_community_stats(user_id=current_user.id, days=days)


@router.get("/insights", response_model=PersonalInsights)
async def get_personal_insights(
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """获取个人洞察"""
    svc = StatsService(db)
    return svc.get_personal_insights(user_id=current_user.id)
  • Step 2: Register router in main app

backend/app/__init__.pymain.py 中添加:

from app.routers import stats
app.include_router(stats.router)

Task 4: Create Frontend API

Files:

  • Create: frontend/src/api/stats.ts

  • Step 1: Create stats API

// frontend/src/api/stats.ts
import axios from '@/api'

export const getSystemHealth = () => axios.get('/stats/system')

export const getConversationStats = (days = 30) =>
  axios.get('/stats/conversations', { params: { days } })

export const getKnowledgeStats = (days = 30) =>
  axios.get('/stats/knowledge', { params: { days } })

export const getKanbanStats = (days = 30) =>
  axios.get('/stats/kanban', { params: { days } })

export const getCommunityStats = (days = 30) =>
  axios.get('/stats/community', { params: { days } })

export const getPersonalInsights = () => axios.get('/stats/insights')

Task 5: Create StatsView Component

Files:

  • Create: frontend/src/views/StatsView.vue

  • Step 1: Create StatsView with 6 tabs

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as statsApi from '@/api/stats'
import * as echarts from 'echarts'
import {
  Cpu, HardDrive, MemoryStick, Users, Activity,
  MessageSquare, BookOpen, CheckSquare, Forum,
  TrendingUp, Clock, Tag, Zap
} from 'lucide-vue-next'

const activeTab = ref('system')
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)

// Format uptime
function formatUptime(seconds: number) {
  const days = Math.floor(seconds / 86400)
  const hours = Math.floor((seconds % 86400) / 3600)
  const mins = Math.floor((seconds % 3600) / 60)
  return `${days}d ${hours}h ${mins}m`
}

// Chart refs
const convChartRef = ref<HTMLElement>()
const knowChartRef = ref<HTMLElement>()
const kanbanChartRef = ref<HTMLElement>()
const communityChartRef = ref<HTMLElement>()
const hourlyChartRef = ref<HTMLElement>()

onMounted(async () => {
  const [sys, conv, know, kanban, community, insights] = await Promise.all([
    statsApi.getSystemHealth(),
    statsApi.getConversationStats(),
    statsApi.getKnowledgeStats(),
    statsApi.getKanbanStats(),
    statsApi.getCommunityStats(),
    statsApi.getPersonalInsights(),
  ])
  systemHealth.value = sys.data
  conversationStats.value = conv.data
  knowledgeStats.value = know.data
  kanbanStats.value = kanban.data
  communityStats.value = community.data
  personalInsights.value = insights.data

  // Render charts
  renderLineChart(convChartRef.value, conv.data)
  renderLineChart(knowChartRef.value, know.data)
  renderKanbanChart(kanbanChartRef.value, kanban.data)
  renderLineChart(communityChartRef.value, community.data)
  renderHourlyChart(hourlyChartRef.value, insights.data)
})

function renderLineChart(el: HTMLElement, data: any) {
  if (!el || !data) return
  const chart = echarts.init(el)
  const dates = data.daily_conversations?.map((d: any) => d.date) || []

  const option = {
    tooltip: { trigger: 'axis' },
    legend: { data: Object.keys(data).filter(k => k.startsWith('daily_')) },
    xAxis: { type: 'category', data: dates },
    yAxis: { type: 'value' },
    series: Object.entries(data).filter(([k]) => k.startsWith('daily_')).map(([name, values]) => ({
      name: name.replace('daily_', ''),
      type: 'line',
      data: (values as any[]).map((v: any) => v.count || v.input_tokens || v.output_tokens || 0)
    }))
  }
  chart.setOption(option)
}

function renderKanbanChart(el: HTMLElement, data: any) {
  if (!el || !data) return
  const chart = echarts.init(el)
  const dates = [...new Set([
    ...data.daily_new_tasks.map((d: any) => d.date),
    ...data.daily_completed_tasks.map((d: any) => d.date)
  ])].sort()

  option = {
    tooltip: { trigger: 'axis' },
    legend: { data: ['新建任务', '完成任务'] },
    xAxis: { type: 'category', data: dates },
    yAxis: { type: 'value' },
    series: [
      { name: '新建任务', type: 'bar', data: dates.map(d => data.daily_new_tasks.find((t: any) => t.date === d)?.count || 0) },
      { name: '完成任务', type: 'bar', data: dates.map(d => data.daily_completed_tasks.find((t: any) => t.date === d)?.count || 0) }
    ]
  }
  chart.setOption(option)
}

function renderHourlyChart(el: HTMLElement, data: any) {
  if (!el || !data) return
  const chart = echarts.init(el)
  const hours = Array.from({ length: 24 }, (_, i) => i)
  const counts = hours.map(h => data.hourly_activity.find((a: any) => a.hour === h)?.count || 0)

  chart.setOption({
    tooltip: { trigger: 'axis' },
    xAxis: { type: 'category', data: hours.map(h => `${h}:00`) },
    yAxis: { type: 'value' },
    series: [{ type: 'bar', data: counts }]
  })
}
</script>

<template>
  <div class="stats-view">
    <div class="stats-header">
      <h1>数据统计</h1>
    </div>

    <el-tabs v-model="activeTab" type="border-card">
      <!-- Tab 1: System Health -->
      <el-tab-pane label="系统健康" name="system">
        <div class="metrics-grid" v-if="systemHealth">
          <div class="metric-card">
            <div class="metric-icon"><Clock /></div>
            <div class="metric-info">
              <span class="metric-value">{{ formatUptime(systemHealth.uptime_seconds) }}</span>
              <span class="metric-label">运行时间</span>
            </div>
          </div>
          <div class="metric-card">
            <div class="metric-icon"><Cpu /></div>
            <div class="metric-info">
              <span class="metric-value">{{ systemHealth.cpu_percent }}%</span>
              <span class="metric-label">CPU 使用率</span>
            </div>
          </div>
          <div class="metric-card">
            <div class="metric-icon"><MemoryStick /></div>
            <div class="metric-info">
              <span class="metric-value">{{ systemHealth.memory_percent }}%</span>
              <span class="metric-label">内存占用</span>
            </div>
          </div>
          <div class="metric-card">
            <div class="metric-icon"><HardDrive /></div>
            <div class="metric-info">
              <span class="metric-value">{{ systemHealth.disk_percent }}%</span>
              <span class="metric-label">磁盘使用</span>
            </div>
          </div>
          <div class="metric-card">
            <div class="metric-icon"><Users /></div>
            <div class="metric-info">
              <span class="metric-value">{{ systemHealth.active_users_24h }}</span>
              <span class="metric-label">活跃用户(24h)</span>
            </div>
          </div>
        </div>
      </el-tab-pane>

      <!-- Tab 2: Conversations -->
      <el-tab-pane label="对话统计" name="conversations">
        <div class="chart-container" ref="convChartRef"></div>
        <div class="totals-row" v-if="conversationStats">
          <div class="total-item">
            <span class="total-value">{{ conversationStats.totals.conversations }}</span>
            <span class="total-label">对话总数</span>
          </div>
          <div class="total-item">
            <span class="total-value">{{ conversationStats.totals.messages }}</span>
            <span class="total-label">消息总数</span>
          </div>
          <div class="total-item">
            <span class="total-value">{{ conversationStats.totals.input_tokens }}</span>
            <span class="total-label">Input Tokens</span>
          </div>
          <div class="total-item">
            <span class="total-value">{{ conversationStats.totals.output_tokens }}</span>
            <span class="total-label">Output Tokens</span>
          </div>
        </div>
      </el-tab-pane>

      <!-- Tab 3: Knowledge -->
      <el-tab-pane label="知识库" name="knowledge">
        <div class="chart-container" ref="knowChartRef"></div>
      </el-tab-pane>

      <!-- Tab 4: Kanban -->
      <el-tab-pane label="看板" name="kanban">
        <div class="chart-container" ref="kanbanChartRef"></div>
        <div class="totals-row" v-if="kanbanStats">
          <div class="total-item">
            <span class="total-value">{{ kanbanStats.current_pending_tasks }}</span>
            <span class="total-label">待办任务</span>
          </div>
        </div>
      </el-tab-pane>

      <!-- Tab 5: Community -->
      <el-tab-pane label="社区" name="community">
        <div class="chart-container" ref="communityChartRef"></div>
      </el-tab-pane>

      <!-- Tab 6: Personal Insights -->
      <el-tab-pane label="个人洞察" name="insights">
        <div class="insights-grid" v-if="personalInsights">
          <div class="insight-card">
            <h3>活跃时段</h3>
            <div class="chart-small" ref="hourlyChartRef"></div>
          </div>
          <div class="insight-card">
            <h3>常用标签 Top5</h3>
            <ul class="tag-list">
              <li v-for="tag in personalInsights.top_tags" :key="tag.tag_path">
                <Tag /> {{ tag.tag_path }} ({{ tag.usage_count }})
              </li>
            </ul>
          </div>
          <div class="insight-card">
            <h3>Token 消耗趋势</h3>
            <div class="trend-value" :class="personalInsights.token_trend_percent > 0 ? 'up' : 'down'">
              <TrendingUp /> {{ personalInsights.token_trend_percent }}%
            </div>
            <p>本月 vs 上月</p>
          </div>
        </div>
      </el-tab-pane>
    </el-tabs>
  </div>
</template>

<style scoped>
.stats-view {
  padding: 24px;
}
.stats-header h1 {
  font-size: 24px;
  margin-bottom: 24px;
}
.metrics-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 16px;
}
.metric-card {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px;
  background: var(--bg-panel);
  border-radius: 8px;
  border: 1px solid var(--border-dim);
}
.metric-icon {
  color: var(--accent-cyan);
}
.metric-info {
  display: flex;
  flex-direction: column;
}
.metric-value {
  font-size: 20px;
  font-weight: 600;
}
.metric-label {
  font-size: 12px;
  color: var(--text-dim);
}
.chart-container {
  height: 300px;
  margin-bottom: 24px;
}
.totals-row {
  display: flex;
  gap: 24px;
}
.total-item {
  display: flex;
  flex-direction: column;
}
.total-value {
  font-size: 24px;
  font-weight: 600;
}
.total-label {
  font-size: 12px;
  color: var(--text-dim);
}
.insights-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 24px;
}
.insight-card {
  background: var(--bg-panel);
  padding: 16px;
  border-radius: 8px;
  border: 1px solid var(--border-dim);
}
.insight-card h3 {
  margin-bottom: 12px;
}
.chart-small {
  height: 200px;
}
.tag-list {
  list-style: none;
  padding: 0;
}
.tag-list li {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 0;
  border-bottom: 1px solid var(--border-dim);
}
.trend-value {
  font-size: 32px;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
}
.trend-value.up { color: var(--accent-red); }
.trend-value.down { color: var(--accent-green); }
</style>

Task 6: Add Route and Navigation

Files:

  • Modify: frontend/src/router/index.ts

  • Modify: frontend/src/components/SidebarNav.vue

  • Step 1: Add route

// frontend/src/router/index.ts
{
  path: 'stats',
  name: 'stats',
  component: () => import('@/views/StatsView.vue'),
},
  • Step 2: Add navigation item
// frontend/src/components/SidebarNav.vue
// Add to navItems array:
{ name: '统计', path: '/stats', icon: Activity },

Summary

Task Description Files
1 Stats Schema schemas/stats.py
2 Stats Service services/stats_service.py
3 Stats Router routers/stats.py
4 Frontend API api/stats.ts
5 StatsView Component views/StatsView.vue
6 Route & Navigation router/index.ts, SidebarNav.vue