<template>
|
<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="请选择" 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 label="计划开始时间" prop="planStartTime">
|
<el-date-picker
|
v-model="form.planStartTime"
|
type="date"
|
value-format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
placeholder="请选择"
|
style="width: 100%"
|
clearable
|
/>
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="计划完工时间" prop="planEndTime">
|
<el-date-picker
|
v-model="form.planEndTime"
|
type="date"
|
value-format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
placeholder="请选择"
|
style="width: 100%"
|
clearable
|
/>
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="实际开工日期" prop="actualStartTime">
|
<el-date-picker
|
v-model="form.actualStartTime"
|
type="date"
|
value-format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
placeholder="请选择"
|
style="width: 100%"
|
clearable
|
/>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
|
<el-row :gutter="20">
|
<el-col :span="8">
|
<el-form-item label="本次进度日期" prop="reportDate">
|
<el-date-picker
|
v-model="form.reportDate"
|
type="date"
|
value-format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
placeholder="请选择"
|
style="width: 100%"
|
clearable
|
/>
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<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-form-item label="完成进度(%)" prop="completionProgress">
|
<div style="display: flex; gap: 8px; width: 100%;">
|
<el-input-number v-model="form.completionProgress" :min="0" :max="100" controls-position="right" style="flex: 1" />
|
<el-button type="danger" @click="markDone">完成</el-button>
|
</div>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
|
<el-row :gutter="20">
|
<el-col :span="8">
|
<el-form-item label="累计进度(%)" prop="totalProgress">
|
<el-input-number v-model="form.totalProgress" :min="0" :max="100" controls-position="right" style="width: 100%" />
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="实际完工日期" prop="actualEndTime">
|
<el-date-picker
|
v-model="form.actualEndTime"
|
type="date"
|
value-format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
placeholder="请选择"
|
style="width: 100%"
|
clearable
|
/>
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="负责人" prop="managerName">
|
<el-input v-model="form.managerName" placeholder="请输入" clearable />
|
</el-form-item>
|
</el-col>
|
</el-row>
|
<el-row :gutter="20">
|
<el-col :span="8">
|
<el-form-item label="部门" prop="departmentName">
|
<el-input v-model="form.departmentName" placeholder="请输入" clearable />
|
</el-form-item>
|
</el-col>
|
<el-col :span="16">
|
<el-form-item label="备注" prop="remark">
|
<el-input v-model="form.remark" placeholder="请输入" clearable />
|
</el-form-item>
|
</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>
|
</el-form>
|
|
<template #footer>
|
<div class="dialog-footer">
|
<el-button type="primary" @click="submit">确定</el-button>
|
<el-button @click="visible = false">取消</el-button>
|
</div>
|
</template>
|
</el-dialog>
|
</template>
|
|
<script setup name="ProgressReportDialog">
|
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 emit = defineEmits(['update:modelValue', 'submitted'])
|
|
const visible = computed({
|
get: () => props.modelValue,
|
set: v => emit('update:modelValue', v)
|
})
|
|
const formRef = ref()
|
const userOptions = ref([])
|
const roleOptions = ref([])
|
const teamRows = ref([])
|
|
const form = ref({
|
planNodeId: undefined,
|
planStartTime: '',
|
planEndTime: '',
|
actualStartTime: '',
|
actualEndTime: '',
|
reportDate: '',
|
lastProgress: 0,
|
completionProgress: 0,
|
totalProgress: 0,
|
managerName: '',
|
departmentName: '',
|
remark: '',
|
storageBlobDTOs: []
|
})
|
|
const rules = {
|
planNodeId: [{ required: true, message: '请选择', trigger: 'change' }],
|
planStartTime: [{ required: true, message: '请选择计划开始时间', trigger: 'change' }],
|
planEndTime: [{ required: true, message: '请选择计划完工时间', trigger: 'change' }],
|
reportDate: [{ required: true, message: '请选择', trigger: 'change' }],
|
completionProgress: [{ required: true, message: '请输入', trigger: 'change' }]
|
}
|
|
const sortedNodes = computed(() => {
|
const list = Array.isArray(props.planNodes) ? props.planNodes : []
|
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 ?? expectedNodeId.value ?? stageOptions.value[0]?.value,
|
planStartTime: '',
|
planEndTime: '',
|
actualStartTime: '',
|
actualEndTime: '',
|
reportDate: todayStr(),
|
lastProgress: 0,
|
completionProgress: 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) {
|
loadOptions()
|
resetFromProject()
|
}
|
}
|
)
|
|
// 累计进度跟随本次完成进度
|
watch(
|
() => form.value.completionProgress,
|
v => {
|
form.value.totalProgress = Number(v) || 0
|
}
|
)
|
|
function handleClose() {
|
formRef.value?.resetFields?.()
|
}
|
|
function markDone() {
|
form.value.completionProgress = 100
|
form.value.totalProgress = 100
|
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() {
|
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>
|
|
<style scoped lang="scss">
|
.dialog-footer {
|
display: flex;
|
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>
|