feat(admin-next): add custom dashboard

pull/90/head
moonrailgun
parent 26f4c6a864
commit 4ee2fa81e2

@ -1598,6 +1598,9 @@ importers:
compression: compression:
specifier: ^1.7.4 specifier: ^1.7.4
version: 1.7.4 version: 1.7.4
dayjs:
specifier: ^1.11.7
version: 1.11.7
express: express:
specifier: ^4.18.2 specifier: ^4.18.2
version: 4.18.2 version: 4.18.2
@ -1629,8 +1632,8 @@ importers:
specifier: workspace:^ specifier: workspace:^
version: link:../packages/sdk version: link:../packages/sdk
tushan: tushan:
specifier: ^0.2.4 specifier: ^0.2.8
version: 0.2.4(prop-types@15.8.1)(ts-node@10.9.1) version: 0.2.8(history@5.3.0)(prop-types@15.8.1)(react-hook-form@7.41.5)(ts-node@10.9.1)
vite-express: vite-express:
specifier: ^0.5.4 specifier: ^0.5.4
version: 0.5.4(express@4.18.2)(vite@4.2.0) version: 0.5.4(express@4.18.2)(vite@4.2.0)
@ -21751,6 +21754,12 @@ packages:
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==} resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
dev: false dev: false
/i18next-browser-languagedetector@7.0.1:
resolution: {integrity: sha512-Pa5kFwaczXJAeHE56CHG2aWzFBMJNUNghf0Pm4SwSrEMps/PTKqW90EYWlIvhuYStf3Sn1K0vw+gH3+TLdkH1g==}
dependencies:
'@babel/runtime': 7.21.0
dev: false
/i18next-fs-backend@1.1.5: /i18next-fs-backend@1.1.5:
resolution: {integrity: sha512-raTel3EfshiUXxR0gvmIoqp75jhkj8+7R1LjB006VZKPTFBbXyx6TlUVhb8Z9+7ahgpFbcQg1QWVOdf/iNzI5A==} resolution: {integrity: sha512-raTel3EfshiUXxR0gvmIoqp75jhkj8+7R1LjB006VZKPTFBbXyx6TlUVhb8Z9+7ahgpFbcQg1QWVOdf/iNzI5A==}
dev: false dev: false
@ -21814,6 +21823,12 @@ packages:
'@babel/runtime': 7.21.0 '@babel/runtime': 7.21.0
dev: true dev: true
/i18next@22.5.0:
resolution: {integrity: sha512-sqWuJFj+wJAKQP2qBQ+b7STzxZNUmnSxrehBCCj9vDOW9RDYPfqCaK1Hbh2frNYQuPziz6O2CGoJPwtzY3vAYA==}
dependencies:
'@babel/runtime': 7.21.0
dev: false
/iconv-lite@0.4.24: /iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -29119,6 +29134,36 @@ packages:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'} engines: {node: '>=10'}
/ra-core@4.10.3(history@5.3.0)(react-dom@18.2.0)(react-hook-form@7.41.5)(react-router-dom@6.11.0)(react-router@6.11.0)(react@18.2.0):
resolution: {integrity: sha512-ai3ekdnkYmNWRP6m79zCs03+O3zBQsRxon/br5rsWodAtTa0j1oqjXNEVoQNipvXBmeCjnKENN9Gzc3wtUUZjA==}
peerDependencies:
history: ^5.1.0
react: ^16.9.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.9.0 || ^17.0.0 || ^18.0.0
react-hook-form: ^7.40.0
react-router: ^6.1.0
react-router-dom: ^6.1.0
dependencies:
clsx: 1.2.1
date-fns: 2.29.3
eventemitter3: 4.0.7
history: 5.3.0
inflection: 1.12.0
jsonexport: 3.2.0
lodash: 4.17.21
prop-types: 15.8.1
query-string: 7.1.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-hook-form: 7.41.5(react@18.2.0)
react-is: 17.0.2
react-query: 3.39.2(react-dom@18.2.0)(react@18.2.0)
react-router: 6.11.0(react@18.2.0)
react-router-dom: 6.11.0(react-dom@18.2.0)(react@18.2.0)
transitivePeerDependencies:
- react-native
dev: false
/ra-core@4.7.0(history@5.3.0)(react-dom@18.2.0)(react-hook-form@7.41.5)(react-router-dom@6.11.0)(react-router@6.11.0)(react@18.2.0): /ra-core@4.7.0(history@5.3.0)(react-dom@18.2.0)(react-hook-form@7.41.5)(react-router-dom@6.11.0)(react-router@6.11.0)(react@18.2.0):
resolution: {integrity: sha512-BqryOzTwuGd5Qn7Q5JLnipgWbhuNbIbK5Y9Kq0BQTNJTr8E8KxwgxduOyQatR4j8Gx8klrZrzZkQDGKlE4287g==} resolution: {integrity: sha512-BqryOzTwuGd5Qn7Q5JLnipgWbhuNbIbK5Y9Kq0BQTNJTr8E8KxwgxduOyQatR4j8Gx8klrZrzZkQDGKlE4287g==}
peerDependencies: peerDependencies:
@ -29149,6 +29194,21 @@ packages:
- react-native - react-native
dev: false dev: false
/ra-data-json-server@4.10.3(history@5.3.0)(react-dom@18.2.0)(react-hook-form@7.41.5)(react-router-dom@6.11.0)(react-router@6.11.0)(react@18.2.0):
resolution: {integrity: sha512-8/9IJ1RPdNybvxwktW0B4GgOfdxgY/G8raSIpnw1dnpaF2xFuLclCRre5UkIFADCtC1/n2N2QQzoCSjMxDODBg==}
dependencies:
query-string: 7.1.3
ra-core: 4.10.3(history@5.3.0)(react-dom@18.2.0)(react-hook-form@7.41.5)(react-router-dom@6.11.0)(react-router@6.11.0)(react@18.2.0)
transitivePeerDependencies:
- history
- react
- react-dom
- react-hook-form
- react-native
- react-router
- react-router-dom
dev: false
/ra-data-json-server@4.7.0(history@5.3.0)(react-dom@18.2.0)(react-hook-form@7.41.5)(react-router-dom@6.11.0)(react-router@6.11.0)(react@18.2.0): /ra-data-json-server@4.7.0(history@5.3.0)(react-dom@18.2.0)(react-hook-form@7.41.5)(react-router-dom@6.11.0)(react-router@6.11.0)(react@18.2.0):
resolution: {integrity: sha512-jUNhmpHPEgiG9UjSXI3eFDPNoAJYzyKaAx2Q9c2xcxIpiErBASvsQDnBrEgAD8OIlhpnhqHi4dCp3r399hR+Zg==} resolution: {integrity: sha512-jUNhmpHPEgiG9UjSXI3eFDPNoAJYzyKaAx2Q9c2xcxIpiErBASvsQDnBrEgAD8OIlhpnhqHi4dCp3r399hR+Zg==}
dependencies: dependencies:
@ -30186,6 +30246,26 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/react-i18next@12.3.1(i18next@22.5.0)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-5v8E2XjZDFzK7K87eSwC7AJcAkcLt5xYZ4+yTPDAW1i7C93oOY1dnr4BaQM7un4Hm+GmghuiPvevWwlca5PwDA==}
peerDependencies:
i18next: '>= 19.0.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
dependencies:
'@babel/runtime': 7.21.0
html-parse-stringify: 3.0.1
i18next: 22.5.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-inspector@5.1.1(react@18.2.0): /react-inspector@5.1.1(react@18.2.0):
resolution: {integrity: sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==} resolution: {integrity: sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==}
peerDependencies: peerDependencies:
@ -34572,25 +34652,28 @@ packages:
domino: 2.1.6 domino: 2.1.6
dev: false dev: false
/tushan@0.2.4(prop-types@15.8.1)(ts-node@10.9.1): /tushan@0.2.8(history@5.3.0)(prop-types@15.8.1)(react-hook-form@7.41.5)(ts-node@10.9.1):
resolution: {integrity: sha512-IcWNjk1HeGGZq3k4ERozgjDgXolu1wTibvK7x5OP8ONc0xGNSqRvS0IwObl4YvvI+e7qJ+YE22LsQgIxF4cy5A==} resolution: {integrity: sha512-/AsdK08s+42k5agZ7l4I2F3mFCr706T8dkbgyTfNmwIjsvZA7POJCeM21xAUrVwteU1x1pCmqCn2+N6iw31LXQ==}
dependencies: dependencies:
'@arco-design/web-react': 2.47.1(@types/react@18.0.20)(react-dom@18.2.0)(react@18.2.0) '@arco-design/web-react': 2.47.1(@types/react@18.0.20)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-query': 4.29.3(react-dom@18.2.0)(react@18.2.0) '@tanstack/react-query': 4.29.3(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-query-devtools': 4.29.3(@tanstack/react-query@4.29.3)(react-dom@18.2.0)(react@18.2.0) '@tanstack/react-query-devtools': 4.29.3(@tanstack/react-query@4.29.3)(react-dom@18.2.0)(react@18.2.0)
'@types/lodash': 4.14.191
'@types/node': 18.16.1 '@types/node': 18.16.1
'@types/react': 18.0.20 '@types/react': 18.0.20
'@types/react-dom': 18.0.11 '@types/react-dom': 18.0.11
axios: 0.27.2 axios: 0.27.2
clsx: 1.2.1 clsx: 1.2.1
i18next: 22.5.0
i18next-browser-languagedetector: 7.0.1
immer: 9.0.21 immer: 9.0.21
jsonexport: 3.2.0 jsonexport: 3.2.0
lodash: 4.17.21 lodash-es: 4.17.21
postcss: 8.4.21 postcss: 8.4.21
qs: 6.11.1 qs: 6.11.1
ra-data-json-server: 4.10.3(history@5.3.0)(react-dom@18.2.0)(react-hook-form@7.41.5)(react-router-dom@6.11.0)(react-router@6.11.0)(react@18.2.0)
react: 18.2.0 react: 18.2.0
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
react-i18next: 12.3.1(i18next@22.5.0)(react-dom@18.2.0)(react@18.2.0)
react-is: 18.2.0 react-is: 18.2.0
react-json-view: 1.21.3(@types/react@18.0.20)(react-dom@18.2.0)(react@18.2.0) react-json-view: 1.21.3(@types/react@18.0.20)(react-dom@18.2.0)(react@18.2.0)
react-router: 6.11.0(react@18.2.0) react-router: 6.11.0(react@18.2.0)
@ -34603,7 +34686,9 @@ packages:
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
- encoding - encoding
- history
- prop-types - prop-types
- react-hook-form
- react-native - react-native
- ts-node - ts-node
dev: false dev: false

@ -14,6 +14,7 @@
"@fastify/busboy": "^1.1.0", "@fastify/busboy": "^1.1.0",
"axios": "^1.4.0", "axios": "^1.4.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"dayjs": "^1.11.7",
"express": "^4.18.2", "express": "^4.18.2",
"express-mongoose-ra-json-server": "^0.1.0", "express-mongoose-ra-json-server": "^0.1.0",
"filesize": "^8.0.7", "filesize": "^8.0.7",
@ -24,7 +25,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"tailchat-server-sdk": "workspace:^", "tailchat-server-sdk": "workspace:^",
"tushan": "^0.2.6", "tushan": "^0.2.8",
"vite-express": "^0.5.4" "vite-express": "^0.5.4"
}, },
"devDependencies": { "devDependencies": {

@ -17,6 +17,7 @@ import {
IconWifi, IconWifi,
} from 'tushan/icon'; } from 'tushan/icon';
import { authProvider } from './auth'; import { authProvider } from './auth';
import { Dashboard } from './components/Dashboard';
import { fileFields, groupFields, mailFields, messageFields } from './fields'; import { fileFields, groupFields, mailFields, messageFields } from './fields';
import { i18n } from './i18n'; import { i18n } from './i18n';
import { httpClient } from './request'; import { httpClient } from './request';
@ -31,6 +32,7 @@ function App() {
return ( return (
<Tushan <Tushan
basename="/admin" basename="/admin"
dashboard={<Dashboard />}
dataProvider={dataProvider} dataProvider={dataProvider}
authProvider={authProvider} authProvider={authProvider}
i18n={i18n} i18n={i18n}

@ -0,0 +1,226 @@
import { IconFile, IconUser, IconUserGroup } from 'tushan/icon';
import React from 'react';
import {
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
AreaChart,
Area,
} from 'tushan/chart';
import {
Card,
Link,
Space,
Grid,
Divider,
Typography,
useUserStore,
createSelector,
useTranslation,
useGetList,
useAsync,
} from 'tushan';
import { request } from '../request';
const { GridItem } = Grid;
export const Dashboard: React.FC = React.memo(() => {
const { userIdentity } = useUserStore(createSelector('userIdentity'));
const { t } = useTranslation();
const { total: usersNum } = useGetList('users', {
pagination: { page: 1, perPage: 1 },
});
const { total: groupNum } = useGetList('groups', {
pagination: { page: 1, perPage: 1 },
});
const { total: fileNum } = useGetList('file', {
pagination: { page: 1, perPage: 1 },
});
return (
<div>
<div>
<Space direction="vertical" style={{ width: '100%' }}>
<Card bordered={false}>
<Typography.Title heading={5}>
{t('tushan.dashboard.welcome', {
name: userIdentity.fullName,
})}
</Typography.Title>
<Divider />
<Grid.Row justify="center">
<Grid.Col flex={1} style={{ paddingLeft: '1rem' }}>
<DataItem
icon={<IconUser />}
title={t('tushan.dashboard.user')}
count={usersNum}
/>
</Grid.Col>
<Divider type="vertical" style={{ height: 40 }} />
<Grid.Col flex={1} style={{ paddingLeft: '1rem' }}>
<DataItem
icon={<IconUserGroup />}
title={t('tushan.dashboard.group')}
count={groupNum}
/>
</Grid.Col>
<Divider type="vertical" style={{ height: 40 }} />
<Grid.Col flex={1} style={{ paddingLeft: '1rem' }}>
<DataItem
icon={<IconFile />}
title={t('custom.dashboard.file')}
count={fileNum}
/>
</Grid.Col>
</Grid.Row>
<Divider />
<Typography.Title heading={6} style={{ marginBottom: 10 }}>
{t('custom.dashboard.messageCount')}
</Typography.Title>
<MessageCountChart />
</Card>
<Grid cols={3} colGap={12} rowGap={16}>
<GridItem index={0}>
<DashboardItem title="Docs" href="https://tailchat.msgbyte.com/">
{t('tushan.dashboard.tip.docs')}
</DashboardItem>
</GridItem>
<GridItem index={0}>
<DashboardItem
title="Github"
href="https://github.com/msgbyte/tailchat"
>
{t('custom.dashboard.tip.github')}
</DashboardItem>
</GridItem>
<GridItem index={0}>
<DashboardItem
title="Provide by Tushan"
href="https://tushan.msgbyte.com/"
>
{t('custom.dashboard.tip.tushan')}
</DashboardItem>
</GridItem>
</Grid>
</Space>
</div>
</div>
);
});
Dashboard.displayName = 'Dashboard';
const DashboardItem: React.FC<
React.PropsWithChildren<{
title: string;
href?: string;
}>
> = React.memo((props) => {
const { t } = useTranslation();
return (
<Card
title={props.title}
extra={
props.href && (
<Link target="_blank" href={props.href}>
{t('tushan.dashboard.more')}
</Link>
)
}
bordered={false}
style={{ overflow: 'hidden' }}
>
{props.children}
</Card>
);
});
DashboardItem.displayName = 'DashboardItem';
const DataItem: React.FC<{
icon: React.ReactElement;
title: string;
count: number;
}> = React.memo((props) => {
return (
<Space>
<div
style={{
fontSize: 20,
padding: '0.5rem',
borderRadius: '9999px',
border: '1px solid #ccc',
width: 24,
height: 24,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
{props.icon}
</div>
<div>
<div style={{ fontWeight: 700 }}>{props.title}</div>
<div>{props.count}</div>
</div>
</Space>
);
});
DataItem.displayName = 'DataItem';
const MessageCountChart: React.FC = React.memo(() => {
const { t } = useTranslation();
const { value: messageCountSummary } = useAsync(async () => {
const { data } = await request.get<{
summary: {
count: number;
date: string;
}[];
}>('/message/count/summary');
return data.summary;
}, []);
return (
<ResponsiveContainer width="100%" height={320}>
<AreaChart
width={730}
height={250}
data={messageCountSummary}
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8884d8" stopOpacity={0.8} />
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" />
<YAxis />
<CartesianGrid strokeDasharray="3 3" />
<Tooltip />
<Area
type="monotone"
dataKey="count"
label={t('custom.dashboard.messageCount')}
stroke="#8884d8"
fillOpacity={1}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
);
});
MessageCountChart.displayName = 'MessageCountChart';

@ -13,6 +13,14 @@ export const i18n: TushanContextProps['i18n'] = {
action: { action: {
resetPassword: 'Reset Password', resetPassword: 'Reset Password',
}, },
dashboard: {
file: 'File',
messageCount: 'Message Count',
tip: {
github:
'Tailchat: The next-generation noIM Application in your own workspace',
},
},
network: { network: {
nodeList: 'Node List', nodeList: 'Node List',
id: 'ID', id: 'ID',
@ -127,6 +135,14 @@ export const i18n: TushanContextProps['i18n'] = {
action: { action: {
resetPassword: '重置密码', resetPassword: '重置密码',
}, },
dashboard: {
file: '文件',
messageCount: '消息数',
tip: {
github: 'Tailchat 是在你私有空间内的下一代noIM应用',
tushan: 'Tailchat Admin后台 由 tushan 提供技术支持',
},
},
network: { network: {
nodeList: '节点列表', nodeList: '节点列表',
id: 'ID', id: 'ID',

@ -6,6 +6,8 @@ import { adminAuth, auth, authSecret } from '../middleware/auth';
import { configRouter } from './config'; import { configRouter } from './config';
import { networkRouter } from './network'; import { networkRouter } from './network';
import { fileRouter } from './file'; import { fileRouter } from './file';
import dayjs from 'dayjs';
import messageModel from '../../../../models/chat/message';
const router = Router(); const router = Router();
@ -64,10 +66,61 @@ router.delete('/messages/:id', auth(), async (req, res) => {
res.status(500).json({ message: (err as any).message }); res.status(500).json({ message: (err as any).message });
} }
}); });
router.get('/message/count/summary', auth(), async (req, res) => {
// 返回最近7天的消息数统计
const day = 7;
const aggregateRes: { count: number; date: string }[] = await messageModel
.aggregate([
{
$match: {
createdAt: {
$gte: dayjs().subtract(day, 'd').startOf('d').toDate(),
$lt: dayjs().endOf('d').toDate(),
},
},
},
{
$group: {
_id: {
createdAt: {
$dateToString: {
format: '%Y-%m-%d',
date: '$createdAt',
},
},
} as any,
count: {
$sum: 1,
},
},
},
{
$project: {
date: '$_id.createdAt',
count: '$count',
},
},
])
.exec();
const summary = Array.from({ length: day })
.map((_, d) => {
const date = dayjs().subtract(d, 'd').format('YYYY-MM-DD');
return {
date,
count: aggregateRes.find((r) => r.date === date)?.count ?? 0,
};
})
.reverse();
res.json({ summary });
});
router.use( router.use(
'/messages', '/messages',
auth(), auth(),
raExpressMongoose(require('../../../../models/chat/message').default, { raExpressMongoose(messageModel, {
q: ['content'], q: ['content'],
allowedRegexFields: ['content'], allowedRegexFields: ['content'],
}) })

@ -6,6 +6,7 @@ import {
ReturnModelType, ReturnModelType,
modelOptions, modelOptions,
Severity, Severity,
index,
} from '@typegoose/typegoose'; } from '@typegoose/typegoose';
import { Group } from '../group/group'; import { Group } from '../group/group';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
@ -31,6 +32,7 @@ class MessageReaction {
allowMixed: Severity.ALLOW, allowMixed: Severity.ALLOW,
}, },
}) })
@index({ createdAt: -1 })
export class Message extends TimeStamps implements Base { export class Message extends TimeStamps implements Base {
_id: Types.ObjectId; _id: Types.ObjectId;
id: string; id: string;

Loading…
Cancel
Save