| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215 |
- <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="cacheKeyUrl" class="application-table">
- <template v-slot:toolbar>
- <el-button 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)" type="primary" :underline="false" icon="el-icon-position"
- @click.stop="launchApplication(row)">
- 提交
- </el-link>
- <el-link v-if="canEdit(row)" type="primary" :underline="false" icon="el-icon-edit"
- @click.stop="openEdit(row)">
- 编辑
- </el-link>
- <el-link v-if="canDelete(row)" 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="cert.name + 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>
|