feat(hrm): 新增「我的薪资」页面,员工自助查看工资条
配套后端 my-page / my-get 接口。
新增:
- api/hrm/salary/calculation:getMySalaryCalculationPage / getMySalaryCalculation
- views/hrm/salary/my-payslip:列表页 + 工资条明细弹窗
* 列表展示:薪资周期、应发合计、扣款合计、实发工资、出勤、加班、状态
* 明细弹窗按工资条样式分组:收入项 / 扣除项 / 出勤情况 / 实发工资(大字突出)
说明:页面不含任何员工选择类筛选条件,数据范围完全由后端按登录人限定。
| | |
| | | ); |
| | | } |
| | | |
| | | /** æ¥è¯¢æçèªèµå页å表ï¼åªè½çå°èªå·±çè®°å½ï¼åç«¯å¼ºå¶æç»å½äººè¿æ»¤ï¼ */ |
| | | 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>( |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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, |
| | | }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |