feat: add group detail dashboard insights
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
"""按条件筛选消息并支持分页和模糊搜索
|
||||
|
||||
@@ -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 "",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user