index.vue 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904
  1. <template>
  2. <div class="ele-body" v-loading="loading">
  3. <el-card shadow="never" class="employee-archive-page">
  4. <section class="archive-workspace">
  5. <aside class="organization-panel" :class="{ collapsed: treeCollapsed }">
  6. <div class="panel-heading">
  7. <div>
  8. <h2>组织架构</h2><span>按公司与部门查看人员</span>
  9. </div>
  10. <el-tooltip :content="treeCollapsed ? '展开组织树' : '收起组织树'" placement="top">
  11. <button type="button" class="icon-button" @click="treeCollapsed = !treeCollapsed">
  12. <i :class="treeCollapsed ? 'el-icon-arrow-right' : 'el-icon-arrow-left'"></i>
  13. </button>
  14. </el-tooltip>
  15. </div>
  16. <template v-if="!treeCollapsed">
  17. <el-input v-model="treeKeyword" clearable prefix-icon="el-icon-search" placeholder="搜索公司或部门"
  18. class="tree-search" />
  19. <div class="all-organization" :class="{ active: !selectedOrganizationId }" @click="selectAllOrganizations">
  20. <span><i class="el-icon-menu"></i>全部员工</span><em>{{ metricsTotal }}</em>
  21. </div>
  22. <!-- 树滚动区:绝对定位撑满剩余空间,树内容再长也不会把面板撑高(面板高度跟右侧表格走) -->
  23. <div class="tree-scroll">
  24. <el-tree ref="organizationTree" :data="organizationTree" node-key="id" :props="treeProps"
  25. :filter-node-method="filterTreeNode" :expand-on-click-node="false" :default-expand-all="true"
  26. highlight-current class="organization-tree" @node-click="selectOrganization">
  27. <span slot-scope="{ data }" class="tree-node-content">
  28. <span class="tree-node-label">
  29. <i :class="isCompanyNode(data) ? 'el-icon-office-building' : 'el-icon-folder'" />
  30. <span>{{ data.name }}</span>
  31. </span>
  32. <em>{{ data.userCount == null ? '—' : data.userCount }}</em>
  33. </span>
  34. </el-tree>
  35. </div>
  36. </template>
  37. <button v-else type="button" class="collapsed-tree-button" @click="treeCollapsed = false">
  38. <i class="el-icon-office-building"></i><span>组织</span>
  39. </button>
  40. </aside>
  41. <div class="table-panel">
  42. <el-form label-width="80px" class="ele-form-search" @keyup.enter.native="applyFilters" @submit.native.prevent>
  43. <el-row :gutter="15">
  44. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  45. <el-form-item label="关键词">
  46. <el-input v-model.trim="filters.keyword" clearable placeholder="姓名 / 工号 / 手机号" />
  47. </el-form-item>
  48. </el-col>
  49. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  50. <el-form-item label="员工状态" class="status-item">
  51. <!-- 选项来自 hr_user_status 字典,选中值即字典值(直接作为接口 status 参数) -->
  52. <el-select v-model="filters.status" clearable placeholder="请选择" style="width: 100%">
  53. <el-option v-for="item in filterStatusOptions" :key="item.value" :label="item.label"
  54. :value="item.value" />
  55. </el-select>
  56. </el-form-item>
  57. </el-col>
  58. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  59. <el-form-item label="性别" class="gender-item">
  60. <el-select v-model="filters.gender" clearable placeholder="请选择" style="width: 100%">
  61. <el-option v-for="item in genderOptions" :key="item.value" :label="item.label"
  62. :value="item.value" />
  63. </el-select>
  64. </el-form-item>
  65. </el-col>
  66. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  67. <el-form-item label="入职日期">
  68. <el-date-picker v-model="filters.hireRange" type="daterange" value-format="yyyy-MM-dd"
  69. range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" class="hire-range"
  70. style="width: 100%" />
  71. </el-form-item>
  72. </el-col>
  73. <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
  74. <div class="ele-form-actions">
  75. <el-button type="primary" icon="el-icon-search" class="ele-btn-icon" @click="applyFilters">
  76. 查询
  77. </el-button>
  78. <el-button type="primary" icon="el-icon-refresh" class="ele-btn-icon" @click="resetFilters">
  79. 重置
  80. </el-button>
  81. </div>
  82. </el-col>
  83. </el-row>
  84. </el-form>
  85. <ele-pro-table ref="table" row-key="id" :columns="columns" :datasource="fetchPage" :need-page="true"
  86. :init-load="false" :page-size="20" :height="520" :cache-key="$tableCacheKey(cacheKeyUrl)" stripe
  87. class="employee-table" @selection-change="selectedRows = $event">
  88. <template v-slot:toolbar>
  89. <el-button v-if="$hasPermission('main:hrEmployee:save')" type="primary" icon="el-icon-plus"
  90. class="ele-btn-icon" @click="openCreate">
  91. 新增
  92. </el-button>
  93. <el-dropdown v-if="$hasPermission('main:hrEmployee:import')
  94. || $hasPermission('main:hrEmployee:importTemplate')" trigger="click" @command="handleImportCommand">
  95. <el-button icon="el-icon-upload2" class="ele-btn-icon">
  96. 导入<i class="el-icon-arrow-down el-icon--right"></i>
  97. </el-button>
  98. <el-dropdown-menu slot="dropdown">
  99. <el-dropdown-item v-if="$hasPermission('main:hrEmployee:importTemplate')" command="template"
  100. icon="el-icon-document">下载导入模板</el-dropdown-item>
  101. <el-dropdown-item v-if="$hasPermission('main:hrEmployee:import')" command="upload"
  102. icon="el-icon-upload2">上传 Excel 导入</el-dropdown-item>
  103. </el-dropdown-menu>
  104. </el-dropdown>
  105. <input ref="importInput" type="file" accept=".xlsx,.xls" style="display: none" @change="onImportFile" />
  106. <el-dropdown v-if="$hasPermission('main:hrEmployee:export')" trigger="click" @command="handleExport">
  107. <el-button icon="el-icon-download" class="ele-btn-icon">
  108. 导出<i class="el-icon-arrow-down el-icon--right"></i>
  109. </el-button>
  110. <el-dropdown-menu slot="dropdown">
  111. <el-dropdown-item command="filtered" icon="el-icon-document">导出当前结果</el-dropdown-item>
  112. <el-dropdown-item command="selected" icon="el-icon-finished"
  113. :disabled="!selectedRows.length">导出已选员工</el-dropdown-item>
  114. </el-dropdown-menu>
  115. </el-dropdown>
  116. <template v-if="selectedRows.length">
  117. <span class="selected-count">已选 {{ selectedRows.length }} 人</span>
  118. </template>
  119. </template>
  120. <template v-slot:name="{ row }">
  121. <div class="employee-cell" @click.stop="openDetail(row)">
  122. <span class="table-avatar" :style="employeeAvatarStyle(row)">
  123. <img v-if="row.avatar" :src="row.avatar" :alt="row.name" />
  124. <span v-else>{{ (row.name || '-').slice(-1) }}</span>
  125. </span>
  126. <!-- 姓名:可点击进详情;工号已独立成列,这里不再重复展示 -->
  127. <el-link type="primary" :underline="false" @click.stop="openDetail(row)">
  128. {{ row.name || '-' }}
  129. </el-link>
  130. </div>
  131. </template>
  132. <template v-slot:status="{ row }">
  133. <!-- 状态:直接用接口返回的 statusName(后端新增字段),不再由本地状态码派生文案 -->
  134. <el-tag v-if="row.statusName" size="mini" :type="statusNameTagType(row.statusName)">
  135. {{ row.statusName }}
  136. </el-tag>
  137. <span v-else>-</span>
  138. </template>
  139. <template v-slot:enabled="{ row }">
  140. <!-- 是否启用:接口只给 isEnabled(0/1),文案由前端渲染成 启用/停用(暂不加后端文案字段) -->
  141. <span>{{ row.enabled ? '启用' : '停用' }}</span>
  142. </template>
  143. <template v-slot:action="{ row }">
  144. <!-- 员工列已可点开详情,此处移除原「查看」入口 -->
  145. <el-link v-if="$hasPermission('main:hrEmployee:update')" type="primary" :underline="false"
  146. icon="el-icon-edit" @click.stop="openEdit(row)">
  147. 编辑
  148. </el-link>
  149. </template>
  150. <template slot="empty">
  151. <el-empty description="暂无数据" />
  152. </template>
  153. </ele-pro-table>
  154. </div>
  155. </section>
  156. </el-card>
  157. <employee-archive-form :visible.sync="formVisible" :employee="editingEmployee" :organizations="organizationTree"
  158. :existing-numbers="existingNumbers" @save="saveEmployee" />
  159. <employee-archive-detail :visible.sync="detailVisible" :employee="selectedEmployee" @edit="editFromDetail" />
  160. </div>
  161. </template>
  162. <script>
  163. import EmployeeArchiveForm from './components/EmployeeArchiveForm.vue';
  164. import EmployeeArchiveDetail from './components/EmployeeArchiveDetail.vue';
  165. import {
  166. listOrganizationsWithUserCount,
  167. getUserPage,
  168. getEmployeeProfile,
  169. saveEmployee as saveEmployeeApi,
  170. serializeEmployee,
  171. adaptEmployee,
  172. adaptEmployeeProfile,
  173. syncWorkExperiences,
  174. listWorkExperiences,
  175. importUsers,
  176. fetchAllUsers,
  177. searchUsersByKeyword,
  178. getFactoryAreaPage,
  179. listTeams
  180. } from '@/api/organization';
  181. import {
  182. buildOrganizationTree
  183. } from '@/utils/organization-tree';
  184. /* 导入/导出列与《员工档案导入模板.xlsx》完全一致(含 2 处重复表头与样例行),导出文件可直接回导 */
  185. import { writeEmployeeTemplateXlsx, downloadEmployeeTemplate } from '@/utils/employee-template';
  186. /* 员工档案可见机构范围:只取登录缓存所属机构(sessionStorage.loginChangeGroupVOList) */
  187. import { getOrgOptions, getCurrentOrgId } from '@/utils/current-org';
  188. import { getCurrentUser } from '@/utils/token-util';
  189. /* 「状态」筛选项:与新增/编辑员工档案共用 hr_user_status 字典(value = 字典值 = 后端 status) */
  190. import { loadEmployeeStatusOptions } from '@/utils/employee-status-dict';
  191. /* 性别:接口口径 1 男 / 2 女(筛选项与新增/编辑同一套码,前端按码过滤) */
  192. import { GENDER_OPTIONS, normalizeGender } from '@/utils/gender';
  193. /* 列表「状态」列的文案已改为**直接用接口返回的 statusName**(后端新增字段),
  194. 本地不再维护 active/probation/inactive/disabled 四态文案表(原 STATUS_OPTIONS 已删);
  195. tag 颜色按 statusName 文案关键词匹配,见 statusNameTagType()。
  196. 筛选的下拉与参数仍用 hr_user_status 字典的数字码(见 loadFilterStatusOptions)。 */
  197. /* 员工档案导出/模板列已抽到 utils/employee-template.js —— 表头与《员工档案导入模板.xlsx》逐列对齐,
  198. 本文件不再维护 CSV 列清单,避免两处口径漂移 */
  199. export default {
  200. name: 'EmployeeArchive',
  201. components: { EmployeeArchiveForm, EmployeeArchiveDetail },
  202. data() {
  203. return {
  204. loading: false,
  205. organizationTree: [],
  206. orgById: {},
  207. /* 「状态」筛选的下拉选项:hr_user_status 字典(与新增/编辑员工档案同一份) */
  208. filterStatusOptions: [],
  209. /* 「性别」筛选项:与新增/编辑同一套接口码(1 男 / 2 女) */
  210. genderOptions: [...GENDER_OPTIONS],
  211. treeProps: { children: 'children', label: 'name' },
  212. treeKeyword: '',
  213. treeCollapsed: false,
  214. selectedOrganizationId: '',
  215. /* 可见机构范围:登录缓存所属机构列表(员工档案只能看自己所属机构的人) */
  216. scopedOrgs: [],
  217. scopedOrgIds: [],
  218. defaultOrganizationId: '',
  219. filters: { keyword: '', status: '', gender: '', hireRange: [] },
  220. appliedFilters: { keyword: '', status: '', gender: '', hireRange: [] },
  221. cacheKeyUrl: 'hr-pc-employee-archive-v3',
  222. selectedRows: [],
  223. formVisible: false,
  224. editingEmployee: null,
  225. detailVisible: false,
  226. selectedEmployee: null,
  227. tableTotal: 0,
  228. /* 工号池:只在打开「新增/编辑员工」弹窗时按需拉取,页面加载不发这个全量请求 */
  229. existingNumbers: [],
  230. employeeNumbersLoaded: false,
  231. /* 导出用字典(工厂 / 班组):人员行只有 factoryId / teamId,名称要查字典;导出时按需加载一次并缓存 */
  232. factoryById: {},
  233. factoryDictLoaded: false,
  234. teamById: {},
  235. exportDictLoaded: false
  236. };
  237. },
  238. computed: {
  239. styleResponsive() {
  240. return this.$store.state.theme?.styleResponsive !== false;
  241. },
  242. /** 登录机构 id:不受组织树选择影响(「全部员工」人数、工号池范围都用它) */
  243. loginOrgId() {
  244. return this.defaultOrganizationId || this.scopedOrgIds[0] || '';
  245. },
  246. /** 工号池 / 机构范围的查询参数(固定登录机构,不跟随组织树里选中的部门) */
  247. loginOrgParams() {
  248. return this.loginOrgId ? { groupId: this.loginOrgId } : {};
  249. },
  250. /**
  251. * 「全部员工」人数:取组织列表接口(getGroupListWithUserCount)下发的 userCount,
  252. * 与左树节点同一个后端字段,前端不做统计。列表未就绪时返回 null,模板显示「—」。
  253. */
  254. metricsTotal() {
  255. const node = this.orgById[String(this.loginOrgId)] || null;
  256. const count = node?.userCount;
  257. return count == null || count === '' ? null : Number(count);
  258. },
  259. /**
  260. * 列结构对齐另一套系统的员工列表(2026-10-21 用户拍板):
  261. * 姓名 / 所属机构 / 部门 / 工号 / 手机号 / 岗位 / 所属工厂 / 用户账号 /
  262. * 性别 / 状态 / 是否启用 / 创建时间 / 操作。
  263. * 列结构变了,cacheKeyUrl 要跟着 bump(v3),否则旧的列显隐缓存会串。
  264. */
  265. columns() {
  266. return [
  267. { type: 'selection', width: 56, fixed: 'left', align: 'center', headerAlign: 'center' },
  268. { type: 'index', label: '序号', width: 60, fixed: 'left', align: 'center', headerAlign: 'center' },
  269. { prop: 'name', label: '姓名', minWidth: 120, fixed: 'left', slot: 'name' },
  270. { prop: 'company', label: '所属机构', minWidth: 160, showOverflowTooltip: true },
  271. { prop: 'deptNames', label: '部门', minWidth: 150, showOverflowTooltip: true },
  272. { prop: 'employeeNo', label: '工号', width: 110, align: 'center' },
  273. { prop: 'phone', label: '手机号', width: 122, align: 'center' },
  274. { prop: 'position', label: '岗位', minWidth: 140, showOverflowTooltip: true },
  275. { prop: 'factoryName', label: '所属工厂', minWidth: 120, showOverflowTooltip: true },
  276. { prop: 'account', label: '用户账号', width: 120, showOverflowTooltip: true },
  277. { prop: 'gender', label: '性别', width: 74, align: 'center' },
  278. { prop: 'statusName', label: '状态', width: 100, align: 'center', slot: 'status' },
  279. { prop: 'enabled', label: '是否启用', width: 90, align: 'center', slot: 'enabled' },
  280. { prop: 'createTime', label: '创建时间', width: 100, align: 'center' },
  281. { columnKey: 'action', label: '操作', width: 140, fixed: 'right', align: 'left', resizable: false, slot: 'action' }
  282. ];
  283. },
  284. currentOrganizationName() {
  285. if (!this.selectedOrganizationId) return '全部员工';
  286. return this.orgById[String(this.selectedOrganizationId)]?.name || '全部员工';
  287. },
  288. activeFilterTags() {
  289. const tags = [];
  290. if (this.selectedOrganizationId) tags.push({ key: 'organization', label: this.currentOrganizationName });
  291. if (this.appliedFilters.keyword) tags.push({ key: 'keyword', label: `关键词:${this.appliedFilters.keyword}` });
  292. if (this.appliedFilters.status !== '' && this.appliedFilters.status != null) {
  293. tags.push({ key: 'status', label: `员工状态:${this.filterStatusLabel(this.appliedFilters.status)}` });
  294. }
  295. if (this.appliedFilters.gender) {
  296. tags.push({ key: 'gender', label: `性别:${this.genderFilterLabel(this.appliedFilters.gender)}` });
  297. }
  298. if (this.appliedFilters.hireRange?.length) {
  299. tags.push({ key: 'hireRange', label: `入职:${this.appliedFilters.hireRange.join(' 至 ')}` });
  300. }
  301. return tags;
  302. }
  303. },
  304. watch: {
  305. treeKeyword(value) {
  306. this.$refs.organizationTree?.filter(value);
  307. }
  308. },
  309. created() {
  310. this.syncOrgScope();
  311. this.bootstrap();
  312. // 字典不阻塞首屏(首屏仍是「组织列表 + 一页员工」两个请求),异步补齐筛选项
  313. this.loadFilterStatusOptions();
  314. },
  315. methods: {
  316. /** 可见机构范围:只取登录缓存所属机构列表,默认定位到当前登录机构 */
  317. syncOrgScope() {
  318. this.scopedOrgs = getOrgOptions();
  319. this.scopedOrgIds = this.scopedOrgs
  320. .map((item) => (item.id == null ? '' : String(item.id)))
  321. .filter(Boolean);
  322. const currentId = getCurrentOrgId();
  323. this.defaultOrganizationId = currentId == null ? '' : String(currentId);
  324. this.selectedOrganizationId = this.defaultOrganizationId;
  325. },
  326. async bootstrap() {
  327. this.loading = true;
  328. try {
  329. await this.loadOrganizations();
  330. // 工厂字典:列表「所属工厂」列翻译 factoryId 用,就绪后再发列表请求(否则首屏该列全空)
  331. await this.ensureFactoryDictionary();
  332. // 页面加载只发两个请求:组织列表(含 userCount)+ 当前这一页员工列表。
  333. // 「全部员工」人数、左树人数都取组织列表下发的 userCount,前端不再额外统计。
  334. // 表格 :init-load="false":只在这里发唯一一次列表请求(等 orgById 就绪,部门/公司名映射完整)
  335. this.$nextTick(() => this.$refs.table?.reload({ page: 1 }));
  336. } finally {
  337. this.loading = false;
  338. }
  339. },
  340. /**
  341. * 工厂字典(列表「所属工厂」列翻译 factoryId → 名称)。
  342. * 加载失败明确报错,字典缺失时该列留空,不编造名称;下次刷新重试。
  343. */
  344. async ensureFactoryDictionary() {
  345. if (this.factoryDictLoaded || this.exportDictLoaded) return;
  346. try {
  347. const data = await getFactoryAreaPage();
  348. const list = Array.isArray(data) ? data : data?.list || [];
  349. const map = {};
  350. list.forEach((item) => {
  351. if (item?.id == null) return;
  352. map[String(item.id)] = item.name || '';
  353. });
  354. this.factoryById = map;
  355. this.factoryDictLoaded = true;
  356. } catch (error) {
  357. this.$message.error(error?.message || '工厂字典加载失败,所属工厂列暂无数据');
  358. }
  359. },
  360. attachOrgMeta(employee) {
  361. const deptId = employee.deptId || '';
  362. const groupId = employee.groupId || employee.legalEntityId || '';
  363. const deptOrg = this.orgById[String(deptId || '')];
  364. const groupOrg = this.orgById[String(groupId || '')];
  365. let company = employee.company || employee.legalEntity || '';
  366. // profile 常只回 groupId、不回 groupName:用组织表匹配机构名称
  367. if ((!company || /^\d+$/.test(String(company))) && groupOrg?.name) {
  368. company = groupOrg.name;
  369. }
  370. if (!company && deptOrg) {
  371. let node = deptOrg;
  372. const seen = new Set();
  373. while (node && !seen.has(String(node.id))) {
  374. seen.add(String(node.id));
  375. if ([10, 20, '10', '20', 'group', 'company'].includes(node.type)) {
  376. company = node.name;
  377. break;
  378. }
  379. const parent = node.parentId != null ? this.orgById[String(node.parentId)] : null;
  380. if (!parent) break;
  381. node = parent;
  382. }
  383. if (!company) company = deptOrg.name || '';
  384. }
  385. return {
  386. ...employee,
  387. groupId: groupId !== '' && groupId != null ? String(groupId) : null,
  388. deptId: deptId !== '' && deptId != null ? String(deptId) : '',
  389. deptName: employee.deptName || deptOrg?.name || '',
  390. // 隶属部门(可多选):deptIds 逐个查组织字典翻译名称;接口没给 id 就留空,不编造
  391. deptNames: (employee.deptIds || [])
  392. .map((id) => this.orgById[String(id)]?.name)
  393. .filter(Boolean)
  394. .join('、'),
  395. // 所属工厂:人员行只带 factoryId,名称按工厂字典翻译(与导出同源口径),查不到留空
  396. factoryName: employee.factoryId != null
  397. ? (this.factoryById[String(employee.factoryId)] ?? '')
  398. : '',
  399. company: company || employee.company || '',
  400. legalEntity: company || employee.legalEntity || ''
  401. };
  402. },
  403. /**
  404. * 加载组织树 + 人数。
  405. * 人数直接用接口返回的 userCount(后端口径),前端不做任何统计、也不逐部门发请求。
  406. * 员工数据变更后调用本方法即可拿到新的人数(接口不接本地缓存)。
  407. */
  408. async loadOrganizations() {
  409. try {
  410. /* 按文档传 currentUserId(当前登录用户 id),让后端按当前用户口径返回组织列表 */
  411. const currentUserId = getCurrentUser()?.id;
  412. const list = await listOrganizationsWithUserCount(
  413. currentUserId != null ? { currentUserId } : {},
  414. );
  415. const records = Array.isArray(list) ? list : [];
  416. const map = {};
  417. records.forEach((item) => { map[String(item.id)] = item; });
  418. this.orgById = map;
  419. // 组织树只保留登录机构(连同其下级部门),避免看到其他机构的人员
  420. this.organizationTree = this.scopeOrganizationTree(buildOrganizationTree(records));
  421. this.$nextTick(() => {
  422. if (this.selectedOrganizationId) {
  423. this.$refs.organizationTree?.setCurrentKey(this.selectedOrganizationId);
  424. }
  425. });
  426. } catch (error) {
  427. this.organizationTree = [];
  428. this.orgById = {};
  429. this.$message.error(error.message || '组织架构加载失败');
  430. }
  431. },
  432. /** 裁剪组织树:只保留登录缓存所属机构节点(命中节点连同其子树整体保留) */
  433. scopeOrganizationTree(tree) {
  434. const ids = this.scopedOrgIds || [];
  435. if (!ids.length) return [];
  436. const scoped = [];
  437. const walk = (nodes) => {
  438. (nodes || []).forEach((node) => {
  439. if (ids.includes(String(node.id))) {
  440. scoped.push(node);
  441. return;
  442. }
  443. walk(node.children || []);
  444. });
  445. };
  446. walk(tree || []);
  447. /* 2026-10-23 用户指出:树里出现「前桥部/研发部」等接口不存在的节点 ——
  448. 原来这里用登录缓存(scopedOrgs)补接口缺失的节点,属于前端兜底拼数据,
  449. 已删除:树只展示 getGroupListWithUserCount 实际返回的组织 */
  450. return scoped;
  451. },
  452. /**
  453. * 员工数据变更后刷新组织列表:人数(userCount)随之更新。
  454. * 「全部员工」总人数 = 登录机构节点的 userCount,跟着本方法一起刷新,不需要额外请求。
  455. */
  456. async refreshOrganizations() {
  457. // 机构范围可能变了,工号池下次开弹窗重新拉
  458. this.employeeNumbersLoaded = false;
  459. await this.loadOrganizations();
  460. },
  461. /**
  462. * 工号池(「员工编号已存在」查重 + 自动生成员工编号的数据源)。
  463. * 只有新增 / 编辑弹窗用得到,所以在弹窗打开时按需拉取,页面加载不发这个全量请求。
  464. * 失败必须明确提示:静默失败会让编号查重悄悄失效。
  465. */
  466. async ensureEmployeeNumbers() {
  467. if (this.employeeNumbersLoaded) return;
  468. this.employeeNumbersLoaded = true;
  469. try {
  470. const all = await fetchAllUsers(
  471. { ...this.loginOrgParams, isQueryLZ: 0 },
  472. { pageSize: 500, maxPages: 30 },
  473. );
  474. const numbers = all.list.map((item) => item.jobNumber).filter(Boolean);
  475. this.existingNumbers = Array.from(new Set([...this.existingNumbers, ...numbers]));
  476. } catch (error) {
  477. // 允许下次重新尝试
  478. this.employeeNumbersLoaded = false;
  479. this.$message.warning(error?.message || '员工编号数据加载失败,新增时无法自动校验编号是否重复');
  480. }
  481. },
  482. needsClientFilter() {
  483. // 状态筛选已改走接口(字典值当 status 参数),不再需要前端全量拉取后过滤
  484. const { gender, hireRange, keyword } = this.appliedFilters;
  485. return Boolean(
  486. gender
  487. || hireRange?.length
  488. || (keyword && keyword.trim())
  489. );
  490. },
  491. /** 当前可见机构 id:员工数据的默认查询范围(登录机构,未选中时回落到第一个) */
  492. scopeGroupId() {
  493. return (
  494. this.selectedOrganizationId ||
  495. this.defaultOrganizationId ||
  496. this.scopedOrgIds[0] ||
  497. ''
  498. );
  499. },
  500. /** 机构查询参数:所有员工取数都带上,避免返回其他机构的人 */
  501. scopeParams() {
  502. const groupId = this.scopeGroupId();
  503. return groupId ? { groupId } : {};
  504. },
  505. buildBaseParams() {
  506. const { status } = this.appliedFilters;
  507. const params = {
  508. ...this.scopeParams(),
  509. // isQueryLZ=1 才会返回离职员工:选中字典里的「离职」项时必须带上,否则筛出来是空的
  510. isQueryLZ: this.isLeaveStatusValue(status) ? 1 : 0
  511. };
  512. // 状态直接用字典值当接口参数(后端 status,string)
  513. if (status !== '' && status != null) {
  514. params.status = status;
  515. }
  516. return params;
  517. },
  518. /** 该筛选值是否「离职」状态(字典码 8 或文案含「离职」),用于决定 isQueryLZ。
  519. * ★ 字典实测码空间:1=正式在职、6=试用期、7=待岗、8=已离职 —— 6 不是离职。 */
  520. isLeaveStatusValue(value) {
  521. if (value === '' || value == null) return false;
  522. if (String(value) === '8') return true;
  523. const label = this.filterStatusOptions.find((item) => String(item.value) === String(value))?.label || '';
  524. return label.includes('离职');
  525. },
  526. /** 「状态」筛选项:hr_user_status 字典;拉不到就留空并提示(不做内置兜底) */
  527. async loadFilterStatusOptions() {
  528. try {
  529. this.filterStatusOptions = await loadEmployeeStatusOptions();
  530. } catch (error) {
  531. this.filterStatusOptions = [];
  532. console.error('[员工档案] 员工状态字典加载失败:', error);
  533. this.$message.error(error.message || '员工状态字典加载失败');
  534. }
  535. },
  536. applyClientFilters(rows) {
  537. // 状态筛选由接口按字典值过滤(见 buildBaseParams),此处只处理性别 / 入职区间 / 关键词
  538. const { gender, hireRange, keyword } = this.appliedFilters;
  539. const normalized = String(keyword || '').trim().toLowerCase();
  540. return rows.filter((employee) => {
  541. // 性别按接口码比较(后端码优先,兼容接口已转好的中文);没有性别值的行不属于任何性别筛选
  542. if (gender && this.resolveGenderCode(employee) !== String(gender)) return false;
  543. if (hireRange?.length) {
  544. if (!employee.hireDate || employee.hireDate < hireRange[0] || employee.hireDate > hireRange[1]) return false;
  545. }
  546. if (normalized) {
  547. const haystack = [employee.name, employee.employeeNo, employee.phone, employee.position, employee.deptName, employee.email]
  548. .join(' ')
  549. .toLowerCase();
  550. if (!haystack.includes(normalized)) return false;
  551. }
  552. return true;
  553. });
  554. },
  555. async fetchPage({ page, limit }) {
  556. try {
  557. const base = this.buildBaseParams();
  558. const { keyword } = this.appliedFilters;
  559. const clientMode = this.needsClientFilter();
  560. if (clientMode) {
  561. let rawList = [];
  562. if (keyword?.trim()) {
  563. // 关键词:先并行字段检索,再全量兜底过滤岗位等字段
  564. const searched = await searchUsersByKeyword(keyword.trim(), {
  565. ...base,
  566. pageNum: 1,
  567. size: 200
  568. });
  569. rawList = Array.isArray(searched?.list) ? searched.list : [];
  570. if (!rawList.length) {
  571. const all = await fetchAllUsers(base, { pageSize: 200, maxPages: 30 });
  572. rawList = all.list;
  573. }
  574. } else {
  575. const all = await fetchAllUsers(base, { pageSize: 200, maxPages: 30 });
  576. rawList = all.list;
  577. }
  578. let list = rawList.map((item) => this.attachOrgMeta(adaptEmployee(item)));
  579. list = this.applyClientFilters(list);
  580. this.tableTotal = list.length;
  581. this.existingNumbers = Array.from(
  582. new Set([...this.existingNumbers, ...list.map((item) => item.employeeNo).filter(Boolean)])
  583. );
  584. const start = (page - 1) * limit;
  585. return { list: list.slice(start, start + limit), count: list.length };
  586. }
  587. const data = await getUserPage({
  588. ...base,
  589. pageNum: page,
  590. size: limit
  591. });
  592. const list = (Array.isArray(data?.list) ? data.list : []).map((item) => this.attachOrgMeta(adaptEmployee(item)));
  593. this.tableTotal = Number(data?.count ?? list.length);
  594. this.existingNumbers = Array.from(
  595. new Set([...this.existingNumbers, ...list.map((item) => item.employeeNo).filter(Boolean)])
  596. );
  597. return { list, count: this.tableTotal };
  598. } catch (error) {
  599. this.tableTotal = 0;
  600. this.$message.error(error.message || '员工列表加载失败');
  601. return { list: [], count: 0 };
  602. }
  603. },
  604. /**
  605. * 「状态」列的 tag 颜色:文案由接口 statusName 给,颜色按文案关键词匹配(沿用原四态配色)。
  606. * 未命中任何关键词时用默认灰(info),不猜状态语义。
  607. */
  608. statusNameTagType(name) {
  609. const text = String(name || '');
  610. if (text.includes('离职')) return 'info';
  611. if (text.includes('试用')) return 'warning';
  612. if (text.includes('待岗')) return 'danger';
  613. if (text.includes('在职') || text.includes('正式')) return 'success';
  614. return 'info';
  615. },
  616. /** 「员工状态」筛选值的文案:入参是 hr_user_status 字典值 */
  617. filterStatusLabel(value) {
  618. const hit = this.filterStatusOptions.find((item) => String(item.value) === String(value));
  619. return hit ? hit.label : String(value);
  620. },
  621. /** 行的性别码:后端原始码优先(adaptEmployee 保留了 raw.sex),其次接口转好的中文文案 */
  622. resolveGenderCode(employee = {}) {
  623. const rawSex = employee.raw?.sex ?? employee.sex;
  624. if (rawSex != null && rawSex !== '') return normalizeGender(rawSex);
  625. return normalizeGender(employee.gender);
  626. },
  627. /** 「性别」筛选值的文案:入参是接口码(1 / 2 / 0) */
  628. genderFilterLabel(value) {
  629. const hit = GENDER_OPTIONS.find((item) => item.value === String(value));
  630. return hit ? hit.label : String(value);
  631. },
  632. isCompanyNode(data) {
  633. return [10, 20, '10', '20', 'group', 'company'].includes(data.type);
  634. },
  635. employeeAvatarStyle(employee) {
  636. const tones = ['#1890ff', '#13a471', '#8b5cf6', '#d97706', '#dc5064'];
  637. const code = [...(employee.name || '')].reduce((sum, char) => sum + char.charCodeAt(0), 0);
  638. return { '--avatar-color': tones[code % tones.length] };
  639. },
  640. filterTreeNode(value, data) {
  641. return !value || String(data.name || '').toLowerCase().includes(value.toLowerCase());
  642. },
  643. selectOrganization(data) {
  644. // 只筛右侧列表:左树人数由组织列表接口下发的 userCount 决定,与选中项无关,这里不重拉
  645. this.selectedOrganizationId = data.id;
  646. this.$refs.table?.reload({ page: 1 });
  647. },
  648. selectAllOrganizations() {
  649. // 可见范围仅限登录机构:这里回到当前登录机构,而不是查全部机构的人
  650. const nextOrg = this.defaultOrganizationId || this.scopedOrgIds[0] || '';
  651. this.selectedOrganizationId = nextOrg;
  652. this.$refs.organizationTree?.setCurrentKey(nextOrg || null);
  653. this.$refs.table?.reload({ page: 1 });
  654. },
  655. applyFilters() {
  656. this.appliedFilters = {
  657. ...this.filters,
  658. hireRange: this.filters.hireRange ? [...this.filters.hireRange] : []
  659. };
  660. this.$refs.table?.reload({ page: 1 });
  661. },
  662. resetFilters() {
  663. this.filters = { keyword: '', status: '', gender: '', hireRange: [] };
  664. this.appliedFilters = { keyword: '', status: '', gender: '', hireRange: [] };
  665. this.selectAllOrganizations();
  666. },
  667. clearFilterTag(key) {
  668. if (key === 'organization') this.selectAllOrganizations();
  669. else {
  670. this.filters[key] = key === 'hireRange' ? [] : '';
  671. this.appliedFilters[key] = key === 'hireRange' ? [] : '';
  672. this.$refs.table?.reload({ page: 1 });
  673. }
  674. },
  675. openCreate() {
  676. this.editingEmployee = null;
  677. this.formVisible = true;
  678. // 弹窗里要用工号池做查重 / 自动生成编号,此时才拉
  679. this.ensureEmployeeNumbers();
  680. },
  681. async openEdit(employee) {
  682. try {
  683. this.loading = true;
  684. const detail = await this.loadEmployeeDetail(employee.id);
  685. this.editingEmployee = {
  686. ...detail,
  687. /* ⚠ 不要在这里把 status 覆写成中文(旧写法 status: detail.formStatus)。
  688. 表单的员工状态只看后端码 statusCode,下拉 value 是字典里的同一套数字码
  689. (实测 1=正式在职、6=试用期、7=待岗、8=已离职),
  690. 覆写成中文会让值空间对不上;中文文案由字典按码派生。 */
  691. // 列表已有文案时兜底,避免详情缺 postName / joinDate 导致岗位回显成裸 ID、入职时间为空
  692. position: detail.position || employee.position || '',
  693. hireDate: detail.hireDate || employee.hireDate || '',
  694. };
  695. this.formVisible = true;
  696. // 弹窗里要用工号池做查重,此时才拉
  697. this.ensureEmployeeNumbers();
  698. } catch (error) {
  699. this.$message.error(error.message || '员工详情加载失败');
  700. } finally {
  701. this.loading = false;
  702. }
  703. },
  704. editFromDetail(employee) {
  705. this.detailVisible = false;
  706. this.$nextTick(() => this.openEdit(employee));
  707. },
  708. async openDetail(employee) {
  709. if (!employee?.id) return;
  710. try {
  711. this.loading = true;
  712. this.selectedEmployee = await this.loadEmployeeDetail(employee.id);
  713. this.detailVisible = true;
  714. } catch (error) {
  715. this.$message.error(error.message || '员工详情加载失败');
  716. } finally {
  717. this.loading = false;
  718. }
  719. },
  720. async loadEmployeeDetail(id) {
  721. const profile = await getEmployeeProfile(id);
  722. return this.attachOrgMeta(adaptEmployeeProfile(profile || {}));
  723. },
  724. async saveEmployee(payload, done) {
  725. const finish = typeof done === 'function' ? done : () => { };
  726. try {
  727. const apiPayload = serializeEmployee({
  728. ...payload,
  729. formStatus: payload.status
  730. });
  731. const result = await saveEmployeeApi(apiPayload);
  732. let savedId = (result && result.id) || payload.id || null;
  733. // 先结束表单 loading / 允许关窗,后续刷新不阻塞交互
  734. this.$message.success(payload.id ? `已更新 ${payload.name} 的员工档案` : `员工 ${payload.name} 已保存`);
  735. this.formVisible = false;
  736. finish(null);
  737. if (!savedId && payload.employeeNo) {
  738. try {
  739. const page = await getUserPage({ pageNum: 1, size: 5, jobNumber: payload.employeeNo });
  740. savedId = page?.list?.[0]?.id || null;
  741. } catch (error) {
  742. // 回查失败不影响主保存成功
  743. }
  744. }
  745. if (savedId && Array.isArray(payload.previousJobs)) {
  746. try {
  747. const existing = await listWorkExperiences(savedId).catch(() => []);
  748. await syncWorkExperiences(savedId, payload.previousJobs, Array.isArray(existing) ? existing : []);
  749. } catch (error) {
  750. this.$message.warning(error.message || '基本信息已保存,工作履历同步失败');
  751. }
  752. }
  753. this.refreshOrganizations()
  754. .then(() => this.$refs.table?.reload())
  755. .catch(() => this.$refs.table?.reload());
  756. if (!payload.id && savedId) {
  757. this.$nextTick(() => this.openDetail({ id: savedId }));
  758. } else if (payload.id && this.selectedEmployee?.id === payload.id) {
  759. this.loadEmployeeDetail(payload.id)
  760. .then((detail) => { this.selectedEmployee = detail; })
  761. .catch(() => { });
  762. }
  763. } catch (error) {
  764. if (!error?.__toastShown) {
  765. this.$message.error(error?.message || '员工保存失败');
  766. }
  767. // 失败必须回调,否则子表单会一直 await 导致按钮/弹窗卡死
  768. finish(error || new Error('员工保存失败'));
  769. }
  770. },
  771. async handleImportCommand(command) {
  772. if (command === 'template') {
  773. // 「下载导入模板」= 本地按《员工档案导入模板.xlsx》生成(28 列 + 样例行),
  774. // 不再调后端 downLoadTemplate(后端下发的是旧 22 列,和页面导出/回导口径对不上)
  775. try {
  776. const fileName = downloadEmployeeTemplate(`员工档案导入模板_${new Date().toISOString().slice(0, 10)}.xlsx`);
  777. this.$message.success(`模板已开始下载:${fileName}`);
  778. } catch (error) {
  779. this.$message.error(error.message || '模板下载失败');
  780. }
  781. return;
  782. }
  783. this.$refs.importInput?.click();
  784. },
  785. async onImportFile(event) {
  786. const file = event.target.files?.[0];
  787. event.target.value = '';
  788. if (!file) return;
  789. try {
  790. this.loading = true;
  791. // 导入走后端「按 HR 任职链路导入」接口,返回 message 时原样展示(如「成功 10 条,失败 2 条」),
  792. // 不吞后端的统计/失败原因;没有 message 才用通用成功文案
  793. const result = await importUsers(file);
  794. const tip = typeof result === 'string' && result.trim() ? result.trim() : '导入成功';
  795. this.$message.success(tip);
  796. await this.refreshOrganizations();
  797. this.$refs.table?.reload({ page: 1 });
  798. } catch (error) {
  799. this.$message.error(error.message || '导入失败');
  800. } finally {
  801. this.loading = false;
  802. }
  803. },
  804. /**
  805. * 导出用字典:人员行只带 factoryId / teamId,工厂名与班组名必须查字典。
  806. * 只在导出时按需拉一次并缓存(页面加载不发)。
  807. * 任一字典失败 → 直接报错中止导出:宁可不导,也不产出「工厂/班组整列空白」的错文件。
  808. */
  809. async ensureExportDictionaries() {
  810. if (this.exportDictLoaded) return;
  811. // 工厂字典可能已被列表页拉过(factoryDictLoaded),避免重复请求
  812. const [factoryList, teamList] = await Promise.all([
  813. this.factoryDictLoaded
  814. ? Promise.resolve([])
  815. : getFactoryAreaPage().catch((error) => {
  816. throw new Error(`工厂字典加载失败:${error?.message || '未知错误'}`);
  817. }),
  818. listTeams().catch((error) => {
  819. throw new Error(`班组字典加载失败:${error?.message || '未知错误'}`);
  820. })
  821. ]);
  822. if (!this.factoryDictLoaded) {
  823. const factoryById = {};
  824. (Array.isArray(factoryList) ? factoryList : factoryList?.list || []).forEach((item) => {
  825. if (item?.id == null) return;
  826. factoryById[String(item.id)] = item.name || '';
  827. });
  828. this.factoryById = factoryById;
  829. this.factoryDictLoaded = true;
  830. }
  831. const teamById = {};
  832. (Array.isArray(teamList) ? teamList : teamList?.list || []).forEach((item) => {
  833. if (item?.id == null) return;
  834. teamById[String(item.id)] = item.name || '';
  835. });
  836. this.teamById = teamById;
  837. this.exportDictLoaded = true;
  838. },
  839. /** 导出员工:列 = 《员工档案导入模板.xlsx》列(真实 .xlsx,可直接回导) */
  840. async exportRowsAsTemplate(rows, fileName) {
  841. await this.ensureExportDictionaries();
  842. return writeEmployeeTemplateXlsx(
  843. rows,
  844. { orgById: this.orgById, factoryById: this.factoryById, teamById: this.teamById },
  845. fileName
  846. );
  847. },
  848. async handleExport(type) {
  849. const today = new Date().toISOString().slice(0, 10);
  850. if (type === 'selected') {
  851. if (!this.selectedRows.length) {
  852. this.$message.warning('请先选择要导出的员工');
  853. return;
  854. }
  855. try {
  856. this.loading = true;
  857. const count = await this.exportRowsAsTemplate(
  858. this.selectedRows,
  859. `员工档案_已选_${today}.xlsx`
  860. );
  861. this.$message.success(`已导出 ${count} 条`);
  862. } catch (error) {
  863. this.$message.error(error.message || '导出失败');
  864. } finally {
  865. this.loading = false;
  866. }
  867. return;
  868. }
  869. // 「导出当前结果」必须与页面所见完全一致:关键词/性别/入职日期等筛选只有前端口径,
  870. // 服务端导出不认这些条件,直接调会把没筛选过的数据导出去(表格信息对不上)→ 本地按模板列导出
  871. try {
  872. this.loading = true;
  873. const total = Math.max(Number(this.tableTotal) || 0, 200);
  874. const result = await this.fetchPage({ page: 1, limit: total });
  875. const rows = result?.list || [];
  876. if (!rows.length) {
  877. this.$message.warning('当前没有可导出的员工数据');
  878. return;
  879. }
  880. const count = await this.exportRowsAsTemplate(
  881. rows,
  882. `员工档案_当前结果_${today}.xlsx`
  883. );
  884. this.$message.success(`已导出 ${count} 条`);
  885. } catch (error) {
  886. this.$message.error(error.message || '导出失败');
  887. } finally {
  888. this.loading = false;
  889. }
  890. }
  891. }
  892. };
  893. </script>
  894. <style src="../../../styles/views/employeeArchive/index.scss" lang="scss" scoped></style>