Merge pull request #112 from felinae98/new-frontend

feat: rewrite frontend
This commit is contained in:
felinae98 2022-10-07 01:36:51 +08:00 committed by GitHub
commit b9f1515265
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 4489 additions and 4681 deletions

View File

@ -20,4 +20,27 @@ repos:
hooks: hooks:
- id: prettier - id: prettier
types_or: [markdown, ts, tsx] types_or: [markdown, ts, tsx]
exclude: 'admin-frontend/'
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.20.0
hooks:
- id: eslint
additional_dependencies:
- "eslint@8.2.0"
- "@typescript-eslint/eslint-plugin"
- "@typescript-eslint/parser"
- "eslint-config-airbnb"
- "eslint-config-airbnb-typescript"
- "eslint-import-resolver-typescript"
- "eslint-plugin-import"
- "eslint-plugin-jsx-a11y"
- "eslint-plugin-react"
- "eslint-plugin-react-hooks"
- "eslint-plugin-react-redux"
types_or: [ts, tsx]
types: []
files: ^admin-frontend/
args: [--fix, -c, './admin-frontend/.eslintrc.json']
exclude: 'CHANGELOG.md' exclude: 'CHANGELOG.md'

View File

@ -0,0 +1,39 @@
{
"env": {
"browser": true,
"es2021": true,
"jest": true
},
"extends": [
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"airbnb"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"react",
"@typescript-eslint"
],
"rules": {
"react/jsx-filename-extension": [ "warn", {"extensions": [".tsx"]} ],
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": ["error"],
"import/extensions": ["error", "ignorePackages", {"ts": "never", "tsx": "never"}],
"no-param-reassign": ["error", { "props": false }],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error"]
},
"settings": {
"import/resolver": {
"typescript": {}
}
}
}

View File

@ -1,6 +1,6 @@
# Getting Started with Create React App # Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app), using the [Redux](https://redux.js.org/) and [Redux Toolkit](https://redux-toolkit.js.org/) TS template.
## Available Scripts ## Available Scripts

View File

@ -1,36 +1,35 @@
{ {
"name": "admin-frontend", "name": "nonebot-bison-admin",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"homepage": "bison", "homepage": "bison",
"proxy": "http://127.0.0.1:8080", "proxy": "http://127.0.0.1:8080",
"dependencies": { "dependencies": {
"@ant-design/icons": "^4.6.4", "@arco-design/web-react": "^2.39.3",
"@reduxjs/toolkit": "^1.7.0", "@reduxjs/toolkit": "^1.8.1",
"@testing-library/jest-dom": "^5.11.4", "@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^11.1.0", "@testing-library/react": "^13.0.1",
"@testing-library/user-event": "^12.1.10", "@testing-library/user-event": "^14.1.1",
"@types/jest": "^26.0.15", "@types/jest": "^27.4.1",
"@types/node": "^12.0.0", "@types/node": "^17.0.25",
"@types/react": "^17.0.0", "@types/react": "^18.0.6",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^18.0.2",
"antd": "^4.16.13", "@types/redux-persist": "^4.3.1",
"axios": "^0.21.4", "react": "^18.2.0",
"jwt-decode": "^3.1.2", "react-dom": "^18.2.0",
"lodash": "^4.17.21", "react-redux": "^8.0.1",
"react": "^17.0.2", "react-router-dom": "^6.3.0",
"react-dom": "^17.0.2", "react-scripts": "5.0.1",
"react-redux": "^7.2.6", "redux-persist": "^6.0.0",
"react-router-dom": "^5.3.0", "typescript": "^4.6.0",
"react-scripts": "^5.0.0", "web-vitals": "^2.1.0"
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build && cp -r -f build/* ../src/plugins/nonebot_bison/admin_page/dist", "build": "react-scripts build && cp -r -f build/* ../src/plugins/nonebot_bison/admin_page/dist",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"lint": "eslint --fix src/**/*.ts src/**/*.tsx"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@ -51,10 +50,16 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@types/lodash": "^4.14.175", "@typescript-eslint/eslint-plugin": "^5.31.0",
"@types/react-redux": "^7.1.20", "@typescript-eslint/parser": "^5.31.0",
"@types/react-router-dom": "^5.3.0", "eslint": "^7.32.0 || ^8.2.0",
"react-app-rewired": "^2.1.8", "eslint-config-airbnb": "^19.0.4",
"redux-devtools": "^3.7.0" "eslint-config-airbnb-typescript": "^17.0.0",
"eslint-import-resolver-typescript": "^3.3.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-react-redux": "^4.0.0"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>React App</title> <title>React Redux App</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -9,30 +9,31 @@
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.App-logo { .App-logo {
animation: App-logo-spin infinite 20s linear; animation: App-logo-float infinite 3s ease-in-out;
} }
} }
.App-header { .App-header {
background-color: #282c34;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: calc(10px + 2vmin); font-size: calc(10px + 2vmin);
color: white;
} }
.App-link { .App-link {
color: #61dafb; color: rgb(112, 76, 182);
} }
@keyframes App-logo-spin { @keyframes App-logo-float {
from { 0% {
transform: rotate(0deg); transform: translateY(0);
} }
to { 50% {
transform: rotate(360deg); transform: translateY(10px);
}
100% {
transform: translateY(0px);
} }
} }

View File

@ -1,9 +1,15 @@
import React from "react"; import React from 'react';
import { render, screen } from "@testing-library/react"; import { render } from '@testing-library/react';
import App from "./App"; import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
test("renders learn react link", () => { test('renders learn react link', () => {
render(<App />); const { getByText } = render(
const linkElement = screen.getByText(/learn react/i); <Provider store={store}>
expect(linkElement).toBeInTheDocument(); <App />
</Provider>,
);
expect(getByText(/learn/i)).toBeInTheDocument();
}); });

View File

@ -1,45 +1,60 @@
import "antd/dist/antd.css"; import React, { useEffect } from 'react';
import React, { useEffect } from "react"; import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { useDispatch, useSelector } from "react-redux"; import './App.css';
import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; import { useAppDispatch, useAppSelector } from './app/hooks';
import "./App.css"; import Auth from './features/auth/Auth';
import { Admin } from "./pages/admin"; import { loadGlobalConf, selectGlobalConfLoaded } from './features/globalConf/globalConfSlice';
import { Auth } from "./pages/auth"; import GroupManager from './features/subsribeConfigManager/GroupManager';
import { getGlobalConf } from "./store/globalConfSlice"; import SubscribeManager from './features/subsribeConfigManager/SubscribeManager';
import { useAppSelector } from "./store/hooks"; import WeightConfig from './features/weightConfig/WeightManager';
import { loadLoginState, loginSelector } from "./store/loginSlice"; import Home from './pages/Home';
import Unauthed from './pages/Unauthed';
function LoginSwitch() {
const login = useSelector(loginSelector);
if (login.login) {
return <Admin />;
} else {
return <div>not login</div>;
}
}
function App() { function App() {
const dispatch = useDispatch(); const dispatch = useAppDispatch();
const globalConf = useAppSelector((state) => state.globalConf); const globalConfLoaded = useAppSelector(selectGlobalConfLoaded);
useEffect(() => { useEffect(() => {
dispatch(getGlobalConf()); if (!globalConfLoaded) {
dispatch(loadLoginState()); dispatch(loadGlobalConf());
}, [dispatch]); }
}, [globalConfLoaded]);
const router = createBrowserRouter([
{
path: '/auth/:code',
element: <Auth />,
},
{
path: '/unauthed',
element: <Unauthed />,
},
{
path: '/home/',
element: <Home />,
// loader: homeLoader,
children: [
{
path: 'groups',
element: <GroupManager />,
},
{
path: 'groups/:groupNumber',
element: <SubscribeManager />,
},
{
path: 'weight',
element: <WeightConfig />,
},
],
},
], { basename: '/bison' });
return ( return (
<> globalConfLoaded
{globalConf.loaded && ( ? (
<Router basename="/bison"> <RouterProvider router={router} />
<Switch> ) : <div>loading</div>
<Route path="/auth/:code">
<Auth />
</Route>
<Route path="/admin/">
<LoginSwitch />
</Route>
</Switch>
</Router>
)}
</>
); );
} }

View File

@ -1,63 +0,0 @@
import axios from "axios";
import {
GlobalConf,
TokenResp,
SubscribeResp,
TargetNameResp,
SubscribeConfig,
} from "../utils/type";
import { baseUrl } from "./utils";
export async function getGlobalConf(): Promise<GlobalConf> {
const res = await axios.get<GlobalConf>(`${baseUrl}global_conf`);
return res.data;
}
export async function auth(token: string): Promise<TokenResp> {
const res = await axios.get<TokenResp>(`${baseUrl}auth`, {
params: { token },
});
return res.data;
}
export async function getSubscribe(): Promise<SubscribeResp> {
const res = await axios.get(`${baseUrl}subs`);
return res.data;
}
export async function getTargetName(
platformName: string,
target: string
): Promise<TargetNameResp> {
const res = await axios.get(`${baseUrl}target_name`, {
params: { platformName, target },
});
return res.data;
}
export async function addSubscribe(groupNumber: string, req: SubscribeConfig) {
const res = await axios.post(`${baseUrl}subs`, req, {
params: { groupNumber },
});
return res.data;
}
export async function delSubscribe(
groupNumber: string,
platformName: string,
target: string
) {
const res = await axios.delete(`${baseUrl}subs`, {
params: { groupNumber, platformName, target },
});
return res.data;
}
export async function updateSubscribe(
groupNumber: string,
req: SubscribeConfig
) {
return axios
.patch(`${baseUrl}subs`, req, { params: { groupNumber } })
.then((res) => res.data);
}

View File

@ -1,61 +0,0 @@
import axios, { AxiosError } from "axios";
import { Store } from "src/store";
import { clearLoginStatus } from "src/store/loginSlice";
// import { useContext } from 'react';
// import { LoginContext } from "../utils/context";
export const baseUrl = "/bison/api/";
let store: Store;
export const injectStore = (_store: Store) => {
store = _store;
};
// const loginStatus = useContext(LoginContext);
axios.interceptors.request.use(
function (config) {
if (
config.url &&
config.url.startsWith(baseUrl) &&
config.url !== `${baseUrl}auth` &&
config.url !== `${baseUrl}global_conf`
) {
const token = localStorage.getItem("token");
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
} else {
throw new axios.Cancel("User not login");
}
}
return config;
},
function (error) {
return Promise.reject(error);
}
);
axios.interceptors.response.use(
function (response) {
// const data = response.data;
// const parseToMap = (item: any): any => {
// if (item instanceof Array) {
// return item.map(parseToMap);
// } else if (item instanceof Object) {
// let res = new Map();
// for (const key of Object.keys(item)) {
// res.set(key, parseToMap(item[key]));
// }
// return res;
// } else {
// return item;
// }
// }
// response.data = parseToMap(data);
return response;
},
function (error: AxiosError) {
if (error.response && error.response.status === 401) {
store.dispatch(clearLoginStatus());
}
return Promise.reject(error);
}
);

View File

@ -0,0 +1,6 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@ -0,0 +1,58 @@
import {
Action, combineReducers, configureStore, ThunkAction,
} from '@reduxjs/toolkit';
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import authReducer from '../features/auth/authSlice';
import globalConfReducer from '../features/globalConf/globalConfSlice';
import { subscribeApi } from '../features/subsribeConfigManager/subscribeConfigSlice';
import { targetNameApi } from '../features/targetName/targetNameSlice';
import { weightApi } from '../features/weightConfig/weightConfigSlice';
const rootReducer = combineReducers({
auth: authReducer,
globalConf: globalConfReducer,
[subscribeApi.reducerPath]: subscribeApi.reducer,
[weightApi.reducerPath]: weightApi.reducer,
[targetNameApi.reducerPath]: targetNameApi.reducer,
});
const persistConfig = {
key: 'root',
storage,
whitelist: ['auth'],
};
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
})
.concat(subscribeApi.middleware)
.concat(weightApi.middleware)
.concat(targetNameApi.middleware),
});
export const persistor = persistStore(store);
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;

