Add project documentation and specs

This commit is contained in:
2026-03-21 10:13:45 +08:00
parent e76f0828b9
commit 3a7f4174ab
20 changed files with 11179 additions and 0 deletions

View File

@@ -0,0 +1,982 @@
# 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**
```python
# 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**
```python
# 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**
```python
# 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__.py``main.py` 中添加:
```python
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**
```typescript
// 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**
```vue
<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**
```typescript
// frontend/src/router/index.ts
{
path: 'stats',
name: 'stats',
component: () => import('@/views/StatsView.vue'),
},
```
- [ ] **Step 2: Add navigation item**
```typescript
// 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` |