<template>
|
<div v-loading="loading" class="execution-panel">
|
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
<el-tab-pane v-if="!singleTab && data.sourceBusiness?.id" label="来源业务" name="business">
|
<el-descriptions :column="3" border>
|
<el-descriptions-item label="业务编号">{{ data.sourceBusiness.id }}</el-descriptions-item>
|
<el-descriptions-item label="项目名称">{{ data.sourceBusiness.projectName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="客户名称">{{ data.sourceBusiness.customerName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="业务员">{{ data.sourceBusiness.salesman || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="录入日期">{{ data.sourceBusiness.entryDate || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="审批状态">{{ ({ 0:'草稿',1:'待审批',2:'已通过',3:'已退回' })[data.sourceBusiness.approvalStatus] || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="业务备注" :span="3">{{ data.sourceBusiness.remarks || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="业务附件" :span="3">
|
<template v-if="data.sourceBusiness.attachmentList?.length">
|
<el-button v-for="(file,index) in data.sourceBusiness.attachmentList" :key="index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index+1}` }}</el-button>
|
</template>
|
<span v-else>{{ data.sourceBusiness.attachmentMaterials || '无' }}</span>
|
</el-descriptions-item>
|
</el-descriptions>
|
</el-tab-pane>
|
|
<el-tab-pane v-if="!singleTab || singleTab === 'modules'" :label="readonly ? '模块信息' : '模块管理'" name="modules">
|
<div class="tab-actions">
|
<span class="hint">模块占项目工作量合计:{{ totalModuleWeight }}%</span>
|
<el-button v-if="!readonly" v-hasPermi="['project:module:edit']" type="primary" @click="openModuleBatch">{{ isStarted ? '管理执行人' : '编辑模块' }}</el-button>
|
</div>
|
<el-table :data="data.modules" border>
|
<el-table-column prop="moduleName" label="模块名称" min-width="160" />
|
<el-table-column prop="plannedStartDate" label="预计开始" width="120" />
|
<el-table-column prop="summary" label="模块概要" min-width="220" show-overflow-tooltip />
|
<el-table-column prop="projectWeight" label="项目工作量占比" width="150" align="center">
|
<template #default="{ row }">{{ row.projectWeight }}%</template>
|
</el-table-column>
|
<el-table-column prop="currentProgress" label="当前进度" width="120" align="center">
|
<template #default="{ row }"><el-progress :percentage="Number(row.currentProgress || 0)" /></template>
|
</el-table-column>
|
<el-table-column label="模块执行人" min-width="230">
|
<template #default="{ row }">
|
<el-tag v-for="member in row.members || []" :key="member.id" class="member-tag">
|
{{ member.userName }}
|
</el-tag>
|
</template>
|
</el-table-column>
|
</el-table>
|
</el-tab-pane>
|
|
<el-tab-pane v-if="isStarted && (!singleTab || singleTab === 'follow')" label="项目跟进" name="follow">
|
<div class="tab-actions">
|
<div class="hint">
|
跟进周期:{{ followCycleLabel }};
|
<span v-if="data.followWindow?.dueDate">本次应跟进日 {{ data.followWindow.dueDate }},可填写 {{ data.followWindow.windowStart }} 至 {{ data.followWindow.windowEnd }}</span>
|
<span v-else>首次跟进可直接填写</span>
|
</div>
|
<el-button v-if="!readonly && canManageFollow" v-hasPermi="['project:follow:add']" type="primary" @click="openFollow()">填写跟进</el-button>
|
</div>
|
<el-table :data="data.followUps" border>
|
<el-table-column prop="followDate" label="跟进日期" width="130" />
|
<el-table-column prop="createUserName" label="项目负责人" width="140" />
|
<el-table-column prop="detail" label="跟进详情" min-width="300" show-overflow-tooltip />
|
<el-table-column label="附件" min-width="190">
|
<template #default="{ row }">
|
<div v-if="row.attachmentList?.length" class="file-cell"><el-button v-for="(file,index) in row.attachmentList" :key="file.id || index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index + 1}` }}</el-button></div>
|
<span v-else class="empty-file">无附件</span>
|
</template>
|
</el-table-column>
|
<el-table-column v-if="!readonly && canManageFollow" label="操作" width="140" align="center"><template #default="{ row }"><el-button v-hasPermi="['project:follow:add']" link type="primary" @click="openFollow(row)">编辑</el-button><el-button v-hasPermi="['project:follow:remove']" link type="danger" @click="removeRecord('follow', row)">删除</el-button></template></el-table-column>
|
</el-table>
|
</el-tab-pane>
|
|
<el-tab-pane v-if="isStarted && (!singleTab || singleTab === 'reports')" label="项目汇报" name="reports">
|
<div class="report-workbench">
|
<aside class="report-module-nav">
|
<div class="report-nav-title">汇报范围</div>
|
<button type="button" class="report-nav-item" :class="{ active: reportModuleFilter === 'all' }" @click="selectReportModule('all')">
|
<span class="report-nav-name">全部(整个项目)</span>
|
<span class="report-nav-meta">共 {{ data.reports.length }} 条汇报</span>
|
</button>
|
<button v-for="module in data.modules" :key="module.id" type="button" class="report-nav-item" :class="{ active: reportModuleFilter === String(module.id) }" @click="selectReportModule(String(module.id))">
|
<span class="report-nav-name">{{ module.moduleName }}</span>
|
<span class="report-nav-meta">进度 {{ Number(module.currentProgress || 0) }}% · {{ moduleReportCount(module.id) }} 条汇报</span>
|
</button>
|
</aside>
|
<section class="report-content">
|
<div class="tab-actions">
|
<span class="report-stat-summary">{{ reportStatisticsSummary }}</span>
|
<el-button v-if="!readonly" v-hasPermi="['project:report:add']" type="primary" :disabled="!eligibleReportModules.length" @click="openReport()">新增汇报</el-button>
|
</div>
|
<div class="report-layout" :class="{ 'report-only': !readonly }">
|
<div v-if="readonly" ref="chartRef" class="chart" />
|
<el-table :data="filteredReports" border class="report-table">
|
<el-table-column prop="moduleName" label="项目模块" min-width="130" />
|
<el-table-column label="汇报人员比重" min-width="210"><template #default="{ row }"><el-tag v-for="member in row.reporterShareList || []" :key="member.userId" class="member-tag">{{ member.userName }} {{ member.moduleShare }}%</el-tag></template></el-table-column>
|
<el-table-column prop="incrementProgress" label="本次进度" width="100"><template #default="{ row }">{{ row.incrementProgress ?? row.progress }}%</template></el-table-column>
|
<el-table-column prop="reportDate" label="时间" width="120" />
|
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
|
<el-table-column label="文件" min-width="190">
|
<template #default="{ row }">
|
<div v-if="row.attachmentList?.length" class="file-cell"><el-button v-for="(file,index) in row.attachmentList" :key="file.id || index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index + 1}` }}</el-button></div>
|
<span v-else class="empty-file">无附件</span>
|
</template>
|
</el-table-column>
|
<el-table-column v-if="!readonly" label="操作" width="140" align="center"><template #default="{ row }"><el-button v-if="canEditReport(row)" v-hasPermi="['project:report:add']" link type="primary" @click="openReport(row)">编辑</el-button><el-button v-if="canEditReport(row)" v-hasPermi="['project:report:remove']" link type="danger" @click="removeRecord('report', row)">删除</el-button></template></el-table-column>
|
</el-table>
|
</div>
|
</section>
|
</div>
|
</el-tab-pane>
|
|
<el-tab-pane v-if="!singleTab || singleTab === 'materials'" label="项目资料" name="materials">
|
<div class="tab-actions">
|
<span class="hint">项目整体资料库,与业务附件和单次汇报附件分开保存</span>
|
<el-button v-if="!readonly" v-hasPermi="['project:material:add']" type="primary" @click="openMaterial()">上传资料</el-button>
|
</div>
|
<el-table :data="data.materials" border>
|
<el-table-column prop="materialName" label="资料名称" min-width="220" />
|
<el-table-column prop="materialType" label="资料类型" width="130" />
|
<el-table-column prop="createUserName" label="维护人" width="130" />
|
<el-table-column prop="createTime" label="上传时间" width="180" />
|
<el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
|
<el-table-column label="文件" min-width="200">
|
<template #default="{ row }">
|
<el-button v-for="(file, index) in row.attachmentList || []" :key="file.id || index" link type="primary" @click="download(file)">
|
{{ file.name || `附件${index + 1}` }}
|
</el-button>
|
</template>
|
</el-table-column>
|
<el-table-column v-if="!readonly" label="操作" width="140" align="center"><template #default="{ row }"><el-button v-hasPermi="['project:material:add']" link type="primary" @click="openMaterial(row)">编辑</el-button><el-button v-hasPermi="['project:material:remove']" link type="danger" @click="removeRecord('material', row)">删除</el-button></template></el-table-column>
|
</el-table>
|
</el-tab-pane>
|
|
<template v-if="!singleTab">
|
<el-tab-pane v-for="stage in visibleProcessStages" :key="stage.code" :label="stage.label" :name="stage.code">
|
<el-empty v-if="!stageRecords(stage.code).length" description="暂无阶段办理记录" />
|
<el-timeline v-else class="phase-record-timeline">
|
<el-timeline-item v-for="record in stageRecords(stage.code)" :key="`${stage.code}-${record.id || record.actionTime}`" :timestamp="formatActionTime(record.actionTime)" placement="top" :type="phaseRecordType(record.actionType)">
|
<el-card shadow="never" class="phase-record-card">
|
<template #header>
|
<div class="phase-record-head"><div><strong>{{ record.actionLabel || stage.phaseName }}</strong><el-tag size="small" :type="phaseRecordType(record.actionType)">{{ recordStatusLabel(record) }}</el-tag></div><span>操作人:{{ record.operatorName || '-' }}</span></div>
|
</template>
|
<el-descriptions v-if="phaseFieldRows(record).length" :column="2" border>
|
<el-descriptions-item v-for="field in phaseFieldRows(record)" :key="field.label" :label="field.label">{{ field.value }}</el-descriptions-item>
|
</el-descriptions>
|
<div v-if="record.remark" class="phase-record-remark"><b>办理说明:</b>{{ record.remark }}</div>
|
<div v-if="record.attachmentList?.length" class="phase-record-files"><b>相关附件:</b><el-button v-for="(file,index) in record.attachmentList" :key="file.id || index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index + 1}` }}</el-button></div>
|
</el-card>
|
</el-timeline-item>
|
</el-timeline>
|
</el-tab-pane>
|
</template>
|
</el-tabs>
|
|
<el-dialog v-model="moduleVisible" :title="isStarted ? '管理模块执行人' : '编辑项目模块'" width="1100px" top="4vh" destroy-on-close>
|
<el-alert :title="isStarted ? '项目开始后模块信息不可修改,仅可新增执行人;已有本模块工作占比的执行人不能删除。' : '所有模块一次性保存,工作量占比合计必须为100%后项目才能开始。'" type="info" :closable="false" class="dialog-tip" />
|
<div class="module-total" :class="{ invalid: Number(moduleDraftTotal) !== 100 }">模块工作量合计:{{ moduleDraftTotal }}%,剩余可分配:{{ moduleDraftRemaining }}%</div>
|
<div v-for="(module, moduleIndex) in moduleDrafts" :key="module.localKey" class="module-batch-card">
|
<div class="module-batch-title">
|
<strong>模块 {{ moduleIndex + 1 }}</strong>
|
<span v-if="module.locked" class="locked-tip">项目已开始,模块信息不可修改</span>
|
<el-button v-if="!isStarted && !module.locked" link type="danger" @click="removeModuleDraft(moduleIndex)">移除模块</el-button>
|
</div>
|
<div class="module-grid">
|
<el-form-item label="模块名称" required><el-input v-model="module.moduleName" :disabled="module.locked" /></el-form-item>
|
<el-form-item label="预计开始" required><el-date-picker v-model="module.plannedStartDate" :disabled="module.locked" :disabled-date="moduleDateDisabled" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
<el-form-item label="工作量占比" required><el-input-number v-model="module.projectWeight" :disabled="module.locked" :min="0.01" :max="draftWeightMax(moduleIndex)" :precision="2" /><span class="unit">%</span><div class="percent-hint">其他模块 {{ otherDraftWeight(moduleIndex) }}%,本模块最多 {{ draftWeightMax(moduleIndex) }}%</div></el-form-item>
|
<el-form-item label="模块概要" required class="summary-item"><el-input v-model="module.summary" :disabled="module.locked" type="textarea" :rows="2" maxlength="1000" show-word-limit /></el-form-item>
|
<el-form-item label="模块执行人" required class="members-item">
|
<div class="member-editor">
|
<div v-for="(member, memberIndex) in module.members" :key="memberIndex" class="member-row">
|
<el-select v-model="member.userId" :disabled="member.hasReportedShare" filterable placeholder="请选择执行人" @change="id => syncMemberName(member, id)"><el-option v-for="user in userOptions" :key="user.value" :label="user.label" :value="user.value" /></el-select>
|
<el-tag v-if="member.hasReportedShare" type="warning">已完成模块占比:{{ member.completedModuleShare }}%</el-tag>
|
<el-button v-else link type="danger" @click="module.members.splice(memberIndex, 1)">删除</el-button>
|
</div>
|
<el-button link type="primary" @click="module.members.push({ userId:null, userName:'', hasReportedShare:false, completedModuleShare:0 })">+ 添加执行人</el-button>
|
</div>
|
</el-form-item>
|
</div>
|
</div>
|
<el-button v-if="!isStarted" type="primary" plain @click="addModuleDraft">+ 添加模块</el-button>
|
<template #footer><el-button @click="moduleVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitModuleBatch">保存</el-button></template>
|
</el-dialog>
|
|
<el-dialog v-model="followVisible" :title="followForm.id ? '编辑项目跟进' : '填写项目跟进'" width="650px" destroy-on-close>
|
<el-form ref="followFormRef" :model="followForm" :rules="followRules" label-width="90px">
|
<el-form-item label="跟进日期" prop="followDate"><el-date-picker v-model="followForm.followDate" :disabled="!!followForm.id" :disabled-date="date => dayjs(date).isAfter(dayjs(), 'day')" type="date" value-format="YYYY-MM-DD" style="width: 100%" /><div v-if="followForm.id" class="percent-hint">历史跟进日期不可修改,避免破坏后续跟进周期。</div></el-form-item>
|
<el-form-item label="跟进详情" prop="detail"><el-input v-model="followForm.detail" type="textarea" :rows="5" maxlength="2000" show-word-limit /></el-form-item>
|
<el-form-item label="附件"><AttachmentUploadFile v-model:file-list="followFiles" :limit="10" /></el-form-item>
|
</el-form>
|
<template #footer><el-button @click="followVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitFollow">提交</el-button></template>
|
</el-dialog>
|
|
<el-dialog v-model="reportVisible" title="进度汇报" width="680px" destroy-on-close>
|
<el-form ref="reportFormRef" :model="reportForm" :rules="reportRules" label-width="100px">
|
<el-form-item label="所属项目"><el-input :model-value="projectInfo?.title" disabled /></el-form-item>
|
<el-form-item label="项目模块" prop="moduleId"><el-select v-model="reportForm.moduleId" :disabled="!!reportForm.id" style="width: 100%" @change="changeReportModule"><el-option v-for="item in reportModuleOptions" :key="item.id" :label="item.moduleName" :value="item.id" /></el-select><div class="percent-hint">仅展示当前登录人参与执行且尚未完成的模块;已有汇报不能更换模块。</div></el-form-item>
|
<el-form-item label="汇报人员比重" required>
|
<div class="member-editor">
|
<div v-for="(member,index) in reportForm.reporterShares" :key="index" class="reporter-row">
|
<el-select v-model="member.userId" filterable placeholder="请选择人员" @change="id => syncMemberName(member,id)"><el-option v-for="user in moduleReporterOptions" :key="user.value" :label="user.label" :value="user.value" /></el-select>
|
<el-input-number v-model="member.moduleShare" :min="0" :max="reporterShareLimit(index)" :precision="2" />
|
<span>%</span>
|
<el-button link type="danger" @click="reportForm.reporterShares.splice(index,1)">删除</el-button>
|
</div>
|
<el-button link type="primary" @click="reportForm.reporterShares.push({userId:null,userName:'',moduleShare:0})">+ 添加人员</el-button>
|
<div class="share-total">本次人员比重合计:{{ reportShareTotal }}%,剩余可分配:{{ reportShareRemaining }}%。每人的上限会随其他人员占比自动调整。</div>
|
</div>
|
</el-form-item>
|
<el-form-item label="本次进度" prop="incrementProgress"><el-input-number v-model="reportForm.incrementProgress" :min="0.01" :max="reportIncrementMax" :precision="2" /><span class="unit">%</span><span class="current-progress-hint">{{ reportProgressHint }}</span></el-form-item>
|
<el-form-item label="汇报时间" prop="reportDate"><el-date-picker v-model="reportForm.reportDate" :disabled="!!reportForm.id" :disabled-date="date => dayjs(date).isAfter(dayjs(), 'day')" type="date" value-format="YYYY-MM-DD" style="width: 100%" /><div v-if="reportForm.id" class="percent-hint">编辑已有汇报时不允许调整时间,以保证历史进度顺序正确。</div></el-form-item>
|
<el-form-item label="备注"><el-input v-model="reportForm.remark" type="textarea" :rows="3" maxlength="2000" show-word-limit /></el-form-item>
|
<el-form-item label="文件"><AttachmentUploadFile v-model:file-list="reportFiles" :limit="10" /></el-form-item>
|
</el-form>
|
<template #footer><el-button @click="reportVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitReport">{{ reportForm.id ? '保存修改' : '提交汇报' }}</el-button></template>
|
</el-dialog>
|
|
<el-dialog v-model="materialVisible" :title="materialForm.id ? '编辑项目资料' : '上传项目资料'" width="650px" destroy-on-close>
|
<el-form ref="materialFormRef" :model="materialForm" :rules="materialRules" label-width="90px">
|
<el-form-item label="资料名称" prop="materialName"><el-input v-model="materialForm.materialName" /></el-form-item>
|
<el-form-item label="资料类型"><el-select v-model="materialForm.materialType" allow-create filterable clearable style="width: 100%"><el-option v-for="type in ['立项资料','开发资料','申报资料','结果资料','验收资料','其他']" :key="type" :label="type" :value="type" /></el-select></el-form-item>
|
<el-form-item label="文件" required><AttachmentUploadFile v-model:file-list="materialFiles" :limit="20" /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="materialForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
</el-form>
|
<template #footer><el-button @click="materialVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitMaterial">保存</el-button></template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup>
|
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import * as echarts from 'echarts'
|
import dayjs from 'dayjs'
|
import AttachmentUploadFile from '@/components/AttachmentUpload/file/index.vue'
|
import { userListAll } from '@/api/publicApi'
|
import {
|
deleteProjectFollowUp, deleteProjectMaterial, deleteProjectProgressReport,
|
getProjectWorkflow, saveProjectFollowUp, saveProjectMaterial, saveProjectModulesBatch, saveProjectProgressReport
|
} from '@/api/projectManagement/workflow'
|
import useUserStore from '@/store/modules/user'
|
|
const props = defineProps({
|
projectId: [String, Number],
|
projectInfo: { type: Object, default: () => ({}) },
|
readonly: { type: Boolean, default: false },
|
singleTab: { type: String, default: '' }
|
})
|
const emit = defineEmits(['project-updated'])
|
const { proxy } = getCurrentInstance()
|
const userStore = useUserStore()
|
const loading = ref(false)
|
const saving = ref(false)
|
const activeTab = ref(props.singleTab || 'modules')
|
const reportModuleFilter = ref('all')
|
const userOptions = ref([])
|
const data = reactive({ modules: [], followUps: [], reports: [], materials: [], phases: [], phaseHistories: [], sourceBusiness: {}, memberStatistics: [], followWindow: {} })
|
const chartRef = ref()
|
let chart
|
|
const today = () => dayjs().format('YYYY-MM-DD')
|
const totalModuleWeight = computed(() => data.modules.reduce((sum, row) => sum + Number(row.projectWeight || 0), 0).toFixed(2))
|
const reportShareTotal = computed(() => reportForm.reporterShares.reduce((sum, row) => sum + Number(row.moduleShare || 0), 0).toFixed(2))
|
const reportShareRemaining = computed(() => Math.max(0, 100 - Number(reportShareTotal.value)).toFixed(2))
|
const followCycleLabel = computed(() => ({ MONTH: '每月', QUARTER: '每季度', YEAR: '每年' }[props.projectInfo?.followCycle] || props.projectInfo?.followCycle || '每月'))
|
const isStarted = computed(() => !!props.projectInfo?.currentPhase && props.projectInfo.currentPhase !== '未开始')
|
const canManageFollow = computed(() => String(props.projectInfo?.projectLeaderId ?? '') === String(userStore.id ?? ''))
|
const memberReportModules = computed(() => data.modules.filter(module =>
|
(module.members || []).some(member => String(member.userId) === String(userStore.id))
|
))
|
const eligibleReportModules = computed(() => memberReportModules.value.filter(module => Number(module.currentProgress || 0) < 100))
|
const reportModuleOptions = computed(() => reportForm.id ? memberReportModules.value : eligibleReportModules.value)
|
const selectedReportModule = computed(() => data.modules.find(module => String(module.id) === String(reportForm.moduleId)))
|
const reportBaseProgress = computed(() => Number(reportForm.id ? reportForm.previousProgress : selectedReportModule.value?.currentProgress || 0))
|
const reportIncrementMax = computed(() => {
|
if (!reportForm.id) return Math.max(0.01, Number((100 - reportBaseProgress.value).toFixed(2)))
|
const current = Number(selectedReportModule.value?.currentProgress || 0)
|
return Math.max(0.01, Number((Number(reportForm.originalIncrementProgress || 0) + 100 - current).toFixed(2)))
|
})
|
const reportProgressHint = computed(() => {
|
const current = Number(selectedReportModule.value?.currentProgress || 0).toFixed(2)
|
if (reportForm.id) return `该次汇报前为 ${reportBaseProgress.value.toFixed(2)}%,模块当前为 ${current}%`
|
return `模块当前进度 ${current}%,本次最多可汇报 ${reportIncrementMax.value}%`
|
})
|
const filteredReports = computed(() => reportModuleFilter.value === 'all'
|
? data.reports
|
: data.reports.filter(report => String(report.moduleId) === reportModuleFilter.value))
|
const filteredMemberStatistics = computed(() => {
|
if (reportModuleFilter.value === 'all') return data.memberStatistics || []
|
const totals = new Map()
|
for (const report of filteredReports.value) {
|
const incrementProgress = Number(report.incrementProgress ?? report.progress ?? 0)
|
for (const member of report.reporterShareList || []) {
|
const userId = String(member.userId)
|
const current = totals.get(userId) || { userId: member.userId, userName: member.userName || '未命名', value: 0 }
|
current.value += incrementProgress * Number(member.moduleShare || 0) / 100
|
totals.set(userId, current)
|
}
|
}
|
return [...totals.values()]
|
.map(item => ({ ...item, value: Number(item.value.toFixed(2)) }))
|
.sort((a, b) => b.value - a.value)
|
})
|
const reportStatisticsSummary = computed(() => {
|
const prefix = reportModuleFilter.value === 'all' ? '项目占比统计' : '模块占比统计'
|
if (!filteredMemberStatistics.value.length) return `${prefix}:暂无数据`
|
return `${prefix}:${filteredMemberStatistics.value.map(item => `${item.userName} ${item.value}%`).join(';')}`
|
})
|
const processStages = [
|
{ code: 'APPLICATION', label: '申报记录', phaseName: '申报', rank: 3 },
|
{ code: 'RESULT', label: '申报结果', phaseName: '申报结果', rank: 4 },
|
{ code: 'ACCEPTANCE', label: '验收记录', phaseName: '验收', rank: 5 }
|
]
|
const currentPhaseRank = computed(() => ({ 未开始: 0, 立项: 1, 开发: 2, 申报: 3, 申报结果: 4, 验收: 5, 已完成: 6 })[props.projectInfo?.currentPhase] || 0)
|
const visibleProcessStages = computed(() => processStages.filter(stage => currentPhaseRank.value >= stage.rank))
|
const moduleReporterOptions = computed(() => {
|
const members = selectedReportModule.value?.members || []
|
return members.map(member => ({
|
value: member.userId,
|
label: member.userName || userOptions.value.find(user => String(user.value) === String(member.userId))?.label || String(member.userId)
|
}))
|
})
|
|
const moduleVisible = ref(false)
|
const moduleDrafts = ref([])
|
let draftSequence = 0
|
const moduleDraftTotal = computed(() => moduleDrafts.value.reduce((sum, module) => sum + Number(module.projectWeight || 0), 0).toFixed(2))
|
const moduleDraftRemaining = computed(() => (100 - Number(moduleDraftTotal.value)).toFixed(2))
|
const followVisible = ref(false)
|
const followFormRef = ref()
|
const followForm = reactive({ id: null, followDate: today(), detail: '' })
|
const followFiles = ref([])
|
const followRules = { followDate: [{ required: true, message: '请选择跟进日期', trigger: 'change' }], detail: [{ required: true, message: '请输入跟进详情', trigger: 'blur' }] }
|
const reportVisible = ref(false)
|
const reportFormRef = ref()
|
const reportForm = reactive({ id: null, moduleId: null, reporterShares: [], incrementProgress: 0, originalIncrementProgress: 0, previousProgress: 0, reportDate: today(), remark: '' })
|
const reportFiles = ref([])
|
const reportRules = { moduleId: [{ required: true, message: '请选择项目模块', trigger: 'change' }], reportDate: [{ required: true, message: '请选择汇报时间', trigger: 'change' }] }
|
const materialVisible = ref(false)
|
const materialFormRef = ref()
|
const materialForm = reactive({ id: null, materialName: '', materialType: '', remark: '' })
|
const materialFiles = ref([])
|
const materialRules = { materialName: [{ required: true, message: '请输入资料名称', trigger: 'blur' }] }
|
async function loadUsers() {
|
const res = await userListAll()
|
const list = res?.data || res?.rows || res || []
|
userOptions.value = (Array.isArray(list) ? list : []).map(user => ({ label: user.nickName || user.userName || user.name, value: user.userId || user.id }))
|
}
|
|
async function loadData() {
|
if (!props.projectId) return
|
loading.value = true
|
try {
|
const res = await getProjectWorkflow(props.projectId)
|
Object.assign(data, res?.data || {})
|
await nextTick()
|
if (activeTab.value === 'reports') renderChart()
|
} finally { loading.value = false }
|
}
|
|
function newModuleDraft(row = {}) {
|
return {
|
id: row.id || null, localKey: row.id ? `module-${row.id}` : `new-${++draftSequence}`,
|
moduleName: row.moduleName || '', plannedStartDate: row.plannedStartDate || '', summary: row.summary || '',
|
projectWeight: Number(row.projectWeight || 0),
|
members: (row.members || []).map(member => {
|
const reportStats = memberReportedContribution(row, member.userId)
|
return { userId: member.userId, userName: member.userName, ...reportStats }
|
}),
|
locked: isStarted.value
|
}
|
}
|
|
function memberReportedContribution(module, userId) {
|
let hasReportedShare = false
|
const completedModuleShare = data.reports
|
.filter(report => String(report.moduleId) === String(module.id))
|
.reduce((total, report) => {
|
const memberShare = (report.reporterShareList || [])
|
.filter(member => String(member.userId) === String(userId))
|
.reduce((sum, member) => sum + Number(member.moduleShare || 0), 0)
|
if (memberShare > 0) hasReportedShare = true
|
const incrementProgress = Number(report.incrementProgress ?? report.progress ?? 0)
|
return total + incrementProgress * memberShare / 100
|
}, 0)
|
return { hasReportedShare, completedModuleShare: Number(completedModuleShare.toFixed(2)) }
|
}
|
|
function openModuleBatch() {
|
moduleDrafts.value = data.modules.map(newModuleDraft)
|
if (!moduleDrafts.value.length) addModuleDraft()
|
moduleVisible.value = true
|
}
|
|
function addModuleDraft() { moduleDrafts.value.push(newModuleDraft({ members: [{ userId: null, userName: '', hasReportedShare: false, completedModuleShare: 0 }] })) }
|
function removeModuleDraft(index) { moduleDrafts.value.splice(index, 1) }
|
function otherDraftWeight(index) { return moduleDrafts.value.reduce((sum, module, i) => i === index ? sum : sum + Number(module.projectWeight || 0), 0).toFixed(2) }
|
function draftWeightMax(index) { return Math.max(0.01, Number((100 - Number(otherDraftWeight(index))).toFixed(2))) }
|
|
function syncMemberName(member, id) { member.userName = userOptions.value.find(user => String(user.value) === String(id))?.label || '' }
|
|
async function submitModuleBatch() {
|
if (!moduleDrafts.value.length) return ElMessage.warning('请至少添加一个项目模块')
|
if (moduleDrafts.value.some(module => !module.moduleName?.trim() || !module.plannedStartDate || !module.summary?.trim())) return ElMessage.warning('请完整填写所有模块的名称、预计开始时间和模块概要')
|
if (Number(moduleDraftTotal.value) !== 100) return ElMessage.warning('所有模块的项目工作量占比合计必须为100%')
|
for (const module of moduleDrafts.value) {
|
if (!module.members.length || module.members.some(member => !member.userId)) return ElMessage.warning(`请为模块“${module.moduleName}”至少选择一名执行人`)
|
if (new Set(module.members.map(member => String(member.userId))).size !== module.members.length) return ElMessage.warning(`模块“${module.moduleName}”的执行人不能重复`)
|
}
|
saving.value = true
|
try {
|
await saveProjectModulesBatch({ projectId: props.projectId, modules: moduleDrafts.value })
|
ElMessage.success('全部模块保存成功'); moduleVisible.value = false; await loadData()
|
} finally { saving.value = false }
|
}
|
|
function openFollow(row) {
|
Object.assign(followForm, row
|
? { id: row.id, followDate: row.followDate, detail: row.detail || '' }
|
: { id: null, followDate: today(), detail: '' })
|
followFiles.value = row ? [...(row.attachmentList || [])] : []
|
followVisible.value = true
|
}
|
function normalizeFiles(list) { return list.map(file => ({ id: file.id ?? file.fileId ?? file.rawData?.id, name: file.name ?? file.fileName ?? file.rawData?.originalFilename, url: file.url ?? file.fileUrl ?? file.rawData?.url })).filter(file => file.id || file.url) }
|
|
async function submitFollow() {
|
await followFormRef.value?.validate(); saving.value = true
|
try { await saveProjectFollowUp({ ...followForm, projectId: props.projectId, attachments: normalizeFiles(followFiles.value) }); ElMessage.success('跟进已保存'); followVisible.value = false; await loadData() }
|
finally { saving.value = false }
|
}
|
|
function openReport(row) {
|
if (row) {
|
if (!memberReportModules.value.some(module => String(module.id) === String(row.moduleId))) return ElMessage.warning('只有该模块执行人才能编辑进度汇报')
|
const incrementProgress = Number(row.incrementProgress ?? row.progress ?? 0)
|
Object.assign(reportForm, {
|
id: row.id, moduleId: row.moduleId,
|
reporterShares: (row.reporterShareList || []).map(member => ({ ...member })),
|
incrementProgress, originalIncrementProgress: incrementProgress,
|
previousProgress: Number(row.previousProgress || 0), reportDate: row.reportDate, remark: row.remark || ''
|
})
|
reportFiles.value = [...(row.attachmentList || [])]
|
reportVisible.value = true
|
return
|
}
|
if (!eligibleReportModules.value.length) return ElMessage.warning('当前没有可汇报的未完成模块,或您不是模块执行人')
|
const userId = userStore.id
|
const userName = userOptions.value.find(user => String(user.value) === String(userId))?.label || userStore.name || ''
|
const module = reportModuleFilter.value === 'all'
|
? null
|
: eligibleReportModules.value.find(item => String(item.id) === reportModuleFilter.value)
|
Object.assign(reportForm, {
|
id: null,
|
moduleId: module?.id ?? null,
|
reporterShares: module && userId ? [{ userId, userName, moduleShare: 100 }] : [],
|
incrementProgress: 0,
|
originalIncrementProgress: 0,
|
previousProgress: Number(module?.currentProgress || 0),
|
reportDate: today(),
|
remark: ''
|
})
|
reportFiles.value = []; reportVisible.value = true
|
}
|
|
function changeReportModule(moduleId) {
|
const module = data.modules.find(item => String(item.id) === String(moduleId))
|
const userId = userStore.id
|
const userName = module?.members?.find(member => String(member.userId) === String(userId))?.userName || userStore.name || ''
|
reportForm.incrementProgress = 0
|
reportForm.previousProgress = Number(module?.currentProgress || 0)
|
reportForm.reporterShares = userId ? [{ userId, userName, moduleShare: 100 }] : []
|
}
|
|
function canEditReport(row) {
|
return (row.reporterIdList || []).some(userId => String(userId) === String(userStore.id))
|
}
|
|
function moduleDateDisabled(date) {
|
const start = props.projectInfo?.planStartTime
|
const end = props.projectInfo?.planEndTime
|
return (start && dayjs(date).isBefore(dayjs(start), 'day')) || (end && dayjs(date).isAfter(dayjs(end), 'day'))
|
}
|
|
function reporterShareLimit(index) {
|
const usedByOthers = reportForm.reporterShares.reduce((sum, member, memberIndex) =>
|
memberIndex === index ? sum : sum + Number(member.moduleShare || 0), 0)
|
return Math.max(0, Number((100 - usedByOthers).toFixed(2)))
|
}
|
|
async function submitReport() {
|
await reportFormRef.value?.validate()
|
if (!reportForm.reporterShares.length || reportForm.reporterShares.some(member => !member.userId)) return ElMessage.warning('请至少选择一名汇报人员')
|
if (new Set(reportForm.reporterShares.map(member => String(member.userId))).size !== reportForm.reporterShares.length) return ElMessage.warning('汇报人员不能重复')
|
if (Number(reportShareTotal.value) !== 100) return ElMessage.warning('本次汇报人员比重合计必须为100%')
|
if (Number(reportForm.incrementProgress || 0) <= 0) return ElMessage.warning('本次进度必须大于0')
|
saving.value = true
|
try {
|
await saveProjectProgressReport({ ...reportForm, projectId: props.projectId, attachments: normalizeFiles(reportFiles.value) })
|
ElMessage.success(reportForm.id ? '汇报修改已保存' : '汇报已提交')
|
reportVisible.value = false
|
await loadData()
|
emit('project-updated')
|
}
|
finally { saving.value = false }
|
}
|
|
function openMaterial(row = null) {
|
Object.assign(materialForm, row
|
? { id: row.id, materialName: row.materialName || '', materialType: row.materialType || '', remark: row.remark || '' }
|
: { id: null, materialName: '', materialType: '', remark: '' })
|
materialFiles.value = row ? [...(row.attachmentList || [])] : []
|
materialVisible.value = true
|
}
|
async function submitMaterial() {
|
await materialFormRef.value?.validate()
|
if (!materialFiles.value.length) return ElMessage.warning('请上传至少一个文件')
|
saving.value = true
|
try { await saveProjectMaterial({ ...materialForm, projectId: props.projectId, attachments: normalizeFiles(materialFiles.value) }); ElMessage.success('资料已保存'); materialVisible.value = false; await loadData() }
|
finally { saving.value = false }
|
}
|
|
async function removeRecord(type, row) {
|
await ElMessageBox.confirm('确认删除这条记录吗?', '提示', { type: 'warning' })
|
const api = { follow: deleteProjectFollowUp, report: deleteProjectProgressReport, material: deleteProjectMaterial }[type]
|
await api(row.id); ElMessage.success('删除成功'); await loadData()
|
if (type === 'report') emit('project-updated')
|
}
|
|
function download(file) { if (!file?.url) return ElMessage.warning('附件暂无下载地址'); proxy.$download.byUrl(file.url, file.name || file.fileName) }
|
|
function renderChart() {
|
if (!chartRef.value) return
|
chart ||= echarts.init(chartRef.value)
|
const source = filteredMemberStatistics.value
|
const isAll = reportModuleFilter.value === 'all'
|
chart.setOption({
|
title: { text: isAll ? '成员项目占比' : '成员模块占比', left: 'center', textStyle: { fontSize: 15 } },
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
legend: { bottom: 0 },
|
series: [{ type: 'pie', radius: ['38%', '68%'], center: ['50%', '48%'], data: source.map(item => ({ name: item.userName || '未命名', value: Number(item.value || 0) })), label: { formatter: '{b}\n{c}' } }]
|
})
|
}
|
|
function handleTabChange(name) { if (name === 'reports') nextTick(renderChart) }
|
function selectReportModule(moduleId) { reportModuleFilter.value = moduleId; nextTick(renderChart) }
|
function moduleReportCount(moduleId) { return data.reports.filter(report => String(report.moduleId) === String(moduleId)).length }
|
function stageRecords(phaseCode) {
|
const histories = (data.phaseHistories || []).filter(record => record.phaseCode === phaseCode)
|
if (histories.length) return histories
|
const snapshot = (data.phases || []).find(record => record.phaseCode === phaseCode)
|
if (!snapshot || snapshot.status === 'NOT_STARTED') return []
|
return [{ ...snapshot, actionLabel: snapshot.phaseName, actionType: snapshot.status === 'COMPLETED' ? 'PASS' : 'ENTER' }]
|
}
|
function formatActionTime(time) { return time ? String(time).replace('T', ' ') : '时间未记录' }
|
function phaseRecordType(type) { return ['RECTIFY', 'SUPPLEMENT'].includes(type) ? 'warning' : type === 'REJECTED' ? 'danger' : type === 'PASS' ? 'success' : 'primary' }
|
function recordStatusLabel(record) { return record.actionType === 'RECTIFY' ? '需要整改' : record.actionType === 'SUPPLEMENT' ? '需要补充' : record.actionType === 'REJECTED' ? '未通过' : record.actionType === 'PASS' ? '已通过' : '已办理' }
|
function phaseFieldRows(record) {
|
const form = record.formData || {}
|
const labels = {
|
applicationDate: '申报日期', applicationType: '申报类型', applicationName: '申报名称', expectedFeedbackDate: '预计反馈日期', description: '申报说明',
|
resultDate: '结果日期', resultStatus: '申报结果', resultDescription: '结果说明', acceptancePlanDate: '计划验收日期',
|
acceptor: '验收人/客户', acceptanceStandard: '验收标准', acceptanceDate: '验收日期', acceptanceResult: '验收结果', acceptanceDescription: '验收说明'
|
}
|
const enumLabels = { PASS: '通过', SUPPLEMENT: '需要补充', REJECTED: '未通过', RECTIFY: '需要整改' }
|
return Object.entries(labels).filter(([key]) => form[key] !== null && form[key] !== undefined && form[key] !== '').map(([key, label]) => ({ label, value: enumLabels[form[key]] || form[key] }))
|
}
|
function resizeChart() { chart?.resize() }
|
onMounted(async () => { await Promise.all([loadUsers(), loadData()]); window.addEventListener('resize', resizeChart) })
|
onBeforeUnmount(() => { window.removeEventListener('resize', resizeChart); chart?.dispose() })
|
defineExpose({ refresh: loadData })
|
</script>
|
|
<style scoped>
|
.execution-panel { min-height: 260px; }
|
.tab-actions { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 14px; }
|
.hint { color: #606266; font-size: 13px; }
|
.report-stat-summary { color: #606266; font-size: 13px; line-height: 1.7; }
|
.report-workbench { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 16px; align-items: start; }
|
.report-module-nav { padding: 12px; border: 1px solid #e4e7ed; border-radius: 10px; background: #f8fafc; }
|
.report-nav-title { padding: 4px 10px 10px; color: #303133; font-size: 14px; font-weight: 600; }
|
.report-nav-item { width: 100%; margin: 0 0 8px; padding: 11px 12px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: #303133; text-align: left; cursor: pointer; transition: all .2s; }
|
.report-nav-item:last-child { margin-bottom: 0; }
|
.report-nav-item:hover { border-color: #b8cdf8; background: #eef4ff; }
|
.report-nav-item.active { border-color: #8fb2f5; background: #e8f1ff; color: #0b46b5; box-shadow: 0 2px 7px rgba(11, 70, 181, .08); }
|
.report-nav-name, .report-nav-meta { display: block; }
|
.report-nav-name { overflow: hidden; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
.report-nav-meta { margin-top: 5px; color: #909399; font-size: 12px; line-height: 1.35; }
|
.report-nav-item.active .report-nav-meta { color: #5e7fbf; }
|
.report-content { min-width: 0; }
|
.member-tag { margin: 2px 6px 2px 0; }
|
.report-layout { display: grid; grid-template-columns: minmax(300px, 34%) 1fr; gap: 14px; }
|
.report-layout.report-only { grid-template-columns: 1fr; }
|
.chart { height: 390px; border: 1px solid #ebeef5; border-radius: 6px; }
|
.report-table { min-width: 0; }
|
.member-editor { width: 100%; }
|
.member-row { display: grid; grid-template-columns: minmax(260px, 1fr) auto 52px; align-items: center; gap: 10px; margin-bottom: 10px; }
|
.reporter-row { display: grid; grid-template-columns: 1fr 150px 24px 52px; align-items: center; gap: 10px; margin-bottom: 10px; }
|
.share-total { margin-top: 8px; color: #606266; }
|
.percent-hint { width: 100%; margin-top: 5px; color: #909399; font-size: 12px; line-height: 1.5; }
|
.dialog-tip { margin-bottom: 16px; }
|
.unit { margin-left: 8px; color: #606266; }
|
.current-progress-hint { margin-left: 14px; color: #909399; font-size: 12px; }
|
.phase-record-timeline { padding: 14px 12px 0 8px; }.phase-record-card { border-color: #dce6f5; }.phase-record-head { display:flex;align-items:center;justify-content:space-between;gap:16px; }.phase-record-head>div { display:flex;align-items:center;gap:10px; }.phase-record-head>span { color:#909399;font-size:12px; }.phase-record-remark,.phase-record-files { margin-top:14px;color:#606266;font-size:13px;line-height:1.7; }.phase-record-files { display:flex;align-items:center;flex-wrap:wrap;gap:4px; }
|
.file-cell { display:flex;flex-direction:column;align-items:flex-start;gap:2px; }.file-cell .el-button { max-width:100%;margin-left:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }.empty-file { color:#c0c4cc;font-size:12px; }
|
.module-total { margin: 0 0 14px; font-weight: 600; color: #067647; }
|
.module-total.invalid { color: #d97706; }
|
.module-batch-card { margin-bottom: 14px; padding: 16px 16px 4px; border: 1px solid #dcdfe6; border-radius: 8px; background: #fafcff; }
|
.module-batch-title { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
|
.module-batch-title .el-button { margin-left: auto; }
|
.locked-tip { color: #e6a23c; font-size: 12px; }
|
.module-grid { display: grid; grid-template-columns: 1.2fr 1fr 1.25fr; column-gap: 18px; }
|
.summary-item, .members-item { grid-column: 1 / -1; }
|
@media (max-width: 1200px) { .report-layout { grid-template-columns: 1fr; } }
|
@media (max-width: 900px) { .report-workbench { grid-template-columns: 1fr; } .report-module-nav { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; } .report-nav-title { grid-column: 1 / -1; } .report-nav-item { margin: 0; } }
|
</style>
|