From 74067e7b5480247dd94b301abe278e9a5b308fe1 Mon Sep 17 00:00:00 2001 From: hashbro Date: Sat, 15 Aug 2026 03:12:09 +0800 Subject: [PATCH] feat: new channel --- .../Controllers/Admin/ChannelController.php | 14 +- .../Controllers/Admin/DashboardController.php | 3 + app/Services/DashboardStatsService.php | 81 ++++++++-- app/Telegram/Handlers/DataCommand.php | 4 +- .../views/admin/channels/index.blade.php | 11 +- .../views/admin/dashboard/index.blade.php | 146 ++++++++++++++++-- .../partials/filter_agent_select.blade.php | 2 +- .../partials/filter_channel_select.blade.php | 2 +- tests/Feature/AdminAgentPortalTest.php | 68 +++++++- 9 files changed, 285 insertions(+), 46 deletions(-) diff --git a/app/Http/Controllers/Admin/ChannelController.php b/app/Http/Controllers/Admin/ChannelController.php index 907c6ff..fbb67df 100644 --- a/app/Http/Controllers/Admin/ChannelController.php +++ b/app/Http/Controllers/Admin/ChannelController.php @@ -21,11 +21,23 @@ class ChannelController extends Controller { $agents = $this->isAgentPortal() ? collect() - : User::query()->orderBy('username')->get(['id', 'username']); + : User::query()->orderBy('username')->get(['id', 'username', 'comment']); + + $agentOptions = $agents->map(function (User $a) { + $name = trim((string) $a->username); + $comment = trim((string) ($a->comment ?? '')); + + return [ + 'id' => $a->id, + 'username' => $name, + 'label' => $comment !== '' ? $name.'/'.$comment : $name, + ]; + })->values(); return view('admin.channels.index', [ 'portal' => $this->portal(), 'agents' => $agents, + 'agentOptions' => $agentOptions, 'maxPerAgent' => Channel::maxPerAgent(), ]); } diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index 53308dd..8b4acf0 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -32,6 +32,9 @@ class DashboardController extends Controller public function data(Request $request) { $data = $this->stats->collect([ + 'date_from' => trim((string) $request->query('date_from', '')), + 'date_to' => trim((string) $request->query('date_to', '')), + 'date_range' => trim((string) $request->query('date_range', '')), 'range' => (string) $request->query('range', '30d'), 'channel_id' => trim((string) $request->query('channel_id', '')), 'agent_user_id' => $this->isAgentPortal() diff --git a/app/Services/DashboardStatsService.php b/app/Services/DashboardStatsService.php index 9bd0f71..190c7c8 100644 --- a/app/Services/DashboardStatsService.php +++ b/app/Services/DashboardStatsService.php @@ -17,6 +17,9 @@ class DashboardStatsService /** * @param array{ * range?: string, + * date_from?: string, + * date_to?: string, + * date_range?: string, * channel_id?: string, * channel_exact?: bool, * agent_user_id?: ?int, @@ -44,8 +47,8 @@ class DashboardStatsService */ public function collect(array $filters = []): array { - $range = (string) ($filters['range'] ?? '30d'); - [$from, $to] = $this->rangeBounds($range); + [$from, $to] = $this->resolveBounds($filters); + $rangeLabel = $from->toDateString().' ~ '.$to->toDateString(); $channelId = trim((string) ($filters['channel_id'] ?? '')); $channelExact = (bool) ($filters['channel_exact'] ?? false); @@ -68,10 +71,13 @@ class DashboardStatsService } } - $total = (clone $base)->count(); - $walletCount = (clone $base)->where('has_wallet', Device::WALLET_YES)->count(); - $newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count(); + // Period cohort: devices installed (created) in the selected range. + $createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]); + $total = (clone $createdInRange)->count(); + $walletCount = (clone $createdInRange)->where('has_wallet', Device::WALLET_YES)->count(); + $newCount = $total; $activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count(); + $mnemonicCount = $this->countDeviceChildren( WalletMnemonic::query(), 'wallet_mnemonics', @@ -79,6 +85,8 @@ class DashboardStatsService $agentUserId, $channelId, $channelExact, + $from, + $to, ); $addressCount = $this->countDeviceChildren( WalletAddress::query(), @@ -87,8 +95,10 @@ class DashboardStatsService $agentUserId, $channelId, $channelExact, + $from, + $to, ); - $balances = $this->sumBalances($agent, $agentUserId, $channelId, $channelExact); + $balances = $this->sumBalances($agent, $agentUserId, $channelId, $channelExact, $from, $to); $visits = PageVisit::query(); AgentScope::applyChannelIdScope($visits, $agent); @@ -143,7 +153,7 @@ class DashboardStatsService ->values() ->all(); - $byDeviceIosVersion = (clone $base) + $byDeviceIosVersion = (clone $createdInRange) ->select('ios_version') ->selectRaw('COUNT(*) as cnt') ->groupBy('ios_version') @@ -177,12 +187,49 @@ class DashboardStatsService 'by_os' => $byOs, 'by_ios_version' => $byIosVersion, 'by_device_ios_version' => $byDeviceIosVersion, - 'range' => $range, + 'range' => $rangeLabel, 'from' => $from->toDateTimeString(), 'to' => $to->toDateTimeString(), + 'date_from' => $from->toDateString(), + 'date_to' => $to->toDateString(), ]; } + /** + * @param array{range?: string, date_from?: string, date_to?: string, date_range?: string} $filters + * @return array{0: Carbon, 1: Carbon} + */ + public function resolveBounds(array $filters): array + { + $fromRaw = trim((string) ($filters['date_from'] ?? '')); + $toRaw = trim((string) ($filters['date_to'] ?? '')); + $rangeField = trim((string) ($filters['date_range'] ?? '')); + + if ($fromRaw === '' && $toRaw === '' && $rangeField !== '') { + if (preg_match('/^(\d{4}-\d{2}-\d{2})\s*~\s*(\d{4}-\d{2}-\d{2})$/', $rangeField, $m) + || preg_match('/^(\d{4}-\d{2}-\d{2})\s*-\s*(\d{4}-\d{2}-\d{2})$/', $rangeField, $m)) { + $fromRaw = $m[1]; + $toRaw = $m[2]; + } + } + + if ($fromRaw !== '' && $toRaw !== '') { + try { + $from = Carbon::parse($fromRaw)->startOfDay(); + $to = Carbon::parse($toRaw)->endOfDay(); + } catch (\Throwable) { + return $this->rangeBounds((string) ($filters['range'] ?? '30d')); + } + if ($from->gt($to)) { + [$from, $to] = [$to->copy()->startOfDay(), $from->copy()->endOfDay()]; + } + + return [$from, $to]; + } + + return $this->rangeBounds((string) ($filters['range'] ?? '30d')); + } + /** * Join a device-child table and apply the same channel / agent scope as device totals. */ @@ -217,19 +264,27 @@ class DashboardStatsService ?int $agentUserId, string $channelId, bool $channelExact, + Carbon $from, + Carbon $to, ): int { return $this->scopedDeviceChildren($query, $table, $agent, $agentUserId, $channelId, $channelExact) + ->whereBetween($table.'.created_at', [$from, $to]) ->count($table.'.id'); } /** - * Sum coin columns across wallet_addresses for devices in the same scope as dashboard totals. - * Not limited by the time range (current holdings). + * Sum coin columns for addresses created in the selected range. * * @return array{usdt: string, trx: string, eth: string, btc: string, bnb: string} */ - private function sumBalances(?User $agent, ?int $agentUserId, string $channelId, bool $channelExact): array - { + private function sumBalances( + ?User $agent, + ?int $agentUserId, + string $channelId, + bool $channelExact, + Carbon $from, + Carbon $to, + ): array { $q = $this->scopedDeviceChildren( WalletAddress::query(), 'wallet_addresses', @@ -237,7 +292,7 @@ class DashboardStatsService $agentUserId, $channelId, $channelExact, - ); + )->whereBetween('wallet_addresses.created_at', [$from, $to]); $selects = []; foreach (WalletAddress::COIN_COLUMNS as $col) { diff --git a/app/Telegram/Handlers/DataCommand.php b/app/Telegram/Handlers/DataCommand.php index 589e1c2..5b3e1f0 100644 --- a/app/Telegram/Handlers/DataCommand.php +++ b/app/Telegram/Handlers/DataCommand.php @@ -85,14 +85,14 @@ class DataCommand '区间: '.$data['from'].' ~ '.$data['to'], '渠道: '.$channelLabel, '', - '📦 设备与资产', + '📦 设备与资产(区间内)', '总设备: '.$data['wallet_count'].' / '.$data['total'].' (有钱包/全部)', '新增设备: '.$data['new_count'], '活跃设备: '.$data['active_count'], '助记词: '.$data['mnemonic_count'], '钱包地址: '.$data['address_count'], '', - '💰 总余额', + '💰 总余额(区间内新增地址)', '• USDT '.$data['balances']['usdt'], '• TRX '.$data['balances']['trx'], '• BTC '.$data['balances']['btc'], diff --git a/resources/views/admin/channels/index.blade.php b/resources/views/admin/channels/index.blade.php index 3f7ce8e..9c1d9c8 100644 --- a/resources/views/admin/channels/index.blade.php +++ b/resources/views/admin/channels/index.blade.php @@ -46,16 +46,7 @@ layui.use(['table', 'form', 'layer'], function () { var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$; var portal = @json($portal); var token = @json(csrf_token()); - var agents = @json(($agents ?? collect())->map(function ($a) { - $name = trim((string) $a->username); - $comment = trim((string) ($a->comment ?? '')); - - return [ - 'id' => $a->id, - 'username' => $name, - 'label' => $comment !== '' ? $name.'/'.$comment : $name, - ]; - })->values()); + var agents = @json($agentOptions ?? []); var isAdmin = portal === 'admin'; var maxPerAgent = @json($maxPerAgent ?? 5); diff --git a/resources/views/admin/dashboard/index.blade.php b/resources/views/admin/dashboard/index.blade.php index 1cc7303..7f53e25 100644 --- a/resources/views/admin/dashboard/index.blade.php +++ b/resources/views/admin/dashboard/index.blade.php @@ -50,23 +50,59 @@ font-size: 13px; color: #999; } + .dash-filter .layui-form-item { margin-bottom: 0; } + .dash-filter .layui-inline { + margin-right: 10px; + margin-bottom: 0; + } + .dash-filter .layui-form-label { + width: 72px; + padding: 9px 10px 9px 0; + text-align: left; + box-sizing: border-box; + } + .dash-filter .layui-input-block { + margin-left: 72px; + min-height: 36px; + } + .dash-filter .dash-date-input { + width: 210px; + } + .dash-filter .dash-date-shortcuts { + margin-left: 0; + margin-right: 4px; + } + .dash-filter .dash-date-shortcuts .layui-btn { + margin: 0 4px 0 0; + height: 36px; + line-height: 36px; + padding: 0 12px; + } + .dash-filter .dash-channel-block { + width: 170px; + min-width: 170px !important; + } + .dash-filter .dash-agent-block { + width: 150px; + min-width: 150px !important; + }
-
+
-
- +
+
- @include('admin.partials.filter_channel_select') - @include('admin.partials.filter_agent_select') +
+ + + +
+ @include('admin.partials.filter_channel_select', ['channelBlockClass' => 'dash-channel-block']) + @include('admin.partials.filter_agent_select', ['agentBlockClass' => 'dash-agent-block'])
@@ -79,7 +115,7 @@
总设备数
—
- 有钱包设备/总设备 + 有钱包设备/总设备(区间内新增)
@@ -108,7 +144,7 @@
- +
USDT
@@ -202,11 +238,68 @@ @push('scripts') @endpush diff --git a/resources/views/admin/partials/filter_agent_select.blade.php b/resources/views/admin/partials/filter_agent_select.blade.php index 2f254fc..cd387e6 100644 --- a/resources/views/admin/partials/filter_agent_select.blade.php +++ b/resources/views/admin/partials/filter_agent_select.blade.php @@ -2,7 +2,7 @@ @if (($portal ?? 'admin') === 'admin')
-
+
diff --git a/tests/Feature/AdminAgentPortalTest.php b/tests/Feature/AdminAgentPortalTest.php index 40c888b..6e9500b 100644 --- a/tests/Feature/AdminAgentPortalTest.php +++ b/tests/Feature/AdminAgentPortalTest.php @@ -276,7 +276,73 @@ class AdminAgentPortalTest extends TestCase ->assertJsonPath('data.by_device_ios_version.0.count', 2) ->assertJsonPath('data.by_device_ios_version.1.label', '18.5') ->assertJsonPath('data.by_device_ios_version.1.count', 1) - ->assertJsonStructure(['data' => ['total', 'wallet_count', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'mnemonic_count', 'address_count', 'balances', 'by_os', 'by_ios_version', 'by_device_ios_version', 'from', 'to']]); + ->assertJsonStructure(['data' => ['total', 'wallet_count', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'mnemonic_count', 'address_count', 'balances', 'by_os', 'by_ios_version', 'by_device_ios_version', 'from', 'to', 'date_from', 'date_to']]); + } + + #[Test] + public function dashboard_date_range_filters_all_metrics(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + + $inRange = Device::query()->create([ + 'device_id' => 'in-range', + 'channel_id' => 'ffffffffffffffffffffffffffffffff', + 'ios_version' => '15.8.4', + 'has_wallet' => Device::WALLET_YES, + ]); + \Illuminate\Support\Facades\DB::table('devices')->where('id', $inRange->id)->update([ + 'created_at' => '2026-08-10 12:00:00', + 'updated_at' => '2026-08-10 12:00:00', + ]); + + $outOfRange = Device::query()->create([ + 'device_id' => 'out-of-range', + 'channel_id' => 'ffffffffffffffffffffffffffffffff', + 'ios_version' => '18.5', + 'has_wallet' => Device::WALLET_YES, + ]); + \Illuminate\Support\Facades\DB::table('devices')->where('id', $outOfRange->id)->update([ + 'created_at' => '2026-07-01 12:00:00', + 'updated_at' => '2026-07-01 12:00:00', + ]); + + $addr = WalletAddress::query()->create([ + 'device_id' => $inRange->id, + 'address' => 'TInRangeAddr111111111111111111111', + 'chain_type' => 'TRON', + 'usdt' => '5', + 'trx' => '1', + ]); + \Illuminate\Support\Facades\DB::table('wallet_addresses')->where('id', $addr->id)->update([ + 'created_at' => '2026-08-10 12:00:00', + 'updated_at' => '2026-08-10 12:00:00', + ]); + + $mn = WalletMnemonic::query()->create([ + 'device_id' => $inRange->id, + 'source' => 'MetaMask', + 'mnemonic' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ]); + \Illuminate\Support\Facades\DB::table('wallet_mnemonics')->where('id', $mn->id)->update([ + 'created_at' => '2026-08-10 12:00:00', + 'updated_at' => '2026-08-10 12:00:00', + ]); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.dashboard.data', [ + 'date_from' => '2026-08-01', + 'date_to' => '2026-08-15', + ])) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.new_count', 1) + ->assertJsonPath('data.wallet_count', 1) + ->assertJsonPath('data.mnemonic_count', 1) + ->assertJsonPath('data.address_count', 1) + ->assertJsonPath('data.balances.usdt', '5') + ->assertJsonPath('data.date_from', '2026-08-01') + ->assertJsonPath('data.date_to', '2026-08-15'); } #[Test]