PositionEditDialog.vue 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648
  1. <template>
  2. <el-drawer
  3. :visible="visible"
  4. direction="rtl"
  5. :size="drawerSize"
  6. custom-class="position-edit-drawer"
  7. append-to-body
  8. :before-close="closeDrawer"
  9. @update:visible="updateVisible"
  10. @closed="reset"
  11. >
  12. <template slot="title">
  13. <div class="drawer-title">
  14. <span><i class="el-icon-collection-tag"></i></span>
  15. <div>
  16. <h2>{{
  17. readonly ? "查看岗位档案" : isUpdate ? "编辑岗位档案" : "新增岗位档案"
  18. }}</h2>
  19. </div>
  20. </div>
  21. </template>
  22. <div class="drawer-body">
  23. <el-form
  24. ref="form"
  25. :model="form"
  26. :rules="rules"
  27. :disabled="readonly"
  28. label-position="top"
  29. >
  30. <!-- ===== 基本信息 置顶 ===== -->
  31. <section class="form-section basic-section">
  32. <div class="section-heading">
  33. <div>
  34. <i class="el-icon-collection-tag"></i
  35. ><span
  36. ><strong>基本信息</strong></span
  37. >
  38. </div>
  39. </div>
  40. <el-row :gutter="18">
  41. <el-col :xs="24" :sm="12" :md="8">
  42. <el-form-item label="岗位编码" prop="positionCode">
  43. <el-input
  44. v-model.trim="form.positionCode"
  45. maxlength="32"
  46. disabled
  47. placeholder="自动生成"
  48. >
  49. <i slot="prefix" class="el-icon-cpu"></i>
  50. </el-input>
  51. </el-form-item>
  52. </el-col>
  53. <el-col :xs="24" :sm="12" :md="8">
  54. <el-form-item label="岗位名称" prop="name" data-field-anchor="field-name">
  55. <el-input
  56. v-model.trim="form.name"
  57. maxlength="64"
  58. placeholder="如:数控车床操作工"
  59. >
  60. <i slot="prefix" class="el-icon-suitcase"></i>
  61. </el-input>
  62. </el-form-item>
  63. </el-col>
  64. <el-col :xs="24" :sm="12" :md="8">
  65. <el-form-item label="所属机构" prop="legalId" data-field-anchor="field-legalId">
  66. <el-select
  67. v-model="form.legalId"
  68. class="full-width"
  69. placeholder="请选择法人"
  70. @change="handleLegalChange"
  71. >
  72. <el-option
  73. v-for="item in legalEntities"
  74. :key="item.id"
  75. :label="item.name"
  76. :value="item.id"
  77. />
  78. </el-select>
  79. </el-form-item>
  80. </el-col>
  81. <!-- <el-col :xs="24" :sm="12" :md="8">-->
  82. <!-- <el-form-item label="所属部门" prop="deptId">-->
  83. <!-- <el-select v-model="form.deptId" filterable class="full-width" placeholder="请选择部门">-->
  84. <!-- <el-option v-for="item in departmentOptions" :key="item.id" :label="item.name" :value="item.id" />-->
  85. <!-- </el-select>-->
  86. <!-- </el-form-item>-->
  87. <!-- </el-col>-->
  88. <el-col :xs="24" :sm="12" :md="8">
  89. <el-form-item label="岗位序列" prop="positionSequence" data-field-anchor="field-positionSequence">
  90. <el-select
  91. v-model="form.positionSequence"
  92. class="full-width"
  93. placeholder="请选择岗位序列"
  94. :disabled="sequenceLocked"
  95. @change="handleSequenceChange"
  96. >
  97. <el-option
  98. v-for="item in sequenceList"
  99. :key="item.id"
  100. :label="item.label"
  101. :value="item.label"
  102. />
  103. </el-select>
  104. </el-form-item>
  105. </el-col>
  106. <!-- 岗位合同模板:暂未区分线上模版/线下附件,先注释 -->
  107. <!--
  108. <el-col :xs="24" :sm="12" :md="8">
  109. <el-form-item label="岗位合同模板" prop="contractTemplateId">
  110. <el-select
  111. v-model="form.contractTemplateId"
  112. filterable
  113. class="full-width"
  114. placeholder="请选择岗位合同模板"
  115. >
  116. <el-option
  117. v-for="item in contractTemplates"
  118. :key="item.id"
  119. :label="item.name"
  120. :value="item.id"
  121. />
  122. </el-select>
  123. </el-form-item>
  124. </el-col>
  125. -->
  126. <!-- <el-col :xs="24" :sm="12" :md="8">-->
  127. <!-- <el-form-item label="岗位层级" prop="level">-->
  128. <!-- <el-select v-if="configuredLevelOptions.length" v-model="form.level" class="full-width" placeholder="请选择岗位层级" @change="handleLevelChange">-->
  129. <!-- <el-option v-for="item in configuredLevelOptions" :key="item.value" :label="item.label" :value="item.value" />-->
  130. <!-- </el-select>-->
  131. <!-- <el-input-number v-else v-model="form.level" :min="1" :max="20" controls-position="right" class="full-width" @change="handleLevelChange" />-->
  132. <!-- </el-form-item>-->
  133. <!-- </el-col>-->
  134. <el-col :xs="24" :sm="12" :md="8">
  135. <el-form-item label="岗位性质" prop="type" data-field-anchor="field-type">
  136. <el-select
  137. v-model="form.type"
  138. class="full-width"
  139. clearable
  140. placeholder="请选择岗位性质"
  141. >
  142. <el-option
  143. v-for="item in typeOptions"
  144. :key="String(item.value)"
  145. :label="item.label"
  146. :value="item.value"
  147. />
  148. </el-select>
  149. </el-form-item>
  150. </el-col>
  151. <!-- <el-col :xs="24" :sm="12" :md="8">-->
  152. <!-- <el-form-item label="岗位编制" prop="staffing">-->
  153. <!-- <el-input-number v-model="form.staffing" :min="0" :max="999" controls-position="right" class="full-width" />-->
  154. <!-- </el-form-item>-->
  155. <!-- </el-col>-->
  156. <el-col :xs="24" :sm="12" :md="8">
  157. <el-form-item label="岗位状态">
  158. <el-select v-model="form.status" class="full-width">
  159. <el-option label="启用" :value="1" />
  160. <el-option label="停用" :value="2" />
  161. <el-option label="待撤销" :value="3" />
  162. </el-select>
  163. </el-form-item>
  164. </el-col>
  165. <el-col v-if="form.status === 3" :xs="24" :sm="12" :md="8">
  166. <el-form-item label="计划撤销日期">
  167. <el-date-picker
  168. v-model="form.revokeDate"
  169. type="date"
  170. value-format="yyyy-MM-dd"
  171. :picker-options="revokePickerOptions"
  172. class="full-width"
  173. />
  174. </el-form-item>
  175. </el-col>
  176. </el-row>
  177. </section>
  178. <!-- ===== 按岗位序列层级动态 Tab 配置 ===== -->
  179. <div class="level-tabs-wrap" :class="{ 'is-empty': !levelTabs.length }">
  180. <template v-if="levelTabs.length">
  181. <el-tabs v-model="activeLevel" class="level-tabs">
  182. <el-tab-pane
  183. v-for="lvl in levelTabs"
  184. :key="lvl"
  185. :label="lvl"
  186. :name="lvl"
  187. />
  188. </el-tabs>
  189. <div class="level-config-body">
  190. <div class="status-tabs module-tabs" role="tablist">
  191. <button
  192. v-for="m in moduleTabs"
  193. :key="m.key"
  194. type="button"
  195. :class="{ active: activeModule === m.key }"
  196. @click="activeModule = m.key"
  197. >
  198. {{ m.label }}
  199. </button>
  200. </div>
  201. <!-- 定薪定责 -->
  202. <div v-show="activeModule === 'salary'" class="module-panel">
  203. <div class="duty-layout">
  204. <section class="form-section salary-section">
  205. <div class="section-heading">
  206. <div>
  207. <i class="el-icon-coin"></i
  208. ><span
  209. ><strong>定薪标准</strong></span
  210. >
  211. </div>
  212. <span class="salary-summary">{{ salarySummary }}</span>
  213. </div>
  214. <el-row :gutter="16">
  215. <el-col :xs="24" :sm="8"
  216. ><el-form-item
  217. label="薪资下限"
  218. prop="salaryMin"
  219. data-field-anchor="field-salaryMin"
  220. ><el-input-number
  221. v-model="form.salaryMin"
  222. :min="0"
  223. :step="500"
  224. controls-position="right"
  225. class="full-width"
  226. @change="syncSalaryMid" /></el-form-item
  227. ></el-col>
  228. <el-col :xs="24" :sm="8"
  229. ><el-form-item label="薪资中位值"
  230. ><el-input-number
  231. v-model="form.salaryMid"
  232. :min="0"
  233. :step="500"
  234. controls-position="right"
  235. class="full-width" /></el-form-item
  236. ></el-col>
  237. <el-col :xs="24" :sm="8"
  238. ><el-form-item
  239. label="薪资上限"
  240. prop="salaryMax"
  241. data-field-anchor="field-salaryMax"
  242. ><el-input-number
  243. v-model="form.salaryMax"
  244. :min="0"
  245. :step="500"
  246. controls-position="right"
  247. class="full-width"
  248. @change="syncSalaryMid" /></el-form-item
  249. ></el-col>
  250. <el-col :xs="24" :sm="12"
  251. ><el-form-item label="薪资模板"
  252. ><el-select
  253. v-model="form.salaryTemplateId"
  254. filterable
  255. allow-create
  256. default-first-option
  257. class="full-width"
  258. placeholder="关联薪资模板名称或编码"
  259. ><el-option
  260. v-for="item in availableSalaryTemplates"
  261. :key="item.id"
  262. :label="item.name"
  263. :value="item.name" /></el-select></el-form-item
  264. ></el-col>
  265. <el-col :xs="24" :sm="12"
  266. ><el-form-item label="绩效方案"
  267. ><el-select
  268. v-model="form.perfSchemeId"
  269. filterable
  270. allow-create
  271. default-first-option
  272. class="full-width"
  273. placeholder="关联绩效方案名称或编码"
  274. ><el-option
  275. v-for="item in performanceSchemes"
  276. :key="item.id"
  277. :label="item.name"
  278. :value="item.name" /></el-select></el-form-item
  279. ></el-col>
  280. </el-row>
  281. </section>
  282. <section class="form-section qualification-section">
  283. <div class="section-heading">
  284. <div>
  285. <i class="el-icon-user"></i
  286. ><span
  287. ><strong>任职条件</strong></span
  288. >
  289. </div>
  290. </div>
  291. <el-row :gutter="16" class="qualification-basics">
  292. <el-col :xs="24" :sm="8"
  293. ><el-form-item label="学历要求"
  294. ><el-select v-model="form.eduReq" class="full-width"
  295. ><el-option
  296. label="初中及以上"
  297. value="初中及以上" /><el-option
  298. label="中专及以上"
  299. value="中专及以上" /><el-option
  300. label="大专及以上"
  301. value="大专及以上" /><el-option
  302. label="本科及以上"
  303. value="本科及以上" /></el-select></el-form-item
  304. ></el-col>
  305. <el-col :xs="24" :sm="8"
  306. ><el-form-item label="性别要求"
  307. ><el-select v-model="form.genderReq" class="full-width"
  308. ><el-option label="不限" value="不限" /><el-option
  309. label="男"
  310. value="男" /><el-option
  311. label="女"
  312. value="女" /></el-select></el-form-item
  313. ></el-col>
  314. <el-col :xs="24" :sm="8"
  315. ><el-form-item label="经验要求(年)"
  316. ><el-input-number
  317. v-model="form.expReq"
  318. :min="0"
  319. :max="30"
  320. controls-position="right"
  321. class="full-width" /></el-form-item
  322. ></el-col>
  323. </el-row>
  324. <el-row :gutter="16" class="qualification-timing">
  325. <el-col :xs="24" :sm="12"
  326. ><div class="age-panel">
  327. <el-form-item label="年龄范围" class="age-field"
  328. ><div class="age-value">
  329. <i class="el-icon-time"></i>{{ form.ageRange[0] }} -
  330. {{ form.ageRange[1] }} 岁
  331. </div>
  332. <el-slider
  333. v-model="form.ageRange"
  334. range
  335. :min="18"
  336. :max="65"
  337. />
  338. <div class="age-scale">
  339. <span>18 岁</span><span>65 岁</span>
  340. </div></el-form-item
  341. >
  342. </div></el-col
  343. >
  344. <el-col :xs="24" :sm="12"
  345. ><el-form-item label="创建日期" class="date-panel"
  346. ><el-date-picker
  347. v-model="form.effectDate"
  348. value-format="yyyy-MM-dd"
  349. type="date"
  350. class="full-width" /></el-form-item
  351. ></el-col>
  352. </el-row>
  353. <el-form-item label="工作环境" class="work-environment-field"
  354. ><el-input
  355. v-model.trim="form.workEnv"
  356. placeholder="如:车间、办公室、高温或粉尘环境等"
  357. ><i slot="prefix" class="el-icon-office-building"></i></el-input
  358. ></el-form-item>
  359. </section>
  360. <section class="form-section responsibility-section">
  361. <div class="section-heading">
  362. <div>
  363. <i class="el-icon-document-checked"></i
  364. ><span
  365. ><strong>岗位职责</strong></span
  366. >
  367. </div>
  368. <span class="duty-counter">已填写 {{ dutyCount }} 条</span>
  369. </div>
  370. <el-form-item
  371. label="岗位职责"
  372. required
  373. data-field-anchor="field-dutyText"
  374. ><el-input
  375. v-model="dutyText"
  376. type="textarea"
  377. :rows="6"
  378. placeholder="1. 负责……&#10;2. 落实……&#10;3. 协同……"
  379. /></el-form-item>
  380. </section>
  381. </div>
  382. </div>
  383. <!-- 技能资质 -->
  384. <div v-show="activeModule === 'skill'" class="module-panel">
  385. <section class="form-section skill-section">
  386. <div class="section-heading">
  387. <div>
  388. <i class="el-icon-s-check"></i
  389. ><span
  390. ><strong>技能资质</strong></span
  391. >
  392. </div>
  393. </div>
  394. <div class="form-subtitle-row" data-field-anchor="field-skills">
  395. <div class="form-subtitle">必备技能</div>
  396. <el-button
  397. v-if="!readonly"
  398. type="primary"
  399. size="mini"
  400. @click="addSkill"
  401. >新增技能</el-button>
  402. </div>
  403. <el-table
  404. :data="form.skills"
  405. border
  406. size="small"
  407. class="skill-cert-table"
  408. empty-text="暂无必备技能"
  409. >
  410. <el-table-column type="index" label="序号" width="55" align="center" />
  411. <el-table-column label="等级" width="160">
  412. <template slot-scope="{ row }">
  413. <el-select v-model="row.level" placeholder="等级" class="full-width">
  414. <el-option
  415. v-for="(item, optIdx) in skillLevelOptions"
  416. :key="'skill-level-' + optIdx + '-' + item.value"
  417. :label="item.label"
  418. :value="item.value"
  419. />
  420. </el-select>
  421. </template>
  422. </el-table-column>
  423. <el-table-column label="技能名称" min-width="320">
  424. <template slot-scope="{ row }">
  425. <el-input v-model.trim="row.name" placeholder="技能名称" />
  426. </template>
  427. </el-table-column>
  428. <el-table-column label="必备" width="110" align="center">
  429. <template slot-scope="{ row }">
  430. <el-switch v-model="row.required"/>
  431. </template>
  432. </el-table-column>
  433. <el-table-column v-if="!readonly" label="操作" width="90" align="center">
  434. <template slot-scope="{ $index }">
  435. <el-button type="text" class="skill-cert-delete" @click="removeSkill($index)">删除</el-button>
  436. </template>
  437. </el-table-column>
  438. </el-table>
  439. <div class="form-subtitle-row">
  440. <div class="form-subtitle">资质证书</div>
  441. <el-button
  442. v-if="!readonly"
  443. type="primary"
  444. size="mini"
  445. @click="addCert"
  446. >新增证书</el-button>
  447. </div>
  448. <el-table
  449. :data="form.certificates"
  450. border
  451. size="small"
  452. class="skill-cert-table"
  453. empty-text="暂无资质证书"
  454. >
  455. <el-table-column type="index" label="序号" width="55" align="center" />
  456. <el-table-column label="证书分类" width="160">
  457. <template slot-scope="{ row }">
  458. <el-select v-model="row.category" placeholder="证书分类" class="full-width">
  459. <el-option
  460. v-for="(item, optIdx) in idTypeOptions"
  461. :key="'id-type-' + optIdx + '-' + item.value"
  462. :label="item.label"
  463. :value="item.value"
  464. />
  465. </el-select>
  466. </template>
  467. </el-table-column>
  468. <el-table-column label="证书名称" min-width="320">
  469. <template slot-scope="{ row }">
  470. <el-input v-model.trim="row.name" placeholder="证书名称" />
  471. </template>
  472. </el-table-column>
  473. <el-table-column v-if="!readonly" label="操作" width="90" align="center">
  474. <template slot-scope="{ $index }">
  475. <el-button type="text" class="skill-cert-delete" @click="removeCert($index)">删除</el-button>
  476. </template>
  477. </el-table-column>
  478. </el-table>
  479. <div class="form-subtitle">推荐培训</div>
  480. <el-select
  481. v-model="form.trainings"
  482. multiple
  483. filterable
  484. allow-create
  485. default-first-option
  486. class="full-width"
  487. placeholder="选择或录入关联培训课程"
  488. >
  489. <el-option
  490. v-for="(item, optIdx) in trainingOptions"
  491. :key="'training-' + optIdx + '-' + item.value"
  492. :label="item.label"
  493. :value="item.value"
  494. />
  495. </el-select>
  496. </section>
  497. </div>
  498. <!-- 晋升路径 -->
  499. <div v-show="activeModule === 'promote'" class="module-panel">
  500. <section class="form-section promote-section">
  501. <div class="section-heading">
  502. <div>
  503. <i class="el-icon-s-opportunity"></i>
  504. <div>
  505. <strong>晋升路径</strong>
  506. </div>
  507. </div>
  508. <el-button
  509. v-if="!readonly"
  510. type="primary"
  511. plain
  512. size="small"
  513. icon="el-icon-plus"
  514. @click="addPromotePath"
  515. >
  516. 新增路径
  517. </el-button>
  518. </div>
  519. <div
  520. v-for="(path, index) in form.promote.paths"
  521. :key="index"
  522. class="promote-path-card"
  523. >
  524. <div class="promote-path-head">
  525. <span class="promote-path-index">路径 {{ index + 1 }}</span>
  526. <el-button
  527. v-if="!readonly"
  528. type="danger"
  529. plain
  530. size="mini"
  531. icon="el-icon-delete"
  532. :disabled="form.promote.paths.length <= 1"
  533. @click="removePromotePath(index)"
  534. >
  535. 删除
  536. </el-button>
  537. </div>
  538. <el-row :gutter="18">
  539. <el-col :xs="24" :md="8">
  540. <el-form-item
  541. label="向上流动层级"
  542. :data-field-anchor="'field-promote-up-' + index"
  543. >
  544. <el-select
  545. v-model="path.up"
  546. filterable
  547. clearable
  548. class="full-width"
  549. placeholder="请选择向上流动岗位序列层级"
  550. :loading="promoteTargetsLoading"
  551. >
  552. <el-option
  553. v-for="item in upwardOptions"
  554. :key="'up-' + item.id"
  555. :label="promoteOptionLabel(item)"
  556. :value="item.name"
  557. />
  558. </el-select>
  559. </el-form-item>
  560. </el-col>
  561. <el-col :xs="24" :md="8">
  562. <el-form-item
  563. label="向下流动层级"
  564. :data-field-anchor="'field-promote-down-' + index"
  565. >
  566. <el-select
  567. v-model="path.down"
  568. filterable
  569. clearable
  570. class="full-width"
  571. placeholder="请选择向下流动岗位序列层级"
  572. :loading="promoteTargetsLoading"
  573. >
  574. <el-option
  575. v-for="item in downwardOptions"
  576. :key="'down-' + item.id"
  577. :label="promoteOptionLabel(item)"
  578. :value="item.name"
  579. />
  580. </el-select>
  581. </el-form-item>
  582. </el-col>
  583. <el-col :xs="24" :md="8">
  584. <el-form-item
  585. label="平级流动层级"
  586. :data-field-anchor="'field-promote-lateral-' + index"
  587. >
  588. <el-select
  589. v-model="path.lateral"
  590. filterable
  591. clearable
  592. class="full-width"
  593. placeholder="请选择平级流动岗位序列层级"
  594. :loading="promoteTargetsLoading"
  595. >
  596. <el-option
  597. v-for="item in lateralOptions"
  598. :key="'lateral-' + item.id"
  599. :label="promoteOptionLabel(item)"
  600. :value="item.name"
  601. />
  602. </el-select>
  603. </el-form-item>
  604. </el-col>
  605. </el-row>
  606. </div>
  607. <el-form-item label="晋升条件" class="promote-conditions">
  608. <el-select
  609. v-model="form.promote.conditions"
  610. multiple
  611. filterable
  612. allow-create
  613. default-first-option
  614. class="full-width"
  615. placeholder="公共条件,输入条件后回车,如:绩效≥B"
  616. >
  617. <el-option
  618. v-for="(item, optIdx) in conditionOptions"
  619. :key="'condition-' + optIdx + '-' + item.value"
  620. :label="item.label"
  621. :value="item.value"
  622. />
  623. </el-select>
  624. </el-form-item>
  625. </section>
  626. </div>
  627. </div>
  628. </template>
  629. <div v-else class="level-empty-tip">
  630. <i class="el-icon-info"></i>
  631. {{ levelMessage }}
  632. </div>
  633. </div>
  634. </el-form>
  635. </div>
  636. <div class="drawer-footer">
  637. <div v-if="readonly">
  638. <el-button @click="closeDrawer()">关闭</el-button>
  639. <el-button
  640. type="primary"
  641. icon="el-icon-edit"
  642. @click="$emit('edit')"
  643. >编辑档案</el-button
  644. >
  645. </div>
  646. <div v-else>
  647. <el-button @click="closeDrawer()">取消</el-button>
  648. <el-button type="primary" :loading="saving" @click="save">
  649. {{ saving ? "正在保存" : "保存岗位" }}
  650. </el-button>
  651. </div>
  652. </div>
  653. </el-drawer>
  654. </template>
  655. <script>
  656. import { performanceSchemes, salaryTemplates } from "../mock";
  657. import { getDict, getDictByCode } from "@/api/sys";
  658. import {
  659. getContractTemplatePage,
  660. getPositionSequencePage,
  661. getPromotionTargets,
  662. adaptPromotionLevelOptions,
  663. } from "@/api/hr";
  664. import { formatDate } from "@/utils/dateUtils";
  665. /** 本地当天日期 yyyy-MM-dd(避免 toISOString 用 UTC 错日) */
  666. function todayLocalDate() {
  667. return formatDate(new Date(), "yyyy-MM-dd");
  668. }
  669. // 每个层级 Tab 独立维护的配置字段(定薪/任职/职责/技能/晋升)
  670. const LEVEL_FIELDS = [
  671. "salaryMin",
  672. "salaryMid",
  673. "salaryMax",
  674. "salaryTemplateId",
  675. "perfSchemeId",
  676. "eduReq",
  677. "genderReq",
  678. "expReq",
  679. "ageRange",
  680. "effectDate",
  681. "workEnv",
  682. "skills",
  683. "certificates",
  684. "trainings",
  685. "promote",
  686. ];
  687. const emptyForm = () => ({
  688. id: null,
  689. positionCode: "",
  690. name: "",
  691. legalId: 1,
  692. deptId: null,
  693. level: 1,
  694. positionName: "",
  695. positionSequence: "",
  696. positionLevel: "",
  697. sequenceId: null,
  698. type: "",
  699. staffing: 0,
  700. actual: 0,
  701. status: 1,
  702. revokeDate: "",
  703. contractTemplateId: null,
  704. version: 1,
  705. salaryMin: 0,
  706. salaryMid: 0,
  707. salaryMax: 0,
  708. salaryTemplateId: "",
  709. perfSchemeId: "",
  710. workEnv: "",
  711. ageRange: [18, 60],
  712. genderReq: "不限",
  713. eduReq: "中专及以上",
  714. expReq: 0,
  715. effectDate: todayLocalDate(),
  716. duties: [],
  717. skills: [],
  718. certificates: [],
  719. trainings: [],
  720. promote: { paths: [{ up: "", down: "", lateral: "" }], conditions: [] },
  721. employees: [],
  722. history: [],
  723. updatedAt: "",
  724. levels: [],
  725. });
  726. export default {
  727. name: "PositionEditDialog",
  728. props: {
  729. visible: Boolean,
  730. data: Object,
  731. // 只读模式:详情查看用,底部显示「编辑档案」按钮
  732. readonly: {
  733. type: Boolean,
  734. default: false,
  735. },
  736. nextIndex: {
  737. type: Number,
  738. default: 1,
  739. },
  740. positionList: {
  741. type: Array,
  742. default: () => [],
  743. },
  744. legalEntities: {
  745. type: Array,
  746. default: () => [],
  747. },
  748. departments: {
  749. type: Array,
  750. default: () => [],
  751. },
  752. sequenceOptions: {
  753. type: Array,
  754. default: () => [],
  755. },
  756. },
  757. data() {
  758. return {
  759. activeLevel: "",
  760. // 当前层级下的子模块:salary 定薪定责 / skill 技能资质 / promote 晋升路径
  761. activeModule: "salary",
  762. // 岗位序列下拉:每次打开弹窗从接口刷新最新数据
  763. sequenceList: [],
  764. form: emptyForm(),
  765. dutyText: "",
  766. saving: false,
  767. salaryTemplates,
  768. performanceSchemes,
  769. // 岗位合同模板:接口加载,无静态兜底
  770. contractTemplates: [],
  771. // 晋升条件:/system/dict/getByCode/Promotion_Conditions
  772. conditionOptions: [],
  773. // 必备技能等级:/system/dict/getByCode/Required_Skills
  774. skillLevelOptions: [],
  775. // 推荐培训:/system/dict/getByCode/Recommended_Training
  776. trainingOptions: [],
  777. // 岗位性质:/system/dict/getByCode/position_nature,无静态兜底
  778. typeOptions: [],
  779. // 资质证书类别:/system/dict/id_type
  780. idTypeOptions: [],
  781. // 晋升路径目标:分别来自 upward / downward / lateral 流动岗位序列层级接口
  782. upwardOptions: [],
  783. downwardOptions: [],
  784. lateralOptions: [],
  785. promoteTargetsLoading: false,
  786. revokePickerOptions: {
  787. disabledDate: (date) =>
  788. date.getTime() < new Date().setHours(0, 0, 0, 0),
  789. },
  790. rules: {
  791. name: [{ required: true, message: "请输入岗位名称", trigger: "blur" }],
  792. legalId: [{ required: true, message: "请选择法人", trigger: "change" }],
  793. deptId: [{ required: true, message: "请选择部门", trigger: "change" }],
  794. positionSequence: [
  795. { required: true, message: "请选择岗位序列", trigger: "change" },
  796. ],
  797. type: [
  798. { required: true, message: "请选择岗位性质", trigger: "change" },
  799. ],
  800. level: [
  801. { required: true, message: "请输入岗位层级", trigger: "change" },
  802. ],
  803. staffing: [
  804. { required: true, message: "请输入编制人数", trigger: "change" },
  805. ],
  806. },
  807. };
  808. },
  809. computed: {
  810. isUpdate() {
  811. return Boolean(this.data?.id);
  812. },
  813. // 新增时岗位序列与左侧所选序列绑定,不可在表单中改选
  814. sequenceLocked() {
  815. return !this.isUpdate && Boolean(this.data?.positionSequence);
  816. },
  817. // 启用层级 Tab 列表(来自当前岗位序列的启用层级)
  818. levelTabs() {
  819. return this.form.levels.map((item) => item.level);
  820. },
  821. // 每个层级下的子模块 Tab(定薪/技能/晋升)
  822. moduleTabs() {
  823. return [
  824. { key: "salary", label: "定薪定责" },
  825. { key: "skill", label: "技能资质" },
  826. { key: "promote", label: "晋升路径" },
  827. ];
  828. },
  829. // 表单 prop → 定位信息(提交失败时切 Tab 并滚动)
  830. formFieldAnchors() {
  831. return {
  832. name: { module: "basic", anchor: "field-name" },
  833. legalId: { module: "basic", anchor: "field-legalId" },
  834. deptId: { module: "basic", anchor: "field-deptId" },
  835. positionSequence: { module: "basic", anchor: "field-positionSequence" },
  836. type: { module: "basic", anchor: "field-type" },
  837. level: { module: "basic", anchor: "field-level" },
  838. staffing: { module: "basic", anchor: "field-staffing" },
  839. salaryMin: { module: "salary", anchor: "field-salaryMin" },
  840. salaryMax: { module: "salary", anchor: "field-salaryMax" },
  841. dutyText: { module: "salary", anchor: "field-dutyText" },
  842. };
  843. },
  844. // 层级 Tab 区占位提示:区分「未选序列」与「所选序列无启用层级」
  845. levelMessage() {
  846. if (!this.form.positionSequence) {
  847. return "请先选择岗位序列,以加载该序列的启用层级配置";
  848. }
  849. if (!this.levelTabs.length) {
  850. return "当前岗位序列暂无启用的岗位层级,请先在「岗位序列 · 维护层级」中配置";
  851. }
  852. return "";
  853. },
  854. departmentOptions() {
  855. return this.departments.filter(
  856. (item) => String(item.legalId) === String(this.form.legalId),
  857. );
  858. },
  859. availableSalaryTemplates() {
  860. return this.salaryTemplates.filter((item) =>
  861. item.legalIds.includes(this.form.legalId),
  862. );
  863. },
  864. configuredLevelOptions() {
  865. const sequence = this.sequenceList.find(
  866. (item) =>
  867. item.label === this.form.positionSequence ||
  868. item.name === this.form.positionSequence,
  869. );
  870. return (sequence?.children || [])
  871. .map((item) => ({
  872. value:
  873. item.value ??
  874. Number(String(item.id || item.label).match(/\d+/)?.[0] || 0),
  875. label: item.label,
  876. ...item,
  877. }))
  878. .filter((item) => item.label);
  879. },
  880. salarySummary() {
  881. const min = Number(this.form.salaryMin || 0).toLocaleString("zh-CN");
  882. const max = Number(this.form.salaryMax || 0).toLocaleString("zh-CN");
  883. return `¥${min} - ¥${max} / 月`;
  884. },
  885. dutyCount() {
  886. return this.dutyText
  887. .split("\n")
  888. .map((item) => item.trim())
  889. .filter(Boolean).length;
  890. },
  891. drawerSize() {
  892. return window.innerWidth < 1000 ? "96%" : "900px";
  893. },
  894. },
  895. created() {
  896. this.loadDictOptions();
  897. this.loadTypeOptions();
  898. this.loadIdTypeOptions();
  899. this.loadContractTemplates();
  900. this.loadSequences();
  901. },
  902. watch: {
  903. visible(value) {
  904. if (value) {
  905. this.initialize();
  906. }
  907. },
  908. // 切换层级 Tab:保存当前层级配置,加载目标层级配置,并刷新晋升路径可选岗位
  909. activeLevel(newVal, oldVal) {
  910. if (!newVal) return;
  911. if (oldVal) this.saveLevelConfig(oldVal);
  912. this.loadLevelConfig(newVal);
  913. this.form.positionLevel = newVal;
  914. this.loadPromoteTargetOptions();
  915. },
  916. },
  917. methods: {
  918. // 岗位合同模板动态加载(启用中),失败不使用静态兜底
  919. async loadContractTemplates() {
  920. try {
  921. const data = await getContractTemplatePage({
  922. pageNum: 1,
  923. size: 1000,
  924. templateStatus: "ENABLED",
  925. });
  926. this.contractTemplates = (data?.list || [])
  927. .map((item) => ({ id: item.id, name: item.templateName || "" }))
  928. .filter((item) => item.name);
  929. } catch (error) {
  930. this.contractTemplates = [];
  931. this.$message.error(error.message || "岗位合同模板加载失败");
  932. }
  933. },
  934. // 岗位序列下拉:每次打开从接口拉取最新启用序列及其层级
  935. async loadSequences() {
  936. this.sequenceList = this.sequenceOptions.slice();
  937. try {
  938. const page = await getPositionSequencePage({
  939. pageNum: 1,
  940. size: 1000,
  941. status: "1",
  942. });
  943. const list = (page?.list || page?.records || [])
  944. .map((item) => {
  945. const name =
  946. item.sequence || item.positionSequence || item.name || "";
  947. return {
  948. id: item.id,
  949. label: name,
  950. name,
  951. children: (item.levelList || []).map((level, index) => ({
  952. id: level.id ?? `lvl-${index}`,
  953. label: level.level || level.label || "",
  954. value: level.level || level.positionLevel || level.label || "",
  955. levelGrade: Number(level.levelGrade) || 0,
  956. })),
  957. };
  958. })
  959. .filter((item) => item.label);
  960. if (list.length) this.sequenceList = list;
  961. } catch (error) {
  962. // 接口失败时保留 prop 传入的序列
  963. }
  964. },
  965. // 晋升条件 / 必备技能等级 / 推荐培训:按字典编码拉取
  966. async loadDictOptions() {
  967. const load = async (code, field) => {
  968. try {
  969. const items = await getDictByCode(code);
  970. this[field] = items.filter((item) => item.value);
  971. } catch (error) {
  972. this[field] = [];
  973. this.$message.error(error.message || `${code} 字典加载失败`);
  974. }
  975. };
  976. await Promise.all([
  977. load("Promotion_Conditions", "conditionOptions"),
  978. load("Required_Skills", "skillLevelOptions"),
  979. load("Recommended_Training", "trainingOptions"),
  980. ]);
  981. },
  982. // 资质证书类别:/system/dict/id_type
  983. async loadIdTypeOptions() {
  984. try {
  985. const items = await getDict("id_type");
  986. this.idTypeOptions = items.filter((item) => item.value);
  987. } catch (error) {
  988. this.idTypeOptions = [];
  989. this.$message.error(error.message || "证书类别字典加载失败");
  990. }
  991. },
  992. // 岗位性质只走字典接口 /system/dict/getByCode/position_nature,无静态兜底
  993. // 选项展示与回填使用字典 value(文案),不用 key(如 general_positions)
  994. async loadTypeOptions() {
  995. try {
  996. const items = await getDictByCode("position_nature");
  997. this.typeOptions = items
  998. .map((item) => {
  999. const text = String(item.value ?? item.label ?? "");
  1000. return {
  1001. label: text,
  1002. value: text,
  1003. key: item.key != null ? String(item.key) : text,
  1004. };
  1005. })
  1006. .filter((item) => item.value);
  1007. this.normalizeFormType();
  1008. } catch (error) {
  1009. this.typeOptions = [];
  1010. this.$message.error(error.message || "岗位性质字典加载失败");
  1011. }
  1012. },
  1013. // 把历史编码(key)转成字典 value,避免下拉显示 general_positions
  1014. resolveTypeValue(raw) {
  1015. if (raw === null || raw === undefined || raw === "") return "";
  1016. const text = String(raw);
  1017. const hit = this.typeOptions.find(
  1018. (item) =>
  1019. String(item.value) === text ||
  1020. String(item.label) === text ||
  1021. String(item.key) === text,
  1022. );
  1023. return hit ? hit.value : text;
  1024. },
  1025. normalizeFormType() {
  1026. if (!this.form) return;
  1027. this.form.type = this.resolveTypeValue(this.form.type);
  1028. },
  1029. // 岗位序列的启用层级:直接从 sequenceList 的 children 取,不调接口(按序列名称)
  1030. fetchEnabledLevels(seqName) {
  1031. if (!seqName) return [];
  1032. const option = this.sequenceList.find(
  1033. (item) => item.label === seqName || item.name === seqName,
  1034. );
  1035. const levels = (option?.children || [])
  1036. .filter((item) => {
  1037. // 若子项含启用状态字段则只取启用,否则默认全部启用
  1038. if (item.enable !== undefined && item.enable !== null) {
  1039. return Boolean(item.enable);
  1040. }
  1041. if (item.status !== undefined && item.status !== null) {
  1042. return Number(item.status) === 1;
  1043. }
  1044. return true;
  1045. })
  1046. .map((item) => item.label || item.level || "")
  1047. .filter(Boolean);
  1048. // 测试:打印当前所选岗位序列及其层级
  1049. console.log("[岗位档案] 当前岗位序列:", seqName, "| 层级:", JSON.stringify(levels));
  1050. return levels;
  1051. },
  1052. // 新建一个层级配置(默认值 + 可选沿用来源配置)
  1053. emptyLevelConfig(level, source = null) {
  1054. const cfg = {
  1055. level,
  1056. salaryMin: 0,
  1057. salaryMid: 0,
  1058. salaryMax: 0,
  1059. salaryTemplateId: this.availableSalaryTemplates[0]?.name || "",
  1060. perfSchemeId: this.performanceSchemes[0]?.name || "",
  1061. eduReq: "中专及以上",
  1062. genderReq: "不限",
  1063. expReq: 0,
  1064. ageRange: [18, 60],
  1065. effectDate: todayLocalDate(),
  1066. workEnv: "",
  1067. skills: [],
  1068. certificates: [],
  1069. trainings: [],
  1070. promote: { paths: [{ up: "", down: "", lateral: "" }], conditions: [] },
  1071. dutyText: "",
  1072. };
  1073. if (source) {
  1074. LEVEL_FIELDS.forEach((field) => {
  1075. cfg[field] = JSON.parse(JSON.stringify(source[field] ?? cfg[field]));
  1076. });
  1077. cfg.dutyText =
  1078. source.dutyText ||
  1079. (Array.isArray(source.duties) ? source.duties.join("\n") : "") ||
  1080. "";
  1081. cfg.duties = Array.isArray(source.duties)
  1082. ? JSON.parse(JSON.stringify(source.duties))
  1083. : String(cfg.dutyText || "")
  1084. .split("\n")
  1085. .map((item) => item.trim())
  1086. .filter(Boolean);
  1087. }
  1088. return cfg;
  1089. },
  1090. // 保存当前表单配置到指定层级
  1091. saveLevelConfig(levelName) {
  1092. const target = this.form.levels.find((item) => item.level === levelName);
  1093. if (!target) return;
  1094. LEVEL_FIELDS.forEach((field) => {
  1095. target[field] = JSON.parse(JSON.stringify(this.form[field]));
  1096. });
  1097. const duties = this.dutyText
  1098. .split("\n")
  1099. .map((item) => item.trim())
  1100. .filter(Boolean);
  1101. target.dutyText = this.dutyText;
  1102. target.duties = duties;
  1103. },
  1104. // 加载指定层级配置到表单
  1105. loadLevelConfig(levelName) {
  1106. const source = this.form.levels.find((item) => item.level === levelName);
  1107. if (!source) return;
  1108. LEVEL_FIELDS.forEach((field) => {
  1109. this.form[field] = JSON.parse(JSON.stringify(source[field]));
  1110. });
  1111. this.dutyText =
  1112. source.dutyText ||
  1113. (Array.isArray(source.duties) ? source.duties.join("\n") : "");
  1114. this.activeModule = "salary";
  1115. this.$nextTick(() => this.$refs.form?.clearValidate());
  1116. },
  1117. async initialize() {
  1118. // 每次打开弹窗都拉取最新岗位序列;清空请求键以便同岗位可重新加载晋升目标
  1119. this._promoteTargetsRequestKey = "";
  1120. await this.loadSequences();
  1121. const source = this.data
  1122. ? JSON.parse(JSON.stringify(this.data))
  1123. : emptyForm();
  1124. this.form = {
  1125. ...emptyForm(),
  1126. ...source,
  1127. promote: this.normalizePromote(source.promote),
  1128. levels: [],
  1129. };
  1130. this.dutyText = (this.form.duties || []).join("\n");
  1131. if (this.isUpdate) {
  1132. // 编辑:优先用接口返回的每层级配置,再按序列启用层级补齐缺失项
  1133. const seqLevels = this.fetchEnabledLevels(this.form.positionSequence);
  1134. const existingLevels = Array.isArray(source.levels) ? source.levels : [];
  1135. const existingMap = new Map(
  1136. existingLevels.map((item) => [item.level, item]),
  1137. );
  1138. this.form.levels = seqLevels.map((level) => {
  1139. const cfg = existingMap.get(level);
  1140. return cfg
  1141. ? { ...this.emptyLevelConfig(level), ...cfg }
  1142. : this.emptyLevelConfig(level, this.form);
  1143. });
  1144. await this.applyActiveLevel(this.form.levels[0]?.level || "");
  1145. if (!this.form.effectDate) {
  1146. this.form.effectDate = todayLocalDate();
  1147. }
  1148. } else {
  1149. this.form.legalId = this.legalEntities[0]?.id || null;
  1150. this.ensureSequenceOption(source);
  1151. // 左侧已选序列则带入;未选则留空,由弹框内自行选择
  1152. this.form.positionSequence = this.resolveCreateSequence(source);
  1153. this.form.level = this.configuredLevelOptions[0]?.value || 1;
  1154. this.syncSequenceFields();
  1155. this.refreshCode();
  1156. this.form.deptId = this.departmentOptions[0]?.id || null;
  1157. this.form.salaryTemplateId =
  1158. this.availableSalaryTemplates[0]?.name || "";
  1159. // 创建日期默认当前操作时间
  1160. this.form.effectDate = todayLocalDate();
  1161. // 加载序列启用层级;若左侧已选层级则落到对应 Tab
  1162. const levels = await this.fetchEnabledLevels(this.form.positionSequence);
  1163. this.form.levels = levels.map((level) => this.emptyLevelConfig(level));
  1164. const presetLevel =
  1165. source.positionLevel && levels.includes(source.positionLevel)
  1166. ? source.positionLevel
  1167. : levels[0] || "";
  1168. await this.applyActiveLevel(presetLevel);
  1169. }
  1170. this.normalizeFormType();
  1171. },
  1172. // 统一切换当前层级:避免与 activeLevel watcher 各请求一遍晋升目标
  1173. async applyActiveLevel(nextLevel) {
  1174. const level = nextLevel || "";
  1175. if (this.activeLevel === level) {
  1176. if (!level) {
  1177. this.upwardOptions = [];
  1178. this.downwardOptions = [];
  1179. this.lateralOptions = [];
  1180. return;
  1181. }
  1182. this.loadLevelConfig(level);
  1183. this.form.positionLevel = level;
  1184. await this.loadPromoteTargetOptions();
  1185. return;
  1186. }
  1187. this.activeLevel = level;
  1188. },
  1189. normalizePromote(source) {
  1190. const promote = { ...emptyForm().promote, ...(source || {}) };
  1191. const hasPath = (list) =>
  1192. Array.isArray(list) &&
  1193. list.some((path) => path?.up || path?.down || path?.lateral);
  1194. if (!Array.isArray(promote.paths) || !hasPath(promote.paths)) {
  1195. promote.paths =
  1196. promote.up?.length || promote.down?.length || promote.lateral?.length
  1197. ? [
  1198. {
  1199. up: promote.up?.[0] || "",
  1200. down: promote.down?.[0] || "",
  1201. lateral: promote.lateral?.[0] || "",
  1202. },
  1203. ]
  1204. : [{ up: "", down: "", lateral: "" }];
  1205. }
  1206. return promote;
  1207. },
  1208. addPromotePath() {
  1209. this.form.promote.paths.push({ up: "", down: "", lateral: "" });
  1210. },
  1211. removePromotePath(index) {
  1212. if (this.form.promote.paths.length <= 1) return;
  1213. this.form.promote.paths.splice(index, 1);
  1214. },
  1215. promoteOptionLabel(item) {
  1216. if (item.positionSequence && item.positionLevel) {
  1217. return `${item.positionSequence} · ${item.positionLevel}`;
  1218. }
  1219. return item.name || item.positionName || "";
  1220. },
  1221. // 当前层级对应的序列主键 / 层级文本 / 层级级别,供流动层级查询使用
  1222. resolvePromoteQuery() {
  1223. const sequence = this.sequenceList.find(
  1224. (item) =>
  1225. item.label === this.form.positionSequence ||
  1226. item.name === this.form.positionSequence ||
  1227. String(item.id) === String(this.form.sequenceId),
  1228. );
  1229. const levelName =
  1230. this.activeLevel || this.form.positionLevel || "";
  1231. const levelMeta = (sequence?.children || []).find(
  1232. (item) =>
  1233. item.label === levelName ||
  1234. String(item.value) === String(levelName),
  1235. );
  1236. return {
  1237. positionSequenceId: sequence?.id ?? this.form.sequenceId ?? null,
  1238. positionSequenceLevel: levelName,
  1239. levelGrade: Number(levelMeta?.levelGrade) || 0,
  1240. };
  1241. },
  1242. // 查询向上 / 向下 / 平级流动岗位序列层级(同参去重,避免 watcher + 手动调用打两遍)
  1243. async loadPromoteTargetOptions() {
  1244. const { positionSequenceId, positionSequenceLevel, levelGrade } =
  1245. this.resolvePromoteQuery();
  1246. if (!positionSequenceId || !positionSequenceLevel || !levelGrade) {
  1247. this._promoteTargetsRequestKey = "";
  1248. this.upwardOptions = [];
  1249. this.downwardOptions = [];
  1250. this.lateralOptions = [];
  1251. return;
  1252. }
  1253. const params = {
  1254. levelGrade,
  1255. positionSequenceId,
  1256. positionSequenceLevel,
  1257. };
  1258. const requestKey = [
  1259. params.levelGrade,
  1260. params.positionSequenceId,
  1261. params.positionSequenceLevel,
  1262. ].join("|");
  1263. if (this._promoteTargetsRequestKey === requestKey) return;
  1264. this._promoteTargetsRequestKey = requestKey;
  1265. this.promoteTargetsLoading = true;
  1266. try {
  1267. const [upward, downward, lateral] = await Promise.all([
  1268. getPromotionTargets("upward", params),
  1269. getPromotionTargets("downward", params),
  1270. getPromotionTargets("lateral", params),
  1271. ]);
  1272. if (this._promoteTargetsRequestKey !== requestKey) return;
  1273. this.upwardOptions = adaptPromotionLevelOptions(upward);
  1274. this.downwardOptions = adaptPromotionLevelOptions(downward);
  1275. this.lateralOptions = adaptPromotionLevelOptions(lateral);
  1276. this.ensureSelectedPromoteOptions();
  1277. } catch (error) {
  1278. if (this._promoteTargetsRequestKey !== requestKey) return;
  1279. this.upwardOptions = [];
  1280. this.downwardOptions = [];
  1281. this.lateralOptions = [];
  1282. this.$message.error(error.message || "流动岗位序列层级加载失败");
  1283. } finally {
  1284. if (this._promoteTargetsRequestKey === requestKey) {
  1285. this.promoteTargetsLoading = false;
  1286. }
  1287. }
  1288. },
  1289. // 已保存但不在本次查询结果中的层级,补进选项以免下拉空白
  1290. ensureSelectedPromoteOptions() {
  1291. const paths = this.form.promote?.paths || [];
  1292. const append = (list, name) => {
  1293. if (!name || list.some((item) => item.name === name)) return list;
  1294. const [sequence, level] = String(name).split(/\s*·\s*/);
  1295. return [
  1296. ...list,
  1297. {
  1298. id: `kept-${name}`,
  1299. name,
  1300. positionName: name,
  1301. positionSequence: level ? sequence : "",
  1302. positionLevel: level || name,
  1303. },
  1304. ];
  1305. };
  1306. paths.forEach((path) => {
  1307. this.upwardOptions = append(this.upwardOptions, path.up);
  1308. this.downwardOptions = append(this.downwardOptions, path.down);
  1309. this.lateralOptions = append(this.lateralOptions, path.lateral);
  1310. });
  1311. },
  1312. handleLegalChange() {
  1313. this.form.deptId = this.departmentOptions[0]?.id || null;
  1314. if (
  1315. !this.availableSalaryTemplates.some(
  1316. (item) => item.id === this.form.salaryTemplateId,
  1317. )
  1318. ) {
  1319. this.form.salaryTemplateId =
  1320. this.availableSalaryTemplates[0]?.name || "";
  1321. }
  1322. this.refreshCode();
  1323. },
  1324. // 选择岗位序列 → 加载启用层级 Tab,并刷新晋升路径可选岗位
  1325. async handleSequenceChange() {
  1326. this.syncSequenceFields();
  1327. this.refreshCode();
  1328. const levels = await this.fetchEnabledLevels(this.form.positionSequence);
  1329. this.form.levels = levels.map((level) => this.emptyLevelConfig(level));
  1330. // 序列变化后允许按新序列重新请求(即使首层文案相同)
  1331. this._promoteTargetsRequestKey = "";
  1332. await this.applyActiveLevel(levels[0] || "");
  1333. },
  1334. handleLevelChange() {
  1335. this.syncSequenceFields();
  1336. this.refreshCode();
  1337. this._promoteTargetsRequestKey = "";
  1338. this.loadPromoteTargetOptions();
  1339. },
  1340. // 新增时优先用左侧所选序列(名称 / 主键 / 序列编码)匹配下拉项
  1341. resolveCreateSequence(source = {}) {
  1342. const name = String(source.positionSequence || "").trim();
  1343. const seqId = source.sequenceId;
  1344. const seqCode = source.seq;
  1345. const found = this.sequenceList.find(
  1346. (item) =>
  1347. (name && (item.label === name || item.name === name)) ||
  1348. (seqId != null && String(item.id) === String(seqId)) ||
  1349. (seqCode &&
  1350. (String(item.id) === String(seqCode) || item.label === seqCode)),
  1351. );
  1352. return found?.label || name || "";
  1353. },
  1354. // 左侧选中的序列若不在启用下拉里,补进选项以免无法回显
  1355. ensureSequenceOption(source = {}) {
  1356. const name = String(source.positionSequence || "").trim();
  1357. if (!name) return;
  1358. const exists = this.sequenceList.some(
  1359. (item) => item.label === name || item.name === name,
  1360. );
  1361. if (exists) return;
  1362. const fromTree = (this.sequenceOptions || []).find(
  1363. (item) => item.label === name || item.sequence === name,
  1364. );
  1365. this.sequenceList = [
  1366. {
  1367. id: source.sequenceId || fromTree?.sequenceId || fromTree?.id || name,
  1368. label: name,
  1369. name,
  1370. children: fromTree?.children || [],
  1371. },
  1372. ...this.sequenceList,
  1373. ];
  1374. },
  1375. syncSequenceFields() {
  1376. const sequence = this.sequenceList.find(
  1377. (item) =>
  1378. item.label === this.form.positionSequence ||
  1379. item.name === this.form.positionSequence,
  1380. );
  1381. if (sequence?.label) this.form.positionSequence = sequence.label;
  1382. if (sequence?.id != null) this.form.sequenceId = sequence.id;
  1383. if (this.activeLevel) this.form.positionLevel = this.activeLevel;
  1384. },
  1385. refreshCode() {
  1386. // 岗位编码由后端生成,前端不再自动生成;新增时保持为空
  1387. },
  1388. syncSalaryMid() {
  1389. if (this.form.salaryMax >= this.form.salaryMin) {
  1390. this.form.salaryMid = Math.round(
  1391. (Number(this.form.salaryMax) + Number(this.form.salaryMin)) / 2,
  1392. );
  1393. }
  1394. },
  1395. addSkill() {
  1396. this.form.skills.push({
  1397. name: "",
  1398. level: this.skillLevelOptions[0]?.value || "",
  1399. required: true,
  1400. matchRate: 0,
  1401. });
  1402. },
  1403. removeSkill(index) {
  1404. this.form.skills.splice(index, 1);
  1405. },
  1406. addCert() {
  1407. this.form.certificates.push({
  1408. name: "",
  1409. category: this.idTypeOptions[0]?.value || "",
  1410. });
  1411. },
  1412. removeCert(index) {
  1413. this.form.certificates.splice(index, 1);
  1414. },
  1415. save() {
  1416. // 保存当前层级配置,并同步岗位序列名称/主键
  1417. if (this.activeLevel) this.saveLevelConfig(this.activeLevel);
  1418. this.syncSequenceFields();
  1419. if (!String(this.form.positionSequence || "").trim()) {
  1420. this.$message.warning("请选择岗位序列");
  1421. this.$refs.form?.validateField("positionSequence");
  1422. this.focusFieldError({
  1423. message: "请选择岗位序列",
  1424. module: "basic",
  1425. anchor: "field-positionSequence",
  1426. });
  1427. return;
  1428. }
  1429. this.$refs.form.validate(async (valid, invalidFields) => {
  1430. if (this.saving) return;
  1431. if (!valid) {
  1432. const firstProp = Object.keys(invalidFields || {})[0];
  1433. const detail = invalidFields?.[firstProp]?.[0];
  1434. const meta = this.formFieldAnchors[firstProp] || {
  1435. module: "basic",
  1436. anchor: `field-${firstProp}`,
  1437. };
  1438. this.$message.warning(detail?.message || "请完善必填项");
  1439. await this.focusFieldError({
  1440. message: detail?.message || "请完善必填项",
  1441. level: meta.module === "basic" ? "" : this.activeLevel,
  1442. ...meta,
  1443. });
  1444. return;
  1445. }
  1446. const errors = this.collectRuleErrors();
  1447. if (errors.length) {
  1448. this.$message.warning(errors[0].message);
  1449. await this.focusFieldError(errors[0]);
  1450. return;
  1451. }
  1452. this.saving = true;
  1453. const duties = this.dutyText
  1454. .split("\n")
  1455. .map((item) => item.trim())
  1456. .filter(Boolean);
  1457. // 顶层与当前层级职责保持一致,确保 levelConfigs[].salary.dutyContent 带上最新输入
  1458. if (this.activeLevel) {
  1459. const current = this.form.levels.find(
  1460. (item) => item.level === this.activeLevel,
  1461. );
  1462. if (current) {
  1463. current.dutyText = this.dutyText;
  1464. current.duties = duties;
  1465. }
  1466. }
  1467. // 提交前再刷一遍当前 Tab 到 levels,避免漏掉未切换层级时的定薪/技能等变更
  1468. if (this.activeLevel) this.saveLevelConfig(this.activeLevel);
  1469. const payload = {
  1470. ...this.form,
  1471. positionSequence: String(this.form.positionSequence || "").trim(),
  1472. version: this.isUpdate ? Number(this.form.version || 1) + 1 : 1,
  1473. duties,
  1474. dutyText: this.dutyText,
  1475. // 供序列化时把晋升目标名称解析为岗位主键
  1476. promoteTargetOptions: [
  1477. ...this.upwardOptions,
  1478. ...this.downwardOptions,
  1479. ...this.lateralOptions,
  1480. ],
  1481. updatedAt: new Date().toISOString().slice(0, 10),
  1482. history: [
  1483. {
  1484. time: new Date().toLocaleString("zh-CN", { hour12: false }),
  1485. title: this.isUpdate ? "编辑岗位档案" : "新增岗位档案",
  1486. content: "岗位档案已提交至人力资源服务。",
  1487. },
  1488. ...(this.form.history || []),
  1489. ],
  1490. };
  1491. this.$emit("save", payload);
  1492. this.saving = false;
  1493. });
  1494. },
  1495. /** 定位到未填项:切层级 / 子模块后滚动并短暂高亮 */
  1496. async focusFieldError(error = {}) {
  1497. if (error.level && error.level !== this.activeLevel) {
  1498. this.activeLevel = error.level;
  1499. await this.$nextTick();
  1500. await new Promise((resolve) => setTimeout(resolve, 50));
  1501. }
  1502. if (
  1503. error.module &&
  1504. error.module !== "basic" &&
  1505. error.module !== this.activeModule
  1506. ) {
  1507. this.activeModule = error.module;
  1508. await this.$nextTick();
  1509. }
  1510. if (
  1511. error.anchor === "field-skills" &&
  1512. !(this.form.skills || []).length &&
  1513. !this.readonly
  1514. ) {
  1515. this.addSkill();
  1516. await this.$nextTick();
  1517. }
  1518. await this.$nextTick();
  1519. const root = this.$el;
  1520. if (!root) return;
  1521. const selector = error.anchor
  1522. ? `[data-field-anchor="${error.anchor}"]`
  1523. : ".el-form-item.is-error";
  1524. const target =
  1525. root.querySelector(selector) ||
  1526. root.querySelector(".el-form-item.is-error");
  1527. if (!target) return;
  1528. target.scrollIntoView({ behavior: "smooth", block: "center" });
  1529. target.classList.add("field-anchor-flash");
  1530. window.setTimeout(() => target.classList.remove("field-anchor-flash"), 1600);
  1531. const focusable = target.querySelector(
  1532. "input:not([disabled]), textarea:not([disabled]), .el-input__inner, .el-textarea__inner",
  1533. );
  1534. if (focusable && typeof focusable.focus === "function") {
  1535. try {
  1536. focusable.focus({ preventScroll: true });
  1537. } catch (e) {
  1538. focusable.focus();
  1539. }
  1540. }
  1541. },
  1542. /** 基础信息必填外的自定义校验(定薪/技能/晋升不再强制必填;岗位职责必填) */
  1543. findFirstFieldError() {
  1544. if (this.form.type === "" || this.form.type == null) {
  1545. return {
  1546. message: "请选择岗位性质",
  1547. module: "basic",
  1548. anchor: "field-type",
  1549. };
  1550. }
  1551. if (!String(this.dutyText || "").trim()) {
  1552. return {
  1553. message: "请输入岗位职责",
  1554. level: this.activeLevel,
  1555. module: "salary",
  1556. anchor: "field-dutyText",
  1557. };
  1558. }
  1559. return null;
  1560. },
  1561. reset() {
  1562. this.form = emptyForm();
  1563. this.dutyText = "";
  1564. this.activeLevel = "";
  1565. this.$refs.form?.clearValidate();
  1566. },
  1567. updateVisible(value) {
  1568. this.$emit("update:visible", value);
  1569. },
  1570. closeDrawer(done) {
  1571. this.updateVisible(false);
  1572. if (typeof done === "function") done();
  1573. },
  1574. collectRuleErrors() {
  1575. const fieldError = this.findFirstFieldError();
  1576. const errors = fieldError ? [fieldError] : [];
  1577. const duplicate = this.positionList.some(
  1578. (item) =>
  1579. item.positionCode === this.form.positionCode &&
  1580. item.id !== this.form.id,
  1581. );
  1582. const department = this.departments.find(
  1583. (item) => String(item.id) === String(this.form.deptId),
  1584. );
  1585. if (!department || String(department.legalId) !== String(this.form.legalId))) {
  1586. errors.push({
  1587. message: "所属部门必须存在且属于当前法人",
  1588. module: "basic",
  1589. anchor: "field-deptId",
  1590. });
  1591. }
  1592. if (this.form.level < 1) {
  1593. errors.push({
  1594. message: "岗位层级必须大于 0",
  1595. module: "basic",
  1596. anchor: "field-level",
  1597. });
  1598. }
  1599. if (this.form.status === 3 && !this.form.revokeDate) {
  1600. errors.push({
  1601. message: "待撤销岗位必须填写计划撤销日期",
  1602. module: "basic",
  1603. anchor: "field-type",
  1604. });
  1605. }
  1606. if (duplicate) {
  1607. errors.push({
  1608. message: "岗位编码已存在",
  1609. module: "basic",
  1610. anchor: "field-name",
  1611. });
  1612. }
  1613. const certificates = this.form.certificates || [];
  1614. if (certificates.some((item) => !String(item?.name || "").trim())) {
  1615. errors.push({
  1616. message: "证书名称不能为空",
  1617. level: this.activeLevel,
  1618. module: "skill",
  1619. anchor: "field-skills",
  1620. });
  1621. }
  1622. return errors;
  1623. },
  1624. },
  1625. };
  1626. </script>
  1627. <style
  1628. src="../../../../styles/views/positionManagement/components/PositionEditDialog.scss"
  1629. lang="scss"
  1630. scoped
  1631. ></style>