Merge pull request #112 from felinae98/new-frontend
feat: rewrite frontend
@ -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'
|
||||
|
39
admin-frontend/.eslintrc.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.5 KiB |
@ -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>
|
||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 12 KiB |
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
);
|
6
admin-frontend/src/app/hooks.ts
Normal 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;
|
58
admin-frontend/src/app/store.ts
Normal 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>
|
||||
>;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
25
admin-frontend/src/features/auth/Auth.tsx
Normal 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>;
|
||||
}
|
33
admin-frontend/src/features/auth/authQuery.ts
Normal 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;
|
70
admin-frontend/src/features/auth/authSlice.ts
Normal 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;
|
35
admin-frontend/src/features/globalConf/globalConfSlice.ts
Normal 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;
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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 />;
|
||||
}
|
@ -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;
|
@ -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;
|
15
admin-frontend/src/features/targetName/targetNameReq.ts
Normal 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;
|
||||
}
|
14
admin-frontend/src/features/targetName/targetNameSlice.ts
Normal 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;
|
32
admin-frontend/src/features/weightConfig/WeightManager.tsx
Normal 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>;
|
||||
}
|
@ -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;
|
@ -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
|
||||
|
@ -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 |
52
admin-frontend/src/pages/Home.css
Normal 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);
|
||||
}
|
118
admin-frontend/src/pages/Home.tsx
Normal 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>
|
||||
);
|
||||
}
|
7
admin-frontend/src/pages/Unauthed.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Unauthed() {
|
||||
return (
|
||||
<div>not login</div>
|
||||
);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
.layout-side .user {
|
||||
height: 32px;
|
||||
margin: 16px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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);
|
||||
|
@ -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';
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
|
4
admin-frontend/src/utils/urls.ts
Normal 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`;
|
@ -18,8 +18,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "./"
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
@ -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")
|
||||
|
||||
|
||||
|
@ -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": ""}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
||||
|
@ -4,3 +4,7 @@ class NoSuchUserException(Exception):
|
||||
|
||||
class NoSuchSubscribeException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoSuchTargetException(Exception):
|
||||
pass
|
||||
|
@ -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
|
||||
|