PositionEditDialog.vue 68 KB

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