fix: stabilize console scrolling and protect administrator login
This commit is contained in:
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user