Files
UserOnlineStatus/Xboard/plugins/UserOnlineDevices/resources/views/admin-users.blade.php
CN-JS-HuiBai 6467f17b30 修复错误
2026-04-07 17:41:45 +08:00

613 lines
20 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>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>