regular-create.vue 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940
  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"
  4. color="#fff" @clickLeft="backToList"></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 || form.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. <!-- ============ 员工信息(字段与 PC 端「新建转正申请」一致)============ -->
  33. <view class="form-card card section">
  34. <view class="section-head">
  35. <view class="section-title"><text>员工信息</text></view>
  36. </view>
  37. <hr-picker-row v-bind="pickerRowMetrics" label="申请员工" required :value="form.userId"
  38. :display="form.userId ? employeeText : ''" :options="employeeOptions" placeholder="当前登录人"
  39. :disabled="lockedEmployee" @change="onEmployeePick" />
  40. <view class="divider"></view>
  41. <view class="field">
  42. <text class="label required">手机号</text>
  43. <input class="input-flex" type="number" maxlength="11" :disabled="formDisabled" :value="form.phone"
  44. placeholder="请输入 11 位手机号码" @input="onPhoneInput" />
  45. </view>
  46. <view class="divider"></view>
  47. <hr-picker-row v-bind="pickerRowMetrics" label="所属机构" required :value="form.groupId" :display="form.company"
  48. :options="legalOptions.map((item) => ({ label: item.name, value: item.id }))" hint="机构数据加载中"
  49. @change="onCascadeLegalChange" />
  50. <view class="divider"></view>
  51. <hr-picker-row v-bind="pickerRowMetrics" mode="tree" label="所属部门" required :value="form.deptId"
  52. :display="form.department" :tree="cascadeDeptTree" :disabled="!form.groupId" :hint="deptPlaceholder"
  53. @change="onCascadeDeptPick" />
  54. <view class="divider"></view>
  55. <hr-picker-row v-bind="pickerRowMetrics" label="岗位序列" required :value="form.positionSequenceId"
  56. :display="form.positionSequence"
  57. :options="sequenceOptions.map((item) => ({ label: item.name, value: item.id }))"
  58. :disabled="!form.deptId" :hint="sequencePlaceholder" @change="onCascadeSequenceChange" />
  59. <view class="divider"></view>
  60. <hr-picker-row v-bind="pickerRowMetrics" label="岗位名称" required :value="form.positionId"
  61. :display="form.position"
  62. :options="cascadePositionOptions.map((item) => ({ label: item.name, value: item.id }))"
  63. :disabled="!form.positionSequenceId" :hint="positionPlaceholder" @change="onCascadePositionChange" />
  64. <view class="divider"></view>
  65. <view class="field">
  66. <text class="label required">岗位性质</text>
  67. <text class="value readonly-text">{{ form.positionTypeName || "选择岗位名称后自动带出" }}</text>
  68. </view>
  69. <view class="divider"></view>
  70. <hr-picker-row v-bind="pickerRowMetrics" label="岗位层级" required :value="form.positionLevelId"
  71. :display="form.positionLevel"
  72. :options="levelOptions.map((item) => ({ label: item.name, value: item.id }))"
  73. :disabled="!form.positionId" :hint="levelPlaceholder" @change="onCascadeLevelChange" />
  74. <view class="divider"></view>
  75. <!-- 直属上级:与「个人档案」同款选人弹窗(整行可点,右侧 › 靠右;只读态不显示 ›) -->
  76. <view class="field press" @click="openManagerPicker">
  77. <text class="label">直属上级</text>
  78. <text :class="form.managerUserName ? 'value' : 'placeholder'">{{ form.managerUserName || "请选择" }}</text>
  79. <text v-if="!isReadonly" class="arrow">›</text>
  80. </view>
  81. </view>
  82. <!-- ============ 转正信息 ============ -->
  83. <view class="form-card card section">
  84. <view class="section-head">
  85. <view class="section-title"><text>转正信息</text></view>
  86. </view>
  87. <view class="field">
  88. <text class="label">入职日期</text>
  89. <text class="value readonly-text">{{ form.hireDate || "未填写" }}</text>
  90. </view>
  91. <view class="divider"></view>
  92. <hr-picker-row v-bind="pickerRowMetrics" label="转正类型" required :value="form.regularizationType"
  93. :display="typeLabel" :options="typeOptions" :disabled="formDisabled" @change="onTypeChange" />
  94. <view class="divider"></view>
  95. <view class="field">
  96. <text class="label required">计划转正日期</text>
  97. <picker class="field-picker" :disabled="formDisabled" mode="date" :value="form.plannedRegularDate"
  98. @change="onDateChange('plannedRegularDate', $event)">
  99. <text :class="form.plannedRegularDate ? 'value' : 'placeholder'">
  100. {{ form.plannedRegularDate || "请选择" }}
  101. </text>
  102. </picker>
  103. <text class="arrow">›</text>
  104. </view>
  105. <view class="divider"></view>
  106. <view class="field">
  107. <text class="label">实际生效日期</text>
  108. <picker class="field-picker" :disabled="formDisabled" mode="date" :value="form.effectiveDate"
  109. @change="onDateChange('effectiveDate', $event)">
  110. <text :class="form.effectiveDate ? 'value' : 'placeholder'">
  111. {{ form.effectiveDate || "为空按计划日期生效" }}
  112. </text>
  113. </picker>
  114. <text class="arrow">›</text>
  115. </view>
  116. <view class="divider"></view>
  117. <view class="field file-field">
  118. <text class="label">申请资料</text>
  119. <view class="field-file">
  120. <fileMain v-model="form.attachmentIds" :type="isReadonly ? 'view' : ''" />
  121. </view>
  122. </view>
  123. </view>
  124. <!-- ============ 延期信息(转正类型 = 延期转正)============ -->
  125. <view v-if="isExtended" class="form-card card section">
  126. <view class="section-head">
  127. <view class="section-title"><text>延期信息</text></view>
  128. <text class="section-tip">延期转正需填写延期天数与原因</text>
  129. </view>
  130. <view class="field">
  131. <text class="label required">延期天数</text>
  132. <input v-model="form.extensionDays" :disabled="formDisabled" type="number" class="number-input"
  133. placeholder="请输入延期天数" />
  134. </view>
  135. <view class="divider"></view>
  136. <view class="field">
  137. <text class="label">延期后试用结束日</text>
  138. <picker class="field-picker" :disabled="formDisabled" mode="date" :value="form.newProbationEndDate"
  139. @change="onDateChange('newProbationEndDate', $event)">
  140. <text :class="form.newProbationEndDate ? 'value' : 'placeholder'">
  141. {{ form.newProbationEndDate || "请选择" }}
  142. </text>
  143. </picker>
  144. <text class="arrow">›</text>
  145. </view>
  146. <view class="divider"></view>
  147. <view class="field textarea-field">
  148. <text class="label required">延期原因</text>
  149. <textarea v-model="form.extensionReason" :disabled="formDisabled" maxlength="200"
  150. placeholder="请说明延期原因" />
  151. <text class="counter">{{ (form.extensionReason || "").length }}/200</text>
  152. </view>
  153. </view>
  154. <view v-if="showBottomBar" class="bottom-action">
  155. <view v-if="isCreateMode" class="draft-btn press" @click="onSaveDraft">保存草稿</view>
  156. <view class="submit-btn press" @click="onBottomAction">{{ bottomButtonText }}</view>
  157. </view>
  158. <!-- 直属上级:与「个人档案」一致的选人弹窗(搜索 + 机构筛选 + 列表) -->
  159. <selectUserDialog ref="userPicker" @confirm="onUserPicked" />
  160. </view>
  161. </template>
  162. <script>
  163. import {
  164. applicationTypes
  165. } from "@/pages/hr/data/apply-config";
  166. import fileMain from "@/pages/doc/index.vue";
  167. import {
  168. REGULARIZATION_TYPE_LABELS,
  169. REGULARIZATION_TYPE_OPTIONS,
  170. canEditRegularization,
  171. fetchRegularizationEmployee,
  172. hydrateRegularizationForm,
  173. loadEmploymentPeriod,
  174. loadRegularizationDetail,
  175. loadRegularizationFormOptions,
  176. resolveRegularizationId,
  177. saveRegularizationDraft,
  178. submitRegularizationFlow,
  179. } from "@/api/hr/regularization";
  180. import {
  181. applicationModeMixin,
  182. APPLICATION_MODE
  183. } from "@/utils/hr/application-mode";
  184. import {
  185. positionCascadeMixin
  186. } from "@/utils/hr/position-cascade";
  187. import {
  188. currentUserId
  189. } from "@/api/hr/user";
  190. import HrPickerRow from "@/components/hr/HrPickerRow.vue";
  191. import selectUserDialog from "@/components/selectUserDialog.vue";
  192. import {
  193. back
  194. } from "@/utils/hr/router";
  195. /** 附件数组 → 非空 id 列表 */
  196. function attachmentsOf(form = {}) {
  197. return (Array.isArray(form.attachmentIds) ? form.attachmentIds : []).filter(Boolean).map((id) => String(id));
  198. }
  199. function emptyForm() {
  200. return {
  201. regularizationId: null,
  202. // —— 员工信息 ——
  203. userId: "",
  204. name: "",
  205. employeeNo: "",
  206. avatar: "",
  207. phone: "",
  208. groupId: "",
  209. company: "",
  210. deptId: "",
  211. department: "",
  212. positionSequenceId: "",
  213. positionSequence: "",
  214. positionId: "",
  215. position: "",
  216. positionType: "",
  217. positionTypeName: "",
  218. positionLevelId: "",
  219. positionLevel: "",
  220. managerUserId: "",
  221. managerUserName: "",
  222. manager: "",
  223. hireDate: "",
  224. // —— 转正信息 ——
  225. regularizationType: "NORMAL",
  226. plannedRegularDate: "",
  227. effectiveDate: "",
  228. // 签署方式固定线下签署(页面上不做切换)
  229. signingMethod: "OFFLINE",
  230. online: false,
  231. onlineDocumentTemplateId: null,
  232. employmentPeriodId: null,
  233. extensionDays: "",
  234. extensionReason: "",
  235. newProbationEndDate: "",
  236. // —— 评估意见 ——
  237. // 表单已不再录入这三项(2026-10-15 按要求去掉页面上的「评估意见」模块),
  238. // 但字段必须保留:一是历史驳回单回填的值不能被清空,二是提交载荷口径不变
  239. // (见 api/hr/regularization.js 的 buildRegularizationPayload)。
  240. userSummary: "",
  241. managerEvaluation: "",
  242. hrComment: "",
  243. // —— 状态 ——
  244. status: "",
  245. statusLabel: "",
  246. attachmentIds: [],
  247. };
  248. }
  249. export default {
  250. components: {
  251. fileMain,
  252. HrPickerRow,
  253. selectUserDialog
  254. },
  255. mixins: [applicationModeMixin, positionCascadeMixin],
  256. data() {
  257. return {
  258. form: emptyForm(),
  259. employees: [],
  260. submitting: false,
  261. };
  262. },
  263. computed: {
  264. /** 统一选择行(HrPickerRow)的行度量:与本页 .field/.label 口径一致,避免左侧对不齐 */
  265. pickerRowMetrics() {
  266. return {
  267. rowMinHeight: "108rpx",
  268. rowPadding: "28rpx",
  269. labelWidth: "180rpx",
  270. };
  271. },
  272. meta() {
  273. return applicationTypes.regular || {
  274. title: "转正申请",
  275. subtitle: "",
  276. icon: "正",
  277. color: "#269a99"
  278. };
  279. },
  280. navTitle() {
  281. return this.applicationTitle(this.meta.title);
  282. },
  283. pageTitle() {
  284. return this.meta.title;
  285. },
  286. /**
  287. * 申请员工:移动端是**员工自助**,只能给自己申请 —— 恒锁定不可更换
  288. * (与 PC 的远程搜人员工选择器口径不同,这是移动端既定定位)
  289. */
  290. lockedEmployee() {
  291. return true;
  292. },
  293. employeeText() {
  294. return this.form.userId ? `${this.form.name || "—"}(${this.form.employeeNo || "-"})` : "当前登录人";
  295. },
  296. typeLabel() {
  297. return REGULARIZATION_TYPE_LABELS[this.form.regularizationType] || "按期转正";
  298. },
  299. /** 申请员工候选(统一选择器用;显示与原来 actionSheet 一致:姓名(工号)) */
  300. employeeOptions() {
  301. return (this.employees || []).map((item) => ({
  302. label: `${item.name}(${item.employeeNo || "-"})`,
  303. value: item.id,
  304. }));
  305. },
  306. /** 转正类型候选 */
  307. typeOptions() {
  308. return REGULARIZATION_TYPE_OPTIONS.map((item) => ({
  309. label: item.label,
  310. value: item.value
  311. }));
  312. },
  313. isExtended() {
  314. return this.form.regularizationType === "EXTENDED";
  315. },
  316. deptPlaceholder() {
  317. return this.form.groupId ? "请选择" : "请先选择所属机构";
  318. },
  319. sequencePlaceholder() {
  320. return this.form.deptId ? "请选择" : "请先选择所属部门";
  321. },
  322. positionPlaceholder() {
  323. return this.form.positionSequenceId ? "请选择" : "请先选择岗位序列";
  324. },
  325. levelPlaceholder() {
  326. return this.form.positionId ? "请选择" : "请先选择岗位名称";
  327. },
  328. },
  329. onLoad(options) {
  330. const opts = options || {};
  331. this.initApplicationMode(opts);
  332. // 本页不直接承接 mode=edit(编辑由详情页内「编辑」按钮进入)
  333. if (this.appMode === APPLICATION_MODE.EDIT) this.appMode = APPLICATION_MODE.DETAIL;
  334. if (this.isCreateMode) {
  335. this.init(opts);
  336. return;
  337. }
  338. uni.setNavigationBarTitle({
  339. title: this.navTitle
  340. });
  341. // 选项(员工 / 模板)仍需加载,只读详情要显示模板名
  342. this.loadOptions().then(() => this.initDetailMode());
  343. },
  344. methods: {
  345. // 返回上一页;页面栈里只有本页时(菜单直达 / 深链进入)兜底回「工作中心」。
  346. // 注意:不能再兜底到转正办理列表页——该列表已下线(pages.json 路由一并删除),
  347. // 否则会出现「凭空跳到一个自己从没进过的页面」。
  348. backToList() {
  349. back("/pages/index/index");
  350. },
  351. async loadOptions() {
  352. try {
  353. const options = await loadRegularizationFormOptions();
  354. this.employees = options.employees || [];
  355. } catch (error) {
  356. uni.showToast({
  357. title: (error && error.message) || "选项加载失败",
  358. icon: "none"
  359. });
  360. }
  361. },
  362. async init(options) {
  363. uni.setNavigationBarTitle({
  364. title: this.navTitle
  365. });
  366. await this.loadOptions();
  367. const id = options.id || "";
  368. if (id) {
  369. uni.showLoading({
  370. title: "加载中..."
  371. });
  372. try {
  373. const row = await loadRegularizationDetail(id);
  374. if (!row) throw new Error("未找到该转正单");
  375. if (!canEditRegularization(row)) throw new Error("当前状态不可编辑");
  376. await this.hydrateBill(row.regularizationId || row.id);
  377. } catch (e) {
  378. uni.hideLoading();
  379. uni.showToast({
  380. title: (e && e.message) || "加载失败",
  381. icon: "none"
  382. });
  383. setTimeout(() => this.backToList(), 900);
  384. return;
  385. }
  386. uni.hideLoading();
  387. return;
  388. }
  389. // 员工自助:只能给自己申请 —— 未显式带 userId 时取当前登录人
  390. const userId = options.userId || currentUserId() || "";
  391. if (userId) {
  392. const employee = this.employees.find((item) => String(item.id) === String(userId)) || null;
  393. if (employee) {
  394. await this.selectEmployee(employee);
  395. } else {
  396. this.form.userId = userId;
  397. // 不在「试用期员工」候选里(例如非试用期)也要把本人档案带出来,便于填表
  398. const brief = await fetchRegularizationEmployee(userId, false);
  399. await this.selectEmployee(Object.assign({
  400. id: userId
  401. }, brief || {}));
  402. const billId = await resolveRegularizationId(userId);
  403. if (billId) {
  404. await this.hydrateBill(billId);
  405. if (this.form.status && !canEditRegularization({
  406. status: this.form.status
  407. })) {
  408. uni.showToast({
  409. title: "该员工当前状态不可申请转正",
  410. icon: "none"
  411. });
  412. setTimeout(() => this.backToList(), 900);
  413. }
  414. }
  415. }
  416. }
  417. // 未带 userId(菜单直达)时也要把机构 / 部门候选加载出来并带出默认机构,
  418. // 否则「所属机构」下拉是空的(原先只有选人后才触发加载)→ 用户以为选不出来
  419. await this.initCascadeOrg();
  420. },
  421. /** 申请员工:统一选择器回传员工主键,再走原有的档案带出逻辑 */
  422. onEmployeePick(userId) {
  423. if (this.isReadonly || this.lockedEmployee) return;
  424. const picked = this.employees.find((item) => String(item.id) === String(userId));
  425. if (picked) this.selectEmployee(picked);
  426. },
  427. /** 选中员工:按 PC 逻辑带出档案快照(含组织 / 岗位级联主键) */
  428. async selectEmployee(employee) {
  429. const brief = employee || {};
  430. Object.assign(this.form, {
  431. userId: brief.id,
  432. name: brief.name || "",
  433. employeeNo: brief.employeeNo || "",
  434. avatar: brief.avatar || String(brief.name || "").charAt(0),
  435. phone: brief.phone || "",
  436. groupId: brief.groupId || "",
  437. company: brief.company || "",
  438. deptId: brief.deptId || "",
  439. department: brief.department || "",
  440. positionSequenceId: brief.positionSequenceId || "",
  441. positionSequence: brief.positionSequence || "",
  442. positionId: brief.positionId || "",
  443. position: brief.position || "",
  444. positionType: brief.positionType || "",
  445. positionTypeName: brief.positionTypeName || "",
  446. positionLevelId: brief.positionLevelId || "",
  447. positionLevel: brief.positionLevel || "",
  448. managerUserId: brief.managerUserId || "",
  449. managerUserName: brief.managerUserName || brief.manager || "",
  450. manager: brief.managerUserName || brief.manager || "",
  451. hireDate: brief.hireDate || "",
  452. });
  453. if (!this.form.plannedRegularDate) {
  454. this.form.plannedRegularDate = brief.regularDate || "";
  455. }
  456. // 列表行缺组织 / 岗位主键时,用完整档案补齐并预热级联
  457. if (!this.form.groupId || !this.form.deptId || !this.form.positionId) {
  458. const detail = await this.loadEmployeeDetail(brief.id);
  459. if (detail) Object.assign(this.form, detail);
  460. }
  461. await this.primeCascade();
  462. const period = await loadEmploymentPeriod(brief.id);
  463. if (period) {
  464. this.form.employmentPeriodId = period.id != null ? period.id : null;
  465. if (!this.form.plannedRegularDate) {
  466. this.form.plannedRegularDate = String(period.probationEndDate || period.regularDate || "")
  467. .slice(0, 10);
  468. }
  469. }
  470. const billId = await resolveRegularizationId(brief.id);
  471. if (billId) await this.hydrateBill(billId);
  472. },
  473. /** 员工档案补全(返回可合并进 form 的级联字段) */
  474. async loadEmployeeDetail(userId) {
  475. if (!userId) return null;
  476. try {
  477. const brief = await fetchRegularizationEmployee(userId, false);
  478. if (!brief) return null;
  479. return {
  480. phone: this.form.phone || brief.phone || "",
  481. groupId: this.form.groupId || brief.groupId || "",
  482. company: this.form.company || brief.company || brief.companyName || "",
  483. deptId: this.form.deptId || brief.deptId || "",
  484. department: this.form.department || brief.department || "",
  485. positionSequenceId: this.form.positionSequenceId || brief.positionSequenceId || "",
  486. positionSequence: this.form.positionSequence || brief.positionSequence || "",
  487. positionId: this.form.positionId || brief.positionId || "",
  488. position: this.form.position || brief.position || brief.positionName || "",
  489. positionType: this.form.positionType || brief.positionType || "",
  490. positionTypeName: this.form.positionTypeName || brief.positionTypeName || "",
  491. positionLevelId: this.form.positionLevelId || brief.positionLevelId || "",
  492. positionLevel: this.form.positionLevel || brief.positionLevel || "",
  493. managerUserId: this.form.managerUserId || brief.managerUserId || "",
  494. managerUserName: this.form.managerUserName || brief.managerUserName || "",
  495. hireDate: this.form.hireDate || brief.hireDate || "",
  496. };
  497. } catch (error) {
  498. return null;
  499. }
  500. },
  501. async hydrateBill(billId) {
  502. if (!billId) return;
  503. try {
  504. const row = await loadRegularizationDetail(billId);
  505. if (!row) return;
  506. const hydrated = hydrateRegularizationForm(row, {
  507. userId: this.form.userId,
  508. name: this.form.name,
  509. employeeNo: this.form.employeeNo,
  510. hireDate: this.form.hireDate,
  511. });
  512. this.form = Object.assign(emptyForm(), this.form, hydrated, {
  513. regularizationId: row.regularizationId || row.id,
  514. userId: row.userId || this.form.userId,
  515. status: row.status || this.form.status,
  516. statusLabel: row.statusLabel || "",
  517. });
  518. // 详情接口不返回岗位主键时,用员工档案补齐并预热级联
  519. if (!this.form.groupId || !this.form.deptId || !this.form.positionId) {
  520. const detail = await this.loadEmployeeDetail(this.form.userId);
  521. if (detail) Object.assign(this.form, detail);
  522. }
  523. await this.primeCascade();
  524. } catch (error) {
  525. uni.showToast({
  526. title: (error && error.message) || "转正单加载失败",
  527. icon: "none"
  528. });
  529. }
  530. },
  531. /** 转正类型 */
  532. onTypeChange(type) {
  533. if (this.isReadonly) return;
  534. this.form.regularizationType = type;
  535. },
  536. /** 通用日期选择:dateKey 为表单字段名 */
  537. onDateChange(dateKey, e) {
  538. if (this.isReadonly) return;
  539. this.form[dateKey] = (e && e.detail && e.detail.value) || "";
  540. },
  541. onPhoneInput(e) {
  542. if (this.isReadonly) return;
  543. this.form.phone = String((e && e.detail && e.detail.value) || "").slice(0, 11);
  544. },
  545. /** 直属上级:打开选人弹窗(与「个人档案」页同一套交互) */
  546. openManagerPicker() {
  547. if (this.isReadonly) return;
  548. if (this.$refs.userPicker) this.$refs.userPicker.open("2");
  549. },
  550. /** 选人弹窗确认:单选模式回传数组,取第一个 */
  551. onUserPicked(list) {
  552. const user = Array.isArray(list) ? list[0] : list;
  553. if (!user) return;
  554. this.applyManager({
  555. id: user.id,
  556. name: user.name || user.userName || ""
  557. });
  558. },
  559. applyManager(picked) {
  560. this.form.managerUserId = picked.id != null ? String(picked.id) : "";
  561. this.form.managerUserName = picked.name || "";
  562. this.form.manager = picked.name || "";
  563. },
  564. // ============ 详情 / 编辑模式 ============
  565. async loadDetailBusiness() {
  566. await this.fetchSourceDetail(this.appBusinessId);
  567. },
  568. /** 只读详情与编辑态共用:按转正单主键拉取业务明细并回填 */
  569. async fetchSourceDetail(businessId) {
  570. const id = businessId || this.appBusinessId;
  571. if (!id) throw new Error("缺少转正单主键,无法加载申请明细");
  572. await this.hydrateBill(id);
  573. if (!this.form.regularizationId) {
  574. throw new Error("未获取到转正申请明细,暂时无法编辑");
  575. }
  576. },
  577. validate() {
  578. if (!this.form.userId) return "请选择申请员工";
  579. if (
  580. this.form.regularizationId &&
  581. this.form.status &&
  582. !canEditRegularization({
  583. status: this.form.status,
  584. regularizationId: this.form.regularizationId
  585. })
  586. ) {
  587. return "当前单据状态不可编辑或提交";
  588. }
  589. if (!/^1[3-9]\d{9}$/.test(String(this.form.phone || "").trim())) return "请输入正确的 11 位手机号码";
  590. if (!this.form.groupId) return "请选择所属机构";
  591. if (!this.form.deptId) return "请选择所属部门";
  592. if (!this.form.positionSequenceId) return "请选择岗位序列";
  593. if (!this.form.positionId) return "请选择岗位名称";
  594. // 岗位性质:PC 的 cascadeRequiredRules 里为必填(选岗位后由接口自动带出)
  595. if (!this.form.positionType) return "请先选择岗位名称以自动带出岗位性质";
  596. if (!this.form.positionLevelId) return "请选择岗位层级";
  597. if (!this.form.regularizationType) return "请选择转正类型";
  598. if (!this.form.plannedRegularDate) return "请选择计划转正日期";
  599. if (this.form.effectiveDate && this.form.effectiveDate < this.form.plannedRegularDate) {
  600. return "实际生效日期不能早于计划转正日期";
  601. }
  602. // 申请资料:PC 表单未设为必填(rules 里没有 offlineFileIds),此处对齐
  603. if (this.isExtended) {
  604. if (!(Number(this.form.extensionDays) > 0)) return "请填写延期天数";
  605. if (!String(this.form.extensionReason || "").trim()) return "请填写延期原因";
  606. }
  607. return "";
  608. },
  609. /** 组装提交参数:附件数组 → 逗号拼接主键 */
  610. buildFormForSubmit() {
  611. return Object.assign({}, this.form, {
  612. approvalDocumentFileId: attachmentsOf(this.form).join(","),
  613. manager: this.form.managerUserName || "",
  614. // 页面已取消「线上签署 / 线下附件」切换,签署方式固定线下签署
  615. signingMethod: "OFFLINE",
  616. online: false,
  617. onlineDocumentTemplateId: null,
  618. });
  619. },
  620. async save(submit) {
  621. if (this.submitting) return;
  622. const error = this.validate();
  623. if (error) {
  624. uni.showToast({
  625. title: error,
  626. icon: "none"
  627. });
  628. return;
  629. }
  630. this.submitting = true;
  631. uni.showLoading({
  632. title: submit ? "提交中..." : "保存中..."
  633. });
  634. try {
  635. const form = this.buildFormForSubmit();
  636. if (!submit) {
  637. const id = await saveRegularizationDraft(form, false);
  638. if (id != null) this.form.regularizationId = id;
  639. uni.hideLoading();
  640. uni.showModal({
  641. title: "已保存草稿",
  642. content: "草稿已保存,可在「转正办理」列表中继续提交或编辑。",
  643. showCancel: false,
  644. success: () => this.leaveAfterSave(),
  645. });
  646. return;
  647. }
  648. const result = await submitRegularizationFlow({
  649. form,
  650. existingId: this.form.regularizationId,
  651. loding: false,
  652. });
  653. if (result.id != null) this.form.regularizationId = result.id;
  654. uni.hideLoading();
  655. uni.showModal({
  656. title: this.isEditMode ? "修改成功" : "提交成功",
  657. content: result.approvalStarted ?
  658. this.isEditMode ?
  659. "转正申请已修改并重新提交审批,可在「转正办理」列表中查看进度。" :
  660. "转正申请已提交审批,可在「转正办理」列表中查看进度。" :
  661. `转正申请已保存,但审批流程未发起:${result.approvalError}。请联系管理员处理。`,
  662. showCancel: false,
  663. success: () => this.leaveAfterSave(),
  664. });
  665. } catch (e) {
  666. uni.hideLoading();
  667. uni.showToast({
  668. title: (e && e.message) || "操作失败",
  669. icon: "none"
  670. });
  671. } finally {
  672. this.submitting = false;
  673. }
  674. },
  675. leaveAfterSave() {
  676. if (this.isEditMode) {
  677. this.backToApplicationList("/pages/hr/apply/list");
  678. return;
  679. }
  680. this.backToList();
  681. },
  682. onSaveDraft() {
  683. this.save(false);
  684. },
  685. submitApplication() {
  686. this.save(true);
  687. },
  688. },
  689. };
  690. </script>
  691. <style scoped lang="scss">
  692. @import "@/pages/hr/common-style.scss";
  693. .page-no-tab {
  694. padding-bottom: 170rpx;
  695. }
  696. .form-card {
  697. overflow: hidden;
  698. margin-top: 20rpx;
  699. }
  700. /* ============ 详情态(只读) ============ */
  701. .readonly-mode .arrow {
  702. display: none;
  703. }
  704. .detail-head {
  705. margin: 24rpx 24rpx 0;
  706. padding: 28rpx;
  707. }
  708. .detail-head-top {
  709. display: flex;
  710. align-items: center;
  711. justify-content: space-between;
  712. margin-bottom: 20rpx;
  713. }
  714. .detail-title {
  715. font-size: 32rpx;
  716. font-weight: 600;
  717. color: #1f2329;
  718. }
  719. .detail-row {
  720. display: flex;
  721. align-items: center;
  722. justify-content: space-between;
  723. padding: 12rpx 0;
  724. font-size: 26rpx;
  725. }
  726. .detail-label {
  727. color: #8b939f;
  728. }
  729. .detail-value {
  730. color: #1f2329;
  731. }
  732. .masked-card {
  733. margin: 20rpx 24rpx 0;
  734. padding: 26rpx 28rpx;
  735. }
  736. .masked-label {
  737. display: block;
  738. margin-bottom: 12rpx;
  739. color: #8b939f;
  740. font-size: 23rpx;
  741. }
  742. .masked-text {
  743. color: #4b5563;
  744. font-size: 26rpx;
  745. line-height: 1.6;
  746. }
  747. .status-tag.status-draft,
  748. .status-tag.status-cancelled {
  749. color: #6f7782;
  750. background: #eef1f3;
  751. }
  752. /* .section-head / .section-title / .section-tip 统一取自
  753. @/pages/hr/common-style.scss,本页不再单独覆盖,避免各页标题深浅不一。 */
  754. .field {
  755. position: relative;
  756. min-height: 108rpx;
  757. padding: 28rpx;
  758. display: flex;
  759. align-items: center;
  760. }
  761. .label {
  762. /* 「延期后试用结束日」8 个字在 200rpx 下会折行 → 加宽到 240rpx 并禁止换行 */
  763. width: 240rpx;
  764. flex-shrink: 0;
  765. white-space: nowrap;
  766. font-size: 28rpx;
  767. }
  768. .required::before {
  769. content: "*";
  770. color: #ee4d4d;
  771. margin-right: 6rpx;
  772. }
  773. .value {
  774. flex: 1;
  775. text-align: right;
  776. color: #1f2329;
  777. font-size: 27rpx;
  778. }
  779. .readonly-text {
  780. flex: 1;
  781. text-align: right;
  782. color: #8b939f;
  783. font-size: 27rpx;
  784. }
  785. .placeholder {
  786. flex: 1;
  787. text-align: right;
  788. color: #b2b7be;
  789. font-size: 27rpx;
  790. }
  791. .arrow {
  792. margin-left: 13rpx;
  793. color: #c4c8ce;
  794. font-size: 38rpx;
  795. }
  796. .input-flex {
  797. flex: 1;
  798. text-align: right;
  799. font-size: 27rpx;
  800. color: #1f2329;
  801. padding: 0 6rpx;
  802. }
  803. .number-input {
  804. flex: 1;
  805. height: 68rpx;
  806. text-align: right;
  807. font-size: 27rpx;
  808. color: #1f2329;
  809. }
  810. /* 签署方式固定线下签署(不提供线上模板切换),相关切换样式已移除 */
  811. .textarea-field {
  812. display: block;
  813. min-height: 240rpx;
  814. }
  815. .textarea-field .label {
  816. display: block;
  817. width: auto;
  818. margin-bottom: 20rpx;
  819. }
  820. .textarea-field textarea {
  821. width: 100%;
  822. height: 130rpx;
  823. padding: 18rpx;
  824. border-radius: 12rpx;
  825. background: #f6f7f8;
  826. font-size: 27rpx;
  827. }
  828. .counter {
  829. position: absolute;
  830. right: 45rpx;
  831. bottom: 38rpx;
  832. color: #b2b7be;
  833. font-size: 21rpx;
  834. }
  835. .field-file {
  836. flex: 1;
  837. display: flex;
  838. justify-content: flex-end;
  839. }
  840. .bottom-action {
  841. position: fixed;
  842. z-index: 10;
  843. left: 0;
  844. right: 0;
  845. bottom: 0;
  846. height: 120rpx;
  847. padding: 16rpx 24rpx;
  848. display: flex;
  849. gap: 18rpx;
  850. background: #fff;
  851. border-top: 1rpx solid #e8e9eb;
  852. }
  853. .draft-btn,
  854. .submit-btn {
  855. height: 84rpx;
  856. border-radius: 12rpx;
  857. display: flex;
  858. align-items: center;
  859. justify-content: center;
  860. font-size: 30rpx;
  861. }
  862. .draft-btn {
  863. width: 210rpx;
  864. border: 1rpx solid #d7dce2;
  865. color: #4b5563;
  866. }
  867. .submit-btn {
  868. flex: 1;
  869. background: #157a2c;
  870. color: #fff;
  871. }
  872. @media (min-width: 800px) {
  873. .bottom-action {
  874. max-width: 520px;
  875. left: 50%;
  876. transform: translateX(-50%);
  877. }
  878. }
  879. </style>