| {% extends 'base.html' %} |
|
|
| {% block content %} |
| <section class="page-grid admin-page-grid presence-admin-grid"> |
| <article class="glass-card form-panel"> |
| <div class="section-head"> |
| <div> |
| <p class="eyebrow">Create Admin</p> |
| <h3>新增管理员</h3> |
| </div> |
| </div> |
| <form method="post" action="/admin/admins" class="form-stack"> |
| <div class="form-grid cols-2"> |
| <label> |
| <span>登录账号</span> |
| <input type="text" name="username" required /> |
| </label> |
| <label> |
| <span>显示名称</span> |
| <input type="text" name="display_name" required /> |
| </label> |
| <label> |
| <span>登录密码</span> |
| <input type="text" name="password" required /> |
| </label> |
| <label> |
| <span>角色</span> |
| <select name="role"> |
| <option value="admin">管理员</option> |
| <option value="superadmin">超级管理员</option> |
| </select> |
| </label> |
| </div> |
| <button class="btn btn-primary" type="submit">创建管理员</button> |
| </form> |
| </article> |
|
|
| <article class="glass-card table-panel"> |
| <div class="section-head"> |
| <div> |
| <p class="eyebrow">Admins</p> |
| <h3>管理员列表</h3> |
| </div> |
| </div> |
| <div class="stack-list"> |
| {% for admin_item in admins %} |
| <div class="stack-item"> |
| <div> |
| <strong>{{ admin_item.display_name }}</strong> |
| <p class="muted">{{ admin_item.username }} · 创建于 {{ admin_item.created_at|datetime_local }}</p> |
| </div> |
| <span class="status-badge {% if admin_item.role == 'superadmin' %}status-approved{% endif %}"> |
| {{ '超级管理员' if admin_item.role == 'superadmin' else '管理员' }} |
| </span> |
| </div> |
| {% else %} |
| <p class="muted">暂无管理员记录。</p> |
| {% endfor %} |
| </div> |
| </article> |
| </section> |
|
|
| <section class="page-grid admin-page-grid presence-admin-grid"> |
| <article class="glass-card table-panel"> |
| <div class="section-head"> |
| <div> |
| <p class="eyebrow">Presence</p> |
| <h3>管理员在线状态</h3> |
| <p class="mini-note">在线心跳按 5 秒同步,连续 15 秒未收到心跳时判定为离线,状态文本按秒自动更新。</p> |
| </div> |
| </div> |
| <div class="stack-list" id="admin-presence-list"> |
| {% for item in admin_statuses %} |
| <div class="stack-item presence-item"> |
| <div> |
| <strong>{{ item.name }}</strong> |
| <p class="muted">{{ item.username }} · {{ item.role_label }}</p> |
| </div> |
| <span |
| class="status-badge {% if item.is_online %}status-approved{% else %}status-rejected{% endif %}" |
| data-presence-badge |
| data-last-seen-ts="{{ item.last_seen_ts or '' }}" |
| title="最近心跳 {{ item.last_seen_at }}" |
| > |
| {{ '在线' if item.is_online else '离线' }} · {{ item.last_seen_at }} |
| </span> |
| </div> |
| {% else %} |
| <p class="muted">暂无管理员在线状态。</p> |
| {% endfor %} |
| </div> |
| </article> |
|
|
| <article class="glass-card form-panel"> |
| <div class="section-head"> |
| <div> |
| <p class="eyebrow">Notice</p> |
| <h3>在线检测说明</h3> |
| </div> |
| </div> |
| <div class="info-box"> |
| <div class="stack-item stack-item-block"> |
| <strong>普通用户在线心跳已关闭</strong> |
| <p class="muted">为降低数据库连接占用与轮询压力,系统不再维护普通用户的实时在线状态,只保留管理员在线检测与审核分发所需的心跳。</p> |
| </div> |
| <div class="stack-item stack-item-block"> |
| <strong>当前策略</strong> |
| <p class="muted">管理员登录时会立即标记在线,之后仅由管理员端每 5 秒心跳续期;页面隐藏或离线时会自动暂停心跳,减少无效请求。</p> |
| </div> |
| </div> |
| </article> |
| </section> |
|
|
| <script> |
| (() => { |
| const adminList = document.getElementById('admin-presence-list'); |
| if (!adminList) return; |
| |
| let serverTs = Number('{{ presence_server_ts or 0 }}') || Math.floor(Date.now() / 1000); |
| let syncClientTs = Date.now(); |
| let onlineWindowSeconds = Number('{{ online_window_seconds or 15 }}') || 15; |
| let snapshotInFlight = false; |
| |
| const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({ |
| '&': '&', |
| '<': '<', |
| '>': '>', |
| '"': '"', |
| "'": ''' |
| }[char])); |
| |
| const estimatedNowTs = () => serverTs + Math.floor((Date.now() - syncClientTs) / 1000); |
| |
| const formatElapsed = (seconds) => { |
| if (seconds <= 1) return '刚刚'; |
| if (seconds < 60) return `${seconds}秒前`; |
| if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟前`; |
| if (seconds < 86400) { |
| const hours = Math.floor(seconds / 3600); |
| const minutes = Math.floor((seconds % 3600) / 60); |
| return minutes ? `${hours}小时${minutes}分钟前` : `${hours}小时前`; |
| } |
| const days = Math.floor(seconds / 86400); |
| const hours = Math.floor((seconds % 86400) / 3600); |
| return hours ? `${days}天${hours}小时前` : `${days}天前`; |
| }; |
| |
| const getStatusMeta = (lastSeenTs) => { |
| if (!lastSeenTs) { |
| return { |
| online: false, |
| text: '离线 · 暂无心跳', |
| }; |
| } |
| const elapsed = Math.max(0, estimatedNowTs() - lastSeenTs); |
| const online = elapsed <= onlineWindowSeconds; |
| return { |
| online, |
| text: `${online ? '在线' : '离线'} · ${formatElapsed(elapsed)}`, |
| }; |
| }; |
| |
| const renderAdminItems = (items) => { |
| if (!items.length) { |
| adminList.innerHTML = '<p class="muted">暂无管理员在线状态。</p>'; |
| return; |
| } |
| adminList.innerHTML = items.map((item) => { |
| const status = getStatusMeta(Number(item.last_seen_ts || 0)); |
| return ` |
| <div class="stack-item presence-item"> |
| <div> |
| <strong>${escapeHtml(item.name)}</strong> |
| <p class="muted">${escapeHtml(item.username)} · ${escapeHtml(item.role_label)}</p> |
| </div> |
| <span |
| class="status-badge ${status.online ? 'status-approved' : 'status-rejected'}" |
| data-presence-badge |
| data-last-seen-ts="${item.last_seen_ts || ''}" |
| title="最近心跳 ${escapeHtml(item.last_seen_at || '-') }" |
| > |
| ${status.text} |
| </span> |
| </div>`; |
| }).join(''); |
| }; |
| |
| const refreshPresenceBadges = () => { |
| if (document.hidden) return; |
| document.querySelectorAll('[data-presence-badge]').forEach((badge) => { |
| const lastSeenTs = Number(badge.dataset.lastSeenTs || 0); |
| const status = getStatusMeta(lastSeenTs); |
| badge.textContent = status.text; |
| badge.classList.toggle('status-approved', status.online); |
| badge.classList.toggle('status-rejected', !status.online); |
| }); |
| }; |
| |
| const refreshPresence = async () => { |
| if (document.hidden || snapshotInFlight) return; |
| snapshotInFlight = true; |
| try { |
| const response = await fetch('/api/admin/presence/overview', { |
| credentials: 'same-origin', |
| cache: 'no-store', |
| headers: { 'X-Requested-With': 'fetch' }, |
| }); |
| if (!response.ok) return; |
| const payload = await response.json(); |
| serverTs = Number(payload.server_ts || 0) || Math.floor(Date.now() / 1000); |
| syncClientTs = Date.now(); |
| onlineWindowSeconds = Number(payload.online_window_seconds || 0) || onlineWindowSeconds; |
| renderAdminItems(payload.admins || []); |
| refreshPresenceBadges(); |
| } catch (error) { |
| console.debug('presence refresh skipped', error); |
| } finally { |
| snapshotInFlight = false; |
| } |
| }; |
| |
| refreshPresenceBadges(); |
| refreshPresence(); |
| window.setInterval(refreshPresenceBadges, 1000); |
| window.setInterval(refreshPresence, 5000); |
| document.addEventListener('visibilitychange', () => { |
| if (!document.hidden) { |
| refreshPresence(); |
| } |
| }); |
| window.addEventListener('focus', refreshPresence); |
| })(); |
| </script> |
| {% endblock %}
|
|
|