555 lines
20 KiB
PHP
555 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>User Status</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f5f1e8;
|
|
--panel: rgba(255, 255, 255, 0.86);
|
|
--line: #ded5c3;
|
|
--text: #2f271d;
|
|
--muted: #756957;
|
|
--accent: #1f7a5a;
|
|
--accent-soft: rgba(31, 122, 90, 0.1);
|
|
--warn: #9a5b29;
|
|
--shadow: 0 22px 60px rgba(73, 54, 27, 0.12);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
font-family: Georgia, "Times New Roman", serif;
|
|
color: var(--text);
|
|
background:
|
|
radial-gradient(circle at top left, rgba(255, 255, 255, 0.82), transparent 32%),
|
|
linear-gradient(145deg, #efe5d4 0%, #faf7f1 46%, #ebe1d0 100%);
|
|
}
|
|
|
|
.wrap {
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
padding: 28px 20px 40px;
|
|
}
|
|
|
|
.hero {
|
|
margin-bottom: 22px;
|
|
}
|
|
|
|
.eyebrow {
|
|
font-size: 12px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
}
|
|
|
|
h1 {
|
|
margin: 10px 0 10px;
|
|
font-size: clamp(32px, 5vw, 58px);
|
|
line-height: 1;
|
|
}
|
|
|
|
.subtitle {
|
|
max-width: 760px;
|
|
font-size: 16px;
|
|
line-height: 1.75;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
gap: 16px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.card {
|
|
background: var(--panel);
|
|
border: 1px solid rgba(222, 213, 195, 0.92);
|
|
border-radius: 24px;
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.stat {
|
|
padding: 22px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.stat-value {
|
|
margin-top: 10px;
|
|
font-size: 34px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.stat-note {
|
|
margin-top: 10px;
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.two-col {
|
|
display: grid;
|
|
grid-template-columns: 1.15fr 0.85fr;
|
|
gap: 18px;
|
|
}
|
|
|
|
.panel {
|
|
padding: 22px;
|
|
}
|
|
|
|
.panel-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.panel-title {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 12px;
|
|
border-radius: 999px;
|
|
background: var(--accent-soft);
|
|
color: var(--accent);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.list {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.row {
|
|
padding: 16px;
|
|
border-radius: 18px;
|
|
background: rgba(255, 255, 255, 0.7);
|
|
border: 1px solid rgba(222, 213, 195, 0.8);
|
|
}
|
|
|
|
.row-title {
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.row-meta {
|
|
margin-top: 8px;
|
|
font-size: 14px;
|
|
line-height: 1.65;
|
|
color: var(--muted);
|
|
word-break: break-all;
|
|
}
|
|
|
|
.empty,
|
|
.error {
|
|
padding: 18px;
|
|
border-radius: 18px;
|
|
font-size: 14px;
|
|
line-height: 1.65;
|
|
}
|
|
|
|
.empty {
|
|
background: rgba(255, 255, 255, 0.55);
|
|
border: 1px dashed var(--line);
|
|
color: var(--muted);
|
|
}
|
|
|
|
.error {
|
|
background: rgba(154, 91, 41, 0.08);
|
|
border: 1px solid rgba(154, 91, 41, 0.18);
|
|
color: var(--warn);
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.hint {
|
|
margin-top: 16px;
|
|
color: var(--warn);
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.actions {
|
|
margin-top: 18px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
}
|
|
|
|
.btn {
|
|
border: 0;
|
|
cursor: pointer;
|
|
border-radius: 999px;
|
|
padding: 10px 16px;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
background: linear-gradient(135deg, #1f7a5a, #2a936d);
|
|
}
|
|
|
|
.btn.secondary {
|
|
color: var(--text);
|
|
background: rgba(255, 255, 255, 0.78);
|
|
border: 1px solid var(--line);
|
|
}
|
|
|
|
@media (max-width: 860px) {
|
|
.two-col {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="wrap">
|
|
<section class="hero">
|
|
<div class="eyebrow">Xboard User Status</div>
|
|
<h1>User Status</h1>
|
|
<div class="subtitle">
|
|
This page is provided by the User Online Devices plugin. After login, it reads your current token from browser storage and shows your own online IP list, online count and account status.
|
|
</div>
|
|
</section>
|
|
|
|
<div id="error-box" class="error" style="display:none;"></div>
|
|
|
|
<section class="grid">
|
|
<article class="card stat">
|
|
<div class="stat-label">User</div>
|
|
<div class="stat-value" id="user-email">-</div>
|
|
<div class="stat-note" id="user-id">Please log in first</div>
|
|
</article>
|
|
<article class="card stat">
|
|
<div class="stat-label">Online IP Count</div>
|
|
<div class="stat-value" id="online-count">-</div>
|
|
<div class="stat-note">Unique real-time IP count reported by Xboard nodes</div>
|
|
</article>
|
|
<article class="card stat">
|
|
<div class="stat-label">Last Online</div>
|
|
<div class="stat-value" id="last-online">-</div>
|
|
<div class="stat-note">Last device state update written by Xboard</div>
|
|
</article>
|
|
<article class="card stat">
|
|
<div class="stat-label">Subscription</div>
|
|
<div class="stat-value" id="plan-name">-</div>
|
|
<div class="stat-note" id="traffic-note">-</div>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="two-col">
|
|
<article class="card panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">Online IP List</div>
|
|
<div class="badge" id="refresh-badge">Auto Refresh</div>
|
|
</div>
|
|
<div class="list" id="ip-list"></div>
|
|
<div class="hint">
|
|
Current Xboard device state is counted by unique IP. If one device changes network, it may appear as a different online IP.
|
|
</div>
|
|
</article>
|
|
|
|
<article class="card panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">Active Sessions</div>
|
|
<div class="badge" id="session-badge">Checking</div>
|
|
</div>
|
|
<div class="list" id="session-list"></div>
|
|
<div class="actions">
|
|
<button class="btn" id="reload-btn" type="button">Reload</button>
|
|
<button class="btn secondary" id="home-btn" type="button">Back Home</button>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const ENDPOINTS = {
|
|
summary: '/api/v1/user-online-devices/summary',
|
|
info: '/api/v1/user/info',
|
|
subscribe: '/api/v1/user/getSubscribe'
|
|
};
|
|
const REFRESH_MS = 30000;
|
|
const TOKEN_KEYS = ['access_token', 'AccessToken', 'ACCESS_TOKEN'];
|
|
let lastAuthorization = '';
|
|
|
|
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 direct = normalizeToken(rawValue);
|
|
if (direct) return direct;
|
|
|
|
const trimmed = rawValue.trim();
|
|
if (trimmed[0] !== '{' && trimmed[0] !== '[') {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
return extractTokenFromParsedValue(JSON.parse(trimmed));
|
|
} catch (error) {
|
|
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 '-';
|
|
const date = new Date(timestamp * 1000);
|
|
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 formatTraffic(bytes) {
|
|
if (!bytes && bytes !== 0) return '-';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let value = Number(bytes);
|
|
let unitIndex = 0;
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value = value / 1024;
|
|
unitIndex += 1;
|
|
}
|
|
return value.toFixed(value >= 100 || unitIndex === 0 ? 0 : 2) + ' ' + units[unitIndex];
|
|
}
|
|
|
|
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 resetView() {
|
|
document.getElementById('user-email').textContent = '-';
|
|
document.getElementById('user-id').textContent = 'Please log in first';
|
|
document.getElementById('online-count').textContent = '-';
|
|
document.getElementById('last-online').textContent = '-';
|
|
document.getElementById('plan-name').textContent = '-';
|
|
document.getElementById('traffic-note').textContent = '-';
|
|
document.getElementById('refresh-badge').textContent = 'Auto Refresh';
|
|
document.getElementById('session-badge').textContent = 'Checking';
|
|
document.getElementById('ip-list').innerHTML = '<div class="empty">No online IP detected currently.</div>';
|
|
document.getElementById('session-list').innerHTML = '<div class="empty">No active session entries available.</div>';
|
|
}
|
|
|
|
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 renderInfo(info) {
|
|
document.getElementById('user-email').textContent = info.email || '-';
|
|
document.getElementById('user-id').textContent = info.email ? ('UUID: ' + (info.uuid || '-')) : 'Please log in first';
|
|
}
|
|
|
|
function renderSubscribe(subscribe) {
|
|
document.getElementById('plan-name').textContent = subscribe.plan && subscribe.plan.name ? subscribe.plan.name : 'No Plan';
|
|
const used = (subscribe.u || 0) + (subscribe.d || 0);
|
|
const total = subscribe.transfer_enable || 0;
|
|
document.getElementById('traffic-note').textContent = 'Used ' + formatTraffic(used) + ' / ' + formatTraffic(total);
|
|
}
|
|
|
|
function renderSummary(summary) {
|
|
document.getElementById('online-count').textContent = String(summary.user ? summary.user.online_count : 0);
|
|
document.getElementById('last-online').textContent = formatTime(summary.user ? summary.user.last_online_at : null);
|
|
document.getElementById('refresh-badge').textContent = 'Refresh every ' + ((summary.meta && summary.meta.refresh_interval_seconds) || 30) + 's';
|
|
document.getElementById('session-badge').textContent = summary.meta && summary.meta.show_active_sessions ? 'Enabled' : 'Disabled';
|
|
|
|
const ipList = document.getElementById('ip-list');
|
|
ipList.innerHTML = '';
|
|
const devices = Array.isArray(summary.online_devices) ? summary.online_devices : [];
|
|
if (!devices.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'empty';
|
|
empty.textContent = 'No online IP detected currently.';
|
|
ipList.appendChild(empty);
|
|
} else {
|
|
devices.forEach(function (device) {
|
|
const row = document.createElement('div');
|
|
row.className = 'row';
|
|
row.innerHTML = '<div class=\"row-title\">' + device.name + '</div><div class=\"row-meta\">IP: ' + device.ip + '</div>';
|
|
ipList.appendChild(row);
|
|
});
|
|
}
|
|
|
|
const sessionList = document.getElementById('session-list');
|
|
sessionList.innerHTML = '';
|
|
const sessions = Array.isArray(summary.active_sessions) ? summary.active_sessions : [];
|
|
if (!sessions.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'empty';
|
|
empty.textContent = summary.meta && summary.meta.show_active_sessions
|
|
? 'No active session entries available.'
|
|
: 'Session display is disabled by plugin config.';
|
|
sessionList.appendChild(empty);
|
|
} else {
|
|
sessions.forEach(function (session) {
|
|
const row = document.createElement('div');
|
|
row.className = 'row';
|
|
row.innerHTML =
|
|
'<div class=\"row-title\">' + (session.device || 'Unknown Session') + '</div>' +
|
|
'<div class=\"row-meta\">' +
|
|
'Session ID: ' + session.id + '<br>' +
|
|
'Last Used: ' + formatTime(session.last_used_at) + '<br>' +
|
|
'Created: ' + formatTime(session.created_at) + '<br>' +
|
|
'Expires: ' + formatTime(session.expires_at) +
|
|
'</div>';
|
|
sessionList.appendChild(row);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function loadPageData() {
|
|
const authorization = getAuthorization();
|
|
if (authorization !== lastAuthorization) {
|
|
resetView();
|
|
lastAuthorization = authorization;
|
|
}
|
|
|
|
if (!authorization) {
|
|
setError('No usable login token was found in Xboard browser storage. Please open the Xboard homepage first, complete login there, then refresh this page.');
|
|
return;
|
|
}
|
|
|
|
clearError();
|
|
|
|
try {
|
|
const results = await Promise.all([
|
|
getJson(ENDPOINTS.summary, authorization),
|
|
getJson(ENDPOINTS.info, authorization),
|
|
getJson(ENDPOINTS.subscribe, authorization)
|
|
]);
|
|
|
|
renderSummary(results[0]);
|
|
renderInfo(results[1]);
|
|
renderSubscribe(results[2]);
|
|
} catch (error) {
|
|
resetView();
|
|
setError('Failed to load your user status. Please confirm the plugin is enabled and your login is still valid.');
|
|
}
|
|
}
|
|
|
|
document.getElementById('reload-btn').addEventListener('click', function () {
|
|
loadPageData();
|
|
});
|
|
|
|
document.getElementById('home-btn').addEventListener('click', function () {
|
|
window.location.href = '/';
|
|
});
|
|
|
|
loadPageData();
|
|
window.setInterval(loadPageData, REFRESH_MS);
|
|
window.addEventListener('storage', function (event) {
|
|
if (!event.key || TOKEN_KEYS.indexOf(event.key) !== -1) {
|
|
loadPageData();
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|