feat: add group detail dashboard insights

This commit is contained in:
liuwei
2026-04-13 11:04:20 +08:00
parent e2b19c0614
commit ec6c1308db
5 changed files with 962 additions and 2 deletions

View File

@@ -2,7 +2,7 @@ from flask import Blueprint, render_template, jsonify, request, current_app
from .auth import login_required
from loguru import logger
from utils.robot_cmd.robot_command import GroupBotManager, Feature, PermissionStatus
from datetime import datetime
from datetime import datetime, timedelta
# 创建机器人管理蓝图
robot_bp = Blueprint('robot', __name__, url_prefix='/robot')
@@ -222,6 +222,233 @@ def api_group_message_trend(group_id):
return jsonify({'success': False, 'error': str(e)}), 500
@robot_bp.route('/api/group/<group_id>/detail')
@login_required
def api_group_detail(group_id):
"""群组详情聚合接口"""
try:
server = current_app.dashboard_server
now = datetime.now()
last_24h = now - timedelta(days=1)
last_7d = now - timedelta(days=7)
last_30d = now - timedelta(days=30)
group_name = server.contact_manager.get_nickname(group_id) or group_id
permission_map = GroupBotManager.list_group_permissions(group_id)
enabled_features = [
feature.description
for feature, status in permission_map.items()
if status == PermissionStatus.ENABLED
]
member_summary = server.contact_db.get_group_member_summary(group_id, inactive_days=30)
inactive_members = server.contact_db.get_inactive_members_rank(group_id, days=30, limit=8)
message_stats_24h = server.message_storage.get_message_stats_by_date_range(group_id, last_24h, now)
message_stats_7d = server.message_storage.get_message_stats_by_date_range(group_id, last_7d, now)
message_stats_30d = server.message_storage.get_message_stats_by_date_range(group_id, last_30d, now)
speaker_ranking = server.message_storage.get_group_member_message_ranking(group_id, last_30d, now, limit=10)
plugin_summary = server.stats_db.get_group_plugin_summary(group_id, days=30)
plugin_stats = server.stats_db.get_group_plugin_stats(group_id, days=30, limit=8)
last_message = server.message_storage.get_group_last_message(group_id)
trend_data = server.message_storage.get_message_trend(group_id, 14)
hourly_distribution = server.message_storage.get_group_hourly_distribution(group_id, 30)
for item in inactive_members:
wxid = item.get("wxid")
item["display_name"] = server.contact_manager.get_group_name(group_id, wxid) or item.get("nick_name") or wxid
for item in speaker_ranking:
sender = item.get("sender")
item["display_name"] = server.contact_manager.get_group_name(group_id, sender) or sender
total_members = member_summary.get("in_group_members") or member_summary.get("total_members") or 0
active_30d = member_summary.get("active_30d_members", 0)
zombie_count = member_summary.get("inactive_members", 0)
msg_30d_total = message_stats_30d.get("total_count", 0)
plugin_total_calls = plugin_summary.get("total_calls", 0)
health_score = 100
if total_members > 0:
health_score -= min(35, round((zombie_count / total_members) * 35))
health_score -= min(25, round((1 - min(active_30d / total_members, 1)) * 25))
if msg_30d_total == 0:
health_score -= 25
elif msg_30d_total < 50:
health_score -= 12
if plugin_total_calls == 0:
health_score -= 10
health_score = max(0, min(100, health_score))
trend_dates = []
trend_counts = []
for item in trend_data:
date_val = item.get("date")
if isinstance(date_val, datetime):
date_str = date_val.strftime('%Y-%m-%d')
else:
date_str = str(date_val)
trend_dates.append(date_str)
trend_counts.append(int(item.get("message_count") or 0))
hourly_distribution_sorted = sorted(
hourly_distribution,
key=lambda x: x.get("message_count", 0),
reverse=True
)
peak_hours = [
{
"hour": item.get("hour", 0),
"label": f"{int(item.get('hour', 0)):02d}:00-{(int(item.get('hour', 0)) + 1) % 24:02d}:00",
"message_count": item.get("message_count", 0),
}
for item in hourly_distribution_sorted[:3]
if item.get("message_count", 0) > 0
]
plugin_success_rate = (
round(plugin_summary.get("success_calls", 0) / plugin_total_calls * 100, 1)
if plugin_total_calls else 0
)
zombie_ratio = round((zombie_count / total_members) * 100, 1) if total_members else 0
active_ratio = round((active_30d / total_members) * 100, 1) if total_members else 0
operation_suggestions = []
if zombie_ratio >= 50:
operation_suggestions.append({
"level": "danger",
"title": "僵尸成员偏多,建议分层清理或召回",
"desc": f"当前僵尸成员占比 {zombie_ratio}% ,建议优先筛查 30 天未发言成员,结合群定位决定清理、激活或转移。"
})
elif zombie_ratio >= 30:
operation_suggestions.append({
"level": "warning",
"title": "沉默成员较多,建议做激活动作",
"desc": f"当前僵尸成员占比 {zombie_ratio}% ,可以通过签到、抽奖、任务或话题互动做一次激活。"
})
if active_ratio < 20:
operation_suggestions.append({
"level": "danger",
"title": "活跃覆盖率偏低,群内容可能只被少数人消费",
"desc": f"近30天活跃覆盖率仅 {active_ratio}% ,建议增加低门槛互动,如投票、签到、接龙、关键词触发。"
})
elif active_ratio < 40:
operation_suggestions.append({
"level": "warning",
"title": "活跃度中等,可继续扩大参与面",
"desc": f"近30天活跃覆盖率为 {active_ratio}% ,建议在高峰时段安排固定互动,提高更多成员参与率。"
})
if plugin_total_calls == 0:
operation_suggestions.append({
"level": "info",
"title": "插件渗透率为 0建议补一次功能引导",
"desc": "这个群近30天没有插件调用建议置顶菜单、欢迎语或群公告里给出可用指令示例。"
})
elif plugin_summary.get("plugin_count", 0) <= 2:
operation_suggestions.append({
"level": "info",
"title": "插件使用面偏窄,可以扩展场景",
"desc": f"近30天仅使用了 {plugin_summary.get('plugin_count', 0)} 个插件,建议根据群定位补充娱乐、工具或运营型插件。"
})
if plugin_total_calls > 0 and plugin_success_rate < 85:
operation_suggestions.append({
"level": "warning",
"title": "插件成功率偏低,建议检查指令体验",
"desc": f"近30天插件成功率 {plugin_success_rate}% ,可排查失败插件、异常命令和提示文案是否清晰。"
})
if msg_30d_total < 30:
operation_suggestions.append({
"level": "info",
"title": "群消息基数偏低,建议降低互动门槛",
"desc": f"近30天消息量仅 {msg_30d_total} 条,更适合短反馈、轻任务、固定时间提醒等低成本互动。"
})
if not operation_suggestions:
operation_suggestions.append({
"level": "success",
"title": "群整体状态健康,可持续做精细化运营",
"desc": "当前活跃、消息量、插件调用都比较均衡,建议继续围绕高峰时段和高频插件做强化。"
})
return jsonify({
"success": True,
"data": {
"group_id": group_id,
"group_name": group_name,
"health_score": health_score,
"overview": {
"member_count": total_members,
"active_member_count_7d": member_summary.get("active_7d_members", 0),
"active_member_count_30d": active_30d,
"zombie_member_count": zombie_count,
"never_spoken_count": member_summary.get("never_spoken_members", 0),
"message_count_24h": message_stats_24h.get("total_count", 0),
"message_count_7d": message_stats_7d.get("total_count", 0),
"message_count_30d": msg_30d_total,
"plugin_call_count_30d": plugin_total_calls,
"plugin_count_30d": plugin_summary.get("plugin_count", 0),
},
"diagnosis": [
{
"title": "活跃覆盖率",
"value": f"{round((active_30d / total_members) * 100, 1) if total_members else 0}%",
"desc": "近30天发言成员占全群人数比例"
},
{
"title": "僵尸用户占比",
"value": f"{round((zombie_count / total_members) * 100, 1) if total_members else 0}%",
"desc": "30天未发言或从未发言成员占比"
},
{
"title": "人均消息量",
"value": round(msg_30d_total / active_30d, 1) if active_30d else 0,
"desc": "近30天活跃成员的人均发言量"
},
{
"title": "插件渗透度",
"value": f"{plugin_summary.get('plugin_count', 0)} 个插件",
"desc": "近30天在本群真实发生调用的插件数"
}
],
"permissions": {
"enabled_count": len(enabled_features),
"enabled_features": enabled_features,
},
"member_summary": member_summary,
"message_summary": {
"last_message": last_message,
"last_24h": message_stats_24h,
"last_7d": message_stats_7d,
"last_30d": message_stats_30d,
"type_mix_30d": [
{"label": "文本", "count": message_stats_30d.get("text_count", 0)},
{"label": "图片", "count": message_stats_30d.get("image_count", 0)},
{"label": "视频", "count": message_stats_30d.get("video_count", 0)},
{"label": "链接", "count": message_stats_30d.get("link_count", 0)},
{"label": "表情", "count": message_stats_30d.get("emoji_count", 0)},
],
"trend_14d": {
"dates": trend_dates,
"counts": trend_counts
},
"hourly_distribution_30d": hourly_distribution,
"peak_hours_30d": peak_hours,
},
"inactive_members": inactive_members,
"speaker_ranking": speaker_ranking,
"plugin_summary": plugin_summary,
"plugin_stats": plugin_stats,
"operation_suggestions": operation_suggestions,
}
})
except Exception as e:
LOG.error(f"获取群组详情失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# 添加缺失的群组状态更新接口
@robot_bp.route('/api/group/<group_id>/status', methods=['POST'])
@login_required

View File

@@ -95,6 +95,9 @@
<el-table-column label="操作" min-width="360">
<template slot-scope="scope">
<div class="action-row">
<el-button size="mini" type="warning" plain @click="viewGroupDetail(scope.row)">
查看详情
</el-button>
<el-button size="mini" type="primary" plain @click="viewGroupPermissions(scope.row)">
查看权限
</el-button>
@@ -168,6 +171,186 @@
<canvas id="messageTrendChart" width="800" height="360"></canvas>
</div>
</el-dialog>
<el-dialog
:title="currentGroupName + ' · 群组详情'"
:visible.sync="groupDetailDialogVisible"
width="80%"
top="5vh">
<div v-loading="groupDetailLoading">
<template v-if="groupDetail">
<div class="detail-hero">
<div>
<div class="detail-health-label">群健康度</div>
<div class="detail-health-value">{% raw %}{{ groupDetail.health_score }}{% endraw %}</div>
<div class="detail-health-note">综合活跃、僵尸成员、消息量、插件调用生成</div>
</div>
<div class="detail-tags">
<el-tag type="success" effect="plain">启用功能 {% raw %}{{ groupDetail.permissions.enabled_count }}{% endraw %}</el-tag>
<el-tag type="info" effect="plain">近30天消息 {% raw %}{{ groupDetail.overview.message_count_30d }}{% endraw %}</el-tag>
<el-tag type="warning" effect="plain">僵尸成员 {% raw %}{{ groupDetail.overview.zombie_member_count }}{% endraw %}</el-tag>
<el-tag type="danger" effect="plain">插件调用 {% raw %}{{ groupDetail.overview.plugin_call_count_30d }}{% endraw %}</el-tag>
</div>
</div>
<el-row :gutter="16" class="overview-grid detail-overview-grid">
<el-col :span="6" v-for="item in detailOverviewCards" :key="item.label">
<el-card class="overview-card" shadow="never">
<div class="overview-label">{% raw %}{{ item.label }}{% endraw %}</div>
<div class="overview-value overview-value--detail">{% raw %}{{ item.value }}{% endraw %}</div>
<div class="overview-note">{% raw %}{{ item.note }}{% endraw %}</div>
</el-card>
</el-col>
</el-row>
<div class="detail-section">
<div class="section-title">建议观察维度</div>
<div class="diagnosis-grid">
<el-card v-for="item in groupDetail.diagnosis" :key="item.title" class="diagnosis-card" shadow="never">
<div class="diagnosis-title">{% raw %}{{ item.title }}{% endraw %}</div>
<div class="diagnosis-value">{% raw %}{{ item.value }}{% endraw %}</div>
<div class="diagnosis-desc">{% raw %}{{ item.desc }}{% endraw %}</div>
</el-card>
</div>
</div>
<div class="detail-section">
<div class="section-title">运营建议</div>
<div class="suggestion-list">
<el-alert
v-for="(item, index) in groupDetail.operation_suggestions"
:key="index"
:title="item.title"
:type="item.level"
:description="item.desc"
show-icon
:closable="false"
class="suggestion-item">
</el-alert>
</div>
</div>
<el-row :gutter="16" class="detail-panels">
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>发言结构</span>
<span class="detail-card-sub">近30天</span>
</div>
<el-table :data="groupDetail.message_summary.type_mix_30d" size="mini">
<el-table-column prop="label" label="类型"></el-table-column>
<el-table-column prop="count" label="数量" width="100"></el-table-column>
</el-table>
<div class="detail-inline-note">
最近一条消息:
<span v-if="groupDetail.message_summary.last_message">
{% raw %}{{ formatLastMessage(groupDetail.message_summary.last_message) }}{% endraw %}
</span>
<span v-else>暂无消息</span>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>启用功能</span>
<span class="detail-card-sub">当前权限</span>
</div>
<div class="feature-chip-list">
<el-tag
v-for="feature in groupDetail.permissions.enabled_features"
:key="feature"
size="small"
type="success"
effect="plain">
{% raw %}{{ feature }}{% endraw %}
</el-tag>
<span v-if="!groupDetail.permissions.enabled_features.length" class="empty-inline">暂无启用功能</span>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="detail-panels">
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>僵尸用户 / 潜水成员</span>
<span class="detail-card-sub">30天未活跃优先</span>
</div>
<el-table :data="groupDetail.inactive_members" size="mini" empty-text="暂无明显沉默成员">
<el-table-column prop="display_name" label="成员"></el-table-column>
<el-table-column prop="inactivity_days" label="未活跃天数" width="110"></el-table-column>
<el-table-column prop="latest_active_time" label="最后活跃时间" min-width="160"></el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>发言排行</span>
<span class="detail-card-sub">近30天 Top 10</span>
</div>
<el-table :data="groupDetail.speaker_ranking" size="mini" empty-text="暂无发言数据">
<el-table-column prop="display_name" label="成员"></el-table-column>
<el-table-column prop="message_count" label="消息数" width="90"></el-table-column>
<el-table-column prop="last_message_time" label="最后发言" min-width="160"></el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="detail-panels">
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>活跃高峰时段</span>
<span class="detail-card-sub">近30天小时分布</span>
</div>
<div class="peak-hour-list">
<div v-for="item in groupDetail.message_summary.peak_hours_30d" :key="item.label" class="peak-hour-item">
<div class="peak-hour-rank">{% raw %}{{ item.label }}{% endraw %}</div>
<div class="peak-hour-count">{% raw %}{{ item.message_count }}{% endraw %} 条</div>
</div>
<div v-if="!groupDetail.message_summary.peak_hours_30d.length" class="empty-inline">暂无足够数据</div>
</div>
<div class="chart-shell chart-shell--compact chart-shell--mini">
<canvas id="groupDetailHourlyChart" height="220"></canvas>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>插件调用情况</span>
<span class="detail-card-sub">近30天</span>
</div>
<el-table :data="groupDetail.plugin_stats" size="mini" empty-text="暂无插件调用">
<el-table-column prop="plugin_name" label="插件"></el-table-column>
<el-table-column prop="command" label="命令"></el-table-column>
<el-table-column prop="total_calls" label="调用" width="80"></el-table-column>
<el-table-column prop="last_used_at" label="最近调用" min-width="160"></el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="detail-panels">
<el-col :span="24">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>消息趋势</span>
<span class="detail-card-sub">近14天</span>
</div>
<div class="chart-shell chart-shell--compact">
<canvas id="groupDetailTrendChart" height="240"></canvas>
</div>
</el-card>
</el-col>
</el-row>
</template>
</div>
</el-dialog>
</div>
{% endblock %}
@@ -195,6 +378,9 @@
{ pattern: /^\S+$/, message: '群组ID不能包含空格', trigger: 'blur' }
]
},
groupDetailDialogVisible: false,
groupDetailLoading: false,
groupDetail: null,
messageTrendDialogVisible: false,
messageTrendData: {
dates: [],
@@ -216,6 +402,20 @@
},
disabledGroupsCount() {
return this.groups.filter(group => group.robot_status !== 'enabled').length;
},
detailOverviewCards() {
if (!this.groupDetail) return [];
const overview = this.groupDetail.overview || {};
return [
{ label: '群成员数', value: overview.member_count || 0, note: '当前群成员规模' },
{ label: '7天活跃成员', value: overview.active_member_count_7d || 0, note: '近7天至少发言一次' },
{ label: '僵尸成员数', value: overview.zombie_member_count || 0, note: '30天未发言或从未发言' },
{ label: '从未发言', value: overview.never_spoken_count || 0, note: '未记录到活跃痕迹' },
{ label: '24h消息量', value: overview.message_count_24h || 0, note: '用于判断短期热度' },
{ label: '30天消息量', value: overview.message_count_30d || 0, note: '用于判断持续热度' },
{ label: '插件调用', value: overview.plugin_call_count_30d || 0, note: '近30天插件总触发次数' },
{ label: '插件种类', value: overview.plugin_count_30d || 0, note: '本群真实使用到的插件数' }
];
}
},
mounted() {
@@ -262,6 +462,33 @@
this.$message.error('加载权限失败: ' + error.message);
});
},
viewGroupDetail(group) {
this.currentGroupId = group.group_id;
this.currentGroupName = group.group_name || group.group_id;
this.groupDetailDialogVisible = true;
this.groupDetailLoading = true;
this.groupDetail = null;
axios.get(`/robot/api/group/${group.group_id}/detail`)
.then(response => {
if (response.data.success) {
this.groupDetail = response.data.data || null;
this.$nextTick(() => {
this.renderGroupDetailTrendChart();
this.renderGroupDetailHourlyChart();
});
} else {
this.$message.error('加载群组详情失败');
}
})
.catch(error => {
console.error('加载群组详情失败:', error);
this.$message.error('加载群组详情失败: ' + error.message);
})
.finally(() => {
this.groupDetailLoading = false;
});
},
togglePermission(permission) {
const newStatus = permission.statusBool ? 'enabled' : 'disabled';
@@ -493,6 +720,133 @@
}
}
});
},
renderGroupDetailTrendChart() {
if (!this.groupDetail || !this.groupDetail.message_summary) return;
const canvas = document.getElementById('groupDetailTrendChart');
if (!canvas) return;
const trendData = this.groupDetail.message_summary.trend_14d || { dates: [], counts: [] };
const ctx = canvas.getContext('2d');
if (this.charts && this.charts.groupDetailTrendChart) {
this.charts.groupDetailTrendChart.destroy();
}
if (!this.charts) {
this.charts = {};
}
this.charts.groupDetailTrendChart = new Chart(ctx, {
type: 'bar',
data: {
labels: trendData.dates,
datasets: [
{
label: '消息数量',
data: trendData.counts,
backgroundColor: 'rgba(249, 115, 22, 0.72)',
borderRadius: 8
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(148,163,184,0.12)'
}
},
x: {
grid: {
display: false
}
}
},
plugins: {
legend: {
display: false
}
}
}
});
},
renderGroupDetailHourlyChart() {
if (!this.groupDetail || !this.groupDetail.message_summary) return;
const canvas = document.getElementById('groupDetailHourlyChart');
if (!canvas) return;
const raw = this.groupDetail.message_summary.hourly_distribution_30d || [];
const hourMap = {};
raw.forEach(item => {
hourMap[item.hour] = item.message_count;
});
const labels = [];
const counts = [];
for (let i = 0; i < 24; i++) {
labels.push(`${String(i).padStart(2, '0')}:00`);
counts.push(hourMap[i] || 0);
}
const ctx = canvas.getContext('2d');
if (this.charts && this.charts.groupDetailHourlyChart) {
this.charts.groupDetailHourlyChart.destroy();
}
if (!this.charts) {
this.charts = {};
}
this.charts.groupDetailHourlyChart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: '小时消息量',
data: counts,
fill: true,
backgroundColor: 'rgba(14, 165, 233, 0.12)',
borderColor: 'rgba(14, 165, 233, 1)',
tension: 0.32,
borderWidth: 2,
pointRadius: 1.5
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(148,163,184,0.12)'
}
},
x: {
grid: {
display: false
}
}
},
plugins: {
legend: {
display: false
}
}
}
});
},
formatLastMessage(lastMessage) {
if (!lastMessage) return '暂无消息';
const sender = lastMessage.sender || '未知成员';
const time = lastMessage.timestamp || '';
const content = lastMessage.content || '[非文本消息]';
return `${sender} · ${time} · ${content}`;
}
}
});
@@ -679,5 +1033,205 @@
font-weight: 600;
color: #334155;
}
.detail-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 18px 20px;
margin-bottom: 16px;
border-radius: 22px;
background: linear-gradient(135deg, rgba(249,115,22,0.10), rgba(14,165,233,0.08), rgba(255,255,255,0.96));
border: 1px solid rgba(148,163,184,0.14);
}
.detail-health-label {
font-size: 13px;
color: #9a3412;
margin-bottom: 8px;
}
.detail-health-value {
font-size: 38px;
line-height: 1;
font-weight: 700;
color: #111827;
margin-bottom: 10px;
}
.detail-health-note {
font-size: 13px;
color: #64748b;
}
.detail-tags {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.detail-overview-grid {
margin-bottom: 16px;
}
.overview-value--detail {
font-size: 24px;
}
.detail-section {
margin-bottom: 16px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
}
.diagnosis-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.diagnosis-card {
border-radius: 18px;
}
.diagnosis-title {
font-size: 13px;
color: #64748b;
margin-bottom: 10px;
}
.diagnosis-value {
font-size: 24px;
font-weight: 700;
color: #0f172a;
margin-bottom: 8px;
}
.diagnosis-desc {
font-size: 12px;
color: #94a3b8;
line-height: 1.5;
}
.detail-panels .el-col {
margin-bottom: 16px;
}
.detail-card {
border-radius: 18px;
}
.detail-card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
color: #0f172a;
}
.detail-card-sub {
font-size: 12px;
color: #94a3b8;
font-weight: 500;
}
.feature-chip-list {
display: flex;
gap: 8px;
flex-wrap: wrap;
min-height: 60px;
align-items: flex-start;
}
.empty-inline,
.detail-inline-note {
font-size: 12px;
color: #64748b;
}
.detail-inline-note {
margin-top: 12px;
line-height: 1.6;
}
.suggestion-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.suggestion-item {
border-radius: 14px;
}
.peak-hour-list {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.peak-hour-item {
min-width: 132px;
padding: 12px 14px;
border-radius: 14px;
background: linear-gradient(180deg, rgba(14,165,233,0.08), rgba(255,255,255,0.96));
border: 1px solid rgba(148,163,184,0.12);
}
.peak-hour-rank {
font-size: 13px;
color: #0f172a;
font-weight: 600;
margin-bottom: 6px;
}
.peak-hour-count {
font-size: 12px;
color: #64748b;
}
.chart-shell--compact {
min-height: 260px;
}
.chart-shell--mini {
min-height: 220px;
}
@media (max-width: 1200px) {
.diagnosis-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.detail-hero,
.page-hero,
.workspace-header {
flex-direction: column;
align-items: stretch;
}
.page-hero-actions,
.detail-tags {
justify-content: flex-start;
}
.hero-search {
width: 100%;
}
.diagnosis-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}

View File

@@ -743,3 +743,64 @@ class ContactsDBOperator(BaseDBOperator):
except Exception as e:
self.LOG.error(f"获取群{chatroom_id}潜水排行失败: {e}")
return []
def get_group_member_summary(self, chatroom_id: str, inactive_days: int = 30) -> Dict[str, Any]:
"""获取群成员概览摘要"""
try:
sql = """
SELECT
COUNT(*) AS total_members,
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS in_group_members,
SUM(CASE WHEN is_owner = 1 THEN 1 ELSE 0 END) AS owner_count,
SUM(CASE WHEN is_admin = 1 THEN 1 ELSE 0 END) AS admin_count,
SUM(CASE WHEN latest_active_time IS NOT NULL THEN 1 ELSE 0 END) AS spoken_members,
SUM(CASE WHEN latest_active_time IS NULL THEN 1 ELSE 0 END) AS never_spoken_members,
SUM(
CASE
WHEN latest_active_time IS NOT NULL
AND latest_active_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
THEN 1 ELSE 0
END
) AS active_7d_members,
SUM(
CASE
WHEN latest_active_time IS NOT NULL
AND latest_active_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
THEN 1 ELSE 0
END
) AS active_30d_members,
SUM(
CASE
WHEN latest_active_time IS NULL
OR latest_active_time < DATE_SUB(NOW(), INTERVAL %s DAY)
THEN 1 ELSE 0
END
) AS inactive_members
FROM t_chatroom_member
WHERE chatroom_id = %s
"""
result = self.execute_query(sql, (inactive_days, chatroom_id), fetch_one=True) or {}
return {
"total_members": int(result.get("total_members") or 0),
"in_group_members": int(result.get("in_group_members") or 0),
"owner_count": int(result.get("owner_count") or 0),
"admin_count": int(result.get("admin_count") or 0),
"spoken_members": int(result.get("spoken_members") or 0),
"never_spoken_members": int(result.get("never_spoken_members") or 0),
"active_7d_members": int(result.get("active_7d_members") or 0),
"active_30d_members": int(result.get("active_30d_members") or 0),
"inactive_members": int(result.get("inactive_members") or 0),
}
except Exception as e:
self.LOG.error(f"获取群{chatroom_id}成员摘要失败: {e}")
return {
"total_members": 0,
"in_group_members": 0,
"owner_count": 0,
"admin_count": 0,
"spoken_members": 0,
"never_spoken_members": 0,
"active_7d_members": 0,
"active_30d_members": 0,
"inactive_members": 0,
}

View File

@@ -269,6 +269,72 @@ class MessageStorageDB(BaseDBOperator):
"""
return self.execute_query(sql, (group_id, days)) or []
def get_group_member_message_ranking(self, group_id: str, start_time: datetime,
end_time: datetime, limit: int = 10) -> List[Dict]:
"""获取群成员发言排行"""
sql = """
SELECT
sender,
COUNT(*) AS message_count,
MAX(timestamp) AS last_message_time
FROM messages
WHERE timestamp >= %s
AND timestamp <= %s
AND group_id = %s
AND sender IS NOT NULL
AND sender <> ''
GROUP BY sender
ORDER BY message_count DESC, last_message_time DESC
LIMIT %s
"""
params = (
start_time.strftime('%Y-%m-%d %H:%M:%S'),
end_time.strftime('%Y-%m-%d %H:%M:%S'),
group_id,
limit,
)
rows = self.execute_query(sql, params) or []
for row in rows:
dt = row.get("last_message_time")
if isinstance(dt, datetime):
row["last_message_time"] = dt.strftime("%Y-%m-%d %H:%M:%S")
return rows
def get_group_hourly_distribution(self, group_id: str, days: int = 30) -> List[Dict]:
"""获取群消息小时分布"""
sql = """
SELECT
HOUR(timestamp) AS hour_slot,
COUNT(*) AS message_count
FROM messages
WHERE group_id = %s
AND timestamp >= DATE_SUB(NOW(), INTERVAL %s DAY)
GROUP BY HOUR(timestamp)
ORDER BY hour_slot
"""
rows = self.execute_query(sql, (group_id, days)) or []
return [
{
"hour": int(row.get("hour_slot") or 0),
"message_count": int(row.get("message_count") or 0),
}
for row in rows
]
def get_group_last_message(self, group_id: str) -> Optional[Dict]:
"""获取群最后一条消息信息"""
sql = """
SELECT sender, content, message_type, timestamp
FROM messages
WHERE group_id = %s
ORDER BY timestamp DESC
LIMIT 1
"""
row = self.execute_query(sql, (group_id,), fetch_one=True)
if row and isinstance(row.get("timestamp"), datetime):
row["timestamp"] = row["timestamp"].strftime("%Y-%m-%d %H:%M:%S")
return row
def get_messages_by_filter(self, group_id=None, start_date=None, end_date=None,
search_text=None, page=1, page_size=20) -> Dict:
"""按条件筛选消息并支持分页和模糊搜索

View File

@@ -555,4 +555,56 @@ class StatsDBOperator(BaseDBOperator):
})
current_date += timedelta(days=1)
return trend_data
return trend_data
def get_group_plugin_stats(self, group_id: str, days: int = 30, limit: int = 10) -> List[Dict]:
"""获取指定群的插件调用统计"""
sql = """
SELECT
plugin_name,
command,
SUM(total_calls) AS total_calls,
SUM(success_calls) AS success_calls,
SUM(failed_calls) AS failed_calls,
MAX(unique_users) AS unique_users,
MIN(first_used_at) AS first_used_at,
MAX(last_used_at) AS last_used_at
FROM t_group_stats
WHERE group_id = %s
AND last_used_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
GROUP BY plugin_name, command
ORDER BY total_calls DESC, last_used_at DESC
LIMIT %s
"""
rows = self.execute_query(sql, (group_id, days, limit)) or []
for row in rows:
for key in ("first_used_at", "last_used_at"):
dt = row.get(key)
if isinstance(dt, datetime):
row[key] = dt.strftime("%Y-%m-%d %H:%M:%S")
return rows
def get_group_plugin_summary(self, group_id: str, days: int = 30) -> Dict:
"""获取指定群的插件调用摘要"""
sql = """
SELECT
SUM(total_calls) AS total_calls,
SUM(success_calls) AS success_calls,
SUM(failed_calls) AS failed_calls,
COUNT(DISTINCT plugin_name) AS plugin_count,
MAX(last_used_at) AS last_used_at
FROM t_group_stats
WHERE group_id = %s
AND last_used_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
"""
result = self.execute_query(sql, (group_id, days), fetch_one=True) or {}
dt = result.get("last_used_at")
if isinstance(dt, datetime):
result["last_used_at"] = dt.strftime("%Y-%m-%d %H:%M:%S")
return {
"total_calls": int(result.get("total_calls") or 0),
"success_calls": int(result.get("success_calls") or 0),
"failed_calls": int(result.get("failed_calls") or 0),
"plugin_count": int(result.get("plugin_count") or 0),
"last_used_at": result.get("last_used_at") or "",
}