修复错误

This commit is contained in:
CN-JS-HuiBai
2026-04-07 18:13:12 +08:00
parent 26fffef653
commit 38666db71a
4 changed files with 269 additions and 61 deletions

View File

@@ -18,9 +18,20 @@ class UserOnlineDevicesController extends PluginController
} }
public function index(Request $request): View public function index(Request $request): View
{
$securePath = admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key'))));
return view('UserOnlineDevices::admin-index', [
'apiEndpoint' => url('/api/v2/' . $securePath . '/user-online-devices/users'),
'adminHomeUrl' => url('/' . $securePath),
'defaultPageSize' => (int) $this->getConfig('default_page_size', 20),
]);
}
public function users(Request $request)
{ {
if ($error = $this->beforePluginAction()) { if ($error = $this->beforePluginAction()) {
abort($error[0], $error[1]); return $this->fail($error);
} }
$defaultPageSize = (int) $this->getConfig('default_page_size', 20); $defaultPageSize = (int) $this->getConfig('default_page_size', 20);
@@ -80,10 +91,18 @@ class UserOnlineDevicesController extends PluginController
return (int) ($user->online_count_live ?? 0); return (int) ($user->online_count_live ?? 0);
}); });
$securePath = admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key')))); return $this->success([
'list' => $paginator->getCollection()->map(function (User $user) {
return view('UserOnlineDevices::admin-index', [ return [
'users' => $paginator, 'id' => $user->id,
'email' => $user->email,
'subscription_name' => $user->subscription_name,
'online_count' => (int) ($user->online_count_live ?? 0),
'online_devices' => $user->online_devices ?? [],
'last_online_text' => $user->last_online_text ?? '-',
'created_text' => $user->created_text ?? '-',
];
})->values()->all(),
'filters' => [ 'filters' => [
'keyword' => $keyword, 'keyword' => $keyword,
'per_page' => $pageSize, 'per_page' => $pageSize,
@@ -94,7 +113,12 @@ class UserOnlineDevicesController extends PluginController
'total_online_ips' => $totalOnlineIps, 'total_online_ips' => $totalOnlineIps,
'current_page' => $paginator->currentPage(), 'current_page' => $paginator->currentPage(),
], ],
'adminHomeUrl' => url('/' . $securePath), 'pagination' => [
'current' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]); ]);
} }

View File

