ZN
3 天以前 fa113c192df7e3b5aad9dcb465307dcffb58f6c5
feat(项目管理): 新增项目详情页与进度汇报功能

- 新增项目详情页,展示项目基础资料、项目类型、阶段等详细信息
- 新增进度汇报对话框,支持填写项目阶段、计划时间、进度百分比等信息
- 新增洽谈进度对话框,支持记录洽谈进展
- 在项目管理列表页添加进度汇报功能,支持从列表直接进行进度汇报
- 新增项目阶段相关API接口(保存、查询、删除阶段)
- 优化按钮加载状态,防止重复提交
- 调整样式颜色,统一视觉风格
已添加3个文件
已修改4个文件
1220 ■■■■■ 文件已修改
src/api/projectManagement/project.js 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ProjectManagement/DiscussProgressDialog.vue 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ProjectManagement/ProgressReportDialog.vue 282 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/index.js 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/Management/components/formDia.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/Management/index.vue 221 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/Management/projectDetail.vue 538 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/projectManagement/project.js
@@ -71,6 +71,29 @@
  })
}
// é˜¶æ®µ
export function saveStage(data) {
  return request({
    url: '/projectManagement/info/saveStage',
    method: 'post',
    data: data
  })
}
export function listStage(projectId) {
  return request({
    url: `/projectManagement/info/listStage/${projectId}`,
    method: 'post'
  })
}
export function deleteStage(stageId) {
  return request({
    url: `/projectManagement/info/deleteStage/${stageId}`,
    method: 'post'
  })
}
export function listPlan(data) {
  return request({
    url: '/projectManagement/plan/listPage',
src/components/ProjectManagement/DiscussProgressDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
<template>
  <el-dialog v-model="visible" title="洽谈进度" width="700px" top="10vh" append-to-body destroy-on-close @close="handleClose">
    <el-form ref="formRef" :model="form" :rules="rules" label-position="top" label-width="120px">
      <el-form-item label="项目阶段" prop="planNodeId">
        <el-select v-model="form.planNodeId" placeholder="请选择" clearable style="width: 100%">
          <el-option v-for="opt in stageOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
      </el-form-item>
      <el-form-item label="备注" prop="remark">
        <el-input v-model="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入" />
      </el-form-item>
      <el-form-item label="附件" prop="attachmentIds">
        <el-upload
          v-model:file-list="fileList"
          :action="upload.url"
          :headers="upload.headers"
          multiple
          name="files"
          :on-success="handleUploadSuccess"
          :on-error="handleUploadError"
          :on-remove="handleRemove"
        >
          <el-button type="primary">上传文件</el-button>
        </el-upload>
      </el-form-item>
    </el-form>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="visible = false">取消</el-button>
        <el-button type="danger" @click="submit">提交</el-button>
      </div>
    </template>
  </el-dialog>
</template>
<script setup name="DiscussProgressDialog">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getToken } from '@/utils/auth'
const props = defineProps({
  modelValue: { type: Boolean, default: false },
  projectId: { type: [Number, String], default: undefined },
  planNodes: { 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 upload = reactive({
  url: import.meta.env.VITE_APP_BASE_API + '/basic/customer-follow/upload',
  headers: { Authorization: 'Bearer ' + getToken() }
})
const formRef = ref()
const fileList = ref([])
const form = ref({
  planNodeId: undefined,
  remark: '',
  attachmentIds: []
})
const rules = {
  planNodeId: [{ required: true, message: '请选择', trigger: 'change' }],
  remark: [{ required: true, message: '请输入', trigger: 'blur' }]
}
const stageOptions = computed(() => {
  const list = Array.isArray(props.planNodes) ? props.planNodes : []
  const sorted = [...list].sort((a, b) => Number(a.sort ?? 0) - Number(b.sort ?? 0))
  return sorted
    .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 !== '')
})
watch(
  () => props.modelValue,
  v => {
    if (v) {
      form.value = { planNodeId: props.defaultPlanNodeId ?? stageOptions.value[0]?.value, remark: '', attachmentIds: [] }
      fileList.value = []
    }
  }
)
function handleClose() {
  formRef.value?.resetFields?.()
}
function handleUploadError() {
  ElMessage.error('上传文件失败')
}
function handleUploadSuccess(res, file) {
  if (res?.code !== 200) {
    ElMessage.error(res?.msg || '上传失败')
    return
  }
  const attachmentId = res?.data?.id ?? res?.data?.tempId ?? ''
  if (!attachmentId) return
  form.value.attachmentIds.push(attachmentId)
  try {
    file.attachmentId = attachmentId
  } catch (e) {}
  ElMessage.success('上传成功')
}
function handleRemove(file) {
  const attachmentId = file?.attachmentId
  if (!attachmentId) return
  form.value.attachmentIds = (form.value.attachmentIds || []).filter(id => id !== attachmentId)
}
async function submit() {
  await formRef.value?.validate?.()
  emit('submitted', {
    projectId: props.projectId,
    ...form.value
  })
  visible.value = false
}
</script>
<style scoped lang="scss">
.dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}
</style>
src/components/ProjectManagement/ProgressReportDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,282 @@
<template>
  <el-dialog v-model="visible" title="进度汇报" width="900px" top="8vh" append-to-body destroy-on-close @close="handleClose">
    <el-form ref="formRef" :model="form" :rules="rules" label-position="top" label-width="120px">
      <el-form-item label="项目阶段" prop="planNodeId">
        <el-select v-model="form.planNodeId" placeholder="请选择" clearable style="width: 100%">
          <el-option v-for="opt in stageOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
      </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="上次进度(%)" prop="lastProgress">
            <el-input-number v-model="form.lastProgress" :min="0" :max="100" controls-position="right" style="width: 100%" />
          </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>
      <el-form-item label="附件" prop="attachmentIds">
        <el-upload
          v-model:file-list="fileList"
          :action="upload.url"
          :headers="upload.headers"
          multiple
          name="files"
          :on-success="handleUploadSuccess"
          :on-error="handleUploadError"
          :on-remove="handleRemove"
        >
          <el-button type="primary">上传文件</el-button>
        </el-upload>
      </el-form-item>
    </el-form>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="visible = false">取消</el-button>
        <el-button type="primary" @click="submit">确定</el-button>
      </div>
    </template>
  </el-dialog>
</template>
<script setup name="ProgressReportDialog">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getToken } from '@/utils/auth'
const props = defineProps({
  modelValue: { type: Boolean, default: false },
  projectId: { type: [Number, String], default: undefined },
  projectInfo: { type: Object, default: () => ({}) },
  planNodes: { 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 fileList = ref([])
const upload = reactive({
  url: import.meta.env.VITE_APP_BASE_API + '/basic/customer-follow/upload',
  headers: { Authorization: 'Bearer ' + getToken() }
})
const form = ref({
  planNodeId: undefined,
  planStartTime: '',
  planEndTime: '',
  actualStartTime: '',
  actualEndTime: '',
  reportDate: '',
  lastProgress: 0,
  completionProgress: 0,
  totalProgress: 0,
  managerName: '',
  departmentName: '',
  remark: '',
  attachmentIds: []
})
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 stageOptions = computed(() => {
  const list = Array.isArray(props.planNodes) ? props.planNodes : []
  const sorted = [...list].sort((a, b) => Number(a.sort ?? 0) - Number(b.sort ?? 0))
  return sorted
    .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 !== '')
})
function resetFromProject() {
  const info = props.projectInfo || {}
  form.value = {
    planNodeId: props.defaultPlanNodeId ?? stageOptions.value[0]?.value,
    planStartTime: info.planStartTime || '',
    planEndTime: info.planEndTime || '',
    actualStartTime: info.actualStartTime || '',
    actualEndTime: info.actualEndTime || '',
    reportDate: '',
    lastProgress: Number(info.lastProgress ?? 0) || 0,
    completionProgress: 0,
    totalProgress: Number(info.totalProgress ?? info.progress ?? 0) || 0,
    managerName: info.managerName || '',
    departmentName: info.departmentName || '',
    remark: '',
    attachmentIds: []
  }
  fileList.value = []
}
watch(
  () => props.modelValue,
  v => {
    if (v) resetFromProject()
  }
)
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 || ''
}
function handleUploadError() {
  ElMessage.error('上传文件失败')
}
function handleUploadSuccess(res, file) {
  if (res?.code !== 200) {
    ElMessage.error(res?.msg || '上传失败')
    return
  }
  const attachmentId = res?.data?.id ?? res?.data?.tempId ?? ''
  if (!attachmentId) return
  form.value.attachmentIds.push(attachmentId)
  try {
    file.attachmentId = attachmentId
  } catch (e) {}
  ElMessage.success('上传成功')
}
function handleRemove(file) {
  const attachmentId = file?.attachmentId
  if (!attachmentId) return
  form.value.attachmentIds = (form.value.attachmentIds || []).filter(id => id !== attachmentId)
}
async function submit() {
  await formRef.value?.validate?.()
  emit('submitted', {
    projectId: props.projectId,
    ...form.value
  })
  visible.value = false
}
</script>
<style scoped lang="scss">
.dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}
</style>
src/router/index.js
@@ -106,6 +106,19 @@
      },
    ],
  },
  {
    path: "/projectManagement/Management/detail",
    component: Layout,
    hidden: true,
    children: [
      {
        path: ":id",
        component: () => import("@/views/projectManagement/Management/projectDetail.vue"),
        name: "ProjectManagementDetail",
        meta: { title: "项目详情", activeMenu: "/projectManagement/Management" },
      },
    ],
  },
];
// åŠ¨æ€è·¯ç”±ï¼ŒåŸºäºŽç”¨æˆ·æƒé™åŠ¨æ€åŽ»åŠ è½½
src/views/projectManagement/Management/components/formDia.vue
@@ -1474,7 +1474,7 @@
.section-bar {
  width: 3px;
  height: 14px;
  background: #e61e1e;
  background: #002FA7;
  border-radius: 2px;
}
src/views/projectManagement/Management/index.vue
@@ -35,10 +35,10 @@
            </el-dropdown-menu>
          </template>
        </el-dropdown> -->
        <el-button @click="handleSubmit">提交</el-button>
        <el-button @click="handleAudit">审核</el-button>
        <el-button @click="handleReverseAudit">反审核</el-button>
        <el-button @click="handleDelete">删除</el-button>
        <el-button :loading="submitLoading" @click="handleSubmit">提交</el-button>
        <el-button :loading="auditLoading" @click="handleAudit">审核</el-button>
        <el-button :loading="reverseAuditLoading" @click="handleReverseAudit">反审核</el-button>
        <el-button :loading="deleteLoading" @click="handleDelete">删除</el-button>
      </div>
      <PIMTable
