Files
abot/admin/dashboard/templates/index.html

1569 lines
55 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}首页概览 - 机器人管理后台{% endblock %}
{% block content %}
<div class="dashboard-page">
<div class="page-title dashboard-title">
<div class="page-title-main">
<h1>首页概览</h1>
<p>聚焦机器人运行状态、关键指标与近期趋势,先看重点,再看明细。</p>
</div>
<div class="dashboard-title-actions">
<div class="live-badge">
<span class="live-dot"></span>
<span>实时监控中</span>
</div>
</div>
</div>
<el-row :gutter="16" class="hero-row">
<el-col :xl="7" :lg="9" :md="24" :sm="24" :xs="24">
<el-card class="hero-card hero-card--profile" shadow="hover">
<div v-if="currentUser.success" class="hero-profile">
<div class="hero-profile-top">
<div class="hero-avatar-wrap">
<img :src="currentUser.data.avatar" alt="用户头像" class="hero-avatar" />
</div>
<div class="hero-profile-copy">
<div class="hero-eyebrow">当前账号</div>
<h2>{% raw %}{{ currentUser.data.nickname || '未知用户' }}{% endraw %}</h2>
<p>{% raw %}{{ currentUser.data.signature || '正在管理机器人后台与系统运行状态。' }}{% endraw %}</p>
</div>
</div>
<div class="hero-profile-meta">
<div class="meta-item">
<span class="meta-label">微信 ID</span>
<span class="meta-value">{% raw %}{{ currentUser.data.wx_id || '-' }}{% endraw %}</span>
</div>
<div class="meta-item" v-if="currentUser.data.mobile">
<span class="meta-label">手机号</span>
<span class="meta-value">{% raw %}{{ currentUser.data.mobile }}{% endraw %}</span>
</div>
<div class="meta-item">
<span class="meta-label">系统运行</span>
<span class="meta-value">{% raw %}{{ formattedUptime }}{% endraw %}</span>
</div>
</div>
</div>
<div v-else class="user-info-empty hero-empty">
<i class="el-icon-warning-outline"></i>
<div>{% raw %}{{ currentUser.message || '未获取到用户信息' }}{% endraw %}</div>
</div>
</el-card>
</el-col>
<el-col :xl="17" :lg="15" :md="24" :sm="24" :xs="24">
<el-row :gutter="16" class="metric-grid">
<el-col :span="8">
<el-card class="metric-card metric-card--primary" shadow="hover">
<div class="metric-label">总调用次数</div>
<div class="metric-value">{% raw %}{{ totalCalls }}{% endraw %}</div>
<div class="metric-footnote">统计周期内累计请求量</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card" shadow="hover">
<div class="metric-label">成功率</div>
<div class="metric-value">{% raw %}{{ successRate.toFixed(2) }}{% endraw %}%</div>
<div class="metric-footnote">请求成功占比</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card" shadow="hover">
<div class="metric-label">平均响应时间</div>
<div class="metric-value">{% raw %}{{ avgResponseTime.toFixed(2) }}{% endraw %}<span class="metric-unit">ms</span></div>
<div class="metric-footnote">接口平均响应耗时</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card" shadow="hover">
<div class="metric-label">活跃用户数</div>
<div class="metric-value">{% raw %}{{ activeUsers }}{% endraw %}</div>
<div class="metric-footnote">近期实际触发用户</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card" shadow="hover">
<div class="metric-label">活跃群组数</div>
<div class="metric-value">{% raw %}{{ activeGroups }}{% endraw %}</div>
<div class="metric-footnote">近期开启互动群组</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card metric-card--soft" shadow="hover">
<div class="metric-label">系统运行时间</div>
<div class="metric-value">{% raw %}{{ formattedUptime }}{% endraw %}</div>
<div class="metric-footnote">当前实例持续在线时长</div>
</el-card>
</el-col>
</el-row>
</el-col>
</el-row>
<el-row :gutter="16" class="metric-extended-row">
<el-col :span="8">
<el-card class="metric-card metric-card--soft" shadow="hover">
<div class="metric-label">新增用户数</div>
<div class="metric-value">{% raw %}{{ newUsers }}{% endraw %}</div>
<div class="metric-footnote">统计周期内首次触发用户</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card" shadow="hover">
<div class="metric-label">群渗透率</div>
<div class="metric-value">{% raw %}{{ avgGroupPenetration.toFixed(2) }}{% endraw %}%</div>
<div class="metric-footnote">活跃群触发人数占成员比例(均值)</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card metric-card--soft" shadow="hover">
<div class="metric-label">群健康分</div>
<div class="metric-value">{% raw %}{{ groupHealthScore.toFixed(2) }}{% endraw %}</div>
<div class="metric-footnote">综合成功率与响应时延评分</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="health-row">
<el-col :span="24">
<el-card class="health-overview-card" shadow="hover">
<div class="section-heading section-heading--stack">
<div>
<h3>系统健康快照</h3>
<p>把连接状态、插件运行、异常数量与转图运行时集中到一个面板里。</p>
</div>
<div class="health-overview-meta">
<span class="health-overview-meta__label">最近刷新</span>
<span class="health-overview-meta__value">{% raw %}{{ healthSummary.timestamp || '-' }}{% endraw %}</span>
</div>
</div>
<div class="health-grid">
<div v-for="card in healthCards" :key="card.key" class="health-item" :class="`health-item--${card.status}`">
<div class="health-item__head">
<span class="health-item__title">{% raw %}{{ card.title }}{% endraw %}</span>
<span class="health-item__badge" :class="`health-item__badge--${card.status}`">
{% raw %}{{ getHealthStatusText(card.status) }}{% endraw %}
</span>
</div>
<div class="health-item__value">{% raw %}{{ card.value }}{% endraw %}</div>
<div class="health-item__summary">{% raw %}{{ card.summary }}{% endraw %}</div>
<div v-if="card.extra" class="health-item__extra">{% raw %}{{ card.extra }}{% endraw %}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="stats-highlight-row">
<el-col :span="8">
<el-card class="insight-card" shadow="hover">
<div class="section-heading">
<div>
<h3>热门用户</h3>
<p>最近最活跃的高频调用用户</p>
</div>
</div>
<el-table :data="topUsers" style="width: 100%">
<el-table-column label="用户信息" min-width="210">
<template slot-scope="scope">
<div class="rank-cell">
<span class="rank-index">{% raw %}{{ scope.$index + 1 }}{% endraw %}</span>
<div class="rank-copy">
<div class="rank-title">{% raw %}{{ scope.row.user_name || scope.row.user_id }}{% endraw %}</div>
<div class="rank-subtitle">ID: {% raw %}{{ scope.row.user_id }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="total_calls" label="调用次数" width="100" align="right"></el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="insight-card" shadow="hover">
<div class="section-heading">
<div>
<h3>热门群组</h3>
<p>最近互动最活跃的群组</p>
</div>
</div>
<el-table :data="topGroups" style="width: 100%">
<el-table-column label="群组信息" min-width="210">
<template slot-scope="scope">
<div class="rank-cell">
<span class="rank-index">{% raw %}{{ scope.$index + 1 }}{% endraw %}</span>
<div class="rank-copy">
<div class="rank-title">{% raw %}{{ scope.row.group_name || scope.row.group_id }}{% endraw %}</div>
<div class="rank-subtitle">ID: {% raw %}{{ scope.row.group_id }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="total_calls" label="调用次数" width="100" align="right"></el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="insight-card" shadow="hover">
<div class="section-heading">
<div>
<h3>热门插件</h3>
<p>最受欢迎的能力模块</p>
</div>
</div>
<el-table :data="topPlugins" style="width: 100%">
<el-table-column label="插件名称" min-width="210">
<template slot-scope="scope">
<div class="rank-cell rank-cell--simple">
<span class="rank-index">{% raw %}{{ scope.$index + 1 }}{% endraw %}</span>
<div class="rank-copy">
<div class="rank-title">{% raw %}{{ scope.row.plugin_name }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="total_calls" label="调用次数" width="100" align="right"></el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="chart-row">
<el-col :span="16">
<el-card class="chart-card chart-card--large" shadow="hover">
<div slot="header" class="chart-card-header">
<div>
<h3>使用趋势</h3>
<p>总调用、成功调用、失败调用随时间变化</p>
</div>
</div>
<div class="chart-container chart-container--large">
<canvas id="trendChart" width="800" height="180"></canvas>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="chart-card" shadow="hover">
<div slot="header" class="chart-card-header">
<div>
<h3>成功率分析</h3>
<p>插件维度稳定性表现</p>
</div>
</div>
<div class="chart-container chart-container--panel">
<canvas id="successRateChart"></canvas>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="chart-row">
<el-col :span="12">
<el-card class="chart-card" shadow="hover">
<div slot="header" class="chart-card-header">
<div>
<h3>插件使用排行</h3>
<p>按调用次数排序的前十插件</p>
</div>
</div>
<div class="chart-container chart-container--panel">
<canvas id="pluginChart"></canvas>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="chart-card" shadow="hover" v-loading="hourlyMessageTrendLoading">
<div slot="header" class="chart-card-header chart-card-header--split">
<div>
<h3>按小时聊天趋势</h3>
<p>观察群组消息在一天内的分布节奏</p>
</div>
<div class="chart-toolbar">
<el-select v-model="selectedGroupForHourlyTrend" placeholder="选择群组" style="width: 190px;" @change="loadHourlyMessageTrend">
<el-option v-for="group in groups" :key="group.group_id" :label="group.group_name" :value="group.group_id"></el-option>
</el-select>
<el-select v-model="hourlyTrendDays" placeholder="选择时间范围" style="width: 120px;" @change="loadHourlyMessageTrend">
<el-option :value="1" label="最近1天"></el-option>
<el-option :value="2" label="最近2天"></el-option>
<el-option :value="3" label="最近3天"></el-option>
</el-select>
</div>
</div>
<div class="chart-container chart-container--panel">
<canvas id="hourlyMessageTrendChart" width="800" height="180"></canvas>
</div>
</el-card>
</el-col>
</el-row>
</div>
{% endblock %}
{% block scripts %}
<script>
new Vue({
el: '#app',
mixins: [baseApp],
data() {
return {
totalCalls: 0,
successRate: 0,
activeUsers: 0,
activeGroups: 0,
newUsers: 0,
avgResponseTime: 0,
avgGroupPenetration: 0,
groupHealthScore: 0,
topUsers: [],
topGroups: [],
topPlugins: [],
pluginStats: [],
charts: {},
currentUser: {
success: false,
message: '加载中...',
data: {}
},
systemInfo: {
os: '加载中...',
os_version: '',
python_version: '加载中...',
cpu_usage: 0,
memory_usage: 0,
disk_usage: 0,
uptime: 0,
timestamp: '-'
},
healthSummary: {
timestamp: '-',
robot: {
status: 'warning',
running: false,
nickname: '',
wxid: '',
summary: '加载中...'
},
plugins: {
status: 'warning',
total: 0,
running: 0,
error: 0,
summary: '加载中...'
},
errors: {
status: 'warning',
recent_24h_count: 0,
summary: '加载中...'
},
infrastructure: {
status: 'warning',
summary: '加载中...',
mysql: {
status: 'warning',
summary: '加载中...'
},
redis: {
status: 'warning',
summary: '加载中...'
}
},
ai_runtime: {
status: 'warning',
total_calls: 0,
failed_calls: 0,
avg_latency_ms: 0,
summary: '加载中...',
last_call: {}
},
md2img: {
status: 'warning',
healthy: false,
runtime_ready: false,
browser_ready: false,
summary: '加载中...'
}
},
groups: [],
selectedGroupForHourlyTrend: '',
hourlyTrendDays: 1,
hourlyMessageTrendData: {
hours: [],
counts: []
},
hourlyMessageTrendLoading: true,
charts: {
pluginChart: null,
successRateChart: null,
trendChart: null,
cpuChart: null,
memoryChart: null,
diskChart: null,
hourlyMessageTrendChart: null
}
}
},
computed: {
formattedUptime() {
const seconds = this.systemInfo.uptime;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
let result = '';
if (days > 0) result += days + 'D ';
if (hours > 0 || days > 0) result += hours + 'H ';
result += minutes + 'M';
return result;
},
healthCards() {
// 首页健康卡片统一在这里做展示层映射,模板只负责渲染,避免 HTML 中堆太多业务判断。
const robot = this.healthSummary.robot || {};
const plugins = this.healthSummary.plugins || {};
const errors = this.healthSummary.errors || {};
const infrastructure = this.healthSummary.infrastructure || {};
const aiRuntime = this.healthSummary.ai_runtime || {};
const md2img = this.healthSummary.md2img || {};
return [
{
key: 'robot',
title: '机器人连接',
status: robot.status || 'warning',
value: robot.running ? '在线' : '离线',
summary: robot.summary || '暂无状态',
extra: robot.wxid ? `标识:${robot.wxid}` : ''
},
{
key: 'plugins',
title: '插件运行',
status: plugins.status || 'warning',
value: `${plugins.running || 0} / ${plugins.total || 0}`,
summary: plugins.summary || '暂无状态',
extra: `异常 ${plugins.error || 0}`
},
{
key: 'errors',
title: '最近异常',
status: errors.status || 'warning',
value: `${errors.recent_24h_count || 0}`,
summary: errors.summary || '暂无状态',
extra: '统计窗口:近 24 小时'
},
{
key: 'infrastructure',
title: '基础设施',
status: infrastructure.status || 'warning',
value: infrastructure.status === 'healthy' ? '正常' : '异常',
summary: infrastructure.summary || '暂无状态',
extra: `MySQL${((infrastructure.mysql || {}).status === 'healthy') ? '正常' : '异常'} / Redis${((infrastructure.redis || {}).status === 'healthy') ? '正常' : '异常'}`
},
{
key: 'ai_runtime',
title: 'AI 运行态',
status: aiRuntime.status || 'warning',
value: `${aiRuntime.avg_latency_ms || 0} ms`,
summary: aiRuntime.summary || '暂无状态',
extra: `最近调用 ${aiRuntime.total_calls || 0} 次,失败 ${aiRuntime.failed_calls || 0}`
},
{
key: 'md2img',
title: 'Markdown 转图',
status: md2img.status || 'warning',
value: md2img.healthy ? '就绪' : '待检查',
summary: md2img.summary || '暂无状态',
extra: `Runtime ${md2img.runtime_ready ? '已就绪' : '未就绪'} / Browser ${md2img.browser_ready ? '已就绪' : '未就绪'}`
}
];
}
},
mounted() {
this.currentView = '1';
this.loadData();
this.refreshRuntimeSnapshot();
this.loadCurrentUserInfo();
this.loadGroups();
this.systemInfoTimer = setInterval(this.refreshRuntimeSnapshot, 30000);
},
beforeDestroy() {
if (this.systemInfoTimer) {
clearInterval(this.systemInfoTimer);
}
},
methods: {
loadData() {
const days = parseInt(this.timeRange);
this.loadDashboardSummary(days);
this.loadPluginStats(days);
this.loadPluginTrend(days);
},
refreshRuntimeSnapshot() {
// 系统资源与健康聚合都属于运行态信息,统一刷新可以降低后续维护成本。
this.loadSystemInfo();
this.loadSystemHealth();
},
loadSystemInfo() {
axios.get('/api/system_info')
.then(response => {
if (response.data.success) {
this.systemInfo = response.data.data;
this.$nextTick(() => {
this.renderSystemCharts();
});
}
})
.catch(error => {
console.error('加载系统信息出错:', error);
});
},
loadSystemHealth() {
axios.get('/api/system_health_summary')
.then(response => {
if (response.data.success) {
this.healthSummary = response.data.data || this.healthSummary;
}
})
.catch(error => {
console.error('加载系统健康摘要出错:', error);
});
},
renderSystemCharts() {
this.renderPieChart('cpuChart', this.systemInfo.cpu_usage, 'CPU使用率');
this.renderPieChart('memoryChart', this.systemInfo.memory_usage, '内存使用率');
this.renderPieChart('diskChart', this.systemInfo.disk_usage, '磁盘使用率');
},
getHealthStatusText(status) {
const statusMap = {
healthy: '健康',
warning: '关注',
danger: '异常'
};
return statusMap[status] || '未知';
},
renderPieChart(chartId, usageValue, label) {
const ctx = document.getElementById(chartId);
if (!ctx) return;
const context = ctx.getContext('2d');
if (this.charts[chartId]) {
this.charts[chartId].destroy();
}
let color = 'rgba(16, 185, 129, 1)';
if (usageValue > 70) {
color = 'rgba(239, 68, 68, 1)';
} else if (usageValue > 50) {
color = 'rgba(245, 158, 11, 1)';
}
this.charts[chartId] = new Chart(context, {
type: 'doughnut',
data: {
labels: ['已使用', '可用'],
datasets: [{
data: [usageValue, 100 - usageValue],
backgroundColor: [color, 'rgba(226, 232, 240, 0.85)'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
cutout: '72%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return context.label + ': ' + context.raw + '%';
}
}
}
}
}
});
},
loadCurrentUserInfo() {
axios.get('/api/current_user_info')
.then(response => {
this.currentUser = response.data;
})
.catch(error => {
console.error('加载当前用户信息出错:', error);
this.currentUser = {
success: false,
message: '获取用户信息失败',
data: {}
};
});
},
getProgressStatus(value) {
if (value > 70) {
return 'exception';
} else if (value > 50) {
return 'warning';
} else {
return 'success';
}
},
loadDashboardSummary(days) {
axios.get(`/api/dashboard_summary?days=${days}`)
.then(response => {
if (response.data.success) {
const data = response.data.data;
this.totalCalls = parseInt(data.total_calls) || 0;
this.successRate = parseFloat(data.success_rate) || 0;
this.activeUsers = data.active_users || 0;
this.activeGroups = data.active_groups || 0;
this.newUsers = data.new_users || 0;
this.avgResponseTime = parseFloat(data.avg_response_time) || 0;
this.avgGroupPenetration = parseFloat(data.avg_group_penetration) || 0;
this.groupHealthScore = parseFloat(data.group_health_score) || 0;
this.topUsers = data.top_users || [];
this.topGroups = data.top_groups || [];
this.topPlugins = data.top_plugins || [];
}
})
.catch(error => {
console.error('加载仪表盘摘要数据出错:', error);
this.$message.error('加载仪表盘摘要数据出错');
});
},
loadPluginStats(days) {
axios.get(`/api/plugin_stats?days=${days}`)
.then(response => {
if (response.data.success) {
this.pluginStats = response.data.data || [];
this.$nextTick(() => {
this.renderPluginChart();
this.renderSuccessRateChart();
});
}
})
.catch(error => {
console.error('加载插件统计数据出错:', error);
this.$message.error('加载插件统计数据出错');
});
},
loadPluginTrend(days) {
axios.get(`/api/plugin_trend?days=${days}`)
.then(response => {
if (response.data.success) {
const trendData = response.data.data || [];
this.$nextTick(() => {
this.renderTrendChart(trendData);
});
}
})
.catch(error => {
console.error('加载插件趋势数据出错:', error);
this.$message.error('加载插件趋势数据出错');
});
},
renderPluginChart() {
const ctx = document.getElementById('pluginChart').getContext('2d');
if (this.charts.pluginChart) {
this.charts.pluginChart.destroy();
}
const sortedData = [...this.pluginStats].sort((a, b) => b.total_calls - a.total_calls).slice(0, 10);
const labels = sortedData.map(item => `${item.plugin_name}(${item.command})`);
const data = sortedData.map(item => item.total_calls);
this.charts.pluginChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '调用次数',
data: data,
backgroundColor: 'rgba(99, 102, 241, 0.72)',
borderColor: 'rgba(79, 70, 229, 1)',
borderRadius: 10,
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(148, 163, 184, 0.12)'
}
},
x: {
grid: {
display: false
}
}
}
}
});
},
renderSuccessRateChart() {
const ctx = document.getElementById('successRateChart').getContext('2d');
if (this.charts.successRateChart) {
this.charts.successRateChart.destroy();
}
const sortedData = [...this.pluginStats]
.filter(item => item.total_calls > 0)
.sort((a, b) => (b.success_calls / b.total_calls) - (a.success_calls / a.total_calls))
.slice(0, 10);
const labels = sortedData.map(item => `${item.plugin_name}(${item.command})`);
const data = sortedData.map(item => (item.success_calls / item.total_calls) * 100);
this.charts.successRateChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '成功率(%)',
data: data,
backgroundColor: 'rgba(16, 185, 129, 0.74)',
borderColor: 'rgba(5, 150, 105, 1)',
borderRadius: 10,
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
max: 100,
grid: {
color: 'rgba(148, 163, 184, 0.12)'
}
},
x: {
grid: {
display: false
}
}
}
}
});
},
renderTrendChart(trendData) {
const ctx = document.getElementById('trendChart').getContext('2d');
if (this.charts && this.charts.trendChart) {
this.charts.trendChart.destroy();
}
if (!this.charts) {
this.charts = {};
}
const labels = trendData.map(item => item.date);
const totalData = trendData.map(item => parseInt(item.total_calls) || 0);
const successData = trendData.map(item => parseInt(item.success_calls) || 0);
const errorData = trendData.map(item => parseInt(item.failed_calls) || 0);
this.charts.trendChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '总调用',
data: totalData,
fill: false,
backgroundColor: 'rgba(79, 70, 229, 0.2)',
borderColor: 'rgba(79, 70, 229, 1)',
tension: 0.32,
borderWidth: 3,
pointRadius: 2
},
{
label: '成功调用',
data: successData,
fill: false,
backgroundColor: 'rgba(16, 185, 129, 0.2)',
borderColor: 'rgba(16, 185, 129, 1)',
tension: 0.32,
borderWidth: 3,
pointRadius: 2
},
{
label: '失败调用',
data: errorData,
fill: false,
backgroundColor: 'rgba(239, 68, 68, 0.2)',
borderColor: 'rgba(239, 68, 68, 1)',
tension: 0.32,
borderWidth: 3,
pointRadius: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(148, 163, 184, 0.12)'
}
},
x: {
grid: {
display: false
}
}
}
}
});
},
loadGroups() {
axios.get('/api/groups')
.then(response => {
if (response.data && response.data.groups) {
this.groups = response.data.groups;
if (this.groups.length > 0) {
this.selectedGroupForHourlyTrend = this.groups[0].group_id;
this.loadHourlyMessageTrend();
}
}
})
.catch(error => {
console.error('加载群组列表失败:', error);
this.$message.error('加载群组列表失败');
});
},
loadHourlyMessageTrend() {
if (!this.selectedGroupForHourlyTrend) return;
this.hourlyMessageTrendLoading = true;
axios.get('/api/hourly_message_trend', {
params: {
group_id: this.selectedGroupForHourlyTrend,
days: this.hourlyTrendDays
}
})
.then(response => {
if (response.data && response.data.success) {
this.hourlyMessageTrendData = response.data.data;
this.$nextTick(() => {
this.renderHourlyMessageTrendChart();
});
} else {
this.$message.error(response.data.error || '加载按小时聊天趋势数据失败');
}
this.hourlyMessageTrendLoading = false;
})
.catch(error => {
console.error('加载按小时聊天趋势数据失败:', error);
this.$message.error('加载按小时聊天趋势数据失败');
this.hourlyMessageTrendLoading = false;
});
},
renderHourlyMessageTrendChart() {
const ctx = document.getElementById('hourlyMessageTrendChart').getContext('2d');
if (this.charts.hourlyMessageTrendChart) {
this.charts.hourlyMessageTrendChart.destroy();
}
this.charts.hourlyMessageTrendChart = new Chart(ctx, {
type: 'line',
data: {
labels: this.hourlyMessageTrendData.hours,
datasets: [{
label: '消息数量',
data: this.hourlyMessageTrendData.counts,
fill: true,
backgroundColor: 'rgba(59, 130, 246, 0.10)',
borderColor: 'rgba(59, 130, 246, 1)',
tension: 0.32,
borderWidth: 3,
pointRadius: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(148, 163, 184, 0.12)'
}
},
x: {
ticks: {
maxRotation: 45,
minRotation: 45
},
grid: {
display: false
}
}
},
plugins: {
title: {
display: true,
text: `${this.hourlyMessageTrendData.group_name || '未知群组'} 的聊天趋势`
},
legend: {
display: false
}
}
}
});
}
}
});
</script>
{% endblock %}
{% block styles %}
<style>
.dashboard-title {
margin-bottom: 18px;
}
.dashboard-page .el-row {
margin-left: 0 !important;
margin-right: 0 !important;
}
.dashboard-page .el-row > .el-col {
padding-left: 8px !important;
padding-right: 8px !important;
}
.dashboard-title-actions {
display: flex;
align-items: center;
gap: 12px;
}
.live-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.18);
color: #475569;
font-size: 13px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12);
}
.hero-row,
.health-row,
.metric-extended-row,
.stats-highlight-row,
.chart-row {
margin-bottom: 16px;
}
.health-overview-card .el-card__body {
padding: 20px !important;
}
.section-heading--stack {
align-items: flex-start;
}
.health-overview-meta {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 14px;
background: rgba(248, 250, 252, 0.92);
border: 1px solid rgba(148, 163, 184, 0.12);
color: #64748b;
font-size: 13px;
}
.health-overview-meta__label {
color: #94a3b8;
}
.health-overview-meta__value {
color: #0f172a;
font-weight: 600;
}
.health-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 18px;
}
.health-item {
padding: 18px;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.88));
min-height: 156px;
}
.health-item--healthy {
box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.10);
}
.health-item--warning {
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.10);
}
.health-item--danger {
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.10);
}
.health-item__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.health-item__title {
font-size: 14px;
color: #475569;
font-weight: 600;
}
.health-item__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 50px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.health-item__badge--healthy {
color: #047857;
background: rgba(16, 185, 129, 0.12);
}
.health-item__badge--warning {
color: #b45309;
background: rgba(245, 158, 11, 0.14);
}
.health-item__badge--danger {
color: #b91c1c;
background: rgba(239, 68, 68, 0.14);
}
.health-item__value {
font-size: 28px;
line-height: 1.1;
font-weight: 700;
color: #0f172a;
margin-bottom: 12px;
}
.health-item__summary {
font-size: 13px;
line-height: 1.7;
color: #475569;
}
.health-item__extra {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed rgba(148, 163, 184, 0.22);
font-size: 12px;
color: #94a3b8;
word-break: break-all;
}
.stats-highlight-row {
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.stats-highlight-row > .el-col {
display: flex;
}
.hero-card {
min-height: 228px;
height: 100%;
}
.hero-card--profile {
background: linear-gradient(145deg, rgba(79,70,229,0.94), rgba(59,130,246,0.82)) !important;
color: #fff !important;
border: none !important;
box-shadow: 0 24px 48px rgba(79, 70, 229, 0.18) !important;
}
.hero-card--profile .el-card__body {
padding: 22px !important;
}
.hero-profile {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 20px;
}
.hero-profile-top {
display: flex;
align-items: flex-start;
gap: 18px;
}
.hero-avatar-wrap {
width: 76px;
height: 76px;
flex-shrink: 0;
padding: 3px;
border-radius: 24px;
background: rgba(255,255,255,0.16);
}
.hero-avatar {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 20px;
background: rgba(255,255,255,0.22);
}
.hero-profile-copy {
flex: 1;
min-width: 0;
}
.hero-eyebrow {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.8;
margin-bottom: 8px;
}
.hero-profile-copy h2 {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
color: #fff;
}
.hero-profile-copy p {
color: rgba(255,255,255,0.80);
line-height: 1.6;
font-size: 14px;
}
.hero-profile-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.meta-item {
padding: 12px 14px;
border-radius: 16px;
background: rgba(255,255,255,0.14);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.meta-label {
display: block;
font-size: 12px;
color: rgba(255,255,255,0.72);
margin-bottom: 6px;
}
.meta-value {
display: block;
font-size: 14px;
font-weight: 600;
color: #fff;
word-break: break-all;
}
.metric-grid .el-col {
margin-bottom: 16px;
display: flex;
}
.metric-extended-row > .el-col {
display: flex;
}
.metric-card {
min-height: 106px;
width: 100%;
height: 100%;
}
.metric-card .el-card__body {
padding: 18px !important;
}
.metric-card--primary {
background: linear-gradient(180deg, rgba(79,70,229,0.10), rgba(255,255,255,0.94)) !important;
}
.metric-card--soft {
background: linear-gradient(180deg, rgba(59,130,246,0.08), rgba(255,255,255,0.94)) !important;
}
.metric-label {
font-size: 13px;
color: #64748b;
margin-bottom: 14px;
}
.metric-value {
font-size: 30px;
line-height: 1;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.02em;
margin-bottom: 12px;
}
.metric-unit {
font-size: 14px;
color: #64748b;
margin-left: 6px;
}
.metric-footnote {
font-size: 12px;
color: #94a3b8;
}
.insight-card .el-card__body {
padding-top: 12px !important;
}
.insight-card {
width: 100%;
height: 100%;
}
.section-heading,
.chart-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.section-heading h3,
.chart-card-header h3 {
font-size: 17px;
font-weight: 700;
color: #0f172a;
margin-bottom: 4px;
}
.section-heading p,
.chart-card-header p {
font-size: 13px;
color: #64748b;
}
.chart-card-header--split {
align-items: flex-start;
}
.chart-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.chart-card--large {
min-height: 390px;
}
.chart-container {
position: relative;
background: linear-gradient(180deg, rgba(248,250,252,0.72), rgba(255,255,255,0.96));
border: 1px solid rgba(148,163,184,0.12);
border-radius: 18px;
padding: 14px;
}
.chart-container--large {
height: 300px;
}
.chart-container--panel {
height: 300px;
}
.rank-cell {
display: flex;
align-items: center;
gap: 12px;
}
.rank-index {
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(79,70,229,0.10);
color: #4f46e5;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.rank-copy {
min-width: 0;
}
.rank-title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rank-subtitle {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hero-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
flex-direction: column;
gap: 8px;
color: rgba(255,255,255,0.85);
}
.hero-empty i {
font-size: 24px;
}
@media (max-width: 1200px) {
.hero-row > .el-col,
.health-row > .el-col,
.metric-extended-row > .el-col,
.stats-highlight-row > .el-col,
.chart-row > .el-col {
width: 100% !important;
}
.metric-grid > .el-col {
width: 50% !important;
}
.health-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.dashboard-title {
flex-direction: column;
align-items: flex-start;
}
.hero-row,
.health-row,
.metric-extended-row,
.stats-highlight-row,
.chart-row {
margin-bottom: 12px;
}
.hero-profile-top {
flex-direction: column;
align-items: flex-start;
}
.hero-profile-meta {
grid-template-columns: 1fr;
}
.chart-card-header,
.chart-card-header--split,
.section-heading {
flex-direction: column;
align-items: flex-start;
}
.chart-toolbar {
width: 100%;
}
.chart-toolbar .el-select {
width: 100% !important;
}
.metric-grid > .el-col {
width: 100% !important;
}
.health-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.dashboard-page {
overflow-x: hidden;
}
.dashboard-title {
margin-bottom: 14px;
}
.page-title-main h1,
.page-title-main h2 {
font-size: 22px;
}
.page-title-main p {
font-size: 13px;
}
.live-badge {
width: 100%;
justify-content: center;
}
.hero-card,
.chart-card,
.insight-card,
.metric-card,
.health-item {
min-height: auto;
}
.hero-card--profile .el-card__body,
.metric-card .el-card__body {
padding: 16px !important;
}
.hero-avatar-wrap {
width: 64px;
height: 64px;
}
.hero-profile-copy h2 {
font-size: 20px;
}
.metric-value {
font-size: 24px;
}
.chart-container--large,
.chart-container--panel {
height: 260px;
}
.rank-cell {
align-items: flex-start;
}
.rank-title,
.rank-subtitle {
white-space: normal;
}
.el-table {
font-size: 12px;
}
}
@media (max-width: 480px) {
.dashboard-page .el-row > .el-col {
padding-left: 0 !important;
padding-right: 0 !important;
}
.hero-row,
.health-row,
.metric-extended-row,
.stats-highlight-row,
.chart-row {
margin-bottom: 10px;
}
.hero-card--profile .el-card__body,
.metric-card .el-card__body,
.insight-card .el-card__body {
padding: 14px !important;
}
.metric-label,
.metric-footnote,
.section-heading p,
.chart-card-header p {
font-size: 12px;
}
.health-overview-card .el-card__body {
padding: 14px !important;
}
.health-item {
padding: 14px;
}
.health-item__value {
font-size: 24px;
}
.chart-container--large,
.chart-container--panel {
height: 220px;
}
}
</style>
{% endblock %}