feat(我的绩效): 开发我的绩效功能。
fix(加班申请、工作报告): 重构加班申请在审批时的样式,工作报告在新增时的对话框、报告详情页的样式。
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import type { FormRules } from 'element-plus';
|
||||
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('请选择绩效月份')],
|
||||
employeeId: [createRequiredRule('请选择员工')]
|
||||
}));
|
||||
|
||||
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 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)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 数据中预设缩放比例 40%,避免调用不可用的 zoom API
|
||||
const data = snapshot || {};
|
||||
if (data.sheets) {
|
||||
Object.values(data.sheets).forEach((sheet: any) => {
|
||||
if (sheet && typeof sheet === 'object') {
|
||||
sheet.zoomRatio = 0.4;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 || '';
|
||||
}
|
||||
|
||||
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();
|
||||
createWorkbook(snapshot);
|
||||
} 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;
|
||||
}
|
||||
|
||||
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();
|
||||
const snapshot = workbook.save();
|
||||
const fileName = createInitialFileName();
|
||||
const buffer = await transformUniverToExcel(snapshot, fileName);
|
||||
const file = new File([buffer], 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;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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>
|
||||
<ElFormItem label="员工" prop="employeeId" class="performance-excel-editor__form-item">
|
||||
<ElSelect v-model="createForm.employeeId" filterable placeholder="选择员工" style="width: 200px">
|
||||
<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>
|
||||
Reference in New Issue
Block a user