tuqin
2026-10-28 95d06576865f9ea1d84f1562ecbda1115bbbd24a
feat(hrm): 新增「我的薪资」页面,员工自助查看工资条

配套后端 my-page / my-get 接口。

新增:
- api/hrm/salary/calculation:getMySalaryCalculationPage / getMySalaryCalculation
- views/hrm/salary/my-payslip:列表页 + 工资条明细弹窗
* 列表展示:薪资周期、应发合计、扣款合计、实发工资、出勤、加班、状态
* 明细弹窗按工资条样式分组:收入项 / 扣除项 / 出勤情况 / 实发工资(大字突出)

说明:页面不含任何员工选择类筛选条件,数据范围完全由后端按登录人限定。
已添加3个文件
已修改1个文件
379 ■■■■■ 文件已修改
src/api/hrm/salary/calculation/index.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/my-payslip/data.ts 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/my-payslip/index.vue 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/my-payslip/modules/detail.vue 165 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/salary/calculation/index.ts
@@ -71,6 +71,23 @@
  );
}
/** æŸ¥è¯¢æˆ‘的薪资分页列表(只能看到自己的记录,后端强制按登录人过滤) */
export function getMySalaryCalculationPage(
  params: HrmSalaryCalculationApi.SalaryCalculationPageParam,
) {
  return requestClient.get<PageResult<HrmSalaryCalculationApi.SalaryCalculation>>(
    '/hrm/salary/calculation/my-page',
    { params },
  );
}
/** æŸ¥è¯¢æˆ‘的薪资详情 */
export function getMySalaryCalculation(id: number) {
  return requestClient.get<HrmSalaryCalculationApi.SalaryCalculation>(
    `/hrm/salary/calculation/my-get?id=${id}`,
  );
}
/** æŸ¥è¯¢è–ªé…¬æ ¸ç®—详情 */
export function getSalaryCalculation(id: number) {
  return requestClient.get<HrmSalaryCalculationApi.SalaryCalculation>(
src/views/hrm/salary/my-payslip/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmSalaryCalculationApi } from '#/api/hrm/salary/calculation';
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'period',
      label: '薪资周期',
      component: 'DatePicker',
      componentProps: {
        picker: 'month',
        allowClear: true,
        format: 'YYYY-MM',
        valueFormat: 'YYYY-MM',
        style: { width: '100%' },
        placeholder: '请选择薪资周期',
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<HrmSalaryCalculationApi.SalaryCalculation>['columns'] {
  return [
    { field: 'period', title: '薪资周期', width: 110 },
    {
      field: 'totalIncome',
      title: '应发合计',
      width: 130,
      align: 'right',
      formatter: ({ row }: { row: HrmSalaryCalculationApi.SalaryCalculation }) =>
        formatAmount(sumIncome(row)),
    },
    {
      field: 'totalDeduction',
      title: '扣款合计',
      width: 130,
      align: 'right',
      formatter: ({ row }: { row: HrmSalaryCalculationApi.SalaryCalculation }) =>
        formatAmount(sumDeduction(row)),
    },
    {
      field: 'actualSalary',
      title: '实发工资',
      width: 140,
      align: 'right',
      slots: { default: 'actualSalary' },
    },
    { field: 'workDays', title: '出勤天数', width: 100, align: 'right' },
    { field: 'overtimeHours', title: '加班时长', width: 100, align: 'right' },
    {
      field: 'status',
      title: '状态',
      width: 100,
      slots: { default: 'status' },
    },
    {
      title: '操作',
      width: 120,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
/** åº”发收入项合计 */
export function sumIncome(row: HrmSalaryCalculationApi.SalaryCalculation): number {
  return (
    (row.baseSalary ?? 0) +
    (row.performanceSalary ?? 0) +
    (row.overtimePay ?? 0) +
    (row.mealAllowance ?? 0) +
    (row.transportAllowance ?? 0) +
    (row.otherAllowance ?? 0)
  );
}
/** æ‰£æ¬¾é¡¹åˆè®¡ */
export function sumDeduction(row: HrmSalaryCalculationApi.SalaryCalculation): number {
  return (
    (row.socialSecurityDeduction ?? 0) +
    (row.housingFundDeduction ?? 0) +
    (row.taxDeduction ?? 0) +
    (row.otherDeduction ?? 0) +
    (row.leaveDeduction ?? 0)
  );
}
/** é‡‘额格式化(两位小数 + åƒåˆ†ä½ï¼‰ */
export function formatAmount(value?: number): string {
  if (value === null || value === undefined || Number.isNaN(value)) {
    return '-';
  }
  return value.toLocaleString('zh-CN', {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });
}
src/views/hrm/salary/my-payslip/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,97 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmSalaryCalculationApi } from '#/api/hrm/salary/calculation';
import { Page } from '#/packages/effects/common-ui/src';
import { Tag } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getMySalaryCalculationPage } from '#/api/hrm/salary/calculation';
import { useVbenModal } from '@vben/common-ui';
import { formatAmount, useGridColumns, useGridFormSchema } from './data';
import DetailModal from './modules/detail.vue';
defineOptions({ name: 'MyPayslip' });
const [DetailModalComp, detailModalApi] = useVbenModal({
  connectedComponent: DetailModal,
  destroyOnClose: true,
});
const [Grid] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) =>
          await getMySalaryCalculationPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          }),
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<HrmSalaryCalculationApi.SalaryCalculation>,
});
/** æŸ¥çœ‹å·¥èµ„条明细 */
function handleViewPayslip(row: HrmSalaryCalculationApi.SalaryCalculation) {
  detailModalApi.setData({ id: row.id }).open();
}
/** çŠ¶æ€æ–‡æ¡ˆä¸Žé¢œè‰² */
function statusText(status?: number): string {
  if (status === 20) return '已发放';
  if (status === 10) return '已确认';
  return '待确认';
}
function statusColor(status?: number): string {
  if (status === 20) return 'success';
  if (status === 10) return 'processing';
  return 'warning';
}
</script>
<template>
  <Page auto-content-height>
    <DetailModalComp />
    <Grid>
      <template #actualSalary="{ row }">
        <span class="font-semibold text-red-600">
          {{ formatAmount(row.actualSalary) }}
        </span>
      </template>
      <template #status="{ row }">
        <Tag :color="statusColor(row.status)">
          {{ statusText(row.status) }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '查看工资条',
              type: 'link',
              onClick: handleViewPayslip.bind(null, row),
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/hrm/salary/my-payslip/modules/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,165 @@
<script lang="ts" setup>
import type { HrmSalaryCalculationApi } from '#/api/hrm/salary/calculation';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, DescriptionsItem, Spin, Tag } from 'ant-design-vue';
import { getMySalaryCalculation } from '#/api/hrm/salary/calculation';
import { formatAmount, sumDeduction, sumIncome } from '../data';
const detail = ref<HrmSalaryCalculationApi.SalaryCalculation>();
const loading = ref(false);
const totalIncome = computed(() => (detail.value ? sumIncome(detail.value) : 0));
const totalDeduction = computed(() =>
  detail.value ? sumDeduction(detail.value) : 0,
);
const statusText = computed(() => {
  const s = detail.value?.status;
  if (s === 20) return '已发放';
  if (s === 10) return '已确认';
  return '待确认';
});
const statusColor = computed(() => {
  const s = detail.value?.status;
  if (s === 20) return 'success';
  if (s === 10) return 'processing';
  return 'warning';
});
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      detail.value = undefined;
      return;
    }
    const data = modalApi.getData<{ id: number }>();
    if (!data?.id) return;
    loading.value = true;
    try {
      detail.value = await getMySalaryCalculation(data.id);
    } finally {
      loading.value = false;
    }
  },
});
</script>
<template>
  <Modal title="我的工资条" class="w-3/5">
    <Spin :spinning="loading">
      <div v-if="detail" class="mx-4">
        <!-- æŠ¬å¤´ -->
        <div class="mb-4 flex items-center justify-between rounded bg-gray-50 px-4 py-3">
          <div>
            <span class="text-base font-semibold">
              {{ detail.period }} è–ªèµ„
            </span>
            <span v-if="detail.userName" class="ml-2 text-gray-500">
              {{ detail.userName }}
            </span>
            <span v-if="detail.deptName" class="ml-2 text-gray-500">
              / {{ detail.deptName }}
            </span>
          </div>
          <Tag :color="statusColor">{{ statusText }}</Tag>
        </div>
        <!-- æ”¶å…¥é¡¹ -->
        <Descriptions
          :column="3"
          bordered
          size="small"
          title="收入项"
          class="mb-4"
        >
          <DescriptionsItem label="基本工资">
            {{ formatAmount(detail.baseSalary) }}
          </DescriptionsItem>
          <DescriptionsItem label="绩效工资">
            {{ formatAmount(detail.performanceSalary) }}
          </DescriptionsItem>
          <DescriptionsItem label="加班工资">
            {{ formatAmount(detail.overtimePay) }}
          </DescriptionsItem>
          <DescriptionsItem label="餐补">
            {{ formatAmount(detail.mealAllowance) }}
          </DescriptionsItem>
          <DescriptionsItem label="交通补贴">
            {{ formatAmount(detail.transportAllowance) }}
          </DescriptionsItem>
          <DescriptionsItem label="其他补贴">
            {{ formatAmount(detail.otherAllowance) }}
          </DescriptionsItem>
          <DescriptionsItem label="应发合计" :span="3">
            <span class="font-semibold text-blue-600">
              {{ formatAmount(totalIncome) }}
            </span>
          </DescriptionsItem>
        </Descriptions>
        <!-- æ‰£é™¤é¡¹ -->
        <Descriptions
          :column="3"
          bordered
          size="small"
          title="扣除项"
          class="mb-4"
        >
          <DescriptionsItem label="社保扣除">
            {{ formatAmount(detail.socialSecurityDeduction) }}
          </DescriptionsItem>
          <DescriptionsItem label="公积金扣除">
            {{ formatAmount(detail.housingFundDeduction) }}
          </DescriptionsItem>
          <DescriptionsItem label="个税扣除">
            {{ formatAmount(detail.taxDeduction) }}
          </DescriptionsItem>
          <DescriptionsItem label="其他扣除">
            {{ formatAmount(detail.otherDeduction) }}
          </DescriptionsItem>
          <DescriptionsItem label="请假扣款">
            {{ formatAmount(detail.leaveDeduction) }}
          </DescriptionsItem>
          <DescriptionsItem label="扣款合计">
            <span class="font-semibold text-orange-600">
              {{ formatAmount(totalDeduction) }}
            </span>
          </DescriptionsItem>
        </Descriptions>
        <!-- å‡ºå‹¤ -->
        <Descriptions :column="3" bordered size="small" title="出勤情况">
          <DescriptionsItem label="出勤天数">
            {{ detail.workDays ?? '-' }}
          </DescriptionsItem>
          <DescriptionsItem label="加班时长">
            {{ detail.overtimeHours ?? '-' }}
          </DescriptionsItem>
          <DescriptionsItem label="排班总工时">
            {{ detail.totalWorkHours ?? '-' }}
          </DescriptionsItem>
        </Descriptions>
        <!-- å®žå‘ -->
        <div
          class="mt-4 flex items-center justify-between rounded bg-red-50 px-4 py-3"
        >
          <span class="text-base font-semibold">实发工资</span>
          <span class="text-xl font-bold text-red-600">
            Â¥ {{ formatAmount(detail.actualSalary) }}
          </span>
        </div>
        <div v-if="detail.remark" class="mt-3 text-gray-500">
          å¤‡æ³¨ï¼š{{ detail.remark }}
        </div>
      </div>
    </Spin>
  </Modal>
</template>