mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2026-01-23 22:43:28 +00:00
统一data model
This commit is contained in:
226
src/ts/schema.ts
Normal file
226
src/ts/schema.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
export const CURRENT_SCHEMA_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
export interface Transform {
|
||||||
|
SCALE_X: number;
|
||||||
|
SCALE_Y: number;
|
||||||
|
TRANSLATE_X: number;
|
||||||
|
TRANSLATE_Y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeStyle {
|
||||||
|
// Size and transform
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotate?: number; // deg
|
||||||
|
|
||||||
|
// Shape/appearance
|
||||||
|
fill?: string;
|
||||||
|
stroke?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
radius?: number | [number, number, number, number];
|
||||||
|
opacity?: number; // 0..1
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
shadow?: {
|
||||||
|
color?: string;
|
||||||
|
blur?: number;
|
||||||
|
offsetX?: number;
|
||||||
|
offsetY?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Text style for text node or nodes with text
|
||||||
|
textStyle?: {
|
||||||
|
color?: string;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
fontWeight?: number | string;
|
||||||
|
lineHeight?: number;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
verticalAlign?: 'top' | 'middle' | 'bottom';
|
||||||
|
letterSpacing?: number;
|
||||||
|
padding?: [number, number, number, number];
|
||||||
|
background?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeMeta {
|
||||||
|
z?: number;
|
||||||
|
locked?: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
groupId?: string;
|
||||||
|
name?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeProperties {
|
||||||
|
style: NodeStyle;
|
||||||
|
meta?: NodeMeta;
|
||||||
|
image?: { url: string; fit?: 'fill'|'contain'|'cover' };
|
||||||
|
text?: { content: string; rich?: boolean };
|
||||||
|
vector?: { kind: 'path'|'rect'|'ellipse'|'polygon'; path?: string; points?: Array<[number, number]> };
|
||||||
|
shikigami?: { name: string; avatar: string; rarity: string };
|
||||||
|
yuhun?: { name: string; type: string; avatar: string; shortName?: string };
|
||||||
|
property?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
properties: NodeProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
id: string;
|
||||||
|
type?: string;
|
||||||
|
sourceNodeId: string;
|
||||||
|
targetNodeId: string;
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphDocument { nodes: GraphNode[]; edges: GraphEdge[]; }
|
||||||
|
|
||||||
|
export interface FlowFile {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
type: string; // 'FLOW'
|
||||||
|
graphRawData: GraphDocument;
|
||||||
|
transform: Transform;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RootDocument {
|
||||||
|
schemaVersion: string;
|
||||||
|
fileList: FlowFile[];
|
||||||
|
activeFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DefaultNodeStyle: NodeStyle = {
|
||||||
|
width: 180,
|
||||||
|
height: 120,
|
||||||
|
rotate: 0,
|
||||||
|
fill: '#ffffff',
|
||||||
|
stroke: '#dcdfe6',
|
||||||
|
strokeWidth: 1,
|
||||||
|
radius: 4,
|
||||||
|
opacity: 1,
|
||||||
|
shadow: { color: 'rgba(0,0,0,0.1)', blur: 4, offsetX: 0, offsetY: 2 },
|
||||||
|
textStyle: {
|
||||||
|
color: '#303133',
|
||||||
|
fontFamily: 'system-ui',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
align: 'left',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
letterSpacing: 0,
|
||||||
|
padding: [8, 8, 8, 8],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureTransform(t?: Partial<Transform>): Transform {
|
||||||
|
return {
|
||||||
|
SCALE_X: t?.SCALE_X ?? 1,
|
||||||
|
SCALE_Y: t?.SCALE_Y ?? 1,
|
||||||
|
TRANSLATE_X: t?.TRANSLATE_X ?? 0,
|
||||||
|
TRANSLATE_Y: t?.TRANSLATE_Y ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration to v1 root document
|
||||||
|
export function migrateToV1(input: any): RootDocument {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Normalize a single node into the v1 shape (properties.style + meta, width/height mirrored)
|
||||||
|
const migrateNode = (node: any): GraphNode => {
|
||||||
|
const n: any = { ...node };
|
||||||
|
const props: any = n.properties ?? {};
|
||||||
|
const style: any = props.style ?? {};
|
||||||
|
const meta: any = props.meta ?? {};
|
||||||
|
|
||||||
|
// Prefer explicit style width/height; otherwise fall back to scattered fields
|
||||||
|
const propWidth = props.width ?? props.w;
|
||||||
|
const propHeight = props.height ?? props.h;
|
||||||
|
|
||||||
|
if (style.width == null) {
|
||||||
|
if (propWidth != null) {
|
||||||
|
style.width = propWidth;
|
||||||
|
} else if (n.width != null) {
|
||||||
|
style.width = n.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (style.height == null) {
|
||||||
|
if (propHeight != null) {
|
||||||
|
style.height = propHeight;
|
||||||
|
} else if (n.height != null) {
|
||||||
|
style.height = n.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure meta defaults
|
||||||
|
if (meta.visible == null) meta.visible = true;
|
||||||
|
if (meta.locked == null) meta.locked = false;
|
||||||
|
|
||||||
|
props.style = style;
|
||||||
|
props.meta = meta;
|
||||||
|
n.properties = props;
|
||||||
|
|
||||||
|
// Mirror back to node width/height for render engines that still read from the node itself
|
||||||
|
if (style.width != null) n.width = style.width;
|
||||||
|
if (style.height != null) n.height = style.height;
|
||||||
|
|
||||||
|
return n as GraphNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureGraphDocument = (f: any): GraphDocument => {
|
||||||
|
const raw = (f?.graphRawData && typeof f.graphRawData === 'object')
|
||||||
|
? f.graphRawData
|
||||||
|
: { nodes: [], edges: [] };
|
||||||
|
const nodes = Array.isArray(raw.nodes) ? raw.nodes.map(migrateNode) : [];
|
||||||
|
const edges = Array.isArray(raw.edges) ? raw.edges : [];
|
||||||
|
return { nodes, edges };
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrap = (files: any[], active?: string): RootDocument => ({
|
||||||
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||||
|
fileList: files.map((f, i) => ({
|
||||||
|
label: f?.label ?? `File ${i + 1}`,
|
||||||
|
name: f?.name ?? `File ${i + 1}`,
|
||||||
|
visible: f?.visible ?? true,
|
||||||
|
type: f?.type ?? 'FLOW',
|
||||||
|
graphRawData: ensureGraphDocument(f),
|
||||||
|
transform: ensureTransform(f?.transform),
|
||||||
|
createdAt: f?.createdAt ?? now,
|
||||||
|
updatedAt: f?.updatedAt ?? now,
|
||||||
|
id: f?.id,
|
||||||
|
})),
|
||||||
|
activeFile: active ?? (files[0]?.name ?? 'File 1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
return wrap([{ label: 'File 1', name: 'File 1', visible: true, type: 'FLOW' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return wrap(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'object' && 'fileList' in input) {
|
||||||
|
const active = (input as any).activeFile;
|
||||||
|
const files = (input as any).fileList ?? [];
|
||||||
|
const root = wrap(files, active);
|
||||||
|
// Preserve version if present
|
||||||
|
root.schemaVersion = (input as any).schemaVersion || CURRENT_SCHEMA_VERSION;
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest shape: treat input as groups array and wrap
|
||||||
|
return wrap([{ label: 'File 1', name: 'File 1', visible: true, type: 'FLOW', groups: input }]);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import {defineStore} from 'pinia';
|
import {defineStore} from 'pinia';
|
||||||
|
import {defineStore} from 'pinia';
|
||||||
import {ref, computed} from 'vue';
|
import {ref, computed} from 'vue';
|
||||||
// import type { Edge, Node, ViewportTransform } from '@vue-flow/core';
|
// import type { Edge, Node, ViewportTransform } from '@vue-flow/core';
|
||||||
import {ElMessageBox} from "element-plus";
|
import {ElMessageBox} from "element-plus";
|
||||||
import {useGlobalMessage} from "./useGlobalMessage";
|
import {useGlobalMessage} from "./useGlobalMessage";
|
||||||
import {getLogicFlowInstance} from "./useLogicFlow";
|
import {getLogicFlowInstance} from "./useLogicFlow";
|
||||||
|
import {CURRENT_SCHEMA_VERSION, migrateToV1, RootDocument} from "./schema";
|
||||||
|
|
||||||
const {showMessage} = useGlobalMessage();
|
const {showMessage} = useGlobalMessage();
|
||||||
|
|
||||||
@@ -11,6 +13,11 @@ const {showMessage} = useGlobalMessage();
|
|||||||
let localStorageDebounceTimer: NodeJS.Timeout | null = null;
|
let localStorageDebounceTimer: NodeJS.Timeout | null = null;
|
||||||
const LOCALSTORAGE_DEBOUNCE_DELAY = 1000; // 1秒防抖
|
const LOCALSTORAGE_DEBOUNCE_DELAY = 1000; // 1秒防抖
|
||||||
|
|
||||||
|
type PersistedRoot = RootDocument & {
|
||||||
|
activeFileId?: string;
|
||||||
|
activeFile?: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface FlowFile {
|
interface FlowFile {
|
||||||
id: string; // stable identity, do not rely on name for selection
|
id: string; // stable identity, do not rely on name for selection
|
||||||
label: string;
|
label: string;
|
||||||
@@ -130,41 +137,26 @@ export const useFilesStore = defineStore('files', () => {
|
|||||||
// 导入数据(兼容旧格式 activeFile/name)
|
// 导入数据(兼容旧格式 activeFile/name)
|
||||||
const importData = (data: any) => {
|
const importData = (data: any) => {
|
||||||
try {
|
try {
|
||||||
let incoming: any[] = [];
|
// 如果已有 schemaVersion,则视为 v1 RootDocument;否则通过迁移器补齐
|
||||||
if (data && Array.isArray(data.fileList)) {
|
const root: PersistedRoot = (data && typeof data === 'object' && (data as any).schemaVersion)
|
||||||
incoming = data.fileList;
|
? (data as PersistedRoot)
|
||||||
} else if (Array.isArray(data)) {
|
: migrateToV1(data) as PersistedRoot;
|
||||||
incoming = data; // old shape: file array directly
|
|
||||||
} else {
|
|
||||||
// older: only groups array -> wrap as one file
|
|
||||||
const index = fileList.value.length + 1;
|
|
||||||
const newFile: FlowFile = {
|
|
||||||
id: genId(),
|
|
||||||
label: `File ${index}`,
|
|
||||||
name: `File ${index}`,
|
|
||||||
visible: true,
|
|
||||||
type: 'FLOW',
|
|
||||||
graphRawData: { nodes: [], edges: [] },
|
|
||||||
transform: { SCALE_X: 1, SCALE_Y: 1, TRANSLATE_X: 0, TRANSLATE_Y: 0 },
|
|
||||||
};
|
|
||||||
fileList.value.push(newFile);
|
|
||||||
activeFileId.value = newFile.id;
|
|
||||||
showMessage('success', '数据导入成功');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = normalizeList(incoming);
|
const normalized = normalizeList(root.fileList || []);
|
||||||
fileList.value = normalized;
|
fileList.value = normalized;
|
||||||
|
|
||||||
// 选中逻辑:优先 activeFileId -> 其次 activeFile(name) -> 首个
|
// 选中逻辑:优先 activeFileId -> 其次 activeFile(name) -> 首个
|
||||||
let nextActiveId: string | undefined = undefined;
|
let nextActiveId: string | undefined = undefined;
|
||||||
const idFromData = (data as any).activeFileId;
|
const idFromData = (data as any).activeFileId ?? root.activeFileId;
|
||||||
if (idFromData && normalized.some(f => f.id === idFromData)) {
|
if (idFromData && normalized.some(f => f.id === idFromData)) {
|
||||||
nextActiveId = idFromData;
|
nextActiveId = idFromData;
|
||||||
} else if ((data as any).activeFile) {
|
} else {
|
||||||
const byName = normalized.find(f => f.name === (data as any).activeFile);
|
const nameFromData = (data as any).activeFile ?? root.activeFile;
|
||||||
|
if (nameFromData) {
|
||||||
|
const byName = normalized.find(f => f.name === nameFromData);
|
||||||
nextActiveId = byName?.id;
|
nextActiveId = byName?.id;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
activeFileId.value = nextActiveId || normalized[0]?.id || '';
|
activeFileId.value = nextActiveId || normalized[0]?.id || '';
|
||||||
|
|
||||||
showMessage('success', '数据导入成功');
|
showMessage('success', '数据导入成功');
|
||||||
@@ -179,6 +171,7 @@ export const useFilesStore = defineStore('files', () => {
|
|||||||
try {
|
try {
|
||||||
const activeName = findById(activeFileId.value)?.name || '';
|
const activeName = findById(activeFileId.value)?.name || '';
|
||||||
const dataStr = JSON.stringify({
|
const dataStr = JSON.stringify({
|
||||||
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||||
fileList: fileList.value,
|
fileList: fileList.value,
|
||||||
activeFileId: activeFileId.value,
|
activeFileId: activeFileId.value,
|
||||||
activeFile: activeName,
|
activeFile: activeName,
|
||||||
@@ -199,16 +192,27 @@ export const useFilesStore = defineStore('files', () => {
|
|||||||
|
|
||||||
// 启动自动恢复;如有保存的数据则直接恢复;否则用默认
|
// 启动自动恢复;如有保存的数据则直接恢复;否则用默认
|
||||||
const initializeWithPrompt = () => {
|
const initializeWithPrompt = () => {
|
||||||
const savedState = loadStateFromLocalStorage();
|
const savedStateRaw = loadStateFromLocalStorage();
|
||||||
const defaultState = getDefaultState();
|
const defaultState = getDefaultState();
|
||||||
|
|
||||||
if (savedState && savedState.fileList) {
|
if (savedStateRaw && (savedStateRaw as any).fileList) {
|
||||||
const normalized = normalizeList(savedState.fileList || []);
|
// 若已有 schemaVersion,则视为 v1;否则通过迁移器补齐到 RootDocument 形态
|
||||||
|
const root: PersistedRoot = ((savedStateRaw as any).schemaVersion)
|
||||||
|
? (savedStateRaw as PersistedRoot)
|
||||||
|
: migrateToV1(savedStateRaw) as PersistedRoot;
|
||||||
|
|
||||||
|
const normalized = normalizeList(root.fileList || []);
|
||||||
fileList.value = normalized;
|
fileList.value = normalized;
|
||||||
const id = savedState.activeFileId;
|
|
||||||
let next = (id && normalized.find(f => f.id === id)?.id) || undefined;
|
let next: string | undefined;
|
||||||
if (!next && savedState.activeFile) {
|
const idFromData = (savedStateRaw as any).activeFileId ?? root.activeFileId;
|
||||||
next = normalized.find(f => f.name === savedState.activeFile)?.id;
|
if (idFromData && normalized.some(f => f.id === idFromData)) {
|
||||||
|
next = idFromData;
|
||||||
|
} else {
|
||||||
|
const nameFromData = (savedStateRaw as any).activeFile ?? root.activeFile;
|
||||||
|
if (nameFromData) {
|
||||||
|
next = normalized.find(f => f.name === nameFromData)?.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
activeFileId.value = next || normalized[0]?.id || '';
|
activeFileId.value = next || normalized[0]?.id || '';
|
||||||
showMessage('success', '已恢复上次工作区');
|
showMessage('success', '已恢复上次工作区');
|
||||||
@@ -290,8 +294,9 @@ export const useFilesStore = defineStore('files', () => {
|
|||||||
syncLogicFlowDataToStore(targetId);
|
syncLogicFlowDataToStore(targetId);
|
||||||
|
|
||||||
// 再保存到 localStorage(带防抖)
|
// 再保存到 localStorage(带防抖)
|
||||||
const state = {
|
const state: PersistedRoot = {
|
||||||
fileList: fileList.value,
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||||
|
fileList: fileList.value as any,
|
||||||
activeFileId: activeFileId.value,
|
activeFileId: activeFileId.value,
|
||||||
activeFile: findById(activeFileId.value)?.name || ''
|
activeFile: findById(activeFileId.value)?.name || ''
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user