From fa113c192df7e3b5aad9dcb465307dcffb58f6c5 Mon Sep 17 00:00:00 2001
From: ZN <zhang_12370@163.com>
Date: 星期三, 11 三月 2026 18:01:35 +0800
Subject: [PATCH] feat(项目管理): 新增项目详情页与进度汇报功能

---
 src/components/ProjectManagement/ProgressReportDialog.vue     |  282 +++++++++++++
 src/api/projectManagement/project.js                          |   23 +
 src/views/projectManagement/Management/components/formDia.vue |    2 
 src/views/projectManagement/Management/projectDetail.vue      |  538 +++++++++++++++++++++++++
 src/router/index.js                                           |   13 
 src/views/projectManagement/Management/index.vue              |  221 +++++++--
 src/components/ProjectManagement/DiscussProgressDialog.vue    |  141 ++++++
 7 files changed, 1,157 insertions(+), 63 deletions(-)

diff --git a/src/api/projectManagement/project.js b/src/api/projectManagement/project.js
index d940e46..2d6eff1 100644
--- a/src/api/projectManagement/project.js
+++ b/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',
diff --git a/src/components/ProjectManagement/DiscussProgressDialog.vue b/src/components/ProjectManagement/DiscussProgressDialog.vue
new file mode 100644
index 0000000..e278a50
--- /dev/null
+++ b/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>
diff --git a/src/components/ProjectManagement/ProgressReportDialog.vue b/src/components/ProjectManagement/ProgressReportDialog.vue
new file mode 100644
index 0000000..d9402c2
--- /dev/null
+++ b/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>
diff --git a/src/router/index.js b/src/router/index.js
index e5dc580..f342004 100644
--- a/src/router/index.js
+++ b/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" },
+      },
+    ],
+  },
 ];
 
 // 鍔ㄦ�佽矾鐢憋紝鍩轰簬鐢ㄦ埛鏉冮檺鍔ㄦ�佸幓鍔犺浇
diff --git a/src/views/projectManagement/Management/components/formDia.vue b/src/views/projectManagement/Management/components/formDia.vue
index eca4f33..f29512b 100644
--- a/src/views/projectManagement/Management/components/formDia.vue
+++ b/src/views/projectManagement/Management/components/formDia.vue
@@ -1474,7 +1474,7 @@
 .section-bar {
   width: 3px;
   height: 14px;
-  background: #e61e1e;
+  background: #002FA7;
   border-radius: 2px;
 }
 
diff --git a/src/views/projectManagement/Management/index.vue b/src/views/projectManagement/Management/index.vue
index 1a48db5..7547209 100644
--- a/src/views/projectManagement/Management/index.vue
+++ b/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) {
diff --git a/src/views/projectManagement/Management/projectDetail.vue b/src/views/projectManagement/Management/projectDetail.vue
new file mode 100644
index 0000000..c54a389
--- /dev/null
+++ b/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="娉曚汉浠h〃">{{ 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>
+

--
Gitblit v1.9.3