transfer-create.vue 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173
  1. <template>
  2. <view class="page-no-tab" :class="{ 'readonly-mode': isReadonly }">
  3. <uni-nav-bar fixed="true" statusBar="true" left-icon="back" :title="navTitle" background-color="#157A2C" color="#fff"
  4. @clickLeft="back"></uni-nav-bar>
  5. <!-- 详情态:统一申请视图(编号 / 状态 / 当前节点 / 提交时间) -->
  6. <view v-if="showDetailHeader" class="detail-head card section">
  7. <view class="detail-head-top">
  8. <text class="detail-title">{{ detailTitle || meta.title }}</text>
  9. <text class="status-tag" :class="'status-' + detailStatusClass">{{ detailStatusLabel }}</text>
  10. </view>
  11. <view class="detail-row">
  12. <text class="detail-label">申请编号</text>
  13. <text class="detail-value">{{ detailNo }}</text>
  14. </view>
  15. <view class="detail-row">
  16. <text class="detail-label">申请人</text>
  17. <text class="detail-value">{{ detailApplicant || currentUser.name || '—' }}</text>
  18. </view>
  19. <view class="detail-row">
  20. <text class="detail-label">提交时间</text>
  21. <text class="detail-value">{{ detailCreatedAt || '—' }}</text>
  22. </view>
  23. <view v-if="detailNode" class="detail-row">
  24. <text class="detail-label">当前节点</text>
  25. <text class="detail-value">{{ detailNode }}</text>
  26. </view>
  27. </view>
  28. <view v-if="essRow.maskedSummary && isReadonly" class="masked-card card section">
  29. <text class="masked-label">申请摘要</text>
  30. <text class="masked-text">{{ essRow.maskedSummary }}</text>
  31. </view>
  32. <view class="section-title"><text>原合同信息</text></view>
  33. <view class="form-card card section">
  34. <view class="field">
  35. <text class="label">申请人</text>
  36. <view class="readonly">
  37. <view class="avatar small-avatar">{{ currentUser.initials || '—' }}</view>
  38. <text>{{ currentUser.name || '—' }}</text>
  39. </view>
  40. </view>
  41. <view class="divider"></view>
  42. <view class="field">
  43. <text class="label">所属机构</text>
  44. <text class="value">{{ profile.legalName || '—' }}</text>
  45. </view>
  46. <view class="divider"></view>
  47. <view class="field">
  48. <text class="label">所属部门</text>
  49. <text class="value">{{ profile.dept || '—' }}</text>
  50. </view>
  51. <view class="divider"></view>
  52. <view class="field">
  53. <text class="label">岗位</text>
  54. <text class="value">{{ profile.position || '—' }}</text>
  55. </view>
  56. <view class="divider"></view>
  57. <view class="field">
  58. <text class="label">原合同签订年限</text>
  59. <text class="value">{{ profile.contractTerm || '—' }}</text>
  60. </view>
  61. <view class="divider"></view>
  62. <view class="field">
  63. <text class="label">原合同期限</text>
  64. <text class="value">{{ profile.contractPeriod || '—' }}</text>
  65. </view>
  66. <view class="divider"></view>
  67. <view class="field">
  68. <text class="label">岗位层级</text>
  69. <text class="value">{{ profile.level || '—' }}</text>
  70. </view>
  71. <view class="divider"></view>
  72. <view class="field">
  73. <text class="label required">手机号</text>
  74. <input
  75. v-model="form.phone"
  76. class="field-input"
  77. type="number"
  78. maxlength="11"
  79. :disabled="formDisabled"
  80. placeholder="请输入手机号"
  81. />
  82. </view>
  83. </view>
  84. <view class="section-title"><text>变更信息</text></view>
  85. <view class="form-card card section">
  86. <hr-picker-row
  87. v-bind="pickerRowMetrics"
  88. label="所属机构"
  89. required
  90. :value="form.targetLegalId"
  91. :display="form.targetLegalName"
  92. :options="legalOptions.map((item) => ({ label: item.name, value: item.id }))"
  93. :disabled="formDisabled"
  94. @change="onLegalChange"
  95. />
  96. <view class="divider"></view>
  97. <hr-picker-row
  98. v-bind="pickerRowMetrics"
  99. mode="tree"
  100. label="调动部门"
  101. required
  102. :value="form.targetDeptId"
  103. :display="form.targetDeptName"
  104. :tree="deptTree"
  105. :disabled="formDisabled || !form.targetLegalId"
  106. :hint="deptPlaceholder"
  107. @change="onTargetDeptPick"
  108. />
  109. <view class="divider"></view>
  110. <hr-picker-row
  111. v-bind="pickerRowMetrics"
  112. label="岗位序列"
  113. required
  114. :value="form.positionSequenceId"
  115. :display="form.positionSequence"
  116. :options="sequenceOptions.map((item) => ({ label: item.name, value: item.id }))"
  117. :disabled="formDisabled || !form.targetDeptId"
  118. :hint="sequencePlaceholder"
  119. @change="onSequenceChange"
  120. />
  121. <view class="divider"></view>
  122. <hr-picker-row
  123. v-bind="pickerRowMetrics"
  124. label="调动岗位"
  125. required
  126. :value="form.targetPositionId"
  127. :display="form.targetPositionName"
  128. :options="positionOptions.map((item) => ({ label: item.name, value: item.id }))"
  129. :disabled="formDisabled || !form.positionSequenceId"
  130. :hint="positionPlaceholder"
  131. @change="onTargetPositionChange"
  132. />
  133. <view class="divider"></view>
  134. <view class="field">
  135. <text class="label required">岗位性质</text>
  136. <text :class="form.positionTypeName ? 'value' : 'placeholder'">{{ form.positionTypeName || '选择岗位后自动带出' }}</text>
  137. </view>
  138. <view class="divider"></view>
  139. <hr-picker-row
  140. v-bind="pickerRowMetrics"
  141. label="岗位层级"
  142. required
  143. :value="form.positionLevelId"
  144. :display="form.positionLevel"
  145. :options="levelOptions.map((item) => ({ label: item.name, value: item.id }))"
  146. :disabled="formDisabled || !form.targetPositionId"
  147. :hint="levelPlaceholder"
  148. @change="onLevelChange"
  149. />
  150. <view class="divider"></view>
  151. <view class="field">
  152. <text class="label">工作地址</text>
  153. <input class="field-input" v-model="form.workLocation" :disabled="formDisabled" maxlength="60" placeholder="调动后工作地点" />
  154. </view>
  155. <view class="divider"></view>
  156. <view class="field press">
  157. <text class="label required">生效日期</text>
  158. <picker class="field-picker" :disabled="formDisabled" mode="date" :value="form.effectiveDate" :start="todayIso"
  159. @change="onEffectiveDateChange">
  160. <text :class="form.effectiveDate ? 'value' : 'placeholder'">{{ form.effectiveDate || '请选择' }}</text>
  161. </picker>
  162. <text class="arrow">›</text>
  163. </view>
  164. <view class="divider"></view>
  165. <hr-picker-row
  166. v-bind="pickerRowMetrics"
  167. label="异动类型"
  168. required
  169. :value="form.changeType"
  170. :display="changeTypeText"
  171. :options="changeTypeOptions"
  172. :disabled="formDisabled"
  173. @change="onChangeType"
  174. />
  175. <view class="divider"></view>
  176. <view class="field column">
  177. <text class="label">签章附件</text>
  178. <fileMain v-model="form.attachmentIds" :type="isReadonly ? 'view' : ''" />
  179. </view>
  180. </view>
  181. <view class="section-title"><text>申请说明</text></view>
  182. <view class="form-card card section">
  183. <view class="field textarea-field">
  184. <text class="label required">变更原因</text>
  185. <textarea v-model="form.reason" :disabled="formDisabled" maxlength="300" placeholder="请说明岗位调动原因:晋升、轮岗、组织调整等" />
  186. <text class="counter">{{ (form.reason || '').length }} / 300</text>
  187. </view>
  188. </view>
  189. <view v-if="showBottomBar" class="bottom-action safe-bottom">
  190. <view class="submit-btn press" @click="onBottomAction">{{ bottomButtonText }}</view>
  191. </view>
  192. </view>
  193. </template>
  194. <script>
  195. import fileMain from "@/pages/doc/index.vue";
  196. import {
  197. applicationTypes
  198. } from "@/pages/hr/data/apply-config";
  199. import {
  200. listOrganizations,
  201. getUserById
  202. } from "@/api/hr/organization";
  203. import {
  204. loadEmployeeContractInfo
  205. } from "@/api/hr/contract";
  206. import {
  207. fetchUserProfile,
  208. pickPrimaryPosition,
  209. dictToPositionNatureOptions
  210. } from "@/api/hr/user";
  211. import {
  212. loadEmploymentPeriod
  213. } from "@/api/hr/regularization";
  214. import {
  215. getDeptPositionSequences,
  216. getDeptPositions,
  217. getDeptPositionType,
  218. getDeptPositionLevels,
  219. } from "@/api/hr/position";
  220. import { getDictByCode } from "@/api/common";
  221. import HrPickerRow from "@/components/hr/HrPickerRow.vue";
  222. import {
  223. buildDeptTreeUnderLegal,
  224. LEGAL_TYPES
  225. } from "@/utils/hr/organization-tree";
  226. import { getOrgOptionsWithFallback } from "@/utils/hr/current-org";
  227. import {
  228. addApplication
  229. } from "@/utils/hr/storage";
  230. import {
  231. ESS_CHANGE_TYPE_OPTIONS,
  232. canEditUserChange,
  233. changeTypeLabel,
  234. loadTransferDetail,
  235. submitTransferApplication,
  236. } from "@/api/hr/userChange";
  237. import { applicationModeMixin, APPLICATION_MODE } from "@/utils/hr/application-mode";
  238. /** 生效日期不得早于今天(员工自助不允许回溯改历史档案) */
  239. function todayIso() {
  240. const d = new Date();
  241. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
  242. }
  243. export default {
  244. components: {
  245. fileMain,
  246. HrPickerRow
  247. },
  248. mixins: [applicationModeMixin],
  249. data() {
  250. return {
  251. /** 生效日期最小可选值(picker start):不早于今天 */
  252. todayIso: todayIso(),
  253. currentUser: {
  254. id: null,
  255. name: "",
  256. initials: "",
  257. department: "",
  258. position: "",
  259. level: ""
  260. },
  261. // 原合同信息(只读,来自员工档案 + 劳动合同接口)
  262. profile: {
  263. employeeNo: "",
  264. legalName: "",
  265. dept: "",
  266. position: "",
  267. level: "",
  268. contractTerm: "",
  269. contractPeriod: "",
  270. phone: "",
  271. },
  272. form: {
  273. id: null,
  274. userId: null,
  275. employmentPeriodId: null,
  276. name: "",
  277. employeeNo: "",
  278. dept: "",
  279. position: "",
  280. level: "",
  281. // 手机号:PC 端「原合同信息」里是可编辑 + 必填(rules 543-550),提交载荷也要传 phone
  282. phone: "",
  283. changeType: "POSITION_TRANSFER",
  284. targetLegalId: null,
  285. targetLegalName: "",
  286. targetDeptId: null,
  287. targetDeptName: "",
  288. positionSequenceId: null,
  289. positionSequence: "",
  290. targetPositionId: null,
  291. targetPositionName: "",
  292. positionType: "",
  293. positionTypeName: "",
  294. positionLevelId: null,
  295. positionLevel: "",
  296. workLocation: "",
  297. effectiveDate: "",
  298. attachmentIds: [],
  299. reason: "",
  300. remark: "",
  301. },
  302. submitting: false,
  303. orgList: [],
  304. // 调动部门树(树选择器用;公司层已在 rebuildDeptOptions 里去掉)
  305. deptTree: [],
  306. orgPromise: null,
  307. legalOptions: [],
  308. sequenceOptions: [],
  309. positionOptions: [],
  310. levelOptions: [],
  311. sequenceLoading: false,
  312. positionLoading: false,
  313. levelLoading: false,
  314. /** 岗位性质字典(编码 → 名称,接口不返 positionTypeName 时反查) */
  315. positionNatureOptions: [],
  316. };
  317. },
  318. computed: {
  319. /** 统一选择行(HrPickerRow)的行度量:与本页 .field/.label 口径一致,避免左侧对不齐 */
  320. pickerRowMetrics() {
  321. return {
  322. rowMinHeight: "108rpx",
  323. rowPadding: "28rpx 36rpx",
  324. labelWidth: "180rpx",
  325. };
  326. },
  327. meta() {
  328. return applicationTypes.transfer || {
  329. title: "调岗申请",
  330. subtitle: "",
  331. icon: "岗",
  332. color: "#9270ca"
  333. };
  334. },
  335. navTitle() {
  336. return this.applicationTitle(this.meta.title);
  337. },
  338. pageTitle() {
  339. return this.meta.title;
  340. },
  341. changeTypeText() {
  342. return this.form.changeType ? changeTypeLabel(this.form.changeType) : "请选择";
  343. },
  344. /** 异动类型候选(统一选择器用) */
  345. changeTypeOptions() {
  346. return ESS_CHANGE_TYPE_OPTIONS.map((item) => ({ label: item.label, value: item.value }));
  347. },
  348. deptPlaceholder() {
  349. return this.form.targetLegalId ? "请选择" : "请先选择所属机构";
  350. },
  351. sequencePlaceholder() {
  352. return this.form.targetDeptId ? "请选择" : "请先选择调动部门";
  353. },
  354. positionPlaceholder() {
  355. return this.form.positionSequenceId ? "请选择" : "请先选择岗位序列";
  356. },
  357. levelPlaceholder() {
  358. return this.form.targetPositionId ? "请选择" : "请先选择调动岗位";
  359. },
  360. summaryText() {
  361. const parts = [changeTypeLabel(this.form.changeType)];
  362. if (this.form.targetDeptName) parts.push(this.form.targetDeptName);
  363. if (this.form.targetPositionName) parts.push(this.form.targetPositionName);
  364. if (this.form.effectiveDate) parts.push(`生效 ${this.form.effectiveDate}`);
  365. return parts.join(" · ");
  366. },
  367. },
  368. onLoad(options) {
  369. const opts = options || {};
  370. this.initApplicationMode(opts);
  371. // 本页不直接承接 mode=edit(编辑由详情页内「编辑」按钮进入)
  372. if (this.appMode === APPLICATION_MODE.EDIT) this.appMode = APPLICATION_MODE.DETAIL;
  373. this.loadCurrentUser();
  374. this.orgPromise = this.loadOrganizations();
  375. uni.setNavigationBarTitle({
  376. title: this.navTitle
  377. });
  378. if (!this.isCreateMode) {
  379. // 详情态:只读展示,业务明细按异动单主键拉取
  380. this.initDetailMode();
  381. return;
  382. }
  383. // 兼容旧入口:/pages/hr/manage/transfer-create?id=<异动单主键>
  384. const legacyId = opts.id || "";
  385. if (legacyId) {
  386. this.hydrateForm(legacyId);
  387. return;
  388. }
  389. this.syncOrigin();
  390. this.loadProfile();
  391. },
  392. methods: {
  393. back() {
  394. uni.navigateBack();
  395. },
  396. loadCurrentUser() {
  397. const vo = uni.getStorageSync("userInfo") || {};
  398. const name = vo.userName || vo.name || "";
  399. this.currentUser = {
  400. id: vo.userId || vo.id || null,
  401. name,
  402. initials: name ? String(name).charAt(0) : "",
  403. department: vo.deptName || vo.department || "",
  404. position: vo.postName || vo.position || "",
  405. level: vo.positionLevelName || vo.level || "",
  406. };
  407. },
  408. async loadOrganizations() {
  409. try {
  410. // 组织树取全量:部门级联要按当前机构从中过滤出部门
  411. const list = await listOrganizations();
  412. this.orgList = Array.isArray(list) ? list : [];
  413. } catch (error) {
  414. this.orgList = [];
  415. }
  416. // 所属机构候选与 PC 一致:只给当前账号所属公司(登录缓存 loginChangeGroupVOList),
  417. // 不再列全量机构树;缺名称时回落组织树,编辑老数据时补当前项
  418. this.legalOptions = getOrgOptionsWithFallback(
  419. this.orgList,
  420. this.form.targetLegalId,
  421. this.form.targetLegalName
  422. );
  423. // 新建态默认带出当前登录机构(对齐 PC openCreate 的 getCurrentOrgId),
  424. // 否则「所属机构」空着,下面的部门 / 序列 / 岗位整条级联都是灰的
  425. if (!this.form.targetLegalId) {
  426. const first = this.legalOptions[0];
  427. if (first) {
  428. this.form.targetLegalId = first.id;
  429. this.form.targetLegalName = first.name || "";
  430. }
  431. }
  432. this.rebuildDeptOptions();
  433. },
  434. rebuildDeptOptions() {
  435. if (!this.form.targetLegalId) {
  436. this.deptTree = [];
  437. return;
  438. }
  439. const nodes = buildDeptTreeUnderLegal(this.orgList, this.form.targetLegalId);
  440. // 树选择器用:公司层不再入列,直接从部门层开始(与个人档案页一致)
  441. const legal = nodes.find((node) => String(node.id) === String(this.form.targetLegalId)) || nodes[0];
  442. this.deptTree = legal && Array.isArray(legal.children) ? legal.children : [];
  443. },
  444. // ============ 原合同信息:员工档案 + 劳动合同接口 ============
  445. async loadProfile() {
  446. const userId = this.form.userId || this.currentUser.id;
  447. this.profile = Object.assign({}, this.profile, {
  448. dept: this.currentUser.department,
  449. position: this.currentUser.position,
  450. level: this.currentUser.level,
  451. });
  452. if (userId == null || userId === "") return;
  453. // 机构名称兜底需要组织树就绪(首屏可能与 loadOrganizations 并发)
  454. if (!this.orgList.length && this.orgPromise) {
  455. try {
  456. await this.orgPromise;
  457. } catch (error) {
  458. // 组织树加载失败时跳过机构上溯
  459. }
  460. }
  461. // 员工档案:工号 / 机构 / 部门 / 岗位 / 层级 / 手机号
  462. try {
  463. const vo = (await getUserById(userId, false)) || {};
  464. const name = vo.name || vo.userName || this.currentUser.name;
  465. if (name) {
  466. this.currentUser.name = name;
  467. this.currentUser.initials = String(name).charAt(0);
  468. }
  469. const deptId = vo.deptId != null ? vo.deptId : vo.groupId;
  470. this.profile.employeeNo = vo.jobNumber || vo.employeeNo || this.profile.employeeNo;
  471. if (!this.form.employeeNo) this.form.employeeNo = this.profile.employeeNo;
  472. this.profile.dept = vo.deptName || vo.department || this.profile.dept;
  473. this.profile.position = vo.postName || vo.position || this.profile.position;
  474. this.profile.level =
  475. vo.positionLevelName || vo.positionLevel || vo.grade || vo.level || this.profile.level;
  476. this.profile.phone = vo.phone || vo.mobile || this.profile.phone;
  477. // 手机号是可编辑项:档案带出后写入表单(已有值不覆盖)
  478. if (!this.form.phone) this.form.phone = vo.phone || vo.mobile || "";
  479. this.profile.legalName =
  480. vo.company ||
  481. vo.legalEntity ||
  482. vo.legalEntityName ||
  483. this.findLegalNameByDept(deptId) ||
  484. this.profile.legalName;
  485. } catch (error) {
  486. // 档案带出失败不阻断,保留本地缓存值
  487. }
  488. // 岗位层级:只有档案聚合接口的「主岗位 positionLevelName」才可靠 ——
  489. // /main/user/getById 多数情况不返回 positionList,导致层级一直显示「—」
  490. try {
  491. const raw = await fetchUserProfile(userId, false);
  492. const primary = pickPrimaryPosition(raw || {});
  493. const levelName =
  494. (primary && (primary.positionLevelName || primary.positionLevel)) || "";
  495. if (levelName) this.profile.level = levelName;
  496. } catch (error) {
  497. // 档案聚合接口不可用时保留 getById 的结果
  498. }
  499. // 劳动合同:签订年限 / 合同期限(岗位层级缺失时用合同快照兜底)
  500. const contract = await loadEmployeeContractInfo(userId, false);
  501. const levelMissing = !this.profile.level;
  502. this.profile.contractTerm = contract.contractTerm || "";
  503. this.profile.contractPeriod = contract.periodText || "";
  504. if (levelMissing && contract.positionLevel) this.profile.level = contract.positionLevel;
  505. // 任职周期:随单保存 employmentPeriodId,为空时后端取当前有效周期
  506. const period = await loadEmploymentPeriod(userId, false);
  507. if (period && period.id != null) this.form.employmentPeriodId = period.id;
  508. },
  509. /** 按部门沿 parentId 上溯到法人节点,取机构名称 */
  510. findLegalNameByDept(deptId) {
  511. if (deptId == null || deptId === "" || !this.orgList.length) return "";
  512. const byId = new Map(this.orgList.map((node) => [String(node.id), node]));
  513. let node = byId.get(String(deptId));
  514. let guard = 0;
  515. while (node && guard < 40) {
  516. if (LEGAL_TYPES.has(String(node.type))) return node.name || "";
  517. const parentId = node.parentId == null ? "0" : String(node.parentId);
  518. if (parentId === "0") break;
  519. node = byId.get(parentId);
  520. guard++;
  521. }
  522. return "";
  523. },
  524. syncOrigin() {
  525. const user = this.currentUser || {};
  526. this.form.userId = this.form.userId || user.id || null;
  527. this.form.name = this.form.name || user.name || "";
  528. this.form.employeeNo = this.form.employeeNo || "";
  529. this.form.dept = this.form.dept || user.department || "";
  530. this.form.position = this.form.position || user.position || "";
  531. this.form.level = this.form.level || user.level || "";
  532. },
  533. // ============ 级联:机构 → 部门 → 序列 → 岗位 → 性质 + 层级 ============
  534. clearBelowDept() {
  535. this.form.targetDeptId = null;
  536. this.form.targetDeptName = "";
  537. this.sequenceOptions = [];
  538. this.clearBelowSequence();
  539. },
  540. clearBelowSequence() {
  541. this.form.positionSequenceId = null;
  542. this.form.positionSequence = "";
  543. this.positionOptions = [];
  544. this.clearBelowPosition();
  545. },
  546. clearBelowPosition() {
  547. this.form.targetPositionId = null;
  548. this.form.targetPositionName = "";
  549. this.form.positionType = "";
  550. this.form.positionTypeName = "";
  551. this.form.positionLevelId = null;
  552. this.form.positionLevel = "";
  553. this.levelOptions = [];
  554. },
  555. onLegalChange(legalId) {
  556. if (this.isReadonly) return;
  557. const legal = this.legalOptions.find((item) => String(item.id) === String(legalId));
  558. if (!legal) return;
  559. this.form.targetLegalId = legal.id;
  560. this.form.targetLegalName = legal.name;
  561. this.clearBelowDept();
  562. this.rebuildDeptOptions();
  563. },
  564. /** 调动部门:树选择器回传节点 id,这里从部门树里补出节点名 */
  565. onTargetDeptPick(deptId) {
  566. if (this.isReadonly) return;
  567. const hit = this.findDeptNode(this.deptTree, deptId);
  568. this.form.targetDeptId = deptId;
  569. this.form.targetDeptName = (hit && hit.name) || "";
  570. this.clearBelowSequence();
  571. this.loadSequences();
  572. },
  573. /** 在部门树里按 id 找节点 */
  574. findDeptNode(tree, id) {
  575. for (const node of tree || []) {
  576. if (String(node.id) === String(id)) return node;
  577. const hit = this.findDeptNode(node.children || [], id);
  578. if (hit) return hit;
  579. }
  580. return null;
  581. },
  582. async loadSequences() {
  583. if (!this.form.targetDeptId) {
  584. this.sequenceOptions = [];
  585. return;
  586. }
  587. this.sequenceLoading = true;
  588. try {
  589. const list = await getDeptPositionSequences(this.form.targetDeptId, false);
  590. this.sequenceOptions = (Array.isArray(list) ? list : [])
  591. .filter((item) => item && item.positionSequenceId != null)
  592. .map((item) => ({
  593. id: String(item.positionSequenceId),
  594. // 名称字段后端有 positionSequence / positionSequenceName 两种口径,
  595. // 只取前者会出现「选项存在但文字空白」(与 utils/hr/position-cascade.js 对齐)
  596. name: item.positionSequenceName || item.positionSequence || "",
  597. }));
  598. } catch (error) {
  599. this.sequenceOptions = [];
  600. } finally {
  601. this.sequenceLoading = false;
  602. }
  603. },
  604. async onSequenceChange(sequenceId) {
  605. if (this.isReadonly) return;
  606. const sequence = this.sequenceOptions.find(
  607. (item) => String(item.id) === String(sequenceId)
  608. );
  609. if (!sequence) return;
  610. this.form.positionSequenceId = sequence.id;
  611. this.form.positionSequence = sequence.name;
  612. this.clearBelowPosition();
  613. await this.loadPositions();
  614. },
  615. async loadPositions() {
  616. const deptId = this.form.targetDeptId;
  617. const positionSequenceId = this.form.positionSequenceId;
  618. if (!deptId || !positionSequenceId) {
  619. this.positionOptions = [];
  620. return;
  621. }
  622. this.positionLoading = true;
  623. try {
  624. const list = await getDeptPositions({
  625. deptId,
  626. positionSequenceId
  627. }, false);
  628. this.positionOptions = (Array.isArray(list) ? list : [])
  629. .filter((item) => item && item.positionId != null)
  630. .map((item) => ({
  631. id: String(item.positionId),
  632. name: item.positionName || "",
  633. }));
  634. } catch (error) {
  635. this.positionOptions = [];
  636. } finally {
  637. this.positionLoading = false;
  638. }
  639. },
  640. async onTargetPositionChange(positionId) {
  641. if (this.isReadonly) return;
  642. const position = this.positionOptions.find(
  643. (item) => String(item.id) === String(positionId)
  644. );
  645. if (!position) return;
  646. this.form.targetPositionId = position.id;
  647. this.form.targetPositionName = position.name;
  648. this.clearBelowPositionKeepName();
  649. await this.loadTypeAndLevels();
  650. },
  651. // 选择岗位后清空性质/层级但保留岗位本身
  652. clearBelowPositionKeepName() {
  653. this.form.positionType = "";
  654. this.form.positionTypeName = "";
  655. this.form.positionLevelId = null;
  656. this.form.positionLevel = "";
  657. this.levelOptions = [];
  658. },
  659. /**
  660. * 部门 + 序列 + 岗位 → 岗位性质(取首条)+ 岗位层级下拉。
  661. * @param {{keepWhenEmpty?:boolean}} options
  662. * keepWhenEmpty:详情/编辑回显时不要把单据里已有的岗位性质清空
  663. * (接口没返回 ≠ 单据没值,PC 的详情回显同样保留原值)
  664. */
  665. async loadTypeAndLevels(options = {}) {
  666. const keepWhenEmpty = !!options.keepWhenEmpty;
  667. const deptId = this.form.targetDeptId;
  668. const positionId = this.form.targetPositionId;
  669. const positionSequenceId = this.form.positionSequenceId;
  670. if (!deptId || !positionId || !positionSequenceId) return;
  671. this.levelLoading = true;
  672. try {
  673. const typeList = await getDeptPositionType({
  674. deptId,
  675. positionId,
  676. positionSequenceId,
  677. }, false);
  678. const first = (Array.isArray(typeList) ? typeList : [])[0];
  679. // 编码只存编码(提交值就是它);名称:接口 positionTypeName 优先,
  680. // 取不到时按岗位性质字典 position_nature 反查(对齐 PC 与 positionCascade 混入),
  681. // 不拿编码当名称,否则只读框会显示成 "1"
  682. const rawType = (first && first.positionType) || "";
  683. let typeName = String((first && first.positionTypeName) || "").trim();
  684. if (!typeName && rawType) {
  685. await this.loadPositionNatureDict();
  686. typeName = this.positionNatureLabelOf(rawType);
  687. }
  688. if (rawType) {
  689. this.form.positionType = rawType;
  690. if (typeName) this.form.positionTypeName = typeName;
  691. } else if (!keepWhenEmpty) {
  692. this.form.positionType = "";
  693. this.form.positionTypeName = "";
  694. }
  695. } catch (error) {
  696. if (!keepWhenEmpty) {
  697. this.form.positionType = "";
  698. this.form.positionTypeName = "";
  699. }
  700. }
  701. if (!this.form.positionType) {
  702. this.levelOptions = [];
  703. this.levelLoading = false;
  704. return;
  705. }
  706. try {
  707. const levelList = await getDeptPositionLevels({
  708. deptId,
  709. positionId,
  710. positionSequenceId,
  711. positionType: this.form.positionType,
  712. }, false);
  713. this.levelOptions = (Array.isArray(levelList) ? levelList : [])
  714. .filter((item) => item && item.positionLevelId != null)
  715. .map((item) => ({
  716. id: String(item.positionLevelId),
  717. name: item.positionLevel || "",
  718. }));
  719. } catch (error) {
  720. this.levelOptions = [];
  721. } finally {
  722. this.levelLoading = false;
  723. }
  724. },
  725. /* ===== 岗位性质字典(接口只回编码时反查显示名,口径对齐 PC) ===== */
  726. async loadPositionNatureDict() {
  727. if ((this.positionNatureOptions || []).length) return this.positionNatureOptions;
  728. try {
  729. const dict = await getDictByCode("position_nature");
  730. this.positionNatureOptions = dictToPositionNatureOptions(dict);
  731. } catch (error) {
  732. this.positionNatureOptions = [];
  733. }
  734. return this.positionNatureOptions;
  735. },
  736. /** 岗位性质编码 → 字典文案;查不到返回空串(不把编码当名称) */
  737. positionNatureLabelOf(code) {
  738. const key = code == null ? "" : String(code).trim();
  739. if (!key) return "";
  740. const hit = (this.positionNatureOptions || []).find(
  741. (item) => String(item.value).trim() === key
  742. );
  743. return hit && hit.label ? String(hit.label).trim() : "";
  744. },
  745. onLevelChange(levelId) {
  746. if (this.isReadonly) return;
  747. const level = this.levelOptions.find((item) => String(item.id) === String(levelId));
  748. if (!level) return;
  749. this.form.positionLevelId = level.id;
  750. this.form.positionLevel = level.name;
  751. },
  752. onChangeType(changeType) {
  753. if (this.isReadonly) return;
  754. this.form.changeType = changeType;
  755. },
  756. onEffectiveDateChange(e) {
  757. if (this.isReadonly) return;
  758. this.form.effectiveDate = (e && e.detail && e.detail.value) || "";
  759. },
  760. validate() {
  761. const f = this.form;
  762. if (!f.changeType) return "请选择异动类型";
  763. if (!f.targetLegalId) return "请选择所属机构";
  764. if (!f.targetDeptId) return "请选择调动部门";
  765. if (!f.positionSequenceId) return "请选择岗位序列";
  766. if (!f.targetPositionId) return "请选择调动岗位";
  767. if (!f.positionType) return "请先选择调动岗位以自动带出岗位性质";
  768. if (!f.positionLevelId) return "请选择岗位层级";
  769. if (!f.effectiveDate) return "请选择生效日期";
  770. // 手机号口径与 PC 一致:必填 + 11 位手机号格式
  771. if (!String(f.phone || "").trim()) return "请输入手机号";
  772. if (!/^1[3-9]\d{9}$/.test(String(f.phone).trim())) return "手机号格式不正确";
  773. if (!String(f.reason || "").trim()) return "请填写变更原因";
  774. return null;
  775. },
  776. async submitApplication() {
  777. if (this.submitting) return;
  778. const error = this.validate();
  779. if (error) {
  780. uni.showToast({
  781. title: error,
  782. icon: "none"
  783. });
  784. return;
  785. }
  786. const isEdit = this.isEditMode;
  787. this.submitting = true;
  788. uni.showLoading({
  789. title: "提交中..."
  790. });
  791. try {
  792. this.syncOrigin();
  793. this.form.approvalDocumentFileId = this.form.attachmentIds[0] || null;
  794. // 编辑态:existingId 为原异动单主键,submitTransferApplication 会先更新再重新发起审批
  795. const result = await submitTransferApplication({
  796. form: Object.assign({}, this.form, {
  797. name: this.form.name || this.currentUser.name
  798. }),
  799. existingId: this.form.id,
  800. });
  801. this.form.id = result.id;
  802. if (!isEdit) {
  803. addApplication({
  804. type: this.meta.title,
  805. summary: this.summaryText,
  806. reason: this.form.reason,
  807. color: this.meta.color,
  808. metadata: {
  809. changeId: result.id,
  810. changeType: this.form.changeType,
  811. targetPositionName: this.form.targetPositionName,
  812. effectiveDate: this.form.effectiveDate,
  813. source: "server",
  814. },
  815. });
  816. }
  817. uni.hideLoading();
  818. const modal = this.resultModal(result, isEdit);
  819. uni.showModal({
  820. title: modal.title,
  821. content: modal.content,
  822. showCancel: false,
  823. success: () => this.leaveAfterSubmit(),
  824. });
  825. } catch (e) {
  826. uni.hideLoading();
  827. uni.showToast({
  828. title: (e && e.message) || "提交失败",
  829. icon: "none"
  830. });
  831. } finally {
  832. this.submitting = false;
  833. }
  834. },
  835. /** 提交结果文案(创建态与编辑态区分) */
  836. resultModal(result, isEdit) {
  837. if (result && result.approvalStarted) {
  838. return {
  839. title: isEdit ? "修改成功" : "提交成功",
  840. content: isEdit
  841. ? "调岗申请已修改并重新提交审批,可在「我的申请」中查看进度。"
  842. : "调岗申请已提交,审批流程已发起,可在「我的申请」中查看进度。",
  843. };
  844. }
  845. const reason = (result && result.approvalError) || "未知原因";
  846. return {
  847. title: "申请已提交",
  848. content: isEdit
  849. ? `异动单已更新,但审批流程未重新发起:${reason}。请联系管理员处理。`
  850. : `异动单已创建,但审批流程未发起:${reason}。请联系管理员处理。`,
  851. };
  852. },
  853. leaveAfterSubmit() {
  854. // 异动列表页已下线:提交后统一回「我的申请」(从菜单直达发起页,没有列表页可退)
  855. this.backToApplicationList("/pages/hr/apply/list");
  856. },
  857. // ============ 详情 / 编辑模式 ============
  858. async loadDetailBusiness() {
  859. await this.fetchSourceDetail();
  860. },
  861. /** 只读详情与编辑态共用:按异动单主键拉取业务明细并回填 */
  862. async fetchSourceDetail(businessId) {
  863. const id = businessId || this.appBusinessId;
  864. if (!id) throw new Error("缺少异动单主键,无法加载申请明细");
  865. await this.hydrateForm(id, {
  866. silent: true
  867. });
  868. },
  869. async hydrateForm(id, options = {}) {
  870. const silent = !!options.silent;
  871. if (!id) return null;
  872. if (!silent) {
  873. uni.showLoading({
  874. title: "加载中..."
  875. });
  876. }
  877. try {
  878. const row = await loadTransferDetail(id);
  879. if (!row) throw new Error("未找到该调岗单");
  880. // 静默回填(详情/编辑)不做状态校验:只读详情需要展示任意状态的数据
  881. if (!silent && !canEditUserChange(row)) throw new Error("当前状态不可编辑");
  882. const raw = row.raw || {};
  883. Object.assign(this.form, {
  884. id: row.id,
  885. userId: row.userId,
  886. name: row.name,
  887. employeeNo: row.employeeNo,
  888. dept: row.dept,
  889. position: row.position,
  890. level: row.level,
  891. changeType: row.changeType || "POSITION_TRANSFER",
  892. targetLegalId: raw.targetLegalId != null ? raw.targetLegalId : null,
  893. targetLegalName: raw.targetLegalName || "",
  894. targetDeptId: row.targetDeptId == null ? null : row.targetDeptId,
  895. targetDeptName: row.targetDept,
  896. positionSequenceId: raw.positionSequenceId != null ? raw.positionSequenceId : null,
  897. positionSequence: raw.positionSequence || "",
  898. targetPositionId: row.targetPositionId == null ? null : row.targetPositionId,
  899. targetPositionName: row.targetPosition,
  900. positionType: raw.positionType || "",
  901. positionTypeName: raw.positionTypeName || "",
  902. positionLevelId: raw.positionLevelId != null ? raw.positionLevelId : null,
  903. positionLevel: raw.positionLevel || row.targetLevel || "",
  904. workLocation: row.workLocation || raw.workLocation || "",
  905. effectiveDate: row.effectiveDate,
  906. employmentPeriodId: raw.employmentPeriodId != null ? raw.employmentPeriodId : null,
  907. attachmentIds: raw.approvalDocumentFileId != null ? [raw.approvalDocumentFileId] : [],
  908. reason: row.reason,
  909. remark: row.remark,
  910. });
  911. this.form.approvalDocumentFileId =
  912. raw.approvalDocumentFileId != null ? raw.approvalDocumentFileId : null;
  913. // 详情/编辑回显:单据只存了岗位性质编码时,按字典补显示名
  914. if (this.form.positionType && !this.form.positionTypeName) {
  915. await this.loadPositionNatureDict();
  916. this.form.positionTypeName = this.positionNatureLabelOf(this.form.positionType);
  917. }
  918. // 原合同信息(编辑态同样带出档案 + 合同)
  919. this.loadProfile();
  920. // 还原级联下拉选项(不回填表单值)
  921. this.rebuildDeptOptions();
  922. if (this.form.targetDeptId) await this.loadSequences();
  923. if (this.form.positionSequenceId) await this.loadPositions();
  924. if (this.form.targetPositionId) await this.loadTypeAndLevels({ keepWhenEmpty: true });
  925. return row;
  926. } catch (e) {
  927. if (silent) throw e;
  928. uni.hideLoading();
  929. uni.showToast({
  930. title: (e && e.message) || "加载失败",
  931. icon: "none"
  932. });
  933. // 加载失败兜底:去「我的申请」(异动列表页已下线,不能再跳它)
  934. setTimeout(() => this.backToApplicationList("/pages/hr/apply/list"), 900);
  935. return null;
  936. } finally {
  937. if (!silent) uni.hideLoading();
  938. }
  939. },
  940. },
  941. };
  942. </script>
  943. <style scoped lang="scss">
  944. @import "@/pages/hr/common-style.scss";
  945. .page-no-tab {
  946. padding-bottom: 150rpx;
  947. }
  948. .form-card {
  949. overflow: hidden;
  950. }
  951. /* ============ 详情态(只读) ============ */
  952. /* 只读态隐藏可点击箭头 */
  953. .readonly-mode .arrow {
  954. display: none;
  955. }
  956. .detail-head {
  957. margin: 24rpx 24rpx 0;
  958. padding: 28rpx;
  959. }
  960. .detail-head-top {
  961. display: flex;
  962. align-items: center;
  963. justify-content: space-between;
  964. margin-bottom: 20rpx;
  965. }
  966. .detail-title {
  967. font-size: 32rpx;
  968. font-weight: 600;
  969. color: #1f2329;
  970. }
  971. .detail-row {
  972. display: flex;
  973. align-items: center;
  974. justify-content: space-between;
  975. padding: 12rpx 0;
  976. font-size: 26rpx;
  977. }
  978. .detail-label {
  979. color: #8b939f;
  980. }
  981. .detail-value {
  982. color: #1f2329;
  983. }
  984. .masked-card {
  985. margin: 20rpx 24rpx 0;
  986. padding: 26rpx 28rpx;
  987. }
  988. .masked-label {
  989. display: block;
  990. margin-bottom: 12rpx;
  991. color: #8b939f;
  992. font-size: 23rpx;
  993. }
  994. .masked-text {
  995. color: #4b5563;
  996. font-size: 26rpx;
  997. line-height: 1.6;
  998. }
  999. .status-tag.status-draft,
  1000. .status-tag.status-cancelled {
  1001. color: #6f7782;
  1002. background: #eef1f3;
  1003. }
  1004. /* 小节标题(主题色竖条 + 加粗深色文字)已统一到
  1005. @/pages/hr/common-style.scss,本页不再重复定义。 */
  1006. .field {
  1007. position: relative;
  1008. min-height: 108rpx;
  1009. /* 右侧多留 8rpx:值 / 箭头离卡片右边缘太贴边 */
  1010. padding: 28rpx 36rpx;
  1011. display: flex;
  1012. align-items: center;
  1013. }
  1014. .field.column {
  1015. display: block;
  1016. padding-bottom: 22rpx;
  1017. }
  1018. .field.column .label {
  1019. display: block;
  1020. width: auto;
  1021. margin-bottom: 16rpx;
  1022. }
  1023. .label {
  1024. /* 「原合同签订年限」7 个字在 180rpx 下会折行 → 加宽到 210rpx 并禁止换行 */
  1025. width: 210rpx;
  1026. flex-shrink: 0;
  1027. white-space: nowrap;
  1028. font-size: 28rpx;
  1029. }
  1030. .required::before {
  1031. content: "*";
  1032. color: #ee4d4d;
  1033. margin-right: 6rpx;
  1034. }
  1035. .readonly {
  1036. flex: 1;
  1037. display: flex;
  1038. align-items: center;
  1039. justify-content: flex-end;
  1040. font-size: 26rpx;
  1041. }
  1042. .small-avatar {
  1043. width: 52rpx;
  1044. height: 52rpx;
  1045. margin-right: 14rpx;
  1046. font-size: 22rpx;
  1047. }
  1048. .value {
  1049. flex: 1;
  1050. text-align: right;
  1051. color: #1f2329;
  1052. font-size: 27rpx;
  1053. }
  1054. .placeholder {
  1055. flex: 1;
  1056. text-align: right;
  1057. color: #b2b7be;
  1058. font-size: 27rpx;
  1059. }
  1060. .field-input {
  1061. flex: 1;
  1062. min-width: 0;
  1063. text-align: right;
  1064. color: #1f2329;
  1065. font-size: 27rpx;
  1066. }
  1067. .arrow {
  1068. margin-left: 13rpx;
  1069. color: #c4c8ce;
  1070. font-size: 38rpx;
  1071. }
  1072. .textarea-field {
  1073. display: block;
  1074. min-height: 240rpx;
  1075. }
  1076. .textarea-field .label {
  1077. display: block;
  1078. width: auto;
  1079. margin-bottom: 20rpx;
  1080. }
  1081. .textarea-field textarea {
  1082. width: 100%;
  1083. height: 130rpx;
  1084. padding: 18rpx;
  1085. border-radius: 12rpx;
  1086. background: #f6f7f8;
  1087. font-size: 27rpx;
  1088. }
  1089. .counter {
  1090. position: absolute;
  1091. right: 45rpx;
  1092. bottom: 38rpx;
  1093. color: #b2b7be;
  1094. font-size: 21rpx;
  1095. }
  1096. .bottom-action {
  1097. position: fixed;
  1098. z-index: 10;
  1099. left: 0;
  1100. right: 0;
  1101. bottom: 0;
  1102. height: 120rpx;
  1103. padding: 16rpx 24rpx;
  1104. display: flex;
  1105. gap: 18rpx;
  1106. background: #fff;
  1107. border-top: 1rpx solid #e8e9eb;
  1108. }
  1109. .submit-btn {
  1110. flex: 1;
  1111. height: 84rpx;
  1112. border-radius: 12rpx;
  1113. display: flex;
  1114. align-items: center;
  1115. justify-content: center;
  1116. font-size: 30rpx;
  1117. background: #157a2c;
  1118. color: #fff;
  1119. }
  1120. @media (min-width: 800px) {
  1121. .bottom-action {
  1122. max-width: 520px;
  1123. left: 50%;
  1124. transform: translateX(-50%);
  1125. }
  1126. }
  1127. </style>