Compare commits

..

7 Commits

Author SHA1 Message Date
roymondchen
c4cf3b9525 fix(dep): 修复共用节点 id 的多个根节点收集依赖时互相清除
多个根节点(如复制出的页面片)共用节点 id 时,逐节点“删除旧依赖 + 收集”会让后一棵树的删除清掉前一棵树刚写入的依赖。改为先统一删除所有节点的旧依赖,再统一收集。
2026-10-27 20:52:23 +08:00
roymondchen
d01bc45b17 feat(form): 支持 table/group-list 行操作自定义按钮
新增 actionButtons 配置,渲染在内置按钮之后,支持 text/props/icon/display/disabled/handler,
回调可获得当前行数据、列表下标、整体数据等上下文。
2026-10-27 20:12:23 +08:00
roymondchen
7e8cb1f9db feat(editor): 支持配置画布 iframe 的 sandbox 属性
新增 iframeSandbox 配置,透传链路为 EditorProps -> StageOptions ->
StageCore -> StageRender,在 createIframe 中设置 src 之前写入 sandbox
属性。默认不设置该属性,保持存量行为不变。

- stage: StageRenderConfig / StageCoreConfig 新增 iframeSandbox 类型
- editor: EditorProps / StageOptions 新增 iframeSandbox 并透传
- docs: 补充 iframeSandbox 说明与 srcdoc、同源限制的注意事项
- test: 覆盖未配置 / false / 空字符串 / 多 token 与 StageCore 透传
2026-10-27 16:12:45 +08:00
roymondchen
d017f43573 fix(stage): iframe 未就绪时跳过注入 magic,避免 contentWindow 为空报错 2026-10-19 17:08:02 +08:00
roymondchen
cbd0442ffc feat(form): 支持 table/group-list 行操作按钮文案与 props 配置
通过 ActionButtonConfig 统一 delete/copy/move/operate 等按钮的 text 与 props,并补充对应单测。
2026-10-19 17:08:02 +08:00
roymondchen
8ac1a38601 fix(editor): 优化导航更多菜单宽度与溢出分割线
不传 popoverWidth 时按内容撑开,并将溢出项中的分割线改为横向且去掉首尾无意义分割线。
2026-10-19 17:08:02 +08:00
roymondchen
7f0e78bf6a chore: 升级根目录与 catalog 依赖版本 2026-10-19 16:42:31 +08:00
31 changed files with 2224 additions and 944 deletions

View File

@ -503,6 +503,32 @@ const renderFunction = async (stage) => {
</template> </template>
``` ```
## iframeSandbox
- **详情:**
画布 iframe 的 sandbox 属性值,仅 `renderType` 为 `iframe` 时生效。不传或传 `false` 时不设置 sandbox 属性。
token 用空格分隔,缺哪个 token,画布内对应能力就会静默失效。编辑器自身依赖 `allow-scripts` + `allow-same-origin`(需要读写 iframe 内的 DOM);组件内需要下载、弹窗、表单提交时,分别要放开 `allow-downloads`、`allow-popups`、`allow-forms` 等。
注意:
- 仅在编辑器初始化时读取,运行期修改不会生效。
- runtime 与编辑器不同域时,会通过 `srcdoc` 加载,同样受 sandbox 约束;缺少 `allow-same-origin` 会导致编辑器无法访问 iframe 内的 DOM。
- `allow-scripts` 与 `allow-same-origin` 同时存在且 iframe 同源时,iframe 内脚本可以移除自身的 sandbox 属性,因此该配置只用于限制 `allow-top-navigation`、`allow-popups` 等能力,不能作为安全隔离手段。
- **默认值:** `undefined`(不设置 sandbox 属性)
- **类型:** `string | false`
- **示例:**
```html
<template>
<m-editor iframe-sandbox="allow-scripts allow-same-origin allow-downloads"></m-editor>
</template>
```
## autoScrollIntoView ## autoScrollIntoView
- **详情:** - **详情:**

View File

@ -41,12 +41,12 @@
"url": "https://github.com/Tencent/tmagic-editor.git" "url": "https://github.com/Tencent/tmagic-editor.git"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^21.2.3", "@commitlint/cli": "^21.2.4",
"@commitlint/config-conventional": "^21.2.3", "@commitlint/config-conventional": "^21.2.4",
"@tmagic/eslint-config": "workspace:*", "@tmagic/eslint-config": "workspace:*",
"@types/node": "26.6.2", "@types/node": "26.6.5",
"@vitejs/plugin-vue": "^6.0.9", "@vitejs/plugin-vue": "^6.0.10",
"@vitest/coverage-v8": "^5.0.1", "@vitest/coverage-v8": "^5.0.5",
"@vue/compiler-sfc": "catalog:", "@vue/compiler-sfc": "catalog:",
"c8": "^12.0.0", "c8": "^12.0.0",
"commitizen": "^4.3.2", "commitizen": "^4.3.2",
@ -55,30 +55,30 @@
"cz-conventional-changelog": "^3.3.0", "cz-conventional-changelog": "^3.3.0",
"element-plus": "catalog:", "element-plus": "catalog:",
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"eslint": "^10.11.0", "eslint": "^10.13.0",
"execa": "^10.0.1", "execa": "^10.2.0",
"happy-dom": "^20.14.5", "happy-dom": "^21.0.3",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^30.1.0", "jsdom": "^30.1.3",
"lint-staged": "^17.5.1", "lint-staged": "^17.6.0",
"minimist": "^1.2.8", "minimist": "^1.2.8",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"prettier": "^3.9.8", "prettier": "^3.9.10",
"recast": "^0.23.20", "recast": "^0.24.0",
"rimraf": "^6.1.3", "rimraf": "^6.1.3",
"rolldown": "^1.2.9", "rolldown": "^1.2.14",
"rolldown-plugin-dts": "^0.28.6", "rolldown-plugin-dts": "^0.28.8",
"sass-embedded": "^1.104.1", "sass-embedded": "^1.105.2",
"semver": "^7.8.5", "semver": "^7.8.5",
"serialize-javascript": "^7.1.1", "serialize-javascript": "^7.1.2",
"shx": "^0.4.0", "shx": "^0.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vitepress": "2.0.0-alpha.20", "vitepress": "2.0.0-alpha.20",
"vitest": "^5.0.1", "vitest": "^5.0.5",
"vue": "^3.5.43", "vue": "^3.5.44",
"vue-tsc": "^3.3.11" "vue-tsc": "^3.3.13"
}, },
"config": { "config": {
"commitizen": { "commitizen": {

View File

@ -133,9 +133,13 @@ export default class Watcher {
return; return;
} }
// 整棵树只遍历一次、在每个属性上检查所有 target,把结构遍历开销从 ×targets 降到 ×1(详见 collectItems) // 先清掉本次节点上的旧依赖,再统一收集。
// 复制出的页面片会共用节点 id。如果在收集每一棵树之前立刻删除,
// 后一棵树会按相同 id 把前一棵树刚写入的依赖清掉。
for (const node of nodes) { for (const node of nodes) {
this.removeTargetsDep(targets, node); this.removeTargetsDep(targets, node);
}
for (const node of nodes) {
this.collectItems(node, targets, depExtendedData, deep); this.collectItems(node, targets, depExtendedData, deep);
} }
} }

View File

@ -249,4 +249,86 @@ describe('Watcher', () => {
expect(target1?.deps?.node_1).toBeUndefined(); expect(target1?.deps?.node_1).toBeUndefined();
expect(target1?.deps?.node_2).toBeUndefined(); expect(target1?.deps?.node_2).toBeUndefined();
}); });
test('collect keeps deps when roots share node ids', () => {
const watcher = new Watcher();
watcher.addTarget(
new Target({
type: 'target',
id: 'ds_a',
isTarget: (key: string | number, value: any) => key === 'text' && value === 'a',
}),
);
watcher.addTarget(
new Target({
type: 'target',
id: 'ds_b',
isTarget: (key: string | number, value: any) => key === 'text' && value === 'b',
}),
);
watcher.collect(
[
{
id: 'fragment_a',
name: 'fragmentA',
items: [
{
id: 'shared',
name: 'shared-a',
text: 'a',
},
],
},
{
id: 'fragment_b',
name: 'fragmentB',
items: [
{
id: 'shared',
name: 'shared-b',
text: 'b',
},
],
},
],
{},
true,
);
const targetA = watcher.getTarget('ds_a', 'target');
const targetB = watcher.getTarget('ds_b', 'target');
expect(targetA?.deps?.shared.keys).toEqual(['text']);
expect(targetA?.deps?.shared.name).toBe('shared-a');
expect(targetB?.deps?.shared.keys).toEqual(['text']);
expect(targetB?.deps?.shared.name).toBe('shared-b');
});
test('collect merges keys when one target matches shared-id nodes in several roots', () => {
const watcher = new Watcher();
watcher.addTarget(
new Target({
type: 'target',
id: 'ds',
isTarget: (key: string | number, value: any) => key === 'text' && value === 'x',
}),
);
watcher.collect(
[
{ id: 'root_a', items: [{ id: 'shared', name: 'shared-a', text: 'x' }] },
{ id: 'root_b', items: [{ id: 'shared', name: 'shared-b', title: 'y', text: 'x' }] },
],
{},
true,
);
const dep = watcher.getTarget('ds', 'target')?.deps?.shared;
expect(dep?.keys).toEqual(['text']);
// 同 id 的依赖只保留一条,name 取最后收集的节点
expect(dep?.name).toBe('shared-b');
});
}); });

