EmployeeArchiveForm.vue 145 KB

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