feat: 添加登录日志和操作日志功能

1. 新增日志相关数据库模型、API路由、前端页面与权限配置
2. 为现有业务接口添加操作日志记录功能
3. 优化导入顺序与权限初始化数据
This commit is contained in:
jayhgq 2026-06-12 22:41:41 +08:00
parent acb86e4fe1
commit 19d6db6ecc
19 changed files with 1173 additions and 77 deletions

View File

View File

@ -0,0 +1,116 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, delete
from database import get_db
from models import LoginLog, OperationLog, User
from schemas import (
LoginLogResponse,
LoginLogSearch,
LoginLogCleanRequest,
OperationLogResponse,
OperationLogSearch,
)
from middleware import get_current_user, has_permission
from utils.like_filter import ilike_contains
router = APIRouter(tags=["logs"])
# ========================================
# 登录日志
# ========================================
@router.post("/login-logs/search", response_model=list[LoginLogResponse])
async def search_login_logs(
query: LoginLogSearch,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(has_permission("log:login")),
):
"""搜索登录日志"""
stmt = select(LoginLog)
filters = []
cond = ilike_contains(LoginLog.username, query.username)
if cond is not None:
filters.append(cond)
if query.status:
filters.append(LoginLog.status == query.status)
if query.start_time:
filters.append(LoginLog.login_time >= query.start_time)
if query.end_time:
filters.append(LoginLog.login_time <= query.end_time)
if filters:
stmt = stmt.where(and_(*filters))
stmt = stmt.order_by(LoginLog.login_time.desc())
result = await db.execute(stmt)
rows = result.scalars().all()
return [LoginLogResponse.model_validate(r) for r in rows]
@router.delete("/login-logs/clean")
async def clean_login_logs(
clean_data: LoginLogCleanRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(has_permission("log:clean")),
):
"""清理指定日期之前的登录日志"""
stmt = delete(LoginLog).where(LoginLog.login_time < clean_data.before_date)
result = await db.execute(stmt)
await db.commit()
return {
"message": f"已清理 {result.rowcount} 条登录日志",
"deleted_count": result.rowcount,
}
# ========================================
# 操作日志
# ========================================
@router.post("/operation-logs/search", response_model=list[OperationLogResponse])
async def search_operation_logs(
query: OperationLogSearch,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(has_permission("log:operation")),
):
"""搜索操作日志"""
stmt = select(OperationLog)
filters = []
cond = ilike_contains(OperationLog.username, query.username)
if cond is not None:
filters.append(cond)
if query.action_type:
filters.append(OperationLog.action_type == query.action_type)
if query.target_type:
filters.append(OperationLog.target_type == query.target_type)
if query.start_time:
filters.append(OperationLog.createtime >= query.start_time)
if query.end_time:
filters.append(OperationLog.createtime <= query.end_time)
if filters:
stmt = stmt.where(and_(*filters))
stmt = stmt.order_by(OperationLog.createtime.desc())
result = await db.execute(stmt)
rows = result.scalars().all()
return [OperationLogResponse.model_validate(r) for r in rows]
@router.delete("/operation-logs/clean")
async def clean_operation_logs(
clean_data: LoginLogCleanRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(has_permission("log:clean")),
):
"""清理指定日期之前的操作日志"""
stmt = delete(OperationLog).where(OperationLog.createtime < clean_data.before_date)
result = await db.execute(stmt)
await db.commit()
return {
"message": f"已清理 {result.rowcount} 条操作日志",
"deleted_count": result.rowcount,
}

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy.orm import selectinload
@ -17,6 +17,7 @@ from schemas import (
from middleware import get_current_user
from models import User
from utils.like_filter import ilike_contains
from utils.log_utils import get_client_ip, get_user_agent, create_operation_log
router = APIRouter(prefix="/permissions", tags=["permissions"])
@ -27,6 +28,7 @@ permission_crud = FastCRUD(Permission, PermissionResponse)
@router.post("/", response_model=PermissionResponse)
async def create_permission(
permission_data: PermissionCreate,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -46,15 +48,24 @@ async def create_permission(
)
db.add(db_permission)
await db.commit()
await db.flush()
await db.refresh(db_permission)
# 记录操作日志
await create_operation_log(
db, current_user.id, current_user.username, "create", "permission",
db_permission.id, db_permission.name,
{"name": permission_data.name, "code": permission_data.code},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return db_permission
@router.post("/search", response_model=list[PermissionResponse])
async def search_permissions(
query: dict = {},
request: Request = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -70,11 +81,21 @@ async def search_permissions(
stmt = stmt.where(and_(*filters))
result = await db.execute(stmt)
permissions = result.scalars().all()
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
None, None, {"filters": {k: v for k, v in query.items() if v}},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return [PermissionResponse.model_validate(p) for p in permissions]
@router.get("/tree", response_model=list[PermissionTreeResponse])
async def get_permission_tree(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -89,6 +110,7 @@ async def get_permission_tree(
"name": perm.name,
"code": perm.code,
"parentid": perm.parentid,
"route": perm.route,
"description": perm.description,
"createtime": perm.createtime,
"updatetime": perm.updatetime,
@ -106,11 +128,20 @@ async def get_permission_tree(
# 子节点,添加到父节点的 children
perm_dict[parentid]["children"].append(perm_data)
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
None, None, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return tree
@router.get("/menu", response_model=list[PermissionTreeResponse])
async def get_menu(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -122,20 +153,20 @@ async def get_menu(
.filter(Role.id == current_user.role_id)
)
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
# 获取用户有权限的权限编码集合
user_permission_codes = {perm.code for perm in role.permissions}
# 获取所有顶级模块和二级模块权限有route字段的
result = await db.execute(
select(Permission)
.filter(Permission.route.isnot(None))
)
all_menu_permissions = result.scalars().all()
# 过滤用户有权限的菜单
user_menu_permissions = []
for perm in all_menu_permissions:
@ -147,7 +178,7 @@ async def get_menu(
break
if has_access:
user_menu_permissions.append(perm)
# 转换为字典,便于查找
perm_dict = {perm.id: {
"id": perm.id,
@ -160,7 +191,7 @@ async def get_menu(
"updatetime": perm.updatetime,
"children": []
} for perm in user_menu_permissions}
# 构建树形结构
tree = []
for perm_id, perm_data in perm_dict.items():
@ -171,13 +202,22 @@ async def get_menu(
elif parentid in perm_dict:
# 子节点,添加到父节点的 children
perm_dict[parentid]["children"].append(perm_data)
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
None, None, {"type": "menu_tree"},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return tree
@router.get("/{permission_id}", response_model=PermissionResponse)
async def get_permission(
permission_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -185,6 +225,15 @@ async def get_permission(
permission = await permission_crud.get(db, id=permission_id)
if not permission:
raise HTTPException(status_code=404, detail="权限不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
permission_id, permission.name, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return permission
@ -192,10 +241,17 @@ async def get_permission(
async def update_permission(
permission_id: int,
permission_data: PermissionCreate,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""更新权限"""
# 获取原权限数据
result = await db.execute(select(Permission).filter(Permission.id == permission_id))
old_permission = result.scalar_one_or_none()
if not old_permission:
raise HTTPException(status_code=404, detail="权限不存在")
# 检查权限编码是否被其他权限使用
result = await db.execute(
select(Permission).filter(Permission.code == permission_data.code, Permission.id != permission_id)
@ -213,12 +269,27 @@ async def update_permission(
)
if not updated_permission:
raise HTTPException(status_code=404, detail="权限不存在")
# 记录操作日志
changed = {}
for field in ["name", "code", "parentid", "description"]:
old_val = getattr(old_permission, field, None)
new_val = update_data.get(field)
if old_val != new_val:
changed[field] = {"old": old_val, "new": new_val}
await create_operation_log(
db, current_user.id, current_user.username, "update", "permission",
permission_id, old_permission.name, changed if changed else None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return updated_permission
@router.delete("/{permission_id}")
async def delete_permission(
permission_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -228,6 +299,13 @@ async def delete_permission(
existing_permission = result.scalar_one_or_none()
if not existing_permission:
raise HTTPException(status_code=404, detail="权限不存在")
# 记录操作日志(删除前)
await create_operation_log(
db, current_user.id, current_user.username, "delete", "permission",
permission_id, existing_permission.name,
{"name": existing_permission.name, "code": existing_permission.code},
get_client_ip(request), get_user_agent(request),
)
# 1. 删除角色-权限关联表中的记录
from sqlalchemy import delete as sql_delete
@ -256,6 +334,7 @@ async def delete_permission(
@router.post("/assign", response_model=RolePermissionResponse)
async def assign_permissions_to_role(
data: RolePermissionAssign,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -276,6 +355,14 @@ async def assign_permissions_to_role(
# 更新角色的权限
role.permissions = permissions
# 记录操作日志
await create_operation_log(
db, current_user.id, current_user.username, "update", "role",
role.id, role.name,
{"permission_ids": data.permission_ids},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
await db.refresh(role)
@ -290,6 +377,7 @@ async def assign_permissions_to_role(
@router.get("/role/{role_id}", response_model=RolePermissionResponse)
async def get_role_permissions(
role_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -300,6 +388,14 @@ async def get_role_permissions(
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
role_id, role.name, {"type": "role_permissions"},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
# 返回角色权限信息
return RolePermissionResponse(
role_id=role.id,
@ -310,6 +406,7 @@ async def get_role_permissions(
@router.get("/user/me", response_model=RolePermissionResponse)
async def get_current_user_permissions(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -321,20 +418,20 @@ async def get_current_user_permissions(
.filter(Role.id == current_user.role_id)
)
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
# 获取用户有权限的权限编码集合
user_permission_codes = {perm.code for perm in role.permissions}
# 获取所有顶级模块和二级模块权限有route字段的
result = await db.execute(
select(Permission)
.filter(Permission.route.isnot(None))
)
all_menu_permissions = result.scalars().all()
# 过滤用户有权限的菜单
user_menu_permissions = []
for perm in all_menu_permissions:
@ -346,7 +443,7 @@ async def get_current_user_permissions(
break
if has_access:
user_menu_permissions.append(perm)
# 转换为字典,便于查找
perm_dict = {perm.id: {
"id": perm.id,
@ -359,7 +456,7 @@ async def get_current_user_permissions(
"updatetime": perm.updatetime,
"children": []
} for perm in user_menu_permissions}
# 构建树形结构
menu_tree = []
for perm_id, perm_data in perm_dict.items():
@ -371,6 +468,14 @@ async def get_current_user_permissions(
# 子节点,添加到父节点的 children
perm_dict[parentid]["children"].append(perm_data)
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
None, None, {"type": "user_permissions_menu"},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
# 返回用户权限信息和菜单
return RolePermissionResponse(
role_id=role.id,

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastcrud import FastCRUD
@ -8,6 +8,7 @@ from models import Role, User
from schemas import RoleCreate, RoleResponse
from middleware import get_current_user
from utils.like_filter import ilike_contains
from utils.log_utils import get_client_ip, get_user_agent, create_operation_log
router = APIRouter(prefix="/roles", tags=["roles"])
@ -18,13 +19,12 @@ role_crud = FastCRUD(Role, RoleResponse, RoleCreate)
@router.post("/", response_model=RoleResponse)
async def create_role(
role_data: RoleCreate,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""创建角色"""
# 检查角色名是否已存在
from sqlalchemy import select
result = await db.execute(select(Role).filter(Role.name == role_data.name))
existing_role = result.scalar_one_or_none()
if existing_role:
@ -38,18 +38,26 @@ async def create_role(
name=role_data.name,
creator=role_data.creator
)
db.add(new_role)
await db.flush() # 获取生成的ID
await db.refresh(new_role)
# 记录操作日志
await create_operation_log(
db, current_user.id, current_user.username, "create", "role",
new_role.id, new_role.name, {"name": role_data.name},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return RoleResponse.model_validate(new_role)
@router.post("/search", response_model=list[RoleResponse])
async def search_roles(
query: dict = {},
request: Request = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -61,12 +69,22 @@ async def search_roles(
stmt = stmt.where(cond)
result = await db.execute(stmt)
rows = result.scalars().all()
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "role",
None, None, {"filters": {k: v for k, v in query.items() if v}},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return [RoleResponse.model_validate(r) for r in rows]
@router.get("/{role_id}", response_model=RoleResponse)
async def get_role(
role_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -75,6 +93,15 @@ async def get_role(
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "role",
role_id, role.name, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return RoleResponse.model_validate(role)
@ -82,13 +109,18 @@ async def get_role(
async def update_role(
role_id: int,
role_data: RoleCreate,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""更新角色"""
# 检查角色名是否被其他角色使用
from sqlalchemy import select
# 获取原角色数据
result = await db.execute(select(Role).filter(Role.id == role_id))
old_role = result.scalar_one_or_none()
if not old_role:
raise HTTPException(status_code=404, detail="角色不存在")
# 检查角色名是否被其他角色使用
result = await db.execute(
select(Role).filter(Role.name == role_data.name, Role.id != role_id)
)
@ -98,29 +130,39 @@ async def update_role(
# 使用原生 SQLAlchemy 更新角色FastCRUD 有问题)
from sqlalchemy import update
stmt = (
update(Role)
.where(Role.id == role_id)
.values(name=role_data.name)
)
result = await db.execute(stmt)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="角色不存在")
# 记录操作日志
changed = {}
if old_role.name != role_data.name:
changed["name"] = {"old": old_role.name, "new": role_data.name}
await create_operation_log(
db, current_user.id, current_user.username, "update", "role",
role_id, old_role.name, changed if changed else None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
# 查询更新后的数据
result = await db.execute(select(Role).filter(Role.id == role_id))
updated_role = result.scalar_one_or_none()
return updated_role
@router.delete("/{role_id}")
async def delete_role(
role_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -139,6 +181,13 @@ async def delete_role(
status_code=400, detail=f"该角色正在被 {len(users)} 个用户使用,无法删除"
)
# 记录操作日志(删除前)
await create_operation_log(
db, current_user.id, current_user.username, "delete", "role",
role_id, existing_role.name, {"name": existing_role.name},
get_client_ip(request), get_user_agent(request),
)
# 使用FastCRUD删除角色
await role_crud.delete(db, id=role_id)
return {"message": "角色删除成功"}

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from fastcrud import FastCRUD
@ -16,6 +16,7 @@ from schemas import (
)
from middleware import get_current_user
from utils.like_filter import ilike_contains
from utils.log_utils import get_client_ip, get_user_agent, create_operation_log
router = APIRouter(prefix="/settings", tags=["settings"])
@ -30,6 +31,7 @@ UPLOAD_DIR.mkdir(exist_ok=True)
@router.post("/", response_model=SystemSettingResponse)
async def create_setting(
setting_data: SystemSettingCreate,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -50,20 +52,39 @@ async def create_setting(
)
db.add(db_setting)
await db.commit()
await db.flush()
await db.refresh(db_setting)
# 记录操作日志
await create_operation_log(
db, current_user.id, current_user.username, "create", "setting",
db_setting.id, db_setting.key,
{"key": setting_data.key, "type": setting_data.type},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return db_setting
@router.get("/", response_model=list[SystemSettingResponse])
async def get_settings(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取所有设置项"""
result = await db.execute(select(SystemSetting))
settings = result.scalars().all()
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "setting",
None, None, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return [SystemSettingResponse.model_validate(s) for s in settings]
@ -97,6 +118,7 @@ async def get_public_setting(
@router.get("/{setting_id}", response_model=SystemSettingResponse)
async def get_setting(
setting_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -104,12 +126,22 @@ async def get_setting(
setting = await setting_crud.get(db, id=setting_id)
if not setting:
raise HTTPException(status_code=404, detail="设置项不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "setting",
setting_id, setting.key, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return setting
@router.get("/key/{key}", response_model=SystemSettingResponse)
async def get_setting_by_key(
key: str,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -118,6 +150,15 @@ async def get_setting_by_key(
setting = result.scalar_one_or_none()
if not setting:
raise HTTPException(status_code=404, detail="设置项不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "setting",
setting.id, setting.key, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return setting
@ -125,10 +166,17 @@ async def get_setting_by_key(
async def update_setting(
setting_id: int,
setting_data: SystemSettingUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""更新设置项"""
# 获取原设置数据
result = await db.execute(select(SystemSetting).filter(SystemSetting.id == setting_id))
old_setting = result.scalar_one_or_none()
if not old_setting:
raise HTTPException(status_code=404, detail="设置项不存在")
# 检查设置键名是否被其他设置使用
if setting_data.key:
result = await db.execute(
@ -147,12 +195,28 @@ async def update_setting(
)
if not updated_setting:
raise HTTPException(status_code=404, detail="设置项不存在")
# 记录操作日志
changed = {}
for field in ["key", "value", "type", "description"]:
old_val = getattr(old_setting, field, None)
new_val = update_data.get(field) if field in update_data else old_val
if old_val != new_val:
changed[field] = {"old": old_val, "new": new_val}
await create_operation_log(
db, current_user.id, current_user.username, "update", "setting",
setting_id, old_setting.key, changed if changed else None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return updated_setting
@router.delete("/{setting_id}")
async def delete_setting(
setting_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -163,6 +227,14 @@ async def delete_setting(
if not existing_setting:
raise HTTPException(status_code=404, detail="设置项不存在")
# 记录操作日志(删除前)
await create_operation_log(
db, current_user.id, current_user.username, "delete", "setting",
setting_id, existing_setting.key,
{"key": existing_setting.key, "value": existing_setting.value},
get_client_ip(request), get_user_agent(request),
)
# 使用FastCRUD删除设置
deleted = await setting_crud.delete(db, id=setting_id)
return {"message": "设置项删除成功"}
@ -186,7 +258,7 @@ async def upload_image(
if file_size > 2 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过2MB")
chunk = await file.read(1024)
# 重置文件指针
await file.seek(0)

View File

@ -3,6 +3,7 @@ from .users.urls import router as users_router
from .roles.urls import router as roles_router
from .permissions.urls import router as permissions_router
from .settings.urls import router as settings_router
from .logs.urls import router as logs_router
# 创建主路由
api_router = APIRouter()
@ -11,4 +12,5 @@ api_router = APIRouter()
api_router.include_router(users_router)
api_router.include_router(roles_router)
api_router.include_router(permissions_router)
api_router.include_router(settings_router)
api_router.include_router(settings_router)
api_router.include_router(logs_router)

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
@ -19,10 +19,11 @@ from schemas import (
from utils.password import verify_password, get_password_hash
from utils.jwt import create_access_token
from config import app_settings
from utils.redis_client import get_redis, set_token_in_redis
from utils.redis_client import get_redis, set_token_in_redis, delete_token_from_redis
from utils.captcha import create_captcha, validate_captcha
from middleware import get_current_user
from utils.like_filter import ilike_contains
from utils.log_utils import get_client_ip, get_user_agent, save_login_log, create_operation_log
router = APIRouter(prefix="/users", tags=["users"])
@ -35,26 +36,31 @@ async def get_captcha(redis_conn: redis.Redis = Depends(get_redis)):
"""获取验证码图片"""
# 创建验证码
captcha_id, image_bytes = await create_captcha(redis_conn)
# 返回验证码图片同时在响应头中返回验证码ID
response = Response(content=image_bytes, media_type="image/png")
response.headers["X-Captcha-ID"] = captcha_id
return response
@router.post("/login", response_model=Token)
async def login(
user_data: UserLogin,
request: Request,
db: AsyncSession = Depends(get_db),
redis_conn: redis.Redis = Depends(get_redis),
):
"""用户登录"""
ip = get_client_ip(request)
ua = get_user_agent(request)
# 验证验证码
is_captcha_valid = await validate_captcha(
redis_conn, user_data.captcha_id, user_data.captcha_code
)
if not is_captcha_valid:
await save_login_log(db, user_data.username, ip, ua, "failure", "验证码错误或已过期")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="验证码错误或已过期",
@ -67,6 +73,7 @@ async def login(
# 验证用户是否存在且密码正确
if not user or not verify_password(user_data.password, user.password_hash):
await save_login_log(db, user_data.username, ip, ua, "failure", "用户名或密码错误")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
@ -88,12 +95,16 @@ async def login(
user.lastlogintime = datetime.utcnow()
await db.commit()
# 记录登录成功日志
await save_login_log(db, user.username, ip, ua, "success", None)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/", response_model=UserResponse)
async def create_user(
user_data: UserCreateRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -118,12 +129,23 @@ async def create_user(
created_user = await user_crud.create(
db, user_data_create, schema_to_select=UserResponse, return_as_model=True
)
# 记录操作日志
await create_operation_log(
db, current_user.id, current_user.username, "create", "user",
created_user.id, created_user.username,
{"username": user_data.username, "nickname": user_data.nickname},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return created_user
@router.post("/search", response_model=list[UserResponse])
async def search_users(
query: dict = {},
request: Request = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -144,18 +166,39 @@ async def search_users(
stmt = stmt.where(and_(*filters))
result = await db.execute(stmt)
rows = result.scalars().all()
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "user",
None, None, {"filters": {k: v for k, v in query.items() if v}},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return [UserResponse.model_validate(u) for u in rows]
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: User = Depends(get_current_user)):
async def get_current_user_info(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取当前用户信息(须声明在 /{user_id} 之前,否则会被当成 id"""
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "user",
current_user.id, current_user.username, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return current_user
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -163,6 +206,15 @@ async def get_user(
user = await user_crud.get(db, id=user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "user",
user_id, user.username, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return user
@ -170,10 +222,17 @@ async def get_user(
async def update_user(
user_id: int,
user_data: UserUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""更新用户"""
# 获取原用户数据用于记录变更
result = await db.execute(select(User).filter(User.id == user_id))
old_user = result.scalar_one_or_none()
if not old_user:
raise HTTPException(status_code=404, detail="用户不存在")
# 检查用户名是否被其他用户使用
result = await db.execute(
select(User).filter(User.username == user_data.username, User.id != user_id)
@ -199,12 +258,28 @@ async def update_user(
)
if not updated_user:
raise HTTPException(status_code=404, detail="用户不存在")
# 记录操作日志(计算变更字段)
changed = {}
for field in ["username", "nickname", "email", "phone", "role_id"]:
old_val = getattr(old_user, field, None)
new_val = update_data.get(field)
if old_val != new_val:
changed[field] = {"old": old_val, "new": new_val}
await create_operation_log(
db, current_user.id, current_user.username, "update", "user",
user_id, old_user.username, changed if changed else None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return updated_user
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -219,6 +294,13 @@ async def delete_user(
elif existing_user.role_id == 1:
raise HTTPException(status_code=400, detail="不能删除管理员用户")
else:
# 记录操作日志(删除前记录被删用户信息)
await create_operation_log(
db, current_user.id, current_user.username, "delete", "user",
user_id, existing_user.username,
{"username": existing_user.username, "nickname": existing_user.nickname},
get_client_ip(request), get_user_agent(request),
)
# 使用FastCRUD删除用户
deleted = await user_crud.delete(db, id=user_id)
return {"message": "用户删除成功"}
@ -226,11 +308,18 @@ async def delete_user(
@router.post("/logout")
async def logout(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
redis_conn: redis.Redis = Depends(get_redis),
):
"""用户登出"""
from utils.redis_client import delete_token_from_redis
await delete_token_from_redis(redis_conn, current_user.id)
# 记录登出日志
await save_login_log(
db, current_user.username,
get_client_ip(request), get_user_agent(request),
"success", None,
)
return {"message": "登出成功"}

View File

@ -14,7 +14,8 @@ async def init_permissions(db: AsyncSession):
# ========================================
{"name": "权限管理", "code": "perm", "parentid": 0, "route": "/admin/permissions", "description": "权限管理模块"},
{"name": "系统设置", "code": "system", "parentid": 0, "route": "/admin/settings", "description": "系统设置模块"},
{"name": "日志管理", "code": "log", "parentid": 0, "route": "/admin/logs", "description": "日志管理模块"},
# ========================================
# 权限管理 -> 二级子模块
# ========================================
@ -23,51 +24,62 @@ async def init_permissions(db: AsyncSession):
{"name": "菜单管理", "code": "perm:menu", "parentid": 1, "route": "/admin/menu", "description": "菜单管理子模块"},
# ========================================
# 用户管理操作parentid=3
# 用户管理操作parentid=4
# ========================================
{"name": "查看列表", "code": "perm:user:list", "parentid": 3, "description": "查看用户列表"},
{"name": "查看详情", "code": "perm:user:detail", "parentid": 3, "description": "查看用户详情"},
{"name": "搜索", "code": "perm:user:search", "parentid": 3, "description": "搜索用户"},
{"name": "创建", "code": "perm:user:create", "parentid": 3, "description": "创建新用户"},
{"name": "编辑", "code": "perm:user:edit", "parentid": 3, "description": "编辑用户信息"},
{"name": "删除", "code": "perm:user:delete", "parentid": 3, "description": "删除用户"},
{"name": "导出", "code": "perm:user:export", "parentid": 3, "description": "导出用户数据"},
{"name": "批量操作", "code": "perm:user:batch", "parentid": 3, "description": "批量操作用户"},
{"name": "查看列表", "code": "perm:user:list", "parentid": 4, "description": "查看用户列表"},
{"name": "查看详情", "code": "perm:user:detail", "parentid": 4, "description": "查看用户详情"},
{"name": "搜索", "code": "perm:user:search", "parentid": 4, "description": "搜索用户"},
{"name": "创建", "code": "perm:user:create", "parentid": 4, "description": "创建新用户"},
{"name": "编辑", "code": "perm:user:edit", "parentid": 4, "description": "编辑用户信息"},
{"name": "删除", "code": "perm:user:delete", "parentid": 4, "description": "删除用户"},
{"name": "导出", "code": "perm:user:export", "parentid": 4, "description": "导出用户数据"},
{"name": "批量操作", "code": "perm:user:batch", "parentid": 4, "description": "批量操作用户"},
# ========================================
# 角色管理操作parentid=4
# 角色管理操作parentid=5
# ========================================
{"name": "查看列表", "code": "perm:role:list", "parentid": 4, "description": "查看角色列表"},
{"name": "查看详情", "code": "perm:role:detail", "parentid": 4, "description": "查看角色详情"},
{"name": "搜索", "code": "perm:role:search", "parentid": 4, "description": "搜索角色"},
{"name": "创建", "code": "perm:role:create", "parentid": 4, "description": "创建新角色"},
{"name": "编辑", "code": "perm:role:edit", "parentid": 4, "description": "编辑角色信息"},
{"name": "删除", "code": "perm:role:delete", "parentid": 4, "description": "删除角色"},
{"name": "导出", "code": "perm:role:export", "parentid": 4, "description": "导出角色数据"},
{"name": "分配权限", "code": "perm:role:assign", "parentid": 4, "description": "为角色分配权限"},
{"name": "查看列表", "code": "perm:role:list", "parentid": 5, "description": "查看角色列表"},
{"name": "查看详情", "code": "perm:role:detail", "parentid": 5, "description": "查看角色详情"},
{"name": "搜索", "code": "perm:role:search", "parentid": 5, "description": "搜索角色"},
{"name": "创建", "code": "perm:role:create", "parentid": 5, "description": "创建新角色"},
{"name": "编辑", "code": "perm:role:edit", "parentid": 5, "description": "编辑角色信息"},
{"name": "删除", "code": "perm:role:delete", "parentid": 5, "description": "删除角色"},
{"name": "导出", "code": "perm:role:export", "parentid": 5, "description": "导出角色数据"},
{"name": "分配权限", "code": "perm:role:assign", "parentid": 5, "description": "为角色分配权限"},
# ========================================
# 权限配置操作parentid=5
# 菜单管理操作parentid=6
# ========================================
{"name": "查看列表", "code": "perm:menu:list", "parentid": 5, "description": "查看权限列表"},
{"name": "查看详情", "code": "perm:menu:detail", "parentid": 5, "description": "查看权限详情"},
{"name": "搜索", "code": "perm:menu:search", "parentid": 5, "description": "搜索权限"},
{"name": "创建", "code": "perm:menu:create", "parentid": 5, "description": "创建新权限"},
{"name": "编辑", "code": "perm:menu:edit", "parentid": 5, "description": "编辑权限信息"},
{"name": "删除", "code": "perm:menu:delete", "parentid": 5, "description": "删除权限"},
{"name": "导出", "code": "perm:menu:export", "parentid": 5, "description": "岀权限数据"},
{"name": "查看列表", "code": "perm:menu:list", "parentid": 6, "description": "查看菜单列表"},
{"name": "查看详情", "code": "perm:menu:detail", "parentid": 6, "description": "查看菜单详情"},
{"name": "搜索", "code": "perm:menu:search", "parentid": 6, "description": "搜索菜单"},
{"name": "创建", "code": "perm:menu:create", "parentid": 6, "description": "创建新菜单"},
{"name": "编辑", "code": "perm:menu:edit", "parentid": 6, "description": "编辑菜单信息"},
{"name": "删除", "code": "perm:menu:delete", "parentid": 6, "description": "删除菜单"},
{"name": "导出", "code": "perm:menu:export", "parentid": 6, "description": "出菜单数据"},
# ========================================
# 系统设置 -> 基础配置操作parentid=6
# 日志管理 -> 子模块parentid=3
# ========================================
{"name": "查看配置", "code": "system:basic:view", "parentid": 6, "description": "查看基础配置"},
{"name": "修改配置", "code": "system:basic:edit", "parentid": 6, "description": "修改基础配置"},
{"name": "登录日志", "code": "log:login", "parentid": 3, "route": "/admin/login-logs", "description": "登录日志查看"},
{"name": "操作日志", "code": "log:operation", "parentid": 3, "route": "/admin/operation-logs", "description": "操作日志查看"},
# ========================================
# 系统设置 -> 高级配置操作parentid=7
# 登录日志操作parentid=30
# ========================================
{"name": "查看配置", "code": "system:advanced:view", "parentid": 7, "description": "查看高级配置"},
{"name": "修改配置", "code": "system:advanced:edit", "parentid": 7, "description": "修改高级配置"},
{"name": "查看列表", "code": "log:login:list", "parentid": 30, "description": "查看登录日志列表"},
{"name": "搜索", "code": "log:login:search", "parentid": 30, "description": "搜索登录日志"},
# ========================================
# 操作日志操作parentid=31
# ========================================
{"name": "查看列表", "code": "log:operation:list", "parentid": 31, "description": "查看操作日志列表"},
{"name": "搜索", "code": "log:operation:search", "parentid": 31, "description": "搜索操作日志"},
# ========================================
# 日志清理parentid=3
# ========================================
{"name": "清理日志", "code": "log:clean", "parentid": 3, "description": "清理旧日志"},
]
# 检查现有权限
@ -351,7 +363,7 @@ async def init_system_settings(db: AsyncSession):
},
{
"key": "页脚信息",
"value": "© 2026 图书管理系统 版权所有 | 技术支持:强蕊科技",
"value": "Copyright © 2026 图书管理系统 版权所有 | 技术支持:强蕊科技",
"type": "string",
"description": "页脚信息"
},

View File

@ -8,6 +8,7 @@ from typing import Callable
from database import get_db
from utils.jwt import decode_token
from utils.redis_client import get_redis, check_token_in_redis
from models import User
security = HTTPBearer()
@ -41,9 +42,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
# 查找用户
from models import User
result = await db.execute(select(User).filter(User.id == user_id))
user = result.scalar_one_or_none()
if not user:

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func, Table
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func, Table, Index, Text
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
@ -97,3 +97,44 @@ class SystemSetting(Base):
updatetime = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class LoginLog(Base):
"""登录日志"""
__tablename__ = "login_log"
id = Column(Integer, primary_key=True, index=True, comment="日志ID")
username = Column(String(50), nullable=False, comment="用户名")
login_time = Column(DateTime, default=datetime.utcnow, comment="登录时间")
ip_address = Column(String(50), nullable=True, comment="IP地址")
user_agent = Column(String(500), nullable=True, comment="User-Agent")
status = Column(String(20), nullable=False, comment="状态: success/failure")
failure_reason = Column(String(255), nullable=True, comment="失败原因")
Index("ix_login_log_login_time", LoginLog.login_time)
Index("ix_login_log_username", LoginLog.username)
Index("ix_login_log_status", LoginLog.status)
class OperationLog(Base):
"""操作日志"""
__tablename__ = "operation_log"
id = Column(Integer, primary_key=True, index=True, comment="日志ID")
user_id = Column(Integer, nullable=False, comment="用户ID")
username = Column(String(50), nullable=False, comment="用户名")
action_type = Column(String(20), nullable=False, comment="操作类型: create/update/delete")
target_type = Column(String(50), nullable=False, comment="目标类型: user/role/permission/setting")
target_id = Column(Integer, nullable=True, comment="目标ID")
target_name = Column(String(100), nullable=True, comment="目标名称")
detail = Column(Text, nullable=True, comment="变更详情(JSON)")
ip_address = Column(String(50), nullable=True, comment="IP地址")
user_agent = Column(String(500), nullable=True, comment="User-Agent")
createtime = Column(DateTime, default=datetime.utcnow, comment="创建时间")
Index("ix_operation_log_createtime", OperationLog.createtime)
Index("ix_operation_log_username", OperationLog.username)
Index("ix_operation_log_action_type", OperationLog.action_type)
Index("ix_operation_log_target_type", OperationLog.target_type)

View File

@ -227,3 +227,62 @@ class SystemSettingResponse(BaseModel):
class Config:
from_attributes = True
class LoginLogResponse(BaseModel):
"""登录日志响应模型"""
id: int = Field(..., description="日志ID")
username: str = Field(..., description="用户名")
login_time: datetime = Field(..., description="登录时间")
ip_address: Optional[str] = Field(None, description="IP地址")
user_agent: Optional[str] = Field(None, description="User-Agent")
status: str = Field(..., description="状态: success/failure")
failure_reason: Optional[str] = Field(None, description="失败原因")
class Config:
from_attributes = True
class LoginLogSearch(BaseModel):
"""登录日志搜索模型"""
username: Optional[str] = Field(None, description="用户名(模糊搜索)")
status: Optional[str] = Field(None, description="状态")
start_time: Optional[datetime] = Field(None, description="开始时间")
end_time: Optional[datetime] = Field(None, description="结束时间")
class LoginLogCleanRequest(BaseModel):
"""登录日志清理请求模型"""
before_date: datetime = Field(..., description="清理此日期之前的日志")
class OperationLogResponse(BaseModel):
"""操作日志响应模型"""
id: int = Field(..., description="日志ID")
user_id: int = Field(..., description="用户ID")
username: str = Field(..., description="用户名")
action_type: str = Field(..., description="操作类型: create/update/delete")
target_type: str = Field(..., description="目标类型: user/role/permission/setting")
target_id: Optional[int] = Field(None, description="目标ID")
target_name: Optional[str] = Field(None, description="目标名称")
detail: Optional[str] = Field(None, description="变更详情(JSON)")
ip_address: Optional[str] = Field(None, description="IP地址")
user_agent: Optional[str] = Field(None, description="User-Agent")
createtime: datetime = Field(..., description="创建时间")
class Config:
from_attributes = True
class OperationLogSearch(BaseModel):
"""操作日志搜索模型"""
username: Optional[str] = Field(None, description="用户名(模糊搜索)")
action_type: Optional[str] = Field(None, description="操作类型")
target_type: Optional[str] = Field(None, description="目标类型")
start_time: Optional[datetime] = Field(None, description="开始时间")
end_time: Optional[datetime] = Field(None, description="结束时间")

View File

@ -0,0 +1,70 @@
import json
from datetime import datetime
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from models import LoginLog, OperationLog
def get_client_ip(request: Request) -> str:
"""从请求中提取客户端IP地址支持代理转发"""
xff = request.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
xri = request.headers.get("X-Real-IP")
if xri:
return xri.strip()
return request.client.host if request.client else "unknown"
def get_user_agent(request: Request) -> str:
"""从请求中提取 User-Agent"""
return request.headers.get("User-Agent", "")
async def save_login_log(
db: AsyncSession,
username: str,
ip_address: str,
user_agent: str,
status: str,
failure_reason: str | None = None,
):
"""保存登录日志(独立提交,确保失败记录不丢失)"""
log = LoginLog(
username=username,
login_time=datetime.utcnow(),
ip_address=ip_address,
user_agent=user_agent,
status=status,
failure_reason=failure_reason,
)
db.add(log)
await db.commit()
async def create_operation_log(
db: AsyncSession,
user_id: int,
username: str,
action_type: str,
target_type: str,
target_id: int | None = None,
target_name: str | None = None,
detail: dict | None = None,
ip_address: str = "",
user_agent: str = "",
):
"""创建操作日志(不提交,由调用方事务统一提交)"""
log = OperationLog(
user_id=user_id,
username=username,
action_type=action_type,
target_type=target_type,
target_id=target_id,
target_name=target_name,
detail=json.dumps(detail, ensure_ascii=False) if detail else None,
ip_address=ip_address,
user_agent=user_agent,
createtime=datetime.utcnow(),
)
db.add(log)

View File

@ -68,7 +68,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, markRaw } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Key, Setting, Folder } from "@element-plus/icons-vue";
import { Key, Setting, Folder, Document } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { useAuthStore } from "@/modules/admin/stores/auth";
import axios from "axios";
@ -87,6 +87,7 @@ const pageTitle = computed(() => (route.meta.title as string) || "");
const iconMap: Record<string, any> = {
"perm": markRaw(Key),
"system": markRaw(Setting),
"log": markRaw(Document),
};
function getIcon(code: string) {

View File

@ -0,0 +1,83 @@
<template>
<fs-page>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
<!-- 清理旧日志对话框 -->
<el-dialog v-model="cleanDlgVisible" title="清理旧日志" width="420px">
<el-form>
<el-form-item label="清理此日期之前的日志">
<el-date-picker
v-model="cleanBeforeDate"
type="datetime"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="cleanDlgVisible = false">取消</el-button>
<el-button
type="warning"
:loading="cleanLoading"
@click="onClean"
>确认清理</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import { ElMessage, ElMessageBox } from "element-plus";
import dayjs from "dayjs";
import { http } from "@/modules/admin/api/http";
import { buildLoginLogCrudOptions } from "./login-log-crud";
const { crudBinding, crudRef, crudExpose } = useFs({
createCrudOptions: buildLoginLogCrudOptions({
onClean: () => {
cleanDlgVisible.value = true;
},
}),
});
const cleanDlgVisible = ref(false);
const cleanBeforeDate = ref<Date>();
const cleanLoading = ref(false);
async function onClean() {
if (!cleanBeforeDate.value) {
ElMessage.warning("请选择要清理的日期");
return;
}
try {
await ElMessageBox.confirm(
`确定要清理 ${dayjs(cleanBeforeDate.value).format("YYYY-MM-DD HH:mm:ss")} 之前的所有登录日志吗?此操作不可撤销。`,
"确认清理",
{ confirmButtonText: "确认清理", cancelButtonText: "取消", type: "warning" }
);
} catch {
return;
}
cleanLoading.value = true;
try {
const { data } = await http.delete("/login-logs/clean", {
data: { before_date: cleanBeforeDate.value.toISOString() },
});
ElMessage.success(data.message || "清理成功");
cleanDlgVisible.value = false;
crudExpose.doRefresh();
} catch (error) {
ElMessage.error("清理失败");
} finally {
cleanLoading.value = false;
}
}
onMounted(() => {
crudExpose.doRefresh();
});
</script>

View File

@ -0,0 +1,83 @@
<template>
<fs-page>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
<!-- 清理旧日志对话框 -->
<el-dialog v-model="cleanDlgVisible" title="清理旧日志" width="420px">
<el-form>
<el-form-item label="清理此日期之前的日志">
<el-date-picker
v-model="cleanBeforeDate"
type="datetime"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="cleanDlgVisible = false">取消</el-button>
<el-button
type="warning"
:loading="cleanLoading"
@click="onClean"
>确认清理</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import { ElMessage, ElMessageBox } from "element-plus";
import dayjs from "dayjs";
import { http } from "@/modules/admin/api/http";
import { buildOperationLogCrudOptions } from "./operation-log-crud";
const { crudBinding, crudRef, crudExpose } = useFs({
createCrudOptions: buildOperationLogCrudOptions({
onClean: () => {
cleanDlgVisible.value = true;
},
}),
});
const cleanDlgVisible = ref(false);
const cleanBeforeDate = ref<Date>();
const cleanLoading = ref(false);
async function onClean() {
if (!cleanBeforeDate.value) {
ElMessage.warning("请选择要清理的日期");
return;
}
try {
await ElMessageBox.confirm(
`确定要清理 ${dayjs(cleanBeforeDate.value).format("YYYY-MM-DD HH:mm:ss")} 之前的所有操作日志吗?此操作不可撤销。`,
"确认清理",
{ confirmButtonText: "确认清理", cancelButtonText: "取消", type: "warning" }
);
} catch {
return;
}
cleanLoading.value = true;
try {
const { data } = await http.delete("/operation-logs/clean", {
data: { before_date: cleanBeforeDate.value.toISOString() },
});
ElMessage.success(data.message || "清理成功");
cleanDlgVisible.value = false;
crudExpose.doRefresh();
} catch (error) {
ElMessage.error("清理失败");
} finally {
cleanLoading.value = false;
}
}
onMounted(() => {
crudExpose.doRefresh();
});
</script>

View File

@ -0,0 +1,124 @@
import type { CreateCrudOptions } from "@fast-crud/fast-crud";
import { dict } from "@fast-crud/fast-crud";
import dayjs from "dayjs";
import { http } from "@/modules/admin/api/http";
export type LoginLogRow = {
id: number;
username: string;
login_time: string;
ip_address: string;
user_agent: string;
status: string;
failure_reason: string | null;
};
export function buildLoginLogCrudOptions(opts: {
onClean: () => void;
}): CreateCrudOptions<LoginLogRow> {
return () => ({
crudOptions: {
pagination: { show: false },
search: {
show: true,
options: { layout: "inline" },
expand: false,
},
request: {
pageRequest: async (query) => {
const { data } = await http.post<LoginLogRow[]>(
"/login-logs/search",
query
);
return data;
},
transformQuery: ({ form }) => {
const params: Record<string, unknown> = {};
if (form?.username) params.username = form.username;
if (form?.status) params.status = form.status;
return params;
},
transformRes: ({ res }) => {
const records = Array.isArray(res) ? res : [];
return {
currentPage: 1,
pageSize: Math.max(records.length, 1),
total: records.length,
records,
};
},
},
rowHandle: { show: false },
toolbar: {
buttons: {
clean: {
text: "清理旧日志",
type: "warning",
click: () => opts.onClean(),
},
},
},
columns: {
id: {
title: "ID",
key: "id",
type: "number",
column: { width: 70 },
form: { show: false },
},
username: {
title: "用户名",
key: "username",
type: "text",
search: { show: true },
},
status: {
title: "状态",
key: "status",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ value: "success", label: "成功" },
{ value: "failure", label: "失败" },
],
value: "value",
label: "label",
}),
column: { width: 80 },
},
failure_reason: {
title: "失败原因",
key: "failure_reason",
type: "text",
column: { width: 160 },
},
ip_address: {
title: "IP地址",
key: "ip_address",
type: "text",
column: { width: 150 },
},
user_agent: {
title: "User-Agent",
key: "user_agent",
type: "text",
column: { width: 300 },
},
login_time: {
title: "登录时间",
key: "login_time",
type: "text",
column: { width: 170 },
form: { show: false },
valueBuilder({ value, row, key }) {
if (!row || value == null) return;
(row as Record<string, unknown>)[key as string] = dayjs(
value
).format("YYYY-MM-DD HH:mm:ss");
},
},
},
},
});
}

View File

@ -0,0 +1,168 @@
import type { CreateCrudOptions } from "@fast-crud/fast-crud";
import { dict } from "@fast-crud/fast-crud";
import dayjs from "dayjs";
import { http } from "@/modules/admin/api/http";
export type OperationLogRow = {
id: number;
user_id: number;
username: string;
action_type: string;
target_type: string;
target_id: number | null;
target_name: string | null;
detail: string | null;
ip_address: string;
user_agent: string;
createtime: string;
};
export function buildOperationLogCrudOptions(opts: {
onClean: () => void;
}): CreateCrudOptions<OperationLogRow> {
return () => ({
crudOptions: {
pagination: { show: false },
search: {
show: true,
options: { layout: "inline" },
expand: false,
},
request: {
pageRequest: async (query) => {
const { data } = await http.post<OperationLogRow[]>(
"/operation-logs/search",
query
);
return data;
},
transformQuery: ({ form }) => {
const params: Record<string, unknown> = {};
if (form?.username) params.username = form.username;
if (form?.action_type) params.action_type = form.action_type;
if (form?.target_type) params.target_type = form.target_type;
return params;
},
transformRes: ({ res }) => {
const records = Array.isArray(res) ? res : [];
return {
currentPage: 1,
pageSize: Math.max(records.length, 1),
total: records.length,
records,
};
},
},
rowHandle: { show: false },
toolbar: {
buttons: {
clean: {
text: "清理旧日志",
type: "warning",
click: () => opts.onClean(),
},
},
},
columns: {
id: {
title: "ID",
key: "id",
type: "number",
column: { width: 70 },
form: { show: false },
},
user_id: {
title: "用户ID",
key: "user_id",
type: "number",
column: { width: 80 },
form: { show: false },
},
username: {
title: "操作人",
key: "username",
type: "text",
search: { show: true },
},
action_type: {
title: "操作类型",
key: "action_type",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ value: "create", label: "创建" },
{ value: "update", label: "更新" },
{ value: "delete", label: "删除" },
],
value: "value",
label: "label",
}),
column: { width: 90 },
},
target_type: {
title: "目标类型",
key: "target_type",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ value: "user", label: "用户" },
{ value: "role", label: "角色" },
{ value: "permission", label: "权限" },
{ value: "setting", label: "系统设置" },
],
value: "value",
label: "label",
}),
column: { width: 100 },
},
target_name: {
title: "目标名称",
key: "target_name",
type: "text",
column: { width: 140 },
},
target_id: {
title: "目标ID",
key: "target_id",
type: "number",
column: { width: 80 },
form: { show: false },
},
detail: {
title: "变更详情",
key: "detail",
type: "text",
column: { width: 200 },
form: { show: false },
},
ip_address: {
title: "IP地址",
key: "ip_address",
type: "text",
column: { width: 150 },
},
user_agent: {
title: "User-Agent",
key: "user_agent",
type: "text",
column: { width: 300 },
},
createtime: {
title: "操作时间",
key: "createtime",
type: "text",
column: { width: 170 },
form: { show: false },
valueBuilder({ value, row, key }) {
if (!row || value == null) return;
(row as Record<string, unknown>)[key as string] = dayjs(
value
).format("YYYY-MM-DD HH:mm:ss");
},
},
},
},
});
}

View File

@ -9,6 +9,7 @@ export type PermissionRow = {
name: string;
code: string;
parentid: number;
route?: string | null;
description?: string | null;
createtime: string;
updatetime: string;
@ -134,6 +135,15 @@ export const createPermissionCrudOptions: CreateCrudOptions<PermissionRow> = ()
type: "textarea",
column: { show: false },
},
route: {
title: "路由",
key: "route",
type: "text",
column: { width: 200 },
form: {
placeholder: "如:/admin/users",
},
},
createtime: {
title: "创建时间",
key: "createtime",

View File

@ -52,6 +52,20 @@ const router = createRouter({
component: () =>
import("@/modules/admin/views/settings/SettingManage.vue"),
},
{
path: "login-logs",
name: "admin-login-logs",
meta: { title: "登录日志", permission: "log:" },
component: () =>
import("@/modules/admin/views/logs/LoginLogManage.vue"),
},
{
path: "operation-logs",
name: "admin-operation-logs",
meta: { title: "操作日志", permission: "log:" },
component: () =>
import("@/modules/admin/views/logs/OperationLogManage.vue"),
},
],
},
],