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

2803 lines
81 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 || '工作内容' });
}
const STRUCTURED_TASK_PREFIX_RE = /^(?:(?:\d+[..、])|(?:\d+\s+)|(?:[一二三四五六七八九十百千万]+[、.]))\s*/u;
function stripStructuredTaskPrefixV2(value: string) {
return value.trim().replace(STRUCTURED_TASK_PREFIX_RE, '');
}
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)}`;
}
function getWorkLogEntries(detail: string): string[] {
if (!detail) return [];
// 仅按中文分号切分,避免误伤文本中的其他标点;每段作为单独一天的工作日志展示。
return detail
.split('')
.map(item => item.trim())
.filter(Boolean);
}
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(STRUCTURED_TASK_PREFIX_RE, '');
}
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 isStructuredMetricsText(text: string) {
return /(?:\bP\d+\b|进度\s*\d+(?:\.\d+)?%|\d+(?:\.\d+)?%|\d+(?:\.\d+)?h)/iu.test(text.trim());
}
function extractStructuredTaskParts(normalizedText: string) {
const colonIndex = normalizedText.search(/[:]/u);
const mainText = colonIndex >= 0 ? normalizedText.slice(0, colonIndex).trim() : normalizedText.trim();
const detailText = colonIndex >= 0 ? normalizedText.slice(colonIndex + 1).trim() : '';
const bracketMatches = Array.from(mainText.matchAll(/[(]([^()]*)[)]/gu));
const completeMetricsMatch = [...bracketMatches].reverse().find(match => isStructuredMetricsText(match[1] || ''));
if (completeMetricsMatch && completeMetricsMatch.index !== undefined) {
const fullMatch = completeMetricsMatch[0] || '';
const metricsText = completeMetricsMatch[1] || '';
const startIndex = completeMetricsMatch.index;
const endIndex = startIndex + fullMatch.length;
return {
rawTitle: `${mainText.slice(0, startIndex)}${mainText.slice(endIndex)}`.trim(),
metricsText,
detail: detailText
};
}
const lastOpenIndex = Math.max(mainText.lastIndexOf(''), mainText.lastIndexOf('('));
const lastCloseIndex = Math.max(mainText.lastIndexOf(''), mainText.lastIndexOf(')'));
if (lastOpenIndex > lastCloseIndex) {
const metricsText = mainText.slice(lastOpenIndex + 1).trim();
if (isStructuredMetricsText(metricsText)) {
return {
rawTitle: mainText.slice(0, lastOpenIndex).trim(),
metricsText,
detail: detailText
};
}
}
return {
rawTitle: mainText,
metricsText: '',
detail: detailText
};
}
function parseStructuredSectionTaskText(
text: string,
fallback?: Partial<StructuredTask>,
useFallback = false
): StructuredTask | null {
const normalizedText = stripStructuredTaskPrefix(text);
if (!normalizedText) return null;
const { rawTitle, metricsText, detail } = extractStructuredTaskParts(normalizedText);
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 = '';
let previousTask: StructuredTask | null = null;
const legacyTaskLineRe =
/^(?!(?:(?:\d+[..、])|(?:\d+\s+)|(?:[一二三四五六七八九十百千万]+[、.]))\s*)(.+?)\s*[-]\s*(.+)$/u;
const isExplicitStructuredTaskLine = (line: string) => {
if (!STRUCTURED_TASK_PREFIX_RE.test(line)) return false;
const normalizedLine = stripStructuredTaskPrefix(line);
const { rawTitle, metricsText } = extractStructuredTaskParts(normalizedLine);
return Boolean(stripStructuredTaskSuffix(rawTitle) && isStructuredMetricsText(metricsText));
};
const shouldAppendToPreviousTaskDetail = (line: string) => {
if (!previousTask?.detail) return false;
if (line.startsWith('#')) return false;
if (legacyTaskLineRe.test(line)) return false;
if (isExplicitStructuredTaskLine(line)) return false;
return true;
};
lines.forEach(line => {
const trimmedLine = line.trim();
if (!trimmedLine) return;
if (trimmedLine.startsWith('#')) {
currentCategory = trimmedLine.replace(/^#\s*/u, '').trim();
if (currentCategory) ensureSection(currentCategory);
previousTask = null;
return;
}
// 仅当行首不是结构化任务前缀(如 "3、")时,才按旧式 "<分类> - <事项>" 解析;
// 否则会把 "2026-06-12 - 2026-06-19" 这种含 " - " 的出差行误判为分类。
const legacyMatch = trimmedLine.match(legacyTaskLineRe);
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;
previousTask = task;
return;
}
if (shouldAppendToPreviousTaskDetail(trimmedLine)) {
const lastTask = previousTask;
if (!lastTask) return;
lastTask.detail = lastTask.detail ? `${lastTask.detail}\n${trimmedLine}` : trimmedLine;
return;
}
const task = parseStructuredSectionTaskText(trimmedLine, fallbackTask);
if (!task) return;
const hasStructuredHint =
STRUCTURED_TASK_PREFIX_RE.test(trimmedLine) ||
trimmedLine.includes('') ||
trimmedLine.includes('(') ||
trimmedLine.includes('') ||
trimmedLine.includes(':');
if (!hasStructuredHint && !currentCategory) return;
ensureSection(currentCategory || defaultCategory).tasks.push(task);
previousTask = 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
};
})
};
});
// 将出差任务从其他分类中抽离,统一归到独立的"差旅"段落,
// 避免失焦后被错误归类到上一个分类下。
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: true,
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 = '';
}
function isStructuredEditorUnchanged(text: string, sections: StructuredSection[] | undefined, showHours = false) {
if (!sections?.length) return false;
return normalizeEditorText(text) === createStructuredTextV2(sections, showHours);
}
/** 编辑态下是否显示"具体工作内容"的结构化预览(含 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;
if (isStructuredEditorUnchanged(target.innerText, item.contentSections, true)) {
item.source.contentJson = createSectionsJson(item.contentSections || []);
item.source.contentText = createStructuredTextV2(item.contentSections || [], true);
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;
if (isStructuredEditorUnchanged(target.innerText, item.targetSections)) {
item.source.targetJson = createSectionsJson(item.targetSections || []);
item.source.targetText = createStructuredTextV2(item.targetSections || []);
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">
<template v-if="getWorkLogEntries(task.detail).length">
<div
v-for="(entry, entryIndex) in getWorkLogEntries(task.detail)"
:key="`${index}-${sectionIndex}-${taskIndex}-${entryIndex}`"
class="structured-preview__log-entry"
>
<div class="structured-preview__log-text">{{ entry }}</div>
<div
v-if="entryIndex < getWorkLogEntries(task.detail).length - 1"
class="structured-preview__log-divider"
/>
</div>
</template>
<template v-else>暂无内容</template>
</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">
<template v-if="getWorkLogEntries(task.detail).length">
<div
v-for="(entry, entryIndex) in getWorkLogEntries(task.detail)"
:key="`${index}-${sectionIndex}-${taskIndex}-${entryIndex}`"
class="structured-preview__log-entry"
>
<div class="structured-preview__log-text">{{ entry }}</div>
<div
v-if="entryIndex < getWorkLogEntries(task.detail).length - 1"
class="structured-preview__log-divider"
/>
</div>
</template>
<template v-else>暂无内容</template>
</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" @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" @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: var(--el-color-primary-light-8);
color: var(--el-color-primary);
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: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.radio-group-full :deep(.el-radio__input.is-checked + .el-radio__label) {
color: var(--el-color-primary);
}
.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: var(--el-color-primary);
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: var(--el-color-primary);
box-shadow: 0 0 0 2px var(--el-color-primary-light-8);
}
.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: var(--el-color-primary);
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: var(--el-color-primary);
}
.structured-preview__popover {
max-width: 100%;
color: #334155;
font-size: 13px;
line-height: 1.6;
display: grid;
gap: 0;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.structured-preview__log-entry {
display: grid;
gap: 10px;
}
.structured-preview__log-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.structured-preview__log-divider {
width: 100%;
height: 1px;
background: #e2e8f0;
}
.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: var(--el-color-primary);
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: var(--el-color-primary);
}
.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: var(--el-color-primary);
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: var(--el-color-primary);
}
.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: var(--el-color-primary-light-9);
color: var(--el-color-primary);
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: var(--el-color-primary-light-7);
background: var(--el-color-primary-light-9);
box-shadow: inset 0 0 0 1px var(--el-color-primary-light-9);
}
.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);
}
@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>