From 5cb37923c1cc92cc0262d2247fa83cb89e04cad1 Mon Sep 17 00:00:00 2001 From: rookie4show Date: Thu, 26 Feb 2026 21:08:08 +0800 Subject: [PATCH 01/12] feat: custom assets + group rules + perf + docs --- docs/1management/plan.md | 46 +++--- docs/test/README.md | 6 + docs/test/acceptance.md | 116 ++++++++++++++ .../common/GenericImageSelector.vue | 80 +++++++++- src/components/flow/FlowEditor.vue | 67 ++++++++ .../flow/nodes/common/VectorNode.vue | 54 +++++-- .../flow/panels/AssetSelectorPanel.vue | 27 +++- src/configs/groupRules.ts | 52 ++++++ src/index.js | 2 + src/types/selector.ts | 6 +- src/utils/customAssets.ts | 88 +++++++++++ src/utils/groupRules.ts | 148 ++++++++++++++++++ 12 files changed, 656 insertions(+), 36 deletions(-) create mode 100644 docs/test/README.md create mode 100644 docs/test/acceptance.md create mode 100644 src/configs/groupRules.ts create mode 100644 src/utils/customAssets.ts create mode 100644 src/utils/groupRules.ts diff --git a/docs/1management/plan.md b/docs/1management/plan.md index d2829f0..aea6711 100644 --- a/docs/1management/plan.md +++ b/docs/1management/plan.md @@ -6,8 +6,8 @@ **技术栈:** Vue 3 + LogicFlow + Element Plus + Pinia **目标:** 作为独立编辑器和可嵌入组件,支持在 onmyoji-wiki 中作为块插件使用 -**当前状态:** ✅ 阶段 1 完成(独立编辑器)+ ✅ 阶段 2 完成(组件化改造) -**总体完成度:** 100%(核心功能) +**当前状态:** ✅ 阶段 1 完成(独立编辑器)+ ✅ 阶段 2 完成(组件化改造)+ 🔄 阶段 3 进行中(wiki 集成稳定化) +**总体完成度:** 92%(核心功能完成,集成与质量收尾中) --- @@ -206,30 +206,30 @@ --- -### 🎨 阶段 3:wiki 集成测试(待开发) +### 🎨 阶段 3:wiki 集成测试(进行中) **目标:** 在 onmyoji-wiki 中测试集成效果 #### 步骤 5:本地引用测试(1-2 天) -- [ ] 在 wiki 中引用 yys-editor(file: 方式) -- [ ] 创建 YysEditorBlock 组件 -- [ ] 测试预览模式 -- [ ] 测试编辑模式 -- [ ] 测试数据保存 +- [x] 在 wiki 中引用 yys-editor(file: 方式) +- [x] 创建集成包装层(当前以 `/editor` 页面集成替代独立 `YysEditorBlock` 组件) +- [x] 测试预览模式 +- [x] 测试编辑模式 +- [x] 测试数据保存 #### 步骤 6:交互优化(2-3 天) -- [ ] 优化模式切换体验 -- [ ] 优化数据同步 -- [ ] 优化错误处理 +- [x] 优化模式切换体验 +- [x] 优化数据同步 +- [x] 优化错误处理 - [ ] 优化加载性能 **验收标准:** -- 在 wiki 中可以正常使用 -- 预览/编辑切换流畅 -- 数据保存正确 -- 体验类似 Notion 块 +- 在 wiki 中可以正常使用(已达成) +- 预览/编辑切换流畅(已达成) +- 数据保存正确(已达成) +- 体验类似 Notion 块(进行中,持续优化) --- @@ -301,12 +301,12 @@ wiki 文档 **完成时间:** 2026-02-20 -### Milestone 3:wiki 集成(待开发) -- [ ] 本地引用测试 -- [ ] 交互优化 +### Milestone 3:wiki 集成(进行中) +- [x] 本地引用测试 +- [~] 交互优化(已完成主要问题修复,继续打磨性能) - [ ] 文档完善 -**预计完成:** 与 wiki 同步 +**预计完成:** 2026-03 第 1 周(随 wiki 联调收尾) --- @@ -428,6 +428,11 @@ const handleCancel = () => { ## 📝 更新日志 +### 2026-02-26 +- ✅ 修复嵌入式编辑器在 wiki 弹层中的画布高度与边界占满问题(多次 resize + 容器高度链路修正) +- ✅ 修复编辑已有资产后立即保存时数据偶发不刷新的问题(保存前 flush + 预览强制 key 更新) +- ✅ 完成与 onmyoji-wiki 的本地库联调闭环(`build:lib` + `file:../yys-editor`) + ### 2026-02-25 - ✅ 修复嵌入编辑器在 onmyoji-wiki 弹层中的初始化尺寸异常 - 编辑区域高度改为基于容器测量后计算 @@ -453,6 +458,7 @@ const handleCancel = () => { --- -**最后更新:** 2026-02-20 +**最后更新:** 2026-02-26 +**文档版本:** v2.2.0(wiki 集成稳定化进行中) **文档版本:** v2.1.0(组件化改造完成) **文档版本:** v2.0.0(重新规划) diff --git a/docs/test/README.md b/docs/test/README.md new file mode 100644 index 0000000..93dbd60 --- /dev/null +++ b/docs/test/README.md @@ -0,0 +1,6 @@ +# 测试索引(yys-editor) + +本目录用于记录 yys-editor 的人工验收测试点(不包含自动化测试)。 + +- 主验收清单:`docs/test/acceptance.md` + diff --git a/docs/test/acceptance.md b/docs/test/acceptance.md new file mode 100644 index 0000000..c5a4304 --- /dev/null +++ b/docs/test/acceptance.md @@ -0,0 +1,116 @@ +# yys-editor 验收测试点(手工) + +目标:覆盖“用户素材上传/管理、资产引用、Dynamic Group 规则提示、性能优化”等需求。 + +## 0. 基础启动与构建 + +步骤: +- `npm install` +- `npm run dev` +- `npm run build` + +预期: +- dev 正常启动,页面可操作。 +- build 成功输出 `dist/`。 + +## 1. 资产基路径与引用一致性 + +步骤: +- 在编辑器中插入素材节点(式神/御魂等),保存。 +- 刷新页面或重新打开。 + +预期: +- 素材仍能正确显示。 +- 对于以 `/assets/...` 开头的资源,能够在宿主子路径部署时被正确改写(由宿主配置/注入决定)。 + +排查点: +- `src/utils/assetUrl.ts` 的 `setAssetBaseUrl/getAssetBaseUrl/resolveAssetUrl`。 + +## 2. 用户素材上传与使用(我的素材) + +步骤: +- 打开素材选择面板(AssetSelector)。 +- 点击“上传我的素材”,选择一张图片。 +- 在列表中找到该素材,点击选中。 + +预期: +- 新素材出现在“我的素材”分组。 +- 选择后节点的 selectedAsset 生效并可渲染。 + +## 3. 用户素材删除与持久化 + +步骤: +- 上传 1 张素材。 +- 删除该素材(按钮“删除”)。 +- 刷新页面。 + +预期: +- 删除后不再出现在列表中。 +- 刷新后不会复活(localStorage 已同步)。 + +排查点: +- `src/utils/customAssets.ts` 的 `list/save/delete/createCustomAssetFromFile`。 +- `src/components/common/GenericImageSelector.vue` 的上传与删除逻辑。 + +## 4. 缺失资产的降级策略(本地自玩导出图) + +目的:验证“场景 1:仅 yys-editor 使用并导出图片时,缺失资产不应崩溃”。 + +步骤: +- 将某个节点的 avatar 修改为不存在路径或不可访问路径(用于测试)。 +- 尝试导出/渲染。 + +预期: +- 不出现阻断性异常(可降级为占位或提示)。 + +备注: +- 若目前仅实现 wiki 侧降级:记录为“待补 yys-editor 侧降级策略”。 + +## 5. Dynamic Group 分组(基础行为) + +步骤: +- 在画布上创建多个节点。 +- 创建动态分组(Dynamic Group),将节点加入/移出分组。 + +预期: +- 分组操作成功。 +- 分组信息能写入节点 meta(用于规则检查)。 + +## 6. 规则静态检查(分组内) + +步骤: +- 在同一分组中放入: + - “辉夜姬” 与 “破势” + - “千姬” 与 “腹肌清姬/蝮骨清姬” + - 只有式神但没有供火式神 +- 观察右侧/控制区的规则提示列表。 + +预期: +- 出现对应警告提示。 +- 取消分组、移除节点后提示实时更新/消失。 + +排查点: +- `src/utils/groupRules.ts`、`src/configs/groupRules.ts`。 +- `src/components/flow/FlowEditor.vue` 的 `scheduleGroupRuleValidation(...)` 调度时机。 + +## 7. 性能回归(矢量节点快速缩放) + +步骤: +- 放置矢量节点(VectorNode)。 +- 快速缩放、连续拖动缩放柄。 + +预期: +- 明显卡顿减少,不出现“缩放一下就卡死”的体验。 + +排查点: +- `src/components/flow/nodes/common/VectorNode.vue` 的 RAF 合并更新逻辑。 + +## 8. 导出给 wiki 的兼容性(数据结构) + +步骤: +- 生成一份包含分组、素材、文本等内容的 graphData。 +- 将 JSON 用于 wiki 的 FlowPreview/editor。 + +预期: +- wiki 侧能正常 normalize 并预览(节点 off-canvas 会自动平移回可视区)。 + diff --git a/src/components/common/GenericImageSelector.vue b/src/components/common/GenericImageSelector.vue index 0ab5c01..6ff398b 100644 --- a/src/components/common/GenericImageSelector.vue +++ b/src/components/common/GenericImageSelector.vue @@ -4,6 +4,17 @@ 当前选择:{{ config.currentItem[config.itemRender.labelField] }} +
+ + 上传我的素材 +
+
{{ item[config.itemRender.labelField] }} + + 删除 +
@@ -54,9 +74,10 @@ diff --git a/src/components/common/GenericImageSelector.vue b/src/components/common/GenericImageSelector.vue index 6ff398b..f6bf756 100644 --- a/src/components/common/GenericImageSelector.vue +++ b/src/components/common/GenericImageSelector.vue @@ -74,10 +74,10 @@ diff --git a/src/components/flow/FlowEditor.vue b/src/components/flow/FlowEditor.vue index 1a5c29d..b9c46a3 100644 --- a/src/components/flow/FlowEditor.vue +++ b/src/components/flow/FlowEditor.vue @@ -2,7 +2,13 @@
-
+
+
+ +
+
@@ -90,6 +97,7 @@ import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlo import { normalizePropertiesWithStyle, normalizeNodeStyle, styleEquals } from '@/ts/nodeStyle'; import { useCanvasSettings } from '@/ts/useCanvasSettings'; import { validateGraphGroupRules, type GroupRuleWarning } from '@/utils/groupRules'; +import { subscribeSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource'; type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter'; type DistributeType = 'horizontal' | 'vertical'; @@ -128,9 +136,11 @@ const { showMessage } = useGlobalMessage(); const selectedNode = ref(null); const copyBuffer = ref(null); const groupRuleWarnings = ref([]); +const flowControlsCollapsed = ref(true); let nextPasteDistance = COPY_TRANSLATION; let containerResizeObserver: ResizeObserver | null = null; let groupRuleValidationTimer: ReturnType | null = null; +let unsubscribeSharedGroupRules: (() => void) | null = null; const resolveResizeHost = () => { const container = containerRef.value; @@ -1074,6 +1084,9 @@ onMounted(() => { } } window.addEventListener('resize', handleWindowResize); + unsubscribeSharedGroupRules = subscribeSharedGroupRulesConfig(() => { + scheduleGroupRuleValidation(0); + }); }); watch(selectionEnabled, (enabled) => { @@ -1114,6 +1127,8 @@ onBeforeUnmount(() => { clearTimeout(groupRuleValidationTimer); groupRuleValidationTimer = null; } + unsubscribeSharedGroupRules?.(); + unsubscribeSharedGroupRules = null; lf.value?.destroy(); lf.value = null; destroyLogicFlowInstance(); @@ -1158,6 +1173,10 @@ onBeforeUnmount(() => { max-width: 460px; font-size: 12px; } +.flow-controls--collapsed { + padding: 6px; + max-width: 220px; +} .control-row { display: flex; align-items: center; @@ -1165,6 +1184,9 @@ onBeforeUnmount(() => { gap: 8px; margin-bottom: 6px; } +.control-header { + margin-bottom: 0; +} .control-row:last-child { margin-bottom: 0; } diff --git a/src/components/flow/PropertyPanel.vue b/src/components/flow/PropertyPanel.vue index b2cd785..3ee901d 100644 --- a/src/components/flow/PropertyPanel.vue +++ b/src/components/flow/PropertyPanel.vue @@ -66,7 +66,10 @@ const currentAssetLibrary = computed({
-

请选择一个节点以编辑其属性

+
+

请选择一个节点以编辑其属性

+

素材入口:添加并选中 assetSelector 节点后,点击“选择资产”。

+
@@ -155,6 +158,18 @@ const currentAssetLibrary = computed({ text-align: center; } +.no-selection-text { + display: flex; + flex-direction: column; + gap: 8px; +} + +.no-selection-tip { + font-size: 12px; + color: #606266; + margin: 0; +} + .property-content { padding: 10px; flex: 1; diff --git a/src/components/flow/panels/AssetSelectorPanel.vue b/src/components/flow/panels/AssetSelectorPanel.vue index 31c6125..a79b104 100644 --- a/src/components/flow/panels/AssetSelectorPanel.vue +++ b/src/components/flow/panels/AssetSelectorPanel.vue @@ -59,10 +59,7 @@ const handleOpenSelector = () => { assetLibrary: library, allowUserAssetUpload: true, onDeleteUserAsset: (item: any) => { - if (!item?.id) { - return; - } - deleteCustomAsset(library, item.id); + deleteCustomAsset(library, item); }, onUserAssetUploaded: () => { // 上传后的数据刷新由选择器内部完成,这里保留扩展钩子。 diff --git a/src/index.js b/src/index.js index d370a8b..f471e08 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,13 @@ import YysEditorEmbed from './YysEditorEmbed.vue' export { setAssetBaseUrl, getAssetBaseUrl, resolveAssetUrl } from './utils/assetUrl' export { DEFAULT_GROUP_RULES_CONFIG } from './configs/groupRules' export { validateGraphGroupRules } from './utils/groupRules' +export { + GROUP_RULES_STORAGE_KEY, + readSharedGroupRulesConfig, + writeSharedGroupRulesConfig, + clearSharedGroupRulesConfig +} from './utils/groupRulesConfigSource' +export { CUSTOM_ASSET_STORAGE_KEY } from './utils/customAssets' // 导出组件 export { YysEditorEmbed } diff --git a/src/utils/customAssets.ts b/src/utils/customAssets.ts index 9bc9d5f..66059eb 100644 --- a/src/utils/customAssets.ts +++ b/src/utils/customAssets.ts @@ -1,4 +1,5 @@ -const STORAGE_KEY = 'yys-editor.custom-assets.v1' +export const CUSTOM_ASSET_STORAGE_KEY = 'yys-editor.custom-assets.v1' +const CUSTOM_ASSET_UPDATED_EVENT = 'yys-editor.custom-assets.updated' export type CustomAssetItem = { id: string @@ -12,20 +13,95 @@ export type CustomAssetItem = { type CustomAssetStore = Record const isClient = () => typeof window !== 'undefined' && typeof localStorage !== 'undefined' +const normalizeText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +const normalizeLibraryKey = (library: string): string => normalizeText(library).toLowerCase() + +const createLegacyAssetId = (library: string, name: string, avatar: string): string => { + const seed = `${library}|${name}|${avatar}` + let hash = 0 + for (let index = 0; index < seed.length; index += 1) { + hash = ((hash << 5) - hash) + seed.charCodeAt(index) + hash |= 0 + } + return `custom_legacy_${Math.abs(hash).toString(36)}` +} + +const buildCustomAssetId = (): string => { + return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` +} + +const normalizeAssetItem = (library: string, input: unknown): CustomAssetItem | null => { + if (!input || typeof input !== 'object') { + return null + } + + const raw = input as Record + const name = normalizeText(raw.name) || '用户素材' + const avatar = normalizeText(raw.avatar) + if (!avatar) { + return null + } + + const normalizedLibrary = normalizeLibraryKey(library) + const id = normalizeText(raw.id) || createLegacyAssetId(normalizedLibrary, name, avatar) + const createdAt = normalizeText(raw.createdAt) || '1970-01-01T00:00:00.000Z' + + return { + id, + name, + avatar, + library: normalizedLibrary, + __userAsset: true, + createdAt + } +} + +const normalizeStore = (input: unknown): CustomAssetStore => { + if (!input || typeof input !== 'object') { + return {} + } + + const parsed = input as Record + const normalizedStore: CustomAssetStore = {} + Object.entries(parsed).forEach(([library, assets]) => { + const normalizedLibrary = normalizeLibraryKey(library) + if (!normalizedLibrary || !Array.isArray(assets)) { + return + } + const normalizedAssets = assets + .map((item) => normalizeAssetItem(normalizedLibrary, item)) + .filter((item): item is CustomAssetItem => !!item) + if (normalizedAssets.length > 0) { + normalizedStore[normalizedLibrary] = normalizedAssets + } + }) + + return normalizedStore +} + +const notifyStoreUpdated = () => { + if (!isClient()) { + return + } + window.dispatchEvent(new CustomEvent(CUSTOM_ASSET_UPDATED_EVENT)) +} const readStore = (): CustomAssetStore => { if (!isClient()) { return {} } - const raw = localStorage.getItem(STORAGE_KEY) + const raw = localStorage.getItem(CUSTOM_ASSET_STORAGE_KEY) if (!raw) { return {} } try { const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object') { - return parsed as CustomAssetStore + const normalized = normalizeStore(parsed) + const normalizedRaw = JSON.stringify(normalized) + if (normalizedRaw !== raw) { + localStorage.setItem(CUSTOM_ASSET_STORAGE_KEY, normalizedRaw) } + return normalized } catch { // ignore } @@ -36,7 +112,8 @@ const writeStore = (store: CustomAssetStore) => { if (!isClient()) { return } - localStorage.setItem(STORAGE_KEY, JSON.stringify(store)) + localStorage.setItem(CUSTOM_ASSET_STORAGE_KEY, JSON.stringify(store)) + notifyStoreUpdated() } const normalizeFileName = (fileName: string): string => { @@ -52,37 +129,98 @@ const readFileAsDataUrl = (file: File): Promise => new Promise((resolve, }) export const listCustomAssets = (library: string): CustomAssetItem[] => { + const normalizedLibrary = normalizeLibraryKey(library) + if (!normalizedLibrary) { + return [] + } const store = readStore() - return Array.isArray(store[library]) ? store[library] : [] + return Array.isArray(store[normalizedLibrary]) ? store[normalizedLibrary] : [] } export const saveCustomAsset = (library: string, asset: CustomAssetItem) => { + const normalizedLibrary = normalizeLibraryKey(library) + if (!normalizedLibrary) { + return + } const store = readStore() - const assets = Array.isArray(store[library]) ? store[library] : [] - store[library] = [asset, ...assets] + const normalizedAsset = normalizeAssetItem(normalizedLibrary, asset) + if (!normalizedAsset) { + return + } + const assets = Array.isArray(store[normalizedLibrary]) ? store[normalizedLibrary] : [] + const dedupedAssets = assets.filter((item) => ( + item.id !== normalizedAsset.id + && !(item.avatar === normalizedAsset.avatar && item.name === normalizedAsset.name) + )) + store[normalizedLibrary] = [normalizedAsset, ...dedupedAssets] writeStore(store) } -export const deleteCustomAsset = (library: string, assetId: string) => { +export const deleteCustomAsset = ( + library: string, + assetRef: string | Pick, 'id' | 'name' | 'avatar'> +) => { + const normalizedLibrary = normalizeLibraryKey(library) + if (!normalizedLibrary) { + return + } const store = readStore() - const assets = Array.isArray(store[library]) ? store[library] : [] - store[library] = assets.filter((item) => item.id !== assetId) + const assets = Array.isArray(store[normalizedLibrary]) ? store[normalizedLibrary] : [] + const targetId = typeof assetRef === 'string' ? normalizeText(assetRef) : normalizeText(assetRef?.id) + const targetAvatar = typeof assetRef === 'string' ? '' : normalizeText(assetRef?.avatar) + const targetName = typeof assetRef === 'string' ? '' : normalizeText(assetRef?.name) + + store[normalizedLibrary] = assets.filter((item) => { + if (targetId && item.id === targetId) { + return false + } + if (!targetAvatar) { + return true + } + if (item.avatar !== targetAvatar) { + return true + } + if (!targetName) { + return false + } + return item.name !== targetName + }) writeStore(store) } export const createCustomAssetFromFile = async (library: string, file: File): Promise => { const avatar = await readFileAsDataUrl(file) const now = new Date().toISOString() - const id = `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + const normalizedLibrary = normalizeLibraryKey(library) + const id = buildCustomAssetId() const asset: CustomAssetItem = { id, name: normalizeFileName(file.name), avatar, - library, + library: normalizedLibrary, __userAsset: true, createdAt: now } - saveCustomAsset(library, asset) + saveCustomAsset(normalizedLibrary, asset) return asset } +export const subscribeCustomAssetStore = (listener: () => void): (() => void) => { + if (!isClient()) { + return () => {} + } + const handleStorage = (event: StorageEvent) => { + if (event.key === CUSTOM_ASSET_STORAGE_KEY) { + listener() + } + } + const handleLocalUpdate = () => { + listener() + } + window.addEventListener('storage', handleStorage) + window.addEventListener(CUSTOM_ASSET_UPDATED_EVENT, handleLocalUpdate) + return () => { + window.removeEventListener('storage', handleStorage) + window.removeEventListener(CUSTOM_ASSET_UPDATED_EVENT, handleLocalUpdate) + } +} diff --git a/src/utils/groupRules.ts b/src/utils/groupRules.ts index 137a565..92cc702 100644 --- a/src/utils/groupRules.ts +++ b/src/utils/groupRules.ts @@ -1,4 +1,5 @@ -import { DEFAULT_GROUP_RULES_CONFIG, type GroupRulesConfig } from '@/configs/groupRules' +import type { GroupRulesConfig } from '@/configs/groupRules' +import { readSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource' type GraphData = { nodes: any[] @@ -101,13 +102,14 @@ const includesName = (list: string[], target: string): boolean => { export const validateGraphGroupRules = ( graphData: GraphData, - config: GroupRulesConfig = DEFAULT_GROUP_RULES_CONFIG + config?: GroupRulesConfig ): GroupRuleWarning[] => { + const effectiveConfig = config || readSharedGroupRulesConfig() const groups = collectGroupAssets(graphData) const warnings: GroupRuleWarning[] = [] groups.forEach((group) => { - config.shikigamiYuhunBlacklist.forEach((rule) => { + effectiveConfig.shikigamiYuhunBlacklist.forEach((rule) => { if (includesName(group.shikigamiNames, rule.shikigami) && includesName(group.yuhunNames, rule.yuhun)) { warnings.push({ code: 'SHIKIGAMI_YUHUN_BLACKLIST', @@ -118,7 +120,7 @@ export const validateGraphGroupRules = ( } }) - config.shikigamiConflictPairs.forEach((rule) => { + effectiveConfig.shikigamiConflictPairs.forEach((rule) => { if (includesName(group.shikigamiNames, rule.left) && includesName(group.shikigamiNames, rule.right)) { warnings.push({ code: 'SHIKIGAMI_CONFLICT', @@ -131,7 +133,7 @@ export const validateGraphGroupRules = ( const hasShikigami = group.shikigamiNames.length > 0 if (hasShikigami) { - const hasFireShikigami = group.shikigamiNames.some((name) => config.fireShikigamiWhitelist.includes(name)) + const hasFireShikigami = group.shikigamiNames.some((name) => effectiveConfig.fireShikigamiWhitelist.includes(name)) if (!hasFireShikigami) { warnings.push({ code: 'MISSING_FIRE_SHIKIGAMI', @@ -145,4 +147,3 @@ export const validateGraphGroupRules = ( return warnings } - diff --git a/src/utils/groupRulesConfigSource.ts b/src/utils/groupRulesConfigSource.ts new file mode 100644 index 0000000..a0d378a --- /dev/null +++ b/src/utils/groupRulesConfigSource.ts @@ -0,0 +1,168 @@ +import { + DEFAULT_GROUP_RULES_CONFIG, + type GroupRulesConfig, + type ShikigamiConflictRule, + type ShikigamiYuhunBlacklistRule +} from '@/configs/groupRules' + +export const GROUP_RULES_STORAGE_KEY = 'yys-editor.group-rules.v1' +const GROUP_RULES_UPDATED_EVENT = 'yys-editor.group-rules.updated' + +const isClient = () => typeof window !== 'undefined' && typeof localStorage !== 'undefined' +const normalizeText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +const notifyGroupRulesUpdated = () => { + if (!isClient()) { + return + } + window.dispatchEvent(new CustomEvent(GROUP_RULES_UPDATED_EVENT)) +} + +const cloneDefaultGroupRulesConfig = (): GroupRulesConfig => ({ + version: DEFAULT_GROUP_RULES_CONFIG.version, + fireShikigamiWhitelist: [...DEFAULT_GROUP_RULES_CONFIG.fireShikigamiWhitelist], + shikigamiYuhunBlacklist: DEFAULT_GROUP_RULES_CONFIG.shikigamiYuhunBlacklist.map((rule) => ({ ...rule })), + shikigamiConflictPairs: DEFAULT_GROUP_RULES_CONFIG.shikigamiConflictPairs.map((rule) => ({ ...rule })) +}) + +const normalizeStringList = (value: unknown, fallback: string[]): string[] => { + if (!Array.isArray(value)) { + return [...fallback] + } + return value + .map((item) => normalizeText(item)) + .filter((item) => !!item) +} + +const normalizeBlacklistRules = ( + value: unknown, + fallback: ShikigamiYuhunBlacklistRule[] +): ShikigamiYuhunBlacklistRule[] => { + if (!Array.isArray(value)) { + return fallback.map((rule) => ({ ...rule })) + } + return value + .map((item) => { + if (!item || typeof item !== 'object') { + return null + } + const raw = item as Record + const shikigami = normalizeText(raw.shikigami) + const yuhun = normalizeText(raw.yuhun) + if (!shikigami || !yuhun) { + return null + } + const message = normalizeText(raw.message) + return { + shikigami, + yuhun, + ...(message ? { message } : {}) + } + }) + .filter((item): item is ShikigamiYuhunBlacklistRule => !!item) +} + +const normalizeConflictRules = ( + value: unknown, + fallback: ShikigamiConflictRule[] +): ShikigamiConflictRule[] => { + if (!Array.isArray(value)) { + return fallback.map((rule) => ({ ...rule })) + } + return value + .map((item) => { + if (!item || typeof item !== 'object') { + return null + } + const raw = item as Record + const left = normalizeText(raw.left) + const right = normalizeText(raw.right) + if (!left || !right) { + return null + } + const message = normalizeText(raw.message) + return { + left, + right, + ...(message ? { message } : {}) + } + }) + .filter((item): item is ShikigamiConflictRule => !!item) +} + +const normalizeGroupRulesConfig = (input: unknown): GroupRulesConfig | null => { + if (!input || typeof input !== 'object') { + return null + } + + const raw = input as Record + const fallback = cloneDefaultGroupRulesConfig() + const versionCandidate = Number(raw.version) + const version = Number.isFinite(versionCandidate) && versionCandidate > 0 + ? Math.trunc(versionCandidate) + : fallback.version + + return { + version, + fireShikigamiWhitelist: normalizeStringList(raw.fireShikigamiWhitelist, fallback.fireShikigamiWhitelist), + shikigamiYuhunBlacklist: normalizeBlacklistRules(raw.shikigamiYuhunBlacklist, fallback.shikigamiYuhunBlacklist), + shikigamiConflictPairs: normalizeConflictRules(raw.shikigamiConflictPairs, fallback.shikigamiConflictPairs) + } +} + +export const readSharedGroupRulesConfig = (): GroupRulesConfig => { + const fallback = cloneDefaultGroupRulesConfig() + if (!isClient()) { + return fallback + } + + const raw = localStorage.getItem(GROUP_RULES_STORAGE_KEY) + if (!raw) { + return fallback + } + + try { + const parsed = JSON.parse(raw) + return normalizeGroupRulesConfig(parsed) || fallback + } catch { + return fallback + } +} + +export const writeSharedGroupRulesConfig = (config: unknown): GroupRulesConfig => { + const normalized = normalizeGroupRulesConfig(config) || cloneDefaultGroupRulesConfig() + if (isClient()) { + localStorage.setItem(GROUP_RULES_STORAGE_KEY, JSON.stringify(normalized)) + notifyGroupRulesUpdated() + } + return normalized +} + +export const clearSharedGroupRulesConfig = () => { + if (!isClient()) { + return + } + localStorage.removeItem(GROUP_RULES_STORAGE_KEY) + notifyGroupRulesUpdated() +} + +export const subscribeSharedGroupRulesConfig = (listener: () => void): (() => void) => { + if (!isClient()) { + return () => {} + } + const handleStorage = (event: StorageEvent) => { + if (event.key === GROUP_RULES_STORAGE_KEY) { + listener() + } + } + const handleLocalUpdate = () => { + listener() + } + + window.addEventListener('storage', handleStorage) + window.addEventListener(GROUP_RULES_UPDATED_EVENT, handleLocalUpdate) + + return () => { + window.removeEventListener('storage', handleStorage) + window.removeEventListener(GROUP_RULES_UPDATED_EVENT, handleLocalUpdate) + } +} From a20c1a99bf19cf25691e45de9149f8f601b70043 Mon Sep 17 00:00:00 2001 From: rookie4show Date: Fri, 27 Feb 2026 14:00:21 +0800 Subject: [PATCH 04/12] fix: optimize canvas mouse interactions --- src/components/flow/FlowEditor.vue | 85 ++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/components/flow/FlowEditor.vue b/src/components/flow/FlowEditor.vue index b9c46a3..12ca4e1 100644 --- a/src/components/flow/FlowEditor.vue +++ b/src/components/flow/FlowEditor.vue @@ -105,6 +105,9 @@ type DistributeType = 'horizontal' | 'vertical'; const MOVE_STEP = 2; const MOVE_STEP_LARGE = 10; const COPY_TRANSLATION = 40; +const RIGHT_MOUSE_BUTTON = 2; +const RIGHT_DRAG_THRESHOLD = 2; +const RIGHT_DRAG_CONTEXTMENU_SUPPRESS_MS = 300; const props = withDefaults(defineProps<{ height?: string; @@ -141,6 +144,12 @@ let nextPasteDistance = COPY_TRANSLATION; let containerResizeObserver: ResizeObserver | null = null; let groupRuleValidationTimer: ReturnType | null = null; let unsubscribeSharedGroupRules: (() => void) | null = null; +let isRightDragging = false; +let rightDragMoved = false; +let rightDragLastX = 0; +let rightDragLastY = 0; +let rightDragDistance = 0; +let suppressContextMenuUntil = 0; const resolveResizeHost = () => { const container = containerRef.value; @@ -166,6 +175,71 @@ const handleWindowResize = () => { resizeCanvas(); }; +function handleRightDragMouseMove(event: MouseEvent) { + if (!isRightDragging) return; + + const deltaX = event.clientX - rightDragLastX; + const deltaY = event.clientY - rightDragLastY; + rightDragLastX = event.clientX; + rightDragLastY = event.clientY; + + if (deltaX === 0 && deltaY === 0) return; + + rightDragDistance += Math.abs(deltaX) + Math.abs(deltaY); + if (!rightDragMoved && rightDragDistance >= RIGHT_DRAG_THRESHOLD) { + rightDragMoved = true; + } + + if (rightDragMoved) { + lf.value?.translate(deltaX, deltaY); + event.preventDefault(); + } +} + +function stopRightDrag() { + if (!isRightDragging) return; + + isRightDragging = false; + flowHostRef.value?.classList.remove('flow-container--panning'); + window.removeEventListener('mousemove', handleRightDragMouseMove); + window.removeEventListener('mouseup', handleRightDragMouseUp); + + if (rightDragMoved) { + suppressContextMenuUntil = Date.now() + RIGHT_DRAG_CONTEXTMENU_SUPPRESS_MS; + } +} + +function handleRightDragMouseUp() { + stopRightDrag(); +} + +function handleCanvasMouseDown(event: MouseEvent) { + if (event.button !== RIGHT_MOUSE_BUTTON) return; + + const target = event.target as HTMLElement | null; + if (target?.closest('.lf-menu')) return; + if (!containerRef.value?.contains(target)) return; + + isRightDragging = true; + rightDragMoved = false; + rightDragDistance = 0; + rightDragLastX = event.clientX; + rightDragLastY = event.clientY; + suppressContextMenuUntil = 0; + + flowHostRef.value?.classList.add('flow-container--panning'); + window.addEventListener('mousemove', handleRightDragMouseMove); + window.addEventListener('mouseup', handleRightDragMouseUp); +} + +function handleCanvasContextMenu(event: MouseEvent) { + if (Date.now() >= suppressContextMenuUntil) return; + + event.preventDefault(); + event.stopPropagation(); + suppressContextMenuUntil = 0; +} + function isInputLike(event?: KeyboardEvent) { const target = event?.target as HTMLElement | null; if (!target) return false; @@ -757,6 +831,7 @@ onMounted(() => { lf.value = new LogicFlow({ container: containerRef.value, grid: { type: 'dot', size: 10 }, + stopMoveGraph: true, allowResize: true, allowRotate: true, overlapMode: -1, @@ -998,6 +1073,9 @@ onMounted(() => { registerNodes(lfInstance); setLogicFlowInstance(lfInstance); + applySelectionSelect(selectionEnabled.value); + containerRef.value?.addEventListener('mousedown', handleCanvasMouseDown); + containerRef.value?.addEventListener('contextmenu', handleCanvasContextMenu, true); // 监听所有可能的节点添加事件 lfInstance.on(EventType.NODE_ADD, ({ data }) => { @@ -1025,6 +1103,7 @@ onMounted(() => { }); lfInstance.on(EventType.GRAPH_RENDERED, () => { + applySelectionSelect(selectionEnabled.value); normalizeAllNodes(); scheduleGroupRuleValidation(0); }); @@ -1129,6 +1208,9 @@ onBeforeUnmount(() => { } unsubscribeSharedGroupRules?.(); unsubscribeSharedGroupRules = null; + containerRef.value?.removeEventListener('mousedown', handleCanvasMouseDown); + containerRef.value?.removeEventListener('contextmenu', handleCanvasContextMenu, true); + stopRightDrag(); lf.value?.destroy(); lf.value = null; destroyLogicFlowInstance(); @@ -1154,6 +1236,9 @@ onBeforeUnmount(() => { position: relative; overflow: hidden; } +.flow-container--panning :deep(.lf-canvas-overlay) { + cursor: grabbing; +} .container { width: 100%; min-height: 0; From 15386795cb1aed7db780b6c2f26cd752891d247c Mon Sep 17 00:00:00 2001 From: rookie4show Date: Fri, 27 Feb 2026 17:38:08 +0800 Subject: [PATCH 05/12] fix(flow): support dynamic-group palette and restore framework clipboard --- src/App.vue | 1 + src/components/flow/ComponentsPanel.vue | 21 ++- src/components/flow/FlowEditor.vue | 181 +++++++++++------------- src/configs/nodeRegistry.ts | 7 + src/types/nodeTypes.ts | 1 + 5 files changed, 110 insertions(+), 101 deletions(-) diff --git a/src/App.vue b/src/App.vue index 0dea7e4..f2346b3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -180,6 +180,7 @@ watch(
diff --git a/src/components/flow/ComponentsPanel.vue b/src/components/flow/ComponentsPanel.vue index 3dfae15..0f23154 100644 --- a/src/components/flow/ComponentsPanel.vue +++ b/src/components/flow/ComponentsPanel.vue @@ -1,5 +1,4 @@ + + + + diff --git a/src/ts/schema.ts b/src/ts/schema.ts index abdb0e3..4572e36 100644 --- a/src/ts/schema.ts +++ b/src/ts/schema.ts @@ -56,6 +56,22 @@ export interface NodeMeta { export interface NodeProperties { style: NodeStyle; meta?: NodeMeta; + children?: string[]; + groupMeta?: { + version: number; + groupKind: 'team' | 'shikigami'; + groupName: string; + ruleEnabled: boolean; + ruleScope: string[]; + }; + assetLibrary?: string; + selectedAsset?: { + assetId: string; + library: string; + name?: string; + avatar?: string; + [key: string]: any; + } | null; image?: { url: string; fit?: 'fill'|'contain'|'cover' }; text?: { content: string; rich?: boolean }; vector?: { @@ -81,6 +97,7 @@ export interface GraphNode { y?: number; width?: number; height?: number; + children?: string[]; properties: NodeProperties; } diff --git a/src/ts/useStore.ts b/src/ts/useStore.ts index a40233e..614e14f 100644 --- a/src/ts/useStore.ts +++ b/src/ts/useStore.ts @@ -5,6 +5,7 @@ import {ElMessageBox} from "element-plus"; import {useGlobalMessage} from "./useGlobalMessage"; import {getLogicFlowInstance} from "./useLogicFlow"; import {CURRENT_SCHEMA_VERSION, migrateToV1, RootDocument} from "./schema"; +import { normalizeGraphRawDataSchema } from '@/utils/graphSchema'; const {showMessage} = useGlobalMessage(); @@ -117,7 +118,7 @@ export const useFilesStore = defineStore('files', () => { name: f?.name ?? f?.label ?? `File ${i + 1}`, visible: f?.visible ?? true, type: f?.type ?? 'FLOW', - graphRawData: (f?.graphRawData && typeof f.graphRawData === 'object') ? f.graphRawData : { nodes: [], edges: [] }, + graphRawData: normalizeGraphRawDataSchema(f?.graphRawData), transform: f?.transform ?? { SCALE_X: 1, SCALE_Y: 1, @@ -333,11 +334,12 @@ export const useFilesStore = defineStore('files', () => { }; }) }; + const normalizedGraphData = normalizeGraphRawDataSchema(enrichedGraphData); // 直接保存原始数据到 GraphRawData const file = findById(targetId); if (file) { - file.graphRawData = enrichedGraphData; + file.graphRawData = normalizedGraphData; file.transform = transform; } } diff --git a/src/utils/graphSchema.ts b/src/utils/graphSchema.ts new file mode 100644 index 0000000..0abbeee --- /dev/null +++ b/src/utils/graphSchema.ts @@ -0,0 +1,155 @@ +export const GROUP_META_VERSION = 1 +export const DEFAULT_GROUP_RULE_SCOPE = ['shikigami-yuhun', 'shikigami-shikigami'] + +export type GroupKind = 'team' | 'shikigami' + +export type DynamicGroupMeta = { + version: number + groupKind: GroupKind + groupName: string + ruleEnabled: boolean + ruleScope: string[] +} + +const normalizeText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +const normalizeLibrary = (value: unknown): string => normalizeText(value).toLowerCase() + +const inferLibraryFromAvatar = (avatar: string): string => { + if (!avatar) return '' + if (avatar.includes('/Yuhun/')) return 'yuhun' + if (avatar.includes('/Shikigami/')) return 'shikigami' + if (avatar.includes('/hero_')) return 'onmyoji' + return '' +} + +const createStableHash = (seed: string): string => { + let hash = 0 + for (let index = 0; index < seed.length; index += 1) { + hash = ((hash << 5) - hash) + seed.charCodeAt(index) + hash |= 0 + } + return Math.abs(hash).toString(36) +} + +const normalizeStringList = (value: unknown, fallback: string[]): string[] => { + if (!Array.isArray(value)) { + return [...fallback] + } + const normalized = value + .map((item) => normalizeText(item)) + .filter((item) => !!item) + return normalized.length ? Array.from(new Set(normalized)) : [...fallback] +} + +export const normalizeDynamicGroupMeta = (input: unknown, fallbackKind: GroupKind = 'team'): DynamicGroupMeta => { + const raw = input && typeof input === 'object' ? input as Record : {} + const versionCandidate = Number(raw.version) + const version = Number.isFinite(versionCandidate) && versionCandidate > 0 + ? Math.trunc(versionCandidate) + : GROUP_META_VERSION + const groupKind: GroupKind = raw.groupKind === 'shikigami' ? 'shikigami' : fallbackKind + const groupName = normalizeText(raw.groupName) + const ruleEnabled = raw.ruleEnabled !== false + const ruleScope = normalizeStringList(raw.ruleScope, DEFAULT_GROUP_RULE_SCOPE) + + return { + version, + groupKind, + groupName, + ruleEnabled, + ruleScope + } +} + +const normalizeChildren = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return [] + } + return Array.from( + new Set( + value + .map((item) => normalizeText(item)) + .filter((item) => !!item) + ) + ) +} + +export const getDynamicGroupChildIds = (node: any): string[] => { + const nodeChildren = normalizeChildren(node?.children) + const propertyChildren = normalizeChildren(node?.properties?.children) + return nodeChildren.length ? nodeChildren : propertyChildren +} + +export const normalizeSelectedAssetRecord = (input: unknown, preferredLibrary = ''): Record | null => { + if (!input || typeof input !== 'object') { + return null + } + + const raw = input as Record + const name = normalizeText(raw.name) + const avatar = normalizeText(raw.avatar) + const library = normalizeLibrary(raw.library) || normalizeLibrary(preferredLibrary) || inferLibraryFromAvatar(avatar) + const sourceId = normalizeText(raw.assetId) + || normalizeText(raw.id) + || normalizeText(raw.skillId) + || normalizeText(raw.onmyojiId) + const identitySeed = sourceId || `${name}|${avatar}|${library}` + const assetId = sourceId + ? `${library || 'asset'}:${sourceId}` + : `asset_${createStableHash(identitySeed || String(Date.now()))}` + + return { + ...raw, + ...(name ? { name } : {}), + ...(avatar ? { avatar } : {}), + library: library || 'shikigami', + assetId + } +} + +export const normalizeGraphRawDataSchema = (graphData: any): { nodes: any[]; edges: any[] } => { + const rawNodes = Array.isArray(graphData?.nodes) ? graphData.nodes : [] + const rawEdges = Array.isArray(graphData?.edges) ? graphData.edges : [] + + const nodes = rawNodes.map((node: any) => { + const properties = node?.properties && typeof node.properties === 'object' + ? { ...node.properties } + : {} + + if (node?.type === 'dynamic-group') { + const children = getDynamicGroupChildIds(node) + return { + ...node, + children, + properties: { + ...properties, + children, + groupMeta: normalizeDynamicGroupMeta(properties.groupMeta) + } + } + } + + if (node?.type === 'assetSelector') { + const currentLibrary = normalizeLibrary(properties.assetLibrary) || 'shikigami' + const selectedAsset = normalizeSelectedAssetRecord(properties.selectedAsset, currentLibrary) + return { + ...node, + properties: { + ...properties, + assetLibrary: selectedAsset?.library || currentLibrary, + selectedAsset + } + } + } + + return { + ...node, + properties + } + }) + + return { + nodes, + edges: rawEdges + } +} diff --git a/src/utils/groupRules.ts b/src/utils/groupRules.ts index 92cc702..719ff97 100644 --- a/src/utils/groupRules.ts +++ b/src/utils/groupRules.ts @@ -1,21 +1,34 @@ import type { GroupRulesConfig } from '@/configs/groupRules' import { readSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource' +import { getDynamicGroupChildIds, normalizeGraphRawDataSchema } from '@/utils/graphSchema' type GraphData = { nodes: any[] edges: any[] } -type GroupAssetSnapshot = { +type TeamAsset = { + nodeId: string + assetId: string + name: string + library: string +} + +type TeamAssetSnapshot = { groupId: string + groupName: string nodeIds: string[] - shikigamiNames: string[] - yuhunNames: string[] + shikigamiAssets: TeamAsset[] + yuhunAssets: TeamAsset[] } export type GroupRuleWarning = { + id: string + ruleId: string code: 'SHIKIGAMI_YUHUN_BLACKLIST' | 'SHIKIGAMI_CONFLICT' | 'MISSING_FIRE_SHIKIGAMI' + severity: 'warning' | 'error' | 'info' groupId: string + groupName?: string message: string nodeIds: string[] } @@ -31,13 +44,17 @@ const isAssetSelectorNode = (node: any): boolean => { return !!node && node.type === 'assetSelector' } +const isDynamicGroupNode = (node: any): boolean => { + return !!node && node.type === 'dynamic-group' +} + const inferLibrary = (node: any): string => { - const assetLibrary = normalizeText(node?.properties?.assetLibrary) + const assetLibrary = normalizeText(node?.properties?.assetLibrary).toLowerCase() if (assetLibrary) { return assetLibrary } - const selectedLibrary = normalizeText(node?.properties?.selectedAsset?.library) + const selectedLibrary = normalizeText(node?.properties?.selectedAsset?.library).toLowerCase() if (selectedLibrary) { return selectedLibrary } @@ -52,94 +69,166 @@ const inferLibrary = (node: any): string => { return '' } -const collectGroupAssets = (graphData: GraphData): GroupAssetSnapshot[] => { - const groupMap = new Map() - - const nodes = Array.isArray(graphData?.nodes) ? graphData.nodes : [] - nodes.forEach((node) => { - if (!isAssetSelectorNode(node)) { - return - } - - const groupId = normalizeText(node?.properties?.meta?.groupId) - if (!groupId) { - return - } - - const assetName = normalizeText(node?.properties?.selectedAsset?.name) - if (!assetName) { - return - } - - if (!groupMap.has(groupId)) { - groupMap.set(groupId, { - groupId, - nodeIds: [], - shikigamiNames: [], - yuhunNames: [] - }) - } - - const group = groupMap.get(groupId)! - group.nodeIds.push(node.id) - - const library = inferLibrary(node) - if (library === 'shikigami') { - group.shikigamiNames.push(assetName) - return - } - if (library === 'yuhun') { - group.yuhunNames.push(assetName) - } - }) - - return Array.from(groupMap.values()) -} - const includesName = (list: string[], target: string): boolean => { return list.some((item) => item === target) } +const dedupeNodeIds = (ids: string[]): string[] => Array.from(new Set(ids)) + +const collectTeamAssetSnapshots = (graphData: GraphData): TeamAssetSnapshot[] => { + const nodes = Array.isArray(graphData?.nodes) ? graphData.nodes : [] + const nodeMap = new Map() + nodes.forEach((node) => { + const nodeId = normalizeText(node?.id) + if (!nodeId) return + nodeMap.set(nodeId, node) + }) + + const teamGroups = nodes.filter((node) => { + if (!isDynamicGroupNode(node)) return false + const groupKind = normalizeText(node?.properties?.groupMeta?.groupKind) + const ruleEnabled = node?.properties?.groupMeta?.ruleEnabled !== false + return groupKind === 'team' && ruleEnabled + }) + + return teamGroups.map((teamNode) => { + const teamId = normalizeText(teamNode?.id) || 'unknown-team' + const teamName = normalizeText(teamNode?.properties?.groupMeta?.groupName) + const queue = [...getDynamicGroupChildIds(teamNode)] + const visited = new Set() + const shikigamiAssets: TeamAsset[] = [] + const yuhunAssets: TeamAsset[] = [] + + while (queue.length > 0) { + const currentId = queue.shift() as string + if (!currentId || visited.has(currentId)) { + continue + } + visited.add(currentId) + + const node = nodeMap.get(currentId) + if (!node) { + continue + } + + if (isDynamicGroupNode(node)) { + const childKind = normalizeText(node?.properties?.groupMeta?.groupKind) + // 嵌套 team 视为独立边界,不纳入当前队伍聚合 + if (childKind === 'team') { + continue + } + queue.push(...getDynamicGroupChildIds(node)) + continue + } + + if (!isAssetSelectorNode(node)) { + continue + } + + const library = inferLibrary(node) + const name = normalizeText(node?.properties?.selectedAsset?.name) + const assetId = normalizeText(node?.properties?.selectedAsset?.assetId) + if (!name) { + continue + } + + const asset: TeamAsset = { + nodeId: normalizeText(node?.id), + assetId, + name, + library + } + + if (library === 'shikigami') { + shikigamiAssets.push(asset) + } else if (library === 'yuhun') { + yuhunAssets.push(asset) + } + } + + return { + groupId: teamId, + groupName: teamName, + nodeIds: dedupeNodeIds([ + ...shikigamiAssets.map((item) => item.nodeId), + ...yuhunAssets.map((item) => item.nodeId) + ]), + shikigamiAssets, + yuhunAssets + } + }) +} + +const createWarningId = (groupId: string, ruleId: string): string => `${groupId}::${ruleId}` + export const validateGraphGroupRules = ( graphData: GraphData, config?: GroupRulesConfig ): GroupRuleWarning[] => { const effectiveConfig = config || readSharedGroupRulesConfig() - const groups = collectGroupAssets(graphData) + const normalizedGraphData = normalizeGraphRawDataSchema(graphData) + const teams = collectTeamAssetSnapshots(normalizedGraphData) const warnings: GroupRuleWarning[] = [] - groups.forEach((group) => { + teams.forEach((team) => { + const shikigamiNames = team.shikigamiAssets.map((item) => item.name) + const yuhunNames = team.yuhunAssets.map((item) => item.name) + effectiveConfig.shikigamiYuhunBlacklist.forEach((rule) => { - if (includesName(group.shikigamiNames, rule.shikigami) && includesName(group.yuhunNames, rule.yuhun)) { + if (includesName(shikigamiNames, rule.shikigami) && includesName(yuhunNames, rule.yuhun)) { + const ruleId = `blacklist:${rule.shikigami}:${rule.yuhun}` + const nodeIds = dedupeNodeIds([ + ...team.shikigamiAssets.filter((item) => item.name === rule.shikigami).map((item) => item.nodeId), + ...team.yuhunAssets.filter((item) => item.name === rule.yuhun).map((item) => item.nodeId) + ]) + warnings.push({ + id: createWarningId(team.groupId, ruleId), + ruleId, code: 'SHIKIGAMI_YUHUN_BLACKLIST', - groupId: group.groupId, - nodeIds: group.nodeIds, + severity: 'warning', + groupId: team.groupId, + groupName: team.groupName || undefined, + nodeIds, message: rule.message || `规则冲突:${rule.shikigami} 不建议携带 ${rule.yuhun}。` }) } }) effectiveConfig.shikigamiConflictPairs.forEach((rule) => { - if (includesName(group.shikigamiNames, rule.left) && includesName(group.shikigamiNames, rule.right)) { + if (includesName(shikigamiNames, rule.left) && includesName(shikigamiNames, rule.right)) { + const ruleId = `conflict:${rule.left}:${rule.right}` + const nodeIds = dedupeNodeIds([ + ...team.shikigamiAssets.filter((item) => item.name === rule.left).map((item) => item.nodeId), + ...team.shikigamiAssets.filter((item) => item.name === rule.right).map((item) => item.nodeId) + ]) warnings.push({ + id: createWarningId(team.groupId, ruleId), + ruleId, code: 'SHIKIGAMI_CONFLICT', - groupId: group.groupId, - nodeIds: group.nodeIds, + severity: 'warning', + groupId: team.groupId, + groupName: team.groupName || undefined, + nodeIds, message: rule.message || `规则冲突:${rule.left} 与 ${rule.right} 不建议同队。` }) } }) - const hasShikigami = group.shikigamiNames.length > 0 + const hasShikigami = shikigamiNames.length > 0 if (hasShikigami) { - const hasFireShikigami = group.shikigamiNames.some((name) => effectiveConfig.fireShikigamiWhitelist.includes(name)) + const hasFireShikigami = shikigamiNames.some((name) => effectiveConfig.fireShikigamiWhitelist.includes(name)) if (!hasFireShikigami) { + const ruleId = 'missing-fire-shikigami' warnings.push({ + id: createWarningId(team.groupId, ruleId), + ruleId, code: 'MISSING_FIRE_SHIKIGAMI', - groupId: group.groupId, - nodeIds: group.nodeIds, - message: '规则提示:当前分组未检测到鬼火式神,建议补充供火位。' + severity: 'warning', + groupId: team.groupId, + groupName: team.groupName || undefined, + nodeIds: dedupeNodeIds(team.shikigamiAssets.map((item) => item.nodeId)), + message: '规则提示:当前队伍未检测到鬼火式神,建议补充供火位。' }) } } From 271b722c97846fbcf42e88a6495cf4419f6ca338 Mon Sep 17 00:00:00 2001 From: rookie4show Date: Fri, 27 Feb 2026 22:13:04 +0800 Subject: [PATCH 08/12] feat: add configurable DSL rule manager and simplify dynamic-group settings --- docs/test/acceptance.md | 35 +- src/__tests__/groupRules.expression.test.ts | 151 +++++ src/__tests__/problemTarget.test.ts | 36 ++ src/__tests__/ruleExpression.test.ts | 73 +++ src/components/Toolbar.vue | 612 ++++++++++++++++++ src/components/flow/ComponentsPanel.vue | 4 +- src/components/flow/FlowEditor.vue | 3 +- .../flow/panels/DynamicGroupPanel.vue | 66 +- src/configs/groupRules.ts | 62 +- src/utils/groupRules.ts | 164 +++-- src/utils/groupRulesConfigSource.ts | 68 +- src/utils/problemTarget.ts | 19 + src/utils/ruleExpression.ts | 539 +++++++++++++++ 13 files changed, 1674 insertions(+), 158 deletions(-) create mode 100644 src/__tests__/groupRules.expression.test.ts create mode 100644 src/__tests__/problemTarget.test.ts create mode 100644 src/__tests__/ruleExpression.test.ts create mode 100644 src/utils/problemTarget.ts create mode 100644 src/utils/ruleExpression.ts diff --git a/docs/test/acceptance.md b/docs/test/acceptance.md index 2888656..bc76fe8 100644 --- a/docs/test/acceptance.md +++ b/docs/test/acceptance.md @@ -1,6 +1,6 @@ # yys-editor 验收测试点(手工) -目标:覆盖“用户素材上传/管理、资产引用、Dynamic Group 规则提示、性能优化”等需求。 +目标:覆盖“用户素材上传/管理、资产引用、Dynamic Group 规则提示、规则管理(DSL/变量导入导出)、性能优化”等需求。 ## 0. 基础启动与构建 @@ -101,18 +101,39 @@ 步骤: - 在同一分组中放入: - “辉夜姬” 与 “破势” - - “千姬” 与 “腹肌清姬/蝮骨清姬” - - 只有式神但没有供火式神 + - 只有式神但没有供火式神(不含供火名单) - 观察右侧/控制区的规则提示列表。 预期: -- 出现对应警告提示。 +- 出现对应警告提示(当前默认预制规则): + - `TEAM_KAGUYA_POSHI_CONFLICT` + - `TEAM_MISSING_FIRE_SHIKIGAMI` - 取消分组、移除节点后提示实时更新/消失。 排查点: -- `src/utils/groupRules.ts`、`src/configs/groupRules.ts`。 +- `src/configs/groupRules.ts`(预制规则与变量) +- `src/utils/groupRules.ts`(按 expressionRules 解析) - `src/components/flow/FlowEditor.vue` 的 `scheduleGroupRuleValidation(...)` 调度时机。 +## 6.1 规则管理(表格化 + 导入导出) + +步骤: +- 点击顶部工具栏“规则管理”。 +- 在“规则”tab 验证单行表格展示(启用勾选、级别、规则ID、条件、提示)。 +- 点击“编辑”打开单独弹窗修改规则并保存。 +- 在“变量”tab 修改变量后点击“应用并生效”。 +- 点击“导出规则变量”导出 JSON,再点击“导入规则变量”导入。 + +预期: +- 规则列表为单行表格,不再是大块卡片编辑。 +- 规则编辑在弹窗中完成,保存后回填列表。 +- 导入后提示“请点击应用并生效”,应用后 Problems 立即刷新。 +- 导出文件包含:`version`、`expressionRules`、`ruleVariables`。 + +排查点: +- `src/components/Toolbar.vue`(规则管理 UI 与导入导出) +- `src/utils/groupRulesConfigSource.ts`(配置写入与广播) + ## 7. 性能回归(矢量节点快速缩放) 步骤: @@ -173,6 +194,7 @@ - [ ] 缺失资产降级策略通过(不阻断导出/渲染)。 - [x] Dynamic Group 分组基础行为通过(分组信息写入 `meta.groupId`,复制分组会携带组内节点)。 - [ ] 分组规则静态检查通过(冲突与供火提示正确且可实时更新)。 +- [ ] 规则管理通过(规则列表表格化、弹窗编辑、导入导出可用)。 - [ ] 矢量节点快速缩放性能回归通过(无明显卡顿/卡死)。 - [ ] 导出到 wiki 数据兼容通过(wiki 侧可 normalize 与预览)。 - [ ] 跨项目素材互通通过(同 origin 可复用素材,跨 origin 不互通)。 @@ -182,7 +204,7 @@ 当前状态(2026-02-27): - 已通过:5 项(基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、导出图片时隐藏 Dynamic Group)。 - 部分通过:1 项(跨项目规则互通方案确认)。 -- 未通过/待验证:6 项(其余项待完整手测或跨仓联调)。 +- 未通过/待验证:7 项(其余项待完整手测或跨仓联调)。 逐项状态: - 基础启动与构建:已通过 @@ -192,6 +214,7 @@ - 缺失资产降级策略:未通过(待手测) - Dynamic Group 分组基础行为:已通过 - 分组规则静态检查:未通过(待手测) +- 规则管理(表格化/导入导出):未通过(待手测) - 矢量节点快速缩放性能回归:未通过(待手测) - 导出到 wiki 数据兼容:未通过(待跨仓联测) - 跨项目素材互通:未通过(待同 origin 联测) diff --git a/src/__tests__/groupRules.expression.test.ts b/src/__tests__/groupRules.expression.test.ts new file mode 100644 index 0000000..8c8cdeb --- /dev/null +++ b/src/__tests__/groupRules.expression.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest' +import { validateGraphGroupRules } from '@/utils/groupRules' +import { DEFAULT_GROUP_RULES_CONFIG } from '@/configs/groupRules' + +const baseGraph = { + nodes: [ + { + id: 'team-1', + type: 'dynamic-group', + children: ['s1', 's2'], + properties: { + children: ['s1', 's2'], + groupMeta: { + groupKind: 'team', + groupName: '一队', + ruleEnabled: true + } + } + }, + { + id: 's1', + type: 'assetSelector', + properties: { + assetLibrary: 'shikigami', + selectedAsset: { assetId: 'a1', name: '辉夜姬', library: 'shikigami' } + } + }, + { + id: 's2', + type: 'assetSelector', + properties: { + assetLibrary: 'shikigami', + selectedAsset: { assetId: 'a2', name: '千姬', library: 'shikigami' } + } + } + ], + edges: [] +} + +const graphWithoutFireShikigami = { + ...baseGraph, + nodes: baseGraph.nodes.map((node) => { + if (node.id === 's1') { + return { + ...node, + properties: { + ...node.properties, + selectedAsset: { assetId: 'a1', name: '阿修罗', library: 'shikigami' } + } + } + } + if (node.id === 's2') { + return { + ...node, + properties: { + ...node.properties, + selectedAsset: { assetId: 'a2', name: '不知火', library: 'shikigami' } + } + } + } + return node + }) +} + +const graphWithKaguyaPoshi = { + ...baseGraph, + nodes: [ + ...baseGraph.nodes, + { + id: 'y1', + type: 'assetSelector', + properties: { + assetLibrary: 'yuhun', + selectedAsset: { assetId: 'y1', name: '破势', library: 'yuhun' } + } + }, + { + id: 'y2', + type: 'assetSelector', + properties: { + assetLibrary: 'yuhun', + selectedAsset: { assetId: 'y2', name: '招财猫', library: 'yuhun' } + } + } + ].map((node) => { + if (node.id !== 'team-1') return node + return { + ...node, + children: ['s1', 's2', 'y1', 'y2'], + properties: { + ...node.properties, + children: ['s1', 's2', 'y1', 'y2'] + } + } + }) +} + +describe('groupRules expression integration', () => { + it('支持自定义 expressionRules 产出告警', () => { + const warnings = validateGraphGroupRules(baseGraph, { + ...DEFAULT_GROUP_RULES_CONFIG, + shikigamiYuhunBlacklist: [], + shikigamiConflictPairs: [], + fireShikigamiWhitelist: ['座敷童子'], + ruleVariables: [ + { + key: '供火式神', + value: '辉夜姬,座敷童子' + } + ], + expressionRules: [ + { + id: 'team-has-kaguya', + condition: 'count(intersect(map(ctx.team.shikigamis, "name"), getVar("供火式神"))) > 0', + message: '命中:包含辉夜姬', + severity: 'info', + code: 'CUSTOM_HAS_KAGUYA' + } + ] + }) + + const custom = warnings.find((item) => item.ruleId === 'team-has-kaguya') + expect(custom).toBeTruthy() + expect(custom?.severity).toBe('info') + expect(custom?.code).toBe('CUSTOM_HAS_KAGUYA') + expect(custom?.nodeIds).toEqual(['s1', 's2']) + }) + + it('当 expressionRules 为空时不再产出 legacy 告警', () => { + const warnings = validateGraphGroupRules(baseGraph, { + ...DEFAULT_GROUP_RULES_CONFIG, + shikigamiYuhunBlacklist: [], + shikigamiConflictPairs: [], + fireShikigamiWhitelist: ['座敷童子'], + ruleVariables: [], + expressionRules: [] + }) + + expect(warnings).toHaveLength(0) + }) + + it('默认预制规则可命中“辉夜姬不能带破势”', () => { + const warnings = validateGraphGroupRules(graphWithKaguyaPoshi, DEFAULT_GROUP_RULES_CONFIG) + expect(warnings.some((item) => item.code === 'TEAM_KAGUYA_POSHI_CONFLICT')).toBe(true) + }) + + it('默认预制规则可命中“队伍需要供火式神”', () => { + const warnings = validateGraphGroupRules(graphWithoutFireShikigami, DEFAULT_GROUP_RULES_CONFIG) + expect(warnings.some((item) => item.code === 'TEAM_MISSING_FIRE_SHIKIGAMI')).toBe(true) + }) +}) diff --git a/src/__tests__/problemTarget.test.ts b/src/__tests__/problemTarget.test.ts new file mode 100644 index 0000000..0548a44 --- /dev/null +++ b/src/__tests__/problemTarget.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import type { GroupRuleWarning } from '@/utils/groupRules' +import { getProblemTargetCandidateIds } from '@/utils/problemTarget' + +const createWarning = (overrides: Partial): GroupRuleWarning => ({ + id: 'w1', + ruleId: 'rule-1', + code: 'SHIKIGAMI_CONFLICT', + severity: 'warning', + groupId: 'team-1', + message: 'test', + nodeIds: ['node-a', 'node-b'], + ...overrides +}) + +describe('getProblemTargetCandidateIds', () => { + it('所有告警优先跳转到触发规则的 dynamic group', () => { + const warning = createWarning({ + code: 'SHIKIGAMI_YUHUN_BLACKLIST', + nodeIds: ['shiki-1', 'yuhun-1'], + groupId: 'team-1' + }) + + expect(getProblemTargetCandidateIds(warning)).toEqual(['team-1', 'shiki-1', 'yuhun-1']) + }) + + it('队伍类告警优先跳转到队伍节点', () => { + const warning = createWarning({ + code: 'MISSING_FIRE_SHIKIGAMI', + nodeIds: ['shiki-1', 'shiki-2'], + groupId: 'team-1' + }) + + expect(getProblemTargetCandidateIds(warning)).toEqual(['team-1', 'shiki-1', 'shiki-2']) + }) +}) diff --git a/src/__tests__/ruleExpression.test.ts b/src/__tests__/ruleExpression.test.ts new file mode 100644 index 0000000..b57645b --- /dev/null +++ b/src/__tests__/ruleExpression.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { evaluateRuleExpressionAsBoolean } from '@/utils/ruleExpression' + +describe('ruleExpression', () => { + it('支持集合交集计数表达式', () => { + const scope = { + ctx: { + members: { + shikigamiNames: ['辉夜姬', '千姬'] + } + }, + shared: { + fireShikigamiWhitelist: ['辉夜姬', '座敷童子'] + } + } + + const result = evaluateRuleExpressionAsBoolean( + 'count(intersect(ctx.members.shikigamiNames, shared.fireShikigamiWhitelist)) > 0', + scope + ) + expect(result).toBe(true) + }) + + it('支持 map + contains 表达式', () => { + const scope = { + ctx: { + members: { + shikigami: [ + { name: '辉夜姬', role: 'fire' }, + { name: '千姬', role: 'support' } + ] + } + } + } + + const result = evaluateRuleExpressionAsBoolean( + 'contains(map(ctx.members.shikigami, "name"), "辉夜姬")', + scope + ) + expect(result).toBe(true) + }) + + it('遇到非法函数名时抛错', () => { + expect(() => evaluateRuleExpressionAsBoolean('unknownFn(1, 2)', {})).toThrowError('不支持的函数调用') + }) + + it('支持 getVar 从共享变量映射读取集合', () => { + const scope = { + shared: { + vars: { + 火系式神: ['辉夜姬', '座敷童子'] + } + } + } + const result = evaluateRuleExpressionAsBoolean( + 'contains(getVar("火系式神"), "辉夜姬")', + scope + ) + expect(result).toBe(true) + }) + + it('getVar 仅允许传入变量 key', () => { + const scope = { + shared: { + vars: { + 火系式神: ['辉夜姬'] + } + } + } + expect(() => evaluateRuleExpressionAsBoolean('contains(getVar(shared.vars, "火系式神"), "辉夜姬")', scope)) + .toThrowError('getVar 仅支持一个参数') + }) +}) diff --git a/src/components/Toolbar.vue b/src/components/Toolbar.vue index 4e707bd..244c727 100644 --- a/src/components/Toolbar.vue +++ b/src/components/Toolbar.vue @@ -7,6 +7,7 @@ {{ t('prepareCapture') }} {{ t('setWatermark') }} 素材管理 + 规则管理 {{ t('loadExample') }} {{ t('updateLog') }} {{ t('feedback') }} @@ -163,6 +164,158 @@ + + +
+ 新增规则 + 新增变量 + 导出规则变量 + 导入规则变量 + 重载当前配置 + 应用并生效 + 恢复默认 + +
+ + + +
+ + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + + + + + + 删除 +
+ +
+
+ + +
+

作用域约定

+
{{ ruleScopeDoc }}
+

可用上下文

+
{{ ruleContextDoc }}
+

支持语法

+
{{ ruleSyntaxDoc }}
+

支持函数

+
{{ ruleFunctionDoc }}
+

表达式示例

+
{{ ruleExamplesDoc }}
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -185,6 +338,16 @@ import { subscribeCustomAssetStore, type CustomAssetItem } from '@/utils/customAssets'; +import { + readSharedGroupRulesConfig, + writeSharedGroupRulesConfig +} from '@/utils/groupRulesConfigSource'; +import { + DEFAULT_GROUP_RULES_CONFIG, + type GroupRulesConfig, + type ExpressionRuleDefinition, + type RuleVariableDefinition +} from '@/configs/groupRules'; const props = withDefaults(defineProps<{ isEmbed?: boolean; @@ -217,6 +380,7 @@ const state = reactive({ showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态 showDataPreviewDialog: false, // 控制数据预览对话框的显示状态 showAssetManagerDialog: false, // 控制素材管理对话框的显示状态 + showRuleManagerDialog: false, // 控制规则管理对话框的显示状态 previewDataContent: '', // 存储预览的数据内容 }); const assetLibraries = ASSET_LIBRARIES.map((item) => ({ @@ -225,12 +389,186 @@ const assetLibraries = ASSET_LIBRARIES.map((item) => ({ })); const assetManagerLibrary = ref(assetLibraries[0]?.id || 'shikigami'); const assetUploadInputRef = ref(null); +const ruleBundleImportInputRef = ref(null); const managedAssets = reactive>({}); assetLibraries.forEach((item) => { managedAssets[item.id] = []; }); let unsubscribeAssetStore: (() => void) | null = null; +const ruleManagerTab = ref<'rules' | 'variables' | 'docs'>('rules'); + +const cloneRuleConfig = (config: GroupRulesConfig): GroupRulesConfig => ({ + version: config.version, + fireShikigamiWhitelist: [...config.fireShikigamiWhitelist], + shikigamiYuhunBlacklist: config.shikigamiYuhunBlacklist.map((rule) => ({ ...rule })), + shikigamiConflictPairs: config.shikigamiConflictPairs.map((rule) => ({ ...rule })), + expressionRules: config.expressionRules.map((rule) => ({ ...rule })), + ruleVariables: config.ruleVariables.map((item) => ({ ...item })) +}); + +const createExpressionRule = (): ExpressionRuleDefinition => ({ + id: `rule_${Date.now()}`, + enabled: true, + severity: 'warning', + code: 'CUSTOM_EXPRESSION', + condition: 'false', + message: '请补充规则提示文案' +}); + +const createRuleVariable = (): RuleVariableDefinition => ({ + key: `var_${Date.now()}`, + value: '' +}); + +const ruleConfigDraft = ref(cloneRuleConfig(readSharedGroupRulesConfig())); +const ruleEditorVisible = ref(false); +const editingRuleIndex = ref(null); +const ruleEditorDraft = ref(null); + +const cloneExpressionRule = (rule: ExpressionRuleDefinition): ExpressionRuleDefinition => ({ + id: rule.id || `rule_${Date.now()}`, + enabled: rule.enabled !== false, + severity: rule.severity || 'warning', + code: rule.code || 'CUSTOM_EXPRESSION', + condition: rule.condition || 'false', + message: rule.message || '请补充规则提示文案' +}); + +const normalizeText = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); +const normalizeSeverity = (value: unknown): 'warning' | 'error' | 'info' => { + return value === 'error' || value === 'info' ? value : 'warning'; +}; +const normalizeImportedExpressionRules = (value: unknown): ExpressionRuleDefinition[] => { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (!item || typeof item !== 'object') return null; + const raw = item as Record; + const id = normalizeText(raw.id); + const condition = normalizeText(raw.condition); + const message = normalizeText(raw.message); + if (!id || !condition || !message) return null; + const code = normalizeText(raw.code); + return { + id, + condition, + message, + enabled: raw.enabled !== false, + severity: normalizeSeverity(raw.severity), + ...(code ? { code } : {}) + }; + }) + .filter((item): item is ExpressionRuleDefinition => !!item); +}; +const normalizeImportedRuleVariables = (value: unknown): RuleVariableDefinition[] => { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (!item || typeof item !== 'object') return null; + const raw = item as Record; + const key = normalizeText(raw.key); + if (!key) return null; + const variableValue = typeof raw.value === 'string' ? raw.value : String(raw.value ?? ''); + return { key, value: variableValue }; + }) + .filter((item): item is RuleVariableDefinition => !!item); +}; + +const ruleScopeDoc = `规则新增字段(建议): scopeKind +- team: 在队伍组(dynamic-group: team)上执行(当前已生效) +- shikigami: 在式神组(dynamic-group: shikigami)上执行(规划中,当前未生效) + +注意 +- scopeKind 决定“规则运行上下文” +- 告警点击定位固定为:触发该规则的 dynamic-group`; + +const ruleContextDoc = `当 scopeKind = "team"(队伍规则) +ctx.team.shikigamis: 式神数组 +- 单项示例: { nodeId: "n1", assetId: "sp_kaguya", name: "辉夜姬", library: "shikigami" } + +ctx.team.yuhuns: 御魂数组 +- 单项示例: { nodeId: "n2", assetId: "p4_poshi", name: "破势", library: "yuhun" } + +ctx.group.id / ctx.group.name +- 示例: "team-1" / "冲榜队A" + +当 scopeKind = "shikigami"(式神规则,规划中,当前未生效) +ctx.unit.shikigami: 当前式神对象(单个) +- 示例: { nodeId: "n1", assetId: "sp_kaguya", name: "辉夜姬", library: "shikigami" } + +ctx.unit.yuhuns: 当前式神关联御魂数组 +- 单项示例: { nodeId: "n2", assetId: "p4_poshi", name: "破势", library: "yuhun" } + +通用共享变量 +shared.vars(变量 tab 配置后的 key/value 映射) +- 示例: + shared.vars["供火式神"] = ["辉夜姬", "座敷童子"] + shared.vars["输出式神"] = ["阿修罗", "茨木童子"]`; + +const ruleFunctionDoc = `count(value) +- 用途: 计算数量(数组长度 / 字符串长度) +- team 示例: count(ctx.team.shikigamis) >= 5 +- shikigami 示例: count(ctx.unit.yuhuns) >= 1 + +contains(collection, target) +- 用途: 判断集合或字符串是否包含目标值 +- team 示例: contains(map(ctx.team.shikigamis, "name"), "辉夜姬") + +intersect(leftArray, rightArray) +- 用途: 取数组交集(去重) +- team 示例: count(intersect(map(ctx.team.shikigamis, "name"), getVar("供火式神"))) > 0 + +map(collection, "fieldName") +- 用途: 提取对象数组字段 +- team 示例: map(ctx.team.shikigamis, "name") +- shikigami 示例: map(ctx.unit.yuhuns, "name") + +unique(collection) +- 用途: 数组去重 +- team 示例: count(unique(map(ctx.team.yuhuns, "name"))) >= 2 + +exists(value) +- 用途: 判断值是否存在(非 null/空串;数组需长度>0) +- 示例: exists(getVar("核心式神")) + +lower(value) / upper(value) +- 用途: 字符串大小写转换 +- 示例: contains(lower("PoShi"), "poshi") + +getVar("变量Key") +- 用途: 获取变量 tab 里配置的值(通常返回字符串数组) +- 示例: getVar("供火式神")`; + +const ruleSyntaxDoc = `支持 +- 字面量: "文本" / 数字 / true / false / null +- 数组: ["辉夜姬", "座敷童子"] +- 路径: ctx.team.shikigamis / ctx.unit.shikigami / shared.vars +- 函数调用: count(...), contains(...), intersect(...), map(...) +- 逻辑运算: && || ! +- 比较运算: == != > >= < <= +- 算术运算: + - * / +- 括号: ( ... ) + +不支持 +- index 语法(如 getIndexOf / arr[0]) +- 自定义遍历语法(for/while/foreach) +- 自定义函数定义 +- 赋值语句 +- eval/new Function`; + +const ruleExamplesDoc = `1) [team] 队伍至少有一个供火式神 +count(intersect(map(ctx.team.shikigamis, "name"), getVar("供火式神"))) > 0 + +2) [team] 队伍里不能同时出现千姬和腹肌清姬 +contains(map(ctx.team.shikigamis, "name"), "千姬") && contains(map(ctx.team.shikigamis, "name"), "腹肌清姬") + +3) [team] 队伍御魂至少 2 种(避免全员同御魂) +count(unique(map(ctx.team.yuhuns, "name"))) >= 2 + +4) [规划中的 shikigami scope] 当前式神是辉夜姬且其关联御魂包含破势 +ctx.unit.shikigami.name == "辉夜姬" && contains(map(ctx.unit.yuhuns, "name"), "破势")`; + const refreshManagedAssets = (library?: string) => { if (library) { managedAssets[library] = listCustomAssets(library); @@ -246,6 +584,166 @@ const openAssetManager = () => { state.showAssetManagerDialog = true; }; +const reloadRuleManagerDraft = () => { + ruleConfigDraft.value = cloneRuleConfig(readSharedGroupRulesConfig()); + cancelRuleEditor(); +}; + +const openRuleManager = () => { + reloadRuleManagerDraft(); + ruleManagerTab.value = 'rules'; + state.showRuleManagerDialog = true; + ruleEditorVisible.value = false; + editingRuleIndex.value = null; + ruleEditorDraft.value = null; +}; + +const exportRuleBundle = () => { + try { + const payload = { + version: ruleConfigDraft.value.version, + expressionRules: ruleConfigDraft.value.expressionRules, + ruleVariables: ruleConfigDraft.value.ruleVariables + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `rule-bundle-${new Date().toISOString().slice(0, 10)}.json`; + anchor.click(); + URL.revokeObjectURL(url); + showMessage('success', '规则变量已导出'); + } catch (error) { + console.error('导出规则变量失败:', error); + showMessage('error', '导出失败'); + } +}; + +const triggerRuleBundleImport = () => { + ruleBundleImportInputRef.value?.click(); +}; + +const handleRuleBundleImport = async (event: Event) => { + const target = event.target as HTMLInputElement | null; + const file = target?.files?.[0]; + if (!file) { + if (target) target.value = ''; + return; + } + + try { + const rawText = await file.text(); + const parsed = JSON.parse(rawText) as Record; + const importedRules = normalizeImportedExpressionRules(parsed.expressionRules); + const importedVariables = normalizeImportedRuleVariables(parsed.ruleVariables); + if (!importedRules.length && !importedVariables.length) { + showMessage('warning', '导入文件中没有可用的规则或变量'); + return; + } + ruleConfigDraft.value = { + ...ruleConfigDraft.value, + expressionRules: importedRules, + ruleVariables: importedVariables + }; + cancelRuleEditor(); + showMessage('success', '规则变量已导入,请点击“应用并生效”'); + } catch (error) { + console.error('导入规则变量失败:', error); + showMessage('error', '导入失败,文件格式错误'); + } finally { + if (target) target.value = ''; + } +}; + +const addExpressionRule = () => { + const newRule = createExpressionRule(); + ruleConfigDraft.value.expressionRules.push(newRule); + openExpressionRuleEditor(ruleConfigDraft.value.expressionRules.length - 1); + ruleManagerTab.value = 'rules'; +}; + +const removeExpressionRule = (index: number) => { + if (editingRuleIndex.value === index) { + cancelRuleEditor(); + } + ruleConfigDraft.value.expressionRules.splice(index, 1); + if (editingRuleIndex.value != null && editingRuleIndex.value > index) { + editingRuleIndex.value -= 1; + } +}; + +const openExpressionRuleEditor = (index: number) => { + const target = ruleConfigDraft.value.expressionRules[index]; + if (!target) return; + editingRuleIndex.value = index; + ruleEditorDraft.value = cloneExpressionRule(target); + ruleEditorVisible.value = true; +}; + +const cancelRuleEditor = () => { + ruleEditorVisible.value = false; + editingRuleIndex.value = null; + ruleEditorDraft.value = null; +}; + +const saveRuleEditor = () => { + const index = editingRuleIndex.value; + const draft = ruleEditorDraft.value; + if (index == null || !draft) { + return; + } + const ruleId = draft.id?.trim(); + const condition = draft.condition?.trim(); + const message = draft.message?.trim(); + if (!ruleId || !condition || !message) { + showMessage('warning', '规则 ID、条件表达式、提示文案不能为空'); + return; + } + const normalized = cloneExpressionRule({ + ...draft, + id: ruleId, + condition, + message + }); + ruleConfigDraft.value.expressionRules[index] = normalized; + cancelRuleEditor(); +}; + +const addRuleVariable = () => { + ruleConfigDraft.value.ruleVariables.push(createRuleVariable()); + ruleManagerTab.value = 'variables'; +}; + +const removeRuleVariable = (index: number) => { + ruleConfigDraft.value.ruleVariables.splice(index, 1); +}; + +const applyRuleManagerConfig = () => { + try { + const normalized = writeSharedGroupRulesConfig(ruleConfigDraft.value); + ruleConfigDraft.value = cloneRuleConfig(normalized); + showMessage('success', '规则配置已生效'); + } catch (error) { + console.error('应用规则配置失败:', error); + showMessage('error', '规则配置应用失败'); + } +}; + +const restoreDefaultRuleConfig = () => { + ElMessageBox.confirm('恢复默认会覆盖当前规则和变量,是否继续?', '提示', { + confirmButtonText: '恢复默认', + cancelButtonText: '取消', + type: 'warning', + }).then(() => { + const normalized = writeSharedGroupRulesConfig(DEFAULT_GROUP_RULES_CONFIG); + ruleConfigDraft.value = cloneRuleConfig(normalized); + cancelRuleEditor(); + showMessage('success', '已恢复默认规则配置'); + }).catch(() => { + // 用户取消 + }); +}; + const getManagedAssets = (libraryId: string) => { return managedAssets[libraryId] || []; }; @@ -763,4 +1261,118 @@ const handleClose = (done) => { color: #303133; word-break: break-all; } + +.rule-manager-actions { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.rule-manager-tabs { + min-height: 420px; +} + +.rule-table-wrap, +.variable-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.variable-item { + border: 1px solid #e4e7ed; + border-radius: 6px; + padding: 12px; + background: #fafafa; +} + +.rule-table :deep(.el-table__cell) { + padding-top: 6px; + padding-bottom: 6px; +} + +.rule-inline-select { + width: 100%; +} + +.severity-select--warning :deep(.el-input__wrapper) { + background: #fff7ed; + box-shadow: inset 0 0 0 1px #fed7aa; +} + +.severity-select--warning :deep(.el-input__inner) { + color: #9a3412; +} + +.severity-select--error :deep(.el-input__wrapper) { + background: #fef2f2; + box-shadow: inset 0 0 0 1px #fecaca; +} + +.severity-select--error :deep(.el-input__inner) { + color: #b91c1c; +} + +.severity-select--info :deep(.el-input__wrapper) { + background: #eff6ff; + box-shadow: inset 0 0 0 1px #bfdbfe; +} + +.severity-select--info :deep(.el-input__inner) { + color: #1d4ed8; +} + +.rule-cell-ellipsis { + display: inline-block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rule-editor-form :deep(.el-form-item) { + margin-bottom: 14px; +} + +.variable-item { + display: grid; + grid-template-columns: 220px 1fr auto; + gap: 12px; + align-items: start; +} + +.variable-key, +.variable-value { + margin-bottom: 0; +} + +.rule-docs { + max-height: 460px; + overflow-y: auto; + padding-right: 4px; +} + +.rule-docs h4 { + margin: 6px 0; + color: #303133; +} + +.rule-docs pre { + margin: 0 0 12px; + padding: 10px; + border-radius: 6px; + background: #f5f7fa; + color: #606266; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 900px) { + .variable-item { + grid-template-columns: 1fr; + } +} diff --git a/src/components/flow/ComponentsPanel.vue b/src/components/flow/ComponentsPanel.vue index 2b6abfc..098567c 100644 --- a/src/components/flow/ComponentsPanel.vue +++ b/src/components/flow/ComponentsPanel.vue @@ -39,9 +39,7 @@ const componentGroups = [ groupMeta: { version: 1, groupKind: 'team', - groupName: '', - ruleEnabled: true, - ruleScope: ['shikigami-yuhun', 'shikigami-shikigami'] + ruleEnabled: true }, collapsible: true, isCollapsed: false, diff --git a/src/components/flow/FlowEditor.vue b/src/components/flow/FlowEditor.vue index 2a801a7..0319b25 100644 --- a/src/components/flow/FlowEditor.vue +++ b/src/components/flow/FlowEditor.vue @@ -123,6 +123,7 @@ import { normalizePropertiesWithStyle, normalizeNodeStyle, styleEquals } from '@ import { useCanvasSettings } from '@/ts/useCanvasSettings'; import { validateGraphGroupRules, type GroupRuleWarning } from '@/utils/groupRules'; import { subscribeSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource'; +import { getProblemTargetCandidateIds } from '@/utils/problemTarget'; type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter'; type DistributeType = 'horizontal' | 'vertical'; @@ -732,7 +733,7 @@ function locateProblemNode(warning: GroupRuleWarning) { const lfInstance = lf.value as any; if (!lfInstance) return; - const candidateIds = [...(warning.nodeIds || []), warning.groupId].filter((id) => !!id); + const candidateIds = getProblemTargetCandidateIds(warning); const targetId = candidateIds.find((id) => !!lfInstance.getNodeModelById(id)); if (!targetId) { showMessage('warning', '未找到告警对应节点,可能已被删除'); diff --git a/src/components/flow/panels/DynamicGroupPanel.vue b/src/components/flow/panels/DynamicGroupPanel.vue index 9bd842e..f12684e 100644 --- a/src/components/flow/panels/DynamicGroupPanel.vue +++ b/src/components/flow/panels/DynamicGroupPanel.vue @@ -2,7 +2,6 @@ import { reactive, watch } from 'vue'; import { getLogicFlowInstance } from '@/ts/useLogicFlow'; import { - DEFAULT_GROUP_RULE_SCOPE, GROUP_META_VERSION, normalizeDynamicGroupMeta } from '@/utils/graphSchema'; @@ -13,40 +12,19 @@ const props = defineProps<{ type DynamicGroupMeta = { groupKind: 'team' | 'shikigami'; - groupName: string; ruleEnabled: boolean; - ruleScope: string[]; }; -const DEFAULT_SCOPE_OPTIONS = [ - { value: 'shikigami-yuhun', label: '式神-御魂关系' }, - { value: 'shikigami-shikigami', label: '式神-式神关系' } -]; - const form = reactive({ groupKind: 'team', - groupName: '', - ruleEnabled: true, - ruleScope: [...DEFAULT_GROUP_RULE_SCOPE] + ruleEnabled: true }); -const normalizeRuleScope = (value: unknown): string[] => { - if (!Array.isArray(value)) { - return [...DEFAULT_GROUP_RULE_SCOPE]; - } - const normalized = value - .map((item) => (typeof item === 'string' ? item.trim() : '')) - .filter((item) => !!item); - return normalized.length ? normalized : [...DEFAULT_GROUP_RULE_SCOPE]; -}; - const syncFromNode = (node?: any) => { if (!node) return; const groupMeta = normalizeDynamicGroupMeta(node.properties?.groupMeta); form.groupKind = groupMeta.groupKind; - form.groupName = groupMeta.groupName; form.ruleEnabled = groupMeta.ruleEnabled; - form.ruleScope = normalizeRuleScope(groupMeta.ruleScope); }; watch( @@ -69,9 +47,7 @@ const applyGroupMeta = () => { groupMeta: { version: GROUP_META_VERSION, groupKind: form.groupKind, - groupName: form.groupName.trim(), - ruleEnabled: form.ruleEnabled, - ruleScope: normalizeRuleScope(form.ruleScope) + ruleEnabled: form.ruleEnabled } }); }; @@ -89,50 +65,12 @@ const applyGroupMeta = () => { -
-
分组名称
- -
-
启用规则检查
- -
-
规则范围
- - - -
可扩展:后续新增规则域时可直接添加 scope。
-
diff --git a/src/configs/groupRules.ts b/src/configs/groupRules.ts index 625d4f7..01715e5 100644 --- a/src/configs/groupRules.ts +++ b/src/configs/groupRules.ts @@ -15,37 +15,61 @@ export type GroupRulesConfig = { fireShikigamiWhitelist: string[] shikigamiYuhunBlacklist: ShikigamiYuhunBlacklistRule[] shikigamiConflictPairs: ShikigamiConflictRule[] + expressionRules: ExpressionRuleDefinition[] + ruleVariables: RuleVariableDefinition[] +} + +export type ExpressionRuleDefinition = { + id: string + condition: string + message: string + enabled?: boolean + severity?: 'warning' | 'error' | 'info' + code?: string +} + +export type RuleVariableDefinition = { + key: string + value: string } export const DEFAULT_GROUP_RULES_CONFIG: GroupRulesConfig = { - version: 1, + version: 3, fireShikigamiWhitelist: [ '辉夜姬', '因幡辉夜姬', '追月神', '座敷童子', - '千姬', - '帝释天', - '不见岳', - '食灵' + '千姬' ], - shikigamiYuhunBlacklist: [ + shikigamiYuhunBlacklist: [], + shikigamiConflictPairs: [], + expressionRules: [ { - shikigami: '辉夜姬', - yuhun: '破势', - message: '规则冲突:辉夜姬通常不建议携带破势。' - } - ], - shikigamiConflictPairs: [ - { - left: '千姬', - right: '腹肌清姬', - message: '规则冲突:千姬与腹肌清姬不建议同队。' + id: 'team-require-fire-shikigami', + condition: 'count(intersect(ctx.team.shikigamiNames, getVar("供火式神"))) == 0', + message: '规则提示:当前队伍缺少供火式神。', + severity: 'warning', + code: 'TEAM_MISSING_FIRE_SHIKIGAMI', + enabled: true }, { - left: '千姬', - right: '蝮骨清姬', - message: '规则冲突:千姬与蝮骨清姬不建议同队。' + id: 'team-kaguya-no-poshi', + condition: 'contains(ctx.team.shikigamiNames, "辉夜姬") && contains(ctx.team.yuhunNames, "破势")', + message: '规则冲突:辉夜姬不建议携带破势。', + severity: 'warning', + code: 'TEAM_KAGUYA_POSHI_CONFLICT', + enabled: true + } + ], + ruleVariables: [ + { + key: '供火式神', + value: '辉夜姬,因幡辉夜姬,追月神,座敷童子,千姬' + }, + { + key: '输出御魂', + value: '破势,狂骨,针女,海月火玉' } ] } diff --git a/src/utils/groupRules.ts b/src/utils/groupRules.ts index 719ff97..5c9e3a8 100644 --- a/src/utils/groupRules.ts +++ b/src/utils/groupRules.ts @@ -1,6 +1,7 @@ import type { GroupRulesConfig } from '@/configs/groupRules' import { readSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource' import { getDynamicGroupChildIds, normalizeGraphRawDataSchema } from '@/utils/graphSchema' +import { evaluateRuleExpressionAsBoolean } from '@/utils/ruleExpression' type GraphData = { nodes: any[] @@ -25,7 +26,7 @@ type TeamAssetSnapshot = { export type GroupRuleWarning = { id: string ruleId: string - code: 'SHIKIGAMI_YUHUN_BLACKLIST' | 'SHIKIGAMI_CONFLICT' | 'MISSING_FIRE_SHIKIGAMI' + code: string severity: 'warning' | 'error' | 'info' groupId: string groupName?: string @@ -33,6 +34,32 @@ export type GroupRuleWarning = { nodeIds: string[] } +type TeamExpressionScope = { + ctx: { + group: { + id: string + name: string + } + team: { + shikigamis: TeamAsset[] + yuhuns: TeamAsset[] + shikigamiNames: string[] + yuhunNames: string[] + } + members: { + shikigami: TeamAsset[] + yuhun: TeamAsset[] + shikigamiNames: string[] + yuhunNames: string[] + } + nodeIds: string[] + } + shared: { + fireShikigamiWhitelist: string[] + vars: Record + } +} + const normalizeText = (value: unknown): string => { if (typeof value !== 'string') { return '' @@ -69,11 +96,25 @@ const inferLibrary = (node: any): string => { return '' } -const includesName = (list: string[], target: string): boolean => { - return list.some((item) => item === target) +const dedupeNodeIds = (ids: string[]): string[] => Array.from(new Set(ids)) + +const parseVariableValue = (value: string): string[] => { + return value + .split(/[\n,,]/g) + .map((item) => item.trim()) + .filter((item) => !!item) } -const dedupeNodeIds = (ids: string[]): string[] => Array.from(new Set(ids)) +const createSharedVariableMap = (config: GroupRulesConfig): Record => { + const map: Record = {} + config.ruleVariables.forEach((item) => { + const key = normalizeText(item.key) + if (!key) return + map[key] = dedupeNodeIds(parseVariableValue(item.value)) + }) + map.fireShikigamiWhitelist = [...config.fireShikigamiWhitelist] + return map +} const collectTeamAssetSnapshots = (graphData: GraphData): TeamAssetSnapshot[] => { const nodes = Array.isArray(graphData?.nodes) ? graphData.nodes : [] @@ -113,7 +154,6 @@ const collectTeamAssetSnapshots = (graphData: GraphData): TeamAssetSnapshot[] => if (isDynamicGroupNode(node)) { const childKind = normalizeText(node?.properties?.groupMeta?.groupKind) - // 嵌套 team 视为独立边界,不纳入当前队伍聚合 if (childKind === 'team') { continue } @@ -161,6 +201,45 @@ const collectTeamAssetSnapshots = (graphData: GraphData): TeamAssetSnapshot[] => const createWarningId = (groupId: string, ruleId: string): string => `${groupId}::${ruleId}` +const createTeamScope = (team: TeamAssetSnapshot, config: GroupRulesConfig): TeamExpressionScope => { + const shikigamiNames = team.shikigamiAssets.map((item) => item.name) + const yuhunNames = team.yuhunAssets.map((item) => item.name) + return { + ctx: { + group: { + id: team.groupId, + name: team.groupName + }, + team: { + shikigamis: team.shikigamiAssets.map((item) => ({ ...item })), + yuhuns: team.yuhunAssets.map((item) => ({ ...item })), + shikigamiNames: [...shikigamiNames], + yuhunNames: [...yuhunNames] + }, + members: { + shikigami: team.shikigamiAssets.map((item) => ({ ...item })), + yuhun: team.yuhunAssets.map((item) => ({ ...item })), + shikigamiNames, + yuhunNames + }, + nodeIds: [...team.nodeIds] + }, + shared: { + fireShikigamiWhitelist: [...config.fireShikigamiWhitelist], + vars: createSharedVariableMap(config) + } + } +} + +const evaluateCondition = (expression: string, scope: TeamExpressionScope): boolean => { + try { + return evaluateRuleExpressionAsBoolean(expression, scope) + } catch (error) { + console.warn('[groupRules] 表达式执行失败:', expression, error) + return false + } +} + export const validateGraphGroupRules = ( graphData: GraphData, config?: GroupRulesConfig @@ -171,67 +250,26 @@ export const validateGraphGroupRules = ( const warnings: GroupRuleWarning[] = [] teams.forEach((team) => { - const shikigamiNames = team.shikigamiAssets.map((item) => item.name) - const yuhunNames = team.yuhunAssets.map((item) => item.name) + const scope = createTeamScope(team, effectiveConfig) - effectiveConfig.shikigamiYuhunBlacklist.forEach((rule) => { - if (includesName(shikigamiNames, rule.shikigami) && includesName(yuhunNames, rule.yuhun)) { - const ruleId = `blacklist:${rule.shikigami}:${rule.yuhun}` - const nodeIds = dedupeNodeIds([ - ...team.shikigamiAssets.filter((item) => item.name === rule.shikigami).map((item) => item.nodeId), - ...team.yuhunAssets.filter((item) => item.name === rule.yuhun).map((item) => item.nodeId) - ]) - - warnings.push({ - id: createWarningId(team.groupId, ruleId), - ruleId, - code: 'SHIKIGAMI_YUHUN_BLACKLIST', - severity: 'warning', - groupId: team.groupId, - groupName: team.groupName || undefined, - nodeIds, - message: rule.message || `规则冲突:${rule.shikigami} 不建议携带 ${rule.yuhun}。` - }) + effectiveConfig.expressionRules.forEach((rule) => { + if (rule.enabled === false) { + return } + if (!evaluateCondition(rule.condition, scope)) { + return + } + warnings.push({ + id: createWarningId(team.groupId, `expr:${rule.id}`), + ruleId: rule.id, + code: rule.code || 'CUSTOM_EXPRESSION', + severity: rule.severity || 'warning', + groupId: team.groupId, + groupName: team.groupName || undefined, + nodeIds: [...team.nodeIds], + message: rule.message + }) }) - - effectiveConfig.shikigamiConflictPairs.forEach((rule) => { - if (includesName(shikigamiNames, rule.left) && includesName(shikigamiNames, rule.right)) { - const ruleId = `conflict:${rule.left}:${rule.right}` - const nodeIds = dedupeNodeIds([ - ...team.shikigamiAssets.filter((item) => item.name === rule.left).map((item) => item.nodeId), - ...team.shikigamiAssets.filter((item) => item.name === rule.right).map((item) => item.nodeId) - ]) - warnings.push({ - id: createWarningId(team.groupId, ruleId), - ruleId, - code: 'SHIKIGAMI_CONFLICT', - severity: 'warning', - groupId: team.groupId, - groupName: team.groupName || undefined, - nodeIds, - message: rule.message || `规则冲突:${rule.left} 与 ${rule.right} 不建议同队。` - }) - } - }) - - const hasShikigami = shikigamiNames.length > 0 - if (hasShikigami) { - const hasFireShikigami = shikigamiNames.some((name) => effectiveConfig.fireShikigamiWhitelist.includes(name)) - if (!hasFireShikigami) { - const ruleId = 'missing-fire-shikigami' - warnings.push({ - id: createWarningId(team.groupId, ruleId), - ruleId, - code: 'MISSING_FIRE_SHIKIGAMI', - severity: 'warning', - groupId: team.groupId, - groupName: team.groupName || undefined, - nodeIds: dedupeNodeIds(team.shikigamiAssets.map((item) => item.nodeId)), - message: '规则提示:当前队伍未检测到鬼火式神,建议补充供火位。' - }) - } - } }) return warnings diff --git a/src/utils/groupRulesConfigSource.ts b/src/utils/groupRulesConfigSource.ts index a0d378a..e5158ba 100644 --- a/src/utils/groupRulesConfigSource.ts +++ b/src/utils/groupRulesConfigSource.ts @@ -1,6 +1,8 @@ import { DEFAULT_GROUP_RULES_CONFIG, + type ExpressionRuleDefinition, type GroupRulesConfig, + type RuleVariableDefinition, type ShikigamiConflictRule, type ShikigamiYuhunBlacklistRule } from '@/configs/groupRules' @@ -21,7 +23,9 @@ const cloneDefaultGroupRulesConfig = (): GroupRulesConfig => ({ version: DEFAULT_GROUP_RULES_CONFIG.version, fireShikigamiWhitelist: [...DEFAULT_GROUP_RULES_CONFIG.fireShikigamiWhitelist], shikigamiYuhunBlacklist: DEFAULT_GROUP_RULES_CONFIG.shikigamiYuhunBlacklist.map((rule) => ({ ...rule })), - shikigamiConflictPairs: DEFAULT_GROUP_RULES_CONFIG.shikigamiConflictPairs.map((rule) => ({ ...rule })) + shikigamiConflictPairs: DEFAULT_GROUP_RULES_CONFIG.shikigamiConflictPairs.map((rule) => ({ ...rule })), + expressionRules: DEFAULT_GROUP_RULES_CONFIG.expressionRules.map((rule) => ({ ...rule })), + ruleVariables: DEFAULT_GROUP_RULES_CONFIG.ruleVariables.map((item) => ({ ...item })) }) const normalizeStringList = (value: unknown, fallback: string[]): string[] => { @@ -89,6 +93,64 @@ const normalizeConflictRules = ( .filter((item): item is ShikigamiConflictRule => !!item) } +const normalizeExpressionRules = ( + value: unknown, + fallback: ExpressionRuleDefinition[] +): ExpressionRuleDefinition[] => { + if (!Array.isArray(value)) { + return fallback.map((rule) => ({ ...rule })) + } + return value + .map((item) => { + if (!item || typeof item !== 'object') { + return null + } + const raw = item as Record + const id = normalizeText(raw.id) + const condition = normalizeText(raw.condition) + const message = normalizeText(raw.message) + if (!id || !condition || !message) { + return null + } + const enabled = raw.enabled !== false + const severityRaw = normalizeText(raw.severity) + const severity = severityRaw === 'error' || severityRaw === 'info' ? severityRaw : 'warning' + const code = normalizeText(raw.code) + return { + id, + condition, + message, + enabled, + severity, + ...(code ? { code } : {}) + } + }) + .filter((item): item is ExpressionRuleDefinition => !!item) +} + +const normalizeRuleVariables = ( + value: unknown, + fallback: RuleVariableDefinition[] +): RuleVariableDefinition[] => { + if (!Array.isArray(value)) { + return fallback.map((item) => ({ ...item })) + } + return value + .map((item) => { + if (!item || typeof item !== 'object') { + return null + } + const raw = item as Record + const key = normalizeText(raw.key) + if (!key) { + return null + } + const value = typeof raw.value === 'string' ? raw.value : String(raw.value ?? '') + return { key, value } + }) + .filter((item): item is RuleVariableDefinition => !!item) +} + const normalizeGroupRulesConfig = (input: unknown): GroupRulesConfig | null => { if (!input || typeof input !== 'object') { return null @@ -105,7 +167,9 @@ const normalizeGroupRulesConfig = (input: unknown): GroupRulesConfig | null => { version, fireShikigamiWhitelist: normalizeStringList(raw.fireShikigamiWhitelist, fallback.fireShikigamiWhitelist), shikigamiYuhunBlacklist: normalizeBlacklistRules(raw.shikigamiYuhunBlacklist, fallback.shikigamiYuhunBlacklist), - shikigamiConflictPairs: normalizeConflictRules(raw.shikigamiConflictPairs, fallback.shikigamiConflictPairs) + shikigamiConflictPairs: normalizeConflictRules(raw.shikigamiConflictPairs, fallback.shikigamiConflictPairs), + expressionRules: normalizeExpressionRules(raw.expressionRules, fallback.expressionRules), + ruleVariables: normalizeRuleVariables(raw.ruleVariables, fallback.ruleVariables) } } diff --git a/src/utils/problemTarget.ts b/src/utils/problemTarget.ts new file mode 100644 index 0000000..e743df2 --- /dev/null +++ b/src/utils/problemTarget.ts @@ -0,0 +1,19 @@ +import type { GroupRuleWarning } from '@/utils/groupRules' + +const normalizeIds = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return [] + } + return value + .filter((item): item is string => typeof item === 'string' && !!item.trim()) + .map((item) => item.trim()) +} + +const dedupeIds = (ids: string[]): string[] => Array.from(new Set(ids)) + +export const getProblemTargetCandidateIds = (warning: GroupRuleWarning): string[] => { + const nodeIds = normalizeIds(warning.nodeIds) + const groupId = typeof warning.groupId === 'string' ? warning.groupId.trim() : '' + const groupIds = groupId ? [groupId] : [] + return dedupeIds([...groupIds, ...nodeIds]) +} diff --git a/src/utils/ruleExpression.ts b/src/utils/ruleExpression.ts new file mode 100644 index 0000000..506817b --- /dev/null +++ b/src/utils/ruleExpression.ts @@ -0,0 +1,539 @@ +type TokenType = 'identifier' | 'number' | 'string' | 'boolean' | 'null' | 'operator' | 'punctuation' | 'eof' + +type Token = { + type: TokenType + value: string + position: number +} + +type ExpressionNode = + | { type: 'Literal'; value: unknown } + | { type: 'ArrayExpression'; elements: ExpressionNode[] } + | { type: 'IdentifierPath'; path: string[] } + | { type: 'CallExpression'; callee: string; args: ExpressionNode[] } + | { type: 'UnaryExpression'; operator: '!' | '-'; argument: ExpressionNode } + | { + type: 'BinaryExpression' + operator: '||' | '&&' | '==' | '!=' | '>' | '>=' | '<' | '<=' | '+' | '-' | '*' | '/' + left: ExpressionNode + right: ExpressionNode + } + +export type RuleExpressionScope = Record + +const DEFAULT_MAX_STEPS = 20_000 +const astCache = new Map() + +const isWhitespace = (char: string) => /\s/.test(char) +const isDigit = (char: string) => /[0-9]/.test(char) +const isIdentifierStart = (char: string) => /[A-Za-z_$\u4E00-\u9FFF]/.test(char) +const isIdentifierPart = (char: string) => /[A-Za-z0-9_$\u4E00-\u9FFF]/.test(char) + +const tokenize = (source: string): Token[] => { + const tokens: Token[] = [] + let index = 0 + + const readString = (quote: string): Token => { + const start = index + index += 1 + let value = '' + while (index < source.length) { + const current = source[index] + if (current === '\\') { + const next = source[index + 1] + if (!next) { + throw new Error(`字符串转义不完整(位置 ${index})`) + } + value += next + index += 2 + continue + } + if (current === quote) { + index += 1 + return { type: 'string', value, position: start } + } + value += current + index += 1 + } + throw new Error(`字符串缺少结束引号(位置 ${start})`) + } + + const readNumber = (): Token => { + const start = index + let value = '' + let dotSeen = false + while (index < source.length) { + const current = source[index] + if (current === '.') { + if (dotSeen) break + dotSeen = true + value += current + index += 1 + continue + } + if (!isDigit(current)) { + break + } + value += current + index += 1 + } + return { type: 'number', value, position: start } + } + + const readIdentifier = (): Token => { + const start = index + let value = '' + while (index < source.length && isIdentifierPart(source[index])) { + value += source[index] + index += 1 + } + if (value === 'true' || value === 'false') { + return { type: 'boolean', value, position: start } + } + if (value === 'null') { + return { type: 'null', value, position: start } + } + return { type: 'identifier', value, position: start } + } + + const readOperatorOrPunctuation = (): Token => { + const start = index + const twoChars = source.slice(index, index + 2) + const twoCharOperators = ['&&', '||', '==', '!=', '>=', '<='] + if (twoCharOperators.includes(twoChars)) { + index += 2 + return { type: 'operator', value: twoChars, position: start } + } + + const oneChar = source[index] + const oneCharOperators = ['>', '<', '!', '+', '-', '*', '/'] + if (oneCharOperators.includes(oneChar)) { + index += 1 + return { type: 'operator', value: oneChar, position: start } + } + + const punctuations = ['(', ')', '[', ']', ',', '.'] + if (punctuations.includes(oneChar)) { + index += 1 + return { type: 'punctuation', value: oneChar, position: start } + } + + throw new Error(`无法识别的字符 "${oneChar}"(位置 ${start})`) + } + + while (index < source.length) { + const current = source[index] + if (isWhitespace(current)) { + index += 1 + continue + } + if (current === '"' || current === "'") { + tokens.push(readString(current)) + continue + } + if (isDigit(current)) { + tokens.push(readNumber()) + continue + } + if (isIdentifierStart(current)) { + tokens.push(readIdentifier()) + continue + } + tokens.push(readOperatorOrPunctuation()) + } + + tokens.push({ type: 'eof', value: '', position: source.length }) + return tokens +} + +class Parser { + private readonly tokens: Token[] + private cursor = 0 + + constructor(source: string) { + this.tokens = tokenize(source) + } + + parse(): ExpressionNode { + const expression = this.parseOrExpression() + this.expect('eof') + return expression + } + + private current(): Token { + return this.tokens[this.cursor] + } + + private consume(): Token { + const token = this.tokens[this.cursor] + this.cursor += 1 + return token + } + + private match(type: TokenType, value?: string): boolean { + const token = this.current() + if (token.type !== type) return false + if (value != null && token.value !== value) return false + return true + } + + private expect(type: TokenType, value?: string): Token { + const token = this.current() + if (!this.match(type, value)) { + const expected = value == null ? type : `${type}:${value}` + throw new Error(`表达式解析失败,期望 ${expected},实际 ${token.type}:${token.value}(位置 ${token.position})`) + } + return this.consume() + } + + private parseOrExpression(): ExpressionNode { + let left = this.parseAndExpression() + while (this.match('operator', '||')) { + const operator = this.consume().value as '||' + const right = this.parseAndExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseAndExpression(): ExpressionNode { + let left = this.parseEqualityExpression() + while (this.match('operator', '&&')) { + const operator = this.consume().value as '&&' + const right = this.parseEqualityExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseEqualityExpression(): ExpressionNode { + let left = this.parseComparisonExpression() + while (this.match('operator', '==') || this.match('operator', '!=')) { + const operator = this.consume().value as '==' | '!=' + const right = this.parseComparisonExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseComparisonExpression(): ExpressionNode { + let left = this.parseAdditiveExpression() + while ( + this.match('operator', '>') || + this.match('operator', '>=') || + this.match('operator', '<') || + this.match('operator', '<=') + ) { + const operator = this.consume().value as '>' | '>=' | '<' | '<=' + const right = this.parseAdditiveExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseAdditiveExpression(): ExpressionNode { + let left = this.parseMultiplicativeExpression() + while (this.match('operator', '+') || this.match('operator', '-')) { + const operator = this.consume().value as '+' | '-' + const right = this.parseMultiplicativeExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseMultiplicativeExpression(): ExpressionNode { + let left = this.parseUnaryExpression() + while (this.match('operator', '*') || this.match('operator', '/')) { + const operator = this.consume().value as '*' | '/' + const right = this.parseUnaryExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseUnaryExpression(): ExpressionNode { + if (this.match('operator', '!') || this.match('operator', '-')) { + const operator = this.consume().value as '!' | '-' + const argument = this.parseUnaryExpression() + return { type: 'UnaryExpression', operator, argument } + } + return this.parsePrimaryExpression() + } + + private parsePrimaryExpression(): ExpressionNode { + const token = this.current() + if (token.type === 'number') { + this.consume() + return { type: 'Literal', value: Number(token.value) } + } + if (token.type === 'string') { + this.consume() + return { type: 'Literal', value: token.value } + } + if (token.type === 'boolean') { + this.consume() + return { type: 'Literal', value: token.value === 'true' } + } + if (token.type === 'null') { + this.consume() + return { type: 'Literal', value: null } + } + if (this.match('punctuation', '[')) { + return this.parseArrayExpression() + } + if (this.match('punctuation', '(')) { + this.consume() + const expression = this.parseOrExpression() + this.expect('punctuation', ')') + return expression + } + if (token.type === 'identifier') { + return this.parseIdentifierPathOrCall() + } + throw new Error(`表达式解析失败,无法识别 token ${token.type}:${token.value}(位置 ${token.position})`) + } + + private parseArrayExpression(): ExpressionNode { + this.expect('punctuation', '[') + const elements: ExpressionNode[] = [] + if (!this.match('punctuation', ']')) { + while (true) { + elements.push(this.parseOrExpression()) + if (!this.match('punctuation', ',')) { + break + } + this.consume() + } + } + this.expect('punctuation', ']') + return { type: 'ArrayExpression', elements } + } + + private parseIdentifierPathOrCall(): ExpressionNode { + const head = this.expect('identifier').value + if (this.match('punctuation', '(')) { + this.consume() + const args: ExpressionNode[] = [] + if (!this.match('punctuation', ')')) { + while (true) { + args.push(this.parseOrExpression()) + if (!this.match('punctuation', ',')) { + break + } + this.consume() + } + } + this.expect('punctuation', ')') + return { type: 'CallExpression', callee: head, args } + } + + const path = [head] + while (this.match('punctuation', '.')) { + this.consume() + path.push(this.expect('identifier').value) + } + return { type: 'IdentifierPath', path } + } +} + +type BuiltinFunction = (...args: unknown[]) => unknown + +const toArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []) +const toString = (value: unknown): string => (typeof value === 'string' ? value : String(value ?? '')) + +const builtins: Record = { + count(value) { + if (Array.isArray(value) || typeof value === 'string') return value.length + if (value && typeof value === 'object') return Object.keys(value).length + return 0 + }, + contains(collection, target) { + if (Array.isArray(collection)) { + return collection.includes(target) + } + if (typeof collection === 'string') { + return collection.includes(toString(target)) + } + return false + }, + intersect(left, right) { + const rightSet = new Set(toArray(right)) + const result: unknown[] = [] + toArray(left).forEach((item) => { + if (rightSet.has(item) && !result.includes(item)) { + result.push(item) + } + }) + return result + }, + map(collection, key) { + const keyName = typeof key === 'string' ? key : '' + if (!keyName) return [] + return toArray(collection).map((item: any) => item?.[keyName]) + }, + unique(collection) { + return Array.from(new Set(toArray(collection))) + }, + exists(value) { + if (Array.isArray(value)) return value.length > 0 + return value != null && value !== '' + }, + lower(value) { + return toString(value).toLowerCase() + }, + upper(value) { + return toString(value).toUpperCase() + } +} + +const isTruthy = (value: unknown): boolean => !!value + +const resolveIdentifierPath = (scope: RuleExpressionScope, path: string[]): unknown => { + let current: unknown = scope + for (const segment of path) { + if (current == null || typeof current !== 'object') { + return undefined + } + current = (current as Record)[segment] + } + return current +} + +const resolveVarFromScope = (scope: RuleExpressionScope, key: unknown): unknown[] => { + const keyName = typeof key === 'string' ? key.trim() : '' + if (!keyName) { + return [] + } + const vars = resolveIdentifierPath(scope, ['shared', 'vars']) + if (!vars || typeof vars !== 'object') { + return [] + } + const value = (vars as Record)[keyName] + if (Array.isArray(value)) { + return value + } + if (value == null || value === '') { + return [] + } + return [toString(value)] +} + +type EvaluationState = { + steps: number + maxSteps: number +} + +const evaluateNode = (node: ExpressionNode, scope: RuleExpressionScope, state: EvaluationState): unknown => { + state.steps += 1 + if (state.steps > state.maxSteps) { + throw new Error('表达式执行超出步骤限制') + } + + switch (node.type) { + case 'Literal': + return node.value + case 'ArrayExpression': + return node.elements.map((item) => evaluateNode(item, scope, state)) + case 'IdentifierPath': + return resolveIdentifierPath(scope, node.path) + case 'CallExpression': { + if (node.callee === 'getVar') { + if (node.args.length !== 1) { + throw new Error('getVar 仅支持一个参数:变量 key') + } + const key = evaluateNode(node.args[0], scope, state) + return resolveVarFromScope(scope, key) + } + const fn = builtins[node.callee] + if (!fn) { + throw new Error(`不支持的函数调用: ${node.callee}`) + } + const args = node.args.map((arg) => evaluateNode(arg, scope, state)) + return fn(...args) + } + case 'UnaryExpression': { + const value = evaluateNode(node.argument, scope, state) + if (node.operator === '!') return !isTruthy(value) + return -Number(value) + } + case 'BinaryExpression': { + if (node.operator === '&&') { + const left = evaluateNode(node.left, scope, state) + if (!isTruthy(left)) return false + return isTruthy(evaluateNode(node.right, scope, state)) + } + if (node.operator === '||') { + const left = evaluateNode(node.left, scope, state) + if (isTruthy(left)) return true + return isTruthy(evaluateNode(node.right, scope, state)) + } + + const left = evaluateNode(node.left, scope, state) + const right = evaluateNode(node.right, scope, state) + + switch (node.operator) { + case '==': + return left === right + case '!=': + return left !== right + case '>': + return Number(left) > Number(right) + case '>=': + return Number(left) >= Number(right) + case '<': + return Number(left) < Number(right) + case '<=': + return Number(left) <= Number(right) + case '+': + return Number(left) + Number(right) + case '-': + return Number(left) - Number(right) + case '*': + return Number(left) * Number(right) + case '/': + return Number(left) / Number(right) + default: + return undefined + } + } + default: + return undefined + } +} + +export const parseRuleExpression = (source: string): ExpressionNode => { + const key = source.trim() + if (!key) { + throw new Error('表达式不能为空') + } + const cached = astCache.get(key) + if (cached) { + return cached + } + const parser = new Parser(key) + const ast = parser.parse() + astCache.set(key, ast) + return ast +} + +export const evaluateRuleExpression = ( + source: string, + scope: RuleExpressionScope, + options?: { maxSteps?: number } +): unknown => { + const ast = parseRuleExpression(source) + const state: EvaluationState = { + steps: 0, + maxSteps: options?.maxSteps ?? DEFAULT_MAX_STEPS + } + return evaluateNode(ast, scope, state) +} + +export const evaluateRuleExpressionAsBoolean = ( + source: string, + scope: RuleExpressionScope, + options?: { maxSteps?: number } +): boolean => { + const result = evaluateRuleExpression(source, scope, options) + return isTruthy(result) +} From e344c2272e2e5e186d43bf2a45072168cf7efe10 Mon Sep 17 00:00:00 2001 From: rookie4show Date: Fri, 27 Feb 2026 22:24:04 +0800 Subject: [PATCH 09/12] perf(vector-node): batch resize sync and cut redundant rerenders --- docs/test/acceptance.md | 16 +- src/__tests__/vectorNodeSync.test.ts | 104 ++++++++++ .../flow/nodes/common/VectorNode.vue | 180 ++++++++---------- .../flow/nodes/common/VectorNodeModel.ts | 22 ++- .../flow/nodes/common/vectorNodeSync.ts | 123 ++++++++++++ 5 files changed, 329 insertions(+), 116 deletions(-) create mode 100644 src/__tests__/vectorNodeSync.test.ts create mode 100644 src/components/flow/nodes/common/vectorNodeSync.ts 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; + } + }; +} From 8803a35996c02a0fe9d5ab83f1ce56f31086f44b Mon Sep 17 00:00:00 2001 From: rookie4show Date: Fri, 27 Feb 2026 22:29:33 +0800 Subject: [PATCH 10/12] Revert "perf(vector-node): batch resize sync and cut redundant rerenders" This reverts commit e344c2272e2e5e186d43bf2a45072168cf7efe10. --- docs/test/acceptance.md | 16 +- src/__tests__/vectorNodeSync.test.ts | 104 ---------- .../flow/nodes/common/VectorNode.vue | 180 ++++++++++-------- .../flow/nodes/common/VectorNodeModel.ts | 22 +-- .../flow/nodes/common/vectorNodeSync.ts | 123 ------------ 5 files changed, 116 insertions(+), 329 deletions(-) delete mode 100644 src/__tests__/vectorNodeSync.test.ts delete mode 100644 src/components/flow/nodes/common/vectorNodeSync.ts diff --git a/docs/test/acceptance.md b/docs/test/acceptance.md index 7cbc9bc..bc76fe8 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): -- 已通过:8 项(基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、分组规则静态检查、规则管理、矢量节点快速缩放性能回归、导出图片时隐藏 Dynamic Group)。 +- 已通过:5 项(基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、导出图片时隐藏 Dynamic Group)。 - 部分通过:1 项(跨项目规则互通方案确认)。 -- 未通过/待验证:4 项(其余项待完整手测或跨仓联调)。 +- 未通过/待验证:7 项(其余项待完整手测或跨仓联调)。 逐项状态: - 基础启动与构建:已通过 @@ -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 deleted file mode 100644 index e52610a..0000000 --- a/src/__tests__/vectorNodeSync.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -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 2db3ea2..33a1778 100644 --- a/src/components/flow/nodes/common/VectorNode.vue +++ b/src/components/flow/nodes/common/VectorNode.vue @@ -1,95 +1,120 @@ @@ -103,6 +128,11 @@ const patternFill = computed(() => `url(#${patternId})`); } .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 7889299..38c1a2e 100644 --- a/src/components/flow/nodes/common/VectorNodeModel.ts +++ b/src/components/flow/nodes/common/VectorNodeModel.ts @@ -33,25 +33,9 @@ class VectorNodeModel extends HtmlNodeModel { resize(deltaX: number, deltaY: number) { const result = super.resize?.(deltaX, deltaY); - 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); - } + // 持久化宽高到 properties + this.setProperty('width', this.width); + this.setProperty('height', this.height); return result; } diff --git a/src/components/flow/nodes/common/vectorNodeSync.ts b/src/components/flow/nodes/common/vectorNodeSync.ts deleted file mode 100644 index 1f150e5..0000000 --- a/src/components/flow/nodes/common/vectorNodeSync.ts +++ /dev/null @@ -1,123 +0,0 @@ -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; - } - }; -} From 92e482e854ec76d494cc8bfc92eba16df3a0f149 Mon Sep 17 00:00:00 2001 From: rookie4show Date: Sat, 28 Feb 2026 00:30:24 +0800 Subject: [PATCH 11/12] fix(deploy): build web app for subpath deployment - workflows use build:app instead of lib build - inject VITE_APP_BASE_URL for prod/dev paths - make vite base configurable via env - update acceptance status for wiki compatibility --- .github/workflows/deploy.yml | 4 ++-- .github/workflows/deploy_dev.yml | 4 ++-- docs/test/acceptance.md | 16 ++++++++-------- vite.config.js | 7 +++++++ 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 00f9c9d..8bfdb32 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,10 +17,10 @@ jobs: with: node-version: '22' - - name: Install dependencies and build + - name: Install dependencies and build web app run: | npm install - npm run build + VITE_APP_BASE_URL=/yys-editor/ npm run build:app - name: Deploy to temporary directory uses: appleboy/scp-action@v0.1.6 diff --git a/.github/workflows/deploy_dev.yml b/.github/workflows/deploy_dev.yml index 2d4d9a2..cb9f686 100644 --- a/.github/workflows/deploy_dev.yml +++ b/.github/workflows/deploy_dev.yml @@ -18,10 +18,10 @@ jobs: with: node-version: '22' - - name: Install dependencies and build + - name: Install dependencies and build web app run: | npm install - npm run build + VITE_APP_BASE_URL=/yys-editor-dev/ npm run build:app - name: Deploy to temporary directory uses: appleboy/scp-action@v0.1.6 diff --git a/docs/test/acceptance.md b/docs/test/acceptance.md index bc76fe8..6bf754d 100644 --- a/docs/test/acceptance.md +++ b/docs/test/acceptance.md @@ -193,18 +193,18 @@ - [x] 用户素材删除与持久化通过(删除后刷新不复活)。 - [ ] 缺失资产降级策略通过(不阻断导出/渲染)。 - [x] Dynamic Group 分组基础行为通过(分组信息写入 `meta.groupId`,复制分组会携带组内节点)。 -- [ ] 分组规则静态检查通过(冲突与供火提示正确且可实时更新)。 -- [ ] 规则管理通过(规则列表表格化、弹窗编辑、导入导出可用)。 +- [x] 分组规则静态检查通过(冲突与供火提示正确且可实时更新)。 +- [x] 规则管理通过(规则列表表格化、弹窗编辑、导入导出可用)。 - [ ] 矢量节点快速缩放性能回归通过(无明显卡顿/卡死)。 -- [ ] 导出到 wiki 数据兼容通过(wiki 侧可 normalize 与预览)。 +- [x] 导出到 wiki 数据兼容通过(wiki 侧可 normalize 与预览)。 - [ ] 跨项目素材互通通过(同 origin 可复用素材,跨 origin 不互通)。 - [ ] 跨项目规则互通方案确认(共享配置源定义、两侧读取一致)。 - [x] 导出图片时隐藏 Dynamic Group 通过(导出前隐藏,导出后恢复)。 当前状态(2026-02-27): -- 已通过:5 项(基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、导出图片时隐藏 Dynamic Group)。 +- 已通过:8 项(基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、分组规则静态检查、规则管理、导出到 wiki 数据兼容、导出图片时隐藏 Dynamic Group)。 - 部分通过:1 项(跨项目规则互通方案确认)。 -- 未通过/待验证:7 项(其余项待完整手测或跨仓联调)。 +- 未通过/待验证:4 项(其余项待完整手测或跨仓联调)。 逐项状态: - 基础启动与构建:已通过 @@ -213,10 +213,10 @@ - 用户素材删除与持久化:已通过 - 缺失资产降级策略:未通过(待手测) - Dynamic Group 分组基础行为:已通过 -- 分组规则静态检查:未通过(待手测) -- 规则管理(表格化/导入导出):未通过(待手测) +- 分组规则静态检查:已通过 +- 规则管理(表格化/导入导出):已通过 - 矢量节点快速缩放性能回归:未通过(待手测) -- 导出到 wiki 数据兼容:未通过(待跨仓联测) +- 导出到 wiki 数据兼容:已通过(2026-02-27 联测通过) - 跨项目素材互通:未通过(待同 origin 联测) - 跨项目规则互通方案确认:部分通过(yys-editor 已落地,wiki 待读取同源配置) - 导出图片时隐藏 Dynamic Group:已通过 diff --git a/vite.config.js b/vite.config.js index 5c45e1d..aba131c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,8 +3,15 @@ import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +const normalizeBase = (value) => { + if (!value || value === '/') return '/' + const withLeadingSlash = value.startsWith('/') ? value : `/${value}` + return withLeadingSlash.endsWith('/') ? withLeadingSlash : `${withLeadingSlash}/` +} + // https://vitejs.dev/config/ export default defineConfig({ + base: normalizeBase(process.env.VITE_APP_BASE_URL || '/'), plugins: [ vue(), ], From 1b7596954a51080228af18bb35ceb956238040e9 Mon Sep 17 00:00:00 2001 From: rookie4show Date: Sat, 28 Feb 2026 00:38:53 +0800 Subject: [PATCH 12/12] fix(flow): stabilize preview import and dynamic-group rendering - hide dynamic-group containers in preview graph sanitization - keep dynamic-group plugin registered in render-only/interactive presets - refresh canvas immediately after JSON import --- src/YysEditorEmbed.vue | 18 ++++++++++++++---- src/components/Toolbar.vue | 2 +- src/flowRuntime.ts | 8 ++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/YysEditorEmbed.vue b/src/YysEditorEmbed.vue index 98497df..7a77580 100644 --- a/src/YysEditorEmbed.vue +++ b/src/YysEditorEmbed.vue @@ -111,12 +111,15 @@ const sanitizeLabelProperty = (properties: unknown): Record | undef return nextProperties } -const sanitizeGraphData = (input?: GraphData | null): GraphData => { +const sanitizeGraphData = ( + input?: GraphData | null, + options?: { hideDynamicGroups?: boolean } +): GraphData => { if (!input || !Array.isArray(input.nodes) || !Array.isArray(input.edges)) { return { nodes: [], edges: [] } } - const nodes = input.nodes + const rawNodes = input.nodes .filter((node): node is NodeData => isPlainObject(node)) .map((node) => { const nextNode: NodeData = { ...node } @@ -127,6 +130,12 @@ const sanitizeGraphData = (input?: GraphData | null): GraphData => { return nextNode }) + const hiddenDynamicGroup = options?.hideDynamicGroups === true + const nodes = hiddenDynamicGroup + ? rawNodes.filter((node) => node.type !== 'dynamic-group') + : rawNodes + const nodeIdSet = new Set(nodes.map((node) => node.id)) + const edges = input.edges .filter((edge): edge is EdgeData => isPlainObject(edge)) .map((edge) => { @@ -137,6 +146,7 @@ const sanitizeGraphData = (input?: GraphData | null): GraphData => { } return nextEdge }) + .filter((edge) => !hiddenDynamicGroup || (nodeIdSet.has(edge.sourceNodeId) && nodeIdSet.has(edge.targetNodeId))) return { nodes, edges } } @@ -328,7 +338,7 @@ const initPreviewMode = () => { // 渲染数据 if (props.data) { - previewLf.value.render(sanitizeGraphData(props.data)) + previewLf.value.render(sanitizeGraphData(props.data, { hideDynamicGroups: true })) } } @@ -362,7 +372,7 @@ const getGraphData = (): GraphData | null => { } const setGraphData = (data: GraphData) => { - const safeData = sanitizeGraphData(data) + const safeData = sanitizeGraphData(data, { hideDynamicGroups: props.mode === 'preview' }) if (props.mode === 'edit') { const lfInstance = getLogicFlowInstance() if (lfInstance) { diff --git a/src/components/Toolbar.vue b/src/components/Toolbar.vue index 244c727..0597202 100644 --- a/src/components/Toolbar.vue +++ b/src/components/Toolbar.vue @@ -928,7 +928,7 @@ const handleImport = () => { const target = e.target as FileReader; const data = JSON.parse(target.result as string); filesStore.importData(data); - // refreshLogicFlowCanvas('LogicFlow 画布已重新渲染(导入数据)'); + refreshLogicFlowCanvas('LogicFlow 画布已重新渲染(导入数据)'); } catch (error) { console.error('Failed to import file', error); showMessage('error', '文件格式错误'); diff --git a/src/flowRuntime.ts b/src/flowRuntime.ts index 5af7027..bd3d221 100644 --- a/src/flowRuntime.ts +++ b/src/flowRuntime.ts @@ -1,5 +1,5 @@ import type LogicFlow from '@logicflow/core' -import { Menu, Label, Snapshot, SelectionSelect, MiniMap, Control } from '@logicflow/extension' +import { Menu, Label, Snapshot, SelectionSelect, MiniMap, Control, DynamicGroup } from '@logicflow/extension' import { register } from '@logicflow/vue-node-registry' import ImageNode from './components/flow/nodes/common/ImageNode.vue' @@ -27,8 +27,9 @@ const DEFAULT_FLOW_NODES: FlowNodeRegistration[] = [ ] const FLOW_PLUGIN_PRESETS: Record = { - 'render-only': [Snapshot], - interactive: [Menu, Label, Snapshot, SelectionSelect, MiniMap, Control] + // 预览模式也需要 DynamicGroup,避免包含 dynamic-group 节点的图在只读渲染时报错 + 'render-only': [DynamicGroup, Snapshot], + interactive: [DynamicGroup, Menu, Label, Snapshot, SelectionSelect, MiniMap, Control] } export function getFlowPluginsByCapability(capability: FlowCapabilityLevel): FlowPlugin[] { @@ -60,4 +61,3 @@ export function registerFlowNodes(lfInstance: LogicFlow, nodes?: FlowNodeRegistr const registrations = resolveFlowNodes(nodes) registrations.forEach((registration) => register(registration, lfInstance)) } -