- 在月报审批页面增加 sign-off 场景支持 - 添加绩效下签对话框组件实现批量下签功能 - 增加导入绩效压缩包和直接下签两种操作模式 - 实现电子签名预览和日期格式化显示功能 - 更新绩效权限配置增加下签相关权限 - 优化审批对话框标题和按钮文案显示 - 添加导入结果提示和文件验证功能 - 实现团队模式下的下签操作入口 - 完善绩效表格状态和操作权限判断逻辑
910 lines
26 KiB
Vue
910 lines
26 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||
import type { FormRules } from 'element-plus';
|
||
import JSZip from 'jszip';
|
||
import '@univerjs/preset-sheets-core/lib/index.css';
|
||
import {
|
||
createPerformanceSheet,
|
||
downloadFile,
|
||
fetchPerformanceSheet,
|
||
fetchPerformanceSheetPrefill,
|
||
fetchPerformanceTemplateCurrent,
|
||
sendPerformanceSheet,
|
||
updatePerformanceSheetExcel,
|
||
uploadFile
|
||
} from '@/service/api';
|
||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||
import { createDefaultPeriodMonth, normalizeScoreText } from './performance-shared';
|
||
|
||
defineOptions({ name: 'PerformanceExcelEditorDrawer' });
|
||
|
||
interface SubordinateOption {
|
||
label: string;
|
||
value: string;
|
||
}
|
||
|
||
interface Props {
|
||
rowData?: Api.Performance.Sheet.Sheet | null;
|
||
mode: 'view' | 'edit' | 'create';
|
||
subordinateOptions?: SubordinateOption[];
|
||
showApprovalFooter?: boolean;
|
||
initialEmployeeId?: string;
|
||
initialPeriodMonth?: string;
|
||
hideDraftAction?: boolean;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
rowData: null,
|
||
subordinateOptions: () => [],
|
||
showApprovalFooter: false,
|
||
initialEmployeeId: '',
|
||
initialPeriodMonth: '',
|
||
hideDraftAction: false
|
||
});
|
||
|
||
const visible = defineModel<boolean>('visible', { default: false });
|
||
|
||
const emit = defineEmits<{
|
||
saved: [payload: PerformanceSheetSavePayload];
|
||
savedAndSent: [payload: PerformanceSheetSavePayload];
|
||
startApproval: [];
|
||
}>();
|
||
|
||
const { formRef, validate } = useForm();
|
||
const { createRequiredRule } = useFormRules();
|
||
|
||
const containerRef = ref<HTMLDivElement>();
|
||
const loading = ref(false);
|
||
const prefillLoading = ref(false);
|
||
const saving = ref(false);
|
||
const sending = ref(false);
|
||
const currentSheet = ref<Api.Performance.Sheet.Sheet | null>(null);
|
||
const currentTemplate = ref<Api.Performance.Template.Template | null>(null);
|
||
const errorMessage = ref('');
|
||
const prefillSourcePeriodMonth = ref('');
|
||
const viewportWidth = ref(typeof window === 'undefined' ? 1920 : window.innerWidth);
|
||
const loadedCreateContextKey = ref('');
|
||
const createContextInitializing = ref(false);
|
||
|
||
const createForm = reactive({
|
||
periodMonth: createDefaultPeriodMonth(),
|
||
employeeId: ''
|
||
});
|
||
|
||
interface PerformanceSheetSavePayload {
|
||
sheet: Api.Performance.Sheet.Sheet;
|
||
actualScoreTotal: string;
|
||
baseScoreTotal: string;
|
||
extraScoreTotal: string;
|
||
}
|
||
|
||
const createFormRules = computed<FormRules>(() => ({
|
||
periodMonth: [createRequiredRule('请选择绩效月份')],
|
||
employeeId: [createRequiredRule('请选择下属')]
|
||
}));
|
||
const ASSESSED_EMPLOYEE_TEXT_PATTERN = /(被考核人\s*[::]\s*)(.+)/u;
|
||
|
||
let univerInstance: any = null;
|
||
let univerAPI: any = null;
|
||
let LuckyExcelModule: any = null;
|
||
let createUniverFn: any = null;
|
||
let UniverSheetsCorePresetFn: any = null;
|
||
let univerLocales: Record<string, unknown> | null = null;
|
||
let excelRuntimeLoading: Promise<void> | null = null;
|
||
const DEFAULT_SHEET_ZOOM_RATIO = 0.4;
|
||
|
||
const isCreateMode = computed(() => props.mode === 'create');
|
||
const drawerTitle = computed(() => {
|
||
let action = '查看';
|
||
if (isCreateMode.value) action = '新增';
|
||
else if (props.mode === 'edit') action = '编辑';
|
||
const selectedEmployee = props.subordinateOptions.find(opt => opt.value === createForm.employeeId);
|
||
const name = currentSheet.value?.employeeName || props.rowData?.employeeName || selectedEmployee?.label || '';
|
||
|
||
return `${action}绩效 Excel${name ? ` - ${name}` : ''}`;
|
||
});
|
||
const canSave = computed(() => props.mode !== 'view');
|
||
const showApprovalFooter = computed(() => props.mode === 'view' && props.showApprovalFooter);
|
||
const drawerSize = computed(() => (viewportWidth.value >= 2560 ? '60%' : '88%'));
|
||
|
||
function syncViewportWidth() {
|
||
viewportWidth.value = window.innerWidth;
|
||
}
|
||
|
||
function handleClose() {
|
||
visible.value = false;
|
||
}
|
||
|
||
function handleStartApproval() {
|
||
emit('startApproval');
|
||
}
|
||
|
||
function disposeUniver() {
|
||
try {
|
||
univerInstance?.dispose?.();
|
||
} catch {
|
||
// ignore dispose errors so closing the drawer still works
|
||
}
|
||
|
||
univerInstance = null;
|
||
univerAPI = null;
|
||
|
||
if (containerRef.value) {
|
||
containerRef.value.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
async function ensureExcelRuntime() {
|
||
if (LuckyExcelModule && createUniverFn && UniverSheetsCorePresetFn && univerLocales) {
|
||
return;
|
||
}
|
||
|
||
if (!excelRuntimeLoading) {
|
||
excelRuntimeLoading = Promise.all([
|
||
import('@zwight/luckyexcel'),
|
||
import('@univerjs/presets'),
|
||
import('@univerjs/preset-sheets-core'),
|
||
import('@univerjs/preset-sheets-core/locales/zh-CN'),
|
||
import('@univerjs/preset-sheets-core/locales/en-US')
|
||
]).then(([luckyexcelModule, presetsModule, sheetsCoreModule, zhCNLocaleModule, enUSLocaleModule]) => {
|
||
LuckyExcelModule = luckyexcelModule.default || luckyexcelModule;
|
||
createUniverFn = presetsModule.createUniver;
|
||
UniverSheetsCorePresetFn = sheetsCoreModule.UniverSheetsCorePreset;
|
||
univerLocales = {
|
||
'zh-CN': zhCNLocaleModule.default || zhCNLocaleModule,
|
||
'en-US': enUSLocaleModule.default || enUSLocaleModule
|
||
};
|
||
});
|
||
}
|
||
|
||
await excelRuntimeLoading;
|
||
}
|
||
|
||
function transformExcelToUniver(file: File) {
|
||
return new Promise<any>((resolve, reject) => {
|
||
LuckyExcelModule.transformExcelToUniver(
|
||
file,
|
||
(snapshot: any) => resolve(snapshot || {}),
|
||
(error: unknown) => reject(error)
|
||
);
|
||
});
|
||
}
|
||
|
||
function transformUniverToExcel(snapshot: any, fileName: string) {
|
||
return new Promise<BlobPart>((resolve, reject) => {
|
||
LuckyExcelModule.transformUniverToExcel({
|
||
snapshot,
|
||
fileName,
|
||
getBuffer: true,
|
||
success: (buffer?: unknown) => {
|
||
if (!buffer) {
|
||
reject(new Error('Excel 导出结果为空'));
|
||
return;
|
||
}
|
||
|
||
resolve(buffer as BlobPart);
|
||
},
|
||
error: (error: Error) => reject(error)
|
||
});
|
||
});
|
||
}
|
||
|
||
async function normalizeExcelBuffer(buffer: BlobPart): Promise<ArrayBuffer> {
|
||
if (buffer instanceof ArrayBuffer) {
|
||
return buffer;
|
||
}
|
||
|
||
if (ArrayBuffer.isView(buffer)) {
|
||
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength).slice().buffer;
|
||
}
|
||
|
||
if (buffer instanceof Blob) {
|
||
return buffer.arrayBuffer();
|
||
}
|
||
|
||
throw new Error('Excel 导出结果格式不受支持');
|
||
}
|
||
|
||
function applySheetZoomRatio(snapshot: any, zoomRatio = DEFAULT_SHEET_ZOOM_RATIO) {
|
||
const data = snapshot || {};
|
||
|
||
if (data.sheets && typeof data.sheets === 'object') {
|
||
Object.values(data.sheets).forEach((sheet: any) => {
|
||
if (sheet && typeof sheet === 'object') {
|
||
sheet.zoomRatio = zoomRatio;
|
||
}
|
||
});
|
||
}
|
||
|
||
return data;
|
||
}
|
||
|
||
function replaceAssessedEmployeeText(value: string, employeeName: string) {
|
||
if (!ASSESSED_EMPLOYEE_TEXT_PATTERN.test(value)) {
|
||
return value;
|
||
}
|
||
|
||
return value.replace(ASSESSED_EMPLOYEE_TEXT_PATTERN, `$1${employeeName}`);
|
||
}
|
||
|
||
function injectAssessedEmployeeName(snapshot: any, employeeName: string) {
|
||
if (!snapshot || !employeeName) return snapshot;
|
||
|
||
const visited = new WeakSet<object>();
|
||
|
||
const walk = (target: unknown) => {
|
||
if (typeof target === 'string') {
|
||
return replaceAssessedEmployeeText(target, employeeName);
|
||
}
|
||
|
||
if (!target || typeof target !== 'object') {
|
||
return target;
|
||
}
|
||
|
||
if (visited.has(target as object)) {
|
||
return target;
|
||
}
|
||
|
||
visited.add(target as object);
|
||
|
||
if (Array.isArray(target)) {
|
||
target.forEach((item, index) => {
|
||
target[index] = walk(item);
|
||
});
|
||
|
||
return target;
|
||
}
|
||
|
||
Object.entries(target).forEach(([key, value]) => {
|
||
(target as Record<string, unknown>)[key] = walk(value);
|
||
});
|
||
|
||
return target;
|
||
};
|
||
|
||
return walk(snapshot);
|
||
}
|
||
|
||
function findFirstElementByLocalName(parent: Element | Document, localName: string) {
|
||
return Array.from(parent.childNodes).find(
|
||
node => node.nodeType === Node.ELEMENT_NODE && (node as Element).localName === localName
|
||
) as Element | undefined;
|
||
}
|
||
|
||
function ensureChildElement(document: XMLDocument, parent: Element, localName: string) {
|
||
const existing = Array.from(parent.childNodes).find(
|
||
node => node.nodeType === Node.ELEMENT_NODE && (node as Element).localName === localName
|
||
) as Element | undefined;
|
||
|
||
if (existing) return existing;
|
||
|
||
const namespace = parent.namespaceURI || document.documentElement?.namespaceURI || null;
|
||
const element = namespace ? document.createElementNS(namespace, localName) : document.createElement(localName);
|
||
parent.appendChild(element);
|
||
|
||
return element;
|
||
}
|
||
|
||
function createXmlRoot(document: XMLDocument, localName: string, namespace?: string | null) {
|
||
const root = namespace ? document.createElementNS(namespace, localName) : document.createElement(localName);
|
||
document.appendChild(root);
|
||
|
||
return root;
|
||
}
|
||
|
||
function applyWorksheetZoomXml(xmlText: string, zoomScale: number) {
|
||
const document = new DOMParser().parseFromString(xmlText, 'application/xml');
|
||
const root =
|
||
document.documentElement && document.documentElement.localName !== 'parsererror'
|
||
? document.documentElement
|
||
: createXmlRoot(document, 'worksheet', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main');
|
||
const sheetViews = ensureChildElement(document, root, 'sheetViews');
|
||
const sheetView =
|
||
findFirstElementByLocalName(sheetViews, 'sheetView') || ensureChildElement(document, sheetViews, 'sheetView');
|
||
|
||
sheetView.setAttribute('workbookViewId', sheetView.getAttribute('workbookViewId') || '0');
|
||
sheetView.setAttribute('zoomScale', String(zoomScale));
|
||
sheetView.setAttribute('zoomScaleNormal', String(zoomScale));
|
||
sheetView.setAttribute('zoomScalePageLayoutView', String(zoomScale));
|
||
|
||
return new XMLSerializer().serializeToString(document);
|
||
}
|
||
|
||
function applyWorkbookZoomXml(xmlText: string, zoomScale: number) {
|
||
const document = new DOMParser().parseFromString(xmlText, 'application/xml');
|
||
const root =
|
||
document.documentElement && document.documentElement.localName !== 'parsererror'
|
||
? document.documentElement
|
||
: createXmlRoot(document, 'workbook', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main');
|
||
const bookViews = ensureChildElement(document, root, 'bookViews');
|
||
const workbookView =
|
||
findFirstElementByLocalName(bookViews, 'workbookView') || ensureChildElement(document, bookViews, 'workbookView');
|
||
|
||
workbookView.setAttribute('zoomScale', String(zoomScale));
|
||
workbookView.setAttribute('zoomScaleNormal', String(zoomScale));
|
||
|
||
return new XMLSerializer().serializeToString(document);
|
||
}
|
||
|
||
async function applyExcelZoomMetadata(buffer: BlobPart, zoomRatio = DEFAULT_SHEET_ZOOM_RATIO) {
|
||
const zoomScale = Math.max(10, Math.min(400, Math.round(zoomRatio * 100)));
|
||
const zip = await JSZip.loadAsync(await normalizeExcelBuffer(buffer));
|
||
const worksheetPaths = Object.keys(zip.files).filter(path => /^xl\/worksheets\/sheet\d+\.xml$/u.test(path));
|
||
|
||
await Promise.all(
|
||
worksheetPaths.map(async path => {
|
||
const entry = zip.file(path);
|
||
if (!entry) return;
|
||
|
||
const xmlText = await entry.async('string');
|
||
zip.file(path, applyWorksheetZoomXml(xmlText, zoomScale));
|
||
})
|
||
);
|
||
|
||
const workbookEntry = zip.file('xl/workbook.xml');
|
||
if (workbookEntry) {
|
||
const workbookXml = await workbookEntry.async('string');
|
||
zip.file('xl/workbook.xml', applyWorkbookZoomXml(workbookXml, zoomScale));
|
||
}
|
||
|
||
return zip.generateAsync({ type: 'arraybuffer' });
|
||
}
|
||
|
||
function createWorkbook(snapshot: any) {
|
||
if (!containerRef.value) return;
|
||
|
||
disposeUniver();
|
||
const { univer, univerAPI: api } = createUniverFn({
|
||
locale: 'zh-CN',
|
||
locales: univerLocales || undefined,
|
||
presets: [
|
||
UniverSheetsCorePresetFn({
|
||
container: containerRef.value,
|
||
header: true,
|
||
toolbar: props.mode !== 'view',
|
||
formulaBar: props.mode !== 'view'
|
||
})
|
||
]
|
||
});
|
||
|
||
univerInstance = univer;
|
||
univerAPI = api;
|
||
|
||
const unitType = api?.Enum?.UniverInstanceType?.UNIVER_SHEET;
|
||
if (!unitType) {
|
||
throw new Error('Univer 工作簿初始化失败');
|
||
}
|
||
|
||
// 在 snapshot 数据中预设缩放比例,保证在线查看和导出文件使用同一套缩放值
|
||
const data = applySheetZoomRatio(snapshot);
|
||
|
||
univer.createUnit(unitType, data);
|
||
}
|
||
|
||
function getActiveWorkbook() {
|
||
return univerAPI?.getActiveWorkbook?.();
|
||
}
|
||
|
||
function getCreateContextKey() {
|
||
return `${createForm.periodMonth || ''}::${createForm.employeeId || ''}`;
|
||
}
|
||
|
||
function parseCellAddress(address?: string | null) {
|
||
const text = String(address || '')
|
||
.trim()
|
||
.toUpperCase();
|
||
const matched = text.match(/^([A-Z]+)(\d+)$/u);
|
||
if (!matched) return null;
|
||
|
||
const [, letters, rowText] = matched;
|
||
let column = 0;
|
||
for (const letter of letters) {
|
||
column = column * 26 + letter.charCodeAt(0) - 64;
|
||
}
|
||
|
||
return {
|
||
row: Number(rowText) - 1,
|
||
column: column - 1
|
||
};
|
||
}
|
||
|
||
function readCell(address?: string | null) {
|
||
const position = parseCellAddress(address);
|
||
const workbook = getActiveWorkbook();
|
||
const sheet = workbook?.getActiveSheet?.();
|
||
if (!position || !sheet) return '';
|
||
|
||
try {
|
||
const range = sheet.getRange(position.row, position.column);
|
||
const value = range?.getValue?.();
|
||
|
||
return normalizeScoreText(value);
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function writeCell(address: string, value: string) {
|
||
const position = parseCellAddress(address);
|
||
const workbook = getActiveWorkbook();
|
||
const sheet = workbook?.getActiveSheet?.();
|
||
if (!position || !sheet) return false;
|
||
|
||
try {
|
||
const range = sheet.getRange(position.row, position.column);
|
||
range?.setValue?.(value);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function applyPrefillValues(cellValues: Record<string, string>) {
|
||
Object.entries(cellValues).forEach(([address, value]) => {
|
||
if (!address) return;
|
||
|
||
writeCell(address, value);
|
||
});
|
||
}
|
||
|
||
function readScores() {
|
||
const mapping = currentTemplate.value?.scoreCellMapping;
|
||
|
||
return {
|
||
actualScoreTotal: readCell(mapping?.actualScoreTotalCell),
|
||
baseScoreTotal: readCell(mapping?.baseScoreTotalCell),
|
||
extraScoreTotal: readCell(mapping?.extraScoreTotalCell)
|
||
};
|
||
}
|
||
|
||
function getCreateEmployeeName() {
|
||
return props.subordinateOptions.find(opt => opt.value === createForm.employeeId)?.label || '';
|
||
}
|
||
|
||
function applyCreateEmployeeName(snapshot: any) {
|
||
if (!isCreateMode.value) return snapshot;
|
||
|
||
const employeeName = getCreateEmployeeName();
|
||
if (!employeeName) return snapshot;
|
||
|
||
return injectAssessedEmployeeName(snapshot, employeeName);
|
||
}
|
||
|
||
function createInitialFileName() {
|
||
const sheet = currentSheet.value;
|
||
if (sheet) {
|
||
return sheet.fileName || `${sheet.periodMonth}-绩效表_${sheet.employeeName}.xlsx`;
|
||
}
|
||
|
||
if (isCreateMode.value && createForm.periodMonth && createForm.employeeId) {
|
||
return `${createForm.periodMonth}-绩效表_${getCreateEmployeeName()}.xlsx`;
|
||
}
|
||
|
||
return '绩效表.xlsx';
|
||
}
|
||
|
||
async function applyCreatePrefill() {
|
||
prefillSourcePeriodMonth.value = '';
|
||
|
||
if (!isCreateMode.value || !createForm.employeeId || !createForm.periodMonth) {
|
||
return;
|
||
}
|
||
|
||
prefillLoading.value = true;
|
||
|
||
try {
|
||
const { error, data } = await fetchPerformanceSheetPrefill(createForm.employeeId, createForm.periodMonth);
|
||
if (error || !data) {
|
||
return;
|
||
}
|
||
|
||
prefillSourcePeriodMonth.value = data.sourcePeriodMonth ?? '';
|
||
|
||
if (Object.keys(data.cellValues).length) {
|
||
applyPrefillValues(data.cellValues);
|
||
}
|
||
} finally {
|
||
prefillLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadWorkbook() {
|
||
loading.value = true;
|
||
prefillLoading.value = false;
|
||
errorMessage.value = '';
|
||
prefillSourcePeriodMonth.value = '';
|
||
currentSheet.value = null;
|
||
currentTemplate.value = null;
|
||
|
||
try {
|
||
let sourceFileId = '';
|
||
|
||
if (isCreateMode.value) {
|
||
const templateResult = await fetchPerformanceTemplateCurrent();
|
||
if (templateResult.error || !templateResult.data) {
|
||
errorMessage.value = '当前没有可用的绩效模板';
|
||
return;
|
||
}
|
||
|
||
currentTemplate.value = templateResult.data;
|
||
sourceFileId = templateResult.data.fileId;
|
||
} else {
|
||
if (!props.rowData?.id) return;
|
||
|
||
const [sheetResult, templateResult] = await Promise.all([
|
||
fetchPerformanceSheet(props.rowData.id),
|
||
fetchPerformanceTemplateCurrent()
|
||
]);
|
||
|
||
if (sheetResult.error || !sheetResult.data) {
|
||
errorMessage.value = '绩效表详情加载失败';
|
||
return;
|
||
}
|
||
|
||
currentSheet.value = sheetResult.data;
|
||
currentTemplate.value = templateResult.error ? null : templateResult.data;
|
||
sourceFileId = currentSheet.value.fileId || currentTemplate.value?.fileId || '';
|
||
}
|
||
|
||
if (!sourceFileId) {
|
||
errorMessage.value = '当前绩效表没有可用的 Excel 文件';
|
||
return;
|
||
}
|
||
|
||
const fileResult = await downloadFile(sourceFileId);
|
||
if (fileResult.error || !fileResult.data) {
|
||
errorMessage.value = 'Excel 文件下载失败';
|
||
return;
|
||
}
|
||
|
||
await ensureExcelRuntime();
|
||
const file = new File([fileResult.data], createInitialFileName(), {
|
||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
});
|
||
const snapshot = await transformExcelToUniver(file);
|
||
await nextTick();
|
||
createWorkbook(applyCreateEmployeeName(snapshot));
|
||
|
||
if (isCreateMode.value) {
|
||
await nextTick();
|
||
await applyCreatePrefill();
|
||
loadedCreateContextKey.value = getCreateContextKey();
|
||
} else {
|
||
loadedCreateContextKey.value = '';
|
||
}
|
||
} catch (error) {
|
||
errorMessage.value = error instanceof Error ? error.message : 'Excel 解析失败';
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function ensureCreatedSheet() {
|
||
if (currentSheet.value) {
|
||
return currentSheet.value;
|
||
}
|
||
|
||
if (!createForm.periodMonth || !createForm.employeeId) {
|
||
throw new Error('请先填写绩效月份和下属');
|
||
}
|
||
|
||
const createResult = await createPerformanceSheet({
|
||
periodMonth: createForm.periodMonth,
|
||
employeeId: createForm.employeeId
|
||
});
|
||
|
||
if (createResult.error || !createResult.data) {
|
||
throw new Error('创建绩效记录失败');
|
||
}
|
||
|
||
const sheetResult = await fetchPerformanceSheet(createResult.data);
|
||
if (sheetResult.error || !sheetResult.data) {
|
||
throw new Error('绩效记录已创建,但加载详情失败');
|
||
}
|
||
|
||
currentSheet.value = sheetResult.data;
|
||
return sheetResult.data;
|
||
}
|
||
|
||
function createInitialPeriodMonth() {
|
||
return props.initialPeriodMonth || createDefaultPeriodMonth();
|
||
}
|
||
|
||
function initializeCreateForm() {
|
||
createForm.periodMonth = createInitialPeriodMonth();
|
||
createForm.employeeId = props.initialEmployeeId || '';
|
||
prefillSourcePeriodMonth.value = '';
|
||
}
|
||
|
||
async function executeSave(): Promise<PerformanceSheetSavePayload | null> {
|
||
const workbook = getActiveWorkbook();
|
||
if (!workbook) return null;
|
||
|
||
const scores = readScores();
|
||
if (!scores.actualScoreTotal || !scores.baseScoreTotal || !scores.extraScoreTotal) {
|
||
window.$message?.warning('未能读取完整的三种得分总计,请检查模板单元格映射配置');
|
||
return null;
|
||
}
|
||
|
||
// create 模式先校验表单
|
||
if (isCreateMode.value) {
|
||
try {
|
||
await validate();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
await ensureExcelRuntime();
|
||
const sheet = await ensureCreatedSheet();
|
||
const snapshot = applySheetZoomRatio(workbook.save());
|
||
const fileName = createInitialFileName();
|
||
const buffer = await transformUniverToExcel(snapshot, fileName);
|
||
const excelBuffer = await applyExcelZoomMetadata(buffer);
|
||
const file = new File([excelBuffer], fileName, {
|
||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
});
|
||
const uploadResult = await uploadFile(file, `performance/sheets/${sheet.periodMonth}`);
|
||
|
||
if (uploadResult.error || !uploadResult.data) {
|
||
return null;
|
||
}
|
||
|
||
const updateResult = await updatePerformanceSheetExcel(sheet.id, {
|
||
fileId: uploadResult.data.id,
|
||
fileName,
|
||
fileVersion: sheet.fileVersion,
|
||
actualScoreTotal: scores.actualScoreTotal,
|
||
baseScoreTotal: scores.baseScoreTotal,
|
||
extraScoreTotal: scores.extraScoreTotal
|
||
});
|
||
|
||
if (updateResult.error) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
sheet,
|
||
actualScoreTotal: scores.actualScoreTotal,
|
||
baseScoreTotal: scores.baseScoreTotal,
|
||
extraScoreTotal: scores.extraScoreTotal
|
||
};
|
||
}
|
||
|
||
async function handleSaveDraft() {
|
||
saving.value = true;
|
||
try {
|
||
const saveResult = await executeSave();
|
||
if (!saveResult) return;
|
||
|
||
window.$message?.success(isCreateMode.value ? '绩效表已保存为草稿' : '绩效 Excel 已保存');
|
||
visible.value = false;
|
||
emit('saved', saveResult);
|
||
} catch (error) {
|
||
window.$message?.error(error instanceof Error ? error.message : '绩效 Excel 保存失败');
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
async function handleSaveAndSend() {
|
||
sending.value = true;
|
||
try {
|
||
const saveResult = await executeSave();
|
||
if (!saveResult) return;
|
||
|
||
const sendResult = await sendPerformanceSheet(saveResult.sheet.id);
|
||
if (sendResult.error) return;
|
||
|
||
window.$message?.success('绩效表已保存并发送');
|
||
visible.value = false;
|
||
emit('savedAndSent', saveResult);
|
||
} catch (error) {
|
||
window.$message?.error(error instanceof Error ? error.message : '绩效表发送失败');
|
||
} finally {
|
||
sending.value = false;
|
||
}
|
||
}
|
||
|
||
watch(visible, async isVisible => {
|
||
if (!isVisible) {
|
||
disposeUniver();
|
||
currentSheet.value = null;
|
||
currentTemplate.value = null;
|
||
loadedCreateContextKey.value = '';
|
||
createContextInitializing.value = false;
|
||
prefillLoading.value = false;
|
||
prefillSourcePeriodMonth.value = '';
|
||
initializeCreateForm();
|
||
return;
|
||
}
|
||
|
||
createContextInitializing.value = isCreateMode.value;
|
||
|
||
try {
|
||
if (isCreateMode.value) {
|
||
initializeCreateForm();
|
||
}
|
||
|
||
await nextTick();
|
||
await loadWorkbook();
|
||
} finally {
|
||
createContextInitializing.value = false;
|
||
}
|
||
});
|
||
|
||
watch(
|
||
() => [createForm.employeeId, createForm.periodMonth] as const,
|
||
async ([employeeId, periodMonth], [previousEmployeeId, previousPeriodMonth]) => {
|
||
if (!visible.value || !isCreateMode.value || createContextInitializing.value) {
|
||
return;
|
||
}
|
||
|
||
const currentKey = `${periodMonth || ''}::${employeeId || ''}`;
|
||
const previousKey = `${previousPeriodMonth || ''}::${previousEmployeeId || ''}`;
|
||
if (
|
||
currentKey === previousKey ||
|
||
currentKey === loadedCreateContextKey.value ||
|
||
loading.value ||
|
||
prefillLoading.value
|
||
) {
|
||
return;
|
||
}
|
||
|
||
await nextTick();
|
||
await loadWorkbook();
|
||
}
|
||
);
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', syncViewportWidth);
|
||
disposeUniver();
|
||
});
|
||
|
||
onMounted(() => {
|
||
syncViewportWidth();
|
||
window.addEventListener('resize', syncViewportWidth);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<ElDrawer
|
||
v-model="visible"
|
||
class="performance-excel-editor-drawer"
|
||
:title="drawerTitle"
|
||
body-class="performance-excel-editor-drawer__body"
|
||
:size="drawerSize"
|
||
:close-on-click-modal="false"
|
||
append-to-body
|
||
>
|
||
<!-- 创建模式下的表单区域 -->
|
||
<div v-if="isCreateMode" class="performance-excel-editor__create-form">
|
||
<ElForm ref="formRef" :model="createForm" :rules="createFormRules" label-position="top" inline>
|
||
<ElFormItem label="绩效月份" prop="periodMonth" class="performance-excel-editor__form-item">
|
||
<ElDatePicker
|
||
v-model="createForm.periodMonth"
|
||
type="month"
|
||
value-format="YYYY-MM"
|
||
placeholder="选择绩效月份"
|
||
:disabled="loading || prefillLoading || saving || sending"
|
||
/>
|
||
</ElFormItem>
|
||
<ElFormItem label="下属" prop="employeeId" class="performance-excel-editor__form-item">
|
||
<ElSelect
|
||
v-model="createForm.employeeId"
|
||
filterable
|
||
placeholder="选择下属"
|
||
style="width: 200px"
|
||
:disabled="loading || prefillLoading || saving || sending"
|
||
>
|
||
<ElOption v-for="opt in props.subordinateOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||
</ElSelect>
|
||
</ElFormItem>
|
||
</ElForm>
|
||
<div v-if="prefillLoading || prefillSourcePeriodMonth" class="performance-excel-editor__prefill-tip">
|
||
{{ prefillLoading ? '正在自动带出上一份绩效数据...' : `已自动带出 ${prefillSourcePeriodMonth} 的绩效数据` }}
|
||
</div>
|
||
</div>
|
||
|
||
<div v-loading="loading" class="performance-excel-editor">
|
||
<ElAlert
|
||
v-if="errorMessage"
|
||
class="performance-excel-editor__alert"
|
||
type="error"
|
||
:title="errorMessage"
|
||
show-icon
|
||
:closable="false"
|
||
/>
|
||
|
||
<div ref="containerRef" class="performance-excel-editor__container" />
|
||
</div>
|
||
|
||
<template v-if="canSave || showApprovalFooter" #footer>
|
||
<div class="performance-excel-editor__footer">
|
||
<template v-if="canSave">
|
||
<ElButton
|
||
v-if="!props.hideDraftAction"
|
||
:loading="saving"
|
||
:disabled="sending || loading || prefillLoading"
|
||
@click="handleSaveDraft"
|
||
>
|
||
保存草稿
|
||
</ElButton>
|
||
<ElButton
|
||
type="primary"
|
||
:loading="sending"
|
||
:disabled="saving || loading || prefillLoading"
|
||
@click="handleSaveAndSend"
|
||
>
|
||
发送绩效
|
||
</ElButton>
|
||
</template>
|
||
<template v-else-if="showApprovalFooter">
|
||
<ElButton @click="handleClose">退出</ElButton>
|
||
<ElButton type="primary" @click="handleStartApproval">确认</ElButton>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</ElDrawer>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
:global(.performance-excel-editor-drawer__body) {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
padding: 20px 24px 0;
|
||
}
|
||
|
||
.performance-excel-editor__create-form {
|
||
flex-shrink: 0;
|
||
margin-bottom: 16px;
|
||
padding: 16px 20px;
|
||
border: 1px solid var(--el-border-color-light);
|
||
border-radius: 8px;
|
||
background: var(--el-fill-color-blank);
|
||
}
|
||
|
||
.performance-excel-editor__form-item {
|
||
margin-bottom: 0;
|
||
margin-right: 24px;
|
||
}
|
||
|
||
.performance-excel-editor__prefill-tip {
|
||
margin-top: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.performance-excel-editor__footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 12px;
|
||
padding: 12px 24px 20px;
|
||
border-top: 1px solid var(--el-border-color-lighter);
|
||
background: var(--el-bg-color);
|
||
}
|
||
|
||
.performance-excel-editor {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
height: 100%;
|
||
}
|
||
|
||
.performance-excel-editor__alert {
|
||
margin-bottom: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.performance-excel-editor__container {
|
||
flex: 1;
|
||
min-height: 0;
|
||
border: 1px solid var(--el-border-color-light);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
background: var(--el-bg-color);
|
||
}
|
||
</style>
|