perf: remove admin-old to reduce image size

test/ios-bundle
moonrailgun 2 years ago
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

@ -11,12 +11,10 @@
"dev:admin": "cd server/admin && pnpm dev",
"start:service": "cd server && pnpm start:service",
"start:admin": "cd server/admin && pnpm start",
"start:admin-old": "cd server/admin-old && pnpm start",
"build": "concurrently npm:build:web npm:build:server npm:build:admin npm:build:admin-old && cp -r client/web/dist/* server/dist/public",
"build": "concurrently npm:build:web npm:build:server npm:build:admin && cp -r client/web/dist/* server/dist/public",
"build:web": "cd client/web && pnpm build",
"build:server": "cd server && pnpm build && echo \"Install server side plugin:\" && pnpm run plugin:install com.msgbyte.tasks com.msgbyte.linkmeta com.msgbyte.github com.msgbyte.simplenotify com.msgbyte.topic com.msgbyte.agora com.msgbyte.wxpusher com.msgbyte.welcome com.msgbyte.iam com.msgbyte.discover && mkdir -p ./dist/public && cp -r ./public/plugins ./dist/public && cp ./public/registry-be.json ./dist/public",
"build:admin": "cd server/admin && pnpm build",
"build:admin-old": "cd server/admin-old && pnpm build",
"check:type": "concurrently npm:check:type:client npm:check:type:server",
"check:type:client": "cd client/web && tsc --noEmit",
"check:type:server": "cd server && tsc --noEmit",

File diff suppressed because it is too large Load Diff

@ -7,7 +7,6 @@ packages:
- 'client/packages/**'
- 'server'
- 'server/admin'
- 'server/admin-old'
- 'server/packages/**'
- 'server/plugins/**'
- 'server/test/demo/**'

@ -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,164 +0,0 @@
export const defaultChineseMessages = {
ra: {
action: {
add_filter: '增加检索',
add: '增加',
back: '回退',
bulk_actions: '选中%{smart_count}项',
cancel: '取消',
clear_input_value: '清空输入',
clone: '克隆',
confirm: '确认',
create: '新建',
create_item: '新建 %{item}',
delete: '删除',
edit: '编辑',
export: '导出',
list: '列表',
refresh: '刷新',
remove_filter: '移除检索',
remove: '删除',
save: '保存',
search: '检索',
select_all: '选中全部',
select_row: '选中这行',
show: '查看',
sort: '排序',
undo: '撤销',
unselect: '反选',
expand: '展开',
close: '关闭',
open_menu: '打开菜单',
close_menu: '关闭菜单',
update: '更新',
move_up: '上移',
move_down: '下移',
open: '打开',
toggle_theme: '切换主题',
},
boolean: {
true: '是',
false: '否',
null: '',
},
page: {
create: '新建 %{name}',
dashboard: '概览',
edit: '%{name} #%{id}',
error: '出现错误',
list: '%{name} 列表',
loading: '加载中',
not_found: '未发现',
show: '%{name} #%{id}',
empty: '无 %{name} ',
invite: '要增加吗?',
},
input: {
file: {
upload_several: '将文件集合拖拽到这里, 或点击这里选择文件集合.',
upload_single: '将文件拖拽到这里, 或点击这里选择文件.',
},
image: {
upload_several: '将图片文件集合拖拽到这里, 或点击这里选择图片文件集合.',
upload_single: '将图片文件拖拽到这里, 或点击这里选择图片文件.',
},
references: {
all_missing: '未找到参考数据.',
many_missing: '至少有一条参考数据不再可用.',
single_missing: '关联的参考数据不再可用.',
},
password: {
toggle_visible: '隐藏密码',
toggle_hidden: '显示密码',
},
},
message: {
about: '关于',
are_you_sure: '您确定操作?',
bulk_delete_title: '删除 %{name} |||| 删除 %{smart_count}项 %{name} ',
bulk_delete_content:
'您确定要删除 %{name}? |||| 您确定要删除 %{smart_count} 项?',
bulk_update_content:
'你确定要更新 %{name}? |||| 你确定想更新 %{smart_count} 项?',
bulk_update_title: '更新 %{name} |||| 您确定更新 %{smart_count} %{name}',
delete_content: '您确定要删除该条目?',
delete_title: '删除 %{name} #%{id}',
details: '详情',
error: '客户端错误导致请求未完成.',
invalid_form: '表单输入无效. 请检查错误提示',
loading: '正在加载页面, 请稍候',
no: '否',
not_found: '您输入了错误的URL或者错误的链接.',
yes: '是',
unsaved_changes: '修改未保存. 放弃修改吗?',
},
navigation: {
no_results: '结果为空',
no_more_results: '页码 %{page} 超出边界. 试试上一页.',
page_out_of_boundaries: '页码 %{page} 超出边界',
page_out_from_end: '已到最末页',
page_out_from_begin: '已到最前页',
page_range_info: '%{offsetBegin}-%{offsetEnd} / %{total}',
partial_page_range_info:
'%{offsetBegin}-%{offsetEnd} of more than %{offsetEnd}',
current_page: '页码 %{page}',
page: '跳转到 %{page}',
prev: '向前',
first: '第一页',
last: '最后一页',
next: '向后',
previous: '先前第一页',
page_rows_per_page: '每页行数:',
skip_nav: '跳到内容',
},
sort: {
sort_by: '按 %{field} %{order}',
ASC: '升序',
DESC: '降序',
},
auth: {
auth_check_error: '请登录以继续',
user_menu: '设置',
username: '用户名',
password: '密码',
sign_in: '登录',
sign_in_error: '验证失败, 请重试',
logout: '退出',
},
notification: {
updated: '条目已更新 |||| %{smart_count} 项条目已更新',
created: '条目已新建',
deleted: '条目已删除 |||| %{smart_count} 项条目已删除',
bad_item: '不正确的条目',
item_doesnt_exist: '条目不存在',
http_error: '与服务通信出错',
canceled: '取消动作',
data_provider_error: 'dataProvider错误. 请检查console的详细信息.',
i18n_error: '无法加载指定语言包',
logged_out: '会话失效, 请重连.',
not_authorized: '您无权访问此资源.',
},
validation: {
required: '必填',
minLength: '必须不少于 %{min} 个字符',
maxLength: '必须不多于 %{max} 个字符',
minValue: '必须不小于 %{min}',
maxValue: '必须不大于 %{max}',
number: '必须为数字',
email: '必须是有效的邮箱',
oneOf: '必须为: %{options}其中一项',
regex: '必须符合指定的格式 (regexp): %{pattern}',
},
saved_queries: {
label: '保存查询',
query_name: '搜索名称',
new_label: '保存当前的查询...',
new_dialog_title: '将当前查询另存为',
remove_label: '删除保存的查询',
remove_label_with_name: '删除查询 "%{name}"',
remove_dialog_title: '是否删除保存的查询?',
remove_message: '确实要从保存的查询列表中删除该项吗?',
help: '筛选列表并保存此查询以备将来使用',
},
},
};

