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:
parent
b984406b86
commit
99d351eb1f
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user