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

2308 lines
64 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, no-plusplus, @typescript-eslint/no-shadow */
import { computed, onMounted, reactive, ref } 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,
formatPeriodLabel,
getStructuredSections
} from '../../shared/types';
const props = defineProps<{
reportType: string;
period: string;
mode?: 'add' | 'edit' | 'detail';
scene?: 'fill' | 'detail' | 'approval';
baseInfo?: Api.WorkReport.Monthly.MonthlyReport | null;
model: Api.WorkReport.Monthly.MonthlyReportSaveParams;
}>();
const emit = defineEmits<{
back: [];
save: [];
submit: [];
viewAudit: [];
viewApproval: [];
pullDefaultDraft: [];
}>();
interface ReviewItem {
workItem: string;
days: number;
hours?: number;
content: string;
contentHtml: string;
contentSections?: StructuredSection[];
reflection: string;
removable?: boolean;
source?: Api.WorkReport.Common.PersonalReportReviewItem;
}
interface PlanItem {
workItem: string;
target: string;
targetHtml: string;
targetSections?: StructuredSection[];
supportNeed: string;
removable?: boolean;
source?: Api.WorkReport.Common.PersonalReportPlanItem;
sourceIndex?: number;
}
interface StructuredTask {
title: string;
priority?: string;
progress?: number;
hours?: number;
detail?: string;
}
interface PlanTaskDraft {
title: string;
priority?: StructuredTask['priority'];
progress: number;
detail?: string;
}
interface StructuredSection {
category: string;
tasks: StructuredTask[];
}
interface PlanSectionDraft extends StructuredSection {
draft: PlanTaskDraft;
}
const planDialogVisible = ref(false);
const activeEditField = ref('');
const EMPTY_TEXT = '本周期内暂无数据';
const isReadonly = computed(() => props.mode === 'detail' || props.scene === 'approval');
const { getLabel: getTaskItemTypeLabel } = useDict(RDMS_TASK_ITEM_TYPE_DICT_CODE);
const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
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 || '--';
}
});
const planForm = ref({
workItem: '',
supportNeed: '',
sections: [] as PlanSectionDraft[]
});
const activePlanSectionIndex = ref(-1);
/** 工作事项下拉里的"我的事项"固定项,用于区分纯个人事务。 */
const MY_AFFAIRS_TITLE = '我的事项';
/** 「我参与的项目」项目名清单,下拉框数据源。 */
const participatedProjectNames = ref<string[]>([]);
async function loadParticipatedProjectNames() {
// pageSize=-1 一次拉全部(不分页),由后端按"进行中 + 创建时间升序"过滤排序。
const { data, error } = await fetchGetMyParticipatedProjectPage({ pageNo: 1, pageSize: -1 });
if (error || !data) {
participatedProjectNames.value = [];
return;
}
participatedProjectNames.value = data.list
.map(project => project.name?.trim())
.filter((name): name is string => Boolean(name));
}
onMounted(loadParticipatedProjectNames);
const reviewWorkItemOptions = computed(() => {
// 下拉数据来自「我参与的项目」API 拉取的项目名 + 「我的事项」固定项,
// 与周报共用同一份数据源fetchGetMyParticipatedProjectPage, pageSize=-1
const names = new Set<string>(participatedProjectNames.value);
return [MY_AFFAIRS_TITLE, ...Array.from(names).filter(name => name !== MY_AFFAIRS_TITLE)];
});
/** 选中"我的事项"时,事项不参与项目优先级,需要隐藏优先级相关展示。 */
const isMyAffairsPlanItem = (workItem: string) => workItem.trim() === MY_AFFAIRS_TITLE;
const DEFAULT_SECTION_CATEGORY = '工作内容';
function createPlanTaskDraft(): PlanTaskDraft {
return { title: '', priority: '2', progress: 0 };
}
function normalizeTask(task: WorkReportStructuredTask): StructuredTask {
return {
title: task.title || '',
priority: normalizePriorityCode(task.priority),
progress: typeof task.progress === 'number' ? task.progress : undefined,
hours: typeof task.hours === 'number' ? task.hours : undefined,
detail: task.detail || ''
};
}
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) {
return getTaskItemTypeLabel(value, { fallback: value || '工作内容' });
}
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());
}
function stripStructuredTaskPrefixV2(value: string) {
return value.trim().replace(/^\d+[..、]\s*/u, '');
}
function stripStructuredTaskSuffixV2(value: string) {
return value.trim().replace(/[。.!?]+$/u, '');
}
function resolveTaskMetricsV2(metricsText: string) {
const parts = metricsText
.split('/')
.map(item => item.trim())
.filter(Boolean);
const priority = normalizePriorityCode(parts.find(item => /^P?\d+$/iu.test(item)));
// 同时支持 "进度 XX%" 与裸 "XX%",避免失焦后丢失进度。
const progressText =
metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1] ||
parts.find(item => /^\d+(?:\.\d+)?%$/u.test(item))?.replace(/%$/u, '');
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/u)?.[1];
return {
priority,
progress: progressText === undefined ? undefined : Number(progressText),
hours: hoursText === undefined ? undefined : Number(hoursText)
};
}
function formatStructuredTaskLineV2(task: StructuredTask, showHours = false) {
const title = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(task.title));
const metrics = [
task.priority ? resolvePriorityLabel(task.priority) : '',
typeof task.progress === 'number' ? `${task.progress}%` : '',
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
].filter(Boolean);
return `${title}${metrics.length ? `${metrics.join(' / ')}` : ''}`;
}
function 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(`${index + 1}${formatStructuredTaskLineV2(task, 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 createStructuredSectionsFromTextV2(text: string, defaultCategory: string): StructuredSection[] {
const lines = normalizeEditorText(text).split('\n').filter(Boolean);
if (!lines.length || lines.join('\n') === EMPTY_TEXT) return [];
const sections: StructuredSection[] = [];
const ensureSection = (categoryText: string) => {
const category = 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;
}
// 旧格式数据是“分类 - 事项(指标)”,这里需要把括号里的指标一并交给任务解析器,
// 否则月报默认稿中的优先级/进度/工时会在这一层被截掉。
const legacyMatch = trimmedLine.match(/^(?!\d+[、.]\s*)(.+?)\s*[-]\s*(.+)$/u);
if (legacyMatch) {
const [, rawCategory, rawTaskText] = legacyMatch;
const category = rawCategory.trim();
const task = parseStructuredSectionTaskText(rawTaskText);
if (!category || !task) return;
ensureSection(category).tasks.push(task);
currentCategory = category;
return;
}
const task = parseStructuredSectionTaskText(trimmedLine);
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 sections.filter(section => section.tasks.length);
}
function parseStructuredSectionTaskText(
text: string,
fallback?: Partial<StructuredTask>,
useFallback = false
): StructuredTask | null {
const normalizedText = stripStructuredTaskPrefixV2(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 = stripStructuredTaskSuffixV2(rawTitle);
if (!title) return null;
return {
title,
detail: detail.trim(),
...resolveTaskMetrics(metricsText, fallback, useFallback)
};
}
function createFallbackTaskLookup(sections: StructuredSection[]) {
const lookup = new Map<string, StructuredTask[]>();
sections.forEach(section => {
const categoryKey = normalizeSectionCategory(section.category);
section.tasks.forEach(task => {
const titleKey = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(task.title));
const key = `${categoryKey}||${titleKey}`;
const list = lookup.get(key);
if (list) {
list.push(task);
} else {
lookup.set(key, [task]);
}
});
});
return lookup;
}
function parseStructuredSectionsFromEditorV2(
editor: HTMLElement,
fallbackSections: StructuredSection[] = [],
defaultCategory = DEFAULT_SECTION_CATEGORY
): StructuredSection[] {
const parsedSections = createStructuredSectionsFromTextV2(editor.innerText, defaultCategory);
if (!parsedSections.length) return [];
const fallbackLookup = createFallbackTaskLookup(fallbackSections);
return parsedSections.map((section, sectionIndex) => {
const fallbackSection = fallbackSections[sectionIndex];
const categoryKey = normalizeSectionCategory(section.category);
return {
category: section.category,
tasks: section.tasks.map((task, taskIndex) => {
const titleKey = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(task.title));
const matchedByKey = fallbackLookup.get(`${categoryKey}||${titleKey}`)?.shift();
const matchedByIndex = fallbackSection?.tasks[taskIndex];
const fallbackTask = matchedByKey || matchedByIndex;
return {
...task,
detail: fallbackTask?.detail || task.detail || '',
hours: task.hours ?? fallbackTask?.hours
};
})
};
});
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function createTaskMetrics(task: StructuredTask, showHours: boolean) {
const metrics = [
task.priority ? escapeHtml(resolvePriorityLabel(task.priority)) : '',
typeof task.progress === 'number' ? `进度${task.progress}%` : '',
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
].filter(Boolean);
return metrics.length ? `<span class="rich-inline-metrics">${metrics.join(' • ')}</span>` : '';
}
function createStructuredHtml(sections: StructuredSection[], showHours = false) {
const stripOrderPrefix = (text: string) => text.trim().replace(/^\d+[..、]\s*/, '');
const splitPunctuation = (text: string) => {
const rawTitle = stripOrderPrefix(text);
const punctuation = rawTitle.match(/[。.!?]$/)?.[0] || '';
return {
title: punctuation ? rawTitle.slice(0, -1) : rawTitle,
punctuation
};
};
return sections
.map(section => {
const tasks = section.tasks
.map((task, taskIndex) => {
const { title, punctuation } = splitPunctuation(task.title);
return `<div class="rich-section-task"><span class="rich-section-task-title">${taskIndex + 1}.${escapeHtml(title)}</span>${createTaskMetrics(task, showHours)}${escapeHtml(punctuation)}</div>`;
})
.join('');
return `
<div class="rich-section">
<div class="rich-section-title">${escapeHtml(resolveTaskItemTypeLabel(section.category.trim()))}</div>
${tasks ? `<div class="rich-section-tasks">${tasks}</div>` : ''}
</div>
`;
})
.join('');
}
function normalizeEditorText(value: string) {
return value
.replace(/\u00A0/g, ' ')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n');
}
function createStructuredSectionsFromText(
text: string,
category: string,
defaultTask?: Partial<StructuredTask>
): StructuredSection[] {
const lines = normalizeEditorText(text).split('\n').filter(Boolean);
if (!lines.length || lines.join('\n') === EMPTY_TEXT) return [];
const groupedSections: StructuredSection[] = [];
lines.forEach(line => {
const structuredMatch = line.match(/^(.+?)\s*[-]\s*(.+?)[(]([^)]*)[)]$/u);
if (!structuredMatch) return;
const [, rawCategory, rawTitle, metricsText] = structuredMatch;
const categoryText = rawCategory.trim();
const title = rawTitle.trim();
if (!categoryText || !title) return;
let section = groupedSections.find(item => item.category === categoryText);
if (!section) {
section = { category: categoryText, tasks: [] };
groupedSections.push(section);
}
section.tasks.push({
...defaultTask,
title,
...resolveTaskMetrics(metricsText, defaultTask)
});
});
if (groupedSections.length) return groupedSections;
const looksLikeTaskLine = (value: string) => /^\d+[.]\s*/u.test(value);
const hasNumberedTask = lines.some(looksLikeTaskLine);
const sectionCategory = hasNumberedTask && !looksLikeTaskLine(lines[0]) ? lines[0] : category;
const taskSourceText = (sectionCategory === lines[0] ? lines.slice(1) : lines).join(' ');
const taskParts = hasNumberedTask
? taskSourceText
.split(/(?=\d+[..、]\s*)/u)
.map(item => item.trim())
.filter(Boolean)
: lines;
const tasks = taskParts
.map((item, index) => {
const normalizedTitle = stripTaskOrderPrefix(item);
const metricsText = normalizedTitle.match(/[(]([^)]*)[)]/u)?.[1] || '';
const title = normalizedTitle.replace(/[(][^)]*[)]/u, '').trim();
if (!title) return null;
return {
...(index === 0 ? defaultTask : undefined),
title,
...resolveTaskMetrics(metricsText, index === 0 ? defaultTask : undefined)
};
})
.filter(Boolean) as StructuredTask[];
if (!tasks.length) return [];
return [
{
category: sectionCategory,
tasks
}
];
}
function getElementText(element: Element | null) {
return normalizeEditorText((element as HTMLElement | null)?.innerText || '');
}
function stripTaskOrderPrefix(value: string) {
return value.trim().replace(/^\d+[..、]\s*/, '');
}
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));
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 createSectionsJson(sections: StructuredSection[]) {
return sections.length ? JSON.stringify({ sections }) : null;
}
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 || DEFAULT_SECTION_CATEGORY);
}
function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
const contentSections = getReviewItemSections(item);
return {
workItem: item.itemTitle || '未命名工作',
days: 0,
hours: item.workHours || 0,
content: item.contentText || '',
contentHtml: contentSections.length
? createStructuredHtmlV2(contentSections, true)
: escapeHtml(item.contentText || '').replace(/\n/g, '<br>') || EMPTY_TEXT,
contentSections,
reflection: item.reflectionText || '',
removable: true,
source: item
};
}
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 toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: number): PlanItem {
const targetSections = getPlanItemSections(item);
return {
workItem: item.itemTitle || '未命名计划',
target: item.targetText || '',
targetHtml: targetSections.length
? createStructuredHtmlV2(targetSections)
: escapeHtml(item.targetText || '').replace(/\n/g, '<br>') || EMPTY_TEXT,
targetSections,
supportNeed: item.supportNeed || '',
removable: true,
source: item,
sourceIndex: index
};
}
const reviewItems = computed<ReviewItem[]>(() => {
return (props.model.reviewItems || []).map(toReviewItem);
});
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(() => formatPeriodLabel(props.model.periodLabel || props.period));
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--;
}
}
function addPlanTask(sectionIndex: number) {
const section = planForm.value.sections[sectionIndex];
if (!section?.draft.title.trim()) return;
const task: StructuredTask = {
title: section.draft.title.trim(),
progress: section.draft.progress,
detail: section.draft.detail?.trim() || undefined
};
// 我的事项不参与项目优先级,存进任务前显式剔除,结构化展示括号里就不会出现优先级。
if (!isMyAffairsPlanItem(planForm.value.workItem)) {
task.priority = section.draft.priority;
}
section.tasks.push(task);
section.draft = createPlanTaskDraft();
}
function removePlanTask(sectionIndex: number, taskIndex: number) {
planForm.value.sections[sectionIndex].tasks.splice(taskIndex, 1);
}
function submitInlinePlan() {
const sections = 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 mergedSections = mergeSectionsByCategory([...getPlanItemSections(sameWorkItem), ...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.indexOf(item.source);
if (sourceIndex >= 0) props.model.reviewItems.splice(sourceIndex, 1);
}
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);
}
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 :model-value="periodText" disabled />
</div>
<div class="field">
<label>直接上级</label>
<ElInput v-model="mainForm.supervisor" disabled />
</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_TEXT }}</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
class="rich-editor"
:contenteditable="!isReadonly"
spellcheck="false"
: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_TEXT }}</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
class="rich-editor"
:contenteditable="!isReadonly"
spellcheck="false"
: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 v-if="false" class="plan-card inline-plan-card">
<div class="plan-index-cell">
<span class="row-index">{{ nextPlans.length + 1 }}</span>
</div>
<div class="plan-name-cell">
<ElInput
v-model="planForm.workItem"
class="inline-plan-name-input"
type="textarea"
:rows="2"
placeholder="请输入项目名或我的事项"
/>
</div>
<div class="plan-action-cell">
<button class="item-remove-btn" aria-label="取消新增计划" @click="cancelInlinePlan">×</button>
</div>
<div class="plan-editor-grid">
<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">
<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"
/>
<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">
<em>{{ resolvePriorityLabel(task.priority) }}</em>
<em>进度 {{ task.progress }}%</em>
</div>
<ElButton link type="danger" size="small" @click="removePlanTask(sIdx, tIdx)">删除</ElButton>
</div>
</div>
<div class="plan-task-form">
<div class="inline-task-row">
<ElInput
v-model="section.draft.title"
size="small"
placeholder="名称"
@focus="activePlanSectionIndex = sIdx"
/>
<div class="field">
<label>优先级</label>
<DictSelect
v-model="section.draft.priority"
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
:clearable="false"
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>
<ElButton
type="primary"
plain
size="small"
:disabled="!section.draft.title.trim()"
@click="addPlanTask(sIdx)"
>
添加
</ElButton>
</div>
</div>
<ElButton
v-if="!planForm.sections.length || planForm.sections.every(s => s.category.trim())"
type="primary"
plain
size="small"
@click="addPlanSection"
>
+ 新增类别
</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>
<div class="inline-plan-actions">
<ElButton size="small" @click="cancelInlinePlan">取消</ElButton>
<ElButton
size="small"
type="primary"
:disabled="
!planForm.workItem.trim() ||
!planForm.sections.some(section => section.category.trim() && section.tasks.length)
"
@click="submitInlinePlan"
>
确认新增
</ElButton>
</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>
<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"
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"
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>
</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;
}
.form-head-actions {
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
.tab-action-btn {
padding: 10px 20px;
border-radius: 13px;
background: #fff;
color: #164e63;
font-size: 14px;
font-weight: 800;
cursor: pointer;
border: 0;
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.08);
transition:
transform 0.16s ease,
box-shadow 0.16s ease,
background 0.16s ease;
}
.tab-action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.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(5, 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,
.plan-editor-grid {
display: grid;
grid-template-columns: minmax(0, calc(57.45% - 6px + 50px)) minmax(0, calc(42.55% - 6px - 50px));
gap: 12px;
}
.review-editor-grid {
grid-column: 3 / span 2;
grid-row: 1;
border-left: 1px solid #e5edf1;
}
.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-section-title) {
position: relative;
min-width: 0;
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-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-section-task) {
display: grid;
gap: 6px;
padding-left: 14px;
color: #334155;
font-size: 13px;
line-height: 1.6;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.rich-editor :deep(.rich-section-task) {
display: block;
white-space: normal;
}
.rich-editor :deep(.rich-section-task-title) {
color: #334155;
overflow-wrap: anywhere;
word-break: break-word;
}
.rich-editor :deep(.rich-inline-metrics) {
display: inline-flex;
align-items: center;
height: 18px;
margin-left: 3px;
padding: 0 6px;
border-radius: 999px;
background: #f3f7f9;
color: #475467;
font-size: 11px;
font-weight: 800;
vertical-align: 1px;
user-select: text;
cursor: text;
}
.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) {
padding-left: 0;
}
.rich-editor :deep(.rich-task-line) {
color: #334155;
font-size: 13px;
line-height: 1.6;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.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-section {
display: grid;
gap: 2px;
}
.structured-section + .structured-section {
margin-top: 0;
padding-top: 4px;
border-top: 1px dashed #cbd5e1;
}
.structured-section-title {
position: relative;
padding-left: 14px;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 800;
}
.structured-section-title::before {
content: '';
position: absolute;
left: 0;
top: 4px;
width: 4px;
height: 16px;
border-radius: 999px;
background: var(--el-color-primary);
}
.structured-task {
display: grid;
gap: 2px;
}
.structured-title + .structured-title {
padding-top: 10px;
border-top: 1px dashed #cbd5e1;
}
.structured-task-category {
position: relative;
padding-left: 14px;
color: var(--el-color-primary);
font-size: 13px;
font-weight: 800;
}
.structured-task-category::before {
content: '';
position: absolute;
left: 0;
top: 4px;
width: 4px;
height: 16px;
border-radius: 999px;
background: var(--el-color-primary);
}
.structured-task-title {
color: #334155;
padding-left: 14px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.plan-dialog-form {
display: grid;
gap: 16px;
}
.plan-dialog-form > .field {
gap: 8px;
}
.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: 3px;
padding: 8px 10px;
border: 1px solid #eef2f6;
border-radius: 9px;
background: #f8fbfc;
}
.plan-task + .plan-task {
margin-top: 0;
}
.plan-task-detail {
color: #475569;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.plan-task-head {
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-form {
display: grid;
gap: 12px;
padding: 12px 0 0;
border: 0;
border-top: 1px dashed #d8e0e8;
border-radius: 0;
background: transparent;
}
.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;
}
.inline-plan-card {
border-color: var(--el-color-primary-light-5);
background: #f8fbfc;
}
.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;
}
.inline-plan-card .plan-name-cell {
justify-content: stretch;
}
.inline-plan-name-input {
width: 100%;
min-width: 260px;
}
.inline-plan-actions {
grid-column: 3 / 4;
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 0 12px 12px;
}
.inline-plan-card :deep(.el-input__wrapper),
.inline-plan-card :deep(.el-select__wrapper) {
border-radius: 10px;
box-shadow: 0 0 0 1px #d8e0e8 inset;
}
.inline-plan-card :deep(.el-input__wrapper.is-focus),
.inline-plan-card :deep(.el-select__wrapper.is-focused) {
box-shadow:
0 0 0 1px var(--el-color-primary) inset,
0 0 0 2px var(--el-color-primary-light-8);
}
.inline-plan-card :deep(.el-textarea__inner:focus) {
box-shadow:
0 0 0 1px var(--el-color-primary) inset,
0 0 0 2px var(--el-color-primary-light-8);
}
.inline-plan-card .inline-task-row :deep(.el-select__selected-item),
.inline-plan-card .inline-task-row :deep(.el-select__placeholder) {
width: 100%;
justify-content: center;
text-align: center;
}
.inline-plan-card .inline-task-row :deep(.el-input__wrapper),
.inline-plan-card .inline-task-row :deep(.el-select__wrapper),
.inline-plan-card .inline-task-row :deep(.el-input-number),
.inline-plan-card .inline-task-row :deep(.el-input-number .el-input__wrapper) {
height: 36px !important;
min-height: 36px !important;
overflow: hidden;
}
.inline-plan-card .inline-task-row :deep(.el-input-number.is-controls-right .el-input-number__increase),
.inline-plan-card .inline-task-row :deep(.el-input-number.is-controls-right .el-input-number__decrease) {
right: 1px;
height: 18px;
}
.inline-plan-card .inline-task-row :deep(.el-input-number.is-controls-right .el-input-number__increase) {
top: 0;
border-top-right-radius: 9px;
}
.inline-plan-card .inline-task-row :deep(.el-input-number.is-controls-right .el-input-number__decrease) {
bottom: 0;
border-bottom-right-radius: 9px;
}
.inline-plan-card .inline-task-row :deep(.el-input-number:focus-within .el-input__wrapper) {
overflow: hidden;
box-shadow:
0 0 0 1px var(--el-color-primary) inset,
0 0 0 2px var(--el-color-primary-light-8) !important;
}
.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);
}
@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 {
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>