u
This commit is contained in:
1 parent
b4498e5350
commit
bf9dca19de
47 files changed
+8592
-313
No files matched your search
@@ -62,7 +62,10 @@ $dark-primary: #e5e5e5;
|
||||
#{fn.css-var('font-size')}: 14px;
|
||||
#{fn.css-var('font-size-lg')}: 16px;
|
||||
|
||||
/* ---------- 字体 ---------- */
|
||||
/* ---------- 字体 ----------
|
||||
只提供「兜底」,不做实际选型:组件库不假定宿主装了什么字体,
|
||||
真正的字体栈由业务方(g3soft-erp)在自己的全局样式里覆盖 --g3-font-family。
|
||||
这里保留 sans-serif 系兜底,保证未覆盖时组件仍可读。 */
|
||||
#{fn.css-var('font-family')}: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||
#{fn.css-var('font-family-mono')}: ui-monospace, 'SF Mono', 'Cascadia Code', 'JetBrains Mono',
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 收敛范围:找出「容器小到装不下内容」的那个临界高度。
|
||||
* 表头 34 + 一行 30 = 64px(加外框上下各 1px → 66px)。
|
||||
* 低于这个高度,表格就是病态输入(连一行都放不下)。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
const clipSample = async (x, y, w, h) => {
|
||||
const shot = await page.screenshot({ clip: { x, y, width: w, height: h } })
|
||||
return page.evaluate(async (dataUrl) => {
|
||||
const img = new Image()
|
||||
await new Promise((res) => {
|
||||
img.onload = res
|
||||
img.src = dataUrl
|
||||
})
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = img.width
|
||||
cv.height = img.height
|
||||
const ctx = cv.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const d = ctx.getImageData(0, 0, img.width, img.height).data
|
||||
const out = []
|
||||
for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]])
|
||||
return out
|
||||
}, `data:image/png;base64,${shot.toString('base64')}`)
|
||||
}
|
||||
|
||||
const heights = [100, 95, 90, 85, 80, 75, 70, 68, 66, 64, 60]
|
||||
for (const h of heights) {
|
||||
await page.evaluate((hh) => {
|
||||
document.querySelector('[data-testid="table-wrap"]').style.height = `${hh}px`
|
||||
}, h)
|
||||
await page.waitForTimeout(420)
|
||||
await page.locator('[data-testid="table-wrap"]').scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(160)
|
||||
|
||||
const g = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const inner = wrap.querySelector('.stk-table')
|
||||
const r = root.getBoundingClientRect()
|
||||
return {
|
||||
left: Math.round(r.left),
|
||||
bottom: Math.round(r.bottom),
|
||||
overflow: inner.scrollHeight > inner.clientHeight + 1,
|
||||
scrollH: inner.scrollHeight,
|
||||
clientH: inner.clientHeight,
|
||||
}
|
||||
})
|
||||
const y = Math.max(0, g.bottom - 4)
|
||||
if (1200 - y < 10) continue
|
||||
const px = await clipSample(g.left + 300, y, 1, 10)
|
||||
const vec = px.map(([r, gg, b]) => (r < 246 || gg < 246 || b < 246 ? 1 : 0))
|
||||
const n = vec.reduce((a, b) => a + b, 0)
|
||||
console.log(
|
||||
`h=${String(h).padStart(3)} 溢出=${String(g.overflow).padStart(5)} (scrollH ${g.scrollH} / clientH ${g.clientH}) 底部 ${vec.join('')} (${n}条) ${n === 1 ? '✅' : '❌'}`,
|
||||
)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,71 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 用**正确阈值**重测。
|
||||
* 之前的探针用 r<246 判"深色",而表头底色是 245 → 被误判成线。
|
||||
* 边框线是 217,白底是 255,表头灰是 245。
|
||||
* 正确判据:只看接近 217 的像素(容差 ±6),把 245 和 255 都当作"非线"。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const LINE = 217
|
||||
const TOL = 6
|
||||
|
||||
const isLine = ([r, g, b]) =>
|
||||
Math.abs(r - LINE) <= TOL && Math.abs(g - LINE) <= TOL && Math.abs(b - LINE) <= TOL
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
const clipSample = async (x, y, w, h) => {
|
||||
const shot = await page.screenshot({ clip: { x, y, width: w, height: h } })
|
||||
return page.evaluate(async (dataUrl) => {
|
||||
const img = new Image()
|
||||
await new Promise((res) => {
|
||||
img.onload = res
|
||||
img.src = dataUrl
|
||||
})
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = img.width
|
||||
cv.height = img.height
|
||||
const ctx = cv.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const d = ctx.getImageData(0, 0, img.width, img.height).data
|
||||
const out = []
|
||||
for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]])
|
||||
return out
|
||||
}, `data:image/png;base64,${shot.toString('base64')}`)
|
||||
}
|
||||
|
||||
const heights = [500, 400, 350, 300, 250, 200, 180, 160, 150, 140, 130, 120, 110, 100, 90, 80]
|
||||
|
||||
console.log('=== 正确阈值(只认 217±6,245/255 均视为非线)===')
|
||||
console.log('容器高 | 底边像素(217线) | 线数 | 判定')
|
||||
let bad = 0
|
||||
for (const h of heights) {
|
||||
await page.evaluate((hh) => {
|
||||
document.querySelector('[data-testid="table-big-wrap"]').style.height = `${hh}px`
|
||||
}, h)
|
||||
await page.waitForTimeout(420)
|
||||
await page.locator('[data-testid="table-big-wrap"]').scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(180)
|
||||
|
||||
const g = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-big-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const r = root.getBoundingClientRect()
|
||||
return { left: Math.round(r.left), bottom: Math.round(r.bottom) }
|
||||
})
|
||||
const y = Math.max(0, g.bottom - 5)
|
||||
if (1200 - y < 12) continue
|
||||
const px = await clipSample(g.left + 300, y, 1, 12)
|
||||
const vec = px.map((p) => (isLine(p) ? 1 : 0))
|
||||
const n = vec.reduce((a, b) => a + b, 0)
|
||||
if (n !== 1) bad += 1
|
||||
console.log(
|
||||
`${String(h).padStart(6)} | ${vec.join('')} | ${String(n).padStart(4)} | ${n === 1 ? '✅ 一条线' : n === 0 ? '❌ 缺线' : '❌ ' + n + ' 条'}`,
|
||||
)
|
||||
}
|
||||
console.log(`\n失败档位: ${bad} 个`)
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,91 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 方向验证:在滚动容器底部覆盖一条 1px 的同色线(画在格线之上、外框之下),
|
||||
* 把"被裁切行留下的底边线"盖掉,使可视底边恒为一条。
|
||||
*
|
||||
* 用 ::after 伪元素实现(absolute,画在内容上层)。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
const clipSample = async (x, y, w, h) => {
|
||||
const shot = await page.screenshot({ clip: { x, y, width: w, height: h } })
|
||||
return page.evaluate(async (dataUrl) => {
|
||||
const img = new Image()
|
||||
await new Promise((res) => {
|
||||
img.onload = res
|
||||
img.src = dataUrl
|
||||
})
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = img.width
|
||||
cv.height = img.height
|
||||
const ctx = cv.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const d = ctx.getImageData(0, 0, img.width, img.height).data
|
||||
const out = []
|
||||
for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]])
|
||||
return out
|
||||
}, `data:image/png;base64,${shot.toString('base64')}`)
|
||||
}
|
||||
|
||||
const applyPatch = () =>
|
||||
page.evaluate(() => {
|
||||
const s = document.createElement('style')
|
||||
s.id = 'probe-cover'
|
||||
s.textContent = `
|
||||
/* 覆盖式收边:在 .stk-table 可视底部画一条与外框同色的线,压住被裁行的底边 */
|
||||
.g3-table.is-bordered .g3-table__inner.stk-table { position: relative; }
|
||||
.g3-table.is-bordered .g3-table__inner.stk-table::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: var(--g3-border-color, #d9d9d9);
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
|
||||
const heights = [500, 400, 300, 250, 200, 180, 160, 150, 140, 130, 120, 110, 100]
|
||||
|
||||
const sweep = async (label) => {
|
||||
const bad = []
|
||||
for (const h of heights) {
|
||||
await page.evaluate((hh) => {
|
||||
document.querySelector('[data-testid="table-big-wrap"]').style.height = `${hh}px`
|
||||
}, h)
|
||||
await page.waitForTimeout(420)
|
||||
await page.locator('[data-testid="table-big-wrap"]').scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(180)
|
||||
|
||||
const g = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-big-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const r = root.getBoundingClientRect()
|
||||
return { left: Math.round(r.left), bottom: Math.round(r.bottom) }
|
||||
})
|
||||
const y = Math.max(0, g.bottom - 4)
|
||||
if (1200 - y < 10) continue
|
||||
const px = await clipSample(g.left + 300, y, 1, 10)
|
||||
const vec = px.map(([r, gg, b]) => (r < 246 || gg < 246 || b < 246 ? 1 : 0))
|
||||
const n = vec.reduce((a, b) => a + b, 0)
|
||||
if (n !== 1) bad.push(`h=${h}(${n})`)
|
||||
console.log(` ${label} h=${String(h).padStart(3)} ${vec.join('')} (${n}条) ${n === 1 ? '✅' : '❌'}`)
|
||||
}
|
||||
console.log(` → ${label} 失败档位: ${bad.length ? bad.join(', ') : '无 ✅'}\n`)
|
||||
}
|
||||
|
||||
console.log('===== 修复前(table-big,真实滚动)=====')
|
||||
await sweep('before')
|
||||
|
||||
console.log('===== 加覆盖式收边 =====')
|
||||
await applyPatch()
|
||||
await sweep('patch ')
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,106 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 定位 h=80 时底部那 4 个连续深色像素来自哪个元素。
|
||||
* 做法:用一个只在上方有元素的地方做"逐元素排除"——逐个隐藏候选元素,看哪一条消失。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
const clipSample = async (x, y, w, h) => {
|
||||
const shot = await page.screenshot({ clip: { x, y, width: w, height: h } })
|
||||
return page.evaluate(async (dataUrl) => {
|
||||
const img = new Image()
|
||||
await new Promise((res) => {
|
||||
img.onload = res
|
||||
img.src = dataUrl
|
||||
})
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = img.width
|
||||
cv.height = img.height
|
||||
const ctx = cv.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const d = ctx.getImageData(0, 0, img.width, img.height).data
|
||||
const out = []
|
||||
for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]])
|
||||
return out
|
||||
}, `data:image/png;base64,${shot.toString('base64')}`)
|
||||
}
|
||||
|
||||
const vec = async (x, y) => {
|
||||
const px = await clipSample(x, y, 1, 10)
|
||||
return px.map(([r, g, b]) => (r < 246 || g < 246 || b < 246 ? 1 : 0)).join('')
|
||||
}
|
||||
|
||||
// 压到 80px
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('[data-testid="table-wrap"]').style.height = '80px'
|
||||
})
|
||||
await page.waitForTimeout(600)
|
||||
await page.locator('[data-testid="table-wrap"]').scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const geo = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const r = root.getBoundingClientRect()
|
||||
const inner = wrap.querySelector('.stk-table')
|
||||
const tbody = wrap.querySelector('.stk-tbody-main')
|
||||
return {
|
||||
left: Math.round(r.left),
|
||||
rootRect: { top: Math.round(r.top), bottom: Math.round(r.bottom) },
|
||||
innerRect: (() => {
|
||||
const x = inner.getBoundingClientRect()
|
||||
return { top: Math.round(x.top), bottom: Math.round(x.bottom), scrollH: inner.scrollHeight, clientH: inner.clientHeight }
|
||||
})(),
|
||||
tbodyRect: (() => {
|
||||
const x = tbody.getBoundingClientRect()
|
||||
return { top: Math.round(x.top), bottom: Math.round(x.bottom) }
|
||||
})(),
|
||||
}
|
||||
})
|
||||
|
||||
console.log('=== h=80 几何 ===')
|
||||
console.log(JSON.stringify(geo, null, 2))
|
||||
|
||||
const x = geo.left + 300
|
||||
const base = await vec(x, geo.rootRect.bottom - 4)
|
||||
console.log(`\n基线采样(root.bottom-4 起 10 行): ${base}`)
|
||||
|
||||
// 逐元素隐藏,看哪一次让多余的线消失
|
||||
const candidates = [
|
||||
['tbody 最后一行', '.stk-tbody-main tr:last-child'],
|
||||
['tbody', '.stk-tbody-main'],
|
||||
['thead', 'thead'],
|
||||
['stk-table 根', '.stk-table'],
|
||||
]
|
||||
for (const [name, sel] of candidates) {
|
||||
const v = await page.evaluate(
|
||||
({ sel, y, x }) => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
const el = wrap.querySelector(sel)
|
||||
if (!el) return 'not-found'
|
||||
const prev = el.style.visibility
|
||||
el.style.visibility = 'hidden'
|
||||
return 'hidden'
|
||||
},
|
||||
{ sel, y: geo.rootRect.bottom - 4, x },
|
||||
)
|
||||
if (v === 'not-found') {
|
||||
console.log(`隐藏 ${name}: 元素不存在`)
|
||||
continue
|
||||
}
|
||||
await page.waitForTimeout(150)
|
||||
const after = await vec(x, geo.rootRect.bottom - 4)
|
||||
console.log(`隐藏 ${name.padEnd(16)} → ${after} ${after !== base ? '← 变化' : ''}`)
|
||||
await page.evaluate((sel) => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
const el = wrap.querySelector(sel)
|
||||
if (el) el.style.visibility = ''
|
||||
}, sel)
|
||||
await page.waitForTimeout(120)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,69 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 上一步的疑点:lastRowBottom 恒为 3053,不随容器高度变化。
|
||||
* 猜想:容器变矮 → 行区溢出/被裁 → 该坐标不再是"可见的最后一行底边"。
|
||||
*
|
||||
* 这里把「滚动容器」也一起量出来,看清溢出时到底发生了什么。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
for (const h of [400, 200, 128, 126, 120, 80]) {
|
||||
await page.evaluate((hh) => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
wrap.style.height = `${hh}px`
|
||||
}, h)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const r = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const inner = wrap.querySelector('.stk-table')
|
||||
const tbody = wrap.querySelector('.stk-tbody-main')
|
||||
const rows = [...wrap.querySelectorAll('.stk-tbody-main tr')]
|
||||
const last = rows[rows.length - 1]
|
||||
|
||||
// 找到所有可能的滚动容器
|
||||
const scrollers = [...wrap.querySelectorAll('*')]
|
||||
.filter((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return /auto|scroll/.test(s.overflowY) && el.scrollHeight > el.clientHeight + 1
|
||||
})
|
||||
.map((el) => ({
|
||||
cls: el.className.toString().slice(0, 40),
|
||||
clientH: el.clientHeight,
|
||||
scrollH: el.scrollHeight,
|
||||
scrollTop: el.scrollTop,
|
||||
}))
|
||||
|
||||
const hasBottom = (s) => /linear-gradient\(0deg, rgb/.test(s)
|
||||
const innerRect = inner.getBoundingClientRect()
|
||||
const cs = getComputedStyle(inner)
|
||||
|
||||
return {
|
||||
rootCls: root.className,
|
||||
wrapH: Math.round(wrap.getBoundingClientRect().height),
|
||||
innerRect: { top: Math.round(innerRect.top), bottom: Math.round(innerRect.bottom) },
|
||||
innerBottomEdge: Math.round(innerRect.bottom - (parseFloat(cs.borderBottomWidth) || 0)),
|
||||
tbodyRect: tbody
|
||||
? { top: Math.round(tbody.getBoundingClientRect().top), bottom: Math.round(tbody.getBoundingClientRect().bottom), h: Math.round(tbody.getBoundingClientRect().height) }
|
||||
: null,
|
||||
lastRow: last
|
||||
? {
|
||||
top: Math.round(last.getBoundingClientRect().top),
|
||||
bottom: Math.round(last.getBoundingClientRect().bottom),
|
||||
draws: [...last.querySelectorAll('td')].some((td) => hasBottom(getComputedStyle(td).backgroundImage)),
|
||||
}
|
||||
: null,
|
||||
rowBottoms: rows.map((tr) => Math.round(tr.getBoundingClientRect().bottom)),
|
||||
scrollers,
|
||||
}
|
||||
})
|
||||
console.log(`\n===== container ${h}px =====`)
|
||||
console.log(JSON.stringify(r, null, 2))
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,81 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 方案验证:外框底边若改用 inset box-shadow(画在 padding box 内侧、覆盖在上层),
|
||||
* 是否能"永远赢"——即无论最后一行是否贴近,视觉上只有一条线。
|
||||
*
|
||||
* 先做纯 CSS 实验:在页面上注入覆盖样式,然后逐像素扫描底边,
|
||||
* 数出「深色像素的数量」,1 条 = 正确,2 条 = 双线,0 条 = 缺线。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
const clipSample = async (x, y, w, h) => {
|
||||
const shot = await page.screenshot({ clip: { x, y, width: w, height: h } })
|
||||
return page.evaluate(async (dataUrl) => {
|
||||
const img = new Image()
|
||||
await new Promise((res) => {
|
||||
img.onload = res
|
||||
img.src = dataUrl
|
||||
})
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = img.width
|
||||
cv.height = img.height
|
||||
const ctx = cv.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const d = ctx.getImageData(0, 0, img.width, img.height).data
|
||||
const out = []
|
||||
for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]])
|
||||
return out
|
||||
}, `data:image/png;base64,${shot.toString('base64')}`)
|
||||
}
|
||||
|
||||
const scan = async (label, h) => {
|
||||
await page.evaluate((hh) => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
wrap.style.height = `${hh}px`
|
||||
}, h)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 必须先把表格滚进视口:page.screenshot 的 clip 用视口坐标,页面下方的元素会落空
|
||||
const wrapLoc = page.locator('[data-testid="table-wrap"]')
|
||||
await wrapLoc.scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const geo = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const r = root.getBoundingClientRect()
|
||||
return {
|
||||
left: Math.round(r.left),
|
||||
// getBoundingClientRect 已是视口坐标,直接可用
|
||||
bottom: Math.round(r.bottom),
|
||||
isBodyFill: root.classList.contains('is-body-fill'),
|
||||
}
|
||||
})
|
||||
|
||||
// 采样窗口必须完整落在视口内
|
||||
const y0 = Math.max(0, geo.bottom - 4)
|
||||
const available = 1200 - y0
|
||||
if (available < 8) {
|
||||
console.log(`${label} h=${h} 跳过(采样窗口超出视口)`)
|
||||
return null
|
||||
}
|
||||
|
||||
const px = await clipSample(geo.left + 300, y0, 1, 8)
|
||||
const dark = px.map(([r, g, b]) => (r < 246 || g < 246 || b < 246 ? 1 : 0))
|
||||
const count = dark.reduce((a, b) => a + b, 0)
|
||||
console.log(
|
||||
`${label} h=${String(h).padStart(3)} bodyFill=${String(geo.isBodyFill).padStart(5)} 底边纵向像素: ${dark.join('')} → ${count === 1 ? '一条线 ✅' : count === 0 ? '缺线 ❌' : count + ' 条 ❌'}`,
|
||||
)
|
||||
return count
|
||||
}
|
||||
|
||||
console.log('========== 修复前(当前实现)==========')
|
||||
for (const h of [400, 200, 140, 130, 128, 127, 126, 125, 124, 120, 100, 80, 60]) {
|
||||
await scan('before', h)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,104 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 路 B 验证:最后一行**永远不画**自己的底边线,底边由外框独占。
|
||||
* —— 不加任何开关,不看容器高度,行本身永不画底边。
|
||||
*
|
||||
* 期望:任何容器高度下,底边都恰好一条线。
|
||||
* 另需确认:行与行之间的分隔线(第 1、2 行之间)不受影响。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
const clipSample = async (x, y, w, h) => {
|
||||
const shot = await page.screenshot({ clip: { x, y, width: w, height: h } })
|
||||
return page.evaluate(async (dataUrl) => {
|
||||
const img = new Image()
|
||||
await new Promise((res) => {
|
||||
img.onload = res
|
||||
img.src = dataUrl
|
||||
})
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = img.width
|
||||
cv.height = img.height
|
||||
const ctx = cv.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const d = ctx.getImageData(0, 0, img.width, img.height).data
|
||||
const out = []
|
||||
for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]])
|
||||
return out
|
||||
}, `data:image/png;base64,${shot.toString('base64')}`)
|
||||
}
|
||||
|
||||
// 路 B 补丁:最后一行永不画底边(无条件),且保留 is-body-fill 的既有规则不冲突
|
||||
const applyB = () =>
|
||||
page.evaluate(() => {
|
||||
const s = document.createElement('style')
|
||||
s.id = 'probe-b'
|
||||
s.textContent = `
|
||||
/* 路 B:数据区最后一行永不画自己的底边线 —— 底边永远由外框独占 */
|
||||
.g3-table.is-bordered .g3-table__inner.stk-table.bordered-v
|
||||
.stk-tbody-main tr:last-child > td {
|
||||
--bg-border-bottom: linear-gradient(transparent, transparent) !important;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
|
||||
const scanColumn = async (label, h) => {
|
||||
await page.evaluate((hh) => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
wrap.style.height = `${hh}px`
|
||||
}, h)
|
||||
await page.waitForTimeout(450)
|
||||
await page.locator('[data-testid="table-wrap"]').scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(180)
|
||||
|
||||
const geo = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const r = root.getBoundingClientRect()
|
||||
const rows = [...wrap.querySelectorAll('.stk-tbody-main tr')]
|
||||
return {
|
||||
left: Math.round(r.left),
|
||||
top: Math.round(r.top),
|
||||
bottom: Math.round(r.bottom),
|
||||
rowBottoms: rows.map((tr) => Math.round(tr.getBoundingClientRect().bottom)),
|
||||
}
|
||||
})
|
||||
|
||||
// ① 底部:外框底边
|
||||
const yB = Math.max(0, geo.bottom - 4)
|
||||
let bottomVec = 'skip'
|
||||
if (1200 - yB >= 8) {
|
||||
const px = await clipSample(geo.left + 300, yB, 1, 8)
|
||||
const dark = px.map(([r, g, b]) => (r < 246 || g < 246 || b < 246 ? 1 : 0))
|
||||
bottomVec = `${dark.join('')} (${dark.reduce((a, b) => a + b, 0)}条)`
|
||||
}
|
||||
|
||||
// ② 行间分隔线:第 1 行与第 2 行之间那条,必须还在
|
||||
let innerVec = 'skip'
|
||||
if (geo.rowBottoms.length >= 2) {
|
||||
const yI = Math.max(0, geo.rowBottoms[0] - 4)
|
||||
if (1200 - yI >= 8) {
|
||||
const px = await clipSample(geo.left + 300, yI, 1, 8)
|
||||
const dark = px.map(([r, g, b]) => (r < 246 || g < 246 || b < 246 ? 1 : 0))
|
||||
innerVec = `${dark.join('')} (${dark.reduce((a, b) => a + b, 0)}条)`
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${label} h=${String(h).padStart(3)} 底部: ${bottomVec.padEnd(16)} 行间: ${innerVec}`)
|
||||
}
|
||||
|
||||
const heights = [400, 140, 130, 129, 128, 127, 126, 124, 120, 100, 80, 60]
|
||||
|
||||
console.log('===== 修复前 =====')
|
||||
for (const h of heights) await scanColumn('before', h)
|
||||
|
||||
console.log('\n===== 路 B:最后一行永不画底边(无开关)=====')
|
||||
await applyB()
|
||||
for (const h of heights) await scanColumn('B ', h)
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,69 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 停止试错,先看清真相:把那 4 个像素的**实际 RGB 和所属元素**打出来。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
const clipSample = async (x, y, w, h) => {
|
||||
const shot = await page.screenshot({ clip: { x, y, width: w, height: h } })
|
||||
return page.evaluate(async (dataUrl) => {
|
||||
const img = new Image()
|
||||
await new Promise((res) => {
|
||||
img.onload = res
|
||||
img.src = dataUrl
|
||||
})
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = img.width
|
||||
cv.height = img.height
|
||||
const ctx = cv.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const d = ctx.getImageData(0, 0, img.width, img.height).data
|
||||
const out = []
|
||||
for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]])
|
||||
return out
|
||||
}, `data:image/png;base64,${shot.toString('base64')}`)
|
||||
}
|
||||
|
||||
// 取一档"坏"的(h=250)和一档"好"的(h=400)
|
||||
for (const h of [250, 400]) {
|
||||
await page.evaluate((hh) => {
|
||||
document.querySelector('[data-testid="table-big-wrap"]').style.height = `${hh}px`
|
||||
}, h)
|
||||
await page.waitForTimeout(500)
|
||||
await page.locator('[data-testid="table-big-wrap"]').scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(220)
|
||||
|
||||
const g = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-big-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const r = root.getBoundingClientRect()
|
||||
// 把该 x 位置自上而下的元素列出来(用 elementFromPoint 逐个 y 采样)
|
||||
return { left: Math.round(r.left), bottom: Math.round(r.bottom) }
|
||||
})
|
||||
|
||||
const x = g.left + 300
|
||||
console.log(`\n=========== h=${h} root.bottom=${g.bottom} ===========`)
|
||||
console.log('从 root.bottom-6 到 root.bottom+3,每行的色值与该点元素:')
|
||||
const px = await clipSample(x, g.bottom - 6, 1, 10)
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const y = g.bottom - 6 + i
|
||||
const info = await page.evaluate(
|
||||
({ x, y }) => {
|
||||
const el = document.elementFromPoint(x, y)
|
||||
if (!el) return 'null'
|
||||
const s = getComputedStyle(el)
|
||||
return `${el.tagName}.${String(el.className).slice(0, 45)} | bg=${s.backgroundColor} | bgImg=${s.backgroundImage === 'none' ? 'none' : 'HAS'} | shadow=${s.boxShadow === 'none' ? 'none' : 'HAS'}`
|
||||
},
|
||||
{ x, y },
|
||||
)
|
||||
const [r, gg, b] = px[i]
|
||||
const mark = r < 246 || gg < 246 || b < 246 ? '◀ 深' : ''
|
||||
console.log(` y=${y} rgb(${r},${gg},${b}) ${mark} ${info}`)
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,78 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 真实场景:内容多、需要垂直滚动。
|
||||
* 用 table-big-wrap(5000 行虚拟滚动)与 table-wrap 塞满行两种方式,
|
||||
* 看底部到底是几条线、以及滚动到不同位置时是否稳定。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
const clipSample = async (x, y, w, h) => {
|
||||
const shot = await page.screenshot({ clip: { x, y, width: w, height: h } })
|
||||
return page.evaluate(async (dataUrl) => {
|
||||
const img = new Image()
|
||||
await new Promise((res) => {
|
||||
img.onload = res
|
||||
img.src = dataUrl
|
||||
})
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = img.width
|
||||
cv.height = img.height
|
||||
const ctx = cv.getContext('2d')
|
||||
ctx.drawImage(ctx.canvas, 0, 0)
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const d = ctx.getImageData(0, 0, img.width, img.height).data
|
||||
const out = []
|
||||
for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]])
|
||||
return out
|
||||
}, `data:image/png;base64,${shot.toString('base64')}`)
|
||||
}
|
||||
|
||||
const probe = async (testid, label) => {
|
||||
const el = page.locator(`[data-testid="${testid}"]`)
|
||||
await el.scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const g = await el.evaluate((wrap) => {
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const inner = wrap.querySelector('.stk-table')
|
||||
const r = root.getBoundingClientRect()
|
||||
return {
|
||||
left: Math.round(r.left),
|
||||
bottom: Math.round(r.bottom),
|
||||
rootCls: root.className,
|
||||
overflow: inner.scrollHeight > inner.clientHeight + 1,
|
||||
scrollH: inner.scrollHeight,
|
||||
clientH: inner.clientHeight,
|
||||
rowCount: wrap.querySelectorAll('.stk-tbody-main tr').length,
|
||||
}
|
||||
})
|
||||
|
||||
const y = Math.max(0, g.bottom - 4)
|
||||
if (1200 - y < 10) {
|
||||
console.log(`${label}: 采样超出视口 (bottom=${g.bottom})`)
|
||||
return
|
||||
}
|
||||
const px = await clipSample(g.left + 300, y, 1, 10)
|
||||
const vec = px.map(([r, gg, b]) => (r < 246 || gg < 246 || b < 246 ? 1 : 0))
|
||||
const n = vec.reduce((a, b) => a + b, 0)
|
||||
console.log(
|
||||
`${label.padEnd(18)} overflow=${String(g.overflow).padStart(5)} (${g.scrollH}/${g.clientH}) rows=${String(g.rowCount).padStart(3)} 底部 ${vec.join('')} (${n}条) ${n === 1 ? '✅' : '❌'} bodyFill=${g.rootCls.includes('is-body-fill')}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log('===== 现有 playground 表格 =====')
|
||||
for (const [id, label] of [
|
||||
['table-wrap', 'table-wrap(3行)'],
|
||||
['table-big-wrap', 'table-big(5000行)'],
|
||||
['table-plain-wrap', 'table-plain(2行)'],
|
||||
['table-fixed-wrap', 'table-fixed'],
|
||||
]) {
|
||||
if ((await page.locator(`[data-testid="${id}"]`).count()) === 0) continue
|
||||
await probe(id, label)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,79 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 真正的滚动场景复现:行数固定(多到溢出),容器高度在「行高之和」附近连续变化。
|
||||
* 这才是用户说的「表格高度 <= 行高之和」——内容溢出并出现滚动条。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
const clipSample = async (x, y, w, h) => {
|
||||
const shot = await page.screenshot({ clip: { x, y, width: w, height: h } })
|
||||
return page.evaluate(async (dataUrl) => {
|
||||
const img = new Image()
|
||||
await new Promise((res) => {
|
||||
img.onload = res
|
||||
img.src = dataUrl
|
||||
})
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = img.width
|
||||
cv.height = img.height
|
||||
const ctx = cv.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const d = ctx.getImageData(0, 0, img.width, img.height).data
|
||||
const out = []
|
||||
for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]])
|
||||
return out
|
||||
}, `data:image/png;base64,${shot.toString('base64')}`)
|
||||
}
|
||||
|
||||
// 用 5000 行的 table-big(真实滚动)扫高度
|
||||
// 表头 34 + 行 30×N;这里固定用大数据量表,只改容器高度
|
||||
const heights = [500, 400, 300, 250, 200, 180, 160, 150, 140, 130, 120, 110, 100]
|
||||
|
||||
console.log('=== table-big(5000 行,真实滚动)扫容器高度 ===')
|
||||
for (const h of heights) {
|
||||
await page.evaluate((hh) => {
|
||||
document.querySelector('[data-testid="table-big-wrap"]').style.height = `${hh}px`
|
||||
}, h)
|
||||
await page.waitForTimeout(450)
|
||||
await page.locator('[data-testid="table-big-wrap"]').scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const g = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-big-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const inner = wrap.querySelector('.stk-table')
|
||||
const r = root.getBoundingClientRect()
|
||||
const rows = [...wrap.querySelectorAll('.stk-tbody-main tr')]
|
||||
const last = rows[rows.length - 1]
|
||||
return {
|
||||
left: Math.round(r.left),
|
||||
bottom: Math.round(r.bottom),
|
||||
bodyFill: root.classList.contains('is-body-fill'),
|
||||
scrollH: inner.scrollHeight,
|
||||
clientH: inner.clientHeight,
|
||||
lastRowBottom: last ? Math.round(last.getBoundingClientRect().bottom) : null,
|
||||
innerBottom: (() => {
|
||||
const ir = inner.getBoundingClientRect()
|
||||
return Math.round(ir.bottom)
|
||||
})(),
|
||||
}
|
||||
})
|
||||
|
||||
const y = Math.max(0, g.bottom - 4)
|
||||
if (1200 - y < 10) {
|
||||
console.log(`h=${h} 超出视口`)
|
||||
continue
|
||||
}
|
||||
const px = await clipSample(g.left + 300, y, 1, 10)
|
||||
const vec = px.map(([r, gg, b]) => (r < 246 || gg < 246 || b < 246 ? 1 : 0))
|
||||
const n = vec.reduce((a, b) => a + b, 0)
|
||||
console.log(
|
||||
`h=${String(h).padStart(3)} fill=${String(g.bodyFill).padStart(5)} lastRowBottom=${String(g.lastRowBottom).padStart(5)} innerBottom=${String(g.innerBottom).padStart(5)} 底部 ${vec.join('')} (${n}条) ${n === 1 ? '✅' : '❌'}`,
|
||||
)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,92 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 验证方向:外框底边改用 inset box-shadow(绘制层级高于背景/格线),
|
||||
* 并去掉"按 bodyFill 摘线"的开关 —— 让外框永远赢。
|
||||
*
|
||||
* 期望:无论容器多高,底边永远只有一条线。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
const clipSample = async (x, y, w, h) => {
|
||||
const shot = await page.screenshot({ clip: { x, y, width: w, height: h } })
|
||||
return page.evaluate(async (dataUrl) => {
|
||||
const img = new Image()
|
||||
await new Promise((res) => {
|
||||
img.onload = res
|
||||
img.src = dataUrl
|
||||
})
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = img.width
|
||||
cv.height = img.height
|
||||
const ctx = cv.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const d = ctx.getImageData(0, 0, img.width, img.height).data
|
||||
const out = []
|
||||
for (let i = 0; i < d.length; i += 4) out.push([d[i], d[i + 1], d[i + 2]])
|
||||
return out
|
||||
}, `data:image/png;base64,${shot.toString('base64')}`)
|
||||
}
|
||||
|
||||
const applyPatched = () =>
|
||||
page.evaluate(() => {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'probe-patch'
|
||||
style.textContent = `
|
||||
/* 外框改 inset box-shadow:层级高于背景与格线,重合也不会变粗 */
|
||||
.g3-table.is-bordered {
|
||||
border-color: transparent !important;
|
||||
box-shadow: inset 0 0 0 1px var(--g3-border-color, #d9d9d9) !important;
|
||||
}
|
||||
/* 同时保留圆角裁剪 */
|
||||
.g3-table.is-bordered { border-radius: var(--g3-radius-lg, 8px) !important; }
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
})
|
||||
|
||||
const removePatched = () =>
|
||||
page.evaluate(() => {
|
||||
document.getElementById('probe-patch')?.remove()
|
||||
})
|
||||
|
||||
const scan = async (label, h) => {
|
||||
await page.evaluate((hh) => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
wrap.style.height = `${hh}px`
|
||||
}, h)
|
||||
await page.waitForTimeout(450)
|
||||
const wrapLoc = page.locator('[data-testid="table-wrap"]')
|
||||
await wrapLoc.scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(180)
|
||||
|
||||
const geo = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const r = root.getBoundingClientRect()
|
||||
return { left: Math.round(r.left), bottom: Math.round(r.bottom) }
|
||||
})
|
||||
|
||||
const y0 = Math.max(0, geo.bottom - 4)
|
||||
if (1200 - y0 < 8) return
|
||||
const px = await clipSample(geo.left + 300, y0, 1, 8)
|
||||
const dark = px.map(([r, g, b]) => (r < 246 || g < 246 || b < 246 ? 1 : 0))
|
||||
const count = dark.reduce((a, b) => a + b, 0)
|
||||
console.log(
|
||||
`${label} h=${String(h).padStart(3)} ${dark.join('')} → ${count === 1 ? '一条线 ✅' : count === 0 ? '缺线 ❌' : count + ' 条 ❌'}`,
|
||||
)
|
||||
}
|
||||
|
||||
const heights = [400, 140, 130, 129, 128, 127, 126, 124, 120, 100, 80, 60]
|
||||
|
||||
console.log('===== 修复前 =====')
|
||||
for (const h of heights) await scan('before', h)
|
||||
|
||||
console.log('\n===== 打补丁:外框改 inset box-shadow =====')
|
||||
await applyPatched()
|
||||
for (const h of heights) await scan('patch ', h)
|
||||
|
||||
await removePatched()
|
||||
await browser.close()
|
||||
@@ -0,0 +1,78 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 复现「表格高度 <= 行高之和 时底部重叠」。
|
||||
* 做法:保持 3 行数据不变,把容器高度从大到小扫一遍,
|
||||
* 记录 is-body-fill 标志、最后一行是否画线、以及外框内底边与最后一行底边的关系。
|
||||
*/
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
|
||||
const heights = [400, 300, 200, 160, 140, 128, 126, 124, 120, 100, 80, 60, 40]
|
||||
|
||||
const rows = []
|
||||
for (const h of heights) {
|
||||
await page.evaluate((hh) => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
wrap.style.height = `${hh}px`
|
||||
}, h)
|
||||
await page.waitForTimeout(450) // 等 resize debounce(150ms) + rAF + nextTick
|
||||
|
||||
const r = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
const inner = wrap.querySelector('.stk-table')
|
||||
const cs = getComputedStyle(inner)
|
||||
const rrc = root.getBoundingClientRect()
|
||||
const tbody = wrap.querySelector('.stk-tbody-main')
|
||||
const rowsEls = [...wrap.querySelectorAll('.stk-tbody-main tr')]
|
||||
const thead = wrap.querySelector('thead')
|
||||
|
||||
const hasBottom = (s) => /linear-gradient\(0deg, rgb/.test(s)
|
||||
const last = rowsEls[rowsEls.length - 1]
|
||||
|
||||
const available =
|
||||
inner.clientHeight - (parseFloat(cs.borderTopWidth) || 0) - (parseFloat(cs.borderBottomWidth) || 0)
|
||||
const headerH = thead ? thead.getBoundingClientRect().height : 0
|
||||
const bodyH = rowsEls.reduce((a, tr) => a + tr.getBoundingClientRect().height, 0)
|
||||
|
||||
return {
|
||||
wrapH: Math.round(wrap.getBoundingClientRect().height),
|
||||
frameBottom: Math.round(rrc.bottom),
|
||||
frameInnerBottom: Math.round(rrc.bottom) - 1,
|
||||
available: Math.round(available),
|
||||
headerH: Math.round(headerH),
|
||||
bodyH: Math.round(bodyH),
|
||||
used: Math.round(headerH + bodyH),
|
||||
isBodyFill: root.classList.contains('is-body-fill'),
|
||||
// 最后一行底边 vs 外框内底边
|
||||
lastRowBottom: last ? Math.round(last.getBoundingClientRect().bottom) : null,
|
||||
// 最后一行是否自己画底线
|
||||
lastRowDrawsBottom: last
|
||||
? [...last.querySelectorAll('td')].some((td) => hasBottom(getComputedStyle(td).backgroundImage))
|
||||
: null,
|
||||
// 最后一行底边是否与外框内底边重合(重合且自己还在画线 = 双线)
|
||||
lastRowTouchesFrame: last
|
||||
? Math.abs(Math.round(last.getBoundingClientRect().bottom) - (Math.round(rrc.bottom) - 1)) <= 2
|
||||
: null,
|
||||
tbodyBottom: tbody ? Math.round(tbody.getBoundingClientRect().bottom) : null,
|
||||
rowCount: rowsEls.length,
|
||||
}
|
||||
})
|
||||
rows.push({ ...r, targetH: h })
|
||||
}
|
||||
|
||||
console.log(
|
||||
'targetH | avail | hdr | body | used | fill | lastRowBottom | frameInner | touches | drawsLine | 判定',
|
||||
)
|
||||
for (const r of rows) {
|
||||
const double = r.lastRowTouchesFrame && r.lastRowDrawsBottom
|
||||
const verdict = double ? '★双线!' : !r.lastRowDrawsBottom && !r.lastRowTouchesFrame ? '缺线?' : 'ok'
|
||||
console.log(
|
||||
`${String(r.targetH).padStart(6)} | ${String(r.available).padStart(5)} | ${String(r.headerH).padStart(3)} | ${String(r.bodyH).padStart(4)} | ${String(r.used).padStart(4)} | ${String(r.isBodyFill).padStart(5)} | ${String(r.lastRowBottom).padStart(13)} | ${String(r.frameInnerBottom).padStart(10)} | ${String(r.lastRowTouchesFrame).padStart(7)} | ${String(r.lastRowDrawsBottom).padStart(9)} | ${verdict}`,
|
||||
)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,41 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
// 直接确认:dev server 吐给浏览器的 G3Table.vue 里,到底有没有新判据。
|
||||
const BASE = process.env.PROBE_URL || 'http://localhost:5200'
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } })
|
||||
|
||||
const seen = []
|
||||
page.on('response', async (res) => {
|
||||
const u = res.url()
|
||||
if (u.includes('G3Table.vue')) {
|
||||
try {
|
||||
const t = await res.text()
|
||||
seen.push({
|
||||
url: u.split('?')[0],
|
||||
hasNew: t.includes('innerBottom - 1'),
|
||||
hasOld: t.includes('used + bodyH >= available'),
|
||||
len: t.length,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
console.log('=== G3Table.vue responses seen by browser ===')
|
||||
console.log(JSON.stringify(seen, null, 2))
|
||||
|
||||
// 再看页面里 is-body-fill 的实际状态,以及 measureBodyFill 是否存在新逻辑
|
||||
const probe = await page.evaluate(() => {
|
||||
const wrap = document.querySelector('[data-testid="table-wrap"]')
|
||||
const root = wrap.querySelector('.g3-table')
|
||||
return {
|
||||
isBodyFill: root.classList.contains('is-body-fill'),
|
||||
wrapH: Math.round(wrap.getBoundingClientRect().height),
|
||||
}
|
||||
})
|
||||
console.log('page state:', JSON.stringify(probe))
|
||||
|
||||
await browser.close()
|
||||
@@ -93,7 +93,13 @@ function handleClick(ev: MouseEvent) {
|
||||
font-family: inherit;
|
||||
font-size: v('font-size', 14px);
|
||||
font-weight: 500;
|
||||
line-height: v('line-height', 1.5);
|
||||
/* line-height 用 1 而不是 v('line-height', 1.5):
|
||||
* 按钮是定高(min-height = control-height)+ align-items:center 的 flex 容器,
|
||||
* 行盒中心相对基线恒为 (ascent - descent) / 2,与 line-height 取值无关,
|
||||
* 真正的居中由 align-items 保证。但 1.5 会让行盒比字形高出 0.5em(14px 字号下约 7px),
|
||||
* 一旦文案换行或撑高,多出来的行距会顶开按钮高度、把整排按钮顶得不齐。
|
||||
* 收成 1 让行盒紧贴字形,高度完全由 control-height 决定。 */
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
@@ -336,9 +336,84 @@ function refreshLayout(xOnly = false) {
|
||||
if (xOnly) tableRef.value?.initVirtualScrollX?.()
|
||||
else tableRef.value?.initVirtualScroll?.()
|
||||
await nextTick()
|
||||
// 行区高度可能因数据/尺寸变化而改变,与重排同一批次量一次
|
||||
measureBodyFill()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据区最后一行的底边,是否已经贴到(或越过)外框的内底边。
|
||||
*
|
||||
* 只服务一个目的:决定**数据区最后一行的底边线归谁**(见 _overrides.scss 的边框一节)。
|
||||
* · 贴到/越过(true)→ 两者重合,线交给外框,否则叠成 2px;
|
||||
* · 没贴到(false)→ 最后一行悬在容器中间,外框那条线在它下面够不着,
|
||||
* 这一行**必须自己画**底边线,否则就是「线凭空消失」。
|
||||
*
|
||||
* ⚠️⚠️ 判据必须是**这一个几何量**,不能用「表头 + 行区高度 ≥ 容器可用高」去推算。
|
||||
* 推算式看着等价,实测却在两个方向都出错(容器高度线性扫一遍,3 行 90px 数据):
|
||||
*
|
||||
* 容器高 | avail | 表头+行区 | 推算结果 | 实际几何 | 症状
|
||||
* -------|-------|-----------|----------|----------|------------------
|
||||
* 140 | 138 | 124 | 未铺满 | 已错开 | ok
|
||||
* 128 | 126 | 124 | 未铺满 | **已贴上** | ★双线(重叠)
|
||||
* 126 | 124 | 124 | 铺满 | 已贴上 | ok
|
||||
* 120 | 118 | 124 | 铺满 | **已错开** | 缺线
|
||||
*
|
||||
* 两处错的根因:
|
||||
* 1. **表头不该参与比较**。决定「两条线是否重合」的是 tbody 底边这一个量,
|
||||
* 表头再高也不会让 tbody 底边移动,把它加进 used 只是给判据掺噪声。
|
||||
* 2. **阈值容差方向反了**。容器变小时 tbody 高度**不会跟着缩**
|
||||
* (实测行高恒定 30px×3=90px 始终不变),变的是它后面的剩余空间;
|
||||
* 用 `>= available - 1` 这种带容差的推算去卡,128px、120px 这种
|
||||
* 「差一点点」的情形必然被卡到错误的一侧。
|
||||
*
|
||||
* 直接量几何则没有这个问题:`tbody 底边 >= 外框内底边` 本身就是
|
||||
* 「线会不会撞上」的充分必要条件。容差留 1px 只为吃掉亚像素取整。
|
||||
*
|
||||
* 为什么需要 JS:纯 CSS 表达不了「tbody 底边是否到达容器底部」。
|
||||
*/
|
||||
const bodyFillsHeight = ref(false)
|
||||
function measureBodyFill() {
|
||||
const root = rootRef.value
|
||||
if (!root) return
|
||||
const inner = root.querySelector<HTMLElement>('.stk-table')
|
||||
if (!inner) return
|
||||
const tbody = root.querySelector<HTMLElement>('.stk-tbody-main')
|
||||
// 没表体(空态)时不接管:底边归外框,空态节点自己那条线已由 CSS 去掉
|
||||
if (!tbody) {
|
||||
if (bodyFillsHeight.value) bodyFillsHeight.value = false
|
||||
return
|
||||
}
|
||||
// 有真实数据行才进入判定:空表时 rows.length 为 0,不该把线交给外框
|
||||
const rows = tbody.querySelectorAll<HTMLElement>('tr')
|
||||
if (rows.length === 0) {
|
||||
if (bodyFillsHeight.value) bodyFillsHeight.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const innerRect = inner.getBoundingClientRect()
|
||||
const cs = getComputedStyle(inner)
|
||||
// 外框的内底边:容器内容盒底边(扣掉自身的下边框)
|
||||
const innerBottom = innerRect.bottom - (parseFloat(cs.borderBottomWidth) || 0)
|
||||
|
||||
// 最后一行的底边(取最后一个**有单元格**的行,虚拟滚动下末行可能是占位空行)
|
||||
let lastBottom: number | null = null
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
const tr = rows[i]
|
||||
if (tr.querySelector('td')) {
|
||||
lastBottom = tr.getBoundingClientRect().bottom
|
||||
break
|
||||
}
|
||||
}
|
||||
if (lastBottom == null) {
|
||||
if (bodyFillsHeight.value) bodyFillsHeight.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const fills = lastBottom >= innerBottom - 1
|
||||
if (fills !== bodyFillsHeight.value) bodyFillsHeight.value = fills
|
||||
}
|
||||
|
||||
/**
|
||||
* 尺寸防抖(trailing)。
|
||||
*
|
||||
@@ -1378,6 +1453,9 @@ onMounted(() => {
|
||||
})
|
||||
resizeObserver.observe(rootRef.value)
|
||||
}
|
||||
|
||||
// 首帧量一次:容器尺寸与行区高度都已就位(nextTick 后 DOM 才完整)
|
||||
nextTick().then(measureBodyFill)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -1398,6 +1476,8 @@ watch(
|
||||
() => props.dataSource,
|
||||
(rows) => {
|
||||
rebindActiveEditingRow(rows)
|
||||
// 行数变化会改变行区高度 → 重新判定底边线归属(空表↔新增行↔铺满)
|
||||
nextTick().then(measureBodyFill)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1549,6 +1629,8 @@ const fixedPatchOff = (() => {
|
||||
autoFitWidth ? is('auto-fit-width') : '',
|
||||
editorSeamless ? is('editor-seamless') : '',
|
||||
fixedPatchOff ? 'g3-fixed-patch-off' : '',
|
||||
// 行区铺满容器时把「最后一行底边线」交给外框(见 measureBodyFill)
|
||||
bodyFillsHeight ? is('body-fill') : '',
|
||||
]"
|
||||
data-slot="table"
|
||||
@keydown.capture="handleKeydownCapture"
|
||||
@@ -2360,6 +2442,32 @@ const fixedPatchOff = (() => {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数值列用等宽数字(tabular-nums)。
|
||||
*
|
||||
* 比例数字(默认)里每个字形的宽度不同,`1` 比 `8` 窄 —— 于是「金额 / 数量 / 单号」
|
||||
* 这类列每换一行,小数点与个位就在水平方向上抖一下,整列看起来参差不齐。
|
||||
* 等宽数字把 0-9 钉成同一宽度,数字按位对齐,列才会形成整齐的数值感。
|
||||
*
|
||||
* 判据取「右对齐」而不是「内容是数字」:
|
||||
* 库渲染 td 时只会带上列配置派生的对齐类,运行期读不到单元格的值,
|
||||
* 且本库约定里金额/数量/数值这类列一律用 `align: 'right'` 声明
|
||||
* (见 docs/examples/table/*.vue),所以右对齐就是数值列的可靠代理。
|
||||
* 这样不会波及左对齐的文本列 —— 中文与拉丁文本来就不需要等宽数字,
|
||||
* 强行套上去反而会让正文的字距变得松散。
|
||||
*
|
||||
* ⚠️ 类名是 `text-r`,不是 `text-right`:由 stk-table-vue 的 getTDProps 打上
|
||||
* (`col.align === 'right' → 'text-r'`,center → `text-c`,header 另走 text-l/text-c/text-r)。
|
||||
* 左对齐的列**不带任何类**,所以只写 `td.text-r` 即可,不会误伤普通文本列。
|
||||
*
|
||||
* 只打在 td 上,靠继承覆盖格内一切内容:展示态的文字、行内编辑时的 input
|
||||
* 都能拿到 —— 不必再给 `.g3-table__cell-editor` 单独写一条,
|
||||
* 那会把 select / 文本编辑器也一起套上等宽数字。
|
||||
*/
|
||||
.#{e('table', 'inner')} td.text-r {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------- 选择列:原生 checkbox + 自绘方框,视觉对齐 G3Checkbox ---------- */
|
||||
.#{e('table', 'checkbox')} {
|
||||
display: flex;
|
||||
|
||||
@@ -175,6 +175,11 @@
|
||||
// 外框:只有 `bordered` 为真才有(false = 嵌进卡片,只留行分隔线)。
|
||||
// `box-sizing: border-box` 必须有:根节点默认是 content-box,加 1px 描边会让它
|
||||
// 比父容器高 2px(`:not(.is-bordered)` 时不描边,故不设)。
|
||||
//
|
||||
// 圆角与外框都画在**根节点**上,靠一层真正的不透明底把内部内容挡住:
|
||||
// 见下面 `.g3-table__inner` 的 background-color。**不要**再给根节点加
|
||||
// `overflow: hidden` —— 库的浮层(列宽拖拽把手、筛选下拉、区域选择框)都
|
||||
// 挂在滚动容器内部,裁掉会让它们被切边。
|
||||
.#{b('table')}.#{is('bordered')} {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #{v('border-color', #d9d9d9)};
|
||||
@@ -213,20 +218,63 @@
|
||||
.#{e('table', 'inner')}.stk-table.bordered-v {
|
||||
border-left: none;
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 最右列的外侧竖线同样归外框管,抹掉单元格自己那条(两条紧挨着会被看成 2px)。
|
||||
//
|
||||
// ⚠️ 只能改 --bg-border-right 这一个变量,**不能整体清空 background-image**:
|
||||
// 底座会给「被前面内容穿透遮挡」的右固定列补 `.fixed-cell--border-left`,
|
||||
// 用 --bg-border-left 画它左边的分隔线;整份清空会把这根线一起干掉
|
||||
// (症状:操作列压住内容却没有分割线)。
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
// ==========================================================================
|
||||
// 最右列竖线 / 最后一行横线:为什么不能再「按 :last-child 摘线」
|
||||
// --------------------------------------------------------------------------
|
||||
// 老做法是给 `th:last-child, td:last-child` 把 --bg-border-right 涂成透明,
|
||||
// 指望「最右列的外侧线归外框管」。**这个假设只在「列宽铺满容器」时成立**,
|
||||
// 实测有两种常见情形会破:
|
||||
//
|
||||
// 1. 列宽之和 < 容器宽(有列没写 width、或容器被拉宽):
|
||||
// 实测 table-wrap —— 容器内宽 1084px,列只铺到 800px,
|
||||
// **右侧留 284px 空白,而那条竖线已被摘掉,于是「线没了 + 留白」**。
|
||||
// 2. 有右固定列且横向滚动:DOM 里 `:last-child` 是滚动区的最后一列
|
||||
// (实测右边缘 1698px,早已滚出可视区),**视觉最右**的反而是固定列
|
||||
// (右边缘贴住 1242px 的内缘)。摘 DOM 末列等于摘错了对象。
|
||||
//
|
||||
// 最后一行同理:横线由每个单元格自己的 --bg-border-bottom 画。
|
||||
// **判据不能是「是不是最后一行」,而是「这行的底边有没有贴住外框内底边」**——
|
||||
// 二者只有行区铺满时才等价。实测(180px 高容器):
|
||||
// · 铺满时:最后一行 bottom 与外框内底边重合 → 线该交给外框(否则叠成 2px);
|
||||
// · 没铺满:只放 1 行 30px 数据时,该行 bottom=440、外框内底边=554,
|
||||
// **差了 114px 够不着** → 这行必须自己画线,否则唯一那条分隔线凭空消失。
|
||||
// 纯 CSS 表达不了「行区高度是否等于可用高度」,所以由组件侧量出结果、
|
||||
// 用 `.g3-table.is-body-fill` 这个类传进来(见 G3Table.vue 的 measureBodyFill)。
|
||||
.#{b('table')}.#{is('bordered')} {
|
||||
// 兜底:行区与外框之间的空隙铺上容器底色,避免露出突兀的空带。
|
||||
// 这层底必须**不透明**,否则圆角处会透出内部内容。
|
||||
background-color: v('bg-container', #fff);
|
||||
|
||||
.#{e('table', 'inner')}.stk-table.bordered-v {
|
||||
// ── 贴住内右缘的右固定列:自己的右线让给外框,避免两条线并排。
|
||||
// 表头与表体都要处理 —— 实测两处都会画,且都会与外框叠。
|
||||
th.fixed-cell--right,
|
||||
td.fixed-cell--right {
|
||||
--bg-border-right: linear-gradient(transparent, transparent);
|
||||
}
|
||||
|
||||
// ── 空态:底边线归外框。
|
||||
// 库的 `.stk-table-no-data.no-data-full` 会撑满剩余高度并**自带一条
|
||||
// border-bottom**,与外框的 border-bottom 落在同一条 y 上(实测都在 218)
|
||||
// → 两条线叠在一起,看着就是「底部重叠」。这里把它的底边交还给外框。
|
||||
.stk-table-no-data {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 仅当行区真的铺满容器时,才把数据区最后一行的底边线交给外框。
|
||||
// 没铺满时保留每行自己的线(缺线比双线更难排查,优先保证有线)。
|
||||
&.#{is('body-fill')} {
|
||||
.#{e('table', 'inner')}.stk-table.bordered-v .stk-tbody-main tr:last-child > td {
|
||||
--bg-border-bottom: linear-gradient(transparent, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ==========================================================================
|
||||
// 表头 / 表尾:不透明底色(防 sticky 滚动时透出下方内容)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user