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

@@ -40,6 +40,8 @@ export interface SearchField {
placeholder?: string;
/** select 类型的自定义选项渲染函数 */
renderOption?: (option: Option) => VNode | VNode[] | string;
/** select/dict 类型是否支持搜索 */
filterable?: boolean;
/** 值写回模型前的转换函数 */
transformValue?: (value: any) => any;
/** 从模型值解析展示值 */
@@ -168,6 +170,7 @@ function getFieldValue(field: SearchField) {
:model-value="getFieldValue(field)"
:placeholder="field.placeholder"
clearable
:filterable="field.filterable"
:disabled="props.disabled"
@update:model-value="val => updateFieldValue(field, val)"
>
@@ -207,6 +210,7 @@ function getFieldValue(field: SearchField) {
:model-value="getFieldValue(field)"
:dict-code="field.dictCode!"
:placeholder="field.placeholder"
:filterable="field.filterable"
:disabled="props.disabled"
:show-remark="field.showRemark"
@update:model-value="val => updateFieldValue(field, val)"
@@ -268,6 +272,7 @@ function getFieldValue(field: SearchField) {
:model-value="getFieldValue(field)"
:placeholder="field.placeholder"
clearable
:filterable="field.filterable"
:disabled="props.disabled"
@update:model-value="val => updateFieldValue(field, val)"
>
@@ -307,6 +312,7 @@ function getFieldValue(field: SearchField) {
:model-value="getFieldValue(field)"
:dict-code="field.dictCode!"
:placeholder="field.placeholder"
:filterable="field.filterable"
:disabled="props.disabled"
:show-remark="field.showRemark"
@update:model-value="val => updateFieldValue(field, val)"

View File

@@ -118,6 +118,7 @@ const actionType = ref<'confirm' | 'reject'>('confirm');
const recordVisible = ref(false);
const exporting = ref(false);
const currentUserId = computed(() => authStore.userInfo.userId || '');
const rowActionLoadingKey = ref('');
const ACTION_ICON_MAP = {
@@ -326,7 +327,9 @@ function getRowActions(row: Api.Performance.Sheet.Sheet): BusinessTableAction[]
}
if (isTeamMode.value) {
if (canUpdate.value && ['draft', 'rejected'].includes(row.statusCode)) {
const isDirectManager = Boolean(currentUserId.value) && row.managerId === currentUserId.value;
if (isDirectManager && canUpdate.value && ['draft', 'rejected'].includes(row.statusCode)) {
actions.push({
key: 'edit',
label: '编辑',
@@ -344,7 +347,7 @@ function getRowActions(row: Api.Performance.Sheet.Sheet): BusinessTableAction[]
});
}
if (canDelete.value && row.statusCode === 'draft') {
if (isDirectManager && canDelete.value && row.statusCode === 'draft') {
actions.push({
key: 'delete',
label: '删除',
@@ -599,13 +602,13 @@ async function loadDeptOptions() {
}
async function loadDirectSubordinateOptions() {
const currentUserId = authStore.userInfo.userId;
if (!currentUserId) {
const loginUserId = authStore.userInfo.userId;
if (!loginUserId) {
directSubordinateOptions.value = [];
return;
}
const { error, data: userList } = await fetchGetDirectSubordinates(currentUserId);
const { error, data: userList } = await fetchGetDirectSubordinates(loginUserId);
if (error || !userList) {
directSubordinateOptions.value = [];
@@ -643,8 +646,12 @@ async function handleTeamViewModeChange(mode: TeamViewMode) {
}
watch(
() => [teamViewMode.value, selectedSubordinateUserId.value],
async () => {
() => [teamViewMode.value, selectedSubordinateUserId.value] as const,
async ([mode], [previousMode]) => {
if (mode === 'self' && previousMode === 'self') {
return;
}
await refreshPageData(1);
}
);

View File

@@ -7,13 +7,18 @@ import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'PerformanceActionDialog' });
type ActionType = 'confirm' | 'reject';
interface Props {
rowData?: Api.Performance.Sheet.Sheet | null;
actionType: 'confirm' | 'reject';
actionType?: ActionType;
selectableActionType?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
rowData: null
rowData: null,
actionType: 'confirm',
selectableActionType: false
});
const visible = defineModel<boolean>('visible', { default: false });
@@ -26,13 +31,25 @@ const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const submitting = ref(false);
const form = reactive({
const form = reactive<{
actionType: ActionType;
reason: string;
}>({
actionType: 'confirm',
reason: ''
});
const isReject = computed(() => props.actionType === 'reject');
const title = computed(() => (isReject.value ? '退回绩效表' : '确认绩效表'));
const confirmText = computed(() => (isReject.value ? '确认退回' : '确认'));
const isReject = computed(() => form.actionType === 'reject');
const title = computed(() => {
if (props.selectableActionType) return '绩效审批';
return isReject.value ? '退回绩效表' : '确认绩效表';
});
const confirmText = computed(() => {
if (props.selectableActionType) return '确认提交';
return isReject.value ? '确认退回' : '确认';
});
const opinionLabel = computed(() => (isReject.value ? '退回原因' : '审批意见'));
const opinionPlaceholder = computed(() => (isReject.value ? `请输入${opinionLabel.value}` : '可填写审批意见'));
const rules = computed<FormRules>(() => ({
reason: isReject.value ? [createRequiredRule('请输入退回原因')] : []
}));
@@ -63,6 +80,7 @@ async function handleSubmit() {
watch(visible, isVisible => {
if (!isVisible) return;
form.actionType = props.actionType;
form.reason = '';
});
</script>
@@ -84,16 +102,119 @@ watch(visible, isVisible => {
<ElDescriptionsItem label="实际得分">{{ props.rowData?.actualScoreTotal ?? '--' }}</ElDescriptionsItem>
</ElDescriptions>
<ElFormItem class="mt-16px" :label="isReject ? '退回原因' : '确认意见'" prop="reason">
<div v-if="props.selectableActionType" class="performance-action-dialog__approval-form">
<div class="audit-field">
<label>审批结论</label>
<div class="audit-conclusion">
<button
type="button"
class="conclusion-btn"
:class="{
active: form.actionType === 'confirm',
pass: form.actionType === 'confirm'
}"
@click="form.actionType = 'confirm'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path
d="M5 8.5L7 10.5L11 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
确认
</button>
<button
type="button"
class="conclusion-btn"
:class="{
active: form.actionType === 'reject',
reject: form.actionType === 'reject'
}"
@click="form.actionType = 'reject'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
退回
</button>
</div>
</div>
</div>
<ElFormItem class="mt-16px" :label="opinionLabel" prop="reason">
<ElInput
v-model="form.reason"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
:placeholder="isReject ? '请输入退回原因' : '可填写确认意见'"
:placeholder="opinionPlaceholder"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.performance-action-dialog__approval-form {
display: grid;
gap: 18px;
margin-top: 16px;
}
.audit-field {
display: grid;
gap: 8px;
}
.audit-field label {
color: #475467;
font-size: 13px;
font-weight: 800;
}
.audit-conclusion {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.conclusion-btn {
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #d8e0e8;
border-radius: 8px;
background: #fff;
color: #475467;
font: inherit;
font-size: 14px;
font-weight: 800;
cursor: pointer;
transition: all 0.18s ease;
}
.conclusion-btn:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.conclusion-btn.active.pass {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.conclusion-btn.active.reject {
border-color: #dc2626;
background: #fef2f2;
color: #dc2626;
}
</style>

View File

@@ -26,11 +26,13 @@ interface Props {
rowData?: Api.Performance.Sheet.Sheet | null;
mode: 'view' | 'edit' | 'create';
subordinateOptions?: SubordinateOption[];
showApprovalFooter?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
rowData: null,
subordinateOptions: () => []
subordinateOptions: () => [],
showApprovalFooter: false
});
const visible = defineModel<boolean>('visible', { default: false });
@@ -38,6 +40,7 @@ const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{
saved: [];
savedAndSent: [];
startApproval: [];
}>();
const { formRef, validate } = useForm();
@@ -83,6 +86,7 @@ const drawerTitle = computed(() => {
return `${action}绩效 Excel${name ? ` - ${name}` : ''}`;
});
const canSave = computed(() => props.mode !== 'view');
const showApprovalFooter = computed(() => props.mode === 'view' && props.showApprovalFooter);
const drawerSize = computed(() => (viewportWidth.value >= 2560 ? '60%' : '88%'));
function syncViewportWidth() {
@@ -93,6 +97,10 @@ function handleClose() {
visible.value = false;
}
function handleStartApproval() {
emit('startApproval');
}
function disposeUniver() {
try {
univerInstance?.dispose?.();
@@ -687,10 +695,16 @@ onMounted(() => {
<div ref="containerRef" class="performance-excel-editor__container" />
</div>
<template v-if="canSave" #footer>
<template v-if="canSave || showApprovalFooter" #footer>
<div class="performance-excel-editor__footer">
<template v-if="canSave">
<ElButton :loading="saving" :disabled="sending" @click="handleSaveDraft">保存草稿</ElButton>
<ElButton type="primary" :loading="sending" :disabled="saving" @click="handleSaveAndSend">发送绩效</ElButton>
</template>
<template v-else-if="showApprovalFooter">
<ElButton @click="handleClose">退出审批</ElButton>
<ElButton type="primary" @click="handleStartApproval">开始审批</ElButton>
</template>
</div>
</template>
</ElDrawer>

View File

@@ -45,7 +45,14 @@ const baseFields = computed<SearchField[]>(() => [
const teamFields = computed<SearchField[]>(() => [
baseFields.value[0],
{ key: 'employeeId', label: '下属', type: 'select', placeholder: '请选择下属', options: props.subordinateOptions },
{
key: 'employeeId',
label: '下属',
type: 'select',
placeholder: '请选择下属',
options: props.subordinateOptions,
filterable: true
},
{ key: 'employeeDeptId', label: '部门', type: 'select', placeholder: '请选择部门', options: props.deptOptions },
{ key: 'managerName', label: '直属上级', type: 'input', placeholder: '请输入直属上级' },
baseFields.value[1]

View File

@@ -255,7 +255,7 @@ watch(visible, isVisible => {
</ElTooltip>
</div>
<ElTooltip placement="top" effect="light">
<template #content>支持 .xlsx.xls选择后会在这里显示文件名</template>
<template #content>支持 .xlsx格式选择后会在这里显示文件名</template>
<button type="button" class="performance-template-dialog__hint-button" aria-label="Excel 文件说明">
<icon-mdi-information-outline />
</button>

View File

@@ -97,6 +97,7 @@ const fields = computed<SearchField[]>(() => {
type: 'select' as const,
options: props.subordinateOptions,
placeholder: '请选择申请人',
filterable: true,
transformValue: (value: string | number | null | undefined) => (value ? [value] : undefined),
resolveValue: (value: unknown) => (Array.isArray(value) ? value[0] : value)
}

View File

@@ -342,6 +342,52 @@ function createStructuredSectionsFromTextV2(text: string, defaultCategory: strin
return sections.filter(section => section.tasks.length);
}
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>,
@@ -350,13 +396,7 @@ function parseStructuredSectionTaskText(
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 { rawTitle, metricsText, detail } = extractStructuredTaskParts(normalizedText);
const title = stripStructuredTaskSuffixV2(rawTitle);
if (!title) return null;
@@ -412,7 +452,7 @@ function parseStructuredSectionsFromEditorV2(
return {
...task,
detail: fallbackTask?.detail || task.detail || '',
hours: task.hours ?? fallbackTask?.hours
hours: task.hours
};
})
};

View File

@@ -178,7 +178,12 @@ function handleConfirm() {
<div class="work-report-create-dialog__body">
<div v-if="selectedReportType === 'project'" class="work-report-create-dialog__project-select">
<label class="work-report-create-dialog__label">项目</label>
<ElSelect v-model="selectedProjectId" class="w-full" placeholder="请选择项目" filterable>
<ElSelect
v-model="selectedProjectId"
class="work-report-create-dialog__project-select-control w-full"
placeholder="请选择项目"
filterable
>
<ElOption
v-for="item in props.projectOptions"
:key="item.id"
@@ -367,6 +372,16 @@ function handleConfirm() {
gap: 6px;
}
.work-report-create-dialog__project-select-control :deep(.el-select__wrapper) {
min-height: 46px;
border-radius: 12px;
padding-inline: 14px;
}
.work-report-create-dialog__project-select-control :deep(.el-select__selected-item) {
line-height: 1.4;
}
.work-report-create-dialog__field {
display: grid;
gap: 6px;

View File

@@ -83,6 +83,7 @@ const fields = computed<SearchField[]>(() => {
type: 'select',
options: props.subordinateOptions,
placeholder: '请选择提交人',
filterable: true,
transformValue: value => (value ? [value] : undefined),
resolveValue: value => (Array.isArray(value) ? value[0] : value)
}
@@ -115,7 +116,8 @@ const fields = computed<SearchField[]>(() => {
label: item.projectCode ? `${item.projectName}${item.projectCode}` : item.projectName,
value: item.id
})),
placeholder: '请选择项目'
placeholder: '请选择项目',
filterable: true
}
];
}
@@ -129,6 +131,7 @@ const fields = computed<SearchField[]>(() => {
type: 'select',
options: props.subordinateOptions,
placeholder: '请选择提交人',
filterable: true,
transformValue: value => (value ? [value] : undefined),
resolveValue: value => (Array.isArray(value) ? value[0] : value)
}

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;

View File

@@ -39,7 +39,7 @@ const PAGE_SIZE = 10;
/** 产品行多列数(名称/编码/经理/我的角色/状态/原因/更新),非产品行整行合并用 */
const COLUMN_COUNT = 7;
/** 产品描述副行长度阈值:超过时展示「详情」入口 */
const PRODUCT_DESC_MAX_LEN = 48;
const PRODUCT_DESC_MAX_LEN = 40;
interface DirectionGroup {
directionCode: string;

View File

@@ -122,6 +122,7 @@ const fields = computed(() => [
type: 'select' as const,
placeholder: '筛选负责人',
options: memberSelectOptions.value,
filterable: true,
renderOption: renderMemberOption
}
]);

View File

@@ -639,7 +639,7 @@ async function reloadTable() {
try {
await loadTreeData();
await Promise.all([loadAllowedTransitionsForAll(), loadRequirementDisplayTotal()]);
await loadAllowedTransitionsForAll();
} finally {
loading.value = false;
}
@@ -813,11 +813,13 @@ async function handleDelete(row: Api.Project.ProjectRequirement) {
}
window.$message?.success('需求删除成功');
await loadRequirementDisplayTotal();
await reloadTable();
}
async function handleCreateSubmitted() {
createVisible.value = false;
await loadRequirementDisplayTotal();
await reloadTable();
}
@@ -828,6 +830,7 @@ async function handleDetailSubmitted() {
async function handleSplitSubmitted() {
splitVisible.value = false;
await loadRequirementDisplayTotal();
await reloadTable();
}
@@ -855,8 +858,10 @@ watch(
return;
}
await Promise.all([loadMembers(), loadTreeData()]);
await Promise.all([loadAllowedTransitionsForAll(), loadRequirementDisplayTotal()]);
treeData.value = [];
allowedTransitionsMap.value = new Map();
pagination.total = 0;
await Promise.all([loadMembers(), loadRequirementDisplayTotal()]);
},
{ immediate: true }
);
@@ -876,6 +881,7 @@ Promise.all([loadStatusOptions()]);
:member-options="memberUserOptions"
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
:status-options="statusOptions"
@reset="handleResetSearch"
@search="handleSearch"
/>

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue';
// import { RDMS_REQ_SOURCE_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetProjectRequirementStatusDict } from '@/service/api';
import { computed, h } from 'vue';
// import { useDict } from '@/hooks/business/dict';
import TableSearchFields from '@/components/custom/table-search-fields.vue';
import MemberSelectOption from './member-select-option.vue';
@@ -18,6 +16,7 @@ interface Props {
memberOptions: MemberUserOption[];
categoryDictCode: string;
priorityDictCode: string;
statusOptions: Array<{ label: string; value: string }>;
}
const props = defineProps<Props>();
@@ -31,7 +30,6 @@ const emit = defineEmits<Emits>();
const model = defineModel<Api.Project.ProjectRequirementSearchParams>('model', { required: true });
const requirementStatusOptions = ref<Array<{ label: string; value: string }>>([]);
// const { enabledDictData: sourceTypeDictData } = useDict(RDMS_REQ_SOURCE_TYPE_DICT_CODE);
// const sourceTypeOptions = computed(() => {
// return sourceTypeDictData.value.map(item => ({
@@ -55,24 +53,6 @@ function renderMemberOption(option: { label: string; value: string | number; rol
});
}
async function loadStatusOptions() {
const { error, data } = await fetchGetProjectRequirementStatusDict();
if (error || !data) {
requirementStatusOptions.value = [];
return;
}
requirementStatusOptions.value = data.map(item => ({
label: item.statusName,
value: item.statusCode
}));
}
onMounted(async () => {
await loadStatusOptions();
});
const fields = computed(() => [
{
key: 'title',
@@ -92,7 +72,7 @@ const fields = computed(() => [
label: '状态',
type: 'select' as const,
placeholder: '筛选状态',
options: requirementStatusOptions.value
options: props.statusOptions
},
{
key: 'category',
@@ -120,6 +100,7 @@ const fields = computed(() => [
type: 'select' as const,
placeholder: '筛选负责人',
options: memberSelectOptions.value,
filterable: true,
renderOption: renderMemberOption
}
]);

View File

@@ -18,6 +18,7 @@ import {
fetchGetProjectReportApprovalPage,
fetchGetProjectTask,
fetchGetWeeklyReportApprovalPage,
fetchPerformanceSheetPage,
fetchRejectOvertimeApplication
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
@@ -29,6 +30,9 @@ import TaskWorklogDialog from '@/views/project/project/execution/modules/task-wo
import PersonalItemDetailDialog from '@/views/personal-center/my-item/modules/personal-item-detail-dialog.vue';
import PersonalItemOperateDialog from '@/views/personal-center/my-item/modules/personal-item-operate-dialog.vue';
import PersonalItemStatusActionDialog from '@/views/personal-center/my-item/modules/personal-item-status-action-dialog.vue';
import PerformanceActionDialog from '@/views/personal-center/my-performance/modules/performance-action-dialog.vue';
import PerformanceExcelEditorDrawer from '@/views/personal-center/my-performance/modules/performance-excel-editor-drawer.vue';
import { PerformancePermission } from '@/views/personal-center/my-performance/modules/performance-shared';
import OvertimeApplicationBatchDetailDialog from '@/views/personal-center/overtime-application/modules/overtime-application-batch-detail-dialog.vue';
import OvertimeApplicationDetailDialog from '@/views/personal-center/overtime-application/modules/overtime-application-detail-dialog.vue';
import WorkReportPrototypePageDialog from '@/views/personal-center/work-report/shared/components/prototype-page-dialog.vue';
@@ -61,7 +65,8 @@ import IconMdiPlayCircleOutline from '~icons/mdi/play-circle-outline';
type SortKey = 'created' | 'priority' | 'deadline';
type OvertimeApprovalActionType = 'approve' | 'reject';
type ApprovalBizType = 'overtime_application' | WorkReportType;
type PerformanceApprovalActionType = 'confirm' | 'reject';
type ApprovalBizType = 'overtime_application' | 'performance' | WorkReportType;
defineOptions({ name: 'WorkbenchTodoPanel' });
@@ -80,6 +85,11 @@ const { routerPushByKey } = useRouterPush();
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const buttonPermissions = computed(() => new Set(authStore.userInfo.buttons || []));
function hasButtonPermission(permission: string) {
return buttonPermissions.value.has(permission);
}
// 工时填报在工作台内弹层完成不切路由需广播给「我的工时」widget 重拉
const { notify: notifyWorklogChanged } = useWorkbenchWorklogSignal();
@@ -89,6 +99,7 @@ const { loading, refresh } = useWorkbenchRefresh(async () => {
loadMyTaskItems(),
loadPersonalTodoItems(),
loadOvertimeApprovalItems(),
loadPerformanceApprovalItems(),
loadWorkReportApprovalItems()
]);
});
@@ -130,9 +141,30 @@ const approvalBizTabs: Array<{ key: ApprovalBizType; label: string }> = [
{ key: 'weekly', label: '周报' },
{ key: 'monthly', label: '月报' },
{ key: 'project', label: '项目半月报' },
{ key: 'performance', label: '我的绩效' },
{ key: 'overtime_application', label: '加班申请' }
];
const hasWorkReportApprovePermission = computed(() => hasButtonPermission('project:work-report:approve'));
const hasOvertimeApprovePermission = computed(() => hasButtonPermission('project:overtime-application:approve'));
const hasPerformanceApprovePermission = computed(
() =>
hasButtonPermission(PerformancePermission.SheetConfirm) && hasButtonPermission(PerformancePermission.SheetReject)
);
const visibleApprovalBizTabs = computed(() =>
approvalBizTabs.filter(tab => {
if (tab.key === 'performance') {
return hasPerformanceApprovePermission.value;
}
if (tab.key === 'overtime_application') {
return hasOvertimeApprovePermission.value;
}
return hasWorkReportApprovePermission.value;
})
);
const myTaskItems = ref<WorkbenchTodoItem[]>([]);
// 保留任务原始行,供操作图标按 availableActions 渲染并取 projectId / executionId 调状态变更接口
const myTaskRows = ref<Api.Project.MyTaskItem[]>([]);
@@ -141,6 +173,8 @@ const personalTodoItems = ref<WorkbenchTodoItem[]>([]);
const personalItemRows = ref<Api.PersonalItem.PersonalItem[]>([]);
const overtimeApprovalItems = ref<WorkbenchTodoItem[]>([]);
const overtimeApprovalRows = ref<Api.OvertimeApplication.OvertimeApplication[]>([]);
const performanceApprovalItems = ref<WorkbenchTodoItem[]>([]);
const performanceApprovalRows = ref<Api.Performance.Sheet.Sheet[]>([]);
const workReportApprovalItems = ref<WorkbenchTodoItem[]>([]);
const weeklyApprovalRows = ref<Api.WorkReport.Weekly.WeeklyReport[]>([]);
const monthlyApprovalRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]);
@@ -150,6 +184,7 @@ const mergedItems = computed(() => [
...myTaskItems.value,
...personalTodoItems.value,
...overtimeApprovalItems.value,
...performanceApprovalItems.value,
...workReportApprovalItems.value
]);
@@ -179,6 +214,10 @@ const batchSubmitting = ref(false);
const workReportDetailVisible = ref(false);
const currentWorkReport = ref<WorkReportRow | null>(null);
const currentWorkReportType = ref<WorkReportType>('weekly');
const performanceDetailVisible = ref(false);
const performanceActionVisible = ref(false);
const currentPerformanceSheet = ref<Api.Performance.Sheet.Sheet | null>(null);
const currentPerformanceActionType = ref<PerformanceApprovalActionType>('confirm');
// 批量审批选中状态(存原始加班申请 id避免映射转换
const selectedOvertimeIds = ref<Set<string>>(new Set());
@@ -191,6 +230,7 @@ function getApprovalCategoryLabel(bizType: ApprovalBizType) {
if (bizType === 'weekly') return '周报';
if (bizType === 'monthly') return '月报';
if (bizType === 'project') return '项目半月报';
if (bizType === 'performance') return '我的绩效';
if (bizType === 'overtime_application') return '加班申请';
return '待审批';
}
@@ -464,6 +504,7 @@ const filteredItems = computed(() => {
const approvalBizTabCounts = computed(() => {
const counts: Record<ApprovalBizType, number> = {
overtime_application: 0,
performance: 0,
weekly: 0,
monthly: 0,
project: 0
@@ -472,6 +513,12 @@ const approvalBizTabCounts = computed(() => {
itemsInTab.value.forEach(item => {
if (item.approvalBizType === 'overtime_application') {
counts.overtime_application += 1;
return;
}
if (item.approvalBizType === 'performance') {
counts.performance += 1;
return;
}
if (isWorkReportApprovalBizType(item.approvalBizType)) {
@@ -510,6 +557,17 @@ watch([activeTab, activeDeadlineFilter, activeSort], () => {
currentPage.value = 1;
});
watch(
visibleApprovalBizTabs,
tabs => {
if (!tabs.length) return;
if (!tabs.some(tab => tab.key === activeApprovalBizType.value)) {
activeApprovalBizType.value = tabs[0].key;
}
},
{ immediate: true }
);
function handleSelectTab(key: WorkbenchTodoMainTab) {
if (activeTab.value === key) return;
activeTab.value = key;
@@ -538,6 +596,11 @@ function handleClickItem(item: WorkbenchTodoItem) {
return;
}
if (item.approvalBizType === 'performance') {
openPerformanceDetail(item);
return;
}
if (isWorkReportApprovalBizType(item.approvalBizType)) {
openWorkReportDetail(item);
return;
@@ -568,6 +631,14 @@ function findOvertimeApprovalRow(item: WorkbenchTodoItem) {
return overtimeApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
}
function findPerformanceApprovalRow(item: WorkbenchTodoItem) {
if (!item.approvalBizId) {
return null;
}
return performanceApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
}
function openOvertimeDetail(item: WorkbenchTodoItem) {
const row = findOvertimeApprovalRow(item);
if (!row) return;
@@ -576,6 +647,21 @@ function openOvertimeDetail(item: WorkbenchTodoItem) {
overtimeDetailVisible.value = true;
}
function openPerformanceDetail(item: WorkbenchTodoItem) {
const row = findPerformanceApprovalRow(item);
if (!row) return;
currentPerformanceSheet.value = row;
performanceDetailVisible.value = true;
}
function openCurrentPerformanceAction(actionType: PerformanceApprovalActionType) {
if (!currentPerformanceSheet.value) return;
currentPerformanceActionType.value = actionType;
performanceActionVisible.value = true;
}
function findWorkReportApprovalRow(item: WorkbenchTodoItem) {
if (!item.approvalBizId || !isWorkReportApprovalBizType(item.approvalBizType)) {
return null;
@@ -627,6 +713,12 @@ async function handleWorkReportSubmitted() {
await loadWorkReportApprovalItems();
}
async function handlePerformanceActionSubmitted() {
performanceActionVisible.value = false;
performanceDetailVisible.value = false;
await loadPerformanceApprovalItems();
}
// 优先级角标用字典 label 原样回显rdms_req_priorityP0~P3不翻译成高/中/低
const { getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
@@ -774,6 +866,12 @@ async function handleBatchActionSubmit(payload: { actionType: OvertimeApprovalAc
}
async function loadOvertimeApprovalItems() {
if (!hasOvertimeApprovePermission.value) {
overtimeApprovalRows.value = [];
overtimeApprovalItems.value = [];
return;
}
const { error, data } = await fetchGetOvertimeApplicationApprovalPage({
pageNo: 1,
pageSize: 20,
@@ -809,6 +907,42 @@ async function loadOvertimeApprovalItems() {
);
}
async function loadPerformanceApprovalItems() {
if (!hasPerformanceApprovePermission.value) {
performanceApprovalRows.value = [];
performanceApprovalItems.value = [];
return;
}
const { error, data } = await fetchPerformanceSheetPage({
pageNo: 1,
pageSize: 20,
statusCode: 'sent'
});
if (error || !data) {
performanceApprovalRows.value = [];
performanceApprovalItems.value = [];
return;
}
performanceApprovalRows.value = data.list;
performanceApprovalItems.value = buildWorkbenchTodoItems(
data.list.map(item => ({
id: `performance-${item.id}`,
category: 'approval',
categoryLabel: '我的绩效',
title: `${item.periodMonth} 绩效待确认`,
createdTime: item.sentTime || item.createTime || item.updateTime || '',
deadline: item.sentTime || item.createTime || item.updateTime || null,
source: `我的绩效 · ${item.managerName || '--'}`,
priority: 'mid',
approvalBizType: 'performance',
approvalBizId: item.id
}))
);
}
function buildWorkReportApprovalItems<T extends WorkReportRow>(
bizType: WorkReportType,
rows: T[]
@@ -832,6 +966,14 @@ function buildWorkReportApprovalItems<T extends WorkReportRow>(
}
async function loadWorkReportApprovalItems() {
if (!hasWorkReportApprovePermission.value) {
weeklyApprovalRows.value = [];
monthlyApprovalRows.value = [];
projectApprovalRows.value = [];
workReportApprovalItems.value = [];
return;
}
const [weeklyResult, monthlyResult, projectResult] = await Promise.all([
fetchGetWeeklyReportApprovalPage({
pageNo: 1,
@@ -938,7 +1080,7 @@ onActivated(refresh);
</div>
<div v-else class="workbench-todo__filters-left">
<button
v-for="tab in approvalBizTabs"
v-for="tab in visibleApprovalBizTabs"
:key="tab.key"
type="button"
class="workbench-todo__filter"
@@ -1076,6 +1218,13 @@ onActivated(refresh);
</ElButton>
</ElTooltip>
</div>
<div v-else-if="item.approvalBizType === 'performance'" class="workbench-todo__actions" @click.stop>
<ElTooltip content="查看绩效">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openPerformanceDetail(item)">
<IconMdiEyeOutline class="text-15px" />
</ElButton>
</ElTooltip>
</div>
<div
v-else-if="isWorkReportApprovalBizType(item.approvalBizType)"
class="workbench-todo__actions"
@@ -1212,6 +1361,22 @@ onActivated(refresh);
:row-data="currentWorkReport"
@submitted="handleWorkReportSubmitted"
/>
<PerformanceExcelEditorDrawer
v-model:visible="performanceDetailVisible"
:row-data="currentPerformanceSheet"
mode="view"
show-approval-footer
@start-approval="openCurrentPerformanceAction('confirm')"
/>
<PerformanceActionDialog
v-model:visible="performanceActionVisible"
:row-data="currentPerformanceSheet"
:action-type="currentPerformanceActionType"
selectable-action-type
@submitted="handlePerformanceActionSubmitted"
/>
</WorkbenchModuleCard>
</template>