EmployeeArchiveForm.vue 129 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651
  1. <template>
  2. <div>
  3. <el-dialog :visible.sync="dialogVisible" :width="fullscreen ? '100%' : '1450px'" :top="fullscreen ? '0' : '3vh'"
  4. append-to-body :close-on-click-modal="false" :close-on-press-escape="!showWorkTypeSelect"
  5. :before-close="handleBeforeClose" :custom-class="'employee-archive-dialog' + (fullscreen ? ' is-fullscreen' : '')"
  6. @closed="resetForm">
  7. <div slot="title" class="archive-dialog-title">
  8. <span>{{ isEdit ? '编辑员工档案' : '新增员工档案' }}</span>
  9. <button type="button" class="archive-dialog-fullscreen" :title="fullscreen ? '退出全屏' : '全屏'"
  10. @click.stop="toggleFullscreen">
  11. <i :class="fullscreen ? 'el-icon-_compress' : 'el-icon-full-screen'"></i>
  12. </button>
  13. </div>
  14. <div class="archive-form-summary">
  15. <span class="summary-icon"><i class="el-icon-user"></i></span>
  16. <div class="summary-copy">
  17. <strong>{{ form.name || '新员工档案' }}</strong>
  18. <p>{{ form.employeeNo || '待生成工号' }}</p>
  19. </div>
  20. <div class="summary-completion">
  21. <div><span>资料完整度</span><strong>{{ completionRate }}%</strong></div>
  22. <span class="completion-track"><i :style="{ width: `${completionRate}%` }"></i></span>
  23. </div>
  24. </div>
  25. <el-form ref="form" :model="form" :rules="rules" label-position="top" size="small" class="archive-form-shell">
  26. <el-tabs v-model="activeTab" tab-position="left" class="archive-form-tabs">
  27. <el-tab-pane v-for="section in sections" :key="section.name" :name="section.name">
  28. <span slot="label" class="section-tab-label">
  29. <i :class="section.icon"></i>
  30. <span><strong>{{ section.label }}</strong><small>{{ section.subtitle }}</small></span>
  31. <em>{{ sectionProgress[section.name] }}</em>
  32. </span>
  33. <div v-if="section.name === 'base'" class="section-content">
  34. <header class="section-title">
  35. <div>
  36. <h3>基本信息</h3>
  37. <p>员工身份、组织任职与联系方式,字段与档案模型一一对应</p>
  38. </div><span>必填 {{ requiredProgress.base }}</span>
  39. </header>
  40. <div class="subsection-title"><strong>身份信息</strong></div>
  41. <el-row :gutter="16">
  42. <el-col :xs="24" :sm="12"><el-form-item label="姓名" prop="name"><el-input v-model.trim="form.name"
  43. maxlength="20" placeholder="请输入员工真实姓名" /></el-form-item></el-col>
  44. <el-col :xs="24" :sm="12"><el-form-item label="性别" prop="gender"><el-select v-model="form.gender"
  45. class="full-width"><el-option v-for="item in genderOptions" :key="item" :label="item"
  46. :value="item" /></el-select></el-form-item></el-col>
  47. <el-col :xs="24" :sm="12"><el-form-item label="身份证" prop="idCard"><el-input v-model.trim="form.idCard"
  48. maxlength="18" placeholder="请输入18位身份证号" @input="handleIdCardInput"
  49. @blur="parseIdCard" /></el-form-item></el-col>
  50. <el-col :xs="24" :sm="12"><el-form-item label="出生日期" prop="birthDate"><el-date-picker
  51. v-model="form.birthDate" value-format="yyyy-MM-dd" type="date" placeholder="可由身份证自动生成"
  52. class="full-width" @change="syncAge" /></el-form-item></el-col>
  53. <el-col :xs="24" :sm="12"><el-form-item label="年龄"><el-input :value="form.age || ''" readonly
  54. placeholder="根据出生日期自动计算" /></el-form-item></el-col>
  55. <el-col :xs="24" :sm="12"><el-form-item label="民族"><el-select v-model="form.ethnic" filterable
  56. allow-create default-first-option class="full-width"><el-option v-for="item in ethnicOptions"
  57. :key="item" :label="item" :value="item" /></el-select></el-form-item></el-col>
  58. <el-col :xs="24" :sm="12"><el-form-item label="政治面貌"><el-input v-model.trim="form.politicalStatus"
  59. maxlength="20" placeholder="党员、预备党员、群众等" /></el-form-item></el-col>
  60. <el-col :xs="24" :sm="12"><el-form-item label="婚姻状态"><el-select v-model="form.maritalStatus"
  61. class="full-width"><el-option v-for="item in maritalOptions" :key="item" :label="item"
  62. :value="item" /></el-select></el-form-item></el-col>
  63. <el-col :xs="24" :sm="12"><el-form-item label="籍贯"><el-input v-model.trim="form.nativePlace"
  64. maxlength="30" placeholder="省 / 市" /></el-form-item></el-col>
  65. <el-col :xs="24" :sm="12"><el-form-item label="最高学历"><el-select v-model="form.education"
  66. class="full-width"><el-option v-for="item in educationOptions" :key="item" :label="item"
  67. :value="item" /></el-select></el-form-item></el-col>
  68. <el-col :xs="24" :sm="12"><el-form-item label="毕业院校"><el-input v-model.trim="form.school" maxlength="40"
  69. placeholder="请输入毕业院校" /></el-form-item></el-col>
  70. <el-col :xs="24" :sm="12"><el-form-item label="户籍地址"><el-input v-model.trim="form.registeredAddress"
  71. maxlength="100" placeholder="请输入户籍地址" /></el-form-item></el-col>
  72. <el-col :xs="24" :sm="12"><el-form-item label="现居住地址"><el-input v-model.trim="form.currentAddress"
  73. maxlength="100" placeholder="请输入现居住地址" /></el-form-item></el-col>
  74. <el-col :xs="24" :sm="12"><el-form-item label="工作地址"><el-input v-model.trim="form.workAddress"
  75. maxlength="60" placeholder="实际办公地点" /></el-form-item></el-col>
  76. </el-row>
  77. <div class="subsection-title"><strong>组织与任职</strong></div>
  78. <el-row :gutter="16">
  79. <el-col :xs="24" :sm="12"><el-form-item label="工号" prop="employeeNo"><el-input
  80. v-model.trim="form.employeeNo" maxlength="20" placeholder="集团全局唯一" disabled><el-button
  81. v-if="!isEdit" slot="append"
  82. @click="generateEmployeeNo">生成</el-button></el-input></el-form-item></el-col>
  83. <el-col :xs="24" :sm="12"><el-form-item label="用户账号"><el-input v-model.trim="form.account"
  84. maxlength="30" placeholder="系统登录账号,支持自动生成、手动修改"><template
  85. slot="prepend">@</template></el-input></el-form-item></el-col>
  86. <el-col :xs="24" :sm="12">
  87. <el-form-item label="所属机构" prop="groupId">
  88. <el-select v-model="form.groupId" filterable class="full-width" placeholder="请选择法人组织"
  89. @change="handleLegalEntityChange">
  90. <el-option v-for="item in legalEntities" :key="String(item.id)" :label="item.name"
  91. :value="String(item.id)" />
  92. </el-select>
  93. </el-form-item>
  94. </el-col>
  95. <el-col :xs="24" :sm="12">
  96. <el-form-item label="所属工厂">
  97. <el-select v-model="form.factoryId" filterable clearable class="full-width"
  98. :loading="factoryLoading" :disabled="!form.groupId"
  99. :placeholder="form.groupId ? '请选择所属工厂' : '请先选择所属机构'" @change="handleFactoryChange">
  100. <el-option v-for="item in factoryOptions" :key="String(item.id)" :label="item.name"
  101. :value="item.id" />
  102. </el-select>
  103. </el-form-item>
  104. </el-col>
  105. <el-col :xs="24" :sm="12"><el-form-item label="隶属部门" prop="deptIds"><el-cascader
  106. :key="`dept-cascader-${form.groupId || 'none'}-${deptCascaderKey}`" v-model="form.deptIds"
  107. :options="deptOptions" :props="deptCascaderProps" filterable clearable :show-all-levels="false"
  108. :append-to-body="true" popper-class="employee-archive-dept-cascader" ref="deptCascader"
  109. class="full-width dept-multi-cascader" :disabled="!form.groupId"
  110. :placeholder="deptCascaderPlaceholder"
  111. @change="handleOrganizationChange" /></el-form-item></el-col>
  112. <el-col :xs="24" :sm="12">
  113. <el-form-item label="岗位序列" prop="positionSequenceId">
  114. <el-select v-model="form.positionSequenceId" filterable clearable class="full-width"
  115. :loading="sequenceLoading" :disabled="!hasDept" :placeholder="hasDept ? '请选择岗位序列' : '请先选择隶属部门'"
  116. @change="handlePositionSequenceChange">
  117. <el-option v-for="item in sequenceOptions" :key="`seq-${String(item.id)}`" :label="item.name"
  118. :value="String(item.id)" />
  119. </el-select>
  120. </el-form-item>
  121. </el-col>
  122. <el-col :xs="24" :sm="12">
  123. <el-form-item label="岗位" prop="positionId">
  124. <el-select :key="`position-select-${positionSelectKey}`" v-model="form.positionId" filterable
  125. clearable class="full-width" :loading="positionLoading" :disabled="!hasPositionSequence"
  126. :placeholder="hasPositionSequence ? '请选择岗位' : '请先选择岗位序列'" @change="handlePositionChange">
  127. <el-option v-for="item in positionOptions" :key="`pos-${String(item.id)}`"
  128. :label="positionOptionLabel(item)" :value="String(item.id)" />
  129. </el-select>
  130. </el-form-item>
  131. </el-col>
  132. <el-col :xs="24" :sm="12"><el-form-item label="岗位性质"><el-select v-model="form.employmentType" filterable
  133. clearable class="full-width" :placeholder="form.positionId ? '请选择岗位性质' : '请先选择岗位'"
  134. @change="syncEmploymentTypeName">
  135. <el-option v-for="item in employmentOptions" :key="item.key || item.value" :label="item.label"
  136. :value="item.value" />
  137. </el-select></el-form-item></el-col>
  138. <el-col :xs="24" :sm="12"><el-form-item label="岗位层级"><el-select v-model="form.staffingLevel"
  139. class="full-width" :disabled="!staffingLevelOptions.length"
  140. :placeholder="form.positionId ? '请选择岗位层级' : '请先选择岗位'" @change="handleStaffingLevelChange">
  141. <el-option v-for="item in staffingLevelOptions" :key="item" :label="item"
  142. :value="item" /></el-select></el-form-item></el-col>
  143. <el-col :xs="24" :sm="12">
  144. <el-form-item label="直属上级">
  145. <el-select v-model="form.managerId" filterable clearable class="full-width"
  146. :loading="managerLoading" :disabled="!hasDept" :placeholder="hasDept ? '请选择直属上级' : '请先选择隶属部门'"
  147. @change="handleManagerChange">
  148. <el-option v-for="item in managerOptions" :key="item.id" :label="item.name" :value="item.id" />
  149. </el-select>
  150. </el-form-item>
  151. </el-col>
  152. <el-col :xs="24" :sm="12"><el-form-item label="入职时间" prop="hireDate"><el-date-picker
  153. v-model="form.hireDate" value-format="yyyy-MM-dd" type="date" placeholder="请选择入职日期"
  154. class="full-width" /></el-form-item></el-col>
  155. <el-col :xs="24" :sm="12"><el-form-item label="转正日期"><el-date-picker v-model="form.regularDate"
  156. value-format="yyyy-MM-dd" type="date" placeholder="转正生效日期"
  157. class="full-width" /></el-form-item></el-col>
  158. <el-col :xs="24" :sm="12"><el-form-item label="员工状态" prop="status"><el-select v-model="form.status"
  159. class="full-width"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label"
  160. :value="item.value" /></el-select></el-form-item></el-col>
  161. <el-col :xs="24" :sm="12"><el-form-item label="关键工序"><el-input v-model.trim="form.keyProcess"
  162. maxlength="30" placeholder="标记特种工序岗位人员" /></el-form-item></el-col>
  163. <el-col :xs="24" :sm="12"><el-form-item label="是否启用">
  164. <div class="enable-field"><el-switch v-model="form.enabled" active-color="#18a875" /><span>{{
  165. form.enabled ? '启用' :
  166. '待岗' }}</span></div>
  167. </el-form-item></el-col>
  168. </el-row>
  169. <div class="subsection-title"><strong>联系方式</strong></div>
  170. <el-row :gutter="16">
  171. <el-col :xs="24" :sm="12"><el-form-item label="手机号" prop="phone"><el-input v-model.trim="form.phone"
  172. maxlength="11" placeholder="请输入 11 位手机号码"
  173. prefix-icon="el-icon-mobile-phone" /></el-form-item></el-col>
  174. <el-col :xs="24" :sm="12"><el-form-item label="邮箱"><el-input v-model.trim="form.email" maxlength="50"
  175. placeholder="name@company.com" prefix-icon="el-icon-message" /></el-form-item></el-col>
  176. <el-col :xs="24" :sm="12"><el-form-item label="微信号"><el-input v-model.trim="form.wechat" maxlength="30"
  177. placeholder="请输入微信号" /></el-form-item></el-col>
  178. <el-col :xs="24" :sm="12"><el-form-item label="紧急联系人"><el-input v-model.trim="form.emergencyContact"
  179. maxlength="20" placeholder="请输入联系人姓名" /></el-form-item></el-col>
  180. <el-col :xs="24" :sm="12"><el-form-item label="紧急联系人关系"><el-input v-model.trim="form.emergencyRelation"
  181. maxlength="20" placeholder="如配偶、父母、子女" /></el-form-item></el-col>
  182. <el-col :xs="24" :sm="12"><el-form-item label="紧急联系电话"><el-input v-model.trim="form.emergencyPhone"
  183. maxlength="11" placeholder="请输入联系电话" /></el-form-item></el-col>
  184. </el-row>
  185. </div>
  186. <div v-else-if="section.name === 'experience'" class="section-content">
  187. <header class="section-title">
  188. <div>
  189. <h3>教育与工作履历</h3>
  190. <p>最高教育经历和入职前工作经历</p>
  191. </div><span>{{ form.previousJobs.length }} 段履历</span>
  192. </header>
  193. <div class="subsection-title"><strong>教育经历</strong><el-button type="primary" size="mini"
  194. @click="addEducation">添加教育</el-button></div>
  195. <el-alert v-if="form.educations.length > 1" type="warning" :closable="false" show-icon
  196. title="多条教育经历保存时只会提交第一条。" style="margin-bottom: 10px" />
  197. <el-table :data="form.educations" border size="small" class="education-table" empty-text="暂无教育经历">
  198. <el-table-column type="index" label="序号" width="55" align="center" />
  199. <el-table-column label="学历" min-width="120">
  200. <template slot-scope="{ row }">
  201. <el-select v-model="row.education" placeholder="请选择" class="full-width">
  202. <el-option v-for="opt in educationOptions" :key="opt" :label="opt" :value="opt" />
  203. </el-select>
  204. </template>
  205. </el-table-column>
  206. <el-table-column label="学位" min-width="110">
  207. <template slot-scope="{ row }">
  208. <el-select v-model="row.degree" clearable placeholder="请选择" class="full-width">
  209. <el-option v-for="opt in ['无', '学士', '硕士', '博士']" :key="opt" :label="opt" :value="opt" />
  210. </el-select>
  211. </template>
  212. </el-table-column>
  213. <el-table-column label="毕业日期" min-width="150">
  214. <template slot-scope="{ row }">
  215. <el-date-picker v-model="row.graduationDate" value-format="yyyy-MM-dd" type="date"
  216. placeholder="选择日期" class="full-width" />
  217. </template>
  218. </el-table-column>
  219. <el-table-column label="毕业院校" min-width="160">
  220. <template slot-scope="{ row }">
  221. <el-input v-model.trim="row.school" maxlength="40" placeholder="请输入毕业院校" />
  222. </template>
  223. </el-table-column>
  224. <el-table-column label="所学专业" min-width="150">
  225. <template slot-scope="{ row }">
  226. <el-input v-model.trim="row.major" maxlength="40" placeholder="请输入所学专业" />
  227. </template>
  228. </el-table-column>
  229. <el-table-column label="操作" width="90" align="center">
  230. <template slot-scope="{ $index }">
  231. <el-button type="text" icon="el-icon-delete" class="education-delete-btn"
  232. @click="removeItem('educations', $index)">删除</el-button>
  233. </template>
  234. </el-table-column>
  235. </el-table>
  236. <div class="subsection-title"><strong>工作履历</strong><el-button type="primary" size="mini"
  237. @click="addPreviousJob">添加履历</el-button></div>
  238. <el-table :data="form.previousJobs" border size="small" class="job-table" empty-text="暂无入职前工作履历">
  239. <el-table-column type="index" label="序号" width="55" align="center" />
  240. <el-table-column min-width="150">
  241. <template slot="header"><span class="cell-required">工作开始日期</span></template>
  242. <template slot-scope="{ row }">
  243. <el-date-picker v-model="row.startDate" value-format="yyyy-MM-dd" type="date" placeholder="开始日期"
  244. class="full-width" />
  245. </template>
  246. </el-table-column>
  247. <el-table-column min-width="150">
  248. <template slot="header"><span class="cell-required">工作结束日期</span></template>
  249. <template slot-scope="{ row }">
  250. <el-date-picker v-model="row.endDate" value-format="yyyy-MM-dd" type="date" placeholder="结束日期"
  251. class="full-width" />
  252. </template>
  253. </el-table-column>
  254. <el-table-column min-width="150">
  255. <template slot="header"><span class="cell-required">前公司名称</span></template>
  256. <template slot-scope="{ row }">
  257. <el-input v-model.trim="row.company" maxlength="50" placeholder="请输入公司名称" />
  258. </template>
  259. </el-table-column>
  260. <el-table-column min-width="130">
  261. <template slot="header"><span class="cell-required">前公司岗位</span></template>
  262. <template slot-scope="{ row }">
  263. <el-input v-model.trim="row.position" maxlength="30" placeholder="请输入岗位" />
  264. </template>
  265. </el-table-column>
  266. <el-table-column label="前公司部门" min-width="130">
  267. <template slot-scope="{ row }">
  268. <el-input v-model.trim="row.department" maxlength="30" placeholder="请输入部门" />
  269. </template>
  270. </el-table-column>
  271. <el-table-column label="离职原因" min-width="130">
  272. <template slot-scope="{ row }">
  273. <el-input v-model.trim="row.leaveReason" maxlength="40" placeholder="请输入" />
  274. </template>
  275. </el-table-column>
  276. <el-table-column label="工作描述" min-width="180">
  277. <template slot-scope="{ row }">
  278. <el-input v-model.trim="row.description" maxlength="300" placeholder="请输入工作描述" />
  279. </template>
  280. </el-table-column>
  281. <el-table-column label="操作" width="90" align="center">
  282. <template slot-scope="{ $index }">
  283. <el-button type="text" icon="el-icon-delete" class="job-delete-btn"
  284. @click="removeItem('previousJobs', $index)">删除</el-button>
  285. </template>
  286. </el-table-column>
  287. </el-table>
  288. </div>
  289. <div v-else-if="section.name === 'contract'" class="section-content">
  290. <header class="section-title">
  291. <div>
  292. <h3>劳动合同</h3>
  293. <p>劳动合同只读同步自合同管理</p>
  294. </div><span>{{ contractState }}</span>
  295. </header>
  296. <div class="subsection-title"><strong>劳动合同(只读)</strong></div>
  297. <el-row :gutter="16">
  298. <el-col :xs="24" :sm="12"><el-form-item label="分类"><el-input :value="form.contract.category" readonly
  299. placeholder="—" /></el-form-item></el-col>
  300. <el-col :xs="24" :sm="12"><el-form-item label="合同编号/名称"><el-input
  301. :value="form.contract.name || form.contract.number" readonly
  302. placeholder="—" /></el-form-item></el-col>
  303. <el-col :xs="24" :sm="8"><el-form-item label="签订时间"><el-input :value="form.contract.signDate" readonly
  304. placeholder="—" /></el-form-item></el-col>
  305. <el-col :xs="24" :sm="8"><el-form-item label="开始时间"><el-input :value="form.contract.startDate" readonly
  306. placeholder="—" /></el-form-item></el-col>
  307. <el-col :xs="24" :sm="8"><el-form-item label="结束时间"><el-input :value="form.contract.endDate" readonly
  308. placeholder="无固定期限/未同步" /></el-form-item></el-col>
  309. <el-col :xs="24" :sm="12"><el-form-item label="生效状态"><el-input :value="form.contract.status" readonly
  310. placeholder="—" /></el-form-item></el-col>
  311. <el-col :span="24"><el-form-item label="备注"><el-input :value="form.contract.remark" type="textarea"
  312. :rows="2" readonly placeholder="—" /></el-form-item></el-col>
  313. </el-row>
  314. </div>
  315. <div v-else-if="section.name === 'payroll'" class="section-content">
  316. <header class="section-title">
  317. <div>
  318. <h3>薪酬与社保</h3>
  319. <p>工资卡银行账户与社保公积金参保记录</p>
  320. </div><span>{{ form.socialSecurityRecords.length }} 条社保记录</span>
  321. </header>
  322. <!--
  323. 职责边界:本页只记「个人参保事实 + 银行账户」。
  324. 缴费比例 / 基数上下限是公司级参数(五险一金方案);
  325. 定岗薪资 / 绩效基数 / 参保方案覆盖按人按版本在「员工薪资档案」维护。
  326. -->
  327. <el-alert class="payroll-scope-alert" type="info" :closable="false" show-icon
  328. title="这里只记个人的参保事实与银行账户">
  329. <template slot="default">
  330. 社保 / 公积金的<b>缴费比例与基数上下限</b>是公司级参数,统一维护在「薪资核算 → 五险一金方案」,本页无需填写;
  331. <b>定岗薪资 / 绩效基数 / 参保方案覆盖</b>按人按版本维护在「薪资核算 → 员工薪资档案」。
  332. 本页「基数」只记录该员工的实际缴费基数。
  333. </template>
  334. </el-alert>
  335. <div class="subsection-title"><strong>薪酬银行</strong><el-button type="primary" size="mini"
  336. @click="addBankAccount">添加银行</el-button></div>
  337. <el-table :data="form.bankAccounts" border size="small" class="bank-table" empty-text="暂无银行账户">
  338. <el-table-column type="index" label="序号" width="55" align="center" />
  339. <el-table-column label="开户行" min-width="180">
  340. <template slot-scope="{ row }">
  341. <el-input v-model.trim="row.bankName" maxlength="50" placeholder="请输入开户行" />
  342. </template>
  343. </el-table-column>
  344. <el-table-column label="银行网点" min-width="160">
  345. <template slot-scope="{ row }">
  346. <el-input v-model.trim="row.bankBranch" maxlength="100" placeholder="请输入银行网点" />
  347. </template>
  348. </el-table-column>
  349. <el-table-column label="银行卡号" min-width="180">
  350. <template slot-scope="{ row }">
  351. <el-input v-model.trim="row.bankAccount" maxlength="24" placeholder="请输入银行卡号" />
  352. </template>
  353. </el-table-column>
  354. <el-table-column min-width="140">
  355. <template slot="header"><span class="cell-required">姓名</span></template>
  356. <template slot-scope="{ row }">
  357. <el-input v-model.trim="row.bankAccountName" maxlength="20" placeholder="请输入开户人" />
  358. </template>
  359. </el-table-column>
  360. <el-table-column label="操作" width="90" align="center">
  361. <template slot-scope="{ $index }">
  362. <el-button type="text" icon="el-icon-delete" class="bank-delete-btn"
  363. @click="removeItem('bankAccounts', $index)">删除</el-button>
  364. </template>
  365. </el-table-column>
  366. </el-table>
  367. <div class="subsection-title"><strong>社保公积金</strong><el-button type="primary" size="mini"
  368. @click="addSocialSecurity">添加记录</el-button></div>
  369. <el-table :data="form.socialSecurityRecords" border size="small" class="social-table" empty-text="暂无社保记录">
  370. <el-table-column type="index" label="序号" width="55" align="center" />
  371. <el-table-column label="社保类型" min-width="140">
  372. <template slot-scope="{ row }">
  373. <el-select v-model="row.type" clearable placeholder="请选择" class="full-width">
  374. <el-option v-for="opt in socialSecurityOptions" :key="opt" :label="opt" :value="opt" />
  375. </el-select>
  376. </template>
  377. </el-table-column>
  378. <el-table-column label="是否启用" min-width="120">
  379. <template slot-scope="{ row }">
  380. <el-select v-model="row.isEnabled" placeholder="请选择" class="full-width"
  381. @change="handleSocialEnabledChange(row)">
  382. <el-option label="是" :value="1" />
  383. <el-option label="否" :value="0" />
  384. </el-select>
  385. </template>
  386. </el-table-column>
  387. <el-table-column label="基数" min-width="120">
  388. <template slot-scope="{ row }">
  389. <el-input-number v-model="row.base" :min="0" :precision="2" :controls="false" class="full-width" />
  390. </template>
  391. </el-table-column>
  392. <el-table-column label="参保日期" min-width="160">
  393. <template slot-scope="{ row }">
  394. <el-date-picker v-model="row.insuredDate" value-format="yyyy-MM-dd" type="date" placeholder="选择日期"
  395. class="full-width" />
  396. </template>
  397. </el-table-column>
  398. <el-table-column label="停保日期" min-width="150">
  399. <template slot-scope="{ row }">
  400. <el-date-picker v-model="row.stopDate" value-format="yyyy-MM-dd" type="date" placeholder="在职可为空"
  401. class="full-width" />
  402. </template>
  403. </el-table-column>
  404. <el-table-column label="停缴记录" min-width="130">
  405. <template slot-scope="{ row }">
  406. <el-select v-model="row.stopReason" clearable placeholder="请选择" class="full-width">
  407. <el-option v-for="opt in ['离职', '调转', '其他']" :key="opt" :label="opt" :value="opt" />
  408. </el-select>
  409. </template>
  410. </el-table-column>
  411. <el-table-column label="操作" width="90" align="center">
  412. <template slot-scope="{ $index }">
  413. <!-- 已保存(接口回显带 id)的社保记录不允许删除,只能通过「停缴」处理 -->
  414. <el-button type="text" icon="el-icon-delete" class="social-delete-btn"
  415. @click="handleSocialDelete($index)">删除</el-button>
  416. </template>
  417. </el-table-column>
  418. </el-table>
  419. </div>
  420. <div v-else-if="section.name === 'qualification'" class="section-content">
  421. <header class="section-title">
  422. <div>
  423. <h3>资质与技能</h3>
  424. <p>工种、证照、技能与档案附件</p>
  425. </div><span>{{ form.workTypes.length + form.certificates.length + form.skills.length }} 条资质记录</span>
  426. </header>
  427. <div class="subsection-title"><strong>工种</strong><el-button type="primary" size="mini"
  428. @click="openWorkTypeSelect">添加工种</el-button></div>
  429. <el-table :data="form.workTypes" border size="small" class="work-type-table" empty-text="暂无工种记录">
  430. <el-table-column type="index" label="序号" width="55" align="center" />
  431. <el-table-column label="类型" min-width="110" show-overflow-tooltip :formatter="workTypeTypeLabel" />
  432. <el-table-column prop="code" label="编码" min-width="110" show-overflow-tooltip />
  433. <el-table-column prop="name" label="名称" min-width="110" show-overflow-tooltip />
  434. <el-table-column label="等级" min-width="110" show-overflow-tooltip :formatter="workTypeLevelLabel" />
  435. <el-table-column label="标准工时费" min-width="110" show-overflow-tooltip
  436. :formatter="workTypeHourCostLabel" />
  437. <el-table-column label="工时费单位" min-width="110" show-overflow-tooltip :formatter="workTypeUnitLabel" />
  438. <el-table-column label="操作" width="90" align="left">
  439. <template slot-scope="{ $index }">
  440. <el-popconfirm class="ele-action" title="确定要删除当前工种吗?" @confirm="removeItem('workTypes', $index)">
  441. <el-link slot="reference" type="danger" :underline="false" icon="el-icon-delete">删除</el-link>
  442. </el-popconfirm>
  443. </template>
  444. </el-table-column>
  445. </el-table>
  446. <div class="subsection-title"><strong>资质证照列表</strong><el-button type="primary" size="mini"
  447. @click="addCertificate">添加资质证照</el-button></div>
  448. <el-table :data="form.certificates" border size="small" class="certificate-table" empty-text="暂无资质证照记录">
  449. <el-table-column type="index" label="序号" width="55" align="center" />
  450. <el-table-column min-width="200">
  451. <template slot="header"><span class="cell-required">证照类型</span></template>
  452. <template slot-scope="{ row }">
  453. <el-select v-model="row.type" placeholder="请选择" class="full-width" @change="handleCertTypeChange(row)">
  454. <el-option v-for="opt in certificateTypeOptions" :key="opt" :label="opt" :value="opt" />
  455. </el-select>
  456. </template>
  457. </el-table-column>
  458. <el-table-column min-width="130">
  459. <template slot="header"><span class="cell-required">证照编号</span></template>
  460. <template slot-scope="{ row }">
  461. <el-input v-model.trim="row.number" maxlength="30" placeholder="请输入" />
  462. </template>
  463. </el-table-column>
  464. <el-table-column label="等级" min-width="200">
  465. <template slot-scope="{ row }">
  466. <el-select v-model="row.level" clearable placeholder="请选择" class="full-width"
  467. :disabled="isCertLevelDisabled(row.type)">
  468. <el-option v-for="opt in certLevelOptionsFor(row.type)" :key="opt.value" :label="opt.label"
  469. :value="opt.value" />
  470. </el-select>
  471. </template>
  472. </el-table-column>
  473. <el-table-column min-width="170">
  474. <template slot="header"><span class="cell-required">有效期开始日期</span></template>
  475. <template slot-scope="{ row }">
  476. <el-date-picker v-model="row.startDate" value-format="yyyy-MM-dd" type="date" placeholder="选择日期"
  477. class="full-width" />
  478. </template>
  479. </el-table-column>
  480. <el-table-column min-width="170">
  481. <template slot="header"><span class="cell-required">有效期结束日期</span></template>
  482. <template slot-scope="{ row }">
  483. <el-date-picker v-model="row.endDate" value-format="yyyy-MM-dd" type="date" placeholder="选择日期"
  484. class="full-width" />
  485. </template>
  486. </el-table-column>
  487. <el-table-column label="颁布日期" min-width="170">
  488. <template slot-scope="{ row }">
  489. <el-date-picker v-model="row.issueDate" value-format="yyyy-MM-dd" type="date" placeholder="选择日期"
  490. class="full-width" />
  491. </template>
  492. </el-table-column>
  493. <el-table-column label="证照附件" min-width="140">
  494. <template slot-scope="{ row }">
  495. <fileMain v-model="row.attachment" size="mini" />
  496. </template>
  497. </el-table-column>
  498. <el-table-column min-width="110">
  499. <template slot="header"><span class="cell-required">状态</span></template>
  500. <template slot-scope="{ row }">
  501. <el-select v-model="row.status" placeholder="请选择" class="full-width">
  502. <el-option label="有效" value="有效" />
  503. <el-option label="失效" value="失效" />
  504. </el-select>
  505. </template>
  506. </el-table-column>
  507. <el-table-column label="是否有岗位津贴" min-width="140">
  508. <template slot-scope="{ row }">
  509. <el-select v-model="row.hasPositionAllowance" placeholder="请选择" class="full-width"
  510. @change="handlePositionAllowanceChange(row)">
  511. <el-option label="是" :value="1" />
  512. <el-option label="否" :value="0" />
  513. </el-select>
  514. </template>
  515. </el-table-column>
  516. <el-table-column label="津贴金额" min-width="130">
  517. <template slot-scope="{ row }">
  518. <!-- 不设 precision:整数就显示整数(100),不要 100.00;小数按输入保留 -->
  519. <el-input-number v-model="row.positionAllowanceAmount" :min="0" :controls="false"
  520. placeholder="请输入" class="full-width" />
  521. </template>
  522. </el-table-column>
  523. <el-table-column label="津贴单位" min-width="140">
  524. <template slot-scope="{ row }">
  525. <el-select v-model="row.positionAllowanceUnit" placeholder="请选择" class="full-width">
  526. <el-option v-for="item in allowanceUnitOptions" :key="item.value" :label="item.label"
  527. :value="item.value" />
  528. </el-select>
  529. </template>
  530. </el-table-column>
  531. <el-table-column label="备注" min-width="140">
  532. <template slot-scope="{ row }">
  533. <el-input v-model.trim="row.remark" maxlength="200" placeholder="请输入" />
  534. </template>
  535. </el-table-column>
  536. <el-table-column label="操作" width="90" align="center">
  537. <template slot-scope="{ $index }">
  538. <el-button type="text" icon="el-icon-delete" class="certificate-delete-btn"
  539. @click="removeItem('certificates', $index)">删除</el-button>
  540. </template>
  541. </el-table-column>
  542. </el-table>
  543. <div class="subsection-title"><strong>技能列表</strong><el-button type="primary" size="mini"
  544. @click="addSkill">添加技能</el-button></div>
  545. <el-table :data="form.skills" border size="small" class="skill-table" empty-text="暂无技能记录">
  546. <el-table-column type="index" label="序号" width="55" align="center" />
  547. <el-table-column label="分类" min-width="130">
  548. <template slot-scope="{ row }">
  549. <el-select v-model="row.category" clearable placeholder="请选择" class="full-width">
  550. <el-option v-for="opt in skillCategoryOptions" :key="opt" :label="opt" :value="opt" />
  551. </el-select>
  552. </template>
  553. </el-table-column>
  554. <el-table-column min-width="140">
  555. <template slot="header"><span class="cell-required">技能名称</span></template>
  556. <template slot-scope="{ row }">
  557. <el-input v-model.trim="row.name" maxlength="30" placeholder="请输入" />
  558. </template>
  559. </el-table-column>
  560. <el-table-column label="技能等级" min-width="120">
  561. <template slot-scope="{ row }">
  562. <el-select v-model="row.level" clearable placeholder="请选择" class="full-width">
  563. <el-option v-for="opt in skillLevelOptions" :key="opt" :label="opt" :value="opt" />
  564. </el-select>
  565. </template>
  566. </el-table-column>
  567. <el-table-column label="技能经验(年限)" min-width="130">
  568. <template slot-scope="{ row }">
  569. <el-input-number v-model="row.years" :min="0" :precision="1" :controls="false" class="full-width" />
  570. </template>
  571. </el-table-column>
  572. <el-table-column label="证照附件" min-width="140">
  573. <template slot-scope="{ row }">
  574. <fileMain v-model="row.attachment" size="mini" />
  575. </template>
  576. </el-table-column>
  577. <el-table-column label="备注" min-width="160">
  578. <template slot-scope="{ row }">
  579. <el-input v-model.trim="row.remark" maxlength="200" placeholder="培训经历、考核情况" />
  580. </template>
  581. </el-table-column>
  582. <el-table-column label="操作" width="90" align="center">
  583. <template slot-scope="{ $index }">
  584. <el-button type="text" icon="el-icon-delete" class="skill-delete-btn"
  585. @click="removeItem('skills', $index)">删除</el-button>
  586. </template>
  587. </el-table-column>
  588. </el-table>
  589. <div class="subsection-title attachment-title"><strong>档案附件</strong>
  590. <fileMain v-model="form.files" size="mini" />
  591. </div>
  592. <el-form-item label="档案备注" class="remarks-item"><el-input v-model.trim="form.remarks" type="textarea"
  593. :rows="3" maxlength="500" show-word-limit placeholder="补充需要说明的员工档案信息" /></el-form-item>
  594. </div>
  595. <div v-else class="section-content record-section-content">
  596. <employee-archive-records ref="records" :archive="form" />
  597. </div>
  598. </el-tab-pane>
  599. </el-tabs>
  600. </el-form>
  601. <span slot="footer" class="dialog-footer">
  602. <span></span>
  603. <span>
  604. <el-button @click="closeDialog">取消</el-button>
  605. <el-button type="primary" :loading="saving" @click="submit">保存</el-button>
  606. </span>
  607. </span>
  608. </el-dialog>
  609. <aptitude-dialog ref="workTypeSelect" :disabled-ids="selectedWorkTypeIds" @success="handleWorkTypeSelect"
  610. @update:visible="showWorkTypeSelect = $event" />
  611. </div>
  612. </template>
  613. <script>
  614. import EmployeeArchiveRecords from './EmployeeArchiveRecords.vue';
  615. import AptitudeDialog from '@/views/hrManagement/jobManagement/components/aptitudeDialog.vue';
  616. import dictMixins from '@/mixins/dictMixins';
  617. import {
  618. getPositionById,
  619. getPositionSequenceList,
  620. getPositionSequencePage,
  621. getPositionSequenceLevels,
  622. getDepartmentPositionSequences,
  623. getDepartmentPositions,
  624. getDepartmentPositionType,
  625. getDepartmentPositionLevels,
  626. } from '@/api/hr';
  627. import { listOrganizations, getFactoryAreaPage, getUserPage, EMPLOYEE_STATUS, CERT_TYPE } from '@/api/organization';
  628. import { getDictByCode } from '@/api/sys';
  629. import { buildOrganizationTree, flattenOrganizationTree } from '@/utils/organization-tree';
  630. import { toDocIds } from '@/components/addDoc/util';
  631. import { positionAllowanceUnitOptions } from '@/enum/dict.js';
  632. /* 证照等级:按证照类型联动(证件类型 → 可选等级对照表) */
  633. import { getCertLevelOptions, isCertLevelDisabled, NO_LEVEL_CERT_TYPES, NO_LEVEL_VALUE } from '@/enum/certLevel';
  634. /* 所属机构默认值取登录缓存(loginChangeGroupVOList 第一条) */
  635. import { getCurrentOrgId, getOrgOptions } from '@/utils/current-org';
  636. const emptyContract = () => ({ category: '', name: '', signDate: '', startDate: '', endDate: '', files: [], status: '生效', remark: '' });
  637. const emptyForm = () => ({
  638. id: null, avatar: '', name: '', employeeNo: '', gender: '男', birthDate: '', age: null, idCard: '', idCardExpiry: '', ethnic: '汉族',
  639. maritalStatus: '未婚', politicalStatus: '群众', nativePlace: '', householdType: '', healthStatus: '优',
  640. company: '中盈产业集团有限公司', legalEntity: '', groupId: null, factory: '', factoryId: null, deptId: null, deptIds: [], deptName: '',
  641. position: '', positionId: null, positionIds: [], positionBindings: [],
  642. // 岗位序列(部门 → 序列 → 岗位 → 性质 → 层级,与办理入职一致)
  643. positionSequenceId: '', positionSequence: '', positionSequenceName: '',
  644. positionType: '', level: '', staffingLevel: '', dutyLevel: '', manager: '', managerId: null,
  645. employmentType: '', employmentTypeName: '', status: '全职', hireDate: '', regularDate: '', account: '', enabled: true,
  646. keyProcess: '',
  647. phone: '', email: '', wechat: '', workAddress: '', currentAddress: '', registeredAddress: '', emergencyContact: '', emergencyRelation: '', emergencyPhone: '', familyMembers: [],
  648. education: '本科', school: '', major: '', graduationDate: '',
  649. educations: [],
  650. previousJobs: [],
  651. contract: emptyContract(), bankAccounts: [],
  652. socialSecurityRecords: [],
  653. workTypes: [],
  654. certificates: [],
  655. skills: [],
  656. files: [], remarks: '',
  657. attendanceRecords: [], performanceRecords: [], trainingRecords: [], rewardPunishmentRecords: [], awards: [], positionTransfers: [], contractChanges: [],
  658. leaveInfo: { enabled: false, position: '', department: '', leaveDate: '', leaveType: '', leaveReason: '', serviceYears: '', handoverStatus: '' }
  659. });
  660. export default {
  661. name: 'EmployeeArchiveForm',
  662. components: { EmployeeArchiveRecords, AptitudeDialog },
  663. mixins: [dictMixins],
  664. props: {
  665. visible: Boolean,
  666. employee: { type: Object, default: null },
  667. organizations: { type: Array, default: () => [] },
  668. existingNumbers: { type: Array, default: () => [] }
  669. },
  670. data() {
  671. const validatePhone = (rule, value, callback) => {
  672. if (!value || /^1\d{10}$/.test(value) || /^1\d{2}\*{4}\d{4}$/.test(value)) callback();
  673. else callback(new Error('请输入正确的 11 位手机号码'));
  674. };
  675. const validateIdCard = (rule, value, callback) => {
  676. if (this._skipFormValidate) return callback();
  677. const text = String(value || '').trim();
  678. if (!text) return callback();
  679. // 脱敏展示(如 4301********1234)允许通过
  680. if (/^\d{6}\*{8}[\dXx]{4}$/.test(text)) return callback();
  681. if (!/^\d{17}[\dXx]$/.test(text)) {
  682. return callback(new Error('请输入正确的身份证号码'));
  683. }
  684. const birth = text.slice(6, 14);
  685. const y = Number(birth.slice(0, 4));
  686. const m = Number(birth.slice(4, 6));
  687. const d = Number(birth.slice(6, 8));
  688. const date = new Date(y, m - 1, d);
  689. if (
  690. date.getFullYear() !== y ||
  691. date.getMonth() !== m - 1 ||
  692. date.getDate() !== d ||
  693. y < 1900 ||
  694. date.getTime() > Date.now()
  695. ) {
  696. return callback(new Error('请输入正确的身份证号码'));
  697. }
  698. const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
  699. const codes = '10X98765432';
  700. let sum = 0;
  701. for (let i = 0; i < 17; i += 1) sum += Number(text[i]) * weights[i];
  702. if (codes[sum % 11] !== text[17].toUpperCase()) {
  703. return callback(new Error('请输入正确的身份证号码'));
  704. }
  705. callback();
  706. };
  707. const validateNumber = (rule, value, callback) => {
  708. const duplicate = this.existingNumbers.some((item) => item === value && item !== this.employee?.employeeNo);
  709. if (duplicate) callback(new Error('员工编号已存在'));
  710. else callback();
  711. };
  712. return {
  713. activeTab: 'base', saving: false, fullscreen: false, form: emptyForm(), organizationTree: [],
  714. showWorkTypeSelect: false,
  715. workTypeLevelOptions: [
  716. { label: '初级', value: '1' },
  717. { label: '中级', value: '2' },
  718. { label: '高级', value: '3' }
  719. ],
  720. sections: [
  721. { name: 'base', label: '基本信息', subtitle: '身份/任职/联系', icon: 'el-icon-user' },
  722. { name: 'experience', label: '教育与履历', subtitle: '学习与工作经历', icon: 'el-icon-notebook-2' },
  723. { name: 'contract', label: '合同', subtitle: '劳动合同信息', icon: 'el-icon-document-checked' },
  724. { name: 'payroll', label: '薪酬与社保', subtitle: '银行/社保公积金', icon: 'el-icon-bank-card' },
  725. { name: 'qualification', label: '资质证照', subtitle: '工种/证照/技能', icon: 'el-icon-medal' },
  726. { name: 'records', label: '业务记录', subtitle: '考勤绩效与异动', icon: 'el-icon-tickets' }
  727. ],
  728. // 岗位设定列表(岗位下拉数据源)
  729. positionOptions: [],
  730. // 岗位序列下拉(多部门并集,来源 /fm/tmaindepartmentposition/sequence/list)
  731. sequenceOptions: [],
  732. sequenceLoading: false,
  733. // 岗位层级名 → 层级主键(部门岗位接口返回,提交 positionLevelId 用)
  734. levelIdByName: {},
  735. // HR 岗位分页真实主键集合(主数据 postId 如 1,4 不在此集合,禁止对其调 getById)
  736. hrPositionIdSet: {},
  737. // 岗位序列 → 层级主键,用于提交 positionLevelId / positionSequenceId
  738. sequenceLevelMap: {},
  739. // 强制重建隶属部门级联,避免 options 清空后内部 activePath 残留导致 node.level 报错
  740. deptCascaderKey: 0,
  741. // 岗位单选回显:用 key 重建,避免 options 后到时只显示 ID
  742. positionSelectKey: 0,
  743. positionLoading: false,
  744. // 直属上级:按隶属部门联动人员
  745. managerOptions: [],
  746. managerLoading: false,
  747. ethnicOptions: ['汉族', '土家族', '苗族', '侗族', '回族', '瑶族', '其他'],
  748. genderOptions: ['男', '女', '未知'],
  749. statusOptions: [],
  750. maritalOptions: ['未婚', '已婚', '离异', '丧偶'],
  751. educationOptions: ['小学', '初中', '高中', '大专', '本科', '硕士', '博士'],
  752. employmentOptions: [],
  753. staffingLevelOptions: [],
  754. factoryOptions: [],
  755. factoryLoading: false,
  756. socialSecurityOptions: ['四险', '四险一金', '五险', '五险一金', '六险一金'],
  757. contractTypeOptions: ['固定期限', '无固定期限', '以完成一定工作任务为期限', '劳务协议', '实习协议'],
  758. // 证照类型:取自 CERT_TYPE 键(与提交编码/回显反查同一数据源,新增类型只需改 CERT_TYPE)
  759. certificateTypeOptions: Object.keys(CERT_TYPE),
  760. skillCategoryOptions: ['专业技能', '办公技能', '语言技能', '特种操作技能', '其他'],
  761. skillLevelOptions: ['了解', '熟练', '精通', '初级', '中级', '高级'],
  762. organizationProps: { value: 'id', label: 'name', children: 'children', emitPath: false, checkStrictly: true },
  763. rules: {
  764. name: [{ required: true, message: '请输入员工姓名', trigger: 'blur' }],
  765. employeeNo: [{ required: true, message: '请输入工号', trigger: 'blur' }, { pattern: /^[A-Za-z0-9_-]{2,20}$/, message: '仅支持字母、数字、下划线和短横线', trigger: 'blur' }, { validator: validateNumber, trigger: 'blur' }],
  766. gender: [{
  767. validator: (rule, value, callback) => {
  768. if (this._skipFormValidate) return callback();
  769. if (!value) callback(new Error('请选择性别'));
  770. else callback();
  771. },
  772. trigger: 'change',
  773. }],
  774. groupId: [{
  775. required: true,
  776. validator: (rule, value, callback) => {
  777. if (this._skipFormValidate) return callback();
  778. if (value == null || value === '') callback(new Error('请选择所属机构'));
  779. else callback();
  780. },
  781. trigger: 'change',
  782. }],
  783. deptIds: [{
  784. required: true,
  785. validator: (rule, value, callback) => {
  786. if (this._skipFormValidate) return callback();
  787. if (!this.normalizeDeptIds(value).length) {
  788. callback(new Error('请选择隶属部门'));
  789. return;
  790. }
  791. callback();
  792. },
  793. trigger: 'change',
  794. }],
  795. status: [{
  796. validator: (rule, value, callback) => {
  797. if (this._skipFormValidate) return callback();
  798. if (!value) callback(new Error('请选择员工状态'));
  799. else callback();
  800. },
  801. trigger: 'change',
  802. }],
  803. hireDate: [{
  804. required: true,
  805. validator: (rule, value, callback) => {
  806. if (this._skipFormValidate) return callback();
  807. if (!value) callback(new Error('请选择入职时间'));
  808. else callback();
  809. },
  810. trigger: 'change',
  811. }],
  812. positionSequenceId: [{
  813. required: true,
  814. validator: (rule, value, callback) => {
  815. if (this._skipFormValidate) return callback();
  816. if (value == null || value === '') {
  817. callback(new Error('请选择岗位序列'));
  818. return;
  819. }
  820. callback();
  821. },
  822. trigger: 'change',
  823. }],
  824. positionId: [{
  825. required: true,
  826. validator: (rule, value, callback) => {
  827. if (this._skipFormValidate) return callback();
  828. if (value == null || value === '') {
  829. callback(new Error('请选择岗位'));
  830. return;
  831. }
  832. callback();
  833. },
  834. trigger: 'change',
  835. }],
  836. phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }, { validator: validatePhone, trigger: 'blur' }],
  837. email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
  838. idCard: [{ validator: validateIdCard, trigger: ['blur', 'change'] }],
  839. }
  840. };
  841. },
  842. computed: {
  843. dialogVisible: {
  844. get() { return this.visible; },
  845. set(value) {
  846. if (!value) this.saving = false;
  847. this.$emit('update:visible', value);
  848. }
  849. },
  850. isEdit() { return Boolean(this.employee?.id); },
  851. nameInitial() { return (this.form.name || '新').slice(-1); },
  852. avatarStyle() { const tones = ['#1890ff', '#13a471', '#7c5ce7', '#d08716', '#d25567']; const code = [...(this.form.name || '新')].reduce((sum, char) => sum + char.charCodeAt(0), 0); return { '--avatar-color': tones[code % tones.length] }; },
  853. currentSectionLabel() { return this.sections.find((item) => item.name === this.activeTab)?.label || ''; },
  854. // 所属机构:只取登录缓存所属机构(sessionStorage.loginChangeGroupVOList);
  855. // 登录缓存缺失时(兜底选项只有 id 没有名称,会显示成 "1"),回落到组织树里的
  856. // 集团/公司节点(type 10/20),保证下拉可读、可选(如「中赢合力」);
  857. // 编辑老数据时原机构不在选项里,则补一条兜底选项,避免下拉只显示 ID
  858. legalEntities() {
  859. let list = getOrgOptions().map((item) => ({
  860. id: String(item.id),
  861. name: item.name
  862. }));
  863. if (!list.some((item) => item.name)) {
  864. const tree = this.organizationTree.length ? this.organizationTree : this.organizations;
  865. const companies = flattenOrganizationTree(tree || [])
  866. .filter((item) => ['10', '20'].includes(String(item.type)))
  867. .map((item) => ({ id: String(item.id), name: item.name, type: item.type }));
  868. if (companies.length) list = companies;
  869. }
  870. const currentId = this.form.groupId != null && this.form.groupId !== '' ? String(this.form.groupId) : '';
  871. if (currentId && !list.some((item) => String(item.id) === currentId)) {
  872. const tree = this.organizationTree.length ? this.organizationTree : this.organizations;
  873. const hit = flattenOrganizationTree(tree || []).find((item) => String(item.id) === currentId);
  874. list.unshift({
  875. id: currentId,
  876. name: hit?.name || this.form.legalEntity || this.form.company || currentId,
  877. type: hit?.type || '20'
  878. });
  879. }
  880. return list;
  881. },
  882. // 隶属部门:只跟所属机构关联(与工厂相互独立),选完机构即可选部门。
  883. // 组织树里部门挂在工厂下,这里把工厂层剥掉,把各部门提级展示
  884. deptOptions() {
  885. const tree = this.organizationTree.length ? this.organizationTree : this.organizations;
  886. if (!this.form.groupId) return [];
  887. const find = (nodes, id) => {
  888. for (const node of nodes || []) {
  889. if (!node) continue;
  890. if (String(node.id) === String(id)) return node;
  891. const matched = find(node.children || [], id);
  892. if (matched) return matched;
  893. }
  894. return null;
  895. };
  896. const orgNode = find(tree || [], this.form.groupId);
  897. if (!orgNode) return [];
  898. const isFactoryNode = (node) => ['40', 'factory'].includes(String(node.type));
  899. // 工厂只作为归属信息:其下的部门直接提到机构层,选任一工厂的部门都能直接选
  900. const collectDeptRoots = (nodes) => {
  901. const list = [];
  902. (nodes || []).forEach((node) => {
  903. if (!node) return;
  904. if (isFactoryNode(node)) {
  905. list.push(...collectDeptRoots(node.children || []));
  906. return;
  907. }
  908. list.push(node);
  909. });
  910. return list;
  911. };
  912. const sanitize = (nodes) =>
  913. (nodes || [])
  914. .filter(Boolean)
  915. .map((node) => {
  916. const children = sanitize(node.children || []);
  917. const next = {
  918. ...node,
  919. id: node.id != null ? String(node.id) : node.id,
  920. name: node.name || '',
  921. };
  922. if (children.length) next.children = children;
  923. else delete next.children;
  924. return next;
  925. });
  926. return sanitize(collectDeptRoots(orgNode.children || []));
  927. },
  928. deptCascaderProps() {
  929. return { ...this.organizationProps, multiple: true };
  930. },
  931. hasDept() {
  932. return this.normalizeDeptIds(this.form.deptIds).length > 0;
  933. },
  934. hasPositionSequence() {
  935. return Boolean(this.form.positionSequenceId);
  936. },
  937. requiredProgress() {
  938. const keys = ['name', 'employeeNo', 'gender', 'groupId', 'status', 'hireDate', 'positionId', 'phone'];
  939. return { base: `${keys.filter((key) => { const value = this.form[key]; return Array.isArray(value) ? value.length : Boolean(value); }).length}/${keys.length}` };
  940. },
  941. sectionProgress() {
  942. const count = (keys) => `${keys.filter((key) => { const value = this.form[key]; return Array.isArray(value) ? value.length : Boolean(value); }).length}/${keys.length}`;
  943. return {
  944. base: count(['name', 'employeeNo', 'groupId', 'factory', 'deptIds', 'status', 'hireDate', 'positionId', 'employmentType', 'staffingLevel', 'gender', 'birthDate', 'idCard', 'ethnic', 'politicalStatus', 'maritalStatus', 'nativePlace', 'education', 'school', 'phone', 'email', 'wechat', 'currentAddress', 'registeredAddress', 'workAddress', 'emergencyContact', 'emergencyPhone', 'keyProcess']),
  945. experience: count(['educations', 'previousJobs']),
  946. contract: `${[this.form.contract.number || this.form.contract.name, this.form.contract.startDate, this.form.contract.endDate].filter(Boolean).length}/3`,
  947. payroll: count(['bankAccounts', 'socialSecurityRecords']),
  948. qualification: count(['workTypes', 'certificates', 'skills', 'files']),
  949. records: `${['attendanceRecords', 'performanceRecords', 'trainingRecords', 'rewardPunishmentRecords', 'awards', 'positionTransfers', 'contractChanges'].reduce((sum, key) => sum + this.form[key].length, 0) + (this.form.leaveInfo.enabled ? 1 : 0)}`
  950. };
  951. },
  952. completionRate() {
  953. const values = [this.form.name, this.form.employeeNo, this.form.groupId, this.form.positionId, this.form.status, this.form.hireDate, this.form.phone, this.form.gender, this.form.email, this.form.birthDate, this.form.education, this.form.school, this.form.currentAddress, this.form.contract.number, this.form.bankAccount, this.form.insuredDate, this.form.skills.length, this.form.files.length];
  954. return Math.round(values.filter(Boolean).length / values.length * 100);
  955. },
  956. contractState() { return this.form.contract.name ? (this.form.contract.endDate ? '合同资料已填写' : '待补结束日期') : '待录入合同'; },
  957. selectedWorkTypeIds() {
  958. return (this.form.workTypes || [])
  959. .map((item) => item.id)
  960. .filter((id) => id != null && id !== '');
  961. },
  962. /** 隶属部门级联的占位符文案(启用后由内层搜索框渲染,见 syncDeptCascaderPlaceholder) */
  963. deptCascaderPlaceholder() {
  964. return this.form.groupId ? '请选择隶属部门(可多选)' : '请先选择所属机构';
  965. },
  966. /** 证照「津贴单位」选项:前端写死(元/月、元/季、元/年),不走接口字典 */
  967. allowanceUnitOptions() {
  968. return positionAllowanceUnitOptions;
  969. }
  970. },
  971. created() {
  972. this.requestDict('工种类型');
  973. this.loadStatusOptions();
  974. },
  975. mounted() {
  976. this.syncDeptCascaderPlaceholder();
  977. },
  978. updated() {
  979. this.syncDeptCascaderPlaceholder();
  980. },
  981. watch: {
  982. visible(value) { if (value) this.loadForm(); }
  983. },
  984. methods: {
  985. /** 员工状态下拉选项:接口字典 hr_user_status(label=中文名,value=状态编码)。
  986. * 下拉 value 用字典编码,回显按状态码匹配、提交直接传编码;
  987. * 字典为空或加载失败时,用 EMPLOYEE_STATUS(中文→数字码)兜底,保证一定能选。 */
  988. async loadStatusOptions() {
  989. const fallback = Object.entries(EMPLOYEE_STATUS).map(([label, code]) => ({ label, value: String(code) }));
  990. try {
  991. const items = await getDictByCode('hr_user_status');
  992. const options = (Array.isArray(items) ? items : [])
  993. .map((item) => {
  994. const label = String(item.label ?? item.name ?? '').trim();
  995. const value = item.value ?? item.id ?? item.key;
  996. if (!label || value == null || value === '') return null;
  997. return { label, value: String(value) };
  998. })
  999. .filter(Boolean);
  1000. this.statusOptions = options.length ? options : fallback;
  1001. } catch (error) {
  1002. this.statusOptions = fallback;
  1003. }
  1004. // 字典晚于表单加载时,把已回填的中文/旧值重新对齐成字典 value
  1005. if (this.form.status) {
  1006. const hit = this.statusOptions.find(
  1007. (opt) => String(opt.value) === String(this.form.status) || opt.label === this.form.status,
  1008. );
  1009. if (hit) this.form.status = hit.value;
  1010. }
  1011. },
  1012. /** 回显员工状态:优先用后端状态码匹配字典 value,其次按中文 label 匹配 */
  1013. resolveStatusForForm(source) {
  1014. const code = source.statusCode ?? source.raw?.status;
  1015. const byCode = code != null && code !== ''
  1016. ? this.statusOptions.find((opt) => String(opt.value) === String(code))
  1017. : null;
  1018. if (byCode) return byCode.value;
  1019. const byLabel = this.statusOptions.find(
  1020. (opt) => opt.label === source.formStatus || opt.label === source.status,
  1021. );
  1022. if (byLabel) return byLabel.value;
  1023. return this.statusOptions.length ? this.statusOptions[0].value : (source.formStatus || '');
  1024. },
  1025. loadForm() {
  1026. this._skipFormValidate = true;
  1027. this.activeTab = 'base';
  1028. const source = this.employee ? JSON.parse(JSON.stringify(this.employee)) : {};
  1029. const empty = emptyForm();
  1030. this.form = {
  1031. ...empty, ...source,
  1032. groupId: source.groupId != null && source.groupId !== ''
  1033. ? String(source.groupId)
  1034. : (source.legalEntityId != null && source.legalEntityId !== '' ? String(source.legalEntityId) : null),
  1035. legalEntity: source.legalEntity || source.company || '',
  1036. company: source.company || source.legalEntity || '',
  1037. status: this.resolveStatusForForm(source),
  1038. hireDate: source.hireDate || source.joinDate || '',
  1039. factoryId: source.factoryId != null && source.factoryId !== '' ? String(source.factoryId) : null,
  1040. factory: source.factory || source.factoryName || '',
  1041. deptIds: this.normalizeDeptIds(source.deptIds?.length ? source.deptIds : source.deptId),
  1042. deptName: source.deptName || '',
  1043. managerId: source.managerId != null && source.managerId !== '' ? String(source.managerId) : null,
  1044. manager: source.manager || '',
  1045. positionIds: this.normalizePositionIds(source).slice(0, 1),
  1046. positionBindings: Array.isArray(source.positionBindings)
  1047. ? source.positionBindings.slice(0, 1)
  1048. : [],
  1049. positionId: this.normalizePositionIds(source)[0] || null,
  1050. position: source.position || source.postName || '',
  1051. // 岗位序列:优先员工主数据字段,其次岗位绑定项
  1052. positionSequenceId: this.pickSequenceId(source),
  1053. positionSequence: source.positionSequence || source.positionSequenceName || '',
  1054. positionSequenceName: source.positionSequenceName || source.positionSequence || '',
  1055. contract: { ...emptyContract(), ...(source.contract || {}) },
  1056. leaveInfo: { ...empty.leaveInfo, ...(source.leaveInfo || {}) },
  1057. familyMembers: source.familyMembers || [], files: toDocIds(source.files),
  1058. // 教育经历:兼容旧的单条 education/degree/school 字段
  1059. educations: source.educations?.length
  1060. ? source.educations
  1061. : (source.education
  1062. ? [{ education: source.education || '本科', degree: source.degree || '', graduationDate: source.graduationDate || '', school: source.school || '', major: source.major || '' }]
  1063. : []),
  1064. // 薪酬银行:兼容旧的单条 bankName/bankAccount 字段
  1065. bankAccounts: source.bankAccounts?.length
  1066. ? source.bankAccounts
  1067. : (source.bankName || source.bankAccount
  1068. ? [{ bankName: source.bankName || '', bankBranch: source.bankBranch || '', bankAccount: source.bankAccount || '', bankAccountName: source.bankAccountName || '' }]
  1069. : []),
  1070. // 工作履历:兼容旧的 dateRange
  1071. previousJobs: (source.previousJobs || []).map((item) => ({
  1072. ...item,
  1073. startDate: item.startDate || item.dateRange?.[0] || '',
  1074. endDate: item.endDate || item.dateRange?.[1] || '',
  1075. })),
  1076. // 技能:兼容旧字符串数组
  1077. skills: (source.skills || []).map((item) => (typeof item === 'string' ? { name: item, category: '', level: '', years: 0, attachment: [], remark: '' } : { ...item, attachment: toDocIds(item.attachment) })),
  1078. // 证照:岗位津贴 0 否 / 1 是,金额转数字,兼容接口返回字符串
  1079. certificates: (source.certificates || []).map((item) => ({
  1080. ...item,
  1081. hasPositionAllowance: Number(item.hasPositionAllowance) === 1 ? 1 : 0,
  1082. positionAllowanceAmount: item.positionAllowanceAmount === '' || item.positionAllowanceAmount == null
  1083. ? null
  1084. : Number(item.positionAllowanceAmount),
  1085. // 津贴单位是写死的 month/quarter/year,接口若返回数字要先转字符串,否则下拉匹配不上;
  1086. // 有津贴但接口没给单位时补默认「元/月」
  1087. positionAllowanceUnit: item.positionAllowanceUnit === '' || item.positionAllowanceUnit == null
  1088. ? (Number(item.hasPositionAllowance) === 1 ? this.defaultAllowanceUnit() : '')
  1089. : String(item.positionAllowanceUnit),
  1090. attachment: toDocIds(item.attachment),
  1091. })),
  1092. // 社保公积金:是否启用 0 否 / 1 是(接口可能返回字符串,统一成数字)
  1093. socialSecurityRecords: (source.socialSecurityRecords || []).map((item) => ({
  1094. ...item,
  1095. isEnabled: Number(item.isEnabled) === 1 ? 1 : 0,
  1096. })),
  1097. workTypes: (source.workTypes || []).map((item) => this.mapProfessionToWorkType(item)),
  1098. attendanceRecords: source.attendanceRecords || [], performanceRecords: source.performanceRecords || [],
  1099. trainingRecords: (source.trainingRecords || []).map((item) => ({ ...item, attachment: toDocIds(item.attachment) })),
  1100. rewardPunishmentRecords: (source.rewardPunishmentRecords || []).map((item) => ({ ...item, attachment: toDocIds(item.attachment) })), awards: source.awards || [], positionTransfers: source.positionTransfers || [], contractChanges: source.contractChanges || []
  1101. };
  1102. this.loadEmploymentTypeOptions();
  1103. this.loadSequences()
  1104. .then(() => this.loadPositionSequences())
  1105. .then(() => this.resolveSequenceForEdit())
  1106. .then(() => (this.form.positionSequenceId ? this.loadDepartmentPositions() : null))
  1107. .then(() => (this.form.positionId ? this.loadPositionNatureAndLevels({ prime: true }) : null))
  1108. .then(() => {
  1109. this.remapLegacyPositionIds();
  1110. this.mergeBoundPositionOptions();
  1111. // 部门岗位接口已给出层级下拉时,不再用岗位自带层级覆盖
  1112. if (!this.staffingLevelOptions.length) this.syncStaffingLevelsFromPosition();
  1113. });
  1114. this.loadOrganizations().then(() => {
  1115. /* 新增员工默认带出所属机构(登录机构列表第一个) */
  1116. this.applyDefaultLegalEntity();
  1117. this.normalizeDeptAgainstOptions();
  1118. this.loadFactories().then(() => this.syncFactoryDisplay());
  1119. this.loadManagerOptions({ keepSelection: true });
  1120. });
  1121. this.syncAge();
  1122. if (!this.employee) this.generateEmployeeNo();
  1123. this.$nextTick(() => {
  1124. this.$refs.form?.clearValidate();
  1125. this.$nextTick(() => {
  1126. this._skipFormValidate = false;
  1127. });
  1128. });
  1129. },
  1130. normalizePositionIds(source = {}) {
  1131. let ids = [];
  1132. if (Array.isArray(source.positionBindings) && source.positionBindings.length) {
  1133. // 主岗优先,避免回显顺序错乱
  1134. ids = [...source.positionBindings]
  1135. .sort((a, b) => Number(b.primaryFlag || 0) - Number(a.primaryFlag || 0))
  1136. .map((item) => (item.positionId != null ? String(item.positionId) : ''))
  1137. .filter(Boolean);
  1138. } else if (Array.isArray(source.positionIds) && source.positionIds.length) {
  1139. ids = source.positionIds.map(String);
  1140. } else if (source.positionId != null && source.positionId !== '') {
  1141. ids = [String(source.positionId)];
  1142. } else if (source.postId != null && source.postId !== '') {
  1143. ids = [String(source.postId)];
  1144. }
  1145. // 兼容 "id1,id2" 整串
  1146. return Array.from(
  1147. new Set(
  1148. ids
  1149. .flatMap((id) => String(id).split(/[,,]/))
  1150. .map((item) => item.trim())
  1151. .filter(Boolean),
  1152. ),
  1153. );
  1154. },
  1155. positionFallbackNames() {
  1156. return String(this.form.position || '')
  1157. .split(/[,,、]/)
  1158. .map((item) => item.trim())
  1159. .filter(Boolean);
  1160. },
  1161. positionOptionLabel(item) {
  1162. return item.positionName || item.name || (item.id ? `岗位 ${item.id}` : '未命名岗位');
  1163. },
  1164. dedupePositionOptions(list = []) {
  1165. const map = new Map();
  1166. list.forEach((item) => {
  1167. if (item == null || item.id == null || item.id === '') return;
  1168. const id = String(item.id);
  1169. if (!map.has(id)) {
  1170. map.set(id, { ...item, id });
  1171. return;
  1172. }
  1173. // 后写入的若带名称则补全
  1174. const prev = map.get(id);
  1175. map.set(id, {
  1176. ...prev,
  1177. ...item,
  1178. id,
  1179. positionName: item.positionName || item.name || prev.positionName || prev.name,
  1180. name: item.name || item.positionName || prev.name || prev.positionName
  1181. });
  1182. });
  1183. return Array.from(map.values());
  1184. },
  1185. mergeBoundPositionOptions() {
  1186. const fallbackNames = this.positionFallbackNames();
  1187. const bindings = this.form.positionBindings || [];
  1188. const selectedIds = (this.form.positionIds || []).map(String).filter(Boolean);
  1189. const extras = [];
  1190. const pushExtra = (positionId, positionName, binding = {}) => {
  1191. if (!positionId) return;
  1192. extras.push({
  1193. id: String(positionId),
  1194. positionName: positionName || binding.positionName || '',
  1195. name: positionName || binding.positionName || '',
  1196. positionSequence: binding.positionSequenceName || '',
  1197. positionSequenceName: binding.positionSequenceName || '',
  1198. positionSequenceId: binding.positionSequenceId || '',
  1199. level: binding.positionLevelName || '',
  1200. positionLevel: binding.positionLevelName || '',
  1201. positionLevelId: binding.positionLevelId || '',
  1202. sequenceId: binding.positionSequenceId || '',
  1203. });
  1204. };
  1205. selectedIds.forEach((id, index) => {
  1206. const binding = bindings.find((item) => String(item.positionId) === id) || {};
  1207. const name =
  1208. binding.positionName ||
  1209. fallbackNames[index] ||
  1210. '';
  1211. pushExtra(id, name, binding);
  1212. });
  1213. bindings.forEach((item, index) => {
  1214. if (item.positionId == null || item.positionId === '') return;
  1215. if (selectedIds.includes(String(item.positionId))) return;
  1216. pushExtra(
  1217. item.positionId,
  1218. item.positionName || fallbackNames[index] || '',
  1219. item,
  1220. );
  1221. });
  1222. // 已选岗位放到前面,保证 el-select 标签优先匹配到名称
  1223. this.positionOptions = this.dedupePositionOptions([
  1224. ...extras,
  1225. ...(this.positionOptions || []),
  1226. ]);
  1227. this.syncPositionDisplay();
  1228. this.refreshPositionSelectEcho();
  1229. },
  1230. /**
  1231. * 主数据短 postId(如 1,4)按名称映射;长整型视为 HR 岗位主键,缺失时保留并补名称,禁止误删导致回显成裸 ID
  1232. */
  1233. remapLegacyPositionIds() {
  1234. const ids = (this.form.positionIds || []).map(String).filter(Boolean);
  1235. if (!ids.length) return;
  1236. const fallbackNames = this.positionFallbackNames();
  1237. const hrOptions = (this.positionOptions || []).filter(
  1238. (item) => item && this.hrPositionIdSet[String(item.id)],
  1239. );
  1240. const findHrByName = (name) => {
  1241. const text = String(name || '').trim();
  1242. if (!text) return null;
  1243. return (
  1244. hrOptions.find((item) => {
  1245. const label = String(item.positionName || item.name || '').trim();
  1246. return label === text;
  1247. }) || null
  1248. );
  1249. };
  1250. const isLikelyHrPositionId = (id) => /^\d{15,}$/.test(String(id).trim());
  1251. const nextIds = [];
  1252. const nextBindings = [];
  1253. let changed = false;
  1254. ids.forEach((key, index) => {
  1255. const binding =
  1256. (this.form.positionBindings || []).find((item) => String(item.positionId) === key) || {};
  1257. const opt = this.positionOptions.find((item) => String(item.id) === key) || {};
  1258. let name =
  1259. binding.positionName ||
  1260. opt.positionName ||
  1261. opt.name ||
  1262. fallbackNames[index] ||
  1263. '';
  1264. if (this.hrPositionIdSet[key] || isLikelyHrPositionId(key)) {
  1265. if (!name && fallbackNames[index]) {
  1266. name = fallbackNames[index];
  1267. changed = true;
  1268. }
  1269. nextIds.push(key);
  1270. nextBindings.push({
  1271. ...binding,
  1272. positionId: key,
  1273. positionName: name || binding.positionName || '',
  1274. primaryFlag: nextIds.length === 1 ? 1 : 0,
  1275. });
  1276. return;
  1277. }
  1278. const matched = findHrByName(name);
  1279. if (matched) {
  1280. const realId = String(matched.id);
  1281. if (!nextIds.includes(realId)) {
  1282. nextIds.push(realId);
  1283. nextBindings.push({
  1284. ...binding,
  1285. positionId: realId,
  1286. positionName: matched.positionName || matched.name || name,
  1287. primaryFlag: nextIds.length === 1 ? 1 : 0,
  1288. });
  1289. }
  1290. changed = true;
  1291. return;
  1292. }
  1293. // 仅丢弃无法识别的短旧 postId
  1294. changed = true;
  1295. });
  1296. if (!changed && nextIds.join(',') === ids.join(',')) {
  1297. // 名称可能仍需回填
  1298. const needNameFill = nextBindings.some((item, index) => !item.positionName && fallbackNames[index]);
  1299. if (!needNameFill) return;
  1300. }
  1301. this.form.positionIds = nextIds.slice(0, 1);
  1302. this.form.positionBindings = nextBindings.slice(0, 1).map((item) => ({
  1303. ...item,
  1304. positionName: item.positionName || fallbackNames[0] || '',
  1305. primaryFlag: 1,
  1306. }));
  1307. this.form.positionId = this.form.positionIds[0] || null;
  1308. this.form.position =
  1309. this.form.positionBindings[0]?.positionName || this.form.position;
  1310. this.syncPositionDisplay();
  1311. },
  1312. /** Element 多选在 options 异步到达后需重建一次,否则标签可能只剩裸 ID */
  1313. refreshPositionSelectEcho() {
  1314. const id = this.form.positionId != null && this.form.positionId !== ''
  1315. ? String(this.form.positionId)
  1316. : (this.form.positionIds || []).map(String).filter(Boolean)[0] || null;
  1317. this.form.positionId = id;
  1318. this.form.positionIds = id ? [id] : [];
  1319. this.positionSelectKey += 1;
  1320. this.$nextTick(() => {
  1321. this.syncPositionDisplay();
  1322. this.$refs.form?.clearValidate('positionId');
  1323. });
  1324. },
  1325. syncPositionDisplay() {
  1326. const fallbackNames = this.positionFallbackNames();
  1327. const key = this.form.positionId != null && this.form.positionId !== ''
  1328. ? String(this.form.positionId)
  1329. : '';
  1330. const selected = key
  1331. ? (() => {
  1332. const opt = this.positionOptions.find((item) => String(item.id) === key) || {};
  1333. const binding =
  1334. (this.form.positionBindings || []).find((item) => String(item.positionId) === key) || {};
  1335. const meta = this.resolvePositionLevelMeta(opt, {
  1336. ...binding,
  1337. positionLevelName: this.form.staffingLevel || binding.positionLevelName,
  1338. });
  1339. return [{
  1340. positionId: key,
  1341. positionName:
  1342. opt.positionName ||
  1343. opt.name ||
  1344. binding.positionName ||
  1345. fallbackNames[0] ||
  1346. '',
  1347. positionLevelId: meta.positionLevelId,
  1348. positionLevelName: meta.positionLevelName,
  1349. positionSequenceId: meta.positionSequenceId,
  1350. positionSequenceName: meta.positionSequenceName,
  1351. primaryFlag: 1,
  1352. }];
  1353. })()
  1354. : [];
  1355. this.form.positionBindings = selected;
  1356. this.form.positionIds = key ? [key] : [];
  1357. this.form.position = selected[0]?.positionName || '';
  1358. this.form.positionId = selected[0]?.positionId || null;
  1359. this.form.level = selected[0]?.positionLevelName || this.form.level;
  1360. if (selected[0]?.positionLevelName) {
  1361. this.form.staffingLevel = selected[0].positionLevelName;
  1362. }
  1363. },
  1364. isNumericId(value) {
  1365. return value != null && value !== '' && /^-?\d+$/.test(String(value).trim());
  1366. },
  1367. findSequenceMeta(sequenceId = '', sequenceName = '') {
  1368. const map = this.sequenceLevelMap || {};
  1369. const name = String(sequenceName || '').trim();
  1370. if (name && map[name]) return map[name];
  1371. const sid = this.isNumericId(sequenceId) ? String(sequenceId).trim() : '';
  1372. if (sid && map[`id:${sid}`]) return map[`id:${sid}`];
  1373. if (sid) {
  1374. return Object.values(map).find((item) => String(item.sequenceId) === sid) || null;
  1375. }
  1376. if (name) {
  1377. return (
  1378. Object.values(map).find(
  1379. (item) => String(item.sequenceName || '').trim() === name,
  1380. ) || null
  1381. );
  1382. }
  1383. return null;
  1384. },
  1385. /**
  1386. * 人员岗位关联必填:positionLevelId / positionSequenceId
  1387. * 岗位分页通常只有序列名与层级文案,需用岗位序列接口映射主键
  1388. */
  1389. resolvePositionLevelMeta(opt = {}, binding = {}) {
  1390. let positionSequenceId = binding.positionSequenceId || opt.sequenceId || opt.positionSequenceId || '';
  1391. let positionSequenceName = String(
  1392. binding.positionSequenceName || opt.positionSequence || opt.positionSequenceName || '',
  1393. ).trim();
  1394. let positionLevelId = binding.positionLevelId || opt.positionLevelId || '';
  1395. let positionLevelName =
  1396. binding.positionLevelName || opt.positionLevel || opt.level || '';
  1397. // 部门岗位接口带回的「层级名 → 主键」优先,避免只认序列层级表
  1398. if (!this.isNumericId(positionLevelId) && positionLevelName) {
  1399. const mapped = this.levelIdByName[String(positionLevelName).trim()];
  1400. if (this.isNumericId(mapped)) positionLevelId = String(mapped).trim();
  1401. }
  1402. const levelNames = [];
  1403. const pushName = (value) => {
  1404. const text = String(value || '').trim();
  1405. if (text && !levelNames.includes(text)) levelNames.push(text);
  1406. };
  1407. (opt.positionLevels || []).forEach(pushName);
  1408. pushName(positionLevelName);
  1409. pushName(opt.positionLevel);
  1410. pushName(opt.level);
  1411. const seqMeta = this.findSequenceMeta(positionSequenceId, positionSequenceName);
  1412. if (seqMeta) {
  1413. if (!positionSequenceName) positionSequenceName = seqMeta.sequenceName || '';
  1414. if (!this.isNumericId(positionSequenceId) && this.isNumericId(seqMeta.sequenceId)) {
  1415. positionSequenceId = String(seqMeta.sequenceId).trim();
  1416. }
  1417. }
  1418. const levels = seqMeta?.levels || [];
  1419. if (!this.isNumericId(positionLevelId) && levels.length) {
  1420. const matched =
  1421. levels.find((item) =>
  1422. levelNames.some(
  1423. (name) =>
  1424. name === item.label ||
  1425. name === item.value ||
  1426. String(item.id) === name,
  1427. ),
  1428. ) || levels[0];
  1429. if (matched && this.isNumericId(matched.id)) {
  1430. positionLevelId = String(matched.id).trim();
  1431. positionLevelName = matched.label || matched.value || positionLevelName;
  1432. }
  1433. } else if (this.isNumericId(positionLevelId) && !positionLevelName && levels.length) {
  1434. const matched = levels.find((item) => String(item.id) === String(positionLevelId));
  1435. if (matched) positionLevelName = matched.label || matched.value || '';
  1436. }
  1437. return {
  1438. positionSequenceId: this.isNumericId(positionSequenceId) ? String(positionSequenceId).trim() : '',
  1439. positionSequenceName: positionSequenceName || '',
  1440. positionLevelId: this.isNumericId(positionLevelId) ? String(positionLevelId).trim() : '',
  1441. positionLevelName: positionLevelName || '',
  1442. };
  1443. },
  1444. /** 写入 sequenceLevelMap:按序列名 + id:主键 双索引 */
  1445. putSequenceMeta(map, item = {}) {
  1446. const name = String(item.sequence || item.label || item.name || '').trim();
  1447. const sequenceId = this.isNumericId(item.id) ? String(item.id).trim() : '';
  1448. if (!name && !sequenceId) return;
  1449. const levels = (item.levelList || item.children || [])
  1450. .map((level) => {
  1451. const id = this.isNumericId(level.id) ? String(level.id).trim() : '';
  1452. const label = level.label || level.value || level.level || level.positionLevel || '';
  1453. return { id, label, value: label };
  1454. })
  1455. .filter((level) => level.id);
  1456. const entry = { sequenceId, sequenceName: name, levels };
  1457. if (name) {
  1458. const prev = map[name];
  1459. map[name] = {
  1460. ...(prev || {}),
  1461. ...entry,
  1462. levels: levels.length ? levels : (prev?.levels || []),
  1463. sequenceId: sequenceId || prev?.sequenceId || '',
  1464. };
  1465. }
  1466. if (sequenceId) map[`id:${sequenceId}`] = entry;
  1467. },
  1468. async loadSequences() {
  1469. const map = {};
  1470. try {
  1471. // 与岗位档案编辑一致:page 接口 levelList 带层级主键
  1472. const page = await getPositionSequencePage({ pageNum: 1, size: 1000, status: '1' });
  1473. (page?.list || []).forEach((item) => this.putSequenceMeta(map, item));
  1474. } catch (error) {
  1475. // page 失败时再走列表接口
  1476. }
  1477. if (!Object.keys(map).length) {
  1478. try {
  1479. const list = await getPositionSequenceList();
  1480. (Array.isArray(list) ? list : []).forEach((item) => this.putSequenceMeta(map, item));
  1481. } catch (error) {
  1482. // ignore
  1483. }
  1484. }
  1485. this.sequenceLevelMap = map;
  1486. },
  1487. enrichSelectedPositions() {
  1488. this.remapLegacyPositionIds();
  1489. this.mergeBoundPositionOptions();
  1490. },
  1491. /** 保存前确保每岗都有序列/层级主键:本地映射 → 缺则拉 getById 再映射 */
  1492. async ensurePositionMetaForSave() {
  1493. this.enrichSelectedPositions();
  1494. const missing = (this.form.positionBindings || []).filter(
  1495. (item) => !item.positionLevelId || !item.positionSequenceId,
  1496. );
  1497. if (!missing.length) return true;
  1498. await Promise.all(
  1499. missing.map(async (item) => {
  1500. const key = String(item.positionId || '');
  1501. if (!key) return;
  1502. try {
  1503. const detail = await Promise.race([
  1504. getPositionById(key),
  1505. new Promise((_, reject) =>
  1506. setTimeout(() => reject(new Error('岗位详情超时')), 8000),
  1507. ),
  1508. ]);
  1509. if (!detail) return;
  1510. // 若序列层级表里还没有该序列,按序列名补拉层级
  1511. const seqName = detail.positionSequence || '';
  1512. if (seqName && !this.findSequenceMeta('', seqName)?.levels?.length) {
  1513. try {
  1514. const levels = await getPositionSequenceLevels(seqName);
  1515. const seqMeta = this.findSequenceMeta('', seqName);
  1516. const sequenceId = seqMeta?.sequenceId || '';
  1517. const mapped = (Array.isArray(levels) ? levels : []).map((level) => ({
  1518. id: level.id != null ? String(level.id) : '',
  1519. label: level.level || level.levelName || level.label || '',
  1520. value: level.level || level.levelName || level.value || '',
  1521. })).filter((level) => this.isNumericId(level.id));
  1522. if (seqName) {
  1523. this.$set(this.sequenceLevelMap, seqName, {
  1524. sequenceId,
  1525. sequenceName: seqName,
  1526. levels: mapped,
  1527. });
  1528. }
  1529. } catch (error) {
  1530. // ignore
  1531. }
  1532. }
  1533. const merged = {
  1534. id: key,
  1535. positionName: detail.positionName || detail.name || item.positionName || '',
  1536. name: detail.name || detail.positionName || item.positionName || '',
  1537. positionSequence: detail.positionSequence || '',
  1538. positionSequenceName: detail.positionSequence || '',
  1539. sequenceId: detail.sequenceId != null ? String(detail.sequenceId) : '',
  1540. positionSequenceId: detail.sequenceId != null ? String(detail.sequenceId) : '',
  1541. positionLevel: detail.positionLevel || '',
  1542. positionLevels: detail.positionLevels || [],
  1543. };
  1544. // 用序列名反查序列主键
  1545. if (!merged.sequenceId && merged.positionSequence) {
  1546. const seqMeta = this.findSequenceMeta('', merged.positionSequence);
  1547. if (seqMeta?.sequenceId) {
  1548. merged.sequenceId = seqMeta.sequenceId;
  1549. merged.positionSequenceId = seqMeta.sequenceId;
  1550. }
  1551. }
  1552. this.positionOptions = this.dedupePositionOptions([
  1553. merged,
  1554. ...(this.positionOptions || []),
  1555. ]);
  1556. } catch (error) {
  1557. // 单岗失败不阻断其余岗
  1558. }
  1559. }),
  1560. );
  1561. this.syncPositionDisplay();
  1562. const stillMissing = (this.form.positionBindings || []).find(
  1563. (item) => !item.positionLevelId || !item.positionSequenceId,
  1564. );
  1565. if (stillMissing) {
  1566. this.activeTab = 'base';
  1567. this.$message.warning(
  1568. `岗位「${stillMissing.positionName || stillMissing.positionId}」缺少岗位序列或层级配置,请在岗位档案中维护后再保存`,
  1569. );
  1570. return false;
  1571. }
  1572. return true;
  1573. },
  1574. /** 强制关闭:复位 loading,并清掉可能残留的 Element 遮罩 */
  1575. closeDialog() {
  1576. this.saving = false;
  1577. this.dialogVisible = false;
  1578. this.clearOrphanOverlays();
  1579. },
  1580. handleBeforeClose(done) {
  1581. this.saving = false;
  1582. if (typeof done === 'function') done();
  1583. this.$nextTick(() => this.clearOrphanOverlays());
  1584. },
  1585. clearOrphanOverlays() {
  1586. this.$nextTick(() => {
  1587. try {
  1588. const openDialog = Array.from(document.querySelectorAll('.el-dialog__wrapper')).some((el) => {
  1589. if (!el || el.style.display === 'none') return false;
  1590. const style = window.getComputedStyle(el);
  1591. return style.display !== 'none' && style.visibility !== 'hidden';
  1592. });
  1593. if (openDialog) return;
  1594. document.querySelectorAll('body > .v-modal').forEach((el) => el.remove());
  1595. document.body.classList.remove('el-popup-parent--hidden');
  1596. if (document.body.style.overflow === 'hidden') {
  1597. document.body.style.removeProperty('overflow');
  1598. }
  1599. } catch (error) {
  1600. // ignore
  1601. }
  1602. });
  1603. },
  1604. toggleFullscreen() {
  1605. this.fullscreen = !this.fullscreen;
  1606. },
  1607. resetForm() {
  1608. this._skipFormValidate = true;
  1609. this.saving = false;
  1610. this.fullscreen = false;
  1611. // 先抬高 key,避免空 options 触发 Cascader 内部 null.level
  1612. this.deptCascaderKey += 1;
  1613. this.positionSelectKey += 1;
  1614. this.activeTab = 'base';
  1615. this.managerOptions = [];
  1616. this.managerLoading = false;
  1617. this.factoryOptions = [];
  1618. this.factoryLoading = false;
  1619. this.staffingLevelOptions = [];
  1620. this.positionOptions = [];
  1621. this.sequenceOptions = [];
  1622. this.sequenceLoading = false;
  1623. this.levelIdByName = {};
  1624. this.showWorkTypeSelect = false;
  1625. this._managerRequestKey = '';
  1626. this.$nextTick(() => {
  1627. this.form = emptyForm();
  1628. this.$refs.form?.clearValidate();
  1629. this.clearOrphanOverlays();
  1630. });
  1631. },
  1632. generateEmployeeNo() { const maxNo = this.existingNumbers.reduce((max, item) => Math.max(max, Number(String(item).match(/\d+/)?.[0] || 0)), 0); this.form.employeeNo = `ZY${String(maxNo + 1).padStart(4, '0')}`; this.$nextTick(() => this.$refs.form?.clearValidate('employeeNo')); },
  1633. normalizeDeptIds(value) {
  1634. const list = Array.isArray(value) ? value : value != null && value !== '' ? [value] : [];
  1635. return Array.from(
  1636. new Set(
  1637. list
  1638. .flat(2)
  1639. .map((id) => String(id == null ? '' : id).trim())
  1640. .filter((id) => id && id !== 'null' && id !== 'undefined'),
  1641. ),
  1642. );
  1643. },
  1644. findDeptNode(id, nodes = this.deptOptions) {
  1645. for (const node of nodes || []) {
  1646. if (!node) continue;
  1647. if (String(node.id) === String(id)) return node;
  1648. const matched = this.findDeptNode(id, node.children || []);
  1649. if (matched) return matched;
  1650. }
  1651. return null;
  1652. },
  1653. applyDeptIds(ids, { resetDependents = false } = {}) {
  1654. const list = this.normalizeDeptIds(ids).filter(
  1655. (id) => String(id) !== String(this.form.groupId || ''),
  1656. );
  1657. this.form.deptIds = list;
  1658. this.form.deptId = list[0] || null;
  1659. this.form.deptName = list
  1660. .map((id) => this.findDeptNode(id)?.name)
  1661. .filter(Boolean)
  1662. .join('、');
  1663. if (resetDependents) {
  1664. this.loadManagerOptions({ keepSelection: false });
  1665. this.resetPositionsByDepartment();
  1666. }
  1667. },
  1668. /** 新增员工默认带出所属机构:登录机构列表第一个(命中法人组织选项时才填充) */
  1669. applyDefaultLegalEntity() {
  1670. if (this.isEdit || (this.form.groupId != null && this.form.groupId !== '')) return;
  1671. const list = this.legalEntities || [];
  1672. if (!list.length) return;
  1673. const wantId = getCurrentOrgId();
  1674. const hit =
  1675. (wantId != null && wantId !== ''
  1676. ? list.find((item) => String(item.id) === String(wantId))
  1677. : null) || list[0];
  1678. if (!hit) return;
  1679. this.form.groupId = String(hit.id);
  1680. this.form.legalEntity = hit.name || '';
  1681. this.form.company = hit.name || this.form.company;
  1682. },
  1683. handleOrganizationChange(ids) {
  1684. this.applyDeptIds(ids, { resetDependents: true });
  1685. this.$nextTick(() => this.$refs.form?.validateField('deptIds'));
  1686. },
  1687. /**
  1688. * Element 多选级联会同时渲染两个占位符:外层 el-input__inner 与内层 el-cascader__search-input,
  1689. * 两层叠在一起就是用户看到的“好几层重影”。仅靠 CSS 伪元素隐藏外层在部分主题/热更新下不生效,
  1690. * 这里直接改 DOM 属性兜底:已选机构(启用)时清空外层占位符,占位符只由内层搜索框渲染;
  1691. * 未选机构(禁用)时内层不渲染,恢复外层提示文案。
  1692. */
  1693. syncDeptCascaderPlaceholder() {
  1694. const input = this.$refs.deptCascader?.$el?.querySelector?.('.el-input__inner');
  1695. if (!input) return;
  1696. const want = this.form.groupId ? '' : this.deptCascaderPlaceholder;
  1697. if ((input.getAttribute('placeholder') || '') !== want) {
  1698. input.setAttribute('placeholder', want);
  1699. }
  1700. },
  1701. handleLegalEntityChange(id) {
  1702. const selected = this.legalEntities.find((item) => String(item.id) === String(id));
  1703. this.form.legalEntity = selected?.name || '';
  1704. this.form.company = selected?.name || this.form.company;
  1705. // 工厂与隶属部门都只挂所属机构:换机构时两者一起重置(彼此之间互不影响)
  1706. this.deptCascaderKey += 1;
  1707. this.form.factoryId = null;
  1708. this.form.factory = '';
  1709. this.factoryOptions = [];
  1710. this.applyDeptIds([]);
  1711. this.form.managerId = null;
  1712. this.form.manager = '';
  1713. this.managerOptions = [];
  1714. this.resetPositionsByDepartment();
  1715. this.loadFactories();
  1716. },
  1717. normalizeDeptAgainstOptions() {
  1718. const ids = this.normalizeDeptIds(
  1719. this.form.deptIds?.length ? this.form.deptIds : this.form.deptId,
  1720. ).filter((id) => String(id) !== String(this.form.groupId || ''));
  1721. const valid = ids.filter((id) => this.findDeptNode(id));
  1722. this.applyDeptIds(valid.length ? valid : ids);
  1723. },
  1724. handleManagerChange(id) {
  1725. const matched = this.managerOptions.find((item) => String(item.id) === String(id));
  1726. this.form.managerId = id != null && id !== '' ? String(id) : null;
  1727. this.form.manager = matched?.name || '';
  1728. },
  1729. mergeCurrentManagerOption() {
  1730. const id = this.form.managerId;
  1731. if (id == null || id === '') return;
  1732. const exists = this.managerOptions.some((item) => String(item.id) === String(id));
  1733. if (exists) return;
  1734. this.managerOptions.unshift({
  1735. id: String(id),
  1736. name: this.form.manager || '未命名人员'
  1737. });
  1738. },
  1739. async loadManagerOptions({ keepSelection = false } = {}) {
  1740. const deptId = this.form.deptId;
  1741. if (!keepSelection) {
  1742. this.form.managerId = null;
  1743. this.form.manager = '';
  1744. }
  1745. if (!deptId) {
  1746. this.managerOptions = [];
  1747. return;
  1748. }
  1749. const requestKey = String(deptId);
  1750. this._managerRequestKey = requestKey;
  1751. this.managerLoading = true;
  1752. try {
  1753. const data = await getUserPage({
  1754. pageNum: 1,
  1755. size: 99999,
  1756. groupId: deptId,
  1757. isQueryLZ: 0
  1758. });
  1759. if (this._managerRequestKey !== requestKey) return;
  1760. const selfId = this.form.id != null ? String(this.form.id) : '';
  1761. this.managerOptions = (data?.list || [])
  1762. .map((item) => ({
  1763. id: item.id == null ? item.id : String(item.id),
  1764. name: item.name || ''
  1765. }))
  1766. .filter((item) => item.id != null && item.id !== '' && item.name && String(item.id) !== selfId);
  1767. if (this.form.managerId != null && this.form.managerId !== '') {
  1768. this.form.managerId = String(this.form.managerId);
  1769. }
  1770. this.mergeCurrentManagerOption();
  1771. } catch (error) {
  1772. if (this._managerRequestKey !== requestKey) return;
  1773. this.managerOptions = [];
  1774. this.mergeCurrentManagerOption();
  1775. } finally {
  1776. if (this._managerRequestKey === requestKey) {
  1777. this.managerLoading = false;
  1778. }
  1779. }
  1780. },
  1781. async loadOrganizations() {
  1782. try {
  1783. const list = await listOrganizations();
  1784. this.organizationTree = buildOrganizationTree(Array.isArray(list) ? list : []);
  1785. } catch (error) {
  1786. this.organizationTree = [];
  1787. }
  1788. },
  1789. collectFactoryOrgs(groupId) {
  1790. if (!groupId) return [];
  1791. const tree = this.organizationTree.length ? this.organizationTree : this.organizations;
  1792. const find = (nodes) => {
  1793. for (const node of nodes || []) {
  1794. if (!node) continue;
  1795. if (String(node.id) === String(groupId)) return node;
  1796. const matched = find(node.children || []);
  1797. if (matched) return matched;
  1798. }
  1799. return null;
  1800. };
  1801. const list = [];
  1802. const walk = (nodes) => {
  1803. (nodes || []).forEach((node) => {
  1804. if (!node) return;
  1805. const type = String(node.type);
  1806. if (type === '40' || type === 'factory') {
  1807. list.push({
  1808. id: String(node.id),
  1809. name: node.name || '',
  1810. code: node.groupCode || '',
  1811. groupId: String(groupId),
  1812. });
  1813. }
  1814. walk(node.children || []);
  1815. });
  1816. };
  1817. walk(find(tree || [])?.children || []);
  1818. return list.filter((item) => item.id && item.name);
  1819. },
  1820. factoryBelongsToOrg(item = {}, groupId) {
  1821. if (!groupId) return false;
  1822. const orgId = String(groupId);
  1823. const ids = [
  1824. item.groupId,
  1825. item.organizationId,
  1826. item.companyId,
  1827. item.parentId,
  1828. item.legalId,
  1829. ]
  1830. .filter((value) => value != null && value !== '')
  1831. .map((value) => String(value));
  1832. if (!ids.length) return true;
  1833. return ids.includes(orgId);
  1834. },
  1835. async loadFactories() {
  1836. const groupId = this.form.groupId;
  1837. if (!groupId) {
  1838. this.factoryOptions = [];
  1839. this.factoryLoading = false;
  1840. return;
  1841. }
  1842. this.factoryLoading = true;
  1843. try {
  1844. const fromTree = this.collectFactoryOrgs(groupId);
  1845. if (fromTree.length) {
  1846. this.factoryOptions = fromTree;
  1847. return;
  1848. }
  1849. const data = await getFactoryAreaPage({
  1850. pageNum: 1,
  1851. size: 999,
  1852. type: 1,
  1853. groupId,
  1854. organizationId: groupId,
  1855. });
  1856. this.factoryOptions = (Array.isArray(data?.list) ? data.list : (Array.isArray(data) ? data : []))
  1857. .map((item) => ({
  1858. id: String(item.id),
  1859. name: item.name || item.factoryName || item.pathName || `工厂 ${item.id}`,
  1860. code: item.code || '',
  1861. enabled: item.enabled,
  1862. groupId: item.groupId || item.organizationId || item.companyId || item.parentId || '',
  1863. }))
  1864. .filter((item) => item.id && item.name && this.factoryBelongsToOrg(item, groupId));
  1865. } catch (error) {
  1866. this.factoryOptions = this.collectFactoryOrgs(groupId);
  1867. if (!this.factoryOptions.length) {
  1868. this.$message.error(error.message || '工厂列表加载失败');
  1869. }
  1870. } finally {
  1871. this.factoryLoading = false;
  1872. }
  1873. },
  1874. syncFactoryDisplay() {
  1875. if (this.form.factoryId == null || this.form.factoryId === '') {
  1876. this.form.factoryId = null;
  1877. return;
  1878. }
  1879. this.form.factoryId = String(this.form.factoryId);
  1880. const matched = this.factoryOptions.find((item) => String(item.id) === String(this.form.factoryId));
  1881. if (matched) {
  1882. this.form.factory = matched.name;
  1883. return;
  1884. }
  1885. // 详情带回的工厂不在分页结果里时,补一条选项避免只显示 ID
  1886. if (this.form.factory) {
  1887. this.factoryOptions.unshift({
  1888. id: String(this.form.factoryId),
  1889. name: this.form.factory,
  1890. code: '',
  1891. enabled: 1
  1892. });
  1893. }
  1894. },
  1895. /* 工厂与隶属部门相互独立(都只挂所属机构):换工厂只更新工厂本身,不动部门与岗位级联 */
  1896. handleFactoryChange(id) {
  1897. const matched = this.factoryOptions.find((item) => String(item.id) === String(id));
  1898. this.form.factory = matched?.name || '';
  1899. this.form.factoryId = id != null && id !== '' ? String(id) : null;
  1900. },
  1901. async handlePositionChange() {
  1902. this.form.positionId =
  1903. this.form.positionId != null && this.form.positionId !== ''
  1904. ? String(this.form.positionId)
  1905. : null;
  1906. this.form.positionIds = this.form.positionId ? [this.form.positionId] : [];
  1907. if (!this.form.positionId) {
  1908. this.levelIdByName = {};
  1909. this.staffingLevelOptions = [];
  1910. this.form.staffingLevel = '';
  1911. this.form.level = '';
  1912. } else {
  1913. // 选完岗位自动带出岗位性质与层级(仍可手动修改)
  1914. await this.loadPositionNatureAndLevels();
  1915. if (!this.staffingLevelOptions.length) this.syncStaffingLevelsFromPosition();
  1916. }
  1917. this.syncPositionDisplay();
  1918. this.enrichSelectedPositions();
  1919. this.$nextTick(() => this.$refs.form?.validateField('positionId'));
  1920. },
  1921. handleAvatarUpload(file) { if (file.size > 2 * 1024 * 1024) { this.$message.warning('员工照片不能超过 2MB'); return false; } const reader = new FileReader(); reader.onload = (event) => { this.form.avatar = event.target.result; }; reader.readAsDataURL(file); return false; },
  1922. addEducation() { this.form.educations.push({ education: '本科', degree: '学士', graduationDate: '', school: '', major: '' }); },
  1923. addBankAccount() { this.form.bankAccounts.push({ bankName: '', bankBranch: '', bankAccount: '', bankAccountName: '' }); },
  1924. addPreviousJob() { this.form.previousJobs.push({ startDate: '', endDate: '', company: '', position: '', department: '', leaveReason: '', description: '' }); },
  1925. addSocialSecurity() { this.form.socialSecurityRecords.push({ type: '五险一金', base: 0, insuredDate: '', stopDate: '', stopReason: '', isEnabled: 0 }); },
  1926. /** 社保公积金只能有一条启用:启用当前行时,其余行自动置为「否」 */
  1927. handleSocialEnabledChange(currentRow) {
  1928. if (currentRow.isEnabled !== 1) return;
  1929. this.form.socialSecurityRecords.forEach((row) => {
  1930. if (row !== currentRow) row.isEnabled = 0;
  1931. });
  1932. },
  1933. /**
  1934. * 社保公积金删除拦截:
  1935. * 1. 已保存的记录(接口回显带 id)不允许删除,提示通过「停缴日期 + 停缴记录」处理;
  1936. * 2. 本次新增、尚未保存的行可以删,但需二次确认。
  1937. */
  1938. handleSocialDelete(index) {
  1939. const row = this.form.socialSecurityRecords[index];
  1940. if (!row) return;
  1941. const saved = row.id != null && row.id !== '';
  1942. if (saved) {
  1943. this.$message.warning('已保存的社保记录不支持删除,如需终止参保请填写「停缴日期」和「停缴记录」');
  1944. return;
  1945. }
  1946. this.$confirm('确认删除该社保记录?', '提示', {
  1947. type: 'warning',
  1948. confirmButtonText: '确定',
  1949. cancelButtonText: '取消'
  1950. })
  1951. .then(() => {
  1952. this.form.socialSecurityRecords.splice(index, 1);
  1953. })
  1954. .catch(() => {});
  1955. },
  1956. workTypeTypeLabel(_row, _column, cellValue) {
  1957. const type = _row?.type ?? cellValue;
  1958. return this.getDictValue('工种类型', type) || type || '-';
  1959. },
  1960. workTypeLevelLabel(_row, _column, cellValue) {
  1961. const level = _row?.level ?? cellValue;
  1962. const hit = this.workTypeLevelOptions.find(
  1963. (item) => String(item.value) === String(level) || item.label === level,
  1964. );
  1965. return hit?.label || level || '-';
  1966. },
  1967. workTypeHourCostLabel(row) {
  1968. const wage = row.hourCost ?? row.hourlyWage;
  1969. return wage || wage === 0 ? wage : '-';
  1970. },
  1971. workTypeUnitLabel(row) {
  1972. return row.unit || row.wageUnit || '-';
  1973. },
  1974. mapProfessionToWorkType(item = {}) {
  1975. const unit = item.unit || (item.money && item.date ? `${item.money}/${item.date}` : '') || item.wageUnit || '';
  1976. return {
  1977. id: item.id,
  1978. type: item.type || '',
  1979. code: item.code || '',
  1980. name: item.name || '',
  1981. level: item.level || '',
  1982. hourCost: item.hourCost ?? item.hourlyWage ?? '',
  1983. unit,
  1984. hourlyWage: item.hourCost ?? item.hourlyWage ?? 0,
  1985. wageUnit: unit,
  1986. userId: item.userId || '',
  1987. userName: item.userName || '',
  1988. };
  1989. },
  1990. openWorkTypeSelect() {
  1991. this.showWorkTypeSelect = true;
  1992. this.$refs.workTypeSelect?.open();
  1993. },
  1994. handleWorkTypeSelect(selection) {
  1995. const existIds = new Set(
  1996. (this.form.workTypes || []).map((item) => String(item.id)).filter((id) => id && id !== 'undefined'),
  1997. );
  1998. (selection || []).forEach((item) => {
  1999. const mapped = this.mapProfessionToWorkType(item);
  2000. const id = mapped.id == null || mapped.id === '' ? '' : String(mapped.id);
  2001. if (id && existIds.has(id)) return;
  2002. this.form.workTypes.push(mapped);
  2003. if (id) existIds.add(id);
  2004. });
  2005. },
  2006. addCertificate() { this.form.certificates.push({ type: '', number: '', level: '', status: '有效', startDate: '', endDate: '', issueDate: '', attachment: [], remark: '', hasPositionAllowance: 0, positionAllowanceAmount: null, positionAllowanceUnit: '' }); },
  2007. /** 等级选项:按证照类型从 certLevel.js 取;表单类型名与字典键差一个「证」字时(特种作业证/特种作业)做兼容 */
  2008. certLevelOptionsFor(type) {
  2009. if (!type) return [];
  2010. const direct = getCertLevelOptions(type);
  2011. if (direct.length) return direct;
  2012. return getCertLevelOptions(type.replace(/证$/, ''));
  2013. },
  2014. /** 等级下拉是否禁用(包装 certLevel.js 的导出函数,供模板调用) */
  2015. isCertLevelDisabled(type) {
  2016. return isCertLevelDisabled(type);
  2017. },
  2018. /** 证照类型变化:无等级类型自动填「无」并禁用;已选等级不在新类型选项里则清空 */
  2019. handleCertTypeChange(row) {
  2020. if (!row.type) { row.level = ''; return; }
  2021. if (NO_LEVEL_CERT_TYPES.includes(row.type)) { row.level = NO_LEVEL_VALUE; return; }
  2022. const opts = this.certLevelOptionsFor(row.type);
  2023. if (!opts.some((opt) => opt.value === row.level)) row.level = '';
  2024. },
  2025. /** 津贴单位默认值:写死选项里优先取 month(元/月),取不到才用第一项 */
  2026. defaultAllowanceUnit(options = this.allowanceUnitOptions) {
  2027. if (!options || !options.length) return '';
  2028. const hit = options.find((item) => String(item.value) === 'month');
  2029. return hit ? hit.value : options[0].value;
  2030. },
  2031. /** 岗位津贴:选「否」时清空金额和单位;选「是」时补上单位默认值(元/月) */
  2032. handlePositionAllowanceChange(row) {
  2033. if (row.hasPositionAllowance !== 1) {
  2034. row.positionAllowanceAmount = null;
  2035. row.positionAllowanceUnit = '';
  2036. return;
  2037. }
  2038. if (!row.positionAllowanceUnit) row.positionAllowanceUnit = this.defaultAllowanceUnit();
  2039. },
  2040. addSkill() { this.form.skills.push({ category: '', name: '', level: '', years: 0, attachment: [], remark: '' }); },
  2041. removeItem(key, index) { this.form[key].splice(index, 1); },
  2042. calculateAge(date) {
  2043. if (!date) return null;
  2044. const birthday = new Date(date);
  2045. if (Number.isNaN(birthday.getTime())) return null;
  2046. const today = new Date();
  2047. let age = today.getFullYear() - birthday.getFullYear();
  2048. if (
  2049. today.getMonth() < birthday.getMonth() ||
  2050. (today.getMonth() === birthday.getMonth() && today.getDate() < birthday.getDate())
  2051. ) {
  2052. age -= 1;
  2053. }
  2054. return age >= 0 ? age : null;
  2055. },
  2056. syncAge() {
  2057. this.form.age = this.calculateAge(this.form.birthDate);
  2058. },
  2059. /** 从 18 位身份证解析出生日期并回填年龄(格式合法时覆盖生成) */
  2060. parseIdCard() {
  2061. const value = String(this.form.idCard || '').trim().toUpperCase();
  2062. this.form.idCard = value;
  2063. if (!/^\d{17}[\dXx]$/.test(value)) return false;
  2064. const y = value.slice(6, 10);
  2065. const m = value.slice(10, 12);
  2066. const d = value.slice(12, 14);
  2067. const birth = `${y}-${m}-${d}`;
  2068. const date = new Date(Number(y), Number(m) - 1, Number(d));
  2069. if (
  2070. date.getFullYear() !== Number(y) ||
  2071. date.getMonth() !== Number(m) - 1 ||
  2072. date.getDate() !== Number(d)
  2073. ) {
  2074. return false;
  2075. }
  2076. this.form.birthDate = birth;
  2077. this.form.age = this.calculateAge(birth);
  2078. return true;
  2079. },
  2080. handleIdCardInput(value) {
  2081. const text = String(value || '').trim();
  2082. if (text.length === 18) this.parseIdCard();
  2083. },
  2084. extractPositionLevelNames(opt = {}) {
  2085. const names = [];
  2086. const add = (value) => {
  2087. if (value == null || value === '') return;
  2088. const text =
  2089. typeof value === 'object'
  2090. ? value.level ||
  2091. value.levelName ||
  2092. value.label ||
  2093. value.positionLevel ||
  2094. value.value ||
  2095. ''
  2096. : value;
  2097. const name = String(text || '').trim();
  2098. if (name && !names.includes(name)) names.push(name);
  2099. };
  2100. (opt.positionLevels || []).forEach(add);
  2101. add(opt.positionLevel);
  2102. add(opt.level);
  2103. return names;
  2104. },
  2105. syncStaffingLevelsFromPosition() {
  2106. const primaryId = this.form.positionId || (this.form.positionIds || [])[0];
  2107. if (!primaryId) {
  2108. this.staffingLevelOptions = [];
  2109. this.form.staffingLevel = '';
  2110. this.form.level = '';
  2111. return;
  2112. }
  2113. const opt =
  2114. this.positionOptions.find((item) => String(item.id) === String(primaryId)) || {};
  2115. const levels = this.extractPositionLevelNames(opt);
  2116. this.staffingLevelOptions = levels;
  2117. if (!levels.length) {
  2118. this.form.staffingLevel = '';
  2119. this.form.level = '';
  2120. return;
  2121. }
  2122. if (!levels.includes(this.form.staffingLevel)) {
  2123. this.form.staffingLevel = levels[0];
  2124. }
  2125. this.form.level = this.form.staffingLevel;
  2126. },
  2127. handleStaffingLevelChange(value) {
  2128. this.form.staffingLevel = value || '';
  2129. this.form.level = value || '';
  2130. this.syncPositionDisplay();
  2131. },
  2132. clearSelectedPositions() {
  2133. this.form.positionIds = [];
  2134. this.form.positionBindings = [];
  2135. this.form.positionId = null;
  2136. this.form.position = '';
  2137. this.form.staffingLevel = '';
  2138. this.form.level = '';
  2139. this.staffingLevelOptions = [];
  2140. this.levelIdByName = {};
  2141. },
  2142. /** 清空「岗位」及以下的级联字段与下拉选项(与办理入职一致) */
  2143. clearPositionCascade() {
  2144. this.clearSelectedPositions();
  2145. this.positionOptions = [];
  2146. this.hrPositionIdSet = {};
  2147. this.positionLoading = false;
  2148. },
  2149. /** 隶属部门变化:清空岗位序列及以下级联,并按新的部门并集重新拉序列 */
  2150. async resetPositionsByDepartment() {
  2151. this.clearPositionCascade();
  2152. this.form.positionSequenceId = '';
  2153. this.form.positionSequence = '';
  2154. this.form.positionSequenceName = '';
  2155. this.sequenceOptions = [];
  2156. this.sequenceLoading = false;
  2157. await this.loadPositionSequences();
  2158. },
  2159. // 后端返回值(字典 id / 历史文案 / 编码)统一转成选项 value(字典 id),便于下拉回显
  2160. resolveEmploymentType(raw) {
  2161. if (raw == null || raw === '') return '';
  2162. const text = String(raw);
  2163. const hit = this.employmentOptions.find(
  2164. (item) =>
  2165. String(item.value) === text ||
  2166. String(item.label) === text ||
  2167. String(item.key) === text,
  2168. );
  2169. return hit ? hit.value : text;
  2170. },
  2171. /** 岗位性质名称快照:按字典 id 反查 label,随 employmentType 一起提交(positionTypeName) */
  2172. syncEmploymentTypeName(value) {
  2173. const hit = this.employmentOptions.find((item) => String(item.value) === String(value));
  2174. this.form.employmentTypeName = hit ? hit.label : '';
  2175. },
  2176. // 岗位性质(员工档案字段 employmentType):字典 id 作为选项 value,提交后端即字典 id
  2177. async loadEmploymentTypeOptions() {
  2178. try {
  2179. const items = await getDictByCode('position_nature');
  2180. this.employmentOptions = (items || [])
  2181. .map((item) => {
  2182. const label = String(item.label ?? item.value ?? '').trim();
  2183. const id = String(item.id ?? item.key ?? '').trim();
  2184. if (!label || !id) return null;
  2185. return {
  2186. label,
  2187. value: id,
  2188. key: item.key != null ? String(item.key) : id,
  2189. };
  2190. })
  2191. .filter(Boolean);
  2192. if (this.form.employmentType) {
  2193. this.form.employmentType = this.resolveEmploymentType(this.form.employmentType);
  2194. this.syncEmploymentTypeName(this.form.employmentType);
  2195. }
  2196. } catch (error) {
  2197. this.employmentOptions = [];
  2198. }
  2199. },
  2200. /** 接口返回可能是数组 / {list|records|rows|data},统一取数组(与办理入职一致) */
  2201. toOptionList(payload) {
  2202. if (Array.isArray(payload)) return payload.filter(Boolean);
  2203. const list =
  2204. payload?.list || payload?.records || payload?.rows || payload?.data;
  2205. return Array.isArray(list) ? list.filter(Boolean) : [];
  2206. },
  2207. /** 岗位序列主键回显:员工主数据字段优先,其次岗位绑定项 */
  2208. pickSequenceId(source = {}) {
  2209. const binding =
  2210. (Array.isArray(source.positionBindings) ? source.positionBindings[0] : null) || {};
  2211. const raw = source.positionSequenceId || binding.positionSequenceId || '';
  2212. return this.isNumericId(raw) ? String(raw).trim() : '';
  2213. },
  2214. /**
  2215. * 隶属部门(可多选)→ 岗位序列下拉:逐部门取序列后并集去重
  2216. * GET /fm/tmaindepartmentposition/sequence/list
  2217. */
  2218. async loadPositionSequences() {
  2219. const deptIds = this.normalizeDeptIds(this.form.deptIds);
  2220. if (!deptIds.length) {
  2221. this.sequenceOptions = [];
  2222. this.sequenceLoading = false;
  2223. return;
  2224. }
  2225. this.sequenceLoading = true;
  2226. let requestFailed = false;
  2227. try {
  2228. const groups = await Promise.all(
  2229. deptIds.map((deptId) =>
  2230. getDepartmentPositionSequences(deptId).catch(() => {
  2231. requestFailed = true;
  2232. return [];
  2233. }),
  2234. ),
  2235. );
  2236. const map = new Map();
  2237. groups
  2238. .flatMap((data) => this.toOptionList(data))
  2239. .forEach((item) => {
  2240. if (!item || item.positionSequenceId == null || item.positionSequenceId === '') return;
  2241. const id = String(item.positionSequenceId);
  2242. if (!map.has(id)) map.set(id, { id, name: item.positionSequence || '' });
  2243. });
  2244. const list = Array.from(map.values());
  2245. const currentId = this.form.positionSequenceId ? String(this.form.positionSequenceId) : '';
  2246. if (currentId && !list.some((item) => item.id === currentId)) {
  2247. list.unshift({
  2248. id: currentId,
  2249. name: this.form.positionSequence || this.form.positionSequenceName || '',
  2250. });
  2251. }
  2252. this.sequenceOptions = list;
  2253. if (!list.length && !requestFailed) {
  2254. // 所选部门下没有维护任何岗位序列(接口异常时不误报):引导去组织机构的岗位信息里维护
  2255. this.warnEmptySequences(deptIds);
  2256. }
  2257. } catch (error) {
  2258. this.sequenceOptions = [];
  2259. } finally {
  2260. this.sequenceLoading = false;
  2261. }
  2262. },
  2263. /** 部门下没有岗位序列时提示:每次选中无岗位部门都弹,不做去重(用户要求) */
  2264. warnEmptySequences(deptIds = []) {
  2265. if (!deptIds.length) return;
  2266. this.$message.warning('该部门下暂无配置岗位信息,请在组织机构的该部门补充岗位信息');
  2267. },
  2268. /** 切换岗位序列:清空岗位及以下的级联字段与选项,再按该序列加载岗位 */
  2269. async handlePositionSequenceChange(id) {
  2270. const hit = this.sequenceOptions.find((item) => String(item.id) === String(id));
  2271. const keep = id != null && id !== '';
  2272. this.clearPositionCascade();
  2273. this.form.positionSequenceId = keep ? String(id) : '';
  2274. this.form.positionSequence = keep ? (hit?.name || '') : '';
  2275. this.form.positionSequenceName = this.form.positionSequence;
  2276. if (keep) await this.loadDepartmentPositions();
  2277. this.$nextTick(() => {
  2278. this.$refs.form?.validateField('positionSequenceId');
  2279. this.$refs.form?.validateField('positionId');
  2280. });
  2281. },
  2282. /**
  2283. * 隶属部门 + 岗位序列 → 岗位下拉(多部门并集,逐项记录来源部门用于带出性质)
  2284. * GET /fm/tmaindepartmentposition/position/list
  2285. */
  2286. async loadDepartmentPositions() {
  2287. const deptIds = this.normalizeDeptIds(this.form.deptIds);
  2288. const positionSequenceId = this.form.positionSequenceId;
  2289. if (!deptIds.length || !positionSequenceId) {
  2290. this.positionOptions = [];
  2291. this.hrPositionIdSet = {};
  2292. this.positionLoading = false;
  2293. return;
  2294. }
  2295. this.positionLoading = true;
  2296. try {
  2297. const groups = await Promise.all(
  2298. deptIds.map(async (deptId) => {
  2299. const data = await getDepartmentPositions({ deptId, positionSequenceId }).catch(() => []);
  2300. return this.toOptionList(data).map((item) => ({ ...item, deptId }));
  2301. }),
  2302. );
  2303. const sequenceName = this.form.positionSequence || this.form.positionSequenceName || '';
  2304. const list = groups
  2305. .flat()
  2306. .filter((item) => item && item.positionId != null && String(item.positionName || '').trim())
  2307. .map((item) => {
  2308. const name = String(item.positionName || '').trim();
  2309. return {
  2310. id: String(item.positionId),
  2311. positionId: String(item.positionId),
  2312. positionName: name,
  2313. name,
  2314. deptId: item.deptId,
  2315. sequenceId: String(positionSequenceId),
  2316. positionSequenceId: String(positionSequenceId),
  2317. positionSequence: sequenceName,
  2318. positionLevels: [],
  2319. };
  2320. });
  2321. const idSet = {};
  2322. list.forEach((item) => {
  2323. idSet[item.id] = true;
  2324. });
  2325. this.hrPositionIdSet = idSet;
  2326. this.positionOptions = this.dedupePositionOptions(list);
  2327. } catch (error) {
  2328. this.positionOptions = [];
  2329. this.hrPositionIdSet = {};
  2330. } finally {
  2331. this.positionLoading = false;
  2332. }
  2333. },
  2334. /** 编辑回显:序列主键缺失时按绑定岗位反查补全,并保证当前序列在下拉里可见 */
  2335. resolveSequenceForEdit() {
  2336. const binding = (this.form.positionBindings || [])[0] || {};
  2337. let seqId = this.isNumericId(this.form.positionSequenceId)
  2338. ? String(this.form.positionSequenceId).trim()
  2339. : '';
  2340. let seqName = String(this.form.positionSequence || this.form.positionSequenceName || '').trim();
  2341. if (!seqId && this.isNumericId(binding.positionSequenceId)) {
  2342. seqId = String(binding.positionSequenceId).trim();
  2343. }
  2344. if (!seqName) {
  2345. seqName = String(binding.positionSequenceName || binding.positionSequence || '').trim();
  2346. }
  2347. if (!seqId || !seqName) {
  2348. const meta = this.findSequenceMeta(seqId, seqName);
  2349. if (meta) {
  2350. if (!seqId && this.isNumericId(meta.sequenceId)) seqId = String(meta.sequenceId).trim();
  2351. if (!seqName) seqName = String(meta.sequenceName || '').trim();
  2352. }
  2353. }
  2354. this.form.positionSequenceId = seqId || '';
  2355. this.form.positionSequence = seqName;
  2356. this.form.positionSequenceName = seqName;
  2357. if (seqId && !this.sequenceOptions.some((item) => String(item.id) === seqId)) {
  2358. this.sequenceOptions = [{ id: seqId, name: seqName || '岗位序列' }].concat(this.sequenceOptions);
  2359. }
  2360. return null;
  2361. },
  2362. /**
  2363. * 岗位 → 岗位性质(自动带出默认值,可手改)→ 岗位层级下拉
  2364. * prime=true 用于编辑回显:只预热下拉与主键映射,不覆盖已存值
  2365. */
  2366. async loadPositionNatureAndLevels({ prime = false } = {}) {
  2367. const positionId = this.form.positionId;
  2368. const positionSequenceId = this.form.positionSequenceId;
  2369. if (!positionId || !positionSequenceId) return;
  2370. const opt = this.positionOptions.find((item) => String(item.id) === String(positionId)) || {};
  2371. const deptId = opt.deptId || this.normalizeDeptIds(this.form.deptIds)[0];
  2372. if (!deptId) return;
  2373. let natureText = '';
  2374. try {
  2375. const data = await getDepartmentPositionType({
  2376. deptId,
  2377. positionId,
  2378. positionSequenceId,
  2379. }).catch(() => []);
  2380. const first = this.toOptionList(data)[0];
  2381. if (first) {
  2382. this.form.positionType = first.positionType || this.form.positionType || '';
  2383. natureText = String(first.positionTypeName || first.positionType || '').trim();
  2384. }
  2385. } catch (error) {
  2386. natureText = '';
  2387. }
  2388. if (natureText && (!prime || !this.form.employmentType)) {
  2389. this.form.employmentType = this.resolveEmploymentType(natureText);
  2390. this.syncEmploymentTypeName(this.form.employmentType);
  2391. }
  2392. const names = [];
  2393. const idMap = {};
  2394. try {
  2395. const levels = await getDepartmentPositionLevels({
  2396. deptId,
  2397. positionId,
  2398. positionSequenceId,
  2399. positionType: this.form.positionType || '',
  2400. }).catch(() => []);
  2401. this.toOptionList(levels).forEach((item) => {
  2402. const name = String(item.positionLevel || item.positionLevelName || '').trim();
  2403. if (!name) return;
  2404. if (!names.includes(name)) names.push(name);
  2405. if (item.positionLevelId != null && item.positionLevelId !== '') {
  2406. idMap[name] = String(item.positionLevelId);
  2407. }
  2408. });
  2409. } catch (error) {
  2410. // 层级接口异常时退回岗位自带层级配置
  2411. }
  2412. if (names.length) {
  2413. this.levelIdByName = idMap;
  2414. this.staffingLevelOptions = names;
  2415. if (names.includes(this.form.staffingLevel)) {
  2416. this.form.level = this.form.staffingLevel;
  2417. } else if (!prime || !this.form.staffingLevel) {
  2418. this.form.staffingLevel = names[0];
  2419. this.form.level = this.form.staffingLevel;
  2420. }
  2421. } else if (!prime) {
  2422. this.levelIdByName = {};
  2423. this.syncStaffingLevelsFromPosition();
  2424. }
  2425. },
  2426. // 保存前整体校验:基础必填 + 各分区必填 + 日期逻辑 + 记录分区,校验不过跳转到对应 tab
  2427. async validateSections() {
  2428. // 1. 基础 el-form(base tab 必填)
  2429. if (!this.$refs.form) {
  2430. this.$message.warning('表单未就绪,请稍后重试');
  2431. return false;
  2432. }
  2433. this._skipFormValidate = false;
  2434. const formValid = await new Promise((resolve) => {
  2435. try {
  2436. this.$refs.form.validate((valid) => resolve(Boolean(valid)));
  2437. } catch (error) {
  2438. resolve(false);
  2439. }
  2440. });
  2441. if (!formValid) {
  2442. this.activeTab = 'base';
  2443. this.$message.warning('请完善基本信息中标记的必填项');
  2444. return false;
  2445. }
  2446. // 同步岗位绑定,并解析 positionList 必填的序列/层级主键(界面仍只展示岗位名)
  2447. const positionMetaOk = await this.ensurePositionMetaForSave();
  2448. if (!positionMetaOk) return false;
  2449. // 2. 各分区重复列表必填/日期逻辑
  2450. const sectionInvalid = this.checkRepeatLists();
  2451. if (sectionInvalid) {
  2452. this.activeTab = sectionInvalid.tab;
  2453. this.$message.warning(sectionInvalid.message);
  2454. return false;
  2455. }
  2456. // 3. 记录分区(考勤/绩效/培训/奖惩)
  2457. const recordsResult = this.$refs.records?.validateRecords?.();
  2458. if (recordsResult && !recordsResult.valid) {
  2459. this.activeTab = 'records';
  2460. this.$message.warning(recordsResult.message);
  2461. return false;
  2462. }
  2463. return true;
  2464. },
  2465. // 校验各分区重复列表必填与日期逻辑,返回首个不满足项 { tab, message } 或 null
  2466. checkRepeatLists() {
  2467. const gt = (start, end) => start && end && start > end;
  2468. // 日期逻辑(基本信息)
  2469. if (gt(this.form.birthDate, this.form.hireDate)) return { tab: 'base', message: '入职日期不能早于出生日期' };
  2470. if (gt(this.form.hireDate, this.form.regularDate)) return { tab: 'base', message: '转正日期不能早于入职日期' };
  2471. // 工作履历
  2472. for (const item of this.form.previousJobs || []) {
  2473. if (!item.startDate || !item.endDate || !item.company || !item.position) {
  2474. return { tab: 'experience', message: '工作履历的必填项未完善(工作开始/结束日期、前公司名称、前公司岗位)' };
  2475. }
  2476. if (gt(item.startDate, item.endDate)) return { tab: 'experience', message: '工作结束日期必须晚于开始日期' };
  2477. }
  2478. // 劳动合同已改为只读,不再在档案页校验必填/日期
  2479. // 薪酬银行:姓名必填
  2480. for (const item of this.form.bankAccounts || []) {
  2481. if (!item.bankAccountName) return { tab: 'payroll', message: '薪酬银行的姓名(开户人)必填' };
  2482. }
  2483. // 社保记录:停保≥参保
  2484. for (const item of this.form.socialSecurityRecords || []) {
  2485. if (gt(item.insuredDate, item.stopDate)) return { tab: 'payroll', message: '社保停保日期不能早于参保日期' };
  2486. }
  2487. // 证照:类型/编号/状态/有效期必填,有效期结束≥开始
  2488. for (const item of this.form.certificates || []) {
  2489. if (!item.type || !item.number || !item.status || !item.startDate || !item.endDate) {
  2490. return { tab: 'qualification', message: '证照的必填项未完善(证照类型、证照编号、状态、有效期起止)' };
  2491. }
  2492. if (gt(item.startDate, item.endDate)) return { tab: 'qualification', message: '证照有效期结束时间不能早于开始时间' };
  2493. if (item.hasPositionAllowance === 1 && (item.positionAllowanceAmount === '' || item.positionAllowanceAmount == null)) {
  2494. return { tab: 'qualification', message: '证照选择了有岗位津贴,请填写津贴金额' };
  2495. }
  2496. if (item.hasPositionAllowance === 1 && !item.positionAllowanceUnit) {
  2497. return { tab: 'qualification', message: '证照选择了有岗位津贴,请选择津贴单位' };
  2498. }
  2499. }
  2500. // 技能:技能名称必填
  2501. for (const item of this.form.skills || []) {
  2502. if (!item.name) return { tab: 'qualification', message: '技能名称必填' };
  2503. }
  2504. return null;
  2505. },
  2506. async submit() {
  2507. if (this.saving) return;
  2508. this.saving = true;
  2509. // 兜底:无论父级是否回调,都要结束 loading,避免异常后整页卡死
  2510. const stopSaving = () => {
  2511. this.saving = false;
  2512. };
  2513. const safetyTimer = setTimeout(stopSaving, 45000);
  2514. try {
  2515. const ok = await this.validateSections();
  2516. if (!ok) return;
  2517. const today = new Date().toISOString().slice(0, 10);
  2518. const certificates = (this.form.certificates || [])
  2519. .filter((item) => item.type)
  2520. .map((item) => ({
  2521. ...item,
  2522. hasPositionAllowance: item.hasPositionAllowance === 1 ? 1 : 0,
  2523. // 无岗位津贴时不给金额,有津贴时按输入值提交
  2524. positionAllowanceAmount: item.hasPositionAllowance === 1 && item.positionAllowanceAmount !== '' && item.positionAllowanceAmount != null
  2525. ? Number(item.positionAllowanceAmount)
  2526. : null,
  2527. // 无岗位津贴时不传单位,字典码统一按字符串提交
  2528. positionAllowanceUnit: item.hasPositionAllowance === 1 && item.positionAllowanceUnit
  2529. ? String(item.positionAllowanceUnit)
  2530. : null,
  2531. status: item.endDate && item.endDate < today ? '失效' : (item.status || '有效'),
  2532. }));
  2533. const contract = { ...(this.form.contract || {}) };
  2534. const leaveEnabled = this.form.leaveInfo?.enabled && Boolean(this.form.leaveInfo?.leaveDate);
  2535. const awards = (this.form.awards || [])
  2536. .filter((item) => item.name)
  2537. .map((item) => ({ ...item, level: item.level || '公司级' }));
  2538. const bankAccounts = (this.form.bankAccounts || []).map((item, i) => ({
  2539. ...item,
  2540. bankBranch: item.bankBranch || '',
  2541. bankAccountName: item.bankAccountName || (i === 0 ? this.form.name : ''),
  2542. }));
  2543. // 社保公积金:isEnabled 只允许一条为 1,历史数据若有多条启用则只保留第一条
  2544. let socialEnabledAssigned = false;
  2545. const socialSecurityRecords = (this.form.socialSecurityRecords || []).map((item) => {
  2546. const enabled = item.isEnabled === 1 && !socialEnabledAssigned;
  2547. if (enabled) socialEnabledAssigned = true;
  2548. return { ...item, isEnabled: enabled ? 1 : 0 };
  2549. });
  2550. if ((this.form.educations || []).length > 1) {
  2551. this.$message.info('多条教育经历仅第一条会写入员工主数据字段');
  2552. }
  2553. const firstEducation = (this.form.educations || [])[0] || {};
  2554. const payload = {
  2555. ...this.form,
  2556. certificates,
  2557. contract,
  2558. awards,
  2559. bankAccounts,
  2560. socialSecurityRecords,
  2561. education: firstEducation.education || this.form.education || '',
  2562. degree: firstEducation.degree || this.form.degree || '',
  2563. school: firstEducation.school || this.form.school || '',
  2564. major: firstEducation.major || this.form.major || '',
  2565. graduationDate: firstEducation.graduationDate || this.form.graduationDate || '',
  2566. status: leaveEnabled ? '离职' : this.form.status,
  2567. age: this.calculateAge(this.form.birthDate),
  2568. };
  2569. // done 必须被调用;用回调而不是依赖 resolve/reject,避免漏调导致永久 await
  2570. const saveError = await new Promise((resolve) => {
  2571. let settled = false;
  2572. const done = (error) => {
  2573. if (settled) return;
  2574. settled = true;
  2575. resolve(error || null);
  2576. };
  2577. try {
  2578. this.$emit('save', payload, done);
  2579. } catch (error) {
  2580. done(error);
  2581. }
  2582. setTimeout(() => done(new Error('保存超时,请重试')), 40000);
  2583. });
  2584. if (saveError) {
  2585. if (saveError.message && !saveError.__toastShown) {
  2586. // 父级未提示时再兜底(例如超时)
  2587. if (saveError.message === '保存超时,请重试') {
  2588. this.$message.error(saveError.message);
  2589. }
  2590. }
  2591. return;
  2592. }
  2593. this.dialogVisible = false;
  2594. } catch (error) {
  2595. this.$message.error(error?.message || '保存失败,请重试');
  2596. } finally {
  2597. clearTimeout(safetyTimer);
  2598. stopSaving();
  2599. }
  2600. }
  2601. }
  2602. };
  2603. </script>
  2604. <style src="../../../../styles/views/employeeArchive/components/EmployeeArchiveForm.scss" lang="scss" scoped></style>
  2605. <style src="../../../../styles/views/employeeArchive/components/EmployeeArchiveForm.global.scss" lang="scss"></style>
  2606. <!-- 薪酬与社保页签的职责边界提示条 -->
  2607. <style lang="scss" scoped>
  2608. .payroll-scope-alert {
  2609. margin-bottom: 16px;
  2610. ::v-deep .el-alert__content {
  2611. line-height: 1.8;
  2612. }
  2613. }
  2614. </style>