1.优化充值跳转链接的问题

2.优化后台渠道管理页面的显示样式
This commit is contained in:
2026-05-30 14:37:46 +08:00
parent 15b9313c07
commit 1cdd597879
19 changed files with 1096 additions and 180 deletions

View File

@@ -22,7 +22,6 @@ class Channel extends Backend
'manualSettlePreview',
'channelAdminShareList',
'saveChannelAdminShare',
'batchSettlePending',
'settleStats',
'dividendRecordList',
'directBetRecordList',
@@ -44,13 +43,9 @@ class Channel extends Backend
protected bool $modelSceneValidate = true;
/** @var array<int, int> 当前管理员绑定的渠道(写操作范围) */
private array $ownChannelIds = [];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\Channel();
$this->ownChannelIds = $this->resolveOwnChannelIds();
return null;
}
@@ -257,7 +252,7 @@ class Channel extends Backend
}
/**
* 删除:仅允许删除本人绑定渠道(查看所有渠道不扩大写权限)
* 删除:须在可写渠道范围内
*/
protected function _del(): Response
{
@@ -685,8 +680,12 @@ class Channel extends Backend
}
$remark = trim((string) $request->post('remark', ''));
$handlingFeeByAdmin = $this->parseCommissionSplitHandlingFees($request->post('commission_split'));
if ($handlingFeeByAdmin === false) {
return $this->error(__('Settlement handling fee rate must be between 0 and 100'));
}
$res = ChannelSettlementService::settleBySuperAdmin((int) $row['id'], intval($this->auth->id), $remark, false);
$res = ChannelSettlementService::settleBySuperAdmin((int) $row['id'], intval($this->auth->id), $remark, false, $handlingFeeByAdmin);
if (($res['ok'] ?? false) !== true) {
return $this->error((string) ($res['msg'] ?? __('Settlement failed')));
}
@@ -694,7 +693,7 @@ class Channel extends Backend
}
/**
* 超管批量结算全部待结算渠道(可作为“提前结算”入口
* 批量结算待结算渠道(需 channel/batchSettlePending范围=当前账号可写渠道
*/
public function batchSettlePending(WebmanRequest $request): Response
{
@@ -702,11 +701,15 @@ class Channel extends Backend
if ($response !== null) {
return $response;
}
if (!$this->auth->isSuperAdmin()) {
if (!$this->canBatchSettle()) {
return $this->error(__('You have no permission'));
}
$scope = AdminChannelScopeService::writableChannelIds($this->auth);
if ($scope !== null && $scope === []) {
return $this->error(__('You have no permission'));
}
// 批量按钮语义:手动触发“待结算渠道”结算,不受结算周期到点限制。
$res = ChannelSettlementService::settleAllDueChannels(intval($this->auth->id), false);
$res = ChannelSettlementService::settleAllDueChannels(intval($this->auth->id), false, $scope);
return $this->success(__('Batch settlement completed'), $res);
}
@@ -1003,13 +1006,15 @@ class Channel extends Backend
private function assertChannelWritable(int $channelId): bool
{
if ($channelId <= 0) {
if ($channelId <= 0 || $this->auth === null) {
return false;
}
if ($this->auth->isSuperAdmin()) {
$scope = AdminChannelScopeService::writableChannelIds($this->auth);
if ($scope === null) {
return Db::name('channel')->where('id', $channelId)->value('id') !== null;
}
return in_array($channelId, $this->ownChannelIds, true);
return in_array($channelId, $scope, true);
}
private function assertChannelAccessible(int $channelId): bool
@@ -1032,6 +1037,18 @@ class Channel extends Backend
return $this->auth->check('channel/manualSettle');
}
private function canBatchSettle(): bool
{
if ($this->auth === null) {
return false;
}
if ($this->auth->isSuperAdmin()) {
return true;
}
return $this->auth->check('channel/batchSettlePending');
}
private function buildChannelPlayRecordQuery(int $channelId, bool $settledOnly)
{
$query = Db::name('game_play_record')->alias('pr')
@@ -1412,34 +1429,6 @@ class Channel extends Backend
return $chosen;
}
/**
* @return array<int, int>
*/
/**
* 写操作可作用的渠道(角色组绑定渠道 + 账号 channel_id不含全平台只读
*
* @return array<int, int>
*/
private function resolveOwnChannelIds(): array
{
if ($this->auth === null) {
return [];
}
$ids = AdminChannelScopeService::resolveBoundGroupChannelIds($this->auth);
if ($ids !== []) {
return $ids;
}
$admin = Db::name('admin')
->field(['id', 'channel_id'])
->where('id', $this->auth->id)
->find();
if ($admin && !empty($admin['channel_id'])) {
$ids[] = (int) $admin['channel_id'];
}
return array_values(array_unique($ids));
}
/**
* 佣金归属管理员:取该渠道下 admin.channel_id 匹配的首个管理员(按 id 升序)。
*/
@@ -1656,6 +1645,16 @@ class Channel extends Backend
}
$mode = isset($data['agent_mode']) ? (string) $data['agent_mode'] : '';
if (isset($data['settlement_handling_fee']) && $data['settlement_handling_fee'] !== '' && $data['settlement_handling_fee'] !== null) {
$fee = (float) $data['settlement_handling_fee'];
if ($fee < 0 || $fee > 100) {
return (string) __('Settlement handling fee rate must be between 0 and 100');
}
$data['settlement_handling_fee'] = number_format($fee, 2, '.', '');
} else {
$data['settlement_handling_fee'] = '0.00';
}
if ($mode === 'turnover') {
if (isset($data['turnover_share_rate']) && $data['turnover_share_rate'] !== '' && $data['turnover_share_rate'] !== null) {
$num = (float) $data['turnover_share_rate'];
@@ -1714,6 +1713,33 @@ class Channel extends Backend
return $negative ? ('-' . $v) : $v;
}
/**
* @return array<int, string>|null|false admin_id => 手续费比例(%)false=比例非法
*/
private function parseCommissionSplitHandlingFees(mixed $splitRaw): array|null|false
{
if (!is_array($splitRaw) || $splitRaw === []) {
return null;
}
$map = [];
foreach ($splitRaw as $item) {
if (!is_array($item)) {
continue;
}
$adminId = intval($item['admin_id'] ?? 0);
if ($adminId <= 0) {
continue;
}
$rateRaw = $item['handling_fee_rate'] ?? ($item['handling_fee'] ?? '0');
$rate = bcadd(strval($rateRaw), '0', 2);
if (bccomp($rate, '0', 2) < 0 || bccomp($rate, '100', 2) > 0) {
return false;
}
$map[$adminId] = $rate;
}
return $map === [] ? null : $map;
}
private function validateLadderRulesField(array &$data): ?string
{
$rulesRaw = $data['affiliate_ladder_rules'] ?? null;

View File

@@ -477,7 +477,7 @@ class Backend extends Api
}
/**
* 全平台只读范围:角色组均未绑定渠道,或拥有查看所有渠道权限,或超管
* 全平台只读范围:超管,或未绑定渠道且拥有渠道模块基础权限
*/
protected function hasGlobalReadScope(): bool
{

View File

@@ -123,6 +123,8 @@ return [
'Weekly settlement must select Monday to Sunday' => 'Weekly settlement must select Monday to Sunday',
'Monthly settlement day must be between 1 and 31' => 'Monthly settlement day must be between 1 and 31',
'Turnover commission rate must be between 0 and 100' => 'Turnover commission rate must be between 0 and 100',
'Settlement handling fee cannot be negative' => 'Settlement handling fee cannot be negative',
'Settlement handling fee rate must be between 0 and 100' => 'Settlement handling fee rate must be between 0 and 100',
'Affiliate share/fee rates are required' => 'Affiliate share/fee rates are required',
'Affiliate share/fee rates must be between 0 and 1' => 'Affiliate share/fee rates must be between 0 and 1',
'Affiliate ladder rules are required' => 'Affiliate ladder rules are required',

View File

@@ -123,6 +123,8 @@ return [
'Weekly settlement must select Monday to Sunday' => '周结必须选择周一到周日',
'Monthly settlement day must be between 1 and 31' => '月结日期必须在1到31之间',
'Turnover commission rate must be between 0 and 100' => '返水分红比例必须在0到100之间',
'Settlement handling fee cannot be negative' => '代理结算手续费不能为负数',
'Settlement handling fee rate must be between 0 and 100' => '代理结算手续费比例必须在0到100之间',
'Affiliate share/fee rates are required' => '联营占成比例不能为空',
'Affiliate share/fee rates must be between 0 and 1' => '联营占成比例必须在0到1之间',
'Affiliate ladder rules are required' => '联营阶梯规则不能为空',

View File

@@ -9,13 +9,14 @@ use support\think\Db;
/**
* 后台管理员渠道数据范围:
* - 账号 channel_id 或角色组 channel_id 任一绑定 → 仅可读对应渠道(优先于「查看所有渠道」
* - 均未绑定且拥有 viewAllChannels → 全平台只读
* - 账号或角色组绑定 channel_id → 仅该批渠道(读+写
* - 均未绑定且拥有渠道模块基础权限channel/index、edit 等)→ 全部渠道(读+写)
* - 均未绑定且无渠道模块权限 → 不可见
*/
class AdminChannelScopeService
{
/**
* 是否具备全平台只读范围(超管 / 未绑定任何渠道且拥有查看所有渠道)
* 是否具备全平台只读范围(其它菜单按 admin 收窄时跳过):超管 / 未绑定渠道且拥有渠道模块权限
*/
public static function hasGlobalReadScope(Auth $auth): bool
{
@@ -29,7 +30,7 @@ class AdminChannelScopeService
return false;
}
return self::hasViewAllChannelsPermission($auth);
return self::hasAnyChannelMenuPermission($auth);
}
/**
@@ -38,6 +39,24 @@ class AdminChannelScopeService
* @return array<int, int>|null
*/
public static function readableChannelIds(Auth $auth): ?array
{
return self::resolveScopedChannelIds($auth);
}
/**
* 可写渠道 ID 列表null 表示不限制(全部渠道)
*
* @return array<int, int>|null
*/
public static function writableChannelIds(Auth $auth): ?array
{
return self::resolveScopedChannelIds($auth);
}
/**
* @return array<int, int>|null
*/
private static function resolveScopedChannelIds(Auth $auth): ?array
{
if (!$auth->isLogin()) {
return [0];
@@ -51,13 +70,55 @@ class AdminChannelScopeService
return $ids;
}
if (self::hasViewAllChannelsPermission($auth)) {
if (self::hasAnyChannelMenuPermission($auth)) {
return null;
}
return [0];
}
/**
* 是否拥有渠道管理模块相关权限(菜单或任一 channel/* 按钮)
*/
public static function hasAnyChannelMenuPermission(Auth $auth): bool
{
if (!$auth->isLogin()) {
return false;
}
if ($auth->isSuperAdmin()) {
return true;
}
$checkNodes = [
'channel',
'channel/index',
'channel/add',
'channel/edit',
'channel/del',
'channel/manualSettle',
'channel/batchSettlePending',
'channel/settleStats',
];
foreach ($checkNodes as $node) {
if ($auth->check($node)) {
return true;
}
}
$ruleList = $auth->getRuleList();
foreach ($ruleList as $name) {
if (!is_string($name) || $name === '') {
continue;
}
$lower = strtolower($name);
if ($lower === 'channel' || str_starts_with($lower, 'channel/')) {
return true;
}
}
return false;
}
/**
* 管理员实际绑定的渠道(角色组 channel_id + 账号 admin.channel_id去重
*
@@ -124,30 +185,6 @@ class AdminChannelScopeService
return array_values(array_unique($ids));
}
/**
* 是否拥有「查看所有渠道」按钮权限
*/
public static function hasViewAllChannelsPermission(Auth $auth): bool
{
if (!$auth->isLogin()) {
return false;
}
$nodes = ['channel/viewAllChannels', 'channel/viewallchannels'];
foreach ($nodes as $node) {
if ($auth->check($node)) {
return true;
}
}
$ruleList = $auth->getRuleList();
foreach ($nodes as $node) {
if (in_array(strtolower($node), $ruleList, true)) {
return true;
}
}
return false;
}
/**
* 列表按 channel_id 过滤时使用的 IDnull=不过滤
*

View File

@@ -152,11 +152,154 @@ class AdminCommissionDistributionService
}
/**
* 将渠道本期总佣金按管理员树分配,返回各管理员实得金额
*
* @return array<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string}>
* 代理费前佣金占渠道本期总佣金的比例(%
*/
public static function distributeChannelCommission(int $channelId, string $totalCommission, string $calcBaseAmount): array
public static function calcCommissionSharePercent(string $gross, string $totalCommission): string
{
if (bccomp($totalCommission, '0', 2) <= 0 || bccomp($gross, '0', 2) <= 0) {
return '0.00';
}
return bcdiv(bcmul($gross, '100', 4), $totalCommission, 2);
}
/**
* 按百分比计算手续费金额:手续费 = 费前佣金 × (费率% / 100)
*/
public static function calcHandlingFeeAmount(string $gross, string $ratePercent): string
{
$rate = bcadd($ratePercent, '0', 2);
if (bccomp($gross, '0', 2) <= 0 || bccomp($rate, '0', 2) <= 0) {
return '0.00';
}
if (bccomp($rate, '100', 2) > 0) {
$rate = '100.00';
}
return bcmul($gross, bcdiv($rate, '100', 4), 2);
}
/**
* 将渠道本期总佣金按管理员树分配,返回各管理员实得金额(费前)
*
* @param array<int, string>|null $handlingFeeRateByAdmin admin_id => 手续费比例(%)
* @return array<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string,handling_fee_rate:string,handling_fee:string,net_commission_amount:string}>
*/
public static function distributeChannelCommission(
int $channelId,
string $totalCommission,
string $calcBaseAmount,
string $defaultHandlingFeeRate = '0.00',
?array $handlingFeeRateByAdmin = null
): array {
$nodes = self::collectHierarchicalNodes($channelId, $totalCommission);
if ($nodes === []) {
return [];
}
$defaultRate = self::normalizeHandlingFeeRatePercent($defaultHandlingFeeRate);
$merged = [];
foreach ($nodes as $node) {
$adminId = intval($node['admin_id'] ?? 0);
if ($adminId <= 0) {
continue;
}
$gross = strval($node['commission_amount'] ?? '0.00');
if (bccomp($gross, '0', 2) <= 0) {
continue;
}
$settlementBase = strval($node['settlement_base_amount'] ?? '0.00');
$feeRate = $defaultRate;
if ($handlingFeeRateByAdmin !== null && isset($handlingFeeRateByAdmin[$adminId])) {
$feeRate = self::normalizeHandlingFeeRatePercent(strval($handlingFeeRateByAdmin[$adminId]));
}
$feeAmount = self::calcHandlingFeeAmount($gross, $feeRate);
$net = bcsub($gross, $feeAmount, 2);
if (bccomp($net, '0', 2) < 0) {
$net = '0.00';
}
$effectiveRate = bccomp($settlementBase, '0', 2) <= 0
? '0.0000'
: bcdiv($gross, $settlementBase, 6);
$merged[$adminId] = [
'admin_id' => $adminId,
'commission_amount' => $gross,
'commission_rate' => $effectiveRate,
'calc_base_amount' => $settlementBase,
'commission_share_percent' => self::calcCommissionSharePercent($gross, $totalCommission),
'handling_fee_rate' => $feeRate,
'handling_fee' => $feeAmount,
'net_commission_amount' => $net,
];
}
return array_values($merged);
}
/**
* 层级分配预览(先序遍历),含结算基数与手续费
*
* @param array<int, string>|null $handlingFeeRateByAdmin admin_id => 手续费比例(%)
* @return array<int, array<string, mixed>>
*/
public static function buildSplitPreview(
int $channelId,
string $commissionTotal,
string $calcBaseAmount,
string $defaultHandlingFeeRate = '0.00',
?array $handlingFeeRateByAdmin = null
): array {
unset($calcBaseAmount);
$nodes = self::collectHierarchicalNodes($channelId, $commissionTotal);
if ($nodes === []) {
return [];
}
$defaultRate = self::normalizeHandlingFeeRatePercent($defaultHandlingFeeRate);
$out = [];
foreach ($nodes as $node) {
$adminId = intval($node['admin_id'] ?? 0);
if ($adminId <= 0) {
continue;
}
$gross = strval($node['commission_amount'] ?? '0.00');
$feeRate = $defaultRate;
if ($handlingFeeRateByAdmin !== null && isset($handlingFeeRateByAdmin[$adminId])) {
$feeRate = self::normalizeHandlingFeeRatePercent(strval($handlingFeeRateByAdmin[$adminId]));
}
$feeAmount = self::calcHandlingFeeAmount($gross, $feeRate);
$net = bcsub($gross, $feeAmount, 2);
if (bccomp($net, '0', 2) < 0) {
$net = '0.00';
}
$out[] = [
'admin_id' => $adminId,
'admin_username' => strval($node['admin_username'] ?? ('#' . $adminId)),
'parent_admin_id' => intval($node['parent_admin_id'] ?? 0),
'level' => intval($node['level'] ?? 0),
'settlement_base_amount' => strval($node['settlement_base_amount'] ?? '0.00'),
'share_rate' => strval($node['share_rate'] ?? '0.00'),
'commission_amount' => $gross,
'commission_share_percent' => self::calcCommissionSharePercent($gross, $commissionTotal),
'handling_fee_rate' => $feeRate,
'handling_fee' => $feeAmount,
'net_commission_amount' => $net,
];
}
return $out;
}
private static function normalizeHandlingFeeRatePercent(string $rateRaw): string
{
$rate = bcadd($rateRaw, '0', 2);
if (bccomp($rate, '0', 2) < 0) {
return '0.00';
}
if (bccomp($rate, '100', 2) > 0) {
return '100.00';
}
return $rate;
}
/**
* @return array<int, array{admin_id:int,admin_username:string,parent_admin_id:int,level:int,settlement_base_amount:string,share_rate:string,commission_amount:string}>
*/
private static function collectHierarchicalNodes(int $channelId, string $totalCommission): array
{
if ($channelId <= 0 || bccomp($totalCommission, '0', 2) <= 0) {
return [];
@@ -166,7 +309,7 @@ class AdminCommissionDistributionService
->where('status', 'enable')
->whereRaw('(parent_admin_id IS NULL OR parent_admin_id = 0)')
->order('id', 'asc')
->field(['id', 'commission_share_rate'])
->field(['id', 'commission_share_rate', 'username'])
->select()
->toArray();
if ($rootRows === []) {
@@ -180,7 +323,7 @@ class AdminCommissionDistributionService
break;
}
}
$merged = [];
$nodes = [];
if ($useRateSplit) {
foreach ($rootRows as $rootRow) {
$rootId = intval($rootRow['id'] ?? 0);
@@ -192,13 +335,7 @@ class AdminCommissionDistributionService
if (bccomp($rootAmount, '0', 2) <= 0) {
continue;
}
$parts = self::distributeFromAdmin($rootId, $rootAmount, $calcBaseAmount);
foreach ($parts as $adminId => $amount) {
if (!isset($merged[$adminId])) {
$merged[$adminId] = '0.00';
}
$merged[$adminId] = bcadd($merged[$adminId], $amount, 2);
}
self::appendNodeTree($rootId, $rootAmount, 0, 0, $rate, $nodes);
}
} else {
$rootCount = count($rootRows);
@@ -214,31 +351,74 @@ class AdminCommissionDistributionService
if (!$isLast) {
$assigned = bcadd($assigned, $rootAmount, 2);
}
$parts = self::distributeFromAdmin($rootId, $rootAmount, $calcBaseAmount);
foreach ($parts as $adminId => $amount) {
if (!isset($merged[$adminId])) {
$merged[$adminId] = '0.00';
}
$merged[$adminId] = bcadd($merged[$adminId], $amount, 2);
if (bccomp($rootAmount, '0', 2) <= 0) {
continue;
}
self::appendNodeTree($rootId, $rootAmount, 0, 0, '100.00', $nodes);
}
}
$out = [];
foreach ($merged as $adminId => $amount) {
if (bccomp($amount, '0', 2) <= 0) {
return $nodes;
}
/**
* @param array<int, array{admin_id:int,admin_username:string,parent_admin_id:int,level:int,settlement_base_amount:string,share_rate:string,commission_amount:string}> $nodes
*/
private static function appendNodeTree(
int $adminId,
string $incomingAmount,
int $level,
int $parentAdminId,
string $shareRateFromParent,
array &$nodes
): void {
if ($adminId <= 0 || bccomp($incomingAmount, '0', 2) <= 0) {
return;
}
$adminRow = Db::name('admin')
->where('id', $adminId)
->field(['id', 'username', 'commission_share_rate'])
->find();
if (!is_array($adminRow)) {
return;
}
$children = Db::name('admin')
->where('parent_admin_id', $adminId)
->where('status', 'enable')
->order('id', 'asc')
->field(['id', 'commission_share_rate'])
->select()
->toArray();
$givenToChildren = '0.00';
$childPlans = [];
foreach ($children as $child) {
$childId = intval($child['id'] ?? 0);
if ($childId <= 0) {
continue;
}
$effectiveRate = bccomp($calcBaseAmount, '0', 2) <= 0
? '0.0000'
: bcdiv($amount, $calcBaseAmount, 6);
$out[] = [
'admin_id' => intval($adminId),
'commission_amount' => $amount,
'commission_rate' => $effectiveRate,
'calc_base_amount' => $calcBaseAmount,
];
$rate = bcadd(strval($child['commission_share_rate'] ?? '0'), '0', 2);
if (bccomp($rate, '0', 2) <= 0) {
continue;
}
$childAmount = bcmul($incomingAmount, bcdiv($rate, '100', 4), 2);
if (bccomp($childAmount, '0', 2) <= 0) {
continue;
}
$givenToChildren = bcadd($givenToChildren, $childAmount, 2);
$childPlans[] = ['id' => $childId, 'amount' => $childAmount, 'rate' => $rate];
}
$selfKeep = bcsub($incomingAmount, $givenToChildren, 2);
$nodes[] = [
'admin_id' => $adminId,
'admin_username' => strval($adminRow['username'] ?? ('#' . $adminId)),
'parent_admin_id' => $parentAdminId,
'level' => $level,
'settlement_base_amount' => $incomingAmount,
'share_rate' => $shareRateFromParent,
'commission_amount' => bccomp($selfKeep, '0', 2) > 0 ? $selfKeep : '0.00',
];
foreach ($childPlans as $plan) {
self::appendNodeTree(intval($plan['id']), strval($plan['amount']), $level + 1, $adminId, strval($plan['rate']), $nodes);
}
return $out;
}
/**
@@ -246,6 +426,7 @@ class AdminCommissionDistributionService
*/
private static function distributeFromAdmin(int $adminId, string $amount, string $calcBaseAmount): array
{
unset($calcBaseAmount);
if ($adminId <= 0 || bccomp($amount, '0', 2) <= 0) {
return [];
}
@@ -272,7 +453,7 @@ class AdminCommissionDistributionService
continue;
}
$givenToChildren = bcadd($givenToChildren, $childAmount, 2);
$childParts = self::distributeFromAdmin($childId, $childAmount, $calcBaseAmount);
$childParts = self::distributeFromAdmin($childId, $childAmount, '0.00');
foreach ($childParts as $aid => $part) {
if (!isset($result[$aid])) {
$result[$aid] = '0.00';
@@ -289,32 +470,4 @@ class AdminCommissionDistributionService
}
return $result;
}
/**
* @return array<int, array{admin_id:int,admin_username:string,share_rate:string,commission_amount:string}>
*/
public static function buildSplitPreview(int $channelId, string $commissionTotal, string $calcBaseAmount): array
{
$rows = self::distributeChannelCommission($channelId, $commissionTotal, $calcBaseAmount);
if ($rows === []) {
return [];
}
$adminIds = array_map(static fn(array $r): int => intval($r['admin_id']), $rows);
$adminNames = Db::name('admin')->where('id', 'in', $adminIds)->column('username', 'id');
$parentMap = Db::name('admin')->where('id', 'in', $adminIds)->column('parent_admin_id', 'id');
$shareRates = Db::name('admin')->where('id', 'in', $adminIds)->column('commission_share_rate', 'id');
$out = [];
foreach ($rows as $row) {
$aid = intval($row['admin_id']);
$parentId = intval($parentMap[$aid] ?? 0);
$shareRate = $parentId > 0 ? bcadd(strval($shareRates[$aid] ?? '0'), '0', 2) : '100.00';
$out[] = [
'admin_id' => $aid,
'admin_username' => strval($adminNames[$aid] ?? ('#' . $aid)),
'share_rate' => $shareRate,
'commission_amount' => strval($row['commission_amount']),
];
}
return $out;
}
}

View File

@@ -9,8 +9,16 @@ use Throwable;
class ChannelSettlementService
{
public static function settleBySuperAdmin(int $channelId, int $operatorAdminId, string $remark = '', bool $auto = false): array
{
/**
* @param array<int, string>|null $handlingFeeByAdmin admin_id => handling fee
*/
public static function settleBySuperAdmin(
int $channelId,
int $operatorAdminId,
string $remark = '',
bool $auto = false,
?array $handlingFeeByAdmin = null
): array {
$channel = Db::name('channel')->where('id', $channelId)->find();
if (!is_array($channel)) {
return ['ok' => false, 'msg' => __('Channel not found')];
@@ -19,6 +27,7 @@ class ChannelSettlementService
if (is_string($payload)) {
return ['ok' => false, 'msg' => $payload];
}
$defaultFee = bcadd(strval($channel['settlement_handling_fee'] ?? '0'), '0', 2);
$settlementNo = self::generateAgentSettlementNo($auto ? 'A' : 'M', $channelId, intval($payload['period_end_ts']));
if (Db::name('agent_settlement_period')->where('settlement_no', $settlementNo)->value('id')) {
return ['ok' => false, 'msg' => __('Settlement number conflict, please retry')];
@@ -26,7 +35,9 @@ class ChannelSettlementService
$distributions = AdminCommissionDistributionService::distributeChannelCommission(
$channelId,
strval($payload['commission_amount']),
strval($payload['calc_base_amount'])
strval($payload['calc_base_amount']),
$defaultFee,
$handlingFeeByAdmin
);
if ($distributions === []) {
return ['ok' => false, 'msg' => __('No channel root agent configured for commission distribution')];
@@ -58,20 +69,21 @@ class ChannelSettlementService
}
foreach ($rows as $row) {
$adminId = intval($row['admin_id'] ?? 0);
$amount = strval($row['commission_amount'] ?? '0.00');
$netAmount = strval($row['net_commission_amount'] ?? '0.00');
if ($adminId <= 0) {
continue;
}
unset($row['net_commission_amount']);
$row['status'] = 1;
$row['settled_at'] = $now;
$row['remark'] = strval($row['remark'] ?? '') . ' | 超管结算直接发放';
$row['update_time'] = $now;
$commissionRecordId = intval(Db::name('agent_commission_record')->insertGetId($row));
if (bccomp($amount, '0.00', 2) > 0) {
if (bccomp($netAmount, '0.00', 2) > 0) {
AdminWalletService::creditCommission(
$adminId,
$channelId,
$amount,
$netAmount,
'agent_commission_record',
$commissionRecordId,
$remark !== '' ? $remark : '超管结算自动发放分红',
@@ -97,9 +109,16 @@ class ChannelSettlementService
return ['ok' => false, 'msg' => __('This flow pays commissions automatically after super admin settlement; channel admin does not need to settle again')];
}
public static function settleAllDueChannels(int $operatorAdminId, bool $respectCycle = true): array
public static function settleAllDueChannels(int $operatorAdminId, bool $respectCycle = true, ?array $channelIds = null): array
{
$channels = Db::name('channel')->where('status', 1)->select()->toArray();
$query = Db::name('channel')->where('status', 1);
if ($channelIds !== null) {
if ($channelIds === []) {
return ['ok_count' => 0, 'failed' => []];
}
$query->whereIn('id', $channelIds);
}
$channels = $query->select()->toArray();
$ok = 0;
$failed = [];
$now = time();
@@ -191,10 +210,12 @@ class ChannelSettlementService
'calc_base_amount' => $commission['calc_base_amount'],
'commission_amount' => $commission['commission_amount'],
'agent_mode' => $mode,
'settlement_handling_fee' => bcadd(strval($row['settlement_handling_fee'] ?? '0'), '0', 2),
'commission_split' => AdminCommissionDistributionService::buildSplitPreview(
$channelId,
$commission['commission_amount'],
$commission['calc_base_amount']
$commission['calc_base_amount'],
bcadd(strval($row['settlement_handling_fee'] ?? '0'), '0', 2)
),
];
}
@@ -332,7 +353,8 @@ class ChannelSettlementService
}
/**
* @param array<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string}> $distributions
* @param array<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string,handling_fee:string,net_commission_amount:string}> $distributions
* @return array<int, array<string, mixed>>
*/
private static function buildCommissionRowsFromDistribution(array $distributions, int $channelId, int $periodId, string $remark, int $now): array
{
@@ -350,6 +372,8 @@ class ChannelSettlementService
'commission_rate' => strval($dist['commission_rate'] ?? '0.0000'),
'calc_base_amount' => strval($dist['calc_base_amount'] ?? '0.00'),
'commission_amount' => $amount,
'handling_fee' => strval($dist['handling_fee'] ?? '0.00'),
'net_commission_amount' => strval($dist['net_commission_amount'] ?? '0.00'),
'status' => 0,
'settled_at' => null,
'remark' => $remark . ' | 树形分红实发',

View File

@@ -138,7 +138,7 @@
### 3.3 注意
`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`。渠道列表**只读范围**:超管、所属角色组均未绑定 `channel_id`、或拥有 `channel/viewAllChannels` 时可查看全平台渠道;否则仅绑定渠道或本人 `admin.channel_id`。详见 `docs/渠道管理后台说明.md`
`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`。渠道列表范围:超管不限;绑定 `channel_id` → 仅对应渠道;均未绑定且拥有渠道模块基础权限(`channel/index``edit` 等)→ 全平台可读可写。详见 `docs/渠道管理后台说明.md`
---

View File

@@ -24,11 +24,11 @@ List filters: **All / With balance / No balance / Enabled only / Disabled only**
`AdminChannelScopeService` applies to list and stats:
**Bound channel wins** (only those channels) if `admin.channel_id` > 0 and/or any role group has `channel_id` — even with `viewAllChannels`.
**Bound channel** (read + write): `admin.channel_id` and/or any role group `channel_id` → only those channels.
**Global read** only when: super admin, **or** no channel binding on account and all groups **and** `viewAllChannels`.
**All channels** (read + write): super admin, **or** no channel binding **and** any channel module permission (`channel/index`, `channel/edit`, etc.).
**Write** (add/edit/delete/manual settle DB) stays on **writable** channels only; `viewAllChannels` does not expand write scope.
**No access**: unbound and no channel module permissions → empty list.
---
@@ -36,12 +36,11 @@ List filters: **All / With balance / No balance / Enabled only / Disabled only**
| Node | Label | Behavior |
|------|-------|----------|
| `channel/viewAllChannels` | View all channels | Global read scope |
| `channel/viewDividendRecords` | Paid dividend records | Top card + dialog |
| `channel/viewDirectBetRecords` | Direct bet records | Direct bet column click |
| `channel/viewSettlementBetRecords` | Settlement-scope bets | Row action |
| `channel/manualSettle` | Manual settle | Preview + submit (readable channel) |
| `channel/batchSettlePending` | Batch settle | **Super admin only** |
| `channel/batchSettlePending` | Batch settle | Writable enabled channels in scope |
Re-login after role changes to refresh `authNode`.

View File

@@ -50,7 +50,7 @@ If a sub-agent has further downline, the same rules apply on **their received am
| Channel commission params | `/admin/channel` | `agent_mode`, turnover/affiliate rates, etc.; see [channel-admin-guide.md](./channel-admin-guide.md) |
| Agent tree & share rates | `/admin/auth/admin` | Tree list; parent agent, share rate, channel |
| Channel filter | Admin list common search | Super admin can filter by channel |
| Channel list read scope | `/admin/channel` | Super admin / unbound groups / `viewAllChannels` → all channels read-only |
| Channel list scope | `/admin/channel` | Super admin / unbound + channel module perms → all channels; bound → bound channels only |
| Visibility | Admin list | Nonsuper admin sees **self + all downline** only |
| Settlement | `/admin/channel` manual / cron | Super admin **or** `channel/manualSettle`; batch still super admin only |

View File

@@ -50,7 +50,7 @@
| 渠道分红参数 | `/admin/channel` | `agent_mode`、返水/联营比例、结算周期等;详见 [渠道管理后台说明.md](./渠道管理后台说明.md) |
| 代理树与分红比例 | `/admin/auth/admin` | 树形列表;配置上级代理、分红比例、渠道归属 |
| 渠道筛选 | 管理员列表公共搜索 | 超管可按渠道筛选 |
| 渠道列表可见范围 | `/admin/channel` | 账号或角色组绑定渠道 → 仅该渠道;未绑定且 `viewAllChannels` → 全平台;超管不限 |
| 渠道列表可见范围 | `/admin/channel` | 绑定渠道 → 仅该渠道;未绑定且有渠道模块权限 → 全平台;超管不限 |
| 数据可见范围 | 管理员列表 | 非超管仅见 **本人 + 全部下级**,不见其他代理线 |
| 结算执行 | `/admin/channel` 手动结算 / 定时任务 | **超管**或 `channel/manualSettle`(渠道可读);批量结算仍仅超管;结算即发放至 `admin_wallet` |

View File

@@ -33,17 +33,17 @@
上述情况合并去重后作为可读渠道 ID 列表。
**全平台只读**仅当同时满足):
**全平台渠道范围**读、写一致,须同时满足):
| 条件 | 说明 |
|------|------|
| 超管 | 权限含 `*` |
| 未绑定任何渠道 | 账号 `channel_id` 为空且所有角色组 `channel_id` 为空 |
| 且拥有「查看所有渠道」 | 按钮权限 `channel/viewAllChannels` |
| 且拥有渠道模块基础权限 | 如 `channel/index``channel/edit` 等(含渠道菜单或任一 `channel/*` 按钮) |
未绑定渠道且无 `viewAllChannels` 时,渠道列表为空。
未绑定渠道且无上述渠道权限时,渠道列表为空。
**写操作**新增/编辑/删除渠道、手动结算写库)仍限制在**可写渠道**:角色组绑定渠道 + 账号 `channel_id`**不**因「查看所有渠道」而扩大写范围
**读、写范围一致**:绑定渠道 → 仅绑定渠道;未绑定且有渠道模块权限 → 全部渠道(含新增/编辑/删除/手动结算写库)。
其它菜单(用户、充值/提现订单、游玩记录、控制台等)在只读全平台时同样可不按 `admin_id` 收窄,逻辑与 `Backend::hasGlobalReadScope()` 一致。
@@ -62,12 +62,11 @@
| 按钮权限节点 | 名称 | 行为 |
|--------------|------|------|
| `channel/index` | 查看 | 列表与详情 |
| `channel/viewAllChannels` | 查看所有渠道 | 扩大**只读**范围至全平台渠道 |
| `channel/viewDividendRecords` | 查看已分红记录 | 顶部「已分红金额」卡片与弹窗 |
| `channel/viewDirectBetRecords` | 查看直属投注记录 | 「直属投注额」列点击 |
| `channel/viewSettlementBetRecords` | 查看总投注金额 | 操作列;分红口径已结算注单 |
| `channel/manualSettle` | 手动结算 | 操作列;预览并提交渠道结算(见 §5 |
| `channel/batchSettlePending` | 一键批量结算 | **仅超管**可见;结算全部待结算渠道 |
| `channel/batchSettlePending` | 一键批量结算 | 批量结算当前账号**可写范围**内启用渠道 |
| `channel/add` / `edit` / `del` | 增删改 | 须对目标渠道具备写权限 |
角色组在 **权限管理 → 角色组** 中勾选对应按钮;保存后管理员需**重新登录**以刷新前端 `authNode`
@@ -80,7 +79,9 @@
- **权限**:超管,或拥有 `channel/manualSettle` 且目标渠道在**可读范围**内
- **逻辑**:与超管结算相同,调用 `ChannelSettlementService::settleBySuperAdmin`,结算即按代理树发放至 `admin_wallet`
- **周期**:上次结算结束时间 ~ 当前时间;金额来自期内 **已结算** 游玩记录(`game_play_record.status = 2`
- **批量**`POST /admin/channel/batchSettlePending` 仍**仅超管**
- **手续费**:渠道字段 `settlement_handling_fee` 为默认**手续费比例(%)**;计算公式:手续费金额 = 费前佣金 × 比例 / 100预览弹窗可按代理修改比例提交时 `commission_split[].handling_fee_rate` 回传;实发 = 费前佣金 手续费金额,记录表 `handling_fee` 存扣除金额
- **分配预览**:按代理树先序展示,列含层级缩进、**结算基数**(上级分给该代理的金额)、分配比例、费前佣金、手续费、实发佣金
- **批量**`POST /admin/channel/batchSettlePending``channel/batchSettlePending`;仅结算当前账号可写范围内启用渠道
---
@@ -139,7 +140,7 @@
| GET | `/admin/channel/dividendRecordList` | 已分红记录 |
| GET | `/admin/channel/manualSettlePreview` | 手动结算预览 |
| POST | `/admin/channel/manualSettle` | 提交手动结算 |
| POST | `/admin/channel/batchSettlePending` | 超管批量结算 |
| POST | `/admin/channel/batchSettlePending` | 批量结算(可写渠道范围) |
---
@@ -162,3 +163,4 @@
| 2026-05-30 | 新增:查看所有渠道、下注/分红查看按钮;下注记录弹窗列与筛选;移动端弹窗适配 |
| 2026-05-30 | 手动结算:拥有 `channel/manualSettle` 且渠道可读即可操作(不再仅限超管展示按钮) |
| 2026-05-30 | 修复:账号已设 `channel_id` 时不再因角色组未绑渠道而误判为全平台可见 |
| 2026-05-30 | 移除 `channel/viewAllChannels`;未绑定渠道且拥有渠道模块基础权限时读写全平台渠道 |

View File

@@ -9,7 +9,9 @@ export default {
admin_username: 'Agent username',
commission_rate: 'Commission rate',
calc_base_amount: 'Calculation base amount',
commission_amount: 'Commission amount',
commission_amount: 'Commission amount (gross)',
handling_fee: 'Handling fee amount',
net_commission_amount: 'Net commission',
status: 'Status',
'status 0': 'Pending',
'status 1': 'Paid',

View File

@@ -15,6 +15,19 @@ export default {
agent_mode_desc_affiliate_2: 'Formula: net loss after costs × affiliate share rate.',
agent_mode_desc_affiliate_3: 'Fields: supports fee deduction rate and carryover balance; turnover rate is cleared automatically.',
turnover_share_rate: 'turnover_share_rate',
settlement_handling_fee: 'settlement_handling_fee(%)',
settlement_handling_fee_tip: 'Percent of gross commission per agent per period; adjustable in manual settle',
settlement_handling_fee_percent: 'handling_fee(%)',
settlement_handling_fee_amount: 'handling_fee_amount',
manual_settle_settlement_base: 'settlement_base',
manual_settle_net_commission: 'net_commission',
manual_settle_col_base: 'Base',
manual_settle_col_share: 'Share',
manual_settle_col_gross: 'Gross',
manual_settle_col_commission_share: 'Share%',
manual_settle_col_net_short: 'Net',
manual_settle_col_fee: 'Fee',
manual_settle_col_net: 'Net',
affiliate_share_rate: 'affiliate_share_rate',
affiliate_fee_rate: 'affiliate_fee_rate',
affiliate_contract_no: 'affiliate_contract_no',
@@ -72,6 +85,19 @@ export default {
manual_settle_commission_amount: 'Commission amount',
manual_settle_submit: 'Settle',
manual_settle_remark: 'Remark',
manual_settle_calc_title: 'Settlement calculation',
manual_settle_calc_intro_turnover_1: 'For this period, sum settled bets: total bet and total payout; platform PnL = bet payout.',
manual_settle_calc_intro_turnover_2: 'Channel commission = settlement base (total bet) × turnover share rate; the table below splits it by agent tree.',
manual_settle_calc_intro_turnover_3: 'Commission applies only when platform-wide PnL is positive; otherwise channel commission is 0.',
manual_settle_calc_intro_affiliate_1: 'For this period, affiliate commission is based on net player loss and ladder rules in this channel.',
manual_settle_calc_intro_affiliate_2: 'Channel commission = base after costs × share rate; split in the table by agent hierarchy.',
manual_settle_calc_intro_affiliate_3: 'If net loss is not positive, commission may be 0 — use preview amounts.',
manual_settle_calc_tree_1: 'Expand/collapse the tree; each row is one agent share in this settlement.',
manual_settle_calc_tree_2: 'Settlement base = amount from parent; share % = cut from parent pool; commission = gross before fee.',
manual_settle_calc_tree_3: 'Commission share % = agent gross ÷ channel total commission × 100%.',
manual_settle_calc_handling_fee: 'Net = gross fee; fee uses channel rate {rate}% of gross, once per agent per period.',
manual_settle_col_net: 'Net',
manual_settle_split_scroll_tip: 'Swipe horizontally to view all columns',
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%.',

View File

@@ -9,7 +9,9 @@ export default {
admin_username: '代理账号',
commission_rate: '佣金比例',
calc_base_amount: '结算基数',
commission_amount: '佣金金额',
commission_amount: '佣金金额(费前)',
handling_fee: '手续费金额',
net_commission_amount: '实发佣金',
status: '状态',
'status 0': '待发放',
'status 1': '已发放',

View File

@@ -15,6 +15,20 @@ export default {
agent_mode_desc_affiliate_2: '计算方式:净客损扣除成本后 × 联营占成比例affiliate_share_rate。',
agent_mode_desc_affiliate_3: '字段说明:可配置联营成本扣除比例与负结转余额;切换到该模式会自动清空返水比例。',
turnover_share_rate: '返水分红比例',
settlement_handling_fee: '代理结算手续费(%)',
settlement_handling_fee_tip: '按该代理费前佣金的百分比扣除,每期每代理扣一次;手动结算时可单独调整',
settlement_handling_fee_percent: '手续费(%)',
settlement_handling_fee_amount: '手续费金额',
manual_settle_settlement_base: '结算基数',
manual_settle_net_commission: '实发佣金',
manual_settle_col_base: '基数',
manual_settle_col_share: '比例',
manual_settle_col_gross: '佣金',
manual_settle_col_commission_share: '占比',
manual_settle_col_net_short: '实发',
manual_settle_col_fee: '手续费',
manual_settle_col_net: '实发',
manual_settle_split_scroll_tip: '左右滑动查看全部分配列',
affiliate_share_rate: '联营占成比例',
affiliate_fee_rate: '联营成本扣除比例',
affiliate_contract_no: '联营契约编号',
@@ -72,6 +86,17 @@ export default {
manual_settle_commission_amount: '佣金金额',
manual_settle_submit: '结算',
manual_settle_remark: '备注',
manual_settle_calc_title: '结算计算说明',
manual_settle_calc_intro_turnover_1: '本期统计区间内,汇总该渠道已结算注单的总投注额与总派彩额,平台盈亏 = 总投注 总派彩。',
manual_settle_calc_intro_turnover_2: '渠道本期可分配佣金 = 结算基数(总投注额)× 返水分红比例;下方表格按代理树自上而下拆分该笔佣金。',
manual_settle_calc_intro_turnover_3: '仅当平台大盘盈利时参与分红;若大盘亏损或持平,本期渠道佣金为 0。',
manual_settle_calc_intro_affiliate_1: '本期统计区间内,按该渠道辖区净客损(平台盈亏)及联营规则计算可分配佣金。',
manual_settle_calc_intro_affiliate_2: '渠道本期可分配佣金 = 扣除联营成本后的结算基数 × 阶梯占成比例;下方表格按代理树拆分。',
manual_settle_calc_intro_affiliate_3: '净客损为负或持平时,本期可分配佣金可能为 0以实际预览金额为准。',
manual_settle_calc_tree_1: '分配树可点击展开/收起;每一行对应该代理在本期分到的金额。',
manual_settle_calc_tree_2: '结算基数 = 上级代理结算后分给本代理的金额;分配比例 = 从上级佣金中抽取的百分比;佣金金额 = 本代理实得(费前)。',
manual_settle_calc_tree_3: '佣金占比 = 该代理费前佣金 ÷ 渠道本期总佣金 × 100%,表示占全部可分配佣金的比例。',
manual_settle_calc_handling_fee: '实发佣金 = 佣金金额 手续费金额;手续费按渠道配置 {rate}%(费前佣金比例)扣除,每期每代理扣一次。',
share_config: '分配比例',
share_config_title: '渠道管理员分配比例',
share_config_tip: '仅启用项参与结算拆分且启用项占比总和必须等于100%。',

View File

@@ -109,6 +109,14 @@ const baTable = new baTableClass(
operator: 'RANGE',
formatter: formatAmount2,
},
{
label: t('agent.commissionRecord.handling_fee'),
prop: 'handling_fee',
align: 'center',
minWidth: 100,
operator: 'RANGE',
formatter: formatAmount2,
},
{
label: t('agent.commissionRecord.status'),
prop: 'status',

View File

@@ -42,7 +42,7 @@
<el-radio-button label="enabled">{{ t('channel.settle_filter_enabled') }}</el-radio-button>
<el-radio-button label="disabled">{{ t('channel.settle_filter_disabled') }}</el-radio-button>
</el-radio-group>
<el-button v-if="adminInfo.super && auth('batchSettlePending')" type="warning" @click="onBatchSettlePending">
<el-button v-if="auth('batchSettlePending')" type="warning" @click="onBatchSettlePending">
{{ t('channel.batch_settle_pending') }}
</el-button>
</div>
@@ -53,17 +53,26 @@
<PopupForm />
<el-dialog
class="ba-operate-dialog manual-settle-dialog"
class="manual-settle-dialog"
:close-on-click-modal="false"
:model-value="manualSettle.visible"
width="860px"
:width="manualSettleDialogWidth"
align-center
append-to-body
destroy-on-close
@close="closeManualSettleDialog"
@opened="dismissFloatingTooltips"
>
<template #header>
<div class="title">{{ t('channel.manual_settle') }}</div>
</template>
<div v-loading="manualSettle.previewLoading" class="manual-settle-dialog-body">
<el-form :model="manualSettle.form" label-width="140px" class="manual-settle-form">
<el-form
:model="manualSettle.form"
:label-width="manualSettleFormLabelWidth"
:label-position="manualSettleFormLabelPosition"
class="manual-settle-form"
>
<el-form-item :label="t('channel.manual_settle_settlement_no')">
<el-input v-model="manualSettle.form.settlement_no" readonly />
</el-form-item>
@@ -92,13 +101,97 @@
<el-input v-model="manualSettle.form.commission_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.share_config')" class="manual-settle-form-item-full">
<el-table :data="manualSettle.form.commission_split" border size="small" class="w100" max-height="220">
<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>
<div class="manual-settle-split-block">
<div
class="manual-settle-split-table-scroll"
:class="{ 'is-mobile': manualSettleViewportMobile }"
>
<div
class="manual-settle-split-table-inner"
:class="{ 'is-mobile': manualSettleViewportMobile }"
:style="manualSettleSplitInnerStyle"
>
<el-table
:data="manualSettleSplitTree"
row-key="admin_id"
:tree-props="{ children: 'children' }"
default-expand-all
border
size="small"
:fit="!manualSettleViewportMobile"
class="manual-settle-split-table"
:class="{ 'is-mobile': manualSettleViewportMobile, 'w100': !manualSettleViewportMobile }"
:table-layout="manualSettleTableLayout"
>
<el-table-column
prop="admin_username"
:label="t('channel.admin__username')"
:min-width="manualSettleViewportMobile ? undefined : manualSettleSplitCol.adminWidth"
:width="manualSettleViewportMobile ? manualSettleSplitCol.adminWidth : undefined"
:show-overflow-tooltip="!manualSettleViewportMobile"
/>
<el-table-column
prop="settlement_base_amount"
:label="t('channel.manual_settle_col_base')"
:width="manualSettleSplitCol.baseWidth"
align="center"
:formatter="formatSplitAmountCell"
/>
<el-table-column
prop="share_rate"
:label="t('channel.manual_settle_col_share')"
:width="manualSettleSplitCol.shareWidth"
align="center"
>
<template #default="scope">{{ scope.row.share_rate }}%</template>
</el-table-column>
<el-table-column
prop="commission_amount"
:label="t('channel.manual_settle_col_gross')"
:width="manualSettleSplitCol.grossWidth"
align="center"
:formatter="formatSplitAmountCell"
/>
<el-table-column
prop="commission_share_percent"
:label="t('channel.manual_settle_col_commission_share')"
:width="manualSettleSplitCol.commissionShareWidth"
align="center"
>
<template #default="scope">{{ formatCommissionSharePercent(scope.row) }}</template>
</el-table-column>
<el-table-column
prop="handling_fee"
:label="t('channel.manual_settle_col_fee')"
:width="manualSettleSplitCol.feeWidth"
align="center"
:formatter="formatSplitAmountCell"
/>
<el-table-column
:label="t('channel.manual_settle_col_net')"
:width="manualSettleSplitCol.netWidth"
align="center"
>
<template #default="scope">{{ formatNetCommission(scope.row) }}</template>
</el-table-column>
</el-table>
</div>
<p v-if="manualSettleViewportMobile" class="manual-settle-split-scroll-tip">
{{ t('channel.manual_settle_split_scroll_tip') }}
</p>
</div>
<el-alert
class="manual-settle-calc-alert"
:title="t('channel.manual_settle_calc_title')"
type="info"
:closable="false"
show-icon
>
<ul class="manual-settle-calc-list">
<li v-for="(line, idx) in manualSettleCalcDescLines" :key="idx">{{ line }}</li>
</ul>
</el-alert>
</div>
</el-form-item>
<el-form-item :label="t('channel.manual_settle_remark')" class="manual-settle-form-item-full">
<el-input v-model="manualSettle.form.remark" type="textarea" :rows="2" />
@@ -328,13 +421,12 @@
</template>
<script setup lang="ts">
import { onMounted, provide, reactive, ref, useTemplateRef } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, provide, reactive, ref, 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'
import { useAdminInfo } from '/@/stores/adminInfo'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
@@ -346,7 +438,76 @@ defineOptions({
})
const { t } = useI18n()
const adminInfo = useAdminInfo()
const MANUAL_SETTLE_MOBILE_BREAKPOINT = 768
const manualSettleViewportMobile = ref(typeof window !== 'undefined' ? window.innerWidth <= MANUAL_SETTLE_MOBILE_BREAKPOINT : false)
const manualSettleDialogWidth = computed(() => (manualSettleViewportMobile.value ? '96%' : '860px'))
const manualSettleFormLabelPosition = computed(() => (manualSettleViewportMobile.value ? 'top' : 'right'))
const manualSettleFormLabelWidth = computed(() => (manualSettleViewportMobile.value ? 'auto' : '140px'))
const manualSettleTableLayout = computed(() => 'fixed')
const manualSettleSplitCol = computed(() => {
if (manualSettleViewportMobile.value) {
return {
adminWidth: 120,
baseWidth: 72,
shareWidth: 58,
grossWidth: 72,
commissionShareWidth: 62,
feeWidth: 64,
netWidth: 72,
}
}
return {
adminWidth: 108,
baseWidth: 76,
shareWidth: 58,
grossWidth: 72,
commissionShareWidth: 62,
feeWidth: 64,
netWidth: 72,
}
})
const manualSettleSplitTableMinWidth = computed(() => {
const col = manualSettleSplitCol.value
return (
col.adminWidth +
col.baseWidth +
col.shareWidth +
col.grossWidth +
col.commissionShareWidth +
col.feeWidth +
col.netWidth +
32
)
})
const manualSettleSplitInnerStyle = computed(() => {
if (!manualSettleViewportMobile.value) {
return {}
}
return {
width: `${manualSettleSplitTableMinWidth.value}px`,
}
})
const syncManualSettleViewport = () => {
if (typeof window === 'undefined') {
return
}
manualSettleViewportMobile.value = window.innerWidth <= MANUAL_SETTLE_MOBILE_BREAKPOINT
}
const dismissFloatingTooltips = () => {
if (typeof document === 'undefined') {
return
}
document.querySelectorAll('.el-popper[role="tooltip"]').forEach((node) => node.remove())
const active = document.activeElement
if (active instanceof HTMLElement) {
active.blur()
}
}
const tableRef = useTemplateRef('tableRef')
let optButtons: OptButton[] = [
{
@@ -371,7 +532,7 @@ let optButtons: OptButton[] = [
type: 'warning',
icon: 'el-icon-Clock',
class: 'table-row-manual-settle',
disabledTip: false,
disabledTip: true,
display: () => auth('manualSettle'),
click: (row: TableRow) => {
void openManualSettleDialog(row)
@@ -385,6 +546,33 @@ const formatAmountInt = (_row: any, _column: any, cellValue: number | string | n
if (Number.isNaN(num)) return '-'
return `${num}`
}
const formatCommissionSharePercent = (row: { commission_amount?: string | number; commission_share_percent?: string | number }) => {
const preset = row.commission_share_percent
if (preset !== null && preset !== undefined && preset !== '') {
const n = Number(preset)
if (Number.isFinite(n)) {
return `${n.toFixed(2)}%`
}
}
const total = Number(manualSettle.form.commission_amount ?? 0)
const gross = Number(row.commission_amount ?? 0)
if (!Number.isFinite(total) || total <= 0 || !Number.isFinite(gross) || gross <= 0) {
return '0.00%'
}
return `${((gross / total) * 100).toFixed(2)}%`
}
const formatSplitAmountCell = (_row: anyObj, _column: any, cellValue: unknown) => {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '-'
}
const n = Number(cellValue)
if (!Number.isFinite(n)) {
return String(cellValue)
}
return n.toFixed(2)
}
const formatAmount2 = (_row: any, _column: any, cellValue: number | string | null) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
const num = Number(cellValue)
@@ -434,7 +622,21 @@ 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 }>,
agent_mode: 'turnover' as string,
settlement_handling_fee: '0.00',
commission_split: [] as Array<{
admin_id: number
admin_username: string
parent_admin_id?: number
level?: number
settlement_base_amount?: string
share_rate: string
commission_amount: string
commission_share_percent?: string
handling_fee_rate: number | string
handling_fee?: string
net_commission_amount?: string
}>,
remark: '',
},
})
@@ -496,18 +698,105 @@ const resetManualSettleForm = () => {
manualSettle.form.calc_base_amount = ''
manualSettle.form.commission_amount = ''
manualSettle.form.commission_split = []
manualSettle.form.agent_mode = 'turnover'
manualSettle.form.settlement_handling_fee = '0.00'
manualSettle.form.remark = ''
}
type ManualSettleSplitRow = {
admin_id: number
admin_username: string
parent_admin_id?: number
level?: number
settlement_base_amount?: string
share_rate: string
commission_amount: string
commission_share_percent?: string
handling_fee_rate: number | string
handling_fee?: string
net_commission_amount?: string
children?: ManualSettleSplitRow[]
}
const buildCommissionSplitTree = (flat: ManualSettleSplitRow[]): ManualSettleSplitRow[] => {
if (!flat.length) {
return []
}
const nodeMap = new Map<number, ManualSettleSplitRow>()
for (const row of flat) {
nodeMap.set(row.admin_id, { ...row, children: [] })
}
const roots: ManualSettleSplitRow[] = []
for (const row of flat) {
const node = nodeMap.get(row.admin_id)
if (!node) {
continue
}
const parentId = Number(row.parent_admin_id ?? 0)
if (parentId > 0 && nodeMap.has(parentId)) {
const parent = nodeMap.get(parentId)
if (parent) {
if (!parent.children) {
parent.children = []
}
parent.children.push(node)
}
continue
}
roots.push(node)
}
const pruneEmptyChildren = (nodes: ManualSettleSplitRow[]) => {
for (const node of nodes) {
if (node.children && node.children.length > 0) {
pruneEmptyChildren(node.children)
} else {
delete node.children
}
}
}
pruneEmptyChildren(roots)
return roots
}
const manualSettleSplitTree = computed(() => buildCommissionSplitTree(manualSettle.form.commission_split))
const manualSettleCalcDescLines = computed(() => {
const mode = manualSettle.form.agent_mode === 'affiliate' ? 'affiliate' : 'turnover'
const feeRate = manualSettle.form.settlement_handling_fee
const prefix =
mode === 'affiliate'
? [
t('channel.manual_settle_calc_intro_affiliate_1'),
t('channel.manual_settle_calc_intro_affiliate_2'),
t('channel.manual_settle_calc_intro_affiliate_3'),
]
: [
t('channel.manual_settle_calc_intro_turnover_1'),
t('channel.manual_settle_calc_intro_turnover_2'),
t('channel.manual_settle_calc_intro_turnover_3'),
]
return [
...prefix,
t('channel.manual_settle_calc_tree_1'),
t('channel.manual_settle_calc_tree_2'),
t('channel.manual_settle_calc_tree_3'),
t('channel.manual_settle_calc_handling_fee', { rate: feeRate }),
]
})
const closeManualSettleDialog = () => {
manualSettle.visible = false
resetManualSettleForm()
}
const openManualSettleDialog = async (row: TableRow) => {
dismissFloatingTooltips()
syncManualSettleViewport()
manualSettle.channelId = row.id
resetManualSettleForm()
manualSettle.visible = true
await nextTick()
dismissFloatingTooltips()
manualSettle.previewLoading = true
try {
const res = await createAxios(
@@ -532,7 +821,9 @@ 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.agent_mode = d.agent_mode ?? 'turnover'
manualSettle.form.settlement_handling_fee = d.settlement_handling_fee ?? '0.00'
manualSettle.form.commission_split = normalizeManualSettleSplit(d.commission_split, d.settlement_handling_fee)
manualSettle.form.remark = `${t('channel.manual_settle')}-CH${row.id}`
} catch {
manualSettle.visible = false
@@ -541,6 +832,64 @@ const openManualSettleDialog = async (row: TableRow) => {
}
}
const calcHandlingFeeByPercent = (gross: number, ratePercent: number) => {
const g = Number.isFinite(gross) && gross > 0 ? gross : 0
let rate = Number.isFinite(ratePercent) ? ratePercent : 0
if (rate < 0) rate = 0
if (rate > 100) rate = 100
return Math.round(g * rate * 100) / 10000
}
const applyManualSettleRowAmounts = (row: {
commission_amount?: string | number
handling_fee_rate?: string | number
handling_fee?: string | number
net_commission_amount?: string
}) => {
const gross = Number(row.commission_amount ?? 0)
const rate = Number(row.handling_fee_rate ?? 0)
const feeAmount = calcHandlingFeeByPercent(gross, rate)
row.handling_fee = feeAmount.toFixed(2)
row.net_commission_amount = Math.max(0, (Number.isFinite(gross) ? gross : 0) - feeAmount).toFixed(2)
}
const normalizeManualSettleSplit = (rows: unknown, defaultFeeRate: unknown) => {
const feeDefault = Number(defaultFeeRate)
const baseRate = Number.isFinite(feeDefault) && feeDefault >= 0 ? Math.min(100, feeDefault) : 0
if (!Array.isArray(rows)) {
return []
}
return rows.map((row: anyObj) => {
let rate = Number(row.handling_fee_rate ?? row.handling_fee ?? baseRate)
if (!Number.isFinite(rate) || rate < 0) {
rate = baseRate
}
if (rate > 100) {
rate = 100
}
const normalized = {
...row,
handling_fee_rate: rate,
}
applyManualSettleRowAmounts(normalized)
return normalized
})
}
const formatNetCommission = (row: {
commission_amount?: string | number
handling_fee_rate?: string | number
net_commission_amount?: string
}) => {
if (row.net_commission_amount !== undefined && row.net_commission_amount !== '') {
return row.net_commission_amount
}
const gross = Number(row.commission_amount ?? 0)
const rate = Number(row.handling_fee_rate ?? 0)
const net = Math.max(0, (Number.isFinite(gross) ? gross : 0) - calcHandlingFeeByPercent(gross, rate))
return net.toFixed(2)
}
const submitManualSettle = async () => {
if (!manualSettle.channelId) return
manualSettle.loading = true
@@ -552,6 +901,10 @@ const submitManualSettle = async () => {
data: {
id: manualSettle.channelId,
remark: manualSettle.form.remark,
commission_split: manualSettle.form.commission_split.map((row) => ({
admin_id: row.admin_id,
handling_fee_rate: row.handling_fee_rate ?? 0,
})),
},
},
{ showSuccessMessage: true }
@@ -944,6 +1297,7 @@ const baTable = new baTableClass(
defaultItems: {
status: '1',
agent_mode: 'turnover',
settlement_handling_fee: 0,
settle_cycle: 'weekly',
settle_weekday: 1,
settle_monthday: 1,
@@ -976,6 +1330,8 @@ baTable.after.getData = () => {
provide('baTable', baTable)
onMounted(() => {
syncManualSettleViewport()
window.addEventListener('resize', syncManualSettleViewport)
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
@@ -984,6 +1340,10 @@ onMounted(() => {
})
void loadSettleStats()
})
onUnmounted(() => {
window.removeEventListener('resize', syncManualSettleViewport)
})
</script>
<style scoped lang="scss">
@@ -1119,16 +1479,59 @@ onMounted(() => {
flex-wrap: wrap;
}
/* 手动结算弹窗:可滚动(勿使用 ba-operate-dialog 固定 58vh 高度) */
:deep(.manual-settle-dialog.el-dialog) {
display: flex !important;
flex-direction: column;
width: 92% !important;
max-width: 860px;
max-height: 92vh;
margin: 4vh auto !important;
padding-bottom: 0;
overflow: hidden;
}
:deep(.manual-settle-dialog .el-dialog__header) {
flex-shrink: 0;
padding: 12px 16px;
margin-right: 0;
}
:deep(.manual-settle-dialog .el-dialog__body) {
flex: 1 1 auto;
min-height: 0;
height: auto !important;
max-height: calc(92vh - 120px);
overflow-y: auto !important;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 12px 16px 16px;
}
:deep(.manual-settle-dialog .el-dialog__footer) {
flex-shrink: 0;
position: static;
width: auto;
box-shadow: none;
border-top: 1px solid var(--el-border-color-lighter);
}
.manual-settle-dialog-body {
max-height: min(70vh, 680px);
overflow: auto;
padding-right: 2px;
min-height: 0;
overflow: visible;
}
:deep(.manual-settle-dialog-body .el-loading-mask) {
border-radius: 4px;
}
.manual-settle-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 16px;
min-width: 0;
max-width: 100%;
}
.manual-settle-form :deep(.el-form-item) {
@@ -1137,6 +1540,127 @@ onMounted(() => {
.manual-settle-form-item-full {
grid-column: 1 / -1;
min-width: 0;
}
.manual-settle-form-item-full :deep(.el-form-item__content) {
min-width: 0;
max-width: 100%;
}
.manual-settle-split-block {
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.manual-settle-split-table-scroll {
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.manual-settle-split-table-scroll.is-mobile {
overflow-x: scroll;
overflow-y: hidden;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-fill-color-blank);
}
.manual-settle-split-table-inner.is-mobile {
display: inline-block;
max-width: none;
vertical-align: top;
}
.manual-settle-split-table-scroll.is-mobile :deep(.el-table) {
width: 100% !important;
}
.manual-settle-split-table-scroll.is-mobile :deep(.el-table__inner-wrapper) {
width: 100% !important;
}
.manual-settle-split-table-scroll.is-mobile :deep(.el-table__body-wrapper),
.manual-settle-split-table-scroll.is-mobile :deep(.el-table__header-wrapper) {
overflow: visible !important;
}
.manual-settle-split-table-scroll.is-mobile :deep(.el-scrollbar__wrap) {
overflow: visible !important;
}
.manual-settle-split-table-scroll.is-mobile :deep(.el-scrollbar__bar.is-horizontal) {
display: none !important;
}
.manual-settle-split-scroll-tip {
margin: 6px 0 0;
font-size: 11px;
line-height: 1.4;
color: var(--el-text-color-secondary);
text-align: center;
}
.manual-settle-split-table:not(.is-mobile) {
width: 100%;
}
.manual-settle-split-table :deep(.el-table__header th.el-table__cell),
.manual-settle-split-table :deep(.el-table__body td.el-table__cell) {
padding: 6px 4px;
}
.manual-settle-split-table :deep(.el-table__header .cell),
.manual-settle-split-table :deep(.el-table__body .cell) {
padding: 0 2px;
font-size: 12px;
line-height: 1.35;
word-break: break-all;
}
.manual-settle-split-table :deep(.el-table__header .cell) {
white-space: normal;
}
.manual-settle-split-table.is-mobile :deep(.el-table__header th.el-table__cell),
.manual-settle-split-table.is-mobile :deep(.el-table__body td.el-table__cell) {
padding: 5px 3px;
}
.manual-settle-split-table.is-mobile :deep(.el-table__header .cell),
.manual-settle-split-table.is-mobile :deep(.el-table__body .cell) {
font-size: 11px;
line-height: 1.3;
white-space: nowrap;
word-break: keep-all;
overflow-wrap: normal;
}
.manual-settle-split-table.is-mobile :deep(.el-table__body td:first-child .cell) {
white-space: nowrap;
word-break: keep-all;
}
.manual-settle-calc-alert {
margin-top: 12px;
}
.manual-settle-calc-list {
margin: 0;
padding-left: 18px;
line-height: 1.6;
font-size: 13px;
}
.manual-settle-calc-list li + li {
margin-top: 4px;
}
.manual-settle-footer {
@@ -1207,6 +1731,82 @@ onMounted(() => {
}
@media (max-width: 768px) {
:deep(.manual-settle-dialog.el-dialog) {
width: 96% !important;
max-width: none;
max-height: 94vh;
margin: 3vh auto !important;
}
:deep(.manual-settle-dialog .el-dialog__header) {
padding: 10px 12px;
}
:deep(.manual-settle-dialog .el-dialog__body) {
max-height: calc(94vh - 96px);
padding: 10px 10px 12px;
}
:deep(.manual-settle-dialog .el-dialog__footer) {
padding: 8px 10px 10px;
}
.manual-settle-form :deep(.el-form-item) {
margin-bottom: 10px;
}
.manual-settle-form :deep(.el-form-item__label) {
padding-bottom: 4px;
line-height: 1.4;
}
.manual-settle-form-item-full :deep(.el-form-item__content) {
width: 100%;
max-width: 100%;
min-width: 0;
}
.manual-settle-split-block {
max-width: 100%;
min-width: 0;
overflow: hidden;
margin: 0;
padding-bottom: 0;
}
.manual-settle-split-table-scroll.is-mobile {
max-width: 100%;
}
.manual-settle-calc-alert {
margin-top: 10px;
}
.manual-settle-calc-alert :deep(.el-alert__title) {
font-size: 13px;
line-height: 1.4;
}
.manual-settle-calc-alert :deep(.el-alert__content) {
max-height: none;
overflow: visible;
}
.manual-settle-calc-list {
font-size: 12px;
padding-left: 16px;
}
.manual-settle-footer {
flex-wrap: wrap;
justify-content: stretch;
}
.manual-settle-footer .el-button {
flex: 1;
margin: 0;
}
:deep(.channel-record-dialog.el-dialog) {
width: 96% !important;
max-height: 94vh;

View File

@@ -69,6 +69,14 @@
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100 }"
:placeholder="`${t('Please input field', { field: t('channel.turnover_share_rate') })} (例如 30.5)`"
/>
<FormItem
:label="t('channel.settlement_handling_fee')"
type="number"
v-model="baTable.form.items!.settlement_handling_fee"
prop="settlement_handling_fee"
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100 }"
:placeholder="t('channel.settlement_handling_fee_tip')"
/>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_contract_no')"