detail.vue 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068
  1. <template>
  2. <el-drawer
  3. :title="detailData.projectName"
  4. v-model="visible"
  5. direction="rtl"
  6. size="85%"
  7. :close-on-click-modal="false"
  8. class="detail-drawer"
  9. >
  10. <div class="detail-container">
  11. <!-- 顶部进度条区域 -->
  12. <div class="top-progress">
  13. <div class="section-title">项目进度</div>
  14. <div class="chevron-steps">
  15. <div
  16. v-for="(step, index) in steps"
  17. :key="index"
  18. class="chevron-step"
  19. :class="{
  20. 'is-active': currentStepIndex === index,
  21. 'is-completed': currentStepIndex > index,
  22. }"
  23. @click="handleStepClick(index)"
  24. >
  25. <span class="step-text">{{ step }}</span>
  26. </div>
  27. </div>
  28. </div>
  29. <!-- 下部分栏区域 -->
  30. <div class="main-content">
  31. <!-- 左侧面板 -->
  32. <div class="left-panel">
  33. <el-tabs v-model="leftActiveTab" class="custom-tabs">
  34. <el-tab-pane label="项目信息" name="info">
  35. <div class="info-block">
  36. <div class="info-header">
  37. <span class="title">基本信息</span>
  38. <el-button type="primary" class="edit-btn" @click="$emit('edit', detailData)" size="default">
  39. <el-icon style="margin-right: 4px;"><Edit /></el-icon>编辑
  40. </el-button>
  41. </div>
  42. <el-descriptions :column="3" class="custom-desc">
  43. <el-descriptions-item label="项目名称:">{{ detailData.projectName }}</el-descriptions-item>
  44. <el-descriptions-item label=" "></el-descriptions-item>
  45. <el-descriptions-item label=" "></el-descriptions-item>
  46. <el-descriptions-item label="归属公司:">{{ detailData.companyName }}</el-descriptions-item>
  47. <el-descriptions-item label="客户名称:" :span="2">{{ detailData.customerName }}</el-descriptions-item>
  48. <el-descriptions-item label="行业:">{{ findIndustryName(detailData.industry) }}</el-descriptions-item>
  49. <el-descriptions-item label="部门:">{{ detailData.deptName }}</el-descriptions-item>
  50. <el-descriptions-item label="金额(万):">{{ detailData.projectBudget != null ? Number(detailData.projectBudget).toFixed(2) : '' }}</el-descriptions-item>
  51. <el-descriptions-item label="赢单率(%):">{{ detailData.winRate }}</el-descriptions-item>
  52. <el-descriptions-item label="立项时间:">{{ proxy.parseTime(detailData.approvalDate, '{y}-{m}-{d}') }}</el-descriptions-item>
  53. <el-descriptions-item label="截止时间:">{{ proxy.parseTime(detailData.expectedCompletionTime, '{y}-{m}-{d}') }}</el-descriptions-item>
  54. <el-descriptions-item label="项目状态:">{{ getSaleStatusLabel(detailData.status) }}</el-descriptions-item>
  55. <el-descriptions-item label="营销活动:">{{ detailData.marketingActivityName || detailData.activityNo }}</el-descriptions-item>
  56. <el-descriptions-item label=" "></el-descriptions-item>
  57. <el-descriptions-item label="项目负责人:">{{ detailData.leaderName || findUserName(detailData.leader) }}</el-descriptions-item>
  58. <el-descriptions-item label="产品支持:">{{ detailData.productSupportName || findUserName(detailData.productSupport) }}</el-descriptions-item>
  59. <el-descriptions-item label=" "></el-descriptions-item>
  60. </el-descriptions>
  61. </div>
  62. <div class="info-block" style="margin-top: 30px;">
  63. <div class="info-header">
  64. <span class="title">项目情况</span>
  65. </div>
  66. <el-descriptions :column="3" class="custom-desc">
  67. <el-descriptions-item label="商机来源:">{{ findInfoSourceName(detailData.infoSource) }}</el-descriptions-item>
  68. <el-descriptions-item label="项目级别:">{{ findProjectLevelName(detailData.projectLevel) }}</el-descriptions-item>
  69. <el-descriptions-item label="项目区域:">{{ detailData.projectArea }}</el-descriptions-item>
  70. <el-descriptions-item label="采购方式:" :span="3">{{ findProcurementMethodName(detailData.procurementMethod) }}</el-descriptions-item>
  71. <el-descriptions-item label="项目描述:" :span="3">
  72. <div class="text-content">{{ detailData.projectDescription }}</div>
  73. </el-descriptions-item>
  74. <el-descriptions-item label="竞争对手:" :span="3">
  75. <div class="text-content">{{ detailData.competitor }}</div>
  76. </el-descriptions-item>
  77. </el-descriptions>
  78. </div>
  79. </el-tab-pane>
  80. <el-tab-pane label="项目联系人" name="contact">
  81. <div class="tab-toolbar">
  82. <el-dropdown @command="handleContactCommand">
  83. <el-button type="primary">
  84. <el-icon style="margin-right: 4px;"><Plus /></el-icon>新建联系人<el-icon class="el-icon--right"><arrow-down /></el-icon>
  85. </el-button>
  86. <template #dropdown>
  87. <el-dropdown-menu>
  88. <el-dropdown-item command="associate">关联客户联系人</el-dropdown-item>
  89. <el-dropdown-item command="new">新建联系人</el-dropdown-item>
  90. </el-dropdown-menu>
  91. </template>
  92. </el-dropdown>
  93. </div>
  94. <el-table :data="contactList" border class="contact-table" style="width: 100%" v-loading="contactLoading">
  95. <el-table-column label="姓名" align="center" width="100">
  96. <template #default="scope">
  97. <el-link type="primary" :underline="false" @click="handleEditContact(scope.row)">
  98. {{ scope.row.contactName || scope.row.name }}
  99. </el-link>
  100. </template>
  101. </el-table-column>
  102. <el-table-column label="性别" align="center" width="80">
  103. <template #default="scope">
  104. {{ scope.row.gender === '0' || scope.row.sex === '0' ? '男' : scope.row.gender === '1' || scope.row.sex === '1' ? '女' : '' }}
  105. </template>
  106. </el-table-column>
  107. <el-table-column label="部门" align="center">
  108. <template #default="scope">
  109. {{ scope.row.deptName || scope.row.department }}
  110. </template>
  111. </el-table-column>
  112. <el-table-column label="职位" align="center">
  113. <template #default="scope">
  114. {{ scope.row.position || scope.row.roleName || scope.row.role }}
  115. </template>
  116. </el-table-column>
  117. <el-table-column label="项目角色" align="center">
  118. <template #default="scope">
  119. {{ scope.row.projectRole || scope.row.role }}
  120. </template>
  121. </el-table-column>
  122. <el-table-column label="是否关键人" align="center" width="100">
  123. <template #default="scope">
  124. <span>{{ (scope.row.isKeyPerson === 1 || scope.row.keyPerson === 1) ? '是' : '否' }}</span>
  125. </template>
  126. </el-table-column>
  127. <el-table-column label="操作" align="center" width="120" fixed="right">
  128. <template #default="scope">
  129. <el-button link type="primary" size="small" @click="handleEditContact(scope.row)">编辑</el-button>
  130. <el-button link type="danger" size="small" @click="handleDeleteContact(scope.row)">删除</el-button>
  131. </template>
  132. </el-table-column>
  133. <template #empty><span class="empty-text">暂无数据</span></template>
  134. </el-table>
  135. </el-tab-pane>
  136. <el-tab-pane label="结果分析" name="analysis">
  137. <div class="analysis-section">
  138. <div class="analysis-form">
  139. <div class="analysis-row">
  140. <span class="form-label">成交结果</span>
  141. <el-radio-group v-model="analysisForm.resultType" class="result-radio">
  142. <el-radio value="win">赢单</el-radio>
  143. <el-radio value="lose">丢单</el-radio>
  144. </el-radio-group>
  145. <div style="flex:1"></div>
  146. <el-button type="primary" icon="CircleCheck" @click="handleSaveAnalysis">保 存</el-button>
  147. </div>
  148. <template v-if="analysisForm.resultType === 'win'">
  149. <div class="summary-title">赢单总结</div>
  150. <div class="summary-textarea-wrap">
  151. <el-input v-model="analysisForm.summary" type="textarea" :rows="4" placeholder="请输入赢单总结" />
  152. </div>
  153. </template>
  154. <template v-else>
  155. <div class="summary-title">丢单原因</div>
  156. <div class="summary-textarea-wrap">
  157. <el-input v-model="analysisForm.loseReason" type="textarea" :rows="4" placeholder="请输入丢单原因" />
  158. </div>
  159. </template>
  160. <div class="attachment-section-title">附件</div>
  161. <div class="upload-area">
  162. <el-upload
  163. :action="uploadFileUrl"
  164. :headers="headers"
  165. :on-success="handleAnalysisUploadSuccess"
  166. :show-file-list="false"
  167. multiple
  168. >
  169. <el-button type="primary" icon="Upload">上 传</el-button>
  170. </el-upload>
  171. </div>
  172. <div v-if="analysisFileList.length > 0" class="analysis-file-list" style="margin-top:10px;">
  173. <el-table :data="analysisFileList" border size="small">
  174. <el-table-column label="文件名称" prop="name" show-overflow-tooltip />
  175. <el-table-column label="操作" width="120" align="center">
  176. <template #default="scope">
  177. <el-button link type="primary" @click="downloadFile(scope.row)">下载</el-button>
  178. <el-button link type="danger" @click="handleAnalysisDeleteFile(scope.$index)">删除</el-button>
  179. </template>
  180. </el-table-column>
  181. </el-table>
  182. </div>
  183. </div>
  184. </div>
  185. </el-tab-pane>
  186. <el-tab-pane label="附件" name="attachment">
  187. <div class="tab-toolbar">
  188. <el-upload
  189. :action="uploadFileUrl"
  190. :headers="headers"
  191. :on-success="handleUploadSuccess"
  192. :show-file-list="false"
  193. multiple
  194. >
  195. <el-button type="primary" icon="Upload">上传附件</el-button>
  196. </el-upload>
  197. </div>
  198. <el-table :data="detailFileList" border class="custom-table" style="width: 100%">
  199. <el-table-column label="文件名称" align="center" prop="name" show-overflow-tooltip />
  200. <el-table-column label="文件类型" align="center" prop="type" width="100" />
  201. <el-table-column label="上传时间" align="center" prop="uploadTime" width="160" />
  202. <el-table-column label="操作" align="center" width="120">
  203. <template #default="scope">
  204. <el-button link type="primary" @click="downloadFile(scope.row)" style="margin-right: 10px;">下载</el-button>
  205. <el-button link type="danger" @click="handleDeleteFile(scope.row)">删除</el-button>
  206. </template>
  207. </el-table-column>
  208. <template #empty><span class="empty-text">暂无数据</span></template>
  209. </el-table>
  210. </el-tab-pane>
  211. </el-tabs>
  212. </div>
  213. <!-- 右侧面板:通用业务活动组件 -->
  214. <div class="right-panel">
  215. <BusinessActivity
  216. v-if="detailData.id"
  217. :businessId="detailData.id"
  218. :infoData="{
  219. ...detailData,
  220. industryName: findIndustryName(detailData.industry),
  221. profession: findIndustryName(detailData.industry)
  222. }"
  223. businessType="opportunity"
  224. @success="getList"
  225. />
  226. </div>
  227. </div>
  228. </div>
  229. </el-drawer>
  230. <!-- 关联客户联系人弹窗 -->
  231. <el-dialog title="关联客户联系人" v-model="associateVisible" width="900px" append-to-body>
  232. <el-table :data="associateList" v-loading="associateLoading" border @selection-change="handleSelectionChange" class="custom-table" max-height="400px">
  233. <el-table-column type="selection" width="55" />
  234. <el-table-column label="联系人" align="center" prop="contactName" width="120" />
  235. <el-table-column label="部门" align="center" prop="deptName" width="130" />
  236. <el-table-column label="客户名称" align="center" prop="customerName" min-width="180" show-overflow-tooltip />
  237. <el-table-column label="职位" align="center" prop="roleName" width="110" />
  238. <el-table-column label="手机号码" align="center" prop="phone" width="120" />
  239. <el-table-column label="办公电话" align="center" prop="officePhone" width="120" />
  240. </el-table>
  241. <template #footer>
  242. <div class="dialog-footer" style="padding-top: 10px;">
  243. <el-button type="primary" @click="confirmAssociate" :disabled="selectedContacts.length === 0">确 认</el-button>
  244. <el-button @click="associateVisible = false">取 消</el-button>
  245. </div>
  246. </template>
  247. </el-dialog>
  248. <!-- 编辑/新建项目联系人抽屉 (完全对齐原型图) -->
  249. <el-drawer v-model="projectContactDrawerOpen" direction="rtl" size="80%" destroy-on-close :with-header="false">
  250. <div class="drawer-header-standard" style="padding: 15px 20px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center;">
  251. <span style="font-size: 16px; font-weight: normal; color: #333;">{{ projectContactForm.id ? '编辑项目联系人' : '新建项目联系人' }}</span>
  252. <el-icon @click="projectContactDrawerOpen = false" style="cursor: pointer; font-size: 20px;"><Close /></el-icon>
  253. </div>
  254. <div class="drawer-body-custom" style="padding: 0; overflow-y: auto; height: calc(100% - 110px);">
  255. <el-form :model="projectContactForm" :rules="projectContactRules" ref="projectContactFormRef" label-width="100px" class="no-bold-label">
  256. <!-- 基本信息 -->
  257. <div class="form-section-group">
  258. <div class="section-group-title">基本信息</div>
  259. <div class="section-group-content">
  260. <el-row :gutter="20">
  261. <el-col :span="8">
  262. <el-form-item label="姓名" prop="name">
  263. <el-input v-model="projectContactForm.name" placeholder="请输入" />
  264. </el-form-item>
  265. </el-col>
  266. <el-col :span="8">
  267. <el-form-item label="联系人类型" prop="contactType">
  268. <el-radio-group v-model="projectContactForm.contactType">
  269. <el-radio value="1">公司职员</el-radio>
  270. <el-radio value="2">关系资源人</el-radio>
  271. </el-radio-group>
  272. </el-form-item>
  273. </el-col>
  274. <el-col :span="8">
  275. <el-form-item label="性别" prop="sex">
  276. <el-radio-group v-model="projectContactForm.sex">
  277. <el-radio value="0">男</el-radio>
  278. <el-radio value="1">女</el-radio>
  279. </el-radio-group>
  280. </el-form-item>
  281. </el-col>
  282. </el-row>
  283. <el-row :gutter="20">
  284. <el-col :span="8">
  285. <el-form-item label="年龄" prop="age">
  286. <el-input v-model="projectContactForm.age" placeholder="请输入" />
  287. </el-form-item>
  288. </el-col>
  289. <el-col :span="8">
  290. <el-form-item label="籍贯" prop="nativePlace">
  291. <el-input v-model="projectContactForm.nativePlace" placeholder="请输入" />
  292. </el-form-item>
  293. </el-col>
  294. <el-col :span="8">
  295. <el-form-item label="生日" prop="birthday">
  296. <el-date-picker v-model="projectContactForm.birthday" type="date" placeholder="请选择" style="width: 100%" value-format="YYYY-MM-DD" />
  297. </el-form-item>
  298. </el-col>
  299. </el-row>
  300. <el-row :gutter="20">
  301. <el-col :span="24">
  302. <el-form-item label="描述" prop="remark">
  303. <el-input v-model="projectContactForm.remark" type="textarea" :rows="2" placeholder="请输入" />
  304. </el-form-item>
  305. </el-col>
  306. </el-row>
  307. </div>
  308. </div>
  309. <!-- 办公信息 -->
  310. <div class="form-section-group">
  311. <div class="section-group-title">办公信息</div>
  312. <div class="section-group-content">
  313. <el-row :gutter="20">
  314. <el-col :span="8">
  315. <el-form-item label="在职状态" prop="status">
  316. <el-radio-group v-model="projectContactForm.status">
  317. <el-radio value="0">在职</el-radio>
  318. <el-radio value="1">离职</el-radio>
  319. </el-radio-group>
  320. </el-form-item>
  321. </el-col>
  322. <el-col :span="8">
  323. <el-form-item label="手机号码" prop="phonenumber">
  324. <el-input v-model="projectContactForm.phonenumber" placeholder="请输入" />
  325. </el-form-item>
  326. </el-col>
  327. <el-col :span="8">
  328. <el-form-item label="部门" prop="deptName">
  329. <el-input v-model="projectContactForm.deptName" placeholder="请输入" />
  330. </el-form-item>
  331. </el-col>
  332. </el-row>
  333. <el-row :gutter="20">
  334. <el-col :span="8">
  335. <el-form-item label="职位" prop="position">
  336. <el-input v-model="projectContactForm.position" placeholder="请输入" />
  337. </el-form-item>
  338. </el-col>
  339. <el-col :span="8">
  340. <el-form-item label="办公座机" prop="officePhone">
  341. <el-input v-model="projectContactForm.officePhone" placeholder="请输入" />
  342. </el-form-item>
  343. </el-col>
  344. </el-row>
  345. <el-row :gutter="20">
  346. <el-col :span="24">
  347. <el-form-item label="办公地址" prop="address">
  348. <div style="display: flex; gap: 10px;">
  349. <el-input v-model="projectContactForm.provincialCityCounty" placeholder="请选择" style="width: 200px" readonly />
  350. <el-input v-model="projectContactForm.addressDetail" placeholder="请输入详细地址" />
  351. </div>
  352. </el-form-item>
  353. </el-col>
  354. </el-row>
  355. <el-row :gutter="20">
  356. <el-col :span="24">
  357. <el-form-item label="工作内容" prop="jobContent">
  358. <el-input v-model="projectContactForm.jobContent" type="textarea" :rows="2" placeholder="请输入" />
  359. </el-form-item>
  360. </el-col>
  361. </el-row>
  362. </div>
  363. </div>
  364. <!-- 项目决策 -->
  365. <div class="form-section-group">
  366. <div class="section-group-title">项目决策</div>
  367. <div class="section-group-content">
  368. <el-row :gutter="20">
  369. <el-col :span="8">
  370. <el-form-item label="项目角色" prop="projectRole">
  371. <el-select v-model="projectContactForm.projectRole" style="width: 100%" placeholder="请选择" filterable>
  372. <el-option v-for="item in projectRoleOptions" :key="item.value" :label="item.label" :value="item.value" />
  373. </el-select>
  374. </el-form-item>
  375. </el-col>
  376. <el-col :span="8">
  377. <el-form-item label="是否关键人" prop="isKeyPerson">
  378. <el-select v-model="projectContactForm.isKeyPerson" style="width: 100%" placeholder="请选择">
  379. <el-option label="是" :value="1" />
  380. <el-option label="否" :value="0" />
  381. </el-select>
  382. </el-form-item>
  383. </el-col>
  384. </el-row>
  385. <el-row :gutter="20">
  386. <el-col :span="24">
  387. <el-form-item label="公关情况" prop="prStatus">
  388. <el-input v-model="projectContactForm.prStatus" type="textarea" :rows="2" placeholder="请输入" />
  389. </el-form-item>
  390. </el-col>
  391. </el-row>
  392. </div>
  393. </div>
  394. </el-form>
  395. </div>
  396. <div class="drawer-footer-standard" style="padding: 15px 20px; border-top: 1px solid #f0f0f0; text-align: right;">
  397. <el-button @click="projectContactDrawerOpen = false">取 消</el-button>
  398. <el-button type="primary" @click="submitProjectContact" :loading="contactSubmitting">确 定</el-button>
  399. </div>
  400. </el-drawer>
  401. </template>
  402. <script setup>
  403. import { ref, computed, reactive, watch, getCurrentInstance, toRefs } from 'vue';
  404. import { useDebounceFn } from '@vueuse/core';
  405. import { Plus, Search, Close, Upload, Edit, ArrowDown, CircleCheck } from '@element-plus/icons-vue';
  406. import { getOpportunity, updateOpportunity } from '@/api/saleManage/opportunity/index';
  407. import { listContact, addContact, delContact, updateContact } from "@/api/customer/crmContact";
  408. import { getContactPerson } from "@/api/customer/contactPerson";
  409. import { getSalesResultAnalyzeByObjectNo, addSalesResultAnalyze, updateSalesResultAnalyze } from '@/api/saleManage/leads/salesResultAnalyze';
  410. import { listByIds } from "@/api/system/oss/index";
  411. import { listIndustryCategory } from "@/api/customer/industryCategory";
  412. import { globalHeaders } from '@/utils/request';
  413. import { ElMessageBox, ElMessage } from 'element-plus';
  414. import BusinessActivity from '@/views/common/businessActivity.vue';
  415. const props = defineProps({
  416. modelValue: Boolean,
  417. id: [String, Number],
  418. saleStatusOptions: { type: Array, default: () => [] },
  419. projectLevelOptions: { type: Array, default: () => [] },
  420. infoSourceOptions: { type: Array, default: () => [] },
  421. procurementMethodOptions: { type: Array, default: () => [] },
  422. userOptions: { type: Array, default: () => [] }
  423. });
  424. const emit = defineEmits(['update:modelValue', 'edit', 'success']);
  425. const steps = ['标前磋商', '立项公示', '应标投标', '结案'];
  426. const currentStepIndex = computed(() => {
  427. const schedule = detailData.value.projectSchedule;
  428. if (!schedule || schedule === '0') return -1;
  429. return parseInt(schedule) - 1;
  430. });
  431. const visible = ref(false);
  432. const detailData = ref({});
  433. const leftActiveTab = ref('info');
  434. const industryOptions = ref([]);
  435. const proxy = getCurrentInstance().proxy;
  436. const findUserName = (id) => {
  437. if (!id) return '';
  438. const user = props.userOptions.find(u => String(u.staffId || u.userId) === String(id));
  439. return user ? (user.staffName || user.nickName) : id;
  440. };
  441. // 状态同步
  442. watch(() => props.modelValue, (val) => { visible.value = val; });
  443. watch(() => visible.value, (val) => { emit('update:modelValue', val); });
  444. // 附件管理相关
  445. const detailFileList = ref([]);
  446. const uploadFileUrl = ref(import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload');
  447. const headers = ref(globalHeaders());
  448. // 项目联系人
  449. const contactList = ref([]);
  450. const contactLoading = ref(false);
  451. const contactSubmitting = ref(false);
  452. const projectContactDrawerOpen = ref(false);
  453. const projectContactFormRef = ref(null);
  454. const projectContactForm = reactive({
  455. id: undefined,
  456. name: '',
  457. contactType: '1',
  458. sex: '0',
  459. age: '',
  460. nativePlace: '',
  461. birthday: '',
  462. remark: '',
  463. status: '0',
  464. phonenumber: '',
  465. deptName: '',
  466. position: '',
  467. officePhone: '',
  468. provincialCityCounty: '',
  469. addressDetail: '',
  470. jobContent: '',
  471. projectRole: '',
  472. isKeyPerson: 0,
  473. prStatus: ''
  474. });
  475. const projectContactRules = {
  476. name: [{ required: true, message: '姓名不能为空', trigger: 'blur' }],
  477. phonenumber: [{ required: true, message: '手机号码不能为空', trigger: 'blur' }],
  478. };
  479. // 结果分析相关
  480. const analysisForm = reactive({
  481. id: undefined,
  482. resultType: 'win',
  483. summary: '',
  484. loseReason: '',
  485. fileNo: ''
  486. });
  487. const analysisFileList = ref([]);
  488. // 关联联系人相关
  489. const associateVisible = ref(false);
  490. const associateLoading = ref(false);
  491. const associateList = ref([]);
  492. const selectedContacts = ref([]);
  493. const associateQuery = reactive({ contactName: '', phone: '' });
  494. const { LXRJE0001: projectRoleOptions } = toRefs(reactive(proxy.useDict("LXRJE0001")));
  495. const getFileType = (name) => {
  496. if (!name) return '未知';
  497. const parts = name.split('.');
  498. return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : '文件';
  499. };
  500. watch(() => detailData.value.fileNo, async (val) => {
  501. if (val) {
  502. try {
  503. const res = await listByIds(val);
  504. detailFileList.value = (res.data || []).map(item => ({
  505. name: item.originalName,
  506. url: item.url,
  507. ossId: item.ossId,
  508. type: getFileType(item.originalName),
  509. uploadTime: item.createTime
  510. }));
  511. } catch (e) {}
  512. } else {
  513. detailFileList.value = [];
  514. }
  515. }, { immediate: true });
  516. const open = async (row, tab) => {
  517. visible.value = true;
  518. leftActiveTab.value = 'info';
  519. detailData.value = { ...row };
  520. // 预加载字典
  521. loadDicts();
  522. try {
  523. const id = row.id;
  524. if (id) {
  525. const res = await getOpportunity(id);
  526. if (res.data) {
  527. Object.assign(detailData.value, res.data);
  528. }
  529. fetchContactList(id);
  530. getIndustryList();
  531. loadAnalysisData();
  532. }
  533. } catch (error) {}
  534. };
  535. const loadAnalysisData = async () => {
  536. if (!detailData.value.projectNo) return;
  537. try {
  538. const res = await getSalesResultAnalyzeByObjectNo(detailData.value.projectNo);
  539. if (res.data) {
  540. Object.assign(analysisForm, {
  541. id: res.data.id,
  542. resultType: res.data.dealResult === 1 ? 'win' : 'lose',
  543. summary: res.data.winSumUp,
  544. loseReason: res.data.loseReason,
  545. fileNo: res.data.fileNo
  546. });
  547. if (analysisForm.fileNo) {
  548. const ossRes = await listByIds(analysisForm.fileNo);
  549. analysisFileList.value = ossRes.data.map(i => ({
  550. name: i.originalName || i.fileName,
  551. url: i.url,
  552. ossId: i.ossId,
  553. type: (i.originalName || i.fileName || '').split('.').pop().toUpperCase()
  554. }));
  555. }
  556. } else {
  557. // 重置表单
  558. Object.assign(analysisForm, {
  559. id: undefined,
  560. resultType: 'win',
  561. summary: '',
  562. loseReason: '',
  563. fileNo: ''
  564. });
  565. analysisFileList.value = [];
  566. }
  567. } catch (e) {}
  568. };
  569. const handleAnalysisUploadSuccess = (res) => {
  570. if (res.code === 200) {
  571. const file = res.data;
  572. const fileName = file.originalName || file.fileName || '未知文件';
  573. analysisFileList.value.push({
  574. name: fileName,
  575. url: file.url,
  576. ossId: file.ossId,
  577. type: fileName.split('.').pop().toUpperCase()
  578. });
  579. proxy.$modal.msgSuccess("上传成功");
  580. } else {
  581. proxy.$modal.msgError(res.msg || "上传失败");
  582. }
  583. };
  584. const handleAnalysisDeleteFile = (index) => {
  585. analysisFileList.value.splice(index, 1);
  586. };
  587. const handleSaveAnalysis = useDebounceFn(async () => {
  588. if (!detailData.value.projectNo) {
  589. proxy.$modal.msgWarning("项目编号缺失,无法保存");
  590. return;
  591. }
  592. const ossIds = analysisFileList.value.map(f => f.ossId).join(',');
  593. const payload = {
  594. id: analysisForm.id,
  595. objectNo: detailData.value.projectNo,
  596. dealResult: analysisForm.resultType === 'win' ? 1 : 2,
  597. winSumUp: analysisForm.summary,
  598. loseReason: analysisForm.loseReason,
  599. fileNo: ossIds
  600. };
  601. proxy.$modal.loading("正在保存...");
  602. try {
  603. if (analysisForm.id) {
  604. await updateSalesResultAnalyze(payload);
  605. } else {
  606. await addSalesResultAnalyze(payload);
  607. }
  608. proxy.$modal.msgSuccess("保存成功");
  609. loadAnalysisData();
  610. } catch (e) {
  611. } finally {
  612. proxy.$modal.closeLoading();
  613. }
  614. }, 300);
  615. const downloadFile = (file) => {
  616. if (file.url) {
  617. proxy.$modal.msgInfo("正在启动下载...");
  618. if (proxy.$download && proxy.$download.resource) {
  619. proxy.$download.resource(file.url);
  620. } else {
  621. window.open(file.url, '_blank');
  622. }
  623. }
  624. };
  625. const loadDicts = () => {
  626. };
  627. const getIndustryList = async () => {
  628. try {
  629. const res = await listIndustryCategory();
  630. industryOptions.value = res.data || [];
  631. } catch (error) {}
  632. };
  633. const fetchContactList = async (id) => {
  634. if (!id) return;
  635. contactLoading.value = true;
  636. try {
  637. const res = await listContact({ platformCode: String(id) });
  638. contactList.value = res.rows || [];
  639. } catch (e) {} finally {
  640. contactLoading.value = false;
  641. }
  642. };
  643. const handleContactCommand = (command) => {
  644. if (command === 'associate') {
  645. handleAssociate();
  646. } else if (command === 'new') {
  647. handleNewProjectContact();
  648. }
  649. };
  650. const handleAssociate = () => {
  651. associateQuery.contactName = '';
  652. associateQuery.phone = '';
  653. associateVisible.value = true;
  654. loadAssociateList();
  655. };
  656. const loadAssociateList = async () => {
  657. if (!detailData.value.companyName) {
  658. proxy.$modal.msgWarning("当前商机未绑定客户,无法关联联系人");
  659. return;
  660. }
  661. associateVisible.value = true;
  662. associateLoading.value = true;
  663. // 根据客户名称查询联系人 (注意:后端参数名为 customName,对齐线索模块逻辑)
  664. listContact({ customName: detailData.value.companyName }).then(res => {
  665. // 过滤掉已经关联到当前项目的联系人
  666. const currentIds = contactList.value.map(c => c.id);
  667. associateList.value = (res.rows || []).filter(item => !currentIds.includes(item.id));
  668. }).finally(() => {
  669. associateLoading.value = false;
  670. });
  671. };
  672. const handleSelectionChange = (selection) => {
  673. selectedContacts.value = selection;
  674. };
  675. const confirmAssociate = useDebounceFn(async () => {
  676. if (selectedContacts.value.length === 0) return;
  677. proxy.$modal.loading("正在关联...");
  678. try {
  679. const promises = selectedContacts.value.map(contact => {
  680. return updateContact({
  681. ...contact,
  682. platformCode: String(detailData.value.id)
  683. });
  684. });
  685. await Promise.all(promises);
  686. ElMessage.success('关联成功');
  687. associateVisible.value = false;
  688. fetchContactList(detailData.value.id);
  689. } catch (e) {} finally {
  690. proxy.$modal.closeLoading();
  691. }
  692. }, 300);
  693. const handleNewProjectContact = () => {
  694. Object.assign(projectContactForm, {
  695. id: undefined, name: '', contactType: '1', sex: '0', age: '', deptName: '',
  696. position: '', projectRole: '', isKeyPerson: 0, status: '1', phonenumber: ''
  697. });
  698. projectContactDrawerOpen.value = true;
  699. };
  700. const handleEditContact = (row) => {
  701. Object.assign(projectContactForm, {
  702. id: row.id,
  703. name: row.contactName || row.name,
  704. sex: row.gender || row.sex || '0',
  705. deptName: row.deptName || row.department,
  706. position: row.position || row.roleName || row.role,
  707. projectRole: row.projectRole || row.role,
  708. isKeyPerson: row.isKeyPerson || (row.keyPerson ? 1 : 0),
  709. phonenumber: row.phonenumber || row.phone,
  710. contactType: row.contactType || '1'
  711. });
  712. projectContactDrawerOpen.value = true;
  713. };
  714. const submitProjectContact = useDebounceFn(() => {
  715. projectContactFormRef.value.validate(async (valid) => {
  716. if (valid) {
  717. contactSubmitting.value = true;
  718. try {
  719. const payload = {
  720. ...projectContactForm,
  721. platformCode: String(detailData.value.id),
  722. // 关联客户信息
  723. customerName: detailData.value.customerName || '',
  724. customerNo: detailData.value.companyNo || '',
  725. customNo: detailData.value.companyNo || ''
  726. };
  727. if (projectContactForm.id) {
  728. await updateContact(payload);
  729. } else {
  730. await addContact(payload);
  731. }
  732. ElMessage.success('保存成功');
  733. projectContactDrawerOpen.value = false;
  734. fetchContactList(detailData.value.id);
  735. } catch (e) {} finally {
  736. contactSubmitting.value = false;
  737. }
  738. }
  739. });
  740. }, 300);
  741. const handleDeleteContact = useDebounceFn((row) => {
  742. ElMessageBox.confirm(`确认删除联系人「${row.name}」吗?`, '提示', { type: 'warning' }).then(async () => {
  743. await delContact(row.id);
  744. ElMessage.success('操作成功');
  745. fetchContactList(detailData.value.id);
  746. });
  747. }, 300);
  748. const handleUploadSuccess = async (res) => {
  749. if (res.code === 200) {
  750. const ossId = res.data.ossId;
  751. const currentFileNos = detailData.value.fileNo ? detailData.value.fileNo.split(',') : [];
  752. currentFileNos.push(ossId);
  753. const newFileNo = currentFileNos.join(',');
  754. try {
  755. await updateOpportunity({ id: detailData.value.id, fileNo: newFileNo });
  756. detailData.value.fileNo = newFileNo;
  757. ElMessage.success('上传成功');
  758. } catch (e) {}
  759. }
  760. };
  761. const handleDeleteFile = useDebounceFn((row) => {
  762. ElMessageBox.confirm(`确认删除附件「${row.name}」吗?`, '提示', { type: 'warning' }).then(async () => {
  763. const currentFileNos = detailData.value.fileNo ? detailData.value.fileNo.split(',') : [];
  764. const newFileNo = currentFileNos.filter(id => String(id) !== String(row.ossId)).join(',');
  765. try {
  766. proxy.$modal.loading("正在删除...");
  767. await updateOpportunity({ id: detailData.value.id, fileNo: newFileNo });
  768. detailData.value.fileNo = newFileNo;
  769. ElMessage.success('删除成功');
  770. } catch (e) {
  771. } finally {
  772. proxy.$modal.closeLoading();
  773. }
  774. });
  775. }, 300);
  776. const handleStepClick = async (index) => {
  777. const targetSchedule = String(index + 1);
  778. const isCancel = targetSchedule === String(detailData.value.projectSchedule);
  779. try {
  780. const finalSchedule = isCancel ? String(index) : targetSchedule;
  781. await updateOpportunity({
  782. id: detailData.value.id,
  783. projectSchedule: finalSchedule,
  784. status: finalSchedule === '4' ? '1' : detailData.value.status
  785. });
  786. detailData.value.projectSchedule = finalSchedule;
  787. if (finalSchedule === '4') detailData.value.status = '1';
  788. ElMessage.success('操作成功');
  789. emit('success');
  790. } catch (error) {
  791. console.error('更新进度失败:', error);
  792. }
  793. };
  794. const getSaleStatusLabel = (status) => {
  795. if (String(status) === '0') return '跟进中';
  796. if (String(status) === '1') return '结案';
  797. return props.saleStatusOptions.find(i => String(i.value) === String(status))?.label || status;
  798. };
  799. const findProjectLevelName = (level) => {
  800. return props.projectLevelOptions.find(i => String(i.value) === String(level))?.label || level;
  801. };
  802. const findInfoSourceName = (source) => {
  803. return props.infoSourceOptions.find(i => String(i.value) === String(source))?.label || source;
  804. };
  805. const findProcurementMethodName = (method) => {
  806. return props.procurementMethodOptions.find(i => String(i.value) === String(method))?.label || method;
  807. };
  808. const findIndustryName = (val) => {
  809. if (!val) return '';
  810. // 处理可能的多个 ID(以逗号分隔)
  811. const ids = String(val).split(',').filter(Boolean);
  812. if (ids.length > 1) {
  813. return ids.map(id => {
  814. return industryOptions.value.find(i => String(i.id) === String(id))?.industryCategoryName || id;
  815. }).join('、');
  816. }
  817. return industryOptions.value.find(i => String(i.id) === String(val))?.industryCategoryName || val;
  818. };
  819. const getList = () => {
  820. emit('success');
  821. };
  822. defineExpose({ open });
  823. </script>
  824. <style lang="scss" scoped>
  825. .detail-drawer {
  826. :deep(.el-drawer__header) {
  827. margin: 0;
  828. padding: 0 20px;
  829. height: 48px;
  830. line-height: 48px;
  831. border-bottom: 1px solid #f0f0f0;
  832. span {
  833. font-size: 16px;
  834. font-weight: normal;
  835. color: #333;
  836. }
  837. }
  838. :deep(.el-drawer__body) { padding: 0 !important; background-color: #fff; }
  839. }
  840. .detail-container { height: 100%; display: flex; flex-direction: column; background: #fff; }
  841. .top-progress {
  842. padding: 5px 20px 12px; background-color: #fff; border-bottom: 1px solid #f5f5f5;
  843. .section-title { font-size: 14px; font-weight: normal !important; color: #333; margin-bottom: 12px; }
  844. .chevron-steps {
  845. display: flex; align-items: center; gap: 0; padding: 2px 0;
  846. .chevron-step {
  847. flex: 1; height: 32px; display: flex; align-items: center; justify-content: center; background-color: #F2F3F5; color: #86909C; font-size: 13px; position: relative; cursor: pointer; transition: all 0.3s;
  848. margin-right: -10px; // 负边距实现重叠
  849. clip-path: polygon(calc(100% - 10px) 0%, 100% 50%, calc(100% - 10px) 100%, 0% 100%, 10px 50%, 0% 0%);
  850. &:first-child {
  851. border-radius: 4px 0 0 4px;
  852. clip-path: polygon(calc(100% - 10px) 0%, 100% 50%, calc(100% - 10px) 100%, 0% 100%, 0% 0%);
  853. }
  854. &:last-child {
  855. border-radius: 0 4px 4px 0;
  856. margin-right: 0;
  857. clip-path: polygon(100% 0%, 100% 100%, 0% 100%, 10px 50%, 0% 0%);
  858. }
  859. &.is-active { background-color: #00B881; color: #fff; z-index: 2; font-weight: 500 !important; }
  860. &.is-completed { background-color: #E6F8F3; color: #00B881; z-index: 1; }
  861. &:hover:not(.is-active) { background-color: #E5E6EB; z-index: 3; }
  862. }
  863. }
  864. }
  865. .main-content {
  866. display: flex; flex: 1; overflow: hidden;
  867. .left-panel { flex: 7; background: #fff; border-right: 1px solid #f0f0f0; padding: 5px 20px 15px; overflow-y: auto; }
  868. .right-panel { flex: 3; background: #fff; overflow-y: auto; }
  869. }
  870. .info-block {
  871. .info-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; .title { font-size: 15px; font-weight: normal !important; color: #409eff; padding-left: 0; } }
  872. }
  873. .custom-tabs {
  874. :deep(.el-tabs__header) { margin-bottom: 15px; height: 56px; border-bottom: 1px solid #f0f0f0; }
  875. :deep(.el-tabs__nav-wrap::after) { height: 0; }
  876. :deep(.el-tabs__item) { font-size: 14px; font-weight: normal !important; height: 56px; line-height: 56px; }
  877. }
  878. .tab-toolbar { margin-bottom: 10px; display: flex; justify-content: flex-end; }
  879. .custom-desc {
  880. :deep(.el-descriptions__label) { color: #86909C; font-weight: normal !important; min-width: 80px; padding-bottom: 8px !important; }
  881. :deep(.el-descriptions__content) { color: #1D2129; padding-bottom: 8px !important; }
  882. }
  883. .section-block { margin-bottom: 15px; .section-title { font-size: 14px; font-weight: normal !important; color: #333; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #f0f0f0; } }
  884. .form-section-group {
  885. margin-bottom: 20px;
  886. .section-group-title {
  887. padding: 8px 15px;
  888. background-color: #f8fbff;
  889. color: #409eff;
  890. font-size: 14px;
  891. margin-bottom: 15px;
  892. }
  893. .section-group-content { padding: 0 15px; }
  894. }
  895. .no-bold-label {
  896. :deep(.el-form-item__label) { font-weight: normal !important; color: #666; }
  897. }
  898. .contact-table {
  899. :deep(th.el-table__cell) {
  900. background-color: #f8f9fb !important;
  901. color: #666;
  902. font-weight: 500;
  903. }
  904. }
  905. .custom-table {
  906. :deep(th.el-table__cell) { font-weight: normal !important; background-color: #f8f9fa !important; }
  907. :deep(.el-table__cell) { font-weight: normal !important; }
  908. }
  909. :deep(.el-dialog) {
  910. .el-dialog__body { padding: 10px 20px 20px; }
  911. .el-table { border-radius: 0; }
  912. }
  913. /* 结果分析样式 */
  914. .analysis-section {
  915. padding: 15px 0;
  916. .analysis-form {
  917. .analysis-row {
  918. display: flex;
  919. align-items: center;
  920. margin-bottom: 20px;
  921. .form-label {
  922. font-size: 14px;
  923. color: #333;
  924. margin-right: 15px;
  925. font-weight: 500;
  926. }
  927. .result-radio {
  928. margin-right: 20px;
  929. :deep(.el-radio) {
  930. margin-right: 15px;
  931. .el-radio__label {
  932. font-weight: normal;
  933. }
  934. }
  935. }
  936. }
  937. .summary-title {
  938. font-size: 14px;
  939. color: #333;
  940. margin-bottom: 10px;
  941. font-weight: 500;
  942. }
  943. .summary-textarea-wrap {
  944. margin-bottom: 25px;
  945. :deep(.el-textarea__inner) {
  946. border-radius: 4px;
  947. background-color: #fff;
  948. border-color: #e5e6eb;
  949. padding: 10px 12px;
  950. font-size: 13px;
  951. &:focus {
  952. background-color: #fff;
  953. border-color: #409eff;
  954. }
  955. }
  956. }
  957. .attachment-section-title {
  958. font-size: 14px;
  959. color: #333;
  960. margin-bottom: 12px;
  961. font-weight: 500;
  962. }
  963. .upload-area {
  964. margin-bottom: 15px;
  965. }
  966. }
  967. }
  968. .drawer-body-custom {
  969. &::-webkit-scrollbar {
  970. display: none;
  971. }
  972. -ms-overflow-style: none;
  973. scrollbar-width: none;
  974. }
  975. .text-content {
  976. white-space: pre-wrap;
  977. word-break: break-all;
  978. line-height: 1.6;
  979. color: #1D2129;
  980. }
  981. </style>