detail.vue 40 KB

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