mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
feat: add brand-new statistics dashboarde overview with model usage and platform usage (#7152)
* feat: add new StatsPage for enhanced statistics overview - Introduced StatsPage.vue to provide a comprehensive overview of statistics with various metrics and charts. - Implemented fetching and displaying of base and model provider statistics. - Added unit tests for provider statistics persistence in the database. * style: refine card styles and remove unnecessary shadow for improved aesthetics
This commit is contained in:
@@ -21,6 +21,7 @@ from astrbot.core.db.po import (
|
||||
PlatformSession,
|
||||
PlatformStat,
|
||||
Preference,
|
||||
ProviderStat,
|
||||
SessionProjectRelation,
|
||||
Stats,
|
||||
)
|
||||
@@ -105,6 +106,21 @@ class BaseDatabase(abc.ABC):
|
||||
"""Get platform statistics within the specified offset in seconds and group by platform_id."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def insert_provider_stat(
|
||||
self,
|
||||
*,
|
||||
umo: str,
|
||||
provider_id: str,
|
||||
provider_model: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
status: str = "completed",
|
||||
stats: dict | None = None,
|
||||
agent_type: str = "internal",
|
||||
) -> ProviderStat:
|
||||
"""Insert a per-response provider stat record."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_conversations(
|
||||
self,
|
||||
|
||||
@@ -38,6 +38,30 @@ class PlatformStat(SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class ProviderStat(TimestampMixin, SQLModel, table=True):
|
||||
"""Per-response provider stats for internal agent runs."""
|
||||
|
||||
__tablename__: str = "provider_stats"
|
||||
|
||||
id: int | None = Field(
|
||||
default=None,
|
||||
primary_key=True,
|
||||
sa_column_kwargs={"autoincrement": True},
|
||||
)
|
||||
agent_type: str = Field(default="internal", nullable=False, index=True)
|
||||
status: str = Field(default="completed", nullable=False, index=True)
|
||||
umo: str = Field(nullable=False, index=True)
|
||||
conversation_id: str | None = Field(default=None, index=True)
|
||||
provider_id: str = Field(nullable=False, index=True)
|
||||
provider_model: str | None = Field(default=None, index=True)
|
||||
token_input_other: int = Field(default=0, nullable=False)
|
||||
token_input_cached: int = Field(default=0, nullable=False)
|
||||
token_output: int = Field(default=0, nullable=False)
|
||||
start_time: float = Field(default=0.0, nullable=False)
|
||||
end_time: float = Field(default=0.0, nullable=False)
|
||||
time_to_first_token: float = Field(default=0.0, nullable=False)
|
||||
|
||||
|
||||
class ConversationV2(TimestampMixin, SQLModel, table=True):
|
||||
__tablename__: str = "conversations"
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from astrbot.core.db.po import (
|
||||
PlatformSession,
|
||||
PlatformStat,
|
||||
Preference,
|
||||
ProviderStat,
|
||||
SessionProjectRelation,
|
||||
SQLModel,
|
||||
)
|
||||
@@ -169,6 +170,51 @@ class SQLiteDatabase(BaseDatabase):
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def insert_provider_stat(
|
||||
self,
|
||||
*,
|
||||
umo: str,
|
||||
provider_id: str,
|
||||
provider_model: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
status: str = "completed",
|
||||
stats: dict | None = None,
|
||||
agent_type: str = "internal",
|
||||
) -> ProviderStat:
|
||||
"""Insert a provider stat record for a single agent response."""
|
||||
stats = stats or {}
|
||||
token_usage = stats.get("token_usage", {})
|
||||
|
||||
token_input_other = int(token_usage.get("input_other", 0) or 0)
|
||||
token_input_cached = int(token_usage.get("input_cached", 0) or 0)
|
||||
token_output = int(token_usage.get("output", 0) or 0)
|
||||
|
||||
start_time = float(stats.get("start_time", 0.0) or 0.0)
|
||||
end_time = float(stats.get("end_time", 0.0) or 0.0)
|
||||
time_to_first_token = float(stats.get("time_to_first_token", 0.0) or 0.0)
|
||||
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
record = ProviderStat(
|
||||
agent_type=agent_type,
|
||||
status=status,
|
||||
umo=umo,
|
||||
conversation_id=conversation_id,
|
||||
provider_id=provider_id,
|
||||
provider_model=provider_model,
|
||||
token_input_other=token_input_other,
|
||||
token_input_cached=token_input_cached,
|
||||
token_output=token_output,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
time_to_first_token=time_to_first_token,
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
# ====
|
||||
# Conversation Management
|
||||
# ====
|
||||
|
||||
@@ -5,7 +5,7 @@ import base64
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import replace
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core import db_helper, logger
|
||||
from astrbot.core.agent.message import Message
|
||||
from astrbot.core.agent.response import AgentStats
|
||||
from astrbot.core.astr_main_agent import (
|
||||
@@ -350,6 +350,15 @@ class InternalAgentSubStage(Stage):
|
||||
resp=final_resp.completion_text if final_resp else None,
|
||||
)
|
||||
|
||||
asyncio.create_task(
|
||||
_record_internal_agent_stats(
|
||||
event,
|
||||
req,
|
||||
agent_runner,
|
||||
final_resp,
|
||||
)
|
||||
)
|
||||
|
||||
# 检查事件是否被停止,如果被停止则不保存历史记录
|
||||
if not event.is_stopped() or agent_runner.was_aborted():
|
||||
await self._save_to_history(
|
||||
@@ -462,3 +471,46 @@ class InternalAgentSubStage(Stage):
|
||||
# these hosts are base64 encoded
|
||||
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
|
||||
decoded_blocked = [base64.b64decode(b).decode("utf-8") for b in BLOCKED]
|
||||
|
||||
|
||||
async def _record_internal_agent_stats(
|
||||
event: AstrMessageEvent,
|
||||
req: ProviderRequest | None,
|
||||
agent_runner: AgentRunner | None,
|
||||
final_resp: LLMResponse | None,
|
||||
) -> None:
|
||||
"""Persist internal agent stats without affecting the user response flow."""
|
||||
if agent_runner is None:
|
||||
return
|
||||
|
||||
provider = agent_runner.provider
|
||||
stats = agent_runner.stats
|
||||
if provider is None or stats is None:
|
||||
return
|
||||
|
||||
try:
|
||||
provider_config = getattr(provider, "provider_config", {}) or {}
|
||||
conversation_id = (
|
||||
req.conversation.cid
|
||||
if req is not None and req.conversation is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if agent_runner.was_aborted():
|
||||
status = "aborted"
|
||||
elif final_resp is not None and final_resp.role == "err":
|
||||
status = "error"
|
||||
else:
|
||||
status = "completed"
|
||||
|
||||
await db_helper.insert_provider_stat(
|
||||
umo=event.unified_msg_origin,
|
||||
conversation_id=conversation_id,
|
||||
provider_id=provider_config.get("id", "") or provider.meta().id,
|
||||
provider_model=provider.get_model(),
|
||||
status=status,
|
||||
stats=stats.to_dict(),
|
||||
agent_type="internal",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Persist provider stats failed: %s", e, exc_info=True)
|
||||
|
||||
@@ -4,18 +4,22 @@ import re
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import cmp_to_key
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import psutil
|
||||
from quart import request
|
||||
from sqlmodel import select
|
||||
|
||||
from astrbot.core import DEMO_MODE, logger
|
||||
from astrbot.core.config import VERSION
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.migration.helper import check_migration_needed_v4
|
||||
from astrbot.core.db.po import ProviderStat
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_path
|
||||
from astrbot.core.utils.io import get_dashboard_version
|
||||
from astrbot.core.utils.storage_cleaner import StorageCleaner
|
||||
@@ -34,6 +38,7 @@ class StatRoute(Route):
|
||||
super().__init__(context)
|
||||
self.routes = {
|
||||
"/stat/get": ("GET", self.get_stat),
|
||||
"/stat/provider-tokens": ("GET", self.get_provider_token_stats),
|
||||
"/stat/version": ("GET", self.get_version),
|
||||
"/stat/start-time": ("GET", self.get_start_time),
|
||||
"/stat/restart-core": ("POST", self.restart_core),
|
||||
@@ -188,6 +193,207 @@ class StatRoute(Route):
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(e.__str__()).__dict__
|
||||
|
||||
@staticmethod
|
||||
def _ensure_aware_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
async def get_provider_token_stats(self):
|
||||
try:
|
||||
try:
|
||||
days = int(request.args.get("days", 1))
|
||||
except (TypeError, ValueError):
|
||||
days = 1
|
||||
if days not in (1, 3, 7):
|
||||
days = 1
|
||||
|
||||
local_tz = datetime.now().astimezone().tzinfo or timezone.utc
|
||||
now_local = datetime.now(local_tz)
|
||||
range_start_local = (now_local - timedelta(days=days)).replace(
|
||||
minute=0, second=0, microsecond=0
|
||||
)
|
||||
today_start_local = now_local.replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
query_start_local = min(range_start_local, today_start_local)
|
||||
query_start_utc = query_start_local.astimezone(timezone.utc)
|
||||
|
||||
async with self.db_helper.get_db() as session:
|
||||
result = await session.execute(
|
||||
select(ProviderStat)
|
||||
.where(
|
||||
ProviderStat.agent_type == "internal",
|
||||
ProviderStat.created_at >= query_start_utc,
|
||||
)
|
||||
.order_by(ProviderStat.created_at.asc())
|
||||
)
|
||||
records = result.scalars().all()
|
||||
|
||||
bucket_timestamps: list[int] = []
|
||||
bucket_cursor = range_start_local
|
||||
while bucket_cursor <= now_local:
|
||||
bucket_timestamps.append(int(bucket_cursor.timestamp() * 1000))
|
||||
bucket_cursor += timedelta(hours=1)
|
||||
|
||||
trend_by_provider: dict[str, dict[int, int]] = defaultdict(
|
||||
lambda: defaultdict(int)
|
||||
)
|
||||
total_by_provider: dict[str, int] = defaultdict(int)
|
||||
total_by_umo: dict[str, int] = defaultdict(int)
|
||||
total_by_bucket: dict[int, int] = defaultdict(int)
|
||||
range_total_tokens = 0
|
||||
range_total_calls = 0
|
||||
range_success_calls = 0
|
||||
range_ttft_total_ms = 0.0
|
||||
range_ttft_samples = 0
|
||||
range_duration_total_ms = 0.0
|
||||
range_duration_samples = 0
|
||||
today_by_model: dict[str, int] = defaultdict(int)
|
||||
today_by_provider: dict[str, int] = defaultdict(int)
|
||||
today_total_tokens = 0
|
||||
today_total_calls = 0
|
||||
|
||||
for record in records:
|
||||
created_at_utc = self._ensure_aware_utc(record.created_at)
|
||||
created_at_local = created_at_utc.astimezone(local_tz)
|
||||
token_total = (
|
||||
record.token_input_other
|
||||
+ record.token_input_cached
|
||||
+ record.token_output
|
||||
)
|
||||
provider_id = record.provider_id or "unknown"
|
||||
provider_model = record.provider_model or "Unknown"
|
||||
|
||||
if created_at_local >= range_start_local:
|
||||
bucket_local = created_at_local.replace(
|
||||
minute=0, second=0, microsecond=0
|
||||
)
|
||||
bucket_ts = int(bucket_local.timestamp() * 1000)
|
||||
trend_by_provider[provider_id][bucket_ts] += token_total
|
||||
total_by_provider[provider_id] += token_total
|
||||
total_by_umo[record.umo or "unknown"] += token_total
|
||||
total_by_bucket[bucket_ts] += token_total
|
||||
range_total_tokens += token_total
|
||||
range_total_calls += 1
|
||||
if record.status != "error":
|
||||
range_success_calls += 1
|
||||
if record.time_to_first_token > 0:
|
||||
range_ttft_total_ms += record.time_to_first_token * 1000
|
||||
range_ttft_samples += 1
|
||||
if record.end_time > record.start_time:
|
||||
range_duration_total_ms += (
|
||||
record.end_time - record.start_time
|
||||
) * 1000
|
||||
range_duration_samples += 1
|
||||
|
||||
if created_at_local >= today_start_local:
|
||||
today_total_calls += 1
|
||||
today_total_tokens += token_total
|
||||
today_by_model[provider_model] += token_total
|
||||
today_by_provider[provider_id] += token_total
|
||||
|
||||
sorted_provider_ids = sorted(
|
||||
total_by_provider.keys(),
|
||||
key=lambda item: total_by_provider[item],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
series = [
|
||||
{
|
||||
"name": provider_id,
|
||||
"data": [
|
||||
[bucket_ts, trend_by_provider[provider_id].get(bucket_ts, 0)]
|
||||
for bucket_ts in bucket_timestamps
|
||||
],
|
||||
"total_tokens": total_by_provider[provider_id],
|
||||
}
|
||||
for provider_id in sorted_provider_ids
|
||||
]
|
||||
|
||||
total_series = [
|
||||
[bucket_ts, total_by_bucket.get(bucket_ts, 0)]
|
||||
for bucket_ts in bucket_timestamps
|
||||
]
|
||||
|
||||
today_by_model_data = [
|
||||
{"provider_model": model_name, "tokens": tokens}
|
||||
for model_name, tokens in sorted(
|
||||
today_by_model.items(),
|
||||
key=lambda item: item[1],
|
||||
reverse=True,
|
||||
)
|
||||
]
|
||||
today_by_provider_data = [
|
||||
{"provider_id": provider_id, "tokens": tokens}
|
||||
for provider_id, tokens in sorted(
|
||||
today_by_provider.items(),
|
||||
key=lambda item: item[1],
|
||||
reverse=True,
|
||||
)
|
||||
]
|
||||
range_by_provider_data = [
|
||||
{"provider_id": provider_id, "tokens": tokens}
|
||||
for provider_id, tokens in sorted(
|
||||
total_by_provider.items(),
|
||||
key=lambda item: item[1],
|
||||
reverse=True,
|
||||
)
|
||||
]
|
||||
range_by_umo_data = [
|
||||
{"umo": umo, "tokens": tokens}
|
||||
for umo, tokens in sorted(
|
||||
total_by_umo.items(),
|
||||
key=lambda item: item[1],
|
||||
reverse=True,
|
||||
)
|
||||
]
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"days": days,
|
||||
"trend": {
|
||||
"series": series,
|
||||
"total_series": total_series,
|
||||
},
|
||||
"range_total_tokens": range_total_tokens,
|
||||
"range_total_calls": range_total_calls,
|
||||
"range_avg_ttft_ms": (
|
||||
range_ttft_total_ms / range_ttft_samples
|
||||
if range_ttft_samples
|
||||
else 0
|
||||
),
|
||||
"range_avg_duration_ms": (
|
||||
range_duration_total_ms / range_duration_samples
|
||||
if range_duration_samples
|
||||
else 0
|
||||
),
|
||||
"range_avg_tpm": (
|
||||
range_total_tokens / (range_duration_total_ms / 1000 / 60)
|
||||
if range_duration_total_ms > 0
|
||||
else 0
|
||||
),
|
||||
"range_success_rate": (
|
||||
range_success_calls / range_total_calls
|
||||
if range_total_calls
|
||||
else 0
|
||||
),
|
||||
"range_by_provider": range_by_provider_data,
|
||||
"range_by_umo": range_by_umo_data,
|
||||
"today_total_tokens": today_total_tokens,
|
||||
"today_total_calls": today_total_calls,
|
||||
"today_by_model": today_by_model_data,
|
||||
"today_by_provider": today_by_provider_data,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Error: {e!s}").__dict__
|
||||
|
||||
async def test_ghproxy_connection(self):
|
||||
"""测试 GitHub 代理连接是否可用。"""
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 248 icons */
|
||||
/* Auto-generated MDI subset – 252 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -136,10 +136,6 @@
|
||||
content: "\F00F3";
|
||||
}
|
||||
|
||||
.mdi-calendar-range::before {
|
||||
content: "\F0679";
|
||||
}
|
||||
|
||||
.mdi-chat::before {
|
||||
content: "\F0B79";
|
||||
}
|
||||
@@ -204,6 +200,10 @@
|
||||
content: "\F0143";
|
||||
}
|
||||
|
||||
.mdi-chip::before {
|
||||
content: "\F061A";
|
||||
}
|
||||
|
||||
.mdi-circle::before {
|
||||
content: "\F0765";
|
||||
}
|
||||
@@ -288,6 +288,10 @@
|
||||
content: "\F0674";
|
||||
}
|
||||
|
||||
.mdi-creation-outline::before {
|
||||
content: "\F1C2B";
|
||||
}
|
||||
|
||||
.mdi-cursor-default-click::before {
|
||||
content: "\F0CFD";
|
||||
}
|
||||
@@ -640,6 +644,10 @@
|
||||
content: "\F164E";
|
||||
}
|
||||
|
||||
.mdi-message-outline::before {
|
||||
content: "\F0365";
|
||||
}
|
||||
|
||||
.mdi-message-text::before {
|
||||
content: "\F0369";
|
||||
}
|
||||
@@ -816,6 +824,10 @@
|
||||
content: "\F16A7";
|
||||
}
|
||||
|
||||
.mdi-robot-outline::before {
|
||||
content: "\F167A";
|
||||
}
|
||||
|
||||
.mdi-send::before {
|
||||
content: "\F048A";
|
||||
}
|
||||
@@ -932,6 +944,10 @@
|
||||
content: "\F0BD4";
|
||||
}
|
||||
|
||||
.mdi-timer-outline::before {
|
||||
content: "\F051B";
|
||||
}
|
||||
|
||||
.mdi-tools::before {
|
||||
content: "\F1064";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"header": {
|
||||
"eyebrow": "Dashboard",
|
||||
"title": "System Statistics",
|
||||
"subtitle": "A unified view of platforms, messages, and model calls.",
|
||||
"notUpdated": "Not updated yet"
|
||||
},
|
||||
"ranges": {
|
||||
"oneDay": "1 Day",
|
||||
"threeDays": "3 Days",
|
||||
"oneWeek": "1 Week"
|
||||
},
|
||||
"rangeLabels": {
|
||||
"oneDay": "Last 1 day",
|
||||
"threeDays": "Last 3 days",
|
||||
"oneWeek": "Last 1 week"
|
||||
},
|
||||
"overviewCards": {
|
||||
"platformCount": {
|
||||
"label": "Platform Instances",
|
||||
"note": "Configured and currently running platform instances"
|
||||
},
|
||||
"messageCount": {
|
||||
"label": "Messages",
|
||||
"note": "Total accumulated messages"
|
||||
},
|
||||
"todayModelCalls": {
|
||||
"label": "Today's Model Calls",
|
||||
"note": "Tokens"
|
||||
},
|
||||
"cpu": {
|
||||
"label": "CPU",
|
||||
"note": "AstrBot CPU usage"
|
||||
},
|
||||
"memory": {
|
||||
"label": "Process Memory",
|
||||
"note": "System memory {systemMemory}"
|
||||
},
|
||||
"uptime": {
|
||||
"label": "Uptime",
|
||||
"note": "Started at {startTime}"
|
||||
}
|
||||
},
|
||||
"messageOverview": {
|
||||
"title": "Message Overview",
|
||||
"subtitle": "Robot message statistics"
|
||||
},
|
||||
"messageTrend": {
|
||||
"title": "Message Trend",
|
||||
"subtitle": "{range}, aggregated by hour",
|
||||
"totalMessages": "Total Messages"
|
||||
},
|
||||
"platformRanking": {
|
||||
"title": "Platform Message Ranking",
|
||||
"subtitle": "{range}, sorted by aggregated message volume"
|
||||
},
|
||||
"modelCalls": {
|
||||
"title": "Model Calls",
|
||||
"subtitle": "Model call statistics"
|
||||
},
|
||||
"modelTrend": {
|
||||
"title": "Model Call Trend",
|
||||
"subtitle": "Token trend for model calls"
|
||||
},
|
||||
"modelTotal": {
|
||||
"title": "{range} total model calls",
|
||||
"callCount": "{count} calls",
|
||||
"avgTtft": "Average TTFT",
|
||||
"avgDuration": "Average Response Time",
|
||||
"avgTpm": "Average TPM",
|
||||
"successRate": "Success Rate"
|
||||
},
|
||||
"modelRanking": {
|
||||
"title": "{range} model call ranking",
|
||||
"subtitle": "Ranked by model call tokens"
|
||||
},
|
||||
"sessionRanking": {
|
||||
"title": "{range} session token top 10",
|
||||
"subtitle": "Sessions ranked by token usage"
|
||||
},
|
||||
"empty": {
|
||||
"platformStats": "No platform message statistics available.",
|
||||
"modelCalls": "No model call data for {range}.",
|
||||
"sessionCalls": "No session call data for {range}."
|
||||
},
|
||||
"chart": {
|
||||
"messages": "Messages",
|
||||
"others": "Others"
|
||||
},
|
||||
"units": {
|
||||
"tokens": "Tokens",
|
||||
"tpm": "TPM",
|
||||
"gb": "GB",
|
||||
"mb": "MB",
|
||||
"ms": "ms",
|
||||
"hoursShort": "h",
|
||||
"minutesShort": "m",
|
||||
"secondsShort": "s"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Failed to load statistics. Please try again later.",
|
||||
"rangeFailed": "Failed to switch the statistics range. Please try again later."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"header": {
|
||||
"eyebrow": "Dashboard",
|
||||
"title": "Статистика системы",
|
||||
"subtitle": "Единый обзор платформ, сообщений и вызовов моделей.",
|
||||
"notUpdated": "Еще не обновлялось"
|
||||
},
|
||||
"ranges": {
|
||||
"oneDay": "1 день",
|
||||
"threeDays": "3 дня",
|
||||
"oneWeek": "1 неделя"
|
||||
},
|
||||
"rangeLabels": {
|
||||
"oneDay": "За 1 день",
|
||||
"threeDays": "За 3 дня",
|
||||
"oneWeek": "За 1 неделю"
|
||||
},
|
||||
"overviewCards": {
|
||||
"platformCount": {
|
||||
"label": "Платформы",
|
||||
"note": "Настроенные и запущенные экземпляры платформ"
|
||||
},
|
||||
"messageCount": {
|
||||
"label": "Сообщения",
|
||||
"note": "Общее число сообщений"
|
||||
},
|
||||
"todayModelCalls": {
|
||||
"label": "Вызовы моделей сегодня",
|
||||
"note": "Tokens"
|
||||
},
|
||||
"cpu": {
|
||||
"label": "CPU",
|
||||
"note": "Использование CPU AstrBot"
|
||||
},
|
||||
"memory": {
|
||||
"label": "Память процесса",
|
||||
"note": "Системная память {systemMemory}"
|
||||
},
|
||||
"uptime": {
|
||||
"label": "Время работы",
|
||||
"note": "Запущен в {startTime}"
|
||||
}
|
||||
},
|
||||
"messageOverview": {
|
||||
"title": "Обзор сообщений",
|
||||
"subtitle": "Статистика сообщений бота"
|
||||
},
|
||||
"messageTrend": {
|
||||
"title": "Динамика сообщений",
|
||||
"subtitle": "{range}, агрегация по часам",
|
||||
"totalMessages": "Всего сообщений"
|
||||
},
|
||||
"platformRanking": {
|
||||
"title": "Рейтинг платформ по сообщениям",
|
||||
"subtitle": "{range}, сортировка по общему числу сообщений"
|
||||
},
|
||||
"modelCalls": {
|
||||
"title": "Вызовы моделей",
|
||||
"subtitle": "Статистика вызовов моделей"
|
||||
},
|
||||
"modelTrend": {
|
||||
"title": "Динамика вызовов моделей",
|
||||
"subtitle": "Динамика токенов вызовов моделей"
|
||||
},
|
||||
"modelTotal": {
|
||||
"title": "{range} всего вызовов моделей",
|
||||
"callCount": "{count} вызовов",
|
||||
"avgTtft": "Средний TTFT",
|
||||
"avgDuration": "Среднее время ответа",
|
||||
"avgTpm": "Средний TPM",
|
||||
"successRate": "Доля успешных вызовов"
|
||||
},
|
||||
"modelRanking": {
|
||||
"title": "{range} рейтинг вызовов моделей",
|
||||
"subtitle": "Рейтинг по токенам вызовов моделей"
|
||||
},
|
||||
"sessionRanking": {
|
||||
"title": "{range} топ-10 сессий по токенам",
|
||||
"subtitle": "Сессии с наибольшим расходом токенов"
|
||||
},
|
||||
"empty": {
|
||||
"platformStats": "Статистика сообщений по платформам отсутствует.",
|
||||
"modelCalls": "Нет данных по вызовам моделей за период {range}.",
|
||||
"sessionCalls": "Нет данных по сессиям за период {range}."
|
||||
},
|
||||
"chart": {
|
||||
"messages": "Сообщения",
|
||||
"others": "Другое"
|
||||
},
|
||||
"units": {
|
||||
"tokens": "Tokens",
|
||||
"tpm": "TPM",
|
||||
"gb": "ГБ",
|
||||
"mb": "МБ",
|
||||
"ms": "мс",
|
||||
"hoursShort": "ч",
|
||||
"minutesShort": "м",
|
||||
"secondsShort": "с"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Не удалось загрузить статистику. Повторите попытку позже.",
|
||||
"rangeFailed": "Не удалось переключить диапазон статистики. Повторите попытку позже."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"header": {
|
||||
"eyebrow": "Dashboard",
|
||||
"title": "系统统计",
|
||||
"subtitle": "平台、消息与模型调用的统一视图。",
|
||||
"notUpdated": "尚未更新"
|
||||
},
|
||||
"ranges": {
|
||||
"oneDay": "1 天",
|
||||
"threeDays": "3 天",
|
||||
"oneWeek": "1 周"
|
||||
},
|
||||
"rangeLabels": {
|
||||
"oneDay": "最近 1 天",
|
||||
"threeDays": "最近 3 天",
|
||||
"oneWeek": "最近 1 周"
|
||||
},
|
||||
"overviewCards": {
|
||||
"platformCount": {
|
||||
"label": "平台实例数",
|
||||
"note": "当前配置并已运行的平台实例"
|
||||
},
|
||||
"messageCount": {
|
||||
"label": "消息总数",
|
||||
"note": "累计消息量"
|
||||
},
|
||||
"todayModelCalls": {
|
||||
"label": "今日模型调用",
|
||||
"note": "词元(Tokens)"
|
||||
},
|
||||
"cpu": {
|
||||
"label": "CPU",
|
||||
"note": "程序 CPU 占用"
|
||||
},
|
||||
"memory": {
|
||||
"label": "进程内存",
|
||||
"note": "系统内存 {systemMemory}"
|
||||
},
|
||||
"uptime": {
|
||||
"label": "运行时间",
|
||||
"note": "启动于 {startTime}"
|
||||
}
|
||||
},
|
||||
"messageOverview": {
|
||||
"title": "消息概览",
|
||||
"subtitle": "机器人消息数据统计"
|
||||
},
|
||||
"messageTrend": {
|
||||
"title": "消息趋势",
|
||||
"subtitle": "{range}按小时聚合",
|
||||
"totalMessages": "总消息数"
|
||||
},
|
||||
"platformRanking": {
|
||||
"title": "平台消息排名",
|
||||
"subtitle": "{range}按聚合消息总量排序"
|
||||
},
|
||||
"modelCalls": {
|
||||
"title": "模型调用",
|
||||
"subtitle": "模型调用数据统计"
|
||||
},
|
||||
"modelTrend": {
|
||||
"title": "模型调用趋势",
|
||||
"subtitle": "模型调用词元(Tokens)趋势统计"
|
||||
},
|
||||
"modelTotal": {
|
||||
"title": "{range}模型调用总量",
|
||||
"callCount": "共 {count} 次调用",
|
||||
"avgTtft": "平均首字延迟(TTFT)",
|
||||
"avgDuration": "平均响应时间",
|
||||
"avgTpm": "平均每分钟词元数(TPM)",
|
||||
"successRate": "调用成功率"
|
||||
},
|
||||
"modelRanking": {
|
||||
"title": "{range}模型调用排名",
|
||||
"subtitle": "模型调用词元(Tokens)排名"
|
||||
},
|
||||
"sessionRanking": {
|
||||
"title": "{range}会话词元(Tokens)Top 10",
|
||||
"subtitle": "词元(Tokens)用量最大的会话排名"
|
||||
},
|
||||
"empty": {
|
||||
"platformStats": "当前没有平台消息统计数据。",
|
||||
"modelCalls": "{range}还没有模型调用数据。",
|
||||
"sessionCalls": "{range}还没有会话调用数据。"
|
||||
},
|
||||
"chart": {
|
||||
"messages": "消息",
|
||||
"others": "其他"
|
||||
},
|
||||
"units": {
|
||||
"tokens": "词元(Tokens)",
|
||||
"tpm": "TPM",
|
||||
"gb": "GB",
|
||||
"mb": "MB",
|
||||
"ms": "ms",
|
||||
"hoursShort": "h",
|
||||
"minutesShort": "m",
|
||||
"secondsShort": "s"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "统计数据加载失败,请稍后重试。",
|
||||
"rangeFailed": "统计范围切换失败,请稍后重试。"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import zhCNAuth from './locales/zh-CN/features/auth.json';
|
||||
import zhCNChart from './locales/zh-CN/features/chart.json';
|
||||
import zhCNDashboard from './locales/zh-CN/features/dashboard.json';
|
||||
import zhCNCron from './locales/zh-CN/features/cron.json';
|
||||
import zhCNStats from './locales/zh-CN/features/stats.json';
|
||||
import zhCNAlkaidIndex from './locales/zh-CN/features/alkaid/index.json';
|
||||
import zhCNAlkaidKnowledgeBase from './locales/zh-CN/features/alkaid/knowledge-base.json';
|
||||
import zhCNAlkaidMemory from './locales/zh-CN/features/alkaid/memory.json';
|
||||
@@ -67,6 +68,7 @@ import enUSAuth from './locales/en-US/features/auth.json';
|
||||
import enUSChart from './locales/en-US/features/chart.json';
|
||||
import enUSDashboard from './locales/en-US/features/dashboard.json';
|
||||
import enUSCron from './locales/en-US/features/cron.json';
|
||||
import enUSStats from './locales/en-US/features/stats.json';
|
||||
import enUSAlkaidIndex from './locales/en-US/features/alkaid/index.json';
|
||||
import enUSAlkaidKnowledgeBase from './locales/en-US/features/alkaid/knowledge-base.json';
|
||||
import enUSAlkaidMemory from './locales/en-US/features/alkaid/memory.json';
|
||||
@@ -108,6 +110,7 @@ import ruRUAuth from './locales/ru-RU/features/auth.json';
|
||||
import ruRUChart from './locales/ru-RU/features/chart.json';
|
||||
import ruRUDashboard from './locales/ru-RU/features/dashboard.json';
|
||||
import ruRUCron from './locales/ru-RU/features/cron.json';
|
||||
import ruRUStats from './locales/ru-RU/features/stats.json';
|
||||
import ruRUAlkaidIndex from './locales/ru-RU/features/alkaid/index.json';
|
||||
import ruRUAlkaidKnowledgeBase from './locales/ru-RU/features/alkaid/knowledge-base.json';
|
||||
import ruRUAlkaidMemory from './locales/ru-RU/features/alkaid/memory.json';
|
||||
@@ -153,6 +156,7 @@ export const translations = {
|
||||
chart: zhCNChart,
|
||||
dashboard: zhCNDashboard,
|
||||
cron: zhCNCron,
|
||||
stats: zhCNStats,
|
||||
alkaid: {
|
||||
index: zhCNAlkaidIndex,
|
||||
'knowledge-base': zhCNAlkaidKnowledgeBase,
|
||||
@@ -202,6 +206,7 @@ export const translations = {
|
||||
chart: enUSChart,
|
||||
dashboard: enUSDashboard,
|
||||
cron: enUSCron,
|
||||
stats: enUSStats,
|
||||
alkaid: {
|
||||
index: enUSAlkaidIndex,
|
||||
'knowledge-base': enUSAlkaidKnowledgeBase,
|
||||
@@ -251,6 +256,7 @@ export const translations = {
|
||||
chart: ruRUChart,
|
||||
dashboard: ruRUDashboard,
|
||||
cron: ruRUCron,
|
||||
stats: ruRUStats,
|
||||
alkaid: {
|
||||
index: ruRUAlkaidIndex,
|
||||
'knowledge-base': ruRUAlkaidKnowledgeBase,
|
||||
|
||||
@@ -52,9 +52,9 @@ const MainRoutes = {
|
||||
redirect: '/config#system'
|
||||
},
|
||||
{
|
||||
name: 'Default',
|
||||
name: 'Stats',
|
||||
path: '/dashboard/default',
|
||||
component: () => import('@/views/dashboards/default/DefaultDashboard.vue')
|
||||
component: () => import('@/views/stats/StatsPage.vue')
|
||||
},
|
||||
{
|
||||
name: 'Conversation',
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<v-slide-y-transition>
|
||||
<v-row v-if="noticeTitle && noticeContent" class="notice-row">
|
||||
<v-alert
|
||||
:type="noticeType"
|
||||
:text="noticeContent"
|
||||
:title="noticeTitle"
|
||||
closable
|
||||
class="dashboard-alert"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
></v-alert>
|
||||
</v-row>
|
||||
</v-slide-y-transition>
|
||||
|
||||
<!-- 主指标卡片行 -->
|
||||
<v-row class="stats-row">
|
||||
<v-col cols="12" md="3">
|
||||
<v-slide-y-transition>
|
||||
<TotalMessage :stat="stat" />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-slide-y-transition>
|
||||
<OnlinePlatform :stat="stat" />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-slide-y-transition>
|
||||
<RunningTime :stat="stat" />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-slide-y-transition>
|
||||
<MemoryUsage :stat="stat" />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 图表行 -->
|
||||
<v-row class="charts-row">
|
||||
<v-col cols="12" lg="8">
|
||||
<v-slide-y-transition>
|
||||
<MessageStat />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
<v-col cols="12" lg="4">
|
||||
<v-slide-y-transition>
|
||||
<PlatformStat :stat="stat" />
|
||||
</v-slide-y-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div class="dashboard-footer">
|
||||
<v-chip size="small" color="primary" variant="flat" prepend-icon="mdi-refresh">
|
||||
{{ t('lastUpdate') }}: {{ lastUpdated }}
|
||||
</v-chip>
|
||||
<v-btn
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click="fetchData"
|
||||
:loading="isRefreshing"
|
||||
></v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import TotalMessage from './components/TotalMessage.vue';
|
||||
import OnlinePlatform from './components/OnlinePlatform.vue';
|
||||
import RunningTime from './components/RunningTime.vue';
|
||||
import MemoryUsage from './components/MemoryUsage.vue';
|
||||
import MessageStat from './components/MessageStat.vue';
|
||||
import PlatformStat from './components/PlatformStat.vue';
|
||||
import axios from 'axios';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'DefaultDashboard',
|
||||
components: {
|
||||
TotalMessage,
|
||||
OnlinePlatform,
|
||||
RunningTime,
|
||||
MemoryUsage,
|
||||
MessageStat,
|
||||
PlatformStat,
|
||||
},
|
||||
setup() {
|
||||
const { tm: t } = useModuleI18n('features/dashboard');
|
||||
return { t };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
stat: {},
|
||||
noticeTitle: '',
|
||||
noticeContent: '',
|
||||
noticeType: '',
|
||||
lastUpdated: '',
|
||||
refreshInterval: null,
|
||||
isRefreshing: false
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.lastUpdated = this.t('status.loading');
|
||||
this.fetchData();
|
||||
this.fetchNotice();
|
||||
|
||||
// 设置自动刷新(每60秒)
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.fetchData();
|
||||
}, 60000);
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
// 清除定时器
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.isRefreshing = true;
|
||||
try {
|
||||
const res = await axios.get('/api/stat/get');
|
||||
this.stat = res.data.data;
|
||||
this.lastUpdated = new Date().toLocaleTimeString();
|
||||
console.log('Dashboard data:', this.stat);
|
||||
} catch (error) {
|
||||
console.error(this.t('status.dataError'), error);
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
},
|
||||
|
||||
fetchNotice() {
|
||||
axios.get('https://api.soulter.top/astrbot-announcement').then((res) => {
|
||||
let data = res.data.data;
|
||||
// 如果 dashboard-notice 在其中
|
||||
if (data['dashboard-notice']) {
|
||||
this.noticeTitle = data['dashboard-notice'].title;
|
||||
this.noticeContent = data['dashboard-notice'].content;
|
||||
this.noticeType = data['dashboard-notice'].type;
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error(this.t('status.noticeError'), error);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
padding: 16px;
|
||||
background-color: var(--v-theme-background);
|
||||
min-height: calc(100vh - 64px);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.notice-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-alert {
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.stats-row, .charts-row, .plugin-row {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
border-radius: 8px;
|
||||
background-color: var(--v-theme-surface);
|
||||
}
|
||||
|
||||
.plugin-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.plugin-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.plugin-item {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.plugin-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plugin-version {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText, #666);
|
||||
}
|
||||
|
||||
.dashboard-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
@@ -1,147 +0,0 @@
|
||||
<template>
|
||||
<v-card elevation="1" class="stat-card memory-card">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-start">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-memory" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ t('stats.memoryUsage.title') }}</div>
|
||||
<div class="stat-value-wrapper">
|
||||
<h2 class="stat-value">{{ stat.memory?.process || 0 }} <span class="memory-unit">MiB / {{ stat.memory?.system || 0 }} MiB</span></h2>
|
||||
<v-chip :color="memoryStatus.color" size="x-small" class="status-chip">
|
||||
{{ memoryStatus.label }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics-container">
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">{{ t('stats.memoryUsage.cpuLoad') }}</div>
|
||||
<div class="metric-value">{{ stat.cpu_percent || '0' }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'MemoryUsage',
|
||||
props: ['stat'],
|
||||
setup() {
|
||||
const { tm: t } = useModuleI18n('features/dashboard');
|
||||
return { t };
|
||||
},
|
||||
computed: {
|
||||
memoryPercentage() {
|
||||
if (!this.stat.memory || !this.stat.memory.process || !this.stat.memory.system) return 0;
|
||||
return Math.round((this.stat.memory.process / this.stat.memory.system) * 100);
|
||||
},
|
||||
memoryStatus() {
|
||||
const percentage = this.memoryPercentage;
|
||||
if (percentage < 30) {
|
||||
return { color: 'success', label: this.t('stats.memoryUsage.status.good') };
|
||||
} else if (percentage < 70) {
|
||||
return { color: 'warning', label: this.t('stats.memoryUsage.status.normal') };
|
||||
} else {
|
||||
return { color: 'error', label: this.t('stats.memoryUsage.status.high') };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.memory-card {
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.memory-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metrics-container {
|
||||
display: flex;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
margin-top: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -1,391 +0,0 @@
|
||||
<template>
|
||||
<v-card elevation="1" class="chart-card">
|
||||
<v-card-text>
|
||||
<div class="chart-header">
|
||||
<div>
|
||||
<div class="chart-title">{{ t('charts.messageTrend.title') }}</div>
|
||||
<div class="chart-subtitle">{{ t('charts.messageTrend.subtitle') }}</div>
|
||||
</div>
|
||||
|
||||
<v-select
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
v-model="selectedTimeRange"
|
||||
:items="timeRanges"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
class="time-select"
|
||||
@update:model-value="fetchMessageSeries"
|
||||
return-object
|
||||
single-line
|
||||
>
|
||||
<template v-slot:selection="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon start size="small">mdi-calendar-range</v-icon>
|
||||
{{ item.raw.label }}
|
||||
</div>
|
||||
</template>
|
||||
</v-select>
|
||||
</div>
|
||||
|
||||
<div class="chart-stats">
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">{{ t('charts.messageTrend.totalMessages') }}</div>
|
||||
<div class="stat-number">{{ totalMessages }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">{{ t('charts.messageTrend.dailyAverage') }}</div>
|
||||
<div class="stat-number">{{ dailyAverage }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box" :class="{'trend-up': growthRate > 0, 'trend-down': growthRate < 0}">
|
||||
<div class="stat-label">{{ t('charts.messageTrend.growthRate') }}</div>
|
||||
<div class="stat-number">
|
||||
<v-icon v-show="growthRate !== 0" size="small" :icon="growthRate > 0 ? 'mdi-arrow-up' : 'mdi-arrow-down'"></v-icon>
|
||||
{{ Math.abs(growthRate) }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||
<div class="loading-text">{{ t('status.loading') }}</div>
|
||||
</div>
|
||||
<apexchart
|
||||
type="area"
|
||||
height="280"
|
||||
:options="chartOptions"
|
||||
:series="chartSeries"
|
||||
ref="chart"
|
||||
></apexchart>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import {useCustomizerStore} from "@/stores/customizer";
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'MessageStat',
|
||||
props: ['stat'],
|
||||
setup() {
|
||||
const { tm: t } = useModuleI18n('features/dashboard');
|
||||
return { t };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
totalMessages: '0',
|
||||
dailyAverage: '0',
|
||||
growthRate: 0,
|
||||
loading: false,
|
||||
selectedTimeRange: null,
|
||||
timeRanges: [],
|
||||
|
||||
chartOptions: {
|
||||
chart: {
|
||||
type: 'area',
|
||||
height: 400,
|
||||
fontFamily: `inherit`,
|
||||
foreColor: '#a1aab2',
|
||||
toolbar: {
|
||||
show: true,
|
||||
tools: {
|
||||
download: true,
|
||||
selection: false,
|
||||
zoom: true,
|
||||
zoomin: true,
|
||||
zoomout: true,
|
||||
pan: true,
|
||||
},
|
||||
},
|
||||
animations: {
|
||||
enabled: true,
|
||||
easing: 'easeinout',
|
||||
speed: 800,
|
||||
},
|
||||
},
|
||||
colors: ['#5e35b1'],
|
||||
fill: {
|
||||
type: 'solid',
|
||||
opacity: 0.3,
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 2
|
||||
},
|
||||
markers: {
|
||||
size: 3,
|
||||
strokeWidth: 2,
|
||||
hover: {
|
||||
size: 5,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
theme: useCustomizerStore().uiTheme==='PurpleTheme' ? 'light' : 'dark',
|
||||
x: {
|
||||
format: 'yyyy-MM-dd HH:mm'
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
formatter: () => ''
|
||||
}
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
labels: {
|
||||
formatter: function (value) {
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
min: function(min) {
|
||||
return min < 10 ? 0 : Math.floor(min * 0.8);
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: "gray100",
|
||||
row: {
|
||||
colors: ['transparent', 'transparent'],
|
||||
opacity: 0.2
|
||||
},
|
||||
column: {
|
||||
colors: ['transparent', 'transparent'],
|
||||
},
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
chartSeries: [
|
||||
{
|
||||
name: '',
|
||||
data: []
|
||||
}
|
||||
],
|
||||
|
||||
messageTimeSeries: []
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// 初始化时间范围选项
|
||||
this.timeRanges = [
|
||||
{ label: this.t('charts.messageTrend.timeRanges.1day'), value: 86400 },
|
||||
{ label: this.t('charts.messageTrend.timeRanges.3days'), value: 259200 },
|
||||
{ label: this.t('charts.messageTrend.timeRanges.1week'), value: 604800 },
|
||||
{ label: this.t('charts.messageTrend.timeRanges.1month'), value: 2592000 },
|
||||
];
|
||||
this.selectedTimeRange = this.timeRanges[0];
|
||||
|
||||
// 设置图表翻译文本
|
||||
this.chartOptions.tooltip.y.title.formatter = () => this.t('charts.messageTrend.messageCount') + ' ';
|
||||
this.chartOptions.xaxis.title.text = this.t('charts.messageTrend.timeLabel');
|
||||
this.chartOptions.yaxis.title.text = this.t('charts.messageTrend.messageCount');
|
||||
this.chartSeries[0].name = this.t('charts.messageTrend.messageCount');
|
||||
|
||||
// 初始加载
|
||||
this.fetchMessageSeries();
|
||||
},
|
||||
|
||||
methods: {
|
||||
formatNumber(num) {
|
||||
return new Intl.NumberFormat('zh-CN').format(num);
|
||||
},
|
||||
|
||||
async fetchMessageSeries() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/stat/get?offset_sec=${this.selectedTimeRange.value}`);
|
||||
const data = response.data.data;
|
||||
|
||||
if (data && data.message_time_series) {
|
||||
this.messageTimeSeries = data.message_time_series;
|
||||
this.processTimeSeriesData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(this.t('status.dataError'), error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
processTimeSeriesData() {
|
||||
// 转换数据为图表格式
|
||||
this.chartSeries[0].data = this.messageTimeSeries.map((item) => {
|
||||
return [new Date(item[0]*1000).getTime(), item[1]];
|
||||
});
|
||||
|
||||
// 计算总消息数
|
||||
let total = 0;
|
||||
this.messageTimeSeries.forEach(item => {
|
||||
total += item[1];
|
||||
});
|
||||
this.totalMessages = this.formatNumber(total);
|
||||
|
||||
// 计算日平均
|
||||
if (this.messageTimeSeries.length > 0) {
|
||||
const daysSpan = this.selectedTimeRange.value / 86400; // 将秒转换为天数
|
||||
this.dailyAverage = this.formatNumber(Math.round(total / daysSpan));
|
||||
}
|
||||
|
||||
// 计算增长率
|
||||
this.calculateGrowthRate();
|
||||
},
|
||||
|
||||
calculateGrowthRate() {
|
||||
if (this.messageTimeSeries.length < 4) {
|
||||
this.growthRate = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算前半部分和后半部分的消息总数
|
||||
const halfIndex = Math.floor(this.messageTimeSeries.length / 2);
|
||||
|
||||
const firstHalf = this.messageTimeSeries
|
||||
.slice(0, halfIndex)
|
||||
.reduce((sum, item) => sum + item[1], 0);
|
||||
|
||||
const secondHalf = this.messageTimeSeries
|
||||
.slice(halfIndex)
|
||||
.reduce((sum, item) => sum + item[1], 0);
|
||||
|
||||
// 计算增长率
|
||||
if (firstHalf > 0) {
|
||||
this.growthRate = Math.round(((secondHalf - firstHalf) / firstHalf) * 100);
|
||||
} else {
|
||||
this.growthRate = secondHalf > 0 ? 100 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card {
|
||||
height: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chart-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.chart-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.time-select {
|
||||
max-width: fit-content;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chart-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
padding: 12px 16px;
|
||||
background: var(--v-theme-surface);
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--v-theme-primaryText);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trend-up .stat-number {
|
||||
color: var(--v-theme-success);
|
||||
}
|
||||
|
||||
.trend-down .stat-number {
|
||||
color: var(--v-theme-error);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
border-top: 1px solid var(--v-theme-border);
|
||||
padding-top: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--v-theme-overlay);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
</style>
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<v-card elevation="1" class="stat-card platform-card">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-start">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-server-network" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ t('stats.onlinePlatform.title') }}</div>
|
||||
<div class="stat-value-wrapper">
|
||||
<h2 class="stat-value">{{ stat.platform_count || 0 }}</h2>
|
||||
</div>
|
||||
<div class="stat-subtitle">{{ t('stats.onlinePlatform.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'OnlinePlatform',
|
||||
props: ['stat'],
|
||||
setup() {
|
||||
const { tm: t } = useModuleI18n('features/dashboard');
|
||||
return { t };
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.platform-card {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.stat-subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -1,196 +0,0 @@
|
||||
<template>
|
||||
<div class="stats-container">
|
||||
<v-card elevation="1" class="stat-card uptime-card mb-4">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-clock-outline" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ tm('features.dashboard.status.uptime') }}</div>
|
||||
<h3 class="uptime-value">{{ stat.running || tm('features.dashboard.status.loading') }}</h3>
|
||||
</div>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<div class="uptime-status">
|
||||
<v-icon icon="mdi-circle" size="10" color="success" class="blink-animation"></v-icon>
|
||||
<span class="status-text">{{ tm('features.dashboard.status.online') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card elevation="1" class="stat-card memory-card">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-memory" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ tm('features.dashboard.status.memoryUsage') }}</div>
|
||||
<div class="memory-values">
|
||||
<h3 class="memory-value">{{ stat.memory?.process || 0 }} <span class="memory-unit">MiB</span></h3>
|
||||
<span class="memory-separator">/</span>
|
||||
<h4 class="memory-total">{{ stat.memory?.system || 0 }} <span class="memory-unit">MiB</span></h4>
|
||||
</div>
|
||||
|
||||
<v-progress-linear
|
||||
:model-value="memoryPercentage"
|
||||
color="warning"
|
||||
height="4"
|
||||
class="mt-2"
|
||||
></v-progress-linear>
|
||||
|
||||
<div class="memory-percentage">{{ memoryPercentage }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'OnlineTime',
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/dashboard');
|
||||
return { tm };
|
||||
},
|
||||
props: ['stat'],
|
||||
data: () => ({
|
||||
stat: {
|
||||
memory: { process: 0, system: 0 },
|
||||
running: "",
|
||||
},
|
||||
}),
|
||||
computed: {
|
||||
memoryPercentage() {
|
||||
if (!this.stat.memory || !this.stat.memory.process || !this.stat.memory.system) return 0;
|
||||
return Math.round((this.stat.memory.process / this.stat.memory.system) * 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stats-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.uptime-card {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.memory-card {
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.uptime-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.uptime-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.memory-values {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.memory-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.memory-separator {
|
||||
margin: 0 6px;
|
||||
font-weight: 300;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.memory-total {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.memory-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.memory-percentage {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.blink-animation {
|
||||
animation: blink 1.5s infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -1,261 +0,0 @@
|
||||
<template>
|
||||
<v-card elevation="1" class="platform-stat-card">
|
||||
<v-card-text>
|
||||
<div class="platform-header">
|
||||
<div>
|
||||
<div class="platform-title">{{ t('charts.platformStat.title') }}</div>
|
||||
<div class="platform-subtitle">{{ t('charts.platformStat.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3"></v-divider>
|
||||
|
||||
<div v-if="platforms.length > 0" class="platform-list-container">
|
||||
<v-list class="platform-list" density="compact">
|
||||
<v-list-item
|
||||
v-for="(platform, i) in sortedPlatforms"
|
||||
:key="i"
|
||||
:value="platform"
|
||||
class="platform-item"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="platform-rank" :class="{'top-rank': i < 3}">{{ i + 1 }}</div>
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="platform-name">{{ platform.name }}</v-list-item-title>
|
||||
|
||||
<template v-slot:append>
|
||||
<div class="platform-count">
|
||||
<span class="count-value">{{ platform.count }}</span>
|
||||
<span class="count-label">{{ t('charts.platformStat.messageUnit') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div class="platform-stats-summary">
|
||||
<div class="platform-stat-item">
|
||||
<div class="stat-label">{{ t('charts.platformStat.platformCount') }}</div>
|
||||
<div class="stat-value">{{ platforms.length }}</div>
|
||||
</div>
|
||||
<v-divider vertical></v-divider>
|
||||
<div class="platform-stat-item">
|
||||
<div class="stat-label">{{ t('charts.platformStat.mostActive') }}</div>
|
||||
<div class="stat-value">{{ mostActivePlatform }}</div>
|
||||
</div>
|
||||
<v-divider vertical></v-divider>
|
||||
<div class="platform-stat-item">
|
||||
<div class="stat-label">{{ t('charts.platformStat.totalPercentage') }}</div>
|
||||
<div class="stat-value">{{ topPlatformPercentage }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-chart">
|
||||
<v-progress-linear
|
||||
v-for="(platform, i) in sortedPlatforms.slice(0, 5)"
|
||||
:key="i"
|
||||
:model-value="getPercentage(platform.count)"
|
||||
height="8"
|
||||
rounded
|
||||
class="platform-progress"
|
||||
:color="i === 0 ? 'primary' : i === 1 ? 'info' : i === 2 ? 'success' : 'grey-lighten-1'"
|
||||
></v-progress-linear>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-data">
|
||||
<v-icon icon="mdi-information-outline" size="40" color="grey-lighten-1"></v-icon>
|
||||
<div class="no-data-text">{{ t('charts.platformStat.noData') }}</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'PlatformStat',
|
||||
props: ['stat'],
|
||||
setup() {
|
||||
const { tm: t } = useModuleI18n('features/dashboard');
|
||||
return { t };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
platforms: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
sortedPlatforms() {
|
||||
return [...this.platforms].sort((a, b) => b.count - a.count);
|
||||
},
|
||||
totalCount() {
|
||||
return this.platforms.reduce((sum, platform) => sum + platform.count, 0);
|
||||
},
|
||||
mostActivePlatform() {
|
||||
return this.sortedPlatforms.length > 0 ? this.sortedPlatforms[0].name : '-';
|
||||
},
|
||||
topPlatformPercentage() {
|
||||
if (this.totalCount === 0 || this.sortedPlatforms.length === 0) return 0;
|
||||
return Math.round((this.sortedPlatforms[0].count / this.totalCount) * 100);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
stat: {
|
||||
handler: function (val) {
|
||||
if (val && val.platform) {
|
||||
this.platforms = val.platform;
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getPercentage(count) {
|
||||
return this.totalCount ? (count / this.totalCount) * 100 : 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-stat-card {
|
||||
height: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.platform-stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.platform-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.platform-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.platform-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.platform-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.platform-list {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.platform-item {
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.platform-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.platform-rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--v-theme-surface);
|
||||
color: var(--v-theme-primaryText);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.top-rank {
|
||||
background-color: var(--v-theme-secondary);
|
||||
color: var(--v-theme-surface);
|
||||
}
|
||||
|
||||
.platform-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.platform-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count-value {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--v-theme-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.count-label {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
|
||||
.platform-stats-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: var(--v-theme-containerBg);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.platform-stat-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.platform-chart {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.platform-progress {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,104 +0,0 @@
|
||||
<template>
|
||||
<v-card elevation="1" class="stat-card uptime-card">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-start">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-clock-outline" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ t('stats.runningTime.title') }}</div>
|
||||
<div class="stat-value-wrapper">
|
||||
<h2 class="stat-value">{{ formattedTime }}</h2>
|
||||
</div>
|
||||
<div class="stat-subtitle">{{ t('stats.runningTime.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'RunningTime',
|
||||
props: ['stat'],
|
||||
setup() {
|
||||
const { tm: t } = useModuleI18n('features/dashboard');
|
||||
return { t };
|
||||
},
|
||||
computed: {
|
||||
formattedTime() {
|
||||
if (!this.stat?.running) {
|
||||
return this.t('status.loading');
|
||||
}
|
||||
|
||||
const { hours, minutes, seconds } = this.stat.running;
|
||||
return this.t('stats.runningTime.format', {
|
||||
hours,
|
||||
minutes,
|
||||
seconds
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.uptime-card {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<v-card elevation="1" class="stat-card message-card">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-start">
|
||||
<div class="icon-wrapper">
|
||||
<v-icon icon="mdi-message-text-outline" size="24"></v-icon>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ t('stats.totalMessage.title') }}</div>
|
||||
<div class="stat-value-wrapper">
|
||||
<h2 class="stat-value">{{ formattedCount }}</h2>
|
||||
<v-chip v-if="stat.daily_increase" class="trend-chip" size="x-small" color="success">
|
||||
+{{ stat.daily_increase }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="stat-subtitle">{{ t('stats.totalMessage.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'TotalMessage',
|
||||
props: ['stat'],
|
||||
setup() {
|
||||
const { tm: t } = useModuleI18n('features/dashboard');
|
||||
return { t };
|
||||
},
|
||||
computed: {
|
||||
formattedCount() {
|
||||
const count = this.stat?.message_count;
|
||||
return count ? count.toLocaleString() : '0';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
background-color: #5e35b1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.trend-chip {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from astrbot.core.agent.response import AgentStats
|
||||
from astrbot.core.db.po import ProviderStat
|
||||
from astrbot.core.pipeline.process_stage.method.agent_sub_stages import internal
|
||||
from astrbot.core.provider.entities import ProviderRequest, TokenUsage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_internal_agent_stats_persists_provider_stat(
|
||||
temp_db,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(internal, "db_helper", temp_db)
|
||||
|
||||
event = SimpleNamespace(unified_msg_origin="webchat:FriendMessage:session-42")
|
||||
req = ProviderRequest(
|
||||
conversation=SimpleNamespace(cid="conv-123"),
|
||||
)
|
||||
stats = AgentStats(
|
||||
token_usage=TokenUsage(input_other=11, input_cached=3, output=7),
|
||||
start_time=100.0,
|
||||
end_time=108.5,
|
||||
time_to_first_token=0.6,
|
||||
)
|
||||
provider = SimpleNamespace(
|
||||
provider_config={"id": "provider-1"},
|
||||
meta=lambda: SimpleNamespace(id="provider-1", type="openai"),
|
||||
get_model=lambda: "gpt-4.1",
|
||||
)
|
||||
agent_runner = SimpleNamespace(
|
||||
provider=provider,
|
||||
stats=stats,
|
||||
was_aborted=lambda: False,
|
||||
)
|
||||
final_resp = SimpleNamespace(role="assistant")
|
||||
|
||||
await internal._record_internal_agent_stats(
|
||||
event,
|
||||
req,
|
||||
agent_runner,
|
||||
final_resp,
|
||||
)
|
||||
|
||||
async with temp_db.get_db() as session:
|
||||
result = await session.execute(select(ProviderStat))
|
||||
records = result.scalars().all()
|
||||
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record.agent_type == "internal"
|
||||
assert record.status == "completed"
|
||||
assert record.umo == "webchat:FriendMessage:session-42"
|
||||
assert record.conversation_id == "conv-123"
|
||||
assert record.provider_id == "provider-1"
|
||||
assert record.provider_model == "gpt-4.1"
|
||||
assert record.token_input_other == 11
|
||||
assert record.token_input_cached == 3
|
||||
assert record.token_output == 7
|
||||
assert record.start_time == 100.0
|
||||
assert record.end_time == 108.5
|
||||
assert record.time_to_first_token == 0.6
|
||||
Reference in New Issue
Block a user