1.修改电话号码格式为60前缀,马来西亚格式
2.优化渠道可以查看分红方式,可以查看游玩详情
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
return [
|
||||
'Remark lang' => '开源等于互助;开源需要大家一起来支持,支持的方式有很多种,比如使用、推荐、写教程、保护生态、贡献代码、回答问题、分享经验、打赏赞助等;欢迎您加入我们!',
|
||||
'Remark lang' => 'telegram@zhenhui666',
|
||||
];
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '登录状态已过期',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可读渠道 ID;null 表示全部渠道
|
||||
*
|
||||
* @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 节点名
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -53,6 +53,10 @@ return [
|
||||
'periodSettings' => '期号设置',
|
||||
'manualSettle' => '手动结算',
|
||||
'batchSettlePending' => '批量结算待结算渠道',
|
||||
'viewDividendRecords' => '查看已分红记录',
|
||||
'viewDirectBetRecords' => '查看直属投注记录',
|
||||
'viewSettlementBetRecords' => '查看总投注金额',
|
||||
'viewAllChannels' => '查看所有渠道',
|
||||
'walletAdjust' => '钱包加减点',
|
||||
'Markdown文档' => 'Markdown文档',
|
||||
'分红说明文档' => '分红说明文档',
|
||||
|
||||
@@ -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' => '结算周期不合法',
|
||||
|
||||
@@ -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;
|
||||
|
||||
29
app/common/library/MalaysiaMobilePhone.php
Normal file
29
app/common/library/MalaysiaMobilePhone.php
Normal 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;
|
||||
}
|
||||
}
|
||||
123
app/common/service/AdminChannelScopeService.php
Normal file
123
app/common/service/AdminChannelScopeService.php
Normal 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 过滤时使用的 ID;null=不过滤
|
||||
*
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
public static function channelIdFilterForQuery(Auth $auth): ?array
|
||||
{
|
||||
return self::readableChannelIds($auth);
|
||||
}
|
||||
}
|
||||
@@ -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-23,2026-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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1002,7 +1002,7 @@ flowchart TD
|
||||
|
||||
## 10. 后台代理分红配置(管理端补充,2026-05-29)
|
||||
|
||||
> 分红比例在 **管理员管理** `/admin/auth/admin` 维护,不再使用渠道页「分配比例」弹窗。
|
||||
> 分红比例在 **管理员管理** `/admin/auth/admin` 维护,不再使用渠道页「分配比例」弹窗。渠道页交互(统计、下注记录、筛选、权限)见 `docs/渠道管理后台说明.md`。
|
||||
|
||||
### 10.1 页面与展示
|
||||
|
||||
|
||||
88
docs/en/channel-admin-guide.md
Normal file
88
docs/en/channel-admin-guide.md
Normal 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 user’s 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 |
|
||||
@@ -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 | Non–super 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` |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
10
docs/后端.md
10
docs/后端.md
@@ -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)
|
||||
设计为与普通流水分佣系统并行的**客损占成代理模块**方案:
|
||||
|
||||
158
docs/渠道管理后台说明.md
Normal file
158
docs/渠道管理后台说明.md
Normal 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` 且渠道可读即可操作(不再仅限超管展示按钮) |
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '无分红余额',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '请输入正确的名称',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user