增加工资发放

This commit is contained in:
zhx 2026-05-21 11:12:43 +08:00
parent c7f7a7047f
commit 5e51b460f4
4 changed files with 478 additions and 0 deletions

View File

@ -138,6 +138,22 @@ export async function pageSalary(params: Record<string, any>) {
);
}
export async function pageManualSalary(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<Record<string, any>>>(
'/team/salary/manual/page',
{
params,
},
);
}
export async function confirmManualSalary(data: Record<string, any>) {
return requestClient.post<Array<Record<string, any>>>(
'/team/salary/manual/pay',
data,
);
}
export async function pageCpApply(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<Record<string, any>>>(
'/user-cp-apply/page',

View File

@ -25,6 +25,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/operate/auto-salary-pay-record.vue'),
meta: { title: '自动发放工资凭据' },
},
{
name: 'ManualSalaryPayment',
path: 'manual-salary-payment',
alias: '/manual-salary-payment',
component: () => import('#/views/operate/manual-salary-payment.vue'),
meta: { title: '工资发放' },
},
{
name: 'UserBankWithdrawMoneyApply',
path: 'withdraw-money-apply-page',

View File

@ -0,0 +1,379 @@
<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 { getAllowedSysOrigins } from '#/views/system/shared';
import {
Button,
Card,
Modal,
Pagination,
Select,
Space,
Table,
Tag,
message,
} from 'antdv-next';
import UserProfileLink from './components/user-profile-link.vue';
defineOptions({ name: 'OperateManualSalaryPayment' });
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 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 query = reactive({
countryCode: '',
cursor: 1,
limit: 20,
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: 'payableSalary', key: 'payableSalary', title: '应发工资', width: 120 },
{ dataIndex: 'totalIssuedSalary', key: 'totalIssuedSalary', title: '总发放工资', width: 130 },
{ dataIndex: 'currentSalaryBalance', key: 'currentSalaryBalance', title: '当前工资余额', width: 140 },
];
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 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) {
if (!query.sysOrigin) {
return;
}
if (!query.countryCode) {
message.warning('请选择国家');
return;
}
if (reset) {
query.cursor = 1;
selectedUserIds.value = [];
}
loading.value = true;
try {
const result = await pageManualSalary({ ...query });
list.value = result.records || [];
total.value = Number(result.total || 0);
} finally {
loading.value = false;
}
}
function handlePageChange(page: number, pageSize: number) {
query.cursor = page;
query.limit = pageSize;
void loadData();
}
function formatMoney(value?: number | string) {
const amount = Number(value || 0);
if (Number.isNaN(amount)) {
return '$0.00';
}
return `$${amount.toFixed(2)}`;
}
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.userId || record.id || '');
}
function confirmPay() {
if (!query.countryCode) {
message.warning('请选择国家');
return;
}
const userIds = [...selectedUserIds.value];
const hasSelectedUsers = userIds.length > 0;
Modal.confirm({
content: hasSelectedUsers
? '本次只发放已勾选的用户。'
: '未勾选用户,本次将发放当前筛选国家的所有可发工资用户。',
title: hasSelectedUsers
? `确认发放已选择的 ${userIds.length} 人当前账期工资?`
: `确认发放 ${countryLabel(query.countryCode)} 当前账期工资?`,
async onOk() {
paying.value = true;
try {
resultRows.value = await confirmManualSalary({
countryCode: query.countryCode,
sysOrigin: query.sysOrigin,
...(hasSelectedUsers ? { userIds } : {}),
});
resultOpen.value = true;
selectedUserIds.value = [];
message.success('发放成功');
await loadData();
} finally {
paying.value = false;
}
},
});
}
void loadCountries();
</script>
<template>
<Page title="工资发放">
<Card>
<div class="toolbar">
<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>
</div>
<Table
:columns="columns"
:data-source="list"
:loading="loading"
:pagination="false"
:row-key="getRowKey"
:row-selection="rowSelection"
:scroll="{ x: 1400 }"
>
<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 === 'payableSalary'">
<Space size="small">
<span>{{ formatMoney(record.payableSalary) }}</span>
<Tag v-if="record.paid" color="green">已发</Tag>
</Space>
</template>
<template v-else-if="column.key === 'totalIssuedSalary'">
{{ formatMoney(record.totalIssuedSalary) }}
</template>
<template v-else-if="column.key === 'currentSalaryBalance'">
{{ formatMoney(record.currentSalaryBalance) }}
</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>
<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>
.toolbar {
margin-bottom: 16px;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

View File

@ -0,0 +1,76 @@
SET NAMES utf8mb4;
SET @app_user_manager_id := (
SELECT id
FROM sys_menu
WHERE alias IN ('AppUserManager', 'OperateManager')
ORDER BY FIELD(alias, 'AppUserManager', 'OperateManager')
LIMIT 1
);
INSERT INTO sys_menu (
id,
parent_id,
menu_name,
path,
menu_type,
icon,
create_user,
update_user,
sort,
status,
router,
alias
)
SELECT
menu_id.next_id,
@app_user_manager_id,
'工资发放',
'operate/manual-salary-payment',
2,
'form',
'0',
'0',
49,
0,
'/manual-salary-payment',
'ManualSalaryPayment'
FROM (
SELECT COALESCE(MAX(id), 1700000000) + 1 AS next_id
FROM sys_menu
) AS menu_id
WHERE @app_user_manager_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM sys_menu WHERE alias = 'ManualSalaryPayment'
);
UPDATE sys_menu
SET
parent_id = @app_user_manager_id,
menu_name = '工资发放',
path = 'operate/manual-salary-payment',
menu_type = 2,
icon = 'form',
update_user = '0',
sort = 49,
status = 0,
router = '/manual-salary-payment'
WHERE alias = 'ManualSalaryPayment'
AND @app_user_manager_id IS NOT NULL;
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT DISTINCT
role_menu.role_id,
target_menu.id
FROM sys_role_menu AS role_menu
JOIN sys_menu AS source_menu
ON source_menu.id = role_menu.menu_id
AND source_menu.alias IN ('AutoSalaryPayRecord', 'AppUserManager', 'OperateManager')
JOIN sys_menu AS target_menu
ON target_menu.alias = 'ManualSalaryPayment'
WHERE NOT EXISTS (
SELECT 1
FROM sys_role_menu AS existing_role_menu
WHERE existing_role_menu.role_id = role_menu.role_id
AND existing_role_menu.menu_id = target_menu.id
);