feat(dashboard): 新增用户活跃度、工具调用、知识库和智能体分析统计功能

- 在后端新增用户活跃度、工具调用、知识库和智能体分析的统计接口
- 前端实现相应的统计组件,展示各类统计信息
- 更新样式以提升用户体验,增加图表和数据展示
- 优化仪表板布局,整合各类统计信息,提升可视化效果
This commit is contained in:
Wenjie Zhang 2025-10-05 21:45:39 +08:00
parent df5d27beec
commit 52ce5f51bd
12 changed files with 3312 additions and 266 deletions

View File

@ -1,33 +1,26 @@
目前已有的开发计划包括:
📝 0.2.2 version
📝 0.3 version
不再新增任何特性,仅作功能调整以及 bug 修复。
🐛**BUGs**
- [ ] 部分 doc 格式的文件支持有问题
- [ ] 当出现不支持的文件类型的时候,前端没有限制
- [ ] 当消息生成的时候有报错的时候,前端无显示(需要解决存储的问题)
- [ ] 当消息生成的时候有报错的时候,前端无显示
- [ ] 另外一个智能体的历史对话无法显示
---
💭 **Features Todo**
- [x] LangGraph 升级到 0.6+ 版本,并适配新特性,如 context 等,添加 MCP 工具的支持。
- [x] 支持动态工具配置的同时,将 Configuration替换为 Context 后能够正常使用
- [x] 使用更好的知识图谱检索方法(二跳结果查询)
- [ ] 使用其他的聊天记录管理方法,解决两个问题,一个是上下文长度过长,一个是上下文的类型变得更加丰富,比如多模态等等。(现在是基于 LangGraph 的 Memory 实现的v0.2.3 版本实现),暂定使用 [mem0](github.com/mem0ai/mem0) 来实现。但是目前了解下来,还不是我想要的那种方案。可能会基于这个实现一个 ThreadConvManager 这个类。
- [ ] 添加对于上传文件的支持这里的复杂的地方就在于如何和历史记录结合在一起v0.2.3 版本实现,放在记忆管理后面)
- [x] 将现在的 graphview.vue 文件中 GraphContainer相关的代码分离到一个单独的组件中然后 actions 和 footer 都是作为 slot top/bottom 提供的
- [x] 然后应用到 web/src/components/ToolCallingResult/KnowledgeGraphResult.vue 中,替换现有的 GraphContainer。
- [ ] 知识图谱的上传和可视化,支持属性,标签的展示
- [ ] 集成智能体评估,首先使用命令行来实现,然后考虑放在 UI 里面展示
- [ ] 开发与生产环境隔离
- [ ] 添加统计信息
- [x] 添加内容审查功能
- [x] 补充 embedding 模型和 reranker 模型的配置说明
- [ ] 支持 MinerU 的解析方法
- [x] 优化 knowledge 文件夹下面的架构
- [ ] Options 中添加网络搜索和绘制图片的选项,分别是用来调用工具
📝 **Base**
@ -40,10 +33,6 @@
下面的功能**可能**会放在后续版本实现,暂时未定
- [x] 支持数据库查询功能
- [x] 消息内容支持图片显示
- [x] 添加 SQL 读取工具
- [x] 添加绘图工具(这里的绘图是指绘制固定格式的图和表等,暂时没想好如何支持自定义绘图)
- [ ] 添加测试脚本,覆盖最常见的功能
- [ ] 优化对文档信息的检索展示(检索结果页、详情页)
- [ ] 集成 LangFuse (观望)添加用户日志与用户反馈模块,可以在 AgentView 中查看信息

View File

