feat: change the main logic of evaluation

This commit is contained in:
2025-11-16 02:49:23 +08:00
parent 77dfe9b6ef
commit a2f48b6bf4
9 changed files with 1103 additions and 620 deletions
+1 -1
View File
@@ -10,5 +10,5 @@ class EvaluationRecordAdmin(admin.ModelAdmin):
'bonus_score', 'deduction_score', 'total_score', 'created_at'
]
list_filter = ['department', 'evaluation_date', 'created_at']
search_fields = ['personnel__name', 'item_description', 'remarks']
search_fields = ['personnel', 'item_description', 'remarks']
ordering = ['-evaluation_date', '-created_at']
+7 -13
View File
@@ -1,6 +1,5 @@
import django_filters
from finance.models import Department
from personnel.models import Personnel
from .models import EvaluationRecord
@@ -11,23 +10,18 @@ class EvaluationRecordFilter(django_filters.FilterSet):
field_name='department',
label='部门'
)
personnel = django_filters.ModelChoiceFilter(
queryset=Personnel.objects.all(),
personnel = django_filters.CharFilter(
field_name='personnel',
lookup_expr='icontains',
label='人员'
)
date_from = django_filters.DateFilter(
field_name='evaluation_date',
lookup_expr='gte',
label='开始日期'
)
date_to = django_filters.DateFilter(
field_name='evaluation_date',
lookup_expr='lte',
label='结束日期'
grade = django_filters.CharFilter(
field_name='grade',
lookup_expr='icontains',
label='年级'
)
class Meta:
model = EvaluationRecord
fields = ['department', 'personnel', 'evaluation_date']
fields = ['department', 'personnel', 'grade']
+4 -7
View File
@@ -10,12 +10,8 @@ class EvaluationRecord(models.Model):
related_name='evaluation_records',
verbose_name='所属部门'
)
personnel = models.ForeignKey(
'personnel.Personnel',
on_delete=models.CASCADE,
related_name='evaluation_records',
verbose_name='人员'
)
personnel = models.CharField(max_length=100, verbose_name='人员')
grade = models.CharField(max_length=50, blank=True, verbose_name='年级')
item_description = models.CharField(max_length=255, verbose_name='加/扣分事项说明')
bonus_score = models.DecimalField(max_digits=8, decimal_places=2, default=0, verbose_name='加分数值')
deduction_score = models.DecimalField(max_digits=8, decimal_places=2, default=0, verbose_name='扣分数值')
@@ -32,10 +28,11 @@ class EvaluationRecord(models.Model):
indexes = [
models.Index(fields=['department', 'evaluation_date']),
models.Index(fields=['personnel', 'evaluation_date']),
models.Index(fields=['evaluation_date']),
]
def __str__(self):
return f'{self.evaluation_date} {self.personnel.name} ({self.total_score})'
return f'{self.evaluation_date} {self.personnel} ({self.total_score})'
def save(self, *args, **kwargs):
self.total_score = (self.bonus_score or 0) - (self.deduction_score or 0)
+24 -3
View File
@@ -1,18 +1,39 @@
from rest_framework import serializers
from django.db.models import Sum, Count, Q
from .models import EvaluationRecord
class EvaluationRecordSerializer(serializers.ModelSerializer):
department_name = serializers.CharField(source='department.name', read_only=True)
personnel_name = serializers.CharField(source='personnel.name', read_only=True)
personnel_name = serializers.CharField(source='personnel', read_only=True)
def validate_department(self, value):
"""验证部门字段"""
if value is None:
raise serializers.ValidationError('部门不能为空')
if isinstance(value, dict):
raise serializers.ValidationError('部门必须是一个有效的ID,不能是对象')
return value
class Meta:
model = EvaluationRecord
fields = [
'id', 'department', 'department_name', 'personnel', 'personnel_name',
'id', 'department', 'department_name', 'personnel', 'personnel_name', 'grade',
'item_description', 'bonus_score', 'deduction_score', 'remarks',
'total_score', 'evaluation_date', 'created_at', 'updated_at'
]
read_only_fields = ['total_score', 'created_at', 'updated_at']
read_only_fields = ['id', 'total_score', 'created_at', 'updated_at']
class PersonnelSummarySerializer(serializers.Serializer):
"""人员汇总序列化器"""
personnel = serializers.CharField()
department_name = serializers.CharField()
grade = serializers.CharField()
total_bonus = serializers.DecimalField(max_digits=10, decimal_places=2)
total_deduction = serializers.DecimalField(max_digits=10, decimal_places=2)
total_score = serializers.DecimalField(max_digits=10, decimal_places=2)
bonus_count = serializers.IntegerField()
deduction_count = serializers.IntegerField()
+405 -107
View File
@@ -5,6 +5,7 @@ from decimal import Decimal
import os
from django.db import transaction
from django.db.models import Sum, Count, Q
from django.http import HttpResponse
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
@@ -15,72 +16,243 @@ from rest_framework.response import Response
from rest_framework_simplejwt.authentication import JWTAuthentication
from openpyxl import load_workbook
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
from finance.models import Department
from personnel.models import Personnel
from .filters import EvaluationRecordFilter
from .models import EvaluationRecord
from .serializers import EvaluationRecordSerializer
from .serializers import EvaluationRecordSerializer, PersonnelSummarySerializer
class EvaluationRecordViewSet(viewsets.ModelViewSet):
"""考评记录视图集"""
authentication_classes = [JWTAuthentication]
queryset = EvaluationRecord.objects.select_related('department', 'personnel').all()
queryset = EvaluationRecord.objects.select_related('department').all()
serializer_class = EvaluationRecordSerializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = EvaluationRecordFilter
search_fields = ['item_description', 'remarks', 'personnel__name', 'department__name']
search_fields = ['item_description', 'remarks', 'personnel', 'department__name', 'grade']
ordering_fields = ['evaluation_date', 'created_at', 'total_score', 'bonus_score', 'deduction_score']
ordering = ['-evaluation_date', '-created_at']
@action(detail=False, methods=['get'], url_path='personnel-summary')
def personnel_summary(self, request, *args, **kwargs):
"""获取人员汇总列表"""
queryset = self.filter_queryset(self.get_queryset())
# 按人员、部门、年级分组汇总
summary_data = queryset.values('personnel', 'department__name', 'grade').annotate(
total_bonus=Sum('bonus_score'),
total_deduction=Sum('deduction_score'),
bonus_count=Count('id', filter=Q(bonus_score__gt=0)),
deduction_count=Count('id', filter=Q(deduction_score__gt=0)),
).order_by('department__name', 'personnel')
# 计算总分
result = []
for item in summary_data:
total_score = (item['total_bonus'] or Decimal('0')) - (item['total_deduction'] or Decimal('0'))
result.append({
'personnel': item['personnel'],
'department_name': item['department__name'] or '',
'grade': item['grade'] or '',
'total_bonus': item['total_bonus'] or Decimal('0'),
'total_deduction': item['total_deduction'] or Decimal('0'),
'total_score': total_score,
'bonus_count': item['bonus_count'],
'deduction_count': item['deduction_count'],
})
serializer = PersonnelSummarySerializer(result, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], url_path='personnel-records')
def personnel_records(self, request, *args, **kwargs):
"""获取某个人员的所有记录"""
personnel_name = request.query_params.get('personnel')
if not personnel_name:
return Response({'detail': '缺少personnel参数'}, status=status.HTTP_400_BAD_REQUEST)
queryset = self.get_queryset().filter(personnel=personnel_name).order_by('-evaluation_date')
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=['delete'], url_path='delete-personnel')
def delete_personnel(self, request, *args, **kwargs):
"""删除某个人员的所有记录"""
personnel_name = request.query_params.get('personnel')
department_name = request.query_params.get('department')
grade = request.query_params.get('grade', '')
if not personnel_name:
return Response({'detail': '缺少personnel参数'}, status=status.HTTP_400_BAD_REQUEST)
# 构建查询条件
filter_params = {'personnel': personnel_name}
if department_name:
filter_params['department__name'] = department_name
if grade:
filter_params['grade'] = grade
# 查找要删除的记录
queryset = self.get_queryset().filter(**filter_params)
deleted_count = queryset.count()
if deleted_count == 0:
return Response({'detail': '未找到要删除的记录'}, status=status.HTTP_404_NOT_FOUND)
# 删除记录
queryset.delete()
return Response({
'detail': f'成功删除 {deleted_count} 条记录',
'deleted_count': deleted_count
}, status=status.HTTP_200_OK)
@action(detail=False, methods=['get'], url_path='export')
def export_records(self, request, *args, **kwargs):
"""导出人员考评记录,每个人一个表格"""
queryset = self.filter_queryset(self.get_queryset())
if not queryset.exists():
return Response({'detail': '暂无数据可导出'}, status=status.HTTP_400_BAD_REQUEST)
filename = f'evaluation_records_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv'
response = HttpResponse(content_type='text/csv; charset=utf-8')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response.write('\ufeff')
writer = csv.writer(response)
headers = [
'id',
'department_id',
'department_name',
'personnel_id',
'personnel_name',
'item_description',
'bonus_score',
'deduction_score',
'total_score',
'evaluation_date',
'remarks',
'created_at',
'updated_at',
]
writer.writerow(headers)
# 创建Excel工作簿
wb = Workbook()
wb.remove(wb.active) # 删除默认工作表
# 按人员分组
personnel_groups = {}
for record in queryset:
writer.writerow([
record.id,
record.department_id,
record.department.name if record.department else '',
record.personnel_id,
record.personnel.name if record.personnel else '',
personnel_key = f"{record.personnel}_{record.department.name if record.department else ''}"
if personnel_key not in personnel_groups:
personnel_groups[personnel_key] = {
'personnel': record.personnel,
'department_name': record.department.name if record.department else '',
'grade': record.grade or '',
'records': []
}
personnel_groups[personnel_key]['records'].append(record)
# 样式定义
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
summary_fill = PatternFill(start_color="DCE6F1", end_color="DCE6F1", fill_type="solid")
summary_font = Font(bold=True)
# 为每个人创建表格
for idx, (key, group) in enumerate(personnel_groups.items(), 1):
ws = wb.create_sheet(title=f"{group['personnel']}_{idx}")
# 计算统计信息
total_bonus = sum(record.bonus_score for record in group['records'])
total_deduction = sum(record.deduction_score for record in group['records'])
total_score = total_bonus - total_deduction
bonus_count = sum(1 for record in group['records'] if record.bonus_score > 0)
deduction_count = sum(1 for record in group['records'] if record.deduction_score > 0)
# 基本信息行
ws.append(['基本信息'])
ws.append(['部门', group['department_name']])
ws.append(['姓名', group['personnel']])
ws.append(['年级', group['grade']])
ws.append([])
# 统计信息行
ws.append(['统计信息'])
ws.append(['总加分', f'{total_bonus:.2f}'])
ws.append(['总扣分', f'{total_deduction:.2f}'])
ws.append(['加分次数', bonus_count])
ws.append(['扣分次数', deduction_count])
ws.append([])
# 记录表头
headers = ['扣分/加分说明', '考评时间', '分值', '备注']
ws.append(headers)
# 设置表头样式
for col in range(1, len(headers) + 1):
cell = ws.cell(row=ws.max_row, column=col)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center')
# 添加记录数据
for record in sorted(group['records'], key=lambda x: x.evaluation_date, reverse=True):
score_value = ''
if record.bonus_score > 0:
score_value = f"+{record.bonus_score:.2f}"
elif record.deduction_score > 0:
score_value = f"-{record.deduction_score:.2f}"
ws.append([
record.item_description,
f'{record.bonus_score:.2f}',
f'{record.deduction_score:.2f}',
f'{record.total_score:.2f}',
record.evaluation_date.isoformat() if record.evaluation_date else '',
record.evaluation_date.strftime('%Y-%m-%d') if record.evaluation_date else '',
score_value,
record.remarks or '',
record.created_at.isoformat(sep=' ') if record.created_at else '',
record.updated_at.isoformat(sep=' ') if record.updated_at else '',
])
# 设置列宽
ws.column_dimensions['A'].width = 30
ws.column_dimensions['B'].width = 15
ws.column_dimensions['C'].width = 12
ws.column_dimensions['D'].width = 30
# 保存到内存
output = io.BytesIO()
wb.save(output)
output.seek(0)
filename = f'人员考评记录_{timezone.now().strftime("%Y%m%d_%H%M%S")}.xlsx'
response = HttpResponse(
output.getvalue(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@action(detail=False, methods=['get'], url_path='download-template')
def download_template(self, request, *args, **kwargs):
"""下载导入样表"""
# 创建Excel工作簿
wb = Workbook()
ws = wb.active
ws.title = '人员导入样表'
# 表头
headers = ['部门', '年级', '姓名']
ws.append(headers)
# 设置表头样式
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
for col in range(1, len(headers) + 1):
cell = ws.cell(row=1, column=col)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center')
# 添加示例数据行
ws.append(['程序部', '24', '张三'])
ws.append(['Web部', '23', '李四'])
# 设置列宽
ws.column_dimensions['A'].width = 20
ws.column_dimensions['B'].width = 15
ws.column_dimensions['C'].width = 15
# 保存到内存
output = io.BytesIO()
wb.save(output)
output.seek(0)
filename = f'人员导入样表_{timezone.now().strftime("%Y%m%d")}.xlsx'
response = HttpResponse(
output.getvalue(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@action(detail=False, methods=['post'], url_path='import')
@@ -112,62 +284,205 @@ class EvaluationRecordViewSet(viewsets.ModelViewSet):
if not records_data:
return Response({'detail': '导入文件没有数据'}, status=status.HTTP_400_BAD_REQUEST)
reader_fieldnames = records_data[0].keys() if isinstance(records_data[0], dict) else []
required_columns = {
'id',
'department_id',
'department_name',
'personnel_id',
'personnel_name',
'item_description',
'bonus_score',
'deduction_score',
'evaluation_date',
# 字段名映射:中文列名 -> 英文字段名
field_mapping = {
'部门': 'department_name',
'所属部门': 'department_name',
'department_name': 'department_name',
'姓名': 'personnel_name',
'personnel_name': 'personnel_name',
'年级': 'grade',
'grade': 'grade',
'扣分/加分说明': 'item_description',
'item_description': 'item_description',
'加分': 'bonus_score',
'bonus_score': 'bonus_score',
'扣分': 'deduction_score',
'deduction_score': 'deduction_score',
'考评日期': 'evaluation_date',
'evaluation_date': 'evaluation_date',
'考评时间': 'evaluation_date',
'备注': 'remarks',
'remarks': 'remarks',
'分值': 'score',
}
missing_columns = required_columns - set(reader_fieldnames)
if missing_columns:
reader_fieldnames = records_data[0].keys() if isinstance(records_data[0], dict) else []
# 获取实际存在的字段名(可能是中文或英文)
available_field_keys = set()
for field_name in reader_fieldnames:
# 去除首尾空格后查找映射
field_name_clean = field_name.strip() if field_name else ''
mapped_key = field_mapping.get(field_name_clean)
if mapped_key:
available_field_keys.add(mapped_key)
# 检查是样表格式(只有部门、年级、姓名)还是完整格式(有考评记录)
# 样表格式:必须包含部门名和姓名,且不包含扣分/加分说明和考评日期
is_template_format = (
'department_name' in available_field_keys and
'personnel_name' in available_field_keys and
'item_description' not in available_field_keys and
'evaluation_date' not in available_field_keys
)
if not is_template_format:
# 完整格式需要检查必要的列
required_fields = {'department_name', 'personnel_name', 'item_description',
'evaluation_date'}
missing_fields = required_fields - available_field_keys
if missing_fields:
# 将英文字段名转换为中文显示
field_name_map = {
'department_name': '部门',
'personnel_name': '姓名',
'item_description': '扣分/加分说明',
'evaluation_date': '考评日期',
}
missing_display = [field_name_map.get(f, f) for f in sorted(missing_fields)]
return Response(
{'detail': f'缺少必要的列: {", ".join(sorted(missing_columns))}'},
{'detail': f'缺少必要的列: {", ".join(missing_display)}'},
status=status.HTTP_400_BAD_REQUEST,
)
def get_field_value(row_dict, field_key):
"""从行数据中获取字段值,支持中英文列名"""
# 收集所有可能的中文列名(去除首尾空格匹配)
possible_keys = [field_key] # 先尝试英文字段名
# 找到所有映射到该字段的中文列名
for chinese, english in field_mapping.items():
if english == field_key:
possible_keys.append(chinese)
# 尝试所有可能的键(包括去除空格后的键)
for key in possible_keys:
# 直接匹配
if key in row_dict:
value = row_dict[key]
if value is not None:
return value
# 去除空格后匹配(忽略键名和值的前后空格)
for row_key in row_dict.keys():
if row_key and str(row_key).strip() == key.strip():
value = row_dict[row_key]
if value is not None:
return value
return None
records_to_create = []
seen_ids = set()
errors = []
skipped_count = 0
for idx, row in enumerate(records_data, start=2):
try:
if not isinstance(row, dict):
raise ValueError('数据格式不正确')
record_id = int(row['id']) if row.get('id') else None
if record_id in seen_ids:
raise ValueError('表中存在重复的记录 ID')
seen_ids.add(record_id)
# 根据 department_name 查找部门(支持中英文列名)
department_name = get_field_value(row, 'department_name')
if department_name is None:
# 显示实际存在的列名,帮助调试
available_keys = list(row.keys())
raise ValueError(f'部门名称不能为空。当前行的列名: {available_keys}')
department_name = str(department_name).strip()
if not department_name:
available_keys = list(row.keys())
raise ValueError(f'部门名称不能为空。当前行的列名: {available_keys}')
department = Department.objects.filter(name=department_name).first()
if not department:
raise ValueError(f'找不到部门: {department_name}')
department = self._resolve_department(row.get('department_id'), row.get('department_name'))
personnel = self._resolve_personnel(row.get('personnel_id'), row.get('personnel_name'))
# personnel_name 直接作为字符串存储(支持中英文列名)
personnel_name = str(get_field_value(row, 'personnel_name') or '').strip()
if not personnel_name:
raise ValueError('人员名称不能为空')
evaluation_date = datetime.strptime(row['evaluation_date'], '%Y-%m-%d').date()
bonus_score = Decimal(row.get('bonus_score') or '0')
deduction_score = Decimal(row.get('deduction_score') or '0')
remarks = row.get('remarks', '')
# 年级(可选)
grade = str(get_field_value(row, 'grade') or '').strip()
created_at = self._parse_datetime(row.get('created_at'))
updated_at = self._parse_datetime(row.get('updated_at'))
# 检查是否已存在相同姓名、年级、部门的人员记录
existing_records = EvaluationRecord.objects.filter(
personnel=personnel_name,
grade=grade,
department=department
).exists()
if existing_records:
skipped_count += 1
continue # 跳过已存在的人员
# 如果是样表格式(只有部门、年级、姓名),创建初始记录(总分为39)
if is_template_format:
evaluation_date = timezone.now().date()
item_description = '初始分数'
bonus_score = Decimal('39')
deduction_score = Decimal('0')
remarks = ''
else:
# 完整格式,从文件中读取
# 解析日期,支持多种格式(支持中英文列名)
evaluation_date_value = get_field_value(row, 'evaluation_date')
if evaluation_date_value is None:
raise ValueError('考评日期不能为空')
# 如果已经是date类型,直接使用
if isinstance(evaluation_date_value, date):
evaluation_date = evaluation_date_value
else:
evaluation_date_str = str(evaluation_date_value).strip()
evaluation_date = None
for date_format in ('%Y-%m-%d', '%Y/%m/%d', '%Y-%m-%d %H:%M:%S', '%Y/%m/%d %H:%M:%S'):
try:
evaluation_date = datetime.strptime(evaluation_date_str, date_format).date()
break
except ValueError:
continue
if evaluation_date is None:
raise ValueError(f'无法解析日期格式: {evaluation_date_str}')
# 获取加分和扣分(支持中英文列名)
# 支持两种格式:1. 分别的加分/扣分列 2. 统一的分值列(+为加分,-为扣分)
score_value = get_field_value(row, 'score')
bonus_score = Decimal('0')
deduction_score = Decimal('0')
if score_value:
# 从分值列解析
score_str = str(score_value).strip()
if score_str.startswith('+'):
bonus_score = Decimal(str(score_str[1:]) or '0')
elif score_str.startswith('-'):
deduction_score = Decimal(str(score_str[1:]) or '0')
else:
# 尝试解析为数字
try:
score_decimal = Decimal(score_str)
if score_decimal >= 0:
bonus_score = score_decimal
else:
deduction_score = abs(score_decimal)
except (ValueError, TypeError):
pass
else:
# 从分别的列读取
bonus_score = Decimal(str(get_field_value(row, 'bonus_score') or '0'))
deduction_score = Decimal(str(get_field_value(row, 'deduction_score') or '0'))
item_description = str(get_field_value(row, 'item_description') or '').strip()
remarks = str(get_field_value(row, 'remarks') or '').strip()
# total_score 会在 save() 方法中自动计算,不需要手动设置
record = EvaluationRecord(
id=record_id,
department=department,
personnel=personnel,
item_description=row.get('item_description', ''),
personnel=personnel_name,
grade=grade,
item_description=item_description,
bonus_score=bonus_score,
deduction_score=deduction_score,
total_score=bonus_score - deduction_score,
evaluation_date=evaluation_date,
remarks=remarks,
created_at=created_at or timezone.now(),
updated_at=updated_at or timezone.now(),
)
records_to_create.append(record)
except Exception as exc: # pylint: disable=broad-except
@@ -176,44 +491,24 @@ class EvaluationRecordViewSet(viewsets.ModelViewSet):
if errors:
return Response({'detail': '导入失败', 'errors': errors}, status=status.HTTP_400_BAD_REQUEST)
if not records_to_create:
skip_msg = f'跳过 {skipped_count} 条已存在的记录' if skipped_count > 0 else ''
return Response(
{'detail': f'没有新数据需要导入。{skip_msg}'},
status=status.HTTP_200_OK
)
with transaction.atomic():
if not is_template_format:
# 完整格式导入时,先删除所有记录(保持原有行为)
EvaluationRecord.objects.all().delete()
EvaluationRecord.objects.bulk_create(records_to_create)
return Response({'detail': f'成功导入 {len(records_to_create)}考评记录'}, status=status.HTTP_200_OK)
skip_msg = f',跳过 {skipped_count}已存在的记录' if skipped_count > 0 else ''
return Response({
'detail': f'成功导入 {len(records_to_create)} 条记录{skip_msg}'
}, status=status.HTTP_200_OK)
@staticmethod
def _parse_datetime(value):
if not value:
return None
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S.%f', '%Y-%m-%dT%H:%M:%S.%f'):
try:
return datetime.strptime(value, fmt)
except ValueError:
continue
raise ValueError('无法解析日期时间字段')
@staticmethod
def _resolve_department(department_id, department_name):
department = None
if department_id:
department = Department.objects.filter(id=department_id).first()
if not department and department_name:
department = Department.objects.filter(name=department_name).first()
if not department:
raise ValueError('无法匹配部门')
return department
@staticmethod
def _resolve_personnel(personnel_id, personnel_name):
personnel = None
if personnel_id:
personnel = Personnel.objects.filter(id=personnel_id).first()
if not personnel and personnel_name:
personnel = Personnel.objects.filter(name=personnel_name).first()
if not personnel:
raise ValueError('无法匹配人员')
return personnel
@staticmethod
def _read_excel(file_bytes):
@@ -238,13 +533,16 @@ class EvaluationRecordViewSet(viewsets.ModelViewSet):
if not header:
continue
value = row[col_idx] if col_idx < len(row) else None
header_key = header.strip().lower()
header_stripped = header.strip()
header_key = header_stripped.lower()
# 检查是否是日期列(支持中英文列名)
is_date_column = (header_key == 'evaluation_date' or header_stripped == '考评日期')
if isinstance(value, datetime):
value = value.strftime('%Y-%m-%d %H:%M:%S')
elif isinstance(value, date):
value = value.strftime('%Y-%m-%d')
elif isinstance(value, (int, float)) and header_key in {'evaluation_date', 'created_at', 'updated_at'}:
value = EvaluationRecordViewSet._excel_date_to_iso(value, header_key != 'evaluation_date')
elif isinstance(value, (int, float)) and is_date_column:
value = EvaluationRecordViewSet._excel_date_to_iso(value, False)
elif isinstance(value, Decimal):
value = str(value)
elif isinstance(value, float):
+7
View File
@@ -13,6 +13,7 @@ import DepartmentProjectGroupManagement from '../views/DepartmentProjectGroupMan
import Settings from '../views/Settings.vue'
import Memo from '../views/Memo.vue'
import EvaluationRecordList from '../views/EvaluationRecordList.vue'
import EvaluationRecordDetail from '../views/EvaluationRecordDetail.vue'
import { authService } from '@/services/api'
const routes = [
@@ -87,6 +88,12 @@ const routes = [
path: '/evaluations',
name: 'EvaluationRecordList',
component: EvaluationRecordList
},
{
path: '/evaluations/:personnel',
name: 'EvaluationRecordDetail',
component: EvaluationRecordDetail,
props: true
}
]
+38 -7
View File
@@ -431,21 +431,28 @@ export const projectGroupService = {
// 考评记录服务
export const evaluationService = {
// 获取考评记录列表
getEvaluationRecords(params = {}) {
return apiClient.get('/evaluation-records/', { params })
// 获取人员汇总列表
getPersonnelSummary(params = {}) {
return apiClient.get('/evaluation-records/personnel-summary/', { params })
},
// 导出考评记录总表
exportEvaluationRecords(params = {}) {
// 获取某个人员的所有记录
getPersonnelRecords(personnelName, params = {}) {
return apiClient.get('/evaluation-records/personnel-records/', {
params: { ...params, personnel: personnelName }
})
},
// 导出人员考评记录
exportPersonnelRecords(params = {}) {
return apiClient.get('/evaluation-records/export/', {
params,
responseType: 'arraybuffer'
})
},
// 导入考评记录总表
importEvaluationRecords(file) {
// 导入人员考评记录
importPersonnelRecords(file) {
const formData = new FormData()
formData.append('file', file)
return apiClient.post('/evaluation-records/import/', formData, {
@@ -455,6 +462,18 @@ export const evaluationService = {
})
},
// 下载导入样表
downloadTemplate() {
return apiClient.get('/evaluation-records/download-template/', {
responseType: 'arraybuffer'
})
},
// 获取考评记录列表(用于详情页)
getEvaluationRecords(params = {}) {
return apiClient.get('/evaluation-records/', { params })
},
// 创建考评记录
createEvaluationRecord(payload) {
return apiClient.post('/evaluation-records/', payload)
@@ -468,6 +487,18 @@ export const evaluationService = {
// 删除考评记录
deleteEvaluationRecord(id) {
return apiClient.delete(`/evaluation-records/${id}/`)
},
// 删除某个人员的所有记录
deletePersonnelRecords(personnelName, departmentName = '', grade = '') {
const params = { personnel: personnelName }
if (departmentName) {
params.department = departmentName
}
if (grade) {
params.grade = grade
}
return apiClient.delete('/evaluation-records/delete-personnel/', { params })
}
}
@@ -0,0 +1,468 @@
<template>
<div>
<AppHeader/>
<div class="detail-container">
<el-card>
<template #header>
<div class="card-header">
<el-button @click="$router.go(-1)">
<el-icon><ArrowLeft/></el-icon>
返回
</el-button>
<span>考评记录详情</span>
<div></div>
</div>
</template>
<!-- 基本信息 -->
<div class="info-section">
<h3>基本信息</h3>
<el-descriptions :column="3" border>
<el-descriptions-item label="部门">{{ personnelInfo.department_name }}</el-descriptions-item>
<el-descriptions-item label="年级">{{ personnelInfo.grade || '-' }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ personnelInfo.personnel }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 统计信息 -->
<div class="statistics-section">
<h3>统计信息</h3>
<el-row :gutter="24">
<el-col :span="8">
<el-statistic title="总分" :value="personnelInfo.total_score">
<template #suffix>
<span class="bonus-text"></span>
</template>
</el-statistic>
</el-col>
<el-col :span="4">
<el-statistic title="总加分数" :value="personnelInfo.total_bonus - 39">
<template #suffix>
<span class="bonus-text"></span>
</template>
</el-statistic>
</el-col>
<el-col :span="4">
<el-statistic title="总扣分数" :value="personnelInfo.total_deduction">
<template #suffix>
<span class="deduction-text"></span>
</template>
</el-statistic>
</el-col>
<el-col :span="4">
<el-statistic title="加分次数" :value="personnelInfo.bonus_count">
<template #suffix>
<span></span>
</template>
</el-statistic>
</el-col>
<el-col :span="4">
<el-statistic title="扣分次数" :value="personnelInfo.deduction_count">
<template #suffix>
<span></span>
</template>
</el-statistic>
</el-col>
</el-row>
</div>
<!-- 记录列表 -->
<div class="records-section">
<div class="section-header">
<h3>考评记录</h3>
<el-button type="primary" @click="openCreateDialog">
<el-icon><Plus/></el-icon>
新增记录
</el-button>
</div>
<el-table
:data="records"
style="width: 100%"
v-loading="loading"
stripe>
<el-table-column prop="item_description" label="扣分/加分说明" min-width="200" show-overflow-tooltip/>
<el-table-column prop="evaluation_date" label="考评时间" width="120"/>
<el-table-column label="分值" width="120">
<template #default="scope">
<span :class="getScoreClass(scope.row)">
{{ formatScore(scope.row) }}
</span>
</template>
</el-table-column>
<el-table-column prop="remarks" label="备注" min-width="200" show-overflow-tooltip/>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button size="small" @click="openEditDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="confirmDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog
:title="isEditing ? '编辑记录' : '新增记录'"
v-model="dialogVisible"
width="600px"
destroy-on-close>
<el-form
ref="recordFormRef"
:model="form"
:rules="formRules"
label-width="120px">
<el-form-item label="扣分/加分说明" prop="item_description">
<el-input
v-model="form.item_description"
placeholder="请输入扣分/加分说明"
maxlength="255"
show-word-limit/>
</el-form-item>
<el-form-item label="考评时间" prop="evaluation_date">
<el-date-picker
v-model="form.evaluation_date"
type="date"
placeholder="请选择日期"
value-format="YYYY-MM-DD"
style="width: 100%"/>
</el-form-item>
<el-form-item label="分值" prop="score">
<el-input-number
v-model="form.score"
:precision="2"
:step="0.5"
controls-position="right"
placeholder="正数为加分,负数为扣分"
style="width: 100%"/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="form.remarks"
placeholder="可填写附加说明"
type="textarea"
:rows="3"
maxlength="255"
show-word-limit/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">
{{ isEditing ? '保存修改' : '创建记录' }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</div>
</template>
<script>
import { evaluationService, financeService } from '@/services/api'
import AppHeader from '@/components/AppHeader.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Plus } from '@element-plus/icons-vue'
export default {
name: 'EvaluationRecordDetail',
components: {
AppHeader,
ArrowLeft,
Plus
},
props: {
personnel: {
type: String,
default: ''
}
},
data() {
return {
loading: false,
saving: false,
personnelInfo: {
personnel: '',
department_name: '',
grade: '',
total_bonus: 0,
total_deduction: 0,
bonus_count: 0,
deduction_count: 0
},
records: [],
dialogVisible: false,
isEditing: false,
currentRecordId: null,
form: this.getEmptyForm(),
formRules: {
item_description: [
{ required: true, message: '请输入扣分/加分说明', trigger: 'blur' },
{ min: 1, max: 255, message: '长度在 1 到 255 个字符', trigger: 'blur' }
],
evaluation_date: [{ required: true, message: '请选择考评时间', trigger: 'change' }],
score: [{ required: true, message: '请输入分值', trigger: 'blur' }]
}
}
},
async mounted() {
await this.fetchData()
},
methods: {
async fetchData() {
this.loading = true
try {
// 从路由参数或props获取人员姓名,确保正确解码
let personnelName = this.personnel
if (!personnelName && this.$route.params.personnel) {
const paramValue = this.$route.params.personnel
try {
// 先尝试解码(处理URL编码的情况)
personnelName = decodeURIComponent(paramValue)
} catch (e) {
// 如果解码失败,可能是已经被解码过了或者是其他格式,直接使用
personnelName = paramValue
}
// 如果解码后还是编码格式(包含%),再次尝试解码
if (personnelName.includes('%')) {
try {
personnelName = decodeURIComponent(personnelName)
} catch (e2) {
// 忽略解码错误
}
}
}
if (!personnelName) {
personnelName = ''
}
const department = this.$route.query.department || ''
const grade = this.$route.query.grade || ''
// 获取人员信息和记录
const [summaryResp, recordsResp] = await Promise.all([
evaluationService.getPersonnelSummary({ personnel: personnelName }),
evaluationService.getPersonnelRecords(personnelName)
])
if (summaryResp.data && summaryResp.data.length > 0) {
this.personnelInfo = summaryResp.data[0]
// 确保personnel字段是正确的(后端返回的应该是正确的)
if (this.personnelInfo.personnel && this.personnelInfo.personnel.includes('%')) {
try {
this.personnelInfo.personnel = decodeURIComponent(this.personnelInfo.personnel)
} catch (e) {
// 忽略解码错误
}
}
} else {
this.personnelInfo = {
personnel: personnelName, // 此时personnelName应该已经是解码后的
department_name: department,
grade: grade,
total_bonus: 0,
total_deduction: 0,
bonus_count: 0,
deduction_count: 0
}
}
this.records = recordsResp.data || []
} catch (error) {
console.error('获取数据失败:', error)
ElMessage.error('加载数据失败')
} finally {
this.loading = false
}
},
getEmptyForm() {
const today = new Date().toISOString().slice(0, 10)
return {
item_description: '',
evaluation_date: today,
score: 0,
remarks: ''
}
},
openCreateDialog() {
this.isEditing = false
this.currentRecordId = null
this.form = this.getEmptyForm()
this.dialogVisible = true
},
openEditDialog(record) {
this.isEditing = true
this.currentRecordId = record.id
const score = Number(record.bonus_score || 0) > 0
? Number(record.bonus_score)
: -Number(record.deduction_score || 0)
this.form = {
item_description: record.item_description,
evaluation_date: record.evaluation_date,
score: score,
remarks: record.remarks || ''
}
this.dialogVisible = true
},
async handleSubmit() {
this.$refs.recordFormRef.validate(async valid => {
if (!valid) return
this.saving = true
try {
const personnelName = this.personnelInfo.personnel
const score = Number(this.form.score) || 0
// 获取部门ID
const departmentId = await this.getDepartmentId()
if (!departmentId) {
ElMessage.error('无法找到部门,请确保人员信息中包含有效的部门名称')
this.saving = false
return
}
const payload = {
department: departmentId,
personnel: personnelName,
grade: this.personnelInfo.grade || '',
item_description: this.form.item_description,
bonus_score: score > 0 ? score : 0,
deduction_score: score < 0 ? Math.abs(score) : 0,
evaluation_date: this.form.evaluation_date,
remarks: this.form.remarks || ''
}
if (this.isEditing && this.currentRecordId) {
await evaluationService.updateEvaluationRecord(this.currentRecordId, payload)
ElMessage.success('更新成功')
} else {
await evaluationService.createEvaluationRecord(payload)
ElMessage.success('创建成功')
}
this.dialogVisible = false
await this.fetchData()
} catch (error) {
console.error('保存失败:', error)
const errorMsg = error.response?.data?.detail || error.response?.data?.department?.[0] || '保存失败'
ElMessage.error(errorMsg)
} finally {
this.saving = false
}
})
},
async confirmDelete(record) {
try {
await ElMessageBox.confirm(
`确定要删除这条记录吗?`,
'删除确认',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
await evaluationService.deleteEvaluationRecord(record.id)
ElMessage.success('删除成功')
await this.fetchData()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
}
},
async getDepartmentId() {
// 根据部门名称查找部门ID
try {
const resp = await financeService.getAllDepartments()
const dept = resp.data.find(d => d.name === this.personnelInfo.department_name)
return dept ? dept.id : null
} catch (error) {
console.error('获取部门ID失败:', error)
return null
}
},
formatScore(record) {
if (record.bonus_score > 0) {
return `+${Number(record.bonus_score).toFixed(2)}`
} else if (record.deduction_score > 0) {
return `-${Number(record.deduction_score).toFixed(2)}`
}
return '0.00'
},
getScoreClass(record) {
if (record.bonus_score > 0) {
return 'bonus-text'
} else if (record.deduction_score > 0) {
return 'deduction-text'
}
return ''
},
formatNumber(value) {
const num = Number(value || 0)
return num.toFixed(2)
}
}
}
</script>
<style scoped>
.detail-container {
margin: 20px auto;
padding: 0 20px;
max-width: 1200px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 18px;
font-weight: 600;
}
.info-section,
.statistics-section,
.records-section {
margin-bottom: 30px;
}
.info-section h3,
.statistics-section h3,
.records-section h3 {
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-header h3 {
margin: 0;
}
.bonus-text {
color: #67c23a;
font-weight: 600;
}
.deduction-text {
color: #f56c6c;
font-weight: 600;
}
.dialog-footer {
text-align: right;
}
@media (max-width: 768px) {
.detail-container {
padding: 0 10px;
}
}
</style>
+141 -474
View File
@@ -11,21 +11,17 @@
<el-icon><Refresh/></el-icon>
重置筛选
</el-button>
<el-button type="primary" @click="openCreateDialog">
<el-icon><Plus/></el-icon>
新增记录
</el-button>
<el-button type="success" @click="openSummaryDialog">
<el-icon><Search/></el-icon>
查看总表
<el-button type="success" :loading="downloadingTemplate" @click="handleDownloadTemplate">
<el-icon><Download/></el-icon>
下载样表
</el-button>
<el-button type="warning" :loading="exporting" @click="handleExport">
<el-icon><Download/></el-icon>
导出总表
导出人员
</el-button>
<el-button type="info" :loading="importing" @click="triggerImport">
<el-icon><UploadFilled/></el-icon>
导入总表
导入人员
</el-button>
<input
ref="importInput"
@@ -53,76 +49,46 @@
:value="String(dept.id)"/>
</el-select>
</el-form-item>
<el-form-item label="人员">
<el-select
v-model="filters.personnel"
placeholder="选择人员"
<el-form-item label="年级">
<el-input
v-model="filters.grade"
placeholder="输入年级"
style="min-width: 200px"
clearable
filterable
@change="fetchRecords">
<el-option
v-for="person in personnel"
:key="person.id"
:label="person.name"
:value="String(person.id)"/>
</el-select>
</el-input>
</el-form-item>
<el-form-item label="日期">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
unlink-panels
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="fetchRecords"/>
</el-form-item>
<el-form-item label="搜索">
<el-form-item label="姓名">
<el-input
v-model="filters.keyword"
placeholder="事项说明 / 备注 / 人员"
v-model="filters.personnel"
placeholder="输入人员姓名"
style="min-width: 200px"
clearable
@change="fetchRecords">
<template #prefix>
<el-icon><Search/></el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
</div>
<el-table
:data="records"
:data="personnelList"
style="width: 100%"
v-loading="loading"
stripe>
<el-table-column prop="evaluation_date" label="考评日期" width="120"/>
<el-table-column prop="department_name" label="部门" width="150"/>
<el-table-column prop="personnel_name" label="姓名" width="120"/>
<el-table-column prop="item_description" label="加/扣分事项说明" min-width="220" show-overflow-tooltip/>
<el-table-column prop="bonus_score" label="分" width="90">
<template #default="scope">
<span class="bonus-text">+{{ formatNumber(scope.row.bonus_score) }}</span>
</template>
</el-table-column>
<el-table-column prop="deduction_score" label="扣分" width="90">
<template #default="scope">
<span class="deduction-text">-{{ formatNumber(scope.row.deduction_score) }}</span>
</template>
</el-table-column>
<el-table-column prop="total_score" label="总计分数" width="110">
stripe
table-layout="auto">
<el-table-column prop="department_name" label="所属部门" min-width="150"/>
<el-table-column prop="grade" label="年级" min-width="120"/>
<el-table-column prop="personnel" label="姓名" min-width="120"/>
<el-table-column label="目前总分" min-width="120">
<template #default="scope">
<el-tag :type="scope.row.total_score >= 0 ? 'success' : 'danger'">
{{ formatNumber(scope.row.total_score) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remarks" label="备注" min-width="160" show-overflow-tooltip/>
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button size="small" @click="openEditDialog(scope.row)">编辑</el-button>
<el-button size="small" type="primary" @click="editPersonnel(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="confirmDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
@@ -130,250 +96,60 @@
<div class="summary-bar">
<div>
<span>记录{{ records.length }}</span>
<span>总加{{ formatNumber(totalBonus) }}</span>
<span>总扣分{{ formatNumber(totalDeduction) }}</span>
<span>综合分{{ formatNumber(totalScore) }}</span>
<span>人员{{ personnelList.length }}</span>
<span>平均{{ averageScore }}</span>
</div>
</div>
</el-card>
<el-dialog
title="个人分数总表"
v-model="summaryDialogVisible"
width="640px"
destroy-on-close>
<div v-if="personSummary.length">
<el-table
:data="personSummary"
border
size="small"
style="width: 100%">
<el-table-column prop="name" label="姓名" min-width="140"/>
<el-table-column label="总加分" width="120">
<template #default="scope">
<span class="bonus-text">+{{ formatNumber(scope.row.bonus) }}</span>
</template>
</el-table-column>
<el-table-column label="总扣分" width="120">
<template #default="scope">
<span class="deduction-text">-{{ formatNumber(scope.row.deduction) }}</span>
</template>
</el-table-column>
<el-table-column label="综合分" width="120">
<template #default="scope">
<el-tag :type="scope.row.total >= 0 ? 'success' : 'danger'">
{{ formatNumber(scope.row.total) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div v-else class="summary-empty">
暂无数据
</div>
<template #footer>
<div class="summary-footer">
<div>
<span>总加分{{ formatNumber(summaryTotals.bonus) }}</span>
<span>总扣分{{ formatNumber(summaryTotals.deduction) }}</span>
<span>综合分{{ formatNumber(summaryTotals.total) }}</span>
</div>
<el-button @click="summaryDialogVisible = false">关闭</el-button>
</div>
</template>
</el-dialog>
<el-dialog
:title="isEditing ? '编辑考评记录' : '新增考评记录'"
v-model="dialogVisible"
width="520px"
destroy-on-close>
<el-form
ref="recordFormRef"
:model="form"
:rules="formRules"
label-width="110px">
<el-form-item label="所属部门" prop="department">
<el-select v-model="form.department" placeholder="请选择部门" style="width: 260px">
<el-option
v-for="dept in departments"
:key="dept.id"
:label="dept.name"
:value="String(dept.id)"/>
</el-select>
</el-form-item>
<el-form-item label="人员" prop="personnel">
<el-select v-model="form.personnel" placeholder="请选择人员" filterable style="width: 260px">
<el-option
v-for="person in personnel"
:key="person.id"
:label="person.name"
:value="String(person.id)"/>
</el-select>
</el-form-item>
<el-form-item label="考评日期" prop="evaluation_date">
<el-date-picker
v-model="form.evaluation_date"
type="date"
placeholder="请选择日期"
value-format="YYYY-MM-DD"/>
</el-form-item>
<el-form-item label="事项说明" prop="item_description">
<el-input
v-model="form.item_description"
placeholder="请输入加/扣分的事项说明"
maxlength="255"
show-word-limit/>
</el-form-item>
<el-form-item label="加分数值" prop="bonus_score">
<el-input-number
v-model="form.bonus_score"
:min="0"
:precision="2"
:step="0.5"
controls-position="right"
/>
</el-form-item>
<el-form-item label="扣分数值" prop="deduction_score">
<el-input-number
v-model="form.deduction_score"
:min="0"
:precision="2"
:step="0.5"
controls-position="right"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="form.remarks"
placeholder="可填写附加说明"
type="textarea"
:rows="3"
maxlength="255"
show-word-limit/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">
{{ isEditing ? '保存修改' : '创建记录' }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</div>
</template>
<script>
import { evaluationService, financeService, personnelService } from '@/services/api'
import { evaluationService, financeService } from '@/services/api'
import AppHeader from '@/components/AppHeader.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Download, UploadFilled } from '@element-plus/icons-vue'
import { Refresh, Download, UploadFilled } from '@element-plus/icons-vue'
import * as XLSX from 'xlsx'
export default {
name: 'EvaluationRecordList',
components: {
AppHeader,
Plus,
Refresh,
Search,
Download,
UploadFilled
},
data() {
return {
loading: false,
saving: false,
exporting: false,
importing: false,
records: [],
downloadingTemplate: false,
personnelList: [],
departments: [],
personnel: [],
dialogVisible: false,
summaryDialogVisible: false,
isEditing: false,
currentRecordId: null,
filters: {
department: '',
personnel: '',
keyword: '',
dateRange: []
},
form: this.getEmptyForm(),
formRules: {
department: [{ required: true, message: '请选择部门', trigger: 'change' }],
personnel: [{ required: true, message: '请选择人员', trigger: 'change' }],
evaluation_date: [{ required: true, message: '请选择考评日期', trigger: 'change' }],
item_description: [
{ required: true, message: '请填写事项说明', trigger: 'blur' },
{ min: 1, max: 255, message: '长度在 1 到 255 个字符', trigger: 'blur' }
],
bonus_score: [{ type: 'number', min: 0, message: '加分数值不能为负', trigger: 'change' }],
deduction_score: [{ type: 'number', min: 0, message: '扣分数值不能为负', trigger: 'change' }]
grade: ''
}
}
},
computed: {
totalBonus() {
return this.records.reduce((sum, item) => sum + Number(item.bonus_score || 0), 0)
},
personSummary() {
const summaryMap = new Map()
this.records.forEach(record => {
const id = this.extractPersonKey(record)
const existing = summaryMap.get(id) || {
id,
name: this.resolvePersonName(record),
bonus: 0,
deduction: 0,
total: 0
averageScore() {
if (!this.personnelList || this.personnelList.length === 0) {
return '0.00'
}
existing.bonus += Number(record.bonus_score || 0)
existing.deduction += Number(record.deduction_score || 0)
existing.total += Number(record.total_score || 0)
summaryMap.set(id, existing)
})
return Array.from(summaryMap.values()).sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
},
summaryTotals() {
return this.personSummary.reduce(
(acc, item) => {
acc.bonus += item.bonus
acc.deduction += item.deduction
acc.total += item.total
return acc
},
{ bonus: 0, deduction: 0, total: 0 }
)
},
totalDeduction() {
return this.records.reduce((sum, item) => sum + Number(item.deduction_score || 0), 0)
},
totalScore() {
return this.records.reduce((sum, item) => sum + Number(item.total_score || 0), 0)
const total = this.personnelList.reduce((sum, item) => sum + Number(item.total_score || 0), 0)
const average = total / this.personnelList.length
return average.toFixed(2)
}
},
async mounted() {
await Promise.all([this.fetchDepartments(), this.fetchPersonnel()])
await this.fetchDepartments()
await this.fetchRecords()
},
methods: {
getEmptyForm() {
const today = new Date().toISOString().slice(0, 10)
return {
department: '',
personnel: '',
evaluation_date: today,
item_description: '',
bonus_score: 0,
deduction_score: 0,
remarks: ''
}
},
async fetchDepartments() {
try {
const resp = await financeService.getAllDepartments()
@@ -383,36 +159,26 @@ export default {
ElMessage.error('获取部门信息失败')
}
},
async fetchPersonnel() {
try {
const resp = await personnelService.getAllPersonnel()
this.personnel = resp.data
} catch (error) {
console.error('获取人员失败:', error)
ElMessage.error('获取人员信息失败')
}
},
buildQueryParams() {
const params = {}
const department = this.parseIdForApi(this.filters.department)
const personnel = this.parseIdForApi(this.filters.personnel)
if (department !== null) params.department = department
if (personnel !== null) params.personnel = personnel
if (this.filters.keyword) params.search = this.filters.keyword
if (this.filters.dateRange && this.filters.dateRange.length === 2) {
params.date_from = this.filters.dateRange[0]
params.date_to = this.filters.dateRange[1]
if (this.filters.personnel && this.filters.personnel.trim()) {
params.personnel = this.filters.personnel.trim()
}
if (this.filters.grade && this.filters.grade.trim()) {
params.grade = this.filters.grade.trim()
}
return params
},
async fetchRecords() {
this.loading = true
try {
const resp = await evaluationService.getEvaluationRecords(this.buildQueryParams())
this.records = resp.data
const resp = await evaluationService.getPersonnelSummary(this.buildQueryParams())
this.personnelList = resp.data
} catch (error) {
console.error('获取考评记录失败:', error)
ElMessage.error('加载考评记录失败')
console.error('获取人员列表失败:', error)
ElMessage.error('加载人员列表失败')
} finally {
this.loading = false
}
@@ -421,19 +187,72 @@ export default {
this.filters = {
department: '',
personnel: '',
keyword: '',
dateRange: []
grade: ''
}
this.fetchRecords()
},
openCreateDialog() {
this.isEditing = false
this.currentRecordId = null
this.form = this.getEmptyForm()
this.dialogVisible = true
editPersonnel(row) {
this.$router.push({
name: 'EvaluationRecordDetail',
params: {
personnel: row.personnel
},
openSummaryDialog() {
this.summaryDialogVisible = true
query: {
department: row.department_name,
grade: row.grade || ''
}
})
},
async confirmDelete(row) {
try {
await ElMessageBox.confirm(
`确定要删除 ${row.personnel} 的所有考评记录吗?此操作不可恢复!`,
'删除确认',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
// 调用API删除该人员的所有记录
await evaluationService.deletePersonnelRecords(
row.personnel,
row.department_name,
row.grade || ''
)
ElMessage.success('删除成功')
// 刷新列表
await this.fetchRecords()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
console.error('删除失败:', error)
const errorMsg = error.response?.data?.detail || '删除失败'
ElMessage.error(errorMsg)
}
}
},
async handleDownloadTemplate() {
this.downloadingTemplate = true
try {
const response = await evaluationService.downloadTemplate()
const blob = new Blob([response.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '人员导入样表.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('下载成功')
} catch (error) {
console.error('下载样表失败:', error)
ElMessage.error('下载样表失败')
} finally {
this.downloadingTemplate = false
}
},
triggerImport() {
this.$refs.importInput?.click()
@@ -444,11 +263,11 @@ export default {
const file = files[0]
this.importing = true
try {
await evaluationService.importEvaluationRecords(file)
await evaluationService.importPersonnelRecords(file)
ElMessage.success('导入成功')
await this.fetchRecords()
} catch (error) {
console.error('导入考评记录失败:', error)
console.error('导入失败:', error)
const detail = error.response?.data?.detail
const errorList = error.response?.data?.errors
let message = detail || '导入失败'
@@ -461,163 +280,16 @@ export default {
if (event.target) event.target.value = ''
}
},
openEditDialog(record) {
this.isEditing = true
this.currentRecordId = record.id
this.form = {
department: this.toStringId(this.findDepartmentId(record)),
personnel: this.toStringId(this.findPersonnelId(record)),
evaluation_date: record.evaluation_date,
item_description: record.item_description,
bonus_score: Number(record.bonus_score),
deduction_score: Number(record.deduction_score),
remarks: record.remarks || ''
}
this.dialogVisible = true
},
handleSubmit() {
this.$refs.recordFormRef.validate(async valid => {
if (!valid) return
this.saving = true
const payload = {
...this.form,
department: this.parseIdForApi(this.form.department),
personnel: this.parseIdForApi(this.form.personnel),
bonus_score: Number(this.form.bonus_score) || 0,
deduction_score: Number(this.form.deduction_score) || 0
}
try {
if (this.isEditing && this.currentRecordId) {
await evaluationService.updateEvaluationRecord(this.currentRecordId, payload)
ElMessage.success('更新考评记录成功')
} else {
await evaluationService.createEvaluationRecord(payload)
ElMessage.success('新增考评记录成功')
}
this.dialogVisible = false
await this.fetchRecords()
} catch (error) {
console.error('保存考评记录失败:', error)
ElMessage.error('保存考评记录失败')
} finally {
this.saving = false
}
})
},
async confirmDelete(record) {
try {
await ElMessageBox.confirm(
`确定要删除 ${record.personnel_name}${record.evaluation_date} 的考评记录吗?`,
'删除确认',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
await evaluationService.deleteEvaluationRecord(record.id)
ElMessage.success('删除成功')
await this.fetchRecords()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
console.error('删除考评记录失败:', error)
ElMessage.error('删除考评记录失败')
}
}
},
formatNumber(value) {
const num = Number(value || 0)
return num.toFixed(2)
},
toStringId(value) {
if (value === null || value === undefined || value === '') return ''
if (typeof value === 'object') {
return value.id !== undefined && value.id !== null ? String(value.id) : ''
}
return String(value)
},
parseIdForApi(value) {
if (value === '' || value === null || value === undefined) return null
const numeric = Number(value)
return Number.isNaN(numeric) ? value : numeric
},
findDepartmentId(record) {
if (record?.department_id !== undefined && record.department_id !== null) {
return record.department_id
}
if (record?.department) {
if (typeof record.department === 'object') {
const deptObj = record.department
if (deptObj.id !== undefined && deptObj.id !== null) return deptObj.id
} else {
const matchById = this.departments.find(dept => String(dept.id) === String(record.department))
if (matchById) return matchById.id
const matchByName = this.departments.find(dept => dept.name === record.department || dept.name === record.department_name)
if (matchByName) return matchByName.id
}
}
if (record?.department_name) {
const matchByName = this.departments.find(dept => dept.name === record.department_name)
if (matchByName) return matchByName.id
}
return ''
},
findPersonnelId(record) {
if (record?.personnel_id !== undefined && record.personnel_id !== null) {
return record.personnel_id
}
if (record?.personnel) {
if (typeof record.personnel === 'object') {
const personObj = record.personnel
if (personObj.id !== undefined && personObj.id !== null) return personObj.id
} else {
const matchById = this.personnel.find(person => String(person.id) === String(record.personnel))
if (matchById) return matchById.id
const matchByName = this.personnel.find(person => person.name === record.personnel || person.name === record.personnel_name)
if (matchByName) return matchByName.id
}
}
if (record?.personnel_name) {
const matchByName = this.personnel.find(person => person.name === record.personnel_name)
if (matchByName) return matchByName.id
}
return ''
},
extractPersonKey(record) {
if (record?.personnel_id !== undefined && record.personnel_id !== null) {
return `id:${record.personnel_id}`
}
if (record?.personnel && typeof record.personnel === 'object' && record.personnel.id !== undefined && record.personnel.id !== null) {
return `id:${record.personnel.id}`
}
if (record?.personnel) {
return `person:${record.personnel}`
}
if (record?.personnel_name) {
return `name:${record.personnel_name}`
}
return `row:${record.id ?? Math.random()}`
},
resolvePersonName(record) {
if (record?.personnel_name) return record.personnel_name
if (record?.personnel && typeof record.personnel === 'object' && record.personnel.name) return record.personnel.name
if (record?.personnel) return record.personnel
return '未命名人员'
},
async handleExport() {
if (!this.personSummary.length) {
if (!this.personnelList.length) {
ElMessage.warning('暂无数据可导出')
return
}
this.exporting = true
try {
const params = this.buildQueryParams()
const response = await evaluationService.exportEvaluationRecords(params)
const workbook = XLSX.read(response.data, { type: 'array' })
const wbout = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })
const blob = new Blob([wbout], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const response = await evaluationService.exportPersonnelRecords(params)
const blob = new Blob([response.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const filename = this.extractFilename(response.headers?.['content-disposition']) || this.generateExportFilename()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
@@ -629,7 +301,7 @@ export default {
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (error) {
console.error('导出考评记录失败:', error)
console.error('导出失败:', error)
ElMessage.error('导出失败')
} finally {
this.exporting = false
@@ -644,7 +316,16 @@ export default {
const now = new Date()
const pad = value => value.toString().padStart(2, '0')
const dateStr = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
return `考评总表_${dateStr}.xlsx`
return `人员考评记录_${dateStr}.xlsx`
},
formatNumber(value) {
const num = Number(value || 0)
return num.toFixed(2)
},
parseIdForApi(value) {
if (value === '' || value === null || value === undefined) return null
const numeric = Number(value)
return Number.isNaN(numeric) ? value : numeric
}
}
}
@@ -654,6 +335,21 @@ export default {
.evaluation-container {
margin: 20px auto;
padding: 0 20px;
width: 100%;
box-sizing: border-box;
}
.evaluation-container :deep(.el-card) {
width: 100%;
}
.evaluation-container :deep(.el-card__body) {
width: 100%;
box-sizing: border-box;
}
.evaluation-container :deep(.el-table) {
width: 100% !important;
}
.card-header {
@@ -671,6 +367,7 @@ export default {
.filter-section {
margin-bottom: 20px;
width: 100%;
}
.filter-form {
@@ -688,43 +385,14 @@ export default {
justify-content: space-between;
font-size: 14px;
color: #606266;
width: 100%;
box-sizing: border-box;
}
.summary-bar span {
margin-right: 20px;
}
.summary-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.summary-footer span {
margin-right: 16px;
color: #606266;
}
.summary-empty {
padding: 40px 0;
text-align: center;
color: #909399;
}
.dialog-footer {
text-align: right;
}
.bonus-text {
color: #67c23a;
font-weight: 600;
}
.deduction-text {
color: #f56c6c;
font-weight: 600;
}
@media (max-width: 768px) {
.evaluation-container {
padding: 0 10px;
@@ -735,4 +403,3 @@ export default {
}
}
</style>