roymondchen 8dae67769c feat(editor): 数据源与代码块 service 支持 undo/redo
- dataSourceService / codeBlockService 新增 undo / redo / canUndo / canRedo 方法
- undo/redo 内部复用 add / update / remove / setCodeDslByIdSync / deleteCodeDslByIds 写回,
  并强制 doNotPushHistory,借此自动驱动 initService 中的依赖收集链路
  (DepTargetType.DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD / CODE_BLOCK)
- 更新场景下若 step 带 changeRecords,按 propPath 局部 patch,不冲掉同节点其它无关变更;
  缺省退化为整 schema / 整内容替换
- 补充对应单测与 API 文档
2026-05-28 16:40:49 +08:00

369 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { reactive } from 'vue';
import { cloneDeep, get } from 'lodash-es';
import type { Writable } from 'type-fest';
import type { DataSourceSchema, EventOption, Id, MNode, TargetOptions } from '@tmagic/core';
import { Target, Watcher } from '@tmagic/core';
import type { ChangeRecord, FormConfig } from '@tmagic/form';
import { getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import type { DataSourceStepValue, DatasourceTypeOption, SyncHookPlugin } from '@editor/type';
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
import BaseService from './BaseService';
interface State {
datasourceTypeList: DatasourceTypeOption[];
dataSources: DataSourceSchema[];
editable: boolean;
configs: Record<string, FormConfig>;
values: Record<string, Partial<DataSourceSchema>>;
events: Record<string, EventOption[]>;
methods: Record<string, EventOption[]>;
}
type StateKey = keyof State;
const canUsePluginMethods = {
async: [],
sync: [
'getFormConfig',
'setFormConfig',
'getFormValue',
'setFormValue',
'getFormEvent',
'setFormEvent',
'getFormMethod',
'setFormMethod',
'add',
'update',
'remove',
'createId',
] as const,
};
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
class DataSource extends BaseService {
private state = reactive<State>({
datasourceTypeList: [],
dataSources: [],
editable: true,
configs: {},
values: {},
events: {},
methods: {},
});
constructor() {
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
}
public set<K extends StateKey, T extends State[K]>(name: K, value: T) {
this.state[name] = value;
}
public get<K extends StateKey>(name: K): State[K] {
return this.state[name];
}
public getFormConfig(type = 'base') {
return getFormConfig(toLine(type), this.get('configs'));
}
public setFormConfig(type: string, config: FormConfig) {
this.get('configs')[toLine(type)] = config;
}
public getFormValue(type = 'base') {
return getFormValue(toLine(type), this.get('values')[type]);
}
public setFormValue(type: string, value: Partial<DataSourceSchema>) {
this.get('values')[toLine(type)] = value;
}
public getFormEvent(type = 'base') {
return this.get('events')[toLine(type)] || [];
}
public setFormEvent(type: string, value: EventOption[] = []) {
this.get('events')[toLine(type)] = value;
}
public getFormMethod(type = 'base') {
return this.get('methods')[toLine(type)] || [];
}
public setFormMethod(type: string, value: EventOption[] = []) {
this.get('methods')[toLine(type)] = value;
}
/**
* 新增数据源
* @param config 数据源配置
* @param options 可选配置
* @param options.doNotPushHistory 是否不写入历史记录(默认 false
*/
public add(config: DataSourceSchema, { doNotPushHistory = false }: { doNotPushHistory?: boolean } = {}) {
const newConfig = {
...config,
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
};
this.get('dataSources').push(newConfig);
if (!doNotPushHistory) {
historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig });
}
this.emit('add', newConfig);
return newConfig;
}
/**
* 更新数据源
* @param config 数据源配置
* @param data 额外数据
* @param data.changeRecords form 端变更记录
* @param data.doNotPushHistory 是否不写入历史记录(默认 false
*/
public update(
config: DataSourceSchema,
{
changeRecords = [],
doNotPushHistory = false,
}: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
) {
const dataSources = this.get('dataSources');
const index = dataSources.findIndex((ds) => ds.id === config.id);
const oldConfig = dataSources[index];
const newConfig = cloneDeep(config);
dataSources[index] = newConfig;
if (!doNotPushHistory) {
historyService.pushDataSource(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig,
changeRecords,
});
}
this.emit('update', newConfig, {
oldConfig,
changeRecords,
});
return newConfig;
}
/**
* 删除数据源
* @param id 数据源 id
* @param options 可选配置
* @param options.doNotPushHistory 是否不写入历史记录(默认 false
*/
public remove(id: string, { doNotPushHistory = false }: { doNotPushHistory?: boolean } = {}) {
const dataSources = this.get('dataSources');
const index = dataSources.findIndex((ds) => ds.id === id);
const oldConfig = index !== -1 ? dataSources[index] : null;
dataSources.splice(index, 1);
if (oldConfig && !doNotPushHistory) {
historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null });
}
this.emit('remove', id);
}
/**
* 撤销指定数据源的最近一次变更。
*
* 内部走 add / update / remove因此会自动触发 dataSourceService 的事件,
* 由 initService 中的对应 handler 重新收集 depDATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD
* 所有写回都带 `doNotPushHistory: true`,避免在历史栈里产生新的记录。
*
* @param id 数据源 id
* @returns 撤销的 step栈不存在或已无可撤销时返回 null
*/
public undo(id: Id) {
const step = historyService.undoDataSource(id);
if (!step) return null;
this.applyHistoryStep(step, true);
return step;
}
/**
* 重做指定数据源的下一次变更。
* @param id 数据源 id
* @returns 重做的 step栈不存在或已无可重做时返回 null
*/
public redo(id: Id) {
const step = historyService.redoDataSource(id);
if (!step) return null;
this.applyHistoryStep(step, false);
return step;
}
/** 是否可对指定数据源撤销。 */
public canUndo(id: Id): boolean {
return historyService.canUndoDataSource(id);
}
/** 是否可对指定数据源重做。 */
public canRedo(id: Id): boolean {
return historyService.canRedoDataSource(id);
}
public createId(): string {
return `ds_${guid()}`;
}
public getDataSourceById(id: string) {
return this.get('dataSources').find((ds) => ds.id === id);
}
public resetState() {
this.set('dataSources', []);
}
public destroy() {
this.removeAllListeners();
this.resetState();
this.removeAllPlugins();
}
public usePlugin(options: SyncHookPlugin<SyncMethodName, DataSource>): void {
super.usePlugin(options);
}
/**
* 复制时会带上组件关联的数据源
* @param config 组件节点配置
* @returns
*/
public copyWithRelated(config: MNode | MNode[], collectorOptions?: TargetOptions): void {
const copyNodes: MNode[] = Array.isArray(config) ? config : [config];
const copyData: DataSourceSchema[] = [];
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
const customTarget = new Target({
...collectorOptions,
});
const coperWatcher = new Watcher();
coperWatcher.addTarget(customTarget);
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
const node = editorService.getNodeById(nodeId);
if (!node) return;
customTarget!.deps[nodeId].keys.forEach((key) => {
const [relateDsId] = get(node, key);
const isExist = copyData.find((dsItem) => dsItem.id === relateDsId);
if (!isExist) {
const relateDs = this.getDataSourceById(relateDsId);
if (relateDs) {
copyData.push(relateDs);
}
}
});
});
}
storageService.setItem(COPY_DS_STORAGE_KEY, copyData, {
protocol: Protocol.OBJECT,
});
}
/**
* 粘贴数据源
* @returns
*/
public paste() {
const dataSource: DataSourceSchema[] = storageService.getItem(COPY_DS_STORAGE_KEY);
dataSource.forEach((item: DataSourceSchema) => {
// 不覆盖同样id的数据源
if (!this.getDataSourceById(item.id)) {
this.add(item);
}
});
}
/**
* 把一条历史 step 应用到当前数据源服务上。
*
* 复用现有的 add / update / remove目的是借助它们发出的事件触发 initService 中
* 的依赖收集逻辑DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD
* 所有写回都带 `doNotPushHistory: true`,确保不会在历史栈里产生新的记录。
*
* - oldSchema=null, newSchema≠null原始为新增 → undo 删除redo 再次 add
* - oldSchema≠null, newSchema=null原始为删除 → undo 还原 addredo 再次删除
* - 两侧都有:原始为更新 → 按 changeRecords 局部 patch缺省退化为整 schema 替换
*
* @param step 历史 step
* @param reverse true=撤销false=重做
*/
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
const { id, oldSchema, newSchema, changeRecords } = step;
// 新增 / 删除:直接 add 或 remove不走 patch 逻辑
if (oldSchema === null && newSchema) {
if (reverse) {
this.remove(`${id}`, { doNotPushHistory: true });
} else {
this.add(cloneDeep(newSchema), { doNotPushHistory: true });
}
return;
}
if (oldSchema && newSchema === null) {
if (reverse) {
this.add(cloneDeep(oldSchema), { doNotPushHistory: true });
} else {
this.remove(`${id}`, { doNotPushHistory: true });
}
return;
}
if (!oldSchema || !newSchema) return;
// 更新场景:优先按 changeRecords 局部 patch缺省退化为整 schema 替换
const sourceForValues = reverse ? oldSchema : newSchema;
if (changeRecords?.length) {
const current = this.getDataSourceById(`${id}`);
if (!current) return;
const patched = cloneDeep(current) as DataSourceSchema;
let fallbackToFullReplace = false;
for (const record of changeRecords) {
if (!record.propPath) {
fallbackToFullReplace = true;
break;
}
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
setValueByKeyPath(record.propPath, value, patched);
}
this.update(fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, {
changeRecords,
doNotPushHistory: true,
});
return;
}
this.update(cloneDeep(sourceForValues), { doNotPushHistory: true });
}
}
export type DataSourceService = DataSource;
export default new DataSource();