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
@@ -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 %}