feat(我的绩效): 开发我的绩效功能。

fix(加班申请、工作报告): 重构加班申请在审批时的样式,工作报告在新增时的对话框、报告详情页的样式。
This commit is contained in:
dk
2026-06-21 18:22:44 +08:00
parent cd64cf42cc
commit 9a5845708d
35 changed files with 4211 additions and 924 deletions

View File

@@ -1,3 +1,761 @@
<script setup lang="tsx">
import { computed, markRaw, onMounted, reactive, ref, watch } from 'vue';
import { ElButton, ElMessageBox, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import {
batchDownloadPerformanceSheets,
deletePerformanceSheet,
downloadPerformanceSheet,
exportPerformanceSheets,
fetchGetDeptSimpleList,
fetchGetMySubordinateTree,
fetchPerformanceSheetPage,
fetchTeamPerformanceSummary,
formatToYYYYMM,
resendPerformanceSheet,
sendPerformanceSheet
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import SubordinateSelector from '@/components/custom/subordinate-selector.vue';
import TeamContextPanel from '@/components/custom/team-context-panel.vue';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import {
type TeamViewContext,
type TeamViewMode,
collectSubordinateUserIds,
findSubordinateNode
} from '../shared/team-dashboard';
import PerformanceActionDialog from './modules/performance-action-dialog.vue';
import PerformanceExcelEditorDrawer from './modules/performance-excel-editor-drawer.vue';
import PerformanceRecordDialog from './modules/performance-record-dialog.vue';
import PerformanceSearch from './modules/performance-search.vue';
import PerformanceSummary from './modules/performance-summary.vue';
import PerformanceTemplateDialog from './modules/performance-template-dialog.vue';
import {
PerformancePermission,
createDefaultPeriodMonth,
downloadBlob,
formatDateTime,
formatScore,
getPerformanceStatusLabel,
getSheetExportName,
resolvePerformanceStatusTagType
} from './modules/performance-shared';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiFileDocumentEditOutline from '~icons/mdi/file-document-edit-outline';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiSendOutline from '~icons/mdi/send-outline';
defineOptions({ name: 'MyPerformance' });
type PerformanceSheetPageResponse = Awaited<ReturnType<typeof fetchPerformanceSheetPage>>;
function createSearchParams(): Api.Performance.Sheet.SearchParams {
return {
pageNo: 1,
pageSize: 10,
employeeIds: undefined,
periodMonthRange: undefined,
employeeId: undefined,
employeeName: undefined,
employeeDeptId: undefined,
employeeDeptName: undefined,
managerId: undefined,
managerName: undefined,
statusCode: undefined
};
}
function transformPageResult(response: PerformanceSheetPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: 1,
pageSize,
total: 0
};
}
const { hasAuth } = useAuth();
const searchParams = reactive(createSearchParams());
const teamViewMode = ref<TeamViewMode>('self');
const subordinateTreeLoading = ref(false);
const subordinateTree = ref<Api.SystemManage.MySubordinateTreeNode | null>(null);
const selectedSubordinateUserId = ref<string | null>(null);
const teamSummaryLoading = ref(false);
const teamSummary = ref<Api.Performance.Team.Summary | null>(null);
const selectedRows = ref<Api.Performance.Sheet.Sheet[]>([]);
const currentRow = ref<Api.Performance.Sheet.Sheet | null>(null);
const deptOptions = ref<Array<{ label: string; value: string }>>([]);
const templateVisible = ref(false);
const excelVisible = ref(false);
const excelMode = ref<'view' | 'edit' | 'create'>('view');
const actionVisible = ref(false);
const actionType = ref<'confirm' | 'reject'>('confirm');
const recordVisible = ref(false);
const exporting = ref(false);
const rowActionLoadingKey = ref('');
const ACTION_ICON_MAP = {
view: markRaw(IconMdiEyeOutline),
edit: markRaw(IconMdiPencilOutline),
send: markRaw(IconMdiSendOutline),
confirm: markRaw(IconMdiCheckCircleOutline),
reject: markRaw(IconMdiCloseCircleOutline),
export: markRaw(IconMdiDownloadOutline),
delete: markRaw(IconMdiDeleteOutline),
response: markRaw(IconMdiFileDocumentEditOutline)
};
const canUseTeamDashboard = computed(() => hasAuth(PerformancePermission.TeamDashboard));
const canCreate = computed(() => hasAuth(PerformancePermission.SheetCreate));
const canUpdate = computed(() => hasAuth(PerformancePermission.SheetUpdate));
const canDelete = computed(() => hasAuth(PerformancePermission.SheetDelete));
const canConfirm = computed(() => hasAuth(PerformancePermission.SheetConfirm));
const canReject = computed(() => hasAuth(PerformancePermission.SheetReject));
const canExport = computed(() => hasAuth(PerformancePermission.SheetExport));
const canManageTemplate = computed(
() => hasAuth(PerformancePermission.TemplateQuery) || hasAuth(PerformancePermission.TemplateUpdate)
);
const isTeamMode = computed(() => teamViewMode.value === 'team');
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
const subordinateOptions = computed(() => {
const options: Array<{ label: string; value: string }> = [];
const walk = (nodes?: Api.SystemManage.MySubordinateTreeNode[] | null) => {
nodes?.forEach(node => {
options.push({ label: node.userNickname, value: node.userId });
walk(node.children ?? null);
});
};
walk(subordinateTree.value?.children ?? null);
return options;
});
const selectedSubordinateNode = computed(() =>
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.value)
);
const formattedPeriodMonthStart = computed(
() => formatToYYYYMM(searchParams.periodMonthRange?.[0]) || createDefaultPeriodMonth()
);
const formattedPeriodMonthEnd = computed(
() => formatToYYYYMM(searchParams.periodMonthRange?.[1]) || createDefaultPeriodMonth()
);
const isRootSelected = computed(() => Boolean(isTeamMode.value && selectedSubordinateNode.value?.isRoot));
const selectedTeamLabel = computed(() => {
if (!isTeamMode.value) return '我自己';
if (!selectedSubordinateNode.value) return '--';
return selectedSubordinateNode.value.isRoot ? '全部下属' : selectedSubordinateNode.value.userNickname;
});
const teamContext = computed<TeamViewContext | null>(() => {
if (!canUseTeamDashboard.value) return null;
return {
mode: teamViewMode.value,
selectedUserId: selectedSubordinateUserId.value,
selectedUserIds:
isTeamMode.value && selectedSubordinateUserId.value && !isRootSelected.value
? [selectedSubordinateUserId.value]
: [],
isRootSelected: isRootSelected.value,
allSubordinateUserIds: allSubordinateUserIds.value,
selectedLabel: selectedTeamLabel.value
};
});
const currentEmployeeIds = computed(() => {
if (!isTeamMode.value) return undefined;
if (isRootSelected.value) return [];
return teamContext.value?.selectedUserIds ?? [];
});
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
PerformanceSheetPageResponse,
Api.Performance.Sheet.Sheet
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () =>
fetchPerformanceSheetPage({
...searchParams,
employeeIds: currentEmployeeIds.value
}),
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'selection', type: 'selection', width: 48 },
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'periodMonth', label: '绩效月份', minWidth: 110 },
{ prop: 'employeeName', label: '员工', minWidth: 110, showOverflowTooltip: true },
{ prop: 'employeeDeptName', label: '部门', minWidth: 110, showOverflowTooltip: true },
{ prop: 'managerName', label: '直属上级', minWidth: 110, showOverflowTooltip: true },
{
prop: 'statusCode',
label: '状态',
minWidth: 100,
align: 'center',
formatter: row => (
<ElTag type={resolvePerformanceStatusTagType(row.statusCode)}>
{getPerformanceStatusLabel(row.statusCode, row.statusName)}
</ElTag>
)
},
{
prop: 'actualScoreTotal',
label: '实际得分',
minWidth: 100,
formatter: row => formatScore(row.actualScoreTotal)
},
// {
// prop: 'baseScoreTotal',
// label: '基础得分',
// width: 100,
// align: 'right',
// formatter: row => formatScore(row.baseScoreTotal)
// },
// {
// prop: 'extraScoreTotal',
// label: '附加得分',
// width: 100,
// align: 'right',
// formatter: row => formatScore(row.extraScoreTotal)
// },
{
prop: 'sentTime',
label: '发送时间',
minWidth: 150,
formatter: row => formatDateTime(row.sentTime)
},
{
prop: 'confirmedTime',
label: '确认时间',
minWidth: 150,
formatter: row => formatDateTime(row.confirmedTime)
},
// {
// prop: 'updateTime',
// label: '更新时间',
// width: 150,
// formatter: row => formatDateTime(row.updateTime)
// },
{
prop: 'operate',
label: '操作',
width: 190,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
]
});
const totalCount = computed(() => mobilePagination.value.total || data.value.length);
function getRowActions(row: Api.Performance.Sheet.Sheet): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'view',
label: '查看',
buttonType: 'primary',
icon: ACTION_ICON_MAP.view,
onClick: () => openExcel(row, 'view')
}
];
if (canExport.value && row.fileId) {
actions.push({
key: 'export',
label: '导出',
buttonType: 'info',
icon: ACTION_ICON_MAP.export,
disabled: rowActionLoadingKey.value === `export:${row.id}`,
onClick: () => handleDownload(row)
});
}
if (isTeamMode.value) {
if (canUpdate.value && ['draft', 'rejected'].includes(row.statusCode)) {
actions.push({
key: 'edit',
label: '编辑',
buttonType: 'primary',
icon: ACTION_ICON_MAP.edit,
onClick: () => openExcel(row, 'edit')
});
actions.push({
key: row.statusCode === 'rejected' ? 'resend' : 'send',
label: row.statusCode === 'rejected' ? '重新发送' : '发送',
buttonType: 'success',
icon: ACTION_ICON_MAP.send,
disabled: rowActionLoadingKey.value === `send:${row.id}`,
onClick: () => handleSend(row)
});
}
if (canDelete.value && row.statusCode === 'draft') {
actions.push({
key: 'delete',
label: '删除',
buttonType: 'danger',
icon: ACTION_ICON_MAP.delete,
disabled: rowActionLoadingKey.value === `delete:${row.id}`,
onClick: () => handleDelete(row)
});
}
} else if (row.statusCode === 'sent') {
if (canConfirm.value) {
actions.push({
key: 'confirm',
label: '确认',
buttonType: 'success',
icon: ACTION_ICON_MAP.confirm,
onClick: () => openAction(row, 'confirm')
});
}
if (canReject.value) {
actions.push({
key: 'reject',
label: '退回',
buttonType: 'danger',
icon: ACTION_ICON_MAP.reject,
onClick: () => openAction(row, 'reject')
});
}
}
actions.push({
key: 'response-record',
label: '反馈历史',
buttonType: 'info',
icon: ACTION_ICON_MAP.response,
onClick: () => openRecord(row)
});
return actions;
}
function resetSearchParams() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, createSearchParams(), { pageSize });
reloadTable(1);
loadTeamSummary();
}
function handleSearch() {
reloadTable(1);
loadTeamSummary();
}
async function reloadTable(page = searchParams.pageNo ?? 1) {
await getDataByPage(page);
}
function createExportParams(): Api.Performance.Sheet.SearchParams {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return {
...params,
employeeIds: currentEmployeeIds.value ?? undefined
};
}
function handleSelectionChange(rows: Api.Performance.Sheet.Sheet[]) {
selectedRows.value = rows;
}
function openExcel(row: Api.Performance.Sheet.Sheet, mode: 'view' | 'edit') {
currentRow.value = row;
excelMode.value = mode;
excelVisible.value = true;
}
function openCreateExcel() {
currentRow.value = null;
excelMode.value = 'create';
excelVisible.value = true;
}
function openAction(row: Api.Performance.Sheet.Sheet, type: 'confirm' | 'reject') {
currentRow.value = row;
actionType.value = type;
actionVisible.value = true;
}
function openRecord(row: Api.Performance.Sheet.Sheet) {
currentRow.value = row;
recordVisible.value = true;
}
async function handleDownload(row: Api.Performance.Sheet.Sheet) {
rowActionLoadingKey.value = `export:${row.id}`;
const { error, data: blob } = await downloadPerformanceSheet(row.id);
rowActionLoadingKey.value = '';
if (error || !blob) return;
downloadBlob(blob, getSheetExportName(row));
}
async function handleExportSelected() {
if (!selectedRows.value.length) {
window.$message?.warning('请选择要导出的绩效表');
return;
}
exporting.value = true;
const { error, data: blob } = await batchDownloadPerformanceSheets({
ids: selectedRows.value.map(item => item.id)
});
exporting.value = false;
if (error || !blob) return;
downloadBlob(blob, `绩效表_导出选中_${dayjs().format('YYYY-MM-DD')}.zip`);
}
async function handleExportAll() {
exporting.value = true;
const { error, data: blob } = await exportPerformanceSheets(createExportParams());
exporting.value = false;
if (error || !blob) return;
downloadBlob(blob, `绩效表_导出全部_${dayjs().format('YYYY-MM-DD')}.zip`);
}
async function handleExportCommand(command: 'selected' | 'all') {
if (command === 'selected') {
await handleExportSelected();
return;
}
await handleExportAll();
}
async function handleSend(row: Api.Performance.Sheet.Sheet) {
if (!row.fileId) {
window.$message?.warning('请先保存绩效 Excel 后再发送');
return;
}
const actionText = row.statusCode === 'rejected' ? '重新发送' : '发送';
try {
await ElMessageBox.confirm(`确认${actionText}${row.employeeName}的绩效表吗?`, `${actionText}确认`, {
type: 'warning',
confirmButtonText: `确认${actionText}`,
cancelButtonText: '取消'
});
} catch {
return;
}
rowActionLoadingKey.value = `send:${row.id}`;
const result =
row.statusCode === 'rejected' ? await resendPerformanceSheet(row.id) : await sendPerformanceSheet(row.id);
rowActionLoadingKey.value = '';
if (result.error) return;
window.$message?.success(`绩效表已${actionText}`);
await reloadAfterMutation();
}
async function handleDelete(row: Api.Performance.Sheet.Sheet) {
try {
await ElMessageBox.confirm(`确认删除${row.employeeName}${row.periodMonth} 绩效表吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消'
});
} catch {
return;
}
rowActionLoadingKey.value = `delete:${row.id}`;
const { error } = await deletePerformanceSheet(row.id);
rowActionLoadingKey.value = '';
if (error) return;
window.$message?.success('绩效表已删除');
await reloadAfterMutation();
}
async function reloadAfterMutation() {
await reloadTable(searchParams.pageNo ?? 1);
await loadTeamSummary();
}
async function loadSubordinateTree() {
if (!canUseTeamDashboard.value) return;
subordinateTreeLoading.value = true;
const { error, data: treeData } = await fetchGetMySubordinateTree();
subordinateTreeLoading.value = false;
subordinateTree.value = error || !treeData ? null : treeData;
selectedSubordinateUserId.value = treeData?.userId || null;
}
async function loadTeamSummary() {
if (!isRootSelected.value) {
teamSummary.value = null;
return;
}
teamSummaryLoading.value = true;
const { error, data: summaryData } = await fetchTeamPerformanceSummary({
periodMonthStart: formattedPeriodMonthStart.value,
periodMonthEnd: formattedPeriodMonthEnd.value
});
teamSummaryLoading.value = false;
teamSummary.value = error || !summaryData ? null : summaryData;
}
async function loadDeptOptions() {
const { error, data: deptList } = await fetchGetDeptSimpleList();
if (error || !deptList) {
deptOptions.value = [];
return;
}
const options: Array<{ label: string; value: string }> = [];
const walk = (nodes: Api.SystemManage.DeptSimple[]) => {
nodes.forEach(node => {
options.push({ label: node.name, value: String(node.id) });
if (node.children) walk(node.children);
});
};
walk(deptList);
deptOptions.value = options;
}
async function handleTeamViewModeChange(mode: TeamViewMode) {
teamViewMode.value = mode;
if (mode === 'team') {
if (!subordinateTree.value) {
await loadSubordinateTree();
}
if (!selectedSubordinateUserId.value) {
selectedSubordinateUserId.value = subordinateTree.value?.userId || null;
}
}
await reloadTable(1);
await loadTeamSummary();
}
watch(
() => [teamViewMode.value, selectedSubordinateUserId.value],
async () => {
await reloadTable(1);
await loadTeamSummary();
}
);
watch(excelVisible, isVisible => {
if (isVisible) return;
currentRow.value = null;
});
onMounted(async () => {
await loadDeptOptions();
if (canUseTeamDashboard.value) {
await loadSubordinateTree();
}
});
</script>
<template>
<LookForward title="我的绩效" subtitle="功能建设中,敬请期待" />
<div class="my-performance-page">
<TeamContextPanel
v-if="canUseTeamDashboard"
v-model:mode="teamViewMode"
:loading="subordinateTreeLoading"
:selected-label="selectedTeamLabel"
:subordinate-count="subordinateTree?.subordinateCount || 0"
@update:mode="handleTeamViewModeChange"
>
<PerformanceSummary
v-if="isRootSelected"
:period-month-start="formattedPeriodMonthStart"
:period-month-end="formattedPeriodMonthEnd"
:loading="teamSummaryLoading"
:summary="teamSummary"
@reminded="loadTeamSummary"
/>
</TeamContextPanel>
<div class="my-performance-page__content" :class="{ 'my-performance-page__content--team': isTeamMode }">
<div v-if="canUseTeamDashboard && isTeamMode" class="my-performance-page__sidebar">
<SubordinateSelector
v-model:selected-user-id="selectedSubordinateUserId"
:loading="subordinateTreeLoading"
:data="subordinateTree"
/>
</div>
<div class="my-performance-page__main">
<PerformanceSearch
v-model:model="searchParams"
:subordinate-options="subordinateOptions"
:dept-options="deptOptions"
@reset="resetSearchParams"
@search="handleSearch"
/>
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p class="text-16px font-600">{{ isTeamMode ? '团队绩效' : '我的绩效' }}</p>
<ElTag effect="plain">{{ totalCount }}</ElTag>
</div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
<template #default>
<ElDropdown v-if="canExport" trigger="click" @command="handleExportCommand">
<ElButton plain :loading="exporting">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="selected" :disabled="exporting || !selectedRows.length">
导出选中
</ElDropdownItem>
<ElDropdownItem command="all" :disabled="exporting">导出全部</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton v-if="canManageTemplate" plain @click="templateVisible = true">
<template #icon>
<icon-mdi-file-cog-outline class="text-icon" />
</template>
模板
</ElButton>
<ElButton v-if="isTeamMode && canCreate" plain type="primary" @click="openCreateExcel">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable
v-loading="loading"
height="100%"
border
row-key="id"
:data="data"
@selection-change="handleSelectionChange"
>
<template v-for="col in columns" :key="String(col.prop)">
<ElTableColumn v-bind="col" />
</template>
</ElTable>
</div>
<div class="mt-20px 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>
</div>
<PerformanceTemplateDialog v-model:visible="templateVisible" @updated="reloadAfterMutation" />
<PerformanceExcelEditorDrawer
v-model:visible="excelVisible"
:row-data="currentRow"
:mode="excelMode"
:subordinate-options="subordinateOptions"
@saved="reloadAfterMutation"
@saved-and-sent="reloadAfterMutation"
/>
<PerformanceActionDialog
v-model:visible="actionVisible"
:row-data="currentRow"
:action-type="actionType"
@submitted="reloadAfterMutation"
/>
<PerformanceRecordDialog v-model:visible="recordVisible" :row-data="currentRow" />
</div>
</template>
<style scoped lang="scss">
.my-performance-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow: hidden;
}
.my-performance-page__content {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.my-performance-page__main {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
}
@media (min-width: 1280px) {
.my-performance-page__content--team {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
}
.my-performance-page__sidebar {
min-height: 0;
}
}
</style>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 || '--';
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -179,7 +179,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
</ElTag>
)
},
{ prop: 'approverName', label: '审人', minWidth: 80, showOverflowTooltip: true },
{ prop: 'approverName', label: '审人', minWidth: 80, showOverflowTooltip: true },
{
prop: 'submitTime',
label: '提交时间',
@@ -188,7 +188,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
},
{
prop: 'approvalTime',
label: '审时间',
label: '审时间',
minWidth: 150,
formatter: row => formatOvertimeDateTime(row.approvalTime)
},

View File

@@ -1,119 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'OvertimeApplicationActionDialog' });
type ActionType = 'approve' | 'reject';
interface Props {
actionType: ActionType;
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false
});
const emit = defineEmits<{
submit: [reason: string | null];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive({
reason: ''
});
const title = computed(() => {
const map: Record<ActionType, string> = {
approve: '通过加班申请',
reject: '退回加班申请'
};
return map[props.actionType];
});
const reasonLabel = computed(() => {
const map: Record<ActionType, string> = {
approve: '审核意见',
reject: '退回原因'
};
return map[props.actionType];
});
const reasonRequired = computed(() => props.actionType === 'reject');
const reasonPlaceholder = computed(() => {
if (reasonRequired.value) {
return `请输入${reasonLabel.value}`;
}
return '可填写审核意见';
});
const rules = computed(() => ({
reason: reasonRequired.value
? [
createRequiredRule(`请输入${reasonLabel.value}`),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error(`请输入${reasonLabel.value}`));
return;
}
callback();
},
trigger: 'blur'
}
]
: []
}));
async function handleSubmit() {
await validate();
emit('submit', model.reason.trim() || null);
}
watch(
() => visible.value,
async value => {
if (value) {
model.reason = '';
await nextTick();
formRef.value?.clearValidate();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="sm"
:confirm-loading="props.loading"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElFormItem :label="reasonLabel" prop="reason">
<ElInput
v-model="model.reason"
type="textarea"
:rows="5"
maxlength="1000"
show-word-limit
:placeholder="reasonPlaceholder"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiChevronLeft from '~icons/mdi/chevron-left';
import IconMdiChevronRight from '~icons/mdi/chevron-right';
defineOptions({ name: 'OvertimeApplicationBatchDetailDialog' });
type ActionType = 'approve' | 'reject';
interface Props {
/** 选中的加班申请 id 列表(原始 id */
selectedIds: string[];
@@ -23,8 +24,7 @@ const props = withDefaults(defineProps<Props>(), {
});
const emit = defineEmits<{
approve: [];
reject: [];
submit: [payload: { actionType: ActionType; reason: string | null }];
}>();
const visible = defineModel<boolean>('visible', {
@@ -34,6 +34,13 @@ const visible = defineModel<boolean>('visible', {
const currentIndex = ref(0);
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const detailLoading = ref(false);
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const approvalModel = reactive({
conclusion: 'approve' as ActionType,
opinion: ''
});
const currentId = computed(() => props.selectedIds[currentIndex.value] ?? null);
@@ -74,12 +81,64 @@ function goNext() {
loadDetail();
}
const opinionLabel = computed(() => (approvalModel.conclusion === 'reject' ? '退回原因' : '审批意见'));
const opinionRequired = computed(() => approvalModel.conclusion === 'reject');
const opinionPlaceholder = computed(() => (opinionRequired.value ? `请输入${opinionLabel.value}` : '可填写审批意见'));
const rules = computed(() => ({
opinion: opinionRequired.value
? [
createRequiredRule(`请输入${opinionLabel.value}`),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error(`请输入${opinionLabel.value}`));
return;
}
callback();
},
trigger: 'blur'
}
]
: []
}));
function resetApprovalForm() {
approvalModel.conclusion = 'approve';
approvalModel.opinion = '';
nextTick(() => {
formRef.value?.clearValidate();
});
}
watch(opinionRequired, async () => {
if (!visible.value) return;
await nextTick();
formRef.value?.clearValidate('opinion');
});
async function handleSubmit() {
try {
await validate();
} catch {
return;
}
emit('submit', {
actionType: approvalModel.conclusion,
reason: approvalModel.opinion.trim() || null
});
}
watch(
() => visible.value,
value => {
if (value) {
currentIndex.value = 0;
loadDetail();
resetApprovalForm();
} else {
detailData.value = null;
}
@@ -122,27 +181,75 @@ watch(
</ElDescriptions>
<ElEmpty v-else description="未获取到加班申请详情" />
<div class="batch-detail__approval-form">
<div class="audit-field">
<label>审批结论</label>
<div class="audit-conclusion">
<button
type="button"
class="conclusion-btn"
:class="{
active: approvalModel.conclusion === 'approve',
pass: approvalModel.conclusion === 'approve'
}"
@click="approvalModel.conclusion = 'approve'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path
d="M5 8.5L7 10.5L11 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
通过
</button>
<button
type="button"
class="conclusion-btn"
:class="{
active: approvalModel.conclusion === 'reject',
reject: approvalModel.conclusion === 'reject'
}"
@click="approvalModel.conclusion = 'reject'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
退回
</button>
</div>
</div>
<ElForm ref="formRef" :model="approvalModel" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElFormItem :label="opinionLabel" prop="opinion">
<ElInput
v-model="approvalModel.opinion"
type="textarea"
:rows="5"
maxlength="1000"
show-word-limit
:placeholder="opinionPlaceholder"
/>
</ElFormItem>
</ElForm>
</div>
<template #footer>
<div class="batch-detail__footer">
<span class="batch-detail__footer-hint">将对全部 {{ total }} 项统一执行操作</span>
<div class="batch-detail__footer-actions">
<ElButton @click="visible = false">取消</ElButton>
<ElButton
class="batch-detail__approve-btn"
type="success"
type="primary"
:loading="props.actionLoading"
:disabled="props.actionLoading || !detailData"
@click="emit('approve')"
@click="handleSubmit"
>
<template #icon>
<IconMdiCheckCircleOutline />
</template>
通过
</ElButton>
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
<template #icon>
<IconMdiCloseCircleOutline />
</template>
退回
确认提交
</ElButton>
</div>
</div>
@@ -208,13 +315,61 @@ watch(
gap: 12px;
}
.batch-detail__approve-btn {
--el-button-bg-color: #0f766e;
--el-button-border-color: #0f766e;
--el-button-hover-bg-color: #115e59;
--el-button-hover-border-color: #115e59;
--el-button-active-bg-color: #134e4a;
--el-button-active-border-color: #134e4a;
.batch-detail__approval-form {
display: grid;
gap: 18px;
margin-top: 16px;
}
.audit-field {
display: grid;
gap: 8px;
}
.audit-field label {
color: #475467;
font-size: 13px;
font-weight: 800;
}
.audit-conclusion {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.conclusion-btn {
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #d8e0e8;
border-radius: 8px;
background: #fff;
color: #475467;
font: inherit;
font-size: 14px;
font-weight: 800;
cursor: pointer;
transition: all 0.18s ease;
}
.conclusion-btn:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.conclusion-btn.active.pass {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.conclusion-btn.active.reject {
border-color: #dc2626;
background: #fef2f2;
color: #dc2626;
}
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
defineOptions({ name: 'OvertimeApplicationDetailDialog' });
type ActionType = 'approve' | 'reject';
interface Props {
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
showApprovalActions?: boolean;
@@ -20,8 +21,7 @@ const props = withDefaults(defineProps<Props>(), {
});
const emit = defineEmits<{
approve: [];
reject: [];
submit: [payload: { actionType: ActionType; reason: string | null }];
}>();
const visible = defineModel<boolean>('visible', {
@@ -30,6 +30,36 @@ const visible = defineModel<boolean>('visible', {
const loading = ref(false);
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const approvalModel = reactive({
conclusion: 'approve' as ActionType,
opinion: ''
});
const opinionLabel = computed(() => (approvalModel.conclusion === 'reject' ? '退回原因' : '审批意见'));
const opinionRequired = computed(() => approvalModel.conclusion === 'reject');
const opinionPlaceholder = computed(() => (opinionRequired.value ? `请输入${opinionLabel.value}` : '可填写审批意见'));
const rules = computed(() => ({
opinion: opinionRequired.value
? [
createRequiredRule(`请输入${opinionLabel.value}`),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error(`请输入${opinionLabel.value}`));
return;
}
callback();
},
trigger: 'blur'
}
]
: []
}));
async function loadDetail() {
if (!props.rowData?.id) {
@@ -44,24 +74,47 @@ async function loadDetail() {
detailData.value = error || !data ? props.rowData : data;
}
function resetApprovalForm() {
approvalModel.conclusion = 'approve';
approvalModel.opinion = '';
nextTick(() => {
formRef.value?.clearValidate();
});
}
watch(opinionRequired, async () => {
if (!visible.value || !props.showApprovalActions) return;
await nextTick();
formRef.value?.clearValidate('opinion');
});
async function handleSubmit() {
try {
await validate();
} catch {
return;
}
emit('submit', {
actionType: approvalModel.conclusion,
reason: approvalModel.opinion.trim() || null
});
}
watch(
() => visible.value,
value => {
if (value) {
loadDetail();
resetApprovalForm();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="加班申请详情"
preset="md"
:loading="loading"
:show-footer="props.showApprovalActions"
>
<BusinessFormDialog v-model="visible" title="加班申请详情" preset="md" :loading="loading">
<ElDescriptions v-if="detailData" class="overtime-application-detail-dialog__descriptions" :column="2" border>
<ElDescriptionsItem label="申请人" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.applicantName }}
@@ -84,25 +137,74 @@ watch(
</ElDescriptions>
<ElEmpty v-else description="未获取到加班申请详情" />
<div v-if="props.showApprovalActions" class="overtime-application-detail-dialog__approval-form">
<div class="audit-field">
<label>审批结论</label>
<div class="audit-conclusion">
<button
type="button"
class="conclusion-btn"
:class="{
active: approvalModel.conclusion === 'approve',
pass: approvalModel.conclusion === 'approve'
}"
@click="approvalModel.conclusion = 'approve'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path
d="M5 8.5L7 10.5L11 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
通过
</button>
<button
type="button"
class="conclusion-btn"
:class="{
active: approvalModel.conclusion === 'reject',
reject: approvalModel.conclusion === 'reject'
}"
@click="approvalModel.conclusion = 'reject'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
退回
</button>
</div>
</div>
<ElForm ref="formRef" :model="approvalModel" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElFormItem :label="opinionLabel" prop="opinion">
<ElInput
v-model="approvalModel.opinion"
type="textarea"
:rows="5"
maxlength="1000"
show-word-limit
:placeholder="opinionPlaceholder"
/>
</ElFormItem>
</ElForm>
</div>
<template #footer>
<div class="overtime-application-detail-dialog__footer">
<ElButton @click="visible = false">取消</ElButton>
<ElButton
class="overtime-application-detail-dialog__approve-btn"
type="success"
v-if="props.showApprovalActions"
type="primary"
:loading="props.actionLoading"
:disabled="props.actionLoading || !detailData"
@click="emit('approve')"
@click="handleSubmit"
>
<template #icon>
<IconMdiCheckCircleOutline />
</template>
通过
</ElButton>
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
<template #icon>
<IconMdiCloseCircleOutline />
</template>
退回
确认提交
</ElButton>
</div>
</template>
@@ -116,13 +218,61 @@ watch(
gap: 12px;
}
.overtime-application-detail-dialog__approve-btn {
--el-button-bg-color: #0f766e;
--el-button-border-color: #0f766e;
--el-button-hover-bg-color: #115e59;
--el-button-hover-border-color: #115e59;
--el-button-active-bg-color: #134e4a;
--el-button-active-border-color: #134e4a;
.overtime-application-detail-dialog__approval-form {
display: grid;
gap: 18px;
margin-top: 16px;
}
.audit-field {
display: grid;
gap: 8px;
}
.audit-field label {
color: #475467;
font-size: 13px;
font-weight: 800;
}
.audit-conclusion {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.conclusion-btn {
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #d8e0e8;
border-radius: 8px;
background: #fff;
color: #475467;
font: inherit;
font-size: 14px;
font-weight: 800;
cursor: pointer;
transition: all 0.18s ease;
}
.conclusion-btn:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.conclusion-btn.active.pass {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.conclusion-btn.active.reject {
border-color: #dc2626;
background: #fef2f2;
color: #dc2626;
}
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {

View File

@@ -80,7 +80,7 @@ const rules = computed(
trigger: 'blur'
}
],
approverId: [createRequiredRule('请选择审人')]
approverId: [createRequiredRule('请选择审人')]
}) satisfies Record<keyof Api.OvertimeApplication.SaveOvertimeApplicationParams, App.Global.FormRule[]>
);
@@ -189,7 +189,7 @@ watch(
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="审人" prop="approverId">
<ElFormItem label="审人" prop="approverId">
<ElInput
class="overtime-application-operate-dialog__readonly-input"
:model-value="approverName"

View File

@@ -95,9 +95,9 @@ const fields = computed<SearchField[]>(() => [
},
{
key: 'approverName',
label: '审人',
label: '审人',
type: 'input',
placeholder: '请输入审人'
placeholder: '请输入审人'
}
]);

View File

@@ -123,12 +123,8 @@ const table = useUIPaginatedTable<
]
});
const summaryPeriod = computed(() =>
resolveWorkReportSummaryPeriod('monthly', {
currentRow: table.data.value[0],
periodRange: searchParams.periodStartDate
})
);
// 团队统计始终使用当前周期(本月),不跟随列表第一条数据的周期
const summaryPeriod = computed(() => resolveWorkReportSummaryPeriod('monthly'));
function getRowActions(row: Api.WorkReport.Monthly.MonthlyReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [

View File

@@ -801,7 +801,7 @@ watch(
<div class="form-actions approval-form-actions">
<ElButton @click="emit('back')">退出审批</ElButton>
<ElButton type="primary" class="btn-submit" @click="openAuditDialog">开始审批</ElButton>
<ElButton type="primary" @click="openAuditDialog">开始审批</ElButton>
</div>
<BusinessFormDialog
@@ -915,8 +915,8 @@ watch(
place-items: center;
flex-shrink: 0;
border-radius: 16px;
background: #ccfbf1;
color: #0f766e;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
font-weight: 900;
}
@@ -1009,12 +1009,12 @@ watch(
}
.radio-group-full :deep(.el-radio.is-checked) {
border-color: #0f766e;
background: #f0fdfa;
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.radio-group-full :deep(.el-radio__input.is-checked + .el-radio__label) {
color: #0f766e;
color: var(--el-color-primary);
}
.review-grid,
@@ -1123,7 +1123,7 @@ watch(
display: grid;
place-items: center;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
color: #fff;
font-size: 13px;
font-weight: 900;
@@ -1292,7 +1292,7 @@ watch(
position: relative;
min-width: 0;
padding-left: 14px;
color: #0f766e;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 800;
white-space: normal;
@@ -1308,10 +1308,10 @@ watch(
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
}
.rich-editor :deep(.rich-section-tasks) {
.rich-editor :deep(.rich-section-task) {
display: grid;
gap: 6px;
padding-left: 14px;
@@ -1324,7 +1324,7 @@ watch(
}
.rich-editor :deep(.rich-category-line) {
color: #0f766e;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 700;
line-height: 1.6;
@@ -1454,7 +1454,7 @@ watch(
.structured-section-title {
position: relative;
padding-left: 14px;
color: #0f766e;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 800;
}
@@ -1467,7 +1467,7 @@ watch(
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
}
.structured-section-tasks {
@@ -1551,14 +1551,14 @@ watch(
}
.conclusion-btn:hover {
border-color: #0f766e;
color: #0f766e;
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.conclusion-btn.active.pass {
border-color: #0f766e;
background: #f0fdfa;
color: #0f766e;
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.conclusion-btn.active.reject {
@@ -1689,9 +1689,9 @@ watch(
}
.feedback-tag.success {
background: #ecfdf5;
color: #059669;
border: 1px solid #d1fae5;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
border: 1px solid var(--el-color-primary-light-7);
}
.feedback-tag.warning {
@@ -1838,16 +1838,6 @@ watch(
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
}
.btn-submit {
background: #0f766e !important;
border-color: #0f766e !important;
}
.btn-submit:hover {
background: #0d9488 !important;
border-color: #0d9488 !important;
}
@media (max-width: 1180px) {
.form-head,
.compose-grid,

View File

@@ -1077,7 +1077,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
<ElButton
size="small"
type="primary"
class="btn-submit"
:disabled="
!planForm.workItem.trim() ||
!planForm.sections.some(section => section.category.trim() && section.tasks.length)
@@ -1094,7 +1093,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
<div v-if="!isReadonly" class="form-actions">
<!-- <ElButton>重置表单</ElButton>-->
<ElButton @click="emit('save')">保存草稿</ElButton>
<ElButton type="primary" class="btn-submit" @click="emit('submit')">提交审批</ElButton>
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
</div>
<BusinessFormDialog
@@ -1327,8 +1326,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
place-items: center;
flex-shrink: 0;
border-radius: 16px;
background: #ccfbf1;
color: #0f766e;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
font-weight: 900;
}
@@ -1421,12 +1420,12 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.radio-group-full :deep(.el-radio.is-checked) {
border-color: #0f766e;
background: #f0fdfa;
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.radio-group-full :deep(.el-radio__input.is-checked + .el-radio__label) {
color: #0f766e;
color: var(--el-color-primary);
}
.review-grid,
@@ -1546,7 +1545,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
display: grid;
place-items: center;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
color: #fff;
font-size: 13px;
font-weight: 900;
@@ -1744,8 +1743,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.rich-editor:focus {
border-color: #0f766e;
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.1);
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px var(--el-color-primary-light-8);
}
.rich-editor:empty::before {
@@ -1769,7 +1768,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
position: relative;
min-width: 0;
padding-left: 14px;
color: #0f766e;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 800;
white-space: normal;
@@ -1785,10 +1784,10 @@ function syncRichSupport(item: PlanItem, event: Event) {
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
}
.rich-editor :deep(.rich-section-tasks) {
.rich-editor :deep(.rich-section-task) {
display: grid;
gap: 6px;
padding-left: 14px;
@@ -1828,7 +1827,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.rich-editor :deep(.rich-category-line) {
color: #0f766e;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 700;
line-height: 1.6;
@@ -1899,7 +1898,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
.structured-section-title {
position: relative;
padding-left: 14px;
color: #0f766e;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 800;
}
@@ -1912,7 +1911,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
}
.structured-task {
@@ -1928,7 +1927,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
.structured-task-category {
position: relative;
padding-left: 14px;
color: #0f766e;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 800;
}
@@ -1941,7 +1940,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
}
.structured-task-title {
@@ -1988,9 +1987,9 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.plan-section.active {
border-color: #cfe3e0;
background: #f7fbfa;
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.06);
border-color: var(--el-color-primary-light-7);
background: var(--el-color-primary-light-9);
box-shadow: inset 0 0 0 1px var(--el-color-primary-light-9);
}
.plan-section-head {
@@ -2124,7 +2123,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.inline-plan-card {
border-color: rgba(15, 118, 110, 0.42);
border-color: var(--el-color-primary-light-5);
background: #f8fbfc;
}
@@ -2171,14 +2170,14 @@ function syncRichSupport(item: PlanItem, event: Event) {
.inline-plan-card :deep(.el-input__wrapper.is-focus),
.inline-plan-card :deep(.el-select__wrapper.is-focused) {
box-shadow:
0 0 0 1px #0f766e inset,
0 0 0 2px rgba(15, 118, 110, 0.1);
0 0 0 1px var(--el-color-primary) inset,
0 0 0 2px var(--el-color-primary-light-8);
}
.inline-plan-card :deep(.el-textarea__inner:focus) {
box-shadow:
0 0 0 1px #0f766e inset,
0 0 0 2px rgba(15, 118, 110, 0.1);
0 0 0 1px var(--el-color-primary) inset,
0 0 0 2px var(--el-color-primary-light-8);
}
.inline-plan-card .inline-task-row :deep(.el-select__selected-item),
@@ -2216,8 +2215,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
.inline-plan-card .inline-task-row :deep(.el-input-number:focus-within .el-input__wrapper) {
overflow: hidden;
box-shadow:
0 0 0 1px #0f766e inset,
0 0 0 2px rgba(15, 118, 110, 0.1) !important;
0 0 0 1px var(--el-color-primary) inset,
0 0 0 2px var(--el-color-primary-light-8) !important;
}
.form-actions {
@@ -2237,16 +2236,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
}
.btn-submit {
background: #0f766e !important;
border-color: #0f766e !important;
}
.btn-submit:hover {
background: #0d9488 !important;
border-color: #0d9488 !important;
}
@media (max-width: 1180px) {
.form-head,
.compose-grid,

View File

@@ -128,13 +128,8 @@ const table = useUIPaginatedTable<
]
});
const summaryPeriod = computed(() =>
resolveWorkReportSummaryPeriod('project', {
currentRow: table.data.value[0],
periodRange: searchParams.periodStartDate,
flag: searchParams.flag
})
);
// 团队统计始终使用当前周期(当前半月),不跟随列表第一条数据的周期
const summaryPeriod = computed(() => resolveWorkReportSummaryPeriod('project'));
function getRowActions(row: Api.WorkReport.Project.ProjectReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [

View File

@@ -474,7 +474,6 @@ function notifyTitleSaved(item: WorkItem) {
<ElButton
size="small"
type="primary"
class="btn-submit"
:disabled="!planForm.title.trim() || isDuplicatePlanTitle"
@click="submitInlinePlan"
>
@@ -520,11 +519,11 @@ function notifyTitleSaved(item: WorkItem) {
<div v-if="!isReadonly" class="form-actions">
<!-- <ElButton>重置表单</ElButton>-->
<ElButton @click="emit('save')">保存草稿</ElButton>
<ElButton type="primary" class="btn-submit" @click="emit('submit')">提交审批</ElButton>
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
</div>
<div v-else-if="scene === 'approval'" class="form-actions approval-form-actions">
<ElButton @click="emit('back')">退出审批</ElButton>
<ElButton type="primary" class="btn-submit" @click="emit('requestApprove')">开始审批</ElButton>
<ElButton type="primary" @click="emit('requestApprove')">开始审批</ElButton>
</div>
</div>
</template>
@@ -567,8 +566,8 @@ function notifyTitleSaved(item: WorkItem) {
place-items: center;
flex-shrink: 0;
border-radius: 16px;
background: #ccfbf1;
color: #0f766e;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
font-weight: 900;
}
@@ -671,8 +670,8 @@ function notifyTitleSaved(item: WorkItem) {
}
.member-chip.more {
background: #f0fdfa;
color: #0f766e;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
cursor: default;
}
@@ -715,7 +714,7 @@ function notifyTitleSaved(item: WorkItem) {
}
.compact-work-card:hover {
border-color: rgba(15, 118, 110, 0.45);
border-color: var(--el-color-primary-light-5);
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
@@ -742,7 +741,7 @@ function notifyTitleSaved(item: WorkItem) {
display: grid;
place-items: center;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
color: #fff;
font-size: 13px;
font-weight: 900;
@@ -806,7 +805,7 @@ function notifyTitleSaved(item: WorkItem) {
}
.work-title-input:focus {
border-bottom-color: #0f766e;
border-bottom-color: var(--el-color-primary);
}
.work-title-line span {
@@ -908,8 +907,8 @@ function notifyTitleSaved(item: WorkItem) {
.rich-editor:focus {
box-shadow:
0 0 0 1px #0f766e inset,
0 0 0 2px rgba(15, 118, 110, 0.1);
0 0 0 1px var(--el-color-primary) inset,
0 0 0 2px var(--el-color-primary-light-8);
}
.rich-editor:empty::before {
@@ -938,7 +937,7 @@ function notifyTitleSaved(item: WorkItem) {
}
.inline-plan-card {
border-color: rgba(15, 118, 110, 0.42);
border-color: var(--el-color-primary-light-5);
background: #f8fbfc;
}
@@ -1001,8 +1000,8 @@ function notifyTitleSaved(item: WorkItem) {
.inline-plan-card :deep(.el-input__wrapper.is-focus),
.inline-plan-card :deep(.el-select__wrapper.is-focused) {
box-shadow:
0 0 0 1px #0f766e inset,
0 0 0 2px rgba(15, 118, 110, 0.1);
0 0 0 1px var(--el-color-primary) inset,
0 0 0 2px var(--el-color-primary-light-8);
}
.form-actions {
@@ -1034,16 +1033,6 @@ function notifyTitleSaved(item: WorkItem) {
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
}
.btn-submit {
background: #0f766e !important;
border-color: #0f766e !important;
}
.btn-submit:hover {
background: #0d9488 !important;
border-color: #0d9488 !important;
}
@media (max-width: 1180px) {
.compose-grid,
.review-grid,

View File

@@ -385,14 +385,14 @@ async function handleSubmit() {
}
.conclusion-btn:hover {
border-color: #0f766e;
color: #0f766e;
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.conclusion-btn.active.pass {
border-color: #0f766e;
background: #f0fdfa;
color: #0f766e;
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.conclusion-btn.active.reject {

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { Calendar } from '@element-plus/icons-vue';
import isoWeek from 'dayjs/plugin/isoWeek';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
type WorkReportPeriodOption,
@@ -12,6 +14,8 @@ import {
} from '../utils';
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
dayjs.extend(isoWeek);
defineOptions({ name: 'WorkReportCreateDialog' });
interface Props {
@@ -44,11 +48,24 @@ const emit = defineEmits<{
const selectedPeriodKey = ref('');
const selectedProjectId = ref('');
const customWeekDate = ref('');
const rawCustomWeekDate = ref('');
const customMonth = ref('');
const customProjectMonth = ref('');
const customProjectFlag = ref(1);
// 自定义周报周期:无论用户点哪一天,都归一到该 ISO 周的周一,
// 这样 ElDatePicker 使用内置 dayjs 的 wwlocale week也能正确显示 ISO 周数。
const customWeekDate = computed<string>({
get: () => rawCustomWeekDate.value,
set: val => {
if (!val) {
rawCustomWeekDate.value = val;
return;
}
rawCustomWeekDate.value = dayjs(val).startOf('isoWeek').format('YYYY-MM-DD');
}
});
const selectedReportType = computed<WorkReportType>(() => {
if (props.defaultReportType === 'project' && !props.projectVisible) return 'weekly';
return props.defaultReportType;
@@ -157,110 +174,113 @@ function handleConfirm() {
:close-on-click-modal="false"
@confirm="handleConfirm"
>
<div v-if="selectedReportType === 'project'" class="work-report-create-dialog__project-select">
<label class="work-report-create-dialog__label">项目</label>
<ElSelect v-model="selectedProjectId" class="w-full" placeholder="请选择项目" filterable>
<ElOption
v-for="item in props.projectOptions"
:key="item.id"
:label="item.projectCode ? `${item.projectName}${item.projectCode}` : item.projectName"
:value="item.id"
/>
</ElSelect>
</div>
<div class="work-report-create-dialog__section">
<div class="work-report-create-dialog__grid is-period">
<button
v-for="item in activePeriodOptions"
:key="item.key"
type="button"
class="work-report-create-dialog__choice"
:class="{ 'is-active': selectedPeriodKey === item.key }"
@click="selectedPeriodKey = item.key"
>
<div class="work-report-create-dialog__choice-title">{{ item.label }}</div>
<div class="work-report-create-dialog__choice-desc">{{ item.description }}</div>
</button>
<button
type="button"
class="work-report-create-dialog__choice"
:class="{ 'is-active': selectedPeriodKey === 'custom' }"
@click="selectedPeriodKey = 'custom'"
>
<div class="work-report-create-dialog__choice-title">自定义周期</div>
<div class="work-report-create-dialog__choice-desc">
{{
selectedReportType === 'weekly'
? '选择某一周作为周报周期。'
: selectedReportType === 'monthly'
? '选择某一月作为月报周期。'
: '选择某个月的上半月或下半月。'
}}
</div>
</button>
<!-- 必须用单根节点包裹ElScrollbar 默认 slot 接收多根 Fragment destroy-on-close patch 会触发 null __vnode 错误 -->
<div class="work-report-create-dialog__body">
<div v-if="selectedReportType === 'project'" class="work-report-create-dialog__project-select">
<label class="work-report-create-dialog__label">项目</label>
<ElSelect v-model="selectedProjectId" class="w-full" placeholder="请选择项目" filterable>
<ElOption
v-for="item in props.projectOptions"
:key="item.id"
:label="item.projectCode ? `${item.projectName}${item.projectCode}` : item.projectName"
:value="item.id"
/>
</ElSelect>
</div>
<div v-if="selectedPeriodKey === 'custom'" class="work-report-create-dialog__custom-period">
<div v-if="selectedReportType === 'weekly'" class="work-report-create-dialog__custom-row">
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
<label class="work-report-create-dialog__label">周报周期</label>
<ElDatePicker
v-model="customWeekDate"
type="date"
format="YYYY[年第]ww[周]"
value-format="YYYY-MM-DD"
popper-class="work-report-create-date-popper"
placeholder="请选择周报周期"
/>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
{{ customPeriodPreviewLabel }}
<div class="work-report-create-dialog__section">
<div class="work-report-create-dialog__grid is-period">
<button
v-for="item in activePeriodOptions"
:key="item.key"
type="button"
class="work-report-create-dialog__choice"
:class="{ 'is-active': selectedPeriodKey === item.key }"
@click="selectedPeriodKey = item.key"
>
<div class="work-report-create-dialog__choice-title">{{ item.label }}</div>
<div class="work-report-create-dialog__choice-desc">{{ item.description }}</div>
</button>
<button
type="button"
class="work-report-create-dialog__choice"
:class="{ 'is-active': selectedPeriodKey === 'custom' }"
@click="selectedPeriodKey = 'custom'"
>
<div class="work-report-create-dialog__choice-title">自定义周期</div>
<div class="work-report-create-dialog__choice-desc">
{{
selectedReportType === 'weekly'
? '选择某一周作为周报周期。'
: selectedReportType === 'monthly'
? '选择某一月作为月报周期。'
: '选择某个月的上半月或下半月。'
}}
</div>
</div>
</button>
</div>
<div v-else-if="selectedReportType === 'monthly'" class="work-report-create-dialog__custom-row">
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
<label class="work-report-create-dialog__label">月报周期</label>
<ElDatePicker
v-model="customMonth"
type="month"
value-format="YYYY-MM"
popper-class="work-report-create-date-popper"
placeholder="请选择月份"
/>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
{{ customPeriodPreviewLabel }}
</div>
</div>
</div>
<div v-else class="work-report-create-dialog__custom-project">
<div class="work-report-create-dialog__custom-project-grid">
<div class="work-report-create-dialog__custom-project-item">
<div class="work-report-create-dialog__custom-project-item-label">选择月份</div>
<div v-if="selectedPeriodKey === 'custom'" class="work-report-create-dialog__custom-period">
<div v-if="selectedReportType === 'weekly'" class="work-report-create-dialog__custom-row">
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
<label class="work-report-create-dialog__label">周报周期</label>
<ElDatePicker
v-model="customProjectMonth"
class="w-full"
v-model="customWeekDate"
type="date"
format="YYYY[年第]ww[周]"
value-format="YYYY-MM-DD"
popper-class="work-report-create-date-popper"
placeholder="请选择周报周期"
/>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
{{ customPeriodPreviewLabel }}
</div>
</div>
</div>
<div v-else-if="selectedReportType === 'monthly'" class="work-report-create-dialog__custom-row">
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
<label class="work-report-create-dialog__label">月报周期</label>
<ElDatePicker
v-model="customMonth"
type="month"
value-format="YYYY-MM"
popper-class="work-report-create-date-popper"
placeholder="请选择月份"
/>
</div>
<div class="work-report-create-dialog__custom-project-item">
<div class="work-report-create-dialog__custom-project-item-label">选择半月</div>
<ElSegmented
v-model="customProjectFlag"
:options="projectHalfOptions"
class="work-report-create-dialog__half-segmented"
/>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
{{ customPeriodPreviewLabel }}
</div>
</div>
</div>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
<ElIcon class="work-report-create-dialog__period-preview-icon"><Calendar /></ElIcon>
<span class="work-report-create-dialog__period-preview-text">已选周期</span>
<span class="work-report-create-dialog__period-preview-value">{{ customPeriodPreviewLabel }}</span>
<div v-else class="work-report-create-dialog__custom-project">
<div class="work-report-create-dialog__custom-project-grid">
<div class="work-report-create-dialog__custom-project-item">
<div class="work-report-create-dialog__custom-project-item-label">选择月份</div>
<ElDatePicker
v-model="customProjectMonth"
class="w-full"
type="month"
value-format="YYYY-MM"
popper-class="work-report-create-date-popper"
placeholder="请选择月份"
/>
</div>
<div class="work-report-create-dialog__custom-project-item">
<div class="work-report-create-dialog__custom-project-item-label">选择半月</div>
<ElSegmented
v-model="customProjectFlag"
:options="projectHalfOptions"
class="work-report-create-dialog__half-segmented"
/>
</div>
</div>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
<ElIcon class="work-report-create-dialog__period-preview-icon"><Calendar /></ElIcon>
<span class="work-report-create-dialog__period-preview-text">已选周期</span>
<span class="work-report-create-dialog__period-preview-value">{{ customPeriodPreviewLabel }}</span>
</div>
</div>
</div>
</div>
@@ -320,13 +340,13 @@ function handleConfirm() {
}
.work-report-create-dialog__choice:hover {
border-color: rgba(15, 118, 110, 0.28);
border-color: var(--el-color-primary-light-3);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.work-report-create-dialog__choice.is-active {
border-color: #0f766e;
background: #ecfdf5;
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.work-report-create-dialog__choice-title {
@@ -381,9 +401,9 @@ function handleConfirm() {
.work-report-create-dialog__custom-period {
margin-top: 14px;
padding: 16px;
border: 1px solid rgba(15, 118, 110, 0.18);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 14px;
background: linear-gradient(180deg, #f8fffd 0%, #ffffff 100%);
background: linear-gradient(180deg, var(--el-color-primary-light-9) 0%, #ffffff 100%);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
@@ -422,7 +442,7 @@ function handleConfirm() {
}
.work-report-create-dialog__custom-project-item:hover {
border-color: rgba(15, 118, 110, 0.4);
border-color: var(--el-color-primary-light-5);
}
.work-report-create-dialog__custom-project-item-label {
@@ -460,10 +480,10 @@ function handleConfirm() {
gap: 6px;
min-height: 32px;
padding: 0 14px;
border: 1px solid rgba(15, 118, 110, 0.18);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 999px;
background: #ecfdf5;
color: #0f766e;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-size: 13px;
font-weight: 700;
white-space: nowrap;
@@ -472,7 +492,7 @@ function handleConfirm() {
.work-report-create-dialog__period-preview-icon {
font-size: 14px;
color: #0f766e;
color: var(--el-color-primary);
}
.work-report-create-dialog__period-preview-text {
@@ -481,7 +501,7 @@ function handleConfirm() {
}
.work-report-create-dialog__period-preview-value {
color: #0f766e;
color: var(--el-color-primary);
font-weight: 800;
}
@@ -529,6 +549,6 @@ function handleConfirm() {
:global(.work-report-create-date-popper .el-date-table td.current:not(.disabled) .el-date-table-cell__text),
:global(.work-report-create-date-popper .el-month-table td.current:not(.disabled) .cell) {
background-color: #0f766e;
background-color: var(--el-color-primary);
}
</style>

View File

@@ -16,6 +16,7 @@ import {
fetchInitMonthlyReport,
fetchInitProjectReport,
fetchInitWeeklyReport,
fetchPerformanceMonthlyResult,
fetchPreviewMonthlyReportDefaultDraft,
fetchPreviewProjectReportDefaultDraft,
fetchPreviewWeeklyReportDefaultDraft,
@@ -111,6 +112,7 @@ const monthlyApprovalDraft = reactive<Api.WorkReport.Monthly.MonthlyReportApprov
supervisorSignName: '',
supervisorSignedDate: ''
});
const monthlyPerformanceAutoFilled = ref(false);
const weeklyModel = reactive<Api.WorkReport.Weekly.WeeklyReportSaveParams>(createWeeklySaveParams());
const monthlyModel = reactive<Api.WorkReport.Monthly.MonthlyReportSaveParams>(createMonthlySaveParams());
@@ -167,6 +169,7 @@ function resetModels() {
supervisorSignName: '',
supervisorSignedDate: ''
});
monthlyPerformanceAutoFilled.value = false;
}
function patchMonthlyApprovalDefaults(report?: Partial<Api.WorkReport.Monthly.MonthlyReport> | null) {
@@ -471,9 +474,19 @@ watch(visible, async isVisible => {
if (props.rowData?.id) {
await loadDetail(props.rowData.id);
await fillMonthlyPerformanceResult();
}
});
watch(
() => currentStage.value,
stage => {
if (stage === 'approval') {
fillMonthlyPerformanceResult();
}
}
);
function hasTextValue(value: unknown) {
const text = String(value ?? '')
.replace(/<[^>]*>/g, '')
@@ -706,6 +719,45 @@ function handleBack() {
function handleViewApproval() {
currentStage.value = 'approval';
fillMonthlyPerformanceResult();
}
function resolveMonthlyPeriodMonth() {
const startDate = monthlyModel.periodStartDate || baseInfo.value?.periodStartDate;
const fromStartDate = dayjs(startDate);
if (fromStartDate.isValid()) {
return fromStartDate.format('YYYY-MM');
}
const periodKey = monthlyModel.periodKey || baseInfo.value?.periodKey;
const periodKeyMatch = String(periodKey || '').match(/(\d{4})[-/年]?(\d{1,2})/u);
if (periodKeyMatch) {
return `${periodKeyMatch[1]}-${periodKeyMatch[2].padStart(2, '0')}`;
}
const periodLabel = monthlyModel.periodLabel || baseInfo.value?.periodLabel;
const periodLabelMatch = String(periodLabel || '').match(/(\d{4})[-/年]?(\d{1,2})/u);
if (periodLabelMatch) {
return `${periodLabelMatch[1]}-${periodLabelMatch[2].padStart(2, '0')}`;
}
return '';
}
async function fillMonthlyPerformanceResult() {
if (props.reportType !== 'monthly' || monthlyPerformanceAutoFilled.value) return;
if (monthlyApprovalDraft.performanceResult?.trim()) return;
const employeeId = (baseInfo.value as Api.WorkReport.Monthly.MonthlyReport | null)?.reporterId;
const periodMonth = resolveMonthlyPeriodMonth();
if (!employeeId || !periodMonth) return;
monthlyPerformanceAutoFilled.value = true;
const { error, data } = await fetchPerformanceMonthlyResult(employeeId, periodMonth);
if (error || !data?.actualScoreTotal) return;
if (monthlyApprovalDraft.performanceResult?.trim()) return;
monthlyApprovalDraft.performanceResult = String(data.actualScoreTotal);
}
function handleRequestApprove() {

View File

@@ -38,10 +38,12 @@ export function formatPeriodDisplayLabel(label?: string | null) {
.replace(/\s*(|||)\s*$/u, '');
}
// 使用 ISO 周数,确保与 buildWeeklyPeriodFromDate 的计算一致
export function getIsoWeekDisplay(date: string | dayjs.Dayjs) {
const selectedDate = dayjs(date);
if (!selectedDate.isValid()) return '';
return `${selectedDate.format('YYYY')}${String(selectedDate.isoWeek()).padStart(2, '0')}`;
// isoWeek() 返回 ISO 8601 周数(周一为一周起始)
return `${selectedDate.format('GGGG')}${String(selectedDate.isoWeek()).padStart(2, '0')}`;
}
/* eslint-disable-next-line max-params */

View File

@@ -152,12 +152,8 @@ const table = useUIPaginatedTable<
]
});
const summaryPeriod = computed(() =>
resolveWorkReportSummaryPeriod('weekly', {
currentRow: table.data.value[0],
periodRange: searchParams.periodStartDate
})
);
// 团队统计始终使用当前周期(本周),不跟随列表第一条数据的周期
const summaryPeriod = computed(() => resolveWorkReportSummaryPeriod('weekly'));
function getRowActions(row: Api.WorkReport.Weekly.WeeklyReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [

View File

@@ -1386,11 +1386,11 @@ function syncRichSupport(item: PlanItem, event: Event) {
<div v-if="!isReadonly" class="form-actions">
<!-- <ElButton>重置表单</ElButton>-->
<ElButton @click="emit('save')">保存草稿</ElButton>
<ElButton type="primary" class="btn-submit" @click="emit('submit')">提交审批</ElButton>
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
</div>
<div v-else-if="scene === 'approval'" class="form-actions approval-form-actions">
<ElButton @click="emit('back')">退出审批</ElButton>
<ElButton type="primary" class="btn-submit" @click="emit('requestApprove')">开始审批</ElButton>
<ElButton type="primary" @click="emit('requestApprove')">开始审批</ElButton>
</div>
<BusinessFormDialog
@@ -1672,8 +1672,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
place-items: center;
flex-shrink: 0;
border-radius: 16px;
background: #ccfbf1;
color: #0f766e;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
font-weight: 900;
}
@@ -1766,12 +1766,12 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.radio-group-full :deep(.el-radio.is-checked) {
border-color: #0f766e;
background: #f0fdfa;
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.radio-group-full :deep(.el-radio__input.is-checked + .el-radio__label) {
color: #0f766e;
color: var(--el-color-primary);
}
.review-grid,
@@ -1891,7 +1891,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
display: grid;
place-items: center;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
color: #fff;
font-size: 13px;
font-weight: 900;
@@ -2091,8 +2091,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.rich-editor:focus {
border-color: #0f766e;
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.1);
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px var(--el-color-primary-light-8);
}
.rich-editor:empty::before {
@@ -2113,7 +2113,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.rich-editor :deep(.rich-category-line) {
color: #0f766e;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 700;
line-height: 1.6;
@@ -2153,7 +2153,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.rich-editor--preview:hover {
border-color: #0f766e;
border-color: var(--el-color-primary);
}
.structured-preview__popover {
@@ -2193,7 +2193,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
min-width: 0;
flex: 1;
padding-left: 14px;
color: #0f766e;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 800;
white-space: normal;
@@ -2239,7 +2239,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
}
.rich-editor :deep(.rich-task-detail) {
@@ -2300,7 +2300,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
.structured-task-title {
position: relative;
padding-left: 14px;
color: #0f766e;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 800;
}
@@ -2313,7 +2313,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
background: var(--el-color-primary);
}
.structured-task-detail {
@@ -2330,8 +2330,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
align-items: center;
padding: 12px 14px;
border-radius: 10px;
background: #f0fdfa;
color: #0f766e;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-size: 13px;
font-weight: 900;
}
@@ -2401,9 +2401,9 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.plan-section.active {
border-color: #cfe3e0;
background: #f7fbfa;
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.06);
border-color: var(--el-color-primary-light-7);
background: var(--el-color-primary-light-9);
box-shadow: inset 0 0 0 1px var(--el-color-primary-light-9);
}
.plan-section-head {
@@ -2627,16 +2627,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
}
.btn-submit {
background: #0f766e !important;
border-color: #0f766e !important;
}
.btn-submit:hover {
background: #0d9488 !important;
border-color: #0d9488 !important;
}
@media (max-width: 1180px) {
.form-head,
.compose-grid,