onboard-create.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. <template>
  2. <view class="page-no-tab">
  3. <!-- 提交成功后的简洁成功页:只保留成功提示与返回按钮,不再跳转入职列表页 -->
  4. <view v-if="submitted" class="success-page">
  5. <view class="success-icon"><text>✓</text></view>
  6. <text class="success-title">提交成功</text>
  7. <text class="success-desc">员工「{{ submittedName }}」的入职登记已提交</text>
  8. <view class="success-btn press" @click="backToWorkbench">返 回</view>
  9. </view>
  10. <block v-else>
  11. <uni-nav-bar
  12. fixed="true"
  13. statusBar="true"
  14. left-icon="back"
  15. title="入职登记表"
  16. background-color="#157A2C"
  17. color="#fff"
  18. @clickLeft="goBack"
  19. ></uni-nav-bar>
  20. <view class="section-title"><text>基础信息</text></view>
  21. <view class="form-card card section">
  22. <view class="field">
  23. <text class="label required">姓名</text>
  24. <input v-model="form.name" class="value-input" placeholder="请输入员工姓名" maxlength="20" />
  25. </view>
  26. <view class="divider"></view>
  27. <view class="field press">
  28. <text class="label required">入职日期</text>
  29. <picker class="field-picker" mode="date" :value="form.hireDate" :start="todayIso" @change="onHireDateChange">
  30. <text :class="form.hireDate ? 'value' : 'placeholder'">{{ form.hireDate || '请选择' }}</text>
  31. </picker>
  32. <text class="arrow">›</text>
  33. </view>
  34. <view class="divider"></view>
  35. <!-- 选择器统一用 hr-picker-row(口径与「个人档案」页一致:枚举走原生 picker,部门走树选择) -->
  36. <hr-picker-row
  37. label="所属机构"
  38. required
  39. :value="form.legalId"
  40. :display="form.legalName"
  41. :options="companies.map((c) => ({ label: c.name, value: c.id }))"
  42. :disabled="!companies.length"
  43. hint="当前账号未配置所属公司"
  44. @change="onLegalChange"
  45. />
  46. <view class="divider"></view>
  47. <hr-picker-row
  48. mode="tree"
  49. label="目标部门"
  50. required
  51. :value="form.deptId"
  52. :display="form.deptPath"
  53. :tree="deptTree"
  54. :disabled="!form.legalId"
  55. hint="请先选择所属机构"
  56. @change="onDeptTreePick"
  57. />
  58. <view class="divider"></view>
  59. <hr-picker-row
  60. label="岗位序列"
  61. required
  62. :value="form.positionSequenceId"
  63. :display="form.positionSequence"
  64. :options="sequenceOptions.map((item) => ({ label: item.name, value: item.id }))"
  65. :disabled="!form.deptId"
  66. hint="请先选择目标部门"
  67. @change="onSequenceChange"
  68. />
  69. <view class="divider"></view>
  70. <hr-picker-row
  71. label="岗位"
  72. required
  73. :value="form.positionId"
  74. :display="form.positionName"
  75. :options="positionOptions.map((item) => ({ label: item.name, value: item.id }))"
  76. :disabled="!form.positionSequenceId"
  77. hint="请先选择岗位序列"
  78. @change="onPositionChange"
  79. />
  80. <view class="divider"></view>
  81. <view class="field">
  82. <text class="label required">岗位性质</text>
  83. <text :class="form.positionTypeName ? 'value' : 'placeholder'">{{ form.positionTypeName || '选择岗位后自动带出' }}</text>
  84. </view>
  85. <view class="divider"></view>
  86. <hr-picker-row
  87. label="岗位层级"
  88. required
  89. :value="form.positionLevelId"
  90. :display="form.positionLevel"
  91. :options="levelOptions.map((item) => ({ label: item.name, value: item.id }))"
  92. :disabled="!form.positionId"
  93. hint="请先选择岗位"
  94. @change="onLevelChange"
  95. />
  96. <view class="divider"></view>
  97. <hr-picker-row
  98. label="员工状态"
  99. required
  100. :value="form.status"
  101. :display="statusLabel"
  102. :options="statusOptions"
  103. @change="onStatusChange"
  104. />
  105. </view>
  106. <view class="section-title"><text>联系信息</text></view>
  107. <view class="form-card card section">
  108. <view class="field">
  109. <text class="label required">手机号</text>
  110. <input v-model="form.phone" class="value-input" type="number" placeholder="11位手机号" maxlength="11" />
  111. </view>
  112. <view class="divider"></view>
  113. <view class="field">
  114. <text class="label required">身份证号</text>
  115. <input v-model="form.idNumber" class="value-input" placeholder="18位身份证号" maxlength="18" />
  116. </view>
  117. </view>
  118. <view class="section-title"><text>入职附件</text></view>
  119. <view class="section attachment-section">
  120. <fileMain v-model="form.attachmentIds" />
  121. </view>
  122. <view class="footer-tip muted">
  123. <text>提交后将直接创建员工档案,请确认信息填写无误。</text>
  124. </view>
  125. <view class="footer-actions">
  126. <view class="ghost-btn press" @click="onCancel">取消</view>
  127. <view class="primary-btn press" @click="onSubmit">提交入职登记</view>
  128. </view>
  129. </block>
  130. </view>
  131. </template>
  132. <script>
  133. import { createUserOnboarding, USER_STATUS_OPTIONS, USER_STATUS_LABEL } from "@/api/hr/onboarding";
  134. import { back, switchMain } from "@/utils/hr/router";
  135. import { listOrganizations } from "@/api/hr/organization";
  136. import {
  137. getDeptPositionSequences,
  138. getDeptPositions,
  139. getDeptPositionType,
  140. getDeptPositionLevels,
  141. } from "@/api/hr/position";
  142. import { buildDeptTreeUnderLegal } from "@/utils/hr/organization-tree";
  143. import { getLoginGroupOptions } from "@/utils/hr/current-org";
  144. import fileMain from "@/pages/doc/index.vue";
  145. import HrPickerRow from "@/components/hr/HrPickerRow.vue";
  146. function todayIso() {
  147. const d = new Date();
  148. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
  149. }
  150. export default {
  151. components: { fileMain, HrPickerRow },
  152. data() {
  153. return {
  154. todayIso: todayIso(),
  155. form: {
  156. name: "",
  157. hireDate: "",
  158. legalId: null,
  159. legalName: "",
  160. deptIds: [],
  161. deptId: null,
  162. deptPath: "",
  163. positionSequenceId: null,
  164. positionSequence: "",
  165. positionId: null,
  166. positionName: "",
  167. positionType: "",
  168. positionTypeName: "",
  169. positionLevelId: null,
  170. positionLevel: "",
  171. status: 1,
  172. phone: "",
  173. idNumber: "",
  174. attachmentIds: [],
  175. },
  176. orgList: [],
  177. companies: [],
  178. deptTree: [],
  179. sequenceOptions: [],
  180. sequenceLoading: false,
  181. positionOptions: [],
  182. positionLoading: false,
  183. levelOptions: [],
  184. levelLoading: false,
  185. submitting: false,
  186. // 提交成功后切换到页内成功页(不再回入职列表页,该页已删除)
  187. submitted: false,
  188. submittedName: "",
  189. };
  190. },
  191. computed: {
  192. statusLabel() {
  193. return USER_STATUS_LABEL[this.form.status] || "全职";
  194. },
  195. /** 员工状态选项:模块级常量要挂到实例上,模板才能访问 */
  196. statusOptions() {
  197. return USER_STATUS_OPTIONS;
  198. },
  199. },
  200. onLoad() {
  201. uni.setNavigationBarTitle({ title: "入职登记表" });
  202. // 机构选项与 PC 一致:只取当前登录账号所属公司(登录缓存 loginChangeGroupVOList),
  203. // 默认带出第一条;部门树仍从组织全量列表按所选机构过滤
  204. this.refreshCompanies();
  205. this.refreshOrgList();
  206. this.clearPositionCascade();
  207. },
  208. onShow() {
  209. if (!this.companies.length) this.refreshCompanies();
  210. },
  211. onUnload() {
  212. this.resetForm();
  213. },
  214. methods: {
  215. goBack() {
  216. back("/pages/home/home");
  217. },
  218. // 成功页的返回按钮:回到工作台
  219. backToWorkbench() {
  220. switchMain("/pages/home/home");
  221. },
  222. /** 机构选项:只列当前账号所属公司,默认选中第一条(与 PC「默认取缓存第一条」约定一致) */
  223. refreshCompanies() {
  224. this.companies = getLoginGroupOptions();
  225. if (!this.companies.length) {
  226. uni.showToast({ title: "当前账号未配置所属公司,请联系管理员", icon: "none" });
  227. return;
  228. }
  229. const first = this.companies[0];
  230. this.form.legalId = first.id;
  231. this.form.legalName = first.name;
  232. this.refreshDeptTree(first.id);
  233. },
  234. /** 组织全量列表仅用于部门级联(与 PC loadOrganizations 分工一致) */
  235. async refreshOrgList() {
  236. try {
  237. this.orgList = await listOrganizations();
  238. if (this.form.legalId) this.refreshDeptTree(this.form.legalId);
  239. } catch (err) {
  240. this.orgList = [];
  241. uni.showToast({ title: "组织列表加载失败", icon: "none" });
  242. }
  243. },
  244. refreshDeptTree(legalId) {
  245. if (!legalId) {
  246. this.deptTree = [];
  247. return;
  248. }
  249. // 所属机构已经在上面的字段选过,不再作为目标部门的第一列重复展示:
  250. // 直接从部门层开始,选出来的部门路径也不带公司名。
  251. const nodes = buildDeptTreeUnderLegal(this.orgList, legalId);
  252. const legal = nodes.find((node) => String(node.id) === String(legalId)) || nodes[0];
  253. this.deptTree = legal && Array.isArray(legal.children) ? legal.children : [];
  254. },
  255. onHireDateChange(e) {
  256. this.form.hireDate = (e && e.detail && e.detail.value) || "";
  257. },
  258. /** 所属机构:换机构要清空部门与整条岗位级联,并按新机构重建部门树 */
  259. onLegalChange(legalId) {
  260. const hit = this.companies.find((c) => String(c.id) === String(legalId));
  261. this.form.legalId = legalId;
  262. this.form.legalName = (hit && hit.name) || "";
  263. this.form.deptIds = [];
  264. this.form.deptId = null;
  265. this.form.deptPath = "";
  266. this.clearPositionCascade();
  267. this.refreshDeptTree(legalId);
  268. },
  269. /** 目标部门:树选择器回传节点 id,这里补齐祖先链(deptIds)与路径名 */
  270. onDeptTreePick(deptId) {
  271. const chain = this.deptChainById(this.deptTree, deptId);
  272. this.form.deptIds = chain.map((node) => node.id);
  273. this.form.deptId = deptId;
  274. this.form.deptPath = chain.map((node) => node.name).filter(Boolean).join(" / ");
  275. // 部门变化 → 岗位级联全部重置,再拉该部门的岗位序列(与 PC onLegalChange/onDeptChange 语义一致)
  276. this.clearPositionCascade();
  277. this.loadSequences();
  278. },
  279. /** 在部门树里按 id 取「根 → 自身」的节点链 */
  280. deptChainById(tree, id, parents = []) {
  281. for (const node of tree || []) {
  282. const path = parents.concat(node);
  283. if (String(node.id) === String(id)) return path;
  284. const hit = this.deptChainById(node.children || [], id, path);
  285. if (hit.length) return hit;
  286. }
  287. return [];
  288. },
  289. /** 员工状态 */
  290. onStatusChange(status) {
  291. this.form.status = status;
  292. },
  293. // ============ 岗位级联:部门 → 岗位序列 → 岗位 → 性质(自动)/层级(与 PC 一致) ============
  294. /** 部门 → 岗位序列 */
  295. async loadSequences() {
  296. if (!this.form.deptId) {
  297. this.sequenceOptions = [];
  298. return;
  299. }
  300. this.sequenceLoading = true;
  301. try {
  302. const data = await getDeptPositionSequences(this.form.deptId, false);
  303. this.sequenceOptions = (Array.isArray(data) ? data : [])
  304. .filter((item) => item && item.positionSequenceId != null)
  305. .map((item) => ({
  306. id: String(item.positionSequenceId),
  307. name: item.positionSequenceName || item.positionSequence || "",
  308. }));
  309. } catch (error) {
  310. this.sequenceOptions = [];
  311. } finally {
  312. this.sequenceLoading = false;
  313. }
  314. },
  315. /** 岗位序列:序列变化 → 岗位及以下重置,再拉该序列下的岗位 */
  316. async onSequenceChange(sequenceId) {
  317. const hit = this.sequenceOptions.find((item) => String(item.id) === String(sequenceId));
  318. this.form.positionSequenceId = sequenceId;
  319. this.form.positionSequence = (hit && hit.name) || "";
  320. this.clearPositionBelow();
  321. await this.loadPositions();
  322. },
  323. /** 部门 + 序列 → 岗位 */
  324. async loadPositions() {
  325. if (!this.form.deptId || !this.form.positionSequenceId) {
  326. this.positionOptions = [];
  327. return;
  328. }
  329. this.positionLoading = true;
  330. try {
  331. const data = await getDeptPositions(
  332. { deptId: this.form.deptId, positionSequenceId: this.form.positionSequenceId },
  333. false
  334. );
  335. this.positionOptions = (Array.isArray(data) ? data : [])
  336. .filter((item) => item && item.positionId != null)
  337. .map((item) => ({ id: String(item.positionId), name: item.positionName || "" }));
  338. } catch (error) {
  339. this.positionOptions = [];
  340. } finally {
  341. this.positionLoading = false;
  342. }
  343. },
  344. /** 岗位:岗位变化 → 性质重新自动带出,层级重置 */
  345. async onPositionChange(positionId) {
  346. const hit = this.positionOptions.find((item) => String(item.id) === String(positionId));
  347. this.form.positionId = positionId;
  348. this.form.positionName = (hit && hit.name) || "";
  349. this.form.positionType = "";
  350. this.form.positionTypeName = "";
  351. this.form.positionLevelId = null;
  352. this.form.positionLevel = "";
  353. this.levelOptions = [];
  354. await this.loadTypeAndLevels();
  355. },
  356. /** 岗位性质自动带出 + 岗位层级下拉(与 PC loadPositionType/loadPositionLevels 一致) */
  357. async loadTypeAndLevels() {
  358. const { deptId, positionId, positionSequenceId } = this.form;
  359. if (!deptId || !positionId || !positionSequenceId) return;
  360. try {
  361. const data = await getDeptPositionType(
  362. { deptId, positionId, positionSequenceId },
  363. false
  364. );
  365. const first = (Array.isArray(data) ? data : [])[0];
  366. this.form.positionType = (first && first.positionType) || "";
  367. this.form.positionTypeName = (first && first.positionTypeName) || "";
  368. } catch (error) {
  369. this.form.positionType = "";
  370. this.form.positionTypeName = "";
  371. }
  372. if (!this.form.positionType) return;
  373. this.levelLoading = true;
  374. try {
  375. const data = await getDeptPositionLevels(
  376. { deptId, positionId, positionSequenceId, positionType: this.form.positionType },
  377. false
  378. );
  379. this.levelOptions = (Array.isArray(data) ? data : [])
  380. .filter((item) => item && item.positionLevelId != null)
  381. .map((item) => ({ id: String(item.positionLevelId), name: item.positionLevel || "" }));
  382. } catch (error) {
  383. this.levelOptions = [];
  384. } finally {
  385. this.levelLoading = false;
  386. }
  387. },
  388. /** 岗位层级 */
  389. onLevelChange(levelId) {
  390. const hit = this.levelOptions.find((item) => String(item.id) === String(levelId));
  391. this.form.positionLevelId = levelId;
  392. this.form.positionLevel = (hit && hit.name) || "";
  393. },
  394. /** 部门变化时清空整条岗位级联 */
  395. clearPositionCascade() {
  396. this.form.positionSequenceId = null;
  397. this.form.positionSequence = "";
  398. this.clearPositionBelow();
  399. this.sequenceOptions = [];
  400. },
  401. /** 序列以下(岗位/性质/层级)清空 */
  402. clearPositionBelow() {
  403. this.form.positionId = null;
  404. this.form.positionName = "";
  405. this.form.positionType = "";
  406. this.form.positionTypeName = "";
  407. this.form.positionLevelId = null;
  408. this.form.positionLevel = "";
  409. this.positionOptions = [];
  410. this.levelOptions = [];
  411. },
  412. validate() {
  413. const f = this.form;
  414. if (!f.name.trim()) return "请填写姓名";
  415. if (!f.hireDate) return "请选择入职日期";
  416. if (!f.legalId) return "请选择所属机构";
  417. if (!f.deptId) return "请选择目标部门";
  418. if (!f.positionSequenceId) return "请选择岗位序列";
  419. if (!f.positionId) return "请选择岗位";
  420. if (!f.positionTypeName) return "岗位性质未自动带出,请重新选择岗位";
  421. if (!f.positionLevelId) return "请选择岗位层级";
  422. if (!f.status) return "请选择员工状态";
  423. if (!f.phone.trim()) return "请填写手机号";
  424. if (!/^1[3-9]\d{9}$/.test(f.phone.trim())) return "手机号格式不正确";
  425. if (!f.idNumber.trim()) return "请填写身份证号";
  426. if (!/^\d{17}[\dXx]$/.test(f.idNumber.trim())) return "身份证号格式不正确";
  427. return null;
  428. },
  429. async onSubmit() {
  430. const error = this.validate();
  431. if (error) {
  432. uni.showToast({ title: error, icon: "none" });
  433. return;
  434. }
  435. if (this.submitting) return;
  436. this.submitting = true;
  437. uni.showLoading({ title: "提交中..." });
  438. try {
  439. await createUserOnboarding(this.form);
  440. uni.hideLoading();
  441. // 提交成功后不再跳回入职列表页(该页已删除),页内切换到简洁成功页
  442. this.submittedName = this.form.name.trim();
  443. this.submitted = true;
  444. } catch (e) {
  445. uni.hideLoading();
  446. uni.showToast({ title: (e && e.message) || "提交失败", icon: "none" });
  447. } finally {
  448. this.submitting = false;
  449. }
  450. },
  451. onCancel() {
  452. uni.showModal({
  453. title: "放弃填写",
  454. content: "当前填写的内容将不会保留,确定离开?",
  455. success: (r) => {
  456. if (r.confirm) this.goBack();
  457. },
  458. });
  459. },
  460. resetForm() {
  461. Object.assign(this.form, {
  462. name: "",
  463. hireDate: "",
  464. legalId: null,
  465. legalName: "",
  466. deptIds: [],
  467. deptId: null,
  468. deptPath: "",
  469. positionSequenceId: null,
  470. positionSequence: "",
  471. positionId: null,
  472. positionName: "",
  473. positionType: "",
  474. positionTypeName: "",
  475. positionLevelId: null,
  476. positionLevel: "",
  477. status: 1,
  478. phone: "",
  479. idNumber: "",
  480. attachmentIds: [],
  481. });
  482. this.clearPositionCascade();
  483. this.orgList = [];
  484. this.companies = [];
  485. this.deptTree = [];
  486. },
  487. },
  488. };
  489. </script>
  490. <style scoped lang="scss">
  491. @import "@/pages/hr/common-style.scss";
  492. .page-no-tab {
  493. padding-bottom: 40rpx;
  494. }
  495. /* .section-title 统一取自 @/pages/hr/common-style.scss */
  496. .attachment-section {
  497. margin: 0 24rpx;
  498. }
  499. .form-card {
  500. padding: 4rpx 28rpx;
  501. }
  502. .field {
  503. display: flex;
  504. align-items: center;
  505. min-height: 96rpx;
  506. font-size: 28rpx;
  507. }
  508. .field .label {
  509. width: 180rpx;
  510. color: #4b5563;
  511. }
  512. .field .label.required::before {
  513. content: "*";
  514. color: #ee4d4d;
  515. margin-right: 4rpx;
  516. }
  517. .field .value,
  518. .field input,
  519. .field picker,
  520. .field .placeholder {
  521. flex: 1;
  522. text-align: right;
  523. color: #1f2329;
  524. font-size: 28rpx;
  525. }
  526. .field picker {
  527. min-width: 0;
  528. }
  529. .field .placeholder {
  530. color: #b5bac3;
  531. }
  532. .value-input {
  533. text-align: right;
  534. height: 96rpx;
  535. }
  536. .placeholder {
  537. color: #b5bac3;
  538. }
  539. .arrow {
  540. margin-left: 12rpx;
  541. color: #c4c8ce;
  542. font-size: 32rpx;
  543. }
  544. .divider {
  545. height: 1rpx;
  546. background: #edf0f2;
  547. margin-left: 180rpx;
  548. }
  549. .footer-tip {
  550. padding: 28rpx 28rpx 8rpx;
  551. font-size: 22rpx;
  552. text-align: center;
  553. }
  554. .footer-actions {
  555. display: flex;
  556. gap: 18rpx;
  557. padding: 24rpx 24rpx calc(40rpx + env(safe-area-inset-bottom));
  558. }
  559. .footer-actions > view {
  560. flex: 1;
  561. }
  562. /* 提交成功页 */
  563. .success-page {
  564. display: flex;
  565. flex-direction: column;
  566. align-items: center;
  567. padding: 220rpx 60rpx 0;
  568. }
  569. .success-icon {
  570. width: 132rpx;
  571. height: 132rpx;
  572. border-radius: 50%;
  573. background: #157a2c;
  574. display: flex;
  575. align-items: center;
  576. justify-content: center;
  577. text {
  578. color: #fff;
  579. font-size: 76rpx;
  580. line-height: 1;
  581. }
  582. }
  583. .success-title {
  584. margin-top: 40rpx;
  585. font-size: 36rpx;
  586. font-weight: 600;
  587. color: #263238;
  588. }
  589. .success-desc {
  590. margin-top: 18rpx;
  591. font-size: 26rpx;
  592. color: #78909c;
  593. text-align: center;
  594. }
  595. .success-btn {
  596. margin-top: 96rpx;
  597. width: 100%;
  598. height: 88rpx;
  599. line-height: 88rpx;
  600. text-align: center;
  601. border-radius: 12rpx;
  602. background: #157a2c;
  603. color: #fff;
  604. font-size: 30rpx;
  605. }
  606. </style>