| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904 |
- <template>
- <div class="ele-body" v-loading="loading">
- <el-card shadow="never" class="employee-archive-page">
- <section class="archive-workspace">
- <aside class="organization-panel" :class="{ collapsed: treeCollapsed }">
- <div class="panel-heading">
- <div>
- <h2>组织架构</h2><span>按公司与部门查看人员</span>
- </div>
- <el-tooltip :content="treeCollapsed ? '展开组织树' : '收起组织树'" placement="top">
- <button type="button" class="icon-button" @click="treeCollapsed = !treeCollapsed">
- <i :class="treeCollapsed ? 'el-icon-arrow-right' : 'el-icon-arrow-left'"></i>
- </button>
- </el-tooltip>
- </div>
- <template v-if="!treeCollapsed">
- <el-input v-model="treeKeyword" clearable prefix-icon="el-icon-search" placeholder="搜索公司或部门"
- class="tree-search" />
- <div class="all-organization" :class="{ active: !selectedOrganizationId }" @click="selectAllOrganizations">
- <span><i class="el-icon-menu"></i>全部员工</span><em>{{ metricsTotal }}</em>
- </div>
- <!-- 树滚动区:绝对定位撑满剩余空间,树内容再长也不会把面板撑高(面板高度跟右侧表格走) -->
- <div class="tree-scroll">
- <el-tree ref="organizationTree" :data="organizationTree" node-key="id" :props="treeProps"
- :filter-node-method="filterTreeNode" :expand-on-click-node="false" :default-expand-all="true"
- highlight-current class="organization-tree" @node-click="selectOrganization">
- <span slot-scope="{ data }" class="tree-node-content">
- <span class="tree-node-label">
- <i :class="isCompanyNode(data) ? 'el-icon-office-building' : 'el-icon-folder'" />
- <span>{{ data.name }}</span>
- </span>
- <em>{{ data.userCount == null ? '—' : data.userCount }}</em>
- </span>
- </el-tree>
- </div>
- </template>
- <button v-else type="button" class="collapsed-tree-button" @click="treeCollapsed = false">
- <i class="el-icon-office-building"></i><span>组织</span>
- </button>
- </aside>
- <div class="table-panel">
- <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="filters.keyword" clearable placeholder="姓名 / 工号 / 手机号" />
- </el-form-item>
- </el-col>
- <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
- <el-form-item label="员工状态" class="status-item">
- <!-- 选项来自 hr_user_status 字典,选中值即字典值(直接作为接口 status 参数) -->
- <el-select v-model="filters.status" clearable placeholder="请选择" style="width: 100%">
- <el-option v-for="item in filterStatusOptions" :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 }">
- <el-form-item label="性别" class="gender-item">
- <el-select v-model="filters.gender" clearable placeholder="请选择" style="width: 100%">
- <el-option v-for="item in genderOptions" :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 }">
- <el-form-item label="入职日期">
- <el-date-picker v-model="filters.hireRange" type="daterange" value-format="yyyy-MM-dd"
- range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" class="hire-range"
- style="width: 100%" />
- </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"
- :init-load="false" :page-size="20" :height="520" :cache-key="$tableCacheKey(cacheKeyUrl)" stripe
- class="employee-table" @selection-change="selectedRows = $event">
- <template v-slot:toolbar>
- <el-button v-if="$hasPermission('main:hrEmployee:save')" type="primary" icon="el-icon-plus"
- class="ele-btn-icon" @click="openCreate">
- 新增
- </el-button>
- <el-dropdown v-if="$hasPermission('main:hrEmployee:import')
- || $hasPermission('main:hrEmployee:importTemplate')" trigger="click" @command="handleImportCommand">
- <el-button icon="el-icon-upload2" class="ele-btn-icon">
- 导入<i class="el-icon-arrow-down el-icon--right"></i>
- </el-button>
- <el-dropdown-menu slot="dropdown">
- <el-dropdown-item v-if="$hasPermission('main:hrEmployee:importTemplate')" command="template"
- icon="el-icon-document">下载导入模板</el-dropdown-item>
- <el-dropdown-item v-if="$hasPermission('main:hrEmployee:import')" command="upload"
- icon="el-icon-upload2">上传 Excel 导入</el-dropdown-item>
- </el-dropdown-menu>
- </el-dropdown>
- <input ref="importInput" type="file" accept=".xlsx,.xls" style="display: none" @change="onImportFile" />
- <el-dropdown v-if="$hasPermission('main:hrEmployee:export')" trigger="click" @command="handleExport">
- <el-button icon="el-icon-download" class="ele-btn-icon">
- 导出<i class="el-icon-arrow-down el-icon--right"></i>
- </el-button>
- <el-dropdown-menu slot="dropdown">
- <el-dropdown-item command="filtered" icon="el-icon-document">导出当前结果</el-dropdown-item>
- <el-dropdown-item command="selected" icon="el-icon-finished"
- :disabled="!selectedRows.length">导出已选员工</el-dropdown-item>
- </el-dropdown-menu>
- </el-dropdown>
- <template v-if="selectedRows.length">
- <span class="selected-count">已选 {{ selectedRows.length }} 人</span>
- </template>
- </template>
- <template v-slot:name="{ row }">
- <div class="employee-cell" @click.stop="openDetail(row)">
- <span class="table-avatar" :style="employeeAvatarStyle(row)">
- <img v-if="row.avatar" :src="row.avatar" :alt="row.name" />
- <span v-else>{{ (row.name || '-').slice(-1) }}</span>
- </span>
- <!-- 姓名:可点击进详情;工号已独立成列,这里不再重复展示 -->
- <el-link type="primary" :underline="false" @click.stop="openDetail(row)">
- {{ row.name || '-' }}
- </el-link>
- </div>
- </template>
- <template v-slot:status="{ row }">
- <!-- 状态:直接用接口返回的 statusName(后端新增字段),不再由本地状态码派生文案 -->
- <el-tag v-if="row.statusName" size="mini" :type="statusNameTagType(row.statusName)">
- {{ row.statusName }}
- </el-tag>
- <span v-else>-</span>
- </template>
- <template v-slot:enabled="{ row }">
- <!-- 是否启用:接口只给 isEnabled(0/1),文案由前端渲染成 启用/停用(暂不加后端文案字段) -->
- <span>{{ row.enabled ? '启用' : '停用' }}</span>
- </template>
- <template v-slot:action="{ row }">
- <!-- 员工列已可点开详情,此处移除原「查看」入口 -->
- <el-link v-if="$hasPermission('main:hrEmployee:update')" type="primary" :underline="false"
- icon="el-icon-edit" @click.stop="openEdit(row)">
- 编辑
- </el-link>
- </template>
- <template slot="empty">
- <el-empty description="暂无数据" />
- </template>
- </ele-pro-table>
- </div>
- </section>
- </el-card>
- <employee-archive-form :visible.sync="formVisible" :employee="editingEmployee" :organizations="organizationTree"
- :existing-numbers="existingNumbers" @save="saveEmployee" />
- <employee-archive-detail :visible.sync="detailVisible" :employee="selectedEmployee" @edit="editFromDetail" />
- </div>
- </template>
- <script>
- import EmployeeArchiveForm from './components/EmployeeArchiveForm.vue';
- import EmployeeArchiveDetail from './components/EmployeeArchiveDetail.vue';
- import {
- listOrganizationsWithUserCount,
- getUserPage,
- getEmployeeProfile,
- saveEmployee as saveEmployeeApi,
- serializeEmployee,
- adaptEmployee,
- adaptEmployeeProfile,
- syncWorkExperiences,
- listWorkExperiences,
- importUsers,
- fetchAllUsers,
- searchUsersByKeyword,
- getFactoryAreaPage,
- listTeams
- } from '@/api/organization';
- import {
- buildOrganizationTree
- } from '@/utils/organization-tree';
- /* 导入/导出列与《员工档案导入模板.xlsx》完全一致(含 2 处重复表头与样例行),导出文件可直接回导 */
- import { writeEmployeeTemplateXlsx, downloadEmployeeTemplate } from '@/utils/employee-template';
- /* 员工档案可见机构范围:只取登录缓存所属机构(sessionStorage.loginChangeGroupVOList) */
- import { getOrgOptions, getCurrentOrgId } from '@/utils/current-org';
- import { getCurrentUser } from '@/utils/token-util';
- /* 「状态」筛选项:与新增/编辑员工档案共用 hr_user_status 字典(value = 字典值 = 后端 status) */
- import { loadEmployeeStatusOptions } from '@/utils/employee-status-dict';
- /* 性别:接口口径 1 男 / 2 女(筛选项与新增/编辑同一套码,前端按码过滤) */
- import { GENDER_OPTIONS, normalizeGender } from '@/utils/gender';
- /* 列表「状态」列的文案已改为**直接用接口返回的 statusName**(后端新增字段),
- 本地不再维护 active/probation/inactive/disabled 四态文案表(原 STATUS_OPTIONS 已删);
- tag 颜色按 statusName 文案关键词匹配,见 statusNameTagType()。
- 筛选的下拉与参数仍用 hr_user_status 字典的数字码(见 loadFilterStatusOptions)。 */
- /* 员工档案导出/模板列已抽到 utils/employee-template.js —— 表头与《员工档案导入模板.xlsx》逐列对齐,
- 本文件不再维护 CSV 列清单,避免两处口径漂移 */
- export default {
- name: 'EmployeeArchive',
- components: { EmployeeArchiveForm, EmployeeArchiveDetail },
- data() {
- return {
- loading: false,
- organizationTree: [],
- orgById: {},
- /* 「状态」筛选的下拉选项:hr_user_status 字典(与新增/编辑员工档案同一份) */
- filterStatusOptions: [],
- /* 「性别」筛选项:与新增/编辑同一套接口码(1 男 / 2 女) */
- genderOptions: [...GENDER_OPTIONS],
- treeProps: { children: 'children', label: 'name' },
- treeKeyword: '',
- treeCollapsed: false,
- selectedOrganizationId: '',
- /* 可见机构范围:登录缓存所属机构列表(员工档案只能看自己所属机构的人) */
- scopedOrgs: [],
- scopedOrgIds: [],
- defaultOrganizationId: '',
- filters: { keyword: '', status: '', gender: '', hireRange: [] },
- appliedFilters: { keyword: '', status: '', gender: '', hireRange: [] },
- cacheKeyUrl: 'hr-pc-employee-archive-v3',
- selectedRows: [],
- formVisible: false,
- editingEmployee: null,
- detailVisible: false,
- selectedEmployee: null,
- tableTotal: 0,
- /* 工号池:只在打开「新增/编辑员工」弹窗时按需拉取,页面加载不发这个全量请求 */
- existingNumbers: [],
- employeeNumbersLoaded: false,
- /* 导出用字典(工厂 / 班组):人员行只有 factoryId / teamId,名称要查字典;导出时按需加载一次并缓存 */
- factoryById: {},
- factoryDictLoaded: false,
- teamById: {},
- exportDictLoaded: false
- };
- },
- computed: {
- styleResponsive() {
- return this.$store.state.theme?.styleResponsive !== false;
- },
- /** 登录机构 id:不受组织树选择影响(「全部员工」人数、工号池范围都用它) */
- loginOrgId() {
- return this.defaultOrganizationId || this.scopedOrgIds[0] || '';
- },
- /** 工号池 / 机构范围的查询参数(固定登录机构,不跟随组织树里选中的部门) */
- loginOrgParams() {
- return this.loginOrgId ? { groupId: this.loginOrgId } : {};
- },
- /**
- * 「全部员工」人数:取组织列表接口(getGroupListWithUserCount)下发的 userCount,
- * 与左树节点同一个后端字段,前端不做统计。列表未就绪时返回 null,模板显示「—」。
- */
- metricsTotal() {
- const node = this.orgById[String(this.loginOrgId)] || null;
- const count = node?.userCount;
- return count == null || count === '' ? null : Number(count);
- },
- /**
- * 列结构对齐另一套系统的员工列表(2026-10-21 用户拍板):
- * 姓名 / 所属机构 / 部门 / 工号 / 手机号 / 岗位 / 所属工厂 / 用户账号 /
- * 性别 / 状态 / 是否启用 / 创建时间 / 操作。
- * 列结构变了,cacheKeyUrl 要跟着 bump(v3),否则旧的列显隐缓存会串。
- */
- columns() {
- return [
- { type: 'selection', width: 56, fixed: 'left', align: 'center', headerAlign: 'center' },
- { type: 'index', label: '序号', width: 60, fixed: 'left', align: 'center', headerAlign: 'center' },
- { prop: 'name', label: '姓名', minWidth: 120, fixed: 'left', slot: 'name' },
- { prop: 'company', label: '所属机构', minWidth: 160, showOverflowTooltip: true },
- { prop: 'deptNames', label: '部门', minWidth: 150, showOverflowTooltip: true },
- { prop: 'employeeNo', label: '工号', width: 110, align: 'center' },
- { prop: 'phone', label: '手机号', width: 122, align: 'center' },
- { prop: 'position', label: '岗位', minWidth: 140, showOverflowTooltip: true },
- { prop: 'factoryName', label: '所属工厂', minWidth: 120, showOverflowTooltip: true },
- { prop: 'account', label: '用户账号', width: 120, showOverflowTooltip: true },
- { prop: 'gender', label: '性别', width: 74, align: 'center' },
- { prop: 'statusName', label: '状态', width: 100, align: 'center', slot: 'status' },
- { prop: 'enabled', label: '是否启用', width: 90, align: 'center', slot: 'enabled' },
- { prop: 'createTime', label: '创建时间', width: 100, align: 'center' },
- { columnKey: 'action', label: '操作', width: 140, fixed: 'right', align: 'left', resizable: false, slot: 'action' }
- ];
- },
- currentOrganizationName() {
- if (!this.selectedOrganizationId) return '全部员工';
- return this.orgById[String(this.selectedOrganizationId)]?.name || '全部员工';
- },
- activeFilterTags() {
- const tags = [];
- if (this.selectedOrganizationId) tags.push({ key: 'organization', label: this.currentOrganizationName });
- if (this.appliedFilters.keyword) tags.push({ key: 'keyword', label: `关键词:${this.appliedFilters.keyword}` });
- if (this.appliedFilters.status !== '' && this.appliedFilters.status != null) {
- tags.push({ key: 'status', label: `员工状态:${this.filterStatusLabel(this.appliedFilters.status)}` });
- }
- if (this.appliedFilters.gender) {
- tags.push({ key: 'gender', label: `性别:${this.genderFilterLabel(this.appliedFilters.gender)}` });
- }
- if (this.appliedFilters.hireRange?.length) {
- tags.push({ key: 'hireRange', label: `入职:${this.appliedFilters.hireRange.join(' 至 ')}` });
- }
- return tags;
- }
- },
- watch: {
- treeKeyword(value) {
- this.$refs.organizationTree?.filter(value);
- }
- },
- created() {
- this.syncOrgScope();
- this.bootstrap();
- // 字典不阻塞首屏(首屏仍是「组织列表 + 一页员工」两个请求),异步补齐筛选项
- this.loadFilterStatusOptions();
- },
- methods: {
- /** 可见机构范围:只取登录缓存所属机构列表,默认定位到当前登录机构 */
- syncOrgScope() {
- this.scopedOrgs = getOrgOptions();
- this.scopedOrgIds = this.scopedOrgs
- .map((item) => (item.id == null ? '' : String(item.id)))
- .filter(Boolean);
- const currentId = getCurrentOrgId();
- this.defaultOrganizationId = currentId == null ? '' : String(currentId);
- this.selectedOrganizationId = this.defaultOrganizationId;
- },
- async bootstrap() {
- this.loading = true;
- try {
- await this.loadOrganizations();
- // 工厂字典:列表「所属工厂」列翻译 factoryId 用,就绪后再发列表请求(否则首屏该列全空)
- await this.ensureFactoryDictionary();
- // 页面加载只发两个请求:组织列表(含 userCount)+ 当前这一页员工列表。
- // 「全部员工」人数、左树人数都取组织列表下发的 userCount,前端不再额外统计。
- // 表格 :init-load="false":只在这里发唯一一次列表请求(等 orgById 就绪,部门/公司名映射完整)
- this.$nextTick(() => this.$refs.table?.reload({ page: 1 }));
- } finally {
- this.loading = false;
- }
- },
- /**
- * 工厂字典(列表「所属工厂」列翻译 factoryId → 名称)。
- * 加载失败明确报错,字典缺失时该列留空,不编造名称;下次刷新重试。
- */
- async ensureFactoryDictionary() {
- if (this.factoryDictLoaded || this.exportDictLoaded) return;
- try {
- const data = await getFactoryAreaPage();
- const list = Array.isArray(data) ? data : data?.list || [];
- const map = {};
- list.forEach((item) => {
- if (item?.id == null) return;
- map[String(item.id)] = item.name || '';
- });
- this.factoryById = map;
- this.factoryDictLoaded = true;
- } catch (error) {
- this.$message.error(error?.message || '工厂字典加载失败,所属工厂列暂无数据');
- }
- },
- attachOrgMeta(employee) {
- const deptId = employee.deptId || '';
- const groupId = employee.groupId || employee.legalEntityId || '';
- const deptOrg = this.orgById[String(deptId || '')];
- const groupOrg = this.orgById[String(groupId || '')];
- let company = employee.company || employee.legalEntity || '';
- // profile 常只回 groupId、不回 groupName:用组织表匹配机构名称
- if ((!company || /^\d+$/.test(String(company))) && groupOrg?.name) {
- company = groupOrg.name;
- }
- if (!company && deptOrg) {
- let node = deptOrg;
- const seen = new Set();
- while (node && !seen.has(String(node.id))) {
- seen.add(String(node.id));
- if ([10, 20, '10', '20', 'group', 'company'].includes(node.type)) {
- company = node.name;
- break;
- }
- const parent = node.parentId != null ? this.orgById[String(node.parentId)] : null;
- if (!parent) break;
- node = parent;
- }
- if (!company) company = deptOrg.name || '';
- }
- return {
- ...employee,
- groupId: groupId !== '' && groupId != null ? String(groupId) : null,
- deptId: deptId !== '' && deptId != null ? String(deptId) : '',
- deptName: employee.deptName || deptOrg?.name || '',
- // 隶属部门(可多选):deptIds 逐个查组织字典翻译名称;接口没给 id 就留空,不编造
- deptNames: (employee.deptIds || [])
- .map((id) => this.orgById[String(id)]?.name)
- .filter(Boolean)
- .join('、'),
- // 所属工厂:人员行只带 factoryId,名称按工厂字典翻译(与导出同源口径),查不到留空
- factoryName: employee.factoryId != null
- ? (this.factoryById[String(employee.factoryId)] ?? '')
- : '',
- company: company || employee.company || '',
- legalEntity: company || employee.legalEntity || ''
- };
- },
- /**
- * 加载组织树 + 人数。
- * 人数直接用接口返回的 userCount(后端口径),前端不做任何统计、也不逐部门发请求。
- * 员工数据变更后调用本方法即可拿到新的人数(接口不接本地缓存)。
- */
- async loadOrganizations() {
- try {
- /* 按文档传 currentUserId(当前登录用户 id),让后端按当前用户口径返回组织列表 */
- const currentUserId = getCurrentUser()?.id;
- const list = await listOrganizationsWithUserCount(
- currentUserId != null ? { currentUserId } : {},
- );
- const records = Array.isArray(list) ? list : [];
- const map = {};
- records.forEach((item) => { map[String(item.id)] = item; });
- this.orgById = map;
- // 组织树只保留登录机构(连同其下级部门),避免看到其他机构的人员
- this.organizationTree = this.scopeOrganizationTree(buildOrganizationTree(records));
- this.$nextTick(() => {
- if (this.selectedOrganizationId) {
- this.$refs.organizationTree?.setCurrentKey(this.selectedOrganizationId);
- }
- });
- } catch (error) {
- this.organizationTree = [];
- this.orgById = {};
- this.$message.error(error.message || '组织架构加载失败');
- }
- },
- /** 裁剪组织树:只保留登录缓存所属机构节点(命中节点连同其子树整体保留) */
- scopeOrganizationTree(tree) {
- const ids = this.scopedOrgIds || [];
- if (!ids.length) return [];
- const scoped = [];
- const walk = (nodes) => {
- (nodes || []).forEach((node) => {
- if (ids.includes(String(node.id))) {
- scoped.push(node);
- return;
- }
- walk(node.children || []);
- });
- };
- walk(tree || []);
- /* 2026-10-23 用户指出:树里出现「前桥部/研发部」等接口不存在的节点 ——
- 原来这里用登录缓存(scopedOrgs)补接口缺失的节点,属于前端兜底拼数据,
- 已删除:树只展示 getGroupListWithUserCount 实际返回的组织 */
- return scoped;
- },
- /**
- * 员工数据变更后刷新组织列表:人数(userCount)随之更新。
- * 「全部员工」总人数 = 登录机构节点的 userCount,跟着本方法一起刷新,不需要额外请求。
- */
- async refreshOrganizations() {
- // 机构范围可能变了,工号池下次开弹窗重新拉
- this.employeeNumbersLoaded = false;
- await this.loadOrganizations();
- },
- /**
- * 工号池(「员工编号已存在」查重 + 自动生成员工编号的数据源)。
- * 只有新增 / 编辑弹窗用得到,所以在弹窗打开时按需拉取,页面加载不发这个全量请求。
- * 失败必须明确提示:静默失败会让编号查重悄悄失效。
- */
- async ensureEmployeeNumbers() {
- if (this.employeeNumbersLoaded) return;
- this.employeeNumbersLoaded = true;
- try {
- const all = await fetchAllUsers(
- { ...this.loginOrgParams, isQueryLZ: 0 },
- { pageSize: 500, maxPages: 30 },
- );
- const numbers = all.list.map((item) => item.jobNumber).filter(Boolean);
- this.existingNumbers = Array.from(new Set([...this.existingNumbers, ...numbers]));
- } catch (error) {
- // 允许下次重新尝试
- this.employeeNumbersLoaded = false;
- this.$message.warning(error?.message || '员工编号数据加载失败,新增时无法自动校验编号是否重复');
- }
- },
- needsClientFilter() {
- // 状态筛选已改走接口(字典值当 status 参数),不再需要前端全量拉取后过滤
- const { gender, hireRange, keyword } = this.appliedFilters;
- return Boolean(
- gender
- || hireRange?.length
- || (keyword && keyword.trim())
- );
- },
- /** 当前可见机构 id:员工数据的默认查询范围(登录机构,未选中时回落到第一个) */
- scopeGroupId() {
- return (
- this.selectedOrganizationId ||
- this.defaultOrganizationId ||
- this.scopedOrgIds[0] ||
- ''
- );
- },
- /** 机构查询参数:所有员工取数都带上,避免返回其他机构的人 */
- scopeParams() {
- const groupId = this.scopeGroupId();
- return groupId ? { groupId } : {};
- },
- buildBaseParams() {
- const { status } = this.appliedFilters;
- const params = {
- ...this.scopeParams(),
- // isQueryLZ=1 才会返回离职员工:选中字典里的「离职」项时必须带上,否则筛出来是空的
- isQueryLZ: this.isLeaveStatusValue(status) ? 1 : 0
- };
- // 状态直接用字典值当接口参数(后端 status,string)
- if (status !== '' && status != null) {
- params.status = status;
- }
- return params;
- },
- /** 该筛选值是否「离职」状态(字典码 8 或文案含「离职」),用于决定 isQueryLZ。
- * ★ 字典实测码空间:1=正式在职、6=试用期、7=待岗、8=已离职 —— 6 不是离职。 */
- isLeaveStatusValue(value) {
- if (value === '' || value == null) return false;
- if (String(value) === '8') return true;
- const label = this.filterStatusOptions.find((item) => String(item.value) === String(value))?.label || '';
- return label.includes('离职');
- },
- /** 「状态」筛选项:hr_user_status 字典;拉不到就留空并提示(不做内置兜底) */
- async loadFilterStatusOptions() {
- try {
- this.filterStatusOptions = await loadEmployeeStatusOptions();
- } catch (error) {
- this.filterStatusOptions = [];
- console.error('[员工档案] 员工状态字典加载失败:', error);
- this.$message.error(error.message || '员工状态字典加载失败');
- }
- },
- applyClientFilters(rows) {
- // 状态筛选由接口按字典值过滤(见 buildBaseParams),此处只处理性别 / 入职区间 / 关键词
- const { gender, hireRange, keyword } = this.appliedFilters;
- const normalized = String(keyword || '').trim().toLowerCase();
- return rows.filter((employee) => {
- // 性别按接口码比较(后端码优先,兼容接口已转好的中文);没有性别值的行不属于任何性别筛选
- if (gender && this.resolveGenderCode(employee) !== String(gender)) return false;
- if (hireRange?.length) {
- if (!employee.hireDate || employee.hireDate < hireRange[0] || employee.hireDate > hireRange[1]) return false;
- }
- if (normalized) {
- const haystack = [employee.name, employee.employeeNo, employee.phone, employee.position, employee.deptName, employee.email]
- .join(' ')
- .toLowerCase();
- if (!haystack.includes(normalized)) return false;
- }
- return true;
- });
- },
- async fetchPage({ page, limit }) {
- try {
- const base = this.buildBaseParams();
- const { keyword } = this.appliedFilters;
- const clientMode = this.needsClientFilter();
- if (clientMode) {
- let rawList = [];
- if (keyword?.trim()) {
- // 关键词:先并行字段检索,再全量兜底过滤岗位等字段
- const searched = await searchUsersByKeyword(keyword.trim(), {
- ...base,
- pageNum: 1,
- size: 200
- });
- rawList = Array.isArray(searched?.list) ? searched.list : [];
- if (!rawList.length) {
- const all = await fetchAllUsers(base, { pageSize: 200, maxPages: 30 });
- rawList = all.list;
- }
- } else {
- const all = await fetchAllUsers(base, { pageSize: 200, maxPages: 30 });
- rawList = all.list;
- }
- let list = rawList.map((item) => this.attachOrgMeta(adaptEmployee(item)));
- list = this.applyClientFilters(list);
- this.tableTotal = list.length;
- this.existingNumbers = Array.from(
- new Set([...this.existingNumbers, ...list.map((item) => item.employeeNo).filter(Boolean)])
- );
- const start = (page - 1) * limit;
- return { list: list.slice(start, start + limit), count: list.length };
- }
- const data = await getUserPage({
- ...base,
- pageNum: page,
- size: limit
- });
- const list = (Array.isArray(data?.list) ? data.list : []).map((item) => this.attachOrgMeta(adaptEmployee(item)));
- this.tableTotal = Number(data?.count ?? list.length);
- this.existingNumbers = Array.from(
- new Set([...this.existingNumbers, ...list.map((item) => item.employeeNo).filter(Boolean)])
- );
- return { list, count: this.tableTotal };
- } catch (error) {
- this.tableTotal = 0;
- this.$message.error(error.message || '员工列表加载失败');
- return { list: [], count: 0 };
- }
- },
- /**
- * 「状态」列的 tag 颜色:文案由接口 statusName 给,颜色按文案关键词匹配(沿用原四态配色)。
- * 未命中任何关键词时用默认灰(info),不猜状态语义。
- */
- statusNameTagType(name) {
- const text = String(name || '');
- if (text.includes('离职')) return 'info';
- if (text.includes('试用')) return 'warning';
- if (text.includes('待岗')) return 'danger';
- if (text.includes('在职') || text.includes('正式')) return 'success';
- return 'info';
- },
- /** 「员工状态」筛选值的文案:入参是 hr_user_status 字典值 */
- filterStatusLabel(value) {
- const hit = this.filterStatusOptions.find((item) => String(item.value) === String(value));
- return hit ? hit.label : String(value);
- },
- /** 行的性别码:后端原始码优先(adaptEmployee 保留了 raw.sex),其次接口转好的中文文案 */
- resolveGenderCode(employee = {}) {
- const rawSex = employee.raw?.sex ?? employee.sex;
- if (rawSex != null && rawSex !== '') return normalizeGender(rawSex);
- return normalizeGender(employee.gender);
- },
- /** 「性别」筛选值的文案:入参是接口码(1 / 2 / 0) */
- genderFilterLabel(value) {
- const hit = GENDER_OPTIONS.find((item) => item.value === String(value));
- return hit ? hit.label : String(value);
- },
- isCompanyNode(data) {
- return [10, 20, '10', '20', 'group', 'company'].includes(data.type);
- },
- employeeAvatarStyle(employee) {
- const tones = ['#1890ff', '#13a471', '#8b5cf6', '#d97706', '#dc5064'];
- const code = [...(employee.name || '')].reduce((sum, char) => sum + char.charCodeAt(0), 0);
- return { '--avatar-color': tones[code % tones.length] };
- },
- filterTreeNode(value, data) {
- return !value || String(data.name || '').toLowerCase().includes(value.toLowerCase());
- },
- selectOrganization(data) {
- // 只筛右侧列表:左树人数由组织列表接口下发的 userCount 决定,与选中项无关,这里不重拉
- this.selectedOrganizationId = data.id;
- this.$refs.table?.reload({ page: 1 });
- },
- selectAllOrganizations() {
- // 可见范围仅限登录机构:这里回到当前登录机构,而不是查全部机构的人
- const nextOrg = this.defaultOrganizationId || this.scopedOrgIds[0] || '';
- this.selectedOrganizationId = nextOrg;
- this.$refs.organizationTree?.setCurrentKey(nextOrg || null);
- this.$refs.table?.reload({ page: 1 });
- },
- applyFilters() {
- this.appliedFilters = {
- ...this.filters,
- hireRange: this.filters.hireRange ? [...this.filters.hireRange] : []
- };
- this.$refs.table?.reload({ page: 1 });
- },
- resetFilters() {
- this.filters = { keyword: '', status: '', gender: '', hireRange: [] };
- this.appliedFilters = { keyword: '', status: '', gender: '', hireRange: [] };
- this.selectAllOrganizations();
- },
- clearFilterTag(key) {
- if (key === 'organization') this.selectAllOrganizations();
- else {
- this.filters[key] = key === 'hireRange' ? [] : '';
- this.appliedFilters[key] = key === 'hireRange' ? [] : '';
- this.$refs.table?.reload({ page: 1 });
- }
- },
- openCreate() {
- this.editingEmployee = null;
- this.formVisible = true;
- // 弹窗里要用工号池做查重 / 自动生成编号,此时才拉
- this.ensureEmployeeNumbers();
- },
- async openEdit(employee) {
- try {
- this.loading = true;
- const detail = await this.loadEmployeeDetail(employee.id);
- this.editingEmployee = {
- ...detail,
- /* ⚠ 不要在这里把 status 覆写成中文(旧写法 status: detail.formStatus)。
- 表单的员工状态只看后端码 statusCode,下拉 value 是字典里的同一套数字码
- (实测 1=正式在职、6=试用期、7=待岗、8=已离职),
- 覆写成中文会让值空间对不上;中文文案由字典按码派生。 */
- // 列表已有文案时兜底,避免详情缺 postName / joinDate 导致岗位回显成裸 ID、入职时间为空
- position: detail.position || employee.position || '',
- hireDate: detail.hireDate || employee.hireDate || '',
- };
- this.formVisible = true;
- // 弹窗里要用工号池做查重,此时才拉
- this.ensureEmployeeNumbers();
- } catch (error) {
- this.$message.error(error.message || '员工详情加载失败');
- } finally {
- this.loading = false;
- }
- },
- editFromDetail(employee) {
- this.detailVisible = false;
- this.$nextTick(() => this.openEdit(employee));
- },
- async openDetail(employee) {
- if (!employee?.id) return;
- try {
- this.loading = true;
- this.selectedEmployee = await this.loadEmployeeDetail(employee.id);
- this.detailVisible = true;
- } catch (error) {
- this.$message.error(error.message || '员工详情加载失败');
- } finally {
- this.loading = false;
- }
- },
- async loadEmployeeDetail(id) {
- const profile = await getEmployeeProfile(id);
- return this.attachOrgMeta(adaptEmployeeProfile(profile || {}));
- },
- async saveEmployee(payload, done) {
- const finish = typeof done === 'function' ? done : () => { };
- try {
- const apiPayload = serializeEmployee({
- ...payload,
- formStatus: payload.status
- });
- const result = await saveEmployeeApi(apiPayload);
- let savedId = (result && result.id) || payload.id || null;
- // 先结束表单 loading / 允许关窗,后续刷新不阻塞交互
- this.$message.success(payload.id ? `已更新 ${payload.name} 的员工档案` : `员工 ${payload.name} 已保存`);
- this.formVisible = false;
- finish(null);
- if (!savedId && payload.employeeNo) {
- try {
- const page = await getUserPage({ pageNum: 1, size: 5, jobNumber: payload.employeeNo });
- savedId = page?.list?.[0]?.id || null;
- } catch (error) {
- // 回查失败不影响主保存成功
- }
- }
- if (savedId && Array.isArray(payload.previousJobs)) {
- try {
- const existing = await listWorkExperiences(savedId).catch(() => []);
- await syncWorkExperiences(savedId, payload.previousJobs, Array.isArray(existing) ? existing : []);
- } catch (error) {
- this.$message.warning(error.message || '基本信息已保存,工作履历同步失败');
- }
- }
- this.refreshOrganizations()
- .then(() => this.$refs.table?.reload())
- .catch(() => this.$refs.table?.reload());
- if (!payload.id && savedId) {
- this.$nextTick(() => this.openDetail({ id: savedId }));
- } else if (payload.id && this.selectedEmployee?.id === payload.id) {
- this.loadEmployeeDetail(payload.id)
- .then((detail) => { this.selectedEmployee = detail; })
- .catch(() => { });
- }
- } catch (error) {
- if (!error?.__toastShown) {
- this.$message.error(error?.message || '员工保存失败');
- }
- // 失败必须回调,否则子表单会一直 await 导致按钮/弹窗卡死
- finish(error || new Error('员工保存失败'));
- }
- },
- async handleImportCommand(command) {
- if (command === 'template') {
- // 「下载导入模板」= 本地按《员工档案导入模板.xlsx》生成(28 列 + 样例行),
- // 不再调后端 downLoadTemplate(后端下发的是旧 22 列,和页面导出/回导口径对不上)
- try {
- const fileName = downloadEmployeeTemplate(`员工档案导入模板_${new Date().toISOString().slice(0, 10)}.xlsx`);
- this.$message.success(`模板已开始下载:${fileName}`);
- } catch (error) {
- this.$message.error(error.message || '模板下载失败');
- }
- return;
- }
- this.$refs.importInput?.click();
- },
- async onImportFile(event) {
- const file = event.target.files?.[0];
- event.target.value = '';
- if (!file) return;
- try {
- this.loading = true;
- // 导入走后端「按 HR 任职链路导入」接口,返回 message 时原样展示(如「成功 10 条,失败 2 条」),
- // 不吞后端的统计/失败原因;没有 message 才用通用成功文案
- const result = await importUsers(file);
- const tip = typeof result === 'string' && result.trim() ? result.trim() : '导入成功';
- this.$message.success(tip);
- await this.refreshOrganizations();
- this.$refs.table?.reload({ page: 1 });
- } catch (error) {
- this.$message.error(error.message || '导入失败');
- } finally {
- this.loading = false;
- }
- },
- /**
- * 导出用字典:人员行只带 factoryId / teamId,工厂名与班组名必须查字典。
- * 只在导出时按需拉一次并缓存(页面加载不发)。
- * 任一字典失败 → 直接报错中止导出:宁可不导,也不产出「工厂/班组整列空白」的错文件。
- */
- async ensureExportDictionaries() {
- if (this.exportDictLoaded) return;
- // 工厂字典可能已被列表页拉过(factoryDictLoaded),避免重复请求
- const [factoryList, teamList] = await Promise.all([
- this.factoryDictLoaded
- ? Promise.resolve([])
- : getFactoryAreaPage().catch((error) => {
- throw new Error(`工厂字典加载失败:${error?.message || '未知错误'}`);
- }),
- listTeams().catch((error) => {
- throw new Error(`班组字典加载失败:${error?.message || '未知错误'}`);
- })
- ]);
- if (!this.factoryDictLoaded) {
- const factoryById = {};
- (Array.isArray(factoryList) ? factoryList : factoryList?.list || []).forEach((item) => {
- if (item?.id == null) return;
- factoryById[String(item.id)] = item.name || '';
- });
- this.factoryById = factoryById;
- this.factoryDictLoaded = true;
- }
- const teamById = {};
- (Array.isArray(teamList) ? teamList : teamList?.list || []).forEach((item) => {
- if (item?.id == null) return;
- teamById[String(item.id)] = item.name || '';
- });
- this.teamById = teamById;
- this.exportDictLoaded = true;
- },
- /** 导出员工:列 = 《员工档案导入模板.xlsx》列(真实 .xlsx,可直接回导) */
- async exportRowsAsTemplate(rows, fileName) {
- await this.ensureExportDictionaries();
- return writeEmployeeTemplateXlsx(
- rows,
- { orgById: this.orgById, factoryById: this.factoryById, teamById: this.teamById },
- fileName
- );
- },
- async handleExport(type) {
- const today = new Date().toISOString().slice(0, 10);
- if (type === 'selected') {
- if (!this.selectedRows.length) {
- this.$message.warning('请先选择要导出的员工');
- return;
- }
- try {
- this.loading = true;
- const count = await this.exportRowsAsTemplate(
- this.selectedRows,
- `员工档案_已选_${today}.xlsx`
- );
- this.$message.success(`已导出 ${count} 条`);
- } catch (error) {
- this.$message.error(error.message || '导出失败');
- } finally {
- this.loading = false;
- }
- return;
- }
- // 「导出当前结果」必须与页面所见完全一致:关键词/性别/入职日期等筛选只有前端口径,
- // 服务端导出不认这些条件,直接调会把没筛选过的数据导出去(表格信息对不上)→ 本地按模板列导出
- try {
- this.loading = true;
- const total = Math.max(Number(this.tableTotal) || 0, 200);
- const result = await this.fetchPage({ page: 1, limit: total });
- const rows = result?.list || [];
- if (!rows.length) {
- this.$message.warning('当前没有可导出的员工数据');
- return;
- }
- const count = await this.exportRowsAsTemplate(
- rows,
- `员工档案_当前结果_${today}.xlsx`
- );
- this.$message.success(`已导出 ${count} 条`);
- } catch (error) {
- this.$message.error(error.message || '导出失败');
- } finally {
- this.loading = false;
- }
- }
- }
- };
- </script>
- <style src="../../../styles/views/employeeArchive/index.scss" lang="scss" scoped></style>
|