@ -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,90 +0,0 @@
import { Router } from 'express';
import raExpressMongoose from 'express-mongoose-ra-json-server';
import jwt from 'jsonwebtoken';
import { callBrokerAction } from '../broker';
import { adminAuth, auth, authSecret } from '../middleware/auth';
import { configRouter } from './config';
import { networkRouter } from './network';
import { fileRouter } from './file';
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.json({
username,
token: token,
expiredAt: new Date().valueOf() + 2 * 60 * 60 * 1000,
});
} else {
res.status(401).end('username or password incorrect');
}
});
router.use('/network', networkRouter);
router.use('/config', configRouter);
router.use('/file', fileRouter);
router.use(
'/users',
auth(),
raExpressMongoose(require('../../../../models/user/user').default, {
q: ['nickname', 'email'],
})
);
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.message });
}
});
router.use(
'/messages',
auth(),
raExpressMongoose(require('../../../../models/chat/message').default, {
q: ['content'],
allowedRegexFields: ['content'],
})
);
router.use(
'/groups',
auth(),
raExpressMongoose(require('../../../../models/group/group').default, {
q: ['name'],
})
);
router.use(
'/file',
auth(),
raExpressMongoose(require('../../../../models/file').default, {
q: ['objectName'],
})
);
export { router as apiRouter };

@ -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": "."
}
}

@ -38,9 +38,5 @@ https://tailchat.example.com/admin/
<details>
<summary>About the deprecated legacy admin</summary>
Old version <strong>admin-old</strong> has been deprecated, if you still wanna use old version, you can use follow command to use it:
```jsx
curl -L "https://raw.githubusercontent.com/msgbyte/tailchat/master/docker/admin-old.yml" -o admin.yml
```
admin-old will be remove in v1.8.6. you can checkout version before to get it
</details>

@ -36,9 +36,6 @@ https://tailchat.example.com/admin/
<details>
<summary>关于弃用的旧版admin</summary>
旧版本 <strong>admin-old</strong> 已被弃用,如果你依旧期望使用旧版本,可以使用以下命令来替换上面的命令:
旧版本 <strong>admin-old</strong> 将于v1.8.6版本被移除,如果你依旧期望使用旧版本,可以切换到之前的版本获取
```jsx
curl -L "https://raw.githubusercontent.com/msgbyte/tailchat/master/docker/admin-old.yml" -o admin.yml
```
</details>

Loading…
Cancel
Save