File size: 8,821 Bytes
ce0719e 02a8414 ce0719e 02a8414 3d6b7f2 02a8414 3d6b7f2 02a8414 3d6b7f2 02a8414 cf289c1 02a8414 cf289c1 02a8414 cf289c1 02a8414 cf289c1 02a8414 3d6b7f2 02a8414 3d6b7f2 02a8414 3d6b7f2 02a8414 3d6b7f2 02a8414 3d6b7f2 02a8414 3d6b7f2 02a8414 3d6b7f2 02a8414 3d6b7f2 02a8414 cf289c1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | {% 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 %}
|