From 68657e26480a588690d750ec1660857405739f8e Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Sat, 18 Apr 2026 16:14:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/Dashboard.php | 375 ++++++++++- web/src/api/backend/dashboard.ts | 47 ++ web/src/lang/backend/en/dashboard.ts | 23 + web/src/lang/backend/zh-cn/dashboard.ts | 23 + web/src/views/backend/dashboard.vue | 853 ++++++++---------------- 5 files changed, 757 insertions(+), 564 deletions(-) diff --git a/app/admin/controller/Dashboard.php b/app/admin/controller/Dashboard.php index 0c128d7..db362e9 100644 --- a/app/admin/controller/Dashboard.php +++ b/app/admin/controller/Dashboard.php @@ -5,18 +5,389 @@ declare(strict_types=1); namespace app\admin\controller; use app\common\controller\Backend; +use support\think\Db; use Webman\Http\Request; use support\Response; +/** + * 后台首页统计:按渠道数据范围(非超管仅本渠道)汇总用户、充值、提现、投注等核心指标。 + */ class Dashboard extends Backend { public function index(Request $request): Response { $response = $this->initializeBackend($request); - if ($response !== null) return $response; + if ($response !== null) { + return $response; + } + + $scope = $this->channelScopeOrNull(); + + $todayStart = strtotime(date('Y-m-d')); + $todayEnd = $todayStart + 86400 - 1; + $yesterdayStart = $todayStart - 86400; + $yesterdayEnd = $todayStart - 1; + + $userTotal = $this->countUsers($scope); + $newToday = $this->countUsersInRange($scope, $todayStart, $todayEnd); + $newYesterday = $this->countUsersInRange($scope, $yesterdayStart, $yesterdayEnd); + $growthPct = null; + if ($newYesterday > 0) { + $growthPct = round(($newToday - $newYesterday) / $newYesterday * 100, 1); + } elseif ($newToday > 0) { + $growthPct = 100.0; + } + + $depositAgg = $this->aggregateDepositToday($scope, $todayStart, $todayEnd); + $withdrawPending = $this->countWithdrawPending($scope); + $betAgg = $this->aggregateBetToday($scope, $todayStart, $todayEnd); + + $trend = $this->buildSevenDayTrend($scope); + $channelShare = $this->buildChannelShare($scope); + $depositAmountChannelShare = $this->buildDepositAmountChannelShare($scope); + $recentUsers = $this->fetchRecentUsers($scope, 10); return $this->success('', [ - 'remark' => get_route_remark() + 'remark' => get_route_remark(), + 'stats' => [ + 'user_total' => $userTotal, + 'user_new_today' => $newToday, + 'user_new_yesterday' => $newYesterday, + 'user_new_growth_pct' => $growthPct, + 'deposit_today_amount' => $depositAgg['amount'], + 'deposit_today_count' => $depositAgg['count'], + 'withdraw_pending' => $withdrawPending, + 'bet_today_amount' => $betAgg['amount'], + 'bet_today_count' => $betAgg['count'], + ], + 'trend' => $trend, + 'channel_share' => $channelShare, + 'deposit_amount_channel_share' => $depositAmountChannelShare, + 'recent_users' => $recentUsers, ]); } + + /** + * @param int[]|null $scope null=超管不限制;非 null 时 whereIn channel_id + */ + private function countUsers(?array $scope): int + { + $q = Db::name('user'); + if ($scope !== null) { + $q->whereIn('channel_id', $scope); + } + return intval($q->count()); + } + + /** + * @param int[]|null $scope + */ + private function countUsersInRange(?array $scope, int $start, int $end): int + { + $q = Db::name('user') + ->where('create_time', '>=', $start) + ->where('create_time', '<=', $end); + if ($scope !== null) { + $q->whereIn('channel_id', $scope); + } + return intval($q->count()); + } + + /** + * 今日成功充值:status=1,按创建日落在今日(与 mock 即时成功一致)。 + * + * @param int[]|null $scope + * @return array{count:int, amount:string} + */ + private function aggregateDepositToday(?array $scope, int $todayStart, int $todayEnd): array + { + $q = Db::name('deposit_order') + ->where('status', 1) + ->where('create_time', '>=', $todayStart) + ->where('create_time', '<=', $todayEnd); + if ($scope !== null) { + $q->whereIn('channel_id', $scope); + } + $rows = $q->fieldRaw('COUNT(*) AS c, COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s')->find(); + if (!is_array($rows)) { + $rows = []; + } + $count = isset($rows['c']) ? intval($rows['c']) : 0; + $sum = isset($rows['s']) ? strval($rows['s']) : '0'; + $amount = $this->formatMoney2($sum); + + return ['count' => $count, 'amount' => $amount]; + } + + /** + * @param int[]|null $scope + */ + private function countWithdrawPending(?array $scope): int + { + $q = Db::name('withdraw_order')->where('status', 0); + if ($scope !== null) { + $q->whereIn('channel_id', $scope); + } + return intval($q->count()); + } + + /** + * 今日投注:创建时间在今日且订单未作废(status 1 或 2)。 + * + * @param int[]|null $scope + * @return array{count:int, amount:string} + */ + private function aggregateBetToday(?array $scope, int $todayStart, int $todayEnd): array + { + $q = Db::name('bet_order') + ->whereIn('status', [1, 2]) + ->where('create_time', '>=', $todayStart) + ->where('create_time', '<=', $todayEnd); + if ($scope !== null) { + $q->whereIn('channel_id', $scope); + } + $rows = $q->fieldRaw('COUNT(*) AS c, COALESCE(SUM(CAST(total_amount AS DECIMAL(18,4))),0) AS s')->find(); + if (!is_array($rows)) { + $rows = []; + } + $count = isset($rows['c']) ? intval($rows['c']) : 0; + $sum = isset($rows['s']) ? strval($rows['s']) : '0'; + + return ['count' => $count, 'amount' => $this->formatMoney2($sum)]; + } + + /** + * @param int[]|null $scope + * @return array{days:string[], new_users:int[], deposit_amount:string[], bet_amount:string[]} + */ + private function buildSevenDayTrend(?array $scope): array + { + $days = []; + $newUsers = []; + $depositAmounts = []; + $betAmounts = []; + + for ($i = 6; $i >= 0; $i--) { + $dayStart = strtotime(date('Y-m-d', strtotime('-' . $i . ' day'))); + $dayEnd = $dayStart + 86400 - 1; + $days[] = date('m-d', $dayStart); + + $newUsers[] = $this->countUsersInRange($scope, $dayStart, $dayEnd); + + $dq = Db::name('deposit_order') + ->where('status', 1) + ->where('create_time', '>=', $dayStart) + ->where('create_time', '<=', $dayEnd); + if ($scope !== null) { + $dq->whereIn('channel_id', $scope); + } + $drow = $dq->fieldRaw('COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s')->find(); + $dsum = is_array($drow) && isset($drow['s']) ? strval($drow['s']) : '0'; + $depositAmounts[] = $this->formatMoney2($dsum); + + $bq = Db::name('bet_order') + ->whereIn('status', [1, 2]) + ->where('create_time', '>=', $dayStart) + ->where('create_time', '<=', $dayEnd); + if ($scope !== null) { + $bq->whereIn('channel_id', $scope); + } + $brow = $bq->fieldRaw('COALESCE(SUM(CAST(total_amount AS DECIMAL(18,4))),0) AS s')->find(); + $bsum = is_array($brow) && isset($brow['s']) ? strval($brow['s']) : '0'; + $betAmounts[] = $this->formatMoney2($bsum); + } + + return [ + 'days' => $days, + 'new_users' => $newUsers, + 'deposit_amount' => $depositAmounts, + 'bet_amount' => $betAmounts, + ]; + } + + /** + * 用户按渠道分布(取前 8 名,其余合并为「其他」)。 + * + * @param int[]|null $scope + * @return list + */ + private function buildChannelShare(?array $scope): array + { + $q = Db::name('user')->fieldRaw('channel_id, COUNT(*) AS c')->group('channel_id'); + if ($scope !== null) { + $q->whereIn('channel_id', $scope); + } + $rows = $q->orderRaw('c DESC')->select()->toArray(); + if ($rows === []) { + return []; + } + + $channelNames = Db::name('channel')->column('name', 'id'); + + $list = []; + foreach ($rows as $row) { + $cid = $row['channel_id']; + $cnt = intval($row['c'] ?? 0); + if ($cid === null || $cid === '' || intval(strval($cid)) === 0) { + $name = '未分配渠道'; + } else { + $id = intval(strval($cid)); + $name = $channelNames[$id] ?? ('#' . strval($id)); + } + $list[] = ['name' => $name, 'value' => $cnt]; + } + + if (count($list) <= 8) { + return $list; + } + + $head = array_slice($list, 0, 8); + $rest = array_slice($list, 8); + $other = 0; + foreach ($rest as $item) { + $other += $item['value']; + } + if ($other > 0) { + $head[] = ['name' => '其他', 'value' => $other]; + } + + return $head; + } + + /** + * 成功充值金额按订单归属渠道汇总(status=1,受渠道范围限制)。 + * + * @param int[]|null $scope + * @return list value 为两位小数字符串,供前端饼图展示 + */ + private function buildDepositAmountChannelShare(?array $scope): array + { + $q = Db::name('deposit_order') + ->where('status', 1) + ->fieldRaw('channel_id, COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s') + ->group('channel_id'); + if ($scope !== null) { + $q->whereIn('channel_id', $scope); + } + $rows = $q->select()->toArray(); + if ($rows === []) { + return []; + } + + $channelNames = Db::name('channel')->column('name', 'id'); + + $list = []; + foreach ($rows as $row) { + $cid = $row['channel_id']; + $sumRaw = isset($row['s']) ? strval($row['s']) : '0'; + $amountStr = $this->formatMoney2($sumRaw); + if (bccomp($amountStr, '0', 2) <= 0) { + continue; + } + if ($cid === null || $cid === '' || intval(strval($cid)) === 0) { + $name = '未分配渠道'; + } else { + $id = intval(strval($cid)); + $name = $channelNames[$id] ?? ('#' . strval($id)); + } + $list[] = [ + 'name' => $name, + 'value' => $amountStr, + ]; + } + + usort($list, static function (array $a, array $b): int { + return bccomp($b['value'], $a['value'], 2); + }); + + if (count($list) <= 8) { + return $list; + } + + $head = array_slice($list, 0, 8); + $rest = array_slice($list, 8); + $other = '0'; + foreach ($rest as $item) { + $other = bcadd($other, $item['value'], 4); + } + $otherFormatted = $this->formatMoney2($other); + if (bccomp($otherFormatted, '0', 2) > 0) { + $head[] = [ + 'name' => '其他', + 'value' => $otherFormatted, + ]; + } + + return $head; + } + + /** + * @param int[]|null $scope + * @return list + */ + private function fetchRecentUsers(?array $scope, int $limit): array + { + $q = Db::name('user') + ->field(['id', 'username', 'create_time', 'channel_id', 'head_image']) + ->order('id', 'desc') + ->limit($limit); + if ($scope !== null) { + $q->whereIn('channel_id', $scope); + } + $rows = $q->select()->toArray(); + if ($rows === []) { + return []; + } + + $channelNames = Db::name('channel')->column('name', 'id'); + $out = []; + foreach ($rows as $row) { + $cid = $row['channel_id'] ?? null; + if ($cid === null || $cid === '' || intval(strval($cid)) === 0) { + $cname = ''; + } else { + $id = intval(strval($cid)); + $cname = $channelNames[$id] ?? ''; + } + $out[] = [ + 'id' => intval($row['id'] ?? 0), + 'username' => strval($row['username'] ?? ''), + 'create_time' => intval($row['create_time'] ?? 0), + 'channel_name' => $cname, + 'head_image' => strval($row['head_image'] ?? ''), + ]; + } + + return $out; + } + + private function formatMoney2(string $amount): string + { + if (!is_numeric($amount)) { + return '0.00'; + } + $normalized = bcadd($amount, '0', 4); + + return bcadd($normalized, '0', 2); + } + + /** + * 非超管:按管理员所属渠道过滤;未绑定渠道时按 channel_id IN (0) 与列表页一致。 + * 超管:返回 null,表示 SQL 不加渠道条件。 + * + * @return int[]|null + */ + private function channelScopeOrNull(): ?array + { + if (!$this->auth || $this->auth->isSuperAdmin()) { + return null; + } + $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']; + } + + return $ids !== [] ? array_values(array_unique($ids)) : [0]; + } } diff --git a/web/src/api/backend/dashboard.ts b/web/src/api/backend/dashboard.ts index 5caef9d..0f68b0e 100644 --- a/web/src/api/backend/dashboard.ts +++ b/web/src/api/backend/dashboard.ts @@ -2,6 +2,53 @@ import createAxios from '/@/utils/axios' export const url = '/admin/Dashboard/' +export interface DashboardTrend { + days: string[] + new_users: number[] + deposit_amount: string[] + bet_amount: string[] +} + +export interface DashboardChannelShareItem { + name: string + value: number +} + +/** 成功充值金额按渠道(value 为两位小数字符串) */ +export interface DashboardDepositAmountChannelItem { + name: string + value: string +} + +export interface DashboardRecentUser { + id: number + username: string + create_time: number + channel_name: string + head_image: string +} + +export interface DashboardStats { + user_total: number + user_new_today: number + user_new_yesterday: number + user_new_growth_pct: number | null + deposit_today_amount: string + deposit_today_count: number + withdraw_pending: number + bet_today_amount: string + bet_today_count: number +} + +export interface DashboardPayload { + remark: string + stats: DashboardStats + trend: DashboardTrend + channel_share: DashboardChannelShareItem[] + deposit_amount_channel_share: DashboardDepositAmountChannelItem[] + recent_users: DashboardRecentUser[] +} + export function index() { return createAxios({ url: url + 'index', diff --git a/web/src/lang/backend/en/dashboard.ts b/web/src/lang/backend/en/dashboard.ts index 3927977..cc44c0d 100644 --- a/web/src/lang/backend/en/dashboard.ts +++ b/web/src/lang/backend/en/dashboard.ts @@ -36,4 +36,27 @@ export default { second: 'Second', day: 'Day', 'Number of attachments Uploaded': 'Number of attachments upload', + + stat_user_total: 'Total users', + stat_new_today: 'New users today', + stat_deposit_today: 'Deposits today (success)', + stat_withdraw_pending: 'Withdrawals pending review', + stat_hint_pending: 'Pending', + chart_new_user_deposit: 'Last 7 days: new users & daily deposits', + chart_bet_7d: 'Last 7 days: bet amount', + chart_channel_users: 'Users by channel', + chart_deposit_status: 'Deposit order status', + chart_deposit_amount_channel: 'Deposit amount by channel', + recent_users: 'Recent sign-ups', + no_data: 'No data', + bet_today_line: "Today's bet volume", + orders_unit: ' orders', + deposit_orders_today: '{n} successful today', + series_new_users: 'New users', + series_deposit_amount: 'Deposit amount', + series_bet_amount: 'Bet amount', + load_failed: 'Failed to load statistics. Please try again later.', + seconds_ago: 's ago', + minutes_ago: 'm ago', + hours_ago: 'h ago', } diff --git a/web/src/lang/backend/zh-cn/dashboard.ts b/web/src/lang/backend/zh-cn/dashboard.ts index 009eaa8..71f8312 100644 --- a/web/src/lang/backend/zh-cn/dashboard.ts +++ b/web/src/lang/backend/zh-cn/dashboard.ts @@ -36,4 +36,27 @@ export default { second: '秒', day: '天', 'Number of attachments Uploaded': '附件上传量', + + stat_user_total: '会员总数', + stat_new_today: '今日新增用户', + stat_deposit_today: '今日充值(成功)', + stat_withdraw_pending: '待审核提现', + stat_hint_pending: '待处理', + chart_new_user_deposit: '近7日新增用户与每日充值', + chart_bet_7d: '近7日投注金额', + chart_channel_users: '用户渠道分布', + chart_deposit_status: '充值订单状态分布', + chart_deposit_amount_channel: '充值金额渠道分布', + recent_users: '最新注册用户', + no_data: '暂无数据', + bet_today_line: '今日投注流水', + orders_unit: '笔', + deposit_orders_today: '今日成功 {n} 笔', + series_new_users: '新增用户', + series_deposit_amount: '充值金额', + series_bet_amount: '投注金额', + load_failed: '统计数据加载失败,请稍后重试', + seconds_ago: '秒前', + minutes_ago: '分钟前', + hours_ago: '小时前', } diff --git a/web/src/views/backend/dashboard.vue b/web/src/views/backend/dashboard.vue index 0921c94..6f8e2bf 100644 --- a/web/src/views/backend/dashboard.vue +++ b/web/src/views/backend/dashboard.vue @@ -1,126 +1,84 @@