优化资源栏目悬浮分页交互
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m39s
Project CI / Native shell tests (pull_request) Successful in 14m42s

将资源栏目大纲改为不占画布宽度的悬浮 Dock,并统一栏目名称。

实现有界滚轮分页队列,显式切页和拖动时取消待处理跳转。

补充栏目切换、拖动取消和资源实时集成回归测试。

同步更新资源自由画板技术方案与验收口径。
This commit is contained in:
2026-08-23 18:47:07 +08:00
parent f3092c8bbc
commit d1e2daace7
6 changed files with 360 additions and 84 deletions
+77 -21
View File
@@ -5143,46 +5143,108 @@ iframe.preview-frame {
}
.game-resource-page-shell {
display: grid;
grid-template-columns: 212px minmax(0, 1fr);
gap: 14px;
position: relative;
width: 100%;
height: 100%;
}
.game-resource-outline {
position: absolute;
top: 50%;
left: 12px;
z-index: 4;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
padding: 12px 2px;
gap: 4px;
width: max-content;
max-width: 180px;
max-height: calc(100% - 24px);
padding: 6px;
overflow: auto;
border-right: 1px solid #eaded8;
border: 1px solid transparent;
border-radius: 14px;
background: transparent;
box-shadow: none;
opacity: 0.68;
transform: translateY(-50%) scale(0.86);
transform-origin: left center;
transition:
border-color 160ms ease,
opacity 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
backdrop-filter: none;
}
.game-resource-outline:hover,
.game-resource-outline:has(> button:focus-visible) {
border-color: var(--platform-nav-active-border);
background: var(--platform-nav-fill);
box-shadow: var(--platform-nav-active-shadow);
opacity: 1;
transform: translateY(-50%) scale(1);
backdrop-filter: blur(10px);
}
.game-resource-outline button {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
grid-template-columns: 0 minmax(0, 1fr);
align-items: center;
gap: 6px;
gap: 0;
min-height: 34px;
padding: 0 9px;
border: 0;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #725348;
color: var(--platform-nav-item-text);
cursor: pointer;
text-align: left;
transition:
grid-template-columns 160ms ease,
gap 160ms ease;
}
.game-resource-outline:hover button,
.game-resource-outline:has(> button:focus-visible) button {
grid-template-columns: 18px minmax(0, 1fr);
gap: 6px;
}
.game-resource-outline button svg {
width: 0;
min-width: 0;
opacity: 0;
transform: scale(0.65);
transition:
width 160ms ease,
opacity 120ms ease,
transform 160ms ease;
}
.game-resource-outline:hover button svg,
.game-resource-outline:has(> button:focus-visible) button svg {
width: 14px;
opacity: 1;
transform: scale(1);
}
.game-resource-outline button:hover,
.game-resource-outline button:focus-visible,
.game-resource-outline button.is-active {
background: #f6ece6;
color: #a9502a;
.game-resource-outline button:focus-visible {
background: var(--platform-nav-item-hover-fill);
outline: 0;
}
.game-resource-outline button.is-active {
color: var(--platform-nav-item-text-active);
}
.game-resource-outline:hover button.is-active,
.game-resource-outline:has(> button:focus-visible) button.is-active {
border-color: var(--platform-nav-active-border);
background: var(--platform-nav-active-fill);
box-shadow: var(--platform-nav-active-shadow);
}
.game-resource-outline button strong {
overflow: hidden;
font-size: 11px;
@@ -5190,12 +5252,6 @@ iframe.preview-frame {
white-space: nowrap;
}
.game-resource-outline button small {
color: #a98a7c;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.game-resource-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
@@ -364,10 +364,10 @@ const categoryOrder = RESOURCE_CANVAS_SECTION_ORDER;
const categoryLabels: Record<ResourceCategory, string> = {
code: '游戏代码',
document: '文档',
document: '设计文档',
version: '项目版本',
art: '美术资源',
audio: '音乐音效资源',
audio: '音乐音效',
};
const categoryIcons: Record<ResourceCategory, typeof FileText> = {
@@ -871,7 +871,19 @@ export default function ProjectDevelopmentView({
scale: 1,
});
const resourceCanvasFitKeyRef = useRef<string | null>(null);
const resourceCanvasPageWheelRef = useRef({ lastSwitchAt: 0 });
const resourceCanvasPageWheelRef = useRef<{
activeCategory: ResourceCategory | null;
accumulatedDeltaY: number;
lastSwitchAt: number;
pendingStepCount: number;
pendingTimerId: number | null;
}>({
activeCategory: null,
accumulatedDeltaY: 0,
lastSwitchAt: 0,
pendingStepCount: 0,
pendingTimerId: null,
});
const resourceCanvasPanRef = useRef<{
pointerId: number;
startClientX: number;
@@ -1347,6 +1359,7 @@ export default function ProjectDevelopmentView({
) ??
resourcePageCategories[0] ??
null;
resourceCanvasPageWheelRef.current.activeCategory = activePageCategory;
const nextPageCategory =
activePageCategory && resourcePageCategories.length > 1
? resourcePageCategories[
@@ -1433,18 +1446,20 @@ export default function ProjectDevelopmentView({
: { x: 48, y: 48, scale: 1 };
const setResourceCanvasViewport = useCallback(
(candidate: CanvasViewport) => {
if (!activePageCategory) {
const activeCategory =
resourceCanvasPageWheelRef.current.activeCategory;
if (!activeCategory) {
return;
}
const next = normalizeInfiniteResourceCanvasViewport(candidate);
resourceCanvasViewportRef.current = next;
setResourceCanvasViewports((current) =>
resourceCanvasViewportsEqual(current[activePageCategory], next)
resourceCanvasViewportsEqual(current[activeCategory], next)
? current
: { ...current, [activePageCategory]: next },
: { ...current, [activeCategory]: next },
);
},
[activePageCategory],
[],
);
resourceCanvasViewportRef.current = activeResourceCanvasViewport;
const selectedResource =
@@ -1652,32 +1667,66 @@ export default function ProjectDevelopmentView({
[queueResourceSectionZoomScroll, setResourceSectionZoom],
);
const cancelQueuedResourceCanvasPageWheel = useCallback(() => {
const state = resourceCanvasPageWheelRef.current;
if (state.pendingTimerId !== null) {
window.clearTimeout(state.pendingTimerId);
}
state.accumulatedDeltaY = 0;
state.lastSwitchAt = 0;
state.pendingStepCount = 0;
state.pendingTimerId = null;
}, []);
const cancelResourceCanvasPan = useCallback(() => {
const pan = resourceCanvasPanRef.current;
if (!pan) {
return;
}
const canvas = resourceCanvasRef.current;
if (canvas?.hasPointerCapture?.(pan.pointerId)) {
canvas.releasePointerCapture?.(pan.pointerId);
}
resourceCanvasPanRef.current = null;
}, []);
const selectResourceCanvasPage = useCallback(
(category: ResourceCategory) => {
cancelResourceCanvasPan();
cancelQueuedResourceCanvasPageWheel();
resourceCanvasPageWheelRef.current.activeCategory = category;
setActiveResourceCategory(category);
},
[cancelQueuedResourceCanvasPageWheel, cancelResourceCanvasPan],
);
const advanceResourceCanvasPage = useCallback(
(deltaY: number) => {
(stepOffset: number) => {
const currentCategory =
resourceCanvasPageWheelRef.current.activeCategory;
const normalizedStepOffset = Math.trunc(stepOffset);
if (
!activePageCategory ||
!currentCategory ||
resourcePageCategories.length < 2 ||
!Number.isFinite(deltaY) ||
Math.abs(deltaY) < 20
!Number.isFinite(stepOffset) ||
normalizedStepOffset === 0
) {
return;
}
const now = performance.now();
if (now - resourceCanvasPageWheelRef.current.lastSwitchAt < 360) {
return;
}
const index = resourcePageCategories.indexOf(activePageCategory);
const index = resourcePageCategories.indexOf(currentCategory);
const nextIndex =
(index + (deltaY > 0 ? 1 : -1) + resourcePageCategories.length) %
(((index + normalizedStepOffset) % resourcePageCategories.length) +
resourcePageCategories.length) %
resourcePageCategories.length;
const nextCategory = resourcePageCategories[nextIndex];
if (!nextCategory) {
return;
}
resourceCanvasPageWheelRef.current.lastSwitchAt = now;
cancelResourceCanvasPan();
resourceCanvasPageWheelRef.current.activeCategory = nextCategory;
setActiveResourceCategory(nextCategory);
},
[activePageCategory, resourcePageCategories],
[cancelResourceCanvasPan, resourcePageCategories],
);
useEffect(() => {
@@ -1689,6 +1738,34 @@ export default function ProjectDevelopmentView({
return undefined;
}
const zoomFrames = resourceSectionZoomFramesRef.current;
const pageWheelSwitchIntervalMs = 180;
const pageWheelDeltaThreshold = 20;
const maxQueuedPageWheelSteps = Math.max(
1,
resourcePageCategories.length - 1,
);
const flushPageWheel = () => {
const state = resourceCanvasPageWheelRef.current;
if (state.pendingStepCount === 0) {
return;
}
const now = performance.now();
const waitMs =
pageWheelSwitchIntervalMs - (now - state.lastSwitchAt);
if (waitMs > 0) {
if (state.pendingTimerId === null) {
state.pendingTimerId = window.setTimeout(() => {
resourceCanvasPageWheelRef.current.pendingTimerId = null;
flushPageWheel();
}, waitMs);
}
return;
}
const stepOffset = state.pendingStepCount;
state.pendingStepCount = 0;
state.lastSwitchAt = now;
advanceResourceCanvasPage(stepOffset);
};
const resolveViewport = (event: Event) => {
const target = event.target;
return target instanceof Element
@@ -1720,7 +1797,55 @@ export default function ProjectDevelopmentView({
setResourceCanvasViewport(result.viewport);
return;
}
advanceResourceCanvasPage(event.deltaY);
if (!Number.isFinite(event.deltaY) || event.deltaY === 0) {
return;
}
const pageWheelState = resourceCanvasPageWheelRef.current;
const deltaModeMultiplier =
event.deltaMode === 1
? 16
: event.deltaMode === 2
? Math.max(canvas.clientHeight, 1)
: 1;
const pageWheelDeltaPerStep =
event.deltaMode === 1
? 48
: event.deltaMode === 2
? Math.max(canvas.clientHeight, 1)
: 120;
const normalizedDeltaY = event.deltaY * deltaModeMultiplier;
const direction = Math.sign(normalizedDeltaY);
if (
(pageWheelState.accumulatedDeltaY !== 0 &&
direction !== Math.sign(pageWheelState.accumulatedDeltaY)) ||
(pageWheelState.pendingStepCount !== 0 &&
direction !== Math.sign(pageWheelState.pendingStepCount))
) {
pageWheelState.accumulatedDeltaY = 0;
pageWheelState.pendingStepCount = 0;
}
pageWheelState.accumulatedDeltaY += normalizedDeltaY;
if (
Math.abs(pageWheelState.accumulatedDeltaY) >=
pageWheelDeltaThreshold
) {
const stepCount = Math.max(
1,
Math.floor(
Math.abs(pageWheelState.accumulatedDeltaY) /
pageWheelDeltaPerStep,
),
);
pageWheelState.accumulatedDeltaY = 0;
pageWheelState.pendingStepCount = Math.max(
-maxQueuedPageWheelSteps,
Math.min(
maxQueuedPageWheelSteps,
pageWheelState.pendingStepCount + direction * stepCount,
),
);
}
flushPageWheel();
return;
}
if (!event.ctrlKey && !event.metaKey) {
@@ -1815,6 +1940,13 @@ export default function ProjectDevelopmentView({
canvas.removeEventListener('gesturechange', gestureChange);
canvas.removeEventListener('gestureend', gestureEnd);
resourceSectionGestureRef.current = null;
const pageWheelState = resourceCanvasPageWheelRef.current;
if (pageWheelState.pendingTimerId !== null) {
window.clearTimeout(pageWheelState.pendingTimerId);
}
pageWheelState.accumulatedDeltaY = 0;
pageWheelState.pendingStepCount = 0;
pageWheelState.pendingTimerId = null;
for (const pending of zoomFrames.values()) {
window.cancelAnimationFrame(pending.frameId);
}
@@ -1997,7 +2129,16 @@ export default function ProjectDevelopmentView({
resourceCanvasFitKeyRef.current = null;
resourceCanvasViewportRef.current =
defaultResourceCanvasViewports().document;
resourceCanvasPageWheelRef.current.lastSwitchAt = 0;
if (resourceCanvasPageWheelRef.current.pendingTimerId !== null) {
window.clearTimeout(resourceCanvasPageWheelRef.current.pendingTimerId);
}
resourceCanvasPageWheelRef.current = {
activeCategory: null,
accumulatedDeltaY: 0,
lastSwitchAt: 0,
pendingStepCount: 0,
pendingTimerId: null,
};
resourceCardDragRef.current = null;
resourceCanvasPanRef.current = null;
resourceDependencyOverlayRef.current?.clearDragPreview();
@@ -2453,6 +2594,7 @@ export default function ProjectDevelopmentView({
if (!Number.isFinite(scale) || scale <= 0) {
return;
}
cancelQueuedResourceCanvasPageWheel();
resourceCardDragRef.current = {
pointerId: event.pointerId,
resourceId: resource.id,
@@ -2466,7 +2608,7 @@ export default function ProjectDevelopmentView({
captured: false,
};
},
[activePageCategory],
[activePageCategory, cancelQueuedResourceCanvasPageWheel],
);
const handleResourceCardPointerMove = useCallback(
@@ -2577,6 +2719,7 @@ export default function ProjectDevelopmentView({
) {
return;
}
cancelQueuedResourceCanvasPageWheel();
event.currentTarget.setPointerCapture(event.pointerId);
resourceCanvasPanRef.current = {
pointerId: event.pointerId,
@@ -2585,7 +2728,7 @@ export default function ProjectDevelopmentView({
startViewport: resourceCanvasViewportRef.current,
};
},
[activePageCategory],
[activePageCategory, cancelQueuedResourceCanvasPageWheel],
);
const handleResourceCanvasPointerMove = useCallback(
@@ -3455,7 +3598,7 @@ export default function ProjectDevelopmentView({
? resources.find((resource) => resource.id === intent.resourceId)
: undefined;
if (targetResource && targetResource.category !== activePageCategory) {
setActiveResourceCategory(targetResource.category);
selectResourceCanvasPage(targetResource.category);
return;
}
const targetVisible = intent.resourceId
@@ -3535,6 +3678,7 @@ export default function ProjectDevelopmentView({
manifest.projectId,
projectPath,
resources,
selectResourceCanvasPage,
typeLayout.layout.positions,
typeLayout.settled,
visibleResourceIds,
@@ -4158,9 +4302,6 @@ export default function ProjectDevelopmentView({
>
{resourcePageCategories.map((category) => {
const Icon = categoryIcons[category];
const count =
projectResourcesByCategory.get(category)
?.length ?? 0;
return (
<button
key={category}
@@ -4176,12 +4317,11 @@ export default function ProjectDevelopmentView({
: undefined
}
onClick={() =>
setActiveResourceCategory(category)
selectResourceCanvasPage(category)
}
>
<Icon size={14} aria-hidden="true" />
<strong>{categoryLabels[category]}</strong>
<small>{count}</small>
</button>
);
})}
@@ -4353,7 +4493,7 @@ export default function ProjectDevelopmentView({
type="button"
className="game-resource-next-page"
onClick={() =>
setActiveResourceCategory(nextPageCategory)
selectResourceCanvasPage(nextPageCategory)
}
>
<span></span>
@@ -167,6 +167,11 @@ export function registerClientHomeTests() {
expect(
await screen.findByRole('button', { name: /live-hero\.png/ }),
).not.toBeNull();
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: '项目版本',
}),
);
expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
@@ -337,6 +342,11 @@ export function registerClientHomeTests() {
{ timeout: 5_000 },
),
).not.toBeNull();
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: '项目版本',
}),
);
expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
@@ -894,8 +894,11 @@ export function registerProjectWorkbenchFoundationTests() {
Array.from(outline.querySelectorAll('strong')).map(
(item) => item.textContent,
),
).toEqual(['文档', '美术资源', '音乐音效资源', '游戏代码', '项目版本']);
expect(screen.getByRole('region', { name: '文档资源画布' })).not.toBeNull();
).toEqual(['设计文档', '美术资源', '音乐音效', '游戏代码', '项目版本']);
expect(outline.querySelectorAll('small')).toHaveLength(0);
expect(
screen.getByRole('region', { name: '设计文档资源画布' }),
).not.toBeNull();
expect(
screen.getByTestId('resource-dependency-overlay-document'),
).not.toBeNull();
@@ -942,7 +945,9 @@ export function registerProjectWorkbenchFoundationTests() {
});
expect(zoomWheelResult).toBe(false);
expect(readViewport()[2]).toBeGreaterThan(viewportBeforeZoom[2]!);
expect(screen.getByRole('region', { name: '文档资源画布' })).not.toBeNull();
expect(
screen.getByRole('region', { name: '设计文档资源画布' }),
).not.toBeNull();
const viewportBeforePan = readViewport();
fireEvent.pointerDown(dependencyCanvas, {
@@ -996,9 +1001,71 @@ export function registerProjectWorkbenchFoundationTests() {
screen.queryByTestId('resource-dependency-overlay-document'),
).toBeNull();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 380));
act(() => {
dependencyCanvas.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY: 160,
}),
);
});
fireEvent.pointerDown(dependencyCanvas, {
button: 0,
clientX: 100,
clientY: 100,
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 220));
});
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.pointerUp(dependencyCanvas);
act(() => {
dependencyCanvas.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY: 160,
}),
);
});
fireEvent.click(
within(outline).getByRole('button', { name: /项目版本/ }),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 220));
});
expect(
screen.getByRole('region', { name: '项目版本资源画布' }),
).not.toBeNull();
fireEvent.click(
within(outline).getByRole('button', { name: /设计文档/ }),
);
act(() => {
dependencyCanvas.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY: 480,
}),
);
});
await waitFor(
() => {
expect(
screen.getByRole('region', { name: '项目版本资源画布' }),
).not.toBeNull();
},
{ timeout: 2_000 },
);
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const typeCanvas = screen.getByLabelText('资源类型视图') as HTMLDivElement;
expect(
@@ -1020,8 +1087,11 @@ export function registerProjectWorkbenchFoundationTests() {
act(() => {
typeCanvas.dispatchEvent(typePageWheel);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
expect(
screen.getByRole('region', { name: '音乐音效资源资源画布' }),
screen.getByRole('region', { name: '音乐音效资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /项目版本/ }));
@@ -1091,21 +1161,21 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(5);
for (const label of [
'文档',
'设计文档',
'美术资源',
'音乐音效资源',
'音乐音效',
'游戏代码',
'项目版本',
]) {
expect(screen.getByRole('region', { name: label })).not.toBeNull();
}
};
await screen.findByRole('region', { name: '文档' });
await screen.findByRole('region', { name: '设计文档' });
assertOverview();
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
assertOverview();
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
await screen.findByRole('region', { name: '文档' });
await screen.findByRole('region', { name: '设计文档' });
assertOverview();
});
@@ -1274,7 +1344,7 @@ export function registerProjectWorkbenchFoundationTests() {
?.getAttribute('src'),
).toBe('blob:mock-attachment-preview');
});
showResourcePage('文档');
showResourcePage('设计文档');
act(() => observer.triggerVisible());
await screen.findByText(/这是安全的卡片正文摘要。/);
showResourcePage('美术资源');
@@ -1314,7 +1384,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(stableVideoControl).toBe(pauseVideoControl);
expect(document.activeElement).toBe(stableVideoControl);
showResourcePage('音乐音效资源');
showResourcePage('音乐音效');
fireEvent.click(screen.getByRole('button', { name: '播放 theme.mp3' }));
await waitFor(() => {
expect(
@@ -1744,7 +1814,7 @@ export function registerProjectWorkbenchFoundationTests() {
resourceCanvas.scrollTop = 36;
const artReceiptButton = screen.getByRole('button', {
name: /打开资源详情:文档 美术资源计划 Agent 文本回执/,
name: /打开资源详情:设计文档 美术资源计划 Agent 文本回执/,
});
const artReceiptCard = artReceiptButton.closest('.game-resource-card');
expect(artReceiptCard).not.toBeNull();
@@ -1833,7 +1903,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(restoredCanvas.scrollLeft).toBe(48);
expect(restoredCanvas.scrollTop).toBe(36);
const restoredCard = screen.getByRole('button', {
name: /打开资源详情:文档 美术资源计划 Agent 文本回执/,
name: /打开资源详情:设计文档 美术资源计划 Agent 文本回执/,
});
expect(restoredCard?.getAttribute('aria-pressed')).toBe('true');
expect(
@@ -2040,12 +2110,12 @@ export function registerProjectWorkbenchFoundationTests() {
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /音乐音效资源/,
name: /音乐音效/,
}),
);
fireEvent.click(
screen.getByRole('button', {
name: '打开资源详情:音乐音效资源 bgm.mp3',
name: '打开资源详情:音乐音效 bgm.mp3',
}),
);
expect(
@@ -2163,7 +2233,7 @@ export function registerProjectWorkbenchFoundationTests() {
fireEvent.click(
await screen.findByRole('button', {
name: '打开资源详情:音乐音效资源 focus.mp3',
name: '打开资源详情:音乐音效 focus.mp3',
}),
);
const audioDetails = await screen.findByRole('dialog', {
@@ -2896,7 +2966,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(dependencyLayoutReads).toBe(0);
expect(
screen.queryByRole('button', {
name: '打开资源详情:文档 延迟依赖图回执',
name: '打开资源详情:设计文档 延迟依赖图回执',
}),
).toBeNull();
@@ -2906,7 +2976,7 @@ export function registerProjectWorkbenchFoundationTests() {
});
expect(
await screen.findByRole('button', {
name: '打开资源详情:文档 延迟依赖图回执',
name: '打开资源详情:设计文档 延迟依赖图回执',
}),
).not.toBeNull();
expect(dependencyLayoutReads).toBe(1);
@@ -2978,7 +3048,7 @@ export function registerProjectWorkbenchFoundationTests() {
});
const view = render(renderView([firstResult]));
const firstCardButton = await screen.findByRole('button', {
name: '打开资源详情:文档 稳定依赖图回执',
name: '打开资源详情:设计文档 稳定依赖图回执',
});
const firstCard = firstCardButton.closest('.game-resource-card');
expect(firstCard).not.toBeNull();
@@ -3005,7 +3075,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(firstCard?.isConnected).toBe(true);
expect(
screen.getByRole('button', {
name: '打开资源详情:文档 稳定依赖图回执',
name: '打开资源详情:设计文档 稳定依赖图回执',
}),
).not.toBeNull();
@@ -3015,7 +3085,7 @@ export function registerProjectWorkbenchFoundationTests() {
});
expect(
await screen.findByRole('button', {
name: '打开资源详情:文档 刷新期间新增回执',
name: '打开资源详情:设计文档 刷新期间新增回执',
}),
).not.toBeNull();
expect(firstCard?.isConnected).toBe(true);
@@ -3321,7 +3391,7 @@ export function registerProjectWorkbenchFoundationTests() {
renderWorkbench();
const cardButton = await screen.findByRole('button', {
name: '打开资源详情:文档 布局持久化回执',
name: '打开资源详情:设计文档 布局持久化回执',
});
const card = cardButton.closest('.game-resource-card');
expect(card).not.toBeNull();
@@ -3383,7 +3453,7 @@ export function registerProjectWorkbenchFoundationTests() {
renderWorkbench();
const restoredCard = (
await screen.findByRole('button', {
name: '打开资源详情:文档 布局持久化回执',
name: '打开资源详情:设计文档 布局持久化回执',
})
).closest('.game-resource-card');
await waitFor(() => {
@@ -3707,7 +3777,7 @@ export function registerProjectWorkbenchFoundationTests() {
).not.toBeNull();
expect(
screen.getByRole('button', {
name: '打开资源详情:文档 自动排版失败回执',
name: '打开资源详情:设计文档 自动排版失败回执',
}),
).not.toBeNull();
});
@@ -856,7 +856,7 @@ describe('project resource live canvas integration', () => {
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
await screen.findByRole('button', {
name: '打开资源详情:文档 rules.md',
name: '打开资源详情:设计文档 rules.md',
}),
).not.toBeNull();
});
@@ -2,7 +2,7 @@
## 目标
资源管理在空项目中展示现有按类型式分区展览。任一栏目出现资源后,“按依赖”和“按类型”共用同一套栏目分页画布:每个非空栏目拥有一个铺满资源管理区域的独立画布,左侧显示飞书文档式栏目大纲,顶部显示当前栏目标题,底部显示下一页标题普通滚轮切换栏目,按住 Ctrl/Meta 的滚轮以指针为锚点缩放当前画布。两种模式唯一差异是“按依赖”在当前栏目内绘制引导线。资源详情是叠加在资源画板之上的非模态卡片;打开、切换和关闭详情都不得卸载背景工具栏、资源卡、依赖连线或重置 viewport、搜索和排序模式。
资源管理在空项目中展示现有按类型式分区展览。任一栏目出现资源后,“按依赖”和“按类型”共用同一套栏目分页画布:每个非空栏目拥有一个铺满资源管理区域的独立画布,栏目大纲悬浮在画布左侧中间,只显示图标和栏目名称,不占用画布布局宽度;顶部显示当前栏目标题,底部显示下一页标题普通滚轮切换栏目,按住 Ctrl/Meta 的滚轮以指针为锚点缩放当前画布。两种模式唯一差异是“按依赖”在当前栏目内绘制引导线。资源详情是叠加在资源画板之上的非模态卡片;打开、切换和关闭详情都不得卸载背景工具栏、资源卡、依赖连线或重置 viewport、搜索和排序模式。
图片资源从“编辑资源”进入持续精修草稿后,点击任意图片直接在图片下方显示快速编辑卡。每次快速编辑创建新的候选图,不覆盖来源图层;生成任务立即在画布中创建占位并进入任务侧栏。用户显式选择候选图“设为最终图”后,保持原资产 ID 不变,以事务方式切换 manifest 指向的正式 PNG;精修草稿和其它候选图继续保留。
@@ -10,10 +10,10 @@
## 权威数据与持久化
### 栏目分页投影与文档缺失边界
### 栏目分页投影与设计文档缺失边界
- 非空栏目页只由当前权威资源投影构造:manifest assets、已完成任务的 artifacts、用户已导入附件和持久化 Agent 文本回执。前端不得扫描项目目录自行发明资源。
- 文档栏因此只有在上述来源存在 Markdown / TXT / JSON / YAML 等文档类条目,或存在 Agent 文本回执时才出现。若 manifest 没有文档资产、任务 artifacts 未完成且没有附件 / 回执,资源页不显示文档页;这是生成或登记缺失,不由 UI 补假数据。
- 设计文档栏因此只有在上述来源存在 Markdown / TXT / JSON / YAML 等文档类条目,或存在 Agent 文本回执时才出现。若 manifest 没有文档资产、任务 artifacts 未完成且没有附件 / 回执,资源页不显示设计文档页;这是生成或登记缺失,不由 UI 补假数据。
- Direct Codex 的游戏生成链路以 `game/index.html``game/style.css``game/game.js` 作为代码资产登记;是否额外产出设计文档由当前任务和 Agent 决定,资源页只消费已登记结果。
### 持续精修草稿
@@ -81,8 +81,8 @@
## 操作边界
- 栏目顺序固定为 `文档 -> 美术资源 -> 音乐音效资源 -> 游戏代码 -> 项目版本`。完全空项目显示全部栏目的分区展览;任一栏目出现资源后,分页大纲展示全部栏目,默认停留在该顺序中的第一个非空栏目,空栏目计数为 0 且可打开空画布。
- 普通滚轮向下切到下一栏目、向上切到上一栏目并循环;短时间连续滚轮事件需要节流,避免触控板一次惯性滚动跨多页。点击大纲底部“下一页”标题是显式切页入口
- 栏目顺序固定为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`。完全空项目显示全部栏目的分区展览;任一栏目出现资源后,分页大纲以左侧垂直居中的悬浮 Dock 展示全部栏目。常态缩小、降低不透明度并移除容器与选中项背景,只露出栏目文字;悬停或键盘聚焦时平滑恢复完整尺寸,显示栏目图标、Dock 背景和选中态视觉强调。默认停留在该顺序中的第一个非空栏目,空栏目可打开空画布。
- 普通滚轮向下切到下一栏目、向上切到上一栏目并循环;持续滚动时将离散切页意图加入有界队列,浏览器合并形成的单个大幅滚轮事件也要按输入强度拆分为多个切页意图。同一节流窗口内的待处理步数必须合并为一次目标栏目切换,不能逐页挂载并加载中间栏目的资源,以免资源渲染阻塞后续滚轮输入;同时限制切页频率和最长排队距离,避免触控板惯性长时间自动翻页。点击大纲底部“下一页”标题或自动定位资源属于显式切页,必须先取消尚未执行的滚轮队列,不能在显式切页后继续跳转;开始拖动画布或资源卡时也必须取消待处理切页,切页前必须终止旧栏目的画布拖动和 pointer capture,避免旧 viewport 写入新栏目
- 每个栏目画布保留独立 viewport;切换栏目后先按该栏目内容适配视口。空白处拖拽平移画布,资源卡拖拽移动卡片并更新依赖线,Ctrl/Meta 缩放只作用于当前栏目,不能牵动其它栏目。
- 依赖画布复用 `@genarrative/image-canvas-core` 的 viewport 计算,并复用现有资源卡片、布局和依赖连线模型。
- 非空状态不使用资源分区滚动条、分区缩放或分区高度操作作为主要导航;栏目通过大纲、底部下一页标题和滚轮切换。
@@ -108,8 +108,8 @@
## 验收
1. 完全空项目在按依赖与按类型下都显示相同的分区展览;任一栏目出现资源后,两种模式都切换为栏目分页画布,大纲包含全部栏目,空栏目计数为 0 且可打开空画布。
2. 栏目顺序为 `文档 -> 美术资源 -> 音乐音效资源 -> 游戏代码 -> 项目版本`,游戏代码不再固定在首位;普通滚轮可循环切换栏目,点击大纲和底部下一页标题也可切页。
1. 完全空项目在按依赖与按类型下都显示相同的分区展览;任一栏目出现资源后,两种模式都切换为栏目分页画布,大纲以左侧垂直居中的悬浮 Dock 覆盖在全宽画布上,包含全部栏目的图标和文字、不显示数量,空栏目仍可打开空画布。
2. 栏目顺序为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`,游戏代码不再固定在首位;普通滚轮可循环切换栏目,点击大纲和底部下一页标题也可切页。
3. 在当前栏目内拖拽空白可以无限平移画布,拖拽资源卡可以移动卡片并同步依赖线,Ctrl/Meta + 滚轮以指针位置为锚点缩放,复位按钮可以适配当前栏目内容;普通平移不会因资源 extent、图片测量或窗口 resize 被拉回,连续缩放仍停在共享画布的最小/最大比例范围内。
4. 按依赖模式显示当前栏目内同类型两端资源的合法精确引用,并具有与当前可见连线一致的无障碍关系说明;按类型模式不显示引导线;搜索隐藏任一端点后连线和说明同时消失。
5. 点击任意资源后“新增资源 / 按依赖 / 按类型 / 复位”仍保持挂载和原状态;详情为非模态独立卡片,背景画板 viewport、搜索、排序、卡片和连线不卸载、不重置。