feat(performance): 新增绩效下签功能支持

- 在月报审批页面增加 sign-off 场景支持
- 添加绩效下签对话框组件实现批量下签功能
- 增加导入绩效压缩包和直接下签两种操作模式
- 实现电子签名预览和日期格式化显示功能
- 更新绩效权限配置增加下签相关权限
- 优化审批对话框标题和按钮文案显示
- 添加导入结果提示和文件验证功能
- 实现团队模式下的下签操作入口
- 完善绩效表格状态和操作权限判断逻辑
This commit is contained in:
dk
2026-07-19 18:21:14 +08:00
parent c2f9c922cf
commit 08fff8bbc5
17 changed files with 1523 additions and 116 deletions

View File

@@ -20,16 +20,20 @@ const props = defineProps<{
reportType: string;
period: string;
mode?: 'add' | 'edit' | 'detail';
scene?: 'fill' | 'detail' | 'approval';
scene?: 'fill' | 'detail' | 'approval' | 'sign-off';
baseInfo?: Api.WorkReport.Monthly.MonthlyReport | null;
model: Api.WorkReport.Monthly.MonthlyReportSaveParams;
approvalModel: Api.WorkReport.Monthly.MonthlyReportApproveParams;
supervisorSignaturePreviewUrl?: string;
supervisorSignatureLoading?: boolean;
employeeSignaturePreviewUrl?: string;
}>();
const emit = defineEmits<{
back: [];
requestApprove: [];
requestReject: [];
requestConfirmSignOff: [];
changeApproval: [payload: Api.WorkReport.Monthly.MonthlyReportApproveParams];
viewAudit: [];
}>();
@@ -79,6 +83,9 @@ const performanceDrawerVisible = ref(false);
const performanceDrawerLoading = ref(false);
const performanceDrawerMode = ref<'create' | 'edit'>('create');
const performanceDrawerRow = ref<Api.Performance.Sheet.Sheet | null>(null);
const isSignOffScene = computed(() => props.scene === 'sign-off');
const hasSupervisorSignaturePreview = computed(() => Boolean(props.supervisorSignaturePreviewUrl));
const hasEmployeeSignaturePreview = computed(() => Boolean(props.employeeSignaturePreviewUrl));
const auditDialogVisible = ref(false);
const auditForm = reactive({
@@ -95,22 +102,47 @@ const pageValidationModel = reactive({
return props.approvalModel.performanceResult || '';
}
});
const pageRules: FormRules = {
performanceResult: [
createRequiredRule('请输入绩效考核结果'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入绩效考核结果'));
return;
}
callback();
},
trigger: 'blur'
}
]
};
function formatDisplayDate(value: unknown) {
const text = String(value ?? '')
.replace(/^\[\s*/u, '')
.replace(/\s*\]$/u, '')
.trim();
if (!text) return '--';
const commaDateMatch = text.match(/^(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})$/u);
if (commaDateMatch) {
const [, year, month, day] = commaDateMatch;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
return text;
}
const pageRules = computed<FormRules>(() => {
if (isSignOffScene.value) {
return {
meetingDate: [createRequiredRule('请选择面谈日期')]
};
}
return {
performanceResult: [
createRequiredRule('请输入绩效考核结果'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入绩效考核结果'));
return;
}
callback();
},
trigger: 'blur'
}
]
};
});
const auditRules = computed<FormRules>(() => ({
opinion: rejectOpinionRequired.value
? [
@@ -252,24 +284,12 @@ const mainForm = reactive({
});
const signatureForm = reactive({
get employeeSign() {
return props.approvalModel.employeeSignName || '';
},
set employeeSign(value: string) {
patchApproval('employeeSignName', value);
},
get employeeDate() {
return props.approvalModel.employeeSignedDate || todayText.value;
return props.approvalModel.employeeSignedDate || '';
},
set employeeDate(value: string) {
patchApproval('employeeSignedDate', value);
},
get supervisorSign() {
return props.approvalModel.supervisorSignName || '';
},
set supervisorSign(value: string) {
patchApproval('supervisorSignName', value);
},
get supervisorDate() {
return props.approvalModel.supervisorSignedDate || todayText.value;
},
@@ -560,7 +580,8 @@ async function submitAudit() {
}
}
patchApproval('employeeSignedDate', signatureForm.employeeDate);
patchApproval('employeeSignName', undefined);
patchApproval('employeeSignedDate', undefined);
patchApproval('supervisorSignedDate', signatureForm.supervisorDate);
patchApproval(
'reason',
@@ -617,6 +638,16 @@ function handlePerformanceSaved(payload: { actualScoreTotal: string }) {
patchApproval('performanceResult', payload.actualScoreTotal);
}
async function handleConfirmSignOff() {
try {
await validatePageForm();
} catch {
return;
}
emit('requestConfirmSignOff');
}
watch(rejectOpinionRequired, async () => {
if (!auditDialogVisible.value) return;
@@ -670,7 +701,13 @@ watch(
<div class="field field-form-item">
<label>面谈时间</label>
<ElFormItem class="field-inline-form-item" prop="meetingDate">
<ElDatePicker v-model="mainForm.meetingDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
<ElDatePicker
v-model="mainForm.meetingDate"
type="date"
value-format="YYYY-MM-DD"
disabled
style="width: 100%"
/>
</ElFormItem>
</div>
</div>
@@ -731,10 +768,23 @@ watch(
<span class="feedback-tag success">优势</span>
</div>
<div class="feedback-cell">
<ElInput v-model="feedbackForm.strengthDesc" type="textarea" :rows="2" placeholder="请输入优势描述" />
<div v-if="isSignOffScene" class="rich-editor readonly feedback-readonly">
{{ feedbackForm.strengthDesc || '' }}
</div>
<ElInput
v-else
v-model="feedbackForm.strengthDesc"
type="textarea"
:rows="2"
placeholder="请输入优势描述"
/>
</div>
<div class="feedback-cell">
<div v-if="isSignOffScene" class="rich-editor readonly feedback-readonly">
{{ feedbackForm.strengthExample || '' }}
</div>
<ElInput
v-else
v-model="feedbackForm.strengthExample"
type="textarea"
:rows="2"
@@ -747,10 +797,23 @@ watch(
<span class="feedback-tag warning">劣势</span>
</div>
<div class="feedback-cell">
<ElInput v-model="feedbackForm.weaknessDesc" type="textarea" :rows="2" placeholder="请输入劣势描述" />
<div v-if="isSignOffScene" class="rich-editor readonly feedback-readonly">
{{ feedbackForm.weaknessDesc || '' }}
</div>
<ElInput
v-else
v-model="feedbackForm.weaknessDesc"
type="textarea"
:rows="2"
placeholder="请输入劣势描述"
/>
</div>
<div class="feedback-cell">
<div v-if="isSignOffScene" class="rich-editor readonly feedback-readonly">
{{ feedbackForm.weaknessExample || '' }}
</div>
<ElInput
v-else
v-model="feedbackForm.weaknessExample"
type="textarea"
:rows="2"
@@ -760,7 +823,11 @@ watch(
</div>
</div>
<div class="feedback-right">
<div v-if="isSignOffScene" class="rich-editor readonly feedback-readonly">
{{ feedbackForm.improvementSuggestion || '' }}
</div>
<ElInput
v-else
v-model="feedbackForm.improvementSuggestion"
type="textarea"
:rows="5"
@@ -778,7 +845,7 @@ watch(
<div class="performance-result">
<div class="performance-label">
绩效考核结果
<span class="field-required-mark">*</span>
<span v-if="!isSignOffScene" class="field-required-mark">*</span>
</div>
<ElFormItem class="performance-inline-form-item" prop="performanceResult">
<div class="performance-input-group">
@@ -790,6 +857,7 @@ watch(
@input="handlePerformanceScoreInput"
/>
<ElButton
v-if="!isSignOffScene"
plain
size="small"
type="primary"
@@ -836,39 +904,92 @@ watch(
</div>
<div class="signature-section">
<div class="signature-row">
<span>被考核人签名</span>
<ElInput v-model="signatureForm.employeeSign" class="signature-input" placeholder="请输入签名" />
<span>日期</span>
<ElDatePicker
v-model="signatureForm.employeeDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="signature-date"
/>
</div>
<div class="signature-row">
<span>上级签名</span>
<ElInput v-model="signatureForm.supervisorSign" class="signature-input" placeholder="请输入签名" />
<span>日期</span>
<ElDatePicker
v-model="signatureForm.supervisorDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="signature-date"
/>
</div>
<template v-if="isSignOffScene">
<div class="signature-row">
<span>被考核人签名</span>
<div
class="signature-preview-panel"
:class="{ 'signature-preview-panel--empty': !hasEmployeeSignaturePreview }"
>
<img
v-if="hasEmployeeSignaturePreview"
:src="props.employeeSignaturePreviewUrl"
alt="被考核人电子签名"
class="signature-preview-image"
/>
<span v-else>未维护电子签名</span>
</div>
<span>日期</span>
<div class="signature-readonly">{{ formatDisplayDate(signatureForm.employeeDate) }}</div>
</div>
<div class="signature-row">
<span>上级签名</span>
<div
v-loading="props.supervisorSignatureLoading"
class="signature-preview-panel"
:class="{ 'signature-preview-panel--empty': !hasSupervisorSignaturePreview }"
>
<img
v-if="hasSupervisorSignaturePreview"
:src="props.supervisorSignaturePreviewUrl"
alt="上级电子签名"
class="signature-preview-image"
/>
<span v-else>未维护电子签名</span>
</div>
<span>日期</span>
<div class="signature-readonly">{{ formatDisplayDate(props.approvalModel.supervisorSignedDate) }}</div>
</div>
<div v-if="!hasEmployeeSignaturePreview" class="signature-warning">
当前账号未维护电子签名确认后后端将无法回填员工签名图片
</div>
</template>
<template v-else>
<div class="signature-row">
<span>被考核人签名</span>
<div class="signature-readonly">将在下签确认时自动补齐</div>
<span>日期</span>
<div class="signature-readonly">将在下签确认时自动补齐</div>
</div>
<div class="signature-row">
<span>上级签名</span>
<div
v-loading="props.supervisorSignatureLoading"
class="signature-preview-panel"
:class="{ 'signature-preview-panel--empty': !hasSupervisorSignaturePreview }"
>
<img
v-if="hasSupervisorSignaturePreview"
:src="props.supervisorSignaturePreviewUrl"
alt="上级电子签名"
class="signature-preview-image"
/>
<span v-else>未维护电子签名</span>
</div>
<span>日期</span>
<ElDatePicker
v-model="signatureForm.supervisorDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="signature-date"
/>
</div>
<div v-if="!hasSupervisorSignaturePreview" class="signature-warning">
当前登录用户未维护电子签名审批提交后将无法回填上级签名图片
</div>
</template>
</div>
</ElForm>
<div class="form-actions approval-form-actions">
<ElButton @click="emit('back')">退出</ElButton>
<ElButton type="primary" @click="openAuditDialog">审批</ElButton>
<ElButton v-if="isSignOffScene" type="primary" @click="handleConfirmSignOff">确认</ElButton>
<ElButton v-else type="primary" @click="openAuditDialog">审批</ElButton>
</div>
<BusinessFormDialog
v-if="!isSignOffScene"
v-model="auditDialogVisible"
title="月报审批"
preset="sm"
@@ -937,6 +1058,7 @@ watch(
</BusinessFormDialog>
<PerformanceExcelEditorDrawer
v-if="!isSignOffScene"
v-model:visible="performanceDrawerVisible"
:row-data="performanceDrawerRow"
:mode="performanceDrawerMode"
@@ -1731,6 +1853,11 @@ watch(
height: 100% !important;
}
.feedback-readonly {
width: 100%;
min-height: 60px;
}
.feedback-cell {
padding: 16px;
border-right: 1px solid #f1f5f9;
@@ -1800,27 +1927,37 @@ watch(
border-top: 1px solid #f1f5f9;
}
.signature-input {
width: 140px;
}
.signature-input :deep(.el-input__wrapper) {
box-shadow: none !important;
background: transparent;
.signature-readonly {
min-width: 180px;
padding: 0 4px 4px;
border-bottom: 1px solid #e2e8f0;
border-radius: 0;
padding: 0 4px;
}
.signature-input :deep(.el-input__inner) {
color: #64748b;
font-size: 14px;
text-align: center;
color: #334155;
font-weight: 600;
}
.signature-input :deep(.el-input__inner::placeholder) {
color: #cbd5e1;
font-weight: 400;
.signature-preview-panel {
display: flex;
align-items: center;
justify-content: center;
width: 116px;
height: 44px;
padding: 3px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
background: #f8fafc;
box-sizing: border-box;
}
.signature-preview-panel--empty {
color: #94a3b8;
font-size: 13px;
}
.signature-preview-image {
max-width: 98px;
max-height: 28px;
object-fit: contain;
}
.signature-date {
@@ -1846,6 +1983,14 @@ watch(
font-weight: 400;
}
.signature-warning {
margin-top: 8px;
color: #c2410c;
font-size: 13px;
font-weight: 600;
text-align: right;
}
.performance-result {
display: flex;
align-items: center;

View File

@@ -0,0 +1,198 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import dayjs from 'dayjs';
import {
downloadFile,
fetchConfirmMonthlyReportSignOff,
fetchGetMonthlyReportSignOffDetail,
fetchGetMyUserSignature
} from '@/service/api';
import MonthlyReportApprovalPage from './approval-page.vue';
defineOptions({ name: 'MonthlySignOffPage' });
const props = defineProps<{
reportId?: string;
baseInfo?: Api.WorkReport.Monthly.MonthlyReport | null;
}>();
const emit = defineEmits<{
back: [];
submitted: [];
}>();
const loading = ref(false);
const submitting = ref(false);
const detail = ref<Api.WorkReport.Monthly.MonthlyReportSignOffDetail | null>(null);
const supervisorSignaturePreviewUrl = ref('');
const supervisorSignatureObjectUrl = ref<string | null>(null);
const employeeSignaturePreviewUrl = ref('');
const employeeSignatureObjectUrl = ref<string | null>(null);
const report = computed(() => detail.value?.report || props.baseInfo || null);
const monthlyModel = computed<Api.WorkReport.Monthly.MonthlyReportSaveParams>(() => ({
periodKey: report.value?.periodKey || '',
periodLabel: report.value?.periodLabel || '',
periodStartDate: report.value?.periodStartDate || '',
periodEndDate: report.value?.periodEndDate || '',
reviewItems: report.value?.reviewItems || [],
planItems: report.value?.planItems || []
}));
const approvalDraft = reactive<Api.WorkReport.Monthly.MonthlyReportApproveParams>({
reason: '',
meetingDate: dayjs().format('YYYY-MM-DD'),
strengthDesc: '',
strengthExample: '',
weaknessDesc: '',
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: undefined,
employeeSignedDate: dayjs().format('YYYY-MM-DD'),
supervisorSignName: '',
supervisorSignedDate: ''
});
function patchApprovalRecord(record: Api.WorkReport.Monthly.MonthlyReportApprovalRecord | null | undefined) {
Object.assign(approvalDraft, {
reason: '',
meetingDate: dayjs().format('YYYY-MM-DD'),
strengthDesc: record?.strengthDesc || '',
strengthExample: record?.strengthExample || '',
weaknessDesc: record?.weaknessDesc || '',
weaknessExample: record?.weaknessExample || '',
improvementSuggestion: record?.improvementSuggestion || '',
performanceResult: record?.performanceResult || '',
employeeSignName: record?.employeeSignName,
employeeSignedDate: dayjs().format('YYYY-MM-DD'),
supervisorSignName: record?.supervisorSignName || '',
supervisorSignedDate: record?.supervisorSignedDate || ''
});
}
function revokePreviewUrl(target: 'supervisor' | 'employee') {
const refValue = target === 'supervisor' ? supervisorSignatureObjectUrl : employeeSignatureObjectUrl;
const previewRef = target === 'supervisor' ? supervisorSignaturePreviewUrl : employeeSignaturePreviewUrl;
if (refValue.value) {
URL.revokeObjectURL(refValue.value);
refValue.value = null;
}
previewRef.value = '';
}
async function loadSignaturePreview(fileId: string | null | undefined, target: 'supervisor' | 'employee') {
revokePreviewUrl(target);
if (!fileId) {
return;
}
const { error, data } = await downloadFile(fileId);
if (error || !data) {
return;
}
const objectUrl = URL.createObjectURL(data);
if (target === 'supervisor') {
supervisorSignatureObjectUrl.value = objectUrl;
supervisorSignaturePreviewUrl.value = objectUrl;
return;
}
employeeSignatureObjectUrl.value = objectUrl;
employeeSignaturePreviewUrl.value = objectUrl;
}
async function loadDetail() {
if (!props.reportId) {
detail.value = null;
patchApprovalRecord(null);
revokePreviewUrl('supervisor');
revokePreviewUrl('employee');
return;
}
loading.value = true;
const [detailResult, mySignatureResult] = await Promise.all([
fetchGetMonthlyReportSignOffDetail(props.reportId),
fetchGetMyUserSignature()
]);
loading.value = false;
if (detailResult.error || !detailResult.data) {
detail.value = null;
patchApprovalRecord(null);
revokePreviewUrl('supervisor');
revokePreviewUrl('employee');
return;
}
detail.value = detailResult.data;
patchApprovalRecord(detailResult.data.latestApprovalRecord);
await Promise.all([
loadSignaturePreview(detailResult.data.latestApprovalRecord?.supervisorSignatureFileId, 'supervisor'),
loadSignaturePreview(mySignatureResult.error ? null : mySignatureResult.data?.fileId, 'employee')
]);
}
function handleApprovalChange(payload: Api.WorkReport.Monthly.MonthlyReportApproveParams) {
Object.assign(approvalDraft, payload);
}
async function handleConfirm() {
if (!props.reportId) {
return;
}
submitting.value = true;
const result = await fetchConfirmMonthlyReportSignOff(props.reportId, {
meetingDate: approvalDraft.meetingDate || dayjs().format('YYYY-MM-DD'),
employeeSignedDate: approvalDraft.employeeSignedDate || undefined
});
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('月报下签确认成功');
emit('submitted');
}
watch(
() => props.reportId,
() => {
loadDetail();
},
{ immediate: true }
);
onBeforeUnmount(() => {
revokePreviewUrl('supervisor');
revokePreviewUrl('employee');
});
</script>
<template>
<div v-loading="loading">
<MonthlyReportApprovalPage
report-type="个人月报"
:period="report?.periodLabel || ''"
mode="detail"
scene="sign-off"
:base-info="report"
:model="monthlyModel"
:approval-model="approvalDraft"
:supervisor-signature-preview-url="supervisorSignaturePreviewUrl"
:employee-signature-preview-url="employeeSignaturePreviewUrl"
:supervisor-signature-loading="false"
@back="emit('back')"
@change-approval="handleApprovalChange"
@request-confirm-sign-off="handleConfirm"
/>
</div>
</template>