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:
- id: prettier
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'

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
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

View File

@ -1,36 +1,35 @@
{
"name": "admin-frontend",
"name": "nonebot-bison-admin",
"version": "0.1.0",
"private": true,
"homepage": "bison",
"proxy": "http://127.0.0.1:8080",
"dependencies": {
"@ant-design/icons": "^4.6.4",
"@reduxjs/toolkit": "^1.7.0",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"antd": "^4.16.13",
"axios": "^0.21.4",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.6",
"react-router-dom": "^5.3.0",
"react-scripts": "^5.0.0",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
"@arco-design/web-react": "^2.39.3",
"@reduxjs/toolkit": "^1.8.1",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.1",
"@testing-library/user-event": "^14.1.1",
"@types/jest": "^27.4.1",
"@types/node": "^17.0.25",
"@types/react": "^18.0.6",
"@types/react-dom": "^18.0.2",
"@types/redux-persist": "^4.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.1",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"redux-persist": "^6.0.0",
"typescript": "^4.6.0",
"web-vitals": "^2.1.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build && cp -r -f build/* ../src/plugins/nonebot_bison/admin_page/dist",
"test": "react-scripts test",
"eject": "react-scripts eject"
"eject": "react-scripts eject",
"lint": "eslint --fix src/**/*.ts src/**/*.tsx"
},
"eslintConfig": {
"extends": [
@ -51,10 +50,16 @@
]
},
"devDependencies": {
"@types/lodash": "^4.14.175",
"@types/react-redux": "^7.1.20",
"@types/react-router-dom": "^5.3.0",
"react-app-rewired": "^2.1.8",
"redux-devtools": "^3.7.0"
"@typescript-eslint/eslint-plugin": "^5.31.0",
"@typescript-eslint/parser": "^5.31.0",
"eslint": "^7.32.0 || ^8.2.0",
"eslint-config-airbnb": "^19.0.4",
"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.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>React Redux App</title>
</head>
<body>
<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) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
animation: App-logo-float infinite 3s ease-in-out;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
color: rgb(112, 76, 182);
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
@keyframes App-logo-float {
0% {
transform: translateY(0);
}
to {
transform: rotate(360deg);
50% {
transform: translateY(10px);
}
100% {
transform: translateY(0px);
}
}

View File

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

View File

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

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 ReactDOM from "react-dom";
import { Provider } from "react-redux";
import App from "./App";
import "./index.css";
import reportWebVitals from "./reportWebVitals";
import store from "./store";
import { injectStore } from "src/api/utils";
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
import App from './App';
import { persistor, store } from './app/store';
import './index.css';
import reportWebVitals from './reportWebVitals';
import '@arco-design/web-react/dist/css/arco.css';
injectStore(store);
ReactDOM.render(
// eslint-disable-next-line
const container = document.getElementById('root')!;
const root = createRoot(container);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
<PersistGate loading={null} persistor={persistor}>
<App />
</PersistGate>
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
// 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) => {
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);
getFID(onPerfEntry);
getFCP(onPerfEntry);

View File

@ -2,4 +2,4 @@
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// 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 {
id: string;
name: string;
}
export interface LoginStatus {
login: boolean;
type: string;
name: string;
id: string;
// groups: Array<QQGroup>
export interface TokenResp {
status: number;
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 {
platformConf: AllPlatformConf;
loaded: boolean;
@ -47,12 +26,12 @@ export interface PlatformConfig {
hasTarget: boolean;
}
export interface TokenResp {
status: number;
token: string;
type: string;
id: string;
name: string;
export interface SubscribeConfig {
platformName: string;
target: string;
targetName: string;
cats: Array<number>;
tags: Array<string>;
}
export interface SubscribeGroupDetail {
@ -64,6 +43,30 @@ export interface SubscribeResp {
[idx: string]: SubscribeGroupDetail;
}
export interface TargetNameResp {
targetName: string;
export interface StatusResp {
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,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "./"
"jsx": "react-jsx"
},
"include": [
"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 ..plugin_config import plugin_config
from ..types import WeightConfig
from .api import (
add_group_sub,
auth,
@ -21,8 +22,10 @@ from .api import (
get_global_conf,
get_subs_info,
get_target_name,
get_weight_config,
test,
update_group_sub,
update_weigth_config,
)
from .jwt import load_jwt
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"
SUBSCRIBE_URL = f"{URL_BASE}api/subs"
GET_TARGET_NAME_URL = f"{URL_BASE}api/target_name"
WEIGHT_URL = f"{URL_BASE}api/weight"
TEST_URL = f"{URL_BASE}test"
STATIC_PATH = (Path(__file__).parent / "dist").resolve()
@ -66,7 +70,7 @@ def register_router_fastapi(driver: Driver, socketio):
return obj
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"]
for group in groups:
@ -74,6 +78,10 @@ def register_router_fastapi(driver: Driver, socketio):
return
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
class AddSubscribeReq:
platformName: str
@ -99,7 +107,7 @@ def register_router_fastapi(driver: Driver, socketio):
return await get_target_name(platformName, target, jwt_obj)
@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(
group_number=groupNumber,
platform_name=req.platformName,
@ -110,7 +118,7 @@ def register_router_fastapi(driver: Driver, socketio):
)
@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(
group_number=groupNumber,
platform_name=req.platformName,
@ -121,9 +129,17 @@ def register_router_fastapi(driver: Driver, socketio):
)
@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)
@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")

View File

@ -1,8 +1,15 @@
import nonebot
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 ..types import Target as T_Target
from ..types import WeightConfig
from .jwt import pack_jwt
from .token_manager import token_manager
@ -45,7 +52,8 @@ async def auth(token: str):
groups = await bot.call_api("get_group_list")
if str(qq) in nonebot.get_driver().config.superusers:
jwt_obj = {
"id": str(qq),
"id": qq,
"type": "admin",
"groups": list(
map(
lambda info: {
@ -59,23 +67,23 @@ async def auth(token: str):
ret_obj = {
"type": "admin",
"name": nickname,
"id": str(qq),
"id": qq,
"token": pack_jwt(jwt_obj),
}
return {"status": 200, **ret_obj}
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 = {
"type": "user",
"name": nickname,
"id": str(qq),
"id": qq,
"token": pack_jwt(jwt_obj),
}
return {"status": 200, **ret_obj}
else:
return {"status": 400, "type": "", "name": "", "id": "", "token": ""}
return {"status": 400, "type": "", "name": "", "id": 0, "token": ""}
else:
return {"status": 400, "type": "", "name": "", "id": "", "token": ""}
return {"status": 400, "type": "", "name": "", "id": 0, "token": ""}
async def get_subs_info(jwt_obj: dict):
@ -83,16 +91,17 @@ async def get_subs_info(jwt_obj: dict):
res = {}
for group in groups:
group_id = group["id"]
raw_subs = await config.list_subscribe(group_id, "group")
subs = list(
map(
lambda sub: {
"platformName": sub["target_type"],
"target": sub["target"],
"targetName": sub["target_name"],
"cats": sub["cats"],
"tags": sub["tags"],
"platformName": sub.target.platform_name,
"targetName": sub.target.target_name,
"cats": sub.categories,
"tags": sub.tags,
"target": sub.target.target,
},
config.list_subscribe(group_id, "group"),
raw_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(
group_number: str,
group_number: int,
platform_name: str,
target: str,
target_name: str,
cats: list[int],
tags: list[str],
):
config.add_subscribe(
int(group_number), "group", target, target_name, platform_name, cats, tags
await config.add_subscribe(
int(group_number),
"group",
T_Target(target),
target_name,
platform_name,
cats,
tags,
)
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:
config.del_subscribe(int(group_number), "group", target, platform_name)
await config.del_subscribe(int(group_number), "group", target, platform_name)
except (NoSuchUserException, NoSuchSubscribeException):
return {"status": 400, "msg": "删除错误"}
return {"status": 200, "msg": ""}
async def update_group_sub(
group_number: str,
group_number: int,
platform_name: str,
target: str,
target_name: str,
@ -134,9 +149,25 @@ async def update_group_sub(
tags: list[str],
):
try:
config.update_subscribe(
await config.update_subscribe(
int(group_number), "group", target, target_name, platform_name, cats, tags
)
except (NoSuchUserException, NoSuchSubscribeException):
return {"status": 400, "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_config import config
from .utils import NoSuchSubscribeException, NoSuchTargetException, NoSuchUserException

View File

@ -1,11 +1,10 @@
from pathlib import Path
import nonebot
from alembic.config import Config
from alembic.runtime.environment import EnvironmentContext
from alembic.script.base import ScriptDirectory
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 sqlalchemy.engine.base import Connection
from sqlalchemy.ext.asyncio.session import AsyncSession

View File

@ -1,3 +1,4 @@
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, time
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.functions import func
from ..types import Category, Tag
from ..types import Category, PlatformWeightConfigResp, Tag
from ..types import Target as T_Target
from ..types import TimeWeightConfig
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 .utils import NoSuchTargetException
def _get_time():
@ -21,20 +24,6 @@ def _get_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:
def __init__(self):
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
)
)
if not targetObj:
raise NoSuchTargetException()
target_id = targetObj.id
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:
new_conf = ScheduleTimeWeight(
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()

View File

@ -1,9 +1,7 @@
from datetime import datetime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
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()

View File

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

View File

@ -1,5 +1,8 @@
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)
Target = NewType("Target", str)
@ -24,3 +27,21 @@ class UserSubInfo(NamedTuple):
user: User
categories: list[Category]
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