mirror of https://github.com/msgbyte/tailchat
feat(admin): add analytics page
- activeGroupTop5 - activeUserTop5 - largeGroupTop5 - fileStorageUserTop5pull/105/merge
parent
288f5a61e8
commit
39e0b2cee7
@ -0,0 +1,203 @@
|
|||||||
|
import fileSize from 'filesize';
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Grid,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
useAsync,
|
||||||
|
useTranslation,
|
||||||
|
} from 'tushan';
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'tushan/chart';
|
||||||
|
import { request } from '../../request';
|
||||||
|
|
||||||
|
export const TailchatAnalytics: React.FC = React.memo(() => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Grid.Row gutter={4}>
|
||||||
|
<Grid.Col md={12}>
|
||||||
|
<Card>
|
||||||
|
<Typography.Title heading={4}>
|
||||||
|
{t('custom.analytics.activeGroupTop5')}
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<ActiveGroupChart />
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col md={12}>
|
||||||
|
<Card>
|
||||||
|
<Typography.Title heading={4}>
|
||||||
|
{t('custom.analytics.activeUserTop5')}
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<ActiveUserChart />
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid.Row>
|
||||||
|
|
||||||
|
<Grid.Row gutter={4} style={{ marginTop: 8 }}>
|
||||||
|
<Grid.Col md={12}>
|
||||||
|
<Card>
|
||||||
|
<Typography.Title heading={4}>
|
||||||
|
{t('custom.analytics.largeGroupTop5')}
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<LargeGroupChart />
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col md={12}>
|
||||||
|
<Card>
|
||||||
|
<Typography.Title heading={4}>
|
||||||
|
{t('custom.analytics.fileStorageUserTop5')}
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<FileStorageChart />
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid.Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TailchatAnalytics.displayName = 'TailchatAnalytics';
|
||||||
|
|
||||||
|
const ActiveGroupChart: React.FC = React.memo(() => {
|
||||||
|
const { value } = useAsync(async () => {
|
||||||
|
const { data } = await request.get<{
|
||||||
|
activeGroups: {
|
||||||
|
groupId: string;
|
||||||
|
groupName: string;
|
||||||
|
messageCount: number;
|
||||||
|
}[];
|
||||||
|
}>('/analytics/activeGroups');
|
||||||
|
|
||||||
|
return data.activeGroups;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<BarChart
|
||||||
|
data={value}
|
||||||
|
layout="vertical"
|
||||||
|
maxBarSize={40}
|
||||||
|
margin={{ left: 60 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="messageCount" type="number" />
|
||||||
|
<YAxis dataKey="groupName" type="category" />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="messageCount" fill="#8884d8" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ActiveGroupChart.displayName = 'ActiveGroupChart';
|
||||||
|
|
||||||
|
const ActiveUserChart: React.FC = React.memo(() => {
|
||||||
|
const { value } = useAsync(async () => {
|
||||||
|
const { data } = await request.get<{
|
||||||
|
activeUsers: {
|
||||||
|
groupId: string;
|
||||||
|
groupName: string;
|
||||||
|
messageCount: number;
|
||||||
|
}[];
|
||||||
|
}>('/analytics/activeUsers');
|
||||||
|
|
||||||
|
return data.activeUsers;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<BarChart
|
||||||
|
data={value}
|
||||||
|
layout="vertical"
|
||||||
|
maxBarSize={40}
|
||||||
|
margin={{ left: 60 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="messageCount" type="number" />
|
||||||
|
<YAxis dataKey="userName" type="category" />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="messageCount" fill="#8884d8" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ActiveUserChart.displayName = 'ActiveUserChart';
|
||||||
|
|
||||||
|
const LargeGroupChart: React.FC = React.memo(() => {
|
||||||
|
const { value } = useAsync(async () => {
|
||||||
|
const { data } = await request.get<{
|
||||||
|
largeGroups: {
|
||||||
|
name: string;
|
||||||
|
memberCount: number;
|
||||||
|
}[];
|
||||||
|
}>('/analytics/largeGroups');
|
||||||
|
|
||||||
|
return data.largeGroups;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<BarChart
|
||||||
|
data={value}
|
||||||
|
layout="vertical"
|
||||||
|
maxBarSize={40}
|
||||||
|
margin={{ left: 60 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="memberCount" type="number" />
|
||||||
|
<YAxis dataKey="name" type="category" />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="memberCount" fill="#8884d8" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
LargeGroupChart.displayName = 'LargeGroupChart';
|
||||||
|
|
||||||
|
const FileStorageChart: React.FC = React.memo(() => {
|
||||||
|
const { value } = useAsync(async () => {
|
||||||
|
const { data } = await request.get<{
|
||||||
|
fileStorageUserTop: {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
fileStorageTotal: number;
|
||||||
|
}[];
|
||||||
|
}>('/analytics/fileStorageUserTop');
|
||||||
|
|
||||||
|
return data.fileStorageUserTop;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<BarChart
|
||||||
|
data={value}
|
||||||
|
layout="vertical"
|
||||||
|
maxBarSize={40}
|
||||||
|
margin={{ left: 60 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="fileStorageTotal"
|
||||||
|
type="number"
|
||||||
|
tickFormatter={(val) => fileSize(val)}
|
||||||
|
/>
|
||||||
|
<YAxis dataKey="userName" type="category" />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="fileStorageTotal" fill="#8884d8" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FileStorageChart.displayName = 'FileStorageChart';
|
@ -0,0 +1,218 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { auth } from '../middleware/auth';
|
||||||
|
import messageModel from '../../../../models/chat/message';
|
||||||
|
import groupModel from '../../../../models/group/group';
|
||||||
|
import fileModel from '../../../../models/file';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { db } from 'tailchat-server-sdk';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/activeGroups', auth(), async (req, res) => {
|
||||||
|
// 返回最近7天的最活跃的群组
|
||||||
|
const day = 7;
|
||||||
|
const aggregateRes: { _id: string; count: number }[] = await messageModel
|
||||||
|
.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
createdAt: {
|
||||||
|
$gte: dayjs().subtract(day, 'd').startOf('d').toDate(),
|
||||||
|
$lt: dayjs().endOf('d').toDate(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$groupId' as any,
|
||||||
|
count: {
|
||||||
|
$sum: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$sort: {
|
||||||
|
count: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$limit: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'groups',
|
||||||
|
localField: '_id',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'groupInfo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
_id: 0,
|
||||||
|
groupId: '$_id',
|
||||||
|
messageCount: '$count',
|
||||||
|
groupName: {
|
||||||
|
$arrayElemAt: ['$groupInfo.name', 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const activeGroups = aggregateRes;
|
||||||
|
|
||||||
|
res.json({ activeGroups });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/activeUsers', auth(), async (req, res) => {
|
||||||
|
// 返回最近7天的最活跃的用户
|
||||||
|
const day = 7;
|
||||||
|
const aggregateRes: { _id: string; count: number }[] = await messageModel
|
||||||
|
.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
author: {
|
||||||
|
$ne: new db.Types.ObjectId('000000000000000000000000'),
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
$gte: dayjs().subtract(day, 'd').startOf('d').toDate(),
|
||||||
|
$lt: dayjs().endOf('d').toDate(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$author' as any,
|
||||||
|
count: {
|
||||||
|
$sum: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$sort: {
|
||||||
|
count: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$limit: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'users',
|
||||||
|
localField: '_id',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'userInfo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
_id: 0,
|
||||||
|
userId: '$_id',
|
||||||
|
messageCount: '$count',
|
||||||
|
userName: {
|
||||||
|
$concat: [
|
||||||
|
{
|
||||||
|
$arrayElemAt: ['$userInfo.nickname', 0],
|
||||||
|
},
|
||||||
|
'#',
|
||||||
|
{
|
||||||
|
$arrayElemAt: ['$userInfo.discriminator', 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// $arrayElemAt: ['$userInfo.nickname', 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const activeUsers = aggregateRes;
|
||||||
|
|
||||||
|
res.json({ activeUsers });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/largeGroups', auth(), async (req, res) => {
|
||||||
|
// 返回最大的 5 个群组
|
||||||
|
const limit = 5;
|
||||||
|
const aggregateRes: { _id: string; count: number }[] = await groupModel
|
||||||
|
.aggregate([
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
name: 1,
|
||||||
|
memberCount: {
|
||||||
|
$size: '$members',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$sort: {
|
||||||
|
memberCount: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$limit: limit,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const largeGroups = aggregateRes;
|
||||||
|
|
||||||
|
res.json({ largeGroups });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/fileStorageUserTop', auth(), async (req, res) => {
|
||||||
|
// 返回最大的 5 个群组
|
||||||
|
const limit = 5;
|
||||||
|
const aggregateRes: { _id: string; count: number }[] = await fileModel
|
||||||
|
.aggregate([
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$userId',
|
||||||
|
total: {
|
||||||
|
$sum: '$size',
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$sort: {
|
||||||
|
total: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$limit: limit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'users',
|
||||||
|
localField: '_id',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'userInfo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
_id: 0,
|
||||||
|
userId: '$_id',
|
||||||
|
fileStorageTotal: '$total',
|
||||||
|
userName: {
|
||||||
|
$concat: [
|
||||||
|
{
|
||||||
|
$arrayElemAt: ['$userInfo.nickname', 0],
|
||||||
|
},
|
||||||
|
'#',
|
||||||
|
{
|
||||||
|
$arrayElemAt: ['$userInfo.discriminator', 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// $arrayElemAt: ['$userInfo.nickname', 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const fileStorageUserTop = aggregateRes;
|
||||||
|
|
||||||
|
res.json({ fileStorageUserTop });
|
||||||
|
});
|
||||||
|
|
||||||
|
export { router as analyticsRouter };
|
Loading…
Reference in New Issue