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

@@ -44,6 +44,15 @@ type SheetPageResponse = {
list: SheetResponse[];
};
type ImportZipResultResponse = Omit<Api.Performance.Sheet.ImportZipResult, 'messages'> & {
workbookCount?: number | string | null;
sheetCount?: number | string | null;
successCount?: number | string | null;
skippedCount?: number | string | null;
failedCount?: number | string | null;
messages?: string[] | null;
};
type StatusLogResponse = Omit<Api.Performance.Sheet.StatusLog, 'id' | 'sheetId' | 'operatorUserId'> & {
id: StringIdResponse;
sheetId: StringIdResponse;
@@ -144,6 +153,8 @@ function normalizeSheet(response: SheetResponse): Api.Performance.Sheet.Sheet {
fileId: normalizeNullableStringId(response.fileId),
fileName: response.fileName ?? null,
statusName: response.statusName || response.statusCode,
signOffStatus: response.signOffStatus ?? null,
signOffStatusName: response.signOffStatusName ?? null,
actualScoreTotal: response.actualScoreTotal ?? null,
baseScoreTotal: response.baseScoreTotal ?? null,
extraScoreTotal: response.extraScoreTotal ?? null,
@@ -282,6 +293,7 @@ function createSheetQuery(params: Api.Performance.Sheet.SearchParams = {}) {
appendValue(query, 'employeeDeptId', params.employeeDeptId);
appendValue(query, 'managerName', params.managerName);
appendValue(query, 'statusCode', params.statusCode);
appendValue(query, 'signOffStatus', params.signOffStatus);
return query.toString();
}
@@ -377,6 +389,46 @@ export async function createPerformanceSheet(data: Api.Performance.Sheet.CreateP
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
}
export async function importPerformanceSheetZip(data: Api.Performance.Sheet.ImportZipParams) {
const formData = new FormData();
formData.append('file', data.file);
formData.append('periodMonth', data.periodMonth);
data.employeeIds?.forEach(id => {
formData.append('employeeIds', id);
});
const result = await request<ImportZipResultResponse>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/import-zip`,
method: 'post',
data: formData
});
return mapServiceResult(result as ServiceRequestResult<ImportZipResultResponse>, payload => ({
workbookCount: normalizeTotal(payload.workbookCount),
sheetCount: normalizeTotal(payload.sheetCount),
successCount: normalizeTotal(payload.successCount),
skippedCount: normalizeTotal(payload.skippedCount),
failedCount: normalizeTotal(payload.failedCount),
messages: payload.messages ?? []
}));
}
export async function signOffPerformanceSheets(data: Api.Performance.Sheet.SignOffParams) {
const result = await request<StringIdResponse>({
...safeJsonRequestConfig,
url: `${SHEET_PREFIX}/sign-off`,
method: 'post',
data: {
...data,
employeeIds: data.employeeIds && data.employeeIds.length ? data.employeeIds : undefined,
remark: data.remark?.trim() || undefined
}
});
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
}
export function updatePerformanceSheetExcel(id: string, data: Api.Performance.Sheet.ExcelUpdateParams) {
return request<boolean>({
...safeJsonRequestConfig,

View File

@@ -17,6 +17,7 @@ const DEPT_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dept`;
const POST_PREFIX = `${SYSTEM_SERVICE_PREFIX}/post`;
const ORG_LEADER_PREFIX = `${SYSTEM_SERVICE_PREFIX}/org-leader`;
const USER_MANAGEMENT_RELATION_PREFIX = `${SYSTEM_SERVICE_PREFIX}/user-management-relation`;
const USER_SIGNATURE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/user-signatures`;
function appendQueryValue(query: URLSearchParams, key: string, value: unknown) {
if (value === null || value === undefined || value === '') {
@@ -146,6 +147,12 @@ type MySubordinateTreeNodeResponse = Omit<Api.SystemManage.MySubordinateTreeNode
children?: MySubordinateTreeNodeResponse[] | null;
};
type UserSignatureResponse = Omit<Api.SystemManage.UserSignature, 'id' | 'userId' | 'fileId'> & {
id: string | number;
userId: string | number;
fileId: string | number;
};
function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple {
return {
...user,
@@ -217,6 +224,20 @@ function normalizeMySubordinateTreeNode(node: MySubordinateTreeNodeResponse): Ap
};
}
function normalizeUserSignature(signature: UserSignatureResponse): Api.SystemManage.UserSignature {
return {
...signature,
id: normalizeStringId(signature.id),
userId: normalizeStringId(signature.userId),
fileId: normalizeStringId(signature.fileId),
fileName: signature.fileName ?? null,
status: signature.status ?? null,
remark: signature.remark ?? null,
createTime: signature.createTime ?? null,
updateTime: signature.updateTime ?? null
};
}
/** 获取角色分页 */
export async function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) {
const query = createRolePageQuery(params);
@@ -742,6 +763,49 @@ export function fetchAssignUserRoles(data: Api.SystemManage.AssignUserRoleParams
});
}
export async function fetchGetUserSignature(userId: string) {
const result = await request<UserSignatureResponse | null>({
...safeJsonRequestConfig,
url: `${USER_SIGNATURE_PREFIX}/get`,
method: 'get',
params: { userId }
});
return mapServiceResult(result as ServiceRequestResult<UserSignatureResponse | null>, data =>
data ? normalizeUserSignature(data) : null
);
}
export function fetchSaveUserSignature(data: Api.SystemManage.SaveUserSignatureParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${USER_SIGNATURE_PREFIX}/save`,
method: 'post',
data
});
}
export function fetchDeleteUserSignature(userId: string) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${USER_SIGNATURE_PREFIX}/delete`,
method: 'delete',
params: { userId }
});
}
export async function fetchGetMyUserSignature() {
const result = await request<UserSignatureResponse | null>({
...safeJsonRequestConfig,
url: `${USER_SIGNATURE_PREFIX}/my`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<UserSignatureResponse | null>, data =>
data ? normalizeUserSignature(data) : null
);
}
// ==================== 用户管理链路相关 API ====================
/**
* 获取用户管理链路树形结构

View File

@@ -88,11 +88,18 @@ type ApprovalRecordResponse = Omit<
type MonthlyApprovalRecordResponse = Omit<
Api.WorkReport.Monthly.MonthlyReportApprovalRecord,
'id' | 'statusLogId' | 'auditorUserId'
'id' | 'statusLogId' | 'auditorUserId' | 'employeeSignatureFileId' | 'supervisorSignatureFileId'
> & {
id: StringIdResponse;
statusLogId: StringIdResponse;
auditorUserId: StringIdResponse;
employeeSignatureFileId?: MaybeStringIdResponse;
supervisorSignatureFileId?: MaybeStringIdResponse;
};
type MonthlySignOffDetailResponse = {
report: MonthlyReportResponse;
latestApprovalRecord?: MonthlyApprovalRecordResponse | null;
};
type ProjectOptionResponse = Omit<Api.WorkReport.Project.ProjectReportOwnerProjectOption, 'id'> & {
@@ -229,6 +236,7 @@ function createWeeklyPageQuery(params: Api.WorkReport.Weekly.WeeklyReportSearchP
function createMonthlyPageQuery(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendNullableArrayFlag(query, 'reporterIds', params.reporterIds);
appendValue(query, 'signOffStatus', params.signOffStatus);
return query.toString();
}
@@ -298,6 +306,8 @@ function normalizeMonthlyReport(response: MonthlyReportResponse): Api.WorkReport
reporterDeptName: response.reporterDeptName ?? null,
reporterPostName: response.reporterPostName ?? null,
statusName: response.statusName || response.statusCode,
signOffStatus: response.signOffStatus ?? null,
signOffStatusName: response.signOffStatusName ?? null,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
@@ -361,7 +371,12 @@ function normalizeMonthlyApprovalRecord(
statusLogId: normalizeStringId(response.statusLogId),
auditorUserId: normalizeStringId(response.auditorUserId),
conclusion: normalizeApprovalConclusion(response.conclusion),
opinion: response.opinion ?? null
opinion: response.opinion ?? null,
meetingDate: normalizeDateText(response.meetingDate) ?? null,
employeeSignatureFileId: normalizeNullableStringId(response.employeeSignatureFileId),
employeeSignedDate: normalizeDateText(response.employeeSignedDate) ?? null,
supervisorSignatureFileId: normalizeNullableStringId(response.supervisorSignatureFileId),
supervisorSignedDate: normalizeDateText(response.supervisorSignedDate) ?? null
};
}
@@ -697,6 +712,19 @@ export async function fetchGetMonthlyReportApprovalPage(params: Api.WorkReport.M
);
}
export async function fetchGetMonthlyReportSignOffPage(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createMonthlyPageQuery(params);
const result = await request<PageResponse<MonthlyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${MONTHLY_PREFIX}/sign-off-page?${query}` : `${MONTHLY_PREFIX}/sign-off-page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<MonthlyReportResponse>>, data =>
mapPage(data, normalizeMonthlyReport)
);
}
export async function fetchGetMonthlyReportDetail(id: string) {
const result = await request<MonthlyReportResponse>({
...safeJsonRequestConfig,
@@ -799,6 +827,34 @@ export async function fetchGetMonthlyReportApprovalRecords(id: string) {
);
}
export async function fetchGetMonthlyReportSignOffDetail(id: string) {
const result = await request<MonthlySignOffDetailResponse>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}/sign-off-detail`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<MonthlySignOffDetailResponse>, data => ({
report: normalizeMonthlyReport(data.report),
latestApprovalRecord: data.latestApprovalRecord ? normalizeMonthlyApprovalRecord(data.latestApprovalRecord) : null
}));
}
export function fetchConfirmMonthlyReportSignOff(
id: string,
data: Api.WorkReport.Monthly.MonthlyReportSignOffConfirmParams
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}/confirm-sign-off`,
method: 'post',
data: {
meetingDate: data.meetingDate,
employeeSignedDate: data.employeeSignedDate || undefined
}
});
}
export function fetchExportMonthlyReports(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createMonthlyPageQuery(params);
return request<Blob, 'blob'>({

View File

@@ -74,6 +74,8 @@ declare namespace Api {
fileVersion: number;
statusCode: Common.SheetStatusCode;
statusName: string;
signOffStatus?: string | null;
signOffStatusName?: string | null;
actualScoreTotal?: string | number | null;
baseScoreTotal?: string | number | null;
extraScoreTotal?: string | number | null;
@@ -96,6 +98,7 @@ declare namespace Api {
managerId: string;
managerName: string;
statusCode: Common.SheetStatusCode;
signOffStatus: string;
}
>;
@@ -121,6 +124,27 @@ declare namespace Api {
ids: string[];
}
interface ImportZipParams {
file: File;
periodMonth: string;
employeeIds?: string[] | null;
}
interface ImportZipResult {
workbookCount: number;
sheetCount: number;
successCount: number;
skippedCount: number;
failedCount: number;
messages: string[];
}
interface SignOffParams {
periodMonth: string;
employeeIds?: string[] | null;
remark?: string | null;
}
interface StatusDict {
statusCode: Common.SheetStatusCode;
statusName: string;

View File

@@ -239,6 +239,25 @@ declare namespace Api {
roleIds: string[];
}
interface UserSignature {
id: string;
userId: string;
fileId: string;
fileName?: string | null;
status?: number | null;
remark?: string | null;
createTime?: string | null;
updateTime?: string | null;
}
interface SaveUserSignatureParams {
userId: string;
fileId: string;
fileName?: string | null;
status?: number | null;
remark?: string | null;
}
/**
* menu type
*

View File

@@ -189,6 +189,8 @@ declare namespace Api {
periodEndDate: string;
statusCode: Common.WorkReportStatusCode | string;
statusName: string;
signOffStatus?: string | null;
signOffStatusName?: string | null;
allowEdit: boolean;
terminal: boolean;
totalWorkHours?: number | string | null;
@@ -204,6 +206,7 @@ declare namespace Api {
type MonthlyReportSearchParams = Common.WorkReportBaseSearchParams & {
reporterIds?: string[] | null;
signOffStatus?: string | null;
};
interface MonthlyReportSaveParams {
@@ -245,10 +248,22 @@ declare namespace Api {
improvementSuggestion?: string | null;
performanceResult?: string | null;
employeeSignName?: string | null;
employeeSignatureFileId?: string | null;
employeeSignedDate?: string | null;
supervisorSignName?: string | null;
supervisorSignatureFileId?: string | null;
supervisorSignedDate?: string | null;
}
interface MonthlyReportSignOffConfirmParams {
meetingDate: string;
employeeSignedDate?: string | null;
}
interface MonthlyReportSignOffDetail {
report: MonthlyReport;
latestApprovalRecord: MonthlyReportApprovalRecord | null;
}
}
namespace Project {

View File

@@ -130,11 +130,13 @@ declare module 'vue' {
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']
IconMdiFolderOutline: typeof import('~icons/mdi/folder-outline')['default']
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
IconMdiFolderZipOutline: typeof import('~icons/mdi/folder-zip-outline')['default']
IconMdiInboxMultipleOutline: typeof import('~icons/mdi/inbox-multiple-outline')['default']
IconMdiInformationOutline: typeof import('~icons/mdi/information-outline')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
IconMdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
IconMdiPenCheckOutline: typeof import('~icons/mdi/pen-check-outline')['default']
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']

View File

@@ -34,6 +34,7 @@ import PerformanceActionDialog from './modules/performance-action-dialog.vue';
import PerformanceExcelEditorDrawer from './modules/performance-excel-editor-drawer.vue';
import PerformanceRecordDialog from './modules/performance-record-dialog.vue';
import PerformanceSearch from './modules/performance-search.vue';
import PerformanceSignOffDialog from './modules/performance-sign-off-dialog.vue';
import PerformanceSummary from './modules/performance-summary.vue';
import PerformanceTemplateDialog from './modules/performance-template-dialog.vue';
import {
@@ -116,6 +117,7 @@ const excelMode = ref<'view' | 'edit' | 'create'>('view');
const actionVisible = ref(false);
const actionType = ref<'confirm' | 'reject'>('confirm');
const recordVisible = ref(false);
const signOffVisible = ref(false);
const exporting = ref(false);
const currentUserId = computed(() => authStore.userInfo.userId || '');
@@ -138,6 +140,9 @@ const canUpdate = computed(() => hasAuth(PerformancePermission.SheetUpdate));
const canDelete = computed(() => hasAuth(PerformancePermission.SheetDelete));
const canConfirm = computed(() => hasAuth(PerformancePermission.SheetConfirm));
const canReject = computed(() => hasAuth(PerformancePermission.SheetReject));
const canSignOff = computed(
() => hasAuth(PerformancePermission.SheetImport) && hasAuth(PerformancePermission.SheetSignOff)
);
const canExport = computed(() => hasAuth(PerformancePermission.SheetExport));
const canManageTemplate = computed(
() => hasAuth(PerformancePermission.TemplateQuery) || hasAuth(PerformancePermission.TemplateUpdate)
@@ -303,6 +308,21 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination, r
});
const totalCount = computed(() => mobilePagination.value.total || data.value.length);
const currentActionEmployeeIds = computed(() => {
if (!isTeamMode.value) {
return undefined;
}
return currentEmployeeIds.value ?? undefined;
});
const signOffInitialPeriodMonth = computed(() => formattedPeriodMonthEnd.value || createDefaultPeriodMonth());
function canHandlePersonalSheet(row: Api.Performance.Sheet.Sheet) {
return (
row.statusCode === 'sent' &&
['pending_employee_confirm', 'employee_rejected'].includes(String(row.signOffStatus || ''))
);
}
function getRowActions(row: Api.Performance.Sheet.Sheet): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
@@ -357,7 +377,7 @@ function getRowActions(row: Api.Performance.Sheet.Sheet): BusinessTableAction[]
onClick: () => handleDelete(row)
});
}
} else if (row.statusCode === 'sent') {
} else if (canHandlePersonalSheet(row)) {
if (canConfirm.value) {
actions.push({
key: 'confirm',
@@ -419,6 +439,10 @@ function handleSelectionChange(rows: Api.Performance.Sheet.Sheet[]) {
selectedRows.value = rows;
}
function openSignOffDialog() {
signOffVisible.value = true;
}
function openExcel(row: Api.Performance.Sheet.Sheet, mode: 'view' | 'edit') {
currentRow.value = row;
excelMode.value = mode;
@@ -743,6 +767,12 @@ onMounted(async () => {
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton v-if="isTeamMode && canSignOff" plain @click="openSignOffDialog">
<template #icon>
<icon-mdi-check-circle-outline class="text-icon" />
</template>
下签
</ElButton>
<ElButton v-if="isTeamMode && canManageTemplate" plain @click="templateVisible = true">
<template #icon>
<icon-mdi-file-cog-outline class="text-icon" />
@@ -811,6 +841,15 @@ onMounted(async () => {
/>
<PerformanceRecordDialog v-model:visible="recordVisible" :row-data="currentRow" />
<PerformanceSignOffDialog
v-model:visible="signOffVisible"
open-mode="sign-off"
:employee-ids="currentActionEmployeeIds"
:initial-period-month="signOffInitialPeriodMonth"
:team-label="selectedTeamLabel"
@submitted="reloadAfterMutation"
/>
</div>
</template>

View File

@@ -41,15 +41,15 @@ const form = reactive<{
const isReject = computed(() => form.actionType === 'reject');
const title = computed(() => {
if (props.selectableActionType) return '绩效审批';
if (props.selectableActionType) return '绩效确认';
return isReject.value ? '退回绩效表' : '确认绩效表';
});
const confirmText = computed(() => {
if (props.selectableActionType) return '确认提交';
return isReject.value ? '确认退回' : '确认';
});
const opinionLabel = computed(() => (isReject.value ? '退回原因' : '审批意见'));
const opinionPlaceholder = computed(() => (isReject.value ? `请输入${opinionLabel.value}` : '可填写审批意见'));
const opinionLabel = computed(() => (isReject.value ? '退回原因' : '意见'));
const opinionPlaceholder = computed(() => (isReject.value ? `请输入${opinionLabel.value}` : '可填写意见'));
const rules = computed<FormRules>(() => ({
reason: isReject.value ? [createRequiredRule('请输入退回原因')] : []
}));
@@ -104,7 +104,7 @@ watch(visible, isVisible => {
<div v-if="props.selectableActionType" class="performance-action-dialog__approval-form">
<div class="audit-field">
<label>审批结论</label>
<label>结论</label>
<div class="audit-conclusion">
<button
type="button"

View File

@@ -841,7 +841,7 @@ onMounted(() => {
</template>
<template v-else-if="showApprovalFooter">
<ElButton @click="handleClose">退出</ElButton>
<ElButton type="primary" @click="handleStartApproval">审批</ElButton>
<ElButton type="primary" @click="handleStartApproval">确认</ElButton>
</template>
</div>
</template>

View File

@@ -16,6 +16,8 @@ export const PerformancePermission = {
SheetDelete: 'project:performance-sheet:delete',
SheetConfirm: 'project:performance-sheet:confirm',
SheetReject: 'project:performance-sheet:reject',
SheetImport: 'project:performance-sheet:import',
SheetSignOff: 'project:performance-sheet:sign-off',
SheetExport: 'project:performance-sheet:export',
TeamDashboard: 'project:performance-sheet:team-dashboard'
} as const;

View File

@@ -0,0 +1,300 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import type { UploadFile, UploadInstance } from 'element-plus';
import { importPerformanceSheetZip, signOffPerformanceSheets } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'PerformanceSignOffDialog' });
type OpenMode = 'import' | 'sign-off';
type ActionMode = 'direct' | 'import';
const props = withDefaults(
defineProps<{
openMode?: OpenMode;
employeeIds?: string[] | null;
initialPeriodMonth?: string;
teamLabel?: string;
}>(),
{
openMode: 'sign-off',
employeeIds: undefined,
initialPeriodMonth: '',
teamLabel: ''
}
);
const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{
submitted: [];
}>();
const uploadRef = ref<UploadInstance>();
const submitting = ref(false);
const importResult = ref<Api.Performance.Sheet.ImportZipResult | null>(null);
const form = reactive<{
actionMode: ActionMode;
periodMonth: string;
remark: string;
file: File | null;
fileName: string;
}>({
actionMode: 'direct',
periodMonth: '',
remark: '',
file: null,
fileName: ''
});
const isImportOnly = computed(() => props.openMode === 'import');
const needsZipFile = computed(() => isImportOnly.value || form.actionMode === 'import');
const title = computed(() => (isImportOnly.value ? '导入绩效压缩包' : '一键下签'));
const confirmText = computed(() => (isImportOnly.value ? '开始导入' : '确认执行'));
const teamLabelText = computed(() => {
if (needsZipFile.value) {
return '压缩包中含有的下属';
}
return props.teamLabel || '当前范围';
});
function resetForm() {
form.actionMode = 'direct';
form.periodMonth = props.initialPeriodMonth || '';
form.remark = '';
form.file = null;
form.fileName = '';
importResult.value = null;
uploadRef.value?.clearFiles();
}
function handleFileChange(file: UploadFile) {
const rawFile = file.raw;
uploadRef.value?.clearFiles();
if (!rawFile) {
return;
}
if (!rawFile.name.toLowerCase().endsWith('.zip')) {
window.$message?.warning('请选择 zip 压缩包');
return;
}
form.file = rawFile;
form.fileName = rawFile.name;
}
async function handleSubmit() {
if (!form.periodMonth) {
window.$message?.warning('请选择绩效月份');
return;
}
if (needsZipFile.value && !form.file) {
window.$message?.warning('请选择绩效压缩包');
return;
}
submitting.value = true;
if (needsZipFile.value && form.file) {
const importResponse = await importPerformanceSheetZip({
file: form.file,
periodMonth: form.periodMonth,
employeeIds: props.employeeIds
});
if (importResponse.error || !importResponse.data) {
submitting.value = false;
return;
}
importResult.value = importResponse.data;
if (isImportOnly.value) {
submitting.value = false;
if (importResponse.data.failedCount > 0) {
window.$message?.warning('导入存在失败项,请处理后重试');
return;
}
window.$message?.success('绩效压缩包导入成功');
visible.value = false;
emit('submitted');
return;
}
if (importResponse.data.failedCount > 0) {
submitting.value = false;
window.$message?.warning('导入存在失败项,已停止后续下签');
return;
}
}
if (!isImportOnly.value) {
const signOffResponse = await signOffPerformanceSheets({
periodMonth: form.periodMonth,
employeeIds: props.employeeIds,
remark: form.remark
});
submitting.value = false;
if (signOffResponse.error) {
return;
}
window.$message?.success('绩效表下签成功');
visible.value = false;
emit('submitted');
return;
}
submitting.value = false;
}
watch(visible, isVisible => {
if (isVisible) {
resetForm();
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="md"
append-to-body
:confirm-text="confirmText"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm label-position="top">
<ElRow :gutter="16">
<ElCol v-if="!isImportOnly" :span="24">
<ElFormItem label="执行方式">
<ElRadioGroup v-model="form.actionMode">
<ElRadio value="import">导入绩效压缩包后下签</ElRadio>
<ElRadio value="direct">直接下签</ElRadio>
</ElRadioGroup>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="绩效月份" required style="width: 100%">
<ElDatePicker
v-model="form.periodMonth"
class="w-full"
type="month"
value-format="YYYY-MM"
placeholder="请选择绩效月份"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="适用范围">
<ElInput :model-value="teamLabelText" readonly />
</ElFormItem>
</ElCol>
<ElCol v-if="needsZipFile" :span="24">
<ElFormItem label="绩效压缩包" required>
<div class="performance-sign-off-dialog__upload-row">
<ElUpload
ref="uploadRef"
:auto-upload="false"
:show-file-list="false"
accept=".zip"
:limit="1"
:on-change="handleFileChange"
>
<ElButton plain>选择 zip 文件</ElButton>
</ElUpload>
<div
class="performance-sign-off-dialog__file-name"
:class="{ 'performance-sign-off-dialog__file-name--placeholder': !form.fileName }"
>
{{ form.fileName || '未选择文件' }}
</div>
</div>
</ElFormItem>
</ElCol>
<ElCol v-if="!isImportOnly" :span="24">
<ElFormItem label="备注">
<ElInput
v-model="form.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="可填写本次下签说明"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
<ElAlert
v-if="importResult"
:type="importResult.failedCount > 0 ? 'warning' : 'success'"
show-icon
:closable="false"
class="mt-12px"
>
<template #title>
导入结果工作簿 {{ importResult.workbookCount }} 工作表 {{ importResult.sheetCount }} 成功
{{ importResult.successCount }} 跳过 {{ importResult.skippedCount }} 失败
{{ importResult.failedCount }}
</template>
<div v-if="importResult.messages.length" class="performance-sign-off-dialog__messages">
<div v-for="(message, index) in importResult.messages" :key="`${index}-${message}`">
{{ message }}
</div>
</div>
</ElAlert>
</BusinessFormDialog>
</template>
<style scoped>
.performance-sign-off-dialog__upload-row {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 10px;
align-items: center;
}
.performance-sign-off-dialog__file-name {
min-width: 0;
height: 36px;
padding: 0 12px;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-fill-color-blank);
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 34px;
text-overflow: ellipsis;
white-space: nowrap;
}
.performance-sign-off-dialog__file-name--placeholder {
color: var(--el-text-color-placeholder);
}
.performance-sign-off-dialog__messages {
display: grid;
gap: 4px;
margin-top: 8px;
font-size: 13px;
line-height: 1.5;
}
</style>

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>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
/* eslint-disable complexity, no-nested-ternary, no-void, vue/no-deprecated-filter */
import { computed, reactive, ref, watch } from 'vue';
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import dayjs from 'dayjs';
import {
downloadFile,
fetchApproveMonthlyReport,
fetchApproveProjectReport,
fetchApproveWeeklyReport,
@@ -11,6 +12,7 @@ import {
fetchCreateProjectReport,
fetchCreateWeeklyReport,
fetchGetMonthlyReportDetail,
fetchGetMyUserSignature,
fetchGetProjectReportDetail,
fetchGetWeeklyReportDetail,
fetchInitMonthlyReport,
@@ -48,6 +50,7 @@ import WeeklyReportPage from '../../weekly/modules/fill-page.vue';
import MonthlyReportPage from '../../monthly/modules/fill-page.vue';
import ProjectReportPage from '../../project/modules/fill-page.vue';
import MonthlyReportApprovalPage from '../../monthly/modules/approval-page.vue';
import MonthlyReportSignOffPage from '../../monthly/modules/sign-off-page.vue';
import WorkReportActionDialog from './action-dialog.vue';
import WorkReportPageDialog from './page-dialog.vue';
@@ -63,7 +66,7 @@ interface PeriodPayload {
interface Props {
reportType: WorkReportType;
mode?: 'add' | 'edit' | 'detail';
scene?: 'fill' | 'detail' | 'approval';
scene?: 'fill' | 'detail' | 'approval' | 'sign-off';
rowData?: WorkReportRow | null;
initialPeriod?: PeriodPayload | null;
initialProjectId?: string;
@@ -95,7 +98,7 @@ const loading = ref(false);
const actionVisible = ref(false);
const actionSubmitting = ref(false);
const currentActionType = ref<'approve' | 'reject'>('approve');
const currentStage = ref<'form' | 'approval'>('form');
const currentStage = ref<'form' | 'approval' | 'sign-off'>('form');
const currentReportId = ref('');
const baseInfo = ref<WorkReportRow | null>(null);
const monthlyApprovalDraft = reactive<Api.WorkReport.Monthly.MonthlyReportApproveParams>({
@@ -107,12 +110,15 @@ const monthlyApprovalDraft = reactive<Api.WorkReport.Monthly.MonthlyReportApprov
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: '',
employeeSignedDate: '',
employeeSignName: undefined,
employeeSignedDate: undefined,
supervisorSignName: '',
supervisorSignedDate: ''
});
const monthlyPerformanceAutoFilled = ref(false);
const monthlySupervisorSignatureLoading = ref(false);
const monthlySupervisorSignaturePreviewUrl = ref('');
const monthlySupervisorSignatureObjectUrl = ref<string | null>(null);
const weeklyModel = reactive<Api.WorkReport.Weekly.WeeklyReportSaveParams>(createWeeklySaveParams());
const monthlyModel = reactive<Api.WorkReport.Monthly.MonthlyReportSaveParams>(createMonthlySaveParams());
@@ -129,14 +135,25 @@ const currentScene = computed(() => {
return 'approval';
}
if (props.reportType === 'monthly' && currentStage.value === 'sign-off') {
return 'sign-off';
}
return props.scene;
});
const currentPageScene = computed<'fill' | 'detail' | 'approval'>(() =>
currentScene.value === 'sign-off' ? 'detail' : (currentScene.value ?? 'detail')
);
const dialogTitle = computed(() => {
if (props.reportType === 'monthly' && currentStage.value === 'approval') {
return `${REPORT_TYPE_TEXT.monthly}审批页`;
}
if (props.reportType === 'monthly' && currentStage.value === 'sign-off') {
return `${REPORT_TYPE_TEXT.monthly}下签确认`;
}
if (currentScene.value === 'approval') {
return `${REPORT_TYPE_TEXT[props.reportType]}审批`;
}
@@ -164,20 +181,53 @@ function resetModels() {
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: '',
employeeSignedDate: '',
employeeSignName: undefined,
employeeSignedDate: undefined,
supervisorSignName: '',
supervisorSignedDate: ''
});
monthlyPerformanceAutoFilled.value = false;
}
function revokeMonthlySupervisorSignaturePreview() {
if (monthlySupervisorSignatureObjectUrl.value) {
URL.revokeObjectURL(monthlySupervisorSignatureObjectUrl.value);
monthlySupervisorSignatureObjectUrl.value = null;
}
monthlySupervisorSignaturePreviewUrl.value = '';
}
async function loadMonthlySupervisorSignature() {
revokeMonthlySupervisorSignaturePreview();
if (props.reportType !== 'monthly' || currentStage.value !== 'approval') {
return;
}
monthlySupervisorSignatureLoading.value = true;
const signatureResult = await fetchGetMyUserSignature();
if (signatureResult.error || !signatureResult.data?.fileId) {
monthlySupervisorSignatureLoading.value = false;
return;
}
const downloadResult = await downloadFile(signatureResult.data.fileId);
monthlySupervisorSignatureLoading.value = false;
if (downloadResult.error || !downloadResult.data) {
return;
}
monthlySupervisorSignatureObjectUrl.value = URL.createObjectURL(downloadResult.data);
monthlySupervisorSignaturePreviewUrl.value = monthlySupervisorSignatureObjectUrl.value;
}
function patchMonthlyApprovalDefaults(report?: Partial<Api.WorkReport.Monthly.MonthlyReport> | null) {
const today = dayjs().format('YYYY-MM-DD');
Object.assign(monthlyApprovalDraft, {
employeeSignName: monthlyApprovalDraft.employeeSignName || report?.reporterName || '',
employeeSignedDate: monthlyApprovalDraft.employeeSignedDate || today,
supervisorSignName: monthlyApprovalDraft.supervisorSignName || report?.supervisorName || '',
supervisorSignedDate: monthlyApprovalDraft.supervisorSignedDate || today
});
@@ -462,12 +512,22 @@ async function loadInitData() {
}
watch(visible, async isVisible => {
if (!isVisible) return;
if (!isVisible) {
revokeMonthlySupervisorSignaturePreview();
return;
}
currentStage.value = props.reportType === 'monthly' && props.scene === 'approval' ? 'approval' : 'form';
if (props.reportType === 'monthly' && props.scene === 'approval') {
currentStage.value = 'approval';
} else if (props.reportType === 'monthly' && props.scene === 'sign-off') {
currentStage.value = 'sign-off';
} else {
currentStage.value = 'form';
}
currentReportId.value = props.rowData?.id || '';
baseInfo.value = null;
resetModels();
await loadMonthlySupervisorSignature();
if (props.mode === 'add') {
if (props.reportType === 'project') {
@@ -754,6 +814,13 @@ function resolveMonthlyPeriodMonth() {
return '';
}
function resolveMonthlyPerformanceResultValue(data: Api.Performance.Sheet.MonthlyResult | null | undefined) {
const actualScoreTotal = data?.actualScoreTotal;
if (actualScoreTotal === null || actualScoreTotal === undefined) return '';
return String(actualScoreTotal).trim();
}
async function fillMonthlyPerformanceResult() {
if (props.reportType !== 'monthly' || monthlyPerformanceAutoFilled.value) return;
if (monthlyApprovalDraft.performanceResult?.trim()) return;
@@ -762,12 +829,16 @@ async function fillMonthlyPerformanceResult() {
const periodMonth = resolveMonthlyPeriodMonth();
if (!employeeId || !periodMonth) return;
monthlyPerformanceAutoFilled.value = true;
const { error, data } = await fetchPerformanceMonthlyResult(employeeId, periodMonth);
if (error || !data?.actualScoreTotal) return;
if (error || !data) return;
const performanceResult = resolveMonthlyPerformanceResultValue(data);
if (!performanceResult) return;
monthlyPerformanceAutoFilled.value = true;
if (monthlyApprovalDraft.performanceResult?.trim()) return;
monthlyApprovalDraft.performanceResult = String(data.actualScoreTotal);
monthlyApprovalDraft.performanceResult = performanceResult;
}
function handleRequestApprove() {
@@ -796,6 +867,15 @@ function handleMonthlyApprovalChange(payload: Api.WorkReport.Monthly.MonthlyRepo
Object.assign(monthlyApprovalDraft, payload);
}
function handleMonthlySignOffSubmitted() {
visible.value = false;
emit('submitted');
}
onBeforeUnmount(() => {
revokeMonthlySupervisorSignaturePreview();
});
async function handleActionSubmit(
payload: Api.WorkReport.Common.StatusActionParams | Api.WorkReport.Monthly.MonthlyReportApproveParams,
actionTypeOverride?: 'approve' | 'reject'
@@ -841,7 +921,7 @@ async function handleActionSubmit(
v-model:visible="visible"
:title="dialogTitle"
:loading="loading"
:approval-mode="currentScene === 'approval'"
:approval-mode="currentScene === 'approval' || currentScene === 'sign-off'"
@close="currentStage = 'form'"
>
<WeeklyReportPage
@@ -849,7 +929,7 @@ async function handleActionSubmit(
:report-type="REPORT_TYPE_TEXT[reportType]"
:period="periodText"
:mode="mode"
:scene="currentScene"
:scene="currentPageScene"
:base-info="baseInfo as Api.WorkReport.Weekly.WeeklyReport | null"
:model="weeklyModel"
@back="handleBack"
@@ -869,18 +949,28 @@ async function handleActionSubmit(
:base-info="baseInfo as Api.WorkReport.Monthly.MonthlyReport | null"
:model="monthlyModel"
:approval-model="monthlyApprovalDraft"
:supervisor-signature-preview-url="monthlySupervisorSignaturePreviewUrl"
:supervisor-signature-loading="monthlySupervisorSignatureLoading"
@back="handleBack"
@change-approval="handleMonthlyApprovalChange"
@request-approve="handleRequestApprove"
@request-reject="handleRequestReject"
/>
<MonthlyReportSignOffPage
v-else-if="reportType === 'monthly' && currentStage === 'sign-off'"
:report-id="currentReportId"
:base-info="baseInfo as Api.WorkReport.Monthly.MonthlyReport | null"
@back="handleBack"
@submitted="handleMonthlySignOffSubmitted"
/>
<MonthlyReportPage
v-else-if="reportType === 'monthly'"
:report-type="REPORT_TYPE_TEXT[reportType]"
:period="periodText"
:mode="mode"
:scene="currentScene"
:scene="currentPageScene"
:base-info="baseInfo as Api.WorkReport.Monthly.MonthlyReport | null"
:model="monthlyModel"
@back="handleBack"
@@ -895,7 +985,7 @@ async function handleActionSubmit(
:report-type="REPORT_TYPE_TEXT[reportType]"
:period="periodText"
:mode="mode"
:scene="currentScene"
:scene="currentPageScene"
:base-info="baseInfo as Api.WorkReport.Project.ProjectReport | null"
:model="projectModel"
@back="handleBack"

View File

@@ -1,13 +1,21 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import type { UploadFile, UploadInstance, UploadRawFile } from 'element-plus';
import { userGenderOptions } from '@/constants/business';
import {
deleteFile,
downloadFile,
fetchAssignUserRoles,
fetchCreateUser,
fetchDeleteUserSignature,
fetchGetUser,
fetchGetUserRoleIds,
fetchUpdateUser
fetchGetUserSignature,
fetchSaveUserSignature,
fetchUpdateUser,
uploadFile
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useForm, useFormRules } from '@/hooks/common/form';
import { translateOptions } from '@/utils/common';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
@@ -40,9 +48,25 @@ const visible = defineModel<boolean>('visible', {
const { formRef, validate } = useForm();
const { createRequiredRule, patternRules } = useFormRules();
const { hasAuth } = useAuth();
const SIGNATURE_QUERY_PERMISSION = 'system:user-signature:query';
const SIGNATURE_UPDATE_PERMISSION = 'system:user-signature:update';
const SIGNATURE_MAX_SIZE = 5 * 1024 * 1024;
const loading = ref(false);
const submitting = ref(false);
const signatureLoading = ref(false);
const signatureUploading = ref(false);
const signatureUploadRef = ref<UploadInstance>();
const originalSignature = ref<Api.SystemManage.UserSignature | null>(null);
const currentSignatureFileId = ref<string | null>(null);
const currentSignatureFileName = ref<string | null>(null);
const currentSignatureSource = ref<'none' | 'original' | 'uploaded'>('none');
const currentSignaturePreviewUrl = ref('');
const currentSignatureObjectUrl = ref<string | null>(null);
const signatureDirty = ref(false);
const keepUploadedSignatureOnClose = ref(false);
const title = computed(() => {
const titles: Record<UI.TableOperateType, string> = {
@@ -54,6 +78,11 @@ const title = computed(() => {
});
const isEdit = computed(() => props.operateType === 'edit');
const canQuerySignature = computed(() => hasAuth(SIGNATURE_QUERY_PERMISSION));
const canUpdateSignature = computed(() => hasAuth(SIGNATURE_UPDATE_PERMISSION));
const showSignatureSection = computed(() => canQuerySignature.value);
const hasSignaturePreview = computed(() => Boolean(currentSignaturePreviewUrl.value));
const signatureReadonly = computed(() => !canUpdateSignature.value);
type Model = Api.SystemManage.SaveUserParams & {
roleIds: string[];
@@ -96,6 +125,201 @@ function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function revokeSignaturePreviewUrl() {
if (currentSignatureObjectUrl.value) {
URL.revokeObjectURL(currentSignatureObjectUrl.value);
currentSignatureObjectUrl.value = null;
}
currentSignaturePreviewUrl.value = '';
}
function setSignaturePreviewFromBlob(blob: Blob) {
revokeSignaturePreviewUrl();
currentSignatureObjectUrl.value = URL.createObjectURL(blob);
currentSignaturePreviewUrl.value = currentSignatureObjectUrl.value;
}
function syncSignatureDirtyState() {
signatureDirty.value = (originalSignature.value?.fileId ?? null) !== currentSignatureFileId.value;
}
async function cleanupUploadedSignatureFile(fileId?: string | null) {
if (!fileId) return;
await deleteFile(fileId).catch(() => undefined);
}
async function cleanupSignatureState(options?: { preserveUploaded?: boolean }) {
const preserveUploaded = Boolean(options?.preserveUploaded);
const uploadedFileId =
currentSignatureSource.value === 'uploaded' &&
currentSignatureFileId.value &&
currentSignatureFileId.value !== originalSignature.value?.fileId
? currentSignatureFileId.value
: null;
if (!preserveUploaded && uploadedFileId) {
await cleanupUploadedSignatureFile(uploadedFileId);
}
signatureUploadRef.value?.clearFiles();
revokeSignaturePreviewUrl();
originalSignature.value = null;
currentSignatureFileId.value = null;
currentSignatureFileName.value = null;
currentSignatureSource.value = 'none';
signatureDirty.value = false;
signatureLoading.value = false;
signatureUploading.value = false;
}
async function loadSignaturePreview(fileId: string) {
const { error, data } = await downloadFile(fileId);
if (error || !data) {
revokeSignaturePreviewUrl();
return;
}
setSignaturePreviewFromBlob(data);
}
async function initSignatureState() {
await cleanupSignatureState({ preserveUploaded: true });
if (!showSignatureSection.value || !isEdit.value || !props.userId) {
return;
}
signatureLoading.value = true;
const { error, data } = await fetchGetUserSignature(String(props.userId));
signatureLoading.value = false;
if (error || !data?.fileId) {
return;
}
originalSignature.value = data;
currentSignatureFileId.value = data.fileId;
currentSignatureFileName.value = data.fileName ?? null;
currentSignatureSource.value = 'original';
syncSignatureDirtyState();
await loadSignaturePreview(data.fileId);
}
function validateSignatureFile(file: UploadRawFile) {
if (!file.type.startsWith('image/')) {
window.$message?.warning('电子签名仅支持图片文件');
return false;
}
if (file.size > SIGNATURE_MAX_SIZE) {
window.$message?.warning('电子签名图片不能超过 5MB');
return false;
}
return true;
}
async function handleSignatureFileChange(file: UploadFile) {
const rawFile = file.raw;
signatureUploadRef.value?.clearFiles();
if (!rawFile || !canUpdateSignature.value || !validateSignatureFile(rawFile)) {
return;
}
signatureUploading.value = true;
const { error, data } = await uploadFile(rawFile, 'system/user-signatures');
signatureUploading.value = false;
if (error || !data) {
return;
}
const previousUploadedFileId =
currentSignatureSource.value === 'uploaded' &&
currentSignatureFileId.value &&
currentSignatureFileId.value !== originalSignature.value?.fileId
? currentSignatureFileId.value
: null;
if (previousUploadedFileId && previousUploadedFileId !== data.id) {
await cleanupUploadedSignatureFile(previousUploadedFileId);
}
currentSignatureFileId.value = data.id;
currentSignatureFileName.value = data.path.split('/').pop() || rawFile.name;
currentSignatureSource.value = 'uploaded';
setSignaturePreviewFromBlob(rawFile);
syncSignatureDirtyState();
window.$message?.success('电子签名上传成功');
}
async function clearSignature() {
if (!canUpdateSignature.value) return;
const uploadedFileId =
currentSignatureSource.value === 'uploaded' &&
currentSignatureFileId.value &&
currentSignatureFileId.value !== originalSignature.value?.fileId
? currentSignatureFileId.value
: null;
if (uploadedFileId) {
await cleanupUploadedSignatureFile(uploadedFileId);
}
currentSignatureFileId.value = null;
currentSignatureFileName.value = null;
currentSignatureSource.value = 'none';
signatureUploadRef.value?.clearFiles();
revokeSignaturePreviewUrl();
syncSignatureDirtyState();
}
async function syncUserSignature(userId: number) {
if (!showSignatureSection.value || !canUpdateSignature.value || !signatureDirty.value) {
return true;
}
const originalFileId = originalSignature.value?.fileId ?? null;
const currentFileId = currentSignatureFileId.value;
if (!currentFileId) {
if (!originalFileId) {
return true;
}
const result = await fetchDeleteUserSignature(String(userId));
if (result.error) {
return false;
}
await cleanupUploadedSignatureFile(originalFileId);
return true;
}
const result = await fetchSaveUserSignature({
userId: String(userId),
fileId: currentFileId,
fileName: currentSignatureFileName.value ?? null,
status: 0,
remark: null
});
if (result.error) {
return false;
}
if (originalFileId && originalFileId !== currentFileId) {
await cleanupUploadedSignatureFile(originalFileId);
}
return true;
}
const rules = computed(() => {
const passwordRules = isEdit.value
? []
@@ -114,6 +338,7 @@ const rules = computed(() => {
async function handleInitModel() {
model.value = createDefaultModel();
await initSignatureState();
if (!isEdit.value || !props.userId) {
return;
@@ -205,9 +430,17 @@ async function handleSubmit() {
submitting.value = false;
return;
}
const signatureSynced = await syncUserSignature(userId);
if (!signatureSynced) {
submitting.value = false;
return;
}
}
submitting.value = false;
keepUploadedSignatureOnClose.value = true;
window.$message?.success(isEdit.value ? $t('common.updateSuccess') : $t('common.addSuccess'));
closeDialog();
@@ -216,6 +449,9 @@ async function handleSubmit() {
watch(visible, async value => {
if (!value) {
const preserveUploaded = keepUploadedSignatureOnClose.value;
keepUploadedSignatureOnClose.value = false;
await cleanupSignatureState({ preserveUploaded });
return;
}
@@ -223,6 +459,10 @@ watch(visible, async value => {
await nextTick();
formRef.value?.clearValidate();
});
onBeforeUnmount(() => {
revokeSignaturePreviewUrl();
});
</script>
<template>
@@ -362,6 +602,40 @@ watch(visible, async value => {
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection v-if="showSignatureSection" title="电子签名">
<div v-loading="signatureLoading" class="signature-section">
<div class="signature-preview-card">
<div v-if="hasSignaturePreview" class="signature-preview-box">
<img :src="currentSignaturePreviewUrl" alt="电子签名预览" class="signature-preview-image" />
</div>
<div v-else class="signature-preview-empty">
{{ signatureReadonly ? '未维护电子签名' : '请上传电子签名图片' }}
</div>
<div class="signature-preview-meta">
<div class="signature-preview-name">{{ currentSignatureFileName || '未选择文件' }}</div>
<div class="signature-preview-tip">仅支持图片大小不超过 5MB</div>
</div>
</div>
<div v-if="canUpdateSignature" class="signature-actions">
<ElUpload
ref="signatureUploadRef"
:show-file-list="false"
:auto-upload="false"
accept="image/*"
:on-change="handleSignatureFileChange"
>
<ElButton plain type="primary" :loading="signatureUploading">
{{ hasSignaturePreview ? '重新上传' : '上传签名' }}
</ElButton>
</ElUpload>
<ElButton plain :disabled="!currentSignatureFileId || signatureUploading" @click="clearSignature">
清空
</ElButton>
</div>
</div>
</BusinessFormSection>
</ElForm>
</BusinessFormDialog>
</template>
@@ -380,4 +654,57 @@ watch(visible, async value => {
opacity: 0;
pointer-events: none;
}
.signature-section {
display: grid;
gap: 12px;
}
.signature-preview-card {
display: grid;
gap: 10px;
}
.signature-preview-box,
.signature-preview-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
padding: 12px;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
background: var(--el-fill-color-lighter);
}
.signature-preview-image {
max-width: 100%;
max-height: 136px;
object-fit: contain;
}
.signature-preview-empty {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.signature-preview-meta {
display: grid;
gap: 4px;
}
.signature-preview-name {
color: var(--el-text-color-primary);
font-size: 13px;
}
.signature-preview-tip {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.signature-actions {
display: flex;
gap: 8px;
}
</style>

View File

@@ -12,6 +12,7 @@ import {
fetchChangePersonalItemStatus,
fetchChangeProjectTaskStatus,
fetchGetMonthlyReportApprovalPage,
fetchGetMonthlyReportSignOffPage,
fetchGetMyTaskPage,
fetchGetOvertimeApplicationApprovalPage,
fetchGetPersonalItemDetail,
@@ -70,6 +71,7 @@ type SortKey = 'created' | 'priority' | 'deadline';
type OvertimeApprovalActionType = 'approve' | 'reject';
type PerformanceApprovalActionType = 'confirm' | 'reject';
type ApprovalBizType = 'overtime_application' | 'performance' | WorkReportType;
type WorkReportDetailScene = 'approval' | 'sign-off';
defineOptions({ name: 'WorkbenchTodoPanel' });
@@ -103,7 +105,8 @@ const { loading, refresh } = useWorkbenchRefresh(async () => {
loadPersonalTodoItems(),
loadOvertimeApprovalItems(),
loadPerformanceApprovalItems(),
loadWorkReportApprovalItems()
loadWorkReportApprovalItems(),
loadMonthlySignOffItems()
]);
});
@@ -147,8 +150,9 @@ const approvalBizTabs: Array<{ key: ApprovalBizType; label: string }> = [
{ key: 'project', label: '项目半月报' },
{ key: 'overtime_application', label: '业务学习' }
];
const confirmBizTabs: Array<{ key: Extract<ApprovalBizType, 'performance'>; label: string }> = [
{ key: 'performance', label: '绩效表' }
const confirmBizTabs: Array<{ key: Extract<ApprovalBizType, 'performance' | 'monthly'>; label: string }> = [
{ key: 'performance', label: '绩效表' },
{ key: 'monthly', label: '月报' }
];
const hasWorkReportApprovePermission = computed(() => hasButtonPermission('project:work-report:approve'));
@@ -157,10 +161,11 @@ const hasPerformanceApprovePermission = computed(
() =>
hasButtonPermission(PerformancePermission.SheetConfirm) && hasButtonPermission(PerformancePermission.SheetReject)
);
const hasMonthlySignOffQueryPermission = computed(() => hasButtonPermission('project:work-report:sign-off-query'));
const visibleMainTabs = computed(() =>
mainTabs.filter(tab => {
if (tab.key === 'confirm') {
return hasPerformanceApprovePermission.value;
return hasPerformanceApprovePermission.value || hasMonthlySignOffQueryPermission.value;
}
return true;
})
@@ -178,7 +183,15 @@ const visibleApprovalBizTabs = computed(() =>
return hasWorkReportApprovePermission.value;
})
);
const visibleConfirmBizTabs = computed(() => (hasPerformanceApprovePermission.value ? confirmBizTabs : []));
const visibleConfirmBizTabs = computed(() =>
confirmBizTabs.filter(tab => {
if (tab.key === 'performance') {
return hasPerformanceApprovePermission.value;
}
return hasMonthlySignOffQueryPermission.value;
})
);
const myTaskItems = ref<WorkbenchTodoItem[]>([]);
// 保留任务原始行,供操作图标按 availableActions 渲染并取 projectId / executionId 调状态变更接口
@@ -190,6 +203,8 @@ const overtimeApprovalItems = ref<WorkbenchTodoItem[]>([]);
const overtimeApprovalRows = ref<Api.OvertimeApplication.OvertimeApplication[]>([]);
const performanceApprovalItems = ref<WorkbenchTodoItem[]>([]);
const performanceApprovalRows = ref<Api.Performance.Sheet.Sheet[]>([]);
const monthlySignOffItems = ref<WorkbenchTodoItem[]>([]);
const monthlySignOffRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]);
const workReportApprovalItems = ref<WorkbenchTodoItem[]>([]);
const weeklyApprovalRows = ref<Api.WorkReport.Weekly.WeeklyReport[]>([]);
const monthlyApprovalRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]);
@@ -200,6 +215,7 @@ const mergedItems = computed(() => [
...personalTodoItems.value,
...overtimeApprovalItems.value,
...performanceApprovalItems.value,
...monthlySignOffItems.value,
...workReportApprovalItems.value
]);
@@ -231,6 +247,7 @@ const batchSubmitting = ref(false);
const workReportDetailVisible = ref(false);
const currentWorkReport = ref<WorkReportRow | null>(null);
const currentWorkReportType = ref<WorkReportType>('weekly');
const currentWorkReportScene = ref<WorkReportDetailScene>('approval');
const performanceDetailVisible = ref(false);
const performanceActionVisible = ref(false);
const currentPerformanceSheet = ref<Api.Performance.Sheet.Sheet | null>(null);
@@ -595,7 +612,7 @@ const filteredItems = computed(() => {
}
if (activeTab.value === 'confirm') {
return itemsInTab.value.filter(item => item.approvalBizType === 'performance');
return itemsInTab.value.filter(item => item.approvalBizType === activeApprovalBizType.value);
}
return filterWorkbenchTodoItemsByDeadline(itemsInTab.value, activeDeadlineFilter.value);
@@ -660,6 +677,19 @@ watch([activeTab, activeDeadlineFilter, activeSort], () => {
watch(
visibleApprovalBizTabs,
tabs => {
if (activeTab.value !== 'approval') return;
if (!tabs.length) return;
if (!tabs.some(tab => tab.key === activeApprovalBizType.value)) {
activeApprovalBizType.value = tabs[0].key;
}
},
{ immediate: true }
);
watch(
visibleConfirmBizTabs,
tabs => {
if (activeTab.value !== 'confirm') return;
if (!tabs.length) return;
if (!tabs.some(tab => tab.key === activeApprovalBizType.value)) {
activeApprovalBizType.value = tabs[0].key;
@@ -679,12 +709,12 @@ watch(
);
function handleSelectTab(key: WorkbenchTodoMainTab) {
if (key === 'confirm' && !hasPerformanceApprovePermission.value) return;
if (key === 'confirm' && !visibleConfirmBizTabs.value.length) return;
if (activeTab.value === key) return;
activeTab.value = key;
activeDeadlineFilter.value = null;
if (key === 'confirm') {
activeApprovalBizType.value = 'performance';
activeApprovalBizType.value = visibleConfirmBizTabs.value[0]?.key ?? 'performance';
} else if (key === 'approval' && activeApprovalBizType.value === 'performance') {
activeApprovalBizType.value = visibleApprovalBizTabs.value[0]?.key ?? 'weekly';
}
@@ -787,7 +817,8 @@ function findWorkReportApprovalRow(item: WorkbenchTodoItem) {
}
if (item.approvalBizType === 'monthly') {
return monthlyApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
const sourceRows = item.category === 'confirm' ? monthlySignOffRows.value : monthlyApprovalRows.value;
return sourceRows.find(row => row.id === item.approvalBizId) || null;
}
return projectApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
@@ -799,6 +830,7 @@ function openWorkReportDetail(item: WorkbenchTodoItem) {
currentWorkReport.value = row;
currentWorkReportType.value = item.approvalBizType;
currentWorkReportScene.value = item.category === 'confirm' ? 'sign-off' : 'approval';
workReportDetailVisible.value = true;
}
@@ -825,7 +857,7 @@ async function handleOvertimeActionSubmit(payload: { actionType: OvertimeApprova
async function handleWorkReportSubmitted() {
workReportDetailVisible.value = false;
await loadWorkReportApprovalItems();
await Promise.all([loadWorkReportApprovalItems(), loadMonthlySignOffItems()]);
}
async function handlePerformanceActionSubmitted() {
@@ -1035,7 +1067,8 @@ async function loadPerformanceApprovalItems() {
const { error, data } = await fetchPerformanceSheetPage({
pageNo: 1,
pageSize: 20,
statusCode: 'sent'
statusCode: 'sent',
signOffStatus: 'pending_employee_confirm'
});
if (error || !data) {
@@ -1061,6 +1094,47 @@ async function loadPerformanceApprovalItems() {
);
}
async function loadMonthlySignOffItems() {
if (!hasMonthlySignOffQueryPermission.value) {
monthlySignOffRows.value = [];
monthlySignOffItems.value = [];
return;
}
const { error, data } = await fetchGetMonthlyReportSignOffPage({
pageNo: 1,
pageSize: 20,
statusCode: 'approved',
signOffStatus: 'pending_employee_confirm',
keyword: undefined,
periodStartDate: undefined,
submitTime: undefined,
supervisorName: undefined
});
if (error || !data) {
monthlySignOffRows.value = [];
monthlySignOffItems.value = [];
return;
}
monthlySignOffRows.value = data.list;
monthlySignOffItems.value = buildWorkbenchTodoItems(
data.list.map(item => ({
id: `monthly-sign-off-${item.id}`,
category: 'confirm',
categoryLabel: '月报',
title: `${WORK_REPORT_TYPE_LABEL.monthly} · ${formatPeriod(item)} 待确认`,
createdTime: item.approvalTime || item.submitTime || item.createTime || '',
deadline: item.approvalTime || item.submitTime || item.createTime || null,
source: `${WORK_REPORT_TYPE_LABEL.monthly} · ${item.reporterName}`,
priority: 'mid',
approvalBizType: 'monthly',
approvalBizId: item.id
}))
);
}
function buildWorkReportApprovalItems<T extends WorkReportRow>(
bizType: WorkReportType,
rows: T[]
@@ -1522,7 +1596,7 @@ onActivated(refresh);
<WorkReportPrototypePageDialog
v-model:visible="workReportDetailVisible"
mode="detail"
scene="approval"
:scene="currentWorkReportScene"
:report-type="currentWorkReportType"
:row-data="currentWorkReport"
@submitted="handleWorkReportSubmitted"