feat(工作报告、加班申请团队视角): 工作报告、加班申请现在可以查看团队视角了(查看下属)。

fix(工作报告): 修复周报在新增/编辑时,不能展示工作日志。
This commit is contained in:
dk
2026-06-14 23:57:42 +08:00
parent 17690283f6
commit 3c1cf6c7fa
19 changed files with 1618 additions and 94 deletions

View File

@@ -1,10 +1,24 @@
<script setup lang="tsx">
import { computed, markRaw, reactive, ref } from 'vue';
import { computed, markRaw, reactive, ref, watch } from 'vue';
import { ElButton, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import { fetchExportOvertimeApplications, fetchGetOvertimeApplicationPage } from '@/service/api';
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';
@@ -20,6 +34,7 @@ import {
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' });
@@ -58,6 +73,13 @@ function transformPageResult(response: OvertimeApplicationPageResponse, pageNo:
}
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);
@@ -71,6 +93,39 @@ const ACTION_ICON_MAP = {
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
@@ -79,7 +134,11 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetOvertimeApplicationPage(searchParams),
api: () =>
fetchGetOvertimeApplicationPage({
...searchParams,
applicantIds: currentApplicantIds.value
}),
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
@@ -87,7 +146,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true },
...(isTeamMode.value ? [{ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true }] : []),
{
prop: 'overtimeDate',
label: '加班日期',
@@ -136,7 +195,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
{
prop: 'operate',
label: '操作',
width: 170,
width: isTeamMode.value ? 140 : 170,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
@@ -150,13 +209,27 @@ function getRowActions(row: Api.OvertimeApplication.OvertimeApplication): Busine
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
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',
@@ -221,9 +294,20 @@ function handleSubmitted() {
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(searchParams);
const { error, data: blob } = await fetchExportOvertimeApplications({
...createExportParams(),
applicantIds: currentApplicantIds.value
});
exporting.value = false;
if (error || !blob) {
@@ -232,56 +316,150 @@ async function handleExport() {
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="flex-col-stretch gap-16px overflow-hidden">
<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 plain type="primary" @click="openAdd">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
</template>
</TableHeaderOperation>
<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>
</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>
</TeamContextPanel>
<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 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>
</ElCard>
<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"
@@ -297,6 +475,42 @@ async function handleExport() {
</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;
@@ -318,4 +532,31 @@ async function handleExport() {
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>

View File

@@ -0,0 +1,70 @@
export type TeamViewMode = 'self' | 'team';
export interface TeamSelectableUser {
userId: string;
userNickname: string;
}
export interface TeamSelectionState {
mode: TeamViewMode;
selectedUserId: string | null;
selectedUserIds: string[] | null;
isRootSelected: boolean;
}
export interface TeamViewContext extends TeamSelectionState {
allSubordinateUserIds: string[];
selectedLabel: string;
}
export function resolveTeamQueryUserIds(context: TeamViewContext | null | undefined): string[] | null {
if (!context || context.mode !== 'team') {
return null;
}
if (context.isRootSelected) {
return [...context.allSubordinateUserIds];
}
return context.selectedUserIds ? [...context.selectedUserIds] : [];
}
export function collectSubordinateUserIds(root: Api.SystemManage.MySubordinateTreeNode | null | undefined): string[] {
if (!root) return [];
const ids: string[] = [];
const walk = (nodes?: Api.SystemManage.MySubordinateTreeNode[] | null) => {
nodes?.forEach(node => {
ids.push(node.userId);
walk(node.children ?? null);
});
};
walk(root.children ?? null);
return ids;
}
export function findSubordinateNode(
root: Api.SystemManage.MySubordinateTreeNode | null | undefined,
userId: string | null
): Api.SystemManage.MySubordinateTreeNode | null {
if (!root || !userId) return null;
if (root.userId === userId) {
return root;
}
const stack = [...(root.children ?? [])];
while (stack.length) {
const current = stack.shift()!;
if (current.userId === userId) {
return current;
}
if (current.children?.length) {
stack.push(...current.children);
}
}
return null;
}

View File

@@ -1,16 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { fetchGetProjectReportOwnerProjectOptions } from '@/service/api';
import { fetchGetMySubordinateTree, fetchGetProjectReportOwnerProjectOptions } from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import SubordinateSelector from '@/components/custom/subordinate-selector.vue';
import TeamContextPanel from '@/components/custom/team-context-panel.vue';
import {
type TeamViewContext,
type TeamViewMode,
collectSubordinateUserIds,
findSubordinateNode
} from '../shared/team-dashboard';
import WorkReportCreateDialog from './shared/components/create-dialog.vue';
import WorkReportPrototypePageDialog from './shared/components/prototype-page-dialog.vue';
import WorkReportTabs from './shared/components/tabs.vue';
import {
WORK_REPORT_PROJECT_OWNER_PERMISSION,
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType
type WorkReportType,
getWorkReportTypeDisplayLabel
} from './shared/types';
import WeeklyReportIndex from './weekly/index.vue';
import WeeklyReportApprovalRecordDialog from './weekly/modules/approval-record-dialog.vue';
@@ -29,6 +37,10 @@ type ReportListExpose = {
const { hasAuth } = useAuth();
const activeTab = ref<WorkReportType>('weekly');
const teamViewMode = ref<TeamViewMode>('self');
const subordinateTreeLoading = ref(false);
const subordinateTree = ref<Api.SystemManage.MySubordinateTreeNode | null>(null);
const selectedSubordinateUserId = ref<string | null>(null);
const createVisible = ref(false);
const pageDialogVisible = ref(false);
const pageDialogMode = ref<PageDialogMode>('detail');
@@ -50,18 +62,45 @@ const monthlyRef = ref<ReportListExpose | null>(null);
const projectRef = ref<ReportListExpose | null>(null);
const canShowProjectTab = computed(() => hasAuth(WORK_REPORT_PROJECT_OWNER_PERMISSION));
const canUseTeamDashboard = computed(() => hasAuth('project:work-report:team-dashboard'));
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
const selectedSubordinateNode = computed(() =>
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.value)
);
const isRootSelected = computed(() => Boolean(selectedSubordinateNode.value?.isRoot));
const selectedTeamLabel = computed(() => {
if (teamViewMode.value === 'self') 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:
teamViewMode.value === 'team' && selectedSubordinateUserId.value && !isRootSelected.value
? [selectedSubordinateUserId.value]
: [],
isRootSelected: teamViewMode.value === 'team' && isRootSelected.value,
allSubordinateUserIds: allSubordinateUserIds.value,
selectedLabel: selectedTeamLabel.value
};
});
/** 项目选项是否加载成功(用于项目半月报列表内部判断) */
const projectOptionsLoaded = ref(false);
const visibleTabs = computed<Array<{ label: string; name: WorkReportType }>>(() => {
const isTeamReportMode = teamViewMode.value === 'team';
const tabs: Array<{ label: string; name: WorkReportType }> = [
{ label: WORK_REPORT_TYPE_LABEL.weekly, name: 'weekly' },
{ label: WORK_REPORT_TYPE_LABEL.monthly, name: 'monthly' }
{ label: getWorkReportTypeDisplayLabel('weekly', isTeamReportMode), name: 'weekly' },
{ label: getWorkReportTypeDisplayLabel('monthly', isTeamReportMode), name: 'monthly' }
];
if (canShowProjectTab.value) {
tabs.push({ label: WORK_REPORT_TYPE_LABEL.project, name: 'project' });
tabs.push({ label: getWorkReportTypeDisplayLabel('project', isTeamReportMode), name: 'project' });
}
return tabs;
@@ -87,6 +126,17 @@ async function loadProjectOptions() {
projectOptionsLoaded.value = !error;
}
async function loadSubordinateTree() {
if (!canUseTeamDashboard.value) return;
subordinateTreeLoading.value = true;
const { error, data } = await fetchGetMySubordinateTree();
subordinateTreeLoading.value = false;
subordinateTree.value = error || !data ? null : data;
selectedSubordinateUserId.value = data?.userId || null;
}
function openCreate(reportType: WorkReportType) {
currentReportType.value = reportType;
createVisible.value = true;
@@ -133,9 +183,10 @@ function openApprovalRecord(reportType: WorkReportType, row: WorkReportRow) {
approvalRecordVisible.value = true;
}
function handleTabChange(tab: WorkReportType) {
async function handleTabChange(tab: WorkReportType) {
activeTab.value = tab;
getListRef(tab)?.reload(1);
await nextTick();
await getListRef(tab)?.reload(1);
}
async function reloadReport(reportType = currentReportType.value) {
@@ -153,8 +204,35 @@ function closeFloatingPanels() {
approvalRecordVisible.value = false;
}
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 nextTick();
await getListRef(activeTab.value)?.reload(1);
}
watch(selectedSubordinateUserId, async (currentUserId, previousUserId) => {
if (!canUseTeamDashboard.value || teamViewMode.value !== 'team') return;
if (!currentUserId || currentUserId === previousUserId) return;
await nextTick();
await getListRef(activeTab.value)?.reload(1);
});
onMounted(async () => {
await loadProjectOptions();
if (canUseTeamDashboard.value) {
await loadSubordinateTree();
}
});
onBeforeRouteLeave(() => {
@@ -167,16 +245,41 @@ onBeforeRouteLeave(() => {
class="work-report-page-shell min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[240px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
>
<!-- 左侧报告类型导航 -->
<div class="flex-col-stretch gap-16px xl:min-h-0">
<WorkReportTabs :active-tab="activeTab" :tabs="visibleTabs" @update:active-tab="handleTabChange" />
<div
class="work-report-page-shell__sidebar"
:class="{ 'work-report-page-shell__sidebar--team': canUseTeamDashboard && teamViewMode === 'team' }"
>
<WorkReportTabs
class="work-report-page-shell__sidebar-card"
:active-tab="activeTab"
:tabs="visibleTabs"
@update:active-tab="handleTabChange"
/>
<SubordinateSelector
v-if="canUseTeamDashboard && teamViewMode === 'team'"
v-model:selected-user-id="selectedSubordinateUserId"
class="work-report-page-shell__sidebar-card"
:loading="subordinateTreeLoading"
:data="subordinateTree"
/>
</div>
<!-- 右侧搜索区 + 列表区 -->
<div class="flex-col-stretch gap-16px xl:min-h-0">
<TeamContextPanel
v-if="canUseTeamDashboard"
v-model:mode="teamViewMode"
:loading="subordinateTreeLoading"
:selected-label="selectedTeamLabel"
:subordinate-count="subordinateTree?.subordinateCount || 0"
@update:mode="handleTeamViewModeChange"
/>
<WeeklyReportIndex
v-show="activeTab === 'weekly'"
ref="weeklyRef"
class="flex-1-hidden"
:team-context="teamContext"
@create="openCreate('weekly')"
@edit="openEdit('weekly', $event)"
@detail="openDetail('weekly', $event)"
@@ -187,6 +290,7 @@ onBeforeRouteLeave(() => {
v-show="activeTab === 'monthly'"
ref="monthlyRef"
class="flex-1-hidden"
:team-context="teamContext"
@create="openCreate('monthly')"
@edit="openEdit('monthly', $event)"
@detail="openDetail('monthly', $event)"
@@ -198,6 +302,7 @@ onBeforeRouteLeave(() => {
v-show="activeTab === 'project'"
ref="projectRef"
class="flex-1-hidden"
:team-context="teamContext"
:project-options="projectOptions"
:project-options-loaded="projectOptionsLoaded"
@create="openCreate('project')"
@@ -238,4 +343,25 @@ onBeforeRouteLeave(() => {
.work-report-page-shell {
height: 100%;
}
.work-report-page-shell__sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
@media (min-width: 1280px) {
.work-report-page-shell__sidebar {
min-height: 0;
}
.work-report-page-shell__sidebar--team {
display: grid;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
.work-report-page-shell__sidebar-card {
min-height: 0;
}
}
</style>

View File

@@ -1,18 +1,19 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { markRaw, reactive, ref } from 'vue';
import { computed, markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag } from 'element-plus';
import {
fetchDeleteMonthlyReport,
fetchExportMonthlyReportContent,
fetchGetMonthlyReportPage,
fetchGetTeamReportSummary,
fetchSubmitMonthlyReport
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import { type TeamViewContext, resolveTeamQueryUserIds } from '@/views/personal-center/shared/team-dashboard';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
createMonthlySearchParams,
createWorkReportContentExportFallbackName,
@@ -21,19 +22,27 @@ import {
formatEmptyText,
formatPeriod,
getWorkReportStatusLabel,
getWorkReportTypeDisplayLabel,
resolveExportFilename,
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import { resolveWorkReportSummaryPeriod } from '../shared/utils';
import TeamReportSummary from '../shared/components/team-report-summary.vue';
import MonthlyReportSearch from './modules/search-panel.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiSendOutline from '~icons/mdi/send-outline';
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
defineOptions({ name: 'MonthlyWorkReportIndex' });
const props = defineProps<{
teamContext?: TeamViewContext | null;
}>();
const emit = defineEmits<{
(e: 'create'): void;
(e: 'edit', row: WorkReportRow): void;
@@ -45,21 +54,33 @@ const { hasAuth } = useAuth();
const exporting = ref(false);
const selectedRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]);
const searchParams = reactive(createMonthlySearchParams());
const teamSummaryLoading = ref(false);
const teamSummary = ref<Api.WorkReport.Common.TeamReportSummary | null>(null);
const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
edit: markRaw(IconMdiPencilOutline),
submit: markRaw(IconMdiSendOutline),
delete: markRaw(IconMdiDeleteOutline),
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
export: markRaw(IconMdiDownloadOutline)
};
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
const currentTeamReporterIds = computed(() => resolveTeamQueryUserIds(props.teamContext));
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('monthly', isTeamMode.value));
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetMonthlyReportPage>>,
Api.WorkReport.Monthly.MonthlyReport
>({
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
api: () => fetchGetMonthlyReportPage(searchParams),
api: () =>
fetchGetMonthlyReportPage({
...searchParams,
reporterIds: currentTeamReporterIds.value
}),
transform: response => transformWorkReportPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
@@ -67,6 +88,7 @@ const table = useUIPaginatedTable<
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
...(isTeamMode.value ? [{ prop: 'reporterName', label: '提交人', minWidth: 100, showOverflowTooltip: true }] : []),
{ prop: 'periodLabel', label: '月份', minWidth: 80, formatter: row => formatPeriod(row) },
{
prop: 'reporterDeptName',
@@ -93,7 +115,7 @@ const table = useUIPaginatedTable<
{
prop: 'operate',
label: '操作',
width: 180,
width: isTeamMode.value ? 140 : 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
@@ -101,17 +123,53 @@ const table = useUIPaginatedTable<
]
});
const summaryPeriod = computed(() =>
resolveWorkReportSummaryPeriod('monthly', {
currentRow: table.data.value[0],
periodRange: searchParams.periodStartDate
})
);
function getRowActions(row: Api.WorkReport.Monthly.MonthlyReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
label: '查看',
buttonType: 'primary',
icon: ACTION_ICON_MAP.detail,
onClick: () => emit('detail', row)
}
];
if (isTeamMode.value) {
actions.push({
key: 'export',
label: '导出',
buttonType: 'success',
icon: ACTION_ICON_MAP.export,
onClick: () =>
exportReportContent(
{
exportAll: false,
ids: [row.id]
},
1
)
});
if (['approved', 'rejected'].includes(row.statusCode)) {
actions.push({
key: 'approval-record',
label: '审批记录',
buttonType: 'info',
icon: ACTION_ICON_MAP.approvalRecord,
onClick: () => emit('approvalRecord', row)
});
}
return actions;
}
if (['draft', 'rejected'].includes(row.statusCode) && row.allowEdit && hasAuth('project:work-report:update')) {
actions.push({
key: 'edit',
@@ -154,6 +212,7 @@ function getRowActions(row: Api.WorkReport.Monthly.MonthlyReport): BusinessTable
async function reload(page?: number) {
await table.getDataByPage(page ?? searchParams.pageNo ?? 1);
await loadTeamSummary();
}
function resetSearchParams() {
@@ -209,7 +268,10 @@ function handleSelectionChange(rows: Api.WorkReport.Monthly.MonthlyReport[]) {
function createExportSearchParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return params;
return {
...params,
reporterIds: currentTeamReporterIds.value
};
}
async function exportReportContent(
@@ -267,6 +329,23 @@ async function handleExportCommand(command: 'selected' | 'all') {
await handleExportAll();
}
async function loadTeamSummary() {
if (!isTeamRootSelected.value) {
teamSummaryLoading.value = false;
teamSummary.value = null;
return;
}
teamSummaryLoading.value = true;
const { error, data } = await fetchGetTeamReportSummary({
reportType: 'monthly',
periodKey: summaryPeriod.value.periodKey
});
teamSummaryLoading.value = false;
teamSummary.value = error || !data ? null : data;
}
defineExpose({ reload });
</script>
@@ -274,11 +353,21 @@ defineExpose({ reload });
<div class="flex-col-stretch gap-16px overflow-hidden">
<MonthlyReportSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<TeamReportSummary
v-if="isTeamRootSelected"
report-type="monthly"
:period-key="summaryPeriod.periodKey"
:period-label="formatPeriod(summaryPeriod)"
:loading="teamSummaryLoading"
:summary="teamSummary"
@reminded="loadTeamSummary"
/>
<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">{{ WORK_REPORT_TYPE_LABEL.monthly }}</p>
<p class="text-16px font-600">{{ reportTitle }}</p>
<ElTag effect="plain">{{ table.mobilePagination.value.total || 0 }}</ElTag>
</div>
@@ -304,7 +393,13 @@ defineExpose({ reload });
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton v-auth="'project:work-report:create'" plain type="primary" @click="emit('create')">
<ElButton
v-if="!isTeamMode"
v-auth="'project:work-report:create'"
plain
type="primary"
@click="emit('create')"
>
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>

View File

@@ -1,18 +1,19 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { markRaw, reactive, ref } from 'vue';
import { computed, markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag } from 'element-plus';
import {
fetchDeleteProjectReport,
fetchExportProjectReportContent,
fetchGetProjectReportPage,
fetchGetTeamReportSummary,
fetchSubmitProjectReport
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import { type TeamViewContext, resolveTeamQueryUserIds } from '@/views/personal-center/shared/team-dashboard';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
createProjectSearchParams,
createWorkReportContentExportFallbackName,
@@ -20,22 +21,26 @@ import {
formatDateTime,
formatEmptyText,
formatPeriod,
getProjectReportFlagLabel,
getWorkReportStatusLabel,
getWorkReportTypeDisplayLabel,
resolveExportFilename,
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import { resolveWorkReportSummaryPeriod } from '../shared/utils';
import TeamReportSummary from '../shared/components/team-report-summary.vue';
import ProjectReportSearch from './modules/search-panel.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiSendOutline from '~icons/mdi/send-outline';
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
defineOptions({ name: 'ProjectWorkReportIndex' });
const props = defineProps<{
teamContext?: TeamViewContext | null;
projectOptions: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
projectOptionsLoaded: boolean;
}>();
@@ -51,21 +56,33 @@ const { hasAuth } = useAuth();
const exporting = ref(false);
const selectedRows = ref<Api.WorkReport.Project.ProjectReport[]>([]);
const searchParams = reactive(createProjectSearchParams());
const teamSummaryLoading = ref(false);
const teamSummary = ref<Api.WorkReport.Common.TeamReportSummary | null>(null);
const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
edit: markRaw(IconMdiPencilOutline),
submit: markRaw(IconMdiSendOutline),
delete: markRaw(IconMdiDeleteOutline),
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
export: markRaw(IconMdiDownloadOutline)
};
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
const currentProjectOwnerIds = computed(() => resolveTeamQueryUserIds(props.teamContext));
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('project', isTeamMode.value));
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetProjectReportPage>>,
Api.WorkReport.Project.ProjectReport
>({
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
api: () => fetchGetProjectReportPage(searchParams),
api: () =>
fetchGetProjectReportPage({
...searchParams,
projectOwnerIds: currentProjectOwnerIds.value
}),
transform: response => transformWorkReportPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
@@ -73,9 +90,11 @@ const table = useUIPaginatedTable<
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
...(isTeamMode.value
? [{ prop: 'projectOwnerName', label: '提交人', minWidth: 100, showOverflowTooltip: true }]
: []),
{ prop: 'projectName', label: '项目名称', minWidth: 200, showOverflowTooltip: true },
{ prop: 'periodLabel', label: '半月周期', minWidth: 120, formatter: row => formatPeriod(row) },
// { prop: 'flag', label: '半月', width: 90, formatter: row => getProjectReportFlagLabel(row.flag) },
{ prop: 'projectOwnerName', label: '项目负责人', minWidth: 80 },
{
prop: 'technicalOwnerName',
@@ -101,7 +120,7 @@ const table = useUIPaginatedTable<
{
prop: 'operate',
label: '操作',
width: 180,
width: isTeamMode.value ? 140 : 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
@@ -109,17 +128,54 @@ const table = useUIPaginatedTable<
]
});
const summaryPeriod = computed(() =>
resolveWorkReportSummaryPeriod('project', {
currentRow: table.data.value[0],
periodRange: searchParams.periodStartDate,
flag: searchParams.flag
})
);
function getRowActions(row: Api.WorkReport.Project.ProjectReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
label: '查看',
buttonType: 'primary',
icon: ACTION_ICON_MAP.detail,
onClick: () => emit('detail', row)
}
];
if (isTeamMode.value) {
actions.push({
key: 'export',
label: '导出',
buttonType: 'success',
icon: ACTION_ICON_MAP.export,
onClick: () =>
exportReportContent(
{
exportAll: false,
ids: [row.id]
},
1
)
});
if (['approved', 'rejected'].includes(row.statusCode)) {
actions.push({
key: 'approval-record',
label: '审批记录',
buttonType: 'info',
icon: ACTION_ICON_MAP.approvalRecord,
onClick: () => emit('approvalRecord', row)
});
}
return actions;
}
if (['draft', 'rejected'].includes(row.statusCode) && row.allowEdit && hasAuth('project:work-report:update')) {
actions.push({
key: 'edit',
@@ -162,6 +218,7 @@ function getRowActions(row: Api.WorkReport.Project.ProjectReport): BusinessTable
async function reload(page?: number) {
await table.getDataByPage(page ?? searchParams.pageNo ?? 1);
await loadTeamSummary();
}
function resetSearchParams() {
@@ -217,7 +274,10 @@ function handleSelectionChange(rows: Api.WorkReport.Project.ProjectReport[]) {
function createExportSearchParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return params;
return {
...params,
projectOwnerIds: currentProjectOwnerIds.value
};
}
async function exportReportContent(
@@ -275,6 +335,23 @@ async function handleExportCommand(command: 'selected' | 'all') {
await handleExportAll();
}
async function loadTeamSummary() {
if (!isTeamRootSelected.value) {
teamSummaryLoading.value = false;
teamSummary.value = null;
return;
}
teamSummaryLoading.value = true;
const { error, data } = await fetchGetTeamReportSummary({
reportType: 'project',
periodKey: summaryPeriod.value.periodKey
});
teamSummaryLoading.value = false;
teamSummary.value = error || !data ? null : data;
}
defineExpose({ reload });
</script>
@@ -292,11 +369,21 @@ defineExpose({ reload });
@search="handleSearch"
/>
<TeamReportSummary
v-if="isTeamRootSelected"
report-type="project"
:period-key="summaryPeriod.periodKey"
:period-label="formatPeriod(summaryPeriod)"
:loading="teamSummaryLoading"
:summary="teamSummary"
@reminded="loadTeamSummary"
/>
<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">{{ WORK_REPORT_TYPE_LABEL.project }}</p>
<p class="text-16px font-600">{{ reportTitle }}</p>
<ElTag effect="plain">{{ table.mobilePagination.value.total || 0 }}</ElTag>
</div>
@@ -322,7 +409,13 @@ defineExpose({ reload });
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton v-auth="'project:work-report:create'" plain type="primary" @click="emit('create')">
<ElButton
v-if="!isTeamMode"
v-auth="'project:work-report:create'"
plain
type="primary"
@click="emit('create')"
>
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>

View File

@@ -0,0 +1,217 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { fetchRemindTeamReport } from '@/service/api';
defineOptions({ name: 'TeamReportSummary' });
interface Props {
reportType: Api.WorkReport.Common.ReportType;
periodKey: string;
periodLabel?: string;
loading?: boolean;
summary?: Api.WorkReport.Common.TeamReportSummary | null;
}
const props = withDefaults(defineProps<Props>(), {
periodLabel: '',
loading: false,
summary: null
});
const emit = defineEmits<{
reminded: [];
}>();
const remindingAll = ref(false);
const remindingUserId = ref('');
const cards = computed(() => [
{ label: '应填人数', value: props.summary?.totalShouldSubmit ?? 0 },
{ label: '已提交', value: props.summary?.submittedCount ?? 0 },
{ label: '待提交', value: props.summary?.unsubmittedCount ?? 0, key: 'unsubmitted' as const },
{ label: '待审批', value: props.summary?.pendingApprovalCount ?? 0 }
]);
async function handleRemind(userIds?: string[]) {
const targetUserId = userIds?.length === 1 ? userIds[0] : '';
if (targetUserId) {
remindingUserId.value = targetUserId;
} else {
remindingAll.value = true;
}
const { error, data } = await fetchRemindTeamReport({
reportType: props.reportType,
periodKey: props.periodKey,
userIds
});
if (!targetUserId) {
remindingAll.value = false;
}
remindingUserId.value = '';
if (error) return;
window.$message?.success(`已催办 ${data?.remindedCount ?? 0}`);
emit('reminded');
}
</script>
<template>
<div v-loading="props.loading" class="team-report-summary">
<div v-if="props.periodLabel" class="team-report-summary__period">{{ props.periodLabel }}</div>
<div class="team-report-summary__grid">
<div v-for="card in cards" :key="card.label" class="team-report-summary__item">
<div class="team-report-summary__label">{{ card.label }}</div>
<div class="team-report-summary__value">
<template v-if="card.key === 'unsubmitted'">
<ElPopover placement="bottom" :width="300" trigger="hover">
<template #reference>
<button type="button" class="team-report-summary__link-button">{{ card.value }}</button>
</template>
<div class="team-report-summary__popover">
<div class="team-report-summary__popover-title">未提交人员</div>
<div v-if="props.summary?.unsubmittedUsers?.length" class="team-report-summary__user-list">
<div
v-for="user in props.summary.unsubmittedUsers"
:key="user.userId"
class="team-report-summary__user-item"
>
<span class="team-report-summary__user-name">{{ user.userNickname }}</span>
<ElButton
link
type="primary"
:loading="remindingUserId === user.userId"
@click="handleRemind([user.userId])"
>
催办
</ElButton>
</div>
</div>
<ElEmpty v-else :image-size="60" description="暂无待提交人员" />
<div class="team-report-summary__popover-footer">
<ElButton
size="small"
type="primary"
plain
:loading="remindingAll"
:disabled="!props.summary?.unsubmittedUsers?.length"
@click="handleRemind()"
>
一键催办全部
</ElButton>
</div>
</div>
</ElPopover>
</template>
<template v-else>
{{ card.value }}
</template>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.team-report-summary {
display: grid;
gap: 12px;
}
.team-report-summary__period {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.team-report-summary__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.team-report-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-report-summary__label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.team-report-summary__value {
color: var(--el-text-color-primary);
font-size: 22px;
font-weight: 600;
line-height: 1.2;
}
.team-report-summary__link-button {
padding: 0;
border: none;
background: transparent;
color: var(--el-color-primary);
font: inherit;
cursor: pointer;
}
.team-report-summary__popover {
display: grid;
gap: 10px;
}
.team-report-summary__popover-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.team-report-summary__user-list {
display: grid;
gap: 8px;
max-height: 240px;
overflow: auto;
}
.team-report-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);
}
.team-report-summary__user-name {
min-width: 0;
color: var(--el-text-color-regular);
}
.team-report-summary__popover-footer {
display: flex;
justify-content: flex-end;
}
@media (width <= 1200px) {
.team-report-summary__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 768px) {
.team-report-summary__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -41,6 +41,16 @@ export const WORK_REPORT_TYPE_LABEL: Record<WorkReportType, string> = {
project: '项目半月报'
};
export const TEAM_WORK_REPORT_TYPE_LABEL: Record<WorkReportType, string> = {
weekly: '团队周报',
monthly: '团队月报',
project: '团队项目半月报'
};
export function getWorkReportTypeDisplayLabel(reportType: WorkReportType, isTeamMode = false) {
return isTeamMode ? TEAM_WORK_REPORT_TYPE_LABEL[reportType] : WORK_REPORT_TYPE_LABEL[reportType];
}
export const WORK_REPORT_STATUS_LABEL: Record<string, string> = {
draft: '待提交',
pending_approval: '待审批',

View File

@@ -20,6 +20,14 @@ export interface WorkReportPeriodOption {
};
}
export interface WorkReportResolvedPeriod {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
flag?: number;
}
function formatRangeLabel(start: dayjs.Dayjs, end: dayjs.Dayjs) {
return `${start.format('YYYY-MM-DD')}${end.format('YYYY-MM-DD')}`;
}
@@ -192,3 +200,72 @@ export function getReportTypePeriodOptions(now = dayjs()) {
project: getProjectPeriodOptions(now)
} as const;
}
type PeriodRange = string[] | null | undefined;
interface ResolveWorkReportSummaryPeriodOptions {
currentRow?: Partial<WorkReportResolvedPeriod> | null;
periodRange?: PeriodRange;
flag?: number | null;
}
function getRangeReferenceDate(periodRange?: PeriodRange) {
const [, endDate] = periodRange || [];
return endDate || periodRange?.[0] || '';
}
function inferProjectSummaryFlag(referenceDate: string, explicitFlag?: number | null) {
if (explicitFlag === 1 || explicitFlag === 2) {
return explicitFlag;
}
const selectedDate = dayjs(referenceDate);
if (selectedDate.isValid()) {
return selectedDate.date() > 15 ? 2 : 1;
}
return getProjectPeriodOptions()[0]?.flag || 1;
}
export function resolveWorkReportSummaryPeriod(
reportType: WorkReportType,
options: ResolveWorkReportSummaryPeriodOptions = {}
): WorkReportResolvedPeriod {
const { currentRow, periodRange, flag } = options;
if (currentRow?.periodKey && currentRow.periodStartDate && currentRow.periodEndDate) {
return {
periodKey: currentRow.periodKey,
periodLabel: currentRow.periodLabel || '',
periodStartDate: currentRow.periodStartDate,
periodEndDate: currentRow.periodEndDate,
flag: currentRow.flag
};
}
const referenceDate = getRangeReferenceDate(periodRange);
const fallbackNow = dayjs();
if (reportType === 'weekly') {
return referenceDate ? buildWeeklyPeriodFromDate(referenceDate) : buildWeeklyPeriodFromDate(fallbackNow);
}
if (reportType === 'monthly') {
return referenceDate ? buildMonthlyPeriodFromMonth(referenceDate) : buildMonthlyPeriodFromMonth(fallbackNow);
}
if (referenceDate) {
const resolvedFlag = inferProjectSummaryFlag(referenceDate, flag);
return {
...buildProjectPeriodFromMonth(referenceDate, resolvedFlag),
flag: resolvedFlag
};
}
const fallbackOption = getProjectPeriodOptions(fallbackNow)[0];
return {
...fallbackOption.period,
flag: fallbackOption.flag
};
}

View File

@@ -1,18 +1,19 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { markRaw, reactive, ref } from 'vue';
import { computed, markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag, ElTooltip } from 'element-plus';
import {
fetchDeleteWeeklyReport,
fetchExportWeeklyReportContent,
fetchGetTeamReportSummary,
fetchGetWeeklyReportPage,
fetchSubmitWeeklyReport
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import { type TeamViewContext, resolveTeamQueryUserIds } from '@/views/personal-center/shared/team-dashboard';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
createWeeklySearchParams,
createWorkReportContentExportFallbackName,
@@ -23,19 +24,27 @@ import {
formatPeriodDateRange,
formatWeeklyPeriodLabel,
getWorkReportStatusLabel,
getWorkReportTypeDisplayLabel,
resolveExportFilename,
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import { resolveWorkReportSummaryPeriod } from '../shared/utils';
import TeamReportSummary from '../shared/components/team-report-summary.vue';
import WeeklyReportSearch from './modules/search-panel.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiSendOutline from '~icons/mdi/send-outline';
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
defineOptions({ name: 'WeeklyWorkReportIndex' });
const props = defineProps<{
teamContext?: TeamViewContext | null;
}>();
const emit = defineEmits<{
(e: 'create'): void;
(e: 'edit', row: WorkReportRow): void;
@@ -47,21 +56,33 @@ const { hasAuth } = useAuth();
const exporting = ref(false);
const selectedRows = ref<Api.WorkReport.Weekly.WeeklyReport[]>([]);
const searchParams = reactive(createWeeklySearchParams());
const teamSummaryLoading = ref(false);
const teamSummary = ref<Api.WorkReport.Common.TeamReportSummary | null>(null);
const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
edit: markRaw(IconMdiPencilOutline),
submit: markRaw(IconMdiSendOutline),
delete: markRaw(IconMdiDeleteOutline),
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
export: markRaw(IconMdiDownloadOutline)
};
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
const currentTeamReporterIds = computed(() => resolveTeamQueryUserIds(props.teamContext));
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('weekly', isTeamMode.value));
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetWeeklyReportPage>>,
Api.WorkReport.Weekly.WeeklyReport
>({
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
api: () => fetchGetWeeklyReportPage(searchParams),
api: () =>
fetchGetWeeklyReportPage({
...searchParams,
reporterIds: currentTeamReporterIds.value
}),
transform: response => transformWorkReportPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
@@ -69,6 +90,7 @@ const table = useUIPaginatedTable<
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
...(isTeamMode.value ? [{ prop: 'reporterName', label: '提交人', minWidth: 100, showOverflowTooltip: true }] : []),
{
prop: 'periodLabel',
label: '周期',
@@ -122,7 +144,7 @@ const table = useUIPaginatedTable<
{
prop: 'operate',
label: '操作',
width: 180,
width: isTeamMode.value ? 140 : 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
@@ -130,17 +152,53 @@ const table = useUIPaginatedTable<
]
});
const summaryPeriod = computed(() =>
resolveWorkReportSummaryPeriod('weekly', {
currentRow: table.data.value[0],
periodRange: searchParams.periodStartDate
})
);
function getRowActions(row: Api.WorkReport.Weekly.WeeklyReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
label: '查看',
buttonType: 'primary',
icon: ACTION_ICON_MAP.detail,
onClick: () => emit('detail', row)
}
];
if (isTeamMode.value) {
actions.push({
key: 'export',
label: '导出',
buttonType: 'success',
icon: ACTION_ICON_MAP.export,
onClick: () =>
exportReportContent(
{
exportAll: false,
ids: [row.id]
},
1
)
});
if (['approved', 'rejected'].includes(row.statusCode)) {
actions.push({
key: 'approval-record',
label: '审批记录',
buttonType: 'info',
icon: ACTION_ICON_MAP.approvalRecord,
onClick: () => emit('approvalRecord', row)
});
}
return actions;
}
if (['draft', 'rejected'].includes(row.statusCode) && row.allowEdit && hasAuth('project:work-report:update')) {
actions.push({
key: 'edit',
@@ -183,6 +241,7 @@ function getRowActions(row: Api.WorkReport.Weekly.WeeklyReport): BusinessTableAc
async function reload(page?: number) {
await table.getDataByPage(page ?? searchParams.pageNo ?? 1);
await loadTeamSummary();
}
function resetSearchParams() {
@@ -238,7 +297,10 @@ function handleSelectionChange(rows: Api.WorkReport.Weekly.WeeklyReport[]) {
function createExportSearchParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return params;
return {
...params,
reporterIds: currentTeamReporterIds.value
};
}
async function exportReportContent(
@@ -296,6 +358,23 @@ async function handleExportCommand(command: 'selected' | 'all') {
await handleExportAll();
}
async function loadTeamSummary() {
if (!isTeamRootSelected.value) {
teamSummaryLoading.value = false;
teamSummary.value = null;
return;
}
teamSummaryLoading.value = true;
const { error, data } = await fetchGetTeamReportSummary({
reportType: 'weekly',
periodKey: summaryPeriod.value.periodKey
});
teamSummaryLoading.value = false;
teamSummary.value = error || !data ? null : data;
}
defineExpose({ reload });
</script>
@@ -303,11 +382,21 @@ defineExpose({ reload });
<div class="flex-col-stretch gap-16px overflow-hidden">
<WeeklyReportSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<TeamReportSummary
v-if="isTeamRootSelected"
report-type="weekly"
:period-key="summaryPeriod.periodKey"
:period-label="formatWeeklyPeriodLabel(summaryPeriod)"
:loading="teamSummaryLoading"
:summary="teamSummary"
@reminded="loadTeamSummary"
/>
<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">{{ WORK_REPORT_TYPE_LABEL.weekly }}</p>
<p class="text-16px font-600">{{ reportTitle }}</p>
<ElTag effect="plain">{{ table.mobilePagination.value.total || 0 }}</ElTag>
</div>
@@ -333,7 +422,13 @@ defineExpose({ reload });
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton v-auth="'project:work-report:create'" plain type="primary" @click="emit('create')">
<ElButton
v-if="!isTeamMode"
v-auth="'project:work-report:create'"
plain
type="primary"
@click="emit('create')"
>
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props, unicorn/prefer-dom-node-text-content, no-useless-escape, no-nested-ternary */
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { Plus } from '@element-plus/icons-vue';
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetMyParticipatedProjectPage } from '@/service/api';
@@ -1065,6 +1065,34 @@ function blurEditField(key: string) {
if (activeEditField.value === key) activeEditField.value = '';
}
/** 编辑态下是否显示"具体工作内容"的结构化预览(含 ElPopover 工作日志) */
function showContentStructuredView(index: number) {
const item = reviewItems.value[index];
if (!item?.contentSections?.length) return false;
if (isReadonly.value) return true;
// 编辑/新增模式下,仅在该字段未聚焦时显示结构化预览
return activeEditField.value !== `content-${index}`;
}
/** 编辑态下是否显示"具体目标"的结构化预览(含 ElPopover 工作日志) */
function showTargetStructuredView(index: number) {
const item = nextPlans.value[index];
if (!item?.targetSections?.length) return false;
if (isReadonly.value) return true;
// 编辑/新增模式下,仅在该字段未聚焦时显示结构化预览
return activeEditField.value !== `target-${index}`;
}
/** 点击结构化预览区域时切换到编辑态并聚焦 */
function handleStructuredViewClick(fieldKey: string) {
if (isReadonly.value) return;
activeEditField.value = fieldKey;
nextTick(() => {
const editor = document.querySelector(`[data-field-key="${fieldKey}"]`) as HTMLElement;
editor?.focus();
});
}
function syncRichContent(item: ReviewItem, event: Event) {
const target = event.currentTarget as HTMLElement;
if (!item.source) return;
@@ -1183,7 +1211,12 @@ function syncRichSupport(item: PlanItem, event: Event) {
<div class="review-editor-grid">
<div class="field">
<label>具体工作内容及成果描述</label>
<div v-if="isReadonly && item.contentSections?.length" class="rich-editor">
<div
v-if="showContentStructuredView(index)"
class="rich-editor"
:class="{ 'rich-editor--preview': !isReadonly }"
@click="handleStructuredViewClick(`content-${index}`)"
>
<div
v-for="(section, sectionIndex) in item.contentSections"
:key="`${index}-${sectionIndex}`"
@@ -1214,6 +1247,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
class="rich-editor"
:contenteditable="!isReadonly"
spellcheck="false"
:data-field-key="`content-${index}`"
:data-placeholder="isReadonly ? undefined : '请输入具体工作内容及成果描述'"
@focus="focusEditField(`content-${index}`)"
@blur="
@@ -1282,7 +1316,12 @@ function syncRichSupport(item: PlanItem, event: Event) {
<div class="plan-editor-grid">
<div class="field">
<label>具体目标</label>
<div v-if="isReadonly && item.targetSections?.length" class="rich-editor">
<div
v-if="showTargetStructuredView(index)"
class="rich-editor"
:class="{ 'rich-editor--preview': !isReadonly }"
@click="handleStructuredViewClick(`target-${index}`)"
>
<div
v-for="(section, sectionIndex) in item.targetSections"
:key="`${index}-${sectionIndex}`"
@@ -1313,6 +1352,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
class="rich-editor"
:contenteditable="!isReadonly"
spellcheck="false"
:data-field-key="`target-${index}`"
:data-placeholder="isReadonly ? undefined : '请输入具体目标'"
@focus="focusEditField(`target-${index}`)"
@blur="
@@ -2107,6 +2147,15 @@ function syncRichSupport(item: PlanItem, event: Event) {
cursor: pointer;
}
/* 编辑态下结构化预览区域:点击可切换到编辑模式 */
.rich-editor--preview {
cursor: text;
}
.rich-editor--preview:hover {
border-color: #0f766e;
}
.structured-preview__popover {
max-width: 100%;
color: #334155;