This commit is contained in:
oneao committed 2026-10-15 17:31:35 +08:00
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()
+106
View File
@@ -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 滚动时透出下方内容)
// --------------------------------------------------------------------------