优化分红方式

This commit is contained in:
2026-04-17 16:46:38 +08:00
parent 2e0bcd3f23
commit 9954ea741b
15 changed files with 677 additions and 373 deletions

View File

@@ -6,11 +6,6 @@ export default {
email: 'Email',
mobile: 'Mobile Number',
invite_code: 'Invite code',
commission_rate: 'Commission rate(%)',
commission_rate_desc_title: 'Admin commission notes',
commission_rate_desc_1: 'Admin commission means this admin allocation ratio inside assigned group.',
commission_rate_desc_2: 'Current admin commission = current group commission × current admin commission rate.',
commission_rate_desc_3: 'Within same group, total admin commission rate cannot exceed 100%; exceed and remaining are returned on validation.',
'Please select exactly one group': 'Please select exactly one group',
'Last login': 'Last login',
Password: 'Password',

View File

@@ -8,11 +8,6 @@ export default {
channel_inherit_hint:
'Sub groups do not pick a channel separately: saving uses the parent group channel; changing parent syncs automatically.',
system_group_no_channel: 'System (no channel)',
commission_rate: 'Commission rate (%)',
commission_rate_desc_title: 'Group commission notes',
commission_rate_desc_1: 'The total group commission rate under the same parent cannot exceed 100%.',
commission_rate_desc_2: 'Current group commission = channel commission × (1 - parent group commission rate) × current group commission rate.',
commission_rate_desc_3: 'If exceeded, the system returns both exceeded value and remaining quota under current parent.',
jurisdiction: 'Permissions',
'Parent group': 'Superior group',
'The parent group cannot be the group itself': 'The parent group cannot be the group itself',

View File

@@ -71,8 +71,16 @@ export default {
manual_settle_calc_base: 'Settlement base',
manual_settle_commission_amount: 'Commission amount',
manual_settle_remark: 'Remark',
share_config: 'Share config',
share_config_title: 'Channel admin share config',
share_config_tip: 'Only enabled rows participate in settlement split, and enabled share total must equal 100%.',
share_rate_percent: 'Share rate(%)',
share_total_enabled: 'Enabled total',
share_total_must_100: 'Enabled share total must equal 100%',
admin_id_placeholder: 'Select an admin (within your permission scope)',
admin__username: 'Person in charge',
admin_group_names: 'Role group',
admin_group_paths: 'Role hierarchy',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id,code,name',

View File

@@ -6,11 +6,6 @@ export default {
email: '电子邮箱',
mobile: '手机号',
invite_code: '邀请码',
commission_rate: '分红比(%)',
commission_rate_desc_title: '管理员分红说明',
commission_rate_desc_1: '管理员分红用于该管理员在所属角色组内的分配比例。',
commission_rate_desc_2: '当前管理员分红=当前角色分红×当前管理员分红比例。',
commission_rate_desc_3: '同一角色组内管理员分红比例总和不能超过100%;超额会提示超出值与剩余额度。',
'Please select exactly one group': '请选择且仅选择一个角色组',
'Last login': '最后登录',
Password: '密码',

View File

@@ -7,11 +7,6 @@ export default {
channel_auto_bind: '将自动绑定为当前账号所属渠道',
channel_inherit_hint: '子级不单独选渠道:保存时将使用上级分组对应渠道,变更上级时会自动同步。',
system_group_no_channel: '系统级(未绑定渠道)',
commission_rate: '分红比例(%)',
commission_rate_desc_title: '角色组分红说明',
commission_rate_desc_1: '同一父级下角色组分红比例总和不能超过100%。',
commission_rate_desc_2: '当前角色分红=渠道设置获取分红×(1-上级角色分红比例)×当前角色分红比例。',
commission_rate_desc_3: '提交超额时,系统会提示超出值与当前父级剩余额度。',
jurisdiction: '权限',
'Parent group': '上级分组',
'The parent group cannot be the group itself': '上级分组不能是分组本身',

View File

@@ -71,8 +71,16 @@ export default {
manual_settle_calc_base: '结算基数',
manual_settle_commission_amount: '佣金金额',
manual_settle_remark: '备注',
share_config: '分配比例',
share_config_title: '渠道管理员分配比例',
share_config_tip: '仅启用项参与结算拆分且启用项占比总和必须等于100%。',
share_rate_percent: '分配比例(%)',
share_total_enabled: '启用项合计',
share_total_must_100: '启用项分配比例总和必须等于100%',
admin_id_placeholder: '请选择管理员(仅当前权限范围内)',
admin__username: '负责人',
admin_group_names: '角色组',
admin_group_paths: '角色组层级',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、渠道标识、渠道名',

View File

@@ -40,13 +40,6 @@ optButtons[1].display = (row) => {
return row.id != adminInfo.id
}
const formatRatePercent = (_row: any, _column: any, cellValue: number | string | null) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return '--'
const num = Number(cellValue)
if (Number.isNaN(num)) return '--'
return `${num.toFixed(2)}%`
}
const baTable = new baTableClass(
new baTableApi('/admin/auth.Admin/'),
{
@@ -64,14 +57,6 @@ const baTable = new baTableClass(
render: 'tags',
},
{ label: t('auth.admin.invite_code'), prop: 'invite_code', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('auth.admin.commission_rate'),
prop: 'commission_rate',
align: 'center',
minWidth: 90,
operator: 'RANGE',
formatter: formatRatePercent,
},
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },

View File

@@ -56,23 +56,6 @@
placeholder: t('Click select'),
}"
/>
<FormItem
:label="t('auth.admin.commission_rate')"
v-model="baTable.form.items!.commission_rate"
type="number"
prop="commission_rate"
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100, disabled: shouldDisableCommissionRate() }"
:placeholder="t('Please input field', { field: t('auth.admin.commission_rate') })"
/>
<el-alert class="commission-rate-alert" :title="t('auth.admin.commission_rate_desc_title')" type="info" :closable="false" show-icon>
<template #default>
<ul class="commission-rate-desc-list">
<li>{{ t('auth.admin.commission_rate_desc_1') }}</li>
<li>{{ t('auth.admin.commission_rate_desc_2') }}</li>
<li>{{ t('auth.admin.commission_rate_desc_3') }}</li>
</ul>
</template>
</el-alert>
<FormItem
v-if="baTable.form.operate == 'Edit'"
:label="t('auth.admin.invite_code')"
@@ -156,32 +139,6 @@ const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
/** 解析管理员分红比例:与后端 isValidCommissionRate 一致地拒绝非法字符,避免 Number('30,00') 等为 NaN 导致误报 */
function parseAdminCommissionRateInput(raw: unknown): { kind: 'empty' } | { kind: 'invalid' } | { kind: 'ok'; value: number } {
if (raw === null || raw === undefined || raw === '') {
return { kind: 'empty' }
}
if (typeof raw === 'number') {
if (!Number.isFinite(raw)) {
return { kind: 'invalid' }
}
return { kind: 'ok', value: raw }
}
const s = String(raw).trim()
if (s === '') {
return { kind: 'empty' }
}
const normalized = s.replace(',', '.')
const n = parseFloat(normalized)
if (!Number.isFinite(n)) {
return { kind: 'invalid' }
}
return { kind: 'ok', value: n }
}
const shouldDisableCommissionRate = () => {
return adminInfo.id == baTable.form.items!.id
}
const singleGroupValue = computed({
get: () => {
const group = baTable.form.items?.group_arr
@@ -241,26 +198,6 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
trigger: 'blur',
},
],
commission_rate: [
{
validator: (_rule: unknown, val: unknown, callback: (e?: Error) => void) => {
const parsed = parseAdminCommissionRateInput(val)
if (parsed.kind === 'empty') {
return callback()
}
if (parsed.kind === 'invalid') {
return callback(new Error(t('Please enter the correct field', { field: t('auth.admin.commission_rate') })))
}
const n = parsed.value
const rounded = Math.round(n * 100) / 100
if (rounded < -0.000001 || rounded > 100.000001) {
return callback(new Error(t('Please enter the correct field', { field: t('auth.admin.commission_rate') })))
}
return callback()
},
trigger: ['blur', 'change'],
},
],
})
watch(
@@ -285,14 +222,6 @@ watch(
width: 110px;
height: 110px;
}
.commission-rate-alert {
margin-bottom: 12px;
}
.commission-rate-desc-list {
margin: 6px 0 0;
padding-left: 18px;
line-height: 1.6;
}
.avatar-uploader:hover {
border-color: var(--el-color-primary);
}

View File

@@ -66,7 +66,6 @@ const baTable: baTableClass = new baTableClass(
align: 'center',
minWidth: '140',
},
{ label: t('auth.group.commission_rate'), prop: 'commission_rate', align: 'center', formatter: formatRatePercent },
{ label: t('auth.group.jurisdiction'), prop: 'rules', align: 'center' },
{
label: t('State'),
@@ -199,13 +198,6 @@ const menuRuleTreeUpdate = () => {
provide('baTable', baTable)
function formatRatePercent(row: anyObj, _column: any, cellValue: number | string | null) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '0%'
}
return `${cellValue}%`
}
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()

View File

@@ -85,23 +85,6 @@
:placeholder="t('Please input field', { field: t('auth.group.Group name') })"
></el-input>
</el-form-item>
<FormItem
:label="t('auth.group.commission_rate')"
v-model="baTable.form.items!.commission_rate"
type="number"
prop="commission_rate"
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100, disabled: shouldDisableCommissionRate() }"
:placeholder="t('Please input field', { field: t('auth.group.commission_rate') })"
/>
<el-alert class="commission-rate-alert" :title="t('auth.group.commission_rate_desc_title')" type="info" :closable="false" show-icon>
<template #default>
<ul class="commission-rate-desc-list">
<li>{{ t('auth.group.commission_rate_desc_1') }}</li>
<li>{{ t('auth.group.commission_rate_desc_2') }}</li>
<li>{{ t('auth.group.commission_rate_desc_3') }}</li>
</ul>
</template>
</el-alert>
<el-form-item prop="auth" :label="t('auth.group.jurisdiction')">
<el-tree
ref="treeRef"
@@ -174,10 +157,6 @@ const strFromRow = (key: string): string => {
const channelPreviewName = computed(() => strFromRow('channel_name'))
const shouldDisableCommissionRate = () => {
return false
}
/**
* 子角色组选择上级分组后只拉取展示用渠道名channel_id 由后端按父级保存,不在此写入提交字段。
*/
@@ -253,25 +232,6 @@ watch(
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('auth.group.Group name') })],
commission_rate: [
{
required: true,
validator: (_rule: any, val: number | string, callback: Function) => {
if (shouldDisableCommissionRate()) {
return callback()
}
const strVal = String(val ?? '').trim()
if (!strVal) {
return callback(new Error(t('Please input field', { field: t('auth.group.commission_rate') })))
}
if (!/^(100(\.00?)?|[0-9]{1,2}(\.[0-9]{1,2})?)$/.test(strVal)) {
return callback(new Error(t('auth.admin.Commission rate must be between 0 and 100 with up to 2 decimals')))
}
return callback()
},
trigger: 'blur',
},
],
auth: [
{
required: true,
@@ -329,16 +289,6 @@ defineExpose({
box-sizing: border-box;
}
.commission-rate-alert {
margin-bottom: 12px;
}
.commission-rate-desc-list {
margin: 6px 0 0;
padding-left: 18px;
line-height: 1.6;
}
:deep(.penultimate-node) {
.el-tree-node__children {
padding-left: 60px;

View File

@@ -44,6 +44,15 @@
<el-form-item :label="t('channel.manual_settle_commission_amount')">
<el-input v-model="manualSettle.form.commission_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.share_config')">
<el-table :data="manualSettle.form.commission_split" border size="small" class="w100">
<el-table-column prop="admin_username" :label="t('channel.admin__username')" min-width="100" />
<el-table-column prop="share_rate" :label="t('channel.share_rate_percent')" min-width="90">
<template #default="scope">{{ scope.row.share_rate }}%</template>
</el-table-column>
<el-table-column prop="commission_amount" :label="t('channel.manual_settle_commission_amount')" min-width="110" />
</el-table>
</el-form-item>
<el-form-item :label="t('channel.manual_settle_remark')">
<el-input v-model="manualSettle.form.remark" type="textarea" :rows="2" />
</el-form-item>
@@ -56,12 +65,59 @@
</el-button>
</template>
</el-dialog>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="shareDialog.visible" @close="closeShareDialog">
<template #header>
<div class="title">{{ t('channel.share_config_title') }}</div>
</template>
<div v-loading="shareDialog.loading" class="manual-settle-dialog-body">
<el-alert type="info" :closable="false" show-icon class="mb-12">
{{ t('channel.share_config_tip') }}
</el-alert>
<el-table :data="shareDialog.list" border size="small">
<el-table-column :label="t('channel.admin_group_names')" min-width="260">
<template #default="scope">
<span v-if="scope.row.role_group_name" class="share-group-single">{{ scope.row.role_group_name }}</span>
<span v-else class="share-group-empty">-</span>
</template>
</el-table-column>
<el-table-column prop="username" :label="t('channel.admin__username')" min-width="120" />
<el-table-column :label="t('channel.status')" width="120">
<template #default="scope">
<el-switch v-model="scope.row.status" :active-value="1" :inactive-value="0" />
</template>
</el-table-column>
<el-table-column :label="t('channel.share_rate_percent')" min-width="180">
<template #default="scope">
<el-input-number
v-model="scope.row.share_rate"
:disabled="scope.row.status !== 1"
:min="0"
:max="100"
:step="0.01"
:precision="2"
class="w100"
/>
</template>
</el-table-column>
</el-table>
<div class="share-total-row">
<span>{{ t('channel.share_total_enabled') }}: </span>
<el-tag :type="shareEnabledTotal === '100.00' ? 'success' : 'danger'">{{ shareEnabledTotal }}%</el-tag>
</div>
</div>
<template #footer>
<el-button @click="closeShareDialog">{{ t('Cancel') }}</el-button>
<el-button type="primary" :loading="shareDialog.saving" @click="saveShareDialog">{{ t('Save') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, reactive, useTemplateRef } from 'vue'
import { computed, onMounted, provide, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { auth } from '/@/utils/common'
@@ -78,6 +134,20 @@ defineOptions({
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
let optButtons: OptButton[] = [
{
render: 'tipButton',
name: 'shareConfig',
title: 'channel.share_config',
text: '',
type: 'primary',
icon: 'el-icon-Setting',
class: 'table-row-share-config',
disabledTip: false,
display: () => auth('edit'),
click: (row: TableRow) => {
void openShareDialog(row)
},
},
{
render: 'tipButton',
name: 'manualSettle',
@@ -94,12 +164,6 @@ let optButtons: OptButton[] = [
},
]
optButtons = optButtons.concat(defaultOptButtons(['edit', 'delete']))
const formatRatePercent = (_row: any, _column: any, cellValue: number | string | null) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
const num = Number(cellValue)
if (Number.isNaN(num)) return '-'
return `${num.toFixed(2)}%`
}
const formatAmountInt = (_row: any, _column: any, cellValue: number | string | null) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
const num = Number(cellValue)
@@ -131,10 +195,103 @@ const manualSettle = reactive({
commission_rate: '',
calc_base_amount: '',
commission_amount: '',
commission_split: [] as Array<{ admin_id: number; admin_username: string; share_rate: string; commission_amount: string }>,
remark: '',
},
})
const shareDialog = reactive({
visible: false,
loading: false,
saving: false,
channelId: 0,
list: [] as Array<{ admin_id: number; username: string; role_group_name: string; role_level: number; status: number; share_rate: number | null }>,
})
const shareEnabledTotal = computed(() => {
let sum = 0
for (const row of shareDialog.list) {
if (row.status === 1) {
const n = Number(row.share_rate ?? 0)
if (Number.isFinite(n)) {
sum += n
}
}
}
return sum.toFixed(2)
})
const closeShareDialog = () => {
shareDialog.visible = false
shareDialog.channelId = 0
shareDialog.list = []
}
const openShareDialog = async (row: TableRow) => {
shareDialog.channelId = Number(row.id || 0)
shareDialog.visible = true
shareDialog.loading = true
try {
const res = await createAxios(
{
url: '/admin/channel/channelAdminShareList',
method: 'get',
params: { id: row.id },
},
{ showErrorMessage: true }
)
if (res.code !== 1 || !res.data) {
closeShareDialog()
return
}
const list = Array.isArray(res.data.list) ? res.data.list : []
shareDialog.list = list.map((item: anyObj) => {
const rate = item.share_rate
return {
admin_id: Number(item.admin_id || 0),
username: String(item.username || ''),
role_group_name: String(item.role_group_name || ''),
role_level: Number(item.role_level ?? 9999),
status: Number(item.status ?? 1) === 1 ? 1 : 0,
share_rate: rate === null || rate === undefined || rate === '' ? null : Number(rate),
}
})
} finally {
shareDialog.loading = false
}
}
const saveShareDialog = async () => {
if (!shareDialog.channelId) {
return
}
if (shareEnabledTotal.value !== '100.00') {
ElMessage.error(t('channel.share_total_must_100'))
return
}
shareDialog.saving = true
try {
await createAxios(
{
url: '/admin/channel/saveChannelAdminShare',
method: 'post',
data: {
id: shareDialog.channelId,
list: shareDialog.list.map((row) => ({
admin_id: row.admin_id,
status: row.status,
share_rate: Number(row.share_rate || 0).toFixed(2),
})),
},
},
{ showSuccessMessage: true }
)
closeShareDialog()
} finally {
shareDialog.saving = false
}
}
const resetManualSettleForm = () => {
manualSettle.form.settlement_no = ''
manualSettle.form.period_start_at = ''
@@ -145,6 +302,7 @@ const resetManualSettleForm = () => {
manualSettle.form.commission_rate = ''
manualSettle.form.calc_base_amount = ''
manualSettle.form.commission_amount = ''
manualSettle.form.commission_split = []
manualSettle.form.remark = ''
}
@@ -181,6 +339,7 @@ const openManualSettleDialog = async (row: TableRow) => {
manualSettle.form.commission_rate = d.commission_rate ?? ''
manualSettle.form.calc_base_amount = d.calc_base_amount ?? ''
manualSettle.form.commission_amount = d.commission_amount ?? ''
manualSettle.form.commission_split = Array.isArray(d.commission_split) ? d.commission_split : []
manualSettle.form.remark = `${t('channel.manual_settle')}-CH${row.id}`
} catch {
manualSettle.visible = false
@@ -251,33 +410,6 @@ const baTable = new baTableClass(
affiliate: t('channel.agent_mode affiliate'),
},
},
{
label: t('channel.turnover_share_rate'),
prop: 'turnover_share_rate',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatRatePercent,
},
{
label: t('channel.affiliate_share_rate'),
prop: 'affiliate_share_rate',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatRatePercent,
},
{
label: t('channel.affiliate_fee_rate'),
prop: 'affiliate_fee_rate',
align: 'center',
minWidth: 140,
sortable: false,
operator: 'RANGE',
formatter: formatRatePercent,
},
{
label: t('channel.carryover_balance'),
prop: 'carryover_balance',
@@ -444,4 +576,25 @@ onMounted(() => {
})
</script>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.mb-12 {
margin-bottom: 12px;
}
.share-total-row {
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.share-group-single {
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-regular);
}
.share-group-empty {
color: var(--el-text-color-placeholder);
}
</style>