feat: webiste add and list

This commit is contained in:
moonrailgun 2023-09-05 01:18:43 +08:00
parent 2ccb084959
commit bd7a5776c3
19 changed files with 341 additions and 70 deletions

7
nodemon.json Normal file
View File

@ -0,0 +1,7 @@
{
"verbose": true,
"watch": ["./src/server"],
"ext": "ts",
"delay": 1000,
"exec": "ts-node --transpileOnly ./src/server/main.ts"
}

View File

@ -3,7 +3,7 @@
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "nodemon src/server/main.ts -w src/server",
"dev": "nodemon",
"start": "NODE_ENV=production ts-node src/server/main.ts",
"build": "vite build && pnpm build:tracker && pnpm build:geo",
"build:tracker": "ts-node scripts/build-tracker.ts",
@ -16,6 +16,7 @@
"@ant-design/charts": "^1.4.2",
"@ant-design/icons": "^5.2.5",
"@prisma/client": "^5.2.0",
"@tanstack/react-query": "^4.33.0",
"@types/uuid": "^9.0.3",
"antd": "^5.8.5",
"axios": "^1.5.0",

View File

@ -10,6 +10,9 @@ dependencies:
'@prisma/client':
specifier: ^5.2.0
version: 5.2.0(prisma@5.2.0)
'@tanstack/react-query':
specifier: ^4.33.0
version: 4.33.0(react-dom@18.2.0)(react@18.2.0)
'@types/uuid':
specifier: ^9.0.3
version: 9.0.3
@ -1760,6 +1763,28 @@ packages:
resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==}
dev: false
/@tanstack/query-core@4.33.0:
resolution: {integrity: sha512-qYu73ptvnzRh6se2nyBIDHGBQvPY1XXl3yR769B7B6mIDD7s+EZhdlWHQ67JI6UOTFRaI7wupnTnwJ3gE0Mr/g==}
dev: false
/@tanstack/react-query@4.33.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-97nGbmDK0/m0B86BdiXzx3EW9RcDYKpnyL2+WwyuLHEgpfThYAnXFaMMmnTDuAO4bQJXEhflumIEUfKmP7ESGA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
react-native: '*'
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
dependencies:
'@tanstack/query-core': 4.33.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/@tsconfig/node10@1.0.9:
resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==}

View File

