diff --git a/assets/admin/src/components/form/AksField.vue b/assets/admin/src/components/form/AksField.vue index ca3941dd..3ebf7e63 100644 --- a/assets/admin/src/components/form/AksField.vue +++ b/assets/admin/src/components/form/AksField.vue @@ -1212,7 +1212,16 @@ function removeRepeaterItem(index) { function updateRepeaterItem(index, fieldKey, value) { const current = Array.isArray(props.modelValue) ? [...props.modelValue] : [] - current[index] = { ...current[index], [fieldKey]: value } + const previous = current[index] + const next = previous && typeof previous === 'object' ? { ...previous, [fieldKey]: value } : { [fieldKey]: value } + // 行对象每次都被替换成新引用,WeakMap 认不出它就会生成新 key,整行随之 + // 重挂载、输入框失焦(表现为打一个字就退出编辑)。把旧 key 继承给新对象, + // 行 key 在整行生命周期内保持稳定。 + const inheritedKey = previous && typeof previous === 'object' ? dragRowKeys.get(previous) : null + if (inheritedKey) { + dragRowKeys.set(next, inheritedKey) + } + current[index] = next emit('update:modelValue', current) } diff --git a/tests/widget-system/repeater-row-key-stability.test.cjs b/tests/widget-system/repeater-row-key-stability.test.cjs new file mode 100644 index 00000000..443a77c4 --- /dev/null +++ b/tests/widget-system/repeater-row-key-stability.test.cjs @@ -0,0 +1,108 @@ +'use strict'; + +/** + * AksField 的 group / repeater 行 key 稳定性契约。 + * + * 背景:inline 展开模式用 vuedraggable 渲染行,vuedraggable 的 computeNodes 会把 + * `node.key = getKey(element)`,而行 key 由 AksField 的 repeaterDragKey 提供。 + * 服务端历史行没有 _id,key 来自 WeakMap;而 updateRepeaterItem 每次输入都会把行 + * 对象展开成新引用,WeakMap 认不出它便生成新 key,整行随之重挂载、输入框失焦, + * 表现为「打一个字就退出编辑」。本测试直接跑这两个真实函数来锁定该不变量。 + */ + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const fieldVue = fs.readFileSync( + path.join(root, 'assets', 'admin', 'src', 'components', 'form', 'AksField.vue'), + 'utf8' +); + +/** 取一整行声明(WeakMap / 种子计数器都没有花括号体)。 */ +function declaration(source, marker) { + const start = source.indexOf(marker); + assert.notStrictEqual(start, -1, `AksField.vue 缺少声明: ${marker}`); + const end = source.indexOf('\n', start); + return source.slice(start, end === -1 ? undefined : end); +} + +/** 按花括号配平切出一个顶层函数(这些函数体内没有字符串/注释里的孤立花括号)。 */ +function functionBlock(source, marker) { + const start = source.indexOf(marker); + assert.notStrictEqual(start, -1, `AksField.vue 缺少函数: ${marker}`); + const open = source.indexOf('{', start); + assert.notStrictEqual(open, -1, `函数没有函数体: ${marker}`); + let depth = 0; + for (let i = open; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}') { + depth--; + if (depth === 0) return source.slice(start, i + 1); + } + } + assert.fail(`花括号不配平: ${marker}`); +} + +const prelude = [ + declaration(fieldVue, 'const dragRowKeys = new WeakMap()'), + declaration(fieldVue, 'let dragRowKeySeed = 0'), + functionBlock(fieldVue, 'function repeaterDragKey(item)'), + functionBlock(fieldVue, 'function updateRepeaterItem(index, fieldKey, value)'), +].join('\n'); + +/** 用真实的 props / emit 形状驱动这两个函数。 */ +function makeField(rows) { + const emitted = []; + const props = { modelValue: rows }; + const emit = (event, value) => emitted.push({ event, value }); + const factory = new Function('props', 'emit', `${prelude}\nreturn { repeaterDragKey, updateRepeaterItem };`); + const api = factory(props, emit); + return { api, props, emitted }; +} + +test('group 行在连续输入期间保持同一个 drag key', () => { + // 服务端历史行:没有 _id,key 只能由 WeakMap 派生。 + const serverRows = [ + { points: 500, level: 1, time: 30, unit: 'day' }, + { points: 5000, level: 1, time: 'Permanent' }, + ]; + const { api, props, emitted } = makeField(serverRows); + const firstKey = api.repeaterDragKey(serverRows[0]); + const secondKey = api.repeaterDragKey(serverRows[1]); + assert.notStrictEqual(firstKey, secondKey, '两行必须各有一个 key'); + + // 模拟逐字输入:每次 emit 后父组件把新数组回填 props.modelValue。 + let rows = serverRows; + for (const value of ['5', '50', '500']) { + api.updateRepeaterItem(0, 'points', value); + rows = emitted[emitted.length - 1].value; + props.modelValue = rows; + } + + assert.strictEqual(rows[0].points, '500', '输入值应写进行内'); + assert.strictEqual( + api.repeaterDragKey(rows[0]), + firstKey, + '行 key 必须在编辑期间保持稳定,否则整行重挂载、输入框失焦' + ); + assert.strictEqual(api.repeaterDragKey(rows[1]), secondKey, '未编辑的行 key 不应变'); +}); + +test('带 _id 的行仍以 _id 作为 key', () => { + const rows = [{ _id: 'rpt_1', points: 100 }, { _id: 'rpt_2', points: 200 }]; + const { api } = makeField(rows); + assert.strictEqual(api.repeaterDragKey(rows[0]), 'rpt_1'); + assert.strictEqual(api.repeaterDragKey(rows[1]), 'rpt_2'); +}); + +test('新增行获得独立 key,不与既有行冲突', () => { + const serverRows = [{ points: 500 }, { points: 5000 }]; + const { api } = makeField(serverRows); + const keys = new Set(serverRows.map((row) => api.repeaterDragKey(row))); + assert.strictEqual(keys.size, 2); + const added = { points: 0 }; + assert.ok(!keys.has(api.repeaterDragKey(added)), '新行必须拿到全新 key'); +});