diff --git a/admin/dashboard/blueprints/robot.py b/admin/dashboard/blueprints/robot.py index 96debcf..89d299a 100644 --- a/admin/dashboard/blueprints/robot.py +++ b/admin/dashboard/blueprints/robot.py @@ -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//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//status', methods=['POST']) @login_required diff --git a/admin/dashboard/templates/robot_management.html b/admin/dashboard/templates/robot_management.html index 7ac0d59..1142a70 100644 --- a/admin/dashboard/templates/robot_management.html +++ b/admin/dashboard/templates/robot_management.html @@ -95,6 +95,9 @@