Files
abot/admin/dashboard/templates/robot_management.html
2026-04-13 11:04:20 +08:00

1238 lines
48 KiB
HTML

{% extends "base.html" %}
{% block title %}群权限管理 - 机器人管理后台{% endblock %}
{% block content %}
<div class="page-shell robot-page">
<div class="page-hero">
<div class="page-hero-copy">
<div class="page-eyebrow">Groups Workspace</div>
<h1>群权限与机器人管理</h1>
<p>集中管理群组启停、权限开关与消息趋势,减少传统后台式分散操作。</p>
</div>
<div class="page-hero-actions">
<el-input
placeholder="搜索群组名称 / ID"
v-model="searchQuery"
class="hero-search"
clearable>
<i slot="prefix" class="el-input__icon el-icon-search"></i>
</el-input>
<el-button type="primary" @click="showAddGroupDialog">
<i class="el-icon-plus"></i> 添加群组
</el-button>
</div>
</div>
<el-row :gutter="16" class="overview-grid">
<el-col :span="6">
<el-card class="overview-card overview-card--primary" shadow="hover">
<div class="overview-label">群组总数</div>
<div class="overview-value">{% raw %}{{ groups.length }}{% endraw %}</div>
<div class="overview-note">已纳入机器人管理的群组</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="overview-card" shadow="hover">
<div class="overview-label">已启用</div>
<div class="overview-value">{% raw %}{{ enabledGroupsCount }}{% endraw %}</div>
<div class="overview-note">机器人功能已开启</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="overview-card" shadow="hover">
<div class="overview-label">已关闭</div>
<div class="overview-value">{% raw %}{{ disabledGroupsCount }}{% endraw %}</div>
<div class="overview-note">等待重新启用或清理</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="overview-card overview-card--soft" shadow="hover">
<div class="overview-label">当前筛选结果</div>
<div class="overview-value">{% raw %}{{ filteredGroups.length }}{% endraw %}</div>
<div class="overview-note">按搜索条件即时过滤</div>
</el-card>
</el-col>
</el-row>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>群组列表</h3>
<p>先处理状态,再深入查看权限与趋势。</p>
</div>
<div class="workspace-actions" v-if="selectedGroups.length > 0">
<span class="selection-summary">已选择 {% raw %}{{ selectedGroups.length }}{% endraw %} 个群组</span>
<el-button type="success" size="small" @click="batchEnableRobot">批量启用</el-button>
<el-button type="danger" size="small" @click="batchDisableRobot">批量关闭</el-button>
<el-button type="warning" size="small" @click="batchRemoveGroups">批量移除</el-button>
</div>
</div>
<el-table
:data="filteredGroups"
style="width: 100%"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="52"></el-table-column>
<el-table-column label="群组信息" min-width="300">
<template slot-scope="scope">
<div class="entity-cell">
<div class="entity-badge">{% raw %}{{ scope.$index + 1 }}{% endraw %}</div>
<div class="entity-copy">
<div class="entity-title">{% raw %}{{ scope.row.group_name || scope.row.group_id }}{% endraw %}</div>
<div class="entity-subtitle">Group ID: {% raw %}{{ scope.row.group_id }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="机器人状态" width="140" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.robot_status === 'enabled' ? 'success' : 'info'">
{% raw %}{{ scope.row.robot_status === 'enabled' ? '已启用' : '已关闭' }}{% endraw %}
</el-tag>
</template>
</el-table-column>
<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>
<el-button
size="mini"
:type="scope.row.robot_status === 'enabled' ? 'danger' : 'success'"
@click="toggleRobotStatus(scope.row)">
{% raw %}{{ scope.row.robot_status === 'enabled' ? '关闭机器人' : '启用机器人' }}{% endraw %}
</el-button>
<el-button size="mini" type="info" plain @click="viewMessageTrend(scope.row)">
消息趋势
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
:title="currentGroupName + ' 功能权限管理'"
:visible.sync="permissionDialogVisible"
width="72%">
<div class="dialog-intro">按功能维度快速启停,适合对群组做差异化权限控制。</div>
<el-table :data="permissions" style="width: 100%">
<el-table-column prop="feature_id" label="功能 ID" width="90"></el-table-column>
<el-table-column prop="feature_description" label="功能描述"></el-table-column>
<el-table-column label="当前状态" width="120" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 'enabled' ? 'success' : 'info'">
{% raw %}{{ scope.row.status === 'enabled' ? '已启用' : '已关闭' }}{% endraw %}
</el-tag>
</template>
</el-table-column>
<el-table-column label="切换" width="140" align="center">
<template slot-scope="scope">
<el-switch
v-model="scope.row.statusBool"
active-color="#10b981"
inactive-color="#cbd5e1"
@change="togglePermission(scope.row)">
</el-switch>
</template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer dialog-footer-split">
<div>
<el-button @click="enableAllPermissions" type="success">一键启用全部</el-button>
<el-button @click="disableAllPermissions" type="danger">一键关闭全部</el-button>
</div>
<el-button @click="permissionDialogVisible = false">关闭</el-button>
</div>
</el-dialog>
<el-dialog title="添加群组" :visible.sync="addGroupDialogVisible" width="34%">
<div class="dialog-intro">输入需要纳入机器人管理的群组 ID。</div>
<el-form :model="addGroupForm" :rules="addGroupRules" ref="addGroupForm">
<el-form-item label="群组ID" prop="groupId">
<el-input v-model="addGroupForm.groupId" placeholder="请输入群组ID"></el-input>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="addGroupDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitAddGroup">确定</el-button>
</span>
</el-dialog>
<el-dialog title="群组消息趋势" :visible.sync="messageTrendDialogVisible" width="72%">
<div class="dialog-intro">观察该群组在当前统计区间内的消息节奏与活跃度变化。</div>
<div class="chart-shell">
<div class="chart-heading">{% raw %}{{ currentGroupName }}{% endraw %} · 消息趋势</div>
<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 %}
{% block scripts %}
<script>
new Vue({
el: '#app',
mixins: [baseApp],
data() {
return {
groups: [],
permissions: [],
currentGroupId: null,
currentGroupName: '',
searchQuery: '',
selectedGroups: [],
permissionDialogVisible: false,
addGroupDialogVisible: false,
addGroupForm: {
groupId: ''
},
addGroupRules: {
groupId: [
{ required: true, message: '请输入群组ID', trigger: 'blur' },
{ pattern: /^\S+$/, message: '群组ID不能包含空格', trigger: 'blur' }
]
},
groupDetailDialogVisible: false,
groupDetailLoading: false,
groupDetail: null,
messageTrendDialogVisible: false,
messageTrendData: {
dates: [],
counts: []
}
}
},
computed: {
filteredGroups() {
if (!this.searchQuery) return this.groups;
const query = this.searchQuery.toLowerCase();
return this.groups.filter(group =>
(group.group_id && group.group_id.toLowerCase().includes(query)) ||
(group.group_name && group.group_name.toLowerCase().includes(query))
);
},
enabledGroupsCount() {
return this.groups.filter(group => group.robot_status === 'enabled').length;
},
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() {
this.currentView = '6';
this.loadGroups();
},
methods: {
loadGroups() {
axios.get('/robot/api/groups')
.then(response => {
if (response.data.success) {
this.groups = response.data.data || [];
} else {
this.$message.error('加载群组失败');
}
})
.catch(error => {
console.error('加载群组失败:', error);
this.$message.error('加载群组失败: ' + error.message);
});
},
handleSelectionChange(selection) {
this.selectedGroups = selection.map(item => item.group_id);
},
viewGroupPermissions(group) {
this.currentGroupId = group.group_id;
this.currentGroupName = group.group_name || group.group_id;
axios.get(`/robot/api/group/${group.group_id}/permissions`)
.then(response => {
if (response.data.success) {
const permissionsData = response.data.data || [];
this.permissions = permissionsData.map(p => ({
...p,
statusBool: p.status === 'enabled'
}));
this.permissionDialogVisible = true;
} else {
this.$message.error('加载权限失败');
}
})
.catch(error => {
console.error('加载权限失败:', error);
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';
axios.post(`/robot/api/group/${this.currentGroupId}/permissions`, {
feature_id: permission.feature_id,
status: newStatus
})
.then(response => {
if (response.data.success) {
permission.status = newStatus;
this.$message.success('更新权限成功');
} else {
permission.statusBool = !permission.statusBool;
this.$message.error('更新权限失败');
}
})
.catch(error => {
permission.statusBool = !permission.statusBool;
console.error('更新权限失败:', error);
this.$message.error('更新权限失败: ' + error.message);
});
},
enableAllPermissions() {
this.updateAllPermissions('enabled');
},
disableAllPermissions() {
this.updateAllPermissions('disabled');
},
updateAllPermissions(status) {
axios.post(`/robot/api/group/${this.currentGroupId}/permissions`, {
status: status
})
.then(response => {
if (response.data.success) {
this.permissions.forEach(p => {
p.status = status;
p.statusBool = status === 'enabled';
});
this.$message.success('批量更新权限成功');
} else {
this.$message.error('批量更新权限失败');
}
})
.catch(error => {
console.error('批量更新权限失败:', error);
this.$message.error('批量更新权限失败: ' + error.message);
});
},
toggleRobotStatus(group) {
const newStatus = group.robot_status === 'enabled' ? 'disabled' : 'enabled';
axios.post(`/robot/api/group/${group.group_id}/status`, {
status: newStatus
})
.then(response => {
if (response.data.success) {
group.robot_status = newStatus;
this.$message.success('更新机器人状态成功');
} else {
this.$message.error('更新机器人状态失败');
}
})
.catch(error => {
console.error('更新机器人状态失败:', error);
this.$message.error('更新机器人状态失败: ' + error.message);
});
},
showAddGroupDialog() {
this.addGroupForm.groupId = '';
this.addGroupDialogVisible = true;
},
submitAddGroup() {
this.$refs.addGroupForm.validate(valid => {
if (valid) {
axios.post('/robot/api/add_group', {
group_id: this.addGroupForm.groupId
})
.then(response => {
if (response.data.success) {
this.addGroupDialogVisible = false;
this.$message.success('添加群组成功');
this.loadGroups();
} else {
this.$message.error('添加群组失败');
}
})
.catch(error => {
console.error('添加群组失败:', error);
this.$message.error('添加群组失败: ' + error.message);
});
}
});
},
batchEnableRobot() {
this.batchUpdateRobotStatus('enabled');
},
batchDisableRobot() {
this.batchUpdateRobotStatus('disabled');
},
batchUpdateRobotStatus(status) {
if (this.selectedGroups.length === 0) {
this.$message.warning('请先选择群组');
return;
}
axios.post('/robot/api/batch_operation', {
operation: 'update_status',
group_ids: this.selectedGroups,
status: status
})
.then(response => {
if (response.data.success) {
this.groups.forEach(g => {
if (this.selectedGroups.includes(g.group_id)) {
g.robot_status = status;
}
});
this.$message.success('批量更新状态成功');
} else {
this.$message.error('批量更新状态失败');
}
})
.catch(error => {
console.error('批量更新状态失败:', error);
this.$message.error('批量更新状态失败: ' + error.message);
});
},
batchRemoveGroups() {
if (this.selectedGroups.length === 0) {
this.$message.warning('请先选择群组');
return;
}
this.$confirm(`确定要批量移除 ${this.selectedGroups.length} 个群组吗? 此操作将清除这些群组的所有设置。`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'danger'
}).then(() => {
axios.post('/robot/api/batch_operation', {
operation: 'remove_groups',
group_ids: this.selectedGroups
})
.then(response => {
if (response.data.success) {
this.groups = this.groups.filter(g => !this.selectedGroups.includes(g.group_id));
this.selectedGroups = [];
this.$message.success('批量移除成功');
} else {
this.$message.error('批量移除失败');
}
})
.catch(error => {
console.error('批量移除失败:', error);
this.$message.error('批量移除失败: ' + error.message);
});
}).catch(() => {});
},
viewMessageTrend(group) {
this.currentGroupId = group.group_id;
this.currentGroupName = group.group_name || group.group_id;
const days = parseInt(this.timeRange || 7);
axios.get(`/robot/api/group/${group.group_id}/message_trend?days=${days}`)
.then(response => {
if (response.data.success) {
this.messageTrendData = response.data.data || { dates: [], counts: [] };
this.messageTrendDialogVisible = true;
this.$nextTick(() => {
this.renderMessageTrendChart();
});
} else {
this.$message.error('加载消息趋势失败');
}
})
.catch(error => {
console.error('加载消息趋势失败:', error);
this.$message.error('加载消息趋势失败: ' + error.message);
});
},
renderMessageTrendChart() {
const ctx = document.getElementById('messageTrendChart').getContext('2d');
if (this.charts && this.charts.messageTrendChart) {
this.charts.messageTrendChart.destroy();
}
if (!this.charts) {
this.charts = {};
}
this.charts.messageTrendChart = new Chart(ctx, {
type: 'line',
data: {
labels: this.messageTrendData.dates,
datasets: [
{
label: '消息数量',
data: this.messageTrendData.counts,
fill: true,
backgroundColor: 'rgba(79, 70, 229, 0.10)',
borderColor: 'rgba(79, 70, 229, 1)',
tension: 0.28,
borderWidth: 3,
pointRadius: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(148,163,184,0.12)'
}
},
x: {
grid: {
display: false
}
}
},
plugins: {
legend: {
display: false
}
}
}
});
},
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}`;
}
}
});
</script>
{% endblock %}
{% block styles %}
<style>
.page-shell {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
padding: 24px 26px;
border-radius: 24px;
background: linear-gradient(135deg, rgba(79,70,229,0.10), rgba(59,130,246,0.08), rgba(255,255,255,0.9));
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
}
.page-eyebrow {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .08em;
color: #6366f1;
font-weight: 700;
margin-bottom: 8px;
}
.page-hero-copy h1 {
font-size: 30px;
line-height: 1.1;
margin-bottom: 10px;
color: #0f172a;
}
.page-hero-copy p {
color: #64748b;
font-size: 14px;
}
.page-hero-actions {
display: flex;
align-items: center;
gap: 12px;
}
.hero-search {
width: 260px;
}
.overview-grid .el-col {
margin-bottom: 16px;
}
.overview-card {
min-height: 112px;
}
.overview-card--primary {
background: linear-gradient(180deg, rgba(79,70,229,0.10), rgba(255,255,255,0.94)) !important;
}
.overview-card--soft {
background: linear-gradient(180deg, rgba(59,130,246,0.08), rgba(255,255,255,0.94)) !important;
}
.overview-label {
font-size: 13px;
color: #64748b;
margin-bottom: 14px;
}
.overview-value {
font-size: 30px;
font-weight: 700;
color: #0f172a;
margin-bottom: 10px;
}
.overview-note {
font-size: 12px;
color: #94a3b8;
}
.workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.workspace-header h3 {
font-size: 18px;
margin-bottom: 4px;
}
.workspace-header p {
font-size: 13px;
color: #64748b;
}
.workspace-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.selection-summary {
font-size: 13px;
color: #475569;
margin-right: 4px;
}
.entity-cell {
display: flex;
align-items: center;
gap: 12px;
}
.entity-badge {
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(79,70,229,0.10);
color: #4f46e5;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.entity-title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.entity-subtitle {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.action-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.dialog-intro {
margin-bottom: 14px;
color: #64748b;
font-size: 13px;
}
.dialog-footer-split {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.chart-shell {
padding: 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(248,250,252,0.78), rgba(255,255,255,0.96));
border: 1px solid rgba(148,163,184,0.12);
}
.chart-heading {
margin-bottom: 12px;
font-size: 14px;
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 %}