Compare commits

..

3 Commits

Author SHA1 Message Date
liulong
99d351eb1f 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>
2026-06-08 12:09:44 +08:00
liulong
b984406b86 fix(dev-server): proxy /sso/did/pub to txw-cloud gateway (BUG-D)
login.vue 的 DID 扫码登录轮询(每 2s 一次)打
GET /sso/did/pub/backresult/login?reqId=xxx 查扫码结果。

老栈 target = https://www.cciw.com.cn (生产),
本地的 reqId 在生产查不到,cookie 里的 admin/admin123 也跟
生产不匹配,结果轮询一直返回"密码错误"。

跟 BUG-C 一样的修法:vue.config.js 新增 '^/sso/did/pub' 代理
(target = http://localhost:8080, pathRewrite: /sso/did/pub -> /auth/did/pub),
把请求改写到新栈 8080 gateway 的 /auth/did/pub/**。nacos 白名单里
这个接口是开放的。

这条规则必须放在 '^/sso' 之前,否则会被广匹配的 '^/sso' 截走,
再次落到生产。
2026-06-07 14:59:01 +08:00
liulong
3189873e54 fix(dev-server): proxy /auth to txw-cloud gateway
阶段 1 收尾 BUG-C:auth-refactor.js 调 /auth/loginByPassword
(txw-cloud 新栈),vue.config.js 的 devServer.proxy 缺 ^/auth 条目,
dev server 不知把请求转给 8080 gateway,请求落到 SPA fallback
返回 index.html,登录永远走不通。

target 指向 http://localhost:8080(与 .env.development.new
中 VUE_APP_MHZC_PROXY 一致,8080 是新 gateway,auth 9200 /
system 9201 都在它后面)。

不破坏 9002 老栈:^/sso / ^/mhzc / ^/gxzx / ^/yygl 保持原状。

验收:点登录按钮 → localStorage 自动出现 txw_access_token →
/system/user/getInfo 请求头带 Authorization: Bearer ...
2026-06-07 13:48:22 +08:00
2 changed files with 161 additions and 73 deletions

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;
}

View File

@ -288,6 +288,21 @@ module.exports = {
// Vue CLI prepareProxy 用 pathname.match(代理键) 判断;键写 '/mhzc' 会变成匹配路径里任意位置的 /mhzc
// 会误伤 SPA 路由 /view/mhzc/...,刷新时整页请求被转发到后端导致 Proxy error。必须用 ^ 限定为路径前缀。
proxy: {
// 阶段 1 收尾 BUG-Cauth-refactor.js 调 /auth/loginByPasswordtxw-cloud 新栈)。
// 8080 是新 gatewayauth 9200 / system 9201 都在它后面),不配这条会落到 SPA fallback。
'^/auth': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'^/sso/did/pub': {
// 阶段 2 BUG-Dlogin.vue 的 DID 扫码轮询(每 2s 一次)打 /sso/did/pub/backresult/login
// 老栈 target 是 cciw.com.cn 生产,本地 reqId/cookie 校验失败会一直返回"密码错误"。
// 新栈 nacos 白名单有 /auth/did/pub/**,把 /sso/did/pub/** 改写到 8080 的 /auth/did/pub/**。
// 必须放在 '^/sso' 之前,否则会被广匹配的 '^/sso' 截走。
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: { '^/sso/did/pub': '/auth/did/pub' },
},
'^/sso': {
// target: 'http://localhost:9300',
// target: 'http://carbon.liantu.tech',