From d01b3a5a5143cd0de9590050657029708095628c Mon Sep 17 00:00:00 2001 From: dayezi <1372755472@qq.com> Date: Wed, 6 Nov 2024 21:36:01 +0800 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=90=9E=20fix(somebugs):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=B8=80=E4=BA=9B=E5=B7=B2=E7=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复部门的人员显示问题 2. 修改各个表格的默认行数规格 3. 新增部门页面展示部门的人数且设定部门人数不为0时部门无法删除 4. 修改部门管理页面默认为折叠状态 5. 新增github工作流程,发布版本时推送最新docker镜像到仓库 --- .github/workflows/docker-image.yml | 49 ++++++++++++++++--- README.md | 4 +- backend/app/api/v1/admin/depart.py | 11 +++-- backend/app/core/crud.py | 4 +- frontend/src/utils/tree.ts | 19 ++++++- .../views/admin/Approval/panels/borrowing.vue | 6 +-- .../views/admin/Approval/panels/returned.vue | 6 +-- .../views/admin/Approval/panels/returning.vue | 6 +-- frontend/src/views/admin/MaterialChecked.vue | 6 +-- frontend/src/views/admin/MaterialMeta.vue | 6 ++- frontend/src/views/admin/OperationLogs.vue | 5 +- frontend/src/views/hooks.ts | 2 + .../views/superAdmin/UserManagement/index.vue | 2 +- .../superAdmin/UserManagement/utils/hook.tsx | 8 +-- .../superAdmin/departManagement/index.vue | 2 +- .../departManagement/utils/hook.tsx | 19 +++++-- .../superAdmin/roleManagement/utils/hook.tsx | 7 +-- 17 files changed, 116 insertions(+), 46 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 32a9a6d..06941ed 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,13 +1,48 @@ -name: Docker Image CI +name: 创建并发布Docker镜像 on: - push: - branches: [ "master" ] + release: + types: [published] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} jobs: - build: + build-and-push-image: runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + # steps: - - uses: actions/checkout@v3 - - name: Build the Docker image - run: docker build . --file Dockerfile --tag my-image-name:$(date +%s) + - name: Checkout repository + uses: actions/checkout@v4 + - name: 登录docker仓库 + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: 提取Docker的元数据:tags,labels + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + - name: 构建并发布Docker镜像 + id: push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }}, ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + labels: ${{ steps.meta.outputs.labels }} + + - name: 生成工件证明 + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true \ No newline at end of file diff --git a/README.md b/README.md index 406db8f..adfb59b 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ ## 安装 -构建镜像 +拉取镜像 ```bash -docker build -t material-manager:0.0.8 . +docker pull ghcr.io/pylover7/material ``` 启动容器 diff --git a/backend/app/api/v1/admin/depart.py b/backend/app/api/v1/admin/depart.py index 1f87014..4181cf3 100644 --- a/backend/app/api/v1/admin/depart.py +++ b/backend/app/api/v1/admin/depart.py @@ -4,6 +4,7 @@ # @Author :dayezi from fastapi import APIRouter +from app.controllers import user_controller from app.controllers.depart import departController from app.log import logger from app.schemas import Success @@ -29,9 +30,13 @@ async def delete_depart(id: int, name: str): @departRouter.get("/list", summary="获取部门列表") async def depart_list(): - _, depart_obj = await departController.list(1, 1000) - data = [await obj.to_dict() for obj in depart_obj] - logger.success(f"部门列表查询成功!{[i['name'] for i in data]}") + depart_obj = await departController.all() + data = [] + for item in depart_obj: + staffCount = await user_controller.model.filter(depart_id=item.id).count() + item = await item.to_dict() + item["staffCount"] = staffCount + data.append(item) return Success(msg="部门列表查询成功!", data=data) diff --git a/backend/app/core/crud.py b/backend/app/core/crud.py index a3062d2..6a039ff 100644 --- a/backend/app/core/crud.py +++ b/backend/app/core/crud.py @@ -54,7 +54,7 @@ async def remove(self, id: int) -> None: await obj.delete() async def children_ids(self, parentId: int) -> List[int]: - childrenList = [] + childrenList = [parentId] childrenId = await self.model.filter(parentId=parentId).all().values_list("id", flat=True) if childrenId: childrenList.extend(childrenId) @@ -63,4 +63,4 @@ async def children_ids(self, parentId: int) -> List[int]: if items: childrenList.extend(items) - return childrenList + return list(set(childrenList)) diff --git a/frontend/src/utils/tree.ts b/frontend/src/utils/tree.ts index f2b4c10..7d7b039 100644 --- a/frontend/src/utils/tree.ts +++ b/frontend/src/utils/tree.ts @@ -132,13 +132,15 @@ export const appendFieldByUniqueId = ( * @param id id字段 默认id * @param parentId 父节点字段,默认parentId * @param children 子节点字段,默认children + * @param countField 节点字段,默认staffCount * @returns 追加字段后的树 */ export const handleTree = ( data: any[], id?: string, parentId?: string, - children?: string + children?: string, + countField?: string, ): any => { if (!Array.isArray(data)) { console.warn("data must be an array"); @@ -147,7 +149,8 @@ export const handleTree = ( const config = { id: id || "id", parentId: parentId || "parentId", - childrenList: children || "children" + childrenList: children || "children", + countField: countField || "staffCount" }; const childrenListMap: any = {}; @@ -172,6 +175,18 @@ export const handleTree = ( for (const t of tree) { adaptToChildrenList(t); + calculateStaffCounts(t); + } + + function calculateStaffCounts(node: Record) { + let totalStaffCount = 0; + if (node[config.childrenList] && node[config.childrenList].length > 0) { + node[config.childrenList].forEach(child => { + calculateStaffCounts(child); + totalStaffCount += child[config.countField]; + }); + } + node[config.countField] += totalStaffCount; } function adaptToChildrenList(o: Record) { diff --git a/frontend/src/views/admin/Approval/panels/borrowing.vue b/frontend/src/views/admin/Approval/panels/borrowing.vue index f6768b8..f5fe490 100644 --- a/frontend/src/views/admin/Approval/panels/borrowing.vue +++ b/frontend/src/views/admin/Approval/panels/borrowing.vue @@ -5,7 +5,7 @@ import Approve from "@iconify-icons/fluent/approvals-app-16-filled"; import Reject from "@iconify-icons/fluent/text-change-reject-24-filled"; import { reactive, ref } from "vue"; import { PaginationProps, PureTable } from "@pureadmin/table"; -import { usePublicHooks } from "@/views/hooks"; +import { defaultPaginationSizes, usePublicHooks } from "@/views/hooks"; import { message } from "@/utils/message"; import PureTableBar from "@/components/RePureTableBar/src/bar"; import { successNotification } from "@/utils/notification"; @@ -46,10 +46,10 @@ const onSearch = () => { // 分页设置 const pagination = reactive({ total: 0, - pageSize: 10, + pageSize: 15, currentPage: 1, background: true, - pageSizes: [10, 20, 30, 50] + pageSizes: defaultPaginationSizes }); const selectedNum = ref(0); diff --git a/frontend/src/views/admin/Approval/panels/returned.vue b/frontend/src/views/admin/Approval/panels/returned.vue index 9df84fd..934837c 100644 --- a/frontend/src/views/admin/Approval/panels/returned.vue +++ b/frontend/src/views/admin/Approval/panels/returned.vue @@ -2,7 +2,7 @@ import { OptionsType } from "@/components/ReSegmented"; import { reactive, ref } from "vue"; import { PaginationProps, PureTable } from "@pureadmin/table"; -import { usePublicHooks } from "@/views/hooks"; +import { defaultPaginationSizes, usePublicHooks } from "@/views/hooks"; import { useRenderIcon } from "@/components/ReIcon/src/hooks"; import Search from "@iconify-icons/ep/search"; import Reject from "@iconify-icons/fluent/text-change-reject-24-filled"; @@ -46,10 +46,10 @@ const onSearch = () => { // 分页设置 const pagination = reactive({ total: 0, - pageSize: 10, + pageSize: 15, currentPage: 1, background: true, - pageSizes: [10, 20, 30, 50] + pageSizes: defaultPaginationSizes }); const selectedNum = ref(0); diff --git a/frontend/src/views/admin/Approval/panels/returning.vue b/frontend/src/views/admin/Approval/panels/returning.vue index f940d10..2e80e74 100644 --- a/frontend/src/views/admin/Approval/panels/returning.vue +++ b/frontend/src/views/admin/Approval/panels/returning.vue @@ -7,7 +7,7 @@ import { message } from "@/utils/message"; import { PaginationProps } from "@pureadmin/table"; import { getKeyList } from "@pureadmin/utils"; import { successNotification } from "@/utils/notification"; -import { usePublicHooks } from "@/views/hooks"; +import { defaultPaginationSizes, usePublicHooks } from "@/views/hooks"; import PureTableBar from "@/components/RePureTableBar/src/bar"; import { OptionsType } from "@/components/ReSegmented"; import { listBorrowed, updateBorrowedInfo } from "@/api/material"; @@ -176,10 +176,10 @@ const returnDataList = ref([]); // 分页设置 const pagination2 = reactive({ total: 0, - pageSize: 10, + pageSize: 15, currentPage: 1, background: true, - pageSizes: [10, 20, 30, 50] + pageSizes: defaultPaginationSizes }); const onBatchReturn = () => { diff --git a/frontend/src/views/admin/MaterialChecked.vue b/frontend/src/views/admin/MaterialChecked.vue index f1a1f2b..bd6266c 100644 --- a/frontend/src/views/admin/MaterialChecked.vue +++ b/frontend/src/views/admin/MaterialChecked.vue @@ -1,7 +1,7 @@ - - - - diff --git a/frontend/src/views/superAdmin/Logs/hook.tsx b/frontend/src/views/superAdmin/Logs/hook.tsx new file mode 100644 index 0000000..2397a7d --- /dev/null +++ b/frontend/src/views/superAdmin/Logs/hook.tsx @@ -0,0 +1,149 @@ +import dayjs from "dayjs"; +import { message } from "@/utils/message"; +import { getKeyList } from "@pureadmin/utils"; +import { getLoginLogsList } from "@/api/system"; +import { usePublicHooks } from "@/views/hooks"; +import type { PaginationProps } from "@pureadmin/table"; +import { type Ref, reactive, ref, onMounted } from "vue"; + +export function useRole(tableRef: Ref) { + const form = reactive({ + username: "", + status: "", + loginTime: "" + }); + const dataList = ref([]); + const loading = ref(true); + const selectedNum = ref(0); + const { tagStyle } = usePublicHooks(); + + const pagination = reactive({ + total: 0, + pageSize: 10, + currentPage: 1, + background: true + }); + const columns: TableColumnList = [ + { + label: "勾选列", // 如果需要表格多选,此处label必须设置 + type: "selection", + fixed: "left", + reserveSelection: true // 数据刷新后保留选项 + }, + { + label: "序号", + type: "index", + minWidth: 40 + }, + { + label: "用户名", + prop: "username", + minWidth: 80 + }, + { + label: "登录 IP", + prop: "ip", + minWidth: 140 + }, + { + label: "登录状态", + prop: "status", + minWidth: 100, + cellRenderer: ({ row, props }) => ( + + {row.status === 1 ? "成功" : "失败"} + + ) + }, + { + label: "登录时间", + prop: "loginTime", + minWidth: 180, + formatter: ({ loginTime }) => + dayjs(loginTime).format("YYYY-MM-DD HH:mm:ss") + } + ]; + + function handleSizeChange(val: number) { + console.log(`${val} items per page`); + } + + function handleCurrentChange(val: number) { + console.log(`current page: ${val}`); + } + + /** 当CheckBox选择项发生变化时会触发该事件 */ + function handleSelectionChange(val) { + selectedNum.value = val.length; + // 重置表格高度 + tableRef.value.setAdaptive(); + } + + /** 取消选择 */ + function onSelectionCancel() { + selectedNum.value = 0; + // 用于多选表格,清空用户的选择 + tableRef.value.getTableRef().clearSelection(); + } + + /** 批量删除 */ + function onbatchDel() { + // 返回当前选中的行 + const curSelected = tableRef.value.getTableRef().getSelectionRows(); + // 接下来根据实际业务,通过选中行的某项数据,比如下面的id,调用接口进行批量删除 + message(`已删除序号为 ${getKeyList(curSelected, "id")} 的数据`, { + type: "success" + }); + tableRef.value.getTableRef().clearSelection(); + onSearch(); + } + + /** 清空日志 */ + function clearAll() { + // 根据实际业务,调用接口删除所有日志数据 + message("已删除所有日志数据", { + type: "success" + }); + onSearch(); + } + + async function onSearch() { + loading.value = true; + const { data, total, currentPage, pageSize } = await getLoginLogsList(); + dataList.value = data; + pagination.total = total; + pagination.pageSize = pageSize; + pagination.currentPage = currentPage; + + setTimeout(() => { + loading.value = false; + }, 500); + } + + const resetForm = formEl => { + if (!formEl) return; + formEl.resetFields(); + onSearch(); + }; + + onMounted(() => { + onSearch(); + }); + + return { + form, + loading, + columns, + dataList, + pagination, + selectedNum, + onSearch, + clearAll, + resetForm, + onbatchDel, + handleSizeChange, + onSelectionCancel, + handleCurrentChange, + handleSelectionChange + }; +} diff --git a/frontend/src/views/superAdmin/Logs/index.vue b/frontend/src/views/superAdmin/Logs/index.vue new file mode 100644 index 0000000..15799ec --- /dev/null +++ b/frontend/src/views/superAdmin/Logs/index.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/frontend/src/views/superAdmin/Logs/utils.ts b/frontend/src/views/superAdmin/Logs/utils.ts new file mode 100644 index 0000000..b481d9a --- /dev/null +++ b/frontend/src/views/superAdmin/Logs/utils.ts @@ -0,0 +1,128 @@ +export const getPickerShortcuts = (): Array<{ + text: string; + value: Date | Function; +}> => { + return [ + { + text: "今天", + value: () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayEnd = new Date(); + todayEnd.setHours(23, 59, 59, 999); + return [today, todayEnd]; + } + }, + { + text: "昨天", + value: () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + const yesterdayEnd = new Date(); + yesterdayEnd.setDate(yesterdayEnd.getDate() - 1); + yesterdayEnd.setHours(23, 59, 59, 999); + return [yesterday, yesterdayEnd]; + } + }, + { + text: "前天", + value: () => { + const beforeYesterday = new Date(); + beforeYesterday.setDate(beforeYesterday.getDate() - 2); + beforeYesterday.setHours(0, 0, 0, 0); + const beforeYesterdayEnd = new Date(); + beforeYesterdayEnd.setDate(beforeYesterdayEnd.getDate() - 2); + beforeYesterdayEnd.setHours(23, 59, 59, 999); + return [beforeYesterday, beforeYesterdayEnd]; + } + }, + { + text: "本周", + value: () => { + const today = new Date(); + const startOfWeek = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - today.getDay() + (today.getDay() === 0 ? -6 : 1) + ); + startOfWeek.setHours(0, 0, 0, 0); + const endOfWeek = new Date( + startOfWeek.getTime() + + 6 * 24 * 60 * 60 * 1000 + + 23 * 60 * 60 * 1000 + + 59 * 60 * 1000 + + 59 * 1000 + + 999 + ); + return [startOfWeek, endOfWeek]; + } + }, + { + text: "上周", + value: () => { + const today = new Date(); + const startOfLastWeek = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - today.getDay() - 7 + (today.getDay() === 0 ? -6 : 1) + ); + startOfLastWeek.setHours(0, 0, 0, 0); + const endOfLastWeek = new Date( + startOfLastWeek.getTime() + + 6 * 24 * 60 * 60 * 1000 + + 23 * 60 * 60 * 1000 + + 59 * 60 * 1000 + + 59 * 1000 + + 999 + ); + return [startOfLastWeek, endOfLastWeek]; + } + }, + { + text: "本月", + value: () => { + const today = new Date(); + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + startOfMonth.setHours(0, 0, 0, 0); + const endOfMonth = new Date( + today.getFullYear(), + today.getMonth() + 1, + 0 + ); + endOfMonth.setHours(23, 59, 59, 999); + return [startOfMonth, endOfMonth]; + } + }, + { + text: "上个月", + value: () => { + const today = new Date(); + const startOfLastMonth = new Date( + today.getFullYear(), + today.getMonth() - 1, + 1 + ); + startOfLastMonth.setHours(0, 0, 0, 0); + const endOfLastMonth = new Date( + today.getFullYear(), + today.getMonth(), + 0 + ); + endOfLastMonth.setHours(23, 59, 59, 999); + return [startOfLastMonth, endOfLastMonth]; + } + }, + { + text: "本年", + value: () => { + const today = new Date(); + const startOfYear = new Date(today.getFullYear(), 0, 1); + startOfYear.setHours(0, 0, 0, 0); + const endOfYear = new Date(today.getFullYear(), 11, 31); + endOfYear.setHours(23, 59, 59, 999); + return [startOfYear, endOfYear]; + } + } + ]; +}; From 67f9e6c642d7e28697b24dabb70a7527809bcd25 Mon Sep 17 00:00:00 2001 From: dayezi <1372755472@qq.com> Date: Tue, 3 Dec 2024 02:57:20 +0800 Subject: [PATCH 09/13] =?UTF-8?q?=E2=9C=A8=20feat(login=20restriction):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=99=BB=E5=BD=95=E9=A2=91=E7=8E=87=E9=99=90?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E9=99=90=E5=88=B6=E6=AF=8F=E5=B0=8F=E6=97=B6?= =?UTF-8?q?=E9=99=90=E5=88=B6=E9=94=99=E8=AF=AF=E6=AC=A1=E6=95=B05?= =?UTF-8?q?=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/base/base.py | 15 +++++++------ backend/app/controllers/user.py | 37 +++++++++++++++++++++++++++------ backend/app/core/dependency.py | 6 ++++-- backend/app/models/users.py | 3 ++- backend/app/schemas/users.py | 1 + backend/app/utils/__init__.py | 9 +++++--- backend/app/utils/cnnp.py | 15 +++++++------ 7 files changed, 60 insertions(+), 26 deletions(-) diff --git a/backend/app/api/v1/base/base.py b/backend/app/api/v1/base/base.py index 8160d25..0420e04 100644 --- a/backend/app/api/v1/base/base.py +++ b/backend/app/api/v1/base/base.py @@ -1,6 +1,6 @@ from datetime import timedelta -from fastapi import APIRouter +from fastapi import APIRouter, Request from jwt.exceptions import ExpiredSignatureError from app.controllers.user import user_controller @@ -18,11 +18,10 @@ @router.post("/accessToken", summary="获取token") -async def login_access_token(credentials: CredentialsSchema): - user = await user_controller.authenticate(credentials) +async def login_access_token(request: Request, credentials: CredentialsSchema): + user = await user_controller.authenticate(credentials, request.client.host) await user_controller.update_last_login(user.id) roles = await user.roles.all().values_list("code", flat=True) - depart = user.department access_token_expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) refresh_token_expires = timedelta(minutes=settings.JWT_REFRESH_TOKEN_EXPIRE_MINUTES) expire = now(0) + access_token_expires @@ -32,7 +31,7 @@ async def login_access_token(credentials: CredentialsSchema): nickname=user.nickname, username=user.username, uuid=user.uuid.__str__(), - depart=depart, + depart=user.department, roles=roles, accessToken=create_access_token( data=JWTPayload( @@ -94,13 +93,13 @@ async def refresh_token(refreshToken: refreshTokenSchema): @router.post("/auth", summary="用户验证") -async def auth(credentials: CredentialsSchema): - user = await user_controller.authenticate(credentials) +async def auth(request: Request, credentials: CredentialsSchema): + user = await user_controller.authenticate(credentials, request.client.host) data = { "uuid": user.uuid.__str__(), "username": user.username, "nickname": user.nickname, - "phone": user.phone, + "phone": user.mobile, "depart": user.department } return Success(data=data) diff --git a/backend/app/controllers/user.py b/backend/app/controllers/user.py index 7ee2f5a..e20d57a 100644 --- a/backend/app/controllers/user.py +++ b/backend/app/controllers/user.py @@ -1,13 +1,17 @@ -from datetime import datetime +from datetime import datetime, timedelta from typing import List, Optional +from fastapi import HTTPException + from app.core.crud import CRUDBase from app.schemas.login import CredentialsSchema from app.schemas.users import UserCreate, UserUpdate from .role import role_controller from ..models import User +from ..utils import now, generate_uuid from ..utils.cnnp import ldap_auth +from ..utils.log import loginLogger class UserController(CRUDBase[User, UserCreate, UserUpdate]): @@ -21,6 +25,7 @@ async def get_by_username(self, username: str) -> Optional[User]: return await self.model.filter(username=username).first() async def create(self, obj_in: UserCreate) -> User: + obj_in.uuid = generate_uuid(obj_in.username) obj = await super().create(obj_in.create_dict()) return obj @@ -29,10 +34,12 @@ async def update_last_login(self, id: int) -> None: user.last_login = datetime.now() await user.save() - async def authenticate(self, credentials: CredentialsSchema) -> User: + async def authenticate(self, credentials: CredentialsSchema, ip: str) -> User: # user = await self.model.filter(username=credentials.username).first() - ldapUser = ldap_auth.authenticate(credentials.username, credentials.password) - user = await self.get_by_username(ldapUser.sAMAccountName) + # ldapUser 有信息就是登录成功 + ldapUser = ldap_auth.get_user_info(credentials.username) + # 获取数据库中的用户信息,没有就注册一个 + user = await self.get_by_username(credentials.username) if not user: userCreate = UserCreate( username=ldapUser.sAMAccountName, @@ -43,9 +50,27 @@ async def authenticate(self, credentials: CredentialsSchema) -> User: department=ldapUser.department, company=ldapUser.company ) - user = await self.create(userCreate) - return user + if now(0) - user.updated_at < timedelta(hours=1) and user.status == 0: + remaining_time = 60 - int((now(0) - user.updated_at).total_seconds()) // 60 + raise HTTPException(status_code=400, + detail=f"密码错误次数过多,账号已被锁定,请【{remaining_time}】分钟后再尝试登录") + ldapUser, result = ldap_auth.authenticate(credentials.username, credentials.password) + if result: + # 解封 + user.status = 1 + user.loginFail = 0 + await user.save() + loginLogger.success(credentials.username, ip=ip) + return user + else: + user.loginFail += 1 + if user.loginFail >= 5 and user.status == 1: + # 锁定 + user.status = 0 + await user.save() + loginLogger.error(credentials.username, ip=ip) + raise HTTPException(status_code=400, detail=f"密码错误,尝试登录次数{user.loginFail}/5!") async def update_roles(self, user: User, roles: List[int]) -> None: await user.roles.clear() diff --git a/backend/app/core/dependency.py b/backend/app/core/dependency.py index c4f6d03..a12dbf3 100644 --- a/backend/app/core/dependency.py +++ b/backend/app/core/dependency.py @@ -37,13 +37,15 @@ async def is_authed(cls, authorization: str = Header(..., description="token验 if not user: raise HTTPException(status_code=401, detail="Authentication failed") CTX_USER_ID.set(int(user_id)) + if user.is_superuser: + return user + if not user.status: + raise HTTPException(status_code=401, detail="用户已被禁用") return user except jwt.DecodeError: raise HTTPException(status_code=401, detail="无效的Token") except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="登录已过期") - except Exception as e: - raise HTTPException(status_code=500, detail=f"{repr(e)}") class PermissionControl: diff --git a/backend/app/models/users.py b/backend/app/models/users.py index 71494b4..e51add4 100644 --- a/backend/app/models/users.py +++ b/backend/app/models/users.py @@ -13,12 +13,13 @@ class User(BaseModel, TimestampMixin, UUIDModel): sex = fields.IntField(default=1, description="性别, 0: 女, 1: 男") email = fields.CharField(max_length=255, null=True, unique=True, description="邮箱") mobile = fields.CharField(max_length=20, null=True, unique=True, description="手机号码") - status = fields.IntField(default=0, max_length=10, description="是否激活") + status = fields.IntField(default=1, max_length=10, description="是否禁用") is_superuser = fields.BooleanField(default=False, description="是否为超级管理员") last_login = fields.DatetimeField(null=True, description="最后登录时间") remark = fields.CharField(max_length=500, null=True, blank=True, description="备注") department = fields.CharField(max_length=500, null=True, blank=True, description="部门") company = fields.CharField(max_length=500, null=True, blank=True, description="公司") + loginFail = fields.IntField(default=0, description="登录失败次数") roles: fields.ManyToManyRelation["Role"] = fields.ManyToManyField("models.Role", related_name="user_roles") borrowed: fields.ManyToManyRelation["Borrowed"] diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py index f7123e6..357b450 100644 --- a/backend/app/schemas/users.py +++ b/backend/app/schemas/users.py @@ -13,6 +13,7 @@ class UserCreate(UserPydantic): is_superuser: bool = False last_login: str = None avatar: str = None + remark: str = None def create_dict(self): return self.model_dump(exclude_unset=True, exclude={"roles", "depart"}) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index 691e436..533e013 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -6,6 +6,7 @@ import time import uuid from datetime import datetime +import pytz def base_decode(data: str) -> bytes: @@ -32,11 +33,13 @@ def now(s: int = 1) -> str | datetime | float: :return: 当前日期时间 """ + utc_timezone = pytz.timezone('Asia/Shanghai') today = datetime.now() + utc_now = today.astimezone(utc_timezone) match s: case 0: - return today + return utc_now case 1: - return today.strftime("%Y-%m-%d %H:%M:%S") + return utc_now.strftime("%Y-%m-%d %H:%M:%S") case 2: - return today.timestamp() + return utc_now.timestamp() diff --git a/backend/app/utils/cnnp.py b/backend/app/utils/cnnp.py index 238b862..b518d5f 100644 --- a/backend/app/utils/cnnp.py +++ b/backend/app/utils/cnnp.py @@ -1,7 +1,9 @@ +from typing import Tuple + from fastapi import HTTPException from ldap3 import Server, Connection, ALL -from app.utils.log import logger +from app.utils.log import logger, loginLogger from app.schemas.users import UserLdap from app.settings import settings @@ -26,6 +28,7 @@ def get_user_info(self, username: str) -> UserLdap: mail=user.mail.value, dn=user.distinguishedName.value, sAMAccountName=user.sAMAccountName.value, + name=user.name.value, ) else: self.conn.unbind() @@ -43,19 +46,19 @@ def get_user_info(self, username: str) -> UserLdap: ) - def authenticate(self, username: str, password: str) -> UserLdap: + def authenticate(self, username: str, password: str) -> Tuple[UserLdap | None, bool]: if settings.DEV: user = self.get_user_info(username) - return user + return user, True else: + user = self.get_user_info(username) try: - user = self.get_user_info(username) conn = Connection(self.server, user=user.dn, password=password, auto_bind=True) conn.unbind() - return user + return user, True except Exception as e: logger.error(f"LDAP认证失败: {e}") - raise HTTPException(status_code=400, detail="密码错误") + return user, False ldap_auth = LDAPAuthentication() From f4efa2b6511ac3c1a6d66b486382786d8e0ba5ca Mon Sep 17 00:00:00 2001 From: dayezi <1372755472@qq.com> Date: Tue, 3 Dec 2024 13:49:59 +0800 Subject: [PATCH 10/13] =?UTF-8?q?=E2=9C=A8=20feat(loginLog):=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E9=A1=B5=E9=9D=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/admin/system.py | 41 ++++++++++-- backend/app/controllers/user.py | 6 +- frontend/src/api/system.ts | 23 ++++++- frontend/src/views/admin/OperationLogs.vue | 2 + frontend/src/views/superAdmin/Logs/hook.tsx | 66 ++++++++++++-------- frontend/src/views/superAdmin/Logs/index.vue | 25 -------- 6 files changed, 102 insertions(+), 61 deletions(-) diff --git a/backend/app/api/v1/admin/system.py b/backend/app/api/v1/admin/system.py index 1c2a995..9aacda6 100644 --- a/backend/app/api/v1/admin/system.py +++ b/backend/app/api/v1/admin/system.py @@ -2,6 +2,7 @@ # @FileName :system.py # @Time :2024/7/6 上午3:49 # @Author :dayezi +from datetime import datetime from pathlib import Path from fastapi import APIRouter, Request, Query @@ -38,21 +39,41 @@ async def set_db_conn(data: DbInfo, request: Request): return Fail(msg="数据库设置失败!") -@sysRouter.get("/getLoginLogs", summary="获取登录日志") +@sysRouter.post("/getLoginLogs", summary="获取登录日志") async def get_login_logs( + data: dict, currentPage: int = Query(1, description="页码"), pageSize: int = Query(15, description="每页数量"), ): loginLog = Path(__file__).parent.parent.parent.parent.parent.joinpath("logs", "login.log") # 读取 loginLog 下的文件,按照逆序读取,根据currentPage 和 pageSize 分页,并计算总数量total - data = [] - with open(loginLog, "r") as f: + logList = [] + with open(loginLog, "r", encoding="utf-8") as f: lines = f.readlines() + # 对读取的日志先经过username和status,以及startTime和endTime 过滤 + if data["username"]: + lines = [line for line in lines if data["username"] in line] + if data["status"]: + newLines = [] + for line in lines: + if data["status"] == "1" and "SUCCESS" in line: + newLines.append(line) + elif data["status"] == "0" and "ERROR" in line: + newLines.append(line) + lines = newLines + if len(data["loginTime"]) > 1: + lines = [line for line in lines if + datetime.strptime(data["loginTime"][0], "%Y-%m-%d %H:%M:%S") + <= datetime.strptime(line.split("|")[0].strip(), "%Y-%m-%d %H:%M:%S.%f") + <= datetime.strptime(data["loginTime"][1], "%Y-%m-%d %H:%M:%S")] + total = len(lines) + if total == 0: + return SuccessExtra(data=logList, total=total, currentPage=currentPage, pageSize=pageSize) start_index = total - pageSize * (currentPage - 1) end_index = start_index - pageSize if end_index < 0: - end_index = 0 + end_index = -1 for i in range(start_index, end_index, -1): # 分割日志行 parts = lines[i - 1].strip().split('|') @@ -63,5 +84,13 @@ async def get_login_logs( 'ip': parts[2].strip(), 'loginTime': parts[0].strip() } - data.append(log_dict) - return SuccessExtra(data=data, total=total, currentPage=currentPage, pageSize=pageSize) + logList.append(log_dict) + return SuccessExtra(data=logList, total=total, currentPage=currentPage, pageSize=pageSize) + + +@sysRouter.get("/clearLoginLogs", summary="清除登录日志") +async def clear_login_logs(): + loginLog = Path(__file__).parent.parent.parent.parent.parent.joinpath("logs", "login.log") + with open(loginLog, "w", encoding="utf-8") as f: + f.write("") + return Success(msg="清除成功!") diff --git a/backend/app/controllers/user.py b/backend/app/controllers/user.py index e20d57a..c09bdfb 100644 --- a/backend/app/controllers/user.py +++ b/backend/app/controllers/user.py @@ -2,6 +2,7 @@ from typing import List, Optional from fastapi import HTTPException +from tortoise.exceptions import IntegrityError from app.core.crud import CRUDBase from app.schemas.login import CredentialsSchema @@ -26,7 +27,10 @@ async def get_by_username(self, username: str) -> Optional[User]: async def create(self, obj_in: UserCreate) -> User: obj_in.uuid = generate_uuid(obj_in.username) - obj = await super().create(obj_in.create_dict()) + try: + obj = await super().create(obj_in.create_dict()) + except IntegrityError: + raise HTTPException(status_code=400, detail="用户已存在") return obj async def update_last_login(self, id: int) -> None: diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index d908145..a01401d 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -2,10 +2,27 @@ import { http } from "@/utils/http"; import { baseUrlApi } from "./utils"; import type { ResultTable } from "@/types/base"; -export const getLoginLogsList = () => { +export const getLoginLogsList = ( + currentPage: number, + pageSize: number, + username: string = null, + status: string = null, + loginTime: any[] = null +) => { return http.request( - "get", + "post", baseUrlApi("/admin/system/getLoginLogs"), - {} + { + params: { currentPage, pageSize }, + data: { + username, + status, + loginTime + } + } ); }; + +export const clearLoginLogs = () => { + return http.request("get", baseUrlApi("/admin/system/clearLoginLogs")); +}; diff --git a/frontend/src/views/admin/OperationLogs.vue b/frontend/src/views/admin/OperationLogs.vue index ea20f13..7fac9ac 100644 --- a/frontend/src/views/admin/OperationLogs.vue +++ b/frontend/src/views/admin/OperationLogs.vue @@ -234,11 +234,13 @@ const pagination = reactive({ }); function handleSizeChange(val: number) { + pagination.pageSize = val; onSearch(); console.log(`${val} items per page`); } function handleCurrentChange(val: number) { + pagination.currentPage = val; onSearch(); console.log(`current page: ${val}`); } diff --git a/frontend/src/views/superAdmin/Logs/hook.tsx b/frontend/src/views/superAdmin/Logs/hook.tsx index 2397a7d..e8d448d 100644 --- a/frontend/src/views/superAdmin/Logs/hook.tsx +++ b/frontend/src/views/superAdmin/Logs/hook.tsx @@ -1,16 +1,17 @@ import dayjs from "dayjs"; import { message } from "@/utils/message"; import { getKeyList } from "@pureadmin/utils"; -import { getLoginLogsList } from "@/api/system"; -import { usePublicHooks } from "@/views/hooks"; +import { clearLoginLogs, getLoginLogsList } from "@/api/system"; +import { defaultPaginationSizes, usePublicHooks } from "@/views/hooks"; import type { PaginationProps } from "@pureadmin/table"; import { type Ref, reactive, ref, onMounted } from "vue"; +import type { DateModelType } from "element-plus"; export function useRole(tableRef: Ref) { const form = reactive({ username: "", status: "", - loginTime: "" + loginTime: Array("") }); const dataList = ref([]); const loading = ref(true); @@ -19,21 +20,16 @@ export function useRole(tableRef: Ref) { const pagination = reactive({ total: 0, - pageSize: 10, + pageSize: 15, currentPage: 1, - background: true + background: true, + pageSizes: defaultPaginationSizes }); const columns: TableColumnList = [ - { - label: "勾选列", // 如果需要表格多选,此处label必须设置 - type: "selection", - fixed: "left", - reserveSelection: true // 数据刷新后保留选项 - }, { label: "序号", type: "index", - minWidth: 40 + width: 60 }, { label: "用户名", @@ -65,11 +61,13 @@ export function useRole(tableRef: Ref) { ]; function handleSizeChange(val: number) { - console.log(`${val} items per page`); + pagination.pageSize = val; + onSearch(); } function handleCurrentChange(val: number) { - console.log(`current page: ${val}`); + pagination.currentPage = val; + onSearch(); } /** 当CheckBox选择项发生变化时会触发该事件 */ @@ -101,23 +99,39 @@ export function useRole(tableRef: Ref) { /** 清空日志 */ function clearAll() { // 根据实际业务,调用接口删除所有日志数据 - message("已删除所有日志数据", { - type: "success" + clearLoginLogs().then(() => { + message("已删除所有日志数据", { + type: "success" + }); + onSearch(); }); - onSearch(); } async function onSearch() { loading.value = true; - const { data, total, currentPage, pageSize } = await getLoginLogsList(); - dataList.value = data; - pagination.total = total; - pagination.pageSize = pageSize; - pagination.currentPage = currentPage; - - setTimeout(() => { - loading.value = false; - }, 500); + if (form.loginTime.length > 1) { + form.loginTime = form.loginTime.map(time => + dayjs(time).format("YYYY-MM-DD HH:mm:ss") + ); + } + getLoginLogsList( + pagination.currentPage, + pagination.pageSize, + form.username, + form.status, + form.loginTime + ) + .then(({ data, total, pageSize, currentPage }) => { + dataList.value = data; + pagination.total = total; + pagination.pageSize = pageSize; + pagination.currentPage = currentPage; + }) + .finally(() => { + setTimeout(() => { + loading.value = false; + }, 500); + }); } const resetForm = formEl => { diff --git a/frontend/src/views/superAdmin/Logs/index.vue b/frontend/src/views/superAdmin/Logs/index.vue index 15799ec..30e81c6 100644 --- a/frontend/src/views/superAdmin/Logs/index.vue +++ b/frontend/src/views/superAdmin/Logs/index.vue @@ -21,13 +21,10 @@ const { columns, dataList, pagination, - selectedNum, onSearch, clearAll, resetForm, - onbatchDel, handleSizeChange, - onSelectionCancel, handleCurrentChange, handleSelectionChange } = useRole(tableRef); @@ -96,28 +93,6 @@ const { + +
+ +
+ + + +
diff --git a/frontend/src/views/superAdmin/roleManagement/utils/hook.tsx b/frontend/src/views/superAdmin/roleManagement/utils/hook.tsx index ae50362..c5492cf 100644 --- a/frontend/src/views/superAdmin/roleManagement/utils/hook.tsx +++ b/frontend/src/views/superAdmin/roleManagement/utils/hook.tsx @@ -15,6 +15,7 @@ import { successNotification } from "@/utils/notification"; import { addRole, deleteRole, + getAllArea, getApiList, getMenuList, getRoleAuth, @@ -23,7 +24,7 @@ import { updateRoleAuth } from "@/api/admin"; -export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { +export function useRole(menuTreeRef: Ref, apiTreeRef: Ref, areaTreeRef: Ref) { const form = reactive({ name: "", code: "", @@ -35,8 +36,10 @@ export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { const menuTreeIds = ref([]); const apiParentTreeIds = ref([]); const apiTreeIds = ref([]); + const areaTreeIds = ref([]); const menuTreeData = ref([]); const apiTreeData = ref([]); + const areaTreeData = ref([]); const isShow = ref(false); const loading = ref(true); const isLinkage = ref(true); @@ -47,6 +50,7 @@ export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { const apiIsExpandAll = ref(false); const isSelectAll = ref(false); const apiIsSelectAll = ref(false); + const areaIsSelectAll = ref(false); const tabIndex = ref(0); const { switchStyle } = usePublicHooks(); const menuTreeProps = { @@ -59,6 +63,10 @@ export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { label: "summary", children: "children" }; + const areaProps = { + value: "id", + label: "name" + }; const pagination = reactive({ total: 0, pageSize: 15, @@ -74,6 +82,10 @@ export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { { label: "API权限", value: 1 + }, + { + label: "区域权限", + value: 2 } ]; const columns: TableColumnList = [ @@ -181,7 +193,9 @@ export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { function handleDelete(row) { deleteRole(row.id, row.name).then(() => { - message(`您删除了角色名称为【${row.name}】的这条数据`, { type: "success" }); + message(`您删除了角色名称为【${row.name}】的这条数据`, { + type: "success" + }); onSearch(); }); } @@ -245,7 +259,7 @@ export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { const curData = options.props.formInline as FormItemProps; function chores() { successNotification( - `您${title}了角色名称为${curData.name}的这条数据` + `您${title}了角色名称为【${curData.name}】的这条数据` ); done(); // 关闭弹框 onSearch(); // 刷新表格数据 @@ -280,9 +294,11 @@ export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { const { data } = await getRoleAuth(id); menuTreeRef.value.setCheckedKeys(data.menus); apiTreeRef.value.setCheckedKeys(data.apis); + areaTreeRef.value.setCheckedKeys(data.areas); } else { curRow.value = null; isShow.value = false; + tabIndex.value = 0; } } @@ -303,7 +319,8 @@ export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { let data = { id: id, menus: menuTreeRef.value.getCheckedKeys(), - apis: apiIds + apis: apiIds, + areas: areaTreeRef.value.getCheckedKeys() }; // 根据用户 id 调用实际项目中菜单权限修改接口 updateRoleAuth(data).then(() => { @@ -332,6 +349,11 @@ export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { apiTreeIds.value = getKeyList(res.data, "id"); apiParentTreeIds.value = getKeyList(apiTreeData.value, "id"); }); + getAllArea().then(res => { + areaTreeData.value = res.data; + areaTreeIds.value = getKeyList(res.data, "id"); + }); + console.log(areaTreeData); }); watch(isExpandAll, val => { @@ -361,6 +383,12 @@ export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { : apiTreeRef.value.setCheckedKeys([]); }); + watch(areaIsSelectAll, val => { + val + ? areaTreeRef.value.setCheckedKeys(areaTreeIds.value) + : areaTreeRef.value.setCheckedKeys([]); + }); + return { form, isShow, @@ -371,15 +399,18 @@ export function useRole(menuTreeRef: Ref, apiTreeRef: Ref) { dataList, menuTreeData, apiTreeData, + areaTreeData, tabIndex, menuTreeProps, apiTreeProps, + areaProps, isLinkage, pagination, isExpandAll, apiIsExpandAll, isSelectAll, apiIsSelectAll, + areaIsSelectAll, apiIsLinkage, tabOperation, treeSearchValue, From c9d5a4cd458976c6e92cbb2f0198122716e8e1a5 Mon Sep 17 00:00:00 2001 From: dayezi <1372755472@qq.com> Date: Wed, 4 Dec 2024 02:21:52 +0800 Subject: [PATCH 13/13] =?UTF-8?q?=E2=9C=A8=20feat(userAreaAuth):=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=8E=B7=E5=8F=96=E6=8E=88=E6=9D=83=E5=8C=BA?= =?UTF-8?q?=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/admin/area.py | 7 +++- backend/app/api/v1/base/base.py | 22 ++++++++++++ frontend/src/api/base.ts | 4 +++ frontend/src/views/admin/Approval/index.vue | 17 ++++++++- .../views/admin/Approval/panels/borrowing.vue | 13 ++++--- .../views/admin/Approval/panels/returned.vue | 13 ++++--- .../views/admin/Approval/panels/returning.vue | 13 ++++--- frontend/src/views/admin/OperationLogs.vue | 35 +++++++++---------- frontend/src/views/admin/utils/types.ts | 7 +++- .../superAdmin/roleManagement/utils/hook.tsx | 1 - 10 files changed, 98 insertions(+), 34 deletions(-) diff --git a/backend/app/api/v1/admin/area.py b/backend/app/api/v1/admin/area.py index 3b0127e..f42c4ec 100644 --- a/backend/app/api/v1/admin/area.py +++ b/backend/app/api/v1/admin/area.py @@ -70,5 +70,10 @@ async def list_area( @areaRouter.get("/all", summary="查看所有区域") async def list_area(): area_list = await areaController.all() - data = [await area.to_dict() for area in area_list] + data = [] + for area in area_list: + if area.status == 0: + continue + item = await area.to_dict() + data.append(item) return Success(data=data, msg="查询成功") \ No newline at end of file diff --git a/backend/app/api/v1/base/base.py b/backend/app/api/v1/base/base.py index 0420e04..b986d32 100644 --- a/backend/app/api/v1/base/base.py +++ b/backend/app/api/v1/base/base.py @@ -6,6 +6,7 @@ from app.controllers.user import user_controller from app.core.ctx import CTX_USER_ID from app.core.dependency import DependAuth +from app.models import MaterialArea from app.utils.log import logger from app.models.users import Api, Menu, Role, User from app.schemas.base import Success, FailAuth @@ -196,3 +197,24 @@ async def get_user_api(): apis.extend([api.method.lower() + api.path for api in api_objs]) apis = list(set(apis)) return Success(data=apis) + + +@router.get("/userArea", summary="查看本人授权区域", dependencies=[DependAuth]) +async def get_user_area(): + user_id = CTX_USER_ID.get() + user_obj = await User.filter(id=user_id).first() + if user_obj.is_superuser: + area_objs: list[MaterialArea] = await MaterialArea.all() + areas = [await area.to_dict() for area in area_objs] + return Success(data=areas) + role_objs: list[Role] = await user_obj.roles + areas = [] + for role_obj in role_objs: + area_obj: list[MaterialArea] = await role_obj.areas + for area in area_obj: + if not area.status: + break + areas.append(await area.to_dict()) + seen = set() + areas = [area for area in areas if area["code"] not in seen and not seen.add(area["code"])] + return Success(data=areas) diff --git a/frontend/src/api/base.ts b/frontend/src/api/base.ts index b6ae37f..7676b2f 100644 --- a/frontend/src/api/base.ts +++ b/frontend/src/api/base.ts @@ -60,3 +60,7 @@ export const initPassword = (newPwd: string) => { data: { newPwd } }); }; + +export const getAreaList = () => { + return http.request("get", baseUrlApi("/base/userArea")); +}; diff --git a/frontend/src/views/admin/Approval/index.vue b/frontend/src/views/admin/Approval/index.vue index fb604fc..6875ecc 100644 --- a/frontend/src/views/admin/Approval/index.vue +++ b/frontend/src/views/admin/Approval/index.vue @@ -1,16 +1,18 @@