@ -27,6 +27,48 @@ dashboard = APIRouter(prefix="/dashboard", tags=["Dashboard"])
# =============================================================================
class UserActivityStats(BaseModel):
"""用户活跃度统计"""
total_users: int
active_users_24h: int
active_users_30d: int
daily_active_users: list[dict] # 最近7天每日活跃用户
class ToolCallStats(BaseModel):
"""工具调用统计"""
total_calls: int
successful_calls: int
failed_calls: int
success_rate: float
most_used_tools: list[dict]
tool_error_distribution: dict
daily_tool_calls: list[dict] # 最近7天每日工具调用数
class KnowledgeStats(BaseModel):
"""知识库统计"""
total_databases: int
total_files: int
total_nodes: int
total_storage_size: int # 字节
databases_by_type: dict
file_type_distribution: dict
class AgentAnalytics(BaseModel):
"""AI智能体分析"""
total_agents: int
agent_conversation_counts: list[dict]
agent_satisfaction_rates: list[dict]
agent_tool_usage: list[dict]
top_performing_agents: list[dict]
class ConversationListItem(BaseModel):
"""Conversation list item"""
@ -176,7 +218,373 @@ async def get_conversation_detail(
# =============================================================================
# Statistics
# User Activity Statistics
# =============================================================================
@dashboard.get("/stats/users", response_model=UserActivityStats)
async def get_user_activity_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
):
"""Get user activity statistics (Admin only)"""
try:
from src.storage.db.models import User, Conversation
now = datetime.utcnow()
# 基础用户统计
total_users = db.query(func.count(User.id)).scalar() or 0
# 不同时间段的活跃用户数(基于对话活动)
active_users_24h = (
db.query(func.count(distinct(Conversation.user_id)))
.filter(Conversation.updated_at >= now - timedelta(days=1))
.scalar()
or 0
)
active_users_30d = (
db.query(func.count(distinct(Conversation.user_id)))
.filter(Conversation.updated_at >= now - timedelta(days=30))
.scalar()
or 0
)
# 最近7天每日活跃用户
daily_active_users = []
for i in range(7):
day_start = now - timedelta(days=i + 1)
day_end = now - timedelta(days=i)
active_count = (
db.query(func.count(distinct(Conversation.user_id)))
.filter(Conversation.updated_at >= day_start, Conversation.updated_at < day_end)
.scalar()
or 0
)
daily_active_users.append({"date": day_start.strftime("%Y-%m-%d"), "active_users": active_count})
return UserActivityStats(
total_users=total_users,
active_users_24h=active_users_24h,
active_users_30d=active_users_30d,
daily_active_users=list(reversed(daily_active_users)), # 按时间正序
)
except Exception as e:
logger.error(f"Error getting user activity stats: {e}")
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Failed to get user activity stats: {str(e)}")
# =============================================================================
# Tool Call Statistics
# =============================================================================
@dashboard.get("/stats/tools", response_model=ToolCallStats)
async def get_tool_call_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
):
"""Get tool call statistics (Admin only)"""
try:
from src.storage.db.models import ToolCall
now = datetime.utcnow()
# 基础工具调用统计
total_calls = db.query(func.count(ToolCall.id)).scalar() or 0
successful_calls = db.query(func.count(ToolCall.id)).filter(ToolCall.status == "success").scalar() or 0
failed_calls = total_calls - successful_calls
success_rate = round((successful_calls / total_calls * 100), 2) if total_calls > 0 else 0
# 最常用工具
most_used_tools = (
db.query(ToolCall.tool_name, func.count(ToolCall.id).label("count"))
.group_by(ToolCall.tool_name)
.order_by(func.count(ToolCall.id).desc())
.limit(10)
.all()
)
most_used_tools = [{"tool_name": name, "count": count} for name, count in most_used_tools]
# 工具错误分布
tool_errors = (
db.query(ToolCall.tool_name, func.count(ToolCall.id).label("error_count"))
.filter(ToolCall.status == "error")
.group_by(ToolCall.tool_name)
.all()
)
tool_error_distribution = {name: count for name, count in tool_errors}
# 最近7天每日工具调用数
daily_tool_calls = []
for i in range(7):
day_start = now - timedelta(days=i + 1)
day_end = now - timedelta(days=i)
daily_count = (
db.query(func.count(ToolCall.id))
.filter(ToolCall.created_at >= day_start, ToolCall.created_at < day_end)
.scalar()
or 0
)
daily_tool_calls.append({"date": day_start.strftime("%Y-%m-%d"), "call_count": daily_count})
return ToolCallStats(
total_calls=total_calls,
successful_calls=successful_calls,
failed_calls=failed_calls,
success_rate=success_rate,
most_used_tools=most_used_tools,
tool_error_distribution=tool_error_distribution,
daily_tool_calls=list(reversed(daily_tool_calls)),
)
except Exception as e:
logger.error(f"Error getting tool call stats: {e}")
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Failed to get tool call stats: {str(e)}")
# =============================================================================
# Knowledge Base Statistics
# =============================================================================
@dashboard.get("/stats/knowledge", response_model=KnowledgeStats)
async def get_knowledge_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
):
"""Get knowledge base statistics (Admin only)"""
try:
from src.knowledge.manager import KnowledgeBaseManager
import json
import os
# 从知识库管理系统获取数据
kb_manager = KnowledgeBaseManager(work_dir="/app/saves/knowledge_base_data")
# 读取全局元数据文件
metadata_file = "/app/saves/knowledge_base_data/global_metadata.json"
if os.path.exists(metadata_file):
with open(metadata_file, encoding="utf-8") as f:
global_metadata = json.load(f)
databases = global_metadata.get("databases", {})
total_databases = len(databases)
# 统计不同类型的知识库
databases_by_type = {}
files_by_type = {}
total_files = 0
total_nodes = 0
total_storage_size = 0
# 文件类型映射到中文友好名称
file_type_mapping = {
"txt": "文本文件",
"pdf": "PDF文档",
"docx": "Word文档",
"doc": "Word文档",
"md": "Markdown",
"html": "HTML网页",
"htm": "HTML网页",
"json": "JSON数据",
"csv": "CSV表格",
"xlsx": "Excel表格",
"xls": "Excel表格",
"pptx": "PowerPoint",
"ppt": "PowerPoint",
"png": "PNG图片",
"jpg": "JPEG图片",
"jpeg": "JPEG图片",
"gif": "GIF图片",
"svg": "SVG图片",
"mp4": "MP4视频",
"mp3": "MP3音频",
"zip": "ZIP压缩包",
"rar": "RAR压缩包",
"7z": "7Z压缩包",
}
# 统计上传文件
uploads_dir = "/app/saves/knowledge_base_data/uploads"
if os.path.exists(uploads_dir):
for root, dirs, files in os.walk(uploads_dir):
for file in files:
# 支持更多文件类型
if file.lower().endswith(tuple(file_type_mapping.keys())):
total_files += 1
file_ext = file.lower().split(".")[-1]
# 使用映射后的友好名称
display_name = file_type_mapping.get(file_ext, file_ext.upper() + "文件")
files_by_type[display_name] = files_by_type.get(display_name, 0) + 1
# 估算文件大小
file_path = os.path.join(root, file)
try:
file_size = os.path.getsize(file_path)
total_storage_size += file_size
except OSError:
total_storage_size += 1024 # 默认1KB
# 统计知识库类型分布
for kb_id, kb_info in databases.items():
kb_type = kb_info.get("kb_type", "unknown")
display_type = {
"lightrag": "LightRAG",
"chroma": "Chroma向量库",
"faiss": "FAISS索引",
"milvus": "Milvus向量库",
"qdrant": "Qdrant向量库",
"elasticsearch": "Elasticsearch",
"unknown": "未知类型",
}.get(kb_type.lower(), kb_type)
databases_by_type[display_type] = databases_by_type.get(display_type, 0) + 1
# 尝试从各个知识库系统获取更详细的统计
try:
kb_instance = kb_manager.get_kb(kb_id)
if kb_instance and hasattr(kb_instance, "get_stats"):
stats = kb_instance.get_stats()
total_nodes += stats.get("node_count", 0)
except Exception as e:
logger.warning(f"Failed to get stats for KB {kb_id}: {e}")
continue
else:
# 如果没有元数据文件,返回空数据
total_databases = 0
total_files = 0
total_nodes = 0
total_storage_size = 0
databases_by_type = {}
files_by_type = {}
return KnowledgeStats(
total_databases=total_databases,
total_files=total_files,
total_nodes=total_nodes,
total_storage_size=total_storage_size,
databases_by_type=databases_by_type,
file_type_distribution=files_by_type, # 保持API兼容但使用新的数据
)
except Exception as e:
logger.error(f"Error getting knowledge stats: {e}")
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Failed to get knowledge stats: {str(e)}")
# =============================================================================
# Agent Analytics
# =============================================================================
@dashboard.get("/stats/agents", response_model=AgentAnalytics)
async def get_agent_analytics(
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
):
"""Get AI agent analytics (Admin only)"""
try:
from src.storage.db.models import Conversation, MessageFeedback, Message, ToolCall
# 获取所有智能体
agents = (
db.query(Conversation.agent_id, func.count(Conversation.id).label("conversation_count"))
.group_by(Conversation.agent_id)
.all()
)
total_agents = len(agents)
agent_conversation_counts = [{"agent_id": agent_id, "conversation_count": count} for agent_id, count in agents]
# 智能体满意度统计
agent_satisfaction = []
for agent_id, _ in agents:
total_feedbacks = (
db.query(func.count(MessageFeedback.id))
.join(Message, MessageFeedback.message_id == Message.id)
.join(Conversation, Message.conversation_id == Conversation.id)
.filter(Conversation.agent_id == agent_id)
.scalar()
or 0
)
positive_feedbacks = (
db.query(func.count(MessageFeedback.id))
.join(Message, MessageFeedback.message_id == Message.id)
.join(Conversation, Message.conversation_id == Conversation.id)
.filter(Conversation.agent_id == agent_id, MessageFeedback.rating == "like")
.scalar()
or 0
)
satisfaction_rate = round((positive_feedbacks / total_feedbacks * 100), 2) if total_feedbacks > 0 else 0
agent_satisfaction.append(
{"agent_id": agent_id, "satisfaction_rate": satisfaction_rate, "total_feedbacks": total_feedbacks}
)
# 智能体工具使用统计
agent_tool_usage = []
for agent_id, _ in agents:
tool_usage_count = (
db.query(func.count(ToolCall.id))
.join(Message, ToolCall.message_id == Message.id)
.join(Conversation, Message.conversation_id == Conversation.id)
.filter(Conversation.agent_id == agent_id)
.scalar()
or 0
)
agent_tool_usage.append({"agent_id": agent_id, "tool_usage_count": tool_usage_count})
# 表现最佳的智能体(综合评分)
top_performing_agents = []
for i, (agent_id, conv_count) in enumerate(agents):
# 综合评分 = 对话数权重 + 满意度权重
satisfaction_data = next(
(s for s in agent_satisfaction if s["agent_id"] == agent_id), {"satisfaction_rate": 0}
)
score = conv_count * 0.3 + satisfaction_data["satisfaction_rate"] * 0.7
top_performing_agents.append(
{
"agent_id": agent_id,
"score": round(score, 2),
"conversation_count": conv_count,
"satisfaction_rate": satisfaction_data["satisfaction_rate"],
}
)
# 按评分排序取前5名
top_performing_agents.sort(key=lambda x: x["score"], reverse=True)
top_performing_agents = top_performing_agents[:5]
return AgentAnalytics(
total_agents=total_agents,
agent_conversation_counts=agent_conversation_counts,
agent_satisfaction_rates=agent_satisfaction,
agent_tool_usage=agent_tool_usage,
top_performing_agents=top_performing_agents,
)
except Exception as e:
logger.error(f"Error getting agent analytics: {e}")
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Failed to get agent analytics: {str(e)}")
# =============================================================================
# Basic Statistics (保留原有接口)
# =============================================================================
@ -197,54 +605,21 @@ async def get_dashboard_stats(
total_messages = db.query(func.count(Message.id)).scalar() or 0
total_users = db.query(func.count(distinct(Conversation.user_id))).scalar() or 0
# Conversations by agent
conversations_by_agent = (
db.query(Conversation.agent_id, func.count(Conversation.id).label("count"))
.group_by(Conversation.agent_id)
.all()
)
# Recent activity (last 24 hours)
yesterday = datetime.utcnow() - timedelta(days=1)
recent_conversations = (
db.query(func.count(Conversation.id)).filter(Conversation.created_at >= yesterday).scalar() or 0
)
recent_messages = db.query(func.count(Message.id)).filter(Message.created_at >= yesterday).scalar() or 0
# Feedback statistics
total_feedbacks = db.query(func.count(MessageFeedback.id)).scalar() or 0
like_count = db.query(func.count(MessageFeedback.id)).filter(MessageFeedback.rating == "like").scalar() or 0
dislike_count = (
db.query(func.count(MessageFeedback.id)).filter(MessageFeedback.rating == "dislike").scalar() or 0
)
# Calculate satisfaction rate
satisfaction_rate = round((like_count / total_feedbacks * 100), 2) if total_feedbacks > 0 else 0
# Recent feedback (last 24 hours)
recent_feedbacks = (
db.query(func.count(MessageFeedback.id)).filter(MessageFeedback.created_at >= yesterday).scalar() or 0
)
return {
"timestamp": datetime.utcnow().isoformat(),
"total_conversations": total_conversations,
"active_conversations": active_conversations,
"total_messages": total_messages,
"total_users": total_users,
"conversations_by_agent": [
{"agent_id": agent_id or "unknown", "count": count} for agent_id, count in conversations_by_agent
],
"recent_activity": {
"conversations_24h": recent_conversations,
"messages_24h": recent_messages,
},
"feedback_stats": {
"total_feedbacks": total_feedbacks,
"like_count": like_count,
"dislike_count": dislike_count,
"satisfaction_rate": satisfaction_rate,
"recent_feedbacks_24h": recent_feedbacks,
},
}
except Exception as e:
@ -262,7 +637,6 @@ class FeedbackListItem(BaseModel):
"""Feedback list item"""
id: int
message_id: int
user_id: str
rating: str
reason: str | None

View File

@ -236,4 +236,5 @@ class Config(SimpleConfig):
return value
config = Config()

View File

@ -59,6 +59,67 @@ export const dashboardApi = {
if (params.offset) queryParams.append('offset', params.offset)
return apiAdminGet(`/api/dashboard/feedbacks?${queryParams.toString()}`)
},
// ========== 新增并行API接口 ==========
/**
* 获取用户活跃度统计
* @returns {Promise<Object>} - 用户活跃度统计信息
*/
getUserStats: () => {
return apiAdminGet('/api/dashboard/stats/users')
},
/**
* 获取工具调用统计
* @returns {Promise<Object>} - 工具调用统计信息
*/
getToolStats: () => {
return apiAdminGet('/api/dashboard/stats/tools')
},
/**
* 获取知识库统计
* @returns {Promise<Object>} - 知识库统计信息
*/
getKnowledgeStats: () => {
return apiAdminGet('/api/dashboard/stats/knowledge')
},
/**
* 获取AI智能体分析数据
* @returns {Promise<Object>} - AI智能体分析信息
*/
getAgentStats: () => {
return apiAdminGet('/api/dashboard/stats/agents')
},
/**
* 批量获取所有统计数据并行请求
* @returns {Promise<Object>} - 所有统计数据
*/
getAllStats: async () => {
try {
const [basicStats, userStats, toolStats, knowledgeStats, agentStats] = await Promise.all([
apiAdminGet('/api/dashboard/stats'),
apiAdminGet('/api/dashboard/stats/users'),
apiAdminGet('/api/dashboard/stats/tools'),
apiAdminGet('/api/dashboard/stats/knowledge'),
apiAdminGet('/api/dashboard/stats/agents')
])
return {
basic: basicStats,
users: userStats,
tools: toolStats,
knowledge: knowledgeStats,
agents: agentStats
}
} catch (error) {
console.error('批量获取统计数据失败:', error)
throw error
}
}
}

