diff --git a/.air.toml b/.air.toml new file mode 100644 index 00000000..921f6cdc --- /dev/null +++ b/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] +args_bin = [] +bin = "./tmp/main" +cmd = "go build -o ./tmp/main ." +delay = 1000 +exclude_dir = ["assets", "tmp", "vendor", "testdata", "web"] +exclude_file = [] +exclude_regex = ["_test.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "tpl", "tmpl", "html"] +include_file = [] +kill_delay = "0s" +log = "build-errors.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = false +stop_on_error = false + +[color] +app = "" +build = "yellow" +main = "magenta" +runner = "green" +watcher = "cyan" + +[log] +main_only = false +time = false + +[misc] +clean_on_exit = false + +[screen] +clear_on_rebuild = false +keep_scroll = true diff --git a/common/constants.go b/common/constants.go index 07214b0d..527d3a28 100644 --- a/common/constants.go +++ b/common/constants.go @@ -121,7 +121,7 @@ var ( GlobalApiRateLimitNum = GetOrDefault("GLOBAL_API_RATE_LIMIT", 180) GlobalApiRateLimitDuration int64 = 3 * 60 - GlobalWebRateLimitNum = GetOrDefault("GLOBAL_WEB_RATE_LIMIT", 60) + GlobalWebRateLimitNum = GetOrDefault("GLOBAL_WEB_RATE_LIMIT", 100) GlobalWebRateLimitDuration int64 = 3 * 60 UploadRateLimitNum = 10 diff --git a/controller/user.go b/controller/user.go index 8fd10b82..3c8ea997 100644 --- a/controller/user.go +++ b/controller/user.go @@ -7,6 +7,7 @@ import ( "one-api/common" "one-api/model" "strconv" + "time" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -248,6 +249,30 @@ func GetUser(c *gin.Context) { return } +func GetUserDashboard(c *gin.Context) { + id := c.GetInt("id") + // 获取7天前 00:00:00 和 今天23:59:59 的秒时间戳 + now := time.Now() + toDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + endOfDay := toDay.Add(time.Hour * 24).Add(-time.Second).Unix() + startOfDay := toDay.AddDate(0, 0, -7).Unix() + + dashboards, err := model.SearchLogsByDayAndModel(id, int(startOfDay), int(endOfDay)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法获取统计信息.", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dashboards, + }) +} + func GenerateAccessToken(c *gin.Context) { id := c.GetInt("id") user, err := model.GetUserById(id, true) diff --git a/model/log.go b/model/log.go index a0bd0c12..31ed61e7 100644 --- a/model/log.go +++ b/model/log.go @@ -23,6 +23,15 @@ type Log struct { ChannelId int `json:"channel" gorm:"index"` } +type LogStatistic struct { + Day string `gorm:"column:day"` + ModelName string `gorm:"column:model_name"` + RequestCount int `gorm:"column:request_count"` + Quota int `gorm:"column:quota"` + PromptTokens int `gorm:"column:prompt_tokens"` + CompletionTokens int `gorm:"column:completion_tokens"` +} + const ( LogTypeUnknown = iota LogTypeTopup @@ -184,6 +193,26 @@ func DeleteOldLog(targetTimestamp int64) (int64, error) { return result.RowsAffected, result.Error } +func SearchLogsByDayAndModel(user_id, start, end int) (LogStatistics []*LogStatistic, err error) { + err = DB.Raw(` + SELECT TO_CHAR(date_trunc('day', to_timestamp(created_at)), 'YYYY-MM-DD') as day, + model_name, count(1) as request_count, + sum(quota) as quota, + sum(prompt_tokens) as prompt_tokens, + sum(completion_tokens) as completion_tokens + FROM logs + WHERE type=2 + AND user_id= ? + AND created_at BETWEEN ? AND ? + GROUP BY day, model_name + ORDER BY day, model_name + `, user_id, start, end).Scan(&LogStatistics).Error + + fmt.Println(user_id, start, end) + + return LogStatistics, err +} + func assembleSumSelectStr(selectStr string) string { sumSelectStr := "%s(sum(%s),0)" nullfunc := "ifnull" diff --git a/model/redemption.go b/model/redemption.go index f16412b5..e1a13b8c 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -3,8 +3,9 @@ package model import ( "errors" "fmt" - "gorm.io/gorm" "one-api/common" + + "gorm.io/gorm" ) type Redemption struct { @@ -27,7 +28,7 @@ func GetAllRedemptions(startIdx int, num int) ([]*Redemption, error) { } func SearchRedemptions(keyword string) (redemptions []*Redemption, err error) { - err = DB.Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&redemptions).Error + err = DB.Where("id = ? or name LIKE ?", common.String2Int(keyword), keyword+"%").Find(&redemptions).Error return redemptions, err } diff --git a/model/user.go b/model/user.go index 7844eb6a..c7564926 100644 --- a/model/user.go +++ b/model/user.go @@ -3,9 +3,10 @@ package model import ( "errors" "fmt" - "gorm.io/gorm" "one-api/common" "strings" + + "gorm.io/gorm" ) // User if you add sensitive fields, don't forget to clean them in setupLogin function. @@ -42,7 +43,8 @@ func GetAllUsers(startIdx int, num int) (users []*User, err error) { } func SearchUsers(keyword string) (users []*User, err error) { - err = DB.Omit("password").Where("id = ? or username LIKE ? or email LIKE ? or display_name LIKE ?", keyword, keyword+"%", keyword+"%", keyword+"%").Find(&users).Error + err = DB.Omit("password").Where("id = ? or username LIKE ? or email LIKE ? or display_name LIKE ?", common.String2Int(keyword), keyword+"%", keyword+"%", keyword+"%").Find(&users).Error + return users, err } diff --git a/router/api-router.go b/router/api-router.go index da3f9e61..162675ce 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -35,6 +35,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute := userRoute.Group("/") selfRoute.Use(middleware.UserAuth()) { + selfRoute.GET("/dashboard", controller.GetUserDashboard) selfRoute.GET("/self", controller.GetSelf) selfRoute.PUT("/self", controller.UpdateSelf) selfRoute.DELETE("/self", controller.DeleteSelf) diff --git a/web/.eslintrc b/web/.eslintrc new file mode 100644 index 00000000..bbda79f0 --- /dev/null +++ b/web/.eslintrc @@ -0,0 +1,89 @@ +{ + "root": true, + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "prettier", + "plugin:react/jsx-runtime", + "plugin:jsx-a11y/recommended", + "plugin:react-hooks/recommended", + "eslint:recommended", + "plugin:react/recommended" + ], + "settings": { + "react": { + "createClass": "createReactClass", // Regex for Component Factory to use, + // default to "createReactClass" + "pragma": "React", // Pragma to use, default to "React" + "fragment": "Fragment", // Fragment to use (may be a property of ), default to "Fragment" + "version": "detect", // React version. "detect" automatically picks the version you have installed. + // You can also use `16.0`, `16.3`, etc, if you want to override the detected value. + // It will default to "latest" and warn if missing, and to "detect" in the future + "flowVersion": "0.53" // Flow version + }, + "import/resolver": { + "node": { + "moduleDirectory": ["node_modules", "src/"] + } + } + }, + "parser": "@babel/eslint-parser", + "parserOptions": { + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "impliedStrict": true, + "jsx": true + }, + "ecmaVersion": 12 + }, + "plugins": ["prettier", "react", "react-hooks"], + "rules": { + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "react/react-in-jsx-scope": "off", + "no-undef": "off", + "react/display-name": "off", + "react/jsx-filename-extension": "off", + "no-param-reassign": "off", + "react/prop-types": 1, + "react/require-default-props": "off", + "react/no-array-index-key": "off", + "react/jsx-props-no-spreading": "off", + "react/forbid-prop-types": "off", + "import/order": "off", + "import/no-cycle": "off", + "no-console": "off", + "jsx-a11y/anchor-is-valid": "off", + "prefer-destructuring": "off", + "no-shadow": "off", + "import/no-named-as-default": "off", + "import/no-extraneous-dependencies": "off", + "jsx-a11y/no-autofocus": "off", + "no-restricted-imports": [ + "error", + { + "patterns": ["@mui/*/*/*", "!@mui/material/test-utils/*"] + } + ], + "no-unused-vars": [ + "error", + { + "ignoreRestSiblings": false + } + ], + "prettier/prettier": [ + "warn", + { + "bracketSpacing": true, + "printWidth": 140, + "singleQuote": true, + "trailingComma": "none", + "tabWidth": 2, + "useTabs": false, + "endOfLine": "auto" + } + ] + } +} diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 00000000..d5fba07c --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,8 @@ +{ + "bracketSpacing": true, + "printWidth": 140, + "singleQuote": true, + "trailingComma": "none", + "tabWidth": 2, + "useTabs": false +} diff --git a/web/README.md b/web/README.md index 1b1031a3..07ca93ca 100644 --- a/web/README.md +++ b/web/README.md @@ -1,21 +1,14 @@ -# React Template +# One API 前端界面 -## Basic Usages +这个项目是 One API 的前端界面,它基于 [Berry Free React Admin Template](https://github.com/codedthemes/berry-free-react-admin-template) 进行开发。 -```shell -# Runs the app in the development mode -npm start +## 使用的开源项目 -# Builds the app for production to the `build` folder -npm run build -``` +使用了以下开源项目作为我们项目的一部分: -If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build, -for example: `REACT_APP_SERVER=http://your.domain.com`. +- [Berry Free React Admin Template](https://github.com/codedthemes/berry-free-react-admin-template) +- [minimal-ui-kit](minimal-ui-kit) -Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled. +## 许可证 -## Reference - -1. https://github.com/OIerDb-ng/OIerDb -2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example \ No newline at end of file +本项目中使用的代码遵循 MIT 许可证。 diff --git a/web/jsconfig.json b/web/jsconfig.json new file mode 100644 index 00000000..35332c70 --- /dev/null +++ b/web/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "baseUrl": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/web/package.json b/web/package.json index a2bf3054..8918ddbc 100644 --- a/web/package.json +++ b/web/package.json @@ -1,20 +1,42 @@ { - "name": "react-template", - "version": "0.1.0", + "name": "one_api_web", + "version": "1.0.0", + "proxy": "http://127.0.0.1:3000", "private": true, + "homepage": "", "dependencies": { + "@emotion/cache": "^11.9.3", + "@emotion/react": "^11.9.3", + "@emotion/styled": "^11.9.3", + "@mui/icons-material": "^5.8.4", + "@mui/lab": "^5.0.0-alpha.88", + "@mui/material": "^5.8.6", + "@mui/system": "^5.8.6", + "@mui/utils": "^5.8.6", + "@mui/x-date-pickers": "^6.18.5", + "@tabler/icons-react": "^2.44.0", + "apexcharts": "^3.35.3", "axios": "^0.27.2", + "dayjs": "^1.11.10", + "formik": "^2.2.9", + "framer-motion": "^6.3.16", "history": "^5.3.0", "marked": "^4.1.1", + "material-ui-popup-state": "^4.0.1", + "notistack": "^3.0.1", + "prop-types": "^15.8.1", "react": "^18.2.0", + "react-apexcharts": "^1.4.0", + "react-device-detect": "^2.2.2", "react-dom": "^18.2.0", - "react-dropzone": "^14.2.3", - "react-router-dom": "^6.3.0", - "react-scripts": "5.0.1", - "react-toastify": "^9.0.8", - "react-turnstile": "^1.0.5", - "semantic-ui-css": "^2.5.0", - "semantic-ui-react": "^2.1.3" + "react-perfect-scrollbar": "^1.5.8", + "react-redux": "^8.0.2", + "react-router": "6.3.0", + "react-router-dom": "6.3.0", + "react-scripts": "^5.0.1", + "react-turnstile": "^1.1.2", + "redux": "^4.2.0", + "yup": "^0.32.11" }, "scripts": { "start": "react-scripts start", @@ -24,15 +46,18 @@ }, "eslintConfig": { "extends": [ - "react-app", - "react-app/jest" + "react-app" + ] + }, + "babel": { + "presets": [ + "@babel/preset-react" ] }, "browserslist": { "production": [ - ">0.2%", - "not dead", - "not op_mini all" + "defaults", + "not IE 11" ], "development": [ "last 1 chrome version", @@ -41,11 +66,19 @@ ] }, "devDependencies": { - "prettier": "^2.7.1" - }, - "prettier": { - "singleQuote": true, - "jsxSingleQuote": true - }, - "proxy": "http://localhost:3000" + "@babel/core": "^7.21.4", + "@babel/eslint-parser": "^7.21.3", + "eslint": "^8.38.0", + "eslint-config-prettier": "^8.8.0", + "eslint-config-react-app": "^7.0.1", + "eslint-plugin-flowtype": "^8.0.3", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "immutable": "^4.3.0", + "prettier": "^2.8.7", + "sass": "^1.53.0" + } } diff --git a/web/public/favicon.ico b/web/public/favicon.ico index c2c8de0c..fbcfb14a 100644 Binary files a/web/public/favicon.ico and b/web/public/favicon.ico differ diff --git a/web/public/index.html b/web/public/index.html index b8e324d2..6f232250 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -1,18 +1,26 @@ + One API + + - - + - One API + + +
+ diff --git a/web/public/logo.png b/web/public/logo.png deleted file mode 100644 index 0f237a22..00000000 Binary files a/web/public/logo.png and /dev/null differ diff --git a/web/public/robots.txt b/web/public/robots.txt deleted file mode 100644 index e9e57dc4..00000000 --- a/web/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: diff --git a/web/src/App.js b/web/src/App.js index 13c884dc..fc54c632 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -1,293 +1,43 @@ -import React, { lazy, Suspense, useContext, useEffect } from 'react'; -import { Route, Routes } from 'react-router-dom'; -import Loading from './components/Loading'; -import User from './pages/User'; -import { PrivateRoute } from './components/PrivateRoute'; -import RegisterForm from './components/RegisterForm'; -import LoginForm from './components/LoginForm'; -import NotFound from './pages/NotFound'; -import Setting from './pages/Setting'; -import EditUser from './pages/User/EditUser'; -import AddUser from './pages/User/AddUser'; -import { API, getLogo, getSystemName, showError, showNotice } from './helpers'; -import PasswordResetForm from './components/PasswordResetForm'; -import GitHubOAuth from './components/GitHubOAuth'; -import PasswordResetConfirm from './components/PasswordResetConfirm'; -import { UserContext } from './context/User'; -import { StatusContext } from './context/Status'; -import Channel from './pages/Channel'; -import Token from './pages/Token'; -import EditToken from './pages/Token/EditToken'; -import EditChannel from './pages/Channel/EditChannel'; -import Redemption from './pages/Redemption'; -import EditRedemption from './pages/Redemption/EditRedemption'; -import TopUp from './pages/TopUp'; -import Log from './pages/Log'; -import Chat from './pages/Chat'; +import { useSelector } from 'react-redux'; -const Home = lazy(() => import('./pages/Home')); -const About = lazy(() => import('./pages/About')); +import { ThemeProvider } from '@mui/material/styles'; +import { CssBaseline, StyledEngineProvider } from '@mui/material'; -function App() { - const [userState, userDispatch] = useContext(UserContext); - const [statusState, statusDispatch] = useContext(StatusContext); +// routing +import Routes from 'routes'; - const loadUser = () => { - let user = localStorage.getItem('user'); - if (user) { - let data = JSON.parse(user); - userDispatch({ type: 'login', payload: data }); - } - }; - const loadStatus = async () => { - const res = await API.get('/api/status'); - const { success, data } = res.data; - if (success) { - localStorage.setItem('status', JSON.stringify(data)); - statusDispatch({ type: 'set', payload: data }); - localStorage.setItem('system_name', data.system_name); - localStorage.setItem('logo', data.logo); - localStorage.setItem('footer_html', data.footer_html); - localStorage.setItem('quota_per_unit', data.quota_per_unit); - localStorage.setItem('display_in_currency', data.display_in_currency); - if (data.chat_link) { - localStorage.setItem('chat_link', data.chat_link); - } else { - localStorage.removeItem('chat_link'); - } - if ( - data.version !== process.env.REACT_APP_VERSION && - data.version !== 'v0.0.0' && - process.env.REACT_APP_VERSION !== '' - ) { - showNotice( - `新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面` - ); - } - } else { - showError('无法正常连接至服务器!'); - } - }; +// defaultTheme +import themes from 'themes'; - useEffect(() => { - loadUser(); - loadStatus().then(); - let systemName = getSystemName(); - if (systemName) { - document.title = systemName; - } - let logo = getLogo(); - if (logo) { - let linkElement = document.querySelector("link[rel~='icon']"); - if (linkElement) { - linkElement.href = logo; - } - } - }, []); +// project imports +import NavigationScroll from 'layout/NavigationScroll'; + +// auth +import UserProvider from 'contexts/UserContext'; +import StatusProvider from 'contexts/StatusContext'; +import { SnackbarProvider } from 'notistack'; + +// ==============================|| APP ||============================== // + +const App = () => { + const customization = useSelector((state) => state.customization); return ( - - }> - - - } - /> - - - - } - /> - }> - - - } - /> - }> - - - } - /> - - - - } - /> - }> - - - } - /> - }> - - - } - /> - - - - } - /> - }> - - - } - /> - }> - - - } - /> - - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - - }> - - - - } - /> - - }> - - - - } - /> - - - - } - /> - }> - - - } - /> - }> - - - } - /> - - } /> - + + + + + + + + + + + + + + ); -} +}; export default App; diff --git a/web/src/assets/images/404.svg b/web/src/assets/images/404.svg new file mode 100644 index 00000000..352a14ad --- /dev/null +++ b/web/src/assets/images/404.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/auth/auth-blue-card.svg b/web/src/assets/images/auth/auth-blue-card.svg new file mode 100644 index 00000000..6c9fe3e7 --- /dev/null +++ b/web/src/assets/images/auth/auth-blue-card.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/images/auth/auth-pattern-dark.svg b/web/src/assets/images/auth/auth-pattern-dark.svg new file mode 100644 index 00000000..aa0e4ab2 --- /dev/null +++ b/web/src/assets/images/auth/auth-pattern-dark.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/images/auth/auth-pattern.svg b/web/src/assets/images/auth/auth-pattern.svg new file mode 100644 index 00000000..b7ac8e27 --- /dev/null +++ b/web/src/assets/images/auth/auth-pattern.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/images/auth/auth-purple-card.svg b/web/src/assets/images/auth/auth-purple-card.svg new file mode 100644 index 00000000..c724e0a3 --- /dev/null +++ b/web/src/assets/images/auth/auth-purple-card.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/images/auth/auth-signup-blue-card.svg b/web/src/assets/images/auth/auth-signup-blue-card.svg new file mode 100644 index 00000000..ebb8e85f --- /dev/null +++ b/web/src/assets/images/auth/auth-signup-blue-card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/images/auth/auth-signup-white-card.svg b/web/src/assets/images/auth/auth-signup-white-card.svg new file mode 100644 index 00000000..56b97e20 --- /dev/null +++ b/web/src/assets/images/auth/auth-signup-white-card.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/images/icons/earning.svg b/web/src/assets/images/icons/earning.svg new file mode 100644 index 00000000..e877b599 --- /dev/null +++ b/web/src/assets/images/icons/earning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/assets/images/icons/github.svg b/web/src/assets/images/icons/github.svg new file mode 100644 index 00000000..e5b1b82a --- /dev/null +++ b/web/src/assets/images/icons/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/images/icons/shape-avatar.svg b/web/src/assets/images/icons/shape-avatar.svg new file mode 100644 index 00000000..38aac7e2 --- /dev/null +++ b/web/src/assets/images/icons/shape-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/images/icons/social-google.svg b/web/src/assets/images/icons/social-google.svg new file mode 100644 index 00000000..2231ce98 --- /dev/null +++ b/web/src/assets/images/icons/social-google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/assets/images/icons/wechat.svg b/web/src/assets/images/icons/wechat.svg new file mode 100644 index 00000000..a0b2e36c --- /dev/null +++ b/web/src/assets/images/icons/wechat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/images/invite/cover.jpg b/web/src/assets/images/invite/cover.jpg new file mode 100644 index 00000000..93be1a40 Binary files /dev/null and b/web/src/assets/images/invite/cover.jpg differ diff --git a/web/src/assets/images/invite/cwok_casual_19.webp b/web/src/assets/images/invite/cwok_casual_19.webp new file mode 100644 index 00000000..1cf2c376 Binary files /dev/null and b/web/src/assets/images/invite/cwok_casual_19.webp differ diff --git a/web/src/assets/images/logo-2.svg b/web/src/assets/images/logo-2.svg new file mode 100644 index 00000000..2e674a7e --- /dev/null +++ b/web/src/assets/images/logo-2.svg @@ -0,0 +1,15 @@ + + + + Layer 1 + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/logo.svg b/web/src/assets/images/logo.svg new file mode 100644 index 00000000..348c7e5a --- /dev/null +++ b/web/src/assets/images/logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/users/user-round.svg b/web/src/assets/images/users/user-round.svg new file mode 100644 index 00000000..eaef7ed9 --- /dev/null +++ b/web/src/assets/images/users/user-round.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/scss/_themes-vars.module.scss b/web/src/assets/scss/_themes-vars.module.scss new file mode 100644 index 00000000..a470b033 --- /dev/null +++ b/web/src/assets/scss/_themes-vars.module.scss @@ -0,0 +1,157 @@ +// paper & background +$paper: #ffffff; + +// primary +$primaryLight: #eef2f6; +$primaryMain: #2196f3; +$primaryDark: #1e88e5; +$primary200: #90caf9; +$primary800: #1565c0; + +// secondary +$secondaryLight: #ede7f6; +$secondaryMain: #673ab7; +$secondaryDark: #5e35b1; +$secondary200: #b39ddb; +$secondary800: #4527a0; + +// success Colors +$successLight: #b9f6ca; +$success200: #69f0ae; +$successMain: #00e676; +$successDark: #00c853; + +// error +$errorLight: #ef9a9a; +$errorMain: #f44336; +$errorDark: #c62828; + +// orange +$orangeLight: #fbe9e7; +$orangeMain: #ffab91; +$orangeDark: #d84315; + +// warning +$warningLight: #fff8e1; +$warningMain: #ffe57f; +$warningDark: #ffc107; + +// grey +$grey50: #f8fafc; +$grey100: #eef2f6; +$grey200: #e3e8ef; +$grey300: #cdd5df; +$grey500: #697586; +$grey600: #4b5565; +$grey700: #364152; +$grey900: #121926; + +// ==============================|| DARK THEME VARIANTS ||============================== // + +// paper & background +$darkBackground: #1a223f; // level 3 +$darkPaper: #111936; // level 4 + +// dark 800 & 900 +$darkLevel1: #29314f; // level 1 +$darkLevel2: #212946; // level 2 + +// primary dark +$darkPrimaryLight: #eef2f6; +$darkPrimaryMain: #2196f3; +$darkPrimaryDark: #1e88e5; +$darkPrimary200: #90caf9; +$darkPrimary800: #1565c0; + +// secondary dark +$darkSecondaryLight: #d1c4e9; +$darkSecondaryMain: #7c4dff; +$darkSecondaryDark: #651fff; +$darkSecondary200: #b39ddb; +$darkSecondary800: #6200ea; + +// text variants +$darkTextTitle: #d7dcec; +$darkTextPrimary: #bdc8f0; +$darkTextSecondary: #8492c4; + +// ==============================|| JAVASCRIPT ||============================== // + +:export { + // paper & background + paper: $paper; + + // primary + primaryLight: $primaryLight; + primary200: $primary200; + primaryMain: $primaryMain; + primaryDark: $primaryDark; + primary800: $primary800; + + // secondary + secondaryLight: $secondaryLight; + secondary200: $secondary200; + secondaryMain: $secondaryMain; + secondaryDark: $secondaryDark; + secondary800: $secondary800; + + // success + successLight: $successLight; + success200: $success200; + successMain: $successMain; + successDark: $successDark; + + // error + errorLight: $errorLight; + errorMain: $errorMain; + errorDark: $errorDark; + + // orange + orangeLight: $orangeLight; + orangeMain: $orangeMain; + orangeDark: $orangeDark; + + // warning + warningLight: $warningLight; + warningMain: $warningMain; + warningDark: $warningDark; + + // grey + grey50: $grey50; + grey100: $grey100; + grey200: $grey200; + grey300: $grey300; + grey500: $grey500; + grey600: $grey600; + grey700: $grey700; + grey900: $grey900; + + // ==============================|| DARK THEME VARIANTS ||============================== // + + // paper & background + darkPaper: $darkPaper; + darkBackground: $darkBackground; + + // dark 800 & 900 + darkLevel1: $darkLevel1; + darkLevel2: $darkLevel2; + + // text variants + darkTextTitle: $darkTextTitle; + darkTextPrimary: $darkTextPrimary; + darkTextSecondary: $darkTextSecondary; + + // primary dark + darkPrimaryLight: $darkPrimaryLight; + darkPrimaryMain: $darkPrimaryMain; + darkPrimaryDark: $darkPrimaryDark; + darkPrimary200: $darkPrimary200; + darkPrimary800: $darkPrimary800; + + // secondary dark + darkSecondaryLight: $darkSecondaryLight; + darkSecondaryMain: $darkSecondaryMain; + darkSecondaryDark: $darkSecondaryDark; + darkSecondary200: $darkSecondary200; + darkSecondary800: $darkSecondary800; +} diff --git a/web/src/assets/scss/style.scss b/web/src/assets/scss/style.scss new file mode 100644 index 00000000..17d566e6 --- /dev/null +++ b/web/src/assets/scss/style.scss @@ -0,0 +1,128 @@ +// color variants +@import 'themes-vars.module.scss'; + +// third-party +@import '~react-perfect-scrollbar/dist/css/styles.css'; + +// ==============================|| LIGHT BOX ||============================== // +.fullscreen .react-images__blanket { + z-index: 1200; +} + +// ==============================|| APEXCHART ||============================== // + +.apexcharts-legend-series .apexcharts-legend-marker { + margin-right: 8px; +} + +// ==============================|| PERFECT SCROLLBAR ||============================== // + +.scrollbar-container { + .ps__rail-y { + &:hover > .ps__thumb-y, + &:focus > .ps__thumb-y, + &.ps--clicking .ps__thumb-y { + background-color: $grey500; + width: 5px; + } + } + .ps__thumb-y { + background-color: $grey500; + border-radius: 6px; + width: 5px; + right: 0; + } +} + +.scrollbar-container.ps, +.scrollbar-container > .ps { + &.ps--active-y > .ps__rail-y { + width: 5px; + background-color: transparent !important; + z-index: 999; + &:hover, + &.ps--clicking { + width: 5px; + background-color: transparent; + } + } + &.ps--scrolling-y > .ps__rail-y, + &.ps--scrolling-x > .ps__rail-x { + opacity: 0.4; + background-color: transparent; + } +} + +// ==============================|| ANIMATION KEYFRAMES ||============================== // + +@keyframes wings { + 50% { + transform: translateY(-40px); + } + 100% { + transform: translateY(0px); + } +} + +@keyframes blink { + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes bounce { + 0%, + 20%, + 53%, + to { + animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); + transform: translateZ(0); + } + 40%, + 43% { + animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); + transform: translate3d(0, -5px, 0); + } + 70% { + animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); + transform: translate3d(0, -7px, 0); + } + 80% { + transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); + transform: translateZ(0); + } + 90% { + transform: translate3d(0, -2px, 0); + } +} + +@keyframes slideY { + 0%, + 50%, + 100% { + transform: translateY(0px); + } + 25% { + transform: translateY(-10px); + } + 75% { + transform: translateY(10px); + } +} + +@keyframes slideX { + 0%, + 50%, + 100% { + transform: translateX(0px); + } + 25% { + transform: translateX(-10px); + } + 75% { + transform: translateX(10px); + } +} diff --git a/web/src/components/ChannelsTable.js b/web/src/components/ChannelsTable.js deleted file mode 100644 index d44ea2d7..00000000 --- a/web/src/components/ChannelsTable.js +++ /dev/null @@ -1,564 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Form, Input, Label, Message, Pagination, Popup, Table } from 'semantic-ui-react'; -import { Link } from 'react-router-dom'; -import { API, setPromptShown, shouldShowPrompt, showError, showInfo, showSuccess, timestamp2string } from '../helpers'; - -import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants'; -import { renderGroup, renderNumber } from '../helpers/render'; - -function renderTimestamp(timestamp) { - return ( - <> - {timestamp2string(timestamp)} - - ); -} - -let type2label = undefined; - -function renderType(type) { - if (!type2label) { - type2label = new Map; - for (let i = 0; i < CHANNEL_OPTIONS.length; i++) { - type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i]; - } - type2label[0] = { value: 0, text: '未知类型', color: 'grey' }; - } - return ; -} - -function renderBalance(type, balance) { - switch (type) { - case 1: // OpenAI - return ${balance.toFixed(2)}; - case 4: // CloseAI - return ¥{balance.toFixed(2)}; - case 8: // 自定义 - return ${balance.toFixed(2)}; - case 5: // OpenAI-SB - return ¥{(balance / 10000).toFixed(2)}; - case 10: // AI Proxy - return {renderNumber(balance)}; - case 12: // API2GPT - return ¥{balance.toFixed(2)}; - case 13: // AIGC2D - return {renderNumber(balance)}; - default: - return 不支持; - } -} - -const ChannelsTable = () => { - const [channels, setChannels] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searching, setSearching] = useState(false); - const [updatingBalance, setUpdatingBalance] = useState(false); - const [showPrompt, setShowPrompt] = useState(shouldShowPrompt("channel-test")); - - const loadChannels = async (startIdx) => { - const res = await API.get(`/api/channel/?p=${startIdx}`); - const { success, message, data } = res.data; - if (success) { - if (startIdx === 0) { - setChannels(data); - } else { - let newChannels = [...channels]; - newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); - setChannels(newChannels); - } - } else { - showError(message); - } - setLoading(false); - }; - - const onPaginationChange = (e, { activePage }) => { - (async () => { - if (activePage === Math.ceil(channels.length / ITEMS_PER_PAGE) + 1) { - // In this case we have to load more data and then append them. - await loadChannels(activePage - 1); - } - setActivePage(activePage); - })(); - }; - - const refresh = async () => { - setLoading(true); - await loadChannels(activePage - 1); - }; - - useEffect(() => { - loadChannels(0) - .then() - .catch((reason) => { - showError(reason); - }); - }, []); - - const manageChannel = async (id, action, idx, value) => { - let data = { id }; - let res; - switch (action) { - case 'delete': - res = await API.delete(`/api/channel/${id}/`); - break; - case 'enable': - data.status = 1; - res = await API.put('/api/channel/', data); - break; - case 'disable': - data.status = 2; - res = await API.put('/api/channel/', data); - break; - case 'priority': - if (value === '') { - return; - } - data.priority = parseInt(value); - res = await API.put('/api/channel/', data); - break; - case 'weight': - if (value === '') { - return; - } - data.weight = parseInt(value); - if (data.weight < 0) { - data.weight = 0; - } - res = await API.put('/api/channel/', data); - break; - } - const { success, message } = res.data; - if (success) { - showSuccess('操作成功完成!'); - let channel = res.data.data; - let newChannels = [...channels]; - let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; - if (action === 'delete') { - newChannels[realIdx].deleted = true; - } else { - newChannels[realIdx].status = channel.status; - } - setChannels(newChannels); - } else { - showError(message); - } - }; - - const renderStatus = (status) => { - switch (status) { - case 1: - return ; - case 2: - return ( - - 已禁用 - } - content='本渠道被手动禁用' - basic - /> - ); - case 3: - return ( - - 已禁用 - } - content='本渠道被程序自动禁用' - basic - /> - ); - default: - return ( - - ); - } - }; - - const renderResponseTime = (responseTime) => { - let time = responseTime / 1000; - time = time.toFixed(2) + ' 秒'; - if (responseTime === 0) { - return ; - } else if (responseTime <= 1000) { - return ; - } else if (responseTime <= 3000) { - return ; - } else if (responseTime <= 5000) { - return ; - } else { - return ; - } - }; - - const searchChannels = async () => { - if (searchKeyword === '') { - // if keyword is blank, load files instead. - await loadChannels(0); - setActivePage(1); - return; - } - setSearching(true); - const res = await API.get(`/api/channel/search?keyword=${searchKeyword}`); - const { success, message, data } = res.data; - if (success) { - setChannels(data); - setActivePage(1); - } else { - showError(message); - } - setSearching(false); - }; - - const testChannel = async (id, name, idx) => { - const res = await API.get(`/api/channel/test/${id}/`); - const { success, message, time } = res.data; - if (success) { - let newChannels = [...channels]; - let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; - newChannels[realIdx].response_time = time * 1000; - newChannels[realIdx].test_time = Date.now() / 1000; - setChannels(newChannels); - showInfo(`通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。`); - } else { - showError(message); - } - }; - - const testAllChannels = async () => { - const res = await API.get(`/api/channel/test`); - const { success, message } = res.data; - if (success) { - showInfo('已成功开始测试所有已启用通道,请刷新页面查看结果。'); - } else { - showError(message); - } - }; - - const deleteAllDisabledChannels = async () => { - const res = await API.delete(`/api/channel/disabled`); - const { success, message, data } = res.data; - if (success) { - showSuccess(`已删除所有禁用渠道,共计 ${data} 个`); - await refresh(); - } else { - showError(message); - } - }; - - const updateChannelBalance = async (id, name, idx) => { - const res = await API.get(`/api/channel/update_balance/${id}/`); - const { success, message, balance } = res.data; - if (success) { - let newChannels = [...channels]; - let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; - newChannels[realIdx].balance = balance; - newChannels[realIdx].balance_updated_time = Date.now() / 1000; - setChannels(newChannels); - showInfo(`通道 ${name} 余额更新成功!`); - } else { - showError(message); - } - }; - - const updateAllChannelsBalance = async () => { - setUpdatingBalance(true); - const res = await API.get(`/api/channel/update_balance`); - const { success, message } = res.data; - if (success) { - showInfo('已更新完毕所有已启用通道余额!'); - } else { - showError(message); - } - setUpdatingBalance(false); - }; - - const handleKeywordChange = async (e, { value }) => { - setSearchKeyword(value.trim()); - }; - - const sortChannel = (key) => { - if (channels.length === 0) return; - setLoading(true); - let sortedChannels = [...channels]; - sortedChannels.sort((a, b) => { - if (!isNaN(a[key])) { - // If the value is numeric, subtract to sort - return a[key] - b[key]; - } else { - // If the value is not numeric, sort as strings - return ('' + a[key]).localeCompare(b[key]); - } - }); - if (sortedChannels[0].id === channels[0].id) { - sortedChannels.reverse(); - } - setChannels(sortedChannels); - setLoading(false); - }; - - - return ( - <> -
- - - { - showPrompt && ( - { - setShowPrompt(false); - setPromptShown("channel-test"); - }}> - 当前版本测试是通过按照 OpenAI API 格式使用 gpt-3.5-turbo - 模型进行非流式请求实现的,因此测试报错并不一定代表通道不可用,该功能后续会修复。 - - 另外,OpenAI 渠道已经不再支持通过 key 获取余额,因此余额显示为 0。对于支持的渠道类型,请点击余额进行刷新。 - - ) - } - - - - { - sortChannel('id'); - }} - > - ID - - { - sortChannel('name'); - }} - > - 名称 - - { - sortChannel('group'); - }} - > - 分组 - - { - sortChannel('type'); - }} - > - 类型 - - { - sortChannel('status'); - }} - > - 状态 - - { - sortChannel('response_time'); - }} - > - 响应时间 - - { - sortChannel('balance'); - }} - > - 余额 - - { - sortChannel('priority'); - }} - > - 优先级 - - 操作 - - - - - {channels - .slice( - (activePage - 1) * ITEMS_PER_PAGE, - activePage * ITEMS_PER_PAGE - ) - .map((channel, idx) => { - if (channel.deleted) return <>; - return ( - - {channel.id} - {channel.name ? channel.name : '无'} - {renderGroup(channel.group)} - {renderType(channel.type)} - {renderStatus(channel.status)} - - - - - { - updateChannelBalance(channel.id, channel.name, idx); - }} style={{ cursor: 'pointer' }}> - {renderBalance(channel.type, channel.balance)} - } - content='点击更新' - basic - /> - - - { - manageChannel( - channel.id, - 'priority', - idx, - event.target.value - ); - }}> - - } - content='渠道选择优先级,越高越优先' - basic - /> - - -
- - {/* {*/} - {/* updateChannelBalance(channel.id, channel.name, idx);*/} - {/* }}*/} - {/*>*/} - {/* 更新余额*/} - {/**/} - - 删除 - - } - on='click' - flowing - hoverable - > - - - - -
-
-
- ); - })} -
- - - - - - - - - 删除禁用渠道 - - } - on='click' - flowing - hoverable - > - - - - - - - -
- - ); -}; - -export default ChannelsTable; diff --git a/web/src/components/Footer.js b/web/src/components/Footer.js deleted file mode 100644 index 334ee379..00000000 --- a/web/src/components/Footer.js +++ /dev/null @@ -1,61 +0,0 @@ -import React, { useEffect, useState } from 'react'; - -import { Container, Segment } from 'semantic-ui-react'; -import { getFooterHTML, getSystemName } from '../helpers'; - -const Footer = () => { - const systemName = getSystemName(); - const [footer, setFooter] = useState(getFooterHTML()); - let remainCheckTimes = 5; - - const loadFooter = () => { - let footer_html = localStorage.getItem('footer_html'); - if (footer_html) { - setFooter(footer_html); - } - }; - - useEffect(() => { - const timer = setInterval(() => { - if (remainCheckTimes <= 0) { - clearInterval(timer); - return; - } - remainCheckTimes--; - loadFooter(); - }, 200); - return () => clearTimeout(timer); - }, []); - - return ( - - - {footer ? ( -
- ) : ( - - )} -
-
- ); -}; - -export default Footer; diff --git a/web/src/components/GitHubOAuth.js b/web/src/components/GitHubOAuth.js deleted file mode 100644 index c43ed2a1..00000000 --- a/web/src/components/GitHubOAuth.js +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { Dimmer, Loader, Segment } from 'semantic-ui-react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { API, showError, showSuccess } from '../helpers'; -import { UserContext } from '../context/User'; - -const GitHubOAuth = () => { - const [searchParams, setSearchParams] = useSearchParams(); - - const [userState, userDispatch] = useContext(UserContext); - const [prompt, setPrompt] = useState('处理中...'); - const [processing, setProcessing] = useState(true); - - let navigate = useNavigate(); - - const sendCode = async (code, state, count) => { - const res = await API.get(`/api/oauth/github?code=${code}&state=${state}`); - const { success, message, data } = res.data; - if (success) { - if (message === 'bind') { - showSuccess('绑定成功!'); - navigate('/setting'); - } else { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - showSuccess('登录成功!'); - navigate('/'); - } - } else { - showError(message); - if (count === 0) { - setPrompt(`操作失败,重定向至登录界面中...`); - navigate('/setting'); // in case this is failed to bind GitHub - return; - } - count++; - setPrompt(`出现错误,第 ${count} 次重试中...`); - await new Promise((resolve) => setTimeout(resolve, count * 2000)); - await sendCode(code, state, count); - } - }; - - useEffect(() => { - let code = searchParams.get('code'); - let state = searchParams.get('state'); - sendCode(code, state, 0).then(); - }, []); - - return ( - - - {prompt} - - - ); -}; - -export default GitHubOAuth; diff --git a/web/src/components/Header.js b/web/src/components/Header.js deleted file mode 100644 index 21ebcab6..00000000 --- a/web/src/components/Header.js +++ /dev/null @@ -1,223 +0,0 @@ -import React, { useContext, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { UserContext } from '../context/User'; - -import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react'; -import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers'; -import '../index.css'; - -// Header Buttons -let headerButtons = [ - { - name: '首页', - to: '/', - icon: 'home' - }, - { - name: '渠道', - to: '/channel', - icon: 'sitemap', - admin: true - }, - { - name: '令牌', - to: '/token', - icon: 'key' - }, - { - name: '兑换', - to: '/redemption', - icon: 'dollar sign', - admin: true - }, - { - name: '充值', - to: '/topup', - icon: 'cart' - }, - { - name: '用户', - to: '/user', - icon: 'user', - admin: true - }, - { - name: '日志', - to: '/log', - icon: 'book' - }, - { - name: '设置', - to: '/setting', - icon: 'setting' - }, - { - name: '关于', - to: '/about', - icon: 'info circle' - } -]; - -if (localStorage.getItem('chat_link')) { - headerButtons.splice(1, 0, { - name: '聊天', - to: '/chat', - icon: 'comments' - }); -} - -const Header = () => { - const [userState, userDispatch] = useContext(UserContext); - let navigate = useNavigate(); - - const [showSidebar, setShowSidebar] = useState(false); - const systemName = getSystemName(); - const logo = getLogo(); - - async function logout() { - setShowSidebar(false); - await API.get('/api/user/logout'); - showSuccess('注销成功!'); - userDispatch({ type: 'logout' }); - localStorage.removeItem('user'); - navigate('/login'); - } - - const toggleSidebar = () => { - setShowSidebar(!showSidebar); - }; - - const renderButtons = (isMobile) => { - return headerButtons.map((button) => { - if (button.admin && !isAdmin()) return <>; - if (isMobile) { - return ( - { - navigate(button.to); - setShowSidebar(false); - }} - > - {button.name} - - ); - } - return ( - - - {button.name} - - ); - }); - }; - - if (isMobile()) { - return ( - <> - - - - logo -
- {systemName} -
-
- - - - - -
-
- {showSidebar ? ( - - - {renderButtons(true)} - - {userState.user ? ( - - ) : ( - <> - - - - )} - - - - ) : ( - <> - )} - - ); - } - - return ( - <> - - - - logo -
- {systemName} -
-
- {renderButtons(false)} - - {userState.user ? ( - - - 注销 - - - ) : ( - - )} - -
-
- - ); -}; - -export default Header; diff --git a/web/src/components/Loading.js b/web/src/components/Loading.js deleted file mode 100644 index 1210a56f..00000000 --- a/web/src/components/Loading.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { Segment, Dimmer, Loader } from 'semantic-ui-react'; - -const Loading = ({ prompt: name = 'page' }) => { - return ( - - - 加载{name}中... - - - ); -}; - -export default Loading; diff --git a/web/src/components/LoginForm.js b/web/src/components/LoginForm.js deleted file mode 100644 index a3913220..00000000 --- a/web/src/components/LoginForm.js +++ /dev/null @@ -1,193 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { Button, Divider, Form, Grid, Header, Image, Message, Modal, Segment } from 'semantic-ui-react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { UserContext } from '../context/User'; -import { API, getLogo, showError, showSuccess, showWarning } from '../helpers'; -import { onGitHubOAuthClicked } from './utils'; - -const LoginForm = () => { - const [inputs, setInputs] = useState({ - username: '', - password: '', - wechat_verification_code: '' - }); - const [searchParams, setSearchParams] = useSearchParams(); - const [submitted, setSubmitted] = useState(false); - const { username, password } = inputs; - const [userState, userDispatch] = useContext(UserContext); - let navigate = useNavigate(); - const [status, setStatus] = useState({}); - const logo = getLogo(); - - useEffect(() => { - if (searchParams.get('expired')) { - showError('未登录或登录已过期,请重新登录!'); - } - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setStatus(status); - } - }, []); - - const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); - - const onWeChatLoginClicked = () => { - setShowWeChatLoginModal(true); - }; - - const onSubmitWeChatVerificationCode = async () => { - const res = await API.get( - `/api/oauth/wechat?code=${inputs.wechat_verification_code}` - ); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - navigate('/'); - showSuccess('登录成功!'); - setShowWeChatLoginModal(false); - } else { - showError(message); - } - }; - - function handleChange(e) { - const { name, value } = e.target; - setInputs((inputs) => ({ ...inputs, [name]: value })); - } - - async function handleSubmit(e) { - setSubmitted(true); - if (username && password) { - const res = await API.post(`/api/user/login`, { - username, - password - }); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - if (username === 'root' && password === '123456') { - navigate('/user/edit'); - showSuccess('登录成功!'); - showWarning('请立刻修改默认密码!'); - } else { - navigate('/token'); - showSuccess('登录成功!'); - } - } else { - showError(message); - } - } - } - - return ( - - -
- 用户登录 -
-
- - - - - -
- - 忘记密码? - - 点击重置 - - ; 没有账户? - - 点击注册 - - - {status.github_oauth || status.wechat_login ? ( - <> - Or - {status.github_oauth ? ( - - - - - -
-
- ); -}; - -export default LoginForm; diff --git a/web/src/components/LogsTable.js b/web/src/components/LogsTable.js deleted file mode 100644 index e266d79a..00000000 --- a/web/src/components/LogsTable.js +++ /dev/null @@ -1,403 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Form, Header, Label, Pagination, Segment, Select, Table } from 'semantic-ui-react'; -import { API, isAdmin, showError, timestamp2string } from '../helpers'; - -import { ITEMS_PER_PAGE } from '../constants'; -import { renderQuota } from '../helpers/render'; - -function renderTimestamp(timestamp) { - return ( - <> - {timestamp2string(timestamp)} - - ); -} - -const MODE_OPTIONS = [ - { key: 'all', text: '全部用户', value: 'all' }, - { key: 'self', text: '当前用户', value: 'self' } -]; - -const LOG_OPTIONS = [ - { key: '0', text: '全部', value: 0 }, - { key: '1', text: '充值', value: 1 }, - { key: '2', text: '消费', value: 2 }, - { key: '3', text: '管理', value: 3 }, - { key: '4', text: '系统', value: 4 } -]; - -function renderType(type) { - switch (type) { - case 1: - return ; - case 2: - return ; - case 3: - return ; - case 4: - return ; - default: - return ; - } -} - -const LogsTable = () => { - const [logs, setLogs] = useState([]); - const [showStat, setShowStat] = useState(false); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searching, setSearching] = useState(false); - const [logType, setLogType] = useState(0); - const isAdminUser = isAdmin(); - let now = new Date(); - const [inputs, setInputs] = useState({ - username: '', - token_name: '', - model_name: '', - start_timestamp: timestamp2string(0), - end_timestamp: timestamp2string(now.getTime() / 1000 + 3600), - channel: '' - }); - const { username, token_name, model_name, start_timestamp, end_timestamp, channel } = inputs; - - const [stat, setStat] = useState({ - quota: 0, - token: 0 - }); - - const handleInputChange = (e, { name, value }) => { - setInputs((inputs) => ({ ...inputs, [name]: value })); - }; - - const getLogSelfStat = async () => { - let localStartTimestamp = Date.parse(start_timestamp) / 1000; - let localEndTimestamp = Date.parse(end_timestamp) / 1000; - let res = await API.get(`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`); - const { success, message, data } = res.data; - if (success) { - setStat(data); - } else { - showError(message); - } - }; - - const getLogStat = async () => { - let localStartTimestamp = Date.parse(start_timestamp) / 1000; - let localEndTimestamp = Date.parse(end_timestamp) / 1000; - let res = await API.get(`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`); - const { success, message, data } = res.data; - if (success) { - setStat(data); - } else { - showError(message); - } - }; - - const handleEyeClick = async () => { - if (!showStat) { - if (isAdminUser) { - await getLogStat(); - } else { - await getLogSelfStat(); - } - } - setShowStat(!showStat); - }; - - const loadLogs = async (startIdx) => { - let url = ''; - let localStartTimestamp = Date.parse(start_timestamp) / 1000; - let localEndTimestamp = Date.parse(end_timestamp) / 1000; - if (isAdminUser) { - url = `/api/log/?p=${startIdx}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`; - } else { - url = `/api/log/self/?p=${startIdx}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; - } - const res = await API.get(url); - const { success, message, data } = res.data; - if (success) { - if (startIdx === 0) { - setLogs(data); - } else { - let newLogs = [...logs]; - newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); - setLogs(newLogs); - } - } else { - showError(message); - } - setLoading(false); - }; - - const onPaginationChange = (e, { activePage }) => { - (async () => { - if (activePage === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) { - // In this case we have to load more data and then append them. - await loadLogs(activePage - 1); - } - setActivePage(activePage); - })(); - }; - - const refresh = async () => { - setLoading(true); - setActivePage(1); - await loadLogs(0); - }; - - useEffect(() => { - refresh().then(); - }, [logType]); - - const searchLogs = async () => { - if (searchKeyword === '') { - // if keyword is blank, load files instead. - await loadLogs(0); - setActivePage(1); - return; - } - setSearching(true); - const res = await API.get(`/api/log/self/search?keyword=${searchKeyword}`); - const { success, message, data } = res.data; - if (success) { - setLogs(data); - setActivePage(1); - } else { - showError(message); - } - setSearching(false); - }; - - const handleKeywordChange = async (e, { value }) => { - setSearchKeyword(value.trim()); - }; - - const sortLog = (key) => { - if (logs.length === 0) return; - setLoading(true); - let sortedLogs = [...logs]; - if (typeof sortedLogs[0][key] === 'string') { - sortedLogs.sort((a, b) => { - return ('' + a[key]).localeCompare(b[key]); - }); - } else { - sortedLogs.sort((a, b) => { - if (a[key] === b[key]) return 0; - if (a[key] > b[key]) return -1; - if (a[key] < b[key]) return 1; - }); - } - if (sortedLogs[0].id === logs[0].id) { - sortedLogs.reverse(); - } - setLogs(sortedLogs); - setLoading(false); - }; - - return ( - <> - -
- 使用明细(总消耗额度: - {showStat && renderQuota(stat.quota)} - {!showStat && 点击查看} - ) -
-
- - - - - - 查询 - - { - isAdminUser && <> - - - - - - - } -
- - - - { - sortLog('created_time'); - }} - width={3} - > - 时间 - - { - isAdminUser && { - sortLog('channel'); - }} - width={1} - > - 渠道 - - } - { - isAdminUser && { - sortLog('username'); - }} - width={1} - > - 用户 - - } - { - sortLog('token_name'); - }} - width={1} - > - 令牌 - - { - sortLog('type'); - }} - width={1} - > - 类型 - - { - sortLog('model_name'); - }} - width={2} - > - 模型 - - { - sortLog('prompt_tokens'); - }} - width={1} - > - 提示 - - { - sortLog('completion_tokens'); - }} - width={1} - > - 补全 - - { - sortLog('quota'); - }} - width={1} - > - 额度 - - { - sortLog('content'); - }} - width={isAdminUser ? 4 : 6} - > - 详情 - - - - - - {logs - .slice( - (activePage - 1) * ITEMS_PER_PAGE, - activePage * ITEMS_PER_PAGE - ) - .map((log, idx) => { - if (log.deleted) return <>; - return ( - - {renderTimestamp(log.created_at)} - { - isAdminUser && ( - {log.channel ? : ''} - ) - } - { - isAdminUser && ( - {log.username ? : ''} - ) - } - {log.token_name ? : ''} - {renderType(log.type)} - {log.model_name ? : ''} - {log.prompt_tokens ? log.prompt_tokens : ''} - {log.completion_tokens ? log.completion_tokens : ''} - {log.quota ? renderQuota(log.quota, 6) : ''} - {log.content} - - ); - })} - - - - - -
-
- - ); -}; - -export default LogsTable; diff --git a/web/src/components/OperationSetting.js b/web/src/components/OperationSetting.js deleted file mode 100644 index bf8b5ffd..00000000 --- a/web/src/components/OperationSetting.js +++ /dev/null @@ -1,360 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Divider, Form, Grid, Header } from 'semantic-ui-react'; -import { API, showError, showSuccess, timestamp2string, verifyJSON } from '../helpers'; - -const OperationSetting = () => { - let now = new Date(); - let [inputs, setInputs] = useState({ - QuotaForNewUser: 0, - QuotaForInviter: 0, - QuotaForInvitee: 0, - QuotaRemindThreshold: 0, - PreConsumedQuota: 0, - ModelRatio: '', - GroupRatio: '', - TopUpLink: '', - ChatLink: '', - QuotaPerUnit: 0, - AutomaticDisableChannelEnabled: '', - ChannelDisableThreshold: 0, - LogConsumeEnabled: '', - DisplayInCurrencyEnabled: '', - DisplayTokenStatEnabled: '', - ApproximateTokenEnabled: '', - RetryTimes: 0 - }); - const [originInputs, setOriginInputs] = useState({}); - let [loading, setLoading] = useState(false); - let [historyTimestamp, setHistoryTimestamp] = useState(timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600)); // a month ago - - const getOptions = async () => { - const res = await API.get('/api/option/'); - const { success, message, data } = res.data; - if (success) { - let newInputs = {}; - data.forEach((item) => { - if (item.key === 'ModelRatio' || item.key === 'GroupRatio') { - item.value = JSON.stringify(JSON.parse(item.value), null, 2); - } - newInputs[item.key] = item.value; - }); - setInputs(newInputs); - setOriginInputs(newInputs); - } else { - showError(message); - } - }; - - useEffect(() => { - getOptions().then(); - }, []); - - const updateOption = async (key, value) => { - setLoading(true); - if (key.endsWith('Enabled')) { - value = inputs[key] === 'true' ? 'false' : 'true'; - } - const res = await API.put('/api/option/', { - key, - value - }); - const { success, message } = res.data; - if (success) { - setInputs((inputs) => ({ ...inputs, [key]: value })); - } else { - showError(message); - } - setLoading(false); - }; - - const handleInputChange = async (e, { name, value }) => { - if (name.endsWith('Enabled')) { - await updateOption(name, value); - } else { - setInputs((inputs) => ({ ...inputs, [name]: value })); - } - }; - - const submitConfig = async (group) => { - switch (group) { - case 'monitor': - if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) { - await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold); - } - if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) { - await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold); - } - break; - case 'ratio': - if (originInputs['ModelRatio'] !== inputs.ModelRatio) { - if (!verifyJSON(inputs.ModelRatio)) { - showError('模型倍率不是合法的 JSON 字符串'); - return; - } - await updateOption('ModelRatio', inputs.ModelRatio); - } - if (originInputs['GroupRatio'] !== inputs.GroupRatio) { - if (!verifyJSON(inputs.GroupRatio)) { - showError('分组倍率不是合法的 JSON 字符串'); - return; - } - await updateOption('GroupRatio', inputs.GroupRatio); - } - break; - case 'quota': - if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) { - await updateOption('QuotaForNewUser', inputs.QuotaForNewUser); - } - if (originInputs['QuotaForInvitee'] !== inputs.QuotaForInvitee) { - await updateOption('QuotaForInvitee', inputs.QuotaForInvitee); - } - if (originInputs['QuotaForInviter'] !== inputs.QuotaForInviter) { - await updateOption('QuotaForInviter', inputs.QuotaForInviter); - } - if (originInputs['PreConsumedQuota'] !== inputs.PreConsumedQuota) { - await updateOption('PreConsumedQuota', inputs.PreConsumedQuota); - } - break; - case 'general': - if (originInputs['TopUpLink'] !== inputs.TopUpLink) { - await updateOption('TopUpLink', inputs.TopUpLink); - } - if (originInputs['ChatLink'] !== inputs.ChatLink) { - await updateOption('ChatLink', inputs.ChatLink); - } - if (originInputs['QuotaPerUnit'] !== inputs.QuotaPerUnit) { - await updateOption('QuotaPerUnit', inputs.QuotaPerUnit); - } - if (originInputs['RetryTimes'] !== inputs.RetryTimes) { - await updateOption('RetryTimes', inputs.RetryTimes); - } - break; - } - }; - - const deleteHistoryLogs = async () => { - console.log(inputs); - const res = await API.delete(`/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`); - const { success, message, data } = res.data; - if (success) { - showSuccess(`${data} 条日志已清理!`); - return; - } - showError('日志清理失败:' + message); - }; - - return ( - - -
-
- 通用设置 -
- - - - - - - - - - - - { - submitConfig('general').then(); - }}>保存通用设置 - -
- 日志设置 -
- - - - - { - setHistoryTimestamp(value); - }} /> - - { - deleteHistoryLogs().then(); - }}>清理历史日志 - -
- 监控设置 -
- - - - - - - - { - submitConfig('monitor').then(); - }}>保存监控设置 - -
- 额度设置 -
- - - - - - - { - submitConfig('quota').then(); - }}>保存额度设置 - -
- 倍率设置 -
- - - - - - - { - submitConfig('ratio').then(); - }}>保存倍率设置 - -
-
- ); -}; - -export default OperationSetting; diff --git a/web/src/components/OtherSetting.js b/web/src/components/OtherSetting.js deleted file mode 100644 index 526a7d86..00000000 --- a/web/src/components/OtherSetting.js +++ /dev/null @@ -1,207 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Divider, Form, Grid, Header, Message, Modal } from 'semantic-ui-react'; -import { API, showError, showSuccess } from '../helpers'; -import { marked } from 'marked'; - -const OtherSetting = () => { - let [inputs, setInputs] = useState({ - Footer: '', - Notice: '', - About: '', - SystemName: '', - Logo: '', - HomePageContent: '' - }); - let [loading, setLoading] = useState(false); - const [showUpdateModal, setShowUpdateModal] = useState(false); - const [updateData, setUpdateData] = useState({ - tag_name: '', - content: '' - }); - - const getOptions = async () => { - const res = await API.get('/api/option/'); - const { success, message, data } = res.data; - if (success) { - let newInputs = {}; - data.forEach((item) => { - if (item.key in inputs) { - newInputs[item.key] = item.value; - } - }); - setInputs(newInputs); - } else { - showError(message); - } - }; - - useEffect(() => { - getOptions().then(); - }, []); - - const updateOption = async (key, value) => { - setLoading(true); - const res = await API.put('/api/option/', { - key, - value - }); - const { success, message } = res.data; - if (success) { - setInputs((inputs) => ({ ...inputs, [key]: value })); - } else { - showError(message); - } - setLoading(false); - }; - - const handleInputChange = async (e, { name, value }) => { - setInputs((inputs) => ({ ...inputs, [name]: value })); - }; - - const submitNotice = async () => { - await updateOption('Notice', inputs.Notice); - }; - - const submitFooter = async () => { - await updateOption('Footer', inputs.Footer); - }; - - const submitSystemName = async () => { - await updateOption('SystemName', inputs.SystemName); - }; - - const submitLogo = async () => { - await updateOption('Logo', inputs.Logo); - }; - - const submitAbout = async () => { - await updateOption('About', inputs.About); - }; - - const submitOption = async (key) => { - await updateOption(key, inputs[key]); - }; - - const openGitHubRelease = () => { - window.location = - 'https://github.com/songquanpeng/one-api/releases/latest'; - }; - - const checkUpdate = async () => { - const res = await API.get( - 'https://api.github.com/repos/songquanpeng/one-api/releases/latest' - ); - const { tag_name, body } = res.data; - if (tag_name === process.env.REACT_APP_VERSION) { - showSuccess(`已是最新版本:${tag_name}`); - } else { - setUpdateData({ - tag_name: tag_name, - content: marked.parse(body) - }); - setShowUpdateModal(true); - } - }; - - return ( - - -
-
通用设置
- 检查更新 - - - - 保存公告 - -
个性化设置
- - - - 设置系统名称 - - - - 设置 Logo - - - - submitOption('HomePageContent')}>保存首页内容 - - - - 保存关于 - 移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。 - - - - 设置页脚 - -
- setShowUpdateModal(false)} - onOpen={() => setShowUpdateModal(true)} - open={showUpdateModal} - > - 新版本:{updateData.tag_name} - - -
-
-
- - - - - - -
- ); -}; - -export default PasswordResetConfirm; diff --git a/web/src/components/PasswordResetForm.js b/web/src/components/PasswordResetForm.js deleted file mode 100644 index f3610d3a..00000000 --- a/web/src/components/PasswordResetForm.js +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; -import { API, showError, showInfo, showSuccess } from '../helpers'; -import Turnstile from 'react-turnstile'; - -const PasswordResetForm = () => { - const [inputs, setInputs] = useState({ - email: '' - }); - const { email } = inputs; - - const [loading, setLoading] = useState(false); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [disableButton, setDisableButton] = useState(false); - const [countdown, setCountdown] = useState(30); - - useEffect(() => { - let countdownInterval = null; - if (disableButton && countdown > 0) { - countdownInterval = setInterval(() => { - setCountdown(countdown - 1); - }, 1000); - } else if (countdown === 0) { - setDisableButton(false); - setCountdown(30); - } - return () => clearInterval(countdownInterval); - }, [disableButton, countdown]); - - function handleChange(e) { - const { name, value } = e.target; - setInputs(inputs => ({ ...inputs, [name]: value })); - } - - async function handleSubmit(e) { - setDisableButton(true); - if (!email) return; - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - const res = await API.get( - `/api/reset_password?email=${email}&turnstile=${turnstileToken}` - ); - const { success, message } = res.data; - if (success) { - showSuccess('重置邮件发送成功,请检查邮箱!'); - setInputs({ ...inputs, email: '' }); - } else { - showError(message); - } - setLoading(false); - } - - return ( - - -
- 密码重置 -
-
- - - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} - - -
-
-
- ); -}; - -export default PasswordResetForm; diff --git a/web/src/components/PersonalSetting.js b/web/src/components/PersonalSetting.js deleted file mode 100644 index 6baf1f35..00000000 --- a/web/src/components/PersonalSetting.js +++ /dev/null @@ -1,376 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react'; -import { Link, useNavigate } from 'react-router-dom'; -import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; -import Turnstile from 'react-turnstile'; -import { UserContext } from '../context/User'; -import { onGitHubOAuthClicked } from './utils'; - -const PersonalSetting = () => { - const [userState, userDispatch] = useContext(UserContext); - let navigate = useNavigate(); - - const [inputs, setInputs] = useState({ - wechat_verification_code: '', - email_verification_code: '', - email: '', - self_account_deletion_confirmation: '' - }); - const [status, setStatus] = useState({}); - const [showWeChatBindModal, setShowWeChatBindModal] = useState(false); - const [showEmailBindModal, setShowEmailBindModal] = useState(false); - const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [loading, setLoading] = useState(false); - const [disableButton, setDisableButton] = useState(false); - const [countdown, setCountdown] = useState(30); - const [affLink, setAffLink] = useState(""); - const [systemToken, setSystemToken] = useState(""); - - useEffect(() => { - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setStatus(status); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - } - }, []); - - useEffect(() => { - let countdownInterval = null; - if (disableButton && countdown > 0) { - countdownInterval = setInterval(() => { - setCountdown(countdown - 1); - }, 1000); - } else if (countdown === 0) { - setDisableButton(false); - setCountdown(30); - } - return () => clearInterval(countdownInterval); // Clean up on unmount - }, [disableButton, countdown]); - - const handleInputChange = (e, { name, value }) => { - setInputs((inputs) => ({ ...inputs, [name]: value })); - }; - - const generateAccessToken = async () => { - const res = await API.get('/api/user/token'); - const { success, message, data } = res.data; - if (success) { - setSystemToken(data); - setAffLink(""); - await copy(data); - showSuccess(`令牌已重置并已复制到剪贴板`); - } else { - showError(message); - } - }; - - const getAffLink = async () => { - const res = await API.get('/api/user/aff'); - const { success, message, data } = res.data; - if (success) { - let link = `${window.location.origin}/register?aff=${data}`; - setAffLink(link); - setSystemToken(""); - await copy(link); - showSuccess(`邀请链接已复制到剪切板`); - } else { - showError(message); - } - }; - - const handleAffLinkClick = async (e) => { - e.target.select(); - await copy(e.target.value); - showSuccess(`邀请链接已复制到剪切板`); - }; - - const handleSystemTokenClick = async (e) => { - e.target.select(); - await copy(e.target.value); - showSuccess(`系统令牌已复制到剪切板`); - }; - - const deleteAccount = async () => { - if (inputs.self_account_deletion_confirmation !== userState.user.username) { - showError('请输入你的账户名以确认删除!'); - return; - } - - const res = await API.delete('/api/user/self'); - const { success, message } = res.data; - - if (success) { - showSuccess('账户已删除!'); - await API.get('/api/user/logout'); - userDispatch({ type: 'logout' }); - localStorage.removeItem('user'); - navigate('/login'); - } else { - showError(message); - } - }; - - const bindWeChat = async () => { - if (inputs.wechat_verification_code === '') return; - const res = await API.get( - `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}` - ); - const { success, message } = res.data; - if (success) { - showSuccess('微信账户绑定成功!'); - setShowWeChatBindModal(false); - } else { - showError(message); - } - }; - - const sendVerificationCode = async () => { - setDisableButton(true); - if (inputs.email === '') return; - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - const res = await API.get( - `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}` - ); - const { success, message } = res.data; - if (success) { - showSuccess('验证码发送成功,请检查邮箱!'); - } else { - showError(message); - } - setLoading(false); - }; - - const bindEmail = async () => { - if (inputs.email_verification_code === '') return; - setLoading(true); - const res = await API.get( - `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}` - ); - const { success, message } = res.data; - if (success) { - showSuccess('邮箱账户绑定成功!'); - setShowEmailBindModal(false); - } else { - showError(message); - } - setLoading(false); - }; - - return ( -
-
通用设置
- - 注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI 相关的服务,请知悉。 - - - - - - - {systemToken && ( - - )} - {affLink && ( - - )} - -
账号绑定
- { - status.wechat_login && ( - - ) - } - setShowWeChatBindModal(false)} - onOpen={() => setShowWeChatBindModal(true)} - open={showWeChatBindModal} - size={'mini'} - > - - - -
-

- 微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效) -

-
-
- - - -
-
-
- { - status.github_oauth && ( - - ) - } - - setShowEmailBindModal(false)} - onOpen={() => setShowEmailBindModal(true)} - open={showEmailBindModal} - size={'tiny'} - style={{ maxWidth: '450px' }} - > - 绑定邮箱地址 - - -
- - {disableButton ? `重新发送(${countdown})` : '获取验证码'} - - } - /> - - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} -
- -
- -
- -
-
-
- setShowAccountDeleteModal(false)} - onOpen={() => setShowAccountDeleteModal(true)} - open={showAccountDeleteModal} - size={'tiny'} - style={{ maxWidth: '450px' }} - > - 危险操作 - - 您正在删除自己的帐户,将清空所有数据且不可恢复 - -
- - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} -
- -
- -
- -
-
-
-
- ); -}; - -export default PersonalSetting; diff --git a/web/src/components/PrivateRoute.js b/web/src/components/PrivateRoute.js deleted file mode 100644 index f7cc7248..00000000 --- a/web/src/components/PrivateRoute.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Navigate } from 'react-router-dom'; - -import { history } from '../helpers'; - - -function PrivateRoute({ children }) { - if (!localStorage.getItem('user')) { - return ; - } - return children; -} - -export { PrivateRoute }; \ No newline at end of file diff --git a/web/src/components/RedemptionsTable.js b/web/src/components/RedemptionsTable.js deleted file mode 100644 index dfd59685..00000000 --- a/web/src/components/RedemptionsTable.js +++ /dev/null @@ -1,320 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Form, Label, Popup, Pagination, Table } from 'semantic-ui-react'; -import { Link } from 'react-router-dom'; -import { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers'; - -import { ITEMS_PER_PAGE } from '../constants'; -import { renderQuota } from '../helpers/render'; - -function renderTimestamp(timestamp) { - return ( - <> - {timestamp2string(timestamp)} - - ); -} - -function renderStatus(status) { - switch (status) { - case 1: - return ; - case 2: - return ; - case 3: - return ; - default: - return ; - } -} - -const RedemptionsTable = () => { - const [redemptions, setRedemptions] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searching, setSearching] = useState(false); - - const loadRedemptions = async (startIdx) => { - const res = await API.get(`/api/redemption/?p=${startIdx}`); - const { success, message, data } = res.data; - if (success) { - if (startIdx === 0) { - setRedemptions(data); - } else { - let newRedemptions = redemptions; - newRedemptions.push(...data); - setRedemptions(newRedemptions); - } - } else { - showError(message); - } - setLoading(false); - }; - - const onPaginationChange = (e, { activePage }) => { - (async () => { - if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) { - // In this case we have to load more data and then append them. - await loadRedemptions(activePage - 1); - } - setActivePage(activePage); - })(); - }; - - useEffect(() => { - loadRedemptions(0) - .then() - .catch((reason) => { - showError(reason); - }); - }, []); - - const manageRedemption = async (id, action, idx) => { - let data = { id }; - let res; - switch (action) { - case 'delete': - res = await API.delete(`/api/redemption/${id}/`); - break; - case 'enable': - data.status = 1; - res = await API.put('/api/redemption/?status_only=true', data); - break; - case 'disable': - data.status = 2; - res = await API.put('/api/redemption/?status_only=true', data); - break; - } - const { success, message } = res.data; - if (success) { - showSuccess('操作成功完成!'); - let redemption = res.data.data; - let newRedemptions = [...redemptions]; - let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; - if (action === 'delete') { - newRedemptions[realIdx].deleted = true; - } else { - newRedemptions[realIdx].status = redemption.status; - } - setRedemptions(newRedemptions); - } else { - showError(message); - } - }; - - const searchRedemptions = async () => { - if (searchKeyword === '') { - // if keyword is blank, load files instead. - await loadRedemptions(0); - setActivePage(1); - return; - } - setSearching(true); - const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`); - const { success, message, data } = res.data; - if (success) { - setRedemptions(data); - setActivePage(1); - } else { - showError(message); - } - setSearching(false); - }; - - const handleKeywordChange = async (e, { value }) => { - setSearchKeyword(value.trim()); - }; - - const sortRedemption = (key) => { - if (redemptions.length === 0) return; - setLoading(true); - let sortedRedemptions = [...redemptions]; - sortedRedemptions.sort((a, b) => { - if (!isNaN(a[key])) { - // If the value is numeric, subtract to sort - return a[key] - b[key]; - } else { - // If the value is not numeric, sort as strings - return ('' + a[key]).localeCompare(b[key]); - } - }); - if (sortedRedemptions[0].id === redemptions[0].id) { - sortedRedemptions.reverse(); - } - setRedemptions(sortedRedemptions); - setLoading(false); - }; - - return ( - <> -
- - - - - - - { - sortRedemption('id'); - }} - > - ID - - { - sortRedemption('name'); - }} - > - 名称 - - { - sortRedemption('status'); - }} - > - 状态 - - { - sortRedemption('quota'); - }} - > - 额度 - - { - sortRedemption('created_time'); - }} - > - 创建时间 - - { - sortRedemption('redeemed_time'); - }} - > - 兑换时间 - - 操作 - - - - - {redemptions - .slice( - (activePage - 1) * ITEMS_PER_PAGE, - activePage * ITEMS_PER_PAGE - ) - .map((redemption, idx) => { - if (redemption.deleted) return <>; - return ( - - {redemption.id} - {redemption.name ? redemption.name : '无'} - {renderStatus(redemption.status)} - {renderQuota(redemption.quota)} - {renderTimestamp(redemption.created_time)} - {redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "尚未兑换"} - -
- - - 删除 - - } - on='click' - flowing - hoverable - > - - - - -
-
-
- ); - })} -
- - - - - - - - - -
- - ); -}; - -export default RedemptionsTable; diff --git a/web/src/components/RegisterForm.js b/web/src/components/RegisterForm.js deleted file mode 100644 index f91d6da0..00000000 --- a/web/src/components/RegisterForm.js +++ /dev/null @@ -1,194 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Form, Grid, Header, Image, Message, Segment } from 'semantic-ui-react'; -import { Link, useNavigate } from 'react-router-dom'; -import { API, getLogo, showError, showInfo, showSuccess } from '../helpers'; -import Turnstile from 'react-turnstile'; - -const RegisterForm = () => { - const [inputs, setInputs] = useState({ - username: '', - password: '', - password2: '', - email: '', - verification_code: '' - }); - const { username, password, password2 } = inputs; - const [showEmailVerification, setShowEmailVerification] = useState(false); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [loading, setLoading] = useState(false); - const logo = getLogo(); - let affCode = new URLSearchParams(window.location.search).get('aff'); - if (affCode) { - localStorage.setItem('aff', affCode); - } - - useEffect(() => { - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setShowEmailVerification(status.email_verification); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - } - }); - - let navigate = useNavigate(); - - function handleChange(e) { - const { name, value } = e.target; - console.log(name, value); - setInputs((inputs) => ({ ...inputs, [name]: value })); - } - - async function handleSubmit(e) { - if (password.length < 8) { - showInfo('密码长度不得小于 8 位!'); - return; - } - if (password !== password2) { - showInfo('两次输入的密码不一致'); - return; - } - if (username && password) { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - if (!affCode) { - affCode = localStorage.getItem('aff'); - } - inputs.aff_code = affCode; - const res = await API.post( - `/api/user/register?turnstile=${turnstileToken}`, - inputs - ); - const { success, message } = res.data; - if (success) { - navigate('/login'); - showSuccess('注册成功!'); - } else { - showError(message); - } - setLoading(false); - } - } - - const sendVerificationCode = async () => { - if (inputs.email === '') return; - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - const res = await API.get( - `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}` - ); - const { success, message } = res.data; - if (success) { - showSuccess('验证码发送成功,请检查你的邮箱!'); - } else { - showError(message); - } - setLoading(false); - }; - - return ( - - -
- 新用户注册 -
-
- - - - - {showEmailVerification ? ( - <> - - 获取验证码 - - } - /> - - - ) : ( - <> - )} - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} - - -
- - 已有账户? - - 点击登录 - - -
-
- ); -}; - -export default RegisterForm; diff --git a/web/src/components/SystemSetting.js b/web/src/components/SystemSetting.js deleted file mode 100644 index 7b34ce5b..00000000 --- a/web/src/components/SystemSetting.js +++ /dev/null @@ -1,537 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Divider, Form, Grid, Header, Modal, Message } from 'semantic-ui-react'; -import { API, removeTrailingSlash, showError } from '../helpers'; - -const SystemSetting = () => { - let [inputs, setInputs] = useState({ - PasswordLoginEnabled: '', - PasswordRegisterEnabled: '', - EmailVerificationEnabled: '', - GitHubOAuthEnabled: '', - GitHubClientId: '', - GitHubClientSecret: '', - Notice: '', - SMTPServer: '', - SMTPPort: '', - SMTPAccount: '', - SMTPFrom: '', - SMTPToken: '', - ServerAddress: '', - Footer: '', - WeChatAuthEnabled: '', - WeChatServerAddress: '', - WeChatServerToken: '', - WeChatAccountQRCodeImageURL: '', - TurnstileCheckEnabled: '', - TurnstileSiteKey: '', - TurnstileSecretKey: '', - RegisterEnabled: '', - EmailDomainRestrictionEnabled: '', - EmailDomainWhitelist: '' - }); - const [originInputs, setOriginInputs] = useState({}); - let [loading, setLoading] = useState(false); - const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]); - const [restrictedDomainInput, setRestrictedDomainInput] = useState(''); - const [showPasswordWarningModal, setShowPasswordWarningModal] = useState(false); - - const getOptions = async () => { - const res = await API.get('/api/option/'); - const { success, message, data } = res.data; - if (success) { - let newInputs = {}; - data.forEach((item) => { - newInputs[item.key] = item.value; - }); - setInputs({ - ...newInputs, - EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(',') - }); - setOriginInputs(newInputs); - - setEmailDomainWhitelist(newInputs.EmailDomainWhitelist.split(',').map((item) => { - return { key: item, text: item, value: item }; - })); - } else { - showError(message); - } - }; - - useEffect(() => { - getOptions().then(); - }, []); - - const updateOption = async (key, value) => { - setLoading(true); - switch (key) { - case 'PasswordLoginEnabled': - case 'PasswordRegisterEnabled': - case 'EmailVerificationEnabled': - case 'GitHubOAuthEnabled': - case 'WeChatAuthEnabled': - case 'TurnstileCheckEnabled': - case 'EmailDomainRestrictionEnabled': - case 'RegisterEnabled': - value = inputs[key] === 'true' ? 'false' : 'true'; - break; - default: - break; - } - const res = await API.put('/api/option/', { - key, - value - }); - const { success, message } = res.data; - if (success) { - if (key === 'EmailDomainWhitelist') { - value = value.split(','); - } - setInputs((inputs) => ({ - ...inputs, [key]: value - })); - } else { - showError(message); - } - setLoading(false); - }; - - const handleInputChange = async (e, { name, value }) => { - if (name === 'PasswordLoginEnabled' && inputs[name] === 'true') { - // block disabling password login - setShowPasswordWarningModal(true); - return; - } - if ( - name === 'Notice' || - name.startsWith('SMTP') || - name === 'ServerAddress' || - name === 'GitHubClientId' || - name === 'GitHubClientSecret' || - name === 'WeChatServerAddress' || - name === 'WeChatServerToken' || - name === 'WeChatAccountQRCodeImageURL' || - name === 'TurnstileSiteKey' || - name === 'TurnstileSecretKey' || - name === 'EmailDomainWhitelist' - ) { - setInputs((inputs) => ({ ...inputs, [name]: value })); - } else { - await updateOption(name, value); - } - }; - - const submitServerAddress = async () => { - let ServerAddress = removeTrailingSlash(inputs.ServerAddress); - await updateOption('ServerAddress', ServerAddress); - }; - - const submitSMTP = async () => { - if (originInputs['SMTPServer'] !== inputs.SMTPServer) { - await updateOption('SMTPServer', inputs.SMTPServer); - } - if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) { - await updateOption('SMTPAccount', inputs.SMTPAccount); - } - if (originInputs['SMTPFrom'] !== inputs.SMTPFrom) { - await updateOption('SMTPFrom', inputs.SMTPFrom); - } - if ( - originInputs['SMTPPort'] !== inputs.SMTPPort && - inputs.SMTPPort !== '' - ) { - await updateOption('SMTPPort', inputs.SMTPPort); - } - if ( - originInputs['SMTPToken'] !== inputs.SMTPToken && - inputs.SMTPToken !== '' - ) { - await updateOption('SMTPToken', inputs.SMTPToken); - } - }; - - - const submitEmailDomainWhitelist = async () => { - if ( - originInputs['EmailDomainWhitelist'] !== inputs.EmailDomainWhitelist.join(',') && - inputs.SMTPToken !== '' - ) { - await updateOption('EmailDomainWhitelist', inputs.EmailDomainWhitelist.join(',')); - } - }; - - const submitWeChat = async () => { - if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) { - await updateOption( - 'WeChatServerAddress', - removeTrailingSlash(inputs.WeChatServerAddress) - ); - } - if ( - originInputs['WeChatAccountQRCodeImageURL'] !== - inputs.WeChatAccountQRCodeImageURL - ) { - await updateOption( - 'WeChatAccountQRCodeImageURL', - inputs.WeChatAccountQRCodeImageURL - ); - } - if ( - originInputs['WeChatServerToken'] !== inputs.WeChatServerToken && - inputs.WeChatServerToken !== '' - ) { - await updateOption('WeChatServerToken', inputs.WeChatServerToken); - } - }; - - const submitGitHubOAuth = async () => { - if (originInputs['GitHubClientId'] !== inputs.GitHubClientId) { - await updateOption('GitHubClientId', inputs.GitHubClientId); - } - if ( - originInputs['GitHubClientSecret'] !== inputs.GitHubClientSecret && - inputs.GitHubClientSecret !== '' - ) { - await updateOption('GitHubClientSecret', inputs.GitHubClientSecret); - } - }; - - const submitTurnstile = async () => { - if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) { - await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey); - } - if ( - originInputs['TurnstileSecretKey'] !== inputs.TurnstileSecretKey && - inputs.TurnstileSecretKey !== '' - ) { - await updateOption('TurnstileSecretKey', inputs.TurnstileSecretKey); - } - }; - - const submitNewRestrictedDomain = () => { - const localDomainList = inputs.EmailDomainWhitelist; - if (restrictedDomainInput !== '' && !localDomainList.includes(restrictedDomainInput)) { - setRestrictedDomainInput(''); - setInputs({ - ...inputs, - EmailDomainWhitelist: [...localDomainList, restrictedDomainInput], - }); - setEmailDomainWhitelist([...EmailDomainWhitelist, { - key: restrictedDomainInput, - text: restrictedDomainInput, - value: restrictedDomainInput, - }]); - } - } - - return ( - - -
-
通用设置
- - - - - 更新服务器地址 - - -
配置登录注册
- - - { - showPasswordWarningModal && - setShowPasswordWarningModal(false)} - size={'tiny'} - style={{ maxWidth: '450px' }} - > - 警告 - -

取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?

-
- - - - -
- } - - - - -
- - - - - -
- 配置邮箱域名白名单 - 用以防止恶意用户利用临时邮箱批量注册 -
- - - - - - { - submitNewRestrictedDomain(); - }}>填入 - } - onKeyDown={(e) => { - if (e.key === 'Enter') { - submitNewRestrictedDomain(); - } - }} - autoComplete='new-password' - placeholder='输入新的允许的邮箱域名' - value={restrictedDomainInput} - onChange={(e, { value }) => { - setRestrictedDomainInput(value); - }} - /> - - 保存邮箱域名白名单设置 - -
- 配置 SMTP - 用以支持系统的邮件发送 -
- - - - - - - - - - 保存 SMTP 设置 - -
- 配置 GitHub OAuth App - - 用以支持通过 GitHub 进行登录注册, - - 点击此处 - - 管理你的 GitHub OAuth App - -
- - Homepage URL 填 {inputs.ServerAddress} - ,Authorization callback URL 填{' '} - {`${inputs.ServerAddress}/oauth/github`} - - - - - - - 保存 GitHub OAuth 设置 - - -
- 配置 WeChat Server - - 用以支持通过微信进行登录注册, - - 点击此处 - - 了解 WeChat Server - -
- - - - - - - 保存 WeChat Server 设置 - - -
- 配置 Turnstile - - 用以支持用户校验, - - 点击此处 - - 管理你的 Turnstile Sites,推荐选择 Invisible Widget Type - -
- - - - - - 保存 Turnstile 设置 - - -
-
- ); -}; - -export default SystemSetting; diff --git a/web/src/components/TokensTable.js b/web/src/components/TokensTable.js deleted file mode 100644 index db4745e4..00000000 --- a/web/src/components/TokensTable.js +++ /dev/null @@ -1,449 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Dropdown, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react'; -import { Link } from 'react-router-dom'; -import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers'; - -import { ITEMS_PER_PAGE } from '../constants'; -import { renderQuota } from '../helpers/render'; - -const COPY_OPTIONS = [ - { key: 'next', text: 'ChatGPT Next Web', value: 'next' }, - { key: 'ama', text: 'AMA 问天', value: 'ama' }, - { key: 'opencat', text: 'OpenCat', value: 'opencat' }, -]; - -const OPEN_LINK_OPTIONS = [ - { key: 'ama', text: 'AMA 问天', value: 'ama' }, - { key: 'opencat', text: 'OpenCat', value: 'opencat' }, -]; - -function renderTimestamp(timestamp) { - return ( - <> - {timestamp2string(timestamp)} - - ); -} - -function renderStatus(status) { - switch (status) { - case 1: - return ; - case 2: - return ; - case 3: - return ; - case 4: - return ; - default: - return ; - } -} - -const TokensTable = () => { - const [tokens, setTokens] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searching, setSearching] = useState(false); - const [showTopUpModal, setShowTopUpModal] = useState(false); - const [targetTokenIdx, setTargetTokenIdx] = useState(0); - - const loadTokens = async (startIdx) => { - const res = await API.get(`/api/token/?p=${startIdx}`); - const { success, message, data } = res.data; - if (success) { - if (startIdx === 0) { - setTokens(data); - } else { - let newTokens = [...tokens]; - newTokens.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); - setTokens(newTokens); - } - } else { - showError(message); - } - setLoading(false); - }; - - const onPaginationChange = (e, { activePage }) => { - (async () => { - if (activePage === Math.ceil(tokens.length / ITEMS_PER_PAGE) + 1) { - // In this case we have to load more data and then append them. - await loadTokens(activePage - 1); - } - setActivePage(activePage); - })(); - }; - - const refresh = async () => { - setLoading(true); - await loadTokens(activePage - 1); - }; - - const onCopy = async (type, key) => { - let status = localStorage.getItem('status'); - let serverAddress = ''; - if (status) { - status = JSON.parse(status); - serverAddress = status.server_address; - } - if (serverAddress === '') { - serverAddress = window.location.origin; - } - let encodedServerAddress = encodeURIComponent(serverAddress); - const nextLink = localStorage.getItem('chat_link'); - let nextUrl; - - if (nextLink) { - nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; - } else { - nextUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; - } - - let url; - switch (type) { - case 'ama': - url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`; - break; - case 'opencat': - url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`; - break; - case 'next': - url = nextUrl; - break; - default: - url = `sk-${key}`; - } - if (await copy(url)) { - showSuccess('已复制到剪贴板!'); - } else { - showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。'); - setSearchKeyword(url); - } - }; - - const onOpenLink = async (type, key) => { - let status = localStorage.getItem('status'); - let serverAddress = ''; - if (status) { - status = JSON.parse(status); - serverAddress = status.server_address; - } - if (serverAddress === '') { - serverAddress = window.location.origin; - } - let encodedServerAddress = encodeURIComponent(serverAddress); - const chatLink = localStorage.getItem('chat_link'); - let defaultUrl; - - if (chatLink) { - defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; - } else { - defaultUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; - } - let url; - switch (type) { - case 'ama': - url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`; - break; - - case 'opencat': - url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`; - break; - - default: - url = defaultUrl; - } - - window.open(url, '_blank'); - } - - useEffect(() => { - loadTokens(0) - .then() - .catch((reason) => { - showError(reason); - }); - }, []); - - const manageToken = async (id, action, idx) => { - let data = { id }; - let res; - switch (action) { - case 'delete': - res = await API.delete(`/api/token/${id}/`); - break; - case 'enable': - data.status = 1; - res = await API.put('/api/token/?status_only=true', data); - break; - case 'disable': - data.status = 2; - res = await API.put('/api/token/?status_only=true', data); - break; - } - const { success, message } = res.data; - if (success) { - showSuccess('操作成功完成!'); - let token = res.data.data; - let newTokens = [...tokens]; - let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; - if (action === 'delete') { - newTokens[realIdx].deleted = true; - } else { - newTokens[realIdx].status = token.status; - } - setTokens(newTokens); - } else { - showError(message); - } - }; - - const searchTokens = async () => { - if (searchKeyword === '') { - // if keyword is blank, load files instead. - await loadTokens(0); - setActivePage(1); - return; - } - setSearching(true); - const res = await API.get(`/api/token/search?keyword=${searchKeyword}`); - const { success, message, data } = res.data; - if (success) { - setTokens(data); - setActivePage(1); - } else { - showError(message); - } - setSearching(false); - }; - - const handleKeywordChange = async (e, { value }) => { - setSearchKeyword(value.trim()); - }; - - const sortToken = (key) => { - if (tokens.length === 0) return; - setLoading(true); - let sortedTokens = [...tokens]; - sortedTokens.sort((a, b) => { - if (!isNaN(a[key])) { - // If the value is numeric, subtract to sort - return a[key] - b[key]; - } else { - // If the value is not numeric, sort as strings - return ('' + a[key]).localeCompare(b[key]); - } - }); - if (sortedTokens[0].id === tokens[0].id) { - sortedTokens.reverse(); - } - setTokens(sortedTokens); - setLoading(false); - }; - - return ( - <> -
- - - - - - - { - sortToken('name'); - }} - > - 名称 - - { - sortToken('status'); - }} - > - 状态 - - { - sortToken('used_quota'); - }} - > - 已用额度 - - { - sortToken('remain_quota'); - }} - > - 剩余额度 - - { - sortToken('created_time'); - }} - > - 创建时间 - - { - sortToken('expired_time'); - }} - > - 过期时间 - - 操作 - - - - - {tokens - .slice( - (activePage - 1) * ITEMS_PER_PAGE, - activePage * ITEMS_PER_PAGE - ) - .map((token, idx) => { - if (token.deleted) return <>; - return ( - - {token.name ? token.name : '无'} - {renderStatus(token.status)} - {renderQuota(token.used_quota)} - {token.unlimited_quota ? '无限制' : renderQuota(token.remain_quota, 2)} - {renderTimestamp(token.created_time)} - {token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)} - -
- - - ({ - ...option, - onClick: async () => { - await onCopy(option.value, token.key); - } - }))} - trigger={<>} - /> - - {' '} - - - ({ - ...option, - onClick: async () => { - await onOpenLink(option.value, token.key); - } - }))} - trigger={<>} - /> - - {' '} - - 删除 - - } - on='click' - flowing - hoverable - > - - - - -
-
-
- ); - })} -
- - - - - - - - - - -
- - ); -}; - -export default TokensTable; diff --git a/web/src/components/UsersTable.js b/web/src/components/UsersTable.js deleted file mode 100644 index ad4e9b49..00000000 --- a/web/src/components/UsersTable.js +++ /dev/null @@ -1,344 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react'; -import { Link } from 'react-router-dom'; -import { API, showError, showSuccess } from '../helpers'; - -import { ITEMS_PER_PAGE } from '../constants'; -import { renderGroup, renderNumber, renderQuota, renderText } from '../helpers/render'; - -function renderRole(role) { - switch (role) { - case 1: - return ; - case 10: - return ; - case 100: - return ; - default: - return ; - } -} - -const UsersTable = () => { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searching, setSearching] = useState(false); - - const loadUsers = async (startIdx) => { - const res = await API.get(`/api/user/?p=${startIdx}`); - const { success, message, data } = res.data; - if (success) { - if (startIdx === 0) { - setUsers(data); - } else { - let newUsers = users; - newUsers.push(...data); - setUsers(newUsers); - } - } else { - showError(message); - } - setLoading(false); - }; - - const onPaginationChange = (e, { activePage }) => { - (async () => { - if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) { - // In this case we have to load more data and then append them. - await loadUsers(activePage - 1); - } - setActivePage(activePage); - })(); - }; - - useEffect(() => { - loadUsers(0) - .then() - .catch((reason) => { - showError(reason); - }); - }, []); - - const manageUser = (username, action, idx) => { - (async () => { - const res = await API.post('/api/user/manage', { - username, - action - }); - const { success, message } = res.data; - if (success) { - showSuccess('操作成功完成!'); - let user = res.data.data; - let newUsers = [...users]; - let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; - if (action === 'delete') { - newUsers[realIdx].deleted = true; - } else { - newUsers[realIdx].status = user.status; - newUsers[realIdx].role = user.role; - } - setUsers(newUsers); - } else { - showError(message); - } - })(); - }; - - const renderStatus = (status) => { - switch (status) { - case 1: - return ; - case 2: - return ( - - ); - default: - return ( - - ); - } - }; - - const searchUsers = async () => { - if (searchKeyword === '') { - // if keyword is blank, load files instead. - await loadUsers(0); - setActivePage(1); - return; - } - setSearching(true); - const res = await API.get(`/api/user/search?keyword=${searchKeyword}`); - const { success, message, data } = res.data; - if (success) { - setUsers(data); - setActivePage(1); - } else { - showError(message); - } - setSearching(false); - }; - - const handleKeywordChange = async (e, { value }) => { - setSearchKeyword(value.trim()); - }; - - const sortUser = (key) => { - if (users.length === 0) return; - setLoading(true); - let sortedUsers = [...users]; - sortedUsers.sort((a, b) => { - if (!isNaN(a[key])) { - // If the value is numeric, subtract to sort - return a[key] - b[key]; - } else { - // If the value is not numeric, sort as strings - return ('' + a[key]).localeCompare(b[key]); - } - }); - if (sortedUsers[0].id === users[0].id) { - sortedUsers.reverse(); - } - setUsers(sortedUsers); - setLoading(false); - }; - - return ( - <> -
- - - - - - - { - sortUser('id'); - }} - > - ID - - { - sortUser('username'); - }} - > - 用户名 - - { - sortUser('group'); - }} - > - 分组 - - { - sortUser('quota'); - }} - > - 统计信息 - - { - sortUser('role'); - }} - > - 用户角色 - - { - sortUser('status'); - }} - > - 状态 - - 操作 - - - - - {users - .slice( - (activePage - 1) * ITEMS_PER_PAGE, - activePage * ITEMS_PER_PAGE - ) - .map((user, idx) => { - if (user.deleted) return <>; - return ( - - {user.id} - - {renderText(user.username, 15)}} - hoverable - /> - - {renderGroup(user.group)} - {/**/} - {/* {user.email ? {renderText(user.email, 24)}} /> : '无'}*/} - {/**/} - - {renderQuota(user.quota)}} /> - {renderQuota(user.used_quota)}} /> - {renderNumber(user.request_count)}} /> - - {renderRole(user.role)} - {renderStatus(user.status)} - -
- - - - 删除 - - } - on='click' - flowing - hoverable - > - - - - -
-
-
- ); - })} -
- - - - - - - - - -
- - ); -}; - -export default UsersTable; diff --git a/web/src/components/utils.js b/web/src/components/utils.js deleted file mode 100644 index 5363ba5e..00000000 --- a/web/src/components/utils.js +++ /dev/null @@ -1,20 +0,0 @@ -import { API, showError } from '../helpers'; - -export async function getOAuthState() { - const res = await API.get('/api/oauth/state'); - const { success, message, data } = res.data; - if (success) { - return data; - } else { - showError(message); - return ''; - } -} - -export async function onGitHubOAuthClicked(github_client_id) { - const state = await getOAuthState(); - if (!state) return; - window.open( - `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email` - ); -} \ No newline at end of file diff --git a/web/src/config.js b/web/src/config.js new file mode 100644 index 00000000..eeeda99a --- /dev/null +++ b/web/src/config.js @@ -0,0 +1,29 @@ +const config = { + // basename: only at build time to set, and Don't add '/' at end off BASENAME for breadcrumbs, also Don't put only '/' use blank('') instead, + // like '/berry-material-react/react/default' + basename: '/', + defaultPath: '/panel/dashboard', + fontFamily: `'Roboto', sans-serif, Helvetica, Arial, sans-serif`, + borderRadius: 12, + siteInfo: { + chat_link: '', + display_in_currency: true, + email_verification: false, + footer_html: '', + github_client_id: '', + github_oauth: false, + logo: '', + quota_per_unit: 500000, + server_address: '', + start_time: 0, + system_name: 'One API', + top_up_link: '', + turnstile_check: false, + turnstile_site_key: '', + version: '', + wechat_login: false, + wechat_qrcode: '' + } +}; + +export default config; diff --git a/web/src/constants/ChannelConstants.js b/web/src/constants/ChannelConstants.js new file mode 100644 index 00000000..f81fb994 --- /dev/null +++ b/web/src/constants/ChannelConstants.js @@ -0,0 +1,146 @@ +export const CHANNEL_OPTIONS = { + 1: { + key: 1, + text: 'OpenAI', + value: 1, + color: 'primary' + }, + 14: { + key: 14, + text: 'Anthropic Claude', + value: 14, + color: 'info' + }, + 3: { + key: 3, + text: 'Azure OpenAI', + value: 3, + color: 'orange' + }, + 11: { + key: 11, + text: 'Google PaLM2', + value: 11, + color: 'orange' + }, + 15: { + key: 15, + text: '百度文心千帆', + value: 15, + color: 'default' + }, + 17: { + key: 17, + text: '阿里通义千问', + value: 17, + color: 'default' + }, + 18: { + key: 18, + text: '讯飞星火认知', + value: 18, + color: 'default' + }, + 16: { + key: 16, + text: '智谱 ChatGLM', + value: 16, + color: 'default' + }, + 19: { + key: 19, + text: '360 智脑', + value: 19, + color: 'default' + }, + 23: { + key: 23, + text: '腾讯混元', + value: 23, + color: 'default' + }, + 24: { + key: 24, + text: 'Azure Speech', + value: 24, + color: 'orange' + }, + 8: { + key: 8, + text: '自定义渠道', + value: 8, + color: 'primary' + }, + 22: { + key: 22, + text: '知识库:FastGPT', + value: 22, + color: 'default' + }, + 21: { + key: 21, + text: '知识库:AI Proxy', + value: 21, + color: 'purple' + }, + 20: { + key: 20, + text: '代理:OpenRouter', + value: 20, + color: 'primary' + }, + 2: { + key: 2, + text: '代理:API2D', + value: 2, + color: 'primary' + }, + 5: { + key: 5, + text: '代理:OpenAI-SB', + value: 5, + color: 'primary' + }, + 7: { + key: 7, + text: '代理:OhMyGPT', + value: 7, + color: 'primary' + }, + 10: { + key: 10, + text: '代理:AI Proxy', + value: 10, + color: 'primary' + }, + 4: { + key: 4, + text: '代理:CloseAI', + value: 4, + color: 'primary' + }, + 6: { + key: 6, + text: '代理:OpenAI Max', + value: 6, + color: 'primary' + }, + 9: { + key: 9, + text: '代理:AI.LS', + value: 9, + color: 'primary' + }, + 12: { + key: 12, + text: '代理:API2GPT', + value: 12, + color: 'primary' + }, + 13: { + key: 13, + text: '代理:AIGC2D', + value: 13, + color: 'primary' + } +}; diff --git a/web/src/constants/common.constant.js b/web/src/constants/CommonConstants.js similarity index 100% rename from web/src/constants/common.constant.js rename to web/src/constants/CommonConstants.js diff --git a/web/src/constants/SnackbarConstants.js b/web/src/constants/SnackbarConstants.js new file mode 100644 index 00000000..a05c6652 --- /dev/null +++ b/web/src/constants/SnackbarConstants.js @@ -0,0 +1,27 @@ +export const snackbarConstants = { + Common: { + ERROR: { + variant: 'error', + autoHideDuration: 5000 + }, + WARNING: { + variant: 'warning', + autoHideDuration: 10000 + }, + SUCCESS: { + variant: 'success', + autoHideDuration: 1500 + }, + INFO: { + variant: 'info', + autoHideDuration: 3000 + }, + NOTICE: { + variant: 'info', + autoHideDuration: 20000 + } + }, + Mobile: { + anchorOrigin: { vertical: 'bottom', horizontal: 'center' } + } +}; diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js deleted file mode 100644 index 10a73f4c..00000000 --- a/web/src/constants/channel.constants.js +++ /dev/null @@ -1,26 +0,0 @@ -export const CHANNEL_OPTIONS = [ - { key: 1, text: 'OpenAI', value: 1, color: 'green' }, - { key: 14, text: 'Anthropic Claude', value: 14, color: 'black' }, - { key: 3, text: 'Azure OpenAI', value: 3, color: 'olive' }, - { key: 11, text: 'Google PaLM2', value: 11, color: 'orange' }, - { key: 15, text: '百度文心千帆', value: 15, color: 'blue' }, - { key: 17, text: '阿里通义千问', value: 17, color: 'orange' }, - { key: 18, text: '讯飞星火认知', value: 18, color: 'blue' }, - { key: 16, text: '智谱 ChatGLM', value: 16, color: 'violet' }, - { key: 19, text: '360 智脑', value: 19, color: 'blue' }, - { key: 23, text: '腾讯混元', value: 23, color: 'teal' }, - { key: 24, text: 'Azure Speech', value: 24, color: 'olive' }, - { key: 8, text: '自定义渠道', value: 8, color: 'pink' }, - { key: 22, text: '知识库:FastGPT', value: 22, color: 'blue' }, - { key: 21, text: '知识库:AI Proxy', value: 21, color: 'purple' }, - { key: 20, text: '代理:OpenRouter', value: 20, color: 'black' }, - { key: 2, text: '代理:API2D', value: 2, color: 'blue' }, - { key: 5, text: '代理:OpenAI-SB', value: 5, color: 'brown' }, - { key: 7, text: '代理:OhMyGPT', value: 7, color: 'purple' }, - { key: 10, text: '代理:AI Proxy', value: 10, color: 'purple' }, - { key: 4, text: '代理:CloseAI', value: 4, color: 'teal' }, - { key: 6, text: '代理:OpenAI Max', value: 6, color: 'violet' }, - { key: 9, text: '代理:AI.LS', value: 9, color: 'yellow' }, - { key: 12, text: '代理:API2GPT', value: 12, color: 'blue' }, - { key: 13, text: '代理:AIGC2D', value: 13, color: 'purple' } -]; \ No newline at end of file diff --git a/web/src/constants/index.js b/web/src/constants/index.js index e83152bc..716ef6aa 100644 --- a/web/src/constants/index.js +++ b/web/src/constants/index.js @@ -1,4 +1,3 @@ -export * from './toast.constants'; -export * from './user.constants'; -export * from './common.constant'; -export * from './channel.constants'; \ No newline at end of file +export * from './SnackbarConstants'; +export * from './CommonConstants'; +export * from './ChannelConstants'; diff --git a/web/src/constants/toast.constants.js b/web/src/constants/toast.constants.js deleted file mode 100644 index 50684722..00000000 --- a/web/src/constants/toast.constants.js +++ /dev/null @@ -1,7 +0,0 @@ -export const toastConstants = { - SUCCESS_TIMEOUT: 1500, - INFO_TIMEOUT: 3000, - ERROR_TIMEOUT: 5000, - WARNING_TIMEOUT: 10000, - NOTICE_TIMEOUT: 20000 -}; diff --git a/web/src/constants/user.constants.js b/web/src/constants/user.constants.js deleted file mode 100644 index 2680d8ef..00000000 --- a/web/src/constants/user.constants.js +++ /dev/null @@ -1,19 +0,0 @@ -export const userConstants = { - REGISTER_REQUEST: 'USERS_REGISTER_REQUEST', - REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS', - REGISTER_FAILURE: 'USERS_REGISTER_FAILURE', - - LOGIN_REQUEST: 'USERS_LOGIN_REQUEST', - LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS', - LOGIN_FAILURE: 'USERS_LOGIN_FAILURE', - - LOGOUT: 'USERS_LOGOUT', - - GETALL_REQUEST: 'USERS_GETALL_REQUEST', - GETALL_SUCCESS: 'USERS_GETALL_SUCCESS', - GETALL_FAILURE: 'USERS_GETALL_FAILURE', - - DELETE_REQUEST: 'USERS_DELETE_REQUEST', - DELETE_SUCCESS: 'USERS_DELETE_SUCCESS', - DELETE_FAILURE: 'USERS_DELETE_FAILURE' -}; diff --git a/web/src/context/Status/index.js b/web/src/context/Status/index.js deleted file mode 100644 index 71f0682b..00000000 --- a/web/src/context/Status/index.js +++ /dev/null @@ -1,19 +0,0 @@ -// contexts/User/index.jsx - -import React from 'react'; -import { initialState, reducer } from './reducer'; - -export const StatusContext = React.createContext({ - state: initialState, - dispatch: () => null, -}); - -export const StatusProvider = ({ children }) => { - const [state, dispatch] = React.useReducer(reducer, initialState); - - return ( - - {children} - - ); -}; \ No newline at end of file diff --git a/web/src/context/Status/reducer.js b/web/src/context/Status/reducer.js deleted file mode 100644 index ec9ac6ae..00000000 --- a/web/src/context/Status/reducer.js +++ /dev/null @@ -1,20 +0,0 @@ -export const reducer = (state, action) => { - switch (action.type) { - case 'set': - return { - ...state, - status: action.payload, - }; - case 'unset': - return { - ...state, - status: undefined, - }; - default: - return state; - } -}; - -export const initialState = { - status: undefined, -}; diff --git a/web/src/context/User/index.js b/web/src/context/User/index.js deleted file mode 100644 index c6671591..00000000 --- a/web/src/context/User/index.js +++ /dev/null @@ -1,19 +0,0 @@ -// contexts/User/index.jsx - -import React from "react" -import { reducer, initialState } from "./reducer" - -export const UserContext = React.createContext({ - state: initialState, - dispatch: () => null -}) - -export const UserProvider = ({ children }) => { - const [state, dispatch] = React.useReducer(reducer, initialState) - - return ( - - { children } - - ) -} \ No newline at end of file diff --git a/web/src/contexts/StatusContext.js b/web/src/contexts/StatusContext.js new file mode 100644 index 00000000..f79436ae --- /dev/null +++ b/web/src/contexts/StatusContext.js @@ -0,0 +1,63 @@ +import { useEffect, useCallback, createContext } from 'react'; +import { API } from 'utils/api'; +import { showNotice } from 'utils/common'; +import { SET_SITE_INFO } from 'store/actions'; +import { useDispatch } from 'react-redux'; + +export const LoadStatusContext = createContext(); + +// eslint-disable-next-line +const StatusProvider = ({ children }) => { + const dispatch = useDispatch(); + + const loadStatus = useCallback(async () => { + const res = await API.get('/api/status'); + const { success, data } = res.data; + let system_name = ''; + if (success) { + if (!data.chat_link) { + delete data.chat_link; + } + localStorage.setItem('siteInfo', JSON.stringify(data)); + localStorage.setItem('quota_per_unit', data.quota_per_unit); + localStorage.setItem('display_in_currency', data.display_in_currency); + dispatch({ type: SET_SITE_INFO, payload: data }); + if ( + data.version !== process.env.REACT_APP_VERSION && + data.version !== 'v0.0.0' && + data.version !== '' && + process.env.REACT_APP_VERSION !== '' + ) { + showNotice(`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`); + } + if (data.system_name) { + system_name = data.system_name; + } + } else { + const backupSiteInfo = localStorage.getItem('siteInfo'); + if (backupSiteInfo) { + const data = JSON.parse(backupSiteInfo); + if (data.system_name) { + system_name = data.system_name; + } + dispatch({ + type: SET_SITE_INFO, + payload: data + }); + } + showError('无法正常连接至服务器!'); + } + + if (system_name) { + document.title = system_name; + } + }, [dispatch]); + + useEffect(() => { + loadStatus().then(); + }, [loadStatus]); + + return {children} ; +}; + +export default StatusProvider; diff --git a/web/src/contexts/UserContext.js b/web/src/contexts/UserContext.js new file mode 100644 index 00000000..491da9d9 --- /dev/null +++ b/web/src/contexts/UserContext.js @@ -0,0 +1,29 @@ +// contexts/User/index.jsx +import React, { useEffect, useCallback, createContext, useState } from 'react'; +import { LOGIN } from 'store/actions'; +import { useDispatch } from 'react-redux'; + +export const UserContext = createContext(); + +// eslint-disable-next-line +const UserProvider = ({ children }) => { + const dispatch = useDispatch(); + const [isUserLoaded, setIsUserLoaded] = useState(false); + + const loadUser = useCallback(() => { + let user = localStorage.getItem('user'); + if (user) { + let data = JSON.parse(user); + dispatch({ type: LOGIN, payload: data }); + } + setIsUserLoaded(true); + }, [dispatch]); + + useEffect(() => { + loadUser(); + }, [loadUser]); + + return {children} ; +}; + +export default UserProvider; diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js deleted file mode 100644 index 35fdb1e9..00000000 --- a/web/src/helpers/api.js +++ /dev/null @@ -1,13 +0,0 @@ -import { showError } from './utils'; -import axios from 'axios'; - -export const API = axios.create({ - baseURL: process.env.REACT_APP_SERVER ? process.env.REACT_APP_SERVER : '', -}); - -API.interceptors.response.use( - (response) => response, - (error) => { - showError(error); - } -); diff --git a/web/src/helpers/auth-header.js b/web/src/helpers/auth-header.js deleted file mode 100644 index a8fe5f5a..00000000 --- a/web/src/helpers/auth-header.js +++ /dev/null @@ -1,10 +0,0 @@ -export function authHeader() { - // return authorization header with jwt token - let user = JSON.parse(localStorage.getItem('user')); - - if (user && user.token) { - return { 'Authorization': 'Bearer ' + user.token }; - } else { - return {}; - } -} \ No newline at end of file diff --git a/web/src/helpers/history.js b/web/src/helpers/history.js deleted file mode 100644 index 629039e5..00000000 --- a/web/src/helpers/history.js +++ /dev/null @@ -1,3 +0,0 @@ -import { createBrowserHistory } from 'history'; - -export const history = createBrowserHistory(); \ No newline at end of file diff --git a/web/src/helpers/index.js b/web/src/helpers/index.js deleted file mode 100644 index 505a8cf9..00000000 --- a/web/src/helpers/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export * from './history'; -export * from './auth-header'; -export * from './utils'; -export * from './api'; \ No newline at end of file diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js deleted file mode 100644 index a9c81cc1..00000000 --- a/web/src/helpers/render.js +++ /dev/null @@ -1,58 +0,0 @@ -import { Label } from 'semantic-ui-react'; - -export function renderText(text, limit) { - if (text.length > limit) { - return text.slice(0, limit - 3) + '...'; - } - return text; -} - -export function renderGroup(group) { - if (group === '') { - return ; - } - let groups = group.split(','); - groups.sort(); - return <> - {groups.map((group) => { - if (group === 'vip' || group === 'pro') { - return ; - } else if (group === 'svip' || group === 'premium') { - return ; - } - return ; - })} - ; -} - -export function renderNumber(num) { - if (num >= 1000000000) { - return (num / 1000000000).toFixed(1) + 'B'; - } else if (num >= 1000000) { - return (num / 1000000).toFixed(1) + 'M'; - } else if (num >= 10000) { - return (num / 1000).toFixed(1) + 'k'; - } else { - return num; - } -} - -export function renderQuota(quota, digits = 2) { - let quotaPerUnit = localStorage.getItem('quota_per_unit'); - let displayInCurrency = localStorage.getItem('display_in_currency'); - quotaPerUnit = parseFloat(quotaPerUnit); - displayInCurrency = displayInCurrency === 'true'; - if (displayInCurrency) { - return '$' + (quota / quotaPerUnit).toFixed(digits); - } - return renderNumber(quota); -} - -export function renderQuotaWithPrompt(quota, digits) { - let displayInCurrency = localStorage.getItem('display_in_currency'); - displayInCurrency = displayInCurrency === 'true'; - if (displayInCurrency) { - return `(等价金额:${renderQuota(quota, digits)})`; - } - return ''; -} \ No newline at end of file diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js deleted file mode 100644 index 28ae4992..00000000 --- a/web/src/helpers/utils.js +++ /dev/null @@ -1,199 +0,0 @@ -import { toast } from 'react-toastify'; -import { toastConstants } from '../constants'; -import React from 'react'; - -const HTMLToastContent = ({ htmlContent }) => { - return
; -}; -export default HTMLToastContent; -export function isAdmin() { - let user = localStorage.getItem('user'); - if (!user) return false; - user = JSON.parse(user); - return user.role >= 10; -} - -export function isRoot() { - let user = localStorage.getItem('user'); - if (!user) return false; - user = JSON.parse(user); - return user.role >= 100; -} - -export function getSystemName() { - let system_name = localStorage.getItem('system_name'); - if (!system_name) return 'One API'; - return system_name; -} - -export function getLogo() { - let logo = localStorage.getItem('logo'); - if (!logo) return '/logo.png'; - return logo -} - -export function getFooterHTML() { - return localStorage.getItem('footer_html'); -} - -export async function copy(text) { - let okay = true; - try { - await navigator.clipboard.writeText(text); - } catch (e) { - okay = false; - console.error(e); - } - return okay; -} - -export function isMobile() { - return window.innerWidth <= 600; -} - -let showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT }; -let showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT }; -let showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT }; -let showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT }; -let showNoticeOptions = { autoClose: false }; - -if (isMobile()) { - showErrorOptions.position = 'top-center'; - // showErrorOptions.transition = 'flip'; - - showSuccessOptions.position = 'top-center'; - // showSuccessOptions.transition = 'flip'; - - showInfoOptions.position = 'top-center'; - // showInfoOptions.transition = 'flip'; - - showNoticeOptions.position = 'top-center'; - // showNoticeOptions.transition = 'flip'; -} - -export function showError(error) { - console.error(error); - if (error.message) { - if (error.name === 'AxiosError') { - switch (error.response.status) { - case 401: - // toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions); - window.location.href = '/login?expired=true'; - break; - case 429: - toast.error('错误:请求次数过多,请稍后再试!', showErrorOptions); - break; - case 500: - toast.error('错误:服务器内部错误,请联系管理员!', showErrorOptions); - break; - case 405: - toast.info('本站仅作演示之用,无服务端!'); - break; - default: - toast.error('错误:' + error.message, showErrorOptions); - } - return; - } - toast.error('错误:' + error.message, showErrorOptions); - } else { - toast.error('错误:' + error, showErrorOptions); - } -} - -export function showWarning(message) { - toast.warn(message, showWarningOptions); -} - -export function showSuccess(message) { - toast.success(message, showSuccessOptions); -} - -export function showInfo(message) { - toast.info(message, showInfoOptions); -} - -export function showNotice(message, isHTML = false) { - if (isHTML) { - toast(, showNoticeOptions); - } else { - toast.info(message, showNoticeOptions); - } -} - -export function openPage(url) { - window.open(url); -} - -export function removeTrailingSlash(url) { - if (url.endsWith('/')) { - return url.slice(0, -1); - } else { - return url; - } -} - -export function timestamp2string(timestamp) { - let date = new Date(timestamp * 1000); - let year = date.getFullYear().toString(); - let month = (date.getMonth() + 1).toString(); - let day = date.getDate().toString(); - let hour = date.getHours().toString(); - let minute = date.getMinutes().toString(); - let second = date.getSeconds().toString(); - if (month.length === 1) { - month = '0' + month; - } - if (day.length === 1) { - day = '0' + day; - } - if (hour.length === 1) { - hour = '0' + hour; - } - if (minute.length === 1) { - minute = '0' + minute; - } - if (second.length === 1) { - second = '0' + second; - } - return ( - year + - '-' + - month + - '-' + - day + - ' ' + - hour + - ':' + - minute + - ':' + - second - ); -} - -export function downloadTextAsFile(text, filename) { - let blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); - let url = URL.createObjectURL(blob); - let a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); -} - -export const verifyJSON = (str) => { - try { - JSON.parse(str); - } catch (e) { - return false; - } - return true; -}; - -export function shouldShowPrompt(id) { - let prompt = localStorage.getItem(`prompt-${id}`); - return !prompt; - -} - -export function setPromptShown(id) { - localStorage.setItem(`prompt-${id}`, 'true'); -} \ No newline at end of file diff --git a/web/src/hooks/useAuth.js b/web/src/hooks/useAuth.js new file mode 100644 index 00000000..fa7cb934 --- /dev/null +++ b/web/src/hooks/useAuth.js @@ -0,0 +1,13 @@ +import { isAdmin } from 'utils/common'; +import { useNavigate } from 'react-router-dom'; +const navigate = useNavigate(); + +const useAuth = () => { + const userIsAdmin = isAdmin(); + + if (!userIsAdmin) { + navigate('/panel/404'); + } +}; + +export default useAuth; diff --git a/web/src/hooks/useLogin.js b/web/src/hooks/useLogin.js new file mode 100644 index 00000000..53626577 --- /dev/null +++ b/web/src/hooks/useLogin.js @@ -0,0 +1,78 @@ +import { API } from 'utils/api'; +import { useDispatch } from 'react-redux'; +import { LOGIN } from 'store/actions'; +import { useNavigate } from 'react-router'; +import { showSuccess } from 'utils/common'; + +const useLogin = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const login = async (username, password) => { + try { + const res = await API.post(`/api/user/login`, { + username, + password + }); + const { success, message, data } = res.data; + if (success) { + localStorage.setItem('user', JSON.stringify(data)); + dispatch({ type: LOGIN, payload: data }); + navigate('/panel'); + } + return { success, message }; + } catch (err) { + // 请求失败,设置错误信息 + return { success: false, message: '' }; + } + }; + + const githubLogin = async (code, state) => { + try { + const res = await API.get(`/api/oauth/github?code=${code}&state=${state}`); + const { success, message, data } = res.data; + if (success) { + if (message === 'bind') { + showSuccess('绑定成功!'); + navigate('/panel'); + } else { + dispatch({ type: LOGIN, payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + showSuccess('登录成功!'); + navigate('/panel'); + } + } + return { success, message }; + } catch (err) { + // 请求失败,设置错误信息 + return { success: false, message: '' }; + } + }; + + const wechatLogin = async (code) => { + try { + const res = await API.get(`/api/oauth/wechat?code=${code}`); + const { success, message, data } = res.data; + if (success) { + dispatch({ type: LOGIN, payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + showSuccess('登录成功!'); + navigate('/panel'); + } + return { success, message }; + } catch (err) { + // 请求失败,设置错误信息 + return { success: false, message: '' }; + } + }; + + const logout = async () => { + await API.get('/api/user/logout'); + localStorage.removeItem('user'); + dispatch({ type: LOGIN, payload: null }); + navigate('/'); + }; + + return { login, logout, githubLogin, wechatLogin }; +}; + +export default useLogin; diff --git a/web/src/hooks/useRegister.js b/web/src/hooks/useRegister.js new file mode 100644 index 00000000..d07dc43a --- /dev/null +++ b/web/src/hooks/useRegister.js @@ -0,0 +1,39 @@ +import { API } from 'utils/api'; +import { useNavigate } from 'react-router'; +import { showSuccess } from 'utils/common'; + +const useRegister = () => { + const navigate = useNavigate(); + const register = async (input, turnstile) => { + try { + const res = await API.post(`/api/user/register?turnstile=${turnstile}`, input); + const { success, message } = res.data; + if (success) { + showSuccess('注册成功!'); + navigate('/login'); + } + return { success, message }; + } catch (err) { + // 请求失败,设置错误信息 + return { success: false, message: '' }; + } + }; + + const sendVerificationCode = async (email, turnstile) => { + try { + const res = await API.get(`/api/verification?email=${email}&turnstile=${turnstile}`); + const { success, message } = res.data; + if (success) { + showSuccess('验证码发送成功,请检查你的邮箱!'); + } + return { success, message }; + } catch (err) { + // 请求失败,设置错误信息 + return { success: false, message: '' }; + } + }; + + return { register, sendVerificationCode }; +}; + +export default useRegister; diff --git a/web/src/hooks/useScriptRef.js b/web/src/hooks/useScriptRef.js new file mode 100644 index 00000000..bd300cbb --- /dev/null +++ b/web/src/hooks/useScriptRef.js @@ -0,0 +1,18 @@ +import { useEffect, useRef } from 'react'; + +// ==============================|| ELEMENT REFERENCE HOOKS ||============================== // + +const useScriptRef = () => { + const scripted = useRef(true); + + useEffect( + () => () => { + scripted.current = true; + }, + [] + ); + + return scripted; +}; + +export default useScriptRef; diff --git a/web/src/index.css b/web/src/index.css deleted file mode 100644 index 5d60e377..00000000 --- a/web/src/index.css +++ /dev/null @@ -1,35 +0,0 @@ -body { - margin: 0; - padding-top: 55px; - overflow-y: scroll; - font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - scrollbar-width: none; -} - -body::-webkit-scrollbar { - display: none; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; -} - -.main-content { - padding: 4px; -} - -.small-icon .icon { - font-size: 1em !important; -} - -.custom-footer { - font-size: 1.1em; -} - -@media only screen and (max-width: 600px) { - .hide-on-mobile { - display: none !important; - } -} diff --git a/web/src/index.js b/web/src/index.js index eca5c3c0..d1411be3 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -1,31 +1,31 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import { Container } from 'semantic-ui-react'; -import App from './App'; -import Header from './components/Header'; -import Footer from './components/Footer'; -import 'semantic-ui-css/semantic.min.css'; -import './index.css'; -import { UserProvider } from './context/User'; -import { ToastContainer } from 'react-toastify'; -import 'react-toastify/dist/ReactToastify.css'; -import { StatusProvider } from './context/Status'; +import { createRoot } from 'react-dom/client'; -const root = ReactDOM.createRoot(document.getElementById('root')); +// third party +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; + +// project imports +import * as serviceWorker from 'serviceWorker'; +import App from 'App'; +import { store } from 'store'; + +// style + assets +import 'assets/scss/style.scss'; +import config from './config'; + +// ==============================|| REACT DOM RENDER ||============================== // + +const container = document.getElementById('root'); +const root = createRoot(container); // createRoot(container!) if you use TypeScript root.render( - - - - -
- - - - -