huminmin
2026-06-01 a563ea879ef5fb6897e76d2df661e465dce2ab9b
src/views/projectManagement/Management/projectDetail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,532 @@
<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,
      storageBlobDTOs: Array.isArray(payload.storageBlobDTOs) ? payload.storageBlobDTOs : []
    }
    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(row) {
  if (row?.url) {
    try {
         proxy.$download.byUrl(row.url, row.originalFilename);
      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>