fix(产品需求、项目需求、工作报告、我的绩效、加班申请): 1、修复搜索区域的下拉框,无法搜索的问题。2、修复工作报告内容溢出的问题。3、修复我的待办 - 待审批,里面的二级tabs没有加权限的问题。4、优化周报里工作日志展示时的分隔符。5、优化项目需求池、我的绩效请求重复发送、影响效率的问题。

feat(我的绩效): 1、增加我的绩效在工作台可以直接处理的功能。2、增加我的绩效全部导出时,合并多个excel的sheet为一个excel的功能。
This commit is contained in:
dk
2026-06-24 18:02:36 +08:00
parent b26a9c8a39
commit 3ffdad142d
16 changed files with 569 additions and 81 deletions

View File

@@ -358,8 +358,10 @@ 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(/^\d+[..、]\s*/u, '');
return value.trim().replace(STRUCTURED_TASK_PREFIX_RE, '');
}
function stripStructuredTaskSuffixV2(value: string) {
@@ -381,16 +383,13 @@ function formatStructuredTaskDisplayLine(task: StructuredTask, index: number, sh
return `${index + 1}${formatStructuredTaskLineV2(task, showHours)}`;
}
// 周报工作日志弹层展示:后端用中文分号 "" 拼接多条工作日志,
// 在 popover 中按行展示,每条工作日志仍保留末尾的分号。
function formatWorkLogDetail(detail: string): string {
if (!detail) return '';
// 仅按中文分号切分,避免误伤文本中的其他标点;每段保持原样展示。
function getWorkLogEntries(detail: string): string[] {
if (!detail) return [];
// 仅按中文分号切分,避免误伤文本中的其他标点;每段作为单独一天的工作日志展示。
return detail
.split('')
.map(item => item.trim())
.filter(Boolean)
.join('\n');
.filter(Boolean);
}
function createStructuredTextV2(sections: StructuredSection[], showHours = false) {
@@ -493,7 +492,7 @@ function createStructuredTasksFromText(text: string, defaultTask?: Partial<Struc
}
function stripStructuredTaskPrefix(value: string) {
return value.trim().replace(/^\d+[..、]\s*/u, '');
return value.trim().replace(STRUCTURED_TASK_PREFIX_RE, '');
}
function stripStructuredTaskSuffix(value: string) {
@@ -524,6 +523,52 @@ function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTa
};
}
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>,
@@ -532,13 +577,7 @@ function parseStructuredSectionTaskText(
const normalizedText = stripStructuredTaskPrefix(text);
if (!normalizedText) return null;
const structuredMatch =
normalizedText.match(/^(.+?)(?:[(]([^()]*)[)])?(?:\s*[:]\s*(.*))?$/u) ||
normalizedText.match(/^(.+?)(?:\(([^()]*)\))?(?::\s*(.*))?$/u);
if (!structuredMatch) return null;
const [, rawTitle, metricsText = '', detail = ''] = structuredMatch;
const { rawTitle, metricsText, detail } = extractStructuredTaskParts(normalizedText);
const title = stripStructuredTaskSuffix(rawTitle);
if (!title) return null;
@@ -569,6 +608,16 @@ function createStructuredSectionsFromTextV2(
};
let currentCategory = '';
let previousTask: StructuredTask | null = null;
const shouldAppendToPreviousTaskDetail = (line: string) => {
if (!previousTask?.detail) return false;
if (line.startsWith('#')) return false;
if (line.includes('') || line.includes('(') || line.includes('') || line.includes(':')) return false;
return !/^(?!(?:(?:\d+[..、])|(?:\d+\s+)|(?:[一二三四五六七八九十百千万]+[、.]))\s*)(.+?)\s*[-]\s*(.+)$/u.test(
line
);
};
lines.forEach(line => {
const trimmedLine = line.trim();
@@ -577,12 +626,15 @@ function createStructuredSectionsFromTextV2(
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(/^(?!\d+[、.]\s*)(.+?)\s*[-]\s*(.+)$/u);
const legacyMatch = trimmedLine.match(
/^(?!(?:(?:\d+[..、])|(?:\d+\s+)|(?:[一二三四五六七八九十百千万]+[、.]))\s*)(.+?)\s*[-]\s*(.+)$/u
);
if (legacyMatch) {
const [, rawCategory, rawTaskText] = legacyMatch;
const category = rawCategory.trim();
@@ -590,6 +642,14 @@ function createStructuredSectionsFromTextV2(
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;
}
@@ -597,7 +657,7 @@ function createStructuredSectionsFromTextV2(
if (!task) return;
const hasStructuredHint =
/^(\d+[..、]\s*)/u.test(trimmedLine) ||
STRUCTURED_TASK_PREFIX_RE.test(trimmedLine) ||
trimmedLine.includes('') ||
trimmedLine.includes('(') ||
trimmedLine.includes('') ||
@@ -606,6 +666,7 @@ function createStructuredSectionsFromTextV2(
if (!hasStructuredHint && !currentCategory) return;
ensureSection(currentCategory || defaultCategory).tasks.push(task);
previousTask = task;
});
return mergeSectionsByCategory(sections);
@@ -657,7 +718,7 @@ function parseStructuredSectionsFromEditorV2(
...task,
detail: fallbackTask?.detail || '',
kind: fallbackTask?.kind,
hours: task.hours ?? fallbackTask?.hours
hours: task.hours
};
})
};
@@ -1065,6 +1126,11 @@ 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];
@@ -1096,6 +1162,11 @@ function handleStructuredViewClick(fieldKey: string) {
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 || [],
@@ -1108,6 +1179,11 @@ function syncRichContent(item: ReviewItem, event: Event) {
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 || [],
@@ -1232,7 +1308,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
</div>
</template>
<div class="structured-preview__popover">
{{ formatWorkLogDetail(task.detail) || '暂无内容' }}
<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">
@@ -1337,7 +1426,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
</div>
</template>
<div class="structured-preview__popover">
{{ formatWorkLogDetail(task.detail) || '暂无内容' }}
<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">
@@ -2161,11 +2263,30 @@ function syncRichSupport(item: PlanItem, event: Event) {
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;