@@ -58,38 +58,63 @@
        </template>
        <template #action="{ row }">
          <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
          <el-button link type="primary" @click="handleProgressReport(row)">进度汇报</el-button>
          <el-button link type="primary" @click="handleDiscussProgress(row)">洽谈进展</el-button>
          <el-button link type="primary" :loading="progressBtnLoadingId===row.id" @click="handleProgressReport(row)">进度汇报</el-button>
          <el-button link type="primary" @click="handleDetail(row)">详情</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDia ref="formDiaRef" @completed="getList" />
    <ProgressReportDialog
      v-model="progressReportVisible"
      :project-id="progressProjectId"
      :project-info="progressProjectInfo"
      :plan-nodes="progressPlanNodes"
      :default-plan-node-id="progressDefaultPlanNodeId"
      @submitted="handleProgressSubmitted"
    />
  </div>
</template>
<script setup name="ProjectManagement">
import { ref, reactive, toRefs, onMounted, getCurrentInstance } from 'vue'
import { useRouter } from 'vue-router'
import SearchPanel from '@/components/SearchPanel/index.vue'
import PIMTable from '@/components/PIMTable/PIMTable.vue'
import FormDia from './components/formDia.vue'
import ProgressReportDialog from '@/components/ProjectManagement/ProgressReportDialog.vue'
import {
  listProject,
  delProject,
  submitProject,
  auditProject,
  reverseAuditProject
  reverseAuditProject,
  getProject,
  saveStage
} from '@/api/projectManagement/project'
import { listPlan } from '@/api/projectManagement/projectType'
import { ElMessage, ElMessageBox } from 'element-plus'
import useUserStore from '@/store/modules/user'
const { proxy } = getCurrentInstance()
const { bill_status, project_management, plan_status } = proxy.useDict('bill_status', 'project_management', 'plan_status')
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const ids = ref([])
const tableData = ref([])
const formDiaRef = ref()
const progressReportVisible = ref(false)
const progressProjectId = ref(undefined)
const progressProjectInfo = ref({})
const progressPlanNodes = ref([])
const progressDefaultPlanNodeId = ref(undefined)
const progressBtnLoadingId = ref(null)
const submitLoading = ref(false)
const auditLoading = ref(false)
const reverseAuditLoading = ref(false)
const deleteLoading = ref(false)
const data = reactive({
  queryParams: {
@@ -208,102 +233,174 @@
  formDiaRef.value?.openDialog({ operationType: 'add' })
}
function handleDelete() {
async function handleDelete() {
  const delIds = ids.value
  if (delIds.length === 0) {
    ElMessage.warning('请选择要删除的数据项')
    return
  }
  ElMessageBox.confirm('是否确认删除所选数据项?', '警告', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(() => delProject(delIds))
    .then(() => {
      getList()
      ElMessage.success('删除成功')
  try {
    await ElMessageBox.confirm('是否确认删除所选数据项?', '警告', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
    .catch(() => {})
    deleteLoading.value = true
    await delProject(delIds)
    getList()
    ElMessage.success('删除成功')
  } catch {} finally {
    deleteLoading.value = false
  }
}
function handleSubmit() {
async function handleSubmit() {
  const submitIds = ids.value
  if (submitIds.length === 0) {
    ElMessage.warning('请选择要提交的数据项')
    return
  }
  ElMessageBox.confirm('是否确认提交所选数据项?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(async () => {
      await Promise.all(submitIds.map(id => submitProject({ id })))
  try {
    await ElMessageBox.confirm('是否确认提交所选数据项?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
    .then(() => {
      getList()
      ElMessage.success('提交成功')
    })
    .catch(() => {})
    submitLoading.value = true
    await Promise.all(submitIds.map(id => submitProject({ id })))
    getList()
    ElMessage.success('提交成功')
  } catch {} finally {
    submitLoading.value = false
  }
}
function handleAudit() {
async function handleAudit() {
  const auditIds = ids.value
  if (auditIds.length === 0) {
    ElMessage.warning('请选择要审核的数据项')
    return
  }
  ElMessageBox.confirm('是否确认审核所选数据项?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(async () => {
      await Promise.all(auditIds.map(id => auditProject({ id })))
  try {
    await ElMessageBox.confirm('是否确认审核所选数据项?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
    .then(() => {
      getList()
      ElMessage.success('审核成功')
    })
    .catch(() => {})
    auditLoading.value = true
    await Promise.all(auditIds.map(id => auditProject({ id })))
    getList()
    ElMessage.success('审核成功')
  } catch {} finally {
    auditLoading.value = false
  }
}
function handleReverseAudit() {
async function handleReverseAudit() {
  const reverseAuditIds = ids.value
  if (reverseAuditIds.length === 0) {
    ElMessage.warning('请选择要反审核的数据项')
    return
  }
  ElMessageBox.confirm('是否确认反审核所选数据项?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(async () => {
      await Promise.all(reverseAuditIds.map(id => reverseAuditProject({ id })))
  try {
    await ElMessageBox.confirm('是否确认反审核所选数据项?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
    .then(() => {
      getList()
      ElMessage.success('反审核成功')
    })
    .catch(() => {})
    reverseAuditLoading.value = true
    await Promise.all(reverseAuditIds.map(id => reverseAuditProject({ id })))
    getList()
    ElMessage.success('反审核成功')
  } catch {} finally {
    reverseAuditLoading.value = false
  }
}
function handleGenerateBill(command) {
  ElMessage.info(`生成单据: ${command}`)
}
function handleProgressReport(row) {
  formDiaRef.value?.openDialog({ operationType: 'view', row })
function computeDefaultPlanNodeId(stageVal, nodes) {
  const list = Array.isArray(nodes) ? nodes : []
  if (list.length === 0) return undefined
  const direct = list.find(n => String(n.id) === String(stageVal))
  if (direct?.id) return direct.id
  const sorted = [...list].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
}
function handleDiscussProgress(row) {
  formDiaRef.value?.openDialog({ operationType: 'view', row })
async function handleProgressReport(row) {
  if (!row?.id) return
  try {
    progressBtnLoadingId.value = row.id
    const res = await getProject(row.id)
    const detail = res?.data?.data ?? res?.data ?? res
    const info = detail?.info || {}
    progressProjectId.value = info.id
    progressProjectInfo.value = info
    const planId = info.projectManagementPlanId
    if (planId) {
      const planRes = await listPlan({ current: 1, size: 999 })
      const records = planRes?.data?.records || planRes?.records || planRes?.rows || []
      const plan = (records || []).find(p => String(p.id) === String(planId)) || {}
      progressPlanNodes.value = Array.isArray(plan?.planNodeList) ? plan.planNodeList : []
    } else {
      progressPlanNodes.value = []
    }
    progressDefaultPlanNodeId.value = computeDefaultPlanNodeId(info.stage, progressPlanNodes.value)
    progressReportVisible.value = true
  } catch (e) {
    ElMessage.error('获取项目详情失败')
  } finally {
    progressBtnLoadingId.value = null
  }
}
async function handleProgressSubmitted(payload) {
  try {
    const nodes = Array.isArray(progressPlanNodes.value) ? progressPlanNodes.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: progressProjectId.value,
      description,
      actualLeaderId: userStore.id || progressProjectInfo.value?.managerId,
      actualLeaderName: userStore.nickName || progressProjectInfo.value?.managerName,
      estimatedDuration: Number(node.estimatedDuration ?? 0) || 0,
      planStartTime: payload.planStartTime || progressProjectInfo.value?.planStartTime,
      planEndTime: payload.planEndTime || progressProjectInfo.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('提交成功')
      getList()
      return
    }
    ElMessage.error(res?.msg || '提交失败')
  } catch (e) {
    ElMessage.error('提交失败')
  }
}
function handleDetail(row) {
  formDiaRef.value?.openDialog({ operationType: 'view', row })
  if (!row?.id) return
  router.push(`/projectManagement/Management/detail/${row.id}`)
}
function handleEdit(row) {
src/views/projectManagement/Management/projectDetail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,538 @@
<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>