工资发放

This commit is contained in:
zhx 2026-05-21 13:44:23 +08:00
parent 5e51b460f4
commit 710fe5406d

View File

@ -15,13 +15,13 @@ import {
pageManualSalary,
} from '#/api/legacy/operate';
import AccountInput from '#/components/account-input.vue';
import AdminConfigTable from '#/views/_shared/admin-config-table.vue';
import { getAllowedSysOrigins } from '#/views/system/shared';
import {
Button,
Card,
Modal,
Pagination,
Select,
Space,
Table,
@ -33,6 +33,12 @@ import UserProfileLink from './components/user-profile-link.vue';
defineOptions({ name: 'OperateManualSalaryPayment' });
type SalarySortKey =
| 'currentSalaryBalance'
| 'payableSalary'
| 'totalIssuedSalary';
type SalarySortOrder = '' | 'ascend' | 'descend';
const accessStore = useAccessStore();
const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
@ -41,6 +47,7 @@ const sysOriginOptions = computed(() => {
const loading = ref(false);
const paying = ref(false);
const settlingUserId = ref('');
const total = ref(0);
const list = ref<Array<Record<string, any>>>([]);
const countries = ref<Array<Record<string, any>>>([]);
@ -48,11 +55,15 @@ const countryKeyword = ref('');
const resultOpen = ref(false);
const resultRows = ref<Array<Record<string, any>>>([]);
const selectedUserIds = ref<string[]>([]);
const salarySortKey = ref<'' | SalarySortKey>('');
const salarySortOrder = ref<SalarySortOrder>('');
const query = reactive({
countryCode: '',
cursor: 1,
limit: 20,
sortField: '',
sortOrder: '' as SalarySortOrder,
sysOrigin: '',
userId: '',
});
@ -63,9 +74,11 @@ const columns = [
{ dataIndex: 'lastSettlementTarget', key: 'lastSettlementTarget', title: '上次结算的积分', width: 150 },
{ dataIndex: 'currentTarget', key: 'currentTarget', title: '本次积分', width: 130 },
{ dataIndex: 'policyLevel', key: 'policyLevel', title: '本次积分档位', width: 130 },
{ dataIndex: 'payableSalary', key: 'payableSalary', title: '应发工资', width: 120 },
{ dataIndex: 'totalIssuedSalary', key: 'totalIssuedSalary', title: '总发放工资', width: 130 },
{ dataIndex: 'currentSalaryBalance', key: 'currentSalaryBalance', title: '当前工资余额', width: 140 },
{ dataIndex: 'expectedSalary', key: 'expectedSalary', title: '本月应发', width: 120 },
{ dataIndex: 'payableSalary', key: 'payableSalary', title: '应发(应发-实收)', width: 160 },
{ dataIndex: 'totalIssuedSalary', key: 'totalIssuedSalary', title: '总发工资', width: 130 },
{ dataIndex: 'currentSalaryBalance', key: 'currentSalaryBalance', title: '当前工资', width: 140 },
{ dataIndex: 'action', fixed: 'right', key: 'action', title: '操作', width: 110 },
];
const resultColumns = [
@ -77,6 +90,28 @@ const resultColumns = [
{ dataIndex: 'currentTarget', key: 'currentTarget', title: '本次积分', width: 120 },
];
const salarySortableKeys = new Set<SalarySortKey>([
'payableSalary',
'totalIssuedSalary',
'currentSalaryBalance',
]);
const hasMore = computed(() => list.value.length < total.value);
const tableRows = computed(() =>
list.value.map((item) => ({
...item,
rowKey: getRowKey(item),
})),
);
const summaryText = computed(() => {
if (total.value <= 0) {
return list.value.length > 0 ? `已加载 ${list.value.length}` : '';
}
return `已加载 ${list.value.length}/${total.value}`;
});
const selectedUserCount = computed(() => selectedUserIds.value.length);
const confirmButtonText = computed(() =>
@ -137,7 +172,7 @@ async function loadCountries() {
countries.value = (await getCountryAlls()) || [];
}
async function loadData(reset = false) {
async function loadData(reset = false, append = false) {
if (!query.sysOrigin) {
return;
}
@ -147,22 +182,34 @@ async function loadData(reset = false) {
}
if (reset) {
query.cursor = 1;
list.value = [];
total.value = 0;
selectedUserIds.value = [];
}
loading.value = true;
try {
const result = await pageManualSalary({ ...query });
list.value = result.records || [];
const records = result.records || [];
list.value = append ? [...list.value, ...records] : records;
total.value = Number(result.total || 0);
sortLoadedRows();
} finally {
loading.value = false;
}
}
function handlePageChange(page: number, pageSize: number) {
query.cursor = page;
query.limit = pageSize;
void loadData();
async function loadNextPage() {
if (loading.value || !hasMore.value) {
return;
}
const previousCursor = query.cursor;
query.cursor = previousCursor + 1;
try {
await loadData(false, true);
} catch (error) {
query.cursor = previousCursor;
throw error;
}
}
function formatMoney(value?: number | string) {
@ -192,6 +239,74 @@ function getRowKey(record: Record<string, any>) {
return String(record.userId || record.id || '');
}
function getSalarySortValue(
record: Record<string, any>,
key: SalarySortKey,
) {
const value = Number(record?.[key] || 0);
return Number.isFinite(value) ? value : 0;
}
function isSalarySortableColumn(key?: number | string) {
return salarySortableKeys.has(String(key || '') as SalarySortKey);
}
function salarySortIndicator(key?: number | string) {
if (salarySortKey.value !== String(key || '') || !salarySortOrder.value) {
return '排序';
}
return salarySortOrder.value === 'descend' ? '降序' : '升序';
}
function sortLoadedRows() {
if (!salarySortKey.value || !salarySortOrder.value) {
return;
}
const sortKey = salarySortKey.value;
const direction = salarySortOrder.value === 'ascend' ? 1 : -1;
list.value = [...list.value].sort((left, right) => {
const diff =
getSalarySortValue(left, sortKey) - getSalarySortValue(right, sortKey);
if (diff !== 0) {
return diff * direction;
}
return getRowKey(left).localeCompare(getRowKey(right));
});
}
function toggleSalarySort(key?: number | string) {
if (!isSalarySortableColumn(key)) {
return;
}
const nextKey = String(key) as SalarySortKey;
if (salarySortKey.value !== nextKey) {
salarySortKey.value = nextKey;
salarySortOrder.value = 'descend';
} else if (salarySortOrder.value === 'descend') {
salarySortOrder.value = 'ascend';
} else if (salarySortOrder.value === 'ascend') {
salarySortKey.value = '';
salarySortOrder.value = '';
} else {
salarySortOrder.value = 'descend';
}
query.sortField = salarySortKey.value;
query.sortOrder = salarySortOrder.value;
void loadData(true);
}
async function submitManualSalary(userIds?: string[]) {
const hasSelectedUsers = !!userIds?.length;
resultRows.value = await confirmManualSalary({
countryCode: query.countryCode,
sysOrigin: query.sysOrigin,
...(hasSelectedUsers ? { userIds } : {}),
});
resultOpen.value = true;
selectedUserIds.value = [];
await loadData(true);
}
function confirmPay() {
if (!query.countryCode) {
message.warning('请选择国家');
@ -211,15 +326,8 @@ function confirmPay() {
async onOk() {
paying.value = true;
try {
resultRows.value = await confirmManualSalary({
countryCode: query.countryCode,
sysOrigin: query.sysOrigin,
...(hasSelectedUsers ? { userIds } : {}),
});
resultOpen.value = true;
selectedUserIds.value = [];
await submitManualSalary(hasSelectedUsers ? userIds : undefined);
message.success('发放成功');
await loadData();
} finally {
paying.value = false;
}
@ -227,13 +335,45 @@ function confirmPay() {
});
}
function settleUser(record: Record<string, any>) {
if (!query.countryCode) {
message.warning('请选择国家');
return;
}
const userId = String(record.userId || '');
if (!userId) {
message.warning('用户ID为空');
return;
}
const payableSalary = Number(record.payableSalary || 0);
Modal.confirm({
content: payableSalary < 0
? '该用户应发小于实收本次发放0并置为已结算不会对用户扣款。'
: '本次只结算当前这一行用户的工资。',
title: `确认结算用户 ${record.userProfile?.account || userId} 的当前账期工资?`,
async onOk() {
settlingUserId.value = userId;
try {
await submitManualSalary([userId]);
message.success('结算成功');
} finally {
settlingUserId.value = '';
}
},
});
}
void loadCountries();
</script>
<template>
<Page title="工资发放">
<Card>
<div class="toolbar">
<Page
auto-content-height
content-class="manual-salary-page-content"
title="工资发放"
>
<div class="manual-salary-page">
<Card class="manual-salary-filter-card">
<Space wrap>
<SysOriginSelect
v-model:value="query.sysOrigin"
@ -273,17 +413,42 @@ void loadCountries();
{{ confirmButtonText }}
</Button>
</Space>
</div>
</Card>
<Table
<AdminConfigTable
auto-height
:columns="columns"
:data-source="list"
:current="query.cursor"
:data-source="tableRows"
:has-more="hasMore"
infinite
:loading="loading"
:pagination="false"
:row-key="getRowKey"
:page-size="query.limit"
row-key="rowKey"
:row-selection="rowSelection"
:scroll="{ x: 1400 }"
:scroll-x="1620"
:show-pager="false"
:summary-text="summaryText"
:total="total"
@load-more="loadNextPage"
>
<template #headerCell="{ column }">
<button
v-if="isSalarySortableColumn(column.key)"
class="salary-sort-header"
:class="{ 'salary-sort-header--active': salarySortKey === column.key }"
type="button"
@click="toggleSalarySort(column.key)"
>
<span>{{ column.title }}</span>
<span class="salary-sort-state">
{{ salarySortIndicator(column.key) }}
</span>
</button>
<template v-else>
{{ column.title }}
</template>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userProfile'">
<UserProfileLink :profile="record.userProfile" />
@ -303,32 +468,45 @@ void loadCountries();
</Tag>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'expectedSalary'">
{{ formatMoney(record.expectedSalary) }}
</template>
<template v-else-if="column.key === 'payableSalary'">
<Space size="small">
<span>{{ formatMoney(record.payableSalary) }}</span>
<Tag v-if="record.paid" color="green">已发</Tag>
<span
:class="{ 'payable-salary-negative': Number(record.payableSalary || 0) < 0 }"
>
{{ formatMoney(record.payableSalary) }}
</span>
<Tag v-if="record.paid" color="green">已结算</Tag>
</Space>
</template>
<template v-else-if="column.key === 'totalIssuedSalary'">
{{ formatMoney(record.totalIssuedSalary) }}
<span>{{ formatMoney(record.totalIssuedSalary) }}</span>
<span
v-if="Number(record.overIssuedSalary || 0) > 0"
class="over-issued-salary"
>
({{ formatMoney(record.overIssuedSalary) }})
</span>
</template>
<template v-else-if="column.key === 'currentSalaryBalance'">
{{ formatMoney(record.currentSalaryBalance) }}
</template>
<template v-else-if="column.key === 'action'">
<Button
:disabled="!record.userId || record.paid"
:loading="settlingUserId === String(record.userId || '')"
size="small"
type="primary"
@click="settleUser(record)"
>
结算
</Button>
</template>
</template>
</Table>
<div class="pager">
<Pagination
:current="query.cursor"
:page-size="query.limit"
:total="total"
show-size-changer
@change="handlePageChange"
@showSizeChange="handlePageChange"
/>
</div>
</Card>
</AdminConfigTable>
</div>
<Modal
v-model:open="resultOpen"
@ -367,13 +545,55 @@ void loadCountries();
</template>
<style scoped>
.toolbar {
margin-bottom: 16px;
.manual-salary-page {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
min-height: 0;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
.manual-salary-filter-card {
flex: 0 0 auto;
}
.salary-sort-header {
align-items: center;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
display: inline-flex;
font: inherit;
gap: 6px;
max-width: 100%;
padding: 0;
text-align: left;
}
.salary-sort-header:hover,
.salary-sort-header--active {
color: rgb(22 119 255);
}
.salary-sort-state {
color: rgb(102 112 133);
flex: 0 0 auto;
font-size: 12px;
font-weight: 400;
}
.salary-sort-header--active .salary-sort-state {
color: rgb(22 119 255);
}
.over-issued-salary {
color: rgb(217 45 32);
margin-left: 4px;
}
.payable-salary-negative {
color: rgb(217 45 32);
font-weight: 600;
}
</style>