@@ -274,44 +274,46 @@
<div class="eyebrow">Xboard Admin Plugin</div> <div class="eyebrow">Xboard Admin Plugin</div>
<h1>All Users Online IP Monitor</h1> <h1>All Users Online IP Monitor</h1>
<div class="subtitle"> <div class="subtitle">
This plugin page is rendered on the server side for administrators. It reads live device state from Xboard nodes and shows each user's current online IP count and active IP list without relying on browser-stored admin tokens. This page is for Xboard administrators. It reads the current admin token from Xboard browser storage, then loads live device state from Xboard nodes and shows each user's current online IP count and active IP list.
</div> </div>
</section> </section>
<div id="error-box" class="empty" style="display:none; background: var(--danger-soft); border-style: solid; color: #991b1b;"></div>
<section class="summary"> <section class="summary">
<article class="card stat"> <article class="card stat">
<div class="stat-label">Users On Current Page</div> <div class="stat-label">Users On Current Page</div>
<div class="stat-value">{{ $summary['page_users'] }}</div> <div class="stat-value" id="page-users">0</div>
<div class="stat-note">Rows currently displayed after filtering</div> <div class="stat-note">Rows currently displayed after filtering</div>
</article> </article>
<article class="card stat"> <article class="card stat">
<div class="stat-label">Users With Online IP</div> <div class="stat-label">Users With Online IP</div>
<div class="stat-value">{{ $summary['users_with_online_ip'] }}</div> <div class="stat-value" id="users-with-ip">0</div>
<div class="stat-note">Users on this page whose live IP count is greater than zero</div> <div class="stat-note">Users on this page whose live IP count is greater than zero</div>
</article> </article>
<article class="card stat"> <article class="card stat">
<div class="stat-label">Total Online IP Count</div> <div class="stat-label">Total Online IP Count</div>
<div class="stat-value">{{ $summary['total_online_ips'] }}</div> <div class="stat-value" id="total-online-ips">0</div>
<div class="stat-note">Sum of all active IPs on the current page</div> <div class="stat-note">Sum of all active IPs on the current page</div>
</article> </article>
<article class="card stat"> <article class="card stat">
<div class="stat-label">Current Page</div> <div class="stat-label">Current Page</div>
<div class="stat-value">{{ $summary['current_page'] }}</div> <div class="stat-value" id="current-page">1</div>
<div class="stat-note">Page {{ $users->currentPage() }} of {{ $users->lastPage() }}</div> <div class="stat-note" id="page-note">Page 1 of 1</div>
</article> </article>
</section> </section>
<form class="toolbar" method="GET" action=""> <section class="toolbar">
<input class="input" name="keyword" type="text" value="{{ $filters['keyword'] }}" placeholder="Search by user ID or email"> <input class="input" id="keyword" type="text" placeholder="Search by user ID or email">
<select class="select" name="per_page"> <select class="select" id="per-page">
@foreach ([20, 50, 100] as $size) @foreach ([20, 50, 100] as $size)
<option value="{{ $size }}" @selected((int) $filters['per_page'] === $size)>{{ $size }} / page</option> <option value="{{ $size }}" @selected((int) $defaultPageSize === $size)>{{ $size }} / page</option>
@endforeach @endforeach
</select> </select>
<button class="btn primary" type="submit">Search</button> <button class="btn primary" id="search-btn" type="button">Search</button>
<a class="btn" href="{{ request()->url() }}">Reset</a> <button class="btn" id="reset-btn" type="button">Reset</button>
<a class="btn" href="{{ $adminHomeUrl }}">Back Admin</a> <a class="btn" href="{{ $adminHomeUrl }}">Back Admin</a>
</form> </section>
<section class="card panel"> <section class="card panel">
<div class="table-wrap"> <div class="table-wrap">
@@ -327,55 +329,228 @@
<th>Created</th> <th>Created</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="table-body"></tbody>
@forelse ($users as $user)
<tr>
<td>{{ $user->id }}</td>
<td>{{ $user->email }}</td>
<td>{{ $user->subscription_name }}</td>
<td>
<span class="count {{ ($user->online_count_live ?? 0) > 0 ? '' : 'zero' }}">
{{ $user->online_count_live ?? 0 }}
</span>
</td>
<td>
@if (!empty($user->online_devices))
<div class="ips">
@foreach ($user->online_devices as $ip)
<span class="ip">{{ $ip }}</span>
@endforeach
</div>
@else
<span class="muted">No active IP</span>
@endif
</td>
<td>{{ $user->last_online_text }}</td>
<td>{{ $user->created_text }}</td>
</tr>
@empty
<tr>
<td colspan="7">
<div class="empty">No users found for the current filter.</div>
</td>
</tr>
@endforelse
</tbody>
</table> </table>
</div> </div>
<div class="footer"> <div class="footer">
<div>Total {{ $users->total() }} users, showing page {{ $users->currentPage() }} / {{ $users->lastPage() }}</div> <div id="footer-text">Total 0 users, showing page 1 / 1</div>
<div class="pager"> <div class="pager">
@php <button class="btn" id="prev-btn" type="button" aria-disabled="true">Prev</button>
$prevUrl = $users->previousPageUrl(); <button class="btn" id="next-btn" type="button" aria-disabled="true">Next</button>
$nextUrl = $users->nextPageUrl();
@endphp
<a class="btn" href="{{ $prevUrl ?: '#' }}" aria-disabled="{{ $prevUrl ? 'false' : 'true' }}">Prev</a>
<a class="btn" href="{{ $nextUrl ?: '#' }}" aria-disabled="{{ $nextUrl ? 'false' : 'true' }}">Next</a>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<script>
(function () {
const API_ENDPOINT = @json($apiEndpoint);
const state = {
page: 1,
perPage: {{ (int) $defaultPageSize }},
lastPage: 1,
keyword: ''
};
function setError(message) {
const box = document.getElementById('error-box');
box.style.display = 'block';
box.textContent = message;
}
function clearError() {
const box = document.getElementById('error-box');
box.style.display = 'none';
box.textContent = '';
}
function normalizeToken(candidate) {
if (typeof candidate !== 'string') return '';
const trimmed = candidate.trim().replace(/^"|"$/g, '');
if (!trimmed) return '';
if (/^Bearer\s+/i.test(trimmed)) return trimmed;
return 'Bearer ' + trimmed;
}
function parseStoredToken(rawValue) {
if (!rawValue || typeof rawValue !== 'string') return '';
const trimmed = rawValue.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object') {
const nested = parsed.value || parsed.token || parsed.access_token || parsed.accessToken || '';
return normalizeToken(nested);
}
} catch (error) {
}
}
return normalizeToken(trimmed);
}
function getAdminAuthorization() {
const keys = [
'XBOARD_ACCESS_TOKEN',
'Xboard_access_token',
'Xboard_ACCESS_TOKEN',
'xboard_access_token'
];
const storages = [window.localStorage, window.sessionStorage];
for (const storage of storages) {
if (!storage) continue;
for (const key of keys) {
const token = parseStoredToken(storage.getItem(key));
if (token && token !== 'Bearer ') {
return token;
}
}
}
return '';
}
function resetTable() {
document.getElementById('page-users').textContent = '0';
document.getElementById('users-with-ip').textContent = '0';
document.getElementById('total-online-ips').textContent = '0';
document.getElementById('current-page').textContent = String(state.page);
document.getElementById('page-note').textContent = 'Page ' + state.page + ' of ' + state.lastPage;
document.getElementById('footer-text').textContent = 'Total 0 users, showing page ' + state.page + ' / ' + state.lastPage;
document.getElementById('prev-btn').setAttribute('aria-disabled', 'true');
document.getElementById('next-btn').setAttribute('aria-disabled', 'true');
document.getElementById('table-body').innerHTML = '<tr><td colspan="7"><div class="empty">No data available.</div></td></tr>';
}
function renderRows(list) {
const body = document.getElementById('table-body');
body.innerHTML = '';
if (!Array.isArray(list) || !list.length) {
body.innerHTML = '<tr><td colspan="7"><div class="empty">No users found for the current filter.</div></td></tr>';
return;
}
list.forEach(function (item) {
const row = document.createElement('tr');
const ips = Array.isArray(item.online_devices) ? item.online_devices : [];
const ipHtml = ips.length
? '<div class="ips">' + ips.map(function (ip) {
return '<span class="ip">' + ip + '</span>';
}).join('') + '</div>'
: '<span class="muted">No active IP</span>';
row.innerHTML =
'<td>' + item.id + '</td>' +
'<td>' + item.email + '</td>' +
'<td>' + item.subscription_name + '</td>' +
'<td><span class="count ' + (item.online_count > 0 ? '' : 'zero') + '">' + item.online_count + '</span></td>' +
'<td>' + ipHtml + '</td>' +
'<td>' + item.last_online_text + '</td>' +
'<td>' + item.created_text + '</td>';
body.appendChild(row);
});
}
function renderPayload(payload) {
const summary = payload.summary || {};
const pagination = payload.pagination || {};
state.lastPage = Number(pagination.last_page || 1);
document.getElementById('page-users').textContent = String(summary.page_users || 0);
document.getElementById('users-with-ip').textContent = String(summary.users_with_online_ip || 0);
document.getElementById('total-online-ips').textContent = String(summary.total_online_ips || 0);
document.getElementById('current-page').textContent = String(summary.current_page || state.page);
document.getElementById('page-note').textContent = 'Page ' + (pagination.current || state.page) + ' of ' + state.lastPage;
document.getElementById('footer-text').textContent = 'Total ' + (pagination.total || 0) + ' users, showing page ' + (pagination.current || state.page) + ' / ' + state.lastPage;
document.getElementById('prev-btn').setAttribute('aria-disabled', state.page <= 1 ? 'true' : 'false');
document.getElementById('next-btn').setAttribute('aria-disabled', state.page >= state.lastPage ? 'true' : 'false');
renderRows(payload.list || []);
}
async function loadData() {
const authorization = getAdminAuthorization();
if (!authorization) {
resetTable();
setError('No usable admin login token was found in Xboard browser storage. Please open the Xboard admin homepage first, complete login there, then refresh this page.');
return;
}
const url = new URL(API_ENDPOINT, window.location.origin);
url.searchParams.set('page', String(state.page));
url.searchParams.set('per_page', String(state.perPage));
if (state.keyword) {
url.searchParams.set('keyword', state.keyword);
}
try {
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': authorization
},
credentials: 'same-origin'
});
const result = await response.json();
if (!response.ok || !result || typeof result !== 'object') {
throw new Error('Request failed');
}
clearError();
renderPayload(result.data || {});
} catch (error) {
resetTable();
setError('Failed to load admin user online IP data. Please confirm you are logged in to the Xboard admin panel and the plugin is enabled.');
}
}
document.getElementById('search-btn').addEventListener('click', function () {
state.page = 1;
state.keyword = document.getElementById('keyword').value.trim();
state.perPage = Number(document.getElementById('per-page').value || {{ (int) $defaultPageSize }});
loadData();
});
document.getElementById('reset-btn').addEventListener('click', function () {
state.page = 1;
state.keyword = '';
state.perPage = {{ (int) $defaultPageSize }};
document.getElementById('keyword').value = '';
document.getElementById('per-page').value = String(state.perPage);
loadData();
});
document.getElementById('prev-btn').addEventListener('click', function () {
if (state.page <= 1) return;
state.page -= 1;
loadData();
});
document.getElementById('next-btn').addEventListener('click', function () {
if (state.page >= state.lastPage) return;
state.page += 1;
loadData();
});
document.getElementById('keyword').addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
document.getElementById('search-btn').click();
}
});
resetTable();
loadData();
})();
</script>
</body> </body>
</html> </html>

View File

@@ -1,3 +1,13 @@
<?php <?php
// This plugin intentionally uses a server-rendered admin page only. use Illuminate\Support\Facades\Route;
use Plugin\UserOnlineDevices\Controllers\UserOnlineDevicesController;
$securePath = admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key'))));
Route::group([
'prefix' => 'api/v2/' . $securePath . '/user-online-devices',
'middleware' => ['admin'],
], function () {
Route::get('/users', [UserOnlineDevicesController::class, 'users']);
});

View File

@@ -7,7 +7,6 @@ $securePath = admin_setting('secure_path', admin_setting('frontend_admin_path',
Route::group([ Route::group([
'prefix' => $securePath, 'prefix' => $securePath,
'middleware' => ['admin'],
], function () { ], function () {
Route::get('/user-online-devices', [UserOnlineDevicesController::class, 'index']); Route::get('/user-online-devices', [UserOnlineDevicesController::class, 'index']);
}); });