初始提交:DSH 多租户平台(dshs)

This commit is contained in:
admin committed 2026-09-13 16:18:10 +08:00
commit 43976fea6a
167 files changed
+24456

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
<!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">
<span>登录为 <strong id="who"></strong> <a href="/skills.html" style="margin-left:10px">技能管理</a></span>
<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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 131 24" 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"></path></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+268
View File
@@ -0,0 +1,268 @@
/* design system — OS-desktop style */
:root {
--accent: #4d7cfe;
--accent-2: #38d6d0;
--ink: #0f1c33;
--ink-soft: #5a6b85;
--line: #e6ebf4;
--surface: #ffffff;
--surface-2: #f6f8fc;
--danger: #e5484d;
--ok: #1fb56a;
--warn: #f5a623;
--radius: 14px;
--shadow: 0 18px 50px -18px rgba(9, 24, 58, 0.35);
--shadow-sm: 0 6px 18px -8px rgba(9, 24, 58, 0.28);
--mono: "SFMono-Regular", "Cascadia Code", Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: var(--ink);
background: #0b1a33;
-webkit-font-smoothing: antialiased;
}
/* ---------- auth pages ---------- */
.auth-bg {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(60rem 40rem at 80% -10%, rgba(56, 214, 208, 0.18), transparent 60%),
radial-gradient(50rem 36rem at 10% 110%, rgba(77, 124, 254, 0.28), transparent 60%),
linear-gradient(160deg, #0d1f3c, #0a1530);
}
.auth-card {
width: 100%;
max-width: 380px;
background: var(--surface);
border-radius: 20px;
padding: 32px 30px 26px;
box-shadow: var(--shadow);
}
.auth-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.auth-brand .logo { width: 34px; height: 34px; border-radius: 9px; }
.auth-brand h1 { font-size: 19px; margin: 0; letter-spacing: 0.2px; }
.wordmark { display: inline-flex; align-items: center; }
.wordmark svg { height: 100%; width: auto; display: block; }
.auth-brand .wordmark { height: 22px; color: var(--ink); }
.topbar .wordmark { height: 20px; color: #dbe5ff; }
.auth-sub { color: var(--ink-soft); font-size: 13px; margin: 6px 0 22px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 13px; color: var(--ink-soft); margin-bottom: 6px; }
.field input {
width: 100%;
padding: 11px 13px;
font-size: 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-2);
color: var(--ink);
outline: none;
transition: border-color .15s, box-shadow .15s;
}
.field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(77, 124, 254, 0.15); background: #fff; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
border: 0; cursor: pointer; font-size: 14px; font-weight: 600;
padding: 11px 16px; border-radius: 10px; transition: transform .06s, filter .15s, background .15s;
}
.btn:active { transform: translateY(1px); }
.btn.primary { width: 100%; background: var(--accent); color: #fff; }
.btn.primary:hover { filter: brightness(1.06); }
.btn.ghost { background: var(--surface-2); color: var(--ink); border: 1px solid var(--line); }
.btn.ghost:hover { background: #eef1f8; }
.btn.danger { background: var(--danger); color: #fff; }
.btn.small { padding: 6px 10px; font-size: 13px; border-radius: 8px; }
.msg { min-height: 20px; font-size: 13px; margin-top: 10px; }
.msg.err { color: var(--danger); }
.msg.ok { color: var(--ok); }
.auth-alt { margin-top: 16px; text-align: center; font-size: 13px; color: var(--ink-soft); }
.auth-alt a { color: var(--accent); text-decoration: none; font-weight: 600; }
/* ---------- desktop ---------- */
.desktop { position: fixed; inset: 0; display: flex; flex-direction: column; }
.wallpaper {
position: absolute; inset: 0; z-index: 0;
background:
radial-gradient(70rem 40rem at 75% -20%, rgba(56, 214, 208, 0.20), transparent 55%),
radial-gradient(60rem 40rem at 10% 110%, rgba(77, 124, 254, 0.30), transparent 55%),
linear-gradient(160deg, #0e2246 0%, #0a1630 60%, #071021 100%);
}
.wallpaper::after {
content: ""; position: absolute; inset: 0;
background-image: radial-gradient(rgba(255,255,255,0.05) 1px, transparent 1px);
background-size: 26px 26px;
}
.topbar {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: space-between;
padding: 0 18px; height: 52px;
background: rgba(13, 26, 52, 0.55); backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(255,255,255,0.08);
color: #dbe5ff;
}
.topbar .brand { display: flex; align-items: center; gap: 9px; font-weight: 600; }
.topbar .brand .logo { width: 24px; height: 24px; border-radius: 6px; }
.topbar .right { display: flex; align-items: center; gap: 12px; font-size: 13px; }
.topbar .who { font-weight: 600; }
.topbar .role {
font-size: 11px; padding: 2px 8px; border-radius: 999px;
background: rgba(77, 124, 254, 0.25); color: #bcd0ff; border: 1px solid rgba(77,124,254,0.4);
}
.topbar .logout { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: #dbe5ff; }
.topbar .logout:hover { background: rgba(255,255,255,0.14); }
.icons { position: relative; z-index: 1; flex: 1; display: flex; flex-wrap: wrap; align-content: flex-start; gap: 10px; padding: 22px; }
.desk-icon {
width: 96px; display: flex; flex-direction: column; align-items: center; gap: 7px;
padding: 10px 6px; border-radius: 12px; cursor: pointer; user-select: none;
border: 1px solid transparent;
}
.desk-icon:hover { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.10); }
.desk-icon .tile {
width: 62px; height: 62px; border-radius: 16px; display: grid; place-items: center;
color: #fff; box-shadow: 0 10px 24px -10px rgba(0,0,0,0.5);
}
.desk-icon .tile svg { width: 30px; height: 30px; }
.desk-icon .label { color: #e6edff; font-size: 12.5px; text-align: center; text-shadow: 0 1px 3px rgba(0,0,0,0.5); }
.tile.dsh { background: linear-gradient(135deg, #4d7cfe, #38d6d0); }
.tile.files { background: linear-gradient(135deg, #f5b14d, #f58a4d); }
.tile.admin { background: linear-gradient(135deg, #9b6cff, #4d7cfe); }
.taskbar {
position: relative; z-index: 2; display: flex; align-items: center; justify-content: center; gap: 10px;
height: 56px; background: rgba(13, 26, 52, 0.6); backdrop-filter: blur(14px);
border-top: 1px solid rgba(255,255,255,0.08);
}
.taskbar .app {
width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; cursor: pointer;
border: 1px solid transparent; transition: background .15s, transform .1s;
}
.taskbar .app:hover { background: rgba(255,255,255,0.1); }
.taskbar .app.active { background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.18); }
.taskbar .app svg { width: 22px; height: 22px; }
.taskbar .clock { position: absolute; right: 18px; color: #cfe0ff; font-size: 13px; }
/* ---------- windows ---------- */
.window {
position: absolute; z-index: 10; min-width: 380px; min-height: 220px;
background: var(--surface); border-radius: 16px; box-shadow: var(--shadow);
display: flex; flex-direction: column; overflow: hidden;
animation: win-in .18s ease;
}
@keyframes win-in { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
.window.focused { box-shadow: 0 26px 70px -20px rgba(0,0,0,0.55); }
.window .rz, .window .resize { position: absolute; z-index: 3; touch-action: none; }
.window .rz-n { top: 0; left: 16px; right: 16px; height: 6px; cursor: ns-resize; }
.window .rz-s { bottom: 0; left: 16px; right: 16px; height: 6px; cursor: ns-resize; }
.window .rz-e { right: 0; top: 48px; bottom: 0; width: 6px; cursor: ew-resize; }
.window .rz-w { left: 0; top: 48px; bottom: 0; width: 6px; cursor: ew-resize; }
.window .rz-ne { top: 30px; right: 0; width: 14px; height: 14px; cursor: nesw-resize; }
.window .rz-nw { top: 30px; left: 0; width: 14px; height: 14px; cursor: nwse-resize; }
.window .rz-se, .window .resize { bottom: 0; right: 0; width: 16px; height: 16px; cursor: nwse-resize; }
.window .rz-sw { bottom: 0; left: 0; width: 14px; height: 14px; cursor: nesw-resize; }
.window .rz-se::after, .window .resize::after {
content: ""; position: absolute; right: 4px; bottom: 4px; width: 9px; height: 9px;
border-right: 2px solid rgba(120, 132, 160, 0.55);
border-bottom: 2px solid rgba(120, 132, 160, 0.55);
border-bottom-right-radius: 4px;
}
.window-title {
display: flex; align-items: center; justify-content: space-between;
padding: 0 14px; height: 44px; background: var(--surface-2); border-bottom: 1px solid var(--line);
cursor: grab; user-select: none;
}
.window-title .t { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 14px; }
.window-title .t svg { width: 16px; height: 16px; color: var(--accent); }
.window-title .controls { display: flex; gap: 6px; }
.window-title .controls button {
width: 26px; height: 26px; border: 0; border-radius: 7px; background: transparent; cursor: pointer;
color: var(--ink-soft); font-size: 13px; line-height: 1; display: grid; place-items: center;
}
.window-title .controls button:hover { background: #e8ecf5; }
.window-title .controls button.close:hover { background: var(--danger); color: #fff; }
.window-body { flex: 1; overflow: auto; padding: 16px; }
/* file table */
.pathbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.pathbar .crumbs { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--accent); }
.pathbar .crumbs .sep { color: var(--ink-soft); }
.pathbar .crumb { cursor: pointer; }
.pathbar .crumb:hover { text-decoration: underline; }
table.files { width: 100%; border-collapse: collapse; font-size: 13.5px; }
table.files th, table.files td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); }
table.files th { color: var(--ink-soft); font-weight: 600; font-size: 12px; }
table.files tbody tr:hover { background: var(--surface-2); }
table.files .dir { color: var(--accent); cursor: pointer; font-weight: 600; }
table.files .dir:hover { text-decoration: underline; }
/* dsh status */
.stat { display: flex; align-items: center; gap: 8px; font-size: 14px; padding: 10px 12px; border-radius: 10px; background: var(--surface-2); margin-bottom: 12px; }
.dot { width: 9px; height: 9px; border-radius: 50%; }
.dot.on { background: var(--ok); box-shadow: 0 0 0 4px rgba(31,181,106,0.15); }
.dot.off { background: var(--ink-soft); }
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.section-label { font-size: 12px; color: var(--ink-soft); font-weight: 600; margin: 16px 0 8px; }
.plugin-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-radius: 8px; font-size: 13.5px; }
.plugin-item:hover { background: var(--surface-2); }
/* admin */
table.admin { width: 100%; border-collapse: collapse; font-size: 13.5px; }
table.admin th, table.admin td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); }
table.admin th { color: var(--ink-soft); font-weight: 600; font-size: 12px; }
.badge { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 600; }
.badge.admin { background: #e7edff; color: #3f5fd0; }
.badge.pending { background: #fff3dd; color: #b0761a; }
.badge.active { background: #e4f7ec; color: #15995a; }
.badge.disabled { background: #eceff4; color: #6a7588; }
.hidden { display: none !important; }
/* ---------- mobile ---------- */
@media (max-width: 768px) {
.auth-card { max-width: 100%; overflow-x: auto; }
.desktop { overflow: hidden; }
.topbar { padding: 0 10px; height: 48px; }
.topbar .role { display: none; }
.icons { padding: 10px; gap: 6px; }
.desk-icon { width: 80px; padding: 8px 4px; }
.desk-icon .tile { width: 52px; height: 52px; border-radius: 14px; }
.desk-icon .label { font-size: 11px; }
.desk-path { padding: 8px 10px 0; font-size: 12px; }
.desk-path .loc { max-width: 70vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.taskbar { height: 50px; }
.taskbar .clock { display: none; }
/* windows go full-screen on phones */
.window {
position: fixed !important;
inset: 0 !important;
left: 0 !important; top: 0 !important;
width: 100% !important; height: 100% !important;
min-width: 0 !important; min-height: 0 !important;
max-width: none !important;
border-radius: 0 !important;
}
.window .rz, .window .resize { display: none; }
.window-body { -webkit-overflow-scrolling: touch; }
.window-body table { min-width: 520px; }
.window-title { height: 48px; }
.window-title .controls button { width: 34px; height: 34px; }
}
/* desktop file grid + path bar */
.desk-path { position: relative; z-index: 1; display: flex; align-items: center; gap: 10px; padding: 12px 22px 0; color: #cfe0ff; font-size: 13px; }
.desk-path .back { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16); color: #dbe5ff; }
.desk-path .back:hover { background: rgba(255,255,255,0.14); }
.desk-path .loc { opacity: .85; }
.tile { overflow: hidden; }
.tile img { min-width: 0; min-height: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
.tile.file { background: linear-gradient(135deg, #7f9cf5, #5a74d0); }
.desk-divider { width: 100%; height: 0; border-top: 1px solid rgba(255,255,255,0.14); margin: 4px 2px 10px; }
#fileIcons { display: contents; }
#fileIcons .desk-icon .label { font-size: 12px; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="0;url=/portal.html" />
<title>跳转中…</title>
</head>
<body>
<script>location.replace('/portal.html')</script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>工作台</title>
</head>
<body>
<script>
;(async () => {
const res = await fetch('/api/auth/me')
if (!res.ok) {
location.href = '/login.html'
return
}
// 所有用户(admin/active)统一直达 DSH 会话窗口
try {
const r = await fetch('/api/dsh/enter', { method: 'POST' })
const b = await r.json().catch(() => ({}))
if (r.ok && b.kind === 'session' && b.url) {
location.href = b.url
return
}
} catch {
// fall through to desktop
}
location.href = '/desktop.html'
})()
</script>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
<!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" />
<style>
/* 档案 81 · R2-UI:提交忙碌态(点击后立刻可见;对齐 06-工作台UI规范 的按钮/圆角/主色) */
.btn[disabled] { opacity: .72; cursor: not-allowed; }
.btn.is-busy { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
.btn .sp { width: 14px; height: 14px; flex: 0 0 14px; border: 2px solid rgba(255,255,255,.45);
border-top-color: #fff; border-radius: 50%; animation: btnsp .7s linear infinite; }
@keyframes btnsp { to { transform: rotate(360deg); } }
.msg { min-height: 20px; }
@media (prefers-reduced-motion: reduce) { .btn .sp { animation: none; } }
</style>
</head>
<body class="auth-bg">
<div class="auth-card">
<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">登录你的 DSH 云桌面</p>
<form id="login">
<div class="field">
<label for="username">用户名</label>
<input id="username" name="username" autocomplete="username" value="guest" required />
</div>
<div class="field">
<label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required />
</div>
<button class="btn primary" type="submit">登 录</button>
<p style="margin:14px 0 0;font-size:12px;line-height:1.7;color:#8a8f98">提示:若会话中 shell 频繁要求审批,<b>新开一个会话</b>即可使用完全权限(无需逐条确认)。</p>
<p class="msg" id="msg"></p>
</form>
<p class="auth-alt">还没有账号?<a href="/register.html">注册</a></p>
</div>
<script>
const form = document.getElementById('login')
const msg = document.getElementById('msg')
// 档案 81 · R2-UI:任何提交都要有"看得见"的忙碌态(否则几秒内用户会以为没反应)
function setBusy(busy, label) {
const btn = form.querySelector('button[type=submit]')
if (!btn) return
if (busy) {
if (!btn.dataset.idle) btn.dataset.idle = btn.innerHTML
btn.disabled = true
btn.classList.add('is-busy')
btn.innerHTML = '<span class="sp"></span><span>' + (label || '处理中…') + '</span>'
} else {
btn.disabled = false
btn.classList.remove('is-busy')
if (btn.dataset.idle) btn.innerHTML = btn.dataset.idle
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault()
msg.textContent = ''
msg.className = 'msg err'
setBusy(true, '登录中…')
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: form.username.value, password: form.password.value }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
msg.textContent =
body.error === 'pending_review' ? '账号待管理员审核'
: body.error === 'disabled' ? '账号已被禁用'
: '用户名或密码错误'
setBusy(false)
return
}
const body = await res.json()
const role = body && body.user ? body.user.role : null
// 所有用户(admin/active)登录后统一直达 DSH 会话窗口
setBusy(true, '正在进入工作台…')
try {
const r = await fetch('/api/dsh/enter', { method: 'POST' })
const b = await r.json().catch(() => ({}))
if (r.ok && b.kind === 'session' && b.url) {
location.href = b.url
return
}
// 失败兜底:admin 可手动进管理台 /desktop.html
const errText =
b.error === 'instance_starting' ? '实例正在启动,请稍后重试'
: (b.error ?? 'HTTP ' + r.status)
msg.innerHTML =
'进入工作台失败:' + errText +
(role === 'admin' ? '(<a href="/desktop.html">前往管理台</a>)' : '')
setBusy(false)
} catch {
msg.textContent = '进入工作台失败:网络错误'
setBusy(false)
}
})
</script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>DeepSeek</title>
<path fill="#4D6BFE" d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="0;url=/portal.html#/plugins" />
<title>跳转中…</title>
</head>
<body>
<script>location.replace('/portal.html#/plugins')</script>
</body>
</html>
+709
View File
@@ -0,0 +1,709 @@
<!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" />
<style>
/* 06-工作台UI规范 Token:亮色工具后台 + 蓝主色 + 橙强调 */
:root {
--bp-primary: #2f6fed; --bp-primary-soft: #eaf1fe; --bp-primary-hover: #2a5fd0;
--bp-accent: #e8590c; --bp-danger: #d93026;
--bp-bg: #f5f6f8; --bp-panel: #ffffff; --bp-border: #e3e6ea;
--bp-text: #24292f; --bp-dim: #6b7280; --bp-head: #f6f8fa;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; background: var(--bp-bg); color: var(--bp-text); }
/* ── 顶栏 ── */
.topbar { display: flex; align-items: center; gap: 20px; height: 55px; padding: 0 20px; background: #fff; border-bottom: 1px solid var(--bp-border); position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; }
.brand .logo { width: 28px; height: 28px; border-radius: 8px; }
.crumbs { display: flex; align-items: center; gap: 6px; flex: 1; font-size: 14px; }
.crumb-home { color: var(--bp-primary); text-decoration: none; cursor: pointer; }
.crumb-home:hover { text-decoration: underline; }
.crumb-sep { color: var(--bp-dim); }
.crumb-current { color: var(--bp-text); font-weight: 600; }
.top-actions { display: flex; align-items: center; gap: 10px; }
.who { color: var(--bp-dim); font-size: 13px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 8px; font-size: 14px; cursor: pointer; border: 1px solid var(--bp-border); background: #fff; color: var(--bp-text); transition: all .15s; }
.btn:hover { border-color: var(--bp-primary); color: var(--bp-primary); }
.btn-primary { background: var(--bp-primary); border-color: var(--bp-primary); color: #fff; }
.btn-primary:hover { background: var(--bp-primary-hover); color: #fff; }
.btn:disabled { opacity: .5; cursor: default; }
/* ── 内容区 ── */
#view { max-width: 960px; margin: 0 auto; padding: 24px 20px 48px; }
.page-head { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }
.page-head .page-title { margin: 0; }
.page-head .page-sub { margin: 4px 0 0; }
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
.page-sub { font-size: 13px; color: var(--bp-dim); margin: 0 0 20px; }
.home-section { margin-bottom: 24px; }
.home-section-title { font-size: 13px; color: var(--bp-dim); font-weight: 600; margin: 0 0 10px; }
.card { background: #fff; border: 1px solid var(--bp-border); border-radius: 10px; padding: 18px; }
/* ── 首页导航卡片 ── */
.nav-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; }
.nav-card { display: flex; flex-direction: column; gap: 6px; background: #fff; border: 1px solid var(--bp-border); border-radius: 10px; padding: 18px 16px; cursor: pointer; transition: all .15s; }
.nav-card:hover { border-color: var(--bp-primary); box-shadow: 0 4px 12px rgba(47,111,237,.12); transform: translateY(-2px); }
.nav-card .ic { color: var(--bp-primary); }
.nav-card .t { font-size: 15px; font-weight: 600; }
.nav-card .d { font-size: 12px; color: var(--bp-dim); }
/* ── 表格 ── */
.table-wrap { overflow: auto; border: 1px solid var(--bp-border); border-radius: 8px; background: #fff; }
table.tbl { width: 100%; border-collapse: collapse; font-size: 15px; }
table.tbl th { position: sticky; top: 0; background: var(--bp-head); padding: 8px 10px; text-align: left; font-weight: 600; white-space: nowrap; border-bottom: 1px solid var(--bp-border); z-index: 1; }
table.tbl td { padding: 7px 10px; border-bottom: 1px solid var(--bp-border); white-space: nowrap; }
table.tbl tbody tr:last-child td { border-bottom: none; }
table.tbl tbody tr:hover { background: var(--bp-primary-soft); }
td.num { text-align: right; font-size: 13px; font-variant-numeric: tabular-nums; color: var(--bp-dim); }
td.desc { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--bp-dim); }
td.act { text-align: center; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 13px; }
.badge.admin { background: var(--bp-primary-soft); color: var(--bp-primary); }
.badge.active { background: #e6f7ef; color: #1a7f37; }
.badge.pending { background: #fff4e5; color: var(--bp-accent); }
.badge.disabled { background: #f0f1f3; color: var(--bp-dim); }
.btn-sm { display: inline-flex; align-items: center; padding: 3px 10px; font-size: 14px; border-radius: 6px; cursor: pointer; border: 1px solid var(--bp-border); background: #fff; color: var(--bp-text); }
.btn-sm:hover { border-color: var(--bp-primary); color: var(--bp-primary); }
.btn-sm.danger { color: var(--bp-danger); }
.btn-sm.danger:hover { border-color: var(--bp-danger); }
/* ── 上传行 / 提示 ── */
.upload-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
.upload-row input[type='file'] { flex: 1; min-width: 220px; padding: 8px; border: 1px solid var(--bp-border); border-radius: 8px; font-size: 14px; background: #fff; }
.hint { font-size: 12px; color: var(--bp-dim); margin: 0 0 14px; line-height: 1.6; }
/* ── 页内 Tab(06 §4.4 下划线式;类名避开全局 .tab 以免与顶栏胶囊式冲突) ── */
.pg-tabs { display: flex; gap: 22px; border-bottom: 1px solid var(--bp-border); margin: 0 0 16px; }
.pg-tab { padding: 8px 2px 10px; font-size: 15px; color: var(--bp-dim); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color .15s, border-color .15s; user-select: none; white-space: nowrap; }
.pg-tab:hover { color: var(--bp-primary); }
.pg-tab.active { color: var(--bp-primary); font-weight: 600; border-bottom-color: var(--bp-primary); }
.pg-tab .pg-cnt { display: inline-block; min-width: 18px; padding: 1px 6px; margin-left: 6px; border-radius: 9px; background: var(--bp-primary-soft); color: var(--bp-primary); font-size: 12px; font-weight: 600; line-height: 16px; text-align: center; }
.pg-tab:not(.active) .pg-cnt { background: #f0f1f3; color: var(--bp-dim); }
.card-h { display: flex; align-items: baseline; gap: 10px; font-size: 15px; font-weight: 600; margin: 0 0 12px; }
.card-h .sub { font-size: 13px; font-weight: 400; color: var(--bp-dim); }
/* ── 白名单表格:以「说明」为主视觉(一眼看懂插件用途) ── */
.wl-cell { white-space: normal; }
.wl-desc { font-size: 14px; line-height: 1.5; color: var(--bp-text); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; max-width: 620px; }
.wl-desc.fallback { font-weight: 600; }
.wl-meta { font-size: 12px; color: var(--bp-dim); margin-top: 3px; max-width: 520px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wl-cat { font-size: 12px; color: var(--bp-dim); } /* ── 文件树 ── */
.wl-link { text-decoration: none; white-space: nowrap; }
/* 插件管理页:表格 6 列信息量大,单独放宽容器(其他页仍 960)。 */
#view.page-plugins { max-width: 1200px; }
/* 2026-09-12:插件管理页底部留白已移除(见档案 61 §六修正)。
原因:列表上方约 362px + 列表高度 + 按钮行,若再加底部留白会让整页必然超出视口。
现列表高度纯自适应(calc(100vh - 400px)),小屏自动变矮、不超屏;大屏自然更高。 */
.wl-cat { white-space: nowrap; } .pathbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.pathbar .crumbs { gap: 4px; }
.pathbar .crumb { color: var(--bp-primary); cursor: pointer; font-size: 14px; }
.pathbar .crumb:hover { text-decoration: underline; }
.dsh-bar { display: flex; align-items: center; gap: 12px; background: #fff; border: 1px solid var(--bp-border); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
.dsh-bar .stat { font-size: 14px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.dot.on { background: #1fb56a; } .dot.off { background: #c4c9d0; }
/* ── 密钥 ── */
.key-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--bp-border); }
.key-row:last-child { border-bottom: none; }
/* ── 空状态 / Toast ── */
.empty { padding: 48px 20px; text-align: center; color: var(--bp-dim); font-size: 14px; }
.toast { position: fixed; bottom: 32px; left: 50%; transform: translateX(-50%); background: #24292f; color: #fff; padding: 10px 18px; border-radius: 8px; font-size: 14px; z-index: 200; display: none; white-space: pre-wrap; max-width: 90vw; }
.toast.show { display: block; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><img class="logo" src="/logo.svg" alt="logo" /><span>管理门户</span></div>
<nav class="crumbs" id="crumbs"></nav>
<div class="top-actions">
<span class="who" id="who"></span>
<button class="btn" id="btnHome">首页</button>
<button class="btn" id="logoutBtn">退出</button>
</div>
</header>
<main id="view"><div class="empty">加载中…</div></main>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id)
let toastTimer = null
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])) }
function fmtSize(n) { if (!n) return '—'; if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB' }
function toast(text) { const el = $('toast'); el.textContent = text; el.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove('show'), 1800) }
function when(ms) { return ms ? new Date(ms).toLocaleString() : '—' }
async function me() { const r = await fetch('/api/auth/me'); return r.ok ? (await r.json()).user : null }
function postJson(url, body) { return fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) }
function readAsBase64(file) { return String(new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file) })).split(',')[1] ?? '' }
function emptyRow(cols, text) { return `<tr><td colspan="${cols}" style="text-align:center;color:var(--bp-dim);padding:40px 10px">${esc(text)}</td></tr>` }
/* ================= 路由 ================= */
const CRUMB_TITLES = { files: '服务管理', keys: '密钥管理', users: '用户管理', skills: '技能管理', plugins: '插件管理' }
function parseHash() { const h = (location.hash || '#/').replace(/^#/, ''); const parts = h.split('/').filter(Boolean); return parts[0] || '' }
function updateCrumbs(name) {
const c = $('crumbs')
if (!name) { c.innerHTML = '<span class="crumb-current">管理门户</span>'; return }
c.innerHTML = `<a class="crumb-home" href="#/">管理门户</a><span class="crumb-sep">/</span><span class="crumb-current">${esc(CRUMB_TITLES[name] || name)}</span>`
}
function pageHead(title, sub) {
return `<div class="page-head"><button class="btn" data-back>← 返回</button><div><h1 class="page-title">${title}</h1><p class="page-sub">${sub}</p></div></div>`
}
function renderHome() {
const sec1 = [
{ ic: '🖥️', t: '服务管理', d: '文件树 + DSH 启动', hash: '#/files' },
{ ic: '🔑', t: '密钥管理', d: '全局 API 密钥', hash: '#/keys' },
]
const sec2 = [
{ ic: '👥', t: '用户管理', d: '审批 / 禁用 / 删除', hash: '#/users', admin: true },
{ ic: '🧩', t: '技能管理', d: '共享技能上传', hash: '#/skills', admin: true },
{ ic: '🔌', t: '插件管理', d: '功能插件投放', hash: '#/plugins', admin: true },
{ ic: '📦', t: '运行环境', d: '共享运行时与工具', hash: '#/runtime', admin: true },
]
const grid = (cards) => cards.filter((c) => !c.admin || isAdmin).map((c) => `
<div class="nav-card" data-hash="${c.hash}">
<div class="ic">${c.ic}</div>
<div class="t">${c.t}</div>
<div class="d">${c.d}</div>
</div>`).join('')
return `<h1 class="page-title">管理门户</h1><p class="page-sub">平台管理</p>
<div class="home-section"><div class="home-section-title">服务</div><div class="nav-grid">${grid(sec1)}</div></div>
<div class="home-section"><div class="home-section-title">管理</div><div class="nav-grid">${grid(sec2)}</div></div>`
}
/* ================= 服务管理(#/files) ================= */
let currentPath = []
function pathString() { return currentPath.join('/') }
async function renderFiles() {
return `${pageHead('服务管理', '浏览文件、在此文件夹启动 DSH')}
<div class="dsh-bar">
<span class="stat" id="dshState"><span class="dot off"></span>未运行</span>
<button class="btn btn-primary" id="launchBtn">启动 DSH</button>
<button class="btn" id="stopBtn" style="display:none">停止</button>
<button class="btn" id="openDshBtn" style="display:none">打开 DSH ↗</button>
<span style="flex:1"></span>
<span style="font-size:13px;color:var(--bp-dim)">启动目录:<b id="dshFolder">根目录</b></span>
</div>
<div class="card">
<div class="pathbar">
<button class="btn btn-sm" id="newFolderBtn">新建文件夹</button>
<button class="btn btn-sm" id="uploadBtn">上传文件</button>
<span class="crumbs" id="breadcrumb"></span>
</div>
<div class="table-wrap"><table class="tbl">
<thead><tr><th>名称</th><th>类型</th><th class="num" style="text-align:right">大小</th><th>修改时间</th></tr></thead>
<tbody id="rows"></tbody>
</table></div>
</div>
<input id="fileInput" type="file" style="display:none" />`
}
async function initFiles() {
async function load() {
const p = pathString()
const res = await fetch('/api/desktop/tree' + (p ? '?path=' + encodeURIComponent(p) : ''))
const body = await res.json()
const rows = $('rows')
rows.innerHTML = ''
if (p) rows.insertAdjacentHTML('beforeend', '<tr><td class="dir" data-up="1">..</td><td></td><td></td><td></td></tr>')
for (const e of body.entries || []) {
const href = e.type === 'dir' ? ' class="dir" data-path="' + encodeURIComponent(p ? p + '/' + e.name : e.name) + '"' : ''
rows.insertAdjacentHTML('beforeend', `<tr><td${href}>${esc(e.name)}</td><td>${e.type === 'dir' ? '文件夹' : '文件'}</td><td class="num">${fmtSize(e.size)}</td><td>${new Date(e.mtimeMs).toLocaleString()}</td></tr>`)
}
// 面包屑
const parts = ['<span class="crumb" data-path="">根</span>']
let acc = []
for (const seg of currentPath) { acc.push(seg); parts.push('<span class="crumb-sep">/</span><span class="crumb" data-path="' + esc(acc.join('/')) + '">' + esc(seg) + '</span>') }
$('breadcrumb').innerHTML = parts.join('')
$('breadcrumb').querySelectorAll('.crumb').forEach((c) => c.addEventListener('click', () => { currentPath = c.dataset.path ? c.dataset.path.split('/').filter(Boolean) : []; initFiles() }))
rows.querySelectorAll('.dir[data-path]').forEach((el) => el.addEventListener('click', () => { currentPath = decodeURIComponent(el.dataset.path).split('/').filter(Boolean); initFiles() }))
const up = rows.querySelector('.dir[data-up]')
if (up) up.addEventListener('click', () => { currentPath = currentPath.slice(0, -1); initFiles() })
}
async function refreshDsh() {
const res = await fetch('/api/dsh/status'); if (!res.ok) return
const body = await res.json()
const running = body.running
$('dshState').innerHTML = `<span class="dot ${running ? 'on' : 'off'}"></span>${running ? '运行中' : '未运行'}`
$('stopBtn').style.display = running ? '' : 'none'
$('openDshBtn').style.display = running ? '' : 'none'
if (running) $('openDshBtn').onclick = () => window.open(body.url, '_blank')
}
$('dshFolder').textContent = pathString() || '根目录'
$('launchBtn').onclick = async () => {
const res = await postJson('/api/dsh/launch', { folder: pathString() })
if (!res.ok) { const b = await res.json().catch(() => ({})); toast(b.error === 'already_running' ? '已有运行中的 DSH' : '启动失败'); return }
toast('DSH 已启动'); await refreshDsh()
}
$('stopBtn').onclick = async () => { await fetch('/api/dsh/stop', { method: 'POST' }); await refreshDsh() }
$('newFolderBtn').onclick = () => { const name = prompt('文件夹名称:'); if (name) doCreate(name, 'dir') }
$('uploadBtn').onclick = () => $('fileInput').click()
$('fileInput').onchange = async (ev) => {
const file = ev.target.files[0]; if (!file) return
const data = await readAsBase64(file)
const res = await postJson('/api/fs/upload', { path: pathString(), name: file.name, data })
if (!res.ok) { const e = (await res.json().catch(() => ({}))).error; toast('上传失败:' + (e || res.status)); return }
ev.target.value = ''; await load()
}
async function doCreate(name, type) {
const res = await postJson('/api/fs/create', { path: pathString(), name, type })
if (!res.ok) { const e = (await res.json().catch(() => ({}))).error; toast('创建失败:' + (e || res.status)); return }
await load()
}
await load(); await refreshDsh()
}
/* ================= 密钥管理(#/keys) ================= */
async function renderKeys() {
return `${pageHead('密钥管理', '全局 API 密钥(所有用户共用,仅管理员可改)')}
<div class="card">
<div id="keyList"></div>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center">
<input id="keyNameInput" placeholder="名称(如 家用 / 服务器)" style="flex:1;min-width:120px;padding:8px 10px;border:1px solid var(--bp-border);border-radius:8px;font-size:14px" />
<input id="keyValueInput" type="password" placeholder="sk-..." style="flex:1.4;min-width:160px;padding:8px 10px;border:1px solid var(--bp-border);border-radius:8px;font-size:14px" />
<button class="btn btn-primary" id="addKeyBtn">添加</button>
</div>
</div>`
}
async function initKeys() {
async function load() {
const res = await fetch('/api/me/keys'); if (!res.ok) return
const { keys } = await res.json()
const list = $('keyList')
if (keys.length === 0) { list.innerHTML = '<div class="empty">暂无全局密钥。添加后所有用户的 DSH 才能调用模型。</div>'; return }
list.innerHTML = keys.map((k) => `<div class="key-row">
<span class="dot ${k.enabled ? 'on' : 'off'}"></span><strong>${esc(k.name)}</strong><span style="flex:1"></span>
${k.enabled ? '<span style="font-size:12px;color:#1a7f37">启用中</span>' : `<button class="btn-sm" data-sel="${k.id}">启用</button>`}
<button class="btn-sm danger" data-del="${k.id}">删除</button></div>`).join('')
list.querySelectorAll('[data-sel]').forEach((b) => b.onclick = async () => { await fetch(`/api/me/keys/${b.dataset.sel}/select`, { method: 'POST' }); await load() })
list.querySelectorAll('[data-del]').forEach((b) => b.onclick = async () => { if (confirm('删除这个密钥?')) { await fetch(`/api/me/keys/${b.dataset.del}`, { method: 'DELETE' }); await load() } })
}
$('addKeyBtn').onclick = async () => {
const name = $('keyNameInput').value.trim(), apiKey = $('keyValueInput').value.trim()
if (!name || !apiKey) { toast('请填名称和密钥'); return }
const res = await postJson('/api/me/keys', { name, apiKey })
if (!res.ok) { toast('名称或密钥格式无效'); return }
$('keyNameInput').value = ''; $('keyValueInput').value = ''; await load()
}
await load()
}
/* ================= 用户管理(#/users) ================= */
async function renderUsers() {
return `${pageHead('用户管理', '审批 / 禁用 / 删除用户')}
<div class="table-wrap"><table class="tbl">
<thead><tr><th>用户名</th><th>角色</th><th>注册时间</th><th class="act">操作</th></tr></thead>
<tbody id="usersTbody"></tbody></table></div>`
}
async function initUsers() {
async function load() {
const res = await fetch('/api/admin/users'); const { users } = await res.json()
const tbody = $('usersTbody'); tbody.innerHTML = ''
const badge = { admin: '管理员', pending: '待审核', active: '正常', disabled: '已禁用' }
for (const u of users) {
const actions = u.role === 'pending' ? `<button class="btn-sm" data-act="approve" data-id="${u.id}">通过</button>`
: u.role === 'active' ? `<button class="btn-sm danger" data-act="disable" data-id="${u.id}">禁用</button>`
: u.role === 'disabled' ? `<button class="btn-sm" data-act="enable" data-id="${u.id}">恢复</button>` : ''
const del = u.role !== 'admin' ? `<button class="btn-sm danger" data-act="del" data-id="${u.id}" data-name="${esc(u.username)}" title="删除用户">删除</button>` : ''
tbody.insertAdjacentHTML('beforeend', `<tr><td><strong>${esc(u.username)}</strong></td><td><span class="badge ${u.role}">${badge[u.role] || u.role}</span></td><td>${new Date(u.createdAt).toLocaleString()}</td><td class="act">${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) { toast('用户名不匹配,已取消'); return }
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' })
if (res.ok) await load(); else toast('删除失败(admin 不可删除)')
return
}
const res = await fetch(`/api/admin/users/${id}/${act}`, { method: 'POST' })
if (res.ok) await load(); else toast('操作失败')
}
}
await load()
}
/* ================= 技能管理(#/skills) ================= */
async function renderSkills() {
return `${pageHead('技能管理', '共享技能(所有用户可用 · 只读)')}
<div class="card">
<div class="upload-row"><input type="file" id="skillFile" accept=".zip" /><button class="btn btn-primary" id="skillUploadBtn">上传 / 替换</button></div>
<p class="hint">仅支持 .zip:包内单个顶层目录且含 SKILL.md(name 小写连字符 + description)。上传先做安全检测;同名确认后整体替换。</p>
<div class="table-wrap"><table class="tbl">
<thead><tr><th>技能名</th><th>说明</th><th class="num" style="text-align:right">文件</th><th class="num" style="text-align:right">大小</th><th>更新时间</th><th class="act">操作</th></tr></thead>
<tbody id="skillTbody"></tbody></table></div>
</div>`
}
async function initSkills() {
async function load() {
const tbody = $('skillTbody'); tbody.innerHTML = emptyRow(6, '加载中…')
try {
const { skills } = await (await fetch('/api/skills/shared')).json()
if (skills.length === 0) { tbody.innerHTML = emptyRow(6, '暂无共享技能 —— 上传 .zip 投放第一个'); return }
tbody.innerHTML = skills.map((s) => `<tr><td><strong>${esc(s.name)}</strong></td><td class="desc" title="${esc(s.description)}">${esc(s.description) || '—'}</td><td class="num">${s.files ?? '—'}</td><td class="num">${fmtSize(s.size)}</td><td>${when(s.mtimeMs)}</td><td class="act"><button class="btn-sm danger" data-del="${esc(s.name)}">删除</button></td></tr>`).join('')
} catch { tbody.innerHTML = emptyRow(6, '加载失败') }
}
$('skillUploadBtn').onclick = async () => {
const input = $('skillFile'); const file = input.files[0]
if (!file) { toast('请先选择 .zip 文件'); return }
if (!file.name.toLowerCase().endsWith('.zip')) { toast('仅支持 .zip 文件'); return }
if (file.size > 150 * 1024 * 1024) { toast('文件过大(>150MB)'); return }
const btn = $('skillUploadBtn'); btn.disabled = true; btn.textContent = '检测中…'
const base64 = await readAsBase64(file)
try {
const res = await postJson('/api/skills/shared', { file: base64, filename: file.name })
const data = await res.json().catch(() => ({}))
if (!res.ok) { toast('上传失败:' + (data.error ?? res.status)); return }
if (!data.conflict) { toast('✓ 已安装技能「' + data.skill.name + '」'); input.value = ''; await load(); return }
const ok = confirm('检测到同名技能「' + data.skill.name + '」已存在。确认整体替换?\n\n⚠️ 替换将删除旧技能全部文件,不可恢复。')
if (!ok) { toast('已取消'); return }
const r2 = await postJson('/api/skills/shared/apply', { stagedId: data.stagedId }); const d2 = await r2.json().catch(() => ({}))
if (!r2.ok) { toast('替换失败:' + (d2.error ?? r2.status)); return }
toast('✓ 已整体替换技能「' + d2.skill.name + '」'); input.value = ''; await load()
} catch { toast('上传失败:网络错误') } finally { btn.disabled = false; btn.textContent = '上传 / 替换' }
}
$('skillTbody').onclick = async (event) => {
const btn = event.target.closest('button[data-del]'); if (!btn) return
if (!confirm(`删除共享技能「${btn.dataset.del}」?所有用户将立即不可用。`)) return
const res = await fetch('/api/skills/shared/' + encodeURIComponent(btn.dataset.del), { method: 'DELETE' })
if (res.ok) { toast('✓ 已删除'); await load() } else toast('删除失败')
}
await load()
}
/* ================= 插件管理(#/plugins) ================= */
async function renderPlugins() {
return `${pageHead('插件管理', '功能插件(系统外插件)的投放与管理')}
<div class="pg-tabs" id="pluginTabs">
<div class="pg-tab active" data-tab="official">官方推荐插件</div>
<div class="pg-tab" data-tab="manual">手动添加 / 管理<span class="pg-cnt" id="poolCnt">0</span></div>
</div>
<div id="pane-official">
<div class="card">
<div class="upload-row">
<input id="wlQ" placeholder="搜索插件名 / 描述 / npm 包名" style="flex:1;min-width:200px;padding:8px 10px;border:1px solid var(--bp-border);border-radius:8px;font-size:14px" />
<select id="wlCat" style="padding:8px 10px;border:1px solid var(--bp-border);border-radius:8px;font-size:14px;background:#fff"><option value="">全部分类</option></select>
<label style="display:inline-flex;align-items:center;gap:6px;font-size:14px;color:var(--bp-dim);cursor:pointer"><input type="checkbox" id="wlOnlyImportable" checked />只看可导入</label>
<button class="btn" id="wlSearchBtn">搜索</button>
<button class="btn" id="wlRefreshBtn" title="绕过本地缓存,从官方站重新下载插件目录(约数秒;平时打开本页不会下载,直接读本地缓存)">重新拉取目录</button>
</div>
<p class="hint">来源:<b>awesome-dsh-plugin</b> 官方插件目录(dsh 官方社区精选,3400+ 插件,按下载量排序,取前 300 条)。<br />
导入只接受<b>预构建包</b>:官方 npm 包(registry tarball)或作者发布的 release 资产 —— 平台不执行任何第三方构建脚本。标记「需源码构建」的插件暂不支持一键导入(官方安装方式为 <code>github:owner/repo</code>),可自行构建后到「手动添加 / 管理」上传。</p>
<div style="margin-bottom:8px"><span style="color:var(--bp-dim);font-size:13px" id="wlInfo">加载中…</span></div>
<div class="table-wrap" style="max-height:calc(100vh - 520px)"><table class="tbl">
<thead><tr><th style="width:34px"></th><th>插件说明</th><th>分类</th><th>导入方式</th><th class="num" style="text-align:right">下载量</th><th class="act">详情</th></tr></thead>
<tbody id="wlTbody"></tbody></table></div>
<div style="margin-top:10px;display:flex;align-items:center;gap:12px">
<button class="btn btn-primary" id="wlImportBtn">导入到平台</button>
<span style="color:var(--bp-dim);font-size:13px" id="wlSelInfo">已选 0</span>
</div>
<div id="wlResult" style="margin-top:12px"></div>
</div>
</div>
<div id="pane-manual" hidden>
<div class="card" style="margin-bottom:16px">
<div class="card-h">投放插件<span class="sub">上传 .tgz 收录到平台</span></div>
<div class="upload-row"><input type="file" id="pluginFile" accept=".tgz" /><button class="btn btn-primary" id="pluginUploadBtn">上传 / 替换</button></div>
<p class="hint" style="margin:0">仅支持 .tgz:dsh 插件 bundle(含 package.json,name 为 npm 包名)。上传先做安全检测;同名按替换策略(旧包先删再落新包)。命中 P0 规则时默认拒绝,但会<b>逐条列出命中内容</b> —— 确认可信可显式声明信任后投放(会记入审计)。</p>
<div id="pluginScanBox"></div>
</div>
<div class="card">
<div class="card-h">已投放插件<span class="sub" id="poolHint">共 0 个 · 全部默认禁用</span></div>
<p class="hint"><b>投放 ≠ 生效。</b>这里只是把插件<b>收录到平台</b>,对任何用户都不生效;用户要真正用上,需在自己实例的「设置 → 功能插件」里勾选启用 —— 启用时平台才会把插件装进他的环境,重启实例后生效。</p>
<div class="table-wrap"><table class="tbl">
<thead><tr><th>插件名</th><th>版本</th><th>说明</th><th class="num" style="text-align:right">大小</th><th>投放时间</th><th class="act">操作</th></tr></thead>
<tbody id="pluginTbody"></tbody></table></div>
</div>
</div>`
}
async function initPlugins() {
const wlSelected = new Set()
const WL_KIND = {
npm: { label: 'npm 预构建', cls: 'active' },
tarball: { label: 'release 资产', cls: 'admin' },
source: { label: '需源码构建', cls: 'disabled' },
}
/** 切 tab:同时写 hash 后缀(#/plugins/<key>)以便深链;replaceState 不触发 hashchange。 */
function showTab(key) {
document.querySelectorAll('#pluginTabs .pg-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === key))
$('pane-official').hidden = key !== 'official'
$('pane-manual').hidden = key !== 'manual'
try { history.replaceState(null, '', '#/plugins/' + key) } catch { /* ignore */ }
}
async function load() {
const tbody = $('pluginTbody'); tbody.innerHTML = emptyRow(6, '加载中…')
try {
const { plugins } = await (await fetch('/api/plugins/business')).json()
if ($('poolCnt')) $('poolCnt').textContent = String(plugins.length)
if ($('poolHint')) $('poolHint').textContent = `共 ${plugins.length} 个 · 全部默认禁用`
if (plugins.length === 0) { tbody.innerHTML = emptyRow(6, '暂无已投放插件 —— 从「官方推荐插件」导入,或在上方上传 .tgz'); return }
tbody.innerHTML = plugins.map((p) => `<tr><td><strong>${esc(p.name)}</strong></td><td>${esc(p.version) || '—'}</td><td class="desc" title="${esc(p.description)}">${esc(p.description) || '—'}</td><td class="num">${fmtSize(p.fileSize)}</td><td>${when(p.updatedAt)}</td><td class="act"><button class="btn-sm danger" data-del="${esc(p.id)}">删除</button></td></tr>`).join('')
} catch { tbody.innerHTML = emptyRow(6, '加载失败') }
}
/** 相对时间(目录缓存的上次更新时间展示用)。 */
function relTime(ts) {
if (!ts || typeof ts !== 'number') return '未知'
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000))
if (s < 60) return '刚刚'
if (s < 3600) return Math.floor(s / 60) + ' 分钟前'
if (s < 86400) return Math.floor(s / 3600) + ' 小时前'
return Math.floor(s / 86400) + ' 天前'
}
async function loadWhitelist() {
const tbody = $('wlTbody'); tbody.innerHTML = emptyRow(7, '加载目录中…(首次会从官方站下载一次,之后读本地缓存)')
const q = encodeURIComponent($('wlQ').value.trim())
const cat = encodeURIComponent($('wlCat').value)
const only = $('wlOnlyImportable').checked ? '1' : ''
try {
const r = await fetch(`/api/plugins/whitelist?q=${q}&category=${cat}&onlyImportable=${only}`)
const d = await r.json().catch(() => ({}))
if (!r.ok) { tbody.innerHTML = emptyRow(7, '拉取失败:' + (d.error ?? r.status)); return }
const sel = $('wlCat')
if (sel.dataset.filled !== '1') { for (const c of (d.categories || [])) sel.insertAdjacentHTML('beforeend', `<option value="${esc(c.id)}">${esc(c.label)}(${c.count})</option>`); sel.dataset.filled = '1' }
$('wlInfo').textContent = `清单 ${d.total} 条 | 可导入 ${d.importable} 条 | 匹配 ${d.count} 条 | 显示 ${(d.plugins || []).length} 条 | 目录缓存更新于 ${relTime(d.fetchedAt)}(6 小时内直接复用,不联网)` + (d.stale ? ' | ⚠ 官方站暂不可达,正在使用本地缓存' : '')
if (!d.plugins || d.plugins.length === 0) { tbody.innerHTML = emptyRow(7, '无匹配'); return }
tbody.innerHTML = d.plugins.map((p) => {
const k = WL_KIND[p.importKind] || WL_KIND.source
const dis = p.importKind === 'source'
// 主视觉 = 中文说明(一眼知道这插件干什么);插件名 / npm 包名 / 版本退为副行小字。
const desc = (p.description || '').trim()
const primary = desc !== '' ? desc : p.name
const meta = [
p.name,
p.npm ? p.npm + (p.version ? ' @ ' + p.version : '') : '',
(p.stars || 0) > 0 ? '★ ' + p.stars : '',
].filter(Boolean).join(' · ')
return `<tr${dis ? ' style="opacity:.55"' : ''}>
<td><input type="checkbox" data-wl="${esc(p.id)}" ${dis ? 'disabled' : ''} ${wlSelected.has(p.id) ? 'checked' : ''} /></td>
<td class="wl-cell">
<div class="wl-desc${desc === '' ? ' fallback' : ''}" title="${esc(primary)}">${esc(primary)}</div>
<div class="wl-meta" title="${esc(meta)}">${esc(meta)}</div>
</td>
<td><span class="wl-cat">${esc(p.categoryLabel || p.category) || '—'}</span></td>
<td><span class="badge ${k.cls}">${k.label}</span></td>
<td class="num">${p.downloads || 0}</td>
<td class="act">${p.page ? `<a class="btn-sm wl-link" href="${esc(p.page)}" target="_blank" rel="noreferrer noopener" title="打开官方详情页(完整介绍 / 截图 / 评论)">详情 ↗</a>` : '—'}</td></tr>`
}).join('')
tbody.querySelectorAll('input[data-wl]').forEach((cb) => { cb.onchange = () => { if (cb.checked) wlSelected.add(cb.dataset.wl); else wlSelected.delete(cb.dataset.wl); $('wlSelInfo').textContent = `已选 ${wlSelected.size}` } })
} catch { tbody.innerHTML = emptyRow(7, '拉取失败') }
}
document.querySelectorAll('#pluginTabs .pg-tab').forEach((t) => { t.onclick = () => showTab(t.dataset.tab) })
$('wlSearchBtn').onclick = () => loadWhitelist()
$('wlQ').onkeydown = (e) => { if (e.key === 'Enter') loadWhitelist() }
$('wlCat').onchange = () => loadWhitelist()
$('wlOnlyImportable').onchange = () => loadWhitelist()
$('wlRefreshBtn').onclick = async () => { const b = $('wlRefreshBtn'); b.disabled = true; b.textContent = '正在重新拉取…'; try { await postJson('/api/plugins/whitelist/refresh', {}); wlSelected.clear(); $('wlSelInfo').textContent = '已选 0'; await loadWhitelist(); toast('✓ 目录已重新拉取(缓存已更新)') } catch { toast('重新拉取失败(网络或官方站不可达)') } finally { b.disabled = false; b.textContent = '重新拉取目录' } }
$('wlImportBtn').onclick = async () => {
const names = [...wlSelected]
if (names.length === 0) { toast('请先勾选插件'); return }
if (names.length > 20) { toast('单次最多导入 20 个'); return }
if (!confirm(`将 ${names.length} 个插件投放(收录)到平台?\n\n投放后默认禁用,对用户不生效;用户需在实例「设置 → 功能插件」里自行启用。`)) return
const btn = $('wlImportBtn'); btn.disabled = true; btn.textContent = '导入中…'
$('wlResult').innerHTML = ''
try {
const r = await postJson('/api/plugins/whitelist/import', { names })
const d = await r.json().catch(() => ({}))
const results = d.results || []
const ok = results.filter((x) => x.ok)
const fail = results.filter((x) => !x.ok)
toast(`投放完成:成功 ${ok.length} / ${names.length}` + (fail.length ? `,失败 ${fail.length}` : ''))
if (ok.length > 0 && fail.length === 0) {
$('wlResult').innerHTML = `<div style="border:1px solid var(--bp-border);border-left:3px solid #1a7f37;border-radius:8px;padding:12px 14px;background:#f4fbf7">
<div style="font-size:13px;color:var(--bp-dim);line-height:1.6">✓ 已投放 <b style="color:var(--bp-text)">${ok.length}</b> 个插件到平台(默认禁用,用户未启用前不生效)。切到 <b style="color:var(--bp-text)">「手动添加 / 管理」</b> 可查看或删除。</div></div>`
}
if (fail.length) {
$('wlResult').innerHTML = `<div style="border:1px solid var(--bp-border);border-left:3px solid var(--bp-danger);border-radius:8px;padding:12px 14px;background:#fff8f6">
<div style="font-size:13px;font-weight:600;color:var(--bp-danger);margin-bottom:6px">失败 ${fail.length} 个</div>
${fail.map((x) => `<div style="font-size:13px;color:var(--bp-dim);margin:4px 0;line-height:1.6"><b style="color:var(--bp-text)">${esc(x.id)}</b>:${esc(x.error)}</div>`).join('')}
</div>`
}
for (const x of results) if (x.ok) wlSelected.delete(x.id)
$('wlSelInfo').textContent = `已选 ${wlSelected.size}`
await loadWhitelist(); await load()
} catch { toast('投放失败:网络错误') } finally { btn.disabled = false; btn.textContent = '导入到平台' }
}
/**
* 上传投放。`trust=false` 走默认路径(命中 P0 即被拒绝并回显详情);
* `trust=true` 表示 admin 已逐条确认并显式声明信任 —— 平台放行并写审计。
*/
async function doUpload(file, trust) {
const input = $('pluginFile'); const btn = $('pluginUploadBtn')
const base64 = await readAsBase64(file)
btn.disabled = true; btn.textContent = trust ? '投放中…' : '检测中…'
$('pluginScanBox').innerHTML = ''
try {
const payload = { file: base64, filename: file.name }
if (trust) payload.trust = { confirmed: true, reason: ($('trustReason') && $('trustReason').value.trim()) || '' }
const res = await postJson('/api/plugins/business', payload)
const data = await res.json().catch(() => ({}))
if (res.status === 409 && (data.error === 'scan_blocked' || data.error === 'compat_incompatible')) { renderScanBlocked(file, data); return }
if (!res.ok) { toast('上传失败:' + (data.error ?? res.status)); return }
toast((data.replaced ? '✓ 已替换' : '✓ 已投放') + '功能插件「' + data.plugin.name + '」' + (data.trustedOverride ? '(已记录信任声明)' : ''))
input.value = ''; await load()
} catch { toast('上传失败:网络错误') } finally { btn.disabled = false; btn.textContent = '上传 / 替换' }
}
/**
* 渲染拒绝详情 + 显式信任入口。
* 两类拒绝共用(信息给足,让 admin 真的看到自己信任了什么):
* · `scan_blocked` —— 安全检测命中 P0(档案 66)
* · `compat_incompatible` —— 与平台 dsh 版本不兼容(档案 71)
*/
function renderScanBlocked(file, data) {
const isCompat = data.error === 'compat_incompatible'
const list = isCompat ? ((data.compat && data.compat.findings) || []) : (data.blocked || [])
const warn = (data.warnings || []).length
const rows = list.map((b) => `<li style="margin:3px 0"><code style="font-size:12px">${esc(isCompat ? (b.pkg + (b.file ? ' ← ' + b.file : '')) : b.file)}</code> — ${esc(isCompat ? b.detail : b.why)}</li>`).join('')
const title = isCompat
? `与当前平台 dsh 版本不兼容,已拒绝投放(共 ${list.length} 条依据)`
: `安全检测命中 P0 规则,已拒绝投放(共 ${list.length} 处)`
$('pluginScanBox').innerHTML = `<div style="border:1px solid var(--bp-border);border-left:3px solid var(--bp-danger);border-radius:8px;padding:12px 14px;background:#fff8f6;margin-top:12px">
<div style="font-size:13px;font-weight:600;color:var(--bp-danger);margin-bottom:6px">${title}</div>
<div style="font-size:13px;color:var(--bp-dim);line-height:1.7;margin-bottom:8px">${esc(data.message || '')}</div>
<ul style="margin:0 0 10px 18px;padding:0;font-size:13px;color:var(--bp-text)">${rows}</ul>
${warn ? `<div style="font-size:12px;color:var(--bp-dim);margin-bottom:8px">另有 ${warn} 条 P1 告警(不阻断)</div>` : ''}
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input id="trustReason" placeholder="信任理由(可选,会记入审计)" style="flex:1;min-width:200px;padding:7px 10px;border:1px solid var(--bp-border);border-radius:8px;font-size:13px" />
<button class="btn btn-primary" id="trustConfirmBtn">我已逐条确认,信任并投放</button>
</div>
<div style="font-size:12px;color:var(--bp-dim);margin-top:6px;line-height:1.6">⚠ 只在确认命中确属误报或风险可控时继续。平台会记录「谁 / 何时 / 命中什么 / 理由」。</div>
</div>`
$('trustConfirmBtn').onclick = () => doUpload(file, true)
}
$('pluginUploadBtn').onclick = async () => {
const input = $('pluginFile'); const file = input.files[0]
if (!file) { toast('请先选择 .tgz 文件'); return }
if (!file.name.toLowerCase().endsWith('.tgz')) { toast('仅支持 .tgz 文件'); return }
if (file.size > 150 * 1024 * 1024) { toast('文件过大(>150MB)'); return }
await doUpload(file, false)
}
$('pluginTbody').onclick = async (event) => {
const btn = event.target.closest('button[data-del]'); if (!btn) return
if (!confirm(`删除已投放插件「${btn.dataset.del}」?\n\n将从平台移除;已启用它的用户实例会失去该插件。`)) return
const res = await fetch('/api/plugins/business/' + encodeURIComponent(btn.dataset.del), { method: 'DELETE' })
if (res.ok) { toast('✓ 已删除'); await load() } else toast('删除失败')
}
// 初始 tab:读 hash 后缀(#/plugins/manual),默认「官方推荐插件」
const seg = (location.hash.replace(/^#/, '').split('/').filter(Boolean))[1]
showTab(seg === 'manual' ? 'manual' : 'official')
await load(); await loadWhitelist()
}
/* ================= 运行环境(#/runtime) ================= */
async function renderRuntime() {
return `${pageHead('运行环境', '实例共享的运行时与工具(服务器装一次,全部用户共享)')}
<div class="card" id="rtWrap"><div class="empty">加载中…</div></div>`
}
async function initRuntime() {
const wrap = document.getElementById('rtWrap')
try {
const r = await (await fetch('/api/admin/runtime')).json()
const mb = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB')
const cell = 'padding:8px 10px;border-bottom:1px solid var(--color-border-tertiary,#eee);vertical-align:top'
const mon = 'font-family:ui-monospace,SFMono-Regular,Menlo,monospace'
const rows = r.items.map((i) => `<tr>
<td style="${cell}"><b>${esc(i.name)}</b><div style="font-size:12px;color:var(--bp-dim)">${esc(i.group)} · ${esc(i.kind)}</div></td>
<td style="${cell};${mon};font-size:12px">${esc(String(i.version).slice(0, 60))}</td>
<td style="${cell};font-size:12px">${esc(i.source)}</td>
<td style="${cell};${mon};font-size:12px">${esc(i.script)}</td>
<td style="${cell};font-size:12px">${i.removable ? '可卸载' : '平台必备'}</td>
</tr>`).join('')
const drift = r.drift && r.drift.length
? `<div style="margin:10px 0;padding:10px 12px;border-radius:8px;background:#FCEBEB;color:#501313;font-size:13px">
⚠️ 检测到版本漂移(与基线不一致):${r.drift.map(esc).join(';')}<br>
若是有意升级,请跑 <code>${esc(r.baselineScript)}</code> 刷新基线。</div>`
: `<div style="margin:10px 0;padding:10px 12px;border-radius:8px;background:#E1F5EE;color:#04342C;font-size:13px">
✅ 版本与基线一致${r.baseline && r.baseline.acceptedAt ? '(基线时间 ' + esc(String(r.baseline.acceptedAt).slice(0, 19)) + ')' : ''}</div>`
wrap.innerHTML = `
<div style="font-size:13px;color:var(--bp-dim);margin-bottom:12px">${esc(r.note)}</div>
${drift}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr>
<th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--color-border-secondary,#ddd)">名称</th>
<th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--color-border-secondary,#ddd)">版本</th>
<th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--color-border-secondary,#ddd)">来源</th>
<th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--color-border-secondary,#ddd)">安装/升级脚本</th>
<th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--color-border-secondary,#ddd)">可卸载</th>
</tr></thead><tbody>${rows}</tbody>
</table>
<div style="margin-top:16px;font-size:13px;line-height:1.9">
<b>目录</b>:<code>${esc(r.runtimeDir.path)}</code> 
<b>体积</b>:${mb(r.runtimeDir.bytes)} 
<b>最近变更</b>:${esc(String(r.runtimeDir.installedAt).slice(0, 19))}
</div>
<details style="margin-top:12px;font-size:13px">
<summary style="cursor:pointer">升级 / 卸载 / 迁移怎么做?</summary>
<div style="margin-top:8px;line-height:1.9">
· <b>升级</b>:改脚本里的固定版本号 → 重跑对应安装脚本(幂等 + 官方 sha256 校验)→
再跑 <code>${esc(r.baselineScript)}</code> 刷新版本基线,否则页面会一直提示漂移。<br>
· <b>卸载</b>:删 <code>/usr/local/bin/&lt;名字&gt;</code> 软链即可("平台必备"项请勿删);<br>
· <b>迁移</b>:整个 <code>${esc(r.runtimeDir.path)}</code> 就是一个打包单元 ——
<code>tar czf dsh-runtime.tar.gz -C /usr/local dsh-runtime</code>,
目标机解压回原位后重跑两个安装脚本(只重建软链)。<br>
· 实例内 <code>/usr</code> 只读 → 用户<b>无法</b>自行安装或修改,这里的变更对全部用户立即生效。
</div>
</details>
<details style="margin-top:12px;font-size:13px">
<summary style="cursor:pointer">安装清单原文(SHARED-TOOLS.md)</summary>
<pre style="margin-top:8px;white-space:pre-wrap;font-size:12px">${esc(r.manifest)}</pre>
</details>`
} catch (err) {
wrap.innerHTML = '<div class="empty">加载失败:' + esc(err.message) + '</div>'
}
}
/* ================= 路由分发 ================= */
const PAGES = { runtime: [renderRuntime, initRuntime], files: [renderFiles, initFiles], keys: [renderKeys, initKeys], users: [renderUsers, initUsers], skills: [renderSkills, initSkills], plugins: [renderPlugins, initPlugins] }
let isAdmin = false
async function router() {
const name = parseHash(); const view = $('view')
if (!name || !PAGES[name]) {
updateCrumbs('')
view.className = ''
view.innerHTML = renderHome()
view.querySelectorAll('.nav-card').forEach((c) => c.addEventListener('click', () => { location.hash = c.dataset.hash }))
return
}
updateCrumbs(name)
view.className = 'page-' + name
const [render, init] = PAGES[name]
view.innerHTML = await render()
const back = view.querySelector('[data-back]')
if (back) back.addEventListener('click', () => { location.hash = '#/' })
try { await init() } catch (err) { console.error('[portal]', name, err); view.innerHTML = '<div class="empty">渲染失败:' + esc(err.message) + '</div>' }
}
window.addEventListener('hashchange', router)
$('btnHome').addEventListener('click', () => { location.hash = '#/' })
$('logoutBtn').addEventListener('click', async () => { await fetch('/api/auth/logout', { method: 'POST' }); location.href = '/login.html' })
/* ================= init ================= */
;(async function init() {
const user = await me()
if (!user) { location.href = '/login.html'; return }
if (user.role !== 'admin') {
// 普通用户不进门户,直接进入实例会话
try {
const res = await fetch('/api/dsh/enter', { method: 'POST' })
const body = await res.json().catch(() => ({}))
if (res.ok && body.kind === 'session' && body.url) { location.href = body.url; return }
} catch {}
}
isAdmin = user.role === 'admin'
$('who').textContent = user.username + (isAdmin ? '(管理员)' : '')
await router()
})()
</script>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<!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" />
<style>
/* 档案 81 · R2-UI:提交忙碌态(点击后立刻可见;对齐 06-工作台UI规范 的按钮/圆角/主色) */
.btn[disabled] { opacity: .72; cursor: not-allowed; }
.btn.is-busy { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
.btn .sp { width: 14px; height: 14px; flex: 0 0 14px; border: 2px solid rgba(255,255,255,.45);
border-top-color: #fff; border-radius: 50%; animation: btnsp .7s linear infinite; }
@keyframes btnsp { to { transform: rotate(360deg); } }
.msg { min-height: 20px; }
@media (prefers-reduced-motion: reduce) { .btn .sp { animation: none; } }
</style>
</head>
<body class="auth-bg">
<div class="auth-card">
<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">注册后需管理员审核方可登录</p>
<form id="register">
<div class="field">
<label for="username">用户名(3–32 位字母/数字/下划线/连字符)</label>
<input id="username" name="username" autocomplete="username" required />
</div>
<div class="field">
<label for="password">密码(至少 8 位)</label>
<input id="password" name="password" type="password" autocomplete="new-password" required />
</div>
<button class="btn primary" type="submit">注 册</button>
<p class="msg" id="msg"></p>
</form>
<p class="auth-alt">已有账号?<a href="/login.html">登录</a></p>
</div>
<script>
const form = document.getElementById('register')
const msg = document.getElementById('msg')
// 档案 81 · R2-UI:任何提交都要有"看得见"的忙碌态(否则几秒内用户会以为没反应)
function setBusy(busy, label) {
const btn = form.querySelector('button[type=submit]')
if (!btn) return
if (busy) {
if (!btn.dataset.idle) btn.dataset.idle = btn.innerHTML
btn.disabled = true
btn.classList.add('is-busy')
btn.innerHTML = '<span class="sp"></span><span>' + (label || '处理中…') + '</span>'
} else {
btn.disabled = false
btn.classList.remove('is-busy')
if (btn.dataset.idle) btn.innerHTML = btn.dataset.idle
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault()
msg.textContent = ''
msg.className = 'msg err'
setBusy(true, '注册中…')
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: form.username.value, password: form.password.value }),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
msg.textContent = body.error === 'username_taken' ? '用户名已被占用' : body.message || '注册失败'
return
}
msg.className = 'msg ok'
msg.textContent = '注册成功,请等待管理员审核后登录。'
setBusy(false)
form.querySelector('button[type=submit]').disabled = true
form.reset()
})
</script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="0;url=/portal.html#/skills" />
<title>跳转中…</title>
</head>
<body>
<script>location.replace('/portal.html#/skills')</script>
</body>
</html>
+159
View File
@@ -0,0 +1,159 @@
<!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="stylesheet" href="/design.css" />
<style>
/* 档案 77「AI 核心」加载体 —— 与实例页恢复浮层**同一套视觉语言**(配色跟随平台主题) */
.wk-orb{position:relative;width:86px;height:86px;margin:2px auto 16px}
.wk-halo{position:absolute;inset:-18px;border-radius:50%;
background:radial-gradient(circle, rgba(83,74,183,.20), rgba(139,124,246,.12) 46%, transparent 70%);
animation:wk-halo 2.6s ease-in-out infinite}
.wk-arc{position:absolute;inset:0;border-radius:50%;
background:conic-gradient(from 0deg, rgba(83,74,183,0) 0deg, rgba(83,74,183,.05) 110deg, #534AB7 296deg, #8B7CF6 342deg, rgba(139,124,246,0) 360deg);
-webkit-mask:radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));
mask:radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));
animation:wk-spin 1.5s linear infinite}
.wk-arc2{position:absolute;inset:13px;border-radius:50%;border:1px dashed rgba(139,124,246,.42);
animation:wk-spin-rev 7s linear infinite}
.wk-ring{position:absolute;inset:24px;border-radius:50%;border:1px solid rgba(83,74,183,.18)}
.wk-core{position:absolute;left:50%;top:50%;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;
background:radial-gradient(circle, #ffffff 0%, #6B5FD6 46%, rgba(83,74,183,0) 74%);
box-shadow:0 0 12px rgba(83,74,183,.55), 0 0 30px rgba(139,124,246,.35);
animation:wk-core 1.6s ease-in-out infinite}
.wk-dots{position:absolute;inset:0;animation:wk-spin 3.4s linear infinite}
.wk-dots i{position:absolute;left:50%;top:-2px;width:5px;height:5px;margin-left:-2.5px;border-radius:50%;
background:#8B7CF6;box-shadow:0 0 8px #8B7CF6;animation:wk-dot 1.4s ease-in-out infinite}
.wk-dots i:nth-child(2){top:auto;bottom:-2px;animation-delay:.35s;background:#534AB7;box-shadow:0 0 8px #534AB7}
.wk-dots i:nth-child(3){left:-2px;top:50%;margin:-2.5px 0 0 0;animation-delay:.7s;background:#4FBFA8;box-shadow:0 0 8px #4FBFA8}
@keyframes wk-spin{to{transform:rotate(360deg)}}
@keyframes wk-spin-rev{to{transform:rotate(-360deg)}}
@keyframes wk-core{0%,100%{transform:scale(.82);opacity:.6}50%{transform:scale(1.12);opacity:1}}
@keyframes wk-halo{0%,100%{opacity:.45;transform:scale(.96)}50%{opacity:.9;transform:scale(1.04)}}
@keyframes wk-dot{0%,100%{opacity:.25}50%{opacity:1}}
@keyframes wkb{0%{margin-left:-40%}100%{margin-left:100%}}
.wk-bar{height:4px;border-radius:999px;background:rgba(127,127,127,.18);overflow:hidden;margin:16px 0 8px}
.wk-bar > i{display:block;height:100%;width:40%;border-radius:999px;
background:linear-gradient(90deg, rgba(83,74,183,0), #534AB7 45%, #8B7CF6 70%, rgba(83,74,183,0));
animation:wkb 1.6s ease-in-out infinite}
.wk-label{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.16em;
color:#8a8f98;text-transform:uppercase;margin-bottom:10px}
.wk-step{font-size:13px;color:#8a8f98;min-height:20px;text-align:center}
.wk-acts{margin-top:16px;text-align:center}
.wk-note{margin-top:14px;font-size:12px;line-height:1.7;color:#8a8f98;text-align:center}
@media (prefers-reduced-motion: reduce){
.wk-arc,.wk-arc2,.wk-dots,.wk-dots i,.wk-core,.wk-halo,.wk-bar > i{animation:none!important}
}
</style>
</head>
<body>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px">
<div class="card" style="max-width:420px;width:100%;text-align:center">
<div class="wk-orb" id="spin">
<div class="wk-halo"></div><div class="wk-arc"></div><div class="wk-arc2"></div>
<div class="wk-ring"></div><div class="wk-core"></div>
<div class="wk-dots"><i></i><i></i><i></i></div>
</div>
<h1 class="page-title" style="margin-bottom:6px">正在启动你的工作区…</h1>
<p class="page-sub" style="margin-bottom:0">空闲一段时间后实例会被回收,首次访问需要几秒重新拉起。</p>
<div class="wk-bar"><i></i></div>
<div class="wk-step" id="step">正在连接…</div>
<div class="wk-label">dsh · auto recovery</div>
<div class="wk-acts" id="acts" style="display:none">
<button class="btn primary" id="retry">重试</button>
</div>
<p class="wk-note" id="note">
若长时间无反应,可点「重试」。本页会自动跳转,无需手动刷新。
</p>
</div>
</div>
<script>
(function () {
var NEXT = new URLSearchParams(location.search).get('next') || ''
var step = document.getElementById('step')
var acts = document.getElementById('acts')
var note = document.getElementById('note')
var tried = 0
var MAX = 3
function show(text) { step.textContent = text }
function fail(msg) {
document.getElementById('spin').style.display = 'none'
acts.style.display = 'block'
note.textContent = msg
show('启动失败')
}
// 只允许跳回 *.alotbuy.com,避免被 next 参数带偏
function safeNext(u) {
try { var x = new URL(u, location.href); return /(^|\.)alotbuy\.com$/.test(x.hostname) ? x.href : '' } catch (e) { return '' }
}
async function wake() {
tried += 1
show(tried === 1 ? '正在拉起实例(第 1 次,通常 5–15 秒)…' : '正在重试(第 ' + tried + ' 次)…')
try {
// 复用平台已有的登录直达接口:它内部会 launch(实例不在则拉起)并返回带 token 的直达 URL
var res = await fetch('/api/dsh/enter', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
})
if (res.status === 401) { location.replace('/login.html'); return }
var j = await res.json().catch(function () { return {} })
var url = j.url || j.redirect || ''
if (res.ok && url) {
show('已就绪,正在跳转…')
location.replace(url)
return
}
if (res.ok && !url) { // 已登录但需要重走门户
location.replace('/portal.html')
return
}
if (j.error === 'instance_circuit_open') { // 档案 78:被熔断冷却,重试没有意义
var waitMin = Math.max(1, Math.ceil((j.retryAfterMs || 0) / 60000))
fail('实例连续崩溃,平台已暂停自动拉起(约 ' + waitMin + ' 分钟后可重试)。若反复出现,请联系管理员查看维护日志。')
return
}
throw new Error(j.error || ('HTTP ' + res.status))
} catch (err) {
if (tried < MAX) {
show('未就绪,2 秒后自动重试…')
setTimeout(wake, 2000)
} else {
fail('多次尝试仍未就绪:' + (err && err.message ? err.message : '未知错误'))
}
}
}
document.getElementById('retry').addEventListener('click', function () {
tried = 0
document.getElementById('spin').style.display = ''
acts.style.display = 'none'
note.textContent = '本页会自动跳转,无需手动刷新。'
wake()
})
// 若 nginx 带了 next(原始目标地址),唤醒后优先回那里
var saved = safeNext(NEXT)
if (saved) { sessionStorage.setItem('dsh.wake.next', saved) }
var t = setInterval(function () {
var n = sessionStorage.getItem('dsh.wake.next')
if (n && n !== NEXT) {
clearInterval(t)
// 直接以原始目标为跳转终点(进入后再由实例侧校验 token)
NEXT = n
}
}, 1000)
setTimeout(function () { clearInterval(t) }, 30000)
wake()
})()
</script>
</body>
</html>