| | |
| | | <template> |
| | | <el-dialog v-model="visible" title="进度汇报" width="900px" top="8vh" append-to-body destroy-on-close @close="handleClose"> |
| | | <el-dialog v-model="visible" title="进度汇报" width="960px" top="6vh" append-to-body destroy-on-close @close="handleClose"> |
| | | <el-form ref="formRef" :model="form" :rules="rules" label-position="top" label-width="120px"> |
| | | <el-form-item label="项目阶段" prop="planNodeId"> |
| | | <el-select v-model="form.planNodeId" placeholder="请选择" clearable style="width: 100%"> |
| | | <el-select v-model="form.planNodeId" placeholder="请选择" style="width: 100%" @change="handleStageChange"> |
| | | <el-option v-for="opt in stageOptions" :key="opt.value" :label="opt.label" :value="opt.value" /> |
| | | </el-select> |
| | | <div v-if="stageTip" class="stage-tip">{{ stageTip }}</div> |
| | | </el-form-item> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="8"> |
| | |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | | <el-form-item label="上次进度(%)" prop="lastProgress"> |
| | | <el-input-number v-model="form.lastProgress" :min="0" :max="100" controls-position="right" style="width: 100%" /> |
| | | <el-form-item label="上次进度(%)"> |
| | | <el-input-number v-model="form.lastProgress" :min="0" :max="100" controls-position="right" style="width: 100%" disabled /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="8"> |
| | |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <div v-if="isTeamStage" class="team-block"> |
| | | <div class="team-header"> |
| | | <span class="team-title">项目团队</span> |
| | | <el-button type="primary" size="small" @click="addTeamRow">新增成员</el-button> |
| | | </div> |
| | | <el-table :data="teamRows" border size="small"> |
| | | <el-table-column label="姓名" width="150"> |
| | | <template #default="{ row }"> |
| | | <el-select v-model="row.userId" placeholder="请选择" filterable clearable style="width: 100%"> |
| | | <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" /> |
| | | </el-select> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="项目组角色" width="150"> |
| | | <template #default="{ row }"> |
| | | <el-select v-model="row.userRoleId" placeholder="请选择" filterable clearable style="width: 100%"> |
| | | <el-option v-for="r in roleOptions" :key="r.value" :label="r.label" :value="r.value" /> |
| | | </el-select> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="进入日期" width="150"> |
| | | <template #default="{ row }"> |
| | | <el-date-picker |
| | | v-model="row.joinTime" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | placeholder="请选择" |
| | | style="width: 100%" |
| | | clearable |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="离开日期" width="150"> |
| | | <template #default="{ row }"> |
| | | <el-date-picker |
| | | v-model="row.departTime" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | format="YYYY-MM-DD" |
| | | placeholder="请选择" |
| | | style="width: 100%" |
| | | clearable |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="联系方式" min-width="150"> |
| | | <template #default="{ row }"> |
| | | <el-input v-model="row.contact" placeholder="请输入" clearable /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="备注" min-width="140"> |
| | | <template #default="{ row }"> |
| | | <el-input v-model="row.remark" placeholder="请输入" clearable /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="70" align="center"> |
| | | <template #default="{ $index }"> |
| | | <el-button link type="danger" size="small" @click="removeTeamRow($index)">删除</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </div> |
| | | |
| | | <el-form-item label="附件" prop="attachmentIds"> |
| | | <FileUpload v-model:file-list="form.storageBlobDTOs" /> |
| | | </el-form-item> |
| | |
| | | </template> |
| | | |
| | | <script setup name="ProgressReportDialog"> |
| | | import { computed, reactive, ref, watch } from 'vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import { getToken } from '@/utils/auth' |
| | | import FileUpload from "@/components/AttachmentUpload/file/index.vue"; |
| | | import { computed, ref, watch } from 'vue' |
| | | import { ElMessageBox } from 'element-plus' |
| | | import FileUpload from '@/components/AttachmentUpload/file/index.vue' |
| | | import { findRoleListPage } from '@/api/projectManagement/role' |
| | | import { userListAll } from '@/api/publicApi' |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { type: Boolean, default: false }, |
| | | projectId: { type: [Number, String], default: undefined }, |
| | | projectInfo: { type: Object, default: () => ({}) }, |
| | | planNodes: { type: Array, default: () => [] }, |
| | | stageRecords: { type: Array, default: () => [] }, |
| | | defaultPlanNodeId: { type: [Number, String], default: undefined } |
| | | }) |
| | | |
| | |
| | | }) |
| | | |
| | | const formRef = ref() |
| | | const userOptions = ref([]) |
| | | const roleOptions = ref([]) |
| | | const teamRows = ref([]) |
| | | |
| | | const form = ref({ |
| | | planNodeId: undefined, |
| | |
| | | completionProgress: [{ required: true, message: '请输入', trigger: 'change' }] |
| | | } |
| | | |
| | | const stageOptions = computed(() => { |
| | | const sortedNodes = computed(() => { |
| | | const list = Array.isArray(props.planNodes) ? props.planNodes : [] |
| | | const sorted = [...list].sort((a, b) => Number(a.sort ?? 0) - Number(b.sort ?? 0)) |
| | | return sorted |
| | | return [...list].sort((a, b) => Number(a.sort ?? 0) - Number(b.sort ?? 0)) |
| | | }) |
| | | |
| | | const stageOptions = computed(() => |
| | | sortedNodes.value |
| | | .map(n => ({ |
| | | label: n.name || n.workContent || n.title || String(n.id ?? ''), |
| | | value: n.id |
| | | })) |
| | | .filter(i => i.value !== undefined && i.value !== null && i.value !== '') |
| | | ) |
| | | |
| | | const stageNameOf = nodeId => { |
| | | const node = sortedNodes.value.find(n => String(n.id) === String(nodeId)) |
| | | return node?.name || node?.workContent || '' |
| | | } |
| | | |
| | | // 各阶段最近一条汇报记录(记录按阶段、时间升序返回) |
| | | const latestProgressMap = computed(() => { |
| | | const map = {} |
| | | const records = Array.isArray(props.stageRecords) ? props.stageRecords : [] |
| | | records.forEach(r => { |
| | | const key = String(r.projectManagementPlanNodeId ?? '') |
| | | map[key] = Number(r.progress) || 0 |
| | | }) |
| | | return map |
| | | }) |
| | | |
| | | // 当前应汇报的下一阶段:第一个未完成(进度 < 100)的阶段 |
| | | const expectedNodeId = computed(() => { |
| | | const nodes = sortedNodes.value |
| | | if (nodes.length === 0) return undefined |
| | | const next = nodes.find(n => (latestProgressMap.value[String(n.id)] ?? 0) < 100) |
| | | return (next || nodes[nodes.length - 1])?.id |
| | | }) |
| | | |
| | | const isTeamStage = computed(() => stageNameOf(form.value.planNodeId).includes('团队')) |
| | | |
| | | const stageTip = computed(() => { |
| | | if (!form.value.planNodeId) return '' |
| | | const progress = latestProgressMap.value[String(form.value.planNodeId)] ?? 0 |
| | | if (progress >= 100) return '该阶段已完成,仍可继续补充汇报' |
| | | if (expectedNodeId.value && String(form.value.planNodeId) !== String(expectedNodeId.value)) { |
| | | return '当前选择的不是应汇报的下一阶段' |
| | | } |
| | | return '' |
| | | }) |
| | | |
| | | function todayStr() { |
| | | const d = new Date() |
| | | const pad = n => String(n).padStart(2, '0') |
| | | return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` |
| | | } |
| | | |
| | | function latestRecordOf(nodeId) { |
| | | const records = Array.isArray(props.stageRecords) ? props.stageRecords : [] |
| | | const matched = records.filter(r => String(r.projectManagementPlanNodeId) === String(nodeId)) |
| | | return matched.length ? matched[matched.length - 1] : null |
| | | } |
| | | |
| | | function applyStageBase(nodeId) { |
| | | const record = latestRecordOf(nodeId) |
| | | form.value.lastProgress = Number(record?.progress ?? 0) || 0 |
| | | form.value.completionProgress = 0 |
| | | form.value.totalProgress = Number(record?.progress ?? 0) || 0 |
| | | form.value.planStartTime = record?.planStartTime || props.projectInfo?.planStartTime || '' |
| | | form.value.planEndTime = record?.planEndTime || props.projectInfo?.planEndTime || '' |
| | | form.value.actualStartTime = record?.actualStartTime || props.projectInfo?.actualStartTime || '' |
| | | form.value.actualEndTime = record?.actualEndTime || '' |
| | | } |
| | | |
| | | async function handleStageChange(val) { |
| | | applyStageBase(val) |
| | | if (!val) return |
| | | const expected = expectedNodeId.value |
| | | if (expected && String(val) !== String(expected)) { |
| | | try { |
| | | await ElMessageBox.confirm( |
| | | `当前应汇报的“${stageNameOf(expected)}”阶段还未完成,选择其他阶段将跳过中间阶段,是否继续?`, |
| | | '提示', |
| | | { confirmButtonText: '继续', cancelButtonText: '返回', type: 'warning' } |
| | | ) |
| | | } catch { |
| | | form.value.planNodeId = expected |
| | | applyStageBase(expected) |
| | | } |
| | | } |
| | | } |
| | | |
| | | function resetFromProject() { |
| | | const info = props.projectInfo || {} |
| | | form.value = { |
| | | planNodeId: props.defaultPlanNodeId ?? stageOptions.value[0]?.value, |
| | | planStartTime: info.planStartTime || '', |
| | | planEndTime: info.planEndTime || '', |
| | | actualStartTime: info.actualStartTime || '', |
| | | actualEndTime: info.actualEndTime || '', |
| | | reportDate: '', |
| | | lastProgress: Number(info.lastProgress ?? 0) || 0, |
| | | planNodeId: props.defaultPlanNodeId ?? expectedNodeId.value ?? stageOptions.value[0]?.value, |
| | | planStartTime: '', |
| | | planEndTime: '', |
| | | actualStartTime: '', |
| | | actualEndTime: '', |
| | | reportDate: todayStr(), |
| | | lastProgress: 0, |
| | | completionProgress: 0, |
| | | totalProgress: Number(info.totalProgress ?? info.progress ?? 0) || 0, |
| | | totalProgress: 0, |
| | | managerName: info.managerName || '', |
| | | departmentName: info.departmentName || '', |
| | | remark: '', |
| | | storageBlobDTOs: [] |
| | | } |
| | | applyStageBase(form.value.planNodeId) |
| | | form.value.reportDate = todayStr() |
| | | loadTeamRows() |
| | | } |
| | | |
| | | function loadTeamRows() { |
| | | const list = Array.isArray(props.projectInfo?.teamList) ? props.projectInfo.teamList : [] |
| | | teamRows.value = list.map(t => ({ |
| | | userId: t.userId, |
| | | userRoleId: t.userRoleId, |
| | | joinTime: t.joinTime || '', |
| | | departTime: t.departTime || '', |
| | | contact: t.contact || '', |
| | | remark: t.remark || '' |
| | | })) |
| | | if (teamRows.value.length === 0) addTeamRow() |
| | | } |
| | | |
| | | function addTeamRow() { |
| | | teamRows.value.push({ userId: undefined, userRoleId: undefined, joinTime: '', departTime: '', contact: '', remark: '' }) |
| | | } |
| | | |
| | | function removeTeamRow(index) { |
| | | teamRows.value.splice(index, 1) |
| | | if (teamRows.value.length === 0) addTeamRow() |
| | | } |
| | | |
| | | async function loadOptions() { |
| | | try { |
| | | const res = await userListAll() |
| | | const list = res?.data || res?.rows || res || [] |
| | | userOptions.value = (Array.isArray(list) ? list : []).map(u => ({ |
| | | label: u.nickName || u.userName || u.username || u.name, |
| | | value: u.userId || u.id |
| | | })) |
| | | } catch { |
| | | userOptions.value = [] |
| | | } |
| | | try { |
| | | const res = await findRoleListPage({ pageNum: 1, pageSize: 999 }) |
| | | const records = res?.data?.records || res?.rows || res?.records || [] |
| | | roleOptions.value = records.map(item => ({ label: item.roleName || item.name, value: item.id })) |
| | | } catch { |
| | | roleOptions.value = [] |
| | | } |
| | | } |
| | | |
| | | watch( |
| | | () => props.modelValue, |
| | | v => { |
| | | if (v) resetFromProject() |
| | | if (v) { |
| | | loadOptions() |
| | | resetFromProject() |
| | | } |
| | | } |
| | | ) |
| | | |
| | | // 累计进度跟随本次完成进度 |
| | | watch( |
| | | () => form.value.completionProgress, |
| | | v => { |
| | | form.value.totalProgress = Number(v) || 0 |
| | | } |
| | | ) |
| | | |
| | |
| | | function markDone() { |
| | | form.value.completionProgress = 100 |
| | | form.value.totalProgress = 100 |
| | | if (!form.value.actualEndTime) form.value.actualEndTime = form.value.reportDate || '' |
| | | if (!form.value.actualEndTime) form.value.actualEndTime = form.value.reportDate || todayStr() |
| | | } |
| | | |
| | | function buildTeamList() { |
| | | const findLabel = (list, value) => (list || []).find(i => String(i.value) === String(value))?.label || '' |
| | | return teamRows.value |
| | | .filter(r => r.userId !== undefined && r.userId !== null && r.userId !== '') |
| | | .map(r => ({ |
| | | userId: Number(r.userId), |
| | | userName: findLabel(userOptions.value, r.userId), |
| | | userRoleId: r.userRoleId ?? '', |
| | | userRoleName: findLabel(roleOptions.value, r.userRoleId), |
| | | joinTime: r.joinTime || null, |
| | | departTime: r.departTime || null, |
| | | contact: r.contact || '', |
| | | remark: r.remark || '' |
| | | })) |
| | | } |
| | | |
| | | async function submit() { |
| | | await formRef.value?.validate?.() |
| | | emit('submitted', { |
| | | try { |
| | | await formRef.value?.validate?.() |
| | | } catch { |
| | | return |
| | | } |
| | | const payload = { |
| | | projectId: props.projectId, |
| | | ...form.value |
| | | }) |
| | | } |
| | | if (isTeamStage.value) { |
| | | payload.teamList = buildTeamList() |
| | | } |
| | | emit('submitted', payload) |
| | | visible.value = false |
| | | } |
| | | </script> |
| | |
| | | justify-content: flex-end; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .stage-tip { |
| | | margin-top: 6px; |
| | | font-size: 12px; |
| | | color: #e6a23c; |
| | | line-height: 1.4; |
| | | } |
| | | |
| | | .team-block { |
| | | margin-bottom: 18px; |
| | | } |
| | | |
| | | .team-header { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .team-title { |
| | | font-weight: 600; |
| | | color: #303133; |
| | | font-size: 14px; |
| | | } |
| | | </style> |