fix: stabilize console scrolling and protect administrator login

This commit is contained in:
2026-09-30 00:09:01 +08:00
parent 9a15406f76
commit f56f233704
32 changed files with 1308 additions and 86 deletions

View File

@@ -12,6 +12,26 @@
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 2800);
}
let loginAuditCursor = '', loginAuditPage = 1, loginAuditLoading = false;
async function loadLoginAudit(first = false) {
if (loginAuditLoading) return;
loginAuditLoading = true;
$('login-audit-first').disabled = true; $('login-audit-next').disabled = true;
text('login-audit-status', '正在读取…');
try {
const cursor = first ? '' : loginAuditCursor;
const page = extractData(await request(`/console/auth/audit${cursor ? `?before=${encodeURIComponent(cursor)}` : ''}`));
const labels = { SUCCESS: '登录成功', INVALID: '账号或密码错误', LOCKED: '临时锁定', ACCOUNT_LIMIT: '账号限流', IP_LIMIT: 'IP 限流', PENDING: '校验中', ERROR: '未完成 / 服务异常' };
$('login-audit-rows').innerHTML = (page.items || []).map(row => `<tr><td>${escapeHTML(formatTime(row.created_at))}</td><td>${escapeHTML(row.username)}</td><td>${escapeHTML(row.remote_ip)}</td><td>${escapeHTML(labels[row.outcome] || row.outcome)}</td><td>${escapeHTML(formatTime(row.retry_at))}</td></tr>`).join('');
loginAuditPage = first ? 1 : loginAuditPage + 1; loginAuditCursor = page.next || '';
text('login-audit-page', `第 ${loginAuditPage} 页`); text('login-audit-status', page.items?.length ? '' : '暂无登录记录');
} catch (error) { text('login-audit-status', `读取失败:${error.message}`); }
finally { loginAuditLoading = false; $('login-audit-first').disabled = false; $('login-audit-next').disabled = !loginAuditCursor; }
}
$('login-audit-open').addEventListener('click', () => { $('login-audit-dialog').showModal(); loadLoginAudit(true); });
$('login-audit-close').addEventListener('click', () => $('login-audit-dialog').close());
$('login-audit-first').addEventListener('click', () => loadLoginAudit(true));
$('login-audit-next').addEventListener('click', () => loadLoginAudit());
function formatTime(value) {
if (!value) return '—';
const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value);
@@ -137,6 +157,7 @@
appendScopeOptions($('scope-application'), scopeUI.applications, scope.entry.namespace_id, '', 'application');
appendScopeOptions($('scope-queue'), queues, state.queue, scopeUI.nextQueue, 'queue');
$('scope-application').disabled = !scope.can_switch;
$('login-audit-open').hidden = !scope.can_switch;
$('scope-queue').disabled = !queues.length;
if (scope.can_switch) {
try { await loadScopeApplications(); } catch (error) { text('scope-error', '应用目录读取失败:' + error.message); }

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN" class="console-document">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -42,6 +42,7 @@
<div class="topbar-actions"><label class="auto-refresh"><input type="checkbox" id="auto-refresh" checked>自动刷新</label>
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
<span class="last-updated" id="last-updated" aria-live="polite">尚未刷新</span>
<button class="secondary-button compact" id="login-audit-open" hidden>登录审计</button>
<button class="icon-button" id="logout-button" title="退出登录" aria-label="退出登录">↪</button>
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
</div>
@@ -357,6 +358,14 @@
</aside>
</div>
<div class="toast" id="toast" role="status"></div>
<dialog id="login-audit-dialog" class="access-dialog login-audit-dialog" aria-labelledby="login-audit-title">
<div class="access-dialog-top"><button type="button" class="icon-button" id="login-audit-close" aria-label="关闭登录审计">×</button></div>
<h2 id="login-audit-title">登录审计</h2>
<p class="panel-description">记录登录成功、失败、临时锁定与限流。每页 10 条,时间为当前时区。</p>
<p id="login-audit-status" role="status"></p>
<div class="table-wrap"><table><thead><tr><th>时间</th><th>账号</th><th>来源 IP</th><th>结果</th><th>可重试时间</th></tr></thead><tbody id="login-audit-rows"></tbody></table></div>
<div class="audit-pagination"><button class="secondary-button compact" id="login-audit-first">最新记录</button><span id="login-audit-page">第 1 页</span><button class="secondary-button compact" id="login-audit-next" disabled>下一页 →</button></div>
</dialog>
<dialog id="access-dialog" class="access-dialog">
<div class="access-dialog-top"><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
<h2 id="access-form-title">新建应用</h2><p class="panel-description" id="access-form-note">为应用创建独立的环境、队列与调用凭据。</p>

View File

@@ -69,7 +69,31 @@
// 初次访问时 URL 片段不会发给服务端,浏览器会把它保留在跳转后的登录地址上。
if (!returnTo.includes('#') && /^#\/(overview|tasks|schedules|workflows|subscriptions|retention|archives|applications)$/.test(location.hash)) returnTo += location.hash;
fetch('/console/auth/session', { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' }).then(response => response.json()).then(body => { if (body.authenticated) location.replace(returnTo); }).catch(() => {});
form.addEventListener('submit', async event => { event.preventDefault(); error.textContent = ''; submit.disabled = true; submit.textContent = '正在登录…'; try { const response = await fetch('/console/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username: username.value.trim(), password: password.value }) }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body?.error?.message || '登录失败,请检查用户名和密码'); location.replace(returnTo); } catch (loginError) { error.textContent = loginError.message || '登录失败,请稍后重试'; password.select(); } finally { submit.disabled = false; submit.textContent = '登录 Orbit →'; } });
let retryUntil = 0, retryTimer;
function showRetry() {
const remaining = Math.max(0, Math.ceil((retryUntil - Date.now()) / 1000));
submit.disabled = remaining > 0;
submit.textContent = remaining ? `请等待 ${Math.floor(remaining / 60)}分 ${remaining % 60}秒` : '登录 Orbit →';
if (!remaining) clearInterval(retryTimer);
}
form.addEventListener('submit', async event => {
event.preventDefault(); if (Date.now() < retryUntil) return;
error.textContent = ''; submit.disabled = true; submit.textContent = '正在登录…';
try {
const response = await fetch('/console/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username: username.value.trim(), password: password.value }) });
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const seconds = Number(body?.error?.retry_after_seconds);
if (response.status === 429 && Number.isFinite(seconds) && seconds > 0) {
retryUntil = Date.now() + seconds * 1000;
clearInterval(retryTimer); retryTimer = setInterval(showRetry, 1000);
}
throw new Error(body?.error?.message || '登录失败,请稍后重试');
}
location.replace(returnTo);
} catch (loginError) { error.textContent = loginError.message || '登录失败,请稍后重试'; password.select(); }
finally { showRetry(); }
});
})();
</script>
</body>

View File

@@ -136,14 +136,18 @@ input:focus-visible,textarea:focus-visible { outline:none; }
@media(max-width:1200px){.scope-field{flex:1;min-width:160px}.scope-note{margin-left:0}}
@media(max-width:920px){.application-scope{gap:12px}.scope-note{flex-basis:100%;margin-top:-4px}.list-filters{grid-template-columns:145px minmax(160px,1fr)}.filter-more{grid-column:1;grid-row:2}.filter-actions{grid-column:2;grid-row:2}.login-brand{padding:40px}.login-intro h1{font-size:40px}}
@media(max-width:640px){.application-scope{padding:14px;gap:12px}.scope-field{flex:1 1 100%;min-width:0}.scope-note{display:none}.scope-field>span{margin-bottom:5px}.list-filters{padding:16px;grid-template-columns:1fr 1fr;gap:14px}.list-filters>label{grid-column:1/-1;min-width:0!important}.filter-more{grid-column:1;grid-row:3}.filter-actions{grid-column:2;grid-row:3;gap:7px}.filter-actions button{flex:1;padding:0 11px}.filter-dates{grid-row:4;gap:14px}.filter-dates label{flex:1 1 100%;min-width:0}.filter-dates small{padding:0}.list-pagination{padding:14px 10px;gap:12px}.list-pagination>div{gap:6px}.list-pagination .select-trigger{width:65px;height:32px}.login-brand{padding:28px}.login-brand>*{width:100%;max-width:380px}.login-intro{margin:30px auto 8px}.login-intro h1{font-size:28px}.login-card{width:100%}}
/* 工作台只滚动内容区,导航和页头保持原位;避免整页滚动、回弹及滚动条切换造成错位。 */
.console-body { height:100dvh; overflow:hidden; overscroll-behavior:none; }
.console-body .app-shell { height:100dvh; min-height:0; overflow:hidden; grid-template-rows:minmax(0,1fr); }
.console-body .sidebar { position:relative; height:100%; min-height:0; overscroll-behavior:contain; }
.console-body .main-content { height:100%; min-height:0; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; scrollbar-gutter:stable; scroll-padding-top:100px; }
/* none 同时关闭边界回弹和滚动传递;contain 仍会让 macOS 上的菜单和内容弹动。
根视口与各实际滚动容器分别设置,保留短屏导航、内容和弹层的正常滚动。 */
.console-document, .console-body { height:100%; overflow:hidden; overscroll-behavior:none; }
.console-body .app-shell { position:fixed; inset:0; height:100%; min-height:0; overflow:hidden; grid-template-rows:minmax(0,1fr); }
.console-body .sidebar { position:relative; height:100%; min-height:0; overscroll-behavior:none; }
.console-body .main-content { height:100%; min-height:0; overflow-y:auto; overflow-x:hidden; overscroll-behavior:none; scrollbar-gutter:stable; scroll-padding-top:100px; }
.console-body .topbar { position:sticky; top:0; z-index:20; background:var(--bg); }
@media(min-width:921px){.access-list-panel{position:sticky;top:100px;max-height:calc(100dvh - 124px);overflow:auto;overscroll-behavior:contain}}
@media(max-width:640px){.console-body .app-shell{display:flex;flex-direction:column}.console-body .sidebar{height:auto;flex:0 0 auto;overflow:visible}.console-body .main-content{flex:1;scroll-padding-top:78px}.nav-list{overscroll-behavior-x:contain}}
.console-body :is(.select-options,.table-wrap,.task-drawer,.access-dialog){overscroll-behavior:none}
@media(min-width:921px){.access-list-panel{position:sticky;top:100px;max-height:calc(100dvh - 124px);overflow:auto;overscroll-behavior:none}}
@media(max-width:640px){.console-body .app-shell{display:flex;flex-direction:column}.console-body .sidebar{height:auto;flex:0 0 auto;overflow:visible}.console-body .main-content{flex:1;scroll-padding-top:78px}.nav-list{overscroll-behavior:none}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
@media(max-width:640px){.data-panel .table-wrap::before{content:"左右滑动查看完整表格 →";display:block;padding:0 15px 10px;color:var(--muted);font-size:10px}.retention-panel{max-width:none}}
.login-audit-dialog{width:min(960px,calc(100vw - 32px))}.login-audit-dialog .table-wrap{margin:20px 0}.login-audit-dialog th,.login-audit-dialog td{padding:12px;text-align:left;white-space:nowrap}.login-audit-dialog .audit-pagination{display:flex;align-items:center;justify-content:space-between;gap:12px}