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) { export function fetchDeleteOvertimeApplication(id: string) {
return request<boolean>({ return request<boolean>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,

View File

@@ -59,6 +59,22 @@ declare namespace Api {
reason?: string | null; 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 { interface OvertimeApplicationApprovalRecord {
id: string; id: string;
overtimeApplicationId: 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"> <script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'; import { computed, nextTick, reactive, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { RDMS_OVERTIME_DURATION_DICT_CODE } from '@/constants/dict'; import { RDMS_OVERTIME_DURATION_DICT_CODE } from '@/constants/dict';
import { import {
fetchCreateOvertimeApplication, fetchCreateOvertimeApplication,
@@ -85,8 +86,8 @@ const rules = computed(
function createDefaultModel(): Api.OvertimeApplication.SaveOvertimeApplicationParams { function createDefaultModel(): Api.OvertimeApplication.SaveOvertimeApplicationParams {
return { return {
overtimeDate: '', overtimeDate: dayjs().format('YYYY-MM-DD'),
overtimeDuration: '', overtimeDuration: '0.5',
overtimeReason: '', overtimeReason: '',
overtimeContent: '', overtimeContent: '',
approverId: '' approverId: ''

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <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 */ /* 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 { Plus } from '@element-plus/icons-vue';
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict'; 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 { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue'; import DictSelect from '@/components/custom/dict-select.vue';
@@ -59,12 +60,14 @@ interface StructuredTask {
priority?: string; priority?: string;
progress?: number; progress?: number;
hours?: number; hours?: number;
detail?: string;
} }
interface PlanTaskDraft { interface PlanTaskDraft {
title: string; title: string;
priority?: StructuredTask['priority']; priority?: StructuredTask['priority'];
progress: number; progress: number;
detail?: string;
} }
interface StructuredSection { interface StructuredSection {
@@ -106,6 +109,37 @@ const planForm = ref({
const activePlanSectionIndex = ref(-1); 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 { function createPlanTaskDraft(): PlanTaskDraft {
return { title: '', priority: '2', progress: 0 }; return { title: '', priority: '2', progress: 0 };
} }
@@ -115,7 +149,8 @@ function normalizeTask(task: WorkReportStructuredTask): StructuredTask {
title: task.title || '', title: task.title || '',
priority: normalizePriorityCode(task.priority), priority: normalizePriorityCode(task.priority),
progress: typeof task.progress === 'number' ? task.progress : undefined, 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 || '工作内容' }); return getTaskItemTypeLabel(value, { fallback: value || '工作内容' });
} }
function normalizeSection(section: WorkReportStructuredSection): StructuredSection { function normalizeSectionCategory(value?: string | null, fallback = DEFAULT_SECTION_CATEGORY) {
return { const category = resolveTaskItemTypeLabel(value || '').trim();
category: resolveTaskItemTypeLabel(section.category || '工作内容'), return category || fallback;
tasks: section.tasks.map(normalizeTask)
};
} }
function normalizeSectionForMerge(section: WorkReportStructuredSection): StructuredSection { function normalizeSection(section: WorkReportStructuredSection): StructuredSection {
return { return {
category: section.category || '工作内容', category: normalizeSectionCategory(section.category),
tasks: section.tasks.map(normalizeTask) tasks: section.tasks.map(normalizeTask)
}; };
} }
@@ -165,7 +198,7 @@ function mergeSectionsByCategory(sections: StructuredSection[]) {
const sectionMap = new Map<string, StructuredSection>(); const sectionMap = new Map<string, StructuredSection>();
sections.forEach(section => { sections.forEach(section => {
const category = section.category || '未分类'; const category = normalizeSectionCategory(section.category, '未分类');
const existing = sectionMap.get(category); const existing = sectionMap.get(category);
if (existing) { if (existing) {
existing.tasks.push(...section.tasks); existing.tasks.push(...section.tasks);
@@ -191,7 +224,10 @@ function resolveTaskMetricsV2(metricsText: string) {
.map(item => item.trim()) .map(item => item.trim())
.filter(Boolean); .filter(Boolean);
const priority = normalizePriorityCode(parts.find(item => /^P?\d+$/iu.test(item))); 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]; const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/u)?.[1];
return { return {
@@ -215,7 +251,7 @@ function formatStructuredTaskLineV2(task: StructuredTask, showHours = false) {
function createStructuredTextV2(sections: StructuredSection[], showHours = false) { function createStructuredTextV2(sections: StructuredSection[], showHours = false) {
return sections return sections
.map(section => { .map(section => {
const categoryLabel = resolveTaskItemTypeLabel(section.category).trim(); const categoryLabel = normalizeSectionCategory(section.category);
return [ return [
`#${categoryLabel}`, `#${categoryLabel}`,
...section.tasks.map((task, index) => `${index + 1}${formatStructuredTaskLineV2(task, showHours)}`) ...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) { function createStructuredHtmlV2(sections: StructuredSection[], showHours = false) {
return sections return sections
.map(section => { .map(section => {
const categoryLabel = resolveTaskItemTypeLabel(section.category.trim()); const categoryLabel = normalizeSectionCategory(section.category);
const tasks = section.tasks const tasks = section.tasks
.map( .map(
(task, index) => (task, index) =>
@@ -253,7 +289,7 @@ function createStructuredSectionsFromTextV2(text: string, defaultCategory: strin
const sections: StructuredSection[] = []; const sections: StructuredSection[] = [];
const ensureSection = (categoryText: string) => { const ensureSection = (categoryText: string) => {
const category = categoryText.trim() || defaultCategory; const category = normalizeSectionCategory(categoryText, defaultCategory);
let section = sections.find(item => item.category === category); let section = sections.find(item => item.category === category);
if (!section) { if (!section) {
section = { category, tasks: [] }; section = { category, tasks: [] };
@@ -276,63 +312,110 @@ function createStructuredSectionsFromTextV2(text: string, defaultCategory: strin
const legacyMatch = trimmedLine.match(/^(.+?)\s*-\s*(.+?)(?:[(]([^()]*)[)])?[。.!?]*$/u); const legacyMatch = trimmedLine.match(/^(.+?)\s*-\s*(.+?)(?:[(]([^()]*)[)])?[。.!?]*$/u);
if (legacyMatch) { if (legacyMatch) {
const [, rawCategory, rawTitle, metricsText = ''] = legacyMatch; const [, rawCategory, rawTaskText] = legacyMatch;
const category = rawCategory.trim(); const category = rawCategory.trim();
const title = rawTitle.trim(); const task = parseStructuredSectionTaskText(rawTaskText);
if (!category || !title) return; if (!category || !task) return;
ensureSection(category).tasks.push({ ensureSection(category).tasks.push(task);
title, currentCategory = category;
...resolveTaskMetricsV2(metricsText)
});
return; return;
} }
const normalizedLine = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(trimmedLine)); const task = parseStructuredSectionTaskText(trimmedLine);
if (!normalizedLine) return; if (!task) return;
const inlineMatch = normalizedLine.match(/^(.*?)(?:[(]([^()]*)[)])?$/u);
const title = inlineMatch?.[1]?.trim() || '';
const metricsText = inlineMatch?.[2]?.trim() || '';
if (!title) return;
ensureSection(currentCategory || defaultCategory).tasks.push({ const hasStructuredHint =
title, /^(\d+[..、]\s*)/u.test(trimmedLine) ||
...resolveTaskMetricsV2(metricsText) 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); return sections.filter(section => section.tasks.length);
} }
function parseStructuredSectionsFromEditorV2(editor: HTMLElement, defaultCategory = '工作内容'): StructuredSection[] { function parseStructuredSectionTaskText(
return createStructuredSectionsFromTextV2(editor.innerText, defaultCategory); text: string,
} fallback?: Partial<StructuredTask>,
useFallback = false
): StructuredTask | null {
const normalizedText = stripStructuredTaskPrefixV2(text);
if (!normalizedText) return null;
function createStructuredText(sections: StructuredSection[], showHours = false) { const structuredMatch =
const stripOrderPrefix = (text: string) => text.trim().replace(/^\d+[..、]\s*/, ''); normalizedText.match(/^(.+?)(?:[(]([^()]*)[)])?(?:\s*[:]\s*(.*))?$/u) ||
const formatTaskText = (task: StructuredTask, taskIndex: number, showHours: boolean) => { normalizedText.match(/^(.+?)(?:\(([^()]*)\))?(?::\s*(.*))?$/u);
const rawTitle = stripOrderPrefix(task.title);
const punctuation = rawTitle.match(/[。.!?]$/)?.[0] || ''; if (!structuredMatch) return null;
const title = punctuation ? rawTitle.slice(0, -1) : rawTitle;
const metrics = [ const [, rawTitle, metricsText = '', detail = ''] = structuredMatch;
task.priority ? resolvePriorityLabel(task.priority) : '', const title = stripStructuredTaskSuffixV2(rawTitle);
typeof task.progress === 'number' ? `进度${task.progress}%` : '', if (!title) return null;
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
] return {
.filter(Boolean) title,
.join('、'); detail: detail.trim(),
return `${taskIndex + 1}.${title}${metrics ? `${metrics}` : ''}${punctuation}`; ...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) { function escapeHtml(value: string) {
return value return value
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -482,63 +565,19 @@ function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTa
hours: hoursText === undefined ? (useFallback ? fallback?.hours : undefined) : Number(hoursText) 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[]) { function createSectionsJson(sections: StructuredSection[]) {
return sections.length ? JSON.stringify({ sections }) : null; 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 structuredSections = mergeSectionsByCategory(getStructuredSections(item.contentJson).map(normalizeSection));
const contentSections = structuredSections.length if (structuredSections.length) return structuredSections;
? structuredSections
: createStructuredSectionsFromTextV2(item.contentText || '', item.itemTitle || '未分类'); return createStructuredSectionsFromTextV2(item.contentText || '', item.itemTitle || DEFAULT_SECTION_CATEGORY);
}
function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
const contentSections = getReviewItemSections(item);
return { return {
workItem: item.itemTitle || '未命名工作', 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 structuredSections = mergeSectionsByCategory(getStructuredSections(item.targetJson).map(normalizeSection));
const targetSections = structuredSections.length if (structuredSections.length) return structuredSections;
? structuredSections
: createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || '未分类'); return createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || DEFAULT_SECTION_CATEGORY);
}
function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: number): PlanItem {
const targetSections = getPlanItemSections(item);
return { return {
workItem: item.itemTitle || '未命名计划', 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, : escapeHtml(item.targetText || '').replace(/\n/g, '<br>') || EMPTY_TEXT,
targetSections, targetSections,
supportNeed: item.supportNeed || '', supportNeed: item.supportNeed || '',
/* 只有用户新增的计划才可删除,默认带入的不可删除 */
removable: true, removable: true,
source: item, source: item,
sourceIndex: index sourceIndex: index
@@ -628,11 +670,16 @@ function removePlanSection(index: number) {
function addPlanTask(sectionIndex: number) { function addPlanTask(sectionIndex: number) {
const section = planForm.value.sections[sectionIndex]; const section = planForm.value.sections[sectionIndex];
if (!section?.draft.title.trim()) return; if (!section?.draft.title.trim()) return;
section.tasks.push({ const task: StructuredTask = {
title: section.draft.title.trim(), 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(); section.draft = createPlanTaskDraft();
} }
@@ -644,25 +691,17 @@ function submitInlinePlan() {
const sections = planForm.value.sections const sections = planForm.value.sections
.filter(section => section.category.trim() && section.tasks.length) .filter(section => section.category.trim() && section.tasks.length)
.map(section => ({ .map(section => ({
category: section.category.trim(), category: normalizeSectionCategory(section.category),
tasks: section.tasks tasks: section.tasks.map(task => ({ ...task }))
})); }));
if (!sections.length) return; if (!sections.length) return;
const target = createStructuredTextV2(sections); const target = createStructuredTextV2(sections);
const workItem = planForm.value.workItem.trim(); const workItem = planForm.value.workItem.trim();
const sameWorkItem = props.model.planItems.find(item => item.itemTitle.trim() === workItem); const sameWorkItem = props.model.planItems.find(item => item.itemTitle.trim() === workItem);
if (sameWorkItem) { if (sameWorkItem) {
const mergedSections = getStructuredSections(sameWorkItem.targetJson).map(normalizeSectionForMerge); const mergedSections = mergeSectionsByCategory([...getPlanItemSections(sameWorkItem), ...sections]);
sections.forEach(section => {
const sameCategory = mergedSections.find(item => item.category === section.category);
if (sameCategory) {
sameCategory.tasks.push(...section.tasks);
} else {
mergedSections.push(section);
}
});
sameWorkItem.targetText = createStructuredTextV2(mergedSections); sameWorkItem.targetText = createStructuredTextV2(mergedSections);
sameWorkItem.targetJson = JSON.stringify({ sections: mergedSections }); sameWorkItem.targetJson = createSectionsJson(mergedSections);
if (planForm.value.supportNeed.trim()) { if (planForm.value.supportNeed.trim()) {
sameWorkItem.supportNeed = [sameWorkItem.supportNeed, planForm.value.supportNeed.trim()] sameWorkItem.supportNeed = [sameWorkItem.supportNeed, planForm.value.supportNeed.trim()]
.filter(Boolean) .filter(Boolean)
@@ -673,7 +712,7 @@ function submitInlinePlan() {
itemNumber: props.model.planItems.length + 1, itemNumber: props.model.planItems.length + 1,
itemTitle: workItem, itemTitle: workItem,
targetText: target, targetText: target,
targetJson: JSON.stringify({ sections }), targetJson: createSectionsJson(sections),
supportNeed: planForm.value.supportNeed, supportNeed: planForm.value.supportNeed,
_isNew: true _isNew: true
} as Api.WorkReport.Common.PersonalReportPlanItem & { _isNew: boolean }); } as Api.WorkReport.Common.PersonalReportPlanItem & { _isNew: boolean });
@@ -698,15 +737,25 @@ function blurEditField(key: string) {
function syncRichContent(item: ReviewItem, event: Event) { function syncRichContent(item: ReviewItem, event: Event) {
const target = event.currentTarget as HTMLElement; const target = event.currentTarget as HTMLElement;
if (!item.source) return; 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.contentJson = createSectionsJson(sections);
item.source.contentText = createStructuredTextV2(sections, true);
} }
function syncRichTarget(item: PlanItem, event: Event) { function syncRichTarget(item: PlanItem, event: Event) {
const target = event.currentTarget as HTMLElement; const target = event.currentTarget as HTMLElement;
if (!item.source) return; 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.targetJson = createSectionsJson(sections);
item.source.targetText = createStructuredTextV2(sections);
} }
function syncRichReflection(item: ReviewItem, event: Event) { function syncRichReflection(item: ReviewItem, event: Event) {
@@ -732,7 +781,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
<ElInput v-model="mainForm.reporter" disabled /> <ElInput v-model="mainForm.reporter" disabled />
</div> </div>
<div class="field"> <div class="field">
<label>部门/方向</label> <label>部门</label>
<ElInput v-model="mainForm.deptName" disabled /> <ElInput v-model="mainForm.deptName" disabled />
</div> </div>
<div class="field"> <div class="field">
@@ -1051,14 +1100,18 @@ function syncRichSupport(item: PlanItem, event: Event) {
> >
<div class="plan-dialog-form"> <div class="plan-dialog-form">
<div class="field"> <div class="field">
<label>项目名/事项</label> <label>工作事项</label>
<ElInput <ElSelect
v-model="planForm.workItem" v-model="planForm.workItem"
class="inline-plan-name-input" class="inline-plan-name-input"
type="textarea" filterable
:rows="2" allow-create
placeholder="请输入项目名或我的事项" default-first-option
/> clearable
placeholder="请选择项目名/我的事项"
>
<ElOption v-for="item in reviewWorkItemOptions" :key="item" :label="item" :value="item" />
</ElSelect>
</div> </div>
<div class="field"> <div class="field">
<label>具体目标</label> <label>具体目标</label>
@@ -1070,6 +1123,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
:class="{ active: activePlanSectionIndex === sIdx }" :class="{ active: activePlanSectionIndex === sIdx }"
> >
<div class="plan-section-head"> <div class="plan-section-head">
<div class="field plan-section-head-field">
<label>类别</label>
<DictSelect <DictSelect
v-model="section.category" v-model="section.category"
:dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE" :dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE"
@@ -1079,6 +1134,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
@focus="activePlanSectionIndex = sIdx" @focus="activePlanSectionIndex = sIdx"
@change="activePlanSectionIndex = sIdx" @change="activePlanSectionIndex = sIdx"
/> />
</div>
<ElButton <ElButton
v-if="planForm.sections.length > 1" v-if="planForm.sections.length > 1"
link link
@@ -1093,14 +1149,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
<div class="plan-task-head"> <div class="plan-task-head">
<span>{{ task.title }}</span> <span>{{ task.title }}</span>
<div class="plan-task-metas"> <div class="plan-task-metas">
<template v-if="!isMyAffairsPlanItem(planForm.workItem)">
<em>{{ resolvePriorityLabel(task.priority) }}</em> <em>{{ resolvePriorityLabel(task.priority) }}</em>
</template>
<em>进度 {{ task.progress }}%</em> <em>进度 {{ task.progress }}%</em>
</div> </div>
<ElButton link type="danger" size="small" @click="removePlanTask(sIdx, tIdx)">删除</ElButton> <ElButton link type="danger" size="small" @click="removePlanTask(sIdx, tIdx)">删除</ElButton>
</div> </div>
<div v-if="task.detail" class="plan-task-detail">{{ task.detail }}</div>
</div> </div>
<div class="plan-task-form"> <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"> <div class="field">
<label>任务名/事项名</label> <label>任务名/事项名</label>
<ElInput <ElInput
@@ -1110,7 +1172,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
@focus="activePlanSectionIndex = sIdx" @focus="activePlanSectionIndex = sIdx"
/> />
</div> </div>
<div class="field"> <div v-if="!isMyAffairsPlanItem(planForm.workItem)" class="field">
<label>优先级</label> <label>优先级</label>
<DictSelect <DictSelect
v-model="section.draft.priority" v-model="section.draft.priority"
@@ -1135,6 +1197,17 @@ function syncRichSupport(item: PlanItem, event: Event) {
/> />
</div> </div>
</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 <ElButton
class="dialog-inline-action" class="dialog-inline-action"
type="primary" type="primary"
@@ -1150,7 +1223,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
</div> </div>
<ElButton <ElButton
v-if="!planForm.sections.length || planForm.sections.every(s => s.category.trim())" 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" type="primary"
plain plain
size="small" size="small"
@@ -1885,6 +1958,10 @@ function syncRichSupport(item: PlanItem, event: Event) {
gap: 12px; gap: 12px;
} }
.inline-task-row--my-affairs {
grid-template-columns: minmax(0, 1fr) 142px;
}
.plan-sections { .plan-sections {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1910,7 +1987,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 8px; gap: 8px;
align-items: center; align-items: end;
} }
.plan-section-head :deep(.el-select__wrapper) { .plan-section-head :deep(.el-select__wrapper) {
@@ -1932,6 +2009,14 @@ function syncRichSupport(item: PlanItem, event: Event) {
margin-top: 0; 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 { .plan-task-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -2042,10 +2127,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
border-radius: 999px; border-radius: 999px;
} }
.dialog-inline-action--secondary {
justify-self: flex-end;
}
.dialog-inline-action :deep(.el-icon) { .dialog-inline-action :deep(.el-icon) {
font-size: 13px; font-size: 13px;
} }
@@ -2130,12 +2211,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
} }
.form-actions { .form-actions {
position: sticky;
z-index: 5;
bottom: 0;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 10px; gap: 10px;
margin-top: auto;
margin-bottom: 0;
padding: 14px 20px; padding: 14px 20px;
border-top: 1px solid #d8e0e8; 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 { .btn-submit {

View File

@@ -998,12 +998,20 @@ function notifyTitleSaved(item: WorkItem) {
} }
.form-actions { .form-actions {
position: sticky;
z-index: 5;
bottom: 0;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 10px; gap: 10px;
margin-top: auto;
margin-bottom: 0;
padding: 14px 20px; padding: 14px 20px;
border-top: 1px solid #d8e0e8; 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 { .approval-form-actions {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, watch } from 'vue'; import { computed, nextTick, reactive, watch } from 'vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue'; import BusinessFormSection from '@/components/custom/business-form-section.vue';
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types'; import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
@@ -33,6 +34,9 @@ const emit = defineEmits<{
): void; ): void;
}>(); }>();
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const reasonModel = reactive<Api.WorkReport.Common.StatusActionParams>({ const reasonModel = reactive<Api.WorkReport.Common.StatusActionParams>({
reason: '' reason: ''
}); });
@@ -72,12 +76,35 @@ const title = computed(() => {
}); });
const preset = computed(() => (isMonthlyApprove.value ? 'lg' : 'sm')); 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(() => { const confirmText = computed(() => {
if (isCommonApprove.value) return '确认提交'; if (isCommonApprove.value) return '确认提交';
if (props.actionType === 'approve') return '通过'; if (props.actionType === 'approve') return '通过';
return '退回'; return '退回';
}); });
const confirmDisabled = computed(() => isCommonApprove.value && !commonApprovalModel.conclusion); 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 => { watch(visible, isVisible => {
if (!isVisible) return; 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 (isCommonApprove.value) {
if (!commonApprovalModel.conclusion) { if (!commonApprovalModel.conclusion) {
window.$message?.warning('请选择审批结论'); window.$message?.warning('请选择审批结论');
return; return;
} }
await validate();
emit( emit(
'submit', 'submit',
{ {
reason: commonApprovalModel.opinion || (commonApprovalModel.conclusion === 'approve' ? '通过' : '不通过') reason: commonApprovalModel.opinion.trim() || (commonApprovalModel.conclusion === 'approve' ? '通过' : '退回')
}, },
commonApprovalModel.conclusion commonApprovalModel.conclusion
); );
@@ -176,14 +223,29 @@ function handleSubmit() {
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" /> <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" /> <path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg> </svg>
不通过 退回
</button> </button>
</div> </div>
</div> </div>
<div class="audit-field">
<label>审批意见</label> <ElForm
<ElInput v-model="commonApprovalModel.opinion" type="textarea" :rows="3" placeholder="请输入审批意见" /> ref="formRef"
</div> :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> </div>
</template> </template>

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ import {
createProjectSaveParams, createProjectSaveParams,
createWeeklySaveParams, createWeeklySaveParams,
formatPeriodLabel, formatPeriodLabel,
getStructuredSections,
normalizePlanItems, normalizePlanItems,
normalizeProjectItems, normalizeProjectItems,
normalizeReviewItems normalizeReviewItems
@@ -459,7 +460,6 @@ function isCompleteWeeklyTravelSegment(segment: Api.WorkReport.Weekly.WeeklyRepo
return Boolean( return Boolean(
segment.startDate && segment.startDate &&
segment.endDate && segment.endDate &&
segment.location?.trim() &&
Number.isFinite(travelDays) && Number.isFinite(travelDays) &&
travelDays >= 0.5 && travelDays >= 0.5 &&
Number.isInteger(travelDays * 2) Number.isInteger(travelDays * 2)
@@ -470,6 +470,21 @@ function hasCompleteWeeklyTravelSegment(items: Api.WorkReport.Weekly.WeeklyRepor
return items.some(isCompleteWeeklyTravelSegment); 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[]) { function hasCompletePersonalReviewItem(items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
return items.some(item => hasTextValue(item.itemTitle) && hasReviewContent(item)); return items.some(item => hasTextValue(item.itemTitle) && hasReviewContent(item));
} }
@@ -506,7 +521,12 @@ function validateRequiredReportItems() {
const messages: string[] = []; const messages: string[] = [];
if (props.reportType === 'weekly') { 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 const reviewMessage = hasTravelReview
? weeklyModel.reviewItems.length ? weeklyModel.reviewItems.length
? getPersonalReviewValidationMessage('当期重点工作回顾', weeklyModel.reviewItems) ? getPersonalReviewValidationMessage('当期重点工作回顾', weeklyModel.reviewItems)
@@ -769,6 +789,7 @@ async function handleActionSubmit(
:action-type="currentActionType" :action-type="currentActionType"
:initial-monthly-approve-data="reportType === 'monthly' ? monthlyApprovalDraft : null" :initial-monthly-approve-data="reportType === 'monthly' ? monthlyApprovalDraft : null"
:loading="actionSubmitting" :loading="actionSubmitting"
append-to-body
@submit="handleActionSubmit" @submit="handleActionSubmit"
/> />
</template> </template>

View File

@@ -1,7 +1,10 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import type { PaginationData } from '@sa/hooks'; import type { PaginationData } from '@sa/hooks';
import { getStatusTagType } from '@/constants/status-tag'; import { getStatusTagType } from '@/constants/status-tag';
dayjs.extend(isoWeek);
export type WorkReportType = Api.WorkReport.Common.ReportType; export type WorkReportType = Api.WorkReport.Common.ReportType;
export type WorkReportRow = export type WorkReportRow =
| Api.WorkReport.Weekly.WeeklyReport | Api.WorkReport.Weekly.WeeklyReport
@@ -83,6 +86,40 @@ export function formatDateTime(value?: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '--'; 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) { export function formatPeriodLabel(value?: string | null) {
return String(value || '') return String(value || '')
.trim() .trim()

View File

@@ -54,7 +54,7 @@ export function buildWeeklyPeriodFromDate(date: string | dayjs.Dayjs) {
const start = selectedDate.startOf('isoWeek'); const start = selectedDate.startOf('isoWeek');
const end = selectedDate.endOf('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) { export function buildMonthlyPeriodFromMonth(month: string | dayjs.Dayjs) {
@@ -62,7 +62,7 @@ export function buildMonthlyPeriodFromMonth(month: string | dayjs.Dayjs) {
const start = selectedMonth.startOf('month'); const start = selectedMonth.startOf('month');
const end = selectedMonth.endOf('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) { export function buildProjectPeriodFromMonth(month: string | dayjs.Dayjs, flag: number) {
@@ -92,14 +92,14 @@ export function getWeeklyPeriodOptions(now = dayjs()): WorkReportPeriodOption[]
label: '本周', label: '本周',
description: formatRangeLabel(thisWeekStart, thisWeekEnd), description: formatRangeLabel(thisWeekStart, thisWeekEnd),
reportType: 'weekly', reportType: 'weekly',
period: buildPeriod('weekly', thisWeekStart, thisWeekEnd, `${formatRangeLabel(thisWeekStart, thisWeekEnd)} 周报`) period: buildPeriod('weekly', thisWeekStart, thisWeekEnd, formatRangeLabel(thisWeekStart, thisWeekEnd))
}, },
{ {
key: 'last-week', key: 'last-week',
label: '上周', label: '上周',
description: formatRangeLabel(lastWeekStart, lastWeekEnd), description: formatRangeLabel(lastWeekStart, lastWeekEnd),
reportType: 'weekly', 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: '本月', label: '本月',
description: thisMonthStart.format('YYYY-MM'), description: thisMonthStart.format('YYYY-MM'),
reportType: 'monthly', reportType: 'monthly',
period: buildPeriod('monthly', thisMonthStart, thisMonthEnd, `${thisMonthStart.format('YYYY-MM')} 月报`) period: buildPeriod('monthly', thisMonthStart, thisMonthEnd, thisMonthStart.format('YYYY-MM'))
}, },
{ {
key: 'last-month', key: 'last-month',
label: '上月', label: '上月',
description: lastMonthStart.format('YYYY-MM'), description: lastMonthStart.format('YYYY-MM'),
reportType: 'monthly', 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, formatDateTime,
formatEmptyText, formatEmptyText,
formatPeriod, formatPeriod,
formatPeriodDateRange,
formatWeeklyPeriodLabel,
getWorkReportStatusLabel, getWorkReportStatusLabel,
resolveExportFilename, resolveExportFilename,
resolveWorkReportStatusTagType, resolveWorkReportStatusTagType,
transformWorkReportPage transformWorkReportPage
} from '../shared/types'; } from '../shared/types';
import { getIsoWeekDisplay } from '../shared/utils';
import WeeklyReportSearch from './modules/search-panel.vue'; import WeeklyReportSearch from './modules/search-panel.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline'; import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline'; import IconMdiEyeOutline from '~icons/mdi/eye-outline';
@@ -73,9 +74,9 @@ const table = useUIPaginatedTable<
label: '周期', label: '周期',
minWidth: 150, minWidth: 150,
formatter: row => { formatter: row => {
const periodText = formatPeriod(row); const periodText = formatWeeklyPeriodLabel(row);
const weekLabel = getIsoWeekDisplay(row.periodStartDate); const weekLabel = formatPeriodDateRange(row);
if (!weekLabel) return periodText; if (!weekLabel || weekLabel === '--') return periodText;
return ( return (
<ElTooltip content={weekLabel} placement="top"> <ElTooltip content={weekLabel} placement="top">
<span>{periodText}</span> <span>{periodText}</span>
@@ -85,7 +86,7 @@ const table = useUIPaginatedTable<
}, },
{ {
prop: 'reporterDeptName', prop: 'reporterDeptName',
label: '部门/方向', label: '部门',
minWidth: 80, minWidth: 80,
showOverflowTooltip: true, showOverflowTooltip: true,
formatter: row => row.reporterDeptName || '--' formatter: row => row.reporterDeptName || '--'

View File

@@ -1,14 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
/* eslint-disable vue/no-mutating-props, unicorn/prefer-dom-node-text-content, no-useless-escape, no-nested-ternary */ /* 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 { Plus } from '@element-plus/icons-vue';
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict'; 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 { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue'; import DictSelect from '@/components/custom/dict-select.vue';
import { import {
type WorkReportStructuredSection, type WorkReportStructuredSection,
type WorkReportStructuredTask, type WorkReportStructuredTask,
formatPeriodDateRange,
formatPeriodLabel, formatPeriodLabel,
getStructuredSections, getStructuredSections,
getStructuredTasks getStructuredTasks
@@ -68,6 +70,19 @@ interface StructuredTask {
kind?: string; kind?: string;
} }
interface PlanTaskDraft {
title: string;
detail: string;
priority: StructuredTask['priority'];
progress: number;
}
interface PlanSectionDraft {
category: string;
tasks: StructuredTask[];
draft: PlanTaskDraft;
}
interface StructuredSection { interface StructuredSection {
category: string; category: string;
tasks: StructuredTask[]; tasks: StructuredTask[];
@@ -76,7 +91,6 @@ interface StructuredSection {
interface TravelSegment { interface TravelSegment {
dateRange: [string, string] | null; dateRange: [string, string] | null;
days: number | null; days: number | null;
location: string;
workItem: string; workItem: string;
/** 是否为用户新增的分段,新增的才显示删除按钮 */ /** 是否为用户新增的分段,新增的才显示删除按钮 */
isNew?: boolean; isNew?: boolean;
@@ -87,6 +101,8 @@ const travelDialogVisible = ref(false);
const isReadonly = computed(() => props.mode === 'detail' || props.scene === 'approval'); const isReadonly = computed(() => props.mode === 'detail' || props.scene === 'approval');
const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE); const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
const { getLabel: getTaskItemTypeLabel } = useDict(RDMS_TASK_ITEM_TYPE_DICT_CODE); const { getLabel: getTaskItemTypeLabel } = useDict(RDMS_TASK_ITEM_TYPE_DICT_CODE);
const DEFAULT_SECTION_CATEGORY = '工作内容';
const TRAVEL_SECTION_CATEGORY = '差旅';
const mainForm = reactive({ const mainForm = reactive({
get reporter() { get reporter() {
@@ -111,36 +127,62 @@ const businessTripValue = computed({
const EMPTY_HTML = '本周期内暂无数据'; const EMPTY_HTML = '本周期内暂无数据';
const TRAVEL_REVIEW_ITEM_TITLE = '本周差旅'; const TRAVEL_REVIEW_ITEM_TITLE = '本周差旅';
/** 工作事项下拉里的"我的事项"固定项,用于区分纯个人事务。 */
const MY_AFFAIRS_TITLE = '我的事项';
const planForm = ref({ const planForm = ref({
workItem: '', workItem: '',
supportNeed: '', supportNeed: '',
tasks: [] as StructuredTask[] sections: [] as PlanSectionDraft[]
}); });
const activePlanSectionIndex = ref(-1);
const planTaskForm = ref({ function createPlanTaskDraft(): PlanTaskDraft {
title: '', return { title: '', detail: '', priority: '2', progress: 0 };
detail: '', }
priority: '2' as StructuredTask['priority'],
progress: 0
});
const travelSegments = ref<TravelSegment[]>([]); const travelSegments = ref<TravelSegment[]>([]);
const planDialogVisible = ref(false); 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 reviewWorkItemOptions = computed(() => {
const titles = new Set<string>(); // 下拉数据来自「我参与的项目」API 拉取的项目名 + 「我的事项」固定项,
(props.baseInfo?.reviewItems || []).forEach(item => { // 供“新增计划-工作事项”下拉使用。
const title = item.itemTitle?.trim(); const names = new Set<string>(participatedProjectNames.value);
if (title && title !== TRAVEL_REVIEW_ITEM_TITLE) titles.add(title); return [MY_AFFAIRS_TITLE, ...Array.from(names).filter(name => name !== MY_AFFAIRS_TITLE)];
}); });
(props.model.reviewItems || []).forEach(item => {
const title = item.itemTitle?.trim(); const reviewItems = computed<ReviewItem[]>(() => {
if (title && title !== TRAVEL_REVIEW_ITEM_TITLE) titles.add(title); return (props.model.reviewItems || []).map(toReviewItem);
}); });
return Array.from(titles);
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) { function normalizeTravelDays(value: unknown) {
const numberValue = Number(value); const numberValue = Number(value);
if (!Number.isFinite(numberValue) || numberValue <= 0) return null; 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)] ? [normalizeTravelDateText(item.startDate), normalizeTravelDateText(item.endDate)]
: null, : null,
days: normalizeTravelDays(item.travelDays), days: normalizeTravelDays(item.travelDays),
location: item.location || '',
workItem: '' workItem: ''
/* 默认带入的分段不带 isNew不可删除 */ /* 默认带入的分段不带 isNew不可删除 */
})); }));
@@ -188,8 +229,7 @@ function toModelTravelSegments(segments: TravelSegment[]): Api.WorkReport.Weekly
sort: index + 1, sort: index + 1,
startDate: normalizeTravelDateText(item.dateRange?.[0]) || '', startDate: normalizeTravelDateText(item.dateRange?.[0]) || '',
endDate: normalizeTravelDateText(item.dateRange?.[1]) || '', endDate: normalizeTravelDateText(item.dateRange?.[1]) || '',
travelDays: normalizeTravelDays(item.days) || 0, travelDays: normalizeTravelDays(item.days) || 0
location: item.location.trim()
})); }));
} }
@@ -206,8 +246,7 @@ function isSameTravelSegments(
return ( return (
item.startDate === normalizeTravelDateText(targetItem.startDate) && item.startDate === normalizeTravelDateText(targetItem.startDate) &&
item.endDate === normalizeTravelDateText(targetItem.endDate) && item.endDate === normalizeTravelDateText(targetItem.endDate) &&
Number(item.travelDays || 0) === Number(targetItem.travelDays || 0) && Number(item.travelDays || 0) === Number(targetItem.travelDays || 0)
item.location === (targetItem.location || '')
); );
}); });
} }
@@ -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 { function normalizeSection(section: WorkReportStructuredSection): StructuredSection {
return { return {
category: section.category || '未分类', category: normalizeSectionCategory(section.category, '未分类'),
tasks: section.tasks.map(normalizeTask) tasks: section.tasks.map(normalizeTask)
}; };
} }
@@ -271,7 +315,7 @@ function mergeSectionsByCategory(sections: StructuredSection[]) {
const sectionMap = new Map<string, StructuredSection>(); const sectionMap = new Map<string, StructuredSection>();
sections.forEach(section => { sections.forEach(section => {
const category = section.category.trim() || '未分类'; const category = normalizeSectionCategory(section.category, '未分类');
const existing = sectionMap.get(category); const existing = sectionMap.get(category);
if (existing) { if (existing) {
existing.tasks.push(...section.tasks); existing.tasks.push(...section.tasks);
@@ -337,13 +381,22 @@ function formatStructuredTaskDisplayLine(task: StructuredTask, index: number, sh
return `${index + 1}${formatStructuredTaskLineV2(task, showHours)}`; 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) { function createStructuredTextV2(sections: StructuredSection[], showHours = false) {
return sections return sections
.map(section => { .map(section => {
const categoryLabel = resolveTaskItemTypeLabel(section.category).trim(); const categoryLabel = normalizeSectionCategory(section.category);
return [ return [
`#${categoryLabel}`, `#${categoryLabel}`,
...section.tasks.map((task, index) => `${index + 1}${formatStructuredTaskLineV2(task, showHours)}`) ...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) { function createStructuredHtmlV2(sections: StructuredSection[], showHours = false) {
return sections return sections
.map(section => { .map(section => {
const categoryLabel = resolveTaskItemTypeLabel(section.category.trim()); const categoryLabel = normalizeSectionCategory(section.category);
const tasks = section.tasks const tasks = section.tasks
.map( .map(
(task, index) => (task, index) =>
@@ -383,14 +436,6 @@ function createSectionsJson(sections: StructuredSection[]) {
return sections.length ? JSON.stringify({ sections }) : null; 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) { function createTaskMetrics(task: StructuredTask, showHours: boolean) {
const metrics = [ const metrics = [
task.priority task.priority
@@ -406,28 +451,6 @@ function isTravelTask(task: StructuredTask) {
return task.kind === 'travel'; 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) { function normalizeEditorText(value: string) {
return value return value
.replace(/\u00A0/g, ' ') .replace(/\u00A0/g, ' ')
@@ -488,7 +511,10 @@ function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTa
.filter(Boolean); .filter(Boolean);
const priorityText = metricsText.match(/\bP\d+\b/iu)?.[0] || metricParts.find(item => /^P?\d+$/iu.test(item)); 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 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]; const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/u)?.[1];
return { return {
@@ -533,7 +559,7 @@ function createStructuredSectionsFromTextV2(
const sections: StructuredSection[] = []; const sections: StructuredSection[] = [];
const ensureSection = (categoryText: string) => { const ensureSection = (categoryText: string) => {
const category = categoryText.trim() || defaultCategory; const category = normalizeSectionCategory(categoryText, defaultCategory);
let section = sections.find(item => item.category === category); let section = sections.find(item => item.category === category);
if (!section) { if (!section) {
section = { category, tasks: [] }; section = { category, tasks: [] };
@@ -554,7 +580,9 @@ function createStructuredSectionsFromTextV2(
return; 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) { if (legacyMatch) {
const [, rawCategory, rawTaskText] = legacyMatch; const [, rawCategory, rawTaskText] = legacyMatch;
const category = rawCategory.trim(); const category = rawCategory.trim();
@@ -613,7 +641,7 @@ function parseStructuredSectionsFromEditorV2(
const fallbackLookup = createFallbackTaskLookup(fallbackSections); const fallbackLookup = createFallbackTaskLookup(fallbackSections);
return parsedSections.map((section, sectionIndex) => { const merged: StructuredSection[] = parsedSections.map((section, sectionIndex) => {
const fallbackSection = fallbackSections[sectionIndex]; const fallbackSection = fallbackSections[sectionIndex];
const categoryKey = resolveTaskItemTypeLabel(section.category).trim(); const categoryKey = resolveTaskItemTypeLabel(section.category).trim();
@@ -634,47 +662,35 @@ function parseStructuredSectionsFromEditorV2(
}) })
}; };
}); });
// 将出差任务从其他分类中抽离,统一归到独立的"差旅"段落,
// 避免失焦后被错误归类到上一个分类下。
return groupTravelTasksIntoSection(merged);
} }
function parseStructuredTasksFromEditor(editor: HTMLElement, fallbackTasks: StructuredTask[] = []) { function groupTravelTasksIntoSection(sections: StructuredSection[]) {
const taskElements = Array.from(editor.querySelectorAll<HTMLElement>('.rich-task')); const travelTasks: StructuredTask[] = [];
const remainingSections = sections.map(section => {
if (!taskElements.length) { const kept = section.tasks.filter(task => {
return createStructuredTasksFromText(editor.innerText); if (isTravelTask(task)) {
travelTasks.push(task);
return false;
} }
return true;
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 { category: section.category, tasks: kept };
});
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 remainingSections.filter(section => section.tasks.length);
} }
function getReviewItemSections(item: Api.WorkReport.Common.PersonalReportReviewItem) { 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 { function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
const contentSections = getReviewItemSections(item); const contentSections = getReviewItemSections(item);
const contentTasks = flattenSectionTasks(contentSections); 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 { function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: number): PlanItem {
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.targetJson).map(normalizeSection)); const targetSections = getPlanItemSections(item);
const targetSections = structuredSections.length
? structuredSections
: createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || '未分类');
const targetTasks = flattenSectionTasks(targetSections); const targetTasks = flattenSectionTasks(targetSections);
const targetHtml = targetSections.length const targetHtml = targetSections.length
? createStructuredHtmlV2(targetSections) ? 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[]>(() => { const nextPlans = computed<PlanItem[]>(() => {
return (props.model.planItems || []).map(toPlanItem); 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); 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 totalTravelDays = computed(() => {
const total = travelSegments.value.reduce((sum, segment) => sum + Number(segment.days || 0), 0); const total = travelSegments.value.reduce((sum, segment) => sum + Number(segment.days || 0), 0);
return Math.round(total * 10) / 10; return Math.round(total * 10) / 10;
@@ -819,6 +843,11 @@ function appendTasksToSections(
if (!tasks.length) return nextSections; if (!tasks.length) return nextSections;
// 出差任务始终归到独立的"差旅"段落,避免被误放到上一个普通分类下。
const travelTasks = tasks.filter(isTravelTask);
const otherTasks = tasks.filter(task => !isTravelTask(task));
if (otherTasks.length) {
const targetSection = const targetSection =
nextSections[nextSections.length - 1] || nextSections[nextSections.length - 1] ||
(() => { (() => {
@@ -826,8 +855,20 @@ function appendTasksToSections(
nextSections.push(section); nextSections.push(section);
return 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; return nextSections;
} }
@@ -869,8 +910,7 @@ function addTravelSegment() {
travelSegments.value.push({ travelSegments.value.push({
dateRange: null, dateRange: null,
days: null, days: null,
location: '', workItem: travelWorkItemOptions.value[0] || MY_AFFAIRS_TITLE,
workItem: reviewWorkItemOptions.value[0] || '',
isNew: true isNew: true
}); });
} }
@@ -880,8 +920,12 @@ function removeTravelSegment(index: number) {
} }
function resetPlanForm() { function resetPlanForm() {
planForm.value = { workItem: '', supportNeed: '', tasks: [] }; planForm.value = {
planTaskForm.value = { title: '', detail: '', priority: '2', progress: 0 }; workItem: '',
supportNeed: '',
sections: [{ category: '', tasks: [], draft: createPlanTaskDraft() }]
};
activePlanSectionIndex.value = 0;
} }
function showInlinePlanForm() { function showInlinePlanForm() {
@@ -894,35 +938,55 @@ function cancelInlinePlan() {
resetPlanForm(); resetPlanForm();
} }
function addPlanTask() { function addPlanSection() {
planForm.value.tasks.push({ ...planTaskForm.value, priority: normalizePriorityCode(planTaskForm.value.priority) }); planForm.value.sections.push({ category: '', tasks: [], draft: createPlanTaskDraft() });
planTaskForm.value = { title: '', detail: '', priority: '2', progress: 0 }; activePlanSectionIndex.value = planForm.value.sections.length - 1;
} }
function removePlanTask(index: number) { function removePlanSection(index: number) {
planForm.value.tasks.splice(index, 1); 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() { function submitInlinePlan() {
const sections: StructuredSection[] = [ const sections: StructuredSection[] = planForm.value.sections
{ .filter(section => section.category.trim() && section.tasks.length)
category: DEFAULT_SECTION_CATEGORY, .map(section => ({
tasks: planForm.value.tasks.map(task => ({ ...task })) category: normalizeSectionCategory(section.category),
} tasks: section.tasks.map(task => ({ ...task }))
]; }));
if (!sections.length) return;
const target = createStructuredTextV2(sections); const target = createStructuredTextV2(sections);
const workItem = planForm.value.workItem.trim(); const workItem = planForm.value.workItem.trim();
const sameWorkItem = props.model.planItems.find(item => item.itemTitle.trim() === workItem); const sameWorkItem = props.model.planItems.find(item => item.itemTitle.trim() === workItem);
if (sameWorkItem) { if (sameWorkItem) {
const existingSections = mergeSectionsByCategory( const existingSections = getPlanItemSections(sameWorkItem);
getStructuredSections(sameWorkItem.targetJson).map(normalizeSection) const mergedSections = mergeSectionsByCategory([...existingSections, ...sections]);
);
const mergedSections = appendTasksToSections(
existingSections,
sections[0].tasks,
existingSections.at(-1)?.category || DEFAULT_SECTION_CATEGORY
);
sameWorkItem.targetText = createStructuredTextV2(mergedSections); sameWorkItem.targetText = createStructuredTextV2(mergedSections);
sameWorkItem.targetJson = createSectionsJson(mergedSections); sameWorkItem.targetJson = createSectionsJson(mergedSections);
if (planForm.value.supportNeed.trim()) { if (planForm.value.supportNeed.trim()) {
@@ -1048,7 +1112,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
<ElInput v-model="mainForm.reporter" disabled /> <ElInput v-model="mainForm.reporter" disabled />
</div> </div>
<div class="field"> <div class="field">
<label>部门/方向</label> <label>部门</label>
<ElInput v-model="mainForm.deptName" disabled /> <ElInput v-model="mainForm.deptName" disabled />
</div> </div>
<div class="field"> <div class="field">
@@ -1126,7 +1190,9 @@ function syncRichSupport(item: PlanItem, event: Event) {
{{ formatStructuredTaskDisplayLine(task, taskIndex, true) }} {{ formatStructuredTaskDisplayLine(task, taskIndex, true) }}
</div> </div>
</template> </template>
<div class="structured-preview__popover">{{ task.detail || '暂无内容' }}</div> <div class="structured-preview__popover">
{{ formatWorkLogDetail(task.detail) || '暂无内容' }}
</div>
</ElPopover> </ElPopover>
<div v-else class="rich-task-line"> <div v-else class="rich-task-line">
{{ formatStructuredTaskDisplayLine(task, taskIndex, true) }} {{ formatStructuredTaskDisplayLine(task, taskIndex, true) }}
@@ -1223,7 +1289,9 @@ function syncRichSupport(item: PlanItem, event: Event) {
{{ formatStructuredTaskDisplayLine(task, taskIndex) }} {{ formatStructuredTaskDisplayLine(task, taskIndex) }}
</div> </div>
</template> </template>
<div class="structured-preview__popover">{{ task.detail || '暂无内容' }}</div> <div class="structured-preview__popover">
{{ formatWorkLogDetail(task.detail) || '暂无内容' }}
</div>
</ElPopover> </ElPopover>
<div v-else class="rich-task-line"> <div v-else class="rich-task-line">
{{ formatStructuredTaskDisplayLine(task, taskIndex) }} {{ formatStructuredTaskDisplayLine(task, taskIndex) }}
@@ -1282,55 +1350,101 @@ function syncRichSupport(item: PlanItem, event: Event) {
title="新增下周期重点工作计划" title="新增下周期重点工作计划"
preset="md" preset="md"
confirm-text="确认新增" 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" @confirm="submitInlinePlan"
@cancel="cancelInlinePlan" @cancel="cancelInlinePlan"
> >
<div class="plan-dialog-form"> <div class="plan-dialog-form">
<div class="field"> <div class="field">
<label>项目名/事项名</label> <label>工作事项</label>
<ElInput <ElSelect
v-model="planForm.workItem" v-model="planForm.workItem"
class="inline-plan-name-input" class="inline-plan-name-input"
type="textarea" filterable
:rows="2" allow-create
placeholder="请输入项目名或我的事项" default-first-option
/> clearable
placeholder="请选择项目名/我的事项"
>
<ElOption v-for="item in reviewWorkItemOptions" :key="item" :label="item" :value="item" />
</ElSelect>
</div> </div>
<div class="field"> <div class="field">
<label>具体目标</label> <label>具体目标</label>
<div class="plan-tasks"> <div class="plan-sections">
<div v-for="(task, tIdx) in planForm.tasks" :key="tIdx" class="plan-task-item"> <div
<div class="plan-task-item-head"> 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> <span>{{ task.title }}</span>
<div class="plan-task-metas"> <div class="plan-task-metas">
<template v-if="!isMyAffairsPlanItem(planForm.workItem)">
<em>{{ resolvePriorityLabel(task.priority) }}</em> <em>{{ resolvePriorityLabel(task.priority) }}</em>
</template>
<em>进度 {{ task.progress }}%</em> <em>进度 {{ task.progress }}%</em>
</div> </div>
<ElButton link type="danger" size="small" @click="removePlanTask(tIdx)">删除</ElButton> <ElButton link type="danger" size="small" @click="removePlanTask(sIdx, tIdx)">删除</ElButton>
</div> </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>
<div class="plan-task-form"> <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"> <div class="field">
<label>任务名/事项名</label> <label>任务名/事项名</label>
<ElInput v-model="planTaskForm.title" size="small" placeholder="请输入任务名或事项名" /> <ElInput
v-model="section.draft.title"
size="small"
placeholder="请输入任务名或事项名"
@focus="activePlanSectionIndex = sIdx"
/>
</div> </div>
<div class="field"> <div v-if="!isMyAffairsPlanItem(planForm.workItem)" class="field">
<label>优先级</label> <label>优先级</label>
<DictSelect <DictSelect
v-model="planTaskForm.priority" v-model="section.draft.priority"
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE" :dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
:clearable="false" :clearable="false"
size="small" size="small"
style="width: 100%" style="width: 100%"
@focus="activePlanSectionIndex = sIdx"
/> />
</div> </div>
<div class="field"> <div class="field">
<label>进度</label> <label>进度</label>
<ElInputNumber <ElInputNumber
v-model="planTaskForm.progress" v-model="section.draft.progress"
:min="0" :min="0"
:max="100" :max="100"
:step="5" :step="5"
@@ -1338,29 +1452,46 @@ function syncRichSupport(item: PlanItem, event: Event) {
controls-position="right" controls-position="right"
size="small" size="small"
style="width: 100%" style="width: 100%"
@focus="activePlanSectionIndex = sIdx"
/> />
</div> </div>
</div> </div>
<div class="field">
<label>详细内容</label>
<ElInput <ElInput
v-model="planTaskForm.detail" v-model="section.draft.detail"
size="small" size="small"
type="textarea" type="textarea"
:rows="3" :rows="3"
placeholder="详细内容可选" placeholder="详细内容可选"
@focus="activePlanSectionIndex = sIdx"
/> />
</div>
<ElButton <ElButton
class="dialog-inline-action" class="dialog-inline-action"
type="primary" type="primary"
plain plain
size="small" size="small"
:disabled="!planTaskForm.title.trim()" :disabled="!section.draft.title.trim()"
@click="addPlanTask" @click="addPlanTask(sIdx)"
> >
<ElIcon><Plus /></ElIcon> <ElIcon><Plus /></ElIcon>
<span>添加</span> <span>添加</span>
</ElButton> </ElButton>
</div> </div>
</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>
<div class="field"> <div class="field">
<label>对他人协助的需求</label> <label>对他人协助的需求</label>
@@ -1397,16 +1528,16 @@ function syncRichSupport(item: PlanItem, event: Event) {
</div> </div>
<div class="travel-grid"> <div class="travel-grid">
<div class="field"> <div class="field">
<label>归属项目/事项</label> <label>工作事项</label>
<ElSelect <ElSelect
v-model="segment.workItem" v-model="segment.workItem"
filterable filterable
allow-create allow-create
default-first-option default-first-option
clearable 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> </ElSelect>
</div> </div>
<div class="field travel-cycle-field"> <div class="field travel-cycle-field">
@@ -1436,7 +1567,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
/> />
</div> </div>
</div> </div>
<!-- <div class="field"><label>地点</label><ElInput v-model="segment.location" placeholder="请输入地点" /></div> -->
</div> </div>
<ElButton <ElButton
class="travel-add-btn dialog-inline-action" class="travel-add-btn dialog-inline-action"
@@ -2269,28 +2399,61 @@ function syncRichSupport(item: PlanItem, event: Event) {
gap: 12px; gap: 12px;
} }
.plan-tasks { .inline-task-row--my-affairs {
grid-template-columns: minmax(0, 1fr) 142px;
}
.plan-sections {
display: grid; display: grid;
gap: 10px; 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; display: grid;
gap: 4px; gap: 4px;
padding: 9px 12px; padding: 9px 12px;
border: 1px solid #e5edf1; border: 1px solid #eef2f6;
border-radius: 10px; border-radius: 9px;
background: #f8fbfc; background: #f8fbfc;
} }
.plan-task-item-head { .plan-task-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.plan-task-item-head span { .plan-task-head span {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
color: #14213d; color: #14213d;
@@ -2323,10 +2486,11 @@ function syncRichSupport(item: PlanItem, event: Event) {
color: #c2410c; color: #c2410c;
} }
.plan-task-item-detail { .plan-task-detail {
color: #334155; color: #334155;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
white-space: pre-wrap;
} }
.plan-task-form { .plan-task-form {
@@ -2393,11 +2557,37 @@ function syncRichSupport(item: PlanItem, event: Event) {
overflow: hidden; overflow: hidden;
} }
.inline-travel-actions { .travel-dialog-form :deep(.el-input-number) {
display: flex; width: 100%;
justify-content: flex-end; border-radius: 8px;
gap: 10px; box-sizing: border-box;
align-items: center; 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 { .inline-plan-name-input {
@@ -2427,12 +2617,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
} }
.form-actions { .form-actions {
position: sticky;
z-index: 5;
bottom: 0;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 10px; gap: 10px;
margin-top: auto;
margin-bottom: 0;
padding: 14px 20px; padding: 14px 20px;
border-top: 1px solid #d8e0e8; 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 { .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 { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { import {
fetchApproveOvertimeApplication, fetchApproveOvertimeApplication,
fetchBatchApproveOvertimeApplication,
fetchBatchRejectOvertimeApplication,
fetchChangePersonalItemStatus, fetchChangePersonalItemStatus,
fetchChangeProjectTaskStatus, fetchChangeProjectTaskStatus,
fetchGetMonthlyReportApprovalPage, 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 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 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 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 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 WorkReportPrototypePageDialog from '@/views/personal-center/work-report/shared/components/prototype-page-dialog.vue';
import { import {
WORK_REPORT_TYPE_LABEL, WORK_REPORT_TYPE_LABEL,
type WorkReportRow, type WorkReportRow,
type WorkReportType, type WorkReportType,
formatPeriod formatPeriod,
formatWeeklyPeriodLabel
} from '@/views/personal-center/work-report/shared/types'; } from '@/views/personal-center/work-report/shared/types';
import { import {
type WorkbenchTodoDeadlineFilter, type WorkbenchTodoDeadlineFilter,
@@ -169,10 +173,19 @@ const overtimeActionVisible = ref(false);
const overtimeActionSubmitting = ref(false); const overtimeActionSubmitting = ref(false);
const currentOvertimeApplication = ref<Api.OvertimeApplication.OvertimeApplication | null>(null); const currentOvertimeApplication = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const currentOvertimeActionType = ref<OvertimeApprovalActionType>('approve'); const currentOvertimeActionType = ref<OvertimeApprovalActionType>('approve');
const batchDetailVisible = ref(false);
const batchActionVisible = ref(false);
const batchSubmitting = ref(false);
const workReportDetailVisible = ref(false); const workReportDetailVisible = ref(false);
const currentWorkReport = ref<WorkReportRow | null>(null); const currentWorkReport = ref<WorkReportRow | null>(null);
const currentWorkReportType = ref<WorkReportType>('weekly'); const currentWorkReportType = ref<WorkReportType>('weekly');
// 批量审批选中状态(存原始加班申请 id避免映射转换
const selectedOvertimeIds = ref<Set<string>>(new Set());
// 批量审批是否为当前操作来源(区分单条审批和批量审批的 submit 回调)
const isBatchActionMode = ref(false);
const OVERTIME_APPROVAL_ACTION_ICONS = { const OVERTIME_APPROVAL_ACTION_ICONS = {
detail: markRaw(IconMdiEyeOutline) 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() { async function loadOvertimeApprovalItems() {
const { error, data } = await fetchGetOvertimeApplicationApprovalPage({ const { error, data } = await fetchGetOvertimeApplicationApprovalPage({
pageNo: 1, pageNo: 1,
@@ -725,7 +829,7 @@ function buildWorkReportApprovalItems<T extends WorkReportRow>(
rows.map(item => ({ rows.map(item => ({
id: `${bizType}-${item.id}`, id: `${bizType}-${item.id}`,
category: 'approval', category: 'approval',
title: `${reportTypeLabel} · ${formatPeriod(item)} 待审批`, title: `${reportTypeLabel} · ${bizType === 'weekly' ? formatWeeklyPeriodLabel(item) : formatPeriod(item)} 待审批`,
createdTime: item.submitTime || item.createTime || '', createdTime: item.submitTime || item.createTime || '',
deadline: item.submitTime || item.createTime || null, deadline: item.submitTime || item.createTime || null,
source: `${reportTypeLabel} · ${'projectName' in item ? item.projectName : item.reporterName}`, source: `${reportTypeLabel} · ${'projectName' in item ? item.projectName : item.reporterName}`,
@@ -882,9 +986,60 @@ onMounted(async () => {
</div> </div>
<div class="workbench-todo__content"> <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"> <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"> <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}`"> <span class="workbench-todo__category" :class="`workbench-todo__category--${item.categoryTone}`">
{{ item.categoryLabel }} {{ item.categoryLabel }}
</span> </span>
@@ -1052,6 +1207,7 @@ onMounted(async () => {
:row-data="currentOvertimeApplication" :row-data="currentOvertimeApplication"
show-approval-actions show-approval-actions
:action-loading="overtimeActionSubmitting" :action-loading="overtimeActionSubmitting"
append-to-body
@approve="openCurrentOvertimeAction('approve')" @approve="openCurrentOvertimeAction('approve')"
@reject="openCurrentOvertimeAction('reject')" @reject="openCurrentOvertimeAction('reject')"
/> />
@@ -1059,9 +1215,30 @@ onMounted(async () => {
v-model:visible="overtimeActionVisible" v-model:visible="overtimeActionVisible"
:action-type="currentOvertimeActionType" :action-type="currentOvertimeActionType"
:loading="overtimeActionSubmitting" :loading="overtimeActionSubmitting"
append-to-body
@submit="handleOvertimeActionSubmit" @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 <WorkReportPrototypePageDialog
v-model:visible="workReportDetailVisible" v-model:visible="workReportDetailVisible"
mode="detail" mode="detail"
@@ -1261,6 +1438,44 @@ onMounted(async () => {
margin: auto; 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 { .workbench-todo__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1281,6 +1496,20 @@ onMounted(async () => {
background-color 160ms ease; 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 { .workbench-todo__leading {
display: flex; display: flex;
align-items: center; align-items: center;