<template>
|
<div class="app-container">
|
<el-card class="header-card" shadow="never">
|
<div class="header-row">
|
<div class="header-title">项目详情</div>
|
<div class="header-actions">
|
<el-button style="color: white;background: #002FA7;" @click="openProgressReport">进度汇报</el-button>
|
<!-- <el-button type="danger" @click="openDiscussProgress">洽谈进度</el-button> -->
|
<el-button @click="goBack">返回</el-button>
|
</div>
|
</div>
|
<el-steps v-if="steps.length > 0" :active="activeStep" align-center finish-status="success">
|
<el-step v-for="(s, idx) in steps" :key="idx" :title="s" />
|
</el-steps>
|
</el-card>
|
|
<el-card class="content-card" shadow="never" v-loading="loading">
|
<el-tabs v-model="activeTab">
|
<el-tab-pane label="基础资料" name="base">
|
<el-descriptions :column="4" border>
|
<el-descriptions-item label="项目ID">{{ info.id ?? '-' }}</el-descriptions-item>
|
<el-descriptions-item label="单据编号">{{ info.no || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="项目名称">{{ info.title || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="客户名称">{{ info.clientName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="客户ID">{{ info.clientId ?? '-' }}</el-descriptions-item>
|
<el-descriptions-item label="父项目">{{ parentProjectLabel }}</el-descriptions-item>
|
<el-descriptions-item label="项目类型">{{ projectTypeLabel }}</el-descriptions-item>
|
<el-descriptions-item label="立项日期">{{ info.establishTime || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="项目来源">{{ info.source || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="立项人">{{ info.managerName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="业务员">{{ info.salesmanName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="部门">{{ info.departmentName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="单据状态">
|
<dict-tag :options="bill_status" :value="String(info.status ?? '')" />
|
</el-descriptions-item>
|
<el-descriptions-item label="审核状态">
|
<dict-tag :options="project_management" :value="String(info.reviewStatus ?? '')" />
|
</el-descriptions-item>
|
<el-descriptions-item label="计划状态">
|
<dict-tag :options="plan_status" :value="String(info.stage ?? '')" />
|
</el-descriptions-item>
|
<el-descriptions-item label="预计工期(天)">{{ estimatedDays }}</el-descriptions-item>
|
<el-descriptions-item label="计划开始日期">{{ info.planStartTime || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="计划完成日期">{{ info.planEndTime || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="实际开始日期">{{ info.actualStartTime || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="实际完成日期">{{ info.actualEndTime || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="订单日期">{{ info.orderDate || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="项目金额">{{ info.orderAmount ?? '-' }}</el-descriptions-item>
|
<el-descriptions-item label="备注" :span="4">{{ info.remark || '-' }}</el-descriptions-item>
|
</el-descriptions>
|
|
<div class="attachment-block" v-if="attachments.length > 0">
|
<div class="attachment-title">附件</div>
|
<div class="attachment-list">
|
<div v-for="(att, idx) in attachments" :key="att.id || att.url || idx" class="attachment-item">
|
<el-icon><Document /></el-icon>
|
<span class="attachment-name">{{ att.name || att.fileName || att.url || '附件' }}</span>
|
<el-button link type="primary" size="small" @click="downloadAttachment(att)">下载</el-button>
|
</div>
|
</div>
|
</div>
|
|
<el-divider content-position="left">产品信息</el-divider>
|
<el-table :data="productRows" border show-summary :summary-method="summarizeProductTable">
|
<el-table-column align="center" label="序号" type="index" width="60" />
|
<el-table-column label="产品大类" prop="productCategory" show-overflow-tooltip />
|
<el-table-column label="规格型号" prop="specificationModel" show-overflow-tooltip />
|
<el-table-column label="单位" prop="unit" width="90" />
|
<el-table-column label="数量" prop="quantity" width="90" />
|
<el-table-column label="税率(%)" prop="taxRate" width="90" />
|
<el-table-column label="含税单价(元)" prop="taxInclusiveUnitPrice" :formatter="formattedNumber" />
|
<el-table-column label="含税总价(元)" prop="taxInclusiveTotalPrice" :formatter="formattedNumber" />
|
<el-table-column label="不含税总价(元)" prop="taxExclusiveTotalPrice" :formatter="formattedNumber" />
|
<el-table-column label="发票类型" prop="invoiceType" width="110" />
|
</el-table>
|
|
<el-divider content-position="left">项目团队</el-divider>
|
<el-table :data="teamRows" border>
|
<el-table-column align="center" label="序号" type="index" width="60" />
|
<el-table-column label="姓名" prop="userName" show-overflow-tooltip />
|
<el-table-column label="项目组角色" prop="userRoleName" show-overflow-tooltip />
|
<el-table-column label="进入日期" prop="joinTime" width="140" />
|
<el-table-column label="离开日期" prop="departTime" width="140" />
|
<el-table-column label="联系方式" prop="contact" show-overflow-tooltip />
|
<el-table-column label="备注" prop="remark" show-overflow-tooltip />
|
</el-table>
|
|
<el-divider content-position="left">收货地址</el-divider>
|
<el-descriptions :column="3" border>
|
<el-descriptions-item label="收货人">{{ shippingAddress.consignee || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="联系方式">{{ shippingAddress.contract || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="地址">{{ shippingAddress.address || '-' }}</el-descriptions-item>
|
</el-descriptions>
|
|
<el-divider content-position="left">联系信息</el-divider>
|
<el-descriptions :column="4" border>
|
<el-descriptions-item label="联系人">{{ contractInfo.name || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="性别">{{ contractInfo.sex || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="生日">{{ contractInfo.birthday || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="部门">{{ contractInfo.department || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="职务">{{ contractInfo.job || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="手机号">{{ contractInfo.phoneNumber || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="邮箱">{{ contractInfo.email || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="QQ">{{ contractInfo.qq || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="固定电话">{{ contractInfo.lineaFissa || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="微信">{{ contractInfo.wx || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="籍贯">{{ contractInfo.origineEtnica || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="法人代表">{{ contractInfo.rappresentanteLegale || '-' }}</el-descriptions-item>
|
</el-descriptions>
|
</el-tab-pane>
|
|
<el-tab-pane label="项目类型" name="plan">
|
<el-descriptions :column="4" border>
|
<el-descriptions-item label="类型名称">{{ projectPlan.name || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="备注" :span="3">{{ projectPlan.description || '-' }}</el-descriptions-item>
|
</el-descriptions>
|
|
<div class="attachment-block" v-if="planAttachments.length > 0">
|
<div class="attachment-title">类型附件</div>
|
<div class="attachment-list">
|
<div v-for="(att, idx) in planAttachments" :key="att.id || att.url || idx" class="attachment-item">
|
<el-icon><Document /></el-icon>
|
<span class="attachment-name">{{ att.name || att.fileName || att.url || '附件' }}</span>
|
<el-button link type="primary" size="small" @click="downloadAttachment(att)">下载</el-button>
|
</div>
|
</div>
|
</div>
|
|
<el-table :data="planNodeRows" border style="margin-top: 14px;">
|
<el-table-column align="center" label="步骤" type="index" width="80" />
|
<el-table-column label="阶段名称" prop="name" min-width="160" show-overflow-tooltip />
|
<el-table-column label="负责人" prop="leaderName" width="140" show-overflow-tooltip />
|
<el-table-column label="预计工期(天)" prop="estimatedDuration" width="140" />
|
<el-table-column label="工时单价" prop="hourlyRate" width="120" />
|
<el-table-column label="作业内容" prop="workContent" min-width="180" show-overflow-tooltip />
|
</el-table>
|
</el-tab-pane>
|
|
<el-tab-pane label="项目阶段" name="stage">
|
<el-table :data="stageNodeRows" border style="margin-top: 14px;">
|
<el-table-column align="center" label="序号" type="index" width="60" />
|
<el-table-column label="阶段" prop="stageName" min-width="160" show-overflow-tooltip />
|
<el-table-column label="描述" prop="description" min-width="220" show-overflow-tooltip />
|
<el-table-column label="实际负责人" prop="actualLeaderName" width="140" show-overflow-tooltip />
|
<el-table-column label="进度(%)" prop="progress" width="110" />
|
<el-table-column label="计划开始" prop="planStartTime" width="120" />
|
<el-table-column label="计划结束" prop="planEndTime" width="120" />
|
<el-table-column label="实际开始" prop="actualStartTime" width="120" />
|
<el-table-column label="实际结束" prop="actualEndTime" width="120" />
|
<el-table-column label="预计工期(天)" prop="estimatedDuration" width="130" />
|
<el-table-column label="附件" width="90" align="center">
|
<template #default="{ row }">
|
<span>{{ row.attachmentCount }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column v-if="false" label="操作" width="100" align="center" fixed="right" >
|
<template #default="{ row }">
|
<el-button link type="danger" size="small" @click="handleDeleteStage(row)">删除</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
</el-tab-pane>
|
</el-tabs>
|
</el-card>
|
</div>
|
|
<ProgressReportDialog
|
v-model="progressReportVisible"
|
:project-id="info.id"
|
:project-info="info"
|
:plan-nodes="planNodeRows"
|
:default-plan-node-id="defaultPlanNodeId"
|
@submitted="handleProgressSubmitted"
|
/>
|
<DiscussProgressDialog
|
v-model="discussProgressVisible"
|
:project-id="info.id"
|
:plan-nodes="planNodeRows"
|
:default-plan-node-id="defaultPlanNodeId"
|
@submitted="handleDiscussSubmitted"
|
/>
|
</template>
|
|
<script setup name="ProjectManagementDetail">
|
import { computed, getCurrentInstance, onMounted, ref } from 'vue'
|
import { useRoute, useRouter } from 'vue-router'
|
import { Document } from '@element-plus/icons-vue'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { getProject, saveStage, listStage, deleteStage } from '@/api/projectManagement/project'
|
import { listPlan } from '@/api/projectManagement/projectType'
|
import ProgressReportDialog from '@/components/ProjectManagement/ProgressReportDialog.vue'
|
import DiscussProgressDialog from '@/components/ProjectManagement/DiscussProgressDialog.vue'
|
import useUserStore from '@/store/modules/user'
|
|
const { proxy } = getCurrentInstance()
|
const route = useRoute()
|
const router = useRouter()
|
const { bill_status, project_management, plan_status } = proxy.useDict('bill_status', 'project_management', 'plan_status')
|
|
const loading = ref(false)
|
const activeTab = ref('base')
|
const progressReportVisible = ref(false)
|
const discussProgressVisible = ref(false)
|
const userStore = useUserStore()
|
|
const info = ref({})
|
const shippingAddress = ref({})
|
const contractInfo = ref({})
|
const productRows = ref([])
|
const teamRows = ref([])
|
const attachments = ref([])
|
const projectTypeMap = ref(new Map())
|
const projectPlan = ref({})
|
const planNodeRows = ref([])
|
const planAttachments = ref([])
|
const stageNodeRows = ref([])
|
|
const estimatedDays = computed(() => {
|
const raw = info.value?.estimatedDays
|
const n = Number(raw)
|
if (Number.isFinite(n) && n > 0) return n
|
const start = info.value?.planStartTime
|
const end = info.value?.planEndTime
|
if (!start || !end) return 0
|
const startTime = new Date(`${start}T00:00:00`).getTime()
|
const endTime = new Date(`${end}T00:00:00`).getTime()
|
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) return 0
|
return Math.floor((endTime - startTime) / (24 * 60 * 60 * 1000)) + 1
|
})
|
|
const projectTypeLabel = computed(() => {
|
const id = info.value?.projectManagementPlanId
|
if (id === undefined || id === null || id === '') return '-'
|
const p = projectTypeMap.value.get(Number(id))
|
return p?.name || String(id)
|
})
|
|
const parentProjectLabel = computed(() => {
|
return (
|
info.value?.parentTitle ||
|
info.value?.projectManagementInfoParentName ||
|
info.value?.projectManagementInfoParentId ||
|
'-'
|
)
|
})
|
|
const planStageEnum = computed(() => {
|
const list = Array.isArray(plan_status) ? plan_status : []
|
return list.map(i => ({ value: String(i.value), label: i.label }))
|
})
|
|
const steps = computed(() => {
|
const nodes = Array.isArray(planNodeRows.value) ? planNodeRows.value : []
|
const sorted = [...nodes].sort((a, b) => Number(a.sort ?? 0) - Number(b.sort ?? 0))
|
const labels = sorted.map(i => i.name || i.workContent).filter(Boolean)
|
return labels.length > 0 ? labels : planStageEnum.value.map(i => i.label)
|
})
|
|
const activeStep = computed(() => {
|
const statusOrStage = info.value?.stage ?? info.value?.status
|
const enumList = planStageEnum.value
|
// 优先使用 planStageEnum 的 value 匹配
|
const found = enumList.find(i => i.value === String(statusOrStage))
|
const label = found?.label
|
// 在项目类型节点中查找对应 label 的下标
|
const nodeLabels = steps.value
|
const idxByLabel = label ? nodeLabels.findIndex(l => String(l) === String(label)) : -1
|
if (idxByLabel >= 0) return idxByLabel + 1
|
// 回退:如果 statusOrStage 是数字索引
|
const n = Number(statusOrStage)
|
if (Number.isFinite(n) && n > 0) return Math.min(n, nodeLabels.length)
|
return 0
|
})
|
|
function goBack() {
|
router.back()
|
}
|
|
function openProgressReport() {
|
progressReportVisible.value = true
|
}
|
|
function openDiscussProgress() {
|
discussProgressVisible.value = true
|
}
|
|
const defaultPlanNodeId = computed(() => {
|
const nodes = Array.isArray(planNodeRows.value) ? planNodeRows.value : []
|
if (nodes.length === 0) return undefined
|
const stageVal = info.value?.stage
|
const direct = nodes.find(n => String(n.id) === String(stageVal))
|
if (direct?.id) return direct.id
|
const sorted = [...nodes].sort((a, b) => Number(a.sort ?? 0) - Number(b.sort ?? 0))
|
const idx = Number(stageVal)
|
if (Number.isFinite(idx)) {
|
const byIndex = sorted[idx - 1] || sorted[idx] || sorted[0]
|
if (byIndex?.id) return byIndex.id
|
}
|
return sorted[0]?.id
|
})
|
|
async function handleProgressSubmitted(payload) {
|
try {
|
const nodes = Array.isArray(planNodeRows.value) ? planNodeRows.value : []
|
const node = nodes.find(n => String(n.id) === String(payload.planNodeId)) || {}
|
const description = payload.remark
|
? `${payload.reportDate || ''} ${payload.remark}`.trim()
|
: `${payload.reportDate || ''} 进度汇报`.trim()
|
const req = {
|
id: null,
|
projectManagementPlanNodeId: payload.planNodeId,
|
projectManagementInfoId: info.value?.id,
|
description,
|
actualLeaderId: userStore.id || info.value?.managerId,
|
actualLeaderName: userStore.nickName || info.value?.managerName || info.value?.managerName,
|
estimatedDuration: Number(node.estimatedDuration ?? 0) || 0,
|
planStartTime: payload.planStartTime || info.value?.planStartTime,
|
planEndTime: payload.planEndTime || info.value?.planEndTime,
|
actualStartTime: payload.actualStartTime || null,
|
actualEndTime: payload.actualEndTime || null,
|
progress: Number(payload.totalProgress ?? payload.completionProgress ?? 0) || 0,
|
attachmentIds: Array.isArray(payload.attachmentIds) ? payload.attachmentIds : []
|
}
|
const res = await saveStage(req)
|
if (res?.code === 200) {
|
ElMessage.success('提交成功')
|
await Promise.all([loadDetail(), loadStageList()])
|
return
|
}
|
ElMessage.error(res?.msg || '提交失败')
|
} catch (e) {
|
ElMessage.error('提交失败')
|
}
|
}
|
|
async function handleDiscussSubmitted(payload) {
|
try {
|
const nodes = Array.isArray(planNodeRows.value) ? planNodeRows.value : []
|
const node = nodes.find(n => String(n.id) === String(payload.planNodeId)) || {}
|
const req = {
|
id: null,
|
projectManagementPlanNodeId: payload.planNodeId,
|
projectManagementInfoId: info.value?.id,
|
description: payload.remark,
|
actualLeaderId: userStore.id || info.value?.managerId,
|
actualLeaderName: userStore.nickName || info.value?.managerName || info.value?.managerName,
|
estimatedDuration: Number(node.estimatedDuration ?? 0) || 0,
|
planStartTime: info.value?.planStartTime,
|
planEndTime: info.value?.planEndTime,
|
actualStartTime: info.value?.actualStartTime || null,
|
actualEndTime: info.value?.actualEndTime || null,
|
progress: Number(info.value?.progress ?? 0) || 0,
|
attachmentIds: Array.isArray(payload.attachmentIds) ? payload.attachmentIds : []
|
}
|
const res = await saveStage(req)
|
if (res?.code === 200) {
|
ElMessage.success('提交成功')
|
await Promise.all([loadDetail(), loadStageList()])
|
return
|
}
|
ElMessage.error(res?.msg || '提交失败')
|
} catch (e) {
|
ElMessage.error('提交失败')
|
}
|
}
|
|
function downloadAttachment(att) {
|
if (att?.url) {
|
try {
|
proxy.$download.resource(att.url)
|
return
|
} catch (e) {}
|
}
|
if (att?.name) {
|
try {
|
proxy.$download.name(att.name, false)
|
return
|
} catch (e) {}
|
}
|
ElMessage.warning('附件暂无下载地址')
|
}
|
|
async function loadProjectTypeMap() {
|
try {
|
const res = await listPlan({ current: 1, size: 999 })
|
const records = res?.data?.records || res?.records || res?.rows || []
|
projectTypeMap.value = new Map((records || []).map(r => [Number(r.id), r]))
|
} catch {
|
projectTypeMap.value = new Map()
|
}
|
}
|
|
function getPlanNodeName(planNodeId) {
|
const list = Array.isArray(planNodeRows.value) ? planNodeRows.value : []
|
const node = list.find(n => String(n.id) === String(planNodeId))
|
return node?.name || node?.workContent || String(planNodeId ?? '')
|
}
|
|
async function loadStageList() {
|
const projectId = info.value?.id
|
if (!projectId) {
|
stageNodeRows.value = []
|
return
|
}
|
try {
|
const res = await listStage(projectId)
|
const data = res?.data?.data ?? res?.data ?? res
|
const list = data?.records || data?.rows || data?.list || data || []
|
const records = Array.isArray(list) ? list : []
|
stageNodeRows.value = records.map(r => {
|
const attachmentList = Array.isArray(r.attachmentList) ? r.attachmentList : []
|
const attachmentIds = Array.isArray(r.attachmentIds) ? r.attachmentIds : []
|
return {
|
...r,
|
stageName: getPlanNodeName(r.projectManagementPlanNodeId),
|
attachmentCount: attachmentList.length || attachmentIds.length || 0
|
}
|
})
|
} catch {
|
stageNodeRows.value = []
|
}
|
}
|
|
async function handleDeleteStage(row) {
|
const stageId = row?.id
|
if (!stageId) return
|
try {
|
await ElMessageBox.confirm('是否确认删除该项目阶段?', '提示', {
|
confirmButtonText: '确定',
|
cancelButtonText: '取消',
|
type: 'warning'
|
})
|
const res = await deleteStage(stageId)
|
if (res?.code === 200) {
|
ElMessage.success('删除成功')
|
await loadStageList()
|
return
|
}
|
ElMessage.error(res?.msg || '删除失败')
|
} catch {}
|
}
|
|
function syncProjectPlan() {
|
const id = info.value?.projectManagementPlanId
|
if (id === undefined || id === null || id === '') {
|
projectPlan.value = {}
|
planNodeRows.value = []
|
planAttachments.value = []
|
return
|
}
|
const plan = projectTypeMap.value.get(Number(id)) || {}
|
projectPlan.value = plan || {}
|
planNodeRows.value = Array.isArray(plan?.planNodeList) ? plan.planNodeList : []
|
planAttachments.value = Array.isArray(plan?.attachmentList) ? plan.attachmentList : []
|
}
|
|
async function loadDetail() {
|
const id = route.params?.id
|
if (!id) return
|
loading.value = true
|
try {
|
const res = await getProject(id)
|
const detail = res?.data?.data ?? res?.data ?? res
|
info.value = detail?.info || {}
|
shippingAddress.value = detail?.shippingAddress || {}
|
contractInfo.value = detail?.contractInfo || {}
|
productRows.value = Array.isArray(detail?.salesLedgerProductList) ? detail.salesLedgerProductList : []
|
teamRows.value = Array.isArray(detail?.info?.teamList) ? detail.info.teamList : []
|
attachments.value = Array.isArray(detail?.info?.attachmentList) ? detail.info.attachmentList : []
|
syncProjectPlan()
|
await loadStageList()
|
} finally {
|
loading.value = false
|
}
|
}
|
|
onMounted(async () => {
|
await loadProjectTypeMap()
|
await loadDetail()
|
})
|
</script>
|
|
<style scoped lang="scss">
|
.section-bar {
|
width: 3px;
|
height: 14px;
|
background: #002FA7;
|
border-radius: 2px;
|
}
|
.header-card {
|
margin-bottom: 14px;
|
}
|
|
.header-row {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
margin-bottom: 12px;
|
}
|
|
.header-title {
|
font-weight: 600;
|
font-size: 16px;
|
}
|
|
.content-card {
|
border-radius: 8px;
|
}
|
|
.attachment-block {
|
margin-top: 14px;
|
}
|
|
.attachment-title {
|
font-weight: 600;
|
margin-bottom: 8px;
|
}
|
|
.attachment-list {
|
display: flex;
|
flex-direction: column;
|
gap: 8px;
|
}
|
|
.attachment-item {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
}
|
|
.attachment-name {
|
max-width: 520px;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
</style>
|