From ab16c87c5da29ac7d638a905c8af323c70c67971 Mon Sep 17 00:00:00 2001 From: menghao Date: Tue, 4 Aug 2026 11:20:58 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E8=B5=84=E6=BA=90=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E8=A7=86=E5=9B=BE=E8=BF=9E=E7=BA=BF=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 限制任务虚线仅在同资源类型分区内渲染 调亮精确资源引用橙线并保持持续展示 取消资源点击后的依赖高亮与无关连线弱化 补充依赖图单元测试与工作台集成测试 同步更新项目开发工作台 PRD --- apps/ai-game-creator-shell/src/styles.css | 36 +-- .../ResourceDependencyOverlay.tsx | 284 ++++++++++-------- .../src/view/project-development/index.tsx | 26 +- .../tests/ResourceDependencyOverlay.test.ts | 113 ++++--- .../appSurface/project-development.suite.ts | 23 +- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 18 +- 6 files changed, 269 insertions(+), 231 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 78d72898d..d80e496b0 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3905,15 +3905,12 @@ iframe.preview-frame { stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; - transition: - opacity 140ms ease, - stroke-width 140ms ease; } .game-resource-dependency-edge--reference { - stroke: #d96f3d; - stroke-width: 2.25px; - opacity: 0.94; + stroke: #f28a52; + stroke-width: 2.4px; + opacity: 1; } .game-resource-dependency-edge--task path { @@ -3931,35 +3928,13 @@ iframe.preview-frame { opacity: 0.72; } -.game-resource-dependency-edge.is-highlighted { - opacity: 1; -} - -.game-resource-dependency-edge--reference.is-highlighted { - stroke-width: 3px; -} - -.game-resource-dependency-edge--task.is-highlighted path { - opacity: 1; - stroke-width: 2px; -} - -.game-resource-dependency-edge--task.is-highlighted - .game-resource-dependency-trunk { - stroke-width: 2.4px; -} - -.game-resource-dependency-edge.is-dimmed { - opacity: 0.14; -} - .game-resource-dependency-edge.is-cyclic, .game-resource-dependency-edge.is-cyclic path { stroke-dashoffset: 4; } .game-resource-dependency-marker--reference path { - fill: #d96f3d; + fill: #f28a52; stroke-linejoin: round; } @@ -4056,9 +4031,6 @@ iframe.preview-frame { box-shadow: 0 8px 22px rgb(195 105 62 / 15%); } -.game-resource-card.is-relation-upstream, -.game-resource-card.is-relation-downstream, -.game-resource-card.is-relation-both, .game-resource-card.is-relation-version-binding { border-color: #d87342; box-shadow: diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx index 19ccd9bbb..38a51a13c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -19,7 +19,6 @@ import { } from './resourceCanvasLayoutModel'; import { type ProjectResourceGraph, - projectResourceGraphNeighbors, type ProjectResourceReferenceEdge, type ProjectResourceTaskFlow, } from './resourceDependencyGraphModel'; @@ -44,7 +43,6 @@ export type ResourceDependencyOverlayProps = { graph: ProjectResourceGraph; positions: readonly ProjectResourceCanvasPosition[]; visibleResourceIds: ReadonlySet; - selectedResourceId: string | null; }; export type ResourceDependencyOverlayHandle = { @@ -58,21 +56,27 @@ type TaskFlowPathRefs = { trunk: SVGPathElement | null; }; +type TaskFlowSectionGeometry = NonNullable< + ReturnType +> & { + section: ProjectResourceCanvasSection; +}; + const SECTION_SELECTOR = '[data-resource-section-plane]'; const TASK_FLOW_HUB_GAP = 20; const CONNECTION_MAX_HANDLE = 180; const TASK_FLOW_BRANCH_MAX_HANDLE = 96; const SELF_REFERENCE_LOOP_WIDTH = 56; const SELF_REFERENCE_LOOP_ANCHOR_OFFSET = 18; +const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [ + 'document', + 'version', + 'art', + 'audio', +]; function pointsEqual(left: SectionOrigins, right: SectionOrigins) { - const sections: ProjectResourceCanvasSection[] = [ - 'document', - 'version', - 'art', - 'audio', - ]; - return sections.every( + return RESOURCE_SECTIONS.every( (section) => left[section]?.x === right[section]?.x && left[section]?.y === right[section]?.y, @@ -219,20 +223,49 @@ function taskFlowGeometry( return { sourceAnchors, targetAnchors, sourceHub, targetHub }; } +function taskFlowSectionGeometries( + flow: ProjectResourceTaskFlow, + rectByResourceId: RectLookup, + sectionByResourceId: ReadonlyMap, +): TaskFlowSectionGeometry[] { + return RESOURCE_SECTIONS.flatMap((section) => { + const geometry = taskFlowGeometry( + { + ...flow, + sourceResourceIds: flow.sourceResourceIds.filter( + (resourceId) => sectionByResourceId.get(resourceId) === section, + ), + targetResourceIds: flow.targetResourceIds.filter( + (resourceId) => sectionByResourceId.get(resourceId) === section, + ), + }, + rectByResourceId, + ); + return geometry ? [{ ...geometry, section }] : []; + }); +} + +function taskFlowRenderKey( + flowId: string, + section: ProjectResourceCanvasSection, +) { + return `${flowId}\n${section}`; +} + export const ResourceDependencyOverlay = forwardRef< ResourceDependencyOverlayHandle, ResourceDependencyOverlayProps >(function ResourceDependencyOverlay( - { graph, positions, visibleResourceIds, selectedResourceId }, + { graph, positions, visibleResourceIds }, ref, ) { const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, ''); const overlayRef = useRef(null); const referencePathRefs = useRef(new Map()); const taskFlowPathRefs = useRef(new Map()); - const activeDragPreviewRef = useRef< - (Point & { resourceId: string }) | null - >(null); + const activeDragPreviewRef = useRef<(Point & { resourceId: string }) | null>( + null, + ); const graphRef = useRef(graph); const positionByResourceIdRef = useRef( new Map(positions.map((position) => [position.resourceId, position])), @@ -316,37 +349,48 @@ export const ResourceDependencyOverlay = forwardRef< } return result; }, [graph.resourceIds, positions, sectionOrigins, visibleResourceIds]); + const sectionByResourceId = useMemo( + () => + new Map( + positions.map((position) => [position.resourceId, position.section]), + ), + [positions], + ); graphRef.current = graph; positionByResourceIdRef.current = new Map( positions.map((position) => [position.resourceId, position]), ); rectByResourceIdRef.current = rectByResourceId; - const neighbors = useMemo( - () => projectResourceGraphNeighbors(graph, selectedResourceId), - [graph, selectedResourceId], - ); - const selected = Boolean( - selectedResourceId && graph.resourceIds.has(selectedResourceId), + const taskFlowRenderEntries = useMemo( + () => + graph.taskFlows.flatMap((flow) => + taskFlowSectionGeometries( + flow, + rectByResourceId, + sectionByResourceId, + ).map((geometry) => ({ + flow, + geometry, + renderKey: taskFlowRenderKey(flow.id, geometry.section), + })), + ), + [graph.taskFlows, rectByResourceId, sectionByResourceId], ); const renderTaskFlows = useMemo( () => - graph.taskFlows.map((flow) => { - const geometry = taskFlowGeometry(flow, rectByResourceId); - if (!geometry) { - return null; - } - const highlighted = neighbors.connectedEdgeIds.has(flow.id); + taskFlowRenderEntries.map(({ flow, geometry, renderKey }) => { const className = `game-resource-dependency-edge game-resource-dependency-edge--task${ - highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : '' - }${flow.cyclic ? ' is-cyclic' : ''}`; + flow.cyclic ? ' is-cyclic' : '' + }`; return ( ( { - let refs = taskFlowPathRefs.current.get(flow.id); + let refs = taskFlowPathRefs.current.get(renderKey); if (!refs) { refs = { sourceBranches: new Map(), targetBranches: new Map(), trunk: null, }; - taskFlowPathRefs.current.set(flow.id, refs); + taskFlowPathRefs.current.set(renderKey, refs); } if (node) { refs.sourceBranches.set(resourceId, node); @@ -381,14 +425,14 @@ export const ResourceDependencyOverlay = forwardRef< ))} { - let refs = taskFlowPathRefs.current.get(flow.id); + let refs = taskFlowPathRefs.current.get(renderKey); if (!refs) { refs = { sourceBranches: new Map(), targetBranches: new Map(), trunk: null, }; - taskFlowPathRefs.current.set(flow.id, refs); + taskFlowPathRefs.current.set(renderKey, refs); } refs.trunk = node; }} @@ -398,14 +442,14 @@ export const ResourceDependencyOverlay = forwardRef< {geometry.targetAnchors.map(({ resourceId, point }) => ( { - let refs = taskFlowPathRefs.current.get(flow.id); + let refs = taskFlowPathRefs.current.get(renderKey); if (!refs) { refs = { sourceBranches: new Map(), targetBranches: new Map(), trunk: null, }; - taskFlowPathRefs.current.set(flow.id, refs); + taskFlowPathRefs.current.set(renderKey, refs); } if (node) { refs.targetBranches.set(resourceId, node); @@ -424,13 +468,7 @@ export const ResourceDependencyOverlay = forwardRef< ); }), - [ - graph.taskFlows, - markerPrefix, - neighbors.connectedEdgeIds, - rectByResourceId, - selected, - ], + [markerPrefix, taskFlowRenderEntries], ); const renderReferenceEdges = useMemo( @@ -440,10 +478,9 @@ export const ResourceDependencyOverlay = forwardRef< if (!geometry) { return null; } - const highlighted = neighbors.connectedEdgeIds.has(edge.id); const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${ - highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : '' - }${edge.cyclic ? ' is-cyclic' : ''}`; + edge.cyclic ? ' is-cyclic' : '' + }`; return ( { @@ -468,97 +505,106 @@ export const ResourceDependencyOverlay = forwardRef< ); }), - [ - graph.referenceEdges, - markerPrefix, - neighbors.connectedEdgeIds, - rectByResourceId, - selected, - ], + [graph.referenceEdges, markerPrefix, rectByResourceId], ); useLayoutEffect(() => { - const activeFlowIds = new Set(graph.taskFlows.map((flow) => flow.id)); - for (const flowId of taskFlowPathRefs.current.keys()) { - if (!activeFlowIds.has(flowId)) { - taskFlowPathRefs.current.delete(flowId); + const activeRenderKeys = new Set( + taskFlowRenderEntries.map((entry) => entry.renderKey), + ); + for (const renderKey of taskFlowPathRefs.current.keys()) { + if (!activeRenderKeys.has(renderKey)) { + taskFlowPathRefs.current.delete(renderKey); } } - }, [graph.taskFlows]); + }, [taskFlowRenderEntries]); const updateAffectedGeometry = useCallback( ( affectedResourceIds: ReadonlySet, dragPreview: (Point & { resourceId: string }) | null, ) => { - const currentGraph = graphRef.current; - const currentRects = rectByResourceIdRef.current; - const dragBasePosition = dragPreview - ? positionByResourceIdRef.current.get(dragPreview.resourceId) - : undefined; - const rectLookup = { - get(resourceId: string) { - const rect = currentRects.get(resourceId); - if (!rect) { - return undefined; + const currentGraph = graphRef.current; + const currentRects = rectByResourceIdRef.current; + const dragBasePosition = dragPreview + ? positionByResourceIdRef.current.get(dragPreview.resourceId) + : undefined; + const rectLookup = { + get(resourceId: string) { + const rect = currentRects.get(resourceId); + if (!rect) { + return undefined; + } + return dragPreview?.resourceId === resourceId + ? { + ...rect, + x: rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x, + y: rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y, + } + : rect; + }, + }; + const affectedEdgeIds = new Set(); + for (const resourceId of affectedResourceIds) { + const index = currentGraph.connectionIndex.get(resourceId); + index?.referenceEdgeIds.forEach((edgeId) => + affectedEdgeIds.add(edgeId), + ); + index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId)); + } + for (const edgeId of affectedEdgeIds) { + const referenceEdge = currentGraph.referenceEdgeById.get(edgeId); + if (referenceEdge) { + const geometry = referenceGeometry(referenceEdge, rectLookup); + const path = referencePathRefs.current.get(edgeId); + if (geometry && path) { + path.setAttribute('d', geometry.path); + } + continue; } - return dragPreview?.resourceId === resourceId - ? { - ...rect, - x: rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x, - y: rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y, - } - : rect; - }, - }; - const affectedEdgeIds = new Set(); - for (const resourceId of affectedResourceIds) { - const index = currentGraph.connectionIndex.get(resourceId); - index?.referenceEdgeIds.forEach((edgeId) => - affectedEdgeIds.add(edgeId), - ); - index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId)); - } - for (const edgeId of affectedEdgeIds) { - const referenceEdge = currentGraph.referenceEdgeById.get(edgeId); - if (referenceEdge) { - const geometry = referenceGeometry(referenceEdge, rectLookup); - const path = referencePathRefs.current.get(edgeId); - if (geometry && path) { - path.setAttribute('d', geometry.path); + const flow = currentGraph.taskFlowById.get(edgeId); + if (!flow) { + continue; } - continue; - } - const flow = currentGraph.taskFlowById.get(edgeId); - const paths = taskFlowPathRefs.current.get(edgeId); - if (!flow || !paths) { - continue; - } - const geometry = taskFlowGeometry(flow, rectLookup); - if (!geometry) { - continue; - } - geometry.sourceAnchors.forEach(({ resourceId, point }) => { - paths.sourceBranches - .get(resourceId) - ?.setAttribute( - 'd', - taskFlowBranchPath(point, geometry.sourceHub), + const sectionByResourceId = new Map( + Array.from( + positionByResourceIdRef.current.values(), + (position) => [position.resourceId, position.section] as const, + ), + ); + for (const geometry of taskFlowSectionGeometries( + flow, + rectLookup, + sectionByResourceId, + )) { + const paths = taskFlowPathRefs.current.get( + taskFlowRenderKey(flow.id, geometry.section), ); - }); - paths.trunk?.setAttribute( - 'd', - connectionPath(geometry.sourceHub, geometry.targetHub), - ); - geometry.targetAnchors.forEach(({ resourceId, point }) => { - paths.targetBranches - .get(resourceId) - ?.setAttribute( + if (!paths) { + continue; + } + geometry.sourceAnchors.forEach(({ resourceId, point }) => { + paths.sourceBranches + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(point, geometry.sourceHub), + ); + }); + paths.trunk?.setAttribute( 'd', - taskFlowBranchPath(geometry.targetHub, point), + connectionPath(geometry.sourceHub, geometry.targetHub), ); - }); - } + geometry.targetAnchors.forEach(({ resourceId, point }) => { + paths.targetBranches + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(geometry.targetHub, point), + ); + }); + } + } }, [], ); diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 8d7663508..11b813a51 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -45,7 +45,6 @@ import { EMPTY_PROJECT_RESOURCE_GRAPH, normalizeProjectResourceGraph, type ProjectResourceGraph, - projectResourceGraphNeighbors, type ProjectResourceGraphNodeInput, type ProjectResourceGraphReadModel, } from './resourceDependencyGraphModel'; @@ -362,7 +361,7 @@ const ResourceCard = memo(function ResourceCard({ }: { resource: ProjectResource; selected: boolean; - relationState: 'upstream' | 'downstream' | 'both' | 'version-binding' | null; + relationState: 'version-binding' | null; x: number; y: number; onSelect: (resourceId: string) => void; @@ -602,10 +601,6 @@ export default function ProjectDevelopmentView({ ), [resourceLayout.positions], ); - const selectedResourceNeighbors = useMemo( - () => projectResourceGraphNeighbors(resourceGraph, selectedResourceId), - [resourceGraph, selectedResourceId], - ); const selectedVersionBindingResourceIds = useMemo(() => { const selectedVersion = resources.find( (resource) => resource.id === selectedResourceId, @@ -1337,7 +1332,6 @@ export default function ProjectDevelopmentView({ graph={resourceGraph} positions={resourceLayout.positions} visibleResourceIds={visibleResourceIds} - selectedResourceId={selectedResourceId} /> ) : null} {categoryOrder.map((category) => { @@ -1384,28 +1378,12 @@ export default function ProjectDevelopmentView({ if (!position) { return null; } - const upstream = - sortMode === 'dependency' && - selectedResourceNeighbors.upstreamResourceIds.has( - resource.id, - ); - const downstream = - sortMode === 'dependency' && - selectedResourceNeighbors.downstreamResourceIds.has( - resource.id, - ); const relationState = selectedVersionBindingResourceIds.has( resource.id, ) ? 'version-binding' - : upstream && downstream - ? 'both' - : upstream - ? 'upstream' - : downstream - ? 'downstream' - : null; + : null; return ( ; - selectedResourceId?: string | null; overlayRef?: React.Ref; }) { + const sections = Array.from( + new Set(positions.map((position) => position.section)), + ); return React.createElement( 'div', null, - React.createElement('div', { 'data-resource-section-plane': 'art' }), + ...sections.map((section) => + React.createElement('div', { + key: section, + 'data-resource-section-plane': section, + }), + ), React.createElement(ResourceDependencyOverlay, { ref: overlayRef, graph, positions, visibleResourceIds, - selectedResourceId, }), ); } @@ -133,14 +142,12 @@ function overlayView( graph: ProjectResourceGraph, positions: ProjectResourceCanvasPosition[], visibleResourceIds: ReadonlySet, - selectedResourceId: string | null = null, overlayRef?: React.Ref, ) { return React.createElement(OverlayHarness, { graph, positions, visibleResourceIds, - selectedResourceId, overlayRef, }); } @@ -179,6 +186,42 @@ describe('ResourceDependencyOverlay', () => { ).toBe('userSpaceOnUse'); }); + it('omits cross-section task endpoints and keeps same-section task flow groups', async () => { + const graph = graphFixture(); + const positions = [ + position('source:one', 0, 0, 'document'), + position('source:two', 0, 0, 'art'), + position('source:three', 0, 120, 'document'), + position('target:one', 240, 0, 'art'), + position('target:two', 240, 120, 'art'), + position('target:three', 240, 0, 'audio'), + ]; + render( + overlayView( + graph, + positions, + new Set(positions.map((position) => position.resourceId)), + ), + ); + + const overlay = await screen.findByTestId('resource-dependency-overlay'); + const taskFlow = await waitFor(() => { + const flows = overlay.querySelectorAll('[data-edge-kind="task-flow"]'); + expect(flows).toHaveLength(1); + return flows.item(0); + }); + expect(taskFlow.getAttribute('data-resource-section')).toBe('art'); + expect(taskFlow.querySelectorAll('path')).toHaveLength(4); + expect( + Array.from(taskFlow.querySelectorAll('[data-resource-id]')).map((node) => + node.getAttribute('data-resource-id'), + ), + ).toEqual(['source:two', 'target:one', 'target:two']); + expect( + overlay.querySelectorAll('[data-edge-kind="asset-reference"]'), + ).toHaveLength(1); + }); + it('filters hidden endpoints and updates path geometry when positions change', async () => { const graph = graphFixture(); const positions = [ @@ -191,7 +234,6 @@ describe('ResourceDependencyOverlay', () => { graph, positions, new Set(['source:one', 'target:one']), - null, overlayRef, ), ); @@ -252,7 +294,7 @@ describe('ResourceDependencyOverlay', () => { ]; const visible = new Set(['source:one', 'target:one']); const overlayRef = React.createRef(); - render(overlayView(graph, positions, visible, null, overlayRef)); + render(overlayView(graph, positions, visible, overlayRef)); const overlay = await screen.findByTestId('resource-dependency-overlay'); const selector = '[data-edge-kind="asset-reference"]'; await waitFor(() => @@ -321,26 +363,18 @@ describe('ResourceDependencyOverlay', () => { ); }); - it('highlights direct edges and dims unrelated cyclic edges for a selection', async () => { + it('keeps every rendered relationship at its default visual state', async () => { const graph = graphFixture(); const positions = Array.from(graph.resourceIds).map((resourceId, index) => position(resourceId, index * 200, 0), ); - render( - overlayView(graph, positions, new Set(graph.resourceIds), 'target:one'), - ); + render(overlayView(graph, positions, new Set(graph.resourceIds))); const overlay = await screen.findByTestId('resource-dependency-overlay'); await waitFor(() => - expect( - overlay.querySelectorAll('.is-highlighted').length, - ).toBeGreaterThan(0), + expect(overlay.querySelectorAll('[data-edge-kind]')).toHaveLength(3), ); - const unrelated = Array.from( - overlay.querySelectorAll( - '[data-edge-kind="asset-reference"]', - ), - ).find((edge) => edge.getAttribute('data-cyclic') === 'true'); - expect(unrelated?.classList.contains('is-dimmed')).toBe(true); + expect(overlay.querySelector('.is-highlighted')).toBeNull(); + expect(overlay.querySelector('.is-dimmed')).toBeNull(); }); it('updates only adjacent paths while dragging inside a 4096-resource topology', async () => { @@ -387,17 +421,14 @@ describe('ResourceDependencyOverlay', () => { ]; const visible = new Set(positions.map(({ resourceId }) => resourceId)); const overlayRef = React.createRef(); - render(overlayView(graph, positions, visible, null, overlayRef)); + render(overlayView(graph, positions, visible, overlayRef)); const overlay = await screen.findByTestId('resource-dependency-overlay'); await waitFor(() => expect( overlay.querySelectorAll('[data-edge-kind="task-flow"]'), ).toHaveLength(2), ); - const setAttribute = vi.spyOn( - SVGElement.prototype, - 'setAttribute', - ); + const setAttribute = vi.spyOn(SVGElement.prototype, 'setAttribute'); try { act(() => overlayRef.current?.updateDragPreview({ @@ -428,18 +459,12 @@ describe('ResourceDependencyOverlay', () => { vi.stubGlobal('ResizeObserver', TestResizeObserver); try { const graph = graphFixture(); - const positions = Array.from(graph.resourceIds).map( - (resourceId, index) => position(resourceId, index * 200, 0), + const positions = Array.from(graph.resourceIds).map((resourceId, index) => + position(resourceId, index * 200, 0), ); const overlayRef = React.createRef(); const view = render( - overlayView( - graph, - positions, - new Set(graph.resourceIds), - null, - overlayRef, - ), + overlayView(graph, positions, new Set(graph.resourceIds), overlayRef), ); expect(observe).toHaveBeenCalledTimes(2); @@ -457,4 +482,20 @@ describe('ResourceDependencyOverlay', () => { vi.unstubAllGlobals(); } }); + + it('uses a bright persistent orange without selection-dependent edge styles', () => { + const styles = readFileSync( + resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), + 'utf8', + ); + expect(styles).toMatch( + /\.game-resource-dependency-edge--reference\s*\{[^}]*stroke:\s*#f28a52[^}]*opacity:\s*1/s, + ); + expect(styles).toMatch( + /\.game-resource-dependency-marker--reference path\s*\{[^}]*fill:\s*#f28a52/s, + ); + expect(styles).not.toMatch( + /\.game-resource-dependency-edge\.is-(?:highlighted|dimmed)/, + ); + }); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 18b7c4bff..8dbd13a61 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -1132,7 +1132,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull(); }); - it('renders, filters, highlights, and destroys the resource dependency overlay without pointer previews', async () => { + it('renders, filters, and destroys persistent resource dependency lines without cross-section task flows', async () => { const manifest = createGameCreationAppManifest( 'workbench-resource-graph', '资源依赖图测试', @@ -1328,7 +1328,7 @@ export function registerProjectWorkbenchFoundationTests() { ).toHaveLength(2); expect( overlay.querySelectorAll('[data-edge-kind="task-flow"]'), - ).toHaveLength(1); + ).toHaveLength(0); }); fireEvent.click(screen.getByRole('button', { name: '按类型' })); @@ -1401,19 +1401,20 @@ export function registerProjectWorkbenchFoundationTests() { ); overlay = await screen.findByTestId('resource-dependency-overlay'); sourceCard = screen.getByRole('button', { name: /spec-source\.json/ }); - await waitFor(() => { - expect(sourceCard.classList.contains('is-relation-upstream')).toBe(true); - expect( - overlay - .querySelector(referenceSelector) - ?.classList.contains('is-highlighted'), - ).toBe(true); - }); + await waitFor(() => + expect(overlay.querySelector(referenceSelector)).not.toBeNull(), + ); + expect(sourceCard.classList.contains('is-relation-upstream')).toBe(false); + expect( + overlay + .querySelector(referenceSelector) + ?.classList.contains('is-highlighted'), + ).toBe(false); expect( overlay .querySelector('[data-source-resource-id="asset:unrelated-cycle"]') ?.classList.contains('is-dimmed'), - ).toBe(true); + ).toBe(false); const previousOverlay = overlay; const nextManifest = createGameCreationAppManifest( diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 46a1ab8de..d94743946 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -1,6 +1,6 @@ # AI 游戏创作项目开发工作台 PRD -更新时间:`2026-08-03`(阶段七验收收口) +更新时间:`2026-08-04`(依赖视图视觉口径调整) ## 1. 产品定位 @@ -263,16 +263,16 @@ type UpdateProjectResourceCanvasLayoutResult = - 阶段五实现状态(2026-08-03):dependency 自动排列同时消费任务 DAG 与精确资源引用。Rust 把可信 producer 的任务深度作为资源深度下限,再对 `asset-reference` 图做迭代式 SCC 压缩与确定性层级传播;被引用资源位于引用资源之前,同一引用环共享稳定深度,环后资源继续递增,没有引用关系的资源保持默认不重叠位置。布局深度通过独立 `dependencyDepths` 返回,不能把 producer assignment 冒充全部资源的布局结果。 - 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。 - 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击。 -- `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。 -- `task-flow` 表示任务产物流转,使用灰色圆头虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。 +- `asset-reference` 表示精确资源引用,使用明亮橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。 +- `task-flow` 表示同一资源类型内的任务产物流转,使用灰色圆头虚线;文档、项目版本、美术、音频之间不得绘制跨分区虚线。任务依赖按 `sourceTaskId -> targetTaskId + section` 分区聚合为一条主线,两端只保留同分区资源并绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。 - 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate` 的 `assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifest;External Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。 - 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。 - 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。 - 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。 -- 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。 -- 资源卡 Pointer Move 不改变基础 positions 或 SVG 几何。连线只随布局读取、资源自动协调、搜索、选择、项目切换或 section origin 变化而更新。 +- 资源点击只进入中央聚焦并保留当前选中卡片,不改变依赖卡片或连线的颜色、线宽与透明度;关系线始终直接展示,不提供点击后的上下游高亮或无关线弱化。 +- 资源卡 Pointer Move 不改变基础 positions 或 SVG 几何。连线只随布局读取、资源自动协调、搜索、项目切换或 section origin 变化而更新。 - `ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。 -- 阶段五不改变手动位置边界:已有 `manuallyPlaced=true` 坐标原样保留,资源引用新增或变化只允许重新派生 `manuallyPlaced=false` 的自动坐标;任务流继续按任务对聚合,禁止为了计算深度或绘线生成资源笛卡尔积。 +- 阶段五不改变手动位置边界:已有 `manuallyPlaced=true` 坐标原样保留,资源引用新增或变化只允许重新派生 `manuallyPlaced=false` 的自动坐标;任务流继续按任务对与资源分区聚合,禁止为了计算深度或绘线生成资源笛卡尔积。 ### 5.3 资源类型与替换兼容性(P1) @@ -427,11 +427,11 @@ type ProjectAgentMudPointAttribution = { ### 7.3 P1 资源依赖关系图验收 -1. dependency 模式同时正确显示橙色实线资源引用与灰色虚线任务流;type 模式没有图层或连线。 +1. dependency 模式显示明亮橙色实线资源引用,并只在同一资源类型分区内显示灰色虚线任务流;跨类型不显示虚线,type 模式没有图层或连线。 2. 精确引用只接受唯一有效的外部资源 ID 映射,删除或不存在的资源不产生幽灵连线。 -3. 多资源任务依赖只形成一条聚合主线与 `O(S+T)` 条端点分支,不产生 `S×T` 连线。 +3. 多资源任务依赖按资源类型分区后,各分区只形成一条聚合主线与 `O(S+T)` 条端点分支,不产生 `S×T` 连线或跨分区虚线。 4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。 -5. 搜索触发端点过滤,选择触发直接上下游与关联边高亮;资源卡指针移动不更新线段,点击与中央聚焦行为不回归。 +5. 搜索触发端点过滤;资源点击不改变上下游卡片或任何连线的视觉状态,资源卡指针移动不更新线段,点击与中央聚焦行为不回归。 6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。 7. 4096 资源链式 fixture 继续验证拓扑、聚合复杂度和自动布局性能;拖动局部更新与真实 Chromium 拖动帧预算暂缓,不作为当前验收条件。最右侧自环与箭头仍需完整显示。 8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。