@ -29,8 +29,10 @@ model Workspace {
updatedAt DateTime? @updatedAt @db.Timestamptz(6)
users WorkspacesOnUsers[]
website Website[]
User User[]
websites Website[]
// for user currentWorkspace
selectedUsers User[]
}
model WorkspacesOnUsers {

View File

@ -8,12 +8,14 @@ import { Settings } from './pages/Settings';
import { Servers } from './pages/Servers';
import { useUserStore } from './store/user';
import { Register } from './pages/Register';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './api/cache';
function App() {
const { info } = useUserStore();
return (
<div className="App">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
{info && (
@ -37,6 +39,7 @@ function App() {
/>
</Routes>
</BrowserRouter>
</QueryClientProvider>
</div>
);
}

10
src/client/api/cache.ts Normal file
View File

@ -0,0 +1,10 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});

View File

@ -1,7 +1,24 @@
import { setUserInfo, UserLoginInfo } from '../../store/user';
import { setUserInfo } from '../../store/user';
import { getJWT, setJWT } from '../auth';
import { request } from '../request';
export interface UserLoginInfo {
id: string;
username: string;
role: string;
currentWorkspace: {
id: string;
name: string;
};
workspaces: {
role: string;
workspace: {
id: string;
name: string;
};
}[];
}
export async function login(username: string, password: string) {
const { data } = await request.post('/api/user/login', {
username,

View File

@ -0,0 +1,54 @@
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../cache';
import { request } from '../request';
export interface WebsiteInfo {
id: string;
name: string;
domain: string | null;
shareId: string | null;
resetAt: string | null;
workspaceId: string;
createdAt: string | null;
updatedAt: string | null;
deletedAt: string | null;
}
export async function getWorkspaceWebsites(
workspaceId: string
): Promise<WebsiteInfo[]> {
const { data } = await request.get('/api/workspace/websites', {
params: {
workspaceId,
},
});
return data.websites;
}
export function useWorspaceWebsites(workspaceId: string) {
const { data: websites = [], isLoading } = useQuery(
['websites', workspaceId],
() => {
return getWorkspaceWebsites(workspaceId);
}
);
return { websites, isLoading };
}
export function refreshWorkspaceWebsites(workspaceId: string) {
queryClient.refetchQueries(['websites', workspaceId]);
}
export async function addWorkspaceWebsite(
workspaceId: string,
name: string,
domain: string
) {
await request.post('/api/workspace/website', {
workspaceId,
name,
domain,
});
}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Select } from 'antd';
import { Dropdown, Select } from 'antd';
import { compact } from 'lodash-es';
export const DateFilter: React.FC<{

View File

@ -0,0 +1,11 @@
import { LoadingOutlined } from '@ant-design/icons';
import React from 'react';
export const Loading: React.FC = React.memo(() => {
return (
<div>
<LoadingOutlined />
</div>
);
});
Loading.displayName = 'Loading';

View File

@ -0,0 +1,6 @@
import React from 'react';
export const NoWorkspaceTip: React.FC = React.memo(() => {
return <div>Please Select Workspace</div>;
});
NoWorkspaceTip.displayName = 'NoWorkspaceTip';

View File

@ -6,18 +6,43 @@ import {
import { Button, Form, Input, Modal, Table } from 'antd';
import { ColumnsType } from 'antd/es/table';
import React, { useMemo, useState } from 'react';
import {
addWorkspaceWebsite,
refreshWorkspaceWebsites,
useWorspaceWebsites,
WebsiteInfo,
} from '../api/model/website';
import { Loading } from '../components/Loading';
import { NoWorkspaceTip } from '../components/NoWorkspaceTip';
import { useRequest } from '../hooks/useRequest';
import { useUserStore } from '../store/user';
export const Website: React.FC = React.memo(() => {
const [isModalOpen, setIsModalOpen] = useState(false);
const currentWorkspace = useUserStore(
(state) => state.info?.currentWorkspace
);
const [form] = Form.useForm();
const handleOk = () => {
const [{ loading }, handleAddWebsite] = useRequest(async () => {
await form.validateFields();
const values = form.getFieldsValue();
await addWorkspaceWebsite(currentWorkspace!.id, values.name, values.domain);
refreshWorkspaceWebsites(currentWorkspace!.id);
setIsModalOpen(false);
};
form.resetFields();
});
if (!currentWorkspace) {
return <NoWorkspaceTip />;
}
return (
<div>
<div className="h-24 flex items-center">
<div className="text-2xl flex-1">Servers</div>
<div className="text-2xl flex-1">Websites</div>
<div>
<Button
type="primary"
@ -30,19 +55,26 @@ export const Website: React.FC = React.memo(() => {
</div>
</div>
<WebsiteList />
<WebsiteList workspaceId={currentWorkspace.id} />
<Modal
title="Add Server"
open={isModalOpen}
onOk={handleOk}
okButtonProps={{
loading,
}}
onOk={() => handleAddWebsite()}
onCancel={() => setIsModalOpen(false)}
>
<Form layout="vertical">
<Form.Item label="Server Name">
<Form layout="vertical" form={form}>
<Form.Item
label="Server Name"
name="name"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item label="Domain">
<Form.Item label="Domain" name="domain" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Form>
@ -52,20 +84,10 @@ export const Website: React.FC = React.memo(() => {
});
Website.displayName = 'Website';
interface WebsiteInfoRecordType {
name: string;
domain: string;
}
const WebsiteList: React.FC<{ workspaceId: string }> = React.memo((props) => {
const { websites, isLoading } = useWorspaceWebsites(props.workspaceId);
const WebsiteList: React.FC = React.memo(() => {
const dataSource: WebsiteInfoRecordType[] = [
{
name: 'tianji',
domain: 'tianji.msgbyte.com',
},
];
const columns = useMemo((): ColumnsType<WebsiteInfoRecordType> => {
const columns = useMemo((): ColumnsType<WebsiteInfo> => {
return [
{
dataIndex: 'name',
@ -89,6 +111,10 @@ const WebsiteList: React.FC = React.memo(() => {
];
}, []);
return <Table columns={columns} dataSource={dataSource} pagination={false} />;
if (isLoading) {
return <Loading />;
}
return <Table columns={columns} dataSource={websites} pagination={false} />;
});
WebsiteList.displayName = 'WebsiteList';

View File

@ -1,21 +1,5 @@
import { create } from 'zustand';
export interface UserLoginInfo {
id: string;
username: string;
role: string;
currentWorkspace: {
id: string;
name: string;
};
workspaces: {
role: string;
workspace: {
id: string;
name: string;
};
}[];
}
import { UserLoginInfo } from '../api/model/user';
interface UserState {
info: UserLoginInfo | null;

View File

@ -6,8 +6,9 @@ import compression from 'compression';
import passport from 'passport';
import { userRouter } from './router/user';
import { websiteRouter } from './router/website';
import { workspaceRouter } from './router/workspace';
const port = Number(process.env.PORT || 3000);
const port = Number(process.env.PORT || 12345);
const app = express();
@ -20,6 +21,7 @@ app.disable('x-powered-by');
app.use('/api/user', userRouter);
app.use('/api/website', websiteRouter);
app.use('/api/workspace', workspaceRouter);
app.use((err: any, req: any, res: any, next: any) => {
console.error(err);

View File

@ -11,7 +11,7 @@ export const jwtSecret =
export const jwtIssuer = process.env.JWT_ISSUER || 'tianji.msgbyte.com';
export const jwtAudience = process.env.JWT_AUDIENCE || 'msgbyte.com';
interface JWTPayload {
export interface JWTPayload {
id: string;
username: string;
role: string;

View File

@ -0,0 +1,52 @@
import { prisma } from './_client';
export async function checkIsWorkspaceUser(
workspaceId: string,
userId: string
) {
const workspace = await prisma.workspace.findUnique({
where: {
id: workspaceId,
users: {
some: {
userId,
},
},
},
});
if (workspace) {
return true;
} else {
return false;
}
}
export async function getWorkspaceWebsites(workspaceId: string) {
const workspace = await prisma.workspace.findUnique({
where: {
id: workspaceId,
},
select: {
websites: true,
},
});
return workspace?.websites ?? [];
}
export async function addWorkspaceWebsite(
workspaceId: string,
name: string,
domain: string
) {
const website = await prisma.website.create({
data: {
name,
domain,
workspaceId,
},
});
return website;
}

View File

@ -0,0 +1,64 @@
import { Router } from 'express';
import { auth } from '../middleware/auth';
import { body, param, query, validate } from '../middleware/validate';
import {
addWorkspaceWebsite,
checkIsWorkspaceUser,
getWorkspaceWebsites,
} from '../model/workspace';
export const workspaceRouter = Router();
workspaceRouter.get(
'/websites',
validate(
query('workspaceId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID')
),
auth(),
async (req, res) => {
const userId = req.user!.id;
const workspaceId = req.query.workspaceId as string;
const isWorkspaceUser = await checkIsWorkspaceUser(workspaceId, userId);
if (!isWorkspaceUser) {
throw new Error('Is not workspace user');
}
const websites = await getWorkspaceWebsites(workspaceId);
res.json({ websites });
}
);
workspaceRouter.post(
'/website',
validate(
body('workspaceId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
body('name').isString().withMessage('name should be a string'),
body('domain').isURL().withMessage('domain should be URL')
),
auth(),
async (req, res) => {
const userId = req.user!.id;
const { workspaceId, name, domain } = req.body;
const isWorkspaceUser = await checkIsWorkspaceUser(workspaceId, userId);
if (!isWorkspaceUser) {
throw new Error('Is not workspace user');
}
const website = await addWorkspaceWebsite(workspaceId, name, domain);
res.json({ website });
}
);

7
src/server/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
import type { JWTPayload } from '../middleware/auth';
declare global {
namespace Express {
interface User extends JWTPayload {}
}
}

View File

@ -13,7 +13,7 @@
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
"noEmit": true,
},
"include": ["src"]
"include": ["src", "types"]
}