From 7edc452c6ac615f074a627069b480d3a5dfe5eb3 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:08:38 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E9=87=8D=E6=9E=84AGC=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E8=8F=9C=E5=8D=95=E4=B8=8E=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88#409=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主菜单保留前五项,其余操作通过向上展开的更多菜单访问 类型与信息入口下沉资源卡片,并复用共享画布角标 补充菜单键盘、滚轮归属、换选及资源操作链路回归 同步方案A规范与待人工验收记录 --- .../resourceCanvasFocusModel.ts | 2 +- apps/ai-game-creator-shell/src/styles.css | 27 -- .../src/view/project-development/index.tsx | 90 +++-- .../tests/appSurface/home.suite.ts | 4 +- .../appSurface/project-development.suite.ts | 26 +- .../projectResourceLiveIntegration.test.tsx | 9 +- .../resourceCanvasFloatingDismiss.test.tsx | 2 + .../tests/resourceVersionReplacement.test.tsx | 97 ++++- .../【实施计划】AGC资源菜单收纳-2026-09-17.md | 10 + .../【里程碑】AGC资源菜单收纳-2026-09-17.md | 31 ++ .../shared-memory/team-conventions.md | 1 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + .../components/CanvasCardCornerActions.tsx | 67 ++++ .../src/components/OverflowActions.test.tsx | 206 ++++++++++ .../shared/src/components/OverflowActions.tsx | 237 ++++++++++++ packages/shared/src/components/index.ts | 2 + packages/shared/src/components/styles.css | 123 ++++++ ...ageCanvasSelectedLayerToolbarView.test.tsx | 44 +++ .../ImageCanvasSelectedLayerToolbarView.tsx | 359 +++++++++--------- .../ImageCanvasWorldView.test.tsx | 11 +- .../image-editor/ImageCanvasWorldView.tsx | 57 +-- vitest.config.ts | 7 + 22 files changed, 1068 insertions(+), 346 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md create mode 100644 packages/shared/src/components/CanvasCardCornerActions.tsx create mode 100644 packages/shared/src/components/OverflowActions.test.tsx create mode 100644 packages/shared/src/components/OverflowActions.tsx diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index 92ba12136..a61125c41 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -49,7 +49,7 @@ export function isResourceCanvasPanTarget( return Boolean( target.closest('.game-resource-card') && !target.closest( - 'button:not(.game-resource-card-select), input, textarea, select, a, audio, video', + 'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video', ), ); } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 613ec51e4..ed82b7507 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -7494,33 +7494,6 @@ iframe.preview-frame { visibility: visible; } -.game-resource-card-type-badge { - position: absolute; - top: 8px; - right: 8px; - z-index: 2; - display: inline-flex; - max-width: calc(100% - 16px); - align-items: center; - min-width: 0; - padding: 4px 8px; - overflow: hidden; - border: 1px solid rgb(255 255 255 / 72%); - border-radius: 999px; - background: rgb(75 48 38 / 84%); - color: #fff; - font-size: 10px; - font-weight: 900; - line-height: 1; - letter-spacing: 0.02em; - pointer-events: none; - text-overflow: ellipsis; - white-space: nowrap; - box-shadow: 0 8px 18px rgb(96 62 47 / 20%); - transform: scale(var(--genarrative-image-canvas-inverse-scale, 1)); - transform-origin: top right; -} - /* 替换血缘标注(本次会话内有效):源素材卡「已被 … 替换」/ 替换素材卡「替换自 …」。 左下角是卡片上唯一空闲的角(右上角标是类型、右下是媒体播放钮),用「当前版本」同一支橙色 把这条关系与光环联系起来。 */ 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 e660c2c73..cd0e4276a 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 @@ -15,6 +15,7 @@ import { SelectionOverlay, } from '@genarrative/image-canvas-react'; import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; +import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AtSign, Crosshair, @@ -42,7 +43,6 @@ import { Replace, RotateCcw, Search, - Shapes, SlidersHorizontal, Sparkles, Trash2, @@ -695,6 +695,9 @@ const ResourceCard = memo(function ResourceCard({ cardSize, activeMediaIdentity, onSelect, + onShowInfo, + onChangeType, + infoPressed, onPointerDown, onPointerMove, onPointerUp, @@ -724,6 +727,9 @@ const ResourceCard = memo(function ResourceCard({ cardSize: ResourceCanvasCardSize; activeMediaIdentity: string | null; onSelect: (resourceId: string, options?: { append?: boolean }) => void; + onShowInfo: (resource: ProjectResource) => void; + onChangeType: (assetId: string) => void; + infoPressed: boolean; onPointerDown: ( event: ReactPointerEvent, resource: ProjectResource, @@ -1072,13 +1078,19 @@ const ResourceCard = memo(function ResourceCard({ - - {cardTypeLabel} - + onChangeType(resource.manifestAssetId!) + : undefined + } + onInfoClick={() => onShowInfo(resource)} + /> {lineage ? ( // 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据 // (稳定 id 见卡上的 `data-resource-replaced-by` / `data-resource-replacement-of`)。 @@ -1604,7 +1616,12 @@ export default function ProjectDevelopmentView({ const [characterAnimationPanel, setCharacterAnimationPanel] = useState(null); /** 画布上的只读信息浮层(「信息」动作的落点),与运行页签的信息面板同源。 */ - const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false); + const [resourceInfoResourceId, setResourceInfoResourceId] = useState< + string | null + >(null); + const resourceInfoPanelOpen = + resourceInfoResourceId !== null && + selectedResourceIds[0] === resourceInfoResourceId; const [resourceCanvasMarquee, setResourceCanvasMarquee] = useState(null); /** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */ @@ -1909,7 +1926,7 @@ export default function ProjectDevelopmentView({ */ const clearResourceCanvasFocus = useCallback(() => { setSelectedResourceIds([]); - setResourceInfoPanelOpen(false); + setResourceInfoResourceId(null); if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) { return; } @@ -2333,7 +2350,9 @@ export default function ProjectDevelopmentView({ * 是因为选中本身有多个清空入口(清焦点、切视图、切项目)。 */ useEffect(() => { - setResourceInfoPanelOpen(false); + setResourceInfoResourceId((current) => + current === selectedResourceId ? current : null, + ); }, [selectedResourceId]); const projectVersions = useMemo( () => manifest.versions ?? [], @@ -6262,6 +6281,16 @@ export default function ProjectDevelopmentView({ const showRunUnavailableHint = !runAvailable && !uiEditorRoute; + const showResourceCardInfo = useCallback( + (resource: ProjectResource) => { + handleResourceSelect(resource.id); + setResourceInfoResourceId((current) => + current === resource.id ? null : resource.id, + ); + }, + [handleResourceSelect], + ); + const renderResourceBookCard = useCallback( ( resource: ProjectResource, @@ -6319,6 +6348,9 @@ export default function ProjectDevelopmentView({ } activeMediaIdentity={activeCardMediaIdentity} onSelect={handleResourceSelect} + onShowInfo={showResourceCardInfo} + onChangeType={setResourceTypeAssetId} + infoPressed={resourceInfoResourceId === resource.id} onPointerDown={(event) => handleResourceCardPointerDown( event, @@ -6350,6 +6382,8 @@ export default function ProjectDevelopmentView({ handleResourceCardPointerMove, handleResourceCardPointerUp, handleResourceSelect, + showResourceCardInfo, + resourceInfoResourceId, resourceCardDragPreview, resourceCardPreviews, resourceReplacementLineageBadgeMap, @@ -7976,6 +8010,7 @@ export default function ProjectDevelopmentView({ ) || selectedResourceOpensUiEditor) ? ( 引用 ) : null} - {selectedResource ? ( - } - onClick={() => - setResourceInfoPanelOpen((open) => !open) - } - > - 信息 - - ) : null} {selectedResource?.manifestAssetId ? ( 编辑标签 ) : null} - {selectedResource?.manifestAssetId ? ( - } - onClick={() => - setResourceTypeAssetId( - selectedResource.manifestAssetId, - ) - } - > - 素材类型 - - ) : null} {selectedResource?.manifestAssetId ? ( setResourceInfoPanelOpen(false)} + onClose={() => setResourceInfoResourceId(null)} /> ) : null} diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index a1ab75aad..6de24b699 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -338,7 +338,7 @@ export function registerClientHomeTests() { expect(await findResourceSelectButton('live-hero.png')).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( - await screen.findByRole('button', { name: /版本 1/ }), + await findResourceSelectButton('版本 1'), ).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { @@ -509,7 +509,7 @@ export function registerClientHomeTests() { ).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( - await screen.findByRole('button', { name: /版本 1/ }), + await findResourceSelectButton('版本 1'), ).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { 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 636ec3d74..f4b95519e 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 @@ -3817,21 +3817,10 @@ export function registerProjectWorkbenchFoundationTests() { ]; await openResourceBookCategory('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - const toolbarLabels = within(toolbar) - .getAllByRole('button') - .map((button) => button.getAttribute('aria-label') ?? ''); - const infoButton = within(toolbar).getByRole('button', { name: '信息' }); - // 位置固定在「引用」之后、「编辑标签」之前:工具条上的顺序即功能顺序。 - expect(toolbarLabels.indexOf('信息')).toBeGreaterThan( - toolbarLabels.indexOf('引用资源 hero.png'), - ); - expect(toolbarLabels.indexOf('信息')).toBeLessThan( - toolbarLabels.indexOf('编辑标签'), - ); + const infoButton = await screen.findByRole('button', { name: '查看hero.png资源信息' }); expect(infoButton.getAttribute('aria-pressed')).toBe('false'); + // 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。 fireEvent.click(infoButton); const canvasPanel = await screen.findByRole('dialog', { name: '资源信息', @@ -3863,11 +3852,11 @@ export function registerProjectWorkbenchFoundationTests() { // Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。 fireEvent.click(await findResourceSelectButton('hero.png')); - const reopenedToolbar = await screen.findByRole('toolbar', { + await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click( - within(reopenedToolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看hero.png资源信息' }), ); expect( await screen.findByRole('dialog', { name: '资源信息' }), @@ -4075,10 +4064,7 @@ export function registerProjectWorkbenchFoundationTests() { ), ).toBe(false); // 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」), - // 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口, - // 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest - // `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」 - // 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程) + // 并且只渲染宿主编排层真实接通的五个动作;信息与类型由卡片角标承接。 // 「导出」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调, // 不能再渲染成点了没反应的按钮。 const audioToolbar = screen.getByRole('toolbar', { @@ -4093,9 +4079,7 @@ export function registerProjectWorkbenchFoundationTests() { .map((button) => button.getAttribute('aria-label')), ).toEqual([ '引用资源 bgm.mp3', - '信息', '编辑标签', - '素材类型', '重命名', '导出', '删除素材', diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 48938bee7..4234d82b8 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -1914,9 +1914,8 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); expect(await cardBadgeText('hero.png')).toBe('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + // 未选中资源也能直接从卡片类型角标进入,不依赖工具栏存在。 + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); const dialog = await screen.findByRole('dialog', { name: '设置素材类型', }); @@ -1993,7 +1992,7 @@ describe('project resource live canvas integration', () => { const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); // 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。 - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); await screen.findByRole('dialog', { name: '设置素材类型' }); fireEvent.click(document.body); expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull(); @@ -2073,7 +2072,7 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '信息' })); + fireEvent.click(screen.getByRole('button', { name: '查看hero.png资源信息' })); const infoPanel = await screen.findByRole('dialog', { name: '资源信息' }); // 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index 22018450b..98ee615bc 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -48,6 +48,7 @@ describe('resourceCanvasFocusModel', () => {
+ 信息
@@ -60,6 +61,7 @@ describe('resourceCanvasFocusModel', () => { } for (const id of [ 'play', + 'corner', 'video', 'input', 'editor', diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index 380692d66..cfbc324f9 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -375,6 +375,13 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { return { invoke, onActiveVersionChange, onPlay, onManifestChange }; } +function toolbarAction(toolbar: HTMLElement, name: string) { + const visible = within(toolbar).queryByRole('button', { name }); + if (visible) return visible; + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + return within(screen.getByRole('group', { name: '更多操作' })).getByRole('button', { name }); +} + async function selectCardAndOpenToolbar(label: string) { await waitFor(() => expect( @@ -538,13 +545,61 @@ function installResourceCardIntersectionObserver() { } describe('版本级资源替换', () => { + it('更多浮层滚轮不平移画布,Escape 只收菜单且换选不残留', async () => { + renderReplacementWorkbench(); + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + const menu = screen.getByRole('group', { name: '更多操作' }); + const viewport = () => document.querySelector('[data-resource-viewport]') + ?.getAttribute('data-resource-viewport'); + const before = viewport(); + expect(before).toBeTruthy(); + const wheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }); + act(() => { menu.dispatchEvent(wheel); }); + expect(wheel.defaultPrevented).toBe(false); + expect(viewport()).toBe(before); + + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBe(toolbar); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + + const scene = document.querySelector('.game-resource-book-scene')!; + act(() => { + scene.dispatchEvent(new WheelEvent('wheel', { + bubbles: true, cancelable: true, deltaY: 120, clientX: 90, clientY: 70, + })); + }); + expect(viewport()).not.toBe(before); + }); + + it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => { + renderReplacementWorkbench(); + await selectCardAndOpenToolbar('legacy.png'); + const lateInfo = screen.getByRole('button', { name: '查看late.png资源信息' }); + fireEvent.pointerDown(lateInfo, { button: 0 }); + fireEvent.click(lateInfo); + const panel = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(panel).getByText('late.png')).toBeTruthy(); + expect(lateInfo.getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(screen.getByRole('button', { name: '查看legacy.png资源信息' })); + const switched = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(switched).getByText('legacy.png')).toBeTruthy(); + expect(within(switched).queryByText('late.png')).toBeNull(); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + }); + it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => { const { invoke } = renderReplacementWorkbench(); // 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。 const lateToolbar = await selectCardAndOpenToolbar('late.png'); + fireEvent.mouseEnter(within(lateToolbar).getByRole('button', { name: '更多' })); expect( - within(lateToolbar).queryByRole('button', { name: '替换素材' }), + screen.queryByRole('button', { name: '替换素材' }), ).toBeNull(); expect( within(lateToolbar).getByRole('button', { name: '快速编辑' }), @@ -560,7 +615,7 @@ describe('版本级资源替换', () => { // 被当前版本绑定的素材:入口出现。 const sourceToolbar = await selectCardAndOpenToolbar('legacy.png'); expect( - within(sourceToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(sourceToolbar, '替换素材'), ).not.toBeNull(); }); @@ -569,7 +624,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -675,7 +730,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -709,7 +764,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -735,7 +790,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -787,7 +842,7 @@ describe('版本级资源替换', () => { ).length; const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -837,8 +892,11 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - const toolbarLabels = within(toolbar) - .getAllByRole('button') + const deleteButton = toolbarAction(toolbar, '删除素材'); + const toolbarLabels = [ + ...within(toolbar).getAllByRole('button'), + ...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole('button'), + ] .map((button) => button.getAttribute('aria-label') ?? ''); // 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。 expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan( @@ -848,9 +906,6 @@ describe('版本级资源替换', () => { toolbarLabels.indexOf('导出'), ); // 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。 - const deleteButton = within(toolbar).getByRole('button', { - name: '删除素材', - }); const divider = deleteButton.previousElementSibling; expect(divider?.getAttribute('class')).toMatch( /(?:image-canvas-editor__floating-toolbar-divider|genarrative-image-canvas__chrome-button)/, @@ -914,7 +969,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '删除素材' })); + fireEvent.click(toolbarAction(toolbar, '删除素材')); const dialog = await screen.findByRole('dialog', { name: '确认删除资源' }); fireEvent.click( within(dialog).getByRole('checkbox', { @@ -976,7 +1031,7 @@ describe('版本级资源替换', () => { ).toBeNull(); // 同一条工具条仍在(只读动作不受 manifest 身份影响),证明不是"整条工具条没渲染"。 expect( - within(toolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看草稿.png资源信息' }), ).not.toBeNull(); }); @@ -1056,7 +1111,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1084,7 +1139,7 @@ describe('版本级资源替换', () => { const { invoke, onManifestChange } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1155,7 +1210,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1238,7 +1293,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1274,7 +1329,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1357,7 +1412,7 @@ describe('版本级资源替换', () => { // 第一次:legacy → final。 const legacyToolbar = await selectCardAndOpenToolbar('legacy.png'); fireEvent.click( - within(legacyToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(legacyToolbar, '替换素材'), ); let dialog = await screen.findByRole('dialog', { name: '选择替换素材', @@ -1377,7 +1432,7 @@ describe('版本级资源替换', () => { // 第二次:final → final.webp(同一个工作台会话内)。 const finalToolbar = await selectCardAndOpenToolbar('final.png'); fireEvent.click( - within(finalToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(finalToolbar, '替换素材'), ); dialog = await screen.findByRole('dialog', { name: '选择替换素材' }); fireEvent.click( diff --git a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md new file mode 100644 index 000000000..96333cf43 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md @@ -0,0 +1,10 @@ +# AGC 资源菜单收纳实施计划 + +对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md),Issue #409,产品已确认方案 A。 + +1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。 +2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。 +3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。 +4. 并行执行定向 Vitest、AGC 类型检查、编码与文档索引检查,再自审整体调用链。 + +风险:portal 浮层点击外部判定、缩放角标与拖拽冲突、原测试依赖完整工具栏。回滚仅撤销本分支 UI 与文档修改;无数据迁移。首个检查点为组件用例通过,第二个为工作台集成与类型检查。真实客户端未测则明确保留待验收状态。 diff --git a/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md new file mode 100644 index 000000000..1c2ac43c9 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md @@ -0,0 +1,31 @@ +# AGC 资源菜单收纳 + +- Version: 1 +- Status: implemented,本地自动化通过,待真实客户端验收 +- Date: 2026-09-17 +- Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md + +## 范围与评审 + +仅调整前端入口和临时浮层状态,不修改资源命令、权限、持久化、后端或 Web 默认菜单行为。主菜单保留前 5 项,其余悬停/点击向上展开;类型与信息下沉卡片。产品已选定方案 A,边界与既有资源操作合同无冲突,按单里程碑实施。对应 Issue #409,已获得创建 Issue 与本地实施授权;推送、PR 与飞书写入仍需单独确认。 + +## 验收 + +- 动作顺序、禁用状态和回调保持一致,少于等于 5 项不出现更多。 +- 更多支持鼠标移入浮层、点击、键盘、外部关闭与视口约束。 +- 卡片类型、信息入口不触发拖拽;未选中卡直接看信息,换选不残留旧信息。 +- 定向组件与工作台测试、类型检查、编码/文档索引/diff 检查通过;真实客户端视觉和触摸板手感单独验收。 + +## 产品结论与验收待办 + +正式实现采用方案 A;方案 B 不进入工作台,A/B 演示只留在忽略目录供本地参考。 + +完整工作台回归的 2 项失败已定位为新增信息按钮导致“版本 1”模糊匹配重复,改为精确查询资源选中按钮,完整重跑通过。 + +## 验收证据 + +- `appSurface.test.ts`:450 项通过、20 项跳过。 +- 收纳/卡片、Web 工具栏/卡片、资源类型实时链路、替换/重命名、浮层判据、动作可用性:178 项通过;追加真实工作台「更多滚轮不平移画布、Escape 仅收菜单、换选收起」与「跨卡片信息身份」2 项通过。 +- 根目录类型检查与 AGC 类型检查(含 skill-pack / config 检查)通过;编码、文档索引与 diff 检查通过。 +- 浏览器中真实组件预览已检查上方展开、执行回调后关闭、卡片信息入口;预览仅用演示数据,不替代真实 AGC 客户端。 +- 剩余:真实客户端、原生保存对话框、触摸板操作人工验收。未推送,未创建 PR,未更新飞书。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 98662a4e2..6f64a3000 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -16,6 +16,7 @@ ## 开发中 +- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。 - 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。 - UI 开发优先复用现有公共组件;跨页面或跨端重复的视觉/交互模式应沉淀到 `packages/shared`,由现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。 - AGC 当前 Agent 与策划 Agent 的消息层级共用 `packages/shared` 的 `AgentMessageContent`:正文使用 `body`,思考、中间输出与工具调用使用 `process`;宿主不按 Agent 类型重新定义过程字号和颜色,错误状态保留语义色。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dec89f82b..5bb7d5633 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -15,6 +15,8 @@ ## 资源卡选中工具栏与导出 +- AGC 选中工具栏按既有动作顺序最多直接显示前 5 项(不计分隔线),剩余动作进入「更多」。悬停、点击及键盘均可展开独立纵向浮层,优先向上展开,窗口顶边空间不足时向下避让;浮层限制在窗口内,超高时自行滚动,不带动画布。动作执行、点击外部、Escape 或换选资源后关闭;禁用状态和原处理链路保持不变。Web 美术画布默认不折叠。 +- 「素材类型」与「信息」不占工具栏名额,改为资源卡右上角的类型标签和信息圆钮,与 Web 美术画布共用卡片控件。未选中卡片可直接打开信息;类型入口仅对 manifest 资产可用。控件不触发卡片拖拽或多选,信息面板仍复用运行页签的字段。 - 共享选中工具栏按实际显示的快速编辑、编辑动作、改造、导出与宿主动作组生成分隔线;空组不产生分隔线,不依赖宿主 CSS 隐藏重复线。 - AGC 所有具有本地文件路径的素材都显示带文字的「导出」按钮,位于工具栏末组的「删除素材」之前,两者之间不插入分隔线;「重命名」继续保留在前面的常规动作组。工具栏宽度上限为 `min(92vw, 800px)`,窄屏仍可横向滚动。图片、视频、音频、动画、UI、文档及其它文件共用 `isResourceCanvasExportable`,不按媒体类型限制导出;无文件路径及虚拟项目版本不提供文件导出入口。 - 导出继续复用 `saveProjectResourcesToDisk`:原生保存对话框选择路径,`save_local_project_asset_file` 复制原始文件字节,不转图片、不重编码、不另建 IPC。后端继续校验源文件、敏感路径和目标路径;取消不写文件,失败通过工作台提示。 diff --git a/packages/shared/src/components/CanvasCardCornerActions.tsx b/packages/shared/src/components/CanvasCardCornerActions.tsx new file mode 100644 index 000000000..4199f474c --- /dev/null +++ b/packages/shared/src/components/CanvasCardCornerActions.tsx @@ -0,0 +1,67 @@ +import { Info } from 'lucide-react'; +import type { CSSProperties, Ref } from 'react'; +import { PlatformIconButton } from './PlatformIconButton'; + +/** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */ +export function CanvasCardCornerActions({ + kindLabel, + kindAriaLabel, + kindClassName, + infoLabel, + style, + kindRef, + onKindClick, + onInfoClick, + infoPressed, +}: { + kindLabel?: string | null; + kindAriaLabel?: string; + kindClassName?: string; + infoLabel: string; + style?: CSSProperties; + kindRef?: Ref; + onKindClick?: () => void; + onInfoClick: () => void; + infoPressed?: boolean; +}) { + return ( + event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {kindLabel ? ( + { + if (onKindClick && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + onKindClick(); + } + }} + > + {kindLabel} + + ) : null} + + ); +} diff --git a/packages/shared/src/components/OverflowActions.test.tsx b/packages/shared/src/components/OverflowActions.test.tsx new file mode 100644 index 000000000..5f25b8d3a --- /dev/null +++ b/packages/shared/src/components/OverflowActions.test.tsx @@ -0,0 +1,206 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { OverflowActions } from './OverflowActions'; +import { CanvasCardCornerActions } from './CanvasCardCornerActions'; + +afterEach(cleanup); +describe('操作收纳与卡片角标', () => { + it('具名入口可收纳全部动作,并优先向上展开', () => { + render( + + + + , + ); + const trigger = screen.getByRole('button', { name: '素材处理' }); + expect(screen.getAllByRole('button')).toHaveLength(1); + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + left: 200, + right: 300, + top: 350, + bottom: 380, + width: 100, + height: 30, + x: 200, + y: 350, + toJSON: () => ({}), + }); + const height = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockReturnValue(100); + fireEvent.mouseEnter(trigger); + const group = screen.getByRole('group', { name: '素材处理操作' }); + expect(parseFloat(group.style.top)).toBeLessThan(350); + expect( + within(group) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['快速编辑', '改造']); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.queryByRole('group')).toBeNull(); + height.mockRestore(); + vi.restoreAllMocks(); + }); + it('前五项可见,跳过分隔符和空 fragment,悬停展示剩余项并执行原回调', () => { + const click = vi.fn(); + render( + + <> + + , + ); + expect(screen.getAllByRole('button')).toHaveLength(6); + expect(screen.queryByText('六')).toBeNull(); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + const group = screen.getByRole('group', { name: '更多操作' }); + expect( + within(group) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['六', '七']); + expect((screen.getByText('七') as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByText('六')); + expect(click).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('group')).toBeNull(); + }); + it('悬停后点击仍展开、移入浮层不消失、外部关闭,Escape 恢复焦点', () => { + vi.useFakeTimers(); + render( + + + + , + ); + const more = screen.getByRole('button', { name: '更多' }); + fireEvent.mouseEnter(more); + fireEvent.click(more); + fireEvent.mouseLeave(more); + fireEvent.mouseEnter(screen.getByRole('group')); + vi.advanceTimersByTime(200); + expect(screen.getByText('二')).toBeTruthy(); + fireEvent.keyDown(screen.getByText('二'), { key: 'Escape' }); + expect(screen.queryByRole('group')).toBeNull(); + expect(document.activeElement).toBe(more); + fireEvent.click(more); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole('group')).toBeNull(); + vi.useRealTimers(); + }); + it('不溢出不显示更多,默认保持 Web 原样', () => { + const view = render( + + + + , + ); + expect(screen.queryByText('更多')).toBeNull(); + view.rerender( + + {Array.from({ length: 10 }, (_, i) => ( + + ))} + , + ); + expect(screen.getAllByRole('button')).toHaveLength(10); + }); + it('动作减少至不溢出后关闭浮层,恢复动作不会自动重开', () => { + const view = render( + + + + , + ); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + expect(screen.getByRole('group')).toBeTruthy(); + view.rerender( + + + , + ); + expect(screen.queryByRole('group')).toBeNull(); + view.rerender( + + + + , + ); + expect(screen.queryByRole('group')).toBeNull(); + }); + it('浮层在窗口右下边界向上展开,方向键跳过禁用项', () => { + render( + + + + + + , + ); + const more = screen.getByRole('button', { name: '更多' }); + vi.spyOn(more, 'getBoundingClientRect').mockReturnValue({ + left: window.innerWidth - 35, + right: window.innerWidth, + top: window.innerHeight - 40, + bottom: window.innerHeight - 10, + width: 35, + height: 30, + x: 0, + y: 0, + toJSON: () => ({}), + }); + // jsdom 没有真实布局,提供浮层测量以验证向上定位。 + const height = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockReturnValue(200); + fireEvent.click(more); + const panel = screen.getByRole('group'); + expect(parseFloat(panel.style.top)).toBeLessThan(window.innerHeight - 40); + screen.getByText('二').focus(); + fireEvent.keyDown(screen.getByText('二'), { key: 'ArrowDown' }); + expect(document.activeElement).toBe(screen.getByText('四')); + fireEvent.keyDown(screen.getByText('四'), { key: 'Home' }); + expect(document.activeElement).toBe(screen.getByText('二')); + height.mockRestore(); + vi.restoreAllMocks(); + }); + it('卡片角标阻断指针和键盘冒泡,类型及信息分别执行', () => { + const parent = vi.fn(), + kind = vi.fn(), + info = vi.fn(); + render( +
+ +
, + ); + const label = screen.getByRole('button', { name: '素材类型' }); + fireEvent.pointerDown(label); + fireEvent.click(label); + fireEvent.keyDown(label, { key: 'Enter' }); + fireEvent.click(screen.getByRole('button', { name: '资源信息' })); + expect(kind).toHaveBeenCalledTimes(2); + expect(info).toHaveBeenCalledOnce(); + expect(parent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/OverflowActions.tsx b/packages/shared/src/components/OverflowActions.tsx new file mode 100644 index 000000000..d79421308 --- /dev/null +++ b/packages/shared/src/components/OverflowActions.tsx @@ -0,0 +1,237 @@ +import { + Children, + cloneElement, + Fragment, + isValidElement, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; + +type ActionProps = { children?: ReactNode; 'aria-hidden'?: boolean | 'true' }; + +function flatten(nodes: ReactNode, prefix = ''): ReactNode[] { + return Children.toArray(nodes).flatMap((node, index) => + isValidElement(node) && node.type === Fragment + ? flatten(node.props.children, `${prefix}${index}.`) + : [ + isValidElement(node) + ? cloneElement(node, { key: `${prefix}${index}` }) + : node, + ], + ); +} + +function isDivider(node: ReactNode) { + return ( + isValidElement(node) && + (node.props['aria-hidden'] === true || node.props['aria-hidden'] === 'true') + ); +} + +/** 只负责展示收纳;动作权限、禁用与执行仍由调用方提供。 */ +export function OverflowActions({ + children, + maxVisible = Infinity, + label = '更多', +}: { + children: ReactNode; + maxVisible?: number; + label?: string; +}) { + const [open, setOpen] = useState(false); + const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 320 }); + const trigger = useRef(null); + const panel = useRef(null); + const timer = useRef | null>(null); + const id = useId(); + const limit = Number.isFinite(maxVisible) + ? Math.max(0, Math.floor(maxVisible)) + : Infinity; + const nodes = flatten(children); + let count = 0; + const split = nodes.findIndex((node) => !isDivider(node) && ++count > limit); + const primary = split < 0 ? nodes : nodes.slice(0, split); + while (primary.length && isDivider(primary[primary.length - 1])) + primary.pop(); + const overflow = + split < 0 ? [] : nodes.slice(split).filter((node) => !isDivider(node)); + useEffect(() => { + if (overflow.length === 0) setOpen(false); + }, [overflow.length]); + const cancelClose = () => { + if (timer.current !== null) clearTimeout(timer.current); + timer.current = null; + }; + const show = () => { + cancelClose(); + setOpen(true); + }; + const scheduleClose = () => { + cancelClose(); + timer.current = setTimeout(() => { + if (!panel.current?.contains(document.activeElement)) setOpen(false); + }, 150); + }; + useEffect( + () => () => { + if (timer.current !== null) clearTimeout(timer.current); + }, + [], + ); + useLayoutEffect(() => { + if (!open || !overflow.length) return; + const update = () => { + const anchor = trigger.current?.getBoundingClientRect(); + if (!anchor) return; + const width = panel.current?.getBoundingClientRect().width ?? 200; + const height = panel.current?.scrollHeight ?? 320; + const below = window.innerHeight - anchor.bottom - 12; + const above = anchor.top - 12; + // 优先上展,留出卡片预览;窗口顶边空间不足时才向下避让。 + const down = above < 80 && below > above; + const maxHeight = Math.max(40, Math.min(360, down ? below : above)); + setPosition({ + left: Math.max( + 8, + Math.min(anchor.right - width, window.innerWidth - width - 8), + ), + top: down + ? anchor.bottom + 4 + : Math.max(8, anchor.top - Math.min(height, maxHeight) - 4), + maxHeight, + }); + }; + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + }; + }, [open, overflow.length, children]); + useEffect(() => { + if (!open) return; + const outside = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !trigger.current?.contains(event.target) && + !panel.current?.contains(event.target) + ) { + setOpen(false); + } + }; + const escape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + }; + document.addEventListener('pointerdown', outside); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('pointerdown', outside); + document.removeEventListener('keydown', escape); + }; + }, [open]); + if (!overflow.length) return <>{children}; + return ( + <> + {primary} + + {open + ? createPortal( +
event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + if ((event.target as Element).closest('button:not(:disabled)')) + setOpen(false); + }} + onBlur={(event) => { + if ( + !event.currentTarget.contains(event.relatedTarget) && + event.relatedTarget !== trigger.current + ) + setOpen(false); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + } + if ( + ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) + ) { + event.preventDefault(); + const buttons = Array.from( + event.currentTarget.querySelectorAll( + 'button:not(:disabled)', + ), + ); + const index = buttons.indexOf( + document.activeElement as HTMLButtonElement, + ); + const next = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? buttons.length - 1 + : (index + + (event.key === 'ArrowDown' ? 1 : -1) + + buttons.length) % + buttons.length; + buttons[next]?.focus(); + } + }} + > + {overflow} +
, + document.body, + ) + : null} + + ); +} diff --git a/packages/shared/src/components/index.ts b/packages/shared/src/components/index.ts index f2b1cee94..327a40757 100644 --- a/packages/shared/src/components/index.ts +++ b/packages/shared/src/components/index.ts @@ -220,6 +220,8 @@ export { Textarea } from './ui/textarea'; // existing application adapters while keeping the public API product-neutral. export type { AgentMessageTone } from './AgentMessageContent'; export { AgentMessageContent } from './AgentMessageContent'; +export { CanvasCardCornerActions } from './CanvasCardCornerActions'; +export { OverflowActions } from './OverflowActions'; export type { ButtonProps as PlatformButtonProps, SwitchProps as PlatformSwitchProps, diff --git a/packages/shared/src/components/styles.css b/packages/shared/src/components/styles.css index 2c969af7d..aa9301eec 100644 --- a/packages/shared/src/components/styles.css +++ b/packages/shared/src/components/styles.css @@ -1310,3 +1310,126 @@ textarea.genarrative-ui-text-field__control { border-radius: 1.25rem 1.25rem 0 0; } } +/* 画布卡片角标:缩放由宿主传入,两个入口保持一致的视觉尺寸。 */ +.shared-canvas-card-corners { + position: absolute; + top: 6px; + right: 6px; + z-index: 3; + display: flex; + align-items: center; + gap: 4px; + max-width: calc(100% - 12px); + transform: scale( + var( + --image-canvas-editor-inverse-scale, + var(--genarrative-image-canvas-inverse-scale, 1) + ) + ); + transform-origin: top right; +} +.shared-canvas-card-kind { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 4px 8px; + border: 1px solid rgb(255 255 255 / 72%); + border-radius: 999px; + background: rgb(199 101 61 / 92%); + color: #fff; + font-size: 11px; + font-weight: 850; + line-height: 1; +} +.shared-canvas-card-kind[role='button'] { + cursor: pointer; +} +.shared-canvas-card-info { + display: grid; + flex: 0 0 22px; + width: 22px; + height: 22px; + place-items: center; + border: 1px solid rgb(255 255 255 / 42%); + border-radius: 50%; + background: rgb(199 101 61 / 92%); + color: #fff; + cursor: pointer; +} +.shared-canvas-card-info:hover, +.shared-canvas-card-kind[role='button']:hover { + background: #b95d3a; +} +.shared-canvas-card-corners [role='button']:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} +.shared-overflow-trigger { + flex: none; + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 30px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + cursor: pointer; +} +.shared-overflow-trigger:hover, +.shared-overflow-trigger[aria-expanded='true'] { + background: #f4e7df; +} +.shared-overflow-panel { + position: fixed; + z-index: 1500; + display: flex; + flex-direction: column; + gap: 3px; + width: 200px; + max-width: calc(100vw - 16px); + overflow-y: auto; + overscroll-behavior: contain; + padding: 6px; + border: 1px solid #e7d5c8; + border-radius: 9px; + background: #fff; + color: #4b3026; + box-shadow: 0 10px 28px rgb(52 30 22 / 18%); +} +.shared-overflow-panel > button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + flex: 0 0 auto; + width: 100%; + min-height: 34px; + padding: 6px 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +.shared-overflow-panel > button:hover, +.shared-overflow-panel > button:focus-visible { + background: #f4e7df; +} +.shared-overflow-panel > button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.shared-overflow-panel + > button.genarrative-image-canvas__chrome-button:not( + .genarrative-image-canvas__chrome-button--with-label + )::after { + content: attr(aria-label); +} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 8b5a462fc..a2f6a607e 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -70,6 +70,50 @@ function renderSelectedToolbar( } describe('ImageCanvasSelectedLayerToolbarView', () => { + it('AGC 收纳包含宿主末组,换选后关闭旧资源更多菜单', () => { + const props = renderSelectedToolbar({ + maxVisibleActions: 5, + supportedActions: new Set([ + 'quick-edit', + 'character-animation', + 'download', + ]), + downloadLabel: '导出', + extraActions: ( + <> + + + + + ), + endActions: , + }); + const toolbar = screen.getByRole('toolbar'); + expect( + within(toolbar) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['快速编辑', '生成动画', '引用', '编辑标签', '重命名', '更多 ▴']); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + const group = screen.getByRole('group', { name: '更多操作' }); + expect( + within(group) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['导出', '删除素材']); + fireEvent.click(within(group).getByRole('button', { name: '导出' })); + expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer); + cleanup(); + const view = render(); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + view.rerender( + , + ); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + }); it('常规动作与末组分别分隔,导出紧邻删除且位于删除之前', () => { const props = renderSelectedToolbar({ supportedActions: new Set(['quick-edit', 'download']), diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index 6587025c0..f61909628 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -1,4 +1,5 @@ import { CanvasChromeButton } from '@genarrative/image-canvas-react'; +import { OverflowActions } from '@genarrative/shared/components'; import { Crop, Download, @@ -38,6 +39,7 @@ export type ImageCanvasSelectedToolbarAction = | 'download'; type ImageCanvasSelectedLayerToolbarViewProps = { + maxVisibleActions?: number; /** * 宿主显式声明的可用动作集合。 * @@ -78,6 +80,7 @@ function hasToolbarActions(actions: ReactNode): boolean { } export function ImageCanvasSelectedLayerToolbarView({ + maxVisibleActions, supportedActions = null, extraActions, endActions, @@ -148,22 +151,24 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="素材工具栏" onPointerDown={(event) => event.stopPropagation()} > - {canRedraw ? ( - } - onClick={() => onOpenRedrawPanel(selectedLayer)} - > - 改造 - - ) : null} - {canRedraw && hasExtraActions ? divider : null} - {extraActions} - {(canRedraw || hasExtraActions) && hasEndActions ? divider : null} - {downloadAction} - {endActions} + + {canRedraw ? ( + } + onClick={() => onOpenRedrawPanel(selectedLayer)} + > + 改造 + + ) : null} + {canRedraw && hasExtraActions ? divider : null} + {extraActions} + {(canRedraw || hasExtraActions) && hasEndActions ? divider : null} + {downloadAction} + {endActions} + ); } @@ -208,170 +213,172 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="图片工具栏" onPointerDown={(event) => event.stopPropagation()} > - {showQuickEdit ? ( - } - onClick={() => onOpenQuickEditPanel(selectedLayer)} - > - 快速编辑 - - ) : null} - {showQuickEdit && hasEditingActions ? divider : null} - {showCropExpand ? ( - onOpenCropExpandPanel(selectedLayer)} - /> - ) : null} - {showRemoveBackground ? ( - onRemoveBackground(selectedLayer)} - /> - ) : null} - {showPerfectPixel ? ( - - ) : ( - - ) - } - // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 - // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 - // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 - // 与相邻的拆分图集按钮保持同一套门禁。 - disabled={ - isPersistingAssetKind || - isPerfectPixelProcessing || - isPerfectPixelPendingConfirmation - } - aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} - onClick={() => onPerfectPixel(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isPerfectPixelProcessing - ? '处理中' - : isPerfectPixelPendingConfirmation - ? '待确认' - : '完美像素'} - - - ) : null} - {showSplitIconSpritesheet ? ( - - ) : ( - - ) - } - disabled={isPersistingAssetKind || isSplittingIconSpritesheet} - aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} - onClick={() => onSplitIconSpritesheet(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isSplittingIconSpritesheet - ? '拆图中' - : '拆分图集'} - - - ) : null} - {showExtractUiDesign ? ( - } - onClick={() => onExtractUiDesignAssets(selectedLayer)} - > - 提取素材 - - ) : null} - {showCharacterAnimation ? ( - } - onClick={() => onOpenCharacterAnimationPanel(selectedLayer)} - > - 生成动画 - - ) : null} - {canRedraw ? ( - <> - {showQuickEdit || hasEditingActions ? divider : null} + + {showQuickEdit ? ( } - onClick={() => onOpenRedrawPanel(selectedLayer)} + label="快速编辑" + title="快速编辑" + icon={} + onClick={() => onOpenQuickEditPanel(selectedLayer)} > - 改造 + 快速编辑 - - ) : null} - {(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions - ? divider - : null} - {extraActions} - {(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) && - hasEndActions - ? divider - : null} - {downloadAction} - {endActions} + ) : null} + {showQuickEdit && hasEditingActions ? divider : null} + {showCropExpand ? ( + onOpenCropExpandPanel(selectedLayer)} + /> + ) : null} + {showRemoveBackground ? ( + onRemoveBackground(selectedLayer)} + /> + ) : null} + {showPerfectPixel ? ( + + ) : ( + + ) + } + // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 + // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 + // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 + // 与相邻的拆分图集按钮保持同一套门禁。 + disabled={ + isPersistingAssetKind || + isPerfectPixelProcessing || + isPerfectPixelPendingConfirmation + } + aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} + onClick={() => onPerfectPixel(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isPerfectPixelProcessing + ? '处理中' + : isPerfectPixelPendingConfirmation + ? '待确认' + : '完美像素'} + + + ) : null} + {showSplitIconSpritesheet ? ( + + ) : ( + + ) + } + disabled={isPersistingAssetKind || isSplittingIconSpritesheet} + aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} + onClick={() => onSplitIconSpritesheet(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isSplittingIconSpritesheet + ? '拆图中' + : '拆分图集'} + + + ) : null} + {showExtractUiDesign ? ( + } + onClick={() => onExtractUiDesignAssets(selectedLayer)} + > + 提取素材 + + ) : null} + {showCharacterAnimation ? ( + } + onClick={() => onOpenCharacterAnimationPanel(selectedLayer)} + > + 生成动画 + + ) : null} + {canRedraw ? ( + <> + {showQuickEdit || hasEditingActions ? divider : null} + } + onClick={() => onOpenRedrawPanel(selectedLayer)} + > + 改造 + + + ) : null} + {(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions + ? divider + : null} + {extraActions} + {(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) && + hasEndActions + ? divider + : null} + {downloadAction} + {endActions} + ); } diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index ebc0b3b75..bd03cd7ab 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -814,7 +814,7 @@ describe('ImageCanvasWorldView', () => { expect( ( - within(layerButton).getByText('角色') as HTMLElement + within(layerButton).getByText('角色').parentElement as HTMLElement ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( @@ -825,6 +825,7 @@ describe('ImageCanvasWorldView', () => { expect( within(layerButton) .getByRole('button', { name: '查看角色主图图片信息' }) + .parentElement! .style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( @@ -1118,12 +1119,8 @@ describe('ImageCanvasWorldView', () => { name: '查看角色主图图片信息', }); - expect(badge.className).toContain( - 'image-canvas-editor__kind-badge--beside-info', - ); - expect(metadataButton.className).not.toContain( - 'image-canvas-editor__metadata-corner--beside-kind', - ); + expect(badge.parentElement?.className).toBe('shared-canvas-card-corners'); + expect(badge.nextElementSibling).toBe(metadataButton); }); it('keeps the layer type menu scrollable and closes it when clicking outside', () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 42315907a..a9e1e9622 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -3,13 +3,13 @@ import { LayerRenderer, SelectionOverlay, } from '@genarrative/image-canvas-react'; +import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AppWindow, Clapperboard, ClipboardList, Grid2X2, ImageIcon, - Info, Megaphone, Mountain, Music, @@ -44,7 +44,6 @@ import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; -import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { @@ -367,17 +366,6 @@ function handleLayerKeyboardActivation(event: ReactKeyboardEvent) { event.currentTarget.click(); } -function handleLayerTagKeyboardActivation( - event: ReactKeyboardEvent, -) { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.click(); -} - function stopMediaControlKeyPropagation( event: ReactKeyboardEvent, ) { @@ -1012,38 +1000,19 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({ mediaTransform={mediaTransform} /> )} - {kindLabel ? ( - event.stopPropagation()} - onClick={(event) => { - event.stopPropagation(); - handlers.setOpenLayerKindMenuId((currentId) => - currentId === layer.id ? null : layer.id, - ); - }} - onKeyDown={handleLayerTagKeyboardActivation} - > - {kindLabel} - - ) : null} - } + { - event.stopPropagation(); - handlers.onOpenLayerMetadata(layer); - }} - onPointerDown={(event) => event.stopPropagation()} + onKindClick={() => + handlers.setOpenLayerKindMenuId((currentId) => + currentId === layer.id ? null : layer.id, + ) + } + onInfoClick={() => handlers.onOpenLayerMetadata(layer)} /> {isHovered && layer.mediaType !== 'audio' ? ( Date: Thu, 17 Sep 2026 20:17:26 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83PR=E7=9A=84CI=E5=9B=9E=E5=BD=92=EF=BC=88#410?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复活动回合空快照引用及停用后晚到请求覆盖问题 对齐共享卡片角标与窗口发布次数回归断言 同步原生HTTP权限检查与图集显式切片测试契约 整理菜单组件导入顺序并记录本地验证范围 --- .../src-tauri/src/tests/project.rs | 21 ++++++- .../src-tauri/src/tests/provider.rs | 10 +++- .../agent-runtime/directActiveTurns.ts | 27 ++++++--- .../src/view/project-development/index.tsx | 2 +- .../tests/directActiveTurns.test.tsx | 60 ++++++++++++++++++- .../tests/workspaceWindowSync.test.tsx | 8 +-- .../【实施计划】AGC资源菜单收纳-2026-09-17.md | 6 ++ docs/project-memory/shared-memory/pitfalls.md | 2 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + .../components/CanvasCardCornerActions.tsx | 1 + .../src/components/OverflowActions.test.tsx | 3 +- .../shared/src/components/OverflowActions.tsx | 2 +- scripts/check-native-shells.mjs | 1 - ...CanvasEditorGenerationIntegration.test.tsx | 4 +- .../ImageCanvasEditorView.test.tsx | 4 +- 15 files changed, 123 insertions(+), 29 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 5d746c7cc..b2f1bdac8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -728,13 +728,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "tool": "canvas.asset_generate", "reason": "生成可用于首版原型的主角素材", "input": { - "prompt": "透明 PNG 像素月光主角,适合厨房弹幕游戏", + "prompt": "透明 PNG 像素月光主角图集,按 2 行 2 列等分网格排布,适合厨房弹幕游戏", "outputPath": "assets/art-spritesheet.png", "aspectRatio": "1:1", "imageSize": "1K", "assetKind": "art-spritesheet", "assetLabel": "游戏首版核心美术素材", - "replaceExisting": false + "replaceExisting": false, + "sliceMode": "grid", + "gridX": 2, + "gridY": 2, + "sliceCount": null } } ], @@ -775,7 +779,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { start_game_creator_agent_background_task_at( &root, "art-asset-plan", - "为月光厨房生成首版主角素材", + "为月光厨房生成首版主角素材图集,按 2 行 2 列等分网格排布", "art-generate-run", ) .expect("start background task"); @@ -933,6 +937,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let generation_idempotency_key = request_header(generation_request, "idempotency-key").expect("generation idempotency key"); assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok()); + let generation_body: Value = serde_json::from_str( + generation_request + .split_once("\r\n\r\n") + .expect("generation request body") + .1, + ) + .expect("generation request json"); + assert_eq!(generation_body["sliceMode"], "grid"); + assert_eq!(generation_body["gridX"], 2); + assert_eq!(generation_body["gridY"], 2); + assert!(generation_body["sliceCount"].is_null()); assert!(generation_request.contains(r#""source":"ai-game-creator-client""#)); assert_eq!( canvas_requests diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index dd4946ea4..7bc72b7af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -6916,7 +6916,11 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "imageSize", "assetKind", "assetLabel", - "replaceExisting" + "replaceExisting", + "sliceMode", + "gridX", + "gridY", + "sliceCount" ]) ); assert_eq!( @@ -6927,6 +6931,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { canvas_asset.parameters["properties"]["input"]["properties"]["imageSize"]["enum"], serde_json::json!(["0.5K", "1K", "2K", null]) ); + assert_eq!( + canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"], + serde_json::json!(["connected-components", "grid", null]) + ); assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"], serde_json::json!([ diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index a9d4c478e..f7b69de22 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -45,7 +45,8 @@ export function useDirectActiveTurns({ * 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目 * 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。 */ - const lastSnapshotSignatureRef = useRef(''); + const lastSnapshotSignatureRef = useRef('[]'); + const requestGenerationRef = useRef(0); const retryTimerRef = useRef(null); useEffect(() => { @@ -60,13 +61,16 @@ export function useDirectActiveTurns({ }, []); const refreshActiveTurns = useCallback(async () => { - if (!invoke) { + if (!enabled || !invoke) { return; } // 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。 if (inFlightRef.current) { return inFlightRef.current; } + const generation = requestGenerationRef.current; + const isCurrent = () => + mountedRef.current && generation === requestGenerationRef.current; const request = (async () => { for ( let attempt = 1; @@ -77,7 +81,7 @@ export function useDirectActiveTurns({ const turns = await invoke( 'list_game_creator_direct_active_turns', ); - if (!mountedRef.current) { + if (!isCurrent()) { return; } const nextTurns = Array.isArray(turns) ? turns : []; @@ -90,6 +94,7 @@ export function useDirectActiveTurns({ inFlightRef.current = null; return; } catch { + if (!isCurrent()) return; if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { await new Promise((resolve) => { retryTimerRef.current = window.setTimeout(() => { @@ -97,22 +102,23 @@ export function useDirectActiveTurns({ resolve(); }, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt); }); + if (!isCurrent()) return; } } } // 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。 - if (mountedRef.current) { + if (isCurrent()) { setSnapshotReadFailed(true); + inFlightRef.current = null; } - inFlightRef.current = null; })(); inFlightRef.current = request; return request; - }, [invoke]); + }, [enabled, invoke]); useEffect(() => { if (!enabled || !invoke) { - lastSnapshotSignatureRef.current = ''; + lastSnapshotSignatureRef.current = '[]'; // 空态也要保持引用稳定:已经空了就不要再换一个新数组。 setActiveTurns((current) => (current.length === 0 ? current : [])); setSnapshotReadFailed((current) => (current ? false : current)); @@ -123,7 +129,12 @@ export function useDirectActiveTurns({ () => void refreshActiveTurns(), Math.max(1_000, pollIntervalMs), ); - return () => window.clearInterval(timer); + return () => { + window.clearInterval(timer); + // 停用或切换读取器后,旧请求不得覆盖新状态,也不能占住新一轮单飞。 + requestGenerationRef.current += 1; + inFlightRef.current = null; + }; }, [enabled, invoke, pollIntervalMs, refreshActiveTurns]); return { activeTurns, refreshActiveTurns, snapshotReadFailed }; 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 cd0e4276a..1675dabb4 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 @@ -14,8 +14,8 @@ import { CanvasChromeButton, SelectionOverlay, } from '@genarrative/image-canvas-react'; -import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { CanvasCardCornerActions } from '@genarrative/shared/components'; +import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { AtSign, Crosshair, diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 521cf634a..f7acdba4e 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -60,10 +60,11 @@ describe('useDirectActiveTurns', () => { { initialProps: { enabled: true } }, ); - await waitFor(() => { - expect(result.current.activeTurns).toEqual([]); - }); const emptySnapshot = result.current.activeTurns; + await act(async () => { + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(emptySnapshot); rerender({ enabled: false }); expect(result.current.activeTurns).toBe(emptySnapshot); }); @@ -96,6 +97,59 @@ describe('useDirectActiveTurns', () => { clearTimeoutSpy.mockRestore(); } }); + + it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => { + let complete!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi.fn( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + const empty = result.current.activeTurns; + await act(async () => { + complete([ACTIVE_TURN]); + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(empty); + expect(result.current.snapshotReadFailed).toBe(false); + expect(invoke).toHaveBeenCalledTimes(1); + }); + + it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => { + let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + completeOld = resolve; + }), + ) + .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + rerender({ enabled: true }); + await waitFor(() => + expect(result.current.activeTurns[0]?.runId).toBe('new-run'), + ); + const current = result.current.activeTurns; + await act(async () => { + completeOld([ACTIVE_TURN]); + }); + expect(result.current.activeTurns).toBe(current); + expect(invoke).toHaveBeenCalledTimes(2); + }); }); describe('ActiveProjectRunsPanel', () => { diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx index 457f61417..897a71ac9 100644 --- a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -84,8 +84,8 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 await act(async () => { await Promise.resolve(); }); - // 无原生 invoke 时 active-turn Hook 会把初始快照归一为空数组一次。 - expect(publications).toHaveLength(2); + // 无原生 invoke 时空快照引用不变,只发布一次。 + expect(publications).toHaveLength(1); expect(cleanups).toBe(0); expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe( 1, @@ -94,13 +94,13 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 const latestOpen = vi.fn(async () => undefined); homeProjectOverride.openProject = latestOpen; rendered.rerender(view('更改显示名')); - expect(publications).toHaveLength(2); + expect(publications).toHaveLength(1); act(() => openProject('/tmp/window-latest-project')); expect(latestOpen).toHaveBeenCalledWith( '/tmp/window-latest-project', 'open', ); - expect(publications).toHaveLength(2); + expect(publications).toHaveLength(1); expect(cleanups).toBe(0); rendered.unmount(); expect(cleanups).toBe(1); diff --git a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md index 96333cf43..6a8060348 100644 --- a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md +++ b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md @@ -2,6 +2,12 @@ 对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md),Issue #409,产品已确认方案 A。 +## PR #410 CI 修复 + +以远端合并提交 959beebf 为基线:修复菜单文件 import 排序、Web 角标结构断言、活动回合空快照与晚到请求竞态;窗口发布次数断言对齐稳定快照合同。原生 HTTP scope 检查对齐官方 updater 当前权限,不恢复退役 OSS 白名单;Rust 图集测试补齐显式切片模式与 strict schema 字段,不放宽正式校验。按故障项定向测试后运行前端全套及原生契约检查;Rust 使用独立 target,实际未执行的检查必须单独列出。推送需再次确认。 + +本地修复验证:`npm test` 342 个文件通过(4137 项通过、37 项跳过),窗口与空快照最后一次定向复验 9 项通过;`lint:eslint`、根目录/AGC 类型检查、原生 contract 检查、Rust fmt、编码、文档索引与 diff 检查通过。Rust 工具目录 schema 用例及后台平台美术生成用例均在 Windows 独立 target 下通过;生成用例同时检查真实 mock 请求中的 grid、2×2 参数与响应匹配。未执行全量 Rust 分片、Linux CI、生产服务或真实客户端手感验收。 + 1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。 2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。 3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 52b39d70a..633245b1a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -21,6 +21,8 @@ JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡 工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。 +活动回合快照的初始签名须与初始空数组一致,首次异步返回空数组不能额外换引用。停用、重新启用或切换读取器时应使旧请求失效,避免晚到结果覆盖新快照;测试需控制 Promise 完成时机,不能用“初始数组已为空”当作请求已结束。无原生读取器时窗口只发布一次空状态。 + ## 2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」 - **现象**:用户反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。查工具目录时两个工具都在(`agc_edit_image`、`agc_create_or_derive_resource`),图片快速编辑在真实项目日志里还有成功记录;但 agent 侧写一句正常长度的背景音乐描述就失败,而客户端 UI 用同一个提示词却只是被截断加提示。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 177465f2b..b1ee2415e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -12,6 +12,7 @@ ## 资源画布交互与工作台状态同步 +- 活动回合轮询的初始空快照与后续空结果保持同一引用;停用或切换读取器使旧请求失效,晚到快照不得恢复已停用的活动回合或覆盖新轮询结果。无原生读取器时窗口只发布一次空状态,不通过额外空数组触发重复发布。 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 - 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。 - 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。 diff --git a/packages/shared/src/components/CanvasCardCornerActions.tsx b/packages/shared/src/components/CanvasCardCornerActions.tsx index 4199f474c..9996e8923 100644 --- a/packages/shared/src/components/CanvasCardCornerActions.tsx +++ b/packages/shared/src/components/CanvasCardCornerActions.tsx @@ -1,5 +1,6 @@ import { Info } from 'lucide-react'; import type { CSSProperties, Ref } from 'react'; + import { PlatformIconButton } from './PlatformIconButton'; /** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */ diff --git a/packages/shared/src/components/OverflowActions.test.tsx b/packages/shared/src/components/OverflowActions.test.tsx index 5f25b8d3a..85ad1d46a 100644 --- a/packages/shared/src/components/OverflowActions.test.tsx +++ b/packages/shared/src/components/OverflowActions.test.tsx @@ -7,8 +7,9 @@ import { within, } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { OverflowActions } from './OverflowActions'; + import { CanvasCardCornerActions } from './CanvasCardCornerActions'; +import { OverflowActions } from './OverflowActions'; afterEach(cleanup); describe('操作收纳与卡片角标', () => { diff --git a/packages/shared/src/components/OverflowActions.tsx b/packages/shared/src/components/OverflowActions.tsx index d79421308..23c4d222a 100644 --- a/packages/shared/src/components/OverflowActions.tsx +++ b/packages/shared/src/components/OverflowActions.tsx @@ -3,12 +3,12 @@ import { cloneElement, Fragment, isValidElement, + type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState, - type ReactNode, } from 'react'; import { createPortal } from 'react-dom'; diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 6748f9628..7b5447faf 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2582,7 +2582,6 @@ function assertAiGameCreatorShellUserDevBoundary() { JSON.stringify([ { url: 'https://dev.genarrative.world/api/*' }, { url: 'https://www.genarrative.world/api/*' }, - { url: 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*' }, { url: 'https://*/api/*' }, { url: 'http://localhost:*/*' }, { url: 'http://127.0.0.1:*/*' }, diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index cdbef119d..1c3c42bf5 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3875,9 +3875,7 @@ describe('ImageCanvasEditorView generation integration', () => { if (!metadataCornerButton) { throw new Error('metadata corner button should exist'); } - expect(metadataCornerButton.className).toContain( - 'image-canvas-editor__metadata-corner', - ); + expect(metadataCornerButton.className).toContain('shared-canvas-card-info'); fireEvent.click(metadataCornerButton); const metadataDialog = screen.getByRole('dialog', { name: '图片信息' }); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 32d777569..92b947c88 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1387,9 +1387,7 @@ describe('ImageCanvasEditorView', () => { const infoButton = screen.getByRole('button', { name: '查看拼图素材图片信息', }); - expect(infoButton.className).toContain( - 'image-canvas-editor__metadata-corner', - ); + expect(infoButton.className).toContain('shared-canvas-card-info'); fireEvent.click(infoButton); const infoPanel = screen.getByRole('dialog', { name: '图片信息' }); -- 2.52.0