diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 62aa2205e..f2c19ac58 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -111,7 +111,6 @@ const allowedUncalledTauriCommands = [ 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', 'create_ui_design_resource', - 'normalize_local_project_raster_resource', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'read_direct_project_conversation', diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css new file mode 100644 index 000000000..9490e5615 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -0,0 +1,241 @@ +/* + * 共享美术画布组件(ImageCanvasSelectedLayerToolbarView / ImageCanvasQuickEditPanelView) + * 的 AGC 宿主 chrome。 + * + * 这些 `image-canvas-editor__*` 类名在网页端由全局样式表 src/index.css 提供;AGC 是独立 + * 应用、不引入那张整站样式表,因此在这里按实际用到的类补一套宿主样式:定位、浮层层级、 + * 按钮与面板外观。组件本身与交互语义仍全部复用美术画布的实现。 + */ + +/* 主题变量:与网页端 .image-canvas-editor 作用域下的一组变量保持一致。 */ +.game-resource-canvas-toolbar-host, +.image-canvas-editor__portal-theme, +.image-canvas-editor__portal-menu { + --image-canvas-brand-accent: var(--platform-accent, #c7653d); + --image-canvas-brand-accent-strong: #6f2f21; + --image-canvas-brand-fill: var( + --platform-button-primary-fill, + linear-gradient(135deg, #df7f40, #b95d3a) + ); + --image-canvas-brand-text-on-fill: var( + --platform-button-primary-text, + #fffaf5 + ); + --image-canvas-brand-border: rgba(204, 117, 76, 0.34); + --image-canvas-brand-border-strong: rgba(199, 101, 61, 0.64); + --image-canvas-brand-border-soft: rgba(226, 203, 184, 0.82); + --image-canvas-brand-soft: rgba(234, 204, 179, 0.28); + --image-canvas-brand-soft-strong: rgba(238, 208, 183, 0.46); + --image-canvas-brand-shadow: rgba(182, 98, 63, 0.16); + --image-canvas-brand-focus-ring: rgba(204, 117, 76, 0.18); +} + +/* 选中工具条:贴在资源卡正上方浮出,层级高于卡片与画布标题栏。 */ +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar { + position: absolute; + z-index: 60; + display: inline-flex; + max-width: min(560px, 92vw); + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.5rem; + background: #ffffff; + padding: 0.28rem; + box-shadow: 0 18px 34px rgba(15, 23, 42, 0.18); + transform: translate(-50%, -100%); + pointer-events: auto; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar + button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.375rem; + background: #ffffff; + color: #334155; + cursor: pointer; + transition: + transform 160ms ease, + background-color 160ms ease, + border-color 160ms ease, + color 160ms ease; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar + button:hover:not([disabled]) { + transform: translateY(-1px); + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar + button[disabled] { + opacity: 0.6; + cursor: not-allowed; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar + button.image-canvas-editor__floating-toolbar-text-button { + width: auto; + min-width: 0; + gap: 0.35rem; + padding: 0.34rem 0.62rem; + font-size: 0.74rem; + font-weight: 850; + white-space: nowrap; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar-divider { + display: block; + width: 1px; + height: 1.1rem; + background: var(--image-canvas-brand-border-soft); +} + +/* 快速编辑浮层面板:锚在资源卡正下方。 */ +.game-resource-canvas-toolbar-host .image-canvas-editor__generation-composer { + position: absolute; + z-index: 70; + display: flex; + width: min(360px, 92vw); + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.75rem; + background: #ffffff; + box-shadow: 0 22px 40px rgba(15, 23, 42, 0.22); + transform: translateX(-50%); + pointer-events: auto; +} + +.game-resource-canvas-toolbar-host .image-canvas-editor__generation-prompt { + width: 100%; + min-height: 4.5rem; + resize: none; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.5rem; + padding: 0.5rem 0.6rem; + background: #fffdfa; + color: #1f2937; + font-size: 0.82rem; + line-height: 1.5; +} + +.game-resource-canvas-toolbar-host .image-canvas-editor__reference-strip { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__generation-composer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + flex-wrap: wrap; +} + +.game-resource-canvas-toolbar-host .image-canvas-editor__option-cluster { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex-wrap: wrap; +} + +.game-resource-canvas-toolbar-host .image-canvas-editor__option-popover-anchor { + position: relative; + display: inline-flex; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__option-popover { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + z-index: 80; + min-width: 12rem; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.6rem; + background: #ffffff; + padding: 0.5rem; + box-shadow: 0 18px 32px rgba(15, 23, 42, 0.2); +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__option-popover-sections { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__option-popover-items { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__option-popover-choice { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.28rem 0.5rem; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.375rem; + background: #fffdfa; + color: #334155; + font-size: 0.74rem; + cursor: pointer; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__generation-submit { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.9rem; + border: 1px solid transparent; + border-radius: 0.5rem; + background: var(--image-canvas-brand-fill); + color: var(--image-canvas-brand-text-on-fill); + font-size: 0.78rem; + font-weight: 850; + cursor: pointer; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__generation-submit:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.game-resource-canvas-toolbar-host .image-canvas-editor__generate-status { + margin: 0; + font-size: 0.74rem; + color: #8c6252; +} + +@media (max-width: 720px) { + .game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar { + max-width: 94vw; + } + + .game-resource-canvas-toolbar-host .image-canvas-editor__generation-composer { + width: 94vw; + } +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts new file mode 100644 index 000000000..5d5535c5b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts @@ -0,0 +1,15 @@ +import type { CanvasLayer } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import type { QuickEditPanelState } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import { createQuickEditPanelDraft } from '../../../../../src/components/image-editor/ImageCanvasGenerationDialogModel'; + +/** + * AGC 资源卡快速编辑面板的初始草稿。 + * + * 直接在网页端美术画布的草稿工厂上包一层,保证两个画布的默认模型、比例与尺寸 + * 取值完全相同;AGC 侧不再自带第二套默认值。 + */ +export function createResourceQuickEditPanelDraft( + sourceLayer: CanvasLayer, +): QuickEditPanelState { + return createQuickEditPanelDraft(sourceLayer, {}); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts new file mode 100644 index 000000000..b4e11bbec --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts @@ -0,0 +1,167 @@ +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { + CanvasAssetKind, + CanvasLayer, + CanvasMediaType, +} from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import type { ImageCanvasSelectedToolbarAction } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; +import { canonicalProjectedResourceMediaType } from '../../view/project-development/resourceEditModel'; +import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; + +/** 快速编辑与改造都走 `derive_local_project_resource`,只接受这三种栅格图片格式。 */ +const RESOURCE_RASTER_MEDIA_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', +]); + +const CANVAS_ASSET_KINDS = new Set([ + 'audio', + 'spec', + 'character', + 'character-animation', + 'icon', + 'icon-spritesheet', + 'icon-spec', + 'publication-material', + 'ui-design', + 'video', + 'sound-effect', + 'background-music', + 'scene', +]); + +export function resourceCanvasMediaType( + resource: Pick, +): CanvasMediaType | undefined { + const mediaType = canonicalProjectedResourceMediaType({ + mediaType: resource.mediaType, + path: resource.path, + } as ProjectResource); + if (mediaType.startsWith('image/')) { + return 'image'; + } + if (mediaType.startsWith('video/')) { + return 'video'; + } + if (mediaType.startsWith('audio/') || resource.category === 'audio') { + return 'audio'; + } + return undefined; +} + +export function resourceCanvasAssetKind( + resource: Pick, +): CanvasAssetKind | null { + return CANVAS_ASSET_KINDS.has(resource.subtype) + ? (resource.subtype as CanvasAssetKind) + : null; +} + +export function isResourceRasterImage( + resource: Pick, +) { + return RESOURCE_RASTER_MEDIA_TYPES.has( + canonicalProjectedResourceMediaType({ + mediaType: resource.mediaType, + path: resource.path, + } as ProjectResource), + ); +} + +/** + * 任务产物类型的资源只有先被正规化成正式 manifest 素材,才能作为派生源。 + * + * 与 Rust `normalize_local_project_raster_resource_at` 的门禁对齐:必须是已完成 + * 任务登记在 artifacts 里的栅格图片产物。 + */ +export function canNormalizeResourceIntoManifestAsset( + manifest: GameCreationAppManifest, + resource: Pick< + ProjectResource, + 'manifestAssetId' | 'producerTaskId' | 'path' | 'mediaType' | 'category' + >, +) { + if (resource.manifestAssetId !== null) { + return false; + } + const producerTaskId = resource.producerTaskId?.trim(); + if (!producerTaskId || !isResourceRasterImage(resource)) { + return false; + } + return manifest.tasks.some( + (task) => + task.id === producerTaskId && + task.status === 'completed' && + task.artifacts.includes(resource.path), + ); +} + +export function resolveResourceCanvasToolbarActions( + manifest: GameCreationAppManifest, + resource: ProjectResource, +): ReadonlySet { + const supported = new Set(); + const mediaType = resourceCanvasMediaType(resource); + const canDeriveFromResource = + resource.manifestAssetId !== null || + canNormalizeResourceIntoManifestAsset(manifest, resource); + if (mediaType === 'image') { + if (isResourceRasterImage(resource) && canDeriveFromResource) { + supported.add('quick-edit'); + } + if (canDeriveFromResource) { + supported.add('redraw'); + } + if ( + resourceCanvasAssetKind(resource) === 'character' && + canDeriveFromResource + ) { + supported.add('character-animation'); + } + return supported; + } + if ( + (mediaType === 'video' || mediaType === 'audio') && + canDeriveFromResource + ) { + supported.add('redraw'); + } + return supported; +} + +/** + * 把资源卡投影成美术画布 `CanvasLayer`,让工具条、命中测试与几何判定共用同一套语义。 + */ +export function projectResourceToCanvasLayer({ + resource, + x, + y, + width, + height, + src = '', +}: { + resource: ProjectResource; + x: number; + y: number; + width: number; + height: number; + src?: string; +}): CanvasLayer { + return { + id: resource.id, + resourceId: resource.id, + title: resource.label, + src, + mediaType: resourceCanvasMediaType(resource), + x, + y, + width, + height, + originalWidth: width, + originalHeight: height, + zIndex: 0, + sourceType: 'uploaded', + assetKind: resourceCanvasAssetKind(resource), + }; +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts new file mode 100644 index 000000000..e74892ba6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts @@ -0,0 +1,35 @@ +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; + +/** + * C5 卡片边框:一个资源是否正在被「当前版本」使用。 + * + * 当前版本 = manifest `versions[]` 的最后一个版本。版本绑定是资源记录的权威口径, + * `slotId` 恒等映射为 `asset:{assetId}`,`resourceId` 指向 manifest 资产 ID。 + * 版本切换状态接入后,只需把 `manifest` 换成「当前选中的版本」即可复用同一判定。 + */ +export function currentVersionResourceBindingIds( + manifest: Pick, +): Set { + const currentVersion = (manifest.versions ?? []).at(-1); + if (!currentVersion) { + return new Set(); + } + return new Set( + currentVersion.resourceBindings.flatMap((binding) => + binding.slotId === `asset:${binding.resourceId}` + ? [binding.resourceId] + : [], + ), + ); +} + +export function isResourceUsedByCurrentVersion( + resource: Pick, + currentVersionBindingIds: ReadonlySet, +) { + return ( + resource.manifestAssetId !== null && + currentVersionBindingIds.has(resource.manifestAssetId) + ); +} diff --git a/apps/ai-game-creator-shell/src/main.tsx b/apps/ai-game-creator-shell/src/main.tsx index b6eb91323..ce8bef64f 100644 --- a/apps/ai-game-creator-shell/src/main.tsx +++ b/apps/ai-game-creator-shell/src/main.tsx @@ -1,5 +1,6 @@ import '@genarrative/image-canvas-react/styles.css'; import './styles.css'; +import './features/resource-canvas/resourceCanvasChrome.css'; import React from 'react'; import { createRoot } from 'react-dom/client'; diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 5776568fe..d32d7bb54 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -6862,7 +6862,7 @@ iframe.preview-frame { min-height: var(--resource-card-height); padding: 0; overflow: visible; - border: 1px solid #eadbd4; + border: 0; border-radius: 12px; background: #fff; color: #4e382f; @@ -6892,6 +6892,14 @@ iframe.preview-frame { box-shadow: 0 8px 22px rgb(195 105 62 / 15%); } +/* C5 卡片边框:只有被当前版本绑定使用的素材带边框,其余资源卡无边框。 */ +.game-resource-card.is-current-version { + border: 1px solid #d87342; + box-shadow: + 0 6px 18px rgb(96 62 47 / 10%), + 0 0 0 2px rgb(216 115 66 / 14%); +} + .game-resource-card.is-relation-version-binding { border-color: #d87342; box-shadow: @@ -6975,7 +6983,7 @@ iframe.preview-frame { transform-origin: bottom right; } -.game-resource-card-open { +.game-resource-card-select { position: absolute; z-index: 1; inset: 0; @@ -6989,7 +6997,7 @@ iframe.preview-frame { touch-action: manipulation; } -.game-resource-card-open:focus-visible { +.game-resource-card-select:focus-visible { outline: 3px solid rgb(213 123 81 / 55%); outline-offset: -4px; } 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 4ec5fa8d1..ed5e575aa 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 @@ -1,4 +1,18 @@ -import type { CanvasViewport } from '@genarrative/image-canvas-core'; +import type { + CanvasMarqueeState, + CanvasViewport, +} from '@genarrative/image-canvas-core'; +import { + createCanvasMarqueeState, + getPointerClient, + resolveQuickEditPanelStyle, + resolveSelectedToolbarStyle, + selectLayersInsideMarquee, +} from '@genarrative/image-canvas-core'; +import { + CanvasChromeButton, + SelectionOverlay, +} from '@genarrative/image-canvas-react'; import { AtSign, Code2, @@ -40,8 +54,6 @@ import { useState, type WheelEvent as ReactWheelEvent, } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; import type { @@ -52,6 +64,12 @@ import type { GameIterationVersion, ProjectResourceCanvasLayoutMode, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { + CanvasLayer, + QuickEditPanelState, +} from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; +import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; import { LocalGamePreviewFrame, type LocalGamePreviewInspectSelection, @@ -62,6 +80,16 @@ import { RESOURCE_REFERENCE_FILTERS, type ResourceReferenceFilter, } from '../../features/project-workspace/resourceReferences'; +import { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel'; +import { + canNormalizeResourceIntoManifestAsset, + projectResourceToCanvasLayer, + resolveResourceCanvasToolbarActions, +} from '../../features/resource-canvas/resourceCanvasToolbarModel'; +import { + currentVersionResourceBindingIds, + isResourceUsedByCurrentVersion, +} from '../../features/resource-canvas/resourceCanvasVersionBindingModel'; import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge'; import { currentPlatformSessionGeneration, @@ -139,15 +167,9 @@ import { type ResourceDependencyOverlayHandle, } from './ResourceDependencyOverlay'; import { + canonicalProjectedResourceMediaType, defaultDerivedResourceName, - type ProjectResourceEditCapability, - resolveProjectCharacterAnimationCapability, - resolveProjectResourceEditCapability, } from './resourceEditModel'; -import { - type ResourceEditSubmitInput, - ResourceEditSurface, -} from './ResourceEditSurface'; import { type ProjectAgentResultSummary, type ProjectAttachmentResult, @@ -207,24 +229,6 @@ type WebKitGestureEvent = Event & { scale?: number; }; -type ResourceEditorRoute = { - flowId: string; - operationId: string; - idempotencyKey: string; - resource: ProjectResource | null; - capability: Extract; - generationMode: 'create' | 'derive'; -}; - -function defaultCreatedResourceName( - editKind: ResourceEditorRoute['capability']['editKind'], -) { - if (editKind === 'video') return '新视频'; - if (editKind === 'sound-effect') return '新音效'; - if (editKind === 'background-music') return '新背景音乐'; - return '新资源'; -} - type UiEditorRoute = { resourceId: string; resourceLabel: string; @@ -449,44 +453,6 @@ const approvalOptions: Array<{ }, ]; -function formatMediaDuration(duration: number | null) { - if (duration === null || !Number.isFinite(duration) || duration < 0) { - return '载入后显示'; - } - const totalSeconds = Math.floor(duration); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, '0')}`; -} - -function formatVersionCreatedAt(createdAt: number) { - const value = new Date(createdAt); - return Number.isFinite(value.getTime()) - ? value.toLocaleString('zh-CN') - : String(createdAt); -} - -function SafeProjectMarkdown({ content }: { content: string }) { - return ( - ( - {children} - ), - img: ({ alt }) => ( - - {alt ? `图片:${alt}` : '文档图片已省略'} - - ), - }} - > - {content} - - ); -} - function summarizeAgent( manifest: GameCreationAppManifest, group: AgentSummary['group'], @@ -535,6 +501,7 @@ const ResourceCard = memo(function ResourceCard({ resource, selected, relationState, + usedByCurrentVersion, x, y, rotation = 0, @@ -544,7 +511,6 @@ const ResourceCard = memo(function ResourceCard({ cardSize, activeMediaIdentity, onSelect, - onOpenEditor, onPointerDown, onPointerMove, onPointerUp, @@ -559,6 +525,7 @@ const ResourceCard = memo(function ResourceCard({ resource: ProjectResource; selected: boolean; relationState: 'version-binding' | null; + usedByCurrentVersion: boolean; x: number; y: number; rotation?: number; @@ -567,8 +534,7 @@ const ResourceCard = memo(function ResourceCard({ preview: ProjectResourceCardPreviewState; cardSize: ResourceCanvasCardSize; activeMediaIdentity: string | null; - onSelect: (resourceId: string) => void; - onOpenEditor: (resource: ProjectResource) => void; + onSelect: (resourceId: string, options?: { append?: boolean }) => void; onPointerDown: ( event: ReactPointerEvent, resource: ProjectResource, @@ -788,9 +754,12 @@ const ResourceCard = memo(function ResourceCard({ ref={cardRef} className={`game-resource-card${selected ? ' is-selected' : ''}${ relationState ? ` is-relation-${relationState}` : '' - }${dragging ? ' is-dragging' : ''}`} + }${usedByCurrentVersion ? ' is-current-version' : ''}${ + dragging ? ' is-dragging' : '' + }`} data-resource-card-id={resource.id} data-preview-kind={kind} + data-used-by-current-version={usedByCurrentVersion ? 'true' : undefined} style={ { '--resource-x': `${x}px`, @@ -804,12 +773,10 @@ const ResourceCard = memo(function ResourceCard({ onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} - onClick={() => { - if (resource.subtype === 'UI' || resource.subtype === 'ui-prototype') { - onOpenEditor(resource); - return; - } - onSelect(resource.id); + onClick={(event) => { + onSelect(resource.id, { + append: event.shiftKey || event.ctrlKey || event.metaKey, + }); }} > ) : null} - {mode === 'resources' && - !resourceEditorRoute && - !uiEditorRoute ? ( + {mode === 'resources' && !uiEditorRoute ? ( <> {pendingResourceEdits.length > 0 || pendingResourceEditsLoadState === 'failed' ? ( @@ -4934,349 +4890,8 @@ export default function ProjectDevelopmentView({ onBack={() => setUiEditorRoute(null)} /> - ) : mode === 'resources' && resourceEditorRoute ? ( - ) : mode === 'resources' ? ( <> - {focusedResource ? ( -
-
- - - - - - {categoryLabels[focusedResource.category]} - - - {focusedResource.label} - - - - - {resolveProjectCharacterAnimationCapability( - focusedResource, - ) ? ( - - ) : null} - - {focusedResource.manifestAssetId ? ( - - ) : null} - - -
-
- {focusedResource.category === 'art' && - focusedResourceCardPreview.status === 'loaded' && - focusedResourceCardPreview.preview.sourceUrl ? ( -
- {focusedPreviewMediaType?.startsWith('video/') ? ( -
- ) : null} -
-
-
资源路径
-
{focusedResource.path}
-
-
-
资源类型
-
{focusedPreviewMediaType}
-
-
-
资源来源
-
{focusedResource.sourceLabel}
-
- {focusedResource.taskTitle ? ( -
-
来源任务
-
{focusedResource.taskTitle}
-
- ) : null} -
-
依赖层级
-
{focusedResource.dependencyDepth}
-
-
-
依赖状态
-
- {focusedResourceDependencyDetails.cyclic - ? '同类型依赖环' - : '正常'} -
-
-
-
引用上游
-
- {focusedResourceDependencyDetails.upstreamLabels - .length > 0 - ? focusedResourceDependencyDetails.upstreamLabels.join( - '、', - ) - : '暂无同类型引用'} -
-
-
-
引用下游
-
- {focusedResourceDependencyDetails.downstreamLabels - .length > 0 - ? focusedResourceDependencyDetails.downstreamLabels.join( - '、', - ) - : '暂无同类型引用'} -
-
-
-
任务流
-
- {focusedResourceDependencyDetails.taskFlowLabels - .length > 0 - ? focusedResourceDependencyDetails.taskFlowLabels.join( - ';', - ) - : '暂无同类型任务流'} -
-
- {focusedResourceIsAudio ? ( -
-
音频时长
-
{formatMediaDuration(mediaDuration)}
-
- ) : null} - {focusedResource.version ? ( - <> -
-
版本 ID
-
{focusedResource.version.versionId}
-
-
-
项目修订
-
{focusedResource.version.projectRevision}
-
-
-
父版本
-
- {focusedResource.version.parentVersionId ?? - '首个版本'} -
-
-
-
直接子版本
-
- {focusedResource.version.childVersionIds.length > - 0 - ? focusedResource.version.childVersionIds.join( - '、', - ) - : '暂无'} -
-
-
-
创建原因
-
- { - { - initial: '初始版本', - 'resource-replacement': '资源替换', - 'agent-revision': 'Agent 修订', - }[focusedResource.version.createdReason] - } -
-
-
-
创建时间
-
- {formatVersionCreatedAt( - focusedResource.version.createdAt, - )} -
-
-
-
资源绑定
-
- {focusedResource.version.resourceBindings.length > - 0 - ? focusedResource.version.resourceBindings - .map( - (binding) => - `${binding.slotId} → ${binding.resourceId}`, - ) - .join(';') - : '暂无'} -
-
- - ) : null} -
- {focusedResourceIsAudio ? ( -
- {focusedResourceCardPreview.status === 'loaded' && - focusedResourceCardPreview.preview.sourceUrl ? ( -
- ) : null} - {focusedResource.category === 'document' ? ( -
- {focusedResourceCardPreview.status === 'loaded' && - focusedResourceCardPreview.preview.content !== - undefined ? ( - focusedResourceCardPreview.preview.content.trim() ? ( - - ) : ( -

文档为空

- ) - ) : focusedResourceCardPreview.status === 'failed' ? ( -

{focusedResourceCardPreview.error}

- ) : ( -

正在载入文档…

- )} -
- ) : null} - {focusedResource.category === 'code' ? ( -
- {focusedResourceCardPreview.status === 'loaded' && - focusedResourceCardPreview.preview.content !== - undefined ? ( -
-                            
-                              {focusedResourceCardPreview.preview.content}
-                            
-                          
- ) : focusedResourceCardPreview.status === 'failed' ? ( -

{focusedResourceCardPreview.error}

- ) : ( -

正在载入游戏代码…

- )} -
- ) : null} -
-
- ) : null}
+ } + overlay={ + resourceBookView === 'main' ? null : ( +
+ {selectedResourceLayer && + selectedToolbarStyle && + ((selectedToolbarActions?.size ?? 0) > 0 || + Boolean(selectedResource?.manifestAssetId) || + selectedResourceOpensUiEditor) ? ( + + {selectedResourceOpensUiEditor ? ( + + } + onClick={() => + openResourceUiEditor(selectedResource) + } + > + UI 编辑器 + + ) : null} + {selectedResource?.manifestAssetId ? ( + } + onClick={() => + setResourceClassificationAssetId( + selectedResource.manifestAssetId, + ) + } + > + 分类与标签 + + ) : null} + + } + onOpenQuickEditPanel={openResourceQuickEditPanel} + onOpenRedrawPanel={() => {}} + onOpenCropExpandPanel={() => {}} + onRemoveBackground={() => {}} + onPerfectPixel={() => {}} + isPerfectPixelProcessing={false} + isPerfectPixelPendingConfirmation={false} + isSplittingIconSpritesheet={false} + isPersistingAssetKind={false} + onSplitIconSpritesheet={() => {}} + onExtractUiDesignAssets={() => {}} + onOpenCharacterAnimationPanel={() => {}} + onDownloadLayer={() => {}} + /> + ) : null} + {quickEditPanel && quickEditPanelStyle + ? (() => { + return ( + { + void submitResourceQuickEdit(); + }} + /> + ); + })() + : null} +
+ ) + } onCollapse={returnToResourceBookMain} onPointerDown={handleResourceCanvasPointerDown} onPointerMove={handleResourceCanvasPointerMove} @@ -5489,15 +5188,6 @@ export default function ProjectDevelopmentView({ onPointerMove={handleResourceCanvasPointerMove} onPointerUp={stopResourceCanvasPan} onPointerCancel={stopResourceCanvasPan} - onScroll={(event) => { - resourceListScrollPositionsRef.current.set( - resourceListScrollKey, - { - left: event.currentTarget.scrollLeft, - top: event.currentTarget.scrollTop, - }, - ); - }} > {sortMode === 'dependency' && dependencyRelationshipDescriptions.length > 0 ? ( diff --git a/apps/ai-game-creator-shell/src/vite-env.d.ts b/apps/ai-game-creator-shell/src/vite-env.d.ts index 01e84f53b..11a4f5896 100644 --- a/apps/ai-game-creator-shell/src/vite-env.d.ts +++ b/apps/ai-game-creator-shell/src/vite-env.d.ts @@ -15,4 +15,23 @@ interface Window { ) => Promise<() => void>; }; }; + // 资源画布复用网页端美术画布的组件,这条依赖链是明确的跨端耦合: + // ImageCanvasSelectedLayerToolbarView → ImageCanvasGenerationModel →(值导入 + // ApiClientError)→ 主仓 src/services/apiClient.ts → src/services/host-bridge/hostBridge.ts。 + // hostBridge 读取的全局只在主仓 `src/vite-env.d.ts` 里声明过,AGC 侧必须补同名字段 + // 才能过 strict 检查。以后往 AGC 引 `src/components/image-editor/` 的组件,都要检查这条链。 + ReactNativeWebView?: { + postMessage?: (message: string) => void; + }; + wx?: { + miniProgram?: { + navigateTo?: (options: { + url: string; + success?: (result?: unknown) => void; + fail?: (error: { errMsg?: string }) => void; + }) => void; + postMessage?: (message: unknown) => void; + }; + }; + WeixinJSBridge?: unknown; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 843b4b079..36fe691b8 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -332,35 +332,35 @@ async function submitChat(value: string) { fireEvent.click(screen.getByRole('button', { name: '发送' })); } -// 同一张资源卡上有两个按钮:打开详情与 V3 新增的 @ 引用按钮,二者的可访问名都包含 -// 资源文件名,因此按文件名正则查询会同时命中两个元素。资源详情按钮的可访问名模板固定为 -// `打开资源详情:<分类标签> <资源文件名>`(分类标签内不含空格),这里按模板做精确匹配。 +// 同一张资源卡上有两个按钮:选中资源与 V3 新增的 @ 引用按钮,二者的可访问名都包含 +// 资源文件名,因此按文件名正则查询会同时命中两个元素。选中按钮的可访问名模板固定为 +// `选中资源:<分类标签> <资源文件名>`(分类标签内不含空格),这里按模板做精确匹配。 function escapeRegExpLiteral(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function resourceDetailButtonName(label: string) { - return new RegExp(`^打开资源详情:\\S+ ${escapeRegExpLiteral(label)}$`); +function resourceSelectButtonName(label: string) { + return new RegExp(`^选中资源:\\S+ ${escapeRegExpLiteral(label)}$`); } -function getResourceDetailButton(label: string) { - return screen.getByRole('button', { name: resourceDetailButtonName(label) }); +function getResourceSelectButton(label: string) { + return screen.getByRole('button', { name: resourceSelectButtonName(label) }); } -function findResourceDetailButton( +function findResourceSelectButton( label: string, options?: { timeout?: number; interval?: number }, ) { return screen.findByRole( 'button', - { name: resourceDetailButtonName(label) }, + { name: resourceSelectButtonName(label) }, options, ); } -function queryResourceDetailButton(label: string) { +function queryResourceSelectButton(label: string) { return screen.queryByRole('button', { - name: resourceDetailButtonName(label), + name: resourceSelectButtonName(label), }); } @@ -1293,10 +1293,10 @@ export { describe, emptyProjectPolicy, expect, - findResourceDetailButton, + findResourceSelectButton, fireEvent, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, - getResourceDetailButton, + getResourceSelectButton, it, mockRoleAgentReply, nativeClipboardMock, @@ -1304,7 +1304,7 @@ export { pickProjectFromLauncher, ProjectDevelopmentView, projectSupervisorResponseStream, - queryResourceDetailButton, + queryResourceSelectButton, React, readFileSync, render, 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 12907344c..e7c0eb06e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -9,13 +9,13 @@ import { createGameCreationAppManifest, createProjectSupervisorRuntimeHarness, expect, - findResourceDetailButton, + findResourceSelectButton, fireEvent, - getResourceDetailButton, + getResourceSelectButton, it, nativeClipboardMock, pickProjectFromLauncher, - queryResourceDetailButton, + queryResourceSelectButton, React, render, renderAppAt, @@ -316,12 +316,12 @@ export function registerClientHomeTests() { pickProjectFromLauncher(projectPath); const runButton = await screen.findByRole('tab', { name: '运行' }); expect(runButton.getAttribute('data-unavailable')).toBe('true'); - expect(queryResourceDetailButton('live-hero.png')).toBeNull(); + expect(queryResourceSelectButton('live-hero.png')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '同步最新 manifest' })); await openResourceBookCategory('美术资源'); - expect(await findResourceDetailButton('live-hero.png')).not.toBeNull(); + expect(await findResourceSelectButton('live-hero.png')).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( await screen.findByRole('button', { name: /版本 1/ }), @@ -462,7 +462,7 @@ export function registerClientHomeTests() { pickProjectFromLauncher(projectPath); const runButton = await screen.findByRole('tab', { name: '运行' }); expect(runButton.getAttribute('data-unavailable')).toBe('true'); - expect(queryResourceDetailButton('runtime-live-hero.png')).toBeNull(); + expect(queryResourceSelectButton('runtime-live-hero.png')).toBeNull(); await waitFor(() => { expect(runtimeHarness.listen).toHaveBeenCalledWith( 'game-creator-manifest-invalidated', @@ -488,7 +488,7 @@ export function registerClientHomeTests() { await openResourceBookCategory('美术资源'); expect( - await findResourceDetailButton('runtime-live-hero.png', { + await findResourceSelectButton('runtime-live-hero.png', { timeout: 5_000, }), ).not.toBeNull(); @@ -663,14 +663,14 @@ export function registerClientHomeTests() { fireEvent.click(screen.getByRole('button', { name: '项目组' })); pickProjectFromLauncher(secondProjectPath); await openResourceBookCategory('美术资源'); - expect(await findResourceDetailButton('second.png')).not.toBeNull(); + expect(await findResourceSelectButton('second.png')).not.toBeNull(); await act(async () => { resolveStaleRefresh(staleFirstManifest); await staleRefresh; }); - expect(queryResourceDetailButton('stale-first.png')).toBeNull(); - expect(getResourceDetailButton('second.png')).not.toBeNull(); + expect(queryResourceSelectButton('stale-first.png')).toBeNull(); + expect(getResourceSelectButton('second.png')).not.toBeNull(); }); it('starts from the client home and opens a project in the same window', async () => { 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 6e6c1bba9..97f9208b3 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 @@ -25,17 +25,17 @@ import { createProjectSupervisorRuntimeHarness, emptyProjectPolicy, expect, - findResourceDetailButton, + findResourceSelectButton, fireEvent, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, type GameCreationAgentRunTrace, - getResourceDetailButton, + getResourceSelectButton, it, mockRoleAgentReply, pickProjectFromLauncher, ProjectDevelopmentView, projectSupervisorResponseStream, - queryResourceDetailButton, + queryResourceSelectButton, React, readFileSync, render, @@ -717,7 +717,7 @@ export function registerProjectWorkbenchFoundationTests() { ), ).toEqual(['设计文档', '美术资源', '音乐音效', '项目版本']); expect(graphResourceIds).toContain('asset:section-code'); - expect(queryResourceDetailButton('section.js')).toBeNull(); + expect(queryResourceSelectButton('section.js')).toBeNull(); expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull(); expect(outline.querySelectorAll('small')).toHaveLength(0); expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull(); @@ -844,18 +844,12 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click( screen.getByRole('button', { - name: /打开资源详情:设计文档 section\.md/, + name: /选中资源:设计文档 section\.md/, }), ); - expect(screen.getByRole('dialog', { name: 'section.md' })).not.toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); dispatchViewWheel(); expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull(); - fireEvent.click( - within(screen.getByRole('dialog', { name: 'section.md' })).getByRole( - 'button', - { name: '收起资源' }, - ), - ); fireEvent.click( within( document.querySelector( @@ -905,7 +899,7 @@ export function registerProjectWorkbenchFoundationTests() { const dragCard = screen .getByRole('button', { - name: /打开资源详情:设计文档 section\.md/, + name: /选中资源:设计文档 section\.md/, }) .closest('.game-resource-card'); expect(dragCard).not.toBeNull(); @@ -953,8 +947,9 @@ export function registerProjectWorkbenchFoundationTests() { await openResourceBookCategory('设计文档'); const viewportBeforePan = readViewport(); + // 与美术画布一致:左键在空白处是框选,平移用中键(或按住空格)。 fireEvent.pointerDown(dependencyCanvas, { - button: 0, + button: 1, clientX: 100, clientY: 100, }); @@ -968,7 +963,7 @@ export function registerProjectWorkbenchFoundationTests() { }); fireEvent.pointerDown(dependencyCanvas, { - button: 0, + button: 1, clientX: 100, clientY: 100, }); @@ -1289,7 +1284,7 @@ export function registerProjectWorkbenchFoundationTests() { .getByLabelText(/资源(?:依赖|类型)视图/) .classList.contains('game-resource-canvas--paged'), ).toBe(false); - expect(queryResourceDetailButton('game.js')).toBeNull(); + expect(queryResourceSelectButton('game.js')).toBeNull(); expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull(); }); }); @@ -1387,7 +1382,7 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ })); expect( await screen.findByRole('button', { - name: '打开资源详情:美术资源 local-imported-image.png', + name: '选中资源:美术资源 local-imported-image.png', }), ).not.toBeNull(); }); @@ -1611,7 +1606,7 @@ export function registerProjectWorkbenchFoundationTests() { const showResourcePage = (label: string) => openResourceBookCategory(label); await showResourcePage('美术资源'); - let heroDetailButton = await findResourceDetailButton('hero.png'); + let heroDetailButton = await findResourceSelectButton('hero.png'); const heroCard = heroDetailButton.closest('.game-resource-card'); expect(heroCard?.textContent).not.toContain('hero.png'); expect(heroCard?.textContent).not.toContain('assets/hero.png'); @@ -1729,26 +1724,20 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.queryByRole('dialog', { name: 'theme.mp3' })).toBeNull(); await showResourcePage('美术资源'); - heroDetailButton = await findResourceDetailButton('hero.png'); + heroDetailButton = await findResourceSelectButton('hero.png'); fireEvent.click(heroDetailButton); - const heroFocus = await screen.findByRole('dialog', { name: 'hero.png' }); - expect(within(heroFocus).getByText('assets/hero.png')).not.toBeNull(); - expect(within(heroFocus).getByText('Agent 生成')).not.toBeNull(); - expect(within(heroFocus).getByText('image/png')).not.toBeNull(); + const heroToolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); expect( - within(heroFocus).getByRole('button', { name: '生成动画' }).className, - ).toContain('game-resource-focus-action'); - expect( - within(heroFocus).getByRole('button', { name: '编辑资源' }).className, - ).toContain('game-resource-focus-action'); + within(heroToolbar).getByRole('button', { name: '快速编辑' }), + ).not.toBeNull(); + expect(screen.queryByRole('dialog', { name: 'hero.png' })).toBeNull(); expect( invoke.mock.calls.filter( ([command]) => command === 'read_local_project_image_preview', ), ).toHaveLength(1); - fireEvent.click( - within(heroFocus).getByRole('button', { name: '收起资源' }), - ); fireEvent.click(screen.getByRole('button', { name: '按类型' })); expect(observer.observedCount()).toBeGreaterThanOrEqual(2); @@ -1772,9 +1761,9 @@ export function registerProjectWorkbenchFoundationTests() { const search = screen.getByLabelText('搜索项目资源'); fireEvent.change(search, { target: { value: 'assets/hero.png' } }); await waitFor(() => expect(pause.mock.instances).toContain(typeVideo)); - expect(getResourceDetailButton('hero.png')).not.toBeNull(); - expect(queryResourceDetailButton('intro.mp4')).toBeNull(); - const typeHeroCard = getResourceDetailButton('hero.png').closest( + expect(getResourceSelectButton('hero.png')).not.toBeNull(); + expect(queryResourceSelectButton('intro.mp4')).toBeNull(); + const typeHeroCard = getResourceSelectButton('hero.png').closest( '.game-resource-card', ); await waitFor(() => @@ -1902,7 +1891,7 @@ export function registerProjectWorkbenchFoundationTests() { document.querySelectorAll('.game-resource-card-visual > img'), ).toHaveLength(48); }); - const wideImageCard = getResourceDetailButton( + const wideImageCard = getResourceSelectButton( 'image-0.png', ).closest('.game-resource-card'); await waitFor(() => { @@ -1938,7 +1927,7 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('美术资源'); await waitFor(() => - expect(getResourceDetailButton('next.png')).not.toBeNull(), + expect(getResourceSelectButton('next.png')).not.toBeNull(), ); await waitFor(() => expect(observer.observedCount()).toBe(1)); act(() => observer.triggerVisible()); @@ -2031,10 +2020,10 @@ export function registerProjectWorkbenchFoundationTests() { }), ); const rootVersionButton = screen.getByRole('button', { - name: /打开资源详情:项目版本 版本 1/, + name: /选中资源:项目版本 版本 1/, }); const childVersionButton = screen.getByRole('button', { - name: /打开资源详情:项目版本 版本 2/, + name: /选中资源:项目版本 版本 2/, }); const rootVersionCard = rootVersionButton.closest('.game-resource-card'); const childVersionCard = childVersionButton.closest('.game-resource-card'); @@ -2043,26 +2032,14 @@ export function registerProjectWorkbenchFoundationTests() { expect(childVersionCard?.textContent).not.toContain('version-root'); fireEvent.click(childVersionButton); - const versionFocus = screen.getByRole('dialog', { name: '版本 2' }); - expect(within(versionFocus).getByText('version-child')).not.toBeNull(); - expect(within(versionFocus).getByText('version-root')).not.toBeNull(); - expect(within(versionFocus).getByText('Agent 修订')).not.toBeNull(); - expect( - within(versionFocus).getByText( - 'player → asset-player;historical → asset-removed', - ), - ).not.toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); expect(screen.queryByText('asset:asset-removed')).toBeNull(); - - fireEvent.click( - within(versionFocus).getByRole('button', { name: '收起资源' }), - ); fireEvent.click( within(screen.getByLabelText('资源栏目大纲')).getByRole('button', { name: /美术资源/, }), ); - const playerCard = getResourceDetailButton('player.png').closest( + const playerCard = getResourceSelectButton('player.png').closest( '.game-resource-card', ); expect(playerCard?.classList.contains('is-relation-version-binding')).toBe( @@ -2071,7 +2048,7 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(screen.getByRole('button', { name: '按依赖' })); await waitFor(() => { - const dependencyPlayerCard = getResourceDetailButton( + const dependencyPlayerCard = getResourceSelectButton( 'player.png', ).closest('.game-resource-card'); expect( @@ -2171,7 +2148,7 @@ export function registerProjectWorkbenchFoundationTests() { resourceCanvas.scrollTop = 36; const artReceiptButton = screen.getByRole('button', { - name: /打开资源详情:设计文档 美术资源计划 Agent 文本回执/, + name: /选中资源:设计文档 美术资源计划 Agent 文本回执/, }); const artReceiptCard = artReceiptButton.closest('.game-resource-card'); expect(artReceiptCard).not.toBeNull(); @@ -2180,7 +2157,7 @@ export function registerProjectWorkbenchFoundationTests() { 'utf8', ); expect(styles).toMatch( - /\.game-resource-card-open\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s, + /\.game-resource-card-select\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s, ); expect(styles).toMatch( /\.game-resource-card-media-control\s*\{[^}]*transform:\s*scale\(var\(--genarrative-image-canvas-inverse-scale,\s*1\)\)[^}]*transform-origin:\s*bottom\s+right/s, @@ -2189,11 +2166,12 @@ export function registerProjectWorkbenchFoundationTests() { /\.game-resource-canvas\s*\{[^}]*user-select:\s*none/s, ); expect(styles).toMatch(/\.game-resource-card\.is-dragging/); + expect(styles).toMatch(/\.game-resource-card\.is-current-version/); fireEvent.click(artReceiptButton); const workbenchStage = screen.getByLabelText('项目主视窗'); expect(workbenchStage.getAttribute('data-resource-view-state')).toBe( - 'resources.focused.document', + 'resources.selected.document', ); expect(screen.getByLabelText('搜索项目资源')).not.toBeNull(); for (const label of [ @@ -2211,7 +2189,6 @@ export function registerProjectWorkbenchFoundationTests() { .getAttribute('aria-pressed'), ).toBe('true'); expect(screen.getByLabelText('资源类型视图')).not.toBeNull(); - expect(document.querySelector('.game-resource-focus-backdrop')).toBeNull(); expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull(); expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull(); expect( @@ -2222,79 +2199,16 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.getByLabelText('资源类型视图').hasAttribute('inert')).toBe( false, ); - const receiptFocus = screen.getByRole('dialog', { - name: '美术资源计划 Agent 文本回执', + // 选中资源后浮出的是画布工具条,不再是"资源详情"面板。 + expect(screen.queryByRole('dialog')).toBeNull(); + expect(artReceiptButton.getAttribute('aria-pressed')).toBe('true'); + const receiptToolbar = screen.getByRole('toolbar', { + name: '图片工具栏', }); - expect(receiptFocus.hasAttribute('aria-modal')).toBe(false); + // 分类与标签入口已从详情面板搬到选中工具条。 expect( - await within(receiptFocus).findByRole('heading', { name: '美术计划' }), + within(receiptToolbar).getByRole('button', { name: '分类与标签' }), ).not.toBeNull(); - expect(within(receiptFocus).getByText('仅有美术计划')).not.toBeNull(); - expect(within(receiptFocus).getByText('没有图片文件')).not.toBeNull(); - expect(within(receiptFocus).getByText('等待实际素材生成。')).not.toBeNull(); - expect( - receiptFocus.querySelector('.game-resource-focus-body'), - ).not.toBeNull(); - expect(receiptFocus.querySelector('ul')).not.toBeNull(); - expect(receiptFocus.querySelector('strong')).not.toBeNull(); - expect(receiptFocus.closest('.game-workbench-stage')).toBe(workbenchStage); - expect(receiptFocus.closest('.game-resource-focus-layer')).toBeNull(); - expect(styles).toMatch( - /\.game-resource-focus\s*\{(?=[^}]*position:\s*absolute)(?=[^}]*top:\s*50%)(?=[^}]*left:\s*50%)(?=[^}]*width:\s*min\(560px,\s*calc\(100%\s*-\s*32px\)\))(?=[^}]*max-height:\s*calc\(100%\s*-\s*32px\))(?=[^}]*transform:\s*translate\(-50%,\s*-50%\))[^}]*\}/s, - ); - expect(styles).not.toMatch( - /\.game-resource-focus\s*\{[^}]*(?:position:\s*fixed|right:)/s, - ); - expect(styles).not.toMatch( - /\.game-resource-focus-titlebar\s*\{[^}]*cursor:/s, - ); - expect(styles).not.toMatch(/\.game-resource-focus-backdrop/); - - fireEvent.click( - within(receiptFocus).getByRole('button', { name: '收起资源' }), - ); - expect(workbenchStage.getAttribute('data-resource-view-state')).toBe( - 'resources.list', - ); - expect( - (screen.getByLabelText('搜索项目资源') as HTMLInputElement).value, - ).toBe('美术资源计划'); - expect( - screen - .getByRole('button', { name: '按类型' }) - .getAttribute('aria-pressed'), - ).toBe('true'); - const restoredCanvas = screen.getByLabelText( - '资源类型视图', - ) as HTMLDivElement; - expect(restoredCanvas.scrollLeft).toBe(48); - expect(restoredCanvas.scrollTop).toBe(36); - const restoredCard = screen.getByRole('button', { - name: /打开资源详情:设计文档 美术资源计划 Agent 文本回执/, - }); - expect(restoredCard?.getAttribute('aria-pressed')).toBe('true'); - expect( - screen - .getByRole('button', { name: '按类型' }) - .getAttribute('aria-pressed'), - ).toBe('true'); - fireEvent.click(restoredCard); - expect( - screen.getByRole('dialog', { - name: '美术资源计划 Agent 文本回执', - }), - ).not.toBeNull(); - fireEvent.keyDown(window, { key: 'Escape' }); - expect( - screen.queryByRole('dialog', { - name: '美术资源计划 Agent 文本回执', - }), - ).toBeNull(); - expect( - screen - .getByRole('button', { name: '按类型' }) - .getAttribute('aria-pressed'), - ).toBe('true'); }); it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => { @@ -2430,52 +2344,25 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('设计文档'); - fireEvent.click(await findResourceDetailButton('design.md')); - const documentFocus = await screen.findByRole('dialog', { - name: 'design.md', - }); - expect( - within(documentFocus).getByRole('heading', { name: '本地设计文档' }), - ).not.toBeNull(); - expect(documentFocus.querySelector('script')).toBeNull(); - expect(documentFocus.querySelector('a')).toBeNull(); - expect(within(documentFocus).getByText('图片:远程图片')).not.toBeNull(); - expect(documentFocus.hasAttribute('aria-modal')).toBe(false); - await openResourceBookCategory('美术资源'); - fireEvent.click(getResourceDetailButton('icon.svg')); + fireEvent.click(await findResourceSelectButton('design.md')); + // 选中资源浮出的是画布工具条;"资源详情"面板已随 C3 交互合同删除。 + expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull(); + await openResourceBookCategory('美术资源'); + fireEvent.click(getResourceSelectButton('icon.svg')); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull(); - const artDetails = await screen.findByRole('dialog', { name: 'icon.svg' }); - expect(within(artDetails).getByText('assets/icon.svg')).not.toBeNull(); - expect(within(artDetails).getByText('image/svg+xml')).not.toBeNull(); - expect(within(artDetails).getByText('依赖层级')).not.toBeNull(); - expect( - within(artDetails).getByRole('img', { name: 'icon.svg 预览' }), - ).not.toBeNull(); - fireEvent.click( - within(artDetails).getByRole('button', { name: '收起资源' }), - ); fireEvent.click( screen.getByRole('button', { - name: '打开资源详情:美术资源 intro.mp4', + name: '选中资源:美术资源 intro.mp4', }), ); - const videoDetails = await screen.findByRole('dialog', { - name: 'intro.mp4', - }); - expect(within(videoDetails).getByText('assets/intro.mp4')).not.toBeNull(); - expect(within(videoDetails).getByText('video/mp4')).not.toBeNull(); - expect( - within(videoDetails).getByLabelText('intro.mp4 视频预览'), - ).not.toBeNull(); - fireEvent.click( - within(videoDetails).getByRole('button', { name: '收起资源' }), - ); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); await openResourceBookCategory('音乐音效'); fireEvent.click( screen.getByRole('button', { - name: '打开资源详情:音乐音效 bgm.mp3', + name: '选中资源:音乐音效 bgm.mp3', }), ); expect( @@ -2485,29 +2372,16 @@ export function registerProjectWorkbenchFoundationTests() { args?.category === 'audio', ), ).toBe(false); - const audioDetails = screen.getByRole('dialog', { name: 'bgm.mp3' }); - fireEvent.click( - within(audioDetails).getByRole('button', { name: '播放 bgm.mp3' }), - ); - const audio = (await screen.findByLabelText( - 'bgm.mp3 音频播放器', - )) as HTMLAudioElement; - expect(audio.controls).toBe(true); - expect(audio.getAttribute('src')).toBe('blob:mock-attachment-preview'); - expect(screen.getByText('载入后显示')).not.toBeNull(); - expect(invoke).toHaveBeenCalledWith( - 'read_local_project_media_preview', - expect.objectContaining({ category: 'audio' }), - ); - fireEvent.click( - within(screen.getByRole('dialog', { name: 'bgm.mp3' })).getByRole( - 'button', - { name: '收起资源' }, - ), - ); + // 音频资源的选中工具条只保留美术画布同名动作;详情面板已删除。 + const audioToolbar = screen.getByRole('toolbar', { + name: '素材工具栏', + }); + expect( + within(audioToolbar).getByRole('button', { name: '改造' }), + ).not.toBeNull(); await openResourceBookCategory('设计文档'); - fireEvent.click(getResourceDetailButton('blocked.md')); + fireEvent.click(getResourceSelectButton('blocked.md')); expect((await screen.findByRole('alert')).textContent).toBe( '当前项目策略要求先确认读取文档,确认后请关闭详情并重试', ); @@ -2588,18 +2462,8 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('音乐音效'); - fireEvent.click(await findResourceDetailButton('focus.mp3')); - const audioDetails = await screen.findByRole('dialog', { - name: 'focus.mp3', - }); - fireEvent.click( - within(audioDetails).getByRole('button', { name: '播放 focus.mp3' }), - ); - const audio = (await within(audioDetails).findByLabelText( - 'focus.mp3 音频播放器', - )) as HTMLAudioElement; - audio.focus(); - expect(document.activeElement).toBe(audio); + fireEvent.click(await findResourceSelectButton('focus.mp3')); + expect(screen.getByRole('toolbar', { name: '素材工具栏' })).not.toBeNull(); const updatedManifest = { ...manifest, @@ -2620,10 +2484,7 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - expect(document.activeElement).toBe(audio); - expect(screen.getByRole('dialog', { name: 'focus.mp3' })).not.toBe( - document.activeElement, - ); + expect(screen.getByRole('toolbar', { name: '素材工具栏' })).not.toBeNull(); rendered.rerender( React.createElement(ProjectDevelopmentView, { @@ -2633,14 +2494,12 @@ export function registerProjectWorkbenchFoundationTests() { ); await waitFor(() => { - expect(screen.queryByRole('dialog', { name: 'focus.mp3' })).toBeNull(); - expect(document.activeElement).toBe( - screen.getByLabelText('搜索项目资源'), - ); + expect(screen.queryByRole('toolbar', { name: '素材工具栏' })).toBeNull(); + expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull(); }); expect( screen - .queryAllByTitle('打开资源详情') + .queryAllByTitle('选中资源') .some((card) => card.getAttribute('aria-pressed') === 'true'), ).toBe(false); }); @@ -2903,8 +2762,8 @@ export function registerProjectWorkbenchFoundationTests() { ); fireEvent.change(search, { target: { value: '' } }); - let sourceCard = getResourceDetailButton('spec-source.json'); - const targetCard = getResourceDetailButton('ui-dependency.json'); + let sourceCard = getResourceSelectButton('spec-source.json'); + const targetCard = getResourceSelectButton('ui-dependency.json'); const referenceSelector = '[data-edge-kind="asset-reference"]' + '[data-source-resource-id="asset:dependency-spec"]' + @@ -2972,14 +2831,9 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(sourceCardContainer!); fireEvent.click(targetCard.closest('.game-resource-card')!); - const targetFocus = screen.getByRole('dialog', { - name: /ui-dependency\.json/u, - }); - fireEvent.click( - within(targetFocus).getByRole('button', { name: '收起资源' }), - ); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); overlay = await screen.findByTestId('resource-dependency-overlay-document'); - sourceCard = getResourceDetailButton('spec-source.json'); + sourceCard = getResourceSelectButton('spec-source.json'); await waitFor(() => expect(overlay.querySelector(referenceSelector)).not.toBeNull(), ); @@ -3393,7 +3247,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(dependencyLayoutReads).toBe(0); expect( screen.queryByRole('button', { - name: '打开资源详情:设计文档 延迟依赖图回执', + name: '选中资源:设计文档 延迟依赖图回执', }), ).toBeNull(); @@ -3403,7 +3257,7 @@ export function registerProjectWorkbenchFoundationTests() { }); expect( await screen.findByRole('button', { - name: '打开资源详情:设计文档 延迟依赖图回执', + name: '选中资源:设计文档 延迟依赖图回执', }), ).not.toBeNull(); expect(dependencyLayoutReads).toBe(1); @@ -3476,7 +3330,7 @@ export function registerProjectWorkbenchFoundationTests() { const view = render(renderView([firstResult])); await openResourceBookCategory('设计文档'); const firstCardButton = await screen.findByRole('button', { - name: '打开资源详情:设计文档 稳定依赖图回执', + name: '选中资源:设计文档 稳定依赖图回执', }); const firstCard = firstCardButton.closest('.game-resource-card'); expect(firstCard).not.toBeNull(); @@ -3503,7 +3357,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(firstCard?.isConnected).toBe(true); expect( screen.getByRole('button', { - name: '打开资源详情:设计文档 稳定依赖图回执', + name: '选中资源:设计文档 稳定依赖图回执', }), ).not.toBeNull(); @@ -3513,7 +3367,7 @@ export function registerProjectWorkbenchFoundationTests() { }); expect( await screen.findByRole('button', { - name: '打开资源详情:设计文档 刷新期间新增回执', + name: '选中资源:设计文档 刷新期间新增回执', }), ).not.toBeNull(); expect(firstCard?.isConnected).toBe(true); @@ -3725,7 +3579,7 @@ export function registerProjectWorkbenchFoundationTests() { await waitFor(() => expect(layoutReads).toBe(1)); await openResourceBookCategory('设计文档'); const cards = [0, 1, 2].map((depth) => - getResourceDetailButton(`truncated-depth-${depth}.json`).closest( + getResourceSelectButton(`truncated-depth-${depth}.json`).closest( '.game-resource-card', ), ); @@ -3819,7 +3673,7 @@ export function registerProjectWorkbenchFoundationTests() { renderWorkbench(); await openResourceBookCategory('设计文档'); const cardButton = await screen.findByRole('button', { - name: '打开资源详情:设计文档 布局持久化回执', + name: '选中资源:设计文档 布局持久化回执', }); const card = cardButton.closest('.game-resource-card'); expect(card).not.toBeNull(); @@ -3862,7 +3716,7 @@ export function registerProjectWorkbenchFoundationTests() { clientY: 60, }); fireEvent.click(card!); - expect(screen.queryByRole('dialog', { name: '布局持久化回执' })).toBeNull(); + expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull(); await waitFor(() => { expect(card?.getAttribute('style')).toContain( @@ -3888,16 +3742,14 @@ export function registerProjectWorkbenchFoundationTests() { ); fireEvent.click(card!); - expect( - screen.getByRole('dialog', { name: '布局持久化回执' }), - ).not.toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); cleanup(); renderWorkbench(); await openResourceBookCategory('设计文档'); const restoredCard = ( await screen.findByRole('button', { - name: '打开资源详情:设计文档 布局持久化回执', + name: '选中资源:设计文档 布局持久化回执', }) ).closest('.game-resource-card'); await waitFor(() => { @@ -3955,7 +3807,7 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('美术资源'); - const cardButton = await findResourceDetailButton('clickable.png'); + const cardButton = await findResourceSelectButton('clickable.png'); fireEvent.pointerDown(cardButton, { pointerId: 7, button: 0, @@ -3974,9 +3826,7 @@ export function registerProjectWorkbenchFoundationTests() { }); fireEvent.click(cardButton.closest('.game-resource-card')!); - expect( - screen.getByRole('dialog', { name: 'clickable.png' }), - ).not.toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); expect( invoke.mock.calls.some( ([command, args]) => @@ -4040,7 +3890,7 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('美术资源'); - const cardButton = await findResourceDetailButton('cancelable.png'); + const cardButton = await findResourceSelectButton('cancelable.png'); const card = cardButton.closest('.game-resource-card'); expect(card).not.toBeNull(); Object.defineProperties(card!, { @@ -4062,9 +3912,7 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.pointerCancel(card, { pointerId: 41 }); fireEvent.click(card!); - expect( - screen.getByRole('dialog', { name: 'cancelable.png' }), - ).not.toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); }); it('maps manifest asset kinds into stable type layout ordering', async () => { @@ -4221,7 +4069,7 @@ export function registerProjectWorkbenchFoundationTests() { await openResourceBookCategory('设计文档'); expect( await screen.findByRole('button', { - name: '打开资源详情:设计文档 自动排版失败回执', + name: '选中资源:设计文档 自动排版失败回执', }), ).not.toBeNull(); }); @@ -4355,7 +4203,7 @@ export function registerProjectWorkbenchFoundationTests() { /
\{walletEntry\}<\/div>/, ); expect(projectDevelopmentSource).toMatch( - /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!focusedResource\s*&&\s*!resourceEditorRoute\s*&&\s*!uiEditorRoute/s, + /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*selectedResourceIds\.length === 0\s*&&\s*!uiEditorRoute/s, ); expect(projectDevelopmentSource).toMatch( /aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s, @@ -4523,13 +4371,9 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.change(screen.getByLabelText('搜索项目资源'), { target: { value: 'hero.png' }, }); - fireEvent.click(await findResourceDetailButton('hero.png')); - const heroDetails = screen.getByRole('dialog', { name: 'hero.png' }); - expect(within(heroDetails).getByText('assets/hero.png')).not.toBeNull(); - expect(within(heroDetails).getByText('image/png')).not.toBeNull(); - expect(within(heroDetails).getByText('引用上游')).not.toBeNull(); - expect(heroDetails.querySelector('img')).toBeNull(); - expect(screen.queryByRole('img', { name: 'hero.png 图片预览' })).toBeNull(); + fireEvent.click(await findResourceSelectButton('hero.png')); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); + expect(screen.queryByRole('dialog', { name: 'hero.png' })).toBeNull(); }); it('marks an unvalidated UI prototype as a candidate image', async () => { @@ -4576,7 +4420,7 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('美术资源'); - const candidate = await findResourceDetailButton( + const candidate = await findResourceSelectButton( 'ui-prototype.png(待视觉验收)', ); const candidateCard = candidate.closest('.game-resource-card'); diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 5293b7e64..e340032f2 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -13,11 +13,13 @@ import ProjectDevelopmentView from '../src/view/project-development'; import { act, createGameCreationAppManifest, - findResourceDetailButton, + findResourceSelectButton, fireEvent, render, screen, + setComposerText, waitFor, + within, } from './appSurface/harness'; const projectPath = '/tmp/live-canvas-integration'; @@ -473,39 +475,36 @@ describe('project resource live canvas integration', () => { }; } - it('derives a document non-destructively and retries with the original operation identity', async () => { + it('derives a new art asset non-destructively from the quick edit panel and reuses the original operation identity', async () => { const { deriveCalls } = installTauri({ failFirstDerive: true }); render(); - fireEvent.click(await findResourceDetailButton('rules.md')); - fireEvent.click(screen.getByRole('button', { name: '编辑资源' })); - expect(await screen.findByText('编辑现有资源')).not.toBeNull(); - fireEvent.change(screen.getByLabelText('编辑提示词'), { - target: { value: '把角色头发设定改为红色' }, + fireEvent.click(screen.getByRole('button', { name: '打开美术资源' })); + fireEvent.click(await findResourceSelectButton('source-art.png')); + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', }); - fireEvent.click(screen.getByRole('button', { name: '生成派生资源' })); + fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); + const panel = await screen.findByRole('dialog', { + name: '快速编辑图片', + }); + await setComposerText( + within(panel).getByLabelText('快速编辑提示词'), + '把角色头发设定改为红色', + ); + fireEvent.click(within(panel).getByRole('button', { name: '修改' })); expect(await screen.findByRole('alert')).not.toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '使用原请求重试' })); + fireEvent.click(within(panel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(2)); expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId); expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey); + expect(deriveCalls[0]?.editKind).toBe('image-reference'); + expect(deriveCalls[0]?.generationMode).toBe('derive'); expect(deriveCalls[0]).not.toHaveProperty('accessToken'); expect(deriveCalls[0]).not.toHaveProperty('apiKey'); expect(deriveCalls[1]).not.toHaveProperty('accessToken'); expect(deriveCalls[1]).not.toHaveProperty('apiKey'); - const operationId = String(deriveCalls[1]?.operationId); - expect( - await screen.findByRole('dialog', { - name: new RegExp(operationId, 'u'), - }), - ).not.toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '收起资源' })); - expect( - await screen.findByRole('button', { - name: '打开资源详情:设计文档 rules.md', - }), - ).not.toBeNull(); }); it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => { @@ -529,11 +528,14 @@ describe('project resource live canvas integration', () => { expect(resumeCalls[0]).not.toHaveProperty('prompt'); expect(resumeCalls[0]).not.toHaveProperty('endpoint'); expect(resumeCalls[0]).not.toHaveProperty('idempotencyKey'); + // 恢复完成后新素材被选中;不再有"资源详情"面板。 expect( - await screen.findByRole('dialog', { - name: /recovered-rules\.md/, - }), - ).not.toBeNull(); + ( + await screen.findByRole('button', { + name: /选中资源:\S+ \S*recovered-rules\.md$/, + }) + ).getAttribute('aria-pressed'), + ).toBe('true'); }); it('显式确认旧 Key-bound 服务后只用原 operation 继续恢复', async () => { @@ -683,8 +685,6 @@ describe('project resource live canvas integration', () => { expect(resumeCalls[0]?.operationId).toBe(resumableOperationId); expect(resumeCalls[0]?.operationId).not.toBe(reconciliationOperationId); - fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); - fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (2)', diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index cd2c772ef..bdb924146 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -10,7 +10,7 @@ import { Sparkles, WandSparkles, } from 'lucide-react'; -import type { CSSProperties } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; @@ -19,7 +19,28 @@ import { isQuickEditSupportedLayer, } from './ImageCanvasGenerationModel'; +/** 选中工具条上的动作标识,供宿主声明自己实际可用的动作集合。 */ +export type ImageCanvasSelectedToolbarAction = + | 'quick-edit' + | 'redraw' + | 'crop-expand' + | 'remove-background' + | 'perfect-pixel' + | 'split-icon-spritesheet' + | 'extract-ui-design' + | 'character-animation' + | 'download'; + type ImageCanvasSelectedLayerToolbarViewProps = { + /** + * 宿主显式声明的可用动作集合。 + * + * 传 `null`(默认)时完全沿用美术画布内置判定;传入集合时以集合为准, + * 让没有对应后端链路的画布宿主不必把「点了没反应」的按钮渲染出来。 + */ + supportedActions?: ReadonlySet | null; + /** 宿主在工具条上追加的动作节点,渲染在下载按钮之前。 */ + extraActions?: ReactNode; selectedLayer: CanvasLayer | null; selectedToolbarStyle: CSSProperties | null; onOpenQuickEditPanel: (layer: CanvasLayer) => void; @@ -38,6 +59,8 @@ type ImageCanvasSelectedLayerToolbarViewProps = { }; export function ImageCanvasSelectedLayerToolbarView({ + supportedActions = null, + extraActions, selectedLayer, selectedToolbarStyle, onOpenQuickEditPanel, @@ -57,7 +80,20 @@ export function ImageCanvasSelectedLayerToolbarView({ if (!selectedLayer || !selectedToolbarStyle) { return null; } - const canRedraw = canOpenRedrawPanel(selectedLayer); + const isActionSupported = ( + action: ImageCanvasSelectedToolbarAction, + builtInAvailability: boolean, + ) => (supportedActions ? supportedActions.has(action) : builtInAvailability); + const canRedraw = isActionSupported( + 'redraw', + canOpenRedrawPanel(selectedLayer), + ); + const extraActionDivider = extraActions ? ( +
); } @@ -93,6 +133,14 @@ export function ImageCanvasSelectedLayerToolbarView({ selectedLayer.mediaType !== 'video' && selectedLayer.mediaType !== 'image-sequence' && selectedLayer.assetKind !== 'character-animation'; + const showQuickEdit = isActionSupported( + 'quick-edit', + isQuickEditSupportedLayer(selectedLayer), + ); + const showCharacterAnimation = isActionSupported( + 'character-animation', + selectedLayer.assetKind === 'character', + ); return (
event.stopPropagation()} > - {isQuickEditSupportedLayer(selectedLayer) ? ( + {showQuickEdit ? ( <> ) : null} - {canRasterEdit ? ( - <> - onOpenCropExpandPanel(selectedLayer)} - /> - onRemoveBackground(selectedLayer)} - /> - - ) : ( - - ) - } - // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 - // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 - // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 - // 与相邻的拆分图集按钮保持同一套门禁。 - disabled={ - isPersistingAssetKind || - isPerfectPixelProcessing || - isPerfectPixelPendingConfirmation - } - aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} - onClick={() => onPerfectPixel(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isPerfectPixelProcessing - ? '处理中' - : isPerfectPixelPendingConfirmation - ? '待确认' - : '完美像素'} - - - + {canRasterEdit && isActionSupported('crop-expand', true) ? ( + onOpenCropExpandPanel(selectedLayer)} + /> ) : null} - {selectedLayer.assetKind === 'icon-spritesheet' ? ( + {canRasterEdit && isActionSupported('remove-background', true) ? ( + onRemoveBackground(selectedLayer)} + /> + ) : null} + {canRasterEdit && isActionSupported('perfect-pixel', true) ? ( + + ) : ( + + ) + } + // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 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} + {selectedLayer.assetKind === 'icon-spritesheet' && + isActionSupported('split-icon-spritesheet', true) ? ( ) : null} - {selectedLayer.assetKind === 'ui-design' ? ( + {selectedLayer.assetKind === 'ui-design' && + isActionSupported('extract-ui-design', true) ? ( 提取素材 ) : null} - {selectedLayer.assetKind === 'character' ? ( + {showCharacterAnimation ? ( ) : null} - onDownloadLayer(selectedLayer)} - /> + {extraActionDivider} + {extraActions} + {isActionSupported('download', true) ? ( + onDownloadLayer(selectedLayer)} + /> + ) : null}
); } diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index a7f772a73..765e23c97 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -493,7 +493,7 @@ export async function navigateWechatMiniProgramPage( success() { resolve(); }, - fail(_error) { + fail(_error: unknown) { console.error('[host-bridge] wechat mini program navigation failed'); reject(new Error(errorMessage)); },