EmployeeArchiveForm.vue 97 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959
  1. <template>
  2. <el-dialog
  3. :visible.sync="dialogVisible"
  4. :width="fullscreen ? '100%' : '1080px'"
  5. :top="fullscreen ? '0' : '3vh'"
  6. append-to-body
  7. :close-on-click-modal="false"
  8. :close-on-press-escape="true"
  9. :before-close="handleBeforeClose"
  10. :custom-class="'employee-archive-dialog' + (fullscreen ? ' is-fullscreen' : '')"
  11. @closed="resetForm"
  12. >
  13. <div slot="title" class="archive-dialog-title">
  14. <span>{{ isEdit ? '编辑员工档案' : '新增员工档案' }}</span>
  15. <button
  16. type="button"
  17. class="archive-dialog-fullscreen"
  18. :title="fullscreen ? '退出全屏' : '全屏'"
  19. @click.stop="toggleFullscreen"
  20. >
  21. <i :class="fullscreen ? 'el-icon-copy-document' : 'el-icon-full-screen'"></i>
  22. </button>
  23. </div>
  24. <div class="archive-form-summary">
  25. <span class="summary-icon"><i class="el-icon-user"></i></span>
  26. <div class="summary-copy">
  27. <strong>{{ form.name || '新员工档案' }}</strong>
  28. <p>{{ form.employeeNo || '待生成工号' }}</p>
  29. </div>
  30. <div class="summary-completion">
  31. <div><span>资料完整度</span><strong>{{ completionRate }}%</strong></div>
  32. <span class="completion-track"><i :style="{ width: `${completionRate}%` }"></i></span>
  33. </div>
  34. </div>
  35. <el-form ref="form" :model="form" :rules="rules" label-position="top" size="small" class="archive-form-shell">
  36. <el-tabs v-model="activeTab" tab-position="left" class="archive-form-tabs">
  37. <el-tab-pane v-for="section in sections" :key="section.name" :name="section.name">
  38. <span slot="label" class="section-tab-label">
  39. <i :class="section.icon"></i>
  40. <span><strong>{{ section.label }}</strong><small>{{ section.subtitle }}</small></span>
  41. <em>{{ sectionProgress[section.name] }}</em>
  42. </span>
  43. <div v-if="section.name === 'base'" class="section-content">
  44. <header class="section-title">
  45. <div>
  46. <h3>基本信息</h3>
  47. <p>员工身份、组织任职与联系方式,字段与档案模型一一对应</p>
  48. </div><span>必填 {{ requiredProgress.base }}</span>
  49. </header>
  50. <div class="subsection-title"><strong>身份信息</strong></div>
  51. <el-row :gutter="16">
  52. <el-col :xs="24" :sm="12"><el-form-item label="姓名" prop="name"><el-input v-model.trim="form.name"
  53. maxlength="20" placeholder="请输入员工真实姓名" /></el-form-item></el-col>
  54. <el-col :xs="24" :sm="12"><el-form-item label="性别" prop="gender"><el-select v-model="form.gender"
  55. class="full-width"><el-option v-for="item in genderOptions" :key="item" :label="item"
  56. :value="item" /></el-select></el-form-item></el-col>
  57. <el-col :xs="24" :sm="12"><el-form-item label="身份证" prop="idCard"><el-input
  58. v-model.trim="form.idCard" maxlength="18" placeholder="请输入18位身份证号"
  59. @input="handleIdCardInput" @blur="parseIdCard" /></el-form-item></el-col>
  60. <el-col :xs="24" :sm="12"><el-form-item label="出生日期" prop="birthDate"><el-date-picker
  61. v-model="form.birthDate" value-format="yyyy-MM-dd" type="date" placeholder="可由身份证自动生成"
  62. class="full-width" @change="syncAge" /></el-form-item></el-col>
  63. <el-col :xs="24" :sm="12"><el-form-item label="年龄"><el-input :value="form.age || ''" readonly
  64. placeholder="根据出生日期自动计算" /></el-form-item></el-col>
  65. <el-col :xs="24" :sm="12"><el-form-item label="民族"><el-select v-model="form.ethnic" filterable
  66. allow-create default-first-option class="full-width"><el-option v-for="item in ethnicOptions"
  67. :key="item" :label="item" :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.politicalStatus"
  69. maxlength="20" placeholder="党员、预备党员、群众等" /></el-form-item></el-col>
  70. <el-col :xs="24" :sm="12"><el-form-item label="婚姻状态"><el-select v-model="form.maritalStatus"
  71. class="full-width"><el-option v-for="item in maritalOptions" :key="item" :label="item"
  72. :value="item" /></el-select></el-form-item></el-col>
  73. <el-col :xs="24" :sm="12"><el-form-item label="籍贯"><el-input v-model.trim="form.nativePlace"
  74. maxlength="30" placeholder="省 / 市" /></el-form-item></el-col>
  75. <el-col :xs="24" :sm="12"><el-form-item label="最高学历"><el-select v-model="form.education"
  76. class="full-width"><el-option v-for="item in educationOptions" :key="item" :label="item"
  77. :value="item" /></el-select></el-form-item></el-col>
  78. <el-col :xs="24" :sm="12"><el-form-item label="毕业院校"><el-input v-model.trim="form.school" maxlength="40"
  79. placeholder="请输入毕业院校" /></el-form-item></el-col>
  80. <el-col :xs="24" :sm="12"><el-form-item label="户籍地址"><el-input v-model.trim="form.registeredAddress"
  81. maxlength="100" placeholder="请输入户籍地址" /></el-form-item></el-col>
  82. <el-col :xs="24" :sm="12"><el-form-item label="现居住地址"><el-input v-model.trim="form.currentAddress"
  83. maxlength="100" placeholder="请输入现居住地址" /></el-form-item></el-col>
  84. <el-col :xs="24" :sm="12"><el-form-item label="工作地址"><el-input v-model.trim="form.workAddress"
  85. maxlength="60" placeholder="实际办公地点" /></el-form-item></el-col>
  86. </el-row>
  87. <div class="subsection-title"><strong>组织与任职</strong></div>
  88. <el-row :gutter="16">
  89. <el-col :xs="24" :sm="12"><el-form-item label="工号" prop="employeeNo"><el-input
  90. v-model.trim="form.employeeNo" maxlength="20" placeholder="集团全局唯一"><el-button slot="append"
  91. :disabled="isEdit" @click="generateEmployeeNo">生成</el-button></el-input></el-form-item></el-col>
  92. <el-col :xs="24" :sm="12"><el-form-item label="用户账号"><el-input v-model.trim="form.account" maxlength="30"
  93. placeholder="系统登录账号,支持自动生成、手动修改"><template
  94. slot="prepend">@</template></el-input></el-form-item></el-col>
  95. <el-col :xs="24" :sm="12">
  96. <el-form-item label="所属机构" prop="groupId">
  97. <el-select
  98. v-model="form.groupId"
  99. filterable
  100. class="full-width"
  101. placeholder="请选择法人组织"
  102. @change="handleLegalEntityChange"
  103. >
  104. <el-option
  105. v-for="item in legalEntities"
  106. :key="String(item.id)"
  107. :label="item.name"
  108. :value="String(item.id)"
  109. />
  110. </el-select>
  111. </el-form-item>
  112. </el-col>
  113. <el-col :xs="24" :sm="12">
  114. <el-form-item label="所属工厂">
  115. <el-select v-model="form.factoryId" filterable clearable class="full-width"
  116. :loading="factoryLoading"
  117. :disabled="!form.groupId"
  118. :placeholder="form.groupId ? '请选择所属工厂' : '请先选择所属机构'"
  119. @change="handleFactoryChange">
  120. <el-option v-for="item in factoryOptions" :key="String(item.id)" :label="item.name"
  121. :value="item.id" />
  122. </el-select>
  123. </el-form-item>
  124. </el-col>
  125. <el-col :xs="24" :sm="12"><el-form-item label="隶属部门"><el-cascader
  126. :key="`dept-cascader-${form.groupId || 'none'}-${form.factoryId || 'none'}-${deptCascaderKey}`"
  127. v-model="form.deptIds"
  128. :options="deptOptions" :props="deptCascaderProps" filterable clearable collapse-tags
  129. class="full-width"
  130. :disabled="!form.factoryId"
  131. :placeholder="form.factoryId ? '请选择隶属部门(可多选)' : (form.groupId ? '请先选择所属工厂' : '请先选择所属机构')"
  132. @change="handleOrganizationChange" /></el-form-item></el-col>
  133. <el-col :xs="24" :sm="12">
  134. <el-form-item label="直属上级">
  135. <el-select v-model="form.managerId" filterable clearable class="full-width" :loading="managerLoading"
  136. :disabled="!hasDept" :placeholder="hasDept ? '请选择直属上级' : '请先选择隶属部门'"
  137. @change="handleManagerChange">
  138. <el-option v-for="item in managerOptions" :key="item.id" :label="item.name" :value="item.id" />
  139. </el-select>
  140. </el-form-item>
  141. </el-col>
  142. <el-col :xs="24" :sm="12"><el-form-item label="入职时间" prop="hireDate"><el-date-picker
  143. v-model="form.hireDate" value-format="yyyy-MM-dd" type="date" placeholder="请选择入职日期"
  144. class="full-width" /></el-form-item></el-col>
  145. <el-col :xs="24" :sm="12"><el-form-item label="转正日期"><el-date-picker v-model="form.regularDate"
  146. value-format="yyyy-MM-dd" type="date" placeholder="转正生效日期"
  147. class="full-width" /></el-form-item></el-col>
  148. <el-col :xs="24" :sm="12">
  149. <el-form-item label="岗位" prop="positionId">
  150. <el-select :key="`position-select-${positionSelectKey}`" v-model="form.positionId"
  151. filterable clearable class="full-width"
  152. :loading="positionLoading"
  153. :disabled="!hasDept"
  154. :placeholder="hasDept ? '请选择本部门已通过申请的岗位' : '请先选择隶属部门'"
  155. @change="handlePositionChange">
  156. <el-option v-for="item in positionOptions" :key="`pos-${String(item.id)}`"
  157. :label="positionOptionLabel(item)" :value="String(item.id)" />
  158. </el-select>
  159. </el-form-item>
  160. </el-col>
  161. <el-col :xs="24" :sm="12"><el-form-item label="员工状态" prop="status"><el-select v-model="form.status"
  162. class="full-width"><el-option v-for="item in statusOptions" :key="item" :label="item"
  163. :value="item" /></el-select></el-form-item></el-col>
  164. <el-col :xs="24" :sm="12"><el-form-item label="岗位性质"><el-select v-model="form.employmentType" filterable
  165. clearable class="full-width" placeholder="请选择岗位性质">
  166. <el-option v-for="item in employmentOptions" :key="item.key || item.value" :label="item.label"
  167. :value="item.value" />
  168. </el-select></el-form-item></el-col>
  169. <el-col :xs="24" :sm="12"><el-form-item label="岗位层级"><el-select v-model="form.staffingLevel"
  170. class="full-width" :disabled="!staffingLevelOptions.length"
  171. :placeholder="form.positionId ? '请选择岗位层级' : '请先选择岗位'"
  172. @change="handleStaffingLevelChange">
  173. <el-option v-for="item in staffingLevelOptions" :key="item" :label="item"
  174. :value="item" /></el-select></el-form-item></el-col>
  175. <el-col :xs="24" :sm="12"><el-form-item label="关键工序"><el-input v-model.trim="form.keyProcess"
  176. maxlength="30" placeholder="标记特种工序岗位人员" /></el-form-item></el-col>
  177. <el-col :xs="24" :sm="12"><el-form-item label="是否启用">
  178. <div class="enable-field"><el-switch v-model="form.enabled" active-color="#18a875" /><span>{{
  179. form.enabled ? '启用' :
  180. '待岗' }}</span></div>
  181. </el-form-item></el-col>
  182. </el-row>
  183. <div class="subsection-title"><strong>联系方式</strong></div>
  184. <el-row :gutter="16">
  185. <el-col :xs="24" :sm="12"><el-form-item label="手机号" prop="phone"><el-input v-model.trim="form.phone"
  186. maxlength="11" placeholder="请输入 11 位手机号码"
  187. prefix-icon="el-icon-mobile-phone" /></el-form-item></el-col>
  188. <el-col :xs="24" :sm="12"><el-form-item label="邮箱"><el-input v-model.trim="form.email" maxlength="50"
  189. placeholder="name@company.com" prefix-icon="el-icon-message" /></el-form-item></el-col>
  190. <el-col :xs="24" :sm="12"><el-form-item label="微信号"><el-input v-model.trim="form.wechat" maxlength="30"
  191. placeholder="请输入微信号" /></el-form-item></el-col>
  192. <el-col :xs="24" :sm="12"><el-form-item label="紧急联系人"><el-input v-model.trim="form.emergencyContact"
  193. maxlength="20" placeholder="请输入联系人姓名" /></el-form-item></el-col>
  194. <el-col :xs="24" :sm="12"><el-form-item label="紧急联系人关系"><el-input v-model.trim="form.emergencyRelation"
  195. maxlength="20" placeholder="如配偶、父母、子女" /></el-form-item></el-col>
  196. <el-col :xs="24" :sm="12"><el-form-item label="紧急联系电话"><el-input v-model.trim="form.emergencyPhone"
  197. maxlength="11" placeholder="请输入联系电话" /></el-form-item></el-col>
  198. </el-row>
  199. </div>
  200. <div v-else-if="section.name === 'experience'" class="section-content">
  201. <header class="section-title">
  202. <div>
  203. <h3>教育与工作履历</h3>
  204. <p>最高教育经历和入职前工作经历</p>
  205. </div><span>{{ form.previousJobs.length }} 段履历</span>
  206. </header>
  207. <div class="subsection-title"><strong>教育经历</strong><el-button type="text" icon="el-icon-plus"
  208. @click="addEducation">添加教育</el-button></div>
  209. <el-alert v-if="form.educations.length > 1" type="warning" :closable="false" show-icon
  210. title="后端目前仅支持一条最高学历字段(educationBackground/school/major),多条教育经历保存时只会提交第一条。" style="margin-bottom: 10px" />
  211. <div v-if="form.educations.length" class="repeat-list">
  212. <div v-for="(item, index) in form.educations" :key="`edu-${index}`" class="repeat-block">
  213. <div class="repeat-block-heading"><strong>教育经历 {{ index + 1 }}</strong><el-button type="text"
  214. icon="el-icon-delete" @click="removeItem('educations', index)">删除</el-button></div>
  215. <el-row :gutter="14">
  216. <el-col :xs="12" :sm="6"><el-form-item label="最高学历"><el-select v-model="item.education"
  217. class="full-width"><el-option v-for="opt in educationOptions" :key="opt" :label="opt"
  218. :value="opt" /></el-select></el-form-item></el-col>
  219. <el-col :xs="12" :sm="6"><el-form-item label="学位"><el-select v-model="item.degree" clearable
  220. class="full-width" placeholder="请选择"><el-option v-for="opt in ['无', '学士', '硕士', '博士']" :key="opt"
  221. :label="opt" :value="opt" /></el-select></el-form-item></el-col>
  222. <el-col :xs="12" :sm="6"><el-form-item label="毕业日期"><el-date-picker v-model="item.graduationDate"
  223. value-format="yyyy-MM-dd" type="date" placeholder="请选择"
  224. class="full-width" /></el-form-item></el-col>
  225. <el-col :xs="12" :sm="6"><el-form-item label="毕业院校"><el-input v-model.trim="item.school"
  226. maxlength="40" placeholder="请输入毕业院校" /></el-form-item></el-col>
  227. <el-col :xs="24" :sm="12"><el-form-item label="所学专业"><el-input v-model.trim="item.major"
  228. maxlength="40" placeholder="请输入所学专业" /></el-form-item></el-col>
  229. </el-row>
  230. </div>
  231. </div>
  232. <div v-else class="inline-empty">暂无教育经历</div>
  233. <div class="subsection-title"><strong>工作履历</strong><el-button type="text" icon="el-icon-plus"
  234. @click="addPreviousJob">添加履历</el-button></div>
  235. <div v-if="form.previousJobs.length" class="repeat-list">
  236. <div v-for="(item, index) in form.previousJobs" :key="`job-${index}`" class="repeat-block">
  237. <div class="repeat-block-heading"><strong>工作经历 {{ index + 1 }}</strong><el-button type="text"
  238. icon="el-icon-delete" @click="removeItem('previousJobs', index)">删除</el-button></div>
  239. <el-row :gutter="14">
  240. <el-col :xs="12" :sm="4"><el-form-item label="序号"><el-input :value="index + 1"
  241. readonly /></el-form-item></el-col>
  242. <el-col :xs="12" :sm="5"><el-form-item label="工作开始日期" required><el-date-picker
  243. v-model="item.startDate" value-format="yyyy-MM-dd" type="date" placeholder="开始日期"
  244. class="full-width" /></el-form-item></el-col>
  245. <el-col :xs="12" :sm="5"><el-form-item label="工作结束日期" required><el-date-picker v-model="item.endDate"
  246. value-format="yyyy-MM-dd" type="date" placeholder="结束日期"
  247. class="full-width" /></el-form-item></el-col>
  248. <el-col :xs="24" :sm="5"><el-form-item label="前公司名称" required><el-input v-model.trim="item.company"
  249. maxlength="50" placeholder="请输入公司名称" /></el-form-item></el-col>
  250. <el-col :xs="24" :sm="5"><el-form-item label="前公司岗位" required><el-input v-model.trim="item.position"
  251. maxlength="30" /></el-form-item></el-col>
  252. <el-col :xs="24" :sm="6"><el-form-item label="前公司部门"><el-input v-model.trim="item.department"
  253. maxlength="30" /></el-form-item></el-col>
  254. <el-col :xs="24" :sm="6"><el-form-item label="离职原因"><el-input v-model.trim="item.leaveReason"
  255. maxlength="40" /></el-form-item></el-col>
  256. <el-col :span="24"><el-form-item label="工作描述"><el-input v-model.trim="item.description"
  257. type="textarea" :rows="2" maxlength="300" show-word-limit /></el-form-item></el-col>
  258. </el-row>
  259. </div>
  260. </div>
  261. <div v-else class="inline-empty">暂无入职前工作履历</div>
  262. </div>
  263. <div v-else-if="section.name === 'contract'" class="section-content">
  264. <header class="section-title">
  265. <div>
  266. <h3>合同与薪酬银行</h3>
  267. <p>劳动合同只读同步自合同管理;工资卡可在此维护</p>
  268. </div><span>{{ contractState }}</span>
  269. </header>
  270. <div class="subsection-title"><strong>劳动合同(只读)</strong></div>
  271. <el-row :gutter="16">
  272. <el-col :xs="24" :sm="12"><el-form-item label="分类"><el-input :value="form.contract.category" readonly
  273. placeholder="—" /></el-form-item></el-col>
  274. <el-col :xs="24" :sm="12"><el-form-item label="合同编号/名称"><el-input
  275. :value="form.contract.name || form.contract.number" readonly
  276. placeholder="—" /></el-form-item></el-col>
  277. <el-col :xs="24" :sm="8"><el-form-item label="签订时间"><el-input :value="form.contract.signDate" readonly
  278. placeholder="—" /></el-form-item></el-col>
  279. <el-col :xs="24" :sm="8"><el-form-item label="开始时间"><el-input :value="form.contract.startDate" readonly
  280. placeholder="—" /></el-form-item></el-col>
  281. <el-col :xs="24" :sm="8"><el-form-item label="结束时间"><el-input :value="form.contract.endDate" readonly
  282. placeholder="无固定期限/未同步" /></el-form-item></el-col>
  283. <el-col :xs="24" :sm="12"><el-form-item label="生效状态"><el-input :value="form.contract.status" readonly
  284. placeholder="—" /></el-form-item></el-col>
  285. <el-col :span="24"><el-form-item label="备注"><el-input :value="form.contract.remark" type="textarea"
  286. :rows="2" readonly placeholder="—" /></el-form-item></el-col>
  287. </el-row>
  288. <div class="subsection-title"><strong>薪酬银行</strong><el-button type="text" icon="el-icon-plus"
  289. @click="addBankAccount">添加银行</el-button></div>
  290. <div v-if="form.bankAccounts.length" class="repeat-list compact-list">
  291. <div v-for="(item, index) in form.bankAccounts" :key="`bank-${index}`" class="repeat-row bank-row">
  292. <el-input v-model.trim="item.bankName" maxlength="50" placeholder="开户行" />
  293. <el-input v-model.trim="item.bankAccount" maxlength="24" placeholder="银行卡号" />
  294. <el-input v-model.trim="item.bankAccountName" maxlength="20" placeholder="姓名(必填)" />
  295. <el-tooltip content="删除" placement="top"><el-button class="remove-button" icon="el-icon-delete"
  296. @click="removeItem('bankAccounts', index)"></el-button></el-tooltip>
  297. </div>
  298. </div>
  299. <div v-else class="inline-empty">暂无银行账户</div>
  300. </div>
  301. <div v-else-if="section.name === 'social'" class="section-content">
  302. <header class="section-title">
  303. <div>
  304. <h3>社保、资质与技能</h3>
  305. <p>社保公积金、工种、证照与技能列表</p>
  306. </div><span>{{ form.socialSecurityRecords.length }} 条社保记录</span>
  307. </header>
  308. <div class="subsection-title"><strong>社保公积金</strong><el-button type="text" icon="el-icon-plus"
  309. @click="addSocialSecurity">添加记录</el-button></div>
  310. <div v-if="form.socialSecurityRecords.length" class="repeat-list">
  311. <div v-for="(item, index) in form.socialSecurityRecords" :key="`ss-${index}`" class="repeat-block">
  312. <div class="repeat-block-heading"><strong>社保记录 {{ index + 1 }}</strong><el-button type="text"
  313. icon="el-icon-delete" @click="removeItem('socialSecurityRecords', index)">删除</el-button></div>
  314. <el-row :gutter="14">
  315. <el-col :xs="12" :sm="6"><el-form-item label="社保类型"><el-select v-model="item.type" clearable
  316. class="full-width"><el-option v-for="opt in socialSecurityOptions" :key="opt" :label="opt"
  317. :value="opt" /></el-select></el-form-item></el-col>
  318. <el-col :xs="12" :sm="6"><el-form-item label="基数"><el-input-number v-model="item.base" :min="0"
  319. :precision="2" :controls="false" class="full-width" /></el-form-item></el-col>
  320. <el-col :xs="12" :sm="6"><el-form-item label="参保日期"><el-date-picker v-model="item.insuredDate"
  321. value-format="yyyy-MM-dd" type="date" placeholder="请选择"
  322. class="full-width" /></el-form-item></el-col>
  323. <el-col :xs="12" :sm="6"><el-form-item label="停保日期"><el-date-picker v-model="item.stopDate"
  324. value-format="yyyy-MM-dd" type="date" placeholder="在职可为空"
  325. class="full-width" /></el-form-item></el-col>
  326. <el-col :xs="24" :sm="8"><el-form-item label="停缴记录"><el-select v-model="item.stopReason" clearable
  327. class="full-width" placeholder="请选择"><el-option v-for="opt in ['离职', '调转', '其他']" :key="opt"
  328. :label="opt" :value="opt" /></el-select></el-form-item></el-col>
  329. </el-row>
  330. </div>
  331. </div>
  332. <div v-else class="inline-empty">暂无社保记录</div>
  333. <div class="subsection-title"><strong>工种</strong><el-button type="text" icon="el-icon-plus"
  334. @click="addWorkType">添加工种</el-button></div>
  335. <div v-if="form.workTypes.length" class="repeat-list">
  336. <div v-for="(item, index) in form.workTypes" :key="`wt-${index}`" class="repeat-block">
  337. <div class="repeat-block-heading"><strong>工种 {{ index + 1 }}</strong><el-button type="text"
  338. icon="el-icon-delete" @click="removeItem('workTypes', index)">删除</el-button></div>
  339. <el-row :gutter="14">
  340. <el-col :xs="12" :sm="6"><el-form-item label="类型"><el-select v-model="item.type" clearable
  341. class="full-width"><el-option v-for="opt in workTypeCategoryOptions" :key="opt" :label="opt"
  342. :value="opt" /></el-select></el-form-item></el-col>
  343. <el-col :xs="12" :sm="6"><el-form-item label="编码"><el-input v-model.trim="item.code"
  344. maxlength="20" /></el-form-item></el-col>
  345. <el-col :xs="12" :sm="6"><el-form-item label="名称"><el-input v-model.trim="item.name"
  346. maxlength="30" /></el-form-item></el-col>
  347. <el-col :xs="12" :sm="6"><el-form-item label="等级"><el-select v-model="item.level" clearable
  348. class="full-width"><el-option v-for="opt in workTypeLevelOptions" :key="opt" :label="opt"
  349. :value="opt" /></el-select></el-form-item></el-col>
  350. <el-col :xs="12" :sm="6"><el-form-item label="标准工时费"><el-input-number v-model="item.hourlyWage"
  351. :min="0" :precision="2" :controls="false" class="full-width" /></el-form-item></el-col>
  352. <el-col :xs="12" :sm="6"><el-form-item label="工时费单位"><el-select v-model="item.wageUnit" clearable
  353. class="full-width"><el-option v-for="opt in ['元/小时', '元/天', '元/件']" :key="opt" :label="opt"
  354. :value="opt" /></el-select></el-form-item></el-col>
  355. <el-col :xs="12" :sm="6"><el-form-item label="健康状况"><el-select v-model="item.healthStatus" clearable
  356. class="full-width"><el-option v-for="opt in ['优', '良', '一般', '差']" :key="opt" :label="opt"
  357. :value="opt" /></el-select></el-form-item></el-col>
  358. <el-col :xs="12" :sm="6"><el-form-item label="是否特种作业人员"><el-select v-model="item.specialOperator"
  359. class="full-width"><el-option label="是" value="是" /><el-option label="否"
  360. value="否" /></el-select></el-form-item></el-col>
  361. <el-col :span="24"><el-form-item label="证件照">
  362. <fileMain v-model="item.photo" size="mini" />
  363. </el-form-item></el-col>
  364. </el-row>
  365. </div>
  366. </div>
  367. <div v-else class="inline-empty">暂无工种记录</div>
  368. <div class="subsection-title"><strong>证照列表</strong><el-button type="text" icon="el-icon-plus"
  369. @click="addCertificate">添加证照</el-button></div>
  370. <div v-if="form.certificates.length" class="repeat-list">
  371. <div v-for="(item, index) in form.certificates" :key="`cert-${index}`" class="repeat-block">
  372. <div class="repeat-block-heading"><strong>证照 {{ index + 1 }}</strong><el-button type="text"
  373. icon="el-icon-delete" @click="removeItem('certificates', index)">删除</el-button></div>
  374. <el-row :gutter="14">
  375. <el-col :xs="12" :sm="6"><el-form-item label="证照类型" required><el-select v-model="item.type"
  376. class="full-width"><el-option v-for="opt in certificateTypeOptions" :key="opt" :label="opt"
  377. :value="opt" /></el-select></el-form-item></el-col>
  378. <el-col :xs="12" :sm="6"><el-form-item label="证照编号" required><el-input v-model.trim="item.number"
  379. maxlength="30" /></el-form-item></el-col>
  380. <el-col :xs="12" :sm="6"><el-form-item label="等级"><el-select v-model="item.level" clearable
  381. class="full-width"><el-option v-for="opt in certificateLevelOptions" :key="opt" :label="opt"
  382. :value="opt" /></el-select></el-form-item></el-col>
  383. <el-col :xs="12" :sm="6"><el-form-item label="状态" required><el-select v-model="item.status"
  384. class="full-width"><el-option label="有效" value="有效" /><el-option label="失效"
  385. value="失效" /></el-select></el-form-item></el-col>
  386. <el-col :xs="12" :sm="6"><el-form-item label="有效期开始时间" required><el-date-picker
  387. v-model="item.startDate" value-format="yyyy-MM-dd" type="date" placeholder="请选择"
  388. class="full-width" /></el-form-item></el-col>
  389. <el-col :xs="12" :sm="6"><el-form-item label="有效期结束时间" required><el-date-picker v-model="item.endDate"
  390. value-format="yyyy-MM-dd" type="date" placeholder="请选择"
  391. class="full-width" /></el-form-item></el-col>
  392. <el-col :xs="12" :sm="6"><el-form-item label="颁布日期"><el-date-picker v-model="item.issueDate"
  393. value-format="yyyy-MM-dd" type="date" placeholder="请选择"
  394. class="full-width" /></el-form-item></el-col>
  395. <el-col :xs="12" :sm="6"><el-form-item label="证照附件">
  396. <fileMain v-model="item.attachment" size="mini" />
  397. </el-form-item></el-col>
  398. <el-col :span="24"><el-form-item label="备注"><el-input v-model.trim="item.remark" maxlength="200"
  399. placeholder="补充说明信息" /></el-form-item></el-col>
  400. </el-row>
  401. </div>
  402. </div>
  403. <div v-else class="inline-empty">暂无证照记录</div>
  404. <div class="subsection-title"><strong>技能列表</strong><el-button type="text" icon="el-icon-plus"
  405. @click="addSkill">添加技能</el-button></div>
  406. <div v-if="form.skills.length" class="repeat-list">
  407. <div v-for="(item, index) in form.skills" :key="`skill-${index}`" class="repeat-block">
  408. <div class="repeat-block-heading"><strong>技能 {{ index + 1 }}</strong><el-button type="text"
  409. icon="el-icon-delete" @click="removeItem('skills', index)">删除</el-button></div>
  410. <el-row :gutter="14">
  411. <el-col :xs="12" :sm="6"><el-form-item label="分类"><el-select v-model="item.category" clearable
  412. class="full-width"><el-option v-for="opt in skillCategoryOptions" :key="opt" :label="opt"
  413. :value="opt" /></el-select></el-form-item></el-col>
  414. <el-col :xs="12" :sm="6"><el-form-item label="技能名称" required><el-input v-model.trim="item.name"
  415. maxlength="30" /></el-form-item></el-col>
  416. <el-col :xs="12" :sm="6"><el-form-item label="技能等级"><el-select v-model="item.level" clearable
  417. class="full-width"><el-option v-for="opt in skillLevelOptions" :key="opt" :label="opt"
  418. :value="opt" /></el-select></el-form-item></el-col>
  419. <el-col :xs="12" :sm="6"><el-form-item label="技能经验(年限)"><el-input-number v-model="item.years" :min="0"
  420. :precision="1" :controls="false" class="full-width" /></el-form-item></el-col>
  421. <el-col :xs="24" :sm="12"><el-form-item label="证照附件">
  422. <fileMain v-model="item.attachment" size="mini" />
  423. </el-form-item></el-col>
  424. <el-col :span="24"><el-form-item label="备注"><el-input v-model.trim="item.remark" type="textarea"
  425. :rows="2" maxlength="200" placeholder="培训经历、考核情况" /></el-form-item></el-col>
  426. </el-row>
  427. </div>
  428. </div>
  429. <div v-else class="inline-empty">暂无技能记录</div>
  430. <div class="subsection-title attachment-title"><strong>档案附件</strong>
  431. <fileMain v-model="form.files" size="mini" />
  432. </div>
  433. <el-form-item label="档案备注" class="remarks-item"><el-input v-model.trim="form.remarks" type="textarea"
  434. :rows="3" maxlength="500" show-word-limit placeholder="补充需要说明的员工档案信息" /></el-form-item>
  435. </div>
  436. <div v-else class="section-content record-section-content">
  437. <employee-archive-records ref="records" :archive="form" />
  438. </div>
  439. </el-tab-pane>
  440. </el-tabs>
  441. </el-form>
  442. <span slot="footer" class="dialog-footer">
  443. <span></span>
  444. <span>
  445. <el-button @click="closeDialog">取消</el-button>
  446. <el-button type="primary" :loading="saving" @click="submit">保存</el-button>
  447. </span>
  448. </span>
  449. </el-dialog>
  450. </template>
  451. <script>
  452. import EmployeeArchiveRecords from './EmployeeArchiveRecords.vue';
  453. import {
  454. getPositionPage,
  455. getPositionById,
  456. adaptPosition,
  457. getPositionSequenceList,
  458. getPositionSequencePage,
  459. getPositionSequenceLevels,
  460. } from '@/api/hr';
  461. import { listOrganizations, getFactoryAreaPage, getUserPage } from '@/api/organization';
  462. import { getDictByCode } from '@/api/sys';
  463. import { buildOrganizationTree, flattenOrganizationTree } from '@/utils/organization-tree';
  464. import { toDocIds } from '@/components/addDoc/util';
  465. const emptyContract = () => ({ category: '', name: '', signDate: '', startDate: '', endDate: '', files: [], status: '生效', remark: '' });
  466. const emptyForm = () => ({
  467. id: null, avatar: '', name: '', employeeNo: '', gender: '男', birthDate: '', age: null, idCard: '', idCardExpiry: '', ethnic: '汉族',
  468. maritalStatus: '未婚', politicalStatus: '群众', nativePlace: '', householdType: '', healthStatus: '优',
  469. company: '中盈产业集团有限公司', legalEntity: '', groupId: null, factory: '', factoryId: null, deptId: null, deptIds: [], deptName: '',
  470. position: '', positionId: null, positionIds: [], positionBindings: [],
  471. positionType: '', level: '', staffingLevel: '', dutyLevel: '', manager: '', managerId: null,
  472. employmentType: '', status: '全职', hireDate: '', regularDate: '', account: '', enabled: true,
  473. keyProcess: '',
  474. phone: '', email: '', wechat: '', workAddress: '', currentAddress: '', registeredAddress: '', emergencyContact: '', emergencyRelation: '', emergencyPhone: '', familyMembers: [],
  475. education: '本科', school: '',
  476. educations: [],
  477. previousJobs: [],
  478. contract: emptyContract(), bankAccounts: [],
  479. socialSecurityRecords: [],
  480. workTypes: [],
  481. certificates: [],
  482. skills: [],
  483. files: [], remarks: '',
  484. attendanceRecords: [], performanceRecords: [], trainingRecords: [], rewardPunishmentRecords: [], awards: [], positionTransfers: [], contractChanges: [],
  485. leaveInfo: { enabled: false, position: '', department: '', leaveDate: '', leaveType: '', leaveReason: '', serviceYears: '', handoverStatus: '' }
  486. });
  487. export default {
  488. name: 'EmployeeArchiveForm',
  489. components: { EmployeeArchiveRecords },
  490. props: {
  491. visible: Boolean,
  492. employee: { type: Object, default: null },
  493. organizations: { type: Array, default: () => [] },
  494. existingNumbers: { type: Array, default: () => [] }
  495. },
  496. data() {
  497. const validatePhone = (rule, value, callback) => {
  498. if (!value || /^1\d{10}$/.test(value) || /^1\d{2}\*{4}\d{4}$/.test(value)) callback();
  499. else callback(new Error('请输入正确的 11 位手机号码'));
  500. };
  501. const validateIdCard = (rule, value, callback) => {
  502. if (this._skipFormValidate) return callback();
  503. const text = String(value || '').trim();
  504. if (!text) return callback();
  505. // 脱敏展示(如 4301********1234)允许通过
  506. if (/^\d{6}\*{8}[\dXx]{4}$/.test(text)) return callback();
  507. if (!/^\d{17}[\dXx]$/.test(text)) {
  508. return callback(new Error('请输入正确的身份证号码'));
  509. }
  510. const birth = text.slice(6, 14);
  511. const y = Number(birth.slice(0, 4));
  512. const m = Number(birth.slice(4, 6));
  513. const d = Number(birth.slice(6, 8));
  514. const date = new Date(y, m - 1, d);
  515. if (
  516. date.getFullYear() !== y ||
  517. date.getMonth() !== m - 1 ||
  518. date.getDate() !== d ||
  519. y < 1900 ||
  520. date.getTime() > Date.now()
  521. ) {
  522. return callback(new Error('请输入正确的身份证号码'));
  523. }
  524. const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
  525. const codes = '10X98765432';
  526. let sum = 0;
  527. for (let i = 0; i < 17; i += 1) sum += Number(text[i]) * weights[i];
  528. if (codes[sum % 11] !== text[17].toUpperCase()) {
  529. return callback(new Error('请输入正确的身份证号码'));
  530. }
  531. callback();
  532. };
  533. const validateNumber = (rule, value, callback) => {
  534. const duplicate = this.existingNumbers.some((item) => item === value && item !== this.employee?.employeeNo);
  535. if (duplicate) callback(new Error('员工编号已存在'));
  536. else callback();
  537. };
  538. return {
  539. activeTab: 'base', saving: false, fullscreen: false, form: emptyForm(), organizationTree: [],
  540. sections: [
  541. { name: 'base', label: '基本信息', subtitle: '身份/任职/联系', icon: 'el-icon-user' },
  542. { name: 'experience', label: '教育与履历', subtitle: '学习与工作经历', icon: 'el-icon-notebook-2' },
  543. { name: 'contract', label: '合同与薪酬', subtitle: '合同与银行', icon: 'el-icon-document-checked' },
  544. { name: 'social', label: '社保与资质', subtitle: '证照与附件', icon: 'el-icon-medal' },
  545. { name: 'records', label: '业务记录', subtitle: '考勤绩效与异动', icon: 'el-icon-tickets' }
  546. ],
  547. // 岗位设定列表(岗位下拉数据源)
  548. positionOptions: [],
  549. // HR 岗位分页真实主键集合(主数据 postId 如 1,4 不在此集合,禁止对其调 getById)
  550. hrPositionIdSet: {},
  551. // 岗位序列 → 层级主键,用于提交 positionLevelId / positionSequenceId
  552. sequenceLevelMap: {},
  553. // 强制重建隶属部门级联,避免 options 清空后内部 activePath 残留导致 node.level 报错
  554. deptCascaderKey: 0,
  555. // 岗位单选回显:用 key 重建,避免 options 后到时只显示 ID
  556. positionSelectKey: 0,
  557. positionLoading: false,
  558. // 直属上级:按隶属部门联动人员
  559. managerOptions: [],
  560. managerLoading: false,
  561. ethnicOptions: ['汉族', '土家族', '苗族', '侗族', '回族', '瑶族', '其他'],
  562. genderOptions: ['男', '女', '未知'],
  563. statusOptions: ['全职', '兼职', '实习', '正式', '试用', '劳务', '离职'],
  564. maritalOptions: ['未婚', '已婚', '离异', '丧偶'],
  565. educationOptions: ['小学', '初中', '高中', '大专', '本科', '硕士', '博士'],
  566. employmentOptions: [],
  567. staffingLevelOptions: [],
  568. factoryOptions: [],
  569. factoryLoading: false,
  570. socialSecurityOptions: ['四险', '四险一金', '五险', '五险一金', '六险一金'],
  571. contractTypeOptions: ['固定期限', '无固定期限', '以完成一定工作任务为期限', '劳务协议', '实习协议'],
  572. workTypeCategoryOptions: ['维修', '装配', '检验', '机加', '焊接', '其他'],
  573. workTypeLevelOptions: ['初级', '中级', '高级', '技师', '高级技师'],
  574. certificateTypeOptions: ['身份证', '护照', '特种作业证', '职称证', '职业资格证', '其他'],
  575. certificateLevelOptions: ['初级', '中级', '高级'],
  576. skillCategoryOptions: ['专业技能', '办公技能', '语言技能', '特种操作技能', '其他'],
  577. skillLevelOptions: ['了解', '熟练', '精通', '初级', '中级', '高级'],
  578. organizationProps: { value: 'id', label: 'name', children: 'children', emitPath: false, checkStrictly: true },
  579. rules: {
  580. name: [{ required: true, message: '请输入员工姓名', trigger: 'blur' }],
  581. employeeNo: [{ required: true, message: '请输入工号', trigger: 'blur' }, { pattern: /^[A-Za-z0-9_-]{2,20}$/, message: '仅支持字母、数字、下划线和短横线', trigger: 'blur' }, { validator: validateNumber, trigger: 'blur' }],
  582. gender: [{
  583. validator: (rule, value, callback) => {
  584. if (this._skipFormValidate) return callback();
  585. if (!value) callback(new Error('请选择性别'));
  586. else callback();
  587. },
  588. trigger: 'change',
  589. }],
  590. groupId: [{
  591. required: true,
  592. validator: (rule, value, callback) => {
  593. if (this._skipFormValidate) return callback();
  594. if (value == null || value === '') callback(new Error('请选择所属机构'));
  595. else callback();
  596. },
  597. trigger: 'change',
  598. }],
  599. status: [{
  600. validator: (rule, value, callback) => {
  601. if (this._skipFormValidate) return callback();
  602. if (!value) callback(new Error('请选择员工状态'));
  603. else callback();
  604. },
  605. trigger: 'change',
  606. }],
  607. hireDate: [{
  608. required: true,
  609. validator: (rule, value, callback) => {
  610. if (this._skipFormValidate) return callback();
  611. if (!value) callback(new Error('请选择入职时间'));
  612. else callback();
  613. },
  614. trigger: 'change',
  615. }],
  616. positionId: [{
  617. required: true,
  618. validator: (rule, value, callback) => {
  619. if (this._skipFormValidate) return callback();
  620. if (value == null || value === '') {
  621. callback(new Error('请选择岗位'));
  622. return;
  623. }
  624. callback();
  625. },
  626. trigger: 'change',
  627. }],
  628. phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }, { validator: validatePhone, trigger: 'blur' }],
  629. email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
  630. idCard: [{ validator: validateIdCard, trigger: ['blur', 'change'] }],
  631. }
  632. };
  633. },
  634. computed: {
  635. dialogVisible: {
  636. get() { return this.visible; },
  637. set(value) {
  638. if (!value) this.saving = false;
  639. this.$emit('update:visible', value);
  640. }
  641. },
  642. isEdit() { return Boolean(this.employee?.id); },
  643. nameInitial() { return (this.form.name || '新').slice(-1); },
  644. avatarStyle() { const tones = ['#1768e5', '#13a471', '#7c5ce7', '#d08716', '#d25567']; const code = [...(this.form.name || '新')].reduce((sum, char) => sum + char.charCodeAt(0), 0); return { '--avatar-color': tones[code % tones.length] }; },
  645. currentSectionLabel() { return this.sections.find((item) => item.name === this.activeTab)?.label || ''; },
  646. // 所属机构:法人组织(集团/公司),来源真实机构接口 getGroupList
  647. legalEntities() {
  648. const tree = this.organizationTree.length ? this.organizationTree : this.organizations;
  649. const isLegalEntity = (item) => ['10', '20', 'group', 'company'].includes(String(item.type));
  650. const list = flattenOrganizationTree(tree || [])
  651. .filter(isLegalEntity)
  652. .map((item) => ({ ...item, id: String(item.id) }));
  653. // 当前已选机构若不在过滤结果中(类型异常),补进选项以免下拉只显示 ID
  654. const currentId = this.form.groupId != null && this.form.groupId !== '' ? String(this.form.groupId) : '';
  655. if (currentId && !list.some((item) => String(item.id) === currentId)) {
  656. const flat = flattenOrganizationTree(tree || []);
  657. const hit = flat.find((item) => String(item.id) === currentId);
  658. if (hit) {
  659. list.unshift({ ...hit, id: String(hit.id) });
  660. } else if (this.form.legalEntity || this.form.company) {
  661. list.unshift({
  662. id: currentId,
  663. name: this.form.legalEntity || this.form.company,
  664. type: '20'
  665. });
  666. }
  667. }
  668. return list;
  669. },
  670. // 隶属部门:先选机构、工厂后再展示。优先工厂节点下的部门,否则回退到机构下部门
  671. deptOptions() {
  672. const tree = this.organizationTree.length ? this.organizationTree : this.organizations;
  673. if (!this.form.groupId || !this.form.factoryId) return [];
  674. const find = (nodes, id) => {
  675. for (const node of nodes || []) {
  676. if (!node) continue;
  677. if (String(node.id) === String(id)) return node;
  678. const matched = find(node.children || [], id);
  679. if (matched) return matched;
  680. }
  681. return null;
  682. };
  683. const factoryNode = find(tree || [], this.form.factoryId);
  684. const orgNode = find(tree || [], this.form.groupId);
  685. const source = factoryNode?.children?.length
  686. ? factoryNode.children
  687. : (orgNode?.children || []);
  688. const sanitize = (nodes) =>
  689. (nodes || [])
  690. .filter(Boolean)
  691. .map((node) => {
  692. const children = sanitize(node.children || []);
  693. const next = {
  694. ...node,
  695. id: node.id != null ? String(node.id) : node.id,
  696. name: node.name || '',
  697. };
  698. if (children.length) next.children = children;
  699. else delete next.children;
  700. return next;
  701. });
  702. return sanitize(source);
  703. },
  704. deptCascaderProps() {
  705. return { ...this.organizationProps, multiple: true };
  706. },
  707. hasDept() {
  708. return this.normalizeDeptIds(this.form.deptIds).length > 0;
  709. },
  710. requiredProgress() {
  711. const keys = ['name', 'employeeNo', 'gender', 'groupId', 'status', 'hireDate', 'positionId', 'phone'];
  712. return { base: `${keys.filter((key) => { const value = this.form[key]; return Array.isArray(value) ? value.length : Boolean(value); }).length}/${keys.length}` };
  713. },
  714. sectionProgress() {
  715. const count = (keys) => `${keys.filter((key) => { const value = this.form[key]; return Array.isArray(value) ? value.length : Boolean(value); }).length}/${keys.length}`;
  716. return {
  717. base: count(['name', 'employeeNo', 'groupId', 'factory', 'deptIds', 'status', 'hireDate', 'positionId', 'employmentType', 'staffingLevel', 'gender', 'birthDate', 'idCard', 'ethnic', 'politicalStatus', 'maritalStatus', 'nativePlace', 'education', 'school', 'phone', 'email', 'wechat', 'currentAddress', 'registeredAddress', 'workAddress', 'emergencyContact', 'emergencyPhone', 'keyProcess']),
  718. experience: count(['educations', 'previousJobs']),
  719. contract: `${[this.form.contract.number, this.form.contract.type, this.form.bankName, this.form.bankAccount].filter(Boolean).length}/4`,
  720. social: count(['socialSecurityRecords', 'workTypes', 'certificates', 'skills', 'files']),
  721. records: `${['attendanceRecords', 'performanceRecords', 'trainingRecords', 'rewardPunishmentRecords', 'awards', 'positionTransfers', 'contractChanges'].reduce((sum, key) => sum + this.form[key].length, 0) + (this.form.leaveInfo.enabled ? 1 : 0)}`
  722. };
  723. },
  724. completionRate() {
  725. const values = [this.form.name, this.form.employeeNo, this.form.groupId, this.form.positionId, this.form.status, this.form.hireDate, this.form.phone, this.form.gender, this.form.email, this.form.birthDate, this.form.education, this.form.school, this.form.currentAddress, this.form.contract.number, this.form.bankAccount, this.form.insuredDate, this.form.skills.length, this.form.files.length];
  726. return Math.round(values.filter(Boolean).length / values.length * 100);
  727. },
  728. contractState() { return this.form.contract.name ? (this.form.contract.endDate ? '合同资料已填写' : '待补结束日期') : '待录入合同'; }
  729. },
  730. watch: { visible(value) { if (value) this.loadForm(); } },
  731. methods: {
  732. loadForm() {
  733. this._skipFormValidate = true;
  734. this.activeTab = 'base';
  735. const source = this.employee ? JSON.parse(JSON.stringify(this.employee)) : {};
  736. const empty = emptyForm();
  737. const statusMap = { active: '全职', probation: '试用', inactive: '离职', disabled: '离职' };
  738. this.form = {
  739. ...empty, ...source,
  740. groupId: source.groupId != null && source.groupId !== ''
  741. ? String(source.groupId)
  742. : (source.legalEntityId != null && source.legalEntityId !== '' ? String(source.legalEntityId) : null),
  743. legalEntity: source.legalEntity || source.company || '',
  744. company: source.company || source.legalEntity || '',
  745. status: source.formStatus || statusMap[source.status] || source.status || '全职',
  746. hireDate: source.hireDate || source.joinDate || '',
  747. factoryId: source.factoryId != null && source.factoryId !== '' ? String(source.factoryId) : null,
  748. factory: source.factory || source.factoryName || '',
  749. deptIds: this.normalizeDeptIds(source.deptIds?.length ? source.deptIds : source.deptId),
  750. deptName: source.deptName || '',
  751. managerId: source.managerId != null && source.managerId !== '' ? String(source.managerId) : null,
  752. manager: source.manager || '',
  753. positionIds: this.normalizePositionIds(source).slice(0, 1),
  754. positionBindings: Array.isArray(source.positionBindings)
  755. ? source.positionBindings.slice(0, 1)
  756. : [],
  757. positionId: this.normalizePositionIds(source)[0] || null,
  758. position: source.position || source.postName || '',
  759. contract: { ...emptyContract(), ...(source.contract || {}) },
  760. leaveInfo: { ...empty.leaveInfo, ...(source.leaveInfo || {}) },
  761. familyMembers: source.familyMembers || [], files: toDocIds(source.files),
  762. // 教育经历:兼容旧的单条 education/degree/school 字段
  763. educations: source.educations?.length
  764. ? source.educations
  765. : (source.education
  766. ? [{ education: source.education || '本科', degree: source.degree || '', graduationDate: source.graduationDate || '', school: source.school || '', major: source.major || '' }]
  767. : []),
  768. // 薪酬银行:兼容旧的单条 bankName/bankAccount 字段
  769. bankAccounts: source.bankAccounts?.length
  770. ? source.bankAccounts
  771. : (source.bankName || source.bankAccount
  772. ? [{ bankName: source.bankName || '', bankAccount: source.bankAccount || '', bankAccountName: source.bankAccountName || '' }]
  773. : []),
  774. // 工作履历:兼容旧的 dateRange
  775. previousJobs: (source.previousJobs || []).map((item) => ({
  776. ...item,
  777. startDate: item.startDate || item.dateRange?.[0] || '',
  778. endDate: item.endDate || item.dateRange?.[1] || '',
  779. })),
  780. // 技能:兼容旧字符串数组
  781. skills: (source.skills || []).map((item) => (typeof item === 'string' ? { name: item, category: '', level: '', years: 0, attachment: [], remark: '' } : { ...item, attachment: toDocIds(item.attachment) })),
  782. certificates: (source.certificates || []).map((item) => ({ ...item, attachment: toDocIds(item.attachment) })),
  783. socialSecurityRecords: source.socialSecurityRecords || [],
  784. workTypes: (source.workTypes || []).map((item) => ({ ...item, photo: toDocIds(item.photo) })),
  785. attendanceRecords: source.attendanceRecords || [], performanceRecords: source.performanceRecords || [],
  786. trainingRecords: (source.trainingRecords || []).map((item) => ({ ...item, attachment: toDocIds(item.attachment) })),
  787. rewardPunishmentRecords: (source.rewardPunishmentRecords || []).map((item) => ({ ...item, attachment: toDocIds(item.attachment) })), awards: source.awards || [], positionTransfers: source.positionTransfers || [], contractChanges: source.contractChanges || []
  788. };
  789. this.loadEmploymentTypeOptions();
  790. this.loadSequences()
  791. .then(() => this.loadPositions())
  792. .then(() => {
  793. this.remapLegacyPositionIds();
  794. this.mergeBoundPositionOptions();
  795. this.syncStaffingLevelsFromPosition();
  796. });
  797. this.loadOrganizations().then(() => {
  798. this.normalizeDeptAgainstOptions();
  799. this.loadFactories().then(() => this.syncFactoryDisplay());
  800. this.loadManagerOptions({ keepSelection: true });
  801. });
  802. this.syncAge();
  803. if (!this.employee) this.generateEmployeeNo();
  804. this.$nextTick(() => {
  805. this.$refs.form?.clearValidate();
  806. this.$nextTick(() => {
  807. this._skipFormValidate = false;
  808. });
  809. });
  810. },
  811. normalizePositionIds(source = {}) {
  812. let ids = [];
  813. if (Array.isArray(source.positionBindings) && source.positionBindings.length) {
  814. // 主岗优先,避免回显顺序错乱
  815. ids = [...source.positionBindings]
  816. .sort((a, b) => Number(b.primaryFlag || 0) - Number(a.primaryFlag || 0))
  817. .map((item) => (item.positionId != null ? String(item.positionId) : ''))
  818. .filter(Boolean);
  819. } else if (Array.isArray(source.positionIds) && source.positionIds.length) {
  820. ids = source.positionIds.map(String);
  821. } else if (source.positionId != null && source.positionId !== '') {
  822. ids = [String(source.positionId)];
  823. } else if (source.postId != null && source.postId !== '') {
  824. ids = [String(source.postId)];
  825. }
  826. // 兼容 "id1,id2" 整串
  827. return Array.from(
  828. new Set(
  829. ids
  830. .flatMap((id) => String(id).split(/[,,]/))
  831. .map((item) => item.trim())
  832. .filter(Boolean),
  833. ),
  834. );
  835. },
  836. positionFallbackNames() {
  837. return String(this.form.position || '')
  838. .split(/[,,、]/)
  839. .map((item) => item.trim())
  840. .filter(Boolean);
  841. },
  842. positionOptionLabel(item) {
  843. return item.positionName || item.name || (item.id ? `岗位 ${item.id}` : '未命名岗位');
  844. },
  845. dedupePositionOptions(list = []) {
  846. const map = new Map();
  847. list.forEach((item) => {
  848. if (item == null || item.id == null || item.id === '') return;
  849. const id = String(item.id);
  850. if (!map.has(id)) {
  851. map.set(id, { ...item, id });
  852. return;
  853. }
  854. // 后写入的若带名称则补全
  855. const prev = map.get(id);
  856. map.set(id, {
  857. ...prev,
  858. ...item,
  859. id,
  860. positionName: item.positionName || item.name || prev.positionName || prev.name,
  861. name: item.name || item.positionName || prev.name || prev.positionName
  862. });
  863. });
  864. return Array.from(map.values());
  865. },
  866. mergeBoundPositionOptions() {
  867. const fallbackNames = this.positionFallbackNames();
  868. const bindings = this.form.positionBindings || [];
  869. const selectedIds = (this.form.positionIds || []).map(String).filter(Boolean);
  870. const extras = [];
  871. const pushExtra = (positionId, positionName, binding = {}) => {
  872. if (!positionId) return;
  873. extras.push({
  874. id: String(positionId),
  875. positionName: positionName || binding.positionName || '',
  876. name: positionName || binding.positionName || '',
  877. positionSequence: binding.positionSequenceName || '',
  878. positionSequenceName: binding.positionSequenceName || '',
  879. positionSequenceId: binding.positionSequenceId || '',
  880. level: binding.positionLevelName || '',
  881. positionLevel: binding.positionLevelName || '',
  882. positionLevelId: binding.positionLevelId || '',
  883. sequenceId: binding.positionSequenceId || '',
  884. });
  885. };
  886. selectedIds.forEach((id, index) => {
  887. const binding = bindings.find((item) => String(item.positionId) === id) || {};
  888. const name =
  889. binding.positionName ||
  890. fallbackNames[index] ||
  891. '';
  892. pushExtra(id, name, binding);
  893. });
  894. bindings.forEach((item, index) => {
  895. if (item.positionId == null || item.positionId === '') return;
  896. if (selectedIds.includes(String(item.positionId))) return;
  897. pushExtra(
  898. item.positionId,
  899. item.positionName || fallbackNames[index] || '',
  900. item,
  901. );
  902. });
  903. // 已选岗位放到前面,保证 el-select 标签优先匹配到名称
  904. this.positionOptions = this.dedupePositionOptions([
  905. ...extras,
  906. ...(this.positionOptions || []),
  907. ]);
  908. this.syncPositionDisplay();
  909. this.refreshPositionSelectEcho();
  910. },
  911. /**
  912. * 主数据短 postId(如 1,4)按名称映射;长整型视为 HR 岗位主键,缺失时保留并补名称,禁止误删导致回显成裸 ID
  913. */
  914. remapLegacyPositionIds() {
  915. const ids = (this.form.positionIds || []).map(String).filter(Boolean);
  916. if (!ids.length) return;
  917. const fallbackNames = this.positionFallbackNames();
  918. const hrOptions = (this.positionOptions || []).filter(
  919. (item) => item && this.hrPositionIdSet[String(item.id)],
  920. );
  921. const findHrByName = (name) => {
  922. const text = String(name || '').trim();
  923. if (!text) return null;
  924. return (
  925. hrOptions.find((item) => {
  926. const label = String(item.positionName || item.name || '').trim();
  927. return label === text;
  928. }) || null
  929. );
  930. };
  931. const isLikelyHrPositionId = (id) => /^\d{15,}$/.test(String(id).trim());
  932. const nextIds = [];
  933. const nextBindings = [];
  934. let changed = false;
  935. ids.forEach((key, index) => {
  936. const binding =
  937. (this.form.positionBindings || []).find((item) => String(item.positionId) === key) || {};
  938. const opt = this.positionOptions.find((item) => String(item.id) === key) || {};
  939. let name =
  940. binding.positionName ||
  941. opt.positionName ||
  942. opt.name ||
  943. fallbackNames[index] ||
  944. '';
  945. if (this.hrPositionIdSet[key] || isLikelyHrPositionId(key)) {
  946. if (!name && fallbackNames[index]) {
  947. name = fallbackNames[index];
  948. changed = true;
  949. }
  950. nextIds.push(key);
  951. nextBindings.push({
  952. ...binding,
  953. positionId: key,
  954. positionName: name || binding.positionName || '',
  955. primaryFlag: nextIds.length === 1 ? 1 : 0,
  956. });
  957. return;
  958. }
  959. const matched = findHrByName(name);
  960. if (matched) {
  961. const realId = String(matched.id);
  962. if (!nextIds.includes(realId)) {
  963. nextIds.push(realId);
  964. nextBindings.push({
  965. ...binding,
  966. positionId: realId,
  967. positionName: matched.positionName || matched.name || name,
  968. primaryFlag: nextIds.length === 1 ? 1 : 0,
  969. });
  970. }
  971. changed = true;
  972. return;
  973. }
  974. // 仅丢弃无法识别的短旧 postId
  975. changed = true;
  976. });
  977. if (!changed && nextIds.join(',') === ids.join(',')) {
  978. // 名称可能仍需回填
  979. const needNameFill = nextBindings.some((item, index) => !item.positionName && fallbackNames[index]);
  980. if (!needNameFill) return;
  981. }
  982. this.form.positionIds = nextIds.slice(0, 1);
  983. this.form.positionBindings = nextBindings.slice(0, 1).map((item) => ({
  984. ...item,
  985. positionName: item.positionName || fallbackNames[0] || '',
  986. primaryFlag: 1,
  987. }));
  988. this.form.positionId = this.form.positionIds[0] || null;
  989. this.form.position =
  990. this.form.positionBindings[0]?.positionName || this.form.position;
  991. this.syncPositionDisplay();
  992. },
  993. /** Element 多选在 options 异步到达后需重建一次,否则标签可能只剩裸 ID */
  994. refreshPositionSelectEcho() {
  995. const id = this.form.positionId != null && this.form.positionId !== ''
  996. ? String(this.form.positionId)
  997. : (this.form.positionIds || []).map(String).filter(Boolean)[0] || null;
  998. this.form.positionId = id;
  999. this.form.positionIds = id ? [id] : [];
  1000. this.positionSelectKey += 1;
  1001. this.$nextTick(() => {
  1002. this.syncPositionDisplay();
  1003. this.$refs.form?.clearValidate('positionId');
  1004. });
  1005. },
  1006. syncPositionDisplay() {
  1007. const fallbackNames = this.positionFallbackNames();
  1008. const key = this.form.positionId != null && this.form.positionId !== ''
  1009. ? String(this.form.positionId)
  1010. : '';
  1011. const selected = key
  1012. ? (() => {
  1013. const opt = this.positionOptions.find((item) => String(item.id) === key) || {};
  1014. const binding =
  1015. (this.form.positionBindings || []).find((item) => String(item.positionId) === key) || {};
  1016. const meta = this.resolvePositionLevelMeta(opt, {
  1017. ...binding,
  1018. positionLevelName: this.form.staffingLevel || binding.positionLevelName,
  1019. });
  1020. return [{
  1021. positionId: key,
  1022. positionName:
  1023. opt.positionName ||
  1024. opt.name ||
  1025. binding.positionName ||
  1026. fallbackNames[0] ||
  1027. '',
  1028. positionLevelId: meta.positionLevelId,
  1029. positionLevelName: meta.positionLevelName,
  1030. positionSequenceId: meta.positionSequenceId,
  1031. positionSequenceName: meta.positionSequenceName,
  1032. primaryFlag: 1,
  1033. }];
  1034. })()
  1035. : [];
  1036. this.form.positionBindings = selected;
  1037. this.form.positionIds = key ? [key] : [];
  1038. this.form.position = selected[0]?.positionName || '';
  1039. this.form.positionId = selected[0]?.positionId || null;
  1040. this.form.level = selected[0]?.positionLevelName || this.form.level;
  1041. if (selected[0]?.positionLevelName) {
  1042. this.form.staffingLevel = selected[0].positionLevelName;
  1043. }
  1044. },
  1045. isNumericId(value) {
  1046. return value != null && value !== '' && /^-?\d+$/.test(String(value).trim());
  1047. },
  1048. findSequenceMeta(sequenceId = '', sequenceName = '') {
  1049. const map = this.sequenceLevelMap || {};
  1050. const name = String(sequenceName || '').trim();
  1051. if (name && map[name]) return map[name];
  1052. const sid = this.isNumericId(sequenceId) ? String(sequenceId).trim() : '';
  1053. if (sid && map[`id:${sid}`]) return map[`id:${sid}`];
  1054. if (sid) {
  1055. return Object.values(map).find((item) => String(item.sequenceId) === sid) || null;
  1056. }
  1057. if (name) {
  1058. return (
  1059. Object.values(map).find(
  1060. (item) => String(item.sequenceName || '').trim() === name,
  1061. ) || null
  1062. );
  1063. }
  1064. return null;
  1065. },
  1066. /**
  1067. * 人员岗位关联必填:positionLevelId / positionSequenceId
  1068. * 岗位分页通常只有序列名与层级文案,需用岗位序列接口映射主键
  1069. */
  1070. resolvePositionLevelMeta(opt = {}, binding = {}) {
  1071. let positionSequenceId = binding.positionSequenceId || opt.sequenceId || opt.positionSequenceId || '';
  1072. let positionSequenceName = String(
  1073. binding.positionSequenceName || opt.positionSequence || opt.positionSequenceName || '',
  1074. ).trim();
  1075. let positionLevelId = binding.positionLevelId || opt.positionLevelId || '';
  1076. let positionLevelName =
  1077. binding.positionLevelName || opt.positionLevel || opt.level || '';
  1078. const levelNames = [];
  1079. const pushName = (value) => {
  1080. const text = String(value || '').trim();
  1081. if (text && !levelNames.includes(text)) levelNames.push(text);
  1082. };
  1083. (opt.positionLevels || []).forEach(pushName);
  1084. pushName(positionLevelName);
  1085. pushName(opt.positionLevel);
  1086. pushName(opt.level);
  1087. const seqMeta = this.findSequenceMeta(positionSequenceId, positionSequenceName);
  1088. if (seqMeta) {
  1089. if (!positionSequenceName) positionSequenceName = seqMeta.sequenceName || '';
  1090. if (!this.isNumericId(positionSequenceId) && this.isNumericId(seqMeta.sequenceId)) {
  1091. positionSequenceId = String(seqMeta.sequenceId).trim();
  1092. }
  1093. }
  1094. const levels = seqMeta?.levels || [];
  1095. if (!this.isNumericId(positionLevelId) && levels.length) {
  1096. const matched =
  1097. levels.find((item) =>
  1098. levelNames.some(
  1099. (name) =>
  1100. name === item.label ||
  1101. name === item.value ||
  1102. String(item.id) === name,
  1103. ),
  1104. ) || levels[0];
  1105. if (matched && this.isNumericId(matched.id)) {
  1106. positionLevelId = String(matched.id).trim();
  1107. positionLevelName = matched.label || matched.value || positionLevelName;
  1108. }
  1109. } else if (this.isNumericId(positionLevelId) && !positionLevelName && levels.length) {
  1110. const matched = levels.find((item) => String(item.id) === String(positionLevelId));
  1111. if (matched) positionLevelName = matched.label || matched.value || '';
  1112. }
  1113. return {
  1114. positionSequenceId: this.isNumericId(positionSequenceId) ? String(positionSequenceId).trim() : '',
  1115. positionSequenceName: positionSequenceName || '',
  1116. positionLevelId: this.isNumericId(positionLevelId) ? String(positionLevelId).trim() : '',
  1117. positionLevelName: positionLevelName || '',
  1118. };
  1119. },
  1120. /** 写入 sequenceLevelMap:按序列名 + id:主键 双索引 */
  1121. putSequenceMeta(map, item = {}) {
  1122. const name = String(item.sequence || item.label || item.name || '').trim();
  1123. const sequenceId = this.isNumericId(item.id) ? String(item.id).trim() : '';
  1124. if (!name && !sequenceId) return;
  1125. const levels = (item.levelList || item.children || [])
  1126. .map((level) => {
  1127. const id = this.isNumericId(level.id) ? String(level.id).trim() : '';
  1128. const label = level.label || level.value || level.level || level.positionLevel || '';
  1129. return { id, label, value: label };
  1130. })
  1131. .filter((level) => level.id);
  1132. const entry = { sequenceId, sequenceName: name, levels };
  1133. if (name) {
  1134. const prev = map[name];
  1135. map[name] = {
  1136. ...(prev || {}),
  1137. ...entry,
  1138. levels: levels.length ? levels : (prev?.levels || []),
  1139. sequenceId: sequenceId || prev?.sequenceId || '',
  1140. };
  1141. }
  1142. if (sequenceId) map[`id:${sequenceId}`] = entry;
  1143. },
  1144. async loadSequences() {
  1145. const map = {};
  1146. try {
  1147. // 与岗位档案编辑一致:page 接口 levelList 带层级主键
  1148. const page = await getPositionSequencePage({ pageNum: 1, size: 1000, status: '1' });
  1149. (page?.list || []).forEach((item) => this.putSequenceMeta(map, item));
  1150. } catch (error) {
  1151. // page 失败时再走列表接口
  1152. }
  1153. if (!Object.keys(map).length) {
  1154. try {
  1155. const list = await getPositionSequenceList();
  1156. (Array.isArray(list) ? list : []).forEach((item) => this.putSequenceMeta(map, item));
  1157. } catch (error) {
  1158. // ignore
  1159. }
  1160. }
  1161. this.sequenceLevelMap = map;
  1162. },
  1163. enrichSelectedPositions() {
  1164. this.remapLegacyPositionIds();
  1165. this.mergeBoundPositionOptions();
  1166. },
  1167. /** 保存前确保每岗都有序列/层级主键:本地映射 → 缺则拉 getById 再映射 */
  1168. async ensurePositionMetaForSave() {
  1169. this.enrichSelectedPositions();
  1170. const missing = (this.form.positionBindings || []).filter(
  1171. (item) => !item.positionLevelId || !item.positionSequenceId,
  1172. );
  1173. if (!missing.length) return true;
  1174. await Promise.all(
  1175. missing.map(async (item) => {
  1176. const key = String(item.positionId || '');
  1177. if (!key) return;
  1178. try {
  1179. const detail = await Promise.race([
  1180. getPositionById(key),
  1181. new Promise((_, reject) =>
  1182. setTimeout(() => reject(new Error('岗位详情超时')), 8000),
  1183. ),
  1184. ]);
  1185. if (!detail) return;
  1186. // 若序列层级表里还没有该序列,按序列名补拉层级
  1187. const seqName = detail.positionSequence || '';
  1188. if (seqName && !this.findSequenceMeta('', seqName)?.levels?.length) {
  1189. try {
  1190. const levels = await getPositionSequenceLevels(seqName);
  1191. const seqMeta = this.findSequenceMeta('', seqName);
  1192. const sequenceId = seqMeta?.sequenceId || '';
  1193. const mapped = (Array.isArray(levels) ? levels : []).map((level) => ({
  1194. id: level.id != null ? String(level.id) : '',
  1195. label: level.level || level.levelName || level.label || '',
  1196. value: level.level || level.levelName || level.value || '',
  1197. })).filter((level) => this.isNumericId(level.id));
  1198. if (seqName) {
  1199. this.$set(this.sequenceLevelMap, seqName, {
  1200. sequenceId,
  1201. sequenceName: seqName,
  1202. levels: mapped,
  1203. });
  1204. }
  1205. } catch (error) {
  1206. // ignore
  1207. }
  1208. }
  1209. const merged = {
  1210. id: key,
  1211. positionName: detail.positionName || detail.name || item.positionName || '',
  1212. name: detail.name || detail.positionName || item.positionName || '',
  1213. positionSequence: detail.positionSequence || '',
  1214. positionSequenceName: detail.positionSequence || '',
  1215. sequenceId: detail.sequenceId != null ? String(detail.sequenceId) : '',
  1216. positionSequenceId: detail.sequenceId != null ? String(detail.sequenceId) : '',
  1217. positionLevel: detail.positionLevel || '',
  1218. positionLevels: detail.positionLevels || [],
  1219. };
  1220. // 用序列名反查序列主键
  1221. if (!merged.sequenceId && merged.positionSequence) {
  1222. const seqMeta = this.findSequenceMeta('', merged.positionSequence);
  1223. if (seqMeta?.sequenceId) {
  1224. merged.sequenceId = seqMeta.sequenceId;
  1225. merged.positionSequenceId = seqMeta.sequenceId;
  1226. }
  1227. }
  1228. this.positionOptions = this.dedupePositionOptions([
  1229. merged,
  1230. ...(this.positionOptions || []),
  1231. ]);
  1232. } catch (error) {
  1233. // 单岗失败不阻断其余岗
  1234. }
  1235. }),
  1236. );
  1237. this.syncPositionDisplay();
  1238. const stillMissing = (this.form.positionBindings || []).find(
  1239. (item) => !item.positionLevelId || !item.positionSequenceId,
  1240. );
  1241. if (stillMissing) {
  1242. this.activeTab = 'base';
  1243. this.$message.warning(
  1244. `岗位「${stillMissing.positionName || stillMissing.positionId}」缺少岗位序列或层级配置,请在岗位档案中维护后再保存`,
  1245. );
  1246. return false;
  1247. }
  1248. return true;
  1249. },
  1250. /** 强制关闭:复位 loading,并清掉可能残留的 Element 遮罩 */
  1251. closeDialog() {
  1252. this.saving = false;
  1253. this.dialogVisible = false;
  1254. this.clearOrphanOverlays();
  1255. },
  1256. handleBeforeClose(done) {
  1257. this.saving = false;
  1258. if (typeof done === 'function') done();
  1259. this.$nextTick(() => this.clearOrphanOverlays());
  1260. },
  1261. clearOrphanOverlays() {
  1262. this.$nextTick(() => {
  1263. try {
  1264. const openDialog = Array.from(document.querySelectorAll('.el-dialog__wrapper')).some((el) => {
  1265. if (!el || el.style.display === 'none') return false;
  1266. const style = window.getComputedStyle(el);
  1267. return style.display !== 'none' && style.visibility !== 'hidden';
  1268. });
  1269. if (openDialog) return;
  1270. document.querySelectorAll('body > .v-modal').forEach((el) => el.remove());
  1271. document.body.classList.remove('el-popup-parent--hidden');
  1272. if (document.body.style.overflow === 'hidden') {
  1273. document.body.style.removeProperty('overflow');
  1274. }
  1275. } catch (error) {
  1276. // ignore
  1277. }
  1278. });
  1279. },
  1280. toggleFullscreen() {
  1281. this.fullscreen = !this.fullscreen;
  1282. },
  1283. resetForm() {
  1284. this._skipFormValidate = true;
  1285. this.saving = false;
  1286. this.fullscreen = false;
  1287. // 先抬高 key,避免空 options 触发 Cascader 内部 null.level
  1288. this.deptCascaderKey += 1;
  1289. this.positionSelectKey += 1;
  1290. this.activeTab = 'base';
  1291. this.managerOptions = [];
  1292. this.managerLoading = false;
  1293. this.factoryOptions = [];
  1294. this.factoryLoading = false;
  1295. this.staffingLevelOptions = [];
  1296. this.positionOptions = [];
  1297. this._managerRequestKey = '';
  1298. this.$nextTick(() => {
  1299. this.form = emptyForm();
  1300. this.$refs.form?.clearValidate();
  1301. this.clearOrphanOverlays();
  1302. });
  1303. },
  1304. 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')); },
  1305. normalizeDeptIds(value) {
  1306. const list = Array.isArray(value) ? value : value != null && value !== '' ? [value] : [];
  1307. return Array.from(
  1308. new Set(
  1309. list
  1310. .flat(2)
  1311. .map((id) => String(id == null ? '' : id).trim())
  1312. .filter((id) => id && id !== 'null' && id !== 'undefined'),
  1313. ),
  1314. );
  1315. },
  1316. findDeptNode(id, nodes = this.deptOptions) {
  1317. for (const node of nodes || []) {
  1318. if (!node) continue;
  1319. if (String(node.id) === String(id)) return node;
  1320. const matched = this.findDeptNode(id, node.children || []);
  1321. if (matched) return matched;
  1322. }
  1323. return null;
  1324. },
  1325. applyDeptIds(ids, { resetDependents = false } = {}) {
  1326. const list = this.normalizeDeptIds(ids).filter(
  1327. (id) => String(id) !== String(this.form.groupId || ''),
  1328. );
  1329. this.form.deptIds = list;
  1330. this.form.deptId = list[0] || null;
  1331. this.form.deptName = list
  1332. .map((id) => this.findDeptNode(id)?.name)
  1333. .filter(Boolean)
  1334. .join('、');
  1335. if (resetDependents) {
  1336. this.loadManagerOptions({ keepSelection: false });
  1337. this.resetPositionsByDepartment();
  1338. }
  1339. },
  1340. handleOrganizationChange(ids) {
  1341. this.applyDeptIds(ids, { resetDependents: true });
  1342. },
  1343. handleLegalEntityChange(id) {
  1344. const selected = this.legalEntities.find((item) => String(item.id) === String(id));
  1345. this.form.legalEntity = selected?.name || '';
  1346. this.form.company = selected?.name || this.form.company;
  1347. this.deptCascaderKey += 1;
  1348. this.form.factoryId = null;
  1349. this.form.factory = '';
  1350. this.factoryOptions = [];
  1351. this.applyDeptIds([]);
  1352. this.form.managerId = null;
  1353. this.form.manager = '';
  1354. this.managerOptions = [];
  1355. this.resetPositionsByDepartment();
  1356. this.loadFactories();
  1357. },
  1358. normalizeDeptAgainstOptions() {
  1359. const ids = this.normalizeDeptIds(
  1360. this.form.deptIds?.length ? this.form.deptIds : this.form.deptId,
  1361. ).filter((id) => String(id) !== String(this.form.groupId || ''));
  1362. const valid = ids.filter((id) => this.findDeptNode(id));
  1363. this.applyDeptIds(valid.length ? valid : ids);
  1364. },
  1365. handleManagerChange(id) {
  1366. const matched = this.managerOptions.find((item) => String(item.id) === String(id));
  1367. this.form.managerId = id != null && id !== '' ? String(id) : null;
  1368. this.form.manager = matched?.name || '';
  1369. },
  1370. mergeCurrentManagerOption() {
  1371. const id = this.form.managerId;
  1372. if (id == null || id === '') return;
  1373. const exists = this.managerOptions.some((item) => String(item.id) === String(id));
  1374. if (exists) return;
  1375. this.managerOptions.unshift({
  1376. id: String(id),
  1377. name: this.form.manager || '未命名人员'
  1378. });
  1379. },
  1380. async loadManagerOptions({ keepSelection = false } = {}) {
  1381. const deptId = this.form.deptId;
  1382. if (!keepSelection) {
  1383. this.form.managerId = null;
  1384. this.form.manager = '';
  1385. }
  1386. if (!deptId) {
  1387. this.managerOptions = [];
  1388. return;
  1389. }
  1390. const requestKey = String(deptId);
  1391. this._managerRequestKey = requestKey;
  1392. this.managerLoading = true;
  1393. try {
  1394. const data = await getUserPage({
  1395. pageNum: 1,
  1396. size: 99999,
  1397. groupId: deptId,
  1398. isQueryLZ: 0
  1399. });
  1400. if (this._managerRequestKey !== requestKey) return;
  1401. const selfId = this.form.id != null ? String(this.form.id) : '';
  1402. this.managerOptions = (data?.list || [])
  1403. .map((item) => ({
  1404. id: item.id == null ? item.id : String(item.id),
  1405. name: item.name || ''
  1406. }))
  1407. .filter((item) => item.id != null && item.id !== '' && item.name && String(item.id) !== selfId);
  1408. if (this.form.managerId != null && this.form.managerId !== '') {
  1409. this.form.managerId = String(this.form.managerId);
  1410. }
  1411. this.mergeCurrentManagerOption();
  1412. } catch (error) {
  1413. if (this._managerRequestKey !== requestKey) return;
  1414. this.managerOptions = [];
  1415. this.mergeCurrentManagerOption();
  1416. } finally {
  1417. if (this._managerRequestKey === requestKey) {
  1418. this.managerLoading = false;
  1419. }
  1420. }
  1421. },
  1422. async loadOrganizations() {
  1423. try {
  1424. const list = await listOrganizations();
  1425. this.organizationTree = buildOrganizationTree(Array.isArray(list) ? list : []);
  1426. } catch (error) {
  1427. this.organizationTree = [];
  1428. }
  1429. },
  1430. collectFactoryOrgs(groupId) {
  1431. if (!groupId) return [];
  1432. const tree = this.organizationTree.length ? this.organizationTree : this.organizations;
  1433. const find = (nodes) => {
  1434. for (const node of nodes || []) {
  1435. if (!node) continue;
  1436. if (String(node.id) === String(groupId)) return node;
  1437. const matched = find(node.children || []);
  1438. if (matched) return matched;
  1439. }
  1440. return null;
  1441. };
  1442. const list = [];
  1443. const walk = (nodes) => {
  1444. (nodes || []).forEach((node) => {
  1445. if (!node) return;
  1446. const type = String(node.type);
  1447. if (type === '40' || type === 'factory') {
  1448. list.push({
  1449. id: String(node.id),
  1450. name: node.name || '',
  1451. code: node.groupCode || '',
  1452. groupId: String(groupId),
  1453. });
  1454. }
  1455. walk(node.children || []);
  1456. });
  1457. };
  1458. walk(find(tree || [])?.children || []);
  1459. return list.filter((item) => item.id && item.name);
  1460. },
  1461. factoryBelongsToOrg(item = {}, groupId) {
  1462. if (!groupId) return false;
  1463. const orgId = String(groupId);
  1464. const ids = [
  1465. item.groupId,
  1466. item.organizationId,
  1467. item.companyId,
  1468. item.parentId,
  1469. item.legalId,
  1470. ]
  1471. .filter((value) => value != null && value !== '')
  1472. .map((value) => String(value));
  1473. if (!ids.length) return true;
  1474. return ids.includes(orgId);
  1475. },
  1476. async loadFactories() {
  1477. const groupId = this.form.groupId;
  1478. if (!groupId) {
  1479. this.factoryOptions = [];
  1480. this.factoryLoading = false;
  1481. return;
  1482. }
  1483. this.factoryLoading = true;
  1484. try {
  1485. const fromTree = this.collectFactoryOrgs(groupId);
  1486. if (fromTree.length) {
  1487. this.factoryOptions = fromTree;
  1488. return;
  1489. }
  1490. const data = await getFactoryAreaPage({
  1491. pageNum: 1,
  1492. size: 999,
  1493. type: 1,
  1494. groupId,
  1495. organizationId: groupId,
  1496. });
  1497. this.factoryOptions = (Array.isArray(data?.list) ? data.list : (Array.isArray(data) ? data : []))
  1498. .map((item) => ({
  1499. id: String(item.id),
  1500. name: item.name || item.factoryName || item.pathName || `工厂 ${item.id}`,
  1501. code: item.code || '',
  1502. enabled: item.enabled,
  1503. groupId: item.groupId || item.organizationId || item.companyId || item.parentId || '',
  1504. }))
  1505. .filter((item) => item.id && item.name && this.factoryBelongsToOrg(item, groupId));
  1506. } catch (error) {
  1507. this.factoryOptions = this.collectFactoryOrgs(groupId);
  1508. if (!this.factoryOptions.length) {
  1509. this.$message.error(error.message || '工厂列表加载失败');
  1510. }
  1511. } finally {
  1512. this.factoryLoading = false;
  1513. }
  1514. },
  1515. syncFactoryDisplay() {
  1516. if (this.form.factoryId == null || this.form.factoryId === '') {
  1517. this.form.factoryId = null;
  1518. return;
  1519. }
  1520. this.form.factoryId = String(this.form.factoryId);
  1521. const matched = this.factoryOptions.find((item) => String(item.id) === String(this.form.factoryId));
  1522. if (matched) {
  1523. this.form.factory = matched.name;
  1524. return;
  1525. }
  1526. // 详情带回的工厂不在分页结果里时,补一条选项避免只显示 ID
  1527. if (this.form.factory) {
  1528. this.factoryOptions.unshift({
  1529. id: String(this.form.factoryId),
  1530. name: this.form.factory,
  1531. code: '',
  1532. enabled: 1
  1533. });
  1534. }
  1535. },
  1536. handleFactoryChange(id) {
  1537. const matched = this.factoryOptions.find((item) => String(item.id) === String(id));
  1538. this.form.factory = matched?.name || '';
  1539. this.form.factoryId = id != null && id !== '' ? String(id) : null;
  1540. this.deptCascaderKey += 1;
  1541. this.applyDeptIds([]);
  1542. this.form.managerId = null;
  1543. this.form.manager = '';
  1544. this.managerOptions = [];
  1545. this.resetPositionsByDepartment();
  1546. },
  1547. handlePositionChange() {
  1548. this.form.positionId =
  1549. this.form.positionId != null && this.form.positionId !== ''
  1550. ? String(this.form.positionId)
  1551. : null;
  1552. this.form.positionIds = this.form.positionId ? [this.form.positionId] : [];
  1553. this.syncStaffingLevelsFromPosition();
  1554. this.syncPositionDisplay();
  1555. this.enrichSelectedPositions();
  1556. this.$nextTick(() => this.$refs.form?.validateField('positionId'));
  1557. },
  1558. 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; },
  1559. addEducation() { this.form.educations.push({ education: '本科', degree: '学士', graduationDate: '', school: '', major: '' }); },
  1560. addBankAccount() { this.form.bankAccounts.push({ bankName: '', bankAccount: '', bankAccountName: '' }); },
  1561. addPreviousJob() { this.form.previousJobs.push({ startDate: '', endDate: '', company: '', position: '', department: '', leaveReason: '', description: '' }); },
  1562. addSocialSecurity() { this.form.socialSecurityRecords.push({ type: '五险一金', base: 0, insuredDate: '', stopDate: '', stopReason: '' }); },
  1563. addWorkType() { this.form.workTypes.push({ type: '', code: '', name: '', level: '', hourlyWage: 0, wageUnit: '元/小时', healthStatus: '优', specialOperator: '否', photo: [] }); },
  1564. addCertificate() { this.form.certificates.push({ type: '', number: '', level: '', status: '有效', startDate: '', endDate: '', issueDate: '', attachment: [], remark: '' }); },
  1565. addSkill() { this.form.skills.push({ category: '', name: '', level: '', years: 0, attachment: [], remark: '' }); },
  1566. removeItem(key, index) { this.form[key].splice(index, 1); },
  1567. calculateAge(date) {
  1568. if (!date) return null;
  1569. const birthday = new Date(date);
  1570. if (Number.isNaN(birthday.getTime())) return null;
  1571. const today = new Date();
  1572. let age = today.getFullYear() - birthday.getFullYear();
  1573. if (
  1574. today.getMonth() < birthday.getMonth() ||
  1575. (today.getMonth() === birthday.getMonth() && today.getDate() < birthday.getDate())
  1576. ) {
  1577. age -= 1;
  1578. }
  1579. return age >= 0 ? age : null;
  1580. },
  1581. syncAge() {
  1582. this.form.age = this.calculateAge(this.form.birthDate);
  1583. },
  1584. /** 从 18 位身份证解析出生日期并回填年龄(格式合法时覆盖生成) */
  1585. parseIdCard() {
  1586. const value = String(this.form.idCard || '').trim().toUpperCase();
  1587. this.form.idCard = value;
  1588. if (!/^\d{17}[\dXx]$/.test(value)) return false;
  1589. const y = value.slice(6, 10);
  1590. const m = value.slice(10, 12);
  1591. const d = value.slice(12, 14);
  1592. const birth = `${y}-${m}-${d}`;
  1593. const date = new Date(Number(y), Number(m) - 1, Number(d));
  1594. if (
  1595. date.getFullYear() !== Number(y) ||
  1596. date.getMonth() !== Number(m) - 1 ||
  1597. date.getDate() !== Number(d)
  1598. ) {
  1599. return false;
  1600. }
  1601. this.form.birthDate = birth;
  1602. this.form.age = this.calculateAge(birth);
  1603. return true;
  1604. },
  1605. handleIdCardInput(value) {
  1606. const text = String(value || '').trim();
  1607. if (text.length === 18) this.parseIdCard();
  1608. },
  1609. sameDepartment(item = {}) {
  1610. const selected = this.normalizeDeptIds(this.form.deptIds);
  1611. if (!selected.length) return false;
  1612. if (item.deptId == null || item.deptId === '') return true;
  1613. return selected.includes(String(item.deptId));
  1614. },
  1615. extractPositionLevelNames(opt = {}) {
  1616. const names = [];
  1617. const add = (value) => {
  1618. if (value == null || value === '') return;
  1619. const text =
  1620. typeof value === 'object'
  1621. ? value.level ||
  1622. value.levelName ||
  1623. value.label ||
  1624. value.positionLevel ||
  1625. value.value ||
  1626. ''
  1627. : value;
  1628. const name = String(text || '').trim();
  1629. if (name && !names.includes(name)) names.push(name);
  1630. };
  1631. (opt.positionLevels || []).forEach(add);
  1632. add(opt.positionLevel);
  1633. add(opt.level);
  1634. return names;
  1635. },
  1636. syncStaffingLevelsFromPosition() {
  1637. const primaryId = this.form.positionId || (this.form.positionIds || [])[0];
  1638. if (!primaryId) {
  1639. this.staffingLevelOptions = [];
  1640. this.form.staffingLevel = '';
  1641. this.form.level = '';
  1642. return;
  1643. }
  1644. const opt =
  1645. this.positionOptions.find((item) => String(item.id) === String(primaryId)) || {};
  1646. const levels = this.extractPositionLevelNames(opt);
  1647. this.staffingLevelOptions = levels;
  1648. if (!levels.length) {
  1649. this.form.staffingLevel = '';
  1650. this.form.level = '';
  1651. return;
  1652. }
  1653. if (!levels.includes(this.form.staffingLevel)) {
  1654. this.form.staffingLevel = levels[0];
  1655. }
  1656. this.form.level = this.form.staffingLevel;
  1657. },
  1658. handleStaffingLevelChange(value) {
  1659. this.form.staffingLevel = value || '';
  1660. this.form.level = value || '';
  1661. this.syncPositionDisplay();
  1662. },
  1663. clearSelectedPositions() {
  1664. this.form.positionIds = [];
  1665. this.form.positionBindings = [];
  1666. this.form.positionId = null;
  1667. this.form.position = '';
  1668. this.form.staffingLevel = '';
  1669. this.form.level = '';
  1670. this.staffingLevelOptions = [];
  1671. },
  1672. resetPositionsByDepartment() {
  1673. this.clearSelectedPositions();
  1674. this.positionOptions = [];
  1675. this.hrPositionIdSet = {};
  1676. this.loadPositions();
  1677. },
  1678. resolveEmploymentType(raw) {
  1679. if (raw == null || raw === '') return '';
  1680. const text = String(raw);
  1681. const hit = this.employmentOptions.find(
  1682. (item) =>
  1683. String(item.value) === text ||
  1684. String(item.label) === text ||
  1685. String(item.key) === text,
  1686. );
  1687. return hit ? hit.value : text;
  1688. },
  1689. async loadEmploymentTypeOptions() {
  1690. try {
  1691. const items = await getDictByCode('position_nature');
  1692. this.employmentOptions = (items || [])
  1693. .map((item) => {
  1694. const label = String(item.label ?? item.value ?? '').trim();
  1695. const value = String(item.value ?? item.label ?? '').trim();
  1696. if (!label && !value) return null;
  1697. return {
  1698. label: label || value,
  1699. value: value || label,
  1700. key: item.key != null ? String(item.key) : value || label,
  1701. };
  1702. })
  1703. .filter(Boolean);
  1704. if (this.form.employmentType) {
  1705. this.form.employmentType = this.resolveEmploymentType(this.form.employmentType);
  1706. }
  1707. } catch (error) {
  1708. this.employmentOptions = [];
  1709. }
  1710. },
  1711. async loadPositions() {
  1712. const deptIds = this.normalizeDeptIds(this.form.deptIds);
  1713. if (!deptIds.length) {
  1714. this.positionOptions = [];
  1715. this.hrPositionIdSet = {};
  1716. this.positionLoading = false;
  1717. return;
  1718. }
  1719. this.positionLoading = true;
  1720. try {
  1721. const pages = await Promise.all(
  1722. deptIds.map((deptId) =>
  1723. getPositionPage({
  1724. pageNum: 1,
  1725. size: 1000,
  1726. status: 1,
  1727. deptId,
  1728. }),
  1729. ),
  1730. );
  1731. const list = pages
  1732. .flatMap((data) => data?.list || [])
  1733. .map((item) => adaptPosition(item))
  1734. .filter((item) => {
  1735. const name = String(item.positionName || item.name || '').trim();
  1736. if (!name) return false;
  1737. if (!this.sameDepartment(item)) return false;
  1738. return Boolean(item.id);
  1739. })
  1740. .map((item) => {
  1741. const name = item.positionName || item.name || '';
  1742. return {
  1743. ...item,
  1744. id: String(item.id),
  1745. positionName: name,
  1746. name,
  1747. sequenceId: item.sequenceId != null ? String(item.sequenceId) : '',
  1748. positionSequenceId: item.sequenceId != null ? String(item.sequenceId) : '',
  1749. positionSequence: item.positionSequence || '',
  1750. positionLevels: item.positionLevels || [],
  1751. };
  1752. });
  1753. const idSet = {};
  1754. list.forEach((item) => {
  1755. if (item.id) idSet[String(item.id)] = true;
  1756. });
  1757. this.hrPositionIdSet = idSet;
  1758. this.positionOptions = this.dedupePositionOptions(list);
  1759. this.positionOptions = this.positionOptions.map((item) => {
  1760. if (this.isNumericId(item.sequenceId || item.positionSequenceId)) return item;
  1761. const seqMeta = this.findSequenceMeta('', item.positionSequence || item.positionSequenceName);
  1762. if (!seqMeta?.sequenceId) return item;
  1763. return {
  1764. ...item,
  1765. sequenceId: seqMeta.sequenceId,
  1766. positionSequenceId: seqMeta.sequenceId,
  1767. positionSequence: item.positionSequence || seqMeta.sequenceName || '',
  1768. };
  1769. });
  1770. } catch (error) {
  1771. this.positionOptions = [];
  1772. this.hrPositionIdSet = {};
  1773. } finally {
  1774. this.positionLoading = false;
  1775. }
  1776. },
  1777. // 保存前整体校验:基础必填 + 各分区必填 + 日期逻辑 + 记录分区,校验不过跳转到对应 tab
  1778. async validateSections() {
  1779. // 1. 基础 el-form(base tab 必填)
  1780. if (!this.$refs.form) {
  1781. this.$message.warning('表单未就绪,请稍后重试');
  1782. return false;
  1783. }
  1784. this._skipFormValidate = false;
  1785. const formValid = await new Promise((resolve) => {
  1786. try {
  1787. this.$refs.form.validate((valid) => resolve(Boolean(valid)));
  1788. } catch (error) {
  1789. resolve(false);
  1790. }
  1791. });
  1792. if (!formValid) {
  1793. this.activeTab = 'base';
  1794. this.$message.warning('请完善基本信息中标记的必填项');
  1795. return false;
  1796. }
  1797. // 同步岗位绑定,并解析 positionList 必填的序列/层级主键(界面仍只展示岗位名)
  1798. const positionMetaOk = await this.ensurePositionMetaForSave();
  1799. if (!positionMetaOk) return false;
  1800. // 2. 各分区重复列表必填/日期逻辑
  1801. const sectionInvalid = this.checkRepeatLists();
  1802. if (sectionInvalid) {
  1803. this.activeTab = sectionInvalid.tab;
  1804. this.$message.warning(sectionInvalid.message);
  1805. return false;
  1806. }
  1807. // 3. 记录分区(考勤/绩效/培训/奖惩)
  1808. const recordsResult = this.$refs.records?.validateRecords?.();
  1809. if (recordsResult && !recordsResult.valid) {
  1810. this.activeTab = 'records';
  1811. this.$message.warning(recordsResult.message);
  1812. return false;
  1813. }
  1814. return true;
  1815. },
  1816. // 校验各分区重复列表必填与日期逻辑,返回首个不满足项 { tab, message } 或 null
  1817. checkRepeatLists() {
  1818. const gt = (start, end) => start && end && start > end;
  1819. // 日期逻辑(基本信息)
  1820. if (gt(this.form.birthDate, this.form.hireDate)) return { tab: 'base', message: '入职日期不能早于出生日期' };
  1821. if (gt(this.form.hireDate, this.form.regularDate)) return { tab: 'base', message: '转正日期不能早于入职日期' };
  1822. // 工作履历
  1823. for (const item of this.form.previousJobs || []) {
  1824. if (!item.startDate || !item.endDate || !item.company || !item.position) {
  1825. return { tab: 'experience', message: '工作履历的必填项未完善(工作开始/结束日期、前公司名称、前公司岗位)' };
  1826. }
  1827. if (gt(item.startDate, item.endDate)) return { tab: 'experience', message: '工作结束日期必须晚于开始日期' };
  1828. }
  1829. // 劳动合同已改为只读,不再在档案页校验必填/日期
  1830. // 薪酬银行:姓名必填
  1831. for (const item of this.form.bankAccounts || []) {
  1832. if (!item.bankAccountName) return { tab: 'contract', message: '薪酬银行的姓名(开户人)必填' };
  1833. }
  1834. // 社保记录:停保≥参保
  1835. for (const item of this.form.socialSecurityRecords || []) {
  1836. if (gt(item.insuredDate, item.stopDate)) return { tab: 'social', message: '社保停保日期不能早于参保日期' };
  1837. }
  1838. // 证照:类型/编号/状态/有效期必填,有效期结束≥开始
  1839. for (const item of this.form.certificates || []) {
  1840. if (!item.type || !item.number || !item.status || !item.startDate || !item.endDate) {
  1841. return { tab: 'social', message: '证照的必填项未完善(证照类型、证照编号、状态、有效期起止)' };
  1842. }
  1843. if (gt(item.startDate, item.endDate)) return { tab: 'social', message: '证照有效期结束时间不能早于开始时间' };
  1844. }
  1845. // 技能:技能名称必填
  1846. for (const item of this.form.skills || []) {
  1847. if (!item.name) return { tab: 'social', message: '技能名称必填' };
  1848. }
  1849. return null;
  1850. },
  1851. async submit() {
  1852. if (this.saving) return;
  1853. this.saving = true;
  1854. // 兜底:无论父级是否回调,都要结束 loading,避免异常后整页卡死
  1855. const stopSaving = () => {
  1856. this.saving = false;
  1857. };
  1858. const safetyTimer = setTimeout(stopSaving, 45000);
  1859. try {
  1860. const ok = await this.validateSections();
  1861. if (!ok) return;
  1862. const today = new Date().toISOString().slice(0, 10);
  1863. const certificates = (this.form.certificates || [])
  1864. .filter((item) => item.type)
  1865. .map((item) => ({
  1866. ...item,
  1867. status: item.endDate && item.endDate < today ? '失效' : (item.status || '有效'),
  1868. }));
  1869. const contract = { ...(this.form.contract || {}) };
  1870. const leaveEnabled = this.form.leaveInfo?.enabled && Boolean(this.form.leaveInfo?.leaveDate);
  1871. const awards = (this.form.awards || [])
  1872. .filter((item) => item.name)
  1873. .map((item) => ({ ...item, level: item.level || '公司级' }));
  1874. const bankAccounts = (this.form.bankAccounts || []).map((item, i) => ({
  1875. ...item,
  1876. bankAccountName: item.bankAccountName || (i === 0 ? this.form.name : ''),
  1877. }));
  1878. if ((this.form.educations || []).length > 1) {
  1879. this.$message.info('多条教育经历仅第一条会写入员工主数据字段');
  1880. }
  1881. const payload = {
  1882. ...this.form,
  1883. certificates,
  1884. contract,
  1885. awards,
  1886. bankAccounts,
  1887. status: leaveEnabled ? '离职' : this.form.status,
  1888. age: this.calculateAge(this.form.birthDate),
  1889. };
  1890. // done 必须被调用;用回调而不是依赖 resolve/reject,避免漏调导致永久 await
  1891. const saveError = await new Promise((resolve) => {
  1892. let settled = false;
  1893. const done = (error) => {
  1894. if (settled) return;
  1895. settled = true;
  1896. resolve(error || null);
  1897. };
  1898. try {
  1899. this.$emit('save', payload, done);
  1900. } catch (error) {
  1901. done(error);
  1902. }
  1903. setTimeout(() => done(new Error('保存超时,请重试')), 40000);
  1904. });
  1905. if (saveError) {
  1906. if (saveError.message && !saveError.__toastShown) {
  1907. // 父级未提示时再兜底(例如超时)
  1908. if (saveError.message === '保存超时,请重试') {
  1909. this.$message.error(saveError.message);
  1910. }
  1911. }
  1912. return;
  1913. }
  1914. this.dialogVisible = false;
  1915. } catch (error) {
  1916. this.$message.error(error?.message || '保存失败,请重试');
  1917. } finally {
  1918. clearTimeout(safetyTimer);
  1919. stopSaving();
  1920. }
  1921. }
  1922. }
  1923. };
  1924. </script>
  1925. <style src="../../../../styles/views/employeeArchive/components/EmployeeArchiveForm.scss" lang="scss" scoped></style>
  1926. <style src="../../../../styles/views/employeeArchive/components/EmployeeArchiveForm.global.scss" lang="scss"></style>