├── .dockerignore ├── .gitattributes ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README-en.md ├── README.md ├── app ├── __init__.py ├── api │ ├── __init__.py │ └── v1 │ │ ├── __init__.py │ │ ├── apis │ │ ├── __init__.py │ │ └── apis.py │ │ ├── auditlog │ │ ├── __init__.py │ │ └── auditlog.py │ │ ├── base │ │ ├── __init__.py │ │ └── base.py │ │ ├── depts │ │ ├── __init__.py │ │ └── depts.py │ │ ├── menus │ │ ├── __init__.py │ │ └── menus.py │ │ ├── roles │ │ ├── __init__.py │ │ └── roles.py │ │ └── users │ │ ├── __init__.py │ │ └── users.py ├── controllers │ ├── __init__.py │ ├── api.py │ ├── dept.py │ ├── menu.py │ ├── role.py │ └── user.py ├── core │ ├── bgtask.py │ ├── crud.py │ ├── ctx.py │ ├── dependency.py │ ├── exceptions.py │ ├── init_app.py │ └── middlewares.py ├── log │ ├── __init__.py │ └── log.py ├── models │ ├── __init__.py │ ├── admin.py │ ├── base.py │ └── enums.py ├── schemas │ ├── __init__.py │ ├── apis.py │ ├── base.py │ ├── depts.py │ ├── login.py │ ├── menus.py │ ├── roles.py │ └── users.py ├── settings │ ├── __init__.py │ └── config.py └── utils │ ├── jwt_utils.py │ └── password.py ├── deploy ├── entrypoint.sh ├── sample-picture │ ├── 1.jpg │ ├── 2.jpg │ ├── 3.jpg │ ├── api.jpg │ ├── group.jpg │ ├── login.jpg │ ├── logo.svg │ ├── menu.jpg │ ├── role.jpg │ ├── user.jpg │ └── workbench.jpg └── web.conf ├── pyproject.toml ├── requirements.txt ├── run.py ├── uv.lock └── web ├── .env ├── .env.development ├── .env.production ├── .eslint-global-variables.json ├── .eslintignore ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── README.md ├── build ├── config │ ├── define.js │ └── index.js ├── constant.js ├── plugin │ ├── html.js │ ├── index.js │ └── unplugin.js ├── script │ ├── build-cname.js │ └── index.js └── utils.js ├── i18n ├── index.js └── messages │ ├── cn.json │ ├── en.json │ └── index.js ├── index.html ├── jsconfig.json ├── package.json ├── pnpm-lock.yaml ├── public ├── favicon.svg └── resource │ ├── loading.css │ └── loading.js ├── settings ├── index.js └── theme.json ├── src ├── App.vue ├── api │ └── index.js ├── assets │ ├── images │ │ └── login_bg.webp │ ├── js │ │ └── icons.js │ └── svg │ │ ├── forbidden.svg │ │ ├── front-page.svg │ │ ├── logo.svg │ │ ├── network-error.svg │ │ ├── no-data.svg │ │ ├── not-found.svg │ │ ├── server-error.svg │ │ ├── service-unavailable.svg │ │ └── unauthorized.svg ├── components │ ├── common │ │ ├── AppFooter.vue │ │ ├── AppProvider.vue │ │ ├── LoadingEmptyWrapper.vue │ │ └── ScrollX.vue │ ├── icon │ │ ├── CustomIcon.vue │ │ ├── IconPicker.vue │ │ ├── SvgIcon.vue │ │ └── TheIcon.vue │ ├── page │ │ ├── AppPage.vue │ │ └── CommonPage.vue │ ├── query-bar │ │ ├── QueryBar.vue │ │ └── QueryBarItem.vue │ └── table │ │ ├── CrudModal.vue │ │ └── CrudTable.vue ├── composables │ ├── index.js │ └── useCRUD.js ├── directives │ ├── index.js │ └── permission.js ├── layout │ ├── components │ │ ├── AppMain.vue │ │ ├── header │ │ │ ├── components │ │ │ │ ├── BreadCrumb.vue │ │ │ │ ├── FullScreen.vue │ │ │ │ ├── GithubSite.vue │ │ │ │ ├── Languages.vue │ │ │ │ ├── MenuCollapse.vue │ │ │ │ ├── ThemeMode.vue │ │ │ │ └── UserAvatar.vue │ │ │ └── index.vue │ │ ├── sidebar │ │ │ ├── components │ │ │ │ ├── SideLogo.vue │ │ │ │ └── SideMenu.vue │ │ │ └── index.vue │ │ └── tags │ │ │ ├── ContextMenu.vue │ │ │ └── index.vue │ └── index.vue ├── main.js ├── router │ ├── guard │ │ ├── auth-guard.js │ │ ├── index.js │ │ ├── page-loading-guard.js │ │ └── page-title-guard.js │ ├── index.js │ └── routes │ │ └── index.js ├── store │ ├── index.js │ └── modules │ │ ├── app │ │ └── index.js │ │ ├── index.js │ │ ├── permission │ │ └── index.js │ │ ├── tags │ │ ├── helpers.js │ │ └── index.js │ │ └── user │ │ └── index.js ├── styles │ ├── global.scss │ └── reset.css ├── utils │ ├── auth │ │ ├── auth.js │ │ ├── index.js │ │ └── token.js │ ├── common │ │ ├── common.js │ │ ├── icon.js │ │ ├── index.js │ │ ├── is.js │ │ ├── naiveTools.js │ │ └── useResize.js │ ├── http │ │ ├── helpers.js │ │ ├── index.js │ │ └── interceptors.js │ ├── index.js │ └── storage │ │ ├── index.js │ │ └── storage.js └── views │ ├── error-page │ ├── 401.vue │ ├── 403.vue │ ├── 404.vue │ └── 500.vue │ ├── login │ └── index.vue │ ├── profile │ └── index.vue │ ├── system │ ├── api │ │ └── index.vue │ ├── auditlog │ │ └── index.vue │ ├── dept │ │ └── index.vue │ ├── menu │ │ └── index.vue │ ├── role │ │ └── index.vue │ └── user │ │ └── index.vue │ ├── top-menu │ └── index.vue │ └── workbench │ └── index.vue ├── unocss.config.js └── vite.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | web/node_modules 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.html linguist-language=python -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea/ 3 | venv/ 4 | .mypy_cache/ 5 | .vscode 6 | .ruff_cache/ 7 | .pytest_cache/ 8 | migrations/ 9 | 10 | db.sqlite3 11 | db.sqlite3-journal 12 | db.sqlite3-shm 13 | db.sqlite3-wal 14 | 15 | .DS_Store 16 | ._.DS_Store -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.12.0-alpine3.16 AS web 2 | 3 | WORKDIR /opt/vue-fastapi-admin 4 | COPY /web ./web 5 | RUN cd /opt/vue-fastapi-admin/web && npm i --registry=https://registry.npmmirror.com && npm run build 6 | 7 | 8 | FROM python:3.11-slim-bullseye 9 | 10 | WORKDIR /opt/vue-fastapi-admin 11 | ADD . . 12 | COPY /deploy/entrypoint.sh . 13 | 14 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core-apt \ 15 | --mount=type=cache,target=/var/lib/apt,sharing=locked,id=core-apt \ 16 | sed -i "s@http://.*.debian.org@http://mirrors.ustc.edu.cn@g" /etc/apt/sources.list \ 17 | && rm -f /etc/apt/apt.conf.d/docker-clean \ 18 | && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ 19 | && echo "Asia/Shanghai" > /etc/timezone \ 20 | && apt-get update \ 21 | && apt-get install -y --no-install-recommends gcc python3-dev bash nginx vim curl procps net-tools 22 | 23 | RUN pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple 24 | 25 | COPY --from=web /opt/vue-fastapi-admin/web/dist /opt/vue-fastapi-admin/web/dist 26 | ADD /deploy/web.conf /etc/nginx/sites-available/web.conf 27 | RUN rm -f /etc/nginx/sites-enabled/default \ 28 | && ln -s /etc/nginx/sites-available/web.conf /etc/nginx/sites-enabled/ 29 | 30 | ENV LANG=zh_CN.UTF-8 31 | EXPOSE 80 32 | 33 | ENTRYPOINT [ "sh", "entrypoint.sh" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 mizhexiaoxiao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Build configuration 2 | # ------------------- 3 | 4 | APP_NAME := `sed -n 's/^ *name.*=.*"\([^"]*\)".*/\1/p' pyproject.toml` 5 | APP_VERSION := `sed -n 's/^ *version.*=.*"\([^"]*\)".*/\1/p' pyproject.toml` 6 | GIT_REVISION = `git rev-parse HEAD` 7 | 8 | # Introspection targets 9 | # --------------------- 10 | 11 | .PHONY: help 12 | help: header targets 13 | 14 | .PHONY: header 15 | header: 16 | @echo "\033[34mEnvironment\033[0m" 17 | @echo "\033[34m---------------------------------------------------------------\033[0m" 18 | @printf "\033[33m%-23s\033[0m" "APP_NAME" 19 | @printf "\033[35m%s\033[0m" $(APP_NAME) 20 | @echo "" 21 | @printf "\033[33m%-23s\033[0m" "APP_VERSION" 22 | @printf "\033[35m%s\033[0m" $(APP_VERSION) 23 | @echo "" 24 | @printf "\033[33m%-23s\033[0m" "GIT_REVISION" 25 | @printf "\033[35m%s\033[0m" $(GIT_REVISION) 26 | @echo "\n" 27 | 28 | .PHONY: targets 29 | targets: 30 | @echo "\033[34mDevelopment Targets\033[0m" 31 | @echo "\033[34m---------------------------------------------------------------\033[0m" 32 | @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}' 33 | 34 | # Development targets 35 | # ------------- 36 | 37 | .PHONY: install 38 | install: ## Install dependencies 39 | uv add pyproject.toml 40 | 41 | 42 | .PHONY: run 43 | run: start 44 | 45 | .PHONY: start 46 | start: ## Starts the server 47 | python run.py 48 | 49 | # Check, lint and format targets 50 | # ------------------------------ 51 | 52 | .PHONY: check 53 | check: check-format lint 54 | 55 | .PHONY: check-format 56 | check-format: ## Dry-run code formatter 57 | black ./ --check 58 | isort ./ --profile black --check 59 | 60 | .PHONY: lint 61 | lint: ## Run ruff 62 | ruff check ./app 63 | 64 | .PHONY: format 65 | format: ## Run code formatter 66 | black ./ 67 | isort ./ --profile black 68 | 69 | 70 | .PHONY: test 71 | test: ## Run the test suite 72 | $(eval include .env) 73 | $(eval export $(sh sed 's/=.*//' .env)) 74 | pytest -vv -s --cache-clear ./ 75 | 76 | .PHONY: clean-db 77 | clean-db: ## 删除migrations文件夹和db.sqlite3 78 | find . -type d -name "migrations" -exec rm -rf {} + 79 | rm -f db.sqlite3 db.sqlite3-shm db.sqlite3-wal 80 | 81 | .PHONY: migrate 82 | migrate: ## 运行aerich migrate命令生成迁移文件 83 | aerich migrate 84 | 85 | .PHONY: upgrade 86 | upgrade: ## 运行aerich upgrade命令应用迁移 87 | aerich upgrade -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Vue FastAPI Admin Logo 4 | 5 |

6 | 7 |

vue-fastapi-admin

