mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-14 23:21:29 +08:00
553 lines
19 KiB
TypeScript
553 lines
19 KiB
TypeScript
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 { 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,
|
||
DslOpWithHistoryIdsResult,
|
||
HistoryOpOptions,
|
||
HistoryOpOptionsWithChangeRecords,
|
||
SyncHookPlugin,
|
||
} from '@editor/type';
|
||
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
|
||
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
|
||
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
|
||
|
||
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: {},
|
||
});
|
||
|
||
/**
|
||
* 最近一次写入历史栈的数据源历史记录 uuid。
|
||
* 供 *AndGetHistoryId 系列方法在调用 add / update / remove 后取回本次产生的历史记录 id;
|
||
* 普通方法不读取它,调用前由 *AndGetHistoryId 重置为 null。
|
||
*/
|
||
private lastPushedHistoryId: string | null = null;
|
||
|
||
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)
|
||
* @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||
*/
|
||
public add(
|
||
config: DataSourceSchema,
|
||
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
|
||
) {
|
||
const newConfig = {
|
||
...config,
|
||
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
|
||
};
|
||
|
||
this.get('dataSources').push(newConfig);
|
||
|
||
if (!doNotPushHistory) {
|
||
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(newConfig.id, {
|
||
oldValue: null,
|
||
newValue: newConfig,
|
||
historyDescription,
|
||
source: historySource,
|
||
});
|
||
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
||
}
|
||
|
||
this.emit('add', newConfig);
|
||
this.emit('change', this.get('dataSources'));
|
||
|
||
return newConfig;
|
||
}
|
||
|
||
/**
|
||
* 更新数据源
|
||
* @param config 数据源配置
|
||
* @param data 额外数据
|
||
* @param data.changeRecords form 端变更记录
|
||
* @param data.doNotPushHistory 是否不写入历史记录(默认 false)
|
||
* @param data.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||
*/
|
||
public update(
|
||
config: DataSourceSchema,
|
||
{
|
||
changeRecords = [],
|
||
doNotPushHistory = false,
|
||
historyDescription,
|
||
historySource,
|
||
}: HistoryOpOptionsWithChangeRecords = {},
|
||
) {
|
||
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) {
|
||
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(newConfig.id, {
|
||
oldValue: oldConfig ? cloneDeep(oldConfig) : null,
|
||
newValue: newConfig,
|
||
changeRecords,
|
||
historyDescription,
|
||
source: historySource,
|
||
});
|
||
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
||
}
|
||
|
||
this.emit('update', newConfig, {
|
||
oldConfig,
|
||
changeRecords,
|
||
});
|
||
this.emit('change', this.get('dataSources'));
|
||
|
||
return newConfig;
|
||
}
|
||
|
||
/**
|
||
* 删除数据源
|
||
* @param id 数据源 id
|
||
* @param options 可选配置
|
||
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||
* @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||
*/
|
||
public remove(id: string, { doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {}) {
|
||
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) {
|
||
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(id, {
|
||
oldValue: cloneDeep(oldConfig),
|
||
newValue: null,
|
||
historyDescription,
|
||
source: historySource,
|
||
});
|
||
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, id) : null)?.uuid ?? null;
|
||
}
|
||
|
||
this.emit('remove', id);
|
||
this.emit('change', this.get('dataSources'));
|
||
}
|
||
|
||
// #region AndGetHistoryId
|
||
/**
|
||
* 下列 *AndGetHistoryId 方法与对应的 add / update / remove 行为完全一致,
|
||
* 返回值在 {@link DslOpWithHistoryIdsResult} 中同时包含原操作结果与本次写入历史栈的 uuid 列表({@link DataSourceStepValue.uuid}),
|
||
* 可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。
|
||
*
|
||
* 当本次操作未写入历史(doNotPushHistory 为 true、或无对应记录)时 historyIds 为 `[]`。
|
||
*/
|
||
|
||
/** 等价于 {@link add},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||
public addAndGetHistoryId(
|
||
config: DataSourceSchema,
|
||
options: HistoryOpOptions = {},
|
||
): DslOpWithHistoryIdsResult<DataSourceSchema> {
|
||
this.lastPushedHistoryId = null;
|
||
const result = this.add(config, options);
|
||
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||
}
|
||
|
||
/** 等价于 {@link update},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||
public updateAndGetHistoryId(
|
||
config: DataSourceSchema,
|
||
options: HistoryOpOptionsWithChangeRecords = {},
|
||
): DslOpWithHistoryIdsResult<DataSourceSchema> {
|
||
this.lastPushedHistoryId = null;
|
||
const result = this.update(config, options);
|
||
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||
}
|
||
|
||
/** 等价于 {@link remove},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||
public removeAndGetHistoryId(id: string, options: HistoryOpOptions = {}): DslOpWithHistoryIdsResult<void> {
|
||
this.lastPushedHistoryId = null;
|
||
this.remove(id, options);
|
||
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||
}
|
||
// #endregion AndGetHistoryId
|
||
|
||
/**
|
||
* 撤销指定数据源的最近一次变更。
|
||
*
|
||
* 内部走 add / update / remove,因此会自动触发 dataSourceService 的事件,
|
||
* 由 initService 中的对应 handler 重新收集 dep(DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD)。
|
||
* 所有写回都带 `doNotPushHistory: true`,避免在历史栈里产生新的记录。
|
||
*
|
||
* @param id 数据源 id
|
||
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
||
*/
|
||
public undo(id: Id) {
|
||
const step = historyService.undo('dataSource', id);
|
||
if (!step) return null;
|
||
this.applyHistoryStep(step, true);
|
||
return step;
|
||
}
|
||
|
||
/**
|
||
* 重做指定数据源的下一次变更。
|
||
* @param id 数据源 id
|
||
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
||
*/
|
||
public redo(id: Id) {
|
||
const step = historyService.redo('dataSource', id);
|
||
if (!step) return null;
|
||
this.applyHistoryStep(step, false);
|
||
return step;
|
||
}
|
||
|
||
/** 是否可对指定数据源撤销。 */
|
||
public canUndo(id: Id): boolean {
|
||
return historyService.canUndo('dataSource', id);
|
||
}
|
||
|
||
/** 是否可对指定数据源重做。 */
|
||
public canRedo(id: Id): boolean {
|
||
return historyService.canRedo('dataSource', id);
|
||
}
|
||
|
||
/**
|
||
* 跳转指定数据源的历史栈到目标游标。语义同 editor.gotoPageStep。
|
||
*
|
||
* @param id 数据源 id
|
||
* @param targetCursor 目标游标位置(已应用步骤数量)
|
||
* @returns 实际移动到的最终游标位置
|
||
*/
|
||
public goto(id: Id, targetCursor: number): number {
|
||
let cursor = historyService.getCursor('dataSource', id);
|
||
const target = Math.max(0, targetCursor);
|
||
while (cursor > target) {
|
||
if (!this.undo(id)) break;
|
||
cursor -= 1;
|
||
}
|
||
while (cursor < target) {
|
||
if (!this.redo(id)) break;
|
||
cursor += 1;
|
||
}
|
||
return cursor;
|
||
}
|
||
|
||
/**
|
||
* 「回滚」指定数据源历史步骤(类 git revert 语义):
|
||
* - 不动原始栈结构(不移动 cursor、不丢弃任何步骤);
|
||
* - 把目标 step 的修改**反向应用**一次,并作为**新步骤**追加到栈顶;
|
||
* - 仅对已应用的步骤生效——未应用步骤本身就不存在于当前 schema 中,反向无意义。
|
||
*
|
||
* @param id 数据源 id
|
||
* @param index 目标 step 在该栈中的索引(0 为最早),通常由历史面板传入
|
||
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
||
*/
|
||
public revert(id: Id, index: number): DataSourceStepValue | null {
|
||
const list = historyService.getStepList('dataSource', id);
|
||
const entry = list[index];
|
||
if (!entry?.applied) return null;
|
||
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
|
||
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
|
||
if (oldSchema && newSchema && !changeRecords?.length) return null;
|
||
const description = `回滚 #${index + 1}: ${describeRevertStep<DataSourceSchema>(entry.step.data.id, entry.step.diff?.[0], (s) => s.title)}`;
|
||
return this.applyRevertStep(entry.step, description);
|
||
}
|
||
|
||
/**
|
||
* 通过历史记录 uuid 回滚数据源历史步骤,语义同 {@link revert},
|
||
* 仅无需调用方再传 dataSourceId 与 index:内部会按 uuid 在全部数据源栈中定位对应步骤后再回滚。
|
||
* 按数组顺序依次回滚,返回与入参同序的结果列表(某项失败时为 `null`)。
|
||
*
|
||
* @param uuids 目标历史记录的 uuid 列表,通常由 {@link addAndGetHistoryId} 等方法返回的 `historyIds`
|
||
*/
|
||
public revertById(uuids: string[]): (DataSourceStepValue | null)[] {
|
||
return uuids.map((uuid) => {
|
||
const location = historyService.findStepLocationByUuid('dataSource', uuid);
|
||
if (!location) return null;
|
||
return this.revert(location.id, location.index);
|
||
});
|
||
}
|
||
|
||
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 并以新 step 入栈(不带 doNotPushHistory)。逻辑与 applyHistoryStep(reverse=true)
|
||
* 同构,差异仅在于走对应的公共 add / update / remove 而不是带 doNotPushHistory 的版本。
|
||
*/
|
||
private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
|
||
const { id } = step.data;
|
||
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
||
|
||
// 原本是新增 → revert 即删除
|
||
if (!oldSchema && newSchema) {
|
||
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
|
||
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||
}
|
||
|
||
// 原本是删除 → revert 即重新加回
|
||
if (oldSchema && !newSchema) {
|
||
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
||
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||
}
|
||
|
||
if (!oldSchema || !newSchema) return null;
|
||
|
||
// 原本是更新 → 把 oldSchema 写回;优先按 changeRecords 局部 patch
|
||
if (changeRecords?.length) {
|
||
const current = this.getDataSourceById(`${id}`);
|
||
if (!current) return null;
|
||
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, oldSchema));
|
||
setValueByKeyPath(record.propPath, value, patched);
|
||
}
|
||
this.update(fallbackToFullReplace ? cloneDeep(oldSchema) : patched, {
|
||
changeRecords,
|
||
historyDescription,
|
||
historySource: 'rollback',
|
||
});
|
||
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||
}
|
||
|
||
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
||
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||
}
|
||
|
||
/**
|
||
* 把一条历史 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 还原 add;redo 再次删除
|
||
* - 两侧都有:原始为更新 → 按 changeRecords 局部 patch;缺省退化为整 schema 替换
|
||
*
|
||
* @param step 历史 step
|
||
* @param reverse true=撤销,false=重做
|
||
*/
|
||
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
|
||
const { id } = step.data;
|
||
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
||
|
||
// 新增 / 删除:直接 add 或 remove,不走 patch 逻辑
|
||
if (!oldSchema && newSchema) {
|
||
if (reverse) {
|
||
this.remove(`${id}`, { doNotPushHistory: true });
|
||
} else {
|
||
this.add(cloneDeep(newSchema), { doNotPushHistory: true });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (oldSchema && !newSchema) {
|
||
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();
|