From 8612311db12a22adcc30188ae1ead03729fa6a7a Mon Sep 17 00:00:00 2001 From: roymondchen Date: Mon, 1 Jun 2026 19:21:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E9=9D=A2=E6=9D=BF=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E6=89=A9=E5=B1=95=20tab=20=E5=B9=B6=E5=BC=80=E6=94=BE?= =?UTF-8?q?=20Bucket/goto=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 historyListExtraTabs 配置,可在内置页面/数据源/代码块 tab 后追加业务自定义历史 tab。 导出 HistoryListBucket 供复用,GroupRow 支持配置是否允许跳转,Bucket 支持配置是否展示初始项。 --- docs/api/editor/props.md | 49 +++++++++++ docs/guide/advanced/history-list.md | 41 ++++++++++ packages/editor/src/Editor.vue | 7 ++ packages/editor/src/editorProps.ts | 4 + packages/editor/src/index.ts | 1 + .../src/layouts/history-list/Bucket.vue | 63 ++++++++------ .../src/layouts/history-list/GroupRow.vue | 82 ++++++++++++------- .../layouts/history-list/HistoryListPanel.vue | 40 ++++++++- packages/editor/src/type.ts | 23 ++++++ .../history-list/HistoryListPanel.spec.ts | 38 ++++++++- 10 files changed, 291 insertions(+), 57 deletions(-) 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,