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