fix(工作报告): 修复工作报告存在的若干问题。

feat(加班申请): 支持批量审批。
This commit is contained in:
dk
2026-06-13 13:06:39 +08:00
parent 5061eced32
commit 80f028bcb9
19 changed files with 1845 additions and 790 deletions

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { computed, nextTick, reactive, watch } from 'vue';
import dayjs from 'dayjs';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
@@ -33,6 +34,9 @@ const emit = defineEmits<{
): void;
}>();
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const reasonModel = reactive<Api.WorkReport.Common.StatusActionParams>({
reason: ''
});
@@ -72,12 +76,35 @@ const title = computed(() => {
});
const preset = computed(() => (isMonthlyApprove.value ? 'lg' : 'sm'));
const rejectOpinionRequired = computed(() => isCommonApprove.value && commonApprovalModel.conclusion === 'reject');
const opinionLabel = computed(() => (rejectOpinionRequired.value ? '退回原因' : '审批意见'));
const opinionPlaceholder = computed(() =>
rejectOpinionRequired.value ? `请输入${opinionLabel.value}` : '可填写审批意见'
);
const confirmText = computed(() => {
if (isCommonApprove.value) return '确认提交';
if (props.actionType === 'approve') return '通过';
return '退回';
});
const confirmDisabled = computed(() => isCommonApprove.value && !commonApprovalModel.conclusion);
const commonRules = computed(() => ({
opinion: rejectOpinionRequired.value
? [
createRequiredRule(`请输入${opinionLabel.value}`),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error(`请输入${opinionLabel.value}`));
return;
}
callback();
},
trigger: 'blur'
}
]
: []
}));
watch(visible, isVisible => {
if (!isVisible) return;
@@ -106,16 +133,36 @@ watch(visible, isVisible => {
}
});
function handleSubmit() {
watch(
() => visible.value,
async isVisible => {
if (!isVisible || !isCommonApprove.value) return;
await nextTick();
formRef.value?.clearValidate();
}
);
watch(rejectOpinionRequired, async () => {
if (!visible.value || !isCommonApprove.value) return;
await nextTick();
formRef.value?.clearValidate('opinion');
});
async function handleSubmit() {
if (isCommonApprove.value) {
if (!commonApprovalModel.conclusion) {
window.$message?.warning('请选择审批结论');
return;
}
await validate();
emit(
'submit',
{
reason: commonApprovalModel.opinion || (commonApprovalModel.conclusion === 'approve' ? '通过' : '不通过')
reason: commonApprovalModel.opinion.trim() || (commonApprovalModel.conclusion === 'approve' ? '通过' : '退回')
},
commonApprovalModel.conclusion
);
@@ -176,14 +223,29 @@ function handleSubmit() {
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
不通过
退回
</button>
</div>
</div>
<div class="audit-field">
<label>审批意见</label>
<ElInput v-model="commonApprovalModel.opinion" type="textarea" :rows="3" placeholder="请输入审批意见" />
</div>
<ElForm
ref="formRef"
:model="commonApprovalModel"
:rules="commonRules"
label-position="top"
:validate-on-rule-change="false"
>
<ElFormItem :label="opinionLabel" prop="opinion">
<ElInput
v-model="commonApprovalModel.opinion"
type="textarea"
:rows="5"
maxlength="1000"
show-word-limit
:placeholder="opinionPlaceholder"
/>
</ElFormItem>
</ElForm>
</div>
</template>

View File

@@ -10,6 +10,8 @@ import {
formatDate,
formatEmptyText,
formatPeriod,
formatPeriodDateRange,
formatWeeklyPeriodLabel,
getProjectReportFlagLabel,
getWorkReportStatusLabel
} from '../types';
@@ -29,6 +31,14 @@ const title = computed(() => `${WORK_REPORT_TYPE_LABEL[props.reportType]}详情`
const weeklyDetail = computed(() =>
props.reportType === 'weekly' ? (detail.value as Api.WorkReport.Weekly.WeeklyReport | null) : null
);
const periodText = computed(() => {
if (!detail.value) return '--';
return props.reportType === 'weekly' ? formatWeeklyPeriodLabel(detail.value) : formatPeriod(detail.value);
});
const periodTooltip = computed(() => {
if (!detail.value || props.reportType !== 'weekly') return '';
return formatPeriodDateRange(detail.value);
});
watch(visible, isVisible => {
if (isVisible) loadDetail();
@@ -68,7 +78,11 @@ function getPersonalDetail() {
<div v-if="detail" class="work-report-detail">
<BusinessFormSection title="基础信息">
<ElDescriptions :column="3" border size="small">
<ElDescriptionsItem label="报告周期">{{ formatPeriod(detail) }}</ElDescriptionsItem>
<ElDescriptionsItem label="报告周期">
<ElTooltip :disabled="!periodTooltip || periodTooltip === '--'" :content="periodTooltip" placement="top">
<span>{{ periodText }}</span>
</ElTooltip>
</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
{{ getWorkReportStatusLabel(detail.statusCode, detail.statusName) }}
</ElDescriptionsItem>

View File

@@ -335,7 +335,7 @@ async function handleSubmit() {
<ElDescriptionsItem label="填报人">
{{ baseReporterName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="部门/方向">{{ baseDeptName }}</ElDescriptionsItem>
<ElDescriptionsItem label="部门">{{ baseDeptName }}</ElDescriptionsItem>
<ElDescriptionsItem label="岗位">{{ basePostName }}</ElDescriptionsItem>
<ElDescriptionsItem label="直属上级">{{ baseInfo?.supervisorName || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="周期" :span="2">{{ activeModel.periodLabel || '--' }}</ElDescriptionsItem>

View File

@@ -76,6 +76,7 @@ onBeforeUnmount(() => {
:title="props.title"
:size="drawerSize"
:close-on-click-modal="false"
append-to-body
@closed="onDrawerClosed"
>
<div v-loading="props.loading" class="work-report-page-drawer__content">
@@ -93,6 +94,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
min-height: 0;
padding-bottom: 0;
}
:global(.work-report-page-drawer__body--approval) {
@@ -107,6 +109,8 @@ onBeforeUnmount(() => {
}
.work-report-page-drawer__content :deep(.form-page) {
display: flex;
flex-direction: column;
flex: 1 0 auto;
min-height: 100%;
box-sizing: border-box;

View File

@@ -35,6 +35,7 @@ import {
createProjectSaveParams,
createWeeklySaveParams,
formatPeriodLabel,
getStructuredSections,
normalizePlanItems,
normalizeProjectItems,
normalizeReviewItems
@@ -459,7 +460,6 @@ function isCompleteWeeklyTravelSegment(segment: Api.WorkReport.Weekly.WeeklyRepo
return Boolean(
segment.startDate &&
segment.endDate &&
segment.location?.trim() &&
Number.isFinite(travelDays) &&
travelDays >= 0.5 &&
Number.isInteger(travelDays * 2)
@@ -470,6 +470,21 @@ function hasCompleteWeeklyTravelSegment(items: Api.WorkReport.Weekly.WeeklyRepor
return items.some(isCompleteWeeklyTravelSegment);
}
/**
* 周报"具体工作内容及成果描述"中已记录出差信息("本周差旅"分类、或含"差旅"分类、
* 或结构化任务里带 kind=travel视为已经有完整出差分段不再强制弹出
* "请至少新增一条完整的出差分段"。避免出差点位等历史录入原因导致保存时反复提示。
*/
function hasTravelInfoInWeeklyReview(items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
return items.some(item => {
if (item.itemTitle?.trim() === '本周差旅') return true;
const sections = getStructuredSections(item.contentJson);
if (sections.some(section => section.category?.trim() === '差旅')) return true;
if (sections.some(section => section.tasks.some(task => task.kind === 'travel'))) return true;
return false;
});
}
function hasCompletePersonalReviewItem(items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
return items.some(item => hasTextValue(item.itemTitle) && hasReviewContent(item));
}
@@ -506,7 +521,12 @@ function validateRequiredReportItems() {
const messages: string[] = [];
if (props.reportType === 'weekly') {
const hasTravelReview = weeklyModel.isBusinessTrip && hasCompleteWeeklyTravelSegment(weeklyModel.travelSegments);
// 出差信息既可能来自 travelSegments也可能来自具体工作内容及成果描述里的"差旅"段,
// 任何一方已有完整出差信息就不再提示"请至少新增一条完整的出差分段"。
const hasTravelReview =
weeklyModel.isBusinessTrip &&
(hasCompleteWeeklyTravelSegment(weeklyModel.travelSegments) ||
hasTravelInfoInWeeklyReview(weeklyModel.reviewItems));
const reviewMessage = hasTravelReview
? weeklyModel.reviewItems.length
? getPersonalReviewValidationMessage('当期重点工作回顾', weeklyModel.reviewItems)
@@ -769,6 +789,7 @@ async function handleActionSubmit(
:action-type="currentActionType"
:initial-monthly-approve-data="reportType === 'monthly' ? monthlyApprovalDraft : null"
:loading="actionSubmitting"
append-to-body
@submit="handleActionSubmit"
/>
</template>

View File

@@ -1,7 +1,10 @@
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import type { PaginationData } from '@sa/hooks';
import { getStatusTagType } from '@/constants/status-tag';
dayjs.extend(isoWeek);
export type WorkReportType = Api.WorkReport.Common.ReportType;
export type WorkReportRow =
| Api.WorkReport.Weekly.WeeklyReport
@@ -83,6 +86,40 @@ export function formatDateTime(value?: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '--';
}
export function formatPeriodDateRange(
rowOrStart: Pick<WorkReportRow, 'periodStartDate' | 'periodEndDate'> | string | null | undefined,
endDate?: string | null
) {
const startDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodStartDate : rowOrStart;
const rangeEndDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodEndDate : endDate;
const startText = formatDate(startDate);
const endText = formatDate(rangeEndDate);
if (startText === '--' && endText === '--') {
return '--';
}
return `${startText}${endText}`;
}
export function formatWeeklyPeriodLabel(
rowOrStart: Pick<WorkReportRow, 'periodLabel' | 'periodStartDate' | 'periodEndDate'> | string | null | undefined,
endDate?: string | null,
periodLabel?: string | null
) {
const startDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodStartDate : rowOrStart;
const rangeEndDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodEndDate : endDate;
const fallbackLabel = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodLabel : periodLabel;
const referenceDate = dayjs(startDate || rangeEndDate);
if (referenceDate.isValid()) {
const weekYear = referenceDate.startOf('isoWeek').add(3, 'day').format('YYYY');
return `${weekYear}年第${String(referenceDate.isoWeek()).padStart(2, '0')}`;
}
return formatPeriodLabel(fallbackLabel) || formatPeriodDateRange(startDate, rangeEndDate);
}
export function formatPeriodLabel(value?: string | null) {
return String(value || '')
.trim()

View File

@@ -54,7 +54,7 @@ export function buildWeeklyPeriodFromDate(date: string | dayjs.Dayjs) {
const start = selectedDate.startOf('isoWeek');
const end = selectedDate.endOf('isoWeek');
return buildPeriod('weekly', start, end, `${formatRangeLabel(start, end)} 周报`);
return buildPeriod('weekly', start, end, formatRangeLabel(start, end));
}
export function buildMonthlyPeriodFromMonth(month: string | dayjs.Dayjs) {
@@ -62,7 +62,7 @@ export function buildMonthlyPeriodFromMonth(month: string | dayjs.Dayjs) {
const start = selectedMonth.startOf('month');
const end = selectedMonth.endOf('month');
return buildPeriod('monthly', start, end, `${selectedMonth.format('YYYY-MM')} 月报`);
return buildPeriod('monthly', start, end, selectedMonth.format('YYYY-MM'));
}
export function buildProjectPeriodFromMonth(month: string | dayjs.Dayjs, flag: number) {
@@ -92,14 +92,14 @@ export function getWeeklyPeriodOptions(now = dayjs()): WorkReportPeriodOption[]
label: '本周',
description: formatRangeLabel(thisWeekStart, thisWeekEnd),
reportType: 'weekly',
period: buildPeriod('weekly', thisWeekStart, thisWeekEnd, `${formatRangeLabel(thisWeekStart, thisWeekEnd)} 周报`)
period: buildPeriod('weekly', thisWeekStart, thisWeekEnd, formatRangeLabel(thisWeekStart, thisWeekEnd))
},
{
key: 'last-week',
label: '上周',
description: formatRangeLabel(lastWeekStart, lastWeekEnd),
reportType: 'weekly',
period: buildPeriod('weekly', lastWeekStart, lastWeekEnd, `${formatRangeLabel(lastWeekStart, lastWeekEnd)} 周报`)
period: buildPeriod('weekly', lastWeekStart, lastWeekEnd, formatRangeLabel(lastWeekStart, lastWeekEnd))
}
];
}
@@ -117,14 +117,14 @@ export function getMonthlyPeriodOptions(now = dayjs()): WorkReportPeriodOption[]
label: '本月',
description: thisMonthStart.format('YYYY-MM'),
reportType: 'monthly',
period: buildPeriod('monthly', thisMonthStart, thisMonthEnd, `${thisMonthStart.format('YYYY-MM')} 月报`)
period: buildPeriod('monthly', thisMonthStart, thisMonthEnd, thisMonthStart.format('YYYY-MM'))
},
{
key: 'last-month',
label: '上月',
description: lastMonthStart.format('YYYY-MM'),
reportType: 'monthly',
period: buildPeriod('monthly', lastMonthStart, lastMonthEnd, `${lastMonthStart.format('YYYY-MM')} 月报`)
period: buildPeriod('monthly', lastMonthStart, lastMonthEnd, lastMonthStart.format('YYYY-MM'))
}
];
}