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

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

View File

@@ -1,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 {