index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. <template>
  2. <div class="ele-body salary-field-page">
  3. <el-card shadow="never">
  4. <!-- 表头标题 -->
  5. <div class="salary-field-header">
  6. <div class="salary-field-title">
  7. <span class="title-bar"></span>
  8. <span class="title-text">薪资项目列表</span>
  9. </div>
  10. </div>
  11. <!-- 五个方向的归属说明(Y13:让 HR 知道每个方向谁在用、谁生成) -->
  12. <div class="direction-legend">
  13. <el-tooltip v-for="item in directionMeta" :key="item.value" placement="top" effect="light"
  14. :content="item.hint" :open-delay="200">
  15. <div class="direction-chip" :class="item.configurable ? 'is-configurable' : 'is-system'">
  16. <span class="chip-label">{{ item.label }}</span>
  17. <span class="chip-owner">{{ item.configurable ? '模板可配' : '系统生成' }}</span>
  18. </div>
  19. </el-tooltip>
  20. </div>
  21. <!-- 搜索表单:公司、项目编码、名称、方向、状态 -->
  22. <field-search @search="reload" />
  23. <!-- 数据表格 -->
  24. <ele-pro-table ref="table" :columns="columns" :datasource="datasource" :selection.sync="selection" row-key="id"
  25. :page-size="pageSize" @columns-change="handleColumnChange" :cache-key="cacheKeyUrl">
  26. <!-- 表头工具栏 -->
  27. <template v-slot:toolbar>
  28. <el-button type="primary" icon="el-icon-plus" class="ele-btn-icon" @click="openEdit()">
  29. 新增
  30. </el-button>
  31. <!-- C17:批量删除接口已就绪,勾选后出现批量删除入口 -->
  32. <el-button v-if="SALARY_ITEM_BATCH_DELETE_READY" type="danger" icon="el-icon-delete"
  33. class="ele-btn-icon" :disabled="!selection.length" @click="removeSelected">
  34. 批量删除{{ selection.length ? `(${selection.length})` : '' }}
  35. </el-button>
  36. </template>
  37. <!-- 是否启用列 -->
  38. <template v-slot:enabled="{ row }">
  39. <el-switch v-model="row.enabled" :active-value="true" :inactive-value="false"
  40. @change="(val) => handleToggleStatus(row, val)" />
  41. </template>
  42. <!-- 项目编码列:点击打开只读详情(列表统一约定 —— 编号列即详情入口) -->
  43. <template v-slot:itemCode="{ row }">
  44. <el-link v-if="row.itemCode" type="primary" :underline="false" @click="openView(row)">
  45. {{ row.itemCode }}
  46. </el-link>
  47. <span v-else>—</span>
  48. </template>
  49. <!-- 来源列 -->
  50. <template v-slot:source="{ row }">
  51. {{ formatSource(row) }}
  52. </template>
  53. <!-- 操作列 -->
  54. <template v-slot:action="{ row }">
  55. <el-link type="primary" :underline="false" icon="el-icon-edit" @click="openEdit(row)">
  56. 编辑
  57. </el-link>
  58. <el-popconfirm class="ele-action" title="确定要删除当前薪资项目吗?删除后不可恢复" @confirm="remove(row)">
  59. <template v-slot:reference>
  60. <el-link type="danger" :underline="false" icon="el-icon-delete">
  61. 删除
  62. </el-link>
  63. </template>
  64. </el-popconfirm>
  65. </template>
  66. <template slot="empty">
  67. <el-empty description="暂无数据" />
  68. </template>
  69. </ele-pro-table>
  70. </el-card>
  71. <!-- 编辑 / 查看弹窗(同一组件,is-view 切换只读态) -->
  72. <field-edit :visible.sync="showEdit" :data="currentRow" :is-view="viewMode" @done="reload" />
  73. </div>
  74. </template>
  75. <script>
  76. import tabMixins from '@/mixins/tableColumnsMixin';
  77. import { getCurrentOrgId } from '@/utils/current-org';
  78. import FieldEdit from '../components/field-edit.vue';
  79. import FieldSearch from '../components/field-search.vue';
  80. import {
  81. SALARY_ITEM_TYPE_OPTIONS,
  82. SALARY_ITEM_DIRECTION_META,
  83. SALARY_ITEM_BATCH_DELETE_READY,
  84. salaryItemTypeLabel,
  85. getSalaryFieldPageList,
  86. deleteSalaryField,
  87. deleteSalaryFields,
  88. toggleSalaryFieldStatus
  89. } from '@/api/salary/fieldSetting';
  90. export default {
  91. name: 'SalaryFieldSetting',
  92. components: {
  93. FieldEdit,
  94. FieldSearch
  95. },
  96. mixins: [tabMixins],
  97. data() {
  98. return {
  99. searchParams: {},
  100. currentRow: null,
  101. showEdit: false,
  102. /* 弹框只读态:点「项目编码」查看时为 true,点「编辑」/「新增项目」时为 false */
  103. viewMode: false,
  104. pageSize: this.$store.state.tablePageSize || 20,
  105. cacheKeyUrl: 'salary-field-setting',
  106. directionMeta: SALARY_ITEM_DIRECTION_META,
  107. /* C17:批量删除接口就绪标记(模板里据此显示勾选列与批量删除按钮) */
  108. SALARY_ITEM_BATCH_DELETE_READY,
  109. columns: [
  110. /*
  111. * C17:批量删除接口 `POST /hr/payroll/items/batch-delete` 已就绪,
  112. * 多选列渲染出来,配合工具栏的「批量删除」。若后端回退该接口,
  113. * 只需把 SALARY_ITEM_BATCH_DELETE_READY 置 false,勾选列与按钮会一起消失。
  114. */
  115. ...(SALARY_ITEM_BATCH_DELETE_READY
  116. ? [
  117. {
  118. columnKey: 'selection',
  119. type: 'selection',
  120. width: 45,
  121. align: 'center',
  122. fixed: 'left'
  123. }
  124. ]
  125. : []),
  126. {
  127. label: '序号',
  128. type: 'index',
  129. width: 55,
  130. align: 'center'
  131. },
  132. {
  133. prop: 'itemCode',
  134. label: '项目编码',
  135. showOverflowTooltip: true,
  136. align: 'center',
  137. minWidth: 130,
  138. /* 点编码开只读详情(替代操作列「查看」入口) */
  139. slot: 'itemCode'
  140. },
  141. {
  142. prop: 'fieldName',
  143. label: '项目名称',
  144. showOverflowTooltip: true,
  145. align: 'center',
  146. minWidth: 140
  147. },
  148. {
  149. prop: 'itemType',
  150. label: '项目方向',
  151. width: 100,
  152. align: 'center',
  153. formatter: (row) => this.formatItemType(row)
  154. },
  155. {
  156. prop: 'fieldHint',
  157. label: '项目备注',
  158. showOverflowTooltip: true,
  159. align: 'center',
  160. minWidth: 160
  161. },
  162. {
  163. columnKey: 'enabled',
  164. label: '是否启用',
  165. width: 110,
  166. align: 'center',
  167. slot: 'enabled'
  168. },
  169. {
  170. columnKey: 'source',
  171. label: '来源',
  172. width: 130,
  173. align: 'center',
  174. slot: 'source'
  175. },
  176. {
  177. prop: 'createUserName',
  178. label: '创建人',
  179. showOverflowTooltip: true,
  180. align: 'center',
  181. minWidth: 140
  182. },
  183. {
  184. prop: 'createTime',
  185. label: '创建时间',
  186. align: 'left',
  187. minWidth: 90
  188. },
  189. {
  190. prop: 'updateUserName',
  191. label: '修改人',
  192. showOverflowTooltip: true,
  193. align: 'center',
  194. minWidth: 140
  195. },
  196. {
  197. prop: 'updateTime',
  198. label: '修改时间',
  199. align: 'left',
  200. minWidth: 90
  201. },
  202. {
  203. columnKey: 'action',
  204. label: '操作',
  205. width: 160,
  206. align: 'center',
  207. resizable: false,
  208. slot: 'action',
  209. showOverflowTooltip: true
  210. }
  211. ],
  212. selection: []
  213. };
  214. },
  215. methods: {
  216. /* 表格数据源:合并搜索表单条件,保证首屏请求也带着默认所属公司 */
  217. async datasource({ page, limit, where, order }) {
  218. const params = { ...this.searchParams, ...where, ...order };
  219. /*
  220. * F11:companyId 兜底。首屏请求有可能早于搜索表单的首次 emit(组件挂载顺序),
  221. * 此时 searchParams 里还没有 companyId,接口会因「查询薪资项目必须指定所属机构」直接失败。
  222. * 只在**从未设置过**(undefined)时兜底;用户主动清空('' = 查询全部授权法人)时保持原样 ——
  223. * 那是一个有意义的筛选值,不能被兜底覆盖掉。
  224. */
  225. if (params.companyId === undefined) {
  226. params.companyId = getCurrentOrgId() || '';
  227. }
  228. return await getSalaryFieldPageList({
  229. ...params,
  230. pageNum: page,
  231. size: limit
  232. });
  233. },
  234. /* 刷新表格 */
  235. reload(where) {
  236. // 搜索表单在 created 阶段就会带默认所属公司 emit 一次,此时表格实例还没挂载
  237. // ($refs.table 为空),这里先只记下条件,交给表格首次加载读 searchParams,
  238. // 否则首屏请求会漏掉 companyId
  239. if (where) this.searchParams = where;
  240. if (this.$refs.table) {
  241. this.$refs.table.reload({ page: 1, where: this.searchParams });
  242. }
  243. },
  244. /* 打开编辑弹窗(新增 / 编辑,均为可写态) */
  245. openEdit(row) {
  246. this.viewMode = false;
  247. this.currentRow = row || null;
  248. this.showEdit = true;
  249. },
  250. /*
  251. * 打开只读详情(点「项目编码」触发)。
  252. * 直接复用 field-edit 的 isView 只读态:表单整体 disabled、隐藏「套用推荐属性」、
  253. * 底部只留「关闭」,不新增第二个弹框组件。
  254. * 行数据来自列表接口,含 itemType / dataType / 四个属性 / sourceType,足够回显,无需再取详情。
  255. */
  256. openView(row) {
  257. this.viewMode = true;
  258. this.currentRow = row || null;
  259. this.showEdit = true;
  260. },
  261. /* 来源格式化(后端枚举 → 业务文案;兼容后端直接回中文的旧值) */
  262. formatSource(row) {
  263. const source = String(row.source || '').trim();
  264. const key = source.toUpperCase();
  265. if (key === 'MANUAL' || source === '手动添加' || source === '手工添加') {
  266. return '手动添加';
  267. }
  268. if (key === 'SYSTEM' || source === '系统内置') return '系统内置';
  269. if (key === 'IMPORT' || source === '导入') return '导入';
  270. if (key === 'API' || source === '接口' || source === '接口同步') {
  271. return '系统生成';
  272. }
  273. return source || '手动添加';
  274. },
  275. /* 项目方向格式化(枚举与后端 itemType 一致,未识别取值原样显示) */
  276. formatItemType(row) {
  277. return salaryItemTypeLabel(row.itemType);
  278. },
  279. /* 启用/停用 */
  280. async handleToggleStatus(row, val) {
  281. try {
  282. await toggleSalaryFieldStatus(row.id, val);
  283. this.$message.success(val ? '已启用' : '已停用');
  284. } catch (error) {
  285. // 还原开关状态
  286. row.enabled = !val;
  287. this.$message.error(error.message || '操作失败');
  288. }
  289. },
  290. /* 删除(单条)。
  291. P2-22 ②:批量删除入口等后端 `POST /hr/payroll/items/batch-delete`(需求 C17)。
  292. API 层已经不再吞掉失败原因 —— 删除失败会抛带明细的错误,这里原样展示(长文案加长驻留)。*/
  293. async remove(row) {
  294. const loading = this.$loading({ lock: true });
  295. try {
  296. const msg = await deleteSalaryField([row.id]);
  297. loading.close();
  298. this.$message.success('删除' + (msg || '成功'));
  299. this.reload();
  300. } catch (error) {
  301. loading.close();
  302. this.$message({
  303. type: 'error',
  304. message: error.message || '删除失败',
  305. showClose: true,
  306. duration: 8000
  307. });
  308. }
  309. },
  310. /**
  311. * C17:批量删除(勾选行)。
  312. * 走 `POST /hr/payroll/items/batch-delete`(单事务,最多 200/次,超出自动分片)。
  313. * 契约会给每个失败项稳定错误码,这里把「成功几条 / 失败几条 + 逐条原因」完整摆出来,
  314. * 不再出现「删了一半还提示成功」。
  315. */
  316. async removeSelected() {
  317. const rows = this.selection || [];
  318. if (!rows.length) {
  319. this.$message.warning('请先勾选要删除的薪资项目');
  320. return;
  321. }
  322. const reasons = rows
  323. .slice(0, 8)
  324. .map((row) => `${row.itemCode || '-'} ${row.fieldName || ''}`.trim())
  325. .join('、');
  326. try {
  327. await this.$confirm(
  328. `确定删除已勾选的 ${rows.length} 个薪资项目吗?删除后不可恢复。\n${reasons}${
  329. rows.length > 8 ? ' 等' : ''
  330. }`,
  331. '批量删除确认',
  332. { type: 'warning', confirmButtonText: '确定删除', cancelButtonText: '取消' }
  333. );
  334. } catch (error) {
  335. return;
  336. }
  337. const loading = this.$loading({ lock: true });
  338. try {
  339. const result = await deleteSalaryFields(rows.map((row) => row.id));
  340. loading.close();
  341. if (result.allSucceeded) {
  342. this.$message.success(`已删除 ${result.successIds.length} 个项目`);
  343. } else {
  344. const detail = result.failed
  345. .slice(0, 5)
  346. .map((item) => `#${item.id}:${item.reasonText || item.reason}`)
  347. .join(';');
  348. this.$message({
  349. type: 'warning',
  350. showClose: true,
  351. duration: 8000,
  352. message: `成功 ${result.successIds.length} 个,失败 ${result.failed.length} 个(${detail}${
  353. result.failed.length > 5 ? ' 等' : ''
  354. })`
  355. });
  356. }
  357. this.selection = [];
  358. this.reload();
  359. } catch (error) {
  360. loading.close();
  361. this.$message({
  362. type: 'error',
  363. message: error.message || '批量删除失败',
  364. showClose: true,
  365. duration: 8000
  366. });
  367. }
  368. }
  369. }
  370. };
  371. </script>
  372. <style lang="scss" scoped>
  373. .salary-field-page {
  374. .salary-field-header {
  375. display: flex;
  376. align-items: center;
  377. justify-content: space-between;
  378. margin-bottom: 16px;
  379. }
  380. .salary-field-title {
  381. display: flex;
  382. align-items: center;
  383. .title-bar {
  384. display: inline-block;
  385. width: 3px;
  386. height: 16px;
  387. background: var(--color-primary, #1890ff);
  388. border-radius: 2px;
  389. margin-right: 8px;
  390. }
  391. .title-text {
  392. font-size: 16px;
  393. font-weight: 600;
  394. color: #303133;
  395. }
  396. }
  397. /* 五个方向的归属图例 */
  398. .direction-legend {
  399. display: flex;
  400. flex-wrap: wrap;
  401. gap: 10px;
  402. margin-bottom: 14px;
  403. }
  404. .direction-chip {
  405. display: flex;
  406. align-items: center;
  407. gap: 6px;
  408. padding: 4px 10px;
  409. border-radius: 4px;
  410. font-size: 13px;
  411. line-height: 20px;
  412. cursor: default;
  413. &.is-configurable {
  414. background: #ecf5ff;
  415. border: 1px solid #b3d8ff;
  416. .chip-label {
  417. color: #1559c8;
  418. font-weight: 600;
  419. }
  420. .chip-owner {
  421. color: #409eff;
  422. }
  423. }
  424. &.is-system {
  425. background: #f4f4f5;
  426. border: 1px solid #e9e9eb;
  427. .chip-label {
  428. color: #606266;
  429. font-weight: 600;
  430. }
  431. .chip-owner {
  432. color: #909399;
  433. }
  434. }
  435. }
  436. }
  437. </style>