调整资源依赖视图连线规则
Project CI / Repository checks (pull_request) Failing after 48s
Project CI / Backend tests (pull_request) Failing after 29s
Project CI / Frontend tests (pull_request) Successful in 2m54s
Project CI / Native shell tests (pull_request) Successful in 10m33s

限制任务虚线仅在同资源类型分区内渲染
调亮精确资源引用橙线并保持持续展示
取消资源点击后的依赖高亮与无关连线弱化
补充依赖图单元测试与工作台集成测试
同步更新项目开发工作台 PRD
This commit is contained in:
2026-08-04 11:20:58 +08:00
parent 6d8c7ae496
commit ab16c87c5d
6 changed files with 269 additions and 231 deletions
+4 -32
View File
@@ -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:
@@ -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<string>;
selectedResourceId: string | null;
};
export type ResourceDependencyOverlayHandle = {
@@ -58,21 +56,27 @@ type TaskFlowPathRefs = {
trunk: SVGPathElement | null;
};
type TaskFlowSectionGeometry = NonNullable<
ReturnType<typeof taskFlowGeometry>
> & {
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<string, ProjectResourceCanvasSection>,
): 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<SVGSVGElement>(null);
const referencePathRefs = useRef(new Map<string, SVGPathElement>());
const taskFlowPathRefs = useRef(new Map<string, TaskFlowPathRefs>());
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 (
<g
key={flow.id}
key={renderKey}
className={className}
data-edge-kind="task-flow"
data-edge-id={flow.id}
data-resource-section={geometry.section}
data-source-task-id={flow.sourceTaskId}
data-target-task-id={flow.targetTaskId}
data-cyclic={flow.cyclic || undefined}
@@ -357,14 +401,14 @@ export const ResourceDependencyOverlay = forwardRef<
{geometry.sourceAnchors.map(({ resourceId, point }) => (
<path
ref={(node) => {
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<
))}
<path
ref={(node) => {
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 }) => (
<path
ref={(node) => {
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<
</g>
);
}),
[
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 (
<path
ref={(node) => {
@@ -468,97 +505,106 @@ export const ResourceDependencyOverlay = forwardRef<
</path>
);
}),
[
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<string>,
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<string>();
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<string>();
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),
);
});
}
}
},
[],
);
@@ -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 (
<ResourceCard
key={resource.id}
@@ -1,9 +1,12 @@
/** @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { ProjectResourceCanvasSection } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
normalizeProjectResourceGraph,
type ProjectResourceGraph,
@@ -18,10 +21,11 @@ function position(
resourceId: string,
x: number,
y: number,
section: ProjectResourceCanvasSection = 'art',
): ProjectResourceCanvasPosition {
return {
resourceId,
section: 'art',
section,
x,
y,
manuallyPlaced: false,
@@ -106,25 +110,30 @@ function OverlayHarness({
graph,
positions,
visibleResourceIds,
selectedResourceId = null,
overlayRef,
}: {
graph: ProjectResourceGraph;
positions: ProjectResourceCanvasPosition[];
visibleResourceIds: ReadonlySet<string>;
selectedResourceId?: string | null;
overlayRef?: React.Ref<ResourceDependencyOverlayHandle>;
}) {
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<string>,
selectedResourceId: string | null = null,
overlayRef?: React.Ref<ResourceDependencyOverlayHandle>,
) {
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<ResourceDependencyOverlayHandle>();
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<SVGElement>(
'[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<ResourceDependencyOverlayHandle>();
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<ResourceDependencyOverlayHandle>();
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)/,
);
});
});
@@ -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(
@@ -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` 必须存在于当前 manifestExternal 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。