✨ feat(im): 实现 im 的首页统计
parent
f5656c8a2f
commit
82022b86de
|
|
@ -1,9 +1,4 @@
|
|||
// IM 数据看板 API
|
||||
//
|
||||
// vibe 阶段:所有方法用本地 mock(setTimeout + 随机数据)实现,不发真实请求。
|
||||
// 后端就绪后把 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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue