feat(admin-next): add custom dashboard

pull/90/head
moonrailgun 2 years ago
parent 26f4c6a864
commit 4ee2fa81e2

@ -1598,6 +1598,9 @@ importers:
compression:
specifier: ^1.7.4
version: 1.7.4
dayjs:
specifier: ^1.11.7
version: 1.11.7
express:
specifier: ^4.18.2
version: 4.18.2
@ -1629,8 +1632,8 @@ importers:
specifier: workspace:^
version: link:../packages/sdk
tushan:
specifier: ^0.2.4
version: 0.2.4(prop-types@15.8.1)(ts-node@10.9.1)
specifier: ^0.2.8
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:
specifier: ^0.5.4
version: 0.5.4(express@4.18.2)(vite@4.2.0)
@ -21751,6 +21754,12 @@ packages:
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
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:
resolution: {integrity: sha512-raTel3EfshiUXxR0gvmIoqp75jhkj8+7R1LjB006VZKPTFBbXyx6TlUVhb8Z9+7ahgpFbcQg1QWVOdf/iNzI5A==}
dev: false
@ -21814,6 +21823,12 @@ packages:
'@babel/runtime': 7.21.0
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:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -29119,6 +29134,36 @@ packages:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
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):
resolution: {integrity: sha512-BqryOzTwuGd5Qn7Q5JLnipgWbhuNbIbK5Y9Kq0BQTNJTr8E8KxwgxduOyQatR4j8Gx8klrZrzZkQDGKlE4287g==}
peerDependencies:
@ -29149,6 +29194,21 @@ packages:
- react-native
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):
resolution: {integrity: sha512-jUNhmpHPEgiG9UjSXI3eFDPNoAJYzyKaAx2Q9c2xcxIpiErBASvsQDnBrEgAD8OIlhpnhqHi4dCp3r399hR+Zg==}
dependencies:
@ -30186,6 +30246,26 @@ packages:
react-dom: 18.2.0(react@18.2.0)
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):
resolution: {integrity: sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==}
peerDependencies:
@ -34572,25 +34652,28 @@ packages:
domino: 2.1.6
dev: false
/tushan@0.2.4(prop-types@15.8.1)(ts-node@10.9.1):
resolution: {integrity: sha512-IcWNjk1HeGGZq3k4ERozgjDgXolu1wTibvK7x5OP8ONc0xGNSqRvS0IwObl4YvvI+e7qJ+YE22LsQgIxF4cy5A==}
/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-/AsdK08s+42k5agZ7l4I2F3mFCr706T8dkbgyTfNmwIjsvZA7POJCeM21xAUrVwteU1x1pCmqCn2+N6iw31LXQ==}
dependencies:
'@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-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/react': 18.0.20
'@types/react-dom': 18.0.11
axios: 0.27.2
clsx: 1.2.1
i18next: 22.5.0
i18next-browser-languagedetector: 7.0.1
immer: 9.0.21
jsonexport: 3.2.0
lodash: 4.17.21
lodash-es: 4.17.21
postcss: 8.4.21
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-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-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)
@ -34603,7 +34686,9 @@ packages:
transitivePeerDependencies:
- debug
- encoding
- history
- prop-types
- react-hook-form
- react-native
- ts-node
dev: false

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

@ -17,6 +17,7 @@ import {
IconWifi,
} from 'tushan/icon';
import { authProvider } from './auth';
import { Dashboard } from './components/Dashboard';
import { fileFields, groupFields, mailFields, messageFields } from './fields';
import { i18n } from './i18n';
import { httpClient } from './request';
@ -31,6 +32,7 @@ function App() {
return (
<Tushan
basename="/admin"
dashboard={<Dashboard />}
dataProvider={dataProvider}
authProvider={authProvider}
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: {
resetPassword: 'Reset Password',
},
dashboard: {
file: 'File',
messageCount: 'Message Count',
tip: {
github:
'Tailchat: The next-generation noIM Application in your own workspace',
},
},
network: {
nodeList: 'Node List',
id: 'ID',
@ -127,6 +135,14 @@ export const i18n: TushanContextProps['i18n'] = {
action: {
resetPassword: '重置密码',
},
dashboard: {
file: '文件',
messageCount: '消息数',
tip: {
github: 'Tailchat 是在你私有空间内的下一代noIM应用',
tushan: 'Tailchat Admin后台 由 tushan 提供技术支持',
},
},
network: {
nodeList: '节点列表',
id: 'ID',

@ -6,6 +6,8 @@ import { adminAuth, auth, authSecret } from '../middleware/auth';
import { configRouter } from './config';
import { networkRouter } from './network';
import { fileRouter } from './file';
import dayjs from 'dayjs';
import messageModel from '../../../../models/chat/message';
const router = Router();
@ -64,10 +66,61 @@ router.delete('/messages/:id', auth(), async (req, res) => {
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(
'/messages',
auth(),
raExpressMongoose(require('../../../../models/chat/message').default, {
raExpressMongoose(messageModel, {
q: ['content'],
allowedRegexFields: ['content'],
})

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

Loading…
Cancel
Save