| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216 |
- <template>
- <div class="ele-body" v-loading="loading">
- <el-card shadow="never">
- <el-form label-width="80px" class="ele-form-search" @keyup.enter.native="applyFilters" @submit.native.prevent>
- <el-row :gutter="15">
- <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
- <el-form-item label="岗位名称">
- <el-input v-model.trim="draftFilters.keyword" clearable placeholder="请输入" />
- </el-form-item>
- </el-col>
- <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
- <el-form-item label="所属机构">
- <el-select v-model="draftFilters.organizationId" clearable filterable placeholder="请选择">
- <el-option v-for="item in legalEntities" :key="item.id" :label="item.name" :value="item.id" />
- </el-select>
- </el-form-item>
- </el-col>
- <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
- <el-form-item label="状态">
- <el-select v-model="draftFilters.status" clearable placeholder="请选择">
- <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
- </el-select>
- </el-form-item>
- </el-col>
- <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
- <div class="ele-form-actions">
- <el-button type="primary" icon="el-icon-search" class="ele-btn-icon" @click="applyFilters">
- 查询
- </el-button>
- <el-button type="primary" icon="el-icon-refresh" class="ele-btn-icon" @click="resetFilters">
- 重置
- </el-button>
- </div>
- </el-col>
- </el-row>
- </el-form>
- <ele-pro-table ref="table" row-key="id" :columns="columns" :datasource="fetchPage" :need-page="true"
- :page-size="20" :cache-key="$tableCacheKey(cacheKeyUrl)" class="application-table">
- <template v-slot:toolbar>
- <el-button v-if="$hasPermission('hr:hrPositionApply:save')" type="primary" icon="el-icon-plus"
- class="ele-btn-icon" @click="openCreate">
- 新增
- </el-button>
- </template>
- <template v-slot:documentCode="{ row }">
- <!-- 点流程编号开详情,替代原操作列「详情」入口 -->
- <el-link v-if="row.documentCode" type="primary" :underline="false" @click.stop="openView(row)">{{
- row.documentCode }}</el-link>
- <span v-else>-</span>
- </template>
- <template v-slot:code="{ row }">
- <el-link v-if="row.code" type="primary" :underline="false" @click.stop="openView(row)">{{
- row.code }}</el-link>
- <span v-else>-</span>
- </template>
- <template v-slot:name="{ row }">
- {{ row.positionName || "-" }}
- </template>
- <template v-slot:duties="{ row }">
- {{ (row.duties || [])[0] || "-" }}
- </template>
- <template v-slot:status="{ row }">
- <el-tag v-if="row.status && row.status !== '-'" size="mini" :type="statusTagType(row.status)">
- {{ statusName(row.status) }}
- </el-tag>
- <span v-else>-</span>
- </template>
- <template v-slot:action="{ row }">
- <el-link v-if="canShowApprovalRecord(row)" type="primary" :underline="false" icon="el-icon-s-order"
- @click.stop="openApprovalRecord(row)">
- 审批记录
- </el-link>
- <el-link v-if="canLaunch(row) && $hasPermission('hr:hrPositionApply:submit')" type="primary"
- :underline="false" icon="el-icon-position" @click.stop="launchApplication(row)">
- 提交
- </el-link>
- <el-link v-if="canEdit(row) && $hasPermission('hr:hrPositionApply:update')" type="primary"
- :underline="false" icon="el-icon-edit" @click.stop="openEdit(row)">
- 编辑
- </el-link>
- <el-link v-if="canDelete(row) && $hasPermission('hr:hrPositionApply:delete')" type="danger"
- :underline="false" icon="el-icon-delete" @click.stop="removeApplication(row)">
- 删除
- </el-link>
- </template>
- <template slot="empty">
- <el-empty description="暂无数据" />
- </template>
- </ele-pro-table>
- </el-card>
- <el-drawer :visible.sync="drawerVisible" direction="rtl" :size="drawerSize" custom-class="application-drawer"
- append-to-body :before-close="closeDrawer">
- <template slot="title">
- <div class="drawer-title">
- <span><i :class="readOnly ? 'el-icon-view' : 'el-icon-edit-outline'"></i></span>
- <div>
- <h2>{{ drawerTitle }}</h2>
- <p>{{ form.code || "保存后自动生成岗位编码" }}</p>
- </div>
- <em v-if="mode !== 'create'" :class="statusTone(form.status)"><i></i>{{ statusName(form.status) }}</em>
- </div>
- </template>
- <div class="drawer-body">
- <template v-if="readOnly">
- <section class="detail-overview">
- <span :class="'type-' + form.type">{{ typeName(form.type) }}</span>
- <div>
- <h3>{{ form.positionName }}</h3>
- <p>
- {{ form.code }} · {{ orgName(form.organizationId) }} ·
- {{ levelText(form.positionLevel) || "未设置层级" }} ·
- 申请招聘 {{ form.headcount }} 人
- </p>
- </div>
- </section>
- <section class="detail-section">
- <div class="detail-heading">
- <span><i class="el-icon-document"></i></span>
- <div>
- <h3>申请信息</h3>
- <p>岗位申请单据的关键信息</p>
- </div>
- </div>
- <div class="detail-grid">
- <div>
- <span>岗位编码</span><strong>{{ form.code }}</strong>
- </div>
- <div>
- <span>所属机构</span><strong>{{ orgName(form.organizationId) }}</strong>
- </div>
- <div>
- <span>岗位层级</span><strong>{{ levelText(form.positionLevel) || "-" }}</strong>
- </div>
- <div>
- <span>岗位性质</span><strong>{{ typeName(form.type) }}</strong>
- </div>
- <div>
- <span>岗位序列</span><strong>{{ form.positionSequence || "-" }}</strong>
- </div>
- <div>
- <span>岗位薪资区间</span><strong>{{ salaryText }}</strong>
- </div>
- <div>
- <span>申请招聘人数</span><strong>{{ form.headcount }} 人</strong>
- </div>
- <div>
- <span>申请人</span><strong>{{ form.applicant || "-" }}</strong>
- </div>
- <div>
- <span>提交时间</span><strong>{{ form.updatedAt || "-" }}</strong>
- </div>
- </div>
- </section>
- <section class="detail-section">
- <div class="detail-heading">
- <span><i class="el-icon-s-cooperation"></i></span>
- <div>
- <h3>岗位职责</h3>
- </div>
- </div>
- <div class="detail-block">
- <ol class="duty-list">
- <li v-for="item in form.duties" :key="item">{{ item }}</li>
- </ol>
- </div>
- </section>
- <section class="detail-section">
- <div class="detail-heading">
- <span><i class="el-icon-user"></i></span>
- <div>
- <h3>技能要求</h3>
- <p>岗位任职能力、学历经验与技能证书要求</p>
- </div>
- </div>
- <div class="detail-grid">
- <div>
- <span>工作年限要求</span><strong>{{ form.expYears || 0 }} 年</strong>
- </div>
- <div>
- <span>学历要求</span><strong>{{ form.education || "-" }}</strong>
- </div>
- <div>
- <span>行业经验</span><strong>{{ form.industryExp || "-" }}</strong>
- </div>
- <div>
- <span>专业能力</span><strong>{{ form.professionalAbility || "-" }}</strong>
- </div>
- <div>
- <span>其他要求</span><strong>{{ form.otherRequirement || "-" }}</strong>
- </div>
- </div>
- <div class="detail-block">
- <span>技能要求</span>
- <div class="detail-tags" v-if="form.skills && form.skills.length">
- <em v-for="skill in form.skills" :key="skill.name">{{ skill.name }} · {{ skill.level
- }}{{ skill.required ? "(必备)" : "" }}</em>
- </div>
- <p v-else class="detail-muted">-</p>
- </div>
- <div class="detail-block">
- <span>技能证书</span>
- <div class="detail-tags" v-if="form.certificates && form.certificates.length">
- <em v-for="cert in form.certificates" :key="cert.name">{{ cert.name }} · {{ cert.category
- }}</em>
- </div>
- <p v-else class="detail-muted">-</p>
- </div>
- </section>
- <section class="detail-audit">
- <span><i class="el-icon-time"></i>
- {{ form.updatedAt || "当前为模拟数据环境" }}</span>
- </section>
- </template>
- <el-form v-else ref="applicationForm" :model="form" :rules="rules" label-position="top"
- class="application-form">
- <div class="form-guide">
- <i class="el-icon-info"></i><span><strong>{{
- mode === "create" ? "发起岗位申请" : "调整岗位申请配置"
- }}</strong><small v-if="mode === 'create'">带 <b>*</b> 的项目为必填项,保存后进入部门负责人 / HR 审核。</small><small v-else>带
- <b>*</b> 的项目为必填项,保存草稿后可继续修改,提交后进入部门负责人 / HR 审核。</small></span>
- </div>
- <section class="form-section">
- <div class="section-title">
- <span><i class="el-icon-suitcase"></i></span>
- <div>
- <h3>岗位基本信息</h3>
- <p>填写申请岗位的基础信息与定薪区间</p>
- </div>
- </div>
- <div class="form-grid">
- <el-form-item label="岗位名称" prop="positionName"><el-input v-model.trim="form.positionName" maxlength="40"
- show-word-limit placeholder="如:数控车床操作工" /></el-form-item>
- <el-form-item label="所属机构" prop="organizationId"><el-select v-model="form.organizationId" filterable
- placeholder="请选择所属机构"><el-option v-for="item in legalEntities" :key="item.id" :label="item.name"
- :value="item.id" /></el-select></el-form-item>
- <el-form-item label="岗位性质" prop="type"><el-select v-model="form.type" clearable
- placeholder="请选择岗位性质"><el-option v-for="item in typeOptions" :key="String(item.value)"
- :label="item.label" :value="item.value" /></el-select></el-form-item>
- <el-form-item label="岗位序列" prop="positionSequence"><el-select v-model="form.positionSequence" filterable
- placeholder="请选择岗位序列" @change="handleSequenceChange"><el-option v-for="item in sequenceOptions"
- :key="item" :label="item" :value="item" /></el-select></el-form-item>
- <el-form-item label="岗位层级" prop="positionLevel"><el-select v-model="form.positionLevel"
- placeholder="请选择岗位层级(随岗位序列联动)"><el-option v-for="item in levelOptions" :key="item.value"
- :label="item.label" :value="item.value" /></el-select></el-form-item>
- <el-form-item label="申请招聘人数" prop="headcount"><el-input-number v-model="form.headcount" :min="1"
- :max="999" controls-position="right" /></el-form-item>
- <el-form-item label="岗位薪资区间" prop="salaryMax">
- <div class="salary-range-field">
- <div class="salary-range-item">
- <span class="salary-range-label">下限</span>
- <el-input-number v-model="form.salaryMin" :min="0" :step="500" controls-position="right"
- placeholder="下限" @change="syncSalaryMid" />
- </div>
- <div class="salary-range-item">
- <span class="salary-range-label">中位值</span>
- <el-input-number v-model="form.salaryMid" :min="0" :step="500" controls-position="right"
- placeholder="中位值" />
- </div>
- <div class="salary-range-item">
- <span class="salary-range-label">上限</span>
- <el-input-number v-model="form.salaryMax" :min="0" :step="500" controls-position="right"
- placeholder="上限" @change="syncSalaryMid" />
- </div>
- </div>
- <p class="field-help">
- <i class="el-icon-info"></i>
- 单位:元,修改上下限自动计算中位值。
- </p>
- </el-form-item>
- </div>
- <el-form-item label="岗位职责" prop="dutiesText"><el-input v-model.trim="form.dutiesText" type="textarea"
- :rows="5" maxlength="300" show-word-limit placeholder="1. 负责…… 2. 落实…… 3. 协同……" />
- <p class="field-help">
- <i class="el-icon-info"></i>
- 建议按职责重要性由高到低逐行填写。
- </p>
- </el-form-item>
- </section>
- <section class="form-section">
- <div class="section-title">
- <span><i class="el-icon-user"></i></span>
- <div>
- <h3>技能要求</h3>
- <p>定义岗位任职能力、学历经验与技能证书要求</p>
- </div>
- </div>
- <div class="form-grid">
- <el-form-item label="工作年限要求"><el-input-number v-model="form.expYears" :min="0" :max="40"
- controls-position="right" placeholder="单位:年" /></el-form-item>
- <el-form-item label="学历要求"><el-select v-model="form.education" placeholder="请选择学历要求"><el-option
- v-for="item in educationOptions" :key="item" :label="item"
- :value="item" /></el-select></el-form-item>
- <el-form-item label="行业经验"><el-input v-model.trim="form.industryExp"
- placeholder="如:3 年以上汽车零部件制造行业" /></el-form-item>
- <el-form-item label="专业能力"><el-input v-model.trim="form.professionalAbility"
- placeholder="如:熟悉 IATF16949、APQP 五大工具" /></el-form-item>
- <el-form-item label="其他要求"><el-input v-model.trim="form.otherRequirement"
- placeholder="补充能力、素质或工作制要求" /></el-form-item>
- </div>
- <div class="form-subtitle">技能</div>
- <div v-for="(skill, index) in form.skills" :key="index" class="dynamic-row">
- <el-input v-model.trim="skill.name" placeholder="技能名称" />
- <el-select v-model="skill.level" placeholder="技能等级">
- <el-option v-for="item in skillLevelOptions" :key="item" :label="item" :value="item" />
- </el-select>
- <el-switch v-model="skill.required" active-text="必备" />
- <el-button icon="el-icon-delete" @click="removeSkill(index)" />
- </div>
- <el-button class="add-line" icon="el-icon-plus" @click="addSkill">新增技能</el-button>
- <div class="form-subtitle">技能证书</div>
- <div v-for="(cert, index) in form.certificates" :key="index" class="dynamic-row cert-row">
- <el-input v-model.trim="cert.name" placeholder="证书名称" />
- <el-select v-model="cert.category" placeholder="证书分类">
- <el-option v-for="item in idTypeOptions" :key="item.key || item.value" :label="item.label"
- :value="item.value" />
- </el-select>
- <el-button icon="el-icon-delete" @click="removeCert(index)" />
- </div>
- <el-button class="add-line" icon="el-icon-plus" @click="addCert">新增证书</el-button>
- </section>
- </el-form>
- </div>
- <div class="drawer-footer">
- <span><i class="el-icon-lock"></i>
- 岗位申请将用于招聘需求岗位基础数据<em class="drawer-hint">新建或已驳回再次保存后将弹出审批提交流程</em></span>
- <div v-if="readOnly">
- <el-button @click="drawerVisible = false">关闭</el-button><el-button v-if="canEdit(form)" type="primary"
- icon="el-icon-edit" @click="mode = 'edit'">编辑</el-button>
- </div>
- <div v-else>
- <el-button :disabled="saving" @click="closeDrawer()">取消</el-button>
- <el-button v-if="mode === 'create'" type="primary" icon="el-icon-check" :loading="saving"
- @click="saveApplication()">保存</el-button>
- <el-button v-else type="primary" icon="el-icon-check" :loading="saving"
- @click="saveApplication(false)">保存</el-button>
- </div>
- </div>
- </el-drawer>
- <processSubmitDialog :processSubmitDialogFlag.sync="processSubmitDialogFlag" v-if="processSubmitDialogFlag"
- ref="processSubmitDialogRef" @reload="onProcessSubmitReload" />
- <bpm-process-detail-dialog ref="approvalRecordDialog" />
- </div>
- </template>
- <script>
- import {
- adaptPositionApplication,
- adaptPositionApplicationDetail,
- createPositionApplication,
- deletePositionApplication,
- getPositionApplicationById,
- getPositionApplicationPage,
- getPositionSequenceList,
- serializePositionApplication,
- submitPositionApplication,
- updatePositionApplication,
- } from "@/api/hr";
- /* 机构选项取登录缓存;组织列表接口仅用于部门与机构名称展示 */
- import { listOrganizations } from "@/api/organization";
- import { getOrgOptions, getCurrentOrgId } from "@/utils/current-org";
- import { getDict, getDictByCode } from "@/api/sys";
- import processSubmitDialog from "@/BIZComponents/processSubmitDialog/processSubmitDialog.vue";
- import BpmProcessDetailDialog from "@/views/bpm/processInstance/detailDialog.vue";
- import { getCurrentUser } from "@/utils/token-util";
- /** 岗位申请审批流程 businessKey,需与 BPM 流程定义 code 一致 */
- const POSITION_APPLY_BUSINESS_KEY = "hr_position_application";
- /**
- * 列表一次拉取的最大条数。
- *
- * 顶部指标卡(总数 / 待审核 / 已通过 / 招聘人数合计)必须基于**全量**统计,
- * 而表格是分页展示。原来两者各调一次 `POST /hr/position/apply/page`
- * (指标卡 size=1000、表格 size=20),首屏与每次增删改后同一接口都要请求两遍。
- * 现在改为:本页只发**一次**请求拉全量,表格在本地切片分页,指标卡复用同一份数据。
- */
- const APPLY_PAGE_MAX_SIZE = 1000;
- const STATUS_LABELS = {
- DRAFT: "未提交",
- PENDING: "待审核",
- APPROVED: "已通过",
- REJECTED: "已驳回",
- };
- const statusOptions = Object.entries(STATUS_LABELS).map(([value, label]) => ({
- value,
- label,
- }));
- const OPERATION_LABELS = {
- CREATE: "创建",
- UPDATE: "编辑",
- DELETE: "删除",
- SUBMIT: "提交审批",
- APPROVE: "审批通过",
- REJECT: "驳回",
- EFFECTIVE: "生效",
- };
- const educationOptions = [
- "不限",
- "初中及以上",
- "高中/中专及以上",
- "大专及以上",
- "本科及以上",
- "硕士及以上",
- ];
- const skillLevelOptions = ["初级", "中级", "高级", "技师", "高级技师"];
- const emptyFilters = () => ({
- keyword: "",
- /* 所属机构默认登录机构列表第一个(用户所属多个机构时默认第一个) */
- organizationId: getCurrentOrgId() || "",
- status: "",
- });
- const emptyForm = () => ({
- id: null,
- code: "",
- positionName: "",
- organizationId: null,
- deptName: "",
- positionLevel: null,
- positionSequence: "",
- type: "",
- headcount: 1,
- salaryMin: 0,
- salaryMid: 0,
- salaryMax: 0,
- expYears: 0,
- education: "",
- industryExp: "",
- professionalAbility: "",
- otherRequirement: "",
- skills: [],
- certificates: [],
- applicant: "",
- dutiesText: "",
- duties: [],
- status: "DRAFT",
- reviewer: "",
- reviewTime: "",
- reviewOpinion: "",
- operationLogs: [],
- updatedAt: "",
- });
- export default {
- name: "PositionApplication",
- components: {
- processSubmitDialog,
- BpmProcessDetailDialog,
- },
- data() {
- return {
- loading: false,
- saving: false,
- processSubmitDialogFlag: false,
- currentLaunchRow: null,
- rows: [],
- totalCount: 0,
- overview: [],
- /* 全量缓存对应的筛选条件 key:相同则复用,避免表格与指标卡重复请求同一个接口 */
- overviewKey: "",
- legalEntities: [],
- departments: [],
- orgMap: {},
- draftFilters: emptyFilters(),
- filters: emptyFilters(),
- levelOptions: [],
- typeOptions: [],
- sequenceOptions: [],
- // 岗位序列 → 该序列下的层级下拉(用于联动加载)
- sequenceLevelMap: {},
- educationOptions,
- skillLevelOptions,
- // 技能证书类别:与岗位设定资质证书一致,走 /system/dict/id_type
- idTypeOptions: [],
- statusOptions,
- cacheKeyUrl: "hr-pc-position-application-v4",
- drawerVisible: false,
- mode: "view",
- form: emptyForm(),
- rules: {
- positionName: [
- { required: true, message: "请输入岗位名称", trigger: "blur" },
- ],
- organizationId: [
- { required: true, message: "请选择所属机构", trigger: "change" },
- ],
- type: [{ required: true, message: "请选择岗位性质", trigger: "change" }],
- // 岗位层级选项由岗位序列联动产生,序列不选则层级无可选项,故两者一并必填
- positionSequence: [
- { required: true, message: "请选择岗位序列", trigger: "change" },
- ],
- positionLevel: [
- { required: true, message: "请选择岗位层级", trigger: "change" },
- ],
- headcount: [
- { required: true, message: "请输入申请招聘人数", trigger: "change" },
- ],
- salaryMax: [{ validator: this.salaryRangeValidator, trigger: "change" }],
- dutiesText: [
- { required: true, message: "请输入岗位职责", trigger: "blur" },
- ],
- },
- };
- },
- computed: {
- styleResponsive() {
- return this.$store.state.theme?.styleResponsive !== false;
- },
- columns() {
- return [
- { type: "index", label: "序号", width: 60, align: "center" },
- {
- prop: "documentCode",
- columnKey: "documentCode",
- label: "流程编号",
- minWidth: 170,
- slot: "documentCode",
- formatter: (_r, _c, value) => value || "-",
- },
- {
- prop: "code",
- columnKey: "code",
- label: "岗位编码",
- width: 150,
- slot: "code",
- align: "center",
- },
- {
- columnKey: "name",
- label: "岗位名称",
- minWidth: 190,
- slot: "name",
- },
- {
- prop: "positionLevel",
- label: "岗位层级",
- width: 100,
- align: "center",
- formatter: (row) =>
- row.positionLevel ? this.levelText(row.positionLevel) : "-",
- },
- {
- prop: "type",
- label: "岗位性质",
- width: 100,
- align: "center",
- formatter: (row) => this.typeName(row.type),
- },
- {
- prop: "headcount",
- label: "申请招聘人数",
- width: 110,
- align: "center",
- formatter: (row) => `${row.headcount} 人`,
- },
- { prop: "applicant", label: "申请人", width: 100, align: "center" },
- // {
- // columnKey: "duties",
- // label: "岗位职责",
- // minWidth: 230,
- // slot: "duties",
- // },
- {
- columnKey: "status",
- label: "状态",
- width: 100,
- align: "center",
- slot: "status",
- },
- {
- prop: "createUserName",
- label: "创建人",
- width: 100,
- align: "center",
- },
- {
- prop: "createTime",
- label: "创建时间",
- width: 100,
- align: "center",
- },
- {
- columnKey: "action",
- label: "操作",
- minWidth: 150,
- align: 'left',
- fixed: "right",
- resizable: false,
- slot: "action",
- },
- ];
- },
- metrics() {
- const total = this.overview.length;
- const pending = this.overview.filter(
- (item) => item.status === "PENDING",
- ).length;
- const approved = this.overview.filter(
- (item) => item.status === "APPROVED",
- ).length;
- const headcount = this.overview.reduce(
- (sum, item) => sum + Number(item.headcount || 0),
- 0,
- );
- return [
- {
- label: "申请总数",
- value: total,
- unit: "单",
- note: "岗位申请单据合计",
- icon: "el-icon-document-copy",
- tone: "blue",
- },
- {
- label: "待审核",
- value: pending,
- unit: "单",
- note: "需部门负责人 / HR 处理",
- icon: "el-icon-time",
- tone: "orange",
- },
- {
- label: "已审核",
- value: approved,
- unit: "单",
- note: "可进入招聘需求岗位库",
- icon: "el-icon-circle-check",
- tone: "green",
- },
- {
- label: "招聘人数",
- value: headcount,
- unit: "人",
- note: "申请编制招聘人数合计",
- icon: "el-icon-user",
- tone: "cyan",
- },
- ];
- },
- salaryText() {
- const min = Number(this.form.salaryMin || 0);
- const mid = Number(this.form.salaryMid || 0);
- const max = Number(this.form.salaryMax || 0);
- if (!min && !max) return "-";
- const range = `¥${min.toLocaleString("zh-CN")} - ¥${max.toLocaleString(
- "zh-CN",
- )}`;
- return mid ? `${range}(中位 ¥${mid.toLocaleString("zh-CN")})` : range;
- },
- hasFilters() {
- return Boolean(
- this.filters.keyword ||
- this.filters.organizationId ||
- this.filters.status,
- );
- },
- readOnly() {
- return this.mode === "view";
- },
- drawerTitle() {
- return this.mode === "create"
- ? "新增岗位申请"
- : this.mode === "edit"
- ? "编辑岗位申请"
- : "岗位申请详情";
- },
- drawerSize() {
- return window.innerWidth < 900 ? "96%" : "760px";
- },
- },
- created() {
- this.loadOrganizations();
- this.loadSequences();
- this.loadTypeOptions();
- this.loadIdTypeOptions();
- /* 列表与指标卡的第 1 次数据请求由表格 datasource(fetchPage)发起,这里不再单独拉概览 */
- },
- methods: {
- // 表格数据源:本页只发一次请求拉全量,在本地切片分页(指标卡复用同一份数据)
- async fetchPage({ page, limit }) {
- const all = await this.loadAllApplications();
- const start = (page - 1) * limit;
- const list = all.slice(start, start + limit);
- this.rows = list;
- this.totalCount = all.length;
- return { list, count: all.length };
- },
- /**
- * 拉取当前筛选条件下的**全量**岗位申请,供表格本地分页与顶部指标卡共用。
- *
- * 为什么合并:原来表格(size=20)与指标卡(size=1000)各调一次
- * `POST /hr/position/apply/page`,首屏以及每次增删改后都会请求同一个接口两遍。
- * 现在按筛选条件缓存一次结果,两者共用,请求数减半。
- *
- * 失败必须明确报错(项目红线:不做静默兜底)——弹错误提示并把异常抛给
- * ele-pro-table,让表格自身也进入错误态,而不是悄悄显示一份空列表。
- */
- async loadAllApplications() {
- const key = JSON.stringify([
- this.filters.keyword || "",
- this.filters.organizationId || "",
- this.filters.status || "",
- ]);
- if (this.overviewKey === key) return this.overview;
- try {
- const data = await getPositionApplicationPage({
- pageNum: 1,
- size: APPLY_PAGE_MAX_SIZE,
- keyword: this.filters.keyword || undefined,
- organizationId: this.filters.organizationId || undefined,
- status: this.filters.status || undefined,
- });
- const list = (data?.list || []).map(adaptPositionApplication);
- const total = Number(data?.count ?? list.length);
- this.overview = list;
- this.overviewKey = key;
- this.fallbackOrganizations();
- /* 命中取数上限:明确告知「只加载了前 N 条」,避免指标卡与分页看起来像全量 */
- if (total > APPLY_PAGE_MAX_SIZE) {
- this.$message({
- type: "warning",
- showClose: true,
- duration: 0,
- message: `岗位申请共 ${total} 条,当前仅加载前 ${APPLY_PAGE_MAX_SIZE} 条(指标卡与分页基于已加载数据),请用筛选条件缩小范围。`,
- });
- }
- return list;
- } catch (error) {
- this.overview = [];
- this.overviewKey = "";
- this.$message.error(error.message || "岗位申请列表加载失败");
- throw error;
- }
- },
- // 机构选项取登录缓存;部门(向上回溯归属机构)与机构名称仍用组织列表接口
- async loadOrganizations() {
- /* 登录缓存先落地:即使组织接口失败,所属机构下拉仍可用 */
- this.legalEntities = getOrgOptions().map((item) => ({
- id: item.id,
- name: item.name,
- code: "ZY",
- }));
- try {
- const list = await listOrganizations();
- const records = Array.isArray(list) ? list : [];
- const parents = new Map(records.map((item) => [String(item.id), item]));
- this.departments = records.map((item) => {
- let current = item;
- while (parents.has(String(current.parentId)))
- current = parents.get(String(current.parentId));
- return {
- id: item.id,
- name: item.name,
- code: item.groupCode || item.code || "",
- legalId: current.id,
- };
- });
- this.orgMap = Object.fromEntries(
- records.map((item) => [String(item.id), item.name]),
- );
- } catch (error) {
- /*
- * 明确报错,不再静默:组织接口失败会让「部门」下拉与机构名称展示不全,
- * 用户必须知道是接口挂了,而不是以为公司没有部门。
- * 注意:不要在这里清空 legalEntities —— 它来自登录缓存,与组织接口无关。
- */
- this.departments = [];
- this.orgMap = {};
- this.$message.error(
- error.message || "组织列表加载失败,部门下拉与机构名称可能显示不全",
- );
- }
- },
- // 加载岗位序列及其层级:序列下拉 + 序列→层级联动映射(仅启用配置)
- async loadSequences() {
- try {
- const list = await getPositionSequenceList();
- if (Array.isArray(list) && list.length) {
- const names = [];
- const map = {};
- list.forEach((item) => {
- const name = (item.sequence || "").trim();
- if (!name) return;
- names.push(name);
- // 序列层级:仅取启用层级,value/label 为层级文本(如 P1/T4);无启用层级保持空选项
- const levels = (item.levelList || [])
- .filter((level) => Number(level.status) !== 0)
- .map((level) => ({
- value: level.value || level.label || "",
- label: level.label || level.value || "",
- }))
- .filter((level) => level.value);
- map[name] = levels.length ? levels : [];
- });
- this.sequenceOptions = names;
- this.sequenceLevelMap = map;
- this.syncLevelOptions(this.form.positionSequence);
- } else {
- this.sequenceOptions = [];
- this.sequenceLevelMap = {};
- }
- } catch (error) {
- this.sequenceOptions = [];
- this.sequenceLevelMap = {};
- this.$message.error(error.message || "岗位序列加载失败,岗位序列下拉暂不可用");
- }
- },
- // 组织接口无数据时,从岗位申请数据推导机构和部门,保证下拉可用
- fallbackOrganizations() {
- if (this.departments.length || !this.overview.length) return;
- this.legalEntities = [
- ...new Map(
- this.overview
- .filter((item) => item.organizationId)
- .map((item) => [
- String(item.organizationId),
- {
- id: item.organizationId,
- name: item.organizationName || `组织${item.organizationId}`,
- },
- ]),
- ).values(),
- ];
- this.departments = [
- ...new Map(
- this.overview
- .filter((item) => item.deptId)
- .map((item) => [
- String(item.deptId),
- {
- id: item.deptId,
- name: item.deptName || `部门${item.deptId}`,
- legalId: item.organizationId,
- },
- ]),
- ).values(),
- ];
- },
- /* 数据变化后统一走这里:一次刷新表格与顶部指标卡(两者共用同一份全量数据) */
- refreshData() {
- this.reloadTable();
- },
- applyFilters() {
- this.filters = { ...this.draftFilters };
- this.reloadTable();
- },
- resetFilters() {
- this.draftFilters = emptyFilters();
- this.filters = emptyFilters();
- this.reloadTable();
- },
- clearFilter(key) {
- this.draftFilters[key] = "";
- this.filters[key] = "";
- this.reloadTable();
- },
- reloadTable() {
- /* 清掉全量缓存:增删改 / 筛选变化都要重拉,顶部指标卡随之刷新 */
- this.overviewKey = "";
- this.$nextTick(() => this.$refs.table?.reload({ page: 1 }));
- },
- openCreate() {
- this.mode = "create";
- this.form = emptyForm();
- // 默认带出当前登录用户所属机构
- this.form.organizationId =
- getCurrentOrgId() ?? this.legalEntities[0]?.id ?? null;
- this.syncLevelOptions(this.form.positionSequence);
- this.drawerVisible = true;
- this.$nextTick(() => this.$refs.applicationForm?.clearValidate());
- },
- async openEdit(row) {
- if (!this.canEdit(row)) return;
- this.mode = "edit";
- this.drawerVisible = true;
- try {
- const detail = await getPositionApplicationById(row.id);
- this.form = this.detailToForm(detail);
- } catch (error) {
- /* 详情接口失败:用列表行数据兜底展示,但必须明确告知用户(不静默) */
- this.form = this.rowToForm(row);
- this.$message.warning(
- error.message ||
- "岗位申请详情加载失败,当前展示列表行信息,部分字段可能为空",
- );
- }
- this.syncLevelOptions(this.form.positionSequence);
- this.$nextTick(() => this.$refs.applicationForm?.clearValidate());
- },
- async openView(row) {
- this.mode = "view";
- this.drawerVisible = true;
- try {
- const detail = await getPositionApplicationById(row.id);
- this.form = this.detailToForm(detail);
- } catch (error) {
- /* 详情接口失败:用列表行数据兜底展示,但必须明确告知用户(不静默) */
- this.form = this.rowToForm(row);
- this.$message.warning(
- error.message ||
- "岗位申请详情加载失败,当前展示列表行信息,部分字段可能为空",
- );
- }
- this.syncLevelOptions(this.form.positionSequence);
- },
- // 详情接口返回 → 表单/详情页字段
- detailToForm(detail) {
- const adapted = adaptPositionApplicationDetail(detail);
- return {
- ...adapted,
- type: this.resolveTypeValue(adapted.type),
- deptName: adapted.deptName || "",
- positionLevel: adapted.positionLevel || "",
- headcount: adapted.headcount || 1,
- };
- },
- // 列表行 → 表单/详情页字段(详情接口失败兜底)
- rowToForm(row) {
- return {
- ...emptyForm(),
- ...row,
- type: this.resolveTypeValue(row.type),
- deptName: row.deptName || "",
- duties: row.duties || [],
- dutiesText: (row.duties || []).join("\n"),
- };
- },
- // 岗位序列变化时联动岗位层级选项,并清空已选层级
- handleSequenceChange() {
- this.form.positionLevel = null;
- this.syncLevelOptions(this.form.positionSequence);
- },
- // 根据岗位序列切换到对应层级下拉(未配置或未选序列时用兜底层级)
- syncLevelOptions(sequence) {
- this.levelOptions =
- (sequence && this.sequenceLevelMap[sequence]) || [];
- },
- // 修改薪资上下限时自动计算中位值
- syncSalaryMid() {
- const min = Number(this.form.salaryMin || 0);
- const max = Number(this.form.salaryMax || 0);
- if (max >= min) {
- this.form.salaryMid = Math.round((min + max) / 2);
- }
- },
- // 薪资区间校验:必填 + 上下限关系;定义在 methods 保证 rules 里能取到有效引用
- salaryRangeValidator(_rule, _value, callback) {
- const min = Number(this.form.salaryMin || 0);
- const max = Number(this.form.salaryMax || 0);
- if (!min || !max) {
- callback(new Error("请输入完整的岗位薪资区间"));
- } else if (max < min) {
- callback(new Error("薪资上限不能低于下限"));
- } else {
- callback();
- }
- },
- addSkill() {
- this.form.skills.push({ name: "", level: "中级", required: true });
- },
- removeSkill(index) {
- this.form.skills.splice(index, 1);
- },
- addCert() {
- this.form.certificates.push({
- name: "",
- category: this.idTypeOptions[0]?.value || "",
- });
- },
- removeCert(index) {
- this.form.certificates.splice(index, 1);
- },
- closeDrawer(done) {
- this.drawerVisible = false;
- if (typeof done === "function") done();
- },
- // 保存岗位申请;新建或再次编辑已驳回单据保存后,弹出 BPM 审批流程
- saveApplication(validate = true) {
- const persist = async () => {
- const payload = serializePositionApplication(this.form);
- this.saving = true;
- const wasRejected = this.form.status === "REJECTED";
- try {
- let id = this.form.id;
- const isCreate = !id;
- if (id) {
- await updatePositionApplication(id, payload);
- } else {
- const created = await createPositionApplication(payload);
- id = created?.id ?? created;
- }
- const savedRow = {
- ...this.form,
- id,
- status: this.form.status || "DRAFT",
- code: this.form.code || this.form.positionCode || "",
- positionName: this.form.positionName || "",
- createUserId: this.form.createUserId,
- };
- this.drawerVisible = false;
- this.$message.success("岗位申请已保存");
- this.reloadTable();
- // 新建,或已驳回再次编辑保存后,重新弹出提交审批弹框
- if (id && (isCreate || wasRejected)) {
- this.$nextTick(() => this.launchApplication(savedRow));
- }
- } catch (error) {
- this.$message.error(error.message || "岗位申请保存失败");
- } finally {
- this.saving = false;
- }
- };
- if (validate)
- this.$refs.applicationForm.validate((valid) => valid && persist());
- else persist();
- },
- // 未提交、已驳回可发起 / 重新拉起流程
- canLaunch(row) {
- return !row?.status || ["DRAFT", "REJECTED"].includes(row.status);
- },
- /** 尚未发起审批时不展示审批记录 */
- canShowApprovalRecord(row) {
- return Boolean(
- row?.processInstanceId ||
- row?.approvalInstanceId ||
- row?.processInstance,
- );
- },
- // 发起岗位申请:打开 BPM 提交审核弹框(提交直接调 /bpm/process-instance/create)
- launchApplication(row) {
- if (!row?.id) {
- this.$message.warning("缺少岗位申请主键,无法发起审批");
- return;
- }
- if (!this.canLaunch(row)) return;
- this.currentLaunchRow = row;
- this.processSubmitDialogFlag = true;
- this.$nextTick(() => {
- const currentUser = getCurrentUser() || {};
- const params = {
- businessId: row.id,
- businessKey: POSITION_APPLY_BUSINESS_KEY,
- formCreateUserId:
- row.createUserId || currentUser.id || currentUser.userId || "",
- variables: {
- businessCode: row.code || row.positionCode || "",
- businessName: row.positionName || "",
- businessType: "岗位申请",
- },
- };
- this.$refs.processSubmitDialogRef?.init(params);
- });
- },
- onProcessSubmitReload() {
- this.currentLaunchRow = null;
- this.reloadTable();
- },
- openApprovalRecord(row) {
- this.$refs.approvalRecordDialog?.open(row);
- },
- // 仅待审核(PENDING)状态显示审批按钮
- canSubmit(row) {
- return row.status === "PENDING";
- },
- // 提交岗位申请审批(操作栏独立按钮触发,仅 PENDING 可操作)
- submitApproval(row) {
- if (!this.canSubmit(row)) return;
- this.$confirm(
- `确认对岗位申请「${row.positionName}」执行审批吗?`,
- "岗位申请审批",
- { type: "warning" },
- )
- .then(async () => {
- try {
- await submitPositionApplication(row.id);
- this.$message.success("岗位申请已提交审批");
- this.reloadTable();
- } catch (error) {
- this.$message.error(error.message || "提交审批失败");
- }
- })
- .catch(() => { });
- },
- // 未提交与已驳回可删除
- canDelete(row) {
- return ["DRAFT", "REJECTED"].includes(row.status);
- },
- // 删除岗位申请(DELETE /hr/position/apply/{id})
- removeApplication(row) {
- if (!this.canDelete(row)) return;
- this.$confirm(
- `确认删除岗位申请「${row.positionName}」吗?删除后不可恢复。`,
- "删除确认",
- { type: "warning" },
- )
- .then(async () => {
- try {
- await deletePositionApplication(row.id);
- this.$message.success("岗位申请已删除");
- this.reloadTable();
- } catch (error) {
- this.$message.error(error.message || "删除失败");
- }
- })
- .catch(() => { });
- },
- // 岗位性质:后端字典 /system/dict/getByCode/position_nature
- // 下拉 value 与提交后端的 positionType 统一用「字典 id」,label 用字典文案
- async loadTypeOptions() {
- try {
- const items = await getDictByCode("position_nature");
- this.typeOptions = items
- .map((item) => {
- const label = String(item.label ?? item.value ?? "").trim();
- const id = String(item.id ?? item.key ?? "").trim();
- if (!label || !id) return null;
- return {
- label,
- value: id,
- key: item.key != null ? String(item.key) : id,
- };
- })
- .filter(Boolean);
- } catch (error) {
- this.typeOptions = [];
- this.$message.error(error.message || "岗位性质字典加载失败");
- }
- },
- // 技能证书类别:与岗位设定资质证书一致,/system/dict/id_type
- async loadIdTypeOptions() {
- try {
- const items = await getDict("id_type");
- this.idTypeOptions = items.filter((item) => item.value);
- } catch (error) {
- this.idTypeOptions = [];
- this.$message.error(error.message || "证书类别字典加载失败");
- }
- },
- // 后端返回值(字典 id / 历史文案 / 编码)统一转成选项 value(字典 id),便于下拉回显
- resolveTypeValue(raw) {
- if (raw === null || raw === undefined || raw === "") return "";
- const text = String(raw);
- const hit = this.typeOptions.find(
- (item) =>
- String(item.value) === text ||
- String(item.label) === text ||
- String(item.key) === text,
- );
- return hit ? hit.value : text;
- },
- orgName(id) {
- if (id === null || id === undefined || id === "") return "-";
- return this.orgMap[String(id)] || "-";
- },
- // 展示用:字典 id / 文案 / 编码 统一反查字典文案(兼容历史文本快照)
- typeName(value) {
- if (value === null || value === undefined || value === "") return "-";
- const text = String(value);
- const hit = this.typeOptions.find(
- (item) =>
- String(item.value) === text ||
- String(item.label) === text ||
- String(item.key) === text,
- );
- return hit ? hit.label : text;
- },
- // 岗位层级展示:数字(离线兜底 1-5)显示「N级」,文本(序列层级如 P1/T4)原样展示
- levelText(value) {
- if (value === null || value === undefined || value === "") return "";
- return Number(value) ? `${value}级` : value;
- },
- statusName(value) {
- return STATUS_LABELS[value] || value || "-";
- },
- statusClass(value) {
- return {
- DRAFT: "is-inactive",
- PENDING: "is-warning",
- APPROVED: "is-active",
- REJECTED: "is-danger",
- }[value];
- },
- statusTone(value) {
- return {
- DRAFT: "draft",
- PENDING: "pending",
- APPROVED: "approved",
- REJECTED: "rejected",
- }[value];
- },
- statusTagType(value) {
- return {
- DRAFT: "info",
- PENDING: "warning",
- APPROVED: "success",
- REJECTED: "danger",
- }[value];
- },
- operationName(value) {
- return OPERATION_LABELS[value] || value || "";
- },
- canEdit(row) {
- // 未提交与已驳回均可编辑后重新提交
- return ["DRAFT", "REJECTED"].includes(row.status);
- },
- },
- };
- </script>
- <style lang="scss" scoped src="../../../styles/views/positionApplication/index.scss"></style>
|