feat:聊天信息支持 文件、图片、语音发送,增加一键启停脚本

This commit is contained in:
bujinyuan
2026-04-20 11:53:27 +08:00
parent 1a5aac9998
commit 0c1540bdee
116 changed files with 20277 additions and 144 deletions

86
admin/src/api/message.js Normal file
View File

@@ -0,0 +1,86 @@
/**
* 消息管理 API 模块
*
* 对应后端路由:/api/v1/admin/messages
* 所有接口需要 JWT + admin 角色权限
*
* 接口列表:
* - GET /api/v1/admin/messages 消息列表(分页 + 多条件筛选)
* - GET /api/v1/admin/messages/:id 消息详情
* - DELETE /api/v1/admin/messages/:id 删除消息(软删除)
* - PUT /api/v1/admin/messages/:id/recall 撤回消息
* - GET /api/v1/admin/messages/stats 消息统计
*/
import request from '@/utils/request'
/**
* 获取消息列表
* @param {Object} params - 查询参数
* @param {number} [params.page] - 页码(默认 1
* @param {number} [params.page_size] - 每页数量(默认 20
* @param {string} [params.keyword] - 搜索关键词(模糊匹配消息内容)
* @param {number} [params.type] - 消息类型筛选1=文本/2=图片/3=语音/5=文件/10=系统)
* @param {number} [params.sender_id] - 发送者 ID
* @param {number} [params.conversation_id] - 会话 ID
* @param {number} [params.status] - 消息状态1=正常/2=已撤回/3=已删除)
* @param {string} [params.start_time] - 开始时间YYYY-MM-DD
* @param {string} [params.end_time] - 结束时间YYYY-MM-DD
* @returns {Promise<{data: {total: number, list: Array, page: number, page_size: number}}>}
*/
export function getMessageList(params) {
return request({
url: '/api/v1/admin/messages',
method: 'get',
params
})
}
/**
* 获取消息详情
* @param {number} id - 消息 ID
* @returns {Promise<{data: Object}>}
*/
export function getMessageDetail(id) {
return request({
url: `/api/v1/admin/messages/${id}`,
method: 'get'
})
}
/**
* 删除消息(软删除)
* @param {number} id - 消息 ID
* @returns {Promise}
*/
export function deleteMessage(id) {
return request({
url: `/api/v1/admin/messages/${id}`,
method: 'delete'
})
}
/**
* 撤回消息
* @param {number} id - 消息 ID
* @returns {Promise}
*/
export function recallMessage(id) {
return request({
url: `/api/v1/admin/messages/${id}/recall`,
method: 'put'
})
}
/**
* 获取消息统计数据
* @param {Object} params - 查询参数
* @param {number} [params.days] - 统计天数(默认 7最大 90
* @returns {Promise<{data: {total_count, today_count, type_distribution, daily_trend, active_users, active_groups}}>}
*/
export function getMessageStats(params) {
return request({
url: '/api/v1/admin/messages/stats',
method: 'get',
params
})
}

View File

@@ -62,6 +62,18 @@ const routes = [
name: 'GroupManage',
component: () => import('@/views/group/list.vue'),
meta: { title: '群组管理' }
},
{
path: 'message/list',
name: 'MessageList',
component: () => import('@/views/message/list.vue'),
meta: { title: '消息列表' }
},
{
path: 'message/stats',
name: 'MessageStats',
component: () => import('@/views/message/stats.vue'),
meta: { title: '消息统计' }
}
]
}

View File

@@ -56,6 +56,15 @@
<el-menu-item index="/group/list">群组列表</el-menu-item>
</el-sub-menu>
<el-sub-menu index="message-manage">
<template #title>
<el-icon><ChatRound /></el-icon>
<span>消息管理</span>
</template>
<el-menu-item index="/message/list">消息列表</el-menu-item>
<el-menu-item index="/message/stats">消息统计</el-menu-item>
</el-sub-menu>
<el-menu-item index="/monitor/online">
<el-icon><Monitor /></el-icon>
<template #title>在线监控</template>

View File

