2026-06-21 18:22:44 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
|
|
|
|
|
import type { FormRules } from 'element-plus';
|
2026-06-22 23:07:21 +08:00
|
|
|
|
import JSZip from 'jszip';
|
2026-06-21 18:22:44 +08:00
|
|
|
|
import '@univerjs/preset-sheets-core/lib/index.css';
|
|
|
|
|
|
import {
|
|
|
|
|
|
createPerformanceSheet,
|
|
|
|
|
|
downloadFile,
|
|
|
|
|
|
fetchPerformanceSheet,
|
|
|
|
|
|
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[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
|
rowData: null,
|
|
|
|
|
|
subordinateOptions: () => []
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const visible = defineModel<boolean>('visible', { default: false });
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
saved: [];
|
|
|
|
|
|
savedAndSent: [];
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
const { formRef, validate } = useForm();
|
|
|
|
|
|
const { createRequiredRule } = useFormRules();
|
|
|
|
|
|
|
|
|
|
|
|
const containerRef = ref<HTMLDivElement>();
|
|
|
|
|
|
const loading = 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 viewportWidth = ref(typeof window === 'undefined' ? 1920 : window.innerWidth);
|
|
|
|
|
|
|
|
|
|
|
|
const createForm = reactive({
|
|
|
|
|
|
periodMonth: createDefaultPeriodMonth(),
|
|
|
|
|
|
employeeId: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const createFormRules = computed<FormRules>(() => ({
|
|
|
|
|
|
periodMonth: [createRequiredRule('请选择绩效月份')],
|
2026-06-22 23:07:21 +08:00
|
|
|
|
employeeId: [createRequiredRule('请选择下属')]
|
2026-06-21 18:22:44 +08:00
|
|
|
|
}));
|
2026-06-22 23:07:21 +08:00
|
|
|
|
const ASSESSED_EMPLOYEE_TEXT_PATTERN = /(被考核人\s*[::]\s*)(.+)/u;
|
2026-06-21 18:22:44 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-22 23:07:21 +08:00
|
|
|
|
const DEFAULT_SHEET_ZOOM_RATIO = 0.4;
|
2026-06-21 18:22:44 +08:00
|
|
|
|
|
|
|
|
|
|
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 drawerSize = computed(() => (viewportWidth.value >= 2560 ? '60%' : '88%'));
|
|
|
|
|
|
|
|
|
|
|
|
function syncViewportWidth() {
|
|
|
|
|
|
viewportWidth.value = window.innerWidth;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleClose() {
|
|
|
|
|
|
visible.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 23:07:21 +08:00
|
|
|
|
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' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 18:22:44 +08:00
|
|
|
|
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 工作簿初始化失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 23:07:21 +08:00
|
|
|
|
// 在 snapshot 数据中预设缩放比例,保证在线查看和导出文件使用同一套缩放值
|
|
|
|
|
|
const data = applySheetZoomRatio(snapshot);
|
2026-06-21 18:22:44 +08:00
|
|
|
|
|
|
|
|
|
|
univer.createUnit(unitType, data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getActiveWorkbook() {
|
|
|
|
|
|
return univerAPI?.getActiveWorkbook?.();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 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 || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 23:07:21 +08:00
|
|
|
|
function applyCreateEmployeeName(snapshot: any) {
|
|
|
|
|
|
if (!isCreateMode.value) return snapshot;
|
|
|
|
|
|
|
|
|
|
|
|
const employeeName = getCreateEmployeeName();
|
|
|
|
|
|
if (!employeeName) return snapshot;
|
|
|
|
|
|
|
|
|
|
|
|
return injectAssessedEmployeeName(snapshot, employeeName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 18:22:44 +08:00
|
|
|
|
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 loadWorkbook() {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
errorMessage.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();
|
2026-06-22 23:07:21 +08:00
|
|
|
|
createWorkbook(applyCreateEmployeeName(snapshot));
|
2026-06-21 18:22:44 +08:00
|
|
|
|
} 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) {
|
2026-06-22 23:07:21 +08:00
|
|
|
|
throw new Error('请先填写绩效月份和下属');
|
2026-06-21 18:22:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function executeSave(): Promise<Api.Performance.Sheet.Sheet | 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();
|
2026-06-22 23:07:21 +08:00
|
|
|
|
const snapshot = applySheetZoomRatio(workbook.save());
|
2026-06-21 18:22:44 +08:00
|
|
|
|
const fileName = createInitialFileName();
|
|
|
|
|
|
const buffer = await transformUniverToExcel(snapshot, fileName);
|
2026-06-22 23:07:21 +08:00
|
|
|
|
const excelBuffer = await applyExcelZoomMetadata(buffer);
|
|
|
|
|
|
const file = new File([excelBuffer], fileName, {
|
2026-06-21 18:22:44 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSaveDraft() {
|
|
|
|
|
|
saving.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const sheet = await executeSave();
|
|
|
|
|
|
if (!sheet) return;
|
|
|
|
|
|
|
|
|
|
|
|
window.$message?.success(isCreateMode.value ? '绩效表已保存为草稿' : '绩效 Excel 已保存');
|
|
|
|
|
|
visible.value = false;
|
|
|
|
|
|
emit('saved');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
window.$message?.error(error instanceof Error ? error.message : '绩效 Excel 保存失败');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSaveAndSend() {
|
|
|
|
|
|
sending.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const sheet = await executeSave();
|
|
|
|
|
|
if (!sheet) return;
|
|
|
|
|
|
|
|
|
|
|
|
const sendResult = await sendPerformanceSheet(sheet.id);
|
|
|
|
|
|
if (sendResult.error) return;
|
|
|
|
|
|
|
|
|
|
|
|
window.$message?.success('绩效表已保存并发送');
|
|
|
|
|
|
visible.value = false;
|
|
|
|
|
|
emit('savedAndSent');
|
|
|
|
|
|
} 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;
|
|
|
|
|
|
// 重置创建表单
|
|
|
|
|
|
createForm.periodMonth = createDefaultPeriodMonth();
|
|
|
|
|
|
createForm.employeeId = '';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await nextTick();
|
|
|
|
|
|
await loadWorkbook();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-22 23:07:21 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
() => createForm.employeeId,
|
|
|
|
|
|
async (employeeId, previousEmployeeId) => {
|
|
|
|
|
|
if (!visible.value || !isCreateMode.value || !employeeId || employeeId === previousEmployeeId) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const workbook = getActiveWorkbook();
|
|
|
|
|
|
if (!workbook) return;
|
|
|
|
|
|
|
|
|
|
|
|
const snapshot = workbook.save();
|
|
|
|
|
|
await nextTick();
|
|
|
|
|
|
createWorkbook(applyCreateEmployeeName(snapshot));
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-21 18:22:44 +08:00
|
|
|
|
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="选择绩效月份"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</ElFormItem>
|
2026-06-22 23:07:21 +08:00
|
|
|
|
<ElFormItem label="下属" prop="employeeId" class="performance-excel-editor__form-item">
|
|
|
|
|
|
<ElSelect v-model="createForm.employeeId" filterable placeholder="选择下属" style="width: 200px">
|
2026-06-21 18:22:44 +08:00
|
|
|
|
<ElOption v-for="opt in props.subordinateOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
|
|
|
|
|
</ElSelect>
|
|
|
|
|
|
</ElFormItem>
|
|
|
|
|
|
</ElForm>
|
|
|
|
|
|
</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" #footer>
|
|
|
|
|
|
<div class="performance-excel-editor__footer">
|
|
|
|
|
|
<ElButton :loading="saving" :disabled="sending" @click="handleSaveDraft">保存草稿</ElButton>
|
|
|
|
|
|
<ElButton type="primary" :loading="sending" :disabled="saving" @click="handleSaveAndSend">发送绩效</ElButton>
|
|
|
|
|
|
</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__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>
|