yumi-admin/apps/src/views/operate/manual-salary-payment.vue
2026-05-25 21:03:07 +08:00

671 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" setup>
import {
computed,
reactive,
ref,
watch,
} from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import { getCountryAlls } from '#/api/legacy/system';
import {
confirmManualSalary,
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,
Select,
Space,
Table,
Tag,
message,
} from 'antdv-next';
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 || []);
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
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>>>([]);
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: '',
});
const columns = [
{ dataIndex: 'userProfile', key: 'userProfile', title: '用户信息', width: 300 },
{ dataIndex: 'countryCode', key: 'countryCode', title: '国家', width: 90 },
{ dataIndex: 'lastSettlementTarget', key: 'lastSettlementTarget', title: '上次结算的积分', width: 150 },
{ dataIndex: 'currentTarget', key: 'currentTarget', title: '本次积分', width: 130 },
{ dataIndex: 'policyLevel', key: 'policyLevel', title: '本次积分档位', width: 130 },
{ dataIndex: 'expectedMemberSalary', key: 'expectedMemberSalary', title: '主播应发', width: 120 },
{ dataIndex: 'expectedAgentSalary', key: 'expectedAgentSalary', title: '代理应发', width: 120 },
{ dataIndex: 'expectedSalary', key: 'expectedSalary', title: '应发合计', width: 120 },
{ dataIndex: 'payableMemberSalary', key: 'payableMemberSalary', title: '主播差额', width: 120 },
{ dataIndex: 'payableAgentSalary', key: 'payableAgentSalary', title: '代理差额', width: 120 },
{ dataIndex: 'payableSalary', key: 'payableSalary', title: '合计差额', width: 140 },
{ dataIndex: 'memberIssuedSalary', key: 'memberIssuedSalary', title: '主播实收', width: 140 },
{ dataIndex: 'agentIssuedSalary', key: 'agentIssuedSalary', title: '代理实收', width: 140 },
{ dataIndex: 'totalIssuedSalary', key: 'totalIssuedSalary', title: '总发工资', width: 140 },
{ dataIndex: 'currentSalaryBalance', key: 'currentSalaryBalance', title: '当前工资', width: 140 },
{ dataIndex: 'action', fixed: 'right', key: 'action', title: '操作', width: 110 },
];
const resultColumns = [
{ dataIndex: 'userProfile', key: 'userProfile', title: '用户信息', width: 280 },
{ dataIndex: 'countryCode', key: 'countryCode', title: '国家', width: 90 },
{ dataIndex: 'salaryChange', key: 'salaryChange', title: '工资变动', width: 180 },
{ dataIndex: 'issuedSalary', key: 'issuedSalary', title: '发放工资', width: 120 },
{ dataIndex: 'policyLevel', key: 'policyLevel', title: '档位', width: 90 },
{ 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(() =>
selectedUserCount.value > 0
? `确认发放(${selectedUserCount.value}人)`
: '确认发放',
);
const rowSelection = computed(() => ({
getCheckboxProps: (record: Record<string, any>) => ({
disabled: !record.userId,
}),
onChange: (keys: Array<number | string>) => {
selectedUserIds.value = keys.map(String).filter(Boolean);
},
preserveSelectedRowKeys: true,
selectedRowKeys: selectedUserIds.value,
}));
const filteredCountries = computed(() => {
const keyword = countryKeyword.value.trim().toLowerCase();
if (!keyword) {
return countries.value;
}
return countries.value.filter((item) =>
[
item.phonePrefix,
item.countryName,
item.aliasName,
item.alphaTwo,
item.alphaThree,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(keyword),
);
});
watch(
sysOriginOptions,
(options) => {
if (!query.sysOrigin && options.length > 0) {
query.sysOrigin = String(options[0]?.value || '');
}
},
{ immediate: true },
);
watch(
[() => query.sysOrigin, () => query.countryCode, () => query.userId],
() => {
selectedUserIds.value = [];
},
);
async function loadCountries() {
countries.value = (await getCountryAlls()) || [];
}
async function loadData(reset = false, append = false) {
if (!query.sysOrigin) {
return;
}
if (!query.countryCode) {
message.warning('请选择国家');
return;
}
if (reset) {
query.cursor = 1;
list.value = [];
total.value = 0;
selectedUserIds.value = [];
}
loading.value = true;
try {
const result = await pageManualSalary({ ...query });
const records = result.records || [];
list.value = append ? [...list.value, ...records] : records;
total.value = Number(result.total || 0);
sortLoadedRows();
} finally {
loading.value = false;
}
}
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) {
const amount = Number(value || 0);
if (Number.isNaN(amount)) {
return '$0.00';
}
return `$${amount.toFixed(2)}`;
}
function shouldShowAgentSalary(record: Record<string, any>) {
if (record.agentMember !== undefined && record.agentMember !== null) {
return record.agentMember === true;
}
return (
!!record.userId &&
!!record.teamOwnId &&
String(record.userId) === String(record.teamOwnId)
);
}
function formatAgentMoney(record: Record<string, any>, key: string) {
return shouldShowAgentSalary(record) ? formatMoney(record[key]) : '-';
}
function formatInteger(value?: number | string) {
if (value === undefined || value === null || value === '') {
return '-';
}
return Number(value).toLocaleString('en-US');
}
function countryLabel(code?: string) {
const country = countries.value.find((item) => item.alphaTwo === code);
if (!country) {
return code || '-';
}
return `${country.phonePrefix || ''} ${country.countryName || country.aliasName || code}`;
}
function getRowKey(record: Record<string, any>) {
return String(record.id || record.userProfile?.account || record.userId || '');
}
function getPayUserIdentifier(record: Record<string, any>) {
const account = record.userProfile?.account;
if (account && query.sysOrigin) {
return `*type:USER,sysOrigin:${query.sysOrigin},accountType:SHORT,content:${account}*`;
}
return String(record.userId || '');
}
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('请选择国家');
return;
}
const selectedKeys = new Set(selectedUserIds.value);
const userIds = tableRows.value
.filter((record) => selectedKeys.has(record.rowKey))
.map(getPayUserIdentifier)
.filter(Boolean);
const hasSelectedUsers = userIds.length > 0;
Modal.confirm({
content: hasSelectedUsers
? '本次只发放已勾选的用户。'
: '未勾选用户,本次将发放当前筛选国家的所有可发工资用户。',
title: hasSelectedUsers
? `确认发放已选择的 ${userIds.length} 人当前账期工资?`
: `确认发放 ${countryLabel(query.countryCode)} 当前账期工资?`,
async onOk() {
paying.value = true;
try {
await submitManualSalary(hasSelectedUsers ? userIds : undefined);
message.success('发放成功');
} finally {
paying.value = false;
}
},
});
}
function settleUser(record: Record<string, any>) {
if (!query.countryCode) {
message.warning('请选择国家');
return;
}
const userId = getPayUserIdentifier(record);
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
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"
:options="sysOriginOptions"
style="width: 140px"
/>
<Select
v-model:value="query.countryCode"
allow-clear
show-search
:filter-option="false"
option-label-prop="label"
placeholder="国家"
style="width: 240px"
:options="filteredCountries.map((item) => ({
label: `${item.phonePrefix || ''} ${item.countryName || item.aliasName || ''}`,
value: item.alphaTwo as any,
}))"
@change="loadData(true)"
@search="countryKeyword = $event"
/>
<AccountInput
v-model:value="query.userId"
:sys-origin="query.sysOrigin"
placeholder="用户ID"
style="width: 260px"
/>
<Button :loading="loading" type="primary" @click="loadData(true)">
搜索
</Button>
<Button
danger
:disabled="!query.countryCode"
:loading="paying"
@click="confirmPay"
>
{{ confirmButtonText }}
</Button>
</Space>
</Card>
<AdminConfigTable
auto-height
:columns="columns"
:current="query.cursor"
:data-source="tableRows"
:has-more="hasMore"
infinite
:loading="loading"
:page-size="query.limit"
row-key="rowKey"
:row-selection="rowSelection"
:scroll-x="2340"
: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" />
</template>
<template v-else-if="column.key === 'countryCode'">
{{ record.countryCode || '-' }}
</template>
<template v-else-if="column.key === 'lastSettlementTarget'">
{{ formatInteger(record.lastSettlementTarget) }}
</template>
<template v-else-if="column.key === 'currentTarget'">
{{ formatInteger(record.currentTarget) }}
</template>
<template v-else-if="column.key === 'policyLevel'">
<Tag v-if="record.policyLevel" color="blue">
{{ record.policyLevel }}
</Tag>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'expectedSalary'">
{{ formatMoney(record.expectedSalary) }}
</template>
<template v-else-if="column.key === 'expectedMemberSalary'">
{{ formatMoney(record.expectedMemberSalary) }}
</template>
<template v-else-if="column.key === 'expectedAgentSalary'">
{{ formatAgentMoney(record, 'expectedAgentSalary') }}
</template>
<template v-else-if="column.key === 'payableMemberSalary'">
<span
:class="{ 'payable-salary-negative': Number(record.payableMemberSalary || 0) < 0 }"
>
{{ formatMoney(record.payableMemberSalary) }}
</span>
</template>
<template v-else-if="column.key === 'payableAgentSalary'">
<span
:class="{ 'payable-salary-negative': Number(record.payableAgentSalary || 0) < 0 }"
>
{{ formatAgentMoney(record, 'payableAgentSalary') }}
</span>
</template>
<template v-else-if="column.key === 'payableSalary'">
<Space size="small">
<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 === 'memberIssuedSalary'">
<span>{{ formatMoney(record.memberIssuedSalary) }}</span>
<span
v-if="Number(record.memberOverIssuedSalary || 0) > 0"
class="over-issued-salary"
>
({{ formatMoney(record.memberOverIssuedSalary) }})
</span>
</template>
<template v-else-if="column.key === 'agentIssuedSalary'">
<span>{{ formatAgentMoney(record, 'agentIssuedSalary') }}</span>
<span
v-if="shouldShowAgentSalary(record) && Number(record.agentOverIssuedSalary || 0) > 0"
class="over-issued-salary"
>
({{ formatMoney(record.agentOverIssuedSalary) }})
</span>
</template>
<template v-else-if="column.key === '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>
</AdminConfigTable>
</div>
<Modal
v-model:open="resultOpen"
:footer="null"
title="发放结果"
width="980px"
>
<Table
:columns="resultColumns"
:data-source="resultRows"
:pagination="false"
row-key="id"
size="small"
:scroll="{ x: 900, y: 520 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userProfile'">
<UserProfileLink :profile="record.userProfile" />
</template>
<template v-else-if="column.key === 'salaryChange'">
{{ formatMoney(record.beforeSalaryBalance) }} -> {{ formatMoney(record.afterSalaryBalance) }}
</template>
<template v-else-if="column.key === 'issuedSalary'">
{{ formatMoney(record.issuedSalary) }}
</template>
<template v-else-if="column.key === 'policyLevel'">
<Tag color="blue">{{ record.policyLevel || '-' }}</Tag>
</template>
<template v-else-if="column.key === 'currentTarget'">
{{ formatInteger(record.currentTarget) }}
</template>
</template>
</Table>
</Modal>
</Page>
</template>
<style scoped>
.manual-salary-page {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
min-height: 0;
}
.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>