@@ -0,0 +1,756 @@
<!--
管理端消息列表页面
设计系统Data-Dense Dashboard 风格与群组/用户列表页保持一致
功能
- 多条件筛选关键词 / 消息类型 / 发送者 / 状态 / 时间范围
- 消息数据表格Element Plus Table
- 分页组件
- 操作按钮查看详情 / 撤回 / 删除
- 消息详情弹窗含富媒体预览
对应后端 API
- GET /api/v1/admin/messages
- GET /api/v1/admin/messages/:id
- DELETE /api/v1/admin/messages/:id
- PUT /api/v1/admin/messages/:id/recall
-->
<template>
<div class="message-list-page">
<div class="page-header">
<h2 class="page-title">消息管理</h2>
</div>
<!-- 筛选区 -->
<el-card class="filter-card" shadow="never">
<el-form :inline="true" class="filter-form" @submit.prevent="handleSearch">
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="搜索消息内容"
clearable
style="width: 200px"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="filters.type" placeholder="全部" clearable style="width: 120px">
<el-option label="文本" :value="1" />
<el-option label="图片" :value="2" />
<el-option label="语音" :value="3" />
<el-option label="文件" :value="5" />
<el-option label="系统" :value="10" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 120px">
<el-option label="正常" :value="1" />
<el-option label="已撤回" :value="2" />
<el-option label="已删除" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="发送者ID">
<el-input
v-model="filters.sender_id"
placeholder="用户ID"
clearable
style="width: 120px"
type="number"
/>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
@change="onDateRangeChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="messageList"
stripe
border
style="width: 100%"
row-class-name="table-row"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column label="发送者" min-width="150">
<template #default="{ row }">
<div class="sender-cell">
<div
class="sender-avatar"
:style="{ backgroundColor: getAvatarColor(row.sender_nickname) }"
>
<img v-if="row.sender_avatar" :src="row.sender_avatar" class="sender-avatar-img" />
<span v-else>{{ (row.sender_nickname || '?')[0] }}</span>
</div>
<div class="sender-info">
<span class="sender-name">{{ row.sender_nickname || '-' }}</span>
<span class="sender-id">ID: {{ row.sender_id }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="90" align="center">
<template #default="{ row }">
<el-tag :type="typeTagMap[row.type]?.type || 'info'" size="small" effect="light">
{{ row.type_label || typeTagMap[row.type]?.label || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="内容" min-width="240">
<template #default="{ row }">
<span class="content-preview">{{ getContentPreview(row) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagMap[row.status]?.type || 'info'" size="small">
{{ row.status_label || statusTagMap[row.status]?.label || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="conversation_id" label="会话ID" width="90" align="center" />
<el-table-column prop="created_at" label="发送时间" width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="viewDetail(row)">
详情
</el-button>
<el-button
v-if="row.status === 1"
type="warning"
link
size="small"
@click="handleRecall(row)"
>
撤回
</el-button>
<el-button
v-if="row.status !== 3"
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchMessageList"
@current-change="fetchMessageList"
/>
</div>
</el-card>
<!-- 消息详情弹窗 -->
<el-dialog
v-model="detailVisible"
title="消息详情"
width="640px"
destroy-on-close
class="detail-dialog"
>
<div v-loading="detailLoading" class="detail-content">
<template v-if="currentMsg">
<!-- 消息头部 -->
<div class="detail-header">
<div
class="detail-avatar"
:style="{ backgroundColor: getAvatarColor(currentMsg.sender_nickname) }"
>
<img
v-if="currentMsg.sender_avatar"
:src="currentMsg.sender_avatar"
class="detail-avatar-img"
/>
<span v-else>{{ (currentMsg.sender_nickname || '?')[0] }}</span>
</div>
<div class="detail-header-info">
<div class="detail-header-name">
<span class="detail-sender-name">{{ currentMsg.sender_nickname }}</span>
<el-tag
:type="statusTagMap[currentMsg.status]?.type || 'info'"
size="small"
effect="light"
>
{{ currentMsg.status_label }}
</el-tag>
</div>
<div class="detail-header-meta">
<span class="meta-item">ID: {{ currentMsg.id }}</span>
<el-divider direction="vertical" />
<span class="meta-item">发送者ID: {{ currentMsg.sender_id }}</span>
<el-divider direction="vertical" />
<span class="meta-item">会话ID: {{ currentMsg.conversation_id }}</span>
</div>
</div>
</div>
<!-- 消息元数据 -->
<div class="detail-grid">
<div class="detail-grid-item">
<span class="grid-label">消息类型</span>
<span class="grid-value">
<el-tag
:type="typeTagMap[currentMsg.type]?.type || 'info'"
size="small"
effect="plain"
>
{{ currentMsg.type_label }}
</el-tag>
</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">发送时间</span>
<span class="grid-value">{{ currentMsg.created_at }}</span>
</div>
</div>
<!-- 消息内容 -->
<div class="detail-section">
<div class="section-header">消息内容</div>
<div class="msg-content-box">
<template v-if="currentMsg.type === 1 || currentMsg.type === 10">
<p class="msg-text-content">{{ currentMsg.content }}</p>
</template>
<template v-else-if="currentMsg.type === 2 && parsedExtra">
<div class="msg-image-grid">
<img
v-for="(img, idx) in parsedExtra.images || []"
:key="idx"
:src="img.thumbnail_url || img.url"
class="msg-image-thumb"
@click="previewImage(img.url)"
/>
</div>
<p v-if="currentMsg.content" class="msg-text-content">{{ currentMsg.content }}</p>
</template>
<template v-else-if="currentMsg.type === 3 && parsedExtra">
<div class="msg-voice-info">
<el-icon :size="20"><Microphone /></el-icon>
<span>语音消息 {{ parsedExtra.duration || 0 }}"</span>
<a v-if="parsedExtra.url" :href="parsedExtra.url" target="_blank" class="voice-link">播放</a>
</div>
</template>
<template v-else-if="currentMsg.type === 5 && parsedExtra">
<div class="msg-file-info">
<el-icon :size="20"><Document /></el-icon>
<div class="file-meta">
<span class="file-name">{{ parsedExtra.file_name || '未知文件' }}</span>
<span class="file-size">{{ formatFileSize(parsedExtra.size || 0) }}</span>
</div>
<a v-if="parsedExtra.url" :href="parsedExtra.url" target="_blank" class="file-link">
下载
</a>
</div>
</template>
<template v-else>
<p class="msg-text-content">{{ currentMsg.content || '(无文本内容)' }}</p>
</template>
</div>
</div>
<!-- extra 原始数据(可折叠) -->
<div v-if="currentMsg.extra" class="detail-section">
<el-collapse>
<el-collapse-item title="Extra 原始数据">
<pre class="extra-raw">{{ formatJSON(currentMsg.extra) }}</pre>
</el-collapse-item>
</el-collapse>
</div>
</template>
</div>
</el-dialog>
</div>
</template>
<script setup>
/**
* 管理端消息列表逻辑
*
* 数据流API → 列表渲染 → 筛选 / 分页 → 详情弹窗 → 撤回 / 删除
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { Search, Microphone, Document } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getMessageList, getMessageDetail, deleteMessage, recallMessage } from '@/api/message'
const AVATAR_COLORS = ['#2563EB', '#7C3AED', '#059669', '#D97706', '#DC2626', '#0891B2', '#4F46E5', '#BE185D']
const typeTagMap = {
1: { label: '文本', type: '' },
2: { label: '图片', type: 'success' },
3: { label: '语音', type: 'warning' },
5: { label: '文件', type: 'primary' },
10: { label: '系统', type: 'info' }
}
const statusTagMap = {
1: { label: '正常', type: 'success' },
2: { label: '已撤回', type: 'warning' },
3: { label: '已删除', type: 'danger' }
}
const loading = ref(false)
const messageList = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const dateRange = ref(null)
const filters = reactive({
keyword: '',
type: null,
status: null,
sender_id: null
})
const detailVisible = ref(false)
const detailLoading = ref(false)
const currentMsg = ref(null)
const parsedExtra = computed(() => {
if (!currentMsg.value?.extra) return null
try {
return JSON.parse(currentMsg.value.extra)
} catch {
return null
}
})
const getAvatarColor = (name) => {
if (!name) return '#94A3B8'
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
}
const getContentPreview = (row) => {
if (row.status === 2) return '[已撤回]'
if (row.status === 3) return '[已删除]'
if (row.type === 2) return '[图片]'
if (row.type === 3) return `[语音]`
if (row.type === 5) {
try {
const extra = JSON.parse(row.extra || '{}')
return `[文件] ${extra.file_name || ''}`
} catch {
return '[文件]'
}
}
return row.content || ''
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const formatJSON = (str) => {
try {
return JSON.stringify(JSON.parse(str), null, 2)
} catch {
return str
}
}
const previewImage = (url) => {
window.open(url, '_blank')
}
const onDateRangeChange = (val) => {
if (!val) {
filters.start_time = undefined
filters.end_time = undefined
}
}
/** 获取消息列表 */
const fetchMessageList = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.type != null) params.type = filters.type
if (filters.status != null) params.status = filters.status
if (filters.sender_id) params.sender_id = Number(filters.sender_id)
if (dateRange.value && dateRange.value.length === 2) {
params.start_time = dateRange.value[0]
params.end_time = dateRange.value[1]
}
const res = await getMessageList(params)
messageList.value = res.data?.list || []
total.value = res.data?.total || 0
} catch (e) {
ElMessage.error(e?.message || '获取消息列表失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
fetchMessageList()
}
const handleReset = () => {
filters.keyword = ''
filters.type = null
filters.status = null
filters.sender_id = null
dateRange.value = null
currentPage.value = 1
fetchMessageList()
}
/** 查看消息详情 */
const viewDetail = async (row) => {
detailVisible.value = true
detailLoading.value = true
currentMsg.value = null
try {
const res = await getMessageDetail(row.id)
currentMsg.value = res.data
} catch (e) {
ElMessage.error(e?.message || '获取消息详情失败')
} finally {
detailLoading.value = false
}
}
/** 撤回消息 */
const handleRecall = (row) => {
ElMessageBox.confirm(
`确定要撤回消息 #${row.id} 吗?撤回后该消息对所有用户不可见。`,
'撤回消息',
{ confirmButtonText: '确定撤回', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
await recallMessage(row.id)
ElMessage.success('消息已撤回')
fetchMessageList()
} catch (e) {
ElMessage.error(e?.message || '撤回失败')
}
}).catch(() => {})
}
/** 删除消息 */
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除消息 #${row.id} 吗?`,
'删除消息',
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
await deleteMessage(row.id)
ElMessage.success('消息已删除')
fetchMessageList()
} catch (e) {
ElMessage.error(e?.message || '删除失败')
}
}).catch(() => {})
}
onMounted(() => {
fetchMessageList()
})
</script>
<style scoped>
.message-list-page { padding: 0; }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #1E293B;
margin: 0;
}
.filter-card { margin-bottom: 16px; }
.filter-form {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.table-card { margin-bottom: 16px; }
/* 发送者单元格 */
.sender-cell {
display: flex;
align-items: center;
gap: 10px;
}
.sender-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: #FFFFFF;
flex-shrink: 0;
overflow: hidden;
}
.sender-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.sender-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sender-name {
font-weight: 500;
color: #1E293B;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sender-id {
font-size: 11px;
color: #94A3B8;
}
.content-preview {
font-size: 13px;
color: #475569;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding-top: 16px;
}
/* ===== 消息详情弹窗 ===== */
.detail-content { min-height: 200px; }
.detail-header {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: linear-gradient(135deg, #F8FAFC 0%, #EFF6FF 100%);
border-radius: 12px;
margin-bottom: 20px;
}
.detail-avatar {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
color: #FFFFFF;
flex-shrink: 0;
overflow: hidden;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
}
.detail-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail-header-info { flex: 1; min-width: 0; }
.detail-header-name {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.detail-sender-name {
font-size: 16px;
font-weight: 700;
color: #0F172A;
}
.detail-header-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #64748B;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.detail-grid-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
background: #F8FAFC;
border-radius: 8px;
border: 1px solid #E2E8F0;
}
.grid-label {
font-size: 12px;
color: #94A3B8;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.grid-value {
font-size: 14px;
color: #1E293B;
font-weight: 500;
}
.detail-section { margin-bottom: 20px; }
.section-header {
font-size: 14px;
font-weight: 600;
color: #334155;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #E2E8F0;
}
.msg-content-box {
padding: 16px;
background: #F8FAFC;
border-radius: 8px;
border: 1px solid #E2E8F0;
}
.msg-text-content {
margin: 0;
font-size: 14px;
color: #334155;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.msg-image-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.msg-image-thumb {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
border: 1px solid #E2E8F0;
transition: transform 0.2s;
}
.msg-image-thumb:hover { transform: scale(1.05); }
.msg-voice-info, .msg-file-info {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
}
.file-meta {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.file-name {
font-size: 14px;
font-weight: 500;
color: #1E293B;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 12px;
color: #94A3B8;
}
.voice-link, .file-link {
color: #2563EB;
font-size: 13px;
text-decoration: none;
}
.voice-link:hover, .file-link:hover { text-decoration: underline; }
.extra-raw {
font-size: 12px;
color: #64748B;
background: #F1F5F9;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
max-height: 200px;
margin: 0;
}
</style>

View File

@@ -0,0 +1,387 @@
<!--
管理端消息统计仪表板
设计系统Data-Dense Dashboard 风格
图表库ECharts
主题色Primary #2563EB
功能
- 顶部统计卡片总消息数 + 今日消息数
- 消息趋势折线图7/30 天切换
- 消息类型分布饼图
- 活跃用户排行水平柱状图Top 10
- 活跃群组排行水平柱状图Top 10
对应后端 APIGET /api/v1/admin/messages/stats
-->
<template>
<div class="stats-page">
<div class="page-header">
<h2 class="page-title">消息统计</h2>
<div class="header-actions">
<el-radio-group v-model="days" size="default" @change="fetchStats">
<el-radio-button :value="7"> 7 </el-radio-button>
<el-radio-button :value="30"> 30 </el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-card-icon total-icon">
<el-icon :size="24"><ChatDotRound /></el-icon>
</div>
<div class="stat-card-info">
<span class="stat-label">消息总数</span>
<span class="stat-value">{{ formatNumber(stats.total_count) }}</span>
</div>
</div>
<div class="stat-card">
<div class="stat-card-icon today-icon">
<el-icon :size="24"><Sunrise /></el-icon>
</div>
<div class="stat-card-info">
<span class="stat-label">今日消息</span>
<span class="stat-value">{{ formatNumber(stats.today_count) }}</span>
</div>
</div>
</div>
<!-- 图表区域 -->
<div v-loading="loading" class="charts-grid">
<!-- 消息趋势折线图 -->
<el-card class="chart-card chart-card-wide" shadow="never">
<template #header>
<span class="chart-title">消息趋势</span>
</template>
<div ref="trendChartRef" class="chart-container"></div>
</el-card>
<!-- 消息类型分布饼图 -->
<el-card class="chart-card" shadow="never">
<template #header>
<span class="chart-title">类型分布</span>
</template>
<div ref="typeChartRef" class="chart-container"></div>
</el-card>
<!-- 活跃用户排行 -->
<el-card class="chart-card" shadow="never">
<template #header>
<span class="chart-title">活跃用户 Top 10</span>
</template>
<div ref="userChartRef" class="chart-container"></div>
</el-card>
<!-- 活跃群组排行 -->
<el-card class="chart-card" shadow="never">
<template #header>
<span class="chart-title">活跃群组 Top 10</span>
</template>
<div ref="groupChartRef" class="chart-container"></div>
</el-card>
</div>
</div>
</template>
<script setup>
/**
* 管理端消息统计逻辑
*
* 使用 ECharts 渲染四个图表,数据从 stats API 获取
*/
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ChatDotRound, Sunrise } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { getMessageStats } from '@/api/message'
const PRIMARY = '#2563EB'
const CHART_COLORS = ['#2563EB', '#7C3AED', '#059669', '#D97706', '#DC2626', '#0891B2']
const days = ref(7)
const loading = ref(false)
const stats = ref({
total_count: 0,
today_count: 0,
type_distribution: [],
daily_trend: [],
active_users: [],
active_groups: []
})
const trendChartRef = ref(null)
const typeChartRef = ref(null)
const userChartRef = ref(null)
const groupChartRef = ref(null)
let trendChart = null
let typeChart = null
let userChart = null
let groupChart = null
const formatNumber = (n) => {
if (n == null) return '0'
return n.toLocaleString()
}
/** 获取统计数据 */
const fetchStats = async () => {
loading.value = true
try {
const res = await getMessageStats({ days: days.value })
stats.value = res.data || {}
await nextTick()
renderCharts()
} catch (e) {
ElMessage.error(e?.message || '获取统计数据失败')
} finally {
loading.value = false
}
}
/** 渲染所有图表 */
const renderCharts = () => {
renderTrendChart()
renderTypeChart()
renderUserChart()
renderGroupChart()
}
/** 消息趋势折线图 */
const renderTrendChart = () => {
if (!trendChartRef.value) return
if (!trendChart) trendChart = echarts.init(trendChartRef.value)
const trend = stats.value.daily_trend || []
trendChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, top: 20, bottom: 30 },
xAxis: {
type: 'category',
data: trend.map(d => d.date?.substring(5) || ''),
axisLabel: { color: '#64748B', fontSize: 11 },
axisLine: { lineStyle: { color: '#E2E8F0' } }
},
yAxis: {
type: 'value',
axisLabel: { color: '#94A3B8', fontSize: 11 },
splitLine: { lineStyle: { color: '#F1F5F9' } }
},
series: [{
type: 'line',
data: trend.map(d => d.count),
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: { color: PRIMARY, width: 2 },
itemStyle: { color: PRIMARY },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(37, 99, 235, 0.2)' },
{ offset: 1, color: 'rgba(37, 99, 235, 0.02)' }
])
}
}]
})
}
/** 消息类型分布饼图 */
const renderTypeChart = () => {
if (!typeChartRef.value) return
if (!typeChart) typeChart = echarts.init(typeChartRef.value)
const dist = stats.value.type_distribution || []
typeChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: {
orient: 'vertical',
right: 10,
top: 'center',
textStyle: { color: '#64748B', fontSize: 12 }
},
color: CHART_COLORS,
series: [{
type: 'pie',
radius: ['40%', '65%'],
center: ['40%', '50%'],
avoidLabelOverlap: true,
label: { show: false },
emphasis: { label: { show: true, fontWeight: 'bold' } },
data: dist.map(d => ({ value: d.count, name: d.label || `类型${d.type}` }))
}]
})
}
/** 活跃用户水平柱状图 */
const renderUserChart = () => {
if (!userChartRef.value) return
if (!userChart) userChart = echarts.init(userChartRef.value)
const users = (stats.value.active_users || []).slice(0, 10).reverse()
userChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 90, right: 30, top: 10, bottom: 20 },
xAxis: {
type: 'value',
axisLabel: { color: '#94A3B8', fontSize: 11 },
splitLine: { lineStyle: { color: '#F1F5F9' } }
},
yAxis: {
type: 'category',
data: users.map(u => u.nickname || `用户${u.user_id}`),
axisLabel: { color: '#64748B', fontSize: 11, width: 70, overflow: 'truncate' }
},
series: [{
type: 'bar',
data: users.map(u => u.count),
barWidth: 14,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#2563EB' },
{ offset: 1, color: '#60A5FA' }
]),
borderRadius: [0, 4, 4, 0]
}
}]
})
}
/** 活跃群组水平柱状图 */
const renderGroupChart = () => {
if (!groupChartRef.value) return
if (!groupChart) groupChart = echarts.init(groupChartRef.value)
const groups = (stats.value.active_groups || []).slice(0, 10).reverse()
groupChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 90, right: 30, top: 10, bottom: 20 },
xAxis: {
type: 'value',
axisLabel: { color: '#94A3B8', fontSize: 11 },
splitLine: { lineStyle: { color: '#F1F5F9' } }
},
yAxis: {
type: 'category',
data: groups.map(g => g.name || `群组${g.group_id}`),
axisLabel: { color: '#64748B', fontSize: 11, width: 70, overflow: 'truncate' }
},
series: [{
type: 'bar',
data: groups.map(g => g.count),
barWidth: 14,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#7C3AED' },
{ offset: 1, color: '#A78BFA' }
]),
borderRadius: [0, 4, 4, 0]
}
}]
})
}
/** 窗口 resize 时自动调整图表尺寸 */
const handleResize = () => {
trendChart?.resize()
typeChart?.resize()
userChart?.resize()
groupChart?.resize()
}
onMounted(() => {
fetchStats()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
trendChart?.dispose()
typeChart?.dispose()
userChart?.dispose()
groupChart?.dispose()
})
</script>
<style scoped>
.stats-page { padding: 0; }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #1E293B;
margin: 0;
}
/* 统计卡片 */
.stat-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
background: #FFFFFF;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
border: 1px solid #E2E8F0;
}
.stat-card-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.total-icon {
background-color: rgba(37, 99, 235, 0.1);
color: #2563EB;
}
.today-icon {
background-color: rgba(5, 150, 105, 0.1);
color: #059669;
}
.stat-card-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 13px;
color: #94A3B8;
font-weight: 500;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #0F172A;
line-height: 1.2;
}
/* 图表网格 */
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.chart-card { border-radius: 12px; }
.chart-card-wide { grid-column: span 2; }
.chart-title {
font-size: 14px;
font-weight: 600;
color: #334155;
}
.chart-container {
width: 100%;
height: 300px;
}
</style>