diff --git a/docs/test/acceptance.md b/docs/test/acceptance.md index 7135bca..2888656 100644 --- a/docs/test/acceptance.md +++ b/docs/test/acceptance.md @@ -72,10 +72,29 @@ 步骤: - 在画布上创建多个节点。 - 创建动态分组(Dynamic Group),将节点加入/移出分组。 +- 仅选中 Dynamic Group 执行 `Ctrl+C` / `Ctrl+V`,观察粘贴结果。 预期: - 分组操作成功。 - 分组信息能写入节点 meta(用于规则检查)。 +- 复制分组时会自动携带组内节点(官方行为),新旧分组互不串联拖拽。 + +排查点: +- `src/components/flow/FlowEditor.vue` 使用 LogicFlow 默认快捷键复制粘贴(`shortcut.js -> lf.addElements`)。 + +## 11. 导出图片时隐藏 Dynamic Group(视觉优化) + +步骤: +- 在画布创建 Dynamic Group,并放入若干子节点。 +- 点击“准备截图”并下载图片。 + +预期: +- 导出的图片中不显示 Dynamic Group 容器边框。 +- 组内节点与其他节点正常显示。 +- 导出完成后,编辑器画布中的 Dynamic Group 仍可见(只在导出瞬间隐藏)。 + +排查点: +- `src/components/Toolbar.vue` 的 `captureLogicFlowSnapshot` 及临时隐藏/恢复逻辑。 ## 6. 规则静态检查(分组内) @@ -149,30 +168,32 @@ - [x] 基础启动与构建通过(`npm install` / `npm run dev` / `npm run build`)。 - [ ] 资产基路径与引用一致性通过(`/assets/...` 在宿主子路径下可正确解析)。 -- [ ] 用户素材上传与使用通过(我的素材可新增并可用于节点)。 -- [ ] 用户素材删除与持久化通过(删除后刷新不复活)。 +- [x] 用户素材上传与使用通过(我的素材可新增并可用于节点)。 +- [x] 用户素材删除与持久化通过(删除后刷新不复活)。 - [ ] 缺失资产降级策略通过(不阻断导出/渲染)。 -- [ ] Dynamic Group 分组基础行为通过(分组信息写入 `meta.groupId`)。 +- [x] Dynamic Group 分组基础行为通过(分组信息写入 `meta.groupId`,复制分组会携带组内节点)。 - [ ] 分组规则静态检查通过(冲突与供火提示正确且可实时更新)。 - [ ] 矢量节点快速缩放性能回归通过(无明显卡顿/卡死)。 - [ ] 导出到 wiki 数据兼容通过(wiki 侧可 normalize 与预览)。 - [ ] 跨项目素材互通通过(同 origin 可复用素材,跨 origin 不互通)。 - [ ] 跨项目规则互通方案确认(共享配置源定义、两侧读取一致)。 +- [x] 导出图片时隐藏 Dynamic Group 通过(导出前隐藏,导出后恢复)。 当前状态(2026-02-27): -- 已通过:1 项(基础启动与构建)。 -- 部分通过:3 项(用户素材上传与使用、用户素材删除与持久化、跨项目规则互通方案确认)。 -- 未通过/待验证:7 项(其余项待完整手测或跨仓联调)。 +- 已通过:5 项(基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、导出图片时隐藏 Dynamic Group)。 +- 部分通过:1 项(跨项目规则互通方案确认)。 +- 未通过/待验证:6 项(其余项待完整手测或跨仓联调)。 逐项状态: - 基础启动与构建:已通过 - 资产基路径与引用一致性:未通过(待手测) -- 用户素材上传与使用:部分通过(实现已就绪,待手测) -- 用户素材删除与持久化:部分通过(实现已修复,待手测) +- 用户素材上传与使用:已通过 +- 用户素材删除与持久化:已通过 - 缺失资产降级策略:未通过(待手测) -- Dynamic Group 分组基础行为:未通过(待手测) +- Dynamic Group 分组基础行为:已通过 - 分组规则静态检查:未通过(待手测) - 矢量节点快速缩放性能回归:未通过(待手测) - 导出到 wiki 数据兼容:未通过(待跨仓联测) - 跨项目素材互通:未通过(待同 origin 联测) - 跨项目规则互通方案确认:部分通过(yys-editor 已落地,wiki 待读取同源配置) +- 导出图片时隐藏 Dynamic Group:已通过 diff --git a/src/components/flow/ComponentsPanel.vue b/src/components/flow/ComponentsPanel.vue index 0f23154..2b6abfc 100644 --- a/src/components/flow/ComponentsPanel.vue +++ b/src/components/flow/ComponentsPanel.vue @@ -36,6 +36,13 @@ const componentGroups = [ description: '可折叠的动态分组容器', data: { children: [], + groupMeta: { + version: 1, + groupKind: 'team', + groupName: '', + ruleEnabled: true, + ruleScope: ['shikigami-yuhun', 'shikigami-shikigami'] + }, collapsible: true, isCollapsed: false, width: 420, diff --git a/src/components/flow/FlowEditor.vue b/src/components/flow/FlowEditor.vue index 92c298f..2a801a7 100644 --- a/src/components/flow/FlowEditor.vue +++ b/src/components/flow/FlowEditor.vue @@ -55,17 +55,43 @@ -
-
规则检查
-
-
- [{{ warning.groupId }}] {{ warning.message }} -
-
-
+
+
+ +
+
+
+ 规则告警 + {{ groupRuleWarnings.length }} 条 +
+
+ 当前没有告警 +
+
+
+
{{ warning.severity.toUpperCase() }}
+
+
{{ warning.message }}
+
{{ warning.groupName || warning.groupId }} · {{ warning.ruleId }}
+
+
+
+
+
@@ -137,6 +163,7 @@ const { showMessage } = useGlobalMessage(); const selectedNode = ref(null); const groupRuleWarnings = ref([]); const flowControlsCollapsed = ref(true); +const problemsPanelOpen = ref(false); let containerResizeObserver: ResizeObserver | null = null; let groupRuleValidationTimer: ReturnType | null = null; let unsubscribeSharedGroupRules: (() => void) | null = null; @@ -701,6 +728,31 @@ function scheduleGroupRuleValidation(delay = 120) { }, delay); } +function locateProblemNode(warning: GroupRuleWarning) { + const lfInstance = lf.value as any; + if (!lfInstance) return; + + const candidateIds = [...(warning.nodeIds || []), warning.groupId].filter((id) => !!id); + const targetId = candidateIds.find((id) => !!lfInstance.getNodeModelById(id)); + if (!targetId) { + showMessage('warning', '未找到告警对应节点,可能已被删除'); + return; + } + + try { + lfInstance.clearSelectElements?.(); + lfInstance.selectElementById?.(targetId, false, false); + lfInstance.focusOn?.(targetId); + const nodeData = lfInstance.getNodeDataById?.(targetId); + if (nodeData) { + selectedNode.value = nodeData; + } + } catch (error) { + console.error('定位告警节点失败:', error); + showMessage('error', '定位节点失败'); + } +} + function applySelectionSelect(enabled: boolean) { const lfInstance = lf.value as any; if (!lfInstance) return; @@ -1256,23 +1308,6 @@ onBeforeUnmount(() => { .control-row:last-child { margin-bottom: 0; } -.rule-row { - align-items: flex-start; -} -.rule-list { - display: flex; - flex-direction: column; - gap: 4px; - max-width: 360px; -} -.rule-item { - color: #9a3412; - background: #fff7ed; - border: 1px solid #fed7aa; - border-radius: 6px; - padding: 4px 6px; - line-height: 1.4; -} .control-label { font-weight: 600; color: #303133; @@ -1307,6 +1342,126 @@ onBeforeUnmount(() => { .control-hint { color: #909399; } +.problems-dock { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 11; + pointer-events: none; +} +.problems-dock-bar { + display: flex; + align-items: center; + gap: 8px; + height: 32px; + padding: 0 10px; + background: rgba(250, 250, 250, 0.98); + border-top: 1px solid #dcdfe6; + pointer-events: auto; +} +.problems-tab { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid #dcdfe6; + background: #fff; + border-radius: 4px; + padding: 2px 10px; + height: 24px; + font-size: 12px; + cursor: pointer; + color: #303133; +} +.problems-tab:hover { + background: #f5f7fa; +} +.problems-badge { + min-width: 18px; + height: 18px; + border-radius: 10px; + background: #fde68a; + color: #92400e; + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-align: center; + padding: 0 4px; + box-sizing: border-box; +} +.problems-panel { + height: 220px; + background: rgba(255, 255, 255, 0.98); + border-top: 1px solid #dcdfe6; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + pointer-events: auto; +} +.problems-header { + height: 32px; + padding: 0 12px; + border-bottom: 1px solid #ebeef5; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + color: #606266; +} +.problems-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: #909399; + font-size: 13px; +} +.problems-list { + flex: 1; + overflow-y: auto; +} +.problem-item { + display: flex; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid #f2f3f5; + cursor: pointer; +} +.problem-item:hover { + background: #f8fafc; +} +.problem-item:focus { + outline: none; + box-shadow: inset 0 0 0 1px #93c5fd; + background: #eff6ff; +} +.problem-severity { + width: 56px; + height: 20px; + border-radius: 10px; + background: #fff7ed; + border: 1px solid #fed7aa; + color: #9a3412; + font-size: 11px; + line-height: 18px; + text-align: center; + flex-shrink: 0; +} +.problem-content { + min-width: 0; +} +.problem-message { + color: #303133; + font-size: 13px; + line-height: 1.4; +} +.problem-meta { + margin-top: 2px; + color: #909399; + font-size: 12px; + line-height: 1.3; + word-break: break-all; +} .context-menu { position: fixed; background: white; diff --git a/src/components/flow/PropertyPanel.vue b/src/components/flow/PropertyPanel.vue index 3ee901d..3751179 100644 --- a/src/components/flow/PropertyPanel.vue +++ b/src/components/flow/PropertyPanel.vue @@ -6,6 +6,7 @@ import TextPanel from './panels/TextPanel.vue'; import StylePanel from './panels/StylePanel.vue'; import AssetSelectorPanel from './panels/AssetSelectorPanel.vue'; import VectorPanel from './panels/VectorPanel.vue'; +import DynamicGroupPanel from './panels/DynamicGroupPanel.vue'; import { ASSET_LIBRARIES } from '@/types/nodeTypes'; import { getLogicFlowInstance } from '@/ts/useLogicFlow'; @@ -35,7 +36,8 @@ const panelMap: Record = { imageNode: ImagePanel, textNode: TextPanel, assetSelector: AssetSelectorPanel, - vectorNode: VectorPanel + vectorNode: VectorPanel, + 'dynamic-group': DynamicGroupPanel }; const panelComponent = computed(() => panelMap[nodeType.value] || null); diff --git a/src/components/flow/panels/AssetSelectorPanel.vue b/src/components/flow/panels/AssetSelectorPanel.vue index a79b104..d4c4843 100644 --- a/src/components/flow/panels/AssetSelectorPanel.vue +++ b/src/components/flow/panels/AssetSelectorPanel.vue @@ -6,6 +6,7 @@ import { SELECTOR_PRESETS } from '@/configs/selectorPresets'; import type { SelectorConfig } from '@/types/selector'; import { resolveAssetUrl, resolveAssetUrlsInDataSource } from '@/utils/assetUrl'; import { deleteCustomAsset, listCustomAssets } from '@/utils/customAssets'; +import { normalizeSelectedAssetRecord } from '@/utils/graphSchema'; const props = defineProps<{ node: any; @@ -34,12 +35,13 @@ const handleOpenSelector = () => { const imageField = preset.itemRender.imageField; const selectedAsset = node.properties?.selectedAsset || null; - const normalizedSelectedAsset = selectedAsset && typeof selectedAsset === 'object' + const normalizedSelectedAssetRecord = normalizeSelectedAssetRecord(selectedAsset, library); + const normalizedSelectedAsset = normalizedSelectedAssetRecord ? { - ...selectedAsset, - [imageField]: resolveAssetUrl(selectedAsset?.[imageField]) + ...normalizedSelectedAssetRecord, + [imageField]: resolveAssetUrl((selectedAsset as any)?.[imageField]) } - : selectedAsset; + : null; const customAssets = listCustomAssets(library); const mergedDataSource = [ @@ -67,12 +69,13 @@ const handleOpenSelector = () => { }; openGenericSelector(config, (selectedItem) => { - const normalizedSelected = selectedItem && typeof selectedItem === 'object' + const normalizedSelectedRecord = normalizeSelectedAssetRecord(selectedItem, library); + const normalizedSelected = normalizedSelectedRecord ? { - ...selectedItem, - [imageField]: resolveAssetUrl(selectedItem?.[imageField]) + ...normalizedSelectedRecord, + [imageField]: resolveAssetUrl((selectedItem as any)?.[imageField]) } - : selectedItem; + : null; lf.setProperties(node.id, { ...node.properties, diff --git a/src/components/flow/panels/DynamicGroupPanel.vue b/src/components/flow/panels/DynamicGroupPanel.vue new file mode 100644 index 0000000..9bd842e --- /dev/null +++ b/src/components/flow/panels/DynamicGroupPanel.vue @@ -0,0 +1,138 @@ + + + + + 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: '规则提示:当前队伍未检测到鬼火式神,建议补充供火位。' }) } }