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; values: Record>; events: Record; methods: Record; } 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({ 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(name: K, value: T) { this.state[name] = value; } public get(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) { 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(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(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(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 { 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 { 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 { 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(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): 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();