View File

@ -51,6 +51,46 @@
--color-error: #c73234;
--color-info: #0058d4;
/* Chart Colors - 图表颜色系统 */
--chart-primary: var(--main-500); /* 主色调 - 用于主要数据 */
--chart-primary-light: var(--main-400); /* 主色调浅色 - 用于渐变 */
--chart-primary-dark: var(--main-600); /* 主色调深色 - 用于强调 */
--chart-success: #52c41a; /* 成功色 - 绿色 */
--chart-success-light: #95de64; /* 成功色浅色 */
--chart-success-dark: #389e0d; /* 成功色深色 */
--chart-warning: #faad14; /* 警告色 - 橙色 */
--chart-warning-light: #ffd666; /* 警告色浅色 */
--chart-warning-dark: #d48806; /* 警告色深色 */
--chart-error: #f5222d; /* 错误色 - 红色 */
--chart-error-light: #ff7875; /* 错误色浅色 */
--chart-error-dark: #cf1322; /* 错误色深色 */
--chart-info: #1890ff; /* 信息色 - 蓝色 */
--chart-info-light: #69c0ff; /* 信息色浅色 */
--chart-info-dark: #096dd9; /* 信息色深色 */
--chart-secondary: #722ed1; /* 次要色 - 紫色 */
--chart-secondary-light: #b37feb; /* 次要色浅色 */
--chart-secondary-dark: #531dab; /* 次要色深色 */
--chart-accent: #13c2c2; /* 强调色 - 青色 */
--chart-accent-light: #5cdbd3; /* 强调色浅色 */
--chart-accent-dark: #08979c; /* 强调色深色 */
/* Chart Palette - 图表调色板 */
--chart-palette-1: var(--chart-primary);
--chart-palette-2: var(--chart-success);
--chart-palette-3: var(--chart-warning);
--chart-palette-4: var(--chart-error);
--chart-palette-5: var(--chart-info);
--chart-palette-6: var(--chart-secondary);
--chart-palette-7: var(--chart-accent);
--chart-palette-8: #fa541c; /* 橙红色 */
--chart-palette-9: #eb2f96; /* 粉红色 */
--chart-palette-10: #a0d911; /* 黄绿色 */
--bg-sider: var(--main-5);
--color-text: var(--c-black);

