This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

13
server/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tailchat Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,7 @@
{
"verbose": true,
"watch": ["./src/server"],
"ext": "ts",
"delay": 1000,
"exec": "ts-node ./src/server/index.ts"
}

50
server/admin/package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "tailchat-admin",
"private": true,
"version": "0.0.0",
"author": "moonrailgun",
"scripts": {
"dev": "nodemon",
"start": "cross-env NODE_ENV=production node dist/admin/src/server/index.js",
"build": "pnpm build:client && pnpm build:server",
"build:client": "vite build",
"build:server": "tsc -p tsconfig.server.json"
},
"dependencies": {
"@bytemd/plugin-gfm": "^1.21.0",
"@bytemd/react": "^1.21.0",
"@fastify/busboy": "^1.1.0",
"@loadable/component": "^5.15.3",
"axios": "^1.4.0",
"bytemd": "^1.21.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",
"jsonwebtoken": "^8.5.1",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"morgan": "^1.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailchat-server-sdk": "workspace:^",
"tushan": "^0.3.11",
"vite-express": "0.8.0"
},
"devDependencies": {
"@types/compression": "^1.7.2",
"@types/express": "^4.17.15",
"@types/loadable__component": "^5.13.4",
"@types/md5": "^2.3.2",
"@types/morgan": "^1.9.4",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react": "^3.1.0",
"cross-env": "^7.0.3",
"nodemon": "^2.0.22",
"ts-node": "^10.9.1",
"typescript": "^4.9.3",
"vite": "^4.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,127 @@
import {
Category,
createTextField,
CustomRoute,
jsonServerProvider,
ListTable,
Resource,
Tushan,
} from 'tushan';
import {
IconCompass,
IconDashboard,
IconEmail,
IconExperiment,
IconFile,
IconMessage,
IconNotification,
IconSettings,
IconStorage,
IconUser,
IconUserGroup,
IconWifi,
} from 'tushan/icon';
import { authHTTPClient, authProvider } from './auth';
import { Dashboard } from './components/Dashboard';
import { discoverFields, mailFields, messageFields } from './fields';
import { i18n } from './i18n';
import { GroupList } from './resources/group';
import { UserList } from './resources/user';
import { FileList } from './resources/file';
import { TailchatAnalytics } from './routes/analytics';
import { CacheManager } from './routes/cache';
import { TailchatNetwork } from './routes/network';
import { SocketIOAdmin } from './routes/socketio';
import { SystemConfig } from './routes/system';
import { SystemNotify } from './routes/system/notify';
const dataProvider = jsonServerProvider('/admin/api', authHTTPClient);
function App() {
return (
<Tushan
basename="/admin"
header={'Tailchat Admin'}
footer={'Build with MsgByte'}
dashboard={<Dashboard />}
dataProvider={dataProvider}
authProvider={authProvider}
i18n={i18n}
>
<CustomRoute name="analytics" icon={<IconExperiment />}>
<TailchatAnalytics />
</CustomRoute>
<Resource name="users" icon={<IconUser />} list={<UserList />} />
<Resource
name="messages"
icon={<IconMessage />}
list={
<ListTable
filter={[
createTextField('q', {
label: 'Search',
}),
]}
showSizeChanger={true}
fields={messageFields}
action={{
detail: true,
edit: true,
delete: true,
export: true,
refresh: true,
}}
batchAction={{ delete: true }}
/>
}
/>
<Resource name="groups" icon={<IconUserGroup />} list={<GroupList />} />
<Resource name="file" icon={<IconFile />} list={<FileList />} />
<Resource
name="mail"
icon={<IconEmail />}
list={<ListTable fields={mailFields} />}
/>
<Category name="plugins">
<Resource
name="p_discover"
icon={<IconCompass />}
list={
<ListTable
fields={discoverFields}
action={{ create: true, detail: true, delete: true }}
/>
}
/>
</Category>
<CustomRoute name="network" icon={<IconWifi />}>
<TailchatNetwork />
</CustomRoute>
<CustomRoute name="socketio" icon={<IconDashboard />}>
<SocketIOAdmin />
</CustomRoute>
<CustomRoute name="cache" icon={<IconStorage />}>
<CacheManager />
</CustomRoute>
<CustomRoute name="system-notify" icon={<IconNotification />}>
<SystemNotify />
</CustomRoute>
<CustomRoute name="system" icon={<IconSettings />}>
<SystemConfig />
</CustomRoute>
</Tushan>
);
}
export default App;

View File

@@ -0,0 +1,15 @@
import {
AuthProvider,
createAuthHttpClient,
createAuthProvider,
HTTPClient,
} from 'tushan';
export const authStorageKey = 'tailchat:admin:auth';
export const authProvider: AuthProvider = createAuthProvider({
authStorageKey,
loginUrl: '/admin/api/login',
});
export const authHTTPClient: HTTPClient = createAuthHttpClient(authStorageKey);

View File

@@ -0,0 +1,303 @@
import { IconFile, IconMessage, 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();
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 />
<DashboardSummary />
<Divider />
<Typography.Title heading={6} style={{ marginBottom: 10 }}>
{t('custom.dashboard.newUserCount')}
</Typography.Title>
<UserCountChart />
<Typography.Title heading={6} style={{ marginBottom: 10 }}>
{t('custom.dashboard.messageCount')}
</Typography.Title>
<MessageCountChart />
</Card>
<Grid cols={3} colGap={12} rowGap={16}>
<GridItem>
<DashboardItem title="Docs" href="https://tailchat.msgbyte.com/">
{t('tushan.dashboard.tip.docs')}
</DashboardItem>
</GridItem>
<GridItem>
<DashboardItem
title="Github"
href="https://github.com/msgbyte/tailchat"
>
{t('custom.dashboard.tip.github')}
</DashboardItem>
</GridItem>
<GridItem>
<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 DashboardSummary: React.FC = React.memo(() => {
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 },
});
const { total: messagesNum } = useGetList('messages', {
pagination: { page: 1, perPage: 1 },
});
return (
<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>
<Divider type="vertical" style={{ height: 40 }} />
<Grid.Col flex={1} style={{ paddingLeft: '1rem' }}>
<DataItem
icon={<IconMessage />}
title={t('custom.dashboard.messages')}
count={messagesNum}
/>
</Grid.Col>
</Grid.Row>
);
});
DashboardSummary.displayName = 'DashboardSummary';
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 UserCountChart: React.FC = React.memo(() => {
const id = 'userCount';
const color = '#82ca9d';
const { t } = useTranslation();
const { value: newUserCountSummary } = useAsync(async () => {
const { data } = await request.get<{
summary: {
count: number;
date: string;
}[];
}>('/user/count/summary');
return data.summary;
}, []);
return (
<ResponsiveContainer width="100%" height={320}>
<AreaChart
width={730}
height={250}
data={newUserCountSummary}
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id={`${id}Color`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.8} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" />
<YAxis />
<CartesianGrid strokeDasharray="3 3" />
<Tooltip />
<Area
type="monotone"
dataKey="count"
label={t('custom.dashboard.newUserCount')}
stroke={color}
fillOpacity={1}
fill={`url(#${id}Color)`}
/>
</AreaChart>
</ResponsiveContainer>
);
});
UserCountChart.displayName = 'UserCountChart';
const MessageCountChart: React.FC = React.memo(() => {
const id = 'messageCount';
const color = '#8884d8';
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={`${id}Color`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.8} />
<stop offset="95%" stopColor={color} 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(#${id}Color)`}
/>
</AreaChart>
</ResponsiveContainer>
);
});
MessageCountChart.displayName = 'MessageCountChart';

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Editor } from '@bytemd/react';
import { plugins } from './plugins';
import 'bytemd/dist/index.css';
import './style.less';
interface MarkdownEditorProps {
value: string;
onChange: (val: string) => void;
}
export const MarkdownEditor: React.FC<MarkdownEditorProps> = React.memo(
(props) => {
return (
<Editor
plugins={plugins}
value={props.value ?? ''}
onChange={props.onChange}
/>
);
}
);
MarkdownEditor.displayName = 'MarkdownEditor';

View File

@@ -0,0 +1,5 @@
import loadable from '@loadable/component';
export const MarkdownEditor = loadable(() =>
import('./editor').then((module) => module.MarkdownEditor)
);

View File

@@ -0,0 +1,6 @@
import gfm from '@bytemd/plugin-gfm';
export const plugins = [
gfm(),
// Add more plugins here
];

View File

