mirror of https://github.com/msgbyte/tailchat
feat: admin 初始化与基本界面
parent
8f587887ee
commit
96292a23ba
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
/.cache
|
||||||
|
/build
|
||||||
|
/public/build
|
||||||
|
.env
|
@ -0,0 +1,53 @@
|
|||||||
|
# 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
|
||||||
|
```
|
@ -0,0 +1,22 @@
|
|||||||
|
import { RemixBrowser } from '@remix-run/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);
|
||||||
|
}
|
@ -0,0 +1,111 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
Admin,
|
||||||
|
Resource,
|
||||||
|
ListGuesser,
|
||||||
|
fetchUtils,
|
||||||
|
EditGuesser,
|
||||||
|
ShowGuesser,
|
||||||
|
} from 'react-admin';
|
||||||
|
import jsonServerProvider from 'ra-data-json-server';
|
||||||
|
import { authProvider } from './authProvider';
|
||||||
|
import { UserList } from './resources/user';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
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('auth') ?? '');
|
||||||
|
(options.headers as Headers).set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
return fetchUtils.fetchJson(url, options);
|
||||||
|
} catch (err) {
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataProvider = jsonServerProvider(
|
||||||
|
// 'https://jsonplaceholder.typicode.com'
|
||||||
|
'/admin/api'
|
||||||
|
// httpClient
|
||||||
|
);
|
||||||
|
|
||||||
|
export const App = () => (
|
||||||
|
<Admin
|
||||||
|
basename="/admin"
|
||||||
|
authProvider={authProvider}
|
||||||
|
dataProvider={dataProvider}
|
||||||
|
>
|
||||||
|
<Resource
|
||||||
|
name="users"
|
||||||
|
options={{ label: '用户管理' }}
|
||||||
|
list={UserList}
|
||||||
|
show={ShowGuesser}
|
||||||
|
/>
|
||||||
|
</Admin>
|
||||||
|
);
|
@ -0,0 +1,33 @@
|
|||||||
|
import { AuthProvider } from 'react-admin';
|
||||||
|
|
||||||
|
export const authProvider: AuthProvider = {
|
||||||
|
login: ({ username, password }) => {
|
||||||
|
// TODO
|
||||||
|
if (username !== 'admin' || password !== 'admin') {
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
localStorage.setItem('username', username);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('username');
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
checkAuth: () =>
|
||||||
|
localStorage.getItem('username') ? Promise.resolve() : Promise.reject(),
|
||||||
|
checkError: (error) => {
|
||||||
|
const status = error.status;
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
localStorage.removeItem('username');
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
// other error code (404, 500, etc): no need to log out
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
getIdentity: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: 'user',
|
||||||
|
fullName: 'John Doe',
|
||||||
|
}),
|
||||||
|
getPermissions: () => Promise.resolve(''),
|
||||||
|
};
|
@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
BooleanField,
|
||||||
|
Datagrid,
|
||||||
|
DateField,
|
||||||
|
EmailField,
|
||||||
|
List,
|
||||||
|
TextField,
|
||||||
|
ShowButton,
|
||||||
|
EditButton,
|
||||||
|
SearchInput,
|
||||||
|
} from 'react-admin';
|
||||||
|
import React from 'react';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
|
||||||
|
const PostListActionToolbar = ({ children, ...props }) => (
|
||||||
|
<Box sx={{ alignItems: 'center', display: 'flex' }}>{children}</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const UserList: React.FC = () => (
|
||||||
|
<List filters={[<SearchInput key="search" source="q" alwaysOn />]}>
|
||||||
|
<Datagrid>
|
||||||
|
<TextField source="id" />
|
||||||
|
<EmailField source="email" />
|
||||||
|
<TextField source="nickname" />
|
||||||
|
<TextField source="discriminator" />
|
||||||
|
<BooleanField source="temporary" />
|
||||||
|
<TextField source="avatar" />
|
||||||
|
<TextField source="type" />
|
||||||
|
<TextField source="password" />
|
||||||
|
<TextField source="settings" />
|
||||||
|
<DateField source="createdAt" />
|
||||||
|
<DateField source="updatedAt" />
|
||||||
|
<PostListActionToolbar>
|
||||||
|
<ShowButton />
|
||||||
|
<EditButton />
|
||||||
|
</PostListActionToolbar>
|
||||||
|
</Datagrid>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
UserList.displayName = 'UserList';
|
@ -0,0 +1,32 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { App } from '~/ra/App';
|
||||||
|
import styles from '~/styles/app.css';
|
||||||
|
|
||||||
|
export function links() {
|
||||||
|
return [{ rel: 'stylesheet', href: styles }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
@ -0,0 +1,8 @@
|
|||||||
|
import { App } from '~/ra/App';
|
||||||
|
import styles from '~/styles/app.css';
|
||||||
|
|
||||||
|
export function links() {
|
||||||
|
return [{ rel: 'stylesheet', href: styles }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
// eslint-disable-next-line react/no-unescaped-entities
|
||||||
|
return <div>Please visit '/admin'</div>;
|
||||||
|
}
|
@ -0,0 +1,3 @@
|
|||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"verbose": true,
|
||||||
|
"watch": ["./server.ts"],
|
||||||
|
"ext": "ts",
|
||||||
|
"delay": 1000,
|
||||||
|
"exec": "ts-node ./server.ts"
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "tailchat-admin",
|
||||||
|
"private": true,
|
||||||
|
"sideEffects": false,
|
||||||
|
"scripts": {
|
||||||
|
"build": "remix build",
|
||||||
|
"dev": "remix build && run-p \"dev:*\"",
|
||||||
|
"dev:node": "cross-env NODE_ENV=development nodemon",
|
||||||
|
"dev:remix": "remix watch",
|
||||||
|
"start": "cross-env NODE_ENV=production node ./server.js",
|
||||||
|
"typecheck": "tsc -b"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mui/material": "^5.11.3",
|
||||||
|
"@remix-run/express": "^1.9.0",
|
||||||
|
"@remix-run/node": "^1.9.0",
|
||||||
|
"@remix-run/react": "^1.9.0",
|
||||||
|
"@typegoose/typegoose": "9.3.1",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-mongoose-ra-json-server": "^0.1.0",
|
||||||
|
"isbot": "^3.6.5",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"ra-data-json-server": "^4.6.3",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-admin": "^4.6.3",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"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.
After Width: | Height: | Size: 17 KiB |
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('@remix-run/dev').AppConfig} */
|
||||||
|
module.exports = {
|
||||||
|
ignoredRouteFiles: ['**/.*'],
|
||||||
|
// appDirectory: "app",
|
||||||
|
// assetsBuildDirectory: "public/build",
|
||||||
|
// serverBuildPath: "build/index.js",
|
||||||
|
// publicPath: "/build/",
|
||||||
|
};
|
@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="@remix-run/dev" />
|
||||||
|
/// <reference types="@remix-run/node" />
|
@ -0,0 +1,85 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import express, { Router } from 'express';
|
||||||
|
import compression from 'compression';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import { createRequestHandler } from '@remix-run/express';
|
||||||
|
import raExpressMongoose from 'express-mongoose-ra-json-server';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// 链接数据库
|
||||||
|
mongoose.connect(process.env.MONGO_URL!, (error: any) => {
|
||||||
|
if (!error) {
|
||||||
|
return console.info('Mongo connected');
|
||||||
|
}
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const BUILD_DIR = path.join(process.cwd(), 'build');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(compression());
|
||||||
|
|
||||||
|
// 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'));
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(
|
||||||
|
'/users',
|
||||||
|
raExpressMongoose(require('../models/user/user').default, {
|
||||||
|
q: ['nickname', 'email'],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use('/admin/api', router);
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Express server listening on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "../models/**/*.ts"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2019"],
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"target": "ES2019",
|
||||||
|
"allowJs": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./app/*"]
|
||||||
|
},
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
// Remix takes care of building everything in `remix build`.
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue