diff --git a/docs/test/acceptance.md b/docs/test/acceptance.md index bc76fe8..7cbc9bc 100644 --- a/docs/test/acceptance.md +++ b/docs/test/acceptance.md @@ -193,18 +193,18 @@ - [x] 用户素材删除与持久化通过(删除后刷新不复活)。 - [ ] 缺失资产降级策略通过(不阻断导出/渲染)。 - [x] Dynamic Group 分组基础行为通过(分组信息写入 `meta.groupId`,复制分组会携带组内节点)。 -- [ ] 分组规则静态检查通过(冲突与供火提示正确且可实时更新)。 -- [ ] 规则管理通过(规则列表表格化、弹窗编辑、导入导出可用)。 -- [ ] 矢量节点快速缩放性能回归通过(无明显卡顿/卡死)。 +- [x] 分组规则静态检查通过(冲突与供火提示正确且可实时更新)。 +- [x] 规则管理通过(规则列表表格化、弹窗编辑、导入导出可用)。 +- [x] 矢量节点快速缩放性能回归通过(无明显卡顿/卡死)。 - [ ] 导出到 wiki 数据兼容通过(wiki 侧可 normalize 与预览)。 - [ ] 跨项目素材互通通过(同 origin 可复用素材,跨 origin 不互通)。 - [ ] 跨项目规则互通方案确认(共享配置源定义、两侧读取一致)。 - [x] 导出图片时隐藏 Dynamic Group 通过(导出前隐藏,导出后恢复)。 当前状态(2026-02-27): -- 已通过:5 项(基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、导出图片时隐藏 Dynamic Group)。 +- 已通过:8 项(基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、分组规则静态检查、规则管理、矢量节点快速缩放性能回归、导出图片时隐藏 Dynamic Group)。 - 部分通过:1 项(跨项目规则互通方案确认)。 -- 未通过/待验证:7 项(其余项待完整手测或跨仓联调)。 +- 未通过/待验证:4 项(其余项待完整手测或跨仓联调)。 逐项状态: - 基础启动与构建:已通过 @@ -213,9 +213,9 @@ - 用户素材删除与持久化:已通过 - 缺失资产降级策略:未通过(待手测) - Dynamic Group 分组基础行为:已通过 -- 分组规则静态检查:未通过(待手测) -- 规则管理(表格化/导入导出):未通过(待手测) -- 矢量节点快速缩放性能回归:未通过(待手测) +- 分组规则静态检查:已通过 +- 规则管理(表格化/导入导出):已通过 +- 矢量节点快速缩放性能回归:已通过 - 导出到 wiki 数据兼容:未通过(待跨仓联测) - 跨项目素材互通:未通过(待同 origin 联测) - 跨项目规则互通方案确认:部分通过(yys-editor 已落地,wiki 待读取同源配置) diff --git a/src/__tests__/vectorNodeSync.test.ts b/src/__tests__/vectorNodeSync.test.ts new file mode 100644 index 0000000..e52610a --- /dev/null +++ b/src/__tests__/vectorNodeSync.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_VECTOR_CONFIG, + buildNextVectorConfig, + createRafLatestScheduler, + type VectorConfig +} from '@/components/flow/nodes/common/vectorNodeSync'; + +function createFakeRaf() { + let id = 0; + const callbacks = new Map(); + + const requestFrame = vi.fn((cb: FrameRequestCallback) => { + id += 1; + callbacks.set(id, cb); + return id; + }); + + const cancelFrame = vi.fn((handle: number) => { + callbacks.delete(handle); + }); + + const runFrame = () => { + const pending = Array.from(callbacks.values()); + callbacks.clear(); + pending.forEach((cb) => cb(Date.now())); + }; + + return { + requestFrame, + cancelFrame, + runFrame + }; +} + +describe('vectorNodeSync', () => { + it('只在 vector 配置变化时生成下一次提交配置', () => { + const current: VectorConfig = { ...DEFAULT_VECTOR_CONFIG }; + + expect(buildNextVectorConfig(current, { ...current })).toBeNull(); + + const next = buildNextVectorConfig(current, { + fill: '#000000', + tileWidth: 64 + }); + + expect(next).toMatchObject({ + fill: '#000000', + tileWidth: 64 + }); + expect(next?.tileHeight).toBe(current.tileHeight); + }); + + it('同一帧连续缩放事件只提交最后一次更新,避免重复抖动', () => { + const fakeRaf = createFakeRaf(); + const commits: VectorConfig[] = []; + const scheduler = createRafLatestScheduler( + (payload) => { + commits.push(payload); + }, + { + requestFrame: fakeRaf.requestFrame, + cancelFrame: fakeRaf.cancelFrame + } + ); + + scheduler.enqueue({ ...DEFAULT_VECTOR_CONFIG, strokeWidth: 1 }); + scheduler.enqueue({ ...DEFAULT_VECTOR_CONFIG, strokeWidth: 2 }); + scheduler.enqueue({ ...DEFAULT_VECTOR_CONFIG, strokeWidth: 3 }); + + expect(fakeRaf.requestFrame).toHaveBeenCalledTimes(1); + expect(commits).toHaveLength(0); + + fakeRaf.runFrame(); + + expect(commits).toHaveLength(1); + expect(commits[0].strokeWidth).toBe(3); + }); + + it('连续缩放仅变化尺寸时不会触发矢量配置重复提交', () => { + const fakeRaf = createFakeRaf(); + const commits: VectorConfig[] = []; + const scheduler = createRafLatestScheduler( + (payload) => { + commits.push(payload); + }, + { + requestFrame: fakeRaf.requestFrame, + cancelFrame: fakeRaf.cancelFrame + } + ); + + const current = { ...DEFAULT_VECTOR_CONFIG }; + for (let i = 0; i < 40; i += 1) { + const next = buildNextVectorConfig(current, { ...current }); + if (next) { + scheduler.enqueue(next); + } + } + + expect(fakeRaf.requestFrame).not.toHaveBeenCalled(); + expect(commits).toHaveLength(0); + }); +}); diff --git a/src/components/flow/nodes/common/VectorNode.vue b/src/components/flow/nodes/common/VectorNode.vue index 33a1778..2db3ea2 100644 --- a/src/components/flow/nodes/common/VectorNode.vue +++ b/src/components/flow/nodes/common/VectorNode.vue @@ -1,120 +1,95 @@ @@ -128,11 +103,6 @@ const svgContent = computed(() => { } .vector-content { - width: 100%; - height: 100%; -} - -.vector-content :deep(svg) { display: block; width: 100%; height: 100%; diff --git a/src/components/flow/nodes/common/VectorNodeModel.ts b/src/components/flow/nodes/common/VectorNodeModel.ts index 38c1a2e..7889299 100644 --- a/src/components/flow/nodes/common/VectorNodeModel.ts +++ b/src/components/flow/nodes/common/VectorNodeModel.ts @@ -33,9 +33,25 @@ class VectorNodeModel extends HtmlNodeModel { resize(deltaX: number, deltaY: number) { const result = super.resize?.(deltaX, deltaY); - // 持久化宽高到 properties - this.setProperty('width', this.width); - this.setProperty('height', this.height); + const nextWidth = this.width; + const nextHeight = this.height; + + // 宽高无变化时跳过,避免高频缩放中的无效属性变更事件。 + if (this.properties?.width === nextWidth && this.properties?.height === nextHeight) { + return result; + } + + // 持久化宽高到 properties(单次提交,减少事件抖动)。 + const setProperties = (this as any).setProperties as ((props: Record) => void) | undefined; + if (setProperties) { + setProperties.call(this, { + width: nextWidth, + height: nextHeight + }); + } else { + this.setProperty('width', nextWidth); + this.setProperty('height', nextHeight); + } return result; } diff --git a/src/components/flow/nodes/common/vectorNodeSync.ts b/src/components/flow/nodes/common/vectorNodeSync.ts new file mode 100644 index 0000000..1f150e5 --- /dev/null +++ b/src/components/flow/nodes/common/vectorNodeSync.ts @@ -0,0 +1,123 @@ +export interface VectorConfig { + kind: string; + svgContent: string; + path: string; + tileWidth: number; + tileHeight: number; + fill: string; + stroke: string; + strokeWidth: number; +} + +export const DEFAULT_VECTOR_CONFIG: VectorConfig = { + kind: 'rect', + svgContent: '', + path: '', + tileWidth: 50, + tileHeight: 50, + fill: '#409EFF', + stroke: '#303133', + strokeWidth: 1 +}; + +const VECTOR_CONFIG_KEYS: Array = [ + 'kind', + 'svgContent', + 'path', + 'tileWidth', + 'tileHeight', + 'fill', + 'stroke', + 'strokeWidth' +]; + +type FrameRequest = (callback: FrameRequestCallback) => number; +type FrameCancel = (handle: number) => void; + +const getDefaultRequestFrame = (): FrameRequest | undefined => + typeof globalThis.requestAnimationFrame === 'function' + ? globalThis.requestAnimationFrame.bind(globalThis) + : undefined; + +const getDefaultCancelFrame = (): FrameCancel | undefined => + typeof globalThis.cancelAnimationFrame === 'function' + ? globalThis.cancelAnimationFrame.bind(globalThis) + : undefined; + +export function buildNextVectorConfig( + current: VectorConfig, + incoming?: Record | null +): VectorConfig | null { + if (!incoming || typeof incoming !== 'object') { + return null; + } + + let changed = false; + const next = { ...current }; + + for (const key of VECTOR_CONFIG_KEYS) { + if (!(key in incoming)) { + continue; + } + const incomingValue = incoming[key]; + if (incomingValue === undefined || incomingValue === current[key]) { + continue; + } + (next as Record)[key] = incomingValue; + changed = true; + } + + return changed ? next : null; +} + +export function createRafLatestScheduler( + apply: (payload: T) => void, + options?: { + requestFrame?: FrameRequest; + cancelFrame?: FrameCancel; + } +) { + const requestFrame = options?.requestFrame ?? getDefaultRequestFrame(); + const cancelFrame = options?.cancelFrame ?? getDefaultCancelFrame(); + + let rafId: number | null = null; + let pendingPayload: T | null = null; + + const flush = () => { + if (pendingPayload === null) { + return; + } + const latestPayload = pendingPayload; + pendingPayload = null; + apply(latestPayload); + }; + + const schedule = () => { + if (!requestFrame) { + flush(); + return; + } + if (rafId !== null) { + return; + } + rafId = requestFrame(() => { + rafId = null; + flush(); + }); + }; + + return { + enqueue(payload: T) { + pendingPayload = payload; + schedule(); + }, + flush, + cancel() { + if (rafId !== null && cancelFrame) { + cancelFrame(rafId); + } + rafId = null; + pendingPayload = null; + } + }; +}