feat(performance): 优化绩效模板管理功能

- 新增绩效模板上传范围接口,限制组织负责人权限
- 实现单人上传和批量导入两种模板上传方式
- 添加模板年份、员工筛选等搜索条件
- 集成文件上传验证和批量导入结果展示
- 重构模板对话框UI布局,提升用户体验
- 更新API类型定义和数据转换逻辑
This commit is contained in:
dk
2026-07-24 16:34:59 +08:00
parent 2ac36bd4f6
commit 366b7a12c8
5 changed files with 633 additions and 380 deletions

View File

@@ -19,6 +19,8 @@ type TemplateResponse = Omit<Api.Performance.Template.Template, 'id' | 'fileId'
id: StringIdResponse; id: StringIdResponse;
fileId: StringIdResponse; fileId: StringIdResponse;
uploadUserId: StringIdResponse; uploadUserId: StringIdResponse;
employeeId?: StringIdResponse | null;
employeeDeptId?: StringIdResponse | null;
activeFlag?: boolean | number | string | null; activeFlag?: boolean | number | string | null;
}; };
@@ -27,6 +29,34 @@ type TemplatePageResponse = {
list: TemplateResponse[]; list: TemplateResponse[];
}; };
type TemplateUploadScopeResponse = {
orgLeader?: boolean | number | string | null;
employeeCount?: number | string | null;
employees?: Array<{
employeeId: StringIdResponse;
employeeName: string;
employeeDeptId?: StringIdResponse | null;
employeeDeptName?: string | null;
}> | null;
};
type TemplateBatchImportResponse = {
successCount?: number | string | null;
failureCount?: number | string | null;
successes?: Array<{
employeeId: StringIdResponse;
employeeName: string;
templateId: StringIdResponse;
sourceFileName: string;
sheetName?: string | null;
}> | null;
failures?: Array<{
sourceFileName: string;
sheetName?: string | null;
reason: string;
}> | null;
};
type SheetResponse = Omit< type SheetResponse = Omit<
Api.Performance.Sheet.Sheet, Api.Performance.Sheet.Sheet,
'id' | 'employeeId' | 'employeeDeptId' | 'managerId' | 'templateId' | 'fileId' 'id' | 'employeeId' | 'employeeDeptId' | 'managerId' | 'templateId' | 'fileId'
@@ -135,6 +165,8 @@ function normalizeTemplate(response: TemplateResponse): Api.Performance.Template
...response, ...response,
id: normalizeStringId(response.id), id: normalizeStringId(response.id),
fileId: normalizeStringId(response.fileId), fileId: normalizeStringId(response.fileId),
employeeId: normalizeNullableStringId(response.employeeId),
employeeDeptId: normalizeNullableStringId(response.employeeDeptId),
uploadUserId: normalizeStringId(response.uploadUserId), uploadUserId: normalizeStringId(response.uploadUserId),
activeFlag: normalizeBooleanFlag(response.activeFlag), activeFlag: normalizeBooleanFlag(response.activeFlag),
remark: response.remark ?? null, remark: response.remark ?? null,
@@ -142,6 +174,40 @@ function normalizeTemplate(response: TemplateResponse): Api.Performance.Template
}; };
} }
function normalizeTemplateUploadScope(response: TemplateUploadScopeResponse): Api.Performance.Template.UploadScope {
return {
orgLeader: normalizeBooleanFlag(response.orgLeader),
employeeCount: normalizeTotal(response.employeeCount),
employees: (response.employees || []).map(item => ({
employeeId: normalizeStringId(item.employeeId),
employeeName: item.employeeName,
employeeDeptId: normalizeNullableStringId(item.employeeDeptId),
employeeDeptName: item.employeeDeptName ?? null
}))
};
}
function normalizeTemplateBatchImportResult(
response: TemplateBatchImportResponse
): Api.Performance.Template.BatchImportResult {
return {
successCount: normalizeTotal(response.successCount),
failureCount: normalizeTotal(response.failureCount),
successes: (response.successes || []).map(item => ({
employeeId: normalizeStringId(item.employeeId),
employeeName: item.employeeName,
templateId: normalizeStringId(item.templateId),
sourceFileName: item.sourceFileName,
sheetName: item.sheetName ?? null
})),
failures: (response.failures || []).map(item => ({
sourceFileName: item.sourceFileName,
sheetName: item.sheetName ?? null,
reason: item.reason
}))
};
}
function normalizeSheet(response: SheetResponse): Api.Performance.Sheet.Sheet { function normalizeSheet(response: SheetResponse): Api.Performance.Sheet.Sheet {
return { return {
...response, ...response,
@@ -304,15 +370,21 @@ function createTemplateQuery(params: Api.Performance.Template.SearchParams = {})
query.append('pageNo', String(params.pageNo ?? 1)); query.append('pageNo', String(params.pageNo ?? 1));
query.append('pageSize', String(params.pageSize ?? 10)); query.append('pageSize', String(params.pageSize ?? 10));
appendValue(query, 'templateName', params.templateName); appendValue(query, 'templateName', params.templateName);
appendValue(query, 'employeeId', params.employeeId);
appendValue(query, 'employeeName', params.employeeName);
appendValue(query, 'templateYear', params.templateYear);
appendValue(query, 'uploadUserName', params.uploadUserName);
appendValue(query, 'activeFlag', params.activeFlag); appendValue(query, 'activeFlag', params.activeFlag);
return query.toString(); return query.toString();
} }
export async function fetchPerformanceTemplateCurrent() { export async function fetchPerformanceTemplateCurrent(employeeId?: string | null) {
const query = new URLSearchParams();
appendValue(query, 'employeeId', employeeId);
const result = await request<TemplateResponse | null>({ const result = await request<TemplateResponse | null>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${TEMPLATE_PREFIX}/current`, url: query.toString() ? `${TEMPLATE_PREFIX}/current?${query.toString()}` : `${TEMPLATE_PREFIX}/current`,
method: 'get' method: 'get'
}); });
@@ -335,10 +407,20 @@ export async function fetchPerformanceTemplatePage(params: Api.Performance.Templ
})); }));
} }
export async function uploadPerformanceTemplate(data: Api.Performance.Template.UploadParams) { export async function fetchPerformanceTemplateUploadScope() {
const result = await request<TemplateUploadScopeResponse>({
...safeJsonRequestConfig,
url: `${TEMPLATE_PREFIX}/upload-scope`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<TemplateUploadScopeResponse>, normalizeTemplateUploadScope);
}
export async function uploadSinglePerformanceTemplate(data: Api.Performance.Template.SingleUploadParams) {
const result = await request<StringIdResponse>({ const result = await request<StringIdResponse>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${TEMPLATE_PREFIX}/upload`, url: `${TEMPLATE_PREFIX}/upload-single`,
method: 'post', method: 'post',
data data
}); });
@@ -346,6 +428,20 @@ export async function uploadPerformanceTemplate(data: Api.Performance.Template.U
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId); return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
} }
export async function batchImportPerformanceTemplates(data: Api.Performance.Template.BatchImportParams) {
const result = await request<TemplateBatchImportResponse>({
...safeJsonRequestConfig,
url: `${TEMPLATE_PREFIX}/batch-import`,
method: 'post',
data
});
return mapServiceResult(
result as ServiceRequestResult<TemplateBatchImportResponse>,
normalizeTemplateBatchImportResult
);
}
export function activatePerformanceTemplate(id: string) { export function activatePerformanceTemplate(id: string) {
return request<boolean>({ return request<boolean>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,

View File

@@ -32,6 +32,11 @@ declare namespace Api {
templateName: string; templateName: string;
fileId: string; fileId: string;
fileName: string; fileName: string;
employeeId?: string | null;
employeeName?: string | null;
employeeDeptId?: string | null;
employeeDeptName?: string | null;
templateYear?: number | null;
versionNo: number; versionNo: number;
activeFlag: boolean; activeFlag: boolean;
uploadUserId: string; uploadUserId: string;
@@ -44,17 +49,67 @@ declare namespace Api {
type SearchParams = CommonType.RecordNullable< type SearchParams = CommonType.RecordNullable<
Common.PageParams & { Common.PageParams & {
templateName: string; templateName: string;
employeeId: string;
employeeName: string;
templateYear: number;
uploadUserName: string;
activeFlag: boolean; activeFlag: boolean;
} }
>; >;
interface UploadParams { interface ScopeEmployee {
employeeId: string;
employeeName: string;
employeeDeptId?: string | null;
employeeDeptName?: string | null;
}
interface UploadScope {
orgLeader: boolean;
employeeCount: number;
employees: ScopeEmployee[];
}
interface SingleUploadParams {
employeeId: string;
templateName: string; templateName: string;
fileId: string; templateYear?: number | null;
fileName: string; sourceFileId: string;
activeFlag?: boolean | null; sourceFileName: string;
remark?: string | null; remark?: string | null;
} }
interface BatchImportFileItem {
sourceFileId: string;
sourceFileName: string;
}
interface BatchImportParams {
templateYear?: number | null;
remark?: string | null;
files: BatchImportFileItem[];
}
interface BatchImportSuccessItem {
employeeId: string;
employeeName: string;
templateId: string;
sourceFileName: string;
sheetName?: string | null;
}
interface BatchImportFailureItem {
sourceFileName: string;
sheetName?: string | null;
reason: string;
}
interface BatchImportResult {
successCount: number;
failureCount: number;
successes: BatchImportSuccessItem[];
failures: BatchImportFailureItem[];
}
} }
namespace Sheet { namespace Sheet {

View File

@@ -13,6 +13,7 @@ import {
fetchGetMySubordinateTree, fetchGetMySubordinateTree,
fetchPerformanceSheetPage, fetchPerformanceSheetPage,
fetchPerformanceSheetStatusDict, fetchPerformanceSheetStatusDict,
fetchPerformanceTemplateUploadScope,
fetchTeamPerformanceSummary, fetchTeamPerformanceSummary,
formatToYYYYMM, formatToYYYYMM,
resendPerformanceSheet, resendPerformanceSheet,
@@ -111,6 +112,8 @@ const deptOptions = ref<Array<{ label: string; value: string }>>([]);
const directSubordinateOptions = ref<Array<{ label: string; value: string }>>([]); const directSubordinateOptions = ref<Array<{ label: string; value: string }>>([]);
const statusOptions = ref<Array<{ label: string; value: string }>>([]); const statusOptions = ref<Array<{ label: string; value: string }>>([]);
const currentDeptId = ref<string>(''); const currentDeptId = ref<string>('');
const templateUploadScope = ref<Api.Performance.Template.UploadScope | null>(null);
const templateUploadScopeLoading = ref(false);
const templateVisible = ref(false); const templateVisible = ref(false);
const excelVisible = ref(false); const excelVisible = ref(false);
@@ -144,9 +147,10 @@ const canSignOff = computed(
() => hasAuth(PerformancePermission.SheetImport) && hasAuth(PerformancePermission.SheetSignOff) () => hasAuth(PerformancePermission.SheetImport) && hasAuth(PerformancePermission.SheetSignOff)
); );
const canExport = computed(() => hasAuth(PerformancePermission.SheetExport)); const canExport = computed(() => hasAuth(PerformancePermission.SheetExport));
const canManageTemplate = computed( const hasTemplatePermission = computed(
() => hasAuth(PerformancePermission.TemplateQuery) || hasAuth(PerformancePermission.TemplateUpdate) () => hasAuth(PerformancePermission.TemplateQuery) || hasAuth(PerformancePermission.TemplateUpdate)
); );
const canManageTemplate = computed(() => hasTemplatePermission.value && Boolean(templateUploadScope.value?.orgLeader));
const isTeamMode = computed(() => teamViewMode.value === 'team'); const isTeamMode = computed(() => teamViewMode.value === 'team');
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value)); const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
const subordinateOptions = computed(() => { const subordinateOptions = computed(() => {
@@ -656,6 +660,19 @@ async function loadStatusOptions() {
statusOptions.value = getPerformanceStatusOptions(statusDict); statusOptions.value = getPerformanceStatusOptions(statusDict);
} }
async function loadTemplateUploadScope() {
if (!hasTemplatePermission.value) {
templateUploadScope.value = null;
return;
}
templateUploadScopeLoading.value = true;
const { error, data: scopeData } = await fetchPerformanceTemplateUploadScope();
templateUploadScopeLoading.value = false;
templateUploadScope.value = error || !scopeData ? null : scopeData;
}
async function handleTeamViewModeChange(mode: TeamViewMode) { async function handleTeamViewModeChange(mode: TeamViewMode) {
teamViewMode.value = mode; teamViewMode.value = mode;
@@ -694,7 +711,12 @@ watch(excelVisible, isVisible => {
}); });
onMounted(async () => { onMounted(async () => {
await Promise.all([loadDeptOptions(), loadDirectSubordinateOptions(), loadStatusOptions()]); await Promise.all([
loadDeptOptions(),
loadDirectSubordinateOptions(),
loadStatusOptions(),
loadTemplateUploadScope()
]);
if (canUseTeamDashboard.value) { if (canUseTeamDashboard.value) {
await loadSubordinateTree(); await loadSubordinateTree();
@@ -773,7 +795,12 @@ onMounted(async () => {
</template> </template>
下签 下签
</ElButton> </ElButton>
<ElButton v-if="isTeamMode && canManageTemplate" plain @click="templateVisible = true"> <ElButton
v-if="isTeamMode && canManageTemplate"
plain
:loading="templateUploadScopeLoading"
@click="templateVisible = true"
>
<template #icon> <template #icon>
<icon-mdi-file-cog-outline class="text-icon" /> <icon-mdi-file-cog-outline class="text-icon" />
</template> </template>
@@ -821,6 +848,7 @@ onMounted(async () => {
<PerformanceTemplateDialog <PerformanceTemplateDialog
v-if="templateVisible" v-if="templateVisible"
v-model:visible="templateVisible" v-model:visible="templateVisible"
:scope="templateUploadScope"
@updated="reloadAfterMutation" @updated="reloadAfterMutation"
/> />

View File

@@ -520,7 +520,12 @@ async function loadWorkbook() {
let sourceFileId = ''; let sourceFileId = '';
if (isCreateMode.value) { if (isCreateMode.value) {
const templateResult = await fetchPerformanceTemplateCurrent(); if (!createForm.employeeId) {
errorMessage.value = '请选择下属后再加载绩效模板';
return;
}
const templateResult = await fetchPerformanceTemplateCurrent(createForm.employeeId);
if (templateResult.error || !templateResult.data) { if (templateResult.error || !templateResult.data) {
errorMessage.value = '当前没有可用的绩效模板'; errorMessage.value = '当前没有可用的绩效模板';
return; return;
@@ -533,7 +538,7 @@ async function loadWorkbook() {
const [sheetResult, templateResult] = await Promise.all([ const [sheetResult, templateResult] = await Promise.all([
fetchPerformanceSheet(props.rowData.id), fetchPerformanceSheet(props.rowData.id),
fetchPerformanceTemplateCurrent() fetchPerformanceTemplateCurrent(props.rowData.employeeId)
]); ]);
if (sheetResult.error || !sheetResult.data) { if (sheetResult.error || !sheetResult.data) {

View File

@@ -1,181 +1,254 @@
<script setup lang="tsx"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import type { UploadFile, UploadFiles } from 'element-plus'; import { ElMessageBox } from 'element-plus';
import { ElButton, ElTag } from 'element-plus'; import type { UploadFile, UploadFiles, UploadRawFile } from 'element-plus';
import dayjs from 'dayjs';
import { import {
activatePerformanceTemplate, activatePerformanceTemplate,
batchImportPerformanceTemplates,
fetchPerformanceTemplatePage, fetchPerformanceTemplatePage,
uploadFile, uploadFile,
uploadPerformanceTemplate uploadSinglePerformanceTemplate
} from '@/service/api'; } from '@/service/api';
import { useUIPaginatedTable } from '@/hooks/common/table';
import { useAuth } from '@/hooks/business/auth'; import { useAuth } from '@/hooks/business/auth';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import { PerformancePermission, formatDateTime } from './performance-shared'; import { PerformancePermission, formatDateTime } from './performance-shared';
defineOptions({ name: 'PerformanceTemplateDialog' }); defineOptions({ name: 'PerformanceTemplateDialog' });
interface Props {
scope?: Api.Performance.Template.UploadScope | null;
}
const props = withDefaults(defineProps<Props>(), {
scope: null
});
const visible = defineModel<boolean>('visible', { default: false }); const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{ const emit = defineEmits<{
updated: []; updated: [];
}>(); }>();
type TemplatePageResponse = Awaited<ReturnType<typeof fetchPerformanceTemplatePage>>;
const { hasAuth } = useAuth(); const { hasAuth } = useAuth();
const canQueryTemplate = computed(() => hasAuth(PerformancePermission.TemplateQuery)); const canQueryTemplate = computed(() => hasAuth(PerformancePermission.TemplateQuery));
const canUpdateTemplate = computed(() => hasAuth(PerformancePermission.TemplateUpdate)); const canUpdateTemplate = computed(() => hasAuth(PerformancePermission.TemplateUpdate));
const employeeOptions = computed(() => props.scope?.employees || []);
const isOrgLeader = computed(() => Boolean(props.scope?.orgLeader));
const currentYear = dayjs().year();
const searchParams = reactive<Api.Performance.Template.SearchParams>({ const searchParams = reactive<Api.Performance.Template.SearchParams>({
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 10,
templateName: undefined, templateName: undefined,
employeeId: undefined,
employeeName: undefined,
templateYear: undefined,
uploadUserName: undefined,
activeFlag: undefined activeFlag: undefined
}); });
const uploadForm = reactive({ const singleForm = reactive({
employeeId: '',
templateName: '', templateName: '',
templateYear: currentYear,
remark: '', remark: '',
activeFlag: true, file: null as UploadRawFile | null
file: null as File | null
}); });
const uploading = ref(false); const batchForm = reactive({
templateYear: currentYear,
remark: '',
files: [] as UploadRawFile[]
});
const tableData = ref<Api.Performance.Template.Template[]>([]);
const total = ref(0);
const loading = ref(false);
const singleUploading = ref(false);
const batchUploading = ref(false);
const activatingId = ref(''); const activatingId = ref('');
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable< const singleFileName = computed(() => singleForm.file?.name || '');
TemplatePageResponse, const batchFileNames = computed(() => batchForm.files.map(file => file.name));
Api.Performance.Template.Template
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchPerformanceTemplatePage(searchParams),
transform: response => {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: searchParams.pageNo ?? 1,
pageSize: searchParams.pageSize ?? 10,
total: response.data.total
};
}
return { function resetSingleForm() {
data: [], Object.assign(singleForm, {
pageNum: 1, employeeId: '',
pageSize: 10, templateName: '',
total: 0 templateYear: currentYear,
}; remark: '',
}, file: null
onPaginationParamsChange: params => { });
searchParams.pageNo = params.currentPage ?? 1; }
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'templateName', label: '模板名称', minWidth: 170, showOverflowTooltip: true },
// { prop: 'fileName', label: '文件名', minWidth: 200, showOverflowTooltip: true },
// { prop: 'versionNo', label: '版本', width: 80 },
{
prop: 'activeFlag',
label: '状态',
width: 100,
formatter: row => <ElTag type={row.activeFlag ? 'success' : 'info'}>{row.activeFlag ? '已启用' : '已禁用'}</ElTag>
},
{ prop: 'uploadUserName', label: '上传人', width: 110 },
{
prop: 'uploadTime',
label: '上传时间',
width: 180,
formatter: row => formatDateTime(row.uploadTime)
},
{
prop: 'operate',
label: '操作',
width: 110,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getTemplateActions(row)} />
}
]
});
const selectedFileName = computed(() => uploadForm.file?.name || ''); function resetBatchForm() {
Object.assign(batchForm, {
templateYear: currentYear,
remark: '',
files: []
});
}
async function loadTemplatePage(page = 1) { async function loadTemplatePage(page = searchParams.pageNo ?? 1) {
if (!canQueryTemplate.value) return; if (!canQueryTemplate.value) return;
await getDataByPage(page);
}
function getTemplateActions(row: Api.Performance.Template.Template): BusinessTableAction[] { searchParams.pageNo = page;
if (!canUpdateTemplate.value) return []; loading.value = true;
const { error, data } = await fetchPerformanceTemplatePage(searchParams);
loading.value = false;
return [ if (error || !data) {
{ tableData.value = [];
key: 'activate', total.value = 0;
label: row.activeFlag ? '已启用' : '启用',
buttonType: 'primary',
disabled: row.activeFlag || Boolean(activatingId.value),
onClick: () => handleActivate(row)
}
];
}
function handleFileChange(file: UploadFile, _files: UploadFiles) {
const rawFile = file.raw;
if (!rawFile) return;
uploadForm.file = rawFile;
if (!uploadForm.templateName) {
uploadForm.templateName = rawFile.name.replace(/\.[^.]+$/u, '');
}
}
async function handleUploadTemplate() {
if (!canUpdateTemplate.value) return;
if (!uploadForm.file) {
window.$message?.warning('请选择 Excel 模板文件');
return; return;
} }
if (!uploadForm.templateName.trim()) {
tableData.value = data.list;
total.value = data.total;
}
function handleSearch() {
loadTemplatePage(1);
}
function handleReset() {
Object.assign(searchParams, {
pageNo: 1,
pageSize: 10,
templateName: undefined,
employeeId: undefined,
employeeName: undefined,
templateYear: undefined,
uploadUserName: undefined,
activeFlag: undefined
});
loadTemplatePage(1);
}
function handleSingleFileChange(file: UploadFile) {
const rawFile = file.raw;
if (!rawFile) return;
singleForm.file = rawFile;
if (!singleForm.templateName) {
singleForm.templateName = rawFile.name.replace(/\.[^.]+$/u, '');
}
}
function handleBatchFileChange(_file: UploadFile, files: UploadFiles) {
batchForm.files = files.reduce<UploadRawFile[]>((list, item) => {
if (item.raw) {
list.push(item.raw);
}
return list;
}, []);
}
async function uploadSourceFile(file: File) {
return uploadFile(file, 'performance/template-sources');
}
async function handleSingleUpload() {
if (!canUpdateTemplate.value || !isOrgLeader.value) return;
if (!singleForm.employeeId) {
window.$message?.warning('请选择员工');
return;
}
if (!singleForm.templateName.trim()) {
window.$message?.warning('请输入模板名称'); window.$message?.warning('请输入模板名称');
return; return;
} }
if (!singleForm.file) {
uploading.value = true; window.$message?.warning('请选择 Excel 文件');
const fileResult = await uploadFile(uploadForm.file, 'performance/templates');
if (fileResult.error || !fileResult.data) {
uploading.value = false;
return; return;
} }
const result = await uploadPerformanceTemplate({ singleUploading.value = true;
templateName: uploadForm.templateName.trim(), const fileResult = await uploadSourceFile(singleForm.file);
fileId: fileResult.data.id, if (fileResult.error || !fileResult.data) {
fileName: uploadForm.file.name, singleUploading.value = false;
activeFlag: uploadForm.activeFlag, return;
remark: uploadForm.remark.trim() || undefined }
const result = await uploadSinglePerformanceTemplate({
employeeId: singleForm.employeeId,
templateName: singleForm.templateName.trim(),
templateYear: singleForm.templateYear || undefined,
sourceFileId: fileResult.data.id,
sourceFileName: singleForm.file.name,
remark: singleForm.remark.trim() || undefined
}); });
uploading.value = false; singleUploading.value = false;
if (result.error) return; if (result.error) return;
window.$message?.success('绩效模板上传'); window.$message?.success('绩效模板上传成功');
Object.assign(uploadForm, { resetSingleForm();
templateName: '',
remark: '',
activeFlag: true,
file: null
});
await loadTemplatePage(1); await loadTemplatePage(1);
emit('updated'); emit('updated');
} }
async function handleBatchImport() {
if (!canUpdateTemplate.value || !isOrgLeader.value) return;
if (!batchForm.files.length) {
window.$message?.warning('请选择要导入的 Excel 文件');
return;
}
batchUploading.value = true;
const uploadResults = await Promise.all(
batchForm.files.map(async file => {
const fileResult = await uploadSourceFile(file);
return { file, fileResult };
})
);
const failedUpload = uploadResults.find(item => item.fileResult.error || !item.fileResult.data);
if (failedUpload) {
batchUploading.value = false;
return;
}
const files: Api.Performance.Template.BatchImportFileItem[] = uploadResults.map(item => ({
sourceFileId: item.fileResult.data!.id,
sourceFileName: item.file.name
}));
const result = await batchImportPerformanceTemplates({
templateYear: batchForm.templateYear || undefined,
remark: batchForm.remark.trim() || undefined,
files
});
batchUploading.value = false;
if (result.error || !result.data) return;
await showBatchImportResult(result.data);
resetBatchForm();
await loadTemplatePage(1);
emit('updated');
}
async function showBatchImportResult(result: Api.Performance.Template.BatchImportResult) {
const lines = [`成功 ${result.successCount}`, `失败 ${result.failureCount}`];
result.failures.slice(0, 20).forEach(item => {
const sheetName = item.sheetName ? ` / ${item.sheetName}` : '';
lines.push(`${item.sourceFileName}${sheetName}${item.reason}`);
});
if (result.failures.length > 20) {
lines.push(`其余 ${result.failures.length - 20} 条失败明细请查看后端日志或再次筛查源文件`);
}
await ElMessageBox.alert(lines.join('\n'), '批量导入结果', {
confirmButtonText: '知道了'
});
}
async function handleActivate(row: Api.Performance.Template.Template) { async function handleActivate(row: Api.Performance.Template.Template) {
if (!canUpdateTemplate.value) return; if (!canUpdateTemplate.value) return;
@@ -191,7 +264,13 @@ async function handleActivate(row: Api.Performance.Template.Template) {
} }
watch(visible, isVisible => { watch(visible, isVisible => {
if (isVisible && canQueryTemplate.value) { if (!isVisible) return;
if (!singleForm.employeeId && employeeOptions.value.length === 1) {
singleForm.employeeId = employeeOptions.value[0].employeeId;
}
if (canQueryTemplate.value) {
loadTemplatePage(1); loadTemplatePage(1);
} }
}); });
@@ -204,125 +283,217 @@ watch(visible, isVisible => {
preset="lg" preset="lg"
append-to-body append-to-body
:show-footer="false" :show-footer="false"
max-body-height="76vh" max-body-height="78vh"
> >
<div class="performance-template-dialog"> <div class="performance-template-dialog">
<ElCard v-if="canUpdateTemplate" shadow="never"> <ElAlert
<ElForm :model="uploadForm" label-position="top" class="performance-template-dialog__upload-form"> v-if="canUpdateTemplate && !isOrgLeader"
<div class="performance-template-dialog__upload-grid"> type="warning"
<ElFormItem label="模板名称" class="performance-template-dialog__field"> :closable="false"
<ElInput v-model="uploadForm.templateName" placeholder="请输入模板名称" /> show-icon
</ElFormItem> title="当前账号不是组织负责人,不能上传绩效模板"
/>
<ElFormItem class="performance-template-dialog__field"> <div v-if="canUpdateTemplate && isOrgLeader" class="performance-template-dialog__upload-sections">
<template #label> <ElCard shadow="never">
<div class="performance-template-dialog__label"> <template #header>
<span>Excel 文件</span> <div class="performance-template-dialog__section-title">单人上传</div>
</div> </template>
</template>
<div class="performance-template-dialog__file-row"> <ElForm :model="singleForm" label-position="top" class="performance-template-dialog__form">
<ElUpload <div class="performance-template-dialog__grid">
class="performance-template-dialog__upload-trigger" <ElFormItem label="员工">
:auto-upload="false" <ElSelect v-model="singleForm.employeeId" filterable placeholder="请选择员工">
:show-file-list="false" <ElOption
accept=".xlsx,.xls" v-for="item in employeeOptions"
:limit="1" :key="item.employeeId"
:on-change="handleFileChange" :label="
> item.employeeDeptName ? `${item.employeeName}${item.employeeDeptName}` : item.employeeName
<ElButton plain class="performance-template-dialog__upload-button"> "
<template #icon> :value="item.employeeId"
<icon-mdi-upload class="text-icon" /> />
</template> </ElSelect>
选择文件 </ElFormItem>
</ElButton>
</ElUpload> <ElFormItem label="模板名称">
<div class="performance-template-dialog__file-name-wrapper"> <ElInput v-model="singleForm.templateName" placeholder="请输入模板名称" />
<ElTooltip </ElFormItem>
:disabled="!selectedFileName"
:content="selectedFileName" <ElFormItem label="模板年份">
placement="top" <ElInputNumber v-model="singleForm.templateYear" :min="2000" :max="2100" controls-position="right" />
effect="light" </ElFormItem>
popper-class="performance-template-dialog__file-tooltip"
<ElFormItem label="Excel 文件" class="performance-template-dialog__full">
<div class="performance-template-dialog__file-line">
<ElUpload
:auto-upload="false"
:show-file-list="false"
:limit="1"
accept=".xlsx"
@change="handleSingleFileChange"
> >
<div <ElButton>
class="performance-template-dialog__file-name" <template #icon>
:class="{ 'performance-template-dialog__file-name--placeholder': !selectedFileName }" <icon-mdi-upload class="text-icon" />
> </template>
<span class="performance-template-dialog__file-name-text"> 选择文件
{{ selectedFileName || '未选择文件' }} </ElButton>
</span> </ElUpload>
</div> <span class="performance-template-dialog__file-text">
</ElTooltip> {{ singleFileName || '未选择文件,仅支持 .xlsx' }}
</span>
</div> </div>
<ElTooltip placement="top" effect="light"> </ElFormItem>
<template #content>仅支持 .xlsx格式选择后会在这里显示文件名</template>
<button type="button" class="performance-template-dialog__hint-button" aria-label="Excel 文件说明">
<icon-mdi-information-outline />
</button>
</ElTooltip>
</div>
</ElFormItem>
<ElFormItem <ElFormItem label="备注" class="performance-template-dialog__full">
label="上传后启用" <ElInput v-model="singleForm.remark" type="textarea" :rows="2" maxlength="500" show-word-limit />
class="performance-template-dialog__field performance-template-dialog__switch-field" </ElFormItem>
> </div>
<div class="performance-template-dialog__switch-box">
<span>上传后立即切换为当前模板</span>
<ElSwitch v-model="uploadForm.activeFlag" />
</div>
</ElFormItem>
<!-- <ElFormItem--> <div class="performance-template-dialog__actions">
<!-- label="备注"--> <ElButton type="primary" :loading="singleUploading" @click="handleSingleUpload">上传模板</ElButton>
<!-- class="performance-template-dialog__field performance-template-dialog__field&#45;&#45;full"--> </div>
<!-- >--> </ElForm>
<!-- <ElInput v-model="uploadForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />--> </ElCard>
<!-- </ElFormItem>-->
</div>
<div class="performance-template-dialog__actions"> <ElCard shadow="never">
<ElButton type="primary" :loading="uploading" @click="handleUploadTemplate">上传模板</ElButton> <template #header>
</div> <div class="performance-template-dialog__section-title">批量导入</div>
</ElForm> </template>
</ElCard>
<ElCard v-if="canQueryTemplate" shadow="never" body-class="business-table-card-body"> <ElForm :model="batchForm" label-position="top" class="performance-template-dialog__form">
<div class="performance-template-dialog__grid">
<ElFormItem label="模板年份">
<ElInputNumber v-model="batchForm.templateYear" :min="2000" :max="2100" controls-position="right" />
</ElFormItem>
<ElFormItem label="Excel 文件" class="performance-template-dialog__full">
<div class="performance-template-dialog__file-line">
<ElUpload
:auto-upload="false"
:show-file-list="false"
multiple
accept=".xlsx"
@change="handleBatchFileChange"
>
<ElButton>
<template #icon>
<icon-mdi-upload class="text-icon" />
</template>
选择多个文件
</ElButton>
</ElUpload>
<div class="performance-template-dialog__batch-files">
<span v-if="!batchFileNames.length" class="performance-template-dialog__file-text">
未选择文件仅支持 .xlsx
</span>
<ElTag v-for="name in batchFileNames" :key="name" effect="plain">{{ name }}</ElTag>
</div>
</div>
</ElFormItem>
<ElFormItem label="备注" class="performance-template-dialog__full">
<ElInput v-model="batchForm.remark" type="textarea" :rows="2" maxlength="500" show-word-limit />
</ElFormItem>
</div>
<div class="performance-template-dialog__actions">
<ElButton type="primary" :loading="batchUploading" @click="handleBatchImport">批量导入</ElButton>
</div>
</ElForm>
</ElCard>
</div>
<ElCard v-if="canQueryTemplate" shadow="never">
<template #header> <template #header>
<div class="flex items-center justify-between gap-12px"> <div class="performance-template-dialog__section-title">模板列表</div>
<p class="text-16px font-600">模板列表</p>
<ElSpace wrap alignment="center">
<ElButton @click="loadTemplatePage(searchParams.pageNo ?? 1)">
<template #icon>
<icon-mdi-refresh class="text-icon" :class="{ 'animate-spin': loading }" />
</template>
刷新
</ElButton>
<TableColumnSetting v-model:columns="columnChecks" />
</ElSpace>
</div>
</template> </template>
<div class="performance-template-dialog__table"> <ElForm :model="searchParams" inline label-position="top" class="performance-template-dialog__search">
<ElTable v-loading="loading" height="100%" border :data="data"> <ElFormItem label="模板名称">
<template v-for="col in columns" :key="String(col.prop)"> <ElInput v-model="searchParams.templateName" clearable placeholder="请输入模板名称" />
<ElTableColumn v-bind="col" /> </ElFormItem>
</template> <ElFormItem label="员工姓名">
</ElTable> <ElInput v-model="searchParams.employeeName" clearable placeholder="请输入员工姓名" />
</div> </ElFormItem>
<ElFormItem label="模板年份">
<ElInputNumber
v-model="searchParams.templateYear"
:min="2000"
:max="2100"
controls-position="right"
placeholder="年份"
/>
</ElFormItem>
<ElFormItem label="上传人">
<ElInput v-model="searchParams.uploadUserName" clearable placeholder="请输入上传人" />
</ElFormItem>
<ElFormItem label="状态">
<ElSelect v-model="searchParams.activeFlag" clearable placeholder="全部">
<ElOption :value="true" label="已启用" />
<ElOption :value="false" label="未启用" />
</ElSelect>
</ElFormItem>
<ElFormItem class="performance-template-dialog__search-actions">
<ElButton type="primary" @click="handleSearch">查询</ElButton>
<ElButton @click="handleReset">重置</ElButton>
</ElFormItem>
</ElForm>
<div class="mt-16px flex justify-end"> <ElTable v-loading="loading" :data="tableData" border>
<ElTableColumn type="index" label="序号" width="64" />
<ElTableColumn prop="templateName" label="模板名称" min-width="180" show-overflow-tooltip />
<ElTableColumn prop="employeeName" label="员工" min-width="110" show-overflow-tooltip />
<ElTableColumn prop="employeeDeptName" label="部门" min-width="120" show-overflow-tooltip />
<ElTableColumn prop="templateYear" label="年份" width="90" />
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag :type="row.activeFlag ? 'success' : 'info'">{{ row.activeFlag ? '已启用' : '未启用' }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="uploadUserName" label="上传人" width="110" />
<ElTableColumn label="上传时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.uploadTime) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<ElButton
v-if="canUpdateTemplate"
link
type="primary"
:disabled="row.activeFlag || activatingId === row.id"
@click="handleActivate(row)"
>
启用
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<div class="performance-template-dialog__pagination">
<ElPagination <ElPagination
v-if="mobilePagination.total" :current-page="searchParams.pageNo || 1"
layout="total,prev,pager,next,sizes" :page-size="searchParams.pageSize || 10"
v-bind="mobilePagination" :total="total"
@current-change="mobilePagination['current-change']" layout="total, prev, pager, next, sizes"
@size-change="mobilePagination['size-change']" @current-change="loadTemplatePage"
@size-change="
size => {
searchParams.pageSize = size;
loadTemplatePage(1);
}
"
/> />
</div> </div>
</ElCard> </ElCard>
<ElEmpty v-if="!canQueryTemplate && !canUpdateTemplate" :image-size="80" description="当前账号没有绩效模板权限" /> <ElEmpty
v-if="!canQueryTemplate && !canUpdateTemplate"
:image-size="80"
description="当前账号没有绩效模板相关权限"
/>
</div> </div>
</BusinessFormDialog> </BusinessFormDialog>
</template> </template>
@@ -333,148 +504,47 @@ watch(visible, isVisible => {
gap: 16px; gap: 16px;
} }
.performance-template-dialog__upload-form { .performance-template-dialog__upload-sections {
display: grid; display: grid;
gap: 16px; gap: 16px;
} }
.performance-template-dialog__upload-grid { .performance-template-dialog__section-title {
font-size: 16px;
font-weight: 600;
}
.performance-template-dialog__form {
display: grid;
gap: 12px;
}
.performance-template-dialog__grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px; gap: 16px;
} }
.performance-template-dialog__field { .performance-template-dialog__full {
margin-bottom: 0;
}
.performance-template-dialog__field :deep(.el-form-item__label) {
display: inline-flex;
align-items: center;
min-height: 22px;
padding-bottom: 6px;
line-height: 22px;
}
.performance-template-dialog__field :deep(.el-form-item__content) {
min-height: 36px;
align-items: stretch;
width: 100%;
}
.performance-template-dialog__field--full {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.performance-template-dialog__label { .performance-template-dialog__file-line {
display: inline-flex;
align-items: center;
gap: 6px;
}
.performance-template-dialog__file-row {
display: grid; display: grid;
grid-template-columns: 112px minmax(0, 1fr) 24px; grid-template-columns: auto 1fr;
align-items: stretch;
gap: 6px;
min-height: 36px;
width: 100% !important;
min-width: 0 !important;
}
.performance-template-dialog__upload-trigger {
display: block;
}
.performance-template-dialog__upload-trigger :deep(.el-upload) {
display: block;
}
.performance-template-dialog__upload-button {
width: 100%;
height: 36px;
padding: 0 12px;
}
.performance-template-dialog__file-name-wrapper {
display: block;
width: 100%;
min-width: 0;
flex: 1 1 auto;
}
.performance-template-dialog__file-name-wrapper :deep(.el-tooltip__trigger) {
display: block;
width: 100% !important;
min-width: 0 !important;
}
.performance-template-dialog__file-name {
display: flex;
align-items: center;
min-width: 0;
width: 100% !important;
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;
}
.performance-template-dialog__file-name-text {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.performance-template-dialog__file-name--placeholder {
color: var(--el-text-color-placeholder);
}
.performance-template-dialog__hint-button {
width: 24px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--el-text-color-secondary);
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.performance-template-dialog__hint-button:hover {
background: var(--el-fill-color-light);
color: var(--el-color-primary);
}
.performance-template-dialog__switch-field {
align-self: stretch;
}
.performance-template-dialog__switch-box {
height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px; gap: 12px;
padding: 0 14px; align-items: center;
border: 1px solid var(--el-border-color); }
border-radius: 8px;
background: var(--el-fill-color-blank); .performance-template-dialog__file-text {
color: var(--el-text-color-regular); color: var(--el-text-color-secondary);
font-size: 13px; word-break: break-all;
line-height: 1; }
.performance-template-dialog__batch-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
} }
.performance-template-dialog__actions { .performance-template-dialog__actions {
@@ -482,32 +552,31 @@ watch(visible, isVisible => {
justify-content: flex-end; justify-content: flex-end;
} }
.performance-template-dialog__table { .performance-template-dialog__search {
height: 360px; margin-bottom: 8px;
} }
@media (max-width: 1080px) { .performance-template-dialog__search-actions {
.performance-template-dialog__upload-grid { align-self: end;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.performance-template-dialog__switch-field {
grid-column: 1 / -1;
}
} }
@media (max-width: 768px) { .performance-template-dialog__pagination {
.performance-template-dialog__upload-grid { display: flex;
justify-content: flex-end;
margin-top: 16px;
}
@media (max-width: 960px) {
.performance-template-dialog__grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.performance-template-dialog__field--full, .performance-template-dialog__full {
.performance-template-dialog__switch-field {
grid-column: auto; grid-column: auto;
} }
.performance-template-dialog__switch-box { .performance-template-dialog__file-line {
height: 36px; grid-template-columns: 1fr;
} }
} }
</style> </style>