diff --git a/backend/auth/apps/logs/__init__.py b/backend/auth/apps/logs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auth/apps/logs/urls.py b/backend/auth/apps/logs/urls.py new file mode 100644 index 0000000..a5af432 --- /dev/null +++ b/backend/auth/apps/logs/urls.py @@ -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, + } diff --git a/backend/auth/apps/permissions/urls.py b/backend/auth/apps/permissions/urls.py index 9d173f0..f9fec4e 100644 --- a/backend/auth/apps/permissions/urls.py +++ b/backend/auth/apps/permissions/urls.py @@ -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, diff --git a/backend/auth/apps/roles/urls.py b/backend/auth/apps/roles/urls.py index 77a5b95..40082bc 100644 --- a/backend/auth/apps/roles/urls.py +++ b/backend/auth/apps/roles/urls.py @@ -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": "角色删除成功"} diff --git a/backend/auth/apps/settings/urls.py b/backend/auth/apps/settings/urls.py index 2ccab61..810cf5e 100644 --- a/backend/auth/apps/settings/urls.py +++ b/backend/auth/apps/settings/urls.py @@ -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) diff --git a/backend/auth/apps/urls.py b/backend/auth/apps/urls.py index f48fa99..899f694 100644 --- a/backend/auth/apps/urls.py +++ b/backend/auth/apps/urls.py @@ -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) \ No newline at end of file +api_router.include_router(settings_router) +api_router.include_router(logs_router) \ No newline at end of file diff --git a/backend/auth/apps/users/urls.py b/backend/auth/apps/users/urls.py index 8dad310..13cfc1b 100644 --- a/backend/auth/apps/users/urls.py +++ b/backend/auth/apps/users/urls.py @@ -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": "登出成功"} diff --git a/backend/auth/init_data.py b/backend/auth/init_data.py index d5e648b..76dc928 100644 --- a/backend/auth/init_data.py +++ b/backend/auth/init_data.py @@ -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": "页脚信息" }, diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py index fed45b6..ef7e6bf 100644 --- a/backend/auth/middleware.py +++ b/backend/auth/middleware.py @@ -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: diff --git a/backend/auth/models.py b/backend/auth/models.py index dacd443..ccae4e4 100644 --- a/backend/auth/models.py +++ b/backend/auth/models.py @@ -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) \ No newline at end of file diff --git a/backend/auth/schemas.py b/backend/auth/schemas.py index c6e7f59..3185148 100644 --- a/backend/auth/schemas.py +++ b/backend/auth/schemas.py @@ -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="结束时间") \ No newline at end of file diff --git a/backend/auth/utils/log_utils.py b/backend/auth/utils/log_utils.py new file mode 100644 index 0000000..20d5605 --- /dev/null +++ b/backend/auth/utils/log_utils.py @@ -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) diff --git a/frontend/src/modules/admin/layout/AdminLayout.vue b/frontend/src/modules/admin/layout/AdminLayout.vue index 5d151bb..a45057d 100644 --- a/frontend/src/modules/admin/layout/AdminLayout.vue +++ b/frontend/src/modules/admin/layout/AdminLayout.vue @@ -68,7 +68,7 @@ diff --git a/frontend/src/modules/admin/views/logs/OperationLogManage.vue b/frontend/src/modules/admin/views/logs/OperationLogManage.vue new file mode 100644 index 0000000..f1fe591 --- /dev/null +++ b/frontend/src/modules/admin/views/logs/OperationLogManage.vue @@ -0,0 +1,83 @@ + + + diff --git a/frontend/src/modules/admin/views/logs/login-log-crud.ts b/frontend/src/modules/admin/views/logs/login-log-crud.ts new file mode 100644 index 0000000..a651b67 --- /dev/null +++ b/frontend/src/modules/admin/views/logs/login-log-crud.ts @@ -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 { + return () => ({ + crudOptions: { + pagination: { show: false }, + search: { + show: true, + options: { layout: "inline" }, + expand: false, + }, + request: { + pageRequest: async (query) => { + const { data } = await http.post( + "/login-logs/search", + query + ); + return data; + }, + transformQuery: ({ form }) => { + const params: Record = {}; + 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)[key as string] = dayjs( + value + ).format("YYYY-MM-DD HH:mm:ss"); + }, + }, + }, + }, + }); +} diff --git a/frontend/src/modules/admin/views/logs/operation-log-crud.ts b/frontend/src/modules/admin/views/logs/operation-log-crud.ts new file mode 100644 index 0000000..cf7086e --- /dev/null +++ b/frontend/src/modules/admin/views/logs/operation-log-crud.ts @@ -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 { + return () => ({ + crudOptions: { + pagination: { show: false }, + search: { + show: true, + options: { layout: "inline" }, + expand: false, + }, + request: { + pageRequest: async (query) => { + const { data } = await http.post( + "/operation-logs/search", + query + ); + return data; + }, + transformQuery: ({ form }) => { + const params: Record = {}; + 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)[key as string] = dayjs( + value + ).format("YYYY-MM-DD HH:mm:ss"); + }, + }, + }, + }, + }); +} diff --git a/frontend/src/modules/admin/views/permissions/permission-crud.ts b/frontend/src/modules/admin/views/permissions/permission-crud.ts index f7481ec..66603d1 100644 --- a/frontend/src/modules/admin/views/permissions/permission-crud.ts +++ b/frontend/src/modules/admin/views/permissions/permission-crud.ts @@ -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 = () type: "textarea", column: { show: false }, }, + route: { + title: "路由", + key: "route", + type: "text", + column: { width: 200 }, + form: { + placeholder: "如:/admin/users", + }, + }, createtime: { title: "创建时间", key: "createtime", diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 9429df4..bda0c55 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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"), + }, ], }, ],