增强群运营分析2.0首版展示能力
变更项: 1. 在现有群详情接口中追加群画像摘要、成员分层、动作建议和最近群总结数据,保留原有健康度、趋势、排行与运营建议结构。 2. 为后台服务补充 message_summary 数据访问对象,复用现有群总结数据作为群运营分析输入。 3. 在通讯录管理的群详情面板中新增群画像摘要、成员分层和可执行动作建议卡片,保持旧页面内容不删除,仅做加法增强。
This commit is contained in:
@@ -4,11 +4,336 @@ from loguru import logger
|
||||
from utils.robot_cmd.robot_command import GroupBotManager, Feature, PermissionStatus
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from db.base import BaseDBOperator
|
||||
from plugins.ai_auto_response.memory.group_memory_profile import GroupMemoryService
|
||||
|
||||
# 创建群管理兼容蓝图(旧入口保留做跳转,实际功能已并入通讯录)
|
||||
robot_bp = Blueprint('robot', __name__, url_prefix='/robot')
|
||||
LOG = logger
|
||||
|
||||
|
||||
def _build_group_ops_profile(server, group_id: str, group_name: str, peak_hours: list, plugin_stats: list) -> dict:
|
||||
"""构建群运营 2.0 所需的群画像摘要。
|
||||
|
||||
设计说明:
|
||||
1. 这里优先复用现有群画像快照与消息总结能力,不重新发明一套分析链路;
|
||||
2. 若历史画像数据不足,也必须返回可展示的兜底结构,避免前端出现大片空白;
|
||||
3. 输出结构专门面向后台运营视图,强调“群是什么群、最近聊什么、适合怎么运营”。
|
||||
"""
|
||||
profile_service = GroupMemoryService(server.db_manager, {})
|
||||
profile = profile_service.build_group_memory_profile(group_id, group_name=group_name) or {}
|
||||
|
||||
style_profile = profile.get("style_profile", {}) or {}
|
||||
focus_topics = [str(item).strip() for item in (profile.get("focus_topics") or []) if str(item).strip()]
|
||||
structured_summary = profile.get("structured_summary", {}) or {}
|
||||
recent_key_points = [
|
||||
str(item).strip() for item in (structured_summary.get("recent_key_points") or []) if str(item).strip()
|
||||
][:4]
|
||||
unresolved_points = [
|
||||
str(item).strip() for item in (structured_summary.get("unresolved_points") or []) if str(item).strip()
|
||||
][:3]
|
||||
stable_topics = [
|
||||
str(item).strip() for item in (structured_summary.get("stable_topics") or []) if str(item).strip()
|
||||
][:4]
|
||||
|
||||
peak_hour_labels = [str(item.get("label") or "").strip() for item in peak_hours if str(item.get("label") or "").strip()]
|
||||
dominant_plugin_names = []
|
||||
for item in plugin_stats[:3]:
|
||||
plugin_name = str(item.get("plugin_name") or "").strip()
|
||||
if plugin_name and plugin_name not in dominant_plugin_names:
|
||||
dominant_plugin_names.append(plugin_name)
|
||||
|
||||
# 群定位摘要优先使用群画像服务的领域判断,再结合真实主题给出一段更像“运营结论”的描述。
|
||||
inferred_domain = str(profile.get("inferred_domain") or "general").strip()
|
||||
domain_label_map = {
|
||||
"openclaw": "工作流 / 编排讨论型群",
|
||||
"robotics": "机器人 / 自动化协作型群",
|
||||
"dota": "Dota / 游戏讨论型群",
|
||||
"tech": "技术排障 / 工具协作型群",
|
||||
"casual": "熟人闲聊 / 日常水群型群",
|
||||
"general": "综合交流型群",
|
||||
}
|
||||
group_identity = domain_label_map.get(inferred_domain, "综合交流型群")
|
||||
|
||||
profile_tags = [
|
||||
tag for tag in [
|
||||
group_identity,
|
||||
str(style_profile.get("interaction_tone") or "").strip(),
|
||||
str(style_profile.get("humor_style") or "").strip(),
|
||||
str(style_profile.get("expressiveness_style") or "").strip(),
|
||||
] if tag
|
||||
]
|
||||
|
||||
summary_lines = []
|
||||
if focus_topics:
|
||||
summary_lines.append(f"近期高频主题集中在:{'、'.join(focus_topics[:4])}")
|
||||
if peak_hour_labels:
|
||||
summary_lines.append(f"活跃时段主要落在:{'、'.join(peak_hour_labels[:2])}")
|
||||
if dominant_plugin_names:
|
||||
summary_lines.append(f"当前最容易被接受的机器人能力偏向:{'、'.join(dominant_plugin_names[:2])}")
|
||||
if not summary_lines:
|
||||
summary_lines.append("当前历史数据仍偏少,建议先继续积累群消息、总结与插件使用记录。")
|
||||
|
||||
return {
|
||||
"group_identity": group_identity,
|
||||
"profile_tags": profile_tags,
|
||||
"focus_topics": focus_topics[:6],
|
||||
"stable_topics": stable_topics,
|
||||
"recent_key_points": recent_key_points,
|
||||
"unresolved_points": unresolved_points,
|
||||
"summary_text": ";".join(summary_lines),
|
||||
"interaction_tone": str(style_profile.get("interaction_tone") or "").strip(),
|
||||
"humor_style": str(style_profile.get("humor_style") or "").strip(),
|
||||
"expressiveness_style": str(style_profile.get("expressiveness_style") or "").strip(),
|
||||
"peak_hours": peak_hour_labels[:3],
|
||||
"dominant_plugins": dominant_plugin_names[:3],
|
||||
"cache_status": str(profile.get("cache_status") or "").strip(),
|
||||
"last_generated_at": str(profile.get("last_generated_at") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _load_recent_group_summaries(server, group_id: str, limit: int = 3) -> list:
|
||||
"""读取最近几条群总结,供运营面板直接展示。
|
||||
|
||||
设计说明:
|
||||
1. 群画像适合给“结论”,但运营者往往还想看到最近几期总结到底说了什么;
|
||||
2. 这里不把整段长文原样吐给前端,而是裁成可扫读的短摘要,避免弹窗过重;
|
||||
3. 若没有总结记录,返回空列表,由前端优雅降级。
|
||||
"""
|
||||
summary_db = getattr(server, "message_summary_db", None)
|
||||
if summary_db is None:
|
||||
return []
|
||||
|
||||
rows = summary_db.execute_query(
|
||||
"""
|
||||
SELECT period_key, summary_type, source_message_count, summary_text, last_generated_at
|
||||
FROM t_message_summary
|
||||
WHERE chatroom_id = %s
|
||||
ORDER BY period_end DESC, update_time DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(group_id, int(limit)),
|
||||
) or []
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
raw_summary = str(row.get("summary_text") or "").strip()
|
||||
normalized_summary = " ".join(raw_summary.split())
|
||||
result.append({
|
||||
"period_key": str(row.get("period_key") or "").strip(),
|
||||
"summary_type": str(row.get("summary_type") or "").strip(),
|
||||
"source_message_count": int(row.get("source_message_count") or 0),
|
||||
"last_generated_at": str(row.get("last_generated_at") or "").strip(),
|
||||
"summary_excerpt": normalized_summary[:180] + ("..." if len(normalized_summary) > 180 else ""),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _load_value_rank_top_members(server, group_id: str, limit: int = 5) -> list:
|
||||
"""读取群内最近一期高价值成员快照。
|
||||
|
||||
说明:
|
||||
1. 该榜单直接复用已落库的身价快照,而不是运行时重新算分;
|
||||
2. 若群里尚未开启或尚未产出这类数据,则静默返回空列表;
|
||||
3. 返回结构会补齐成员展示名,避免前端出现一串 wxid。
|
||||
"""
|
||||
db = BaseDBOperator(server.db_manager)
|
||||
latest_row = db.execute_query(
|
||||
"""
|
||||
SELECT MAX(stat_date) AS latest_stat_date
|
||||
FROM t_value_rank_snapshot
|
||||
WHERE group_id = %s
|
||||
""",
|
||||
(group_id,),
|
||||
fetch_one=True,
|
||||
) or {}
|
||||
latest_stat_date = latest_row.get("latest_stat_date")
|
||||
if not latest_stat_date:
|
||||
return []
|
||||
|
||||
rows = db.execute_query(
|
||||
"""
|
||||
SELECT user_id, score, rank_no, title, points_total, msg_count_7d, active_days_30, inactive_days
|
||||
FROM t_value_rank_snapshot
|
||||
WHERE group_id = %s AND stat_date = %s
|
||||
ORDER BY rank_no ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(group_id, latest_stat_date, int(limit)),
|
||||
) or []
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
user_id = str(row.get("user_id") or "").strip()
|
||||
result.append({
|
||||
"wxid": user_id,
|
||||
"display_name": server.contact_manager.get_group_name(group_id, user_id) or user_id,
|
||||
"score": float(row.get("score") or 0.0),
|
||||
"rank_no": int(row.get("rank_no") or 0),
|
||||
"title": str(row.get("title") or "").strip(),
|
||||
"points_total": int(row.get("points_total") or 0),
|
||||
"msg_count_7d": int(row.get("msg_count_7d") or 0),
|
||||
"active_days_30": int(row.get("active_days_30") or 0),
|
||||
"inactive_days": int(row.get("inactive_days") or 0),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _load_activation_candidates(server, group_id: str, limit: int = 6) -> list:
|
||||
"""识别“待激活成员”。
|
||||
|
||||
口径选择:
|
||||
1. 不直接拿最沉默的人,因为那类成员很多已经处于长期静默,激活收益未必最高;
|
||||
2. 这里优先找“7~30 天未发言,但仍不是完全流失”的边缘成员,更适合做召回动作;
|
||||
3. 这类成员通常对低门槛互动、固定时段提醒更敏感,运营价值更高。
|
||||
"""
|
||||
db = BaseDBOperator(server.db_manager)
|
||||
rows = db.execute_query(
|
||||
"""
|
||||
SELECT
|
||||
wxid,
|
||||
COALESCE(NULLIF(display_name, ''), nick_name, wxid) AS display_name,
|
||||
latest_active_time,
|
||||
TIMESTAMPDIFF(DAY, latest_active_time, NOW()) AS inactivity_days
|
||||
FROM t_chatroom_member
|
||||
WHERE chatroom_id = %s
|
||||
AND status = 1
|
||||
AND latest_active_time IS NOT NULL
|
||||
AND latest_active_time < DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
AND latest_active_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
ORDER BY latest_active_time DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(group_id, int(limit)),
|
||||
) or []
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
result.append({
|
||||
"wxid": str(row.get("wxid") or "").strip(),
|
||||
"display_name": str(row.get("display_name") or "").strip(),
|
||||
"latest_active_time": str(row.get("latest_active_time") or "").strip(),
|
||||
"inactivity_days": int(row.get("inactivity_days") or 0),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _build_ops_member_segments(server, group_id: str, speaker_ranking: list, inactive_members: list) -> dict:
|
||||
"""构建群成员分层结果。
|
||||
|
||||
设计目标:
|
||||
1. 保留原有“沉默成员 / 发言排行”表格;
|
||||
2. 在此基础上额外产出“核心成员 / 高价值成员 / 待激活成员”三类运营视角;
|
||||
3. 前端展示时以卡片为主,让运营者更快找到“该看谁”。
|
||||
"""
|
||||
value_rank_members = _load_value_rank_top_members(server, group_id, limit=5)
|
||||
activation_candidates = _load_activation_candidates(server, group_id, limit=6)
|
||||
member_context_map = {
|
||||
str(item.get("wxid") or "").strip(): item
|
||||
for item in (server.member_context_db.list_group_member_contexts(group_id) or [])
|
||||
}
|
||||
|
||||
core_members = []
|
||||
for item in speaker_ranking[:5]:
|
||||
wxid = str(item.get("sender") or "").strip()
|
||||
context = member_context_map.get(wxid, {}) or {}
|
||||
core_members.append({
|
||||
"wxid": wxid,
|
||||
"display_name": str(item.get("display_name") or wxid).strip(),
|
||||
"message_count": int(item.get("message_count") or 0),
|
||||
"last_message_time": str(item.get("last_message_time") or "").strip(),
|
||||
"activity_level": str(context.get("activity_level") or "").strip(),
|
||||
"summary_text": str(context.get("summary_text") or "").strip(),
|
||||
})
|
||||
|
||||
dormant_members = []
|
||||
for item in inactive_members[:5]:
|
||||
wxid = str(item.get("wxid") or "").strip()
|
||||
context = member_context_map.get(wxid, {}) or {}
|
||||
dormant_members.append({
|
||||
"wxid": wxid,
|
||||
"display_name": str(item.get("display_name") or wxid).strip(),
|
||||
"inactivity_days": int(item.get("inactivity_days") or 0),
|
||||
"latest_active_time": str(item.get("latest_active_time") or "").strip(),
|
||||
"response_style_hint": str(context.get("response_style_hint") or "").strip(),
|
||||
})
|
||||
|
||||
return {
|
||||
"core_members": core_members,
|
||||
"value_rank_members": value_rank_members,
|
||||
"activation_candidates": activation_candidates,
|
||||
"dormant_members": dormant_members,
|
||||
}
|
||||
|
||||
|
||||
def _build_ops_action_cards(group_name: str, ops_profile: dict, ops_members: dict, plugin_stats: list) -> list:
|
||||
"""生成更偏“可执行动作”的运营建议卡片。
|
||||
|
||||
与旧版 `operation_suggestions` 的区别:
|
||||
1. 旧版更像诊断提示;新版更强调“下一步怎么做”;
|
||||
2. 仍然采用规则组合,避免引入高成本、不可控的 LLM 依赖;
|
||||
3. 结构尽量稳定,方便前端做卡片展示与后续继续增强。
|
||||
"""
|
||||
action_cards = []
|
||||
peak_hours = ops_profile.get("peak_hours") or []
|
||||
focus_topics = ops_profile.get("focus_topics") or []
|
||||
dominant_plugins = ops_profile.get("dominant_plugins") or []
|
||||
activation_candidates = ops_members.get("activation_candidates") or []
|
||||
core_members = ops_members.get("core_members") or []
|
||||
|
||||
if peak_hours:
|
||||
action_cards.append({
|
||||
"type": "time",
|
||||
"title": "建议把互动动作放到高峰时段",
|
||||
"summary": f"优先考虑在 {peak_hours[0]} 发起签到、话题或菜单引导。",
|
||||
"detail": f"{group_name} 最近高峰主要集中在 {'、'.join(peak_hours[:2])},在这个时间段发起动作更容易拿到首轮反馈。",
|
||||
})
|
||||
|
||||
if dominant_plugins:
|
||||
action_cards.append({
|
||||
"type": "feature",
|
||||
"title": "建议优先强化已被接受的功能入口",
|
||||
"summary": f"先围绕 {dominant_plugins[0]} 做菜单、公告或欢迎语曝光。",
|
||||
"detail": f"当前群里真实被用起来的功能更偏向 {'、'.join(dominant_plugins[:3])},说明这类能力更容易被接受,适合作为下一阶段主推入口。",
|
||||
})
|
||||
|
||||
if activation_candidates:
|
||||
candidate_names = [str(item.get("display_name") or "").strip() for item in activation_candidates[:3] if str(item.get("display_name") or "").strip()]
|
||||
action_cards.append({
|
||||
"type": "activation",
|
||||
"title": "建议优先激活边缘活跃成员",
|
||||
"summary": f"可以先针对 {'、'.join(candidate_names) if candidate_names else '近 7~30 天边缘成员'} 做轻量召回。",
|
||||
"detail": "这批成员不是长期沉默用户,仍有回流可能,更适合通过低门槛互动、固定提醒或轻福利重新拉回讨论。",
|
||||
})
|
||||
|
||||
if focus_topics:
|
||||
action_cards.append({
|
||||
"type": "topic",
|
||||
"title": "建议围绕当前高频主题设计话题入口",
|
||||
"summary": f"优先从 {focus_topics[0]}、{focus_topics[1] if len(focus_topics) > 1 else focus_topics[0]} 这类主题切入。",
|
||||
"detail": "与其硬推通用活动,不如顺着群里原本就在讨论的内容做问题、投票、接龙或功能引导,转化更自然。",
|
||||
})
|
||||
|
||||
if core_members:
|
||||
core_names = [str(item.get("display_name") or "").strip() for item in core_members[:3] if str(item.get("display_name") or "").strip()]
|
||||
action_cards.append({
|
||||
"type": "core",
|
||||
"title": "建议借核心成员带动扩散",
|
||||
"summary": f"可以优先让 {'、'.join(core_names) if core_names else '核心活跃成员'} 参与首轮互动。",
|
||||
"detail": "核心成员往往能决定首轮响应速度和讨论氛围,先让他们接住话题,比单纯增加曝光更有效。",
|
||||
})
|
||||
|
||||
if not action_cards:
|
||||
action_cards.append({
|
||||
"type": "fallback",
|
||||
"title": "建议先继续积累群画像与使用数据",
|
||||
"summary": "当前数据不足以给出更细的动作建议。",
|
||||
"detail": "可以先保持基础运营动作,等群总结、成员画像和插件使用数据更完整后,再做更细的策略推荐。",
|
||||
})
|
||||
|
||||
return action_cards[:4]
|
||||
|
||||
|
||||
# 旧群权限页入口兼容跳转
|
||||
@robot_bp.route('/')
|
||||
@login_required
|
||||
@@ -373,6 +698,31 @@ def api_group_detail(group_id):
|
||||
"desc": "当前活跃、消息量、插件调用都比较均衡,建议继续围绕高峰时段和高频插件做强化。"
|
||||
})
|
||||
|
||||
# 群运营分析 2.0 采用“增量增强”策略:
|
||||
# 1. 现有 overview / diagnosis / operation_suggestions 等结构继续保留,避免影响旧页面;
|
||||
# 2. 新增 ops_profile / ops_members / ops_actions 三类字段,供现有详情页逐步增强使用;
|
||||
# 3. 即使某些增强数据暂时为空,也不应影响老内容展示。
|
||||
ops_profile = _build_group_ops_profile(
|
||||
server=server,
|
||||
group_id=group_id,
|
||||
group_name=group_name,
|
||||
peak_hours=peak_hours,
|
||||
plugin_stats=plugin_stats,
|
||||
)
|
||||
ops_members = _build_ops_member_segments(
|
||||
server=server,
|
||||
group_id=group_id,
|
||||
speaker_ranking=speaker_ranking,
|
||||
inactive_members=inactive_members,
|
||||
)
|
||||
recent_summaries = _load_recent_group_summaries(server, group_id, limit=3)
|
||||
ops_actions = _build_ops_action_cards(
|
||||
group_name=group_name,
|
||||
ops_profile=ops_profile,
|
||||
ops_members=ops_members,
|
||||
plugin_stats=plugin_stats,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
@@ -442,6 +792,10 @@ def api_group_detail(group_id):
|
||||
"plugin_summary": plugin_summary,
|
||||
"plugin_stats": plugin_stats,
|
||||
"operation_suggestions": operation_suggestions,
|
||||
"ops_profile": ops_profile,
|
||||
"ops_members": ops_members,
|
||||
"ops_actions": ops_actions,
|
||||
"recent_summaries": recent_summaries,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
|
||||
@@ -18,6 +18,7 @@ from db.admin_account_db import AdminAccountDBOperator
|
||||
from db.emoji_asset_db import EmojiAssetDB
|
||||
from db.member_context_db import MemberContextDBOperator
|
||||
from db.message_storage import MessageStorageDB
|
||||
from db.message_summary_db import MessageSummaryDBOperator
|
||||
from db.stats_db import StatsDBOperator
|
||||
from db.task_db import TaskDBOperator
|
||||
from db.fun_command_rule_db import FunCommandRuleDBOperator
|
||||
@@ -61,6 +62,11 @@ class DashboardServer:
|
||||
# 3. 因此这里优先复用 Robot 已初始化的 message_storage,没有则再安全回退到 DB 层对象。
|
||||
self.message_storage = getattr(robot_instance, "message_storage", None) or MessageStorageDB(self.db_manager)
|
||||
self.emoji_asset_db = getattr(self.message_storage, "emoji_asset_db", None) or EmojiAssetDB(self.db_manager)
|
||||
# 群运营分析 2.0 会直接复用群消息总结表:
|
||||
# 1. 这类数据已经由现有插件产出,不需要另起一套采集逻辑;
|
||||
# 2. 统一在 DashboardServer 上挂载,便于多个后台蓝图复用;
|
||||
# 3. 即使对应插件未在当前请求时运行,数据库读能力也应保持可用。
|
||||
self.message_summary_db = MessageSummaryDBOperator(self.db_manager)
|
||||
self.contact_db: ContactsDBOperator = ContactsDBOperator(self.db_manager)
|
||||
self.member_context_db = MemberContextDBOperator(self.db_manager)
|
||||
self.task_db: TaskDBOperator = TaskDBOperator(self.db_manager)
|
||||
|
||||
@@ -262,6 +262,140 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!--
|
||||
群运营分析 2.0 先走“加法增强”:
|
||||
1. 老的健康度、趋势、排行继续保留;
|
||||
2. 新增群画像摘要,用更接近运营语言的方式解释群状态;
|
||||
3. 这样即使后面要调整 2.0 结构,也不会影响旧洞察面板。
|
||||
-->
|
||||
<div class="detail-section">
|
||||
<div class="section-title">
|
||||
<h3>群画像摘要</h3>
|
||||
</div>
|
||||
<el-row :gutter="16" class="detail-panels">
|
||||
<el-col :span="14">
|
||||
<el-card class="detail-card detail-card--profile" shadow="never">
|
||||
<div slot="header" class="detail-card-header">
|
||||
<span>群定位与讨论风格</span>
|
||||
<span class="detail-card-sub">2.0 增强摘要</span>
|
||||
</div>
|
||||
<div class="ops-profile-title">{% raw %}{{ groupInsight.ops_profile.group_identity || '综合交流型群' }}{% endraw %}</div>
|
||||
<div class="feature-chip-list">
|
||||
<el-tag
|
||||
v-for="tag in (groupInsight.ops_profile.profile_tags || [])"
|
||||
:key="tag"
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain">
|
||||
{% raw %}{{ tag }}{% endraw %}
|
||||
</el-tag>
|
||||
<span v-if="!(groupInsight.ops_profile.profile_tags || []).length" class="empty-inline">暂无群画像标签</span>
|
||||
</div>
|
||||
<div class="ops-profile-summary">{% raw %}{{ groupInsight.ops_profile.summary_text || '暂无群画像摘要' }}{% endraw %}</div>
|
||||
<div class="ops-topic-block">
|
||||
<div class="ops-subtitle">高频主题</div>
|
||||
<div class="feature-chip-list">
|
||||
<el-tag
|
||||
v-for="topic in (groupInsight.ops_profile.focus_topics || [])"
|
||||
:key="topic"
|
||||
size="small"
|
||||
type="success"
|
||||
effect="plain">
|
||||
{% raw %}{{ topic }}{% endraw %}
|
||||
</el-tag>
|
||||
<span v-if="!(groupInsight.ops_profile.focus_topics || []).length" class="empty-inline">暂无高频主题</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ops-topic-block" v-if="(groupInsight.ops_profile.unresolved_points || []).length">
|
||||
<div class="ops-subtitle">待跟进问题</div>
|
||||
<div class="ops-bullet-list">
|
||||
<div v-for="item in groupInsight.ops_profile.unresolved_points" :key="item" class="ops-bullet-item">
|
||||
{% raw %}{{ item }}{% endraw %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-card class="detail-card" shadow="never">
|
||||
<div slot="header" class="detail-card-header">
|
||||
<span>最近群总结</span>
|
||||
<span class="detail-card-sub">复用现有 message_summary</span>
|
||||
</div>
|
||||
<div class="ops-summary-timeline" v-if="(groupInsight.recent_summaries || []).length">
|
||||
<div v-for="item in groupInsight.recent_summaries" :key="`${item.summary_type}-${item.period_key}`" class="ops-summary-item">
|
||||
<div class="ops-summary-head">
|
||||
<span class="ops-summary-period">{% raw %}{{ item.period_key || '-' }}{% endraw %}</span>
|
||||
<span class="ops-summary-meta">{% raw %}{{ item.source_message_count || 0 }}{% endraw %} 条消息</span>
|
||||
</div>
|
||||
<div class="ops-summary-excerpt">{% raw %}{{ item.summary_excerpt || '暂无摘要内容' }}{% endraw %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-inline">当前还没有可展示的群总结记录</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
成员分层主要服务“下一步看谁”:
|
||||
1. 核心成员强调谁在带讨论;
|
||||
2. 高价值成员强调谁值得重点关注;
|
||||
3. 待激活成员强调谁最适合做轻量召回。
|
||||
-->
|
||||
<div class="detail-section">
|
||||
<div class="section-title">
|
||||
<h3>成员分层</h3>
|
||||
</div>
|
||||
<el-row :gutter="16" class="detail-panels detail-panels--triple">
|
||||
<el-col :span="8">
|
||||
<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="ops-member-list" v-if="(groupInsight.ops_members.core_members || []).length">
|
||||
<div v-for="item in groupInsight.ops_members.core_members" :key="item.wxid" class="ops-member-item">
|
||||
<div class="ops-member-name">{% raw %}{{ item.display_name }}{% endraw %}</div>
|
||||
<div class="ops-member-meta">消息数 {% raw %}{{ item.message_count }}{% endraw %} · {% raw %}{{ item.activity_level || '未分层' }}{% endraw %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-inline">暂无核心成员数据</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<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="ops-member-list" v-if="(groupInsight.ops_members.value_rank_members || []).length">
|
||||
<div v-for="item in groupInsight.ops_members.value_rank_members" :key="item.wxid" class="ops-member-item">
|
||||
<div class="ops-member-name">{% raw %}{{ item.display_name }}{% endraw %}</div>
|
||||
<div class="ops-member-meta">#{% raw %}{{ item.rank_no }}{% endraw %} · {% raw %}{{ item.title || '成员' }}{% endraw %} · 分值 {% raw %}{{ item.score }}{% endraw %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-inline">暂无高价值成员快照</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card class="detail-card" shadow="never">
|
||||
<div slot="header" class="detail-card-header">
|
||||
<span>待激活成员</span>
|
||||
<span class="detail-card-sub">近7~30天边缘活跃</span>
|
||||
</div>
|
||||
<div class="ops-member-list" v-if="(groupInsight.ops_members.activation_candidates || []).length">
|
||||
<div v-for="item in groupInsight.ops_members.activation_candidates" :key="item.wxid" class="ops-member-item">
|
||||
<div class="ops-member-name">{% raw %}{{ item.display_name }}{% endraw %}</div>
|
||||
<div class="ops-member-meta">距今 {% raw %}{{ item.inactivity_days }}{% endraw %} 天未发言</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-inline">暂无待激活成员</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-title">
|
||||
<h3>运营建议</h3>
|
||||
@@ -280,6 +414,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
旧版运营建议更偏诊断提示;
|
||||
新增的动作建议更偏“下一步怎么做”。
|
||||
两层同时保留,便于后续比较哪种信息对你更有用。
|
||||
-->
|
||||
<div class="detail-section">
|
||||
<div class="section-title">
|
||||
<h3>可执行动作建议</h3>
|
||||
</div>
|
||||
<el-row :gutter="16" class="detail-panels detail-panels--double">
|
||||
<el-col :span="12" v-for="(item, index) in (groupInsight.ops_actions || [])" :key="`${item.type}-${index}`">
|
||||
<el-card class="detail-card action-card" shadow="never">
|
||||
<div class="action-card-type">{% raw %}{{ item.type || 'action' }}{% endraw %}</div>
|
||||
<div class="action-card-title">{% raw %}{{ item.title }}{% endraw %}</div>
|
||||
<div class="action-card-summary">{% raw %}{{ item.summary }}{% endraw %}</div>
|
||||
<div class="action-card-detail">{% raw %}{{ item.detail }}{% endraw %}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" class="detail-panels">
|
||||
<el-col :span="12">
|
||||
<el-card class="detail-card" shadow="never">
|
||||
@@ -1810,11 +1965,87 @@
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px;
|
||||
}
|
||||
.detail-panels .el-col { margin-bottom: 16px; }
|
||||
.detail-panels--triple .el-col,
|
||||
.detail-panels--double .el-col { margin-bottom: 16px; }
|
||||
.detail-card { border-radius: 18px; }
|
||||
.detail-card--profile {
|
||||
background: linear-gradient(180deg, rgba(14,165,233,0.08), rgba(255,255,255,0.98));
|
||||
}
|
||||
.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; }
|
||||
.ops-profile-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ops-profile-summary {
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.78);
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
color: #334155;
|
||||
line-height: 1.8;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ops-topic-block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.ops-subtitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ops-bullet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.ops-bullet-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(248,250,252,0.9);
|
||||
color: #475569;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
line-height: 1.6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ops-summary-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.ops-summary-item {
|
||||
padding: 14px 14px 12px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(248,250,252,0.9), rgba(255,255,255,0.98));
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.ops-summary-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ops-summary-period {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
.ops-summary-meta {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.ops-summary-excerpt {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.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; }
|
||||
.group-announcement-wrap { display: flex; gap: 12px; align-items: flex-start; justify-content: space-between; }
|
||||
@@ -1822,6 +2053,66 @@
|
||||
.detail-inline-note { margin-top: 12px; line-height: 1.6; }
|
||||
.suggestion-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.suggestion-item { border-radius: 14px; }
|
||||
.ops-member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.ops-member-item {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(248,250,252,0.9);
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.ops-member-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ops-member-meta {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.action-card {
|
||||
min-height: 180px;
|
||||
background: linear-gradient(180deg, rgba(249,115,22,0.06), rgba(255,255,255,0.98));
|
||||
}
|
||||
.action-card-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(249,115,22,0.12);
|
||||
color: #c2410c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.action-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.action-card-summary {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.action-card-detail {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.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;
|
||||
@@ -1946,6 +2237,8 @@
|
||||
.page-hero-actions, .detail-tags { justify-content: flex-start; }
|
||||
.hero-search, .group-search { width: 100%; }
|
||||
.diagnosis-grid { grid-template-columns: 1fr; }
|
||||
.detail-panels--triple .el-col,
|
||||
.detail-panels--double .el-col { width: 100%; }
|
||||
.chat-header-card { flex-direction: column; align-items: flex-start; }
|
||||
.chat-header-actions { justify-content: flex-start; }
|
||||
.message-content { max-width: 92%; }
|
||||
|
||||
Reference in New Issue
Block a user