feat(我的绩效): 开发我的绩效功能。
fix(加班申请、工作报告): 重构加班申请在审批时的样式,工作报告在新增时的对话框、报告详情页的样式。
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import type { FormRules } from 'element-plus';
|
||||
import { confirmPerformanceSheet, rejectPerformanceSheet } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'PerformanceActionDialog' });
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.Performance.Sheet.Sheet | null;
|
||||
actionType: 'confirm' | 'reject';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rowData: null
|
||||
});
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
submitted: [];
|
||||
}>();
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const isReject = computed(() => props.actionType === 'reject');
|
||||
const title = computed(() => (isReject.value ? '退回绩效表' : '确认绩效表'));
|
||||
const confirmText = computed(() => (isReject.value ? '确认退回' : '确认'));
|
||||
const rules = computed<FormRules>(() => ({
|
||||
reason: isReject.value ? [createRequiredRule('请输入退回原因')] : []
|
||||
}));
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.rowData?.id) return;
|
||||
|
||||
if (isReject.value) {
|
||||
try {
|
||||
await validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
const result = isReject.value
|
||||
? await rejectPerformanceSheet(props.rowData.id, { reason: form.reason.trim() })
|
||||
: await confirmPerformanceSheet(props.rowData.id, { reason: form.reason.trim() || undefined });
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) return;
|
||||
|
||||
window.$message?.success(isReject.value ? '绩效表已退回' : '绩效表已确认');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, isVisible => {
|
||||
if (!isVisible) return;
|
||||
form.reason = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
append-to-body
|
||||
:confirm-text="confirmText"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<ElDescriptions :column="1" border>
|
||||
<ElDescriptionsItem label="绩效月份">{{ props.rowData?.periodMonth || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="员工">{{ props.rowData?.employeeName || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="实际得分">{{ props.rowData?.actualScoreTotal ?? '--' }}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<ElFormItem class="mt-16px" :label="isReject ? '退回原因' : '确认意见'" prop="reason">
|
||||
<ElInput
|
||||
v-model="form.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="isReject ? '请输入退回原因' : '可填写确认意见'"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { fetchPerformanceSheetResponseRecords } from '@/service/api';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { formatDateTime, getPerformanceActionLabel } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceRecordDialog' });
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.Performance.Sheet.Sheet | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rowData: null
|
||||
});
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const loading = ref(false);
|
||||
const responseRecords = ref<Api.Performance.Sheet.ResponseRecord[]>([]);
|
||||
|
||||
async function loadData() {
|
||||
if (!props.rowData?.id) return;
|
||||
|
||||
loading.value = true;
|
||||
const { error, data } = await fetchPerformanceSheetResponseRecords(props.rowData.id);
|
||||
responseRecords.value = error || !data ? [] : data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
watch(visible, isVisible => {
|
||||
if (isVisible) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="员工反馈历史"
|
||||
preset="lg"
|
||||
append-to-body
|
||||
:show-footer="false"
|
||||
:loading="loading"
|
||||
>
|
||||
<ElTable border :data="responseRecords">
|
||||
<ElTableColumn prop="roundNo" label="轮次" width="80" />
|
||||
<ElTableColumn label="动作" width="110">
|
||||
<template #default="{ row }">{{ getPerformanceActionLabel(row.actionType) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="responderName" label="反馈人" width="120" />
|
||||
<ElTableColumn prop="opinion" label="反馈意见" min-width="240" show-overflow-tooltip />
|
||||
<ElTableColumn label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
import TableSearchFields from '@/components/custom/table-search-fields.vue';
|
||||
import { performanceStatusOptions } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceSearch' });
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
subordinateOptions?: Option[];
|
||||
deptOptions?: Option[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
subordinateOptions: () => [],
|
||||
deptOptions: () => []
|
||||
});
|
||||
|
||||
const model = defineModel<Api.Performance.Sheet.SearchParams>('model', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
}>();
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'periodMonthRange',
|
||||
label: '绩效月份',
|
||||
type: 'dateRange',
|
||||
dateRangeType: 'monthrange',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
placeholder: '选择月份区间'
|
||||
},
|
||||
{ key: 'employeeId', label: '员工', type: 'select', placeholder: '请选择员工', options: props.subordinateOptions },
|
||||
{ key: 'employeeDeptId', label: '部门', type: 'select', placeholder: '请选择部门', options: props.deptOptions },
|
||||
{ key: 'managerName', label: '直属上级', type: 'input', placeholder: '请输入直属上级' },
|
||||
{ key: 'statusCode', label: '状态', type: 'select', placeholder: '请选择状态', options: performanceStatusOptions }
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="emit('reset')" @search="emit('search')" />
|
||||
</template>
|
||||
@@ -0,0 +1,118 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getStatusTagType } from '@/constants/status-tag';
|
||||
|
||||
export interface PerformanceCreateDraft {
|
||||
periodMonth: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
}
|
||||
|
||||
export const PerformancePermission = {
|
||||
TemplateQuery: 'project:performance-template:query',
|
||||
TemplateUpdate: 'project:performance-template:update',
|
||||
SheetQuery: 'project:performance-sheet:query',
|
||||
SheetCreate: 'project:performance-sheet:create',
|
||||
SheetUpdate: 'project:performance-sheet:update',
|
||||
SheetDelete: 'project:performance-sheet:delete',
|
||||
SheetConfirm: 'project:performance-sheet:confirm',
|
||||
SheetReject: 'project:performance-sheet:reject',
|
||||
SheetExport: 'project:performance-sheet:export',
|
||||
TeamDashboard: 'project:performance-sheet:team-dashboard'
|
||||
} as const;
|
||||
|
||||
export const performanceStatusOptions: Array<{
|
||||
label: string;
|
||||
value: Api.Performance.Common.SheetStatusCode;
|
||||
}> = [
|
||||
{ label: '待发送', value: 'draft' },
|
||||
{ label: '待确认', value: 'sent' },
|
||||
{ label: '已确认', value: 'confirmed' },
|
||||
{ label: '已退回', value: 'rejected' }
|
||||
];
|
||||
|
||||
export const performanceActionNameMap: Record<string, string> = {
|
||||
send: '发送',
|
||||
resend: '重新发送',
|
||||
confirm: '确认',
|
||||
reject: '退回',
|
||||
delete: '删除'
|
||||
};
|
||||
|
||||
export function getPerformanceStatusLabel(statusCode?: string | null, statusName?: string | null) {
|
||||
return statusName || performanceStatusOptions.find(item => item.value === statusCode)?.label || statusCode || '--';
|
||||
}
|
||||
|
||||
export function resolvePerformanceStatusTagType(statusCode?: string | null) {
|
||||
return getStatusTagType('performanceSheet', statusCode);
|
||||
}
|
||||
|
||||
export function getPerformanceActionLabel(actionCode?: string | null) {
|
||||
if (!actionCode) return '--';
|
||||
|
||||
return performanceActionNameMap[actionCode] || actionCode;
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null) {
|
||||
if (!value) return '--';
|
||||
|
||||
const target = dayjs(value);
|
||||
|
||||
return target.isValid() ? target.format('YYYY-MM-DD HH:mm') : value;
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null) {
|
||||
if (!value) return '--';
|
||||
|
||||
const target = dayjs(value);
|
||||
|
||||
return target.isValid() ? target.format('YYYY-MM-DD') : value;
|
||||
}
|
||||
|
||||
export function formatScore(value?: string | number | null) {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
|
||||
const numberValue = Number(value);
|
||||
|
||||
return Number.isFinite(numberValue) ? numberValue.toFixed(2) : String(value);
|
||||
}
|
||||
|
||||
export function normalizeScoreText(value: unknown) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
|
||||
const normalized = text.replace(/,/g, '');
|
||||
const numberValue = Number(normalized);
|
||||
|
||||
return Number.isFinite(numberValue) ? numberValue.toFixed(2) : text;
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function getSheetExportName(row: Api.Performance.Sheet.Sheet) {
|
||||
return row.fileName || `${row.periodMonth}月-绩效表_${row.employeeName}.xlsx`;
|
||||
}
|
||||
|
||||
export function createDefaultPeriodMonth() {
|
||||
return dayjs().format('YYYY-MM');
|
||||
}
|
||||
|
||||
export function getDeptOrgTypeLabel(value?: string | null) {
|
||||
const map: Record<string, string> = {
|
||||
direction: '方向',
|
||||
function: '职能',
|
||||
dept: '部门',
|
||||
team: '团队',
|
||||
company: '公司'
|
||||
};
|
||||
|
||||
return map[value || ''] || value || '--';
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { remindTeamPerformance } from '@/service/api';
|
||||
import { formatDateTime, formatScore, getDeptOrgTypeLabel, getPerformanceStatusLabel } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceSummary' });
|
||||
|
||||
interface Props {
|
||||
periodMonthStart: string;
|
||||
periodMonthEnd: string;
|
||||
loading?: boolean;
|
||||
summary?: Api.Performance.Team.Summary | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
summary: null
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
reminded: [];
|
||||
}>();
|
||||
|
||||
const remindingKey = ref('');
|
||||
|
||||
const deptOrgAverageCount = computed(() => props.summary?.deptOrgAverages?.length ?? 0);
|
||||
|
||||
const cards = computed(() => [
|
||||
{ label: '本月绩效表总数', value: props.summary?.totalSheetCount ?? 0 },
|
||||
{ label: '待发送数', value: props.summary?.pendingSendCount ?? 0, key: 'pending_send' as const },
|
||||
{ label: '待确认数', value: props.summary?.pendingConfirmCount ?? 0, key: 'pending_confirm' as const },
|
||||
{ label: '已确认率', value: `${props.summary?.confirmedRate ?? '0.00'}%` },
|
||||
{ label: '各方向绩效平均分', value: deptOrgAverageCount.value, key: 'dept_org_average' as const }
|
||||
]);
|
||||
|
||||
async function handleRemind(type: Api.Performance.Common.RemindType, userIds?: string[]) {
|
||||
const key = userIds?.length === 1 ? `${type}:${userIds[0]}` : `${type}:all`;
|
||||
remindingKey.value = key;
|
||||
|
||||
const { error, data } = await remindTeamPerformance({
|
||||
periodMonthStart: props.periodMonthStart,
|
||||
periodMonthEnd: props.periodMonthEnd,
|
||||
remindType: type,
|
||||
userIds
|
||||
});
|
||||
|
||||
remindingKey.value = '';
|
||||
|
||||
if (error) return;
|
||||
|
||||
window.$message?.success(`已催办 ${data?.remindedCount ?? 0} 人`);
|
||||
emit('reminded');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="props.loading" class="performance-summary">
|
||||
<div class="performance-summary__grid">
|
||||
<div v-for="card in cards" :key="card.label" class="performance-summary__item">
|
||||
<div class="performance-summary__label">{{ card.label }}</div>
|
||||
<div class="performance-summary__value">
|
||||
<template v-if="card.key === 'pending_send'">
|
||||
<ElPopover placement="bottom" :width="360" trigger="hover">
|
||||
<template #reference>
|
||||
<button type="button" class="performance-summary__link-button">{{ card.value }}</button>
|
||||
</template>
|
||||
|
||||
<div class="performance-summary__popover">
|
||||
<div class="performance-summary__popover-title">待发送人员</div>
|
||||
<div v-if="props.summary?.pendingSendUsers?.length" class="performance-summary__user-list">
|
||||
<div
|
||||
v-for="user in props.summary.pendingSendUsers"
|
||||
:key="`${user.userId}-${user.sheetId || 'none'}`"
|
||||
class="performance-summary__user-item"
|
||||
>
|
||||
<div class="performance-summary__user-main">
|
||||
<span>{{ user.userNickname }}</span>
|
||||
<small>
|
||||
{{ getPerformanceStatusLabel(user.statusCode) }},提醒 {{ user.managerName || '直属上级' }}
|
||||
</small>
|
||||
</div>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
:loading="remindingKey === `pending_send:${user.userId}`"
|
||||
@click="handleRemind('pending_send', [user.userId])"
|
||||
>
|
||||
催办
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :image-size="60" description="暂无待发送人员" />
|
||||
|
||||
<div class="performance-summary__popover-footer">
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="remindingKey === 'pending_send:all'"
|
||||
:disabled="!props.summary?.pendingSendUsers?.length"
|
||||
@click="handleRemind('pending_send')"
|
||||
>
|
||||
一键催办全部
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<template v-else-if="card.key === 'pending_confirm'">
|
||||
<ElPopover placement="bottom" :width="340" trigger="hover">
|
||||
<template #reference>
|
||||
<button type="button" class="performance-summary__link-button">{{ card.value }}</button>
|
||||
</template>
|
||||
|
||||
<div class="performance-summary__popover">
|
||||
<div class="performance-summary__popover-title">待确认人员</div>
|
||||
<div v-if="props.summary?.pendingConfirmUsers?.length" class="performance-summary__user-list">
|
||||
<div
|
||||
v-for="user in props.summary.pendingConfirmUsers"
|
||||
:key="user.sheetId"
|
||||
class="performance-summary__user-item"
|
||||
>
|
||||
<div class="performance-summary__user-main">
|
||||
<span>{{ user.userNickname }}</span>
|
||||
<small>发送时间:{{ formatDateTime(user.sentTime) }}</small>
|
||||
</div>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
:loading="remindingKey === `pending_confirm:${user.userId}`"
|
||||
@click="handleRemind('pending_confirm', [user.userId])"
|
||||
>
|
||||
催办
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :image-size="60" description="暂无待确认人员" />
|
||||
|
||||
<div class="performance-summary__popover-footer">
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="remindingKey === 'pending_confirm:all'"
|
||||
:disabled="!props.summary?.pendingConfirmUsers?.length"
|
||||
@click="handleRemind('pending_confirm')"
|
||||
>
|
||||
一键催办全部
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<template v-else-if="card.key === 'dept_org_average'">
|
||||
<ElPopover placement="bottom" :width="360" trigger="hover">
|
||||
<template #reference>
|
||||
<button type="button" class="performance-summary__link-button">{{ card.value }}</button>
|
||||
</template>
|
||||
|
||||
<div class="performance-summary__popover">
|
||||
<div class="performance-summary__popover-title">各方向绩效平均分</div>
|
||||
<div v-if="props.summary?.deptOrgAverages?.length" class="performance-summary__user-list">
|
||||
<div
|
||||
v-for="item in props.summary.deptOrgAverages"
|
||||
:key="item.deptId"
|
||||
class="performance-summary__user-item performance-summary__user-item--score"
|
||||
>
|
||||
<div class="performance-summary__user-main">
|
||||
<span>{{ item.deptName }}</span>
|
||||
<small>{{ getDeptOrgTypeLabel(item.deptOrgType) }} / {{ item.confirmedCount }} 人</small>
|
||||
</div>
|
||||
<strong class="performance-summary__score">{{ formatScore(item.averageScore) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :image-size="60" description="暂无方向平均分" />
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
{{ card.value }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.performance-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.performance-summary__label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.performance-summary__link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.performance-summary__popover {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.performance-summary__popover-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.performance-summary__user-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.performance-summary__user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.performance-summary__user-item--score {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.performance-summary__user-main {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.performance-summary__user-main span {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.performance-summary__user-main small {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__popover-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.performance-summary__score {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (width <= 1400px) {
|
||||
.performance-summary__grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.performance-summary__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.performance-summary__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,384 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import type { UploadFile, UploadFiles } from 'element-plus';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import {
|
||||
activatePerformanceTemplate,
|
||||
fetchPerformanceTemplatePage,
|
||||
uploadFile,
|
||||
uploadPerformanceTemplate
|
||||
} from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import { formatDateTime } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceTemplateDialog' });
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: [];
|
||||
}>();
|
||||
|
||||
type TemplatePageResponse = Awaited<ReturnType<typeof fetchPerformanceTemplatePage>>;
|
||||
|
||||
const searchParams = reactive<Api.Performance.Template.SearchParams>({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
templateName: undefined,
|
||||
activeFlag: undefined
|
||||
});
|
||||
|
||||
const uploadForm = reactive({
|
||||
templateName: '',
|
||||
remark: '',
|
||||
activeFlag: true,
|
||||
file: null as File | null
|
||||
});
|
||||
|
||||
const uploading = ref(false);
|
||||
const activatingId = ref('');
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
TemplatePageResponse,
|
||||
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 {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
};
|
||||
},
|
||||
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 getTemplateActions(row: Api.Performance.Template.Template): BusinessTableAction[] {
|
||||
return [
|
||||
{
|
||||
key: 'activate',
|
||||
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 (!uploadForm.file) {
|
||||
window.$message?.warning('请选择 Excel 模板文件');
|
||||
return;
|
||||
}
|
||||
if (!uploadForm.templateName.trim()) {
|
||||
window.$message?.warning('请输入模板名称');
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
const fileResult = await uploadFile(uploadForm.file, 'performance/templates');
|
||||
if (fileResult.error || !fileResult.data) {
|
||||
uploading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await uploadPerformanceTemplate({
|
||||
templateName: uploadForm.templateName.trim(),
|
||||
fileId: fileResult.data.id,
|
||||
fileName: uploadForm.file.name,
|
||||
activeFlag: uploadForm.activeFlag,
|
||||
remark: uploadForm.remark.trim() || undefined
|
||||
});
|
||||
uploading.value = false;
|
||||
|
||||
if (result.error) return;
|
||||
|
||||
window.$message?.success('绩效模板已上传');
|
||||
Object.assign(uploadForm, {
|
||||
templateName: '',
|
||||
remark: '',
|
||||
activeFlag: true,
|
||||
file: null
|
||||
});
|
||||
await getDataByPage(1);
|
||||
emit('updated');
|
||||
}
|
||||
|
||||
async function handleActivate(row: Api.Performance.Template.Template) {
|
||||
activatingId.value = row.id;
|
||||
const { error } = await activatePerformanceTemplate(row.id);
|
||||
activatingId.value = '';
|
||||
|
||||
if (error) return;
|
||||
|
||||
window.$message?.success('绩效模板已启用');
|
||||
await getDataByPage(searchParams.pageNo ?? 1);
|
||||
emit('updated');
|
||||
}
|
||||
|
||||
watch(visible, isVisible => {
|
||||
if (isVisible) {
|
||||
getDataByPage(1);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="绩效模板"
|
||||
preset="lg"
|
||||
append-to-body
|
||||
:show-footer="false"
|
||||
max-body-height="76vh"
|
||||
>
|
||||
<div class="performance-template-dialog">
|
||||
<ElCard shadow="never">
|
||||
<ElForm :model="uploadForm" label-position="top" class="performance-template-dialog__upload-form">
|
||||
<div class="performance-template-dialog__upload-grid">
|
||||
<ElFormItem label="模板名称" class="performance-template-dialog__field">
|
||||
<ElInput v-model="uploadForm.templateName" placeholder="请输入模板名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="Excel 文件" class="performance-template-dialog__field">
|
||||
<div class="performance-template-dialog__file-picker">
|
||||
<ElUpload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
:limit="1"
|
||||
:on-change="handleFileChange"
|
||||
>
|
||||
<ElButton plain>
|
||||
<template #icon>
|
||||
<icon-mdi-upload class="text-icon" />
|
||||
</template>
|
||||
选择文件
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
<div class="performance-template-dialog__file-hint">
|
||||
{{ selectedFileName || '支持 .xlsx、.xls,选择后会在这里显示文件名' }}
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
label="上传后启用"
|
||||
class="performance-template-dialog__field performance-template-dialog__switch-field"
|
||||
>
|
||||
<div class="performance-template-dialog__switch-box">
|
||||
<span>上传后立即切换为当前模板</span>
|
||||
<ElSwitch v-model="uploadForm.activeFlag" />
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
label="备注"
|
||||
class="performance-template-dialog__field performance-template-dialog__field--full"
|
||||
>
|
||||
<ElInput v-model="uploadForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<div class="performance-template-dialog__actions">
|
||||
<ElButton type="primary" :loading="uploading" @click="handleUploadTemplate">上传模板</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<p class="text-16px font-600">模板列表</p>
|
||||
<ElSpace wrap alignment="center">
|
||||
<ElButton @click="getDataByPage()">
|
||||
<template #icon>
|
||||
<icon-mdi-refresh class="text-icon" :class="{ 'animate-spin': loading }" />
|
||||
</template>
|
||||
刷新
|
||||
</ElButton>
|
||||
<TableColumnSetting v-model:columns="columnChecks" />
|
||||
</ElSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="performance-template-dialog__table">
|
||||
<ElTable v-loading="loading" height="100%" border :data="data">
|
||||
<template v-for="col in columns" :key="String(col.prop)">
|
||||
<ElTableColumn v-bind="col" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-16px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.performance-template-dialog {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__upload-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.performance-template-dialog__field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.performance-template-dialog__file-picker {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__file-hint {
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-field {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-box {
|
||||
height: 100%;
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 14px;
|
||||
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__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.performance-template-dialog__table {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.performance-template-dialog__upload-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.performance-template-dialog__upload-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.performance-template-dialog__field--full,
|
||||
.performance-template-dialog__switch-field {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-box {
|
||||
min-height: 56px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user