资源总览新增「所有资源」汇总卡与全部资源页

- 渲染层新增 `RESOURCE_BOOK_ALL_TARGET = 'all'` 特殊项:跨端契约 `ProjectResourceCanvasCategory` 不加 `'all'`,sidecar schema 不升版,落盘布局的 `section` 也不会出现它
- 资源总览第 0 张卡即「所有资源」:排在全部栏目卡之前,复用同一套缩略图类名/圆角/底色/阴影与栏目标题栏组件,不新增卡片样式
- 该卡的计数徽标取全量资源数,口径等于各栏目计数之和(同一份画布投影,不另算一套)
- 画本场景的栏目顺序与资源分组各多一个渲染层入口;`buildResourceBookScenePlan` 对特殊项只出计数与标题栏,不铺卡片,避免同一张资源卡在总览里挂载两份(`data-resource-card-id` 查询与 FLIP 的 `card:*` 键都会撞车)
- 新增「所有资源」页:栏目大纲 + 收起资源按钮 + 按栏目分组的滚动卡片网格;卡片复用栏目页同一颗渲染器与卡宽高,容器与网格样式补齐在 styles.css
- 该页的卡片显式关掉拖拽(卡片坐标不属于任何栏目画布,写回会污染布局 sidecar),保留选中/播放/菜单交互,并把光标与 `touch-action: pan-y` 交给滚动容器
- 栏目大纲抽成 `ResourceBookOutlineNav`,栏目页与全部资源页共用同一条入口带;渲染出的 DOM 与抽取前一致
- `renderResourceBookCard` 增加可选 `draggable` 参数,`ResourceCard` 的三个移动端指针回调改为可选
- 断言:总览存在「所有资源」卡且为第 0 张、计数等于全量资源数且等于各栏目计数之和、点它进入的页面按栏目分组且每组都挂真实资源卡、场景计划里特殊项不出卡片且整份计划无重复卡片键、全部资源页分组只保留非空栏目且保持总览顺序
- 既有「N 项」徽标断言从全文档文本查找收窄到 character 栏自己的标题栏(新增第 0 张卡后同文案会撞车,收窄而非放宽)
This commit is contained in:
2026-09-11 11:21:03 +08:00
parent 364492de37
commit 1cc9a23815
7 changed files with 791 additions and 83 deletions
+59
View File
@@ -6650,6 +6650,65 @@ iframe.preview-frame {
font-size: 12px;
}
/*
* 「所有资源」页。
*
* 资源总览第 0 张卡打开的汇总页:左侧沿用栏目大纲,正文按栏目分组的滚动卡片网格。
* 整页自成一层(`z-index: 21`):栏目态的画本场景自带一层不透明底纹(`z-index: 20`),
* 只在 `z-index: auto` 上铺会被它盖住;同时它不落在 `.game-resource-canvas` 里,
* 那层挂着画布平移/框选的指针与滚轮处理器,会把这里的滚动吃掉。
*
* 卡片本体仍由栏目页同一颗卡片渲染器产出(尺寸取画布默认卡宽高),
* 因此这一页只补齐容器与网格,不新增卡片样式。
*/
.game-resource-all-resources-host {
position: absolute;
z-index: 21;
inset: 0;
padding: 12px;
overflow: hidden;
background-color: #fffdfa;
background-image: radial-gradient(#eaded8 0.8px, transparent 0.8px);
background-size: 18px 18px;
}
.game-resource-all-scroll {
display: grid;
gap: 14px;
align-content: start;
min-height: 0;
padding: 2px;
overflow: auto;
overscroll-behavior: contain;
}
.game-resource-all-section {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #ecdfd8;
border-radius: 12px;
background: rgb(255 253 250 / 82%);
}
.game-resource-all-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 180px);
gap: 14px;
}
.game-resource-all-card {
position: relative;
display: block;
}
/* 只浏览的页面不接拖拽:光标如实反映「点选 / 播放」,并且把纵向触摸手势交还给滚动容器
`.game-resource-card` 常态是 `touch-action: none`,那是画布拖卡需要的)。 */
.game-resource-all-grid .game-resource-card {
cursor: pointer;
touch-action: pan-y;
}
.game-resource-section-stack {
display: contents;
}
File diff suppressed because it is too large Load Diff
@@ -2,8 +2,10 @@ import type {
ResourceBookCardPresentation,
ResourceBookCategory,
ResourceBookState,
ResourceBookTarget,
} from './resourceBookModel';
import {
isResourceBookAllTarget,
resourceBookCategoryCardPresentation,
resourceBookShowsExitingCards,
} from './resourceBookModel';
@@ -55,6 +57,29 @@ export function groupResourceBookStacks(
);
}
export type ResourceBookAllResourcesSection = {
category: ResourceBookCategory;
resources: ProjectResource[];
};
/**
* 「所有资源」页的分组分页口径:只保留有资源的栏目,并沿用总览的栏目顺序。
*
* 空栏目不占一屏,用户在全部资源页看到的每一屏都能对上资源总览里的某一栏。
*/
export function buildResourceBookAllResourcesSections({
categoryOrder,
resourcesByCategory,
}: {
categoryOrder: readonly ResourceBookCategory[];
resourcesByCategory: ReadonlyMap<ResourceBookCategory, ProjectResource[]>;
}): ResourceBookAllResourcesSection[] {
return categoryOrder.flatMap((category) => {
const resources = resourcesByCategory.get(category) ?? [];
return resources.length > 0 ? [{ category, resources }] : [];
});
}
export function groupResourceBookResourcesByCategory(
resources: readonly ProjectResource[],
) {
@@ -174,13 +199,13 @@ export function resourceBookSceneCardKey(resourceId: string) {
return `card:${resourceId}`;
}
export function resourceBookSceneTitleKey(category: ResourceBookCategory) {
export function resourceBookSceneTitleKey(category: ResourceBookTarget) {
return `title:${category}`;
}
export type ResourceBookSceneCardPlan = {
key: string;
category: ResourceBookCategory;
category: ResourceBookTarget;
resource: ProjectResource;
presentation: ResourceBookCardPresentation;
stackIndex: number;
@@ -191,7 +216,7 @@ export type ResourceBookSceneCardPlan = {
};
export type ResourceBookSceneCategoryPlan = {
category: ResourceBookCategory;
category: ResourceBookTarget;
resourceCount: number;
titlebarActive: boolean;
titlebarOpacity: number;
@@ -205,6 +230,7 @@ export type ResourceBookSceneCategoryPlan = {
* - 进入栏目时当前栏目换成子画布布局,其他栏目留在 DOM 里按总览布局淡出;
* - 返回总览时源栏目未回到摞上的卡片按子画布布局淡出;
* - 搜索/排序导致的卸载由 `exitingCards` 保留一帧,用同一套淡出撤下。
* - 「所有资源」组只出栏目标题栏(计数即全量资源数),卡片本体仍由各栏目摞承载。
*/
export function buildResourceBookScenePlan({
state,
@@ -220,18 +246,18 @@ export function buildResourceBookScenePlan({
exitingCards = [],
}: {
state: ResourceBookState;
visibleCategoryOrder: readonly ResourceBookCategory[];
resourcesByCategory: ReadonlyMap<ResourceBookCategory, ProjectResource[]>;
overviewRects: ReadonlyMap<ResourceBookCategory, ResourceBookOverviewRect>;
visibleCategoryOrder: readonly ResourceBookTarget[];
resourcesByCategory: ReadonlyMap<ResourceBookTarget, ProjectResource[]>;
overviewRects: ReadonlyMap<ResourceBookTarget, ResourceBookOverviewRect>;
positions: ReadonlyMap<string, { x: number; y: number }>;
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
visibleResourceIds: ReadonlySet<string>;
categoryViewCenters: ReadonlyMap<
ResourceBookCategory,
ResourceBookTarget,
{ x: number; y: number }
>;
cardsReady: boolean;
exitingCategory?: ResourceBookCategory | null;
exitingCategory?: ResourceBookTarget | null;
exitingCards?: readonly ResourceBookSceneCardPlan[];
}): ResourceBookSceneCategoryPlan[] {
return visibleCategoryOrder.map((category) => {
@@ -241,7 +267,11 @@ export function buildResourceBookScenePlan({
const categoryResources = resourcesByCategory.get(category) ?? [];
const stacks = groupResourceBookStacks(categoryResources);
const childLayout = presentation === 'child';
if (cardsReady && presentation) {
// 「所有资源」的卡片在各栏目摞里已经渲染过;这里再铺一遍会让同一张资源卡在
// 总览里挂载两份(`data-resource-card-id` 查询与 FLIP 的 `card:*` 键都会撞车)。
const rendersCards =
cardsReady && presentation && !isResourceBookAllTarget(category);
if (rendersCards) {
stacks.forEach(([, items], stackColumn) => {
// 总览只铺有限张,数量由栏目标题栏的“N 项”表达;子画布渲染全部资源。
const stackItems = childLayout
@@ -1,17 +1,38 @@
import type { ProjectResourceCategory } from './resourceProjectionModel';
export type ResourceBookCategory = ProjectResourceCategory;
/**
* 「所有资源」是渲染层的特殊项,不是真实资源栏目。
*
* 它只存在于画本(资源总览)自己的状态与渲染里:跨端契约的
* `ProjectResourceCanvasCategory` 不新增 `'all'`,落盘布局的 `section` 也不会出现它,
* 因此既不需要改 sidecar schema,也不会污染写入侧的分区口径。
*/
export const RESOURCE_BOOK_ALL_TARGET = 'all' as const;
/** 画本可以打开的目标:真实栏目,或渲染层的「所有资源」。 */
export type ResourceBookTarget =
| ResourceBookCategory
| typeof RESOURCE_BOOK_ALL_TARGET;
export function isResourceBookAllTarget(
value: unknown,
): value is typeof RESOURCE_BOOK_ALL_TARGET {
return value === RESOURCE_BOOK_ALL_TARGET;
}
export type ResourceBookPhase = 'idle' | 'entering' | 'returning-main';
export type ResourceBookState = {
view: 'main' | 'child';
category: ResourceBookCategory | null;
category: ResourceBookTarget | null;
phase: ResourceBookPhase;
token: number;
};
export type ResourceBookAction =
| { type: 'open-category'; category: ResourceBookCategory; token: number }
| { type: 'open-category'; category: ResourceBookTarget; token: number }
| { type: 'finish-transition'; token: number }
| { type: 'return-to-main'; token: number }
| { type: 'reset'; token: number };
@@ -91,11 +112,16 @@ export type ResourceBookCardPresentation = 'child' | 'overview' | 'exiting';
export function resourceBookCategoryCardPresentation(
state: ResourceBookState,
category: ResourceBookCategory,
category: ResourceBookTarget,
): Exclude<ResourceBookCardPresentation, 'exiting'> | null {
if (state.view === 'main') {
return 'overview';
}
// 「所有资源」页自己按栏目渲染全部资源卡,画本场景不再复制一份;
// 否则同一张资源卡会在总览/全部资源页同时挂载(DOM 查询与 FLIP 键都会撞车)。
if (isResourceBookAllTarget(state.category)) {
return null;
}
if (category === state.category) {
return 'child';
}
@@ -1130,7 +1130,13 @@ export function registerProjectWorkbenchFoundationTests() {
),
).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT),
);
expect(screen.getByText('5 项')).not.toBeNull();
// 「N 项」徽标现在同样出现在总览第 0 张「所有资源」卡上(全量计数),
// 因此这里必须钉到 character 栏自己的标题栏,而不是按可见文本全文档查找。
expect(
document.querySelector(
'.game-resource-book-scene-titlebar[data-resource-book-category="character"] small',
)?.textContent,
).toBe('5 项');
const overviewCardLayers = Array.from(
document.querySelectorAll<HTMLElement>(
'.game-resource-book-scene-card[data-resource-book-category="character"]',
@@ -8599,4 +8605,204 @@ export function registerProjectAgentStatusTests() {
});
}
});
/**
* 0
*
*
*
* 绿
*/
it('keeps an all-resources tile first in the overview and opens every section', async () => {
const manifest = createGameCreationAppManifest(
'workbench-all-resources',
'全部资源项目',
);
manifest.assets = [
{
id: 'all-resources-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'memory/all-resources.md',
source: { kind: 'generated', taskId: 'design-foundation' },
category: 'document',
},
{
id: 'all-resources-character',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/all-resources-character.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
category: 'character',
},
{
id: 'all-resources-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/all-resources-audio.mp3',
source: { kind: 'generated', taskId: 'audio-asset-plan' },
category: 'audio',
},
];
manifest.versions = [
{
versionId: 'all-resources-version',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [],
createdReason: 'initial',
createdAt: 1,
},
];
const allResources = projectResourcesFromReadModels(manifest, [], []);
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs([]);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: layoutRevision,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-all-resources',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
const readCountBadge = (selector: string) => {
const badge = document.querySelector<HTMLElement>(
`${selector} .game-resource-book-titlebar-meta small`,
);
const match = /^(\d+) $/u.exec(badge?.textContent ?? '');
expect(badge, `${selector} 的计数徽标缺失`).not.toBeNull();
expect(match, `${selector} 的计数徽标不是「N 项」`).not.toBeNull();
return Number(match![1]);
};
await waitFor(() =>
expect(
document.querySelector(
'.game-resource-book-scene-titlebar[data-resource-book-category="all"]',
),
).not.toBeNull(),
);
// 1) 总览里存在「所有资源」卡,并且排在栏目卡之前(第 0 张)。
const overviewGrid = document.querySelector<HTMLElement>(
'.game-resource-book-main-grid',
);
expect(overviewGrid).not.toBeNull();
const overviewTiles = Array.from(
overviewGrid!.querySelectorAll<HTMLElement>(
'.game-resource-book-thumbnail',
),
);
expect(
overviewTiles.map((tile) => tile.dataset.resourceBookCategory),
).toEqual([
'all',
'ui-interaction',
'character',
'scene',
'audio',
'document',
'unclassified',
'version',
]);
expect(screen.getByRole('button', { name: '打开所有资源' })).toBe(
overviewTiles[0],
);
// 2) 它的计数等于全量资源数,并且与各栏目计数之和同一口径(不另算一套)。
const allResourcesCount = readCountBadge(
'.game-resource-book-scene-titlebar[data-resource-book-category="all"]',
);
expect(allResourcesCount).toBe(allResources.length);
const perCategoryCounts = Array.from(
document.querySelectorAll<HTMLElement>(
'.game-resource-book-scene-titlebar[data-resource-book-category]',
),
)
.filter((titlebar) => titlebar.dataset.resourceBookCategory !== 'all')
.map((titlebar) =>
readCountBadge(
`.game-resource-book-scene-titlebar[data-resource-book-category="${titlebar.dataset.resourceBookCategory}"]`,
),
);
expect(perCategoryCounts.reduce((total, count) => total + count, 0)).toBe(
allResourcesCount,
);
// 3) 点它进入的视图包含来自多个栏目的资源,而不是某一个栏目。
fireEvent.click(screen.getByRole('button', { name: '打开所有资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-all-host="true"]'),
).not.toBeNull(),
);
const expectSortedCategories = (categories: string[]) =>
[...categories].sort();
const allSections = Array.from(
document.querySelectorAll<HTMLElement>(
'.game-resource-all-section[data-resource-book-category]',
),
);
const expectedSections = expectSortedCategories(
Array.from(new Set(allResources.map((resource) => resource.category))),
);
expect(
expectSortedCategories(
allSections.map((section) => section.dataset.resourceBookCategory!),
),
).toEqual(expectedSections);
expect(allSections.length).toBeGreaterThan(1);
for (const section of allSections) {
expect(
section.querySelectorAll('.game-resource-card').length,
).toBeGreaterThan(0);
}
// 汇总页自己的标题栏也用同一份全量计数。
expect(readCountBadge('.game-resource-all-page')).toBe(allResourcesCount);
// 汇总页复用栏目大纲:第 0 项是「所有资源」,并标成当前页。
const allOutline = screen.getByLabelText('资源栏目大纲');
expect(
allOutline.querySelector('[aria-current="page"]')?.textContent?.trim(),
).toBe('所有资源');
});
}
@@ -1,19 +1,28 @@
import { describe, expect, it } from 'vitest';
import {
buildResourceBookAllResourcesSections,
buildResourceBookScenePlan,
groupResourceBookStacks,
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
resourceBookOverviewCardLayout,
type ResourceBookOverviewRect,
resourceBookSceneCardKey,
selectResourceBookOverviewCards,
} from '../src/view/project-development/resourceBookLayout';
import {
RESOURCE_BOOK_ALL_TARGET,
type ResourceBookTarget,
} from '../src/view/project-development/resourceBookModel';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
function resource(id: string): ProjectResource {
function resource(
id: string,
category: ProjectResource['category'] = 'unclassified',
): ProjectResource {
return {
id,
category: 'unclassified',
category,
subtype: 'image',
label: id,
path: `assets/${id}.png`,
@@ -278,4 +287,90 @@ describe('buildResourceBookScenePlan', () => {
keys.filter((key) => key === resourceBookSceneCardKey('art-0')),
).toHaveLength(1);
});
/**
* 资源总览第 0 张「所有资源」卡走的是同一条渲染管线:它有栏目标题栏(计数即全量
* 资源数),但正文不铺卡片——同一张资源卡在各栏目摞里已经渲染过,再铺一遍会让
* `data-resource-card-id` 与 FLIP 的 `card:*` 键在同一份计划里出现两次。
*/
it('reports the full count for the all-resources tile without duplicating cards', () => {
const documentResource = resource('design-0', 'document');
const allResources = [...resources, documentResource];
const allPositions = new Map([
...positions,
[documentResource.id, { x: 900, y: 0 }],
]);
const allCardSizes = new Map([
...cardSizes,
[documentResource.id, { width: 180, height: 128 }],
]);
const plan = buildResourceBookScenePlan({
...base,
visibleCategoryOrder: [
RESOURCE_BOOK_ALL_TARGET,
'unclassified',
'document',
],
resourcesByCategory: new Map<ResourceBookTarget, ProjectResource[]>([
[RESOURCE_BOOK_ALL_TARGET, allResources],
['unclassified', resources],
['document', [documentResource]],
]),
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>([
[RESOURCE_BOOK_ALL_TARGET, { ...rect, left: 0, top: 0 }],
['unclassified', rect],
['document', { ...rect, left: 500 }],
]),
positions: allPositions,
cardSizes: allCardSizes,
visibleResourceIds: new Set(allResources.map((item) => item.id)),
categoryViewCenters: new Map([
['unclassified', { x: 0, y: 64 }],
['document', { x: 900, y: 64 }],
]),
state: { view: 'main', category: null, phase: 'idle', token: 0 },
});
const allGroup = plan.find(
(group) => group.category === RESOURCE_BOOK_ALL_TARGET,
)!;
expect(allGroup.resourceCount).toBe(allResources.length);
expect(allGroup.cards).toHaveLength(0);
expect(allGroup.titlebarActive).toBe(false);
expect(allGroup.titlebarOpacity).toBe(1);
// 各栏目自己的摞照旧,且整份计划里没有任何一张资源卡被挂两次。
const cardKeys = plan
.flatMap((group) => group.cards)
.map((card) => card.key);
expect(new Set(cardKeys).size).toBe(cardKeys.length);
expect(
plan
.find((group) => group.category === 'document')!
.cards.map((card) => card.key),
).toEqual([resourceBookSceneCardKey('design-0')]);
expect(
plan.find((group) => group.category === 'unclassified')!.cards.length,
).toBeGreaterThan(0);
});
});
describe('buildResourceBookAllResourcesSections', () => {
it('keeps the overview section order and drops empty sections', () => {
const sections = buildResourceBookAllResourcesSections({
categoryOrder: ['ui-interaction', 'character', 'audio'],
resourcesByCategory: new Map([
['ui-interaction', [resource('ui-0', 'ui-interaction')]],
['character', []],
['audio', [resource('bgm-0', 'audio'), resource('bgm-1', 'audio')]],
]),
});
expect(
sections.map((section) => [section.category, section.resources.length]),
).toEqual([
['ui-interaction', 1],
['audio', 2],
]);
});
});
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
initialResourceBookState,
isResourceBookAllTarget,
RESOURCE_BOOK_ALL_TARGET,
resourceBookCategoryCardPresentation,
resourceBookReducer,
resourceBookSceneCategory,
@@ -162,3 +164,54 @@ describe('resource book card presentation', () => {
expect(resourceBookShowsExitingCards(initialResourceBookState)).toBe(false);
});
});
describe('resource book all-resources target', () => {
it('takes the overview stack as the first tile on the main canvas', () => {
expect(
resourceBookCategoryCardPresentation(
initialResourceBookState,
RESOURCE_BOOK_ALL_TARGET,
),
).toBe('overview');
});
it('lets the all-resources page render every category without a second copy', () => {
const all = resourceBookReducer(
resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: RESOURCE_BOOK_ALL_TARGET,
token: 1,
}),
{ type: 'finish-transition', token: 1 },
);
expect(all).toMatchObject({
view: 'child',
category: RESOURCE_BOOK_ALL_TARGET,
phase: 'idle',
});
expect(resourceBookSceneCategory(all)).toBe(RESOURCE_BOOK_ALL_TARGET);
// 「所有资源」页自己按栏目渲染全部资源卡;画本场景不再复制一份,否则同一张
// 资源卡会挂载两次(DOM 查询与 FLIP 键都会撞车)。
for (const category of [
'ui-interaction',
'character',
'scene',
'audio',
'document',
'unclassified',
'version',
] as const) {
expect(resourceBookCategoryCardPresentation(all, category)).toBeNull();
}
expect(
resourceBookCategoryCardPresentation(all, RESOURCE_BOOK_ALL_TARGET),
).toBeNull();
});
it('identifies the render-layer sentinel without widening the section contract', () => {
expect(isResourceBookAllTarget(RESOURCE_BOOK_ALL_TARGET)).toBe(true);
expect(isResourceBookAllTarget('unclassified')).toBe(false);
expect(isResourceBookAllTarget(null)).toBe(false);
});
});