🎈 perf: UI 优化

This commit is contained in:
Soulter
2025-03-24 00:32:40 +08:00
parent e5828713cf
commit ade87f378a
10 changed files with 1426 additions and 574 deletions
+25 -3
View File
@@ -1,6 +1,7 @@
import traceback
import psutil
import time
import threading
from .route import Route, Response, RouteContext
from astrbot.core import logger
from quart import request
@@ -64,6 +65,25 @@ class StatRoute(Route):
stat_dict = stat.__dict__
# 获取CPU使用率 - 修复CPU始终为0的问题
process = psutil.Process()
# 获取系统CPU使用率而不是进程CPU使用率
cpu_percent = psutil.cpu_percent(interval=0.5)
# 获取线程数
thread_count = threading.active_count()
# 获取插件信息
plugins = self.core_lifecycle.star_context.get_all_stars()
plugin_info = []
for plugin in plugins:
info = {
"name": getattr(plugin, "name", plugin.__class__.__name__),
"version": getattr(plugin, "version", "1.0.0"),
"is_enabled": True
}
plugin_info.append(info)
stat_dict.update(
{
"platform": self.db_helper.get_grouped_base_stats(
@@ -73,9 +93,8 @@ class StatRoute(Route):
"platform_count": len(
self.core_lifecycle.platform_manager.get_insts()
),
"plugin_count": len(
self.core_lifecycle.star_context.get_all_stars()
),
"plugin_count": len(plugins),
"plugins": plugin_info,
"message_time_series": message_time_based_stats,
"running": self.format_sec(
int(time.time()) - self.core_lifecycle.start_time
@@ -84,6 +103,9 @@ class StatRoute(Route):
"process": psutil.Process().memory_info().rss >> 20,
"system": psutil.virtual_memory().total >> 20,
},
"cpu_percent": round(cpu_percent, 1),
"thread_count": thread_count,
"start_time": self.core_lifecycle.start_time,
}
)
+88 -275
View File
@@ -1,207 +1,141 @@
<template>
<div class="config-section" v-if="iterable && metadata[metadataKey]?.type === 'object'">
<v-list-item-title class="config-title">
{{ metadata[metadataKey]?.description }} <span class="key-name">({{ metadataKey }})</span>
<div style="margin-bottom: 6px;" v-if="iterable && metadata[metadataKey]?.type === 'object'">
<v-list-item-title style="font-weight: bold;">
{{ metadata[metadataKey]?.description }} ({{ metadataKey }})
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="hint-icon"></span>
<v-list-item-subtitle style="font-size: 12px;">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"
style="opacity: 1.0;"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</div>
<v-card-text class="config-container">
<div v-for="(val, key, index) in iterable" :key="key" class="config-item"
<v-card-text style="padding: 0px;">
<div v-for="(val, key, index) in iterable" :key="key" style="margin-bottom: 0.5px;"
v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template">
<div v-if="metadata[metadataKey].items[key]?.type === 'object'" class="nested-object">
<div v-if="metadata[metadataKey].items[key]?.type === 'object'" style="padding-left: 16px;">
<div v-if="metadata[metadataKey].items[key] && !metadata[metadataKey].items[key]?.invisible"
class="nested-object-container">
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]" :metadataKey="key">
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px; margin-top: 16px">
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]" :metadataKey=key>
</AstrBotConfig>
</div>
</div>
<v-row v-else class="config-row">
<v-col cols="6" class="label-col">
<v-row v-else style="margin: 0; align-items: center;">
<v-col cols="6" style="padding: 0px;">
<v-list-item>
<v-list-item-title class="item-title">
<span v-if="metadata[metadataKey].items[key]?.description">
{{ metadata[metadataKey].items[key]?.description }}
<span class="key-name">({{ key }})</span>
</span>
<v-list-item-title style="font-size: 14px; font-weight: bold;">
<span v-if="metadata[metadataKey].items[key]?.description">{{ metadata[metadataKey].items[key]?.description + '(' + key + ')' }}</span>
<span v-else>{{ key }}</span>
</v-list-item-title>
<v-list-item-subtitle class="item-hint">
<v-list-item-subtitle style="font-size: 12px;">
<span
v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
class="hint-icon"></span>
style="opacity: 1.0;"></span>
{{ metadata[metadataKey].items[key]?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="1" class="type-col">
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible"
color="primary" label size="x-small"
class="type-chip">
{{ metadata[metadataKey].items[key]?.type || 'string' }}
<v-col cols="1">
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible" color="primary" label size="x-small"
class="mb-1">{{
metadata[metadataKey].items[key]?.type || 'string' }}
</v-chip>
</v-col>
<v-col cols="5" class="input-col">
<div v-if="metadata[metadataKey].items[key]" class="input-wrapper">
<v-fade-transition>
<v-select
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
variant="outlined"
:items="metadata[metadataKey].items[key]?.options"
:disabled="metadata[metadataKey].items[key]?.readonly"
density="compact" flat
hide-details
class="config-input"
></v-select>
<v-text-field
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
variant="outlined"
density="compact" flat
hide-details
class="config-input"
></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
variant="outlined"
density="compact" flat
hide-details
class="config-input"
></v-text-field>
<v-textarea
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
variant="outlined"
auto-grow
rows="2"
flat
hide-details
class="config-input"
></v-textarea>
<v-switch
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
color="primary"
hide-details
class="config-switch"
></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
:value="iterable[key]" />
</v-fade-transition>
<v-col cols="5">
<div style="width: 100%;" v-if="metadata[metadataKey].items[key]">
<v-select
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined"
:items="metadata[metadataKey].items[key]?.options" dense
:disabled="metadata[metadataKey].items[key]?.readonly" density="compact" flat hide-details
single-line></v-select>
<v-text-field
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-textarea
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" dense flat hide-details single-line></v-textarea>
<v-switch
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" color="primary" hide-details></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
:value="iterable[key]" />
</div>
<div v-else class="input-wrapper">
<div style="width: 100%;" v-else>
<!-- metadata 中没有 key -->
<v-text-field
v-model="iterable[key]"
:label="key"
variant="outlined"
density="compact"
flat
hide-details
class="config-input"
></v-text-field>
<v-text-field v-model="iterable[key]" :label="key" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
</div>
</v-col>
</v-row>
<v-divider class="item-divider"
v-if="index !== Object.keys(iterable).length - 1 && !metadata[metadataKey].items[key]?.invisible"></v-divider>
<v-divider style="border-color: #ccc;" v-if="index !== Object.keys(iterable).length - 1 && !metadata[metadataKey].items[key]?.invisible "></v-divider>
</div>
<div v-else>
<v-row class="config-row">
<v-col cols="6" class="label-col">
<v-row style="margin: 0; align-items: center;">
<v-col cols="6" style="padding: 0px;">
<v-list-item>
<v-list-item-title class="item-title">
{{ metadata[metadataKey]?.description }}
<span class="key-name">({{ metadataKey }})</span>
<v-list-item-title style="font-size: 14px; font-weight: bold">
{{ metadata[metadataKey]?.description + '(' + metadataKey + ')' }}
</v-list-item-title>
<v-list-item-subtitle class="item-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="hint-icon"></span>
<v-list-item-subtitle style="font-size: 12px;">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="1" class="type-col">
<v-chip v-if="!metadata[metadataKey]?.invisible"
color="primary"
label
size="x-small"
class="type-chip">
{{ metadata[metadataKey]?.type }}
<v-col cols="1">
<v-chip v-if="!metadata[metadataKey]?.invisible" color="primary" label size="x-small"
class="mb-1">{{
metadata[metadataKey]?.type }}
</v-chip>
</v-col>
<v-col cols="5" class="input-col">
<div class="input-wrapper">
<v-fade-transition>
<v-select v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
variant="outlined"
:items="metadata[metadataKey]?.options"
:disabled="metadata[metadataKey]?.readonly"
density="compact"
flat
hide-details
class="config-input"
></v-select>
<v-text-field
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
variant="outlined"
density="compact"
flat
hide-details
class="config-input"
></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
variant="outlined"
density="compact"
flat
hide-details
class="config-input"
></v-text-field>
<v-textarea
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
variant="outlined"
auto-grow
rows="2"
flat
hide-details
class="config-input"
></v-textarea>
<v-switch
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
color="primary"
hide-details
class="config-switch"
></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
:value="iterable[metadataKey]" />
</v-fade-transition>
<v-col cols="5">
<div style="width: 100%;">
<v-select v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" :items="metadata[metadataKey]?.options"
dense :disabled="metadata[metadataKey]?.readonly" density="compact" flat hide-details
single-line></v-select>
<v-text-field
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-textarea
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
single-line></v-textarea>
<v-switch
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" color="primary" hide-details></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
:value="iterable[metadataKey]" />
</div>
</v-col>
</v-row>
<v-divider class="main-divider"></v-divider>
<v-divider style="border-color: #ddd;"></v-divider>
</div>
</v-card-text>
</template>
@@ -219,125 +153,4 @@ export default {
metadataKey: String
}
}
</script>
<style scoped>
.config-section {
margin-bottom: 12px;
}
.config-title {
font-weight: 600;
font-size: 16px;
color: #1a1a1a;
}
.config-hint {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.hint-icon {
opacity: 1.0;
margin-right: 4px;
}
.key-name {
color: #666;
font-size: 0.9em;
font-weight: normal;
}
.config-container {
padding: 0;
}
.config-item {
margin-bottom: 4px;
transition: background-color 0.2s;
}
.config-item:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.nested-object {
padding-left: 16px;
}
.nested-object-container {
border: 1px solid #e0e0e0;
border-radius: 10px;
padding: 12px;
margin: 16px 0;
background-color: rgba(250, 250, 250, 0.5);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.config-row {
margin: 0;
align-items: center;
padding: 6px 4px;
border-radius: 8px;
}
.label-col {
padding: 0;
}
.item-title {
font-size: 14px;
font-weight: 600;
color: #333;
}
.item-hint {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.type-col {
display: flex;
justify-content: center;
}
.type-chip {
font-size: 10px !important;
text-transform: lowercase;
font-weight: 500;
}
.input-col {
padding-top: 4px;
padding-bottom: 4px;
}
.input-wrapper {
width: 100%;
}
.config-input {
border-radius: 6px;
transition: box-shadow 0.2s;
}
.config-input:hover {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.config-switch {
margin-top: 8px;
}
.item-divider {
border-color: #eee;
margin: 4px 0;
}
.main-divider {
border-color: #ddd;
margin: 8px 0;
}
</style>
</script>
@@ -1,37 +1,84 @@
<template>
<v-row style="margin: 2px;">
<v-alert
:type="noticeType"
:text="noticeContent"
:title="noticeTitle"
v-if="noticeTitle && noticeContent"
closable
></v-alert>
</v-row>
<v-row>
<v-col cols="12" md="4">
<TotalMessage :stat="stat" />
</v-col>
<v-col cols="12" md="4">
<OnlinePlatform :stat="stat" />
</v-col>
<v-col cols="12" md="4">
<OnlineTime :stat="stat" />
</v-col>
<v-col cols="12" lg="8">
<MessageStat :stat="stat" />
</v-col>
<v-col cols="12" lg="4">
<PlatformStat :stat="stat" />
</v-col>
</v-row>
<div class="dashboard-container">
<div class="dashboard-header">
<h1 class="dashboard-title">控制台</h1>
<div class="dashboard-subtitle">实时监控和统计数据</div>
</div>
<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">
最后更新: {{ 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 OnlineTime from './components/OnlineTime.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';
@@ -41,7 +88,8 @@ export default {
components: {
TotalMessage,
OnlinePlatform,
OnlineTime,
RunningTime,
MemoryUsage,
MessageStat,
PlatformStat,
},
@@ -50,23 +98,142 @@ export default {
noticeTitle: '',
noticeContent: '',
noticeType: '',
lastUpdated: '加载中...',
refreshInterval: null,
isRefreshing: false
}),
mounted() {
axios.get('/api/stat/get').then((res) => {
this.stat = res.data.data;
});
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;
}
});
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('获取数据失败:', 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('获取公告失败:', error);
});
}
}
};
</script>
<style scoped>
.dashboard-container {
padding: 16px;
background-color: #f9fafc;
min-height: calc(100vh - 64px);
border-radius: 10px;
}
.dashboard-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.dashboard-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.dashboard-subtitle {
font-size: 14px;
color: #666;
}
.notice-row {
margin-bottom: 20px;
}
.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: white;
}
.plugin-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.plugin-subtitle {
font-size: 12px;
color: #666;
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: #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>
@@ -0,0 +1,140 @@
<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">内存占用</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">CPU 负载</div>
<div class="metric-value">{{ stat.cpu_percent || '0' }}%</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'MemoryUsage',
props: ['stat'],
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: '良好' };
} else if (percentage < 70) {
return { color: 'warning', label: '正常' };
} else {
return { color: 'error', label: '偏高' };
}
}
}
};
</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;
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,65 +1,136 @@
<script setup>
//
</script>
<template>
<v-card elevation="0">
<v-card variant="outlined">
<v-card-text>
<v-row>
<v-col cols="12" sm="7">
<span class="text-subtitle-2 text-disabled font-weight-bold">总消息趋势</span>
<!-- <h3 class="text-h3 mt-1">{{ total_cnt }}</h3> -->
</v-col>
<v-col cols="12" sm="5">
<v-select color="primary" variant="outlined" hide-details v-model="select" :items="items" item-title="state"
item-value="abbr" label="Select" persistent-hint return-object single-line>
</v-select>
</v-col>
</v-row>
<div class="mt-4">
<apexchart type="area" height="280" :options="chartOptions1" :series="lineChart1.series" ref="rtchart">
</apexchart>
<v-card elevation="1" class="chart-card">
<v-card-text>
<div class="chart-header">
<div>
<div class="chart-title">消息趋势分析</div>
<div class="chart-subtitle">跟踪消息数量随时间的变化</div>
</div>
</v-card-text>
</v-card>
<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">总消息数</div>
<div class="stat-number">{{ totalMessages }}</div>
</div>
<div class="stat-box">
<div class="stat-label">平均每天</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">增长率</div>
<div class="stat-number">
<v-icon 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">加载中...</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';
export default {
name: 'MessageStat',
components: {
},
props: ['stat'],
data: () => ({
total_cnt: 0,
select: { state: 'Today', abbr: 'FL' },
items: [
{ state: '过去 1 天', abbr: 'FL' },
totalMessages: '0',
dailyAverage: '0',
growthRate: 0,
loading: false,
selectedTimeRange: { label: '过去 1 天', value: 86400 },
timeRanges: [
{ label: '过去 1 天', value: 86400 },
{ label: '过去 3 天', value: 259200 },
{ label: '过去 7 天', value: 604800 },
{ label: '过去 30 天', value: 2592000 },
],
chartOptions1: {
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: 1
width: 2
},
markers: {
size: 3,
strokeWidth: 2,
hover: {
size: 5,
}
},
tooltip: {
fixed: {
enabled: false
},
theme: 'light',
x: {
show: true,
format: 'yyyy-MM-dd HH:mm'
},
y: {
@@ -75,45 +146,225 @@ export default {
},
labels: {
formatter: function (value) {
return new Date(value).toLocaleString();
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: {
show: true
borderColor: '#f1f1f1',
row: {
colors: ['transparent', 'transparent'],
opacity: 0.2
},
column: {
colors: ['transparent', 'transparent'],
},
padding: {
left: 0,
right: 0
}
}
},
lineChart1: {
series: [
{
name: '消息条数',
data: []
}
]
},
chartSeries: [
{
name: '消息条数',
data: []
}
],
messageTimeSeries: []
}),
watch: {
stat: {
handler: function (val, oldVal) {
val = val.message_time_series
// this.total_cnt = val.message_count
// [[timestamp, cnt], ...]
this.lineChart1.series[0].data = val.map((item) => {
return [new Date(item[0]*1000).getTime(), item[1]];
});
},
deep: true
}
mounted() {
// 初始加载
this.fetchMessageSeries();
},
};
</script>
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('获取消息趋势数据失败:', 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;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.chart-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.chart-subtitle {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.time-select {
max-width: 150px;
font-size: 14px;
}
.chart-stats {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.stat-box {
padding: 12px 16px;
background: #f5f5f5;
border-radius: 8px;
flex: 1;
}
.stat-label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.stat-number {
font-size: 18px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
}
.trend-up .stat-number {
color: #4caf50;
}
.trend-down .stat-number {
color: #f44336;
}
.chart-container {
border-top: 1px solid #f0f0f0;
padding-top: 20px;
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 10;
}
.loading-text {
margin-top: 12px;
font-size: 14px;
color: #666;
}
</style>
@@ -1,37 +1,84 @@
<script setup>
//
</script>
<template>
<v-card elevation="0" class="bg-primary overflow-hidden bubble-shape bubble-primary-shape">
<v-card elevation="1" class="stat-card platform-card">
<v-card-text>
<div class="d-flex align-start mb-3">
<v-btn icon rounded="sm" color="darkprimary" variant="flat">
<v-icon icon="mdi-account-multiple-outline"></v-icon>
</v-btn>
<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">消息平台</div>
<div class="stat-value-wrapper">
<h2 class="stat-value">{{ stat.platform_count || 0 }}</h2>
</div>
<div class="stat-subtitle">已连接的消息平台数量</div>
</div>
</div>
<v-row>
<v-col cols="6">
<h2 class="text-h1 font-weight-medium">
{{ stat.platform_count }}
</h2>
<span class="text-subtitle-1 text-medium-emphasis text-white">消息平台数</span>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'TotalSession',
props: ['stat'],
data: () => ({
stat: {
platform_count: 0
}
}),
name: 'OnlinePlatform',
props: ['stat']
};
</script>
</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,61 +1,190 @@
<script setup lang="ts"></script>
<template>
<v-card elevation="0" class="bg-primary overflow-hidden bubble-shape-sm bubble-primary mb-6">
<v-card-text class="pa-5">
<div class="d-flex align-center gap-3">
<v-btn color="darkprimary" icon rounded="sm" variant="flat">
<v-icon icon="mdi-clock"></v-icon>
</v-btn>
<div>
<h4 class="text-h4 font-weight-medium">{{ stat.running }}</h4>
<span class="text-subtitle-2 text-medium-emphasis text-white">运行时间</span>
<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">运行时间</div>
<h3 class="uptime-value">{{ stat.running || '加载中...' }}</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">在线</span>
</div>
</div>
<v-spacer></v-spacer>
<div>
<v-btn icon rounded="sm" variant="plain">
<v-icon color="black" icon="mdi-stop" size="32"></v-icon>
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
<v-card elevation="0" class="bubble-shape-sm overflow-hidden bubble-warning">
<v-card-text class="pa-5">
<div class="d-flex align-center gap-3">
<v-btn color="lightwarning" icon rounded="sm" variant="flat">
<v-icon icon="mdi-memory"></v-icon>
</v-btn>
<div>
<h4 class="text-h4 font-weight-medium">{{ stat.memory?.process }} / {{ stat.memory?.system }} MiB</h4>
<span class="text-subtitle-2 text-disabled font-weight-medium">占用内存</span>
<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">内存占用</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>
</div>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
</div>
</template>
<script>
export default {
name: 'OnlineTime',
components: {
},
props: ['stat'],
watch: {
},
data: () => ({
stat: {
memory: "Loading",
running: "Loading",
memory: { process: 0, system: 0 },
running: "加载中...",
},
}),
mounted() {
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>
</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,116 +1,253 @@
<script setup>
import { ref, computed } from 'vue';
// chart 1
const chartOptions1 = computed(() => {
return {
chart: {
type: 'area',
height: 95,
fontFamily: `inherit`,
foreColor: '#a1aab2',
sparkline: {
enabled: true
}
},
colors: ['#5e35b1'],
dataLabels: {
enabled: false
},
stroke: {
curve: 'smooth',
width: 1
},
tooltip: {
theme: 'dark',
fixed: {
enabled: false
},
x: {
show: false
},
y: {
title: {
formatter: () => '消息条数 '
}
},
marker: {
show: false
}
}
};
});
// chart 1
const lineChart1 = {
series: [
{
data: [0, 15, 10, 50, 30, 40, 25]
}
]
};
</script>
<template>
<v-card elevation="0">
<v-card variant="outlined">
<v-card-text>
<div class="d-flex align-center">
<h4 class="text-h4 mt-1">平台消息</h4>
<v-card elevation="1" class="platform-stat-card">
<v-card-text>
<div class="platform-header">
<div>
<div class="platform-title">平台消息统计</div>
<div class="platform-subtitle">各平台消息数量分布</div>
</div>
<div class="mt-4">
<v-list lines="two" class="py-0" style="height: 270px;">
</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 v-for="(platform, i) in platforms" :key="i" :value="platform" color="secondary" rounded="sm">
<div class="d-inline-flex align-center justify-space-between w-100">
<div>
<h6 class="text-subtitle-1 text-medium-emphasis font-weight-bold">
{{ platform.name }}
</h6>
</div>
<div class="ml-auto text-subtitle-1 text-medium-emphasis font-weight-bold">{{ platform.count }} </div>
<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"></span>
</div>
</v-list-item>
</v-list>
<div class="text-center mt-3">
<v-btn color="primary" variant="text"
>详情
<template v-slot:append>
<ChevronRightIcon stroke-width="1.5" width="20" />
</template>
</v-btn>
</template>
</v-list-item>
</v-list>
<div class="platform-stats-summary">
<div class="platform-stat-item">
<div class="stat-label">平台数</div>
<div class="stat-value">{{ platforms.length }}</div>
</div>
<v-divider vertical></v-divider>
<div class="platform-stat-item">
<div class="stat-label">最活跃</div>
<div class="stat-value">{{ mostActivePlatform }}</div>
</div>
<v-divider vertical></v-divider>
<div class="platform-stat-item">
<div class="stat-label">总消息占比</div>
<div class="stat-value">{{ topPlatformPercentage }}%</div>
</div>
</div>
</v-card-text>
</v-card>
<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">暂无平台数据</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'PlatformStat',
components: {
},
props: ['stat'],
data: () => ({
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, oldVal) {
this.platforms = val.platform
handler: function (val) {
if (val && val.platform) {
this.platforms = val.platform;
}
},
deep: true,
}
},
data: () => ({
platforms: [
]
}),
mounted() {
methods: {
getPercentage(count) {
return this.totalCount ? (count / this.totalCount) * 100 : 0;
}
}
};
</script>
</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: #333;
}
.platform-subtitle {
font-size: 12px;
color: #666;
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: #f0f0f0;
color: #333;
font-weight: 600;
font-size: 14px;
margin-right: 12px;
}
.top-rank {
background-color: #5e35b1;
color: white;
}
.platform-name {
font-weight: 500;
}
.platform-count {
display: flex;
align-items: center;
}
.count-value {
font-weight: 600;
font-size: 14px;
color: #5e35b1;
margin-right: 4px;
}
.count-label {
font-size: 12px;
color: #666;
}
.platform-stats-summary {
display: flex;
justify-content: space-between;
background-color: #f5f5f5;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
}
.platform-stat-item {
flex: 1;
text-align: center;
}
.stat-label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.stat-value {
font-weight: 600;
color: #333;
}
.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: #999;
margin-top: 16px;
font-size: 14px;
}
</style>
@@ -0,0 +1,89 @@
<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">运行时间</div>
<div class="stat-value-wrapper">
<h2 class="stat-value">{{ formattedTime }}</h2>
</div>
<div class="stat-subtitle">AstrBot 运行时间</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'RunningTime',
props: ['stat'],
computed: {
formattedTime() {
return this.stat?.running || '加载中...';
}
}
};
</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,40 +1,97 @@
<script setup>
//
</script>
<template>
<v-card elevation="0" class="bg-secondary overflow-hidden bubble-shape bubble-secondary-shape">
<v-card elevation="1" class="stat-card message-card">
<v-card-text>
<div class="d-flex align-start mb-3">
<v-btn icon rounded="sm" color="darksecondary" variant="flat">
<v-icon icon="mdi-account-multiple-outline"></v-icon>
</v-btn>
<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">消息总数</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">所有平台发送的消息总计</div>
</div>
</div>
<v-row>
<v-col cols="6">
<h2 class="text-h1 font-weight-medium">
{{ stat.message_count }}
</h2>
<span class="text-subtitle-1 text-medium-emphasis text-white">消息总数</span>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'TotalMessage',
props: ['stat'],
data: () => ({
stat: {
message_count: 0
computed: {
formattedCount() {
const count = this.stat?.message_count;
return count ? count.toLocaleString() : '0';
}
}),
mounted() {
}
};
</script>
</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>