Compare commits

..

5 Commits

Author SHA1 Message Date
roymondchen
5a3edacb96 fix(editor): 画布 drop 仅还原内部拖拽数据,避免外部源触发 parseDSL 执行
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 15:22:02 +08:00
roymondchen
911cecfb1d chore: release v1.8.0-beta.28 2026-09-09 11:25:18 +08:00
roymondchen
2a4a54a495 feat(form): group-list 标题吸顶改为按 header.sticky 显式开启
默认关闭避免长列表误吸顶,嵌套层按外层真实标题/吸底按钮高度让位;属性面板列表通过 stickyAddButton 保持原表现。
2026-09-09 11:19:54 +08:00
roymondchen
7e4546899b test(editor): 补充历史回滚单测并精简确认弹窗文案 2026-09-09 11:17:22 +08:00
roymondchen
66b54a52f8 fix(editor): 丢弃过期 root 快照,避免画布回退与 modelValue 回写死循环 2026-09-07 19:02:59 +08:00
37 changed files with 1012 additions and 135 deletions

View File

@ -1,3 +1,14 @@
# [1.8.0-beta.28](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.27...v1.8.0-beta.28) (2026-09-09)
### Bug Fixes
* **editor:** 丢弃过期 root 快照,避免画布回退与 modelValue 回写死循环 ([66b54a5](https://github.com/Tencent/tmagic-editor/commit/66b54a52f8e9ff5041305ab7eb4057c820a4542a))
### Features
* **form:** group-list 标题吸顶改为按 header.sticky 显式开启 ([2a4a54a](https://github.com/Tencent/tmagic-editor/commit/2a4a54a49567007787295769e3f8d05e3e8a30e0))
# [1.8.0-beta.27](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.26...v1.8.0-beta.27) (2026-09-04) # [1.8.0-beta.27](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.26...v1.8.0-beta.27) (2026-09-04)

View File

@ -2,7 +2,7 @@
## 配置类型 ## 配置类型
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig 配置类型定义 ::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig / GroupListHeaderConfig 配置类型定义
<<< @/../packages/form-schema/src/base.ts#ContainerCommonConfig{ts} <<< @/../packages/form-schema/src/base.ts#ContainerCommonConfig{ts}
<<< @/../packages/form-schema/src/base.ts#RowConfig{ts} <<< @/../packages/form-schema/src/base.ts#RowConfig{ts}
@ -27,6 +27,8 @@
<<< @/../packages/form-schema/src/base.ts#TableGroupListCommonConfig{ts} <<< @/../packages/form-schema/src/base.ts#TableGroupListCommonConfig{ts}
<<< @/../packages/form-schema/src/base.ts#GroupListHeaderConfig{ts}
<<< @/../packages/form-schema/src/base.ts#FormItem{ts} <<< @/../packages/form-schema/src/base.ts#FormItem{ts}
::: :::
@ -211,6 +213,27 @@
}] }]
}]"></demo-block> }]"></demo-block>
#### 标题吸顶
列表项较多、需要边滚动边看清当前编辑的是哪一项时,可以用 `header.sticky` 让卡片标题吸顶:
```ts
{
type: 'groupList',
name: 'group',
header: { sticky: true },
items: [/* ... */],
}
```
吸顶默认关闭,需要逐层显式开启:外层开了不会带动内层,嵌套列表想一起吸顶就各自配一次,内层标题会自动下移让开外层标题。
标题让位的高度默认按 65px 计算。如果自定义了卡片标题的样式导致实际高度不同、内层标题出现重叠或空隙,用 `header.height` 告知真实高度即可(它只影响内层的让位距离,不会改变标题本身的高度)。
::: warning 行为变更
1.8.0-beta.28 之前group-list 的卡片标题在任何情况下都会吸顶。现在改为默认关闭、由 `header.sticky` 显式开启。升级后如需保持原有表现,请在对应配置上补上 `header: { sticky: true }`
:::
### table ### table
<demo-block type="form" :config="[{ <demo-block type="form" :config="[{

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "tmagic", "name": "tmagic",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/cli", "name": "@tmagic/cli",
"type": "module", "type": "module",
"main": "lib/index.js", "main": "lib/index.js",

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/core", "name": "@tmagic/core",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/data-source", "name": "@tmagic/data-source",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/dep", "name": "@tmagic/dep",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/design", "name": "@tmagic/design",
"type": "module", "type": "module",
"sideEffects": [ "sideEffects": [

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/editor", "name": "@tmagic/editor",
"type": "module", "type": "module",
"sideEffects": [ "sideEffects": [

View File

@ -17,12 +17,13 @@
*/ */
/** /**
* group-list * group-list
* *
* *
*/ */
export const stickyAddButton = (text: string) => ({ export const stickyAddButton = (text: string) => ({
scrollLastItemIntoView: true as const, scrollLastItemIntoView: true as const,
header: { sticky: true as const },
addButtonConfig: { addButtonConfig: {
sticky: true as const, sticky: true as const,
text, text,

View File

@ -427,12 +427,26 @@ export const initServiceEvents = (
}); });
}; };
/**
* root root
*
* `editorService.set('root', v)` `root-change` `v`
* root `root-change` stage / runtime / root
* DSL root `v`
* root
*/
const isStaleRoot = (value: MApp | null) => toRaw(editorService.get('root')) !== toRaw(value);
const updateStageDsl = async (value: MApp | null) => { const updateStageDsl = async (value: MApp | null) => {
const stage = await getStage(); const stage = await getStage();
const runtime = await stage.renderer?.getRuntime(); const runtime = await stage.renderer?.getRuntime();
const app = await getTMagicApp(); const app = await getTMagicApp();
// 等 stage / runtime 就绪期间 root 已被替换:新 root 的刷新可能已经完成,
// 再把旧 dsl 推给 runtime 会让画布回退到旧内容
if (isStaleRoot(value)) return;
if (!app?.dataSourceManager) { if (!app?.dataSourceManager) {
runtime?.updateRootConfig?.(cloneDeep(toRaw(value))!); runtime?.updateRootConfig?.(cloneDeep(toRaw(value))!);
} }
@ -449,6 +463,8 @@ export const initServiceEvents = (
await (typeof Worker === 'undefined' ? collectIdle(value.items, true) : depService.collectByWorker(value)); await (typeof Worker === 'undefined' ? collectIdle(value.items, true) : depService.collectByWorker(value));
if (isStaleRoot(value)) return;
const dsl = cloneDeep(toRaw(value)); const dsl = cloneDeep(toRaw(value));
if (dsl.dataSources && dsl.dataSourceDeps && app?.dataSourceManager) { if (dsl.dataSources && dsl.dataSourceDeps && app?.dataSourceManager) {
for (const node of getNodes(getDepNodeIds(dsl.dataSourceDeps), dsl.items)) { for (const node of getNodes(getDepNodeIds(dsl.dataSourceDeps), dsl.items)) {
@ -466,7 +482,7 @@ export const initServiceEvents = (
depService.addTarget(createDataSourceCondTarget(ds, reactive({}))); depService.addTarget(createDataSourceCondTarget(ds, reactive({})));
}; };
const rootChangeHandler = (value: MApp | null, preValue?: MApp | null) => { const rootChangeHandler = (value: MApp | null) => {
if (!value) return; if (!value) return;
value.codeBlocks = value.codeBlocks || {}; value.codeBlocks = value.codeBlocks || {};
@ -509,7 +525,13 @@ export const initServiceEvents = (
editorService.set('page', null); editorService.set('page', null);
} }
if (toRaw(value) !== toRaw(preValue)) { // 上面的 select 是异步的,期间 root 可能已被替换,过期快照不能再回写给外部:
// 两次整体替换各自持有一个快照时,回写会把对方的 root 顶掉,外部 modelValue 变化又会
// 重新 set root两条链路无休止地交替下去表现为编辑器卡死
if (isStaleRoot(value)) return;
// 外部已经持有这个 root如本次变化就是 modelValue 传进来的)时无需回写
if (toRaw(props.modelValue) !== toRaw(value)) {
emit('update:modelValue', value); emit('update:modelValue', value);
} }
})(); })();

View File

@ -204,10 +204,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
* *
* false * false
*/ */
const confirmRevert = (): Promise<boolean> => const confirmRevert = (): Promise<boolean> => confirmHistoryAction('确定回滚该步骤吗?');
confirmHistoryAction(
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
);
/** /**
* HistoryDiffDialog * HistoryDiffDialog

View File

@ -56,7 +56,14 @@ import { calcValueByFontsize, getIdFromEl } from '@tmagic/utils';
import ScrollViewer from '@editor/components/ScrollViewer.vue'; import ScrollViewer from '@editor/components/ScrollViewer.vue';
import { useServices } from '@editor/hooks'; import { useServices } from '@editor/hooks';
import { useStage } from '@editor/hooks/use-stage'; import { useStage } from '@editor/hooks/use-stage';
import type { CustomContentMenuFunction, MenuButton, MenuComponent, StageOptions, StageSlots } from '@editor/type'; import type {
AddMNode,
CustomContentMenuFunction,
MenuButton,
MenuComponent,
StageOptions,
StageSlots,
} from '@editor/type';
import { DragType, Layout } from '@editor/type'; import { DragType, Layout } from '@editor/type';
import { getEditorConfig } from '@editor/utils/config'; import { getEditorConfig } from '@editor/utils/config';
import { KeyBindingContainerKey } from '@editor/utils/keybinding-config'; import { KeyBindingContainerKey } from '@editor/utils/keybinding-config';
@ -270,11 +277,56 @@ const resizeObserver = new globalThis.ResizeObserver((entries) => {
} }
}); });
const parseDSL = getEditorConfig('parseDSL');
/**
* 本次拖拽是否由编辑器文档内部发起
*
* drop text/json 里可能带函数组件配置的事件钩子等还原只能交给 parseDSL
* parseDSL 的默认实现是 evalHTML 拖放又允许其他源的页面在 DataTransfer 中投递
* 自定义 MIME 数据跨源页面只要诱导用户拖拽一次就能让 eval 执行任意脚本
*
* 跨源页面既不会在本文档触发 dragstart也无法往本文档创建的 DataTransfer 中写数据
* 因此只有起源于本文档的拖拽才交给 parseDSL 还原
* 同源拖拽源写入的 text/json 由业务保证可信
*/
let isInternalDrag = false;
let internalDragSession = 0;
let clearInternalDragTimer: ReturnType<typeof setTimeout> | undefined;
const documentDragStartHandler = () => {
internalDragSession += 1;
isInternalDrag = true;
if (clearInternalDragTimer !== undefined) {
globalThis.clearTimeout(clearInternalDragTimer);
clearInternalDragTimer = undefined;
}
};
const documentDragEndHandler = () => {
// WebKit dragend drop
// drop session dragstart
const session = internalDragSession;
if (clearInternalDragTimer !== undefined) {
globalThis.clearTimeout(clearInternalDragTimer);
}
clearInternalDragTimer = globalThis.setTimeout(() => {
clearInternalDragTimer = undefined;
if (session === internalDragSession) {
isInternalDrag = false;
}
}, 0);
};
onMounted(() => { onMounted(() => {
if (stageWrapRef.value?.container) { if (stageWrapRef.value?.container) {
resizeObserver.observe(stageWrapRef.value.container); resizeObserver.observe(stageWrapRef.value.container);
keybindingService.registerEl(KeyBindingContainerKey.STAGE, stageWrapRef.value.container); keybindingService.registerEl(KeyBindingContainerKey.STAGE, stageWrapRef.value.container);
} }
// dragstart
globalThis.document.addEventListener('dragstart', documentDragStartHandler, true);
globalThis.document.addEventListener('dragend', documentDragEndHandler, true);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@ -283,9 +335,15 @@ onBeforeUnmount(() => {
resizeObserver.disconnect(); resizeObserver.disconnect();
editorService.set('stage', null); editorService.set('stage', null);
keybindingService.unregisterEl('stage'); keybindingService.unregisterEl('stage');
});
const parseDSL = getEditorConfig('parseDSL'); if (clearInternalDragTimer !== undefined) {
globalThis.clearTimeout(clearInternalDragTimer);
clearInternalDragTimer = undefined;
}
globalThis.document.removeEventListener('dragstart', documentDragStartHandler, true);
globalThis.document.removeEventListener('dragend', documentDragEndHandler, true);
});
const contextmenuHandler = (e: MouseEvent) => { const contextmenuHandler = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
@ -299,15 +357,27 @@ const dragoverHandler = (e: DragEvent) => {
}; };
const dropHandler = async (e: DragEvent) => { const dropHandler = async (e: DragEvent) => {
// drop text/json drop
const allowed = isInternalDrag;
isInternalDrag = false;
if (!e.dataTransfer) return; if (!e.dataTransfer) return;
const data = e.dataTransfer.getData('text/json'); const data = e.dataTransfer.getData('text/json');
if (!data) return; // parseDSL( eval)
if (!data || !allowed) return;
const config = parseDSL(`(${data})`); let config: { dragType?: string; data?: AddMNode } | undefined;
try {
config = parseDSL(`(${data})`);
} catch {
return;
}
if (!config || config.dragType !== DragType.COMPONENT_LIST) return; if (!config || config.dragType !== DragType.COMPONENT_LIST || !config.data) return;
const dragData = config.data;
e.preventDefault(); e.preventDefault();
@ -349,7 +419,7 @@ const dropHandler = async (e: DragEvent) => {
const containerRect = stageContainerEl.value.getBoundingClientRect(); const containerRect = stageContainerEl.value.getBoundingClientRect();
const { scrollTop, scrollLeft } = stage.mask!; const { scrollTop, scrollLeft } = stage.mask!;
const { style = {} } = config.data; const { style = {} } = dragData;
let top = 0; let top = 0;
let left = 0; let left = 0;
@ -371,16 +441,16 @@ const dropHandler = async (e: DragEvent) => {
} }
} }
config.data.style = { dragData.style = {
...style, ...style,
position, position,
top: calcValueByFontsize(doc, top / zoom.value), top: calcValueByFontsize(doc, top / zoom.value),
left: calcValueByFontsize(doc, left / zoom.value), left: calcValueByFontsize(doc, left / zoom.value),
}; };
config.data.inputEvent = e; dragData.inputEvent = e;
editorService.add(config.data, parent, { historySource: 'component-panel' }); editorService.add(dragData, parent, { historySource: 'component-panel' });
} }
}; };
</script> </script>

View File

@ -122,6 +122,7 @@ describe('CodeSelect', () => {
const container = wrapper.findComponent({ name: 'MContainer' }); const container = wrapper.findComponent({ name: 'MContainer' });
const config = container.props('config') as any; const config = container.props('config') as any;
expect(config.scrollLastItemIntoView).toBe(true); expect(config.scrollLastItemIntoView).toBe(true);
expect(config.header.sticky).toBe(true);
expect(config.addButtonConfig.sticky).toBe(true); expect(config.addButtonConfig.sticky).toBe(true);
expect(config.addButtonConfig.text).toBe('添加'); expect(config.addButtonConfig.text).toBe('添加');
const codeTypeSelect = config.items[0]; const codeTypeSelect = config.items[0];

View File

@ -84,6 +84,7 @@ describe('DisplayConds', () => {
expect(capturedConfig.type).toBe('groupList'); expect(capturedConfig.type).toBe('groupList');
expect(capturedConfig.defaultAdd).toEqual({ cond: [] }); expect(capturedConfig.defaultAdd).toEqual({ cond: [] });
expect(capturedConfig.scrollLastItemIntoView).toBe(true); expect(capturedConfig.scrollLastItemIntoView).toBe(true);
expect(capturedConfig.header.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.sticky).toBe(true); expect(capturedConfig.addButtonConfig.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.text).toBe('新增条件组'); expect(capturedConfig.addButtonConfig.text).toBe('新增条件组');
expect(capturedConfig.items[0].type).toBe('groupList'); expect(capturedConfig.items[0].type).toBe('groupList');
@ -93,6 +94,7 @@ describe('DisplayConds', () => {
expect(capturedConfig.items[0].movable).toBe(false); expect(capturedConfig.items[0].movable).toBe(false);
expect(capturedConfig.items[0].flat).toBe(true); expect(capturedConfig.items[0].flat).toBe(true);
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true); expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
expect(capturedConfig.items[0].header.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true); expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增条件'); expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增条件');
expect(capturedConfig.items[0].items.every((item: any) => item.span === undefined)).toBe(true); expect(capturedConfig.items[0].items.every((item: any) => item.span === undefined)).toBe(true);

View File

@ -133,11 +133,13 @@ describe('EventSelect', () => {
expect(wrapper.find('.fake-table').exists()).toBe(false); expect(wrapper.find('.fake-table').exists()).toBe(false);
expect(capturedConfig.type).toBe('group-list'); expect(capturedConfig.type).toBe('group-list');
expect(capturedConfig.scrollLastItemIntoView).toBe(true); expect(capturedConfig.scrollLastItemIntoView).toBe(true);
expect(capturedConfig.header.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.sticky).toBe(true); expect(capturedConfig.addButtonConfig.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.text).toBe('添加事件'); expect(capturedConfig.addButtonConfig.text).toBe('添加事件');
expect(capturedConfig.defaultAdd).toEqual({ name: '', actions: [] }); expect(capturedConfig.defaultAdd).toEqual({ name: '', actions: [] });
expect(capturedConfig.movable).toBe(false); expect(capturedConfig.movable).toBe(false);
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true); expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
expect(capturedConfig.items[0].header.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true); expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增动作'); expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增动作');
}); });

View File

@ -357,7 +357,6 @@ describe('initServiceEvents', () => {
}); });
test('rootChange 处理代码块和数据源', async () => { test('rootChange 处理代码块和数据源', async () => {
services.editorService.state.root = { id: 'r' };
mount(WrapEvents({} as any, emit, services)); mount(WrapEvents({} as any, emit, services));
const value: any = { const value: any = {
id: 'r', id: 'r',
@ -365,6 +364,8 @@ describe('initServiceEvents', () => {
dataSources: [{ id: 'd1', type: 'base' }], dataSources: [{ id: 'd1', type: 'base' }],
items: [], items: [],
}; };
// set('root', v) 是先赋值再派发事件mock 中同样先对齐 state 再 emit
services.editorService.state.root = value;
services.editorService.emit('root-change', value, null); services.editorService.emit('root-change', value, null);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(services.codeBlockService.setCodeDsl).toHaveBeenCalled(); expect(services.codeBlockService.setCodeDsl).toHaveBeenCalled();
@ -593,13 +594,15 @@ describe('initServiceEvents', () => {
services.editorService.state.node = { id: 'n1' }; services.editorService.state.node = { id: 'n1' };
mount(WrapEvents({} as any, emit, services)); mount(WrapEvents({} as any, emit, services));
services.editorService.emit('root-change', { const value: any = {
id: 'r', id: 'r',
items: [{ id: 'n1', type: 'text' }], items: [{ id: 'n1', type: 'text' }],
dataSources: [], dataSources: [],
dataSourceDeps: { d1: {} }, dataSourceDeps: { d1: {} },
codeBlocks: {}, codeBlocks: {},
}); };
services.editorService.state.root = value;
services.editorService.emit('root-change', value);
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
expect(stage.runtime.updatePageId).toHaveBeenCalledWith('p1'); expect(stage.runtime.updatePageId).toHaveBeenCalledWith('p1');
@ -608,10 +611,10 @@ describe('initServiceEvents', () => {
}); });
test('rootChange items 不是数组时清空依赖', async () => { test('rootChange items 不是数组时清空依赖', async () => {
services.editorService.state.root = { id: 'r' };
mount(WrapEvents({} as any, emit, services)); mount(WrapEvents({} as any, emit, services));
const value: any = { id: 'r', dataSourceDeps: { a: {} }, dataSourceCondDeps: { b: {} } }; const value: any = { id: 'r', dataSourceDeps: { a: {} }, dataSourceCondDeps: { b: {} } };
services.editorService.state.root = value;
services.editorService.emit('root-change', value); services.editorService.emit('root-change', value);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@ -625,6 +628,7 @@ describe('initServiceEvents', () => {
mount(WrapEvents({} as any, emit, services)); mount(WrapEvents({} as any, emit, services));
const value: any = { id: 'r', items: [] }; const value: any = { id: 'r', items: [] };
services.editorService.state.root = value;
services.editorService.emit('root-change', value, { id: 'prev' }); services.editorService.emit('root-change', value, { id: 'prev' });
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@ -638,12 +642,44 @@ describe('initServiceEvents', () => {
services.editorService.getNodeById.mockReturnValue(null); services.editorService.getNodeById.mockReturnValue(null);
mount(WrapEvents({} as any, emit, services)); mount(WrapEvents({} as any, emit, services));
services.editorService.emit('root-change', { id: 'r', items: [{ id: 'first', type: 'page' }] }); const value: any = { id: 'r', items: [{ id: 'first', type: 'page' }] };
services.editorService.state.root = value;
services.editorService.emit('root-change', value);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(services.editorService.select).toHaveBeenCalledWith({ id: 'first', type: 'page' }); expect(services.editorService.select).toHaveBeenCalledWith({ id: 'first', type: 'page' });
}); });
test('rootChange 外部已持有该 root 时不回写 modelValue', async () => {
services.editorService.getNodeById.mockReturnValue(null);
const value: any = { id: 'r', items: [] };
mount(WrapEvents({ modelValue: value } as any, emit, services));
services.editorService.state.root = value;
services.editorService.emit('root-change', value);
await new Promise((r) => setTimeout(r, 0));
expect(emit).not.toHaveBeenCalled();
});
test('rootChange 处理期间 root 被替换:不刷旧 dsl 也不回写过期快照', async () => {
const app: any = { dsl: {}, dataSourceManager: mkDataSourceManager() };
const stage = mkReadyStage(app);
services.editorService.state.stage = stage;
services.editorService.getNodeById.mockReturnValue(null);
mount(WrapEvents({} as any, emit, services));
const value: any = { id: 'r', items: [{ id: 'n1', type: 'text' }], dataSources: [], codeBlocks: {} };
services.editorService.state.root = value;
services.editorService.emit('root-change', value);
// 异步处理还没跑完root 已被新的一次整体替换顶掉
services.editorService.state.root = { id: 'r', items: [], dataSources: [], codeBlocks: {} };
await new Promise((r) => setTimeout(r, 10));
expect(stage.runtime.updateRootConfig).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
test('update 事件ROOT 节点、无 propPath、命中已收集依赖三种分支', async () => { test('update 事件ROOT 节点、无 propPath、命中已收集依赖三种分支', async () => {
services.editorService.state.root = { id: 'r', items: [] }; services.editorService.state.root = { id: 'r', items: [] };
services.depService.getTargets.mockReturnValue({ services.depService.getTargets.mockReturnValue({

View File

@ -88,8 +88,14 @@ const diffableGroups = (id: string | number = 'p1') => [
}, },
]; ];
/** 让 getHistoryGroups 只对指定类别返回可对比分组,避免三类历史互相串味。 */
const groupsFor = (category: string, id: string | number) => (type: string) =>
type === category ? diffableGroups(id) : [];
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.useRealTimers();
appMock._context = {};
}); });
describe('useHistoryRevert', () => { describe('useHistoryRevert', () => {
@ -226,4 +232,255 @@ describe('useHistoryRevert', () => {
expect(result).toBe('done'); expect(result).toBe('done');
}); });
}); });
describe('数据源历史', () => {
test('可差异步骤走差异确认弹窗,确认后执行 revert', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([
{ step: { opType: 'update', diff: [{ newSchema: { id: 'ds_1' }, oldSchema: { id: 'ds_1' } }] } },
]);
services.historyService.getHistoryGroups.mockImplementation(groupsFor('dataSource', 'ds_1'));
services.dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds_1', title: '当前' });
const { onDataSourceRevert } = useHistoryRevert({}, services);
await onDataSourceRevert('ds_1', 0);
expect(lastDialogProps().isConfirm).toBe(true);
expect(services.dataSourceService.revert).toHaveBeenCalledWith('ds_1', 0);
});
test('buildDataSourceDiffPayload 取 title 作展示名、type 缺省为 base并带上当前值', () => {
const services = createServices();
services.historyService.getHistoryGroups.mockImplementation(() => [
{
id: 'ds_1',
steps: [
{
index: 0,
step: { diff: [{ oldSchema: { title: '旧' }, newSchema: { title: '新' } }] },
},
],
},
]);
services.dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds_1', title: '当前' });
const { buildDataSourceDiffPayload } = useHistoryRevert({}, services);
const payload = buildDataSourceDiffPayload('ds_1', 0);
expect(payload).toMatchObject({
category: 'data-source',
type: 'base',
targetLabel: '新',
id: 'ds_1',
currentValue: { id: 'ds_1', title: '当前' },
});
expect(services.dataSourceService.getDataSourceById).toHaveBeenCalledWith('ds_1');
});
test('onDataSourceDiff 打开只读弹窗', async () => {
const services = createServices();
services.historyService.getHistoryGroups.mockImplementation(groupsFor('dataSource', 'ds_1'));
const { onDataSourceDiff } = useHistoryRevert({ dialogWidth: '900px' }, services);
await onDataSourceDiff('ds_1', 0);
expect(lastDialogProps().isConfirm).toBe(false);
expect(lastDialogProps().width).toBe('900px');
expect(dialogInstance.open).toHaveBeenCalled();
});
test('无可对比内容时 onDataSourceDiff 不弹窗', async () => {
const services = createServices();
const { onDataSourceDiff } = useHistoryRevert({}, services);
await onDataSourceDiff('ds_1', 0);
expect(createAppMock).not.toHaveBeenCalled();
});
});
describe('代码块历史', () => {
test('update 记录对应代码块已删除时,提示错误且不执行回滚', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([{ step: { opType: 'update', diff: [] } }]);
services.codeBlockService.getCodeContentById.mockReturnValue(null);
const { onCodeBlockRevert } = useHistoryRevert({}, services);
await onCodeBlockRevert('code_1', 0);
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
expect(services.codeBlockService.revert).not.toHaveBeenCalled();
});
test('可差异步骤走差异确认弹窗,确认后执行 revert', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([{ step: { opType: 'update', diff: [] } }]);
services.historyService.getHistoryGroups.mockImplementation(groupsFor('codeBlock', 'code_1'));
services.codeBlockService.getCodeContentById.mockReturnValue({ name: '当前' });
const { onCodeBlockRevert } = useHistoryRevert({}, services);
await onCodeBlockRevert('code_1', 0);
expect(lastDialogProps().isConfirm).toBe(true);
expect(services.codeBlockService.revert).toHaveBeenCalledWith('code_1', 0);
});
test('用户取消确认时不执行 revert', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([{ step: { opType: 'add', diff: [] } }]);
vi.mocked(confirmHistoryAction).mockResolvedValueOnce(false);
const { onCodeBlockRevert } = useHistoryRevert({}, services);
await onCodeBlockRevert('code_1', 0);
expect(services.codeBlockService.revert).not.toHaveBeenCalled();
});
test('buildCodeBlockDiffPayload 不带 type展示名回退到 id', () => {
const services = createServices();
services.historyService.getHistoryGroups.mockImplementation(() => [
{
id: 'code_1',
steps: [{ index: 0, step: { diff: [{ oldSchema: {}, newSchema: {} }] } }],
},
]);
services.codeBlockService.getCodeContentById.mockReturnValue(undefined);
const { buildCodeBlockDiffPayload } = useHistoryRevert({}, services);
const payload = buildCodeBlockDiffPayload('code_1', 0);
expect(payload).toMatchObject({ category: 'code-block', targetLabel: 'code_1', currentValue: null });
expect(payload).not.toHaveProperty('type');
});
test('onCodeBlockDiff 打开只读弹窗', async () => {
const services = createServices();
services.historyService.getHistoryGroups.mockImplementation(groupsFor('codeBlock', 'code_1'));
const { onCodeBlockDiff } = useHistoryRevert({}, services);
await onCodeBlockDiff('code_1', 0);
expect(lastDialogProps().isConfirm).toBe(false);
expect(dialogInstance.open).toHaveBeenCalled();
});
});
describe('回滚前置校验', () => {
test('页面 remove 记录的原父容器已删除时判定为无法回滚', () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([
{ step: { opType: 'remove', diff: [{ parentId: 'parent_gone' }] } },
]);
services.editorService.getNodeById.mockReturnValue(null);
const { isPageRevertTargetMissing } = useHistoryRevert({}, services);
expect(isPageRevertTargetMissing(0)).toBe(true);
});
test('页面 remove 记录的原父容器仍在时可以回滚', () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([
{ step: { opType: 'remove', diff: [{ parentId: 'parent_1' }] } },
]);
services.editorService.getNodeById.mockReturnValue({ id: 'parent_1' });
const { isPageRevertTargetMissing } = useHistoryRevert({}, services);
expect(isPageRevertTargetMissing(0)).toBe(false);
});
test('步骤不存在时不判定为无法回滚', () => {
const services = createServices();
const { isPageRevertTargetMissing, isCodeBlockRevertTargetMissing } = useHistoryRevert({}, services);
expect(isPageRevertTargetMissing(0)).toBe(false);
expect(isCodeBlockRevertTargetMissing('code_1', 0)).toBe(false);
});
test('confirmAndRevert 在 isTargetMissing 命中时提示错误并返回 null', async () => {
const services = createServices();
const revert = vi.fn(async () => 'done');
const { confirmAndRevert } = useHistoryRevert({}, services);
const result = await confirmAndRevert({ isTargetMissing: () => true, diffPayload: null, revert });
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
expect(revert).not.toHaveBeenCalled();
expect(result).toBeNull();
});
test('confirmAndRevert 在用户取消时返回 null', async () => {
const services = createServices();
const revert = vi.fn(async () => 'done');
vi.mocked(confirmHistoryAction).mockResolvedValueOnce(false);
const { confirmAndRevert } = useHistoryRevert({}, services);
const result = await confirmAndRevert({ diffPayload: null, revert });
expect(revert).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('弹窗挂载与卸载', () => {
test('传入 appContext 时合并进弹窗 app 的上下文', async () => {
const services = createServices();
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
const appContext = { provides: { host: 1 } } as any;
const { onPageDiff } = useHistoryRevert({ appContext }, services);
await onPageDiff(0);
expect(appMock._context).toMatchObject({ provides: { host: 1 } });
});
test('未显式指定 size 时回落到属性面板尺寸', async () => {
const services = createServices();
services.uiService = { get: vi.fn(() => 'small') };
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
const { onPageDiff } = useHistoryRevert({}, services);
await onPageDiff(0);
expect(lastDialogProps().size).toBe('small');
});
test('确认弹窗结束后延迟卸载并移除容器', async () => {
vi.useFakeTimers();
const services = createServices();
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
const containerCount = document.body.childElementCount;
const { confirmAndRevert } = useHistoryRevert({}, services);
await confirmAndRevert({
diffPayload: { category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any,
revert: async () => 'done',
});
expect(document.body.childElementCount).toBe(containerCount + 1);
expect(appMock.unmount).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(appMock.unmount).toHaveBeenCalled();
expect(document.body.childElementCount).toBe(containerCount);
});
test('卸载抛错不影响容器清理', async () => {
vi.useFakeTimers();
const services = createServices();
appMock.unmount.mockImplementationOnce(() => {
throw new Error('unmount failed');
});
const containerCount = document.body.childElementCount;
const { viewDiff } = useHistoryRevert({}, services);
await viewDiff({ category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any);
// 只读弹窗要等用户关闭才卸载
lastDialogProps().onClose();
vi.advanceTimersByTime(300);
expect(document.body.childElementCount).toBe(containerCount);
});
});
}); });

View File

@ -55,11 +55,14 @@ vi.mock('@editor/hooks', () => ({
useServices: () => ({ editorService, uiService, keybindingService, stageOverlayService }), useServices: () => ({ editorService, uiService, keybindingService, stageOverlayService }),
})); }));
// 与 plugin.ts 中 parseDSL 的默认实现eval保持一致用于验证守卫能否阻止其执行
const { parseDSL } = vi.hoisted(() => ({
// eslint-disable-next-line no-new-func
parseDSL: vi.fn((dsl: string) => new Function(`return ${dsl}`)()),
}));
vi.mock('@editor/utils/config', () => ({ vi.mock('@editor/utils/config', () => ({
getEditorConfig: vi.fn(() => (s: string) => { getEditorConfig: vi.fn(() => parseDSL),
if (s.startsWith('(')) return JSON.parse(s.slice(1, -1));
return JSON.parse(s);
}),
})); }));
vi.mock('@editor/components/ScrollViewer.vue', () => ({ vi.mock('@editor/components/ScrollViewer.vue', () => ({
@ -146,6 +149,26 @@ const mountIt = (props: any = {}) =>
attachTo: document.body, attachTo: document.body,
}); });
/** 模拟编辑器文档内部(如组件列表面板)发起拖拽 */
const startInternalDrag = () => {
document.dispatchEvent(new Event('dragstart'));
};
const endDrag = () => {
document.dispatchEvent(new Event('dragend'));
};
const createDropEvent = (raw: string) => {
const event: any = new Event('drop');
event.dataTransfer = { getData: vi.fn(() => raw) };
event.preventDefault = vi.fn();
return event;
};
const waitMacrotask = () => new Promise((r) => setTimeout(r, 10));
const COMPONENT_LIST_JSON = '{"dragType":"component-list","data":{"name":"text","style":{}}}';
describe('Stage', () => { describe('Stage', () => {
test('挂载并创建 stage', async () => { test('挂载并创建 stage', async () => {
const wrapper = mountIt(); const wrapper = mountIt();
@ -185,17 +208,28 @@ describe('Stage', () => {
editorService.getNodeById.mockReturnValue(null); editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt(); const wrapper = mountIt();
await nextTick(); await nextTick();
const event: any = new Event('drop'); const event = createDropEvent(COMPONENT_LIST_JSON);
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
};
event.clientX = 100; event.clientX = 100;
event.clientY = 100; event.clientY = 100;
event.preventDefault = vi.fn();
const stageContainer = wrapper.find('.m-editor-stage-container').element; const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
stageContainer.dispatchEvent(event); stageContainer.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10)); await waitMacrotask();
expect(editorService.add).toHaveBeenCalled(); expect(editorService.add).toHaveBeenCalled();
endDrag();
});
test('drop 保留拖拽数据中的函数', async () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt();
await nextTick();
const event = createDropEvent('{dragType:"component-list",data:{name:"text",style:{},created:() => "created"}}');
event.clientX = 10;
event.clientY = 10;
startInternalDrag();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10));
expect(editorService.add.mock.calls[0][0].created()).toBe('created');
}); });
test('zoom 变化时 stage.setZoom 被调用', async () => { test('zoom 变化时 stage.setZoom 被调用', async () => {
@ -218,48 +252,163 @@ describe('Stage', () => {
expect(wrapper.find('.fake-overlay').exists()).toBe(false); expect(wrapper.find('.fake-overlay').exists()).toBe(false);
}); });
test('drop 数据为空时不处理', async () => { test('drop 数据为空时消费内部拖拽标记,后续 drop 不再解析', async () => {
const wrapper = mountIt(); const wrapper = mountIt();
await nextTick(); await nextTick();
const event: any = new Event('drop');
event.dataTransfer = { getData: vi.fn(() => '') };
event.preventDefault = vi.fn();
const stageContainer = wrapper.find('.m-editor-stage-container').element; const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
stageContainer.dispatchEvent(createDropEvent(''));
expect(editorService.add).not.toHaveBeenCalled();
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(parseDSL).not.toHaveBeenCalled();
expect(editorService.add).not.toHaveBeenCalled();
});
test('drop 无 dataTransfer 时也消费内部拖拽标记', async () => {
const wrapper = mountIt();
await nextTick();
const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
const event: any = new Event('drop');
event.dataTransfer = null;
stageContainer.dispatchEvent(event); stageContainer.dispatchEvent(event);
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(parseDSL).not.toHaveBeenCalled();
expect(editorService.add).not.toHaveBeenCalled(); expect(editorService.add).not.toHaveBeenCalled();
}); });
test('drop 非 COMPONENT_LIST 时不处理', async () => { test('drop 非 COMPONENT_LIST 时不处理', async () => {
const wrapper = mountIt(); const wrapper = mountIt();
await nextTick(); await nextTick();
const event: any = new Event('drop'); const event = createDropEvent('{"dragType":"other","data":{"name":"text","style":{}}}');
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"other","data":{"name":"text","style":{}}}'),
};
event.preventDefault = vi.fn();
const stageContainer = wrapper.find('.m-editor-stage-container').element; const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
stageContainer.dispatchEvent(event); stageContainer.dispatchEvent(event);
await waitMacrotask();
expect(editorService.add).not.toHaveBeenCalled();
});
test('drop 缺少 data 时不处理', async () => {
const wrapper = mountIt();
await nextTick();
startInternalDrag();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent('{"dragType":"component-list"}'));
await waitMacrotask();
expect(editorService.add).not.toHaveBeenCalled();
});
test('drop 拖拽非本文档发起时不解析数据,不执行其中的脚本', async () => {
let executed = false;
Object.defineProperty(globalThis, '__PWNED__', {
configurable: true,
set() {
executed = true;
},
});
const wrapper = mountIt();
await nextTick();
// PoC 中攻击页面跨源投递的 payload
const event = createDropEvent("{a:(globalThis.__PWNED__='code execution',0), dragType:'component-list'}");
const stageContainer = wrapper.find('.m-editor-stage-container').element;
// 不触发 dragstart模拟拖拽起源于编辑器之外。
// 同源内部拖拽源写入的 text/json 由业务保证可信,本用例不覆盖。
expect(() => stageContainer.dispatchEvent(event)).not.toThrow();
await new Promise((r) => setTimeout(r, 10));
expect(parseDSL).not.toHaveBeenCalled();
expect(executed).toBe(false);
expect(editorService.add).not.toHaveBeenCalled();
});
test('drop 一次 dragstart 只消费一次', async () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt();
await nextTick();
const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(editorService.add).toHaveBeenCalledTimes(1);
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(editorService.add).toHaveBeenCalledTimes(1);
});
test('drop 在 dragend 之后仍处理(兼容 WebKit 先 dragend 再 drop', async () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt();
await nextTick();
startInternalDrag();
endDrag();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(editorService.add).toHaveBeenCalled();
});
test('dragend 宏任务之后取消的拖拽不再处理', async () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt();
await nextTick();
startInternalDrag();
endDrag();
await waitMacrotask();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(parseDSL).not.toHaveBeenCalled();
expect(editorService.add).not.toHaveBeenCalled();
});
test('dragend 延迟清理不会清掉下一次 dragstart', async () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt();
await nextTick();
startInternalDrag();
endDrag();
startInternalDrag();
await waitMacrotask();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(editorService.add).toHaveBeenCalled();
});
test('drop parseDSL 解析失败时不抛错', async () => {
const wrapper = mountIt();
await nextTick();
const event = createDropEvent('{"dragType":');
const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
expect(() => stageContainer.dispatchEvent(event)).not.toThrow();
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
expect(editorService.add).not.toHaveBeenCalled(); expect(editorService.add).not.toHaveBeenCalled();
}); });
test('卸载时移除 document 上的拖拽监听', async () => {
const removeSpy = vi.spyOn(document, 'removeEventListener');
const wrapper = mountIt();
await nextTick();
wrapper.unmount();
expect(removeSpy).toHaveBeenCalledWith('dragstart', expect.any(Function), true);
expect(removeSpy).toHaveBeenCalledWith('dragend', expect.any(Function), true);
removeSpy.mockRestore();
});
test('drop position fixed 计算位置', async () => { test('drop position fixed 计算位置', async () => {
editorService.getNodeById.mockReturnValue(null); editorService.getNodeById.mockReturnValue(null);
editorService.getLayout.mockResolvedValue('relative'); editorService.getLayout.mockResolvedValue('relative');
const wrapper = mountIt(); const wrapper = mountIt();
await nextTick(); await nextTick();
const event: any = new Event('drop'); const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{"position":"fixed"}}}');
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{"position":"fixed"}}}'),
};
event.clientX = 80; event.clientX = 80;
event.clientY = 60; event.clientY = 60;
event.preventDefault = vi.fn();
const stageContainer = wrapper.find('.m-editor-stage-container').element; const stageContainer = wrapper.find('.m-editor-stage-container').element;
Object.defineProperty(stageContainer, 'getBoundingClientRect', { Object.defineProperty(stageContainer, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: 800, height: 600 }), value: () => ({ left: 0, top: 0, width: 800, height: 600 }),
configurable: true, configurable: true,
}); });
startInternalDrag();
stageContainer.dispatchEvent(event); stageContainer.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
const args = editorService.add.mock.calls[0][0]; const args = editorService.add.mock.calls[0][0];
@ -271,18 +420,15 @@ describe('Stage', () => {
editorService.getLayout.mockResolvedValue('absolute'); editorService.getLayout.mockResolvedValue('absolute');
const wrapper = mountIt(); const wrapper = mountIt();
await nextTick(); await nextTick();
const event: any = new Event('drop'); const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
};
event.clientX = 80; event.clientX = 80;
event.clientY = 60; event.clientY = 60;
event.preventDefault = vi.fn();
const stageContainer = wrapper.find('.m-editor-stage-container').element; const stageContainer = wrapper.find('.m-editor-stage-container').element;
Object.defineProperty(stageContainer, 'getBoundingClientRect', { Object.defineProperty(stageContainer, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: 800, height: 600 }), value: () => ({ left: 0, top: 0, width: 800, height: 600 }),
configurable: true, configurable: true,
}); });
startInternalDrag();
stageContainer.dispatchEvent(event); stageContainer.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
const args = editorService.add.mock.calls[0][0]; const args = editorService.add.mock.calls[0][0];
@ -296,14 +442,11 @@ describe('Stage', () => {
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight', canDropIn }, stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight', canDropIn },
}); });
await nextTick(); await nextTick();
const event: any = new Event('drop'); const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
};
event.clientX = 50; event.clientX = 50;
event.clientY = 50; event.clientY = 50;
event.preventDefault = vi.fn();
const stageContainer = wrapper.find('.m-editor-stage-container').element; const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
stageContainer.dispatchEvent(event); stageContainer.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
expect(canDropIn).toHaveBeenCalled(); expect(canDropIn).toHaveBeenCalled();
@ -414,13 +557,10 @@ describe('Stage', () => {
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'h', canDropIn }, stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'h', canDropIn },
}); });
await nextTick(); await nextTick();
const event: any = new Event('drop'); const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
};
event.clientX = 1; event.clientX = 1;
event.clientY = 1; event.clientY = 1;
event.preventDefault = vi.fn(); startInternalDrag();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(event); wrapper.find('.m-editor-stage-container').element.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
expect(canDropIn).toHaveBeenCalled(); expect(canDropIn).toHaveBeenCalled();

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/element-plus-adapter", "name": "@tmagic/element-plus-adapter",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/form-schema", "name": "@tmagic/form-schema",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -830,6 +830,20 @@ export interface AddButtonConfig {
} }
// #endregion AddButtonConfig // #endregion AddButtonConfig
// #region GroupListHeaderConfig
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
export interface GroupListHeaderConfig {
/** 标题吸顶 */
sticky?: boolean;
/**
*
* **** 65px
* px CSS 使
*/
height?: number | string;
}
// #endregion GroupListHeaderConfig
// #region TableGroupListCommonConfig // #region TableGroupListCommonConfig
export interface TableGroupListCommonConfig extends FormItem { export interface TableGroupListCommonConfig extends FormItem {
type: 'table' | 'groupList' | 'group-list'; type: 'table' | 'groupList' | 'group-list';
@ -846,6 +860,8 @@ export interface TableGroupListCommonConfig extends FormItem {
/** 新增后滚动到最后一项group-list 形态,默认关闭) */ /** 新增后滚动到最后一项group-list 形态,默认关闭) */
scrollLastItemIntoView?: boolean; scrollLastItemIntoView?: boolean;
addButtonConfig?: AddButtonConfig; addButtonConfig?: AddButtonConfig;
/** group-list 形态的标题吸顶默认关闭table 形态忽略 */
header?: GroupListHeaderConfig;
} }
// #endregion TableGroupListCommonConfig // #endregion TableGroupListCommonConfig

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/form", "name": "@tmagic/form",
"type": "module", "type": "module",
"sideEffects": [ "sideEffects": [

View File

@ -1,5 +1,12 @@
<template> <template>
<div class="m-fields-group-list"> <div
class="m-fields-group-list"
:class="{
'is-header-sticky': Boolean(config.header?.sticky),
'is-footer-sticky': isFooterSticky($slots),
}"
:style="headerVars"
>
<div v-if="config.extra" v-html="config.extra" style="color: rgba(0, 0, 0, 0.45)"></div> <div v-if="config.extra" v-html="config.extra" style="color: rgba(0, 0, 0, 0.45)"></div>
<div v-if="!displayItems.length" class="el-table__empty-block"> <div v-if="!displayItems.length" class="el-table__empty-block">
<span class="el-table__empty-text t-table__empty">暂无{{ config.titlePrefix || '' }}数据</span> <span class="el-table__empty-text t-table__empty">暂无{{ config.titlePrefix || '' }}数据</span>
@ -34,7 +41,7 @@
<div <div
class="m-fields-group-list-footer" class="m-fields-group-list-footer"
:class="{ 'is-sticky-full': Boolean(config.addButtonConfig?.sticky) }" :class="{ 'is-sticky-full': Boolean(config.addButtonConfig?.sticky) }"
v-if="!isCompare && ($slots['toggle-button'] || $slots['add-button'])" v-if="hasFooter($slots)"
> >
<slot name="toggle-button"></slot> <slot name="toggle-button"></slot>
<div style="display: flex; justify-content: flex-end; flex: 1"> <div style="display: flex; justify-content: flex-end; flex: 1">
@ -49,6 +56,7 @@ import { computed } from 'vue';
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import type { ContainerChangeEventData, GroupListConfig } from '../schema'; import type { ContainerChangeEventData, GroupListConfig } from '../schema';
import { getGroupListHeaderVars } from '../utils/tableGroupList';
import MFieldsGroupListItem from './GroupListItem.vue'; import MFieldsGroupListItem from './GroupListItem.vue';
@ -104,6 +112,19 @@ const onAddDiffCount = () => emit('addDiffCount');
const asList = (value: unknown): any[] => (Array.isArray(value) ? value : []); const asList = (value: unknown): any[] => (Array.isArray(value) ? value : []);
const headerVars = computed(() => getGroupListHeaderVars(props.config.header));
/**
* `$slots` 而不是 `useSlots()`宿主切换 `addable` 时插槽会增删
* computed 缓存不随插槽变化失效只有在渲染期读才拿得到最新的
*/
const hasFooter = (slots: Record<string, unknown>) =>
!props.isCompare && Boolean(slots['toggle-button'] || slots['add-button']);
/** 只有真的渲染出吸底 footer内层列表才需要为它让位 */
const isFooterSticky = (slots: Record<string, unknown>) =>
hasFooter(slots) && Boolean(props.config.addButtonConfig?.sticky);
const currentList = computed(() => asList(props.model[props.name])); const currentList = computed(() => asList(props.model[props.name]));
/** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */ /** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */

View File

@ -70,27 +70,60 @@
flex-shrink: 0; flex-shrink: 0;
gap: 8px; gap: 8px;
} }
// 卡片标题吸顶补不透明背景避免滚动时正文透出
// z-index 必须低于吸底 footer否则标题会盖住新增按钮
> .el-card__header,
> .t-card__header {
position: sticky;
top: var(--m-group-list-header-sticky-top, 0px);
z-index: 7;
background-color: var(--m-group-list-header-bg, #fff);
}
} }
// 嵌套标题叠在外层标题之下层级更低以免盖住外层底部分隔线 // 嵌套吸底按钮叠在外层 footer 之上新增条件叠在新增条件组上面
.m-fields-group-list-item .m-fields-group-list-item { // 每穿过一层自己渲染了吸底 footer的列表才多让出一个 footer 高度外层没有吸底按钮时
> .el-card__header, // 内层不能凭空抬高自定义属性不能自增所以用 item / list 两个变量交替接力
> .t-card__header { > .m-fields-group-list-item {
top: calc( --m-group-list-item-footer-bottom: var(
var(--m-group-list-header-sticky-top, 0px) + --m-group-list-nested-footer-bottom,
var(--m-group-list-header-height) 0px
);
}
&.is-footer-sticky > .m-fields-group-list-item {
--m-group-list-item-footer-bottom: calc(
var(--m-group-list-nested-footer-bottom, 0px) +
var(--m-group-list-footer-height)
);
}
.m-fields-group-list-item .m-fields-group-list {
--m-group-list-nested-footer-bottom: var(
--m-group-list-item-footer-bottom,
0px
);
}
// header.sticky 时标题吸顶直接子 item避免外层吸顶带动未开启的内层
&.is-header-sticky {
// 从外层 item 接过已累加的偏移最外层没有该变量时走 fallback
--m-group-list-nested-sticky-top: var(--m-group-list-child-list-sticky-top);
--m-group-list-nested-header-z: var(--m-group-list-child-list-header-z);
> .m-fields-group-list-item {
--m-group-list-item-sticky-top: var(
--m-group-list-nested-sticky-top,
var(--m-group-list-header-sticky-top, 0px)
); );
z-index: 6; --m-group-list-item-header-z: var(--m-group-list-nested-header-z, 7);
--m-group-list-child-list-sticky-top: calc(
var(--m-group-list-item-sticky-top) + var(--m-group-list-header-height)
);
--m-group-list-child-list-header-z: calc(
var(--m-group-list-item-header-z) - 1
);
// 卡片标题吸顶补不透明背景避免滚动时正文透出
// z-index 必须低于吸底 footer否则标题会盖住新增按钮
> .el-card__header,
> .t-card__header {
position: sticky;
top: var(--m-group-list-item-sticky-top);
z-index: var(--m-group-list-item-header-z);
background-color: var(--m-group-list-header-bg, #fff);
}
} }
} }
@ -113,10 +146,10 @@
} }
// 吸底全宽主按钮event-select / code-select / display-conds 外层列表 // 吸底全宽主按钮event-select / code-select / display-conds 外层列表
// z-index 高于 item header避免吸顶标题挡住新增按钮 // z-index 高于 item header避免吸顶标题挡住新增按钮多层时 bottom 由内层 list 累加
> .m-fields-group-list-footer.is-sticky-full { > .m-fields-group-list-footer.is-sticky-full {
position: sticky; position: sticky;
bottom: 0; bottom: var(--m-group-list-nested-footer-bottom, 0px);
z-index: 7; z-index: 7;
margin-bottom: 0; margin-bottom: 0;
padding: var(--m-group-list-footer-padding-top) 0 padding: var(--m-group-list-footer-padding-top) 0
@ -133,14 +166,6 @@
height: var(--m-group-list-footer-button-height); height: var(--m-group-list-footer-button-height);
} }
} }
// 嵌套吸底按钮叠在外层 footer 之上新增条件叠在新增条件组上面
.m-fields-group-list-item
.m-fields-group-list
> .m-fields-group-list-footer.is-sticky-full {
bottom: var(--m-group-list-footer-height);
z-index: 7;
}
} }
/** 最外层的groupList需要每个item需要增加空白区域界限时增加outer-gorup_list可以实现 */ /** 最外层的groupList需要每个item需要增加空白区域界限时增加outer-gorup_list可以实现 */

View File

@ -18,7 +18,9 @@
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema'; import { isNumber } from '@tmagic/utils';
import type { GroupListConfig, GroupListHeaderConfig, TableColumnConfig, TableConfig } from '../schema';
/** /**
* table / group-list `TableGroupList.vue` * table / group-list `TableGroupList.vue`
@ -31,6 +33,31 @@ import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema'
/** group-list 形态的 type兼容驼峰与中划线两种写法 */ /** group-list 形态的 type兼容驼峰与中划线两种写法 */
export const isGroupListType = (type: unknown): boolean => type === 'groupList' || type === 'group-list'; export const isGroupListType = (type: unknown): boolean => type === 'groupList' || type === 'group-list';
/**
* CSS
*
* JSON `'48'` CSS
* `calc()` `px`
*/
const toCssLength = (height: number | string | undefined): string | undefined => {
const value = `${height ?? ''}`.trim();
if (!value) return undefined;
if (isNumber(value)) return `${value}px`;
// 走到这里还是 number 的只剩 NaN / Infinity不写变量交给样式默认值兜底
return typeof height === 'number' ? undefined : value;
};
/** header.sticky 开启时把 height 写成 CSS 变量,给嵌套吸顶偏移用 */
export const getGroupListHeaderVars = (
header: GroupListHeaderConfig | undefined,
): Record<string, string> | undefined => {
if (!header?.sticky) return undefined;
const height = toCssLength(header.height);
return height ? { '--m-group-list-header-height': height } : undefined;
};
/** 按 label 文案长度估算 label 宽度(中文按 20px、其他按 8px最小 80px */ /** 按 label 文案长度估算 label 宽度(中文按 20px、其他按 8px最小 80px */
export const calcLabelWidth = (label: string): string => { export const calcLabelWidth = (label: string): string => {
if (!label) return '0px'; if (!label) return '0px';

View File

@ -202,6 +202,63 @@ describe('GroupList container', () => {
).toBe(false); ).toBe(false);
warn.mockRestore(); warn.mockRestore();
}); });
test('点击删除按钮移除对应项', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
await wrapper.findAll('.delete-button')[0].trigger('click');
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'b' }]);
expect(wrapper.findAll('.m-fields-group-list-item')).toHaveLength(1);
});
test('点击复制按钮在末尾追加一份副本', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
const copyButton = wrapper.findAll('button').find((btn) => btn.text().includes('复制'));
await copyButton?.trigger('click');
await nextTick();
const { list } = (wrapper.vm as any).values;
expect(list).toHaveLength(3);
expect(list[2]).toEqual({ text: 'a' });
// 深拷贝,改副本不应牵动原项
expect(list[2]).not.toBe(list[0]);
});
test('点击下移 / 上移交换相邻两项', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
// 首项的「上移」与末项的「下移」只是 v-show 隐藏,仍在 DOM 里,按 item 作用域取才不会点错
const buttonIn = (index: number, text: string) =>
wrapper
.findAll('.m-fields-group-list-item')
[index].findAll('button')
.find((btn) => btn.text().includes(text));
await buttonIn(0, '下移')?.trigger('click');
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'b' }, { text: 'a' }]);
await buttonIn(1, '上移')?.trigger('click');
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'a' }, { text: 'b' }]);
});
test('移动越界时夹到列表两端,不丢项', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
const item = wrapper.findAllComponents({ name: 'MFormGroupListItem' })[1];
item.vm.$emit('swap-item', 1, 5);
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'a' }, { text: 'b' }]);
});
}); });
describe('labelPosition 透传', () => { describe('labelPosition 透传', () => {
@ -501,6 +558,131 @@ describe('GroupList container', () => {
await nextTick(); await nextTick();
expect(wrapper.find('.m-fields-group-list-footer.is-sticky-full').exists()).toBe(true); expect(wrapper.find('.m-fields-group-list-footer.is-sticky-full').exists()).toBe(true);
expect(wrapper.text()).toContain('添加'); expect(wrapper.text()).toContain('添加');
expect(wrapper.find('.m-fields-group-list').classes()).toContain('is-footer-sticky');
});
// 没有吸底 footer 的层不能让内层凭空抬高
test('未开 addButtonConfig.sticky 时根节点不带 is-footer-sticky', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list').classes()).not.toContain('is-footer-sticky');
});
test('对比模式下不渲染 footer也不带 is-footer-sticky', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
addButtonConfig: { sticky: true, text: '添加', props: { type: 'primary', plain: true, text: false } },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
{ isCompare: true, lastValues: { list: [{ text: 'a' }] } },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list-footer').exists()).toBe(false);
expect(wrapper.find('.m-fields-group-list').classes()).not.toContain('is-footer-sticky');
});
test('header.sticky 时根节点带 is-header-sticky', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
header: { sticky: true },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(true);
});
test('未开 header.sticky 时不加吸顶 class', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false);
});
test('header.height 在 sticky 开启时写入 CSS 变量', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
header: { sticky: true, height: 48 },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
const el = wrapper.find('.m-fields-group-list').element as HTMLElement;
expect(el.style.getPropertyValue('--m-group-list-header-height')).toBe('48px');
});
test('仅配置 header.height 未开 sticky 时不写 CSS 变量', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
header: { height: 48 },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
const el = wrapper.find('.m-fields-group-list').element as HTMLElement;
expect(el.style.getPropertyValue('--m-group-list-header-height')).toBe('');
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false);
});
test('嵌套列表各自按 header.sticky 决定是否吸顶', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'groups',
header: { sticky: true },
items: [
{
type: 'group-list',
name: 'cond',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
},
],
{ groups: [{ cond: [{ text: 'a' }] }] },
);
await nextTick();
const lists = wrapper.findAll('.m-fields-group-list');
expect(lists[0].classes()).toContain('is-header-sticky');
expect(lists[1].classes()).not.toContain('is-header-sticky');
}); });
}); });
}); });

