Compare commits

...

27 Commits

Author SHA1 Message Date
roymondchen
b564c9e4aa fix(editor): 切换动作类型时清空关联配置字段 2026-07-24 17:17:55 +08:00
roymondchen
b5abf31066 fix(editor): 修复 collectIdle 批次结算与中断时 Promise 挂起问题
引入批次级 Promise 结算,避免快速连续触发或 clearIdleTasks 后 collecting 卡死。
collectIdle 返回 boolean 表示是否完整完成,initService 据此跳过被中断的 stage 更新。
2026-07-24 17:17:55 +08:00
roymondchen
bf8df74864 fix(form): 完善 mountFormInstance 清理逻辑,避免临时表单实例泄漏
补充 mount 全流程异常清理、非正数 timeout 兜底,并支持 AbortSignal 主动中断 debug 弹层。
2026-07-24 16:51:42 +08:00
roymondchen
a37e0487c6 chore: update lockfile v1.8.0-beta.16 2026-07-23 22:12:42 +08:00
roymondchen
329cd947e1 chore: release v1.8.0-beta.16 2026-07-23 22:11:18 +08:00
roymondchen
0e1986c819 feat: 参考建议可选值最多展示个数由 5 调整为 20 2026-07-23 22:07:51 +08:00
roymondchen
d0a22315c0 refactor(form): 抽离表单校验逻辑至 validateForm 并复用于 props 面板
- 将 editor 的 validatePropsForm 逻辑迁移到 @tmagic/form 的 validateForm
- FormPanel 源码保存改为复用 validateForm 静默校验
- 补充 form 校验与 applyExtendState 相关单元测试
2026-07-23 22:02:43 +08:00
roymondchen
fe7815cf3d chore: update lockfile v1.8.0-beta.15 2026-07-23 20:25:53 +08:00
roymondchen
bf9078a458 chore: release v1.8.0-beta.15 2026-07-23 20:24:47 +08:00
roymondchen
3b9d5da0bc feat(form,editor): submitForm/validateForm 静默挂载时注入静默标记,vs-code 字段跳过 monaco 渲染 2026-07-23 20:22:47 +08:00
roymondchen
2e031cc134 feat(design): 优化 FormItem 和 colorPicker 样式 2026-07-23 19:26:54 +08:00
roymondchen
1f389ff9fd feat(form): text 类型校验允许数字值,兼容数据源字段配置 2026-07-23 19:04:04 +08:00
roymondchen
fe9e754b50 feat(editor): 数据源字段选择器校验提示细化并支持 fieldConfig 类型校验 2026-07-23 19:03:28 +08:00
roymondchen
745f449396 feat: 新增 page-structure-change 事件,支持页面增删统一通知 2026-07-23 19:00:12 +08:00
manmanyu
48abab7051 feat(form,design): 一行多个表单默认右靠齐label 2026-07-23 08:44:10 +00:00
roymondchen
11bf037054 fix: 跳过无字段名称配置的类型校验 2026-07-23 16:32:07 +08:00
roymondchen
97e0cac44a fix(core): 为背景图片 URL 添加引号 2026-07-23 15:40:58 +08:00
roymondchen
cde0805292 fix(editor): 使用属性面板中的源码删除样式无效 2026-07-23 15:15:19 +08:00
roymondchen
6919a0a0b1 fix(editor): 右键菜单动态变化后修复菜单位置防止截断 2026-07-23 15:13:26 +08:00
roymondchen
d9d2f8f04c chore: update lockfile v1.8.0-beta.14 2026-07-23 12:13:40 +08:00
roymondchen
3fbce585a4 chore: release v1.8.0-beta.14 2026-07-23 12:12:18 +08:00
roymondchen
09eecd5bed fix(stage): 修复选中组件时 stage 外层滚动 2026-07-23 12:09:13 +08:00
roymondchen
ee5247ca94 feat: playground 支持主题切换 2026-07-22 19:03:05 +08:00
roymondchen
8699dc12af fix: 调整编辑器配置项展示 2026-07-22 18:39:10 +08:00
manmanyu
b13cd3425b feat(design,editor): 新增magic-admin主题样式,优化组件表单配置布局样式 2026-07-22 09:31:26 +00:00
roymondchen
89689bb9fd fix(editor): 保留历史操作来源并补发插入事件 2026-07-22 17:17:42 +08:00
roymondchen
0feada8d1f fix(form): 统一 extendState 合并逻辑 2026-07-22 16:51:07 +08:00
168 changed files with 3847 additions and 898 deletions

View File

