hjjjj e4c1ff7900
Some checks failed
Deploy skills-market-server / deploy (push) Has been cancelled
feat(admin): 添加管理员面板和权限管理功能
新增管理员面板的静态文件支持,提供用户列表、角色切换和权限配置功能。更新了环境变量示例以包含管理员邮箱,并在auth.js中实现了管理员登录和权限审计日志功能。更新README文档以说明管理员面板的使用和相关接口。
2026-03-24 16:57:33 +08:00

658 lines
21 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>权限管理平台</title>
<style>
:root {
--bg: #f3f6ff;
--bg2: #eefcf6;
--card: #ffffff;
--text: #0f172a;
--muted: #64748b;
--line: #e2e8f0;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--secondary: #64748b;
--success: #059669;
--danger: #dc2626;
--warning: #d97706;
--fz-xs: 12px;
--fz-sm: 14px;
--fz-md: 15px;
--fz-lg: 18px;
--fz-xl: 24px;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
background: radial-gradient(circle at 20% 0%, #dbeafe 0%, transparent 35%),
radial-gradient(circle at 100% 20%, #dcfce7 0%, transparent 30%),
linear-gradient(145deg, var(--bg), var(--bg2));
color: var(--text);
min-height: 100vh;
font-size: var(--fz-md);
line-height: 1.55;
}
.container {
max-width: 1280px;
margin: 0 auto;
padding: 30px;
}
.container.auth-mode {
min-height: calc(100vh - 60px);
display: flex;
flex-direction: column;
justify-content: center;
}
.container.auth-mode .hero {
text-align: center;
}
.container.auth-mode #loginCard {
width: min(820px, 100%);
margin: 0 auto;
animation: login-card-enter 180ms ease-out;
}
.container.auth-mode #loginCard .row {
justify-content: center;
}
.hero {
margin-bottom: 18px;
}
.hero h2 {
margin: 0;
font-size: clamp(22px, 3vw, 28px);
letter-spacing: 0.2px;
}
.hero .sub {
margin-top: 6px;
color: var(--muted);
font-size: var(--fz-md);
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 5px 12px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.1);
color: var(--primary);
font-size: var(--fz-sm);
font-weight: 600;
}
.card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), #fff);
border: 1px solid rgba(226, 232, 240, 0.8);
border-radius: 16px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
padding: 20px 22px;
backdrop-filter: blur(5px);
}
.row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.panel-title {
margin: 0 0 10px;
font-size: var(--fz-lg);
}
input, button, select {
height: 42px;
border-radius: 12px;
border: 1px solid var(--line);
padding: 0 14px;
font-size: var(--fz-md);
outline: none;
transition: all 0.18s ease;
}
input, select {
background: #fff;
color: var(--text);
}
input:focus, select:focus {
border-color: #93c5fd;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13);
}
button {
cursor: pointer;
border: 1px solid transparent;
background: var(--primary);
color: #fff;
font-weight: 600;
}
button:hover {
background: var(--primary-hover);
}
button.secondary {
background: #fff;
border-color: var(--line);
color: #334155;
}
button.secondary:hover {
background: #f8fafc;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.search-input {
min-width: 320px;
}
#email { min-width: 300px; }
#code { width: 140px; }
.table-wrap {
overflow: auto;
border: 1px solid #e5e7eb;
border-radius: 12px;
margin-top: 10px;
background: #fff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 1050px;
font-size: var(--fz-md);
}
th, td {
border-bottom: 1px solid #f1f5f9;
padding: 12px 14px;
text-align: left;
vertical-align: top;
white-space: nowrap;
}
th {
background: #f8fafc;
color: #334155;
font-size: var(--fz-xs);
letter-spacing: 0.2px;
position: sticky;
top: 0;
z-index: 1;
}
tbody tr:hover { background: #f8fbff; }
.muted {
color: var(--muted);
font-size: var(--fz-xs);
}
.hidden { display: none; }
.mode-list {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.mode-list label {
padding: 6px 10px;
border-radius: 999px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: var(--fz-xs);
line-height: 1;
display: inline-flex;
align-items: center;
gap: 4px;
}
.danger {
color: var(--danger);
}
.role {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: var(--fz-xs);
font-weight: 700;
margin-bottom: 8px;
}
.role-user {
background: #ecfeff;
color: #0e7490;
border: 1px solid #bae6fd;
}
.role-admin {
background: #fef3c7;
color: #b45309;
border: 1px solid #fde68a;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin-top: 12px;
}
.stat {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
padding: 10px 12px;
}
.stat .k {
color: var(--muted);
font-size: var(--fz-sm);
}
.stat .v {
margin-top: 4px;
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
min-width: 220px;
max-width: 420px;
padding: 12px 14px;
border-radius: 10px;
color: #fff;
font-size: var(--fz-sm);
box-shadow: 0 10px 24px rgba(2, 6, 23, 0.2);
transform: translateY(10px);
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
.toast.info { background: #0f766e; }
.table-tip {
margin-top: 8px;
font-size: var(--fz-xs);
color: var(--muted);
}
.auth-note {
margin: 12px 0 0;
font-size: var(--fz-sm);
}
@keyframes login-card-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.container.auth-mode #loginCard {
animation: none;
}
}
@media (max-width: 960px) {
.container {
padding: 18px;
}
.container.auth-mode {
min-height: calc(100vh - 36px);
justify-content: center;
}
.card {
padding: 16px;
border-radius: 14px;
}
.search-input {
min-width: 240px;
flex: 1 1 auto;
}
#email {
min-width: 240px;
flex: 1 1 auto;
}
.row {
gap: 10px;
}
}
@media (max-width: 640px) {
.container {
padding: 12px;
}
.hero .sub {
font-size: var(--fz-sm);
}
input, button, select {
height: 40px;
font-size: var(--fz-sm);
}
#code {
width: 120px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<h2>权限管理平台</h2>
<div class="sub">管理员可在此配置用户模式、页面访问、SSH 与开发者模式权限。</div>
<div class="chip">Admin Console</div>
</div>
<div id="loginCard" class="card">
<h3 class="panel-title">管理员登录</h3>
<div class="row">
<input id="email" type="email" placeholder="管理员邮箱" />
<input id="code" type="text" placeholder="6位验证码" maxlength="6" />
<button id="sendCodeBtn" class="secondary">发送验证码</button>
<button id="loginBtn">登录</button>
</div>
<p class="muted auth-note">复用邮箱验证码登录;登录后仅 `role=admin` 可访问管理接口</p>
</div>
<div id="panel" class="card hidden" style="margin-top: 12px">
<div class="row">
<input id="query" class="search-input" type="text" placeholder="搜索邮箱或昵称" />
<button id="refreshBtn" class="secondary">搜索</button>
<button id="logoutBtn" class="secondary">退出</button>
</div>
<div class="stats">
<div class="stat">
<div class="k">用户数</div>
<div id="userCount" class="v">0</div>
</div>
<div class="stat">
<div class="k">可编辑用户</div>
<div id="editableCount" class="v">0</div>
</div>
<div class="stat">
<div class="k">审计记录</div>
<div id="auditCount" class="v">0</div>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>用户</th>
<th>角色</th>
<th>可用模式</th>
<th>技能页</th>
<th>智能体页</th>
<th>SSH 页</th>
<th>开发者模式</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="table-tip">提示:只有可编辑用户才可保存;未授权项会显示“未授权”。</div>
</div>
<div id="auditPanel" class="card hidden" style="margin-top: 12px">
<div class="row" style="justify-content: space-between">
<strong>权限变更审计日志</strong>
<button id="refreshAuditBtn" class="secondary">刷新日志</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>时间</th>
<th>操作人</th>
<th>目标用户</th>
<th>角色变更</th>
<th>模式变更</th>
</tr>
</thead>
<tbody id="auditTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="toast" class="toast info"></div>
<script>
const ALL_MODES = ['chat', 'clarify', 'cowork', 'video', 'code']
let token = localStorage.getItem('admin_token') || ''
const containerEl = document.querySelector('.container')
const loginCardEl = document.getElementById('loginCard')
const panelEl = document.getElementById('panel')
const auditPanelEl = document.getElementById('auditPanel')
let adminCapabilities = {
grantable_permissions: {
allowedModes: [...ALL_MODES],
canViewSkillsPage: true,
canViewAgentsPage: true,
canUseSshPage: true,
canUseDeveloperMode: true
}
}
function api(path, options = {}) {
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }
if (token) headers.Authorization = `Bearer ${token}`
return fetch(path, { ...options, headers }).then((r) => r.json())
}
let toastTimer = null
function notify(message, type = 'info') {
const toast = document.getElementById('toast')
if (!toast) return
toast.className = `toast ${type}`
toast.textContent = message
requestAnimationFrame(() => toast.classList.add('show'))
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => toast.classList.remove('show'), 1800)
}
function boolCell(name, checked, enabled) {
if (!enabled) return '<span class="muted">未授权</span>'
return `<input type="checkbox" data-perm="${name}" ${checked ? 'checked' : ''} />`
}
function renderAuditLogs(logs) {
const tbody = document.getElementById('auditTbody')
document.getElementById('auditCount').textContent = String(logs.length || 0)
tbody.innerHTML = logs
.map((log) => {
const beforeModes = (log.target_permissions_before?.allowedModes || []).join(', ')
const afterModes = (log.target_permissions_after?.allowedModes || []).join(', ')
const roleChanged = log.target_role_before !== log.target_role_after
const modeChanged = beforeModes !== afterModes
return `
<tr>
<td>${new Date(log.created_at).toLocaleString()}</td>
<td>
<div>${log.actor_nickname || '-'}</div>
<div class="muted">${log.actor_email || ''}</div>
</td>
<td>
<div>${log.target_nickname || '-'}</div>
<div class="muted">${log.target_email || ''}</div>
</td>
<td class="${roleChanged ? 'danger' : 'muted'}">${log.target_role_before} -> ${log.target_role_after}</td>
<td class="${modeChanged ? 'danger' : 'muted'}">${beforeModes || '-'} -> ${afterModes || '-'}</td>
</tr>
`
})
.join('')
}
function renderUsers(users) {
const tbody = document.getElementById('tbody')
document.getElementById('userCount').textContent = String(users.length || 0)
document.getElementById('editableCount').textContent = String(users.filter((u) => !!u.editable).length)
const grantable = adminCapabilities.grantable_permissions || {}
const grantableModes = Array.isArray(grantable.allowedModes) ? grantable.allowedModes : []
tbody.innerHTML = users
.map((u) => {
const p = u.permissions || {}
const allowedModes = Array.isArray(p.allowedModes) ? p.allowedModes : []
const modeChecks = ALL_MODES
.filter((m) => grantableModes.includes(m))
.map(
(m) =>
`<label><input type="checkbox" data-mode="${m}" ${allowedModes.includes(m) ? 'checked' : ''}/> ${m}</label>`
)
.join(' ')
const editable = !!u.editable
return `
<tr data-id="${u.id}">
<td>
<div>${u.nickname || '-'}</div>
<div class="muted">${u.email}</div>
</td>
<td>
<div class="role ${u.role === 'admin' ? 'role-admin' : 'role-user'}">${u.role}</div>
<select data-field="role" ${editable ? '' : 'disabled'}>
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
</select>
</td>
<td><div class="mode-list">${modeChecks || '<span class="muted">未授权</span>'}</div></td>
<td>${boolCell('canViewSkillsPage', !!p.canViewSkillsPage, !!grantable.canViewSkillsPage)}</td>
<td>${boolCell('canViewAgentsPage', !!p.canViewAgentsPage, !!grantable.canViewAgentsPage)}</td>
<td>${boolCell('canUseSshPage', !!p.canUseSshPage, !!grantable.canUseSshPage)}</td>
<td>${boolCell('canUseDeveloperMode', !!p.canUseDeveloperMode, !!grantable.canUseDeveloperMode)}</td>
<td>
<button data-action="save" ${editable ? '' : 'disabled'}>保存</button>
${editable ? '' : '<div class="muted">不可编辑</div>'}
</td>
</tr>
`
})
.join('')
Array.from(tbody.querySelectorAll('tr')).forEach((tr, idx) => {
const editable = !!users[idx]?.editable
if (!editable) {
tr.querySelectorAll('input[type="checkbox"]').forEach((el) => (el.disabled = true))
}
})
}
async function loadUsers() {
const query = document.getElementById('query').value.trim()
const data = await api(`/api/admin/users${query ? `?query=${encodeURIComponent(query)}` : ''}`)
if (!data.success) {
notify(data.error || '加载失败', 'error')
return
}
adminCapabilities = data.admin_capabilities || adminCapabilities
renderUsers(data.users || [])
}
async function loadAuditLogs() {
const data = await api('/api/admin/audit-logs?limit=50')
if (!data.success) {
notify(data.error || '日志加载失败', 'error')
return
}
renderAuditLogs(data.logs || [])
}
async function sendCode() {
const email = document.getElementById('email').value.trim()
if (!email) {
notify('请输入邮箱', 'error')
return
}
const data = await api('/api/auth/send-code', {
method: 'POST',
body: JSON.stringify({ email })
})
if (!data.success) {
notify(data.error || '发送验证码失败', 'error')
return
}
notify('验证码已发送', 'success')
}
async function login() {
const email = document.getElementById('email').value.trim()
const code = document.getElementById('code').value.trim()
const data = await api('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ email, code })
})
if (!data.success) {
notify(data.error || '登录失败', 'error')
return
}
token = data.token
localStorage.setItem('admin_token', token)
setLoggedInState(true)
await loadUsers()
await loadAuditLogs()
notify('登录成功', 'success')
}
function setLoggedInState(isLoggedIn) {
containerEl?.classList.toggle('auth-mode', !isLoggedIn)
loginCardEl?.classList.toggle('hidden', isLoggedIn)
panelEl?.classList.toggle('hidden', !isLoggedIn)
auditPanelEl?.classList.toggle('hidden', !isLoggedIn)
}
function readRowPayload(tr) {
const modeChecks = tr.querySelectorAll('input[data-mode]')
const allowedModes = Array.from(modeChecks)
.filter((el) => el.checked)
.map((el) => el.dataset.mode)
const getPerm = (name) =>
!!tr.querySelector(`input[data-perm="${name}"]`)?.checked
return {
role: tr.querySelector('select[data-field="role"]').value,
permissions: {
allowedModes,
canViewSkillsPage: getPerm('canViewSkillsPage'),
canViewAgentsPage: getPerm('canViewAgentsPage'),
canUseSshPage: getPerm('canUseSshPage'),
canUseDeveloperMode: getPerm('canUseDeveloperMode')
}
}
}
document.getElementById('sendCodeBtn').addEventListener('click', sendCode)
document.getElementById('loginBtn').addEventListener('click', login)
document.getElementById('refreshBtn').addEventListener('click', loadUsers)
document.getElementById('refreshAuditBtn').addEventListener('click', loadAuditLogs)
document.getElementById('logoutBtn').addEventListener('click', () => {
token = ''
localStorage.removeItem('admin_token')
setLoggedInState(false)
})
document.getElementById('tbody').addEventListener('click', async (e) => {
if (!(e.target instanceof HTMLButtonElement)) return
if (e.target.dataset.action !== 'save') return
const tr = e.target.closest('tr')
const id = tr?.dataset.id
if (!id || !tr) return
const payload = readRowPayload(tr)
const data = await api(`/api/admin/users/${id}/permissions`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
if (!data.success) {
notify(data.error || '保存失败', 'error')
return
}
notify('保存成功', 'success')
await loadUsers()
await loadAuditLogs()
})
document.getElementById('query').addEventListener('keydown', (e) => {
if (e.key === 'Enter') loadUsers()
})
if (token) {
setLoggedInState(true)
loadUsers()
loadAuditLogs()
} else {
setLoggedInState(false)
}
</script>
</body>
</html>