@@ -0,0 +1,8 @@
.bytemd .bytemd-toolbar-right [bytemd-tippy-path='5'] {
// Hidden github icon
display: none;
}
.bytemd-fullscreen.bytemd {
z-index: 99;
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { styled, Tag } from 'tushan';
const Root = styled.div`
* {
margin-right: 4px;
margin-bottom: 4px;
}
`;
export const TagItems: React.FC<{
items: string[];
}> = React.memo((props) => {
return (
<Root>
{props.items.map((item, i) => (
<Tag key={i}>{item}</Tag>
))}
</Root>
);
});
TagItems.displayName = 'TagItems';

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { parseUrlStr } from '../utils';
import { Image as OriginImage, ImageProps, styled } from 'tushan';
const Image = styled(OriginImage)`
img {
max-width: 100%;
max-height: 100%;
}
`;
export const TailchatImage: React.FC<ImageProps> = React.memo((props) => {
return <Image {...props} src={parseUrlStr(props.src ?? '')} />;
});
TailchatImage.displayName = 'TailchatImage';

View File

@@ -0,0 +1,13 @@
import React from 'react';
import filesize from 'filesize';
import { createFieldFactory, FieldDetailComponent } from 'tushan';
export const FileSizeFieldDetail: FieldDetailComponent = React.memo((props) => {
return <span>{filesize(Number(props.value))}</span>;
});
FileSizeFieldDetail.displayName = 'FileSizeFieldDetail';
export const createFileSizeField = createFieldFactory({
detail: FileSizeFieldDetail,
edit: FileSizeFieldDetail,
});

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { createFieldFactory, FieldDetailComponent, Image, Link } from 'tushan';
export const ImageUrlFieldDetail: FieldDetailComponent = React.memo((props) => {
const url = props.value;
const isImage = ['.png', '.jpg', '.gif', '.jpeg', '.webp'].some((ext) =>
String(url).endsWith(ext)
);
if (isImage) {
return <Image src={url} height={100} />;
}
return (
<Link href={props.value} icon={true} target="_blank">
{props.value}
</Link>
);
});
ImageUrlFieldDetail.displayName = 'ImageUrlFieldDetail';
export const createImageUrlField = createFieldFactory({
detail: ImageUrlFieldDetail,
edit: ImageUrlFieldDetail,
});

View File

@@ -0,0 +1,23 @@
import React from 'react';
import {
createFieldFactory,
FieldDetailComponent,
ReferenceFieldDetail,
ReferenceFieldOptions,
} from 'tushan';
const SYSTEM_USERID = '000000000000000000000000';
export const UserFieldDetail: FieldDetailComponent = React.memo((props) => {
if (props.value === SYSTEM_USERID) {
return <div>System</div>;
}
return <ReferenceFieldDetail {...props} />;
});
UserFieldDetail.displayName = 'UserFieldDetail';
export const createUserField = createFieldFactory<ReferenceFieldOptions>({
detail: UserFieldDetail,
edit: UserFieldDetail,
});

View File

@@ -0,0 +1,228 @@
import {
createEmailField,
createTextField,
createBooleanField,
createAvatarField,
createJSONField,
createDateTimeField,
createUrlField,
emailValidator,
createNumberField,
createReferenceField,
createTextAreaField,
} from 'tushan';
import { createFileSizeField } from './components/field/filesize';
import { createUserField } from './components/field/user';
import { parseUrlStr } from './utils';
import { createImageUrlField } from './components/field/imageUrl';
export const userFields = [
createTextField('id', {
list: {
sort: true,
},
}),
createEmailField('email', {
edit: {
rules: [
{
required: true,
},
{
validator: emailValidator,
},
],
},
}),
createTextField('nickname'),
createTextField('discriminator', {
edit: {
rules: [
{
required: true,
},
{
match: /\d{4}/,
},
],
},
}),
createBooleanField('temporary'),
createAvatarField('avatar', {
preRenderTransform: (val) =>
typeof val === 'string' ? parseUrlStr(val) : val,
}),
createTextField('type', {
edit: {
hidden: true,
},
}),
createBooleanField('emailVerified'),
createBooleanField('banned', {
edit: {
hidden: true,
},
}),
createJSONField('settings', {
list: {
width: 200,
},
}),
createDateTimeField('createdAt', {
format: 'iso',
edit: {
hidden: true,
},
}),
];
export const messageFields = [
createTextField('id', {
list: {
sort: true,
},
}),
createTextAreaField('content', {
list: {
width: 400,
ellipsis: true,
},
}),
createUserField('author', {
reference: 'users',
displayField: 'nickname',
list: {
width: 80,
},
}),
createReferenceField('groupId', {
reference: 'groups',
displayField: 'name',
list: {
width: 80,
},
}),
createTextField('converseId'),
createBooleanField('hasRecall'),
createJSONField('reactions'),
createDateTimeField('createdAt', {
format: 'iso',
edit: {
hidden: true,
},
}),
];
export const groupFields = [
createTextField('id'),
createTextField('name'),
createReferenceField('owner', {
reference: 'users',
displayField: (record) => `${record.nickname}#${record.discriminator}`,
list: {
width: 160,
},
}),
createTextField('members.length', {
edit: {
hidden: true,
},
}),
createTextField('panels.length', {
edit: {
hidden: true,
},
}),
createJSONField('roles', {
edit: {
hidden: true,
},
}),
createJSONField('fallbackPermissions', {
edit: {
hidden: true,
},
}),
createDateTimeField('createdAt', {
format: 'iso',
edit: {
hidden: true,
},
}),
];
export const fileFields = [
createTextField('objectName', {
list: {
width: 320,
},
}),
createImageUrlField('url', {
preRenderTransform: parseUrlStr,
list: {
width: 140,
},
}),
createTextField('usage', {
list: {
width: 100,
},
}),
createFileSizeField('size', {
list: {
width: 120,
sort: true,
},
}),
createTextField('metaData.content-type'),
createTextField('etag', {
list: {
width: 300,
},
}),
createUserField('userId', {
reference: 'users',
displayField: 'nickname',
list: {
width: 200,
ellipsis: true,
},
}),
createDateTimeField('createdAt', {
list: {
sort: true,
},
}),
];
export const mailFields = [
createTextField('to'),
createTextField('subject'),
createTextField('host'),
createNumberField('port'),
createBooleanField('secure'),
createBooleanField('is_success'),
createJSONField('data'),
createTextField('error'),
createDateTimeField('createdAt'),
];
export const discoverFields = [
createReferenceField('groupId', {
reference: 'groups',
displayField: 'name',
}),
createBooleanField('active', {
edit: {
default: true,
},
}),
createNumberField('order', {
edit: {
default: 0,
},
list: {
sort: true,
},
}),
];

View File

@@ -0,0 +1,7 @@
.arco-table-th {
white-space: nowrap;
}
.arco-table-td {
/* white-space: nowrap; */
overflow: hidden;
}

View File

@@ -0,0 +1,102 @@
import { i18nEnTranslation } from 'tushan/client/i18n/resources/en';
export const enTranslation = {
...i18nEnTranslation,
resources: {
p_discover: {
name: 'Discover',
fields: {
groupId: 'Group ID',
active: 'Is Active',
order: 'Order',
},
},
},
custom: {
action: {
resetPassword: 'Reset Password',
resetPasswordTip:
'After resetting the password, the password becomes: 123456789, please change the password in time',
banUser: 'Ban User',
banUserDesc:
'Banning a user disconnects the user from the current connection and prevents future logins',
unbanUser: 'Unban User',
unbanUserDesc: 'After lifting the ban, the user can login normally',
addGroupMember: 'Add Group Member',
addGroupMemberTitle: 'Select Member and append into group member',
addGroupMemberRequiredTip: 'You need select group member',
selectUser: 'Select User',
},
dashboard: {
file: 'File',
messages: 'Messages',
newUserCount: 'New User Count',
messageCount: 'Message Count',
tip: {
github:
'Tailchat: The next-generation noIM Application in your own workspace',
},
},
file: {
fileTotalSize: 'File Total Size',
},
analytics: {
activeGroupTop5: 'Active Group Top 5',
activeUserTop5: 'Active User Top 5',
largeGroupTop5: 'Large Group Top 5',
fileStorageUserTop5: 'File Storage User Top 5',
},
network: {
nodeList: 'Node List',
id: 'ID',
hostname: 'Host Name',
cpuUsage: 'CPU Usage',
ipList: 'IP List',
sdkVersion: 'SDK Version',
serviceList: 'Service List',
actionList: 'Action List',
eventList: 'Event List',
},
socketio: {
tip1: 'The server URL is:',
tip2: 'The account password is the account password of Tailchat Admin',
tip3: 'NOTICE: please check "Advanced options" then select "websocket only" and "MessagePack parser"',
btn: 'Open the Admin platform',
},
config: {
uploadFileLimit: 'Upload file limit (Byte)',
emailVerification: 'Mandatory Email Verification',
allowGuestLogin: 'Allow Guest Login',
allowUserRegister: 'Allow User Register',
allowCreateGroup: 'Allow Create Group',
serverName: 'Server Name',
serverEntryImage: 'Server Entry Page Image',
configPanel: 'Config',
announcementPanel: 'Announcement',
announcementEnable: 'Is Enable Announcement',
announcementText: 'Announcement Text',
announcementLink: 'Announcement Link',
announcementLinkTip:
'This content is optional, and it is the address to announce more content',
},
cache: {
cleanTitle: 'Are you sure you want to clear the cache?',
cleanDesc:
'Please be cautious in the production environment, clearing the cache may lead to increased pressure on the database in a short period of time',
cleanConfigBtn: 'Clean Client Config Cache',
cleanAllBtn: 'Clean All Cache',
},
'system-notify': {
create: 'Create System Notify',
tip: 'The system notification will be sent to the corresponding user in the form of inbox',
title: 'Title',
content: 'Content',
scope: 'Notify Scope',
allUser: 'All User',
allUserTip:
'All users excluding temporary users. Also, if there are many users, it may not be possible to notify all users at once',
specifiedUser: 'Specified User',
notifySuccess: 'Sent successfully, sent to ${data.userIds.length} users',
},
},
};

View File

@@ -0,0 +1,18 @@
import type { TushanContextProps } from 'tushan';
import { enTranslation } from './en';
import { zhTranslation } from './zh';
export const i18n: TushanContextProps['i18n'] = {
languages: [
{
key: 'en',
label: 'English',
translation: enTranslation,
},
{
key: 'zh',
label: '简体中文',
translation: zhTranslation,
},
],
};

View File

@@ -0,0 +1,192 @@
import { i18nZhTranslation } from 'tushan/client/i18n/resources/zh';
export const zhTranslation = {
...i18nZhTranslation,
resources: {
analytics: {
name: '分析',
},
users: {
name: '用户管理',
fields: {
id: '用户ID',
email: '邮箱',
avatar: '头像',
username: '用户名',
password: '密码',
nickname: '昵称',
discriminator: '标识符',
temporary: '是否游客',
type: '用户类型',
emailVerified: '邮箱校验',
settings: '用户设置',
banned: '是否被封禁',
createdAt: '创建时间',
},
},
messages: {
name: '消息管理',
fields: {
content: '内容',
author: '作者',
groupId: '群组ID',
converseId: '会话ID',
hasRecall: '撤回',
reactions: '消息反应',
createdAt: '创建时间',
},
},
groups: {
name: '群组管理',
fields: {
id: '群组ID',
name: '群组名称',
avatar: '头像',
owner: '管理员',
'members.length': '成员数量',
'panels.length': '面板数量',
roles: '角色',
config: '配置信息',
fallbackPermissions: '默认权限',
createdAt: '创建时间',
updatedAt: '更新时间',
},
},
file: {
name: '文件管理',
fields: {
objectName: '对象存储名',
url: '文件路径',
size: '文件大小',
usage: '使用场景',
'metaData.content-type': '文件类型',
userId: '存储用户',
createdAt: '创建时间',
},
},
mail: {
name: '邮件历史',
fields: {
userId: '用户ID',
to: '目标邮箱',
subject: '邮件主题',
host: '发信主机',
port: '发信端口',
secure: '是否加密',
is_success: '是否成功',
data: '数据',
error: '错误信息',
createdAt: '创建时间',
},
},
p_discover: {
name: '探索',
fields: {
groupId: '群组ID',
active: '是否活跃',
order: '排序',
},
},
system: {
name: '系统设置',
},
network: {
name: '微服务网络',
},
socketio: {
name: 'Socket.IO 长链接',
},
cache: {
name: '缓存管理',
},
'system-notify': {
name: '系统通知',
},
},
category: {
plugins: '插件',
},
custom: {
action: {
resetPassword: '重置密码',
resetPasswordTip: '重置密码后密码变为: 123456789, 请及时修改密码',
banUser: '封禁用户',
banUserDesc: '封禁用户会将用户从当前连接断开并阻止之后的登录操作',
unbanUser: '解除封禁用户',
unbanUserDesc: '解除封禁后用户可以正常登录',
addGroupMember: '增加群组成员',
addGroupMemberTitle: '选择用户并添加为群组成员',
addGroupMemberRequiredTip: '你需要选择用户',
selectUser: '选择用户',
},
dashboard: {
file: '文件',
messages: '消息数',
newUserCount: '用户新增',
messageCount: '消息数',
tip: {
github: 'Tailchat 是在你私有空间内的下一代noIM应用',
tushan: 'Tailchat Admin后台 由 tushan 提供技术支持',
},
},
file: {
fileTotalSize: '文件总大小',
},
analytics: {
activeGroupTop5: '前 5 名活跃群组',
activeUserTop5: '前 5 名活跃用户',
largeGroupTop5: '最大的 5 个群组',
fileStorageUserTop5: '文件存储用量最大 5 名用户',
},
network: {
nodeList: '节点列表',
id: 'ID',
hostname: '主机名',
cpuUsage: 'CPU占用',
ipList: 'IP地址列表',
sdkVersion: 'SDK版本',
serviceList: '服务列表',
actionList: '操作列表',
eventList: '事件列表',
},
socketio: {
tip1: '服务器URL为:',
tip2: '账号密码为Tailchat后台的账号密码',
tip3: '注意: 请打开 "Advanced options" 并选中 "websocket only" 与 "MessagePack parser"',
btn: '打开管理平台',
},
config: {
uploadFileLimit: '上传文件限制(Byte)',
emailVerification: '邮箱强制验证',
allowGuestLogin: '允许访客登录',
allowUserRegister: '允许用户注册',
allowCreateGroup: '允许创建群组',
serverName: '服务器名',
serverEntryImage: '服务器登录图',
configPanel: '配置',
announcementPanel: '公告',
announcementEnable: '是否启用公告',
announcementText: '公告文本',
announcementLink: '公告链接',
announcementLinkTip: '该内容可选,为公告更多内容的地址',
},
cache: {
cleanTitle: '确定要清理缓存么?',
cleanDesc: '生产环境请谨慎操作, 清理缓存可能会导致短时间内数据库压力增加',
cleanConfigBtn: '清理配置缓存',
cleanAllBtn: '清理所有缓存',
},
'system-notify': {
create: '创建系统通知',
tip: '系统通知将会以收件箱的形式发送给对应的用户',
title: '标题',
content: '内容',
scope: '通知范围',
allUser: '所有用户',
allUserTip:
'所有用户不包含临时用户。另外,如果用户很多,可能会无法立即通知所有用户',
specifiedUser: '指定用户',
notifySuccess: '发送成功,已发送给 ${count} 名用户',
},
},
};

View File

@@ -0,0 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './global.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<App />
);

