This commit is contained in:
mizhexiaoxiao 2024-09-10 17:17:27 +08:00
parent 6123e6f971
commit 3e00fc440c
25 changed files with 1109 additions and 451 deletions

2
.gitignore vendored
View File

@ -5,7 +5,7 @@ venv/
.vscode
.ruff_cache/
.pytest_cache/
migrations/
db.sqlite3
db.sqlite3-journal

View File

@ -74,4 +74,17 @@ test: ## Run the test suite
$(eval include .env)
$(eval export $(sh sed 's/=.*//' .env))
poetry run pytest -vv -s --cache-clear ./
poetry run pytest -vv -s --cache-clear ./
.PHONY: clean-db
clean-db: ## 删除migrations文件夹和db.sqlite3
find . -type d -name "migrations" -exec rm -rf {} +
rm -f db.sqlite3 db.sqlite3-shm db.sqlite3-wal
.PHONY: migrate
migrate: ## 运行aerich migrate命令生成迁移文件
poetry run aerich migrate
.PHONY: upgrade
upgrade: ## 运行aerich upgrade命令应用迁移
poetry run aerich upgrade

View File

@ -86,8 +86,8 @@ password123456
#### 后端
启动项目需要以下环境:
- Python 3.11
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer)
#### 方法一(推荐):[Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) 安装依赖
1. 创建虚拟环境
```sh
poetry shell
@ -100,6 +100,24 @@ poetry install
```sh
make run
```
#### 方法二Pip 安装依赖
1. 创建虚拟环境
```sh
python3.11 -m venv venv
```
2. 激活虚拟环境
```sh
source venv/bin/activate
```
3. 安装依赖
```sh
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
```
3. 启动服务
```sh
python run.py
```
服务现在应该正在运行,访问 http://localhost:9999/docs 查看API文档
#### 前端

View File

@ -5,8 +5,7 @@ from tortoise import Tortoise
from app.core.exceptions import SettingNotFound
from app.core.init_app import (
init_menus,
init_superuser,
init_data,
make_middlewares,
register_exceptions,
register_routers,
@ -20,10 +19,7 @@ except ImportError:
@asynccontextmanager
async def lifespan(app: FastAPI):
await Tortoise.init(config=settings.TORTOISE_ORM)
await Tortoise.generate_schemas()
await init_superuser()
await init_menus()
await init_data()
yield
await Tortoise.close_connections()

View File

@ -3,12 +3,12 @@ from fastapi import APIRouter
from app.core.dependency import DependPermisson
from .apis import apis_router
from .auditlog import auditlog_router
from .base import base_router
from .depts import depts_router
from .menus import menus_router
from .roles import roles_router
from .users import users_router
from .auditlog import auditlog_router
v1_router = APIRouter()

View File

@ -1,14 +1,14 @@
from fastapi import APIRouter, Query
from tortoise.expressions import Q
from app.models.admin import AuditLog
from app.models.admin import AuditLog
from app.schemas import SuccessExtra
from app.schemas.apis import *
from app.core.dependency import DependPermisson
router = APIRouter()
@router.get('/list', summary="查看操作日志", dependencies=[DependPermisson])
@router.get("/list", summary="查看操作日志")
async def get_audit_log_list(
page: int = Query(1, description="页码"),
page_size: int = Query(10, description="每页数量"),
@ -18,7 +18,6 @@ async def get_audit_log_list(
start_time: str = Query("", description="开始时间"),
end_time: str = Query("", description="结束时间"),
):
q = Q()
if username:
q &= Q(username__icontains=username)
@ -32,7 +31,7 @@ async def get_audit_log_list(
q &= Q(created_at__gte=start_time)
elif end_time:
q &= Q(created_at__lte=end_time)
audit_log_objs = await AuditLog.filter(q).offset((page - 1) * page_size).limit(page_size).order_by("-created_at")
total = await AuditLog.filter(q).count()
data = [await audit_log.to_dict() for audit_log in audit_log_objs]

View File

@ -76,6 +76,6 @@ async def delete_user(
@router.post("/reset_password", summary="重置密码")
async def reset_password(user_id: int = Body(..., description="用户ID")):
async def reset_password(user_id: int = Body(..., description="用户ID", embed=True)):
await user_controller.reset_password(user_id)
return Success(msg="密码已重置为123456")

View File

@ -16,7 +16,8 @@ class ApiController(CRUDBase[Api, ApiCreate, ApiUpdate]):
# 删除废弃API数据
all_api_list = []
for route in app.routes:
if isinstance(route, APIRoute):
# 只更新有鉴权的API
if isinstance(route, APIRoute) and len(route.dependencies) > 0:
all_api_list.append((list(route.methods)[0], route.path_format))
delete_api = []
for api in await Api.all():
@ -28,7 +29,7 @@ class ApiController(CRUDBase[Api, ApiCreate, ApiUpdate]):
await Api.filter(method=method, path=path).delete()
for route in app.routes:
if isinstance(route, APIRoute):
if isinstance(route, APIRoute) and len(route.dependencies) > 0:
method = list(route.methods)[0]
path = route.path_format
summary = route.summary

View File

@ -1,8 +1,11 @@
from aerich import Command
from fastapi import FastAPI
from fastapi.middleware import Middleware
from fastapi.middleware.cors import CORSMiddleware
from tortoise.expressions import Q
from app.api import api_router
from app.controllers.api import api_controller
from app.controllers.user import UserCreate, user_controller
from app.core.exceptions import (
DoesNotExist,
@ -16,7 +19,7 @@ from app.core.exceptions import (
ResponseValidationError,
ResponseValidationHandle,
)
from app.models.admin import Menu
from app.models.admin import Api, Menu, Role
from app.schemas.menus import MenuType
from app.settings.config import settings
@ -152,29 +155,69 @@ async def init_menus():
is_hidden=False,
component="/system/auditlog",
keepalive=False,
)
),
]
await Menu.bulk_create(children_menu)
parent_menu = await Menu.create(
menu_type=MenuType.CATALOG,
name="一级菜单",
path="/",
order=2,
parent_id=0,
icon="mdi-fan-speed-1",
is_hidden=False,
component="Layout",
keepalive=False,
redirect="",
)
await Menu.create(
menu_type=MenuType.MENU,
name="一级菜单",
path="top-menu",
order=1,
parent_id=parent_menu.id,
icon="mdi-fan-speed-1",
path="/top-menu",
order=2,
parent_id=0,
icon="material-symbols:featured-play-list-outline",
is_hidden=False,
component="/top-menu",
keepalive=False,
redirect="",
)
async def init_apis():
apis = await api_controller.model.exists()
if not apis:
await api_controller.refresh_api()
async def init_db():
command = Command(tortoise_config=settings.TORTOISE_ORM)
try:
await command.init_db(safe=True)
except FileExistsError:
pass
await command.init()
await command.migrate()
await command.upgrade(run_in_transaction=True)
async def init_roles():
roles = await Role.exists()
if not roles:
admin_role = await Role.create(
name="管理员",
desc="管理员角色",
)
user_role = await Role.create(
name="普通用户",
desc="普通用户角色",
)
# 分配所有API给管理员角色
all_apis = await Api.all()
await admin_role.apis.add(*all_apis)
# 分配所有菜单给管理员和普通用户
all_menus = await Menu.all()
await admin_role.menus.add(*all_menus)
await user_role.menus.add(*all_menus)
# 为普通用户分配基本API
basic_apis = await Api.filter(Q(method__in=["GET"]) | Q(tags="基础模块"))
await user_role.apis.add(*basic_apis)
async def init_data():
await init_db()
await init_superuser()
await init_menus()
await init_apis()
await init_roles()

View File

@ -21,11 +21,6 @@ class User(BaseModel, TimestampMixin):
class Meta:
table = "user"
class PydanticMeta:
# todo
# computed = ["full_name"]
...
class Role(BaseModel, TimestampMixin):
name = fields.CharField(max_length=20, unique=True, description="角色名称", index=True)

View File

@ -20,18 +20,34 @@ class BaseModel(models.Model):
if isinstance(value, datetime):
value = value.strftime(settings.DATETIME_FORMAT)
d[field] = value
if m2m:
tasks = [self.__fetch_m2m_field(field) for field in self._meta.m2m_fields if field not in exclude_fields]
tasks = [
self.__fetch_m2m_field(field, exclude_fields)
for field in self._meta.m2m_fields
if field not in exclude_fields
]
results = await asyncio.gather(*tasks)
for field, values in results:
d[field] = values
return d
async def __fetch_m2m_field(self, field):
values = [value for value in await getattr(self, field).all().values()]
async def __fetch_m2m_field(self, field, exclude_fields):
values = await getattr(self, field).all().values()
formatted_values = []
for value in values:
value.update((k, v.strftime(settings.DATETIME_FORMAT)) for k, v in value.items() if isinstance(v, datetime))
return field, values
formatted_value = {}
for k, v in value.items():
if k not in exclude_fields:
if isinstance(v, datetime):
formatted_value[k] = v.strftime(settings.DATETIME_FORMAT)
else:
formatted_value[k] = v
formatted_values.append(formatted_value)
return field, formatted_values
class Meta:
abstract = True

View File

@ -16,42 +16,77 @@ class Settings(BaseSettings):
CORS_ALLOW_HEADERS: typing.List = ["*"]
DEBUG: bool = True
DB_URL: str = "sqlite://db.sqlite3"
DB_CONNECTIONS: dict = {
"default": {
"engine": "tortoise.backends.sqlite",
"db_url": DB_URL,
"credentials": {
"host": "",
"port": "",
"user": "",
"password": "",
"database": "",
},
},
}
PROJECT_ROOT: str = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
BASE_DIR: str = os.path.abspath(os.path.join(PROJECT_ROOT, os.pardir))
LOGS_ROOT: str = os.path.join(BASE_DIR, "app/logs")
SECRET_KEY: str = "3488a63e1765035d386f05409663f55c83bfae3b3c61a932744b20ad14244dcf" # openssl rand -hex 32
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 24 * 7 # 7 day
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 day
TORTOISE_ORM: dict = {
"connections": {
# SQLite configuration
"sqlite": {
"engine": "tortoise.backends.sqlite",
"credentials": {"file_path": f"{BASE_DIR}/db.sqlite3"},
}
"credentials": {"file_path": f"{BASE_DIR}/db.sqlite3"}, # Path to SQLite database file
},
# MySQL/MariaDB configuration
# Install with: tortoise-orm[asyncmy]
# "mysql": {
# "engine": "tortoise.backends.mysql",
# "credentials": {
# "host": "localhost", # Database host address
# "port": 3306, # Database port
# "user": "yourusername", # Database username
# "password": "yourpassword", # Database password
# "database": "yourdatabase", # Database name
# },
# },
# PostgreSQL configuration
# Install with: tortoise-orm[asyncpg]
# "postgres": {
# "engine": "tortoise.backends.asyncpg",
# "credentials": {
# "host": "localhost", # Database host address
# "port": 5432, # Database port
# "user": "yourusername", # Database username
# "password": "yourpassword", # Database password
# "database": "yourdatabase", # Database name
# },
# },
# MSSQL/Oracle configuration
# Install with: tortoise-orm[asyncodbc]
# "oracle": {
# "engine": "tortoise.backends.asyncodbc",
# "credentials": {
# "host": "localhost", # Database host address
# "port": 1433, # Database port
# "user": "yourusername", # Database username
# "password": "yourpassword", # Database password
# "database": "yourdatabase", # Database name
# },
# },
# SQLServer configuration
# Install with: tortoise-orm[asyncodbc]
# "sqlserver": {
# "engine": "tortoise.backends.asyncodbc",
# "credentials": {
# "host": "localhost", # Database host address
# "port": 1433, # Database port
# "user": "yourusername", # Database username
# "password": "yourpassword", # Database password
# "database": "yourdatabase", # Database name
# },
# },
},
"apps": {
"models": {
"models": ["app.models"],
"models": ["app.models", "aerich.models"],
"default_connection": "sqlite",
},
},
"use_tz": False,
"timezone": "Asia/Shanghai",
"use_tz": False, # Whether to use timezone-aware datetimes
"timezone": "Asia/Shanghai", # Timezone setting
}
DATETIME_FORMAT: str = "%Y-%m-%d %H:%M:%S"

1033
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@ annotated-types = "^0.6.0"
setuptools = "^70.0.0"
uvicorn = "^0.30.1"
h11 = "^0.14.0"
aerich = "^0.7.2"
[tool.black]
line-length = 120
@ -48,4 +49,8 @@ extend-select = [
ignore = [
"F403",
"F405",
]
]
[tool.aerich]
tortoise_orm = "app.settings.TORTOISE_ORM"
location = "./migrations"
src_folder = "./."

View File

@ -1,28 +1,21 @@
aiosqlite==0.17.0
annotated-types==0.6.0
anyio==4.3.0
anyio==4.4.0
argon2-cffi==23.1.0
argon2-cffi-bindings==21.2.0
black==23.12.1
blinker==1.7.0
certifi==2024.2.2
cffi==1.16.0
certifi==2024.7.4
cffi==1.17.0
click==8.1.7
dep-logic==0.2.0
distlib==0.3.8
dnspython==2.6.1
email_validator==2.1.1
email_validator==2.2.0
fastapi==0.111.0
fastapi-cli==0.0.4
filelock==3.13.4
findpython==0.6.0
gunicorn==21.2.0
fastapi-cli==0.0.5
h11==0.14.0
hishel==0.0.26
httpcore==1.0.5
httptools==0.6.1
httpx==0.27.0
idna==3.7
installer==0.7.0
idna==3.8
iso8601==1.1.0
isort==5.13.2
Jinja2==3.1.4
@ -30,40 +23,35 @@ loguru==0.7.2
markdown-it-py==3.0.0
MarkupSafe==2.1.5
mdurl==0.1.2
msgpack==1.0.8
mypy-extensions==1.0.0
orjson==3.10.3
packaging==24.0
orjson==3.10.7
packaging==24.1
passlib==1.7.4
pathspec==0.12.1
pbs-installer==2024.4.1
pdm==2.14.0
platformdirs==4.2.2
pycparser==2.22
pydantic==2.7.1
pydantic-settings==2.2.1
pydantic_core==2.18.2
pydantic-settings==2.4.0
pydantic_core==2.23.0
Pygments==2.18.0
PyJWT==2.8.0
PyJWT==2.9.0
pypika-tortoise==0.1.6
pyproject_hooks==1.0.0
python-dotenv==1.0.1
python-multipart==0.0.9
pytz==2024.1
resolvelib==1.0.1
rich==13.7.1
PyYAML==6.0.2
rich==13.8.0
ruff==0.0.281
setuptools==70.3.0
shellingham==1.5.4
sniffio==1.3.1
socksio==1.0.0
starlette==0.37.2
tomlkit==0.12.4
tortoise-orm==0.20.1
truststore==0.8.0
typer==0.12.3
typing_extensions==4.11.0
typer==0.12.5
typing_extensions==4.12.2
ujson==5.10.0
unearth==0.15.1
uvicorn==0.30.1
virtualenv==20.25.1
zstandard==0.22.0
uvicorn==0.30.6
uvloop==0.20.0
watchfiles==0.23.0
websockets==13.0
aerich==0.7.2

View File

@ -13,6 +13,7 @@ export default {
createUser: (data = {}) => request.post('/user/create', data),
updateUser: (data = {}) => request.post('/user/update', data),
deleteUser: (params = {}) => request.delete(`/user/delete`, { params }),
resetPassword: (data = {}) => request.post(`/user/reset_password`, data),
// role
getRoleList: (params = {}) => request.get('/role/list', { params }),
createRole: (data = {}) => request.post('/role/create', data),

View File

@ -1,19 +1,21 @@
<template>
<QueryBar v-if="$slots.queryBar" mb-30 @search="handleSearch" @reset="handleReset">
<slot name="queryBar" />
</QueryBar>
<div v-bind="$attrs">
<QueryBar v-if="$slots.queryBar" mb-30 @search="handleSearch" @reset="handleReset">
<slot name="queryBar" />
</QueryBar>
<n-data-table
:remote="remote"
:loading="loading"
:columns="columns"
:data="tableData"
:scroll-x="scrollX"
:row-key="(row) => row[rowKey]"
:pagination="isPagination ? pagination : false"
@update:checked-row-keys="onChecked"
@update:page="onPageChange"
/>
<n-data-table
:remote="remote"
:loading="loading"
:columns="columns"
:data="tableData"
:scroll-x="scrollX"
:row-key="(row) => row[rowKey]"
:pagination="isPagination ? pagination : false"
@update:checked-row-keys="onChecked"
@update:page="onPageChange"
/>
</div>
</template>
<script setup>

View File

@ -17,7 +17,7 @@ export const basicRoutes = [
{
path: '',
component: () => import('@/views/workbench/index.vue'),
name: t('views.workbench.label_workbench'),
name: `${t('views.workbench.label_workbench')}Default`,
meta: {
title: t('views.workbench.label_workbench'),
icon: 'icon-park-outline:workbench',
@ -36,7 +36,7 @@ export const basicRoutes = [
{
path: '',
component: () => import('@/views/profile/index.vue'),
name: t('views.profile.label_profile'),
name: `${t('views.profile.label_profile')}Default`,
meta: {
title: t('views.profile.label_profile'),
icon: 'user',

View File

@ -5,33 +5,56 @@ import api from '@/api'
// * 后端路由相关函数
// 根据后端传来数据构建出前端路由
function buildRoutes(routes = []) {
return routes.map((e) => ({
name: e.name,
path: e.path, // 处理目录是一级菜单的情况
component: shallowRef(Layout), // ? 不使用 shallowRef 控制台会有 warning
isHidden: e.is_hidden,
redirect: e.redirect,
meta: {
title: e.name,
icon: e.icon,
order: e.order,
keepAlive: e.keepalive,
},
children: e.children.map((e_child) => ({
name: e_child.name,
path: e_child.path, // 父路径 + 当前菜单路径
// ! 读取动态加载的路由模块
component: vueModules[`/src/views${e_child.component}/index.vue`],
isHidden: e_child.is_hidden,
return routes.map((e) => {
const route = {
name: e.name,
path: e.path,
component: shallowRef(Layout),
isHidden: e.is_hidden,
redirect: e.redirect,
meta: {
title: e_child.name,
icon: e_child.icon,
order: e_child.order,
keepAlive: e_child.keepalive,
title: e.name,
icon: e.icon,
order: e.order,
keepAlive: e.keepalive,
},
})),
}))
children: [],
}
if (e.children && e.children.length > 0) {
// 有子菜单
route.children = e.children.map((e_child) => ({
name: e_child.name,
path: e_child.path,
component: vueModules[`/src/views${e_child.component}/index.vue`],
isHidden: e_child.is_hidden,
meta: {
title: e_child.name,
icon: e_child.icon,
order: e_child.order,
keepAlive: e_child.keepalive,
},
}))
} else {
// 没有子菜单,创建一个默认的子路由
route.children.push({
name: `${e.name}Default`,
path: '',
component: vueModules[`/src/views${e.component}/index.vue`],
isHidden: true,
meta: {
title: e.name,
icon: e.icon,
order: e.order,
keepAlive: e.keepalive,
},
})
}
return route
})
}
export const usePermissionStore = defineStore('permission', {

View File

@ -156,6 +156,7 @@ const columns = [
{
size: 'small',
type: 'error',
style: 'margin-right: 8px;',
},
{
default: () => '删除',

View File

@ -90,7 +90,7 @@ const columns = [
{
size: 'small',
type: 'primary',
style: 'margin-right: 8px;',
style: 'margin-left: 8px;',
onClick: () => {
console.log('row', row.parent_id)
if (row.parent_id === 0) {
@ -122,6 +122,7 @@ const columns = [
{
size: 'small',
type: 'error',
style: 'margin-left: 8px;',
},
{
default: () => '删除',

View File

@ -9,6 +9,8 @@ import {
NPopconfirm,
NSwitch,
NTreeSelect,
NRadio,
NRadioGroup,
} from 'naive-ui'
import CommonPage from '@/components/page/CommonPage.vue'
@ -26,7 +28,6 @@ defineOptions({ name: '菜单管理' })
const $table = ref(null)
const queryItems = ref({})
const vPermission = resolveDirective('permission')
const menuDisabled = ref(false)
//
const initForm = {
@ -126,12 +127,11 @@ const columns = [
size: 'tiny',
quaternary: true,
type: 'primary',
style: `display: ${row.children ? '' : 'none'};`,
style: `display: ${row.children && row.menu_type !== 'menu' ? '' : 'none'};`,
onClick: () => {
initForm.parent_id = row.id
initForm.menu_type = 'menu'
showMenuType.value = false
menuDisabled.value = false
handleAdd()
},
},
@ -216,7 +216,6 @@ function handleClickAdd() {
initForm.order = 1
initForm.keepalive = true
showMenuType.value = true
menuDisabled.value = true
handleAdd()
}
@ -263,6 +262,12 @@ async function getTreeSelect() {
:label-width="80"
:model="modalForm"
>
<NFormItem label="菜单类型" path="menu_type">
<NRadioGroup v-model:value="modalForm.menu_type">
<NRadio label="目录" value="catalog" />
<NRadio label="菜单" value="menu" />
</NRadioGroup>
</NFormItem>
<NFormItem label="上级菜单" path="parent_id">
<NTreeSelect
v-model:value="modalForm.parent_id"
@ -270,7 +275,6 @@ async function getTreeSelect() {
label-field="name"
:options="menuOptions"
default-expand-all="true"
:disabled="menuDisabled"
/>
</NFormItem>
<NFormItem

View File

@ -1,37 +0,0 @@
// const Layout = () => import('@/layout/index.vue')
// export default {
// name: 'System',
// path: '/system',
// component: Layout,
// redirect: '/system/user',
// meta: {
// title: '系统管理',
// icon: 'ph:user-list-bold',
// order: 5,
// // role: ['admin'],
// // requireAuth: true,
// },
// children: [
// {
// name: 'User',
// path: 'user',
// component: () => import('./user/index.vue'),
// meta: {
// title: '用户列表',
// icon: 'mdi:account',
// keepAlive: true,
// },
// },
// {
// name: 'Menu',
// path: 'menu',
// component: () => import('./menu/index.vue'),
// meta: {
// title: '菜单列表',
// icon: 'ic:twotone-menu-book',
// keepAlive: true,
// },
// },
// ],
// }

View File

@ -192,6 +192,7 @@ const columns = [
{
size: 'small',
type: 'error',
style: 'margin-right: 8px;',
},
{
default: () => '删除',
@ -203,6 +204,40 @@ const columns = [
default: () => h('div', {}, '确定删除该用户吗?'),
}
),
!row.is_superuser && h(
NPopconfirm,
{
onPositiveClick: async () => {
try {
await api.resetPassword({ user_id: row.id });
$message.success('密码已成功重置为123456');
await $table.value?.handleSearch();
} catch (error) {
$message.error('重置密码失败: ' + error.message);
}
},
onNegativeClick: () => {},
},
{
trigger: () =>
withDirectives(
h(
NButton,
{
size: 'small',
type: 'error',
style: 'margin-right: 8px;',
},
{
default: () => '重置密码',
icon: renderIcon('material-symbols:delete-outline', { size: 16 }),
}
),
[[vPermission, 'post/api/v1/user/reset_password']]
),
default: () => h('div', {}, '确定重置用户密码为123456吗?'),
}
),
]
},
},

View File

@ -1,20 +0,0 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: 'Dashboard',
path: '/',
component: Layout,
redirect: '/workbench',
children: [
{
name: 'Workbench',
path: 'workbench',
component: () => import('./index.vue'),
meta: {
title: '工作台',
icon: 'mdi:home',
order: 0,
},
},
],
}