feat: add database-backed console login
This commit is contained in:
@@ -1,11 +1,8 @@
|
||||
(() => {
|
||||
const state = { token: '', queue: '', base: location.origin, consoleConfig: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
|
||||
const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
|
||||
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅' };
|
||||
const sessionQueue = sessionStorage.getItem('orbit.console.queue') || '';
|
||||
$('api-url').value = location.origin;
|
||||
$('queue-id').value = sessionQueue;
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||
@@ -32,42 +29,49 @@
|
||||
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
function redirectToLogin() {
|
||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||
location.replace(`/console/login?return=${encodeURIComponent(returnTo.startsWith('/console') ? returnTo : '/console/')}`);
|
||||
}
|
||||
async function ensureSession() {
|
||||
const response = await fetch('/console/auth/session', { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' });
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!body.authenticated) { redirectToLogin(); return false; }
|
||||
state.authenticated = true; return true;
|
||||
}
|
||||
async function request(path, options = {}) {
|
||||
const headers = { Accept: 'application/json', ...(options.headers || {}) };
|
||||
if (state.token) headers.Authorization = `Bearer ${state.token}`;
|
||||
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers });
|
||||
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
const raw = await response.text(); let body = {};
|
||||
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
||||
if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); }
|
||||
if (!response.ok) throw new Error(body?.error?.message || body?.message || `HTTP ${response.status}`);
|
||||
return body;
|
||||
}
|
||||
async function loadConsoleConfig() {
|
||||
try {
|
||||
const response = await fetch('/console/config', { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
||||
const response = await fetch('/console/config', { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' });
|
||||
if (!response.ok) return;
|
||||
state.consoleConfig = await response.json();
|
||||
const defaultQueue = String(state.consoleConfig?.default_queue_id || '').trim();
|
||||
if (!state.queue && defaultQueue) {
|
||||
state.queue = defaultQueue;
|
||||
$('queue-id').value = defaultQueue;
|
||||
sessionStorage.setItem('orbit.console.queue', defaultQueue);
|
||||
text('queue-hint', '已使用服务端默认队列配置;也可选择凭据 JSON 覆盖。');
|
||||
renderConnectionSummary();
|
||||
}
|
||||
} catch {
|
||||
// 连接表单仍可使用手工 API 地址和凭据,不因可选配置失败而阻断页面。
|
||||
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
|
||||
}
|
||||
}
|
||||
function renderConnectionSummary() {
|
||||
text('summary-api', state.base || location.origin);
|
||||
text('summary-api-hint', state.connected ? '接口响应正常,按 10 秒周期刷新' : 'API 地址自动跟随当前页面');
|
||||
text('summary-queue', state.queue ? shortID(state.queue) : '未配置');
|
||||
text('summary-credential', state.token ? '已载入' : '未载入');
|
||||
text('summary-note', state.token && state.queue ? '已具备读取业务数据的连接条件。' : '服务状态会自动检查;需要业务数据时再加载凭据。');
|
||||
text('summary-credential', state.authenticated ? '账号会话' : '未登录');
|
||||
text('summary-note', state.authenticated ? '当前页面已通过账号会话访问服务,数据按账号权限显示。' : '登录后加载业务数据。');
|
||||
}
|
||||
function setConnection(connected, detail = '') {
|
||||
state.connected = connected;
|
||||
const authenticated = connected && Boolean(state.token && state.queue);
|
||||
const authenticated = connected && state.authenticated;
|
||||
$('connection-state').textContent = authenticated ? '已连接' : (connected ? '服务在线' : '未连接');
|
||||
$('connection-state').classList.toggle('ready', connected);
|
||||
$('side-status').textContent = authenticated ? '环境已连接' : (connected ? '服务在线' : '等待连接');
|
||||
@@ -172,8 +176,6 @@
|
||||
return `console-${prefix}-${id}`.slice(0, 128);
|
||||
}
|
||||
function terminalState(value) { return ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(value); }
|
||||
function openConnectionSettings() { $('connection-settings-backdrop').hidden = false; document.body.classList.add('drawer-open'); $('api-url').focus(); }
|
||||
function closeConnectionSettings() { $('connection-settings-backdrop').hidden = true; document.body.classList.remove('drawer-open'); }
|
||||
function showTaskDrawer() { $('task-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
||||
function closeTaskDrawer() { $('task-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeActionForm(); }
|
||||
function renderDetailList(id, items, emptyText, renderItem) {
|
||||
@@ -204,7 +206,7 @@
|
||||
}
|
||||
async function openTaskDetail(id) {
|
||||
if (!id) return;
|
||||
if (!state.token) { showToast('请先连接具备 tasks:read 权限的凭据'); return; }
|
||||
if (!state.authenticated) { redirectToLogin(); return; }
|
||||
showTaskDrawer(); text('detail-title', '正在读取任务…'); text('detail-id', shortID(id)); text('detail-state', '读取中'); $('detail-actions').hidden = true;
|
||||
$('detail-attempts').innerHTML = '<div class="detail-loading">正在读取尝试历史…</div>'; $('detail-operations').innerHTML = '<div class="detail-loading">正在读取操作审计…</div>';
|
||||
const encoded = encodeURIComponent(id); const [taskResult, attemptsResult, operationsResult] = await Promise.allSettled([request(`/v1/tasks/${encoded}`), request(`/v1/tasks/${encoded}/attempts?limit=20`), request(`/v1/tasks/${encoded}/operations?limit=20`)]);
|
||||
@@ -253,7 +255,7 @@
|
||||
}
|
||||
async function openScheduleDetail(id) {
|
||||
if (!id) return;
|
||||
if (!state.token) { showToast('请先连接具备 schedules:read 权限的凭据'); return; }
|
||||
if (!state.authenticated) { redirectToLogin(); return; }
|
||||
showScheduleDrawer(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', shortID(id)); $('schedule-actions').hidden = true; $('schedule-occurrences').innerHTML = '<div class="detail-loading">正在读取触发记录…</div>';
|
||||
const encoded = encodeURIComponent(id); const [scheduleResult, occurrencesResult] = await Promise.allSettled([request(`/v1/schedules/${encoded}`), request(`/v1/schedules/${encoded}/occurrences?limit=20`)]);
|
||||
if (scheduleResult.status !== 'fulfilled') { text('schedule-detail-title', '计划读取失败'); text('schedule-action-note', scheduleResult.reason?.message || '无法读取计划详情'); return; }
|
||||
@@ -393,7 +395,7 @@
|
||||
}
|
||||
async function openWorkflowDetail(id) {
|
||||
if (!id) return;
|
||||
if (!state.token) { showToast('请先连接具备 workflows:read 权限的凭据'); return; }
|
||||
if (!state.authenticated) { redirectToLogin(); return; }
|
||||
showWorkflowDrawer(); closeWorkflowAction(); closeWorkflowRecovery(); $('workflow-actions').hidden = true; $('workflow-recovery-block').hidden = true; text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', shortID(id)); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '<div class="detail-loading">正在读取步骤…</div>'; $('workflow-events').innerHTML = '<div class="detail-loading">正在读取事件…</div>';
|
||||
const encoded = encodeURIComponent(id); const [workflowResult, stepsResult, eventsResult] = await Promise.allSettled([request(`/v1/workflows/${encoded}`), request(`/v1/workflows/${encoded}/steps`), request(`/v1/workflows/${encoded}/events?limit=20`)]);
|
||||
if (workflowResult.status !== 'fulfilled') { text('workflow-detail-title', '流程读取失败'); text('workflow-detail-note', workflowResult.reason?.message || '无法读取流程详情'); renderWorkflowSteps({}, true); renderWorkflowEvents({}, true); return; }
|
||||
@@ -443,7 +445,7 @@
|
||||
}
|
||||
async function openSubscriptionDetail(id) {
|
||||
if (!id) return;
|
||||
if (!state.token) { showToast('请先连接具备 events:read 权限的凭据'); return; }
|
||||
if (!state.authenticated) { redirectToLogin(); return; }
|
||||
showSubscriptionDrawer(); closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', shortID(id)); $('subscription-deliveries').innerHTML = '<div class="detail-loading">正在读取交付历史…</div>';
|
||||
const encoded = encodeURIComponent(id); const [subscriptionResult, deliveriesResult] = await Promise.allSettled([request(`/v1/subscriptions/${encoded}`), request(`/v1/subscriptions/${encoded}/deliveries?limit=20`)]);
|
||||
if (subscriptionResult.status !== 'fulfilled') { text('subscription-detail-title', '订阅读取失败'); text('subscription-detail-note', subscriptionResult.reason?.message || '无法读取订阅详情'); renderSubscriptionDeliveries({}, true); return; }
|
||||
@@ -453,7 +455,7 @@
|
||||
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
|
||||
try {
|
||||
await loadHealth();
|
||||
if (!state.token || !state.queue) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
|
||||
if (!state.authenticated || !state.queue) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
|
||||
const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); const taskItems = extractItems(tasks); renderTasks(taskItems);
|
||||
const optional = await Promise.allSettled([
|
||||
request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
|
||||
@@ -470,23 +472,14 @@
|
||||
} catch (error) { setConnection(false); showToast(`连接失败:${error.message}`); text('last-updated', '刷新失败'); }
|
||||
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
|
||||
}
|
||||
function connect() {
|
||||
state.base = $('api-url').value.trim().replace(/\/$/, '') || location.origin; state.token = $('token').value.trim(); state.queue = $('queue-id').value.trim() || String(state.consoleConfig?.default_queue_id || '').trim();
|
||||
$('queue-id').value = state.queue;
|
||||
sessionStorage.setItem('orbit.console.queue', state.queue);
|
||||
if (!state.token) showToast('服务状态可以直接查看;选择凭据 JSON 后可读取业务数据');
|
||||
else if (!state.queue) showToast('队列会从凭据 JSON 自动读取,也可配置 ORBIT_CONSOLE_QUEUE_ID');
|
||||
closeConnectionSettings();
|
||||
refresh();
|
||||
}
|
||||
$('connect-button').addEventListener('click', connect); $('connect-hero').addEventListener('click', openConnectionSettings); $('connection-settings-button').addEventListener('click', openConnectionSettings); $('connection-settings-inline').addEventListener('click', openConnectionSettings); $('close-connection-settings').addEventListener('click', closeConnectionSettings); $('connection-settings-backdrop').addEventListener('click', event => { if (event.target.id === 'connection-settings-backdrop') closeConnectionSettings(); }); $('refresh-button').addEventListener('click', refresh);
|
||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('connection-settings-backdrop').hidden) closeConnectionSettings(); else if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
||||
async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); }
|
||||
$('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh);
|
||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
||||
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
|
||||
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); });
|
||||
$('reveal-token').addEventListener('click', () => { const field = $('token'); field.type = field.type === 'password' ? 'text' : 'password'; $('reveal-token').textContent = field.type === 'password' ? '显示' : '隐藏'; });
|
||||
$('credential-file').addEventListener('change', event => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const credential = JSON.parse(reader.result); const queue = String(credential.queue_id || state.consoleConfig?.default_queue_id || '').trim(); $('token').value = credential.token || ''; $('queue-id').value = queue; state.queue = queue; state.token = String(credential.token || '').trim(); sessionStorage.setItem('orbit.console.queue', queue); text('queue-hint', queue ? '队列已从凭据 JSON 自动读取;无需手工填写。' : '凭据未附带队列,请配置 ORBIT_CONSOLE_QUEUE_ID。'); renderConnectionSummary(); showToast(queue ? '凭据已读取,队列已自动填充' : '凭据已读取,但缺少队列配置'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); });
|
||||
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
|
||||
window.addEventListener('hashchange', () => setView(currentView(), false));
|
||||
setView(currentView(), false);
|
||||
loadConsoleConfig().finally(() => refresh()); state.timer = setInterval(refresh, 10000);
|
||||
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(() => refresh()); }).catch(() => redirectToLogin());
|
||||
state.timer = setInterval(() => { if (state.authenticated) refresh(); }, 10000);
|
||||
})();
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="topbar-actions">
|
||||
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
|
||||
<span class="last-updated" id="last-updated">尚未刷新</span>
|
||||
<button class="icon-button" id="connection-settings-button" title="连接设置" aria-label="连接设置">⚙</button>
|
||||
<button class="icon-button" id="logout-button" title="退出登录" aria-label="退出登录">↪</button>
|
||||
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -53,7 +53,7 @@
|
||||
<p>任务、定时、流程和交付状态,在一块屏幕上保持有序运转。</p>
|
||||
<div class="hero-actions">
|
||||
<button class="primary-button" data-view="tasks">查看任务 <span>↗</span></button>
|
||||
<button class="quiet-button" id="connect-hero">连接设置</button>
|
||||
<button class="quiet-button" id="logout-hero">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual" aria-hidden="true">
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
<section class="metric-grid" aria-label="运行概览">
|
||||
<article class="metric-card metric-highlight"><div class="metric-top"><span>服务状态</span><span class="metric-icon green">✦</span></div><strong id="metric-health">—</strong><small id="metric-health-sub">等待就绪检查</small><div class="sparkline green-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
||||
<article class="metric-card"><div class="metric-top"><span>任务总览</span><span class="metric-icon purple">◇</span></div><strong id="metric-tasks">—</strong><small id="metric-tasks-sub">连接凭据后加载</small><div class="mini-bar"><i id="task-bar-succeeded"></i><i id="task-bar-running"></i><i id="task-bar-waiting"></i><i id="task-bar-other"></i></div></article>
|
||||
<article class="metric-card"><div class="metric-top"><span>任务总览</span><span class="metric-icon purple">◇</span></div><strong id="metric-tasks">—</strong><small id="metric-tasks-sub">当前会话加载</small><div class="mini-bar"><i id="task-bar-succeeded"></i><i id="task-bar-running"></i><i id="task-bar-waiting"></i><i id="task-bar-other"></i></div></article>
|
||||
<article class="metric-card"><div class="metric-top"><span>流程实例</span><span class="metric-icon blue">⌘</span></div><strong id="metric-workflows">—</strong><small id="metric-workflows-sub">等待工作流权限</small><div class="sparkline blue-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
||||
<article class="metric-card"><div class="metric-top"><span>结果订阅</span><span class="metric-icon orange">⌁</span></div><strong id="metric-subscriptions">—</strong><small id="metric-subscriptions-sub">等待事件权限</small><div class="subscription-meter"><span id="subscription-meter-fill"></span></div></article>
|
||||
</section>
|
||||
@@ -93,11 +93,11 @@
|
||||
</div>
|
||||
</article>
|
||||
<article class="panel connection-summary-panel">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">CONTROL PLANE</span><h2>连接状态</h2></div><span class="connection-state" id="connection-state">未连接</span></div>
|
||||
<div class="panel-heading"><div><span class="panel-kicker">CONTROL PLANE</span><h2>登录状态</h2></div><span class="connection-state" id="connection-state">未连接</span></div>
|
||||
<div class="connection-summary-copy"><span class="connection-summary-icon">↗</span><div><b id="summary-api">当前页面</b><small id="summary-api-hint">API 地址自动跟随当前页面</small></div></div>
|
||||
<div class="connection-summary-grid"><div><span>队列范围</span><b id="summary-queue">自动配置</b></div><div><span>凭据状态</span><b id="summary-credential">未载入</b></div></div>
|
||||
<div class="connection-summary-grid"><div><span>队列范围</span><b id="summary-queue">自动配置</b></div><div><span>登录会话</span><b id="summary-credential">未登录</b></div></div>
|
||||
<p class="connection-summary-note" id="summary-note">服务状态会自动检查;需要业务数据时再加载凭据。</p>
|
||||
<button class="quiet-button" id="connection-settings-inline" type="button">管理连接设置 <span>→</span></button>
|
||||
<button class="quiet-button" id="logout-inline" type="button">退出当前账号 <span>→</span></button>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
@@ -106,7 +106,7 @@
|
||||
<section class="content-grid single-column">
|
||||
<article class="panel wide-panel" id="tasks">
|
||||
<div class="panel-heading"><div><span class="panel-kicker">PULSE / TASKS</span><h2>任务执行流</h2></div><div class="panel-tools"><span class="table-count" id="task-count">0 项</span><button class="text-button" data-view="overview">返回总览 <span>←</span></button></div></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>任务</th><th>处理器</th><th>状态</th><th>修订</th><th>创建时间</th></tr></thead><tbody id="task-table"><tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>还没有任务数据</b><small>连接一个具备 tasks:read 权限的凭据后自动加载</small></td></tr></tbody></table></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>任务</th><th>处理器</th><th>状态</th><th>修订</th><th>创建时间</th></tr></thead><tbody id="task-table"><tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>还没有任务数据</b><small>登录后按账号权限自动加载</small></td></tr></tbody></table></div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
@@ -132,20 +132,6 @@
|
||||
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
||||
</main>
|
||||
</div>
|
||||
<div class="drawer-backdrop" id="connection-settings-backdrop" hidden>
|
||||
<aside class="task-drawer connection-drawer" role="dialog" aria-modal="true" aria-labelledby="connection-settings-title">
|
||||
<div class="drawer-header">
|
||||
<div><span class="panel-kicker">CONTROL PLANE / SETTINGS</span><h2 id="connection-settings-title">连接设置</h2><small class="drawer-id">凭据只在当前浏览器会话中使用</small></div>
|
||||
<button class="icon-button" id="close-connection-settings" type="button" aria-label="关闭连接设置">×</button>
|
||||
</div>
|
||||
<p class="drawer-note connection-settings-intro">API 地址默认使用当前页面;队列优先读取服务端配置或凭据 JSON。这里不会读取数据库,也不会上传凭据。</p>
|
||||
<label class="field-label" for="api-url">API 地址 <small>自动使用当前页面</small></label><div class="input-wrap"><span class="input-prefix">↗</span><input id="api-url" type="url" value="" placeholder="http://127.0.0.1:8080"></div>
|
||||
<label class="field-label" for="queue-id">队列范围 <small>自动读取</small></label><div class="input-wrap"><span class="input-prefix">#</span><input id="queue-id" type="text" autocomplete="off" placeholder="凭据 JSON 或服务端配置会自动填充"></div><div class="field-hint" id="queue-hint">选择凭据 JSON 后自动填充;也可由服务端配置默认队列。</div>
|
||||
<label class="field-label" for="token">Bearer Token</label><div class="input-wrap"><span class="input-prefix">●</span><input id="token" type="password" placeholder="粘贴开发凭据 token"><button class="reveal-button" id="reveal-token" type="button">显示</button></div>
|
||||
<div class="connect-row"><label class="file-button"><input id="credential-file" type="file" accept="application/json">选择凭据 JSON</label><button class="primary-button compact" id="connect-button" type="button">连接 <span>→</span></button></div>
|
||||
<div class="security-note"><span>▣</span><span>当前页面只调用公开 API 和授权接口,不读取数据库。</span></div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
|
||||
<aside class="task-drawer" role="dialog" aria-modal="true" aria-labelledby="detail-title">
|
||||
<div class="drawer-header">
|
||||
|
||||
37
internal/transport/httpserver/console/login.html
Normal file
37
internal/transport/httpserver/console/login.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>登录 Orbit · 星轨控制台</title>
|
||||
<link rel="stylesheet" href="/console/styles.css">
|
||||
<style>
|
||||
html,body{min-height:100%;margin:0}body{display:grid;place-items:center;background:#06101e;color:#dceafb;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.ambient{position:fixed;pointer-events:none}.login-shell{position:relative;width:min(430px,calc(100% - 40px));padding:38px;border:1px solid rgba(156,183,218,.15);border-radius:24px;background:linear-gradient(145deg,rgba(18,37,62,.95),rgba(7,19,34,.97));box-shadow:0 26px 80px rgba(0,0,0,.35)}.login-brand{display:flex;align-items:center;gap:13px;margin-bottom:34px}.login-brand-mark{display:grid;grid-template-columns:repeat(3,5px);align-items:end;gap:3px;width:35px;height:35px;padding:9px;border:1px solid rgba(98,230,212,.5);border-radius:13px;background:rgba(98,230,212,.08)}.login-brand-mark i{display:block;border-radius:4px;background:#62e6d4}.login-brand-mark i:nth-child(1){height:11px}.login-brand-mark i:nth-child(2){height:20px}.login-brand-mark i:nth-child(3){height:15px}.login-brand strong{display:block;font-size:20px;letter-spacing:.02em}.login-brand small{display:block;margin-top:3px;color:#7189a3;font-size:10px}.login-kicker{color:#62e6d4;font-size:10px;letter-spacing:.22em;font-weight:700}.login-title{margin:9px 0 9px;font-size:29px;letter-spacing:-.04em}.login-copy{margin:0 0 28px;color:#8095ad;font-size:12px;line-height:1.7}.login-form{display:grid;gap:15px}.login-field span{display:block;margin-bottom:8px;color:#91a6bc;font-size:11px}.login-field input{box-sizing:border-box;width:100%;padding:13px 14px;border:1px solid rgba(156,183,218,.18);border-radius:11px;outline:0;background:rgba(3,12,24,.58);color:#e5f2ff;font:inherit;font-size:13px}.login-field input:focus{border-color:rgba(98,230,212,.72);box-shadow:0 0 0 3px rgba(98,230,212,.09)}.login-submit{margin-top:6px;padding:13px 16px;border:0;border-radius:11px;background:#62e6d4;color:#05262a;font:700 13px inherit;cursor:pointer;box-shadow:0 12px 25px rgba(98,230,212,.16)}.login-submit:hover{background:#8ff2e6}.login-submit:disabled{opacity:.62;cursor:wait}.login-error{min-height:16px;margin:1px 0 0;color:#ff9baa;font-size:11px}.login-foot{margin:26px 0 0;color:#5e7895;font-size:10px;line-height:1.6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient ambient-one"></div><div class="ambient ambient-two"></div>
|
||||
<main class="login-shell">
|
||||
<div class="login-brand"><div class="login-brand-mark" aria-hidden="true"><i></i><i></i><i></i></div><div><strong>Orbit</strong><small>星轨控制台</small></div></div>
|
||||
<div class="login-kicker">CONTROL PLANE</div>
|
||||
<h1 class="login-title">登录控制台</h1>
|
||||
<p class="login-copy">使用你的控制台账号进入任务、定时、流程和结果交付页面。</p>
|
||||
<form class="login-form" id="login-form" autocomplete="on">
|
||||
<label class="login-field"><span>用户名</span><input id="username" name="username" type="text" autocomplete="username" maxlength="128" required autofocus></label>
|
||||
<label class="login-field"><span>密码</span><input id="password" name="password" type="password" autocomplete="current-password" maxlength="1024" required></label>
|
||||
<p class="login-error" id="login-error" role="alert"></p>
|
||||
<button class="login-submit" id="login-submit" type="submit">登录 Orbit <span>→</span></button>
|
||||
</form>
|
||||
<p class="login-foot">会话由服务端保存和校验,浏览器不会读取业务凭据。</p>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.getElementById('login-form'); const username = document.getElementById('username'); const password = document.getElementById('password'); const submit = document.getElementById('login-submit'); const error = document.getElementById('login-error');
|
||||
const params = new URLSearchParams(location.search); let returnTo = params.get('return') || '/console/'; if (!returnTo.startsWith('/console') || returnTo.startsWith('//') || returnTo.startsWith('/console/login')) returnTo = '/console/';
|
||||
fetch('/console/auth/session', { headers: { Accept: 'application/json' }, 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' }, 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 →'; } });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user