diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ece07e..68037fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## main — 2026-09-29 +- 修复长页面滚动体验:固定导航与页头、内容独立滚动,应用详情后台刷新避免重建相同快照及打断交互。 + - 筛选范围改为可搜索的浅色菜单;四个资源列表默认每页 10 条,登录页大屏内容居中,统一筛选区与表格间距。 - 管理端统一浅色工作台、分组导航、紧凑总览、中文状态和移动端布局。 diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 1e7a795..d45d889 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -337,3 +337,10 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专 - [x] 四个资源列表默认每页 10 条,保留服务端游标与原条件。 - [x] 登录页左侧内容居中、主工作区最大宽度与移动布局调整。 - [x] 浏览器交互及分页回归、基础构建、本地 Docker 部署与文档同步;见[验收记录](reports/console-refresh.md)。 + +## 2026-09-29:滚动稳定性与登录保护核对 + +- [x] 控制台独立内容滚动,固定导航与页头,保留移动端导航和表格横向滚动。 +- [x] 应用详情相同快照不重建,后台刷新在滚动/编辑/展开详情时让位,检查请求返回时的交互状态。 +- [x] 核对登录实现:当前没有失败次数封控或登录限流,明确记录缺口。 +- [ ] 浏览器滚动、刷新和切页回归,基础检查及持久 Docker 部署。 diff --git a/docs/console.md b/docs/console.md index 69501cd..4e2069f 100644 --- a/docs/console.md +++ b/docs/console.md @@ -20,6 +20,7 @@ http://127.0.0.1:8080/console/ - 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。 - `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。 - 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。 +- 工作台固定导航和页头,内容区独立滚动;切换模块回到内容顶部。应用详情相同快照不重建,后台刷新会避让正在滚动、编辑或展开详情的操作。 - 左侧八个模块是独立视图:总览、任务、计划、流程、订阅、保留策略、归档作业和应用接入。 - “保留策略”只允许单管理员查看和修改命名空间级窗口;修改需要版本号、幂等键和原因并留下审计。预览和只读导出不会删除源数据,未确认交付、未知效果和未结束流程不会成为候选。 - 当前视图按 10 秒自动刷新,可在页顶暂停;输入、打开详情或操作弹窗时不自动重载。页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。 @@ -74,6 +75,8 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx' ## 安全边界 +当前登录接口尚未实现连续密码错误计数、临时锁定或账号/IP 登录限流。错误统一返回 401,密码使用 bcrypt 存储;这些措施不能替代防暴力尝试封控。此项是待补能力,不能视为已具备。 + 控制台通过服务端授权访问数据库。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。普通账号不自动获得跨命名空间权限;部署指定的单管理员可显式选择应用范围,每个业务事务重新核验原会话及目标应用。各应用分别展示,SDK 权限不变。 不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。 diff --git a/docs/reports/console-refresh.md b/docs/reports/console-refresh.md index eab6cf4..8ceb668 100644 --- a/docs/reports/console-refresh.md +++ b/docs/reports/console-refresh.md @@ -54,3 +54,11 @@ SDK、数据库 Schema 34、原应用与凭据保持。部署未重新 bootstrap - 本轮基础 test/vet/build、控制台端到端和 console-check/docs-check 通过。部署复核另附下方。 第二轮已部署至持久 Compose 环境:`/version` 修订为 `df76dcb9c0b69b3c947d112de619737145c44015`,就绪检查 HTTP 200。实际 18080 入口应用菜单、10 条列表、退出和原管理员登录通过;原应用身份与数据库保留。本次不涉及 SDK 发版。 + +## 第三轮:滚动稳定性 + +原布局让整个 document 滚动,页头会被带离视口;应用详情每次轮询都重建 DOM。改为固定视口的工作台、独立内容滚动、固定页头和桌面应用目录;后台请求在返回时再次检查滚动/编辑状态,相同应用快照保留原有 DOM。切换视图重置内容滚动区域,菜单仍按当前控件坐标定位。 + +浏览器验证:1440×900 与 390×844 下,document 不滚动,页头和导航坐标固定;鼠标点击页头刷新前后内容 scrollTop 均为 293、scrollHeight 均为 1193;展开状态不因相同快照刷新被折叠;手机切页归零、详情打开/关闭和菜单定位通过,无横向页面溢出或 JS 错误。基础 test/vet/build、console-check/docs-check 通过。 + +登录保护核对:`ConsoleAuthStore.Login` 与 HTTP 登录路由目前没有失败计数、临时锁定和登录限流;本轮如实记录缺口,没有改动账号密码或认证策略。 diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js index ce102cd..b763cf1 100644 --- a/internal/transport/httpserver/console/app.js +++ b/internal/transport/httpserver/console/app.js @@ -39,7 +39,7 @@ loadScopeApplications().catch(error => showToast(`应用目录读取失败:${error.message}`)); } text('scope-hint', ['workflows', 'retention', 'archives'].includes(next) ? '当前应用环境 · 不受队列筛选影响' : '任务、计划和订阅按所选队列显示'); - window.scrollTo({ top: 0, behavior: 'instant' }); + $('main-content').scrollTo({ top: 0, behavior: 'instant' }); if (matchMedia('(max-width:640px)').matches) document.querySelector('.nav-item.active')?.scrollIntoView({inline:'nearest',block:'nearest'}); if (next === 'retention' && state.scopeReady) loadRetention(); if (next === 'archives' && state.scopeReady) loadArchives(); @@ -863,7 +863,7 @@ if (!state.authenticated || !state.scopeReady) return; if (lists[view]) await loadList(view, background === true); else if (view === 'overview') await loadOverview(); - else if (view === 'applications') await window.orbitApplications.load(); + else if (view === 'applications') await window.orbitApplications.load({ background: background === true }); else if (view === 'retention') await loadRetention(); else if (view === 'archives') await loadArchives(); text('last-updated', `更新于 ${formatTime(new Date())}`); diff --git a/internal/transport/httpserver/console/applications.js b/internal/transport/httpserver/console/applications.js index 7e66191..b6cbe23 100644 --- a/internal/transport/httpserver/console/applications.js +++ b/internal/transport/httpserver/console/applications.js @@ -38,27 +38,44 @@ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\;&|<>(){}]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址'); return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_APPLICATION_ID=${ui.detail?.entry.application_id || ''}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\nORBIT_TOKEN=${token}\n`; } - async function load() { + let lastScrollAt = -Infinity, detailSignature = '', previousListHTML = ''; + document.addEventListener('scroll', () => { lastScrollAt = performance.now(); }, true); + function editingOrScrolling() { + return performance.now() - lastScrollAt < 1500 || document.activeElement?.matches('input,select,textarea') || + !!document.querySelector('dialog[open], #access-detail details[open], #access-retire-form:not([hidden])'); + } + async function load({ background = false } = {}) { if (ui.loading || ui.busy) return; + if (background && editingOrScrolling()) return; ui.loading = true; $('access-error').textContent = ''; try { - const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : ''); ui.next = page.next || ''; $('access-next').disabled = !ui.next; - $('access-list').innerHTML = page.items.length ? page.items.map(entry => ``).join('') : '
还没有接入应用,点击右上角新建。
'; + const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : ''); + if (background && editingOrScrolling()) return; + ui.next = page.next || ''; $('access-next').disabled = !ui.next; + const listHTML = page.items.length ? page.items.map(entry => ``).join('') : '还没有接入应用,点击右上角新建。
'; + if (previousListHTML !== listHTML) { $('access-list').innerHTML = listHTML; previousListHTML = listHTML; } filterApplications(); if (!ui.selected && page.items.length) await select(page.items[0].namespace_id); - else if (ui.selected && !$('access-dialog').open && !$('access-token-dialog').open) await select(ui.selected); + else if (ui.selected && !$('access-dialog').open && !$('access-token-dialog').open) await select(ui.selected, { background }); } catch (error) { $('access-error').textContent = message(error); } finally { ui.loading = false; } } - async function select(namespace) { + async function select(namespace, { background = false } = {}) { if (namespace !== ui.selected) { + detailSignature = ''; retirement = null; $('access-retire-form').hidden = true; $('access-detail').hidden = true; $('access-empty').hidden = false; $('access-empty').textContent = '正在读取应用配置…'; } const requested = namespace, ticket = ++selectionSequence; ui.selected = namespace; try { - const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested || ticket !== selectionSequence) return; ui.detail = detail; + const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested || ticket !== selectionSequence) return; + if (background && editingOrScrolling()) return; + const now = Date.now(); + // 相同快照不重建详情,保留滚动锚点、焦点和折叠状态;到期状态仍按当前时间更新。 + const signature = JSON.stringify([detail, detail.credentials.map(c => [!!c.expires_at && Date.parse(c.expires_at) <= now, Date.parse(c.previous_valid_until) > now])]); + ui.detail = detail; + if (signature === detailSignature && !$('access-detail').hidden) return; $('access-empty').hidden = true; $('access-detail').hidden = false; $('access-title').textContent = detail.entry.application; $('access-status').textContent = detail.entry.enabled ? '已启用' : '已停用'; $('access-app-id').textContent = detail.entry.application_id; $('access-ns-id').textContent = namespace; $('access-scope').textContent = `${detail.entry.environment} / ${detail.entry.namespace}`; @@ -71,7 +88,6 @@ $('access-copy-template').textContent = availableCredential?.token_stored ? '查看完整接入配置 ↗' : '补存当前 Token ↗'; $('access-config-note').textContent = availableCredential?.token_stored ? '选择正确队列后,可查看、复制含当前 Token 的完整配置。' : '当前 Token 尚未补存。请从已保存的新配置导入当前 Token,再查看或复制;过渡期旧 Token 不能用于补存。'; $('access-truncated').hidden = !detail.truncated; - const now = Date.now(); $('access-credentials').innerHTML = detail.credentials.map(c => { const status = c.revoked_at ? '已撤销' : c.expires_at && Date.parse(c.expires_at) <= now ? '已到期' : '有效'; const transitioning = !c.revoked_at && Date.parse(c.previous_valid_until) > now; @@ -92,6 +108,7 @@ }).join('') || '该环境暂无凭据。
'; if ($('access-help-dialog').open) renderCredentialHelp(); document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig(); + detailSignature = signature; } catch (error) { if (ticket !== selectionSequence) return; $('access-error').textContent = message(error); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index 8f05dca..086cde2 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -7,7 +7,7 @@