557 lines
19 KiB
PHP
557 lines
19 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>User Online Devices</title>
|
|
<style>
|
|
:root {
|
|
--bg: #eff5f4;
|
|
--panel: #ffffff;
|
|
--line: #d9e3e1;
|
|
--text: #172126;
|
|
--muted: #5f6f74;
|
|
--accent: #0f766e;
|
|
--accent-soft: rgba(15, 118, 110, 0.10);
|
|
--danger-soft: rgba(185, 28, 28, 0.08);
|
|
--shadow: 0 18px 46px rgba(23, 33, 38, 0.08);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
color: var(--text);
|
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
background:
|
|
radial-gradient(circle at top right, rgba(15, 118, 110, 0.12), transparent 28%),
|
|
linear-gradient(180deg, #f6fbfa 0%, #edf3f1 100%);
|
|
}
|
|
|
|
.wrap {
|
|
max-width: 1360px;
|
|
margin: 0 auto;
|
|
padding: 28px 20px 40px;
|
|
}
|
|
|
|
.hero {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.eyebrow {
|
|
font-size: 12px;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
}
|
|
|
|
h1 {
|
|
margin: 10px 0 12px;
|
|
font-size: clamp(30px, 4vw, 50px);
|
|
line-height: 1.06;
|
|
}
|
|
|
|
.subtitle {
|
|
max-width: 860px;
|
|
color: var(--muted);
|
|
line-height: 1.8;
|
|
font-size: 15px;
|
|
}
|
|
|
|
.summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 14px;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.card {
|
|
background: var(--panel);
|
|
border: 1px solid rgba(217, 227, 225, 0.94);
|
|
border-radius: 24px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.stat {
|
|
padding: 22px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.stat-value {
|
|
margin-top: 10px;
|
|
font-size: 34px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.stat-note {
|
|
margin-top: 8px;
|
|
color: var(--muted);
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
align-items: center;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.input,
|
|
.select,
|
|
.btn {
|
|
min-height: 46px;
|
|
border-radius: 14px;
|
|
border: 1px solid var(--line);
|
|
background: #fff;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.input,
|
|
.select {
|
|
padding: 12px 14px;
|
|
color: var(--text);
|
|
}
|
|
|
|
.input {
|
|
min-width: 280px;
|
|
flex: 1 1 320px;
|
|
}
|
|
|
|
.select {
|
|
min-width: 130px;
|
|
}
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0 16px;
|
|
text-decoration: none;
|
|
color: var(--text);
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn.primary {
|
|
color: #fff;
|
|
border-color: var(--accent);
|
|
background: linear-gradient(135deg, #0f766e, #115e59);
|
|
}
|
|
|
|
.panel {
|
|
padding: 12px;
|
|
}
|
|
|
|
.table-wrap {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
th,
|
|
td {
|
|
padding: 14px 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--line);
|
|
vertical-align: top;
|
|
font-size: 14px;
|
|
}
|
|
|
|
th {
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--muted);
|
|
}
|
|
|
|
tr:last-child td {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.count {
|
|
display: inline-flex;
|
|
min-width: 42px;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
background: var(--accent-soft);
|
|
color: var(--accent);
|
|
font-weight: 800;
|
|
}
|
|
|
|
.count.zero {
|
|
background: #eef2f2;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.ips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.ip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
background: #f3f5f5;
|
|
color: #374151;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.muted {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.empty {
|
|
margin: 10px;
|
|
padding: 16px 18px;
|
|
border-radius: 18px;
|
|
border: 1px dashed var(--line);
|
|
color: var(--muted);
|
|
background: #f9fbfb;
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.footer {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 14px 4px 2px;
|
|
color: var(--muted);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.pager {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.pager .btn[aria-disabled="true"] {
|
|
opacity: 0.45;
|
|
pointer-events: none;
|
|
}
|
|
|
|
@media (max-width: 1080px) {
|
|
.summary {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.summary {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="wrap">
|
|
<section class="hero">
|
|
<div class="eyebrow">Xboard Admin Plugin</div>
|
|
<h1>All Users Online IP Monitor</h1>
|
|
<div class="subtitle">
|
|
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>
|
|
</section>
|
|
|
|
<div id="error-box" class="empty" style="display:none; background: var(--danger-soft); border-style: solid; color: #991b1b;"></div>
|
|
|
|
<section class="summary">
|
|
<article class="card stat">
|
|
<div class="stat-label">Users On Current Page</div>
|
|
<div class="stat-value" id="page-users">0</div>
|
|
<div class="stat-note">Rows currently displayed after filtering</div>
|
|
</article>
|
|
<article class="card stat">
|
|
<div class="stat-label">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>
|
|
</article>
|
|
<article class="card stat">
|
|
<div class="stat-label">Total Online IP Count</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>
|
|
</article>
|
|
<article class="card stat">
|
|
<div class="stat-label">Current Page</div>
|
|
<div class="stat-value" id="current-page">1</div>
|
|
<div class="stat-note" id="page-note">Page 1 of 1</div>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="toolbar">
|
|
<input class="input" id="keyword" type="text" placeholder="Search by user ID or email">
|
|
<select class="select" id="per-page">
|
|
@foreach ([20, 50, 100] as $size)
|
|
<option value="{{ $size }}" @selected((int) $defaultPageSize === $size)>{{ $size }} / page</option>
|
|
@endforeach
|
|
</select>
|
|
<button class="btn primary" id="search-btn" type="button">Search</button>
|
|
<button class="btn" id="reset-btn" type="button">Reset</button>
|
|
<a class="btn" href="{{ $adminHomeUrl }}">Back Admin</a>
|
|
</section>
|
|
|
|
<section class="card panel">
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Email</th>
|
|
<th>Subscription</th>
|
|
<th>Online IP Count</th>
|
|
<th>Online IP List</th>
|
|
<th>Last Online</th>
|
|
<th>Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="table-body"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<div id="footer-text">Total 0 users, showing page 1 / 1</div>
|
|
<div class="pager">
|
|
<button class="btn" id="prev-btn" type="button" aria-disabled="true">Prev</button>
|
|
<button class="btn" id="next-btn" type="button" aria-disabled="true">Next</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</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>
|
|
|
|
</html>
|