优化
This commit is contained in:
13
server/admin/index.html
Normal file
13
server/admin/index.html
Normal 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>
|
||||
7
server/admin/nodemon.json
Normal file
7
server/admin/nodemon.json
Normal 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
50
server/admin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
server/admin/public/favicon.ico
Normal file
BIN
server/admin/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
127
server/admin/src/client/App.tsx
Normal file
127
server/admin/src/client/App.tsx
Normal 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;
|
||||
15
server/admin/src/client/auth.ts
Normal file
15
server/admin/src/client/auth.ts
Normal 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);
|
||||
303
server/admin/src/client/components/Dashboard.tsx
Normal file
303
server/admin/src/client/components/Dashboard.tsx
Normal 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';
|
||||
22
server/admin/src/client/components/MarkdownEditor/editor.tsx
Normal file
22
server/admin/src/client/components/MarkdownEditor/editor.tsx
Normal 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';
|
||||
@@ -0,0 +1,5 @@
|
||||
import loadable from '@loadable/component';
|
||||
|
||||
export const MarkdownEditor = loadable(() =>
|
||||
import('./editor').then((module) => module.MarkdownEditor)
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
import gfm from '@bytemd/plugin-gfm';
|
||||
|
||||
export const plugins = [
|
||||
gfm(),
|
||||
// Add more plugins here
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
.bytemd .bytemd-toolbar-right [bytemd-tippy-path='5'] {
|
||||
// Hidden github icon
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bytemd-fullscreen.bytemd {
|
||||
z-index: 99;
|
||||
}
|
||||
22
server/admin/src/client/components/TagItems.tsx
Normal file
22
server/admin/src/client/components/TagItems.tsx
Normal 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';
|
||||
15
server/admin/src/client/components/TailchatImage.tsx
Normal file
15
server/admin/src/client/components/TailchatImage.tsx
Normal 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';
|
||||
13
server/admin/src/client/components/field/filesize.tsx
Normal file
13
server/admin/src/client/components/field/filesize.tsx
Normal 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,
|
||||
});
|
||||
25
server/admin/src/client/components/field/imageUrl.tsx
Normal file
25
server/admin/src/client/components/field/imageUrl.tsx
Normal 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,
|
||||
});
|
||||
23
server/admin/src/client/components/field/user.tsx
Normal file
23
server/admin/src/client/components/field/user.tsx
Normal 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,
|
||||
});
|
||||
228
server/admin/src/client/fields.ts
Normal file
228
server/admin/src/client/fields.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
];
|
||||
7
server/admin/src/client/global.css
Normal file
7
server/admin/src/client/global.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.arco-table-th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.arco-table-td {
|
||||
/* white-space: nowrap; */
|
||||
overflow: hidden;
|
||||
}
|
||||
102
server/admin/src/client/i18n/en.ts
Normal file
102
server/admin/src/client/i18n/en.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
18
server/admin/src/client/i18n/index.ts
Normal file
18
server/admin/src/client/i18n/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
192
server/admin/src/client/i18n/zh.ts
Normal file
192
server/admin/src/client/i18n/zh.ts
Normal 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} 名用户',
|
||||
},
|
||||
},
|
||||
};
|
||||
8
server/admin/src/client/main.tsx
Normal file
8
server/admin/src/client/main.tsx
Normal 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 />
|
||||
);
|
||||
41
server/admin/src/client/request.ts
Normal file
41
server/admin/src/client/request.ts
Normal 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;
|
||||
}
|
||||
88
server/admin/src/client/resources/file.tsx
Normal file
88
server/admin/src/client/resources/file.tsx
Normal 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';
|
||||
100
server/admin/src/client/resources/group.tsx
Normal file
100
server/admin/src/client/resources/group.tsx
Normal 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';
|
||||
115
server/admin/src/client/resources/user.tsx
Normal file
115
server/admin/src/client/resources/user.tsx
Normal 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';
|
||||
203
server/admin/src/client/routes/analytics/index.tsx
Normal file
203
server/admin/src/client/routes/analytics/index.tsx
Normal 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';
|
||||
55
server/admin/src/client/routes/cache.tsx
Normal file
55
server/admin/src/client/routes/cache.tsx
Normal 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';
|
||||
94
server/admin/src/client/routes/network/index.tsx
Normal file
94
server/admin/src/client/routes/network/index.tsx
Normal 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';
|
||||
35
server/admin/src/client/routes/socketio.tsx
Normal file
35
server/admin/src/client/routes/socketio.tsx
Normal 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';
|
||||
245
server/admin/src/client/routes/system/index.tsx
Normal file
245
server/admin/src/client/routes/system/index.tsx
Normal 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';
|
||||
147
server/admin/src/client/routes/system/notify.tsx
Normal file
147
server/admin/src/client/routes/system/notify.tsx
Normal 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';
|
||||
13
server/admin/src/client/utils.ts
Normal file
13
server/admin/src/client/utils.ts
Normal 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
1
server/admin/src/client/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
28
server/admin/src/server/broker.ts
Normal file
28
server/admin/src/server/broker.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
63
server/admin/src/server/index.ts
Normal file
63
server/admin/src/server/index.ts
Normal 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/`
|
||||
);
|
||||
});
|
||||
39
server/admin/src/server/middleware/auth.ts
Normal file
39
server/admin/src/server/middleware/auth.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
fork from https://github.com/NathanAdhitya/express-mongoose-ra-json-server
|
||||
|
||||
modify:
|
||||
- count logic in get `/`
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Model, Document } from 'mongoose';
|
||||
|
||||
export interface ADPBaseSchema {
|
||||
_id: string;
|
||||
}
|
||||
|
||||
export type ADPBaseModel = Model<ADPBaseSchema & Document & any>;
|
||||
@@ -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 it’s 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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
218
server/admin/src/server/router/analytics.ts
Normal file
218
server/admin/src/server/router/analytics.ts
Normal 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 };
|
||||
373
server/admin/src/server/router/api.ts
Normal file
373
server/admin/src/server/router/api.ts
Normal 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 };
|
||||
32
server/admin/src/server/router/cache.ts
Normal file
32
server/admin/src/server/router/cache.ts
Normal 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 };
|
||||
38
server/admin/src/server/router/config.ts
Normal file
38
server/admin/src/server/router/config.ts
Normal 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 };
|
||||
82
server/admin/src/server/router/file.ts
Normal file
82
server/admin/src/server/router/file.ts
Normal 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 };
|
||||
37
server/admin/src/server/router/network.ts
Normal file
37
server/admin/src/server/router/network.ts
Normal 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 };
|
||||
23
server/admin/tsconfig.json
Normal file
23
server/admin/tsconfig.json
Normal 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"]
|
||||
}
|
||||
20
server/admin/tsconfig.server.json
Normal file
20
server/admin/tsconfig.server.json
Normal 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
|
||||
}
|
||||
}
|
||||
14
server/admin/vite.config.ts
Normal file
14
server/admin/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user