1.修改电话号码格式为60前缀,马来西亚格式

2.优化渠道可以查看分红方式,可以查看游玩详情
This commit is contained in:
2026-05-30 11:09:54 +08:00
parent 28d0100d5a
commit e65c3474bd
37 changed files with 1772 additions and 113 deletions

View File

@@ -4,6 +4,7 @@ namespace app\admin\controller;
use Throwable;
use app\common\controller\Backend;
use app\common\service\AdminChannelScopeService;
use app\common\service\ChannelSettlementService;
use support\think\Db;
use support\Response;
@@ -17,7 +18,16 @@ class Channel extends Backend
/**
* 预览接口与手动结算共用「手动结算」按钮权限(避免额外菜单节点)
*/
protected array $noNeedPermission = ['manualSettlePreview', 'channelAdminShareList', 'saveChannelAdminShare', 'batchSettlePending', 'settleStats'];
protected array $noNeedPermission = [
'manualSettlePreview',
'channelAdminShareList',
'saveChannelAdminShare',
'batchSettlePending',
'settleStats',
'dividendRecordList',
'directBetRecordList',
'settlementBetRecordList',
];
/**
* Channel模型对象
@@ -34,15 +44,40 @@ class Channel extends Backend
protected bool $modelSceneValidate = true;
private array $currentChannelIds = [];
/** @var array<int, int> 当前管理员绑定的渠道(写操作范围) */
private array $ownChannelIds = [];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\Channel();
$this->currentChannelIds = $this->getCurrentChannelIds();
$this->ownChannelIds = $this->resolveOwnChannelIds();
return null;
}
/**
* 列表/统计按可读渠道收窄null 表示不限制
*
* @param array<int, array<int|string, mixed>> $where
*/
private function appendReadableChannelWhere(array &$where, array $alias, string $tableKey = 'channel'): void
{
$scope = $this->readableChannelIds();
if ($scope !== null) {
$where[] = [$alias[$tableKey] . '.id', 'in', $scope];
}
}
/**
* @param \think\db\BaseQuery|\think\db\Query $query
*/
private function applyReadableChannelScope($query, string $column): void
{
$scope = $this->readableChannelIds();
if ($scope !== null) {
$query->where($column, 'in', $scope);
}
}
/**
* 渠道-管理员树(父级=渠道,子级=管理员,仅可选择子级)
*/
@@ -54,9 +89,7 @@ class Channel extends Backend
$query = Db::name('channel')
->field(['id', 'name'])
->order('id', 'asc');
if (!$this->auth->isSuperAdmin()) {
$query = $query->where('id', 'in', $this->currentChannelIds ?: [0]);
}
$this->applyReadableChannelScope($query, 'id');
$channels = $query->select()->toArray();
$tree = [];
@@ -161,7 +194,7 @@ class Channel extends Backend
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array($row['id'], $this->currentChannelIds, true)) {
if (!$this->assertChannelVisible((int) $row['id'])) {
return $this->error(__('You have no permission'));
}
@@ -171,6 +204,9 @@ class Channel extends Backend
}
if ($this->request && $this->request->method() === 'POST') {
if (!$this->assertChannelWritable((int) $row['id'])) {
return $this->error(__('You have no permission'));
}
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
@@ -220,6 +256,21 @@ class Channel extends Backend
]);
}
/**
* 删除:仅允许删除本人绑定渠道(查看所有渠道不扩大写权限)
*/
protected function _del(): Response
{
$ids = $this->request ? ($this->request->post('ids') ?? $this->request->get('ids') ?? []) : [];
$ids = is_array($ids) ? $ids : [];
foreach ($ids as $id) {
if (!$this->assertChannelWritable((int) $id)) {
return $this->error(__('You have no permission'));
}
}
return parent::_del();
}
/**
* 查看
* @throws Throwable
@@ -231,9 +282,7 @@ class Channel extends Backend
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
if (!$this->auth->isSuperAdmin()) {
$where[] = [$alias['channel'] . '.id', 'in', $this->currentChannelIds ?: [0]];
}
$this->appendReadableChannelWhere($where, $alias);
$res = $this->model
->alias($alias)
->where($where)
@@ -269,12 +318,18 @@ class Channel extends Backend
->where('status', 2)
->group('channel_id')
->column('SUM(total_amount - win_amount - jackpot_extra_amount) AS p', 'channel_id');
$directBetMap = Db::name('game_play_record')
->where('channel_id', 'in', $channelIds)
->group('channel_id')
->column('SUM(total_amount) AS s', 'channel_id');
foreach ($items as $k => $item) {
$cid = intval($item['id'] ?? 0);
if ($cid <= 0) {
continue;
}
$items[$k]['user_count'] = intval($userCountMap[$cid] ?? 0);
$directBet = strval($directBetMap[$cid] ?? '0.00');
$items[$k]['direct_bet_amount'] = bcadd($directBet, '0', 2);
$profit = strval($profitMap[$cid] ?? '0.00');
$items[$k]['profit_amount'] = bcadd($profit, '0', 2);
if (!isset($items[$k]['total_profit_amount']) || $items[$k]['total_profit_amount'] === null || $items[$k]['total_profit_amount'] === '') {
@@ -302,7 +357,7 @@ class Channel extends Backend
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/manualSettle')) {
if (!$this->canManualSettle()) {
return $this->error(__('You have no permission'));
}
@@ -314,7 +369,7 @@ class Channel extends Backend
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
if (!$this->assertChannelVisible((int) $row['id'])) {
return $this->error(__('You have no permission'));
}
@@ -346,7 +401,7 @@ class Channel extends Backend
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
if (!$this->assertChannelVisible((int) $row['id'])) {
return $this->error(__('You have no permission'));
}
@@ -537,7 +592,7 @@ class Channel extends Backend
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
if (!$this->assertChannelWritable((int) $row['id'])) {
return $this->error(__('You have no permission'));
}
$rowsRaw = $request->post('list', []);
@@ -613,8 +668,8 @@ class Channel extends Backend
if ($response !== null) {
return $response;
}
if (!$this->auth->isSuperAdmin()) {
return $this->error(__('Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets'));
if (!$this->canManualSettle()) {
return $this->error(__('You have no permission'));
}
$id = (int) ($request->post('id', $request->get('id', 0)));
@@ -625,7 +680,7 @@ class Channel extends Backend
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
if (!$this->assertChannelVisible((int) $row['id'])) {
return $this->error(__('You have no permission'));
}
@@ -635,7 +690,7 @@ class Channel extends Backend
if (($res['ok'] ?? false) !== true) {
return $this->error((string) ($res['msg'] ?? __('Settlement failed')));
}
return $this->success(__('Super admin settlement completed; paid automatically by share ratios'));
return $this->success(__('Settlement completed; commissions paid automatically by share ratios'));
}
/**
@@ -665,9 +720,7 @@ class Channel extends Backend
return $response;
}
$query = Db::name('channel');
if (!$this->auth->isSuperAdmin()) {
$query->where('id', 'in', $this->currentChannelIds ?: [0]);
}
$this->applyReadableChannelScope($query, 'id');
$rows = $query->field(['id', 'status', 'carryover_balance'])->select()->toArray();
$total = count($rows);
$enabled = 0;
@@ -675,7 +728,12 @@ class Channel extends Backend
$carryoverPositiveCount = 0;
$carryoverTotal = '0.00';
$carryoverPositiveTotal = '0.00';
$channelIdList = [];
foreach ($rows as $row) {
$cid = intval($row['id'] ?? 0);
if ($cid > 0) {
$channelIdList[] = $cid;
}
$status = intval($row['status'] ?? 0);
if ($status === 1) {
$enabled++;
@@ -689,6 +747,15 @@ class Channel extends Backend
$carryoverPositiveTotal = bcadd($carryoverPositiveTotal, $carry, 2);
}
}
$paidDividendTotal = '0.00';
if ($channelIdList !== []) {
$paidRow = Db::name('agent_commission_record')
->where('channel_id', 'in', $channelIdList)
->where('status', 1)
->field('SUM(commission_amount) AS s')
->find();
$paidDividendTotal = bcadd(strval(is_array($paidRow) ? ($paidRow['s'] ?? '0') : '0'), '0', 2);
}
return $this->success('', [
'channel_total' => $total,
'enabled_count' => $enabled,
@@ -696,9 +763,418 @@ class Channel extends Backend
'carryover_positive_count' => $carryoverPositiveCount,
'carryover_total' => $carryoverTotal,
'carryover_positive_total' => $carryoverPositiveTotal,
'paid_dividend_total' => $paidDividendTotal,
]);
}
/**
* 已分红记录列表(顶部统计卡片点击)
*/
public function dividendRecordList(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/viewDividendRecords')) {
return $this->error(__('You have no permission'));
}
[$page, $limit] = $this->parseListPageParams($request);
$channelId = (int) ($request->get('channel_id', 0));
$query = Db::name('agent_commission_record')->alias('acr')
->leftJoin('agent_settlement_period asp', 'acr.settlement_period_id = asp.id')
->leftJoin('channel c', 'acr.channel_id = c.id')
->leftJoin('admin a', 'acr.admin_id = a.id')
->where('acr.status', 1);
$this->applyReadableChannelScope($query, 'acr.channel_id');
if ($channelId > 0) {
if (!$this->assertChannelAccessible($channelId)) {
return $this->error(__('You have no permission'));
}
$query->where('acr.channel_id', $channelId);
}
$total = (int) $query->count('acr.id');
$list = $query
->field([
'acr.id',
'acr.channel_id',
'acr.admin_id',
'acr.commission_amount',
'acr.settled_at',
'acr.remark',
'asp.settlement_no',
'asp.period_start_at',
'asp.period_end_at',
'c.name as channel_name',
'a.username as admin_username',
])
->order('acr.settled_at', 'desc')
->order('acr.id', 'desc')
->page($page, $limit)
->select()
->toArray();
foreach ($list as $idx => $row) {
if (!is_array($row)) {
continue;
}
$startTs = intval($row['period_start_at'] ?? 0);
$endTs = intval($row['period_end_at'] ?? 0);
$list[$idx]['period_start_at'] = $startTs > 0 ? date('Y-m-d H:i:s', $startTs) : '';
$list[$idx]['period_end_at'] = $endTs > 0 ? date('Y-m-d H:i:s', $endTs) : '';
$settledTs = intval($row['settled_at'] ?? 0);
$list[$idx]['settled_at'] = $settledTs > 0 ? date('Y-m-d H:i:s', $settledTs) : '';
$list[$idx]['commission_amount'] = bcadd(strval($row['commission_amount'] ?? '0'), '0', 2);
}
return $this->success('', [
'list' => $list,
'total' => $total,
]);
}
/**
* 渠道直属玩家下注记录(直属投注额列点击)
*/
public function directBetRecordList(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/viewDirectBetRecords')) {
return $this->error(__('You have no permission'));
}
$channelId = (int) ($request->get('channel_id', 0));
if ($channelId <= 0 || !$this->assertChannelAccessible($channelId)) {
return $this->error(__('You have no permission'));
}
return $this->success('', $this->fetchChannelPlayRecordListPayload($request, $channelId, false));
}
/**
* 参与分红口径的下注记录(操作列「查看总投注金额」)
*/
public function settlementBetRecordList(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/viewSettlementBetRecords')) {
return $this->error(__('You have no permission'));
}
$channelId = (int) ($request->get('channel_id', 0));
if ($channelId <= 0 || !$this->assertChannelAccessible($channelId)) {
return $this->error(__('You have no permission'));
}
return $this->success('', $this->fetchChannelPlayRecordListPayload($request, $channelId, true));
}
/**
* @return array{list: array<int, array<string, mixed>>, total: int, summary: array{record_count:int,total_bet_amount:string,total_win_amount:string}}
*/
private function fetchChannelPlayRecordListPayload(WebmanRequest $request, int $channelId, bool $settledOnly): array
{
[$page, $limit] = $this->parseListPageParams($request);
$filters = $this->parsePlayRecordListFilters($request);
$listQuery = $this->buildChannelPlayRecordQuery($channelId, $settledOnly);
$this->applyPlayRecordListFilters($listQuery, $filters);
$total = (int) $listQuery->count('pr.id');
$list = $listQuery
->field($this->channelPlayRecordListFields())
->order('pr.id', 'desc')
->page($page, $limit)
->select()
->toArray();
$summaryQuery = $this->buildChannelPlayRecordQuery($channelId, $settledOnly);
$this->applyPlayRecordListFilters($summaryQuery, $filters);
$summary = $this->summarizePlayRecordQuery($summaryQuery);
return [
'list' => $this->normalizePlayRecordListRows($list),
'total' => $total,
'summary' => $summary,
];
}
/**
* @return array{period_no: string, user_keyword: string, result_number: string, pick_number: string, win_hit: string}
*/
private function parsePlayRecordListFilters(WebmanRequest $request): array
{
$winHit = trim((string) $request->get('win_hit', ''));
if (!in_array($winHit, ['won', 'lost', 'pending'], true)) {
$winHit = '';
}
return [
'period_no' => trim((string) $request->get('period_no', '')),
'user_keyword' => trim((string) $request->get('user_keyword', '')),
'result_number' => trim((string) $request->get('result_number', '')),
'pick_number' => trim((string) $request->get('pick_number', '')),
'win_hit' => $winHit,
];
}
/**
* @param \think\db\Query $query
* @param array{period_no: string, user_keyword: string, result_number: string, pick_number: string, win_hit: string} $filters
*/
private function applyPlayRecordListFilters($query, array $filters): void
{
if ($filters['period_no'] !== '') {
$like = '%' . $this->escapeLikeKeyword($filters['period_no']) . '%';
$query->where(function ($sub) use ($like) {
$sub->where('pr.period_no', 'like', $like)->whereOr('gr.period_no', 'like', $like);
});
}
if ($filters['user_keyword'] !== '') {
$like = '%' . $this->escapeLikeKeyword($filters['user_keyword']) . '%';
$query->where(function ($sub) use ($like) {
$sub->where('u.username', 'like', $like)->whereOr('u.phone', 'like', $like);
});
}
if ($filters['result_number'] !== '') {
$query->where('gr.result_number', $filters['result_number']);
}
if ($filters['pick_number'] !== '') {
$like = '%' . $this->escapeLikeKeyword($filters['pick_number']) . '%';
$query->where('pr.pick_numbers', 'like', $like);
}
if ($filters['win_hit'] === 'won') {
$query->where('pr.status', 2)
->whereRaw('(pr.win_amount + IFNULL(pr.jackpot_extra_amount, 0)) > 0');
} elseif ($filters['win_hit'] === 'lost') {
$query->where('pr.status', 2)
->whereRaw('(pr.win_amount + IFNULL(pr.jackpot_extra_amount, 0)) <= 0');
} elseif ($filters['win_hit'] === 'pending') {
$query->where('pr.status', '<>', 2);
}
}
private function escapeLikeKeyword(string $keyword): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $keyword);
}
/**
* @return array{0:int,1:int}
*/
private function parseListPageParams(WebmanRequest $request): array
{
$pageRaw = $request->get('page', 1);
$page = is_numeric((string) $pageRaw) ? (int) $pageRaw : 1;
if ($page < 1) {
$page = 1;
}
$limitRaw = $request->get('limit', 20);
$limit = is_numeric((string) $limitRaw) ? (int) $limitRaw : 20;
if ($limit < 1) {
$limit = 1;
}
if ($limit > 200) {
$limit = 200;
}
return [$page, $limit];
}
private function assertChannelVisible(int $channelId): bool
{
if ($channelId <= 0) {
return false;
}
$scope = $this->readableChannelIds();
if ($scope === null) {
return Db::name('channel')->where('id', $channelId)->value('id') !== null;
}
return in_array($channelId, $scope, true);
}
private function assertChannelWritable(int $channelId): bool
{
if ($channelId <= 0) {
return false;
}
if ($this->auth->isSuperAdmin()) {
return Db::name('channel')->where('id', $channelId)->value('id') !== null;
}
return in_array($channelId, $this->ownChannelIds, true);
}
private function assertChannelAccessible(int $channelId): bool
{
return $this->assertChannelVisible($channelId);
}
/**
* @return \think\db\Query
*/
private function canManualSettle(): bool
{
if ($this->auth === null) {
return false;
}
if ($this->auth->isSuperAdmin()) {
return true;
}
return $this->auth->check('channel/manualSettle');
}
private function buildChannelPlayRecordQuery(int $channelId, bool $settledOnly)
{
$query = Db::name('game_play_record')->alias('pr')
->leftJoin('user u', 'u.id = pr.user_id')
->leftJoin('game_record gr', 'gr.id = pr.period_id')
->leftJoin('channel c', 'c.id = pr.channel_id')
->where('pr.channel_id', $channelId);
if ($settledOnly) {
$query->where('pr.status', 2);
}
return $query;
}
/**
* @return array<int, string>
*/
private function channelPlayRecordListFields(): array
{
return [
'pr.id',
'pr.period_no',
'pr.period_id',
'pr.user_id',
'pr.pick_numbers',
'pr.total_amount',
'pr.win_amount',
'pr.jackpot_extra_amount',
'pr.status',
'pr.create_time',
'u.username as user_username',
'c.name as channel_name',
'gr.period_no as game_record_period_no',
'gr.result_number',
'gr.status as game_record_status',
];
}
/**
* @param array<int, array<string, mixed>> $list
* @return array<int, array<string, mixed>>
*/
private function normalizePlayRecordListRows(array $list): array
{
$out = [];
foreach ($list as $row) {
if (!is_array($row)) {
continue;
}
$periodNo = trim((string) ($row['period_no'] ?? ''));
if ($periodNo === '') {
$periodNo = trim((string) ($row['game_record_period_no'] ?? ''));
}
$win = bcadd(strval($row['win_amount'] ?? '0'), strval($row['jackpot_extra_amount'] ?? '0'), 2);
$playStatus = intval($row['status'] ?? 0);
$out[] = [
'id' => intval($row['id'] ?? 0),
'period_no' => $periodNo,
'user_username' => (string) ($row['user_username'] ?? ''),
'channel_name' => (string) ($row['channel_name'] ?? ''),
'pick_numbers' => $this->formatPickNumbersForList($row['pick_numbers'] ?? null),
'result_number' => $this->formatResultNumberForList($row['result_number'] ?? null),
'win_hit' => $this->resolveWinHitCode($win, $playStatus),
'play_status' => $playStatus,
'total_amount' => bcadd(strval($row['total_amount'] ?? '0'), '0', 2),
'win_amount' => bcadd($win, '0', 2),
'create_time' => intval($row['create_time'] ?? 0),
];
}
return $out;
}
private function formatPickNumbersForList(mixed $pickNumbers): string
{
if ($pickNumbers === null || $pickNumbers === '') {
return '';
}
if (is_string($pickNumbers)) {
$trimmed = trim($pickNumbers);
if ($trimmed === '') {
return '';
}
$decoded = json_decode($trimmed, true);
if (is_array($decoded)) {
$pickNumbers = $decoded;
} else {
return $trimmed;
}
}
if (!is_array($pickNumbers)) {
return '';
}
$parts = [];
foreach ($pickNumbers as $num) {
if ($num === null || $num === '') {
continue;
}
$parts[] = (string) $num;
}
return implode(',', $parts);
}
private function formatResultNumberForList(mixed $resultNumber): string
{
if ($resultNumber === null || $resultNumber === '') {
return '';
}
return (string) $resultNumber;
}
private function resolveWinHitCode(string $winAmount, int $playStatus): string
{
if ($playStatus > 0 && $playStatus !== 2) {
return 'pending';
}
if (bccomp($winAmount, '0', 2) > 0) {
return 'won';
}
return 'lost';
}
/**
* @param \think\db\Query $query
* @return array{record_count:int,total_bet_amount:string,total_win_amount:string}
*/
private function summarizePlayRecordQuery($query): array
{
$row = $query
->field('COUNT(pr.id) AS c, SUM(pr.total_amount) AS tb, SUM(pr.win_amount) AS tw, SUM(pr.jackpot_extra_amount) AS tj')
->find();
$count = is_array($row) ? intval($row['c'] ?? 0) : 0;
$tb = is_array($row) ? strval($row['tb'] ?? '0') : '0';
$tw = is_array($row) ? strval($row['tw'] ?? '0') : '0';
$tj = is_array($row) ? strval($row['tj'] ?? '0') : '0';
return [
'record_count' => $count,
'total_bet_amount' => bcadd($tb, '0', 2),
'total_win_amount' => bcadd(bcadd($tw, $tj, 2), '0', 2),
];
}
/**
* @return array|string 成功返回预览数据数组,失败返回错误文案
*/
@@ -936,19 +1412,31 @@ class Channel extends Backend
return $chosen;
}
private function getCurrentChannelIds(): array
/**
* @return array<int, int>
*/
/**
* 写操作可作用的渠道(角色组绑定渠道 + 账号 channel_id不含全平台只读
*
* @return array<int, int>
*/
private function resolveOwnChannelIds(): array
{
if ($this->auth->isSuperAdmin()) {
return Db::name('channel')->column('id');
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();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
$ids[] = (int) $admin['channel_id'];
}
return array_values(array_unique($ids));
}