View File

@ -5,7 +5,7 @@
*/ */
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { getGroupListRowConfig } from '@form/utils/tableGroupList'; import { getGroupListHeaderVars, getGroupListRowConfig } from '@form/utils/tableGroupList';
describe('getGroupListRowConfig', () => { describe('getGroupListRowConfig', () => {
test('把 group-list 的 labelWidth / labelPosition 复制到 row 配置', () => { test('把 group-list 的 labelWidth / labelPosition 复制到 row 配置', () => {
@ -26,3 +26,47 @@ describe('getGroupListRowConfig', () => {
expect(row.items).toHaveLength(1); expect(row.items).toHaveLength(1);
}); });
}); });
describe('getGroupListHeaderVars', () => {
test('未开 sticky 时不写样式', () => {
expect(getGroupListHeaderVars(undefined)).toBeUndefined();
expect(getGroupListHeaderVars({})).toBeUndefined();
expect(getGroupListHeaderVars({ height: 48 })).toBeUndefined();
});
test('sticky 但 height 为空时不写变量,走样式默认值', () => {
expect(getGroupListHeaderVars({ sticky: true })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: '' })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: ' ' })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: Number.NaN })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: Number.POSITIVE_INFINITY })).toBeUndefined();
});
test('数字 height 转成 px', () => {
expect(getGroupListHeaderVars({ sticky: true, height: 48 })).toEqual({
'--m-group-list-header-height': '48px',
});
expect(getGroupListHeaderVars({ sticky: true, height: 0 })).toEqual({
'--m-group-list-header-height': '0px',
});
});
// 无单位的值会让嵌套层的 calc() 整体失效、吸顶静默失灵
test('无单位数字字符串补 px', () => {
expect(getGroupListHeaderVars({ sticky: true, height: '48' })).toEqual({
'--m-group-list-header-height': '48px',
});
expect(getGroupListHeaderVars({ sticky: true, height: ' 47.5 ' })).toEqual({
'--m-group-list-header-height': '47.5px',
});
});
test('带单位的字符串 height 原样写入', () => {
expect(getGroupListHeaderVars({ sticky: true, height: '4em' })).toEqual({
'--m-group-list-header-height': '4em',
});
expect(getGroupListHeaderVars({ sticky: true, height: 'var(--x)' })).toEqual({
'--m-group-list-header-height': 'var(--x)',
});
});
});

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/schema", "name": "@tmagic/schema",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/stage", "name": "@tmagic/stage",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/table", "name": "@tmagic/table",
"type": "module", "type": "module",
"sideEffects": [ "sideEffects": [

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/tdesign-vue-next-adapter", "name": "@tmagic/tdesign-vue-next-adapter",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"name": "@tmagic/utils", "name": "@tmagic/utils",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,6 +1,6 @@
{ {
"name": "tmagic-playground", "name": "tmagic-playground",
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
@ -12,11 +12,11 @@
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"@tmagic/core": "1.8.0-beta.27", "@tmagic/core": "1.8.0-beta.28",
"@tmagic/design": "1.8.0-beta.27", "@tmagic/design": "1.8.0-beta.28",
"@tmagic/editor": "1.8.0-beta.27", "@tmagic/editor": "1.8.0-beta.28",
"@tmagic/element-plus-adapter": "1.8.0-beta.27", "@tmagic/element-plus-adapter": "1.8.0-beta.28",
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.27", "@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.28",
"@tmagic/tmagic-form-runtime": "1.1.6", "@tmagic/tmagic-form-runtime": "1.1.6",
"element-plus": "catalog:", "element-plus": "catalog:",
"lodash-es": "^4.18.1", "lodash-es": "^4.18.1",

22
pnpm-lock.yaml generated
View File

@ -567,19 +567,19 @@ importers:
specifier: ^2.3.2 specifier: ^2.3.2
version: 2.3.2(vue@3.5.41(typescript@6.0.3)) version: 2.3.2(vue@3.5.41(typescript@6.0.3))
'@tmagic/core': '@tmagic/core':
specifier: 1.8.0-beta.27 specifier: 1.8.0-beta.28
version: link:../packages/core version: link:../packages/core
'@tmagic/design': '@tmagic/design':
specifier: 1.8.0-beta.27 specifier: 1.8.0-beta.28
version: link:../packages/design version: link:../packages/design
'@tmagic/editor': '@tmagic/editor':
specifier: 1.8.0-beta.27 specifier: 1.8.0-beta.28
version: link:../packages/editor version: link:../packages/editor
'@tmagic/element-plus-adapter': '@tmagic/element-plus-adapter':
specifier: 1.8.0-beta.27 specifier: 1.8.0-beta.28
version: link:../packages/element-plus-adapter version: link:../packages/element-plus-adapter
'@tmagic/tdesign-vue-next-adapter': '@tmagic/tdesign-vue-next-adapter':
specifier: 1.8.0-beta.27 specifier: 1.8.0-beta.28
version: link:../packages/tdesign-vue-next-adapter version: link:../packages/tdesign-vue-next-adapter
'@tmagic/tmagic-form-runtime': '@tmagic/tmagic-form-runtime':
specifier: 1.1.6 specifier: 1.1.6
@ -923,13 +923,13 @@ importers:
runtime/react: runtime/react:
dependencies: dependencies:
'@tmagic/core': '@tmagic/core':
specifier: 1.8.0-beta.27 specifier: 1.8.0-beta.28
version: link:../../packages/core version: link:../../packages/core
'@tmagic/react-runtime-help': '@tmagic/react-runtime-help':
specifier: 0.2.2 specifier: 0.2.2
version: link:../react-runtime-help version: link:../react-runtime-help
'@tmagic/stage': '@tmagic/stage':
specifier: 1.8.0-beta.27 specifier: 1.8.0-beta.28
version: link:../../packages/stage version: link:../../packages/stage
axios: axios:
specifier: ^1.19.0 specifier: ^1.19.0
@ -945,7 +945,7 @@ importers:
version: 19.2.8(react@19.2.8) version: 19.2.8(react@19.2.8)
devDependencies: devDependencies:
'@tmagic/cli': '@tmagic/cli':
specifier: 1.8.0-beta.27 specifier: 1.8.0-beta.28
version: link:../../packages/cli version: link:../../packages/cli
'@types/fs-extra': '@types/fs-extra':
specifier: ^11.0.4 specifier: ^11.0.4
@ -1021,10 +1021,10 @@ importers:
runtime/vue: runtime/vue:
dependencies: dependencies:
'@tmagic/core': '@tmagic/core':
specifier: 1.8.0-beta.27 specifier: 1.8.0-beta.28
version: link:../../packages/core version: link:../../packages/core
'@tmagic/stage': '@tmagic/stage':
specifier: 1.8.0-beta.27 specifier: 1.8.0-beta.28
version: link:../../packages/stage version: link:../../packages/stage
'@tmagic/vue-runtime-help': '@tmagic/vue-runtime-help':
specifier: ^2.0.4 specifier: ^2.0.4
@ -1037,7 +1037,7 @@ importers:
version: 3.5.41(typescript@6.0.3) version: 3.5.41(typescript@6.0.3)
devDependencies: devDependencies:
'@tmagic/cli': '@tmagic/cli':
specifier: 1.8.0-beta.27 specifier: 1.8.0-beta.28
version: link:../../packages/cli version: link:../../packages/cli
'@types/fs-extra': '@types/fs-extra':
specifier: ^11.0.4 specifier: ^11.0.4

View File

@ -1,6 +1,6 @@
{ {
"name": "runtime-react", "name": "runtime-react",
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"type": "module", "type": "module",
"private": true, "private": true,
"engines": { "engines": {
@ -16,16 +16,16 @@
"build:playground": "node scripts/build.mjs --type=playground" "build:playground": "node scripts/build.mjs --type=playground"
}, },
"dependencies": { "dependencies": {
"@tmagic/core": "1.8.0-beta.27", "@tmagic/core": "1.8.0-beta.28",
"@tmagic/react-runtime-help": "0.2.2", "@tmagic/react-runtime-help": "0.2.2",
"@tmagic/stage": "1.8.0-beta.27", "@tmagic/stage": "1.8.0-beta.28",
"axios": "^1.19.0", "axios": "^1.19.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@tmagic/cli": "1.8.0-beta.27", "@tmagic/cli": "1.8.0-beta.28",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",

View File

@ -1,6 +1,6 @@
{ {
"name": "runtime-vue", "name": "runtime-vue",
"version": "1.8.0-beta.27", "version": "1.8.0-beta.28",
"type": "module", "type": "module",
"private": true, "private": true,
"engines": { "engines": {
@ -16,14 +16,14 @@
"build:playground": "node scripts/build.mjs --type=playground" "build:playground": "node scripts/build.mjs --type=playground"
}, },
"dependencies": { "dependencies": {
"@tmagic/core": "1.8.0-beta.27", "@tmagic/core": "1.8.0-beta.28",
"@tmagic/stage": "1.8.0-beta.27", "@tmagic/stage": "1.8.0-beta.28",
"@tmagic/vue-runtime-help": "^2.0.4", "@tmagic/vue-runtime-help": "^2.0.4",
"axios": "^1.19.0", "axios": "^1.19.0",
"vue": "catalog:" "vue": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@tmagic/cli": "1.8.0-beta.27", "@tmagic/cli": "1.8.0-beta.28",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@vitejs/plugin-legacy": "^8.2.2", "@vitejs/plugin-legacy": "^8.2.2",