feat(dashboard): 新增用户活跃度、工具调用、知识库和智能体分析统计功能
- 在后端新增用户活跃度、工具调用、知识库和智能体分析的统计接口 - 前端实现相应的统计组件,展示各类统计信息 - 更新样式以提升用户体验,增加图表和数据展示 - 优化仪表板布局,整合各类统计信息,提升可视化效果
This commit is contained in:
parent
df5d27beec
commit
52ce5f51bd
@ -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 中查看信息
|
||||
@ -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
|
||||
|
||||
@ -236,4 +236,5 @@ class Config(SimpleConfig):
|
||||
|
||||
return value
|
||||
|
||||
|
||||
config = Config()
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
464
web/src/assets/css/dashboard.css
Normal file
464
web/src/assets/css/dashboard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
246
web/src/components/StatusBar.vue
Normal file
246
web/src/components/StatusBar.vue
Normal 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>
|
||||
415
web/src/components/dashboard/AgentStatsComponent.vue
Normal file
415
web/src/components/dashboard/AgentStatsComponent.vue
Normal 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>
|
||||
390
web/src/components/dashboard/KnowledgeStatsComponent.vue
Normal file
390
web/src/components/dashboard/KnowledgeStatsComponent.vue
Normal 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>
|
||||
334
web/src/components/dashboard/ToolStatsComponent.vue
Normal file
334
web/src/components/dashboard/ToolStatsComponent.vue
Normal 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>
|
||||
194
web/src/components/dashboard/UserStatsComponent.vue
Normal file
194
web/src/components/dashboard/UserStatsComponent.vue
Normal 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
Loading…
Reference in New Issue
Block a user