View File

@@ -0,0 +1,41 @@
import axios from 'axios';
import { authStorageKey } from './auth';
import _set from 'lodash/set';
/**
* 创建请求实例
*/
function createRequest() {
const ins = axios.create({
baseURL: '/admin/api',
});
ins.interceptors.request.use(async (val) => {
try {
const { token } = JSON.parse(
window.localStorage.getItem(authStorageKey) ?? '{}'
);
_set(val, ['headers', 'Authorization'], `Bearer ${token}`);
return val;
} catch (err) {
throw err;
}
});
return ins;
}
export const request = createRequest();
export async function callAction(
actionName: string,
params: Record<string, any>
) {
const { data } = await request.post('/callAction', {
action: actionName,
params,
});
return data;
}

View File

@@ -0,0 +1,88 @@
import filesize from 'filesize';
import React, { useState } from 'react';
import {
createTextField,
createSelectField,
ListTable,
useAsync,
useTranslation,
Typography,
styled,
Checkbox,
} from 'tushan';
import { fileFields } from '../fields';
import { request } from '../request';
const Row = styled.div`
display: flex;
gap: 20px;
justify-content: end;
`;
export const FileList: React.FC = React.memo(() => {
const { t } = useTranslation();
const [isOnlyChatFiles, setIsOnlyChatFiles] = useState(false);
const { value: totalSize = 0 } = useAsync(async () => {
const { data } = await request.get('/file/filesizeSum');
return data.totalSize ?? 0;
}, []);
return (
<>
<Row>
<Checkbox
checked={isOnlyChatFiles}
onClick={() => {
setIsOnlyChatFiles(!isOnlyChatFiles);
}}
>
Only show chat files
</Checkbox>
<Typography.Paragraph>
{t('custom.file.fileTotalSize')}: {filesize(totalSize)}
</Typography.Paragraph>
</Row>
<ListTable
filter={[
createTextField('q', {
label: 'Search',
}),
createSelectField('usage', {
label: 'Usage',
allowClear: true,
items: [
{
value: 'chat',
},
{
value: 'group',
},
{
value: 'user',
},
{
value: 'server',
},
{
value: 'unknown',
},
],
}),
]}
tableProps={{
scroll: {
x: 1600,
},
}}
fields={fileFields}
action={{ detail: true, delete: true }}
batchAction={{ delete: true }}
showSizeChanger={[20, 50, 100, 500, 2000]}
meta={isOnlyChatFiles ? 'onlyChat' : undefined}
/>
</>
);
});
FileList.displayName = 'FileList';

