fix(加班申请、工作报告、我的绩效): 重构页面样式、修复一系列bug、对不合理的地方进行调整。

This commit is contained in:
dk
2026-06-22 23:07:21 +08:00
parent b1d52b852f
commit 632c123112
30 changed files with 1574 additions and 451 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { type ComputedRef, type Ref, computed, nextTick, onMounted, ref, watch } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { fetchGetMySubordinateTree, fetchGetProjectReportOwnerProjectOptions } from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
@@ -11,6 +11,7 @@ import {
collectSubordinateUserIds,
findSubordinateNode
} from '../shared/team-dashboard';
import TeamReportSummary from './shared/components/team-report-summary.vue';
import WorkReportCreateDialog from './shared/components/create-dialog.vue';
import WorkReportPrototypePageDialog from './shared/components/prototype-page-dialog.vue';
import WorkReportTabs from './shared/components/tabs.vue';
@@ -20,6 +21,8 @@ import {
type WorkReportType,
getWorkReportTypeDisplayLabel
} from './shared/types';
import { formatIsoWeekRangeLabel } from './shared/utils';
import type { resolveWorkReportSummaryPeriod } from './shared/utils';
import WeeklyReportIndex from './weekly/index.vue';
import WeeklyReportApprovalRecordDialog from './weekly/modules/approval-record-dialog.vue';
import MonthlyReportIndex from './monthly/index.vue';
@@ -32,6 +35,12 @@ defineOptions({ name: 'PersonalCenterWorkReport' });
type PageDialogMode = 'add' | 'edit' | 'detail';
type ReportListExpose = {
reload: (page?: number) => Promise<void>;
teamSummary: Ref<Api.WorkReport.Common.TeamReportSummary | null>;
teamSummaryLoading: Ref<boolean>;
summaryPeriod: Ref<ReturnType<typeof resolveWorkReportSummaryPeriod>>;
summaryPeriodKeys: ComputedRef<string[]>;
hasSearchedDateRange: ComputedRef<boolean>;
loadTeamSummary: () => Promise<void>;
};
const { hasAuth } = useAuth();
@@ -67,6 +76,19 @@ const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordina
const selectedSubordinateNode = computed(() =>
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.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 isRootSelected = computed(() => Boolean(selectedSubordinateNode.value?.isRoot));
const selectedTeamLabel = computed(() => {
if (teamViewMode.value === 'self') return '我自己';
@@ -118,6 +140,30 @@ function getListRef(reportType: WorkReportType) {
return weeklyRef.value;
}
const activeReportRef = computed(() => getListRef(activeTab.value));
const activeTeamSummary = computed(() => activeReportRef.value?.teamSummary ?? null);
const activeTeamSummaryLoading = computed(() => activeReportRef.value?.teamSummaryLoading ?? false);
const activeSummaryPeriodKeys = computed(() => {
const activeRef = activeReportRef.value;
if (!activeRef) return [];
return activeRef.summaryPeriodKeys ?? [];
});
const activeSummaryReportType = computed<Api.WorkReport.Common.ReportType>(() => {
if (activeTab.value === 'monthly') return 'monthly';
if (activeTab.value === 'project') return 'project';
return 'weekly';
});
const activeSummaryPeriodLabel = computed(() => {
const summaryPeriod = activeReportRef.value?.summaryPeriod;
if (!summaryPeriod) return '';
if (activeTab.value === 'weekly') {
return formatIsoWeekRangeLabel(summaryPeriod.periodStartDate, summaryPeriod.periodEndDate);
}
return summaryPeriod.periodLabel ?? '';
});
async function loadProjectOptions() {
if (!canShowProjectTab.value) return;
@@ -198,6 +244,10 @@ function handleSubmitted() {
reloadReport(currentReportType.value);
}
async function handleSummaryRemind() {
await activeReportRef.value?.loadTeamSummary();
}
function closeFloatingPanels() {
createVisible.value = false;
pageDialogVisible.value = false;
@@ -273,13 +323,24 @@ onBeforeRouteLeave(() => {
:selected-label="selectedTeamLabel"
:subordinate-count="subordinateTree?.subordinateCount || 0"
@update:mode="handleTeamViewModeChange"
/>
>
<TeamReportSummary
v-if="isRootSelected && teamViewMode === 'team'"
:report-type="activeSummaryReportType"
:period-keys="activeSummaryPeriodKeys"
:period-label="activeSummaryPeriodLabel"
:loading="activeTeamSummaryLoading"
:summary="activeTeamSummary"
@reminded="handleSummaryRemind"
/>
</TeamContextPanel>
<WeeklyReportIndex
v-show="activeTab === 'weekly'"
ref="weeklyRef"
class="flex-1-hidden"
:team-context="teamContext"
:subordinate-options="subordinateOptions"
@create="openCreate('weekly')"
@edit="openEdit('weekly', $event)"
@detail="openDetail('weekly', $event)"
@@ -291,6 +352,7 @@ onBeforeRouteLeave(() => {
ref="monthlyRef"
class="flex-1-hidden"
:team-context="teamContext"
:subordinate-options="subordinateOptions"
@create="openCreate('monthly')"
@edit="openEdit('monthly', $event)"
@detail="openDetail('monthly', $event)"

View File

@@ -1,7 +1,8 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { computed, markRaw, reactive, ref } from 'vue';
import { computed, markRaw, reactive, ref, watch } from 'vue';
import { ElMessageBox, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import {
fetchDeleteMonthlyReport,
fetchExportMonthlyReportContent,
@@ -12,7 +13,7 @@ import {
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 { type TeamViewContext } from '@/views/personal-center/shared/team-dashboard';
import {
type WorkReportRow,
createMonthlySearchParams,
@@ -27,8 +28,7 @@ import {
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import { resolveWorkReportSummaryPeriod } from '../shared/utils';
import TeamReportSummary from '../shared/components/team-report-summary.vue';
import { buildMonthlyPeriodFromMonth, resolveWorkReportSummaryPeriod } from '../shared/utils';
import MonthlyReportSearch from './modules/search-panel.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
@@ -41,6 +41,7 @@ defineOptions({ name: 'MonthlyWorkReportIndex' });
const props = defineProps<{
teamContext?: TeamViewContext | null;
subordinateOptions?: Array<{ label: string; value: string }>;
}>();
const emit = defineEmits<{
@@ -68,8 +69,45 @@ const ACTION_ICON_MAP = {
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
const currentTeamReporterIds = computed(() => resolveTeamQueryUserIds(props.teamContext));
const currentTeamReporterIds = computed(() => {
if (!isTeamMode.value) {
return null;
}
if (isTeamRootSelected.value) {
return [];
}
return props.teamContext?.selectedUserIds ?? [];
});
const resolvedTeamReporterIds = computed(() => {
if (!isTeamMode.value) {
return undefined;
}
if (searchParams.reporterIds?.length) {
return searchParams.reporterIds;
}
return currentTeamReporterIds.value;
});
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('monthly', isTeamMode.value));
const normalizedPeriodRange = computed(() => {
const periodRange = searchParams.periodStartDate;
if (!periodRange?.length) {
return periodRange;
}
const [startDate, endDate] = periodRange;
const start = dayjs(startDate);
const end = dayjs(endDate || startDate);
if (!start.isValid() || !end.isValid()) {
return periodRange;
}
return [start.startOf('month').format('YYYY-MM-DD'), end.endOf('month').format('YYYY-MM-DD')];
});
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetMonthlyReportPage>>,
@@ -79,52 +117,104 @@ const table = useUIPaginatedTable<
api: () =>
fetchGetMonthlyReportPage({
...searchParams,
reporterIds: currentTeamReporterIds.value
periodStartDate: normalizedPeriodRange.value,
reporterIds: resolvedTeamReporterIds.value
}),
transform: response => transformWorkReportPage(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: 'reporterName', label: '提交人', minWidth: 100, showOverflowTooltip: true }] : []),
{ prop: 'periodLabel', label: '月份', minWidth: 80, formatter: row => formatPeriod(row) },
{
prop: 'reporterDeptName',
label: '部门',
minWidth: 80,
showOverflowTooltip: true,
formatter: row => row.reporterDeptName || '--'
},
{ prop: 'supervisorName', label: '直属上级', minWidth: 80 },
{ prop: 'totalWorkHours', label: '总工时', minWidth: 80, formatter: row => formatEmptyText(row.totalWorkHours) },
{
prop: 'statusCode',
label: '状态',
minWidth: 80,
align: 'center',
formatter: row => (
<ElTag type={resolveWorkReportStatusTagType(row.statusCode)}>
{getWorkReportStatusLabel(row.statusCode, row.statusName)}
</ElTag>
)
},
{ prop: 'submitTime', label: '提交时间', minWidth: 100, formatter: row => formatDateTime(row.submitTime) },
{ prop: 'approvalTime', label: '审批时间', minWidth: 100, formatter: row => formatDateTime(row.approvalTime) },
{
prop: 'operate',
label: '操作',
width: isTeamMode.value ? 140 : 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
columns: () => {
const cols: UI.TableColumn<Api.WorkReport.Monthly.MonthlyReport>[] = [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'periodLabel', label: '月份', minWidth: 80, formatter: row => formatPeriod(row) }
];
if (isTeamMode.value) {
cols.push({ prop: 'reporterName', label: '提交人', minWidth: 100, showOverflowTooltip: true });
}
]
cols.push(
{
prop: 'reporterDeptName',
label: '部门',
minWidth: 80,
showOverflowTooltip: true,
formatter: row => row.reporterDeptName || '--'
},
{ prop: 'supervisorName', label: '直属上级', minWidth: 80 },
{ prop: 'totalWorkHours', label: '总工时', minWidth: 80, formatter: row => formatEmptyText(row.totalWorkHours) },
{
prop: 'statusCode',
label: '状态',
minWidth: 80,
align: 'center',
formatter: row => (
<ElTag type={resolveWorkReportStatusTagType(row.statusCode)}>
{getWorkReportStatusLabel(row.statusCode, row.statusName)}
</ElTag>
)
},
{ prop: 'submitTime', label: '提交时间', minWidth: 100, formatter: row => formatDateTime(row.submitTime) },
{ prop: 'approvalTime', label: '审批时间', minWidth: 100, formatter: row => formatDateTime(row.approvalTime) },
{
prop: 'operate',
label: '操作',
width: isTeamMode.value ? 140 : 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
);
return cols;
}
});
// 团队统计始终使用当前周期(本月),不跟随列表第一条数据的周期
const summaryPeriod = computed(() => resolveWorkReportSummaryPeriod('monthly'));
const summaryPeriod = computed(() =>
resolveWorkReportSummaryPeriod('monthly', {
periodRange: normalizedPeriodRange.value
})
);
const summaryPeriodKeys = computed(() => {
const dateRange = normalizedPeriodRange.value;
const fallbackKey = summaryPeriod.value.periodKey;
if (!dateRange?.length) {
return fallbackKey ? [fallbackKey] : [];
}
const [startDate, endDate] = dateRange;
const start = dayjs(startDate);
const end = dayjs(endDate || startDate);
if (!start.isValid() || !end.isValid()) {
return fallbackKey ? [fallbackKey] : [];
}
const keys: string[] = [];
const endBoundary = end.endOf('month');
for (
let cursor = start.startOf('month');
cursor.isBefore(endBoundary, 'month') || cursor.isSame(endBoundary, 'month');
cursor = cursor.add(1, 'month')
) {
keys.push(buildMonthlyPeriodFromMonth(cursor).periodKey);
}
return keys;
});
const hasSearchedDateRange = computed(() => searchParams.periodStartDate?.length === 2);
watch(
() => isTeamMode.value,
() => {
table.reloadColumns();
}
);
function getRowActions(row: Api.WorkReport.Monthly.MonthlyReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
@@ -266,7 +356,8 @@ function createExportSearchParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return {
...params,
reporterIds: currentTeamReporterIds.value
periodStartDate: normalizedPeriodRange.value,
reporterIds: resolvedTeamReporterIds.value
};
}
@@ -332,31 +423,42 @@ async function loadTeamSummary() {
return;
}
const dateRange = normalizedPeriodRange.value;
const summaryParams: Api.WorkReport.Common.TeamReportSummaryParams = { reportType: 'monthly' };
if (dateRange?.length === 2) {
summaryParams.periodStartDate = dateRange[0];
summaryParams.periodEndDate = dateRange[1];
} else {
summaryParams.periodKey = summaryPeriod.value.periodKey;
}
teamSummaryLoading.value = true;
const { error, data } = await fetchGetTeamReportSummary({
reportType: 'monthly',
periodKey: summaryPeriod.value.periodKey
});
const { error, data } = await fetchGetTeamReportSummary(summaryParams);
teamSummaryLoading.value = false;
teamSummary.value = error || !data ? null : data;
}
defineExpose({ reload });
defineExpose({
reload,
teamSummary,
teamSummaryLoading,
summaryPeriod,
summaryPeriodKeys,
hasSearchedDateRange,
loadTeamSummary
});
</script>
<template>
<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"
<MonthlyReportSearch
v-model:model="searchParams"
:team-mode="isTeamMode"
:subordinate-options="props.subordinateOptions"
@reset="resetSearchParams"
@search="handleSearch"
/>
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">

View File

@@ -4,6 +4,8 @@ import SharedWorkReportSearch from '../../shared/components/search-panel.vue';
defineOptions({ name: 'MonthlyReportSearch' });
defineProps<{
teamMode?: boolean;
subordinateOptions?: Array<{ label: string; value: string }>;
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}>();
@@ -19,6 +21,8 @@ const emit = defineEmits<{
<SharedWorkReportSearch
v-model:model="model"
report-type="monthly"
:team-mode="teamMode"
:subordinate-options="subordinateOptions"
:project-options="projectOptions"
@reset="emit('reset')"
@search="emit('search')"

View File

@@ -2,6 +2,7 @@
/* eslint-disable no-void */
import { computed, markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import {
fetchDeleteProjectReport,
fetchExportProjectReportContent,
@@ -27,8 +28,7 @@ import {
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import { resolveWorkReportSummaryPeriod } from '../shared/utils';
import TeamReportSummary from '../shared/components/team-report-summary.vue';
import { buildProjectPeriodFromMonth, resolveWorkReportSummaryPeriod } from '../shared/utils';
import ProjectReportSearch from './modules/search-panel.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
@@ -72,6 +72,22 @@ 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 normalizedPeriodRange = computed(() => {
const periodRange = searchParams.periodStartDate;
if (!periodRange?.length) {
return periodRange;
}
const [startDate, endDate] = periodRange;
const start = dayjs(startDate);
const end = dayjs(endDate || startDate);
if (!start.isValid() || !end.isValid()) {
return periodRange;
}
return [start.startOf('month').format('YYYY-MM-DD'), end.endOf('month').format('YYYY-MM-DD')];
});
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetProjectReportPage>>,
@@ -81,6 +97,7 @@ const table = useUIPaginatedTable<
api: () =>
fetchGetProjectReportPage({
...searchParams,
periodStartDate: normalizedPeriodRange.value,
projectOwnerIds: currentProjectOwnerIds.value
}),
transform: response => transformWorkReportPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
@@ -129,7 +146,42 @@ const table = useUIPaginatedTable<
});
// 团队统计始终使用当前周期(当前半月),不跟随列表第一条数据的周期
const summaryPeriod = computed(() => resolveWorkReportSummaryPeriod('project'));
const summaryPeriod = computed(() =>
resolveWorkReportSummaryPeriod('project', {
periodRange: normalizedPeriodRange.value
})
);
const summaryPeriodKeys = computed(() => {
const dateRange = normalizedPeriodRange.value;
const fallbackKey = summaryPeriod.value.periodKey;
if (!dateRange?.length) {
return fallbackKey ? [fallbackKey] : [];
}
const [startDate, endDate] = dateRange;
const start = dayjs(startDate);
const end = dayjs(endDate || startDate);
if (!start.isValid() || !end.isValid()) {
return fallbackKey ? [fallbackKey] : [];
}
const keys: string[] = [];
const endBoundary = end.endOf('month');
for (
let cursor = start.startOf('month');
cursor.isBefore(endBoundary, 'month') || cursor.isSame(endBoundary, 'month');
cursor = cursor.add(1, 'month')
) {
keys.push(buildProjectPeriodFromMonth(cursor, 1).periodKey);
keys.push(buildProjectPeriodFromMonth(cursor, 2).periodKey);
}
return keys;
});
const hasSearchedDateRange = computed(() => searchParams.periodStartDate?.length === 2);
function getRowActions(row: Api.WorkReport.Project.ProjectReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
@@ -271,6 +323,7 @@ function createExportSearchParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return {
...params,
periodStartDate: normalizedPeriodRange.value,
projectOwnerIds: currentProjectOwnerIds.value
};
}
@@ -337,17 +390,32 @@ async function loadTeamSummary() {
return;
}
const dateRange = normalizedPeriodRange.value;
const summaryParams: Api.WorkReport.Common.TeamReportSummaryParams = { reportType: 'project' };
if (dateRange?.length === 2) {
summaryParams.periodStartDate = dateRange[0];
summaryParams.periodEndDate = dateRange[1];
} else {
summaryParams.periodKey = summaryPeriod.value.periodKey;
}
teamSummaryLoading.value = true;
const { error, data } = await fetchGetTeamReportSummary({
reportType: 'project',
periodKey: summaryPeriod.value.periodKey
});
const { error, data } = await fetchGetTeamReportSummary(summaryParams);
teamSummaryLoading.value = false;
teamSummary.value = error || !data ? null : data;
}
defineExpose({ reload });
defineExpose({
reload,
teamSummary,
teamSummaryLoading,
summaryPeriod,
summaryPeriodKeys,
hasSearchedDateRange,
loadTeamSummary
});
</script>
<template>
@@ -364,16 +432,6 @@ 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">

View File

@@ -1,19 +1,28 @@
<script setup lang="ts">
/* eslint-disable no-void */
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import { fetchGetWorkReportStatusDict } from '@/service/api';
import type { SearchField } from '@/components/custom/table-search-fields.vue';
import TableSearchFields from '@/components/custom/table-search-fields.vue';
import { BOOLEAN_TRUE_FALSE_OPTIONS, type WorkReportSearchParams, type WorkReportType } from '../types';
import { formatIsoWeekRangeLabel, normalizeWeeklySearchRange } from '../utils';
dayjs.extend(isoWeek);
defineOptions({ name: 'WorkReportSearch' });
interface Props {
reportType: WorkReportType;
teamMode?: boolean;
subordinateOptions?: Array<{ label: string; value: string }>;
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}
const props = withDefaults(defineProps<Props>(), {
teamMode: false,
subordinateOptions: () => [],
projectOptions: () => []
});
@@ -35,6 +44,12 @@ const statusOptions = computed(() =>
}))
);
const weeklyPeriodPlaceholder = computed(() => {
const range = normalizeWeeklySearchRange(model.value.periodStartDate as string[] | undefined);
if (!range?.length) return '请选择周报周期';
return formatIsoWeekRangeLabel(range[0], range[1]) || '请选择周报周期';
});
const fields = computed<SearchField[]>(() => {
const baseFields: SearchField[] = [
{ key: 'statusCode', label: '状态', type: 'select', options: statusOptions.value, placeholder: '请选择状态' },
@@ -51,8 +66,33 @@ const fields = computed<SearchField[]>(() => {
};
if (props.reportType === 'weekly') {
const weeklyPeriodField: SearchField = {
key: 'periodStartDate',
label: '周期',
type: 'dateRange',
placeholder: weeklyPeriodPlaceholder.value,
format: 'YYYY[年第]ww[周]',
rangeSeparator: '至'
};
const teamReporterField: SearchField[] = props.teamMode
? [
{
key: 'reporterIds',
label: '提交人',
type: 'select',
options: props.subordinateOptions,
placeholder: '请选择提交人',
transformValue: value => (value ? [value] : undefined),
resolveValue: value => (Array.isArray(value) ? value[0] : value)
}
]
: [];
return [
...baseFields,
baseFields[0],
weeklyPeriodField,
...teamReporterField,
{
key: 'isBusinessTrip',
label: '是否出差',
@@ -81,7 +121,21 @@ const fields = computed<SearchField[]>(() => {
}
if (props.reportType === 'monthly') {
return [baseFields[0], monthPeriodField];
const teamReporterField: SearchField[] = props.teamMode
? [
{
key: 'reporterIds',
label: '提交人',
type: 'select',
options: props.subordinateOptions,
placeholder: '请选择提交人',
transformValue: value => (value ? [value] : undefined),
resolveValue: value => (Array.isArray(value) ? value[0] : value)
}
]
: [];
return [baseFields[0], monthPeriodField, ...teamReporterField];
}
return baseFields;
@@ -95,6 +149,34 @@ async function loadStatusDict() {
onMounted(() => {
loadStatusDict();
});
watch(
() => props.reportType,
type => {
if (type !== 'weekly') return;
model.value.periodStartDate = normalizeWeeklySearchRange(model.value.periodStartDate as string[] | undefined);
},
{ immediate: true }
);
watch(
() => model.value.periodStartDate,
value => {
if (props.reportType !== 'weekly') return;
const normalizedValue = normalizeWeeklySearchRange(value as string[] | undefined);
const currentValue = Array.isArray(value) ? value : [];
if (
normalizedValue?.length === currentValue.length &&
normalizedValue?.every((item, index) => item === currentValue[index])
) {
return;
}
model.value.periodStartDate = normalizedValue;
}
);
</script>
<template>

View File

@@ -1,18 +1,21 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import dayjs from 'dayjs';
import { fetchRemindTeamReport } from '@/service/api';
import { formatIsoWeekRangeLabel } from '../utils';
defineOptions({ name: 'TeamReportSummary' });
interface Props {
reportType: Api.WorkReport.Common.ReportType;
periodKey: string;
periodKeys?: string[];
periodLabel?: string;
loading?: boolean;
summary?: Api.WorkReport.Common.TeamReportSummary | null;
}
const props = withDefaults(defineProps<Props>(), {
periodKeys: () => [],
periodLabel: '',
loading: false,
summary: null
@@ -24,6 +27,34 @@ const emit = defineEmits<{
const remindingAll = ref(false);
const remindingUserId = ref('');
const canRemind = computed(() => props.periodKeys.length > 0);
function formatSummaryPeriodLabel() {
if (!props.summary?.periodStartDate || !props.summary?.periodEndDate) {
return props.periodLabel;
}
const start = dayjs(props.summary.periodStartDate);
const end = dayjs(props.summary.periodEndDate);
if (!start.isValid() || !end.isValid()) {
return `${props.summary.periodStartDate}${props.summary.periodEndDate}`;
}
if (props.reportType === 'monthly' || props.reportType === 'project') {
const startMonth = start.format('YYYY-MM');
const endMonth = end.format('YYYY-MM');
return startMonth === endMonth ? startMonth : `${startMonth}${endMonth}`;
}
if (props.reportType === 'weekly') {
return formatIsoWeekRangeLabel(props.summary.periodStartDate, props.summary.periodEndDate);
}
return `${start.format('YYYY-MM-DD')}${end.format('YYYY-MM-DD')}`;
}
const displayPeriodLabel = computed(() => formatSummaryPeriodLabel());
const cards = computed(() => [
{ label: '应填人数', value: props.summary?.totalShouldSubmit ?? 0 },
@@ -33,6 +64,8 @@ const cards = computed(() => [
]);
async function handleRemind(userIds?: string[]) {
if (!props.periodKeys.length) return;
const targetUserId = userIds?.length === 1 ? userIds[0] : '';
if (targetUserId) {
@@ -41,27 +74,36 @@ async function handleRemind(userIds?: string[]) {
remindingAll.value = true;
}
const { error, data } = await fetchRemindTeamReport({
reportType: props.reportType,
periodKey: props.periodKey,
userIds
});
const results = await Promise.all(
props.periodKeys.map(periodKey =>
fetchRemindTeamReport({
reportType: props.reportType,
periodKey,
userIds
})
)
);
if (!targetUserId) {
remindingAll.value = false;
}
remindingUserId.value = '';
if (error) return;
const remindedCount = results.reduce((total, result) => {
if (result.error) return total;
return total + (result.data?.remindedCount ?? 0);
}, 0);
window.$message?.success(`已催办 ${data?.remindedCount ?? 0}`);
if (!remindedCount) return;
window.$message?.success(props.periodKeys.length > 1 ? '已按所选区间发送催办提醒' : `已催办 ${remindedCount}`);
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 v-if="displayPeriodLabel" class="team-report-summary__period">{{ displayPeriodLabel }}</div>
<div class="team-report-summary__grid">
<div v-for="card in cards" :key="card.label" class="team-report-summary__item">
@@ -83,6 +125,7 @@ async function handleRemind(userIds?: string[]) {
>
<span class="team-report-summary__user-name">{{ user.userNickname }}</span>
<ElButton
v-if="canRemind"
link
type="primary"
:loading="remindingUserId === user.userId"
@@ -94,7 +137,7 @@ async function handleRemind(userIds?: string[]) {
</div>
<ElEmpty v-else :image-size="60" description="暂无待提交人员" />
<div class="team-report-summary__popover-footer">
<div v-if="canRemind" class="team-report-summary__popover-footer">
<ElButton
size="small"
type="primary"

View File

@@ -155,12 +155,16 @@ export function createInitBaseSearchParams() {
export function createWeeklySearchParams(): Api.WorkReport.Weekly.WeeklyReportSearchParams {
return {
...createInitBaseSearchParams(),
reporterIds: undefined,
isBusinessTrip: undefined
};
}
export function createMonthlySearchParams(): Api.WorkReport.Monthly.MonthlyReportSearchParams {
return createInitBaseSearchParams();
return {
...createInitBaseSearchParams(),
reporterIds: undefined
};
}
export function createProjectSearchParams(): Api.WorkReport.Project.ProjectReportSearchParams {

View File

@@ -46,6 +46,29 @@ export function getIsoWeekDisplay(date: string | dayjs.Dayjs) {
return `${selectedDate.format('GGGG')}${String(selectedDate.isoWeek()).padStart(2, '0')}`;
}
export function formatIsoWeekCompactLabel(date: string | dayjs.Dayjs) {
const selectedDate = dayjs(date);
if (!selectedDate.isValid()) return '';
const weekDate = selectedDate.startOf('isoWeek');
const weekYear = weekDate.add(3, 'day').format('YYYY');
return `${weekYear}-${String(weekDate.isoWeek()).padStart(2, '0')}`;
}
export function formatIsoWeekRangeLabel(
startDate?: string | dayjs.Dayjs | null,
endDate?: string | dayjs.Dayjs | null
) {
const startLabel = startDate ? formatIsoWeekCompactLabel(startDate) : '';
const endLabel = endDate ? formatIsoWeekCompactLabel(endDate) : '';
if (!startLabel && !endLabel) return '';
if (!endLabel || startLabel === endLabel) return startLabel;
if (!startLabel) return endLabel;
return `${startLabel}${endLabel}`;
}
/* eslint-disable-next-line max-params */
function buildPeriod(reportType: WorkReportType, start: dayjs.Dayjs, end: dayjs.Dayjs, label: string, flag?: number) {
const startText = start.format('YYYY-MM-DD');
@@ -67,6 +90,20 @@ export function buildWeeklyPeriodFromDate(date: string | dayjs.Dayjs) {
return buildPeriod('weekly', start, end, formatRangeLabel(start, end));
}
export function normalizeWeeklySearchRange(periodRange?: string[] | null): string[] | undefined {
if (!periodRange?.length) return undefined;
const [startDate, endDate] = periodRange;
const start = dayjs(startDate);
const end = dayjs(endDate || startDate);
if (!start.isValid() || !end.isValid()) {
return periodRange ?? undefined;
}
return [start.startOf('isoWeek').format('YYYY-MM-DD'), end.startOf('isoWeek').format('YYYY-MM-DD')];
}
export function buildMonthlyPeriodFromMonth(month: string | dayjs.Dayjs) {
const selectedMonth = dayjs(month);
const start = selectedMonth.startOf('month');

View File

@@ -1,7 +1,8 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { computed, markRaw, reactive, ref } from 'vue';
import { computed, markRaw, reactive, ref, watch } from 'vue';
import { ElMessageBox, ElTag, ElTooltip } from 'element-plus';
import dayjs from 'dayjs';
import {
fetchDeleteWeeklyReport,
fetchExportWeeklyReportContent,
@@ -12,7 +13,7 @@ import {
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 { type TeamViewContext } from '@/views/personal-center/shared/team-dashboard';
import {
type WorkReportRow,
createWeeklySearchParams,
@@ -29,8 +30,7 @@ import {
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import { resolveWorkReportSummaryPeriod } from '../shared/utils';
import TeamReportSummary from '../shared/components/team-report-summary.vue';
import { buildWeeklyPeriodFromDate, normalizeWeeklySearchRange, resolveWorkReportSummaryPeriod } from '../shared/utils';
import WeeklyReportSearch from './modules/search-panel.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
@@ -43,6 +43,7 @@ defineOptions({ name: 'WeeklyWorkReportIndex' });
const props = defineProps<{
teamContext?: TeamViewContext | null;
subordinateOptions?: Array<{ label: string; value: string }>;
}>();
const emit = defineEmits<{
@@ -70,9 +71,30 @@ const ACTION_ICON_MAP = {
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 currentTeamReporterIds = computed(() => {
if (!isTeamMode.value) {
return null;
}
if (isTeamRootSelected.value) {
return [];
}
return props.teamContext?.selectedUserIds ?? [];
});
const resolvedTeamReporterIds = computed(() => {
if (!isTeamMode.value) {
return undefined;
}
if (searchParams.reporterIds?.length) {
return searchParams.reporterIds;
}
return currentTeamReporterIds.value;
});
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('weekly', isTeamMode.value));
const normalizedPeriodRange = computed(() => normalizeWeeklySearchRange(searchParams.periodStartDate));
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetWeeklyReportPage>>,
Api.WorkReport.Weekly.WeeklyReport
@@ -81,79 +103,134 @@ const table = useUIPaginatedTable<
api: () =>
fetchGetWeeklyReportPage({
...searchParams,
reporterIds: currentTeamReporterIds.value
periodStartDate: normalizedPeriodRange.value,
reporterIds: resolvedTeamReporterIds.value
}),
transform: response => transformWorkReportPage(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: 'reporterName', label: '提交人', minWidth: 100, showOverflowTooltip: true }] : []),
{
prop: 'periodLabel',
label: '周期',
minWidth: 150,
formatter: row => {
const periodText = formatWeeklyPeriodLabel(row);
const weekLabel = formatPeriodDateRange(row);
if (!weekLabel || weekLabel === '--') return periodText;
return (
<ElTooltip content={weekLabel} placement="top">
<span>{periodText}</span>
</ElTooltip>
);
columns: () => {
const cols: UI.TableColumn<Api.WorkReport.Weekly.WeeklyReport>[] = [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{
prop: 'periodLabel',
label: '周期',
minWidth: 150,
formatter: row => {
const periodText = formatWeeklyPeriodLabel(row);
const weekLabel = formatPeriodDateRange(row);
if (!weekLabel || weekLabel === '--') return periodText;
return (
<ElTooltip content={weekLabel} placement="top">
<span>{periodText}</span>
</ElTooltip>
);
}
}
},
{
prop: 'reporterDeptName',
label: '部门',
minWidth: 80,
showOverflowTooltip: true,
formatter: row => row.reporterDeptName || '--'
},
{ prop: 'supervisorName', label: '直属上级', minWidth: 80 },
{ prop: 'totalWorkHours', label: '总工时', minWidth: 80, formatter: row => formatEmptyText(row.totalWorkHours) },
{
prop: 'isBusinessTrip',
label: '出差',
minWidth: 80,
align: 'center',
formatter: row => (row.isBusinessTrip ? '是' : '否')
},
{
prop: 'totalTravelDays',
label: '出差天数',
minWidth: 90,
formatter: row => formatEmptyText(row.totalTravelDays)
},
{
prop: 'statusCode',
label: '状态',
minWidth: 80,
align: 'center',
formatter: row => (
<ElTag type={resolveWorkReportStatusTagType(row.statusCode)}>
{getWorkReportStatusLabel(row.statusCode, row.statusName)}
</ElTag>
)
},
{ prop: 'submitTime', label: '提交时间', minWidth: 100, formatter: row => formatDateTime(row.submitTime) },
{ prop: 'approvalTime', label: '审批时间', minWidth: 100, formatter: row => formatDateTime(row.approvalTime) },
{
prop: 'operate',
label: '操作',
width: isTeamMode.value ? 140 : 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
];
if (isTeamMode.value) {
cols.push({ prop: 'reporterName', label: '提交人', minWidth: 100, showOverflowTooltip: true });
}
]
cols.push(
{
prop: 'reporterDeptName',
label: '部门',
minWidth: 80,
showOverflowTooltip: true,
formatter: row => row.reporterDeptName || '--'
},
{ prop: 'supervisorName', label: '直属上级', minWidth: 80 },
{ prop: 'totalWorkHours', label: '总工时', minWidth: 80, formatter: row => formatEmptyText(row.totalWorkHours) },
{
prop: 'isBusinessTrip',
label: '出差',
minWidth: 80,
align: 'center',
formatter: row => (row.isBusinessTrip ? '是' : '否')
},
{
prop: 'totalTravelDays',
label: '出差天数',
minWidth: 90,
formatter: row => formatEmptyText(row.totalTravelDays)
},
{
prop: 'statusCode',
label: '状态',
minWidth: 80,
align: 'center',
formatter: row => (
<ElTag type={resolveWorkReportStatusTagType(row.statusCode)}>
{getWorkReportStatusLabel(row.statusCode, row.statusName)}
</ElTag>
)
},
{ prop: 'submitTime', label: '提交时间', minWidth: 100, formatter: row => formatDateTime(row.submitTime) },
{ prop: 'approvalTime', label: '审批时间', minWidth: 100, formatter: row => formatDateTime(row.approvalTime) },
{
prop: 'operate',
label: '操作',
width: isTeamMode.value ? 140 : 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
);
return cols;
}
});
// 团队统计始终使用当前周期(本周),不跟随列表第一条数据的周期
const summaryPeriod = computed(() => resolveWorkReportSummaryPeriod('weekly'));
const summaryPeriod = computed(() =>
resolveWorkReportSummaryPeriod('weekly', {
periodRange: normalizedPeriodRange.value
})
);
const summaryPeriodKeys = computed(() => {
const dateRange = normalizedPeriodRange.value;
const fallbackKey = summaryPeriod.value.periodKey;
if (!dateRange?.length) {
return fallbackKey ? [fallbackKey] : [];
}
const [startDate, endDate] = dateRange;
const start = dayjs(startDate);
const end = dayjs(endDate || startDate);
if (!start.isValid() || !end.isValid()) {
return fallbackKey ? [fallbackKey] : [];
}
const keys: string[] = [];
const startBoundary = start.startOf('day');
const endBoundary = end.endOf('day');
for (
let cursor = start.startOf('isoWeek');
cursor.isBefore(endBoundary, 'day') || cursor.isSame(endBoundary, 'day');
cursor = cursor.add(1, 'week')
) {
if (!cursor.isBefore(startBoundary, 'day')) {
keys.push(buildWeeklyPeriodFromDate(cursor).periodKey);
}
}
return keys;
});
const hasSearchedDateRange = computed(() => searchParams.periodStartDate?.length === 2);
watch(
() => isTeamMode.value,
() => {
table.reloadColumns();
}
);
function getRowActions(row: Api.WorkReport.Weekly.WeeklyReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
@@ -295,7 +372,8 @@ function createExportSearchParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return {
...params,
reporterIds: currentTeamReporterIds.value
periodStartDate: normalizedPeriodRange.value,
reporterIds: resolvedTeamReporterIds.value
};
}
@@ -361,31 +439,42 @@ async function loadTeamSummary() {
return;
}
const dateRange = normalizedPeriodRange.value;
const summaryParams: Api.WorkReport.Common.TeamReportSummaryParams = { reportType: 'weekly' };
if (dateRange?.length === 2) {
summaryParams.periodStartDate = dateRange[0];
summaryParams.periodEndDate = dateRange[1];
} else {
summaryParams.periodKey = summaryPeriod.value.periodKey;
}
teamSummaryLoading.value = true;
const { error, data } = await fetchGetTeamReportSummary({
reportType: 'weekly',
periodKey: summaryPeriod.value.periodKey
});
const { error, data } = await fetchGetTeamReportSummary(summaryParams);
teamSummaryLoading.value = false;
teamSummary.value = error || !data ? null : data;
}
defineExpose({ reload });
defineExpose({
reload,
teamSummary,
teamSummaryLoading,
summaryPeriod,
summaryPeriodKeys,
hasSearchedDateRange,
loadTeamSummary
});
</script>
<template>
<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"
<WeeklyReportSearch
v-model:model="searchParams"
:team-mode="isTeamMode"
:subordinate-options="props.subordinateOptions"
@reset="resetSearchParams"
@search="handleSearch"
/>
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">

View File

@@ -4,6 +4,8 @@ import SharedWorkReportSearch from '../../shared/components/search-panel.vue';
defineOptions({ name: 'WeeklyReportSearch' });
defineProps<{
teamMode?: boolean;
subordinateOptions?: Array<{ label: string; value: string }>;
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}>();
@@ -19,6 +21,8 @@ const emit = defineEmits<{
<SharedWorkReportSearch
v-model:model="model"
report-type="weekly"
:team-mode="teamMode"
:subordinate-options="subordinateOptions"
:project-options="projectOptions"
@reset="emit('reset')"
@search="emit('search')"