@ -1,3 +1,43 @@
# [1.8.0-beta.16](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.15...v1.8.0-beta.16) (2026-07-23)
### Features
* 参考建议可选值最多展示个数由 5 调整为 20 ([0e1986c](https://github.com/Tencent/tmagic-editor/commit/0e1986c81998e50461c294c1a27a8139e8ae9a13))
# [1.8.0-beta.15](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.14...v1.8.0-beta.15) (2026-07-23)
### Bug Fixes
* **core:** 为背景图片 URL 添加引号 ([97e0cac](https://github.com/Tencent/tmagic-editor/commit/97e0cac44a2d6ceafaf95fbc4ae716bfe5d47f70))
* **editor:** 使用属性面板中的源码删除样式无效 ([cde0805](https://github.com/Tencent/tmagic-editor/commit/cde08052924ddef1c51faeb68ce15d3195827adb))
* **editor:** 右键菜单动态变化后修复菜单位置防止截断 ([6919a0a](https://github.com/Tencent/tmagic-editor/commit/6919a0a0b19efff92821fc8d52b61b2f7382a817))
* 跳过无字段名称配置的类型校验 ([11bf037](https://github.com/Tencent/tmagic-editor/commit/11bf037054774937132743a40006364e49ee8fd5))
### Features
* **design:** 优化 FormItem 和 colorPicker 样式 ([2e031cc](https://github.com/Tencent/tmagic-editor/commit/2e031cc1346fb6ea739f0d45a89213b6577dc7ee))
* **editor:** 数据源字段选择器校验提示细化并支持 fieldConfig 类型校验 ([fe9e754](https://github.com/Tencent/tmagic-editor/commit/fe9e754b50f14e6034170b2284553ec1c4536150))
* **form,design:** 一行多个表单默认右靠齐label ([48abab7](https://github.com/Tencent/tmagic-editor/commit/48abab7051b93b7ed336172c21ab3b908733f2e7))
* **form,editor:** submitForm/validateForm 静默挂载时注入静默标记vs-code 字段跳过 monaco 渲染 ([3b9d5da](https://github.com/Tencent/tmagic-editor/commit/3b9d5da0bc9f323e944c0e2adb4fc88642ce1915))
* **form:** text 类型校验允许数字值,兼容数据源字段配置 ([1f389ff](https://github.com/Tencent/tmagic-editor/commit/1f389ff9fd77fb93ad749f3fdbbc8aa0fe8725ac))
* 新增 page-structure-change 事件,支持页面增删统一通知 ([745f449](https://github.com/Tencent/tmagic-editor/commit/745f44939605528e8e2ccd0639478db98293a441))
# [1.8.0-beta.14](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.13...v1.8.0-beta.14) (2026-07-23)
### Bug Fixes
* **editor:** 保留历史操作来源并补发插入事件 ([89689bb](https://github.com/Tencent/tmagic-editor/commit/89689bb9fd2d2d3edcc027b87932cbdd3a5755ad))
* **form:** 统一 extendState 合并逻辑 ([0feada8](https://github.com/Tencent/tmagic-editor/commit/0feada8d1fa4057444da746040b3824df09eef07))
* **stage:** 修复选中组件时 stage 外层滚动 ([09eecd5](https://github.com/Tencent/tmagic-editor/commit/09eecd5bedd49e5a002fa5ef122eaa1579dc895b))
* 调整编辑器配置项展示 ([8699dc1](https://github.com/Tencent/tmagic-editor/commit/8699dc12af9d52b1c460f61f3d3f48e4cf3fc5f6))
### Features
* **design,editor:** 新增magic-admin主题样式,优化组件表单配置布局样式 ([b13cd34](https://github.com/Tencent/tmagic-editor/commit/b13cd3425b16eaeac33f897bf51e80f7984db782))
* playground 支持主题切换 ([ee5247c](https://github.com/Tencent/tmagic-editor/commit/ee5247ca9436c385a5b634d161726006912fe77c))
# [1.8.0-beta.13](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.11...v1.8.0-beta.13) (2026-07-22) # [1.8.0-beta.13](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.11...v1.8.0-beta.13) (2026-07-22)

View File

@ -162,7 +162,7 @@
} }
&:hover { &:hover {
color: #409eff; color: var(--el-color-primary,#409eff);;
background-color: #f9fafc; background-color: #f9fafc;
} }

View File

@ -16,7 +16,7 @@ export default defineConfig([
'*/**/public/**/*', '*/**/public/**/*',
'*/**/types/**/*', '*/**/types/**/*',
'*/**/*.config.ts', '*/**/*.config.ts',
'./tepm/**/*', './temp/**/*',
'vite-env.d.ts', 'vite-env.d.ts',
]), ]),
...eslintConfig(path.join(path.dirname(fileURLToPath(import.meta.url)), 'tsconfig.json')), ...eslintConfig(path.join(path.dirname(fileURLToPath(import.meta.url)), 'tsconfig.json')),

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.13", "version": "1.8.0-beta.16",
"name": "@tmagic/cli", "name": "@tmagic/cli",
"main": "lib/index.js", "main": "lib/index.js",
"types": "lib/index.d.ts", "types": "lib/index.d.ts",

View File

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

View File

@ -51,7 +51,7 @@ export const style2Obj = (style: string) => {
export const fillBackgroundImage = (value: string) => { export const fillBackgroundImage = (value: string) => {
if (value && !/^url/.test(value) && !/^linear-gradient/.test(value)) { if (value && !/^url/.test(value) && !/^linear-gradient/.test(value)) {
return `url(${value})`; return `url("${value}")`;
} }
return value; return value;
}; };

View File

@ -50,7 +50,7 @@ describe('style2Obj', () => {
describe('fillBackgroundImage', () => { describe('fillBackgroundImage', () => {
test('裸路径会包裹 url()', () => { test('裸路径会包裹 url()', () => {
expect(fillBackgroundImage('a.png')).toBe('url(a.png)'); expect(fillBackgroundImage('a.png')).toBe('url("a.png")');
}); });
test('已经是 url() 不重复包裹', () => { test('已经是 url() 不重复包裹', () => {
@ -127,7 +127,7 @@ describe('transformStyle', () => {
test('backgroundImage: browser 下补全 url()', () => { test('backgroundImage: browser 下补全 url()', () => {
expect(transformStyle({ backgroundImage: 'a.png' }, 'browser')).toEqual({ expect(transformStyle({ backgroundImage: 'a.png' }, 'browser')).toEqual({
backgroundImage: 'url(a.png)', backgroundImage: 'url("a.png")',
}); });
}); });

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.13", "version": "1.8.0-beta.16",
"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.13", "version": "1.8.0-beta.16",
"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.13", "version": "1.8.0-beta.16",
"name": "@tmagic/design", "name": "@tmagic/design",
"type": "module", "type": "module",
"sideEffects": [ "sideEffects": [

View File

@ -0,0 +1,23 @@
<template>
<component class="tmagic-design-alert" :is="uiComponent" v-bind="uiProps">
<slot></slot>
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { getDesignConfig } from './config';
import type { AlertProps } from './types';
defineOptions({
name: 'TMAlert',
});
const props = defineProps<AlertProps>();
const ui = getDesignConfig('components')?.alert;
const uiComponent = ui?.component || 'el-alert';
const uiProps = computed<AlertProps>(() => ui?.props(props) || props);
</script>

View File

@ -1,5 +1,5 @@
<template> <template>
<component class="tmagic-design-card" :is="uiComponent" v-bind="uiProps"> <component :is="uiComponent" :class="['tmagic-design-card', { 'tmagic-design-card--flat': isFlat }]" v-bind="uiProps">
<template #header v-if="$slots.header"> <template #header v-if="$slots.header">
<slot name="header" class="header"></slot> <slot name="header" class="header"></slot>
</template> </template>
@ -14,6 +14,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { getDesignConfig } from './config'; import { getDesignConfig } from './config';
import { isGlobalFlat } from './index';
import type { CardProps } from './types'; import type { CardProps } from './types';
defineOptions({ defineOptions({
@ -26,5 +27,14 @@ const ui = getDesignConfig('components')?.card;
const uiComponent = ui?.component || 'el-card'; const uiComponent = ui?.component || 'el-card';
const uiProps = computed<CardProps>(() => ui?.props(props) || props); // `<MEditor>` / `<MForm>` theme `magic-admin`
// `flat=true` `flat`
const isFlat = computed(() => !!props.flat || isGlobalFlat.value);
// `flat` UI el-card / t-card props
// / DOM
const uiProps = computed<CardProps>(() => {
const { flat: _flat, ...rest } = props;
return ui?.props(rest as CardProps) || (rest as CardProps);
});
</script> </script>

View File

@ -7,18 +7,39 @@
@update:modelValue="updateModelValue" @update:modelValue="updateModelValue"
> >
</component> </component>
<MInput
v-if="isFlat && !isLargeStageContainer"
@update:modelValue="updateModelValue"
@change="changeHandler"
v-bind="uiProps"
class="tmagic-design-color-picker-input"
clearable
></MInput>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, type ComputedRef, inject } from 'vue';
import { getDesignConfig } from './config'; import { getDesignConfig } from './config';
import { isGlobalFlat } from './index';
import MInput from './Input.vue';
import type { ColorPickerProps } from './types'; import type { ColorPickerProps } from './types';
defineOptions({ defineOptions({
name: 'TMColorPicker', name: 'TMColorPicker',
}); });
const isFlat = computed(() => !!props.flat || isGlobalFlat.value);
// `<MEditor>` computed provide props.isLargeStageContainer
// `ComputedRef<boolean>` `<script setup>`
// `!isLargeStageContainer` computed
// `false` `ComputedRef`
const isLargeStageContainer = inject<ComputedRef<boolean>>(
'isLargeStageContainer',
computed(() => false),
);
const props = withDefaults(defineProps<ColorPickerProps>(), { const props = withDefaults(defineProps<ColorPickerProps>(), {
showAlpha: false, showAlpha: false,
disabled: false, disabled: false,

View File

@ -1,12 +1,24 @@
<template> <template>
<component class="tmagic-design-form-item" :is="uiComponent" v-bind="uiProps"> <component
class="tmagic-design-form-item"
:class="{ 'has-extra-tips': adapterType === 'element-plus' && extraTips }"
:is="uiComponent"
v-bind="uiProps"
>
<template #label> <template #label>
<slot name="label"></slot> <slot name="label"> </slot>
</template> </template>
<template #default> <template #default>
<slot></slot> <slot></slot>
<div v-if="adapterType === 'element-plus' && extra" v-html="extra" class="m-form-tip"></div> <alert
v-if="adapterType === 'element-plus' && extraTips"
:title="extraTips"
type="warning"
show-icon
:closable="false"
></alert>
<div v-else-if="adapterType === 'element-plus' && extra" v-html="extra" class="m-form-tip"></div>
</template> </template>
<template v-if="adapterType === 'element-plus'" #error="{ error }"> <template v-if="adapterType === 'element-plus'" #error="{ error }">
@ -16,26 +28,32 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, inject } from 'vue';
import Alert from './Alert.vue';
import { getDesignConfig } from './config'; import { getDesignConfig } from './config';
import { stripValidateSuggestion } from './formValidateMessage'; import { stripValidateSuggestion } from './formValidateMessage';
import { isGlobalFlat } from './index';
import type { FormItemProps } from './types'; import type { FormItemProps } from './types';
defineOptions({ defineOptions({
name: 'TMFormItem', name: 'TMFormItem',
}); });
const props = defineProps<FormItemProps>(); const props = defineProps<FormItemProps & { theme?: string }>();
const ui = getDesignConfig('components')?.formItem; const ui = getDesignConfig('components')?.formItem;
const uiComponent = ui?.component || 'el-form-item'; const uiComponent = ui?.component || 'el-form-item';
const adapterType = getDesignConfig('adapterType'); const adapterType = getDesignConfig('adapterType');
const formInline = inject<boolean>('formInline', false);
const formInRow = inject<boolean>('isInRow', false);
const uiProps = computed<FormItemProps>(() => { const uiProps = computed<FormItemProps>(() => {
const { extra, ...rest } = ui?.props(props) || props; const { extra, extraTips, ...rest } = ui?.props(props) || props;
if (isGlobalFlat.value && rest.labelPosition === undefined) {
return { ...rest, labelPosition: formInline || formInRow ? 'right' : 'left' };
}
return rest; return rest;
}); });

View File

@ -4,10 +4,9 @@
<div <div
v-if="popoverVisible || !destroyOnClose" v-if="popoverVisible || !destroyOnClose"
v-show="popoverVisible" v-show="popoverVisible"
class="tmagic-design-popper"
ref="popperElementRef" ref="popperElementRef"
:tabindex="tabindex" :tabindex="tabindex"
:class="popperClass" :class="['tmagic-design-popper', popperClass, themeClass]"
:style="style" :style="style"
@mouseenter.once="popperMouseenterHandler" @mouseenter.once="popperMouseenterHandler"
> >
@ -22,7 +21,7 @@ import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref
import type { Instance } from '@popperjs/core'; import type { Instance } from '@popperjs/core';
import { createPopper } from '@popperjs/core'; import { createPopper } from '@popperjs/core';
import { useZIndex } from './index'; import { useThemeClass, useZIndex } from './index';
import type { PopoverProps } from './types'; import type { PopoverProps } from './types';
defineSlots<{ defineSlots<{
@ -46,6 +45,12 @@ const props = withDefaults(defineProps<PopoverProps>(), {
closeOnClickOutside: true, closeOnClickOutside: true,
}); });
/**
* 主题修饰类来自最近的 `<MEditor>` / `<MForm>` 祖先 provide
* 挂在 `Teleport` 出去的 popper 根节点上让主题级 CSS 变量`--el-color-primary`
* portal 节点上也能命中详见 `@tmagic/design/theme.ts`
*/
const themeClass = useThemeClass();
const emit = defineEmits<{ const emit = defineEmits<{
/** 受控模式(传入了 visible下点击外部收起时触发便于配合 v-model:visible。 */ /** 受控模式(传入了 visible下点击外部收起时触发便于配合 v-model:visible。 */
'update:visible': [_visible: boolean]; 'update:visible': [_visible: boolean];

View File

@ -8,6 +8,7 @@ import './theme/index.scss';
export * from './types'; export * from './types';
export * from './config'; export * from './config';
export * from './theme';
export * from './formValidateMessage'; export * from './formValidateMessage';
export { default as TMagicAutocomplete } from './Autocomplete.vue'; export { default as TMagicAutocomplete } from './Autocomplete.vue';
@ -94,6 +95,10 @@ export let useZIndex = (zIndexOverrides?: Ref<number>) => {
}; };
}; };
// 与 form/utils/config、editor/utils/config 里的同名变量保持一致:用 ref 持有 flat 全局开关,
// const 引用本身不可变,规避 `no-mutable-exports` / `naming-convention` 对 `let` 模块变量的限制。
export const isGlobalFlat = ref(false);
export default { export default {
install(app: App, options: DesignPluginOptions) { install(app: App, options: DesignPluginOptions) {
tMagicMessage = tMagicMessage =
@ -129,6 +134,8 @@ export default {
useZIndex = options.useZIndex; useZIndex = options.useZIndex;
} }
isGlobalFlat.value = options.flat ?? false;
if (options.adapterType && globalThis.document?.documentElement) { if (options.adapterType && globalThis.document?.documentElement) {
globalThis.document.documentElement.classList.add(`tmagic-adapter-${options.adapterType}`); globalThis.document.documentElement.classList.add(`tmagic-adapter-${options.adapterType}`);
} }

View File

@ -0,0 +1,59 @@
/*
* 穿 portal
*
* `<MEditor>` / `<MForm>` provide `theme` `<Teleport>`
* `useThemeClass()` inject
* CSS `--el-color-primary` body portal
*
* portal `m-editor` `m-form`使
* `m-theme--<theme>` CSS
* editor / form / portal editor / form
* `.m-editor.m-editor--<theme>` / `.m-form.m-form--<theme>`
*
*/
import type { ComputedRef, InjectionKey, Ref } from 'vue';
import { computed, inject } from 'vue';
/** 祖先 `<MEditor>` / `<MForm>` theme props.theme MForm
* theme `<MEditor>` Form.vue effectiveTheme */
export const M_THEME_KEY: InjectionKey<Ref<string>> = Symbol('mTheme');
/**
* `<MEditor>` / `<MForm>` provide class
* `"m-theme--magic-admin"`
*
* -
* - `m-editor` / `m-form` portal
* editor / form DOM
*
* 使 `<Teleport>` `TMagicPopover` / `FloatingBox` /
* `ContentMenu` CSS
*/
export const useThemeClass = (): ComputedRef<string> => {
const theme = inject(M_THEME_KEY, null);
return computed(() => {
const t = theme?.value;
return t ? `m-theme--${t}` : '';
});
};
/**
* theme `prop.theme` 退
* `<MEditor>` / `<MForm>` provide
*
* `prop` `theme?: string` `defineProps()`
* reactive proxycomputed
* `toRefs`
*
* `prop.theme`
*
* @example
* const props = defineProps<{ theme?: string }>();
* const theme = useTheme(props);
* // theme.value -> 'magic-admin' 或 ''
*/
export const useTheme = <T extends { theme?: string }>(prop: T): ComputedRef<string> => {
const injected = inject(M_THEME_KEY, null);
return computed(() => prop.theme || injected?.value || '');
};

View File

@ -0,0 +1,67 @@
// `TMagicCard` 铺平模式去掉卡片自身的视觉表达背景 / 阴影 / 边框 / 圆角 /
// header 分隔线 / header & body 内边距让卡片在视觉上与外层容器融为一体
//
// 不动 body-style不动折叠相关 prop 这些上层用法仍可正常工作
// `is-always-shadow` el-card `shadow="always"` 时附加的类需要单独再压一次
.tmagic-design-card.tmagic-design-card--flat {
background: #fff;
box-shadow: none !important;
border-top: 0;
border-left: 0;
border-right: 0;
border-radius: 0;
border-bottom: 0;
&.m-fields-group-list-item {
margin-bottom: 0;
}
&.is-always-shadow {
box-shadow: none;
}
.el-card__header {
border-bottom: none;
padding: 16px;
&:hover {
background: transparent;
box-shadow: none;
cursor: default;
}
}
.el-card__body {
padding: 0 16px 0 16px;
// 这里每个form-item应该要有一个16px间距但是除了tbody中的form-item
.tmagic-design-form-item:not(tbody .tmagic-design-form-item) {
margin-bottom: 16px;
}
> .m-container-row {
overflow-x: hidden;
}
}
.tmagic-design-card--flat {
// .el-card__body {
// padding: 0;
// }
// .el-card__header {
// padding: 0 16px;
// }
}
}
.m-split-title-card-container {
> .tmagic-design-card.tmagic-design-card--flat {
background-color: transparent;
> .el-card__header {
margin-bottom: 10px;
padding: 0;
font-size: 16px;
font-weight: 500;
line-height: 24px;
}
> .el-card__body {
background-color: #fff;
padding-top: 16px;
}
}
}

View File

@ -0,0 +1,8 @@
.tmagic-design-color-picker-input {
margin-left: 8px;
width: calc(var(--el-input-width, 100%) - 40px);
}
.tmagic-design-color-picker {
flex: 0 0 auto;
}

View File

@ -1 +1,3 @@
@use "./popover.scss"; @use "./popover.scss";
@use "./card.scss";
@use "./colorPicker.scss";

View File

@ -0,0 +1,38 @@
@use "../../index.scss";
.m-form.m-form--magic-admin {
.el-collapse-item__header {
background-color: transparent;
padding: 0;
border: 0;
padding-left: 0;
font-size: 16px;
font-weight: 500;
line-height: 24px;
margin-top: 8px;
margin-bottom: 8px;
}
}
.tmagic-design-form-item {
&.has-extra-tips {
> .el-form-item__content {
gap: 10px;
> .tmagic-design-alert {
border-radius: 2px;
width: fit-content;
padding: 0 8px;
color: var(--el-color-primary, #0056ea);
background-color: var(--el-color-primary-light-9, rgb(236, 245, 255));
.el-icon.el-alert__icon {
fill: var(--el-color-primary, #0056ea);
color: var(--el-color-primary, #0056ea);
}
.el-alert__title {
font-size: 14px;
line-height: 22px;
}
}
}
}
}

View File

@ -24,6 +24,17 @@ export interface BadgeProps {
hidden?: boolean; hidden?: boolean;
} }
export interface AlertProps {
title?: string;
/** ElAlert 实际支持的 type与 ButtonProps.type 不同,没有 `primary` / `danger` */
type?: 'success' | 'warning' | 'info' | 'error' | 'primary';
description?: string;
closable?: boolean;
center?: boolean;
closeText?: string;
showIcon?: boolean;
effect?: 'light' | 'dark';
}
export interface ButtonProps { export interface ButtonProps {
type?: string; type?: string;
size?: FieldSize; size?: FieldSize;
@ -39,6 +50,11 @@ export interface CardProps {
bodyStyle?: Record<string, any>; bodyStyle?: Record<string, any>;
shadow?: string; shadow?: string;
header?: string; header?: string;
/**
* / / / / header 线 / header body
* 使TMagicCard prop 使
*/
flat?: boolean;
} }
export interface CascaderProps { export interface CascaderProps {
@ -97,6 +113,7 @@ export interface ColorPickerProps {
disabled?: boolean; disabled?: boolean;
showAlpha?: boolean; showAlpha?: boolean;
size?: FieldSize; size?: FieldSize;
flat?: boolean;
} }
export interface DatePickerProps { export interface DatePickerProps {
@ -185,7 +202,9 @@ export interface FormItemProps {
labelWidth?: string | number; labelWidth?: string | number;
rules?: any; rules?: any;
extra?: string; extra?: string;
extraTips?: string;
labelPosition?: 'top' | 'left' | 'right'; labelPosition?: 'top' | 'left' | 'right';
text?: string;
} }
export interface InputProps { export interface InputProps {
@ -464,6 +483,11 @@ export interface Components {
props: (props: BadgeProps) => BadgeProps; props: (props: BadgeProps) => BadgeProps;
}; };
alert: {
component: DefineComponent<AlertProps, {}, any> | string;
props: (props: AlertProps) => AlertProps;
};
autocomplete: { autocomplete: {
component: DefineComponent<AutocompleteProps, {}, any> | string; component: DefineComponent<AutocompleteProps, {}, any> | string;
props: (props: AutocompleteProps) => AutocompleteProps; props: (props: AutocompleteProps) => AutocompleteProps;
@ -726,6 +750,7 @@ export interface Components {
} }
export interface DesignPluginOptions { export interface DesignPluginOptions {
flat?: boolean;
adapterType?: string; adapterType?: string;
message?: TMagicMessage; message?: TMagicMessage;
messageBox?: TMagicMessageBox; messageBox?: TMagicMessageBox;

View File

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

View File

@ -4,6 +4,7 @@
:page-bar-sort-options="pageBarSortOptions" :page-bar-sort-options="pageBarSortOptions"
:page-filter-function="pageFilterFunction" :page-filter-function="pageFilterFunction"
:hide-sidebar="hideSidebar" :hide-sidebar="hideSidebar"
:theme="theme"
> >
<template #header> <template #header>
<slot name="header"></slot> <slot name="header"></slot>
@ -135,9 +136,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { provide, ref } from 'vue'; import { computed, provide, ref } from 'vue';
import type { MApp } from '@tmagic/core'; import type { MApp } from '@tmagic/core';
import { M_THEME_KEY } from '@tmagic/design';
import Framework from './layouts/Framework.vue'; import Framework from './layouts/Framework.vue';
import TMagicNavMenu from './layouts/NavMenu.vue'; import TMagicNavMenu from './layouts/NavMenu.vue';
@ -241,6 +243,15 @@ provide(ENABLE_PROPS_FORM_VALIDATE, props.enablePropsFormValidate ?? false);
* PropsPanel 通过 `:extend-state` 显式传入的方式保持等价 * PropsPanel 通过 `:extend-state` 显式传入的方式保持等价
*/ */
provide('extendFormState', props.extendFormState); provide('extendFormState', props.extendFormState);
// computed provide provide
// props.isLargeStageContainer @tmagic/design/ColorPicker
// `inject<ComputedRef<boolean>>('isLargeStageContainer', computed(() => false))`
// `.value`
provide(
'isLargeStageContainer',
computed(() => props.isLargeStageContainer),
);
/** /**
* 提供 PropsPanel 主属性表单的 formState getter供历史差异弹窗复用 * 提供 PropsPanel 主属性表单的 formState getter供历史差异弹窗复用
* CompareForm PropsPanel filterFunction 上下文保持一致 * CompareForm PropsPanel filterFunction 上下文保持一致
@ -256,6 +267,16 @@ provide('historyListExtraTabs', props.historyListExtraTabs);
provide<EventBus>('eventBus', new EventEmitter()); provide<EventBus>('eventBus', new EventEmitter());
/**
* 把当前主题以响应式 ref 形式 provide 给后代供包含 `Teleport` 的组件
* `TMagicPopover` / `FloatingBox` / `ContentMenu`在传送目标上挂 `m-theme--<theme>`
* 让主题级 CSS 变量在 portal 节点上也能命中详见 `@tmagic/design/theme.ts`
*/
provide(
M_THEME_KEY,
computed(() => props.theme ?? ''),
);
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => { const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
propsPanelRef.value = e; propsPanelRef.value = e;
emit('props-panel-mounted', e); emit('props-panel-mounted', e);

View File

@ -2,8 +2,8 @@
<transition name="fade"> <transition name="fade">
<div <div
v-show="visible" v-show="visible"
class="magic-editor-content-menu"
ref="menu" ref="menu"
:class="['magic-editor-content-menu', themeClass]"
:style="menuStyle" :style="menuStyle"
@mouseenter="mouseenterHandler()" @mouseenter="mouseenterHandler()"
@contextmenu.prevent @contextmenu.prevent
@ -39,7 +39,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, type Ref, ref, useTemplateRef } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, type Ref, ref, useTemplateRef } from 'vue';
import { useZIndex } from '@tmagic/design'; import { useThemeClass, useZIndex } from '@tmagic/design';
import { MenuButton, MenuComponent } from '@editor/type'; import { MenuButton, MenuComponent } from '@editor/type';
@ -73,6 +73,14 @@ const menuEl = useTemplateRef<HTMLDivElement>('menu');
const buttonRefs = useTemplateRef<InstanceType<typeof ToolButton>[]>('buttons'); const buttonRefs = useTemplateRef<InstanceType<typeof ToolButton>[]>('buttons');
const subMenuRef = useTemplateRef<any>('subMenu'); const subMenuRef = useTemplateRef<any>('subMenu');
const visible = ref(false); const visible = ref(false);
/**
* 主题修饰类来自最近的 `<MEditor>` / `<MForm>` 祖先 provide
* - 顶层 `ContentMenu` 渲染在编辑器子树中加上去是冗余但无害
* - 子菜单递归 `<content-menu :is-sub-menu="true">` `<teleport to="body">` 送到 body
* 不在编辑器子树中必须显式挂上主题类主题级 CSS 变量才能命中
*/
const themeClass = useThemeClass();
const subMenuData: Ref<(MenuButton | MenuComponent)[]> = ref<(MenuButton | MenuComponent)[]>([]); const subMenuData: Ref<(MenuButton | MenuComponent)[]> = ref<(MenuButton | MenuComponent)[]>([]);
const zIndex = useZIndex(); const zIndex = useZIndex();
const curZIndex = ref<number>(0); const curZIndex = ref<number>(0);
@ -120,20 +128,42 @@ const outsideClickHideHandler = (e: MouseEvent) => {
hide(); hide();
}; };
const setPosition = (e: { clientY: number; clientX: number }) => { //
const menuHeight = menuEl.value?.clientHeight || 0; const fixPosition = () => {
const menu = menuEl.value;
if (!menu || !visible.value) return;
let top = e.clientY; const menuHeight = menu.clientHeight;
if (menuHeight + e.clientY > document.body.clientHeight) { const menuWidth = menu.clientWidth;
top = document.body.clientHeight - menuHeight;
let { top, left } = menuPosition.value;
if (top + menuHeight > document.body.clientHeight) {
top = Math.max(0, document.body.clientHeight - menuHeight);
} }
if (left + menuWidth > document.body.clientWidth) {
left = Math.max(0, document.body.clientWidth - menuWidth);
}
if (top !== menuPosition.value.top || left !== menuPosition.value.left) {
menuPosition.value = { top, left };
}
};
const setPosition = (e: { clientY: number; clientX: number }) => {
menuPosition.value = { menuPosition.value = {
top, top: e.clientY,
left: e.clientX, left: e.clientX,
}; };
fixPosition();
}; };
//
const resizeObserver = new ResizeObserver(() => {
fixPosition();
});
const show = (e?: { clientY: number; clientX: number }) => { const show = (e?: { clientY: number; clientX: number }) => {
visible.value = true; visible.value = true;
@ -178,12 +208,18 @@ const mouseenterHandler = () => {
}; };
onMounted(() => { onMounted(() => {
if (menuEl.value) {
resizeObserver.observe(menuEl.value);
}
if (props.isSubMenu) return; if (props.isSubMenu) return;
globalThis.addEventListener('mousedown', outsideClickHideHandler, true); globalThis.addEventListener('mousedown', outsideClickHideHandler, true);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
resizeObserver.disconnect();
if (props.isSubMenu) return; if (props.isSubMenu) return;
globalThis.removeEventListener('mousedown', outsideClickHideHandler, true); globalThis.removeEventListener('mousedown', outsideClickHideHandler, true);

View File

@ -2,7 +2,7 @@
<Teleport to="body" v-if="visible"> <Teleport to="body" v-if="visible">
<div <div
ref="target" ref="target"
class="m-editor-float-box" :class="['m-editor-float-box', themeClass]"
v-bind="$attrs" v-bind="$attrs"
:style="{ ...style, zIndex: curZIndex }" :style="{ ...style, zIndex: curZIndex }"
@mousedown="nextZIndex" @mousedown="nextZIndex"
@ -27,7 +27,7 @@ import { computed, type CSSProperties, nextTick, onBeforeUnmount, provide, ref,
import { Close } from '@element-plus/icons-vue'; import { Close } from '@element-plus/icons-vue';
import VanillaMoveable from 'moveable'; import VanillaMoveable from 'moveable';
import { TMagicButton, useZIndex } from '@tmagic/design'; import { TMagicButton, useThemeClass, useZIndex } from '@tmagic/design';
import MIcon from '@editor/components/Icon.vue'; import MIcon from '@editor/components/Icon.vue';
@ -62,6 +62,12 @@ const props = withDefaults(
const targetEl = useTemplateRef<HTMLDivElement>('target'); const targetEl = useTemplateRef<HTMLDivElement>('target');
const titleEl = useTemplateRef<HTMLDivElement>('title'); const titleEl = useTemplateRef<HTMLDivElement>('title');
/**
* 主题修饰类来自最近的 `<MEditor>` / `<MForm>` 祖先 provide
* 挂在 `Teleport` 出去的浮动面板根节点上让主题级 CSS 变量在 portal 节点上也能命中
*/
const themeClass = useThemeClass();
const zIndex = useZIndex(); const zIndex = useZIndex();
const curZIndex = ref<number>(0); const curZIndex = ref<number>(0);

View File

@ -35,6 +35,10 @@ import type {
export const ENABLE_PROPS_FORM_VALIDATE: InjectionKey<boolean> = Symbol('enablePropsFormValidate'); export const ENABLE_PROPS_FORM_VALIDATE: InjectionKey<boolean> = Symbol('enablePropsFormValidate');
export interface EditorProps { export interface EditorProps {
/** 是否是大屏模拟器容器大屏容器时左侧属性面板会扩展为两列正常3列颜色表单不扩展两列等 */
isLargeStageContainer?: boolean;
theme?: string;
/** 页面初始值 */ /** 页面初始值 */
modelValue?: MApp; modelValue?: MApp;
/** 左侧面板中的组件类型列表 */ /** 左侧面板中的组件类型列表 */

View File

@ -1,5 +1,6 @@
<template> <template>
<MagicCodeEditor <MagicCodeEditor
v-if="!silentMode"
:height="config.height" :height="config.height"
:type="diffMode ? 'diff' : undefined" :type="diffMode ? 'diff' : undefined"
:init-values="diffMode ? (lastValues || {})[name] : model[name]" :init-values="diffMode ? (lastValues || {})[name] : model[name]"
@ -17,9 +18,9 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed, inject } from 'vue';
import type { CodeConfig, FieldProps } from '@tmagic/form'; import { type CodeConfig, type FieldProps, FORM_SILENT_MODE_KEY } from '@tmagic/form';
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue'; import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
@ -27,6 +28,13 @@ defineOptions({
name: 'MFieldsVsCode', name: 'MFieldsVsCode',
}); });
/**
* 静默模式submitForm/validateForm 隐藏挂载下跳过 monaco 渲染
* 校验由 FormItem 针对 model 中的值完成与编辑器实例无关本组件挂载无值副作用
* save 仅由用户操作/编辑器内容变更触发跳过可省去 monaco worker/model 的无谓实例化
*/
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const emit = defineEmits<{ const emit = defineEmits<{
change: [value: string | any]; change: [value: string | any];
}>(); }>();

View File

@ -1,9 +1,10 @@
<template> <template>
<div class="m-fields-code-select" :class="config.className"> <div class="m-fields-code-select" :class="config.className">
<TMagicCard> <TMagicCard :flat="config.flat">
<MContainer <MContainer
:config="codeConfig" :config="codeConfig"
:size="size" :size="size"
class="code-select-content"
:prop="prop" :prop="prop"
:disabled="disabled" :disabled="disabled"
:is-compare="isCompareMode" :is-compare="isCompareMode"
@ -12,16 +13,20 @@
@change="changeHandler" @change="changeHandler"
> >
</MContainer> </MContainer>
<TMagicButton class="create-button fullWidth" :icon="Plus" :size="size" :disabled="disabled" @click="newHandler()"
>添加{{ config.text }}</TMagicButton
>
</TMagicCard> </TMagicCard>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import { Plus } from '@element-plus/icons-vue';
import { isEmpty } from 'lodash-es'; import { isEmpty } from 'lodash-es';
import { HookCodeType, HookType } from '@tmagic/core'; import { HookCodeType, HookType } from '@tmagic/core';
import { TMagicCard } from '@tmagic/design'; import { TMagicButton, TMagicCard } from '@tmagic/design';
import type { CodeSelectConfig, ContainerChangeEventData, FieldProps, GroupListConfig } from '@tmagic/form'; import type { CodeSelectConfig, ContainerChangeEventData, FieldProps, GroupListConfig } from '@tmagic/form';
import { MContainer } from '@tmagic/form'; import { MContainer } from '@tmagic/form';
@ -53,12 +58,23 @@ const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
* 仅当存在历史值时才启用对比避免 lastValues 缺失时退化为全部新增的空对比 * 仅当存在历史值时才启用对比避免 lastValues 缺失时退化为全部新增的空对比
*/ */
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues)); const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
const newHandler = () => {
const defaultCode = {
codeType: HookCodeType.CODE,
codeId: '',
};
const name = props.config.name || '';
const hookData = props.model[name]?.hookData || [];
emit('change', defaultCode, {
modifyKey: `hookData.${hookData.length}`,
});
};
const codeConfig = computed<GroupListConfig>(() => ({ const codeConfig = computed<GroupListConfig>(() => ({
type: 'group-list', type: 'group-list',
name: 'hookData', name: 'hookData',
enableToggleMode: false, enableToggleMode: false,
expandAll: true, expandAll: true,
addable: () => false,
title: (mForm, { model, index }: any) => { title: (mForm, { model, index }: any) => {
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) { if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
if (Array.isArray(model.codeId)) { if (Array.isArray(model.codeId)) {
@ -81,49 +97,44 @@ const codeConfig = computed<GroupListConfig>(() => ({
return model.codeId || index; return model.codeId || index;
}, },
titlePrefix: props.config.name === undefined ? undefined : String(props.config.name),
items: [ items: [
{ {
type: 'row', text: '代码类型',
items: [ type: 'select',
{ name: 'codeType',
type: 'select', labelPosition: 'right',
name: 'codeType', rules: [{ typeMatch: true, trigger: 'change' }],
span: 6, options: [
options: [ { value: HookCodeType.CODE, text: '代码块' },
{ value: HookCodeType.CODE, text: '代码块' }, { value: HookCodeType.DATA_SOURCE_METHOD, text: '数据源方法' },
{ value: HookCodeType.DATA_SOURCE_METHOD, text: '数据源方法' },
],
rules: [{ typeMatch: true, trigger: 'change' }],
defaultValue: HookCodeType.CODE,
onChange: (_mForm, v: HookCodeType, { setModel }) => {
if (v === HookCodeType.DATA_SOURCE_METHOD) {
setModel('codeId', []);
} else {
setModel('codeId', '');
}
return v;
},
},
{
type: 'code-select-col',
name: 'codeId',
span: 18,
labelWidth: 0,
display: (_mForm, { model }) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !codeBlockService.getEditStatus(),
rules: [{ typeMatch: true, trigger: 'change' }],
},
{
type: 'data-source-method-select',
name: 'codeId',
span: 18,
labelWidth: 0,
display: (_mForm, { model }) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !dataSourceService.get('editable'),
rules: [{ typeMatch: true, trigger: 'change' }],
},
], ],
defaultValue: HookCodeType.CODE,
onChange: (_mForm, v: HookCodeType, { setModel }) => {
if (v === HookCodeType.DATA_SOURCE_METHOD) {
setModel('codeId', []);
} else {
setModel('codeId', '');
}
return v;
},
},
{
type: 'code-select-col',
name: 'codeId',
text: '代码块',
rules: [{ typeMatch: true, trigger: 'change' }],
display: (_mForm, { model }) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !codeBlockService.getEditStatus(),
},
{
type: 'data-source-method-select',
name: 'codeId',
text: '数据源字段',
rules: [{ typeMatch: true, trigger: 'change' }],
display: (_mForm, { model }) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !dataSourceService.get('editable'),
}, },
], ],
})); }));

View File

@ -147,6 +147,7 @@ const selectConfig: SelectConfig = {
type: 'select', type: 'select',
name: props.name, name: props.name,
disabled: props.disabled, disabled: props.disabled,
text: props.config.text,
options: () => { options: () => {
if (codeDsl.value) { if (codeDsl.value) {
return map(codeDsl.value, (value, key) => ({ return map(codeDsl.value, (value, key) => ({

View File

@ -1,7 +1,12 @@
<template> <template>
<div class="m-fields-data-source-field-select"> <div
class="m-fields-data-source-field-select"
:class="{
[`data-source-field-${type}`]: !isSelectValid,
}"
>
<FieldSelect <FieldSelect
v-if="!disabledDataSource && (showDataSourceFieldSelect || !config.fieldConfig)" v-if="isSelectValid"
:model-value="model[name]" :model-value="model[name]"
:disabled="disabled" :disabled="disabled"
:size="size" :size="size"
@ -37,15 +42,14 @@
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
@click="onToggleDataSourceFieldSelectHandler" @click="onToggleDataSourceFieldSelectHandler"
><MIcon :icon="Coin"></MIcon ><MIcon :icon="dataSourceIcon"></MIcon>
></TMagicButton> </TMagicButton>
</TMagicTooltip> </TMagicTooltip>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, ref, resolveComponent, watch } from 'vue'; import { computed, inject, ref, resolveComponent, watch } from 'vue';
import { Coin } from '@element-plus/icons-vue';
import { DataSchema } from '@tmagic/core'; import { DataSchema } from '@tmagic/core';
import { TMagicButton, tMagicMessage, TMagicTooltip } from '@tmagic/design'; import { TMagicButton, tMagicMessage, TMagicTooltip } from '@tmagic/design';
@ -61,6 +65,8 @@ import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, removeDataSourceFieldPrefix } f
import MIcon from '@editor/components/Icon.vue'; import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services'; import { useServices } from '@editor/hooks/use-services';
import dataSourceIcon from '../../icons/DatasourceIcon.vue';
import FieldSelect from './FieldSelect.vue'; import FieldSelect from './FieldSelect.vue';
defineOptions({ defineOptions({
@ -75,6 +81,9 @@ const props = withDefaults(defineProps<FieldProps<DataSourceFieldSelectConfig>>(
const showDataSourceFieldSelect = ref(false); const showDataSourceFieldSelect = ref(false);
const isSelectValid = computed(
() => !disabledDataSource.value && (showDataSourceFieldSelect.value || !props.config.fieldConfig),
);
watch( watch(
() => props.model[props.name], () => props.model[props.name],
(value) => { (value) => {

View File

@ -68,12 +68,15 @@ const config = computed<GroupListConfig>(() => ({
titlePrefix: props.config.titlePrefix, titlePrefix: props.config.titlePrefix,
expandAll: true, expandAll: true,
enableToggleMode: false, enableToggleMode: false,
flat: props.config.flat,
items: [ items: [
{ {
type: 'table', type: 'table',
name: 'cond', name: 'cond',
operateColWidth: 80, operateColWidth: props.config.operateColWidth,
enableToggleMode: false, enableToggleMode: false,
fixed: props.config.fixed,
flat: props.config.flat,
items: [ items: [
parentFields.value.length parentFields.value.length
? { ? {

View File

@ -12,16 +12,21 @@
@change="onChangeHandler" @change="onChangeHandler"
></MTable> ></MTable>
<div v-else class="fullWidth"> <div v-else class="fullWidth event-select-container">
<TMagicButton <div class="event-select-header">
v-if="!isCompareMode" <div class="event-select-title">事件配置</div>
class="create-button" <TMagicButton
type="primary" v-if="!isCompareMode && displayList.length > 0"
:size="size" class="create-button"
:disabled="disabled" text
@click="addEvent()" type="primary"
>添加事件</TMagicButton :icon="Plus"
> :size="size"
:disabled="disabled"
@click="addEvent()"
>添加事件</TMagicButton
>
</div>
<MPanel <MPanel
v-for="entry in displayList" v-for="entry in displayList"
:key="entry.index" :key="entry.index"
@ -32,32 +37,45 @@
:model="entry.cardItem" :model="entry.cardItem"
:last-values="entry.lastCardItem" :last-values="entry.lastCardItem"
:is-compare="isCompareMode" :is-compare="isCompareMode"
:hide-expand="true"
:label-width="config.labelWidth || '100px'" :label-width="config.labelWidth || '100px'"
@change="onChangeHandler" @change="onChangeHandler"
> >
<template #header> <template #header>
<MFormContainer <div class="event-item-header">
class="fullWidth" <div class="event-item-title">事件{{ Number(entry.index) + 1 }}</div>
:config="eventNameConfig" <MFormContainer
:model="entry.cardItem" class="fullWidth"
:last-values="entry.lastCardItem" :config="eventNameConfig"
:is-compare="isCompareMode" :model="entry.cardItem"
:disabled="disabled" :last-values="entry.lastCardItem"
:size="size" :is-compare="isCompareMode"
:prop="`${prop}.${entry.index}`" :disabled="disabled"
@change="eventNameChangeHandler" :size="size"
></MFormContainer> :prop="`${prop}.${entry.index}`"
<TMagicButton @change="eventNameChangeHandler"
v-if="!isCompareMode" ></MFormContainer>
style="color: #f56c6c" <TMagicButton
link class="event-item-delete-button"
:icon="Delete" v-if="!isCompareMode"
:disabled="disabled" link
:size="size" :icon="Delete"
@click="removeEvent(Number(entry.index))" :disabled="disabled"
></TMagicButton> :size="size"
@click="removeEvent(Number(entry.index))"
></TMagicButton>
</div>
</template> </template>
</MPanel> </MPanel>
<TMagicButton
v-if="!isCompareMode"
class="create-button fullWidth"
:icon="Plus"
:disabled="disabled"
@click="addEvent()"
>添加事件</TMagicButton
>
</div> </div>
</div> </div>
</template> </template>
@ -65,6 +83,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { Delete } from '@element-plus/icons-vue'; import { Delete } from '@element-plus/icons-vue';
import { Plus } from '@element-plus/icons-vue';
import { has } from 'lodash-es'; import { has } from 'lodash-es';
import { ActionType } from '@tmagic/core'; import { ActionType } from '@tmagic/core';
@ -108,7 +127,7 @@ const { editorService, dataSourceService, eventsService, codeBlockService, props
const eventNameConfig = computed(() => { const eventNameConfig = computed(() => {
const defaultEventNameConfig = { const defaultEventNameConfig = {
name: 'name', name: 'name',
text: '事件', text: '事件类型',
type: (mForm: FormState | undefined, { formValue }: any) => { type: (mForm: FormState | undefined, { formValue }: any) => {
if ( if (
props.config.src !== 'component' || props.config.src !== 'component' ||
@ -118,7 +137,7 @@ const eventNameConfig = computed(() => {
} }
return 'select'; return 'select';
}, },
labelWidth: '40px', labelWidth: '70px',
checkStrictly: () => props.config.src !== 'component', checkStrictly: () => props.config.src !== 'component',
valueSeparator: '.', valueSeparator: '.',
options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(props.config.src, formValue), options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(props.config.src, formValue),
@ -178,6 +197,7 @@ const actionTypeConfig = computed(() => {
name: 'actionType', name: 'actionType',
text: '联动类型', text: '联动类型',
type: 'select', type: 'select',
labelPosition: 'left',
defaultValue: ActionType.COMP, defaultValue: ActionType.COMP,
options: actionTypeOptions.value, options: actionTypeOptions.value,
rules: [ rules: [
@ -190,6 +210,12 @@ const actionTypeConfig = computed(() => {
trigger: 'blur', trigger: 'blur',
}, },
], ],
onChange: (_mForm: FormState, _v: string, { setModel }: any) => {
setModel('to', '');
setModel('method', '');
setModel('codeId', '');
setModel('dataSourceMethod', []);
},
}; };
return { ...defaultActionTypeConfig, ...props.config.actionTypeConfig }; return { ...defaultActionTypeConfig, ...props.config.actionTypeConfig };
}); });
@ -200,8 +226,9 @@ const targetCompConfig = computed(() => {
name: 'to', name: 'to',
text: '联动组件', text: '联动组件',
type: 'ui-select', type: 'ui-select',
labelPosition: 'left',
display: (_mForm, { model }) => model.actionType === ActionType.COMP, display: (_mForm, { model }) => model.actionType === ActionType.COMP,
onChange: (_MForm, _v, { setModel }) => { onChange: (_mForm, _v, { setModel }) => {
setModel('method', ''); setModel('method', '');
}, },
rules: [ rules: [
@ -219,6 +246,7 @@ const compActionConfig = computed(() => {
const defaultCompActionConfig: DynamicTypeConfig = { const defaultCompActionConfig: DynamicTypeConfig = {
name: 'method', name: 'method',
text: '动作', text: '动作',
labelPosition: 'left',
type: (mForm: FormState | undefined, { model }: any) => { type: (mForm: FormState | undefined, { model }: any) => {
const to = editorService.getNodeById(model.to); const to = editorService.getNodeById(model.to);
@ -317,6 +345,7 @@ const actionsConfig = computed(
() => () =>
defineFormItem({ defineFormItem({
type: 'panel', type: 'panel',
labelPosition: 'left',
items: [ items: [
{ {
type: 'group-list', type: 'group-list',
@ -324,6 +353,7 @@ const actionsConfig = computed(
expandAll: true, expandAll: true,
enableToggleMode: false, enableToggleMode: false,
titlePrefix: '动作', titlePrefix: '动作',
labelPosition: 'left',
items: [ items: [
actionTypeConfig.value, actionTypeConfig.value,
targetCompConfig.value, targetCompConfig.value,

View File

@ -2,7 +2,7 @@
<TMagicCollapse class="m-fields-style-setter" v-model="collapseValue"> <TMagicCollapse class="m-fields-style-setter" v-model="collapseValue">
<template v-for="(item, index) in list" :key="index"> <template v-for="(item, index) in list" :key="index">
<TMagicCollapseItem :name="`${index}`"> <TMagicCollapseItem :name="`${index}`">
<template #title><MIcon :icon="Grid"></MIcon>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<component <component
v-if="item.component" v-if="item.component"
:is="item.component" :is="item.component"
@ -22,14 +22,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { shallowRef } from 'vue'; import { shallowRef } from 'vue';
import { Grid } from '@element-plus/icons-vue';
import { TMagicCollapse, TMagicCollapseItem } from '@tmagic/design'; import { TMagicCollapse, TMagicCollapseItem } from '@tmagic/design';
import type { ContainerChangeEventData, FieldProps } from '@tmagic/form'; import type { ContainerChangeEventData, FieldProps } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema'; import type { StyleSchema } from '@tmagic/schema';
import MIcon from '@editor/components/Icon.vue';
import { Background, Border, Font, Layout, Position, Transform } from './pro/'; import { Background, Border, Font, Layout, Position, Transform } from './pro/';
defineOptions({ defineOptions({

View File

@ -15,7 +15,7 @@
@click="selectDirection('Left')" @click="selectDirection('Left')"
></div> ></div>
<div <div
class="border-icon" class="border-icon center"
:class="{ active: direction === '', configured: isConfigured('') }" :class="{ active: direction === '', configured: isConfigured('') }"
@click="selectDirection()" @click="selectDirection()"
></div> ></div>

View File

@ -0,0 +1,19 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M12 4C12.5523 4 13 4.44772 13 5V11C13 11.5523 12.5523 12 12 12H4C3.44772 12 3 11.5523 3 11V5C3 4.44772 3.44772 4 4 4H12ZM4.5 5C4.22386 5 4 5.22386 4 5.5V10.5C4 10.7761 4.22386 11 4.5 11H11.5C11.7761 11 12 10.7761 12 10.5V5.5C12 5.22386 11.7761 5 11.5 5H4.5Z"
/>
<rect
x="13.75"
y="7.75"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 7.75)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -0,0 +1,19 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M12 3C12.5523 3 13 3.44772 13 4V10C13 10.5523 12.5523 11 12 11H4C3.44772 11 3 10.5523 3 10V4C3 3.44772 3.44772 3 4 3H12ZM4.5 4C4.22386 4 4 4.22386 4 4.5V9.5C4 9.77614 4.22386 10 4.5 10H11.5C11.7761 10 12 9.77614 12 9.5V4.5C12 4.22386 11.7761 4 11.5 4H4.5Z"
/>
<rect
x="13.75"
y="12.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 12.25)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -0,0 +1,19 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M12 5C12.5523 5 13 5.44772 13 6V12C13 12.5523 12.5523 13 12 13H4C3.44772 13 3 12.5523 3 12V6C3 5.44772 3.44772 5 4 5H12ZM4.5 6C4.22386 6 4 6.22386 4 6.5V11.5C4 11.7761 4.22386 12 4.5 12H11.5C11.7761 12 12 11.7761 12 11.5V6.5C12 6.22386 11.7761 6 11.5 6H4.5Z"
/>
<rect
x="13.75"
y="3.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 3.25)"
stroke="white"
stroke-opacity="0.3"
style="stroke: white; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -0,0 +1,34 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M12 8C12.5523 8 13 8.44772 13 9V12C13 12.5523 12.5523 13 12 13H4C3.44772 13 3 12.5523 3 12V9C3 8.44772 3.44772 8 4 8H12ZM4.5 9C4.22386 9 4 9.22386 4 9.5V11.5C4 11.7761 4.22386 12 4.5 12H11.5C11.7761 12 12 11.7761 12 11.5V9.5C12 9.22386 11.7761 9 11.5 9H4.5Z"
/>
<path
d="M12 2C12.5523 2 13 2.44772 13 3V6C13 6.55228 12.5523 7 12 7H4C3.44772 7 3 6.55228 3 6V3C3 2.44772 3.44772 2 4 2H12ZM4.5 3C4.22386 3 4 3.22386 4 3.5V5.5C4 5.77614 4.22386 6 4.5 6H11.5C11.7761 6 12 5.77614 12 5.5V3.5C12 3.22386 11.7761 3 11.5 3H4.5Z"
/>
<rect
x="13.75"
y="10.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 10.25)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
<rect
x="13.75"
y="4.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 4.25)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -0,0 +1,34 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M12 9C12.5523 9 13 9.44772 13 10V12C13 12.5523 12.5523 13 12 13H4C3.44772 13 3 12.5523 3 12V10C3 9.44772 3.44772 9 4 9H12ZM4.5 10C4.22386 10 4 10.2239 4 10.5V11.5C4 11.7761 4.22386 12 4.5 12H11.5C11.7761 12 12 11.7761 12 11.5V10.5C12 10.2239 11.7761 10 11.5 10H4.5Z"
/>
<path
d="M12 3C12.5523 3 13 3.44772 13 4V6C13 6.55228 12.5523 7 12 7H4C3.44772 7 3 6.55228 3 6V4C3 3.44772 3.44772 3 4 3H12ZM4.5 4C4.22386 4 4 4.22386 4 4.5V5.5C4 5.77614 4.22386 6 4.5 6H11.5C11.7761 6 12 5.77614 12 5.5V4.5C12 4.22386 11.7761 4 11.5 4H4.5Z"
/>
<rect
x="13.75"
y="14.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 14.25)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
<rect
x="13.75"
y="1.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 1.25)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -0,0 +1,5 @@
export { default as AlignItemsCenter } from './Center.vue';
export { default as AlignItemsFlexEnd } from './FlexEnd.vue';
export { default as AlignItemsFlexStart } from './FlexStart.vue';
export { default as AlignItemsSpaceAround } from './SpaceAround.vue';
export { default as AlignItemsSpaceBetween } from './SpaceBetween.vue';

View File

@ -1,8 +1,24 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<path <path
d="M912.526651 867.741144 555.540144 510.712681l356.986507-357.000833c11.171434-11.18576 11.171434-29.257348 0-40.443108-11.20111-11.18576-29.272697-11.18576-40.444131 0L515.096013 470.267527 158.096203 113.267716c-11.187807-11.159154-29.258371-11.159154-40.444131 0-11.186783 11.186783-11.186783 29.286 0 40.47176L474.623229 510.712681 117.623419 867.741144c-11.159154 11.172457-11.159154 29.216415 0 40.443108 11.18576 11.17348 29.284977 11.17348 40.47176 0l357.000833-357.027439 356.985484 357.027439c11.171434 11.17348 29.243021 11.17348 40.444131 0C923.698085 896.957559 923.725714 878.913601 912.526651 867.741144z" d="M0 0.625C0 0.279822 0.248731 0 0.555556 0H9.44444C9.75127 0 10 0.279822 10 0.625C10 0.970178 9.75127 1.25 9.44444 1.25H0.555556C0.248731 1.25 0 0.970178 0 0.625Z"
fill="#5D5D5D" fill-opacity="0.3"
></path> />
<path
d="M0 9.375C0 9.02982 0.248731 8.75 0.555556 8.75H9.44444C9.75127 8.75 10 9.02982 10 9.375C10 9.72018 9.75127 10 9.44444 10H0.555556C0.248731 10 0 9.72018 0 9.375Z"
fill-opacity="0.3"
/>
<path
d="M0.555556 4.375C0.248731 4.375 0 4.65482 0 5C0 5.34518 0.248731 5.625 0.555556 5.625H2.87307L3.42868 4.99994L2.87318 4.375H0.555556Z"
fill-opacity="0.3"
/>
<path
d="M9.44444 5.625H7.12699L6.57138 4.99994L7.12688 4.375H9.44444C9.75127 4.375 10 4.65482 10 5C10 5.34518 9.75127 5.625 9.44444 5.625Z"
fill-opacity="0.3"
/>
<path
transform="translate(0 2.6072)"
d="M3.30542 0.183058C3.52237 -0.0610194 3.87413 -0.0610194 4.09109 0.183058C4.30805 0.427136 4.30805 0.822864 4.09109 1.06694L2.91256 2.39279L4.0911 3.71865C4.30806 3.96273 4.30806 4.35846 4.0911 4.60253C3.87414 4.84661 3.52239 4.84661 3.30543 4.60253L2.12688 3.27667L0.948393 4.60248C0.731435 4.84655 0.379676 4.84655 0.162719 4.60248C-0.0542395 4.3584 -0.0542395 3.96267 0.162719 3.71859L1.34121 2.39279L0.162731 1.067C-0.0542269 0.822922 -0.0542268 0.427194 0.162731 0.183116C0.379689 -0.0609612 0.731447 -0.0609612 0.948405 0.183116L2.12688 1.50891L3.30542 0.183058Z"
/>
</svg> </svg>
</template> </template>

View File

@ -1,31 +1,13 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path <rect x="13" y="3" width="2" height="2" rx="0.5" transform="rotate(90 13 3)" fill-opacity="0.3" />
d="M884.736 102.4l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456C696.32 311.296 712.704 327.68 733.184 327.68l147.456 0c20.48 0 36.864-16.384 36.864-36.864L917.504 139.264C921.6 118.784 905.216 102.4 884.736 102.4zM884.736 290.816l-147.456 0L737.28 139.264l147.456 0L884.736 290.816z" <rect x="13" y="7" width="2" height="2" rx="0.5" transform="rotate(90 13 7)" fill-opacity="0.3" />
></path> <rect x="13" y="11" width="2" height="2" rx="0.5" transform="rotate(90 13 11)" fill-opacity="0.3" />
<path <rect x="9" y="3" width="2" height="2" rx="0.5" transform="rotate(90 9 3)" />
d="M884.736 696.32l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C921.6 712.704 905.216 696.32 884.736 696.32zM884.736 884.736l-147.456 0 0-147.456 147.456 0L884.736 884.736z" <rect x="9" y="7" width="2" height="2" rx="0.5" transform="rotate(90 9 7)" />
></path> <rect x="9" y="11" width="2" height="2" rx="0.5" transform="rotate(90 9 11)" fill-opacity="0.3" />
<path <rect x="5" y="3" width="2" height="2" rx="0.5" transform="rotate(90 5 3)" />
d="M884.736 401.408l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C921.6 417.792 905.216 401.408 884.736 401.408zM884.736 585.728l-147.456 0 0-147.456 147.456 0L884.736 585.728z" <rect x="5" y="7" width="2" height="2" rx="0.5" transform="rotate(90 5 7)" />
></path> <rect x="5" y="11" width="2" height="2" rx="0.5" transform="rotate(90 5 11)" fill-opacity="0.3" />
<path
d="M585.728 401.408l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C622.592 417.792 606.208 401.408 585.728 401.408zM585.728 585.728l-147.456 0 0-147.456 147.456 0L585.728 585.728z"
></path>
<path
d="M585.728 102.4l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864L622.592 139.264C622.592 118.784 606.208 102.4 585.728 102.4zM585.728 290.816l-147.456 0L438.272 139.264l147.456 0L585.728 290.816z"
></path>
<path
d="M585.728 696.32l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C622.592 712.704 606.208 696.32 585.728 696.32zM585.728 884.736l-147.456 0 0-147.456 147.456 0L585.728 884.736z"
></path>
<path
d="M290.816 696.32 139.264 696.32c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C327.68 712.704 311.296 696.32 290.816 696.32zM290.816 884.736 139.264 884.736l0-147.456 147.456 0L286.72 884.736z"
></path>
<path
d="M290.816 401.408 139.264 401.408c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C327.68 417.792 311.296 401.408 290.816 401.408zM290.816 585.728 139.264 585.728l0-147.456 147.456 0L286.72 585.728z"
></path>
<path
d="M290.816 102.4 139.264 102.4c-20.48 0-36.864 16.384-36.864 36.864l0 147.456C102.4 311.296 118.784 327.68 139.264 327.68l147.456 0C311.296 327.68 327.68 311.296 327.68 290.816L327.68 139.264C327.68 118.784 311.296 102.4 290.816 102.4zM290.816 290.816 139.264 290.816 139.264 139.264l147.456 0L286.72 290.816z"
></path>
</svg> </svg>
</template> </template>

View File

@ -1,8 +1,7 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path <rect x="3" y="3" width="2" height="10" rx="1" />
d="M796.444444 170.666667h-227.555555v682.666666h227.555555V170.666667z m-56.888888 625.777777h-113.777778V227.555556h113.777778v568.888888zM455.111111 170.666667H227.555556v682.666666h227.555555V170.666667zM398.222222 796.444444H284.444444V227.555556h113.777778v568.888888zM910.222222 56.888889h56.888889v910.222222h-56.888889zM56.888889 56.888889h56.888889v910.222222H56.888889z" <rect x="7" y="3" width="2" height="10" rx="1" />
fill="#333333" <rect x="11" y="3" width="2" height="10" rx="1" fill-opacity="0.3" />
></path>
</svg> </svg>
</template> </template>

View File

@ -1,8 +1,7 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path <rect x="13" y="3" width="2" height="10" rx="1" transform="rotate(90 13 3)" />
d="M170.666667 227.555556v227.555555h682.666666V227.555556H170.666667z m625.777777 56.888888v113.777778H227.555556V284.444444h568.888888zM170.666667 568.888889v227.555555h682.666666v-227.555555H170.666667z m625.777777 56.888889v113.777778H227.555556v-113.777778h568.888888zM56.888889 56.888889h910.222222v56.888889H56.888889zM56.888889 910.222222h910.222222v56.888889H56.888889z" <rect x="13" y="7" width="2" height="10" rx="1" transform="rotate(90 13 7)" />
fill="#333333" <rect x="13" y="11" width="2" height="10" rx="1" transform="rotate(90 13 11)" fill-opacity="0.3" />
></path>
</svg> </svg>
</template> </template>

View File

@ -1,7 +1,8 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path <path
d="M896 320H128V0h768v320z m0 320H128v-256h768v256z m-128 192l-256 192-256-192 192-0.032V704h128v128h192z" d="M12 3C12.5523 3 13 3.44772 13 4V10C13 10.5523 12.5523 11 12 11H4C3.44772 11 3 10.5523 3 10V4C3 3.44772 3.44772 3 4 3H12ZM4.5 4C4.22386 4 4 4.22386 4 4.5V9.5C4 9.77614 4.22386 10 4.5 10H11.5C11.7761 10 12 9.77614 12 9.5V4.5C12 4.22386 11.7761 4 11.5 4H4.5Z"
></path> />
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 12L8 15L5 12L11 12Z" fill-opacity="0.3" />
</svg> </svg>
</template> </template>

View File

@ -1,7 +1,8 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path <path
d="M896 704H128v320h768V704z m0-320H128v256h768v-256z m-128-192l-256-192-256 192 192 0.032V320h128V192h192z" d="M12 5C12.5523 5 13 5.44772 13 6V12C13 12.5523 12.5523 13 12 13H4C3.44772 13 3 12.5523 3 12V6C3 5.44772 3.44772 5 4 5H12ZM4.5 6C4.22386 6 4 6.22386 4 6.5V11.5C4 11.7761 4.22386 12 4.5 12H11.5C11.7761 12 12 11.7761 12 11.5V6.5C12 6.22386 11.7761 6 11.5 6H4.5Z"
></path> />
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 4L8 1L11 4L5 4Z" fill-opacity="0.3" />
</svg> </svg>
</template> </template>

View File

@ -1,7 +1,8 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path <path
d="M320 128v768H0V128h320z m320 0v768h-256V128h256z m192 128l192 256-192 256-0.032-192H704v-128h128V256z" d="M10 4C10.5523 4 11 4.44772 11 5V11C11 11.5523 10.5523 12 10 12H2C1.44772 12 1 11.5523 1 11V5C1 4.44772 1.44772 4 2 4H10ZM2.5 5C2.22386 5 2 5.22386 2 5.5V10.5C2 10.7761 2.22386 11 2.5 11H9.5C9.77614 11 10 10.7761 10 10.5V5.5C10 5.22386 9.77614 5 9.5 5H2.5Z"
></path> />
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 5L15 8L12 11L12 5Z" fill-opacity="0.3" />
</svg> </svg>
</template> </template>

View File

@ -1,7 +1,8 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path <path
d="M704 128v768h320V128H704zM384 128v768h256V128h-256zM192 256l-192 256 192 256 0.032-192H320v-128H192V256z" d="M14 4C14.5523 4 15 4.44772 15 5V11C15 11.5523 14.5523 12 14 12H6C5.44772 12 5 11.5523 5 11V5C5 4.44772 5.44772 4 6 4H14ZM6.5 5C6.22386 5 6 5.22386 6 5.5V10.5C6 10.7761 6.22386 11 6.5 11H13.5C13.7761 11 14 10.7761 14 10.5V5.5C14 5.22386 13.7761 5 13.5 5H6.5Z"
></path> />
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 11L1 8L4 5L4 11Z" fill-opacity="0.3" />
</svg> </svg>
</template> </template>

View File

@ -1,5 +1,8 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M480 1024V0h64v1024h-64z m128-64V64h320v896H608zM96 960V64h320v896H96z"></path> <path
d="M13 4C13.5523 4 14 4.44772 14 5V11C14 11.5523 13.5523 12 13 12H3C2.44772 12 2 11.5523 2 11V5C2 4.44772 2.44772 4 3 4H13ZM3.59082 5C3.31472 5.00005 3.09082 5.22389 3.09082 5.5V10.5C3.09082 10.7761 3.31472 11 3.59082 11H12.4092C12.6853 11 12.9092 10.7761 12.9092 10.5V5.5C12.9092 5.22389 12.6853 5.00005 12.4092 5H3.59082Z"
/>
<rect x="7.5" y="2" width="1" height="12" rx="0.5" fill-opacity="0.3" style="fill: black; fill-opacity: 0.3" />
</svg> </svg>
</template> </template>

View File

@ -1,5 +1,18 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M416 160H96v704h320V160z m384 0H480v704h320V160z m128-160h-64v1024h64V0z"></path> <path
d="M11 4C11.5523 4 12 4.44772 12 5V11C12 11.5523 11.5523 12 11 12H2C1.44772 12 1 11.5523 1 11V5C1 4.44772 1.44772 4 2 4H11ZM2.5 5C2.22386 5 2 5.22386 2 5.5V10.5C2 10.7761 2.22386 11 2.5 11H10.5C10.7761 11 11 10.7761 11 10.5V5.5C11 5.22386 10.7761 5 10.5 5H2.5Z"
/>
<rect
x="14.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg> </svg>
</template> </template>

View File

@ -1,5 +1,18 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M608 160h320v704H608V160zM224 160h320v704H224V160zM96 0h64v1024H96V0z"></path> <path
d="M14 4C14.5523 4 15 4.44772 15 5V11C15 11.5523 14.5523 12 14 12H5C4.44772 12 4 11.5523 4 11V5C4 4.44772 4.44772 4 5 4H14ZM5.5 5C5.22386 5 5 5.22386 5 5.5V10.5C5 10.7761 5.22386 11 5.5 11H13.5C13.7761 11 14 10.7761 14 10.5V5.5C14 5.22386 13.7761 5 13.5 5H5.5Z"
/>
<rect
x="1.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="white"
stroke-opacity="0.3"
style="stroke: white; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg> </svg>
</template> </template>

View File

@ -1,7 +1,32 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path <path
d="M320 864H192v160H128v-160H0V160h128V0h64v160h128v704z m704 0h-128v160h-64v-160h-128V160h128V0h64v160h128v704z" d="M6 4C6.55228 4 7 4.44772 7 5V11C7 11.5523 6.55228 12 6 12H3C2.44772 12 2 11.5523 2 11V5C2 4.44772 2.44772 4 3 4H6ZM3.5 5C3.22386 5 3 5.22386 3 5.5V10.5C3 10.7761 3.22386 11 3.5 11H5.5C5.77614 11 6 10.7761 6 10.5V5.5C6 5.22386 5.77614 5 5.5 5H3.5Z"
></path> />
<path
d="M13 4C13.5523 4 14 4.44772 14 5V11C14 11.5523 13.5523 12 13 12H10C9.44772 12 9 11.5523 9 11V5C9 4.44772 9.44772 4 10 4H13ZM10.5 5C10.2239 5 10 5.22386 10 5.5V10.5C10 10.7761 10.2239 11 10.5 11H12.5C12.7761 11 13 10.7761 13 10.5V5.5C13 5.22386 12.7761 5 12.5 5H10.5Z"
/>
<rect
x="11.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
<rect
x="4.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg> </svg>
</template> </template>

View File

@ -1,5 +1,31 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M64 1024H0V0h64v1024z m384-160H128V160h320v704z m448 0H576V160h320v704z m128 160h-64V0h64v1024z"></path> <path
d="M6 4C6.55228 4 7 4.44772 7 5V11C7 11.5523 6.55228 12 6 12H4C3.44772 12 3 11.5523 3 11V5C3 4.44772 3.44772 4 4 4H6ZM4.5 5C4.22386 5 4 5.22386 4 5.5V10.5C4 10.7761 4.22386 11 4.5 11H5.5C5.77614 11 6 10.7761 6 10.5V5.5C6 5.22386 5.77614 5 5.5 5H4.5Z"
/>
<path
d="M12 4C12.5523 4 13 4.44772 13 5V11C13 11.5523 12.5523 12 12 12H10C9.44772 12 9 11.5523 9 11V5C9 4.44772 9.44772 4 10 4H12ZM10.5 5C10.2239 5 10 5.22386 10 5.5V10.5C10 10.7761 10.2239 11 10.5 11H11.5C11.7761 11 12 10.7761 12 10.5V5.5C12 5.22386 11.7761 5 11.5 5H10.5Z"
/>
<rect
x="14.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="black"
stroke-opacity="0.3"
stroke-width="0.5"
/>
<rect
x="1.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg> </svg>
</template> </template>

View File

@ -29,6 +29,7 @@ defineProps<{
isCompare?: boolean; isCompare?: boolean;
disabled?: boolean; disabled?: boolean;
size?: 'large' | 'default' | 'small'; size?: 'large' | 'default' | 'small';
theme?: string;
prop?: string; prop?: string;
}>(); }>();
@ -112,10 +113,14 @@ const formConfig = defineFormConfig([
childType: 'button', childType: 'button',
labelWidth: '68px', labelWidth: '68px',
options: [ options: [
{ value: 'repeat', icon: markRaw(BackgroundRepeat), tooltip: '垂直和水平方向重复 repeat' }, { value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' },
{ value: 'repeat-x', icon: markRaw(BackgroundRepeatX), tooltip: '水平方向重复 repeat-x' }, { value: 'repeat-x', icon: markRaw(BackgroundRepeatX), tooltip: '水平方向重复 repeat-x' },
{ value: 'repeat-y', icon: markRaw(BackgroundRepeatY), tooltip: '垂直方向重复 repeat-y' }, { value: 'repeat-y', icon: markRaw(BackgroundRepeatY), tooltip: '垂直方向重复 repeat-y' },
{ value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' }, {
value: 'repeat',
icon: markRaw(BackgroundRepeat),
tooltip: '垂直和水平方向重复 repeat',
},
], ],
}, },
{ {

View File

@ -95,9 +95,9 @@ const formConfig = defineFormConfig([
childType: 'button', childType: 'button',
labelWidth: '68px', labelWidth: '68px',
options: [ options: [
{ value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row' }, { value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row', text: '左对齐' },
{ value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center' }, { value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center', text: '居中对齐' },
{ value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right' }, { value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right', text: '右对齐' },
], ],
}, },
]); ]);

View File

@ -26,11 +26,19 @@
<script lang="ts" setup> <script lang="ts" setup>
import { markRaw } from 'vue'; import { markRaw } from 'vue';
import { useTheme } from '@tmagic/design';
import type { ContainerChangeEventData } from '@tmagic/form'; import type { ContainerChangeEventData } from '@tmagic/form';
import { defineFormConfig, MContainer } from '@tmagic/form'; import { defineFormConfig, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema'; import type { StyleSchema } from '@tmagic/schema';
import Box from '../components/Box.vue'; import Box from '../components/Box.vue';
import {
AlignItemsCenter,
AlignItemsFlexEnd,
AlignItemsFlexStart,
AlignItemsSpaceAround,
AlignItemsSpaceBetween,
} from '../icons/align-items';
import { DisplayBlock, DisplayFlex, DisplayInline, DisplayInlineBlock, DisplayNone } from '../icons/display'; import { DisplayBlock, DisplayFlex, DisplayInline, DisplayInlineBlock, DisplayNone } from '../icons/display';
import { import {
FlexDirectionColumn, FlexDirectionColumn,
@ -46,12 +54,13 @@ import {
JustifyContentSpaceBetween, JustifyContentSpaceBetween,
} from '../icons/justify-content'; } from '../icons/justify-content';
defineProps<{ const props = defineProps<{
values: Partial<StyleSchema>; values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>; lastValues?: Partial<StyleSchema>;
isCompare?: boolean; isCompare?: boolean;
disabled?: boolean; disabled?: boolean;
size?: 'large' | 'default' | 'small'; size?: 'large' | 'default' | 'small';
theme?: string;
prop?: string; prop?: string;
}>(); }>();
@ -60,19 +69,42 @@ const emit = defineEmits<{
addDiffCount: []; addDiffCount: [];
}>(); }>();
const displayTheme = useTheme(props);
const formConfig = defineFormConfig([ const formConfig = defineFormConfig([
{ {
name: 'display', name: 'display',
text: '模式', text: '模式',
type: 'radioGroup', type: 'radioGroup',
childType: 'button', childType: 'button',
labelWidth: '68px', labelWidth: '90px',
iconSize: '24px',
options: [ options: [
{ value: 'inline', icon: markRaw(DisplayInline), tooltip: '内联布局 inline' }, {
{ value: 'flex', icon: markRaw(DisplayFlex), tooltip: '弹性布局 flex' }, value: 'inline',
{ value: 'block', icon: markRaw(DisplayBlock), tooltip: '块级布局 block' }, icon: markRaw(DisplayInline),
{ value: 'inline-block', icon: markRaw(DisplayInlineBlock), tooltip: '内联块布局 inline-block' }, tooltip: '内联布局 inline',
{ value: 'none', icon: markRaw(DisplayNone), tooltip: '隐藏 none' }, },
{
value: 'flex',
icon: markRaw(DisplayFlex),
tooltip: '弹性布局 flex',
},
{
value: 'block',
icon: markRaw(DisplayBlock),
tooltip: '块级布局 block',
},
{
value: 'inline-block',
icon: markRaw(DisplayInlineBlock),
tooltip: '内联块布局 inline-block',
},
{
value: 'none',
icon: markRaw(DisplayNone),
tooltip: '隐藏 none',
},
], ],
}, },
{ {
@ -80,11 +112,20 @@ const formConfig = defineFormConfig([
text: '主轴方向', text: '主轴方向',
type: 'radioGroup', type: 'radioGroup',
childType: 'button', childType: 'button',
labelWidth: '68px', labelWidth: '90px',
iconSize: '24px',
options: [ options: [
{ value: 'row', icon: markRaw(FlexDirectionRow), tooltip: '水平方向 起点在左侧 row' }, { value: 'row', icon: markRaw(FlexDirectionRow), tooltip: '水平方向 起点在左侧 row' },
{ value: 'row-reverse', icon: markRaw(FlexDirectionRowReverse), tooltip: '水平方向 起点在右侧 row-reverse' }, {
{ value: 'column', icon: markRaw(FlexDirectionColumn), tooltip: '垂直方向 起点在上沿 column' }, value: 'row-reverse',
icon: markRaw(FlexDirectionRowReverse),
tooltip: '水平方向 起点在右侧 row-reverse',
},
{
value: 'column',
icon: markRaw(FlexDirectionColumn),
tooltip: '垂直方向 起点在上沿 column',
},
{ {
value: 'column-reverse', value: 'column-reverse',
icon: markRaw(FlexDirectionColumnReverse), icon: markRaw(FlexDirectionColumnReverse),
@ -98,13 +139,22 @@ const formConfig = defineFormConfig([
text: '主轴对齐', text: '主轴对齐',
type: 'radioGroup', type: 'radioGroup',
childType: 'button', childType: 'button',
labelWidth: '68px', labelWidth: '90px',
iconSize: '24px',
options: [ options: [
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' }, { value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' }, { value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' }, { value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
{ value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' }, {
{ value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' }, value: 'space-between',
icon: markRaw(JustifyContentSpaceBetween),
tooltip: '两端对齐 space-between',
},
{
value: 'space-around',
icon: markRaw(JustifyContentSpaceAround),
tooltip: '横向平分 space-around',
},
], ],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex', display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
}, },
@ -113,13 +163,22 @@ const formConfig = defineFormConfig([
text: '辅轴对齐', text: '辅轴对齐',
type: 'radioGroup', type: 'radioGroup',
childType: 'button', childType: 'button',
labelWidth: '68px', labelWidth: '90px',
iconSize: '24px',
options: [ options: [
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' }, { value: 'flex-start', icon: markRaw(AlignItemsFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' }, { value: 'flex-end', icon: markRaw(AlignItemsFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' }, { value: 'center', icon: markRaw(AlignItemsCenter), tooltip: '居中 center' },
{ value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' }, {
{ value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' }, value: 'space-between',
icon: markRaw(AlignItemsSpaceBetween),
tooltip: '两端对齐 space-between',
},
{
value: 'space-around',
icon: markRaw(AlignItemsSpaceAround),
tooltip: '横向平分 space-around',
},
], ],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex', display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
}, },
@ -127,8 +186,9 @@ const formConfig = defineFormConfig([
name: 'flexWrap', name: 'flexWrap',
text: '换行', text: '换行',
type: 'radioGroup', type: 'radioGroup',
childType: 'button', childType: displayTheme.value !== 'magic-admin' ? 'button' : 'default',
labelWidth: '68px', labelWidth: '90px',
iconSize: '24px',
options: [ options: [
{ value: 'nowrap', text: '不换行', tooltip: '不换行 nowrap' }, { value: 'nowrap', text: '不换行', tooltip: '不换行 nowrap' },
{ value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' }, { value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' },
@ -141,17 +201,22 @@ const formConfig = defineFormConfig([
items: [ items: [
{ {
name: 'width', name: 'width',
text: '宽度', text: '宽度px',
labelWidth: '68px', labelWidth: '90px',
type: 'data-source-field-select', type: 'data-source-field-select',
fieldConfig: { fieldConfig: {
type: 'text', type: 'text',
}, },
}, },
],
},
{
type: 'row',
items: [
{ {
name: 'height', name: 'height',
text: '高度', text: '高度px',
labelWidth: '68px', labelWidth: '90px',
type: 'data-source-field-select', type: 'data-source-field-select',
fieldConfig: { fieldConfig: {
type: 'text', type: 'text',
@ -166,7 +231,7 @@ const formConfig = defineFormConfig([
type: 'data-source-field-select', type: 'data-source-field-select',
text: 'overflow', text: 'overflow',
name: 'overflow', name: 'overflow',
labelWidth: '68px', labelWidth: '90px',
checkStrictly: false, checkStrictly: false,
dataSourceFieldType: ['string'], dataSourceFieldType: ['string'],
fieldConfig: { fieldConfig: {
@ -184,11 +249,16 @@ const formConfig = defineFormConfig([
], ],
}, },
}, },
],
},
{
type: 'row',
items: [
{ {
type: 'data-source-field-select', type: 'data-source-field-select',
text: '透明度', text: '透明度%',
name: 'opacity', name: 'opacity',
labelWidth: '68px', labelWidth: '90px',
dataSourceFieldType: ['string', 'number'], dataSourceFieldType: ['string', 'number'],
fieldConfig: { fieldConfig: {
type: 'text', type: 'text',

File diff suppressed because one or more lines are too long

View File

@ -32,6 +32,9 @@ export { DepTargetType } from '@tmagic/core';
export * from './type'; export * from './type';
export * from './hooks'; export * from './hooks';
export * from './utils'; export * from './utils';
// `isGlobalFlat` 同时在 `@tmagic/design` 和 `./utils/config` 中存在,
// 通过显式重导一次消除 `export *` 带来的歧义,并固定使用 editor 自己那份。
export { isGlobalFlat } from './utils/config';
export { default as TMagicEditor } from './Editor.vue'; export { default as TMagicEditor } from './Editor.vue';
export { default as TMagicCodeEditor } from './layouts/CodeEditor.vue'; export { default as TMagicCodeEditor } from './layouts/CodeEditor.vue';
export { default as editorService } from './services/editor'; export { default as editorService } from './services/editor';

View File

@ -333,11 +333,11 @@ export const initServiceEvents = (
Promise.all( Promise.all(
nodes.map((node) => { nodes.map((node) => {
if (node.type === NodeType.ROOT) { if (node.type === NodeType.ROOT) {
return Promise.resolve(); return Promise.resolve(true);
} }
return depService.collectIdle([node], { pageId: getPageIdByNode(node) }, deep, type); return depService.collectIdle([node], { pageId: getPageIdByNode(node) }, deep, type);
}), }),
); ).then((results) => results.every(Boolean));
watch( watch(
() => editorService.get('stage'), () => editorService.get('stage'),
@ -351,7 +351,7 @@ export const initServiceEvents = (
if (!node) return; if (!node) return;
await collectIdle([node], true, DepTargetType.DATA_SOURCE); if (!(await collectIdle([node], true, DepTargetType.DATA_SOURCE))) return;
updateStageNode(node); updateStageNode(node);
}); });
}, },
@ -494,8 +494,10 @@ export const initServiceEvents = (
// 新增节点,收集依赖 // 新增节点,收集依赖
const nodeAddHandler = (nodes: MComponent[]) => { const nodeAddHandler = (nodes: MComponent[]) => {
collectIdle(nodes, true).then(() => { collectIdle(nodes, true).then((completed) => {
updateStageNodes(nodes); if (completed) {
updateStageNodes(nodes);
}
}); });
}; };
@ -549,8 +551,8 @@ export const initServiceEvents = (
if (needRecollectNodes.length) { if (needRecollectNodes.length) {
// 有数据源依赖需要等依赖重新收集完才更新stage // 有数据源依赖需要等依赖重新收集完才更新stage
const handler = async () => { const handler = async () => {
await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE); if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE))) return;
await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND); if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND))) return;
updateStageNodes(needRecollectNodes); updateStageNodes(needRecollectNodes);
}; };
handler(); handler();
@ -572,8 +574,10 @@ export const initServiceEvents = (
// 历史记录变化时,需要重新收集依赖 // 历史记录变化时,需要重新收集依赖
const historyChangeHandler = (page: MPage | MPageFragment) => { const historyChangeHandler = (page: MPage | MPageFragment) => {
collectIdle([page], true).then(() => { collectIdle([page], true).then((completed) => {
updateStageNode(page); if (completed) {
updateStageNode(page);
}
}); });
}; };
@ -654,7 +658,7 @@ export const initServiceEvents = (
if (Array.isArray(root?.items)) { if (Array.isArray(root?.items)) {
depService.clearIdleTasks(); depService.clearIdleTasks();
let collectIdlePromises: Promise<void[]>[] = []; let collectIdlePromises: Promise<boolean>[] = [];
if (isModifyField) { if (isModifyField) {
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE); depService.removeTarget(config.id, DepTargetType.DATA_SOURCE);
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND); depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND);
@ -679,10 +683,15 @@ export const initServiceEvents = (
collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE_METHOD)]; collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE_METHOD)];
} }
Promise.all(collectIdlePromises) const handler = async () => {
.then(() => updateDataSourceSchema()) const results = await Promise.all(collectIdlePromises);
.then(() => updateDsData()) if (!results.every(Boolean)) return;
.then(() => updateStageNodes(root.items));
updateDataSourceSchema();
await updateDsData();
updateStageNodes(root.items);
};
handler();
} }
} else if (root?.dataSources) { } else if (root?.dataSources) {
updateDsData(); updateDsData();
@ -706,11 +715,12 @@ export const initServiceEvents = (
const nodeIds = Object.keys(root.dataSourceDeps?.[id] || {}); const nodeIds = Object.keys(root.dataSourceDeps?.[id] || {});
const nodes = getNodes(nodeIds, root.items); const nodes = getNodes(nodeIds, root.items);
await Promise.all([ const results = await Promise.all([
collectIdle(nodes, false, DepTargetType.DATA_SOURCE), collectIdle(nodes, false, DepTargetType.DATA_SOURCE),
collectIdle(nodes, false, DepTargetType.DATA_SOURCE_COND), collectIdle(nodes, false, DepTargetType.DATA_SOURCE_COND),
collectIdle(nodes, false, DepTargetType.DATA_SOURCE_METHOD), collectIdle(nodes, false, DepTargetType.DATA_SOURCE_METHOD),
]); ]);
if (!results.every(Boolean)) return;
updateDataSourceSchema(); updateDataSourceSchema();

View File

@ -1,5 +1,9 @@
<template> <template>
<div class="m-editor" ref="content" style="min-width: 900px"> <div
ref="content"
:class="['m-editor', theme ? `m-editor--${theme}` : '', theme ? `m-theme--${theme}` : '']"
style="min-width: 900px"
>
<slot name="header"></slot> <slot name="header"></slot>
<slot name="nav"></slot> <slot name="nav"></slot>
@ -97,6 +101,8 @@ const props = defineProps<{
pageFilterFunction?: (_page: MPage | MPageFragment, _keyword: string) => boolean; pageFilterFunction?: (_page: MPage | MPageFragment, _keyword: string) => boolean;
/** 是否隐藏左侧面板 */ /** 是否隐藏左侧面板 */
hideSidebar?: boolean; hideSidebar?: boolean;
/** 主题名称,会在根节点追加 `m-editor--<theme>` 修饰类 */
theme?: string;
}>(); }>();
const codeOptions = inject('codeOptions', {}); const codeOptions = inject('codeOptions', {});

View File

@ -49,14 +49,13 @@ import { Document as DocumentIcon } from '@element-plus/icons-vue';
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design'; import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form'; import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form';
import { MForm } from '@tmagic/form'; import { MForm, validateForm } from '@tmagic/form';
import { filterXSS } from '@tmagic/utils'; import { filterXSS } from '@tmagic/utils';
import MIcon from '@editor/components/Icon.vue'; import MIcon from '@editor/components/Icon.vue';
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps'; import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height'; import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
import { useServices } from '@editor/hooks/use-services'; import { useServices } from '@editor/hooks/use-services';
import { validatePropsForm } from '@editor/utils/props';
import CodeEditor from '../CodeEditor.vue'; import CodeEditor from '../CodeEditor.vue';
@ -162,14 +161,19 @@ const saveCode = async (values: any) => {
// //
// 使 // 使
try { try {
const error = await validatePropsForm({ const error = await validateForm({
config: props.config, config: props.config,
values: newValues,
appContext: internalInstance?.appContext ?? null,
services,
stage: stage.value,
typeMatchValid: true, typeMatchValid: true,
extendState: props.extendState, initValues: newValues,
// provides Editor services / codeOptions provide
// appContext使 MForm DataSourceInput inject
appContext: internalInstance?.appContext ? { ...internalInstance?.appContext, provides: { services } } : null,
extendState: (state) => {
if (configFormRef.value?.formState) {
return { ...(configFormRef.value?.formState || {}) };
}
return props.extendState?.(state) || {};
},
}); });
if (error) { if (error) {

View File

@ -111,14 +111,31 @@ const styleFormConfig = [
}, },
]; ];
// init "" await ref
// init + stale config
// / FormPanel remount ref Vue setRef __vnode
// null TypeError: Cannot set properties of null (setting '__vnode')
let initSeq = 0;
let mounted = true;
const init = async () => { const init = async () => {
initSeq += 1;
const seq = initSeq;
if (!node.value) { if (!node.value) {
if (seq !== initSeq || !mounted) return;
curFormConfig.value = []; curFormConfig.value = [];
return; return;
} }
const type = node.value.type || (node.value.items ? 'container' : 'text'); const type = node.value.type || (node.value.items ? 'container' : 'text');
curFormConfig.value = await propsService.getPropsConfig(type, { node: node.value }); const config = await propsService.getPropsConfig(type, { node: node.value });
// init /
if (seq !== initSeq || !mounted) return;
if (!node.value) return;
curFormConfig.value = config;
values.value = node.value; values.value = node.value;
}; };
@ -126,6 +143,7 @@ watchEffect(init);
propsService.on('props-configs-change', init); propsService.on('props-configs-change', init);
onBeforeUnmount(() => { onBeforeUnmount(() => {
mounted = false;
propsService.off('props-configs-change', init); propsService.off('props-configs-change', init);
}); });
@ -146,17 +164,25 @@ const submit = async (
}; };
if (v.style) { if (v.style) {
Object.entries(v.style).forEach(([key, value]) => { // doUpdate mergeWith
if (value !== '' && newValue.style) { if (eventData) {
newValue.style[key] = value; // DSL
} // changeRecords
}); Object.entries(v.style).forEach(([key, value]) => {
if (value !== '' && newValue.style) {
newValue.style[key] = value;
}
});
eventData?.changeRecords?.forEach((record) => { eventData.changeRecords?.forEach((record) => {
if (record.propPath?.startsWith('style') && record.value === '') { if (record.propPath?.startsWith('style') && record.value === '') {
setValueByKeyPath(record.propPath, record.value, newValue); setValueByKeyPath(record.propPath, record.value, newValue);
} }
}); });
} else {
// eventDatastyle
newValue.style = { ...v.style };
}
} }
// MForm @change eventData changeRecords // MForm @change eventData changeRecords

View File

@ -155,7 +155,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { Close, Coin, EditPen, Goods, List } from '@element-plus/icons-vue'; import { Box, Close, Coin, EditPen, Finished } from '@element-plus/icons-vue';
import FloatingBox from '@editor/components/FloatingBox.vue'; import FloatingBox from '@editor/components/FloatingBox.vue';
import MIcon from '@editor/components/Icon.vue'; import MIcon from '@editor/components/Icon.vue';
@ -253,7 +253,7 @@ const getItemConfig = (data: SideItem): SideComponent => {
[SideItemKey.COMPONENT_LIST]: { [SideItemKey.COMPONENT_LIST]: {
$key: SideItemKey.COMPONENT_LIST, $key: SideItemKey.COMPONENT_LIST,
type: 'component', type: 'component',
icon: Goods, icon: Box,
text: '组件', text: '组件',
component: ComponentListPanel, component: ComponentListPanel,
slots: {}, slots: {},
@ -261,7 +261,7 @@ const getItemConfig = (data: SideItem): SideComponent => {
layer: { layer: {
$key: SideItemKey.LAYER, $key: SideItemKey.LAYER,
type: 'component', type: 'component',
icon: List, icon: Finished,
text: '已选组件', text: '已选组件',
props: { props: {
layerContentMenu: props.layerContentMenu, layerContentMenu: props.layerContentMenu,

View File

@ -16,7 +16,7 @@
* limitations under the License. * limitations under the License.
*/ */
import type { App } from 'vue'; import { type App } from 'vue';
import type { DesignPluginOptions } from '@tmagic/design'; import type { DesignPluginOptions } from '@tmagic/design';
import designPlugin from '@tmagic/design'; import designPlugin from '@tmagic/design';
@ -56,6 +56,8 @@ const defaultInstallOpt: EditorInstallOptions = {
customCreateMonacoEditor: (monaco, codeEditorEl, options) => monaco.editor.create(codeEditorEl, options), customCreateMonacoEditor: (monaco, codeEditorEl, options) => monaco.editor.create(codeEditorEl, options),
customCreateMonacoDiffEditor: (monaco, codeEditorEl, options) => customCreateMonacoDiffEditor: (monaco, codeEditorEl, options) =>
monaco.editor.createDiffEditor(codeEditorEl, options), monaco.editor.createDiffEditor(codeEditorEl, options),
flat: false,
}; };
export default { export default {
@ -69,7 +71,6 @@ export default {
app.config.globalProperties.$TMAGIC_EDITOR = option; app.config.globalProperties.$TMAGIC_EDITOR = option;
setEditorConfig(option); setEditorConfig(option);
app.component(`${Editor.name || 'MEditor'}`, Editor); app.component(`${Editor.name || 'MEditor'}`, Editor);
app.component('magic-code-editor', CodeEditor); app.component('magic-code-editor', CodeEditor);
app.component('m-fields-ui-select', uiSelect); app.component('m-fields-ui-select', uiSelect);

View File

@ -42,6 +42,21 @@ interface State {
type StateKey = keyof State; type StateKey = keyof State;
/**
* collectIdle /
* Promise idleTask finish resolve
* clearTasks Promise resolvecollecting
*/
interface CollectBatch {
nodes: MNode[];
deep: boolean;
pending: number;
dsPending: number;
collectedEmitted: boolean;
dsSettled: boolean;
resolve: (completed: boolean) => void;
}
class Dep extends BaseService { class Dep extends BaseService {
private state = shallowReactive<State>({ private state = shallowReactive<State>({
collecting: false, collecting: false,
@ -54,6 +69,12 @@ class Dep extends BaseService {
private waitingWorker?: Promise<void>; private waitingWorker?: Promise<void>;
private resolveWaitingWorker?: () => void;
private workerGeneration = 0;
private activeBatches = new Set<CollectBatch>();
constructor() { constructor() {
super(); super();
@ -137,38 +158,44 @@ class Dep extends BaseService {
await this.waitingWorker; await this.waitingWorker;
} }
this.set('collecting', true); const batch: CollectBatch = {
let startTask = false; nodes,
this.watcher.collectByCallback(nodes, type, ({ node, target }) => { deep,
startTask = true; pending: 0,
dsPending: 0,
collectedEmitted: false,
dsSettled: false,
resolve: () => {},
};
this.enqueueTask(node, target, depExtendedData, deep); this.watcher.collectByCallback(nodes, type, ({ node, target }) => {
this.enqueueTask(node, target, depExtendedData, deep, batch);
}); });
return new Promise<void>((resolve) => { // 没有命中任何 target无需收集直接完成
if (!startTask) { if (batch.pending === 0) {
this.emit('collected', nodes, deep); this.emit('collected', nodes, deep);
this.set('collecting', false); this.updateCollectingState();
resolve(); return true;
return; }
}
this.idleTask.once('finish', () => { this.activeBatches.add(batch);
this.emit('collected', nodes, deep); this.set('collecting', true);
this.set('collecting', false);
}); return new Promise<boolean>((resolve) => {
this.idleTask.once('hight-level-finish', () => { batch.resolve = resolve;
this.emit('ds-collected', nodes, deep);
resolve();
});
}); });
} }
public collectByWorker(dsl: MApp) { public collectByWorker(dsl: MApp) {
this.set('collecting', true); this.set('collecting', true);
this.workerGeneration += 1;
const generation = this.workerGeneration;
const { promise, resolve: waitingResolve } = Promise.withResolvers<void>(); const { promise, resolve: waitingResolve } = Promise.withResolvers<void>();
this.waitingWorker = promise; this.waitingWorker = promise;
this.resolveWaitingWorker = waitingResolve;
return new Promise<Record<string, Record<string, DepData>>>((resolve) => { return new Promise<Record<string, Record<string, DepData>>>((resolve) => {
const worker = new Work(); const worker = new Work();
@ -182,6 +209,11 @@ class Dep extends BaseService {
resolve({}); resolve({});
}; };
}).then((depsData) => { }).then((depsData) => {
if (generation !== this.workerGeneration) {
waitingResolve();
return depsData;
}
traverseTarget(this.watcher.getTargetsList(), (target) => { traverseTarget(this.watcher.getTargetsList(), (target) => {
if (depsData[target.type]?.[target.id]) { if (depsData[target.type]?.[target.id]) {
target.deps = reactive(depsData[target.type][target.id]); target.deps = reactive(depsData[target.type][target.id]);
@ -200,6 +232,10 @@ class Dep extends BaseService {
this.emit('collected', dsl.items, true); this.emit('collected', dsl.items, true);
this.emit('ds-collected', dsl.items, true); this.emit('ds-collected', dsl.items, true);
waitingResolve(); waitingResolve();
if (this.waitingWorker === promise) {
this.waitingWorker = undefined;
this.resolveWaitingWorker = undefined;
}
return depsData; return depsData;
}); });
@ -233,6 +269,7 @@ class Dep extends BaseService {
} }
public clearIdleTasks() { public clearIdleTasks() {
this.abortActiveBatches();
this.idleTask.clearTasks(); this.idleTask.clearTasks();
} }
@ -251,6 +288,11 @@ class Dep extends BaseService {
} }
public reset() { public reset() {
this.abortActiveBatches();
this.workerGeneration += 1;
this.resolveWaitingWorker?.();
this.resolveWaitingWorker = undefined;
this.waitingWorker = undefined;
this.idleTask.clearTasks(); this.idleTask.clearTasks();
for (const type of Object.keys(this.watcher.getTargetsList())) { for (const type of Object.keys(this.watcher.getTargetsList())) {
@ -286,25 +328,99 @@ class Dep extends BaseService {
} }
} }
private enqueueTask(node: MNode, target: Target, depExtendedData: DepExtendedData, deep: boolean) { private enqueueTask(
node: MNode,
target: Target,
depExtendedData: DepExtendedData,
deep: boolean,
batch: CollectBatch,
) {
const isDataSource = target.type === DepTargetType.DATA_SOURCE;
batch.pending += 1;
if (isDataSource) {
batch.dsPending += 1;
}
this.idleTask.enqueueTask( this.idleTask.enqueueTask(
({ node, deep, target }) => { ({ node, deep, target }) => {
this.collectNode(node, target, depExtendedData, deep); try {
this.collectNode(node, target, depExtendedData, deep);
} finally {
this.onBatchTaskDone(batch, isDataSource);
}
}, },
{ {
node, node,
deep: false, deep: false,
target, target,
}, },
target.type === DepTargetType.DATA_SOURCE, isDataSource,
); );
if (deep && Array.isArray(node.items) && node.items.length) { if (deep && Array.isArray(node.items) && node.items.length) {
node.items.forEach((item) => { node.items.forEach((item) => {
this.enqueueTask(item, target, depExtendedData, deep); this.enqueueTask(item, target, depExtendedData, deep, batch);
}); });
} }
} }
private onBatchTaskDone(batch: CollectBatch, isDataSource: boolean) {
if (isDataSource) {
batch.dsPending -= 1;
// 数据源依赖收集完先 resolve让 stage 尽快更新,其余依赖继续在后台收集
if (batch.dsPending === 0) {
this.settleBatchDs(batch);
}
}
batch.pending -= 1;
if (batch.pending === 0) {
this.finishBatch(batch);
}
}
private settleBatchDs(batch: CollectBatch) {
if (batch.dsSettled) return;
batch.dsSettled = true;
this.emit('ds-collected', batch.nodes, batch.deep);
batch.resolve(true);
}
private finishBatch(batch: CollectBatch) {
if (!batch.collectedEmitted) {
batch.collectedEmitted = true;
this.emit('collected', batch.nodes, batch.deep);
}
// 没有数据源任务的批次在此结算 Promise
this.settleBatchDs(batch);
this.activeBatches.delete(batch);
this.updateCollectingState();
}
/**
* idleTask
* collectIdle Promise resolvecollecting trueonce
* emit collected/ds-collected Promise
*/
private abortActiveBatches() {
if (!this.activeBatches.size) {
return;
}
const batches = [...this.activeBatches];
this.activeBatches.clear();
for (const batch of batches) {
batch.resolve(false);
}
this.updateCollectingState();
}
private updateCollectingState() {
this.set('collecting', this.activeBatches.size > 0);
}
} }
export type DepService = Dep; export type DepService = Dep;

View File

@ -550,8 +550,16 @@ class Editor extends BaseService {
this.emit('change', { this.emit('change', {
type: 'add', type: 'add',
data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })), data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
historySource,
doNotPushHistory,
}); });
// 页面 / 页面片新增不入历史栈(见上方 isPage / isPageFragment 分支),这里合并补发一次结构变更通知
const addedPages = newNodes.filter((node) => isPage(node) || isPageFragment(node)) as (MPage | MPageFragment)[];
if (addedPages.length) {
historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
}
return Array.isArray(addNode) ? newNodes : newNodes[0]; return Array.isArray(addNode) ? newNodes : newNodes[0];
} }
@ -692,7 +700,13 @@ class Editor extends BaseService {
} }
this.emit('remove', nodes); this.emit('remove', nodes);
this.emit('change', { type: 'remove', data: changeItems }); this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory });
// 页面 / 页面片删除不入历史栈(见上方 isPage / isPageFragment 分支),这里合并补发一次结构变更通知
const removedPages = nodes.filter((node) => isPage(node) || isPageFragment(node)) as (MPage | MPageFragment)[];
if (removedPages.length) {
historyService.notifyPageStructureChange({ add: [], remove: removedPages });
}
} }
public async doUpdate( public async doUpdate(
@ -846,6 +860,8 @@ class Editor extends BaseService {
this.emit('change', { this.emit('change', {
type: 'update', type: 'update',
data: updateData.map((node) => ({ node, page: this.getPageOfNode(node.newNode.id) })), data: updateData.map((node) => ({ node, page: this.getPageOfNode(node.newNode.id) })),
historySource,
doNotPushHistory,
}); });
return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode; return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode;
} }
@ -1092,6 +1108,8 @@ class Editor extends BaseService {
type: 'move-layer', type: 'move-layer',
data: [{ node, page: this.getPageOfNode(node.id) }], data: [{ node, page: this.getPageOfNode(node.id) }],
offset, offset,
historySource,
doNotPushHistory,
}); });
} }
@ -1277,6 +1295,8 @@ class Editor extends BaseService {
data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })), data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
targetIndex, targetIndex,
targetParent, targetParent,
historySource,
doNotPushHistory,
}); });
} }
@ -1768,6 +1788,10 @@ class Editor extends BaseService {
const nextMap = new Map(nextPages.map((p) => [`${p.id}`, p])); const nextMap = new Map(nextPages.map((p) => [`${p.id}`, p]));
const indexInItems = (root: MApp, id: Id) => (root.items ?? []).findIndex((item) => `${item.id}` === `${id}`); const indexInItems = (root: MApp, id: Id) => (root.items ?? []).findIndex((item) => `${item.id}` === `${id}`);
// 收集本次整体替换中增删的页面,循环结束后合并为一次结构变更通知(避免逐页派发多个事件)
const addedPages: (MPage | MPageFragment)[] = [];
const removedPages: (MPage | MPageFragment)[] = [];
nextPages.forEach((nextPage) => { nextPages.forEach((nextPage) => {
const prevPage = prevMap.get(`${nextPage.id}`); const prevPage = prevMap.get(`${nextPage.id}`);
if (!prevPage) { if (!prevPage) {
@ -1777,6 +1801,7 @@ class Editor extends BaseService {
{ newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) }, { newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
source, source,
); );
addedPages.push(nextPage);
} else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) { } else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
this.pushPageDiffStep( this.pushPageDiffStep(
'update', 'update',
@ -1795,8 +1820,13 @@ class Editor extends BaseService {
{ oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) }, { oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
source, source,
); );
removedPages.push(prevPage);
} }
}); });
if (addedPages.length || removedPages.length) {
historyService.notifyPageStructureChange({ add: addedPages, remove: removedPages });
}
} }
/** /**
@ -1878,10 +1908,12 @@ class Editor extends BaseService {
/** /**
* / * /
* *
* DSL `editor.add / remove / update` `doNotPushHistory` * / `editor.remove / update` `doNotPushHistory`
* `doNotSelect / doNotSwitchPage` * `doNotSelect / doNotSwitchPage`
* remove / add step parentId / index
* this.add splice + stage.add add / change emitHistoryInsertEvents
* *
* add / remove / update "用户操作""撤销重做触发" * add / remove / update / change "用户操作""撤销重做触发"
* `history-change` * `history-change`
* *
* @param step * @param step
@ -1894,7 +1926,14 @@ class Editor extends BaseService {
const stage = this.get('stage'); const stage = this.get('stage');
if (!root) return; if (!root) return;
const commonOpts = { doNotSelect: true, doNotSwitchPage: true, doNotPushHistory: true } as const; // 撤销/重做内部复用 add/remove/update透传被应用 step 上记录的 source
// 使其触发的 change 事件同样携带「历史来源」
const commonOpts = {
doNotSelect: true,
doNotSwitchPage: true,
doNotPushHistory: true,
historySource: step.source,
} as const;
switch (step.opType) { switch (step.opType) {
case 'add': { case 'add': {
@ -1910,17 +1949,22 @@ class Editor extends BaseService {
} }
} else { } else {
// 重做 add按记录的 parentId / index 把节点重新插回父容器。 // 重做 add按记录的 parentId / index 把节点重新插回父容器。
// 按目标 index 升序逐个插入,先小后大避免索引漂移 // 按目标 index 升序逐个插入,先小后大避免索引漂移。
// 不走 this.adddoAdd 依赖当前选中节点(无选区时直接抛错),且只能插到选中节点之后 / 末尾,
// 无法按 step 记录的 parentId / index 精确还原,还会重算 style 破坏已记录的终态位置。
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)); const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
const addedNodes: MNode[] = [];
for (const { newSchema, parentId, index } of sorted) { for (const { newSchema, parentId, index } of sorted) {
if (!newSchema || parentId === undefined) continue; if (!newSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null; const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) { if (parent?.items) {
const addedNode = cloneDeep(newSchema);
if (typeof index === 'number' && index >= 0 && index < parent.items.length) { if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
parent.items.splice(index, 0, cloneDeep(newSchema)); parent.items.splice(index, 0, addedNode);
} else { } else {
parent.items.push(cloneDeep(newSchema)); parent.items.push(addedNode);
} }
addedNodes.push(addedNode);
await stage?.add({ await stage?.add({
config: cloneDeep(newSchema), config: cloneDeep(newSchema),
parent: cloneDeep(parent), parent: cloneDeep(parent),
@ -1929,19 +1973,23 @@ class Editor extends BaseService {
}); });
} }
} }
this.emitHistoryInsertEvents(addedNodes, step.source);
} }
break; break;
} }
case 'remove': { case 'remove': {
const items = step.diff ?? []; const items = step.diff ?? [];
if (reverse) { if (reverse) {
// 撤销 remove按原 index 升序逐个插回(先小后大避免索引漂移) // 撤销 remove按原 index 升序逐个插回(先小后大避免索引漂移);不走 this.add 的原因同上
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)); const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
const addedNodes: MNode[] = [];
for (const { oldSchema, parentId, index } of sorted) { for (const { oldSchema, parentId, index } of sorted) {
if (!oldSchema || parentId === undefined) continue; if (!oldSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null; const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) { if (parent?.items) {
parent.items.splice(index ?? parent.items.length, 0, cloneDeep(oldSchema)); const addedNode = cloneDeep(oldSchema);
parent.items.splice(index ?? parent.items.length, 0, addedNode);
addedNodes.push(addedNode);
await stage?.add({ await stage?.add({
config: cloneDeep(oldSchema), config: cloneDeep(oldSchema),
parent: cloneDeep(parent), parent: cloneDeep(parent),
@ -1950,6 +1998,7 @@ class Editor extends BaseService {
}); });
} }
} }
this.emitHistoryInsertEvents(addedNodes, step.source);
} else { } else {
// 重做 remove再删一次 // 重做 remove再删一次
for (const { oldSchema } of items) { for (const { oldSchema } of items) {
@ -1989,7 +2038,7 @@ class Editor extends BaseService {
return cloneDeep(reverse ? oldNode : newNode); return cloneDeep(reverse ? oldNode : newNode);
}); });
if (configs.length) { if (configs.length) {
await this.update(configs, { doNotPushHistory: true }); await this.update(configs, { doNotPushHistory: true, historySource: step.source });
} }
break; break;
} }
@ -2028,6 +2077,22 @@ class Editor extends BaseService {
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo { private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id); return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id);
} }
/**
* remove / add splice step parentId / index this.add
* this.add add / change /
*/
private emitHistoryInsertEvents(nodes: MNode[], source?: HistoryOpSource) {
if (!nodes.length) return;
this.emit('add', nodes);
this.emit('change', {
type: 'add',
data: nodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
historySource: source,
// 撤销/重做本身不再写入历史记录
doNotPushHistory: true,
});
}
} }
export type EditorService = Editor; export type EditorService = Editor;

View File

@ -19,7 +19,7 @@
import { reactive } from 'vue'; import { reactive } from 'vue';
import type { Writable } from 'type-fest'; import type { Writable } from 'type-fest';
import type { Id } from '@tmagic/core'; import type { Id, MPage, MPageFragment } from '@tmagic/core';
import { guid } from '@tmagic/utils'; import { guid } from '@tmagic/utils';
import type { import type {
@ -353,6 +353,27 @@ class History extends BaseService {
this.emit('mark-saved', { kind: stepType, id }); this.emit('mark-saved', { kind: stepType, id });
} }
/**
* / `page-structure-change`
*
* `editorService.add` / `remove` `page` editor.add / remove
* isPage / isPageFragment historyService
* setRoot DSL
*
* ** change **
* 使`add` / `remove`
*
* @param change { add: 新增的页面列表, remove: 删除的页面列表 }
*/
public notifyPageStructureChange(change: {
add: (MPage | MPageFragment)[];
remove: (MPage | MPageFragment)[];
}): void {
// 增删均为空时不派发,避免无意义通知
if (!change.add.length && !change.remove.length) return;
this.emit('page-structure-change', change);
}
/** /**
* / / / IndexedDB * / / / IndexedDB
* *

View File

@ -49,13 +49,14 @@ const state = shallowReactive<UiState>({
width: 375, width: 375,
height: 817, height: 817,
}, },
// 先给 columnWidth 留占位,真正的 clamp 逻辑在 state 创建完成后
// 用 state.minLeftColumnWidth / state.minRightColumnWidth 作为下限重新赋值 —
// 这样以后从外部(比如 uiService.set('minLeftColumnWidth', ...))动态改最小宽度,
// 只改一处 state 字段即可,不用同步 MIN_* 常量。
columnWidth: { columnWidth: {
left: left: 0,
storageService.getItem(LEFT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) || DEFAULT_LEFT_COLUMN_WIDTH,
center: 0, center: 0,
right: right: 0,
storageService.getItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) ||
DEFAULT_RIGHT_COLUMN_WIDTH,
}, },
minLeftColumnWidth: MIN_LEFT_COLUMN_WIDTH, minLeftColumnWidth: MIN_LEFT_COLUMN_WIDTH,
minCenterColumnWidth: MIN_CENTER_COLUMN_WIDTH, minCenterColumnWidth: MIN_CENTER_COLUMN_WIDTH,
@ -83,6 +84,20 @@ const state = shallowReactive<UiState>({
}, },
}); });
// 左右两栏宽度延后初始化:
// - 优先取 storage 里用户上次拖到的值storage 空时回落到 DEFAULT_*
// - 再用 state.min*ColumnWidth 兜底,防止历史 storage 里存的是老版本或用户拖过头
// 留下的"比当前 min 还小"的数值。放到 state 之后写是因为初始化时无法在同一个
// 对象字面量里读到自己的其它字段。
state.columnWidth.left = Math.max(
storageService.getItem(LEFT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) || DEFAULT_LEFT_COLUMN_WIDTH,
state.minLeftColumnWidth,
);
state.columnWidth.right = Math.max(
storageService.getItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) || DEFAULT_RIGHT_COLUMN_WIDTH,
state.minRightColumnWidth,
);
const canUsePluginMethods = { const canUsePluginMethods = {
async: ['zoom', 'calcZoom'] as const, async: ['zoom', 'calcZoom'] as const,
sync: [] as const, sync: [] as const,

View File

@ -1,3 +1,40 @@
.m-fields-code-select { .m-fields-code-select {
width: 100%; width: 100%;
> .el-card.tmagic-design-card--flat {
background-color: transparent;
margin-bottom: 10px;
> .el-card__body {
padding-left: 0;
.code-select-content {
> .m-fields-group-list {
> .tmagic-design-card--flat.el-card {
padding-left: 16px;
padding-right: 16px;
margin-bottom: 10px;
border: 0 !important;
}
}
}
}
}
.el-card__body {
.tmagic-design-form-item {
margin-bottom: 18px;
}
}
.code-select-content {
> .m-fields-group-list {
> .tmagic-design-card--flat {
> .el-card__header {
padding: 16px 0;
}
}
}
}
.create-button {
&.fullWidth {
width: 100%;
margin: 0 0 16px 0;
}
}
} }

View File

@ -1,4 +1,4 @@
$theme-color: #2882e0; $theme-color: var(--el-color-primary, #2882e0);
$font-color: #313a40; $font-color: #313a40;
$border-color: #d9dbdd; $border-color: #d9dbdd;

View File

@ -77,9 +77,9 @@
margin-bottom: 5px; margin-bottom: 5px;
&:hover { &:hover {
background: #2882e0; background: var(--el-color-primary, #2882e0);
color: #fff; color: #fff;
border-color: #4e8be1; border-color: var(--el-color-primary, #4e8be1);
} }
} }

View File

@ -5,6 +5,10 @@
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
&.data-source-field-img-upload,
&.data-source-field-big-img-upload {
align-items: flex-start;
}
.m-fields-select-action-button { .m-fields-select-action-button {
margin-left: 5px; margin-left: 5px;

View File

@ -0,0 +1,11 @@
.m-container-text.display-conds-title {
.tmagic-design-form-item {
.el-form-item__label {
color: #111;
font-weight: 500;
font-size: 16px;
}
line-height: 24px;
margin-bottom: 10px;
}
}

View File

@ -2,8 +2,29 @@
width: 100%; width: 100%;
.fullWidth { .fullWidth {
width: 100%; width: 100%;
box-sizing: border-box;
}
.event-select-container {
padding: 0 16px;
> .el-card.tmagic-design-card--flat {
> .el-card__header {
border-bottom: 1px solid #ebeef5;
}
> .el-card__body {
padding-bottom: 16px;
.m-fields-group-list-footer {
div {
justify-content: flex-start !important;
}
}
.el-card.tmagic-design-card--flat {
> .el-card__header {
padding: 16px 0;
}
}
}
}
} }
.event-select-code { .event-select-code {
margin-left: 20px; margin-left: 20px;
width: auto; width: auto;
@ -36,10 +57,43 @@
} }
} }
.el-form-item { .event-select-container {
padding: 0 16px;
}
.event-select-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.event-select-title {
color: #0f1113;
font-size: 16px;
font-weight: 500;
line-height: 24px;
}
}
.event-item-header {
position: relative;
width: 100%;
.event-item-title {
color: #0f1113;
font-size: 16px;
font-weight: 500;
line-height: 24px;
margin-bottom: 16px;
}
.event-item-delete-button {
position: absolute;
right: 0;
top: 0;
}
.el-form-item { .el-form-item {
&.is-error { .el-form-item {
margin-bottom: 18px; &.is-error {
margin-bottom: 18px;
}
} }
} }
} }

View File

@ -6,7 +6,10 @@
width: 100%; width: 100%;
&-content { &-content {
height: calc(100% - #{$nav-height}); // nav-menu.scss `--editor-nav-height` 联动默认 fallback SCSS
// `$nav-height`35px主题覆盖时两处会同时切成同一个高度保证 nav
// 主内容区总高度 = 100%
height: calc(100% - var(--editor-nav-height, #{$nav-height}));
} }
&-framework-center { &-framework-center {

View File

@ -21,7 +21,7 @@
color: #909399; color: #909399;
&:hover { &:hover {
color: #409eff; color: var(--el-color-primary, #409eff);
} }
} }
@ -96,7 +96,7 @@
// 故这里仅作用于非合并组的单步条目避免与卡片样式互相干扰 // 故这里仅作用于非合并组的单步条目避免与卡片样式互相干扰
&.is-current:not(.m-editor-history-list-group.is-merged) { &.is-current:not(.m-editor-history-list-group.is-merged) {
background-color: rgba(64, 158, 255, 0.1); background-color: rgba(64, 158, 255, 0.1);
box-shadow: inset 2px 0 0 #409eff; box-shadow: inset 2px 0 0 var(--el-color-primary, #409eff);
&:hover { &:hover {
background-color: rgba(64, 158, 255, 0.16); background-color: rgba(64, 158, 255, 0.16);
@ -104,7 +104,7 @@
.m-editor-history-list-item-desc { .m-editor-history-list-item-desc {
font-weight: 600; font-weight: 600;
color: #409eff; color: var(--el-color-primary, #409eff);
} }
} }
} }
@ -177,11 +177,11 @@
&.is-current { &.is-current {
background-color: rgba(64, 158, 255, 0.08); background-color: rgba(64, 158, 255, 0.08);
border-color: rgba(64, 158, 255, 0.3); border-color: rgba(64, 158, 255, 0.3);
border-left-color: #409eff; border-left-color: var(--el-color-primary, #409eff);
box-shadow: none; // 覆盖 .is-current 公共的 inset 阴影 box-shadow: none; // 覆盖 .is-current 公共的 inset 阴影
.m-editor-history-list-group-head { .m-editor-history-list-group-head {
color: #409eff; color: var(--el-color-primary, #409eff);
} }
} }
} }
@ -216,7 +216,7 @@
} }
&.is-current { &.is-current {
color: #409eff; color: var(--el-color-primary, #409eff);
font-weight: 600; font-weight: 600;
background-color: rgba(64, 158, 255, 0.08); background-color: rgba(64, 158, 255, 0.08);
border-radius: 3px; border-radius: 3px;
@ -231,7 +231,7 @@
font-size: 10px; font-size: 10px;
line-height: 16px; line-height: 16px;
color: #fff; color: #fff;
background-color: #409eff; background-color: var(--el-color-primary, #409eff);
font-weight: 500; font-weight: 500;
} }
@ -395,7 +395,7 @@
border-radius: 2px; border-radius: 2px;
font-size: 10px; font-size: 10px;
line-height: 16px; line-height: 16px;
color: #409eff; color: var(--el-color-primary, #409eff);
background-color: rgba(64, 158, 255, 0.1); background-color: rgba(64, 158, 255, 0.1);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@ -471,7 +471,7 @@
flex: 1 1 auto; flex: 1 1 auto;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace; font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 11px; font-size: 11px;
color: #409eff; color: var(--el-color-primary, #409eff);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;

View File

@ -13,7 +13,11 @@
font-weight: 400; font-weight: 400;
box-sizing: border-box; box-sizing: border-box;
margin: 0px; margin: 0px;
flex: 0 0 $nav-height; // CSS 变量做主题化开关默认没设 `--editor-nav-height` 时回落到
// SCSS 变量 `$nav-height`common/var.scss35px主题 magic-admin
// 只需在祖先节点声明 `--editor-nav-height: 40px` 即可运行时覆盖避免
// `@use with` 那条对加载顺序极敏感的 SCSS 模块配置路径
flex: 0 0 var(--editor-nav-height, #{$nav-height});
border-bottom: 1px solid #d8dee8; border-bottom: 1px solid #d8dee8;
> div { > div {

View File

@ -129,7 +129,8 @@
> .m-container-tab { > .m-container-tab {
> .tmagic-design-tabs { > .tmagic-design-tabs {
> .el-tabs__content { > .el-tabs__content {
padding-top: 55px; margin-top: var(--el-tabs-header-height);
padding-top: 15px;
} }
> .el-tabs__header.is-top { > .el-tabs__header.is-top {
position: absolute; position: absolute;

View File

@ -1,5 +1,6 @@
.border-box-container { .border-box-container {
display: flex; display: flex;
--unuse-border-style: dashed;
.border-icon-container { .border-icon-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -28,24 +29,32 @@
border-color: var(--el-color-success, var(--td-success-color, #2ba471)); border-color: var(--el-color-success, var(--td-success-color, #2ba471));
} }
&.active { &.active {
border-color: var(--el-color-primary);
border-width: 1px;
// border-color: var(--el-color-primary, var(--td-brand-color, #0052d9));
}
&.center {
border-width: 1px; border-width: 1px;
border-color: var(--el-color-primary, var(--td-brand-color, #0052d9));
} }
&.border-icon-top { &.border-icon-top {
border-top-width: 2px; border-top-width: 2px;
border-style: solid dashed dashed dashed; border-style: solid var(--unuse-border-style) var(--unuse-border-style)
var(--unuse-border-style);
} }
&.border-icon-right { &.border-icon-right {
border-right-width: 2px; border-right-width: 2px;
border-style: dashed solid dashed dashed; border-style: var(--unuse-border-style) solid var(--unuse-border-style)
var(--unuse-border-style);
} }
&.border-icon-bottom { &.border-icon-bottom {
border-bottom-width: 2px; border-bottom-width: 2px;
border-style: dashed dashed solid dashed; border-style: var(--unuse-border-style) var(--unuse-border-style) solid
var(--unuse-border-style);
} }
&.border-icon-left { &.border-icon-left {
border-left-width: 2px; border-left-width: 2px;
border-style: dashed dashed dashed solid; border-style: var(--unuse-border-style) var(--unuse-border-style)
var(--unuse-border-style) solid;
} }
} }
} }

View File

@ -27,6 +27,7 @@
@use "./page-fragment-select.scss"; @use "./page-fragment-select.scss";
@use "./data-source-field.scss"; @use "./data-source-field.scss";
@use "./data-source-field-select.scss"; @use "./data-source-field-select.scss";
@use "./display-conds.scss";
@use "./scroll-bar.scss"; @use "./scroll-bar.scss";
@use "./ui-select.scss"; @use "./ui-select.scss";
@use "./layer-node-content.scss"; @use "./layer-node-content.scss";

View File

@ -0,0 +1,214 @@
// magic-admin 主题所有规则均以 `.m-editor.m-editor--magic-admin` 为前缀
// 仅在 `<MEditor theme="magic-admin" />` 时生效避免污染默认样式
@use "@tmagic/design/src/theme/themes/magic-admin/index.scss" as tMagicDesign;
@use "@tmagic/table/src/theme/index.scss" as tMagicTable;
@use "@tmagic/form/src/theme/themes/magic-admin/index.scss" as tMagicForm;
@use "../../theme.scss";
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
// 仅作用于编辑器内部 DOM 的具体样式仍保留双类避免误中 portal 上只挂 `m-theme--*`
// 的节点
.m-editor.m-editor--magic-admin {
// 主题级 CSS 变量入口想覆盖 nav 高度等 token 改这里一行即可
// CSS 级联运行时切主题 `.m-editor--magic-admin` class即可即时生效
// 不受 SCSS `@use` 加载顺序限制fallback 值定义在 common/var.scss未启用主题
// 时自动回落 35px
--editor-nav-height: 40px;
.m-editor-sidebar {
.m-editor-sidebar-header {
// padding: 8px 0;
width: 61px;
padding-top: 4px;
// sidebar 父容器是 `display: flex` (row)同级 `.m-editor-sidebar-content` width
// `calc(100% - 40px)`sidebar.scss:5240 是老 header 宽度 header 改成 61px
// 两者之和会溢出 21px 左右默认 `flex-shrink: 1` 会按权重压缩 header最终渲染
// 56.3px 而非 61px这里强制 `flex-shrink: 0` header 守住宽度多余的部分由
// content 侧承担content `100% - 40` 会自然让出
flex-shrink: 0;
border-right: 1px solid rgba(0, 84, 225, 0.1);
display: flex;
background-color: #fff;
box-sizing: border-box;
flex-direction: column;
align-items: center;
.m-editor-sidebar-header-item {
display: flex;
flex-direction: column;
align-items: center;
border-radius: 4px;
padding: 4px 2px;
width: 52px;
margin-bottom: 4px;
box-sizing: border-box;
background-color: transparent;
color: #84909d;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
> i {
color: #84909d;
font-size: 20px;
}
&.is-active {
background-color: var(--el-button-hover-bg-color, rgb(230, 238, 253));
color: var(--el-color-primary);
&:hover {
background-color: var(
--el-button-hover-bg-color,
rgb(230, 238, 253)
);
}
> i {
color: var(--el-color-primary);
}
.magic-editor-tab-panel-title {
color: var(--el-color-primary);
}
}
.magic-editor-tab-panel-title {
font-size: 12px;
line-height: 14px;
font-weight: 400;
margin: 0 4px;
width: 52px;
text-align: center;
}
}
}
.m-editor-sidebar-content {
width: calc(100% - 61px);
}
}
.m-editor-props-panel .el-input__wrapper {
border-radius: 4px;
}
.border-box-container .border-icon-container .border-icon {
border-color: #ccc;
--unuse-border-style: solid;
&.configured {
border-color: var(--el-color-success, var(--td-success-color, #2ba471));
}
&.active {
border-color: var(--el-color-primary);
background-color: rgba(0, 84, 225, 0.1);
&.center {
border-width: 2px;
}
// 命中 `.border-icon-<side>` 时只保留该侧 2px其余三边清零
// `@each` 循环避免四面手抄后续要加禁用hover 等状态也可以照搬这套结构
@each $side in (top, right, bottom, left) {
&.border-icon-#{$side} {
border-width: 0;
border-#{$side}-width: 2px;
}
}
}
&.center {
border-width: 2px;
}
border-radius: 2px;
}
.m-container-code-select {
> .el-form-item:first-child {
> .el-form-item__label:first-child > :not(.m-form-tip) {
color: #111;
font-weight: 500;
font-size: 16px;
line-height: 24px;
}
}
}
}
.m-editor.m-theme--magic-admin {
.m-editor-props-form-panel-form {
> .m-container-tab {
> .tmagic-design-tabs {
> .el-tabs__content {
background-color: #fafafa;
border-radius: 8px;
padding-left: 16px;
padding-right: 16px;
}
}
}
}
}
.m-editor.m-theme--magic-admin {
.m-fields-style-setter {
border: 0;
.tmagic-design-collapse-item {
.el-collapse-item__wrap {
padding: 16px;
}
}
}
}
.m-editor.m-theme--magic-admin {
.magic-right-panel-tabs-top {
> .el-tabs {
> .el-tabs__header {
.el-tabs__nav-wrap.is-top {
.el-tabs__nav.is-top {
.el-tabs__active-bar {
display: none;
}
.el-tabs__item {
&:not(:last-child)::after {
content: "";
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 12px;
border-right: 1px solid #e6e6e6;
}
}
}
&::after {
display: none;
}
}
}
}
}
}
.m-editor.m-theme--magic-admin {
.m-editor-nav-menu {
.menu-right,
.menu-left {
> .menu-item {
> button {
color: #111;
}
}
}
.menu-item {
margin-right: 8px;
.el-button--small {
font-size: 14px;
}
> button {
> i {
font-size: 16px;
}
}
}
}
}

View File

@ -4,6 +4,6 @@
margin-right: 3px; margin-right: 3px;
} }
span { span {
color: #2882e0; color: var(--el-color-primary, #2882e0);
} }
} }

View File

@ -140,6 +140,7 @@ export type BeforeAdd = (config: MNode, parent: MContainer) => Promise<MNode> |
export type GetConfig = (config: FormConfig) => Promise<FormConfig> | FormConfig; export type GetConfig = (config: FormConfig) => Promise<FormConfig> | FormConfig;
export interface EditorInstallOptions { export interface EditorInstallOptions {
flat?: boolean;
parseDSL: <T = any>(dsl: string) => T; parseDSL: <T = any>(dsl: string) => T;
customCreateMonacoEditor: ( customCreateMonacoEditor: (
monaco: typeof import('monaco-editor'), monaco: typeof import('monaco-editor'),
@ -1338,17 +1339,37 @@ export interface EditorUpdateChangeItem {
page: StoreState['page']; page: StoreState['page'];
} }
/** {@link EditorEvents.change} 各操作类型共有的历史相关字段。 */
export interface EditorChangeEventHistoryMeta {
/**
* DSL {@link HistoryOpOptions.historySource}
* / step `source` undefined
*/
historySource?: HistoryOpSource;
/**
* {@link HistoryOpOptions.doNotPushHistory} false
* / true/
*/
doNotPushHistory?: boolean;
}
/** /**
* {@link EditorEvents.change} `type` * {@link EditorEvents.change} `type`
* `data` node page null * `data` node page null
* `move-layer` `offset``drag-to` `targetIndex` / `targetParent` * `move-layer` `offset``drag-to` `targetIndex` / `targetParent`
* historySource / doNotPushHistory {@link EditorChangeEventHistoryMeta}
*/ */
export type EditorChangeEvent = export type EditorChangeEvent =
| { type: 'add'; data: EditorChangeItem[] } | ({ type: 'add'; data: EditorChangeItem[] } & EditorChangeEventHistoryMeta)
| { type: 'remove'; data: EditorChangeItem[] } | ({ type: 'remove'; data: EditorChangeItem[] } & EditorChangeEventHistoryMeta)
| { type: 'update'; data: EditorUpdateChangeItem[] } | ({ type: 'update'; data: EditorUpdateChangeItem[] } & EditorChangeEventHistoryMeta)
| { type: 'move-layer'; data: EditorChangeItem[]; offset: number | LayerOffset } | ({ type: 'move-layer'; data: EditorChangeItem[]; offset: number | LayerOffset } & EditorChangeEventHistoryMeta)
| { type: 'drag-to'; data: EditorChangeItem[]; targetIndex: number; targetParent: MContainer }; | ({
type: 'drag-to';
data: EditorChangeItem[];
targetIndex: number;
targetParent: MContainer;
} & EditorChangeEventHistoryMeta);
// #endregion EditorChangeEvent // #endregion EditorChangeEvent
export interface HistoryEvents { export interface HistoryEvents {
@ -1364,6 +1385,12 @@ export interface HistoryEvents {
'mark-saved': [{ kind: HistoryStepType; id?: Id }]; 'mark-saved': [{ kind: HistoryStepType; id?: Id }];
clear: [{ id: Id; stepType: HistoryStepType }]; clear: [{ id: Id; stepType: HistoryStepType }];
'marker-change': [{ id: Id; marker: StepValue; stepType: HistoryStepType }]; 'marker-change': [{ id: Id; marker: StepValue; stepType: HistoryStepType }];
/**
* / / {@link HistoryService.notifyPageStructureChange}
* add / remove / setRoot ****
* `add` / `remove`
*/
'page-structure-change': [change: { add: (MPage | MPageFragment)[]; remove: (MPage | MPageFragment)[] }];
} }
export const canUsePluginMethods = { export const canUsePluginMethods = {

View File

@ -16,14 +16,22 @@
* limitations under the License. * limitations under the License.
*/ */
import { ref } from 'vue';
import { EditorInstallOptions } from '@editor/type'; import { EditorInstallOptions } from '@editor/type';
let $TMAGIC_EDITOR: EditorInstallOptions = {} as any; let $TMAGIC_EDITOR: EditorInstallOptions = {} as any;
// 用 ref 持有 flat 全局开关const 引用本身不可变,符合 `import/no-mutable-exports`
// 通过 `.value` 改值仍保持模块级响应式语义,与 editor/plugin.ts、design/index.ts 里
// 的同名变量写法对齐。
const isGlobalFlat = ref(false);
const setEditorConfig = (option: EditorInstallOptions): void => { const setEditorConfig = (option: EditorInstallOptions): void => {
$TMAGIC_EDITOR = option; $TMAGIC_EDITOR = option;
isGlobalFlat.value = option.flat ?? false;
}; };
const getEditorConfig = <K extends keyof EditorInstallOptions>(key: K): EditorInstallOptions[K] => $TMAGIC_EDITOR[key]; const getEditorConfig = <K extends keyof EditorInstallOptions>(key: K): EditorInstallOptions[K] => $TMAGIC_EDITOR[key];
export { getEditorConfig, setEditorConfig }; export { getEditorConfig, isGlobalFlat, setEditorConfig };

View File

@ -5,10 +5,11 @@ export const LEFT_COLUMN_WIDTH_STORAGE_KEY = '$MagicEditorLeftColumnWidthData';
export const RIGHT_COLUMN_WIDTH_STORAGE_KEY = '$MagicEditorRightColumnWidthData'; export const RIGHT_COLUMN_WIDTH_STORAGE_KEY = '$MagicEditorRightColumnWidthData';
export const PROPS_PANEL_WIDTH_STORAGE_KEY = '$MagicEditorPropsPanelWidthData'; export const PROPS_PANEL_WIDTH_STORAGE_KEY = '$MagicEditorPropsPanelWidthData';
export const DEFAULT_LEFT_COLUMN_WIDTH = 310; export const DEFAULT_LEFT_COLUMN_WIDTH = 345;
export const DEFAULT_RIGHT_COLUMN_WIDTH = 480; export const DEFAULT_RIGHT_COLUMN_WIDTH = 480;
export const MIN_LEFT_COLUMN_WIDTH = 200; // 2026-06-11 设计稿修改,固定左侧栏存在时最小宽度为 345
export const MIN_LEFT_COLUMN_WIDTH = 345;
export const MIN_CENTER_COLUMN_WIDTH = 400; export const MIN_CENTER_COLUMN_WIDTH = 400;
export const MIN_RIGHT_COLUMN_WIDTH = 300; export const MIN_RIGHT_COLUMN_WIDTH = 300;

View File

@ -243,7 +243,7 @@ export const resolveFieldByPath = (
fields: DataSchema[] | undefined, fields: DataSchema[] | undefined,
fieldNames: string[], fieldNames: string[],
options: { skipNumberIndices?: boolean } = {}, options: { skipNumberIndices?: boolean } = {},
): { ok: boolean; field?: DataSchema; fields: DataSchema[] } => { ): { ok: boolean; field?: DataSchema; fields: DataSchema[]; failedName?: string } => {
let currentFields = fields || []; let currentFields = fields || [];
let field: DataSchema | undefined; let field: DataSchema | undefined;
@ -252,11 +252,11 @@ export const resolveFieldByPath = (
continue; continue;
} }
if (!currentFields.length) { if (!currentFields.length) {
return { ok: false, fields: currentFields }; return { ok: false, fields: currentFields, failedName: name };
} }
field = currentFields.find((item) => item.name === name); field = currentFields.find((item) => item.name === name);
if (!field) { if (!field) {
return { ok: false, fields: currentFields }; return { ok: false, fields: currentFields, failedName: name };
} }
currentFields = field.fields || []; currentFields = field.fields || [];
} }

View File

@ -25,6 +25,12 @@ globalThis.requestIdleCallback =
}, 1); }, 1);
}; };
globalThis.cancelIdleCallback =
globalThis.cancelIdleCallback ||
function (handle) {
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
};
export class IdleTask<T = any> extends EventEmitter { export class IdleTask<T = any> extends EventEmitter {
private taskList: TaskList<T> = []; private taskList: TaskList<T> = [];

View File

@ -16,9 +16,6 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import type { AppContext } from 'vue';
import { import {
HookType, HookType,
NODE_CONDS_KEY, NODE_CONDS_KEY,
@ -27,18 +24,7 @@ import {
NODE_DISABLE_DATA_SOURCE_KEY, NODE_DISABLE_DATA_SOURCE_KEY,
} from '@tmagic/core'; } from '@tmagic/core';
import { tMagicMessage } from '@tmagic/design'; import { tMagicMessage } from '@tmagic/design';
import type { import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form';
ChildConfig,
DisplayCondsConfig,
FormConfig,
FormState,
FormValue,
TabConfig,
TabPaneConfig,
} from '@tmagic/form';
import { validateForm } from '@tmagic/form';
import type { Services } from '@editor/type';
export const arrayOptions = [ export const arrayOptions = [
{ text: '包含', value: 'include' }, { text: '包含', value: 'include' },
@ -180,6 +166,7 @@ export const advancedTabConfig: TabPaneConfig = {
name: NODE_DISABLE_CODE_BLOCK_KEY, name: NODE_DISABLE_CODE_BLOCK_KEY,
text: '禁用代码块', text: '禁用代码块',
type: 'switch', type: 'switch',
labelPosition: 'left',
defaultValue: false, defaultValue: false,
extra: '开启后,配置的代码块将不会被执行', extra: '开启后,配置的代码块将不会被执行',
}, },
@ -187,15 +174,16 @@ export const advancedTabConfig: TabPaneConfig = {
name: NODE_DISABLE_DATA_SOURCE_KEY, name: NODE_DISABLE_DATA_SOURCE_KEY,
text: '禁用数据源', text: '禁用数据源',
type: 'switch', type: 'switch',
labelPosition: 'left',
defaultValue: false, defaultValue: false,
extra: '开启后,组件内配置的数据源相关配置将不会被编译,显隐条件将失效', extra: '开启后,组件内配置的数据源相关配置将不会被编译,显隐条件将失效',
}, },
{ {
name: 'created', name: 'created',
text: 'created', text: 'Created',
labelPosition: 'top', labelPosition: 'top',
type: 'code-select', type: 'code-select',
extra: '组件初始化时执行', titleExtra: '组件初始化时执行',
rules: [ rules: [
{ typeMatch: true, trigger: 'change' }, { typeMatch: true, trigger: 'change' },
{ {
@ -210,10 +198,10 @@ export const advancedTabConfig: TabPaneConfig = {
}, },
{ {
name: 'mounted', name: 'mounted',
text: 'mounted', text: 'Mounted',
labelPosition: 'top', labelPosition: 'top',
type: 'code-select', type: 'code-select',
extra: '组件挂载到dom时执行', titleExtra: '组件挂载到dom时执行',
rules: [ rules: [
{ typeMatch: true, trigger: 'change' }, { typeMatch: true, trigger: 'change' },
{ {
@ -228,8 +216,8 @@ export const advancedTabConfig: TabPaneConfig = {
}, },
{ {
name: 'display', name: 'display',
text: 'display', text: 'Display',
extra: '控制组件是否渲染关系的代码块返回值为false时不渲染', titleExtra: '控制组件是否渲染关系的代码块返回值为false时不渲染',
labelPosition: 'top', labelPosition: 'top',
type: 'code-select', type: 'code-select',
rules: [ rules: [
@ -263,10 +251,17 @@ export const displayTabConfig: TabPaneConfig<DisplayCondsConfig> = {
extra: (_state, { model }) => extra: (_state, { model }) =>
`条件成立时${model[NODE_CONDS_RESULT_KEY] ? '隐藏' : '显示'},不成立时${model[NODE_CONDS_RESULT_KEY] ? '显示' : '隐藏'}<br />同一条件组内的所有条件配置同时成立时表示该条件组成立,任意一个条件组成立时表示条件成立(条件组内为且的关系,条件组间为或的关系)<br />条件为空时表示成立;`, `条件成立时${model[NODE_CONDS_RESULT_KEY] ? '隐藏' : '显示'},不成立时${model[NODE_CONDS_RESULT_KEY] ? '显示' : '隐藏'}<br />同一条件组内的所有条件配置同时成立时表示该条件组成立,任意一个条件组成立时表示条件成立(条件组内为且的关系,条件组间为或的关系)<br />条件为空时表示成立;`,
}, },
{
text: '条件组配置',
static: true,
className: 'display-conds-title',
},
{ {
type: 'display-conds', type: 'display-conds',
name: NODE_CONDS_KEY, name: NODE_CONDS_KEY,
titlePrefix: '条件组', titlePrefix: '条件组',
fixed: 'right',
operateColWidth: 112,
defaultValue: [], defaultValue: [],
rules: [{ typeMatch: true }], rules: [{ typeMatch: true }],
}, },
@ -303,10 +298,13 @@ export const fillConfig = (
name: 'id', name: 'id',
text: 'ID', text: 'ID',
type: 'text', type: 'text',
disabled: true, // 走纯文本渲染,避免出现 disabled 的灰底输入框append 仍正常显示。
static: true,
append: { append: {
type: 'button', type: 'icon',
text: '复制', text: 'https://vip.image.video.qpic.cn/vupload/20260615/36cf7e1781493669935.svg',
extra: '复制',
handler: (vm, { model }) => { handler: (vm, { model }) => {
navigator.clipboard navigator.clipboard
.writeText(`${model.id}`) .writeText(`${model.id}`)
@ -339,6 +337,7 @@ export const fillConfig = (
const tabConfig: TabConfig = { const tabConfig: TabConfig = {
type: 'tab', type: 'tab',
labelWidth, labelWidth,
className: 'magic-right-panel-tabs-top',
items: [ items: [
{ {
title: '属性', title: '属性',
@ -390,86 +389,3 @@ export const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig =>
}), }),
}; };
}); });
// #region ValidatePropsFormOptions
/**
* validatePropsForm
*/
export interface ValidatePropsFormOptions {
/** 组件属性表单配置 */
config: FormConfig;
/** 待校验的表单值 */
values: FormValue;
/**
* appContext `getCurrentInstance()?.appContext`
* services MForm appContext使DataSourceInput inject
*/
appContext?: AppContext | null;
/** 编辑器服务集合,注入到临时表单的 formState */
services?: Services;
/** stage 实例,注入到临时表单的 formState */
stage?: any;
/** 外部扩展的 formState */
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* `true`
* `false`
*/
debug?: boolean;
typeMatchValid?: boolean;
}
// #endregion ValidatePropsFormOptions
/**
* + ****
*
* `@tmagic/form` `validateForm` MForm
* provides appContext formState
* stage / services
*
*
* 使
*
* @returns `''` `<br>`
* reject
*
* @example
* ```ts
* const error = await validatePropsForm({
* config,
* values,
* appContext: getCurrentInstance()?.appContext,
* services,
* stage: editorService.get('stage'),
* extendState,
* });
* if (error) {
* // 配置不合法error 为错误文案
* }
* ```
*/
export const validatePropsForm = ({
config,
values,
appContext = null,
services,
stage,
extendState,
debug,
typeMatchValid,
}: ValidatePropsFormOptions): Promise<string> =>
validateForm({
config,
debug,
typeMatchValid,
initValues: values,
// 将当前组件实例的 provides含 Editor 顶层的 services / codeOptions 等组件级 provide
// 合入 appContext使临时 MForm 中的编辑器字段组件DataSourceInput 等)能正常 inject
appContext: appContext ? { ...appContext, provides: { services } } : null,
// 与页面表单保持一致:注入 stage/services 及外部扩展状态,保证校验规则依赖的上下文可用
extendState: async (state) => ({
...((await extendState?.(state)) || {}),
stage,
services,
}),
});

View File

@ -20,6 +20,7 @@ import type { DataSourceFieldType, DataSourceSchema, Id } from '@tmagic/core';
import { NodeType } from '@tmagic/core'; import { NodeType } from '@tmagic/core';
import { appendValidateSuggestion } from '@tmagic/design'; import { appendValidateSuggestion } from '@tmagic/design';
import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form'; import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form';
import { validateTypeMatch } from '@tmagic/form';
import { import {
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
DATA_SOURCE_SET_DATA_METHOD_NAME, DATA_SOURCE_SET_DATA_METHOD_NAME,
@ -57,7 +58,7 @@ const stringifyExampleValue = (value: any): string => {
}; };
// 参考建议中最多展示的可选值个数,超出以「等」省略。 // 参考建议中最多展示的可选值个数,超出以「等」省略。
const MAX_SUGGESTION_OPTIONS = 5; const MAX_SUGGESTION_OPTIONS = 20;
/** /**
* 使xxxxxx * 使xxxxxx
@ -261,16 +262,20 @@ const validateDataSourceFieldPath = (
const ds = findDataSource(`${dsId}`); const ds = findDataSource(`${dsId}`);
if (!ds) { if (!ds) {
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion()); return defaultMessage(options.message, `数据源(${dsId})不存在`, dataSourceIdSuggestion());
} }
if (!fieldNames.length) { if (!fieldNames.length) {
return undefined; return undefined;
} }
const { field, ok } = resolveFieldByPath(ds.fields, fieldNames); const { field, ok, fields, failedName } = resolveFieldByPath(ds.fields, fieldNames);
if (!ok) { if (!ok) {
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion()); return defaultMessage(
options.message,
`数据源字段(${failedName})不存在`,
listSuggestion(fields.map((item) => item.name)),
);
} }
const allowedTypes = options.dataSourceFieldType || ['any']; const allowedTypes = options.dataSourceFieldType || ['any'];
@ -471,11 +476,12 @@ const isDataSourceFieldPathValue = (value: any, config: any): value is string[]
return `${value[0]}`.startsWith(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX); return `${value[0]}`.startsWith(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX);
}; };
const validateDataSourceFieldSelect: TypeMatchValidator = (value, { message, props }) => { const validateDataSourceFieldSelect: TypeMatchValidator = (value, { mForm, message, props }) => {
const config = props.config || {}; const config = props.config || {};
if (config.fieldConfig && !isDataSourceFieldPathValue(value, config)) { if (config.fieldConfig && !isDataSourceFieldPathValue(value, config)) {
return undefined; // 值不是数据源字段路径时,按 fieldConfig 的类型校验(与表单项自身 typeMatch 行为一致)
return validateTypeMatch(value, mForm, { ...props, config: { name: config.name, ...config.fieldConfig } }, message);
} }
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {

View File

@ -4,10 +4,27 @@
* Copyright (C) 2025 Tencent. * Copyright (C) 2025 Tencent.
*/ */
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ContentMenu from '@editor/components/ContentMenu.vue'; import ContentMenu from '@editor/components/ContentMenu.vue';
const roCallbacks: Array<() => void> = [];
class FakeResizeObserver {
public static disconnectSpy = vi.fn();
public cb: () => void;
constructor(cb: () => void) {
this.cb = cb;
roCallbacks.push(cb);
}
public observe() {}
public unobserve() {}
public disconnect() {
FakeResizeObserver.disconnectSpy();
}
}
(globalThis as any).ResizeObserver = FakeResizeObserver;
const provideServices = () => ({ const provideServices = () => ({
global: { global: {
provide: { provide: {
@ -22,6 +39,8 @@ const provideServices = () => ({
describe('ContentMenu.vue', () => { describe('ContentMenu.vue', () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
roCallbacks.length = 0;
FakeResizeObserver.disconnectSpy.mockClear();
}); });
test('show 后触发 show 事件', async () => { test('show 后触发 show 事件', async () => {
@ -67,6 +86,62 @@ describe('ContentMenu.vue', () => {
expect((wrapper.vm as any).menuPosition.top).toBeLessThanOrEqual(100); expect((wrapper.vm as any).menuPosition.top).toBeLessThanOrEqual(100);
}); });
test('菜单大小动态变化后修正位置避免超出可视范围', async () => {
// jsdom 中 clientWidth/clientHeight 默认为 0先 mock 一个足够大的视口,避免 show 时位置被修正
Object.defineProperty(document.body, 'clientHeight', { value: 1000, configurable: true });
Object.defineProperty(document.body, 'clientWidth', { value: 1000, configurable: true });
const wrapper = mount(ContentMenu as any, {
...provideServices(),
props: { menuData: [{ id: '1', type: 'button', text: 'a' }] as any },
attachTo: document.body,
});
(wrapper.vm as any).show({ clientX: 90, clientY: 90 });
await new Promise((r) => setTimeout(r, 0));
expect((wrapper.vm as any).menuPosition.top).toBe(90);
expect((wrapper.vm as any).menuPosition.left).toBe(90);
// 视口缩小为 100x100
Object.defineProperty(document.body, 'clientHeight', { value: 100, configurable: true });
Object.defineProperty(document.body, 'clientWidth', { value: 100, configurable: true });
const menuEl = wrapper.find('.magic-editor-content-menu').element as HTMLElement;
// 模拟菜单内容增多后尺寸变为 50x50
Object.defineProperty(menuEl, 'clientHeight', { value: 50, configurable: true });
Object.defineProperty(menuEl, 'clientWidth', { value: 50, configurable: true });
roCallbacks.forEach((cb) => cb());
await nextTick();
expect((wrapper.vm as any).menuPosition.top).toBe(50);
expect((wrapper.vm as any).menuPosition.left).toBe(50);
wrapper.unmount();
});
test('菜单未显示时尺寸变化不修正位置', async () => {
const wrapper = mount(ContentMenu as any, {
...provideServices(),
props: { menuData: [{ id: '1', type: 'button', text: 'a' }] as any },
});
Object.defineProperty(document.body, 'clientHeight', { value: 100, configurable: true });
roCallbacks.forEach((cb) => cb());
await nextTick();
expect((wrapper.vm as any).menuPosition.top).toBe(0);
expect((wrapper.vm as any).menuPosition.left).toBe(0);
wrapper.unmount();
});
test('卸载时断开 ResizeObserver', () => {
const wrapper = mount(ContentMenu as any, {
...provideServices(),
props: { menuData: [] as any },
});
wrapper.unmount();
expect(FakeResizeObserver.disconnectSpy).toHaveBeenCalled();
});
test('contains 判断 DOM 是否在菜单内部', async () => { test('contains 判断 DOM 是否在菜单内部', async () => {
const wrapper = mount(ContentMenu as any, { const wrapper = mount(ContentMenu as any, {
...provideServices(), ...provideServices(),

View File

@ -7,6 +7,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue'; import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import Code from '@editor/fields/Code.vue'; import Code from '@editor/fields/Code.vue';
// 用一个简单的桩组件代替 MagicCodeEditor把所有 props 原样渲染到 data-* 属性上, // 用一个简单的桩组件代替 MagicCodeEditor把所有 props 原样渲染到 data-* 属性上,
@ -45,7 +47,7 @@ vi.mock('@editor/layouts/CodeEditor.vue', () => ({
}), }),
})); }));
const mountCode = (props: Record<string, any>) => const mountCode = (props: Record<string, any>, global?: Record<string, any>) =>
mount(Code, { mount(Code, {
props: { props: {
// FieldProps 必填字段,用 as any 绕过测试中类型严格匹配 // FieldProps 必填字段,用 as any 绕过测试中类型严格匹配
@ -55,6 +57,7 @@ const mountCode = (props: Record<string, any>) =>
prop: 'codeField', prop: 'codeField',
...props, ...props,
} as any, } as any,
global,
}); });
const getEl = (wrapper: ReturnType<typeof mountCode>) => wrapper.find('.fake-code-editor').element as HTMLElement; const getEl = (wrapper: ReturnType<typeof mountCode>) => wrapper.find('.fake-code-editor').element as HTMLElement;
@ -168,6 +171,28 @@ describe('Code', () => {
}); });
}); });
describe('静默模式', () => {
test('注入 FORM_SILENT_MODE_KEY=true 时不渲染 CodeEditor', () => {
const wrapper = mountCode(
{},
{
provide: { [FORM_SILENT_MODE_KEY as symbol]: true },
},
);
expect(wrapper.find('.fake-code-editor').exists()).toBe(false);
});
test('注入 FORM_SILENT_MODE_KEY=false 时正常渲染 CodeEditor', () => {
const wrapper = mountCode(
{},
{
provide: { [FORM_SILENT_MODE_KEY as symbol]: false },
},
);
expect(wrapper.find('.fake-code-editor').exists()).toBe(true);
});
});
describe('diff 模式 (对比)', () => { describe('diff 模式 (对比)', () => {
test('isCompare=true 且有 lastValues 时切换为 diff 模式', () => { test('isCompare=true 且有 lastValues 时切换为 diff 模式', () => {
const wrapper = mountCode({ const wrapper = mountCode({

View File

@ -44,6 +44,12 @@ vi.mock('@tmagic/design', () => ({
return () => h('div', { class: 'fake-card' }, slots.default?.()); return () => h('div', { class: 'fake-card' }, slots.default?.());
}, },
}), }),
TMagicButton: defineComponent({
name: 'TMagicButton',
setup(_p, { slots }) {
return () => h('button', { class: 'fake-button' }, slots.default?.());
},
}),
})); }));
const baseProps = (extra: any = {}) => ({ const baseProps = (extra: any = {}) => ({
@ -108,13 +114,11 @@ describe('CodeSelect', () => {
expect((props.model.cs as any).hookData).toEqual([]); expect((props.model.cs as any).hookData).toEqual([]);
}); });
test('codeType row items 配置正确', () => { test('codeType items 配置正确', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any }); const wrapper = mount(CodeSelect, { props: baseProps() as any });
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;
const row = config.items[0]; const codeTypeSelect = config.items[0];
expect(row.type).toBe('row');
const codeTypeSelect = row.items[0];
expect(codeTypeSelect.name).toBe('codeType'); expect(codeTypeSelect.name).toBe('codeType');
const setModel = vi.fn(); const setModel = vi.fn();
codeTypeSelect.onChange(undefined, 'data-source-method', { setModel }); codeTypeSelect.onChange(undefined, 'data-source-method', { setModel });
@ -128,9 +132,8 @@ describe('CodeSelect', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any }); const wrapper = mount(CodeSelect, { props: baseProps() as any });
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;
const row = config.items[0]; const codeIdCol = config.items[1];
const codeIdCol = row.items[1]; const dsCol = config.items[2];
const dsCol = row.items[2];
expect(codeIdCol.display(undefined, { model: { codeType: 'code' } })).toBe(true); expect(codeIdCol.display(undefined, { model: { codeType: 'code' } })).toBe(true);
expect(codeIdCol.display(undefined, { model: { codeType: 'data-source-method' } })).toBe(false); expect(codeIdCol.display(undefined, { model: { codeType: 'data-source-method' } })).toBe(false);
expect(dsCol.display(undefined, { model: { codeType: 'data-source-method' } })).toBe(true); expect(dsCol.display(undefined, { model: { codeType: 'data-source-method' } })).toBe(true);
@ -141,9 +144,8 @@ describe('CodeSelect', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any }); const wrapper = mount(CodeSelect, { props: baseProps() as any });
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;
const row = config.items[0]; const codeIdCol = config.items[1];
const codeIdCol = row.items[1]; const dsCol = config.items[2];
const dsCol = row.items[2];
expect(codeIdCol.type).toBe('code-select-col'); expect(codeIdCol.type).toBe('code-select-col');
expect(codeIdCol.rules).toEqual([{ typeMatch: true, trigger: 'change' }]); expect(codeIdCol.rules).toEqual([{ typeMatch: true, trigger: 'change' }]);
expect(dsCol.type).toBe('data-source-method-select'); expect(dsCol.type).toBe('data-source-method-select');
@ -156,9 +158,8 @@ describe('CodeSelect', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any }); const wrapper = mount(CodeSelect, { props: baseProps() as any });
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;
const row = config.items[0]; expect(config.items[1].notEditable()).toBe(true);
expect(row.items[1].notEditable()).toBe(true); expect(config.items[2].notEditable()).toBe(true);
expect(row.items[2].notEditable()).toBe(true);
codeBlockService.getEditStatus.mockReturnValue(true); codeBlockService.getEditStatus.mockReturnValue(true);
dataSourceService.get.mockReturnValue(true); dataSourceService.get.mockReturnValue(true);
}); });

View File

@ -526,9 +526,11 @@ describe('EventSelect', () => {
], ],
}; };
const wrapper = mount(EventSelect, { props: baseProps({ model: m }) as any }); const wrapper = mount(EventSelect, { props: baseProps({ model: m }) as any });
const buttons = wrapper.findAll('button'); // 用 class 选择器直击 panel header 里的删除按钮:模板里同时存在顶部 / 底部「添加事件」按钮,
const deleteBtn = buttons[buttons.length - 1]; // 早期靠 `buttons[length - 1]` 取最后一个会误选到底部添加按钮,导致 events 没被删减。
await deleteBtn.trigger('click'); const deleteBtns = wrapper.findAll('.event-item-delete-button');
expect(deleteBtns.length).toBe(m.events.length);
await deleteBtns[deleteBtns.length - 1].trigger('click');
expect(m.events.length).toBe(1); expect(m.events.length).toBe(1);
}); });
}); });

View File

@ -91,7 +91,7 @@ const mkServices = () => {
clear: vi.fn(), clear: vi.fn(),
clearTargets: vi.fn(), clearTargets: vi.fn(),
clearIdleTasks: vi.fn(), clearIdleTasks: vi.fn(),
collectIdle: vi.fn(async () => undefined), collectIdle: vi.fn(async () => true),
collectByWorker: vi.fn(async () => undefined), collectByWorker: vi.fn(async () => undefined),
reset: vi.fn(), reset: vi.fn(),
}; };

View File

@ -95,7 +95,7 @@ vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({
// 模拟 CodeEditor 源码保存:仅传 values无 eventData、无 error对应 saveCode 路径) // 模拟 CodeEditor 源码保存:仅传 values无 eventData、无 error对应 saveCode 路径)
h('button', { h('button', {
class: 'code-save-btn', class: 'code-save-btn',
onClick: () => emit('submit', { id: 'n1', style: { color: 'red' } }), onClick: () => emit('submit', { id: 'n1', style: { color: 'red', width: '' } }),
}), }),
h('button', { class: 'submit-err-btn', onClick: () => emit('submit-error', new Error('e')) }), h('button', { class: 'submit-err-btn', onClick: () => emit('submit-error', new Error('e')) }),
h('button', { class: 'form-err-btn', onClick: () => emit('form-error', new Error('e')) }), h('button', { class: 'form-err-btn', onClick: () => emit('form-error', new Error('e')) }),
@ -239,6 +239,16 @@ describe('PropsPanel', () => {
expect(options.historySource).toBe('code'); expect(options.historySource).toBe('code');
}); });
test('CodeEditor 源码保存时 style 中的空字符串值被保留(表示清除该样式)', async () => {
const wrapper = mount(PropsPanel, { props: {} as any });
await new Promise((r) => setTimeout(r, 0));
await wrapper.find('.code-save-btn').trigger('click');
const calledNode = (editorService.update.mock.calls[0] as any)[0];
expect(calledNode.style.color).toBe('red');
expect(calledNode.style.width).toBe('');
});
test('mounted 事件 emit', async () => { test('mounted 事件 emit', async () => {
const wrapper = mount(PropsPanel, { props: {} as any }); const wrapper = mount(PropsPanel, { props: {} as any });
await wrapper.find('.mounted-btn').trigger('click'); await wrapper.find('.mounted-btn').trigger('click');

View File

@ -13,16 +13,17 @@ vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({
default: class FakeWorker { default: class FakeWorker {
public static nextData: Record<string, any> = {}; public static nextData: Record<string, any> = {};
public static nextError = false; public static nextError = false;
public static nextDelay = 0;
public onmessage: ((e: any) => void) | null = null; public onmessage: ((e: any) => void) | null = null;
public onerror: (() => void) | null = null; public onerror: (() => void) | null = null;
public postMessage() { public postMessage() {
setTimeout(() => { setTimeout(() => {
if (FakeWorker.nextError) { if (FakeWorker.nextError) {
this.onerror?.(new Event('error')); this.onerror?.();
return; return;
} }
this.onmessage?.({ data: FakeWorker.nextData }); this.onmessage?.({ data: FakeWorker.nextData });
}, 0); }, FakeWorker.nextDelay);
} }
}, },
})); }));
@ -121,7 +122,7 @@ describe('Dep service', () => {
test('collectIdle - 没有命中时立即 resolve 并 emit collected', async () => { test('collectIdle - 没有命中时立即 resolve 并 emit collected', async () => {
const fn = vi.fn(); const fn = vi.fn();
depService.on('collected', fn); depService.on('collected', fn);
await depService.collectIdle([{ id: 'n1', type: 'text' }] as any); await expect(depService.collectIdle([{ id: 'n1', type: 'text' }] as any)).resolves.toBe(true);
expect(fn).toHaveBeenCalled(); expect(fn).toHaveBeenCalled();
depService.off('collected', fn); depService.off('collected', fn);
}); });
@ -219,6 +220,83 @@ describe('Dep service', () => {
fakeWorker.nextData = {}; fakeWorker.nextData = {};
}); });
test('collectIdle 命中 target 时最终 resolve 并按批次 emit collected/ds-collected', async () => {
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
const collected = vi.fn();
const dsCollected = vi.fn();
depService.on('collected', collected);
depService.on('ds-collected', dsCollected);
const nodes = [{ id: 'n1', type: 'text' }] as any;
await expect(depService.collectIdle(nodes, {}, false, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
expect(dsCollected).toHaveBeenCalledWith(nodes, false);
expect(collected).toHaveBeenCalledWith(nodes, false);
expect(depService.get('collecting')).toBe(false);
depService.off('collected', collected);
depService.off('ds-collected', dsCollected);
});
test('clearIdleTasks 会结算在途 collectIdle避免 Promise 永久挂起且 collecting 复位', async () => {
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
expect(depService.get('collecting')).toBe(true);
// 快速触发:任务尚未执行就清空队列,批次应被主动结算而不是永久挂起
depService.clearIdleTasks();
await expect(promise).resolves.toBe(false);
expect(depService.get('collecting')).toBe(false);
});
test('reset 会结算在途 collectIdle', async () => {
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
depService.reset();
await expect(promise).resolves.toBe(false);
expect(depService.get('collecting')).toBe(false);
});
test('reset 会忽略在途 worker 的过期结果,避免覆盖新依赖', async () => {
const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any;
fakeWorker.nextDelay = 20;
fakeWorker.nextData = {
[DepTargetType.DATA_SOURCE]: { ds1: { n1: { data: {} } } },
};
const workerPromise = depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any);
depService.reset();
const target = makeTarget('ds1', DepTargetType.DATA_SOURCE);
depService.addTarget(target);
const idlePromise = depService.collectIdle(
[{ id: 'n1', type: 'text' }] as any,
{},
false,
DepTargetType.DATA_SOURCE,
);
await Promise.all([workerPromise, idlePromise]);
expect(target.deps.n1).toBeUndefined();
fakeWorker.nextDelay = 0;
fakeWorker.nextData = {};
});
test('多个批次并发时各自独立 resolve全部完成后 collecting 复位', async () => {
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
const p1 = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
const p2 = depService.collectIdle([{ id: 'n2', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
await Promise.all([p1, p2]);
expect(depService.get('collecting')).toBe(false);
});
test('destroy 会 reset 并移除监听', () => { test('destroy 会 reset 并移除监听', () => {
depService.addTarget(makeTarget('destroy-me')); depService.addTarget(makeTarget('destroy-me'));
expect(() => depService.destroy()).not.toThrow(); expect(() => depService.destroy()).not.toThrow();

View File

@ -857,6 +857,127 @@ describe('change 事件', () => {
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID); expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
} }
}); });
test('change 事件携带历史来源 historySource未传时为 undefined', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const page = editorService.get('page') as unknown as MContainer;
const fn = vi.fn();
editorService.on('change', fn);
// update
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'source' }, { historySource: 'props' });
expect(lastChangeEvent(fn).historySource).toBe('props');
// moveLayer
await editorService.select(NodeId.NODE_ID);
await editorService.moveLayer(1, { historySource: 'stage' });
expect(lastChangeEvent(fn).historySource).toBe('stage');
// dragTo
await editorService.select(NodeId.PAGE_ID);
await editorService.dragTo({ id: NodeId.NODE_ID2, type: 'text' }, page, 0, { historySource: 'stage' });
expect(lastChangeEvent(fn).historySource).toBe('stage');
// add
const newNode = (await editorService.add({ type: 'text' }, undefined, {
historySource: 'component-panel',
})) as MNode;
expect(lastChangeEvent(fn).historySource).toBe('component-panel');
// remove
await editorService.remove(newNode, { historySource: 'shortcut' });
expect(lastChangeEvent(fn).historySource).toBe('shortcut');
// 未传 historySource 时为 undefined
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'no-source' });
expect(lastChangeEvent(fn).historySource).toBeUndefined();
// doNotPushHistory 透传true 表示本次变更未写入历史记录,缺省为 false
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'no-history' }, { doNotPushHistory: true });
expect(lastChangeEvent(fn).doNotPushHistory).toBe(true);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'with-history' });
expect(lastChangeEvent(fn).doNotPushHistory).toBe(false);
editorService.off('change', fn);
});
test('撤销/重做触发的 change 事件携带被应用 step 的 source', async () => {
historyService.reset();
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'undo-source' }, { historySource: 'props' });
const fn = vi.fn();
editorService.on('change', fn);
await editorService.undo();
expect(lastChangeEvent(fn).historySource).toBe('props');
await editorService.redo();
expect(lastChangeEvent(fn).historySource).toBe('props');
editorService.off('change', fn);
});
test('撤销 remove重新插回节点时触发 changetype 为 add携带 step source', async () => {
historyService.reset();
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
await editorService.remove({ id: NodeId.NODE_ID, type: 'text' }, { historySource: 'shortcut' });
expect(editorService.getNodeById(NodeId.NODE_ID)).toBeNull();
const fn = vi.fn();
editorService.on('change', fn);
await editorService.undo();
editorService.off('change', fn);
// 节点已按 step 记录的位置插回
expect(editorService.getNodeById(NodeId.NODE_ID)?.id).toBe(NodeId.NODE_ID);
const events = fn.mock.calls.map((call) => call[0]) as EditorChangeEvent[];
const addEvent = events.find((e) => e.type === 'add');
expect(addEvent).toBeTruthy();
expect(addEvent?.historySource).toBe('shortcut');
// 撤销/重做补发的事件不写入历史记录
expect(addEvent?.doNotPushHistory).toBe(true);
if (addEvent?.type === 'add') {
expect(addEvent.data[0].node.id).toBe(NodeId.NODE_ID);
expect(addEvent.data[0].page?.id).toBe(NodeId.PAGE_ID);
}
});
test('重做 add重新插入节点时触发 changetype 为 add携带 step source', async () => {
historyService.reset();
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const newNode = (await editorService.add({ type: 'text' }, undefined, {
historySource: 'component-panel',
})) as MNode;
// 撤销 add使节点从 DSL 中移除
await editorService.undo();
expect(editorService.getNodeById(newNode.id)).toBeNull();
const fn = vi.fn();
editorService.on('change', fn);
await editorService.redo();
editorService.off('change', fn);
// 节点已重新插入
expect(editorService.getNodeById(newNode.id)?.id).toBe(newNode.id);
const events = fn.mock.calls.map((call) => call[0]) as EditorChangeEvent[];
const addEvent = events.find((e) => e.type === 'add');
expect(addEvent).toBeTruthy();
expect(addEvent?.historySource).toBe('component-panel');
// 撤销/重做补发的事件不写入历史记录
expect(addEvent?.doNotPushHistory).toBe(true);
if (addEvent?.type === 'add') {
expect(addEvent.data[0].node.id).toBe(newNode.id);
}
});
}); });
describe('undo redo', () => { describe('undo redo', () => {

View File

@ -105,8 +105,8 @@ describe('data-source utils', () => {
fields: [], fields: [],
}); });
expect(resolveFieldByPath(fields, ['obj']).field?.name).toBe('obj'); expect(resolveFieldByPath(fields, ['obj']).field?.name).toBe('obj');
expect(resolveFieldByPath(fields, ['unknown'])).toEqual({ ok: false, fields }); expect(resolveFieldByPath(fields, ['unknown'])).toEqual({ ok: false, fields, failedName: 'unknown' });
expect(resolveFieldByPath(undefined, ['x'])).toEqual({ ok: false, fields: [] }); expect(resolveFieldByPath(undefined, ['x'])).toEqual({ ok: false, fields: [], failedName: 'x' });
expect(resolveFieldByPath(fields, []).ok).toBe(true); expect(resolveFieldByPath(fields, []).ok).toBe(true);
}); });
@ -124,7 +124,9 @@ describe('data-source utils', () => {
expect(result.field?.name).toBe('item'); expect(result.field?.name).toBe('item');
expect(result.fields).toEqual([{ name: 'n', type: 'string' }]); expect(result.fields).toEqual([{ name: 'n', type: 'string' }]);
expect(resolveFieldByPath(fields, ['arr', '0', 'missing'], { skipNumberIndices: true }).ok).toBe(false); const failed = resolveFieldByPath(fields, ['arr', '0', 'missing'], { skipNumberIndices: true });
expect(failed.ok).toBe(false);
expect(failed.failedName).toBe('missing');
}); });
test('getFieldType 沿 path 取最终类型', () => { test('getFieldType 沿 path 取最终类型', () => {

Some files were not shown because too many files have changed in this diff Show More