View File

@@ -0,0 +1,100 @@
import React, { useState } from 'react';
import {
createTextField,
Identifier,
ListTable,
Message,
Modal,
ReferenceFieldEdit,
useEvent,
useTranslation,
} from 'tushan';
import { groupFields } from '../fields';
import { callAction } from '../request';
export const GroupList: React.FC = React.memo(() => {
const { t } = useTranslation();
const [modal, contextHolder] = Modal.useModal();
return (
<>
{contextHolder}
<ListTable
filter={[
createTextField('q', {
label: 'Search',
}),
]}
fields={groupFields}
action={{
detail: true,
edit: true,
delete: true,
export: true,
create: true,
custom: (record) => [
{
key: 'addGroupMember',
label: t('custom.action.addGroupMember'),
onClick: () => {
let userId: Identifier;
const { close } = modal.confirm({
title: t('custom.action.addGroupMemberTitle'),
content: (
<div>
<div>{t('custom.action.selectUser')}:</div>
<UserSelector onChange={(val) => (userId = val)} />
</div>
),
onOk: async () => {
if (!userId) {
Message.error(
t('custom.action.addGroupMemberRequiredTip')
);
return;
}
try {
await callAction('group.addMember', {
groupId: record.id,
userId,
});
Message.success(t('tushan.common.success'));
close();
} catch (err) {
console.error(err);
Message.error(String(err));
}
},
});
},
},
],
}}
/>
</>
);
});
GroupList.displayName = 'GroupList';
export const UserSelector: React.FC<{ onChange: (val: Identifier) => void }> =
React.memo((props) => {
const [userId, setUserId] = useState<Identifier>('');
const handleChange = useEvent((val: Identifier) => {
setUserId(val);
props.onChange(val);
});
return (
<ReferenceFieldEdit
value={userId}
onChange={handleChange}
options={{
displayField: (record) =>
`${record.nickname}#${record.discriminator}`,
reference: 'users',
}}
/>
);
});
UserSelector.displayName = 'UserSelector';

View File

@@ -0,0 +1,115 @@
import React from 'react';
import {
createTextField,
ListTable,
Message,
Modal,
useRefreshList,
useResourceContext,
useTranslation,
useUpdate,
} from 'tushan';
import { userFields } from '../fields';
import { request } from '../request';
export const UserList: React.FC = React.memo(() => {
const { t } = useTranslation();
const [update] = useUpdate();
const resource = useResourceContext();
const refreshUser = useRefreshList(resource);
return (
<ListTable
filter={[
createTextField('q', {
label: 'Search',
}),
]}
fields={userFields}
action={{
create: true,
detail: true,
edit: true,
delete: true,
refresh: true,
export: true,
custom: (record) => [
{
key: 'resetPassword',
label: t('custom.action.resetPassword'),
onClick: () => {
const { close } = Modal.confirm({
title: t('tushan.common.confirmTitle'),
content: t('custom.action.resetPasswordTip'),
onConfirm: async () => {
try {
await update(resource, {
id: record.id,
data: {
password:
'$2a$10$eSebpg0CEvsbDC7j1NxB2epMUkYwKhfT8vGdPQYkfeXYMqM8HjnpW', // 123456789
},
});
Message.success(t('tushan.common.success'));
close();
} catch (err) {
console.error(err);
Message.error(String(err));
}
},
});
},
},
!record.banned
? {
key: 'banUser',
label: t('custom.action.banUser'),
onClick: () => {
const { close } = Modal.confirm({
title: t('tushan.common.confirmTitle'),
content: t('custom.action.banUserDesc'),
onConfirm: async () => {
try {
await request.post('/user/ban', {
userId: record.id,
});
Message.success(t('tushan.common.success'));
refreshUser();
close();
} catch (err) {
console.error(err);
Message.error(String(err));
}
},
});
},
}
: {
key: 'unbanUser',
label: t('custom.action.unbanUser'),
onClick: () => {
const { close } = Modal.confirm({
title: t('tushan.common.confirmTitle'),
content: t('custom.action.unbanUserDesc'),
onConfirm: async () => {
try {
await request.post('/user/unban', {
userId: record.id,
});
Message.success(t('tushan.common.success'));
refreshUser();
close();
} catch (err) {
console.error(err);
Message.error(String(err));
}
},
});
},
},
],
}}
/>
);
});
UserList.displayName = 'UserList';

View File

@@ -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';

View File

@@ -0,0 +1,55 @@
import React from 'react';
import {
Button,
Card,
Message,
Popconfirm,
Space,
useAsyncRequest,
useTranslation,
} from 'tushan';
import { request } from '../request';
/**
* 缓存管理
*/
export const CacheManager: React.FC = React.memo(() => {
const { t } = useTranslation();
const [, cleanCache] = useAsyncRequest(async (target?: string) => {
const { data } = await request.post('/cache/clean', {
target,
});
if (!data.success) {
Message.error(t('tushan.common.failed') + ':' + data.msg);
throw new Error(data.msg);
}
Message.success(t('tushan.common.success'));
});
return (
<Card>
<Space direction="vertical">
<Popconfirm
title={t('custom.cache.cleanTitle')}
content={t('custom.cache.cleanDesc')}
onOk={() => cleanCache('config.client')}
>
<Button type="primary">{t('custom.cache.cleanConfigBtn')}</Button>
</Popconfirm>
<Popconfirm
title={t('custom.cache.cleanTitle')}
content={t('custom.cache.cleanDesc')}
onOk={() => cleanCache()}
>
<Button type="primary" status="danger">
{t('custom.cache.cleanAllBtn')}
</Button>
</Popconfirm>
</Space>
</Card>
);
});
CacheManager.displayName = 'CacheManager';

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { request } from '../../request';
import _uniq from 'lodash/uniq';
import { TagItems } from '../../components/TagItems';
import {
Card,
Spin,
Table,
Typography,
useAsync,
useTranslation,
} from 'tushan';
/**
* Tailchat 网络状态
*/
export const TailchatNetwork: React.FC = React.memo(() => {
const { value: data, loading } = useAsync(async () => {
const { data } = await request('/network/all');
return data;
});
const { t } = useTranslation();
if (loading) {
return <Spin />;
}
return (
<Card>
<Typography.Title heading={6}>
{t('custom.network.nodeList')}
</Typography.Title>
<Table
columns={[
{
dataIndex: 'id',
title: 'ID',
render: (id, item: any) => (
<div>
{id}
{item.local && <span> (*)</span>}
</div>
),
},
{
dataIndex: 'hostname',
title: 'Host',
},
{
dataIndex: 'cpu',
title: 'CPU',
render: (usage) => usage + '%',
},
{
dataIndex: 'ipList',
title: 'IP',
render: (ips) => <TagItems items={ips ?? []} />,
},
{
dataIndex: 'client.version',
title: 'Client Version',
},
]}
data={data.nodes ?? []}
/>
<Typography.Title heading={6}>
{t('custom.network.serviceList')}
</Typography.Title>
<div>
<TagItems items={_uniq<string>(data.services ?? [])} />
</div>
<Typography.Title heading={6}>
{t('custom.network.actionList')}
</Typography.Title>
<div>
<TagItems items={_uniq<string>(data.actions ?? [])} />
</div>
<Typography.Title heading={6}>
{t('custom.network.eventList')}
</Typography.Title>
<div>
<TagItems items={_uniq<string>(data.events ?? [])} />
</div>
</Card>
);
});
TailchatNetwork.displayName = 'TailchatNetwork';

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Button, Card, Typography, useTranslation } from 'tushan';
/**
* SocketIO 管理
*/
export const SocketIOAdmin: React.FC = React.memo(() => {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const { t } = useTranslation();
return (
<Card>
<div>
<Typography.Paragraph>
{t('custom.socketio.tip1')}{' '}
<strong>
{protocol}://{window.location.host}
</strong>
</Typography.Paragraph>
<Typography.Paragraph>{t('custom.socketio.tip2')}</Typography.Paragraph>
<Typography.Paragraph>{t('custom.socketio.tip3')}</Typography.Paragraph>
</div>
<Button
type="primary"
onClick={() => {
window.open('https://admin.socket.io/');
}}
>
{t('custom.socketio.btn')}
</Button>
</Card>
);
});
SocketIOAdmin.displayName = 'SocketIOAdmin';