View File

@ -0,0 +1,464 @@
/* Dashboard 共享样式文件 */
/* Chart Color System - 图表颜色系统 */
:root {
/* ECharts 图表颜色配置 */
--echarts-primary-gradient: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'var(--chart-primary)' },
{ offset: 1, color: 'var(--chart-primary-light)' }
]
};
--echarts-success-gradient: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'var(--chart-success)' },
{ offset: 1, color: 'var(--chart-success-light)' }
]
};
--echarts-warning-gradient: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'var(--chart-warning)' },
{ offset: 1, color: 'var(--chart-warning-light)' }
]
};
--echarts-error-gradient: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'var(--chart-error)' },
{ offset: 1, color: 'var(--chart-error-light)' }
]
};
--echarts-info-gradient: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'var(--chart-info)' },
{ offset: 1, color: 'var(--chart-info-light)' }
]
};
--echarts-secondary-gradient: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'var(--chart-secondary)' },
{ offset: 1, color: 'var(--chart-secondary-light)' }
]
};
--echarts-accent-gradient: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'var(--chart-accent)' },
{ offset: 1, color: 'var(--chart-accent-light)' }
]
};
}
/* Chart Color Palette - 图表调色板 */
.chart-color-palette {
--palette-1: var(--chart-palette-1);
--palette-2: var(--chart-palette-2);
--palette-3: var(--chart-palette-3);
--palette-4: var(--chart-palette-4);
--palette-5: var(--chart-palette-5);
--palette-6: var(--chart-palette-6);
--palette-7: var(--chart-palette-7);
--palette-8: var(--chart-palette-8);
--palette-9: var(--chart-palette-9);
--palette-10: var(--chart-palette-10);
}
/* Chart Area Gradient - 图表区域渐变 */
.chart-area-gradient-primary {
background: linear-gradient(180deg,
rgba(57, 150, 174, 0.3) 0%,
rgba(57, 150, 174, 0.05) 100%
);
}
.chart-area-gradient-success {
background: linear-gradient(180deg,
rgba(82, 196, 26, 0.3) 0%,
rgba(82, 196, 26, 0.05) 100%
);
}
.chart-area-gradient-warning {
background: linear-gradient(180deg,
rgba(250, 173, 20, 0.3) 0%,
rgba(250, 173, 20, 0.05) 100%
);
}
.chart-area-gradient-error {
background: linear-gradient(180deg,
rgba(245, 34, 45, 0.3) 0%,
rgba(245, 34, 45, 0.05) 100%
);
}
/* Chart Shadow Effects - 图表阴影效果 */
.chart-shadow-primary {
box-shadow: 0 4px 12px rgba(57, 150, 174, 0.15);
}
.chart-shadow-success {
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.15);
}
.chart-shadow-warning {
box-shadow: 0 4px 12px rgba(250, 173, 20, 0.15);
}
.chart-shadow-error {
box-shadow: 0 4px 12px rgba(245, 34, 45, 0.15);
}
/* 共享的卡片样式 */
.dashboard-card {
background-color: var(--gray-0);
border: 1px solid var(--gray-200);
border-radius: 8px;
transition: all 0.2s ease;
height: 100%;
display: flex;
flex-direction: column;
&:hover {
border-color: var(--gray-300);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
:deep(.ant-card-head) {
border-bottom: 1px solid var(--gray-200);
min-height: 56px;
padding: 0 20px;
flex-shrink: 0;
background-color: var(--gray-0);
.ant-card-head-title {
font-size: 16px;
font-weight: 600;
color: var(--gray-1000);
}
}
:deep(.ant-card-body) {
padding: 20px;
flex: 1;
overflow: auto;
background-color: var(--gray-0);
}
}
/* 统计概览样式 */
.stats-overview {
margin-bottom: 16px;
:deep(.ant-statistic) {
.ant-statistic-title {
font-size: 13px;
font-weight: 500;
color: var(--gray-600);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.ant-statistic-content {
font-size: 24px;
font-weight: 700;
color: var(--gray-1000);
}
}
}
/* 图表容器样式 */
.chart-container {
h4, h5 {
margin-bottom: 16px;
font-weight: 600;
color: var(--gray-1000);
}
h4 {
font-size: 16px;
}
h5 {
font-size: 14px;
color: var(--gray-600);
}
.chart {
height: 250px;
width: 100%;
background-color: var(--gray-0);
border-radius: 8px;
padding: 10px;
border: 1px solid var(--gray-100);
}
.chart-medium {
height: 180px;
width: 100%;
background-color: var(--gray-0);
border-radius: 8px;
padding: 8px;
border: 1px solid var(--gray-100);
}
.chart-small {
height: 180px;
width: 100%;
}
}
/* Ant Design 组件样式覆盖 */
:deep(.ant-statistic-title) {
color: var(--gray-600) !important;
font-weight: 500 !important;
}
:deep(.ant-statistic-content) {
color: var(--gray-1000) !important;
}
:deep(.ant-divider) {
border-color: var(--gray-200) !important;
}
:deep(.ant-table) {
background-color: var(--gray-0);
border-radius: 8px;
overflow: hidden;
.ant-table-thead > tr > th {
background-color: var(--gray-25);
border-bottom: 1px solid var(--gray-200);
font-weight: 600;
padding: 12px 16px;
font-size: 12px;
color: var(--gray-700);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.ant-table-tbody > tr {
transition: background-color 0.2s ease;
> td {
border-bottom: 1px solid var(--gray-100);
color: var(--gray-1000);
padding: 12px 16px;
font-weight: 400;
}
&:hover > td {
background-color: var(--gray-25);
}
}
}
:deep(.ant-progress) {
.ant-progress-bg {
background-color: var(--main-500) !important;
border-radius: 4px;
}
.ant-progress-text {
color: var(--gray-1000) !important;
font-weight: 500;
font-size: 12px;
}
}
/* 紧凑型统计网格 */
.compact-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
.mini-stat-card {
background-color: var(--gray-0);
border: 1px solid var(--gray-200);
border-radius: 6px;
padding: 16px;
text-align: center;
transition: all 0.2s ease;
&:hover {
border-color: var(--gray-300);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.mini-stat-value {
font-size: 20px;
font-weight: 600;
color: var(--gray-1000);
line-height: 1.2;
margin-bottom: 4px;
}
.mini-stat-label {
font-size: 12px;
color: var(--gray-600);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
}
}
/* 紧凑型图表容器 */
.compact-chart-container {
background-color: var(--gray-0);
border: 1px solid var(--gray-200);
border-radius: 6px;
padding: 16px;
height: 240px;
transition: all 0.2s ease;
&:hover {
border-color: var(--gray-300);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.chart-title {
font-size: 14px;
font-weight: 600;
color: var(--gray-1000);
}
.chart-subtitle {
font-size: 11px;
color: var(--gray-600);
background-color: var(--gray-100);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
}
.compact-chart {
height: 180px;
width: 100%;
}
}
/* 排名显示样式 */
.rank-display {
display: flex;
align-items: center;
justify-content: center;
.rank-medal {
font-size: 20px;
}
.rank-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background-color: var(--gray-100);
border-radius: 50%;
font-size: 12px;
font-weight: 600;
color: var(--gray-600);
border: 1px solid var(--gray-200);
}
}
/* 指标值样式 */
.metric-value {
font-weight: 500;
color: var(--gray-1000);
font-size: 14px;
}
/* 排名样式 */
:deep(.ant-table-tbody > tr.top-rank.first) {
background-color: #fef3c7;
border-radius: 4px;
}
:deep(.ant-table-tbody > tr.top-rank.second) {
background-color: var(--gray-100);
border-radius: 4px;
}
:deep(.ant-table-tbody > tr.top-rank.third) {
background-color: #fed7aa;
border-radius: 4px;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.compact-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.compact-stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
.mini-stat-card {
padding: 8px;
.mini-stat-value {
font-size: 16px;
}
.mini-stat-label {
font-size: 10px;
}
}
}
.compact-chart-container {
height: 180px;
padding: 8px;
.compact-chart {
height: 130px;
}
.chart-header {
margin-bottom: 4px;
.chart-title {
font-size: 11px;
}
.chart-subtitle {
font-size: 9px;
}
}
}
}

View File

@ -0,0 +1,246 @@
<template>
<div class="status-bar">
<div class="status-bar-content">
<!-- 左侧系统信息 -->
<div class="status-left">
<div class="system-info">
<div class="system-details">
<div class="system-name">{{ branding.name }}</div>
<div class="system-subtitle">{{ branding.subtitle }}</div>
</div>
</div>
</div>
<!-- 右侧时间和用户信息 -->
<div class="status-right">
<div class="time-info">
<Clock class="icon" />
<span class="current-time">{{ currentTime }}</span>
</div>
<div class="user-info">
<User class="icon" />
<span class="user-name">{{ currentUser }}</span>
</div>
<div class="status-indicator" :class="systemStatus">
<div class="status-dot"></div>
<span class="status-text">{{ statusText }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useInfoStore } from '@/stores/info'
import { Clock, User } from 'lucide-vue-next'
// 使 info store
const infoStore = useInfoStore()
//
const currentTime = ref('')
const currentUser = ref('管理员')
const systemStatus = ref('online')
//
const organization = computed(() => infoStore.organization)
const branding = computed(() => infoStore.branding)
const statusText = computed(() => {
switch (systemStatus.value) {
case 'online':
return '在线'
case 'offline':
return '离线'
case 'maintenance':
return '维护中'
default:
return '未知'
}
})
//
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
//
let timeInterval = null
onMounted(() => {
updateTime()
timeInterval = setInterval(updateTime, 1000)
})
onUnmounted(() => {
if (timeInterval) {
clearInterval(timeInterval)
}
})
</script>
<style scoped lang="less">
.status-bar {
// background: white;
// backdrop-filter: blur(10px);
height: 60px;
display: flex;
align-items: center;
// position: sticky;
top: 0;
z-index: 100;
}
.status-bar-content {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px;
}
.status-left {
display: flex;
align-items: center;
}
.system-info {
display: flex;
align-items: center;
gap: 12px;
}
.system-details {
.system-name {
font-size: 16px;
font-weight: 600;
color: #111827;
line-height: 1.4;
}
.system-subtitle {
font-size: 12px;
color: #6b7280;
line-height: 1.2;
}
}
.status-right {
display: flex;
align-items: center;
gap: 20px;
}
.time-info,
.user-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6b7280;
.icon {
width: 14px;
height: 14px;
}
}
.current-time,
.user-name {
font-weight: 500;
}
.status-indicator {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
&.online {
background-color: #f0fdf4;
color: #16a34a;
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #16a34a;
}
}
&.offline {
background-color: #fef2f2;
color: #dc2626;
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #dc2626;
}
}
&.maintenance {
background-color: #fffbeb;
color: #d97706;
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #d97706;
}
}
}
//
@media (max-width: 768px) {
.status-bar {
height: 44px;
}
.status-bar-content {
padding: 0 16px;
}
.system-details {
.system-name {
font-size: 13px;
}
.system-subtitle {
font-size: 10px;
}
}
.status-right {
gap: 12px;
}
.time-info,
.user-info {
font-size: 11px;
.icon {
width: 12px;
height: 12px;
}
}
.current-time {
display: none; //
}
}
</style>

