diff --git a/controller/user.go b/controller/user.go index 8fd10b8277..3c8ea997ff 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 3d3ffae354..f3858c3b9a 100644 --- a/model/log.go +++ b/model/log.go @@ -3,8 +3,9 @@ package model import ( "context" "fmt" - "gorm.io/gorm" "one-api/common" + + "gorm.io/gorm" ) type Log struct { @@ -22,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 @@ -134,7 +144,7 @@ func SearchUserLogs(userId int, keyword string) (logs []*Log, err error) { } func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int) (quota int) { - tx := DB.Table("logs").Select("ifnull(sum(quota),0)") + tx := DB.Table("logs").Select(assembleSumSelectStr("quota")) if username != "" { tx = tx.Where("username = ?", username) } @@ -158,7 +168,7 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa } func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (token int) { - tx := DB.Table("logs").Select("ifnull(sum(prompt_tokens),0) + ifnull(sum(completion_tokens),0)") + tx := DB.Table("logs").Select(assembleSumSelectStr("prompt_tokens") + " + " + assembleSumSelectStr("completion_tokens")) if username != "" { tx = tx.Where("username = ?", username) } @@ -182,3 +192,45 @@ func DeleteOldLog(targetTimestamp int64) (int64, error) { result := DB.Where("created_at < ?", targetTimestamp).Delete(&Log{}) return result.RowsAffected, result.Error } + +func SearchLogsByDayAndModel(user_id, start, end int) (LogStatistics []*LogStatistic, err error) { + groupSelect := "DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d') as day" + + if common.UsingPostgreSQL { + groupSelect = "TO_CHAR(date_trunc('day', to_timestamp(created_at)), 'YYYY-MM-DD') as day" + } + + if common.UsingSQLite { + groupSelect = "strftime('%Y-%m-%d', datetime(created_at, 'unixepoch')) as day" + } + + err = DB.Raw(` + SELECT `+groupSelect+`, + 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" + if common.UsingPostgreSQL { + nullfunc = "coalesce" + } + + sumSelectStr = fmt.Sprintf(sumSelectStr, nullfunc, selectStr) + + return sumSelectStr +} diff --git a/router/api-router.go b/router/api-router.go index da3f9e61ed..162675ceb6 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_v2/.eslintrc b/web_v2/.eslintrc new file mode 100644 index 0000000000..bbda79f0be --- /dev/null +++ b/web_v2/.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_v2/.gitignore b/web_v2/.gitignore new file mode 100644 index 0000000000..2b5bba767b --- /dev/null +++ b/web_v2/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.idea +package-lock.json +yarn.lock \ No newline at end of file diff --git a/web_v2/.prettierrc b/web_v2/.prettierrc new file mode 100644 index 0000000000..d5fba07c11 --- /dev/null +++ b/web_v2/.prettierrc @@ -0,0 +1,8 @@ +{ + "bracketSpacing": true, + "printWidth": 140, + "singleQuote": true, + "trailingComma": "none", + "tabWidth": 2, + "useTabs": false +} diff --git a/web_v2/README.md b/web_v2/README.md new file mode 100644 index 0000000000..07ca93ca38 --- /dev/null +++ b/web_v2/README.md @@ -0,0 +1,14 @@ +# One API 前端界面 + +这个项目是 One API 的前端界面,它基于 [Berry Free React Admin Template](https://github.com/codedthemes/berry-free-react-admin-template) 进行开发。 + +## 使用的开源项目 + +使用了以下开源项目作为我们项目的一部分: + +- [Berry Free React Admin Template](https://github.com/codedthemes/berry-free-react-admin-template) +- [minimal-ui-kit](minimal-ui-kit) + +## 许可证 + +本项目中使用的代码遵循 MIT 许可证。 diff --git a/web_v2/jsconfig.json b/web_v2/jsconfig.json new file mode 100644 index 0000000000..35332c7085 --- /dev/null +++ b/web_v2/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "baseUrl": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/web_v2/package.json b/web_v2/package.json new file mode 100644 index 0000000000..8918ddbcc9 --- /dev/null +++ b/web_v2/package.json @@ -0,0 +1,84 @@ +{ + "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-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", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app" + ] + }, + "babel": { + "presets": [ + "@babel/preset-react" + ] + }, + "browserslist": { + "production": [ + "defaults", + "not IE 11" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@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_v2/public/favicon.ico b/web_v2/public/favicon.ico new file mode 100644 index 0000000000..fbcfb14a5f Binary files /dev/null and b/web_v2/public/favicon.ico differ diff --git a/web_v2/public/index.html b/web_v2/public/index.html new file mode 100644 index 0000000000..6f232250ab --- /dev/null +++ b/web_v2/public/index.html @@ -0,0 +1,26 @@ + + + + One API + + + + + + + + + + + + +
+ + + diff --git a/web_v2/src/App.js b/web_v2/src/App.js new file mode 100644 index 0000000000..fc54c632d1 --- /dev/null +++ b/web_v2/src/App.js @@ -0,0 +1,43 @@ +import { useSelector } from 'react-redux'; + +import { ThemeProvider } from '@mui/material/styles'; +import { CssBaseline, StyledEngineProvider } from '@mui/material'; + +// routing +import Routes from 'routes'; + +// defaultTheme +import themes from 'themes'; + +// 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_v2/src/assets/images/404.svg b/web_v2/src/assets/images/404.svg new file mode 100644 index 0000000000..352a14ad6b --- /dev/null +++ b/web_v2/src/assets/images/404.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web_v2/src/assets/images/auth/auth-blue-card.svg b/web_v2/src/assets/images/auth/auth-blue-card.svg new file mode 100644 index 0000000000..6c9fe3e732 --- /dev/null +++ b/web_v2/src/assets/images/auth/auth-blue-card.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web_v2/src/assets/images/auth/auth-pattern-dark.svg b/web_v2/src/assets/images/auth/auth-pattern-dark.svg new file mode 100644 index 0000000000..aa0e4ab220 --- /dev/null +++ b/web_v2/src/assets/images/auth/auth-pattern-dark.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web_v2/src/assets/images/auth/auth-pattern.svg b/web_v2/src/assets/images/auth/auth-pattern.svg new file mode 100644 index 0000000000..b7ac8e2787 --- /dev/null +++ b/web_v2/src/assets/images/auth/auth-pattern.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web_v2/src/assets/images/auth/auth-purple-card.svg b/web_v2/src/assets/images/auth/auth-purple-card.svg new file mode 100644 index 0000000000..c724e0a320 --- /dev/null +++ b/web_v2/src/assets/images/auth/auth-purple-card.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web_v2/src/assets/images/auth/auth-signup-blue-card.svg b/web_v2/src/assets/images/auth/auth-signup-blue-card.svg new file mode 100644 index 0000000000..ebb8e85f9f --- /dev/null +++ b/web_v2/src/assets/images/auth/auth-signup-blue-card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_v2/src/assets/images/auth/auth-signup-white-card.svg b/web_v2/src/assets/images/auth/auth-signup-white-card.svg new file mode 100644 index 0000000000..56b97e200d --- /dev/null +++ b/web_v2/src/assets/images/auth/auth-signup-white-card.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web_v2/src/assets/images/icons/earning.svg b/web_v2/src/assets/images/icons/earning.svg new file mode 100644 index 0000000000..e877b599ea --- /dev/null +++ b/web_v2/src/assets/images/icons/earning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web_v2/src/assets/images/icons/github.svg b/web_v2/src/assets/images/icons/github.svg new file mode 100644 index 0000000000..e5b1b82a2a --- /dev/null +++ b/web_v2/src/assets/images/icons/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_v2/src/assets/images/icons/shape-avatar.svg b/web_v2/src/assets/images/icons/shape-avatar.svg new file mode 100644 index 0000000000..38aac7e271 --- /dev/null +++ b/web_v2/src/assets/images/icons/shape-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_v2/src/assets/images/icons/social-google.svg b/web_v2/src/assets/images/icons/social-google.svg new file mode 100644 index 0000000000..2231ce9862 --- /dev/null +++ b/web_v2/src/assets/images/icons/social-google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web_v2/src/assets/images/icons/wechat.svg b/web_v2/src/assets/images/icons/wechat.svg new file mode 100644 index 0000000000..a0b2e36c57 --- /dev/null +++ b/web_v2/src/assets/images/icons/wechat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_v2/src/assets/images/invite/cover.jpg b/web_v2/src/assets/images/invite/cover.jpg new file mode 100644 index 0000000000..93be1a40c9 Binary files /dev/null and b/web_v2/src/assets/images/invite/cover.jpg differ diff --git a/web_v2/src/assets/images/invite/cwok_casual_19.webp b/web_v2/src/assets/images/invite/cwok_casual_19.webp new file mode 100644 index 0000000000..1cf2c376c3 Binary files /dev/null and b/web_v2/src/assets/images/invite/cwok_casual_19.webp differ diff --git a/web_v2/src/assets/images/logo-2.svg b/web_v2/src/assets/images/logo-2.svg new file mode 100644 index 0000000000..2e674a7e68 --- /dev/null +++ b/web_v2/src/assets/images/logo-2.svg @@ -0,0 +1,15 @@ + + + + Layer 1 + + + + + + + + + + + \ No newline at end of file diff --git a/web_v2/src/assets/images/logo.svg b/web_v2/src/assets/images/logo.svg new file mode 100644 index 0000000000..348c7e5a30 --- /dev/null +++ b/web_v2/src/assets/images/logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/web_v2/src/assets/images/users/user-round.svg b/web_v2/src/assets/images/users/user-round.svg new file mode 100644 index 0000000000..eaef7ed956 --- /dev/null +++ b/web_v2/src/assets/images/users/user-round.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_v2/src/assets/scss/_themes-vars.module.scss b/web_v2/src/assets/scss/_themes-vars.module.scss new file mode 100644 index 0000000000..a470b033eb --- /dev/null +++ b/web_v2/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_v2/src/assets/scss/style.scss b/web_v2/src/assets/scss/style.scss new file mode 100644 index 0000000000..17d566e623 --- /dev/null +++ b/web_v2/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_v2/src/config.js b/web_v2/src/config.js new file mode 100644 index 0000000000..eeeda99a28 --- /dev/null +++ b/web_v2/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_v2/src/constants/ChannelConstants.js b/web_v2/src/constants/ChannelConstants.js new file mode 100644 index 0000000000..3ce2783876 --- /dev/null +++ b/web_v2/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' + }, + 24: { + key: 24, + text: 'Google Gemini', + value: 24, + 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' + }, + 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_v2/src/constants/CommonConstants.js b/web_v2/src/constants/CommonConstants.js new file mode 100644 index 0000000000..1a37d5f6f4 --- /dev/null +++ b/web_v2/src/constants/CommonConstants.js @@ -0,0 +1 @@ +export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend! diff --git a/web_v2/src/constants/SnackbarConstants.js b/web_v2/src/constants/SnackbarConstants.js new file mode 100644 index 0000000000..a05c6652a0 --- /dev/null +++ b/web_v2/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_v2/src/constants/index.js b/web_v2/src/constants/index.js new file mode 100644 index 0000000000..716ef6aaba --- /dev/null +++ b/web_v2/src/constants/index.js @@ -0,0 +1,3 @@ +export * from './SnackbarConstants'; +export * from './CommonConstants'; +export * from './ChannelConstants'; diff --git a/web_v2/src/contexts/StatusContext.js b/web_v2/src/contexts/StatusContext.js new file mode 100644 index 0000000000..f79436ae94 --- /dev/null +++ b/web_v2/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_v2/src/contexts/UserContext.js b/web_v2/src/contexts/UserContext.js new file mode 100644 index 0000000000..491da9d994 --- /dev/null +++ b/web_v2/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_v2/src/hooks/useAuth.js b/web_v2/src/hooks/useAuth.js new file mode 100644 index 0000000000..fa7cb934ab --- /dev/null +++ b/web_v2/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_v2/src/hooks/useLogin.js b/web_v2/src/hooks/useLogin.js new file mode 100644 index 0000000000..53626577ba --- /dev/null +++ b/web_v2/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_v2/src/hooks/useRegister.js b/web_v2/src/hooks/useRegister.js new file mode 100644 index 0000000000..d07dc43a90 --- /dev/null +++ b/web_v2/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_v2/src/hooks/useScriptRef.js b/web_v2/src/hooks/useScriptRef.js new file mode 100644 index 0000000000..bd300cbba0 --- /dev/null +++ b/web_v2/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_v2/src/index.js b/web_v2/src/index.js new file mode 100644 index 0000000000..d1411be368 --- /dev/null +++ b/web_v2/src/index.js @@ -0,0 +1,31 @@ +import { createRoot } from 'react-dom/client'; + +// 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( + + + + + +); + +// If you want your app to work offline and load faster, you can change +// unregister() to register() below. Note this comes with some pitfalls. +// Learn more about service workers: https://bit.ly/CRA-PWA +serviceWorker.register(); diff --git a/web_v2/src/layout/MainLayout/Header/ProfileSection/index.js b/web_v2/src/layout/MainLayout/Header/ProfileSection/index.js new file mode 100644 index 0000000000..37210d2fc0 --- /dev/null +++ b/web_v2/src/layout/MainLayout/Header/ProfileSection/index.js @@ -0,0 +1,173 @@ +import { useState, useRef, useEffect } from 'react'; + +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +// material-ui +import { useTheme } from '@mui/material/styles'; +import { + Avatar, + Chip, + ClickAwayListener, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Paper, + Popper, + Typography +} from '@mui/material'; + +// project imports +import MainCard from 'ui-component/cards/MainCard'; +import Transitions from 'ui-component/extended/Transitions'; +import User1 from 'assets/images/users/user-round.svg'; +import useLogin from 'hooks/useLogin'; + +// assets +import { IconLogout, IconSettings, IconUserScan } from '@tabler/icons-react'; + +// ==============================|| PROFILE MENU ||============================== // + +const ProfileSection = () => { + const theme = useTheme(); + const navigate = useNavigate(); + const customization = useSelector((state) => state.customization); + const { logout } = useLogin(); + + const [open, setOpen] = useState(false); + /** + * anchorRef is used on different componets and specifying one type leads to other components throwing an error + * */ + const anchorRef = useRef(null); + const handleLogout = async () => { + logout(); + }; + + const handleClose = (event) => { + if (anchorRef.current && anchorRef.current.contains(event.target)) { + return; + } + setOpen(false); + }; + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const prevOpen = useRef(open); + useEffect(() => { + if (prevOpen.current === true && open === false) { + anchorRef.current.focus(); + } + + prevOpen.current = open; + }, [open]); + + return ( + <> + + } + label={} + variant="outlined" + ref={anchorRef} + aria-controls={open ? 'menu-list-grow' : undefined} + aria-haspopup="true" + onClick={handleToggle} + color="primary" + /> + + {({ TransitionProps }) => ( + + + + + + navigate('/panel/profile')}> + + + + 设置} /> + + + + + + + Logout} /> + + + + + + + )} + + + ); +}; + +export default ProfileSection; diff --git a/web_v2/src/layout/MainLayout/Header/index.js b/web_v2/src/layout/MainLayout/Header/index.js new file mode 100644 index 0000000000..51d40c7546 --- /dev/null +++ b/web_v2/src/layout/MainLayout/Header/index.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Avatar, Box, ButtonBase } from '@mui/material'; + +// project imports +import LogoSection from '../LogoSection'; +import ProfileSection from './ProfileSection'; + +// assets +import { IconMenu2 } from '@tabler/icons-react'; + +// ==============================|| MAIN NAVBAR / HEADER ||============================== // + +const Header = ({ handleLeftDrawerToggle }) => { + const theme = useTheme(); + + return ( + <> + {/* logo & toggler button */} + + + + + + + + + + + + + + + + + ); +}; + +Header.propTypes = { + handleLeftDrawerToggle: PropTypes.func +}; + +export default Header; diff --git a/web_v2/src/layout/MainLayout/LogoSection/index.js b/web_v2/src/layout/MainLayout/LogoSection/index.js new file mode 100644 index 0000000000..e9ad78a555 --- /dev/null +++ b/web_v2/src/layout/MainLayout/LogoSection/index.js @@ -0,0 +1,24 @@ +import { Link } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; + +// material-ui +import { ButtonBase } from '@mui/material'; + +// project imports +import config from 'config'; +import Logo from 'ui-component/Logo'; +import { MENU_OPEN } from 'store/actions'; + +// ==============================|| MAIN LOGO ||============================== // + +const LogoSection = () => { + const defaultId = useSelector((state) => state.customization.defaultId); + const dispatch = useDispatch(); + return ( + dispatch({ type: MENU_OPEN, id: defaultId })} component={Link} to={config.basename}> + + + ); +}; + +export default LogoSection; diff --git a/web_v2/src/layout/MainLayout/Sidebar/MenuCard/index.js b/web_v2/src/layout/MainLayout/Sidebar/MenuCard/index.js new file mode 100644 index 0000000000..16b132319b --- /dev/null +++ b/web_v2/src/layout/MainLayout/Sidebar/MenuCard/index.js @@ -0,0 +1,130 @@ +// import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; + +// material-ui +import { styled, useTheme } from '@mui/material/styles'; +import { + Avatar, + Card, + CardContent, + // Grid, + // LinearProgress, + List, + ListItem, + ListItemAvatar, + ListItemText, + Typography + // linearProgressClasses +} from '@mui/material'; +import User1 from 'assets/images/users/user-round.svg'; +import { useNavigate } from 'react-router-dom'; + +// assets +// import TableChartOutlinedIcon from '@mui/icons-material/TableChartOutlined'; + +// styles +// const BorderLinearProgress = styled(LinearProgress)(({ theme }) => ({ +// height: 10, +// borderRadius: 30, +// [`&.${linearProgressClasses.colorPrimary}`]: { +// backgroundColor: '#fff' +// }, +// [`& .${linearProgressClasses.bar}`]: { +// borderRadius: 5, +// backgroundColor: theme.palette.primary.main +// } +// })); + +const CardStyle = styled(Card)(({ theme }) => ({ + background: theme.palette.primary.light, + marginBottom: '22px', + overflow: 'hidden', + position: 'relative', + '&:after': { + content: '""', + position: 'absolute', + width: '157px', + height: '157px', + background: theme.palette.primary[200], + borderRadius: '50%', + top: '-105px', + right: '-96px' + } +})); + +// ==============================|| PROGRESS BAR WITH LABEL ||============================== // + +// function LinearProgressWithLabel({ value, ...others }) { +// const theme = useTheme(); + +// return ( +// +// +// +// +// +// Progress +// +// +// +// {`${Math.round(value)}%`} +// +// +// +// +// +// +// +// ); +// } + +// LinearProgressWithLabel.propTypes = { +// value: PropTypes.number +// }; + +// ==============================|| SIDEBAR MENU Card ||============================== // + +const MenuCard = () => { + const theme = useTheme(); + const account = useSelector((state) => state.account); + const navigate = useNavigate(); + + return ( + + + + + + navigate('/panel/profile')} + > + + + {account.user?.username} + + } + secondary={ 欢迎回来 } + /> + + + {/* */} + + + ); +}; + +export default MenuCard; diff --git a/web_v2/src/layout/MainLayout/Sidebar/MenuList/NavCollapse/index.js b/web_v2/src/layout/MainLayout/Sidebar/MenuList/NavCollapse/index.js new file mode 100644 index 0000000000..0632d56f1f --- /dev/null +++ b/web_v2/src/layout/MainLayout/Sidebar/MenuList/NavCollapse/index.js @@ -0,0 +1,158 @@ +import PropTypes from 'prop-types'; +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useLocation, useNavigate } from 'react-router'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Collapse, List, ListItemButton, ListItemIcon, ListItemText, Typography } from '@mui/material'; + +// project imports +import NavItem from '../NavItem'; + +// assets +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; +import { IconChevronDown, IconChevronUp } from '@tabler/icons-react'; + +// ==============================|| SIDEBAR MENU LIST COLLAPSE ITEMS ||============================== // + +const NavCollapse = ({ menu, level }) => { + const theme = useTheme(); + const customization = useSelector((state) => state.customization); + const navigate = useNavigate(); + + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(null); + + const handleClick = () => { + setOpen(!open); + setSelected(!selected ? menu.id : null); + if (menu?.id !== 'authentication') { + navigate(menu.children[0]?.url); + } + }; + + const { pathname } = useLocation(); + const checkOpenForParent = (child, id) => { + child.forEach((item) => { + if (item.url === pathname) { + setOpen(true); + setSelected(id); + } + }); + }; + + // menu collapse for sub-levels + useEffect(() => { + setOpen(false); + setSelected(null); + if (menu.children) { + menu.children.forEach((item) => { + if (item.children?.length) { + checkOpenForParent(item.children, menu.id); + } + if (item.url === pathname) { + setSelected(menu.id); + setOpen(true); + } + }); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname, menu.children]); + + // menu collapse & item + const menus = menu.children?.map((item) => { + switch (item.type) { + case 'collapse': + return ; + case 'item': + return ; + default: + return ( + + Menu Items Error + + ); + } + }); + + const Icon = menu.icon; + const menuIcon = menu.icon ? ( + + ) : ( + 0 ? 'inherit' : 'medium'} + /> + ); + + return ( + <> + 1 ? 'transparent !important' : 'inherit', + py: level > 1 ? 1 : 1.25, + pl: `${level * 24}px` + }} + selected={selected === menu.id} + onClick={handleClick} + > + {menuIcon} + + {menu.title} + + } + secondary={ + menu.caption && ( + + {menu.caption} + + ) + } + /> + {open ? ( + + ) : ( + + )} + + + + {menus} + + + + ); +}; + +NavCollapse.propTypes = { + menu: PropTypes.object, + level: PropTypes.number +}; + +export default NavCollapse; diff --git a/web_v2/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.js b/web_v2/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.js new file mode 100644 index 0000000000..b6479bc279 --- /dev/null +++ b/web_v2/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Divider, List, Typography } from '@mui/material'; + +// project imports +import NavItem from '../NavItem'; +import NavCollapse from '../NavCollapse'; + +// ==============================|| SIDEBAR MENU LIST GROUP ||============================== // + +const NavGroup = ({ item }) => { + const theme = useTheme(); + + // menu list collapse & items + const items = item.children?.map((menu) => { + switch (menu.type) { + case 'collapse': + return ; + case 'item': + return ; + default: + return ( + + Menu Items Error + + ); + } + }); + + return ( + <> + + {item.title} + {item.caption && ( + + {item.caption} + + )} + + ) + } + > + {items} + + + {/* group divider */} + + + ); +}; + +NavGroup.propTypes = { + item: PropTypes.object +}; + +export default NavGroup; diff --git a/web_v2/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.js b/web_v2/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.js new file mode 100644 index 0000000000..ddce9cf4ef --- /dev/null +++ b/web_v2/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import { forwardRef, useEffect } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Avatar, Chip, ListItemButton, ListItemIcon, ListItemText, Typography, useMediaQuery } from '@mui/material'; + +// project imports +import { MENU_OPEN, SET_MENU } from 'store/actions'; + +// assets +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; + +// ==============================|| SIDEBAR MENU LIST ITEMS ||============================== // + +const NavItem = ({ item, level }) => { + const theme = useTheme(); + const dispatch = useDispatch(); + const { pathname } = useLocation(); + const customization = useSelector((state) => state.customization); + const matchesSM = useMediaQuery(theme.breakpoints.down('lg')); + + const Icon = item.icon; + const itemIcon = item?.icon ? ( + + ) : ( + id === item?.id) > -1 ? 8 : 6, + height: customization.isOpen.findIndex((id) => id === item?.id) > -1 ? 8 : 6 + }} + fontSize={level > 0 ? 'inherit' : 'medium'} + /> + ); + + let itemTarget = '_self'; + if (item.target) { + itemTarget = '_blank'; + } + + let listItemProps = { + component: forwardRef((props, ref) => ) + }; + if (item?.external) { + listItemProps = { component: 'a', href: item.url, target: itemTarget }; + } + + const itemHandler = (id) => { + dispatch({ type: MENU_OPEN, id }); + if (matchesSM) dispatch({ type: SET_MENU, opened: false }); + }; + + // active menu item on page load + useEffect(() => { + const currentIndex = document.location.pathname + .toString() + .split('/') + .findIndex((id) => id === item.id); + if (currentIndex > -1) { + dispatch({ type: MENU_OPEN, id: item.id }); + } + // eslint-disable-next-line + }, [pathname]); + + return ( + 1 ? 'transparent !important' : 'inherit', + py: level > 1 ? 1 : 1.25, + pl: `${level * 24}px` + }} + selected={customization.isOpen.findIndex((id) => id === item.id) > -1} + onClick={() => itemHandler(item.id)} + > + {itemIcon} + id === item.id) > -1 ? 'h5' : 'body1'} color="inherit"> + {item.title} + + } + secondary={ + item.caption && ( + + {item.caption} + + ) + } + /> + {item.chip && ( + {item.chip.avatar}} + /> + )} + + ); +}; + +NavItem.propTypes = { + item: PropTypes.object, + level: PropTypes.number +}; + +export default NavItem; diff --git a/web_v2/src/layout/MainLayout/Sidebar/MenuList/index.js b/web_v2/src/layout/MainLayout/Sidebar/MenuList/index.js new file mode 100644 index 0000000000..4872057a06 --- /dev/null +++ b/web_v2/src/layout/MainLayout/Sidebar/MenuList/index.js @@ -0,0 +1,36 @@ +// material-ui +import { Typography } from '@mui/material'; + +// project imports +import NavGroup from './NavGroup'; +import menuItem from 'menu-items'; +import { isAdmin } from 'utils/common'; + +// ==============================|| SIDEBAR MENU LIST ||============================== // +const MenuList = () => { + const userIsAdmin = isAdmin(); + + return ( + <> + {menuItem.items.map((item) => { + if (item.type !== 'group') { + return ( + + Menu Items Error + + ); + } + + const filteredChildren = item.children.filter((child) => !child.isAdmin || userIsAdmin); + + if (filteredChildren.length === 0) { + return null; + } + + return ; + })} + + ); +}; + +export default MenuList; diff --git a/web_v2/src/layout/MainLayout/Sidebar/index.js b/web_v2/src/layout/MainLayout/Sidebar/index.js new file mode 100644 index 0000000000..e3c6d12d3a --- /dev/null +++ b/web_v2/src/layout/MainLayout/Sidebar/index.js @@ -0,0 +1,94 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Box, Chip, Drawer, Stack, useMediaQuery } from '@mui/material'; + +// third-party +import PerfectScrollbar from 'react-perfect-scrollbar'; +import { BrowserView, MobileView } from 'react-device-detect'; + +// project imports +import MenuList from './MenuList'; +import LogoSection from '../LogoSection'; +import MenuCard from './MenuCard'; +import { drawerWidth } from 'store/constant'; + +// ==============================|| SIDEBAR DRAWER ||============================== // + +const Sidebar = ({ drawerOpen, drawerToggle, window }) => { + const theme = useTheme(); + const matchUpMd = useMediaQuery(theme.breakpoints.up('md')); + + const drawer = ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + ); + + const container = window !== undefined ? () => window.document.body : undefined; + + return ( + + + {drawer} + + + ); +}; + +Sidebar.propTypes = { + drawerOpen: PropTypes.bool, + drawerToggle: PropTypes.func, + window: PropTypes.object +}; + +export default Sidebar; diff --git a/web_v2/src/layout/MainLayout/index.js b/web_v2/src/layout/MainLayout/index.js new file mode 100644 index 0000000000..973a167b33 --- /dev/null +++ b/web_v2/src/layout/MainLayout/index.js @@ -0,0 +1,103 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { Outlet } from 'react-router-dom'; +import AuthGuard from 'utils/route-guard/AuthGuard'; + +// material-ui +import { styled, useTheme } from '@mui/material/styles'; +import { AppBar, Box, CssBaseline, Toolbar, useMediaQuery } from '@mui/material'; +import AdminContainer from 'ui-component/AdminContainer'; + +// project imports +import Breadcrumbs from 'ui-component/extended/Breadcrumbs'; +import Header from './Header'; +import Sidebar from './Sidebar'; +import navigation from 'menu-items'; +import { drawerWidth } from 'store/constant'; +import { SET_MENU } from 'store/actions'; + +// assets +import { IconChevronRight } from '@tabler/icons-react'; + +// styles +const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })(({ theme, open }) => ({ + ...theme.typography.mainContent, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + transition: theme.transitions.create( + 'margin', + open + ? { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen + } + : { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen + } + ), + [theme.breakpoints.up('md')]: { + marginLeft: open ? 0 : -(drawerWidth - 20), + width: `calc(100% - ${drawerWidth}px)` + }, + [theme.breakpoints.down('md')]: { + marginLeft: '20px', + width: `calc(100% - ${drawerWidth}px)`, + padding: '16px' + }, + [theme.breakpoints.down('sm')]: { + marginLeft: '10px', + width: `calc(100% - ${drawerWidth}px)`, + padding: '16px', + marginRight: '10px' + } +})); + +// ==============================|| MAIN LAYOUT ||============================== // + +const MainLayout = () => { + const theme = useTheme(); + const matchDownMd = useMediaQuery(theme.breakpoints.down('md')); + // Handle left drawer + const leftDrawerOpened = useSelector((state) => state.customization.opened); + const dispatch = useDispatch(); + const handleLeftDrawerToggle = () => { + dispatch({ type: SET_MENU, opened: !leftDrawerOpened }); + }; + + return ( + + + {/* header */} + + +
+ + + + {/* drawer */} + + + {/* main content */} +
+ {/* breadcrumb */} + + + + + + +
+ + ); +}; + +export default MainLayout; diff --git a/web_v2/src/layout/MinimalLayout/Header/index.js b/web_v2/src/layout/MinimalLayout/Header/index.js new file mode 100644 index 0000000000..b9dfbf5db4 --- /dev/null +++ b/web_v2/src/layout/MinimalLayout/Header/index.js @@ -0,0 +1,55 @@ +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Box, Button, Stack } from '@mui/material'; +import LogoSection from 'layout/MainLayout/LogoSection'; +import { Link } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; +import { useSelector } from 'react-redux'; + +// ==============================|| MAIN NAVBAR / HEADER ||============================== // + +const Header = () => { + const theme = useTheme(); + const { pathname } = useLocation(); + const account = useSelector((state) => state.account); + + return ( + <> + + + + + + + + + + + + {account.user ? ( + + ) : ( + + )} + + + ); +}; + +export default Header; diff --git a/web_v2/src/layout/MinimalLayout/index.js b/web_v2/src/layout/MinimalLayout/index.js new file mode 100644 index 0000000000..084ee6ace9 --- /dev/null +++ b/web_v2/src/layout/MinimalLayout/index.js @@ -0,0 +1,39 @@ +import { Outlet } from 'react-router-dom'; +import { useTheme } from '@mui/material/styles'; +import { AppBar, Box, CssBaseline, Toolbar } from '@mui/material'; +import Header from './Header'; +import Footer from 'ui-component/Footer'; + +// ==============================|| MINIMAL LAYOUT ||============================== // + +const MinimalLayout = () => { + const theme = useTheme(); + + return ( + + + + +
+ + + + + + +