detail.vue 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290
  1. <template>
  2. <el-drawer
  3. v-model="visible"
  4. direction="rtl"
  5. size="80%"
  6. class="platform-detail-drawer"
  7. :destroy-on-close="true"
  8. :with-header="false"
  9. >
  10. <!-- 自定义头部 -->
  11. <div class="detail-header">
  12. <div class="header-left">
  13. <span class="project-title">{{ drawerForm.projectName }}</span>
  14. </div>
  15. <div class="header-right">
  16. <el-icon class="close-btn" @click="visible = false"><Close /></el-icon>
  17. </div>
  18. </div>
  19. <!-- 详情内容区 -->
  20. <div class="detail-main-scroll" v-loading="loading">
  21. <!-- 项目进度 (扁平化设计) -->
  22. <div class="project-progress-card">
  23. <div class="section-title">项目进度</div>
  24. <div class="chevron-steps">
  25. <div v-for="(step, idx) in stepList" :key="idx" class="chevron-step"
  26. :class="{
  27. 'is-finished': (drawerForm.projectStatus || 1) > (idx + 1),
  28. 'is-active': (drawerForm.projectStatus || 1) === (idx + 1)
  29. }"
  30. @click="handleStepClick(idx + 1)">
  31. <span class="step-text">{{ step }}</span>
  32. </div>
  33. </div>
  34. <div class="progress-footer">
  35. <span class="sub-label">最新进度</span>
  36. <span class="all-progress-link">全部进度</span>
  37. </div>
  38. </div>
  39. <div class="detail-content-layout">
  40. <!-- 左侧核心信息 -->
  41. <div class="content-left-panel">
  42. <div class="detail-content-card">
  43. <el-tabs v-model="drawerActiveTab" class="custom-tabs sticky-tabs">
  44. <el-tab-pane label="项目信息" name="info">
  45. <!-- 基本信息 -->
  46. <div class="info-section">
  47. <div class="info-section-header">
  48. <span class="title">基本信息</span>
  49. <el-button v-if="!isReadOnly" type="primary" size="small" @click="handleEdit">
  50. <el-icon style="margin-right: 4px;"><Edit /></el-icon>编辑
  51. </el-button>
  52. </div>
  53. <el-descriptions :column="3" class="custom-desc">
  54. <el-descriptions-item label="项目名称" :span="3">{{ drawerForm.projectName }}</el-descriptions-item>
  55. <el-descriptions-item label="归属公司">{{ displayCompanyName }}</el-descriptions-item>
  56. <el-descriptions-item label="客户名称">{{ drawerForm.customName || drawerForm.customerName }}</el-descriptions-item>
  57. <el-descriptions-item label="行业">{{ displayIndustryName }}</el-descriptions-item>
  58. <el-descriptions-item label="部门">{{ displayDeptName }}</el-descriptions-item>
  59. <el-descriptions-item label="项目状态">{{ getStatusLabel(drawerForm.projectStatus) }}</el-descriptions-item>
  60. <el-descriptions-item label="项目级别">{{ displayProjectLevelName }}</el-descriptions-item>
  61. <el-descriptions-item label="项目类型">{{ displayProjectTypeName }}</el-descriptions-item>
  62. <el-descriptions-item label="业务负责人">{{ drawerForm.leaderName || findUserName(drawerForm.leader) }}</el-descriptions-item>
  63. <el-descriptions-item label="产品支持">{{ drawerForm.productSupportName || findUserName(drawerForm.productSupport) }}</el-descriptions-item>
  64. <el-descriptions-item label="平台名称" :span="2">{{ drawerForm.platformName }}</el-descriptions-item>
  65. <el-descriptions-item label="平台链接" :span="3">{{ drawerForm.platformLink }}</el-descriptions-item>
  66. </el-descriptions>
  67. </div>
  68. <!-- 项目情况 -->
  69. <div class="info-section mt-24">
  70. <div class="info-section-header">
  71. <span class="title">项目情况</span>
  72. </div>
  73. <el-descriptions :column="3" class="custom-desc">
  74. <el-descriptions-item label="登记日期">{{ formatDate(drawerForm.createTime) }}</el-descriptions-item>
  75. <el-descriptions-item label="金额(万)">{{ drawerForm.amount ? Number(drawerForm.amount).toFixed(2) : '' }}</el-descriptions-item>
  76. <el-descriptions-item label="报名费">{{ drawerForm.entryFee ? Number(drawerForm.entryFee).toFixed(2) : '' }}</el-descriptions-item>
  77. <el-descriptions-item label="投标保证金">{{ drawerForm.bidBond ? Number(drawerForm.bidBond).toFixed(2) : '' }}</el-descriptions-item>
  78. <el-descriptions-item label="赢单率(%)">{{ drawerForm.winningRate ? drawerForm.winningRate + '%' : (drawerForm.winRate ? drawerForm.winRate + '%' : '') }}</el-descriptions-item>
  79. <el-descriptions-item label="报名截止时间">{{ formatDate(drawerForm.signUpDeadline) }}</el-descriptions-item>
  80. <el-descriptions-item label="投标截止时间">{{ formatDate(drawerForm.tenderDeadline) }}</el-descriptions-item>
  81. <el-descriptions-item label="标书汇编完成时间">{{ formatDate(drawerForm.docCompileTime) }}</el-descriptions-item>
  82. <el-descriptions-item label="服务期(年)">{{ drawerForm.standardPeriod }}</el-descriptions-item>
  83. <el-descriptions-item label="服务时间段" :span="1">{{ drawerForm.serviceTime }}</el-descriptions-item>
  84. <el-descriptions-item label="入围类型" :span="2">{{ displayShortlistedName }}</el-descriptions-item>
  85. <el-descriptions-item label="物资类目" :span="3">{{ displayProfessionName }}</el-descriptions-item>
  86. <el-descriptions-item label="招标代理机构" :span="1">{{ drawerForm.biddingAgency }}</el-descriptions-item>
  87. <el-descriptions-item label="机构联系方式" :span="2">{{ drawerForm.agencyContact }}</el-descriptions-item>
  88. <el-descriptions-item label="标期类型">{{ drawerForm.bidPeriodType == 2 ? '周期性框架' : '单项目入围' }}</el-descriptions-item>
  89. <template v-if="drawerForm.bidPeriodType == 2">
  90. <el-descriptions-item label="预计下次投标时间">{{ formatDate(drawerForm.nextBiddingTime) }}</el-descriptions-item>
  91. <el-descriptions-item label="提前提醒天数">{{ drawerForm.noticeAdvanceDays }}</el-descriptions-item>
  92. </template>
  93. <template v-else>
  94. <el-descriptions-item :span="2" label-class-name="hidden-label" class-name="hidden-value"></el-descriptions-item>
  95. </template>
  96. <el-descriptions-item label="入围要求" :span="3">{{ drawerForm.shortlistedRequirement }}</el-descriptions-item>
  97. <el-descriptions-item label="项目描述" :span="3">{{ drawerForm.projectDesc }}</el-descriptions-item>
  98. </el-descriptions>
  99. </div>
  100. </el-tab-pane>
  101. <el-tab-pane label="项目联系人" name="contact">
  102. <div class="contact-tab-content">
  103. <div class="tab-toolbar">
  104. <el-dropdown v-if="!isReadOnly" @command="handleContactCommand">
  105. <el-button type="primary">
  106. <el-icon style="margin-right: 4px;"><Plus /></el-icon>新建联系人<el-icon class="el-icon--right"><arrow-down /></el-icon>
  107. </el-button>
  108. <template #dropdown>
  109. <el-dropdown-menu>
  110. <el-dropdown-item command="associate">关联客户联系人</el-dropdown-item>
  111. <el-dropdown-item command="new">新建联系人</el-dropdown-item>
  112. </el-dropdown-menu>
  113. </template>
  114. </el-dropdown>
  115. </div>
  116. <el-table :data="contactList" v-loading="contactLoading" class="custom-table" border>
  117. <el-table-column label="姓名" align="center" width="100">
  118. <template #default="scope">
  119. <el-link type="primary" :underline="false" @click="handleEditContact(scope.row)">
  120. {{ scope.row.contactName || scope.row.name }}
  121. </el-link>
  122. </template>
  123. </el-table-column>
  124. <el-table-column label="性别" align="center" width="80">
  125. <template #default="scope">
  126. {{ (scope.row.gender == '0' || scope.row.sex == '0') ? '男' : (scope.row.gender == '1' || scope.row.sex == '1' ? '女' : '') }}
  127. </template>
  128. </el-table-column>
  129. <el-table-column label="部门" prop="deptName" align="center" show-overflow-tooltip />
  130. <el-table-column label="职位" align="center" prop="position" show-overflow-tooltip />
  131. <el-table-column label="项目角色" align="center">
  132. <template #default="scope">
  133. {{ getProjectRoleLabel(scope.row.projectRole) }}
  134. </template>
  135. </el-table-column>
  136. <el-table-column label="是否关键人" align="center" width="100">
  137. <template #default="scope">
  138. <span>{{ (scope.row.isKeyPerson === 1 || scope.row.keyPerson === 1) ? '是' : '否' }}</span>
  139. </template>
  140. </el-table-column>
  141. <el-table-column label="操作" align="center" width="120" fixed="right">
  142. <template #default="scope">
  143. <el-button v-if="!isReadOnly" link type="primary" @click="handleEditContact(scope.row)">编辑</el-button>
  144. <el-button v-if="!isReadOnly" link type="danger" @click="handleDeleteContact(scope.row)">删除</el-button>
  145. </template>
  146. </el-table-column>
  147. </el-table>
  148. </div>
  149. </el-tab-pane>
  150. <el-tab-pane label="结果分析" name="analysis">
  151. <div class="analysis-tab-content">
  152. <div class="analysis-form">
  153. <div class="analysis-row">
  154. <span class="form-label">成交结果</span>
  155. <el-radio-group v-model="analysisForm.resultType" class="result-radio">
  156. <el-radio value="win">赢单</el-radio>
  157. <el-radio value="lose">丢单</el-radio>
  158. </el-radio-group>
  159. <div style="flex:1"></div>
  160. <el-button v-if="!isReadOnly" type="primary" icon="CircleCheck" @click="handleSaveAnalysis">保 存</el-button>
  161. </div>
  162. <template v-if="analysisForm.resultType === 'win'">
  163. <div class="summary-title">赢单总结</div>
  164. <div class="summary-textarea-wrap">
  165. <el-input v-model="analysisForm.summary" type="textarea" :rows="4" placeholder="请输入赢单总结" />
  166. </div>
  167. </template>
  168. <template v-else>
  169. <div class="summary-title">丢单原因</div>
  170. <div class="summary-textarea-wrap">
  171. <el-input v-model="analysisForm.loseReason" type="textarea" :rows="4" placeholder="请输入丢单原因" />
  172. </div>
  173. </template>
  174. <div class="attachment-section-title">附件</div>
  175. <div v-if="!isReadOnly" class="upload-area">
  176. <el-upload
  177. :action="uploadFileUrl"
  178. :headers="headers"
  179. :on-success="handleAnalysisUploadSuccess"
  180. :show-file-list="false"
  181. multiple
  182. >
  183. <el-button type="primary" icon="Upload">上 传</el-button>
  184. </el-upload>
  185. </div>
  186. <div v-if="analysisFileList.length > 0" class="analysis-file-list" style="margin-top:10px;">
  187. <el-table :data="analysisFileList" border size="small">
  188. <el-table-column label="文件名称" prop="name" align="center" show-overflow-tooltip />
  189. <el-table-column label="操作" width="120" align="center">
  190. <template #default="scope">
  191. <el-button link type="primary" @click="downloadFile(scope.row)">下载</el-button>
  192. <el-button v-if="!isReadOnly" link type="danger" @click="handleAnalysisDeleteFile(scope.$index)">删除</el-button>
  193. </template>
  194. </el-table-column>
  195. </el-table>
  196. </div>
  197. </div>
  198. </div>
  199. </el-tab-pane>
  200. <el-tab-pane label="附件" name="files">
  201. <div class="files-tab-content">
  202. <div v-if="!isReadOnly" class="tab-toolbar">
  203. <el-upload
  204. :action="uploadFileUrl"
  205. :headers="headers"
  206. :on-success="handleUploadSuccess"
  207. :show-file-list="false"
  208. multiple
  209. >
  210. <el-button type="primary" icon="Upload">上传附件</el-button>
  211. </el-upload>
  212. </div>
  213. <el-table :data="detailFileList" border class="custom-table">
  214. <el-table-column label="文件名称" prop="name" show-overflow-tooltip />
  215. <el-table-column label="上传时间" prop="uploadTime" width="180" align="center">
  216. <template #default="scope"><span>{{ formatDate(scope.row.uploadTime) }}</span></template>
  217. </el-table-column>
  218. <el-table-column label="操作" width="120" align="center">
  219. <template #default="scope">
  220. <el-button link type="primary" @click="downloadFile(scope.row)">下载</el-button>
  221. <el-button v-if="!isReadOnly" link type="danger" @click="handleDeleteFile(scope.row)">删除</el-button>
  222. </template>
  223. </el-table-column>
  224. </el-table>
  225. </div>
  226. </el-tab-pane>
  227. </el-tabs>
  228. </div>
  229. </div>
  230. <!-- 右侧跟进活动 -->
  231. <div class="content-right-panel">
  232. <business-activity
  233. ref="businessActivityRef"
  234. :business-id="props.id"
  235. :dataType="3"
  236. business-type="platformSelection"
  237. :info-data="{
  238. ...drawerForm,
  239. customerName: drawerForm.customName || drawerForm.customerName,
  240. deptName: displayDeptName,
  241. department: displayDeptName,
  242. profession: displayIndustryName,
  243. industryName: displayIndustryName,
  244. /* 显式传入负责人姓名,兼容多种字段名,避免回退到系统创建人 */
  245. leaderName: drawerForm.leaderName || drawerForm.managerName || drawerForm.staffName || ''
  246. }"
  247. @update-readonly="val => isReadOnly = val"
  248. />
  249. </div>
  250. </div>
  251. </div>
  252. </el-drawer>
  253. <!-- 项目联系人抽屉 -->
  254. <el-drawer
  255. v-model="projectContactDrawerOpen"
  256. direction="rtl"
  257. size="80%"
  258. destroy-on-close
  259. :with-header="false"
  260. >
  261. <div class="drawer-header-compact" style="height: 40px; padding: 0 16px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; background: #fff;">
  262. <span style="font-size: 15px; font-weight: 600; color: #333;">{{ projectContactForm.id ? '编辑项目联系人' : '新建项目联系人' }}</span>
  263. <el-icon @click="projectContactDrawerOpen = false" style="cursor: pointer; font-size: 18px; color: #94a3b8;"><Close /></el-icon>
  264. </div>
  265. <div class="drawer-body-custom" style="padding: 24px; overflow-y: auto; height: calc(100% - 120px);">
  266. <el-form :model="projectContactForm" :rules="projectContactRules" ref="projectContactFormRef" label-width="100px" label-position="right" class="no-bold-label">
  267. <!-- 基本信息 -->
  268. <div class="form-section-group">
  269. <div class="section-group-title-bar">基本信息</div>
  270. <el-row :gutter="20">
  271. <el-col :span="8">
  272. <el-form-item label="姓名" prop="contactName"><el-input v-model="projectContactForm.contactName" placeholder="请输入" /></el-form-item>
  273. </el-col>
  274. <el-col :span="8">
  275. <el-form-item label="联系人类型" prop="type">
  276. <el-radio-group v-model="projectContactForm.type">
  277. <el-radio value="1">公司职员</el-radio>
  278. <el-radio value="2">关系资源人</el-radio>
  279. </el-radio-group>
  280. </el-form-item>
  281. </el-col>
  282. <el-col :span="8">
  283. <el-form-item label="性别" prop="gender">
  284. <el-radio-group v-model="projectContactForm.gender">
  285. <el-radio value="0">男</el-radio><el-radio value="1">女</el-radio>
  286. </el-radio-group>
  287. </el-form-item>
  288. </el-col>
  289. </el-row>
  290. <el-row :gutter="20">
  291. <el-col :span="8">
  292. <el-form-item label="年龄" prop="age"><el-input v-model="projectContactForm.age" type="number" placeholder="请输入" /></el-form-item>
  293. </el-col>
  294. <el-col :span="8">
  295. <el-form-item label="籍贯" prop="nativePlace"><el-input v-model="projectContactForm.nativePlace" placeholder="请输入" /></el-form-item>
  296. </el-col>
  297. <el-col :span="8">
  298. <el-form-item label="生日" prop="birthday">
  299. <el-date-picker v-model="projectContactForm.birthday" type="date" placeholder="请选择" style="width: 100%" value-format="YYYY-MM-DD" />
  300. </el-form-item>
  301. </el-col>
  302. </el-row>
  303. <el-row :gutter="20">
  304. <el-col :span="24">
  305. <el-form-item label="描述" prop="remark"><el-input v-model="projectContactForm.remark" type="textarea" :rows="3" placeholder="请输入" /></el-form-item>
  306. </el-col>
  307. </el-row>
  308. </div>
  309. <!-- 办公信息 -->
  310. <div class="form-section-group">
  311. <div class="section-group-title-bar">办公信息</div>
  312. <el-row :gutter="20">
  313. <el-col :span="8">
  314. <el-form-item label="在职状态" prop="jobStatus">
  315. <el-radio-group v-model="projectContactForm.jobStatus">
  316. <el-radio value="1">在职</el-radio>
  317. <el-radio value="2">离职</el-radio>
  318. </el-radio-group>
  319. </el-form-item>
  320. </el-col>
  321. <el-col :span="8">
  322. <el-form-item label="手机号码" prop="phone"><el-input v-model="projectContactForm.phone" placeholder="请输入" /></el-form-item>
  323. </el-col>
  324. <el-col :span="8">
  325. <el-form-item label="部门" prop="deptName"><el-input v-model="projectContactForm.deptName" placeholder="请输入" /></el-form-item>
  326. </el-col>
  327. </el-row>
  328. <el-row :gutter="20">
  329. <el-col :span="8">
  330. <el-form-item label="职位" prop="position"><el-input v-model="projectContactForm.position" placeholder="请输入" /></el-form-item>
  331. </el-col>
  332. <el-col :span="8">
  333. <el-form-item label="办公座机" prop="officePhone"><el-input v-model="projectContactForm.officePhone" placeholder="请输入" /></el-form-item>
  334. </el-col>
  335. </el-row>
  336. <el-row :gutter="20">
  337. <el-col :span="24">
  338. <el-form-item label="办公地址" prop="addressDetail">
  339. <div style="display: flex; gap: 10px;">
  340. <el-cascader
  341. v-model="projectContactForm.officeRegion"
  342. :options="areaOptions"
  343. :props="{ label: 'areaName', value: 'id', children: 'children' }"
  344. placeholder="请选择省/市/区"
  345. style="width: 250px"
  346. clearable
  347. />
  348. <el-input v-model="projectContactForm.addressDetail" placeholder="请输入详细地址" style="flex: 1" />
  349. </div>
  350. </el-form-item>
  351. </el-col>
  352. </el-row>
  353. <el-row :gutter="20">
  354. <el-col :span="24">
  355. <el-form-item label="工作内容" prop="jobContent"><el-input v-model="projectContactForm.jobContent" type="textarea" :rows="3" placeholder="请输入" /></el-form-item>
  356. </el-col>
  357. </el-row>
  358. </div>
  359. <!-- 项目决策 -->
  360. <div class="form-section-group">
  361. <div class="section-group-title-bar">项目决策</div>
  362. <el-row :gutter="20">
  363. <el-col :span="8">
  364. <el-form-item label="项目角色" prop="projectRole">
  365. <el-select v-model="projectContactForm.projectRole" style="width: 100%" placeholder="请选择" filterable clearable>
  366. <el-option v-for="item in projectRoleOptions" :key="item.value" :label="item.label" :value="parseInt(item.value)" />
  367. </el-select>
  368. </el-form-item>
  369. </el-col>
  370. <el-col :span="8">
  371. <el-form-item label="是否关键人" prop="isKeyPerson">
  372. <el-select v-model="projectContactForm.isKeyPerson" style="width: 100%" placeholder="请选择">
  373. <el-option label="是" :value="1" /><el-option label="否" :value="0" />
  374. </el-select>
  375. </el-form-item>
  376. </el-col>
  377. </el-row>
  378. <el-row :gutter="20">
  379. <el-col :span="24">
  380. <el-form-item label="公关情况" prop="prStatus"><el-input v-model="projectContactForm.prStatus" type="textarea" :rows="3" placeholder="请输入" /></el-form-item>
  381. </el-col>
  382. </el-row>
  383. </div>
  384. <!-- 家庭信息 -->
  385. <div class="form-section-group">
  386. <div class="section-group-title-bar">家庭信息</div>
  387. <el-row :gutter="20">
  388. <el-col :span="24">
  389. <el-form-item label="家庭住址" prop="homeAddressDetail">
  390. <div style="display: flex; gap: 10px;">
  391. <el-cascader
  392. v-model="projectContactForm.homeRegion"
  393. :options="areaOptions"
  394. :props="{ label: 'areaName', value: 'id', children: 'children' }"
  395. placeholder="请选择省/市/区"
  396. style="width: 250px"
  397. clearable
  398. />
  399. <el-input v-model="projectContactForm.homeAddressDetail" placeholder="请输入详细地址" style="flex: 1" />
  400. </div>
  401. </el-form-item>
  402. </el-col>
  403. </el-row>
  404. <el-row :gutter="20">
  405. <el-col :span="8">
  406. <el-form-item label="家庭情况" prop="familyStatus"><el-input v-model="projectContactForm.familyStatus" placeholder="请输入" /></el-form-item>
  407. </el-col>
  408. <el-col :span="8">
  409. <el-form-item label="爱好" prop="hobby"><el-input v-model="projectContactForm.hobby" placeholder="请输入" /></el-form-item>
  410. </el-col>
  411. <el-col :span="8">
  412. <el-form-item label="性格特征" prop="characterTrait"><el-input v-model="projectContactForm.characterTrait" placeholder="请输入" /></el-form-item>
  413. </el-col>
  414. </el-row>
  415. <el-row :gutter="20">
  416. <el-col :span="8">
  417. <el-form-item label="是否抽烟" prop="isSmoke">
  418. <el-radio-group v-model="projectContactForm.isSmoke">
  419. <el-radio value="1">是</el-radio><el-radio value="0">否</el-radio>
  420. </el-radio-group>
  421. </el-form-item>
  422. </el-col>
  423. <el-col :span="8">
  424. <el-form-item label="是否喝酒" prop="isDrink">
  425. <el-radio-group v-model="projectContactForm.isDrink">
  426. <el-radio value="1">是</el-radio><el-radio value="0">否</el-radio>
  427. </el-radio-group>
  428. </el-form-item>
  429. </el-col>
  430. </el-row>
  431. </div>
  432. </el-form>
  433. </div>
  434. <div class="drawer-footer-standard" style="padding: 16px 24px; border-top: 1px solid #f0f0f0; text-align: right;">
  435. <el-button @click="projectContactDrawerOpen = false">取 消</el-button>
  436. <el-button type="primary" @click="submitProjectContact" :loading="contactSubmitting">确 定</el-button>
  437. </div>
  438. </el-drawer>
  439. <!-- 关联联系人对话框 -->
  440. <el-dialog title="关联客户联系人" v-model="associateVisible" width="900px" append-to-body>
  441. <el-table :data="associateList" v-loading="associateLoading" border @selection-change="handleSelectionChange" max-height="400px">
  442. <el-table-column type="selection" width="55" />
  443. <el-table-column label="姓名" align="center" prop="contactName" width="120" />
  444. <el-table-column label="客户名称" align="center" prop="customerName" show-overflow-tooltip />
  445. <el-table-column label="部门" align="center" prop="deptName" show-overflow-tooltip />
  446. <el-table-column label="职位" align="center" prop="position" width="150" />
  447. <el-table-column label="手机号码" align="center" prop="phone" width="150" />
  448. </el-table>
  449. <template #footer>
  450. <div class="dialog-footer">
  451. <el-button @click="associateVisible = false">取 消</el-button>
  452. <el-button type="primary" @click="confirmAssociate" :disabled="selectedContacts.length === 0">确 认</el-button>
  453. </div>
  454. </template>
  455. </el-dialog>
  456. </template>
  457. <script setup>
  458. import { ref, reactive, computed, watch, getCurrentInstance, toRefs } from 'vue';
  459. import { getPlatformSelection, updatePlatformSelection } from '@/api/saleManage/platformSelection/index';
  460. import { listContact, addContact, delContact, updateContact, getContact } from '@/api/customer/crmContact';
  461. import { getSalesResultAnalyzeByObjectNo, addSalesResultAnalyze, updateSalesResultAnalyze } from '@/api/saleManage/leads/salesResultAnalyze';
  462. import { listByIds } from "@/api/system/oss/index";
  463. import { globalHeaders } from '@/utils/request';
  464. import { listProvinceWithCities } from "@/api/customer/addressArea";
  465. import { useDebounceFn } from '@vueuse/core';
  466. import BusinessActivity from '@/views/common/businessActivity.vue';
  467. import { ElMessageBox, ElMessage } from 'element-plus';
  468. import { listComStaff } from '@/api/system/comStaff/index';
  469. import { listCustomerInfo } from "@/api/customer/customerInfo/index";
  470. const proxy = getCurrentInstance().proxy;
  471. const businessActivityRef = ref(null);
  472. const props = defineProps({
  473. modelValue: Boolean,
  474. id: [String, Number],
  475. options: { type: Object, default: () => ({}) }
  476. });
  477. const emit = defineEmits(['update:modelValue', 'edit', 'success']);
  478. const visible = computed({
  479. get: () => props.modelValue,
  480. set: (val) => emit('update:modelValue', val)
  481. });
  482. const loading = ref(false);
  483. const isReadOnly = ref(true);
  484. const drawerForm = ref({ projectStatus: 1, fileList: [], memberList: [] });
  485. const drawerActiveTab = ref('info');
  486. const stepList = ['获取信息', '正式立项', '竞价投标', '项目跟进', '结案'];
  487. /**
  488. * loadData 后调用:当后端详情接口未返回 leaderName / deptName 文字时,
  489. * 通过 listComStaff(staffId) 查询 CRM 员工信息(含 staffName 和 deptName)进行补全。
  490. */
  491. const enrichFormNames = async () => {
  492. const data = drawerForm.value;
  493. const leaderId = data.leader || data.managerId;
  494. // leaderName 和 deptName 都已有文字,不需要查询
  495. const hasLeaderName = data.leaderName && isNaN(Number(data.leaderName));
  496. const hasDeptName = data.deptName && isNaN(Number(data.deptName));
  497. if (hasLeaderName && hasDeptName) return;
  498. if (!leaderId) return;
  499. try {
  500. const res = await listComStaff({ staffId: leaderId });
  501. // listComStaff 返回分页结构,取第一条匹配的员工
  502. const staff = (res.rows || res.data || [])[0];
  503. if (!staff) return;
  504. drawerForm.value = {
  505. ...drawerForm.value,
  506. // 只在当前字段为空时覆盖
  507. leaderName: hasLeaderName ? data.leaderName : (staff.staffName || ''),
  508. deptName: hasDeptName ? data.deptName : (staff.deptName || '')
  509. };
  510. } catch (e) {
  511. // 静默失败,不影响主流程
  512. }
  513. };
  514. const contactList = ref([]);
  515. const contactLoading = ref(false);
  516. const contactSubmitting = ref(false);
  517. const projectContactDrawerOpen = ref(false);
  518. const projectContactFormRef = ref(null);
  519. const projectContactForm = reactive({
  520. id: undefined,
  521. contactName: '',
  522. type: '1',
  523. gender: '0',
  524. age: '',
  525. nativePlace: '',
  526. birthday: '',
  527. remark: '',
  528. jobStatus: '1',
  529. phone: '',
  530. deptName: '',
  531. position: '',
  532. officePhone: '',
  533. officeRegion: [],
  534. addressDetail: '',
  535. jobContent: '',
  536. projectRole: null,
  537. isKeyPerson: 0,
  538. prStatus: '',
  539. familyStatus: '',
  540. hobby: '',
  541. characterTrait: '',
  542. isSmoke: '0',
  543. isDrink: '0',
  544. homeRegion: [],
  545. homeAddressDetail: ''
  546. });
  547. const projectContactRules = {
  548. contactName: [{ required: true, message: '姓名不能为空', trigger: 'blur' }],
  549. age: [{ required: true, message: '年龄不能为空', trigger: 'blur' }],
  550. deptName: [{ required: true, message: '部门不能为空', trigger: 'blur' }],
  551. phone: [
  552. { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的11位手机号码', trigger: 'blur' }
  553. ],
  554. officePhone: [
  555. { pattern: /^1[3-9]\d{9}$/, message: '座机号请输入正确的11位手机号码', trigger: 'blur' }
  556. ]
  557. };
  558. const { LXRJE0001: projectRoleOptions } = toRefs(reactive(proxy.useDict("LXRJE0001")));
  559. const areaOptions = ref([]);
  560. const associateVisible = ref(false);
  561. const associateLoading = ref(false);
  562. const associateList = ref([]);
  563. const selectedContacts = ref([]);
  564. // 结果分析逻辑
  565. const analysisForm = reactive({ id: undefined, resultType: 'win', summary: '', loseReason: '', fileNo: '' });
  566. const analysisFileList = ref([]);
  567. // 附件管理
  568. const detailFileList = ref([]);
  569. const uploadFileUrl = ref(import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload');
  570. const headers = ref(globalHeaders());
  571. watch(() => props.modelValue, (val) => {
  572. if (val && props.id) {
  573. loadData();
  574. }
  575. });
  576. const loadData = async () => {
  577. loading.value = true;
  578. try {
  579. const res = await getPlatformSelection(props.id);
  580. const data = res.data || {};
  581. drawerForm.value = {
  582. ...data,
  583. // 字段兼容映射
  584. materialCategory: data.materialCategory || data.profession,
  585. biddingAgency: data.biddingAgency || data.agency,
  586. nextBiddingTime: data.nextBiddingTime || data.nextBidTime,
  587. noticeAdvanceDays: data.noticeAdvanceDays ?? data.reminderDays,
  588. serviceTime: data.serviceTime || data.serviceTimeRange,
  589. signUpDeadline: data.signUpDeadline || data.entryDeadline,
  590. tenderDeadline: data.tenderDeadline || data.bidDeadline,
  591. shortlistedType: data.shortlistedType || data.finalizationType,
  592. shortlistedRequirement: data.shortlistedRequirement || data.condition || data.requirement,
  593. industry: data.industry || data.industryName || data.professionName,
  594. deptName: data.deptName || data.createDeptName || data.dept?.deptName || data.createDept
  595. };
  596. // 通过客户编号查询真实的客户 ID
  597. const cNo = drawerForm.value.customNo || drawerForm.value.companyNo || drawerForm.value.customerNo;
  598. if (cNo) {
  599. listCustomerInfo({ customerNo: cNo }).then(cRes => {
  600. if (cRes.rows && cRes.rows.length > 0) {
  601. drawerForm.value.realCustomerId = cRes.rows[0].id;
  602. }
  603. });
  604. }
  605. // 加载完数据后,立即补全负责人和部门的文字名称
  606. enrichFormNames();
  607. fetchContactList(props.id);
  608. loadAnalysisData();
  609. loadFileList();
  610. } catch (error) {
  611. proxy.$modal.msgError("获取数据失败");
  612. } finally {
  613. loading.value = false;
  614. }
  615. };
  616. const loadFileList = async () => {
  617. if (drawerForm.value.fileNo) {
  618. try {
  619. const res = await listByIds(drawerForm.value.fileNo);
  620. detailFileList.value = (res.data || []).map(item => ({
  621. name: item.originalName || item.fileName || '',
  622. url: item.url,
  623. ossId: item.ossId,
  624. uploadTime: item.createTime
  625. }));
  626. } catch (e) {}
  627. } else {
  628. detailFileList.value = [];
  629. }
  630. };
  631. const fetchContactList = async (id) => {
  632. if (!id) return;
  633. contactLoading.value = true;
  634. try {
  635. const res = await listContact({ platformCode: String(id) });
  636. contactList.value = res.rows || [];
  637. } catch (e) {} finally { contactLoading.value = false; }
  638. };
  639. const loadDicts = () => {
  640. if (areaOptions.value.length === 0) {
  641. listProvinceWithCities().then(res => {
  642. const list = res.rows || [];
  643. if (list.length > 0) {
  644. areaOptions.value = handleTree(list, "id", "parentId");
  645. }
  646. });
  647. }
  648. };
  649. /** 构造树型结构数据 */
  650. function handleTree(data, id, parentId, children) {
  651. let config = {
  652. id: id || 'id',
  653. parentId: parentId || 'parentId',
  654. childrenList: children || 'children'
  655. };
  656. var childrenListMap = {};
  657. var nodeIds = {};
  658. var tree = [];
  659. for (let d of data) {
  660. let pId = d[config.parentId];
  661. if (childrenListMap[pId] == null) {
  662. childrenListMap[pId] = [];
  663. }
  664. nodeIds[d[config.id]] = d;
  665. childrenListMap[pId].push(d);
  666. }
  667. for (let d of data) {
  668. let pId = d[config.parentId];
  669. if (nodeIds[pId] == null) {
  670. tree.push(d);
  671. }
  672. }
  673. for (let t of tree) {
  674. adaptToChildrenList(t);
  675. }
  676. function adaptToChildrenList(o) {
  677. if (childrenListMap[o[config.id]] !== null) {
  678. o[config.childrenList] = childrenListMap[o[config.id]];
  679. }
  680. if (o[config.childrenList]) {
  681. for (let c of o[config.childrenList]) {
  682. adaptToChildrenList(c);
  683. }
  684. }
  685. }
  686. return tree;
  687. }
  688. loadDicts();
  689. // --- 项目联系人交互 ---
  690. const handleContactCommand = (command) => {
  691. if (command === 'associate') { handleAssociate(); }
  692. else if (command === 'new') { handleNewProjectContact(); }
  693. };
  694. const handleAssociate = () => {
  695. if (!drawerForm.value.customNo && !drawerForm.value.companyNo && !drawerForm.value.customerNo) {
  696. return ElMessage.warning("缺少客户信息,无法关联联系人");
  697. }
  698. associateVisible.value = true;
  699. associateLoading.value = true;
  700. // 统一使用客户编号进行查询
  701. const cNo = drawerForm.value.customNo || drawerForm.value.companyNo || drawerForm.value.customerNo;
  702. listContact({ customNo: cNo }).then(res => {
  703. const currentIds = contactList.value.map(c => c.id);
  704. associateList.value = (res.rows || []).filter(item => !currentIds.includes(item.id));
  705. }).finally(() => { associateLoading.value = false; });
  706. };
  707. const handleSelectionChange = (selection) => { selectedContacts.value = selection; };
  708. const confirmAssociate = async () => {
  709. if (selectedContacts.value.length === 0) return;
  710. try {
  711. const promises = selectedContacts.value.map(contact => {
  712. return updateContact({ ...contact, platformCode: String(props.id) });
  713. });
  714. await Promise.all(promises);
  715. ElMessage.success('关联成功');
  716. associateVisible.value = false;
  717. fetchContactList(props.id);
  718. } catch (e) {}
  719. };
  720. const handleNewProjectContact = () => {
  721. Object.assign(projectContactForm, {
  722. id: undefined, contactName: '', type: '1', gender: '0', age: '', nativePlace: '', birthday: '', remark: '',
  723. jobStatus: '1', phone: '', deptName: '', position: '', officePhone: '', officeRegion: [], addressDetail: '', jobContent: '',
  724. projectRole: null, isKeyPerson: 0, prStatus: '', familyStatus: '', hobby: '', characterTrait: '', isSmoke: '0', isDrink: '0',
  725. homeRegion: [], homeAddressDetail: ''
  726. });
  727. projectContactDrawerOpen.value = true;
  728. };
  729. const handleEditContact = (row) => {
  730. const contactId = row.contactId || row.id;
  731. if (!contactId) return;
  732. proxy.$modal.loading("加载中...");
  733. getContact(contactId).then(res => {
  734. const data = res.data || {};
  735. // 防御性合并:优先使用详情接口数据,但如果详情接口返回 null,则保留列表行中的有效数据
  736. const mergedData = { ...row };
  737. for (const key in data) {
  738. if (data[key] !== null && data[key] !== undefined && data[key] !== '') {
  739. mergedData[key] = data[key];
  740. }
  741. }
  742. Object.assign(projectContactForm, {
  743. id: data.id || row.id,
  744. contactName: mergedData.contactName,
  745. type: String(mergedData.type || '1'),
  746. gender: String(mergedData.gender || '0'),
  747. age: mergedData.age,
  748. nativePlace: mergedData.nativePlace,
  749. birthday: mergedData.birthday,
  750. remark: mergedData.remark,
  751. jobStatus: String(mergedData.jobStatus !== null && mergedData.jobStatus !== undefined ? mergedData.jobStatus : '1'),
  752. phone: mergedData.phone,
  753. deptName: mergedData.deptName,
  754. position: mergedData.position,
  755. officePhone: mergedData.officePhone,
  756. officeRegion: (mergedData.addressProvince != null && mergedData.addressCity != null && mergedData.addressCounty != null)
  757. ? [String(mergedData.addressProvince), String(mergedData.addressCity), String(mergedData.addressCounty)] : [],
  758. addressDetail: mergedData.addressDetail,
  759. jobContent: mergedData.jobContent,
  760. projectRole: mergedData.projectRole !== null && mergedData.projectRole !== undefined ? Number(mergedData.projectRole) : null,
  761. isKeyPerson: mergedData.isKeyPerson !== null && mergedData.isKeyPerson !== undefined ? Number(mergedData.isKeyPerson) : 0,
  762. prStatus: mergedData.prStatus,
  763. familyStatus: mergedData.familyStatus || mergedData.familyInfo || '',
  764. hobby: mergedData.hobby || mergedData.hobbies || '',
  765. characterTrait: mergedData.characterTrait || mergedData.character || '',
  766. isSmoke: String(mergedData.isSmoke !== null && mergedData.isSmoke !== undefined ? mergedData.isSmoke : '0'),
  767. isDrink: String(mergedData.isDrink !== null && mergedData.isDrink !== undefined ? mergedData.isDrink : '0'),
  768. homeRegion: (mergedData.homeProvinceId != null && mergedData.homeCityId != null && mergedData.homeAreaId != null)
  769. ? [Number(mergedData.homeProvinceId), Number(mergedData.homeCityId), Number(mergedData.homeAreaId)] : [],
  770. homeAddressDetail: mergedData.homeAddressDetail
  771. });
  772. // 区域级联处理 (适配代码转换)
  773. const findIdByCode = (code) => {
  774. if (!code) return null;
  775. const findInTree = (nodes, targetCode) => {
  776. for (const node of nodes) {
  777. if (String(node.areaCode) === String(targetCode)) return node.id;
  778. if (node.children) {
  779. const found = findInTree(node.children, targetCode);
  780. if (found) return found;
  781. }
  782. }
  783. return null;
  784. };
  785. return findInTree(areaOptions.value, code);
  786. };
  787. if (mergedData.addressProvince || mergedData.addressCity || mergedData.addressCounty) {
  788. const officeArr = [];
  789. if (mergedData.addressProvince) {
  790. const pid = /^\d+$/.test(mergedData.addressProvince) && mergedData.addressProvince.length < 5 ? mergedData.addressProvince : findIdByCode(mergedData.addressProvince);
  791. if (pid) officeArr.push(Number(pid));
  792. }
  793. if (mergedData.addressCity) {
  794. const cid = /^\d+$/.test(mergedData.addressCity) && mergedData.addressCity.length < 7 ? mergedData.addressCity : findIdByCode(mergedData.addressCity);
  795. if (cid) officeArr.push(Number(cid));
  796. }
  797. if (mergedData.addressCounty) {
  798. const aid = /^\d+$/.test(mergedData.addressCounty) && mergedData.addressCounty.length < 9 ? mergedData.addressCounty : findIdByCode(mergedData.addressCounty);
  799. if (aid) officeArr.push(Number(aid));
  800. }
  801. projectContactForm.officeRegion = officeArr;
  802. }
  803. projectContactDrawerOpen.value = true;
  804. }).finally(() => {
  805. proxy.$modal.closeLoading();
  806. });
  807. };
  808. const submitProjectContact = () => {
  809. projectContactFormRef.value.validate(async (valid) => {
  810. if (valid) {
  811. contactSubmitting.value = true;
  812. try {
  813. // 获取选中的 AreaCode 列表 (用于办公地址转换)
  814. const findCodeById = (id) => {
  815. const findInTree = (nodes, targetId) => {
  816. for (const node of nodes) {
  817. if (Number(node.id) === Number(targetId)) return node.areaCode;
  818. if (node.children) {
  819. const found = findInTree(node.children, targetId);
  820. if (found) return found;
  821. }
  822. }
  823. return null;
  824. };
  825. return findInTree(areaOptions.value, id);
  826. };
  827. const payload = {
  828. ...projectContactForm,
  829. platformCode: String(props.id),
  830. // 转换办公地址编码为代码 String
  831. addressProvince: projectContactForm.officeRegion?.[0] ? findCodeById(projectContactForm.officeRegion[0]) : null,
  832. addressCity: projectContactForm.officeRegion?.[1] ? findCodeById(projectContactForm.officeRegion[1]) : null,
  833. addressCounty: projectContactForm.officeRegion?.[2] ? findCodeById(projectContactForm.officeRegion[2]) : null,
  834. // 转换家庭地址为 ID Long
  835. homeProvinceId: projectContactForm.homeRegion?.[0] || null,
  836. homeCityId: projectContactForm.homeRegion?.[1] || null,
  837. homeAreaId: projectContactForm.homeRegion?.[2] || null,
  838. // 关联客户信息
  839. customerName: drawerForm.value.customName || drawerForm.value.customerName || '',
  840. customNo: drawerForm.value.customNo || drawerForm.value.customerNo || '',
  841. customerId: drawerForm.value.realCustomerId || null
  842. };
  843. if (projectContactForm.id) { await updateContact(payload); }
  844. else { await addContact(payload); }
  845. ElMessage.success('保存成功');
  846. projectContactDrawerOpen.value = false;
  847. fetchContactList(props.id);
  848. } catch (e) {} finally { contactSubmitting.value = false; }
  849. }
  850. });
  851. };
  852. const handleDeleteContact = (row) => {
  853. ElMessageBox.confirm(`确认删除联系人「${row.name || row.contactName}」吗?`, '提示', { type: 'warning' }).then(async () => {
  854. await delContact(row.id);
  855. ElMessage.success('操作成功');
  856. fetchContactList(props.id);
  857. });
  858. };
  859. // --- 结果分析交互 ---
  860. const loadAnalysisData = async () => {
  861. if (!drawerForm.value.projectNo) return;
  862. try {
  863. const res = await getSalesResultAnalyzeByObjectNo(drawerForm.value.projectNo);
  864. if (res.data) {
  865. Object.assign(analysisForm, {
  866. id: res.data.id, resultType: res.data.dealResult === 1 ? 'win' : 'lose',
  867. summary: res.data.winSumUp, loseReason: res.data.loseReason, fileNo: res.data.fileNo
  868. });
  869. if (analysisForm.fileNo) {
  870. const ossRes = await listByIds(analysisForm.fileNo);
  871. analysisFileList.value = (ossRes.data || []).map(i => ({
  872. name: i.originalName || i.fileName || '',
  873. url: i.url,
  874. ossId: i.ossId
  875. }));
  876. }
  877. }
  878. } catch (e) {}
  879. };
  880. const handleSaveAnalysis = useDebounceFn(async () => {
  881. const ossIds = analysisFileList.value.map(f => f.ossId).join(',');
  882. const payload = {
  883. id: analysisForm.id,
  884. objectNo: drawerForm.value.projectNo,
  885. dataType: 4, // 1:线索, 2:商机, 3:项目优选, 4:平台优选
  886. dealResult: analysisForm.resultType === 'win' ? 1 : 2,
  887. winSumUp: analysisForm.summary,
  888. loseReason: analysisForm.loseReason,
  889. fileNo: ossIds
  890. };
  891. try {
  892. if (analysisForm.id) { await updateSalesResultAnalyze(payload); }
  893. else { await addSalesResultAnalyze(payload); }
  894. ElMessage.success("保存成功");
  895. loadAnalysisData();
  896. } catch (e) {}
  897. }, 300);
  898. const handleAnalysisUploadSuccess = (res) => {
  899. if (res.code === 200) {
  900. analysisFileList.value.push({
  901. name: res.data.originalName || res.data.fileName || '',
  902. url: res.data.url,
  903. ossId: res.data.ossId
  904. });
  905. ElMessage.success("上传成功");
  906. }
  907. };
  908. const handleAnalysisDeleteFile = (index) => { analysisFileList.value.splice(index, 1); };
  909. // --- 附件管理交互 ---
  910. const handleUploadSuccess = async (res) => {
  911. if (res.code === 200) {
  912. const ossId = res.data.ossId;
  913. const currentFileNos = drawerForm.value.fileNo ? drawerForm.value.fileNo.split(',') : [];
  914. currentFileNos.push(ossId);
  915. const newFileNo = currentFileNos.join(',');
  916. try {
  917. await updatePlatformSelection({ id: props.id, fileNo: newFileNo });
  918. drawerForm.value.fileNo = newFileNo;
  919. loadFileList();
  920. ElMessage.success('上传成功');
  921. } catch (e) {}
  922. }
  923. };
  924. const downloadFile = (file) => {
  925. if (file.url) {
  926. if (proxy.$download && proxy.$download.resource) {
  927. proxy.$download.resource(file.url);
  928. } else {
  929. window.open(file.url, '_blank');
  930. }
  931. }
  932. };
  933. const handleDeleteFile = (row) => {
  934. ElMessageBox.confirm(`确认删除附件「${row.name}」吗?`, '提示', { type: 'warning' }).then(async () => {
  935. const currentFileNos = drawerForm.value.fileNo ? drawerForm.value.fileNo.split(',') : [];
  936. const newFileNos = currentFileNos.filter(id => String(id) !== String(row.ossId));
  937. const newFileNoStr = newFileNos.join(',');
  938. try {
  939. await updatePlatformSelection({ id: props.id, fileNo: newFileNoStr });
  940. drawerForm.value.fileNo = newFileNoStr;
  941. loadFileList();
  942. ElMessage.success('删除成功');
  943. } catch (e) {
  944. proxy.$modal.msgError("删除失败");
  945. }
  946. }).catch(() => {});
  947. };
  948. const handleStepClick = async (status) => {
  949. if (isReadOnly.value) return;
  950. // 再次点击取消:如果点击已激活步骤则设为 null,否则设为目标步骤
  951. const newStatus = String(drawerForm.value.projectStatus) === String(status) ? null : status;
  952. try {
  953. await updatePlatformSelection({ id: props.id, projectStatus: newStatus });
  954. drawerForm.value.projectStatus = newStatus;
  955. proxy.$modal.msgSuccess(newStatus ? "进度更新成功" : "进度已取消");
  956. if (businessActivityRef.value) {
  957. businessActivityRef.value.fetchDynamicData();
  958. }
  959. emit('success');
  960. } catch (err) {
  961. console.error('更新进度失败:', err);
  962. }
  963. };
  964. const handleEdit = () => {
  965. emit('edit', props.id);
  966. };
  967. const handleDownload = (row) => {
  968. if (!row.fileUrl) return proxy.$modal.msgError("文件地址不存在");
  969. window.open(import.meta.env.VITE_APP_BASE_API + row.fileUrl, '_blank');
  970. };
  971. const submitAnalysis = async () => {
  972. await updatePlatformSelection(drawerForm.value);
  973. proxy.$modal.msgSuccess("分析结果保存成功");
  974. };
  975. const openLink = (link) => {
  976. if (link) {
  977. if (!link.startsWith('http')) link = 'http://' + link;
  978. window.open(link, '_blank');
  979. }
  980. };
  981. const formatDate = (date) => date ? proxy.parseTime(date, '{y}-{m}-{d}') : '';
  982. const getStatusLabel = (s) => props.options.status?.find(o => String(o.value) === String(s))?.label || (String(s) === '0' || String(s) === '1' ? '跟进中' : '');
  983. const getProjectRoleLabel = (val) => {
  984. return projectRoleOptions.value.find(o => String(o.value) === String(val))?.label || val || '';
  985. };
  986. const displayProfessionName = computed(() => {
  987. const val = drawerForm.value.profession || drawerForm.value.materialCategory;
  988. return props.options.material?.find(o => String(o.value) === String(val))?.label || '';
  989. });
  990. const displayShortlistedName = computed(() => {
  991. const val = drawerForm.value.shortlistedType || drawerForm.value.finalizationType;
  992. return props.options.shortlisted?.find(o => String(o.value) === String(val))?.label || '';
  993. });
  994. const displayCompanyName = computed(() => {
  995. const val = drawerForm.value.companyNo;
  996. return props.options.company?.find(c => String(c.companyCode) === String(val) || String(c.id) === String(val))?.companyName || '';
  997. });
  998. const displayProjectLevelName = computed(() => props.options.level?.find(o => String(o.value) === String(drawerForm.value.projectLevel))?.label || '');
  999. const displayProjectTypeName = computed(() => props.options.type?.find(o => String(o.value) === String(drawerForm.value.businessType))?.label || '');
  1000. const displayIndustryName = computed(() => {
  1001. // 优先取确认是文字名称的字段(排除纯数字 ID)
  1002. if (drawerForm.value.industryName && isNaN(Number(drawerForm.value.industryName))) return drawerForm.value.industryName;
  1003. if (drawerForm.value.industryCategoryName && isNaN(Number(drawerForm.value.industryCategoryName))) return drawerForm.value.industryCategoryName;
  1004. if (drawerForm.value.industry && isNaN(Number(drawerForm.value.industry))) return drawerForm.value.industry;
  1005. const flatten = (list) => {
  1006. let res = [];
  1007. list.forEach(i => {
  1008. res.push(i);
  1009. if (i.children) res = res.concat(flatten(i.children));
  1010. });
  1011. return res;
  1012. };
  1013. // 否则通过 ID 查找
  1014. const professionId = drawerForm.value.profession || drawerForm.value.industry;
  1015. if (professionId && props.options.industryList) {
  1016. const flatList = flatten(props.options.industryList);
  1017. const found = flatList.find(i => String(i.id) === String(professionId))?.industryCategoryName || '';
  1018. if (found) return found;
  1019. }
  1020. return '';
  1021. });
  1022. const displayDeptName = computed(() => {
  1023. // 第一优先:后端直接返回的文字部门名
  1024. const name = drawerForm.value.deptName || drawerForm.value.createDeptName || '';
  1025. if (name && isNaN(Number(name))) return name;
  1026. // 第二兑底:通过部门 ID 在父组件传入的部门树中反查
  1027. const deptId = drawerForm.value.deptNo || drawerForm.value.deptId;
  1028. if (deptId && props.options.dept) {
  1029. const found = flatten(props.options.dept).find(d => String(d.id) === String(deptId));
  1030. if (found) return found.label || found.deptName || '';
  1031. }
  1032. return '';
  1033. });
  1034. const findUserName = (id) => {
  1035. if (!id) return '';
  1036. const user = props.options.user?.find(u => String(u.userId || u.staffId || u.id) === String(id));
  1037. return user ? (user.nickName || user.staffName || user.name) : id;
  1038. };
  1039. </script>
  1040. <style scoped lang="scss">
  1041. .platform-detail-drawer {
  1042. :deep(.el-drawer__body) { padding: 0; background: #fff; overflow: hidden; display: flex; flex-direction: column; }
  1043. }
  1044. .detail-header {
  1045. padding: 12px 24px;
  1046. background: #fff;
  1047. display: flex;
  1048. justify-content: space-between;
  1049. align-items: center;
  1050. z-index: 10;
  1051. border-bottom: 1px solid #f0f2f5;
  1052. .project-title {
  1053. font-size: 16px;
  1054. color: #333;
  1055. font-weight: normal;
  1056. }
  1057. .header-right {
  1058. display: flex;
  1059. align-items: center;
  1060. gap: 16px;
  1061. .close-btn {
  1062. font-size: 20px;
  1063. color: #86909c;
  1064. cursor: pointer;
  1065. &:hover { color: #333; }
  1066. }
  1067. }
  1068. }
  1069. .detail-main-scroll {
  1070. flex: 1;
  1071. display: flex;
  1072. flex-direction: column;
  1073. overflow: hidden;
  1074. padding: 0;
  1075. }
  1076. .drawer-body-custom {
  1077. &::-webkit-scrollbar { display: none; }
  1078. -ms-overflow-style: none;
  1079. scrollbar-width: none;
  1080. }
  1081. .project-progress-card {
  1082. background: #fff; padding: 15px 24px 20px; margin-bottom: 0;
  1083. flex-shrink: 0;
  1084. .section-title { font-size: 13px; color: #333; margin-bottom: 12px; font-weight: normal; }
  1085. .chevron-steps {
  1086. display: flex; align-items: center; gap: 0; padding: 2px 0;
  1087. .chevron-step {
  1088. 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;
  1089. margin-right: -10px; // 负边距实现重叠
  1090. clip-path: polygon(calc(100% - 10px) 0%, 100% 50%, calc(100% - 10px) 100%, 0% 100%, 10px 50%, 0% 0%);
  1091. &:first-child {
  1092. border-radius: 4px 0 0 4px;
  1093. clip-path: polygon(calc(100% - 10px) 0%, 100% 50%, calc(100% - 10px) 100%, 0% 100%, 0% 0%);
  1094. }
  1095. &:last-child {
  1096. border-radius: 0 4px 4px 0;
  1097. margin-right: 0;
  1098. clip-path: polygon(100% 0%, 100% 100%, 0% 100%, 10px 50%, 0% 0%);
  1099. }
  1100. &.is-active { background-color: #00B881; color: #fff; z-index: 2; font-weight: 500 !important; }
  1101. &.is-finished { background-color: #E6F8F3; color: #00B881; z-index: 1; }
  1102. &:hover:not(.is-active) { background-color: #E5E6EB; z-index: 3; }
  1103. }
  1104. }
  1105. .progress-footer { display: none; }
  1106. }
  1107. .detail-content-layout { display: flex; gap: 0; align-items: stretch; margin-top: 0; min-height: 0; flex: 1; border-top: 1px solid #f0f2f5; }
  1108. .content-left-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
  1109. .content-right-panel { width: 450px; background: #fff; display: flex; flex-direction: column; flex-shrink: 0; border-left: 1px solid #f0f2f5; overflow: hidden; }
  1110. .detail-content-card {
  1111. background: #fff; padding: 0; flex: 1; display: flex; flex-direction: column;
  1112. }
  1113. .custom-tabs {
  1114. flex: 1; display: flex; flex-direction: column; overflow: hidden;
  1115. :deep(.el-tabs__header) {
  1116. order: 1;
  1117. margin: 0 !important;
  1118. padding: 0 16px !important;
  1119. background: #fff !important;
  1120. height: 48px !important;
  1121. box-sizing: border-box !important;
  1122. position: relative !important;
  1123. border-bottom: 1px solid #f2f2f2 !important;
  1124. }
  1125. :deep(.el-tabs__nav-wrap::after) { display: none !important; }
  1126. :deep(.el-tabs__item) {
  1127. height: 48px !important;
  1128. line-height: 48px !important;
  1129. font-size: 14px;
  1130. color: #666;
  1131. &.is-active { color: #409eff; font-weight: 500 !important; }
  1132. }
  1133. :deep(.el-tabs__active-bar) {
  1134. height: 2px !important;
  1135. border-radius: 1px !important;
  1136. bottom: 0 !important;
  1137. z-index: 5;
  1138. }
  1139. :deep(.el-tabs__content) { order: 2; flex: 1; overflow-y: auto; padding: 24px; }
  1140. }
  1141. .content-right-panel {
  1142. :deep(.el-tabs) { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
  1143. :deep(.el-tabs__content) { order: 2; flex: 1; overflow-y: auto; padding: 20px; }
  1144. :deep(.el-tabs__header) {
  1145. order: 1;
  1146. margin: 0 !important;
  1147. padding: 0 16px !important;
  1148. background: #fff !important;
  1149. height: 48px !important;
  1150. box-sizing: border-box !important;
  1151. position: relative !important;
  1152. border-bottom: 1px solid #f2f2f2 !important;
  1153. }
  1154. :deep(.el-tabs__item) {
  1155. height: 48px !important;
  1156. line-height: 48px !important;
  1157. padding: 0 10px !important;
  1158. min-width: auto !important;
  1159. }
  1160. :deep(.el-tabs__nav-wrap::after) { display: none !important; }
  1161. :deep(.el-tabs__active-bar) {
  1162. height: 2px !important;
  1163. bottom: 0 !important;
  1164. z-index: 5;
  1165. }
  1166. /* 隐藏右侧可能出现的滚动箭头 */
  1167. :deep(.el-tabs__nav-prev), :deep(.el-tabs__nav-next) { display: none !important; }
  1168. :deep(.el-tabs__nav-scroll) { overflow: visible !important; }
  1169. }
  1170. .info-section {
  1171. .info-section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; .title { font-size: 15px; color: #409eff; font-weight: normal; } }
  1172. .custom-desc {
  1173. :deep(.el-descriptions__label) { color: #86909c; font-weight: normal; width: auto; background: transparent !important; padding-bottom: 12px; &::after { content: ":"; } }
  1174. :deep(.el-descriptions__content) { color: #333; padding-bottom: 12px; }
  1175. }
  1176. }
  1177. .mt-24 { margin-top: 24px; }
  1178. :deep(.hidden-label) { display: none !important; }
  1179. :deep(.hidden-value) { display: none !important; }
  1180. .no-bold-label {
  1181. :deep(.el-form-item__label) { font-weight: normal !important; color: #666; }
  1182. :deep(.el-form-item) { margin-bottom: 18px !important; }
  1183. }
  1184. // 移除全局强制取消加粗限制
  1185. // :deep(*) { font-weight: normal !important; }
  1186. .mt-20 { margin-top: 20px; }
  1187. .tab-toolbar { margin-bottom: 16px; display: flex; justify-content: flex-end; }
  1188. .contact-tab-content, .analysis-tab-content, .files-tab-content { min-height: 400px; padding: 10px 0; }
  1189. .analysis-form {
  1190. max-width: 900px;
  1191. .analysis-row { display: flex; align-items: center; margin-bottom: 24px; .form-label { width: 80px; color: #4E5969; } .result-radio { margin-left: 12px; } }
  1192. .summary-title { font-size: 14px; color: #4E5969; margin-bottom: 12px; }
  1193. .summary-textarea-wrap { margin-bottom: 24px; }
  1194. .attachment-section-title { font-size: 14px; color: #4E5969; margin-bottom: 12px; }
  1195. .upload-area { margin-bottom: 12px; }
  1196. }
  1197. .empty-tab { color: #86909c; font-size: 13px; text-align: center; padding: 40px; }
  1198. .custom-table { :deep(th.el-table__cell) { background-color: #f7f8fa !important; color: #4e5969; font-weight: normal; } }
  1199. /* 抽屉标准样式 */
  1200. .section-group-title-bar { font-size: 14px; color: #409eff; padding: 8px 16px; background-color: #F8F9FA; margin-bottom: 20px; border-radius: 4px; }
  1201. .form-section-group {
  1202. margin-bottom: 32px;
  1203. :deep(.el-form-item) { margin-bottom: 18px !important; }
  1204. }
  1205. .dialog-footer { display: flex; justify-content: flex-end; gap: 12px; padding-top: 20px; }
  1206. </style>