fix(工作报告): 修复工作报告存在的若干问题。
feat(加班申请): 支持批量审批。
This commit is contained in:
2
.trae/rules/vue-need.md
Normal file
2
.trae/rules/vue-need.md
Normal file
@@ -0,0 +1,2 @@
|
||||
1. 每次开发新功能、编写代码时都添加好相应的注释。
|
||||
2. 所有的vue文件编码必须是UTF-8的。
|
||||
@@ -232,6 +232,28 @@ export function fetchRejectOvertimeApplication(id: string, data: Api.OvertimeApp
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchBatchApproveOvertimeApplication(
|
||||
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
|
||||
) {
|
||||
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/batch-approve`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchBatchRejectOvertimeApplication(
|
||||
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
|
||||
) {
|
||||
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/batch-reject`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchDeleteOvertimeApplication(id: string) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
|
||||
16
src/typings/api/overtime-application.d.ts
vendored
16
src/typings/api/overtime-application.d.ts
vendored
@@ -59,6 +59,22 @@ declare namespace Api {
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface OvertimeApplicationBatchActionParams {
|
||||
ids: string[];
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface OvertimeApplicationBatchFailItem {
|
||||
id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface OvertimeApplicationBatchActionResult {
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
failItems: OvertimeApplicationBatchFailItem[];
|
||||
}
|
||||
|
||||
interface OvertimeApplicationApprovalRecord {
|
||||
id: string;
|
||||
overtimeApplicationId: string;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
|
||||
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
|
||||
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||
import IconMdiChevronLeft from '~icons/mdi/chevron-left';
|
||||
import IconMdiChevronRight from '~icons/mdi/chevron-right';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationBatchDetailDialog' });
|
||||
|
||||
interface Props {
|
||||
/** 选中的加班申请 id 列表(原始 id) */
|
||||
selectedIds: string[];
|
||||
/** 全部加班申请行数据,用于通过 id 查找 */
|
||||
rows: Api.OvertimeApplication.OvertimeApplication[];
|
||||
actionLoading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
actionLoading: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: [];
|
||||
reject: [];
|
||||
}>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const currentIndex = ref(0);
|
||||
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
|
||||
const currentId = computed(() => props.selectedIds[currentIndex.value] ?? null);
|
||||
|
||||
const total = computed(() => props.selectedIds.length);
|
||||
|
||||
const canGoPrev = computed(() => currentIndex.value > 0);
|
||||
const canGoNext = computed(() => currentIndex.value < props.selectedIds.length - 1);
|
||||
|
||||
async function loadDetail() {
|
||||
const id = currentId.value;
|
||||
if (!id) {
|
||||
detailData.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const row = props.rows.find(r => r.id === id);
|
||||
if (!row) {
|
||||
detailData.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
detailLoading.value = true;
|
||||
const { error, data } = await fetchGetOvertimeApplicationDetail(id);
|
||||
detailLoading.value = false;
|
||||
|
||||
detailData.value = error || !data ? row : data;
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
if (!canGoPrev.value) return;
|
||||
currentIndex.value -= 1;
|
||||
loadDetail();
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (!canGoNext.value) return;
|
||||
currentIndex.value += 1;
|
||||
loadDetail();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
currentIndex.value = 0;
|
||||
loadDetail();
|
||||
} else {
|
||||
detailData.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog v-model="visible" title="批量审批" preset="md" :loading="detailLoading" :show-footer="true">
|
||||
<!-- 左右导航 -->
|
||||
<div class="batch-detail__nav">
|
||||
<button type="button" class="batch-detail__nav-btn" :disabled="!canGoPrev" @click.stop="goPrev">
|
||||
<IconMdiChevronLeft class="text-20px" />
|
||||
</button>
|
||||
<span class="batch-detail__nav-counter">{{ currentIndex + 1 }} / {{ total }}</span>
|
||||
<button type="button" class="batch-detail__nav-btn" :disabled="!canGoNext" @click.stop="goNext">
|
||||
<IconMdiChevronRight class="text-20px" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ElDescriptions v-if="detailData" class="overtime-application-detail-dialog__descriptions" :column="2" border>
|
||||
<ElDescriptionsItem label="申请人" label-class-name="overtime-application-detail-dialog__label">
|
||||
{{ detailData.applicantName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班日期" label-class-name="overtime-application-detail-dialog__label--compact">
|
||||
{{ formatOvertimeDate(detailData.overtimeDate) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班时长" label-class-name="overtime-application-detail-dialog__label">
|
||||
{{ detailData.overtimeDuration }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="提交时间" label-class-name="overtime-application-detail-dialog__label--compact">
|
||||
{{ formatOvertimeDateTime(detailData.submitTime) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班原因" :span="2" label-class-name="overtime-application-detail-dialog__label">
|
||||
{{ detailData.overtimeReason }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班内容" :span="2" label-class-name="overtime-application-detail-dialog__label">
|
||||
{{ detailData.overtimeContent }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
<ElEmpty v-else description="未获取到加班申请详情" />
|
||||
|
||||
<template #footer>
|
||||
<div class="batch-detail__footer">
|
||||
<span class="batch-detail__footer-hint">将对全部 {{ total }} 项统一执行操作</span>
|
||||
<div class="batch-detail__footer-actions">
|
||||
<ElButton
|
||||
class="batch-detail__approve-btn"
|
||||
type="success"
|
||||
:loading="props.actionLoading"
|
||||
:disabled="props.actionLoading || !detailData"
|
||||
@click="emit('approve')"
|
||||
>
|
||||
<template #icon>
|
||||
<IconMdiCheckCircleOutline />
|
||||
</template>
|
||||
通过
|
||||
</ElButton>
|
||||
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
|
||||
<template #icon>
|
||||
<IconMdiCloseCircleOutline />
|
||||
</template>
|
||||
退回
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.batch-detail__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.batch-detail__nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 8px;
|
||||
background-color: rgb(255 255 255 / 98%);
|
||||
color: rgb(71 85 105 / 94%);
|
||||
cursor: pointer;
|
||||
transition: all 160ms ease;
|
||||
}
|
||||
|
||||
.batch-detail__nav-btn:hover:not(:disabled) {
|
||||
border-color: rgb(14 116 144 / 60%);
|
||||
color: rgb(14 116 144 / 96%);
|
||||
}
|
||||
|
||||
.batch-detail__nav-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.batch-detail__nav-counter {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgb(15 23 42 / 96%);
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.batch-detail__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.batch-detail__footer-hint {
|
||||
font-size: 13px;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
}
|
||||
|
||||
.batch-detail__footer-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.batch-detail__approve-btn {
|
||||
--el-button-bg-color: #0f766e;
|
||||
--el-button-border-color: #0f766e;
|
||||
--el-button-hover-bg-color: #115e59;
|
||||
--el-button-hover-border-color: #115e59;
|
||||
--el-button-active-bg-color: #134e4a;
|
||||
--el-button-active-border-color: #134e4a;
|
||||
}
|
||||
|
||||
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
:deep(.overtime-application-detail-dialog__label),
|
||||
:deep(.overtime-application-detail-dialog__label--compact) {
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:deep(.overtime-application-detail-dialog__label) {
|
||||
width: 96px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
:deep(.overtime-application-detail-dialog__label--compact) {
|
||||
width: 86px;
|
||||
min-width: 86px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_OVERTIME_DURATION_DICT_CODE } from '@/constants/dict';
|
||||
import {
|
||||
fetchCreateOvertimeApplication,
|
||||
@@ -85,8 +86,8 @@ const rules = computed(
|
||||
|
||||
function createDefaultModel(): Api.OvertimeApplication.SaveOvertimeApplicationParams {
|
||||
return {
|
||||
overtimeDate: '',
|
||||
overtimeDuration: '',
|
||||
overtimeDate: dayjs().format('YYYY-MM-DD'),
|
||||
overtimeDuration: '0.5',
|
||||
overtimeReason: '',
|
||||
overtimeContent: '',
|
||||
approverId: ''
|
||||
|
||||
@@ -70,7 +70,7 @@ const table = useUIPaginatedTable<
|
||||
{ prop: 'periodLabel', label: '月份', minWidth: 80, formatter: row => formatPeriod(row) },
|
||||
{
|
||||
prop: 'reporterDeptName',
|
||||
label: '部门/方向',
|
||||
label: '部门',
|
||||
minWidth: 80,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => row.reporterDeptName || '--'
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable no-useless-escape */
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import type { FormRules } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {
|
||||
@@ -30,6 +32,10 @@ const emit = defineEmits<{
|
||||
viewAudit: [];
|
||||
}>();
|
||||
|
||||
const { formRef: pageFormRef, validate: validatePageForm } = useForm();
|
||||
const { formRef: auditFormRef } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
interface ReviewItem {
|
||||
workItem: string;
|
||||
days: number;
|
||||
@@ -69,14 +75,55 @@ const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_
|
||||
const todayText = computed(() => dayjs().format('YYYY-MM-DD'));
|
||||
|
||||
const auditDialogVisible = ref(false);
|
||||
const auditForm = ref({
|
||||
const auditForm = reactive({
|
||||
conclusion: '通过',
|
||||
opinion: ''
|
||||
});
|
||||
|
||||
/** 必填字段校验错误提示 */
|
||||
const performanceError = ref('');
|
||||
const meetingDateError = ref('');
|
||||
const rejectOpinionRequired = computed(() => auditForm.conclusion === '退回');
|
||||
const pageValidationModel = reactive({
|
||||
get meetingDate() {
|
||||
return props.approvalModel.meetingDate || '';
|
||||
},
|
||||
get performanceResult() {
|
||||
return props.approvalModel.performanceResult || '';
|
||||
}
|
||||
});
|
||||
const pageRules: FormRules = {
|
||||
meetingDate: [createRequiredRule('请选择面谈时间')],
|
||||
performanceResult: [
|
||||
createRequiredRule('请输入绩效考核结果'),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error('请输入绩效考核结果'));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
};
|
||||
const auditRules = computed<FormRules>(() => ({
|
||||
opinion: rejectOpinionRequired.value
|
||||
? [
|
||||
createRequiredRule('请输入退回原因'),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error('请输入退回原因'));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
: []
|
||||
}));
|
||||
|
||||
function patchApproval<K extends keyof Api.WorkReport.Monthly.MonthlyReportApproveParams>(
|
||||
key: K,
|
||||
@@ -158,17 +205,6 @@ function handlePerformanceScoreInput(value: string) {
|
||||
if (filtered !== value) {
|
||||
performanceForm.score = filtered;
|
||||
}
|
||||
// 清除错误提示
|
||||
if (filtered) {
|
||||
performanceError.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 绩效分数失焦校验 */
|
||||
function handlePerformanceScoreBlur() {
|
||||
if (!performanceForm.score) {
|
||||
performanceError.value = '请输入绩效考核结果';
|
||||
}
|
||||
}
|
||||
|
||||
const mainForm = reactive({
|
||||
@@ -192,15 +228,6 @@ const mainForm = reactive({
|
||||
}
|
||||
});
|
||||
|
||||
/** 面谈时间失焦校验 */
|
||||
function handleMeetingDateBlur() {
|
||||
if (!mainForm.meetingDate) {
|
||||
meetingDateError.value = '请选择面谈时间';
|
||||
} else {
|
||||
meetingDateError.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
const signatureForm = reactive({
|
||||
get employeeSign() {
|
||||
return props.approvalModel.employeeSignName || '';
|
||||
@@ -297,39 +324,57 @@ function escapeHtml(value: string) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function createTaskMetrics(task: StructuredTask, showHours: boolean) {
|
||||
const metrics = [
|
||||
task.priority ? escapeHtml(resolvePriorityLabel(task.priority)) : '',
|
||||
typeof task.progress === 'number' ? `进度${task.progress}%` : '',
|
||||
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
|
||||
].filter(Boolean);
|
||||
return metrics.length
|
||||
? `<span class="rich-inline-metrics" contenteditable="false">${metrics.join(' · ')}</span>`
|
||||
: '';
|
||||
function stripStructuredTaskPrefixV2(value: string) {
|
||||
return value.trim().replace(/^\d+[..、]\s*/u, '');
|
||||
}
|
||||
|
||||
function createStructuredHtml(sections: StructuredSection[], showHours = false) {
|
||||
const stripOrderPrefix = (text: string) => text.trim().replace(/^\d+[..、]\s*/, '');
|
||||
const splitPunctuation = (text: string) => {
|
||||
const rawTitle = stripOrderPrefix(text);
|
||||
const punctuation = rawTitle.match(/[。?!!??]$/)?.[0] || '';
|
||||
return {
|
||||
title: punctuation ? rawTitle.slice(0, -1) : rawTitle,
|
||||
punctuation
|
||||
};
|
||||
};
|
||||
function stripStructuredTaskSuffixV2(value: string) {
|
||||
return value.trim().replace(/[。.!!??]+$/u, '');
|
||||
}
|
||||
|
||||
function resolveTaskMetricsV2(metricsText: string) {
|
||||
const parts = metricsText
|
||||
.split('/')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
const priority = normalizePriorityCode(parts.find(item => /^P?\d+$/iu.test(item)));
|
||||
const progressText =
|
||||
metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1] ||
|
||||
parts.find(item => /^\d+(?:\.\d+)?%$/u.test(item))?.replace(/%$/u, '');
|
||||
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/u)?.[1];
|
||||
|
||||
return {
|
||||
priority,
|
||||
progress: progressText === undefined ? undefined : Number(progressText),
|
||||
hours: hoursText === undefined ? undefined : Number(hoursText)
|
||||
};
|
||||
}
|
||||
|
||||
function formatStructuredTaskLineV2(task: StructuredTask, showHours = false) {
|
||||
const title = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(task.title));
|
||||
const metrics = [
|
||||
task.priority ? resolvePriorityLabel(task.priority) : '',
|
||||
typeof task.progress === 'number' ? `${task.progress}%` : '',
|
||||
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
|
||||
].filter(Boolean);
|
||||
|
||||
return `${title}${metrics.length ? `(${metrics.join(' / ')})` : ''}`;
|
||||
}
|
||||
|
||||
function createStructuredHtmlV2(sections: StructuredSection[], showHours = false) {
|
||||
return sections
|
||||
.map(section => {
|
||||
const categoryLabel = resolveTaskItemTypeLabel(section.category.trim());
|
||||
const tasks = section.tasks
|
||||
.map((task, taskIndex) => {
|
||||
const { title, punctuation } = splitPunctuation(task.title);
|
||||
return `<span class="rich-section-task"><span class="rich-section-task-title">${taskIndex + 1}.${escapeHtml(title)}</span>${createTaskMetrics(task, showHours)}${escapeHtml(punctuation)}</span>`;
|
||||
})
|
||||
.map(
|
||||
(task, index) =>
|
||||
`<div class="rich-task-line">${escapeHtml(`${index + 1}、${formatStructuredTaskLineV2(task, showHours)}`)}</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="rich-section">
|
||||
<div class="rich-section-title">${escapeHtml(resolveTaskItemTypeLabel(section.category.trim()))}</div>
|
||||
<div class="rich-category-line"># ${escapeHtml(categoryLabel)}</div>
|
||||
${tasks ? `<div class="rich-section-tasks">${tasks}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
@@ -337,6 +382,63 @@ function createStructuredHtml(sections: StructuredSection[], showHours = false)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function createStructuredSectionsFromTextV2(text: string, defaultCategory: string): StructuredSection[] {
|
||||
const lines = normalizeEditorText(text).split('\n').filter(Boolean);
|
||||
if (!lines.length || lines.join('\n') === EMPTY_TEXT) return [];
|
||||
|
||||
const sections: StructuredSection[] = [];
|
||||
const ensureSection = (categoryText: string) => {
|
||||
const category = categoryText.trim() || defaultCategory;
|
||||
let section = sections.find(item => item.category === category);
|
||||
if (!section) {
|
||||
section = { category, tasks: [] };
|
||||
sections.push(section);
|
||||
}
|
||||
return section;
|
||||
};
|
||||
|
||||
let currentCategory = '';
|
||||
|
||||
lines.forEach(line => {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) return;
|
||||
|
||||
if (trimmedLine.startsWith('#')) {
|
||||
currentCategory = trimmedLine.replace(/^#\s*/u, '').trim();
|
||||
if (currentCategory) ensureSection(currentCategory);
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyMatch = trimmedLine.match(/^(.+?)\s*-\s*(.+?)(?:[((]([^()()]*)[))])?[。.!!??]*$/u);
|
||||
if (legacyMatch) {
|
||||
const [, rawCategory, rawTitle, metricsText = ''] = legacyMatch;
|
||||
const category = rawCategory.trim();
|
||||
const title = rawTitle.trim();
|
||||
if (!category || !title) return;
|
||||
|
||||
ensureSection(category).tasks.push({
|
||||
title,
|
||||
...resolveTaskMetricsV2(metricsText)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLine = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(trimmedLine));
|
||||
if (!normalizedLine) return;
|
||||
const inlineMatch = normalizedLine.match(/^(.*?)(?:[((]([^()()]*)[))])?$/u);
|
||||
const title = inlineMatch?.[1]?.trim() || '';
|
||||
const metricsText = inlineMatch?.[2]?.trim() || '';
|
||||
if (!title) return;
|
||||
|
||||
ensureSection(currentCategory || defaultCategory).tasks.push({
|
||||
title,
|
||||
...resolveTaskMetricsV2(metricsText)
|
||||
});
|
||||
});
|
||||
|
||||
return sections.filter(section => section.tasks.length);
|
||||
}
|
||||
|
||||
function normalizeEditorText(value: string) {
|
||||
return value
|
||||
.replace(/\u00A0/g, ' ')
|
||||
@@ -346,106 +448,13 @@ function normalizeEditorText(value: string) {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function stripTaskOrderPrefix(value: string) {
|
||||
return value.trim().replace(/^\d+[..、]\s*/, '');
|
||||
}
|
||||
|
||||
function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTask>) {
|
||||
const metricParts = metricsText
|
||||
.split(/[\/、·]/u)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
const priorityText = metricsText.match(/\bP\d+\b/iu)?.[0] || metricParts.find(item => /^P?\d+$/iu.test(item));
|
||||
const priority = normalizePriorityCode(priorityText || fallback?.priority);
|
||||
const progressText = metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1];
|
||||
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/iu)?.[1];
|
||||
|
||||
return {
|
||||
priority,
|
||||
progress: progressText === undefined ? fallback?.progress : Number(progressText),
|
||||
hours: hoursText === undefined ? fallback?.hours : Number(hoursText)
|
||||
};
|
||||
}
|
||||
|
||||
function createStructuredSectionsFromText(
|
||||
text: string,
|
||||
category: string,
|
||||
defaultTask?: Partial<StructuredTask>
|
||||
): StructuredSection[] {
|
||||
const lines = normalizeEditorText(text).split('\n').filter(Boolean);
|
||||
if (!lines.length || lines.join('\n') === EMPTY_TEXT) return [];
|
||||
|
||||
const groupedSections: StructuredSection[] = [];
|
||||
|
||||
lines.forEach(line => {
|
||||
const structuredMatch = line.match(/^(.+?)\s*[-–—]\s*(.+?)\s*[((]([^))]*)[))]$/u);
|
||||
if (!structuredMatch) return;
|
||||
|
||||
const [, rawCategory, rawTitle, metricsText] = structuredMatch;
|
||||
const categoryText = rawCategory.trim();
|
||||
const title = rawTitle.trim();
|
||||
if (!categoryText || !title) return;
|
||||
|
||||
let section = groupedSections.find(item => item.category === categoryText);
|
||||
if (!section) {
|
||||
section = { category: categoryText, tasks: [] };
|
||||
groupedSections.push(section);
|
||||
}
|
||||
|
||||
section.tasks.push({
|
||||
...defaultTask,
|
||||
title,
|
||||
...resolveTaskMetrics(metricsText, defaultTask)
|
||||
});
|
||||
});
|
||||
|
||||
if (groupedSections.length) return groupedSections;
|
||||
|
||||
const looksLikeTaskLine = (value: string) => /^\d+[..、]\s*/u.test(value);
|
||||
const hasNumberedTask = lines.some(looksLikeTaskLine);
|
||||
const sectionCategory = hasNumberedTask && !looksLikeTaskLine(lines[0]) ? lines[0] : category;
|
||||
const taskSourceText = (sectionCategory === lines[0] ? lines.slice(1) : lines).join(' ');
|
||||
const taskParts = hasNumberedTask
|
||||
? taskSourceText
|
||||
.split(/(?=\d+[..、]\s*)/u)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
: lines;
|
||||
|
||||
const tasks = taskParts
|
||||
.map((item, index) => {
|
||||
const normalizedTitle = stripTaskOrderPrefix(item);
|
||||
const metricsText = normalizedTitle.match(/[((]([^))]*)[))]/u)?.[1] || '';
|
||||
const title = normalizedTitle.replace(/[((][^))]*[))]/u, '').trim();
|
||||
if (!title) return null;
|
||||
|
||||
return {
|
||||
...(index === 0 ? defaultTask : undefined),
|
||||
title,
|
||||
...resolveTaskMetrics(metricsText, index === 0 ? defaultTask : undefined)
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as StructuredTask[];
|
||||
|
||||
if (!tasks.length) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
category: sectionCategory,
|
||||
tasks
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
|
||||
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.contentJson).map(normalizeSection));
|
||||
const contentSections = structuredSections.length
|
||||
? structuredSections
|
||||
: createStructuredSectionsFromText(item.contentText || '', item.itemTitle || '未分类', {
|
||||
hours: typeof item.workHours === 'number' ? item.workHours : Number(item.workHours || 0) || undefined
|
||||
});
|
||||
: createStructuredSectionsFromTextV2(item.contentText || '', item.itemTitle || '未分类');
|
||||
const contentHtml = contentSections.length
|
||||
? createStructuredHtml(contentSections, true)
|
||||
? createStructuredHtmlV2(contentSections, true)
|
||||
: escapeHtml(item.contentText || '').replace(/\n/g, '<br>') || EMPTY_TEXT;
|
||||
|
||||
return {
|
||||
@@ -464,9 +473,9 @@ function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem): PlanIte
|
||||
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.targetJson).map(normalizeSection));
|
||||
const targetSections = structuredSections.length
|
||||
? structuredSections
|
||||
: createStructuredSectionsFromText(item.targetText || '', item.itemTitle || '未分类');
|
||||
: createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || '未分类');
|
||||
const targetHtml = targetSections.length
|
||||
? createStructuredHtml(targetSections)
|
||||
? createStructuredHtmlV2(targetSections)
|
||||
: escapeHtml(item.targetText || '').replace(/\n/g, '<br>') || EMPTY_TEXT;
|
||||
|
||||
return {
|
||||
@@ -505,29 +514,38 @@ function getTaskPunctuation(title: string) {
|
||||
return rawTitle.match(/[。.!!??]$/)?.[0] || '';
|
||||
}
|
||||
|
||||
function submitAudit() {
|
||||
// 校验必填字段
|
||||
if (!performanceForm.score) {
|
||||
performanceError.value = '请输入绩效考核结果';
|
||||
}
|
||||
if (!mainForm.meetingDate) {
|
||||
meetingDateError.value = '请选择面谈时间';
|
||||
}
|
||||
if (!auditForm.value.conclusion) {
|
||||
async function submitAudit() {
|
||||
if (!auditForm.conclusion) {
|
||||
window.$message?.warning('请选择审批结论');
|
||||
return;
|
||||
}
|
||||
if (!performanceForm.score || !mainForm.meetingDate) {
|
||||
window.$message?.warning('请填写必填字段');
|
||||
|
||||
// 仅"通过"时才校验面谈时间、绩效考核结果等必填项;"退回"时跳过这些校验。
|
||||
if (auditForm.conclusion !== '退回') {
|
||||
try {
|
||||
await validatePageForm();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (rejectOpinionRequired.value) {
|
||||
try {
|
||||
await auditFormRef.value?.validateField?.('opinion');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
patchApproval('employeeSignedDate', signatureForm.employeeDate);
|
||||
patchApproval('supervisorSignedDate', signatureForm.supervisorDate);
|
||||
patchApproval('reason', auditForm.value.opinion || auditForm.value.conclusion);
|
||||
patchApproval(
|
||||
'reason',
|
||||
rejectOpinionRequired.value ? auditForm.opinion.trim() : auditForm.opinion.trim() || auditForm.conclusion
|
||||
);
|
||||
patchApproval('meetingDate', mainForm.meetingDate);
|
||||
auditDialogVisible.value = false;
|
||||
if (auditForm.value.conclusion === '不通过') {
|
||||
if (auditForm.conclusion === '退回') {
|
||||
emit('requestReject');
|
||||
} else {
|
||||
emit('requestApprove');
|
||||
@@ -535,16 +553,41 @@ function submitAudit() {
|
||||
}
|
||||
|
||||
function openAuditDialog() {
|
||||
Object.assign(auditForm.value, {
|
||||
Object.assign(auditForm, {
|
||||
conclusion: '通过',
|
||||
opinion: ''
|
||||
});
|
||||
auditDialogVisible.value = true;
|
||||
nextTick(() => {
|
||||
auditFormRef.value?.clearValidate();
|
||||
});
|
||||
}
|
||||
|
||||
watch(rejectOpinionRequired, async () => {
|
||||
if (!auditDialogVisible.value) return;
|
||||
|
||||
await nextTick();
|
||||
auditFormRef.value?.clearValidate('opinion');
|
||||
});
|
||||
|
||||
watch(
|
||||
() => auditForm.opinion,
|
||||
value => {
|
||||
if (!rejectOpinionRequired.value || !value?.trim()) return;
|
||||
auditFormRef.value?.clearValidate('opinion');
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card form-page">
|
||||
<ElForm
|
||||
ref="pageFormRef"
|
||||
:model="pageValidationModel"
|
||||
:rules="pageRules"
|
||||
label-position="top"
|
||||
:validate-on-rule-change="false"
|
||||
>
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<span>基础信息</span>
|
||||
@@ -570,21 +613,14 @@ function openAuditDialog() {
|
||||
<label>直接上级</label>
|
||||
<ElInput v-model="mainForm.supervisor" disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field field-form-item">
|
||||
<label>
|
||||
<span class="required-mark">*</span>
|
||||
面谈时间
|
||||
<span class="field-required-mark">*</span>
|
||||
</label>
|
||||
<div class="field-with-error">
|
||||
<ElDatePicker
|
||||
v-model="mainForm.meetingDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
@blur="handleMeetingDateBlur"
|
||||
/>
|
||||
<span v-if="meetingDateError" class="field-error">{{ meetingDateError }}</span>
|
||||
</div>
|
||||
<ElFormItem class="field-inline-form-item" prop="meetingDate">
|
||||
<ElDatePicker v-model="mainForm.meetingDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -609,6 +645,7 @@ function openAuditDialog() {
|
||||
<span>共 {{ item.hours }}h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="review-action-cell"></div>
|
||||
|
||||
<div class="review-editor-grid">
|
||||
<div class="field">
|
||||
@@ -689,19 +726,17 @@ function openAuditDialog() {
|
||||
</div>
|
||||
<div class="performance-result">
|
||||
<div class="performance-label">
|
||||
<span class="required-mark">*</span>
|
||||
绩效考核结果
|
||||
<span class="field-required-mark">*</span>
|
||||
</div>
|
||||
<div class="performance-input-wrapper">
|
||||
<ElFormItem class="performance-inline-form-item" prop="performanceResult">
|
||||
<ElInput
|
||||
v-model="performanceForm.score"
|
||||
class="performance-input"
|
||||
placeholder="请输入考核分数"
|
||||
@input="handlePerformanceScoreInput"
|
||||
@blur="handlePerformanceScoreBlur"
|
||||
/>
|
||||
<span v-if="performanceError" class="field-error">{{ performanceError }}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -721,6 +756,7 @@ function openAuditDialog() {
|
||||
<div class="plan-name-cell">
|
||||
<strong>{{ item.workItem }}</strong>
|
||||
</div>
|
||||
<div class="plan-action-cell"></div>
|
||||
<div class="plan-editor-grid">
|
||||
<div class="field">
|
||||
<label>具体目标</label>
|
||||
@@ -761,6 +797,7 @@ function openAuditDialog() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
|
||||
<div class="form-actions approval-form-actions">
|
||||
<ElButton @click="emit('back')">退出审批</ElButton>
|
||||
@@ -799,21 +836,39 @@ function openAuditDialog() {
|
||||
<button
|
||||
type="button"
|
||||
class="conclusion-btn"
|
||||
:class="{ active: auditForm.conclusion === '不通过', reject: auditForm.conclusion === '不通过' }"
|
||||
@click="auditForm.conclusion = '不通过'"
|
||||
:class="{ active: auditForm.conclusion === '退回', reject: auditForm.conclusion === '退回' }"
|
||||
@click="auditForm.conclusion = '退回'"
|
||||
>
|
||||
<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 class="audit-field">
|
||||
<label>审批意见</label>
|
||||
<ElInput v-model="auditForm.opinion" type="textarea" :rows="3" placeholder="请输入审批意见" />
|
||||
</div>
|
||||
<ElForm
|
||||
ref="auditFormRef"
|
||||
:model="auditForm"
|
||||
:rules="auditRules"
|
||||
label-position="top"
|
||||
:validate-on-rule-change="false"
|
||||
>
|
||||
<ElFormItem
|
||||
:label="rejectOpinionRequired ? '退回原因' : '审批意见'"
|
||||
prop="opinion"
|
||||
:required="rejectOpinionRequired"
|
||||
>
|
||||
<ElInput
|
||||
v-model="auditForm.opinion"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="rejectOpinionRequired ? '请输入退回原因' : '请输入审批意见'"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
</BusinessFormDialog>
|
||||
</div>
|
||||
@@ -986,14 +1041,14 @@ function openAuditDialog() {
|
||||
}
|
||||
|
||||
.review-card {
|
||||
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr) 44px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
grid-template-columns: 88px minmax(225px, 330px) minmax(0, 1.35fr) minmax(0, 1fr) 44px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@@ -1002,9 +1057,11 @@ function openAuditDialog() {
|
||||
.review-index-cell,
|
||||
.review-name-cell,
|
||||
.review-editor-grid,
|
||||
.review-action-cell,
|
||||
.plan-index-cell,
|
||||
.plan-name-cell,
|
||||
.plan-editor-grid {
|
||||
.plan-editor-grid,
|
||||
.plan-action-cell {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
@@ -1018,6 +1075,8 @@ function openAuditDialog() {
|
||||
.plan-name-cell {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid #e5edf1;
|
||||
}
|
||||
|
||||
@@ -1028,12 +1087,14 @@ function openAuditDialog() {
|
||||
}
|
||||
|
||||
.review-name-cell .work-title-line {
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.review-name-cell strong,
|
||||
.plan-name-cell strong {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
@@ -1042,8 +1103,10 @@ function openAuditDialog() {
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.review-card-head,
|
||||
@@ -1115,11 +1178,16 @@ function openAuditDialog() {
|
||||
.review-editor-grid,
|
||||
.plan-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, calc(57.45% - 6px + 50px)) minmax(0, calc(42.55% - 6px - 50px));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.review-editor-grid,
|
||||
.review-editor-grid {
|
||||
grid-column: 3 / span 2;
|
||||
grid-row: 1;
|
||||
border-left: 1px solid #e5edf1;
|
||||
}
|
||||
|
||||
.plan-editor-grid {
|
||||
grid-column: 3 / span 2;
|
||||
grid-row: 1;
|
||||
@@ -1244,7 +1312,8 @@ function openAuditDialog() {
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-section-tasks) {
|
||||
display: block;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-left: 14px;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
@@ -1254,9 +1323,31 @@ function openAuditDialog() {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-category-line) {
|
||||
color: #0f766e;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-section-tasks) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-task-line) {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-section-task) {
|
||||
display: inline;
|
||||
margin-right: 5px;
|
||||
display: block;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@@ -1278,8 +1369,8 @@ function openAuditDialog() {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
vertical-align: 1px;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.structured-input {
|
||||
@@ -1327,11 +1418,9 @@ function openAuditDialog() {
|
||||
}
|
||||
|
||||
.review-editor-grid .rich-editor,
|
||||
.plan-editor-grid .rich-editor,
|
||||
.review-editor-grid .structured-input,
|
||||
.plan-editor-grid .structured-input {
|
||||
.plan-editor-grid .rich-editor {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 86px;
|
||||
}
|
||||
|
||||
.structured-input.auto-height {
|
||||
@@ -1478,23 +1567,38 @@ function openAuditDialog() {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/** 必填字段红色星号 */
|
||||
.required-mark {
|
||||
color: #dc2626;
|
||||
margin-right: 2px;
|
||||
.field-form-item {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
/** 字段错误提示容器 */
|
||||
.field-with-error {
|
||||
.field-required-mark {
|
||||
margin-left: 2px;
|
||||
color: #f04438;
|
||||
}
|
||||
|
||||
.field-inline-form-item,
|
||||
.performance-inline-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.field-inline-form-item :deep(.el-form-item__content),
|
||||
.performance-inline-form-item :deep(.el-form-item__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/** 字段错误提示文字 */
|
||||
.field-error {
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
.field-inline-form-item :deep(.el-form-item__error),
|
||||
.performance-inline-form-item :deep(.el-form-item__error) {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.field-inline-form-item {
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.field-inline-form-item :deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.feedback-table {
|
||||
@@ -1678,12 +1782,18 @@ function openAuditDialog() {
|
||||
}
|
||||
|
||||
.performance-label {
|
||||
flex: 0 0 auto;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.performance-inline-form-item {
|
||||
margin-bottom: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.performance-input {
|
||||
width: 200px;
|
||||
}
|
||||
@@ -1757,9 +1867,11 @@ function openAuditDialog() {
|
||||
.review-index-cell,
|
||||
.review-name-cell,
|
||||
.review-editor-grid,
|
||||
.review-action-cell,
|
||||
.plan-index-cell,
|
||||
.plan-name-cell,
|
||||
.plan-editor-grid {
|
||||
.plan-editor-grid,
|
||||
.plan-action-cell {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
border-left: 0;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-mutating-props, unicorn/prefer-dom-node-text-content, no-useless-escape, no-nested-ternary, no-plusplus, @typescript-eslint/no-shadow */
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetMyParticipatedProjectPage } from '@/service/api';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
@@ -59,12 +60,14 @@ interface StructuredTask {
|
||||
priority?: string;
|
||||
progress?: number;
|
||||
hours?: number;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface PlanTaskDraft {
|
||||
title: string;
|
||||
priority?: StructuredTask['priority'];
|
||||
progress: number;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface StructuredSection {
|
||||
@@ -106,6 +109,37 @@ const planForm = ref({
|
||||
|
||||
const activePlanSectionIndex = ref(-1);
|
||||
|
||||
/** 工作事项下拉里的"我的事项"固定项,用于区分纯个人事务。 */
|
||||
const MY_AFFAIRS_TITLE = '我的事项';
|
||||
|
||||
/** 「我参与的项目」项目名清单,下拉框数据源。 */
|
||||
const participatedProjectNames = ref<string[]>([]);
|
||||
|
||||
async function loadParticipatedProjectNames() {
|
||||
// pageSize=-1 一次拉全部(不分页),由后端按"进行中 + 创建时间升序"过滤排序。
|
||||
const { data, error } = await fetchGetMyParticipatedProjectPage({ pageNo: 1, pageSize: -1 });
|
||||
if (error || !data) {
|
||||
participatedProjectNames.value = [];
|
||||
return;
|
||||
}
|
||||
participatedProjectNames.value = data.list
|
||||
.map(project => project.name?.trim())
|
||||
.filter((name): name is string => Boolean(name));
|
||||
}
|
||||
|
||||
onMounted(loadParticipatedProjectNames);
|
||||
|
||||
const reviewWorkItemOptions = computed(() => {
|
||||
// 下拉数据来自「我参与的项目」API 拉取的项目名 + 「我的事项」固定项,
|
||||
// 与周报共用同一份数据源(fetchGetMyParticipatedProjectPage, pageSize=-1)。
|
||||
const names = new Set<string>(participatedProjectNames.value);
|
||||
return [MY_AFFAIRS_TITLE, ...Array.from(names).filter(name => name !== MY_AFFAIRS_TITLE)];
|
||||
});
|
||||
|
||||
/** 选中"我的事项"时,事项不参与项目优先级,需要隐藏优先级相关展示。 */
|
||||
const isMyAffairsPlanItem = (workItem: string) => workItem.trim() === MY_AFFAIRS_TITLE;
|
||||
const DEFAULT_SECTION_CATEGORY = '工作内容';
|
||||
|
||||
function createPlanTaskDraft(): PlanTaskDraft {
|
||||
return { title: '', priority: '2', progress: 0 };
|
||||
}
|
||||
@@ -115,7 +149,8 @@ function normalizeTask(task: WorkReportStructuredTask): StructuredTask {
|
||||
title: task.title || '',
|
||||
priority: normalizePriorityCode(task.priority),
|
||||
progress: typeof task.progress === 'number' ? task.progress : undefined,
|
||||
hours: typeof task.hours === 'number' ? task.hours : undefined
|
||||
hours: typeof task.hours === 'number' ? task.hours : undefined,
|
||||
detail: task.detail || ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,16 +182,14 @@ function resolveTaskItemTypeLabel(value: string) {
|
||||
return getTaskItemTypeLabel(value, { fallback: value || '工作内容' });
|
||||
}
|
||||
|
||||
function normalizeSection(section: WorkReportStructuredSection): StructuredSection {
|
||||
return {
|
||||
category: resolveTaskItemTypeLabel(section.category || '工作内容'),
|
||||
tasks: section.tasks.map(normalizeTask)
|
||||
};
|
||||
function normalizeSectionCategory(value?: string | null, fallback = DEFAULT_SECTION_CATEGORY) {
|
||||
const category = resolveTaskItemTypeLabel(value || '').trim();
|
||||
return category || fallback;
|
||||
}
|
||||
|
||||
function normalizeSectionForMerge(section: WorkReportStructuredSection): StructuredSection {
|
||||
function normalizeSection(section: WorkReportStructuredSection): StructuredSection {
|
||||
return {
|
||||
category: section.category || '工作内容',
|
||||
category: normalizeSectionCategory(section.category),
|
||||
tasks: section.tasks.map(normalizeTask)
|
||||
};
|
||||
}
|
||||
@@ -165,7 +198,7 @@ function mergeSectionsByCategory(sections: StructuredSection[]) {
|
||||
const sectionMap = new Map<string, StructuredSection>();
|
||||
|
||||
sections.forEach(section => {
|
||||
const category = section.category || '未分类';
|
||||
const category = normalizeSectionCategory(section.category, '未分类');
|
||||
const existing = sectionMap.get(category);
|
||||
if (existing) {
|
||||
existing.tasks.push(...section.tasks);
|
||||
@@ -191,7 +224,10 @@ function resolveTaskMetricsV2(metricsText: string) {
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
const priority = normalizePriorityCode(parts.find(item => /^P?\d+$/iu.test(item)));
|
||||
const progressText = metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1];
|
||||
// 同时支持 "进度 XX%" 与裸 "XX%",避免失焦后丢失进度。
|
||||
const progressText =
|
||||
metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1] ||
|
||||
parts.find(item => /^\d+(?:\.\d+)?%$/u.test(item))?.replace(/%$/u, '');
|
||||
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/u)?.[1];
|
||||
|
||||
return {
|
||||
@@ -215,7 +251,7 @@ function formatStructuredTaskLineV2(task: StructuredTask, showHours = false) {
|
||||
function createStructuredTextV2(sections: StructuredSection[], showHours = false) {
|
||||
return sections
|
||||
.map(section => {
|
||||
const categoryLabel = resolveTaskItemTypeLabel(section.category).trim();
|
||||
const categoryLabel = normalizeSectionCategory(section.category);
|
||||
return [
|
||||
`#${categoryLabel}`,
|
||||
...section.tasks.map((task, index) => `${index + 1}、${formatStructuredTaskLineV2(task, showHours)}`)
|
||||
@@ -229,7 +265,7 @@ function createStructuredTextV2(sections: StructuredSection[], showHours = false
|
||||
function createStructuredHtmlV2(sections: StructuredSection[], showHours = false) {
|
||||
return sections
|
||||
.map(section => {
|
||||
const categoryLabel = resolveTaskItemTypeLabel(section.category.trim());
|
||||
const categoryLabel = normalizeSectionCategory(section.category);
|
||||
const tasks = section.tasks
|
||||
.map(
|
||||
(task, index) =>
|
||||
@@ -253,7 +289,7 @@ function createStructuredSectionsFromTextV2(text: string, defaultCategory: strin
|
||||
|
||||
const sections: StructuredSection[] = [];
|
||||
const ensureSection = (categoryText: string) => {
|
||||
const category = categoryText.trim() || defaultCategory;
|
||||
const category = normalizeSectionCategory(categoryText, defaultCategory);
|
||||
let section = sections.find(item => item.category === category);
|
||||
if (!section) {
|
||||
section = { category, tasks: [] };
|
||||
@@ -276,63 +312,110 @@ function createStructuredSectionsFromTextV2(text: string, defaultCategory: strin
|
||||
|
||||
const legacyMatch = trimmedLine.match(/^(.+?)\s*-\s*(.+?)(?:[((]([^()()]*)[))])?[。.!!??]*$/u);
|
||||
if (legacyMatch) {
|
||||
const [, rawCategory, rawTitle, metricsText = ''] = legacyMatch;
|
||||
const [, rawCategory, rawTaskText] = legacyMatch;
|
||||
const category = rawCategory.trim();
|
||||
const title = rawTitle.trim();
|
||||
if (!category || !title) return;
|
||||
const task = parseStructuredSectionTaskText(rawTaskText);
|
||||
if (!category || !task) return;
|
||||
|
||||
ensureSection(category).tasks.push({
|
||||
title,
|
||||
...resolveTaskMetricsV2(metricsText)
|
||||
});
|
||||
ensureSection(category).tasks.push(task);
|
||||
currentCategory = category;
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLine = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(trimmedLine));
|
||||
if (!normalizedLine) return;
|
||||
const inlineMatch = normalizedLine.match(/^(.*?)(?:[((]([^()()]*)[))])?$/u);
|
||||
const title = inlineMatch?.[1]?.trim() || '';
|
||||
const metricsText = inlineMatch?.[2]?.trim() || '';
|
||||
if (!title) return;
|
||||
const task = parseStructuredSectionTaskText(trimmedLine);
|
||||
if (!task) return;
|
||||
|
||||
ensureSection(currentCategory || defaultCategory).tasks.push({
|
||||
title,
|
||||
...resolveTaskMetricsV2(metricsText)
|
||||
});
|
||||
const hasStructuredHint =
|
||||
/^(\d+[..、]\s*)/u.test(trimmedLine) ||
|
||||
trimmedLine.includes('(') ||
|
||||
trimmedLine.includes('(') ||
|
||||
trimmedLine.includes(':') ||
|
||||
trimmedLine.includes(':');
|
||||
|
||||
if (!hasStructuredHint && !currentCategory) return;
|
||||
|
||||
ensureSection(currentCategory || defaultCategory).tasks.push(task);
|
||||
});
|
||||
|
||||
return sections.filter(section => section.tasks.length);
|
||||
}
|
||||
|
||||
function parseStructuredSectionsFromEditorV2(editor: HTMLElement, defaultCategory = '工作内容'): StructuredSection[] {
|
||||
return createStructuredSectionsFromTextV2(editor.innerText, defaultCategory);
|
||||
}
|
||||
function parseStructuredSectionTaskText(
|
||||
text: string,
|
||||
fallback?: Partial<StructuredTask>,
|
||||
useFallback = false
|
||||
): StructuredTask | null {
|
||||
const normalizedText = stripStructuredTaskPrefixV2(text);
|
||||
if (!normalizedText) return null;
|
||||
|
||||
function createStructuredText(sections: StructuredSection[], showHours = false) {
|
||||
const stripOrderPrefix = (text: string) => text.trim().replace(/^\d+[..、]\s*/, '');
|
||||
const formatTaskText = (task: StructuredTask, taskIndex: number, showHours: boolean) => {
|
||||
const rawTitle = stripOrderPrefix(task.title);
|
||||
const punctuation = rawTitle.match(/[。.!!??]$/)?.[0] || '';
|
||||
const title = punctuation ? rawTitle.slice(0, -1) : rawTitle;
|
||||
const metrics = [
|
||||
task.priority ? resolvePriorityLabel(task.priority) : '',
|
||||
typeof task.progress === 'number' ? `进度${task.progress}%` : '',
|
||||
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('、');
|
||||
return `${taskIndex + 1}.${title}${metrics ? `(${metrics})` : ''}${punctuation}`;
|
||||
const structuredMatch =
|
||||
normalizedText.match(/^(.+?)(?:[((]([^()()]*)[))])?(?:\s*[::]\s*(.*))?$/u) ||
|
||||
normalizedText.match(/^(.+?)(?:\(([^()]*)\))?(?::\s*(.*))?$/u);
|
||||
|
||||
if (!structuredMatch) return null;
|
||||
|
||||
const [, rawTitle, metricsText = '', detail = ''] = structuredMatch;
|
||||
const title = stripStructuredTaskSuffixV2(rawTitle);
|
||||
if (!title) return null;
|
||||
|
||||
return {
|
||||
title,
|
||||
detail: detail.trim(),
|
||||
...resolveTaskMetrics(metricsText, fallback, useFallback)
|
||||
};
|
||||
|
||||
return sections
|
||||
.map(section => {
|
||||
const categoryLabel = resolveTaskItemTypeLabel(section.category);
|
||||
const tasksText = section.tasks.map((task, taskIndex) => formatTaskText(task, taskIndex, showHours)).join('');
|
||||
return [categoryLabel, tasksText ? ` ${tasksText}` : ''].filter(Boolean).join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function createFallbackTaskLookup(sections: StructuredSection[]) {
|
||||
const lookup = new Map<string, StructuredTask[]>();
|
||||
|
||||
sections.forEach(section => {
|
||||
const categoryKey = normalizeSectionCategory(section.category);
|
||||
section.tasks.forEach(task => {
|
||||
const titleKey = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(task.title));
|
||||
const key = `${categoryKey}||${titleKey}`;
|
||||
const list = lookup.get(key);
|
||||
if (list) {
|
||||
list.push(task);
|
||||
} else {
|
||||
lookup.set(key, [task]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function parseStructuredSectionsFromEditorV2(
|
||||
editor: HTMLElement,
|
||||
fallbackSections: StructuredSection[] = [],
|
||||
defaultCategory = DEFAULT_SECTION_CATEGORY
|
||||
): StructuredSection[] {
|
||||
const parsedSections = createStructuredSectionsFromTextV2(editor.innerText, defaultCategory);
|
||||
if (!parsedSections.length) return [];
|
||||
|
||||
const fallbackLookup = createFallbackTaskLookup(fallbackSections);
|
||||
|
||||
return parsedSections.map((section, sectionIndex) => {
|
||||
const fallbackSection = fallbackSections[sectionIndex];
|
||||
const categoryKey = normalizeSectionCategory(section.category);
|
||||
|
||||
return {
|
||||
category: section.category,
|
||||
tasks: section.tasks.map((task, taskIndex) => {
|
||||
const titleKey = stripStructuredTaskSuffixV2(stripStructuredTaskPrefixV2(task.title));
|
||||
const matchedByKey = fallbackLookup.get(`${categoryKey}||${titleKey}`)?.shift();
|
||||
const matchedByIndex = fallbackSection?.tasks[taskIndex];
|
||||
const fallbackTask = matchedByKey || matchedByIndex;
|
||||
|
||||
return {
|
||||
...task,
|
||||
detail: fallbackTask?.detail || task.detail || '',
|
||||
hours: task.hours ?? fallbackTask?.hours
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
@@ -482,63 +565,19 @@ function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTa
|
||||
hours: hoursText === undefined ? (useFallback ? fallback?.hours : undefined) : Number(hoursText)
|
||||
};
|
||||
}
|
||||
|
||||
function parseStructuredSectionsFromEditor(
|
||||
editor: HTMLElement,
|
||||
fallbackSections: StructuredSection[] = [],
|
||||
defaultCategory = '工作内容'
|
||||
) {
|
||||
const sectionElements = Array.from(editor.querySelectorAll<HTMLElement>('.rich-section'));
|
||||
|
||||
if (!sectionElements.length) {
|
||||
return createStructuredSectionsFromText(editor.innerText, defaultCategory);
|
||||
}
|
||||
|
||||
return sectionElements
|
||||
.map((sectionElement, sectionIndex) => {
|
||||
const fallbackSection = fallbackSections[sectionIndex];
|
||||
const category =
|
||||
getElementText(sectionElement.querySelector('.rich-section-title')) ||
|
||||
fallbackSection?.category ||
|
||||
defaultCategory;
|
||||
const taskElements = Array.from(sectionElement.querySelectorAll<HTMLElement>('.rich-section-task'));
|
||||
const tasks = taskElements
|
||||
.map((taskElement, taskIndex) => {
|
||||
const fallbackTask = fallbackSection?.tasks[taskIndex];
|
||||
const title = stripTaskOrderPrefix(getElementText(taskElement.querySelector('.rich-section-task-title')));
|
||||
const metricsElement = taskElement.querySelector('.rich-inline-metrics');
|
||||
const metrics = metricsElement
|
||||
? resolveTaskMetrics(getElementText(metricsElement), fallbackTask, false)
|
||||
: { priority: undefined, progress: undefined, hours: undefined };
|
||||
|
||||
if (!title) return null;
|
||||
|
||||
return {
|
||||
title,
|
||||
...metrics
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as StructuredTask[];
|
||||
|
||||
if (!category && !tasks.length) return null;
|
||||
|
||||
return {
|
||||
category,
|
||||
tasks
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as StructuredSection[];
|
||||
}
|
||||
|
||||
function createSectionsJson(sections: StructuredSection[]) {
|
||||
return sections.length ? JSON.stringify({ sections }) : null;
|
||||
}
|
||||
|
||||
function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
|
||||
function getReviewItemSections(item: Api.WorkReport.Common.PersonalReportReviewItem) {
|
||||
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.contentJson).map(normalizeSection));
|
||||
const contentSections = structuredSections.length
|
||||
? structuredSections
|
||||
: createStructuredSectionsFromTextV2(item.contentText || '', item.itemTitle || '未分类');
|
||||
if (structuredSections.length) return structuredSections;
|
||||
|
||||
return createStructuredSectionsFromTextV2(item.contentText || '', item.itemTitle || DEFAULT_SECTION_CATEGORY);
|
||||
}
|
||||
|
||||
function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
|
||||
const contentSections = getReviewItemSections(item);
|
||||
|
||||
return {
|
||||
workItem: item.itemTitle || '未命名工作',
|
||||
@@ -555,11 +594,15 @@ function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): Rev
|
||||
};
|
||||
}
|
||||
|
||||
function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: number): PlanItem {
|
||||
function getPlanItemSections(item: Api.WorkReport.Common.PersonalReportPlanItem) {
|
||||
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.targetJson).map(normalizeSection));
|
||||
const targetSections = structuredSections.length
|
||||
? structuredSections
|
||||
: createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || '未分类');
|
||||
if (structuredSections.length) return structuredSections;
|
||||
|
||||
return createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || DEFAULT_SECTION_CATEGORY);
|
||||
}
|
||||
|
||||
function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: number): PlanItem {
|
||||
const targetSections = getPlanItemSections(item);
|
||||
|
||||
return {
|
||||
workItem: item.itemTitle || '未命名计划',
|
||||
@@ -569,7 +612,6 @@ function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: n
|
||||
: escapeHtml(item.targetText || '').replace(/\n/g, '<br>') || EMPTY_TEXT,
|
||||
targetSections,
|
||||
supportNeed: item.supportNeed || '',
|
||||
/* 只有用户新增的计划才可删除,默认带入的不可删除 */
|
||||
removable: true,
|
||||
source: item,
|
||||
sourceIndex: index
|
||||
@@ -628,11 +670,16 @@ function removePlanSection(index: number) {
|
||||
function addPlanTask(sectionIndex: number) {
|
||||
const section = planForm.value.sections[sectionIndex];
|
||||
if (!section?.draft.title.trim()) return;
|
||||
section.tasks.push({
|
||||
const task: StructuredTask = {
|
||||
title: section.draft.title.trim(),
|
||||
priority: section.draft.priority,
|
||||
progress: section.draft.progress
|
||||
});
|
||||
progress: section.draft.progress,
|
||||
detail: section.draft.detail?.trim() || undefined
|
||||
};
|
||||
// 我的事项不参与项目优先级,存进任务前显式剔除,结构化展示括号里就不会出现优先级。
|
||||
if (!isMyAffairsPlanItem(planForm.value.workItem)) {
|
||||
task.priority = section.draft.priority;
|
||||
}
|
||||
section.tasks.push(task);
|
||||
section.draft = createPlanTaskDraft();
|
||||
}
|
||||
|
||||
@@ -644,25 +691,17 @@ function submitInlinePlan() {
|
||||
const sections = planForm.value.sections
|
||||
.filter(section => section.category.trim() && section.tasks.length)
|
||||
.map(section => ({
|
||||
category: section.category.trim(),
|
||||
tasks: section.tasks
|
||||
category: normalizeSectionCategory(section.category),
|
||||
tasks: section.tasks.map(task => ({ ...task }))
|
||||
}));
|
||||
if (!sections.length) return;
|
||||
const target = createStructuredTextV2(sections);
|
||||
const workItem = planForm.value.workItem.trim();
|
||||
const sameWorkItem = props.model.planItems.find(item => item.itemTitle.trim() === workItem);
|
||||
if (sameWorkItem) {
|
||||
const mergedSections = getStructuredSections(sameWorkItem.targetJson).map(normalizeSectionForMerge);
|
||||
sections.forEach(section => {
|
||||
const sameCategory = mergedSections.find(item => item.category === section.category);
|
||||
if (sameCategory) {
|
||||
sameCategory.tasks.push(...section.tasks);
|
||||
} else {
|
||||
mergedSections.push(section);
|
||||
}
|
||||
});
|
||||
const mergedSections = mergeSectionsByCategory([...getPlanItemSections(sameWorkItem), ...sections]);
|
||||
sameWorkItem.targetText = createStructuredTextV2(mergedSections);
|
||||
sameWorkItem.targetJson = JSON.stringify({ sections: mergedSections });
|
||||
sameWorkItem.targetJson = createSectionsJson(mergedSections);
|
||||
if (planForm.value.supportNeed.trim()) {
|
||||
sameWorkItem.supportNeed = [sameWorkItem.supportNeed, planForm.value.supportNeed.trim()]
|
||||
.filter(Boolean)
|
||||
@@ -673,7 +712,7 @@ function submitInlinePlan() {
|
||||
itemNumber: props.model.planItems.length + 1,
|
||||
itemTitle: workItem,
|
||||
targetText: target,
|
||||
targetJson: JSON.stringify({ sections }),
|
||||
targetJson: createSectionsJson(sections),
|
||||
supportNeed: planForm.value.supportNeed,
|
||||
_isNew: true
|
||||
} as Api.WorkReport.Common.PersonalReportPlanItem & { _isNew: boolean });
|
||||
@@ -698,15 +737,25 @@ function blurEditField(key: string) {
|
||||
function syncRichContent(item: ReviewItem, event: Event) {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
if (!item.source) return;
|
||||
const sections = parseStructuredSectionsFromEditorV2(target, item.workItem || '未分类');
|
||||
const sections = parseStructuredSectionsFromEditorV2(
|
||||
target,
|
||||
item.contentSections || [],
|
||||
item.contentSections?.[0]?.category || DEFAULT_SECTION_CATEGORY
|
||||
);
|
||||
item.source.contentJson = createSectionsJson(sections);
|
||||
item.source.contentText = createStructuredTextV2(sections, true);
|
||||
}
|
||||
|
||||
function syncRichTarget(item: PlanItem, event: Event) {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
if (!item.source) return;
|
||||
const sections = parseStructuredSectionsFromEditorV2(target, item.workItem || '未分类');
|
||||
const sections = parseStructuredSectionsFromEditorV2(
|
||||
target,
|
||||
item.targetSections || [],
|
||||
item.targetSections?.[0]?.category || DEFAULT_SECTION_CATEGORY
|
||||
);
|
||||
item.source.targetJson = createSectionsJson(sections);
|
||||
item.source.targetText = createStructuredTextV2(sections);
|
||||
}
|
||||
|
||||
function syncRichReflection(item: ReviewItem, event: Event) {
|
||||
@@ -732,7 +781,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
<ElInput v-model="mainForm.reporter" disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>部门/方向</label>
|
||||
<label>部门</label>
|
||||
<ElInput v-model="mainForm.deptName" disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -1051,14 +1100,18 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
>
|
||||
<div class="plan-dialog-form">
|
||||
<div class="field">
|
||||
<label>项目名/事项名</label>
|
||||
<ElInput
|
||||
<label>工作事项</label>
|
||||
<ElSelect
|
||||
v-model="planForm.workItem"
|
||||
class="inline-plan-name-input"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入项目名或我的事项"
|
||||
/>
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="请选择项目名/我的事项"
|
||||
>
|
||||
<ElOption v-for="item in reviewWorkItemOptions" :key="item" :label="item" :value="item" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>具体目标</label>
|
||||
@@ -1070,6 +1123,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
:class="{ active: activePlanSectionIndex === sIdx }"
|
||||
>
|
||||
<div class="plan-section-head">
|
||||
<div class="field plan-section-head-field">
|
||||
<label>类别</label>
|
||||
<DictSelect
|
||||
v-model="section.category"
|
||||
:dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE"
|
||||
@@ -1079,6 +1134,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
@focus="activePlanSectionIndex = sIdx"
|
||||
@change="activePlanSectionIndex = sIdx"
|
||||
/>
|
||||
</div>
|
||||
<ElButton
|
||||
v-if="planForm.sections.length > 1"
|
||||
link
|
||||
@@ -1093,14 +1149,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
<div class="plan-task-head">
|
||||
<span>{{ task.title }}</span>
|
||||
<div class="plan-task-metas">
|
||||
<template v-if="!isMyAffairsPlanItem(planForm.workItem)">
|
||||
<em>{{ resolvePriorityLabel(task.priority) }}</em>
|
||||
</template>
|
||||
<em>进度 {{ task.progress }}%</em>
|
||||
</div>
|
||||
<ElButton link type="danger" size="small" @click="removePlanTask(sIdx, tIdx)">删除</ElButton>
|
||||
</div>
|
||||
<div v-if="task.detail" class="plan-task-detail">{{ task.detail }}</div>
|
||||
</div>
|
||||
<div class="plan-task-form">
|
||||
<div class="inline-task-row">
|
||||
<div
|
||||
class="inline-task-row"
|
||||
:class="{ 'inline-task-row--my-affairs': isMyAffairsPlanItem(planForm.workItem) }"
|
||||
>
|
||||
<div class="field">
|
||||
<label>任务名/事项名</label>
|
||||
<ElInput
|
||||
@@ -1110,7 +1172,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
@focus="activePlanSectionIndex = sIdx"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div v-if="!isMyAffairsPlanItem(planForm.workItem)" class="field">
|
||||
<label>优先级</label>
|
||||
<DictSelect
|
||||
v-model="section.draft.priority"
|
||||
@@ -1135,6 +1197,17 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>详细内容</label>
|
||||
<ElInput
|
||||
v-model="section.draft.detail"
|
||||
size="small"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="详细内容(可选)"
|
||||
@focus="activePlanSectionIndex = sIdx"
|
||||
/>
|
||||
</div>
|
||||
<ElButton
|
||||
class="dialog-inline-action"
|
||||
type="primary"
|
||||
@@ -1150,7 +1223,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
</div>
|
||||
<ElButton
|
||||
v-if="!planForm.sections.length || planForm.sections.every(s => s.category.trim())"
|
||||
class="dialog-inline-action dialog-inline-action--secondary"
|
||||
class="dialog-inline-action"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
@@ -1885,6 +1958,10 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inline-task-row--my-affairs {
|
||||
grid-template-columns: minmax(0, 1fr) 142px;
|
||||
}
|
||||
|
||||
.plan-sections {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1910,7 +1987,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.plan-section-head :deep(.el-select__wrapper) {
|
||||
@@ -1932,6 +2009,14 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.plan-task-detail {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.plan-task-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -2042,10 +2127,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.dialog-inline-action--secondary {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
.dialog-inline-action :deep(.el-icon) {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -2130,12 +2211,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
position: sticky;
|
||||
z-index: 5;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
margin-bottom: 0;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid #d8e0e8;
|
||||
background: #fff;
|
||||
border-bottom-left-radius: 18px;
|
||||
border-bottom-right-radius: 18px;
|
||||
background: #f5f7fa;
|
||||
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
|
||||
@@ -998,12 +998,20 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
position: sticky;
|
||||
z-index: 5;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
margin-bottom: 0;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid #d8e0e8;
|
||||
background: #fff;
|
||||
border-bottom-left-radius: 18px;
|
||||
border-bottom-right-radius: 18px;
|
||||
background: #f5f7fa;
|
||||
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.approval-form-actions {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { computed, nextTick, reactive, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
|
||||
@@ -33,6 +34,9 @@ const emit = defineEmits<{
|
||||
): void;
|
||||
}>();
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const reasonModel = reactive<Api.WorkReport.Common.StatusActionParams>({
|
||||
reason: ''
|
||||
});
|
||||
@@ -72,12 +76,35 @@ const title = computed(() => {
|
||||
});
|
||||
|
||||
const preset = computed(() => (isMonthlyApprove.value ? 'lg' : 'sm'));
|
||||
const rejectOpinionRequired = computed(() => isCommonApprove.value && commonApprovalModel.conclusion === 'reject');
|
||||
const opinionLabel = computed(() => (rejectOpinionRequired.value ? '退回原因' : '审批意见'));
|
||||
const opinionPlaceholder = computed(() =>
|
||||
rejectOpinionRequired.value ? `请输入${opinionLabel.value}` : '可填写审批意见'
|
||||
);
|
||||
const confirmText = computed(() => {
|
||||
if (isCommonApprove.value) return '确认提交';
|
||||
if (props.actionType === 'approve') return '通过';
|
||||
return '退回';
|
||||
});
|
||||
const confirmDisabled = computed(() => isCommonApprove.value && !commonApprovalModel.conclusion);
|
||||
const commonRules = computed(() => ({
|
||||
opinion: rejectOpinionRequired.value
|
||||
? [
|
||||
createRequiredRule(`请输入${opinionLabel.value}`),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error(`请输入${opinionLabel.value}`));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
: []
|
||||
}));
|
||||
|
||||
watch(visible, isVisible => {
|
||||
if (!isVisible) return;
|
||||
@@ -106,16 +133,36 @@ watch(visible, isVisible => {
|
||||
}
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
watch(
|
||||
() => visible.value,
|
||||
async isVisible => {
|
||||
if (!isVisible || !isCommonApprove.value) return;
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
|
||||
watch(rejectOpinionRequired, async () => {
|
||||
if (!visible.value || !isCommonApprove.value) return;
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate('opinion');
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
if (isCommonApprove.value) {
|
||||
if (!commonApprovalModel.conclusion) {
|
||||
window.$message?.warning('请选择审批结论');
|
||||
return;
|
||||
}
|
||||
|
||||
await validate();
|
||||
|
||||
emit(
|
||||
'submit',
|
||||
{
|
||||
reason: commonApprovalModel.opinion || (commonApprovalModel.conclusion === 'approve' ? '通过' : '不通过')
|
||||
reason: commonApprovalModel.opinion.trim() || (commonApprovalModel.conclusion === 'approve' ? '通过' : '退回')
|
||||
},
|
||||
commonApprovalModel.conclusion
|
||||
);
|
||||
@@ -176,14 +223,29 @@ function handleSubmit() {
|
||||
<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 class="audit-field">
|
||||
<label>审批意见</label>
|
||||
<ElInput v-model="commonApprovalModel.opinion" type="textarea" :rows="3" placeholder="请输入审批意见" />
|
||||
</div>
|
||||
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="commonApprovalModel"
|
||||
:rules="commonRules"
|
||||
label-position="top"
|
||||
:validate-on-rule-change="false"
|
||||
>
|
||||
<ElFormItem :label="opinionLabel" prop="opinion">
|
||||
<ElInput
|
||||
v-model="commonApprovalModel.opinion"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="opinionPlaceholder"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
formatDate,
|
||||
formatEmptyText,
|
||||
formatPeriod,
|
||||
formatPeriodDateRange,
|
||||
formatWeeklyPeriodLabel,
|
||||
getProjectReportFlagLabel,
|
||||
getWorkReportStatusLabel
|
||||
} from '../types';
|
||||
@@ -29,6 +31,14 @@ const title = computed(() => `${WORK_REPORT_TYPE_LABEL[props.reportType]}详情`
|
||||
const weeklyDetail = computed(() =>
|
||||
props.reportType === 'weekly' ? (detail.value as Api.WorkReport.Weekly.WeeklyReport | null) : null
|
||||
);
|
||||
const periodText = computed(() => {
|
||||
if (!detail.value) return '--';
|
||||
return props.reportType === 'weekly' ? formatWeeklyPeriodLabel(detail.value) : formatPeriod(detail.value);
|
||||
});
|
||||
const periodTooltip = computed(() => {
|
||||
if (!detail.value || props.reportType !== 'weekly') return '';
|
||||
return formatPeriodDateRange(detail.value);
|
||||
});
|
||||
|
||||
watch(visible, isVisible => {
|
||||
if (isVisible) loadDetail();
|
||||
@@ -68,7 +78,11 @@ function getPersonalDetail() {
|
||||
<div v-if="detail" class="work-report-detail">
|
||||
<BusinessFormSection title="基础信息">
|
||||
<ElDescriptions :column="3" border size="small">
|
||||
<ElDescriptionsItem label="报告周期">{{ formatPeriod(detail) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="报告周期">
|
||||
<ElTooltip :disabled="!periodTooltip || periodTooltip === '--'" :content="periodTooltip" placement="top">
|
||||
<span>{{ periodText }}</span>
|
||||
</ElTooltip>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="状态">
|
||||
{{ getWorkReportStatusLabel(detail.statusCode, detail.statusName) }}
|
||||
</ElDescriptionsItem>
|
||||
|
||||
@@ -335,7 +335,7 @@ async function handleSubmit() {
|
||||
<ElDescriptionsItem label="填报人">
|
||||
{{ baseReporterName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="部门/方向">{{ baseDeptName }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="部门">{{ baseDeptName }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="岗位">{{ basePostName }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="直属上级">{{ baseInfo?.supervisorName || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="周期" :span="2">{{ activeModel.periodLabel || '--' }}</ElDescriptionsItem>
|
||||
|
||||
@@ -76,6 +76,7 @@ onBeforeUnmount(() => {
|
||||
:title="props.title"
|
||||
:size="drawerSize"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
@closed="onDrawerClosed"
|
||||
>
|
||||
<div v-loading="props.loading" class="work-report-page-drawer__content">
|
||||
@@ -93,6 +94,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.work-report-page-drawer__body--approval) {
|
||||
@@ -107,6 +109,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.work-report-page-drawer__content :deep(.form-page) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 auto;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
createProjectSaveParams,
|
||||
createWeeklySaveParams,
|
||||
formatPeriodLabel,
|
||||
getStructuredSections,
|
||||
normalizePlanItems,
|
||||
normalizeProjectItems,
|
||||
normalizeReviewItems
|
||||
@@ -459,7 +460,6 @@ function isCompleteWeeklyTravelSegment(segment: Api.WorkReport.Weekly.WeeklyRepo
|
||||
return Boolean(
|
||||
segment.startDate &&
|
||||
segment.endDate &&
|
||||
segment.location?.trim() &&
|
||||
Number.isFinite(travelDays) &&
|
||||
travelDays >= 0.5 &&
|
||||
Number.isInteger(travelDays * 2)
|
||||
@@ -470,6 +470,21 @@ function hasCompleteWeeklyTravelSegment(items: Api.WorkReport.Weekly.WeeklyRepor
|
||||
return items.some(isCompleteWeeklyTravelSegment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 周报"具体工作内容及成果描述"中已记录出差信息("本周差旅"分类、或含"差旅"分类、
|
||||
* 或结构化任务里带 kind=travel)时,视为已经有完整出差分段,不再强制弹出
|
||||
* "请至少新增一条完整的出差分段"。避免出差点位等历史录入原因导致保存时反复提示。
|
||||
*/
|
||||
function hasTravelInfoInWeeklyReview(items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
|
||||
return items.some(item => {
|
||||
if (item.itemTitle?.trim() === '本周差旅') return true;
|
||||
const sections = getStructuredSections(item.contentJson);
|
||||
if (sections.some(section => section.category?.trim() === '差旅')) return true;
|
||||
if (sections.some(section => section.tasks.some(task => task.kind === 'travel'))) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function hasCompletePersonalReviewItem(items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
|
||||
return items.some(item => hasTextValue(item.itemTitle) && hasReviewContent(item));
|
||||
}
|
||||
@@ -506,7 +521,12 @@ function validateRequiredReportItems() {
|
||||
const messages: string[] = [];
|
||||
|
||||
if (props.reportType === 'weekly') {
|
||||
const hasTravelReview = weeklyModel.isBusinessTrip && hasCompleteWeeklyTravelSegment(weeklyModel.travelSegments);
|
||||
// 出差信息既可能来自 travelSegments,也可能来自具体工作内容及成果描述里的"差旅"段,
|
||||
// 任何一方已有完整出差信息就不再提示"请至少新增一条完整的出差分段"。
|
||||
const hasTravelReview =
|
||||
weeklyModel.isBusinessTrip &&
|
||||
(hasCompleteWeeklyTravelSegment(weeklyModel.travelSegments) ||
|
||||
hasTravelInfoInWeeklyReview(weeklyModel.reviewItems));
|
||||
const reviewMessage = hasTravelReview
|
||||
? weeklyModel.reviewItems.length
|
||||
? getPersonalReviewValidationMessage('当期重点工作回顾', weeklyModel.reviewItems)
|
||||
@@ -769,6 +789,7 @@ async function handleActionSubmit(
|
||||
:action-type="currentActionType"
|
||||
:initial-monthly-approve-data="reportType === 'monthly' ? monthlyApprovalDraft : null"
|
||||
:loading="actionSubmitting"
|
||||
append-to-body
|
||||
@submit="handleActionSubmit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import dayjs from 'dayjs';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import type { PaginationData } from '@sa/hooks';
|
||||
import { getStatusTagType } from '@/constants/status-tag';
|
||||
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
export type WorkReportType = Api.WorkReport.Common.ReportType;
|
||||
export type WorkReportRow =
|
||||
| Api.WorkReport.Weekly.WeeklyReport
|
||||
@@ -83,6 +86,40 @@ export function formatDateTime(value?: string | null) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '--';
|
||||
}
|
||||
|
||||
export function formatPeriodDateRange(
|
||||
rowOrStart: Pick<WorkReportRow, 'periodStartDate' | 'periodEndDate'> | string | null | undefined,
|
||||
endDate?: string | null
|
||||
) {
|
||||
const startDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodStartDate : rowOrStart;
|
||||
const rangeEndDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodEndDate : endDate;
|
||||
const startText = formatDate(startDate);
|
||||
const endText = formatDate(rangeEndDate);
|
||||
|
||||
if (startText === '--' && endText === '--') {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return `${startText} 至 ${endText}`;
|
||||
}
|
||||
|
||||
export function formatWeeklyPeriodLabel(
|
||||
rowOrStart: Pick<WorkReportRow, 'periodLabel' | 'periodStartDate' | 'periodEndDate'> | string | null | undefined,
|
||||
endDate?: string | null,
|
||||
periodLabel?: string | null
|
||||
) {
|
||||
const startDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodStartDate : rowOrStart;
|
||||
const rangeEndDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodEndDate : endDate;
|
||||
const fallbackLabel = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodLabel : periodLabel;
|
||||
const referenceDate = dayjs(startDate || rangeEndDate);
|
||||
|
||||
if (referenceDate.isValid()) {
|
||||
const weekYear = referenceDate.startOf('isoWeek').add(3, 'day').format('YYYY');
|
||||
return `${weekYear}年第${String(referenceDate.isoWeek()).padStart(2, '0')}周`;
|
||||
}
|
||||
|
||||
return formatPeriodLabel(fallbackLabel) || formatPeriodDateRange(startDate, rangeEndDate);
|
||||
}
|
||||
|
||||
export function formatPeriodLabel(value?: string | null) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
|
||||
@@ -54,7 +54,7 @@ export function buildWeeklyPeriodFromDate(date: string | dayjs.Dayjs) {
|
||||
const start = selectedDate.startOf('isoWeek');
|
||||
const end = selectedDate.endOf('isoWeek');
|
||||
|
||||
return buildPeriod('weekly', start, end, `${formatRangeLabel(start, end)} 周报`);
|
||||
return buildPeriod('weekly', start, end, formatRangeLabel(start, end));
|
||||
}
|
||||
|
||||
export function buildMonthlyPeriodFromMonth(month: string | dayjs.Dayjs) {
|
||||
@@ -62,7 +62,7 @@ export function buildMonthlyPeriodFromMonth(month: string | dayjs.Dayjs) {
|
||||
const start = selectedMonth.startOf('month');
|
||||
const end = selectedMonth.endOf('month');
|
||||
|
||||
return buildPeriod('monthly', start, end, `${selectedMonth.format('YYYY-MM')} 月报`);
|
||||
return buildPeriod('monthly', start, end, selectedMonth.format('YYYY-MM'));
|
||||
}
|
||||
|
||||
export function buildProjectPeriodFromMonth(month: string | dayjs.Dayjs, flag: number) {
|
||||
@@ -92,14 +92,14 @@ export function getWeeklyPeriodOptions(now = dayjs()): WorkReportPeriodOption[]
|
||||
label: '本周',
|
||||
description: formatRangeLabel(thisWeekStart, thisWeekEnd),
|
||||
reportType: 'weekly',
|
||||
period: buildPeriod('weekly', thisWeekStart, thisWeekEnd, `${formatRangeLabel(thisWeekStart, thisWeekEnd)} 周报`)
|
||||
period: buildPeriod('weekly', thisWeekStart, thisWeekEnd, formatRangeLabel(thisWeekStart, thisWeekEnd))
|
||||
},
|
||||
{
|
||||
key: 'last-week',
|
||||
label: '上周',
|
||||
description: formatRangeLabel(lastWeekStart, lastWeekEnd),
|
||||
reportType: 'weekly',
|
||||
period: buildPeriod('weekly', lastWeekStart, lastWeekEnd, `${formatRangeLabel(lastWeekStart, lastWeekEnd)} 周报`)
|
||||
period: buildPeriod('weekly', lastWeekStart, lastWeekEnd, formatRangeLabel(lastWeekStart, lastWeekEnd))
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -117,14 +117,14 @@ export function getMonthlyPeriodOptions(now = dayjs()): WorkReportPeriodOption[]
|
||||
label: '本月',
|
||||
description: thisMonthStart.format('YYYY-MM'),
|
||||
reportType: 'monthly',
|
||||
period: buildPeriod('monthly', thisMonthStart, thisMonthEnd, `${thisMonthStart.format('YYYY-MM')} 月报`)
|
||||
period: buildPeriod('monthly', thisMonthStart, thisMonthEnd, thisMonthStart.format('YYYY-MM'))
|
||||
},
|
||||
{
|
||||
key: 'last-month',
|
||||
label: '上月',
|
||||
description: lastMonthStart.format('YYYY-MM'),
|
||||
reportType: 'monthly',
|
||||
period: buildPeriod('monthly', lastMonthStart, lastMonthEnd, `${lastMonthStart.format('YYYY-MM')} 月报`)
|
||||
period: buildPeriod('monthly', lastMonthStart, lastMonthEnd, lastMonthStart.format('YYYY-MM'))
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -20,12 +20,13 @@ import {
|
||||
formatDateTime,
|
||||
formatEmptyText,
|
||||
formatPeriod,
|
||||
formatPeriodDateRange,
|
||||
formatWeeklyPeriodLabel,
|
||||
getWorkReportStatusLabel,
|
||||
resolveExportFilename,
|
||||
resolveWorkReportStatusTagType,
|
||||
transformWorkReportPage
|
||||
} from '../shared/types';
|
||||
import { getIsoWeekDisplay } from '../shared/utils';
|
||||
import WeeklyReportSearch from './modules/search-panel.vue';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
@@ -73,9 +74,9 @@ const table = useUIPaginatedTable<
|
||||
label: '周期',
|
||||
minWidth: 150,
|
||||
formatter: row => {
|
||||
const periodText = formatPeriod(row);
|
||||
const weekLabel = getIsoWeekDisplay(row.periodStartDate);
|
||||
if (!weekLabel) return periodText;
|
||||
const periodText = formatWeeklyPeriodLabel(row);
|
||||
const weekLabel = formatPeriodDateRange(row);
|
||||
if (!weekLabel || weekLabel === '--') return periodText;
|
||||
return (
|
||||
<ElTooltip content={weekLabel} placement="top">
|
||||
<span>{periodText}</span>
|
||||
@@ -85,7 +86,7 @@ const table = useUIPaginatedTable<
|
||||
},
|
||||
{
|
||||
prop: 'reporterDeptName',
|
||||
label: '部门/方向',
|
||||
label: '部门',
|
||||
minWidth: 80,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => row.reporterDeptName || '--'
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-mutating-props, unicorn/prefer-dom-node-text-content, no-useless-escape, no-nested-ternary */
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetMyParticipatedProjectPage } from '@/service/api';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import {
|
||||
type WorkReportStructuredSection,
|
||||
type WorkReportStructuredTask,
|
||||
formatPeriodDateRange,
|
||||
formatPeriodLabel,
|
||||
getStructuredSections,
|
||||
getStructuredTasks
|
||||
@@ -68,6 +70,19 @@ interface StructuredTask {
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
interface PlanTaskDraft {
|
||||
title: string;
|
||||
detail: string;
|
||||
priority: StructuredTask['priority'];
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface PlanSectionDraft {
|
||||
category: string;
|
||||
tasks: StructuredTask[];
|
||||
draft: PlanTaskDraft;
|
||||
}
|
||||
|
||||
interface StructuredSection {
|
||||
category: string;
|
||||
tasks: StructuredTask[];
|
||||
@@ -76,7 +91,6 @@ interface StructuredSection {
|
||||
interface TravelSegment {
|
||||
dateRange: [string, string] | null;
|
||||
days: number | null;
|
||||
location: string;
|
||||
workItem: string;
|
||||
/** 是否为用户新增的分段,新增的才显示删除按钮 */
|
||||
isNew?: boolean;
|
||||
@@ -87,6 +101,8 @@ const travelDialogVisible = ref(false);
|
||||
const isReadonly = computed(() => props.mode === 'detail' || props.scene === 'approval');
|
||||
const { dictData: priorityDictData, getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
|
||||
const { getLabel: getTaskItemTypeLabel } = useDict(RDMS_TASK_ITEM_TYPE_DICT_CODE);
|
||||
const DEFAULT_SECTION_CATEGORY = '工作内容';
|
||||
const TRAVEL_SECTION_CATEGORY = '差旅';
|
||||
|
||||
const mainForm = reactive({
|
||||
get reporter() {
|
||||
@@ -111,36 +127,62 @@ const businessTripValue = computed({
|
||||
|
||||
const EMPTY_HTML = '本周期内暂无数据';
|
||||
const TRAVEL_REVIEW_ITEM_TITLE = '本周差旅';
|
||||
/** 工作事项下拉里的"我的事项"固定项,用于区分纯个人事务。 */
|
||||
const MY_AFFAIRS_TITLE = '我的事项';
|
||||
|
||||
const planForm = ref({
|
||||
workItem: '',
|
||||
supportNeed: '',
|
||||
tasks: [] as StructuredTask[]
|
||||
sections: [] as PlanSectionDraft[]
|
||||
});
|
||||
const activePlanSectionIndex = ref(-1);
|
||||
|
||||
const planTaskForm = ref({
|
||||
title: '',
|
||||
detail: '',
|
||||
priority: '2' as StructuredTask['priority'],
|
||||
progress: 0
|
||||
});
|
||||
function createPlanTaskDraft(): PlanTaskDraft {
|
||||
return { title: '', detail: '', priority: '2', progress: 0 };
|
||||
}
|
||||
|
||||
const travelSegments = ref<TravelSegment[]>([]);
|
||||
const planDialogVisible = ref(false);
|
||||
|
||||
/** 「我参与的项目」项目名清单,供“新增计划”工作事项下拉使用。 */
|
||||
const participatedProjectNames = ref<string[]>([]);
|
||||
|
||||
async function loadParticipatedProjectNames() {
|
||||
// pageSize=-1 一次拉全部(不分页),由后端按"进行中 + 创建时间升序"过滤排序。
|
||||
const { data, error } = await fetchGetMyParticipatedProjectPage({ pageNo: 1, pageSize: -1 });
|
||||
if (error || !data) {
|
||||
participatedProjectNames.value = [];
|
||||
return;
|
||||
}
|
||||
participatedProjectNames.value = data.list
|
||||
.map(project => project.name?.trim())
|
||||
.filter((name): name is string => Boolean(name));
|
||||
}
|
||||
|
||||
onMounted(loadParticipatedProjectNames);
|
||||
|
||||
const reviewWorkItemOptions = computed(() => {
|
||||
const titles = new Set<string>();
|
||||
(props.baseInfo?.reviewItems || []).forEach(item => {
|
||||
const title = item.itemTitle?.trim();
|
||||
if (title && title !== TRAVEL_REVIEW_ITEM_TITLE) titles.add(title);
|
||||
});
|
||||
(props.model.reviewItems || []).forEach(item => {
|
||||
const title = item.itemTitle?.trim();
|
||||
if (title && title !== TRAVEL_REVIEW_ITEM_TITLE) titles.add(title);
|
||||
});
|
||||
return Array.from(titles);
|
||||
// 下拉数据来自「我参与的项目」API 拉取的项目名 + 「我的事项」固定项,
|
||||
// 供“新增计划-工作事项”下拉使用。
|
||||
const names = new Set<string>(participatedProjectNames.value);
|
||||
return [MY_AFFAIRS_TITLE, ...Array.from(names).filter(name => name !== MY_AFFAIRS_TITLE)];
|
||||
});
|
||||
|
||||
const reviewItems = computed<ReviewItem[]>(() => {
|
||||
return (props.model.reviewItems || []).map(toReviewItem);
|
||||
});
|
||||
|
||||
const travelWorkItemOptions = computed(() => {
|
||||
const names = reviewItems.value
|
||||
.map(item => item.workItem.trim())
|
||||
.filter(name => Boolean(name) && name !== TRAVEL_REVIEW_ITEM_TITLE && name !== MY_AFFAIRS_TITLE);
|
||||
|
||||
return [...new Set(names), MY_AFFAIRS_TITLE];
|
||||
});
|
||||
|
||||
/** 选中"我的事项"时,事项不参与项目优先级,需要隐藏优先级相关展示。 */
|
||||
const isMyAffairsPlanItem = (workItem: string) => workItem.trim() === MY_AFFAIRS_TITLE;
|
||||
|
||||
function normalizeTravelDays(value: unknown) {
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue) || numberValue <= 0) return null;
|
||||
@@ -171,7 +213,6 @@ function toTravelSegments(segments: Api.WorkReport.Weekly.WeeklyReportTravelSegm
|
||||
? [normalizeTravelDateText(item.startDate), normalizeTravelDateText(item.endDate)]
|
||||
: null,
|
||||
days: normalizeTravelDays(item.travelDays),
|
||||
location: item.location || '',
|
||||
workItem: ''
|
||||
/* 默认带入的分段不带 isNew,不可删除 */
|
||||
}));
|
||||
@@ -188,8 +229,7 @@ function toModelTravelSegments(segments: TravelSegment[]): Api.WorkReport.Weekly
|
||||
sort: index + 1,
|
||||
startDate: normalizeTravelDateText(item.dateRange?.[0]) || '',
|
||||
endDate: normalizeTravelDateText(item.dateRange?.[1]) || '',
|
||||
travelDays: normalizeTravelDays(item.days) || 0,
|
||||
location: item.location.trim()
|
||||
travelDays: normalizeTravelDays(item.days) || 0
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -206,8 +246,7 @@ function isSameTravelSegments(
|
||||
return (
|
||||
item.startDate === normalizeTravelDateText(targetItem.startDate) &&
|
||||
item.endDate === normalizeTravelDateText(targetItem.endDate) &&
|
||||
Number(item.travelDays || 0) === Number(targetItem.travelDays || 0) &&
|
||||
item.location === (targetItem.location || '')
|
||||
Number(item.travelDays || 0) === Number(targetItem.travelDays || 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -260,9 +299,14 @@ function normalizeTask(task: WorkReportStructuredTask): StructuredTask {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSectionCategory(value?: string | null, fallback = DEFAULT_SECTION_CATEGORY) {
|
||||
const category = resolveTaskItemTypeLabel(value).trim();
|
||||
return category || fallback;
|
||||
}
|
||||
|
||||
function normalizeSection(section: WorkReportStructuredSection): StructuredSection {
|
||||
return {
|
||||
category: section.category || '未分类',
|
||||
category: normalizeSectionCategory(section.category, '未分类'),
|
||||
tasks: section.tasks.map(normalizeTask)
|
||||
};
|
||||
}
|
||||
@@ -271,7 +315,7 @@ function mergeSectionsByCategory(sections: StructuredSection[]) {
|
||||
const sectionMap = new Map<string, StructuredSection>();
|
||||
|
||||
sections.forEach(section => {
|
||||
const category = section.category.trim() || '未分类';
|
||||
const category = normalizeSectionCategory(section.category, '未分类');
|
||||
const existing = sectionMap.get(category);
|
||||
if (existing) {
|
||||
existing.tasks.push(...section.tasks);
|
||||
@@ -337,13 +381,22 @@ function formatStructuredTaskDisplayLine(task: StructuredTask, index: number, sh
|
||||
return `${index + 1}、${formatStructuredTaskLineV2(task, showHours)}`;
|
||||
}
|
||||
|
||||
const DEFAULT_SECTION_CATEGORY = '工作内容';
|
||||
const TRAVEL_SECTION_CATEGORY = '差旅';
|
||||
// 周报工作日志弹层展示:后端用中文分号 ";" 拼接多条工作日志,
|
||||
// 在 popover 中按行展示,每条工作日志仍保留末尾的分号。
|
||||
function formatWorkLogDetail(detail: string): string {
|
||||
if (!detail) return '';
|
||||
// 仅按中文分号切分,避免误伤文本中的其他标点;每段保持原样展示。
|
||||
return detail
|
||||
.split(';')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
.join(';\n');
|
||||
}
|
||||
|
||||
function createStructuredTextV2(sections: StructuredSection[], showHours = false) {
|
||||
return sections
|
||||
.map(section => {
|
||||
const categoryLabel = resolveTaskItemTypeLabel(section.category).trim();
|
||||
const categoryLabel = normalizeSectionCategory(section.category);
|
||||
return [
|
||||
`#${categoryLabel}`,
|
||||
...section.tasks.map((task, index) => `${index + 1}、${formatStructuredTaskLineV2(task, showHours)}`)
|
||||
@@ -357,7 +410,7 @@ function createStructuredTextV2(sections: StructuredSection[], showHours = false
|
||||
function createStructuredHtmlV2(sections: StructuredSection[], showHours = false) {
|
||||
return sections
|
||||
.map(section => {
|
||||
const categoryLabel = resolveTaskItemTypeLabel(section.category.trim());
|
||||
const categoryLabel = normalizeSectionCategory(section.category);
|
||||
const tasks = section.tasks
|
||||
.map(
|
||||
(task, index) =>
|
||||
@@ -383,14 +436,6 @@ function createSectionsJson(sections: StructuredSection[]) {
|
||||
return sections.length ? JSON.stringify({ sections }) : null;
|
||||
}
|
||||
|
||||
function getTaskMetricLabels(task: StructuredTask, showHours = false) {
|
||||
return [
|
||||
task.priority ? resolvePriorityLabel(task.priority) : '',
|
||||
typeof task.progress === 'number' ? `进度${task.progress}%` : '',
|
||||
showHours && typeof task.hours === 'number' ? `${task.hours}h` : ''
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function createTaskMetrics(task: StructuredTask, showHours: boolean) {
|
||||
const metrics = [
|
||||
task.priority
|
||||
@@ -406,28 +451,6 @@ function isTravelTask(task: StructuredTask) {
|
||||
return task.kind === 'travel';
|
||||
}
|
||||
|
||||
function hasTravelTasks(tasks: StructuredTask[]) {
|
||||
return tasks.some(isTravelTask);
|
||||
}
|
||||
|
||||
function createStructuredHtml(tasks: StructuredTask[], showHours = false) {
|
||||
return tasks
|
||||
.map(task => {
|
||||
const title = escapeHtml(task.title.trim());
|
||||
const detail = escapeHtml(task.detail.trim());
|
||||
return `
|
||||
<div class="rich-task">
|
||||
<div class="rich-task-head">
|
||||
<div class="rich-task-title">${title}</div>
|
||||
${createTaskMetrics(task, showHours)}
|
||||
</div>
|
||||
${detail && detail !== title ? `<div class="rich-task-detail">${detail}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function normalizeEditorText(value: string) {
|
||||
return value
|
||||
.replace(/\u00A0/g, ' ')
|
||||
@@ -488,7 +511,10 @@ function resolveTaskMetrics(metricsText: string, fallback?: Partial<StructuredTa
|
||||
.filter(Boolean);
|
||||
const priorityText = metricsText.match(/\bP\d+\b/iu)?.[0] || metricParts.find(item => /^P?\d+$/iu.test(item));
|
||||
const priority = normalizePriorityCode(priorityText || (useFallback ? fallback?.priority : undefined));
|
||||
const progressText = metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1];
|
||||
// 同时支持 "进度 XX%" 与裸 "XX%",避免失焦后丢失进度。
|
||||
const progressText =
|
||||
metricsText.match(/进度\s*(\d+(?:\.\d+)?)%/u)?.[1] ||
|
||||
metricParts.find(item => /^\d+(?:\.\d+)?%$/u.test(item))?.replace(/%$/u, '');
|
||||
const hoursText = metricsText.match(/(\d+(?:\.\d+)?)h/u)?.[1];
|
||||
|
||||
return {
|
||||
@@ -533,7 +559,7 @@ function createStructuredSectionsFromTextV2(
|
||||
|
||||
const sections: StructuredSection[] = [];
|
||||
const ensureSection = (categoryText: string) => {
|
||||
const category = categoryText.trim() || defaultCategory;
|
||||
const category = normalizeSectionCategory(categoryText, defaultCategory);
|
||||
let section = sections.find(item => item.category === category);
|
||||
if (!section) {
|
||||
section = { category, tasks: [] };
|
||||
@@ -554,7 +580,9 @@ function createStructuredSectionsFromTextV2(
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyMatch = trimmedLine.match(/^(.+?)\s*[--]\s*(.+)$/u);
|
||||
// 仅当行首不是结构化任务前缀(如 "3、")时,才按旧式 "<分类> - <事项>" 解析;
|
||||
// 否则会把 "2026-06-12 - 2026-06-19" 这种含 " - " 的出差行误判为分类。
|
||||
const legacyMatch = trimmedLine.match(/^(?!\d+[、..]\s*)(.+?)\s*[--]\s*(.+)$/u);
|
||||
if (legacyMatch) {
|
||||
const [, rawCategory, rawTaskText] = legacyMatch;
|
||||
const category = rawCategory.trim();
|
||||
@@ -613,7 +641,7 @@ function parseStructuredSectionsFromEditorV2(
|
||||
|
||||
const fallbackLookup = createFallbackTaskLookup(fallbackSections);
|
||||
|
||||
return parsedSections.map((section, sectionIndex) => {
|
||||
const merged: StructuredSection[] = parsedSections.map((section, sectionIndex) => {
|
||||
const fallbackSection = fallbackSections[sectionIndex];
|
||||
const categoryKey = resolveTaskItemTypeLabel(section.category).trim();
|
||||
|
||||
@@ -634,47 +662,35 @@ function parseStructuredSectionsFromEditorV2(
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
// 将出差任务从其他分类中抽离,统一归到独立的"差旅"段落,
|
||||
// 避免失焦后被错误归类到上一个分类下。
|
||||
return groupTravelTasksIntoSection(merged);
|
||||
}
|
||||
|
||||
function parseStructuredTasksFromEditor(editor: HTMLElement, fallbackTasks: StructuredTask[] = []) {
|
||||
const taskElements = Array.from(editor.querySelectorAll<HTMLElement>('.rich-task'));
|
||||
function groupTravelTasksIntoSection(sections: StructuredSection[]) {
|
||||
const travelTasks: StructuredTask[] = [];
|
||||
const remainingSections = sections.map(section => {
|
||||
const kept = section.tasks.filter(task => {
|
||||
if (isTravelTask(task)) {
|
||||
travelTasks.push(task);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { category: section.category, tasks: kept };
|
||||
});
|
||||
|
||||
if (!taskElements.length) {
|
||||
return createStructuredTasksFromText(editor.innerText);
|
||||
if (!travelTasks.length) return remainingSections;
|
||||
|
||||
const existingTravelIndex = remainingSections.findIndex(section => section.category === TRAVEL_SECTION_CATEGORY);
|
||||
if (existingTravelIndex >= 0) {
|
||||
remainingSections[existingTravelIndex].tasks = [...remainingSections[existingTravelIndex].tasks, ...travelTasks];
|
||||
} else {
|
||||
remainingSections.push({ category: TRAVEL_SECTION_CATEGORY, tasks: travelTasks });
|
||||
}
|
||||
|
||||
return taskElements
|
||||
.map((taskElement, index) => {
|
||||
const fallback = fallbackTasks[index];
|
||||
const title = getElementText(taskElement.querySelector('.rich-task-title'));
|
||||
const detail = getElementText(taskElement.querySelector('.rich-task-detail'));
|
||||
const metrics = resolveTaskMetrics(getElementText(taskElement.querySelector('.rich-task-metas')), fallback);
|
||||
|
||||
if (!title && !detail) return null;
|
||||
|
||||
return {
|
||||
title: title || fallback?.title || '未命名事项',
|
||||
detail,
|
||||
...metrics
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as StructuredTask[];
|
||||
}
|
||||
|
||||
function createTasksJson(tasks: StructuredTask[]) {
|
||||
return tasks.length ? JSON.stringify({ tasks }) : null;
|
||||
}
|
||||
|
||||
function getReviewItemTasks(item: Api.WorkReport.Common.PersonalReportReviewItem) {
|
||||
const jsonTasks = getStructuredTasks(item.contentJson).map(normalizeTask);
|
||||
if (jsonTasks.length) return jsonTasks;
|
||||
const jsonSections = getStructuredSections(item.contentJson).map(normalizeSection);
|
||||
if (jsonSections.length) return flattenSectionTasks(jsonSections);
|
||||
|
||||
return createStructuredTasksFromText(item.contentText || '', {
|
||||
title: item.itemTitle || '未命名工作',
|
||||
hours: typeof item.workHours === 'number' ? item.workHours : Number(item.workHours || 0) || undefined
|
||||
});
|
||||
return remainingSections.filter(section => section.tasks.length);
|
||||
}
|
||||
|
||||
function getReviewItemSections(item: Api.WorkReport.Common.PersonalReportReviewItem) {
|
||||
@@ -686,6 +702,13 @@ function getReviewItemSections(item: Api.WorkReport.Common.PersonalReportReviewI
|
||||
});
|
||||
}
|
||||
|
||||
function getPlanItemSections(item: Api.WorkReport.Common.PersonalReportPlanItem) {
|
||||
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.targetJson).map(normalizeSection));
|
||||
if (structuredSections.length) return structuredSections;
|
||||
|
||||
return createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || DEFAULT_SECTION_CATEGORY);
|
||||
}
|
||||
|
||||
function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): ReviewItem {
|
||||
const contentSections = getReviewItemSections(item);
|
||||
const contentTasks = flattenSectionTasks(contentSections);
|
||||
@@ -708,10 +731,7 @@ function toReviewItem(item: Api.WorkReport.Common.PersonalReportReviewItem): Rev
|
||||
}
|
||||
|
||||
function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: number): PlanItem {
|
||||
const structuredSections = mergeSectionsByCategory(getStructuredSections(item.targetJson).map(normalizeSection));
|
||||
const targetSections = structuredSections.length
|
||||
? structuredSections
|
||||
: createStructuredSectionsFromTextV2(item.targetText || '', item.itemTitle || '未分类');
|
||||
const targetSections = getPlanItemSections(item);
|
||||
const targetTasks = flattenSectionTasks(targetSections);
|
||||
const targetHtml = targetSections.length
|
||||
? createStructuredHtmlV2(targetSections)
|
||||
@@ -731,10 +751,6 @@ function toPlanItem(item: Api.WorkReport.Common.PersonalReportPlanItem, index: n
|
||||
};
|
||||
}
|
||||
|
||||
const reviewItems = computed<ReviewItem[]>(() => {
|
||||
return (props.model.reviewItems || []).map(toReviewItem);
|
||||
});
|
||||
|
||||
const nextPlans = computed<PlanItem[]>(() => {
|
||||
return (props.model.planItems || []).map(toPlanItem);
|
||||
});
|
||||
@@ -745,7 +761,15 @@ const totalHours = computed(() => {
|
||||
|
||||
return (props.model.reviewItems || []).reduce((sum, item) => sum + Number(item.workHours || 0), 0);
|
||||
});
|
||||
const periodText = computed(() => formatPeriodLabel(props.model.periodLabel || props.period));
|
||||
const periodText = computed(() => {
|
||||
const rangeText = formatPeriodDateRange(
|
||||
props.model.periodStartDate || undefined,
|
||||
props.model.periodEndDate || undefined
|
||||
);
|
||||
if (rangeText !== '--') return rangeText;
|
||||
|
||||
return formatPeriodLabel(props.model.periodLabel || props.period) || '--';
|
||||
});
|
||||
const totalTravelDays = computed(() => {
|
||||
const total = travelSegments.value.reduce((sum, segment) => sum + Number(segment.days || 0), 0);
|
||||
return Math.round(total * 10) / 10;
|
||||
@@ -819,6 +843,11 @@ function appendTasksToSections(
|
||||
|
||||
if (!tasks.length) return nextSections;
|
||||
|
||||
// 出差任务始终归到独立的"差旅"段落,避免被误放到上一个普通分类下。
|
||||
const travelTasks = tasks.filter(isTravelTask);
|
||||
const otherTasks = tasks.filter(task => !isTravelTask(task));
|
||||
|
||||
if (otherTasks.length) {
|
||||
const targetSection =
|
||||
nextSections[nextSections.length - 1] ||
|
||||
(() => {
|
||||
@@ -826,8 +855,20 @@ function appendTasksToSections(
|
||||
nextSections.push(section);
|
||||
return section;
|
||||
})();
|
||||
targetSection.tasks.push(...otherTasks);
|
||||
}
|
||||
|
||||
if (travelTasks.length) {
|
||||
const travelSection =
|
||||
nextSections.find(section => section.category === TRAVEL_SECTION_CATEGORY) ||
|
||||
(() => {
|
||||
const section = { category: TRAVEL_SECTION_CATEGORY, tasks: [] as StructuredTask[] };
|
||||
nextSections.push(section);
|
||||
return section;
|
||||
})();
|
||||
travelSection.tasks.push(...travelTasks);
|
||||
}
|
||||
|
||||
targetSection.tasks.push(...tasks);
|
||||
return nextSections;
|
||||
}
|
||||
|
||||
@@ -869,8 +910,7 @@ function addTravelSegment() {
|
||||
travelSegments.value.push({
|
||||
dateRange: null,
|
||||
days: null,
|
||||
location: '',
|
||||
workItem: reviewWorkItemOptions.value[0] || '',
|
||||
workItem: travelWorkItemOptions.value[0] || MY_AFFAIRS_TITLE,
|
||||
isNew: true
|
||||
});
|
||||
}
|
||||
@@ -880,8 +920,12 @@ function removeTravelSegment(index: number) {
|
||||
}
|
||||
|
||||
function resetPlanForm() {
|
||||
planForm.value = { workItem: '', supportNeed: '', tasks: [] };
|
||||
planTaskForm.value = { title: '', detail: '', priority: '2', progress: 0 };
|
||||
planForm.value = {
|
||||
workItem: '',
|
||||
supportNeed: '',
|
||||
sections: [{ category: '', tasks: [], draft: createPlanTaskDraft() }]
|
||||
};
|
||||
activePlanSectionIndex.value = 0;
|
||||
}
|
||||
|
||||
function showInlinePlanForm() {
|
||||
@@ -894,35 +938,55 @@ function cancelInlinePlan() {
|
||||
resetPlanForm();
|
||||
}
|
||||
|
||||
function addPlanTask() {
|
||||
planForm.value.tasks.push({ ...planTaskForm.value, priority: normalizePriorityCode(planTaskForm.value.priority) });
|
||||
planTaskForm.value = { title: '', detail: '', priority: '2', progress: 0 };
|
||||
function addPlanSection() {
|
||||
planForm.value.sections.push({ category: '', tasks: [], draft: createPlanTaskDraft() });
|
||||
activePlanSectionIndex.value = planForm.value.sections.length - 1;
|
||||
}
|
||||
|
||||
function removePlanTask(index: number) {
|
||||
planForm.value.tasks.splice(index, 1);
|
||||
function removePlanSection(index: number) {
|
||||
planForm.value.sections.splice(index, 1);
|
||||
if (activePlanSectionIndex.value === index) {
|
||||
activePlanSectionIndex.value = -1;
|
||||
} else if (activePlanSectionIndex.value > index) {
|
||||
activePlanSectionIndex.value -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function addPlanTask(sectionIndex: number) {
|
||||
const section = planForm.value.sections[sectionIndex];
|
||||
if (!section?.draft.title.trim()) return;
|
||||
const task: StructuredTask = {
|
||||
title: section.draft.title.trim(),
|
||||
detail: section.draft.detail.trim(),
|
||||
progress: section.draft.progress
|
||||
};
|
||||
// 我的事项不参与项目优先级,存进任务前显式剔除,结构化展示括号里就不会出现优先级。
|
||||
if (!isMyAffairsPlanItem(planForm.value.workItem)) {
|
||||
task.priority = normalizePriorityCode(section.draft.priority);
|
||||
}
|
||||
section.tasks.push(task);
|
||||
section.draft = createPlanTaskDraft();
|
||||
}
|
||||
|
||||
function removePlanTask(sectionIndex: number, taskIndex: number) {
|
||||
planForm.value.sections[sectionIndex]?.tasks.splice(taskIndex, 1);
|
||||
}
|
||||
|
||||
function submitInlinePlan() {
|
||||
const sections: StructuredSection[] = [
|
||||
{
|
||||
category: DEFAULT_SECTION_CATEGORY,
|
||||
tasks: planForm.value.tasks.map(task => ({ ...task }))
|
||||
}
|
||||
];
|
||||
const sections: StructuredSection[] = planForm.value.sections
|
||||
.filter(section => section.category.trim() && section.tasks.length)
|
||||
.map(section => ({
|
||||
category: normalizeSectionCategory(section.category),
|
||||
tasks: section.tasks.map(task => ({ ...task }))
|
||||
}));
|
||||
if (!sections.length) return;
|
||||
const target = createStructuredTextV2(sections);
|
||||
const workItem = planForm.value.workItem.trim();
|
||||
const sameWorkItem = props.model.planItems.find(item => item.itemTitle.trim() === workItem);
|
||||
|
||||
if (sameWorkItem) {
|
||||
const existingSections = mergeSectionsByCategory(
|
||||
getStructuredSections(sameWorkItem.targetJson).map(normalizeSection)
|
||||
);
|
||||
const mergedSections = appendTasksToSections(
|
||||
existingSections,
|
||||
sections[0].tasks,
|
||||
existingSections.at(-1)?.category || DEFAULT_SECTION_CATEGORY
|
||||
);
|
||||
const existingSections = getPlanItemSections(sameWorkItem);
|
||||
const mergedSections = mergeSectionsByCategory([...existingSections, ...sections]);
|
||||
sameWorkItem.targetText = createStructuredTextV2(mergedSections);
|
||||
sameWorkItem.targetJson = createSectionsJson(mergedSections);
|
||||
if (planForm.value.supportNeed.trim()) {
|
||||
@@ -1048,7 +1112,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
<ElInput v-model="mainForm.reporter" disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>部门/方向</label>
|
||||
<label>部门</label>
|
||||
<ElInput v-model="mainForm.deptName" disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -1126,7 +1190,9 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
{{ formatStructuredTaskDisplayLine(task, taskIndex, true) }}
|
||||
</div>
|
||||
</template>
|
||||
<div class="structured-preview__popover">{{ task.detail || '暂无内容' }}</div>
|
||||
<div class="structured-preview__popover">
|
||||
{{ formatWorkLogDetail(task.detail) || '暂无内容' }}
|
||||
</div>
|
||||
</ElPopover>
|
||||
<div v-else class="rich-task-line">
|
||||
{{ formatStructuredTaskDisplayLine(task, taskIndex, true) }}
|
||||
@@ -1223,7 +1289,9 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
{{ formatStructuredTaskDisplayLine(task, taskIndex) }}
|
||||
</div>
|
||||
</template>
|
||||
<div class="structured-preview__popover">{{ task.detail || '暂无内容' }}</div>
|
||||
<div class="structured-preview__popover">
|
||||
{{ formatWorkLogDetail(task.detail) || '暂无内容' }}
|
||||
</div>
|
||||
</ElPopover>
|
||||
<div v-else class="rich-task-line">
|
||||
{{ formatStructuredTaskDisplayLine(task, taskIndex) }}
|
||||
@@ -1282,55 +1350,101 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
title="新增下周期重点工作计划"
|
||||
preset="md"
|
||||
confirm-text="确认新增"
|
||||
:confirm-disabled="!planForm.workItem.trim() || !planForm.tasks.length"
|
||||
:confirm-disabled="
|
||||
!planForm.workItem.trim() || !planForm.sections.some(section => section.category.trim() && section.tasks.length)
|
||||
"
|
||||
@confirm="submitInlinePlan"
|
||||
@cancel="cancelInlinePlan"
|
||||
>
|
||||
<div class="plan-dialog-form">
|
||||
<div class="field">
|
||||
<label>项目名/事项名</label>
|
||||
<ElInput
|
||||
<label>工作事项</label>
|
||||
<ElSelect
|
||||
v-model="planForm.workItem"
|
||||
class="inline-plan-name-input"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入项目名或我的事项"
|
||||
/>
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="请选择项目名/我的事项"
|
||||
>
|
||||
<ElOption v-for="item in reviewWorkItemOptions" :key="item" :label="item" :value="item" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>具体目标</label>
|
||||
<div class="plan-tasks">
|
||||
<div v-for="(task, tIdx) in planForm.tasks" :key="tIdx" class="plan-task-item">
|
||||
<div class="plan-task-item-head">
|
||||
<div class="plan-sections">
|
||||
<div
|
||||
v-for="(section, sIdx) in planForm.sections"
|
||||
:key="sIdx"
|
||||
class="plan-section"
|
||||
:class="{ active: activePlanSectionIndex === sIdx }"
|
||||
>
|
||||
<div class="plan-section-head">
|
||||
<div class="field plan-section-head-field">
|
||||
<label>类别</label>
|
||||
<DictSelect
|
||||
v-model="section.category"
|
||||
:dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE"
|
||||
size="small"
|
||||
placeholder="选择类别"
|
||||
style="width: 100%"
|
||||
@focus="activePlanSectionIndex = sIdx"
|
||||
@change="activePlanSectionIndex = sIdx"
|
||||
/>
|
||||
</div>
|
||||
<ElButton
|
||||
v-if="planForm.sections.length > 1"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="removePlanSection(sIdx)"
|
||||
>
|
||||
删除类别
|
||||
</ElButton>
|
||||
</div>
|
||||
<div v-for="(task, tIdx) in section.tasks" :key="tIdx" class="plan-task">
|
||||
<div class="plan-task-head">
|
||||
<span>{{ task.title }}</span>
|
||||
<div class="plan-task-metas">
|
||||
<template v-if="!isMyAffairsPlanItem(planForm.workItem)">
|
||||
<em>{{ resolvePriorityLabel(task.priority) }}</em>
|
||||
</template>
|
||||
<em>进度 {{ task.progress }}%</em>
|
||||
</div>
|
||||
<ElButton link type="danger" size="small" @click="removePlanTask(tIdx)">删除</ElButton>
|
||||
<ElButton link type="danger" size="small" @click="removePlanTask(sIdx, tIdx)">删除</ElButton>
|
||||
</div>
|
||||
<div v-if="task.detail" class="plan-task-item-detail">{{ task.detail }}</div>
|
||||
<div v-if="task.detail" class="plan-task-detail">{{ task.detail }}</div>
|
||||
</div>
|
||||
<div class="plan-task-form">
|
||||
<div class="inline-task-row">
|
||||
<div
|
||||
class="inline-task-row"
|
||||
:class="{ 'inline-task-row--my-affairs': isMyAffairsPlanItem(planForm.workItem) }"
|
||||
>
|
||||
<div class="field">
|
||||
<label>任务名/事项名</label>
|
||||
<ElInput v-model="planTaskForm.title" size="small" placeholder="请输入任务名或事项名" />
|
||||
<ElInput
|
||||
v-model="section.draft.title"
|
||||
size="small"
|
||||
placeholder="请输入任务名或事项名"
|
||||
@focus="activePlanSectionIndex = sIdx"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div v-if="!isMyAffairsPlanItem(planForm.workItem)" class="field">
|
||||
<label>优先级</label>
|
||||
<DictSelect
|
||||
v-model="planTaskForm.priority"
|
||||
v-model="section.draft.priority"
|
||||
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||||
:clearable="false"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
@focus="activePlanSectionIndex = sIdx"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>进度</label>
|
||||
<ElInputNumber
|
||||
v-model="planTaskForm.progress"
|
||||
v-model="section.draft.progress"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="5"
|
||||
@@ -1338,29 +1452,46 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
controls-position="right"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
@focus="activePlanSectionIndex = sIdx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>详细内容</label>
|
||||
<ElInput
|
||||
v-model="planTaskForm.detail"
|
||||
v-model="section.draft.detail"
|
||||
size="small"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="详细内容(可选)"
|
||||
@focus="activePlanSectionIndex = sIdx"
|
||||
/>
|
||||
</div>
|
||||
<ElButton
|
||||
class="dialog-inline-action"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
:disabled="!planTaskForm.title.trim()"
|
||||
@click="addPlanTask"
|
||||
:disabled="!section.draft.title.trim()"
|
||||
@click="addPlanTask(sIdx)"
|
||||
>
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
<span>添加</span>
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElButton
|
||||
v-if="!planForm.sections.length || planForm.sections.every(s => s.category.trim())"
|
||||
class="dialog-inline-action dialog-inline-action--secondary"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
@click="addPlanSection"
|
||||
>
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
<span>类别</span>
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>对他人协助的需求</label>
|
||||
@@ -1397,16 +1528,16 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
</div>
|
||||
<div class="travel-grid">
|
||||
<div class="field">
|
||||
<label>归属项目/事项</label>
|
||||
<label>工作事项</label>
|
||||
<ElSelect
|
||||
v-model="segment.workItem"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="请选择或输入项目名/事项名"
|
||||
placeholder="请选择或输入工作事项"
|
||||
>
|
||||
<ElOption v-for="item in reviewWorkItemOptions" :key="item" :label="item" :value="item" />
|
||||
<ElOption v-for="item in travelWorkItemOptions" :key="item" :label="item" :value="item" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
<div class="field travel-cycle-field">
|
||||
@@ -1436,7 +1567,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="field"><label>地点</label><ElInput v-model="segment.location" placeholder="请输入地点" /></div> -->
|
||||
</div>
|
||||
<ElButton
|
||||
class="travel-add-btn dialog-inline-action"
|
||||
@@ -2269,28 +2399,61 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-tasks {
|
||||
.inline-task-row--my-affairs {
|
||||
grid-template-columns: minmax(0, 1fr) 142px;
|
||||
}
|
||||
|
||||
.plan-sections {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.plan-task-item {
|
||||
.plan-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5edf1;
|
||||
border-radius: 12px;
|
||||
background: #fafcfd;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.plan-section.active {
|
||||
border-color: #cfe3e0;
|
||||
background: #f7fbfa;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.06);
|
||||
}
|
||||
|
||||
.plan-section-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.plan-section-head :deep(.el-select__wrapper) {
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.plan-task {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #e5edf1;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #eef2f6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfc;
|
||||
}
|
||||
|
||||
.plan-task-item-head {
|
||||
.plan-task-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-task-item-head span {
|
||||
.plan-task-head span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #14213d;
|
||||
@@ -2323,10 +2486,11 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.plan-task-item-detail {
|
||||
.plan-task-detail {
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.plan-task-form {
|
||||
@@ -2393,11 +2557,37 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.inline-travel-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
.travel-dialog-form :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border: 1px solid var(--el-border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.travel-dialog-form :deep(.el-input-number:focus-within) {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.travel-dialog-form :deep(.el-input-number .el-input__wrapper) {
|
||||
box-shadow: none !important;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.travel-dialog-form :deep(.el-input-number.is-controls-right .el-input-number__increase),
|
||||
.travel-dialog-form :deep(.el-input-number.is-controls-right .el-input-number__decrease) {
|
||||
right: 0;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.travel-dialog-form :deep(.el-input-number.is-controls-right .el-input-number__increase) {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.travel-dialog-form :deep(.el-input-number.is-controls-right .el-input-number__decrease) {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.inline-plan-name-input {
|
||||
@@ -2427,12 +2617,20 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
position: sticky;
|
||||
z-index: 5;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
margin-bottom: 0;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid #d8e0e8;
|
||||
background: #fff;
|
||||
border-bottom-left-radius: 18px;
|
||||
border-bottom-right-radius: 18px;
|
||||
background: #f5f7fa;
|
||||
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.approval-form-actions {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
|
||||
import {
|
||||
fetchApproveOvertimeApplication,
|
||||
fetchBatchApproveOvertimeApplication,
|
||||
fetchBatchRejectOvertimeApplication,
|
||||
fetchChangePersonalItemStatus,
|
||||
fetchChangeProjectTaskStatus,
|
||||
fetchGetMonthlyReportApprovalPage,
|
||||
@@ -28,13 +30,15 @@ import PersonalItemDetailDialog from '@/views/personal-center/my-item/modules/pe
|
||||
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 OvertimeApplicationActionDialog from '@/views/personal-center/overtime-application/modules/overtime-application-action-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 WorkReportPrototypePageDialog from '@/views/personal-center/work-report/shared/components/prototype-page-dialog.vue';
|
||||
import {
|
||||
WORK_REPORT_TYPE_LABEL,
|
||||
type WorkReportRow,
|
||||
type WorkReportType,
|
||||
formatPeriod
|
||||
formatPeriod,
|
||||
formatWeeklyPeriodLabel
|
||||
} from '@/views/personal-center/work-report/shared/types';
|
||||
import {
|
||||
type WorkbenchTodoDeadlineFilter,
|
||||
@@ -169,10 +173,19 @@ const overtimeActionVisible = ref(false);
|
||||
const overtimeActionSubmitting = ref(false);
|
||||
const currentOvertimeApplication = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||
const currentOvertimeActionType = ref<OvertimeApprovalActionType>('approve');
|
||||
const batchDetailVisible = ref(false);
|
||||
const batchActionVisible = ref(false);
|
||||
const batchSubmitting = ref(false);
|
||||
const workReportDetailVisible = ref(false);
|
||||
const currentWorkReport = ref<WorkReportRow | null>(null);
|
||||
const currentWorkReportType = ref<WorkReportType>('weekly');
|
||||
|
||||
// 批量审批选中状态(存原始加班申请 id,避免映射转换)
|
||||
const selectedOvertimeIds = ref<Set<string>>(new Set());
|
||||
|
||||
// 批量审批是否为当前操作来源(区分单条审批和批量审批的 submit 回调)
|
||||
const isBatchActionMode = ref(false);
|
||||
|
||||
const OVERTIME_APPROVAL_ACTION_ICONS = {
|
||||
detail: markRaw(IconMdiEyeOutline)
|
||||
};
|
||||
@@ -680,6 +693,97 @@ async function loadPersonalTodoItems() {
|
||||
);
|
||||
}
|
||||
|
||||
// 批量审批选中状态管理
|
||||
function isOvertimeItemSelected(item: WorkbenchTodoItem) {
|
||||
return item.approvalBizId ? selectedOvertimeIds.value.has(item.approvalBizId) : false;
|
||||
}
|
||||
|
||||
function toggleOvertimeItem(item: WorkbenchTodoItem, checked: boolean) {
|
||||
if (!item.approvalBizId) return;
|
||||
if (checked) {
|
||||
selectedOvertimeIds.value.add(item.approvalBizId);
|
||||
} else {
|
||||
selectedOvertimeIds.value.delete(item.approvalBizId);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAllOvertimeItems(checked: boolean) {
|
||||
if (checked) {
|
||||
overtimeApprovalItems.value.forEach(item => {
|
||||
if (item.approvalBizId) selectedOvertimeIds.value.add(item.approvalBizId);
|
||||
});
|
||||
} else {
|
||||
selectedOvertimeIds.value.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function clearOvertimeSelection() {
|
||||
selectedOvertimeIds.value.clear();
|
||||
}
|
||||
|
||||
// 切换审批子页签时清空选中
|
||||
watch(activeApprovalBizType, () => {
|
||||
clearOvertimeSelection();
|
||||
});
|
||||
|
||||
// 当前页加班申请是否全部选中(用于全选复选框状态)
|
||||
const allOvertimeItemsSelected = computed(() => {
|
||||
const items = overtimeApprovalItems.value;
|
||||
if (items.length === 0) return false;
|
||||
return items.every(item => item.approvalBizId && selectedOvertimeIds.value.has(item.approvalBizId));
|
||||
});
|
||||
|
||||
// 当前页是否有部分选中
|
||||
const someOvertimeItemsSelected = computed(() => {
|
||||
return overtimeApprovalItems.value.some(
|
||||
item => item.approvalBizId && selectedOvertimeIds.value.has(item.approvalBizId)
|
||||
);
|
||||
});
|
||||
|
||||
// 批量审批选中的 id 数组(用于传给批量详情弹窗)
|
||||
const batchSelectedIds = computed(() => Array.from(selectedOvertimeIds.value));
|
||||
|
||||
// 打开批量审批详情弹窗
|
||||
function handleBatchReview() {
|
||||
batchDetailVisible.value = true;
|
||||
}
|
||||
|
||||
// 批量详情弹窗中点击"通过"或"退回"
|
||||
function openBatchActionDialog(actionType: OvertimeApprovalActionType) {
|
||||
currentOvertimeActionType.value = actionType;
|
||||
isBatchActionMode.value = true;
|
||||
batchActionVisible.value = true;
|
||||
}
|
||||
|
||||
// 批量审批提交(对所有选中项执行批量 API)
|
||||
async function handleBatchActionSubmit(reason: string | null) {
|
||||
const ids = Array.from(selectedOvertimeIds.value);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const fn =
|
||||
currentOvertimeActionType.value === 'approve'
|
||||
? fetchBatchApproveOvertimeApplication
|
||||
: fetchBatchRejectOvertimeApplication;
|
||||
|
||||
batchSubmitting.value = true;
|
||||
const { error, data } = await fn({ ids, reason });
|
||||
batchSubmitting.value = false;
|
||||
|
||||
if (error || !data) return;
|
||||
|
||||
batchActionVisible.value = false;
|
||||
batchDetailVisible.value = false;
|
||||
clearOvertimeSelection();
|
||||
|
||||
if (data.failCount > 0) {
|
||||
window.$message?.warning(`成功 ${data.successCount} 条,失败 ${data.failCount} 条`);
|
||||
} else {
|
||||
window.$message?.success(`已批量处理 ${data.successCount} 条`);
|
||||
}
|
||||
|
||||
await loadOvertimeApprovalItems();
|
||||
}
|
||||
|
||||
async function loadOvertimeApprovalItems() {
|
||||
const { error, data } = await fetchGetOvertimeApplicationApprovalPage({
|
||||
pageNo: 1,
|
||||
@@ -725,7 +829,7 @@ function buildWorkReportApprovalItems<T extends WorkReportRow>(
|
||||
rows.map(item => ({
|
||||
id: `${bizType}-${item.id}`,
|
||||
category: 'approval',
|
||||
title: `${reportTypeLabel} · ${formatPeriod(item)} 待审批`,
|
||||
title: `${reportTypeLabel} · ${bizType === 'weekly' ? formatWeeklyPeriodLabel(item) : formatPeriod(item)} 待审批`,
|
||||
createdTime: item.submitTime || item.createTime || '',
|
||||
deadline: item.submitTime || item.createTime || null,
|
||||
source: `${reportTypeLabel} · ${'projectName' in item ? item.projectName : item.reporterName}`,
|
||||
@@ -882,9 +986,60 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div class="workbench-todo__content">
|
||||
<!-- 批量操作栏(仅加班申请待审批时显示) -->
|
||||
<div
|
||||
v-if="
|
||||
activeTab === 'approval' &&
|
||||
activeApprovalBizType === 'overtime_application' &&
|
||||
overtimeApprovalItems.length > 0
|
||||
"
|
||||
class="workbench-todo__batch-bar"
|
||||
:class="{ 'workbench-todo__batch-bar--active': selectedOvertimeIds.size > 0 }"
|
||||
>
|
||||
<div class="workbench-todo__batch-bar-left">
|
||||
<ElCheckbox
|
||||
:model-value="allOvertimeItemsSelected"
|
||||
:indeterminate="someOvertimeItemsSelected && !allOvertimeItemsSelected"
|
||||
@change="val => toggleSelectAllOvertimeItems(Boolean(val))"
|
||||
@click.stop
|
||||
>
|
||||
全选
|
||||
</ElCheckbox>
|
||||
<span v-if="selectedOvertimeIds.size > 0" class="workbench-todo__batch-bar-count">
|
||||
已选择 {{ selectedOvertimeIds.size }} 项
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="selectedOvertimeIds.size > 0" class="workbench-todo__batch-bar-right">
|
||||
<ElButton size="small" type="primary" :loading="batchSubmitting" @click.stop="handleBatchReview">
|
||||
批量审批
|
||||
</ElButton>
|
||||
<ElButton size="small" link @click.stop="clearOvertimeSelection">取消选择</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pagedItems.length" class="workbench-todo__list">
|
||||
<article v-for="item in pagedItems" :key="item.id" class="workbench-todo__item">
|
||||
<article
|
||||
v-for="item in pagedItems"
|
||||
:key="item.id"
|
||||
class="workbench-todo__item"
|
||||
:class="{
|
||||
'workbench-todo__item--clickable': Boolean(item.routeKey || item.approvalBizType),
|
||||
'workbench-todo__item--selected': isOvertimeItemSelected(item)
|
||||
}"
|
||||
@click="handleClickItem(item)"
|
||||
>
|
||||
<div class="workbench-todo__leading">
|
||||
<!-- 加班申请待审批时显示复选框 -->
|
||||
<ElCheckbox
|
||||
v-if="
|
||||
activeTab === 'approval' &&
|
||||
activeApprovalBizType === 'overtime_application' &&
|
||||
item.approvalBizType === 'overtime_application'
|
||||
"
|
||||
:model-value="isOvertimeItemSelected(item)"
|
||||
@change="val => toggleOvertimeItem(item, Boolean(val))"
|
||||
@click.stop
|
||||
/>
|
||||
<span class="workbench-todo__category" :class="`workbench-todo__category--${item.categoryTone}`">
|
||||
{{ item.categoryLabel }}
|
||||
</span>
|
||||
@@ -1052,6 +1207,7 @@ onMounted(async () => {
|
||||
:row-data="currentOvertimeApplication"
|
||||
show-approval-actions
|
||||
:action-loading="overtimeActionSubmitting"
|
||||
append-to-body
|
||||
@approve="openCurrentOvertimeAction('approve')"
|
||||
@reject="openCurrentOvertimeAction('reject')"
|
||||
/>
|
||||
@@ -1059,9 +1215,30 @@ onMounted(async () => {
|
||||
v-model:visible="overtimeActionVisible"
|
||||
:action-type="currentOvertimeActionType"
|
||||
:loading="overtimeActionSubmitting"
|
||||
append-to-body
|
||||
@submit="handleOvertimeActionSubmit"
|
||||
/>
|
||||
|
||||
<!-- 批量审批详情弹窗(左右箭头切换 + 通过/退回按钮) -->
|
||||
<OvertimeApplicationBatchDetailDialog
|
||||
v-model:visible="batchDetailVisible"
|
||||
:selected-ids="batchSelectedIds"
|
||||
:rows="overtimeApprovalRows"
|
||||
:action-loading="batchSubmitting"
|
||||
append-to-body
|
||||
@approve="openBatchActionDialog('approve')"
|
||||
@reject="openBatchActionDialog('reject')"
|
||||
/>
|
||||
|
||||
<!-- 批量审批意见/退回原因对话框 -->
|
||||
<OvertimeApplicationActionDialog
|
||||
v-model:visible="batchActionVisible"
|
||||
:action-type="currentOvertimeActionType"
|
||||
:loading="batchSubmitting"
|
||||
append-to-body
|
||||
@submit="handleBatchActionSubmit"
|
||||
/>
|
||||
|
||||
<WorkReportPrototypePageDialog
|
||||
v-model:visible="workReportDetailVisible"
|
||||
mode="detail"
|
||||
@@ -1261,6 +1438,44 @@ onMounted(async () => {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
/* 批量操作栏 */
|
||||
.workbench-todo__batch-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 12px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
font-size: 13px;
|
||||
color: rgb(71 85 105 / 94%);
|
||||
transition: all 160ms ease;
|
||||
}
|
||||
|
||||
.workbench-todo__batch-bar--active {
|
||||
border-color: rgb(14 116 144 / 40%);
|
||||
background-color: rgb(240 253 250 / 80%);
|
||||
color: rgb(14 116 144 / 96%);
|
||||
}
|
||||
|
||||
.workbench-todo__batch-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workbench-todo__batch-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.workbench-todo__batch-bar-count {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workbench-todo__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1281,6 +1496,20 @@ onMounted(async () => {
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
.workbench-todo__item--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workbench-todo__item--clickable:hover {
|
||||
border-color: rgb(14 116 144 / 60%);
|
||||
background-color: rgb(240 253 250 / 84%);
|
||||
}
|
||||
|
||||
.workbench-todo__item--selected {
|
||||
border-color: rgb(14 116 144 / 60%);
|
||||
background-color: rgb(240 253 250 / 90%);
|
||||
}
|
||||
|
||||
.workbench-todo__leading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user