diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 51c1e87..6610c26 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -148,7 +148,24 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') #自己在根目录下创建media DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'utils.pagination.CustomPagination', + 'PAGE_SIZE': 20, + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework.filters.OrderingFilter', + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', + ), + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'utils.authentication.BearerTokenAuthentication', + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', + ] +} # celery 配置 CELERY_BROKER_URL = 'redis://localhost:6379/6' diff --git a/backend/system/serializers.py b/backend/system/serializers.py deleted file mode 100644 index 03c6d3d..0000000 --- a/backend/system/serializers.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.contrib.auth.models import Group, Permission -from django.contrib.contenttypes.models import ContentType -from rest_framework import serializers -from .models import Department, Menu, MenuMeta, Role - diff --git a/backend/system/views/dept.py b/backend/system/views/dept.py index 66ee4b0..9d46f5c 100644 --- a/backend/system/views/dept.py +++ b/backend/system/views/dept.py @@ -8,9 +8,10 @@ from rest_framework.response import Response from system.models import Dept from utils.custom_model_viewSet import CustomModelViewSet +from utils.serializers import CustomModelSerializer -class DeptSerializer(serializers.ModelSerializer): +class DeptSerializer(CustomModelSerializer): """部门序列化器""" children = serializers.SerializerMethodField() status_text = serializers.SerializerMethodField() diff --git a/backend/utils/custom_model_viewSet.py b/backend/utils/custom_model_viewSet.py index 4911547..9220273 100644 --- a/backend/utils/custom_model_viewSet.py +++ b/backend/utils/custom_model_viewSet.py @@ -38,19 +38,19 @@ class CustomModelViewSet(viewsets.ModelViewSet): def list(self, request, *args, **kwargs): """重写列表视图,支持软删除过滤""" queryset = self.get_queryset() - # 应用软删除过滤 if self.enable_soft_delete: queryset = queryset.filter(**{self.soft_delete_field: False}) - # 应用搜索和过滤 queryset = self.filter_queryset(queryset) - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) - + # 判断是否传了 page 参数 + if 'page' in request.query_params: + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + # 没有 page 参数,返回全部数据 serializer = self.get_serializer(queryset, many=True) return self._build_response( data=serializer.data, diff --git a/backend/utils/pagination.py b/backend/utils/pagination.py index 2dbad86..030b100 100644 --- a/backend/utils/pagination.py +++ b/backend/utils/pagination.py @@ -10,6 +10,7 @@ from django.core.paginator import InvalidPage class CustomPagination(PageNumberPagination): page_size = 20 + page_query_param = 'page' # 默认就是'page',如果你用别的名字要改成对应的 page_size_query_param = "pageSize" max_page_size = 999 django_paginator_class = DjangoPaginator diff --git a/backend/utils/serializers.py b/backend/utils/serializers.py index 02738b1..14c0510 100644 --- a/backend/utils/serializers.py +++ b/backend/utils/serializers.py @@ -8,8 +8,6 @@ from rest_framework.serializers import ModelSerializer from django.utils.functional import cached_property from rest_framework.utils.serializer_helpers import BindingDict -from system.models import User - class CustomModelSerializer(ModelSerializer): """ @@ -18,16 +16,6 @@ class CustomModelSerializer(ModelSerializer): """ # 修改人的审计字段名称, 默认modifier, 继承使用时可自定义覆盖 modifier_field_id = 'modifier' - modifier_name = serializers.SerializerMethodField(read_only=True) - - def get_modifier_name(self, instance): - if not hasattr(instance, 'modifier'): - return None - queryset = User.objects.filter(id=instance.modifier).values_list('name', flat=True).first() - if queryset: - return queryset - return None - # 创建人的审计字段名称, 默认creator, 继承使用时可自定义覆盖 creator_field_id = 'creator' # 添加默认时间返回格式 @@ -43,16 +31,16 @@ class CustomModelSerializer(ModelSerializer): def create(self, validated_data): if self.request: - if self.modifier_field_id in self.fields.fields: + if self.modifier_field_id in self.fields: validated_data[self.modifier_field_id] = self.get_request_username() - if self.creator_field_id in self.fields.fields: + if self.creator_field_id in self.fields: validated_data[self.creator_field_id] = self.get_request_username() return super().create(validated_data) def update(self, instance, validated_data): if self.request: if hasattr(self.instance, self.modifier_field_id): - self.instance.modifier = self.get_request_username() + validated_data[self.modifier_field_id] = self.get_request_username() return super().update(instance, validated_data) def get_request_username(self): diff --git a/web/apps/web-antd/src/adapter/vxe-table.ts b/web/apps/web-antd/src/adapter/vxe-table.ts index 8d17d9d..71a0ecb 100644 --- a/web/apps/web-antd/src/adapter/vxe-table.ts +++ b/web/apps/web-antd/src/adapter/vxe-table.ts @@ -1,10 +1,16 @@ -import type { VxeTableGridOptions } from '@vben/plugins/vxe-table'; +import type { Recordable } from '@vben/types'; import { h } from 'vue'; +import { IconifyIcon } from '@vben/icons'; +import { $te } from '@vben/locales'; import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table'; +import { get, isFunction, isString } from '@vben/utils'; -import { Button, Image } from 'ant-design-vue'; +import { objectOmit } from '@vueuse/core'; +import { Button, Image, Popconfirm, Switch, Tag } from 'ant-design-vue'; + +import { $t } from '#/locales'; import { useVbenForm } from './form'; @@ -17,17 +23,18 @@ setupVbenVxeTable({ columnConfig: { resizable: true, }, - minHeight: 180, + formConfig: { // 全局禁用vxe-table的表单配置,使用formOptions enabled: false, }, + minHeight: 180, proxyConfig: { autoLoad: true, response: { result: 'items', total: 'total', - list: 'items', + list: '', }, showActiveMsg: true, showResponseMsg: false, @@ -35,7 +42,16 @@ setupVbenVxeTable({ round: true, showOverflow: true, size: 'small', - } as VxeTableGridOptions, + }, + }); + + /** + * 解决vxeTable在热更新时可能会出错的问题 + */ + vxeUI.renderer.forEach((_item, key) => { + if (key.startsWith('Cell')) { + vxeUI.renderer.delete(key); + } }); // 表格配置项可以用 cellRender: { name: 'CellImage' }, @@ -58,6 +74,203 @@ setupVbenVxeTable({ }, }); + // 单元格渲染: Tag + vxeUI.renderer.add('CellTag', { + renderTableDefault({ options, props }, { column, row }) { + const value = get(row, column.field); + const tagOptions = options ?? [ + { color: 'success', label: $t('common.enabled'), value: 1 }, + { color: 'error', label: $t('common.disabled'), value: 0 }, + ]; + const tagItem = tagOptions.find((item) => item.value === value); + return h( + Tag, + { + ...props, + ...objectOmit(tagItem ?? {}, ['label']), + }, + { default: () => tagItem?.label ?? value }, + ); + }, + }); + + vxeUI.renderer.add('CellSwitch', { + renderTableDefault({ attrs, props }, { column, row }) { + const loadingKey = `__loading_${column.field}`; + const finallyProps = { + checkedChildren: $t('common.enabled'), + checkedValue: 1, + unCheckedChildren: $t('common.disabled'), + unCheckedValue: 0, + ...props, + checked: row[column.field], + loading: row[loadingKey] ?? false, + 'onUpdate:checked': onChange, + }; + async function onChange(newVal: any) { + row[loadingKey] = true; + try { + const result = await attrs?.beforeChange?.(newVal, row); + if (result !== false) { + row[column.field] = newVal; + } + } finally { + row[loadingKey] = false; + } + } + return h(Switch, finallyProps); + }, + }); + + /** + * 注册表格的操作按钮渲染器 + */ + vxeUI.renderer.add('CellOperation', { + renderTableDefault({ attrs, options, props }, { column, row }) { + const defaultProps = { size: 'small', type: 'link', ...props }; + let align = 'end'; + switch (column.align) { + case 'center': { + align = 'center'; + break; + } + case 'left': { + align = 'start'; + break; + } + default: { + align = 'end'; + break; + } + } + const presets: Recordable> = { + delete: { + danger: true, + text: $t('common.delete'), + }, + edit: { + text: $t('common.edit'), + }, + }; + const operations: Array> = ( + options || ['edit', 'delete'] + ) + .map((opt) => { + if (isString(opt)) { + return presets[opt] + ? { code: opt, ...presets[opt], ...defaultProps } + : { + code: opt, + text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt, + ...defaultProps, + }; + } else { + return { ...defaultProps, ...presets[opt.code], ...opt }; + } + }) + .map((opt) => { + const optBtn: Recordable = {}; + Object.keys(opt).forEach((key) => { + optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key]; + }); + return optBtn; + }) + .filter((opt) => opt.show !== false); + + function renderBtn(opt: Recordable, listen = true) { + return h( + Button, + { + ...props, + ...opt, + icon: undefined, + onClick: listen + ? () => + attrs?.onClick?.({ + code: opt.code, + row, + }) + : undefined, + }, + { + default: () => { + const content = []; + if (opt.icon) { + content.push( + h(IconifyIcon, { class: 'size-5', icon: opt.icon }), + ); + } + content.push(opt.text); + return content; + }, + }, + ); + } + + function renderConfirm(opt: Recordable) { + let viewportWrapper: HTMLElement | null = null; + return h( + Popconfirm, + { + /** + * 当popconfirm用在固定列中时,将固定列作为弹窗的容器时可能会因为固定列较窄而无法容纳弹窗 + * 将表格主体区域作为弹窗容器时又会因为固定列的层级较高而遮挡弹窗 + * 将body或者表格视口区域作为弹窗容器时又会导致弹窗无法跟随表格滚动。 + * 鉴于以上各种情况,一种折中的解决方案是弹出层展示时,禁止操作表格的滚动条。 + * 这样既解决了弹窗的遮挡问题,又不至于让弹窗随着表格的滚动而跑出视口区域。 + */ + getPopupContainer(el) { + viewportWrapper = el.closest('.vxe-table--viewport-wrapper'); + return document.body; + }, + placement: 'topLeft', + title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']), + ...props, + ...opt, + icon: undefined, + onOpenChange: (open: boolean) => { + // 当弹窗打开时,禁止表格的滚动 + if (open) { + viewportWrapper?.style.setProperty('pointer-events', 'none'); + } else { + viewportWrapper?.style.removeProperty('pointer-events'); + } + }, + onConfirm: () => { + attrs?.onClick?.({ + code: opt.code, + row, + }); + }, + }, + { + default: () => renderBtn({ ...opt }, false), + description: () => + h( + 'div', + { class: 'truncate' }, + $t('ui.actionMessage.deleteConfirm', [ + row[attrs?.nameField || 'name'], + ]), + ), + }, + ); + } + + const btns = operations.map((opt) => + opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt), + ); + return h( + 'div', + { + class: 'flex table-operations', + style: { justifyContent: align }, + }, + btns, + ); + }, + }); + // 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化 // vxeUI.formats.add }, diff --git a/web/apps/web-antd/src/api/system/dept.ts b/web/apps/web-antd/src/api/system/dept.ts index 6faa76e..e2fc485 100644 --- a/web/apps/web-antd/src/api/system/dept.ts +++ b/web/apps/web-antd/src/api/system/dept.ts @@ -1,3 +1,5 @@ +import type { Recordable } from '@vben/types'; + import { requestClient } from '#/api/request'; export namespace SystemDeptApi { @@ -14,8 +16,10 @@ export namespace SystemDeptApi { /** * 获取部门列表数据 */ -async function getDeptList() { - return requestClient.get>('/system/dept/'); +async function getDeptList(params: Recordable) { + return requestClient.get>('/system/dept/', { + params, + }); } /** diff --git a/web/apps/web-antd/src/views/system/dept/list.vue b/web/apps/web-antd/src/views/system/dept/list.vue index b1b28d0..4c6eff9 100644 --- a/web/apps/web-antd/src/views/system/dept/list.vue +++ b/web/apps/web-antd/src/views/system/dept/list.vue @@ -102,8 +102,11 @@ const [Grid, gridApi] = useVbenVxeGrid({ }, proxyConfig: { ajax: { - query: async (_params) => { - return await getDeptList(); + query: async ({ page }, _params) => { + return await getDeptList({ + page: page.currentPage, + pageSize: page.pageSize, + }); }, }, },