8 | 9 | [English](./README-en.md) | 简体中文 10 | 11 | 基于 FastAPI + Vue3 + Naive UI 的现代化前后端分离开发平台,融合了 RBAC 权限管理、动态路由和 JWT 鉴权,助力中小型应用快速搭建,也可用于学习参考。 12 | 13 | ### 特性 14 | - **最流行技术栈**:基于 Python 3.11 和 FastAPI 高性能异步框架,结合 Vue3 和 Vite 等前沿技术进行开发,同时使用高效的 npm 包管理器 pnpm。 15 | - **代码规范**:项目内置丰富的规范插件,确保代码质量和一致性,有效提高团队协作效率。 16 | - **动态路由**:后端动态路由,结合 RBAC(Role-Based Access Control)权限模型,提供精细的菜单路由控制。 17 | - **JWT鉴权**:使用 JSON Web Token(JWT)进行身份验证和授权,增强应用的安全性。 18 | - **细粒度权限控制**:实现按钮和接口级别的权限控制,确保不同用户或角色在界面操作和接口访问时具有不同的权限限制。 19 | 20 | ### 在线预览 21 | - [http://47.111.145.81:3000](http://47.111.145.81:3000) 22 | - username: admin 23 | - password: 123456 24 | 25 | ### 登录页 26 | 27 | ![image](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/login.jpg) 28 | ### 工作台 29 | 30 | ![image](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/workbench.jpg) 31 | 32 | ### 用户管理 33 | 34 | ![image](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/user.jpg) 35 | ### 角色管理 36 | 37 | ![image](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/role.jpg) 38 | 39 | ### 菜单管理 40 | 41 | ![image](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/menu.jpg) 42 | 43 | ### API管理 44 | 45 | ![image](https://github.com/mizhexiaoxiao/vue-fastapi-admin/blob/main/deploy/sample-picture/api.jpg) 46 | 47 | ### 快速开始 48 | #### 方法一:dockerhub拉取镜像 49 | 50 | ```sh 51 | docker pull mizhexiaoxiao/vue-fastapi-admin:latest 52 | docker run -d --restart=always --name=vue-fastapi-admin -p 9999:80 mizhexiaoxiao/vue-fastapi-admin 53 | ``` 54 | 55 | #### 方法二:dockerfile构建镜像 56 | ##### docker安装(版本17.05+) 57 | 58 | ```sh 59 | yum install -y docker-ce 60 | systemctl start docker 61 | ``` 62 | 63 | ##### 构建镜像 64 | 65 | ```sh 66 | git clone https://github.com/mizhexiaoxiao/vue-fastapi-admin.git 67 | cd vue-fastapi-admin 68 | docker build --no-cache . -t vue-fastapi-admin 69 | ``` 70 | 71 | ##### 启动容器 72 | 73 | ```sh 74 | docker run -d --restart=always --name=vue-fastapi-admin -p 9999:80 vue-fastapi-admin 75 | ``` 76 | 77 | ##### 访问 78 | 79 | http://localhost:9999 80 | 81 | username:admin 82 | 83 | password:123456 84 | 85 | ### 本地启动 86 | #### 后端 87 | 启动项目需要以下环境: 88 | - Python 3.11 89 | 90 | #### 方法一(推荐):使用 uv 安装依赖 91 | 1. 安装 uv 92 | ```sh 93 | pip install uv 94 | ``` 95 | 96 | 2. 创建并激活虚拟环境 97 | ```sh 98 | uv venv 99 | source .venv/bin/activate # Linux/Mac 100 | # 或 101 | .\.venv\Scripts\activate # Windows 102 | ``` 103 | 104 | 3. 安装依赖 105 | ```sh 106 | uv add pyproject.toml 107 | ``` 108 | 109 | 4. 启动服务 110 | ```sh 111 | python run.py 112 | ``` 113 | 114 | #### 方法二:使用 Pip 安装依赖 115 | 1. 创建虚拟环境 116 | ```sh 117 | python3 -m venv venv 118 | ``` 119 | 120 | 2. 激活虚拟环境 121 | ```sh 122 | source venv/bin/activate # Linux/Mac 123 | # 或 124 | .\venv\Scripts\activate # Windows 125 | ``` 126 | 127 | 3. 安装依赖 128 | ```sh 129 | pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple 130 | ``` 131 | 132 | 4. 启动服务 133 | ```sh 134 | python run.py 135 | ``` 136 | 137 | 服务现在应该正在运行,访问 http://localhost:9999/docs 查看API文档 138 | 139 | #### 前端 140 | 启动项目需要以下环境: 141 | - node v18.8.0+ 142 | 143 | 1. 进入前端目录 144 | ```sh 145 | cd web 146 | ``` 147 | 148 | 2. 安装依赖(建议使用pnpm: https://pnpm.io/zh/installation) 149 | ```sh 150 | npm i -g pnpm # 已安装可忽略 151 | pnpm i # 或者 npm i 152 | ``` 153 | 154 | 3. 启动 155 | ```sh 156 | pnpm dev 157 | ``` 158 | 159 | ### 目录说明 160 | 161 | ``` 162 | ├── app // 应用程序目录 163 | │ ├── api // API接口目录 164 | │ │ └── v1 // 版本1的API接口 165 | │ │ ├── apis // API相关接口 166 | │ │ ├── base // 基础信息接口 167 | │ │ ├── menus // 菜单相关接口 168 | │ │ ├── roles // 角色相关接口 169 | │ │ └── users // 用户相关接口 170 | │ ├── controllers // 控制器目录 171 | │ ├── core // 核心功能模块 172 | │ ├── log // 日志目录 173 | │ ├── models // 数据模型目录 174 | │ ├── schemas // 数据模式/结构定义 175 | │ ├── settings // 配置设置目录 176 | │ └── utils // 工具类目录 177 | ├── deploy // 部署相关目录 178 | │ └── sample-picture // 示例图片目录 179 | └── web // 前端网页目录 180 | ├── build // 构建脚本和配置目录 181 | │ ├── config // 构建配置 182 | │ ├── plugin // 构建插件 183 | │ └── script // 构建脚本 184 | ├── public // 公共资源目录 185 | │ └── resource // 公共资源文件 186 | ├── settings // 前端项目配置 187 | └── src // 源代码目录 188 | ├── api // API接口定义 189 | ├── assets // 静态资源目录 190 | │ ├── images // 图片资源 191 | │ ├── js // JavaScript文件 192 | │ └── svg // SVG矢量图文件 193 | ├── components // 组件目录 194 | │ ├── common // 通用组件 195 | │ ├── icon // 图标组件 196 | │ ├── page // 页面组件 197 | │ ├── query-bar // 查询栏组件 198 | │ └── table // 表格组件 199 | ├── composables // 可组合式功能块 200 | ├── directives // 指令目录 201 | ├── layout // 布局目录 202 | │ └── components // 布局组件 203 | ├── router // 路由目录 204 | │ ├── guard // 路由守卫 205 | │ └── routes // 路由定义 206 | ├── store // 状态管理(pinia) 207 | │ └── modules // 状态模块 208 | ├── styles // 样式文件目录 209 | ├── utils // 工具类目录 210 | │ ├── auth // 认证相关工具 211 | │ ├── common // 通用工具 212 | │ ├── http // 封装axios 213 | │ └── storage // 封装localStorage和sessionStorage 214 | └── views // 视图/页面目录 215 | ├── error-page // 错误页面 216 | ├── login // 登录页面 217 | ├── profile // 个人资料页面 218 | ├── system // 系统管理页面 219 | └── workbench // 工作台页面 220 | ``` 221 | 222 | ### 进群交流 223 | 进群的条件是给项目一个star,小小的star是作者维护下去的动力。 224 | 225 | 你可以在群里提出任何疑问,我会尽快回复答疑。 226 | 227 | 228 | 229 | ## 打赏 230 | 如果项目有帮助到你,可以请作者喝杯咖啡~ 231 | 232 |
233 | 234 | 235 |
236 | 237 | ## 定制开发 238 | 如果有基于该项目的定制需求或其他合作,请添加下方微信,备注来意 239 | 240 | 241 | 242 | ### Visitors Count 243 | 244 | Loading 245 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | 3 | from fastapi import FastAPI 4 | from tortoise import Tortoise 5 | 6 | from app.core.exceptions import SettingNotFound 7 | from app.core.init_app import ( 8 | init_data, 9 | make_middlewares, 10 | register_exceptions, 11 | register_routers, 12 | ) 13 | 14 | try: 15 | from app.settings.config import settings 16 | except ImportError: 17 | raise SettingNotFound("Can not import settings") 18 | 19 | 20 | @asynccontextmanager 21 | async def lifespan(app: FastAPI): 22 | await init_data() 23 | yield 24 | await Tortoise.close_connections() 25 | 26 | 27 | def create_app() -> FastAPI: 28 | app = FastAPI( 29 | title=settings.APP_TITLE, 30 | description=settings.APP_DESCRIPTION, 31 | version=settings.VERSION, 32 | openapi_url="/openapi.json", 33 | middleware=make_middlewares(), 34 | lifespan=lifespan, 35 | ) 36 | register_exceptions(app) 37 | register_routers(app, prefix="/api") 38 | return app 39 | 40 | 41 | app = create_app() 42 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .v1 import v1_router 4 | 5 | api_router = APIRouter() 6 | api_router.include_router(v1_router, prefix="/v1") 7 | 8 | 9 | __all__ = ["api_router"] 10 | -------------------------------------------------------------------------------- /app/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.core.dependency import DependPermission 4 | 5 | from .apis import apis_router 6 | from .auditlog import auditlog_router 7 | from .base import base_router 8 | from .depts import depts_router 9 | from .menus import menus_router 10 | from .roles import roles_router 11 | from .users import users_router 12 | 13 | v1_router = APIRouter() 14 | 15 | v1_router.include_router(base_router, prefix="/base") 16 | v1_router.include_router(users_router, prefix="/user", dependencies=[DependPermission]) 17 | v1_router.include_router(roles_router, prefix="/role", dependencies=[DependPermission]) 18 | v1_router.include_router(menus_router, prefix="/menu", dependencies=[DependPermission]) 19 | v1_router.include_router(apis_router, prefix="/api", dependencies=[DependPermission]) 20 | v1_router.include_router(depts_router, prefix="/dept", dependencies=[DependPermission]) 21 | v1_router.include_router(auditlog_router, prefix="/auditlog", dependencies=[DependPermission]) 22 | -------------------------------------------------------------------------------- /app/api/v1/apis/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .apis import router 4 | 5 | apis_router = APIRouter() 6 | apis_router.include_router(router, tags=["API模块"]) 7 | 8 | __all__ = ["apis_router"] 9 | -------------------------------------------------------------------------------- /app/api/v1/apis/apis.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Query 2 | from tortoise.expressions import Q 3 | 4 | from app.controllers.api import api_controller 5 | from app.schemas import Success, SuccessExtra 6 | from app.schemas.apis import * 7 | 8 | router = APIRouter() 9 | 10 | 11 | @router.get("/list", summary="查看API列表") 12 | async def list_api( 13 | page: int = Query(1, description="页码"), 14 | page_size: int = Query(10, description="每页数量"), 15 | path: str = Query(None, description="API路径"), 16 | summary: str = Query(None, description="API简介"), 17 | tags: str = Query(None, description="API模块"), 18 | ): 19 | q = Q() 20 | if path: 21 | q &= Q(path__contains=path) 22 | if summary: 23 | q &= Q(summary__contains=summary) 24 | if tags: 25 | q &= Q(tags__contains=tags) 26 | total, api_objs = await api_controller.list(page=page, page_size=page_size, search=q, order=["tags", "id"]) 27 | data = [await obj.to_dict() for obj in api_objs] 28 | return SuccessExtra(data=data, total=total, page=page, page_size=page_size) 29 | 30 | 31 | @router.get("/get", summary="查看Api") 32 | async def get_api( 33 | id: int = Query(..., description="Api"), 34 | ): 35 | api_obj = await api_controller.get(id=id) 36 | data = await api_obj.to_dict() 37 | return Success(data=data) 38 | 39 | 40 | @router.post("/create", summary="创建Api") 41 | async def create_api( 42 | api_in: ApiCreate, 43 | ): 44 | await api_controller.create(obj_in=api_in) 45 | return Success(msg="Created Successfully") 46 | 47 | 48 | @router.post("/update", summary="更新Api") 49 | async def update_api( 50 | api_in: ApiUpdate, 51 | ): 52 | await api_controller.update(id=api_in.id, obj_in=api_in) 53 | return Success(msg="Update Successfully") 54 | 55 | 56 | @router.delete("/delete", summary="删除Api") 57 | async def delete_api( 58 | api_id: int = Query(..., description="ApiID"), 59 | ): 60 | await api_controller.remove(id=api_id) 61 | return Success(msg="Deleted Success") 62 | 63 | 64 | @router.post("/refresh", summary="刷新API列表") 65 | async def refresh_api(): 66 | await api_controller.refresh_api() 67 | return Success(msg="OK") 68 | -------------------------------------------------------------------------------- /app/api/v1/auditlog/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .auditlog import router 4 | 5 | auditlog_router = APIRouter() 6 | auditlog_router.include_router(router, tags=["审计日志模块"]) 7 | 8 | __all__ = ["auditlog_router"] 9 | -------------------------------------------------------------------------------- /app/api/v1/auditlog/auditlog.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from fastapi import APIRouter, Query 3 | from tortoise.expressions import Q 4 | 5 | from app.models.admin import AuditLog 6 | from app.schemas import SuccessExtra 7 | from app.schemas.apis import * 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get("/list", summary="查看操作日志") 13 | async def get_audit_log_list( 14 | page: int = Query(1, description="页码"), 15 | page_size: int = Query(10, description="每页数量"), 16 | username: str = Query("", description="操作人名称"), 17 | module: str = Query("", description="功能模块"), 18 | method: str = Query("", description="请求方法"), 19 | summary: str = Query("", description="接口描述"), 20 | status: int = Query(None, description="状态码"), 21 | start_time: datetime = Query("", description="开始时间"), 22 | end_time: datetime = Query("", description="结束时间"), 23 | ): 24 | q = Q() 25 | if username: 26 | q &= Q(username__icontains=username) 27 | if module: 28 | q &= Q(module__icontains=module) 29 | if method: 30 | q &= Q(method__icontains=method) 31 | if summary: 32 | q &= Q(summary__icontains=summary) 33 | if status: 34 | q &= Q(status=status) 35 | if start_time and end_time: 36 | q &= Q(created_at__range=[start_time, end_time]) 37 | elif start_time: 38 | q &= Q(created_at__gte=start_time) 39 | elif end_time: 40 | q &= Q(created_at__lte=end_time) 41 | 42 | audit_log_objs = await AuditLog.filter(q).offset((page - 1) * page_size).limit(page_size).order_by("-created_at") 43 | total = await AuditLog.filter(q).count() 44 | data = [await audit_log.to_dict() for audit_log in audit_log_objs] 45 | return SuccessExtra(data=data, total=total, page=page, page_size=page_size) 46 | -------------------------------------------------------------------------------- /app/api/v1/base/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .base import router 4 | 5 | base_router = APIRouter() 6 | base_router.include_router(router, tags=["基础模块"]) 7 | 8 | __all__ = ["base_router"] 9 | -------------------------------------------------------------------------------- /app/api/v1/base/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | from fastapi import APIRouter 4 | 5 | from app.controllers.user import user_controller 6 | from app.core.ctx import CTX_USER_ID 7 | from app.core.dependency import DependAuth 8 | from app.models.admin import Api, Menu, Role, User 9 | from app.schemas.base import Fail, Success 10 | from app.schemas.login import * 11 | from app.schemas.users import UpdatePassword 12 | from app.settings import settings 13 | from app.utils.jwt_utils import create_access_token 14 | from app.utils.password import get_password_hash, verify_password 15 | 16 | router = APIRouter() 17 | 18 | 19 | @router.post("/access_token", summary="获取token") 20 | async def login_access_token(credentials: CredentialsSchema): 21 | user: User = await user_controller.authenticate(credentials) 22 | await user_controller.update_last_login(user.id) 23 | access_token_expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) 24 | expire = datetime.now(timezone.utc) + access_token_expires 25 | 26 | data = JWTOut( 27 | access_token=create_access_token( 28 | data=JWTPayload( 29 | user_id=user.id, 30 | username=user.username, 31 | is_superuser=user.is_superuser, 32 | exp=expire, 33 | ) 34 | ), 35 | username=user.username, 36 | ) 37 | return Success(data=data.model_dump()) 38 | 39 | 40 | @router.get("/userinfo", summary="查看用户信息", dependencies=[DependAuth]) 41 | async def get_userinfo(): 42 | user_id = CTX_USER_ID.get() 43 | user_obj = await user_controller.get(id=user_id) 44 | data = await user_obj.to_dict(exclude_fields=["password"]) 45 | data["avatar"] = "https://avatars.githubusercontent.com/u/54677442?v=4" 46 | return Success(data=data) 47 | 48 | 49 | @router.get("/usermenu", summary="查看用户菜单", dependencies=[DependAuth]) 50 | async def get_user_menu(): 51 | user_id = CTX_USER_ID.get() 52 | user_obj = await User.filter(id=user_id).first() 53 | menus: list[Menu] = [] 54 | if user_obj.is_superuser: 55 | menus = await Menu.all() 56 | else: 57 | role_objs: list[Role] = await user_obj.roles 58 | for role_obj in role_objs: 59 | menu = await role_obj.menus 60 | menus.extend(menu) 61 | menus = list(set(menus)) 62 | parent_menus: list[Menu] = [] 63 | for menu in menus: 64 | if menu.parent_id == 0: 65 | parent_menus.append(menu) 66 | res = [] 67 | for parent_menu in parent_menus: 68 | parent_menu_dict = await parent_menu.to_dict() 69 | parent_menu_dict["children"] = [] 70 | for menu in menus: 71 | if menu.parent_id == parent_menu.id: 72 | parent_menu_dict["children"].append(await menu.to_dict()) 73 | res.append(parent_menu_dict) 74 | return Success(data=res) 75 | 76 | 77 | @router.get("/userapi", summary="查看用户API", dependencies=[DependAuth]) 78 | async def get_user_api(): 79 | user_id = CTX_USER_ID.get() 80 | user_obj = await User.filter(id=user_id).first() 81 | if user_obj.is_superuser: 82 | api_objs: list[Api] = await Api.all() 83 | apis = [api.method.lower() + api.path for api in api_objs] 84 | return Success(data=apis) 85 | role_objs: list[Role] = await user_obj.roles 86 | apis = [] 87 | for role_obj in role_objs: 88 | api_objs: list[Api] = await role_obj.apis 89 | apis.extend([api.method.lower() + api.path for api in api_objs]) 90 | apis = list(set(apis)) 91 | return Success(data=apis) 92 | 93 | 94 | @router.post("/update_password", summary="修改密码", dependencies=[DependAuth]) 95 | async def update_user_password(req_in: UpdatePassword): 96 | user_id = CTX_USER_ID.get() 97 | user = await user_controller.get(user_id) 98 | verified = verify_password(req_in.old_password, user.password) 99 | if not verified: 100 | return Fail(msg="旧密码验证错误!") 101 | user.password = get_password_hash(req_in.new_password) 102 | await user.save() 103 | return Success(msg="修改成功") 104 | -------------------------------------------------------------------------------- /app/api/v1/depts/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .depts import router 4 | 5 | depts_router = APIRouter() 6 | depts_router.include_router(router, tags=["部门模块"]) 7 | 8 | __all__ = ["depts_router"] 9 | -------------------------------------------------------------------------------- /app/api/v1/depts/depts.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Query 2 | 3 | from app.controllers.dept import dept_controller 4 | from app.schemas import Success 5 | from app.schemas.depts import * 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.get("/list", summary="查看部门列表") 11 | async def list_dept( 12 | name: str = Query(None, description="部门名称"), 13 | ): 14 | dept_tree = await dept_controller.get_dept_tree(name) 15 | return Success(data=dept_tree) 16 | 17 | 18 | @router.get("/get", summary="查看部门") 19 | async def get_dept( 20 | id: int = Query(..., description="部门ID"), 21 | ): 22 | dept_obj = await dept_controller.get(id=id) 23 | data = await dept_obj.to_dict() 24 | return Success(data=data) 25 | 26 | 27 | @router.post("/create", summary="创建部门") 28 | async def create_dept( 29 | dept_in: DeptCreate, 30 | ): 31 | await dept_controller.create_dept(obj_in=dept_in) 32 | return Success(msg="Created Successfully") 33 | 34 | 35 | @router.post("/update", summary="更新部门") 36 | async def update_dept( 37 | dept_in: DeptUpdate, 38 | ): 39 | await dept_controller.update_dept(obj_in=dept_in) 40 | return Success(msg="Update Successfully") 41 | 42 | 43 | @router.delete("/delete", summary="删除部门") 44 | async def delete_dept( 45 | dept_id: int = Query(..., description="部门ID"), 46 | ): 47 | await dept_controller.delete_dept(dept_id=dept_id) 48 | return Success(msg="Deleted Success") 49 | -------------------------------------------------------------------------------- /app/api/v1/menus/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .menus import router 4 | 5 | menus_router = APIRouter() 6 | menus_router.include_router(router, tags=["菜单模块"]) 7 | 8 | __all__ = ["menus_router"] 9 | -------------------------------------------------------------------------------- /app/api/v1/menus/menus.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import APIRouter, Query 4 | 5 | from app.controllers.menu import menu_controller 6 | from app.schemas.base import Fail, Success, SuccessExtra 7 | from app.schemas.menus import * 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.get("/list", summary="查看菜单列表") 15 | async def list_menu( 16 | page: int = Query(1, description="页码"), 17 | page_size: int = Query(10, description="每页数量"), 18 | ): 19 | async def get_menu_with_children(menu_id: int): 20 | menu = await menu_controller.model.get(id=menu_id) 21 | menu_dict = await menu.to_dict() 22 | child_menus = await menu_controller.model.filter(parent_id=menu_id).order_by("order") 23 | menu_dict["children"] = [await get_menu_with_children(child.id) for child in child_menus] 24 | return menu_dict 25 | 26 | parent_menus = await menu_controller.model.filter(parent_id=0).order_by("order") 27 | res_menu = [await get_menu_with_children(menu.id) for menu in parent_menus] 28 | return SuccessExtra(data=res_menu, total=len(res_menu), page=page, page_size=page_size) 29 | 30 | 31 | @router.get("/get", summary="查看菜单") 32 | async def get_menu( 33 | menu_id: int = Query(..., description="菜单id"), 34 | ): 35 | result = await menu_controller.get(id=menu_id) 36 | return Success(data=result) 37 | 38 | 39 | @router.post("/create", summary="创建菜单") 40 | async def create_menu( 41 | menu_in: MenuCreate, 42 | ): 43 | await menu_controller.create(obj_in=menu_in) 44 | return Success(msg="Created Success") 45 | 46 | 47 | @router.post("/update", summary="更新菜单") 48 | async def update_menu( 49 | menu_in: MenuUpdate, 50 | ): 51 | await menu_controller.update(id=menu_in.id, obj_in=menu_in) 52 | return Success(msg="Updated Success") 53 | 54 | 55 | @router.delete("/delete", summary="删除菜单") 56 | async def delete_menu( 57 | id: int = Query(..., description="菜单id"), 58 | ): 59 | child_menu_count = await menu_controller.model.filter(parent_id=id).count() 60 | if child_menu_count > 0: 61 | return Fail(msg="Cannot delete a menu with child menus") 62 | await menu_controller.remove(id=id) 63 | return Success(msg="Deleted Success") 64 | -------------------------------------------------------------------------------- /app/api/v1/roles/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .roles import router 4 | 5 | roles_router = APIRouter() 6 | roles_router.include_router(router, tags=["角色模块"]) 7 | 8 | __all__ = ["roles_router"] 9 | -------------------------------------------------------------------------------- /app/api/v1/roles/roles.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import APIRouter, Query 4 | from fastapi.exceptions import HTTPException 5 | from tortoise.expressions import Q 6 | 7 | from app.controllers import role_controller 8 | from app.schemas.base import Success, SuccessExtra 9 | from app.schemas.roles import * 10 | 11 | logger = logging.getLogger(__name__) 12 | router = APIRouter() 13 | 14 | 15 | @router.get("/list", summary="查看角色列表") 16 | async def list_role( 17 | page: int = Query(1, description="页码"), 18 | page_size: int = Query(10, description="每页数量"), 19 | role_name: str = Query("", description="角色名称,用于查询"), 20 | ): 21 | q = Q() 22 | if role_name: 23 | q = Q(name__contains=role_name) 24 | total, role_objs = await role_controller.list(page=page, page_size=page_size, search=q) 25 | data = [await obj.to_dict() for obj in role_objs] 26 | return SuccessExtra(data=data, total=total, page=page, page_size=page_size) 27 | 28 | 29 | @router.get("/get", summary="查看角色") 30 | async def get_role( 31 | role_id: int = Query(..., description="角色ID"), 32 | ): 33 | role_obj = await role_controller.get(id=role_id) 34 | return Success(data=await role_obj.to_dict()) 35 | 36 | 37 | @router.post("/create", summary="创建角色") 38 | async def create_role(role_in: RoleCreate): 39 | if await role_controller.is_exist(name=role_in.name): 40 | raise HTTPException( 41 | status_code=400, 42 | detail="The role with this rolename already exists in the system.", 43 | ) 44 | await role_controller.create(obj_in=role_in) 45 | return Success(msg="Created Successfully") 46 | 47 | 48 | @router.post("/update", summary="更新角色") 49 | async def update_role(role_in: RoleUpdate): 50 | await role_controller.update(id=role_in.id, obj_in=role_in) 51 | return Success(msg="Updated Successfully") 52 | 53 | 54 | @router.delete("/delete", summary="删除角色") 55 | async def delete_role( 56 | role_id: int = Query(..., description="角色ID"), 57 | ): 58 | await role_controller.remove(id=role_id) 59 | return Success(msg="Deleted Success") 60 | 61 | 62 | @router.get("/authorized", summary="查看角色权限") 63 | async def get_role_authorized(id: int = Query(..., description="角色ID")): 64 | role_obj = await role_controller.get(id=id) 65 | data = await role_obj.to_dict(m2m=True) 66 | return Success(data=data) 67 | 68 | 69 | @router.post("/authorized", summary="更新角色权限") 70 | async def update_role_authorized(role_in: RoleUpdateMenusApis): 71 | role_obj = await role_controller.get(id=role_in.id) 72 | await role_controller.update_roles(role=role_obj, menu_ids=role_in.menu_ids, api_infos=role_in.api_infos) 73 | return Success(msg="Updated Successfully") 74 | -------------------------------------------------------------------------------- /app/api/v1/users/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .users import router 4 | 5 | users_router = APIRouter() 6 | users_router.include_router(router, tags=["用户模块"]) 7 | 8 | __all__ = ["users_router"] 9 | -------------------------------------------------------------------------------- /app/api/v1/users/users.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import APIRouter, Body, Query 4 | from tortoise.expressions import Q 5 | 6 | from app.controllers.dept import dept_controller 7 | from app.controllers.user import user_controller 8 | from app.schemas.base import Fail, Success, SuccessExtra 9 | from app.schemas.users import * 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.get("/list", summary="查看用户列表") 17 | async def list_user( 18 | page: int = Query(1, description="页码"), 19 | page_size: int = Query(10, description="每页数量"), 20 | username: str = Query("", description="用户名称,用于搜索"), 21 | email: str = Query("", description="邮箱地址"), 22 | dept_id: int = Query(None, description="部门ID"), 23 | ): 24 | q = Q() 25 | if username: 26 | q &= Q(username__contains=username) 27 | if email: 28 | q &= Q(email__contains=email) 29 | if dept_id is not None: 30 | q &= Q(dept_id=dept_id) 31 | total, user_objs = await user_controller.list(page=page, page_size=page_size, search=q) 32 | data = [await obj.to_dict(m2m=True, exclude_fields=["password"]) for obj in user_objs] 33 | for item in data: 34 | dept_id = item.pop("dept_id", None) 35 | item["dept"] = await (await dept_controller.get(id=dept_id)).to_dict() if dept_id else {} 36 | 37 | return SuccessExtra(data=data, total=total, page=page, page_size=page_size) 38 | 39 | 40 | @router.get("/get", summary="查看用户") 41 | async def get_user( 42 | user_id: int = Query(..., description="用户ID"), 43 | ): 44 | user_obj = await user_controller.get(id=user_id) 45 | user_dict = await user_obj.to_dict(exclude_fields=["password"]) 46 | return Success(data=user_dict) 47 | 48 | 49 | @router.post("/create", summary="创建用户") 50 | async def create_user( 51 | user_in: UserCreate, 52 | ): 53 | user = await user_controller.get_by_email(user_in.email) 54 | if user: 55 | return Fail(code=400, msg="The user with this email already exists in the system.") 56 | new_user = await user_controller.create_user(obj_in=user_in) 57 | await user_controller.update_roles(new_user, user_in.role_ids) 58 | return Success(msg="Created Successfully") 59 | 60 | 61 | @router.post("/update", summary="更新用户") 62 | async def update_user( 63 | user_in: UserUpdate, 64 | ): 65 | user = await user_controller.update(id=user_in.id, obj_in=user_in) 66 | await user_controller.update_roles(user, user_in.role_ids) 67 | return Success(msg="Updated Successfully") 68 | 69 | 70 | @router.delete("/delete", summary="删除用户") 71 | async def delete_user( 72 | user_id: int = Query(..., description="用户ID"), 73 | ): 74 | await user_controller.remove(id=user_id) 75 | return Success(msg="Deleted Successfully") 76 | 77 | 78 | @router.post("/reset_password", summary="重置密码") 79 | async def reset_password(user_id: int = Body(..., description="用户ID", embed=True)): 80 | await user_controller.reset_password(user_id) 81 | return Success(msg="密码已重置为123456") 82 | -------------------------------------------------------------------------------- /app/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from .role import role_controller as role_controller 2 | from .user import user_controller as user_controller 3 | -------------------------------------------------------------------------------- /app/controllers/api.py: -------------------------------------------------------------------------------- 1 | from fastapi.routing import APIRoute 2 | 3 | from app.core.crud import CRUDBase 4 | from app.log import logger 5 | from app.models.admin import Api 6 | from app.schemas.apis import ApiCreate, ApiUpdate 7 | 8 | 9 | class ApiController(CRUDBase[Api, ApiCreate, ApiUpdate]): 10 | def __init__(self): 11 | super().__init__(model=Api) 12 | 13 | async def refresh_api(self): 14 | from app import app 15 | 16 | # 删除废弃API数据 17 | all_api_list = [] 18 | for route in app.routes: 19 | # 只更新有鉴权的API 20 | if isinstance(route, APIRoute) and len(route.dependencies) > 0: 21 | all_api_list.append((list(route.methods)[0], route.path_format)) 22 | delete_api = [] 23 | for api in await Api.all(): 24 | if (api.method, api.path) not in all_api_list: 25 | delete_api.append((api.method, api.path)) 26 | for item in delete_api: 27 | method, path = item 28 | logger.debug(f"API Deleted {method} {path}") 29 | await Api.filter(method=method, path=path).delete() 30 | 31 | for route in app.routes: 32 | if isinstance(route, APIRoute) and len(route.dependencies) > 0: 33 | method = list(route.methods)[0] 34 | path = route.path_format 35 | summary = route.summary 36 | tags = list(route.tags)[0] 37 | api_obj = await Api.filter(method=method, path=path).first() 38 | if api_obj: 39 | await api_obj.update_from_dict(dict(method=method, path=path, summary=summary, tags=tags)).save() 40 | else: 41 | logger.debug(f"API Created {method} {path}") 42 | await Api.create(**dict(method=method, path=path, summary=summary, tags=tags)) 43 | 44 | 45 | api_controller = ApiController() 46 | -------------------------------------------------------------------------------- /app/controllers/dept.py: -------------------------------------------------------------------------------- 1 | from tortoise.expressions import Q 2 | from tortoise.transactions import atomic 3 | 4 | from app.core.crud import CRUDBase 5 | from app.models.admin import Dept, DeptClosure 6 | from app.schemas.depts import DeptCreate, DeptUpdate 7 | 8 | 9 | class DeptController(CRUDBase[Dept, DeptCreate, DeptUpdate]): 10 | def __init__(self): 11 | super().__init__(model=Dept) 12 | 13 | async def get_dept_tree(self, name): 14 | q = Q() 15 | # 获取所有未被软删除的部门 16 | q &= Q(is_deleted=False) 17 | if name: 18 | q &= Q(name__contains=name) 19 | all_depts = await self.model.filter(q).order_by("order") 20 | 21 | # 辅助函数,用于递归构建部门树 22 | def build_tree(parent_id): 23 | return [ 24 | { 25 | "id": dept.id, 26 | "name": dept.name, 27 | "desc": dept.desc, 28 | "order": dept.order, 29 | "parent_id": dept.parent_id, 30 | "children": build_tree(dept.id), # 递归构建子部门 31 | } 32 | for dept in all_depts 33 | if dept.parent_id == parent_id 34 | ] 35 | 36 | # 从顶级部门(parent_id=0)开始构建部门树 37 | dept_tree = build_tree(0) 38 | return dept_tree 39 | 40 | async def get_dept_info(self): 41 | pass 42 | 43 | async def update_dept_closure(self, obj: Dept): 44 | parent_depts = await DeptClosure.filter(descendant=obj.parent_id) 45 | for i in parent_depts: 46 | print(i.ancestor, i.descendant) 47 | dept_closure_objs: list[DeptClosure] = [] 48 | # 插入父级关系 49 | for item in parent_depts: 50 | dept_closure_objs.append(DeptClosure(ancestor=item.ancestor, descendant=obj.id, level=item.level + 1)) 51 | # 插入自身x 52 | dept_closure_objs.append(DeptClosure(ancestor=obj.id, descendant=obj.id, level=0)) 53 | # 创建关系 54 | await DeptClosure.bulk_create(dept_closure_objs) 55 | 56 | @atomic() 57 | async def create_dept(self, obj_in: DeptCreate): 58 | # 创建 59 | if obj_in.parent_id != 0: 60 | await self.get(id=obj_in.parent_id) 61 | new_obj = await self.create(obj_in=obj_in) 62 | await self.update_dept_closure(new_obj) 63 | 64 | @atomic() 65 | async def update_dept(self, obj_in: DeptUpdate): 66 | dept_obj = await self.get(id=obj_in.id) 67 | # 更新部门关系 68 | if dept_obj.parent_id != obj_in.parent_id: 69 | await DeptClosure.filter(ancestor=dept_obj.id).delete() 70 | await DeptClosure.filter(descendant=dept_obj.id).delete() 71 | await self.update_dept_closure(dept_obj) 72 | # 更新部门信息 73 | dept_obj.update_from_dict(obj_in.model_dump(exclude_unset=True)) 74 | await dept_obj.save() 75 | 76 | @atomic() 77 | async def delete_dept(self, dept_id: int): 78 | # 删除部门 79 | obj = await self.get(id=dept_id) 80 | obj.is_deleted = True 81 | await obj.save() 82 | # 删除关系 83 | await DeptClosure.filter(descendant=dept_id).delete() 84 | 85 | 86 | dept_controller = DeptController() 87 | -------------------------------------------------------------------------------- /app/controllers/menu.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from app.core.crud import CRUDBase 4 | from app.models.admin import Menu 5 | from app.schemas.menus import MenuCreate, MenuUpdate 6 | 7 | 8 | class MenuController(CRUDBase[Menu, MenuCreate, MenuUpdate]): 9 | def __init__(self): 10 | super().__init__(model=Menu) 11 | 12 | async def get_by_menu_path(self, path: str) -> Optional["Menu"]: 13 | return await self.model.filter(path=path).first() 14 | 15 | 16 | menu_controller = MenuController() 17 | -------------------------------------------------------------------------------- /app/controllers/role.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from app.core.crud import CRUDBase 4 | from app.models.admin import Api, Menu, Role 5 | from app.schemas.roles import RoleCreate, RoleUpdate 6 | 7 | 8 | class RoleController(CRUDBase[Role, RoleCreate, RoleUpdate]): 9 | def __init__(self): 10 | super().__init__(model=Role) 11 | 12 | async def is_exist(self, name: str) -> bool: 13 | return await self.model.filter(name=name).exists() 14 | 15 | async def update_roles(self, role: Role, menu_ids: List[int], api_infos: List[dict]) -> None: 16 | await role.menus.clear() 17 | for menu_id in menu_ids: 18 | menu_obj = await Menu.filter(id=menu_id).first() 19 | await role.menus.add(menu_obj) 20 | 21 | await role.apis.clear() 22 | for item in api_infos: 23 | api_obj = await Api.filter(path=item.get("path"), method=item.get("method")).first() 24 | await role.apis.add(api_obj) 25 | 26 | 27 | role_controller = RoleController() 28 | -------------------------------------------------------------------------------- /app/controllers/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | 4 | from fastapi.exceptions import HTTPException 5 | 6 | from app.core.crud import CRUDBase 7 | from app.models.admin import User 8 | from app.schemas.login import CredentialsSchema 9 | from app.schemas.users import UserCreate, UserUpdate 10 | from app.utils.password import get_password_hash, verify_password 11 | 12 | from .role import role_controller 13 | 14 | 15 | class UserController(CRUDBase[User, UserCreate, UserUpdate]): 16 | def __init__(self): 17 | super().__init__(model=User) 18 | 19 | async def get_by_email(self, email: str) -> Optional[User]: 20 | return await self.model.filter(email=email).first() 21 | 22 | async def get_by_username(self, username: str) -> Optional[User]: 23 | return await self.model.filter(username=username).first() 24 | 25 | async def create_user(self, obj_in: UserCreate) -> User: 26 | obj_in.password = get_password_hash(password=obj_in.password) 27 | obj = await self.create(obj_in) 28 | return obj 29 | 30 | async def update_last_login(self, id: int) -> None: 31 | user = await self.model.get(id=id) 32 | user.last_login = datetime.now() 33 | await user.save() 34 | 35 | async def authenticate(self, credentials: CredentialsSchema) -> Optional["User"]: 36 | user = await self.model.filter(username=credentials.username).first() 37 | if not user: 38 | raise HTTPException(status_code=400, detail="无效的用户名") 39 | verified = verify_password(credentials.password, user.password) 40 | if not verified: 41 | raise HTTPException(status_code=400, detail="密码错误!") 42 | if not user.is_active: 43 | raise HTTPException(status_code=400, detail="用户已被禁用") 44 | return user 45 | 46 | async def update_roles(self, user: User, role_ids: List[int]) -> None: 47 | await user.roles.clear() 48 | for role_id in role_ids: 49 | role_obj = await role_controller.get(id=role_id) 50 | await user.roles.add(role_obj) 51 | 52 | async def reset_password(self, user_id: int): 53 | user_obj = await self.get(id=user_id) 54 | if user_obj.is_superuser: 55 | raise HTTPException(status_code=403, detail="不允许重置超级管理员密码") 56 | user_obj.password = get_password_hash(password="123456") 57 | await user_obj.save() 58 | 59 | 60 | user_controller = UserController() 61 | -------------------------------------------------------------------------------- /app/core/bgtask.py: -------------------------------------------------------------------------------- 1 | from starlette.background import BackgroundTasks 2 | 3 | from .ctx import CTX_BG_TASKS 4 | 5 | 6 | class BgTasks: 7 | """后台任务统一管理""" 8 | 9 | @classmethod 10 | async def init_bg_tasks_obj(cls): 11 | """实例化后台任务,并设置到上下文""" 12 | bg_tasks = BackgroundTasks() 13 | CTX_BG_TASKS.set(bg_tasks) 14 | 15 | @classmethod 16 | async def get_bg_tasks_obj(cls): 17 | """从上下文中获取后台任务实例""" 18 | return CTX_BG_TASKS.get() 19 | 20 | @classmethod 21 | async def add_task(cls, func, *args, **kwargs): 22 | """添加后台任务""" 23 | bg_tasks = await cls.get_bg_tasks_obj() 24 | bg_tasks.add_task(func, *args, **kwargs) 25 | 26 | @classmethod 27 | async def execute_tasks(cls): 28 | """执行后台任务,一般是请求结果返回之后执行""" 29 | bg_tasks = await cls.get_bg_tasks_obj() 30 | if bg_tasks.tasks: 31 | await bg_tasks() 32 | -------------------------------------------------------------------------------- /app/core/crud.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Generic, List, NewType, Tuple, Type, TypeVar, Union 2 | 3 | from pydantic import BaseModel 4 | from tortoise.expressions import Q 5 | from tortoise.models import Model 6 | 7 | Total = NewType("Total", int) 8 | ModelType = TypeVar("ModelType", bound=Model) 9 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 10 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 11 | 12 | 13 | class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 14 | def __init__(self, model: Type[ModelType]): 15 | self.model = model 16 | 17 | async def get(self, id: int) -> ModelType: 18 | return await self.model.get(id=id) 19 | 20 | async def list(self, page: int, page_size: int, search: Q = Q(), order: list = []) -> Tuple[Total, List[ModelType]]: 21 | query = self.model.filter(search) 22 | return await query.count(), await query.offset((page - 1) * page_size).limit(page_size).order_by(*order) 23 | 24 | async def create(self, obj_in: CreateSchemaType) -> ModelType: 25 | if isinstance(obj_in, Dict): 26 | obj_dict = obj_in 27 | else: 28 | obj_dict = obj_in.model_dump() 29 | obj = self.model(**obj_dict) 30 | await obj.save() 31 | return obj 32 | 33 | async def update(self, id: int, obj_in: Union[UpdateSchemaType, Dict[str, Any]]) -> ModelType: 34 | if isinstance(obj_in, Dict): 35 | obj_dict = obj_in 36 | else: 37 | obj_dict = obj_in.model_dump(exclude_unset=True, exclude={"id"}) 38 | obj = await self.get(id=id) 39 | obj = obj.update_from_dict(obj_dict) 40 | await obj.save() 41 | return obj 42 | 43 | async def remove(self, id: int) -> None: 44 | obj = await self.get(id=id) 45 | await obj.delete() 46 | -------------------------------------------------------------------------------- /app/core/ctx.py: -------------------------------------------------------------------------------- 1 | import contextvars 2 | 3 | from starlette.background import BackgroundTasks 4 | 5 | CTX_USER_ID: contextvars.ContextVar[int] = contextvars.ContextVar("user_id", default=0) 6 | CTX_BG_TASKS: contextvars.ContextVar[BackgroundTasks] = contextvars.ContextVar("bg_task", default=None) 7 | -------------------------------------------------------------------------------- /app/core/dependency.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import jwt 4 | from fastapi import Depends, Header, HTTPException, Request 5 | 6 | from app.core.ctx import CTX_USER_ID 7 | from app.models import Role, User 8 | from app.settings import settings 9 | 10 | 11 | class AuthControl: 12 | @classmethod 13 | async def is_authed(cls, token: str = Header(..., description="token验证")) -> Optional["User"]: 14 | try: 15 | if token == "dev": 16 | user = await User.filter().first() 17 | user_id = user.id 18 | else: 19 | decode_data = jwt.decode(token, settings.SECRET_KEY, algorithms=settings.JWT_ALGORITHM) 20 | user_id = decode_data.get("user_id") 21 | user = await User.filter(id=user_id).first() 22 | if not user: 23 | raise HTTPException(status_code=401, detail="Authentication failed") 24 | CTX_USER_ID.set(int(user_id)) 25 | return user 26 | except jwt.DecodeError: 27 | raise HTTPException(status_code=401, detail="无效的Token") 28 | except jwt.ExpiredSignatureError: 29 | raise HTTPException(status_code=401, detail="登录已过期") 30 | except Exception as e: 31 | raise HTTPException(status_code=500, detail=f"{repr(e)}") 32 | 33 | 34 | class PermissionControl: 35 | @classmethod 36 | async def has_permission(cls, request: Request, current_user: User = Depends(AuthControl.is_authed)) -> None: 37 | if current_user.is_superuser: 38 | return 39 | method = request.method 40 | path = request.url.path 41 | roles: list[Role] = await current_user.roles 42 | if not roles: 43 | raise HTTPException(status_code=403, detail="The user is not bound to a role") 44 | apis = [await role.apis for role in roles] 45 | permission_apis = list(set((api.method, api.path) for api in sum(apis, []))) 46 | # path = "/api/v1/auth/userinfo" 47 | # method = "GET" 48 | if (method, path) not in permission_apis: 49 | raise HTTPException(status_code=403, detail=f"Permission denied method:{method} path:{path}") 50 | 51 | 52 | DependAuth = Depends(AuthControl.is_authed) 53 | DependPermission = Depends(PermissionControl.has_permission) 54 | -------------------------------------------------------------------------------- /app/core/exceptions.py: -------------------------------------------------------------------------------- 1 | from fastapi.exceptions import ( 2 | HTTPException, 3 | RequestValidationError, 4 | ResponseValidationError, 5 | ) 6 | from fastapi.requests import Request 7 | from fastapi.responses import JSONResponse 8 | from tortoise.exceptions import DoesNotExist, IntegrityError 9 | 10 | 11 | class SettingNotFound(Exception): 12 | pass 13 | 14 | 15 | async def DoesNotExistHandle(req: Request, exc: DoesNotExist) -> JSONResponse: 16 | content = dict( 17 | code=404, 18 | msg=f"Object has not found, exc: {exc}, query_params: {req.query_params}", 19 | ) 20 | return JSONResponse(content=content, status_code=404) 21 | 22 | 23 | async def IntegrityHandle(_: Request, exc: IntegrityError) -> JSONResponse: 24 | content = dict( 25 | code=500, 26 | msg=f"IntegrityError,{exc}", 27 | ) 28 | return JSONResponse(content=content, status_code=500) 29 | 30 | 31 | async def HttpExcHandle(_: Request, exc: HTTPException) -> JSONResponse: 32 | content = dict(code=exc.status_code, msg=exc.detail, data=None) 33 | return JSONResponse(content=content, status_code=exc.status_code) 34 | 35 | 36 | async def RequestValidationHandle(_: Request, exc: RequestValidationError) -> JSONResponse: 37 | content = dict(code=422, msg=f"RequestValidationError, {exc}") 38 | return JSONResponse(content=content, status_code=422) 39 | 40 | 41 | async def ResponseValidationHandle(_: Request, exc: ResponseValidationError) -> JSONResponse: 42 | content = dict(code=500, msg=f"ResponseValidationError, {exc}") 43 | return JSONResponse(content=content, status_code=500) 44 | -------------------------------------------------------------------------------- /app/log/__init__.py: -------------------------------------------------------------------------------- 1 | from .log import logger as logger 2 | -------------------------------------------------------------------------------- /app/log/log.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from loguru import logger as loguru_logger 4 | 5 | from app.settings import settings 6 | 7 | 8 | class Loggin: 9 | def __init__(self) -> None: 10 | debug = settings.DEBUG 11 | if debug: 12 | self.level = "DEBUG" 13 | else: 14 | self.level = "INFO" 15 | 16 | def setup_logger(self): 17 | loguru_logger.remove() 18 | loguru_logger.add(sink=sys.stdout, level=self.level) 19 | 20 | # logger.add("my_project.log", level=level, rotation="100 MB") # Output log messages to a file 21 | return loguru_logger 22 | 23 | 24 | loggin = Loggin() 25 | logger = loggin.setup_logger() 26 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | # 新增model需要在这里导入 2 | from .admin import * 3 | -------------------------------------------------------------------------------- /app/models/admin.py: -------------------------------------------------------------------------------- 1 | from tortoise import fields 2 | 3 | from app.schemas.menus import MenuType 4 | 5 | from .base import BaseModel, TimestampMixin 6 | from .enums import MethodType 7 | 8 | 9 | class User(BaseModel, TimestampMixin): 10 | username = fields.CharField(max_length=20, unique=True, description="用户名称", index=True) 11 | alias = fields.CharField(max_length=30, null=True, description="姓名", index=True) 12 | email = fields.CharField(max_length=255, unique=True, description="邮箱", index=True) 13 | phone = fields.CharField(max_length=20, null=True, description="电话", index=True) 14 | password = fields.CharField(max_length=128, null=True, description="密码") 15 | is_active = fields.BooleanField(default=True, description="是否激活", index=True) 16 | is_superuser = fields.BooleanField(default=False, description="是否为超级管理员", index=True) 17 | last_login = fields.DatetimeField(null=True, description="最后登录时间", index=True) 18 | roles = fields.ManyToManyField("models.Role", related_name="user_roles") 19 | dept_id = fields.IntField(null=True, description="部门ID", index=True) 20 | 21 | class Meta: 22 | table = "user" 23 | 24 | 25 | class Role(BaseModel, TimestampMixin): 26 | name = fields.CharField(max_length=20, unique=True, description="角色名称", index=True) 27 | desc = fields.CharField(max_length=500, null=True, description="角色描述") 28 | menus = fields.ManyToManyField("models.Menu", related_name="role_menus") 29 | apis = fields.ManyToManyField("models.Api", related_name="role_apis") 30 | 31 | class Meta: 32 | table = "role" 33 | 34 | 35 | class Api(BaseModel, TimestampMixin): 36 | path = fields.CharField(max_length=100, description="API路径", index=True) 37 | method = fields.CharEnumField(MethodType, description="请求方法", index=True) 38 | summary = fields.CharField(max_length=500, description="请求简介", index=True) 39 | tags = fields.CharField(max_length=100, description="API标签", index=True) 40 | 41 | class Meta: 42 | table = "api" 43 | 44 | 45 | class Menu(BaseModel, TimestampMixin): 46 | name = fields.CharField(max_length=20, description="菜单名称", index=True) 47 | remark = fields.JSONField(null=True, description="保留字段") 48 | menu_type = fields.CharEnumField(MenuType, null=True, description="菜单类型") 49 | icon = fields.CharField(max_length=100, null=True, description="菜单图标") 50 | path = fields.CharField(max_length=100, description="菜单路径", index=True) 51 | order = fields.IntField(default=0, description="排序", index=True) 52 | parent_id = fields.IntField(default=0, description="父菜单ID", index=True) 53 | is_hidden = fields.BooleanField(default=False, description="是否隐藏") 54 | component = fields.CharField(max_length=100, description="组件") 55 | keepalive = fields.BooleanField(default=True, description="存活") 56 | redirect = fields.CharField(max_length=100, null=True, description="重定向") 57 | 58 | class Meta: 59 | table = "menu" 60 | 61 | 62 | class Dept(BaseModel, TimestampMixin): 63 | name = fields.CharField(max_length=20, unique=True, description="部门名称", index=True) 64 | desc = fields.CharField(max_length=500, null=True, description="备注") 65 | is_deleted = fields.BooleanField(default=False, description="软删除标记", index=True) 66 | order = fields.IntField(default=0, description="排序", index=True) 67 | parent_id = fields.IntField(default=0, max_length=10, description="父部门ID", index=True) 68 | 69 | class Meta: 70 | table = "dept" 71 | 72 | 73 | class DeptClosure(BaseModel, TimestampMixin): 74 | ancestor = fields.IntField(description="父代", index=True) 75 | descendant = fields.IntField(description="子代", index=True) 76 | level = fields.IntField(default=0, description="深度", index=True) 77 | 78 | 79 | class AuditLog(BaseModel, TimestampMixin): 80 | user_id = fields.IntField(description="用户ID", index=True) 81 | username = fields.CharField(max_length=64, default="", description="用户名称", index=True) 82 | module = fields.CharField(max_length=64, default="", description="功能模块", index=True) 83 | summary = fields.CharField(max_length=128, default="", description="请求描述", index=True) 84 | method = fields.CharField(max_length=10, default="", description="请求方法", index=True) 85 | path = fields.CharField(max_length=255, default="", description="请求路径", index=True) 86 | status = fields.IntField(default=-1, description="状态码", index=True) 87 | response_time = fields.IntField(default=0, description="响应时间(单位ms)", index=True) 88 | request_args = fields.JSONField(null=True, description="请求参数") 89 | response_body = fields.JSONField(null=True, description="返回数据") 90 | -------------------------------------------------------------------------------- /app/models/base.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime 3 | 4 | from tortoise import fields, models 5 | 6 | from app.settings import settings 7 | 8 | 9 | class BaseModel(models.Model): 10 | id = fields.BigIntField(pk=True, index=True) 11 | 12 | async def to_dict(self, m2m: bool = False, exclude_fields: list[str] | None = None): 13 | if exclude_fields is None: 14 | exclude_fields = [] 15 | 16 | d = {} 17 | for field in self._meta.db_fields: 18 | if field not in exclude_fields: 19 | value = getattr(self, field) 20 | if isinstance(value, datetime): 21 | value = value.strftime(settings.DATETIME_FORMAT) 22 | d[field] = value 23 | 24 | if m2m: 25 | tasks = [ 26 | self.__fetch_m2m_field(field, exclude_fields) 27 | for field in self._meta.m2m_fields 28 | if field not in exclude_fields 29 | ] 30 | results = await asyncio.gather(*tasks) 31 | for field, values in results: 32 | d[field] = values 33 | 34 | return d 35 | 36 | async def __fetch_m2m_field(self, field, exclude_fields): 37 | values = await getattr(self, field).all().values() 38 | formatted_values = [] 39 | 40 | for value in values: 41 | formatted_value = {} 42 | for k, v in value.items(): 43 | if k not in exclude_fields: 44 | if isinstance(v, datetime): 45 | formatted_value[k] = v.strftime(settings.DATETIME_FORMAT) 46 | else: 47 | formatted_value[k] = v 48 | formatted_values.append(formatted_value) 49 | 50 | return field, formatted_values 51 | 52 | class Meta: 53 | abstract = True 54 | 55 | 56 | class UUIDModel: 57 | uuid = fields.UUIDField(unique=True, pk=False, index=True) 58 | 59 | 60 | class TimestampMixin: 61 | created_at = fields.DatetimeField(auto_now_add=True, index=True) 62 | updated_at = fields.DatetimeField(auto_now=True, index=True) 63 | -------------------------------------------------------------------------------- /app/models/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, StrEnum 2 | 3 | 4 | class EnumBase(Enum): 5 | @classmethod 6 | def get_member_values(cls): 7 | return [item.value for item in cls._member_map_.values()] 8 | 9 | @classmethod 10 | def get_member_names(cls): 11 | return [name for name in cls._member_names_] 12 | 13 | 14 | class MethodType(StrEnum): 15 | GET = "GET" 16 | POST = "POST" 17 | PUT = "PUT" 18 | DELETE = "DELETE" 19 | PATCH = "PATCH" 20 | -------------------------------------------------------------------------------- /app/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | -------------------------------------------------------------------------------- /app/schemas/apis.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | from app.models.enums import MethodType 4 | 5 | 6 | class BaseApi(BaseModel): 7 | path: str = Field(..., description="API路径", example="/api/v1/user/list") 8 | summary: str = Field("", description="API简介", example="查看用户列表") 9 | method: MethodType = Field(..., description="API方法", example="GET") 10 | tags: str = Field(..., description="API标签", example="User") 11 | 12 | 13 | class ApiCreate(BaseApi): ... 14 | 15 | 16 | class ApiUpdate(BaseApi): 17 | id: int 18 | -------------------------------------------------------------------------------- /app/schemas/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from fastapi.responses import JSONResponse 4 | 5 | 6 | class Success(JSONResponse): 7 | def __init__( 8 | self, 9 | code: int = 200, 10 | msg: Optional[str] = "OK", 11 | data: Optional[Any] = None, 12 | **kwargs, 13 | ): 14 | content = {"code": code, "msg": msg, "data": data} 15 | content.update(kwargs) 16 | super().__init__(content=content, status_code=code) 17 | 18 | 19 | class Fail(JSONResponse): 20 | def __init__( 21 | self, 22 | code: int = 400, 23 | msg: Optional[str] = None, 24 | data: Optional[Any] = None, 25 | **kwargs, 26 | ): 27 | content = {"code": code, "msg": msg, "data": data} 28 | content.update(kwargs) 29 | super().__init__(content=content, status_code=code) 30 | 31 | 32 | class SuccessExtra(JSONResponse): 33 | def __init__( 34 | self, 35 | code: int = 200, 36 | msg: Optional[str] = None, 37 | data: Optional[Any] = None, 38 | total: int = 0, 39 | page: int = 1, 40 | page_size: int = 20, 41 | **kwargs, 42 | ): 43 | content = { 44 | "code": code, 45 | "msg": msg, 46 | "data": data, 47 | "total": total, 48 | "page": page, 49 | "page_size": page_size, 50 | } 51 | content.update(kwargs) 52 | super().__init__(content=content, status_code=code) 53 | -------------------------------------------------------------------------------- /app/schemas/depts.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class BaseDept(BaseModel): 5 | name: str = Field(..., description="部门名称", example="研发中心") 6 | desc: str = Field("", description="备注", example="研发中心") 7 | order: int = Field(0, description="排序") 8 | parent_id: int = Field(0, description="父部门ID") 9 | 10 | 11 | class DeptCreate(BaseDept): ... 12 | 13 | 14 | class DeptUpdate(BaseDept): 15 | id: int 16 | 17 | def update_dict(self): 18 | return self.model_dump(exclude_unset=True, exclude={"id"}) 19 | -------------------------------------------------------------------------------- /app/schemas/login.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class CredentialsSchema(BaseModel): 7 | username: str = Field(..., description="用户名称", example="admin") 8 | password: str = Field(..., description="密码", example="123456") 9 | 10 | 11 | class JWTOut(BaseModel): 12 | access_token: str 13 | username: str 14 | 15 | 16 | class JWTPayload(BaseModel): 17 | user_id: int 18 | username: str 19 | is_superuser: bool 20 | exp: datetime 21 | -------------------------------------------------------------------------------- /app/schemas/menus.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | 7 | class MenuType(StrEnum): 8 | CATALOG = "catalog" # 目录 9 | MENU = "menu" # 菜单 10 | 11 | 12 | class BaseMenu(BaseModel): 13 | id: int 14 | name: str 15 | path: str 16 | remark: Optional[dict] 17 | menu_type: Optional[MenuType] 18 | icon: Optional[str] 19 | order: int 20 | parent_id: int 21 | is_hidden: bool 22 | component: str 23 | keepalive: bool 24 | redirect: Optional[str] 25 | children: Optional[list["BaseMenu"]] 26 | 27 | 28 | class MenuCreate(BaseModel): 29 | menu_type: MenuType = Field(default=MenuType.CATALOG.value) 30 | name: str = Field(example="用户管理") 31 | icon: Optional[str] = "ph:user-list-bold" 32 | path: str = Field(example="/system/user") 33 | order: Optional[int] = Field(example=1) 34 | parent_id: Optional[int] = Field(example=0, default=0) 35 | is_hidden: Optional[bool] = False 36 | component: str = Field(default="Layout", example="/system/user") 37 | keepalive: Optional[bool] = True 38 | redirect: Optional[str] = "" 39 | 40 | 41 | class MenuUpdate(BaseModel): 42 | id: int 43 | menu_type: Optional[MenuType] = Field(example=MenuType.CATALOG.value) 44 | name: Optional[str] = Field(example="用户管理") 45 | icon: Optional[str] = "ph:user-list-bold" 46 | path: Optional[str] = Field(example="/system/user") 47 | order: Optional[int] = Field(example=1) 48 | parent_id: Optional[int] = Field(example=0) 49 | is_hidden: Optional[bool] = False 50 | component: str = Field(example="/system/user") 51 | keepalive: Optional[bool] = False 52 | redirect: Optional[str] = "" 53 | -------------------------------------------------------------------------------- /app/schemas/roles.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | 7 | class BaseRole(BaseModel): 8 | id: int 9 | name: str 10 | desc: str = "" 11 | users: Optional[list] = [] 12 | menus: Optional[list] = [] 13 | apis: Optional[list] = [] 14 | created_at: Optional[datetime] 15 | updated_at: Optional[datetime] 16 | 17 | 18 | class RoleCreate(BaseModel): 19 | name: str = Field(example="管理员") 20 | desc: str = Field("", example="管理员角色") 21 | 22 | 23 | class RoleUpdate(BaseModel): 24 | id: int = Field(example=1) 25 | name: str = Field(example="管理员") 26 | desc: str = Field("", example="管理员角色") 27 | 28 | 29 | class RoleUpdateMenusApis(BaseModel): 30 | id: int 31 | menu_ids: list[int] = [] 32 | api_infos: list[dict] = [] 33 | -------------------------------------------------------------------------------- /app/schemas/users.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | 4 | from pydantic import BaseModel, EmailStr, Field 5 | 6 | 7 | class BaseUser(BaseModel): 8 | id: int 9 | email: Optional[EmailStr] = None 10 | username: Optional[str] = None 11 | is_active: Optional[bool] = True 12 | is_superuser: Optional[bool] = False 13 | created_at: Optional[datetime] 14 | updated_at: Optional[datetime] 15 | last_login: Optional[datetime] 16 | roles: Optional[list] = [] 17 | 18 | 19 | class UserCreate(BaseModel): 20 | email: EmailStr = Field(example="admin@qq.com") 21 | username: str = Field(example="admin") 22 | password: str = Field(example="123456") 23 | is_active: Optional[bool] = True 24 | is_superuser: Optional[bool] = False 25 | role_ids: Optional[List[int]] = [] 26 | dept_id: Optional[int] = Field(0, description="部门ID") 27 | 28 | def create_dict(self): 29 | return self.model_dump(exclude_unset=True, exclude={"role_ids"}) 30 | 31 | 32 | class UserUpdate(BaseModel): 33 | id: int 34 | email: EmailStr 35 | username: str 36 | is_active: Optional[bool] = True 37 | is_superuser: Optional[bool] = False 38 | role_ids: Optional[List[int]] = [] 39 | dept_id: Optional[int] = 0 40 | 41 | 42 | class UpdatePassword(BaseModel): 43 | old_password: str = Field(description="旧密码") 44 | new_password: str = Field(description="新密码") 45 | -------------------------------------------------------------------------------- /app/settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import settings as settings 2 | 3 | TORTOISE_ORM = settings.TORTOISE_ORM 4 | -------------------------------------------------------------------------------- /app/settings/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import typing 3 | 4 | from pydantic_settings import BaseSettings 5 | 6 | 7 | class Settings(BaseSettings): 8 | VERSION: str = "0.1.0" 9 | APP_TITLE: str = "Vue FastAPI Admin" 10 | PROJECT_NAME: str = "Vue FastAPI Admin" 11 | APP_DESCRIPTION: str = "Description" 12 | 13 | CORS_ORIGINS: typing.List = ["*"] 14 | CORS_ALLOW_CREDENTIALS: bool = True 15 | CORS_ALLOW_METHODS: typing.List = ["*"] 16 | CORS_ALLOW_HEADERS: typing.List = ["*"] 17 | 18 | DEBUG: bool = True 19 | 20 | PROJECT_ROOT: str = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) 21 | BASE_DIR: str = os.path.abspath(os.path.join(PROJECT_ROOT, os.pardir)) 22 | LOGS_ROOT: str = os.path.join(BASE_DIR, "app/logs") 23 | SECRET_KEY: str = "3488a63e1765035d386f05409663f55c83bfae3b3c61a932744b20ad14244dcf" # openssl rand -hex 32 24 | JWT_ALGORITHM: str = "HS256" 25 | JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 day 26 | TORTOISE_ORM: dict = { 27 | "connections": { 28 | # SQLite configuration 29 | "sqlite": { 30 | "engine": "tortoise.backends.sqlite", 31 | "credentials": {"file_path": f"{BASE_DIR}/db.sqlite3"}, # Path to SQLite database file 32 | }, 33 | # MySQL/MariaDB configuration 34 | # Install with: tortoise-orm[asyncmy] 35 | # "mysql": { 36 | # "engine": "tortoise.backends.mysql", 37 | # "credentials": { 38 | # "host": "localhost", # Database host address 39 | # "port": 3306, # Database port 40 | # "user": "yourusername", # Database username 41 | # "password": "yourpassword", # Database password 42 | # "database": "yourdatabase", # Database name 43 | # }, 44 | # }, 45 | # PostgreSQL configuration 46 | # Install with: tortoise-orm[asyncpg] 47 | # "postgres": { 48 | # "engine": "tortoise.backends.asyncpg", 49 | # "credentials": { 50 | # "host": "localhost", # Database host address 51 | # "port": 5432, # Database port 52 | # "user": "yourusername", # Database username 53 | # "password": "yourpassword", # Database password 54 | # "database": "yourdatabase", # Database name 55 | # }, 56 | # }, 57 | # MSSQL/Oracle configuration 58 | # Install with: tortoise-orm[asyncodbc] 59 | # "oracle": { 60 | # "engine": "tortoise.backends.asyncodbc", 61 | # "credentials": { 62 | # "host": "localhost", # Database host address 63 | # "port": 1433, # Database port 64 | # "user": "yourusername", # Database username 65 | # "password": "yourpassword", # Database password 66 | # "database": "yourdatabase", # Database name 67 | # }, 68 | # }, 69 | # SQLServer configuration 70 | # Install with: tortoise-orm[asyncodbc] 71 | # "sqlserver": { 72 | # "engine": "tortoise.backends.asyncodbc", 73 | # "credentials": { 74 | # "host": "localhost", # Database host address 75 | # "port": 1433, # Database port 76 | # "user": "yourusername", # Database username 77 | # "password": "yourpassword", # Database password 78 | # "database": "yourdatabase", # Database name 79 | # }, 80 | # }, 81 | }, 82 | "apps": { 83 | "models": { 84 | "models": ["app.models", "aerich.models"], 85 | "default_connection": "sqlite", 86 | }, 87 | }, 88 | "use_tz": False, # Whether to use timezone-aware datetimes 89 | "timezone": "Asia/Shanghai", # Timezone setting 90 | } 91 | DATETIME_FORMAT: str = "%Y-%m-%d %H:%M:%S" 92 | 93 | 94 | settings = Settings() 95 | -------------------------------------------------------------------------------- /app/utils/jwt_utils.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | 3 | from app.schemas.login import JWTPayload 4 | from app.settings.config import settings 5 | 6 | 7 | def create_access_token(*, data: JWTPayload): 8 | payload = data.model_dump().copy() 9 | encoded_jwt = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM) 10 | return encoded_jwt 11 | -------------------------------------------------------------------------------- /app/utils/password.py: -------------------------------------------------------------------------------- 1 | from passlib import pwd 2 | from passlib.context import CryptContext 3 | 4 | pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") 5 | 6 | 7 | def verify_password(plain_password: str, hashed_password: str) -> bool: 8 | return pwd_context.verify(plain_password, hashed_password) 9 | 10 | 11 | def get_password_hash(password: str) -> str: 12 | return pwd_context.hash(password) 13 | 14 | 15 | def generate_password() -> str: 16 | return pwd.genword() 17 | -------------------------------------------------------------------------------- /deploy/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | nginx 5 | python run.py -------------------------------------------------------------------------------- /deploy/sample-picture/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizhexiaoxiao/vue-fastapi-admin/d32a1df922b6bcb02b0347bfe0712668dedbc7cd/deploy/sample-picture/1.jpg -------------------------------------------------------------------------------- /deploy/sample-picture/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizhexiaoxiao/vue-fastapi-admin/d32a1df922b6bcb02b0347bfe0712668dedbc7cd/deploy/sample-picture/2.jpg -------------------------------------------------------------------------------- /deploy/sample-picture/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizhexiaoxiao/vue-fastapi-admin/d32a1df922b6bcb02b0347bfe0712668dedbc7cd/deploy/sample-picture/3.jpg -------------------------------------------------------------------------------- /deploy/sample-picture/api.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizhexiaoxiao/vue-fastapi-admin/d32a1df922b6bcb02b0347bfe0712668dedbc7cd/deploy/sample-picture/api.jpg -------------------------------------------------------------------------------- /deploy/sample-picture/group.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizhexiaoxiao/vue-fastapi-admin/d32a1df922b6bcb02b0347bfe0712668dedbc7cd/deploy/sample-picture/group.jpg -------------------------------------------------------------------------------- /deploy/sample-picture/login.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizhexiaoxiao/vue-fastapi-admin/d32a1df922b6bcb02b0347bfe0712668dedbc7cd/deploy/sample-picture/login.jpg -------------------------------------------------------------------------------- /deploy/sample-picture/menu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizhexiaoxiao/vue-fastapi-admin/d32a1df922b6bcb02b0347bfe0712668dedbc7cd/deploy/sample-picture/menu.jpg -------------------------------------------------------------------------------- /deploy/sample-picture/role.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizhexiaoxiao/vue-fastapi-admin/d32a1df922b6bcb02b0347bfe0712668dedbc7cd/deploy/sample-picture/role.jpg -------------------------------------------------------------------------------- /deploy/sample-picture/user.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizhexiaoxiao/vue-fastapi-admin/d32a1df922b6bcb02b0347bfe0712668dedbc7cd/deploy/sample-picture/user.jpg -------------------------------------------------------------------------------- /deploy/sample-picture/workbench.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizhexiaoxiao/vue-fastapi-admin/d32a1df922b6bcb02b0347bfe0712668dedbc7cd/deploy/sample-picture/workbench.jpg -------------------------------------------------------------------------------- /deploy/web.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | location / { 5 | root /opt/vue-fastapi-admin/web/dist; 6 | index index.html index.htm; 7 | try_files $uri /index.html; 8 | } 9 | location ^~ /api/ { 10 | proxy_pass http://127.0.0.1:9999; 11 | } 12 | 13 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "vue-fastapi-admin" 3 | version = "0.1.0" 4 | description = "Vue Fastapi admin" 5 | authors = [ 6 | {name = "mizhexiaoxiao", email = "mizhexiaoxiao@gmail.com"}, 7 | ] 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "fastapi==0.111.0", 11 | "tortoise-orm==0.23.0", 12 | "pydantic==2.10.5", 13 | "email-validator==2.2.0", 14 | "passlib==1.7.4", 15 | "pyjwt==2.10.1", 16 | "black==24.10.0", 17 | "isort==5.13.2", 18 | "ruff==0.9.1", 19 | "loguru==0.7.3", 20 | "pydantic-settings==2.7.1", 21 | "argon2-cffi==23.1.0", 22 | "pydantic-core==2.27.2", 23 | "annotated-types==0.7.0", 24 | "setuptools==75.8.0", 25 | "uvicorn==0.34.0", 26 | "h11==0.14.0", 27 | "aerich==0.8.1", 28 | "aiosqlite==0.20.0", 29 | "anyio==4.8.0", 30 | "argon2-cffi-bindings==21.2.0", 31 | "asyncclick==8.1.8", 32 | "certifi==2024.12.14", 33 | "cffi==1.17.1", 34 | "click==8.1.8", 35 | "dictdiffer==0.9.0", 36 | "dnspython==2.7.0", 37 | "fastapi-cli==0.0.7", 38 | "httpcore==1.0.7", 39 | "httptools==0.6.4", 40 | "httpx==0.28.1", 41 | "idna==3.10", 42 | "iso8601==2.1.0", 43 | "jinja2==3.1.5", 44 | "markdown-it-py==3.0.0", 45 | "markupsafe==3.0.2", 46 | "mdurl==0.1.2", 47 | "mypy-extensions==1.0.0", 48 | "orjson==3.10.14", 49 | "packaging==24.2", 50 | "pathspec==0.12.1", 51 | "platformdirs==4.3.6", 52 | "pycparser==2.22", 53 | "pygments==2.19.1", 54 | "pypika-tortoise==0.3.2", 55 | "python-dotenv==1.0.1", 56 | "python-multipart==0.0.20", 57 | "pytz==2024.2", 58 | "pyyaml==6.0.2", 59 | "rich==13.9.4", 60 | "rich-toolkit==0.13.2", 61 | "shellingham==1.5.4", 62 | "sniffio==1.3.1", 63 | "starlette==0.37.2", 64 | "typer==0.15.1", 65 | "typing-extensions==4.12.2", 66 | "ujson==5.10.0", 67 | "uvloop==0.21.0", 68 | "watchfiles==1.0.4", 69 | "websockets==14.1", 70 | "pyproject-toml>=0.1.0", 71 | "uvloop==0.21.0 ; sys_platform != 'win32'", 72 | ] 73 | 74 | [tool.black] 75 | line-length = 120 76 | target-version = ["py310", "py311"] 77 | 78 | [tool.ruff] 79 | line-length = 120 80 | lint.extend-select = [] 81 | lint.ignore = [ 82 | "F403", 83 | "F405", 84 | ] 85 | 86 | [tool.aerich] 87 | tortoise_orm = "app.settings.TORTOISE_ORM" 88 | location = "./migrations" 89 | src_folder = "./." 90 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aerich==0.8.1 2 | aiosqlite==0.20.0 3 | annotated-types==0.7.0 4 | anyio==4.8.0 5 | argon2-cffi==23.1.0 6 | argon2-cffi-bindings==21.2.0 7 | asyncclick==8.1.8 8 | black==24.10.0 9 | certifi==2024.12.14 10 | cffi==1.17.1 11 | click==8.1.8 12 | dictdiffer==0.9.0 13 | dnspython==2.7.0 14 | email-validator==2.2.0 15 | fastapi==0.111.0 16 | fastapi-cli==0.0.7 17 | h11==0.14.0 18 | httpcore==1.0.7 19 | httptools==0.6.4 20 | httpx==0.28.1 21 | idna==3.10 22 | iso8601==2.1.0 23 | isort==5.13.2 24 | jinja2==3.1.5 25 | loguru==0.7.3 26 | markdown-it-py==3.0.0 27 | markupsafe==3.0.2 28 | mdurl==0.1.2 29 | mypy-extensions==1.0.0 30 | orjson==3.10.14 31 | packaging==24.2 32 | passlib==1.7.4 33 | pathspec==0.12.1 34 | platformdirs==4.3.6 35 | pycparser==2.22 36 | pydantic==2.10.5 37 | pydantic-core==2.27.2 38 | pydantic-settings==2.7.1 39 | pygments==2.19.1 40 | pyjwt==2.10.1 41 | pypika-tortoise==0.3.2 42 | python-dotenv==1.0.1 43 | python-multipart==0.0.20 44 | pytz==2024.2 45 | pyyaml==6.0.2 46 | rich==13.9.4 47 | rich-toolkit==0.13.2 48 | ruff==0.9.1 49 | setuptools==75.8.0 50 | shellingham==1.5.4 51 | sniffio==1.3.1 52 | starlette==0.37.2 53 | tortoise-orm==0.23.0 54 | typer==0.15.1 55 | typing-extensions==4.12.2 56 | ujson==5.10.0 57 | uvicorn==0.34.0 58 | uvloop==0.21.0; sys_platform != 'win32' 59 | watchfiles==1.0.4 60 | websockets==14.1 61 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from uvicorn.config import LOGGING_CONFIG 3 | 4 | if __name__ == "__main__": 5 | # 修改默认日志配置 6 | LOGGING_CONFIG["formatters"]["default"]["fmt"] = "%(asctime)s - %(levelname)s - %(message)s" 7 | LOGGING_CONFIG["formatters"]["default"]["datefmt"] = "%Y-%m-%d %H:%M:%S" 8 | LOGGING_CONFIG["formatters"]["access"][ 9 | "fmt" 10 | ] = '%(asctime)s - %(levelname)s - %(client_addr)s - "%(request_line)s" %(status_code)s' 11 | LOGGING_CONFIG["formatters"]["access"]["datefmt"] = "%Y-%m-%d %H:%M:%S" 12 | 13 | uvicorn.run("app:app", host="0.0.0.0", port=9999, reload=True, log_config=LOGGING_CONFIG) 14 | -------------------------------------------------------------------------------- /web/.env: -------------------------------------------------------------------------------- 1 | VITE_TITLE = 'Vue FastAPI Admin' 2 | 3 | VITE_PORT = 3100 -------------------------------------------------------------------------------- /web/.env.development: -------------------------------------------------------------------------------- 1 | # 资源公共路径,需要以 /开头和结尾 2 | VITE_PUBLIC_PATH = '/' 3 | 4 | # 是否启用代理 5 | VITE_USE_PROXY = true 6 | 7 | # base api 8 | VITE_BASE_API = '/api/v1' 9 | -------------------------------------------------------------------------------- /web/.env.production: -------------------------------------------------------------------------------- 1 | # 资源公共路径,需要以 /开头和结尾 2 | VITE_PUBLIC_PATH = '/' 3 | 4 | # base api 5 | VITE_BASE_API = '/api/v1' 6 | 7 | # 是否启用压缩 8 | VITE_USE_COMPRESS = true 9 | 10 | # 压缩类型 11 | VITE_COMPRESS_TYPE = gzip -------------------------------------------------------------------------------- /web/.eslint-global-variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "$loadingBar": true, 4 | "$message": true, 5 | "defineOptions": true, 6 | "$dialog": true, 7 | "$notification": true, 8 | "EffectScope": true, 9 | "computed": true, 10 | "createApp": true, 11 | "customRef": true, 12 | "defineAsyncComponent": true, 13 | "defineComponent": true, 14 | "effectScope": true, 15 | "getCurrentInstance": true, 16 | "getCurrentScope": true, 17 | "h": true, 18 | "inject": true, 19 | "isProxy": true, 20 | "isReactive": true, 21 | "isReadonly": true, 22 | "isRef": true, 23 | "markRaw": true, 24 | "nextTick": true, 25 | "onActivated": true, 26 | "onBeforeMount": true, 27 | "onBeforeUnmount": true, 28 | "onBeforeUpdate": true, 29 | "onDeactivated": true, 30 | "onErrorCaptured": true, 31 | "onMounted": true, 32 | "onRenderTracked": true, 33 | "onRenderTriggered": true, 34 | "onScopeDispose": true, 35 | "onServerPrefetch": true, 36 | "onUnmounted": true, 37 | "onUpdated": true, 38 | "provide": true, 39 | "reactive": true, 40 | "readonly": true, 41 | "ref": true, 42 | "resolveComponent": true, 43 | "shallowReactive": true, 44 | "shallowReadonly": true, 45 | "shallowRef": true, 46 | "toRaw": true, 47 | "toRef": true, 48 | "toRefs": true, 49 | "triggerRef": true, 50 | "unref": true, 51 | "useAttrs": true, 52 | "useCssModule": true, 53 | "useCssVars": true, 54 | "useRoute": true, 55 | "useRouter": true, 56 | "useSlots": true, 57 | "watch": true, 58 | "watchEffect": true, 59 | "watchPostEffect": true, 60 | "watchSyncEffect": true 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /web/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | public 4 | package.json -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | stats.html 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules/** 2 | /dist/* 3 | /public/* -------------------------------------------------------------------------------- /web/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "semi": false, 5 | "endOfLine": "lf" 6 | } 7 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | ## 快速开始 2 | 3 | 进入前端目录 4 | 5 | ```sh 6 | cd web 7 | ``` 8 | 9 | 安装依赖(建议使用pnpm: https://pnpm.io/zh/installation) 10 | 11 | ```sh 12 | npm i -g pnpm # 已安装可忽略 13 | pnpm i # 或者 npm i 14 | ``` 15 | 16 | 启动 17 | 18 | ```sh 19 | pnpm dev 20 | ``` 21 | -------------------------------------------------------------------------------- /web/build/config/define.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | /** 4 | * * 此处定义的是全局常量,启动或打包后将添加到window中 5 | * https://vitejs.cn/config/#define 6 | */ 7 | 8 | // 项目构建时间 9 | const _BUILD_TIME_ = JSON.stringify(dayjs().format('YYYY-MM-DD HH:mm:ss')) 10 | 11 | export const viteDefine = { 12 | _BUILD_TIME_, 13 | } 14 | -------------------------------------------------------------------------------- /web/build/config/index.js: -------------------------------------------------------------------------------- 1 | export * from './define' 2 | -------------------------------------------------------------------------------- /web/build/constant.js: -------------------------------------------------------------------------------- 1 | export const OUTPUT_DIR = 'dist' 2 | 3 | export const PROXY_CONFIG = { 4 | // /** 5 | // * @desc 替换匹配值 6 | // * @请求路径 http://localhost:3100/api/user 7 | // * @转发路径 http://localhost:9999/api/v1 +/user 8 | // */ 9 | // '/api': { 10 | // target: 'http://localhost:9999/api/v1', 11 | // changeOrigin: true, 12 | // rewrite: (path) => path.replace(new RegExp('^/api'), ''), 13 | // }, 14 | /** 15 | * @desc 不替换匹配值 16 | * @请求路径 http://localhost:3100/api/v1/user 17 | * @转发路径 http://localhost:9999/api/v1/user 18 | */ 19 | '/api/v1': { 20 | target: 'http://127.0.0.1:9999', 21 | changeOrigin: true, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /web/build/plugin/html.js: -------------------------------------------------------------------------------- 1 | import { createHtmlPlugin } from 'vite-plugin-html' 2 | 3 | export function configHtmlPlugin(viteEnv, isBuild) { 4 | const { VITE_TITLE } = viteEnv 5 | 6 | const htmlPlugin = createHtmlPlugin({ 7 | minify: isBuild, 8 | inject: { 9 | data: { 10 | title: VITE_TITLE, 11 | }, 12 | }, 13 | }) 14 | return htmlPlugin 15 | } 16 | -------------------------------------------------------------------------------- /web/build/plugin/index.js: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue' 2 | 3 | /** 4 | * * unocss插件,原子css 5 | * https://github.com/antfu/unocss 6 | */ 7 | import Unocss from 'unocss/vite' 8 | 9 | // rollup打包分析插件 10 | import visualizer from 'rollup-plugin-visualizer' 11 | // 压缩 12 | import viteCompression from 'vite-plugin-compression' 13 | 14 | import { configHtmlPlugin } from './html' 15 | import unplugin from './unplugin' 16 | 17 | export function createVitePlugins(viteEnv, isBuild) { 18 | const plugins = [vue(), ...unplugin, configHtmlPlugin(viteEnv, isBuild), Unocss()] 19 | 20 | if (viteEnv.VITE_USE_COMPRESS) { 21 | plugins.push(viteCompression({ algorithm: viteEnv.VITE_COMPRESS_TYPE || 'gzip' })) 22 | } 23 | 24 | if (isBuild) { 25 | plugins.push( 26 | visualizer({ 27 | open: true, 28 | gzipSize: true, 29 | brotliSize: true, 30 | }), 31 | ) 32 | } 33 | 34 | return plugins 35 | } 36 | -------------------------------------------------------------------------------- /web/build/plugin/unplugin.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import AutoImport from 'unplugin-auto-import/vite' 3 | import Components from 'unplugin-vue-components/vite' 4 | import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' 5 | import { FileSystemIconLoader } from 'unplugin-icons/loaders' 6 | import IconsResolver from 'unplugin-icons/resolver' 7 | 8 | /** 9 | * * unplugin-icons插件,自动引入iconify图标 10 | * usage: https://github.com/antfu/unplugin-icons 11 | * 图标库: https://icones.js.org/ 12 | */ 13 | import Icons from 'unplugin-icons/vite' 14 | import { createSvgIconsPlugin } from 'vite-plugin-svg-icons' 15 | 16 | import { getSrcPath } from '../utils' 17 | 18 | const customIconPath = resolve(getSrcPath(), 'assets/svg') 19 | 20 | export default [ 21 | AutoImport({ 22 | imports: ['vue', 'vue-router'], 23 | dts: false, 24 | }), 25 | Icons({ 26 | compiler: 'vue3', 27 | customCollections: { 28 | custom: FileSystemIconLoader(customIconPath), 29 | }, 30 | scale: 1, 31 | defaultClass: 'inline-block', 32 | }), 33 | Components({ 34 | resolvers: [ 35 | NaiveUiResolver(), 36 | IconsResolver({ customCollections: ['custom'], componentPrefix: 'icon' }), 37 | ], 38 | dts: false, 39 | }), 40 | createSvgIconsPlugin({ 41 | iconDirs: [customIconPath], 42 | symbolId: 'icon-custom-[dir]-[name]', 43 | inject: 'body-last', 44 | customDomId: '__CUSTOM_SVG_ICON__', 45 | }), 46 | ] 47 | -------------------------------------------------------------------------------- /web/build/script/build-cname.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import chalk from 'chalk' 3 | import { writeFileSync } from 'fs-extra' 4 | import { OUTPUT_DIR } from '../constant' 5 | import { getEnvConfig, getRootPath } from '../utils' 6 | 7 | export function runBuildCNAME() { 8 | const { VITE_CNAME } = getEnvConfig() 9 | if (!VITE_CNAME) return 10 | try { 11 | writeFileSync(resolve(getRootPath(), `${OUTPUT_DIR}/CNAME`), VITE_CNAME) 12 | } catch (error) { 13 | console.log(chalk.red('CNAME file failed to package:\n' + error)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /web/build/script/index.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { runBuildCNAME } from './build-cname' 3 | 4 | export const runBuild = async () => { 5 | try { 6 | runBuildCNAME() 7 | console.log(`✨ ${chalk.cyan('build successfully!')}`) 8 | } catch (error) { 9 | console.log(chalk.red('vite build error:\n' + error)) 10 | process.exit(1) 11 | } 12 | } 13 | 14 | runBuild() 15 | -------------------------------------------------------------------------------- /web/build/utils.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import dotenv from 'dotenv' 4 | 5 | /** 6 | * * 项目根路径 7 | * @descrition 结尾不带/ 8 | */ 9 | export function getRootPath() { 10 | return path.resolve(process.cwd()) 11 | } 12 | 13 | /** 14 | * * 项目src路径 15 | * @param srcName src目录名称(默认: "src") 16 | * @descrition 结尾不带斜杠 17 | */ 18 | export function getSrcPath(srcName = 'src') { 19 | return path.resolve(getRootPath(), srcName) 20 | } 21 | 22 | export function convertEnv(envOptions) { 23 | const result = {} 24 | if (!envOptions) return result 25 | 26 | for (const envKey in envOptions) { 27 | let envVal = envOptions[envKey] 28 | if (['true', 'false'].includes(envVal)) envVal = envVal === 'true' 29 | 30 | if (['VITE_PORT'].includes(envKey)) envVal = +envVal 31 | 32 | result[envKey] = envVal 33 | } 34 | return result 35 | } 36 | 37 | /** 38 | * 获取当前环境下生效的配置文件名 39 | */ 40 | function getConfFiles() { 41 | const script = process.env.npm_lifecycle_script 42 | const reg = new RegExp('--mode ([a-z_\\d]+)') 43 | const result = reg.exec(script) 44 | if (result) { 45 | const mode = result[1] 46 | return ['.env', '.env.local', `.env.${mode}`] 47 | } 48 | return ['.env', '.env.local', '.env.production'] 49 | } 50 | 51 | export function getEnvConfig(match = 'VITE_', confFiles = getConfFiles()) { 52 | let envConfig = {} 53 | confFiles.forEach((item) => { 54 | try { 55 | if (fs.existsSync(path.resolve(process.cwd(), item))) { 56 | const env = dotenv.parse(fs.readFileSync(path.resolve(process.cwd(), item))) 57 | envConfig = { ...envConfig, ...env } 58 | } 59 | } catch (e) { 60 | console.error(`Error in parsing ${item}`, e) 61 | } 62 | }) 63 | const reg = new RegExp(`^(${match})`) 64 | Object.keys(envConfig).forEach((key) => { 65 | if (!reg.test(key)) { 66 | Reflect.deleteProperty(envConfig, key) 67 | } 68 | }) 69 | return envConfig 70 | } 71 | -------------------------------------------------------------------------------- /web/i18n/index.js: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n' 2 | import { lStorage } from '@/utils' 3 | 4 | import messages from './messages' 5 | 6 | const currentLocale = lStorage.get('locale') 7 | 8 | const i18n = createI18n({ 9 | legacy: false, 10 | globalInjection: true, 11 | locale: currentLocale || 'cn', 12 | fallbackLocale: 'cn', 13 | messages: messages, 14 | }) 15 | 16 | export default i18n 17 | -------------------------------------------------------------------------------- /web/i18n/messages/cn.json: -------------------------------------------------------------------------------- 1 | { 2 | "lang": "中文", 3 | "app_name": "Vue FastAPI Admin", 4 | "header": { 5 | "label_profile": "个人信息", 6 | "label_logout": "退出登录", 7 | "label_logout_dialog_title": "提示", 8 | "text_logout_confirm": "确认退出?", 9 | "text_logout_success": "已退出登录" 10 | }, 11 | "views": { 12 | "login": { 13 | "text_login": "登录", 14 | "message_input_username_password": "请输入用户名和密码", 15 | "message_verifying": "正在验证...", 16 | "message_login_success": "登录成功" 17 | }, 18 | "workbench": { 19 | "label_workbench": "工作台", 20 | "text_hello": "hello, {username}", 21 | "text_welcome": "今天又是元气满满的一天!", 22 | "label_number_of_items": "项目数", 23 | "label_upcoming": "待办", 24 | "label_information": "消息", 25 | "label_project": "项目", 26 | "label_more": "更多" 27 | }, 28 | "profile": { 29 | "label_profile": "个人中心", 30 | "label_modify_information": "修改信息", 31 | "label_change_password": "修改密码", 32 | "label_avatar": "头像", 33 | "label_username": "用户姓名", 34 | "label_email": "邮箱", 35 | "label_old_password": "旧密码", 36 | "label_new_password": "新密码", 37 | "label_confirm_password": "确认密码", 38 | "placeholder_username": "请填写姓名", 39 | "placeholder_email": "请填写邮箱", 40 | "placeholder_old_password": "请输入旧密码", 41 | "placeholder_new_password": "请输入新密码", 42 | "placeholder_confirm_password": "请再次输入新密码", 43 | "message_username_required": "请输入昵称", 44 | "message_old_password_required": "请输入旧密码", 45 | "message_new_password_required": "请输入新密码", 46 | "message_password_confirmation_required": "请再次输入密码", 47 | "message_password_confirmation_diff": "两次密码输入不一致" 48 | }, 49 | "errors": { 50 | "label_error": "错误页", 51 | "text_back_to_home": "返回首页" 52 | } 53 | }, 54 | "common": { 55 | "text": { 56 | "update_success": "修改成功" 57 | }, 58 | "buttons": { 59 | "update": "修改" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /web/i18n/messages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "lang": "English", 3 | "app_name": "Vue FastAPI Admin", 4 | "header": { 5 | "label_profile": "Profile", 6 | "label_logout": "Logout", 7 | "label_logout_dialog_title": "Hint", 8 | "text_logout_confirm": "Logout confirm", 9 | "text_logout_success": "Logout success" 10 | }, 11 | "views": { 12 | "login": { 13 | "text_login": "Login", 14 | "message_input_username_password": "Please enter username and password", 15 | "message_verifying": "Verifying...", 16 | "message_login_success": "Login successful" 17 | }, 18 | "workbench": { 19 | "label_workbench": "Workbench", 20 | "text_hello": "hello, {username}", 21 | "text_welcome": "Today is another day full of energy!", 22 | "label_number_of_items": "Number of items", 23 | "label_upcoming": "Upcoming", 24 | "label_information": "Information", 25 | "label_project": "Project", 26 | "label_more": "More" 27 | }, 28 | "profile": { 29 | "label_profile": "Profile", 30 | "label_modify_information": "Modify your information", 31 | "label_change_password": "Change password", 32 | "label_avatar": "Avatar", 33 | "label_username": "Username", 34 | "label_email": "Email", 35 | "label_old_password": "Old password", 36 | "label_new_password": "New password", 37 | "label_confirm_password": "Password confirmation", 38 | "placeholder_username": "Please fill in your name", 39 | "placeholder_email": "Please fill in your email address", 40 | "placeholder_old_password": "Please enter the old password", 41 | "placeholder_new_password": "Please enter a new password", 42 | "placeholder_confirm_password": "Please enter the confirm password", 43 | "message_username_required": "Please enter username", 44 | "message_old_password_required": "Please enter the old password", 45 | "message_new_password_required": "Please enter a new password", 46 | "message_password_confirmation_required": "Please enter confirm password", 47 | "message_password_confirmation_diff": "Two password inputs are inconsistent" 48 | }, 49 | "errors": { 50 | "label_error": "Error", 51 | "text_back_to_home": "Back to home" 52 | } 53 | }, 54 | "common": { 55 | "text": { 56 | "update_success": "Update success" 57 | }, 58 | "buttons": { 59 | "update": "Update" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /web/i18n/messages/index.js: -------------------------------------------------------------------------------- 1 | import * as en from './en.json' 2 | import * as cn from './cn.json' 3 | 4 | export default { 5 | en, 6 | cn, 7 | } 8 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <%= title %> 14 | 15 | 16 | 17 |
18 | 19 |
20 | 21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
<%= title %>
30 |
31 | 32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /web/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "baseUrl": "./", 5 | "moduleResolution": "node", 6 | "paths": { 7 | "~/*": ["./*"], 8 | "@/*": ["src/*"] 9 | }, 10 | "jsx": "preserve", 11 | "allowJs": true 12 | }, 13 | "exclude": ["node_modules", "dist"] 14 | } 15 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-fastapi-admin-web", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "lint": "eslint --ext .js,.vue .", 10 | "lint:fix": "eslint --fix --ext .js,.vue .", 11 | "lint:staged": "lint-staged", 12 | "prettier": "npx prettier --write ." 13 | }, 14 | "dependencies": { 15 | "@iconify/json": "^2.2.228", 16 | "@iconify/vue": "^4.1.1", 17 | "@unocss/eslint-config": "^0.55.0", 18 | "@vueuse/core": "^10.3.0", 19 | "@zclzone/eslint-config": "^0.0.4", 20 | "axios": "^1.4.0", 21 | "dayjs": "^1.11.9", 22 | "dotenv": "^16.3.1", 23 | "eslint": "^8.46.0", 24 | "lodash-es": "^4.17.21", 25 | "naive-ui": "^2.34.4", 26 | "pinia": "^2.1.6", 27 | "rollup-plugin-visualizer": "^5.9.2", 28 | "sass": "^1.65.1", 29 | "typescript": "^5.1.6", 30 | "unocss": "^0.55.0", 31 | "unplugin-auto-import": "^0.16.6", 32 | "unplugin-icons": "^0.16.5", 33 | "unplugin-vue-components": "^0.25.1", 34 | "vite-plugin-compression": "^0.5.1", 35 | "vite-plugin-html": "^3.2.0", 36 | "vite-plugin-svg-icons": "^2.0.1", 37 | "vue": "^3.3.4", 38 | "vue-i18n": "9", 39 | "vue-router": "^4.2.4" 40 | }, 41 | "devDependencies": { 42 | "@vitejs/plugin-vue": "^4.2.3", 43 | "vite": "^4.4.6" 44 | }, 45 | "lint-staged": { 46 | "*.{js,vue}": [ 47 | "eslint --ext .js,.vue ." 48 | ] 49 | }, 50 | "eslintConfig": { 51 | "extends": [ 52 | "@zclzone", 53 | "@unocss", 54 | ".eslint-global-variables.json" 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /web/public/resource/loading.css: -------------------------------------------------------------------------------- 1 | .loading-container { 2 | position: fixed; 3 | left: 0; 4 | top: 0; 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: center; 8 | align-items: center; 9 | width: 100%; 10 | height: 100%; 11 | } 12 | 13 | .loading-svg { 14 | width: 128px; 15 | height: 128px; 16 | color: var(--primary-color); 17 | } 18 | 19 | .loading-spin__container { 20 | width: 56px; 21 | height: 56px; 22 | margin: 36px 0; 23 | } 24 | 25 | .loading-spin { 26 | position: relative; 27 | height: 100%; 28 | animation: loadingSpin 1s linear infinite; 29 | } 30 | 31 | .left-0 { 32 | left: 0; 33 | } 34 | .right-0 { 35 | right: 0; 36 | } 37 | .top-0 { 38 | top: 0; 39 | } 40 | .bottom-0 { 41 | bottom: 0; 42 | } 43 | 44 | .loading-spin-item { 45 | position: absolute; 46 | height: 16px; 47 | width: 16px; 48 | background-color: var(--primary-color); 49 | border-radius: 8px; 50 | -webkit-animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 51 | animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 52 | } 53 | 54 | @keyframes loadingSpin { 55 | from { 56 | -webkit-transform: rotate(0deg); 57 | transform: rotate(0deg); 58 | } 59 | to { 60 | -webkit-transform: rotate(360deg); 61 | transform: rotate(360deg); 62 | } 63 | } 64 | 65 | @keyframes loadingPulse { 66 | 0%, 100% { 67 | opacity: 1; 68 | } 69 | 50% { 70 | opacity: .5; 71 | } 72 | } 73 | 74 | .loading-delay-500 { 75 | -webkit-animation-delay: 500ms; 76 | animation-delay: 500ms; 77 | } 78 | .loading-delay-1000 { 79 | -webkit-animation-delay: 1000ms; 80 | animation-delay: 1000ms; 81 | } 82 | .loading-delay-1500 { 83 | -webkit-animation-delay: 1500ms; 84 | animation-delay: 1500ms; 85 | } 86 | 87 | .loading-title { 88 | font-size: 28px; 89 | font-weight: 500; 90 | color: #6a6a6a; 91 | } 92 | -------------------------------------------------------------------------------- /web/settings/index.js: -------------------------------------------------------------------------------- 1 | export * from './theme.json' 2 | -------------------------------------------------------------------------------- /web/settings/theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "header": { 3 | "height": 60 4 | }, 5 | "tags": { 6 | "visible": true, 7 | "height": 50 8 | }, 9 | "naiveThemeOverrides": { 10 | "common": { 11 | "primaryColor": "#F4511E", 12 | "primaryColorHover": "#F4511E", 13 | "primaryColorPressed": "#2B4C59FF", 14 | "primaryColorSuppl": "#F4511E", 15 | 16 | "infoColor": "#2080F0FF", 17 | "infoColorHover": "#4098FCFF", 18 | "infoColorPressed": "#1060C9FF", 19 | "infoColorSuppl": "#4098FCFF", 20 | 21 | "successColor": "#18A058FF", 22 | "successColorHover": "#F4511E", 23 | "successColorPressed": "#0C7A43FF", 24 | "successColorSuppl": "#F4511E", 25 | 26 | "warningColor": "#F0A020FF", 27 | "warningColorHover": "#FCB040FF", 28 | "warningColorPressed": "#C97C10FF", 29 | "warningColorSuppl": "#FCB040FF", 30 | 31 | "errorColor": "#D03050FF", 32 | "errorColorHover": "#DE576DFF", 33 | "errorColorPressed": "#AB1F3FFF", 34 | "errorColorSuppl": "#DE576DFF" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /web/src/api/index.js: -------------------------------------------------------------------------------- 1 | import { request } from '@/utils' 2 | 3 | export default { 4 | login: (data) => request.post('/base/access_token', data, { noNeedToken: true }), 5 | getUserInfo: () => request.get('/base/userinfo'), 6 | getUserMenu: () => request.get('/base/usermenu'), 7 | getUserApi: () => request.get('/base/userapi'), 8 | // profile 9 | updatePassword: (data = {}) => request.post('/base/update_password', data), 10 | // users 11 | getUserList: (params = {}) => request.get('/user/list', { params }), 12 | getUserById: (params = {}) => request.get('/user/get', { params }), 13 | createUser: (data = {}) => request.post('/user/create', data), 14 | updateUser: (data = {}) => request.post('/user/update', data), 15 | deleteUser: (params = {}) => request.delete(`/user/delete`, { params }), 16 | resetPassword: (data = {}) => request.post(`/user/reset_password`, data), 17 | // role 18 | getRoleList: (params = {}) => request.get('/role/list', { params }), 19 | createRole: (data = {}) => request.post('/role/create', data), 20 | updateRole: (data = {}) => request.post('/role/update', data), 21 | deleteRole: (params = {}) => request.delete('/role/delete', { params }), 22 | updateRoleAuthorized: (data = {}) => request.post('/role/authorized', data), 23 | getRoleAuthorized: (params = {}) => request.get('/role/authorized', { params }), 24 | // menus 25 | getMenus: (params = {}) => request.get('/menu/list', { params }), 26 | createMenu: (data = {}) => request.post('/menu/create', data), 27 | updateMenu: (data = {}) => request.post('/menu/update', data), 28 | deleteMenu: (params = {}) => request.delete('/menu/delete', { params }), 29 | // apis 30 | getApis: (params = {}) => request.get('/api/list', { params }), 31 | createApi: (data = {}) => request.post('/api/create', data), 32 | updateApi: (data = {}) => request.post('/api/update', data), 33 | deleteApi: (params = {}) => request.delete('/api/delete', { params }), 34 | refreshApi: (data = {}) => request.post('/api/refresh', data), 35 | // depts 36 | getDepts: (params = {}) => request.get('/dept/list', { params }), 37 | createDept: (data = {}) => request.post('/dept/create', data), 38 | updateDept: (data = {}) => request.post('/dept/update', data), 39 | deleteDept: (params = {}) => request.delete('/dept/delete', { params }), 40 | // auditlog 41 | getAuditLogList: (params = {}) => request.get('/auditlog/list', { params }), 42 | } 43 | -------------------------------------------------------------------------------- /web/src/assets/images/login_bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizhexiaoxiao/vue-fastapi-admin/d32a1df922b6bcb02b0347bfe0712668dedbc7cd/web/src/assets/images/login_bg.webp -------------------------------------------------------------------------------- /web/src/assets/js/icons.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'mdi-air-humidifier-off', 3 | 'mdi-chili-off', 4 | 'mdi-cigar-off', 5 | 'mdi-clock-time-eight', 6 | 'mdi-clock-time-eight-outline', 7 | 'mdi-clock-time-eleven', 8 | 'mdi-clock-time-eleven-outline', 9 | 'mdi-clock-time-five', 10 | 'mdi-clock-time-five-outline', 11 | 'mdi-clock-time-four', 12 | 'mdi-clock-time-four-outline', 13 | 'mdi-clock-time-nine', 14 | 'mdi-clock-time-nine-outline', 15 | 'mdi-clock-time-one', 16 | 'mdi-clock-time-one-outline', 17 | 'mdi-clock-time-seven', 18 | 'mdi-clock-time-seven-outline', 19 | 'mdi-clock-time-six', 20 | 'mdi-clock-time-six-outline', 21 | 'mdi-clock-time-ten', 22 | 'mdi-clock-time-ten-outline', 23 | 'mdi-clock-time-three', 24 | 'mdi-clock-time-three-outline', 25 | 'mdi-clock-time-twelve', 26 | 'mdi-clock-time-twelve-outline', 27 | 'mdi-clock-time-two', 28 | 'mdi-clock-time-two-outline', 29 | 'mdi-cog-refresh', 30 | 'mdi-cog-refresh-outline', 31 | 'mdi-cog-sync', 32 | 'mdi-cog-sync-outline', 33 | 'mdi-content-save-cog', 34 | 'mdi-content-save-cog-outline', 35 | 'mdi-cosine-wave', 36 | 'mdi-cube-off', 37 | 'mdi-cube-off-outline', 38 | 'mdi-dome-light', 39 | 'mdi-download-box', 40 | 'mdi-download-box-outline', 41 | 'mdi-download-circle', 42 | 'mdi-download-circle-outline', 43 | 'mdi-fan-alert', 44 | 'mdi-fan-chevron-down', 45 | 'mdi-fan-chevron-up', 46 | 'mdi-fan-minus', 47 | 'mdi-fan-plus', 48 | 'mdi-fan-remove', 49 | 'mdi-fan-speed-1', 50 | 'mdi-fan-speed-2', 51 | 'mdi-fan-speed-3', 52 | 'mdi-food-drumstick', 53 | 'mdi-food-drumstick-off', 54 | 'mdi-food-drumstick-off-outline', 55 | 'mdi-food-drumstick-outline', 56 | 'mdi-food-steak', 57 | 'mdi-food-steak-off', 58 | 'mdi-fuse-alert', 59 | 'mdi-fuse-off', 60 | 'mdi-heart-minus', 61 | 'mdi-heart-minus-outline', 62 | 'mdi-heart-off-outline', 63 | 'mdi-heart-plus', 64 | 'mdi-heart-plus-outline', 65 | 'mdi-heart-remove', 66 | 'mdi-heart-remove-outline', 67 | 'mdi-hours-24', 68 | 'mdi-incognito-circle', 69 | 'mdi-incognito-circle-off', 70 | 'mdi-lingerie', 71 | 'mdi-microwave-off', 72 | 'mdi-minus-circle-off', 73 | 'mdi-minus-circle-off-outline', 74 | 'mdi-motion-sensor-off', 75 | 'mdi-pail-minus', 76 | 'mdi-pail-minus-outline', 77 | 'mdi-pail-off', 78 | 'mdi-pail-off-outline', 79 | 'mdi-pail-outline', 80 | 'mdi-pail-plus', 81 | 'mdi-pail-plus-outline', 82 | 'mdi-pail-remove', 83 | 'mdi-pail-remove-outline', 84 | 'mdi-pine-tree-fire', 85 | 'mdi-power-plug-off-outline', 86 | 'mdi-power-plug-outline', 87 | 'mdi-printer-eye', 88 | 'mdi-printer-search', 89 | 'mdi-puzzle-check', 90 | 'mdi-puzzle-check-outline', 91 | 'mdi-rug', 92 | 'mdi-sawtooth-wave', 93 | 'mdi-set-square', 94 | 'mdi-smoking-pipe-off', 95 | 'mdi-spoon-sugar', 96 | 'mdi-square-wave', 97 | 'mdi-table-split-cell', 98 | 'mdi-ticket-percent-outline', 99 | 'mdi-triangle-wave', 100 | 'mdi-waveform', 101 | 'mdi-wizard-hat', 102 | 'mdi-ab-testing', 103 | 'mdi-abjad-arabic', 104 | 'mdi-abjad-hebrew', 105 | 'mdi-abugida-devanagari', 106 | 'mdi-abugida-thai', 107 | 'mdi-access-point', 108 | 'mdi-access-point-network', 109 | 'mdi-access-point-network-off', 110 | 'mdi-account', 111 | 'mdi-account-alert', 112 | 'mdi-account-alert-outline', 113 | 'mdi-account-arrow-left', 114 | 'mdi-account-arrow-left-outline', 115 | 'mdi-account-arrow-right', 116 | 'mdi-account-arrow-right-outline', 117 | 'mdi-account-box', 118 | 'mdi-account-box-multiple', 119 | 'mdi-account-box-multiple-outline', 120 | 'mdi-account-box-outline', 121 | 'mdi-account-cancel', 122 | 'mdi-account-cancel-outline', 123 | 'mdi-account-cash', 124 | 'mdi-account-cash-outline', 125 | 'mdi-account-check', 126 | 'mdi-account-check-outline', 127 | 'mdi-account-child', 128 | 'mdi-account-child-circle', 129 | 'mdi-account-child-outline', 130 | 'mdi-account-circle', 131 | 'mdi-account-circle-outline', 132 | 'mdi-account-clock', 133 | 'mdi-account-clock-outline', 134 | 'mdi-account-cog', 135 | 'mdi-account-cog-outline', 136 | 'mdi-account-convert', 137 | 'mdi-account-convert-outline', 138 | 'mdi-account-cowboy-hat', 139 | 'mdi-account-details', 140 | 'mdi-account-details-outline', 141 | 'mdi-account-edit', 142 | 'mdi-account-edit-outline', 143 | 'mdi-account-group', 144 | 'mdi-account-group-outline', 145 | 'mdi-account-hard-hat', 146 | 'mdi-account-heart', 147 | 'mdi-account-heart-outline', 148 | 'mdi-account-key', 149 | 'mdi-account-key-outline', 150 | 'mdi-account-lock', 151 | 'mdi-account-lock-outline', 152 | 'mdi-account-minus', 153 | 'mdi-account-minus-outline', 154 | 'mdi-account-multiple', 155 | 'mdi-account-multiple-check', 156 | 'mdi-account-multiple-check-outline', 157 | 'mdi-account-multiple-minus', 158 | 'mdi-account-multiple-minus-outline', 159 | 'mdi-account-multiple-outline', 160 | 'mdi-account-multiple-plus', 161 | 'mdi-account-multiple-plus-outline', 162 | 'mdi-account-multiple-remove', 163 | 'mdi-account-multiple-remove-outline', 164 | 'mdi-account-music', 165 | 'mdi-account-music-outline', 166 | 'mdi-account-network', 167 | 'mdi-account-network-outline', 168 | 'mdi-account-off', 169 | 'mdi-account-off-outline', 170 | 'mdi-account-outline', 171 | 'mdi-account-plus', 172 | 'mdi-account-plus-outline', 173 | 'mdi-account-question', 174 | 'mdi-account-question-outline', 175 | 'mdi-account-remove', 176 | 'mdi-account-remove-outline', 177 | 'mdi-account-search', 178 | 'mdi-account-search-outline', 179 | 'mdi-account-settings', 180 | 'mdi-account-settings-outline', 181 | 'mdi-account-star', 182 | 'mdi-account-star-outline', 183 | 'mdi-account-supervisor', 184 | 'mdi-account-supervisor-circle', 185 | 'mdi-account-supervisor-outline', 186 | 'mdi-account-switch', 187 | 'mdi-account-switch-outline', 188 | 'mdi-account-tie', 189 | 'mdi-account-tie-outline', 190 | 'mdi-account-tie-voice', 191 | 'mdi-account-tie-voice-off', 192 | 'mdi-account-tie-voice-off-outline', 193 | 'mdi-account-tie-voice-outline', 194 | 'mdi-account-voice', 195 | 'mdi-adjust', 196 | 'mdi-adobe', 197 | 'mdi-adobe-acrobat', 198 | 'mdi-air-conditioner', 199 | 'mdi-air-filter', 200 | 'mdi-air-horn', 201 | 'mdi-air-humidifier', 202 | 'mdi-air-purifier', 203 | 'mdi-airbag', 204 | 'mdi-airballoon', 205 | 'mdi-airballoon-outline', 206 | 'mdi-airplane', 207 | 'mdi-airplane-landing', 208 | 'mdi-airplane-off', 209 | 'mdi-airplane-takeoff', 210 | 'mdi-airport', 211 | 'mdi-alarm', 212 | 'mdi-alarm-bell', 213 | 'mdi-alarm-check', 214 | 'mdi-alarm-light', 215 | 'mdi-alarm-light-outline', 216 | 'mdi-alarm-multiple', 217 | 'mdi-alarm-note', 218 | 'mdi-alarm-note-off', 219 | 'mdi-alarm-off', 220 | 'mdi-alarm-plus', 221 | 'mdi-alarm-snooze', 222 | 'mdi-album', 223 | 'mdi-alert', 224 | 'mdi-alert-box', 225 | 'mdi-alert-box-outline', 226 | 'mdi-alert-circle', 227 | 'mdi-alert-circle-check', 228 | 'mdi-alert-circle-check-outline', 229 | 'mdi-alert-circle-outline', 230 | ] 231 | -------------------------------------------------------------------------------- /web/src/components/common/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /web/src/components/common/AppProvider.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 68 | -------------------------------------------------------------------------------- /web/src/components/common/LoadingEmptyWrapper.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /web/src/components/common/ScrollX.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 112 | 113 | 161 | -------------------------------------------------------------------------------- /web/src/components/icon/CustomIcon.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /web/src/components/icon/IconPicker.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 71 | -------------------------------------------------------------------------------- /web/src/components/icon/SvgIcon.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 25 | -------------------------------------------------------------------------------- /web/src/components/icon/TheIcon.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /web/src/components/page/AppPage.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /web/src/components/page/CommonPage.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | -------------------------------------------------------------------------------- /web/src/components/query-bar/QueryBar.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 27 | -------------------------------------------------------------------------------- /web/src/components/query-bar/QueryBarItem.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 35 | -------------------------------------------------------------------------------- /web/src/components/table/CrudModal.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 57 | -------------------------------------------------------------------------------- /web/src/components/table/CrudTable.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 152 | -------------------------------------------------------------------------------- /web/src/composables/index.js: -------------------------------------------------------------------------------- 1 | export { default as useCRUD } from './useCRUD' 2 | -------------------------------------------------------------------------------- /web/src/composables/useCRUD.js: -------------------------------------------------------------------------------- 1 | import { isNullOrWhitespace } from '@/utils' 2 | 3 | const ACTIONS = { 4 | view: '查看', 5 | edit: '编辑', 6 | add: '新增', 7 | } 8 | 9 | export default function ({ name, initForm = {}, doCreate, doDelete, doUpdate, refresh }) { 10 | const modalVisible = ref(false) 11 | const modalAction = ref('') 12 | const modalTitle = computed(() => ACTIONS[modalAction.value] + name) 13 | const modalLoading = ref(false) 14 | const modalFormRef = ref(null) 15 | const modalForm = ref({ ...initForm }) 16 | 17 | /** 新增 */ 18 | function handleAdd() { 19 | modalAction.value = 'add' 20 | modalVisible.value = true 21 | modalForm.value = { ...initForm } 22 | } 23 | 24 | /** 修改 */ 25 | function handleEdit(row) { 26 | modalAction.value = 'edit' 27 | modalVisible.value = true 28 | modalForm.value = { ...row } 29 | } 30 | 31 | /** 查看 */ 32 | function handleView(row) { 33 | modalAction.value = 'view' 34 | modalVisible.value = true 35 | modalForm.value = { ...row } 36 | } 37 | 38 | /** 保存 */ 39 | function handleSave(...callbacks) { 40 | if (!['edit', 'add'].includes(modalAction.value)) { 41 | modalVisible.value = false 42 | return 43 | } 44 | modalFormRef.value?.validate(async (err) => { 45 | if (err) return 46 | const actions = { 47 | add: { 48 | api: () => doCreate(modalForm.value), 49 | cb: () => { 50 | callbacks.forEach((callback) => callback && callback()) 51 | }, 52 | msg: () => $message.success('新增成功'), 53 | }, 54 | edit: { 55 | api: () => doUpdate(modalForm.value), 56 | cb: () => { 57 | callbacks.forEach((callback) => callback && callback()) 58 | }, 59 | msg: () => $message.success('编辑成功'), 60 | }, 61 | } 62 | const action = actions[modalAction.value] 63 | 64 | try { 65 | modalLoading.value = true 66 | const data = await action.api() 67 | action.cb() 68 | action.msg() 69 | modalLoading.value = modalVisible.value = false 70 | data && refresh(data) 71 | } catch (error) { 72 | modalLoading.value = false 73 | } 74 | }) 75 | } 76 | 77 | /** 删除 */ 78 | async function handleDelete(params = {}) { 79 | if (isNullOrWhitespace(params)) return 80 | try { 81 | modalLoading.value = true 82 | const data = await doDelete(params) 83 | $message.success('删除成功') 84 | modalLoading.value = false 85 | refresh(data) 86 | } catch (error) { 87 | modalLoading.value = false 88 | } 89 | } 90 | 91 | return { 92 | modalVisible, 93 | modalAction, 94 | modalTitle, 95 | modalLoading, 96 | handleAdd, 97 | handleDelete, 98 | handleEdit, 99 | handleView, 100 | handleSave, 101 | modalForm, 102 | modalFormRef, 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /web/src/directives/index.js: -------------------------------------------------------------------------------- 1 | import setupPermissionDirective from './permission' 2 | 3 | /** setup custom vue directives. - [安装自定义的vue指令] */ 4 | export function setupDirectives(app) { 5 | setupPermissionDirective(app) 6 | } 7 | -------------------------------------------------------------------------------- /web/src/directives/permission.js: -------------------------------------------------------------------------------- 1 | import { useUserStore, usePermissionStore } from '@/store' 2 | 3 | function hasPermission(permission) { 4 | const userStore = useUserStore() 5 | const userPermissionStore = usePermissionStore() 6 | 7 | const accessApis = userPermissionStore.apis 8 | if (userStore.isSuperUser) { 9 | return true 10 | } 11 | return accessApis.includes(permission) 12 | } 13 | 14 | export default function setupPermissionDirective(app) { 15 | function updateElVisible(el, permission) { 16 | if (!permission) { 17 | throw new Error(`need roles: like v-permission="get/api/v1/user/list"`) 18 | } 19 | if (!hasPermission(permission)) { 20 | el.parentElement?.removeChild(el) 21 | } 22 | } 23 | 24 | const permissionDirective = { 25 | mounted(el, binding) { 26 | updateElVisible(el, binding.value) 27 | }, 28 | beforeUpdate(el, binding) { 29 | updateElVisible(el, binding.value) 30 | }, 31 | } 32 | 33 | app.directive('permission', permissionDirective) 34 | } 35 | -------------------------------------------------------------------------------- /web/src/layout/components/AppMain.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 24 | -------------------------------------------------------------------------------- /web/src/layout/components/header/components/BreadCrumb.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 31 | -------------------------------------------------------------------------------- /web/src/layout/components/header/components/FullScreen.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /web/src/layout/components/header/components/GithubSite.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /web/src/layout/components/header/components/Languages.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 34 | -------------------------------------------------------------------------------- /web/src/layout/components/header/components/MenuCollapse.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /web/src/layout/components/header/components/ThemeMode.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /web/src/layout/components/header/components/UserAvatar.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 51 | -------------------------------------------------------------------------------- /web/src/layout/components/header/index.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | -------------------------------------------------------------------------------- /web/src/layout/components/sidebar/components/SideLogo.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 25 | -------------------------------------------------------------------------------- /web/src/layout/components/sidebar/components/SideMenu.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 109 | 110 | 126 | -------------------------------------------------------------------------------- /web/src/layout/components/sidebar/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 10 | -------------------------------------------------------------------------------- /web/src/layout/components/tags/ContextMenu.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 124 | -------------------------------------------------------------------------------- /web/src/layout/components/tags/index.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 90 | 91 | 102 | -------------------------------------------------------------------------------- /web/src/layout/index.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 75 | -------------------------------------------------------------------------------- /web/src/main.js: -------------------------------------------------------------------------------- 1 | /** 重置样式 */ 2 | import '@/styles/reset.css' 3 | import 'uno.css' 4 | import '@/styles/global.scss' 5 | 6 | import { createApp } from 'vue' 7 | import { setupRouter } from '@/router' 8 | import { setupStore } from '@/store' 9 | import App from './App.vue' 10 | import { setupDirectives } from './directives' 11 | import { useResize } from '@/utils' 12 | import i18n from '~/i18n' 13 | 14 | async function setupApp() { 15 | const app = createApp(App) 16 | 17 | setupStore(app) 18 | 19 | await setupRouter(app) 20 | setupDirectives(app) 21 | app.use(useResize) 22 | app.use(i18n) 23 | app.mount('#app') 24 | } 25 | 26 | setupApp() 27 | -------------------------------------------------------------------------------- /web/src/router/guard/auth-guard.js: -------------------------------------------------------------------------------- 1 | import { getToken, isNullOrWhitespace } from '@/utils' 2 | 3 | const WHITE_LIST = ['/login', '/404'] 4 | export function createAuthGuard(router) { 5 | router.beforeEach(async (to) => { 6 | const token = getToken() 7 | 8 | /** 没有token的情况 */ 9 | if (isNullOrWhitespace(token)) { 10 | if (WHITE_LIST.includes(to.path)) return true 11 | return { path: 'login', query: { ...to.query, redirect: to.path } } 12 | } 13 | 14 | /** 有token的情况 */ 15 | if (to.path === '/login') return { path: '/' } 16 | return true 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /web/src/router/guard/index.js: -------------------------------------------------------------------------------- 1 | import { createPageLoadingGuard } from './page-loading-guard' 2 | import { createPageTitleGuard } from './page-title-guard' 3 | import { createAuthGuard } from './auth-guard' 4 | 5 | export function setupRouterGuard(router) { 6 | createPageLoadingGuard(router) 7 | createAuthGuard(router) 8 | createPageTitleGuard(router) 9 | } 10 | -------------------------------------------------------------------------------- /web/src/router/guard/page-loading-guard.js: -------------------------------------------------------------------------------- 1 | export function createPageLoadingGuard(router) { 2 | router.beforeEach(() => { 3 | window.$loadingBar?.start() 4 | }) 5 | 6 | router.afterEach(() => { 7 | setTimeout(() => { 8 | window.$loadingBar?.finish() 9 | }, 200) 10 | }) 11 | 12 | router.onError(() => { 13 | window.$loadingBar?.error() 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /web/src/router/guard/page-title-guard.js: -------------------------------------------------------------------------------- 1 | const baseTitle = import.meta.env.VITE_TITLE 2 | 3 | export function createPageTitleGuard(router) { 4 | router.afterEach((to) => { 5 | const pageTitle = to.meta?.title 6 | if (pageTitle) { 7 | document.title = `${pageTitle} | ${baseTitle}` 8 | } else { 9 | document.title = baseTitle 10 | } 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /web/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router' 2 | import { setupRouterGuard } from './guard' 3 | import { basicRoutes, EMPTY_ROUTE, NOT_FOUND_ROUTE } from './routes' 4 | import { getToken, isNullOrWhitespace } from '@/utils' 5 | import { useUserStore, usePermissionStore } from '@/store' 6 | 7 | const isHash = import.meta.env.VITE_USE_HASH === 'true' 8 | export const router = createRouter({ 9 | history: isHash ? createWebHashHistory('/') : createWebHistory('/'), 10 | routes: basicRoutes, 11 | scrollBehavior: () => ({ left: 0, top: 0 }), 12 | }) 13 | 14 | export async function setupRouter(app) { 15 | await addDynamicRoutes() 16 | setupRouterGuard(router) 17 | app.use(router) 18 | } 19 | 20 | export async function resetRouter() { 21 | const basicRouteNames = getRouteNames(basicRoutes) 22 | router.getRoutes().forEach((route) => { 23 | const name = route.name 24 | if (!basicRouteNames.includes(name)) { 25 | router.removeRoute(name) 26 | } 27 | }) 28 | } 29 | 30 | export async function addDynamicRoutes() { 31 | const token = getToken() 32 | 33 | // 没有token情况 34 | if (isNullOrWhitespace(token)) { 35 | router.addRoute(EMPTY_ROUTE) 36 | return 37 | } 38 | // 有token的情况 39 | const userStore = useUserStore() 40 | const permissionStore = usePermissionStore() 41 | !userStore.userId && (await userStore.getUserInfo()) 42 | try { 43 | const accessRoutes = await permissionStore.generateRoutes() 44 | await permissionStore.getAccessApis() 45 | accessRoutes.forEach((route) => { 46 | !router.hasRoute(route.name) && router.addRoute(route) 47 | }) 48 | router.hasRoute(EMPTY_ROUTE.name) && router.removeRoute(EMPTY_ROUTE.name) 49 | router.addRoute(NOT_FOUND_ROUTE) 50 | } catch (error) { 51 | console.error('error', error) 52 | const userStore = useUserStore() 53 | await userStore.logout() 54 | } 55 | } 56 | 57 | export function getRouteNames(routes) { 58 | return routes.map((route) => getRouteName(route)).flat(1) 59 | } 60 | 61 | function getRouteName(route) { 62 | const names = [route.name] 63 | if (route.children && route.children.length) { 64 | names.push(...route.children.map((item) => getRouteName(item)).flat(1)) 65 | } 66 | return names 67 | } 68 | -------------------------------------------------------------------------------- /web/src/router/routes/index.js: -------------------------------------------------------------------------------- 1 | import i18n from '~/i18n' 2 | const { t } = i18n.global 3 | 4 | const Layout = () => import('@/layout/index.vue') 5 | 6 | export const basicRoutes = [ 7 | { 8 | path: '/', 9 | redirect: '/workbench', // 默认跳转到首页 10 | meta: { order: 0 }, 11 | }, 12 | { 13 | name: t('views.workbench.label_workbench'), 14 | path: '/workbench', 15 | component: Layout, 16 | children: [ 17 | { 18 | path: '', 19 | component: () => import('@/views/workbench/index.vue'), 20 | name: `${t('views.workbench.label_workbench')}Default`, 21 | meta: { 22 | title: t('views.workbench.label_workbench'), 23 | icon: 'icon-park-outline:workbench', 24 | affix: true, 25 | }, 26 | }, 27 | ], 28 | meta: { order: 1 }, 29 | }, 30 | { 31 | name: t('views.profile.label_profile'), 32 | path: '/profile', 33 | component: Layout, 34 | isHidden: true, 35 | children: [ 36 | { 37 | path: '', 38 | component: () => import('@/views/profile/index.vue'), 39 | name: `${t('views.profile.label_profile')}Default`, 40 | meta: { 41 | title: t('views.profile.label_profile'), 42 | icon: 'user', 43 | affix: true, 44 | }, 45 | }, 46 | ], 47 | meta: { order: 99 }, 48 | }, 49 | { 50 | name: 'ErrorPage', 51 | path: '/error-page', 52 | component: Layout, 53 | redirect: '/error-page/404', 54 | meta: { 55 | title: t('views.errors.label_error'), 56 | icon: 'mdi:alert-circle-outline', 57 | order: 99, 58 | }, 59 | children: [ 60 | { 61 | name: 'ERROR-401', 62 | path: '401', 63 | component: () => import('@/views/error-page/401.vue'), 64 | meta: { 65 | title: '401', 66 | icon: 'material-symbols:authenticator', 67 | }, 68 | }, 69 | { 70 | name: 'ERROR-403', 71 | path: '403', 72 | component: () => import('@/views/error-page/403.vue'), 73 | meta: { 74 | title: '403', 75 | icon: 'solar:forbidden-circle-line-duotone', 76 | }, 77 | }, 78 | { 79 | name: 'ERROR-404', 80 | path: '404', 81 | component: () => import('@/views/error-page/404.vue'), 82 | meta: { 83 | title: '404', 84 | icon: 'tabler:error-404', 85 | }, 86 | }, 87 | { 88 | name: 'ERROR-500', 89 | path: '500', 90 | component: () => import('@/views/error-page/500.vue'), 91 | meta: { 92 | title: '500', 93 | icon: 'clarity:rack-server-outline-alerted', 94 | }, 95 | }, 96 | ], 97 | }, 98 | { 99 | name: '403', 100 | path: '/403', 101 | component: () => import('@/views/error-page/403.vue'), 102 | isHidden: true, 103 | }, 104 | { 105 | name: '404', 106 | path: '/404', 107 | component: () => import('@/views/error-page/404.vue'), 108 | isHidden: true, 109 | }, 110 | { 111 | name: 'Login', 112 | path: '/login', 113 | component: () => import('@/views/login/index.vue'), 114 | isHidden: true, 115 | meta: { 116 | title: '登录页', 117 | }, 118 | }, 119 | ] 120 | 121 | export const NOT_FOUND_ROUTE = { 122 | name: 'NotFound', 123 | path: '/:pathMatch(.*)*', 124 | redirect: '/404', 125 | isHidden: true, 126 | } 127 | 128 | export const EMPTY_ROUTE = { 129 | name: 'Empty', 130 | path: '/:pathMatch(.*)*', 131 | component: null, 132 | } 133 | 134 | const modules = import.meta.glob('@/views/**/route.js', { eager: true }) 135 | const asyncRoutes = [] 136 | Object.keys(modules).forEach((key) => { 137 | asyncRoutes.push(modules[key].default) 138 | }) 139 | 140 | // 加载 views 下每个模块的 index.vue 文件 141 | const vueModules = import.meta.glob('@/views/**/index.vue') 142 | 143 | export { asyncRoutes, vueModules } 144 | -------------------------------------------------------------------------------- /web/src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | 3 | export function setupStore(app) { 4 | app.use(createPinia()) 5 | } 6 | 7 | export * from './modules' 8 | -------------------------------------------------------------------------------- /web/src/store/modules/app/index.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { useDark } from '@vueuse/core' 3 | import { lStorage } from '@/utils' 4 | import i18n from '~/i18n' 5 | 6 | const currentLocale = lStorage.get('locale') 7 | const { locale } = i18n.global 8 | 9 | const isDark = useDark() 10 | export const useAppStore = defineStore('app', { 11 | state() { 12 | return { 13 | reloadFlag: true, 14 | collapsed: false, 15 | fullScreen: true, 16 | /** keepAlive路由的key,重新赋值可重置keepAlive */ 17 | aliveKeys: {}, 18 | isDark, 19 | locale: currentLocale || 'en', 20 | } 21 | }, 22 | actions: { 23 | async reloadPage() { 24 | $loadingBar.start() 25 | this.reloadFlag = false 26 | await nextTick() 27 | this.reloadFlag = true 28 | 29 | setTimeout(() => { 30 | document.documentElement.scrollTo({ left: 0, top: 0 }) 31 | $loadingBar.finish() 32 | }, 100) 33 | }, 34 | switchCollapsed() { 35 | this.collapsed = !this.collapsed 36 | }, 37 | setCollapsed(collapsed) { 38 | this.collapsed = collapsed 39 | }, 40 | setFullScreen(fullScreen) { 41 | this.fullScreen = fullScreen 42 | }, 43 | setAliveKeys(key, val) { 44 | this.aliveKeys[key] = val 45 | }, 46 | /** 设置暗黑模式 */ 47 | setDark(isDark) { 48 | this.isDark = isDark 49 | }, 50 | /** 切换/关闭 暗黑模式 */ 51 | toggleDark() { 52 | this.isDark = !this.isDark 53 | }, 54 | setLocale(newLocale) { 55 | this.locale = newLocale 56 | locale.value = newLocale 57 | lStorage.set('locale', newLocale) 58 | }, 59 | }, 60 | }) 61 | -------------------------------------------------------------------------------- /web/src/store/modules/index.js: -------------------------------------------------------------------------------- 1 | export * from './app' 2 | export * from './permission' 3 | export * from './tags' 4 | export * from './user' 5 | -------------------------------------------------------------------------------- /web/src/store/modules/permission/index.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { basicRoutes, vueModules } from '@/router/routes' 3 | import Layout from '@/layout/index.vue' 4 | import api from '@/api' 5 | 6 | // * 后端路由相关函数 7 | // 根据后端传来数据构建出前端路由 8 | 9 | function buildRoutes(routes = []) { 10 | return routes.map((e) => { 11 | const route = { 12 | name: e.name, 13 | path: e.path, 14 | component: shallowRef(Layout), 15 | isHidden: e.is_hidden, 16 | redirect: e.redirect, 17 | meta: { 18 | title: e.name, 19 | icon: e.icon, 20 | order: e.order, 21 | keepAlive: e.keepalive, 22 | }, 23 | children: [], 24 | } 25 | 26 | if (e.children && e.children.length > 0) { 27 | // 有子菜单 28 | route.children = e.children.map((e_child) => ({ 29 | name: e_child.name, 30 | path: e_child.path, 31 | component: vueModules[`/src/views${e_child.component}/index.vue`], 32 | isHidden: e_child.is_hidden, 33 | meta: { 34 | title: e_child.name, 35 | icon: e_child.icon, 36 | order: e_child.order, 37 | keepAlive: e_child.keepalive, 38 | }, 39 | })) 40 | } else { 41 | // 没有子菜单,创建一个默认的子路由 42 | route.children.push({ 43 | name: `${e.name}Default`, 44 | path: '', 45 | component: vueModules[`/src/views${e.component}/index.vue`], 46 | isHidden: true, 47 | meta: { 48 | title: e.name, 49 | icon: e.icon, 50 | order: e.order, 51 | keepAlive: e.keepalive, 52 | }, 53 | }) 54 | } 55 | 56 | return route 57 | }) 58 | } 59 | 60 | export const usePermissionStore = defineStore('permission', { 61 | state() { 62 | return { 63 | accessRoutes: [], 64 | accessApis: [], 65 | } 66 | }, 67 | getters: { 68 | routes() { 69 | return basicRoutes.concat(this.accessRoutes) 70 | }, 71 | menus() { 72 | return this.routes.filter((route) => route.name && !route.isHidden) 73 | }, 74 | apis() { 75 | return this.accessApis 76 | }, 77 | }, 78 | actions: { 79 | async generateRoutes() { 80 | const res = await api.getUserMenu() // 调用接口获取后端传来的菜单路由 81 | this.accessRoutes = buildRoutes(res.data) // 处理成前端路由格式 82 | return this.accessRoutes 83 | }, 84 | async getAccessApis() { 85 | const res = await api.getUserApi() 86 | this.accessApis = res.data 87 | return this.accessApis 88 | }, 89 | resetPermission() { 90 | this.$reset() 91 | }, 92 | }, 93 | }) 94 | -------------------------------------------------------------------------------- /web/src/store/modules/tags/helpers.js: -------------------------------------------------------------------------------- 1 | import { lStorage } from '@/utils' 2 | 3 | export const activeTag = lStorage.get('activeTag') 4 | export const tags = lStorage.get('tags') 5 | 6 | export const WITHOUT_TAG_PATHS = ['/404', '/login'] 7 | -------------------------------------------------------------------------------- /web/src/store/modules/tags/index.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { activeTag, tags, WITHOUT_TAG_PATHS } from './helpers' 3 | import { router } from '@/router' 4 | import { lStorage } from '@/utils' 5 | 6 | export const useTagsStore = defineStore('tag', { 7 | state() { 8 | return { 9 | tags: tags || [], 10 | activeTag: activeTag || '', 11 | } 12 | }, 13 | getters: { 14 | activeIndex() { 15 | return this.tags.findIndex((item) => item.path === this.activeTag) 16 | }, 17 | }, 18 | actions: { 19 | setActiveTag(path) { 20 | this.activeTag = path 21 | lStorage.set('activeTag', path) 22 | }, 23 | setTags(tags) { 24 | this.tags = tags 25 | lStorage.set('tags', tags) 26 | }, 27 | addTag(tag = {}) { 28 | this.setActiveTag(tag.path) 29 | if (WITHOUT_TAG_PATHS.includes(tag.path) || this.tags.some((item) => item.path === tag.path)) 30 | return 31 | this.setTags([...this.tags, tag]) 32 | }, 33 | removeTag(path) { 34 | if (path === this.activeTag) { 35 | if (this.activeIndex > 0) { 36 | router.push(this.tags[this.activeIndex - 1].path) 37 | } else { 38 | router.push(this.tags[this.activeIndex + 1].path) 39 | } 40 | } 41 | this.setTags(this.tags.filter((tag) => tag.path !== path)) 42 | }, 43 | removeOther(curPath = this.activeTag) { 44 | this.setTags(this.tags.filter((tag) => tag.path === curPath)) 45 | if (curPath !== this.activeTag) { 46 | router.push(this.tags[this.tags.length - 1].path) 47 | } 48 | }, 49 | removeLeft(curPath) { 50 | const curIndex = this.tags.findIndex((item) => item.path === curPath) 51 | const filterTags = this.tags.filter((item, index) => index >= curIndex) 52 | this.setTags(filterTags) 53 | if (!filterTags.find((item) => item.path === this.activeTag)) { 54 | router.push(filterTags[filterTags.length - 1].path) 55 | } 56 | }, 57 | removeRight(curPath) { 58 | const curIndex = this.tags.findIndex((item) => item.path === curPath) 59 | const filterTags = this.tags.filter((item, index) => index <= curIndex) 60 | this.setTags(filterTags) 61 | if (!filterTags.find((item) => item.path === this.activeTag)) { 62 | router.push(filterTags[filterTags.length - 1].path) 63 | } 64 | }, 65 | resetTags() { 66 | this.setTags([]) 67 | this.setActiveTag('') 68 | }, 69 | }, 70 | }) 71 | -------------------------------------------------------------------------------- /web/src/store/modules/user/index.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { resetRouter } from '@/router' 3 | import { useTagsStore, usePermissionStore } from '@/store' 4 | import { removeToken, toLogin } from '@/utils' 5 | import api from '@/api' 6 | 7 | export const useUserStore = defineStore('user', { 8 | state() { 9 | return { 10 | userInfo: {}, 11 | } 12 | }, 13 | getters: { 14 | userId() { 15 | return this.userInfo?.id 16 | }, 17 | name() { 18 | return this.userInfo?.username 19 | }, 20 | email() { 21 | return this.userInfo?.email 22 | }, 23 | avatar() { 24 | return this.userInfo?.avatar 25 | }, 26 | role() { 27 | return this.userInfo?.roles || [] 28 | }, 29 | isSuperUser() { 30 | return this.userInfo?.is_superuser 31 | }, 32 | isActive() { 33 | return this.userInfo?.is_active 34 | }, 35 | }, 36 | actions: { 37 | async getUserInfo() { 38 | try { 39 | const res = await api.getUserInfo() 40 | if (res.code === 401) { 41 | this.logout() 42 | return 43 | } 44 | const { id, username, email, avatar, roles, is_superuser, is_active } = res.data 45 | this.userInfo = { id, username, email, avatar, roles, is_superuser, is_active } 46 | return res.data 47 | } catch (error) { 48 | return error 49 | } 50 | }, 51 | async logout() { 52 | const { resetTags } = useTagsStore() 53 | const { resetPermission } = usePermissionStore() 54 | removeToken() 55 | resetTags() 56 | resetPermission() 57 | resetRouter() 58 | this.$reset() 59 | toLogin() 60 | }, 61 | setUserInfo(userInfo = {}) { 62 | this.userInfo = { ...this.userInfo, ...userInfo } 63 | }, 64 | }, 65 | }) 66 | -------------------------------------------------------------------------------- /web/src/styles/global.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | width: 100%; 4 | height: 100%; 5 | overflow: hidden; 6 | } 7 | 8 | html { 9 | font-size: 4px; // * 1rem = 4px 方便unocss计算:在unocss中 1字体单位 = 0.25rem,相当于 1等份 = 1px 10 | } 11 | 12 | body { 13 | font-size: 16px; 14 | } 15 | 16 | #app { 17 | width: 100%; 18 | height: 100%; 19 | } 20 | 21 | /* transition fade-slide */ 22 | .fade-slide-leave-active, 23 | .fade-slide-enter-active { 24 | transition: all 0.3s; 25 | } 26 | 27 | .fade-slide-enter-from { 28 | opacity: 0; 29 | transform: translateX(-30px); 30 | } 31 | 32 | .fade-slide-leave-to { 33 | opacity: 0; 34 | transform: translateX(30px); 35 | } 36 | 37 | /* 自定义滚动条样式 */ 38 | .cus-scroll { 39 | overflow: auto; 40 | &::-webkit-scrollbar { 41 | width: 8px; 42 | height: 8px; 43 | } 44 | } 45 | .cus-scroll-x { 46 | overflow-x: auto; 47 | &::-webkit-scrollbar { 48 | width: 0; 49 | height: 8px; 50 | } 51 | } 52 | .cus-scroll-y { 53 | overflow-y: auto; 54 | &::-webkit-scrollbar { 55 | width: 8px; 56 | height: 0; 57 | } 58 | } 59 | .cus-scroll, 60 | .cus-scroll-x, 61 | .cus-scroll-y { 62 | &::-webkit-scrollbar-thumb { 63 | background-color: transparent; 64 | border-radius: 4px; 65 | } 66 | &:hover { 67 | &::-webkit-scrollbar-thumb { 68 | background: #bfbfbf; 69 | } 70 | &::-webkit-scrollbar-thumb:hover { 71 | background: var(--primary-color); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /web/src/styles/reset.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, 6 | ::before, 7 | ::after { 8 | margin: 0; 9 | padding: 0; 10 | box-sizing: inherit; 11 | } 12 | 13 | a { 14 | text-decoration: none; 15 | color: inherit; 16 | } 17 | 18 | a:hover, 19 | a:link, 20 | a:visited, 21 | a:active { 22 | text-decoration: none; 23 | } 24 | 25 | ol, 26 | ul { 27 | list-style: none; 28 | } 29 | 30 | input, 31 | textarea { 32 | outline: none; 33 | border: none; 34 | resize: none; 35 | } 36 | -------------------------------------------------------------------------------- /web/src/utils/auth/auth.js: -------------------------------------------------------------------------------- 1 | import { router } from '@/router' 2 | 3 | export function toLogin() { 4 | const currentRoute = unref(router.currentRoute) 5 | const needRedirect = 6 | !currentRoute.meta.requireAuth && !['/404', '/login'].includes(router.currentRoute.value.path) 7 | router.replace({ 8 | path: '/login', 9 | query: needRedirect ? { ...currentRoute.query, redirect: currentRoute.path } : {}, 10 | }) 11 | } 12 | 13 | export function toFourZeroFour() { 14 | router.replace({ 15 | path: '/404', 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /web/src/utils/auth/index.js: -------------------------------------------------------------------------------- 1 | export * from './auth' 2 | export * from './token' 3 | -------------------------------------------------------------------------------- /web/src/utils/auth/token.js: -------------------------------------------------------------------------------- 1 | import { lStorage } from '@/utils' 2 | 3 | const TOKEN_CODE = 'access_token' 4 | 5 | export function getToken() { 6 | return lStorage.get(TOKEN_CODE) 7 | } 8 | 9 | export function setToken(token) { 10 | lStorage.set(TOKEN_CODE, token) 11 | } 12 | 13 | export function removeToken() { 14 | lStorage.remove(TOKEN_CODE) 15 | } 16 | 17 | // export async function refreshAccessToken() { 18 | // const tokenItem = lStorage.getItem(TOKEN_CODE) 19 | // if (!tokenItem) { 20 | // return 21 | // } 22 | // const { time } = tokenItem 23 | // // token生成或者刷新后30分钟内不执行刷新 24 | // if (new Date().getTime() - time <= 1000 * 60 * 30) return 25 | // try { 26 | // const res = await api.refreshToken() 27 | // setToken(res.data.token) 28 | // } catch (error) { 29 | // console.error(error) 30 | // } 31 | // } 32 | -------------------------------------------------------------------------------- /web/src/utils/common/common.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | /** 4 | * @desc 格式化时间 5 | * @param {(Object|string|number)} time 6 | * @param {string} format 7 | * @returns {string | null} 8 | */ 9 | export function formatDateTime(time = undefined, format = 'YYYY-MM-DD HH:mm:ss') { 10 | return dayjs(time).format(format) 11 | } 12 | 13 | export function formatDate(date = undefined, format = 'YYYY-MM-DD') { 14 | return formatDateTime(date, format) 15 | } 16 | 17 | /** 18 | * @desc 函数节流 19 | * @param {Function} fn 20 | * @param {Number} wait 21 | * @returns {Function} 22 | */ 23 | export function throttle(fn, wait) { 24 | var context, args 25 | var previous = 0 26 | 27 | return function () { 28 | var now = +new Date() 29 | context = this 30 | args = arguments 31 | if (now - previous > wait) { 32 | fn.apply(context, args) 33 | previous = now 34 | } 35 | } 36 | } 37 | 38 | /** 39 | * @desc 函数防抖 40 | * @param {Function} func 41 | * @param {number} wait 42 | * @param {boolean} immediate 43 | * @return {*} 44 | */ 45 | export function debounce(method, wait, immediate) { 46 | let timeout 47 | return function (...args) { 48 | let context = this 49 | if (timeout) { 50 | clearTimeout(timeout) 51 | } 52 | // 立即执行需要两个条件,一是immediate为true,二是timeout未被赋值或被置为null 53 | if (immediate) { 54 | /** 55 | * 如果定时器不存在,则立即执行,并设置一个定时器,wait毫秒后将定时器置为null 56 | * 这样确保立即执行后wait毫秒内不会被再次触发 57 | */ 58 | let callNow = !timeout 59 | timeout = setTimeout(() => { 60 | timeout = null 61 | }, wait) 62 | if (callNow) { 63 | method.apply(context, args) 64 | } 65 | } else { 66 | // 如果immediate为false,则函数wait毫秒后执行 67 | timeout = setTimeout(() => { 68 | /** 69 | * args是一个类数组对象,所以使用fn.apply 70 | * 也可写作method.call(context, ...args) 71 | */ 72 | method.apply(context, args) 73 | }, wait) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /web/src/utils/common/icon.js: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | import { Icon } from '@iconify/vue' 3 | import { NIcon } from 'naive-ui' 4 | import SvgIcon from '@/components/icon/SvgIcon.vue' 5 | 6 | export function renderIcon(icon, props = { size: 12 }) { 7 | return () => h(NIcon, props, { default: () => h(Icon, { icon }) }) 8 | } 9 | 10 | export function renderCustomIcon(icon, props = { size: 12 }) { 11 | return () => h(NIcon, props, { default: () => h(SvgIcon, { icon }) }) 12 | } 13 | -------------------------------------------------------------------------------- /web/src/utils/common/index.js: -------------------------------------------------------------------------------- 1 | export * from './common' 2 | export * from './is' 3 | export * from './icon' 4 | export * from './naiveTools' 5 | export * from './useResize' 6 | -------------------------------------------------------------------------------- /web/src/utils/common/is.js: -------------------------------------------------------------------------------- 1 | const toString = Object.prototype.toString 2 | 3 | export function is(val, type) { 4 | return toString.call(val) === `[object ${type}]` 5 | } 6 | 7 | export function isDef(val) { 8 | return typeof val !== 'undefined' 9 | } 10 | 11 | export function isUndef(val) { 12 | return typeof val === 'undefined' 13 | } 14 | 15 | export function isNull(val) { 16 | return val === null 17 | } 18 | 19 | export function isWhitespace(val) { 20 | return val === '' 21 | } 22 | 23 | export function isObject(val) { 24 | return !isNull(val) && is(val, 'Object') 25 | } 26 | 27 | export function isArray(val) { 28 | return val && Array.isArray(val) 29 | } 30 | 31 | export function isString(val) { 32 | return is(val, 'String') 33 | } 34 | 35 | export function isNumber(val) { 36 | return is(val, 'Number') 37 | } 38 | 39 | export function isBoolean(val) { 40 | return is(val, 'Boolean') 41 | } 42 | 43 | export function isDate(val) { 44 | return is(val, 'Date') 45 | } 46 | 47 | export function isRegExp(val) { 48 | return is(val, 'RegExp') 49 | } 50 | 51 | export function isFunction(val) { 52 | return typeof val === 'function' 53 | } 54 | 55 | export function isPromise(val) { 56 | return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch) 57 | } 58 | 59 | export function isElement(val) { 60 | return isObject(val) && !!val.tagName 61 | } 62 | 63 | export function isWindow(val) { 64 | return typeof window !== 'undefined' && isDef(window) && is(val, 'Window') 65 | } 66 | 67 | export function isNullOrUndef(val) { 68 | return isNull(val) || isUndef(val) 69 | } 70 | 71 | export function isNullOrWhitespace(val) { 72 | return isNullOrUndef(val) || isWhitespace(val) 73 | } 74 | 75 | /** 空数组 | 空字符串 | 空对象 | 空Map | 空Set */ 76 | export function isEmpty(val) { 77 | if (isArray(val) || isString(val)) { 78 | return val.length === 0 79 | } 80 | 81 | if (val instanceof Map || val instanceof Set) { 82 | return val.size === 0 83 | } 84 | 85 | if (isObject(val)) { 86 | return Object.keys(val).length === 0 87 | } 88 | 89 | return false 90 | } 91 | 92 | /** 93 | * * 类似mysql的IFNULL函数 94 | * * 第一个参数为null/undefined/'' 则返回第二个参数作为备用值,否则返回第一个参数 95 | * @param {Number|Boolean|String} val 96 | * @param {Number|Boolean|String} def 97 | * @returns 98 | */ 99 | export function ifNull(val, def = '') { 100 | return isNullOrWhitespace(val) ? def : val 101 | } 102 | 103 | export function isUrl(path) { 104 | const reg = 105 | /(((^https?:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)$/ 106 | return reg.test(path) 107 | } 108 | 109 | /** 110 | * @param {string} path 111 | * @returns {Boolean} 112 | */ 113 | export function isExternal(path) { 114 | return /^(https?:|mailto:|tel:)/.test(path) 115 | } 116 | 117 | export const isServer = typeof window === 'undefined' 118 | 119 | export const isClient = !isServer 120 | -------------------------------------------------------------------------------- /web/src/utils/common/naiveTools.js: -------------------------------------------------------------------------------- 1 | import { isNullOrUndef } from '@/utils' 2 | 3 | export function setupMessage(NMessage) { 4 | let loadingMessage = null 5 | class Message { 6 | /** 7 | * 规则: 8 | * * loading message只显示一个,新的message会替换正在显示的loading message 9 | * * loading message不会自动清除,除非被替换成非loading message,非loading message默认2秒后自动清除 10 | */ 11 | 12 | removeMessage(message = loadingMessage, duration = 2000) { 13 | setTimeout(() => { 14 | if (message) { 15 | message.destroy() 16 | message = null 17 | } 18 | }, duration) 19 | } 20 | 21 | showMessage(type, content, option = {}) { 22 | if (loadingMessage && loadingMessage.type === 'loading') { 23 | // 如果存在则替换正在显示的loading message 24 | loadingMessage.type = type 25 | loadingMessage.content = content 26 | 27 | if (type !== 'loading') { 28 | // 非loading message需设置自动清除 29 | this.removeMessage(loadingMessage, option.duration) 30 | } 31 | } else { 32 | // 不存在正在显示的loading则新建一个message,如果新建的message是loading message则将message赋值存储下来 33 | let message = NMessage[type](content, option) 34 | if (type === 'loading') { 35 | loadingMessage = message 36 | } 37 | } 38 | } 39 | 40 | loading(content) { 41 | this.showMessage('loading', content, { duration: 0 }) 42 | } 43 | 44 | success(content, option = {}) { 45 | this.showMessage('success', content, option) 46 | } 47 | 48 | error(content, option = {}) { 49 | this.showMessage('error', content, option) 50 | } 51 | 52 | info(content, option = {}) { 53 | this.showMessage('info', content, option) 54 | } 55 | 56 | warning(content, option = {}) { 57 | this.showMessage('warning', content, option) 58 | } 59 | } 60 | 61 | return new Message() 62 | } 63 | 64 | export function setupDialog(NDialog) { 65 | NDialog.confirm = function (option = {}) { 66 | const showIcon = !isNullOrUndef(option.title) 67 | return NDialog[option.type || 'warning']({ 68 | showIcon, 69 | positiveText: '确定', 70 | negativeText: '取消', 71 | onPositiveClick: option.confirm, 72 | onNegativeClick: option.cancel, 73 | onMaskClick: option.cancel, 74 | ...option, 75 | }) 76 | } 77 | 78 | return NDialog 79 | } 80 | -------------------------------------------------------------------------------- /web/src/utils/common/useResize.js: -------------------------------------------------------------------------------- 1 | export function useResize(el, cb) { 2 | const observer = new ResizeObserver((entries) => { 3 | cb(entries[0].contentRect) 4 | }) 5 | observer.observe(el) 6 | return observer 7 | } 8 | 9 | const install = (app) => { 10 | let observer 11 | 12 | app.directive('resize', { 13 | mounted(el, binding) { 14 | observer = useResize(el, binding.value) 15 | }, 16 | beforeUnmount() { 17 | observer?.disconnect() 18 | }, 19 | }) 20 | } 21 | 22 | useResize.install = install 23 | -------------------------------------------------------------------------------- /web/src/utils/http/helpers.js: -------------------------------------------------------------------------------- 1 | import { useUserStore } from '@/store' 2 | 3 | export function addBaseParams(params) { 4 | if (!params.userId) { 5 | params.userId = useUserStore().userId 6 | } 7 | } 8 | 9 | export function resolveResError(code, message) { 10 | switch (code) { 11 | case 400: 12 | message = message ?? '请求参数错误' 13 | break 14 | case 401: 15 | message = message ?? '登录已过期' 16 | break 17 | case 403: 18 | message = message ?? '没有权限' 19 | break 20 | case 404: 21 | message = message ?? '资源或接口不存在' 22 | break 23 | case 500: 24 | message = message ?? '服务器异常' 25 | break 26 | default: 27 | message = message ?? `【${code}】: 未知异常!` 28 | break 29 | } 30 | return message 31 | } 32 | -------------------------------------------------------------------------------- /web/src/utils/http/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { resReject, resResolve, reqReject, reqResolve } from './interceptors' 3 | 4 | export function createAxios(options = {}) { 5 | const defaultOptions = { 6 | timeout: 12000, 7 | } 8 | const service = axios.create({ 9 | ...defaultOptions, 10 | ...options, 11 | }) 12 | service.interceptors.request.use(reqResolve, reqReject) 13 | service.interceptors.response.use(resResolve, resReject) 14 | return service 15 | } 16 | 17 | export const request = createAxios({ 18 | baseURL: import.meta.env.VITE_BASE_API, 19 | }) 20 | -------------------------------------------------------------------------------- /web/src/utils/http/interceptors.js: -------------------------------------------------------------------------------- 1 | import { getToken } from '@/utils' 2 | import { resolveResError } from './helpers' 3 | import { useUserStore } from '@/store' 4 | 5 | export function reqResolve(config) { 6 | // 处理不需要token的请求 7 | if (config.noNeedToken) { 8 | return config 9 | } 10 | 11 | const token = getToken() 12 | if (token) { 13 | config.headers.token = config.headers.token || token 14 | } 15 | 16 | return config 17 | } 18 | 19 | export function reqReject(error) { 20 | return Promise.reject(error) 21 | } 22 | 23 | export function resResolve(response) { 24 | const { data, status, statusText } = response 25 | if (data?.code !== 200) { 26 | const code = data?.code ?? status 27 | /** 根据code处理对应的操作,并返回处理后的message */ 28 | const message = resolveResError(code, data?.msg ?? statusText) 29 | window.$message?.error(message, { keepAliveOnHover: true }) 30 | return Promise.reject({ code, message, error: data || response }) 31 | } 32 | return Promise.resolve(data) 33 | } 34 | 35 | export async function resReject(error) { 36 | if (!error || !error.response) { 37 | const code = error?.code 38 | /** 根据code处理对应的操作,并返回处理后的message */ 39 | const message = resolveResError(code, error.message) 40 | window.$message?.error(message) 41 | return Promise.reject({ code, message, error }) 42 | } 43 | const { data, status } = error.response 44 | 45 | if (data?.code === 401) { 46 | try { 47 | const userStore = useUserStore() 48 | userStore.logout() 49 | } catch (error) { 50 | console.log('resReject error', error) 51 | return 52 | } 53 | } 54 | // 后端返回的response数据 55 | const code = data?.code ?? status 56 | const message = resolveResError(code, data?.msg ?? error.message) 57 | window.$message?.error(message, { keepAliveOnHover: true }) 58 | return Promise.reject({ code, message, error: error.response?.data || error.response }) 59 | } 60 | -------------------------------------------------------------------------------- /web/src/utils/index.js: -------------------------------------------------------------------------------- 1 | export * from './common' 2 | export * from './storage' 3 | export * from './http' 4 | export * from './auth' 5 | -------------------------------------------------------------------------------- /web/src/utils/storage/index.js: -------------------------------------------------------------------------------- 1 | import { createStorage } from './storage' 2 | 3 | const prefixKey = '' 4 | 5 | export const createLocalStorage = function (option = {}) { 6 | return createStorage({ 7 | prefixKey: option.prefixKey || '', 8 | storage: localStorage, 9 | }) 10 | } 11 | 12 | export const createSessionStorage = function (option = {}) { 13 | return createStorage({ 14 | prefixKey: option.prefixKey || '', 15 | storage: sessionStorage, 16 | }) 17 | } 18 | 19 | export const lStorage = createLocalStorage({ prefixKey }) 20 | 21 | export const sStorage = createSessionStorage({ prefixKey }) 22 | -------------------------------------------------------------------------------- /web/src/utils/storage/storage.js: -------------------------------------------------------------------------------- 1 | import { isNullOrUndef } from '@/utils' 2 | 3 | class Storage { 4 | constructor(option) { 5 | this.storage = option.storage 6 | this.prefixKey = option.prefixKey 7 | } 8 | 9 | getKey(key) { 10 | return `${this.prefixKey}${key}`.toUpperCase() 11 | } 12 | 13 | set(key, value, expire) { 14 | const stringData = JSON.stringify({ 15 | value, 16 | time: Date.now(), 17 | expire: !isNullOrUndef(expire) ? new Date().getTime() + expire * 1000 : null, 18 | }) 19 | this.storage.setItem(this.getKey(key), stringData) 20 | } 21 | 22 | get(key) { 23 | const { value } = this.getItem(key, {}) 24 | return value 25 | } 26 | 27 | getItem(key, def = null) { 28 | const val = this.storage.getItem(this.getKey(key)) 29 | if (!val) return def 30 | try { 31 | const data = JSON.parse(val) 32 | const { value, time, expire } = data 33 | if (isNullOrUndef(expire) || expire > new Date().getTime()) { 34 | return { value, time } 35 | } 36 | this.remove(key) 37 | return def 38 | } catch (error) { 39 | this.remove(key) 40 | return def 41 | } 42 | } 43 | 44 | remove(key) { 45 | this.storage.removeItem(this.getKey(key)) 46 | } 47 | 48 | clear() { 49 | this.storage.clear() 50 | } 51 | } 52 | 53 | export function createStorage({ prefixKey = '', storage = sessionStorage }) { 54 | return new Storage({ prefixKey, storage }) 55 | } 56 | -------------------------------------------------------------------------------- /web/src/views/error-page/401.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 19 | -------------------------------------------------------------------------------- /web/src/views/error-page/403.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 19 | -------------------------------------------------------------------------------- /web/src/views/error-page/404.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 19 | -------------------------------------------------------------------------------- /web/src/views/error-page/500.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 19 | -------------------------------------------------------------------------------- /web/src/views/login/index.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 109 | -------------------------------------------------------------------------------- /web/src/views/system/dept/index.vue: -------------------------------------------------------------------------------- 1 | 142 | 143 | 218 | -------------------------------------------------------------------------------- /web/src/views/top-menu/index.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /web/src/views/workbench/index.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 75 | -------------------------------------------------------------------------------- /web/unocss.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetAttributify, presetUno } from 'unocss' 2 | 3 | export default defineConfig({ 4 | exclude: [ 5 | 'node_modules', 6 | '.git', 7 | '.github', 8 | '.husky', 9 | '.vscode', 10 | 'build', 11 | 'dist', 12 | 'mock', 13 | 'public', 14 | './stats.html', 15 | ], 16 | presets: [presetUno(), presetAttributify()], 17 | shortcuts: [ 18 | ['wh-full', 'w-full h-full'], 19 | ['f-c-c', 'flex justify-center items-center'], 20 | ['flex-col', 'flex flex-col'], 21 | ['absolute-lt', 'absolute left-0 top-0'], 22 | ['absolute-lb', 'absolute left-0 bottom-0'], 23 | ['absolute-rt', 'absolute right-0 top-0'], 24 | ['absolute-rb', 'absolute right-0 bottom-0'], 25 | ['absolute-center', 'absolute-lt f-c-c wh-full'], 26 | ['text-ellipsis', 'truncate'], 27 | ], 28 | rules: [ 29 | [/^bc-(.+)$/, ([, color]) => ({ 'border-color': `#${color}` })], 30 | [ 31 | 'card-shadow', 32 | { 'box-shadow': '0 1px 2px -2px #00000029, 0 3px 6px #0000001f, 0 5px 12px 4px #00000017' }, 33 | ], 34 | ], 35 | theme: { 36 | colors: { 37 | primary: 'var(--primary-color)', 38 | primary_hover: 'var(--primary-color-hover)', 39 | primary_pressed: 'var(--primary-color-pressed)', 40 | primary_active: 'var(--primary-color-active)', 41 | info: 'var(--info-color)', 42 | info_hover: 'var(--info-color-hover)', 43 | info_pressed: 'var(--info-color-pressed)', 44 | info_active: 'var(--info-color-active)', 45 | success: 'var(--success-color)', 46 | success_hover: 'var(--success-color-hover)', 47 | success_pressed: 'var(--success-color-pressed)', 48 | success_active: 'var(--success-color-active)', 49 | warning: 'var(--warning-color)', 50 | warning_hover: 'var(--warning-color-hover)', 51 | warning_pressed: 'var(--warning-color-pressed)', 52 | warning_active: 'var(--warning-color-active)', 53 | error: 'var(--error-color)', 54 | error_hover: 'var(--error-color-hover)', 55 | error_pressed: 'var(--error-color-pressed)', 56 | error_active: 'var(--error-color-active)', 57 | dark: '#18181c', 58 | }, 59 | }, 60 | }) 61 | -------------------------------------------------------------------------------- /web/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from 'vite' 2 | 3 | import { convertEnv, getSrcPath, getRootPath } from './build/utils' 4 | import { viteDefine } from './build/config' 5 | import { createVitePlugins } from './build/plugin' 6 | import { OUTPUT_DIR, PROXY_CONFIG } from './build/constant' 7 | 8 | export default defineConfig(({ command, mode }) => { 9 | const srcPath = getSrcPath() 10 | const rootPath = getRootPath() 11 | const isBuild = command === 'build' 12 | 13 | const env = loadEnv(mode, process.cwd()) 14 | const viteEnv = convertEnv(env) 15 | const { VITE_PORT, VITE_PUBLIC_PATH, VITE_USE_PROXY, VITE_BASE_API } = viteEnv 16 | 17 | return { 18 | base: VITE_PUBLIC_PATH || '/', 19 | resolve: { 20 | alias: { 21 | '~': rootPath, 22 | '@': srcPath, 23 | }, 24 | }, 25 | define: viteDefine, 26 | plugins: createVitePlugins(viteEnv, isBuild), 27 | server: { 28 | host: '0.0.0.0', 29 | port: VITE_PORT, 30 | open: true, 31 | proxy: VITE_USE_PROXY 32 | ? { 33 | [VITE_BASE_API]: PROXY_CONFIG[VITE_BASE_API], 34 | } 35 | : undefined, 36 | }, 37 | build: { 38 | target: 'es2015', 39 | outDir: OUTPUT_DIR || 'dist', 40 | reportCompressedSize: false, // 启用/禁用 gzip 压缩大小报告 41 | chunkSizeWarningLimit: 1024, // chunk 大小警告的限制(单位kb) 42 | }, 43 | } 44 | }) 45 | --------------------------------------------------------------------------------