把资源卡的 @ 引用入口挪进选中资源工具条

- 删除资源卡右上角的 @ 引用圆钮,连同它的 44×44 热区伪元素与全部样式一起退役
- 选中资源的画布工具条新增「@引用」动作,排在「UI 编辑器」之后,出站负载与来源逐字未变(source: 'resource-card')
- 测试:原「卡片圆钮视觉尺寸」用例改写为「入口只留在工具条里」的源级 + 样式级断言,并补反向断言卡片组件内不再出现插入调用
- 测试:新增键盘通路用例,聚焦后回车必须真正派发 agc-resource-reference-insert,且两条通路负载逐字相同、每次激活只派发一次
- harness 注释同步:资源卡本体只剩「选中资源」与媒体播放钮
This commit is contained in:
2026-09-11 16:28:48 +08:00
parent 3bfc3f0849
commit d80011656b
4 changed files with 204 additions and 128 deletions
-53
View File
@@ -7025,59 +7025,6 @@ iframe.preview-frame {
outline-offset: 1px;
}
.game-resource-card-reference {
position: absolute;
right: 49px;
/* 与右侧 34px 播放钮圆心对齐:9 + 34 / 2 = 14 + 24 / 2。 */
bottom: 14px;
z-index: 2;
display: grid;
/* 视觉按 0.8× 缩小:30 → 24,内部图标 16 → 13 同步缩。 */
width: 24px;
height: 24px;
padding: 0;
border: 1px solid rgb(255 255 255 / 70%);
border-radius: 50%;
background: rgb(75 48 38 / 82%);
color: #fff;
box-shadow: 0 5px 14px rgb(52 30 22 / 24%);
cursor: pointer;
place-items: center;
}
/*
* 视觉缩小但热区不缩:伪元素撑出 44×44(本仓 window chrome / 素材选择列表同口径的最小
* 可点尺寸),只扩命中范围,不参与布局也不改变绘制;整体向左让 4px,让热区右边缘正好与
* 右侧 34px 播放钮的盒子相邻而不覆盖它。
*/
.game-resource-card-reference::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 44px;
border-radius: 50%;
transform: translate(calc(-50% - 4px), -50%);
}
.game-resource-card-reference svg {
width: 13px;
height: 13px;
}
.game-resource-card:not(:has(.game-resource-card-media-control))
.game-resource-card-reference {
right: 9px;
}
.game-resource-card-reference:hover,
.game-resource-card-reference:focus-visible {
background: #c96f44;
outline: 2px solid rgb(255 255 255 / 92%);
outline-offset: 1px;
}
.game-resource-card-placeholder,
.game-resource-card-version-visual,
.game-resource-card-audio-visual {
@@ -894,30 +894,6 @@ const ResourceCard = memo(function ResourceCard({
)}
</button>
) : null}
{resource.manifestAssetId ? (
<button
type="button"
className="game-resource-card-reference"
aria-label={`引用资源 ${resource.label}`}
title="@引用"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
dispatchResourceReferenceInsert({
type: 'resource',
resourceId: resource.manifestAssetId!,
kind: resource.subtype || resource.category,
mediaType: resource.mediaType,
label: resource.label,
category: projectResourceAssetCategory(resource),
tags: resource.assetTags ?? [],
source: 'resource-card',
});
}}
>
<AtSign size={16} aria-hidden="true" />
</button>
) : null}
</div>
);
});
@@ -5742,6 +5718,34 @@ export default function ProjectDevelopmentView({
<span>UI </span>
</CanvasChromeButton>
) : null}
{selectedResource?.manifestAssetId ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label={`引用资源 ${selectedResource.label}`}
title="@引用"
icon={<AtSign className="h-4 w-4" />}
onClick={() =>
dispatchResourceReferenceInsert({
type: 'resource',
resourceId:
selectedResource.manifestAssetId!,
kind:
selectedResource.subtype ||
selectedResource.category,
mediaType: selectedResource.mediaType,
label: selectedResource.label,
category:
projectResourceAssetCategory(
selectedResource,
),
tags: selectedResource.assetTags ?? [],
source: 'resource-card',
})
}
>
<span>@引用</span>
</CanvasChromeButton>
) : null}
{selectedResource?.manifestAssetId ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
@@ -332,8 +332,8 @@ async function submitChat(value: string) {
fireEvent.click(screen.getByRole('button', { name: '发送' }));
}
// 同一张资源卡上有两个按钮:选中资源与 V3 新增的 @ 引用按钮,二者的可访问名都包含
// 资源文件名,因此按文件名正则查询会同时命中两个元素。选中按钮的可访问名模板固定为
// 同一张资源卡上的按钮变少了:卡片本体只有「选中资源」与媒体播放钮两个(原先右上角
// 那个 @ 引用圆钮已挪进选中工具条)。选中按钮的可访问名模板固定为
// `选中资源:<分类标签> <资源文件名>`;分类标签可能自带空格(例如 `UI 交互`),
// 这里按模板做精确匹配。
function escapeRegExpLiteral(value: string) {
@@ -1,7 +1,10 @@
import userEvent from '@testing-library/user-event';
import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasPosition,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences';
import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout';
import {
RESOURCE_CANVAS_CARD_WIDTH,
@@ -2899,53 +2902,15 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styles).not.toMatch(/\.game-resource-card-open\s*\{/);
});
it('keeps the resource card @ reference button visually small without shrinking its hit area', () => {
it('资源卡的 @ 引用入口只留在选中工具条里', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const reference = styleRuleBody(styles, '\\.game-resource-card-reference');
const referenceSize = styleNumber(reference, 'width');
// 视觉按 0.8× 缩:原 30×30 圆钮 → 24×24,内部图标 16 → 13。
expect(referenceSize).toBe(24);
expect(styleNumber(reference, 'height')).toBe(24);
expect(referenceSize).toBeLessThan(30);
const icon = styleRuleBody(styles, '\\.game-resource-card-reference svg');
expect(styleNumber(icon, 'width')).toBe(13);
expect(styleNumber(icon, 'height')).toBe(13);
expect(styleNumber(icon, 'width')).toBeLessThan(16);
// 卡片右上角那个圆钮连同它撑出来的 44×44 热区一起退役:卡片本体只剩
// 「选中资源」与媒体播放钮,卡片 chrome 用例(上一条)继续钉这条口径。
expect(styles).not.toContain('.game-resource-card-reference');
// 热区不跟着缩:伪元素撑到 44×44(本仓 window chrome / 素材选择列表同口径的最小可点尺寸)。
const hitArea = styleRuleBody(
styles,
'\\.game-resource-card-reference::before',
);
const hitSize = styleNumber(hitArea, 'width');
expect(hitSize).toBeGreaterThanOrEqual(44);
expect(styleNumber(hitArea, 'height')).toBeGreaterThanOrEqual(44);
expect(hitSize).toBeGreaterThan(referenceSize);
expect(hitArea).toMatch(/position:\s*absolute/u);
// 热区向左让开后与右侧 34px 播放钮的盒子相邻而不覆盖:不许为了撑热区去挤压播放钮。
const shiftLeft = Number(
/translate\(calc\(-50% - (\d+)px\), -50%\)/u.exec(hitArea)?.[1],
);
expect(shiftLeft).toBeGreaterThan(0);
const playButton =
/\.game-resource-card-media-control\s*\{[^}]*right:\s*(\d+)px[^}]*width:\s*(\d+)px[^}]*\}/su.exec(
styles,
);
expect(playButton).not.toBeNull();
const playBoxLeftFromRight =
Number(playButton![1]) + Number(playButton![2]);
const hitAreaLeftFromRight =
styleNumber(reference, 'right') +
referenceSize / 2 -
hitSize / 2 +
shiftLeft;
expect(hitAreaLeftFromRight).toBeGreaterThanOrEqual(playBoxLeftFromRight);
// 语义未动:入口仍在资源卡上,仍是「引用资源 <label>」触发插入素材引用。
const source = readFileSync(
resolve(
process.cwd(),
@@ -2953,9 +2918,169 @@ export function registerProjectWorkbenchFoundationTests() {
),
'utf8',
);
expect(source).toMatch(
/className="game-resource-card-reference"[\s\S]*?aria-label=\{` \$\{resource\.label\}`\}[\s\S]*?dispatchResourceReferenceInsert\(\{/u,
expect(source).not.toContain('game-resource-card-reference');
// 反向守卫:卡片组件内部不能再出现任何插入素材引用的调用。
const cardStart = source.indexOf('function ResourceCard(');
const cardEnd = source.indexOf('function ResourceBookThumbnail(');
expect(cardStart).toBeGreaterThan(0);
expect(cardEnd).toBeGreaterThan(cardStart);
expect(source.slice(cardStart, cardEnd)).not.toContain(
'dispatchResourceReferenceInsert',
);
// 入口挪进选中工具条的 `extraActions`:用插槽边界切片,确保它落在宿主编排层
// 注入的动作里,而不是又被挂回卡片或塞到工具条外面。
const extraStart = source.indexOf('extraActions={');
const extraEnd = source.indexOf(
'onOpenQuickEditPanel={openResourceQuickEditPanel}',
);
expect(extraStart).toBeGreaterThan(0);
expect(extraEnd).toBeGreaterThan(extraStart);
const extraActions = source.slice(extraStart, extraEnd);
expect(extraActions).toContain(
'label={`引用资源 ${selectedResource.label}`}',
);
expect(extraActions).toContain('title="@引用"');
expect(extraActions).toContain('<span>@引用</span>');
expect(extraActions).toMatch(
/<CanvasChromeButton[\s\S]*?label=\{` \$\{selectedResource\.label\}`\}[\s\S]*?dispatchResourceReferenceInsert\(\{/u,
);
// 出站契约逐字未变:仍是 `resource-card` 来源的 resource 引用。
const insertStart = extraActions.indexOf(
'dispatchResourceReferenceInsert({',
);
expect(insertStart).toBeGreaterThan(0);
expect(extraActions.slice(insertStart)).toMatch(
/\{\s*type: 'resource',\s*resourceId:\s*selectedResource\.manifestAssetId!,\s*kind:\s*selectedResource\.subtype \|\|\s*selectedResource\.category,\s*mediaType: selectedResource\.mediaType,\s*label: selectedResource\.label,\s*category:\s*projectResourceAssetCategory\(\s*selectedResource,\s*\),\s*tags: selectedResource\.assetTags \?\? \[\],\s*source: 'resource-card',/u,
);
});
it('工具条里的 @引用 用键盘也能插进聊天输入框', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-reference',
'引用入口测试',
);
manifest.assets.push({
id: 'scene-hero',
kind: 'image',
category: 'scene',
mediaType: 'image/png',
localPath: 'assets/hero.png',
tags: ['主舞台'],
source: { kind: 'generated' },
});
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_media_preview') {
return {
path: 'assets/hero.png',
mediaType: 'image/png',
byteLen: 48,
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '引用入口测试',
projectPath: '/tmp/workbench-resource-reference',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('场景与环境');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
const referenceButton = within(toolbar).getByRole('button', {
name: '引用资源 hero.png',
});
// 原生按钮 = 天生进 Tab 序;标题沿用「@引用」,与卡片入口时期的说法一致。
expect(referenceButton.tagName).toBe('BUTTON');
expect(referenceButton.getAttribute('type')).toBe('button');
expect(referenceButton.getAttribute('title')).toBe('@引用');
const inserted: unknown[] = [];
const onInsert = (event: Event) => {
inserted.push(
(event as CustomEvent<{ reference: unknown }>).detail.reference,
);
};
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
try {
// 键盘通路:聚焦后回车确认。App 侧监听同一个事件并把它插成输入框里的引用 chip
// `App.tsx` 的 `handleResourceReferenceInsert`),所以这里钉的是真链路而不是按钮长相。
referenceButton.focus();
expect(document.activeElement).toBe(referenceButton);
await userEvent.setup().keyboard('{Enter}');
expect(inserted).toHaveLength(1);
// 鼠标通路仍然只派发一次;两条通路带的是逐字相同的出站负载。
fireEvent.click(referenceButton);
expect(inserted).toHaveLength(2);
} finally {
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
}
expect(inserted).toEqual([
{
type: 'resource',
resourceId: 'scene-hero',
kind: 'image',
mediaType: 'image/png',
label: 'hero.png',
category: 'scene',
tags: ['主舞台'],
source: 'resource-card',
},
{
type: 'resource',
resourceId: 'scene-hero',
kind: 'image',
mediaType: 'image/png',
label: 'hero.png',
category: 'scene',
tags: ['主舞台'],
source: 'resource-card',
},
]);
});
it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => {
const manifest = createGameCreationAppManifest(
@@ -3146,10 +3271,10 @@ export function registerProjectWorkbenchFoundationTests() {
),
).toBe(false);
// 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」),
// 并且只渲染宿主编排层真实接通的动作:「编辑标签」(面板只编辑 manifest
// `assets[].tags`,分类不再有手动入口)「重命名」已接面板
// 「下载按钮」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调,
// 不能再渲染成点了没反应的按钮。
// 并且只渲染宿主编排层真实接通的动作:「@引用」(从卡片挪进工具条的引用入口,
// 资源卡上的圆钮已删除)「编辑标签」(面板只编辑 manifest `assets[].tags`
// 分类不再有手动入口)「重命名」已接面板,「下载按钮」复用资源面板同一条落盘
// 链路,「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。
const audioToolbar = screen.getByRole('toolbar', {
name: '素材工具栏',
});
@@ -3160,7 +3285,7 @@ export function registerProjectWorkbenchFoundationTests() {
within(audioToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['编辑标签', '重命名', '下载按钮']);
).toEqual(['引用资源 bgm.mp3', '编辑标签', '重命名', '下载按钮']);
// 工具条的「下载按钮」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制,
// 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了