View File

@@ -0,0 +1,245 @@
import React, { useEffect } from 'react';
import { request } from '../../request';
import {
useAsyncRequest,
useEditValue,
Button,
Input,
Spin,
Message,
Form,
Upload,
useTranslation,
Card,
Tabs,
Switch,
} from 'tushan';
import _get from 'lodash/get';
import { IconCheck, IconClose, IconDelete } from 'tushan/icon';
import { TailchatImage } from '../../components/TailchatImage';
/**
* Tailchat 系统设置
*/
export const SystemConfig: React.FC = React.memo(() => {
const [{ value: config = {}, loading, error }, fetchConfig] = useAsyncRequest(
async () => {
const { data } = await request.get('/config/client');
return data.config ?? {};
}
);
const { t } = useTranslation();
useEffect(() => {
fetchConfig();
}, []);
const [serverName, setServerName, saveServerName] = useEditValue(
config?.serverName,
async (val) => {
if (val === config?.serverName) {
return;
}
try {
await request.patch('/config/client', {
key: 'serverName',
value: val,
});
fetchConfig();
Message.success(t('tushan.common.success'));
} catch (err) {
console.log(err);
Message.error(String(err));
}
}
);
const [{}, handleChangeServerEntryImage] = useAsyncRequest(
async (file: File | null) => {
if (file) {
const formdata = new FormData();
formdata.append('file', file);
formdata.append('usage', 'server');
const { data } = await request.put('/file/upload', formdata, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
const fileInfo = data.files[0];
if (!fileInfo) {
throw new Error('not get file');
}
const url = fileInfo.url;
await request.patch('/config/client', {
key: 'serverEntryImage',
value: url,
});
fetchConfig();
} else {
// delete
await request.patch('/config/client', {
key: 'serverEntryImage',
value: '',
});
fetchConfig();
}
}
);
const [{}, handleChangeAnnouncement] = useAsyncRequest(
async (values: { enable: boolean; link: string; text: string }) => {
console.log(values);
const { enable = false, link = '', text = '' } = values;
if (enable) {
await request.patch('/config/client', {
key: 'announcement',
value: {
id: Date.now(),
text,
link,
},
});
} else {
await request.patch('/config/client', {
key: 'announcement',
value: false,
});
}
Message.success(t('tushan.common.success'));
}
);
if (loading) {
return <Spin />;
}
if (error) {
console.log('error', error);
return <div>{String(error)}</div>;
}
return (
<Card>
<Tabs>
<Tabs.TabPane key={0} title={t('custom.config.configPanel')}>
<Form>
<Form.Item label={t('custom.config.uploadFileLimit')}>
{config.uploadFileLimit}
</Form.Item>
<Form.Item label={t('custom.config.emailVerification')}>
{config.emailVerification ? <IconCheck /> : <IconClose />}
</Form.Item>
<Form.Item label={t('custom.config.allowGuestLogin')}>
{!config.disableGuestLogin ? <IconCheck /> : <IconClose />}
</Form.Item>
<Form.Item label={t('custom.config.allowUserRegister')}>
{!config.disableUserRegister ? <IconCheck /> : <IconClose />}
</Form.Item>
<Form.Item label={t('custom.config.allowCreateGroup')}>
{!config.disableCreateGroup ? <IconCheck /> : <IconClose />}
</Form.Item>
<Form.Item label={t('custom.config.serverName')}>
<Input
value={serverName}
onChange={(val) => setServerName(val)}
onBlur={() => saveServerName()}
placeholder="Tailchat"
/>
</Form.Item>
<Form.Item label={t('custom.config.serverEntryImage')}>
<div>
{config?.serverEntryImage ? (
<div style={{ marginTop: 10 }}>
<div>
<TailchatImage
style={{
maxWidth: '100%',
maxHeight: 360,
overflow: 'hidden',
marginBottom: 4,
}}
src={config?.serverEntryImage}
/>
</div>
<Button
type="primary"
icon={<IconDelete />}
onClick={() => handleChangeServerEntryImage(null)}
>
Delete
</Button>
</div>
) : (
<Upload
onChange={(_, file) => {
handleChangeServerEntryImage(file.originFile);
}}
/>
)}
</div>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane key={1} title={t('custom.config.announcementPanel')}>
<Form
initialValues={
config['announcement']
? {
enable: true,
text: _get(config, ['announcement', 'text'], ''),
link: _get(config, ['announcement', 'link'], ''),
}
: { enable: false, text: '', link: '' }
}
onSubmit={handleChangeAnnouncement}
>
<Form.Item
label={t('custom.config.announcementEnable')}
field="enable"
>
<SwitchFormInput />
</Form.Item>
<Form.Item label={t('custom.config.announcementText')} field="text">
<Input maxLength={240} />
</Form.Item>
<Form.Item
label={t('custom.config.announcementLink')}
field="link"
tooltip={t('custom.config.announcementLinkTip')}
>
<Input placeholder="https://tailchat.msgbyte.com/" />
</Form.Item>
<Form.Item label={' '}>
<Button htmlType="submit">{t('tushan.common.submit')}</Button>
</Form.Item>
</Form>
</Tabs.TabPane>
</Tabs>
</Card>
);
});
SystemConfig.displayName = 'SystemConfig';
export const SwitchFormInput: React.FC<{
value?: boolean;
onChange?: (val: boolean) => void;
}> = React.memo((props) => {
return <Switch checked={props.value} onChange={props.onChange} />;
});
SwitchFormInput.displayName = 'SwitchFormInput';

View File

@@ -0,0 +1,147 @@
import React from 'react';
import {
Button,
Input,
Form,
useTranslation,
Typography,
Card,
Radio,
ReferenceFieldEdit,
useAsyncRequest,
Tooltip,
Message,
} from 'tushan';
import { IconExclamationCircle } from 'tushan/icon';
import { MarkdownEditor } from '../../components/MarkdownEditor';
import { request } from '../../request';
/**
* Tailchat 系统通知
*
* 发送markdown格式的消息到指定用户的收件箱
*/
export const SystemNotify: React.FC = React.memo(() => {
const { t } = useTranslation();
const [form] = Form.useForm();
const scope: 'all' | 'specified' = Form.useWatch('scope', form);
const [{ loading }, handleSubmit] = useAsyncRequest(async (values) => {
const { data } = await request.post('/users/system/notify', {
scope: values.scope,
specifiedUser: values.specifiedUser,
title: values.title,
content: values.content,
});
Message.success(
t('custom.system-notify.notifySuccess', { count: data.userIds.length })
);
});
return (
<Card>
<Typography.Title heading={3} style={{ textAlign: 'center' }}>
{t('custom.system-notify.create')}
</Typography.Title>
<Typography.Title
heading={6}
style={{ textAlign: 'center', color: '#666' }}
>
{t('custom.system-notify.tip')}
</Typography.Title>
<Form form={form} onSubmit={handleSubmit}>
<Form.Item label={t('custom.system-notify.title')} field="title">
<Input name="title" />
</Form.Item>
<Form.Item
label={t('custom.system-notify.content')}
field="content"
rules={[{ required: true }]}
>
<MarkdownFormInput />
</Form.Item>
<Form.Item
label={t('custom.system-notify.scope')}
field="scope"
rules={[{ required: true }]}
initialValue="all"
>
<Radio.Group>
<Radio value="all">
{t('custom.system-notify.allUser')}
<Tooltip content={t('custom.system-notify.allUserTip')}>
<IconExclamationCircle
style={{ margin: '0 8px', color: 'rgb(var(--arcoblue-6))' }}
/>
</Tooltip>
</Radio>
<Radio value="specified">
{t('custom.system-notify.specifiedUser')}
</Radio>
</Radio.Group>
</Form.Item>
{scope === 'specified' && (
<Form.Item
label={t('custom.system-notify.specifiedUser')}
field="specifiedUser"
>
<UserSelectedFormInput />
</Form.Item>
)}
<Form.Item label={' '}>
<Button htmlType="submit" loading={loading}>
{t('tushan.common.submit')}
</Button>
</Form.Item>
</Form>
</Card>
);
});
SystemNotify.displayName = 'SystemNotify';
export const MarkdownFormInput: React.FC<{
value?: string;
onChange?: (val: string) => void;
}> = React.memo((props) => {
const value = props.value || '';
const handleChange = (newValue) => {
props.onChange && props.onChange(newValue);
};
return <MarkdownEditor value={value} onChange={handleChange} />;
});
MarkdownFormInput.displayName = 'MarkdownFormInput';
export const UserSelectedFormInput: React.FC<{
value?: string;
onChange?: (val: string) => void;
}> = React.memo((props) => {
const value = props.value || '';
const handleChange = (newValue) => {
props.onChange && props.onChange(newValue);
};
/**
* Wait for ReferenceMany
*/
return (
<ReferenceFieldEdit
value={value}
onChange={handleChange}
options={{
reference: 'users',
displayField: 'nickname',
}}
/>
);
});
UserSelectedFormInput.displayName = 'UserSelectedFormInput';

View File

@@ -0,0 +1,13 @@
/**
* parse url, and replace some constants with variable
* @param originUrl 原始Url
* @returns 解析后的url
*/
export function parseUrlStr(originUrl: string): string {
return String(originUrl).replace(
'{BACKEND}',
process.env.NODE_ENV === 'development'
? 'http://localhost:11000'
: window.location.origin
);
}

1
server/admin/src/client/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,28 @@
import { TcBroker, SYSTEM_USERID } from 'tailchat-server-sdk';
import brokerConfig from '../../../moleculer.config';
const transporter = process.env.TRANSPORTER;
export const broker = new TcBroker({
...brokerConfig,
metrics: false,
logger: false,
transporter,
});
broker.start().then(() => {
console.log('Connnected to Tailchat network, TRANSPORTER: ', transporter);
});
export function callBrokerAction<T>(
actionName: string,
params: any,
opts?: Record<string, any>
): Promise<T> {
return broker.call(actionName, params, {
...opts,
meta: {
...opts?.meta,
userId: SYSTEM_USERID,
},
});
}

View File

@@ -0,0 +1,63 @@
import express from 'express';
import ViteExpress from 'vite-express';
import mongoose from 'mongoose';
import compression from 'compression';
import morgan from 'morgan';
import path from 'path';
import dotenv from 'dotenv';
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
import { apiRouter } from './router/api';
const app = express();
const port = Number(process.env.ADMIN_PORT || 3000);
if (!process.env.MONGO_URL) {
console.error('Require env: MONGO_URL');
process.exit(1);
}
// 链接数据库
mongoose.connect(process.env.MONGO_URL, (error: any) => {
if (!error) {
return console.info('Datebase connected');
}
console.error('Datebase connect error', error);
});
app.use(compression());
app.use(express.json());
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable('x-powered-by');
// Remix fingerprints its assets so we can cache forever.
app.use(
'/build',
express.static('public/build', { immutable: true, maxAge: '1y' })
);
// Everything else (like favicon.ico) is cached for an hour. You may want to be
// more aggressive with this caching.
app.use(express.static('public', { maxAge: '1h' }));
app.use(morgan('tiny'));
app.use('/admin/api', apiRouter);
app.use((err: any, req: any, res: any, next: any) => {
res.status(500);
res.json({ error: err.message });
});
if (process.env.NODE_ENV === 'production') {
ViteExpress.config({
mode: 'production',
});
}
ViteExpress.listen(app, port, () => {
console.log(
`Server is listening on port ${port}, visit with: http://localhost:${port}/admin/`
);
});

View File

@@ -0,0 +1,39 @@
import type { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import md5 from 'md5';
export const adminAuth = {
username: process.env.ADMIN_USER,
password: process.env.ADMIN_PASS,
};
export const authSecret =
(process.env.SECRET || 'tailchat') + md5(JSON.stringify(adminAuth)); // 增加一个md5的盐值确保SECRET没有设置的情况下只修改了用户名密码也不会被人伪造token秘钥
export function auth() {
return (req: Request, res: Response, next: NextFunction) => {
try {
const authorization = req.headers.authorization;
if (!authorization) {
res.status(401).end('not found authorization in headers');
return;
}
const token = authorization.slice('Bearer '.length);
const payload = jwt.verify(token, authSecret);
if (typeof payload === 'string') {
res.status(401).end('payload type error');
return;
}
if (payload.platform !== 'admin') {
res.status(401).end('Payload invalid');
return;
}
next();
} catch (err) {
res.status(401).end(String(err));
}
};
}

View File

@@ -0,0 +1,4 @@
fork from https://github.com/NathanAdhitya/express-mongoose-ra-json-server
modify:
- count logic in get `/`

View File

@@ -0,0 +1,259 @@
import { RequestHandler, Router } from 'express';
import type { LeanDocument } from 'mongoose';
import statusMessages from './statusMessages';
import type { ADPBaseModel, ADPBaseSchema } from './utils/baseModel.interface';
import castFilter from './utils/castFilter';
import convertId from './utils/convertId';
import filterGetList from './utils/filterGetList';
import { filterReadOnly } from './utils/filterReadOnly';
import parseQuery from './utils/parseQuery';
import virtualId from './utils/virtualId';
// Export certain helper functions for custom reuse.
export { default as virtualId } from './utils/virtualId';
export { default as convertId } from './utils/convertId';
export { default as castFilter } from './utils/castFilter';
export { default as parseQuery } from './utils/parseQuery';
export { default as filterGetList } from './utils/filterGetList';
export { filterReadOnly } from './utils/filterReadOnly';
export { default as statusMessages } from './statusMessages';
export interface raExpressMongooseCapabilities {
list?: boolean;
get?: boolean;
create?: boolean;
update?: boolean;
delete?: boolean;
}
export interface raExpressMongooseOptions<T> {
/** Fields to search from ?q (used for autofill and search) */
q?: string[];
/** Base name for ACLs (e.g. list operation does baseName.list) */
aclName?: string;
/** Fields to allow regex based search (non-exact search) */
allowedRegexFields?: string[];
/** Read-only fields to filter out during create and update */
readOnlyFields?: string[];
/** Function to transform inputs received in create and update */
inputTransformer?: (input: Partial<T>) => Promise<Partial<T>>;
/** Additional queries for list, e.g. deleted/hidden flag. */
listQuery?: Record<string, any>;
/** Max rows from a get operation to prevent accidental server suicide (default 100) */
maxRows?: number;
/** Extra selects for mongoose queries (in the case that certain fields are hidden by default) */
extraSelects?: string;
/** Disable or enable certain parts. */
capabilities?: raExpressMongooseCapabilities;
/** Specify a custom express.js router */
router?: Router;
/** Specify an ACL middleware to check against permissions */
ACLMiddleware?: (name: string) => RequestHandler;
}
export function raExpressMongoose<T extends ADPBaseModel, I>(
model: T,
options?: raExpressMongooseOptions<I>
) {
const {
q,
allowedRegexFields = [],
readOnlyFields,
inputTransformer = (input: any) => input,
listQuery,
extraSelects,
maxRows = 100,
capabilities,
aclName,
router = Router(),
ACLMiddleware,
} = options ?? {};
const {
list: canList = true,
get: canGet = true,
create: canCreate = true,
update: canUpdate = true,
delete: canDelete = true,
} = capabilities ?? {};
/** getList, getMany, getManyReference */
if (canList)
router.get(
'/',
aclName && ACLMiddleware
? ACLMiddleware(`${aclName}.list`)
: (req, res, next) => next(),
async (req, res) => {
const filterQuery = {
...listQuery,
...parseQuery(
castFilter(
convertId(filterGetList(req.query)),
model,
allowedRegexFields
),
model,
allowedRegexFields,
q
),
};
let query = model.find(filterQuery);
if (req.query._sort && req.query._order)
query = query.sort({
[typeof req.query._sort === 'string'
? req.query._sort === 'id'
? '_id'
: req.query._sort
: '_id']: req.query._order === 'ASC' ? 1 : -1,
});
if (req.query._start)
query = query.skip(
parseInt(
typeof req.query._start === 'string' ? req.query._start : '0'
)
);
if (req.query._end)
query = query.limit(
Math.min(
parseInt(
typeof req.query._end === 'string' ? req.query._end : '0'
) -
(req.query._start
? parseInt(
typeof req.query._start === 'string'
? req.query._start
: '0'
)
: 0),
maxRows
)
);
else query = query.limit(maxRows);
if (extraSelects) query = query.select(extraSelects);
if (Object.keys(filterQuery).length === 0) {
res.set(
'X-Total-Count',
(await model.estimatedDocumentCount()).toString()
);
} else {
res.set(
'X-Total-Count',
(await model.countDocuments(filterQuery)).toString()
);
}
return res.json(
virtualId((await query.lean()) as LeanDocument<ADPBaseSchema>)
);
}
);
/** getOne, getMany */
if (canGet)
router.get(
'/:id',
aclName && ACLMiddleware
? ACLMiddleware(`${aclName}.list`)
: (req, res, next) => next(),
async (req, res) => {
await model
.findById(req.params.id)
.select(extraSelects)
.lean()
.then((result) => res.json(virtualId(result)))
.catch((e) => {
return statusMessages.error(res, 400, e);
});
}
);
/** create */
if (canCreate)
router.post(
'/',
aclName && ACLMiddleware
? ACLMiddleware(`${aclName}.create`)
: (req, res, next) => next(),
async (req, res) => {
// eslint-disable-next-line new-cap
const result = convertId(
await inputTransformer(filterReadOnly<I>(req.body, readOnlyFields))
);
const newData = {
...result,
};
const newEntry = new model(newData);
await newEntry
.save()
.then((result) => res.json(virtualId(result)))
.catch((e: any) => {
return statusMessages.error(res, 400, e, 'Bad request');
});
}
);
/** update */
if (canUpdate)
router.put(
'/:id',
aclName && ACLMiddleware
? ACLMiddleware(`${aclName}.edit`)
: (req, res, next) => next(),
async (req, res) => {
const updateData = {
...(await convertId(
await inputTransformer(filterReadOnly<I>(req.body, readOnlyFields))
)),
};
await model
.findOneAndUpdate({ _id: req.params.id }, updateData, {
new: true,
runValidators: true,
})
.lean()
.then((result) => res.json(virtualId(result)))
.catch((e) => {
return statusMessages.error(res, 400, e, 'Bad request');
});
}
);
/**
* delete
*/
if (canDelete)
router.delete(
'/:id',
aclName && ACLMiddleware
? ACLMiddleware(`${aclName}.delete`)
: (req, res, next) => next(),
async (req, res) => {
await model
.findOneAndDelete({ _id: req.params.id })
.then((result) => res.json(virtualId(result)))
.catch((e) => {
return statusMessages.error(res, 404, e, 'Element does not exist');
});
}
);
return router;
}

View File

@@ -0,0 +1,24 @@
/**
* @file statusMessages
* @description handles status messages / error responses
*/
import type { Response } from 'express';
/**
* Handles rejections other than errors. 400, 401, etc.
*/
function reject(res: Response, status: number, reason?: any) {
return res.status(status).json({ message: reason ?? 'Invalid request' });
}
/**
* Handles errors
*/
function error(res: Response, status: number, e: Error, message?: string) {
if (process.env.NODE_ENV !== 'production') {
return res.status(status).json({ message, error: e.message });
}
}
export default { reject, error };

View File

@@ -0,0 +1,7 @@
import type { Model, Document } from 'mongoose';
export interface ADPBaseSchema {
_id: string;
}
export type ADPBaseModel = Model<ADPBaseSchema & Document & any>;

View File

@@ -0,0 +1,35 @@
import type { ADPBaseModel } from './baseModel.interface';
/**
* Turns all the params into their proper types, string into regexes.
* Only works with shallow objects.
* Mutates original object and returns mutated object.
*/
export default function castFilter<T extends ADPBaseModel>(
obj: Record<string, any>,
model: T,
allowedRegexes: string[] = []
) {
const { path } = model.schema;
Object.keys(obj).forEach((key) => {
try {
obj[key] = path(key).cast(obj[key], null, null);
} catch (e) {}
if (allowedRegexes.includes(key) && typeof obj[key] === 'string') {
obj[key] = new RegExp(escapeStringRegexp(obj[key]));
}
});
return obj;
}
function escapeStringRegexp(string) {
if (typeof string !== 'string') {
throw new TypeError('Expected a string');
}
// Escape characters with special meaning either inside or outside character sets.
// Use a simple backslash escape when its always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns stricter grammar.
return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d');
}

View File

@@ -0,0 +1,14 @@
/** Turns id into _id for search queries */
export default function convertId<T extends Record<string, unknown>>(obj: T) {
if (obj.id) {
const newObject = {
_id: obj.id,
...obj,
};
delete newObject.id;
return newObject;
} else {
return obj;
}
}

View File

@@ -0,0 +1,17 @@
export const filterGetListParams = [
'_sort',
'_order',
'_start',
'_end',
] as const;
/** Removes _sort, _order, _start, _end from a query. */
export default function filterGetList<T extends Record<string, unknown>>(
obj: T
) {
const filtered: any = {};
Object.entries(obj).forEach(([index, value]) => {
if (!filterGetListParams.includes(index as any)) filtered[index] = value;
});
return filtered as Omit<T, (typeof filterGetListParams)[number]>;
}

View File

@@ -0,0 +1,13 @@
/** Makes sure that it does not modify crucial and sacred parts mutates the original object. */
export function filterReadOnly<T extends {}>(
obj: T,
readOnlyFields?: string[]
) {
if (!readOnlyFields) return obj as T;
readOnlyFields.forEach((v) => {
delete obj[v];
});
return obj as Partial<T>;
}

View File

@@ -0,0 +1,38 @@
import type { ADPBaseModel } from './baseModel.interface';
import castFilter from './castFilter';
import { isValidObjectId } from 'mongoose';
interface parseQueryParam {
q?: string;
$or?: any;
}
/**
* Turns ?q into $or queries, deletes q
* @param {Object} results Original object with the q field
* @param {string[]} fields Fields to apply q to
*/
export default function parseQuery<
T extends parseQueryParam,
M extends ADPBaseModel
>(
result: T,
model: M,
allowedRegexes: string[],
fields?: string[]
): T & { $or?: any } {
if (!fields) return result;
if (result.q) {
if (!Array.isArray(result.$or)) result.$or = [];
fields.forEach((field) => {
if (field === '_id' && !isValidObjectId(result.q)) {
// Skip _id search in invalid objectid
return;
}
const newFilter = { [field]: result.q };
result.$or.push(castFilter(newFilter, model, allowedRegexes));
});
delete result.q;
}
return result;
}

View File

@@ -0,0 +1,21 @@
export default function virtualId<T extends { _id: string }>(
arr: T[]
): Array<T & { id: string }>;
export default function virtualId<T extends { _id: string }>(
doc: T
): T & { id: string };
/** Virtual ID (_id to id) for react-admin */
export default function virtualId<T extends { _id: string }>(el: Array<T> | T) {
if (Array.isArray(el)) {
return el.map((e) => {
return {
id: e._id,
...e,
_id: undefined,
};
});
}
return { id: el._id, ...el, _id: undefined };
}

View File

@@ -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 };

View File

@@ -0,0 +1,373 @@
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import { broker, callBrokerAction } from '../broker';
import { adminAuth, auth, authSecret } from '../middleware/auth';
import { configRouter } from './config';
import { networkRouter } from './network';
import { fileRouter } from './file';
import dayjs from 'dayjs';
import userModel from '../../../../models/user/user';
import messageModel from '../../../../models/chat/message';
import fileModel from '../../../../models/file';
import groupModel from '../../../../models/group/group';
import {
raExpressMongoose,
virtualId,
} from '../middleware/express-mongoose-ra-json-server';
import { cacheRouter } from './cache';
import { analyticsRouter } from './analytics';
import _ from 'lodash';
const router = Router();
router.post('/login', (req, res) => {
if (!adminAuth.username || !adminAuth.password) {
res.status(401).end('Server not set env: ADMIN_USER, ADMIN_PASS');
return;
}
const { username, password } = req.body;
if (username === adminAuth.username && password === adminAuth.password) {
// 用户名和密码都正确返回token
const token = jwt.sign(
{
username,
platform: 'admin',
},
authSecret,
{
expiresIn: '2h',
}
);
res.status(200).json({
username,
token: token,
expiredAt: new Date().valueOf() + 2 * 60 * 60 * 1000,
});
} else {
res.status(401).end('username or password incorrect');
}
});
router.use('/analytics', analyticsRouter);
router.use('/network', networkRouter);
router.use('/config', configRouter);
router.use('/file', fileRouter);
router.use('/cache', cacheRouter);
router.post('/callAction', auth(), async (req, res) => {
const { action, params } = req.body;
const ret = await callBrokerAction(action, params);
res.json(ret);
});
router.get('/user/count/summary', auth(), async (req, res) => {
// 返回最近14天的用户数统计
const day = 14;
const aggregateRes: { count: number; date: string }[] = await userModel
.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.post('/user/ban', auth(), async (req, res) => {
const { userId } = req.body;
const ret = await broker.call('user.banUser', {
userId,
});
res.json({
ret,
});
});
router.post('/user/unban', auth(), async (req, res) => {
const { userId } = req.body;
const ret = await broker.call('user.unbanUser', {
userId,
});
res.json({
ret,
});
});
router.post('/users/system/notify', auth(), async (req, res) => {
const { scope, specifiedUser, title, content } = req.body;
let userIds = [];
if (scope === 'all') {
const users = await userModel.find(
{
// false 或 null(正式用户或者老的用户)
temporary: {
$ne: true,
},
},
{
_id: 1,
}
);
userIds = users.map((u) => u._id);
} else if (scope === 'specified') {
userIds = Array.isArray(specifiedUser) ? specifiedUser : [specifiedUser];
}
broker.call('chat.inbox.batchAppend', {
userIds,
type: 'markdown',
payload: {
title,
content,
},
});
res.json({ userIds });
});
router.use(
'/users',
auth(),
raExpressMongoose(userModel, {
q: ['_id', 'nickname', 'email'],
allowedRegexFields: ['nickname'],
})
);
router.delete('/messages/:id', auth(), async (req, res) => {
try {
const messageId = req.params.id;
await callBrokerAction('chat.message.deleteMessage', {
messageId,
});
res.json({ id: messageId });
} catch (err) {
console.error(err);
res.status(500).json({ message: (err as any).message });
}
});
router.get('/message/count/summary', auth(), async (req, res) => {
// 返回最近14天的消息数统计
const day = 14;
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(messageModel, {
q: ['content'],
allowedRegexFields: ['content'],
})
);
router.post('/groups/', auth(), async (req, res) => {
// create group
const { name, owner } = req.body;
const group = await groupModel.createGroup({
name,
owner,
});
res.json({
id: group._id,
});
});
router.use(
'/groups',
auth(),
raExpressMongoose(groupModel, {
q: ['_id', 'name'],
capabilities: {
create: false,
},
})
);
router.delete('/file/:id', auth(), async (req, res) => {
try {
const fileId = req.params.id;
const record = await fileModel.findById(fileId);
if (record) {
await callBrokerAction('file.delete', {
objectName: record.objectName,
});
}
res.json({ id: fileId });
} catch (err) {
console.error(err);
res.status(500).json({ message: (err as any).message });
}
});
router.use(
'/file',
auth(),
async (req, res, next) => {
const onlyChatFile = req.query.meta === 'onlyChat';
if (!onlyChatFile) {
return next();
}
// only return chatted file rather than all file
const result = await fileModel
.aggregate()
.lookup({
from: 'users',
localField: 'url',
foreignField: 'avatar',
as: 'avatarMatchedUser',
})
.lookup({
from: 'groups',
localField: 'url',
foreignField: 'avatar',
as: 'avatarMatchedGroup',
})
.lookup({
from: 'groups',
localField: 'url',
foreignField: 'config.groupBackgroundImage',
as: 'backgroundMatchedGroup',
})
.match({
'avatarMatchedUser.0': { $exists: false },
'avatarMatchedGroup.0': { $exists: false },
'backgroundMatchedGroup.0': { $exists: false },
})
.project({
avatarMatchedUser: 0,
avatarMatchedGroup: 0,
backgroundMatchedGroup: 0,
})
.facet({
metadata: [{ $count: 'total' }],
data: [
{
$sort: {
[typeof req.query._sort === 'string'
? req.query._sort === 'id'
? '_id'
: req.query._sort
: '_id']: req.query._order === 'ASC' ? 1 : -1,
},
},
{ $skip: Number(req.query._start) },
{ $limit: Number(req.query._end) - Number(req.query._start) },
],
})
.exec();
const list = _.get(result, '0.data');
const total = _.get(result, '0.metadata.0.total');
return res.set('X-Total-Count', total).json(virtualId(list)).end();
},
raExpressMongoose(fileModel, {
q: ['objectName'],
allowedRegexFields: ['objectName'],
capabilities: {
delete: false,
},
maxRows: 2000,
})
);
router.use(
'/mail',
auth(),
raExpressMongoose(require('../../../../models/user/mail').default)
);
export { router as apiRouter };

View File

@@ -0,0 +1,32 @@
import { Router } from 'express';
import { broker } from '../broker';
import { auth } from '../middleware/auth';
const router = Router();
/**
* 清理所有缓存
*/
router.post('/clean', auth(), async (req, res, next) => {
try {
if (!broker.cacher) {
res.json({
success: false,
message: 'Not found cacher',
});
return;
}
const { target = undefined } = req.body;
await broker.cacher.clean(target);
res.json({
success: true,
});
} catch (err) {
next(err);
}
});
export { router as cacheRouter };

View File

@@ -0,0 +1,38 @@
/**
* Network 相关接口
*/
import { Router } from 'express';
import { broker } from '../broker';
import { auth } from '../middleware/auth';
const router = Router();
router.get('/client', auth(), async (req, res, next) => {
try {
const config = await broker.call('config.client');
res.json({
config,
});
} catch (err) {
next(err);
}
});
router.patch('/client', auth(), async (req, res, next) => {
try {
await broker.call('config.setClientConfig', {
key: req.body.key,
value: req.body.value,
});
res.json({
success: true,
});
} catch (err) {
next(err);
}
});
export { router as configRouter };

View File

@@ -0,0 +1,82 @@
/**
* Network 相关接口
*/
import { Router } from 'express';
import { callBrokerAction } from '../broker';
import { auth } from '../middleware/auth';
import Busboy from '@fastify/busboy';
import fileModel from '../../../../models/file';
const router = Router();
router.put('/upload', auth(), async (req, res) => {
const busboy = new Busboy({ headers: req.headers as any });
const promises: Promise<any>[] = [];
busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
promises.push(
callBrokerAction('file.save', file, {
filename: filename,
})
.then((data) => {
console.log(data);
return data;
})
.catch((err) => {
file.resume(); // Drain file stream to continue processing form
busboy.emit('error', err);
return err;
})
);
});
busboy.on('finish', async () => {
/* istanbul ignore next */
if (promises.length == 0) {
res.status(500).json('File missing in the request');
return;
}
try {
const files = await Promise.all(promises);
res.json({ files });
} catch (err) {
console.error(err);
res.status(500).json(String(err));
}
});
busboy.on('error', (err) => {
console.error(err);
req.unpipe(busboy);
req.resume();
res.status(500).json({ err });
});
req.pipe(busboy);
});
router.get('/filesizeSum', auth(), async (req, res) => {
const ret = await fileModel.aggregate([
{
$group: {
_id: '$objectName' as any,
size: { $first: '$size' },
},
},
{
$group: {
_id: null,
totalSize: { $sum: '$size' },
},
},
]);
const totalSize = ret[0].totalSize;
res.json({ totalSize });
});
export { router as fileRouter };

View File

@@ -0,0 +1,37 @@
/**
* Network 相关接口
*/
import { Router } from 'express';
import { broker } from '../broker';
import { auth } from '../middleware/auth';
import _ from 'lodash';
const router = Router();
router.get('/all', auth(), async (req, res) => {
res.json({
nodes: Array.from(new Map(broker.registry.nodes.nodes).values()).map(
(item) =>
_.pick(item, [
'id',
'available',
'local',
'ipList',
'hostname',
'cpu',
'client',
])
),
events: broker.registry.events.events.map((item: any) => item.name),
services: broker.registry.services.services.map((item: any) => item.name),
actions: Array.from(new Map(broker.registry.actions.actions).keys()),
});
});
router.get('/ping', auth(), async (req, res) => {
const pong = await broker.ping();
res.json(pong);
});
export { router as networkRouter };

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"jsx": "react-jsx",
"forceConsistentCasingInFileNames": true,
"importsNotUsedAsValues": "error",
"module": "CommonJS",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"include": ["./src/server/**/*.ts", "../models/**/*.ts"],
"exclude": ["node_modules/**/*", "dist"],
"compilerOptions": {
"rootDirs": ["./", "../"],
"outDir": "./dist",
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"target": "ES2019",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"importsNotUsedAsValues": "error",
"experimentalDecorators": true,
"noEmit": false
}
}

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
base: '/admin',
plugins: [react()],
server: {
// just for link tushan
fs: {
strict: mode === 'development' ? false : true,
},
},
}));