feat(gxnlpt): sidebar sticky 重构为 portal + fixed 定位

- sidebar-sticky 元素 portal 到 body,避开 scale-stage transform 上下文
- 改用 top/left/width 而非 transform,sticky 在 fixed 时正确锚定 viewport
- _measureStickyGeometry 重写,sticky 视觉位置独立于滚动
- 组件 destroy 时把 sticky 移回原父节点,避免 DOM 泄漏
- 加 gxnlpt-sidebar-bg 视觉白底铺满
- 重构:_lastStickyOffset 拆为 _lastStickyTop/Left/Width

兼容性:本改动纯前端 DOM 操作,不涉及任何后端 API 调用,
不影响登录态、不改 .env.development、不动 request.js 拦截器,
老后端(ry-cloud 9300)和 txw-cloud(8080)都能跑。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
liulong 2026-06-08 12:09:44 +08:00
parent b984406b86
commit 99d351eb1f

View File

@ -9,6 +9,8 @@
<div class="gxnlpt-layout" :class="{ 'gxnlpt-layout--stacked': stackedNavLayout }">
<!-- Figma Frame_left 280px窄屏为服务中心式子导航面板 -->
<aside class="gxnlpt-sidebar" :class="{ 'gxnlpt-sidebar--panel': stackedNavLayout }">
<!-- 视觉白底铺满 sticky 高度无关,独立 absolute 铺满 sidebar 容器 -->
<div class="gxnlpt-sidebar-bg" aria-hidden="true"></div>
<div class="gxnlpt-sidebar-sticky">
<div class="gxnlpt-side-nav-wrap">
<nav class="gxnlpt-side-nav" role="tablist" aria-label="共性能力分类">
@ -1328,47 +1330,39 @@ export default {
/**
* 测量侧边栏的"几何常量"
*
* 注意:sticky 现在被 portal body ,完全脱离 sidebar 容器跟随
* 所以这里测的是 sidebar 容器的视觉位置 + sticky 内容自然高度
* 滚动无关的常量,只在 layout 可能变化时调用一次
*
* 设计原则:**不在每帧滚动时测量** DOM 位置,那样会反复触发 reflow,
* 且浮点坐标的子像素精度在 `translateY` 像素取整时会引起视觉抖动
*
* 这里只测量**滚动无关的尺寸/偏移**:
* - sticky 元素视觉高度(布局不变则不变)
* - sidebar 容器视觉高度(异步数据填充时才会变)
* - sticky 顶部到 sidebar 顶部的"内边距"偏移(用于算 maxOffset)
*
* 滚动位置由 scrollTop 实时推算, _applySidebarSticky
*/
_measureStickyGeometry() {
const stickyEl = this.$el?.querySelector('.gxnlpt-sidebar-sticky');
const sidebarEl = stickyEl?.parentElement;
if (!stickyEl || !sidebarEl) return null;
const sidebarEl = this.$el?.querySelector('.gxnlpt-sidebar');
const stickyEl = document.body.querySelector('.gxnlpt-sidebar-sticky');
if (!sidebarEl || !stickyEl) return null;
const scale = this._readScale();
if (scale <= 0) return null;
// transform, translateY
const savedTransform = stickyEl.style.transform;
stickyEl.style.transform = '';
const stickyRect = stickyEl.getBoundingClientRect();
const sidebarRect = sidebarEl.getBoundingClientRect();
stickyEl.style.transform = savedTransform;
const stickyHeight = stickyRect.height;
const sidebarHeight = sidebarRect.height;
// sticky sidebar (),
// scrollTop sidebar padding
const innerOffsetVisual = stickyRect.top - sidebarRect.top;
// :sidebar sticky
const travelVisual = Math.max(0, sidebarHeight - stickyHeight - innerOffsetVisual);
// (translateY scale )
const travelLayout = travelVisual / scale;
// sticky top/left/width,( fixed )
const savedTop = stickyEl.style.top;
const savedLeft = stickyEl.style.left;
const savedWidth = stickyEl.style.width;
stickyEl.style.top = '0';
stickyEl.style.left = '-9999px';
stickyEl.style.width = 'auto';
const stickyRect = stickyEl.getBoundingClientRect();
stickyEl.style.top = savedTop;
stickyEl.style.left = savedLeft;
stickyEl.style.width = savedWidth;
return {
stickyHeight,
sidebarHeight,
innerOffsetVisual,
travelLayout,
stickyHeight: stickyRect.height,
sidebarHeight: sidebarRect.height,
// sticky left/width sidebar left/width
sidebarVisualLeft: sidebarRect.left,
sidebarVisualWidth: sidebarRect.width,
// sidebar top(scrollTop=0 ) -scrollTop
sidebarInitialTop: sidebarRect.top,
};
},
initSidebarSticky() {
@ -1379,6 +1373,16 @@ export default {
if (!scrollRoot) return;
this._sidebarStickyScrollEl = scrollRoot;
// portal: .gxnlpt-sidebar-sticky scale-stage
// document.body ,使 transform ,
// position:fixed viewport
const stickyEl = this.$el?.querySelector('.gxnlpt-sidebar-sticky');
if (stickyEl && stickyEl.parentElement !== document.body) {
this._stickyOriginParent = stickyEl.parentElement;
this._stickyNextSibling = stickyEl.nextSibling;
document.body.appendChild(stickyEl);
}
const handler = () => {
if (this._sidebarStickyRaf) return;
this._sidebarStickyRaf = requestAnimationFrame(() => {
@ -1444,10 +1448,28 @@ export default {
this._sidebarStageTimer = null;
}
this._stickyGeometry = null;
this._lastStickyOffset = undefined;
this._lastStickyTop = undefined;
this._lastStickyLeft = undefined;
this._lastStickyWidth = undefined;
const stickyEl = this.$el?.querySelector('.gxnlpt-sidebar-sticky');
if (stickyEl) stickyEl.style.transform = '';
// sticky portal body , body
const stickyEl = document.body.querySelector('.gxnlpt-sidebar-sticky');
if (stickyEl) {
stickyEl.style.transform = '';
stickyEl.style.top = '';
stickyEl.style.left = '';
stickyEl.style.width = '';
// sticky sidebar , destroy DOM
if (this._stickyOriginParent && stickyEl.parentElement === document.body) {
if (this._stickyNextSibling) {
this._stickyOriginParent.insertBefore(stickyEl, this._stickyNextSibling);
} else {
this._stickyOriginParent.appendChild(stickyEl);
}
}
}
this._stickyOriginParent = null;
this._stickyNextSibling = null;
},
/**
* 缓存侧边栏的"几何常量"仅在布局可能变化时调用
@ -1459,11 +1481,22 @@ export default {
this._stickyGeometry = info;
},
/**
* 把当前 scrollTop 映射到 translateY,直接套用缓存的几何常量
* 把当前 scrollTop 映射到 sticky 视觉位置,直接套用缓存的几何常量
* 关键:不调用 getBoundingClientRect,避免每帧 reflow + 子像素取整抖动
*
* sticky 现在被 portal body ,position:fixed viewport
* 视觉行为:
* 1. sidebar 顶部视觉位置 >= navOffset(sidebar 还没滚出 nav):
* sticky 跟着 sidebar 一起向下移动(stickyTopVisual = sidebarTopVisual)
* 2. sidebar 顶部已滚出 nav 下方:
* sticky 钉在 nav 下方(stickyTopVisual = navOffset)
* 3. sidebar 底部接近视口底部sticky 顶部即将超出 sidebar 底部:
* sticky 跟随 sidebar 一起向上滚(stickyTopVisual = sidebarBottomVisual - stickyHeight)
* 防止 sticky 底部超出 sidebar 底部
*/
_applySidebarSticky() {
const stickyEl = this.$el?.querySelector('.gxnlpt-sidebar-sticky');
// sticky body , body
const stickyEl = document.body.querySelector('.gxnlpt-sidebar-sticky');
const scrollRoot = this._sidebarStickyScrollEl;
if (!stickyEl || !scrollRoot) return;
if (!this._stickyGeometry) {
@ -1471,35 +1504,52 @@ export default {
if (!this._stickyGeometry) return;
}
// scrollTop 0 X ,sidebar X
// sticky "",translateY() = scrollTop / scale
// [0, travelLayout] , sticky sidebar
// DESIRED_DP (24dp) sidebar padding-top ,
// ( sticky 24dp ;
// 24dp 稿,sticky )
const scale = this._readScale();
if (scale <= 0) return;
const scrollTop = scrollRoot.scrollTop;
// translateY
let layoutOffset = scrollTop / scale;
// : sticky sidebar
const maxOffset = this._stickyGeometry.travelLayout;
if (layoutOffset > maxOffset) layoutOffset = maxOffset;
if (layoutOffset < 0) layoutOffset = 0;
const navOffset = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue('--page-nav-height'),
) || 64;
// :0.4 transform,
if (this._lastStickyOffset !== undefined) {
if (Math.abs(layoutOffset - this._lastStickyOffset) < 0.4) return;
}
const g = this._stickyGeometry;
// sidebar :scrollTop sidebar
const sidebarTopVisual = g.sidebarInitialTop - scrollTop;
const sidebarBottomVisual = sidebarTopVisual + g.sidebarHeight;
const stickyHeight = g.stickyHeight;
this._lastStickyOffset = layoutOffset;
if (layoutOffset > 0) {
// translate3d GPU ,便
stickyEl.style.transform = `translate3d(0, ${layoutOffset}px, 0)`;
// sticky :sticky sidebar
const stickyMaxTopVisual = sidebarBottomVisual - stickyHeight;
// sticky
let stickyTopVisual;
if (sidebarTopVisual >= navOffset) {
// sidebar nav ,sticky sidebar
stickyTopVisual = sidebarTopVisual;
} else {
stickyEl.style.transform = '';
// sidebar nav,sticky nav
stickyTopVisual = navOffset;
}
// sticky sidebar ( sticky sidebar )
if (stickyTopVisual > stickyMaxTopVisual) {
stickyTopVisual = stickyMaxTopVisual;
}
// :0.4 ,
if (
this._lastStickyTop !== undefined &&
Math.abs(stickyTopVisual - this._lastStickyTop) < 0.4 &&
this._lastStickyLeft === g.sidebarVisualLeft &&
this._lastStickyWidth === g.sidebarVisualWidth
) {
return;
}
this._lastStickyTop = stickyTopVisual;
this._lastStickyLeft = g.sidebarVisualLeft;
this._lastStickyWidth = g.sidebarVisualWidth;
// fixed left/width/top
stickyEl.style.left = `${g.sidebarVisualLeft}px`;
stickyEl.style.width = `${g.sidebarVisualWidth}px`;
stickyEl.style.top = `${stickyTopVisual}px`;
},
handleCardClick(card) {
// 使 wzLj lj
@ -1623,37 +1673,59 @@ html.portal-figma-scale-active .gxnlpt-page {
@gxnlpt-side-actions-block-h: 114px;
@gxnlpt-side-actions-gap-top: 20px;
/* Figma Frame_left 602px
sticky 侧边导航提供足够的垂直滚动空间确保导航始终可见
空白区域由 gxnlpt-sidebar-tail 填充符合设计稿"白底向下铺满"的意图 */
/* Figma Frame_left 602px
- sidebar 容器保持 stretch 拉伸到右侧内容同高,提供白底铺满的视觉;
- sticky 元素(分类 + 收录/收藏)会通过 JS portal 移到 document.body ,
这样 sticky 没有 transform 祖先,position:fixed 钉到 viewport 屏幕坐标,
不再被 sidebar 容器拖走,实现"导航钉死顶部"的视觉
- 白底背景由独立的 .gxnlpt-sidebar-bg(absolute 铺满)提供,
sticky 移走后 sidebar 容器依然有正确的视觉白底卡片观感*/
.gxnlpt-sidebar {
position: relative;
display: flex;
flex-direction: column;
align-self: stretch;
width: @gxnlpt-sidebar-width;
min-height: 0;
padding: @gxnlpt-sidebar-padding;
background: #fff;
border-radius: @gxnlpt-sidebar-radius;
box-sizing: border-box;
}
/* + / JS sticky
不能用 CSS position:sticky:外层 .portal-figma-scale-stage 上的
transform:scale() 会创建新的 containing block,导致原生 sticky 失效,
JS 模拟与 CSS sticky 同时启用时会互相冲突引起上下抖动
故此处关闭 CSS sticky,完全交给 _applySidebarSticky() 通过 transform 实现 */
.gxnlpt-sidebar-bg {
position: absolute;
inset: 0;
z-index: 0;
background: #fff;
border-radius: inherit;
}
/* + /
- 不能用 CSS position:sticky:外层 .portal-figma-scale-stage 上的
transform:scale() 会创建新的 containing block,导致原生 sticky 失效
- 方案:mounted 时把 .gxnlpt-sidebar-sticky 元素 appendChild document.body,
脱离 scale-stage 容器,此时 sticky 没有 transform 祖先,
position:fixed 会真正钉到 viewport 屏幕坐标
- left/width 在每次 scroll 时根据 sidebar 容器的视觉位置重算,
视觉行为:侧边栏跟着页面滚条一起走,直到 sidebar 顶部滚出 nav 时钉死顶部,
sidebar 底部时跟随 sidebar 一起向上滚(防止 sticky 底部超出 sidebar 底部)
- 视觉背景:.gxnlpt-sidebar-sticky 自身需要有白底圆角(不再依赖 sidebar bg)*/
.gxnlpt-sidebar-sticky {
position: relative;
position: fixed;
top: 0;
left: 0;
z-index: 20;
flex: 0 0 auto;
display: flex;
flex-direction: column;
padding: @gxnlpt-sidebar-padding;
background: #fff;
border-radius: @gxnlpt-sidebar-radius;
box-sizing: border-box;
max-height: calc(
100vh - var(--page-offset-top, @home-nav-height) - @gxnlpt-shell-padding-top -
@gxnlpt-shell-padding-bottom - @gxnlpt-sidebar-sticky-top
);
box-shadow: 0 4px 16px rgba(0, 59, 26, 0.06);
}
/* 图三:分类过多时在块内滚动 */
@ -1690,6 +1762,7 @@ html.portal-figma-scale-active .gxnlpt-page {
}
.gxnlpt-sidebar-tail {
/* 占满 sidebar 内剩余空间,撑高 sidebar 让 bg 视觉白底铺满 */
flex: 1 1 0;
min-height: 0;
}