View File

@ -0,0 +1,415 @@
<template>
<a-card title="AI智能体分析" :loading="loading" class="dashboard-card">
<!-- 智能体概览 -->
<div class="stats-overview">
<a-row :gutter="16">
<a-col :span="6">
<a-statistic
title="智能体总数"
:value="agentStats?.total_agents || 0"
:value-style="{ color: 'var(--chart-info)' }"
suffix="个"
/>
</a-col>
<a-col :span="6">
<a-statistic
title="平均满意度"
:value="averageSatisfaction"
suffix="%"
:value-style="{
color: averageSatisfaction >= 80 ? 'var(--chart-success)' :
averageSatisfaction >= 60 ? 'var(--chart-warning)' : 'var(--chart-error)'
}"
/>
</a-col>
<a-col :span="6">
<a-statistic
title="总对话数"
:value="totalConversations"
:value-style="{ color: 'var(--chart-secondary)' }"
suffix="次"
/>
</a-col>
<a-col :span="6">
<a-statistic
title="工具调用总数"
:value="totalToolUsage"
:value-style="{ color: 'var(--chart-warning)' }"
suffix="次"
/>
</a-col>
</a-row>
</div>
<a-divider />
<!-- 图表区域 -->
<a-row :gutter="24">
<!-- 对话数和工具调用数分布 -->
<a-col :span="24">
<div class="chart-container">
<h4>智能体对话数与工具调用数分布</h4>
<div ref="conversationToolChartRef" class="chart"></div>
</div>
</a-col>
</a-row>
<!-- 表现排行榜 -->
<a-divider />
<div class="top-performers">
<h4>表现最佳智能体 TOP 5</h4>
<a-table
:columns="performerColumns"
:data-source="topPerformers"
size="small"
:pagination="false"
:row-class-name="getRowClassName"
:scroll="{ y: 240 }"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'rank'">
<div class="rank-display">
<span v-if="index < 3" class="rank-medal">
{{ index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉' }}
</span>
<span v-else class="rank-number">{{ index + 1 }}</span>
</div>
</template>
<template v-if="column.key === 'agent_id'">
<a-tag color="blue">{{ record.agent_id }}</a-tag>
</template>
<template v-if="column.key === 'score'">
<a-progress
:percent="record.score"
size="small"
:stroke-color="getScoreColor(record.score)"
:show-info="true"
:format="(percent) => `${percent}分`"
/>
</template>
<template v-if="column.key === 'satisfaction_rate'">
<a-statistic
:value="record.satisfaction_rate"
suffix="%"
:value-style="{
color: record.satisfaction_rate >= 80 ? 'var(--chart-success)' :
record.satisfaction_rate >= 60 ? 'var(--chart-warning)' : 'var(--chart-error)',
fontSize: '14px'
}"
/>
</template>
<template v-if="column.key === 'conversation_count'">
<span class="metric-value">{{ record.conversation_count }}</span>
</template>
</template>
</a-table>
</div>
</a-card>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, computed } from 'vue'
import * as echarts from 'echarts'
// Props
const props = defineProps({
agentStats: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
}
})
// Chart refs
const conversationToolChartRef = ref(null)
let conversationToolChart = null
//
const performerColumns = [
{
title: '排名',
key: 'rank',
width: '80px',
align: 'center'
},
{
title: '智能体ID',
key: 'agent_id',
width: '25%'
},
{
title: '综合评分',
key: 'score',
width: '20%'
},
{
title: '满意度',
key: 'satisfaction_rate',
width: '20%',
align: 'center'
},
{
title: '对话数',
key: 'conversation_count',
width: '15%',
align: 'center'
}
]
//
const averageSatisfaction = computed(() => {
const satisfactionRates = props.agentStats?.agent_satisfaction_rates || []
if (satisfactionRates.length === 0) return 0
const total = satisfactionRates.reduce((sum, item) => sum + item.satisfaction_rate, 0)
return Math.round(total / satisfactionRates.length)
})
const totalConversations = computed(() => {
const conversationCounts = props.agentStats?.agent_conversation_counts || []
return conversationCounts.reduce((sum, item) => sum + item.conversation_count, 0)
})
const totalToolUsage = computed(() => {
const toolUsage = props.agentStats?.agent_tool_usage || []
return toolUsage.reduce((sum, item) => sum + item.tool_usage_count, 0)
})
const topPerformers = computed(() => {
return props.agentStats?.top_performing_agents || []
})
//
const getScoreColor = (score) => {
if (score >= 80) return '#3996ae'
if (score >= 60) return '#82c3d6'
return '#a3d8e8'
}
const getRowClassName = (record, index) => {
if (index === 0) return 'top-rank first'
if (index === 1) return 'top-rank second'
if (index === 2) return 'top-rank third'
return ''
}
//
const initConversationToolChart = () => {
if (!conversationToolChartRef.value ||
(!props.agentStats?.agent_conversation_counts?.length &&
!props.agentStats?.agent_tool_usage?.length)) return
conversationToolChart = echarts.init(conversationToolChartRef.value)
const conversationData = props.agentStats.agent_conversation_counts || []
const toolData = props.agentStats.agent_tool_usage || []
// ID
const allAgentIds = [...new Set([
...conversationData.map(item => item.agent_id),
...toolData.map(item => item.agent_id)
])]
const option = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#666'
}
},
legend: {
data: ['对话数', '工具调用数'],
top: '5%',
textStyle: {
color: '#666'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: allAgentIds,
axisLine: {
lineStyle: {
color: '#e8e8e8'
}
},
axisLabel: {
color: '#666',
interval: 0,
rotate: 45
}
},
yAxis: {
type: 'value',
axisLine: {
lineStyle: {
color: '#e8e8e8'
}
},
axisLabel: {
color: '#666'
},
splitLine: {
lineStyle: {
color: '#f0f0f0'
}
}
},
series: [
{
name: '对话数',
type: 'bar',
data: allAgentIds.map(agentId => {
const item = conversationData.find(d => d.agent_id === agentId)
return item ? item.conversation_count : 0
}),
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: '#3996ae'
}, {
offset: 1, color: '#5faec2'
}]
},
borderRadius: [4, 4, 0, 0]
},
emphasis: {
itemStyle: {
color: '#24839a',
shadowBlur: 10,
shadowColor: 'rgba(57, 150, 174, 0.3)'
}
}
},
{
name: '工具调用数',
type: 'bar',
data: allAgentIds.map(agentId => {
const item = toolData.find(d => d.agent_id === agentId)
return item ? item.tool_usage_count : 0
}),
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: '#82c3d6'
}, {
offset: 1, color: '#a3d8e8'
}]
},
borderRadius: [4, 4, 0, 0]
},
emphasis: {
itemStyle: {
color: '#5faec2',
shadowBlur: 10,
shadowColor: 'rgba(130, 195, 214, 0.3)'
}
}
}
]
}
conversationToolChart.setOption(option)
}
//
const updateCharts = () => {
nextTick(() => {
initConversationToolChart()
})
}
//
watch(() => props.agentStats, () => {
updateCharts()
}, { deep: true })
//
const handleResize = () => {
if (conversationToolChart) conversationToolChart.resize()
}
onMounted(() => {
updateCharts()
window.addEventListener('resize', handleResize)
})
//
const cleanup = () => {
window.removeEventListener('resize', handleResize)
if (conversationToolChart) {
conversationToolChart.dispose()
conversationToolChart = null
}
}
//
defineExpose({
cleanup
})
</script>
<style scoped lang="less">
@import '@/assets/css/dashboard.css';
// AgentStatsComponent
.top-performers, .metrics-comparison {
h4 {
margin-bottom: 16px;
font-weight: 600;
color: var(--gray-1000);
font-size: 16px;
}
h5 {
margin-bottom: 12px;
color: var(--gray-600);
font-weight: 500;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
//
:deep(.ant-table-tbody > tr.top-rank.first) {
background: linear-gradient(90deg, rgba(255, 215, 0, 0.1) 0%, transparent 100%);
}
:deep(.ant-table-tbody > tr.top-rank.second) {
background: linear-gradient(90deg, rgba(192, 192, 192, 0.1) 0%, transparent 100%);
}
:deep(.ant-table-tbody > tr.top-rank.third) {
background: linear-gradient(90deg, rgba(205, 127, 50, 0.1) 0%, transparent 100%);
}
:deep(.ant-progress-bg) {
transition: all 0.3s ease;
}
</style>

View File

@ -0,0 +1,390 @@
<template>
<a-card title="知识库使用情况" :loading="loading" class="dashboard-card">
<!-- 知识库概览 -->
<div class="stats-overview">
<a-row :gutter="16">
<a-col :span="6">
<a-statistic
title="知识库总数"
:value="knowledgeStats?.total_databases || 0"
:value-style="{ color: 'var(--chart-info)' }"
suffix="个"
/>
</a-col>
<a-col :span="6">
<a-statistic
title="文件总数"
:value="knowledgeStats?.total_files || 0"
:value-style="{ color: 'var(--chart-success)' }"
suffix="个"
/>
</a-col>
<a-col :span="6">
<a-statistic
title="知识节点总数"
:value="knowledgeStats?.total_nodes || 0"
:value-style="{ color: 'var(--chart-secondary)' }"
suffix="个"
/>
</a-col>
<a-col :span="6">
<a-statistic
title="存储容量"
:value="formattedStorageSize"
:value-style="{ color: 'var(--chart-warning)' }"
/>
</a-col>
</a-row>
</div>
<a-divider />
<!-- 图表区域 -->
<a-row :gutter="24">
<!-- 数据库类型分布 -->
<a-col :span="12">
<div class="chart-container">
<h4>数据库类型分布</h4>
<div ref="dbTypeChartRef" class="chart"></div>
</div>
</a-col>
<!-- 文件类型分布 -->
<a-col :span="12">
<div class="chart-container">
<h4>文件类型分布</h4>
<div ref="fileTypeChartRef" class="chart"></div>
</div>
</a-col>
</a-row>
<!-- 详细统计信息 -->
<a-divider />
<a-row :gutter="16">
<a-col :span="8">
<a-statistic
title="平均每库文件数"
:value="averageFilesPerDatabase"
suffix="个"
:precision="1"
/>
</a-col>
<a-col :span="8">
<a-statistic
title="平均每文件节点数"
:value="averageNodesPerFile"
suffix="个"
:precision="1"
/>
</a-col>
<a-col :span="8">
<a-statistic
title="平均节点大小"
:value="averageNodeSize"
suffix="KB"
:precision="2"
/>
</a-col>
</a-row>
</a-card>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, computed } from 'vue'
import * as echarts from 'echarts'
// Props
const props = defineProps({
knowledgeStats: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
}
})
// Chart refs
const dbTypeChartRef = ref(null)
const fileTypeChartRef = ref(null)
let dbTypeChart = null
let fileTypeChart = null
//
const formattedStorageSize = computed(() => {
const size = props.knowledgeStats?.total_storage_size || 0
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(2)} MB`
return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`
})
const averageFilesPerDatabase = computed(() => {
const databases = props.knowledgeStats?.total_databases || 0
const files = props.knowledgeStats?.total_files || 0
return databases > 0 ? files / databases : 0
})
const averageNodesPerFile = computed(() => {
const files = props.knowledgeStats?.total_files || 0
const nodes = props.knowledgeStats?.total_nodes || 0
return files > 0 ? nodes / files : 0
})
const averageNodeSize = computed(() => {
const nodes = props.knowledgeStats?.total_nodes || 0
const size = props.knowledgeStats?.total_storage_size || 0
return nodes > 0 ? size / (nodes * 1024) : 0 // KB
})
// -
const colorPalette = [
'#3996ae', //
'#5faec2', //
'#82c3d6', //
'#a3d8e8', //
'#24839a', //
'#046a82', //
'#035065', //
'#c4eaf5', //
'#e1f6fb', //
'#f2fbfd' //
]
const getColorByIndex = (index) => {
return colorPalette[index % colorPalette.length]
}
// -
const initDbTypeChart = () => {
if (!dbTypeChartRef.value || !props.knowledgeStats?.databases_by_type) return
dbTypeChart = echarts.init(dbTypeChartRef.value)
const data = Object.entries(props.knowledgeStats.databases_by_type).map(([type, count]) => ({
name: type || '未知',
value: count
}))
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#666'
},
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [{
name: '数据库类型',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '60%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '16',
fontWeight: 'bold',
color: '#333'
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
labelLine: {
show: false
},
data: data,
color: ['#3996ae', '#5faec2', '#82c3d6', '#a3d8e8', '#24839a']
}]
}
dbTypeChart.setOption(option)
}
//
const initFileTypeChart = () => {
if (!fileTypeChartRef.value) return
fileTypeChart = echarts.init(fileTypeChartRef.value)
// -
const fileTypesData = props.knowledgeStats?.files_by_type || props.knowledgeStats?.file_type_distribution || {}
if (Object.keys(fileTypesData).length > 0) {
const data = Object.entries(fileTypesData).map(([type, count]) => ({
name: type || '未知',
value: count
}))
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#666'
},
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [{
name: '文件类型',
type: 'pie',
radius: ['30%', '70%'],
center: ['50%', '60%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}: {c}'
},
emphasis: {
label: {
show: true,
fontSize: '14',
fontWeight: 'bold',
color: '#333'
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
labelLine: {
show: true
},
data: data,
color: ['#3996ae', '#5faec2', '#82c3d6', '#a3d8e8', '#24839a', '#046a82', '#035065', '#c4eaf5']
}]
}
fileTypeChart.setOption(option)
} else {
//
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#666'
},
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [{
name: '文件类型',
type: 'pie',
radius: ['30%', '70%'],
center: ['50%', '60%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}: {c}'
},
emphasis: {
label: {
show: true,
fontSize: '14',
fontWeight: 'bold',
color: '#333'
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
labelLine: {
show: true
},
data: [
{ name: '暂无数据', value: 1 }
],
color: ['#e1f6fb']
}]
}
fileTypeChart.setOption(option)
}
}
//
const updateCharts = () => {
nextTick(() => {
initDbTypeChart()
initFileTypeChart()
})
}
//
watch(() => props.knowledgeStats, () => {
updateCharts()
}, { deep: true })
//
const handleResize = () => {
if (dbTypeChart) dbTypeChart.resize()
if (fileTypeChart) fileTypeChart.resize()
}
onMounted(() => {
updateCharts()
window.addEventListener('resize', handleResize)
})
//
const cleanup = () => {
window.removeEventListener('resize', handleResize)
if (dbTypeChart) {
dbTypeChart.dispose()
dbTypeChart = null
}
if (fileTypeChart) {
fileTypeChart.dispose()
fileTypeChart = null
}
}
//
defineExpose({
cleanup
})
</script>
<style scoped lang="less">
@import '@/assets/css/dashboard.css';
// KnowledgeStatsComponent
.chart-container {
.chart {
height: 300px;
width: 100%;
}
}
</style>

