修复错误
This commit is contained in:
@@ -0,0 +1,612 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Online IP Monitor</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3f4f6;
|
||||
--panel: #ffffff;
|
||||
--line: #e5e7eb;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--accent: #0f766e;
|
||||
--accent-soft: rgba(15, 118, 110, 0.1);
|
||||
--shadow: 0 18px 45px rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(15, 118, 110, 0.08), transparent 30%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px 40px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0 10px;
|
||||
font-size: clamp(30px, 4vw, 52px);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-width: 820px;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.btn {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select {
|
||||
padding: 12px 14px;
|
||||
min-height: 46px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 260px;
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.select {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: 46px;
|
||||
padding: 0 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(229, 231, 235, 0.92);
|
||||
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;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 42px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error,
|
||||
.empty {
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-bottom: 14px;
|
||||
background: rgba(185, 28, 28, 0.08);
|
||||
border: 1px solid rgba(185, 28, 28, 0.14);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.empty {
|
||||
background: #f9fafb;
|
||||
border: 1px dashed var(--line);
|
||||
color: var(--muted);
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Xboard Admin Monitor</div>
|
||||
<h1>All Users Online IP Count</h1>
|
||||
<div class="subtitle">
|
||||
This page is intended for administrators. It shows the current online IP quantity of all users reported by Xboard nodes, and supports keyword search by user ID or email.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="error-box" class="error" style="display:none;"></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">How many user rows are currently loaded</div>
|
||||
</article>
|
||||
<article class="card stat">
|
||||
<div class="stat-label">Users With Online IP</div>
|
||||
<div class="stat-value" id="online-users">0</div>
|
||||
<div class="stat-note">Users on this page whose online 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-ips">0</div>
|
||||
<div class="stat-note" id="refresh-note">Waiting for data</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="toolbar">
|
||||
<input class="input" id="keyword-input" type="text" placeholder="Search by user ID or email">
|
||||
<select class="select" id="page-size">
|
||||
<option value="20">20 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</select>
|
||||
<button class="btn primary" id="search-btn" type="button">Search</button>
|
||||
<button class="btn secondary" id="reload-btn" type="button">Reload</button>
|
||||
<button class="btn secondary" id="home-btn" type="button">Back Admin</button>
|
||||
</section>
|
||||
|
||||
<section class="card panel">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</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="pagination-text">Page 1</div>
|
||||
<div>
|
||||
<button class="btn secondary" id="prev-btn" type="button">Prev</button>
|
||||
<button class="btn secondary" id="next-btn" type="button">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const API_ENDPOINT = @json($apiEndpoint);
|
||||
const ADMIN_HOME_URL = @json($adminHomeUrl);
|
||||
const REFRESH_MS = 30000;
|
||||
const TOKEN_KEYS = [
|
||||
'VUE_NAIVE_ACCESS_TOKEN',
|
||||
'Vue_Naive_access_token',
|
||||
'access_token',
|
||||
'AccessToken',
|
||||
'ACCESS_TOKEN'
|
||||
];
|
||||
const state = {
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
lastPage: 1,
|
||||
keyword: ''
|
||||
};
|
||||
|
||||
function isObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeToken(candidate) {
|
||||
if (typeof candidate !== 'string') return '';
|
||||
|
||||
const trimmed = candidate.trim().replace(/^"|"$/g, '');
|
||||
if (!trimmed) return '';
|
||||
|
||||
const raw = trimmed.replace(/^Bearer\s+/i, '').trim();
|
||||
if (raw.length < 20 || /\s/.test(raw)) return '';
|
||||
|
||||
return 'Bearer ' + raw;
|
||||
}
|
||||
|
||||
function extractTokenFromParsedValue(input) {
|
||||
if (!input) return '';
|
||||
|
||||
if (typeof input === 'string') {
|
||||
return normalizeToken(input);
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
for (const item of input) {
|
||||
const token = extractTokenFromParsedValue(item);
|
||||
if (token) return token;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!isObject(input)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof input.expired === 'boolean' && input.expired) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const expiresAt = Number(input.expires_at || input.expiresAt || input.expire_at || input.expireAt || 0);
|
||||
if (expiresAt && expiresAt < Math.floor(Date.now() / 1000)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const directKeys = ['value', 'token', 'access_token', 'accessToken', 'data'];
|
||||
for (const key of directKeys) {
|
||||
const token = extractTokenFromParsedValue(input[key]);
|
||||
if (token) return token;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseStoredToken(rawValue) {
|
||||
if (!rawValue || typeof rawValue !== 'string') return '';
|
||||
|
||||
const trimmed = rawValue.trim();
|
||||
const direct = normalizeToken(trimmed);
|
||||
if (direct) return direct;
|
||||
|
||||
if (trimmed[0] === '{' || trimmed[0] === '[') {
|
||||
try {
|
||||
return extractTokenFromParsedValue(JSON.parse(trimmed));
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function readTokenFromStorage(storage) {
|
||||
if (!storage) return '';
|
||||
|
||||
for (const key of TOKEN_KEYS) {
|
||||
const token = parseStoredToken(storage.getItem(key));
|
||||
if (token) return token;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getAuthorization() {
|
||||
return readTokenFromStorage(window.localStorage) || readTokenFromStorage(window.sessionStorage);
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return '-';
|
||||
|
||||
let date = null;
|
||||
if (typeof timestamp === 'number') {
|
||||
date = new Date(timestamp > 9999999999 ? timestamp : timestamp * 1000);
|
||||
} else if (typeof timestamp === 'string') {
|
||||
const trimmed = timestamp.trim();
|
||||
if (!trimmed) return '-';
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const numeric = Number(trimmed);
|
||||
date = new Date(numeric > 9999999999 ? numeric : numeric * 1000);
|
||||
} else {
|
||||
date = new Date(trimmed);
|
||||
}
|
||||
} else {
|
||||
date = new Date(timestamp);
|
||||
}
|
||||
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const pad = function (value) { return String(value).padStart(2, '0'); };
|
||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
|
||||
}
|
||||
|
||||
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 resetTable() {
|
||||
document.getElementById('table-body').innerHTML = '<tr><td colspan="6"><div class="empty">No data available.</div></td></tr>';
|
||||
document.getElementById('page-users').textContent = '0';
|
||||
document.getElementById('online-users').textContent = '0';
|
||||
document.getElementById('total-ips').textContent = '0';
|
||||
document.getElementById('refresh-note').textContent = 'Waiting for data';
|
||||
document.getElementById('pagination-text').textContent = 'Page 1';
|
||||
}
|
||||
|
||||
async function getJson(url, authorization) {
|
||||
const response = await fetch(url, {
|
||||
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');
|
||||
}
|
||||
|
||||
return result.data || {};
|
||||
}
|
||||
|
||||
function renderData(payload) {
|
||||
const list = Array.isArray(payload.list) ? payload.list : [];
|
||||
const onlineUsers = list.filter(function (item) { return Number(item.online_count || 0) > 0; }).length;
|
||||
const totalIps = list.reduce(function (sum, item) {
|
||||
return sum + Number(item.online_count || 0);
|
||||
}, 0);
|
||||
|
||||
document.getElementById('page-users').textContent = String(list.length);
|
||||
document.getElementById('online-users').textContent = String(onlineUsers);
|
||||
document.getElementById('total-ips').textContent = String(totalIps);
|
||||
document.getElementById('refresh-note').textContent = 'Refresh every ' + ((payload.meta && payload.meta.refresh_interval_seconds) || 30) + 's';
|
||||
|
||||
const pagination = payload.pagination || {};
|
||||
state.total = Number(pagination.total || 0);
|
||||
state.lastPage = Number(pagination.lastPage || 1);
|
||||
document.getElementById('pagination-text').textContent = 'Page ' + state.current + ' / ' + state.lastPage + ' , Total ' + state.total;
|
||||
|
||||
const body = document.getElementById('table-body');
|
||||
body.innerHTML = '';
|
||||
|
||||
if (!list.length) {
|
||||
body.innerHTML = '<tr><td colspan="6"><div class="empty">No users found for the current filter.</div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.forEach(function (item) {
|
||||
const row = document.createElement('tr');
|
||||
const devices = Array.isArray(item.online_devices) ? item.online_devices : [];
|
||||
const ipHtml = devices.length
|
||||
? '<div class="ips">' + devices.map(function (device) {
|
||||
return '<span class="ip">' + device.ip + '</span>';
|
||||
}).join('') + '</div>'
|
||||
: '<span class="muted">No online IP</span>';
|
||||
|
||||
row.innerHTML =
|
||||
'<td>' + item.id + '</td>' +
|
||||
'<td>' + item.email + '</td>' +
|
||||
'<td><span class="count">' + item.online_count + '</span></td>' +
|
||||
'<td>' + ipHtml + '</td>' +
|
||||
'<td>' + formatTime(item.last_online_at) + '</td>' +
|
||||
'<td>' + formatTime(item.created_at) + '</td>';
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const authorization = getAuthorization();
|
||||
if (!authorization) {
|
||||
resetTable();
|
||||
setError('No usable admin login token was found in Xboard browser storage. Please log in to the admin panel first, then refresh this page.');
|
||||
return;
|
||||
}
|
||||
|
||||
clearError();
|
||||
|
||||
const url = new URL(API_ENDPOINT, window.location.origin);
|
||||
url.searchParams.set('current', String(state.current));
|
||||
url.searchParams.set('pageSize', String(state.pageSize));
|
||||
if (state.keyword) {
|
||||
url.searchParams.set('keyword', state.keyword);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getJson(url.toString(), authorization);
|
||||
renderData(payload);
|
||||
} catch (error) {
|
||||
resetTable();
|
||||
setError('Failed to load admin user online IP data. Please confirm you are logged in as an administrator and the plugin is enabled.');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('search-btn').addEventListener('click', function () {
|
||||
state.current = 1;
|
||||
state.keyword = document.getElementById('keyword-input').value.trim();
|
||||
state.pageSize = Number(document.getElementById('page-size').value || 20);
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('reload-btn').addEventListener('click', function () {
|
||||
state.pageSize = Number(document.getElementById('page-size').value || 20);
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('home-btn').addEventListener('click', function () {
|
||||
window.location.href = ADMIN_HOME_URL;
|
||||
});
|
||||
|
||||
document.getElementById('prev-btn').addEventListener('click', function () {
|
||||
if (state.current <= 1) return;
|
||||
state.current -= 1;
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('next-btn').addEventListener('click', function () {
|
||||
if (state.current >= state.lastPage) return;
|
||||
state.current += 1;
|
||||
loadData();
|
||||
});
|
||||
|
||||
document.getElementById('keyword-input').addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter') {
|
||||
document.getElementById('search-btn').click();
|
||||
}
|
||||
});
|
||||
|
||||
resetTable();
|
||||
loadData();
|
||||
window.setInterval(loadData, REFRESH_MS);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user