Files
cn-rdms-web/src/views/personal-center/overtime-application/index.vue
2026-06-14 23:57:42 +08:00

563 lines
17 KiB
Vue

<script setup lang="tsx">
import { computed, markRaw, reactive, ref, watch } from 'vue';
import { ElButton, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import {
fetchExportOvertimeApplications,
fetchGetMySubordinateTree,
fetchGetOvertimeApplicationPage,
fetchGetTeamOvertimeSummary
} from '@/service/api';
import { useUIPaginatedTable } from '@/hooks/common/table';
import { useAuth } from '@/hooks/business/auth';
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 OvertimeApplicationApprovalRecordDialog from './modules/overtime-application-approval-record-dialog.vue';
import OvertimeApplicationDetailDialog from './modules/overtime-application-detail-dialog.vue';
import OvertimeApplicationOperateDialog from './modules/overtime-application-operate-dialog.vue';
import OvertimeApplicationSearch from './modules/overtime-application-search.vue';
import {
downloadBlob,
formatEmptyText,
formatOvertimeDate,
formatOvertimeDateTime,
getOvertimeApplicationStatusLabel,
resolveOvertimeApplicationStatusTagType
} from './modules/overtime-application-shared';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
defineOptions({ name: 'OvertimeApplication' });
type OvertimeApplicationPageResponse = Awaited<ReturnType<typeof fetchGetOvertimeApplicationPage>>;
function getInitSearchParams(): Api.OvertimeApplication.OvertimeApplicationSearchParams {
return {
pageNo: 1,
pageSize: 10,
keyword: undefined,
applicantName: undefined,
approverId: undefined,
approverName: undefined,
statusCode: undefined,
overtimeDate: undefined,
createTime: undefined
};
}
function transformPageResult(response: OvertimeApplicationPageResponse, 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 searchParams = reactive(getInitSearchParams());
const { hasAuth } = useAuth();
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.OvertimeApplication.TeamOvertimeSummary | null>(null);
const operateVisible = ref(false);
const detailVisible = ref(false);
const approvalRecordVisible = ref(false);
const operateType = ref<'add' | 'edit'>('add');
const currentRow = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const exporting = ref(false);
const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
edit: markRaw(IconMdiPencilOutline)
};
const canUseTeamDashboard = computed(() => hasAuth('project:work-report:team-dashboard'));
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
const selectedSubordinateNode = computed(() =>
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.value)
);
const isTeamMode = computed(() => teamViewMode.value === 'team');
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 currentApplicantIds = computed(() => {
if (!isTeamMode.value) return null;
if (isRootSelected.value) return [];
return teamContext.value?.selectedUserIds ?? [];
});
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
OvertimeApplicationPageResponse,
Api.OvertimeApplication.OvertimeApplication
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () =>
fetchGetOvertimeApplicationPage({
...searchParams,
applicantIds: currentApplicantIds.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: 'index', type: 'index', label: '序号', width: 64 },
...(isTeamMode.value ? [{ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true }] : []),
{
prop: 'overtimeDate',
label: '加班日期',
width: 120,
formatter: row => formatOvertimeDate(row.overtimeDate)
},
{ prop: 'overtimeDuration', label: '加班时长', width: 110, showOverflowTooltip: true },
{
prop: 'overtimeReason',
label: '加班原因',
minWidth: 180,
className: 'overtime-application__cell-ellipsis',
formatter: row => formatEmptyText(row.overtimeReason)
},
{
prop: 'overtimeContent',
label: '加班内容',
minWidth: 200,
className: 'overtime-application__cell-ellipsis',
formatter: row => formatEmptyText(row.overtimeContent)
},
{
prop: 'statusCode',
label: '状态',
width: 110,
align: 'center',
formatter: row => (
<ElTag type={resolveOvertimeApplicationStatusTagType(row.statusCode)}>
{getOvertimeApplicationStatusLabel(row.statusCode, row.statusName)}
</ElTag>
)
},
{ prop: 'approverName', label: '审核人', minWidth: 80, showOverflowTooltip: true },
{
prop: 'submitTime',
label: '提交时间',
minWidth: 150,
formatter: row => formatOvertimeDateTime(row.submitTime)
},
{
prop: 'approvalTime',
label: '审核时间',
minWidth: 150,
formatter: row => formatOvertimeDateTime(row.approvalTime)
},
{
prop: 'operate',
label: '操作',
width: isTeamMode.value ? 140 : 170,
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.OvertimeApplication.OvertimeApplication): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '查看',
buttonType: 'primary',
icon: ACTION_ICON_MAP.detail,
onClick: () => openDetail(row)
}
];
if (isTeamMode.value) {
if (['approved', 'rejected'].includes(row.statusCode)) {
actions.push({
key: 'approval-record',
label: '审批记录',
buttonType: 'info',
icon: ACTION_ICON_MAP.approvalRecord,
onClick: () => openApprovalRecord(row)
});
}
return actions;
}
if (row.statusCode === 'rejected' && row.allowEdit) {
actions.push({
key: 'edit',
label: '修改',
buttonType: 'primary',
icon: ACTION_ICON_MAP.edit,
onClick: () => openEdit(row)
});
}
if (['approved', 'rejected'].includes(row.statusCode)) {
actions.push({
key: 'approval-record',
label: '审批记录',
buttonType: 'info',
icon: ACTION_ICON_MAP.approvalRecord,
onClick: () => openApprovalRecord(row)
});
}
return actions;
}
function openAdd() {
operateType.value = 'add';
currentRow.value = null;
operateVisible.value = true;
}
function openEdit(row: Api.OvertimeApplication.OvertimeApplication) {
operateType.value = 'edit';
currentRow.value = row;
operateVisible.value = true;
}
function openDetail(row: Api.OvertimeApplication.OvertimeApplication) {
currentRow.value = row;
detailVisible.value = true;
}
function openApprovalRecord(row: Api.OvertimeApplication.OvertimeApplication) {
currentRow.value = row;
approvalRecordVisible.value = true;
}
async function reloadTable(page = searchParams.pageNo ?? 1) {
await getDataByPage(page);
}
function resetSearchParams() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, getInitSearchParams(), { pageSize });
reloadTable(1);
}
function handleSearch() {
reloadTable(1);
}
function handleSubmitted() {
operateVisible.value = false;
reloadTable(searchParams.pageNo ?? 1);
}
function createExportParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return {
...params,
applicantIds: currentApplicantIds.value
};
}
async function handleExport() {
exporting.value = true;
const { error, data: blob } = await fetchExportOvertimeApplications({
...createExportParams(),
applicantIds: currentApplicantIds.value
});
exporting.value = false;
if (error || !blob) {
return;
}
downloadBlob(blob, `加班申请_${dayjs().format('YYYY-MM-DD')}.xls`);
}
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 fetchGetTeamOvertimeSummary();
teamSummaryLoading.value = false;
teamSummary.value = error || !summaryData ? null : summaryData;
}
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);
}
watch(
() => [teamViewMode.value, selectedSubordinateUserId.value],
async () => {
if (!isTeamMode.value) return;
await reloadTable(1);
}
);
watch(
() => isRootSelected.value,
() => {
loadTeamSummary();
}
);
</script>
<template>
<div class="overtime-application-page">
<TeamContextPanel
v-if="canUseTeamDashboard"
v-model:mode="teamViewMode"
:loading="subordinateTreeLoading"
:selected-label="selectedTeamLabel"
:subordinate-count="subordinateTree?.subordinateCount || 0"
@update:mode="handleTeamViewModeChange"
>
<div v-if="isRootSelected" v-loading="teamSummaryLoading" class="team-overtime-summary">
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月申请单数</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.totalApplicationCount ?? 0 }}</strong>
</div>
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月待审批</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.pendingCount ?? 0 }}</strong>
</div>
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月已通过</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.approvedCount ?? 0 }}</strong>
</div>
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月已退回</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.rejectedCount ?? 0 }}</strong>
</div>
</div>
</TeamContextPanel>
<div class="overtime-application-page__content" :class="{ 'overtime-application-page__content--team': isTeamMode }">
<div v-if="canUseTeamDashboard && isTeamMode" class="overtime-application-page__sidebar">
<SubordinateSelector
v-model:selected-user-id="selectedSubordinateUserId"
:loading="subordinateTreeLoading"
:data="subordinateTree"
/>
</div>
<div class="overtime-application-page__main">
<OvertimeApplicationSearch v-model:model="searchParams" @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">加班申请</p>
<ElTag effect="plain">{{ totalCount }}</ElTag>
</div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
<template #default>
<ElButton plain :loading="exporting" @click="handleExport">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
<ElButton v-if="!isTeamMode" plain type="primary" @click="openAdd">
<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">
<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>
<OvertimeApplicationOperateDialog
v-model:visible="operateVisible"
:operate-type="operateType"
:row-data="currentRow"
@submitted="handleSubmitted"
/>
<OvertimeApplicationDetailDialog v-model:visible="detailVisible" :row-data="currentRow" />
<OvertimeApplicationApprovalRecordDialog v-model:visible="approvalRecordVisible" :row-data="currentRow" />
</div>
</template>
<style scoped>
.overtime-application-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow: hidden;
}
.overtime-application-page__content {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.overtime-application-page__main {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
}
@media (min-width: 1280px) {
.overtime-application-page__content--team {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
}
.overtime-application-page__sidebar {
min-height: 0;
}
}
:deep(.overtime-application__reason-link) {
max-width: 100%;
padding: 0;
vertical-align: baseline;
}
:deep(.overtime-application__reason-link > span) {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 加班原因/加班内容:单元格内容溢出时仅显示省略号,不弹出 tooltip */
:deep(.overtime-application__cell-ellipsis .cell) {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team-overtime-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.team-overtime-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);
}
.team-overtime-summary__label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.team-overtime-summary__value {
color: var(--el-text-color-primary);
font-size: 22px;
font-weight: 600;
line-height: 1.2;
}
</style>