feat:修复部分问题
64
tmp-active.mjs
Normal file
@ -0,0 +1,64 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 测试所有 6 个标签
|
||||
const labels = ['碳核算平台', '碳交易平台', '碳认证机构', '碳金融服务', '碳技术咨询', '更多能力'];
|
||||
const expectedAnchors = ['content-1', 'content-3', 'content-2', 'content-4', 'content-5', null];
|
||||
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
await page.locator('.capability-card').nth(i).click();
|
||||
await page.waitForFunction(() => location.href.includes('gxnlpt'), { timeout: 5000 });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const data = await page.evaluate(() => {
|
||||
const sideItems = document.querySelectorAll('.gxnlpt-side-item');
|
||||
const active = Array.from(sideItems).map((el, idx) => ({
|
||||
idx, text: el.textContent.trim(), active: el.classList.contains('is-active')
|
||||
})).find(x => x.active);
|
||||
const content1 = document.getElementById('content-1');
|
||||
const content2 = document.getElementById('content-2');
|
||||
const content3 = document.getElementById('content-3');
|
||||
const content4 = document.getElementById('content-4');
|
||||
const content5 = document.getElementById('content-5');
|
||||
const rects = {};
|
||||
for (const el of [content1, content2, content3, content4, content5]) {
|
||||
if (el) rects[el.id] = Math.round(el.getBoundingClientRect().top);
|
||||
}
|
||||
return {
|
||||
url: location.href,
|
||||
scrollTop: document.querySelector('.content-wrap').scrollTop,
|
||||
activeTab: active,
|
||||
sectionTops: rects,
|
||||
};
|
||||
});
|
||||
console.log(`[${labels[i]}]`, JSON.stringify(data));
|
||||
|
||||
// 回到首页
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
67
tmp-capability-debug.mjs
Normal file
@ -0,0 +1,67 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
||||
page.on('console', (m) => {
|
||||
const t = m.text();
|
||||
console.log(`[browser:${m.type()}]`, t);
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
console.log('home loaded');
|
||||
|
||||
// 滚动到 capability 区域
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 点击 "碳核算平台"
|
||||
const cardIndex = 0;
|
||||
await page.locator('.capability-card').nth(cardIndex).click();
|
||||
// 多等几秒看是否跳转
|
||||
await page.waitForTimeout(4000);
|
||||
console.log('url:', page.url());
|
||||
|
||||
// 检查 router-view 内的内容
|
||||
const data = await page.evaluate(() => {
|
||||
const outlet = document.querySelector('.portal-route-outlet');
|
||||
if (!outlet) return { hasOutlet: false };
|
||||
const child = outlet.firstElementChild;
|
||||
return {
|
||||
hasOutlet: true,
|
||||
hasChild: !!child,
|
||||
childTag: child ? child.tagName : null,
|
||||
childClass: child ? child.className : null,
|
||||
hasGongXingNeng: !!document.querySelector('.gxnlpt-shell'),
|
||||
hasCapabilitySection: !!document.querySelector('.capability-section'),
|
||||
hasContent1: !!document.getElementById('content-1'),
|
||||
bodyText: (document.body.innerText || '').slice(0, 300),
|
||||
};
|
||||
});
|
||||
console.log('data:', data);
|
||||
|
||||
await page.screenshot({ path: 'tmp-debug.png', fullPage: false });
|
||||
|
||||
// 等待 5s 后再次检查
|
||||
await page.waitForTimeout(5000);
|
||||
const data2 = await page.evaluate(() => ({
|
||||
hasGongXingNeng: !!document.querySelector('.gxnlpt-shell'),
|
||||
hasCapabilitySection: !!document.querySelector('.capability-section'),
|
||||
hasContent1: !!document.getElementById('content-1'),
|
||||
url: location.href,
|
||||
}));
|
||||
console.log('data2:', data2);
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
93
tmp-capability-test.mjs
Normal file
@ -0,0 +1,93 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
page.on('console', (m) => {
|
||||
const t = m.text();
|
||||
if (t.includes('anchor') || t.includes('Capability') || t.includes('section')) {
|
||||
console.log('[browser]', t);
|
||||
}
|
||||
});
|
||||
// 1) 打开首页
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
// 等到 capability 区域可见
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
console.log('home loaded');
|
||||
|
||||
// 滚动到 capability 区域
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 截图 home
|
||||
await page.screenshot({ path: 'tmp-home.png', fullPage: false });
|
||||
|
||||
// 取各 capability 卡的文本
|
||||
const cards = await page.$$eval('.capability-card .capability-name', (els) => els.map((e) => e.textContent.trim()));
|
||||
console.log('capability cards:', cards);
|
||||
|
||||
// 点击 "碳核算平台"
|
||||
const cardIndex = cards.indexOf('碳核算平台');
|
||||
console.log('clicking index', cardIndex);
|
||||
await page.locator('.capability-card').nth(cardIndex).click();
|
||||
await page.waitForTimeout(2500);
|
||||
console.log('after click url:', page.url());
|
||||
|
||||
// 等 section 出现
|
||||
const hasContent1 = await page.locator('#content-1').count();
|
||||
console.log('content-1 exists:', hasContent1);
|
||||
if (hasContent1) {
|
||||
const inView = await page.locator('#content-1').evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { top: r.top, bottom: r.bottom, vh: window.innerHeight };
|
||||
});
|
||||
console.log('content-1 rect after click:', inView);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'tmp-gxnlpt-content-1.png', fullPage: false });
|
||||
|
||||
// 回到首页
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 点击 "碳认证机构" -> content-2
|
||||
const idx2 = cards.indexOf('碳认证机构');
|
||||
await page.locator('.capability-card').nth(idx2).click();
|
||||
await page.waitForTimeout(2500);
|
||||
console.log('after click content-2 url:', page.url());
|
||||
const r2 = await page.locator('#content-2').evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { top: r.top, bottom: r.bottom };
|
||||
});
|
||||
console.log('content-2 rect after click:', r2);
|
||||
|
||||
// 点击 "更多能力"
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
const moreIdx = cards.indexOf('更多能力');
|
||||
await page.locator('.capability-card').nth(moreIdx).click();
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('after click 更多能力 url:', page.url());
|
||||
await page.screenshot({ path: 'tmp-gxnlpt-more.png', fullPage: false });
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
70
tmp-debug2.mjs
Normal file
@ -0,0 +1,70 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 点击
|
||||
await page.locator('.capability-card').nth(0).click();
|
||||
await page.waitForTimeout(3500);
|
||||
|
||||
// 查看 DOM 状态
|
||||
const data = await page.evaluate(() => {
|
||||
const out = {};
|
||||
const wrap = document.querySelector('.content-wrap');
|
||||
out.wrapScrollTop = wrap ? wrap.scrollTop : null;
|
||||
out.wrapScrollHeight = wrap ? wrap.scrollHeight : null;
|
||||
out.wrapClientHeight = wrap ? wrap.clientHeight : null;
|
||||
out.wrapClasses = wrap ? wrap.className : null;
|
||||
out.wrapStyleHeight = wrap ? getComputedStyle(wrap).height : null;
|
||||
out.htmlClasses = document.documentElement.className;
|
||||
out.htmlData = (function() {
|
||||
const r = document.documentElement.getBoundingClientRect();
|
||||
return { width: r.width, height: r.height };
|
||||
})();
|
||||
const root = document.documentElement;
|
||||
out.homeFigmaScale = getComputedStyle(root).getPropertyValue('--home-figma-scale');
|
||||
out.portalShellMinDesignHeight = getComputedStyle(root).getPropertyValue('--portal-shell-min-design-height');
|
||||
out.portalLandingFirstScreenHeight = getComputedStyle(root).getPropertyValue('--portal-landing-first-screen-height');
|
||||
out.pageNavHeight = getComputedStyle(root).getPropertyValue('--page-nav-height');
|
||||
|
||||
const outlet = document.querySelector('.portal-route-outlet');
|
||||
out.outletChildren = outlet ? outlet.children.length : null;
|
||||
const firstChild = outlet && outlet.firstElementChild;
|
||||
out.firstChildClass = firstChild ? firstChild.className : null;
|
||||
out.firstChildBoundingRect = firstChild ? (function() {
|
||||
const r = firstChild.getBoundingClientRect();
|
||||
return { top: r.top, left: r.left, width: r.width, height: r.height };
|
||||
})() : null;
|
||||
out.firstChildStyle = firstChild ? firstChild.getAttribute('style') : null;
|
||||
|
||||
// 找 content-1
|
||||
const c1 = document.getElementById('content-1');
|
||||
out.content1Rect = c1 ? (function() {
|
||||
const r = c1.getBoundingClientRect();
|
||||
return { top: r.top, left: r.left, width: r.width, height: r.height };
|
||||
})() : null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
|
||||
// 不截图
|
||||
// await page.screenshot({ path: 'tmp-debug2.png', fullPage: false });
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
BIN
tmp-direct-1.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
tmp-direct-2.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
41
tmp-elementfrom.mjs
Normal file
@ -0,0 +1,41 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.locator('.capability-card').nth(0).click();
|
||||
await page.waitForTimeout(3500);
|
||||
|
||||
// 检查视口不同位置是什么元素
|
||||
const points = await page.evaluate(() => {
|
||||
const results = [];
|
||||
const pts = [[720, 100], [720, 300], [720, 500], [720, 700], [200, 200], [1200, 200], [720, 850]];
|
||||
for (const [x, y] of pts) {
|
||||
const els = document.elementsFromPoint(x, y);
|
||||
results.push({ x, y, els: els.slice(0, 5).map((el) => ({ tag: el.tagName, cls: (el.className || '').slice(0, 80), id: el.id })) });
|
||||
}
|
||||
return results;
|
||||
});
|
||||
console.log('points:');
|
||||
for (const p of points) {
|
||||
console.log(` (${p.x},${p.y}):`, JSON.stringify(p.els));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
BIN
tmp-final-1.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
tmp-final-2.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
46
tmp-final.mjs
Normal file
@ -0,0 +1,46 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// 不使用 keep-alive
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
|
||||
// 滚动到 capability 区域
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 用 evaluate 在点击前修改 router 行为,禁用 keep-alive
|
||||
// 不行;改用:点击后等久一点,确认 router 完成
|
||||
await page.locator('.capability-card').nth(0).click();
|
||||
// 等待 URL 变化
|
||||
await page.waitForFunction(() => location.href.includes('gxnlpt'), { timeout: 5000 });
|
||||
// 等待 gxnlpt-page 真正在视口里
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.gxnlpt-page');
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.top >= -10 && r.top < 200;
|
||||
}, { timeout: 5000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 截 viewport
|
||||
await page.screenshot({ path: 'tmp-final-1.png', fullPage: false });
|
||||
|
||||
// 截元素
|
||||
const el = await page.locator('.gxnlpt-page').first();
|
||||
await el.screenshot({ path: 'tmp-final-2.png' });
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
BIN
tmp-gxnlpt-content-1.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
tmp-gxnlpt-el.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
tmp-gxnlpt-more.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
tmp-home.png
Normal file
|
After Width: | Height: | Size: 577 KiB |
50
tmp-no-keepalive.mjs
Normal file
@ -0,0 +1,50 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// 模拟 真实用户使用:刷新一次后不依赖 keep-alive
|
||||
// 第一次访问 gxnlpt
|
||||
await page.goto(`${BASE}/view/mhzc/gxnlpt`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.gxnlpt-page', { timeout: 20000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 直接访问首页 + 点击, 不等待 keep-alive 命中
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 点击 "碳交易平台"
|
||||
await page.locator('.capability-card').nth(1).click();
|
||||
await page.waitForFunction(() => location.href.includes('gxnlpt'), { timeout: 5000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 检查实际显示
|
||||
const data = await page.evaluate(() => {
|
||||
const out = {};
|
||||
out.url = location.href;
|
||||
out.scrollTop = document.querySelector('.content-wrap').scrollTop;
|
||||
const sections = ['content-1', 'content-2', 'content-3', 'content-4', 'content-5'].map((id) => {
|
||||
const el = document.getElementById(id);
|
||||
return { id, top: el ? Math.round(el.getBoundingClientRect().top) : null };
|
||||
});
|
||||
out.sections = sections;
|
||||
return out;
|
||||
});
|
||||
console.log('after click 碳交易平台:', JSON.stringify(data, null, 2));
|
||||
|
||||
await page.screenshot({ path: 'tmp-no-keepalive.png', fullPage: false });
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
BIN
tmp-pix-1.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
tmp-pix-2.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
tmp-pix-3.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
tmp-pix-4.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
tmp-pix-5.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
57
tmp-pixel.mjs
Normal file
@ -0,0 +1,57 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.locator('.capability-card').nth(0).click();
|
||||
await page.waitForTimeout(3500);
|
||||
|
||||
// 视口截图
|
||||
await page.screenshot({ path: 'tmp-pix-1.png', fullPage: false });
|
||||
|
||||
// 用 dom snapshot(用 canvas 截屏)
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
// 强制重排
|
||||
document.body.offsetHeight;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: 'tmp-pix-2.png', fullPage: false });
|
||||
|
||||
// 显式滚动到顶部
|
||||
await page.evaluate(() => {
|
||||
const w = document.querySelector('.content-wrap');
|
||||
if (w) w.scrollTop = 0;
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: 'tmp-pix-3.png', fullPage: false });
|
||||
|
||||
// 直接用 html2canvas 方式:通过截图 element 方式
|
||||
const gxnlptEl = await page.locator('.gxnlpt-page').first();
|
||||
await gxnlptEl.screenshot({ path: 'tmp-pix-4.png' });
|
||||
|
||||
// 强制重排 + 再次截图
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: 'tmp-pix-5.png', fullPage: false });
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
BIN
tmp-snap-body.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
tmp-snap-viewport.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
54
tmp-snap.mjs
Normal file
@ -0,0 +1,54 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.locator('.capability-card').nth(0).click();
|
||||
await page.waitForTimeout(3500);
|
||||
|
||||
// 给 gxnlpt-page 元素截图
|
||||
const gxnlptEl = await page.locator('.gxnlpt-page').first();
|
||||
await gxnlptEl.screenshot({ path: 'tmp-gxnlpt-el.png' });
|
||||
|
||||
// 给视口截图
|
||||
await page.screenshot({ path: 'tmp-snap-viewport.png', fullPage: false });
|
||||
|
||||
// 给 body 截图
|
||||
const body = await page.locator('body').first();
|
||||
await body.screenshot({ path: 'tmp-snap-body.png' });
|
||||
|
||||
// 检查 gxnlpt-page 的可见性
|
||||
const r = await gxnlptEl.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
return {
|
||||
rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height, bottom: rect.bottom, right: rect.right },
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
zIndex: style.zIndex,
|
||||
transform: style.transform,
|
||||
transformOrigin: style.transformOrigin,
|
||||
position: style.position,
|
||||
};
|
||||
});
|
||||
console.log('gxnlpt:', JSON.stringify(r, null, 2));
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
42
tmp-snap2.mjs
Normal file
@ -0,0 +1,42 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// 直接访问 gxnlpt?anchor=content-1(不要先访问 home),看看非 keep-alive 场景是否正常
|
||||
await page.goto(`${BASE}/view/mhzc/gxnlpt?anchor=content-1`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.gxnlpt-page', { timeout: 20000 });
|
||||
await page.waitForTimeout(3500);
|
||||
|
||||
await page.screenshot({ path: 'tmp-direct-1.png', fullPage: false });
|
||||
|
||||
// 滚动到顶部
|
||||
await page.evaluate(() => {
|
||||
const w = document.querySelector('.content-wrap');
|
||||
if (w) w.scrollTop = 0;
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: 'tmp-direct-2.png', fullPage: false });
|
||||
|
||||
// 在视口顶部 evaluate 一下
|
||||
const data = await page.evaluate(() => {
|
||||
const outlet = document.querySelector('.portal-route-outlet');
|
||||
const child = outlet && outlet.firstElementChild;
|
||||
return {
|
||||
outletChildCount: outlet ? outlet.children.length : 0,
|
||||
childClass: child && child.className,
|
||||
contentWrapScrollTop: document.querySelector('.content-wrap').scrollTop,
|
||||
url: location.href,
|
||||
};
|
||||
});
|
||||
console.log('data:', JSON.stringify(data));
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
BIN
tmp-zoom-1-home.png
Normal file
|
After Width: | Height: | Size: 575 KiB |
BIN
tmp-zoom-2-after-click.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
tmp-zoom-3-top.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
39
tmp-zoom.mjs
Normal file
@ -0,0 +1,39 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
|
||||
// 滚动到 capability 区域
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 截图 home capability
|
||||
await page.screenshot({ path: 'tmp-zoom-1-home.png', fullPage: false });
|
||||
|
||||
// 点击 "碳核算平台"
|
||||
await page.locator('.capability-card').nth(0).click();
|
||||
await page.waitForTimeout(3000);
|
||||
console.log('url:', page.url());
|
||||
|
||||
await page.screenshot({ path: 'tmp-zoom-2-after-click.png', fullPage: false });
|
||||
|
||||
// 滚动到顶部再截图
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(800);
|
||||
await page.screenshot({ path: 'tmp-zoom-3-top.png', fullPage: false });
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
75
tmp-zoom2.mjs
Normal file
@ -0,0 +1,75 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
|
||||
// 滚动到 capability 区域
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 点击 "碳核算平台"
|
||||
await page.locator('.capability-card').nth(0).click();
|
||||
await page.waitForTimeout(3000);
|
||||
console.log('url:', page.url());
|
||||
|
||||
// 检查实际 DOM
|
||||
const data = await page.evaluate(() => {
|
||||
const outlet = document.querySelector('.portal-route-outlet');
|
||||
const child = outlet && outlet.firstElementChild;
|
||||
const sidebar = !!document.querySelector('.gxnlpt-sidebar');
|
||||
return {
|
||||
hasOutlet: !!outlet,
|
||||
hasChild: !!child,
|
||||
childClass: child ? child.className : null,
|
||||
hasGongXingNeng: !!document.querySelector('.gxnlpt-shell'),
|
||||
hasGxnlptSidebar: sidebar,
|
||||
hasContent1: !!document.getElementById('content-1'),
|
||||
activeSection: (function() {
|
||||
const els = document.querySelectorAll('[id^="content-"]');
|
||||
const inView = [];
|
||||
els.forEach((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.top >= -50 && r.top < 800) {
|
||||
inView.push({ id: el.id, top: Math.round(r.top) });
|
||||
}
|
||||
});
|
||||
return inView;
|
||||
})(),
|
||||
visibleViewport: (function() {
|
||||
const els = document.querySelectorAll('.gxnlpt-block, [id^="content-"], .gxnlpt-side-nav, .gxnlpt-sidebar');
|
||||
const all = [];
|
||||
for (const el of els) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) continue;
|
||||
all.push({
|
||||
tag: el.tagName,
|
||||
cls: el.className && el.className.slice(0, 60),
|
||||
id: el.id,
|
||||
top: Math.round(r.top),
|
||||
bottom: Math.round(r.bottom),
|
||||
left: Math.round(r.left),
|
||||
});
|
||||
}
|
||||
return all;
|
||||
})(),
|
||||
};
|
||||
});
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
|
||||
await page.screenshot({ path: 'tmp-zoom2.png', fullPage: false });
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
BIN
tmp-zoom2.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
tmp-zoom3-no-eval.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
32
tmp-zoom3.mjs
Normal file
@ -0,0 +1,32 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
|
||||
// 滚动到 capability 区域
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 点击 "碳核算平台"
|
||||
await page.locator('.capability-card').nth(0).click();
|
||||
await page.waitForTimeout(5000);
|
||||
console.log('url:', page.url());
|
||||
|
||||
// 不调用 evaluate,直接截图
|
||||
await page.screenshot({ path: 'tmp-zoom3-no-eval.png', fullPage: false });
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
49
tmp-zoom4.mjs
Normal file
@ -0,0 +1,49 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:9027';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForSelector('.capability-section', { timeout: 20000 });
|
||||
|
||||
// 滚动到 capability 区域
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('section-capability');
|
||||
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 点击 "碳核算平台"
|
||||
await page.locator('.capability-card').nth(0).click();
|
||||
|
||||
// 不停截图
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.waitForTimeout(1000);
|
||||
const info = await page.evaluate(() => {
|
||||
const contentWrap = document.querySelector('.content-wrap');
|
||||
const gxnlptSidebar = document.querySelector('.gxnlpt-sidebar');
|
||||
const home = document.querySelector('.capability-section');
|
||||
const content1 = document.getElementById('content-1');
|
||||
return {
|
||||
url: location.href,
|
||||
scrollTop: contentWrap ? contentWrap.scrollTop : null,
|
||||
scrollHeight: contentWrap ? contentWrap.scrollHeight : null,
|
||||
hasGxnlptSidebar: !!gxnlptSidebar,
|
||||
hasHome: !!home,
|
||||
content1Top: content1 ? Math.round(content1.getBoundingClientRect().top) : null,
|
||||
};
|
||||
});
|
||||
console.log(`t=${i+1}s`, JSON.stringify(info));
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'tmp-zoom4.png', fullPage: false });
|
||||
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('TEST ERROR', e);
|
||||
process.exit(1);
|
||||
});
|
||||
BIN
tmp-zoom4.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
@ -314,7 +314,7 @@ export default [
|
||||
title: '数据列表',
|
||||
isShowSideBar: false,
|
||||
hasHome: true,
|
||||
breadCrumbs: [{ title: '首页', to: '/home' }, { title: '服务中心', to: '' }, { title: '碳数据市场', to: '/fwscsjlbc' }],
|
||||
breadCrumbs: [{ title: '首页', to: '/home' }, { title: '服务中心', to: '' }, { title: '碳数据市场', to: '/tsjsc' }],
|
||||
disableBack: true,
|
||||
},
|
||||
},
|
||||
|
||||
@ -930,7 +930,7 @@ export default {
|
||||
.price-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #FF4D4F;
|
||||
color: #2E7D32;
|
||||
}
|
||||
|
||||
.price-unit {
|
||||
|
||||
@ -320,7 +320,7 @@ export default {
|
||||
.breadcrumb-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
width: 83%;
|
||||
max-width: 1400px;
|
||||
padding: 20px 20px 0;
|
||||
margin: 0 auto;
|
||||
@ -354,7 +354,7 @@ export default {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
width: 81%;
|
||||
max-width: 1400px;
|
||||
padding: 24px;
|
||||
margin: 16px auto 0;
|
||||
|
||||
@ -36,11 +36,6 @@
|
||||
<div class="card-title-text">
|
||||
<div class="card-title-row">
|
||||
<div class="card-title-main">{{ card.bt1 }}</div>
|
||||
<!-- 收藏按钮 -->
|
||||
<div class="card-collect" @click="handleCollect(card)">
|
||||
<img v-if="card.scbz === 'Y'" src="../../assets/fwsc/ysc.svg" />
|
||||
<img v-else src="../../assets/fwsc/wsc.svg" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-title-sub">
|
||||
<div class="card-company">
|
||||
@ -71,8 +66,8 @@
|
||||
<div class="card-price-info">
|
||||
<span class="price-value">免费</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<span @click="handleContact(card)">立即咨询</span>
|
||||
<div class="card-actions" @click="goToDataList(card.id)">
|
||||
<span>查看数据库</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -293,7 +288,6 @@ export default {
|
||||
fwnr: item.sjms,
|
||||
fwfw: item.fwfw || '',
|
||||
fwlxbqList: item.sjlxMc ? [item.sjlxMc] : [],
|
||||
scbz: item.scbz || 'N',
|
||||
lxr: item.lxr || '',
|
||||
lxdh: item.lxdh || '',
|
||||
email: item.email || '',
|
||||
@ -362,15 +356,9 @@ export default {
|
||||
this.rzVisible = true;
|
||||
}
|
||||
},
|
||||
// 收藏/取消收藏
|
||||
async handleCollect(card) {
|
||||
try {
|
||||
const type = card.scbz === 'Y' ? 'remove' : 'add';
|
||||
await gxzxApi.gxsc({ gxUuid: card.gxUuid, type });
|
||||
card.scbz = card.scbz === 'Y' ? 'N' : 'Y';
|
||||
} catch (error) {
|
||||
console.error('收藏失败', error);
|
||||
}
|
||||
// 跳转到数据列表
|
||||
goToDataList(id) {
|
||||
this.$router.push({ path: '/tsjlbc', query: { id } });
|
||||
},
|
||||
// 联系数据提供方
|
||||
handleContact(card) {
|
||||
@ -476,7 +464,7 @@ export default {
|
||||
color: #003B1A;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border-radius: 32px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
|
||||
@ -505,7 +505,7 @@ export default {
|
||||
color: #003B1A;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border-radius: 32px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
|
||||
@ -672,12 +672,15 @@ export default {
|
||||
},
|
||||
activated() {
|
||||
this.syncStackedNavLayout();
|
||||
if (this.contentView === 'list' && !this.stackedNavLayout) {
|
||||
this.$nextTick(() => {
|
||||
if (this.contentView !== 'list') return;
|
||||
this.$nextTick(() => {
|
||||
// 路由进入时携带 anchor,需要跳转到对应分类(keep-alive 缓存场景)
|
||||
if (this.navigateToRouteAnchor()) return;
|
||||
if (!this.stackedNavLayout) {
|
||||
this.initScrollSpy();
|
||||
this.initSidebarSticky();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.unbindStackedNavMedia();
|
||||
@ -702,23 +705,32 @@ export default {
|
||||
this.applyDemoFallback();
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
const anchor = this.$route.query.anchor || this.$route.params.anchor;
|
||||
if (anchor) {
|
||||
const index = this.categoryList.findIndex((item) => item.id === anchor);
|
||||
const tabIndex = index >= 0 ? index : 0;
|
||||
if (this.isStackedNavMode) {
|
||||
this.activeTabIndex = tabIndex;
|
||||
} else {
|
||||
this.initScrollSpy();
|
||||
this.initSidebarSticky();
|
||||
this.scrollToSection(anchor, tabIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.navigateToRouteAnchor()) return;
|
||||
this.initScrollSpy();
|
||||
this.initSidebarSticky();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 根据当前路由的 anchor 参数,跳转到对应的分类区块。
|
||||
* 返回 true 表示有 anchor 并已处理;返回 false 表示没有 anchor,调用方应继续后续初始化。
|
||||
* 同时供 bootstrap(mounted 首次进入)与 activated(keep-alive 缓存命中)复用。
|
||||
*/
|
||||
navigateToRouteAnchor() {
|
||||
const anchor = this.$route.query.anchor || this.$route.params.anchor;
|
||||
if (!anchor) return false;
|
||||
const index = this.categoryList.findIndex((item) => item.id === anchor);
|
||||
const tabIndex = index >= 0 ? index : 0;
|
||||
if (this.isStackedNavMode) {
|
||||
// 窄屏:切到对应 tab,并把内容滚到顶部,与 handleCategoryNav 行为一致
|
||||
this.activeTabIndex = tabIndex;
|
||||
this.$nextTick(() => this.scrollListContentToTop());
|
||||
return true;
|
||||
}
|
||||
this.initScrollSpy();
|
||||
this.initSidebarSticky();
|
||||
this.scrollToSection(anchor, tabIndex);
|
||||
return true;
|
||||
},
|
||||
getIconUrl(iconName) {
|
||||
return SIDE_ICON_GRAY[iconName] || SIDE_ICON_GRAY['thspt.svg'];
|
||||
},
|
||||
@ -1276,11 +1288,11 @@ export default {
|
||||
* 原因:.portal-figma-scale-stage 有 transform:scale(),
|
||||
* 会创建新的 containing block,导致 CSS position:sticky 完全失效。
|
||||
*
|
||||
* 解决方案:
|
||||
* - 每帧滚动时,实时测量侧边栏"自然视觉位置"(无 transform 时的 getBoundingClientRect)
|
||||
* - 与导航栏上方 88dp (64dp 导航栏 + 24dp 间距) 比较
|
||||
* - 若自然位置已被滚出视口上方,用 translateY 推回来
|
||||
* - translateY 值不超过 sidebar 容器高度,防止侧边栏脱出
|
||||
* 方案:把"测量"和"应用"解耦。
|
||||
* - _recomputeStickyGeometry:仅在 resize / stage 尺寸变化时测量一次几何常量
|
||||
* (sticky 高度、sidebar 高度、可上推行程 travelLayout)。
|
||||
* - _applySidebarSticky:每帧 scroll 直接读 scrollTop 算 translateY,
|
||||
* 不再调用 getBoundingClientRect,避免每帧 reflow 与子像素取整抖动。
|
||||
*
|
||||
* 坐标系:
|
||||
* - getBoundingClientRect → 视觉像素(scale 后)
|
||||
@ -1294,36 +1306,49 @@ export default {
|
||||
return val > 0 ? val : (window.innerWidth >= 768 ? window.innerWidth / 1440 : 1);
|
||||
},
|
||||
/**
|
||||
* 测量 sticky 元素当前的自然视觉 top(清除 transform 后测量,再恢复)。
|
||||
* 返回 { naturalVisualTop, stickyHeight, sidebarHeight } 三个视觉像素值。
|
||||
* 若元素不存在或 scale 无效则返回 null。
|
||||
* 测量侧边栏的"几何常量"。
|
||||
*
|
||||
* 设计原则:**不在每帧滚动时测量** DOM 位置,那样会反复触发 reflow,
|
||||
* 且浮点坐标的子像素精度在 `translateY` 像素取整时会引起视觉抖动。
|
||||
*
|
||||
* 这里只测量**滚动无关的尺寸/偏移**:
|
||||
* - sticky 元素视觉高度(布局不变则不变)
|
||||
* - sidebar 容器视觉高度(异步数据填充时才会变)
|
||||
* - sticky 顶部到 sidebar 顶部的"内边距"偏移(用于算 maxOffset)
|
||||
*
|
||||
* 滚动位置由 scrollTop 实时推算,见 _applySidebarSticky。
|
||||
*/
|
||||
_measureStickyNatural() {
|
||||
_measureStickyGeometry() {
|
||||
const stickyEl = this.$el?.querySelector('.gxnlpt-sidebar-sticky');
|
||||
const sidebarEl = stickyEl?.parentElement;
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
if (!stickyEl || !sidebarEl) return null;
|
||||
|
||||
const scale = this._readScale();
|
||||
if (!stickyEl || !sidebarEl || !scrollRoot || scale <= 0) return null;
|
||||
if (scale <= 0) return null;
|
||||
|
||||
// 保存当前 transform,清除后测量自然位置
|
||||
const saved = stickyEl.style.transform;
|
||||
// 临时清掉内联 transform,避免上一次的 translateY 污染测量
|
||||
const savedTransform = stickyEl.style.transform;
|
||||
stickyEl.style.transform = '';
|
||||
const rect = stickyEl.getBoundingClientRect();
|
||||
const rootRect = scrollRoot.getBoundingClientRect();
|
||||
const stickyRect = stickyEl.getBoundingClientRect();
|
||||
const sidebarRect = sidebarEl.getBoundingClientRect();
|
||||
stickyEl.style.transform = savedTransform;
|
||||
|
||||
// 恢复 transform
|
||||
stickyEl.style.transform = saved;
|
||||
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;
|
||||
|
||||
return {
|
||||
// 自然视觉 top(相对于滚动容器 visual top)
|
||||
naturalVisualTop: rect.top - rootRect.top,
|
||||
// sticky 元素视觉高度
|
||||
stickyHeight: rect.height,
|
||||
// 父容器视觉高度
|
||||
sidebarHeight: sidebarRect.height,
|
||||
// 父容器视觉 top(相对于滚动容器 visual top)
|
||||
sidebarVisualTop: sidebarRect.top - rootRect.top,
|
||||
stickyHeight,
|
||||
sidebarHeight,
|
||||
innerOffsetVisual,
|
||||
travelLayout,
|
||||
};
|
||||
},
|
||||
initSidebarSticky() {
|
||||
@ -1338,7 +1363,7 @@ export default {
|
||||
if (this._sidebarStickyRaf) return;
|
||||
this._sidebarStickyRaf = requestAnimationFrame(() => {
|
||||
this._sidebarStickyRaf = null;
|
||||
this.updateSidebarSticky();
|
||||
this._applySidebarSticky();
|
||||
});
|
||||
};
|
||||
|
||||
@ -1349,7 +1374,8 @@ export default {
|
||||
this._onSidebarResize = () => {
|
||||
if (this._sidebarResizeTimer) clearTimeout(this._sidebarResizeTimer);
|
||||
this._sidebarResizeTimer = setTimeout(() => {
|
||||
this.updateSidebarSticky();
|
||||
this._recomputeStickyGeometry();
|
||||
this._applySidebarSticky();
|
||||
}, 60);
|
||||
};
|
||||
window.addEventListener('resize', this._onSidebarResize);
|
||||
@ -1360,13 +1386,16 @@ export default {
|
||||
this._sidebarStageObserver = new ResizeObserver(() => {
|
||||
if (this._sidebarStageTimer) clearTimeout(this._sidebarStageTimer);
|
||||
this._sidebarStageTimer = setTimeout(() => {
|
||||
this.updateSidebarSticky();
|
||||
this._recomputeStickyGeometry();
|
||||
this._applySidebarSticky();
|
||||
}, 80);
|
||||
});
|
||||
this._sidebarStageObserver.observe(stage);
|
||||
}
|
||||
|
||||
this.$nextTick(() => this.updateSidebarSticky());
|
||||
// 初始化几何 + 首次应用
|
||||
this._recomputeStickyGeometry();
|
||||
this.$nextTick(() => this._applySidebarSticky());
|
||||
},
|
||||
destroySidebarSticky() {
|
||||
if (this._sidebarStickyScrollEl && this._onSidebarStickyScroll) {
|
||||
@ -1394,51 +1423,63 @@ export default {
|
||||
clearTimeout(this._sidebarStageTimer);
|
||||
this._sidebarStageTimer = null;
|
||||
}
|
||||
this._stickyGeometry = null;
|
||||
this._lastStickyOffset = undefined;
|
||||
|
||||
const stickyEl = this.$el?.querySelector('.gxnlpt-sidebar-sticky');
|
||||
if (stickyEl) stickyEl.style.transform = '';
|
||||
},
|
||||
updateSidebarSticky() {
|
||||
const stickyEl = this.$el?.querySelector('.gxnlpt-sidebar-sticky');
|
||||
if (!stickyEl) return;
|
||||
|
||||
const info = this._measureStickyNatural();
|
||||
/**
|
||||
* 缓存侧边栏的"几何常量"。仅在布局可能变化时调用
|
||||
* (resize、stage 尺寸变化、初始化),滚动时不再测量。
|
||||
*/
|
||||
_recomputeStickyGeometry() {
|
||||
const info = this._measureStickyGeometry();
|
||||
if (!info) return;
|
||||
|
||||
const scale = this._readScale();
|
||||
// CSS position:sticky 的 top:24px → 相对于滚动容器顶部 24dp
|
||||
// naturalVisualTop 也是相对于滚动容器顶部,所以用 24 直接对比
|
||||
const DESIRED_DP = 24;
|
||||
const desiredVisualTop = DESIRED_DP * scale;
|
||||
|
||||
const naturalVisualTop = info.naturalVisualTop;
|
||||
|
||||
// 自然位置已在目标线之下 → 不需要补偿
|
||||
if (naturalVisualTop >= desiredVisualTop) {
|
||||
stickyEl.style.transform = '';
|
||||
return;
|
||||
this._stickyGeometry = info;
|
||||
},
|
||||
/**
|
||||
* 把当前 scrollTop 映射到 translateY,直接套用缓存的几何常量。
|
||||
* 关键:不调用 getBoundingClientRect,避免每帧 reflow + 子像素取整抖动。
|
||||
*/
|
||||
_applySidebarSticky() {
|
||||
const stickyEl = this.$el?.querySelector('.gxnlpt-sidebar-sticky');
|
||||
const scrollRoot = this._sidebarStickyScrollEl;
|
||||
if (!stickyEl || !scrollRoot) return;
|
||||
if (!this._stickyGeometry) {
|
||||
this._recomputeStickyGeometry();
|
||||
if (!this._stickyGeometry) return;
|
||||
}
|
||||
|
||||
// 需要推回去的视觉像素距离
|
||||
const visualOffset = desiredVisualTop - naturalVisualTop;
|
||||
// 转为布局像素(translateY 在 scale 容器内以布局像素为单位)
|
||||
const layoutOffset = visualOffset / scale;
|
||||
// 当 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;
|
||||
|
||||
// 不能推出父容器底部
|
||||
// sticky 元素在 sidebar 内部有 padding 偏移,需要核算 sidebar 顶部到 sticky 顶部
|
||||
// 的真实可用距离,而不能简单地 sidebarHeight - stickyHeight
|
||||
const sidebarBottom = info.sidebarVisualTop + info.sidebarHeight;
|
||||
const stickyBottom = info.naturalVisualTop + info.stickyHeight;
|
||||
// 允许的视觉余量:sidebar 底部 - sticky 自然底部
|
||||
const availableVisual = Math.max(0, sidebarBottom - stickyBottom);
|
||||
// 由于 naturalVisualTop 已经包含了 padding 偏移,所以 availableVisual
|
||||
// 就是 sticky 底部到 sidebar 底部的真实视觉距离
|
||||
const maxLayoutOffset = availableVisual / scale;
|
||||
const clamped = Math.min(layoutOffset, maxLayoutOffset);
|
||||
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;
|
||||
|
||||
stickyEl.style.transform = clamped > 0
|
||||
? `translateY(${clamped}px)`
|
||||
: '';
|
||||
// 像素级死区:0.4 像素以内的微小变化不写 transform,避免浮点抖动
|
||||
if (this._lastStickyOffset !== undefined) {
|
||||
if (Math.abs(layoutOffset - this._lastStickyOffset) < 0.4) return;
|
||||
}
|
||||
|
||||
this._lastStickyOffset = layoutOffset;
|
||||
if (layoutOffset > 0) {
|
||||
// 用 translate3d 强制 GPU 合成层,顺便保留子像素精度
|
||||
stickyEl.style.transform = `translate3d(0, ${layoutOffset}px, 0)`;
|
||||
} else {
|
||||
stickyEl.style.transform = '';
|
||||
}
|
||||
},
|
||||
handleCardClick(card) {
|
||||
// 优先使用后端返回的 wzLj,否则用兜底数据的 lj
|
||||
@ -1577,10 +1618,14 @@ html.portal-figma-scale-active .gxnlpt-page {
|
||||
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-sticky {
|
||||
position: sticky;
|
||||
top: @gxnlpt-sidebar-sticky-top;
|
||||
position: relative;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
@ -2303,7 +2348,7 @@ html.portal-figma-scale-active .gxnlpt-page {
|
||||
}
|
||||
|
||||
.gxnlpt-layout--stacked .gxnlpt-sidebar-sticky {
|
||||
position: sticky;
|
||||
position: relative;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
max-height: none;
|
||||
@ -2326,7 +2371,7 @@ html.portal-figma-scale-active .gxnlpt-page {
|
||||
}
|
||||
|
||||
.gxnlpt-sidebar-sticky {
|
||||
position: sticky;
|
||||
position: relative;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: @gxnlpt-page-bg;
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<!-- 主页面 -->
|
||||
<div class="container">
|
||||
<!-- 顶部背景轮播 -->
|
||||
<div class="top-box snap-section" id="section-hero">
|
||||
<div class="top-box snap-section" id="section-hero" :style="{ minHeight: topBannerHeight + 'px' }">
|
||||
<t-swiper class="top-banner-swiper" animation="fade" :height="topBannerHeight" :interval="10000" :duration="500"
|
||||
:loop="true" :autoplay="true" theme="dark" :navigation="{ showSlideBtn: 'never' }">
|
||||
<t-swiper-item v-for="(video, idx) in topBannerVideos" :key="idx">
|
||||
@ -3227,9 +3227,9 @@ export default {
|
||||
margin-top: var(--page-offset-top);
|
||||
}
|
||||
|
||||
/* 顶部区域 */
|
||||
/* 顶部区域:移动端由 :style 绑定 minHeight(topBannerHeight) 撑满一屏;
|
||||
这里不再强制 min-height:auto,避免覆盖内联高度 */
|
||||
.top-box {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
|
||||
@ -140,14 +140,22 @@ export default {
|
||||
activeTab() {
|
||||
this.page.pageNo = 1;
|
||||
},
|
||||
// 兜底:移动端断点切换 / Figma 缩放等导致 .content-wrap 重新挂载时,重绑滚动
|
||||
'$route.path'() {
|
||||
this.unbindScrollListener();
|
||||
this.$nextTick(() => this.bindScrollListener());
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.syncTabFromRoute(this.$route.query.type);
|
||||
this.fetchNewsData();
|
||||
window.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
// 注意:滚动容器是 main.vue 中的 .content-wrap,window 不会冒泡到滚动事件
|
||||
this.$nextTick(() => {
|
||||
this.bindScrollListener();
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('scroll', this.onScroll);
|
||||
this.unbindScrollListener();
|
||||
},
|
||||
methods: {
|
||||
goHomeToNews() {
|
||||
@ -162,17 +170,11 @@ export default {
|
||||
// 同 tab 直接 no-op(避免无意义的状态抖动)
|
||||
if (this.activeTab === index) return;
|
||||
this.activeTab = index;
|
||||
this.bannerCollapsed = true;
|
||||
const type = this.newsTabs[index]?.type;
|
||||
if (this.$route.query.type !== type) {
|
||||
this.$router.replace({ path: '/hydt', query: { type } });
|
||||
}
|
||||
// 平滑滚动到列表区,让用户看到收起后的内容
|
||||
// 收起 banner:列表区本来就在 banner 紧邻下方,收起后用户自然能看到更多列表。
|
||||
// 注意:这里不再调用 $router.replace,否则会触发 main.vue 中 $route.watch
|
||||
// 把 .content-wrap.scrollTop 瞬间置 0,导致用户感觉到"弹一下"。
|
||||
this.$nextTick(() => {
|
||||
const body = this.$el.querySelector('.hydt-body');
|
||||
if (body) {
|
||||
body.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
this.bannerCollapsed = true;
|
||||
});
|
||||
},
|
||||
onPageChange() {
|
||||
@ -198,6 +200,28 @@ export default {
|
||||
if (portalRoot) return portalRoot;
|
||||
return window;
|
||||
},
|
||||
bindScrollListener() {
|
||||
// 必须把滚动监听挂到真正的滚动容器 .content-wrap 上;
|
||||
// 之前挂在 window 上,window 不会冒泡来自内部容器的滚动事件,导致 onScroll 永不触发
|
||||
this.unbindScrollListener();
|
||||
const root = this.getScrollRoot();
|
||||
if (!root || root === window) {
|
||||
this._scrollTarget = window;
|
||||
window.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
} else {
|
||||
this._scrollTarget = root;
|
||||
root.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
}
|
||||
},
|
||||
unbindScrollListener() {
|
||||
if (!this._scrollTarget) {
|
||||
// 兜底:组件销毁时即使 _scrollTarget 未初始化也要清掉 window 上的残留监听
|
||||
window.removeEventListener('scroll', this.onScroll);
|
||||
return;
|
||||
}
|
||||
this._scrollTarget.removeEventListener('scroll', this.onScroll);
|
||||
this._scrollTarget = null;
|
||||
},
|
||||
handleNewsClick(item) {
|
||||
const link = item.yyLj || item.lj;
|
||||
if (link) {
|
||||
|
||||
@ -107,7 +107,7 @@ public class SearchServiceImpl implements SearchService {
|
||||
|
||||
@Override
|
||||
public List<String> getHotSearchKeywords() {
|
||||
return Arrays.asList("碳达峰", "碳核查", "ESG", "碳资产管理", "ISO 14067");
|
||||
return Arrays.asList("碳达峰", "ESG", "碳资产管理", "ISO 14067");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||