feat(im): 实现 im 的首页统计

im
YunaiV 2026-05-01 09:25:39 +08:00
parent f5656c8a2f
commit 82022b86de
7 changed files with 137 additions and 152 deletions

View File

@ -1,9 +1,4 @@
// IM 数据看板 API
//
// vibe 阶段:所有方法用本地 mocksetTimeout + 随机数据)实现,不发真实请求。
// 后端就绪后把 mockXxx 调用替换为 request.get(...) 一行即可。
//
// import request from '@/config/axios'
import request from '@/config/axios'
export interface ImStatisticsOverviewVO {
totalUser: number
@ -24,99 +19,48 @@ export interface ImStatisticsTrendVO {
series: Record<string, number[]>
}
export interface ImStatisticsDistributionVO {
messageTypeDistribution: { name: string; value: number }[]
groupSizeDistribution: { range: string; count: number }[]
topSenders: { userId: number; nickname: string; messageCount: number }[]
export interface ImStatisticsMessageTypeVO {
type: number // 参见 ImMessageTypeEnum 枚举类,由前端按 DICT_TYPE.IM_MESSAGE_TYPE 翻译
value: number
}
// ==================== mock helpers ====================
const fakePromise = <T>(data: T, delay = 300): Promise<T> =>
new Promise((r) => setTimeout(() => r(data), delay))
const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
const buildDates = (days: number): string[] => {
const dates: string[] = []
const today = new Date()
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
dates.push(d.toISOString().slice(0, 10))
}
return dates
export interface ImStatisticsGroupSizeVO {
range: string
count: number
}
// ==================== exposed APIs ====================
export interface ImStatisticsTopSenderVO {
userId: number
nickname: string
messageCount: number
}
// 获得 KPI 概览
export const getStatisticsOverview = (): Promise<ImStatisticsOverviewVO> => {
return fakePromise<ImStatisticsOverviewVO>({
totalUser: 12345,
newUserToday: 23,
totalGroup: 678,
newGroupToday: 4,
activeUserDaily: 1023,
activeUserWeekly: 4567,
activeUserMonthly: 8901,
privateMessageToday: 8765,
groupMessageToday: 3210,
privateMessageYesterday: 7890,
groupMessageYesterday: 3000
})
// 真实请求版本return request.get({ url: '/im/manager/statistics/overview' })
return request.get({ url: '/im/manager/statistics/overview' })
}
// 获得消息趋势(私聊 + 群聊双线)
export const getMessageTrend = (days: number): Promise<ImStatisticsTrendVO> => {
const dates = buildDates(days)
return fakePromise<ImStatisticsTrendVO>({
dates,
series: {
private: dates.map(() => randomInt(500, 2000)),
group: dates.map(() => randomInt(200, 1200))
}
})
// return request.get({ url: '/im/manager/statistics/message-trend', params: { days } })
return request.get({ url: '/im/manager/statistics/message-trend', params: { days } })
}
// 获得用户趋势(新增注册 + 日活双线)
export const getUserTrend = (days: number): Promise<ImStatisticsTrendVO> => {
const dates = buildDates(days)
return fakePromise<ImStatisticsTrendVO>({
dates,
series: {
register: dates.map(() => randomInt(5, 80)),
active: dates.map(() => randomInt(800, 1500))
}
})
// return request.get({ url: '/im/manager/statistics/user-trend', params: { days } })
return request.get({ url: '/im/manager/statistics/user-trend', params: { days } })
}
// 获得分布数据(消息类型 / 群规模 / TOP 发送者)
export const getStatisticsDistribution = (): Promise<ImStatisticsDistributionVO> => {
return fakePromise<ImStatisticsDistributionVO>({
messageTypeDistribution: [
{ name: '文本', value: 8000 },
{ name: '图片', value: 2400 },
{ name: '视频', value: 320 },
{ name: '语音', value: 980 },
{ name: '文件', value: 540 },
{ name: '位置', value: 65 },
{ name: '名片', value: 32 }
],
groupSizeDistribution: [
{ range: '1-9 人', count: 320 },
{ range: '10-49 人', count: 240 },
{ range: '50-199 人', count: 95 },
{ range: '200+ 人', count: 23 }
],
topSenders: Array.from({ length: 10 }, (_, i) => ({
userId: 1000 + i,
nickname: `测试用户${i + 1}`,
messageCount: 1500 - i * 120
}))
})
// return request.get({ url: '/im/manager/statistics/distribution' })
// 获得消息类型分布(最近 30 天)
export const getMessageTypeDistribution = (): Promise<ImStatisticsMessageTypeVO[]> => {
return request.get({ url: '/im/manager/statistics/message-type-distribution' })
}
// 获得群规模分布
export const getGroupSizeDistribution = (): Promise<ImStatisticsGroupSizeVO[]> => {
return request.get({ url: '/im/manager/statistics/group-size-distribution' })
}
// 获得消息 TOP 发送者(最近 30 天)
export const getTopSenders = (): Promise<ImStatisticsTopSenderVO[]> => {
return request.get({ url: '/im/manager/statistics/top-senders' })
}

View File

@ -1,47 +1,62 @@
<template>
<el-card shadow="never" class="chart-card">
<template #header>群规模分布</template>
<div ref="chartRef" style="width: 100%; height: 320px"></div>
<div ref="chartRef" v-loading="loading" style="width: 100%; height: 320px"></div>
</el-card>
</template>
<script lang="ts" setup>
import * as echarts from 'echarts'
import * as StatisticsApi from '@/api/im/manager/statistics'
defineOptions({ name: 'ImStatisticsGroupSizeChart' })
const props = defineProps<{ data: { range: string; count: number }[] }>()
const chartRef = ref<HTMLElement>()
const loading = ref(false)
let chart: echarts.ECharts | null = null
const render = () => {
if (!chart) return
chart.setOption({
/** 渲染柱状图 */
const render = (data: StatisticsApi.ImStatisticsGroupSizeVO[]) => {
chart?.setOption({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', top: 30, containLabel: true },
xAxis: { type: 'category', data: props.data.map((d) => d.range) },
xAxis: { type: 'category', data: data.map((d) => d.range) },
yAxis: { type: 'value', name: '群组数' },
series: [{
type: 'bar',
data: props.data.map((d) => d.count),
itemStyle: { color: '#67C23A', borderRadius: [4, 4, 0, 0] },
barMaxWidth: 48
}]
series: [
{
type: 'bar',
data: data.map((d) => d.count),
itemStyle: { color: '#67C23A', borderRadius: [4, 4, 0, 0] },
barMaxWidth: 48
}
]
})
}
/** 拉取并渲染数据 */
const loadData = async () => {
loading.value = true
try {
const data = await StatisticsApi.getGroupSizeDistribution()
render(data)
} finally {
loading.value = false
}
}
onMounted(async () => {
await nextTick()
if (chartRef.value) {
chart = echarts.init(chartRef.value)
render()
await loadData()
}
})
watch(() => props.data, render, { deep: true })
onUnmounted(() => chart?.dispose())
</script>
<style scoped>
.chart-card { border-radius: 8px; margin-bottom: 16px; }
.chart-card {
border-radius: 8px;
margin-bottom: 16px;
}
</style>

View File

@ -31,7 +31,7 @@ const buildOption = (dates: string[], priv: number[], grp: number[]): echarts.EC
xAxis: {
type: 'category',
data: dates,
axisLabel: { formatter: (v: string) => v.substring(5) }
axisLabel: { formatter: (v: string) => v.slice(5, 10) }
},
yAxis: { type: 'value', name: '消息量' },
series: [

View File

@ -1,47 +1,67 @@
<template>
<el-card shadow="never" class="chart-card">
<template #header>消息类型分布</template>
<div ref="chartRef" style="width: 100%; height: 320px"></div>
<div ref="chartRef" v-loading="loading" style="width: 100%; height: 320px"></div>
</el-card>
</template>
<script lang="ts" setup>
import * as echarts from 'echarts'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import * as StatisticsApi from '@/api/im/manager/statistics'
defineOptions({ name: 'ImStatisticsMessageTypeChart' })
const props = defineProps<{ data: { name: string; value: number }[] }>()
const chartRef = ref<HTMLElement>()
const loading = ref(false)
let chart: echarts.ECharts | null = null
const render = () => {
if (!chart) return
chart.setOption({
/** 渲染饼图type 在前端按字典翻译为名称给 echarts */
const render = (data: StatisticsApi.ImStatisticsMessageTypeVO[]) => {
const items = data.map((d) => ({
name: getDictLabel(DICT_TYPE.IM_MESSAGE_TYPE, d.type) || `未知(${d.type})`,
value: d.value
}))
chart?.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { orient: 'vertical', right: 8, top: 'middle' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: props.data
}]
series: [
{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: items
}
]
})
}
/** 拉取并渲染数据 */
const loadData = async () => {
loading.value = true
try {
const data = await StatisticsApi.getMessageTypeDistribution()
render(data)
} finally {
loading.value = false
}
}
onMounted(async () => {
await nextTick()
if (chartRef.value) {
chart = echarts.init(chartRef.value)
render()
await loadData()
}
})
watch(() => props.data, render, { deep: true })
onUnmounted(() => chart?.dispose())
</script>
<style scoped>
.chart-card { border-radius: 8px; margin-bottom: 16px; }
.chart-card {
border-radius: 8px;
margin-bottom: 16px;
}
</style>

View File

@ -1,55 +1,67 @@
<template>
<el-card shadow="never" class="chart-card">
<template #header>消息发送 TOP 10</template>
<div ref="chartRef" style="width: 100%; height: 320px"></div>
<div ref="chartRef" v-loading="loading" style="width: 100%; height: 320px"></div>
</el-card>
</template>
<script lang="ts" setup>
import * as echarts from 'echarts'
import * as StatisticsApi from '@/api/im/manager/statistics'
defineOptions({ name: 'ImStatisticsTopSendersChart' })
const props = defineProps<{
data: { userId: number; nickname: string; messageCount: number }[]
}>()
const chartRef = ref<HTMLElement>()
const loading = ref(false)
let chart: echarts.ECharts | null = null
const render = () => {
if (!chart) return
//
const sorted = [...props.data].sort((a, b) => a.messageCount - b.messageCount)
chart.setOption({
/** 渲染横向条形图(从下到上排名) */
const render = (data: StatisticsApi.ImStatisticsTopSenderVO[]) => {
const sorted = [...data].sort((a, b) => a.messageCount - b.messageCount)
chart?.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', top: 20, containLabel: true },
xAxis: { type: 'value', name: '消息数' },
yAxis: {
type: 'category',
data: sorted.map((d) => `${d.nickname}(${d.userId})`),
data: sorted.map((d) => `${d.nickname || d.userId}(${d.userId})`),
axisLabel: { width: 110, overflow: 'truncate' }
},
series: [{
type: 'bar',
data: sorted.map((d) => d.messageCount),
itemStyle: { color: '#409EFF', borderRadius: [0, 4, 4, 0] },
barMaxWidth: 18
}]
series: [
{
type: 'bar',
data: sorted.map((d) => d.messageCount),
itemStyle: { color: '#409EFF', borderRadius: [0, 4, 4, 0] },
barMaxWidth: 18
}
]
})
}
/** 拉取并渲染数据 */
const loadData = async () => {
loading.value = true
try {
const data = await StatisticsApi.getTopSenders()
render(data)
} finally {
loading.value = false
}
}
onMounted(async () => {
await nextTick()
if (chartRef.value) {
chart = echarts.init(chartRef.value)
render()
await loadData()
}
})
watch(() => props.data, render, { deep: true })
onUnmounted(() => chart?.dispose())
</script>
<style scoped>
.chart-card { border-radius: 8px; margin-bottom: 16px; }
.chart-card {
border-radius: 8px;
margin-bottom: 16px;
}
</style>

View File

@ -31,7 +31,7 @@ const buildOption = (dates: string[], reg: number[], act: number[]): echarts.ECh
xAxis: {
type: 'category',
data: dates,
axisLabel: { formatter: (v: string) => v.substring(5) }
axisLabel: { formatter: (v: string) => v.slice(5, 10) }
},
yAxis: [
{ type: 'value', name: '新增注册', position: 'left' },

View File

@ -1,6 +1,6 @@
<template>
<div class="dashboard">
<!-- KPI 卡片 -->
<!-- 概览卡片 -->
<OverviewCards v-if="overview" :overview="overview" />
<!-- 趋势 -->
@ -14,15 +14,15 @@
</el-row>
<!-- 分布 -->
<el-row v-if="distribution" :gutter="16">
<el-row :gutter="16">
<el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24">
<MessageTypeChart :data="distribution.messageTypeDistribution" />
<MessageTypeChart />
</el-col>
<el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24">
<GroupSizeChart :data="distribution.groupSizeDistribution" />
<GroupSizeChart />
</el-col>
<el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24">
<TopSendersChart :data="distribution.topSenders" />
<TopSendersChart />
</el-col>
</el-row>
</div>
@ -39,17 +39,11 @@ import TopSendersChart from './components/TopSendersChart.vue'
defineOptions({ name: 'ImStatistics' })
// /
const overview = ref<StatisticsApi.ImStatisticsOverviewVO>()
const distribution = ref<StatisticsApi.ImStatisticsDistributionVO>()
onMounted(async () => {
// KPI + days
const [o, d] = await Promise.all([
StatisticsApi.getStatisticsOverview(),
StatisticsApi.getStatisticsDistribution()
])
overview.value = o
distribution.value = d
overview.value = await StatisticsApi.getStatisticsOverview()
})
</script>