Merge pull request #1 from ywh5680/main

新添加考评功能
This commit is contained in:
Yaosanqi137
2025-11-15 20:18:33 +08:00
committed by GitHub
19 changed files with 9561 additions and 3 deletions
+62
View File
@@ -0,0 +1,62 @@
# OS artifacts
.DS_Store
Thumbs.db
# IDE / editor folders
.idea/
.vscode/
# Python build artifacts
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.so
*.egg
*.egg-info/
.Python
build/
dist/
# Django / local data
*.sqlite3
media/
staticfiles/
*.log
*/migrations/*.py
!*/migrations/__init__.py
# Virtual environments
env/
venv/
.venv/
src/backend/venv/
# Environment / secret files
*.env
.env.*
src/backend/item_manager/secure.json
# Node / frontend artifacts
node_modules/
src/fronted/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
dist/
src/fronted/dist/
# Coverage / test reports
.coverage
coverage.xml
htmlcov/
# Local tooling
.pytest_cache/
.mypy_cache/
.ruff_cache/
.sass-cache/
.gitignore
+14
View File
@@ -0,0 +1,14 @@
from django.contrib import admin
from .models import EvaluationRecord
@admin.register(EvaluationRecord)
class EvaluationRecordAdmin(admin.ModelAdmin):
list_display = [
'evaluation_date', 'department', 'personnel', 'item_description',
'bonus_score', 'deduction_score', 'total_score', 'created_at'
]
list_filter = ['department', 'evaluation_date', 'created_at']
search_fields = ['personnel__name', 'item_description', 'remarks']
ordering = ['-evaluation_date', '-created_at']
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EvaluationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'evaluation'
+33
View File
@@ -0,0 +1,33 @@
import django_filters
from finance.models import Department
from personnel.models import Personnel
from .models import EvaluationRecord
class EvaluationRecordFilter(django_filters.FilterSet):
department = django_filters.ModelChoiceFilter(
queryset=Department.objects.all(),
field_name='department',
label='部门'
)
personnel = django_filters.ModelChoiceFilter(
queryset=Personnel.objects.all(),
field_name='personnel',
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='结束日期'
)
class Meta:
model = EvaluationRecord
fields = ['department', 'personnel', 'evaluation_date']
+42
View File
@@ -0,0 +1,42 @@
from django.db import models
from django.utils import timezone
class EvaluationRecord(models.Model):
"""考评记录"""
department = models.ForeignKey(
'finance.Department',
on_delete=models.CASCADE,
related_name='evaluation_records',
verbose_name='所属部门'
)
personnel = models.ForeignKey(
'personnel.Personnel',
on_delete=models.CASCADE,
related_name='evaluation_records',
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='扣分数值')
remarks = models.CharField(max_length=255, blank=True, verbose_name='备注')
total_score = models.DecimalField(max_digits=8, decimal_places=2, default=0, verbose_name='总计分数')
evaluation_date = models.DateField(default=timezone.now, 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 = ['-evaluation_date', '-created_at']
indexes = [
models.Index(fields=['department', 'evaluation_date']),
models.Index(fields=['personnel', 'evaluation_date']),
]
def __str__(self):
return f'{self.evaluation_date} {self.personnel.name} ({self.total_score})'
def save(self, *args, **kwargs):
self.total_score = (self.bonus_score or 0) - (self.deduction_score or 0)
super().save(*args, **kwargs)
+18
View File
@@ -0,0 +1,18 @@
from rest_framework import serializers
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)
class Meta:
model = EvaluationRecord
fields = [
'id', 'department', 'department_name', 'personnel', 'personnel_name',
'item_description', 'bonus_score', 'deduction_score', 'remarks',
'total_score', 'evaluation_date', 'created_at', 'updated_at'
]
read_only_fields = ['total_score', 'created_at', 'updated_at']
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import EvaluationRecordViewSet
router = DefaultRouter()
router.register(r'evaluation-records', EvaluationRecordViewSet)
urlpatterns = [
path('api/', include(router.urls)),
]
+271
View File
@@ -0,0 +1,271 @@
import csv
import io
from datetime import datetime, date, timedelta
from decimal import Decimal
import os
from django.db import transaction
from django.http import HttpResponse
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.response import Response
from rest_framework_simplejwt.authentication import JWTAuthentication
from openpyxl import load_workbook
from finance.models import Department
from personnel.models import Personnel
from .filters import EvaluationRecordFilter
from .models import EvaluationRecord
from .serializers import EvaluationRecordSerializer
class EvaluationRecordViewSet(viewsets.ModelViewSet):
"""考评记录视图集"""
authentication_classes = [JWTAuthentication]
queryset = EvaluationRecord.objects.select_related('department', 'personnel').all()
serializer_class = EvaluationRecordSerializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = EvaluationRecordFilter
search_fields = ['item_description', 'remarks', 'personnel__name', 'department__name']
ordering_fields = ['evaluation_date', 'created_at', 'total_score', 'bonus_score', 'deduction_score']
ordering = ['-evaluation_date', '-created_at']
@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)
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 '',
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.remarks or '',
record.created_at.isoformat(sep=' ') if record.created_at else '',
record.updated_at.isoformat(sep=' ') if record.updated_at else '',
])
return response
@action(detail=False, methods=['post'], url_path='import')
def import_records(self, request, *args, **kwargs):
upload = request.FILES.get('file')
if not upload:
return Response({'detail': '请上传文件'}, status=status.HTTP_400_BAD_REQUEST)
file_bytes = upload.read()
records_data = []
ext = os.path.splitext(upload.name or '')[1].lower()
try:
if ext in ('.xlsx', '.xlsm', '.xltx', '.xltm'):
records_data = self._read_excel(file_bytes)
elif ext == '.csv' or not ext:
decoded = file_bytes.decode('utf-8-sig')
reader = csv.DictReader(io.StringIO(decoded))
records_data = list(reader)
else:
decoded = file_bytes.decode('utf-8-sig')
reader = csv.DictReader(io.StringIO(decoded))
records_data = list(reader)
except UnicodeDecodeError:
return Response({'detail': '文件编码必须为UTF-8'}, status=status.HTTP_400_BAD_REQUEST)
except ValueError as exc:
return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
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',
}
missing_columns = required_columns - set(reader_fieldnames)
if missing_columns:
return Response(
{'detail': f'缺少必要的列: {", ".join(sorted(missing_columns))}'},
status=status.HTTP_400_BAD_REQUEST,
)
records_to_create = []
seen_ids = set()
errors = []
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 = self._resolve_department(row.get('department_id'), row.get('department_name'))
personnel = self._resolve_personnel(row.get('personnel_id'), row.get('personnel_name'))
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', '')
created_at = self._parse_datetime(row.get('created_at'))
updated_at = self._parse_datetime(row.get('updated_at'))
record = EvaluationRecord(
id=record_id,
department=department,
personnel=personnel,
item_description=row.get('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
errors.append(f'{idx} 行: {exc}')
if errors:
return Response({'detail': '导入失败', 'errors': errors}, status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
EvaluationRecord.objects.all().delete()
EvaluationRecord.objects.bulk_create(records_to_create)
return Response({'detail': f'成功导入 {len(records_to_create)} 条考评记录'}, 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):
workbook = load_workbook(filename=io.BytesIO(file_bytes), data_only=True)
sheet = workbook.active
rows = list(sheet.iter_rows(values_only=True))
if not rows:
return []
headers = [
(str(cell).strip() if cell is not None else '').strip()
for cell in rows[0]
]
if not any(headers):
raise ValueError('Excel表头为空')
records = []
for row_idx, row in enumerate(rows[1:], start=2):
if row is None:
continue
row_dict = {}
for col_idx, header in enumerate(headers):
if not header:
continue
value = row[col_idx] if col_idx < len(row) else None
header_key = header.strip().lower()
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, Decimal):
value = str(value)
elif isinstance(value, float):
formatted = format(value, 'f')
if '.' in formatted:
formatted = formatted.rstrip('0').rstrip('.')
value = formatted
row_dict[header] = '' if value is None else value
if any(value not in (None, '') for value in row_dict.values()):
records.append(row_dict)
return records
@staticmethod
def _excel_date_to_iso(excel_value, include_time):
try:
float_value = float(excel_value)
except (TypeError, ValueError) as exc:
raise ValueError('无法解析Excel日期') from exc
base_date = datetime(1899, 12, 30)
delta = timedelta(days=float_value)
result = base_date + delta
if include_time:
return result.strftime('%Y-%m-%d %H:%M:%S')
return result.strftime('%Y-%m-%d')
+1
View File
@@ -55,6 +55,7 @@ INSTALLED_APPS = [
"personnel", "personnel",
"scheduler", "scheduler",
"memo", "memo",
"evaluation",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
+1
View File
@@ -28,6 +28,7 @@ urlpatterns = [
path("", include("email_notice.urls")), path("", include("email_notice.urls")),
path("", include("personnel.urls")), path("", include("personnel.urls")),
path("", include("memo.urls")), path("", include("memo.urls")),
path("", include("evaluation.urls")),
path("api-auth/", include("rest_framework.urls")), path("api-auth/", include("rest_framework.urls")),
path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
Binary file not shown.
+2 -1
View File
@@ -18,7 +18,8 @@
"marked": "^16.3.0", "marked": "^16.3.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-router": "^4.5.1" "vue-router": "^4.5.1",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.12.16", "@babel/core": "^7.12.16",
+8298
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -33,6 +33,10 @@
<el-icon><OfficeBuilding /></el-icon> <el-icon><OfficeBuilding /></el-icon>
部门管理 部门管理
</el-menu-item> </el-menu-item>
<el-menu-item index="/evaluations">
<el-icon><Trophy /></el-icon>
考评记录
</el-menu-item>
<el-menu-item index="/finance"> <el-menu-item index="/finance">
<el-icon><Money /></el-icon> <el-icon><Money /></el-icon>
财务管理 财务管理
@@ -82,6 +86,10 @@
<el-icon><OfficeBuilding /></el-icon> <el-icon><OfficeBuilding /></el-icon>
部门管理 部门管理
</el-menu-item> </el-menu-item>
<el-menu-item index="/evaluations">
<el-icon><Trophy /></el-icon>
考评记录
</el-menu-item>
<el-menu-item index="/finance"> <el-menu-item index="/finance">
<el-icon><Money /></el-icon> <el-icon><Money /></el-icon>
财务管理 财务管理
@@ -100,7 +108,7 @@
</template> </template>
<script> <script>
import {Box, Document, House, Money, OfficeBuilding, Setting, Tickets, User, Menu, EditPen} from '@element-plus/icons-vue' import {Box, Document, House, Money, OfficeBuilding, Setting, Tickets, User, Menu, EditPen, Trophy} from '@element-plus/icons-vue'
export default { export default {
name: 'AppHeader', name: 'AppHeader',
@@ -114,7 +122,8 @@ export default {
User, User,
OfficeBuilding, OfficeBuilding,
Menu, Menu,
EditPen EditPen,
Trophy
}, },
data() { data() {
return { return {
+6
View File
@@ -12,6 +12,7 @@ import PersonnelList from '../views/PersonnelList.vue'
import DepartmentProjectGroupManagement from '../views/DepartmentProjectGroupManagement.vue' import DepartmentProjectGroupManagement from '../views/DepartmentProjectGroupManagement.vue'
import Settings from '../views/Settings.vue' import Settings from '../views/Settings.vue'
import Memo from '../views/Memo.vue' import Memo from '../views/Memo.vue'
import EvaluationRecordList from '../views/EvaluationRecordList.vue'
import { authService } from '@/services/api' import { authService } from '@/services/api'
const routes = [ const routes = [
@@ -81,6 +82,11 @@ const routes = [
path: '/memo', path: '/memo',
name: 'Memo', name: 'Memo',
component: Memo component: Memo
},
{
path: '/evaluations',
name: 'EvaluationRecordList',
component: EvaluationRecordList
} }
] ]
+42
View File
@@ -429,6 +429,48 @@ export const projectGroupService = {
} }
} }
// 考评记录服务
export const evaluationService = {
// 获取考评记录列表
getEvaluationRecords(params = {}) {
return apiClient.get('/evaluation-records/', { params })
},
// 导出考评记录总表
exportEvaluationRecords(params = {}) {
return apiClient.get('/evaluation-records/export/', {
params,
responseType: 'arraybuffer'
})
},
// 导入考评记录总表
importEvaluationRecords(file) {
const formData = new FormData()
formData.append('file', file)
return apiClient.post('/evaluation-records/import/', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 创建考评记录
createEvaluationRecord(payload) {
return apiClient.post('/evaluation-records/', payload)
},
// 更新考评记录
updateEvaluationRecord(id, payload) {
return apiClient.put(`/evaluation-records/${id}/`, payload)
},
// 删除考评记录
deleteEvaluationRecord(id) {
return apiClient.delete(`/evaluation-records/${id}/`)
}
}
// 备忘录管理服务 // 备忘录管理服务
export const memoService = { export const memoService = {
// 获取所有备忘录 // 获取所有备忘录
@@ -0,0 +1,739 @@
<template>
<div>
<AppHeader/>
<div class="evaluation-container">
<el-card>
<template #header>
<div class="card-header">
<span>考评记录</span>
<div class="header-actions">
<el-button @click="resetFilters">
<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>
<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"
type="file"
style="display: none"
accept=".xlsx,.xls,.csv"
@change="handleImportChange">
</div>
</div>
</template>
<div class="filter-section">
<el-form :inline="true" label-width="80px" class="filter-form">
<el-form-item label="部门">
<el-select
v-model="filters.department"
placeholder="选择部门"
style="min-width: 200px"
clearable
@change="fetchRecords">
<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="人员">
<el-select
v-model="filters.personnel"
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-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-input
v-model="filters.keyword"
placeholder="事项说明 / 备注 / 人员"
clearable
@change="fetchRecords">
<template #prefix>
<el-icon><Search/></el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
</div>
<el-table
:data="records"
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">
<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="danger" @click="confirmDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="summary-bar">
<div>
<span>记录数{{ records.length }}</span>
<span>总加分{{ formatNumber(totalBonus) }}</span>
<span>总扣分{{ formatNumber(totalDeduction) }}</span>
<span>综合分{{ formatNumber(totalScore) }}</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 AppHeader from '@/components/AppHeader.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, 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: [],
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' }]
}
}
},
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
}
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)
}
},
async mounted() {
await Promise.all([this.fetchDepartments(), this.fetchPersonnel()])
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()
this.departments = resp.data
} catch (error) {
console.error('获取部门失败:', error)
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]
}
return params
},
async fetchRecords() {
this.loading = true
try {
const resp = await evaluationService.getEvaluationRecords(this.buildQueryParams())
this.records = resp.data
} catch (error) {
console.error('获取考评记录失败:', error)
ElMessage.error('加载考评记录失败')
} finally {
this.loading = false
}
},
resetFilters() {
this.filters = {
department: '',
personnel: '',
keyword: '',
dateRange: []
}
this.fetchRecords()
},
openCreateDialog() {
this.isEditing = false
this.currentRecordId = null
this.form = this.getEmptyForm()
this.dialogVisible = true
},
openSummaryDialog() {
this.summaryDialogVisible = true
},
triggerImport() {
this.$refs.importInput?.click()
},
async handleImportChange(event) {
const files = event.target.files
if (!files || !files.length) return
const file = files[0]
this.importing = true
try {
await evaluationService.importEvaluationRecords(file)
ElMessage.success('导入成功')
await this.fetchRecords()
} catch (error) {
console.error('导入考评记录失败:', error)
const detail = error.response?.data?.detail
const errorList = error.response?.data?.errors
let message = detail || '导入失败'
if (Array.isArray(errorList) && errorList.length) {
message = `${message}: ${errorList[0]}`
}
ElMessage.error(message)
} finally {
this.importing = false
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) {
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 filename = this.extractFilename(response.headers?.['content-disposition']) || this.generateExportFilename()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (error) {
console.error('导出考评记录失败:', error)
ElMessage.error('导出失败')
} finally {
this.exporting = false
}
},
extractFilename(disposition) {
if (!disposition) return ''
const match = /filename="?([^"]+)"?/i.exec(disposition)
return match ? decodeURIComponent(match[1]) : ''
},
generateExportFilename() {
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`
}
}
}
</script>
<style scoped>
.evaluation-container {
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 18px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 10px;
}
.filter-section {
margin-bottom: 20px;
}
.filter-form {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.summary-bar {
margin-top: 16px;
padding: 14px 20px;
background: #f5f7fa;
border-radius: 8px;
display: flex;
justify-content: space-between;
font-size: 14px;
color: #606266;
}
.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;
}
.filter-form {
flex-direction: column;
}
}
</style>