u
This commit is contained in:
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)
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()))
|
||||
|
||||
Reference in new issue
Block a user