View File

@ -1,219 +0,0 @@
import { Form, Input, Modal, Select, Tag } from "antd";
import React, { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { addSubscribe, getTargetName, updateSubscribe } from "src/api/config";
import { InputTag } from "src/component/inputTag";
import { platformConfSelector } from "src/store/globalConfSlice";
import { CategoryConfig, SubscribeConfig } from "src/utils/type";
interface InputTagCustomProp {
value?: Array<string>;
onChange?: (value: Array<string>) => void;
disabled?: boolean;
}
function InputTagCustom(prop: InputTagCustomProp) {
const [value, setValue] = useState(prop.value || []);
const handleSetValue = (newVal: Array<string>) => {
setValue(() => newVal);
if (prop.onChange) {
prop.onChange(newVal);
}
};
useEffect(() => {
if (prop.value) {
setValue(prop.value);
}
}, [prop.value]);
return (
<>
{prop.disabled ? (
<Tag color="default"></Tag>
) : (
<>
{value.length === 0 && <Tag color="green"></Tag>}
<InputTag
color="blue"
addText="添加标签"
value={value}
onChange={handleSetValue}
/>
</>
)}
</>
);
}
interface AddModalProp {
showModal: boolean;
groupNumber: string;
setShowModal: (s: boolean) => void;
refresh: () => void;
initVal?: SubscribeConfig;
}
export function AddModal({
showModal,
groupNumber,
setShowModal,
refresh,
initVal,
}: AddModalProp) {
const [confirmLoading, setConfirmLoading] = useState<boolean>(false);
const platformConf = useSelector(platformConfSelector);
const [hasTarget, setHasTarget] = useState(false);
const [categories, setCategories] = useState({} as CategoryConfig);
const [enabledTag, setEnableTag] = useState(false);
const [form] = Form.useForm();
const [inited, setInited] = useState(false);
const changePlatformSelect = (platform: string) => {
setHasTarget((_) => platformConf[platform].hasTarget);
setCategories((_) => platformConf[platform].categories);
setEnableTag(platformConf[platform].enabledTag);
if (!platformConf[platform].hasTarget) {
getTargetName(platform, "default").then((res) => {
console.log(res);
form.setFieldsValue({
targetName: res.targetName,
target: "",
});
});
} else {
form.setFieldsValue({
targetName: "",
target: "",
});
}
};
const handleSubmit = (value: any) => {
let newVal = Object.assign({}, value);
if (typeof newVal.tags !== "object") {
newVal.tags = [];
}
if (typeof newVal.cats !== "object") {
newVal.cats = [];
}
if (newVal.target === "") {
newVal.target = "default";
}
if (initVal) {
// patch
updateSubscribe(groupNumber, newVal).then(() => {
setConfirmLoading(false);
setShowModal(false);
form.resetFields();
refresh();
});
} else {
addSubscribe(groupNumber, newVal).then(() => {
setConfirmLoading(false);
setShowModal(false);
form.resetFields();
refresh();
});
}
};
const handleModleFinish = () => {
form.submit();
setConfirmLoading(() => true);
};
useEffect(() => {
if (initVal && !inited) {
const platformName = initVal.platformName;
setHasTarget(platformConf[platformName].hasTarget);
setCategories(platformConf[platformName].categories);
setEnableTag(platformConf[platformName].enabledTag);
setInited(true);
form.setFieldsValue(initVal);
}
}, [initVal, form, platformConf, inited]);
return (
<Modal
title="添加订阅"
visible={showModal}
confirmLoading={confirmLoading}
onCancel={() => setShowModal(false)}
onOk={handleModleFinish}
>
<Form form={form} labelCol={{ span: 6 }} name="b" onFinish={handleSubmit}>
<Form.Item label="平台" name="platformName" rules={[]}>
<Select style={{ width: "80%" }} onChange={changePlatformSelect}>
{Object.keys(platformConf).map((platformName) => (
<Select.Option key={platformName} value={platformName}>
{platformConf[platformName].name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="账号"
name="target"
rules={[
{ required: hasTarget, message: "请输入账号" },
{
validator: async (_, value) => {
try {
const res = await getTargetName(
form.getFieldValue("platformName"),
value
);
if (res.targetName) {
form.setFieldsValue({
targetName: res.targetName,
});
return Promise.resolve();
} else {
form.setFieldsValue({
targetName: "",
});
return Promise.reject("账号不正确,请重新检查账号");
}
} catch {
return Promise.reject("服务器错误,请稍后再试");
}
},
},
]}
>
<Input
placeholder={hasTarget ? "获取方式见文档" : "此平台不需要账号"}
disabled={!hasTarget}
style={{ width: "80%" }}
/>
</Form.Item>
<Form.Item label="账号名称" name="targetName">
<Input style={{ width: "80%" }} disabled />
</Form.Item>
<Form.Item
label="订阅分类"
name="cats"
rules={[
{
required: Object.keys(categories).length > 0,
message: "请至少选择一个分类进行订阅",
},
]}
>
<Select
style={{ width: "80%" }}
mode="multiple"
disabled={Object.keys(categories).length === 0}
placeholder={
Object.keys(categories).length > 0
? "请选择要订阅的分类"
: "本平台不支持分类"
}
>
{Object.keys(categories).length > 0 &&
Object.keys(categories).map((indexStr) => (
<Select.Option key={indexStr} value={parseInt(indexStr)}>
{categories[parseInt(indexStr)]}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="订阅Tag" name="tags">
<InputTagCustom disabled={!enabledTag} />
</Form.Item>
</Form>
</Modal>
);
}

View File

@ -1,151 +0,0 @@
import { Input, Tag, Tooltip } from "antd";
import { PresetColorType, PresetStatusColorType } from "antd/lib/_util/colors";
import { LiteralUnion } from "antd/lib/_util/type";
import React, { useRef, useState, useEffect } from "react";
import { PlusOutlined } from "@ant-design/icons";
interface InputTagProp {
value?: Array<string>;
onChange?: (value: Array<string>) => void;
color?: LiteralUnion<PresetColorType | PresetStatusColorType, string>;
addText?: string;
}
export function InputTag(prop: InputTagProp) {
const [value, setValue] = useState<Array<string>>(prop.value || []);
const [inputVisible, setInputVisible] = useState(false);
const [inputValue, setInputValue] = useState("");
const [editInputIndex, setEditInputIndex] = useState(-1);
const [editInputValue, setEditInputValue] = useState("");
const inputRef = useRef(null as any);
const editInputRef = useRef(null as any);
useEffect(() => {
if (prop.value) {
setValue(prop.value);
}
}, [prop.value]);
useEffect(() => {
if (inputVisible) {
inputRef.current.focus();
}
}, [inputVisible]);
useEffect(() => {
if (editInputIndex !== -1) {
editInputRef.current.focus();
}
}, [editInputIndex]);
const handleClose = (removedTag: string) => {
const tags = value.filter((tag) => tag !== removedTag);
setValue((_) => tags);
if (prop.onChange) {
prop.onChange(tags);
}
};
const showInput = () => {
setInputVisible((_) => true);
};
const handleInputConfirm = () => {
if (inputValue && value.indexOf(inputValue) === -1) {
const newVal = [...value, inputValue];
setValue((_) => newVal);
if (prop.onChange) {
prop.onChange(newVal);
}
}
setInputVisible((_) => false);
setInputValue((_) => "");
};
const handleEditInputChange = (e: any) => {
setEditInputValue((_) => e.target.value);
};
const handleEditInputConfirm = () => {
const newTags = value.slice();
newTags[editInputIndex] = editInputValue;
setValue((_) => newTags);
if (prop.onChange) {
prop.onChange(newTags);
}
setEditInputIndex((_) => -1);
setEditInputValue((_) => "");
};
const handleInputChange = (e: any) => {
setInputValue(e.target.value);
};
return (
<>
{value.map((tag, index) => {
if (editInputIndex === index) {
return (
<Input
ref={editInputRef}
key={tag}
size="small"
value={editInputValue}
onChange={handleEditInputChange}
onBlur={handleEditInputConfirm}
onPressEnter={handleInputConfirm}
/>
);
}
const isLongTag = tag.length > 20;
const tagElem = (
<Tag
color={prop.color || "default"}
style={{ userSelect: "none" }}
key={tag}
closable
onClose={() => handleClose(tag)}
>
<span
onDoubleClick={(e) => {
setEditInputIndex((_) => index);
setEditInputValue((_) => tag);
e.preventDefault();
}}
>
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
</span>
</Tag>
);
return isLongTag ? (
<Tooltip title={tag} key={tag}>
{tagElem}
</Tooltip>
) : (
tagElem
);
})}
{inputVisible && (
<Input
ref={inputRef}
type="text"
size="small"
style={{ width: "78px", marginRight: "8px", verticalAlign: "top" }}
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputConfirm}
onPressEnter={handleInputConfirm}
/>
)}
{!inputVisible && (
<Tag
className="site-tag-plus"
onClick={showInput}
style={{
background: "#fff",
border: "dashed thin",
borderColor: "#bfbfbf",
}}
>
<PlusOutlined /> {prop.addText || "Add Tag"}
</Tag>
)}
</>
);
}

View File

@ -1,190 +0,0 @@
import { CopyOutlined, DeleteOutlined, EditOutlined } from "@ant-design/icons";
import {
Card,
Col,
Form,
message,
Popconfirm,
Select,
Tag,
Tooltip,
} from "antd";
import Modal from "antd/lib/modal/Modal";
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { addSubscribe, delSubscribe } from "src/api/config";
import { platformConfSelector } from "src/store/globalConfSlice";
import {
groupConfigSelector,
updateGroupSubs,
} from "src/store/groupConfigSlice";
import { PlatformConfig, SubscribeConfig, SubscribeResp } from "src/utils/type";
import { AddModal } from "./addSubsModal";
interface CopyModalProp {
setShowModal: (modalShow: boolean) => void;
showModal: boolean;
config: SubscribeConfig;
groups: SubscribeResp;
currentGroupNumber: string;
reload: () => void;
}
function CopyModal({
setShowModal,
config,
currentGroupNumber,
groups,
showModal,
reload,
}: CopyModalProp) {
const [confirmLoading, setConfirmLoading] = useState(false);
const [selectedGroups, setSelectGroups] = useState<Array<string>>([]);
const postReqs = async (
selectedGroups: Array<string>,
config: SubscribeConfig
) => {
for (let selectedGroup of selectedGroups) {
await addSubscribe(selectedGroup, config);
}
};
const handleOk = () => {
if (selectedGroups.length === 0) {
message.error("请至少选择一个目标群");
} else {
setConfirmLoading(true);
postReqs(selectedGroups, config).then(() => {
setConfirmLoading(false);
setShowModal(false);
return reload();
});
}
};
return (
<Modal
title="复制订阅"
visible={showModal}
confirmLoading={confirmLoading}
onCancel={() => setShowModal(false)}
onOk={handleOk}
>
<Select
mode="multiple"
onChange={(value: Array<string>) => setSelectGroups(value)}
style={{ width: "80%" }}
>
{Object.keys(groups)
.filter((groupNumber) => groupNumber !== currentGroupNumber)
.map((groupNumber) => (
<Select.Option value={groupNumber} key={groupNumber}>
{`${groupNumber} - ${groups[groupNumber].name}`}
</Select.Option>
))}
</Select>
</Modal>
);
}
interface SubscribeCardProp {
groupNumber: string;
config: SubscribeConfig;
}
export function SubscribeCard({ groupNumber, config }: SubscribeCardProp) {
const platformConfs = useSelector(platformConfSelector);
const [showModal, setShowModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const platformConf = platformConfs[config.platformName] as PlatformConfig;
const dispatcher = useDispatch();
const groupSubscribes = useSelector(groupConfigSelector);
const reload = () => dispatcher(updateGroupSubs());
const handleDelete =
(groupNumber: string, platformName: string, target: string) => () => {
delSubscribe(groupNumber, platformName, target).then(() => {
reload();
});
};
return (
<Col span={8} key={`${config.platformName}-${config.target}`}>
<Card
title={`${platformConf.name} - ${config.targetName}`}
actions={[
<Tooltip title="编辑">
<EditOutlined
onClick={() => {
setShowEditModal((state) => !state);
}}
/>
</Tooltip>,
<Tooltip title="添加到其他群">
<CopyOutlined
onClick={() => {
setShowModal((state) => !state);
}}
/>
</Tooltip>,
<Popconfirm
title={`确定要删除 ${platformConf.name} - ${config.targetName}`}
onConfirm={handleDelete(
groupNumber,
config.platformName,
config.target || "default"
)}
>
<Tooltip title="删除">
<DeleteOutlined />
</Tooltip>
</Popconfirm>,
]}
>
<Form labelCol={{ span: 4 }}>
<Form.Item label="订阅帐号">
{platformConf.hasTarget ? (
config.target
) : (
<Tag color="default"></Tag>
)}
</Form.Item>
<Form.Item label="订阅类型">
{Object.keys(platformConf.categories).length > 0 ? (
config.cats.map((catKey: number) => (
<Tag color="green" key={catKey}>
{platformConf.categories[catKey]}
</Tag>
))
) : (
<Tag color="default"></Tag>
)}
</Form.Item>
<Form.Item label="订阅Tag">
{platformConf.enabledTag ? (
config.tags.length > 0 ? (
config.tags.map((tag) => (
<Tag color="green" key={tag}>
{tag}
</Tag>
))
) : (
<Tag color="blue"></Tag>
)
) : (
<Tag color="default">Tag</Tag>
)}
</Form.Item>
</Form>
</Card>
<CopyModal
setShowModal={setShowModal}
reload={reload}
currentGroupNumber={groupNumber}
showModal={showModal}
config={config}
groups={groupSubscribes}
/>
<AddModal
showModal={showEditModal}
setShowModal={setShowEditModal}
groupNumber={groupNumber}
refresh={reload}
initVal={config}
/>
</Col>
);
}

View File

@ -0,0 +1,25 @@
import React, { useEffect } from 'react';
import { Navigate, useParams } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../app/hooks';
import { login, selectIsFailed, selectIsLogin } from './authSlice';
export default function Auth() {
const isLogin = useAppSelector(selectIsLogin);
const dispatch = useAppDispatch();
const { code } = useParams();
const isFailed = useAppSelector(selectIsFailed);
useEffect(() => {
if (!isLogin && code) {
dispatch(login(code));
}
}, [isLogin, code]);
if (isLogin) {
return <Navigate to="/home" />;
}
if (isFailed) {
return <Navigate to="/unauthed" />;
}
return <div> login </div>;
}

View File

@ -0,0 +1,33 @@
import {
BaseQueryFn, FetchArgs, fetchBaseQuery, FetchBaseQueryError,
} from '@reduxjs/toolkit/dist/query';
import { RootState } from '../../app/store';
import { baseUrl } from '../../utils/urls';
import { setLogout } from './authSlice';
const baseQuery = fetchBaseQuery({
baseUrl,
prepareHeaders: (headers, { getState }) => {
const { token } = (getState() as RootState).auth;
if (token) {
headers.set('authorization', `Bearer ${token}`);
}
return headers;
},
});
export const baseQueryWithAuth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
const result = await baseQuery(args, api, extraOptions);
if (result.error && result.error.status === 401) {
api.dispatch(setLogout());
}
return result;
};
export default baseQueryWithAuth;

View File

@ -0,0 +1,70 @@
import {
CaseReducer, createAsyncThunk, createSlice, PayloadAction,
} from '@reduxjs/toolkit';
import { RootState } from '../../app/store';
import { TokenResp } from '../../utils/type';
import { authUrl } from '../../utils/urls';
export interface AuthStatus {
login: boolean;
token: string;
failed: boolean;
userType: string;
id: number;
}
const initialState = {
login: false,
failed: false,
token: '',
userType: '',
id: 0,
} as AuthStatus;
export const login = createAsyncThunk(
'auth/login',
async (code: string) => {
const res = await fetch(`${authUrl}?token=${code}`);
return (await res.json()) as TokenResp;
},
);
const setLoginReducer: CaseReducer<AuthStatus, PayloadAction<TokenResp>> = (state, action) => {
if (action.payload.status === 200) {
state.login = true;
state.id = action.payload.id;
state.userType = action.payload.type;
state.token = action.payload.token;
} else {
state.login = false;
state.failed = true;
}
};
export const setLogoutReducer: CaseReducer<AuthStatus> = (state) => {
state.login = false;
};
export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setLogin: setLoginReducer,
setLogout: setLogoutReducer,
},
extraReducers(builder) {
builder
.addCase(login.pending, (state) => {
state.failed = false;
})
.addCase(login.fulfilled, setLoginReducer)
.addCase(login.rejected, setLogoutReducer);
},
});
export const { setLogin, setLogout } = authSlice.actions;
export const selectIsLogin = (state: RootState) => state.auth.login;
export const selectIsFailed = (state: RootState) => state.auth.failed;
export default authSlice.reducer;

View File

@ -0,0 +1,35 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { RootState } from '../../app/store';
import { GlobalConf } from '../../utils/type';
import { globalConfUrl } from '../../utils/urls';
const initialState = {
loaded: false,
platformConf: {},
} as GlobalConf;
export const loadGlobalConf = createAsyncThunk(
'globalConf/load',
async () => {
const res = await fetch(globalConfUrl);
return (await res.json()) as GlobalConf;
},
);
export const globalConfSlice = createSlice({
name: 'globalConf',
initialState,
reducers: {},
extraReducers(builder) {
builder
.addCase(loadGlobalConf.fulfilled, (state, payload) => {
state.platformConf = payload.payload.platformConf;
state.loaded = true;
});
},
});
export default globalConfSlice.reducer;
export const selectGlobalConfLoaded = (state: RootState) => state.globalConf.loaded;
export const selectPlatformConf = (state: RootState) => state.globalConf.platformConf;

View File

@ -0,0 +1,36 @@
import React from 'react';
import {
Card, Typography, Grid, Button,
} from '@arco-design/web-react';
import { Link } from 'react-router-dom';
import { useGetSubsQuery } from './subscribeConfigSlice';
export default function GroupManager() {
const { data: subs } = useGetSubsQuery();
return (
<>
<Typography.Title heading={4} style={{ margin: '15px' }}></Typography.Title>
<div>
{ subs && (
<Grid.Row gutter={20}>
{ Object.keys(subs).map(
(groupNumber: string) => (
<Grid.Col span={6} key={groupNumber}>
<Card
title={subs[groupNumber].name}
actions={[
<Link to={`/home/groups/${groupNumber}`}><Button></Button></Link>,
<Button type="primary"></Button>,
]}
>
<div>{groupNumber}</div>
</Card>
</Grid.Col>
),
)}
</Grid.Row>
)}
</div>
</>
);
}

View File

@ -0,0 +1,121 @@
import React, { useState } from 'react';
import {
Button, Empty, Message, Popconfirm, Space, Table, Tag, Typography,
} from '@arco-design/web-react';
import { useParams } from 'react-router-dom';
import { useDeleteSubMutation, useGetSubsQuery } from './subscribeConfigSlice';
import { useAppSelector } from '../../app/hooks';
import { selectPlatformConf } from '../globalConf/globalConfSlice';
import { SubscribeConfig } from '../../utils/type';
import SubscribeModal from './SubscribeModal';
export default function SubscribeManager() {
const { data: subs } = useGetSubsQuery();
const [deleteSub, { isLoading: deleteIsLoading }] = useDeleteSubMutation();
const { groupNumber } = useParams();
const platformConf = useAppSelector(selectPlatformConf);
const isLoading = deleteIsLoading;
const [showModal, setShowModal] = useState(false);
const [formInitVal, setFormInitVal] = useState(null as SubscribeConfig | null);
const handleNewSub = () => {
setFormInitVal(null);
setShowModal(true);
};
const handleEdit = (sub: SubscribeConfig) => () => {
setFormInitVal(sub);
setShowModal(true);
};
const columns = [
{
title: '平台名称',
dataIndex: 'platformName',
render: (col: string, record: SubscribeConfig) => (
<span>{platformConf[record.platformName].name}</span>
),
},
{ title: '帐号名称', dataIndex: 'targetName' },
{ title: '订阅帐号', dataIndex: 'target' },
{
title: '订阅分类',
dataIndex: 'cats',
render: (col: string[], record: SubscribeConfig) => (
<span>
<Space>
{
record.cats.map((catNumber: number) => (
<Tag>{platformConf[record.platformName].categories[catNumber]}</Tag>
))
}
</Space>
</span>
),
},
{
title: '订阅标签',
dataIndex: 'tags',
render: (col: string[], record: SubscribeConfig) => (
<span>
<Space>
{
record.tags.length === 0 ? <Tag color="green"></Tag>
: record.tags.map((tag: string) => (
<Tag color="blue">{tag}</Tag>
))
}
</Space>
</span>
),
},
{
title: '操作',
dataIndex: 'op',
render: (_: null, record: SubscribeConfig) => (
<Space>
<Button type="text" onClick={handleEdit(record)}></Button>
<Button type="text" status="success" onClick={() => Message.error('懒得写了')}></Button>
<Popconfirm
title={`确认删除订阅 ${record.targetName} ?`}
onOk={() => {
deleteSub({
groupNumber: parseInt(groupNumber || '0', 10),
target: record.target,
platformName: record.platformName,
});
}}
>
<Button type="text" status="danger"></Button>
</Popconfirm>
</Space>
),
},
];
if (subs && groupNumber) {
return (
<>
<span>
<Typography.Title heading={3}>{subs[groupNumber].name}</Typography.Title>
<Typography.Text type="secondary">{groupNumber}</Typography.Text>
</span>
<Button style={{ width: '90px', margin: '20px 10px' }} type="primary" onClick={handleNewSub}></Button>
<Table
columns={columns}
data={subs[groupNumber].subscribes}
rowKey={(record: SubscribeConfig) => `${record.platformName}-${record.target}`}
loading={isLoading}
/>
<SubscribeModal
visible={showModal}
setVisible={setShowModal}
groupNumber={groupNumber}
initval={formInitVal}
/>
</>
);
}
return <Empty />;
}

View File

@ -0,0 +1,241 @@
import React, { useEffect, useState } from 'react';
import {
Form, Input, InputTag, Modal, Select, Space, Tag,
} from '@arco-design/web-react';
import useForm from '@arco-design/web-react/es/Form/useForm';
import { IconInfoCircle } from '@arco-design/web-react/icon';
import { useAppSelector } from '../../app/hooks';
import { selectPlatformConf } from '../globalConf/globalConfSlice';
import { CategoryConfig, SubscribeConfig } from '../../utils/type';
import getTargetName from '../targetName/targetNameReq';
import { useUpdateSubMutation, useNewSubMutation } from './subscribeConfigSlice';
function SubscribeTag({
value, onChange, disabled,
}: {
value?: string[];
onChange?: (arg0: string[]) => void;
disabled?: boolean;
}) {
const [valueState, setValueState] = useState(value || []);
const handleSetValue = (newVal: string[]) => {
setValueState(newVal);
if (onChange) {
onChange(newVal);
}
};
useEffect(() => {
if (value) {
setValueState(value);
}
}, [value]);
if (disabled) {
return <Tag color="gray"></Tag>;
}
return (
<Space>
{ valueState.length === 0 && <Tag color="green"></Tag> }
<InputTag
allowClear
placeholder="添加标签"
value={value}
onChange={handleSetValue}
/>
</Space>
);
}
SubscribeTag.defaultProps = {
value: [],
onChange: null,
disabled: false,
};
interface SubscribeModalProp {
visible: boolean;
setVisible: (arg0: boolean) => void;
groupNumber: string;
initval?: SubscribeConfig | null;
}
function SubscribeModal({
visible, setVisible, groupNumber, initval,
}: SubscribeModalProp) {
const [form] = useForm();
const [confirmLoading, setConfirmLoading] = useState(false);
const platformConf = useAppSelector(selectPlatformConf);
const [updateSub] = useUpdateSubMutation();
const [newSub] = useNewSubMutation();
const onSubmit = () => {
form.validate().then((value: SubscribeConfig) => {
const newVal = { ...value };
if (typeof newVal.tags !== 'object') {
newVal.tags = [];
}
if (typeof newVal.cats !== 'object') {
newVal.cats = [];
}
if (newVal.target === '') {
newVal.target = 'default';
}
let postPromise: ReturnType<typeof updateSub>;
if (initval) {
postPromise = updateSub({
groupNumber: parseInt(groupNumber, 10),
sub: newVal,
});
} else {
postPromise = newSub({
groupNumber: parseInt(groupNumber, 10),
sub: newVal,
});
}
setConfirmLoading(true);
postPromise.then(() => {
setConfirmLoading(false);
setVisible(false);
form.clearFields();
});
});
};
const [hasTarget, setHasTarget] = useState(false);
const [categories, setCategories] = useState({} as CategoryConfig);
const [enableTags, setEnableTags] = useState(false);
const setPlatformStates = (platform: string) => {
setHasTarget(platformConf[platform].hasTarget);
setCategories(platformConf[platform].categories);
setEnableTags(platformConf[platform].enabledTag);
};
const handlePlatformSelected = (platform: string) => {
setPlatformStates(platform);
form.setFieldValue('cats', []);
if (!platformConf[platform].hasTarget) {
getTargetName(platform, 'default').then((res) => {
form.setFieldsValue({
targetName: res,
target: '',
});
});
} else {
form.setFieldsValue({
targetName: '',
target: '',
});
}
};
useEffect(() => {
if (initval) {
const { platformName } = initval;
setPlatformStates(platformName);
form.setFieldsValue(initval);
} else {
form.clearFields();
}
}, [initval, form, platformConf]);
return (
<Modal
title="编辑订阅"
visible={visible}
onCancel={() => setVisible(false)}
confirmLoading={confirmLoading}
onOk={onSubmit}
>
<Form
form={form}
>
<Form.Item label="平台" field="platformName">
<Select placeholder="平台" onChange={handlePlatformSelected}>
{ Object.keys(platformConf).map(
(platformName: string) => (
<Select.Option value={platformName} key={platformName}>
{platformConf[platformName].name}
</Select.Option>
),
) }
</Select>
</Form.Item>
<Form.Item
label="帐号"
field="target"
rules={[
{ required: hasTarget, message: '请输入账号' },
{
validator: (value, callback) => new Promise<void>((resolve) => {
getTargetName(form.getFieldValue('platformName'), value)
.then((res) => {
if (res) {
form.setFieldsValue({
targetName: res,
});
resolve();
} else {
form.setFieldsValue({
targetName: '',
});
callback('账号不正确,请重新检查账号');
resolve();
}
})
.catch(() => {
callback('服务器错误,请稍后再试');
resolve();
});
}),
},
]}
>
<Input
disabled={!hasTarget}
suffix={<IconInfoCircle />}
placeholder={hasTarget ? '获取方式见文档' : '此平台不需要账号'}
/>
</Form.Item>
<Form.Item label="帐号名称" field="targetName">
<Input disabled />
</Form.Item>
<Form.Item
label="订阅分类"
field="cats"
rules={[
{
required: Object.keys(categories).length > 0,
message: '请至少选择一个分类进行订阅',
},
]}
>
<Select
mode="multiple"
disabled={Object.keys(categories).length === 0}
placeholder={
Object.keys(categories).length > 0
? '请选择要订阅的分类'
: '本平台不支持分类'
}
>
{ Object.keys(categories).length > 0
&& Object.keys(categories).map((indexStr) => (
<Select.Option key={indexStr} value={parseInt(indexStr, 10)}>
{ categories[parseInt(indexStr, 10)] }
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="标签" field="tags">
<SubscribeTag disabled={!enableTags} />
</Form.Item>
</Form>
</Modal>
);
}
SubscribeModal.defaultProps = {
initval: null,
};
export default SubscribeModal;

View File

@ -0,0 +1,45 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import {
StatusResp, SubmitParam, SubscribeResp,
} from '../../utils/type';
import { baseQueryWithAuth } from '../auth/authQuery';
export const subscribeApi = createApi({
reducerPath: 'subscribe',
baseQuery: baseQueryWithAuth,
tagTypes: ['Subscribe'],
endpoints: (builder) => ({
getSubs: builder.query<SubscribeResp, void>({
query: () => '/subs',
providesTags: ['Subscribe'],
}),
newSub: builder.mutation<StatusResp, SubmitParam>({
query: ({ groupNumber, sub }) => ({
method: 'POST',
url: `/subs?groupNumber=${groupNumber}`,
body: sub,
}),
invalidatesTags: ['Subscribe'],
}),
updateSub: builder.mutation<StatusResp, SubmitParam>({
query: ({ groupNumber, sub }) => ({
method: 'PATCH',
url: `/subs?groupNumber=${groupNumber}`,
body: sub,
}),
invalidatesTags: ['Subscribe'],
}),
deleteSub: builder.mutation<StatusResp,
{ groupNumber: number; target: string; platformName: string }>({
query: ({ groupNumber, target, platformName }) => ({
method: 'DELETE',
url: `/subs?groupNumber=${groupNumber}&target=${target}&platformName=${platformName}`,
}),
invalidatesTags: ['Subscribe'],
}),
}),
});
export const {
useGetSubsQuery, useNewSubMutation, useDeleteSubMutation, useUpdateSubMutation,
} = subscribeApi;

View File

@ -0,0 +1,15 @@
import { RootState, store } from '../../app/store';
import { baseUrl } from '../../utils/urls';
export default async function getTargetName(platformName: string, target: string) {
const url = `${baseUrl}target_name?platformName=${platformName}&target=${target}`;
const state = store.getState() as RootState;
const authToken = state.auth.token;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
const resObj = await res.json();
return resObj.targetName as string;
}

View File

@ -0,0 +1,14 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import baseQueryWithAuth from '../auth/authQuery';
export const targetNameApi = createApi({
reducerPath: 'targetName',
baseQuery: baseQueryWithAuth,
endpoints: (builder) => ({
getTargetName: builder.query<{targetName: string}, {target: string; platformName: string}>({
query: () => '/target_name',
}),
}),
});
export const { useGetTargetNameQuery } = targetNameApi;

View File

@ -0,0 +1,32 @@
import React from 'react';
// import { WeightConfig } from '../../utils/type';
// import { useGetWeightQuery, useUpdateWeightMutation } from './weightConfigSlice';
//
// export default function WeightManager() {
// const { data: weight } = useGetWeightQuery();
// const [updateWeight] = useUpdateWeightMutation();
//
// const doUpdate = () => {
// const weightConfig: WeightConfig = {
// default: 20,
// time_config: [
// {
// start_time: '01:00',
// end_time: '02:00',
// weight: 50,
// },
// ],
// };
// updateWeight({ weight: weightConfig, platform_name: 'weibo', target: '' });
// };
// return (
// <>
// <div>{weight && JSON.stringify(weight)}</div>
// <button type="button" onClick={doUpdate}> 123</button>
// </>
// );
// }
export default function WeightConfig() {
return <div></div>;
}

View File

@ -0,0 +1,28 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { PlatformWeightConfigResp, StatusResp } from '../../utils/type';
import baseQueryWithAuth from '../auth/authQuery';
export const weightApi = createApi({
reducerPath: 'weight',
baseQuery: baseQueryWithAuth,
tagTypes: ['Weight'],
endpoints: (builder) => ({
getWeight: builder.query<PlatformWeightConfigResp, void>({
query: () => '/weight',
providesTags: ['Weight'],
}),
updateWeight: builder.mutation<StatusResp,
Pick<PlatformWeightConfigResp, 'platform_name' | 'target' | 'weight' >>({
query: ({ platform_name: platformName, target, weight }) => ({
method: 'PUT',
url: `/weight?platform_name=${platformName}&target=${target}`,
body: weight,
}),
invalidatesTags: ['Weight'],
}),
}),
});
export const {
useGetWeightQuery, useUpdateWeightMutation,
} = weightApi;

View File

@ -1,20 +1,25 @@
import React from "react"; import React from 'react';
import ReactDOM from "react-dom"; import { createRoot } from 'react-dom/client';
import { Provider } from "react-redux"; import { Provider } from 'react-redux';
import App from "./App"; import { PersistGate } from 'redux-persist/integration/react';
import "./index.css"; import App from './App';
import reportWebVitals from "./reportWebVitals"; import { persistor, store } from './app/store';
import store from "./store"; import './index.css';
import { injectStore } from "src/api/utils"; import reportWebVitals from './reportWebVitals';
import '@arco-design/web-react/dist/css/arco.css';
injectStore(store); // eslint-disable-next-line
ReactDOM.render( const container = document.getElementById('root')!;
const root = createRoot(container);
root.render(
<React.StrictMode> <React.StrictMode>
<Provider store={store}> <Provider store={store}>
<App /> <PersistGate loading={null} persistor={persistor}>
<App />
</PersistGate>
</Provider> </Provider>
</React.StrictMode>, </React.StrictMode>,
document.getElementById("root")
); );
// If you want to start measuring performance in your app, pass a function // If you want to start measuring performance in your app, pass a function

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g fill="#764ABC"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,52 @@
.layout-collapse-demo {
height: 100vh;
border: 1px solid var(--color-border);
background: var(--color-fill-2);
}
.layout-collapse-demo .arco-layout-header .logo {
height: 32px;
margin: 12px 8px;
width: 150px;
background: var(--color-fill-2);
}
.layout-collapse-demo .arco-layout-header span {
height: 100%;
line-height: 100%;
font-size: 20px;
margin: 0 20px;
}
.layout-collapse-demo .arco-layout-content .arco-layout-footer,
.layout-collapse-demo .arco-layout-content .arco-layout-content {
color: var(--color-white);
/* text-align: center; */
font-stretch: condensed;
font-size: 16px;
display: flex;
flex-direction: column;
/* justify-content: center; */
}
.layout-collapse-demo .arco-layout-footer {
color: var(--color-text-2);
height: 48px;
line-height: 48px;
font-weight: 400;
font-size: 14px;
}
.layout-collapse-demo .arco-layout-content .arco-layout-content {
background: var(--color-bg-3);
color: var(--color-text-2);
font-weight: 400;
font-size: 14px;
height: 100%;
}
.layout-collapse-demo .arco-layout-header {
height: 64px;
line-height: 64px;
background: var(--color-bg-3);
}

View File

@ -0,0 +1,118 @@
import React, { ReactNode, useEffect, useState } from 'react';
import { Breadcrumb, Layout, Menu } from '@arco-design/web-react';
import { IconRobot, IconDashboard } from '@arco-design/web-react/icon';
import './Home.css';
// import SubscribeManager from '../features/subsribeConfigManager/SubscribeManager';
import {
Link, Navigate, Outlet, useLocation, useNavigate,
} from 'react-router-dom';
import { useAppSelector } from '../app/hooks';
import { selectIsLogin } from '../features/auth/authSlice';
export default function Home() {
const location = useLocation();
const navigate = useNavigate();
const isLogin = useAppSelector(selectIsLogin);
const path = location.pathname;
useEffect(() => {
if (path === '/home') {
navigate('/home/groups');
}
if (path !== '/home/groups' && !path.startsWith('/home/groups/') && path !== '/home/weight') {
navigate('/home/groups');
}
}, [path]);
let currentKey = '';
if (path === '/home/groups') {
currentKey = 'groups';
} else if (path.startsWith('/home/groups/')) {
currentKey = 'subs';
}
const [selectedTab, changeSelectTab] = useState(currentKey);
const handleTabSelect = (tab: string) => {
changeSelectTab(tab);
if (tab === 'groups') {
navigate('/home/groups');
} else if (tab === 'weight') {
navigate('/home/weight');
}
};
if (!isLogin) {
return <Navigate to="/unauthed" />;
}
let breadcrumbContent: ReactNode;
if (path === '/home/groups') {
breadcrumbContent = (
<Breadcrumb style={{ margin: '16px 0' }}>
<Breadcrumb.Item>
<IconRobot />
</Breadcrumb.Item>
</Breadcrumb>
);
} else if (path.startsWith('/home/groups/')) {
breadcrumbContent = (
<Breadcrumb style={{ margin: '16px 0' }}>
<Breadcrumb.Item>
<Link to="/home/groups">
<IconDashboard />
</Link>
</Breadcrumb.Item>
<Breadcrumb.Item>
</Breadcrumb.Item>
</Breadcrumb>
);
} else if (path === '/home/weight') {
breadcrumbContent = (
<Breadcrumb style={{ margin: '16px 0' }}>
<Breadcrumb.Item>
<IconDashboard />
</Breadcrumb.Item>
</Breadcrumb>
);
}
return (
<Layout className="layout-collapse-demo">
<Layout.Header>
<span>
Nonebot Bison
</span>
</Layout.Header>
<Layout className="layout-collapse-demo">
<Layout.Sider>
<Menu
defaultSelectedKeys={[selectedTab]}
onClickMenuItem={(key) => { handleTabSelect(key); }}
>
<Menu.Item key="groups">
<IconRobot />
</Menu.Item>
<Menu.Item key="weight">
<IconDashboard />
</Menu.Item>
</Menu>
</Layout.Sider>
<Layout.Content style={{ padding: '0 24px' }}>
<Layout style={{ height: '100%' }}>
{ breadcrumbContent }
<Layout.Content style={{ margin: '10px', padding: '40px' }}>
<Outlet />
</Layout.Content>
</Layout>
</Layout.Content>
</Layout>
</Layout>
);
}

View File

@ -0,0 +1,7 @@
import React from 'react';
export default function Unauthed() {
return (
<div>not login</div>
);
}

View File

@ -1,5 +0,0 @@
.layout-side .user {
height: 32px;
margin: 16px;
background: rgba(255, 255, 255, 0.3);
}

View File

@ -1,73 +0,0 @@
import { Button, Collapse, Empty, Row } from "antd";
import React, { ReactElement, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { AddModal } from "src/component/addSubsModal";
import { SubscribeCard } from "src/component/subscribeCard";
import {
groupConfigSelector,
updateGroupSubs,
} from "src/store/groupConfigSlice";
interface ConfigPageProp {
tab: string;
}
export function ConfigPage(prop: ConfigPageProp) {
const [showModal, setShowModal] = useState<boolean>(false);
const [currentAddingGroupNumber, setCurrentAddingGroupNumber] = useState("");
const configData = useSelector(groupConfigSelector);
const dispatcher = useDispatch();
useEffect(() => {
dispatcher(updateGroupSubs());
}, [prop.tab, dispatcher]);
const clickNew =
(groupNumber: string) => (e: React.MouseEvent<HTMLButtonElement>) => {
setShowModal((_) => true);
setCurrentAddingGroupNumber(groupNumber);
e.stopPropagation();
};
if (Object.keys(configData).length === 0) {
return <Empty />;
} else {
let groups: Array<ReactElement> = [];
for (let key of Object.keys(configData)) {
let value = configData[key];
groups.push(
<Collapse.Panel
key={key}
header={
<span>
{`${key} - ${value.name}`}
<Button style={{ float: "right" }} onClick={clickNew(key)}>
</Button>
</span>
}
>
<Row
gutter={[
{ xs: 8, sm: 16, md: 24, lg: 32 },
{ xs: 8, sm: 16, md: 24, lg: 32 },
]}
align="middle"
>
{value.subscribes.map((subs, idx) => (
<SubscribeCard key={idx} groupNumber={key} config={subs} />
))}
</Row>
</Collapse.Panel>
);
}
return (
<div>
<Collapse>{groups}</Collapse>
<AddModal
groupNumber={currentAddingGroupNumber}
showModal={showModal}
refresh={() => dispatcher(updateGroupSubs())}
setShowModal={(s: boolean) => setShowModal((_) => s)}
/>
</div>
);
}
}

View File

@ -1,39 +0,0 @@
import { BugOutlined, SettingOutlined } from "@ant-design/icons";
import { Layout, Menu } from "antd";
import React, { useState } from "react";
import { useSelector } from "react-redux";
import { loginSelector } from "src/store/loginSlice";
import "./admin.css";
import { ConfigPage } from "./configPage";
export function Admin() {
const login = useSelector(loginSelector);
const [tab, changeTab] = useState("manage");
return (
<Layout style={{ minHeight: "100vh" }}>
<Layout.Sider className="layout-side">
<div className="user"></div>
<Menu
mode="inline"
theme="dark"
defaultSelectedKeys={[tab]}
onClick={({ key }) => changeTab(key)}
>
<Menu.Item key="manage" icon={<SettingOutlined />}>
</Menu.Item>
{login.type === "admin" && (
<Menu.Item key="log" icon={<BugOutlined />}>
</Menu.Item>
)}
</Menu>
</Layout.Sider>
<Layout.Content>
<div style={{ margin: "24px", background: "#fff", minHeight: "640px" }}>
{tab === "manage" ? <ConfigPage tab={tab} /> : null}
</div>
</Layout.Content>
</Layout>
);
}

View File

@ -1,31 +0,0 @@
import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router";
import { Redirect } from "react-router-dom";
import { login, loginSelector } from "src/store/loginSlice";
interface AuthParam {
code: string;
}
export function Auth() {
const { code } = useParams<AuthParam>();
const dispatch = useDispatch();
const loginState = useSelector(loginSelector);
useEffect(() => {
const loginFun = async () => {
dispatch(login(code));
};
loginFun();
}, [code, dispatch]);
return (
<>
{loginState.login ? (
<Redirect to={{ pathname: "/admin" }} />
) : loginState.failed ? (
<div></div>
) : (
<div>Logining...</div>
)}
</>
);
}

View File

@ -1,8 +1,10 @@
import { ReportHandler } from "web-vitals"; import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => { const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) { if (onPerfEntry && onPerfEntry instanceof Function) {
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { import('web-vitals').then(({
getCLS, getFID, getFCP, getLCP, getTTFB,
}) => {
getCLS(onPerfEntry); getCLS(onPerfEntry);
getFID(onPerfEntry); getFID(onPerfEntry);
getFCP(onPerfEntry); getFCP(onPerfEntry);

View File

@ -2,4 +2,4 @@
// allows you to do things like: // allows you to do things like:
// expect(element).toHaveTextContent(/react/i) // expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom // learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom"; import '@testing-library/jest-dom/extend-expect';

View File

@ -1,44 +0,0 @@
import {
CaseReducer,
createAsyncThunk,
createSlice,
PayloadAction,
} from "@reduxjs/toolkit";
import { getGlobalConf as getGlobalConfApi } from "src/api/config";
import { GlobalConf } from "src/utils/type";
import { RootState } from ".";
const initialState: GlobalConf = {
platformConf: {},
loaded: false,
};
const setGlobalConf: CaseReducer<GlobalConf, PayloadAction<GlobalConf>> = (
_,
action
) => {
return { ...action.payload, loaded: true };
};
export const getGlobalConf = createAsyncThunk(
"globalConf/set",
getGlobalConfApi,
{
condition: (_, { getState }) =>
!(getState() as RootState).globalConf.loaded,
}
);
export const globalConfSlice = createSlice({
name: "globalConf",
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(getGlobalConf.fulfilled, setGlobalConf);
},
});
export const platformConfSelector = (state: RootState) =>
state.globalConf.platformConf;
export default globalConfSlice.reducer;

View File

@ -1,36 +0,0 @@
import {
CaseReducer,
createAsyncThunk,
createSlice,
PayloadAction,
} from "@reduxjs/toolkit";
import { SubscribeResp } from "src/utils/type";
import { getSubscribe } from "src/api/config";
import { RootState } from ".";
const initialState: SubscribeResp = {};
const setSubs: CaseReducer<SubscribeResp, PayloadAction<SubscribeResp>> = (
_,
action
) => {
return action.payload;
};
export const updateGroupSubs = createAsyncThunk(
"groupConfig/update",
getSubscribe
);
export const groupConfigSlice = createSlice({
name: "groupConfig",
initialState,
reducers: {
setSubs,
},
extraReducers: (reducer) => {
reducer.addCase(updateGroupSubs.fulfilled, setSubs);
},
});
export const groupConfigSelector = (state: RootState) => state.groupConfig;
export default groupConfigSlice.reducer;

View File

@ -1,5 +0,0 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import { AppDispatch, RootState } from ".";
export const useAppDispacher = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@ -1,18 +0,0 @@
import { configureStore } from "@reduxjs/toolkit";
import loginSlice from "./loginSlice";
import globalConfSlice from "./globalConfSlice";
import groupConfigSlice from "./groupConfigSlice";
const store = configureStore({
reducer: {
login: loginSlice,
globalConf: globalConfSlice,
groupConfig: groupConfigSlice,
},
});
export default store;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export type Store = typeof store;

View File

@ -1,121 +0,0 @@
import {
AnyAction,
CaseReducer,
createAsyncThunk,
createSlice,
PayloadAction,
ThunkAction,
} from "@reduxjs/toolkit";
import jwt_decode from "jwt-decode";
import { LoginStatus, TokenResp } from "src/utils/type";
import { auth } from "src/api/config";
import { RootState } from ".";
const initialState: LoginStatus = {
login: false,
type: "",
name: "",
id: "123",
// groups: [],
token: "",
failed: false,
};
interface storedInfo {
type: string;
name: string;
id: string;
}
const loginAction: CaseReducer<LoginStatus, PayloadAction<TokenResp>> = (
_,
action
) => {
return {
login: true,
failed: false,
type: action.payload.type,
name: action.payload.name,
id: action.payload.id,
token: action.payload.token,
};
};
export const login = createAsyncThunk(
"auth/login",
async (code: string) => {
let res = await auth(code);
if (res.status !== 200) {
throw Error("Login Error");
} else {
localStorage.setItem(
"loginInfo",
JSON.stringify({
type: res.type,
name: res.name,
id: res.id,
})
);
localStorage.setItem("token", res.token);
}
return res;
},
{
condition: (_: string, { getState }) => {
const { login } = getState() as { login: LoginStatus };
return !login.login;
},
}
);
export const loginSlice = createSlice({
name: "auth",
initialState,
reducers: {
doLogin: loginAction,
doClearLogin: (state) => {
state.login = false;
},
},
extraReducers: (builder) => {
builder.addCase(login.fulfilled, loginAction);
builder.addCase(login.rejected, (stat) => {
stat.failed = true;
});
},
});
export const { doLogin, doClearLogin } = loginSlice.actions;
export const loadLoginState =
(): ThunkAction<void, RootState, unknown, AnyAction> =>
(dispatch, getState) => {
if (getState().login.login) {
return;
}
const infoJson = localStorage.getItem("loginInfo");
const jwtToken = localStorage.getItem("token");
if (infoJson && jwtToken) {
const decodedJwt = jwt_decode(jwtToken) as { exp: number };
if (decodedJwt.exp < Date.now() / 1000) {
return;
}
const info = JSON.parse(infoJson) as storedInfo;
const payload: TokenResp = {
...info,
status: 200,
token: jwtToken,
};
dispatch(doLogin(payload));
}
};
export const clearLoginStatus =
(): ThunkAction<void, RootState, unknown, AnyAction> => (dispatch) => {
localStorage.removeItem("loginInfo");
localStorage.removeItem("token");
dispatch(doClearLogin());
};
export const loginSelector = (state: RootState) => state.login;
export default loginSlice.reducer;

View File

@ -1,31 +1,10 @@
interface QQGroup { export interface TokenResp {
id: string; status: number;
name: string;
}
export interface LoginStatus {
login: boolean;
type: string;
name: string;
id: string;
// groups: Array<QQGroup>
token: string; token: string;
failed: boolean; type: string;
id: number;
name: string;
} }
export type LoginContextType = {
login: LoginStatus;
save: (status: LoginStatus) => void;
};
export interface SubscribeConfig {
platformName: string;
target: string;
targetName: string;
cats: Array<number>;
tags: Array<string>;
}
export interface GlobalConf { export interface GlobalConf {
platformConf: AllPlatformConf; platformConf: AllPlatformConf;
loaded: boolean; loaded: boolean;
@ -47,12 +26,12 @@ export interface PlatformConfig {
hasTarget: boolean; hasTarget: boolean;
} }
export interface TokenResp { export interface SubscribeConfig {
status: number; platformName: string;
token: string; target: string;
type: string; targetName: string;
id: string; cats: Array<number>;
name: string; tags: Array<string>;
} }
export interface SubscribeGroupDetail { export interface SubscribeGroupDetail {
@ -64,6 +43,30 @@ export interface SubscribeResp {
[idx: string]: SubscribeGroupDetail; [idx: string]: SubscribeGroupDetail;
} }
export interface TargetNameResp { export interface StatusResp {
targetName: string; status: number;
msg: string;
}
export interface SubmitParam {
groupNumber: number;
sub: SubscribeConfig;
}
export interface TimeWeightConfig {
start_time: string;
end_time: string;
weight: number;
}
export interface WeightConfig {
default: number;
time_config: TimeWeightConfig[];
}
export interface PlatformWeightConfigResp {
target: string;
target_name: string;
platform_name: string;
weight: WeightConfig;
} }

View File

@ -0,0 +1,4 @@
export const baseUrl = '/bison/api/';
export const authUrl = `${baseUrl}auth`;
export const globalConfUrl = `${baseUrl}global_conf`;
export const subsribeUrl = `${baseUrl}subs`;

View File

@ -18,8 +18,7 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx"
"baseUrl": "./"
}, },
"include": [ "include": [
"src" "src"

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ from nonebot.rule import to_me
from nonebot.typing import T_State from nonebot.typing import T_State
from ..plugin_config import plugin_config from ..plugin_config import plugin_config
from ..types import WeightConfig
from .api import ( from .api import (
add_group_sub, add_group_sub,
auth, auth,
@ -21,8 +22,10 @@ from .api import (
get_global_conf, get_global_conf,
get_subs_info, get_subs_info,
get_target_name, get_target_name,
get_weight_config,
test, test,
update_group_sub, update_group_sub,
update_weigth_config,
) )
from .jwt import load_jwt from .jwt import load_jwt
from .token_manager import token_manager as tm from .token_manager import token_manager as tm
@ -32,6 +35,7 @@ GLOBAL_CONF_URL = f"{URL_BASE}api/global_conf"
AUTH_URL = f"{URL_BASE}api/auth" AUTH_URL = f"{URL_BASE}api/auth"
SUBSCRIBE_URL = f"{URL_BASE}api/subs" SUBSCRIBE_URL = f"{URL_BASE}api/subs"
GET_TARGET_NAME_URL = f"{URL_BASE}api/target_name" GET_TARGET_NAME_URL = f"{URL_BASE}api/target_name"
WEIGHT_URL = f"{URL_BASE}api/weight"
TEST_URL = f"{URL_BASE}test" TEST_URL = f"{URL_BASE}test"
STATIC_PATH = (Path(__file__).parent / "dist").resolve() STATIC_PATH = (Path(__file__).parent / "dist").resolve()
@ -66,7 +70,7 @@ def register_router_fastapi(driver: Driver, socketio):
return obj return obj
async def check_group_permission( async def check_group_permission(
groupNumber: str, token_obj: dict = Depends(get_jwt_obj) groupNumber: int, token_obj: dict = Depends(get_jwt_obj)
): ):
groups = token_obj["groups"] groups = token_obj["groups"]
for group in groups: for group in groups:
@ -74,6 +78,10 @@ def register_router_fastapi(driver: Driver, socketio):
return return
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
async def check_is_superuser(token_obj: dict = Depends(get_jwt_obj)):
if token_obj.get("type") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
@dataclass @dataclass
class AddSubscribeReq: class AddSubscribeReq:
platformName: str platformName: str
@ -99,7 +107,7 @@ def register_router_fastapi(driver: Driver, socketio):
return await get_target_name(platformName, target, jwt_obj) return await get_target_name(platformName, target, jwt_obj)
@app.post(SUBSCRIBE_URL, dependencies=[Depends(check_group_permission)]) @app.post(SUBSCRIBE_URL, dependencies=[Depends(check_group_permission)])
async def _add_group_subs(groupNumber: str, req: AddSubscribeReq): async def _add_group_subs(groupNumber: int, req: AddSubscribeReq):
return await add_group_sub( return await add_group_sub(
group_number=groupNumber, group_number=groupNumber,
platform_name=req.platformName, platform_name=req.platformName,
@ -110,7 +118,7 @@ def register_router_fastapi(driver: Driver, socketio):
) )
@app.patch(SUBSCRIBE_URL, dependencies=[Depends(check_group_permission)]) @app.patch(SUBSCRIBE_URL, dependencies=[Depends(check_group_permission)])
async def _update_group_subs(groupNumber: str, req: AddSubscribeReq): async def _update_group_subs(groupNumber: int, req: AddSubscribeReq):
return await update_group_sub( return await update_group_sub(
group_number=groupNumber, group_number=groupNumber,
platform_name=req.platformName, platform_name=req.platformName,
@ -121,9 +129,17 @@ def register_router_fastapi(driver: Driver, socketio):
) )
@app.delete(SUBSCRIBE_URL, dependencies=[Depends(check_group_permission)]) @app.delete(SUBSCRIBE_URL, dependencies=[Depends(check_group_permission)])
async def _del_group_subs(groupNumber: str, target: str, platformName: str): async def _del_group_subs(groupNumber: int, target: str, platformName: str):
return await del_group_sub(groupNumber, platformName, target) return await del_group_sub(groupNumber, platformName, target)
@app.get(WEIGHT_URL, dependencies=[Depends(check_is_superuser)])
async def _get_weight_config():
return await get_weight_config()
@app.put(WEIGHT_URL, dependencies=[Depends(check_is_superuser)])
async def _update_weight_config(platform_name: str, target: str, req: WeightConfig):
return await update_weigth_config(platform_name, target, req)
app.mount(URL_BASE, SinglePageApplication(directory=static_path), name="bison") app.mount(URL_BASE, SinglePageApplication(directory=static_path), name="bison")

View File

@ -1,8 +1,15 @@
import nonebot import nonebot
from nonebot.adapters.onebot.v11.bot import Bot from nonebot.adapters.onebot.v11.bot import Bot
from ..config import NoSuchSubscribeException, NoSuchUserException, config from ..config import (
NoSuchSubscribeException,
NoSuchTargetException,
NoSuchUserException,
config,
)
from ..platform import check_sub_target, platform_manager from ..platform import check_sub_target, platform_manager
from ..types import Target as T_Target
from ..types import WeightConfig
from .jwt import pack_jwt from .jwt import pack_jwt
from .token_manager import token_manager from .token_manager import token_manager
@ -45,7 +52,8 @@ async def auth(token: str):
groups = await bot.call_api("get_group_list") groups = await bot.call_api("get_group_list")
if str(qq) in nonebot.get_driver().config.superusers: if str(qq) in nonebot.get_driver().config.superusers:
jwt_obj = { jwt_obj = {
"id": str(qq), "id": qq,
"type": "admin",
"groups": list( "groups": list(
map( map(
lambda info: { lambda info: {
@ -59,23 +67,23 @@ async def auth(token: str):
ret_obj = { ret_obj = {
"type": "admin", "type": "admin",
"name": nickname, "name": nickname,
"id": str(qq), "id": qq,
"token": pack_jwt(jwt_obj), "token": pack_jwt(jwt_obj),
} }
return {"status": 200, **ret_obj} return {"status": 200, **ret_obj}
if admin_groups := await get_admin_groups(int(qq)): if admin_groups := await get_admin_groups(int(qq)):
jwt_obj = {"id": str(qq), "groups": admin_groups} jwt_obj = {"id": str(qq), "type": "user", "groups": admin_groups}
ret_obj = { ret_obj = {
"type": "user", "type": "user",
"name": nickname, "name": nickname,
"id": str(qq), "id": qq,
"token": pack_jwt(jwt_obj), "token": pack_jwt(jwt_obj),
} }
return {"status": 200, **ret_obj} return {"status": 200, **ret_obj}
else: else:
return {"status": 400, "type": "", "name": "", "id": "", "token": ""} return {"status": 400, "type": "", "name": "", "id": 0, "token": ""}
else: else:
return {"status": 400, "type": "", "name": "", "id": "", "token": ""} return {"status": 400, "type": "", "name": "", "id": 0, "token": ""}
async def get_subs_info(jwt_obj: dict): async def get_subs_info(jwt_obj: dict):
@ -83,16 +91,17 @@ async def get_subs_info(jwt_obj: dict):
res = {} res = {}
for group in groups: for group in groups:
group_id = group["id"] group_id = group["id"]
raw_subs = await config.list_subscribe(group_id, "group")
subs = list( subs = list(
map( map(
lambda sub: { lambda sub: {
"platformName": sub["target_type"], "platformName": sub.target.platform_name,
"target": sub["target"], "targetName": sub.target.target_name,
"targetName": sub["target_name"], "cats": sub.categories,
"cats": sub["cats"], "tags": sub.tags,
"tags": sub["tags"], "target": sub.target.target,
}, },
config.list_subscribe(group_id, "group"), raw_subs,
) )
) )
res[group_id] = {"name": group["name"], "subscribes": subs} res[group_id] = {"name": group["name"], "subscribes": subs}
@ -104,29 +113,35 @@ async def get_target_name(platform_name: str, target: str, jwt_obj: dict):
async def add_group_sub( async def add_group_sub(
group_number: str, group_number: int,
platform_name: str, platform_name: str,
target: str, target: str,
target_name: str, target_name: str,
cats: list[int], cats: list[int],
tags: list[str], tags: list[str],
): ):
config.add_subscribe( await config.add_subscribe(
int(group_number), "group", target, target_name, platform_name, cats, tags int(group_number),
"group",
T_Target(target),
target_name,
platform_name,
cats,
tags,
) )
return {"status": 200, "msg": ""} return {"status": 200, "msg": ""}
async def del_group_sub(group_number: str, platform_name: str, target: str): async def del_group_sub(group_number: int, platform_name: str, target: str):
try: try:
config.del_subscribe(int(group_number), "group", target, platform_name) await config.del_subscribe(int(group_number), "group", target, platform_name)
except (NoSuchUserException, NoSuchSubscribeException): except (NoSuchUserException, NoSuchSubscribeException):
return {"status": 400, "msg": "删除错误"} return {"status": 400, "msg": "删除错误"}
return {"status": 200, "msg": ""} return {"status": 200, "msg": ""}
async def update_group_sub( async def update_group_sub(
group_number: str, group_number: int,
platform_name: str, platform_name: str,
target: str, target: str,
target_name: str, target_name: str,
@ -134,9 +149,25 @@ async def update_group_sub(
tags: list[str], tags: list[str],
): ):
try: try:
config.update_subscribe( await config.update_subscribe(
int(group_number), "group", target, target_name, platform_name, cats, tags int(group_number), "group", target, target_name, platform_name, cats, tags
) )
except (NoSuchUserException, NoSuchSubscribeException): except (NoSuchUserException, NoSuchSubscribeException):
return {"status": 400, "msg": "更新错误"} return {"status": 400, "msg": "更新错误"}
return {"status": 200, "msg": ""} return {"status": 200, "msg": ""}
async def get_weight_config():
return await config.get_all_weight_config()
async def update_weigth_config(
platform_name: str, target: str, weight_config: WeightConfig
):
try:
await config.update_time_weight_config(
T_Target(target), platform_name, weight_config
)
except NoSuchTargetException:
return {"status": 400, "msg": "该订阅不存在"}
return {"status": 200, "msg": ""}

View File

@ -1,3 +1,3 @@
from .config_legacy import NoSuchSubscribeException, NoSuchUserException
from .db import DATA from .db import DATA
from .db_config import config from .db_config import config
from .utils import NoSuchSubscribeException, NoSuchTargetException, NoSuchUserException

View File

@ -1,11 +1,10 @@
from pathlib import Path from pathlib import Path
import nonebot
from alembic.config import Config from alembic.config import Config
from alembic.runtime.environment import EnvironmentContext from alembic.runtime.environment import EnvironmentContext
from alembic.script.base import ScriptDirectory from alembic.script.base import ScriptDirectory
from nonebot.log import logger from nonebot.log import logger
from nonebot_plugin_datastore import PluginData, create_session, db from nonebot_plugin_datastore import PluginData, db
from nonebot_plugin_datastore.db import get_engine from nonebot_plugin_datastore.db import get_engine
from sqlalchemy.engine.base import Connection from sqlalchemy.engine.base import Connection
from sqlalchemy.ext.asyncio.session import AsyncSession from sqlalchemy.ext.asyncio.session import AsyncSession

View File

@ -1,3 +1,4 @@
from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, time from datetime import datetime, time
from typing import Any, Awaitable, Callable, Optional from typing import Any, Awaitable, Callable, Optional
@ -8,11 +9,13 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.sql.expression import delete, select from sqlalchemy.sql.expression import delete, select
from sqlalchemy.sql.functions import func from sqlalchemy.sql.functions import func
from ..types import Category, Tag from ..types import Category, PlatformWeightConfigResp, Tag
from ..types import Target as T_Target from ..types import Target as T_Target
from ..types import TimeWeightConfig
from ..types import User as T_User from ..types import User as T_User
from ..types import UserSubInfo from ..types import UserSubInfo, WeightConfig
from .db_model import ScheduleTimeWeight, Subscribe, Target, User from .db_model import ScheduleTimeWeight, Subscribe, Target, User
from .utils import NoSuchTargetException
def _get_time(): def _get_time():
@ -21,20 +24,6 @@ def _get_time():
return cur_time return cur_time
@dataclass
class TimeWeightConfig:
start_time: time
end_time: time
weight: int
@dataclass
class WeightConfig:
default: int
time_config: list[TimeWeightConfig]
class DBConfig: class DBConfig:
def __init__(self): def __init__(self):
self.add_target_hook: Optional[Callable[[str, T_Target], Awaitable]] = None self.add_target_hook: Optional[Callable[[str, T_Target], Awaitable]] = None
@ -202,9 +191,14 @@ class DBConfig:
Target.platform_name == platform_name, Target.target == target Target.platform_name == platform_name, Target.target == target
) )
) )
if not targetObj:
raise NoSuchTargetException()
target_id = targetObj.id target_id = targetObj.id
targetObj.default_schedule_weight = conf.default targetObj.default_schedule_weight = conf.default
delete(ScheduleTimeWeight).where(ScheduleTimeWeight.target_id == target_id) delete_statement = delete(ScheduleTimeWeight).where(
ScheduleTimeWeight.target_id == target_id
)
await sess.execute(delete_statement)
for time_conf in conf.time_config: for time_conf in conf.time_config:
new_conf = ScheduleTimeWeight( new_conf = ScheduleTimeWeight(
start_time=time_conf.start_time, start_time=time_conf.start_time,
@ -262,5 +256,41 @@ class DBConfig:
) )
) )
async def get_all_weight_config(
self,
) -> dict[str, dict[str, PlatformWeightConfigResp]]:
res: dict[str, dict[str, PlatformWeightConfigResp]] = defaultdict(dict)
async with AsyncSession(get_engine()) as sess:
query = select(Target)
targets: list[Target] = (await sess.scalars(query)).all()
query = select(ScheduleTimeWeight).options(
selectinload(ScheduleTimeWeight.target)
)
time_weights: list[ScheduleTimeWeight] = (await sess.scalars(query)).all()
for target in targets:
platform_name = target.platform_name
if platform_name not in res.keys():
res[platform_name][target.target] = PlatformWeightConfigResp(
target=T_Target(target.target),
target_name=target.target_name,
platform_name=platform_name,
weight=WeightConfig(
default=target.default_schedule_weight, time_config=[]
),
)
for time_weight_config in time_weights:
platform_name = time_weight_config.target.platform_name
target = time_weight_config.target.target
res[platform_name][target].weight.time_config.append(
TimeWeightConfig(
start_time=time_weight_config.start_time,
end_time=time_weight_config.end_time,
weight=time_weight_config.weight,
)
)
return res
config = DBConfig() config = DBConfig()

View File

@ -1,9 +1,7 @@
from datetime import datetime
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql.schema import Column, ForeignKey, UniqueConstraint from sqlalchemy.sql.schema import Column, ForeignKey, UniqueConstraint
from sqlalchemy.sql.sqltypes import JSON, DateTime, Integer, String, Time from sqlalchemy.sql.sqltypes import JSON, Integer, String, Time
Base = declarative_base() Base = declarative_base()

View File

@ -4,3 +4,7 @@ class NoSuchUserException(Exception):
class NoSuchSubscribeException(Exception): class NoSuchSubscribeException(Exception):
pass pass
class NoSuchTargetException(Exception):
pass

View File

@ -1,5 +1,8 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Literal, NamedTuple, NewType from datetime import time
from typing import Any, Literal, NamedTuple, NewType
from pydantic import BaseModel
RawPost = NewType("RawPost", Any) RawPost = NewType("RawPost", Any)
Target = NewType("Target", str) Target = NewType("Target", str)
@ -24,3 +27,21 @@ class UserSubInfo(NamedTuple):
user: User user: User
categories: list[Category] categories: list[Category]
tags: list[Tag] tags: list[Tag]
class TimeWeightConfig(BaseModel):
start_time: time
end_time: time
weight: int
class WeightConfig(BaseModel):
default: int
time_config: list[TimeWeightConfig]
class PlatformWeightConfigResp(BaseModel):
target: Target
target_name: str
platform_name: str
weight: WeightConfig