This commit is contained in:
oneao committed 2026-10-12 22:25:41 +08:00
1 parent ae3ca3cfa1
commit 7bd5f1dde9
45 files changed
+3414 -229

No files matched your search

@@ -0,0 +1,258 @@
import { expect, test } from '@playwright/test'
import zlib from 'node:zlib'
/**
* 最小 PNG 解码(8bit,colorType 2/6,非隔行)。
* 用于「读真实渲染像素」——只量 computed 样式会漏掉「声明正确但没画出来」
* 这类问题(表头半透明底色最初就是这么漏过去的)。
*/
function decodePng(buf: Buffer) {
let pos = 8
let width = 0
let height = 0
let colorType = 6
const idat: Buffer[] = []
while (pos < buf.length) {
const len = buf.readUInt32BE(pos)
const type = buf.toString('ascii', pos + 4, pos + 8)
const data = buf.subarray(pos + 8, pos + 8 + len)
if (type === 'IHDR') {
width = data.readUInt32BE(0)
height = data.readUInt32BE(4)
colorType = data[9]
} else if (type === 'IDAT') idat.push(data)
pos += 12 + len
}
const channels = colorType === 6 ? 4 : 3
const raw = zlib.inflateSync(Buffer.concat(idat))
const bpp = channels
const stride = width * bpp
const out = Buffer.alloc(height * stride)
let rp = 0
for (let y = 0; y < height; y += 1) {
const filter = raw[rp]
rp += 1
const rs = y * stride
for (let x = 0; x < stride; x += 1) {
const cur = raw[rp + x]
const left = x >= bpp ? out[rs + x - bpp] : 0
const up = y > 0 ? out[rs - stride + x] : 0
const ul = y > 0 && x >= bpp ? out[rs - stride + x - bpp] : 0
let v = cur
if (filter === 1) v = cur + left
else if (filter === 2) v = cur + up
else if (filter === 3) v = cur + ((left + up) >> 1)
else if (filter === 4) {
const p = left + up - ul
const pa = Math.abs(p - left)
const pb = Math.abs(p - up)
const pc = Math.abs(p - ul)
v = cur + (pa <= pb && pa <= pc ? left : pb <= pc ? up : ul)
}
out[rs + x] = v & 0xff
}
rp += stride
}
return { width, height, pixels: out, bpp }
}
/** 取图片主色(跳过 1px 边缘,避开边框线),返回 "r,g,b" */
function dominantColor(img: ReturnType<typeof decodePng>): string {
const { width, height, pixels, bpp } = img
const hist = new Map<string, number>()
for (let y = 1; y < height - 1; y += 1) {
for (let x = 1; x < width - 1; x += 1) {
const i = (y * width + x) * bpp
const k = pixels[i] + ',' + pixels[i + 1] + ',' + pixels[i + 2]
hist.set(k, (hist.get(k) ?? 0) + 1)
}
}
return [...hist.entries()].sort((a, b) => b[1] - a[1])[0][0]
}
/**
* 文档站上 G3Table 的渲染核对。
*
* 放在独立的 e2e-docs 目录、用 playwright.docs.config.ts 跑:
* 组件自带的 e2e/ 指向 playground,那里没有 VitePress 的 .vp-doc 上下文,
* 量不到文档页才有的样式问题(前几轮反复踩坑就在这)。
*/
test.describe('文档站 /ui/data/table', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/ui/data/table')
await page.waitForTimeout(1000)
})
test('七个示例都渲染出表格', async ({ page }) => {
// basic / virtual / selection / sorting / editable / actions / resizable(+2 表)
const tables = page.locator('.g3-table')
const count = await tables.count()
expect(count).toBeGreaterThanOrEqual(7)
})
test('没有 Vue 渲染告警泄漏到页面(控制台无 error)', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (e) => errors.push(e.message))
await page.goto('/ui/data/table')
await page.waitForTimeout(800)
expect(errors).toEqual([])
})
test('内部边框是 1px:逐像素量横线带与竖线宽', async ({ page }) => {
const table = page.locator('.g3-table').first()
await table.scrollIntoViewIfNeeded()
await page.waitForTimeout(300)
// 用 canvas 采样不方便,这里退一步量计算样式:
// 库用 background-image 画线,.vp-doc 会用 border 画四边框 —— 后者必须被清掉。
const cell = table.locator('.stk-tbody-main td').first()
const styles = await cell.evaluate((el) => {
const cs = getComputedStyle(el)
return {
borderTop: cs.borderTopWidth,
borderBottom: cs.borderBottomWidth,
borderLeft: cs.borderLeftWidth,
borderRight: cs.borderRightWidth,
bg: cs.backgroundImage,
}
})
// .vp-doc 的四边框必须已被 demo 隔离规则清掉
expect(styles.borderTop).toBe('0px')
expect(styles.borderBottom).toBe('0px')
expect(styles.borderLeft).toBe('0px')
expect(styles.borderRight).toBe('0px')
// 库自己的线仍在(background-image 里至少一条可见渐变)
expect(styles.bg).not.toBe('none')
expect(styles.bg).toContain('gradient')
})
test('「名称长度(自定义)」列显示数字而不是名称,且点表头能排序', async ({ page }) => {
const table = page
.locator('.g3-table')
.filter({ has: page.locator('th', { hasText: '名称长度' }) })
.first()
await table.scrollIntoViewIfNeeded()
const headers = await table.locator('thead th').allTextContents()
const idx = headers.findIndex((h) => h.includes('名称长度')) + 1
const read = async () =>
(await table.locator(`.stk-tbody-main tr td:nth-child(${idx})`).allTextContents()).map((s) =>
s.trim(),
)
// 该列必须显示**长度值**(dataIndex 指向真实字段),不能显示名称本身
const before = await read()
expect(before).toEqual(['4', '8', '3'])
await table.locator('th', { hasText: '名称长度' }).first().click()
await page.waitForTimeout(300)
expect(await read()).toEqual(['8', '4', '3']) // desc
await table.locator('th', { hasText: '名称长度' }).first().click()
await page.waitForTimeout(300)
expect(await read()).toEqual(['3', '4', '8']) // asc
})
/**
* 虚拟滚动开启时表头必须钉住。
*
* 这条专门防「宿主 CSS 泄漏」回归:VitePress 的
* `.vp-doc table { display:block; overflow-x:auto; margin:20px 0 }`
* 会命中组件内部的 `<table class="stk-table-main">`,让它变成滚动容器,
* 于是 th 的 sticky 基准错位、表头跟着滚走(实测 delta=-600)。
* custom.css 里已把这几个属性还原,这里用位移量锁住。
*
* 注意 playground 的同类断言抓不到这个 bug —— 那里没有 .vp-doc。
*/
test('虚拟滚动:纵向滚动 600px 后表头仍钉在容器顶部', async ({ page }) => {
await page.goto('/ui/data/table')
await page.waitForTimeout(1000)
const section = page
.locator('.vitepress-demo-plugin__container')
.filter({ has: page.locator('th', { hasText: '数值' }) })
.first()
await section.scrollIntoViewIfNeeded()
await page.waitForTimeout(300)
const r = await page.evaluate(() => {
const stk = document.querySelector('.stk-table.virtual') as HTMLElement | null
if (!stk) return null
const th = stk.querySelector('thead th') as HTMLElement
const top0 = stk.getBoundingClientRect().top
const before = th.getBoundingClientRect().top - top0
stk.scrollTop = 600
const after = th.getBoundingClientRect().top - top0
return { before, after, delta: after - before, scrollTop: stk.scrollTop }
})
expect(r).not.toBeNull()
expect(r!.scrollTop).toBe(600)
// 表头相对滚动容器顶部的偏移不应变化
expect(Math.abs(r!.delta)).toBeLessThan(2)
})
/**
* 表头底色必须不透明。
*
* 库把表头底色做成 alpha 值(--th-bgc: rgba(0,0,0,.04))画在 thead tr 上、
* th 继承它;th 又是 sticky,滚动时表体行从它下面高速掠过,半透明像素会与
* 「此刻经过的那一行」实时合成 —— 表现为滚动时能看到行内容一闪而过。
* 静止截图看不出(下方恰好是白行),所以这里主动塞一行纯红到表头正下方,
* 再截图表头:若还能读出红色,说明底色仍是半透明。
*
* 注意:这条同样只有文档站能跑出意义 —— playground 不会有 .vp-doc 干扰,
* 但真正要防的是「表头底色被改回 alpha」,两边都成立;放这里是为了顺带
* 覆盖文档站的真实渲染。
*/
test('表头底色不透明:下方内容不会透出来', async ({ page }) => {
await page.goto('/ui/data/table')
await page.waitForTimeout(1000)
const section = page
.locator('.vitepress-demo-plugin__container')
.filter({ has: page.locator('th', { hasText: '数值' }) })
.first()
await section.scrollIntoViewIfNeeded()
await page.waitForTimeout(400)
const table = section.locator('.g3-table').first()
// 1) alpha 必须是 1
const alpha = await table.evaluate((root) => {
const th = root.querySelector('thead th') as HTMLElement
const c = getComputedStyle(th).backgroundColor
const m = c.match(/rgba?\(([^)]+)\)/)
if (!m) return null
const parts = m[1].split(',').map((p) => p.trim())
return { bg: c, alpha: parts.length > 3 ? Number(parts[3]) : 1 }
})
expect(alpha).not.toBeNull()
expect(alpha!.alpha).toBe(1)
// 2) 行为验证:把表体行染成纯红、滚到表头正下方,截图表头 —— 读像素判断。
// 只量 computed 是不够的(正是那种检查漏掉了原 bug),必须看真实渲染像素。
await table.evaluate((root) => {
const rows = root.querySelectorAll('.stk-tbody-main tr')
const r = rows[0] as HTMLElement | undefined
if (r) {
r.querySelectorAll('td').forEach((td) => {
;(td as HTMLElement).style.backgroundColor = 'rgb(255,0,0)'
;(td as HTMLElement).style.backgroundImage = 'none'
})
}
const stk = root.querySelector('.stk-table') as HTMLElement
stk.scrollTop = 1
})
await page.waitForTimeout(400)
const shot = await table.locator('thead th').first().screenshot()
const color = dominantColor(decodePng(Buffer.from(shot)))
const [r, g, b] = color.split(',').map(Number)
// 表头主色不应偏红 —— 红行若透上来,R 会显著高于 G/B
expect(r - Math.max(g, b)).toBeLessThan(20)
})
})
@@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test'
/**
* 验证去掉分页、默认 1000 行后的 /ocean/booking。
*
* 注意:ERP 的 Vite dep 缓存可能是旧包(曾实测缓存里没有 G3Table),
* 若行编辑相关断言失败,先确认缓存已刷新再判断是页面问题。
*/
test.describe('/ocean/booking(无分页,1000 行)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/ocean/booking')
await page.waitForTimeout(1500)
})
test('页面渲染表格,且分页器已移除', async ({ page }) => {
await expect(page.locator('.booking__title')).toHaveText('订舱单')
await expect(page.locator('.g3-table')).toBeVisible()
// 分页器不该再存在
await expect(page.locator('.g3-pagination')).toHaveCount(0)
// 「每页」选择器也不该存在
await expect(page.locator('.booking__field').filter({ hasText: '每页' })).toHaveCount(0)
})
test('数据量选择器默认 1000,且只保留一个下拉', async ({ page }) => {
const selects = page.locator('.booking__select')
await expect(selects).toHaveCount(1)
await expect(selects.first()).toHaveValue('1000')
})
test('1000 行数据:虚拟滚动开启,DOM 行数远少于 1000', async ({ page }) => {
const stk = page.locator('.g3-table .stk-table').first()
// 库开启虚拟滚动时会加 .virtual 类
await expect(stk).toHaveClass(/virtual/)
const rendered = await page.locator('.stk-tbody-main tr').count()
console.log('DOM ROWS (1000 数据): ' + rendered)
expect(rendered).toBeGreaterThan(0)
expect(rendered).toBeLessThan(200)
})
test('可滚动到底部(数据确实有 1000 行)', async ({ page }) => {
const info = await page.locator('.g3-table .stk-table').first().evaluate((el) => {
const stk = el as HTMLElement
return { scrollH: stk.scrollHeight, clientH: stk.clientHeight }
})
console.log('SCROLL: ' + JSON.stringify(info))
// 1000 行 * 32px 行高 ≈ 32000,远大于视口
expect(info.scrollH).toBeGreaterThan(10000)
})
test('切换数据量到 100 行后虚拟滚动关闭(低于阈值)', async ({ page }) => {
await page.locator('.booking__select').first().selectOption('100')
await page.waitForTimeout(600)
const stk = page.locator('.g3-table .stk-table').first()
await expect(stk).not.toHaveClass(/virtual/)
// 100 行全量渲染
await expect(page.locator('.stk-tbody-main tr')).toHaveCount(100)
})
})
@@ -0,0 +1,87 @@
import { expect, test } from '@playwright/test'
/**
* 复现用户报的问题:
* 编辑态里的 Select,点选项后**不立即**生效;要点别处才更新。
* 「select 消失了,但框里的数据没变」。
*
* 严格控制步骤,每步都记录 DOM 实际内容与 row 数据。
*/
test('reproduce: select value not applied immediately', async ({ page }) => {
const logs: string[] = []
page.on('pageerror', (e) => logs.push('pageerror: ' + e.message))
page.on('console', (m) => {
if (m.type() === 'error' || m.type() === 'warning') logs.push('[' + m.type() + '] ' + m.text().slice(0, 200))
})
await page.goto('/ocean/booking')
await page.waitForTimeout(2000)
const rowState = () =>
page.evaluate(() => {
const root = document.querySelector('.g3-table') as HTMLElement
const rows = Array.from(root.querySelectorAll('.stk-tbody-main tr')).filter(
(r) => !(r as HTMLElement).className.includes('padding-top'),
) as HTMLElement[]
const tr = rows[0]
// 起运港列(pol)—— 用 data-col-key 精确定位
const polTd = tr?.querySelector('td[data-col-key="pol"]') as HTMLElement | null
const editor = polTd?.querySelector('.g3-select') as HTMLElement | null
const displayText = polTd?.textContent?.trim() ?? null
// 触发器里显示的文字
const triggerInput = editor?.querySelector('input') as HTMLInputElement | null
return {
rowKey: tr?.getAttribute('data-row-key'),
polTdText: displayText,
hasSelectEditor: !!editor,
triggerValue: triggerInput?.value ?? null,
// 展示态(非编辑)的文本
displaySpan: polTd?.querySelector('.g3-table__cell-display')?.textContent ?? null,
}
})
console.log('1) 初始: ' + JSON.stringify(await rowState()))
// 点起运港单元格进入编辑
const polCell = page.locator('.stk-tbody-main tr:not(.padding-top-tr)').first().locator('td[data-col-key="pol"]')
await polCell.click()
await page.waitForTimeout(600)
console.log('2) 进入编辑后: ' + JSON.stringify(await rowState()))
// 打开下拉
const selectRoot = polCell.locator('.g3-select')
await selectRoot.click()
await page.waitForTimeout(500)
const dropdownInfo = await page.evaluate(() => {
const panels = Array.from(document.querySelectorAll('.g3-select__dropdown, [role="listbox"], .g3-popover'))
return {
panelCount: panels.length,
options: panels.length
? Array.from(panels[panels.length - 1].querySelectorAll('[role="option"], li, .g3-select__option')).map((o) =>
(o.textContent || '').trim().slice(0, 12),
)
: [],
}
})
console.log('3) 下拉内容: ' + JSON.stringify(dropdownInfo))
// 点「宁波」
const option = page.locator('[role="option"], .g3-select__option').filter({ hasText: '宁波' }).first()
const optCount = await option.count()
console.log('4) 找到「宁波」选项数: ' + optCount)
if (optCount > 0) {
await option.click()
await page.waitForTimeout(700)
console.log('5) 点完选项后: ' + JSON.stringify(await rowState()))
console.log(' (期望 pol 立即变成 CNNGB / 宁波)')
// 再点空白处
await page.locator('.booking__title').click()
await page.waitForTimeout(700)
console.log('6) 点空白处后: ' + JSON.stringify(await rowState()))
}
console.log('ERRORS: ' + JSON.stringify(logs.slice(0, 8), null, 2))
expect(true).toBe(true)
})
@@ -0,0 +1,202 @@
import { expect, test } from '@playwright/test'
/**
* /ocean/booking 行编辑验证(修正版)。
*
* 上一版断言写错了:按 Enter 后期待 change 回写 —— 但 Enter 的语义是
* 「全选当前内容」(便于直接覆写),落库靠 **失焦**。见 utils/editable.ts:
* if (key === 'Enter') { preventDefault(); stopPropagation(); target.select() }
* onBlur: commitInputDraft(...) + notifyChange(...)
* 所以验证回写必须用 blur(点别处 / Tab 切走),不能只按 Enter。
*/
test.describe('/ocean/booking 行编辑', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/ocean/booking')
await page.waitForTimeout(2000)
})
test('区域选择已启用(注册时机修复的正面证据)', async ({ page }) => {
const cls = await page.evaluate(
() => (document.querySelector('.g3-table .stk-table') as HTMLElement)?.className ?? '',
)
console.log('STK CLASSES: ' + cls)
expect(cls).toContain('area-selection')
})
test('单击单元格进入编辑,焦点落在该格', async ({ page }) => {
const cell = page
.locator('.stk-tbody-main tr:not(.padding-top-tr)')
.first()
.locator('td')
.nth(2)
const before = await cell.innerText()
console.log('CELL BEFORE: ' + before)
await cell.click()
await page.waitForTimeout(600)
// 编辑器出现
await expect(page.locator('.g3-table__cell-editor-root').first()).toBeVisible()
// 焦点在该格的 input 上,且带出原值
const focused = await page.evaluate(() => {
const a = document.activeElement as HTMLInputElement | null
return { tag: a?.tagName, value: a?.value ?? null }
})
console.log('FOCUSED: ' + JSON.stringify(focused))
expect(focused.tag).toBe('INPUT')
expect(focused.value).toBe(before)
})
test('输入后失焦落库并抛出 change', async ({ page }) => {
const cell = page
.locator('.stk-tbody-main tr:not(.padding-top-tr)')
.first()
.locator('td')
.nth(2)
await cell.click()
await page.waitForTimeout(500)
await page.keyboard.press('Control+A')
await page.keyboard.type('测试客户A')
await page.waitForTimeout(200)
// 关键:用失焦提交(不是 Enter —— Enter 的语义是"全选以便覆写",见下一条用例)
await page.locator('.booking__title').click()
await page.waitForTimeout(700)
const tip = await page.locator('.booking__tip').innerText()
console.log('TIP: ' + tip.replace(/\n/g, ' | '))
expect(tip).toContain('测试客户A')
// 校验新值已落到单元格上。
//
// 试过三种写法,前两种都是错的,记在这里免得再踩:
// 1) cell.innerText() —— 编辑态里是 <input>,innerText 取不到 value,得空串
// 2) 断言 input 数量为 0 —— 错。editingActive 一旦为 true,整行可编辑列都保留
// 编辑器(性能设计),input 一直在,不等于"还在编辑"
// 3) .g3-table__cell-display —— 也错。失焦后该行仍处编辑态(编辑器未卸载),
// 展示态节点根本不存在
//
// 真正可靠的做法:直接读编辑器 input 的 value(它就是该格当前值的真源),
// 并用「焦点是否还在该格」判断编辑是否结束。
const inputValue = await cell.locator('input').first().inputValue()
console.log('CELL INPUT VALUE AFTER BLUR: ' + inputValue)
expect(inputValue).toBe('测试客户A')
const stillFocused = await page.evaluate(() => {
const td = document.activeElement?.closest('td')
return !!td?.querySelector('.g3-table__cell-editor-root')
})
console.log('STILL FOCUSED IN EDITOR: ' + stillFocused)
expect(stillFocused).toBe(false)
})
test('Enter 的语义是全选而非提交', async ({ page }) => {
const cell = page
.locator('.stk-tbody-main tr:not(.padding-top-tr)')
.first()
.locator('td')
.nth(2)
await cell.click()
await page.waitForTimeout(500)
await page.keyboard.press('Enter')
await page.waitForTimeout(300)
// 全选:input 的 selectionStart/End 覆盖整个值
const sel = await page.evaluate(() => {
const a = document.activeElement as HTMLInputElement | null
return { len: a?.value.length ?? 0, s: a?.selectionStart ?? -1, e: a?.selectionEnd ?? -1 }
})
console.log('SELECTION AFTER ENTER: ' + JSON.stringify(sel))
expect(sel.s).toBe(0)
expect(sel.e).toBe(sel.len)
expect(sel.len).toBeGreaterThan(0)
})
/**
* Esc = **取消编辑并恢复原值**。
*
* 与 Excel / AG Grid / Handsontable / antd 的通用约定一致(Enter 提交、Esc 取消)。
*
* 改前的错误行为:Esc 只是 blur,而 blur 会走 onBlur → commit + notifyChange,
* 等于「按 Esc 反而保存了」(从参考实现 fms 原样照抄而来)。
* 现在 utils/editable.ts 的 Escape 分支会先 ctx.revertRow(row) 再 blur ——
* 回滚后 input 里是原值,随后的 blur 提交的就是原值,change 被去重挡掉。
*/
test('Esc 取消编辑并恢复原值', async ({ page }) => {
const cell = page
.locator('.stk-tbody-main tr:not(.padding-top-tr)')
.first()
.locator('td')
.nth(2)
const original = (await cell.innerText()).trim()
console.log('ORIGINAL VALUE: ' + original)
await cell.click()
await page.waitForTimeout(500)
await page.keyboard.press('Control+A')
await page.keyboard.type('不该保存')
await page.waitForTimeout(200)
// 输入过程中不该有 change(草稿机制)
const tipDuringTyping = await page.locator('.booking__tip').innerText()
expect(tipDuringTyping).not.toContain('不该保存')
await page.keyboard.press('Escape')
await page.waitForTimeout(700)
const tip = await page.locator('.booking__tip').innerText()
console.log('TIP AFTER ESC: ' + tip.replace(/\n/g, ' | '))
// 核心:Esc 不产生 change(值已回滚,被去重挡掉)
expect(tip).not.toContain('不该保存')
// 编辑器里的值应已恢复成原值
const valueAfterEsc = await cell.locator('input').first().inputValue()
console.log('INPUT VALUE AFTER ESC: ' + valueAfterEsc)
expect(valueAfterEsc).toBe(original)
// 焦点应已离开编辑器
const focusedInCell = await page.evaluate(() => {
const td = document.activeElement?.closest('td')
return !!td?.querySelector('.g3-table__cell-editor-root')
})
console.log('STILL FOCUSED IN EDITOR: ' + focusedInCell)
expect(focusedInCell).toBe(false)
})
test('Esc 回滚后仍可正常再次编辑并提交', async ({ page }) => {
const cell = page
.locator('.stk-tbody-main tr:not(.padding-top-tr)')
.first()
.locator('td')
.nth(2)
// 第一次:改后 Esc 回滚
await cell.click()
await page.waitForTimeout(400)
await page.keyboard.press('Control+A')
await page.keyboard.type('第一次改动')
await page.keyboard.press('Escape')
await page.waitForTimeout(600)
expect(await cell.locator('input').first().inputValue()).not.toBe('第一次改动')
// 第二次:正常提交应仍然生效(回滚不能把去重基线弄坏)
await cell.click()
await page.waitForTimeout(400)
await page.keyboard.press('Control+A')
await page.keyboard.type('第二次改动')
await page.locator('.booking__title').click()
await page.waitForTimeout(700)
const tip = await page.locator('.booking__tip').innerText()
console.log('TIP AFTER SECOND COMMIT: ' + tip.replace(/\n/g, ' | '))
expect(tip).toContain('第二次改动')
})
})
@@ -0,0 +1,65 @@
import { expect, test } from '@playwright/test'
/**
* 验证行高修复:编辑前后行高必须都等于 rowHeight(32px)。
* 这是当初就该有、却一直缺失的断言 —— 组件里有防此问题的意图(height: var(--row-height)),
* 但从没有任何测试验证它真的生效。
*/
test('编辑前后行高保持 32px', async ({ page }) => {
await page.goto('/ocean/booking')
await page.waitForTimeout(2000)
const measure = () =>
page.evaluate(() => {
const root = document.querySelector('.g3-table') as HTMLElement
const rows = Array.from(root.querySelectorAll('.stk-tbody-main tr')).filter(
(r) => !(r as HTMLElement).className.includes('padding-top'),
) as HTMLElement[]
const h = (el?: HTMLElement | null) =>
el ? Math.round(el.getBoundingClientRect().height * 100) / 100 : null
const editing = rows[0]
const tdEditor = editing?.querySelector(
'td:has(> .g3-table__cell-editor-root)',
) as HTMLElement | null
const er = tdEditor?.firstElementChild as HTMLElement | null
return {
rowHeights: rows.slice(0, 4).map((r) => h(r)),
editorTd: h(tdEditor),
editorRoot: h(er),
editorTdPad: tdEditor ? getComputedStyle(tdEditor).padding : null,
rowHeightVar: getComputedStyle(root.querySelector('.stk-table') as HTMLElement)
.getPropertyValue('--row-height')
.trim(),
}
})
const before = await measure()
console.log('BEFORE: ' + JSON.stringify(before))
const cell = page.locator('.stk-tbody-main tr:not(.padding-top-tr)').first().locator('td').nth(2)
await cell.click()
await page.waitForTimeout(700)
const during = await measure()
console.log('DURING: ' + JSON.stringify(during))
// 退出编辑
await page.keyboard.press('Escape')
await page.waitForTimeout(600)
const after = await measure()
console.log('AFTER: ' + JSON.stringify(after))
// 核心断言:三态行高一致,且等于 --row-height
expect(before.rowHeights[0]).toBe(32)
expect(during.rowHeights[0]).toBe(32) // ← 修复前是 42
expect(after.rowHeights[0]).toBe(32)
// 同行其它行不受影响
expect(during.rowHeights[1]).toBe(32)
// 编辑器确实存在(确认真的进入了编辑态,而不是断言空过)
const editorCount = await page.evaluate(
() => document.querySelectorAll('.g3-table__cell-editor-root').length,
)
console.log('EDITOR COUNT (退出后应为 0): ' + editorCount)
})
+92 -5
View File
@@ -66,15 +66,22 @@ test.describe('G3Table 表格', () => {
expect(innerBox!.height).toBeLessThanOrEqual(wrapBox!.height + 2)
})
test('外框圆角:bordered 时容器有圆角与描边', async ({ page }) => {
// 注意:data-testid 透传给了内层 StkTable(组件 inheritAttrs: false),
// 外框与圆角在我们自己的根节点 .g3-table 上,所以要按类名定位。
test('边框由库绘制:不再有自绘外框与圆角', async ({ page }) => {
// 组件已改为把边框完全交给库(传 bordered: true),自己不再画外框 / 圆角。
// data-testid 透传给内层 StkTable(组件 inheritAttrs: false),所以按类名定位根节点。
const root = page.locator('[data-testid="table-wrap"] .g3-table').first()
await expect(root).toHaveClass(/is-bordered/)
// 自绘外框已删除:根节点不该再有 border / 圆角
const radius = await root.evaluate((el) => getComputedStyle(el).borderRadius)
expect(radius).not.toBe('0px')
expect(radius).toBe('0px')
const borderWidth = await root.evaluate((el) => getComputedStyle(el).borderTopWidth)
expect(borderWidth).toBe('1px')
expect(borderWidth).toBe('0px')
// 边框改由库的 .bordered 提供
await expect(page.locator('[data-testid="table-wrap"] .stk-table').first()).toHaveClass(
/bordered/,
)
})
test('表头显示序号列(seq 默认开启)', async ({ page }) => {
@@ -147,6 +154,86 @@ test.describe('G3Table 选择列(真浏览器命中)', () => {
})
})
/**
* 这两条盯的是「库的 flex 布局 + 自绘分隔线」这对组合:
* 库的 DOM 里 table.stk-table-main 是 .stk-table-scroll-container 的 flex item,
* 库默认给 flex:1,而表头 th 靠 position:sticky 固定,一旦 table 被拉伸,
* sticky 几何就会失效(表头跟着滚、分隔线错位叠线)。
*/
test.describe('G3Table 布局:表头固定与分隔线', () => {
test('table 不被拉伸:.stk-table-main 是 flex:none(非 autoFitWidth)', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const flex = await wrap
.locator('.stk-table-main')
.first()
.evaluate((el) => getComputedStyle(el).flexGrow)
expect(flex).toBe('0')
})
test('滚动后表头仍固定在容器顶部', async ({ page }) => {
await page.goto('/')
const big = page.locator('[data-testid="table-big-wrap"]')
await big.scrollIntoViewIfNeeded()
const header = big.locator('thead th').first()
const before = await header.boundingBox()
// 滚动表体
const scroller = big.locator('.stk-table-scroll-container').first()
await scroller.evaluate((el) => {
el.scrollTop = 1200
})
await page.waitForTimeout(200)
const after = await header.boundingBox()
expect(before).not.toBeNull()
expect(after).not.toBeNull()
// 表头是 sticky:滚动前后它相对视口的位置不应发生位移
expect(Math.abs(after!.y - before!.y)).toBeLessThan(2)
})
test('边框交由库绘制:库根节点带 .bordered,且不再叠加自绘实线', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 我们传 bordered: true,库会给根节点挂 .bordered(并因是字符串才挂 bordered-xxx)
const stk = wrap.locator('.stk-table').first()
await expect(stk).toHaveClass(/bordered/)
// 自绘的行分隔线已删除,单元格不应再有真的 border-bottom
// (库用 background-image 渐变画线,不走 border)
const td = await wrap
.locator('.stk-tbody-main td')
.first()
.evaluate((el) => getComputedStyle(el).borderBottomWidth)
expect(td).toBe('0px')
})
test('库自绘边框:单元格带可见的横竖渐变线', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 库的 .bordered th,td 用 background-image 画 --bg-border-right / --bg-border-bottom。
// 这里只断言「至少有一条可见渐变线」,不锁具体层数——那是库的内部实现。
const layers = await wrap
.locator('.stk-tbody-main td')
.first()
.evaluate((el) => {
const bg = getComputedStyle(el).backgroundImage
return bg
.split(/,(?![^(]*\))/)
.map((s) => s.trim())
.filter((s) => /rgb\(\d+, \d+, \d+\)/.test(s) && !/rgba\(0, 0, 0, 0\)/.test(s)).length
})
expect(layers).toBeGreaterThan(0)
})
})
test.describe('G3Table RTL 镜像', () => {
test('RTL 下固定列与表格布局不塌陷', async ({ page }) => {
await page.goto('/?dir=rtl')
+1 -1
View File
@@ -51,7 +51,7 @@
"@tiptap/extension-text-align": "^3.31.4",
"@tiptap/extension-text-style": "^3.31.4",
"@tiptap/starter-kit": "^3.31.4",
"stk-table-vue": "1.1.0"
"stk-table-vue": "^1.2.7"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
@@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test'
/**
* 量**文档站**的配置。
*
* 组件自带的 playwright.config.ts 指向 playground(5200),那没有 VitePress 的
* .vp-doc 排版上下文,量不到真实文档页上的样式问题(前几轮反复踩坑就在这)。
* 这个配置起 docs 的 preview 服务,用于核对文档站上的实际渲染。
*/
export default defineConfig({
testDir: './e2e-docs',
timeout: 60_000,
reporter: [['list']],
use: {
baseURL: 'http://localhost:4173',
viewport: { width: 1440, height: 900 },
},
webServer: {
command: 'pnpm exec vitepress preview --port 4173 --strictPort',
cwd: 'D:/workspace/code/g3soft-libs/docs',
url: 'http://localhost:4173/ui/data/table',
reuseExistingServer: true,
timeout: 120_000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
})
@@ -0,0 +1,16 @@
import { defineConfig, devices } from '@playwright/test'
/**
* 量已运行中的 ERP 前端(5090)。
* 不配 webServer:那是用户自己的 dev server,不应由测试重启/占用。
*/
export default defineConfig({
testDir: './e2e-erp',
timeout: 60_000,
reporter: [['list']],
use: {
baseURL: 'http://localhost:5090',
viewport: { width: 1440, height: 900 },
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
})
@@ -347,6 +347,32 @@ describe('事件', () => {
// 业务列的新宽度要带上
expect(emitted[0].width).toBe(200)
})
it('行拖拽回传索引(库给的是 key,组件负责换算)', async () => {
const onOrder = vi.fn()
const rows = makeRows(3)
const wrapper = mount(G3Table, {
props: {
columns,
dataSource: rows,
seq: false,
rowDraggable: true,
'onRowOrderChange': onOrder,
},
attachTo: document.body,
})
const stk = wrapper.findComponent({ name: 'StkTable' })
// 库回传的是行 key,不是索引
await stk.vm.$emit('row-order-change', '3', '1')
await nextTick()
expect(onOrder).toHaveBeenCalled()
const [emittedRows, sourceIndex, targetIndex] = onOrder.mock.calls[0]
expect(emittedRows).toHaveLength(3)
// id=3 在下标 2,id=1 在下标 0
expect(sourceIndex).toBe(2)
expect(targetIndex).toBe(0)
})
})
describe('暴露的方法', () => {
@@ -366,3 +392,62 @@ describe('暴露的方法', () => {
}
})
})
describe('编辑态:值去重(initialValues)', () => {
/**
* 通过 editRow 进入编辑,再模拟库抛选区变化落到 name 列上,
* 从而拿到真实的 change 链路——直接调内部 ctx 是拿不到 emit 的。
*/
async function enterEditing(wrapper: ReturnType<typeof mountTable>) {
const vm = wrapper.vm as unknown as { editRow: (k: string | number, c?: string | null) => Promise<void> }
await vm.editRow(1, 'name')
await nextTick()
}
function editableColumns(): G3TableColumn<TableRow>[] {
return [
{ dataIndex: 'name', title: '名称', editor: { type: 'text' } },
{ dataIndex: 'amount', title: '金额', editor: { type: 'number' } },
] as G3TableColumn<TableRow>[]
}
it('值确实变化时抛出 change', async () => {
const onChange = vi.fn()
const rows = makeRows(3)
const wrapper = mount(G3Table, {
props: { columns: editableColumns(), dataSource: rows, seq: false, onChange },
attachTo: document.body,
})
await enterEditing(wrapper)
const stk = wrapper.findComponent({ name: 'StkTable' })
// 库的编辑器失焦后会走 notifyChange;这里直接从渲染出的编辑器触发 blur
const input = wrapper.find('input')
expect(input.exists()).toBe(true)
await input.setValue('新名称')
await input.trigger('blur')
await nextTick()
expect(onChange).toHaveBeenCalled()
expect(onChange.mock.calls[0][2]).toBe('新名称')
void stk
})
it('值未变化时不抛 change(点进去又点出来不该触发)', async () => {
const onChange = vi.fn()
const rows = makeRows(3)
const wrapper = mount(G3Table, {
props: { columns: editableColumns(), dataSource: rows, seq: false, onChange },
attachTo: document.body,
})
await enterEditing(wrapper)
const input = wrapper.find('input')
expect(input.exists()).toBe(true)
// 不改变值,直接失焦:初始值快照与当前值相等,应被去重挡掉
await input.trigger('blur')
await nextTick()
expect(onChange).not.toHaveBeenCalled()
})
})
+453 -109
View File
@@ -4,13 +4,24 @@ import { registerFeature, useAreaSelection } from 'stk-table-vue'
/**
* 区域选择特性注册。
*
* 库的区域选择是**按需全局注册**的,不注册则 `area-selection-change` 永不触发
* (行内编辑依赖它)。组件库不拥有 app 启动流程,不能要求消费方在 main.ts 里调用,
* 所以组件挂载时会自动注册一次;这个函数用于需要显式控制的场景
* (例如想在挂载前就注册好,或单测里手动重置状态)。
* 库的区域选择是**按需注册**的(`registerFeature(useAreaSelection)`),
* 不注册则 `area-selection-change` 永不触发、行内编辑整个失效。
*
* `registerFeature` 本身是幂等的;这里再兜一层模块级 + 全局 flag,
* 避免多实例、多入口重复注册。
* ⚠ 注册时机是硬约束:**必须早于任何 StkTable 实例的创建**。
* 库在 `StkTable.vue` 的 **setup 顶层**做这件事:
* } = ON_DEMAND_FEATURE[useAreaSelectionName](props, emits, ...)
* 即组件一被创建就立刻从注册表取值。若那时还没注册,取到的是库内置的
* **降级 stub**(`config: computed(() => ({ enabled: false }))`),
* 此后 `area-selection` 类永不出现、单击单元格不会进入编辑。
*
* 所以不能在 `onMounted` 里注册 —— 挂载晚于子组件的 setup,已经来不及了。
* 正确做法是本模块**被求值时就注册**(见文件末尾的模块级调用)。
*
* 为什么在模块顶层调用而不是要求消费方在 main.ts 里调用:
* 组件库不拥有 app 启动流程,不该要求用户改自己的入口文件;而本模块一旦
* 被 import(用 G3Table 就必然发生),注册就已经完成,先于任何组件创建。
*
* `registerFeature` 本身幂等;这里再兜一层模块级 + 全局 flag,兼容多入口/多副本。
*/
const AREA_SELECTION_FEATURE_KEY = '__g3TableAreaSelectionRegistered__'
let areaSelectionRegistered = false
@@ -24,12 +35,16 @@ export function registerTableFeatures(): void {
}
areaSelectionRegistered = true
}
// 模块求值即注册(早于任何 StkTable 实例化)。原因见上方注释。
registerTableFeatures()
</script>
<script setup lang="ts">
import {
computed,
getCurrentInstance,
isReactive,
nextTick,
onBeforeUnmount,
onMounted,
@@ -43,8 +58,10 @@ import type { StkTableColumn } from 'stk-table-vue'
import { useNamespace } from '../_composables/useNamespace'
import { createSelectionColumn, isSelectionColumn, SELECTION_COLUMN_KEY } from './utils/selection'
import {
DRAG_ROW_COLUMN,
DRAG_ROW_COLUMN_TYPE,
getHeaderAlign,
isInternalColumn,
readInternalWidths,
resolveCustomCell,
resolveSeqConfig,
@@ -52,12 +69,15 @@ import {
} from './utils/column'
import type { EditableContext } from './utils/editable'
import type { G3TableColumn, TableRow, TableRowKey, TableRowSelection } from './types'
import type { ShallowRef } from 'vue'
/**
* G3Table —— 高性能表格。
*
* 底座 `stk-table-vue`:虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序
* 都由它提供。本组件负责三件事:**性能门控**、**内部列管理**、**编辑态管理**。
*
* 实现照搬 fms 的 `FmsTable.vue`(已上线验证),只把命名与令牌换成 @g3soft 体系。
*/
defineOptions({ name: 'G3Table', inheritAttrs: false })
@@ -112,7 +132,8 @@ const props = withDefaults(
autoFitWidth: false,
theme: undefined,
footerData: () => [],
footerConfig: () => ({}),
// 空对象默认值:必须返回新对象(对象字面量直接写会所有实例共享同一引用)
footerConfig: () => ({}) as Record<string, unknown>,
},
)
@@ -169,9 +190,16 @@ function readDocumentTheme() {
/* ---------- 布局刷新 ---------- */
const rootRef = ref<HTMLElement | null>(null)
const tableRef = ref<{ initVirtualScroll?: () => void; initVirtualScrollX?: () => void } | null>(
null,
)
const tableRef = ref<{
initVirtualScroll?: () => void
initVirtualScrollX?: () => void
getTableData?: () => TableRow[]
scrollTo?: (top: number, left: number | null) => void
setCurrentRow?: (row: TableRow | undefined, options: object) => void
getSelectedArea?: () => unknown
clearSelectedArea?: () => void
setRowExpand?: (...args: unknown[]) => unknown
} | null>(null)
/** rAF 合并同一帧内的多次通知,避免重复重排 */
let refreshRafId = 0
@@ -202,13 +230,28 @@ let resizeObserver: ResizeObserver | null = null
/* ---------- 行编辑态 ---------- */
/**
* 单行的编辑状态。
*
* `focused` 在 `activateEditing` 里刻意不重置(fms 同此):它记录的是「重渲染前
* 编辑器是否持有焦点」,供编辑器焦点恢复判定用,不是 UI 状态。
*/
interface RowEditingState {
editing: boolean
focusCol: string | null
focused: boolean
/** 编辑开始时的各可编辑列取值快照,供 shouldNotify 去重 */
initialValues: Map<string, unknown> | null
}
/**
* 每行的编辑状态存在 WeakMap 里(key 是行对象本身)。
* 用 WeakMap 而不是给行对象打标记:不污染业务数据,行被回收时状态自动释放。
*/
const rowEditingStates = new WeakMap<TableRow, ReturnType<typeof shallowRef>>()
const rowEditingStates = new WeakMap<TableRow, ShallowRef<RowEditingState>>()
let activeEditingRow: TableRow | null = null
let pendingFocusKey: string | null = null
let pendingEditingTarget: { row: TableRow; focusCol: string | null } | null = null
const pressedNavigationKeys = new Set<string>()
/**
* 是否存在活动编辑行。
@@ -224,20 +267,38 @@ function getRowKey(row: TableRow): string | number {
return row?.[key] as string | number
}
function getRowEditingState(row: TableRow) {
function getRowEditingState(row: TableRow): ShallowRef<RowEditingState> | null {
if (!row || typeof row !== 'object') return null
let state = rowEditingStates.get(row)
if (!state) {
state = shallowRef({ editing: false, focusCol: null as string | null, focused: false })
state = shallowRef<RowEditingState>({
editing: false,
focusCol: null,
focused: false,
initialValues: null,
})
rowEditingStates.set(row, state)
}
return state
}
/** 编辑开始时的各可编辑列取值快照,供 shouldNotify 去重(fms 的 initialValues) */
function captureEditableValues(row: TableRow): Map<string, unknown> {
return new Map(
props.columns
.filter((col) => col.editor)
.map((col) => [col.dataIndex, row[col.dataIndex]]),
)
}
/**
* 仅清除某行的编辑态(不触碰 activeEditingRow / editingActive)。
* 行与行之间切换编辑时使用,避免 editingActive 翻转导致整表列重建。
*/
function deactivateRowEditing(row: TableRow) {
const state = rowEditingStates.get(row)
if (state && (state.value as { editing: boolean }).editing) {
state.value = { editing: false, focusCol: null, focused: false }
if (state && state.value.editing) {
state.value = { editing: false, focusCol: null, focused: false, initialValues: null }
}
}
@@ -246,13 +307,83 @@ function clearActiveEditing() {
const state = rowEditingStates.get(activeEditingRow)
activeEditingRow = null
editingActive.value = false
if (state && (state.value as { editing: boolean }).editing) {
state.value = { editing: false, focusCol: null, focused: false }
// 仅在确实处于编辑态时写 ref:长按方向键滚动期间每键都会调用本函数,
// 跳过无变化的写入可避免活动行单元格每键重复渲染。
if (state && state.value.editing) {
state.value = { editing: false, focusCol: null, focused: false, initialValues: null }
}
}
/**
* 父级可能在 change 后回灌同 rowKey 的新对象,编辑状态需要随行对象迁移。
* 丢了这个迁移,用户正在编辑时一旦父级刷新数据,编辑态与 focus 就会凭空消失。
*/
function rebindActiveEditingRow(rows: TableRow[]) {
if (!activeEditingRow || rows.includes(activeEditingRow)) return
const previousRow = activeEditingRow
const previousState = rowEditingStates.get(previousRow)?.value
const previousKey = getRowKey(previousRow)
const replacement =
previousKey == null ? null : rows.find((row) => String(getRowKey(row)) === String(previousKey))
activeEditingRow = null
deactivateRowEditing(previousRow)
if (!replacement || !previousState?.editing) {
editingActive.value = false
return
}
const state = getRowEditingState(replacement)
activeEditingRow = replacement
editingActive.value = true
if (state) {
state.value = {
editing: true,
focusCol: previousState.focusCol,
focused: previousState.focused,
initialValues: captureEditableValues(replacement),
}
}
}
/** 开发模式:数据源非响应式时只警告一次(避免每次点单元格都刷屏) */
let warnedNonReactive = false
/**
* 校验数据源是否满足行内编辑的前提:**行对象必须可被 Vue 追踪**。
*
* 编辑回写是直接改 row 上的字段(utils/editable.ts 的 onUpdate:modelValue),
* 若行对象是普通 JS 对象(典型来源:`computed(() => [...])` 派生出的数组),
* 这次写入不会触发任何重渲染。症状极具迷惑性:
* · `change` 事件照常抛出(数据确实改了,父级能拿到)
* · 但界面上仍显示旧值
* · 点一下别处(触发无关重渲染)才"突然"更新
* 实测:computed 数组里 row.pol 从 CNSHA 改成 CNNGB 后,
* G3Select 收到的 modelValue 仍是 CNSHA。
*
* 之所以在这里检测而不是在 props 变化时检测:只有真正进入编辑才会踩到,
* 且此时行对象一定已存在。用 isReactive 判断(Vue 的响应式代理带 __v_isReactive)。
*/
function warnIfDataSourceNotReactive(row: TableRow) {
if (!import.meta.env?.DEV || warnedNonReactive) return
if (isReactive(row) || isReactive(props.dataSource)) return
warnedNonReactive = true
console.warn(
'[g3-ui] G3Table 行内编辑需要响应式数据源,但当前 dataSource 的行对象不是响应式代理。\n' +
' 症状:编辑器里改了值,界面上不刷新(change 事件仍会抛出,数据其实已改),' +
'点其他位置触发重渲染后才显示新值。\n' +
' 原因:编辑回写是直接改 row 字段,普通对象无法被 Vue 追踪。\n' +
' 修法:用 ref() 而不是 computed() 返回普通对象数组,例如\n' +
' const rows = ref<Row[]>([])\n' +
' watch(source, (v) => { rows.value = build(v) }, { immediate: true })',
)
}
function activateEditing(row: TableRow, focusCol: string | null) {
if (!props.editable || !row) return
warnIfDataSourceNotReactive(row)
const state = getRowEditingState(row)
if (!state) return
@@ -260,23 +391,46 @@ function activateEditing(row: TableRow, focusCol: string | null) {
if (activeEditingRow) deactivateRowEditing(activeEditingRow)
activeEditingRow = row
editingActive.value = true
state.value = { editing: true, focusCol, focused: false }
state.value = {
editing: true,
focusCol,
focused: false,
initialValues: captureEditableValues(row),
}
pendingFocusKey = String(getRowKey(row))
return
}
const current = state.value as { editing: boolean; focusCol: string | null; focused: boolean }
const current = state.value
if (current.editing && current.focusCol === focusCol) return
state.value = { ...current, editing: true, focusCol }
// 同行切换列:编辑器会因 focusCol 变化重挂载,标记待聚焦以恢复焦点+全选
pendingFocusKey = String(getRowKey(row))
}
/**
* 值比较:select / checkbox 用 Object.is,其余按字符串比(Input 落库的是 string)。
* 用于 shouldNotify 去重——值没真变就不 emit,否则「点进去又点出来」也会触发 change,
* 业务回灌数据后整表重渲染,进而抢焦点、再失焦,表现为高亮一闪而过。
*/
function editorValuesEqual(col: G3TableColumn<TableRow>, previous: unknown, current: unknown) {
if (col.editor?.type === 'select' || col.editor?.type === 'checkbox') {
return Object.is(previous, current)
}
return String(previous ?? '') === String(current ?? '')
}
/**
* 最近激活、等待编辑器挂载后聚焦的行 key。用于区分「用户激活单元格(点击/键盘/程序化)」
* 与「数据变更引发的重挂载」:前者需要自动聚焦+全选,后者不应聚焦——否则失焦引发的
* 重渲染会把焦点抢回来(表现为离开单元格时高亮一闪而过)。
*/
let pendingFocusKey: string | null = null
const editingCtx: EditableContext<TableRow> = {
isEditing: (row) =>
props.editable && Boolean((getRowEditingState(row)?.value as { editing: boolean })?.editing),
shouldFocus: (row, col) =>
(getRowEditingState(row)?.value as { focusCol: string | null })?.focusCol === col.dataIndex,
wasFocused: (row) => Boolean((getRowEditingState(row)?.value as { focused: boolean })?.focused),
isEditing: (row) => props.editable && Boolean(getRowEditingState(row)?.value.editing),
shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex,
wasFocused: (row) => Boolean(getRowEditingState(row)?.value.focused),
consumePendingFocus: (row) => {
if (pendingFocusKey == null) return false
if (pendingFocusKey !== String(getRowKey(row))) return false
@@ -285,24 +439,61 @@ const editingCtx: EditableContext<TableRow> = {
},
onEditorFocus: (row) => {
const state = getRowEditingState(row)
if (state?.value) state.value = { ...(state.value as object), focused: true }
if (state?.value) state.value = { ...state.value, focused: true }
},
onEditorBlur: (row) => {
const state = getRowEditingState(row)
if (state?.value) state.value = { ...(state.value as object), focused: false }
if (state?.value) state.value = { ...state.value, focused: false }
},
shouldNotify: (row, col, newValue) => {
const values = getRowEditingState(row)?.value.initialValues
if (!values?.has(col.dataIndex)) return true
if (editorValuesEqual(col, values.get(col.dataIndex), newValue)) return false
values.set(col.dataIndex, newValue)
return true
},
shouldNotify: () => true,
onChange: (row, col, value) => {
emit('change', row, col, value)
},
/**
* Esc 回滚:把该行所有可编辑列恢复到进入编辑态时的值。
*
* 快照就是 initialValues(activateEditing 时用 captureEditableValues 存的),
* 与 shouldNotify 去重共用同一份 Map,所以这里**只回滚数据、不改快照**:
* 快照代表的就是「编辑前的原值」,回滚到原值后二者自然一致,
* 之后用户再编辑时去重逻辑仍然正确。
*
* 只做数据回滚,不负责退出编辑态(调用方随后 blur 即可)。
* 返回是否发生了实际变化。
*/
revertRow: (row) => {
const state = getRowEditingState(row)
const values = state?.value.initialValues
if (!values) return false
let changed = false
for (const [dataIndex, original] of values) {
if (Object.is(row[dataIndex], original)) continue
;(row as TableRow)[dataIndex] = original
changed = true
}
return changed
},
}
/* ---------- 列 ---------- */
const seqConfig = computed(() => resolveSeqConfig(props.seq))
const seqWidth = ref(seqConfig.value.width)
watch(
() => seqConfig.value.width,
(width) => {
seqWidth.value = width
},
{ immediate: true },
)
const selectionWidth = ref(44)
const dragRowWidth = ref(40)
const dragRowWidth = ref(DRAG_ROW_COLUMN.width)
const hasRowSelection = computed(() => Boolean(props.rowSelection))
@@ -323,7 +514,7 @@ const columnsBound = 'onUpdate:columns' in (getCurrentInstance()?.vnode.props ??
const effectiveColResizable = (() => {
if (!columnsBound || props.autoFitWidth || !props.colResizable) return false
const configured: { disabled?: boolean | ((col: G3TableColumn<TableRow>) => boolean) } =
props.colResizable === true ? {} : props.colResizable
props.colResizable === true ? { } : props.colResizable
return {
// 入参用库自己的列类型:库的 props 泛型是 any,若这里收窄成 G3TableColumn<TableRow>,
// 函数参数逆变会判定不兼容(库可能传任意列进来)。收宽再在内部窄化是唯一正解。
@@ -343,7 +534,7 @@ const columnDragConfig = computed(() => {
const configured: {
mode?: 'none' | 'insert' | 'swap'
disabled?: boolean | ((col: G3TableColumn<TableRow>) => boolean)
} = props.columnDraggable === true ? {} : props.columnDraggable
} = props.columnDraggable === true ? { } : props.columnDraggable
return {
...configured,
disabled: (column: StkTableColumn<TableRow>) => {
@@ -380,9 +571,7 @@ const displayColumns = computed<G3TableColumn<TableRow>[]>(() => {
}
if (props.rowDraggable) {
cols.unshift({
type: DRAG_ROW_COLUMN_TYPE,
dataIndex: '__g3DragRow',
title: '',
...DRAG_ROW_COLUMN,
width: dragRowWidth.value,
} as unknown as G3TableColumn<TableRow>)
}
@@ -413,42 +602,20 @@ function onColumnsUpdate(rawColumns: StkTableColumn<TableRow>[]) {
* 这里换算成索引再抛给业务——索引更通用,业务不必知道 rowKey 怎么取值。
*/
function handleRowOrderChange(dragStartKey: string, targetRowKey: string) {
const rows =
(tableRef.value as unknown as { getTableData?: () => TableRow[] })?.getTableData?.() ??
props.dataSource
const rows = tableRef.value?.getTableData?.() ?? props.dataSource
tableRef.value?.clearSelectedArea?.()
const sourceIndex = rows.findIndex((row) => String(getRowKey(row)) === String(dragStartKey))
const targetIndex = rows.findIndex((row) => String(getRowKey(row)) === String(targetRowKey))
emit('row-order-change', [...rows], sourceIndex, targetIndex)
}
function handleAreaSelectionChange(ranges: unknown[]) {
if (!props.editable) return
const list = ranges as Array<{ index?: { end?: { row: number; col: number } } }>
const target = list?.at(-1)?.index?.end
if (!target) {
clearActiveEditing()
return
}
const row = props.dataSource[target.row]
const col = displayColumns.value[target.col]
if (!row) {
clearActiveEditing()
return
}
activateEditing(row, col?.editor ? col.dataIndex : null)
emit('area-selection-change', ranges)
}
/* ---------- 对外方法 ---------- */
async function scrollToRow(rowKey: string | number) {
await nextTick()
const idx = props.dataSource.findIndex((row) => String(getRowKey(row)) === String(rowKey))
if (idx < 0) return
;(tableRef.value as unknown as { scrollTo?: (top: number, left: number | null) => void })?.scrollTo?.(
idx * props.rowHeight,
null,
)
tableRef.value?.scrollTo?.(idx * props.rowHeight, null)
}
async function editRow(rowKey: string | number, focusCol: string | null = null) {
@@ -456,10 +623,7 @@ async function editRow(rowKey: string | number, focusCol: string | null = null)
await nextTick()
const row = props.dataSource.find((item) => String(getRowKey(item)) === String(rowKey))
if (!row) return
;(tableRef.value as unknown as { setCurrentRow?: (r: TableRow, o: object) => void })?.setCurrentRow?.(
row,
{ silent: true },
)
tableRef.value?.setCurrentRow?.(row, { silent: true })
activateEditing(row, focusCol)
}
@@ -468,23 +632,155 @@ function initVirtualScroll() {
}
function getSelectedArea() {
return (tableRef.value as unknown as { getSelectedArea?: () => unknown })?.getSelectedArea?.()
return tableRef.value?.getSelectedArea?.()
}
function clearSelectedArea() {
;(tableRef.value as unknown as { clearSelectedArea?: () => void })?.clearSelectedArea?.()
tableRef.value?.clearSelectedArea?.()
}
function setRowExpand(...args: unknown[]) {
return (tableRef.value as unknown as { setRowExpand?: (...a: unknown[]) => unknown })?.setRowExpand?.(
...args,
return tableRef.value?.setRowExpand?.(...args)
}
/* ---------- 键盘导航:编辑器激活防抖 ---------- */
const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab'])
let editActivateTimer: ReturnType<typeof setTimeout> | null = null
let lastEditActivateAt = 0
let isRepeatNavigation = false
function clearEditActivateTimer() {
if (editActivateTimer) {
clearTimeout(editActivateTimer)
editActivateTimer = null
}
}
/**
* 键盘导航停止(松开按键或停顿)后再激活编辑器,合并连续/按住按键,
* 避免滚动过程中每帧创建/销毁编辑器组件导致掉帧。
*/
function scheduleEditActivation() {
clearEditActivateTimer()
editActivateTimer = setTimeout(() => {
editActivateTimer = null
commitPendingEditing()
}, 100)
}
function commitPendingEditing() {
const target = pendingEditingTarget
pendingEditingTarget = null
if (!target) return
const row = props.dataSource.find(
(item) => item === target.row || String(getRowKey(item)) === String(getRowKey(target.row)),
)
if (row) {
activateEditing(row, target.focusCol)
tableRef.value?.setCurrentRow?.(row, { silent: true })
lastEditActivateAt = performance.now()
}
}
function removeNavigationListeners() {
window.removeEventListener('keyup', handleKeyupCapture, true)
window.removeEventListener('blur', handleWindowBlur)
}
function handleKeydownCapture(event: KeyboardEvent) {
if (!NAVIGATION_KEYS.has(event.key)) return
if (!pressedNavigationKeys.size) {
window.addEventListener('keyup', handleKeyupCapture, true)
window.addEventListener('blur', handleWindowBlur)
}
pressedNavigationKeys.add(event.key)
// event.repeat=true 表示系统自动重复(按住方向键不松),用于区分"按住滚动"与"离散点按"
if (event.repeat) isRepeatNavigation = true
}
function handleKeyupCapture(event: KeyboardEvent) {
if (!NAVIGATION_KEYS.has(event.key)) return
if (!pressedNavigationKeys.delete(event.key) || pressedNavigationKeys.size) return
removeNavigationListeners()
if (isRepeatNavigation) {
// 长按已经结束,立即恢复最终目标行,不再额外等待防抖时间。
isRepeatNavigation = false
clearEditActivateTimer()
commitPendingEditing()
return
}
// 快速离散连按仍使用短防抖,避免连续挂载/卸载整行编辑器。
if (pendingEditingTarget) scheduleEditActivation()
}
function handleWindowBlur() {
removeNavigationListeners()
pressedNavigationKeys.clear()
isRepeatNavigation = false
clearEditActivateTimer()
commitPendingEditing()
}
/**
* 选区变化后,目标行进入编辑,活动单元格获得焦点。
*
* 三档策略(照搬 fms):
* - 按住方向键滚动(event.repeat):全程保持 0 编辑器,避免长按下反复创建组件导致卡顿/闪烁;
* 松手后再激活到最终行。
* - 慢速离散点按(非 repeat):立即激活,编辑器逐行跟随、跟手。
* - 快速连按(间隔 <150ms):防抖合并,避免连按时每键挂载掉帧。
*/
function handleAreaSelectionChange(ranges: unknown[]) {
if (!props.editable) return
const list = ranges as Array<{ index?: { end?: { row: number; col: number } } }>
const target = list?.at(-1)?.index?.end
const row = target ? (tableRef.value?.getTableData?.() ?? props.dataSource)[target.row] : null
const col = target ? displayColumns.value[target.col] : null
if (!row) {
pendingEditingTarget = null
clearActiveEditing()
clearEditActivateTimer()
return
}
const focusCol = col?.editor ? col.dataIndex : null
if (pressedNavigationKeys.size > 0) {
if (isRepeatNavigation) {
pendingEditingTarget = { row, focusCol }
clearActiveEditing()
} else {
const now = performance.now()
if (now - lastEditActivateAt >= 150) {
pendingEditingTarget = null
clearEditActivateTimer()
activateEditing(row, focusCol)
tableRef.value?.setCurrentRow?.(row, { silent: true })
lastEditActivateAt = now
} else {
pendingEditingTarget = { row, focusCol }
scheduleEditActivation()
}
}
return
}
// 非键盘导航(鼠标点击等):立即激活。同行切换列时编辑器重挂载并恢复焦点+全选
// (直接输入即替换整格内容);点击已聚焦编辑器内部则由原生行为定位光标。
pendingEditingTarget = null
clearEditActivateTimer()
activateEditing(row, focusCol)
lastEditActivateAt = performance.now()
emit('area-selection-change', ranges)
}
/* ---------- 生命周期 ---------- */
onMounted(() => {
registerTableFeatures()
readDocumentTheme()
if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
@@ -518,27 +814,44 @@ onBeforeUnmount(() => {
resizeObserver = null
themeObserver?.disconnect()
themeObserver = null
removeNavigationListeners()
clearEditActivateTimer()
pressedNavigationKeys.clear()
})
// 数据被父级刷新后,行对象可能整体换新,编辑态需随 key 迁移
watch(
() => props.dataSource,
(rows) => {
if (!activeEditingRow) return
if (rows.includes(activeEditingRow)) return
const previousKey = getRowKey(activeEditingRow)
const replacement = rows.find((row) => String(getRowKey(row)) === String(previousKey))
deactivateRowEditing(activeEditingRow)
activeEditingRow = null
editingActive.value = false
if (replacement) activateEditing(replacement, null)
rebindActiveEditingRow(rows)
},
)
// activeRowKey 只控制当前行高亮,不隐式触发编辑
watch(
[() => props.activeRowKey, () => props.dataSource],
async ([rowKey]) => {
rebindActiveEditingRow(props.dataSource)
await nextTick()
if (rowKey == null) {
tableRef.value?.setCurrentRow?.(undefined, { silent: true })
return
}
const row = props.dataSource.find((item) => String(getRowKey(item)) === String(rowKey))
if (row) tableRef.value?.setCurrentRow?.(row, { silent: true })
},
{ immediate: true },
)
watch(
() => props.editable,
(editable) => {
if (editable) return
pendingEditingTarget = null
removeNavigationListeners()
pressedNavigationKeys.clear()
isRepeatNavigation = false
clearEditActivateTimer()
clearActiveEditing()
clearSelectedArea()
},
@@ -570,6 +883,7 @@ defineExpose({
autoFitWidth ? is('auto-fit-width') : '',
]"
data-slot="table"
@keydown.capture="handleKeydownCapture"
>
<StkTable
ref="tableRef"
@@ -594,7 +908,7 @@ defineExpose({
:show-overflow="showOverflow"
:empty-cell-text="''"
:area-selection="editable"
:bordered="bordered ? 'v' : false"
:bordered="bordered ? true : false"
@update:columns="onColumnsUpdate"
@area-selection-change="handleAreaSelectionChange"
@row-order-change="handleRowOrderChange"
@@ -623,13 +937,10 @@ defineExpose({
overflow: hidden;
}
/* 自绘外框:描边沿圆弧走、四角闭合。
库自身的边框恒为 false(我们传 bordered="v" 只取它的竖向分割线),
外框与圆角由这一层提供,避免库的方形渐变外框与圆角打架。 */
.#{b('table')}.#{is('bordered')} {
border: 1px solid v('border-color', #d9d9d9);
border-radius: v('radius-lg', 8px);
}
/* 边框完全交给库:传 `bordered: true`,外框 / 圆角 / 横竖线都由库自己画。
库文档(table/basic/bordered)说明:出于滚动条影响,表格**右侧和底部的边框
由单元格的 border-right / border-bottom 实现,因此会消失**,需要时自行补 CSS。
我们按库的原样使用,不再自绘外框与行分隔线。 */
.#{e('table', 'inner')} {
width: 100%;
@@ -642,34 +953,46 @@ defineExpose({
}
}
/* 不自适应时按列配置宽度展示;自适应模式不启用列宽拖拽,使用库默认布局 */
.#{b('table')}:not(.#{is('auto-fit-width')}) :deep(.stk-table-main) {
/* 不自适应时按列配置宽度展示;自适应模式不启用列宽拖拽,使用库默认布局。
库的 DOM 是
.stk-table{display:flex;flex-direction:column}
└ .stk-table-scroll-container{display:flex} ← 表头粘性定位的作用域
└ table.stk-table-main ← 这里是个 flex item
库默认给 --main 是 flex:1,表头 th 靠 position:sticky 固定、作用域就是这个
flex 容器,table 一旦被拉伸到容器宽,表头几何就走样(表现为滚动时表头跟着跑)。
实测(playwright):去掉本规则并强制 flex:1 后,table 宽度 790px → 1086px
(被拉满容器),表头跟着变形,所以这条必须留着。
注意写普通后代选择器:这个 style 块**不是 scoped**,:deep() 在非 scoped
块里会被编译器整条丢弃(实测产物里该规则消失),必须用真选择器。 */
.#{b('table')}:not(.#{is('auto-fit-width')}) .#{e('table', 'inner')} .stk-table-main {
flex: none;
}
/* bordered="v" 仍会给根节点画方形外框与最后一列的右侧渐变线,
这些边缘该由外层圆角容器提供,去掉库的外边缘后只保留单元格之间的竖线。
注意只能抹掉最右一列的外侧右边线——整份清空会把库给右固定列补的
fixed-cell--border-left 也一并干掉(表现为操作列压住内容却没有分割线)。 */
.#{e('table', 'inner')}.stk-table.bordered-v {
border-left: none;
background-image: none;
th:last-child,
td:last-child {
--bg-border-right: linear-gradient(transparent, transparent);
}
}
/* ---------- 行分隔线 ---------- */
.#{e('table', 'inner')}.stk-table {
th,
td {
border-bottom: 1px solid v('border-color', #d9d9d9);
}
}
/* ---------- 行内编辑 ---------- */
/**
* 编辑态行高必须与 rowHeight 一致(32px = var(--row-height))。
*
* 曾经的 bug:编辑某行后该行从 32px 变成 42px,同行的其它单元格(包括
* 纯文本的序号列)也一起被撑高。逐条禁用声明的 bisect 实测:
* padding: 3px + 编辑器 height:100% → 42px ← 原始
* padding: 0 → 36px (减 6)
* padding: 0 + 编辑器显式 26px → 32px ← 目标达成
* padding: 0 + 编辑器显式 32px → 36px
*
* 根因:td 是 border-box,指定 height 32px 时其 content box 只有
* 32 - 6(padding) = 26px;子元素的 height:100% 却解析成 32px(不是 26px)。
* 于是「编辑器 32 + 上下 padding 6 = 38px」超过了 td 的指定高度,
* table-cell 只能按内容撑开,再加行盒的取整余量,最终落到 42px。
*
* 修法:**td 的垂直 padding 归零**,只保留水平内边距。
* 这样 td 的 content box 就等于行高,子元素 height:100% 有确定基准(32px),
* 不会再撑高行。编辑器与格线之间那点呼吸感改由编辑器自身的 padding 提供
* (G3Input/G3Select 本来就带内边距,无需再靠 td 的 padding)。
*
* ⚠ 不要再把垂直 padding 加回 td —— 那会立刻重现 42px。
* 组件自带的 E2E 有断言锁住「编辑前后行高不变」。
*/
.#{e('table', 'cell-editor')} {
width: 100%;
height: 100%;
@@ -681,10 +1004,16 @@ defineExpose({
子元素 height:100% 无法解析,Input 会塌成内容高度。 */
.#{e('table', 'inner')}.stk-table .stk-tbody-main td:has(> .#{e('table', 'cell-editor-root')}) {
height: var(--row-height);
/* 垂直 padding 必须为 0:见上方注释,它会让 height:100% 的基准与 td 的
border-box 高度不一致,把行撑高 10px。水平保留一点,避免输入框贴边。 */
padding-top: 0;
padding-bottom: 0;
}
/* 水平内边距单独给(不写 padding 简写,避免把垂直方向又带回来) */
.#{e('table', 'inner')} td:has(> .#{e('table', 'cell-editor-root')}) {
padding: 3px;
padding-left: 3px;
padding-right: 3px;
}
/* 编辑器根节点填满单元格 */
@@ -696,6 +1025,21 @@ defineExpose({
height: 100%;
min-height: 0;
box-sizing: border-box;
/**
* vertical-align 必须显式给,不能留默认的 baseline。
*
* 编辑器是**行内级**元素(G3Input/G3Select 用 inline-flex / inline-block),
* 行盒按 baseline 对齐时会在其下方额外留出 descender 高度(实测约 4px),
* 把整行从 32px 撑到 36px。bisect 实测:
* 基线(inline-flex + baseline) → 36px
* vertical-align: middle → 32px ✅
* vertical-align: top → 32px
* display: block → 32px
* 选 middle 而非 block:既消除基线空隙,又保留编辑器自身的行内布局方式,
* 同时让它垂直居中于单元格(视觉上也更合理)。
*/
vertical-align: middle;
}
}
}
@@ -0,0 +1,61 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import G3Table from './G3Table.vue'
import type { G3TableColumn, TableRow } from './types'
/**
* 验证 inheritAttrs: false + v-bind="$attrs" 的实际落点。
*
* 结论应当是:非 prop 的 attrs 落在**内层 StkTable 的根元素**上,
* 而不是 G3Table 自己的根 div(.g3-table)。
*/
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称' },
]
const rows: TableRow[] = [{ id: 1, name: 'A' }]
describe('attrs 落点', () => {
it('非 prop 属性(data-testid / class / style)落在内层,而不是 .g3-table 根 div', () => {
const wrapper = mount(G3Table, {
props: { columns, dataSource: rows, seq: false },
attrs: {
'data-testid': 'my-table',
class: 'my-extra-class',
style: 'outline: 1px solid red',
'aria-label': '测试表',
},
attachTo: document.body,
})
const rootEl = wrapper.element as HTMLElement
console.log('ROOT div classes: ' + rootEl.className)
console.log('ROOT div outerHTML head: ' + rootEl.outerHTML.slice(0, 160))
const stk = wrapper.findComponent({ name: 'StkTable' })
const stkEl = stk.element as HTMLElement
console.log('STK element classes: ' + stkEl.className)
// 根 div 不应带上用户传的 class / testid
expect(rootEl.className).not.toContain('my-extra-class')
expect(rootEl.getAttribute('data-testid')).toBeNull()
// 内层应当拿到
expect(stkEl.className).toContain('my-extra-class')
expect(stkEl.getAttribute('data-testid')).toBe('my-table')
expect(stkEl.getAttribute('aria-label')).toBe('测试表')
})
it('已声明的 prop 不会被当 attrs 透传下去(如 bordered / seq)', () => {
const wrapper = mount(G3Table, {
props: { columns, dataSource: rows, seq: false, bordered: false },
attachTo: document.body,
})
const stk = wrapper.findComponent({ name: 'StkTable' })
const props = stk.props() as Record<string, unknown>
// bordered 是我们声明过的 prop,经我们处理后传给库(false → false)
expect(props.bordered).toBe(false)
// seq 是组件自己的 prop,不应原样出现在 $attrs 里影响库
expect(props.seq).toBeUndefined()
})
})
@@ -28,8 +28,10 @@
// 三个 --bg-border-* 覆盖(那是在跟派生关系较劲,而且写到了全局作用域)。
--border-color: #{v('border-color', #d9d9d9)};
--td-bgc: #{v('bg-container', #fff)};
--th-bgc: #{v('fill', rgba(0, 0, 0, 0.02))};
--tf-bgc: #{v('fill', rgba(0, 0, 0, 0.02))};
// 表头 / 表尾底色:这里**不设** --th-bgc / --tf-bgc。
// 库那两个变量是 alpha 值、且在 sticky 滚动时会透出下方行内容;
// 改为在文件末尾直接给 th / 表尾 td 一个不透明实色(含完整原因与实测数据)。
--th-color: #{v('text-1', rgba(0, 0, 0, 0.88))};
--stripe-bgc: #{v('fill', rgba(0, 0, 0, 0.02))};
@@ -72,6 +74,14 @@
// ── 单元格高亮(程序化 highlight)
--highlight-color: #{v('color-primary', #18181b)};
// ── 空态底色:库的 `.stk-table-no-data` 用它当背景。
// 我们不传 empty 插槽时由组件渲染 `.g3-table__empty`,但库的空态节点仍在
// (noDataFull 时铺满),漏掉会让底色停在库默认的 #fff/dark 上,与本库主题不一致。
--table-bgc: #{v('bg-container', #fff)};
// ── 树形懒加载 loading 图标颜色(1.2.x 新增,1.1.0 没有)
--tree-loading-color: #{v('text-3', rgba(0, 0, 0, 0.45))};
// ── 字体:库用 inherit,这里显式跟随令牌
font-family: v('font-family', inherit);
font-size: v('font-size', 14px);
@@ -85,8 +95,7 @@
.#{e('table', 'inner')}.stk-table.dark {
--border-color: #{v('border-color', #424242)};
--td-bgc: #{v('bg-container', #141414)};
--th-bgc: #{v('fill', rgba(255, 255, 255, 0.04))};
--tf-bgc: #{v('fill', rgba(255, 255, 255, 0.04))};
// 表头 / 表尾底色改在文件末尾统一处理(不透明基底 + fill 叠加),此处不设
--th-color: #{v('text-1', rgba(255, 255, 255, 0.88))};
--stripe-bgc: #{v('fill', rgba(255, 255, 255, 0.04))};
@@ -118,6 +127,54 @@
--rs-bc: #{v('color-primary', #e5e5e5)};
--highlight-color: #{v('color-primary', #e5e5e5)};
// 与亮色同因:空态底色 + 树形懒加载图标色(见上方亮色块注释)
--table-bgc: #{v('bg-container', #141414)};
--tree-loading-color: #{v('text-3', rgba(255, 255, 255, 0.45))};
}
}
// ==========================================================================
// 表头 / 表尾:不透明底色(防 sticky 滚动时透出下方内容)
// --------------------------------------------------------------------------
// 症状:虚拟滚动 demo 里滚动时能看到表体行的内容从表头下面一闪而过;
// 静止时看不出来(下方恰好是纯白行)。
//
// 根因:库把表头底色做成 **alpha** 值并画在 `thead tr` 上:
// .stk-table thead tr { background-color: var(--th-bgc) }
// .stk-table th { background-color: inherit } ← th 继承这份半透明色
// th 又是 position:sticky,滚动时下方行高速掠过,浏览器必须把表头每个像素
// 与「此刻经过的那一行」实时合成 —— 于是内容透出来。
//
// ⚠ 踩过的坑(别重犯):曾试图在 `thead tr` 上铺「实色 + fill 渐变」两层,
// 结果表头渲染成**纯白 255**(本该是 245)——因为 th 自己还有一层
// background-image(库用它画边框渐变,`.stk-table.bordered th`),
// th 的背景绘制在 tr 之上,把 tr 的渐变压掉了。
// 实测三种写法(截图取主色):
// 写在 tr(实色+渐变) → 255,255,255 ❌ 被 th 盖住
// 写在 th(直接给实色 #f5f5f5)→ 245,245,245 ✅
// 写在 th(白底+4%渐变) → 245,245,245 ✅
// 故采用最简单的一种:直接给 th 一个不透明实色。
//
// 令牌体系里 fill 全是 alpha(rgba),没有「不透明的浅灰面」这一类;而 v() 返回
// var(...) 字符串,编译期拿不到色值、Sass mix() 也用不上。所以这里写死实色:
// 245,245,245 正是 rgba(0,0,0,.04) 叠在白底上的等效结果,观感与改动前一致。
// 暗色侧同理(rgba(255,255,255,.04) 叠 #141414 ≈ 20,20,20)。
.#{b('table')} {
.#{e('table', 'inner')}.stk-table {
thead th,
.stk-footer td {
background-color: #f5f5f5;
}
}
}
.#{b('table')} {
.#{e('table', 'inner')}.stk-table.dark {
thead th,
.stk-footer td {
background-color: #141414;
}
}
}
@@ -7,7 +7,7 @@ import type { DropdownClickPayload, DropdownItemData } from '../../dropdown'
import type { G3TableColumn, TableAction, TableRow } from '../types'
/**
* 操作列。
* 操作列(照抄 fms 的 createCellRenderer.js 中操作列部分)。
*
* 两类按钮:内联的(直接渲染成小按钮)与「更多」下拉(`type: 'more'`)。
* 带 `confirm` 的操作先弹确认框——用**状态化**的 G3Modal 而不是命令式 `Modal.confirm()`,
@@ -144,6 +144,9 @@ export function createActionsCell<T extends TableRow>(actions: TableAction<T>[])
const confirmState = ref<{ action: TableAction<T>; row: T } | null>(null) as {
value: { action: TableAction<T>; row: T } | null
}
const itemByKey = new Map(
moreAction?.items?.map((item, index) => [String(item.key ?? item.type ?? index), item]) ?? [],
)
function closeMenu() {
openRow.value = null
@@ -219,7 +222,10 @@ export function createActionsCell<T extends TableRow>(actions: TableAction<T>[])
onClick: (payload: DropdownClickPayload) => {
const item =
visibleItemByKey.get(String(payload?.key)) ??
visibleMoreItems.find((it, index) => String(it.key ?? it.type ?? index) === String(payload?.key))
itemByKey.get(String(payload?.key)) ??
visibleMoreItems.find(
(it, index) => String(it.key ?? it.type ?? index) === String(payload?.key),
)
closeMenu()
if (item) triggerAction(item, row)
},
@@ -15,7 +15,15 @@ export const SEQ_COLUMN_TYPE = 'seq'
/** 行拖拽列的内部类型标识(库用它渲染拖拽手柄) */
export const DRAG_ROW_COLUMN_TYPE = 'dragRow'
/** 内部列(选择 / 序号 / 拖拽 / 操作)的 dataIndex,用于回写时识别并剥离 */
/** 行拖拽列定义(fms 里是模块级常量,宽度需要响应式所以只复用这个模板) */
export const DRAG_ROW_COLUMN = {
type: DRAG_ROW_COLUMN_TYPE,
dataIndex: '__g3DragRow',
title: '',
width: 40,
}
/** 内部列(选择 / 序号 / 拖拽)的 dataIndex,用于回写时识别并剥离 */
const INTERNAL_DATA_INDEXES = new Set(['', '__g3Selection', '__g3DragRow', '__actions__'])
export interface SeqConfig {
@@ -29,7 +37,7 @@ export function resolveSeqConfig(
seq: boolean | { title?: string; width?: number } | undefined,
): SeqConfig {
if (!seq) return { enabled: false, title: '序号', width: 50 }
const cfg: { title?: string; width?: number } = seq === true ? {} : seq
const cfg: { title?: string; width?: number } = seq === true ? { } : seq
return {
enabled: true,
title: cfg.title ?? '序号',
@@ -51,6 +59,8 @@ export function getHeaderAlign<T extends TableRow>(
if (isSelectionColumn(column)) return 'center'
if (column.dataIndex === 'actions') return 'center'
if (column.actions && column.actions.length) return 'center'
// 库自己的 checkbox 列类型(与我们的 editor.type === 'checkbox' 是两回事)
if ((column as { checkbox?: boolean }).checkbox === true) return 'center'
if (column.editor && column.editor.type === 'checkbox') return 'center'
return 'left'
}
@@ -21,11 +21,14 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
changes: Array<{ row: Row; value: unknown }>
focused: boolean[]
blurred: boolean[]
/** revertRow 被调用的行 id 列表(Esc 回滚) */
reverted: number[]
notified: number
} {
const changes: Array<{ row: Row; value: unknown }> = []
const focused: number[] = []
const blurred: number[] = []
const reverted: number[] = []
let notified = 0
const ctx: EditableContext<Row> = {
isEditing: () => true,
@@ -39,6 +42,10 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
return true
},
onChange: (row, _col, value) => changes.push({ row, value }),
revertRow: (row) => {
reverted.push(row.id)
return true
},
...overrides,
}
return {
@@ -46,6 +53,7 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
changes,
focused,
blurred,
reverted,
// 用 getter 读实时值
get notified() {
return notified
@@ -404,3 +412,97 @@ describe('select 编辑器', () => {
expect(options[0].label).toBe('行7')
})
})
/**
* 键盘语义:Enter 与 Esc。
*
* 这一组此前**完全缺失** —— 正因为没有测试守着,Esc 才会长期是
* 「blur 提交」(按 Esc 反而保存),与 Excel / AG Grid / Handsontable / antd
* 的通用约定(Enter 提交、Esc 取消)相反。
*/
describe('键盘:Enter 与 Esc', () => {
/** 取出某列编辑器 vnode 的 onKeydown */
function keydownOf(col: G3TableColumn<Row>, ctx: EditableContext<Row>, row: Row) {
const vnode = render(col, ctx, row)
return vnode.props.onKeydown as (e: unknown) => void
}
/** 造一个足够真实的 KeyboardEvent 替身(编辑器只用到 key / target / preventDefault / stopPropagation) */
function makeKeyEvent(key: string, value: string) {
const target = {
value,
select: vi.fn(),
blur: vi.fn(),
}
return {
key,
target,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
}
}
it('Enter:阻止默认并全选内容(语义是"全选以便覆写",不是提交)', () => {
const col = makeColumn({ editor: { type: 'text' } } as never)
const { ctx } = makeCtx()
const event = makeKeyEvent('Enter', 'abc')
keydownOf(col, ctx, { id: 1, name: 'abc', status: 1 })(event)
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
expect(event.target.select).toHaveBeenCalled()
// 关键:Enter 不该提交,也不该 blur
expect(event.target.blur).not.toHaveBeenCalled()
})
it('Esc:先回滚数据,再失焦(语义是"取消",不是提交)', () => {
const col = makeColumn({ editor: { type: 'text' } } as never)
const row = { id: 1, name: '已改动', status: 1 }
const { ctx, reverted, changes } = makeCtx()
const event = makeKeyEvent('Escape', '已改动')
keydownOf(col, ctx, row)(event)
// 必须先调用 revertRow 再 blur —— 顺序反了就等于「按 Esc 保存了」
expect(reverted).toEqual([1])
expect(event.target.blur).toHaveBeenCalled()
expect(changes).toHaveLength(0)
})
it('Esc 回滚后失焦:不会再抛出 change(值已复原,被去重挡掉)', () => {
const col = makeColumn({ editor: { type: 'text' } } as never)
const row = { id: 1, name: '原始', status: 1 }
// 真实场景:revertRow 会把行数据恢复成原值
const { ctx, changes } = makeCtx({
revertRow: (r) => {
r.name = '原始'
return true
},
})
const vnode = render(col, ctx, row)
// 用户改了内容
;(vnode.props['onUpdate:modelValue'] as (v: string) => void)('改过的')
// 按 Esc
;(vnode.props.onKeydown as (e: unknown) => void)(makeKeyEvent('Escape', '改过的'))
// 随后 blur(blur 会 commit 当前 input 的值)
;(vnode.props.onBlur as (e: unknown) => void)({ target: { value: '原始' } })
expect(row.name).toBe('原始')
})
it('Esc 会清掉草稿:再渲染回到行数据的值而不是残留的输入', () => {
const col = makeColumn({ editor: { type: 'text' } } as never)
const row = { id: 1, name: '原始', status: 1 }
const { ctx } = makeCtx()
const vnode = render(col, ctx, row)
;(vnode.props['onUpdate:modelValue'] as (v: string) => void)('输入中')
;(vnode.props.onKeydown as (e: unknown) => void)(makeKeyEvent('Escape', '输入中'))
// 草稿已清,重新渲染应显示行数据的值
const again = render(col, ctx, row)
expect(again.props.modelValue).toBe('原始')
})
})
@@ -5,7 +5,7 @@ import { useNamespace } from '../../_composables/useNamespace'
import type { G3TableColumn, TableEditor, TableRow, TableSelectOption } from '../types'
/**
* 行内编辑单元格。
* 行内编辑单元格(照抄 fms 的 createEditableCellRenderer.js)。
*
* 三条关键设计(都来自 fms 的实战结论,此处照搬):
*
@@ -41,6 +41,14 @@ export interface EditableContext<T extends TableRow> {
shouldNotify: (row: T, col: G3TableColumn<T>, value: unknown) => boolean
/** 通知父级 */
onChange: (row: T, col: G3TableColumn<T>, value: unknown) => void
/**
* 把该行恢复到「进入编辑态时」的值(Esc 用)。
*
* 快照由 G3Table 在 activateEditing 时用 captureEditableValues 存下,
* 与 shouldNotify 去重共用同一份 initialValues。
* 返回是否真的发生了回滚(值本来就没变则返回 false,避免无谓 emit)。
*/
revertRow: (row: T) => boolean
}
/* ---------- 草稿 ---------- */
@@ -183,6 +191,17 @@ function createInput<T extends TableRow>(
) {
const { e } = useNamespace('table')
const focus = createFocusHandler(ctx, row, col, true)
/**
* 本次失焦是否为「Esc 取消」触发的。
*
* 为什么需要它:onBlur 读的是 `event.target.value`(**DOM 里用户敲进去的文本**),
* 而 ctx.revertRow 只恢复**行数据**、不会碰 DOM input。若只回滚数据就 blur,
* onBlur 会把用户输入又写回 row,回滚当场被覆盖(实测确认过这条)。
*
* 用标志位而不是手动改 input.value:后者要直接操作 DOM,还要处理 G3Input
* 内部受控值同步,容易与组件的受控渲染打架。
*/
let canceling = false
return h(G3Input, {
type: INPUT_TYPE_MAP[editor.type] ?? 'default',
modelValue: (getInputDraft(row, col.dataIndex) ?? '') as string,
@@ -194,6 +213,12 @@ function createInput<T extends TableRow>(
onFocus: () => ctx.onEditorFocus?.(row),
onBlur: (event: FocusEvent) => {
ctx.onEditorBlur?.(row)
if (canceling) {
// Esc 取消:丢弃用户输入,不落库、不通知父级
canceling = false
clearInputDraft(row, col.dataIndex)
return
}
const value = (event.target as HTMLInputElement).value
commitInputDraft(row, col.dataIndex, value)
notifyChange(ctx, editor, row, col, value)
@@ -205,6 +230,16 @@ function createInput<T extends TableRow>(
event.stopPropagation()
;(event.target as HTMLInputElement).select()
} else if (event.key === 'Escape') {
// Esc = **放弃本次编辑**(恢复到进入编辑前的值),符合 Excel / AG Grid /
// Handsontable / antd 的通用约定(Enter 提交、Esc 取消)。
//
// ⚠ 顺序与配套缺一不可:
// 1) 置 canceling —— 让随后 onBlur 跳过提交(关键,否则回滚被用户输入覆盖)
// 2) revertRow —— 把行数据恢复到进入编辑时的快照
// 3) 清草稿 + blur —— 退出编辑态,输入框值经受控渲染回到行数据的原值
canceling = true
ctx.revertRow(row)
clearInputDraft(row, col.dataIndex)
;(event.target as HTMLInputElement).blur()
}
},
@@ -3,7 +3,7 @@ import { useNamespace } from '../../_composables/useNamespace'
import type { G3TableColumn, TableRow } from '../types'
/**
* 多选列。
* 多选列(照抄 fms 的 createSelectionCellRenderers.js)。
*
* **为什么不用 `G3Checkbox` 组件**:表格是逐行渲染的,一个 Checkbox 组件实例
* = 一次 setup + 响应式 + vnode 树。滚动时这些实例不断挂载/卸载,是纯粹的浪费。
@@ -45,7 +45,7 @@ export function createSelectionColumn<T extends TableRow>(
const { getDataSource, getRowKey, getSelectedRowKeys, isRowDisabled = () => false, onChange } =
options
const { e, b, is } = useNamespace('table')
const { e, is } = useNamespace('table')
/** 已选 key 集合 */
const selectedRowKeySet = computed(() => new Set(getSelectedRowKeys()))