diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0753a8d..fee26a0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/server/admin-next/package.json b/server/admin-next/package.json index 0e99e617..d11c6670 100644 --- a/server/admin-next/package.json +++ b/server/admin-next/package.json @@ -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": { diff --git a/server/admin-next/src/client/App.tsx b/server/admin-next/src/client/App.tsx index 3e385e37..9912e249 100644 --- a/server/admin-next/src/client/App.tsx +++ b/server/admin-next/src/client/App.tsx @@ -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 ( } dataProvider={dataProvider} authProvider={authProvider} i18n={i18n} diff --git a/server/admin-next/src/client/components/Dashboard.tsx b/server/admin-next/src/client/components/Dashboard.tsx new file mode 100644 index 00000000..29c58e8e --- /dev/null +++ b/server/admin-next/src/client/components/Dashboard.tsx @@ -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 ( +
+
+ + + + {t('tushan.dashboard.welcome', { + name: userIdentity.fullName, + })} + + + + + + + } + title={t('tushan.dashboard.user')} + count={usersNum} + /> + + + + + + } + title={t('tushan.dashboard.group')} + count={groupNum} + /> + + + + + + } + title={t('custom.dashboard.file')} + count={fileNum} + /> + + + + + + + {t('custom.dashboard.messageCount')} + + + + + + + + + {t('tushan.dashboard.tip.docs')} + + + + + {t('custom.dashboard.tip.github')} + + + + + {t('custom.dashboard.tip.tushan')} + + + + +
+
+ ); +}); +Dashboard.displayName = 'Dashboard'; + +const DashboardItem: React.FC< + React.PropsWithChildren<{ + title: string; + href?: string; + }> +> = React.memo((props) => { + const { t } = useTranslation(); + + return ( + + {t('tushan.dashboard.more')} + + ) + } + bordered={false} + style={{ overflow: 'hidden' }} + > + {props.children} + + ); +}); +DashboardItem.displayName = 'DashboardItem'; + +const DataItem: React.FC<{ + icon: React.ReactElement; + title: string; + count: number; +}> = React.memo((props) => { + return ( + +
+ {props.icon} +
+
+
{props.title}
+
{props.count}
+
+
+ ); +}); +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 ( + + + + + + + + + + + + + + + + ); +}); +MessageCountChart.displayName = 'MessageCountChart'; diff --git a/server/admin-next/src/client/i18n.ts b/server/admin-next/src/client/i18n.ts index 4c77acce..154cd5a8 100644 --- a/server/admin-next/src/client/i18n.ts +++ b/server/admin-next/src/client/i18n.ts @@ -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', diff --git a/server/admin-next/src/server/router/api.ts b/server/admin-next/src/server/router/api.ts index 9e347775..62aa3d77 100644 --- a/server/admin-next/src/server/router/api.ts +++ b/server/admin-next/src/server/router/api.ts @@ -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'], }) diff --git a/server/models/chat/message.ts b/server/models/chat/message.ts index 54afd70e..083eaefb 100644 --- a/server/models/chat/message.ts +++ b/server/models/chat/message.ts @@ -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;