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