From e4bc6d21281de1de1c677e312404ba04c8bd2a06 Mon Sep 17 00:00:00 2001 From: rookie4show Date: Wed, 24 Dec 2025 15:48:38 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80data=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ts/schema.ts | 226 +++++++++++++++++++++++++++++++++++++++++++++ src/ts/useStore.ts | 79 ++++++++-------- 2 files changed, 268 insertions(+), 37 deletions(-) create mode 100644 src/ts/schema.ts diff --git a/src/ts/schema.ts b/src/ts/schema.ts new file mode 100644 index 0000000..c5a7518 --- /dev/null +++ b/src/ts/schema.ts @@ -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; +} + +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; +} + +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 { + 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 }]); +} diff --git a/src/ts/useStore.ts b/src/ts/useStore.ts index 4b0e3cb..18373ca 100644 --- a/src/ts/useStore.ts +++ b/src/ts/useStore.ts @@ -1,9 +1,11 @@ import {defineStore} from 'pinia'; +import {defineStore} from 'pinia'; import {ref, computed} from 'vue'; // import type { Edge, Node, ViewportTransform } from '@vue-flow/core'; import {ElMessageBox} from "element-plus"; import {useGlobalMessage} from "./useGlobalMessage"; import {getLogicFlowInstance} from "./useLogicFlow"; +import {CURRENT_SCHEMA_VERSION, migrateToV1, RootDocument} from "./schema"; const {showMessage} = useGlobalMessage(); @@ -11,6 +13,11 @@ const {showMessage} = useGlobalMessage(); let localStorageDebounceTimer: NodeJS.Timeout | null = null; const LOCALSTORAGE_DEBOUNCE_DELAY = 1000; // 1秒防抖 +type PersistedRoot = RootDocument & { + activeFileId?: string; + activeFile?: string; +}; + interface FlowFile { id: string; // stable identity, do not rely on name for selection label: string; @@ -130,40 +137,25 @@ export const useFilesStore = defineStore('files', () => { // 导入数据(兼容旧格式 activeFile/name) const importData = (data: any) => { try { - let incoming: any[] = []; - if (data && Array.isArray(data.fileList)) { - incoming = data.fileList; - } else if (Array.isArray(data)) { - 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; - } + // 如果已有 schemaVersion,则视为 v1 RootDocument;否则通过迁移器补齐 + const root: PersistedRoot = (data && typeof data === 'object' && (data as any).schemaVersion) + ? (data as PersistedRoot) + : migrateToV1(data) as PersistedRoot; - const normalized = normalizeList(incoming); + const normalized = normalizeList(root.fileList || []); fileList.value = normalized; // 选中逻辑:优先 activeFileId -> 其次 activeFile(name) -> 首个 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)) { nextActiveId = idFromData; - } else if ((data as any).activeFile) { - const byName = normalized.find(f => f.name === (data as any).activeFile); - nextActiveId = byName?.id; + } else { + const nameFromData = (data as any).activeFile ?? root.activeFile; + if (nameFromData) { + const byName = normalized.find(f => f.name === nameFromData); + nextActiveId = byName?.id; + } } activeFileId.value = nextActiveId || normalized[0]?.id || ''; @@ -179,6 +171,7 @@ export const useFilesStore = defineStore('files', () => { try { const activeName = findById(activeFileId.value)?.name || ''; const dataStr = JSON.stringify({ + schemaVersion: CURRENT_SCHEMA_VERSION, fileList: fileList.value, activeFileId: activeFileId.value, activeFile: activeName, @@ -199,16 +192,27 @@ export const useFilesStore = defineStore('files', () => { // 启动自动恢复;如有保存的数据则直接恢复;否则用默认 const initializeWithPrompt = () => { - const savedState = loadStateFromLocalStorage(); + const savedStateRaw = loadStateFromLocalStorage(); const defaultState = getDefaultState(); - if (savedState && savedState.fileList) { - const normalized = normalizeList(savedState.fileList || []); + if (savedStateRaw && (savedStateRaw as any).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; - const id = savedState.activeFileId; - let next = (id && normalized.find(f => f.id === id)?.id) || undefined; - if (!next && savedState.activeFile) { - next = normalized.find(f => f.name === savedState.activeFile)?.id; + + let next: string | undefined; + const idFromData = (savedStateRaw as any).activeFileId ?? root.activeFileId; + 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 || ''; showMessage('success', '已恢复上次工作区'); @@ -290,8 +294,9 @@ export const useFilesStore = defineStore('files', () => { syncLogicFlowDataToStore(targetId); // 再保存到 localStorage(带防抖) - const state = { - fileList: fileList.value, + const state: PersistedRoot = { + schemaVersion: CURRENT_SCHEMA_VERSION, + fileList: fileList.value as any, activeFileId: activeFileId.value, activeFile: findById(activeFileId.value)?.name || '' }; @@ -351,4 +356,4 @@ export const useFilesStore = defineStore('files', () => { activeFileId, visibleFiles, }; -});;; \ No newline at end of file +});;;