View File

@ -0,0 +1,334 @@
<template>
<a-card title="工具调用监控" :loading="loading" class="dashboard-card">
<!-- 工具调用概览 -->
<div class="stats-overview">
<a-row :gutter="16">
<a-col :span="6">
<a-statistic
title="总调用次数"
:value="toolStats?.total_calls || 0"
:value-style="{ color: 'var(--chart-info)' }"
/>
</a-col>
<a-col :span="6">
<a-statistic
title="成功调用"
:value="toolStats?.successful_calls || 0"
:value-style="{ color: 'var(--chart-success)' }"
suffix="次"
/>
</a-col>
<a-col :span="6">
<a-statistic
title="失败调用"
:value="toolStats?.failed_calls || 0"
:value-style="{ color: 'var(--chart-error)' }"
suffix="次"
/>
</a-col>
<a-col :span="6">
<a-statistic
title="成功率"
:value="toolStats?.success_rate || 0"
suffix="%"
:value-style="{
color: (toolStats?.success_rate || 0) >= 90 ? 'var(--chart-success)' :
(toolStats?.success_rate || 0) >= 70 ? 'var(--chart-warning)' : 'var(--chart-error)'
}"
/>
</a-col>
</a-row>
</div>
<!-- 最常用工具 -->
<a-divider />
<div class="chart-container">
<h4>最常用工具 TOP 10</h4>
<div ref="toolsChartRef" class="chart"></div>
</div>
<!-- 错误分析 -->
<a-divider />
<div class="error-analysis" v-if="hasErrorData">
<h4>工具错误分析</h4>
<a-row :gutter="16">
<a-col :span="12">
<a-table
:columns="errorColumns"
:data-source="errorData"
size="small"
:pagination="false"
:scroll="{ y: 200 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'tool_name'">
<a-tag color="blue">{{ record.tool_name }}</a-tag>
</template>
<template v-if="column.key === 'error_count'">
<a-tag :color="record.error_count > 5 ? 'red' : 'orange'">
{{ record.error_count }}
</a-tag>
</template>
</template>
</a-table>
</a-col>
<a-col :span="12">
<div class="chart-container">
<h4>错误分布图</h4>
<div ref="errorChartRef" class="chart-small"></div>
</div>
</a-col>
</a-row>
</div>
</a-card>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, computed } from 'vue'
import * as echarts from 'echarts'
// Props
const props = defineProps({
toolStats: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
}
})
// Chart refs
const toolsChartRef = ref(null)
const errorChartRef = ref(null)
let toolsChart = null
let errorChart = null
//
const errorColumns = [
{
title: '工具名称',
dataIndex: 'tool_name',
key: 'tool_name',
width: '50%'
},
{
title: '错误次数',
dataIndex: 'error_count',
key: 'error_count',
width: '50%',
sorter: (a, b) => a.error_count - b.error_count
}
]
const hasErrorData = computed(() => {
return props.toolStats?.tool_error_distribution &&
Object.keys(props.toolStats.tool_error_distribution).length > 0
})
const errorData = computed(() => {
if (!hasErrorData.value) return []
return Object.entries(props.toolStats.tool_error_distribution)
.map(([tool_name, error_count]) => ({ tool_name, error_count }))
.sort((a, b) => b.error_count - a.error_count)
})
//
const initToolsChart = () => {
if (!toolsChartRef.value || !props.toolStats?.most_used_tools?.length) return
toolsChart = echarts.init(toolsChartRef.value)
const data = props.toolStats.most_used_tools.slice(0, 10) // 10
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#666'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '5%',
containLabel: true
},
xAxis: {
type: 'value',
axisLine: {
lineStyle: {
color: '#e8e8e8'
}
},
axisLabel: {
color: '#666'
},
splitLine: {
lineStyle: {
color: '#f0f0f0'
}
}
},
yAxis: {
type: 'category',
data: data.map(item => item.tool_name),
axisLine: {
lineStyle: {
color: '#e8e8e8'
}
},
axisLabel: {
color: '#666',
interval: 0
}
},
series: [{
name: '调用次数',
type: 'bar',
data: data.map(item => item.count),
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [{
offset: 0, color: '#3996ae'
}, {
offset: 1, color: '#5faec2'
}]
},
borderRadius: [0, 4, 4, 0]
},
emphasis: {
itemStyle: {
color: '#24839a',
shadowBlur: 10,
shadowColor: 'rgba(57, 150, 174, 0.3)'
}
}
}]
}
toolsChart.setOption(option)
}
//
const initErrorChart = () => {
if (!errorChartRef.value || !hasErrorData.value) return
errorChart = echarts.init(errorChartRef.value)
const data = errorData.value.slice(0, 5) // 5
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#666'
},
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [{
name: '错误分布',
type: 'pie',
radius: ['30%', '70%'],
center: ['50%', '60%'],
data: data.map(item => ({
name: item.tool_name,
value: item.error_count
})),
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}: {c}'
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
color: ['#3996ae', '#5faec2', '#82c3d6', '#a3d8e8', '#24839a']
}]
}
errorChart.setOption(option)
}
//
const updateCharts = () => {
nextTick(() => {
initToolsChart()
if (hasErrorData.value) {
initErrorChart()
}
})
}
//
watch(() => props.toolStats, () => {
updateCharts()
}, { deep: true })
//
const handleResize = () => {
if (toolsChart) toolsChart.resize()
if (errorChart) errorChart.resize()
}
onMounted(() => {
updateCharts()
window.addEventListener('resize', handleResize)
})
//
const cleanup = () => {
window.removeEventListener('resize', handleResize)
if (toolsChart) {
toolsChart.dispose()
toolsChart = null
}
if (errorChart) {
errorChart.dispose()
errorChart = null
}
}
//
defineExpose({
cleanup
})
</script>
<style scoped lang="less">
@import '@/assets/css/dashboard.css';
// ToolStatsComponent
.error-analysis {
h4 {
margin-bottom: 16px;
color: var(--gray-1000);
font-weight: 500;
}
}
</style>

