diff --git a/docs/api/editor/props.md b/docs/api/editor/props.md
index 0f97a2a5..15ef8116 100644
--- a/docs/api/editor/props.md
+++ b/docs/api/editor/props.md
@@ -1508,6 +1508,55 @@ const extendFormState = async (state) => {
```
:::
+## historyListExtraTabs
+
+- **详情:**
+
+ [历史记录面板](/guide/advanced/history-list.md) 的自定义扩展 tab。
+
+ 业务方可借此在历史记录面板内置的「页面 / 数据源 / 代码块」三个 tab 之后追加自定义模块的历史 tab,例如某个自定义模块维护自己的操作历史时,可在面板中增加一个独立的 tab 来展示与回滚。
+
+- **默认值:** `[]`
+
+- **类型:** `HistoryListExtraTab[]`
+
+ ::: details 查看 HistoryListExtraTab 类型定义
+ <<< @/../packages/editor/src/type.ts#HistoryListExtraTab{ts}
+ :::
+
+- **示例:**
+
+```html
+
+
+
+
+
+```
+
+::: tip
+内容组件内部可自行通过 `useServices()` 获取 `historyService` 等服务来读取与回滚自定义模块的历史。
+:::
+
## pageBarSortOptions
- **详情:**
diff --git a/docs/guide/advanced/history-list.md b/docs/guide/advanced/history-list.md
index 6c2d9058..ff281018 100644
--- a/docs/guide/advanced/history-list.md
+++ b/docs/guide/advanced/history-list.md
@@ -76,6 +76,47 @@ const menu = ref({
表单对比依赖 `@tmagic/form` 的对比模式(`isCompare` / `lastValues`)。对于 `event-select`、`code-select`、`code-select-col` 等由列表或嵌套子表单组成的复合字段,表单会逐项展示新增 / 删除 / 修改的高亮差异,并在对比模式下隐藏「添加 / 删除 / 编辑」等写操作按钮,仅保留只读展示。
:::
+## 扩展自定义 tab
+
+内置的三个 tab 之外,业务方可以通过 Editor 的 [`historyListExtraTabs`](/api/editor/props.html#historylistextratabs) 在面板中追加自定义的历史 tab,追加在「页面 / 数据源 / 代码块」之后。适用于某个自定义模块维护自己的操作历史,需要在历史记录面板中独立展示与回滚的场景。
+
+```html
+
+
+
+
+
+```
+
+每个扩展 tab 的字段说明:
+
+| 字段 | 必填 | 说明 |
+| --- | --- | --- |
+| `name` | 是 | tab 唯一标识,作为内部 `TMagicTabs` 的 `name` |
+| `label` | 是 | tab 显示文案,支持字符串或返回字符串的函数(便于展示动态数量) |
+| `component` | 是 | tab 内容区渲染的组件 |
+| `props` | 否 | 传入内容组件的 props |
+| `listeners` | 否 | 内容组件的事件监听 |
+
+> 内容组件内部可自行通过 `useServices()` 拿到 `historyService` 等服务,读取并回滚自定义模块自己维护的历史。
+
## 自定义对比判断
差异对话框中的「表单对比」最终透传到 `MForm`,你可以通过 Editor 顶层注入的 `extendFormState` 让对比表单拿到完整业务上下文,从而让依赖上下文的 `display` / `disabled` 等 `filterFunction` 正常工作。
diff --git a/packages/editor/src/Editor.vue b/packages/editor/src/Editor.vue
index ef1489ed..28e4adc4 100644
--- a/packages/editor/src/Editor.vue
+++ b/packages/editor/src/Editor.vue
@@ -237,6 +237,13 @@ provide('stageOptions', stageOptions);
*/
provide('extendFormState', props.extendFormState);
+/**
+ * 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel(它挂在 NavMenu 中,
+ * 以 markRaw component 形式渲染,无法直接通过 props 透传)。业务方可借此在历史记录
+ * 面板内追加自定义模块的历史 tab。
+ */
+provide('historyListExtraTabs', props.historyListExtraTabs);
+
provide('eventBus', new EventEmitter());
const propsPanelMountedHandler = (e: InstanceType) => {
diff --git a/packages/editor/src/editorProps.ts b/packages/editor/src/editorProps.ts
index 61c07c81..8c16ec3b 100644
--- a/packages/editor/src/editorProps.ts
+++ b/packages/editor/src/editorProps.ts
@@ -15,6 +15,7 @@ import type {
ComponentGroup,
CustomContentMenuFunction,
DatasourceTypeOption,
+ HistoryListExtraTab,
IsExpandableFunction,
MenuBarData,
MenuButton,
@@ -125,6 +126,8 @@ export interface EditorProps {
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise | boolean | void;
extendFormState?: (state: FormState) => Record | Promise>;
+ /** 历史记录面板的自定义扩展 tab,追加在内置的页面/数据源/代码块 tab 之后 */
+ historyListExtraTabs?: HistoryListExtraTab[];
/** 页面顺序拖拽配置参数 */
pageBarSortOptions?: PageBarSortOptions;
/** 页面搜索函数 */
@@ -145,6 +148,7 @@ export const defaultEditorProps = {
disabledCodeBlock: false,
componentGroupList: () => [],
datasourceList: () => [],
+ historyListExtraTabs: () => [],
menu: () => ({ left: [], right: [] }),
layerContentMenu: () => [],
stageContentMenu: () => [],
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index 2cc2198c..b3e25b92 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -71,6 +71,7 @@ export { default as SplitView } from './components/SplitView.vue';
export { default as Resizer } from './components/Resizer.vue';
export { default as CodeBlockEditor } from './components/CodeBlockEditor.vue';
export { default as CompareForm } from './components/CompareForm.vue';
+export { default as HistoryListBucket } from './layouts/history-list/Bucket.vue';
export { default as FloatingBox } from './components/FloatingBox.vue';
export { default as Tree } from './components/Tree.vue';
export { default as TreeNode } from './components/TreeNode.vue';
diff --git a/packages/editor/src/layouts/history-list/Bucket.vue b/packages/editor/src/layouts/history-list/Bucket.vue
index e874820f..6faa5ea8 100644
--- a/packages/editor/src/layouts/history-list/Bucket.vue
+++ b/packages/editor/src/layouts/history-list/Bucket.vue
@@ -36,8 +36,13 @@
-
+
@@ -54,29 +59,39 @@ defineOptions({
name: 'MEditorHistoryListBucket',
});
-const props = defineProps<{
- /** Bucket 标题,例如 "数据源" / "代码块",渲染在 bucket 头部。 */
- title: string;
- /** 当前 bucket 对应的目标 id(dataSource.id 或 codeBlock.id),同时用于组装子项的 key。 */
- bucketId: string | number;
- /** 子项 key 的命名空间前缀:`ds` 表示数据源,`cb` 表示代码块。与上层折叠状态 key 保持一致。 */
- prefix: 'ds' | 'cb';
- /** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
- groups: {
- applied: boolean;
- isCurrent?: boolean;
- opType: HistoryOpType;
- steps: { index: number; applied: boolean; isCurrent?: boolean; step: any }[];
- }[];
- /** 组级描述文案生成器,接收一个 group,返回展示文本。由父组件按业务类型注入。 */
- describeGroup: (_group: any) => string;
- /** 单步描述文案生成器,接收一个 step,返回展示文本。用于合并组展开后的子步列表。 */
- describeStep: (_step: any) => string;
- /** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入;不传则一律不展示差异入口。 */
- isStepDiffable?: (_step: any) => boolean;
- /** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
- expanded: Record;
-}>();
+const props = withDefaults(
+ defineProps<{
+ /** Bucket 标题,例如 "数据源" / "代码块",渲染在 bucket 头部。 */
+ title: string;
+ /** 当前 bucket 对应的目标 id(dataSource.id 或 codeBlock.id),同时用于组装子项的 key。 */
+ bucketId: string | number;
+ /**
+ * 子项 key 的命名空间前缀:内置 `ds` 表示数据源,`cb` 表示代码块;
+ * 业务方复用 Bucket 时可传入自定义前缀(如 `mod`)。与上层折叠状态 key 保持一致。
+ */
+ prefix: string;
+ /** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false 关闭。 */
+ showInitial?: boolean;
+ /** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
+ groups: {
+ applied: boolean;
+ isCurrent?: boolean;
+ opType: HistoryOpType;
+ steps: { index: number; applied: boolean; isCurrent?: boolean; step: any }[];
+ }[];
+ /** 组级描述文案生成器,接收一个 group,返回展示文本。由父组件按业务类型注入。 */
+ describeGroup: (_group: any) => string;
+ /** 单步描述文案生成器,接收一个 step,返回展示文本。用于合并组展开后的子步列表。 */
+ describeStep: (_step: any) => string;
+ /** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入;不传则一律不展示差异入口。 */
+ isStepDiffable?: (_step: any) => boolean;
+ /** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
+ expanded: Record;
+ }>(),
+ {
+ showInitial: true,
+ },
+);
defineEmits<{
/** 透传子组件 GroupRow 的 toggle,由上层 panel 更新 expanded。 */
diff --git a/packages/editor/src/layouts/history-list/GroupRow.vue b/packages/editor/src/layouts/history-list/GroupRow.vue
index 03c9134a..e6e0e135 100644
--- a/packages/editor/src/layouts/history-list/GroupRow.vue
+++ b/packages/editor/src/layouts/history-list/GroupRow.vue
@@ -35,8 +35,8 @@
#{{ s.index + 1 }}
@@ -72,34 +72,46 @@ defineOptions({
name: 'MEditorHistoryListGroupRow',
});
-const props = defineProps<{
- /** 唯一标识当前组的 key,作为 toggle 事件的 payload 回传给上层。形如 `pg-${idx}` / `ds-${id}-${idx}` / `cb-${id}-${idx}`。 */
- groupKey: string;
- /** 该组当前是否处于已应用状态(false 表示已被 undo 撤销,UI 会显示为灰态)。 */
- applied: boolean;
- /** 是否为合并组(即组内 step 数大于 1,由多次连续操作合并而来)。决定是否展示合并标记与可展开的子步列表。 */
- merged: boolean;
- /** 操作类型:`add` / `remove` / `update`,用于决定操作徽标的颜色和文案。 */
- opType: HistoryOpType;
- /** 组的整体描述文案,由上层根据 step / group 计算后传入,例如 "修改 button · style.color"。 */
- desc: string;
- /** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
- stepCount: number;
- /** 子步列表,用于在展开状态下逐条展示每个 step 的索引、应用状态与描述文案。 */
- subSteps: {
- index: number;
+const props = withDefaults(
+ defineProps<{
+ /** 唯一标识当前组的 key,作为 toggle 事件的 payload 回传给上层。形如 `pg-${idx}` / `ds-${id}-${idx}` / `cb-${id}-${idx}`。 */
+ groupKey: string;
+ /** 该组当前是否处于已应用状态(false 表示已被 undo 撤销,UI 会显示为灰态)。 */
applied: boolean;
+ /** 是否为合并组(即组内 step 数大于 1,由多次连续操作合并而来)。决定是否展示合并标记与可展开的子步列表。 */
+ merged: boolean;
+ /** 操作类型:`add` / `remove` / `update`,用于决定操作徽标的颜色和文案。 */
+ opType: HistoryOpType;
+ /** 组的整体描述文案,由上层根据 step / group 计算后传入,例如 "修改 button · style.color"。 */
desc: string;
+ /** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
+ stepCount: number;
+ /** 子步列表,用于在展开状态下逐条展示每个 step 的索引、应用状态与描述文案。 */
+ subSteps: {
+ index: number;
+ applied: boolean;
+ desc: string;
+ isCurrent?: boolean;
+ diffable?: boolean;
+ /** 是否可对该子步执行「回滚」(已应用 + 业务侧确认支持反向)。父级根据 step 与 applied 决定。 */
+ revertable?: boolean;
+ }[];
+ /** 当前组是否处于展开状态。仅在 merged 为 true 时生效,控制子步列表是否渲染。 */
+ expanded: boolean;
+ /** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组),UI 高亮展示。 */
isCurrent?: boolean;
- diffable?: boolean;
- /** 是否可对该子步执行「回滚」(已应用 + 业务侧确认支持反向)。父级根据 step 与 applied 决定。 */
- revertable?: boolean;
- }[];
- /** 当前组是否处于展开状态。仅在 merged 为 true 时生效,控制子步列表是否渲染。 */
- expanded: boolean;
- /** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组),UI 高亮展示。 */
- isCurrent?: boolean;
-}>();
+ /**
+ * 是否支持「跳转到该记录」(goto)。默认 true。
+ * 为 false 时:单步组头部与子步条目都不再可点击跳转、也不会触发 goto 事件,
+ * 仅保留合并组头部的展开 / 收起能力,以及查看差异、回滚等其它入口。
+ */
+ gotoEnabled?: boolean;
+ }>(),
+ {
+ isCurrent: false,
+ gotoEnabled: true,
+ },
+);
const emit = defineEmits<{
/**
@@ -129,15 +141,19 @@ const emit = defineEmits<{
(_e: 'revert-step', _index: number): void;
}>();
-/** 单步组:头部可点击 goto;合并组:头部可点击切换展开。当前组(isCurrent)的单步组头部不可点击。 */
+/**
+ * 单步组:头部可点击 goto(需 gotoEnabled);合并组:头部可点击切换展开。
+ * 当前组(isCurrent)或禁用 goto 时,单步组头部不可点击。
+ */
const isHeadClickable = computed(() => {
if (props.merged) return true;
- return !props.isCurrent;
+ return props.gotoEnabled && !props.isCurrent;
});
const headTitle = computed(() => {
if (props.merged) return props.expanded ? '点击收起子步' : '点击展开子步';
if (props.isCurrent) return '当前所在记录';
+ if (!props.gotoEnabled) return '';
return '点击跳转到该记录';
});
@@ -152,15 +168,23 @@ const onHeadClick = () => {
return;
}
if (props.isCurrent) return;
+ if (!props.gotoEnabled) return;
if (!props.subSteps.length) return;
emit('goto', props.subSteps[0].index);
};
const onSubStepClick = (s: { index: number; isCurrent?: boolean }) => {
if (s.isCurrent) return;
+ if (!props.gotoEnabled) return;
emit('goto', s.index);
};
+const subStepTitle = (s: { isCurrent?: boolean }) => {
+ if (s.isCurrent) return '当前所在记录';
+ if (!props.gotoEnabled) return '';
+ return '点击跳转到该记录';
+};
+
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
const headDiffable = computed(() => !props.merged && Boolean(props.subSteps[0]?.diffable));
diff --git a/packages/editor/src/layouts/history-list/HistoryListPanel.vue b/packages/editor/src/layouts/history-list/HistoryListPanel.vue
index c2eb6f81..c8791de7 100644
--- a/packages/editor/src/layouts/history-list/HistoryListPanel.vue
+++ b/packages/editor/src/layouts/history-list/HistoryListPanel.vue
@@ -32,6 +32,7 @@
@@ -47,6 +48,7 @@
@@ -60,6 +62,15 @@
@revert-step="onCodeBlockRevert"
/>
+
+
+
+
@@ -98,7 +109,7 @@
* 各 tab 的内容拆分为独立的 SFC(PageTab / DataSourceTab / CodeBlockTab),
* 共享的描述生成与折叠状态在 composables.ts 中维护。
*/
-import { inject, markRaw, ref, useTemplateRef } from 'vue';
+import { computed, inject, markRaw, ref, useTemplateRef, watch } from 'vue';
import { Clock, Close } from '@element-plus/icons-vue';
import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
@@ -106,6 +117,7 @@ import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
+import type { HistoryListExtraTab } from '@editor/type';
import CodeBlockTab from './CodeBlockTab.vue';
import { useHistoryList } from './composables';
@@ -119,14 +131,36 @@ defineOptions({
const ClockIcon = markRaw(Clock);
const CloseIcon = markRaw(Close);
-const activeTab = ref<'page' | 'data-source' | 'code-block'>('page');
+const activeTab = ref('page');
/** 面板显隐受控:reference 图标点击切换,右上角关闭按钮置为 false。 */
const visible = ref(false);
const tabPaneComponent = getDesignConfig('components')?.tabPane;
-const { editorService, dataSourceService, codeBlockService, historyService } = useServices();
+/**
+ * 业务方自定义的扩展 tab,由 Editor 顶层通过 `historyListExtraTabs` 注入。
+ * 追加在内置「页面 / 数据源 / 代码块」三个 tab 之后,未提供时为空数组。
+ */
+const extraTabs = inject('historyListExtraTabs', []);
+
+/** label 支持字符串或函数,函数形式便于展示动态数量等内容。 */
+const resolveTabLabel = (tab: HistoryListExtraTab) => (typeof tab.label === 'function' ? tab.label() : tab.label);
+
+const { editorService, dataSourceService, codeBlockService, historyService, propsService } = useServices();
+
+/**
+ * 数据源 / 代码块功能可被业务方通过 `disabledDataSource` / `disabledCodeBlock` 禁用,
+ * 禁用后对应的历史记录 tab 不再展示。若当前激活的 tab 恰好被禁用,则回退到「页面」tab。
+ */
+const disabledDataSource = computed(() => propsService.getDisabledDataSource());
+const disabledCodeBlock = computed(() => propsService.getDisabledCodeBlock());
+
+watch([disabledDataSource, disabledCodeBlock], ([dsDisabled, cbDisabled]) => {
+ if ((activeTab.value === 'data-source' && dsDisabled) || (activeTab.value === 'code-block' && cbDisabled)) {
+ activeTab.value = 'page';
+ }
+});
/**
* 通过 inject 拿到 Editor 顶层注入的 `extendFormState`,转交给 HistoryDiffDialog
diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts
index 37cc85ad..fa967dda 100644
--- a/packages/editor/src/type.ts
+++ b/packages/editor/src/type.ts
@@ -475,6 +475,29 @@ export interface SideComponent extends MenuComponent {
}
// #endregion SideComponent
+// #region HistoryListExtraTab
+/**
+ * 历史记录面板(HistoryListPanel)的自定义扩展 tab。
+ *
+ * 业务方可通过 Editor 的 `historyListExtraTabs` 注入额外的历史记录 tab,
+ * 例如某个自定义模块维护自己的操作历史时,可以在历史记录面板中增加一个
+ * 独立的 tab 来展示与回滚。内置的「页面 / 数据源 / 代码块」三个 tab 之后
+ * 会依次追加这些扩展 tab。
+ */
+export interface HistoryListExtraTab {
+ /** tab 唯一标识,作为 TMagicTabs 的 name */
+ name: string;
+ /** tab 显示文案,支持传入函数以展示动态内容(如记录数量) */
+ label: string | (() => string);
+ /** tab 内容区渲染的组件(Vue 组件或字符串标签) */
+ component: any;
+ /** 传入内容组件的 props */
+ props?: Record;
+ /** 内容组件的事件监听 */
+ listeners?: Record any>;
+}
+// #endregion HistoryListExtraTab
+
// #region SideItemKey
export enum SideItemKey {
COMPONENT_LIST = 'component-list',
diff --git a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts
index 7655da79..24e19e39 100644
--- a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts
+++ b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts
@@ -12,9 +12,13 @@ import historyService from '@editor/services/history';
const editorService = { gotoPageStep: vi.fn(async () => 0) };
const dataSourceService = { goto: vi.fn(() => 0) };
const codeBlockService = { goto: vi.fn(async () => 0) };
+const propsService = {
+ getDisabledDataSource: vi.fn(() => false),
+ getDisabledCodeBlock: vi.fn(() => false),
+};
vi.mock('@editor/hooks/use-services', () => ({
- useServices: () => ({ historyService, editorService, dataSourceService, codeBlockService }),
+ useServices: () => ({ historyService, editorService, dataSourceService, codeBlockService, propsService }),
}));
vi.mock('@tmagic/design', () => ({
@@ -251,6 +255,38 @@ describe('HistoryListPanel.vue', () => {
expect(editorService.gotoPageStep).toHaveBeenCalledWith(0);
});
+ test('注入 historyListExtraTabs 时追加渲染自定义 tab 内容组件', async () => {
+ const { default: historyListPanel } = await import('@editor/layouts/history-list/HistoryListPanel.vue');
+ const customTab = defineComponent({
+ name: 'CustomHistoryTab',
+ props: ['title'],
+ setup(p) {
+ return () => h('div', { class: 'custom-history-tab' }, p.title);
+ },
+ });
+
+ const wrapper = mount(historyListPanel, {
+ attachTo: document.body,
+ global: {
+ provide: {
+ historyListExtraTabs: [
+ {
+ name: 'custom-module',
+ label: () => '自定义模块 (1)',
+ component: customTab,
+ props: { title: 'hello-custom' },
+ },
+ ],
+ },
+ },
+ });
+ await nextTick();
+
+ const custom = wrapper.find('.custom-history-tab');
+ expect(custom.exists()).toBe(true);
+ expect(custom.text()).toBe('hello-custom');
+ });
+
test('点击数据源/代码块初始项调用对应 service.goto(id, 0)', async () => {
historyService.pushDataSource('ds_x', {
oldSchema: null,