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