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

2
.trae/rules/vue-need.md Normal file
View File

@@ -0,0 +1,2 @@
1. 每次开发新功能、编写代码时都添加好相应的注释。
2. 所有的vue文件编码必须是UTF-8的。

View File

@@ -232,6 +232,28 @@ export function fetchRejectOvertimeApplication(id: string, data: Api.OvertimeApp
});
}
export function fetchBatchApproveOvertimeApplication(
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
) {
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/batch-approve`,
method: 'post',
data
});
}
export function fetchBatchRejectOvertimeApplication(
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
) {
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/batch-reject`,
method: 'post',
data
});
}
export function fetchDeleteOvertimeApplication(id: string) {
return request<boolean>({
...safeJsonRequestConfig,

View File

@@ -59,6 +59,22 @@ declare namespace Api {
reason?: string | null;
}
interface OvertimeApplicationBatchActionParams {
ids: string[];
reason?: string | null;
}
interface OvertimeApplicationBatchFailItem {
id: string;
reason: string;
}
interface OvertimeApplicationBatchActionResult {
successCount: number;
failCount: number;
failItems: OvertimeApplicationBatchFailItem[];
}
interface OvertimeApplicationApprovalRecord {
id: string;
overtimeApplicationId: string;

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiChevronLeft from '~icons/mdi/chevron-left';
import IconMdiChevronRight from '~icons/mdi/chevron-right';
defineOptions({ name: 'OvertimeApplicationBatchDetailDialog' });
interface Props {
/** 选中的加班申请 id 列表(原始 id */
selectedIds: string[];
/** 全部加班申请行数据,用于通过 id 查找 */
rows: Api.OvertimeApplication.OvertimeApplication[];
actionLoading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
actionLoading: false
});
const emit = defineEmits<{
approve: [];
reject: [];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const currentIndex = ref(0);
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const detailLoading = ref(false);
const currentId = computed(() => props.selectedIds[currentIndex.value] ?? null);
const total = computed(() => props.selectedIds.length);
const canGoPrev = computed(() => currentIndex.value > 0);
const canGoNext = computed(() => currentIndex.value < props.selectedIds.length - 1);
async function loadDetail() {
const id = currentId.value;
if (!id) {
detailData.value = null;
return;
}
const row = props.rows.find(r => r.id === id);
if (!row) {
detailData.value = null;
return;
}
detailLoading.value = true;
const { error, data } = await fetchGetOvertimeApplicationDetail(id);
detailLoading.value = false;
detailData.value = error || !data ? row : data;
}
function goPrev() {
if (!canGoPrev.value) return;
currentIndex.value -= 1;
loadDetail();
}
function goNext() {
if (!canGoNext.value) return;
currentIndex.value += 1;
loadDetail();
}
watch(
() => visible.value,
value => {
if (value) {
currentIndex.value = 0;
loadDetail();
} else {
detailData.value = null;
}
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" title="批量审批" preset="md" :loading="detailLoading" :show-footer="true">
<!-- 左右导航 -->
<div class="batch-detail__nav">
<button type="button" class="batch-detail__nav-btn" :disabled="!canGoPrev" @click.stop="goPrev">
<IconMdiChevronLeft class="text-20px" />
</button>
<span class="batch-detail__nav-counter">{{ currentIndex + 1 }} / {{ total }}</span>
<button type="button" class="batch-detail__nav-btn" :disabled="!canGoNext" @click.stop="goNext">
<IconMdiChevronRight class="text-20px" />
</button>
</div>
<ElDescriptions v-if="detailData" class="overtime-application-detail-dialog__descriptions" :column="2" border>
<ElDescriptionsItem label="申请人" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.applicantName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班日期" label-class-name="overtime-application-detail-dialog__label--compact">
{{ formatOvertimeDate(detailData.overtimeDate) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班时长" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeDuration }}
</ElDescriptionsItem>
<ElDescriptionsItem label="提交时间" label-class-name="overtime-application-detail-dialog__label--compact">
{{ formatOvertimeDateTime(detailData.submitTime) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班原因" :span="2" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeReason }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班内容" :span="2" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeContent }}
</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty v-else description="未获取到加班申请详情" />
<template #footer>
<div class="batch-detail__footer">
<span class="batch-detail__footer-hint">将对全部 {{ total }} 项统一执行操作</span>
<div class="batch-detail__footer-actions">
<ElButton
class="batch-detail__approve-btn"
type="success"
:loading="props.actionLoading"
:disabled="props.actionLoading || !detailData"
@click="emit('approve')"
>
<template #icon>
<IconMdiCheckCircleOutline />
</template>
通过
</ElButton>
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
<template #icon>
<IconMdiCloseCircleOutline />
</template>
退回
</ElButton>
</div>
</div>
</template>
</BusinessFormDialog>
</template>
<style scoped>
.batch-detail__nav {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-bottom: 16px;
}
.batch-detail__nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 8px;
background-color: rgb(255 255 255 / 98%);
color: rgb(71 85 105 / 94%);
cursor: pointer;
transition: all 160ms ease;
}
.batch-detail__nav-btn:hover:not(:disabled) {
border-color: rgb(14 116 144 / 60%);
color: rgb(14 116 144 / 96%);
}
.batch-detail__nav-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.batch-detail__nav-counter {
font-size: 14px;
font-weight: 600;
color: rgb(15 23 42 / 96%);
min-width: 60px;
text-align: center;
}
.batch-detail__footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.batch-detail__footer-hint {
font-size: 13px;
color: rgb(100 116 139 / 92%);
}
.batch-detail__footer-actions {
display: flex;
gap: 12px;
}
.batch-detail__approve-btn {
--el-button-bg-color: #0f766e;
--el-button-border-color: #0f766e;
--el-button-hover-bg-color: #115e59;
--el-button-hover-border-color: #115e59;
--el-button-active-bg-color: #134e4a;
--el-button-active-border-color: #134e4a;
}
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {
line-height: 1.7;
}
:deep(.overtime-application-detail-dialog__label),
:deep(.overtime-application-detail-dialog__label--compact) {
white-space: nowrap;
vertical-align: middle;
}
:deep(.overtime-application-detail-dialog__label) {
width: 96px;
min-width: 96px;
}
:deep(.overtime-application-detail-dialog__label--compact) {
width: 86px;
min-width: 86px;
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { RDMS_OVERTIME_DURATION_DICT_CODE } from '@/constants/dict';
import {
fetchCreateOvertimeApplication,
@@ -85,8 +86,8 @@ const rules = computed(
function createDefaultModel(): Api.OvertimeApplication.SaveOvertimeApplicationParams {
return {
overtimeDate: '',
overtimeDuration: '',
overtimeDate: dayjs().format('YYYY-MM-DD'),
overtimeDuration: '0.5',
overtimeReason: '',
overtimeContent: '',
approverId: ''

View File

@@ -70,7 +70,7 @@ const table = useUIPaginatedTable<
{ prop: 'periodLabel', label: '月份', minWidth: 80, formatter: row => formatPeriod(row) },
{
prop: 'reporterDeptName',
label: '部门/方向',
label: '部门',
minWidth: 80,
showOverflowTooltip: true,
formatter: row => row.reporterDeptName || '--'

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
/* eslint-disable no-useless-escape */
import { computed, reactive, ref } from 'vue';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import type { FormRules } from 'element-plus';
import dayjs from 'dayjs';
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
@@ -30,6 +32,10 @@ const emit = defineEmits<{
viewAudit: [];
}>();
const { formRef: pageFormRef, validate: validatePageForm } = useForm();
const { formRef: auditFormRef } = useForm();
const { createRequiredRule } = useFormRules();
interface ReviewItem {
workItem: string;
days: number;
@@ -69,14 +75,55 @@ const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_
const todayText = computed(() => dayjs().format('YYYY-MM-DD'));
const auditDialogVisible = ref(false);
const auditForm = ref({
const auditForm = reactive({
conclusion: '通过',
opinion: ''
});
/** 必填字段校验错误提示 */
const performanceError = ref('');
const meetingDateError = ref('');
const rejectOpinionRequired = computed(() => auditForm.conclusion === '退回');
const pageValidationModel = reactive({
get meetingDate() {
return props.approvalModel.meetingDate || '';
},
get performanceResult() {
return props.approvalModel.performanceResult || '';
}
});
const pageRules: FormRules = {
meetingDate: [createRequiredRule('请选择面谈时间')],
performanceResult: [
createRequiredRule('请输入绩效考核结果'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入绩效考核结果'));
return;
}
callback();
},
trigger: 'blur'
}
]
};
const auditRules = computed<FormRules>(() => ({
opinion: rejectOpinionRequired.value
? [
createRequiredRule('请输入退回原因'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入退回原因'));
return;
}
callback();
},
trigger: 'blur'
}
]
: []
}));
function patchApproval<K extends keyof Api.WorkReport.Monthly.MonthlyReportApproveParams>(
key: K,
@@ -158,17 +205,6 @@ function handlePerformanceScoreInput(value: string) {
if (filtered !== value) {
performanceForm.score = filtered;
}
// 清除错误提示
if (filtered) {
performanceError.value = '';
}
}
/** 绩效分数失焦校验 */
function handlePerformanceScoreBlur() {
if (!performanceForm.score) {
performanceError.value = '请输入绩效考核结果';
}
}
const mainForm = reactive({
@@ -192,15 +228,6 @@ const mainForm = reactive({
}
});
/** 面谈时间失焦校验 */
function handleMeetingDateBlur() {
if (!mainForm.meetingDate) {
meetingDateError.value = '请选择面谈时间';
} else {
meetingDateError.value = '';
}
}
const signatureForm = reactive({
get employeeSign() {
return props.approvalModel.employeeSignName || '';
@@ -297,39 +324,57 @@ function escapeHtml(value: string) {
.replace(/'/g, '&#39;');
}
function createTaskMetrics(task: StructuredTask, showHours: boolean) {
const metrics = [
task.priority ? escapeHtml(resolvePriorityLabel(task.priority)) : '',
typeof task.progress === 'number' ? `进度${task.progress}%` : '',
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
].filter(Boolean);
return metrics.length
? `<span class="rich-inline-metrics" contenteditable="false">${metrics.join(' · ')}</span>`
: '';
function stripStructuredTaskPrefixV2(value: string) {
return value.trim().replace(/^\d+[..、]\s*/u, '');
}
function createStructuredHtml(sections: StructuredSection[], showHours = false) {
const stripOrderPrefix = (text: string) => text.trim().replace(/^\d+[..、]\s*/, '');
const splitPunctuation = (text: string) => {
const rawTitle = stripOrderPrefix(text);
const punctuation = rawTitle.match(/[。?!?]$/)?.[0] || '';
return {
title: punctuation ? rawTitle.slice(0, -1) : rawTitle,
punctuation
};
};
function stripStructuredTaskSuffixV2(value: string) {
return value.trim().replace(/[。.!?]+$/u, '');
}
function resolveTaskMetricsV2(metricsText: string) {
const parts = metricsText
.split('/')
.map(item => item.trim())
.filter(Boolean);
const priority = normalizePriorityCode(parts.find(item => /^P?\d+$/iu.test(item)));
const progressText =
metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1] ||
parts.find(item => /^\d+(?:\.\d+)?%$/u.test(item))?.replace(/%$/u, '');
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/u)?.[1];
return {
priority,
progress: progressText === undefined ? undefined : Number(progressText),
hours: hoursText === undefined ? undefined : Number(hoursText)
};
}
function formatStructuredTaskLineV2(task: StructuredTask, showHours = false) {
const title = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(task.title));
const metrics = [
task.priority ? resolvePriorityLabel(task.priority) : '',
typeof task.progress === 'number' ? `${task.progress}%` : '',
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
].filter(Boolean);
return `${title}${metrics.length ? `${metrics.join(' / ')}` : ''}`;
}
function createStructuredHtmlV2(sections: StructuredSection[], showHours = false) {
return sections
.map(section => {
const categoryLabel = resolveTaskItemTypeLabel(section.category.trim());
const tasks = section.tasks
.map((task, taskIndex) => {
const { title, punctuation } = splitPunctuation(task.title);
return `<span class="rich-section-task"><span class="rich-section-task-title">${taskIndex + 1}.${escapeHtml(title)}</span>${createTaskMetrics(task, showHours)}${escapeHtml(punctuation)}</span>`;
})
.map(
(task, index) =>
`<div class="rich-task-line">${escapeHtml(`${index + 1}${formatStructuredTaskLineV2(task, showHours)}`)}</div>`
)
.join('');
return `
<div class="rich-section">
<div class="rich-section-title">${escapeHtml(resolveTaskItemTypeLabel(section.category.trim()))}</div>
<div class="rich-category-line"># ${escapeHtml(categoryLabel)}</div>
${tasks ? `<div class="rich-section-tasks">${tasks}</div>` : ''}
</div>
`;
@@ -337,6 +382,63 @@ function createStructuredHtml(sections: StructuredSection[], showHours = false)
.join('');
}
function createStructuredSectionsFromTextV2(text: string, defaultCategory: string): StructuredSection[] {
const lines = normalizeEditorText(text).split('\n').filter(Boolean);
if (!lines.length || lines.join('\n') === EMPTY_TEXT) return [];
const sections: StructuredSection[] = [];
const ensureSection = (categoryText: string) => {
const category = categoryText.trim() || defaultCategory;
let section = sections.find(item => item.category === category);
if (!section) {
section = { category, tasks: [] };
sections.push(section);
}
return section;
};
let currentCategory = '';
lines.forEach(line => {
const trimmedLine = line.trim();
if (!trimmedLine) return;
if (trimmedLine.startsWith('#')) {
currentCategory = trimmedLine.replace(/^#\s*/u, '').trim();
if (currentCategory) ensureSection(currentCategory);
return;
}
const legacyMatch = trimmedLine.match(/^(.+?)\s*-\s*(.+?)(?:[(]([^()]*)[)])?[。.!?]*$/u);
if (legacyMatch) {
const [, rawCategory, rawTitle, metricsText = ''] = legacyMatch;
const category = rawCategory.trim();
const title = rawTitle.trim();
if (!category || !title) return;
ensureSection(category).tasks.push({
title,
...resolveTaskMetricsV2(metricsText)
});
return;
}
const normalizedLine = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(trimmedLine));
if (!normalizedLine) return;
const inlineMatch = normalizedLine.match(/^(.*?)(?:[(]([^()]*)[)])?$/u);
const title = inlineMatch?.[1]?.trim() || '';
const metricsText = inlineMatch?.[2]?.trim() || '';
if (!title) return;
ensureSection(currentCategory || defaultCategory).tasks.push({
title,
...resolveTaskMetricsV2(metricsText)
});
});
return sections.filter(section => section.tasks.length);
}
function normalizeEditorText(value: string) {
return value
.replace(/\u00A0/g, ' ')
@@ -346,106 +448,13 @@ function normalizeEditorText(value: string) {
.join('\n');
}
function stripTaskOrderPrefix(value: string) {
return value.trim().replace(/^\d+[..、]\s*/, '');
}
function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTask>) {
const metricParts = metricsText
.split(/[\/、·]/u)
.map(item => item.trim())
.filter(Boolean);
const priorityText = metricsText.match(/\bP\d+\b/iu)?.[0] || metricParts.find(item => /^P?\d+$/iu.test(item));
const priority = normalizePriorityCode(priorityText || fallback?.priority);
const progressText = metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1];
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/iu)?.[1];
return {
priority,
progress: progressText === undefined ? fallback?.progress : Number(progressText),
hours: hoursText === undefined ? fallback?.hours : Number(hoursText)
};
}
function createStructuredSectionsFromText(
text: string,
category: string,
defaultTask?: Partial<StructuredTask>
): StructuredSection[] {
const lines = normalizeEditorText(text).split('\n').filter(Boolean);
if (!lines.length || lines.join('\n') === EMPTY_TEXT) return [];
const groupedSections: StructuredSection[] = [];
lines.forEach(line => {
const structuredMatch = line.match(/^(.+?)\s*[-–—]\s*(.+?)\s*[(]([^)]*)[)]$/u);
if (!structuredMatch) return;
const [, rawCategory, rawTitle, metricsText] = structuredMatch;
const categoryText = rawCategory.trim();
const title = rawTitle.trim();
if (!categoryText || !title) return;
let section = groupedSections.find(item => item.category === categoryText);
if (!section) {
section = { category: categoryText, tasks: [] };
groupedSections.push(section);
}
section.tasks.push({
...defaultTask,
title,
...resolveTaskMetrics(metricsText, defaultTask)
});
});
if (groupedSections.length) return groupedSections;
const looksLikeTaskLine = (value: string) => /^\d+[.]\s*/u.test(value);
const hasNumberedTask = lines.some(looksLikeTaskLine);
const sectionCategory = hasNumberedTask && !looksLikeTaskLine(lines[0]) ? lines[0] : category;
const taskSourceText = (sectionCategory === lines[0] ? lines.slice(1) : lines).join(' ');
const taskParts = hasNumberedTask
? taskSourceText
.split(/(?=\d+[..、]\s*)/u)
.map(item => item.trim())
.filter(Boolean)
: lines;
const tasks = taskParts
.map((item, index) => {
const normalizedTitle = stripTaskOrderPrefix(item);
const metricsText = normalizedTitle.match(/[(]([^)]*)[)]/u)?.[1] || '';
const title = normalizedTitle.replace(/[(][^)]*[)]/u, '').trim();
if (!title) return null;
return {
...(index === 0 ? defaultTask : undefined),
title,
...resolveTaskMetrics(metricsText, index === 0 ? defaultTask : undefined)
};
})
.filter(Boolean) as StructuredTask[];
if (!tasks.length) return [];
return [
{
category: sectionCategory,
tasks
}
];
}
function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.contentJson).map(normalizeSection));
const contentSections = structuredSections.length
? structuredSections
: createStructuredSectionsFromText(item.contentText || '', item.itemTitle || '未分类', {
hours: typeof item.workHours === 'number' ? item.workHours : Number(item.workHours || 0) || undefined
});
: createStructuredSectionsFromTextV2(item.contentText || '', item.itemTitle || '未分类');
const contentHtml = contentSections.length
? createStructuredHtml(contentSections, true)
? createStructuredHtmlV2(contentSections, true)
: escapeHtml(item.contentText || '').replace(/\n/g, '<br>') || EMPTY_TEXT;
return {
@@ -464,9 +473,9 @@ function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem): PlanIte
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.targetJson).map(normalizeSection));
const targetSections = structuredSections.length
? structuredSections
: createStructuredSectionsFromText(item.targetText || '', item.itemTitle || '未分类');
: createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || '未分类');
const targetHtml = targetSections.length
? createStructuredHtml(targetSections)
? createStructuredHtmlV2(targetSections)
: escapeHtml(item.targetText || '').replace(/\n/g, '<br>') || EMPTY_TEXT;
return {
@@ -505,29 +514,38 @@ function getTaskPunctuation(title: string) {
return rawTitle.match(/[。.!?]$/)?.[0] || '';
}
function submitAudit() {
// 校验必填字段
if (!performanceForm.score) {
performanceError.value = '请输入绩效考核结果';
}
if (!mainForm.meetingDate) {
meetingDateError.value = '请选择面谈时间';
}
if (!auditForm.value.conclusion) {
async function submitAudit() {
if (!auditForm.conclusion) {
window.$message?.warning('请选择审批结论');
return;
}
if (!performanceForm.score || !mainForm.meetingDate) {
window.$message?.warning('请填写必填字段');
// 仅"通过"时才校验面谈时间、绩效考核结果等必填项;"退回"时跳过这些校验。
if (auditForm.conclusion !== '退回') {
try {
await validatePageForm();
} catch {
return;
}
}
if (rejectOpinionRequired.value) {
try {
await auditFormRef.value?.validateField?.('opinion');
} catch {
return;
}
}
patchApproval('employeeSignedDate', signatureForm.employeeDate);
patchApproval('supervisorSignedDate', signatureForm.supervisorDate);
patchApproval('reason', auditForm.value.opinion || auditForm.value.conclusion);
patchApproval(
'reason',
rejectOpinionRequired.value ? auditForm.opinion.trim() : auditForm.opinion.trim() || auditForm.conclusion
);
patchApproval('meetingDate', mainForm.meetingDate);
auditDialogVisible.value = false;
if (auditForm.value.conclusion === '不通过') {
if (auditForm.conclusion === '退回') {
emit('requestReject');
} else {
emit('requestApprove');
@@ -535,16 +553,41 @@ function submitAudit() {
}
function openAuditDialog() {
Object.assign(auditForm.value, {
Object.assign(auditForm, {
conclusion: '通过',
opinion: ''
});
auditDialogVisible.value = true;
nextTick(() => {
auditFormRef.value?.clearValidate();
});
}
watch(rejectOpinionRequired, async () => {
if (!auditDialogVisible.value) return;
await nextTick();
auditFormRef.value?.clearValidate('opinion');
});
watch(
() => auditForm.opinion,
value => {
if (!rejectOpinionRequired.value || !value?.trim()) return;
auditFormRef.value?.clearValidate('opinion');
}
);
</script>
<template>
<div class="card form-page">
<ElForm
ref="pageFormRef"
:model="pageValidationModel"
:rules="pageRules"
label-position="top"
:validate-on-rule-change="false"
>
<div class="section">
<div class="section-title">
<span>基础信息</span>
@@ -570,21 +613,14 @@ function openAuditDialog() {
<label>直接上级</label>
<ElInput v-model="mainForm.supervisor" disabled />
</div>
<div class="field">
<div class="field field-form-item">
<label>
<span class="required-mark">*</span>
面谈时间
<span class="field-required-mark">*</span>
</label>
<div class="field-with-error">
<ElDatePicker
v-model="mainForm.meetingDate"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
@blur="handleMeetingDateBlur"
/>
<span v-if="meetingDateError" class="field-error">{{ meetingDateError }}</span>
</div>
<ElFormItem class="field-inline-form-item" prop="meetingDate">
<ElDatePicker v-model="mainForm.meetingDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</ElFormItem>
</div>
</div>
</div>
@@ -609,6 +645,7 @@ function openAuditDialog() {
<span> {{ item.hours }}h</span>
</div>
</div>
<div class="review-action-cell"></div>
<div class="review-editor-grid">
<div class="field">
@@ -689,19 +726,17 @@ function openAuditDialog() {
</div>
<div class="performance-result">
<div class="performance-label">
<span class="required-mark">*</span>
绩效考核结果
<span class="field-required-mark">*</span>
</div>
<div class="performance-input-wrapper">
<ElFormItem class="performance-inline-form-item" prop="performanceResult">
<ElInput
v-model="performanceForm.score"
class="performance-input"
placeholder="请输入考核分数"
@input="handlePerformanceScoreInput"
@blur="handlePerformanceScoreBlur"
/>
<span v-if="performanceError" class="field-error">{{ performanceError }}</span>
</div>
</ElFormItem>
</div>
</div>
@@ -721,6 +756,7 @@ function openAuditDialog() {
<div class="plan-name-cell">
<strong>{{ item.workItem }}</strong>
</div>
<div class="plan-action-cell"></div>
<div class="plan-editor-grid">
<div class="field">
<label>具体目标</label>
@@ -761,6 +797,7 @@ function openAuditDialog() {
/>
</div>
</div>
</ElForm>
<div class="form-actions approval-form-actions">
<ElButton @click="emit('back')">退出审批</ElButton>
@@ -799,21 +836,39 @@ function openAuditDialog() {
<button
type="button"
class="conclusion-btn"
:class="{ active: auditForm.conclusion === '不通过', reject: auditForm.conclusion === '不通过' }"
@click="auditForm.conclusion = '不通过'"
:class="{ active: auditForm.conclusion === '退回', reject: auditForm.conclusion === '退回' }"
@click="auditForm.conclusion = '退回'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
不通过
退回
</button>
</div>
</div>
<div class="audit-field">
<label>审批意见</label>
<ElInput v-model="auditForm.opinion" type="textarea" :rows="3" placeholder="请输入审批意见" />
</div>
<ElForm
ref="auditFormRef"
:model="auditForm"
:rules="auditRules"
label-position="top"
:validate-on-rule-change="false"
>
<ElFormItem
:label="rejectOpinionRequired ? '退回原因' : '审批意见'"
prop="opinion"
:required="rejectOpinionRequired"
>
<ElInput
v-model="auditForm.opinion"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
:placeholder="rejectOpinionRequired ? '请输入退回原因' : '请输入审批意见'"
/>
</ElFormItem>
</ElForm>
</div>
</BusinessFormDialog>
</div>
@@ -986,14 +1041,14 @@ function openAuditDialog() {
}
.review-card {
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr);
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr) 44px;
gap: 0;
padding: 0;
overflow: hidden;
}
.plan-card {
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr);
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr) 44px;
gap: 0;
padding: 0;
overflow: hidden;
@@ -1002,9 +1057,11 @@ function openAuditDialog() {
.review-index-cell,
.review-name-cell,
.review-editor-grid,
.review-action-cell,
.plan-index-cell,
.plan-name-cell,
.plan-editor-grid {
.plan-editor-grid,
.plan-action-cell {
padding: 14px;
}
@@ -1018,6 +1075,8 @@ function openAuditDialog() {
.plan-name-cell {
display: grid;
align-items: center;
min-width: 0;
overflow: hidden;
border-left: 1px solid #e5edf1;
}
@@ -1028,12 +1087,14 @@ function openAuditDialog() {
}
.review-name-cell .work-title-line {
min-width: 0;
flex-direction: column;
gap: 4px;
}
.review-name-cell strong,
.plan-name-cell strong {
display: block;
width: 100%;
min-width: 0;
flex: 0 1 auto;
@@ -1042,8 +1103,10 @@ function openAuditDialog() {
font-weight: 400;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.review-card-head,
@@ -1115,11 +1178,16 @@ function openAuditDialog() {
.review-editor-grid,
.plan-editor-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
grid-template-columns: minmax(0, calc(57.45% - 6px + 50px)) minmax(0, calc(42.55% - 6px - 50px));
gap: 12px;
}
.review-editor-grid,
.review-editor-grid {
grid-column: 3 / span 2;
grid-row: 1;
border-left: 1px solid #e5edf1;
}
.plan-editor-grid {
grid-column: 3 / span 2;
grid-row: 1;
@@ -1244,7 +1312,8 @@ function openAuditDialog() {
}
.rich-editor :deep(.rich-section-tasks) {
display: block;
display: grid;
gap: 6px;
padding-left: 14px;
color: #334155;
font-size: 13px;
@@ -1254,9 +1323,31 @@ function openAuditDialog() {
word-break: break-word;
}
.rich-editor :deep(.rich-category-line) {
color: #0f766e;
font-size: 13px;
font-weight: 700;
line-height: 1.6;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.rich-editor :deep(.rich-section-tasks) {
padding-left: 0;
}
.rich-editor :deep(.rich-task-line) {
color: #334155;
font-size: 13px;
line-height: 1.6;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.rich-editor :deep(.rich-section-task) {
display: inline;
margin-right: 5px;
display: block;
white-space: normal;
}
@@ -1278,8 +1369,8 @@ function openAuditDialog() {
font-size: 11px;
font-weight: 800;
vertical-align: 1px;
user-select: none;
cursor: default;
user-select: text;
cursor: text;
}
.structured-input {
@@ -1327,11 +1418,9 @@ function openAuditDialog() {
}
.review-editor-grid .rich-editor,
.plan-editor-grid .rich-editor,
.review-editor-grid .structured-input,
.plan-editor-grid .structured-input {
.plan-editor-grid .rich-editor {
flex: 1;
height: 100%;
min-height: 86px;
}
.structured-input.auto-height {
@@ -1478,23 +1567,38 @@ function openAuditDialog() {
color: #dc2626;
}
/** 必填字段红色星号 */
.required-mark {
color: #dc2626;
margin-right: 2px;
.field-form-item {
align-self: start;
}
/** 字段错误提示容器 */
.field-with-error {
.field-required-mark {
margin-left: 2px;
color: #f04438;
}
.field-inline-form-item,
.performance-inline-form-item {
margin-bottom: 0;
}
.field-inline-form-item :deep(.el-form-item__content),
.performance-inline-form-item :deep(.el-form-item__content) {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
line-height: 1;
}
/** 字段错误提示文字 */
.field-error {
color: #dc2626;
font-size: 12px;
.field-inline-form-item :deep(.el-form-item__error),
.performance-inline-form-item :deep(.el-form-item__error) {
padding-top: 4px;
}
.field-inline-form-item {
margin-top: -1px;
}
.field-inline-form-item :deep(.el-date-editor) {
width: 100%;
}
.feedback-table {
@@ -1678,12 +1782,18 @@ function openAuditDialog() {
}
.performance-label {
flex: 0 0 auto;
color: #475569;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.performance-inline-form-item {
margin-bottom: 0;
flex: 0 0 auto;
}
.performance-input {
width: 200px;
}
@@ -1757,9 +1867,11 @@ function openAuditDialog() {
.review-index-cell,
.review-name-cell,
.review-editor-grid,
.review-action-cell,
.plan-index-cell,
.plan-name-cell,
.plan-editor-grid {
.plan-editor-grid,
.plan-action-cell {
grid-column: auto;
grid-row: auto;
border-left: 0;

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props, unicorn/prefer-dom-node-text-content, no-useless-escape, no-nested-ternary, no-plusplus, @typescript-eslint/no-shadow */
import { computed, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref } 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';
import { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
@@ -59,12 +60,14 @@ interface StructuredTask {
priority?: string;
progress?: number;
hours?: number;
detail?: string;
}
interface PlanTaskDraft {
title: string;
priority?: StructuredTask['priority'];
progress: number;
detail?: string;
}
interface StructuredSection {
@@ -106,6 +109,37 @@ const planForm = ref({
const activePlanSectionIndex = ref(-1);
/** 工作事项下拉里的"我的事项"固定项,用于区分纯个人事务。 */
const MY_AFFAIRS_TITLE = '我的事项';
/** 「我参与的项目」项目名清单,下拉框数据源。 */
const participatedProjectNames = ref<string[]>([]);
async function loadParticipatedProjectNames() {
// pageSize=-1 一次拉全部(不分页),由后端按"进行中 + 创建时间升序"过滤排序。
const { data, error } = await fetchGetMyParticipatedProjectPage({ pageNo: 1, pageSize: -1 });
if (error || !data) {
participatedProjectNames.value = [];
return;
}
participatedProjectNames.value = data.list
.map(project => project.name?.trim())
.filter((name): name is string => Boolean(name));
}
onMounted(loadParticipatedProjectNames);
const reviewWorkItemOptions = computed(() => {
// 下拉数据来自「我参与的项目」API 拉取的项目名 + 「我的事项」固定项,
// 与周报共用同一份数据源fetchGetMyParticipatedProjectPage, pageSize=-1
const names = new Set<string>(participatedProjectNames.value);
return [MY_AFFAIRS_TITLE, ...Array.from(names).filter(name => name !== MY_AFFAIRS_TITLE)];
});
/** 选中"我的事项"时,事项不参与项目优先级,需要隐藏优先级相关展示。 */
const isMyAffairsPlanItem = (workItem: string) => workItem.trim() === MY_AFFAIRS_TITLE;
const DEFAULT_SECTION_CATEGORY = '工作内容';
function createPlanTaskDraft(): PlanTaskDraft {
return { title: '', priority: '2', progress: 0 };
}
@@ -115,7 +149,8 @@ function normalizeTask(task: WorkReportStructuredTask): StructuredTask {
title: task.title || '',
priority: normalizePriorityCode(task.priority),
progress: typeof task.progress === 'number' ? task.progress : undefined,
hours: typeof task.hours === 'number' ? task.hours : undefined
hours: typeof task.hours === 'number' ? task.hours : undefined,
detail: task.detail || ''
};
}
@@ -147,16 +182,14 @@ function resolveTaskItemTypeLabel(value: string) {
return getTaskItemTypeLabel(value, { fallback: value || '工作内容' });
}
function normalizeSection(section: WorkReportStructuredSection): StructuredSection {
return {
category: resolveTaskItemTypeLabel(section.category || '工作内容'),
tasks: section.tasks.map(normalizeTask)
};
function normalizeSectionCategory(value?: string | null, fallback = DEFAULT_SECTION_CATEGORY) {
const category = resolveTaskItemTypeLabel(value || '').trim();
return category || fallback;
}
function normalizeSectionForMerge(section: WorkReportStructuredSection): StructuredSection {
function normalizeSection(section: WorkReportStructuredSection): StructuredSection {
return {
category: section.category || '工作内容',
category: normalizeSectionCategory(section.category),
tasks: section.tasks.map(normalizeTask)
};
}
@@ -165,7 +198,7 @@ function mergeSectionsByCategory(sections: StructuredSection[]) {
const sectionMap = new Map<string, StructuredSection>();
sections.forEach(section => {
const category = section.category || '未分类';
const category = normalizeSectionCategory(section.category, '未分类');
const existing = sectionMap.get(category);
if (existing) {
existing.tasks.push(...section.tasks);
@@ -191,7 +224,10 @@ function resolveTaskMetricsV2(metricsText: string) {
.map(item => item.trim())
.filter(Boolean);
const priority = normalizePriorityCode(parts.find(item => /^P?\d+$/iu.test(item)));
const progressText = metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1];
// 同时支持 "进度 XX%" 与裸 "XX%",避免失焦后丢失进度。
const progressText =
metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1] ||
parts.find(item => /^\d+(?:\.\d+)?%$/u.test(item))?.replace(/%$/u, '');
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/u)?.[1];
return {
@@ -215,7 +251,7 @@ function formatStructuredTaskLineV2(task: StructuredTask, showHours = false) {
function createStructuredTextV2(sections: StructuredSection[], showHours = false) {
return sections
.map(section => {
const categoryLabel = resolveTaskItemTypeLabel(section.category).trim();
const categoryLabel = normalizeSectionCategory(section.category);
return [
`#${categoryLabel}`,
...section.tasks.map((task, index) => `${index + 1}${formatStructuredTaskLineV2(task, showHours)}`)
@@ -229,7 +265,7 @@ function createStructuredTextV2(sections: StructuredSection[], showHours = false
function createStructuredHtmlV2(sections: StructuredSection[], showHours = false) {
return sections
.map(section => {
const categoryLabel = resolveTaskItemTypeLabel(section.category.trim());
const categoryLabel = normalizeSectionCategory(section.category);
const tasks = section.tasks
.map(
(task, index) =>
@@ -253,7 +289,7 @@ function createStructuredSectionsFromTextV2(text: string, defaultCategory: strin
const sections: StructuredSection[] = [];
const ensureSection = (categoryText: string) => {
const category = categoryText.trim() || defaultCategory;
const category = normalizeSectionCategory(categoryText, defaultCategory);
let section = sections.find(item => item.category === category);
if (!section) {
section = { category, tasks: [] };
@@ -276,63 +312,110 @@ function createStructuredSectionsFromTextV2(text: string, defaultCategory: strin
const legacyMatch = trimmedLine.match(/^(.+?)\s*-\s*(.+?)(?:[(]([^()]*)[)])?[。.!?]*$/u);
if (legacyMatch) {
const [, rawCategory, rawTitle, metricsText = ''] = legacyMatch;
const [, rawCategory, rawTaskText] = legacyMatch;
const category = rawCategory.trim();
const title = rawTitle.trim();
if (!category || !title) return;
const task = parseStructuredSectionTaskText(rawTaskText);
if (!category || !task) return;
ensureSection(category).tasks.push({
title,
...resolveTaskMetricsV2(metricsText)
});
ensureSection(category).tasks.push(task);
currentCategory = category;
return;
}
const normalizedLine = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(trimmedLine));
if (!normalizedLine) return;
const inlineMatch = normalizedLine.match(/^(.*?)(?:[(]([^()]*)[)])?$/u);
const title = inlineMatch?.[1]?.trim() || '';
const metricsText = inlineMatch?.[2]?.trim() || '';
if (!title) return;
const task = parseStructuredSectionTaskText(trimmedLine);
if (!task) return;
ensureSection(currentCategory || defaultCategory).tasks.push({
title,
...resolveTaskMetricsV2(metricsText)
});
const hasStructuredHint =
/^(\d+[..、]\s*)/u.test(trimmedLine) ||
trimmedLine.includes('') ||
trimmedLine.includes('(') ||
trimmedLine.includes('') ||
trimmedLine.includes(':');
if (!hasStructuredHint && !currentCategory) return;
ensureSection(currentCategory || defaultCategory).tasks.push(task);
});
return sections.filter(section => section.tasks.length);
}
function parseStructuredSectionsFromEditorV2(editor: HTMLElement, defaultCategory = '工作内容'): StructuredSection[] {
return createStructuredSectionsFromTextV2(editor.innerText, defaultCategory);
}
function parseStructuredSectionTaskText(
text: string,
fallback?: Partial<StructuredTask>,
useFallback = false
): StructuredTask | null {
const normalizedText = stripStructuredTaskPrefixV2(text);
if (!normalizedText) return null;
function createStructuredText(sections: StructuredSection[], showHours = false) {
const stripOrderPrefix = (text: string) => text.trim().replace(/^\d+[..、]\s*/, '');
const formatTaskText = (task: StructuredTask, taskIndex: number, showHours: boolean) => {
const rawTitle = stripOrderPrefix(task.title);
const punctuation = rawTitle.match(/[。.!?]$/)?.[0] || '';
const title = punctuation ? rawTitle.slice(0, -1) : rawTitle;
const metrics = [
task.priority ? resolvePriorityLabel(task.priority) : '',
typeof task.progress === 'number' ? `进度${task.progress}%` : '',
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
]
.filter(Boolean)
.join('、');
return `${taskIndex + 1}.${title}${metrics ? `${metrics}` : ''}${punctuation}`;
const structuredMatch =
normalizedText.match(/^(.+?)(?:[(]([^()]*)[)])?(?:\s*[:]\s*(.*))?$/u) ||
normalizedText.match(/^(.+?)(?:\(([^()]*)\))?(?::\s*(.*))?$/u);
if (!structuredMatch) return null;
const [, rawTitle, metricsText = '', detail = ''] = structuredMatch;
const title = stripStructuredTaskSuffixV2(rawTitle);
if (!title) return null;
return {
title,
detail: detail.trim(),
...resolveTaskMetrics(metricsText, fallback, useFallback)
};
return sections
.map(section => {
const categoryLabel = resolveTaskItemTypeLabel(section.category);
const tasksText = section.tasks.map((task, taskIndex) => formatTaskText(task, taskIndex, showHours)).join('');
return [categoryLabel, tasksText ? ` ${tasksText}` : ''].filter(Boolean).join('\n');
})
.join('\n');
}
function createFallbackTaskLookup(sections: StructuredSection[]) {
const lookup = new Map<string, StructuredTask[]>();
sections.forEach(section => {
const categoryKey = normalizeSectionCategory(section.category);
section.tasks.forEach(task => {
const titleKey = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(task.title));
const key = `${categoryKey}||${titleKey}`;
const list = lookup.get(key);
if (list) {
list.push(task);
} else {
lookup.set(key, [task]);
}
});
});
return lookup;
}
function parseStructuredSectionsFromEditorV2(
editor: HTMLElement,
fallbackSections: StructuredSection[] = [],
defaultCategory = DEFAULT_SECTION_CATEGORY
): StructuredSection[] {
const parsedSections = createStructuredSectionsFromTextV2(editor.innerText, defaultCategory);
if (!parsedSections.length) return [];
const fallbackLookup = createFallbackTaskLookup(fallbackSections);
return parsedSections.map((section, sectionIndex) => {
const fallbackSection = fallbackSections[sectionIndex];
const categoryKey = normalizeSectionCategory(section.category);
return {
category: section.category,
tasks: section.tasks.map((task, taskIndex) => {
const titleKey = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(task.title));
const matchedByKey = fallbackLookup.get(`${categoryKey}||${titleKey}`)?.shift();
const matchedByIndex = fallbackSection?.tasks[taskIndex];
const fallbackTask = matchedByKey || matchedByIndex;
return {
...task,
detail: fallbackTask?.detail || task.detail || '',
hours: task.hours ?? fallbackTask?.hours
};
})
};
});
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
@@ -482,63 +565,19 @@ function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTa
hours: hoursText === undefined ? (useFallback ? fallback?.hours : undefined) : Number(hoursText)
};
}
function parseStructuredSectionsFromEditor(
editor: HTMLElement,
fallbackSections: StructuredSection[] = [],
defaultCategory = '工作内容'
) {
const sectionElements = Array.from(editor.querySelectorAll<HTMLElement>('.rich-section'));
if (!sectionElements.length) {
return createStructuredSectionsFromText(editor.innerText, defaultCategory);
}
return sectionElements
.map((sectionElement, sectionIndex) => {
const fallbackSection = fallbackSections[sectionIndex];
const category =
getElementText(sectionElement.querySelector('.rich-section-title')) ||
fallbackSection?.category ||
defaultCategory;
const taskElements = Array.from(sectionElement.querySelectorAll<HTMLElement>('.rich-section-task'));
const tasks = taskElements
.map((taskElement, taskIndex) => {
const fallbackTask = fallbackSection?.tasks[taskIndex];
const title = stripTaskOrderPrefix(getElementText(taskElement.querySelector('.rich-section-task-title')));
const metricsElement = taskElement.querySelector('.rich-inline-metrics');
const metrics = metricsElement
? resolveTaskMetrics(getElementText(metricsElement), fallbackTask, false)
: { priority: undefined, progress: undefined, hours: undefined };
if (!title) return null;
return {
title,
...metrics
};
})
.filter(Boolean) as StructuredTask[];
if (!category && !tasks.length) return null;
return {
category,
tasks
};
})
.filter(Boolean) as StructuredSection[];
}
function createSectionsJson(sections: StructuredSection[]) {
return sections.length ? JSON.stringify({ sections }) : null;
}
function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
function getReviewItemSections(item: Api.WorkReport.Common.PersonalReportReviewItem) {
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.contentJson).map(normalizeSection));
const contentSections = structuredSections.length
? structuredSections
: createStructuredSectionsFromTextV2(item.contentText || '', item.itemTitle || '未分类');
if (structuredSections.length) return structuredSections;
return createStructuredSectionsFromTextV2(item.contentText || '', item.itemTitle || DEFAULT_SECTION_CATEGORY);
}
function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
const contentSections = getReviewItemSections(item);
return {
workItem: item.itemTitle || '未命名工作',
@@ -555,11 +594,15 @@ function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): Rev
};
}
function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: number): PlanItem {
function getPlanItemSections(item: Api.WorkReport.Common.PersonalReportPlanItem) {
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.targetJson).map(normalizeSection));
const targetSections = structuredSections.length
? structuredSections
: createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || '未分类');
if (structuredSections.length) return structuredSections;
return createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || DEFAULT_SECTION_CATEGORY);
}
function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: number): PlanItem {
const targetSections = getPlanItemSections(item);
return {
workItem: item.itemTitle || '未命名计划',
@@ -569,7 +612,6 @@ function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: n
: escapeHtml(item.targetText || '').replace(/\n/g, '<br>') || EMPTY_TEXT,
targetSections,
supportNeed: item.supportNeed || '',
/* 只有用户新增的计划才可删除,默认带入的不可删除 */
removable: true,
source: item,
sourceIndex: index
@@ -628,11 +670,16 @@ function removePlanSection(index: number) {
function addPlanTask(sectionIndex: number) {
const section = planForm.value.sections[sectionIndex];
if (!section?.draft.title.trim()) return;
section.tasks.push({
const task: StructuredTask = {
title: section.draft.title.trim(),
priority: section.draft.priority,
progress: section.draft.progress
});
progress: section.draft.progress,
detail: section.draft.detail?.trim() || undefined
};
// 我的事项不参与项目优先级,存进任务前显式剔除,结构化展示括号里就不会出现优先级。
if (!isMyAffairsPlanItem(planForm.value.workItem)) {
task.priority = section.draft.priority;
}
section.tasks.push(task);
section.draft = createPlanTaskDraft();
}
@@ -644,25 +691,17 @@ function submitInlinePlan() {
const sections = planForm.value.sections
.filter(section => section.category.trim() && section.tasks.length)
.map(section => ({
category: section.category.trim(),
tasks: section.tasks
category: normalizeSectionCategory(section.category),
tasks: section.tasks.map(task => ({ ...task }))
}));
if (!sections.length) return;
const target = createStructuredTextV2(sections);
const workItem = planForm.value.workItem.trim();
const sameWorkItem = props.model.planItems.find(item => item.itemTitle.trim() === workItem);
if (sameWorkItem) {
const mergedSections = getStructuredSections(sameWorkItem.targetJson).map(normalizeSectionForMerge);
sections.forEach(section => {
const sameCategory = mergedSections.find(item => item.category === section.category);
if (sameCategory) {
sameCategory.tasks.push(...section.tasks);
} else {
mergedSections.push(section);
}
});
const mergedSections = mergeSectionsByCategory([...getPlanItemSections(sameWorkItem), ...sections]);
sameWorkItem.targetText = createStructuredTextV2(mergedSections);
sameWorkItem.targetJson = JSON.stringify({ sections: mergedSections });
sameWorkItem.targetJson = createSectionsJson(mergedSections);
if (planForm.value.supportNeed.trim()) {
sameWorkItem.supportNeed = [sameWorkItem.supportNeed, planForm.value.supportNeed.trim()]
.filter(Boolean)
@@ -673,7 +712,7 @@ function submitInlinePlan() {
itemNumber: props.model.planItems.length + 1,
itemTitle: workItem,
targetText: target,
targetJson: JSON.stringify({ sections }),
targetJson: createSectionsJson(sections),
supportNeed: planForm.value.supportNeed,
_isNew: true
} as Api.WorkReport.Common.PersonalReportPlanItem & { _isNew: boolean });
@@ -698,15 +737,25 @@ function blurEditField(key: string) {
function syncRichContent(item: ReviewItem, event: Event) {
const target = event.currentTarget as HTMLElement;
if (!item.source) return;
const sections = parseStructuredSectionsFromEditorV2(target, item.workItem || '未分类');
const sections = parseStructuredSectionsFromEditorV2(
target,
item.contentSections || [],
item.contentSections?.[0]?.category || DEFAULT_SECTION_CATEGORY
);
item.source.contentJson = createSectionsJson(sections);
item.source.contentText = createStructuredTextV2(sections, true);
}
function syncRichTarget(item: PlanItem, event: Event) {
const target = event.currentTarget as HTMLElement;
if (!item.source) return;
const sections = parseStructuredSectionsFromEditorV2(target, item.workItem || '未分类');
const sections = parseStructuredSectionsFromEditorV2(
target,
item.targetSections || [],
item.targetSections?.[0]?.category || DEFAULT_SECTION_CATEGORY
);
item.source.targetJson = createSectionsJson(sections);
item.source.targetText = createStructuredTextV2(sections);
}
function syncRichReflection(item: ReviewItem, event: Event) {
@@ -732,7 +781,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
<ElInput v-model="mainForm.reporter" disabled />
</div>
<div class="field">
<label>部门/方向</label>
<label>部门</label>
<ElInput v-model="mainForm.deptName" disabled />
</div>
<div class="field">
@@ -1051,14 +1100,18 @@ function syncRichSupport(item: PlanItem, event: Event) {
>
<div class="plan-dialog-form">
<div class="field">
<label>项目名/事项</label>
<ElInput
<label>工作事项</label>
<ElSelect
v-model="planForm.workItem"
class="inline-plan-name-input"
type="textarea"
:rows="2"
placeholder="请输入项目名或我的事项"
/>
filterable
allow-create
default-first-option
clearable
placeholder="请选择项目名/我的事项"
>
<ElOption v-for="item in reviewWorkItemOptions" :key="item" :label="item" :value="item" />
</ElSelect>
</div>
<div class="field">
<label>具体目标</label>
@@ -1070,6 +1123,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
:class="{ active: activePlanSectionIndex === sIdx }"
>
<div class="plan-section-head">
<div class="field plan-section-head-field">
<label>类别</label>
<DictSelect
v-model="section.category"
:dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE"
@@ -1079,6 +1134,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
@focus="activePlanSectionIndex = sIdx"
@change="activePlanSectionIndex = sIdx"
/>
</div>
<ElButton
v-if="planForm.sections.length > 1"
link
@@ -1093,14 +1149,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
<div class="plan-task-head">
<span>{{ task.title }}</span>
<div class="plan-task-metas">
<template v-if="!isMyAffairsPlanItem(planForm.workItem)">
<em>{{ resolvePriorityLabel(task.priority) }}</em>
</template>
<em>进度 {{ task.progress }}%</em>
</div>
<ElButton link type="danger" size="small" @click="removePlanTask(sIdx, tIdx)">删除</ElButton>
</div>
<div v-if="task.detail" class="plan-task-detail">{{ task.detail }}</div>
</div>
<div class="plan-task-form">
<div class="inline-task-row">
<div
class="inline-task-row"
:class="{ 'inline-task-row--my-affairs': isMyAffairsPlanItem(planForm.workItem) }"
>
<div class="field">
<label>任务名/事项名</label>
<ElInput
@@ -1110,7 +1172,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
@focus="activePlanSectionIndex = sIdx"
/>
</div>
<div class="field">
<div v-if="!isMyAffairsPlanItem(planForm.workItem)" class="field">
<label>优先级</label>
<DictSelect
v-model="section.draft.priority"
@@ -1135,6 +1197,17 @@ function syncRichSupport(item: PlanItem, event: Event) {
/>
</div>
</div>
<div class="field">
<label>详细内容</label>
<ElInput
v-model="section.draft.detail"
size="small"
type="textarea"
:rows="3"
placeholder="详细内容可选"
@focus="activePlanSectionIndex = sIdx"
/>
</div>
<ElButton
class="dialog-inline-action"
type="primary"
@@ -1150,7 +1223,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
</div>
<ElButton
v-if="!planForm.sections.length || planForm.sections.every(s => s.category.trim())"
class="dialog-inline-action dialog-inline-action--secondary"
class="dialog-inline-action"
type="primary"
plain
size="small"
@@ -1885,6 +1958,10 @@ function syncRichSupport(item: PlanItem, event: Event) {
gap: 12px;
}
.inline-task-row--my-affairs {
grid-template-columns: minmax(0, 1fr) 142px;
}
.plan-sections {
display: grid;
gap: 10px;
@@ -1910,7 +1987,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
align-items: end;
}
.plan-section-head :deep(.el-select__wrapper) {
@@ -1932,6 +2009,14 @@ function syncRichSupport(item: PlanItem, event: Event) {
margin-top: 0;
}
.plan-task-detail {
color: #475569;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.plan-task-head {
display: flex;
justify-content: space-between;
@@ -2042,10 +2127,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
border-radius: 999px;
}
.dialog-inline-action--secondary {
justify-self: flex-end;
}
.dialog-inline-action :deep(.el-icon) {
font-size: 13px;
}
@@ -2130,12 +2211,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.form-actions {
position: sticky;
z-index: 5;
bottom: 0;
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: auto;
margin-bottom: 0;
padding: 14px 20px;
border-top: 1px solid #d8e0e8;
background: #fff;
border-bottom-left-radius: 18px;
border-bottom-right-radius: 18px;
background: #f5f7fa;
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
}
.btn-submit {

View File

@@ -998,12 +998,20 @@ function notifyTitleSaved(item: WorkItem) {
}
.form-actions {
position: sticky;
z-index: 5;
bottom: 0;
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: auto;
margin-bottom: 0;
padding: 14px 20px;
border-top: 1px solid #d8e0e8;
background: #fff;
border-bottom-left-radius: 18px;
border-bottom-right-radius: 18px;
background: #f5f7fa;
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
}
.approval-form-actions {

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'))
}
];
}

View File

@@ -20,12 +20,13 @@ import {
formatDateTime,
formatEmptyText,
formatPeriod,
formatPeriodDateRange,
formatWeeklyPeriodLabel,
getWorkReportStatusLabel,
resolveExportFilename,
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import { getIsoWeekDisplay } from '../shared/utils';
import WeeklyReportSearch from './modules/search-panel.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
@@ -73,9 +74,9 @@ const table = useUIPaginatedTable<
label: '周期',
minWidth: 150,
formatter: row => {
const periodText = formatPeriod(row);
const weekLabel = getIsoWeekDisplay(row.periodStartDate);
if (!weekLabel) return periodText;
const periodText = formatWeeklyPeriodLabel(row);
const weekLabel = formatPeriodDateRange(row);
if (!weekLabel || weekLabel === '--') return periodText;
return (
<ElTooltip content={weekLabel} placement="top">
<span>{periodText}</span>
@@ -85,7 +86,7 @@ const table = useUIPaginatedTable<
},
{
prop: 'reporterDeptName',
label: '部门/方向',
label: '部门',
minWidth: 80,
showOverflowTooltip: true,
formatter: row => row.reporterDeptName || '--'

View File

@@ -1,14 +1,16 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props, unicorn/prefer-dom-node-text-content, no-useless-escape, no-nested-ternary */
import { computed, reactive, ref, watch } from 'vue';
import { computed, 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';
import { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import {
type WorkReportStructuredSection,
type WorkReportStructuredTask,
formatPeriodDateRange,
formatPeriodLabel,
getStructuredSections,
getStructuredTasks
@@ -68,6 +70,19 @@ interface StructuredTask {
kind?: string;
}
interface PlanTaskDraft {
title: string;
detail: string;
priority: StructuredTask['priority'];
progress: number;
}
interface PlanSectionDraft {
category: string;
tasks: StructuredTask[];
draft: PlanTaskDraft;
}
interface StructuredSection {
category: string;
tasks: StructuredTask[];
@@ -76,7 +91,6 @@ interface StructuredSection {
interface TravelSegment {
dateRange: [string, string] | null;
days: number | null;
location: string;
workItem: string;
/** 是否为用户新增的分段,新增的才显示删除按钮 */
isNew?: boolean;
@@ -87,6 +101,8 @@ const travelDialogVisible = ref(false);
const isReadonly = computed(() => props.mode === 'detail' || props.scene === 'approval');
const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
const { getLabel: getTaskItemTypeLabel } = useDict(RDMS_TASK_ITEM_TYPE_DICT_CODE);
const DEFAULT_SECTION_CATEGORY = '工作内容';
const TRAVEL_SECTION_CATEGORY = '差旅';
const mainForm = reactive({
get reporter() {
@@ -111,36 +127,62 @@ const businessTripValue = computed({
const EMPTY_HTML = '本周期内暂无数据';
const TRAVEL_REVIEW_ITEM_TITLE = '本周差旅';
/** 工作事项下拉里的"我的事项"固定项,用于区分纯个人事务。 */
const MY_AFFAIRS_TITLE = '我的事项';
const planForm = ref({
workItem: '',
supportNeed: '',
tasks: [] as StructuredTask[]
sections: [] as PlanSectionDraft[]
});
const activePlanSectionIndex = ref(-1);
const planTaskForm = ref({
title: '',
detail: '',
priority: '2' as StructuredTask['priority'],
progress: 0
});
function createPlanTaskDraft(): PlanTaskDraft {
return { title: '', detail: '', priority: '2', progress: 0 };
}
const travelSegments = ref<TravelSegment[]>([]);
const planDialogVisible = ref(false);
/** 「我参与的项目」项目名清单,供“新增计划”工作事项下拉使用。 */
const participatedProjectNames = ref<string[]>([]);
async function loadParticipatedProjectNames() {
// pageSize=-1 一次拉全部(不分页),由后端按"进行中 + 创建时间升序"过滤排序。
const { data, error } = await fetchGetMyParticipatedProjectPage({ pageNo: 1, pageSize: -1 });
if (error || !data) {
participatedProjectNames.value = [];
return;
}
participatedProjectNames.value = data.list
.map(project => project.name?.trim())
.filter((name): name is string => Boolean(name));
}
onMounted(loadParticipatedProjectNames);
const reviewWorkItemOptions = computed(() => {
const titles = new Set<string>();
(props.baseInfo?.reviewItems || []).forEach(item => {
const title = item.itemTitle?.trim();
if (title && title !== TRAVEL_REVIEW_ITEM_TITLE) titles.add(title);
});
(props.model.reviewItems || []).forEach(item => {
const title = item.itemTitle?.trim();
if (title && title !== TRAVEL_REVIEW_ITEM_TITLE) titles.add(title);
});
return Array.from(titles);
// 下拉数据来自「我参与的项目」API 拉取的项目名 + 「我的事项」固定项,
// 供“新增计划-工作事项”下拉使用。
const names = new Set<string>(participatedProjectNames.value);
return [MY_AFFAIRS_TITLE, ...Array.from(names).filter(name => name !== MY_AFFAIRS_TITLE)];
});
const reviewItems = computed<ReviewItem[]>(() => {
return (props.model.reviewItems || []).map(toReviewItem);
});
const travelWorkItemOptions = computed(() => {
const names = reviewItems.value
.map(item => item.workItem.trim())
.filter(name => Boolean(name) && name !== TRAVEL_REVIEW_ITEM_TITLE && name !== MY_AFFAIRS_TITLE);
return [...new Set(names), MY_AFFAIRS_TITLE];
});
/** 选中"我的事项"时,事项不参与项目优先级,需要隐藏优先级相关展示。 */
const isMyAffairsPlanItem = (workItem: string) => workItem.trim() === MY_AFFAIRS_TITLE;
function normalizeTravelDays(value: unknown) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue) || numberValue <= 0) return null;
@@ -171,7 +213,6 @@ function toTravelSegments(segments: Api.WorkReport.Weekly.WeeklyReportTravelSegm
? [normalizeTravelDateText(item.startDate), normalizeTravelDateText(item.endDate)]
: null,
days: normalizeTravelDays(item.travelDays),
location: item.location || '',
workItem: ''
/* 默认带入的分段不带 isNew不可删除 */
}));
@@ -188,8 +229,7 @@ function toModelTravelSegments(segments: TravelSegment[]): Api.WorkReport.Weekly
sort: index + 1,
startDate: normalizeTravelDateText(item.dateRange?.[0]) || '',
endDate: normalizeTravelDateText(item.dateRange?.[1]) || '',
travelDays: normalizeTravelDays(item.days) || 0,
location: item.location.trim()
travelDays: normalizeTravelDays(item.days) || 0
}));
}
@@ -206,8 +246,7 @@ function isSameTravelSegments(
return (
item.startDate === normalizeTravelDateText(targetItem.startDate) &&
item.endDate === normalizeTravelDateText(targetItem.endDate) &&
Number(item.travelDays || 0) === Number(targetItem.travelDays || 0) &&
item.location === (targetItem.location || '')
Number(item.travelDays || 0) === Number(targetItem.travelDays || 0)
);
});
}
@@ -260,9 +299,14 @@ function normalizeTask(task: WorkReportStructuredTask): StructuredTask {
};
}
function normalizeSectionCategory(value?: string | null, fallback = DEFAULT_SECTION_CATEGORY) {
const category = resolveTaskItemTypeLabel(value).trim();
return category || fallback;
}
function normalizeSection(section: WorkReportStructuredSection): StructuredSection {
return {
category: section.category || '未分类',
category: normalizeSectionCategory(section.category, '未分类'),
tasks: section.tasks.map(normalizeTask)
};
}
@@ -271,7 +315,7 @@ function mergeSectionsByCategory(sections: StructuredSection[]) {
const sectionMap = new Map<string, StructuredSection>();
sections.forEach(section => {
const category = section.category.trim() || '未分类';
const category = normalizeSectionCategory(section.category, '未分类');
const existing = sectionMap.get(category);
if (existing) {
existing.tasks.push(...section.tasks);
@@ -337,13 +381,22 @@ function formatStructuredTaskDisplayLine(task: StructuredTask, index: number, sh
return `${index + 1}${formatStructuredTaskLineV2(task, showHours)}`;
}
const DEFAULT_SECTION_CATEGORY = '工作内容';
const TRAVEL_SECTION_CATEGORY = '差旅';
// 周报工作日志弹层展示:后端用中文分号 "" 拼接多条工作日志,
// 在 popover 中按行展示,每条工作日志仍保留末尾的分号。
function formatWorkLogDetail(detail: string): string {
if (!detail) return '';
// 仅按中文分号切分,避免误伤文本中的其他标点;每段保持原样展示。
return detail
.split('')
.map(item => item.trim())
.filter(Boolean)
.join('\n');
}
function createStructuredTextV2(sections: StructuredSection[], showHours = false) {
return sections
.map(section => {
const categoryLabel = resolveTaskItemTypeLabel(section.category).trim();
const categoryLabel = normalizeSectionCategory(section.category);
return [
`#${categoryLabel}`,
...section.tasks.map((task, index) => `${index + 1}${formatStructuredTaskLineV2(task, showHours)}`)
@@ -357,7 +410,7 @@ function createStructuredTextV2(sections: StructuredSection[], showHours = false
function createStructuredHtmlV2(sections: StructuredSection[], showHours = false) {
return sections
.map(section => {
const categoryLabel = resolveTaskItemTypeLabel(section.category.trim());
const categoryLabel = normalizeSectionCategory(section.category);
const tasks = section.tasks
.map(
(task, index) =>
@@ -383,14 +436,6 @@ function createSectionsJson(sections: StructuredSection[]) {
return sections.length ? JSON.stringify({ sections }) : null;
}
function getTaskMetricLabels(task: StructuredTask, showHours = false) {
return [
task.priority ? resolvePriorityLabel(task.priority) : '',
typeof task.progress === 'number' ? `进度${task.progress}%` : '',
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
].filter(Boolean);
}
function createTaskMetrics(task: StructuredTask, showHours: boolean) {
const metrics = [
task.priority
@@ -406,28 +451,6 @@ function isTravelTask(task: StructuredTask) {
return task.kind === 'travel';
}
function hasTravelTasks(tasks: StructuredTask[]) {
return tasks.some(isTravelTask);
}
function createStructuredHtml(tasks: StructuredTask[], showHours = false) {
return tasks
.map(task => {
const title = escapeHtml(task.title.trim());
const detail = escapeHtml(task.detail.trim());
return `
<div class="rich-task">
<div class="rich-task-head">
<div class="rich-task-title">${title}</div>
${createTaskMetrics(task, showHours)}
</div>
${detail && detail !== title ? `<div class="rich-task-detail">${detail}</div>` : ''}
</div>
`;
})
.join('');
}
function normalizeEditorText(value: string) {
return value
.replace(/\u00A0/g, ' ')
@@ -488,7 +511,10 @@ function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTa
.filter(Boolean);
const priorityText = metricsText.match(/\bP\d+\b/iu)?.[0] || metricParts.find(item => /^P?\d+$/iu.test(item));
const priority = normalizePriorityCode(priorityText || (useFallback ? fallback?.priority : undefined));
const progressText = metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1];
// 同时支持 "进度 XX%" 与裸 "XX%",避免失焦后丢失进度。
const progressText =
metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1] ||
metricParts.find(item => /^\d+(?:\.\d+)?%$/u.test(item))?.replace(/%$/u, '');
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/u)?.[1];
return {
@@ -533,7 +559,7 @@ function createStructuredSectionsFromTextV2(
const sections: StructuredSection[] = [];
const ensureSection = (categoryText: string) => {
const category = categoryText.trim() || defaultCategory;
const category = normalizeSectionCategory(categoryText, defaultCategory);
let section = sections.find(item => item.category === category);
if (!section) {
section = { category, tasks: [] };
@@ -554,7 +580,9 @@ function createStructuredSectionsFromTextV2(
return;
}
const legacyMatch = trimmedLine.match(/^(.+?)\s*[-]\s*(.+)$/u);
// 仅当行首不是结构化任务前缀(如 "3、")时,才按旧式 "<分类> - <事项>" 解析;
// 否则会把 "2026-06-12 - 2026-06-19" 这种含 " - " 的出差行误判为分类。
const legacyMatch = trimmedLine.match(/^(?!\d+[、.]\s*)(.+?)\s*[-]\s*(.+)$/u);
if (legacyMatch) {
const [, rawCategory, rawTaskText] = legacyMatch;
const category = rawCategory.trim();
@@ -613,7 +641,7 @@ function parseStructuredSectionsFromEditorV2(
const fallbackLookup = createFallbackTaskLookup(fallbackSections);
return parsedSections.map((section, sectionIndex) => {
const merged: StructuredSection[] = parsedSections.map((section, sectionIndex) => {
const fallbackSection = fallbackSections[sectionIndex];
const categoryKey = resolveTaskItemTypeLabel(section.category).trim();
@@ -634,47 +662,35 @@ function parseStructuredSectionsFromEditorV2(
})
};
});
// 将出差任务从其他分类中抽离,统一归到独立的"差旅"段落,
// 避免失焦后被错误归类到上一个分类下。
return groupTravelTasksIntoSection(merged);
}
function parseStructuredTasksFromEditor(editor: HTMLElement, fallbackTasks: StructuredTask[] = []) {
const taskElements = Array.from(editor.querySelectorAll<HTMLElement>('.rich-task'));
function groupTravelTasksIntoSection(sections: StructuredSection[]) {
const travelTasks: StructuredTask[] = [];
const remainingSections = sections.map(section => {
const kept = section.tasks.filter(task => {
if (isTravelTask(task)) {
travelTasks.push(task);
return false;
}
return true;
});
return { category: section.category, tasks: kept };
});
if (!taskElements.length) {
return createStructuredTasksFromText(editor.innerText);
if (!travelTasks.length) return remainingSections;
const existingTravelIndex = remainingSections.findIndex(section => section.category === TRAVEL_SECTION_CATEGORY);
if (existingTravelIndex >= 0) {
remainingSections[existingTravelIndex].tasks = [...remainingSections[existingTravelIndex].tasks, ...travelTasks];
} else {
remainingSections.push({ category: TRAVEL_SECTION_CATEGORY, tasks: travelTasks });
}
return taskElements
.map((taskElement, index) => {
const fallback = fallbackTasks[index];
const title = getElementText(taskElement.querySelector('.rich-task-title'));
const detail = getElementText(taskElement.querySelector('.rich-task-detail'));
const metrics = resolveTaskMetrics(getElementText(taskElement.querySelector('.rich-task-metas')), fallback);
if (!title && !detail) return null;
return {
title: title || fallback?.title || '未命名事项',
detail,
...metrics
};
})
.filter(Boolean) as StructuredTask[];
}
function createTasksJson(tasks: StructuredTask[]) {
return tasks.length ? JSON.stringify({ tasks }) : null;
}
function getReviewItemTasks(item: Api.WorkReport.Common.PersonalReportReviewItem) {
const jsonTasks = getStructuredTasks(item.contentJson).map(normalizeTask);
if (jsonTasks.length) return jsonTasks;
const jsonSections = getStructuredSections(item.contentJson).map(normalizeSection);
if (jsonSections.length) return flattenSectionTasks(jsonSections);
return createStructuredTasksFromText(item.contentText || '', {
title: item.itemTitle || '未命名工作',
hours: typeof item.workHours === 'number' ? item.workHours : Number(item.workHours || 0) || undefined
});
return remainingSections.filter(section => section.tasks.length);
}
function getReviewItemSections(item: Api.WorkReport.Common.PersonalReportReviewItem) {
@@ -686,6 +702,13 @@ function getReviewItemSections(item: Api.WorkReport.Common.PersonalReportReviewI
});
}
function getPlanItemSections(item: Api.WorkReport.Common.PersonalReportPlanItem) {
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.targetJson).map(normalizeSection));
if (structuredSections.length) return structuredSections;
return createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || DEFAULT_SECTION_CATEGORY);
}
function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
const contentSections = getReviewItemSections(item);
const contentTasks = flattenSectionTasks(contentSections);
@@ -708,10 +731,7 @@ function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): Rev
}
function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: number): PlanItem {
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.targetJson).map(normalizeSection));
const targetSections = structuredSections.length
? structuredSections
: createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || '未分类');
const targetSections = getPlanItemSections(item);
const targetTasks = flattenSectionTasks(targetSections);
const targetHtml = targetSections.length
? createStructuredHtmlV2(targetSections)
@@ -731,10 +751,6 @@ function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: n
};
}
const reviewItems = computed<ReviewItem[]>(() => {
return (props.model.reviewItems || []).map(toReviewItem);
});
const nextPlans = computed<PlanItem[]>(() => {
return (props.model.planItems || []).map(toPlanItem);
});
@@ -745,7 +761,15 @@ const totalHours = computed(() => {
return (props.model.reviewItems || []).reduce((sum, item) => sum + Number(item.workHours || 0), 0);
});
const periodText = computed(() => formatPeriodLabel(props.model.periodLabel || props.period));
const periodText = computed(() => {
const rangeText = formatPeriodDateRange(
props.model.periodStartDate || undefined,
props.model.periodEndDate || undefined
);
if (rangeText !== '--') return rangeText;
return formatPeriodLabel(props.model.periodLabel || props.period) || '--';
});
const totalTravelDays = computed(() => {
const total = travelSegments.value.reduce((sum, segment) => sum + Number(segment.days || 0), 0);
return Math.round(total * 10) / 10;
@@ -819,6 +843,11 @@ function appendTasksToSections(
if (!tasks.length) return nextSections;
// 出差任务始终归到独立的"差旅"段落,避免被误放到上一个普通分类下。
const travelTasks = tasks.filter(isTravelTask);
const otherTasks = tasks.filter(task => !isTravelTask(task));
if (otherTasks.length) {
const targetSection =
nextSections[nextSections.length - 1] ||
(() => {
@@ -826,8 +855,20 @@ function appendTasksToSections(
nextSections.push(section);
return section;
})();
targetSection.tasks.push(...otherTasks);
}
if (travelTasks.length) {
const travelSection =
nextSections.find(section => section.category === TRAVEL_SECTION_CATEGORY) ||
(() => {
const section = { category: TRAVEL_SECTION_CATEGORY, tasks: [] as StructuredTask[] };
nextSections.push(section);
return section;
})();
travelSection.tasks.push(...travelTasks);
}
targetSection.tasks.push(...tasks);
return nextSections;
}
@@ -869,8 +910,7 @@ function addTravelSegment() {
travelSegments.value.push({
dateRange: null,
days: null,
location: '',
workItem: reviewWorkItemOptions.value[0] || '',
workItem: travelWorkItemOptions.value[0] || MY_AFFAIRS_TITLE,
isNew: true
});
}
@@ -880,8 +920,12 @@ function removeTravelSegment(index: number) {
}
function resetPlanForm() {
planForm.value = { workItem: '', supportNeed: '', tasks: [] };
planTaskForm.value = { title: '', detail: '', priority: '2', progress: 0 };
planForm.value = {
workItem: '',
supportNeed: '',
sections: [{ category: '', tasks: [], draft: createPlanTaskDraft() }]
};
activePlanSectionIndex.value = 0;
}
function showInlinePlanForm() {
@@ -894,35 +938,55 @@ function cancelInlinePlan() {
resetPlanForm();
}
function addPlanTask() {
planForm.value.tasks.push({ ...planTaskForm.value, priority: normalizePriorityCode(planTaskForm.value.priority) });
planTaskForm.value = { title: '', detail: '', priority: '2', progress: 0 };
function addPlanSection() {
planForm.value.sections.push({ category: '', tasks: [], draft: createPlanTaskDraft() });
activePlanSectionIndex.value = planForm.value.sections.length - 1;
}
function removePlanTask(index: number) {
planForm.value.tasks.splice(index, 1);
function removePlanSection(index: number) {
planForm.value.sections.splice(index, 1);
if (activePlanSectionIndex.value === index) {
activePlanSectionIndex.value = -1;
} else if (activePlanSectionIndex.value > index) {
activePlanSectionIndex.value -= 1;
}
}
function addPlanTask(sectionIndex: number) {
const section = planForm.value.sections[sectionIndex];
if (!section?.draft.title.trim()) return;
const task: StructuredTask = {
title: section.draft.title.trim(),
detail: section.draft.detail.trim(),
progress: section.draft.progress
};
// 我的事项不参与项目优先级,存进任务前显式剔除,结构化展示括号里就不会出现优先级。
if (!isMyAffairsPlanItem(planForm.value.workItem)) {
task.priority = normalizePriorityCode(section.draft.priority);
}
section.tasks.push(task);
section.draft = createPlanTaskDraft();
}
function removePlanTask(sectionIndex: number, taskIndex: number) {
planForm.value.sections[sectionIndex]?.tasks.splice(taskIndex, 1);
}
function submitInlinePlan() {
const sections: StructuredSection[] = [
{
category: DEFAULT_SECTION_CATEGORY,
tasks: planForm.value.tasks.map(task => ({ ...task }))
}
];
const sections: StructuredSection[] = planForm.value.sections
.filter(section => section.category.trim() && section.tasks.length)
.map(section => ({
category: normalizeSectionCategory(section.category),
tasks: section.tasks.map(task => ({ ...task }))
}));
if (!sections.length) return;
const target = createStructuredTextV2(sections);
const workItem = planForm.value.workItem.trim();
const sameWorkItem = props.model.planItems.find(item => item.itemTitle.trim() === workItem);
if (sameWorkItem) {
const existingSections = mergeSectionsByCategory(
getStructuredSections(sameWorkItem.targetJson).map(normalizeSection)
);
const mergedSections = appendTasksToSections(
existingSections,
sections[0].tasks,
existingSections.at(-1)?.category || DEFAULT_SECTION_CATEGORY
);
const existingSections = getPlanItemSections(sameWorkItem);
const mergedSections = mergeSectionsByCategory([...existingSections, ...sections]);
sameWorkItem.targetText = createStructuredTextV2(mergedSections);
sameWorkItem.targetJson = createSectionsJson(mergedSections);
if (planForm.value.supportNeed.trim()) {
@@ -1048,7 +1112,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
<ElInput v-model="mainForm.reporter" disabled />
</div>
<div class="field">
<label>部门/方向</label>
<label>部门</label>
<ElInput v-model="mainForm.deptName" disabled />
</div>
<div class="field">
@@ -1126,7 +1190,9 @@ function syncRichSupport(item: PlanItem, event: Event) {
{{ formatStructuredTaskDisplayLine(task, taskIndex, true) }}
</div>
</template>
<div class="structured-preview__popover">{{ task.detail || '暂无内容' }}</div>
<div class="structured-preview__popover">
{{ formatWorkLogDetail(task.detail) || '暂无内容' }}
</div>
</ElPopover>
<div v-else class="rich-task-line">
{{ formatStructuredTaskDisplayLine(task, taskIndex, true) }}
@@ -1223,7 +1289,9 @@ function syncRichSupport(item: PlanItem, event: Event) {
{{ formatStructuredTaskDisplayLine(task, taskIndex) }}
</div>
</template>
<div class="structured-preview__popover">{{ task.detail || '暂无内容' }}</div>
<div class="structured-preview__popover">
{{ formatWorkLogDetail(task.detail) || '暂无内容' }}
</div>
</ElPopover>
<div v-else class="rich-task-line">
{{ formatStructuredTaskDisplayLine(task, taskIndex) }}
@@ -1282,55 +1350,101 @@ function syncRichSupport(item: PlanItem, event: Event) {
title="新增下周期重点工作计划"
preset="md"
confirm-text="确认新增"
:confirm-disabled="!planForm.workItem.trim() || !planForm.tasks.length"
:confirm-disabled="
!planForm.workItem.trim() || !planForm.sections.some(section => section.category.trim() && section.tasks.length)
"
@confirm="submitInlinePlan"
@cancel="cancelInlinePlan"
>
<div class="plan-dialog-form">
<div class="field">
<label>项目名/事项名</label>
<ElInput
<label>工作事项</label>
<ElSelect
v-model="planForm.workItem"
class="inline-plan-name-input"
type="textarea"
:rows="2"
placeholder="请输入项目名或我的事项"
/>
filterable
allow-create
default-first-option
clearable
placeholder="请选择项目名/我的事项"
>
<ElOption v-for="item in reviewWorkItemOptions" :key="item" :label="item" :value="item" />
</ElSelect>
</div>
<div class="field">
<label>具体目标</label>
<div class="plan-tasks">
<div v-for="(task, tIdx) in planForm.tasks" :key="tIdx" class="plan-task-item">
<div class="plan-task-item-head">
<div class="plan-sections">
<div
v-for="(section, sIdx) in planForm.sections"
:key="sIdx"
class="plan-section"
:class="{ active: activePlanSectionIndex === sIdx }"
>
<div class="plan-section-head">
<div class="field plan-section-head-field">
<label>类别</label>
<DictSelect
v-model="section.category"
:dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE"
size="small"
placeholder="选择类别"
style="width: 100%"
@focus="activePlanSectionIndex = sIdx"
@change="activePlanSectionIndex = sIdx"
/>
</div>
<ElButton
v-if="planForm.sections.length > 1"
link
type="danger"
size="small"
@click="removePlanSection(sIdx)"
>
删除类别
</ElButton>
</div>
<div v-for="(task, tIdx) in section.tasks" :key="tIdx" class="plan-task">
<div class="plan-task-head">
<span>{{ task.title }}</span>
<div class="plan-task-metas">
<template v-if="!isMyAffairsPlanItem(planForm.workItem)">
<em>{{ resolvePriorityLabel(task.priority) }}</em>
</template>
<em>进度 {{ task.progress }}%</em>
</div>
<ElButton link type="danger" size="small" @click="removePlanTask(tIdx)">删除</ElButton>
<ElButton link type="danger" size="small" @click="removePlanTask(sIdx, tIdx)">删除</ElButton>
</div>
<div v-if="task.detail" class="plan-task-item-detail">{{ task.detail }}</div>
<div v-if="task.detail" class="plan-task-detail">{{ task.detail }}</div>
</div>
<div class="plan-task-form">
<div class="inline-task-row">
<div
class="inline-task-row"
:class="{ 'inline-task-row--my-affairs': isMyAffairsPlanItem(planForm.workItem) }"
>
<div class="field">
<label>任务名/事项名</label>
<ElInput v-model="planTaskForm.title" size="small" placeholder="请输入任务名或事项名" />
<ElInput
v-model="section.draft.title"
size="small"
placeholder="请输入任务名或事项名"
@focus="activePlanSectionIndex = sIdx"
/>
</div>
<div class="field">
<div v-if="!isMyAffairsPlanItem(planForm.workItem)" class="field">
<label>优先级</label>
<DictSelect
v-model="planTaskForm.priority"
v-model="section.draft.priority"
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
:clearable="false"
size="small"
style="width: 100%"
@focus="activePlanSectionIndex = sIdx"
/>
</div>
<div class="field">
<label>进度</label>
<ElInputNumber
v-model="planTaskForm.progress"
v-model="section.draft.progress"
:min="0"
:max="100"
:step="5"
@@ -1338,29 +1452,46 @@ function syncRichSupport(item: PlanItem, event: Event) {
controls-position="right"
size="small"
style="width: 100%"
@focus="activePlanSectionIndex = sIdx"
/>
</div>
</div>
<div class="field">
<label>详细内容</label>
<ElInput
v-model="planTaskForm.detail"
v-model="section.draft.detail"
size="small"
type="textarea"
:rows="3"
placeholder="详细内容可选"
@focus="activePlanSectionIndex = sIdx"
/>
</div>
<ElButton
class="dialog-inline-action"
type="primary"
plain
size="small"
:disabled="!planTaskForm.title.trim()"
@click="addPlanTask"
:disabled="!section.draft.title.trim()"
@click="addPlanTask(sIdx)"
>
<ElIcon><Plus /></ElIcon>
<span>添加</span>
</ElButton>
</div>
</div>
<ElButton
v-if="!planForm.sections.length || planForm.sections.every(s => s.category.trim())"
class="dialog-inline-action dialog-inline-action--secondary"
type="primary"
plain
size="small"
@click="addPlanSection"
>
<ElIcon><Plus /></ElIcon>
<span>类别</span>
</ElButton>
</div>
</div>
<div class="field">
<label>对他人协助的需求</label>
@@ -1397,16 +1528,16 @@ function syncRichSupport(item: PlanItem, event: Event) {
</div>
<div class="travel-grid">
<div class="field">
<label>归属项目/事项</label>
<label>工作事项</label>
<ElSelect
v-model="segment.workItem"
filterable
allow-create
default-first-option
clearable
placeholder="请选择或输入项目名/事项名"
placeholder="请选择或输入工作事项"
>
<ElOption v-for="item in reviewWorkItemOptions" :key="item" :label="item" :value="item" />
<ElOption v-for="item in travelWorkItemOptions" :key="item" :label="item" :value="item" />
</ElSelect>
</div>
<div class="field travel-cycle-field">
@@ -1436,7 +1567,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
/>
</div>
</div>
<!-- <div class="field"><label>地点</label><ElInput v-model="segment.location" placeholder="请输入地点" /></div> -->
</div>
<ElButton
class="travel-add-btn dialog-inline-action"
@@ -2269,28 +2399,61 @@ function syncRichSupport(item: PlanItem, event: Event) {
gap: 12px;
}
.plan-tasks {
.inline-task-row--my-affairs {
grid-template-columns: minmax(0, 1fr) 142px;
}
.plan-sections {
display: grid;
gap: 10px;
}
.plan-task-item {
.plan-section {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #e5edf1;
border-radius: 12px;
background: #fafcfd;
box-shadow: none;
}
.plan-section.active {
border-color: #cfe3e0;
background: #f7fbfa;
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.06);
}
.plan-section-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: end;
}
.plan-section-head :deep(.el-select__wrapper) {
height: 36px;
min-height: 36px;
border-radius: 9px;
}
.plan-task {
display: grid;
gap: 4px;
padding: 9px 12px;
border: 1px solid #e5edf1;
border-radius: 10px;
border: 1px solid #eef2f6;
border-radius: 9px;
background: #f8fbfc;
}
.plan-task-item-head {
.plan-task-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.plan-task-item-head span {
.plan-task-head span {
flex: 1;
min-width: 0;
color: #14213d;
@@ -2323,10 +2486,11 @@ function syncRichSupport(item: PlanItem, event: Event) {
color: #c2410c;
}
.plan-task-item-detail {
.plan-task-detail {
color: #334155;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.plan-task-form {
@@ -2393,11 +2557,37 @@ function syncRichSupport(item: PlanItem, event: Event) {
overflow: hidden;
}
.inline-travel-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
align-items: center;
.travel-dialog-form :deep(.el-input-number) {
width: 100%;
border-radius: 8px;
box-sizing: border-box;
background: #fff;
border: 1px solid var(--el-border-color);
overflow: hidden;
}
.travel-dialog-form :deep(.el-input-number:focus-within) {
border-color: var(--el-color-primary);
}
.travel-dialog-form :deep(.el-input-number .el-input__wrapper) {
box-shadow: none !important;
background: transparent;
border-radius: 0;
}
.travel-dialog-form :deep(.el-input-number.is-controls-right .el-input-number__increase),
.travel-dialog-form :deep(.el-input-number.is-controls-right .el-input-number__decrease) {
right: 0;
height: 18px;
}
.travel-dialog-form :deep(.el-input-number.is-controls-right .el-input-number__increase) {
top: 0;
}
.travel-dialog-form :deep(.el-input-number.is-controls-right .el-input-number__decrease) {
bottom: 0;
}
.inline-plan-name-input {
@@ -2427,12 +2617,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
}
.form-actions {
position: sticky;
z-index: 5;
bottom: 0;
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: auto;
margin-bottom: 0;
padding: 14px 20px;
border-top: 1px solid #d8e0e8;
background: #fff;
border-bottom-left-radius: 18px;
border-bottom-right-radius: 18px;
background: #f5f7fa;
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
}
.approval-form-actions {

View File

@@ -6,6 +6,8 @@ import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import {
fetchApproveOvertimeApplication,
fetchBatchApproveOvertimeApplication,
fetchBatchRejectOvertimeApplication,
fetchChangePersonalItemStatus,
fetchChangeProjectTaskStatus,
fetchGetMonthlyReportApprovalPage,
@@ -28,13 +30,15 @@ import PersonalItemDetailDialog from '@/views/personal-center/my-item/modules/pe
import PersonalItemOperateDialog from '@/views/personal-center/my-item/modules/personal-item-operate-dialog.vue';
import PersonalItemStatusActionDialog from '@/views/personal-center/my-item/modules/personal-item-status-action-dialog.vue';
import OvertimeApplicationActionDialog from '@/views/personal-center/overtime-application/modules/overtime-application-action-dialog.vue';
import OvertimeApplicationBatchDetailDialog from '@/views/personal-center/overtime-application/modules/overtime-application-batch-detail-dialog.vue';
import OvertimeApplicationDetailDialog from '@/views/personal-center/overtime-application/modules/overtime-application-detail-dialog.vue';
import WorkReportPrototypePageDialog from '@/views/personal-center/work-report/shared/components/prototype-page-dialog.vue';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType,
formatPeriod
formatPeriod,
formatWeeklyPeriodLabel
} from '@/views/personal-center/work-report/shared/types';
import {
type WorkbenchTodoDeadlineFilter,
@@ -169,10 +173,19 @@ const overtimeActionVisible = ref(false);
const overtimeActionSubmitting = ref(false);
const currentOvertimeApplication = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const currentOvertimeActionType = ref<OvertimeApprovalActionType>('approve');
const batchDetailVisible = ref(false);
const batchActionVisible = ref(false);
const batchSubmitting = ref(false);
const workReportDetailVisible = ref(false);
const currentWorkReport = ref<WorkReportRow | null>(null);
const currentWorkReportType = ref<WorkReportType>('weekly');
// 批量审批选中状态(存原始加班申请 id避免映射转换
const selectedOvertimeIds = ref<Set<string>>(new Set());
// 批量审批是否为当前操作来源(区分单条审批和批量审批的 submit 回调)
const isBatchActionMode = ref(false);
const OVERTIME_APPROVAL_ACTION_ICONS = {
detail: markRaw(IconMdiEyeOutline)
};
@@ -680,6 +693,97 @@ async function loadPersonalTodoItems() {
);
}
// 批量审批选中状态管理
function isOvertimeItemSelected(item: WorkbenchTodoItem) {
return item.approvalBizId ? selectedOvertimeIds.value.has(item.approvalBizId) : false;
}
function toggleOvertimeItem(item: WorkbenchTodoItem, checked: boolean) {
if (!item.approvalBizId) return;
if (checked) {
selectedOvertimeIds.value.add(item.approvalBizId);
} else {
selectedOvertimeIds.value.delete(item.approvalBizId);
}
}
function toggleSelectAllOvertimeItems(checked: boolean) {
if (checked) {
overtimeApprovalItems.value.forEach(item => {
if (item.approvalBizId) selectedOvertimeIds.value.add(item.approvalBizId);
});
} else {
selectedOvertimeIds.value.clear();
}
}
function clearOvertimeSelection() {
selectedOvertimeIds.value.clear();
}
// 切换审批子页签时清空选中
watch(activeApprovalBizType, () => {
clearOvertimeSelection();
});
// 当前页加班申请是否全部选中(用于全选复选框状态)
const allOvertimeItemsSelected = computed(() => {
const items = overtimeApprovalItems.value;
if (items.length === 0) return false;
return items.every(item => item.approvalBizId && selectedOvertimeIds.value.has(item.approvalBizId));
});
// 当前页是否有部分选中
const someOvertimeItemsSelected = computed(() => {
return overtimeApprovalItems.value.some(
item => item.approvalBizId && selectedOvertimeIds.value.has(item.approvalBizId)
);
});
// 批量审批选中的 id 数组(用于传给批量详情弹窗)
const batchSelectedIds = computed(() => Array.from(selectedOvertimeIds.value));
// 打开批量审批详情弹窗
function handleBatchReview() {
batchDetailVisible.value = true;
}
// 批量详情弹窗中点击"通过"或"退回"
function openBatchActionDialog(actionType: OvertimeApprovalActionType) {
currentOvertimeActionType.value = actionType;
isBatchActionMode.value = true;
batchActionVisible.value = true;
}
// 批量审批提交(对所有选中项执行批量 API
async function handleBatchActionSubmit(reason: string | null) {
const ids = Array.from(selectedOvertimeIds.value);
if (ids.length === 0) return;
const fn =
currentOvertimeActionType.value === 'approve'
? fetchBatchApproveOvertimeApplication
: fetchBatchRejectOvertimeApplication;
batchSubmitting.value = true;
const { error, data } = await fn({ ids, reason });
batchSubmitting.value = false;
if (error || !data) return;
batchActionVisible.value = false;
batchDetailVisible.value = false;
clearOvertimeSelection();
if (data.failCount > 0) {
window.$message?.warning(`成功 ${data.successCount} 条,失败 ${data.failCount}`);
} else {
window.$message?.success(`已批量处理 ${data.successCount}`);
}
await loadOvertimeApprovalItems();
}
async function loadOvertimeApprovalItems() {
const { error, data } = await fetchGetOvertimeApplicationApprovalPage({
pageNo: 1,
@@ -725,7 +829,7 @@ function buildWorkReportApprovalItems<T extends WorkReportRow>(
rows.map(item => ({
id: `${bizType}-${item.id}`,
category: 'approval',
title: `${reportTypeLabel} · ${formatPeriod(item)} 待审批`,
title: `${reportTypeLabel} · ${bizType === 'weekly' ? formatWeeklyPeriodLabel(item) : formatPeriod(item)} 待审批`,
createdTime: item.submitTime || item.createTime || '',
deadline: item.submitTime || item.createTime || null,
source: `${reportTypeLabel} · ${'projectName' in item ? item.projectName : item.reporterName}`,
@@ -882,9 +986,60 @@ onMounted(async () => {
</div>
<div class="workbench-todo__content">
<!-- 批量操作栏仅加班申请待审批时显示 -->
<div
v-if="
activeTab === 'approval' &&
activeApprovalBizType === 'overtime_application' &&
overtimeApprovalItems.length > 0
"
class="workbench-todo__batch-bar"
:class="{ 'workbench-todo__batch-bar--active': selectedOvertimeIds.size > 0 }"
>
<div class="workbench-todo__batch-bar-left">
<ElCheckbox
:model-value="allOvertimeItemsSelected"
:indeterminate="someOvertimeItemsSelected && !allOvertimeItemsSelected"
@change="val => toggleSelectAllOvertimeItems(Boolean(val))"
@click.stop
>
全选
</ElCheckbox>
<span v-if="selectedOvertimeIds.size > 0" class="workbench-todo__batch-bar-count">
已选择 {{ selectedOvertimeIds.size }} 项
</span>
</div>
<div v-if="selectedOvertimeIds.size > 0" class="workbench-todo__batch-bar-right">
<ElButton size="small" type="primary" :loading="batchSubmitting" @click.stop="handleBatchReview">
批量审批
</ElButton>
<ElButton size="small" link @click.stop="clearOvertimeSelection">取消选择</ElButton>
</div>
</div>
<div v-if="pagedItems.length" class="workbench-todo__list">
<article v-for="item in pagedItems" :key="item.id" class="workbench-todo__item">
<article
v-for="item in pagedItems"
:key="item.id"
class="workbench-todo__item"
:class="{
'workbench-todo__item--clickable': Boolean(item.routeKey || item.approvalBizType),
'workbench-todo__item--selected': isOvertimeItemSelected(item)
}"
@click="handleClickItem(item)"
>
<div class="workbench-todo__leading">
<!-- 加班申请待审批时显示复选框 -->
<ElCheckbox
v-if="
activeTab === 'approval' &&
activeApprovalBizType === 'overtime_application' &&
item.approvalBizType === 'overtime_application'
"
:model-value="isOvertimeItemSelected(item)"
@change="val => toggleOvertimeItem(item, Boolean(val))"
@click.stop
/>
<span class="workbench-todo__category" :class="`workbench-todo__category--${item.categoryTone}`">
{{ item.categoryLabel }}
</span>
@@ -1052,6 +1207,7 @@ onMounted(async () => {
:row-data="currentOvertimeApplication"
show-approval-actions
:action-loading="overtimeActionSubmitting"
append-to-body
@approve="openCurrentOvertimeAction('approve')"
@reject="openCurrentOvertimeAction('reject')"
/>
@@ -1059,9 +1215,30 @@ onMounted(async () => {
v-model:visible="overtimeActionVisible"
:action-type="currentOvertimeActionType"
:loading="overtimeActionSubmitting"
append-to-body
@submit="handleOvertimeActionSubmit"
/>
<!-- 批量审批详情弹窗(左右箭头切换 + 通过/退回按钮) -->
<OvertimeApplicationBatchDetailDialog
v-model:visible="batchDetailVisible"
:selected-ids="batchSelectedIds"
:rows="overtimeApprovalRows"
:action-loading="batchSubmitting"
append-to-body
@approve="openBatchActionDialog('approve')"
@reject="openBatchActionDialog('reject')"
/>
<!-- 批量审批意见/退回原因对话框 -->
<OvertimeApplicationActionDialog
v-model:visible="batchActionVisible"
:action-type="currentOvertimeActionType"
:loading="batchSubmitting"
append-to-body
@submit="handleBatchActionSubmit"
/>
<WorkReportPrototypePageDialog
v-model:visible="workReportDetailVisible"
mode="detail"
@@ -1261,6 +1438,44 @@ onMounted(async () => {
margin: auto;
}
/* 批量操作栏 */
.workbench-todo__batch-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 8px 14px;
margin-bottom: 10px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 12px;
background-color: rgb(248 250 252 / 96%);
font-size: 13px;
color: rgb(71 85 105 / 94%);
transition: all 160ms ease;
}
.workbench-todo__batch-bar--active {
border-color: rgb(14 116 144 / 40%);
background-color: rgb(240 253 250 / 80%);
color: rgb(14 116 144 / 96%);
}
.workbench-todo__batch-bar-left {
display: flex;
align-items: center;
gap: 10px;
}
.workbench-todo__batch-bar-right {
display: flex;
align-items: center;
gap: 6px;
}
.workbench-todo__batch-bar-count {
font-weight: 600;
}
.workbench-todo__list {
display: flex;
flex-direction: column;
@@ -1281,6 +1496,20 @@ onMounted(async () => {
background-color 160ms ease;
}
.workbench-todo__item--clickable {
cursor: pointer;
}
.workbench-todo__item--clickable:hover {
border-color: rgb(14 116 144 / 60%);
background-color: rgb(240 253 250 / 84%);
}
.workbench-todo__item--selected {
border-color: rgb(14 116 144 / 60%);
background-color: rgb(240 253 250 / 90%);
}
.workbench-todo__leading {
display: flex;
align-items: center;