View File

@@ -379,7 +379,7 @@ class Dashboard extends Backend
*/
private function ownerAdminIdOrNull(): ?int
{
if (!$this->auth || $this->auth->isSuperAdmin()) {
if (!$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
return null;
}
$idRaw = $this->auth->id;

View File

@@ -76,7 +76,7 @@ class PlayRecord extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
}
@@ -182,7 +182,7 @@ class PlayRecord extends Backend
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
// 权限范围校验:复用列表逻辑(非超管只能操作其下辖用户)
if ($this->auth && !$this->auth->isSuperAdmin()) {
if ($this->shouldApplyUserAdminScope()) {
$uidRaw = Db::name('game_play_record')->where('id', $id)->value('user_id');
$uid = ($uidRaw === null || $uidRaw === '' || !is_numeric(strval($uidRaw))) ? 0 : (int) $uidRaw;
if ($uid <= 0) {
@@ -240,7 +240,7 @@ class PlayRecord extends Backend
return $this->error(__('Please provide reject reason'));
}
if ($this->auth && !$this->auth->isSuperAdmin()) {
if ($this->shouldApplyUserAdminScope()) {
$uidRaw = Db::name('game_play_record')->where('id', $id)->value('user_id');
$uid = ($uidRaw === null || $uidRaw === '' || !is_numeric(strval($uidRaw))) ? 0 : (int) $uidRaw;
if ($uid <= 0) {

View File

@@ -78,7 +78,7 @@ class UserNoticeRead extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
}

View File

@@ -42,8 +42,9 @@ class AdminWithdrawOrder extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
$where[] = [$mainShort . '.channel_id', 'in', $this->getCurrentAdminChannelIds()];
$channelScope = $this->readableChannelIds();
if ($mainShort !== '' && $channelScope !== null) {
$where[] = [$mainShort . '.channel_id', 'in', $channelScope];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
@@ -170,8 +171,9 @@ class AdminWithdrawOrder extends Backend
return $response;
}
$query = Db::name('admin_withdraw_order');
if ($this->auth && !$this->auth->isSuperAdmin()) {
$query->where('channel_id', 'in', $this->getCurrentAdminChannelIds());
$channelScope = $this->readableChannelIds();
if ($channelScope !== null) {
$query->where('channel_id', 'in', $channelScope);
}
$rows = $query->field(['status', 'amount', 'actual_amount'])->select()->toArray();
$total = count($rows);
@@ -227,53 +229,19 @@ class AdminWithdrawOrder extends Backend
if (!$this->auth) {
return false;
}
if ($this->auth->isSuperAdmin()) {
if ($this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
return true;
}
$channelId = intval($order['channel_id'] ?? 0);
if ($channelId <= 0) {
return false;
}
$allowed = $this->getCurrentAdminChannelIds();
$allowed = $this->readableChannelIds();
if ($allowed === null) {
return true;
}
return in_array($channelId, $allowed, true);
}
/**
* 当前管理员可审核的渠道(优先取自身 channel_id同时兼容角色组继承链上的 channel_id
*
* @return int[]
*/
private function getCurrentAdminChannelIds(): array
{
$uid = intval($this->auth->id ?? 0);
if ($uid <= 0) {
return [0];
}
$channelIds = [];
$selfChannelId = intval(Db::name('admin')->where('id', $uid)->value('channel_id') ?? 0);
if ($selfChannelId > 0) {
$channelIds[] = $selfChannelId;
}
$groupIds = Db::name('admin_group_access')->where('uid', $uid)->column('group_id');
if ($groupIds !== []) {
$groupIds = array_values(array_unique(array_merge($groupIds, $this->auth->getAdminChildGroups())));
$rows = Db::name('admin_group')
->field(['id', 'channel_id'])
->where('id', 'in', $groupIds)
->whereNotNull('channel_id')
->select()
->toArray();
foreach ($rows as $row) {
$cid = intval($row['channel_id'] ?? 0);
if ($cid > 0) {
$channelIds[] = $cid;
}
}
}
return $channelIds === [] ? [0] : array_values(array_unique($channelIds));
}
}

View File

@@ -77,7 +77,7 @@ class BetOrder extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
}

View File

@@ -48,7 +48,7 @@ class DepositOrder extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
}
$this->appendDepositOrderIndexWhere($where, $mainShort);
@@ -129,7 +129,7 @@ class DepositOrder extends Backend
private function checkChannelScoped(array $row): bool
{
if (!$this->auth || $this->auth->isSuperAdmin()) {
if (!$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
return true;
}
$userRow = $row['user'] ?? null;

View File

@@ -49,7 +49,7 @@ class WithdrawOrder extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
}
@@ -658,7 +658,7 @@ class WithdrawOrder extends Backend
private function checkChannelScoped(array|object $row): bool
{
if (!$this->auth || $this->auth->isSuperAdmin()) {
if (!$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
return true;
}
$uidRaw = is_array($row) ? ($row['user_id'] ?? null) : ($row->user_id ?? null);

View File

@@ -77,7 +77,7 @@ class UserWalletRecord extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
}

View File

@@ -1,4 +1,4 @@
<?php
return [
'Remark lang' => '开源等于互助;开源需要大家一起来支持,支持的方式有很多种,比如使用、推荐、写教程、保护生态、贡献代码、回答问题、分享经验、打赏赞助等;欢迎您加入我们!',
'Remark lang' => 'telegram@zhenhui666',
];

View File

@@ -5,6 +5,7 @@ namespace app\api\controller;
use ba\Date;
use ba\Captcha;
use ba\Random;
use app\common\library\MalaysiaMobilePhone;
use app\common\library\finance\WithdrawFlow;
use app\common\model\User;
use app\common\facade\Token;
@@ -235,7 +236,7 @@ class Account extends Frontend
return $this->error(__('Email') . ' ' . __('already exists'));
}
} else {
Validator::make($params, ['mobile' => 'required|regex:/^1[3-9]\d{9}$/'])->validate();
Validator::make($params, ['mobile' => 'required|regex:' . MalaysiaMobilePhone::PATTERN])->validate();
if (User::where('mobile', $params['mobile'])->find()) {
return $this->error(__('Mobile') . ' ' . __('already exists'));
}

View File

@@ -6,6 +6,7 @@ namespace app\api\controller;
use app\common\facade\Token;
use app\common\library\Auth as UserAuth;
use app\common\library\MalaysiaMobilePhone;
use app\common\model\User;
use app\common\service\MobileAuthDeviceService;
use ba\Random;
@@ -38,7 +39,7 @@ class Auth extends MobileBase
if ($inviteCode === '') {
return $this->mobileError(1001, 'Invite code required');
}
if (!preg_match('/^1[3-9]\d{9}$/', $username)) {
if (!MalaysiaMobilePhone::isValid($username)) {
return $this->mobileError(1003, 'Please enter the correct mobile number');
}

View File

@@ -2,6 +2,7 @@
namespace app\api\controller;
use app\common\library\MalaysiaMobilePhone;
use ba\Captcha;
use ba\ClickCaptcha;
use app\common\controller\Frontend;
@@ -138,7 +139,7 @@ class User extends Frontend
'password' => 'required|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/',
'registerType' => 'required|in:email,mobile',
'email' => 'required_if:registerType,email|email|unique:user,email',
'mobile' => 'required_if:registerType,mobile|regex:/^1[3-9]\d{9}$/|unique:user,mobile',
'mobile' => 'required_if:registerType,mobile|regex:' . MalaysiaMobilePhone::PATTERN . '|unique:user,mobile',
'captcha' => 'required|string',
'invite_code' => 'nullable|string|max:64',
],

View File

@@ -30,7 +30,7 @@ return [
'Invite code not bound to channel' => 'This invite code is not bound to a valid channel',
'Channel disabled' => 'Channel is disabled',
'Account already registered' => 'This phone number is already registered. Please sign in.',
'Please enter the correct mobile number' => 'Please enter the correct mobile number',
'Please enter the correct mobile number' => 'Please enter a valid Malaysia mobile number starting with 60',
'Registered successfully but login failed' => 'Registered successfully but login failed',
'Incorrect account or password' => 'Incorrect account or password',
'Login status has expired' => 'Login status has expired',

View File

@@ -62,7 +62,7 @@ return [
'Invite code not bound to channel' => '该邀请码未绑定有效渠道',
'Channel disabled' => '渠道已关闭',
'Account already registered' => '该手机号已注册,请直接登录',
'Please enter the correct mobile number' => '请输入正确的手机号',
'Please enter the correct mobile number' => '请输入正确的马来西亚手机号60开头',
'Registered successfully but login failed' => '注册成功但登录失败',
'Incorrect account or password' => '账号或密码错误',
'Login status has expired' => '登录状态已过期',

View File

@@ -6,6 +6,7 @@ namespace app\common\controller;
use Throwable;
use app\admin\library\Auth;
use app\common\service\AdminChannelScopeService;
use app\common\library\token\TokenExpirationException;
use app\admin\library\traits\Backend as BackendTrait;
use support\Response;
@@ -421,7 +422,7 @@ class Backend extends Api
*/
protected function getDataLimitAdminIds(): array
{
if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin()) {
if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
return [];
}
$adminIds = [];
@@ -475,6 +476,51 @@ class Backend extends Api
return get_controller_path($request);
}
/**
* 全平台只读范围:角色组均未绑定渠道,或拥有查看所有渠道权限,或超管
*/
protected function hasGlobalReadScope(): bool
{
return $this->auth !== null && AdminChannelScopeService::hasGlobalReadScope($this->auth);
}
/**
* 是否按「名下管理员/用户」收窄列表(非超管且非全平台只读范围)
*/
protected function shouldApplyUserAdminScope(): bool
{
if ($this->auth === null || !$this->auth->isLogin()) {
return false;
}
if ($this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
return false;
}
return true;
}
/**
* 可读渠道 IDnull 表示全部渠道
*
* @return array<int, int>|null
*/
protected function readableChannelIds(): ?array
{
if ($this->auth === null) {
return [0];
}
return AdminChannelScopeService::readableChannelIds($this->auth);
}
/**
* 列表是否需要按 channel_id 收窄
*/
protected function shouldApplyChannelIdScope(): bool
{
return $this->readableChannelIds() !== null;
}
/**
* 构造权限节点候选:兼容 snake_case 与 camelCase 节点名
*

View File

@@ -120,6 +120,11 @@ return [
'createNextManual' => 'Create next period manually',
'periodSettings' => 'Period settings',
'manualSettle' => 'Manual settle',
'batchSettlePending' => 'Batch settle pending channels',
'viewDividendRecords' => 'View paid dividend records',
'viewDirectBetRecords' => 'View direct bet records',
'viewSettlementBetRecords' => 'View settlement-scope bets',
'viewAllChannels' => 'View all channels',
// 其它中文按钮文案
'期号开关' => 'Period toggle',

View File

@@ -115,6 +115,7 @@ return [
'Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets' => 'Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets',
'Settlement failed' => 'Settlement failed',
'Super admin settlement completed; paid automatically by share ratios' => 'Super admin settlement completed; paid automatically by share ratios',
'Settlement completed; commissions paid automatically by share ratios' => 'Settlement completed; commissions paid automatically by share ratios',
'Batch settlement completed' => 'Batch settlement completed',
'Invalid settlement cycle' => 'Invalid settlement cycle',

View File

@@ -53,6 +53,10 @@ return [
'periodSettings' => '期号设置',
'manualSettle' => '手动结算',
'batchSettlePending' => '批量结算待结算渠道',
'viewDividendRecords' => '查看已分红记录',
'viewDirectBetRecords' => '查看直属投注记录',
'viewSettlementBetRecords' => '查看总投注金额',
'viewAllChannels' => '查看所有渠道',
'walletAdjust' => '钱包加减点',
'Markdown文档' => 'Markdown文档',
'分红说明文档' => '分红说明文档',

View File

@@ -115,6 +115,7 @@ return [
'Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets' => '仅超管可执行结算,结算后系统会自动发放至管理员钱包',
'Settlement failed' => '结算失败',
'Super admin settlement completed; paid automatically by share ratios' => '超管结算完成,已按分配比例自动发放给管理员',
'Settlement completed; commissions paid automatically by share ratios' => '结算完成,已按分配比例自动发放给管理员',
'Batch settlement completed' => '批量结算完成',
'Invalid settlement cycle' => '结算周期不合法',

View File

@@ -105,7 +105,7 @@ class Auth extends \ba\Auth
$this->setError(__('Email'));
return false;
}
$isMobileUsername = preg_match('/^1[3-9]\d{9}$/', $username) === 1;
$isMobileUsername = MalaysiaMobilePhone::isValid($username);
$isNormalUsername = preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username) === 1;
if ($username && !$isMobileUsername && !$isNormalUsername) {
$this->setError(__('Username'));
@@ -127,7 +127,7 @@ class Auth extends \ba\Auth
return false;
}
$nickname = preg_replace_callback('/1[3-9]\d{9}/', fn($m) => substr($m[0], 0, 3) . '****' . substr($m[0], 7), $username);
$nickname = MalaysiaMobilePhone::maskInNickname($username);
$time = time();
$data = [
'group_id' => $group,
@@ -170,9 +170,13 @@ class Auth extends \ba\Auth
public function login(string $username, string $password, bool $keep): bool
{
$accountType = false;
if (preg_match('/^1[3-9]\d{9}$/', $username)) $accountType = 'phone';
elseif (filter_var($username, FILTER_VALIDATE_EMAIL)) $accountType = 'email';
elseif (preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username)) $accountType = 'username';
if (MalaysiaMobilePhone::isValid($username)) {
$accountType = 'phone';
} elseif (filter_var($username, FILTER_VALIDATE_EMAIL)) {
$accountType = 'email';
} elseif (preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username)) {
$accountType = 'username';
}
if (!$accountType) {
$this->setError('Account not exist');
return false;

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace app\common\library;
/**
* 马来西亚手机号60 国际前缀,不含 +
*/
class MalaysiaMobilePhone
{
public const PATTERN = '/^60(1[0-9])\d{7,9}$/';
public static function isValid(string $phone): bool
{
return $phone !== '' && preg_match(self::PATTERN, $phone) === 1;
}
public static function maskInNickname(string $text): string
{
$masked = preg_replace_callback(
self::PATTERN,
static fn(array $matches): string => substr($matches[0], 0, 3) . '****' . substr($matches[0], -4),
$text
);
return is_string($masked) ? $masked : $text;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\library\Auth;
use support\think\Db;
/**
* 后台管理员渠道数据范围:角色组 channel_id 未绑定时可读全平台;否则按绑定渠道过滤。
*/
class AdminChannelScopeService
{
/**
* 是否具备全平台只读范围(超管 / 角色组均未绑定渠道 / 拥有查看所有渠道权限)
*/
public static function hasGlobalReadScope(Auth $auth): bool
{
if (!$auth->isLogin()) {
return false;
}
if ($auth->isSuperAdmin()) {
return true;
}
if (self::resolveBoundGroupChannelIds($auth) === []) {
return true;
}
return self::hasViewAllChannelsPermission($auth);
}
/**
* 可读渠道 ID 列表null 表示不限制(全部渠道)
*
* @return array<int, int>|null
*/
public static function readableChannelIds(Auth $auth): ?array
{
if (!$auth->isLogin()) {
return [0];
}
if (self::hasGlobalReadScope($auth)) {
return null;
}
$ids = self::resolveBoundGroupChannelIds($auth);
if ($ids !== []) {
return $ids;
}
$selfChannelId = (int) Db::name('admin')->where('id', (int) $auth->id)->value('channel_id');
if ($selfChannelId > 0) {
return [$selfChannelId];
}
return [0];
}
/**
* 当前管理员所属角色组上绑定的渠道 ID去重
*
* @return array<int, int>
*/
public static function resolveBoundGroupChannelIds(Auth $auth): array
{
$uid = (int) $auth->id;
if ($uid <= 0) {
return [];
}
$groupIds = Db::name('admin_group_access')->where('uid', $uid)->column('group_id');
if ($groupIds === []) {
return [];
}
$rows = Db::name('admin_group')
->where('id', 'in', $groupIds)
->whereNotNull('channel_id')
->where('channel_id', '>', 0)
->column('channel_id');
$ids = [];
foreach ($rows as $cid) {
$ids[] = (int) $cid;
}
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=不过滤
*
* @return array<int, int>|null
*/
public static function channelIdFilterForQuery(Auth $auth): ?array
{
return self::readableChannelIds($auth);
}
}

View File

@@ -138,7 +138,7 @@
### 3.3 注意
`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`除超管外,渠道管理仅可查看当前管理员归属渠道
`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`渠道列表**只读范围**:超管、所属角色组均未绑定 `channel_id`、或拥有 `channel/viewAllChannels` 时可查看全平台渠道;否则仅绑定渠道或本人 `admin.channel_id`。详见 `docs/渠道管理后台说明.md`
---
@@ -316,9 +316,9 @@
### 8.3.1 渠道结算新流程2026-04-232026-05-29 树形拆分)
1. **仅超管可结算**:按渠道结算周期(支持自动任务与手动提前结算)执行渠道结算
1. **结算权限**:超管,或拥有按钮权限 `channel/manualSettle` 且目标渠道在可读范围内,可手动提前结算;**一键批量结算**仍仅超管。自动任务由 `ChannelAutoSettleTicker` 执行
2. **结算即发放**:结算时按 **代理树**`admin.parent_admin_id` + `admin.commission_share_rate`)拆分各管理员实得,直接发放到管理员钱包并写入 `admin_wallet_record``commission_income`),同时写 `agent_settlement_period` / `agent_commission_record`
3. **配置位置**:分红比例在 **管理员管理** 维护,不在渠道管理页维护 flat 分配表。
3. **配置位置**:分红比例在 **管理员管理** 维护,不在渠道管理页维护 flat 分配表。渠道页支持查看直属/分红口径下注记录、已分红记录及筛选,见 `docs/渠道管理后台说明.md`
4. **提前结算规则**:手动提前结算后,新的周期起点从本次结算结束时间开始,后续自动周期归入下个结算段。
5. **停用渠道限制**`channel.status != 1` 时,该渠道不再允许玩家注册与登录。
@@ -407,6 +407,7 @@
| V1.16 | 2026-04-23 | 渠道结算改为单阶段口径(仅超管结算,结算即按比例发放管理员钱包并记录操作人)与管理员钱包提现流程(`admin_wallet` / `admin_wallet_record` / `admin_withdraw_order`,渠道顶级组审核) |
| V1.17 | 2026-05-29 | 代理分红改为树形拆分:`admin.commission_share_rate` + `parent_admin_id`;配置迁移至管理员管理;渠道页移除 `channel_admin_share` 入口;管理员列表树形展示与下级可见范围 |
| V1.18 | 2026-05-29 | 顶级角色组(`admin_group.pid=0`)可配置渠道分红比例;表单禁用上级代理并增加说明 |
| V1.19 | 2026-05-30 | 渠道管理:全平台只读范围、`viewAllChannels`、下注记录弹窗与筛选、移动端适配;`manualSettle` 按权限;`docs/渠道管理后台说明.md` |
---

View File

@@ -1002,7 +1002,7 @@ flowchart TD
## 10. 后台代理分红配置管理端补充2026-05-29
> 分红比例在 **管理员管理** `/admin/auth/admin` 维护,不再使用渠道页「分配比例」弹窗。
> 分红比例在 **管理员管理** `/admin/auth/admin` 维护,不再使用渠道页「分配比例」弹窗。渠道页交互(统计、下注记录、筛选、权限)见 `docs/渠道管理后台说明.md`。
### 10.1 页面与展示

View File

@@ -0,0 +1,88 @@
# Channel Management (Admin)
## 1. Purpose
Documents the **Channel Management** page (`/admin/channel`): summary cards, list scope, button permissions, bet-record dialogs, and APIs. For commission calculation and agent tree split, see [commission-share-guide.md](./commission-share-guide.md).
---
## 2. Summary cards
| Card | Description |
|------|-------------|
| Total channels | Count in **readable scope** |
| Enabled | `status = 1` |
| Pending dividend (count) | `carryover_balance > 0` |
| Pending dividend (amount) | Sum of those balances |
| Paid dividend | Sum of paid `agent_commission_record` in scope; clickable dialog requires `viewDividendRecords` |
List filters: **All / With balance / No balance / Enabled only / Disabled only** (UI search only).
---
## 3. Read scope
`AdminChannelScopeService` applies to list and stats:
**Global read** (all channels) if any of:
- Super admin (`*`)
- No `admin_group.channel_id` bound for the users groups
- Button permission `channel/viewAllChannels`
Otherwise: bound group channel IDs, or `admin.channel_id`, or empty.
**Write** (add/edit/delete/manual settle DB) stays on **writable** channels only; `viewAllChannels` does not expand write scope.
---
## 4. Actions & permissions
| 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** |
Re-login after role changes to refresh `authNode`.
---
## 5. Manual settlement
- `GET /admin/channel/manualSettlePreview?id=`
- `POST /admin/channel/manualSettle`
- Super admin **or** `channel/manualSettle` with channel in read scope
- Same payout flow as super-admin settle (`ChannelSettlementService::settleBySuperAdmin`)
---
## 6. Bet record dialogs
| Entry | API | Data |
|-------|-----|------|
| Direct bet amount | `directBetRecordList` | All play records for channel |
| View settlement bets | `settlementBetRecordList` | `status = 2` only |
**Filters (GET):** `period_no`, `user_keyword`, `result_number`, `pick_number`, `win_hit` (`won`/`lost`/`pending`), `page`, `limit`.
**Columns:** period, player, channel, picks, winning number, win status, bet amount, win amount.
**Mobile:** ~92% width, scrollable body, 3 summary cards per row, horizontal table scroll.
---
## 7. APIs
See §8 in the Chinese doc `docs/渠道管理后台说明.md` (same paths under `/admin/channel/`).
---
## 8. Changelog
| Date | Note |
|------|------|
| 2026-05-30 | Bet record columns, filters, mobile layout; `viewAllChannels`; manual settle by permission |

View File

@@ -47,11 +47,12 @@ If a sub-agent has further downline, the same rules apply on **their received am
| Capability | Entry | Notes |
|------------|-------|-------|
| Channel commission params | `/admin/channel` | `agent_mode`, turnover/affiliate rates, settlement cycle, etc. |
| 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 |
| Visibility | Admin list | Nonsuper admin sees **self + all downline** only |
| Settlement | `/admin/channel` manual / cron | **Super admin only**; credits `admin_wallet` on settle |
| Settlement | `/admin/channel` manual / cron | Super admin **or** `channel/manualSettle`; batch still super admin only |
### 3.1 Share rate validation
@@ -80,7 +81,7 @@ If a level totals 100%, the parent at that level keeps **no commission**.
## 4. Settlement Flow
1. Super admin triggers channel settlement (manual or `ChannelAutoSettleTicker`)
1. Super admin or holder of `channel/manualSettle` triggers settlement (manual or cron; batch API still super admin only)
2. `ChannelSettlementService::buildSettlePayload` aggregates bets and computes channel total commission
3. `AdminCommissionDistributionService::distributeChannelCommission` splits by agent tree
4. In one transaction:
@@ -123,6 +124,8 @@ If a level totals 100%, the parent at that level keeps **no commission**.
| Module | Path |
|--------|------|
| Channel settlement | `app/common/service/ChannelSettlementService.php` |
| Channel read scope | `app/common/service/AdminChannelScopeService.php` |
| Channel admin UI | `app/admin/controller/Channel.php` |
| Tree split | `app/common/service/AdminCommissionDistributionService.php` |
| Admin CRUD / validation | `app/admin/controller/auth/Admin.php` |
| Admin UI | `web/src/views/backend/auth/admin/` |
@@ -138,3 +141,4 @@ If a level totals 100%, the parent at that level keeps **no commission**.
| 2026-04-23 | Settle-and-pay to admin wallet; `admin_wallet` system |
| 2026-05-29 | **Agent tree commission** in Administrator Management; removed channel share UI; tree list & downline visibility |
| 2026-05-29 | Top-level role groups (`pid=0`) require channel share rate; parent agent disabled in form |
| 2026-05-30 | Channel UI: view-all, bet/dividend dialogs, filters, mobile; manual settle by permission; `channel-admin-guide.md` |

View File

@@ -47,11 +47,12 @@
| 能力 | 入口 | 说明 |
|------|------|------|
| 渠道分红参数 | `/admin/channel` | `agent_mode`、返水/联营比例、结算周期等 |
| 渠道分红参数 | `/admin/channel` | `agent_mode`、返水/联营比例、结算周期等;详见 [渠道管理后台说明.md](./渠道管理后台说明.md) |
| 代理树与分红比例 | `/admin/auth/admin` | 树形列表;配置上级代理、分红比例、渠道归属 |
| 渠道筛选 | 管理员列表公共搜索 | 超管可按渠道筛选 |
| 渠道列表可见范围 | `/admin/channel` | 超管 / 角色组未绑渠道 / `viewAllChannels` → 全平台只读;否则仅绑定渠道 |
| 数据可见范围 | 管理员列表 | 非超管仅见 **本人 + 全部下级**,不见其他代理线 |
| 结算执行 | `/admin/channel` 手动结算 / 定时任务 | **超管**可结算;结算即发放至 `admin_wallet` |
| 结算执行 | `/admin/channel` 手动结算 / 定时任务 | **超管**`channel/manualSettle`(渠道可读);批量结算仍仅超管;结算即发放至 `admin_wallet` |
### 3.1 分红比例校验
@@ -80,7 +81,7 @@
## 4. 结算执行流程
1. 超管触发渠道结算(手动或 `ChannelAutoSettleTicker` 周期任务)
1. 超管或具备 `channel/manualSettle` 的管理员触发渠道结算(手动或 `ChannelAutoSettleTicker` 周期任务;批量接口仍仅超管
2. `ChannelSettlementService::buildSettlePayload` 汇总注单并计算渠道总佣金
3. `AdminCommissionDistributionService::distributeChannelCommission` 按代理树拆分各管理员实得
4. 事务内写入:
@@ -123,6 +124,8 @@
| 模块 | 路径 |
|------|------|
| 渠道结算 | `app/common/service/ChannelSettlementService.php` |
| 渠道列表范围 | `app/common/service/AdminChannelScopeService.php` |
| 渠道后台 | `app/admin/controller/Channel.php``web/src/views/backend/channel/` |
| 树形拆分 | `app/common/service/AdminCommissionDistributionService.php` |
| 管理员 CRUD / 校验 | `app/admin/controller/auth/Admin.php` |
| 管理员前端 | `web/src/views/backend/auth/admin/` |
@@ -139,3 +142,4 @@
| 2026-05-29 | **改为代理树形分红**:配置迁移至管理员管理 `commission_share_rate` + `parent_admin_id`;移除渠道页分配比例入口;管理员列表树形展示与下级可见范围 |
| 2026-05-29 | 新增英文文档 `docs/en/commission-share-guide.md`;后台切换 `lang=en` 时文档页自动加载英文版 |
| 2026-05-29 | **顶级角色组可配置渠道分红比例**`pid=0` 时禁用上级代理并必填 `commission_share_rate`;结算按顶级比例划入后再向下拆分 |
| 2026-05-30 | 渠道页:查看所有渠道、下注/分红记录弹窗与筛选、移动端适配;`manualSettle` 按按钮权限展示;文档 [渠道管理后台说明.md](./渠道管理后台说明.md) |

View File

@@ -78,12 +78,16 @@
### 3.5 🏢 代理中枢与佣金结算系统 (Agent System)
* **创建总代/子代账号**:在 **管理员管理**`/admin/auth/admin`)维护代理树:`parent_admin_id``commission_share_rate`(顶级角色组从渠道总佣金分得 %,子代理从上级实得抽取 %)、`channel_id`、邀请码。
* **代理树状图 (Tree View)**:管理员列表以树形展示;非超管仅见本人及全部下级。
* **渠道佣金结算**仅超管
* **渠道管理页**`/admin/channel`
* 顶部统计:渠道数、待分红、已分红(可点开记录);列表支持分红余额/启用状态筛选。
* **数据范围**`AdminChannelScopeService`;全平台只读条件见 `docs/渠道管理后台说明.md` §3。
* **操作**:查看总投注金额 / 直属投注记录(弹窗 + 筛选);手动结算(超管或 `channel/manualSettle`)。
* **渠道佣金结算**
* 按渠道 `agent_mode` 与已结算注单计算渠道总佣金(非充值口径)。
* 按代理树拆分各管理员实得,写入 `agent_commission_record`**即时入账** `admin_wallet`
* 支持周期自动结算(`ChannelAutoSettleTicker`手动提前结算。
* 支持周期自动结算(`ChannelAutoSettleTicker`手动提前结算;批量待结算仅超管
* **佣金结算看板**`agent_settlement_period``agent_commission_record` 列表查询与对账。
* 详细口径见 `docs/分红说明文档.md`
* 详细口径见 `docs/分红说明文档.md`;渠道页交互见 `docs/渠道管理后台说明.md`
### 3.6 🤝 联营契约与合营代理管控大厅 (Affiliate Management)
设计为与普通流水分佣系统并行的**客损占成代理模块**方案:

View File

@@ -0,0 +1,158 @@
# 渠道管理后台说明
## 1. 文档目的
说明 **渠道管理**`/admin/channel`)页面的统计卡片、列表数据范围、操作按钮权限、下注记录弹窗与相关接口,便于运营配置权限与开发联调。
分红计算与代理树拆分口径见 [分红说明文档.md](./分红说明文档.md)。
---
## 2. 页面入口与统计卡片
| 卡片 | 说明 |
|------|------|
| 渠道总数 | 当前账号**可读范围**内渠道数量 |
| 启用渠道 | `status = 1` 的渠道数 |
| 待分红渠道 | `carryover_balance > 0` 的渠道数 |
| 待分红总额 | 上述渠道 `carryover_balance` 合计 |
| 已分红金额 | 可读范围内渠道下,已发放佣金(`agent_commission_record.status = 1`)合计;**可点击**打开已分红记录弹窗(需 `viewDividendRecords` 权限) |
列表上方筛选:**全部 / 有分红余额 / 无分红余额 / 仅启用 / 仅停用**(前端 `search` 条件,不改变数据范围规则)。
---
## 3. 数据可见范围(只读)
`app/common/service/AdminChannelScopeService.php` 统一判定,列表与统计均遵守:
| 条件(满足任一即**全平台渠道可读** | 说明 |
|--------------------------------------|------|
| 超管 | 权限含 `*` |
| 角色组均未绑定 `channel_id` | 该管理员所属角色组 `admin_group.channel_id` 均为空 |
| 拥有「查看所有渠道」 | 按钮权限 `channel/viewAllChannels` |
否则仅可读:
- 角色组绑定的 `channel_id` 集合,或
- 本人 `admin.channel_id`(若 > 0
- 无绑定且无账号渠道时返回空列表。
**写操作**(新增/编辑/删除渠道、手动结算写库)仍限制在**可写渠道**:角色组绑定渠道 + 账号 `channel_id`**不**因「查看所有渠道」而扩大写范围。
其它菜单(用户、充值/提现订单、游玩记录、控制台等)在只读全平台时同样可不按 `admin_id` 收窄,逻辑与 `Backend::hasGlobalReadScope()` 一致。
---
## 4. 列表字段与操作
### 4.1 常用列
- 渠道标识、名称、代理模式、联营负结转、契约编号、结算周期等
- **直属投注额**:该渠道下 `game_play_record` 投注合计;**可点击**打开直属下注记录弹窗(需 `viewDirectBetRecords`
- 操作列:**查看总投注金额**、**手动结算**、编辑、删除(后两者受写权限约束)
### 4.2 操作按钮权限
| 按钮权限节点 | 名称 | 行为 |
|--------------|------|------|
| `channel/index` | 查看 | 列表与详情 |
| `channel/viewAllChannels` | 查看所有渠道 | 扩大**只读**范围至全平台渠道 |
| `channel/viewDividendRecords` | 查看已分红记录 | 顶部「已分红金额」卡片与弹窗 |
| `channel/viewDirectBetRecords` | 查看直属投注记录 | 「直属投注额」列点击 |
| `channel/viewSettlementBetRecords` | 查看总投注金额 | 操作列;分红口径已结算注单 |
| `channel/manualSettle` | 手动结算 | 操作列;预览并提交渠道结算(见 §5 |
| `channel/batchSettlePending` | 一键批量结算 | **仅超管**可见;结算全部待结算渠道 |
| `channel/add` / `edit` / `del` | 增删改 | 须对目标渠道具备写权限 |
角色组在 **权限管理 → 角色组** 中勾选对应按钮;保存后管理员需**重新登录**以刷新前端 `authNode`
---
## 5. 手动结算
- **接口**`GET /admin/channel/manualSettlePreview?id={channelId}``POST /admin/channel/manualSettle`
- **权限**:超管,或拥有 `channel/manualSettle` 且目标渠道在**可读范围**内
- **逻辑**:与超管结算相同,调用 `ChannelSettlementService::settleBySuperAdmin`,结算即按代理树发放至 `admin_wallet`
- **周期**:上次结算结束时间 ~ 当前时间;金额来自期内 **已结算** 游玩记录(`game_play_record.status = 2`
- **批量**`POST /admin/channel/batchSettlePending` 仍**仅超管**
---
## 6. 下注记录弹窗
两种入口共用同一套 UI 与筛选(接口不同):
| 入口 | 接口 | 数据范围 |
|------|------|----------|
| 直属投注额 | `GET /admin/channel/directBetRecordList` | 该渠道全部游玩记录 |
| 查看总投注金额 | `GET /admin/channel/settlementBetRecordList` | 该渠道 **已结算** 记录(`status = 2`,参与分红口径) |
### 6.1 顶部统计Card
- 总笔数、总投注额、总中奖额(随筛选条件重算)
### 6.2 列表列
游戏期号、玩家名、渠道、选号、中奖号码、中奖状态(已中奖 / 未中奖 / 待开奖)、投注金额、玩家中奖金额(含 `jackpot_extra_amount`)。
### 6.3 筛选参数GET
| 参数 | 说明 |
|------|------|
| `channel_id` | 必填 |
| `page` / `limit` | 分页,默认 1 / 20最大 200 |
| `period_no` | 期号模糊(`pr.period_no``game_record.period_no` |
| `user_keyword` | 玩家名或手机号模糊 |
| `result_number` | 开奖号码精确匹配 |
| `pick_number` | 选号模糊(匹配 `pick_numbers` JSON 文本) |
| `win_hit` | `won` / `lost` / `pending`(与列表中奖状态一致) |
### 6.4 移动端适配
- 弹窗宽度约 92% 视口,内容区可**纵向滚动**(勿使用 `ba-operate-dialog` 固定高度)
- 统计 Card **一行三列**;筛选项纵向铺满;表格区域可**横向滑动**
---
## 7. 已分红记录弹窗
- **接口**`GET /admin/channel/dividendRecordList`
- **权限**`channel/viewDividendRecords`
- **字段**:结算单号、渠道名、代理账号、分红金额、结算周期、发放时间等
---
## 8. 相关接口一览
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/admin/channel/index` | 渠道列表 |
| GET | `/admin/channel/settleStats` | 顶部统计卡片 |
| GET | `/admin/channel/directBetRecordList` | 直属下注记录 |
| GET | `/admin/channel/settlementBetRecordList` | 分红口径下注记录 |
| GET | `/admin/channel/dividendRecordList` | 已分红记录 |
| GET | `/admin/channel/manualSettlePreview` | 手动结算预览 |
| POST | `/admin/channel/manualSettle` | 提交手动结算 |
| POST | `/admin/channel/batchSettlePending` | 超管批量结算 |
---
## 9. 相关代码
| 模块 | 路径 |
|------|------|
| 渠道控制器 | `app/admin/controller/Channel.php` |
| 数据范围 | `app/common/service/AdminChannelScopeService.php` |
| 渠道结算 | `app/common/service/ChannelSettlementService.php` |
| 前端页面 | `web/src/views/backend/channel/index.vue` |
| 权限迁移 | `database/migrations/20260530120000_*``20260530130000_*` |
---
## 10. 变更记录
| 日期 | 说明 |
|------|------|
| 2026-05-30 | 新增:查看所有渠道、下注/分红查看按钮;下注记录弹窗列与筛选;移动端弹窗适配 |
| 2026-05-30 | 手动结算:拥有 `channel/manualSettle` 且渠道可读即可操作(不再仅限超管展示按钮) |

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace support\bootstrap;
use app\common\library\MalaysiaMobilePhone;
use think\Validate;
use support\think\Db;
use Workerman\Worker;
@@ -17,6 +18,9 @@ class ValidateInit implements \Webman\Bootstrap
{
Validate::maker(function (Validate $validate): void {
$validate->setDb(Db::connect());
$validate->extend('mobile', static function ($value): bool {
return MalaysiaMobilePhone::isValid((string) $value);
});
});
}
}

View File

@@ -83,6 +83,33 @@ export default {
settle_stats_enabled: 'Enabled channels',
settle_stats_pending_dividend: 'Channels pending dividend',
settle_stats_pending_amount: 'Pending dividend amount',
settle_stats_paid_dividend: 'Paid dividend amount',
direct_bet_amount: 'Direct bet amount',
view_settlement_bet: 'View settlement bets',
dividend_record_dialog_title: 'Paid dividend records',
direct_bet_record_dialog_title: 'Direct player bet records',
settlement_bet_record_dialog_title: 'Dividend-scope bet records',
bet_record_period_no: 'Period No.',
bet_record_user_username: 'Player',
bet_record_total_amount: 'Bet amount',
bet_record_win_amount: 'Win amount',
bet_record_channel_name: 'Channel',
bet_record_pick_numbers: 'Picks',
bet_record_result_number: 'Winning number',
bet_record_win_hit: 'Win status',
bet_record_win_hit_won: 'Won',
bet_record_win_hit_lost: 'Lost',
bet_record_win_hit_pending: 'Pending',
bet_record_pick_filter_placeholder: 'Number',
bet_record_summary_count: 'Total records',
bet_record_summary_bet: 'Total bet',
bet_record_summary_win: 'Total win',
dividend_settlement_no: 'Settlement No.',
dividend_channel_name: 'Channel',
dividend_admin_username: 'Agent',
dividend_commission_amount: 'Dividend amount',
dividend_settled_at: 'Paid at',
dividend_period_range: 'Settlement period',
settle_filter_all: 'All',
settle_filter_with_balance: 'With dividend balance',
settle_filter_no_balance: 'No dividend balance',

View File

@@ -83,6 +83,33 @@ export default {
settle_stats_enabled: '启用渠道',
settle_stats_pending_dividend: '待分红渠道',
settle_stats_pending_amount: '待分红总额',
settle_stats_paid_dividend: '已分红金额',
direct_bet_amount: '直属投注额',
view_settlement_bet: '查看总投注金额',
dividend_record_dialog_title: '已分红记录',
direct_bet_record_dialog_title: '直属玩家下注记录',
settlement_bet_record_dialog_title: '分红口径下注记录',
bet_record_period_no: '游戏期号',
bet_record_user_username: '玩家名',
bet_record_total_amount: '投注金额',
bet_record_win_amount: '玩家中奖金额',
bet_record_channel_name: '渠道',
bet_record_pick_numbers: '选号',
bet_record_result_number: '中奖号码',
bet_record_win_hit: '中奖状态',
bet_record_win_hit_won: '已中奖',
bet_record_win_hit_lost: '未中奖',
bet_record_win_hit_pending: '待开奖',
bet_record_pick_filter_placeholder: '号码',
bet_record_summary_count: '总笔数',
bet_record_summary_bet: '总投注额',
bet_record_summary_win: '总中奖额',
dividend_settlement_no: '结算单号',
dividend_channel_name: '渠道',
dividend_admin_username: '代理账号',
dividend_commission_amount: '分红金额',
dividend_settled_at: '发放时间',
dividend_period_range: '结算周期',
settle_filter_all: '全部',
settle_filter_with_balance: '有分红余额',
settle_filter_no_balance: '无分红余额',

View File

@@ -3,7 +3,7 @@ export default {
'The correct area is not clicked, please try again!': 'The correct area is not clicked, please try again!',
'Verification is successful!': 'Verification is successful!',
'Please click': 'Please click',
'Please enter the correct mobile number': 'Please enter the correct mobile number',
'Please enter the correct mobile number': 'Please enter a valid Malaysia mobile number starting with 60',
'Please enter the correct account': 'The account requires 3 to 15 characters and contains a-z A-Z 0-9 _',
'Please enter the correct password': 'The password requires 6 to 32 characters and cannot contains & < > " \'',
'Please enter the correct name': 'Please enter the correct name',

View File

@@ -3,7 +3,7 @@ export default {
'The correct area is not clicked, please try again!': '未点中正确区域,请重试!',
'Verification is successful!': '验证成功!',
'Please click': '请依次点击',
'Please enter the correct mobile number': '请输入正确的手机号',
'Please enter the correct mobile number': '请输入正确的马来西亚手机号60开头',
'Please enter the correct account': '要求3到15位字母开头且只含字母、数字、下划线',
'Please enter the correct password': '密码要求6到32位不能包含 & < > " \'',
'Please enter the correct name': '请输入正确的名称',

View File

@@ -2,6 +2,11 @@ import type { RuleType } from 'async-validator'
import type { FormItemRule } from 'element-plus'
import { i18n } from '../lang'
/**
* 马来西亚手机号60 国际前缀,不含 +
*/
export const malaysiaMobilePattern = /^60(1[0-9])\d{7,9}$/
/**
* 手机号码验证
*/
@@ -10,7 +15,7 @@ export function validatorMobile(rule: any, mobile: string | number, callback: Fu
if (!mobile) {
return callback()
}
if (!/^(1[3-9])\d{9}$/.test(mobile.toString())) {
if (!malaysiaMobilePattern.test(mobile.toString())) {
return callback(new Error(i18n.global.t('validate.Please enter the correct mobile number')))
}
return callback()

View File

@@ -24,6 +24,15 @@
<div class="label">{{ t('channel.settle_stats_pending_amount') }}</div>
<div class="value">{{ settleStats.carryover_positive_total }}</div>
</el-card>
<el-card
shadow="never"
class="channel-stat-card"
:class="{ 'channel-stat-card-clickable': auth('viewDividendRecords') }"
@click="onPaidDividendCardClick"
>
<div class="label">{{ t('channel.settle_stats_paid_dividend') }}</div>
<div class="value">{{ settleStats.paid_dividend_total }}</div>
</el-card>
</div>
<div class="channel-action-row">
<el-radio-group v-model="settleFilterMode" size="small" @change="onSettleFilterChange">
@@ -106,11 +115,220 @@
</template>
</el-dialog>
<el-dialog
class="channel-record-dialog"
:close-on-click-modal="false"
:model-value="dividendDialog.visible"
width="92%"
@close="closeDividendDialog"
>
<template #header>
<div class="title">{{ t('channel.dividend_record_dialog_title') }}</div>
</template>
<div v-loading="dividendDialog.loading" class="channel-record-dialog__body">
<div class="channel-record-table-wrap">
<el-table :data="dividendDialog.list" border size="small" class="channel-record-table">
<el-table-column prop="settlement_no" :label="t('channel.dividend_settlement_no')" min-width="150" show-overflow-tooltip />
<el-table-column prop="channel_name" :label="t('channel.dividend_channel_name')" min-width="100" />
<el-table-column prop="admin_username" :label="t('channel.dividend_admin_username')" min-width="100" />
<el-table-column prop="commission_amount" :label="t('channel.dividend_commission_amount')" min-width="100" align="right" />
<el-table-column :label="t('channel.dividend_period_range')" min-width="180">
<template #default="scope">
{{ scope.row.period_start_at || '-' }} ~ {{ scope.row.period_end_at || '-' }}
</template>
</el-table-column>
<el-table-column prop="settled_at" :label="t('channel.dividend_settled_at')" min-width="150" />
</el-table>
</div>
<div class="channel-record-pagination">
<el-pagination
background
layout="total, prev, pager, next"
:total="dividendDialog.total"
:page-size="dividendDialog.limit"
:current-page="dividendDialog.page"
@current-change="onDividendPageChange"
/>
</div>
</div>
</el-dialog>
<el-dialog
class="channel-record-dialog channel-bet-record-dialog"
:close-on-click-modal="false"
:model-value="betRecordDialog.visible"
width="92%"
@close="closeBetRecordDialog"
>
<template #header>
<div class="title">{{ betRecordDialog.title }}</div>
</template>
<div v-loading="betRecordDialog.loading" class="channel-record-dialog__body">
<div class="channel-bet-summary">
<el-card shadow="never" class="channel-bet-summary-card channel-stat-card">
<div class="label">{{ t('channel.bet_record_summary_count') }}</div>
<div class="value">{{ betRecordDialog.summary.record_count }}</div>
</el-card>
<el-card shadow="never" class="channel-bet-summary-card channel-stat-card">
<div class="label">{{ t('channel.bet_record_summary_bet') }}</div>
<div class="value">{{ betRecordDialog.summary.total_bet_amount }}</div>
</el-card>
<el-card shadow="never" class="channel-bet-summary-card channel-stat-card">
<div class="label">{{ t('channel.bet_record_summary_win') }}</div>
<div class="value">{{ betRecordDialog.summary.total_win_amount }}</div>
</el-card>
</div>
<el-form :inline="true" size="small" class="channel-bet-filter" @submit.prevent="onBetRecordSearch">
<el-form-item :label="t('channel.bet_record_period_no')">
<el-input
v-model="betRecordDialog.filters.period_no"
clearable
:placeholder="t('Fuzzy query')"
class="channel-bet-filter-input"
@keyup.enter="onBetRecordSearch"
/>
</el-form-item>
<el-form-item :label="t('channel.bet_record_user_username')">
<el-input
v-model="betRecordDialog.filters.user_keyword"
clearable
:placeholder="t('Fuzzy query')"
class="channel-bet-filter-input"
@keyup.enter="onBetRecordSearch"
/>
</el-form-item>
<el-form-item :label="t('channel.bet_record_result_number')">
<el-input
v-model="betRecordDialog.filters.result_number"
clearable
class="channel-bet-filter-input--short"
@keyup.enter="onBetRecordSearch"
/>
</el-form-item>
<el-form-item :label="t('channel.bet_record_pick_numbers')">
<el-input
v-model="betRecordDialog.filters.pick_number"
clearable
:placeholder="t('channel.bet_record_pick_filter_placeholder')"
class="channel-bet-filter-input--short"
@keyup.enter="onBetRecordSearch"
/>
</el-form-item>
<el-form-item :label="t('channel.bet_record_win_hit')">
<el-select
v-model="betRecordDialog.filters.win_hit"
clearable
:placeholder="t('Please select field', { field: t('channel.bet_record_win_hit') })"
class="channel-bet-filter-select"
>
<el-option :label="t('channel.bet_record_win_hit_won')" value="won" />
<el-option :label="t('channel.bet_record_win_hit_lost')" value="lost" />
<el-option :label="t('channel.bet_record_win_hit_pending')" value="pending" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onBetRecordSearch">{{ t('Search') }}</el-button>
<el-button @click="onBetRecordReset">{{ t('Reset') }}</el-button>
</el-form-item>
</el-form>
<div class="channel-record-table-wrap">
<el-table
:data="betRecordDialog.list"
border
size="small"
class="channel-bet-record-table channel-record-table"
>
<el-table-column
prop="period_no"
:label="t('channel.bet_record_period_no')"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="user_username"
:label="t('channel.bet_record_user_username')"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="channel_name"
:label="t('channel.bet_record_channel_name')"
min-width="100"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="pick_numbers"
:label="t('channel.bet_record_pick_numbers')"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="result_number"
:label="t('channel.bet_record_result_number')"
min-width="90"
align="center"
header-align="center"
/>
<el-table-column
:label="t('channel.bet_record_win_hit')"
min-width="90"
align="center"
header-align="center"
>
<template #default="scope">
<el-tag
v-if="scope.row.win_hit"
:type="betRecordWinHitTagType(scope.row.win_hit)"
effect="dark"
size="small"
>
{{ formatBetRecordWinHit(scope.row.win_hit) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
prop="total_amount"
:label="t('channel.bet_record_total_amount')"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="win_amount"
:label="t('channel.bet_record_win_amount')"
min-width="100"
align="center"
header-align="center"
/>
</el-table>
</div>
<div class="channel-record-pagination">
<el-pagination
background
layout="total, prev, pager, next"
:total="betRecordDialog.total"
:page-size="betRecordDialog.limit"
:current-page="betRecordDialog.page"
@current-change="onBetRecordPageChange"
/>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, provide, reactive, ref, useTemplateRef } from 'vue'
import { onMounted, provide, reactive, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import PopupForm from './popupForm.vue'
@@ -131,6 +349,20 @@ const { t } = useI18n()
const adminInfo = useAdminInfo()
const tableRef = useTemplateRef('tableRef')
let optButtons: OptButton[] = [
{
render: 'tipButton',
name: 'viewSettlementBet',
title: 'channel.view_settlement_bet',
text: '',
type: 'info',
icon: 'fa fa-list-alt',
class: 'table-row-view-settlement-bet',
disabledTip: false,
display: () => auth('viewSettlementBetRecords'),
click: (row: TableRow) => {
void openBetRecordDialog('settlement', row)
},
},
{
render: 'tipButton',
name: 'manualSettle',
@@ -140,7 +372,7 @@ let optButtons: OptButton[] = [
icon: 'el-icon-Clock',
class: 'table-row-manual-settle',
disabledTip: false,
display: () => adminInfo.super && auth('manualSettle'),
display: () => auth('manualSettle'),
click: (row: TableRow) => {
void openManualSettleDialog(row)
},
@@ -153,6 +385,30 @@ const formatAmountInt = (_row: any, _column: any, cellValue: number | string | n
if (Number.isNaN(num)) return '-'
return `${num}`
}
const formatAmount2 = (_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 formatBetRecordWinHit = (code: unknown) => {
const key = code === null || code === undefined ? '' : String(code)
if (!key) return '-'
const i18nKey = `channel.bet_record_win_hit_${key}`
const text = t(i18nKey)
return text !== i18nKey ? text : key
}
const betRecordWinHitTagType = (code: unknown): 'success' | 'info' | 'warning' | 'danger' => {
const key = code === null || code === undefined ? '' : String(code)
const map: Record<string, 'success' | 'info' | 'warning' | 'danger'> = {
won: 'success',
lost: 'info',
pending: 'warning',
}
return map[key] ?? 'info'
}
const formatSettleDay = (row: anyObj) => {
if (row.settle_cycle === 'weekly') {
return t(`channel.weekday ${row.settle_weekday ?? 1}`)
@@ -191,6 +447,42 @@ const settleStats = reactive({
carryover_positive_count: 0,
carryover_total: '0.00',
carryover_positive_total: '0.00',
paid_dividend_total: '0.00',
})
const dividendDialog = reactive({
visible: false,
loading: false,
page: 1,
limit: 20,
total: 0,
list: [] as anyObj[],
})
const createBetRecordFilters = () => ({
period_no: '',
user_keyword: '',
result_number: '',
pick_number: '',
win_hit: '' as '' | 'won' | 'lost' | 'pending',
})
const betRecordDialog = reactive({
visible: false,
loading: false,
mode: '' as '' | 'direct' | 'settlement',
channelId: 0,
title: '',
page: 1,
limit: 20,
total: 0,
list: [] as anyObj[],
filters: createBetRecordFilters(),
summary: {
record_count: 0,
total_bet_amount: '0.00',
total_win_amount: '0.00',
},
})
const resetManualSettleForm = () => {
@@ -298,6 +590,156 @@ const loadSettleStats = async () => {
settleStats.carryover_positive_count = Number(res.data.carryover_positive_count ?? 0)
settleStats.carryover_total = String(res.data.carryover_total ?? '0.00')
settleStats.carryover_positive_total = String(res.data.carryover_positive_total ?? '0.00')
settleStats.paid_dividend_total = String(res.data.paid_dividend_total ?? '0.00')
}
const loadDividendRecords = async () => {
dividendDialog.loading = true
try {
const res = await createAxios({
url: '/admin/channel/dividendRecordList',
method: 'get',
params: { page: dividendDialog.page, limit: dividendDialog.limit },
})
if (res.code !== 1 || !res.data) {
return
}
dividendDialog.list = Array.isArray(res.data.list) ? res.data.list : []
dividendDialog.total = Number(res.data.total ?? 0)
} finally {
dividendDialog.loading = false
}
}
const onPaidDividendCardClick = () => {
if (!auth('viewDividendRecords')) {
return
}
dividendDialog.page = 1
dividendDialog.visible = true
void loadDividendRecords()
}
const closeDividendDialog = () => {
dividendDialog.visible = false
dividendDialog.list = []
dividendDialog.total = 0
dividendDialog.page = 1
}
const onDividendPageChange = (page: number) => {
dividendDialog.page = page
void loadDividendRecords()
}
const resetBetRecordDialog = () => {
betRecordDialog.mode = ''
betRecordDialog.channelId = 0
betRecordDialog.title = ''
betRecordDialog.page = 1
betRecordDialog.total = 0
betRecordDialog.list = []
betRecordDialog.summary = {
record_count: 0,
total_bet_amount: '0.00',
total_win_amount: '0.00',
}
Object.assign(betRecordDialog.filters, createBetRecordFilters())
}
const buildBetRecordFilterParams = () => {
const f = betRecordDialog.filters
const params: Record<string, string> = {}
if (f.period_no.trim()) {
params.period_no = f.period_no.trim()
}
if (f.user_keyword.trim()) {
params.user_keyword = f.user_keyword.trim()
}
if (f.result_number.trim()) {
params.result_number = f.result_number.trim()
}
if (f.pick_number.trim()) {
params.pick_number = f.pick_number.trim()
}
if (f.win_hit) {
params.win_hit = f.win_hit
}
return params
}
const loadBetRecords = async () => {
if (!betRecordDialog.channelId || !betRecordDialog.mode) {
return
}
const url =
betRecordDialog.mode === 'direct' ? '/admin/channel/directBetRecordList' : '/admin/channel/settlementBetRecordList'
betRecordDialog.loading = true
try {
const res = await createAxios({
url,
method: 'get',
params: {
channel_id: betRecordDialog.channelId,
page: betRecordDialog.page,
limit: betRecordDialog.limit,
...buildBetRecordFilterParams(),
},
})
if (res.code !== 1 || !res.data) {
return
}
betRecordDialog.list = Array.isArray(res.data.list) ? res.data.list : []
betRecordDialog.total = Number(res.data.total ?? 0)
const summary = res.data.summary
if (summary && typeof summary === 'object') {
betRecordDialog.summary.record_count = Number(summary.record_count ?? 0)
betRecordDialog.summary.total_bet_amount = String(summary.total_bet_amount ?? '0.00')
betRecordDialog.summary.total_win_amount = String(summary.total_win_amount ?? '0.00')
}
} finally {
betRecordDialog.loading = false
}
}
const openBetRecordDialog = (mode: 'direct' | 'settlement', row: TableRow) => {
const permission = mode === 'direct' ? 'viewDirectBetRecords' : 'viewSettlementBetRecords'
if (!auth(permission)) {
return
}
resetBetRecordDialog()
betRecordDialog.mode = mode
betRecordDialog.channelId = Number(row.id ?? 0)
betRecordDialog.title =
mode === 'direct'
? `${t('channel.direct_bet_record_dialog_title')} - ${row.name ?? row.id}`
: `${t('channel.settlement_bet_record_dialog_title')} - ${row.name ?? row.id}`
betRecordDialog.visible = true
void loadBetRecords()
}
const closeBetRecordDialog = () => {
betRecordDialog.visible = false
resetBetRecordDialog()
}
const onBetRecordPageChange = (page: number) => {
betRecordDialog.page = page
void loadBetRecords()
}
const onBetRecordSearch = () => {
betRecordDialog.page = 1
void loadBetRecords()
}
const onBetRecordReset = () => {
Object.assign(betRecordDialog.filters, createBetRecordFilters())
onBetRecordSearch()
}
const openDirectBetDialog = (row: TableRow) => {
void openBetRecordDialog('direct', row)
}
const baTable = new baTableClass(
@@ -420,6 +862,22 @@ const baTable = new baTableClass(
sortable: false,
operator: 'RANGE',
},
{
label: t('channel.direct_bet_amount'),
prop: 'direct_bet_amount',
align: 'center',
minWidth: 120,
sortable: false,
operator: false,
render: 'tag',
formatter: formatAmount2,
customRenderAttr: {
tag: ({ row }: { row: TableRow }) => ({
class: auth('viewDirectBetRecords') ? 'channel-direct-bet-tag' : '',
onClick: auth('viewDirectBetRecords') ? () => openDirectBetDialog(row) : undefined,
}),
},
},
{
label: t('channel.profit_amount'),
prop: 'profit_amount',
@@ -478,7 +936,7 @@ const baTable = new baTableClass(
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 120, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
{ label: t('Operate'), align: 'center', width: 160, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined, 'status'],
},
@@ -556,11 +1014,91 @@ onMounted(() => {
.channel-stats-cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.channel-stat-card-clickable {
cursor: pointer;
transition: box-shadow 0.2s ease;
}
.channel-stat-card-clickable:hover {
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
.channel-stat-card-clickable .value {
color: var(--el-color-primary);
}
:deep(.channel-direct-bet-tag) {
cursor: pointer;
}
.channel-bet-filter {
margin-bottom: 12px;
padding: 10px 12px 2px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.channel-bet-filter :deep(.el-form-item) {
margin-bottom: 8px;
margin-right: 12px;
}
.channel-bet-filter-input {
width: 140px;
}
.channel-bet-filter-input--short {
width: 100px;
}
.channel-bet-filter-select {
width: 120px;
}
.channel-bet-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.channel-bet-summary-card {
text-align: center;
}
.channel-bet-summary-card .label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.channel-bet-summary-card .value {
margin-top: 6px;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.channel-bet-record-table :deep(.el-table__cell) {
text-align: center;
}
.channel-bet-record-table :deep(.cell) {
display: flex;
align-items: center;
justify-content: center;
}
.channel-record-pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.channel-stat-card .label {
font-size: 12px;
color: var(--el-text-color-secondary);
@@ -607,6 +1145,57 @@ onMounted(() => {
gap: 8px;
}
/* 渠道记录弹窗:可滚动、适配移动端(勿使用 ba-operate-dialog 固定高度) */
:deep(.channel-record-dialog.el-dialog) {
display: flex;
flex-direction: column;
max-height: 92vh;
margin: 4vh auto !important;
padding-bottom: 0;
overflow: hidden;
width: 92% !important;
max-width: 1080px;
}
:deep(.channel-record-dialog.channel-bet-record-dialog.el-dialog) {
max-width: 1080px;
}
:deep(.channel-record-dialog .el-dialog__header) {
flex-shrink: 0;
padding: 12px 16px;
margin-right: 0;
}
:deep(.channel-record-dialog .el-dialog__body) {
flex: 1;
min-height: 0;
height: auto !important;
max-height: calc(92vh - 56px);
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding: 12px 14px 16px;
}
.channel-record-dialog__body {
min-height: 0;
}
.channel-record-table-wrap {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.channel-record-table {
min-width: 640px;
}
.channel-bet-record-table {
min-width: 880px;
}
@media (max-width: 900px) {
.channel-stats-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -616,4 +1205,80 @@ onMounted(() => {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
:deep(.channel-record-dialog.el-dialog) {
width: 96% !important;
max-height: 94vh;
margin: 3vh auto !important;
}
:deep(.channel-record-dialog .el-dialog__body) {
max-height: calc(94vh - 52px);
padding: 10px 10px 14px;
}
.channel-bet-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin-bottom: 10px;
}
.channel-bet-summary-card :deep(.el-card__body) {
padding: 8px 4px;
}
.channel-bet-summary-card .label {
font-size: 10px;
line-height: 1.3;
}
.channel-bet-summary-card .value {
margin-top: 4px;
font-size: 13px;
word-break: break-all;
}
.channel-bet-filter {
padding: 8px 10px 0;
}
.channel-bet-filter :deep(.el-form-item) {
display: flex;
width: 100%;
margin-right: 0;
}
.channel-bet-filter :deep(.el-form-item__label) {
width: 72px !important;
padding-right: 6px;
}
.channel-bet-filter :deep(.el-form-item__content) {
flex: 1;
min-width: 0;
}
.channel-bet-filter-input,
.channel-bet-filter-input--short,
.channel-bet-filter-select {
width: 100%;
}
.channel-bet-filter :deep(.el-form-item:last-child .el-form-item__content) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.channel-record-pagination {
justify-content: center;
}
.channel-record-pagination :deep(.el-pagination) {
flex-wrap: wrap;
justify-content: center;
row-gap: 6px;
}
}
</style>