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

View File

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

View File

@@ -7,13 +7,18 @@ import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'PerformanceActionDialog' }); defineOptions({ name: 'PerformanceActionDialog' });
type ActionType = 'confirm' | 'reject';
interface Props { interface Props {
rowData?: Api.Performance.Sheet.Sheet | null; rowData?: Api.Performance.Sheet.Sheet | null;
actionType: 'confirm' | 'reject'; actionType?: ActionType;
selectableActionType?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
rowData: null rowData: null,
actionType: 'confirm',
selectableActionType: false
}); });
const visible = defineModel<boolean>('visible', { default: false }); const visible = defineModel<boolean>('visible', { default: false });
@@ -26,13 +31,25 @@ const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules(); const { createRequiredRule } = useFormRules();
const submitting = ref(false); const submitting = ref(false);
const form = reactive({ const form = reactive<{
actionType: ActionType;
reason: string;
}>({
actionType: 'confirm',
reason: '' reason: ''
}); });
const isReject = computed(() => props.actionType === 'reject'); const isReject = computed(() => form.actionType === 'reject');
const title = computed(() => (isReject.value ? '退回绩效表' : '确认绩效表')); const title = computed(() => {
const confirmText = computed(() => (isReject.value ? '确认退回' : '确认')); 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>(() => ({ const rules = computed<FormRules>(() => ({
reason: isReject.value ? [createRequiredRule('请输入退回原因')] : [] reason: isReject.value ? [createRequiredRule('请输入退回原因')] : []
})); }));
@@ -63,6 +80,7 @@ async function handleSubmit() {
watch(visible, isVisible => { watch(visible, isVisible => {
if (!isVisible) return; if (!isVisible) return;
form.actionType = props.actionType;
form.reason = ''; form.reason = '';
}); });
</script> </script>
@@ -84,16 +102,119 @@ watch(visible, isVisible => {
<ElDescriptionsItem label="实际得分">{{ props.rowData?.actualScoreTotal ?? '--' }}</ElDescriptionsItem> <ElDescriptionsItem label="实际得分">{{ props.rowData?.actualScoreTotal ?? '--' }}</ElDescriptionsItem>
</ElDescriptions> </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 <ElInput
v-model="form.reason" v-model="form.reason"
type="textarea" type="textarea"
:rows="4" :rows="4"
maxlength="1000" maxlength="1000"
show-word-limit show-word-limit
:placeholder="isReject ? '请输入退回原因' : '可填写确认意见'" :placeholder="opinionPlaceholder"
/> />
</ElFormItem> </ElFormItem>
</ElForm> </ElForm>
</BusinessFormDialog> </BusinessFormDialog>
</template> </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; rowData?: Api.Performance.Sheet.Sheet | null;
mode: 'view' | 'edit' | 'create'; mode: 'view' | 'edit' | 'create';
subordinateOptions?: SubordinateOption[]; subordinateOptions?: SubordinateOption[];
showApprovalFooter?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
rowData: null, rowData: null,
subordinateOptions: () => [] subordinateOptions: () => [],
showApprovalFooter: false
}); });
const visible = defineModel<boolean>('visible', { default: false }); const visible = defineModel<boolean>('visible', { default: false });
@@ -38,6 +40,7 @@ const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{ const emit = defineEmits<{
saved: []; saved: [];
savedAndSent: []; savedAndSent: [];
startApproval: [];
}>(); }>();
const { formRef, validate } = useForm(); const { formRef, validate } = useForm();
@@ -83,6 +86,7 @@ const drawerTitle = computed(() => {
return `${action}绩效 Excel${name ? ` - ${name}` : ''}`; return `${action}绩效 Excel${name ? ` - ${name}` : ''}`;
}); });
const canSave = computed(() => props.mode !== 'view'); const canSave = computed(() => props.mode !== 'view');
const showApprovalFooter = computed(() => props.mode === 'view' && props.showApprovalFooter);
const drawerSize = computed(() => (viewportWidth.value >= 2560 ? '60%' : '88%')); const drawerSize = computed(() => (viewportWidth.value >= 2560 ? '60%' : '88%'));
function syncViewportWidth() { function syncViewportWidth() {
@@ -93,6 +97,10 @@ function handleClose() {
visible.value = false; visible.value = false;
} }
function handleStartApproval() {
emit('startApproval');
}
function disposeUniver() { function disposeUniver() {
try { try {
univerInstance?.dispose?.(); univerInstance?.dispose?.();
@@ -687,10 +695,16 @@ onMounted(() => {
<div ref="containerRef" class="performance-excel-editor__container" /> <div ref="containerRef" class="performance-excel-editor__container" />
</div> </div>
<template v-if="canSave" #footer> <template v-if="canSave || showApprovalFooter" #footer>
<div class="performance-excel-editor__footer"> <div class="performance-excel-editor__footer">
<template v-if="canSave">
<ElButton :loading="saving" :disabled="sending" @click="handleSaveDraft">保存草稿</ElButton> <ElButton :loading="saving" :disabled="sending" @click="handleSaveDraft">保存草稿</ElButton>
<ElButton type="primary" :loading="sending" :disabled="saving" @click="handleSaveAndSend">发送绩效</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> </div>
</template> </template>
</ElDrawer> </ElDrawer>

View File

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

View File

@@ -255,7 +255,7 @@ watch(visible, isVisible => {
</ElTooltip> </ElTooltip>
</div> </div>
<ElTooltip placement="top" effect="light"> <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 文件说明"> <button type="button" class="performance-template-dialog__hint-button" aria-label="Excel 文件说明">
<icon-mdi-information-outline /> <icon-mdi-information-outline />
</button> </button>

View File

@@ -97,6 +97,7 @@ const fields = computed<SearchField[]>(() => {
type: 'select' as const, type: 'select' as const,
options: props.subordinateOptions, options: props.subordinateOptions,
placeholder: '请选择申请人', placeholder: '请选择申请人',
filterable: true,
transformValue: (value: string | number | null | undefined) => (value ? [value] : undefined), transformValue: (value: string | number | null | undefined) => (value ? [value] : undefined),
resolveValue: (value: unknown) => (Array.isArray(value) ? value[0] : value) 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); 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( function parseStructuredSectionTaskText(
text: string, text: string,
fallback?: Partial<StructuredTask>, fallback?: Partial<StructuredTask>,
@@ -350,13 +396,7 @@ function parseStructuredSectionTaskText(
const normalizedText = stripStructuredTaskPrefixV2(text); const normalizedText = stripStructuredTaskPrefixV2(text);
if (!normalizedText) return null; if (!normalizedText) return null;
const structuredMatch = const { rawTitle, metricsText, detail } = extractStructuredTaskParts(normalizedText);
normalizedText.match(/^(.+?)(?:[(]([^()]*)[)])?(?:\s*[:]\s*(.*))?$/u) ||
normalizedText.match(/^(.+?)(?:\(([^()]*)\))?(?::\s*(.*))?$/u);
if (!structuredMatch) return null;
const [, rawTitle, metricsText = '', detail = ''] = structuredMatch;
const title = stripStructuredTaskSuffixV2(rawTitle); const title = stripStructuredTaskSuffixV2(rawTitle);
if (!title) return null; if (!title) return null;
@@ -412,7 +452,7 @@ function parseStructuredSectionsFromEditorV2(
return { return {
...task, ...task,
detail: fallbackTask?.detail || task.detail || '', 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 class="work-report-create-dialog__body">
<div v-if="selectedReportType === 'project'" class="work-report-create-dialog__project-select"> <div v-if="selectedReportType === 'project'" class="work-report-create-dialog__project-select">
<label class="work-report-create-dialog__label">项目</label> <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 <ElOption
v-for="item in props.projectOptions" v-for="item in props.projectOptions"
:key="item.id" :key="item.id"
@@ -367,6 +372,16 @@ function handleConfirm() {
gap: 6px; 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 { .work-report-create-dialog__field {
display: grid; display: grid;
gap: 6px; gap: 6px;

View File

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

View File

@@ -358,8 +358,10 @@ function resolveTaskItemTypeLabel(value?: string | null) {
return getTaskItemTypeLabel(value, { fallback: value || '工作内容' }); return getTaskItemTypeLabel(value, { fallback: value || '工作内容' });
} }
const STRUCTURED_TASK_PREFIX_RE = /^(?:(?:\d+[..、])|(?:\d+\s+)|(?:[一二三四五六七八九十百千万]+[、.]))\s*/u;
function stripStructuredTaskPrefixV2(value: string) { function stripStructuredTaskPrefixV2(value: string) {
return value.trim().replace(/^\d+[..、]\s*/u, ''); return value.trim().replace(STRUCTURED_TASK_PREFIX_RE, '');
} }
function stripStructuredTaskSuffixV2(value: string) { function stripStructuredTaskSuffixV2(value: string) {
@@ -381,16 +383,13 @@ function formatStructuredTaskDisplayLine(task: StructuredTask, index: number, sh
return `${index + 1}${formatStructuredTaskLineV2(task, showHours)}`; return `${index + 1}${formatStructuredTaskLineV2(task, showHours)}`;
} }
// 周报工作日志弹层展示:后端用中文分号 "" 拼接多条工作日志, function getWorkLogEntries(detail: string): string[] {
// 在 popover 中按行展示,每条工作日志仍保留末尾的分号。 if (!detail) return [];
function formatWorkLogDetail(detail: string): string { // 仅按中文分号切分,避免误伤文本中的其他标点;每段作为单独一天的工作日志展示。
if (!detail) return '';
// 仅按中文分号切分,避免误伤文本中的其他标点;每段保持原样展示。
return detail return detail
.split('') .split('')
.map(item => item.trim()) .map(item => item.trim())
.filter(Boolean) .filter(Boolean);
.join('\n');
} }
function createStructuredTextV2(sections: StructuredSection[], showHours = false) { function createStructuredTextV2(sections: StructuredSection[], showHours = false) {
@@ -493,7 +492,7 @@ function createStructuredTasksFromText(text: string, defaultTask?: Partial<Struc
} }
function stripStructuredTaskPrefix(value: string) { function stripStructuredTaskPrefix(value: string) {
return value.trim().replace(/^\d+[..、]\s*/u, ''); return value.trim().replace(STRUCTURED_TASK_PREFIX_RE, '');
} }
function stripStructuredTaskSuffix(value: string) { 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( function parseStructuredSectionTaskText(
text: string, text: string,
fallback?: Partial<StructuredTask>, fallback?: Partial<StructuredTask>,
@@ -532,13 +577,7 @@ function parseStructuredSectionTaskText(
const normalizedText = stripStructuredTaskPrefix(text); const normalizedText = stripStructuredTaskPrefix(text);
if (!normalizedText) return null; if (!normalizedText) return null;
const structuredMatch = const { rawTitle, metricsText, detail } = extractStructuredTaskParts(normalizedText);
normalizedText.match(/^(.+?)(?:[(]([^()]*)[)])?(?:\s*[:]\s*(.*))?$/u) ||
normalizedText.match(/^(.+?)(?:\(([^()]*)\))?(?::\s*(.*))?$/u);
if (!structuredMatch) return null;
const [, rawTitle, metricsText = '', detail = ''] = structuredMatch;
const title = stripStructuredTaskSuffix(rawTitle); const title = stripStructuredTaskSuffix(rawTitle);
if (!title) return null; if (!title) return null;
@@ -569,6 +608,16 @@ function createStructuredSectionsFromTextV2(
}; };
let currentCategory = ''; 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 => { lines.forEach(line => {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
@@ -577,12 +626,15 @@ function createStructuredSectionsFromTextV2(
if (trimmedLine.startsWith('#')) { if (trimmedLine.startsWith('#')) {
currentCategory = trimmedLine.replace(/^#\s*/u, '').trim(); currentCategory = trimmedLine.replace(/^#\s*/u, '').trim();
if (currentCategory) ensureSection(currentCategory); if (currentCategory) ensureSection(currentCategory);
previousTask = null;
return; return;
} }
// 仅当行首不是结构化任务前缀(如 "3、")时,才按旧式 "<分类> - <事项>" 解析; // 仅当行首不是结构化任务前缀(如 "3、")时,才按旧式 "<分类> - <事项>" 解析;
// 否则会把 "2026-06-12 - 2026-06-19" 这种含 " - " 的出差行误判为分类。 // 否则会把 "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) { if (legacyMatch) {
const [, rawCategory, rawTaskText] = legacyMatch; const [, rawCategory, rawTaskText] = legacyMatch;
const category = rawCategory.trim(); const category = rawCategory.trim();
@@ -590,6 +642,14 @@ function createStructuredSectionsFromTextV2(
if (!category || !task) return; if (!category || !task) return;
ensureSection(category).tasks.push(task); ensureSection(category).tasks.push(task);
currentCategory = category; currentCategory = category;
previousTask = task;
return;
}
if (shouldAppendToPreviousTaskDetail(trimmedLine)) {
const lastTask = previousTask;
if (!lastTask) return;
lastTask.detail = lastTask.detail ? `${lastTask.detail}\n${trimmedLine}` : trimmedLine;
return; return;
} }
@@ -597,7 +657,7 @@ function createStructuredSectionsFromTextV2(
if (!task) return; if (!task) return;
const hasStructuredHint = const hasStructuredHint =
/^(\d+[..、]\s*)/u.test(trimmedLine) || STRUCTURED_TASK_PREFIX_RE.test(trimmedLine) ||
trimmedLine.includes('') || trimmedLine.includes('') ||
trimmedLine.includes('(') || trimmedLine.includes('(') ||
trimmedLine.includes('') || trimmedLine.includes('') ||
@@ -606,6 +666,7 @@ function createStructuredSectionsFromTextV2(
if (!hasStructuredHint && !currentCategory) return; if (!hasStructuredHint && !currentCategory) return;
ensureSection(currentCategory || defaultCategory).tasks.push(task); ensureSection(currentCategory || defaultCategory).tasks.push(task);
previousTask = task;
}); });
return mergeSectionsByCategory(sections); return mergeSectionsByCategory(sections);
@@ -657,7 +718,7 @@ function parseStructuredSectionsFromEditorV2(
...task, ...task,
detail: fallbackTask?.detail || '', detail: fallbackTask?.detail || '',
kind: fallbackTask?.kind, 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 = ''; 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 工作日志) */ /** 编辑态下是否显示"具体工作内容"的结构化预览(含 ElPopover 工作日志) */
function showContentStructuredView(index: number) { function showContentStructuredView(index: number) {
const item = reviewItems.value[index]; const item = reviewItems.value[index];
@@ -1096,6 +1162,11 @@ function handleStructuredViewClick(fieldKey: string) {
function syncRichContent(item: ReviewItem, event: Event) { function syncRichContent(item: ReviewItem, event: Event) {
const target = event.currentTarget as HTMLElement; const target = event.currentTarget as HTMLElement;
if (!item.source) return; 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( const sections = parseStructuredSectionsFromEditorV2(
target, target,
item.contentSections || [], item.contentSections || [],
@@ -1108,6 +1179,11 @@ function syncRichContent(item: ReviewItem, event: Event) {
function syncRichTarget(item: PlanItem, event: Event) { function syncRichTarget(item: PlanItem, event: Event) {
const target = event.currentTarget as HTMLElement; const target = event.currentTarget as HTMLElement;
if (!item.source) return; 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( const sections = parseStructuredSectionsFromEditorV2(
target, target,
item.targetSections || [], item.targetSections || [],
@@ -1232,7 +1308,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
</div> </div>
</template> </template>
<div class="structured-preview__popover"> <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> </div>
</ElPopover> </ElPopover>
<div v-else class="rich-task-line"> <div v-else class="rich-task-line">
@@ -1337,7 +1426,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
</div> </div>
</template> </template>
<div class="structured-preview__popover"> <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> </div>
</ElPopover> </ElPopover>
<div v-else class="rich-task-line"> <div v-else class="rich-task-line">
@@ -2161,11 +2263,30 @@ function syncRichSupport(item: PlanItem, event: Event) {
color: #334155; color: #334155;
font-size: 13px; font-size: 13px;
line-height: 1.6; 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; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
} }
.structured-preview__log-divider {
width: 100%;
height: 1px;
background: #e2e8f0;
}
.rich-editor :deep(.rich-task) { .rich-editor :deep(.rich-task) {
display: grid; display: grid;
gap: 4px; gap: 4px;

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import {
fetchGetProjectReportApprovalPage, fetchGetProjectReportApprovalPage,
fetchGetProjectTask, fetchGetProjectTask,
fetchGetWeeklyReportApprovalPage, fetchGetWeeklyReportApprovalPage,
fetchPerformanceSheetPage,
fetchRejectOvertimeApplication fetchRejectOvertimeApplication
} from '@/service/api'; } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth'; 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 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 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 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 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 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'; 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 SortKey = 'created' | 'priority' | 'deadline';
type OvertimeApprovalActionType = 'approve' | 'reject'; type OvertimeApprovalActionType = 'approve' | 'reject';
type ApprovalBizType = 'overtime_application' | WorkReportType; type PerformanceApprovalActionType = 'confirm' | 'reject';
type ApprovalBizType = 'overtime_application' | 'performance' | WorkReportType;
defineOptions({ name: 'WorkbenchTodoPanel' }); defineOptions({ name: 'WorkbenchTodoPanel' });
@@ -80,6 +85,11 @@ const { routerPushByKey } = useRouterPush();
const authStore = useAuthStore(); const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || ''); const currentUserId = computed(() => authStore.userInfo.userId || '');
const buttonPermissions = computed(() => new Set(authStore.userInfo.buttons || []));
function hasButtonPermission(permission: string) {
return buttonPermissions.value.has(permission);
}
// 工时填报在工作台内弹层完成不切路由需广播给「我的工时」widget 重拉 // 工时填报在工作台内弹层完成不切路由需广播给「我的工时」widget 重拉
const { notify: notifyWorklogChanged } = useWorkbenchWorklogSignal(); const { notify: notifyWorklogChanged } = useWorkbenchWorklogSignal();
@@ -89,6 +99,7 @@ const { loading, refresh } = useWorkbenchRefresh(async () => {
loadMyTaskItems(), loadMyTaskItems(),
loadPersonalTodoItems(), loadPersonalTodoItems(),
loadOvertimeApprovalItems(), loadOvertimeApprovalItems(),
loadPerformanceApprovalItems(),
loadWorkReportApprovalItems() loadWorkReportApprovalItems()
]); ]);
}); });
@@ -130,9 +141,30 @@ const approvalBizTabs: Array<{ key: ApprovalBizType; label: string }> = [
{ key: 'weekly', label: '周报' }, { key: 'weekly', label: '周报' },
{ key: 'monthly', label: '月报' }, { key: 'monthly', label: '月报' },
{ key: 'project', label: '项目半月报' }, { key: 'project', label: '项目半月报' },
{ key: 'performance', label: '我的绩效' },
{ key: 'overtime_application', 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[]>([]); const myTaskItems = ref<WorkbenchTodoItem[]>([]);
// 保留任务原始行,供操作图标按 availableActions 渲染并取 projectId / executionId 调状态变更接口 // 保留任务原始行,供操作图标按 availableActions 渲染并取 projectId / executionId 调状态变更接口
const myTaskRows = ref<Api.Project.MyTaskItem[]>([]); const myTaskRows = ref<Api.Project.MyTaskItem[]>([]);
@@ -141,6 +173,8 @@ const personalTodoItems = ref<WorkbenchTodoItem[]>([]);
const personalItemRows = ref<Api.PersonalItem.PersonalItem[]>([]); const personalItemRows = ref<Api.PersonalItem.PersonalItem[]>([]);
const overtimeApprovalItems = ref<WorkbenchTodoItem[]>([]); const overtimeApprovalItems = ref<WorkbenchTodoItem[]>([]);
const overtimeApprovalRows = ref<Api.OvertimeApplication.OvertimeApplication[]>([]); const overtimeApprovalRows = ref<Api.OvertimeApplication.OvertimeApplication[]>([]);
const performanceApprovalItems = ref<WorkbenchTodoItem[]>([]);
const performanceApprovalRows = ref<Api.Performance.Sheet.Sheet[]>([]);
const workReportApprovalItems = ref<WorkbenchTodoItem[]>([]); const workReportApprovalItems = ref<WorkbenchTodoItem[]>([]);
const weeklyApprovalRows = ref<Api.WorkReport.Weekly.WeeklyReport[]>([]); const weeklyApprovalRows = ref<Api.WorkReport.Weekly.WeeklyReport[]>([]);
const monthlyApprovalRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]); const monthlyApprovalRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]);
@@ -150,6 +184,7 @@ const mergedItems = computed(() => [
...myTaskItems.value, ...myTaskItems.value,
...personalTodoItems.value, ...personalTodoItems.value,
...overtimeApprovalItems.value, ...overtimeApprovalItems.value,
...performanceApprovalItems.value,
...workReportApprovalItems.value ...workReportApprovalItems.value
]); ]);
@@ -179,6 +214,10 @@ const batchSubmitting = ref(false);
const workReportDetailVisible = ref(false); const workReportDetailVisible = ref(false);
const currentWorkReport = ref<WorkReportRow | null>(null); const currentWorkReport = ref<WorkReportRow | null>(null);
const currentWorkReportType = ref<WorkReportType>('weekly'); 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避免映射转换 // 批量审批选中状态(存原始加班申请 id避免映射转换
const selectedOvertimeIds = ref<Set<string>>(new Set()); const selectedOvertimeIds = ref<Set<string>>(new Set());
@@ -191,6 +230,7 @@ function getApprovalCategoryLabel(bizType: ApprovalBizType) {
if (bizType === 'weekly') return '周报'; if (bizType === 'weekly') return '周报';
if (bizType === 'monthly') return '月报'; if (bizType === 'monthly') return '月报';
if (bizType === 'project') return '项目半月报'; if (bizType === 'project') return '项目半月报';
if (bizType === 'performance') return '我的绩效';
if (bizType === 'overtime_application') return '加班申请'; if (bizType === 'overtime_application') return '加班申请';
return '待审批'; return '待审批';
} }
@@ -464,6 +504,7 @@ const filteredItems = computed(() => {
const approvalBizTabCounts = computed(() => { const approvalBizTabCounts = computed(() => {
const counts: Record<ApprovalBizType, number> = { const counts: Record<ApprovalBizType, number> = {
overtime_application: 0, overtime_application: 0,
performance: 0,
weekly: 0, weekly: 0,
monthly: 0, monthly: 0,
project: 0 project: 0
@@ -472,6 +513,12 @@ const approvalBizTabCounts = computed(() => {
itemsInTab.value.forEach(item => { itemsInTab.value.forEach(item => {
if (item.approvalBizType === 'overtime_application') { if (item.approvalBizType === 'overtime_application') {
counts.overtime_application += 1; counts.overtime_application += 1;
return;
}
if (item.approvalBizType === 'performance') {
counts.performance += 1;
return;
} }
if (isWorkReportApprovalBizType(item.approvalBizType)) { if (isWorkReportApprovalBizType(item.approvalBizType)) {
@@ -510,6 +557,17 @@ watch([activeTab, activeDeadlineFilter, activeSort], () => {
currentPage.value = 1; 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) { function handleSelectTab(key: WorkbenchTodoMainTab) {
if (activeTab.value === key) return; if (activeTab.value === key) return;
activeTab.value = key; activeTab.value = key;
@@ -538,6 +596,11 @@ function handleClickItem(item: WorkbenchTodoItem) {
return; return;
} }
if (item.approvalBizType === 'performance') {
openPerformanceDetail(item);
return;
}
if (isWorkReportApprovalBizType(item.approvalBizType)) { if (isWorkReportApprovalBizType(item.approvalBizType)) {
openWorkReportDetail(item); openWorkReportDetail(item);
return; return;
@@ -568,6 +631,14 @@ function findOvertimeApprovalRow(item: WorkbenchTodoItem) {
return overtimeApprovalRows.value.find(row => row.id === item.approvalBizId) || null; 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) { function openOvertimeDetail(item: WorkbenchTodoItem) {
const row = findOvertimeApprovalRow(item); const row = findOvertimeApprovalRow(item);
if (!row) return; if (!row) return;
@@ -576,6 +647,21 @@ function openOvertimeDetail(item: WorkbenchTodoItem) {
overtimeDetailVisible.value = true; 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) { function findWorkReportApprovalRow(item: WorkbenchTodoItem) {
if (!item.approvalBizId || !isWorkReportApprovalBizType(item.approvalBizType)) { if (!item.approvalBizId || !isWorkReportApprovalBizType(item.approvalBizType)) {
return null; return null;
@@ -627,6 +713,12 @@ async function handleWorkReportSubmitted() {
await loadWorkReportApprovalItems(); await loadWorkReportApprovalItems();
} }
async function handlePerformanceActionSubmitted() {
performanceActionVisible.value = false;
performanceDetailVisible.value = false;
await loadPerformanceApprovalItems();
}
// 优先级角标用字典 label 原样回显rdms_req_priorityP0~P3不翻译成高/中/低 // 优先级角标用字典 label 原样回显rdms_req_priorityP0~P3不翻译成高/中/低
const { getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE); const { getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
@@ -774,6 +866,12 @@ async function handleBatchActionSubmit(payload: { actionType: OvertimeApprovalAc
} }
async function loadOvertimeApprovalItems() { async function loadOvertimeApprovalItems() {
if (!hasOvertimeApprovePermission.value) {
overtimeApprovalRows.value = [];
overtimeApprovalItems.value = [];
return;
}
const { error, data } = await fetchGetOvertimeApplicationApprovalPage({ const { error, data } = await fetchGetOvertimeApplicationApprovalPage({
pageNo: 1, pageNo: 1,
pageSize: 20, 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>( function buildWorkReportApprovalItems<T extends WorkReportRow>(
bizType: WorkReportType, bizType: WorkReportType,
rows: T[] rows: T[]
@@ -832,6 +966,14 @@ function buildWorkReportApprovalItems<T extends WorkReportRow>(
} }
async function loadWorkReportApprovalItems() { async function loadWorkReportApprovalItems() {
if (!hasWorkReportApprovePermission.value) {
weeklyApprovalRows.value = [];
monthlyApprovalRows.value = [];
projectApprovalRows.value = [];
workReportApprovalItems.value = [];
return;
}
const [weeklyResult, monthlyResult, projectResult] = await Promise.all([ const [weeklyResult, monthlyResult, projectResult] = await Promise.all([
fetchGetWeeklyReportApprovalPage({ fetchGetWeeklyReportApprovalPage({
pageNo: 1, pageNo: 1,
@@ -938,7 +1080,7 @@ onActivated(refresh);
</div> </div>
<div v-else class="workbench-todo__filters-left"> <div v-else class="workbench-todo__filters-left">
<button <button
v-for="tab in approvalBizTabs" v-for="tab in visibleApprovalBizTabs"
:key="tab.key" :key="tab.key"
type="button" type="button"
class="workbench-todo__filter" class="workbench-todo__filter"
@@ -1076,6 +1218,13 @@ onActivated(refresh);
</ElButton> </ElButton>
</ElTooltip> </ElTooltip>
</div> </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 <div
v-else-if="isWorkReportApprovalBizType(item.approvalBizType)" v-else-if="isWorkReportApprovalBizType(item.approvalBizType)"
class="workbench-todo__actions" class="workbench-todo__actions"
@@ -1212,6 +1361,22 @@ onActivated(refresh);
:row-data="currentWorkReport" :row-data="currentWorkReport"
@submitted="handleWorkReportSubmitted" @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> </WorkbenchModuleCard>
</template> </template>