983 lines
30 KiB
Markdown
983 lines
30 KiB
Markdown
|
|
# Stats Dashboard Implementation Plan
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** 实现统计页面,展示系统健康、对话趋势、知识库、看板、社区和个人洞察等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` |
|