Some checks failed
Deploy skills-market-server / deploy (push) Has been cancelled
- Added middleware to handle invalid JSON body errors, returning a 400 status with a descriptive message. - Introduced functions to normalize and filter mode tags for user permissions, improving access control for skills based on user roles. - Updated API endpoints to incorporate mode tag filtering, ensuring users can only access skills they are permitted to view. - Implemented a new endpoint for updating skill tags, with permission checks for non-admin users. - Enhanced the frontend with updated styles and layout adjustments for better user experience.
1028 lines
34 KiB
HTML
1028 lines
34 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: #f8fafc;
|
|
--bg2: #eff6ff;
|
|
--card: #ffffff;
|
|
--text: #0f172a;
|
|
--muted: #64748b;
|
|
--line: #dbe3ef;
|
|
--primary: #2563eb;
|
|
--primary-hover: #1d4ed8;
|
|
--secondary: #64748b;
|
|
--success: #059669;
|
|
--danger: #dc2626;
|
|
--warning: #d97706;
|
|
--ring: rgba(37, 99, 235, 0.18);
|
|
--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 12% 0%, #dbeafe 0%, transparent 32%),
|
|
radial-gradient(circle at 100% 10%, #d1fae5 0%, transparent 26%),
|
|
linear-gradient(155deg, var(--bg), var(--bg2));
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
font-size: var(--fz-md);
|
|
line-height: 1.55;
|
|
}
|
|
.container {
|
|
max-width: 1360px;
|
|
margin: 0 auto;
|
|
padding: 26px;
|
|
}
|
|
.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: 14px;
|
|
padding: 16px 18px;
|
|
border: 1px solid rgba(226, 232, 240, 0.9);
|
|
border-radius: 16px;
|
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.72));
|
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
.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: 9px;
|
|
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.97), #fff);
|
|
border: 1px solid rgba(226, 232, 240, 0.8);
|
|
border-radius: 18px;
|
|
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
|
|
padding: 18px 20px;
|
|
backdrop-filter: blur(5px);
|
|
}
|
|
.section-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.section-title {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.2px;
|
|
}
|
|
.section-sub {
|
|
margin: 4px 0 0;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
.row {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.panel-title {
|
|
margin: 0 0 10px;
|
|
font-size: var(--fz-lg);
|
|
}
|
|
input:not([type="checkbox"]), button, select {
|
|
height: 40px;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--line);
|
|
padding: 0 13px;
|
|
font-size: var(--fz-md);
|
|
outline: none;
|
|
transition: all 0.18s ease;
|
|
}
|
|
input:not([type="checkbox"]), select {
|
|
background: #fff;
|
|
color: var(--text);
|
|
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08);
|
|
}
|
|
input:not([type="checkbox"]):focus, select:focus {
|
|
border-color: #93c5fd;
|
|
box-shadow: 0 0 0 3px var(--ring);
|
|
}
|
|
button {
|
|
cursor: pointer;
|
|
border: 1px solid transparent;
|
|
background: linear-gradient(180deg, #3b82f6, var(--primary));
|
|
color: #fff;
|
|
font-weight: 600;
|
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.24);
|
|
}
|
|
button:hover {
|
|
background: linear-gradient(180deg, #2563eb, var(--primary-hover));
|
|
}
|
|
button.secondary {
|
|
background: #fff;
|
|
border-color: var(--line);
|
|
color: #334155;
|
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
}
|
|
button.secondary:hover {
|
|
background: #f8fbff;
|
|
}
|
|
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 #dbe4f0;
|
|
border-radius: 14px;
|
|
margin-top: 10px;
|
|
background: #fff;
|
|
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08);
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
min-width: 1120px;
|
|
font-size: 14px;
|
|
}
|
|
th, td {
|
|
border-bottom: 1px solid #edf2f7;
|
|
padding: 12px 12px;
|
|
text-align: left;
|
|
vertical-align: top;
|
|
white-space: nowrap;
|
|
}
|
|
th {
|
|
background: linear-gradient(180deg, #f8fafc, #f3f7fd);
|
|
color: #334155;
|
|
font-size: var(--fz-xs);
|
|
letter-spacing: 0.2px;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
tbody tr:nth-child(2n) {
|
|
background: #fcfdff;
|
|
}
|
|
tbody tr:hover {
|
|
background: #f4f9ff;
|
|
}
|
|
.muted {
|
|
color: var(--muted);
|
|
font-size: var(--fz-xs);
|
|
}
|
|
.hidden { display: none; }
|
|
.mode-picker {
|
|
min-width: 210px;
|
|
width: 226px;
|
|
}
|
|
.mode-picker-trigger {
|
|
width: 100%;
|
|
height: auto;
|
|
min-height: 42px;
|
|
padding: 8px 10px;
|
|
border: 1px solid #d7e0ee;
|
|
border-radius: 11px;
|
|
background: linear-gradient(180deg, #ffffff, #f8fbff);
|
|
color: #1f2937;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
box-shadow: 0 2px 7px rgba(15, 23, 42, 0.06);
|
|
}
|
|
.mode-picker-trigger:hover {
|
|
border-color: #c6d4ea;
|
|
background: #f8fbff;
|
|
}
|
|
.mode-picker-trigger:focus-visible {
|
|
outline: none;
|
|
border-color: #93c5fd;
|
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13);
|
|
}
|
|
.mode-picker-title {
|
|
font-size: 11px;
|
|
color: #64748b;
|
|
letter-spacing: 0.2px;
|
|
}
|
|
.mode-picker-value {
|
|
flex: 1;
|
|
text-align: right;
|
|
font-size: 12px;
|
|
color: #1e293b;
|
|
font-weight: 600;
|
|
line-height: 1.35;
|
|
}
|
|
.mode-picker-caret {
|
|
color: #94a3b8;
|
|
font-size: 12px;
|
|
}
|
|
.mode-hidden-inputs {
|
|
display: none;
|
|
}
|
|
.mode-popover {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(15, 23, 42, 0.12);
|
|
z-index: 999;
|
|
display: none;
|
|
opacity: 0;
|
|
transition: opacity 130ms ease-out;
|
|
backdrop-filter: blur(1px);
|
|
}
|
|
.mode-popover.show {
|
|
display: block;
|
|
opacity: 1;
|
|
}
|
|
.mode-popover-card {
|
|
position: fixed;
|
|
width: 276px;
|
|
border: 1px solid #dbe4f0;
|
|
border-radius: 12px;
|
|
background: linear-gradient(180deg, #fff, #fafdff);
|
|
padding: 10px;
|
|
display: grid;
|
|
gap: 8px;
|
|
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.16);
|
|
transform: translateY(6px) scale(0.985);
|
|
transform-origin: top left;
|
|
opacity: 0;
|
|
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 150ms ease-out;
|
|
will-change: transform, opacity;
|
|
}
|
|
.mode-popover-card[data-side="top"] {
|
|
transform: translateY(-6px) scale(0.985);
|
|
transform-origin: bottom left;
|
|
}
|
|
.mode-popover.show .mode-popover-card {
|
|
transform: translateY(0) scale(1);
|
|
opacity: 1;
|
|
}
|
|
.mode-popover-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 2px 2px 4px;
|
|
font-size: 12px;
|
|
color: #475569;
|
|
font-weight: 600;
|
|
}
|
|
.mode-popover-options {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 6px;
|
|
}
|
|
.mode-popover-options label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
color: #334155;
|
|
padding: 8px 8px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
background: #f8fafc;
|
|
cursor: pointer;
|
|
transition: border-color 140ms ease, background 140ms ease;
|
|
}
|
|
.mode-popover-options label:hover {
|
|
border-color: #cbd5e1;
|
|
background: #f1f5f9;
|
|
}
|
|
.mode-popover-options input[type="checkbox"] {
|
|
width: 14px;
|
|
height: 14px;
|
|
accent-color: #2563eb;
|
|
}
|
|
.user-name {
|
|
font-weight: 600;
|
|
color: #0f172a;
|
|
}
|
|
.perm-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
.perm-toggle input[type="checkbox"] {
|
|
appearance: none;
|
|
width: 34px;
|
|
height: 20px;
|
|
border-radius: 999px;
|
|
border: 1px solid #cbd5e1;
|
|
background: #e2e8f0;
|
|
position: relative;
|
|
cursor: pointer;
|
|
transition: all 150ms ease;
|
|
margin: 0;
|
|
}
|
|
.perm-toggle input[type="checkbox"]::after {
|
|
content: '';
|
|
position: absolute;
|
|
width: 14px;
|
|
height: 14px;
|
|
top: 2px;
|
|
left: 2px;
|
|
border-radius: 999px;
|
|
background: #fff;
|
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
|
|
transition: transform 150ms ease;
|
|
}
|
|
.perm-toggle input[type="checkbox"]:checked {
|
|
border-color: #3b82f6;
|
|
background: #3b82f6;
|
|
}
|
|
.perm-toggle input[type="checkbox"]:checked::after {
|
|
transform: translateX(14px);
|
|
}
|
|
.perm-toggle input[type="checkbox"]:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
button[data-action="save"] {
|
|
min-width: 66px;
|
|
height: 36px;
|
|
border-radius: 10px;
|
|
font-size: 13px;
|
|
padding: 0 12px;
|
|
}
|
|
.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(170px, 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: 16px;
|
|
}
|
|
.container.auth-mode {
|
|
min-height: calc(100vh - 36px);
|
|
justify-content: center;
|
|
}
|
|
.card {
|
|
padding: 15px;
|
|
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: 10px;
|
|
}
|
|
.hero .sub {
|
|
font-size: var(--fz-sm);
|
|
}
|
|
input:not([type="checkbox"]), button, select {
|
|
height: 38px;
|
|
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="section-head">
|
|
<div>
|
|
<h3 class="section-title">用户权限配置</h3>
|
|
<p class="section-sub">按用户粒度设置角色、模式访问范围与功能页权限</p>
|
|
</div>
|
|
</div>
|
|
<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="section-head">
|
|
<div>
|
|
<h3 class="section-title">权限变更审计日志</h3>
|
|
<p class="section-sub">最近 50 条权限调整记录,便于追踪治理行为</p>
|
|
</div>
|
|
<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>
|
|
<div id="modePopover" class="mode-popover hidden" aria-hidden="true">
|
|
<div id="modePopoverCard" class="mode-popover-card">
|
|
<div class="mode-popover-header">
|
|
<span>可用模式</span>
|
|
<button id="modePopoverClose" class="secondary" style="height: 28px; padding: 0 10px">完成</button>
|
|
</div>
|
|
<div id="modePopoverOptions" class="mode-popover-options"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const ALL_MODES = ['chat', 'clarify', 'cowork', 'create', 'video', 'code']
|
|
const MODE_LABELS = {
|
|
chat: '对话',
|
|
clarify: '澄清',
|
|
cowork: '协作',
|
|
create: '创作',
|
|
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
|
|
}
|
|
}
|
|
const modePopover = document.getElementById('modePopover')
|
|
const modePopoverCard = document.getElementById('modePopoverCard')
|
|
const modePopoverOptions = document.getElementById('modePopoverOptions')
|
|
const modePopoverClose = document.getElementById('modePopoverClose')
|
|
let activeModePicker = null
|
|
let modePopoverHideTimer = null
|
|
|
|
function summarizeSelectedModes(selected, available) {
|
|
if (!available || available.length === 0) return '未授权'
|
|
if (selected.length === 0) return '未选择'
|
|
if (selected.length === available.length) return `全部(${selected.length})`
|
|
const labels = selected.map((m) => MODE_LABELS[m] || m)
|
|
if (labels.length <= 2) return labels.join(' / ')
|
|
return `${labels.slice(0, 2).join(' / ')} +${labels.length - 2}`
|
|
}
|
|
|
|
function setModePopoverVisible(visible) {
|
|
if (!modePopover) return
|
|
if (modePopoverHideTimer) {
|
|
clearTimeout(modePopoverHideTimer)
|
|
modePopoverHideTimer = null
|
|
}
|
|
if (visible) {
|
|
modePopover.classList.remove('hidden')
|
|
modePopover.classList.add('show')
|
|
modePopover.setAttribute('aria-hidden', 'false')
|
|
return
|
|
}
|
|
modePopover.classList.remove('show')
|
|
modePopover.setAttribute('aria-hidden', 'true')
|
|
modePopoverHideTimer = setTimeout(() => {
|
|
modePopover.classList.add('hidden')
|
|
}, 180)
|
|
}
|
|
|
|
function closeModePopover() {
|
|
setModePopoverVisible(false)
|
|
activeModePicker = null
|
|
}
|
|
|
|
function positionModePopover(triggerEl) {
|
|
if (!triggerEl || !modePopoverCard) return
|
|
const rect = triggerEl.getBoundingClientRect()
|
|
const cardWidth = 276
|
|
const cardHeight = 208
|
|
const spacing = 8
|
|
let left = Math.max(8, Math.min(rect.left, window.innerWidth - cardWidth - 8))
|
|
const spaceBelow = window.innerHeight - rect.bottom
|
|
const openTop = spaceBelow < cardHeight + spacing && rect.top > cardHeight + spacing
|
|
let top = openTop ? rect.top - cardHeight - spacing : rect.bottom + spacing
|
|
top = Math.max(8, Math.min(top, window.innerHeight - cardHeight - 8))
|
|
modePopoverCard.style.left = `${left}px`
|
|
modePopoverCard.style.top = `${top}px`
|
|
modePopoverCard.setAttribute('data-side', openTop ? 'top' : 'bottom')
|
|
}
|
|
|
|
function openModePopover(triggerEl, rowEl, availableModes) {
|
|
if (!modePopoverOptions || !rowEl) return
|
|
activeModePicker = { triggerEl, rowEl, availableModes }
|
|
const selectedModes = Array.from(rowEl.querySelectorAll('input[data-mode]'))
|
|
.filter((el) => el.checked)
|
|
.map((el) => el.dataset.mode)
|
|
modePopoverOptions.innerHTML = availableModes
|
|
.map((mode) => {
|
|
const checked = selectedModes.includes(mode) ? 'checked' : ''
|
|
const id = `mode-popover-${rowEl.dataset.id}-${mode}`
|
|
return `
|
|
<label for="${id}">
|
|
<input id="${id}" type="checkbox" data-popover-mode="${mode}" ${checked} />
|
|
<span>${MODE_LABELS[mode] || mode}</span>
|
|
</label>
|
|
`
|
|
})
|
|
.join('')
|
|
positionModePopover(triggerEl)
|
|
setModePopoverVisible(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 `<label class="perm-toggle"><input type="checkbox" data-perm="${name}" ${checked ? 'checked' : ''} /></label>`
|
|
}
|
|
|
|
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 canEditAdmins = !!adminCapabilities.canEditAdmins
|
|
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 selectableModes = 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
|
|
const roleOptions = [
|
|
`<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>`,
|
|
...(u.role === 'admin' || canEditAdmins
|
|
? [`<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>`]
|
|
: [])
|
|
].join('')
|
|
const roleHint =
|
|
!canEditAdmins && u.role !== 'admin'
|
|
? '<div class="muted">仅初始管理员可授予 admin</div>'
|
|
: ''
|
|
|
|
return `
|
|
<tr data-id="${u.id}">
|
|
<td>
|
|
<div class="user-name">${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'}>
|
|
${roleOptions}
|
|
</select>
|
|
${roleHint}
|
|
</td>
|
|
<td>
|
|
${
|
|
selectableModes
|
|
? `<div class="mode-picker">
|
|
<button type="button" class="mode-picker-trigger" data-action="open-modes">
|
|
<span class="mode-picker-title">可用模式</span>
|
|
<span class="mode-picker-value" data-mode-summary>
|
|
${summarizeSelectedModes(
|
|
ALL_MODES.filter((m) => grantableModes.includes(m) && allowedModes.includes(m)),
|
|
ALL_MODES.filter((m) => grantableModes.includes(m))
|
|
)}
|
|
</span>
|
|
<span class="mode-picker-caret">▾</span>
|
|
</button>
|
|
<div class="mode-hidden-inputs">${selectableModes}</div>
|
|
</div>`
|
|
: '<span class="muted">未授权</span>'
|
|
}
|
|
</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, adminOnly: true })
|
|
})
|
|
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) => {
|
|
const target = e.target
|
|
if (!(target instanceof Element)) return
|
|
const tr = target.closest('tr')
|
|
if (!tr) return
|
|
const editable = !tr.querySelector('button[data-action="save"]')?.disabled
|
|
|
|
const modeTrigger = target.closest('button[data-action="open-modes"]')
|
|
if (modeTrigger instanceof HTMLButtonElement) {
|
|
if (!editable) return
|
|
const modeInputs = Array.from(tr.querySelectorAll('input[data-mode]'))
|
|
const availableModes = modeInputs.map((el) => el.dataset.mode).filter(Boolean)
|
|
if (availableModes.length === 0) return
|
|
openModePopover(modeTrigger, tr, availableModes)
|
|
return
|
|
}
|
|
|
|
const saveBtn = target.closest('button[data-action="save"]')
|
|
if (!(saveBtn instanceof HTMLButtonElement)) return
|
|
const id = tr.dataset.id
|
|
if (!id) 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()
|
|
})
|
|
|
|
modePopoverOptions?.addEventListener('change', (e) => {
|
|
const target = e.target
|
|
if (!(target instanceof HTMLInputElement)) return
|
|
if (target.dataset.popoverMode == null || !activeModePicker) return
|
|
const { rowEl, availableModes } = activeModePicker
|
|
const selectedModes = Array.from(modePopoverOptions.querySelectorAll('input[data-popover-mode]'))
|
|
.filter((el) => el.checked)
|
|
.map((el) => el.dataset.popoverMode)
|
|
rowEl.querySelectorAll('input[data-mode]').forEach((el) => {
|
|
const mode = el.dataset.mode
|
|
el.checked = !!mode && selectedModes.includes(mode)
|
|
})
|
|
const summaryEl = rowEl.querySelector('[data-mode-summary]')
|
|
if (summaryEl) {
|
|
summaryEl.textContent = summarizeSelectedModes(selectedModes, availableModes)
|
|
}
|
|
})
|
|
|
|
modePopoverClose?.addEventListener('click', () => closeModePopover())
|
|
modePopover?.addEventListener('click', (e) => {
|
|
if (e.target === modePopover) closeModePopover()
|
|
})
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modePopover && modePopover.classList.contains('show')) {
|
|
closeModePopover()
|
|
}
|
|
})
|
|
window.addEventListener('resize', () => {
|
|
if (activeModePicker) positionModePopover(activeModePicker.triggerEl)
|
|
})
|
|
document.getElementById('query').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') loadUsers()
|
|
})
|
|
|
|
if (token) {
|
|
setLoggedInState(true)
|
|
loadUsers()
|
|
loadAuditLogs()
|
|
} else {
|
|
setLoggedInState(false)
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|