mirror of https://github.com/msgbyte/tailchat
perf: remove admin-old to reduce image size
parent
bf5c040515
commit
6f57f80f57
@ -1,20 +0,0 @@
|
||||
version: "3.3"
|
||||
|
||||
services:
|
||||
# 后台应用
|
||||
tailchat-admin-old:
|
||||
build:
|
||||
context: ../
|
||||
image: tailchat
|
||||
restart: unless-stopped
|
||||
env_file: docker-compose.env
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.admin-old.rule=PathPrefix(`/admin`)"
|
||||
- "traefik.http.services.admin-old.loadbalancer.server.port=3000"
|
||||
networks:
|
||||
- internal
|
||||
command: pnpm start:admin-old
|
File diff suppressed because it is too large
Load Diff
@ -1,7 +0,0 @@
|
||||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
/public/build
|
||||
/public/admin
|
||||
.env
|
@ -1,53 +0,0 @@
|
||||
# Welcome to Remix!
|
||||
|
||||
- [Remix Docs](https://remix.run/docs)
|
||||
|
||||
## Development
|
||||
|
||||
From your terminal:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts your app in development mode, rebuilding assets on file changes.
|
||||
|
||||
## Deployment
|
||||
|
||||
First, build your app for production:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then run the app in production mode:
|
||||
|
||||
```sh
|
||||
npm start
|
||||
```
|
||||
|
||||
Now you'll need to pick a host to deploy it to.
|
||||
|
||||
### DIY
|
||||
|
||||
If you're familiar with deploying node applications, the built-in Remix app server is production-ready.
|
||||
|
||||
Make sure to deploy the output of `remix build`
|
||||
|
||||
- `build/`
|
||||
- `public/build/`
|
||||
|
||||
### Using a Template
|
||||
|
||||
When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server.
|
||||
|
||||
```sh
|
||||
cd ..
|
||||
# create a new project, and pick a pre-configured host
|
||||
npx create-remix@latest
|
||||
cd my-new-remix-app
|
||||
# remove the new project's app (not the old one!)
|
||||
rm -rf app
|
||||
# copy your app over
|
||||
cp -R ../my-old-remix-app/app app
|
||||
```
|
@ -1,23 +0,0 @@
|
||||
import { RemixBrowser } from '@remix-run/react';
|
||||
import React from 'react';
|
||||
import { startTransition, StrictMode } from 'react';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
|
||||
function hydrate() {
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(hydrate);
|
||||
} else {
|
||||
// Safari doesn't support requestIdleCallback
|
||||
// https://caniuse.com/requestidlecallback
|
||||
setTimeout(hydrate, 1);
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
import { PassThrough } from 'stream';
|
||||
import type { EntryContext } from '@remix-run/node';
|
||||
import { Response } from '@remix-run/node';
|
||||
import { RemixServer } from '@remix-run/react';
|
||||
import isbot from 'isbot';
|
||||
import { renderToPipeableStream } from 'react-dom/server';
|
||||
|
||||
const ABORT_DELAY = 5000;
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) {
|
||||
return isbot(request.headers.get('user-agent'))
|
||||
? handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
)
|
||||
: handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
);
|
||||
}
|
||||
|
||||
function handleBotRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let didError = false;
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer context={remixContext} url={request.url} />,
|
||||
{
|
||||
onAllReady() {
|
||||
const body = new PassThrough();
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
|
||||
resolve(
|
||||
new Response(body, {
|
||||
headers: responseHeaders,
|
||||
status: didError ? 500 : responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
didError = true;
|
||||
|
||||
console.error(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrowserRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let didError = false;
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer context={remixContext} url={request.url} />,
|
||||
{
|
||||
onShellReady() {
|
||||
const body = new PassThrough();
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
|
||||
resolve(
|
||||
new Response(body, {
|
||||
headers: responseHeaders,
|
||||
status: didError ? 500 : responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(err: unknown) {
|
||||
reject(err);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
didError = true;
|
||||
|
||||
console.error(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
import { Admin, Resource, ShowGuesser, CustomRoutes } from 'react-admin';
|
||||
import jsonServerProvider from 'ra-data-json-server';
|
||||
import { authProvider } from './authProvider';
|
||||
import { UserEdit, UserList, UserShow } from './resources/user';
|
||||
import React from 'react';
|
||||
import { GroupList, GroupShow } from './resources/group';
|
||||
import { MessageList, MessageShow } from './resources/chat';
|
||||
import { FileList } from './resources/file';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import MessageIcon from '@mui/icons-material/Message';
|
||||
import GroupIcon from '@mui/icons-material/Group';
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import { theme } from './theme';
|
||||
import { Dashboard } from './dashboard';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { TailchatNetwork } from './routes/network';
|
||||
import { TailchatLayout } from './layout';
|
||||
import { i18nProvider } from './i18n/index';
|
||||
import { httpClient } from './request';
|
||||
import { SocketIOAdmin } from './routes/socketio';
|
||||
import { SystemConfig } from './routes/system';
|
||||
|
||||
const dataProvider = jsonServerProvider(
|
||||
// 'https://jsonplaceholder.typicode.com'
|
||||
'/admin/api',
|
||||
httpClient
|
||||
);
|
||||
|
||||
export const App = () => (
|
||||
<Admin
|
||||
basename="/admin"
|
||||
theme={theme}
|
||||
dashboard={Dashboard}
|
||||
layout={TailchatLayout}
|
||||
disableTelemetry={true}
|
||||
authProvider={authProvider}
|
||||
dataProvider={dataProvider}
|
||||
i18nProvider={i18nProvider}
|
||||
requireAuth={true}
|
||||
>
|
||||
<Resource
|
||||
icon={PersonIcon}
|
||||
name="users"
|
||||
list={UserList}
|
||||
show={UserShow}
|
||||
edit={UserEdit}
|
||||
/>
|
||||
<Resource
|
||||
icon={MessageIcon}
|
||||
name="messages"
|
||||
list={MessageList}
|
||||
show={MessageShow}
|
||||
/>
|
||||
<Resource
|
||||
icon={GroupIcon}
|
||||
name="groups"
|
||||
list={GroupList}
|
||||
show={GroupShow}
|
||||
/>
|
||||
<Resource
|
||||
icon={AttachFileIcon}
|
||||
name="file"
|
||||
list={FileList}
|
||||
show={ShowGuesser}
|
||||
/>
|
||||
|
||||
<CustomRoutes>
|
||||
{/* 添加完毕以后还需要到 layout/Menu 增加侧边栏 */}
|
||||
<Route path="/system" element={<SystemConfig />} />
|
||||
<Route path="/network" element={<TailchatNetwork />} />
|
||||
<Route path="/socketio" element={<SocketIOAdmin />} />
|
||||
</CustomRoutes>
|
||||
</Admin>
|
||||
);
|
@ -1,65 +0,0 @@
|
||||
import type { AuthProvider } from 'react-admin';
|
||||
|
||||
export const authStorageKey = 'tailchat:admin:auth';
|
||||
|
||||
export const authProvider: AuthProvider = {
|
||||
login: ({ username, password }) => {
|
||||
const request = new Request('/admin/api/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
});
|
||||
return fetch(request)
|
||||
.then((response) => {
|
||||
return response.json();
|
||||
})
|
||||
.then((auth) => {
|
||||
console.log(auth);
|
||||
localStorage.setItem(authStorageKey, JSON.stringify(auth));
|
||||
})
|
||||
.catch(() => {
|
||||
throw new Error('Login Failed');
|
||||
});
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem(authStorageKey);
|
||||
return Promise.resolve();
|
||||
},
|
||||
checkAuth: () => {
|
||||
const auth = localStorage.getItem(authStorageKey);
|
||||
if (auth) {
|
||||
try {
|
||||
const obj = JSON.parse(auth);
|
||||
if (obj.expiredAt && Date.now() < obj.expiredAt) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
return Promise.reject();
|
||||
},
|
||||
checkError: (error) => {
|
||||
const status = error.status;
|
||||
if (status === 401 || status === 403) {
|
||||
localStorage.removeItem(authStorageKey);
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
// other error code (404, 500, etc): no need to log out
|
||||
return Promise.resolve();
|
||||
},
|
||||
getIdentity: () => {
|
||||
const { username } = JSON.parse(
|
||||
localStorage.getItem(authStorageKey) ?? '{}'
|
||||
);
|
||||
if (!username) {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
id: username,
|
||||
fullName: username,
|
||||
});
|
||||
},
|
||||
getPermissions: () => Promise.resolve(''),
|
||||
};
|
@ -1,47 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, ButtonProps, Confirm, useTranslate } from 'react-admin';
|
||||
|
||||
interface Props extends Pick<ButtonProps, 'label'> {
|
||||
component?: React.ComponentType<ButtonProps>;
|
||||
confirmTitle?: string;
|
||||
confirmContent?: string;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
export const ButtonWithConfirm: React.FC<Props> = React.memo((props) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
const {
|
||||
component: ButtonComponent = Button,
|
||||
confirmTitle = translate('custom.common.confirmTitle'),
|
||||
confirmContent = translate('custom.common.confirmContent'),
|
||||
} = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonComponent
|
||||
onClick={(e) => {
|
||||
setOpen(true);
|
||||
}}
|
||||
label={props.label}
|
||||
/>
|
||||
<Confirm
|
||||
isOpen={open}
|
||||
loading={loading}
|
||||
title={confirmTitle}
|
||||
content={confirmContent}
|
||||
onConfirm={() => {
|
||||
setLoading(true);
|
||||
props.onConfirm?.();
|
||||
setLoading(false);
|
||||
setOpen(false);
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
ButtonWithConfirm.displayName = 'ButtonWithConfirm';
|
@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Chip, Grid } from '@mui/material';
|
||||
|
||||
export const ChipItems: React.FC<{
|
||||
items: string[];
|
||||
}> = React.memo((props) => {
|
||||
return (
|
||||
<Grid container spacing={1}>
|
||||
{props.items.map((item) => (
|
||||
<Grid key={item} item>
|
||||
<Chip label={item} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
ChipItems.displayName = 'ChipItems';
|
@ -1,17 +0,0 @@
|
||||
import { styled, alpha } from '@mui/material';
|
||||
import { Button } from 'react-admin';
|
||||
|
||||
export const DangerButton = styled(Button, {
|
||||
name: 'DangerBtn',
|
||||
overridesResolver: (props, styles) => styles.root,
|
||||
})(({ theme }) => ({
|
||||
color: theme.palette.error.main,
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.error.main, 0.12),
|
||||
// Reset on mouse devices
|
||||
'@media (hover: none)': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
}));
|
||||
DangerButton.displayName = 'DangerButton';
|
@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import filesize from 'filesize';
|
||||
import {
|
||||
NumberFieldProps,
|
||||
sanitizeFieldRestProps,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
} from 'react-admin';
|
||||
import get from 'lodash/get';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
export const FilesizeField: React.FC<NumberFieldProps> = React.memo((props) => {
|
||||
const { className, emptyText, source, locales, options, textAlign, ...rest } =
|
||||
props;
|
||||
const record = useRecordContext(props);
|
||||
const translate = useTranslate();
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const value = get(record, source!);
|
||||
|
||||
if (value == null) {
|
||||
return emptyText ? (
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
className={className}
|
||||
{...sanitizeFieldRestProps(rest)}
|
||||
>
|
||||
{emptyText && translate(emptyText, { _: emptyText })}
|
||||
</Typography>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
className={className}
|
||||
{...sanitizeFieldRestProps(rest)}
|
||||
>
|
||||
{filesize(value)}
|
||||
</Typography>
|
||||
);
|
||||
});
|
||||
FilesizeField.displayName = 'FilesizeField';
|
@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ReferenceField, ReferenceFieldProps, TextField } from 'react-admin';
|
||||
|
||||
export const GroupField: React.FC<Omit<ReferenceFieldProps, 'reference'>> =
|
||||
React.memo((props) => {
|
||||
return (
|
||||
<ReferenceField link="show" {...props} reference="groups">
|
||||
<TextField source="name" />
|
||||
</ReferenceField>
|
||||
);
|
||||
});
|
||||
GroupField.displayName = 'GroupField';
|
@ -1,9 +0,0 @@
|
||||
import React, { ImgHTMLAttributes } from 'react';
|
||||
import { parseUrlStr } from '../utils';
|
||||
|
||||
export const Image: React.FC<ImgHTMLAttributes<HTMLImageElement>> = React.memo(
|
||||
(props) => {
|
||||
return <img {...props} src={parseUrlStr(props.src)} />;
|
||||
}
|
||||
);
|
||||
Image.displayName = 'Image';
|
@ -1,6 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export const PostListActionToolbar = ({ children, ...props }) => (
|
||||
<Box sx={{ alignItems: 'center', display: 'flex' }}>{children}</Box>
|
||||
);
|
@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ReferenceField,
|
||||
ReferenceFieldProps,
|
||||
TextField,
|
||||
useRecordContext,
|
||||
} from 'react-admin';
|
||||
|
||||
const SYSTEM_USERID = '000000000000000000000000';
|
||||
|
||||
export const UserField: React.FC<Omit<ReferenceFieldProps, 'reference'>> =
|
||||
React.memo((props) => {
|
||||
const record = useRecordContext(props);
|
||||
if (props.source && record) {
|
||||
if (record[props.source] === SYSTEM_USERID) {
|
||||
return <div>System</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ReferenceField link="show" {...props} reference="users">
|
||||
<>
|
||||
<TextField source="nickname" />
|
||||
(<TextField source="email" />)
|
||||
</>
|
||||
</ReferenceField>
|
||||
);
|
||||
});
|
||||
UserField.displayName = 'UserField';
|
@ -1,69 +0,0 @@
|
||||
import { FC, createElement } from 'react';
|
||||
import { Card, Box, Typography, Divider } from '@mui/material';
|
||||
import { Link, To } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { LoadingIndicator } from 'react-admin';
|
||||
|
||||
import cartouche from './cartouche.png';
|
||||
import cartoucheDark from './cartoucheDark.png';
|
||||
|
||||
interface Props {
|
||||
icon: FC<any>;
|
||||
to: To;
|
||||
title?: string;
|
||||
subtitle?: string | number;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const CardWithIcon = (props: Props) => {
|
||||
const { icon, title, subtitle, to, children } = props;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
minHeight: 52,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: '1',
|
||||
'& a': {
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link to={to}>
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'inherit',
|
||||
padding: '16px',
|
||||
background: (theme) =>
|
||||
`url(${
|
||||
theme.palette.mode === 'dark' ? cartoucheDark : cartouche
|
||||
}) no-repeat`,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
'& .icon': {
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'inherit' : '#dc2440',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box width="3em" className="icon">
|
||||
{createElement(icon, { fontSize: 'large' })}
|
||||
</Box>
|
||||
<Box textAlign="right">
|
||||
<Typography color="textSecondary">{title}</Typography>
|
||||
<Typography variant="h5" component="h2">
|
||||
{subtitle ?? <LoadingIndicator />}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Link>
|
||||
{children && <Divider />}
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardWithIcon;
|
@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslate } from 'react-admin';
|
||||
import { Card, Box, Typography, CardActions, Button } from '@mui/material';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import logoSvg from './logo.svg';
|
||||
|
||||
export const Welcome: React.FC = React.memo(() => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
background: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? '#535353'
|
||||
: `linear-gradient(to right, #1a1d26 0%, #232c50 35%)`,
|
||||
|
||||
color: '#fff',
|
||||
padding: '20px',
|
||||
marginTop: 2,
|
||||
marginBottom: '1em',
|
||||
}}
|
||||
>
|
||||
<Box display="flex">
|
||||
<Box flex="1">
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
{translate('custom.dashboard.welcomeTitle')}
|
||||
</Typography>
|
||||
<Box maxWidth="40em">
|
||||
<Typography variant="body1" component="p" gutterBottom>
|
||||
{translate('custom.dashboard.welcomeDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CardActions
|
||||
sx={{
|
||||
padding: { xs: 0, xl: null },
|
||||
flexWrap: { xs: 'wrap', xl: null },
|
||||
'& a': {
|
||||
marginTop: { xs: '1em', xl: null },
|
||||
marginLeft: { xs: '0!important', xl: null },
|
||||
marginRight: { xs: '1em', xl: null },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
href="https://tailchat.msgbyte.com/"
|
||||
startIcon={<HomeIcon />}
|
||||
target="__blank"
|
||||
>
|
||||
{translate('custom.dashboard.welcomeHomepage')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
href="https://github.com/msgbyte/tailchat"
|
||||
startIcon={<CodeIcon />}
|
||||
target="__blank"
|
||||
>
|
||||
{translate('custom.dashboard.welcomeSourcecode')}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Box>
|
||||
<Box
|
||||
display={{ xs: 'none', sm: 'none', md: 'block' }}
|
||||
sx={{
|
||||
marginLeft: 'auto',
|
||||
backgroundImage: `url(${logoSvg})`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
width="9em"
|
||||
height="9em"
|
||||
overflow="hidden"
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
Welcome.displayName = 'Welcome';
|
Binary file not shown.
Before Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
Before Width: | Height: | Size: 1.1 KiB |
@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import CardWithIcon from './CardWithIcon';
|
||||
import { Welcome } from './Welcome';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import MessageIcon from '@mui/icons-material/Message';
|
||||
import GroupIcon from '@mui/icons-material/Group';
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import { useGetList, useTranslate } from 'react-admin';
|
||||
import { Grid } from '@mui/material';
|
||||
|
||||
export const Dashboard: React.FC = React.memo(() => {
|
||||
const { total: usersNum } = useGetList('users', {
|
||||
pagination: { page: 1, perPage: 1 },
|
||||
});
|
||||
const { total: tempUsersNum } = useGetList('users', {
|
||||
filter: { temporary: true },
|
||||
pagination: { page: 1, perPage: 1 },
|
||||
});
|
||||
const { total: messageNum } = useGetList('messages', {
|
||||
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 translate = useTranslate();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Welcome />
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={4}>
|
||||
<CardWithIcon
|
||||
to="/admin/users"
|
||||
icon={PersonIcon}
|
||||
title={translate('custom.dashboard.userCount')}
|
||||
subtitle={usersNum}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<CardWithIcon
|
||||
to="/admin/users"
|
||||
icon={PersonIcon}
|
||||
title={translate('custom.dashboard.tempUserCount')}
|
||||
subtitle={tempUsersNum}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<CardWithIcon
|
||||
to="/admin/messages"
|
||||
icon={MessageIcon}
|
||||
title={translate('custom.dashboard.messageCount')}
|
||||
subtitle={messageNum}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<CardWithIcon
|
||||
to="/admin/groups"
|
||||
icon={GroupIcon}
|
||||
title={translate('custom.dashboard.groupCount')}
|
||||
subtitle={groupNum}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<CardWithIcon
|
||||
to="/admin/file"
|
||||
icon={AttachFileIcon}
|
||||
title={translate('custom.dashboard.fileCount')}
|
||||
subtitle={fileNum}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Dashboard.displayName = 'Dashboard';
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 16 KiB |
@ -1,157 +0,0 @@
|
||||
export const englishCustom = {
|
||||
custom: {
|
||||
common: {
|
||||
summary: 'Summary',
|
||||
panel: 'Panel',
|
||||
name: 'Name',
|
||||
permission: 'Permission',
|
||||
confirmTitle: 'Are you sure you want to perform this operation?',
|
||||
confirmContent: 'This action cannot be undone',
|
||||
errorOccurred: 'some errors occurred',
|
||||
operateSuccess: 'Operate Success',
|
||||
operateFailed: 'Operate Failed',
|
||||
upload: 'Upload',
|
||||
delete: 'Delete',
|
||||
},
|
||||
menu: {
|
||||
network: 'Tailchat Network',
|
||||
socket: 'Socket.IO TCP',
|
||||
system: 'System Config',
|
||||
},
|
||||
dashboard: {
|
||||
welcomeTitle: 'Welcome to Tailchat Admin',
|
||||
welcomeDesc:
|
||||
'Tailchat is a completely open source instant messaging application',
|
||||
welcomeHomepage: 'Visit the official website',
|
||||
welcomeSourcecode: 'Browse the source code',
|
||||
userCount: 'User Count',
|
||||
tempUserCount: 'Temp User Count',
|
||||
messageCount: 'Message Count',
|
||||
groupCount: 'Group Count',
|
||||
fileCount: 'File Count',
|
||||
},
|
||||
users: {
|
||||
search: 'Search nickname or email',
|
||||
resetPassword: 'Reset Password',
|
||||
resetPasswordTip:
|
||||
'After resetting the password, the password becomes: 123456789, please change the password in time',
|
||||
},
|
||||
messages: {
|
||||
search: 'Search Message Content',
|
||||
searchConverseId: 'Search Converse ID',
|
||||
},
|
||||
groups: {
|
||||
noAvatar: 'No Avatar',
|
||||
'panels.name': 'Panel Name',
|
||||
'panels.type': 'Panel Type',
|
||||
'panels.provider': 'Panel Provider',
|
||||
'panels.pluginPanelName': 'Panel Name',
|
||||
'panels.meta': 'Panel Meta',
|
||||
'panels.parentId': 'Panel Parent',
|
||||
textPanel: 'Text Panel',
|
||||
groupPanel: 'Panel Group',
|
||||
pluginPanel: 'Plugin Panel',
|
||||
},
|
||||
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',
|
||||
serverName: 'Server Name',
|
||||
serverEntryImage: 'Server Entry Page Image',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const chineseCustom = {
|
||||
custom: {
|
||||
common: {
|
||||
summary: '概述',
|
||||
panel: '面板',
|
||||
name: '名称',
|
||||
permission: '权限',
|
||||
confirmTitle: '确认要进行该操作么?',
|
||||
confirmContent: '该操作不可撤回',
|
||||
errorOccurred: '发生了一些错误',
|
||||
operateSuccess: '操作成功',
|
||||
operateFailed: '操作失败',
|
||||
upload: '上传',
|
||||
delete: '删除',
|
||||
},
|
||||
menu: {
|
||||
network: 'Tailchat 网络',
|
||||
socket: 'Socket.IO 长链接',
|
||||
system: '系统设置',
|
||||
},
|
||||
dashboard: {
|
||||
welcomeTitle: '欢迎使用 Tailchat 后台管理程序',
|
||||
welcomeDesc: 'Tailchat 是一个完全开源的即时通讯应用',
|
||||
welcomeHomepage: '访问官网',
|
||||
welcomeSourcecode: '浏览源码',
|
||||
userCount: '用户数',
|
||||
tempUserCount: '临时用户数',
|
||||
messageCount: '总消息数',
|
||||
groupCount: '总群组数',
|
||||
fileCount: '总文件数',
|
||||
},
|
||||
users: {
|
||||
search: '搜索昵称或邮箱',
|
||||
resetPassword: '重置密码',
|
||||
resetPasswordTip: '重置密码后密码变为: 123456789, 请及时修改密码',
|
||||
},
|
||||
messages: {
|
||||
search: '搜索消息内容',
|
||||
searchConverseId: '搜索会话ID',
|
||||
},
|
||||
groups: {
|
||||
noAvatar: '无头像',
|
||||
'panels.name': '面板名',
|
||||
'panels.type': '面板类型',
|
||||
'panels.provider': '面板供应插件',
|
||||
'panels.pluginPanelName': '插件面板名',
|
||||
'panels.meta': '面板元信息',
|
||||
'panels.parentId': '面板父级',
|
||||
textPanel: '文本频道',
|
||||
groupPanel: '面板分组',
|
||||
pluginPanel: '插件面板',
|
||||
},
|
||||
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: '邮箱强制验证',
|
||||
serverName: '服务器名',
|
||||
serverEntryImage: '服务器登录图',
|
||||
},
|
||||
},
|
||||
};
|
@ -1,37 +0,0 @@
|
||||
import type { TranslationMessages } from 'react-admin';
|
||||
import _merge from 'lodash/merge';
|
||||
import defaultEnglishMessages from 'ra-language-english';
|
||||
import polyglotI18nProvider from 'ra-i18n-polyglot';
|
||||
import { chineseResources, englishResources } from './resources';
|
||||
import { chineseCustom, englishCustom } from './custom';
|
||||
import { defaultChineseMessages } from './builtin';
|
||||
|
||||
const chineseMessages: TranslationMessages = _merge(
|
||||
{},
|
||||
defaultEnglishMessages,
|
||||
defaultChineseMessages,
|
||||
chineseResources,
|
||||
chineseCustom
|
||||
);
|
||||
|
||||
const englishMessages = _merge(
|
||||
{},
|
||||
defaultEnglishMessages,
|
||||
englishResources,
|
||||
englishCustom
|
||||
);
|
||||
|
||||
export const i18nProvider = polyglotI18nProvider(
|
||||
(locale: string) => {
|
||||
if (locale === 'ch') {
|
||||
return chineseMessages;
|
||||
} else {
|
||||
return englishMessages;
|
||||
}
|
||||
},
|
||||
'en',
|
||||
[
|
||||
{ locale: 'en', name: 'English' },
|
||||
{ locale: 'ch', name: '简体中文' },
|
||||
]
|
||||
);
|
@ -1,123 +0,0 @@
|
||||
export const englishResources = {
|
||||
resources: {
|
||||
users: {
|
||||
name: 'User',
|
||||
fields: {
|
||||
id: 'ID',
|
||||
email: 'Email',
|
||||
avatar: 'Avatar',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
nickname: 'Nick Name',
|
||||
discriminator: 'Discriminator',
|
||||
temporary: 'is Template User',
|
||||
type: 'User Type',
|
||||
settings: 'User Settings',
|
||||
createdAt: 'Create Time',
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
name: 'Messages',
|
||||
fields: {
|
||||
content: 'Content',
|
||||
author: 'Author',
|
||||
groupId: 'Group ID',
|
||||
converseId: 'Converse ID',
|
||||
hasRecall: 'Recall',
|
||||
reactions: 'Reactions',
|
||||
createdAt: 'Create Time',
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
name: 'Group',
|
||||
fields: {
|
||||
id: 'Group ID',
|
||||
name: 'Group Name',
|
||||
avatar: 'Avatar',
|
||||
owner: 'Owner',
|
||||
members: 'Member List',
|
||||
'members.length': 'Member count',
|
||||
'panels.length': 'Panel count',
|
||||
roles: 'Roles',
|
||||
config: 'Config',
|
||||
panels: 'Group Panels',
|
||||
fallbackPermissions: 'Default Permission',
|
||||
createdAt: 'Create Time',
|
||||
updatedAt: 'Update Time',
|
||||
},
|
||||
},
|
||||
file: {
|
||||
name: 'File',
|
||||
fields: {
|
||||
objectName: 'Object Name',
|
||||
url: 'Path',
|
||||
size: 'Size',
|
||||
'metaData.content-type': 'Type',
|
||||
userId: 'Storage User',
|
||||
createdAt: 'Create Time',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const chineseResources = {
|
||||
resources: {
|
||||
users: {
|
||||
name: '用户管理',
|
||||
fields: {
|
||||
id: '用户ID',
|
||||
email: '邮箱',
|
||||
avatar: '头像',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
nickname: '昵称',
|
||||
discriminator: '标识符',
|
||||
temporary: '是否游客',
|
||||
type: '用户类型',
|
||||
settings: '用户设置',
|
||||
createdAt: '创建时间',
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
name: '消息管理',
|
||||
fields: {
|
||||
content: '内容',
|
||||
author: '作者',
|
||||
groupId: '群组ID',
|
||||
converseId: '会话ID',
|
||||
hasRecall: '撤回',
|
||||
reactions: '消息反应',
|
||||
createdAt: '创建时间',
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
name: '群组管理',
|
||||
fields: {
|
||||
id: '群组ID',
|
||||
name: '群组名称',
|
||||
avatar: '头像',
|
||||
owner: '管理员',
|
||||
members: '成员列表',
|
||||
'members.length': '成员数量',
|
||||
'panels.length': '面板数量',
|
||||
roles: '角色',
|
||||
config: '配置信息',
|
||||
panels: '群组面板',
|
||||
fallbackPermissions: '默认权限',
|
||||
createdAt: '创建时间',
|
||||
updatedAt: '更新时间',
|
||||
},
|
||||
},
|
||||
file: {
|
||||
name: '文件管理',
|
||||
fields: {
|
||||
objectName: '对象存储名',
|
||||
url: '文件路径',
|
||||
size: '文件大小',
|
||||
'metaData.content-type': '文件类型',
|
||||
userId: '存储用户',
|
||||
createdAt: '创建时间',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Menu,
|
||||
MenuProps,
|
||||
ResourceMenuItem,
|
||||
useResourceDefinitions,
|
||||
useTranslate,
|
||||
} from 'react-admin';
|
||||
import FilterDramaIcon from '@mui/icons-material/FilterDrama';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
|
||||
export const TailchatMenu: React.FC<MenuProps> = React.memo((props) => {
|
||||
const resources = useResourceDefinitions();
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<Menu {...props}>
|
||||
<Menu.DashboardItem />
|
||||
|
||||
{...Object.keys(resources)
|
||||
.filter((name) => resources[name].hasList)
|
||||
.map((name) => <ResourceMenuItem key={name} name={name} />)}
|
||||
|
||||
<Menu.Item
|
||||
to="/admin/system"
|
||||
primaryText={translate('custom.menu.system')}
|
||||
leftIcon={<SettingsIcon />}
|
||||
/>
|
||||
|
||||
<Menu.Item
|
||||
to="/admin/network"
|
||||
primaryText={translate('custom.menu.network')}
|
||||
leftIcon={<FilterDramaIcon />}
|
||||
/>
|
||||
|
||||
<Menu.Item
|
||||
to="/admin/socketio"
|
||||
primaryText={translate('custom.menu.socket')}
|
||||
leftIcon={<LinkIcon />}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
TailchatMenu.displayName = 'TailchatMenu';
|
@ -1,8 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { LayoutComponent } from 'react-admin';
|
||||
import { Layout } from 'react-admin';
|
||||
import { TailchatMenu } from './Menu';
|
||||
|
||||
export const TailchatLayout: LayoutComponent = (props) => (
|
||||
<Layout {...props} menu={TailchatMenu} />
|
||||
);
|
@ -1,46 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { authStorageKey } from './authProvider';
|
||||
import _set from 'lodash/set';
|
||||
import { fetchUtils } from 'react-admin';
|
||||
|
||||
/**
|
||||
* 创建请求实例
|
||||
*/
|
||||
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 const httpClient: typeof fetchUtils.fetchJson = (url, options = {}) => {
|
||||
try {
|
||||
if (!options.headers) {
|
||||
options.headers = new Headers({ Accept: 'application/json' });
|
||||
}
|
||||
const { token } = JSON.parse(
|
||||
window.localStorage.getItem(authStorageKey) ?? '{}'
|
||||
);
|
||||
(options.headers as Headers).set('Authorization', `Bearer ${token}`);
|
||||
|
||||
return fetchUtils.fetchJson(url, options);
|
||||
} catch (err) {
|
||||
return Promise.reject();
|
||||
}
|
||||
};
|
@ -1,77 +0,0 @@
|
||||
import {
|
||||
BooleanField,
|
||||
Datagrid,
|
||||
DateField,
|
||||
List,
|
||||
TextField,
|
||||
SearchInput,
|
||||
useTranslate,
|
||||
BulkDeleteButton,
|
||||
ShowButton,
|
||||
ReferenceInput,
|
||||
SelectInput,
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
ReferenceField,
|
||||
} from 'react-admin';
|
||||
import { GroupField } from '../components/GroupField';
|
||||
import { PostListActionToolbar } from '../components/PostListActionToolbar';
|
||||
import { UserField } from '../components/UserField';
|
||||
|
||||
export const MessageList: React.FC = () => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<List
|
||||
filters={[
|
||||
<SearchInput
|
||||
key="search"
|
||||
source="q"
|
||||
alwaysOn
|
||||
placeholder={translate('custom.messages.search')}
|
||||
/>,
|
||||
<ReferenceInput key="groupId" source="groupId" reference="groups">
|
||||
<SelectInput optionText="name" />
|
||||
</ReferenceInput>,
|
||||
<SearchInput
|
||||
key="search"
|
||||
source="converseId"
|
||||
placeholder={translate('custom.messages.searchConverseId')}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<Datagrid
|
||||
bulkActionButtons={<BulkDeleteButton mutationMode="optimistic" />}
|
||||
>
|
||||
<TextField source="id" sortable={true} sortByOrder="DESC" />
|
||||
<TextField source="content" />
|
||||
<UserField source="author" />
|
||||
<GroupField source="groupId" />
|
||||
<TextField source="converseId" />
|
||||
<BooleanField source="hasRecall" />
|
||||
<TextField source="reactions" />
|
||||
<DateField source="createdAt" />
|
||||
<PostListActionToolbar>
|
||||
<ShowButton />
|
||||
</PostListActionToolbar>
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
MessageList.displayName = 'MessageList';
|
||||
|
||||
export const MessageShow: React.FC = () => (
|
||||
<Show>
|
||||
<SimpleShowLayout>
|
||||
<TextField source="id" />
|
||||
<ReferenceField source="groupId" reference="groups" />
|
||||
<TextField source="converseId" />
|
||||
<TextField source="author" />
|
||||
<TextField source="content" />
|
||||
<TextField source="reactions" />
|
||||
<DateField source="createdAt" />
|
||||
<DateField source="updatedAt" />
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
);
|
||||
MessageShow.displayName = 'MessageShow';
|
@ -1,17 +0,0 @@
|
||||
import { Datagrid, DateField, List, TextField, UrlField } from 'react-admin';
|
||||
import { FilesizeField } from '../components/FilesizeField';
|
||||
import { UserField } from '../components/UserField';
|
||||
|
||||
export const FileList: React.FC = () => (
|
||||
<List>
|
||||
<Datagrid bulkActionButtons={false}>
|
||||
<TextField source="objectName" />
|
||||
<UrlField source="url" target="__blank" />
|
||||
<FilesizeField source="size" noWrap={true} />
|
||||
<TextField source="metaData.content-type" />
|
||||
<TextField source="etag" />
|
||||
<UserField source="userId" />
|
||||
<DateField source="createdAt" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
@ -1,139 +0,0 @@
|
||||
import {
|
||||
Datagrid,
|
||||
DateField,
|
||||
List,
|
||||
TextField,
|
||||
ShowButton,
|
||||
SearchInput,
|
||||
ArrayField,
|
||||
SingleFieldList,
|
||||
ChipField,
|
||||
Show,
|
||||
SelectField,
|
||||
TabbedShowLayout,
|
||||
ImageField,
|
||||
useTranslate,
|
||||
} from 'react-admin';
|
||||
import { Box } from '@mui/material';
|
||||
import { UserField } from '../components/UserField';
|
||||
|
||||
const PostListActionToolbar = ({ children, ...props }) => (
|
||||
<Box sx={{ alignItems: 'center', display: 'flex' }}>{children}</Box>
|
||||
);
|
||||
|
||||
export const GroupList: React.FC = () => (
|
||||
<List filters={[<SearchInput key="search" source="q" alwaysOn />]}>
|
||||
<Datagrid>
|
||||
<TextField source="id" sortable={true} sortByOrder="DESC" />
|
||||
<TextField source="name" />
|
||||
<TextField source="owner" />
|
||||
<TextField source="members.length" />
|
||||
<TextField source="panels.length" />
|
||||
<ArrayField source="roles">
|
||||
<SingleFieldList>
|
||||
<ChipField source="name" />
|
||||
</SingleFieldList>
|
||||
</ArrayField>
|
||||
<TextField source="fallbackPermissions" />
|
||||
<DateField source="createdAt" />
|
||||
<PostListActionToolbar>
|
||||
<ShowButton />
|
||||
</PostListActionToolbar>
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
GroupList.displayName = 'GroupList';
|
||||
|
||||
export const GroupShow: React.FC = () => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<Show>
|
||||
<TabbedShowLayout>
|
||||
<TabbedShowLayout.Tab label={translate('custom.common.summary')}>
|
||||
<TextField source="id" />
|
||||
<ImageField
|
||||
source="avatar"
|
||||
emptyText={`(${translate('custom.groups.noAvatar')})`}
|
||||
/>
|
||||
<TextField source="name" />
|
||||
<UserField source="owner" />
|
||||
|
||||
<DateField source="createdAt" />
|
||||
<DateField source="updatedAt" />
|
||||
<TextField source="fallbackPermissions" />
|
||||
<TextField source="config" />
|
||||
</TabbedShowLayout.Tab>
|
||||
|
||||
{/* 面板 */}
|
||||
<TabbedShowLayout.Tab label={translate('custom.common.panel')}>
|
||||
<ArrayField source="panels">
|
||||
<Datagrid>
|
||||
<TextField source="id" />
|
||||
<TextField
|
||||
source="name"
|
||||
label={translate('custom.groups.panels.name')}
|
||||
/>
|
||||
<SelectField
|
||||
source="type"
|
||||
choices={[
|
||||
{ id: 0, name: translate('custom.groups.textPanel') },
|
||||
{ id: 1, name: translate('custom.groups.groupPanel') },
|
||||
{ id: 2, name: translate('custom.groups.pluginPanel') },
|
||||
]}
|
||||
label={translate('custom.groups.panels.type')}
|
||||
/>
|
||||
<TextField
|
||||
source="provider"
|
||||
label={translate('custom.groups.panels.provider')}
|
||||
/>
|
||||
<TextField
|
||||
source="pluginPanelName"
|
||||
label={translate('custom.groups.panels.name')}
|
||||
/>
|
||||
<TextField
|
||||
source="meta"
|
||||
label={translate('custom.groups.panels.meta')}
|
||||
/>
|
||||
<TextField
|
||||
source="parentId"
|
||||
label={translate('custom.groups.panels.parentId')}
|
||||
/>
|
||||
</Datagrid>
|
||||
</ArrayField>
|
||||
</TabbedShowLayout.Tab>
|
||||
|
||||
{/* 身份组 */}
|
||||
<TabbedShowLayout.Tab
|
||||
label={translate('resources.groups.fields.roles')}
|
||||
>
|
||||
<ArrayField source="roles">
|
||||
<Datagrid>
|
||||
<TextField
|
||||
source="name"
|
||||
label={translate('custom.common.name')}
|
||||
/>
|
||||
<TextField
|
||||
source="permission"
|
||||
label={translate('custom.common.permission')}
|
||||
/>
|
||||
</Datagrid>
|
||||
</ArrayField>
|
||||
</TabbedShowLayout.Tab>
|
||||
|
||||
{/* 成员列表 */}
|
||||
<TabbedShowLayout.Tab
|
||||
label={translate('resources.groups.fields.members')}
|
||||
>
|
||||
<ArrayField source="members">
|
||||
<Datagrid>
|
||||
<UserField source="userId" />
|
||||
<TextField source="roles" />
|
||||
</Datagrid>
|
||||
</ArrayField>
|
||||
</TabbedShowLayout.Tab>
|
||||
</TabbedShowLayout>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
GroupShow.displayName = 'GroupShow';
|
@ -1,132 +0,0 @@
|
||||
import {
|
||||
BooleanField,
|
||||
Datagrid,
|
||||
DateField,
|
||||
EmailField,
|
||||
List,
|
||||
TextField,
|
||||
ShowButton,
|
||||
SearchInput,
|
||||
ImageField,
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
TopToolbar,
|
||||
useUpdate,
|
||||
useShowContext,
|
||||
useTranslate,
|
||||
EditButton,
|
||||
Edit,
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
Labeled,
|
||||
} from 'react-admin';
|
||||
import { DangerButton } from '../components/DangerButton';
|
||||
import { ButtonWithConfirm } from '../components/ButtonWithConfirm';
|
||||
import { PostListActionToolbar } from '../components/PostListActionToolbar';
|
||||
|
||||
export const UserList: React.FC = () => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<List
|
||||
filters={[
|
||||
<SearchInput
|
||||
key="search"
|
||||
source="q"
|
||||
alwaysOn
|
||||
placeholder={translate('custom.users.search')}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<Datagrid bulkActionButtons={false}>
|
||||
<TextField source="id" sortByOrder="DESC" />
|
||||
<EmailField source="email" />
|
||||
<TextField source="nickname" />
|
||||
<TextField source="discriminator" />
|
||||
<BooleanField source="temporary" />
|
||||
<ImageField
|
||||
sx={{ '.RaImageField-image': { height: 40, width: 40 } }}
|
||||
source="avatar"
|
||||
/>
|
||||
<TextField source="type" />
|
||||
<TextField source="settings" />
|
||||
<DateField source="createdAt" />
|
||||
<PostListActionToolbar>
|
||||
<EditButton />
|
||||
<ShowButton />
|
||||
</PostListActionToolbar>
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
UserList.displayName = 'UserList';
|
||||
|
||||
const UserShowActions: React.FC = () => {
|
||||
const [update] = useUpdate();
|
||||
const { record, refetch, resource } = useShowContext();
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<TopToolbar>
|
||||
<EditButton />
|
||||
|
||||
<ButtonWithConfirm
|
||||
component={DangerButton}
|
||||
label={translate('custom.users.resetPassword')}
|
||||
confirmContent={translate('custom.users.resetPasswordTip')}
|
||||
onConfirm={async () => {
|
||||
await update(resource, {
|
||||
id: record.id,
|
||||
data: {
|
||||
password:
|
||||
'$2a$10$eSebpg0CEvsbDC7j1NxB2epMUkYwKhfT8vGdPQYkfeXYMqM8HjnpW', // 123456789
|
||||
},
|
||||
});
|
||||
await refetch();
|
||||
}}
|
||||
/>
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
UserShowActions.displayName = 'UserShowActions';
|
||||
|
||||
export const UserShow: React.FC = () => (
|
||||
<Show actions={<UserShowActions />}>
|
||||
<SimpleShowLayout>
|
||||
<TextField source="id" />
|
||||
<EmailField source="email" />
|
||||
<TextField source="password" />
|
||||
<TextField source="nickname" />
|
||||
<TextField source="discriminator" />
|
||||
<BooleanField source="temporary" />
|
||||
<TextField source="avatar" />
|
||||
<TextField source="type" />
|
||||
<BooleanField source="settings" />
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
);
|
||||
UserShow.displayName = 'UserShow';
|
||||
|
||||
export const UserEdit: React.FC = () => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<Edit mutationMode="optimistic">
|
||||
<SimpleForm>
|
||||
<Labeled label={translate('resources.users.fields.id')}>
|
||||
<TextField source="id" fullWidth={true} />
|
||||
</Labeled>
|
||||
<TextInput source="email" />
|
||||
<TextInput source="nickname" />
|
||||
<Labeled label={translate('resources.users.fields.temporary')}>
|
||||
<BooleanField source="temporary" />
|
||||
</Labeled>
|
||||
<TextInput source="avatar" />
|
||||
<Labeled label={translate('resources.users.fields.type')}>
|
||||
<TextField source="type" />
|
||||
</Labeled>
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
};
|
||||
UserEdit.displayName = 'UserEdit';
|
@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
import { request } from '../../request';
|
||||
import { useRequest } from 'ahooks';
|
||||
import {
|
||||
CircularProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import _uniq from 'lodash/uniq';
|
||||
import { ChipItems } from '../../components/ChipItems';
|
||||
import { useTranslate } from 'react-admin';
|
||||
|
||||
/**
|
||||
* Tailchat 网络状态
|
||||
*/
|
||||
export const TailchatNetwork: React.FC = React.memo(() => {
|
||||
const translate = useTranslate();
|
||||
const { data, loading } = useRequest(async () => {
|
||||
const { data } = await request('/network/all');
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <CircularProgress />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
maxWidth: '100vw',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{translate('custom.network.nodeList')}
|
||||
</Typography>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{translate('custom.network.id')}</TableCell>
|
||||
<TableCell>{translate('custom.network.hostname')}</TableCell>
|
||||
<TableCell>{translate('custom.network.cpuUsage')}</TableCell>
|
||||
<TableCell>{translate('custom.network.ipList')}</TableCell>
|
||||
<TableCell>{translate('custom.network.sdkVersion')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(data.nodes ?? []).map((row) => (
|
||||
<TableRow
|
||||
key={row.name}
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<TableCell component="th" scope="row">
|
||||
{row.id}
|
||||
{row.local && <span> (*)</span>}
|
||||
</TableCell>
|
||||
<TableCell>{row.hostname}</TableCell>
|
||||
<TableCell>{row.cpu}%</TableCell>
|
||||
<TableCell>
|
||||
<ChipItems items={row.ipList ?? []} />
|
||||
</TableCell>
|
||||
<TableCell>{row.client.version}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{translate('custom.network.serviceList')}
|
||||
</Typography>
|
||||
<Box flexWrap="wrap" overflow="hidden">
|
||||
<ChipItems items={_uniq<string>(data.services ?? [])} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{translate('custom.network.actionList')}
|
||||
</Typography>
|
||||
<Box flexWrap="wrap" overflow="hidden">
|
||||
<ChipItems items={_uniq<string>(data.actions ?? [])} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{translate('custom.network.eventList')}
|
||||
</Typography>
|
||||
<Box flexWrap="wrap" overflow="hidden">
|
||||
<ChipItems items={_uniq<string>(data.events ?? [])} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
TailchatNetwork.displayName = 'TailchatNetwork';
|
@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslate } from 'react-admin';
|
||||
import { Typography, CardActions, Button, Box } from '@mui/material';
|
||||
import { Card, CardContent } from '@mui/material';
|
||||
|
||||
/**
|
||||
* SocketIO 管理
|
||||
*/
|
||||
export const SocketIOAdmin: React.FC = React.memo(() => {
|
||||
const translate = useTranslate();
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
|
||||
return (
|
||||
<Box p={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography component="div">
|
||||
{translate('custom.socketio.tip1')}{' '}
|
||||
<strong>
|
||||
{protocol}://{window.location.host}
|
||||
</strong>
|
||||
</Typography>
|
||||
<Typography component="div">
|
||||
{translate('custom.socketio.tip2')}
|
||||
</Typography>
|
||||
<Typography component="div">
|
||||
{translate('custom.socketio.tip3')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
window.open('https://admin.socket.io/');
|
||||
}}
|
||||
>
|
||||
{translate('custom.socketio.btn')}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
SocketIOAdmin.displayName = 'SocketIOAdmin';
|
@ -1,205 +0,0 @@
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { request } from '../../request';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { CircularProgress, Box, Grid, Input, Button } from '@mui/material';
|
||||
import { useTranslate, useNotify } from 'react-admin';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { useEditValue } from '../../utils/hooks';
|
||||
import { Image } from '../../components/Image';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
|
||||
const SystemItem: React.FC<
|
||||
PropsWithChildren<{
|
||||
label: string;
|
||||
}>
|
||||
> = React.memo((props) => {
|
||||
return (
|
||||
<Grid container spacing={2} marginBottom={2}>
|
||||
<Grid item xs={4}>
|
||||
{props.label}:
|
||||
</Grid>
|
||||
<Grid item xs={8}>
|
||||
{props.children}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
SystemItem.displayName = 'SystemItem';
|
||||
|
||||
/**
|
||||
* Tailchat 系统设置
|
||||
*/
|
||||
export const SystemConfig: React.FC = React.memo(() => {
|
||||
const translate = useTranslate();
|
||||
const notify = useNotify();
|
||||
const {
|
||||
data: config,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
} = useRequest(async () => {
|
||||
const { data } = await request.get('/config/client');
|
||||
|
||||
return data.config ?? {};
|
||||
});
|
||||
|
||||
const [serverName, setServerName, saveServerName] = useEditValue(
|
||||
config?.serverName,
|
||||
async (val) => {
|
||||
if (val === config?.serverName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await request.patch('/config/client', {
|
||||
key: 'serverName',
|
||||
value: val,
|
||||
});
|
||||
refresh();
|
||||
notify('custom.common.operateSuccess', {
|
||||
type: 'info',
|
||||
});
|
||||
} catch (err) {
|
||||
notify('custom.common.operateFailed', {
|
||||
type: 'info',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
loading: loadingServerEntryImage,
|
||||
run: handleChangeServerEntryImage,
|
||||
} = useRequest(
|
||||
async (file: File | null) => {
|
||||
try {
|
||||
if (file) {
|
||||
const formdata = new FormData();
|
||||
formdata.append('file', file);
|
||||
|
||||
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,
|
||||
});
|
||||
refresh();
|
||||
} else {
|
||||
// delete
|
||||
await request.patch('/config/client', {
|
||||
key: 'serverEntryImage',
|
||||
value: '',
|
||||
});
|
||||
refresh();
|
||||
}
|
||||
|
||||
notify('custom.common.operateSuccess', {
|
||||
type: 'info',
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
notify('custom.common.operateFailed', {
|
||||
type: 'info',
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <CircularProgress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>{translate('custom.common.errorOccurred')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
maxWidth: '100vw',
|
||||
}}
|
||||
>
|
||||
<SystemItem label={translate('custom.config.uploadFileLimit')}>
|
||||
{config.uploadFileLimit}
|
||||
</SystemItem>
|
||||
|
||||
<SystemItem label={translate('custom.config.emailVerification')}>
|
||||
{config.emailVerification ? (
|
||||
<DoneIcon fontSize="small" />
|
||||
) : (
|
||||
<ClearIcon fontSize="small" />
|
||||
)}
|
||||
</SystemItem>
|
||||
|
||||
<SystemItem label={translate('custom.config.serverName')}>
|
||||
<Input
|
||||
value={serverName}
|
||||
onChange={(e) => setServerName(e.target.value)}
|
||||
onBlur={() => saveServerName()}
|
||||
placeholder="Tailchat"
|
||||
/>
|
||||
</SystemItem>
|
||||
|
||||
<SystemItem label={translate('custom.config.serverEntryImage')}>
|
||||
<div>
|
||||
<LoadingButton
|
||||
loading={loadingServerEntryImage}
|
||||
variant="contained"
|
||||
component="label"
|
||||
>
|
||||
{translate('custom.common.upload')}
|
||||
<input
|
||||
hidden
|
||||
accept="image/*"
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
handleChangeServerEntryImage(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</LoadingButton>
|
||||
|
||||
{config?.serverEntryImage && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div>
|
||||
<Image
|
||||
style={{ maxWidth: '100%', maxHeight: 360 }}
|
||||
src={config?.serverEntryImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => handleChangeServerEntryImage(null)}
|
||||
>
|
||||
{translate('custom.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SystemItem>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
SystemConfig.displayName = 'SystemConfig';
|
@ -1,22 +0,0 @@
|
||||
import { defaultTheme } from 'react-admin';
|
||||
import type { ThemeOptions } from '@mui/material';
|
||||
|
||||
const customRaComponents = {
|
||||
RaDatagrid: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .RaDatagrid-headerCell': {
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const theme: ThemeOptions = {
|
||||
...defaultTheme,
|
||||
components: {
|
||||
...defaultTheme.components,
|
||||
...customRaComponents,
|
||||
},
|
||||
};
|
@ -1,15 +0,0 @@
|
||||
import { useCallback, useLayoutEffect, useState } from 'react';
|
||||
|
||||
export function useEditValue<T>(value: T, onChange: (val: T) => void) {
|
||||
const [inner, setInner] = useState(value);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setInner(value);
|
||||
}, [value]);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
onChange(inner);
|
||||
}, [inner, onChange]);
|
||||
|
||||
return [inner, setInner, onSave] as const;
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
/**
|
||||
* 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,32 +0,0 @@
|
||||
import type { MetaFunction } from '@remix-run/node';
|
||||
import {
|
||||
Links,
|
||||
LiveReload,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from '@remix-run/react';
|
||||
|
||||
export const meta: MetaFunction = () => ({
|
||||
charset: 'utf-8',
|
||||
title: 'Tailchat Admin',
|
||||
viewport: 'width=device-width,initial-scale=1',
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Outlet />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<LiveReload />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import { App } from '../../ra/App';
|
||||
import styles from '../../styles/app.css';
|
||||
|
||||
export function links() {
|
||||
return [{ rel: 'stylesheet', href: styles }];
|
||||
}
|
||||
|
||||
export default App;
|
@ -1,8 +0,0 @@
|
||||
import { App } from '../../ra/App';
|
||||
import styles from '../../styles/app.css';
|
||||
|
||||
export function links() {
|
||||
return [{ rel: 'stylesheet', href: styles }];
|
||||
}
|
||||
|
||||
export default App;
|
@ -1,6 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Index() {
|
||||
// eslint-disable-next-line react/no-unescaped-entities
|
||||
return <div>Please visit '/admin/'</div>;
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
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('Linked 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,
|
||||
},
|
||||
});
|
||||
}
|
@ -1,88 +0,0 @@
|
||||
import path from 'path';
|
||||
import express from 'express';
|
||||
import compression from 'compression';
|
||||
import morgan from 'morgan';
|
||||
import { createRequestHandler } from '@remix-run/express';
|
||||
import mongoose from 'mongoose';
|
||||
import bodyParser from 'body-parser';
|
||||
import { apiRouter } from './router/api';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
const BUILD_DIR = path.join(process.cwd(), 'build');
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(compression());
|
||||
app.use(bodyParser());
|
||||
|
||||
// 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.all(
|
||||
'/admin/*',
|
||||
process.env.NODE_ENV === 'development'
|
||||
? (req, res, next) => {
|
||||
purgeRequireCache();
|
||||
|
||||
return createRequestHandler({
|
||||
build: require(BUILD_DIR),
|
||||
mode: process.env.NODE_ENV,
|
||||
})(req, res, next);
|
||||
}
|
||||
: createRequestHandler({
|
||||
build: require(BUILD_DIR),
|
||||
mode: process.env.NODE_ENV,
|
||||
})
|
||||
);
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(500);
|
||||
res.json({ error: err.message });
|
||||
});
|
||||
|
||||
const port = process.env.ADMIN_PORT || 3000;
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(
|
||||
`Express server listening on port ${port}, visit with: http://localhost:${port}/admin/`
|
||||
);
|
||||
});
|
||||
|
||||
function purgeRequireCache() {
|
||||
// purge require cache on requests for "server side HMR" this won't let
|
||||
// you have in-memory objects between requests in development,
|
||||
// alternatively you can set up nodemon/pm2-dev to restart the server on
|
||||
// file changes, but then you'll have to reconnect to databases/etc on each
|
||||
// change. We prefer the DX of this, so we've included it for you by default
|
||||
for (const key in require.cache) {
|
||||
if (key.startsWith(BUILD_DIR)) {
|
||||
delete require.cache[key];
|
||||
}
|
||||
}
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
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));
|
||||
}
|
||||
};
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Network 相关接口
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { callBrokerAction } from '../broker';
|
||||
import { auth } from '../middleware/auth';
|
||||
import Busboy from '@fastify/busboy';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.put('/upload', auth(), async (req, res) => {
|
||||
const busboy = new Busboy({ headers: req.headers as any });
|
||||
|
||||
const promises = [];
|
||||
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);
|
||||
});
|
||||
|
||||
export { router as fileRouter };
|
@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 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) => item.name),
|
||||
services: broker.registry.services.services.map((item) => 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 };
|
@ -1,3 +0,0 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
{
|
||||
"verbose": true,
|
||||
"watch": ["./server.ts", "./app/server/*"],
|
||||
"ext": "ts",
|
||||
"delay": 1000,
|
||||
"exec": "ts-node ./server.ts"
|
||||
}
|
@ -1,60 +0,0 @@
|
||||
{
|
||||
"name": "tailchat-admin-old",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"dev": "remix build && run-p \"dev:*\"",
|
||||
"dev:node": "cross-env NODE_ENV=development nodemon",
|
||||
"dev:remix": "remix watch",
|
||||
"start": "cd dist/admin && cross-env NODE_ENV=production node ./server.js",
|
||||
"build": "rm -rf ./dist && remix build && tsc --noEmit false && mv ./build ./dist/admin/ && cp -r ./public ./dist/admin/",
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^1.1.0",
|
||||
"@mui/icons-material": "^5.11.0",
|
||||
"@mui/lab": "5.0.0-alpha.122",
|
||||
"@mui/material": "^5.11.3",
|
||||
"@remix-run/express": "^1.9.0",
|
||||
"@remix-run/node": "^1.9.0",
|
||||
"@remix-run/react": "^1.9.0",
|
||||
"@types/md5": "^2.3.2",
|
||||
"ahooks": "^3.7.4",
|
||||
"axios": "^1.2.2",
|
||||
"body-parser": "^1.20.1",
|
||||
"compression": "^1.7.4",
|
||||
"express": "^4.18.2",
|
||||
"express-mongoose-ra-json-server": "^0.1.0",
|
||||
"filesize": "^8.0.7",
|
||||
"isbot": "^3.6.5",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"lodash": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"morgan": "^1.10.0",
|
||||
"ra-data-json-server": "^4.7.0",
|
||||
"ra-i18n-polyglot": "^4.7.0",
|
||||
"ra-language-english": "^4.7.0",
|
||||
"react": "^18.2.0",
|
||||
"react-admin": "^4.7.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.5.0",
|
||||
"tailchat-server-sdk": "workspace:^0.0.14",
|
||||
"ts-node": "^10.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "^1.9.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/express": "^4.17.15",
|
||||
"@types/morgan": "^1.9.4",
|
||||
"@types/react": "^18.0.25",
|
||||
"@types/react-dom": "^18.0.8",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"nodemon": "^2.0.20",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 17 KiB |
@ -1,8 +0,0 @@
|
||||
/** @type {import('@remix-run/dev').AppConfig} */
|
||||
module.exports = {
|
||||
ignoredRouteFiles: ['**/.*'],
|
||||
// appDirectory: "app",
|
||||
assetsBuildDirectory: 'public/admin',
|
||||
serverBuildPath: 'build/index.js',
|
||||
publicPath: '/admin/',
|
||||
};
|
@ -1,2 +0,0 @@
|
||||
/// <reference types="@remix-run/dev" />
|
||||
/// <reference types="@remix-run/node" />
|
@ -1,5 +0,0 @@
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
import('./app/server');
|
@ -1,26 +0,0 @@
|
||||
version: "3.3"
|
||||
|
||||
services:
|
||||
# 后台应用
|
||||
tailchat-admin:
|
||||
image: tailchat
|
||||
restart: unless-stopped
|
||||
env_file: ../../../docker-compose.env
|
||||
environment:
|
||||
ADMIN_PASS: tailchat
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
ports:
|
||||
- 13000:3000
|
||||
command: pnpm start:admin
|
||||
|
||||
# Database
|
||||
mongo:
|
||||
image: mongo:4
|
||||
restart: on-failure
|
||||
|
||||
# Data cache and Transporter
|
||||
redis:
|
||||
image: redis:alpine
|
||||
restart: on-failure
|
@ -1,23 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["remix.env.d.ts", "./**/*.ts", "./**/*.tsx", "../models/**/*.ts"],
|
||||
"exclude": ["node_modules/**/*", "dist"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2019"],
|
||||
"rootDirs": ["./", "../"],
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"target": "ES2019",
|
||||
"allowJs": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"importsNotUsedAsValues": "error",
|
||||
"experimentalDecorators": true,
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue