-
+
+ 回写后的列宽:{{ widths || '(拖一下试试)' }}
+
+
+
+
+
+
+ 按列禁用拖宽:用 colResizable 的 disabled(col) 回调。
+
+
+
diff --git a/code/g3soft-libs/docs/examples/table/selection.vue b/code/g3soft-libs/docs/examples/table/selection.vue
index 50fa92bb..ab23237c 100644
--- a/code/g3soft-libs/docs/examples/table/selection.vue
+++ b/code/g3soft-libs/docs/examples/table/selection.vue
@@ -4,31 +4,37 @@ import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 多选:传 rowSelection 即在最前注入选择列。
-// getCheckboxProps 可禁用个别行(禁用行不参与「全选」的分母)。
+// - selectedRowKeys 用 v-model 式双向绑定,勾选变化会回写
+// - getCheckboxProps 可禁用个别行;**禁用行不计入「全选」的分母**
+// (所以下面表格里表头全选只会选中 2 行)
const columns: G3TableColumn[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额', width: 120, align: 'right' },
+ { dataIndex: 'owner', title: '负责人', width: 120 },
]
const data: TableRow[] = [
- { id: 1, name: '框架合同', amount: 120000 },
- { id: 2, name: '采购订单', amount: 86000, locked: true },
- { id: 3, name: '验收单', amount: 43000 },
+ { id: 1, name: '框架合同', amount: 120000, owner: '张三' },
+ { id: 2, name: '采购订单', amount: 86000, owner: '李四', locked: true },
+ { id: 3, name: '验收单', amount: 43000, owner: '王五' },
]
const selectedRowKeys = ref>([])
-const selectedText = ref('')
+const selectedNames = ref([])
function onSelectionChange(keys: Array, rows: TableRow[]) {
selectedRowKeys.value = keys
- selectedText.value = rows.map((row) => row.name).join('、')
+ selectedNames.value = rows.map((row) => String(row.name))
}
- 已选:{{ selectedText || '(无)' }}
+ 已选 {{ selectedRowKeys.length }} 项:{{ selectedNames.join('、') || '(无)' }}
+
+
+ 「采购订单」被 getCheckboxProps 禁用,不参与全选。
[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
- { dataIndex: 'amount', title: '金额', width: 140, align: 'right', sorter: true, sortType: 'number' },
+ {
+ dataIndex: 'amount',
+ title: '金额(数字排序)',
+ width: 160,
+ align: 'right',
+ sorter: true,
+ sortType: 'number',
+ },
{ dataIndex: 'qty', title: '数量', width: 120, align: 'right', sorter: true, sortType: 'number' },
+ {
+ // dataIndex 必须指向一个**真实存在的字段**:库按 row[dataIndex] 渲染单元格,
+ // 复用 'name' 会让这一列显示名称本身(而不是长度),并且与「名称」列重复。
+ dataIndex: 'nameLength',
+ title: '名称长度(自定义)',
+ width: 160,
+ align: 'right',
+ // 自定义排序接收的是**整个数组**与排序状态,返回排好的新数组
+ // (不是 Array.prototype.sort 的 (a, b) 比较器——写成 (a, b) 会静默不生效)
+ sorter: (rows: TableRow[], { order }: { order: 'asc' | 'desc' | null }) => {
+ if (!order) return rows
+ const sign = order === 'asc' ? 1 : -1
+ return [...rows].sort((a, b) => (Number(a.nameLength) - Number(b.nameLength)) * sign)
+ },
+ },
]
const data = ref([
- { id: 1, name: '框架合同', amount: 120000, qty: 12 },
- { id: 2, name: '采购订单', amount: 86000, qty: 45 },
- { id: 3, name: '验收单', amount: 43000, qty: 7 },
+ { id: 1, name: '框架合同', nameLength: 4, amount: 120000, qty: 12 },
+ { id: 2, name: '采购订单补充协议', nameLength: 8, amount: 86000, qty: 45 },
+ { id: 3, name: '验收单', nameLength: 3, amount: 43000, qty: 7 },
])
diff --git a/code/g3soft-libs/docs/examples/table/virtual.vue b/code/g3soft-libs/docs/examples/table/virtual.vue
index f88695e9..a240ed52 100644
--- a/code/g3soft-libs/docs/examples/table/virtual.vue
+++ b/code/g3soft-libs/docs/examples/table/virtual.vue
@@ -3,13 +3,14 @@ import { computed, ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
-// 性能门控:行数超过 100 才开启纵向虚拟滚动。
-// 组件内部按 dataSource.length 自动判断,业务不需要手动切换 virtual。
+// 性能门控:行数 > 100 才开启纵向虚拟滚动,组件内部按 dataSource.length 自动判断,
+// 业务不需要手动切 virtual(也**不能**强制打开到阈值以下)。
+// 拖动下面的滑杆可以直观看到阈值前后的差异。
const rowCount = ref(5000)
const virtualColumns: G3TableColumn[] = [
{ dataIndex: 'id', title: 'ID', width: 90 },
{ dataIndex: 'name', title: '名称', width: 200 },
- { dataIndex: 'value', title: '数值', width: 140, align: 'right' },
+ { dataIndex: 'value', title: '数值', width: 140, align: 'right', sorter: true, sortType: 'number' },
]
const virtualData = computed(() =>
@@ -24,9 +25,11 @@ const virtualData = computed(() =>
- 当前 {{ rowCount }} 行 → {{ rowCount > 100 ? '已开启' : '未开启(行数不足 100 时全量渲染)' }}纵向虚拟滚动
+ 当前 {{ rowCount }} 行 →
+ {{ rowCount > 100 ? '已开启' : '未开启(行数不足 100 时全量渲染)' }}纵向虚拟滚动
-
diff --git a/code/g3soft-libs/packages/ui/e2e-docs/table-docs.spec.ts b/code/g3soft-libs/packages/ui/e2e-docs/table-docs.spec.ts
new file mode 100644
index 00000000..a300de15
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e-docs/table-docs.spec.ts
@@ -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
): string {
+ const { width, height, pixels, bpp } = img
+ const hist = new Map()
+ 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 }`
+ * 会命中组件内部的 ``,让它变成滚动容器,
+ * 于是 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)
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/e2e-erp/booking.spec.ts b/code/g3soft-libs/packages/ui/e2e-erp/booking.spec.ts
new file mode 100644
index 00000000..e7e6c982
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e-erp/booking.spec.ts
@@ -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)
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/e2e-erp/repro-select.spec.ts b/code/g3soft-libs/packages/ui/e2e-erp/repro-select.spec.ts
new file mode 100644
index 00000000..1c82423c
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e-erp/repro-select.spec.ts
@@ -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)
+})
diff --git a/code/g3soft-libs/packages/ui/e2e-erp/verify-fix.spec.ts b/code/g3soft-libs/packages/ui/e2e-erp/verify-fix.spec.ts
new file mode 100644
index 00000000..2b0ebcb8
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e-erp/verify-fix.spec.ts
@@ -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() —— 编辑态里是 ,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('第二次改动')
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/e2e-erp/verify-rowheight.spec.ts b/code/g3soft-libs/packages/ui/e2e-erp/verify-rowheight.spec.ts
new file mode 100644
index 00000000..286099fd
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e-erp/verify-rowheight.spec.ts
@@ -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)
+})
diff --git a/code/g3soft-libs/packages/ui/e2e/table.spec.ts b/code/g3soft-libs/packages/ui/e2e/table.spec.ts
index 0243ba9b..0218fea5 100644
--- a/code/g3soft-libs/packages/ui/e2e/table.spec.ts
+++ b/code/g3soft-libs/packages/ui/e2e/table.spec.ts
@@ -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')
diff --git a/code/g3soft-libs/packages/ui/package.json b/code/g3soft-libs/packages/ui/package.json
index 6a7c2ab8..67884268 100644
--- a/code/g3soft-libs/packages/ui/package.json
+++ b/code/g3soft-libs/packages/ui/package.json
@@ -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",
diff --git a/code/g3soft-libs/packages/ui/playwright.docs.config.ts b/code/g3soft-libs/packages/ui/playwright.docs.config.ts
new file mode 100644
index 00000000..340c06f5
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/playwright.docs.config.ts
@@ -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'] } }],
+})
diff --git a/code/g3soft-libs/packages/ui/playwright.erp.config.ts b/code/g3soft-libs/packages/ui/playwright.erp.config.ts
new file mode 100644
index 00000000..c5cefc14
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/playwright.erp.config.ts
@@ -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'] } }],
+})
diff --git a/code/g3soft-libs/packages/ui/src/table/G3Table.test.ts b/code/g3soft-libs/packages/ui/src/table/G3Table.test.ts
index d33b17b6..75393c58 100644
--- a/code/g3soft-libs/packages/ui/src/table/G3Table.test.ts
+++ b/code/g3soft-libs/packages/ui/src/table/G3Table.test.ts
@@ -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) {
+ const vm = wrapper.vm as unknown as { editRow: (k: string | number, c?: string | null) => Promise }
+ await vm.editRow(1, 'name')
+ await nextTick()
+ }
+
+ function editableColumns(): G3TableColumn[] {
+ return [
+ { dataIndex: 'name', title: '名称', editor: { type: 'text' } },
+ { dataIndex: 'amount', title: '金额', editor: { type: 'number' } },
+ ] as G3TableColumn[]
+ }
+
+ 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()
+ })
+})
\ No newline at end of file
diff --git a/code/g3soft-libs/packages/ui/src/table/G3Table.vue b/code/g3soft-libs/packages/ui/src/table/G3Table.vue
index b1c21515..6d352da8 100644
--- a/code/g3soft-libs/packages/ui/src/table/G3Table.vue
+++ b/code/g3soft-libs/packages/ui/src/table/G3Table.vue
@@ -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()