View File

@ -0,0 +1,194 @@
<template>
<a-card title="用户活跃度分析" :loading="loading" class="dashboard-card">
<!-- 紧凑型用户统计概览 -->
<div class="compact-stats-grid">
<div class="mini-stat-card">
<div class="mini-stat-value">{{ userStats?.total_users || 0 }}</div>
<div class="mini-stat-label">总用户</div>
</div>
<div class="mini-stat-card">
<div class="mini-stat-value">{{ userStats?.active_users_24h || 0 }}</div>
<div class="mini-stat-label">24h活跃</div>
</div>
<div class="mini-stat-card">
<div class="mini-stat-value">{{ userStats?.active_users_30d || 0 }}</div>
<div class="mini-stat-label">30天活跃</div>
</div>
</div>
<!-- 图表区域 - 更紧凑 -->
<div class="compact-chart-container">
<div class="chart-header">
<span class="chart-title">活跃度趋势</span>
<span class="chart-subtitle">最近7天</span>
</div>
<div ref="activityChartRef" class="compact-chart"></div>
</div>
</a-card>
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import { dashboardApi } from '@/apis/dashboard_api'
// Props
const props = defineProps({
userStats: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
}
})
// Chart refs
const activityChartRef = ref(null)
let activityChart = null
//
const initActivityChart = () => {
if (!activityChartRef.value || !props.userStats?.daily_active_users) return
activityChart = echarts.init(activityChartRef.value)
const option = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#666'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: props.userStats.daily_active_users.map(item => item.date),
axisLine: {
lineStyle: {
color: '#e8e8e8'
}
},
axisLabel: {
color: '#666'
}
},
yAxis: {
type: 'value',
axisLine: {
lineStyle: {
color: '#e8e8e8'
}
},
axisLabel: {
color: '#666'
},
splitLine: {
lineStyle: {
color: '#f0f0f0'
}
}
},
series: [{
name: '活跃用户数',
type: 'line',
data: props.userStats.daily_active_users.map(item => item.active_users),
smooth: true,
lineStyle: {
color: '#3996ae',
width: 3
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: 'rgba(57, 150, 174, 0.3)'
}, {
offset: 1, color: 'rgba(57, 150, 174, 0.05)'
}]
}
},
itemStyle: {
color: '#3996ae',
borderWidth: 2,
borderColor: '#fff'
},
emphasis: {
itemStyle: {
color: '#24839a',
borderWidth: 3,
borderColor: '#fff',
shadowBlur: 10,
shadowColor: 'rgba(57, 150, 174, 0.5)'
}
}
}]
}
activityChart.setOption(option)
}
//
const updateCharts = () => {
nextTick(() => {
initActivityChart()
})
}
//
watch(() => props.userStats, () => {
updateCharts()
}, { deep: true })
//
const handleResize = () => {
if (activityChart) activityChart.resize()
}
onMounted(() => {
updateCharts()
window.addEventListener('resize', handleResize)
})
//
const cleanup = () => {
window.removeEventListener('resize', handleResize)
if (activityChart) {
activityChart.dispose()
activityChart = null
}
}
//
defineExpose({
cleanup
})
</script>
<style scoped lang="less">
@import '@/assets/css/dashboard.css';
// UserStatsComponent
.compact-chart-container {
.chart-header {
.chart-title {
color: var(--chart-primary);
}
}
}
</style>

File diff suppressed because it is too large Load Diff