View File

@ -224,6 +224,7 @@ const stageOptions: StageOptions = {
containerHighlightAddOnly: props.containerHighlightAddOnly, containerHighlightAddOnly: props.containerHighlightAddOnly,
disabledDragStart: props.disabledDragStart, disabledDragStart: props.disabledDragStart,
renderType: props.renderType, renderType: props.renderType,
iframeSandbox: props.iframeSandbox,
guidesOptions: props.guidesOptions, guidesOptions: props.guidesOptions,
disabledMultiSelect: props.disabledMultiSelect, disabledMultiSelect: props.disabledMultiSelect,
alwaysMultiSelect: props.alwaysMultiSelect, alwaysMultiSelect: props.alwaysMultiSelect,

View File

@ -62,6 +62,16 @@ export interface EditorProps {
runtimeUrl?: string; runtimeUrl?: string;
/** 是用iframe渲染还是直接渲染 */ /** 是用iframe渲染还是直接渲染 */
renderType?: RenderType; renderType?: RenderType;
/**
* 画布 iframe 的 sandbox 属性值(仅 iframe 渲染时生效)
*
* - 不传 / `false`:不设置 sandbox 属性(默认)
* - 字符串:原样写入,token 空格分隔,如 `'allow-scripts allow-same-origin allow-downloads'`
*
* 需要注意编辑器自身依赖 `allow-scripts` + `allow-same-origin`(读写 iframe 内 DOM),
* 其余 token(`allow-downloads`、`allow-popups`、`allow-forms` 等)按业务需要放开。
*/
iframeSandbox?: string | false;
/** 选中时是否自动滚动到可视区域 */ /** 选中时是否自动滚动到可视区域 */
autoScrollIntoView?: boolean; autoScrollIntoView?: boolean;
/** 组件的属性配置表单的dsl */ /** 组件的属性配置表单的dsl */

View File

@ -31,6 +31,7 @@ export const useStage = (stageOptions: StageOptions) => {
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly, containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
disabledDragStart: stageOptions.disabledDragStart, disabledDragStart: stageOptions.disabledDragStart,
renderType: stageOptions.renderType, renderType: stageOptions.renderType,
iframeSandbox: stageOptions.iframeSandbox,
canSelect: (el, event, stop) => { canSelect: (el, event, stop) => {
if (!stageOptions.canSelect) return true; if (!stageOptions.canSelect) return true;

View File

@ -62,13 +62,14 @@ const props = withDefaults(
width?: number; width?: number;
/** 子元素之间的间距,需与 SCSS gap 保持一致 */ /** 子元素之间的间距,需与 SCSS gap 保持一致 */
gap?: number; gap?: number;
/** Popover 内容宽度 */ /**
popoverWidth?: number; * Popover 内容宽度。不传时按内容撑开,避免下拉框超出菜单。
*/
popoverWidth?: number | string;
}>(), }>(),
{ {
items: () => [], items: () => [],
gap: 3, gap: 3,
popoverWidth: 180,
}, },
); );
@ -110,7 +111,18 @@ const containerWidth = ref(0);
const hiddenIndexSet = ref<Set<number>>(new Set()); const hiddenIndexSet = ref<Set<number>>(new Set());
const hasOverflow = computed(() => hiddenIndexSet.value.size > 0); const hasOverflow = computed(() => hiddenIndexSet.value.size > 0);
const overflowItems = computed(() => props.items.filter((_, index) => hiddenIndexSet.value.has(index))); // 更多菜单是纵向列表:分割线改成横线;收进去的首尾分割线没有分组意义,直接去掉。
const overflowItems = computed(() => {
const items = props.items
.filter((_, index) => hiddenIndexSet.value.has(index))
.map((item) => (item.type === 'divider' ? { ...item, direction: 'horizontal' as const } : item));
let start = 0;
let end = items.length;
while (start < end && items[start].type === 'divider') start += 1;
while (end > start && items[end - 1].type === 'divider') end -= 1;
return items.slice(start, end);
});
const measureAndCompute = () => { const measureAndCompute = () => {
if (!columnEl.value) return; if (!columnEl.value) return;

View File

@ -13,18 +13,14 @@
:data="{ :data="{
type: 'button', type: 'button',
text: '页面', text: '页面',
handler: () => { handler: addNormalPage,
addPage(NodeType.PAGE);
},
}" }"
></ToolButton> ></ToolButton>
<ToolButton <ToolButton
:data="{ :data="{
type: 'button', type: 'button',
text: '页面片', text: '页面片',
handler: () => { handler: addPageFragment,
addPage(NodeType.PAGE_FRAGMENT);
},
}" }"
></ToolButton> ></ToolButton>
</TMagicPopover> </TMagicPopover>
@ -62,4 +58,7 @@ const addPage = (type: NodeType.PAGE | NodeType.PAGE_FRAGMENT) => {
}; };
editorService.add(pageConfig); editorService.add(pageConfig);
}; };
const addNormalPage = () => addPage(NodeType.PAGE);
const addPageFragment = () => addPage(NodeType.PAGE_FRAGMENT);
</script> </script>

View File

@ -150,8 +150,10 @@ class Dep extends BaseService {
const targets = this.watcher.getCollectableTargets(type); const targets = this.watcher.getCollectableTargets(type);
if (targets.length) { if (targets.length) {
// 先统一删除所有节点的旧依赖,再统一收集。
// 如果逐节点“删除 + 收集”,共用 id 的多棵树(如复制出的页面片),
// 后一棵树的删除会清掉前一棵树刚写入的依赖。
for (const node of nodes) { for (const node of nodes) {
// 先删除原有依赖,再批量收集
if (isPage(node)) { if (isPage(node)) {
for (const target of targets) { for (const target of targets) {
this.removePageDep(target, depExtendedData); this.removePageDep(target, depExtendedData);
@ -159,6 +161,8 @@ class Dep extends BaseService {
} else { } else {
this.watcher.removeTargetsDep(targets, node); this.watcher.removeTargetsDep(targets, node);
} }
}
for (const node of nodes) {
this.watcher.collectItems(node, targets, depExtendedData, deep); this.watcher.collectItems(node, targets, depExtendedData, deep);
} }
} }

View File

@ -120,7 +120,12 @@
} }
} }
.m-editor-nav-menu-popover { .tmagic-design-popper.m-editor-nav-menu-popover {
// 不固定宽度,按内容撑开,避免下拉框超出菜单;content-box 让 padding 不挤占内容宽度。
width: max-content;
min-width: 180px;
box-sizing: content-box;
.m-editor-nav-menu-overflow-list { .m-editor-nav-menu-overflow-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -139,8 +144,30 @@
} }
&.divider { &.divider {
padding: 0; display: flex;
align-items: center;
align-self: stretch;
width: auto;
height: auto;
padding: 4px 12px;
cursor: default; cursor: default;
box-sizing: border-box;
// 更多菜单是纵向列表,分割线必须盖过组件默认的竖线样式
.tmagic-design-divider,
.el-divider,
.t-divider {
display: block;
flex: 1 1 auto;
width: 100%;
min-width: 0;
height: 0;
margin: 0;
border: 0;
border-top: 1px solid var(--el-border-color, #dcdfe6);
border-left: 0;
position: static;
}
&:hover { &:hover {
background-color: transparent; background-color: transparent;

View File

@ -221,6 +221,8 @@ export interface StageOptions {
*/ */
disabledFlashTip?: boolean; disabledFlashTip?: boolean;
zoom?: number; zoom?: number;
/** 画布 iframe 的 sandbox 属性值,不传 / false 表示不设置,见 @tmagic/stage 的 StageRenderConfig.iframeSandbox */
iframeSandbox?: string | false;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */ /** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void; beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
} }

View File

@ -21,7 +21,8 @@ vi.mock('@editor/components/ToolButton.vue', () => ({
'button', 'button',
{ {
ref: rootEl, ref: rootEl,
class: ['tool-btn', (props.data as any)?.className], class: ['tool-btn', (props.data as any)?.className, (props.data as any)?.type],
'data-direction': (props.data as any)?.direction || '',
}, },
(props.data as any)?.text || '', (props.data as any)?.text || '',
); );
@ -119,6 +120,15 @@ describe('NavMenuColumn', () => {
expect(moreWrapper.classes()).toContain('m-editor-nav-menu-more-wrapper-hidden'); expect(moreWrapper.classes()).toContain('m-editor-nav-menu-more-wrapper-hidden');
}); });
test('未指定 popoverWidth 时不给更多菜单固定宽度', () => {
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
});
const popover = wrapper.findComponent({ name: 'TMagicPopover' });
expect(popover.exists()).toBe(true);
expect(popover.props('width')).toBeUndefined();
});
test('点击 more 引用元素切换 popover 显示状态', async () => { test('点击 more 引用元素切换 popover 显示状态', async () => {
const wrapper = mount(NavMenuColumn as any, { const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] }, props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
@ -344,6 +354,127 @@ describe('NavMenuColumn', () => {
wrapper.unmount(); wrapper.unmount();
}); });
test('更多菜单中的分割线改为横向,工具栏中的分割线保持竖向', async () => {
const items = [
{ type: 'button', text: 'A' },
{ type: 'button', text: 'B' },
{ type: 'divider', className: 'sep' },
{ type: 'button', text: 'C' },
];
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'right', items, width: 80 },
attachTo: document.body,
});
const columnEl = wrapper.find('.m-editor-nav-menu-column').element as HTMLElement;
Object.defineProperty(columnEl, 'clientWidth', { configurable: true, get: () => 80 });
const moreEl = wrapper.find('.m-editor-nav-menu-more-wrapper').element as HTMLElement;
moreEl.getBoundingClientRect = () => ({
width: 30,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
wrapper.findAll('.tool-btn').forEach((b) => {
const el = b.element as HTMLElement;
el.getBoundingClientRect = () => ({
width: 40,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
});
roCallbacks.forEach((cb) => cb());
await flushRaf();
await flushRaf();
const overflowDividers = wrapper.findAll('.m-editor-nav-menu-overflow-list .sep');
expect(overflowDividers.length).toBeGreaterThan(0);
overflowDividers.forEach((divider) => {
expect(divider.attributes('data-direction')).toBe('horizontal');
});
wrapper.findAll('.m-editor-nav-menu-column > .sep').forEach((divider) => {
expect(divider.attributes('data-direction')).toBe('');
});
wrapper.unmount();
});
test('更多菜单首尾的分割线会被去掉,中间的分割线保留', async () => {
const items = [
{ type: 'button', text: 'A' },
{ type: 'divider', className: 'lead' },
{ type: 'button', text: 'B' },
{ type: 'divider', className: 'mid' },
{ type: 'button', text: 'C' },
{ type: 'divider', className: 'trail' },
];
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'right', items, width: 80 },
attachTo: document.body,
});
const columnEl = wrapper.find('.m-editor-nav-menu-column').element as HTMLElement;
Object.defineProperty(columnEl, 'clientWidth', { configurable: true, get: () => 80 });
const moreEl = wrapper.find('.m-editor-nav-menu-more-wrapper').element as HTMLElement;
moreEl.getBoundingClientRect = () => ({
width: 30,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
wrapper.findAll('.tool-btn').forEach((b) => {
const el = b.element as HTMLElement;
el.getBoundingClientRect = () => ({
width: 40,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
});
roCallbacks.forEach((cb) => cb());
await flushRaf();
await flushRaf();
const overflow = wrapper.find('.m-editor-nav-menu-overflow-list');
expect(overflow.find('.lead').exists()).toBe(false);
expect(overflow.find('.trail').exists()).toBe(false);
expect(
overflow.findAll('.tool-btn').map((button) => button.text() || button.classes().find((name) => name === 'mid')),
).toEqual(['B', 'mid', 'C']);
wrapper.unmount();
});
test('卸载时清理 ResizeObserver', () => { test('卸载时清理 ResizeObserver', () => {
const wrapper = mount(NavMenuColumn as any, { const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] }, props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },

View File

@ -227,6 +227,49 @@ describe('Dep service', () => {
expect(() => depService.collect([{ id: 'p1', type: 'page', items: [] }] as any, { pageId: 'p1' })).not.toThrow(); expect(() => depService.collect([{ id: 'p1', type: 'page', items: [] }] as any, { pageId: 'p1' })).not.toThrow();
}); });
const makeTextTarget = (id: string, text: string) =>
new Target({
id,
type: DepTargetType.DEFAULT,
isTarget: (key: string | number, value: any) => key === 'text' && value === text,
});
const makeSharedIdFragments = () => [
{ id: 'fragment_a', type: 'container', items: [{ id: 'shared', type: 'text', text: 'a' }] },
{ id: 'fragment_b', type: 'container', items: [{ id: 'shared', type: 'text', text: 'b' }] },
];
test('collect 多个根节点共用子节点 id 时不会互相清掉依赖', () => {
const targetA = makeTextTarget('ds_a', 'a');
const targetB = makeTextTarget('ds_b', 'b');
depService.addTarget(targetA);
depService.addTarget(targetB);
depService.collect(makeSharedIdFragments() as any, {}, true);
expect(targetA.deps.shared?.keys).toEqual(['text']);
expect(targetB.deps.shared?.keys).toEqual(['text']);
});
test('collect 多个 page 节点同 pageId 时不会互相清掉依赖', () => {
const target = makeTextTarget('ds_a', 'a');
depService.addTarget(target);
target.deps.stale = { name: 'stale', keys: ['text'], data: { pageId: 'p' } };
depService.collect(
[
{ id: 'page_1', type: 'page', text: 'a', items: [] },
{ id: 'page_2', type: 'page', items: [{ id: 'child', type: 'text', text: 'a' }] },
] as any,
{ pageId: 'p' },
true,
);
expect(target.deps.stale).toBeUndefined();
expect(target.deps.page_1?.keys).toEqual(['text']);
expect(target.deps.child?.keys).toEqual(['text']);
});
test('collectNode 支持 page 与普通节点两条路径', () => { test('collectNode 支持 page 与普通节点两条路径', () => {
const target = makeTarget('node-target'); const target = makeTarget('node-target');
depService.addTarget(target); depService.addTarget(target);

View File

@ -832,6 +832,47 @@ export interface AddButtonConfig {
} }
// #endregion AddButtonConfig // #endregion AddButtonConfig
// #region ActionButtonConfig
/**
* 行操作按钮。与 `addButtonConfig` 相同:`text` 是文案,`props` 透传给按钮。
* 不支持吸底。未配置 `text` 时使用内置中文。
*/
export type ActionButtonConfig = Omit<AddButtonConfig, 'sticky'>;
// #endregion ActionButtonConfig
// #region CustomActionButtonContext
/** 自定义行操作按钮回调收到的上下文 */
export interface CustomActionButtonContext {
/** 当前行数据 */
model: any;
/** 当前行在整个列表中的下标(table 分页时为全局下标) */
index: number;
/** 整个列表数据 */
groupModel: any[];
prop?: string;
config: any;
formValue: any;
}
// #endregion CustomActionButtonContext
// #region CustomActionButtonConfig
/** table / group-list 行操作中的自定义按钮,渲染在内置按钮之后 */
export interface CustomActionButtonConfig {
/** 按钮文案;table 非 flat 模式下仅作为 title 提示(有 icon 时) */
text?: string;
/** 透传给按钮的属性 */
props?: Record<string, any>;
/** 按钮图标组件 */
icon?: any;
/** 是否显示,默认显示 */
display?: boolean | ((mForm: FormState | undefined, ctx: CustomActionButtonContext) => boolean);
/** 是否禁用,表单禁用时始终禁用 */
disabled?: boolean | ((mForm: FormState | undefined, ctx: CustomActionButtonContext) => boolean);
/** 点击回调 */
handler: (mForm: FormState | undefined, ctx: CustomActionButtonContext) => void;
}
// #endregion CustomActionButtonConfig
// #region GroupListHeaderConfig // #region GroupListHeaderConfig
/** group-list 卡片标题;仅 sticky 开启时吸顶 */ /** group-list 卡片标题;仅 sticky 开启时吸顶 */
export interface GroupListHeaderConfig { export interface GroupListHeaderConfig {
@ -862,6 +903,18 @@ export interface TableGroupListCommonConfig extends FormItem {
/** 新增后滚动到最后一项(group-list 形态,默认关闭) */ /** 新增后滚动到最后一项(group-list 形态,默认关闭) */
scrollLastItemIntoView?: boolean; scrollLastItemIntoView?: boolean;
addButtonConfig?: AddButtonConfig; addButtonConfig?: AddButtonConfig;
/** 删除按钮,text 默认「删除」 */
deleteButtonConfig?: ActionButtonConfig;
/** 复制按钮,text 默认「复制」 */
copyButtonConfig?: ActionButtonConfig;
/** 上移按钮,text 默认「上移」。仅 group-list 形态 */
moveUpButtonConfig?: ActionButtonConfig;
/** 下移按钮,text 默认「下移」。仅 group-list 形态 */
moveDownButtonConfig?: ActionButtonConfig;
/** 移动至按钮,text 默认「移动至」。仅 group-list 形态 */
moveToButtonConfig?: ActionButtonConfig;
/** 自定义行操作按钮,渲染在内置按钮之后 */
actionButtons?: CustomActionButtonConfig[];
/** group-list 形态的标题吸顶(默认关闭);table 形态忽略 */ /** group-list 形态的标题吸顶(默认关闭);table 形态忽略 */
header?: GroupListHeaderConfig; header?: GroupListHeaderConfig;
} }
@ -898,6 +951,11 @@ export interface TableConfig<T = never> extends TableGroupListCommonConfig {
showIndex?: boolean; showIndex?: boolean;
/** 操作栏宽度 */ /** 操作栏宽度 */
operateColWidth?: number | string; operateColWidth?: number | string;
/**
* 操作列。`text` 是列标题,默认「操作」。
* `props` 透传给列(如 width、align、fixed、minWidth),可覆盖 operateColWidth / fixed。
*/
operateColConfig?: ActionButtonConfig;
pagination?: boolean; pagination?: boolean;
/** 表格标题前缀 ,新增xxx,暂无xxx数据,表明表格内容是什么 */ /** 表格标题前缀 ,新增xxx,暂无xxx数据,表明表格内容是什么 */
titlePrefix?: string; titlePrefix?: string;
@ -919,6 +977,8 @@ export interface TableConfig<T = never> extends TableGroupListCommonConfig {
dropActionButtonIcon?: any; dropActionButtonIcon?: any;
copyActionButtonIcon?: any; copyActionButtonIcon?: any;
deleteActionButtonIcon?: any; deleteActionButtonIcon?: any;
/** 拖动排序按钮,text 作为提示,默认「拖动排序」 */
dropSortButtonConfig?: ActionButtonConfig;
/** 是否显示全屏按钮 */ /** 是否显示全屏按钮 */
enableFullscreen?: boolean; enableFullscreen?: boolean;
fixed?: boolean | 'left' | 'right'; fixed?: boolean | 'left' | 'right';

View File

@ -19,8 +19,9 @@
type="primary" type="primary"
:icon="DocumentCopy" :icon="DocumentCopy"
:disabled="disabled" :disabled="disabled"
v-bind="config.copyButtonConfig?.props"
@click="copyHandler" @click="copyHandler"
>复制</TMagicButton >{{ actionButtonText(config.copyButtonConfig, 'copy') }}</TMagicButton
> >
<template v-if="movable && !isCompare"> <template v-if="movable && !isCompare">
@ -30,8 +31,9 @@
size="default" size="default"
:disabled="disabled" :disabled="disabled"
:icon="Top" :icon="Top"
v-bind="config.moveUpButtonConfig?.props"
@click="changeOrder(-1)" @click="changeOrder(-1)"
>上移</TMagicButton >{{ actionButtonText(config.moveUpButtonConfig, 'moveUp') }}</TMagicButton
> >
<TMagicButton <TMagicButton
v-show="index !== length - 1" v-show="index !== length - 1"
@ -39,8 +41,9 @@
size="default" size="default"
:disabled="disabled" :disabled="disabled"
:icon="Bottom" :icon="Bottom"
v-bind="config.moveDownButtonConfig?.props"
@click="changeOrder(1)" @click="changeOrder(1)"
>下移</TMagicButton >{{ actionButtonText(config.moveDownButtonConfig, 'moveDown') }}</TMagicButton
> >
</template> </template>
@ -58,8 +61,9 @@
type="primary" type="primary"
:icon="Position" :icon="Position"
:disabled="disabled" :disabled="disabled"
v-bind="config.moveToButtonConfig?.props"
@click="moveSpecifyLocationVisible = true" @click="moveSpecifyLocationVisible = true"
>移动至</TMagicButton >{{ actionButtonText(config.moveToButtonConfig, 'moveTo') }}</TMagicButton
> >
</template> </template>
<div> <div>
@ -81,7 +85,10 @@
</TMagicPopover> </TMagicPopover>
<!-- v-if 必须挂在 Tooltip 上:子节点为 false 时 ElOnlyChild 会报 no valid child node found --> <!-- v-if 必须挂在 Tooltip 上:子节点为 false 时 ElOnlyChild 会报 no valid child node found -->
<TMagicTooltip v-if="!isCompare" :content="`删除 ${title}`"> <TMagicTooltip
v-if="!isCompare"
:content="`${actionButtonText(config.deleteButtonConfig, 'delete')} ${title}`"
>
<TMagicButton <TMagicButton
v-show="showDelete" v-show="showDelete"
size="default" size="default"
@ -89,10 +96,26 @@
class="delete-button" class="delete-button"
:icon="Delete" :icon="Delete"
:disabled="disabled" :disabled="disabled"
v-bind="config.deleteButtonConfig?.props"
@click="removeHandler" @click="removeHandler"
></TMagicButton> ></TMagicButton>
</TMagicTooltip> </TMagicTooltip>
<template v-if="!isCompare">
<TMagicButton
v-for="(item, i) in customButtons"
:key="i"
link
size="default"
type="primary"
:icon="item.button.icon"
:disabled="item.disabled"
v-bind="item.button.props"
@click="item.onClick"
>{{ item.button.text }}</TMagicButton
>
</template>
<span v-if="itemExtra" v-html="itemExtra" class="m-form-tip"></span> <span v-if="itemExtra" v-html="itemExtra" class="m-form-tip"></span>
</div> </div>
</div> </div>
@ -122,6 +145,7 @@ import { ArrowDown, ArrowRight, Bottom, Delete, DocumentCopy, Position, Top } fr
import { TMagicButton, TMagicCard, TMagicIcon, TMagicInputNumber, TMagicPopover, TMagicTooltip } from '@tmagic/design'; import { TMagicButton, TMagicCard, TMagicIcon, TMagicInputNumber, TMagicPopover, TMagicTooltip } from '@tmagic/design';
import type { ContainerChangeEventData, FormState, GroupListConfig } from '../schema'; import type { ContainerChangeEventData, FormState, GroupListConfig } from '../schema';
import { actionButtonText, resolveCustomActionButtons } from '../utils/actionButton';
import { appendProp, filterFunction } from '../utils/form'; import { appendProp, filterFunction } from '../utils/form';
import { getGroupListRowConfig } from '../utils/tableGroupList'; import { getGroupListRowConfig } from '../utils/tableGroupList';
@ -210,6 +234,22 @@ const movable = computed(() => {
return movable; return movable;
}); });
const customButtons = computed(() =>
resolveCustomActionButtons(
mForm,
props.config.actionButtons,
{
model: props.model,
index: props.index,
groupModel: props.groupModel,
prop: rowProp.value,
config: props.config,
formValue: mForm?.values || props.model,
},
props.disabled,
),
);
const copyable = computed(() => filterFunction<boolean>(mForm, props.config.copyable, props)); const copyable = computed(() => filterFunction<boolean>(mForm, props.config.copyable, props));
const onAddDiffCount = () => emit('addDiffCount'); const onAddDiffCount = () => emit('addDiffCount');

View File

@ -1,11 +1,16 @@
<template> <template>
<slot name="operateCol" :scope="{ $index: index, row: row }"></slot> <slot name="operateCol" :scope="{ $index: index, row: row }"></slot>
<TMagicTooltip v-if="config.dropSort && config.dropSortHandle" content="拖动排序" placement="left-start"> <TMagicTooltip
v-if="config.dropSort && config.dropSortHandle"
:content="actionButtonText(config.dropSortButtonConfig, 'dropSort')"
placement="left-start"
>
<TMagicButton <TMagicButton
size="small" size="small"
link link
:class="{ 'tmagic-form-table-drag-target': config.dropSortHandle }" :class="{ 'tmagic-form-table-drag-target': config.dropSortHandle }"
:icon="config.dropActionButtonIcon || Sort" :icon="config.dropActionButtonIcon || Sort"
v-bind="config.dropSortButtonConfig?.props"
> >
</TMagicButton> </TMagicButton>
</TMagicTooltip> </TMagicTooltip>
@ -15,11 +20,12 @@
size="small" size="small"
type="danger" type="danger"
link link
title="删除" :title="actionButtonText(config.deleteButtonConfig, 'delete')"
:icon="flat ? undefined : config.deleteActionButtonIcon || Delete" :icon="flat ? undefined : config.deleteActionButtonIcon || Delete"
v-bind="config.deleteButtonConfig?.props"
@click="removeHandler(index + 1 + currentPage * pageSize - 1)" @click="removeHandler(index + 1 + currentPage * pageSize - 1)"
> >
<template v-if="flat">删除</template> <template v-if="flat">{{ actionButtonText(config.deleteButtonConfig, 'delete') }}</template>
</TMagicButton> </TMagicButton>
<TMagicButton <TMagicButton
@ -27,23 +33,40 @@
link link
size="small" size="small"
type="primary" type="primary"
title="复制" :title="actionButtonText(config.copyButtonConfig, 'copy')"
:icon="flat ? undefined : config.copyActionButtonIcon || DocumentCopy" :icon="flat ? undefined : config.copyActionButtonIcon || DocumentCopy"
:disabled="disabled" :disabled="disabled"
v-bind="config.copyButtonConfig?.props"
@click="copyHandler(index + 1 + currentPage * pageSize - 1)" @click="copyHandler(index + 1 + currentPage * pageSize - 1)"
> >
<template v-if="flat">复制</template> <template v-if="flat">{{ actionButtonText(config.copyButtonConfig, 'copy') }}</template>
</TMagicButton>
<TMagicButton
v-for="(item, i) in customButtons"
:key="i"
link
size="small"
type="primary"
:title="item.button.text"
:icon="flat ? undefined : item.button.icon"
:disabled="item.disabled"
v-bind="item.button.props"
@click="item.onClick"
>
<template v-if="flat || !item.button.icon">{{ item.button.text }}</template>
</TMagicButton> </TMagicButton>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue'; import { computed, inject } from 'vue';
import { Delete, DocumentCopy, Sort } from '@element-plus/icons-vue'; import { Delete, DocumentCopy, Sort } from '@element-plus/icons-vue';
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import { TMagicButton, TMagicTooltip } from '@tmagic/design'; import { TMagicButton, TMagicTooltip } from '@tmagic/design';
import type { FormState, TableConfig } from '@form/schema'; import type { FormState, TableConfig } from '@form/schema';
import { actionButtonText, resolveCustomActionButtons } from '@form/utils/actionButton';
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
@ -63,6 +86,22 @@ const props = defineProps<{
const mForm = inject<FormState | undefined>('mForm'); const mForm = inject<FormState | undefined>('mForm');
const customButtons = computed(() =>
resolveCustomActionButtons(
mForm,
props.config.actionButtons,
{
model: props.row,
index: props.index + 1 + props.currentPage * props.pageSize - 1,
groupModel: props.model[props.name],
prop: props.prop,
config: props.config,
formValue: mForm?.values || props.model,
},
props.disabled,
),
);
const removeHandler = (index: number) => { const removeHandler = (index: number) => {
if (props.disabled) return; if (props.disabled) return;
emit('change', props.model[props.name].toSpliced(index, 1)); emit('change', props.model[props.name].toSpliced(index, 1));

View File

@ -5,6 +5,7 @@ import { type TableColumnOptions, TMagicIcon, TMagicTooltip } from '@tmagic/desi
import type { FormItemConfig, FormState } from '@tmagic/form-schema'; import type { FormItemConfig, FormState } from '@tmagic/form-schema';
import type { ContainerChangeEventData } from '@form/schema'; import type { ContainerChangeEventData } from '@form/schema';
import { actionButtonText } from '@form/utils/actionButton';
import { isGlobalFlat } from '@form/utils/config'; import { isGlobalFlat } from '@form/utils/config';
import { appendProp, display as displayFunc, getDataByPage, sortArray } from '@form/utils/form'; import { appendProp, display as displayFunc, getDataByPage, sortArray } from '@form/utils/form';
import { isTableColumnRendered, makeTableColumnConfig } from '@form/utils/tableGroupList'; import { isTableColumnRendered, makeTableColumnConfig } from '@form/utils/tableGroupList';
@ -112,11 +113,12 @@ export const useTableColumns = (
const actionColumn = { const actionColumn = {
props: { props: {
label: '操作',
fixed: actionFixed,
width: props.config.operateColWidth ?? (props.config.dropSortHandle && props.config.dropSort ? 132 : 112),
align: 'center', align: 'center',
fixed: actionFixed,
width: props.config.operateColWidth ?? (props.config.dropSortHandle && props.config.dropSort ? 132 : 112),
// props 覆盖列上的默认属性(宽度、对齐、fixed 等),标题仍以 text 为准
...props.config.operateColConfig?.props,
label: actionButtonText(props.config.operateColConfig, 'operate'),
}, },
cell: ({ row, $index }: any) => cell: ({ row, $index }: any) =>
h(ActionsColumn, { h(ActionsColumn, {

View File

@ -0,0 +1,67 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
ActionButtonConfig,
CustomActionButtonConfig,
CustomActionButtonContext,
FormState,
} from '@tmagic/form-schema';
/** table / group-list 行操作的内置文案。空 text 与未配置一样回退到这里,与 addButtonConfig 一致 */
export const DEFAULT_ACTION_BUTTON_TEXT = {
operate: '操作',
delete: '删除',
copy: '复制',
dropSort: '拖动排序',
moveUp: '上移',
moveDown: '下移',
moveTo: '移动至',
} as const;
export const actionButtonText = (
config: ActionButtonConfig | undefined,
key: keyof typeof DEFAULT_ACTION_BUTTON_TEXT,
): string => config?.text || DEFAULT_ACTION_BUTTON_TEXT[key];
export interface ResolvedCustomActionButton {
button: CustomActionButtonConfig;
disabled: boolean;
onClick: () => void;
}
/** 解析自定义行操作按钮:过滤掉不显示的,并计算禁用状态与点击回调 */
export const resolveCustomActionButtons = (
mForm: FormState | undefined,
buttons: CustomActionButtonConfig[] | undefined,
ctx: CustomActionButtonContext,
formDisabled?: boolean,
): ResolvedCustomActionButton[] => {
if (!Array.isArray(buttons)) return [];
return buttons
.filter(({ display }) => (typeof display === 'function' ? display(mForm, ctx) : (display ?? true)))
.map((button) => {
const { disabled } = button;
return {
button,
disabled: Boolean(formDisabled) || Boolean(typeof disabled === 'function' ? disabled(mForm, ctx) : disabled),
onClick: () => button.handler(mForm, ctx),
};
});
};

View File

@ -5,6 +5,7 @@
*/ */
import { describe, expect, test, vi } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
import { nextTick, reactive } from 'vue'; import { nextTick, reactive } from 'vue';
import { Sort } from '@element-plus/icons-vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus'; import ElementPlus from 'element-plus';
@ -29,14 +30,25 @@ const createMForm = (context: Record<string, any>): FormState =>
() => context, () => context,
); );
const mountColumn = (config: any, context: Record<string, any> = { env: 'prod' }) => const COMPONENT_PROP_KEYS = ['flat', 'disabled', 'sortKey', 'model'] as const;
mount(ActionsColumn as any, {
const mountColumn = (config: any, context: Record<string, any> = { env: 'prod' }) => {
const tableConfig = { ...config };
const extra: Record<string, any> = {};
for (const key of COMPONENT_PROP_KEYS) {
if (key in tableConfig) {
extra[key] = tableConfig[key];
delete tableConfig[key];
}
}
return mount(ActionsColumn as any, {
global: { global: {
plugins: [ElementPlus as any, MagicForm as any], plugins: [ElementPlus as any, MagicForm as any],
provide: { mForm: createMForm(context) }, provide: { mForm: createMForm(context) },
}, },
props: { props: {
config: { type: 'table', name: 'list', ...config }, config: { type: 'table', name: 'list', ...tableConfig },
model: { list: [{ text: 'a' }] }, model: { list: [{ text: 'a' }] },
name: 'list', name: 'list',
prop: 'list', prop: 'list',
@ -44,8 +56,10 @@ const mountColumn = (config: any, context: Record<string, any> = { env: 'prod' }
pageSize: 10, pageSize: 10,
index: 0, index: 0,
row: { text: 'a' }, row: { text: 'a' },
...extra,
}, },
}); });
};
describe('ActionsColumn', () => { describe('ActionsColumn', () => {
test('copyable 函数收到 model / index / prop', async () => { test('copyable 函数收到 model / index / prop', async () => {
@ -75,4 +89,122 @@ describe('ActionsColumn', () => {
expect(wrapper.text()).not.toContain('复制'); expect(wrapper.text()).not.toContain('复制');
}); });
test('未配置按钮文案时使用默认操作名称', async () => {
const wrapper = mountColumn({ flat: true, dropSort: true, dropSortHandle: true });
await nextTick();
expect(wrapper.text()).toContain('删除');
expect(wrapper.text()).toContain('复制');
expect(wrapper.findComponent({ name: 'TMTooltip' }).props('content')).toBe('拖动排序');
});
test('按钮配置与 addButtonConfig 一样,用 text 改名称、props 透传', async () => {
const wrapper = mountColumn({
flat: true,
dropSort: true,
dropSortHandle: true,
deleteButtonConfig: { text: '移除', props: { class: 'custom-delete', type: 'warning', size: 'large' } },
copyButtonConfig: { text: '克隆', props: { plain: true } },
dropSortButtonConfig: { text: '拖拽换序', props: { class: 'custom-drag' } },
});
await nextTick();
expect(wrapper.text()).toContain('移除');
expect(wrapper.text()).toContain('克隆');
expect(wrapper.text()).not.toContain('删除');
expect(wrapper.text()).not.toContain('复制');
expect(wrapper.find('.custom-delete').exists()).toBe(true);
expect(wrapper.find('.custom-drag').exists()).toBe(true);
const deleteButton = wrapper.findAllComponents({ name: 'TMButton' }).find((btn) => btn.props('type') === 'warning');
expect(deleteButton?.props('size')).toBe('large');
const copyButton = wrapper.findAllComponents({ name: 'TMButton' }).find((btn) => btn.text().includes('克隆'));
expect(copyButton?.find('button').classes()).toContain('is-plain');
expect(wrapper.findComponent({ name: 'TMTooltip' }).props('content')).toBe('拖拽换序');
expect(wrapper.findAll('button').map((btn) => btn.attributes('title'))).toEqual(
expect.arrayContaining(['移除', '克隆']),
);
});
test('delete 函数返回 false 时不展示删除,点击删除会移除当前行', async () => {
const deleteFn = vi.fn(() => false);
const hidden = mountColumn({ delete: deleteFn, flat: true });
await nextTick();
expect(deleteFn).toHaveBeenCalled();
expect((hidden.findAll('button')[0].element as HTMLElement).style.display).toBe('none');
const wrapper = mountColumn({ flat: true });
await nextTick();
await wrapper.findAll('button')[0].trigger('click');
expect(wrapper.emitted('change')?.[0][0]).toEqual([]);
});
test('disabled 时点击删除不改数据,复制会沿 sortKey 递减', async () => {
const blocked = mountColumn({ disabled: true, flat: true });
await nextTick();
await blocked.find('button').trigger('click');
expect(blocked.emitted('change')).toBeUndefined();
const wrapper = mountColumn({
flat: true,
sortKey: 'order',
copyable: true,
});
await wrapper.setProps({
model: {
list: [
{ text: 'a', order: 3 },
{ text: 'b', order: 1 },
],
},
});
await nextTick();
const copyButton = wrapper.findAll('button').find((btn) => btn.text().includes('复制'));
await copyButton?.trigger('click');
expect(wrapper.emitted('change')?.[0][0]).toEqual([
{ text: 'a', order: 3 },
{ text: 'b', order: 1 },
{ text: 'a', order: 0 },
]);
});
test('actionButtons 渲染自定义按钮,点击收到行与下标,禁用时不触发', async () => {
const handler = vi.fn();
const wrapper = mountColumn({
flat: true,
actionButtons: [
{ text: '详情', props: { class: 'custom-detail' }, handler },
{ text: '隐藏', display: false, handler },
{ text: '禁用', disabled: () => true, props: { class: 'custom-disabled' }, handler },
],
});
await nextTick();
expect(wrapper.text()).toContain('详情');
expect(wrapper.text()).not.toContain('隐藏');
expect(wrapper.find('.custom-detail').exists()).toBe(true);
await wrapper.find('.custom-detail').trigger('click');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][1]).toMatchObject({
model: { text: 'a' },
index: 0,
prop: 'list',
});
await wrapper.find('.custom-disabled').trigger('click');
expect(handler).toHaveBeenCalledTimes(1);
});
test('非 flat 且有 icon 时自定义按钮只显示图标,text 作为 title', async () => {
const wrapper = mountColumn({
actionButtons: [{ text: '详情', icon: Sort, props: { class: 'custom-icon' }, handler: () => undefined }],
});
await nextTick();
const btn = wrapper.find('.custom-icon');
expect(btn.exists()).toBe(true);
expect(btn.attributes('title')).toBe('详情');
expect(btn.text()).not.toContain('详情');
});
}); });

View File

@ -661,6 +661,112 @@ describe('GroupList container', () => {
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false); expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false);
}); });
test('行操作按钮用 text 改名称,形态与 addButtonConfig 相同', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
copyable: true,
movable: true,
moveSpecifyLocation: true,
titleKey: 'text',
titlePrefix: '条目',
copyButtonConfig: { text: '克隆', props: { class: 'custom-copy', type: 'success' } },
moveUpButtonConfig: { text: '前移', props: { class: 'custom-up' } },
moveDownButtonConfig: { text: '后移', props: { class: 'custom-down' } },
moveToButtonConfig: { text: '挪到', props: { class: 'custom-move' } },
deleteButtonConfig: { text: '移除', props: { class: 'custom-remove' } },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }, { text: 'b' }] },
);
await nextTick();
expect(wrapper.text()).toContain('克隆');
expect(wrapper.text()).toContain('前移');
expect(wrapper.text()).toContain('后移');
expect(wrapper.text()).toContain('挪到');
expect(wrapper.text()).not.toContain('复制');
expect(wrapper.find('.custom-copy').exists()).toBe(true);
expect(wrapper.find('.custom-up').exists()).toBe(true);
expect(wrapper.find('.custom-down').exists()).toBe(true);
expect(wrapper.find('.custom-move').exists()).toBe(true);
expect(wrapper.find('.custom-remove').exists()).toBe(true);
expect(
wrapper
.findAllComponents({ name: 'TMButton' })
.find((btn) => btn.text().includes('克隆'))
?.props('type'),
).toBe('success');
expect(wrapper.findComponent({ name: 'TMTooltip' }).props('content')).toBe('移除 条目 1: a');
await wrapper.find('.expand-button').trigger('click');
await wrapper
.findAll('button')
.find((btn) => btn.text().includes('挪到'))
?.trigger('click');
await wrapper
.findAll('button')
.find((btn) => btn.text().includes('取消'))
?.trigger('click');
await wrapper
.findAll('button')
.find((btn) => btn.text().includes('确认'))
?.trigger('click');
await nextTick();
expect((wrapper.vm as any).values.list.map((item: { text: string }) => item.text)).toEqual(['a', 'b']);
});
test('actionButtons 渲染自定义按钮并收到当前行上下文', async () => {
const handler = vi.fn();
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
actionButtons: [
{ text: '详情', props: { class: 'custom-detail' }, handler },
{ text: '隐藏', display: () => false, handler },
],
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }, { text: 'b' }] },
);
await nextTick();
expect(wrapper.text()).not.toContain('隐藏');
const buttons = wrapper.findAll('.custom-detail');
expect(buttons).toHaveLength(2);
await buttons[1].trigger('click');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][1]).toMatchObject({ index: 1, model: { text: 'b' } });
});
test('delete 函数可隐藏单行删除,title 函数作为卡片标题', async () => {
const deleteFn = vi.fn(() => false);
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
delete: deleteFn,
title: () => '自定义标题',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(deleteFn).toHaveBeenCalled();
expect((wrapper.find('.delete-button').element as HTMLElement).style.display).toBe('none');
expect(wrapper.text()).toContain('自定义标题');
});
test('嵌套列表各自按 header.sticky 决定是否吸顶', async () => { test('嵌套列表各自按 header.sticky 决定是否吸顶', async () => {
const wrapper = mountForm( const wrapper = mountForm(
[ [

View File

@ -0,0 +1,218 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick, reactive } from 'vue';
import { mount } from '@vue/test-utils';
import { useTableColumns } from '@form/containers/table/useTableColumns';
import { setConfig } from '@form/utils/config';
const Harness = defineComponent({
name: 'ColumnsHarness',
props: {
tableProps: { type: Object, required: true },
},
setup(props) {
const events: any[] = [];
const { columns } = useTableColumns(
props.tableProps as any,
(...args: any[]) => {
events.push(args);
},
props.tableProps.currentPage,
props.tableProps.pageSize,
props.tableProps.modelName,
);
return { columns, events };
},
render() {
return h('div');
},
});
const createState = (overrides: Record<string, any> = {}) => {
const currentPage = overrides.currentPage ?? { value: 0 };
const pageSize = overrides.pageSize ?? { value: 10 };
const modelName = overrides.modelName ?? { value: 'list' };
return reactive({
model: { list: [{ text: 'a' }, { text: 'b' }] },
lastValues: { list: [{ text: 'old-a' }, { text: 'old-b' }] },
name: 'list',
prop: 'list',
showIndex: false,
sort: false,
config: {
type: 'table',
name: 'list',
items: [{ name: 'text', label: '文本', type: 'text' }],
},
currentPage,
pageSize,
modelName,
...overrides,
config: {
type: 'table',
name: 'list',
items: [{ name: 'text', label: '文本', type: 'text' }],
...(overrides.config || {}),
},
});
};
const mountColumns = (state: ReturnType<typeof createState>, mForm?: any) =>
mount(Harness, {
props: { tableProps: state },
global: mForm ? { provide: { mForm } } : undefined,
});
const labels = (wrapper: ReturnType<typeof mountColumns>) =>
(wrapper.vm as any).columns.map((column: any) => column.props.label ?? column.props.type);
describe('useTableColumns', () => {
test('操作列标题默认为「操作」,operateColConfig.text 可替换', async () => {
setConfig({});
const defaults = mountColumns(createState());
await nextTick();
expect(labels(defaults)).toContain('操作');
const custom = mountColumns(
createState({
config: {
operateColWidth: 112,
fixed: 'left',
operateColConfig: { text: '动作', props: { width: 200, align: 'left', minWidth: 80, fixed: 'right' } },
},
}),
);
await nextTick();
const operateCol = (custom.vm as any).columns.find((column: any) => column.props.label === '动作');
expect(operateCol).toBeTruthy();
expect(labels(custom)).not.toContain('操作');
expect(operateCol.props).toMatchObject({ width: 200, align: 'left', minWidth: 80, fixed: 'right' });
});
test('展开列、选择列、排序列、序号列与数据列按配置拼出', async () => {
setConfig({ flat: true });
const itemExtra = vi.fn(() => '<em>extra</em>');
const titleTip = vi.fn(() => '提示');
const selection = vi.fn(() => true);
const mForm = { initValues: { list: [] }, values: { list: [{ text: 'form' }] }, $emit: vi.fn() };
const state = createState({
sort: true,
showIndex: true,
config: {
itemExtra,
selection,
showIndex: true,
flat: undefined,
fixed: 'right',
pagination: true,
items: [
{ name: 'hidden', type: 'hidden' },
{ name: 'skip', label: '跳过', display: false },
{ name: 'text', label: '文本', titleTip, itemsFunction: (row: any) => [{ name: 'inner', text: row.text }] },
],
},
});
const wrapper = mountColumns(state, mForm);
await nextTick();
const { columns } = wrapper.vm as any;
expect(labels(wrapper)).toEqual(['expand', 'selection', '排序', '序号', '文本', '操作']);
const extraVnode = columns[0].cell({ $index: 1 });
expect(itemExtra).toHaveBeenCalled();
expect(extraVnode.props.innerHTML).toBe('<em>extra</em>');
expect(extraVnode.props.class).toBe('m-form-tip');
const sortVnode = columns[2].cell({ $index: 1 });
sortVnode.props.onSwap(1, 0);
expect(mForm.$emit).toHaveBeenCalledWith('field-change', expect.any(Array));
expect((wrapper.vm as any).events.at(-1)[0]).toBe('change');
expect(String(columns[3].cell({ $index: 1 }).children)).toBe('2');
const dataVnode = columns[4].cell({ row: { text: 'a' }, $index: 0 });
expect(dataVnode.props.config.items).toEqual([{ name: 'inner', text: 'a' }]);
expect(dataVnode.props.lastValues).toEqual({ text: 'old-a' });
dataVnode.props.onChange({ text: 'next' }, { changeRecords: [] });
dataVnode.props.onAddDiffCount();
expect((wrapper.vm as any).events.map((event: any[]) => event[0])).toEqual(
expect.arrayContaining(['change', 'addDiffCount']),
);
const titleVnode = columns[4].title();
expect(titleTip).toHaveBeenCalled();
expect(titleVnode).toBeTruthy();
const actionVnode = columns[5].cell({ row: { text: 'a' }, $index: 0 });
expect(actionVnode.props.flat).toBe(true);
actionVnode.props.onChange([{ text: 'only' }]);
expect((wrapper.vm as any).events.at(-1)).toEqual(['change', [{ text: 'only' }]]);
const dropped = mountColumns(
createState({
config: {
fixed: false,
dropSort: true,
itemExtra: 'plain',
selection: false,
pagination: false,
items: [{ name: 'text', label: '文本', titleTip: '静态提示' }],
},
}),
mForm,
);
await nextTick();
const nextColumns = (dropped.vm as any).columns;
expect(nextColumns[0].props.label).toBe('操作');
expect(nextColumns[0].props.fixed).toBeUndefined();
expect(nextColumns.some((column: any) => column.props.type === 'expand')).toBe(false);
const plain = nextColumns.find((column: any) => column.props.prop === 'text');
expect(plain.props.class).toBe('el-table__column--dropable');
expect(plain.title()).toBeTruthy();
expect(plain.cell({ row: { text: 'a' }, $index: 0 }).props.lastValues).toEqual({ text: 'old-a' });
setConfig({});
});
test('选择函数返回 false、非法 fixed、全局非 flat 时不铺平操作列', async () => {
setConfig({});
const state = createState({
sort: true,
model: { list: [{ text: 'only' }] },
lastValues: {},
config: {
selection: () => false,
fixed: 'center',
items: [{ name: 'text', label: '文本', display: () => false }],
},
});
const wrapper = mountColumns(state);
await nextTick();
const { columns } = wrapper.vm as any;
expect(columns.map((column: any) => column.props.label ?? column.props.type)).toEqual(['操作']);
expect(columns[0].props.fixed).toBe('left');
const actionVnode = columns[0].cell({ row: { text: 'only' }, $index: 0 });
expect(actionVnode.props.flat).toBeUndefined();
const plain = mountColumns(
createState({
lastValues: {},
config: {
fixed: 'left',
items: [{ name: 'text', label: '文本' }],
},
}),
);
await nextTick();
const dataColumn = (plain.vm as any).columns.find((column: any) => column.props.prop === 'text');
expect(dataColumn.cell({ row: { text: 'only' }, $index: 0 }).props.lastValues).toBeUndefined();
expect(dataColumn.title).toBeUndefined();
});
});

View File

@ -0,0 +1,61 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test, vi } from 'vitest';
import { actionButtonText, DEFAULT_ACTION_BUTTON_TEXT, resolveCustomActionButtons } from '@form/utils/actionButton';
describe('actionButtonText', () => {
test('未配置时返回内置文案', () => {
expect(actionButtonText(undefined, 'operate')).toBe(DEFAULT_ACTION_BUTTON_TEXT.operate);
expect(actionButtonText({}, 'delete')).toBe('删除');
});
test('text 覆盖内置文案,空字符串回退', () => {
expect(actionButtonText({ text: '克隆' }, 'copy')).toBe('克隆');
expect(actionButtonText({ text: '' }, 'moveUp')).toBe('上移');
expect(actionButtonText({ text: '前移' }, 'moveDown')).toBe('前移');
});
});
describe('resolveCustomActionButtons', () => {
const ctx = { model: { a: 1 }, index: 0, groupModel: [{ a: 1 }], config: {}, formValue: {} };
test('未配置返回空数组', () => {
expect(resolveCustomActionButtons(undefined, undefined, ctx)).toEqual([]);
});
test('display 为 false 或函数返回 false 时过滤', () => {
const handler = vi.fn();
const display = vi.fn(() => false);
const result = resolveCustomActionButtons(
undefined,
[{ handler }, { handler, display: false }, { handler, display }, { handler, display: true }],
ctx,
);
expect(result).toHaveLength(2);
expect(display).toHaveBeenCalledWith(undefined, ctx);
});
test('disabled 支持静态值、函数与表单禁用', () => {
const handler = vi.fn();
const [a, b, c] = resolveCustomActionButtons(
undefined,
[{ handler }, { handler, disabled: () => true }, { handler, disabled: false }],
ctx,
);
expect([a.disabled, b.disabled, c.disabled]).toEqual([false, true, false]);
const [d] = resolveCustomActionButtons(undefined, [{ handler }], ctx, true);
expect(d.disabled).toBe(true);
});
test('onClick 调用 handler 并传入上下文', () => {
const handler = vi.fn();
const [item] = resolveCustomActionButtons(undefined, [{ handler }], ctx);
item.onClick();
expect(handler).toHaveBeenCalledWith(undefined, ctx);
});
});

View File

@ -71,6 +71,7 @@ export default class StageCore extends EventEmitter {
runtimeUrl: config.runtimeUrl, runtimeUrl: config.runtimeUrl,
zoom: config.zoom, zoom: config.zoom,
renderType: config.renderType, renderType: config.renderType,
iframeSandbox: config.iframeSandbox,
customizedRender: async (): Promise<HTMLElement | null | void> => { customizedRender: async (): Promise<HTMLElement | null | void> => {
if (this?.customizedRender) { if (this?.customizedRender) {
return await this.customizedRender(this); return await this.customizedRender(this);

View File

@ -38,13 +38,21 @@ export default class StageRender extends EventEmitter {
private runtimeUrl?: string; private runtimeUrl?: string;
private zoom = DEFAULT_ZOOM; private zoom = DEFAULT_ZOOM;
private renderType: RenderType; private renderType: RenderType;
private iframeSandbox?: string | false;
private customizedRender?: () => Promise<HTMLElement | null | void>; private customizedRender?: () => Promise<HTMLElement | null | void>;
constructor({ runtimeUrl, zoom, customizedRender, renderType = RenderType.IFRAME }: StageRenderConfig) { constructor({
runtimeUrl,
zoom,
customizedRender,
renderType = RenderType.IFRAME,
iframeSandbox,
}: StageRenderConfig) {
super(); super();
this.renderType = renderType; this.renderType = renderType;
this.runtimeUrl = runtimeUrl || ''; this.runtimeUrl = runtimeUrl || '';
this.iframeSandbox = iframeSandbox;
this.customizedRender = customizedRender; this.customizedRender = customizedRender;
this.setZoom(zoom); this.setZoom(zoom);
@ -194,7 +202,10 @@ export default class StageRender extends EventEmitter {
} }
public postTmagicRuntimeReady() { public postTmagicRuntimeReady() {
this.contentWindow = this.iframe?.contentWindow as RuntimeWindow; this.contentWindow = (this.iframe?.contentWindow as RuntimeWindow | null) ?? null;
// iframe 尚未就绪时 contentWindow 为空,等 load 后再注入 magic
if (!this.contentWindow) return;
this.contentWindow.magic = this.getMagicApi(); this.contentWindow.magic = this.getMagicApi();
@ -247,6 +258,11 @@ export default class StageRender extends EventEmitter {
private createIframe(): HTMLIFrameElement { private createIframe(): HTMLIFrameElement {
this.iframe = globalThis.document.createElement('iframe'); this.iframe = globalThis.document.createElement('iframe');
// 沙箱由使用方配置(空字符串也是合法取值,表示放开全部限制),必须在设置 src 之前写入,
// 否则首次导航不受沙箱约束
if (typeof this.iframeSandbox === 'string') {
this.iframe.setAttribute('sandbox', this.iframeSandbox);
}
// 同源,直接加载 // 同源,直接加载
this.iframe.src = this.runtimeUrl && isSameDomain(this.runtimeUrl) ? this.runtimeUrl : ''; this.iframe.src = this.runtimeUrl && isSameDomain(this.runtimeUrl) ? this.runtimeUrl : '';
this.iframe.style.cssText = ` this.iframe.style.cssText = `

View File

@ -104,6 +104,8 @@ export interface StageCoreConfig {
* 用于从图层树、面包屑等外部选中组件时,帮助用户快速定位组件在画布中的位置。 * 用于从图层树、面包屑等外部选中组件时,帮助用户快速定位组件在画布中的位置。
*/ */
disabledFlashTip?: boolean; disabledFlashTip?: boolean;
/** 画布 iframe 的 sandbox 属性值,见 StageRenderConfig.iframeSandbox */
iframeSandbox?: string | false;
} }
export interface ActionManagerConfig { export interface ActionManagerConfig {
@ -153,6 +155,17 @@ export interface StageRenderConfig {
runtimeUrl?: string; runtimeUrl?: string;
zoom: number | undefined; zoom: number | undefined;
renderType?: RenderType; renderType?: RenderType;
/**
* iframe 渲染时给画布 iframe 设置的 sandbox 属性值
*
* - 不传 / `false`:不设置 sandbox 属性(默认,与未引入该配置前行为一致)
* - 字符串:原样写入 sandbox 属性,token 用空格分隔,如
* `'allow-scripts allow-same-origin allow-forms allow-popups allow-downloads'`
*
* 注意:沙箱缺哪个 token,iframe 内对应能力就会静默失效(下载、弹窗、表单提交等),
* 编辑器自身需要 `allow-scripts` + `allow-same-origin`,否则画布不可用。
*/
iframeSandbox?: string | false;
customizedRender?: () => Promise<HTMLElement | null | void>; customizedRender?: () => Promise<HTMLElement | null | void>;
} }

View File

@ -100,6 +100,21 @@ describe('StageCore', () => {
stage.destroy(); stage.destroy();
}); });
test('iframeSandbox 透传到 iframe 的 sandbox 属性', async () => {
const host = globalThis.document.createElement('div');
globalThis.document.body.appendChild(host);
const sandbox = 'allow-scripts allow-same-origin';
const stage = new StageCore({
renderType: RenderType.IFRAME,
runtimeUrl: '',
iframeSandbox: sandbox,
disabledRule: true,
});
await stage.mount(host);
expect(host.querySelector('iframe')!.getAttribute('sandbox')).toBe(sandbox);
stage.destroy();
});
test('select 选中节点并触发 flash', async () => { test('select 选中节点并触发 flash', async () => {
const { host, page, stage } = createStage(); const { host, page, stage } = createStage();
await stage.mount(host); await stage.mount(host);

View File

@ -133,6 +133,47 @@ describe('StageRender - NATIVE', () => {
renderer.destroy(); renderer.destroy();
}); });
test('未配置 iframeSandbox 时不设置 sandbox 属性', async () => {
const host = globalThis.document.createElement('div');
globalThis.document.body.appendChild(host);
const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '' });
await renderer.mount(host);
expect(host.querySelector('iframe')!.hasAttribute('sandbox')).toBe(false);
renderer.destroy();
});
test('iframeSandbox 为 false 时不设置 sandbox 属性', async () => {
const host = globalThis.document.createElement('div');
globalThis.document.body.appendChild(host);
const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '', iframeSandbox: false });
await renderer.mount(host);
expect(host.querySelector('iframe')!.hasAttribute('sandbox')).toBe(false);
renderer.destroy();
});
test('iframeSandbox 配置的 token 原样写入 sandbox 属性', async () => {
const host = globalThis.document.createElement('div');
globalThis.document.body.appendChild(host);
const sandbox = 'allow-scripts allow-same-origin allow-downloads';
const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '', iframeSandbox: sandbox });
await renderer.mount(host);
expect(host.querySelector('iframe')!.getAttribute('sandbox')).toBe(sandbox);
renderer.destroy();
});
test('空字符串 iframeSandbox 也写入 sandbox 属性(放开全部限制)', async () => {
const host = globalThis.document.createElement('div');
globalThis.document.body.appendChild(host);
const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '', iframeSandbox: '' });
await renderer.mount(host);
expect(host.querySelector('iframe')!.getAttribute('sandbox')).toBe('');
renderer.destroy();
});
test('getElementImage 找到元素时调用 snapdom', async () => { test('getElementImage 找到元素时调用 snapdom', async () => {
const page = globalThis.document.createElement('div'); const page = globalThis.document.createElement('div');
setIdToEl()(page, 'img-node'); setIdToEl()(page, 'img-node');
@ -154,6 +195,24 @@ describe('StageRender - NATIVE', () => {
renderer.destroy(); renderer.destroy();
}); });
test('contentWindow 未就绪时 mount 不抛错,就绪后注入 magic', async () => {
const host = globalThis.document.createElement('div');
globalThis.document.body.appendChild(host);
const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '' });
await expect(renderer.mount(host)).resolves.toBeUndefined();
expect(renderer.contentWindow).toBeNull();
const postMessage = vi.fn();
Object.defineProperty(renderer.iframe!, 'contentWindow', {
configurable: true,
value: { postMessage, document: globalThis.document },
});
renderer.postTmagicRuntimeReady();
expect(postMessage).toHaveBeenCalledWith({ tmagicRuntimeReady: true }, '*');
expect(renderer.contentWindow?.magic).toBeDefined();
renderer.destroy();
});
test('IFRAME getElementsFromPoint 考虑 iframe 偏移', async () => { test('IFRAME getElementsFromPoint 考虑 iframe 偏移', async () => {
const host = globalThis.document.createElement('div'); const host = globalThis.document.createElement('div');
globalThis.document.body.appendChild(host); globalThis.document.body.appendChild(host);

View File

@ -5,6 +5,7 @@
ref="editor" ref="editor"
:menu="menu" :menu="menu"
:runtime-url="runtimeUrl" :runtime-url="runtimeUrl"
:iframe-sandbox="iframeSandbox"
:props-configs="propsConfigs" :props-configs="propsConfigs"
:props-values="propsValues" :props-values="propsValues"
:event-method-list="eventMethodList" :event-method-list="eventMethodList"
@ -83,6 +84,22 @@ uiService.set('propsPanelSize', 'default');
const datasourceList: DatasourceTypeOption[] = []; const datasourceList: DatasourceTypeOption[] = [];
const runtimeUrl = `${VITE_RUNTIME_PATH}/playground/index.html`; const runtimeUrl = `${VITE_RUNTIME_PATH}/playground/index.html`;
/**
* 画布 iframe 的 sandbox 配置(较宽松的演示值,非推荐值),演示「是否加、加哪些 token」由使用方决定:
* - 改成 `false` 即不设置 sandbox 属性(与未配置时一致)
* - 画布需要 allow-scripts + allow-same-origin(编辑器要读写 iframe 内的 DOM),
* allow-downloads 用于画布内组件的下载,其余 token 保留表单提交、弹窗、新窗口打开能力
*/
const iframeSandbox = [
'allow-downloads',
'allow-forms',
'allow-modals',
'allow-popups',
'allow-popups-to-escape-sandbox',
'allow-same-origin',
'allow-scripts',
].join(' ');
const { propsValues, propsConfigs, eventMethodList, datasourceConfigs, datasourceValues, datasourceEventMethodList } = const { propsValues, propsConfigs, eventMethodList, datasourceConfigs, datasourceValues, datasourceEventMethodList } =
useEditorRes(); useEditorRes();
const { contentMenuData } = useEditorContentMenuData(); const { contentMenuData } = useEditorContentMenuData();

1860
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -18,12 +18,12 @@ allowBuilds:
esbuild: true esbuild: true
catalog: catalog:
"@vue/compiler-sfc": ^3.5.43 "@vue/compiler-sfc": ^3.5.44
typescript: "^6.0.3" typescript: "^6.0.3"
vite: ^8.3.0 vite: ^8.3.4
vue: ^3.5.43 vue: ^3.5.44
element-plus: ^2.14.6 element-plus: ^2.14.7
overrides: overrides:
element-plus: ^2.14.6 element-plus: ^2.14.7
vue: ^3.5.43 vue: ^3.5.44