├── .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 |
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 | 
28 | ### 工作台
29 |
30 | 
31 |
32 | ### 用户管理
33 |
34 | 
35 | ### 角色管理
36 |
37 | 
38 |
39 | ### 菜单管理
40 |
41 | 
42 |
43 | ### API管理
44 |
45 | 
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 |
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 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/web/src/components/common/AppProvider.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
68 |
--------------------------------------------------------------------------------
/web/src/components/common/LoadingEmptyWrapper.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
{{ emptyDesc }}
12 |
13 |
14 |
15 |
20 |
21 |
{{ networkErrorDesc }}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/web/src/components/common/ScrollX.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
21 |
22 |
23 |
24 |
25 |
26 |
112 |
113 |
161 |
--------------------------------------------------------------------------------
/web/src/components/icon/CustomIcon.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/web/src/components/icon/IconPicker.vue:
--------------------------------------------------------------------------------
1 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | 更多图标去
50 |
51 | Icones
52 |
53 | 查看
54 |
55 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/web/src/components/icon/SvgIcon.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
24 |
25 |
--------------------------------------------------------------------------------
/web/src/components/icon/TheIcon.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/web/src/components/page/AppPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
19 |
--------------------------------------------------------------------------------
/web/src/components/page/CommonPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ title || route.meta?.title }}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
34 |
--------------------------------------------------------------------------------
/web/src/components/query-bar/QueryBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
17 | 重置
18 | 搜索
19 |
20 |
21 |
22 |
23 |
24 |
27 |
--------------------------------------------------------------------------------
/web/src/components/query-bar/QueryBarItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
35 |
--------------------------------------------------------------------------------
/web/src/components/table/CrudModal.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
19 |
20 |
21 |
22 |
23 |
57 |
--------------------------------------------------------------------------------
/web/src/components/table/CrudTable.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
18 |
19 |
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 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
24 |
--------------------------------------------------------------------------------
/web/src/layout/components/header/components/BreadCrumb.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 | {{ item.meta.title }}
10 |
11 |
12 |
13 |
14 |
31 |
--------------------------------------------------------------------------------
/web/src/layout/components/header/components/FullScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
15 |
--------------------------------------------------------------------------------
/web/src/layout/components/header/components/GithubSite.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
--------------------------------------------------------------------------------
/web/src/layout/components/header/components/Languages.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
34 |
--------------------------------------------------------------------------------
/web/src/layout/components/header/components/MenuCollapse.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
13 |
--------------------------------------------------------------------------------
/web/src/layout/components/header/components/ThemeMode.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/web/src/layout/components/header/components/UserAvatar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
![]()
5 |
{{ userStore.name }}
6 |
7 |
8 |
9 |
10 |
51 |
--------------------------------------------------------------------------------
/web/src/layout/components/header/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
24 |
--------------------------------------------------------------------------------
/web/src/layout/components/sidebar/components/SideLogo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
14 | {{ title }}
15 |
16 |
17 |
18 |
19 |
25 |
--------------------------------------------------------------------------------
/web/src/layout/components/sidebar/components/SideMenu.vue:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
15 |
109 |
110 |
126 |
--------------------------------------------------------------------------------
/web/src/layout/components/sidebar/index.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/web/src/layout/components/tags/ContextMenu.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
124 |
--------------------------------------------------------------------------------
/web/src/layout/components/tags/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
14 | {{ tag.title }}
15 |
16 |
23 |
24 |
25 |
26 |
90 |
91 |
102 |
--------------------------------------------------------------------------------
/web/src/layout/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
13 |
14 |
15 |
22 |
25 |
28 |
29 |
30 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{
9 | $t('views.errors.text_back_to_home')
10 | }}
11 |
12 |
13 |
14 |
15 |
16 |
19 |
--------------------------------------------------------------------------------
/web/src/views/error-page/403.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{
9 | $t('views.errors.text_back_to_home')
10 | }}
11 |
12 |
13 |
14 |
15 |
16 |
19 |
--------------------------------------------------------------------------------
/web/src/views/error-page/404.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{
9 | $t('views.errors.text_back_to_home')
10 | }}
11 |
12 |
13 |
14 |
15 |
16 |
19 |
--------------------------------------------------------------------------------
/web/src/views/error-page/500.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{
9 | $t('views.errors.text_back_to_home')
10 | }}
11 |
12 |
13 |
14 |
15 |
16 |
19 |
--------------------------------------------------------------------------------
/web/src/views/login/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{ $t('app_name') }}
15 |
16 |
17 |
24 |
25 |
26 |
35 |
36 |
37 |
38 |
47 | {{ $t('views.login.text_login') }}
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
109 |
--------------------------------------------------------------------------------
/web/src/views/system/dept/index.vue:
--------------------------------------------------------------------------------
1 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
154 | 新建部门
155 |
156 |
157 |
158 |
159 |
165 |
166 |
167 |
174 |
175 |
176 |
177 |
178 |
179 |
185 |
193 |
194 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
--------------------------------------------------------------------------------
/web/src/views/top-menu/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | 一级菜单
4 |
5 |
6 |
--------------------------------------------------------------------------------
/web/src/views/workbench/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
![]()
8 |
9 |
10 | {{ $t('views.workbench.text_hello', { username: userStore.name }) }}
11 |
12 |
{{ $t('views.workbench.text_welcome') }}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
28 |
29 | {{ $t('views.workbench.label_more') }}
30 |
31 |
32 |
40 | {{ dummyText }}
41 |
42 |
43 |
44 |
45 |
46 |
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 |
--------------------------------------------------------------------------------