Files
cn-rdms-web/src/views/personal-center/work-report/weekly/modules/fill-page.vue

2686 lines
76 KiB
Vue
Raw Normal View History

<script setup lang="ts">
/* eslint-disable vue/no-mutating-props, unicorn/prefer-dom-node-text-content, no-useless-escape, no-nested-ternary */
import { computed, nextTick, 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
} from '../../shared/types';
const props = defineProps<{
reportType: string;
period: string;
mode?: 'add' | 'edit' | 'detail';
scene?: 'fill' | 'detail' | 'approval';
baseInfo?: Api.WorkReport.Weekly.WeeklyReport | null;
model: Api.WorkReport.Weekly.WeeklyReportSaveParams;
}>();
const emit = defineEmits<{
back: [];
save: [];
submit: [];
viewAudit: [];
requestApprove: [];
requestReject: [];
pullDefaultDraft: [];
}>();
interface ReviewItem {
workItem: string;
days: number;
hours?: number;
content: string;
contentHtml: string;
contentSections?: StructuredSection[];
contentTasks?: StructuredTask[];
travelTasks?: StructuredTask[];
reflection: string;
removable?: boolean;
source?: Api.WorkReport.Common.PersonalReportReviewItem;
}
interface PlanItem {
workItem: string;
target: string;
targetHtml: string;
targetSections?: StructuredSection[];
targetTasks?: StructuredTask[];
supportNeed: string;
removable?: boolean;
source?: Api.WorkReport.Common.PersonalReportPlanItem;
sourceIndex?: number;
}
interface StructuredTask {
title: string;
detail: string;
priority?: string;
progress?: number;
hours?: number;
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[];
}
interface TravelSegment {
dateRange: [string, string] | null;
days: number | null;
workItem: string;
/** 是否为用户新增的分段,新增的才显示删除按钮 */
isNew?: boolean;
}
const activeEditField = ref('');
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() {
return props.baseInfo?.reporterName || '--';
},
get deptName() {
return props.baseInfo?.reporterDeptName || '--';
},
get postName() {
return props.baseInfo?.reporterPostName || '--';
},
get supervisor() {
return props.baseInfo?.supervisorName || '--';
}
});
/** 是否出差:由父级 model 作为唯一提交源,本地编辑态只承载未完成的出差分段。 */
const businessTripValue = computed({
get: () => props.model.isBusinessTrip,
set: value => updateBusinessTrip(value)
});
const EMPTY_HTML = '本周期内暂无数据';
const TRAVEL_REVIEW_ITEM_TITLE = '本周差旅';
/** 工作事项下拉里的"我的事项"固定项,用于区分纯个人事务。 */
const MY_AFFAIRS_TITLE = '我的事项';
const planForm = ref({
workItem: '',
supportNeed: '',
sections: [] as PlanSectionDraft[]
});
const activePlanSectionIndex = ref(-1);
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(() => {
// 下拉数据来自「我参与的项目」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;
return Math.round(numberValue * 2) / 2;
}
function isValidTravelDays(value: unknown) {
const normalizedValue = normalizeTravelDays(value);
return normalizedValue !== null && normalizedValue >= 0.5 && Number.isInteger(normalizedValue * 2);
}
function normalizeTravelDateText(value?: string | null) {
if (!value) return '';
const commaDateMatch = value.match(/^(\d{4}),(\d{1,2}),(\d{1,2})$/);
if (commaDateMatch) {
const [, year, month, day] = commaDateMatch;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
return value;
}
function toTravelSegments(segments: Api.WorkReport.Weekly.WeeklyReportTravelSegment[] | undefined): TravelSegment[] {
return (segments || []).map(item => ({
dateRange:
item.startDate && item.endDate
? [normalizeTravelDateText(item.startDate), normalizeTravelDateText(item.endDate)]
: null,
days: normalizeTravelDays(item.travelDays),
workItem: ''
/* 默认带入的分段不带 isNew不可删除 */
}));
}
function isCompleteTravelSegment(segment: TravelSegment) {
return Boolean(
segment.dateRange?.[0] && segment.dateRange?.[1] && isValidTravelDays(segment.days) && segment.workItem.trim()
);
}
function toModelTravelSegments(segments: TravelSegment[]): Api.WorkReport.Weekly.WeeklyReportTravelSegment[] {
return segments.filter(isCompleteTravelSegment).map((item, index) => ({
sort: index + 1,
startDate: normalizeTravelDateText(item.dateRange?.[0]) || '',
endDate: normalizeTravelDateText(item.dateRange?.[1]) || '',
travelDays: normalizeTravelDays(item.days) || 0
}));
}
function isSameTravelSegments(
source: TravelSegment[],
target: Api.WorkReport.Weekly.WeeklyReportTravelSegment[] | undefined
) {
const sourceSegments = toModelTravelSegments(source);
const targetSegments = target || [];
if (sourceSegments.length !== targetSegments.length) return false;
return sourceSegments.every((item, index) => {
const targetItem = targetSegments[index];
return (
item.startDate === normalizeTravelDateText(targetItem.startDate) &&
item.endDate === normalizeTravelDateText(targetItem.endDate) &&
Number(item.travelDays || 0) === Number(targetItem.travelDays || 0)
);
});
}
function syncTravelSegmentsToModel() {
const nextSegments = toModelTravelSegments(travelSegments.value);
if (isSameTravelSegments(travelSegments.value, props.model.travelSegments)) return;
props.model.travelSegments = nextSegments;
}
watch(
() => props.model.travelSegments,
segments => {
if (isSameTravelSegments(travelSegments.value, segments)) return;
travelSegments.value = toTravelSegments(segments);
},
{ immediate: true, deep: true }
);
watch(travelSegments, () => syncTravelSegmentsToModel(), { deep: true });
function createStructuredText(tasks: StructuredTask[]) {
return tasks
.map(task => {
const title = task.title.trim();
const detail = task.detail.trim();
if (!detail || detail === title) return title;
return `${title}\n ${detail}`;
})
.join('\n');
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function normalizeTask(task: WorkReportStructuredTask): StructuredTask {
return {
title: task.title || '',
detail: task.detail || '',
priority: normalizePriorityCode(task.priority),
progress: typeof task.progress === 'number' ? task.progress : undefined,
hours: typeof task.hours === 'number' ? task.hours : undefined,
kind: task.kind ? String(task.kind) : undefined
};
}
function normalizeSectionCategory(value?: string | null, fallback = DEFAULT_SECTION_CATEGORY) {
const category = resolveTaskItemTypeLabel(value).trim();
return category || fallback;
}
function normalizeSection(section: WorkReportStructuredSection): StructuredSection {
return {
category: normalizeSectionCategory(section.category, '未分类'),
tasks: section.tasks.map(normalizeTask)
};
}
function mergeSectionsByCategory(sections: StructuredSection[]) {
const sectionMap = new Map<string, StructuredSection>();
sections.forEach(section => {
const category = normalizeSectionCategory(section.category, '未分类');
const existing = sectionMap.get(category);
if (existing) {
existing.tasks.push(...section.tasks);
} else {
sectionMap.set(category, {
category,
tasks: [...section.tasks]
});
}
});
return Array.from(sectionMap.values()).filter(section => section.tasks.length);
}
function normalizePriorityCode(value?: string | number | null) {
const text = String(value ?? '').trim();
if (!text) return undefined;
const matchedByValue = priorityDictData.value.find(item => String(item.value) === text);
if (matchedByValue) return String(matchedByValue.value);
const matchedByLabel = priorityDictData.value.find(item => item.label === text);
if (matchedByLabel) return String(matchedByLabel.value);
const priorityLabelMatch = text.match(/^P(\d+)$/iu);
if (priorityLabelMatch) return priorityLabelMatch[1];
return text;
}
function resolvePriorityLabel(value?: string | number | null) {
const priorityCode = normalizePriorityCode(value);
if (!priorityCode) return '';
return getPriorityLabel(priorityCode, {
fallback: /^P\d+$/iu.test(priorityCode) ? priorityCode : `P${priorityCode}`
});
}
function resolveTaskItemTypeLabel(value?: string | null) {
return getTaskItemTypeLabel(value, { fallback: value || '工作内容' });
}
function stripStructuredTaskPrefixV2(value: string) {
return value.trim().replace(/^\d+[..、]\s*/u, '');
}
function stripStructuredTaskSuffixV2(value: string) {
return value.trim().replace(/[。.!?]+$/u, '');
}
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 formatStructuredTaskDisplayLine(task: StructuredTask, index: number, showHours = false) {
return `${index + 1}${formatStructuredTaskLineV2(task, showHours)}`;
}
// 周报工作日志弹层展示:后端用中文分号 "" 拼接多条工作日志,
// 在 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 = normalizeSectionCategory(section.category);
return [
`#${categoryLabel}`,
...section.tasks.map((task, index) => `${index + 1}${formatStructuredTaskLineV2(task, showHours)}`)
]
.filter(Boolean)
.join('\n');
})
.join('\n');
}
function createStructuredHtmlV2(sections: StructuredSection[], showHours = false) {
return sections
.map(section => {
const categoryLabel = normalizeSectionCategory(section.category);
const tasks = section.tasks
.map(
(task, index) =>
`<div class="rich-task-line">${escapeHtml(formatStructuredTaskDisplayLine(task, index, showHours))}</div>`
)
.join('');
return `
<div class="rich-section">
<div class="rich-category-line"># ${escapeHtml(categoryLabel)}</div>
${tasks ? `<div class="rich-section-tasks">${tasks}</div>` : ''}
</div>
`;
})
.join('');
}
function flattenSectionTasks(sections: StructuredSection[]) {
return sections.flatMap(section => section.tasks.map(task => ({ ...task })));
}
function createSectionsJson(sections: StructuredSection[]) {
return sections.length ? JSON.stringify({ sections }) : null;
}
function createTaskMetrics(task: StructuredTask, showHours: boolean) {
const metrics = [
task.priority
? `<span class="rich-task-meta priority">${escapeHtml(resolvePriorityLabel(task.priority))}</span>`
: '',
typeof task.progress === 'number' ? `<span class="rich-task-meta">进度 ${task.progress}%</span>` : '',
showHours && typeof task.hours === 'number' ? `<span class="rich-task-meta">${task.hours}h</span>` : ''
].filter(Boolean);
return metrics.length ? `<div class="rich-task-metas" contenteditable="false">${metrics.join('')}</div>` : '';
}
function isTravelTask(task: StructuredTask) {
return task.kind === 'travel';
}
function normalizeEditorText(value: string) {
return value
.replace(/\u00A0/g, ' ')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n');
}
function createStructuredTasksFromText(text: string, defaultTask?: Partial<StructuredTask>) {
const lines = normalizeEditorText(text).split('\n').filter(Boolean);
if (!lines.length || lines.join('\n') === EMPTY_HTML) return [];
const defaultTitle = String(defaultTask?.title || '').trim();
const tasks = lines
.map(line => {
const structuredMatch = line.match(/^(.+?)[(]([^)]*)[)](?:\s*[:]\s*(.*))?$/u);
if (!structuredMatch) return null;
const [, title, metricsText, detail = ''] = structuredMatch;
return {
...defaultTask,
title: title.trim(),
detail: detail.trim(),
...resolveTaskMetrics(metricsText, defaultTask)
};
})
.filter(Boolean) as StructuredTask[];
if (tasks.length) return tasks;
const detailLines = defaultTitle && lines[0] === defaultTitle ? lines.slice(1) : lines;
return [
{
...defaultTask,
title: defaultTitle || lines[0],
detail: defaultTitle ? detailLines.join('\n') : lines.slice(1).join('\n')
}
];
}
function stripStructuredTaskPrefix(value: string) {
return value.trim().replace(/^\d+[..、]\s*/u, '');
}
function stripStructuredTaskSuffix(value: string) {
return value.trim().replace(/[。.!?]+$/u, '');
}
function getElementText(element: Element | null) {
return normalizeEditorText((element as HTMLElement | null)?.innerText || '');
}
function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTask>, useFallback = true) {
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 || (useFallback ? fallback?.priority : undefined));
// 同时支持 "进度 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 {
priority,
progress: progressText === undefined ? (useFallback ? fallback?.progress : undefined) : Number(progressText),
hours: hoursText === undefined ? (useFallback ? fallback?.hours : undefined) : Number(hoursText)
};
}
function parseStructuredSectionTaskText(
text: string,
fallback?: Partial<StructuredTask>,
useFallback = false
): StructuredTask | null {
const normalizedText = stripStructuredTaskPrefix(text);
if (!normalizedText) return null;
const structuredMatch =
normalizedText.match(/^(.+?)(?:[(]([^()]*)[)])?(?:\s*[:]\s*(.*))?$/u) ||
normalizedText.match(/^(.+?)(?:\(([^()]*)\))?(?::\s*(.*))?$/u);
if (!structuredMatch) return null;
const [, rawTitle, metricsText = '', detail = ''] = structuredMatch;
const title = stripStructuredTaskSuffix(rawTitle);
if (!title) return null;
return {
title,
detail: detail.trim(),
...resolveTaskMetrics(metricsText, fallback, useFallback)
};
}
function createStructuredSectionsFromTextV2(
text: string,
defaultCategory: string,
fallbackTask?: Partial<StructuredTask>
): StructuredSection[] {
const lines = normalizeEditorText(text).split('\n').filter(Boolean);
if (!lines.length || lines.join('\n') === EMPTY_HTML) return [];
const sections: StructuredSection[] = [];
const ensureSection = (categoryText: string) => {
const category = normalizeSectionCategory(categoryText, 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;
}
// 仅当行首不是结构化任务前缀(如 "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();
const task = parseStructuredSectionTaskText(rawTaskText, fallbackTask);
if (!category || !task) return;
ensureSection(category).tasks.push(task);
currentCategory = category;
return;
}
const task = parseStructuredSectionTaskText(trimmedLine, fallbackTask);
if (!task) return;
const hasStructuredHint =
/^(\d+[..、]\s*)/u.test(trimmedLine) ||
trimmedLine.includes('') ||
trimmedLine.includes('(') ||
trimmedLine.includes('') ||
trimmedLine.includes(':');
if (!hasStructuredHint && !currentCategory) return;
ensureSection(currentCategory || defaultCategory).tasks.push(task);
});
return mergeSectionsByCategory(sections);
}
function createFallbackTaskLookup(sections: StructuredSection[]) {
const lookup = new Map<string, StructuredTask[]>();
sections.forEach(section => {
const categoryKey = resolveTaskItemTypeLabel(section.category).trim();
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
) {
const parsedSections = createStructuredSectionsFromTextV2(editor.innerText, defaultCategory);
if (!parsedSections.length) return [];
const fallbackLookup = createFallbackTaskLookup(fallbackSections);
const merged: StructuredSection[] = parsedSections.map((section, sectionIndex) => {
const fallbackSection = fallbackSections[sectionIndex];
const categoryKey = resolveTaskItemTypeLabel(section.category).trim();
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 || '',
kind: fallbackTask?.kind,
hours: task.hours ?? fallbackTask?.hours
};
})
};
});
// 将出差任务从其他分类中抽离,统一归到独立的"差旅"段落,
// 避免失焦后被错误归类到上一个分类下。
return groupTravelTasksIntoSection(merged);
}
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 (!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) {
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.contentJson).map(normalizeSection));
if (structuredSections.length) return structuredSections;
return createStructuredSectionsFromTextV2(item.contentText || '', item.itemTitle || '未分类', {
hours: typeof item.workHours === 'number' ? item.workHours : Number(item.workHours || 0) || undefined
});
}
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);
const contentHtml = contentSections.length
? createStructuredHtmlV2(contentSections, true)
: escapeHtml(item.contentText || '').replace(/\n/g, '<br>');
return {
workItem: item.itemTitle || '未命名工作',
days: 0,
hours: item.workHours || 0,
content: item.contentText || '',
contentHtml: contentHtml || EMPTY_HTML,
contentSections,
contentTasks,
reflection: item.reflectionText || '',
removable: false,
source: item
};
}
function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: number): PlanItem {
const targetSections = getPlanItemSections(item);
const targetTasks = flattenSectionTasks(targetSections);
const targetHtml = targetSections.length
? createStructuredHtmlV2(targetSections)
: escapeHtml(item.targetText || '').replace(/\n/g, '<br>');
return {
workItem: item.itemTitle || '未命名计划',
target: item.targetText || '',
targetHtml: targetHtml || EMPTY_HTML,
targetSections,
targetTasks,
supportNeed: item.supportNeed || '',
/* 只有用户新增的计划才可删除,默认带入的不可删除 */
removable: true,
source: item,
sourceIndex: index
};
}
const nextPlans = computed<PlanItem[]>(() => {
return (props.model.planItems || []).map(toPlanItem);
});
const totalHours = computed(() => {
const baseTotalWorkHours = Number(props.baseInfo?.totalWorkHours ?? 0);
if (Number.isFinite(baseTotalWorkHours) && baseTotalWorkHours > 0) return baseTotalWorkHours;
return (props.model.reviewItems || []).reduce((sum, item) => sum + Number(item.workHours || 0), 0);
});
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;
});
const hasCompleteTravelSegment = computed(() => travelSegments.value.some(isCompleteTravelSegment));
function calcSegmentDays(segment: TravelSegment) {
if (!segment.dateRange || !segment.dateRange[0] || !segment.dateRange[1]) return segment.days || 0;
const start = new Date(segment.dateRange[0]);
const end = new Date(segment.dateRange[1]);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) return segment.days || 0;
const diffDays = (end.getTime() - start.getTime()) / 86400000 + 1;
return Math.round(diffDays * 10) / 10;
}
function syncSegmentDays(index: number) {
const segment = travelSegments.value[index];
segment.days = normalizeTravelDays(calcSegmentDays(segment));
}
function buildGroupedTravelTasks() {
const groupedMap = new Map<string, StructuredTask[]>();
travelSegments.value.filter(isCompleteTravelSegment).forEach(segment => {
const key = segment.workItem.trim();
const task: StructuredTask = {
title: `${normalizeTravelDateText(segment.dateRange![0])} - ${normalizeTravelDateText(segment.dateRange![1])} · 共出差${segment.days}`,
detail: '',
kind: 'travel'
};
const existing = groupedMap.get(key);
if (existing) {
existing.push(task);
} else {
groupedMap.set(key, [task]);
}
});
return Array.from(groupedMap.entries()).map(([workItem, tasks]) => ({ workItem, tasks }));
}
function handleTravelDaysChange(index: number, value: number | undefined) {
const segment = travelSegments.value[index];
if (!segment) return;
segment.days = normalizeTravelDays(value);
}
function syncTravelReviewItem() {
props.model.isBusinessTrip = true;
syncTravelSegmentsToModel();
}
function removeTravelTasksFromSections(sections: StructuredSection[]) {
return sections
.map(section => ({
category: section.category,
tasks: section.tasks.filter(task => !isTravelTask(task))
}))
.filter(section => section.tasks.length);
}
function appendTasksToSections(
sections: StructuredSection[],
tasks: StructuredTask[],
fallbackCategory = DEFAULT_SECTION_CATEGORY
) {
const nextSections = sections.map(section => ({
category: section.category,
tasks: [...section.tasks]
}));
if (!tasks.length) return nextSections;
// 出差任务始终归到独立的"差旅"段落,避免被误放到上一个普通分类下。
const travelTasks = tasks.filter(isTravelTask);
const otherTasks = tasks.filter(task => !isTravelTask(task));
if (otherTasks.length) {
const targetSection =
nextSections[nextSections.length - 1] ||
(() => {
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);
}
return nextSections;
}
function removeTravelTasksFromReviewItems() {
(props.model.reviewItems || []).forEach(item => {
const currentSections = getReviewItemSections(item);
const nextSections = removeTravelTasksFromSections(currentSections);
if (
nextSections.length === currentSections.length &&
flattenSectionTasks(nextSections).length === flattenSectionTasks(currentSections).length
)
return;
item.contentJson = createSectionsJson(nextSections);
item.contentText = createStructuredTextV2(nextSections, true);
});
}
function updateBusinessTrip(value: boolean) {
if (!value) {
removeTravelReviewItem();
return;
}
props.model.isBusinessTrip = true;
travelDialogVisible.value = true;
if (!travelSegments.value.length) addTravelSegment();
syncTravelSegmentsToModel();
}
function removeTravelReviewItem() {
props.model.isBusinessTrip = false;
props.model.travelSegments = [];
travelSegments.value = [];
travelDialogVisible.value = false;
removeTravelTasksFromReviewItems();
}
function addTravelSegment() {
travelSegments.value.push({
dateRange: null,
days: null,
workItem: travelWorkItemOptions.value[0] || MY_AFFAIRS_TITLE,
isNew: true
});
}
function removeTravelSegment(index: number) {
travelSegments.value.splice(index, 1);
}
function resetPlanForm() {
planForm.value = {
workItem: '',
supportNeed: '',
sections: [{ category: '', tasks: [], draft: createPlanTaskDraft() }]
};
activePlanSectionIndex.value = 0;
}
function showInlinePlanForm() {
planDialogVisible.value = true;
resetPlanForm();
}
function cancelInlinePlan() {
planDialogVisible.value = false;
resetPlanForm();
}
function addPlanSection() {
planForm.value.sections.push({ category: '', tasks: [], draft: createPlanTaskDraft() });
activePlanSectionIndex.value = planForm.value.sections.length - 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[] = 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 = getPlanItemSections(sameWorkItem);
const mergedSections = mergeSectionsByCategory([...existingSections, ...sections]);
sameWorkItem.targetText = createStructuredTextV2(mergedSections);
sameWorkItem.targetJson = createSectionsJson(mergedSections);
if (planForm.value.supportNeed.trim()) {
sameWorkItem.supportNeed = [sameWorkItem.supportNeed, planForm.value.supportNeed.trim()]
.filter(Boolean)
.join('\n');
}
} else {
props.model.planItems.push({
itemNumber: props.model.planItems.length + 1,
itemTitle: workItem,
targetText: target,
targetJson: createSectionsJson(sections),
supportNeed: planForm.value.supportNeed,
_isNew: true
} as Api.WorkReport.Common.PersonalReportPlanItem & { _isNew: boolean });
}
resetPlanForm();
planDialogVisible.value = false;
}
function removePlanItem(index: number) {
const item = nextPlans.value[index];
if (item?.sourceIndex !== undefined) props.model.planItems.splice(item.sourceIndex, 1);
}
function removeReviewItem(index: number) {
const item = reviewItems.value[index];
if (!item?.source) return;
const sourceIndex = props.model.reviewItems.findIndex(source => source === item.source);
if (sourceIndex >= 0) props.model.reviewItems.splice(sourceIndex, 1);
}
function confirmTravelReviewItem() {
if (!hasCompleteTravelSegment.value) return;
const groupedTravelTasks = buildGroupedTravelTasks();
if (!groupedTravelTasks.length) return;
removeTravelTasksFromReviewItems();
groupedTravelTasks.forEach(({ workItem, tasks }) => {
const targetItem = props.model.reviewItems.find(item => item.itemTitle.trim() === workItem);
if (targetItem) {
const existingSections = getReviewItemSections(targetItem);
const mergedSections = appendTasksToSections(
existingSections,
tasks,
existingSections.at(-1)?.category || TRAVEL_SECTION_CATEGORY
);
targetItem.contentJson = createSectionsJson(mergedSections);
targetItem.contentText = createStructuredTextV2(mergedSections, true);
return;
}
const travelSections: StructuredSection[] = [{ category: TRAVEL_SECTION_CATEGORY, tasks }];
props.model.reviewItems.push({
itemNumber: props.model.reviewItems.length + 1,
itemTitle: workItem,
workHours: 0,
contentText: createStructuredTextV2(travelSections, true),
contentJson: createSectionsJson(travelSections),
reflectionText: ''
});
});
syncTravelReviewItem();
travelDialogVisible.value = false;
}
function focusEditField(key: string) {
activeEditField.value = key;
}
function blurEditField(key: string) {
if (activeEditField.value === key) activeEditField.value = '';
}
/** 编辑态下是否显示"具体工作内容"的结构化预览(含 ElPopover 工作日志) */
function showContentStructuredView(index: number) {
const item = reviewItems.value[index];
if (!item?.contentSections?.length) return false;
if (isReadonly.value) return true;
// 编辑/新增模式下,仅在该字段未聚焦时显示结构化预览
return activeEditField.value !== `content-${index}`;
}
/** 编辑态下是否显示"具体目标"的结构化预览(含 ElPopover 工作日志) */
function showTargetStructuredView(index: number) {
const item = nextPlans.value[index];
if (!item?.targetSections?.length) return false;
if (isReadonly.value) return true;
// 编辑/新增模式下,仅在该字段未聚焦时显示结构化预览
return activeEditField.value !== `target-${index}`;
}
/** 点击结构化预览区域时切换到编辑态并聚焦 */
function handleStructuredViewClick(fieldKey: string) {
if (isReadonly.value) return;
activeEditField.value = fieldKey;
nextTick(() => {
const editor = document.querySelector(`[data-field-key="${fieldKey}"]`) as HTMLElement;
editor?.focus();
});
}
function syncRichContent(item: ReviewItem, event: Event) {
const target = event.currentTarget as HTMLElement;
if (!item.source) return;
const sections = parseStructuredSectionsFromEditorV2(
target,
item.contentSections || [],
item.contentSections?.[0]?.category || DEFAULT_SECTION_CATEGORY
);
item.source.contentJson = createSectionsJson(sections);
item.source.contentText = createStructuredTextV2(sections, true);
}
function syncRichTarget(item: PlanItem, event: Event) {
const target = event.currentTarget as HTMLElement;
if (!item.source) return;
const sections = parseStructuredSectionsFromEditorV2(
target,
item.targetSections || [],
item.targetSections?.[0]?.category || DEFAULT_SECTION_CATEGORY
);
item.source.targetJson = createSectionsJson(sections);
item.source.targetText = createStructuredTextV2(sections);
}
function syncRichReflection(item: ReviewItem, event: Event) {
const target = event.currentTarget as HTMLElement;
if (item.source) item.source.reflectionText = target.innerText.trim();
}
function syncRichSupport(item: PlanItem, event: Event) {
const target = event.currentTarget as HTMLElement;
if (item.source) item.source.supportNeed = target.innerText.trim();
}
</script>
<template>
<div class="card form-page">
<div class="section">
<div class="section-title">
<span>基础信息</span>
<div v-if="mode === 'edit' && !isReadonly" class="section-title-right">
<ElButton size="small" plain type="primary" @click="emit('pullDefaultDraft')">
<template #icon>
<icon-mdi-refresh class="text-icon" />
</template>
刷新
</ElButton>
</div>
</div>
<div class="compose-grid">
<div class="field">
<label>姓名</label>
<ElInput v-model="mainForm.reporter" disabled />
</div>
<div class="field">
<label>部门</label>
<ElInput v-model="mainForm.deptName" disabled />
</div>
<div class="field">
<label>岗位</label>
<ElInput v-model="mainForm.postName" disabled />
</div>
<div class="field">
<label>直接上级</label>
<ElInput v-model="mainForm.supervisor" disabled />
</div>
<div class="field">
<label>周期</label>
<ElInput :model-value="periodText" disabled />
</div>
<div class="field">
<label>是否出差</label>
<ElRadioGroup v-model="businessTripValue" class="radio-group-full" :disabled="isReadonly">
<ElRadio :value="false" border></ElRadio>
<ElRadio :value="true" border></ElRadio>
</ElRadioGroup>
</div>
</div>
</div>
<div class="section">
<div class="section-title">
<div class="section-title-left">
<span>当期重点工作回顾</span>
<span class="source-chip">{{ reviewItems.length }} 项工作</span>
<span class="source-chip"> {{ totalHours }}h</span>
</div>
</div>
<div class="review-grid layout-row">
<div v-if="!reviewItems.length">{{ EMPTY_HTML }}</div>
<div v-for="(item, index) in reviewItems" :key="index" class="review-card">
<div class="review-index-cell">
<span class="row-index">{{ index + 1 }}</span>
</div>
<div class="review-name-cell">
<div class="work-title-line">
<strong>{{ item.workItem }}</strong>
<span> {{ item.hours }}h</span>
</div>
</div>
<div class="review-action-cell">
<ElPopconfirm
v-if="item.removable !== false && !isReadonly"
title="确认删除这条数据吗?"
confirm-button-text="删除"
cancel-button-text="取消"
width="220"
@confirm="removeReviewItem(index)"
>
<template #reference>
<button class="item-remove-btn" aria-label="删除数据">×</button>
</template>
</ElPopconfirm>
</div>
<div class="review-editor-grid">
<div class="field">
<label>具体工作内容及成果描述</label>
<div
v-if="showContentStructuredView(index)"
class="rich-editor"
:class="{ 'rich-editor--preview': !isReadonly }"
@click="handleStructuredViewClick(`content-${index}`)"
>
<div
v-for="(section, sectionIndex) in item.contentSections"
:key="`${index}-${sectionIndex}`"
class="rich-section"
>
<div class="rich-category-line"># {{ resolveTaskItemTypeLabel(section.category) }}</div>
<div class="rich-section-tasks">
<template v-for="(task, taskIndex) in section.tasks" :key="`${index}-${sectionIndex}-${taskIndex}`">
<ElPopover v-if="task.detail" placement="top-start" trigger="hover" :width="420">
<template #reference>
<div class="rich-task-line rich-task-line--interactive">
{{ formatStructuredTaskDisplayLine(task, taskIndex, true) }}
</div>
</template>
<div class="structured-preview__popover">
{{ formatWorkLogDetail(task.detail) || '暂无内容' }}
</div>
</ElPopover>
<div v-else class="rich-task-line">
{{ formatStructuredTaskDisplayLine(task, taskIndex, true) }}
</div>
</template>
</div>
</div>
</div>
<div
v-else
class="rich-editor"
:contenteditable="!isReadonly"
spellcheck="false"
:data-field-key="`content-${index}`"
:data-placeholder="isReadonly ? undefined : '请输入具体工作内容及成果描述'"
@focus="focusEditField(`content-${index}`)"
@blur="
syncRichContent(item, $event);
blurEditField(`content-${index}`);
"
v-html="item.contentHtml"
></div>
</div>
<div class="field">
<label>工作感悟</label>
<div
class="rich-editor"
:contenteditable="!isReadonly"
spellcheck="false"
:data-placeholder="isReadonly ? undefined : '请输入工作感悟'"
@focus="focusEditField(`reflection-${index}`)"
@blur="
syncRichReflection(item, $event);
blurEditField(`reflection-${index}`);
"
>
{{ item.reflection }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">
<div class="section-title-left">
<span>下周期重点工作计划</span>
<span class="source-chip">{{ nextPlans.length }} 项计划</span>
</div>
<div class="section-title-right">
<ElButton v-if="!isReadonly" size="small" :disabled="planDialogVisible" @click="showInlinePlanForm">
新增计划
</ElButton>
</div>
</div>
<div class="plan-list layout-row">
<div v-if="!nextPlans.length">{{ EMPTY_HTML }}</div>
<div v-for="(item, index) in nextPlans" :key="index" class="plan-card">
<div class="plan-index-cell">
<span class="row-index">{{ index + 1 }}</span>
</div>
<div class="plan-name-cell">
<strong>{{ item.workItem }}</strong>
</div>
<div class="plan-action-cell">
<ElPopconfirm
v-if="item.removable !== false && !isReadonly"
title="确认删除这条计划吗?"
confirm-button-text="删除"
cancel-button-text="取消"
width="220"
@confirm="removePlanItem(index)"
>
<template #reference>
<button class="item-remove-btn" aria-label="删除计划">×</button>
</template>
</ElPopconfirm>
</div>
<div class="plan-editor-grid">
<div class="field">
<label>具体目标</label>
<div
v-if="showTargetStructuredView(index)"
class="rich-editor"
:class="{ 'rich-editor--preview': !isReadonly }"
@click="handleStructuredViewClick(`target-${index}`)"
>
<div
v-for="(section, sectionIndex) in item.targetSections"
:key="`${index}-${sectionIndex}`"
class="rich-section"
>
<div class="rich-category-line"># {{ resolveTaskItemTypeLabel(section.category) }}</div>
<div class="rich-section-tasks">
<template v-for="(task, taskIndex) in section.tasks" :key="`${index}-${sectionIndex}-${taskIndex}`">
<ElPopover v-if="task.detail" placement="top-start" trigger="hover" :width="420">
<template #reference>
<div class="rich-task-line rich-task-line--interactive">
{{ formatStructuredTaskDisplayLine(task, taskIndex) }}
</div>
</template>
<div class="structured-preview__popover">
{{ formatWorkLogDetail(task.detail) || '暂无内容' }}
</div>
</ElPopover>
<div v-else class="rich-task-line">
{{ formatStructuredTaskDisplayLine(task, taskIndex) }}
</div>
</template>
</div>
</div>
</div>
<div
v-else
class="rich-editor"
:contenteditable="!isReadonly"
spellcheck="false"
:data-field-key="`target-${index}`"
:data-placeholder="isReadonly ? undefined : '请输入具体目标'"
@focus="focusEditField(`target-${index}`)"
@blur="
syncRichTarget(item, $event);
blurEditField(`target-${index}`);
"
v-html="item.targetHtml"
></div>
</div>
<div class="field">
<label>对他人协助的需求</label>
<div
class="rich-editor"
:contenteditable="!isReadonly"
spellcheck="false"
:data-placeholder="isReadonly ? undefined : '请输入协助需求,没有可留空'"
@focus="focusEditField(`support-${index}`)"
@blur="
syncRichSupport(item, $event);
blurEditField(`support-${index}`);
"
>
{{ item.supportNeed }}
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="!isReadonly" class="form-actions">
<!-- <ElButton>重置表单</ElButton>-->
<ElButton @click="emit('save')">保存草稿</ElButton>
<ElButton type="primary" class="btn-submit" @click="emit('submit')">提交审批</ElButton>
</div>
<div v-else-if="scene === 'approval'" class="form-actions approval-form-actions">
<ElButton @click="emit('back')">退出审批</ElButton>
<ElButton type="primary" class="btn-submit" @click="emit('requestApprove')">开始审批</ElButton>
</div>
<BusinessFormDialog
v-model="planDialogVisible"
title="新增下周期重点工作计划"
preset="md"
confirm-text="确认新增"
: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>
<ElSelect
v-model="planForm.workItem"
class="inline-plan-name-input"
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-sections">
<div
v-for="(section, sIdx) in planForm.sections"
:key="sIdx"
class="plan-section"
:class="{ active: activePlanSectionIndex === sIdx }"
>
<div class="plan-section-head">
<div class="field plan-section-head-field">
<label>类别</label>
<DictSelect
v-model="section.category"
:dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE"
size="small"
placeholder="选择类别"
style="width: 100%"
@focus="activePlanSectionIndex = sIdx"
@change="activePlanSectionIndex = sIdx"
/>
</div>
<ElButton
v-if="planForm.sections.length > 1"
link
type="danger"
size="small"
@click="removePlanSection(sIdx)"
>
删除类别
</ElButton>
</div>
<div v-for="(task, tIdx) in section.tasks" :key="tIdx" class="plan-task">
<div class="plan-task-head">
<span>{{ task.title }}</span>
<div class="plan-task-metas">
<template v-if="!isMyAffairsPlanItem(planForm.workItem)">
<em>{{ resolvePriorityLabel(task.priority) }}</em>
</template>
<em>进度 {{ task.progress }}%</em>
</div>
<ElButton link type="danger" size="small" @click="removePlanTask(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>
<ElInput
v-model="section.draft.detail"
size="small"
type="textarea"
:rows="3"
placeholder="详细内容(可选)"
@focus="activePlanSectionIndex = sIdx"
/>
</div>
<ElButton
class="dialog-inline-action"
type="primary"
plain
size="small"
:disabled="!section.draft.title.trim()"
@click="addPlanTask(sIdx)"
>
<ElIcon><Plus /></ElIcon>
<span>添加</span>
</ElButton>
</div>
</div>
<ElButton
v-if="!planForm.sections.length || planForm.sections.every(s => s.category.trim())"
class="dialog-inline-action dialog-inline-action--secondary"
type="primary"
plain
size="small"
@click="addPlanSection"
>
<ElIcon><Plus /></ElIcon>
<span>类别</span>
</ElButton>
</div>
</div>
<div class="field">
<label>对他人协助的需求</label>
<ElInput
v-model="planForm.supportNeed"
class="styled-textarea"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="请输入协助需求,没有可留空"
/>
</div>
</div>
</BusinessFormDialog>
<BusinessFormDialog
v-model="travelDialogVisible"
title="新增本周差旅"
preset="md"
confirm-text="确认新增"
:confirm-disabled="!hasCompleteTravelSegment"
@confirm="confirmTravelReviewItem"
@cancel="businessTripValue = false"
>
<div class="travel-dialog-form">
<div class="travel-summary">
<span>出差总天数</span>
<strong>{{ totalTravelDays }} </strong>
</div>
<div v-for="(segment, segmentIndex) in travelSegments" :key="segmentIndex" class="travel-segment">
<div class="travel-segment-head">
<span class="row-index">{{ segmentIndex + 1 }}</span>
<strong>分段出差</strong>
<ElButton v-if="segment.isNew" link type="danger" @click="removeTravelSegment(segmentIndex)">删除</ElButton>
</div>
<div class="travel-grid">
<div class="field">
<label>工作事项</label>
<ElSelect
v-model="segment.workItem"
filterable
allow-create
default-first-option
clearable
placeholder="请选择或输入工作事项"
>
<ElOption v-for="item in travelWorkItemOptions" :key="item" :label="item" :value="item" />
</ElSelect>
</div>
<div class="field travel-cycle-field">
<label>出差周期</label>
<ElDatePicker
v-model="segment.dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 100%"
@change="syncSegmentDays(segmentIndex)"
/>
</div>
<div class="field">
<label>天数</label>
<ElInputNumber
v-model="segment.days"
:min="0.5"
:step="0.5"
step-strictly
:precision="1"
controls-position="right"
style="width: 100%"
@change="value => handleTravelDaysChange(segmentIndex, value)"
/>
</div>
</div>
</div>
<ElButton
class="travel-add-btn dialog-inline-action"
type="primary"
plain
size="small"
@click="addTravelSegment"
>
<ElIcon><Plus /></ElIcon>
<span>分段</span>
</ElButton>
</div>
</BusinessFormDialog>
</div>
</template>
<style scoped>
.card {
border: 1px solid rgba(216, 224, 232, 0.88);
border-radius: 18px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.12);
padding-top: 20px;
}
.card-title {
margin: 0;
font-size: 18px;
font-weight: 900;
}
.card-subtitle {
margin-top: 5px;
color: #667085;
font-size: 12px;
}
.form-head {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
padding: 20px;
}
.report-title {
display: flex;
gap: 12px;
align-items: center;
}
.type-mark {
width: 48px;
height: 48px;
display: grid;
place-items: center;
flex-shrink: 0;
border-radius: 16px;
background: #ccfbf1;
color: #0f766e;
font-weight: 900;
}
.section {
margin: 0 20px 18px;
border: 1px solid #d8e0e8;
border-radius: 16px;
overflow: hidden;
background: #fff;
}
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 13px 16px;
background: #f8fbfc;
border-bottom: 1px solid #d8e0e8;
font-weight: 900;
}
.section-title-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.section-title-right {
display: flex;
align-items: center;
gap: 10px;
}
.source-chip {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #f3f7f9;
color: #475467;
font-size: 12px;
font-weight: 800;
}
.compose-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 14px;
padding: 16px;
align-items: start;
}
@media (max-width: 1180px) {
.compose-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.compose-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.field {
display: grid;
gap: 6px;
}
.field label {
color: #667085;
font-size: 12px;
font-weight: 800;
}
.radio-group-full {
width: 100%;
display: flex;
gap: 12px;
}
.radio-group-full :deep(.el-radio) {
flex: 1;
min-width: 0;
justify-content: center;
margin-right: 0;
}
.radio-group-full :deep(.el-radio.is-checked) {
border-color: #0f766e;
background: #f0fdfa;
}
.radio-group-full :deep(.el-radio__input.is-checked + .el-radio__label) {
color: #0f766e;
}
.review-grid,
.plan-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 16px;
}
.review-grid.layout-row,
.plan-list.layout-row {
grid-template-columns: 1fr;
}
.review-card,
.plan-card {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid #e5edf1;
border-radius: 12px;
background: #fbfdfe;
}
.review-card {
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr) 44px;
gap: 0;
padding: 0;
overflow: hidden;
}
.plan-card {
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr) 44px;
gap: 0;
padding: 0;
overflow: hidden;
}
.review-index-cell,
.review-name-cell,
.review-editor-grid,
.review-action-cell,
.plan-index-cell,
.plan-name-cell,
.plan-editor-grid,
.plan-action-cell {
padding: 14px;
}
.review-index-cell,
.plan-index-cell {
display: grid;
place-items: center;
}
.review-name-cell,
.plan-name-cell {
display: grid;
align-items: center;
min-width: 0;
overflow: hidden;
border-left: 1px solid #e5edf1;
}
.review-name-cell .work-title-line,
.plan-name-cell {
justify-content: center;
text-align: center;
}
.review-name-cell .work-title-line {
min-width: 0;
flex-direction: column;
gap: 4px;
}
.review-name-cell strong,
.plan-name-cell strong {
display: block;
width: 100%;
min-width: 0;
flex: 0 1 auto;
color: #606266;
font-size: 14px;
font-weight: 400;
line-height: 1.3;
overflow: hidden;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.review-action-cell,
.plan-action-cell {
grid-column: 5;
grid-row: 1;
display: grid;
justify-items: center;
align-items: start;
border-left: 1px solid #e5edf1;
padding: 6px;
}
.review-card-head,
.plan-card-head {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 28px;
gap: 10px;
align-items: start;
}
.row-index {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 999px;
background: #0f766e;
color: #fff;
font-size: 13px;
font-weight: 900;
}
.review-title-fields,
.review-title-readonly,
.plan-title-readonly {
display: grid;
gap: 10px;
align-items: center;
}
.review-title-readonly {
grid-template-columns: 1fr;
}
.work-title-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.review-title-readonly strong,
.plan-title-readonly strong {
color: #606266;
font-size: 14px;
font-weight: 400;
line-height: 1.3;
}
.work-title-line span,
.inline-metrics {
display: flex;
align-items: center;
gap: 6px;
color: #667085;
font-size: 12px;
font-weight: 800;
}
.review-name-cell .work-title-line span {
justify-content: center;
width: 100%;
}
.inline-metrics :deep(.el-input-number) {
width: 92px;
}
.review-editor-grid {
display: grid;
grid-column: 3 / span 2;
grid-row: 1;
grid-template-columns: minmax(0, calc(57.45% - 6px + 50px)) minmax(0, calc(42.55% - 6px - 50px));
gap: 12px;
border-left: 1px solid #e5edf1;
}
.plan-editor-grid {
display: grid;
grid-template-columns: minmax(0, calc(57.45% - 6px + 50px)) minmax(0, calc(42.55% - 6px - 50px));
gap: 12px;
}
.plan-editor-grid {
grid-column: 3 / span 2;
grid-row: 1;
border-left: 1px solid #e5edf1;
}
.item-remove-btn {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 1px solid #fecaca;
border-radius: 999px;
background: #fff;
color: #dc2626;
font: inherit;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.item-remove-btn:hover {
background: #fef2f2;
}
.structured-input,
.fixed-textarea :deep(.el-textarea__inner),
.styled-textarea :deep(.el-textarea__inner) {
scrollbar-width: thin;
scrollbar-color: #94a3b8 transparent;
}
.structured-input::-webkit-scrollbar,
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar),
.styled-textarea :deep(.el-textarea__inner::-webkit-scrollbar) {
width: 6px;
height: 6px;
}
.structured-input::-webkit-scrollbar-track,
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar-track),
.styled-textarea :deep(.el-textarea__inner::-webkit-scrollbar-track) {
background: transparent;
}
.structured-input::-webkit-scrollbar-thumb,
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar-thumb),
.styled-textarea :deep(.el-textarea__inner::-webkit-scrollbar-thumb) {
border-radius: 999px;
background: #94a3b8;
}
.structured-input::-webkit-scrollbar-button,
.fixed-textarea :deep(.el-textarea__inner::-webkit-scrollbar-button),
.styled-textarea :deep(.el-textarea__inner::-webkit-scrollbar-button) {
width: 0;
height: 0;
display: none;
}
.structured-input {
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
padding: 5px 11px;
cursor: pointer;
transition: border-color 0.2s;
height: 86px;
overflow: auto;
box-sizing: border-box;
}
.structured-input:hover {
border-color: #c0c4cc;
}
.structured-input.disabled {
background: #f5f7fa;
color: #a8abb2;
cursor: not-allowed;
}
.fixed-textarea :deep(.el-textarea__inner) {
height: 86px;
min-height: 86px;
max-height: 86px;
resize: none;
overflow: auto;
padding: 5px 11px;
line-height: 1.6;
white-space: pre-wrap;
}
.styled-textarea :deep(.el-textarea__inner) {
overflow: auto;
}
.auto-textarea,
.fixed-textarea {
width: 100%;
}
.auto-textarea :deep(.el-textarea__inner) {
resize: none;
overflow: hidden;
padding: 5px 11px;
line-height: 1.6;
white-space: pre-wrap;
}
.rich-editor {
width: 100%;
min-height: 86px;
padding: 5px 11px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
box-sizing: border-box;
color: #334155;
font-size: 13px;
line-height: 1.6;
outline: none;
overflow: auto;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.rich-editor:hover {
border-color: #c0c4cc;
}
.rich-editor:focus {
border-color: #0f766e;
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.1);
}
.rich-editor:empty::before {
content: attr(data-placeholder);
color: #a8abb2;
}
.rich-editor :deep(.rich-section) {
display: grid;
gap: 4px;
min-width: 0;
}
.rich-editor :deep(.rich-section + .rich-section) {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #cbd5e1;
}
.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) {
display: grid;
gap: 6px;
padding-left: 0;
color: #334155;
font-size: 13px;
line-height: 1.6;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.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-task-line--interactive) {
cursor: pointer;
}
/* 编辑态下结构化预览区域:点击可切换到编辑模式 */
.rich-editor--preview {
cursor: text;
}
.rich-editor--preview:hover {
border-color: #0f766e;
}
.structured-preview__popover {
max-width: 100%;
color: #334155;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.rich-editor :deep(.rich-task) {
display: grid;
gap: 4px;
min-width: 0;
}
.rich-editor :deep(.rich-task-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.rich-editor :deep(.rich-task + .rich-task) {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #cbd5e1;
}
.rich-editor :deep(.rich-task-title),
.rich-editor :deep(.rich-section-title) {
position: relative;
min-width: 0;
flex: 1;
padding-left: 14px;
color: #0f766e;
font-size: 13px;
font-weight: 800;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.rich-editor :deep(.rich-task-metas) {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 5px;
flex-wrap: wrap;
flex: 0 0 auto;
user-select: none;
cursor: default;
}
.rich-editor :deep(.rich-task-meta) {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: #f3f7f9;
color: #475467;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.rich-editor :deep(.rich-task-meta.priority) {
background: #fff7ed;
color: #c2410c;
}
.rich-editor :deep(.rich-task-title::before),
.rich-editor :deep(.rich-section-title::before) {
content: '';
position: absolute;
left: 0;
top: 4px;
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
}
.rich-editor :deep(.rich-task-detail) {
padding-left: 14px;
color: #334155;
font-size: 13px;
line-height: 1.6;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.review-editor-grid .field,
.plan-editor-grid .field {
display: flex;
flex-direction: column;
min-height: 0;
}
.review-editor-grid .rich-editor,
.plan-editor-grid .rich-editor {
flex: 1;
height: 100%;
}
.review-editor-grid .fixed-textarea,
.plan-editor-grid .fixed-textarea {
display: flex;
flex: 1;
}
.review-editor-grid .fixed-textarea :deep(.el-textarea__inner),
.plan-editor-grid .fixed-textarea :deep(.el-textarea__inner) {
height: 100%;
max-height: none;
}
.structured-input-inner {
color: #334155;
line-height: 1.6;
font-size: 13px;
}
.structured-input-inner.plain {
white-space: pre-wrap;
}
.structured-task {
display: grid;
gap: 6px;
}
.structured-task + .structured-task {
padding-top: 10px;
border-top: 1px dashed #cbd5e1;
}
.structured-task-title {
position: relative;
padding-left: 14px;
color: #0f766e;
font-size: 13px;
font-weight: 800;
}
.structured-task-title::before {
content: '';
position: absolute;
left: 0;
top: 4px;
width: 4px;
height: 16px;
border-radius: 999px;
background: #0f766e;
}
.structured-task-detail {
color: #334155;
padding-left: 14px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.travel-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
border-radius: 10px;
background: #f0fdfa;
color: #0f766e;
font-size: 13px;
font-weight: 900;
}
.plan-dialog-form,
.travel-dialog-form {
display: grid;
gap: 16px;
}
.plan-dialog-form > .field,
.travel-dialog-form > .field {
gap: 8px;
}
.travel-segment {
display: grid;
gap: 14px;
padding: 14px;
border: 1px solid #e5edf1;
border-radius: 12px;
background: #fbfdfe;
}
.travel-segment-head {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.travel-segment-head strong {
color: #14213d;
}
.travel-grid {
display: grid;
grid-template-columns: minmax(200px, 1fr) minmax(280px, 1.2fr) 120px;
gap: 16px;
align-items: start;
}
.inline-task-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px 142px;
align-items: end;
gap: 12px;
}
.inline-task-row--my-affairs {
grid-template-columns: minmax(0, 1fr) 142px;
}
.plan-sections {
display: grid;
gap: 10px;
}
.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 #eef2f6;
border-radius: 9px;
background: #f8fbfc;
}
.plan-task-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.plan-task-head span {
flex: 1;
min-width: 0;
color: #14213d;
font-size: 13px;
font-weight: 700;
}
.plan-task-metas {
display: inline-flex;
gap: 5px;
flex: 0 0 auto;
}
.plan-task-metas em {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: #f3f7f9;
color: #475467;
font-size: 11px;
font-style: normal;
font-weight: 800;
white-space: nowrap;
}
.plan-task-metas em:first-child {
background: #fff7ed;
color: #c2410c;
}
.plan-task-detail {
color: #334155;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.plan-task-form {
display: grid;
gap: 12px;
padding: 12px;
border: 1px dashed #cbd5e1;
border-radius: 10px;
background: #f8fbfc;
}
.plan-dialog-form .inline-task-row .field {
gap: 6px;
}
.plan-dialog-form .inline-task-row :deep(.el-input__wrapper),
.plan-dialog-form .inline-task-row :deep(.el-select__wrapper),
.plan-dialog-form .inline-task-row :deep(.el-input-number),
.plan-dialog-form .inline-task-row :deep(.el-input-number .el-input__wrapper) {
height: 36px !important;
min-height: 36px !important;
overflow: hidden;
}
.plan-dialog-form .inline-task-row :deep(.el-input-number) {
width: 100%;
border-radius: 8px;
box-sizing: border-box;
background: #fff;
border: 1px solid var(--el-border-color);
overflow: hidden;
}
.plan-dialog-form .inline-task-row :deep(.el-input-number:focus-within) {
border-color: var(--el-color-primary);
}
.plan-dialog-form .inline-task-row :deep(.el-input-number .el-input__wrapper) {
box-shadow: none !important;
background: transparent;
border-radius: 0;
}
.plan-dialog-form .inline-task-row :deep(.el-input-number.is-controls-right .el-input-number__increase),
.plan-dialog-form .inline-task-row :deep(.el-input-number.is-controls-right .el-input-number__decrease) {
right: 0;
height: 18px;
}
.plan-dialog-form .inline-task-row :deep(.el-input-number.is-controls-right .el-input-number__increase) {
top: 0;
}
.plan-dialog-form .inline-task-row :deep(.el-input-number.is-controls-right .el-input-number__decrease) {
bottom: 0;
}
.travel-dialog-form :deep(.el-input__wrapper),
.travel-dialog-form :deep(.el-select__wrapper),
.travel-dialog-form :deep(.el-input-number),
.travel-dialog-form :deep(.el-input-number .el-input__wrapper),
.travel-dialog-form :deep(.el-date-editor.el-input__wrapper) {
min-height: 36px;
overflow: hidden;
}
.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 {
width: 100%;
min-width: 260px;
}
.travel-add-btn {
justify-self: flex-end;
}
.dialog-inline-action {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
padding-inline: 12px;
border-radius: 999px;
}
.dialog-inline-action :deep(.el-icon) {
font-size: 13px;
}
.plan-task-form > .dialog-inline-action {
justify-self: flex-end;
}
.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;
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 {
position: sticky;
z-index: 5;
bottom: 0;
margin-top: auto;
margin-bottom: 0;
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 {
background: #0f766e !important;
border-color: #0f766e !important;
}
.btn-submit:hover {
background: #0d9488 !important;
border-color: #0d9488 !important;
}
@media (max-width: 1180px) {
.form-head,
.compose-grid,
.review-title-fields,
.review-title-readonly,
.plan-title-readonly,
.review-editor-grid,
.plan-editor-grid,
.inline-task-row,
.travel-grid {
grid-template-columns: 1fr;
}
.review-card,
.plan-card {
grid-template-columns: 1fr;
}
.review-index-cell,
.review-name-cell,
.review-editor-grid,
.review-action-cell,
.plan-index-cell,
.plan-name-cell,
.plan-editor-grid,
.plan-action-cell,
.inline-plan-actions {
grid-column: auto;
grid-row: auto;
border-left: 0;
}
.review-name-cell,
.review-editor-grid,
.review-action-cell,
.plan-name-cell,
.plan-editor-grid,
.plan-action-cell {
border-top: 1px solid #e5edf1;
}
.section-title {
align-items: flex-start;
}
}
</style>