/** * 机构 → 部门 → 岗位序列 → 岗位名称 → 岗位性质(自动带出) → 岗位层级 级联选择(移动端版) * 与 PC 端 src/views/hrManagement/mixins/positionCascade.js 同源,接口一致: * GET /main/group/getGroupList 机构 + 部门(前端拆树) * GET /fm/tmaindepartmentposition/sequence/list 部门 → 岗位序列 * GET /fm/tmaindepartmentposition/position/list 部门 + 序列 → 岗位 * GET /fm/tmaindepartmentposition/positionType/list → 岗位性质(取首条) * GET /fm/tmaindepartmentposition/positionLevel/list → 岗位层级 * * 用法: * 1) 页面 mixins: [positionCascadeMixin],表单对象必须是 this.form(与 PC 的 cascadeForm 对齐); * 2) 模板里用统一选择器 (components/hr/HrPickerRow.vue)绑定本混入的 * onCascadeLegalChange / onCascadeDeptPick / onCascadeSequenceChange / * onCascadePositionChange / onCascadeLevelChange(@change 直接传这些方法即可); * 部门行用 mode="tree" + :tree="cascadeDeptTree"; * 3) 选人 / 详情回显后调用 await this.primeCascade() 预热各级下拉,保证名称能回显。 * * 字段映射与 PC 的 CASCADE_CONFIG 一致(游离在 mixin 内,避免每个页面重复声明): * groupId / company、deptId / department、positionSequenceId / positionSequence、 * positionId / position、positionType / positionTypeName、positionLevelId / positionLevel */ import { listOrganizations } from "@/api/hr/organization"; import { getDeptPositionSequences, getDeptPositions, getDeptPositionType, getDeptPositionLevels, } from "@/api/hr/position"; import { buildDeptTreeUnderLegal, LEGAL_TYPES } from "@/utils/hr/organization-tree"; import { getOrgOptionsWithFallback } from "@/utils/hr/current-org"; import { getDictByCode } from "@/api/common"; import { dictToPositionNatureOptions } from "@/api/hr/user"; /** 级联层级顺序:清空时按此后缀裁剪 */ const CASCADE_ORDER = ["dept", "sequence", "position", "type", "level"]; /** 表单字段映射(与后端 PO 字段名一致) */ export const CASCADE_FIELDS = { legalId: "groupId", legalName: "company", deptId: "deptId", deptName: "department", sequenceId: "positionSequenceId", sequenceName: "positionSequence", positionId: "positionId", positionName: "position", typeId: "positionType", typeName: "positionTypeName", levelId: "positionLevelId", levelName: "positionLevel", }; /** 把扁平机构/部门列表拍平成「路径名」,便于在 actionSheet 里区分同名部门 */ function flattenDepartments(nodes = [], parentPath = "") { const result = []; nodes.forEach((node) => { const path = parentPath ? `${parentPath} / ${node.name}` : node.name; // 法人节点只作为路径前缀,不作为可选的部门 if (!LEGAL_TYPES.has(String(node.type))) result.push({ id: node.id, name: node.name, path }); result.push(...flattenDepartments(node.children || [], path)); }); return result; } /** 级联接口返回可能是数组 / {list|records|rows|data},统一取数组 */ function cascadeList(payload) { if (Array.isArray(payload)) return payload.filter(Boolean); const list = payload && (payload.list || payload.records || payload.rows || payload.data); return Array.isArray(list) ? list.filter(Boolean) : []; } /** 选项里找不到 id 时,把当前表单值的名称塞回列表,保证 actionSheet / 回显可用 */ function ensureOption(list, id, name) { if (id == null || id === "") return list; if (list.some((item) => String(item.id) === String(id))) return list; return list.concat([{ id: String(id), name: String(name || id), path: String(name || id) }]); } export const positionCascadeMixin = { data() { return { /** 组织架构原始列表(含 parentId / type) */ orgList: [], /** 机构(顶层组织)下拉 */ legalOptions: [], /** 当前机构下的部门下拉(含路径名) */ deptOptions: [], /** 岗位序列下拉 */ sequenceOptions: [], sequenceLoading: false, /** 岗位下拉 */ cascadePositionOptions: [], positionLoading: false, /** 岗位层级下拉 */ levelOptions: [], levelLoading: false, /** 接口返回的岗位性质编码:查询岗位层级时需原样回传 */ cascadeRawType: "", /** 岗位性质字典(编码 → 名称),接口不给 positionTypeName 时兜底显示 */ positionNatureOptions: [], }; }, computed: { /** 级联字段所在的表单对象(离职申请 / 转正办理都是 this.form) */ cascadeForm() { return this.form || {}; }, /** * 部门树(树选择用):与个人档案页同口径 —— * 取所选机构节点下的 children 作为根,公司层不再入列。 */ cascadeDeptTree() { const f = CASCADE_FIELDS; const legalId = this.cascadeForm[f.legalId]; if (!legalId) return []; const nodes = buildDeptTreeUnderLegal(this.orgList, legalId); const legal = nodes.find((node) => String(node.id) === String(legalId)) || nodes[0]; return legal && Array.isArray(legal.children) ? legal.children : []; }, }, methods: { /** 机构 + 部门基础数据 */ async loadCascadeOrganizations() { try { // 组织树取全量:部门级联要按当前法人从中过滤出部门 const list = await listOrganizations({}, false); this.orgList = Array.isArray(list) ? list : []; } catch (error) { this.orgList = []; } // 所属机构候选项与 PC 一致(EmployeeArchiveForm.legalEntities): // 主源是登录缓存 loginChangeGroupVOList(当前账号所属公司),不再列全量机构树; // 缓存缺名称时回落组织树兜底,编辑老数据时补当前项,避免下拉只显示 ID。 const f = CASCADE_FIELDS; this.legalOptions = getOrgOptionsWithFallback( this.orgList, this.cascadeForm[f.legalId], this.cascadeForm[f.legalName] ); this.rebuildDeptOptions(); }, /** 按当前机构重建部门下拉 */ rebuildDeptOptions() { const f = CASCADE_FIELDS; const legalId = this.cascadeForm[f.legalId]; if (legalId == null || legalId === "") { this.deptOptions = []; return; } const nodes = buildDeptTreeUnderLegal(this.orgList, legalId); this.deptOptions = ensureOption(flattenDepartments(nodes), this.cascadeForm[f.deptId], this.cascadeForm[f.deptName]); }, /** 按当前下拉选项取名称 */ cascadeNameOf(listName, id) { const hit = (this[listName] || []).find((item) => String(item.id) === String(id)); return hit ? hit.name : ""; }, /** 按名称反查主键(回显兜底用) */ cascadeIdByName(listName, name) { const text = String(name || "").trim(); if (!text) return ""; const hit = (this[listName] || []).find((item) => String(item.name).trim() === text); return hit ? hit.id : ""; }, /** 清空指定层级之后的字段与下拉(level: dept/sequence/position) */ clearCascadeBelow(level) { const f = CASCADE_FIELDS; const form = this.cascadeForm; const rest = CASCADE_ORDER.slice(CASCADE_ORDER.indexOf(level) + 1); if (rest.indexOf("sequence") >= 0) { form[f.sequenceId] = ""; form[f.sequenceName] = ""; this.sequenceOptions = []; } if (rest.indexOf("position") >= 0) { form[f.positionId] = null; form[f.positionName] = ""; this.cascadePositionOptions = []; } if (rest.indexOf("type") >= 0) { form[f.typeId] = ""; form[f.typeName] = ""; this.cascadeRawType = ""; } if (rest.indexOf("level") >= 0) { form[f.levelId] = ""; form[f.levelName] = ""; this.levelOptions = []; } }, /** 新增表单默认带出所属机构:登录机构列表第一个 */ applyDefaultCascadeOrg() { const f = CASCADE_FIELDS; const form = this.cascadeForm; if (form[f.legalId] != null && form[f.legalId] !== "") return; const first = (this.legalOptions || [])[0]; if (!first) return; form[f.legalId] = first.id; form[f.legalName] = first.name || ""; this.rebuildDeptOptions(); }, /** * 新建态页面初始化:先把机构 / 部门候选加载出来并带出默认机构。 * 背景:loadCascadeOrganizations 原先只在 primeCascade 里被调用,而 primeCascade * 只在「选人 / 详情回显」之后触发 —— 从菜单直达发起页时机构下拉会是空的,用户以为「选不出来」。 * 与 PC 端 created(){ loadOrganizations(); applyDefaultCascadeOrg(); } 对齐。 */ async initCascadeOrg() { if (!this.orgList.length) await this.loadCascadeOrganizations(); this.applyDefaultCascadeOrg(); this.rebuildDeptOptions(); }, /* ================= 岗位性质字典(名称兜底显示) ================= * 接口只回编码、不回 positionTypeName 时,只读框会显示成 "1"。 * 与 PC 一致地接 /system/dict/getByCode/position_nature 反查文案。 * 注意:只用于「显示名」,不写回 form.positionType(提交值仍用接口原编码,见下方注释)。 */ async loadPositionNatureDict() { if ((this.positionNatureOptions || []).length) return this.positionNatureOptions; try { const dict = await getDictByCode("position_nature"); this.positionNatureOptions = dictToPositionNatureOptions(dict); } catch (error) { this.positionNatureOptions = []; } return this.positionNatureOptions; }, /** 岗位性质编码 → 字典文案;查不到返回空串 */ positionNatureLabelOf(code) { const key = code == null ? "" : String(code).trim(); if (!key) return ""; const hit = (this.positionNatureOptions || []).find( (item) => String(item.value).trim() === key ); return hit && hit.label ? String(hit.label).trim() : ""; }, /* ================= 选择行回调 ================= * 统一选择器(components/hr/HrPickerRow)的 @change 回传 (value, { label, value }), * 历史 actionSheet 回传的是内部选项对象 { id, name } —— 这里统一成内部形态, * 所以回调签名必须是 (value, picked),只接一个参数会把「值」当成选项对象、名称丢失。 */ /** * 把选择器回传项归一化为 { id, name }。 * 兼容三种入参:① 选项对象 { label, value }(HrPickerRow 的第二参) * ② 内部历史形态 { id, name } ③ 纯值(主键/编码)。 */ cascadePicked(picked) { if (picked == null) return { id: "", name: "" }; if (typeof picked !== "object") return { id: picked, name: "" }; return { id: picked.value != null ? picked.value : picked.id, name: picked.label != null ? picked.label : picked.name || "", }; }, /** * 所属机构 * 注意签名:HrPickerRow 的 @change 回传 (value, option), * 所以第一参是值、第二参才是带名称的选项对象;只接一个参数会拿不到名称。 */ onCascadeLegalChange(value, picked) { const legal = this.cascadePicked(picked || value); const f = CASCADE_FIELDS; const form = this.cascadeForm; form[f.legalId] = legal.id; form[f.legalName] = legal.name || ""; form[f.deptId] = ""; form[f.deptName] = ""; this.clearCascadeBelow("dept"); this.rebuildDeptOptions(); }, /** 所属部门(树选择器回传节点 id,这里补出节点名与路径) */ onCascadeDeptPick(deptId, picked) { void picked; const hit = flattenDepartments(this.cascadeDeptTree).find( (item) => String(item.id) === String(deptId) ); return this.onCascadeDeptChange(hit || { id: deptId, name: "" }); }, async onCascadeDeptChange(value, picked) { const dept = this.cascadePicked(picked || value); const f = CASCADE_FIELDS; const form = this.cascadeForm; form[f.deptId] = dept.id; form[f.deptName] = dept.name || ""; this.clearCascadeBelow("dept"); await this.loadCascadeSequences(dept.id); }, /** 部门 → 岗位序列下拉 */ async loadCascadeSequences(deptId) { const f = CASCADE_FIELDS; const current = deptId != null && deptId !== "" ? deptId : this.cascadeForm[f.deptId]; if (current == null || current === "") { this.sequenceOptions = []; return []; } this.sequenceLoading = true; try { const data = await getDeptPositionSequences(current, false); this.sequenceOptions = cascadeList(data) .filter((item) => item && item.positionSequenceId != null) .map((item) => ({ id: String(item.positionSequenceId), name: item.positionSequenceName || item.positionSequence || "", })); } catch (error) { this.sequenceOptions = []; } finally { this.sequenceLoading = false; } this.sequenceOptions = ensureOption( this.sequenceOptions, this.cascadeForm[f.sequenceId], this.cascadeForm[f.sequenceName] ); return this.sequenceOptions; }, /** 岗位序列 */ async onCascadeSequenceChange(value, picked) { const sequence = this.cascadePicked(picked || value); const f = CASCADE_FIELDS; const form = this.cascadeForm; form[f.sequenceId] = sequence.id; form[f.sequenceName] = sequence.name || ""; this.clearCascadeBelow("sequence"); await this.loadCascadePositions(); }, /** 拉取「部门 + 序列」下的岗位(纯查询) */ async fetchCascadePositions(deptId, positionSequenceId) { if (!deptId || !positionSequenceId) return []; try { const data = await getDeptPositions({ deptId, positionSequenceId }, false); return cascadeList(data) .filter((item) => item && item.positionId != null) .map((item) => ({ id: String(item.positionId), name: item.positionName || "" })); } catch (error) { return []; } }, /** 部门 + 序列 → 岗位下拉 */ async loadCascadePositions() { const f = CASCADE_FIELDS; const form = this.cascadeForm; const deptId = form[f.deptId]; const positionSequenceId = form[f.sequenceId]; if (!deptId || !positionSequenceId) { this.cascadePositionOptions = []; return []; } this.positionLoading = true; try { this.cascadePositionOptions = await this.fetchCascadePositions(deptId, positionSequenceId); } finally { this.positionLoading = false; } this.cascadePositionOptions = ensureOption( this.cascadePositionOptions, this.cascadeForm[f.positionId], this.cascadeForm[f.positionName] ); return this.cascadePositionOptions; }, /** 岗位名称 */ async onCascadePositionChange(value, picked) { const position = this.cascadePicked(picked || value); const f = CASCADE_FIELDS; const form = this.cascadeForm; form[f.positionId] = position.id; form[f.positionName] = position.name || ""; this.clearCascadeBelow("position"); await this.loadCascadeTypeAndLevels(); }, /** 部门 + 序列 + 岗位 → 岗位性质(取首条)+ 岗位层级下拉 */ async loadCascadeTypeAndLevels() { const f = CASCADE_FIELDS; const form = this.cascadeForm; const deptId = form[f.deptId]; const positionSequenceId = form[f.sequenceId]; const positionId = form[f.positionId]; if (!deptId || !positionSequenceId || !positionId) return; try { const data = await getDeptPositionType({ deptId, positionId, positionSequenceId }, false); const first = cascadeList(data)[0] || null; const rawType = String((first && first.positionType) || ""); // 名称:接口 positionTypeName 优先,取不到时用岗位性质字典按编码反查(对齐 PC), // 不能拿编码当名称兜底,否则只读框会显示成 "1" let typeName = String((first && first.positionTypeName) || "").trim(); if (!typeName && rawType) { await this.loadPositionNatureDict(); typeName = this.positionNatureLabelOf(rawType); } this.cascadeRawType = rawType; if (typeName) form[f.typeName] = typeName; // 编码字段只存编码(对齐 PC:positionType = first.positionType)。 // 之前误把名称写进 positionType,提交时报「岗位快照与员工当前岗位关系不一致: // 员工当前岗位关系为【1】,转正提交为【全职】」 if (rawType) form[f.typeId] = rawType; } catch (error) { // 级联接口异常:保留已带出的值 } this.levelLoading = true; try { const data = await getDeptPositionLevels( { deptId, positionId, positionSequenceId, positionType: this.cascadeRawType }, false ); this.levelOptions = cascadeList(data) .filter((item) => item && item.positionLevelId != null) .map((item) => ({ id: String(item.positionLevelId), name: item.positionLevel || "" })); } catch (error) { this.levelOptions = []; } finally { this.levelLoading = false; } this.levelOptions = ensureOption(this.levelOptions, form[f.levelId], form[f.levelName]); // 层级唯一时自动带出,减少一次点击 if (!form[f.levelId] && this.levelOptions.length === 1) { form[f.levelId] = this.levelOptions[0].id; form[f.levelName] = this.levelOptions[0].name || ""; } }, /** 岗位层级 */ onCascadeLevelChange(value, picked) { const level = this.cascadePicked(picked || value); const f = CASCADE_FIELDS; const form = this.cascadeForm; form[f.levelId] = level.id; form[f.levelName] = level.name || ""; }, /** * 回显兜底:员工档案只带出岗位主键(或名称)而序列为空时, * 遍历当前部门下所有岗位序列定位,补齐序列与岗位下拉,避免级联链断裂。 */ async locateCascadePosition() { const f = CASCADE_FIELDS; const form = this.cascadeForm; const deptId = form[f.deptId]; const targetId = form[f.positionId]; const targetName = String(form[f.positionName] || "").trim(); if (!deptId || (!targetId && !targetName)) return false; if (!this.sequenceOptions.length) await this.loadCascadeSequences(deptId); const sequences = this.sequenceOptions.slice(); if (!sequences.length) return false; const isIdText = /^\d{15,}$/.test(targetName); for (let i = 0; i < sequences.length; i += 1) { const sequence = sequences[i]; const options = await this.fetchCascadePositions(deptId, sequence.id); const hit = options.find((item) => targetId ? String(item.id) === String(targetId) : !isIdText && String(item.name).trim() === targetName ); if (!hit) continue; form[f.sequenceId] = sequence.id; form[f.sequenceName] = sequence.name || ""; form[f.positionId] = hit.id; form[f.positionName] = hit.name || ""; this.sequenceOptions = sequences; this.cascadePositionOptions = options; return true; } return false; }, /** 选人 / 回显后逐级预热下拉(部门 → 序列 → 岗位 → 层级) */ async primeCascade() { const f = CASCADE_FIELDS; const form = this.cascadeForm; if (!this.orgList.length) await this.loadCascadeOrganizations(); // 机构:只有名称时按名称反查 if (!form[f.legalId]) { form[f.legalId] = this.cascadeIdByName("legalOptions", form[f.legalName]); } this.rebuildDeptOptions(); if (!form[f.deptId]) { form[f.deptId] = this.cascadeIdByName("deptOptions", form[f.deptName]); } if (!form[f.deptId]) return; await this.loadCascadeSequences(form[f.deptId]); if (!form[f.sequenceId]) { form[f.sequenceId] = this.cascadeIdByName("sequenceOptions", form[f.sequenceName]); } if (!form[f.sequenceId] && (form[f.positionId] || form[f.positionName])) { await this.locateCascadePosition(); } if (!form[f.sequenceId]) { this.cascadePositionOptions = ensureOption( this.cascadePositionOptions, form[f.positionId], form[f.positionName] ); return; } await this.loadCascadePositions(); if ( form[f.positionId] && !this.cascadePositionOptions.some((item) => String(item.id) === String(form[f.positionId])) ) { await this.locateCascadePosition(); } if (!form[f.positionId]) { form[f.positionId] = this.cascadeIdByName("cascadePositionOptions", form[f.positionName]); } if (!form[f.positionId] && form[f.positionName]) { await this.locateCascadePosition(); } if (!form[f.positionId]) return; await this.loadCascadeTypeAndLevels(); if (!form[f.levelId]) { form[f.levelId] = this.cascadeIdByName("levelOptions", form[f.levelName]); } }, }, }; export default positionCascadeMixin;