From 4a0b845dffde3fa75312ca7f826e4e320fcb6763 Mon Sep 17 00:00:00 2001 From: Yaosanqi137 Date: Sat, 20 Sep 2025 21:01:02 +0800 Subject: [PATCH] feat: add personnel and department manage pages --- src/backend/email_notice/middleware.py | 150 +++++- src/backend/finance/views.py | 67 ++- src/backend/item_manager/settings.py | 39 ++ src/backend/item_manager/urls.py | 1 + src/backend/personnel/__init__.py | 0 src/backend/personnel/admin.py | 117 +++++ src/backend/personnel/apps.py | 7 + src/backend/personnel/filters.py | 67 +++ src/backend/personnel/management/__init__.py | 0 .../personnel/management/commands/__init__.py | 0 .../management/commands/check_expiry.py | 57 +++ .../personnel/management/commands/example.py | 88 ++++ src/backend/personnel/models.py | 124 +++++ src/backend/personnel/serializers.py | 73 +++ src/backend/personnel/urls.py | 11 + src/backend/personnel/views.py | 269 +++++++++++ src/backend/requirements.txt | Bin 190 -> 240 bytes src/backend/scheduler/__init__.py | 10 + src/backend/scheduler/apps.py | 10 + src/backend/scheduler/jobs.py | 66 +++ src/fronted/src/components/AppHeader.vue | 22 +- src/fronted/src/main.js | 1 + src/fronted/src/router/index.js | 12 + src/fronted/src/services/api.js | 126 ++++- .../DepartmentProjectGroupManagement.vue | 344 ++++++++++++++ src/fronted/src/views/PersonnelDetail.vue | 102 +++++ src/fronted/src/views/PersonnelForm.vue | 312 +++++++++++++ src/fronted/src/views/PersonnelList.vue | 430 ++++++++++++++++++ src/fronted/src/views/PersonnelStatistics.vue | 130 ++++++ 29 files changed, 2617 insertions(+), 18 deletions(-) create mode 100644 src/backend/personnel/__init__.py create mode 100644 src/backend/personnel/admin.py create mode 100644 src/backend/personnel/apps.py create mode 100644 src/backend/personnel/filters.py create mode 100644 src/backend/personnel/management/__init__.py create mode 100644 src/backend/personnel/management/commands/__init__.py create mode 100644 src/backend/personnel/management/commands/check_expiry.py create mode 100644 src/backend/personnel/management/commands/example.py create mode 100644 src/backend/personnel/models.py create mode 100644 src/backend/personnel/serializers.py create mode 100644 src/backend/personnel/urls.py create mode 100644 src/backend/personnel/views.py create mode 100644 src/backend/scheduler/__init__.py create mode 100644 src/backend/scheduler/apps.py create mode 100644 src/backend/scheduler/jobs.py create mode 100644 src/fronted/src/views/DepartmentProjectGroupManagement.vue create mode 100644 src/fronted/src/views/PersonnelDetail.vue create mode 100644 src/fronted/src/views/PersonnelForm.vue create mode 100644 src/fronted/src/views/PersonnelList.vue create mode 100644 src/fronted/src/views/PersonnelStatistics.vue diff --git a/src/backend/email_notice/middleware.py b/src/backend/email_notice/middleware.py index 9189583..d16a644 100644 --- a/src/backend/email_notice/middleware.py +++ b/src/backend/email_notice/middleware.py @@ -38,6 +38,12 @@ class EmailNotificationMiddleware(MiddlewareMixin): self._handle_item_operation_async(request, response, user_info) elif '/api/records/' in request.path or '/api/finance/' in request.path: self._handle_finance_operation_async(request, response, user_info) + elif '/api/personnel/' in request.path: + self._handle_personnel_operation_async(request, response, user_info) + elif '/api/departments/' in request.path: + self._handle_department_operation_async(request, response, user_info) + elif '/api/project-groups/' in request.path: + self._handle_project_group_operation_async(request, response, user_info) except Exception as e: logger.error(f"邮件通知中间件处理失败: {e}") @@ -137,7 +143,7 @@ class EmailNotificationMiddleware(MiddlewareMixin): 'department': finance_data.get('department', ''), 'category': finance_data.get('category', ''), 'approver': finance_data.get('approver', ''), - 'timestamp': finance_data.get('transaction_date', ''), + 'timestamp': finance_data.get('updated_at', ''), 'operation_path': request.path, 'operation_method': request.method } @@ -154,6 +160,148 @@ class EmailNotificationMiddleware(MiddlewareMixin): thread.daemon = True # 设置为守护线程 thread.start() + def _handle_personnel_operation_async(self, request, response, user_info): + """异步处理人员操作""" + def send_notification(): + try: + operation_type = self._get_operation_type(request.method, request.path) + + # 尝试从响应中获取数据 + if hasattr(response, 'data'): + personnel_data = response.data + else: + try: + content = response.content.decode('utf-8') + personnel_data = json.loads(content) if content else {} + except: + personnel_data = {} + + # 确保personnel_data不为None + if personnel_data is None: + personnel_data = {} + + # 构建通知数据,使用正确的字段名 + message = personnel_data['message', ''] + personnel_data = personnel_data['data', ''] + notification_data = { + 'message': message, + 'id': personnel_data.get('id', ''), + 'name': personnel_data.get('name', ''), + 'student_id': personnel_data.get('student_id', ''), + 'gender': personnel_data.get('gender', ''), + 'grader_major': personnel_data.get('grade_major', ''), + 'department': personnel_data.get('department', ''), + 'project_group': personnel_data.get('project_group', ''), + 'position': personnel_data.get('position', ''), + 'start_date': personnel_data.get('start_date', ''), + 'end_date': personnel_data.get('end_date', ''), + 'is_active': personnel_data.get('is_active', ''), + 'phone': personnel_data.get('phone', ''), + 'qq': personnel_data.get('qq', ''), + 'email': personnel_data.get('email', ''), + 'description': personnel_data.get('description', ''), + 'timestamp': personnel_data.get('updated_at', ''), + 'operation_path': request.path, + 'operation_method': request.method + } + + EmailNotificationService.send_operation_notification( + operation_type, '人员', notification_data, user_info + ) + + except Exception as e: + logger.error(f"异步处理人员操作通知失败: {e}") + + # 创建并启动后台线程 + thread = threading.Thread(target=send_notification) + thread.daemon = True # 设置为守护线程 + thread.start() + + def _handle_department_operation_async(self, request, response, user_info): + """异步处理部门操作""" + def send_notification(): + try: + operation_type = self._get_operation_type(request.method, request.path) + + # 尝试从响应中获取数据 + if hasattr(response, 'data'): + department_data = response.data + else: + try: + content = response.content.decode('utf-8') + department_data = json.loads(content) if content else {} + except: + department_data = {} + + # 确保department_data不为None + if department_data is None: + department_data = {} + + # 构建通知数据 + notification_data = { + 'id': department_data.get('id', ''), + 'name': department_data.get('name', ''), + 'timestamp': department_data.get('updated_at', ''), + 'operation_path': request.path, + 'operation_method': request.method + } + + EmailNotificationService.send_operation_notification( + operation_type, '部门', notification_data, user_info + ) + + except Exception as e: + logger.error(f"异步处理部门操作通知失败: {e}") + + # 创建并启动后台线程 + thread = threading.Thread(target=send_notification) + thread.daemon = True # 设置为守护线程 + thread.start() + + def _handle_project_group_operation_async(self, request, response, user_info): + """异步处理项目组操作""" + def send_notification(): + try: + operation_type = self._get_operation_type(request.method, request.path) + + # 尝试从响应中获取数据 + if hasattr(response, 'data'): + project_group_data = response.data + else: + try: + content = response.content.decode('utf-8') + project_group_data = json.loads(content) if content else {} + except: + project_group_data = {} + + # 确保project_group_data不为None + if project_group_data is None: + project_group_data = {} + + # 构建通知数据 + notification_data = { + 'id': project_group_data.get('id', ''), + 'name': project_group_data.get('name', ''), + 'department': project_group_data.get('department', ''), + 'department_name': project_group_data.get('department_name', ''), + 'description': project_group_data.get('description', ''), + 'timestamp': project_group_data.get('updated_at', ''), + 'operation_path': request.path, + 'operation_method': request.method + } + + EmailNotificationService.send_operation_notification( + operation_type, '项目组', notification_data, user_info + ) + + except Exception as e: + logger.error(f"异步处理项目组操作通知失败: {e}") + + # 创建并启动后台线程 + thread = threading.Thread(target=send_notification) + thread.daemon = True # 设置为守护线程 + thread.start() + def _get_operation_type(self, method, path): """根据HTTP方法和路径判断操作类型""" if method == 'POST': diff --git a/src/backend/finance/views.py b/src/backend/finance/views.py index 96f10c9..85153b2 100644 --- a/src/backend/finance/views.py +++ b/src/backend/finance/views.py @@ -15,7 +15,7 @@ from .serializers import ( class FinancialRecordViewSet(viewsets.ModelViewSet): """ - API endpoint that allows financial records to be viewed or edited. + 获取财务记录 """ queryset = FinancialRecord.objects.all() @@ -56,6 +56,7 @@ class FinancialRecordViewSet(viewsets.ModelViewSet): 'description': instance.description, 'record_type': instance.record_type, 'transaction_date': str(instance.transaction_date), + 'timestamp': timezone.now().isoformat(), 'department': instance.department.name if instance.department else '', 'category': instance.category.name if instance.category else '', 'fund_manager': instance.fund_manager if hasattr(instance, 'fund_manager') else '' @@ -76,7 +77,6 @@ class FinancialRecordViewSet(viewsets.ModelViewSet): # 立即删除财务记录(会级联删除相关的凭证图片记录) super().destroy(request, *args, **kwargs) - # 异步发送删除通知邮件 def send_delete_notification(): """异步发送删除通知邮件""" try: @@ -96,7 +96,7 @@ class FinancialRecordViewSet(viewsets.ModelViewSet): 'category': record_info['category'], 'fund_manager': record_info['fund_manager'], 'transaction_date': record_info['transaction_date'], - 'deleted_at': timezone.now().isoformat(), + 'timestamp': timezone.now().isoformat(), # 删除条目的时间 :( 'operation_path': request.path, 'operation_method': request.method } @@ -147,7 +147,7 @@ class FinancialRecordViewSet(viewsets.ModelViewSet): class ProofImageViewSet(viewsets.ModelViewSet): """ - API endpoint that allows proof images to be viewed or edited. + 凭证API """ queryset = ProofImage.objects.all() serializer_class = ProofImageSerializer @@ -182,15 +182,70 @@ class ProofImageViewSet(viewsets.ModelViewSet): class DepartmentViewSet(viewsets.ModelViewSet): """ - API endpoint that allows departments to be viewed or edited. + 获取部门 """ queryset = Department.objects.all() serializer_class = DepartmentSerializer + def destroy(self, request, *args, **kwargs): + """删除部门,发送邮件通知""" + from email_notice.services import EmailNotificationService + import threading + import logging + + logger = logging.getLogger(__name__) + + # 获取要删除的部门信息 + department = self.get_object() + department_data = { + 'id': department.id, + 'name': department.name, + } + + # 获取用户信息 + user_info = self._get_user_info(request) + + # 执行删除操作 + response = super().destroy(request, *args, **kwargs) + + # 异步发送删除通知邮件 + def send_delete_notification(): + try: + EmailNotificationService.send_operation_notification( + 'DELETE', '部门', department_data, user_info + ) + except Exception as e: + logger.error(f"发送部门删除通知邮件失败: {e}") + + thread = threading.Thread(target=send_delete_notification) + thread.daemon = True + thread.start() + + return response + + def _get_user_info(self, request): + """获取用户信息""" + try: + if hasattr(request, 'user') and request.user.is_authenticated: + return f"{request.user.username} ({request.user.email})" + else: + return f"匿名用户 (IP: {self._get_client_ip(request)})" + except: + return "未知用户" + + def _get_client_ip(self, request): + """获取客户端IP""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + class CategoryViewSet(viewsets.ModelViewSet): """ - API endpoint that allows categories to be viewed or edited. + 获取分类 """ queryset = Category.objects.all() serializer_class = CategorySerializer diff --git a/src/backend/item_manager/settings.py b/src/backend/item_manager/settings.py index dbfe48f..ac3a406 100644 --- a/src/backend/item_manager/settings.py +++ b/src/backend/item_manager/settings.py @@ -44,9 +44,13 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "rest_framework", "corsheaders", + "django_filters", + "django_apscheduler", "items", "finance", "email_notice", + "personnel", + "scheduler", ] MIDDLEWARE = [ @@ -170,4 +174,39 @@ ADMINS = SECURE["SMTP"]["ADMINS"] # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field +# 日志配置 +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': BASE_DIR / 'logs' / 'scheduler.log', + 'formatter': 'verbose', + }, + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + }, + }, + 'loggers': { + 'scheduler': { + 'handlers': ['file', 'console'], + 'level': 'INFO', + 'propagate': True, + }, + }, +} DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/src/backend/item_manager/urls.py b/src/backend/item_manager/urls.py index f0eb952..8c8c934 100644 --- a/src/backend/item_manager/urls.py +++ b/src/backend/item_manager/urls.py @@ -25,6 +25,7 @@ urlpatterns = [ path("", include("items.urls")), path("", include("finance.urls")), path("", include("email_notice.urls")), + path("", include("personnel.urls")), path("api-auth/", include("rest_framework.urls")), ] diff --git a/src/backend/personnel/__init__.py b/src/backend/personnel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/personnel/admin.py b/src/backend/personnel/admin.py new file mode 100644 index 0000000..2811df0 --- /dev/null +++ b/src/backend/personnel/admin.py @@ -0,0 +1,117 @@ +from django.contrib import admin +from .models import Personnel, ProjectGroup + + +@admin.register(ProjectGroup) +class ProjectGroupAdmin(admin.ModelAdmin): + """项目组管理""" + list_display = ['name', 'department', 'description', 'created_at'] + list_filter = ['department', 'created_at'] + search_fields = ['name', 'description'] + ordering = ['department', 'name'] + +import django_filters +@admin.register(Personnel) +class PersonnelAdmin(admin.ModelAdmin): + """人员信息管理""" + list_display = [ + 'name', 'student_id', 'department', 'project_group', 'position', + 'is_active', 'start_date', 'end_date' + ] + list_filter = [ + 'department', 'project_group', 'position', 'gender', + 'is_active', 'start_date' + ] + search_fields = ['name', 'student_id', 'phone', 'email', 'grade_major'] + ordering = ['-is_active', 'department', 'position', 'name'] + + fieldsets = ( + ('基本信息', { + 'fields': ('name', 'student_id', 'gender', 'grade_major') + }), + ('职位信息', { + 'fields': ('department', 'project_group', 'position', 'start_date', 'end_date', 'is_active') + }), + ('联系方式', { + 'fields': ('phone', 'qq', 'email') + }), + ('其他信息', { + 'fields': ('description',), + 'classes': ('collapse',) + }) + ) + + readonly_fields = ('created_at', 'updated_at') + + def save_model(self, request, obj, form, change): + """保存模型时的额外处理""" + super().save_model(request, obj, form, change) + + def get_queryset(self, request): + """优化查询""" + return super().get_queryset(request).select_related('department', 'project_group') +from .models import Personnel, ProjectGroup +from finance.models import Department + + +class PersonnelFilter(django_filters.FilterSet): + """人员信息过滤器""" + + # 基本筛选 + department = django_filters.ModelChoiceFilter( + queryset=Department.objects.all(), + field_name='department', + label='部门' + ) + + project_group = django_filters.ModelChoiceFilter( + queryset=ProjectGroup.objects.all(), + field_name='project_group', + label='项目组' + ) + + position = django_filters.CharFilter( + field_name='position', + lookup_expr='icontains', + label='职位' + ) + + gender = django_filters.ChoiceFilter( + choices=Personnel.GENDER_CHOICES, + field_name='gender', + label='性别' + ) + + is_active = django_filters.BooleanFilter( + field_name='is_active', + label='在职状态' + ) + + # 日期范围筛选 + start_date_from = django_filters.DateFilter( + field_name='start_date', + lookup_expr='gte', + label='任职开始时间(从)' + ) + + start_date_to = django_filters.DateFilter( + field_name='start_date', + lookup_expr='lte', + label='任职开始时间(到)' + ) + + end_date_from = django_filters.DateFilter( + field_name='end_date', + lookup_expr='gte', + label='任职结束时间(从)' + ) + + end_date_to = django_filters.DateFilter( + field_name='end_date', + lookup_expr='lte', + label='任职结束时间(到)' + ) + + class Meta: + model = Personnel + fields = ['department', 'project_group', 'position', 'gender', 'is_active'] diff --git a/src/backend/personnel/apps.py b/src/backend/personnel/apps.py new file mode 100644 index 0000000..5e659bf --- /dev/null +++ b/src/backend/personnel/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PersonnelConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'personnel' + verbose_name = '人员管理' diff --git a/src/backend/personnel/filters.py b/src/backend/personnel/filters.py new file mode 100644 index 0000000..13290a5 --- /dev/null +++ b/src/backend/personnel/filters.py @@ -0,0 +1,67 @@ +import django_filters +from django.db import models +from .models import Personnel, ProjectGroup +from finance.models import Department + + +class PersonnelFilter(django_filters.FilterSet): + """人员信息过滤器""" + + # 基本筛选 + department = django_filters.ModelChoiceFilter( + queryset=Department.objects.all(), + field_name='department', + label='部门' + ) + + project_group = django_filters.ModelChoiceFilter( + queryset=ProjectGroup.objects.all(), + field_name='project_group', + label='项目组' + ) + + position = django_filters.CharFilter( + field_name='position', + lookup_expr='icontains', + label='职位' + ) + + gender = django_filters.ChoiceFilter( + choices=Personnel.GENDER_CHOICES, + field_name='gender', + label='性别' + ) + + is_active = django_filters.BooleanFilter( + field_name='is_active', + label='在职状态' + ) + + # 日期范围筛选 + start_date_from = django_filters.DateFilter( + field_name='start_date', + lookup_expr='gte', + label='任职开始时间(从)' + ) + + start_date_to = django_filters.DateFilter( + field_name='start_date', + lookup_expr='lte', + label='任职开始时间(到)' + ) + + end_date_from = django_filters.DateFilter( + field_name='end_date', + lookup_expr='gte', + label='任职结束时间(从)' + ) + + end_date_to = django_filters.DateFilter( + field_name='end_date', + lookup_expr='lte', + label='任职结束时间(到)' + ) + + class Meta: + model = Personnel + fields = ['department', 'project_group', 'position', 'gender', 'is_active'] diff --git a/src/backend/personnel/management/__init__.py b/src/backend/personnel/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/personnel/management/commands/__init__.py b/src/backend/personnel/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/personnel/management/commands/check_expiry.py b/src/backend/personnel/management/commands/check_expiry.py new file mode 100644 index 0000000..f9d2c83 --- /dev/null +++ b/src/backend/personnel/management/commands/check_expiry.py @@ -0,0 +1,57 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from personnel.models import Personnel + + +class Command(BaseCommand): + help = '检查人员任职结束时间,自动设置已到期人员为已卸任状态' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='仅显示将要更新的人员,不实际执行更新', + ) + + def handle(self, *args, **options): + today = timezone.now().date() + dry_run = options['dry_run'] + + # 查找任职结束时间已到期但仍在职的人员 + expired_personnel = Personnel.objects.filter( + end_date__lte=today, + is_active=True + ) + + if not expired_personnel.exists(): + self.stdout.write( + self.style.SUCCESS('没有需要设置为已卸任状态的人员') + ) + return + + self.stdout.write( + self.style.WARNING(f'发现 {expired_personnel.count()} 名任职已到期的人员:') + ) + + updated_count = 0 + for person in expired_personnel: + self.stdout.write( + f' - {person.name} (学号: {person.student_id}) ' + f'任职结束时间: {person.end_date} ' + f'部门: {person.department.name} ' + f'职位: {person.position}' + ) + + if not dry_run: + person.is_active = False + person.save() + updated_count += 1 + + if dry_run: + self.stdout.write( + self.style.WARNING('这是预览模式,未实际更新任何数据') + ) + else: + self.stdout.write( + self.style.SUCCESS(f'成功将 {updated_count} 名人员设置为已卸任状态') + ) diff --git a/src/backend/personnel/management/commands/example.py b/src/backend/personnel/management/commands/example.py new file mode 100644 index 0000000..45fcc93 --- /dev/null +++ b/src/backend/personnel/management/commands/example.py @@ -0,0 +1,88 @@ +from django.core.management.base import BaseCommand +from finance.models import Department +from personnel.models import ProjectGroup + + +class Command(BaseCommand): + help = '创建示例的项目组数据' + + def handle(self, *args, **options): + # 确保部门存在,如果不存在则创建 + departments_data = [ + '爱特工作室本部', + '程序部', + 'Web部', + '游戏部', + 'IOS部', + 'APP部', + 'UI部', + '智能应用部', + 'OpenHarmony部', + 'FOSS部' + ] + + self.stdout.write('正在创建部门...') + for dept_name in departments_data: + dept, created = Department.objects.get_or_create(name=dept_name) + if created: + self.stdout.write(f' 创建部门: {dept_name}') + else: + self.stdout.write(f' 部门已存在: {dept_name}') + + # 创建项目组数据 + project_groups_data = [ + # 程序部项目组 + {'name': '后端开发组', 'department_name': '程序部', 'description': '负责后端系统开发与维护'}, + {'name': '算法研究组', 'department_name': '程序部', 'description': '专注算法研究与优化'}, + + # Web部项目组 + {'name': '前端开发组', 'department_name': 'Web部', 'description': '负责前端页面开发'}, + {'name': 'UI设计组', 'department_name': 'Web部', 'description': '负责用户界面设计'}, + + # 游戏部项目组 + {'name': 'Unity开发组', 'department_name': '游戏部', 'description': '使用Unity引擎开发游戏'}, + {'name': '游戏策划组', 'department_name': '游戏部', 'description': '负责游戏策划与设计'}, + + # IOS部项目组 + {'name': 'iOS原生开发组', 'department_name': 'IOS部', 'description': '开发iOS原生应用'}, + {'name': 'Swift开发组', 'department_name': 'IOS部', 'description': '使用Swift语言开发'}, + + # APP部项目组 + {'name': 'Android开发组', 'department_name': 'APP部', 'description': '开发Android应用'}, + {'name': '跨平台开发组', 'department_name': 'APP部', 'description': '使用Flutter、React Native等开发'}, + + # UI部项目组 + {'name': '视觉设计组', 'department_name': 'UI部', 'description': '负责视觉设计与品牌设计'}, + {'name': '交互设计组', 'department_name': 'UI部', 'description': '负责用户体验与交互设计'}, + + # 智能应用部项目组 + {'name': 'AI研发组', 'department_name': '智能应用部', 'description': '人工智能技术研发'}, + {'name': '机器学习组', 'department_name': '智能应用部', 'description': '机器学习算法应用'}, + + # OpenHarmony部项目组 + {'name': 'HarmonyOS开发组', 'department_name': 'OpenHarmony部', 'description': '开发HarmonyOS应用'}, + {'name': '鸿蒙系统组', 'department_name': 'OpenHarmony部', 'description': '鸿蒙生态系统开发'}, + + # FOSS部项目组 + {'name': '开源项目组', 'department_name': 'FOSS部', 'description': '维护和开发开源项目'}, + {'name': 'Linux系统组', 'department_name': 'FOSS部', 'description': 'Linux系统维护与开发'}, + ] + + self.stdout.write('正在创建项目组...') + for group_data in project_groups_data: + try: + department = Department.objects.get(name=group_data['department_name']) + group, created = ProjectGroup.objects.get_or_create( + name=group_data['name'], + department=department, + defaults={'description': group_data['description']} + ) + if created: + self.stdout.write(f' 创建项目组: {group_data["name"]} ({group_data["department_name"]})') + else: + self.stdout.write(f' 项目组已存在: {group_data["name"]}') + except Department.DoesNotExist: + self.stdout.write(self.style.ERROR(f' 部门不存在: {group_data["department_name"]}')) + + self.stdout.write(self.style.SUCCESS('示例数据创建完成!')) + self.stdout.write('现在您可以在人员管理界面中看到这些项目组选项了。') diff --git a/src/backend/personnel/models.py b/src/backend/personnel/models.py new file mode 100644 index 0000000..f85a76b --- /dev/null +++ b/src/backend/personnel/models.py @@ -0,0 +1,124 @@ +from django.db import models +from django.core.validators import RegexValidator +from finance.models import Department + + +class ProjectGroup(models.Model): + """项目组模型""" + name = models.CharField(max_length=100, unique=True, verbose_name="项目组名称") + department = models.ForeignKey(Department, on_delete=models.CASCADE, verbose_name="所属部门") + description = models.TextField(blank=True, null=True, verbose_name="项目组描述") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + + class Meta: + verbose_name = "项目组" + verbose_name_plural = verbose_name + ordering = ['department', 'name'] + + def __str__(self): + return f"{self.department.name} - {self.name}" + + +class Personnel(models.Model): + """人员信息模型""" + GENDER_CHOICES = [ + ('male', '男'), + ('female', '女'), + ] + + # 基本信息 + name = models.CharField(max_length=50, verbose_name="姓名") + student_id = models.CharField( + max_length=20, + unique=True, + verbose_name="学号", + validators=[RegexValidator( + regex=r'^\d{8,12}$', + message='学号应为8-12位数字' + )] + ) + gender = models.CharField(max_length=10, choices=GENDER_CHOICES, verbose_name="性别") + grade_major = models.CharField(max_length=100, verbose_name="年级专业") + + # 职位信息 + department = models.ForeignKey(Department, on_delete=models.CASCADE, verbose_name="所属部门") + project_group = models.ForeignKey( + ProjectGroup, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="项目组" + ) + position = models.CharField(max_length=50, verbose_name="职位") + start_date = models.DateField(verbose_name="任职开始时间") + end_date = models.DateField(null=True, blank=True, verbose_name="任职结束时间") + is_active = models.BooleanField(default=True, verbose_name="是否在职") + + # 联系方式 + phone = models.CharField( + max_length=11, + verbose_name="手机号", + validators=[RegexValidator( + regex=r'^1[3-9]\d{9}$', + message='请输入有效的手机号码' + )] + ) + qq = models.CharField( + max_length=15, + verbose_name="QQ号", + validators=[RegexValidator( + regex=r'^\d{5,15}$', + message='QQ号应为5-15位数字' + )] + ) + email = models.EmailField(verbose_name="邮箱") + + # 详细信息 + description = models.TextField(blank=True, null=True, verbose_name="备注信息") + + # 时间戳 + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + verbose_name = "人员信息" + verbose_name_plural = verbose_name + ordering = ['-is_active', 'department', 'position', 'name'] + indexes = [ + models.Index(fields=['department', 'is_active']), + models.Index(fields=['student_id']), + ] + + def __str__(self): + return f"{self.name} ({self.department.name} - {self.position})" + + @property + def status_display(self): + """返回在职状态显示""" + if self.is_active: + return "在职" + return "已卸任" + + def save(self, *args, **kwargs): + """保存时自动更新在职状态""" + from django.utils import timezone + + # 如果设置了结束时间且已到期,自动设置为已卸任 + if self.end_date and self.end_date <= timezone.now().date(): + self.is_active = False + + super().save(*args, **kwargs) + + @classmethod + def check_and_update_expired_personnel(cls): + """检查并更新所有已到期的人员状态""" + from django.utils import timezone + today = timezone.now().date() + + expired_personnel = cls.objects.filter( + end_date__lte=today, + is_active=True + ) + + updated_count = expired_personnel.update(is_active=False) + return updated_count, list(expired_personnel.values_list('name', flat=True)) diff --git a/src/backend/personnel/serializers.py b/src/backend/personnel/serializers.py new file mode 100644 index 0000000..4a62df3 --- /dev/null +++ b/src/backend/personnel/serializers.py @@ -0,0 +1,73 @@ +from rest_framework import serializers +from .models import Personnel, ProjectGroup +from finance.models import Department + + +class ProjectGroupSerializer(serializers.ModelSerializer): + """项目组序列化器""" + department_name = serializers.CharField(source='department.name', read_only=True) + + class Meta: + model = ProjectGroup + fields = ['id', 'name', 'department', 'department_name', 'description', 'created_at'] + read_only_fields = ['created_at'] + + +class PersonnelReadSerializer(serializers.ModelSerializer): + """人员信息读取序列化器""" + department_name = serializers.CharField(source='department.name', read_only=True) + project_group_name = serializers.CharField(source='project_group.name', read_only=True) + position_display = serializers.CharField(source='position', read_only=True) + gender_display = serializers.CharField(source='get_gender_display', read_only=True) + status_display = serializers.CharField(read_only=True) + + class Meta: + model = Personnel + fields = [ + 'id', 'name', 'student_id', 'gender', 'gender_display', 'grade_major', + 'department', 'department_name', 'project_group', 'project_group_name', + 'position', 'position_display', 'start_date', 'end_date', 'is_active', + 'status_display', 'phone', 'qq', 'email', 'description', + 'created_at', 'updated_at' + ] + + +class PersonnelWriteSerializer(serializers.ModelSerializer): + """人员信息写入序列化器""" + + class Meta: + model = Personnel + fields = [ + 'id', 'name', 'student_id', 'gender', 'grade_major', + 'department', 'project_group', 'position', 'start_date', 'end_date', + 'is_active', 'phone', 'qq', 'email', 'description' + ] + + def validate_student_id(self, value): + """验证学号唯一性""" + if self.instance and self.instance.student_id == value: + return value + + if Personnel.objects.filter(student_id=value).exists(): + raise serializers.ValidationError("该学号已存在") + return value + + def validate_project_group(self, value): + """验证项目组是否属于选定的部门""" + if value and hasattr(self, 'initial_data'): + department_id = self.initial_data.get('department') + if department_id and value.department_id != int(department_id): + raise serializers.ValidationError("项目组必须属于选定的部门") + return value + + def validate(self, attrs): + """整体验证""" + start_date = attrs.get('start_date') + end_date = attrs.get('end_date') + + if start_date and end_date and start_date >= end_date: + raise serializers.ValidationError({ + 'end_date': '任职结束时间必须晚于开始时间' + }) + + return attrs diff --git a/src/backend/personnel/urls.py b/src/backend/personnel/urls.py new file mode 100644 index 0000000..9e61c26 --- /dev/null +++ b/src/backend/personnel/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import PersonnelViewSet, ProjectGroupViewSet + +router = DefaultRouter() +router.register(r'personnel', PersonnelViewSet) +router.register(r'project-groups', ProjectGroupViewSet) + +urlpatterns = [ + path('api/', include(router.urls)), +] diff --git a/src/backend/personnel/views.py b/src/backend/personnel/views.py new file mode 100644 index 0000000..6f79da7 --- /dev/null +++ b/src/backend/personnel/views.py @@ -0,0 +1,269 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import SearchFilter, OrderingFilter +from django.utils import timezone +import logging +from .models import Personnel, ProjectGroup +from .serializers import ( + PersonnelReadSerializer, + PersonnelWriteSerializer, + ProjectGroupSerializer +) +from .filters import PersonnelFilter + +logger = logging.getLogger(__name__) + + +class PersonnelViewSet(viewsets.ModelViewSet): + """人员信息视图集""" + queryset = Personnel.objects.all() + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = PersonnelFilter + search_fields = ['name', 'student_id', 'phone', 'email', 'grade_major'] + ordering_fields = ['created_at', 'start_date', 'end_date', 'name'] + ordering = ['-is_active', 'department', 'position', 'name'] + + def get_serializer_class(self): + """根据操作类型返回不同的序列化器""" + if self.action in ['list', 'retrieve']: + return PersonnelReadSerializer + return PersonnelWriteSerializer + + @action(detail=False, methods=['get']) + def statistics(self, request): + """获取人员统计信息""" + total_count = Personnel.objects.count() + active_count = Personnel.objects.filter(is_active=True).count() + inactive_count = total_count - active_count + + # 按部门统计 + department_stats = {} + for person in Personnel.objects.select_related('department'): + dept_name = person.department.name + if dept_name not in department_stats: + department_stats[dept_name] = {'total': 0, 'active': 0, 'inactive': 0} + department_stats[dept_name]['total'] += 1 + if person.is_active: + department_stats[dept_name]['active'] += 1 + else: + department_stats[dept_name]['inactive'] += 1 + + # 按职位统计 + position_stats = {} + for person in Personnel.objects.all(): + position = person.position + if position not in position_stats: + position_stats[position] = {'total': 0, 'active': 0, 'inactive': 0} + position_stats[position]['total'] += 1 + if person.is_active: + position_stats[position]['active'] += 1 + else: + position_stats[position]['inactive'] += 1 + + return Response({ + 'overview': { + 'total': total_count, + 'active': active_count, + 'inactive': inactive_count + }, + 'by_department': department_stats, + 'by_position': position_stats + }) + + @action(detail=True, methods=['post']) + def set_inactive(self, request, pk=None): + """设置人员为已卸任状态""" + personnel = self.get_object() + personnel.is_active = False + personnel.end_date = timezone.now().date() + personnel.save() + + serializer = self.get_serializer(personnel) + return Response({ + 'message': f'{personnel.name} 已设置为已卸任状态', + 'data': serializer.data + }) + + @action(detail=True, methods=['post']) + def set_active(self, request, pk=None): + """设置人员为在职状态""" + personnel = self.get_object() + personnel.is_active = True + personnel.end_date = None + personnel.save() + + serializer = self.get_serializer(personnel) + return Response({ + 'message': f'{personnel.name} 已设置为在职状态', + 'data': serializer.data + }) + + def destroy(self, request, *args, **kwargs): + """删除人员,发送邮件通知""" + from email_notice.services import EmailNotificationService + import threading + + # 获取要删除的人员信息 + personnel = self.get_object() + personnel_data = { + 'id': personnel.id, + 'name': personnel.name, + 'student_id': personnel.student_id, + 'email': personnel.email, + 'phone': personnel.phone, + 'department_name': personnel.department.name if personnel.department else '', + 'project_group_name': personnel.project_group.name if personnel.project_group else '', + 'position': personnel.position, + 'is_active': personnel.is_active, + 'start_date': str(personnel.start_date), + 'end_date': str(personnel.end_date) if personnel.end_date else '', + 'timestamp': timezone.now().isoformat(), + + } + + # 获取用户信息 + user_info = self._get_user_info(request) + + # 执行删除操作 + response = super().destroy(request, *args, **kwargs) + + # 异步发送删除通知邮件 + def send_delete_notification(): + try: + EmailNotificationService.send_operation_notification( + 'DELETE', '人员', personnel_data, user_info + ) + except Exception as e: + logger.error(f"发送人员删除通知邮件失败: {e}") + + thread = threading.Thread(target=send_delete_notification) + thread.daemon = True + thread.start() + + return response + + def _get_user_info(self, request): + """获取用户信息""" + try: + if hasattr(request, 'user') and request.user.is_authenticated: + return f"{request.user.username} ({request.user.email})" + else: + return f"匿名用户 (IP: {self._get_client_ip(request)})" + except: + return "未知用户" + + def _get_client_ip(self, request): + """获取客户端IP""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + @action(detail=False, methods=['post']) + def check_expired(self, request): + """检查并更新已到期的人员状态""" + updated_count, updated_names = Personnel.check_and_update_expired_personnel() + + if updated_count > 0: + return Response({ + 'message': f'成功将 {updated_count} 名人员设置为已卸任状态', + 'updated_personnel': updated_names, + 'count': updated_count + }) + else: + return Response({ + 'message': '没有需要更新的人员', + 'updated_personnel': [], + 'count': 0 + }) + + +class ProjectGroupViewSet(viewsets.ModelViewSet): + """项目组视图集""" + queryset = ProjectGroup.objects.all() + serializer_class = ProjectGroupSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['department'] + search_fields = ['name', 'description'] + ordering = ['department', 'name'] + + @action(detail=False, methods=['get']) + def by_department(self, request): + """按部门获取项目组""" + department_id = request.query_params.get('department_id') + if department_id: + project_groups = ProjectGroup.objects.filter(department_id=department_id) + serializer = self.get_serializer(project_groups, many=True) + return Response(serializer.data) + return Response([]) + + def destroy(self, request, *args, **kwargs): + """删除项目组,发送邮件通知""" + from email_notice.services import EmailNotificationService + import threading + + # 获取要删除的项目组信息 + project_group = self.get_object() + project_group_data = { + 'id': project_group.id, + 'name': project_group.name, + 'department_name': project_group.department.name if project_group.department else '', + 'description': project_group.description, + 'created_at': str(project_group.created_at), + 'operation_path': request.path, + 'operation_method': request.method + } + + # 获取用户信息 + user_info = self._get_user_info(request) + + # 执行删除操作 + response = super().destroy(request, *args, **kwargs) + + # 异步发送删除通知邮件 + def send_delete_notification(): + try: + EmailNotificationService.send_operation_notification( + 'DELETE', '项目组', project_group_data, user_info + ) + except Exception as e: + logger.error(f"发送项目组删除通知邮件失败: {e}") + + thread = threading.Thread(target=send_delete_notification) + thread.daemon = True + thread.start() + + return response + + def _get_user_info(self, request): + """获取用户信息""" + try: + if hasattr(request, 'user') and request.user.is_authenticated: + return f"{request.user.username} ({request.user.email})" + else: + return f"匿名用户 (IP: {self._get_client_ip(request)})" + except: + return "未知用户" + + def _get_client_ip(self, request): + """获取客户端IP""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + @action(detail=False, methods=['get']) + def by_department(self, request): + """按部门获取项目组""" + department_id = request.query_params.get('department_id') + if department_id: + project_groups = ProjectGroup.objects.filter(department_id=department_id) + serializer = self.get_serializer(project_groups, many=True) + return Response(serializer.data) + return Response([]) diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 4b56a9f30e58f9f13e84054745f25d6df72a6da5..d63878e84cb03c1e944869eee2cc2a5fcb084279 100644 GIT binary patch delta 61 zcmdnT_ 0: + logger.info(f"定时任务执行成功:将 {updated_count} 名人员设置为已卸任状态") + logger.info(f"更新的人员:{', '.join(updated_names)}") + else: + logger.info("定时任务执行成功:没有需要更新的人员") + + except Exception as e: + logger.error(f"定时任务执行失败:{str(e)}") + +@util.close_old_connections +def delete_old_job_executions(max_age=604_800): + """删除旧的任务执行记录(默认保留7天)""" + DjangoJobExecution.objects.delete_old_job_executions(max_age) + +def start_scheduler(): + """启动定时任务调度器""" + scheduler = BackgroundScheduler() + scheduler.add_jobstore(DjangoJobStore(), "default") + + # 添加人员到期检测任务 - 每天早上8点执行 + scheduler.add_job( + check_expired_personnel, + trigger="cron", + hour=8, + minute=0, + id="check_expired_personnel", + max_instances=1, + replace_existing=True, + ) + logger.info("已添加人员到期检测定时任务:每天早上8:00执行") + + # 添加清理旧任务记录的任务 - 每周执行一次 + scheduler.add_job( + delete_old_job_executions, + trigger="cron", + day_of_week="mon", + hour=1, + minute=0, + id="delete_old_job_executions", + max_instances=1, + replace_existing=True, + ) + logger.info("已添加清理旧任务记录任务:每周一凌晨1:00执行") + + try: + logger.info("正在启动定时任务调度器...") + scheduler.start() + logger.info("定时任务调度器启动成功") + except KeyboardInterrupt: + logger.info("正在停止定时任务调度器...") + scheduler.shutdown() + logger.info("定时任务调度器已停止") diff --git a/src/fronted/src/components/AppHeader.vue b/src/fronted/src/components/AppHeader.vue index 4f420bd..9dc82ed 100644 --- a/src/fronted/src/components/AppHeader.vue +++ b/src/fronted/src/components/AppHeader.vue @@ -20,6 +20,14 @@ 使用记录 + + + 人员管理 + + + + 部门管理 + 财务管理 @@ -30,12 +38,8 @@
- - + +
@@ -43,7 +47,7 @@ + + diff --git a/src/fronted/src/views/PersonnelDetail.vue b/src/fronted/src/views/PersonnelDetail.vue new file mode 100644 index 0000000..c5ad4a7 --- /dev/null +++ b/src/fronted/src/views/PersonnelDetail.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/fronted/src/views/PersonnelForm.vue b/src/fronted/src/views/PersonnelForm.vue new file mode 100644 index 0000000..9a9246f --- /dev/null +++ b/src/fronted/src/views/PersonnelForm.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/src/fronted/src/views/PersonnelList.vue b/src/fronted/src/views/PersonnelList.vue new file mode 100644 index 0000000..2d377cd --- /dev/null +++ b/src/fronted/src/views/PersonnelList.vue @@ -0,0 +1,430 @@ + + + + + diff --git a/src/fronted/src/views/PersonnelStatistics.vue b/src/fronted/src/views/PersonnelStatistics.vue new file mode 100644 index 0000000..be7e3ff --- /dev/null +++ b/src/fronted/src/views/PersonnelStatistics.vue @@ -0,0 +1,130 @@ + + + + +