2026-09-13 16:18:10 +08:00
|
|
|
|
<!doctype html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
|
|
<title>管理台</title>
|
|
|
|
|
|
<link rel="icon" href="/logo.svg" />
|
|
|
|
|
|
<link rel="stylesheet" href="/design.css" />
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body class="auth-bg">
|
|
|
|
|
|
<script>
|
|
|
|
|
|
/* 会话失效统一跳登录页:避免页面直接吐 {"error":"unauthorized"} 却停在原地。
|
|
|
|
|
|
登录页/注册页自身不装,否则密码错误时会被弹走。 */
|
|
|
|
|
|
;(function () {
|
|
|
|
|
|
var p = location.pathname
|
|
|
|
|
|
if (p === '/' || p === '/index.html' || p === '/login.html' || p === '/register.html') return
|
|
|
|
|
|
var orig = window.fetch
|
|
|
|
|
|
window.fetch = function () {
|
|
|
|
|
|
return orig.apply(this, arguments).then(function (res) {
|
|
|
|
|
|
if (res && res.status === 401) location.href = '/login.html'
|
|
|
|
|
|
return res
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="auth-card" style="max-width: 720px">
|
|
|
|
|
|
<div class="auth-brand">
|
|
|
|
|
|
<img class="logo" src="/logo.svg" alt="logo" />
|
|
|
|
|
|
<span class="wordmark"><svg viewBox="0 0 131 24" fill="currentColor" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M117.986 0h-3.21v19.404h3.21V0zM8.7 5.215h1.83v2.838H8.7c-1.135 0-2.28.282-3.019 1.068-.738.785-1.016 1.99-1.016 3.194s.267 2.408 1.016 3.193c.75.786 1.884 1.068 3.018 1.068 1.135 0 2.28-.282 3.018-1.068.739-.785 1.017-1.99 1.017-3.193V.649h3.21v18.755h-3.21V18.21h-.589a3.498 3.498 0 01-.192.199c-.803.733-2.034.995-3.243.995-1.894 0-3.788-.472-5.03-1.78C2.44 16.314 2 14.303 2 12.303c0-2 .45-4 1.68-5.32 1.242-1.308 3.136-1.77 5.02-1.77zM57.564 18.9h-1.83v-2.837h1.83c1.134 0 2.28-.283 3.017-1.068.739-.785 1.017-1.99 1.017-3.194s-.267-2.408-1.017-3.194c-.749-.785-1.883-1.068-3.017-1.068s-2.28.283-3.018 1.068c-.738.786-1.017 1.99-1.017 3.194v11.655h-3.21V4.712h3.21v1.194h.59a3.42 3.42 0 01.186-.194l.005-.005c.803-.733 2.034-.995 3.243-.995 1.895 0 3.788.471 5.03 1.78 1.241 1.31 1.68 3.32 1.68 5.32 0 2-.45 4-1.68 5.319-1.23 1.32-3.135 1.77-5.019 1.77zM32.05 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.264-1.843-5.223-1.843-1.958 0-3.928.492-5.222 1.843-1.295 1.35-1.755 3.434-1.755 5.497 0 2.063.47 4.136 1.755 5.498 1.284 1.36 3.264 1.843 5.222 1.843 1.959 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168a5.026 5.026 0 01-.3.367c-.77.816-1.958 1.11-3.135 1.11-1.177 0-2.365-.304-3.136-1.11-.77-.807-1.048-2.063-1.048-3.299 0-1.236.278-2.482 1.049-3.298.77-.817 1.958-1.11 3.135-1.11 1.177 0 2.365.293 3.136 1.11.535.565.834 1.34.963 2.167H23.5v2.272h8.55zM48.168 12.063v1.141h-8.55v-2.272h5.671c-.129-.827-.428-1.602-.963-2.167-.77-.817-1.959-1.11-3.136-1.11s-2.365.293-3.136 1.11c-.77.816-1.049 2.063-1.049 3.298 0 1.236.279 2.492 1.05 3.299.77.806 1.958 1.11 3.135 1.11 1.177 0 2.365-.294 3.136-1.11.107-.116.203-.241.299-.367h3.168c-.278.985-.717 1.874-1.38 2.566-1.285 1.35-3.264 1.843-5.223 1.843s-3.938-.482-5.222-1.843c-1.285-1.362-1.756-3.435-1.756-5.498s.46-4.147 1.755-5.497c1.296-1.351 3.264-1.843 5.223-1.843s3.938.481 5.222 1.843c1.296 1.36 1.756 3.434 1.756 5.497zM78.635 18.315c-1.284.806-3.263 1.089-5.222 1.089-1.958 0-3.917-.294-5.212-1.09-1.295-.795-1.755-2.03-1.755-3.246h3.767c0 .472.225.953.824 1.257.6.304 1.54.419 2.462.419.92 0 1.851-.115 2.46-.42.611-.303.825-.784.825-1.256 0-.47-.214-.952-.824-1.256-.61-.304-1.627-.419-2.547-.419-1.777 0-3.563-.293-4.73-1.09-1.167-.795-1.584-2.03-1.584-3.245 0-1.215.417-2.44 1.584-3.246 1.167-.807 2.953-1.09 4.73-1.09 1.776 0 3.564.294 4.73 1.09 1.167.795 1.584 2.031 1.584 3.246h-3.264c0-.471-.203-.942-.749-1.257-.546-.303-1.391-.419-2.226-.419s-1.68.105-2.226.42c-.556.303-.75.785-.75 1.256 0 .47.204.942.75 1.256.545.304 1.316.42 2.151.42 1.959 0 3.938.292 5.222 1.088 1.295.796 1.756 2.032 1.756 3.246 0 1.215-.471 2.44-1.756 3.247zM96.507 12.063v1.141h-8.55v-2.272h5.672c-.129-.827-.429-1.602-.963-2.167-.771-.817-1.959-1.11-3.136-1.11s-2.366.293-3.136 1.11c-.77.816-1.048 2.063-1.048 3.298 0 1.236.278 2.492 1.048 3.299.77.806 1.959 1.11 3.135 1.11 1.178 0 2.366-.294 3.137-1.11.106-.116.203-.241.3-.367h3.167c-.279.985-.717 1.874-1.38 2.566-1.284 1.35-3.265 1.843-5.224 1.843-1.957 0-3.938-.482-5.222-1.843-1.284-1.362-1.754-3.435-1.754-5.498s.46-4.147 1.754-5.497c1.296-1.351 3.265-1.843 5.222-1.843 1.96 0 3.94.481 5.224 1.843 1.294 1.36 1.754 3.434 1.754 5.497zM112.624 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.265-1.843-5.223-1.843-1.959 0-3.928.492-5.222 1.843-1.296 1.35-1.756 3.434-1.756 5.497 0 2.063.471 4.136 1.756 5.498 1.284 1.36 3.263 1.843 5.222 1.843 1.958 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168l-.012.016c-.093.12-.185.24-.288.35-.77.817-1.957 1.11-3.135 1.11-1.177 0-2.365-.303-3.136-1.11-.77-.806-1.049-2.062-1.049-3.298 0-1.236.279-2.482 1.049-3.298.771-.817 1.959-1.11 3.136-1.11 1.178 0 2.365.293 3.135 1.11.536.565.836 1.34.964 2.167h-5.672v2.272h8.55zM128.73 19.404l-5.264-7.78 5.264-6.252h-3.97l-5.265 6.251 5.265 7.78h3.97z"/></svg></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="auth-sub" id="sub">仅限管理员</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="login" class="hidden">
|
|
|
|
|
|
<div class="field"><label>用户名</label><input id="username" autocomplete="username" /></div>
|
|
|
|
|
|
<div class="field"><label>密码</label><input id="password" type="password" autocomplete="current-password" /></div>
|
|
|
|
|
|
<button class="btn primary" id="loginBtn">登录</button>
|
|
|
|
|
|
<p class="msg err" id="loginMsg"></p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="console" class="hidden">
|
|
|
|
|
|
<div class="row-actions" style="justify-content:space-between">
|
2026-09-13 19:27:30 +08:00
|
|
|
|
<span>登录为 <strong id="who"></strong> <a href="/portal.html#/skills" style="margin-left:10px">技能管理</a></span>
|
2026-09-13 16:18:10 +08:00
|
|
|
|
<button class="btn ghost small" id="logoutBtn">退出</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<table class="admin">
|
|
|
|
|
|
<thead><tr><th>用户名</th><th>角色</th><th>注册时间</th><th>操作</th></tr></thead>
|
|
|
|
|
|
<tbody id="users"></tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
|
|
<h3 style="margin:22px 0 8px">存储用量 <span class="auth-sub" id="storageAt" style="font-weight:400"></span></h3>
|
|
|
|
|
|
<table class="admin">
|
|
|
|
|
|
<thead><tr><th>用户</th><th>工作区</th><th>会话</th><th>回收站</th><th>可清理</th></tr></thead>
|
|
|
|
|
|
<tbody id="storage"></tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
<p class="auth-sub" style="margin:6px 0 0">工作区达 <span id="thWs"></span> / 会话达 <span id="thSess"></span> 时由每日维护任务自动清理(工作区 90 天、会话 365 天前的记录;均先入回收站保留 30 天)。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
const badge = { admin: '管理员', pending: '待审核', active: '正常', disabled: '已禁用' }
|
|
|
|
|
|
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) }
|
|
|
|
|
|
async function me() {
|
|
|
|
|
|
const res = await fetch('/api/auth/me')
|
|
|
|
|
|
return res.ok ? (await res.json()).user : null
|
|
|
|
|
|
}
|
|
|
|
|
|
async function loadUsers() {
|
|
|
|
|
|
const { users } = await (await fetch('/api/admin/users')).json()
|
|
|
|
|
|
const tbody = document.getElementById('users')
|
|
|
|
|
|
tbody.innerHTML = ''
|
|
|
|
|
|
for (const u of users) {
|
|
|
|
|
|
const actions = u.role === 'pending'
|
|
|
|
|
|
? `<button class="btn small ghost" data-act="approve" data-id="${u.id}">通过</button>`
|
|
|
|
|
|
: u.role === 'active' ? `<button class="btn small danger" data-act="disable" data-id="${u.id}">禁用</button>`
|
|
|
|
|
|
: u.role === 'disabled' ? `<button class="btn small ghost" data-act="enable" data-id="${u.id}">恢复</button>` : ''
|
|
|
|
|
|
const del = u.role !== 'admin'
|
|
|
|
|
|
? `<button class="btn small ghost" style="color:var(--danger)" data-act="del" data-id="${u.id}" data-name="${esc(u.username)}" title="删除用户">🗑</button>`
|
|
|
|
|
|
: ''
|
|
|
|
|
|
tbody.insertAdjacentHTML('beforeend', `<tr><td>${esc(u.username)}</td><td><span class="badge ${u.role}">${badge[u.role] || u.role}</span></td><td>${new Date(u.createdAt).toLocaleString()}</td><td>${actions}${actions && del ? ' ' : ''}${del}</td></tr>`)
|
|
|
|
|
|
}
|
|
|
|
|
|
tbody.onclick = async (event) => {
|
|
|
|
|
|
const btn = event.target.closest('button[data-act]')
|
|
|
|
|
|
if (!btn) return
|
|
|
|
|
|
const { act, id } = btn.dataset
|
|
|
|
|
|
if (act === 'del') {
|
|
|
|
|
|
const name = prompt(`删除后不可恢复。输入用户名「${btn.dataset.name}」以确认:`)
|
|
|
|
|
|
if (name !== btn.dataset.name) { alert('用户名不匹配,已取消'); return }
|
|
|
|
|
|
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' })
|
|
|
|
|
|
if (res.ok) await loadUsers()
|
|
|
|
|
|
else alert('删除失败(admin 账号不可删除)')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const res = await fetch(`/api/admin/users/${id}/${act}`, { method: 'POST' })
|
|
|
|
|
|
if (res.ok) await loadUsers()
|
|
|
|
|
|
else alert('操作失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
async function init() {
|
|
|
|
|
|
const u = await me()
|
|
|
|
|
|
if (u && u.role === 'admin') {
|
|
|
|
|
|
document.getElementById('login').classList.add('hidden')
|
|
|
|
|
|
document.getElementById('console').classList.remove('hidden')
|
|
|
|
|
|
document.getElementById('who').textContent = u.username
|
|
|
|
|
|
await loadUsers()
|
|
|
|
|
|
await loadStorage()
|
|
|
|
|
|
} else if (u) {
|
|
|
|
|
|
document.getElementById('sub').textContent = '该账号不是管理员'
|
|
|
|
|
|
} else {
|
|
|
|
|
|
document.getElementById('login').classList.remove('hidden')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById('loginBtn').addEventListener('click', async () => {
|
|
|
|
|
|
const res = await fetch('/api/auth/login', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'content-type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ username: document.getElementById('username').value, password: document.getElementById('password').value }),
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!res.ok) { document.getElementById('loginMsg').textContent = '用户名或密码错误'; return }
|
|
|
|
|
|
const { user } = await res.json()
|
|
|
|
|
|
if (user.role !== 'admin') { document.getElementById('loginMsg').textContent = '该账号不是管理员'; return }
|
|
|
|
|
|
await init()
|
|
|
|
|
|
})
|
|
|
|
|
|
function fmtB(b) { return b >= 1073741824 ? (b / 1073741824).toFixed(2) + ' GB' : (b / 1048576).toFixed(1) + ' MB' }
|
|
|
|
|
|
async function loadStorage() {
|
|
|
|
|
|
const tbody = document.getElementById('storage')
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await (await fetch('/api/admin/storage')).json()
|
|
|
|
|
|
document.getElementById('storageAt').textContent = r.generatedAt ? `(更新于 ${new Date(r.generatedAt).toLocaleString()})` : ''
|
|
|
|
|
|
if (r.thresholds) {
|
|
|
|
|
|
document.getElementById('thWs').textContent = r.thresholds.wsMB + ' MB'
|
|
|
|
|
|
document.getElementById('thSess').textContent = r.thresholds.sessionsMB + ' MB'
|
|
|
|
|
|
}
|
|
|
|
|
|
tbody.innerHTML = (r.users ?? []).map((u) => `<tr>
|
|
|
|
|
|
<td>${esc(u.username)}</td>
|
|
|
|
|
|
<td style="${u.wsOver ? 'color:var(--danger)' : ''}">${fmtB(u.ws)}</td>
|
|
|
|
|
|
<td style="${u.sessionsOver ? 'color:var(--danger)' : ''}">${fmtB(u.sessions)}</td>
|
|
|
|
|
|
<td>${fmtB(u.trash)}</td>
|
|
|
|
|
|
<td>${u.cleanableT1 + u.cleanableT2} 项 / ${fmtB(u.cleanableBytes)}</td>
|
|
|
|
|
|
</tr>`).join('') || `<tr><td colspan="5" class="auth-sub">${esc(r.note ?? '暂无数据')}</td></tr>`
|
|
|
|
|
|
} catch { tbody.innerHTML = '<tr><td colspan="5" class="auth-sub">读取失败</td></tr>' }
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById('logoutBtn').addEventListener('click', async () => { await fetch('/api/auth/logout', { method: 'POST' }); location.reload() })
|
|
|
|
|
|
init()
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|