From 082d8d62dee446f5200fa20c8a8fb4965b6be2c0 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 16:42:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=E8=BF=90=E8=A1=8C=E9=A1=B5=E7=AD=BE?= =?UTF-8?q?=E7=9A=84=E8=B5=84=E6=BA=90=E4=BF=A1=E6=81=AF=E6=90=AC=E5=88=B0?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E9=80=89=E4=B8=AD=E5=B7=A5=E5=85=B7=E6=9D=A1?= =?UTF-8?q?=EF=BC=8C=E4=B8=A4=E5=A4=84=E5=85=B1=E7=94=A8=E4=B8=80=E4=BB=BD?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增「信息」动作:排在「@引用」之后、「编辑标签」之前,点开非模态信息浮层,再点一次即收起(aria-pressed 反映开合) - 新增只读字段组件 ResourceInfoFieldsView(ResourceInfoPanelView.tsx),运行页签的「信息展示」与画布浮层共用,字段清单只在 resolveResourceInfoFieldRows 单点定义 - 字段口径:名称 / 路径 / 类型 + 只读分类与标签(分类取画布栏目口径,标签为空时给「暂无标签」),刻意不含来源任务、资产 ID 等 manifest 内部标识 - 浮层 chrome 与快速编辑浮层同族(同一锚点、同档圆角与阴影),样式落在 resourceCanvasChrome.css;关闭入口三处:动作按钮、面板关闭按钮、画布浮层统一的 Esc / 点边界外 - 关闭判据收口:resolveResourceCanvasQuickEditDismissOpen 更名为 resolveResourceCanvasFloatingPanelDismissOpen、参数改为 isFloatingPanelOpen,宿主把「快速编辑开」与「信息开」合成一个布尔 - .game-resource-info-panel 补进 RESOURCE_CANVAS_INTERACTION_SELECTOR,否则点在只读字段值上会被判成点画布空白而清掉选中 - 换选 / 清空选中 / 切项目时信息浮层自动收起:在选中派生处按 selectedResourceId 收口,不在每个重置点各补一行 - 测试:新增 resourceCanvasInfoModel 三条与「信息面板在画布浮层与运行页签里渲染同一份只读字段」(工具条顺序、开合、关闭按钮只收面板、点画布以外收起、Esc 连工具条一起收、运行页签字段逐行相同);resourceCanvasFloatingDismiss 的判据用例与交互选择器夹具改为浮层通用口径 - resourceCanvasChrome.css 的字段排版收在浮层选择器下,不跨上下文改运行页签的字号与列宽 - decision-log 新增一条:记录这次对 Issue #309 C3「取消资源详情面板」的有意局部回退(只回退「看信息」,不恢复可编辑详情面板与全屏编辑路由),并登记需要同步的 C3 引用位置 --- .../resource-canvas/resourceCanvasChrome.css | 84 ++++++++ .../resourceCanvasFocusModel.ts | 32 +-- .../resourceCanvasInfoModel.ts | 87 +++++++++ .../ResourceInfoPanelView.tsx | 81 ++++++++ .../src/view/project-development/index.tsx | 78 +++++--- .../appSurface/project-development.suite.ts | 182 +++++++++++++++++- .../resourceCanvasFloatingDismiss.test.tsx | 29 +-- .../tests/resourceCanvasInfoModel.test.ts | 96 +++++++++ .../shared-memory/decision-log.md | 12 ++ 9 files changed, 627 insertions(+), 54 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasInfoModel.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/ResourceInfoPanelView.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasInfoModel.test.ts 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 index 068b31d02..aa07a6335 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -759,6 +759,90 @@ pointer-events: auto; } +/* 信息浮层:与快速编辑浮层同一族(同一个锚点、同一档圆角与阴影), + * 但只读、更窄,所以不复用生成 composer 的栅格。 */ +.game-resource-canvas-toolbar-host .game-resource-info-panel { + position: absolute; + z-index: 70; + display: grid; + width: min(19rem, calc(100vw - 1.5rem)); + max-height: min(24rem, calc(100% - 1.5rem)); + gap: 0.5rem; + overflow: auto; + border: 1px solid rgba(148, 163, 184, 0.36); + border-radius: 1.1rem; + background: rgba(255, 255, 255, 0.97); + padding: 0.72rem; + color: #1f2937; + font-size: 0.78rem; + box-shadow: 0 22px 48px rgba(15, 23, 42, 0.22); + transform: translateX(-50%); + pointer-events: auto; +} + +.game-resource-info-panel__header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.38rem; + color: #334155; +} + +.game-resource-info-panel__header strong { + font-size: 0.82rem; + font-weight: 800; +} + +.game-resource-info-panel__close { + display: grid; + width: 1.5rem; + height: 1.5rem; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: #64748b; + cursor: pointer; + place-items: center; +} + +.game-resource-info-panel__close:hover, +.game-resource-info-panel__close:focus-visible { + background: rgba(148, 163, 184, 0.22); + color: #1f2937; +} + +/* 字段列表在信息浮层里单独排版;运行页签那处继续用 `.game-run-panels` 的既有口径, + * 所以这里把声明全部收在浮层选择器下,不跨上下文改字号与列宽。 */ +.game-resource-info-panel .game-resource-info-fields { + display: grid; + gap: 0.34rem; + margin: 0; +} + +.game-resource-info-panel .game-resource-info-fields div { + display: grid; + grid-template-columns: 3.1rem minmax(0, 1fr); + gap: 0.4rem; +} + +.game-resource-info-panel .game-resource-info-fields dt, +.game-resource-info-panel .game-resource-info-fields dd { + margin: 0; + font-size: 0.72rem; + line-height: 1.5; +} + +.game-resource-info-panel .game-resource-info-fields dt { + color: #94a3b8; +} + +.game-resource-info-panel .game-resource-info-fields dd { + color: #1f2937; + font-weight: 620; + overflow-wrap: anywhere; +} + @media (max-width: 720px) { .game-resource-canvas-toolbar-host .image-canvas-editor__floating-toolbar { 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 a5db859f6..6096de61c 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 @@ -5,7 +5,7 @@ * 的 `clearCanvasFocus()` 与 `src/components/image-editor/useImageCanvasKeyboardShortcuts.ts` * 的 Escape 分支),AGC 不再自带第二套口径: * - * - 画布空白处左键 pointerdown → 清焦点(清空选中 + 关闭快速编辑浮层); + * - 画布空白处左键 pointerdown → 清焦点(清空选中 + 关闭画布浮层:快速编辑与信息); * - 点在资源卡、交互控件或画布浮层里 → 不清焦点; * - 选中变化 / 切项目 / 切模式 → 由宿主既有的重置逻辑负责。 */ @@ -14,8 +14,8 @@ * 落在这些元素里的 pointerdown 不清画布焦点。 * * 与美术画布「点在图层上不清焦点、点在空白/world 上才清」同口径:AGC 的资源卡 - * 等价于图层,工具条与快速编辑面板等价于浮层,其余交互控件(按钮、输入、缩放、 - * 页签)都不该顺手把选中清掉。 + * 等价于图层,工具条、快速编辑面板与信息浮层等价于浮层,其余交互控件(按钮、输入、 + * 缩放、页签)都不该顺手把选中清掉。 */ export const RESOURCE_CANVAS_INTERACTION_SELECTOR = [ 'button', @@ -28,6 +28,7 @@ export const RESOURCE_CANVAS_INTERACTION_SELECTOR = [ '.game-resource-book-zoom', '.image-canvas-editor__floating-toolbar', '.image-canvas-editor__generation-composer', + '.game-resource-info-panel', ].join(', '); /** 这一次画布 pointerdown 要不要清焦点。 */ @@ -38,10 +39,10 @@ export function isResourceCanvasInteractionTarget( } /** - * 快速编辑面板是否可以被清焦点顺手关掉。 + * 画布浮层是否可以被清焦点顺手关掉。 * * 对齐美术画布 `closeTransientEditorPanels()`:生成中的面板不关,否则用户看不到 - * 「修改中」状态,也拿不到失败后的重试入口。 + * 「修改中」状态,也拿不到失败后的重试入口。信息浮层是只读的,没有生成态。 */ export function canDismissResourceCanvasQuickEdit( panel: { readonly status: string } | null | undefined, @@ -71,26 +72,26 @@ export function isResourceCanvasHostOverlayOpen( } /** - * 快速编辑浮层是否接管「点外部 / Esc 关闭」。 + * 画布浮层(快速编辑 / 信息)是否接管「点外部 / Esc 关闭」。 * - * 只有它自己开着时才接管,而且是唯一的取消条件:AGC 弹层开着时不接管——资源面板的 + * 只有它们自己开着时才接管,而且是唯一的取消条件:AGC 弹层开着时不接管——资源面板的 * 多选直接读写画布那一份 `selectedResourceIds`,此刻清选中会把用户刚勾的资源清空。 * * 选中工具条不在这里,它跟美术画布一样只随选中存在:单独选中一个资源卡不会因为 * 点了一下对话区就丢选中(资源面板下载、版本绑定高亮都挂在同一份选中上)。 */ -export function resolveResourceCanvasQuickEditDismissOpen({ +export function resolveResourceCanvasFloatingPanelDismissOpen({ isCanvasVisible, - isQuickEditPanelOpen, + isFloatingPanelOpen, hostOverlay, }: { isCanvasVisible: boolean; - isQuickEditPanelOpen: boolean; + isFloatingPanelOpen: boolean; hostOverlay: ResourceCanvasHostOverlayState; }): boolean { return ( isCanvasVisible && - isQuickEditPanelOpen && + isFloatingPanelOpen && !isResourceCanvasHostOverlayOpen(hostOverlay) ); } @@ -99,21 +100,22 @@ export function resolveResourceCanvasQuickEditDismissOpen({ * 宿主自己的 Escape 是否归资源画布。 * * 对齐美术画布:Escape 走 `closeTransientEditorPanels()` + `clearCanvasSelection()`, - * 既收浮层也清选中。AGC 自带弹窗开着时让位给弹窗自己(Esc 只关弹窗)。 + * 既收浮层也清选中。AGC 自带弹窗开着时让位给弹窗自己(Esc 只关弹窗);画布浮层开着时 + * 由浮层在 document 上截断(见 `resolveResourceCanvasFloatingPanelDismissOpen`)。 */ export function resolveResourceCanvasFocusEscapeActive({ isCanvasVisible, hasSelectedResource, - isQuickEditPanelOpen, + isFloatingPanelOpen, hostOverlay, }: { isCanvasVisible: boolean; hasSelectedResource: boolean; - isQuickEditPanelOpen: boolean; + isFloatingPanelOpen: boolean; hostOverlay: ResourceCanvasHostOverlayState; }): boolean { if (!isCanvasVisible || isResourceCanvasHostOverlayOpen(hostOverlay)) { return false; } - return hasSelectedResource || isQuickEditPanelOpen; + return hasSelectedResource || isFloatingPanelOpen; } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasInfoModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasInfoModel.ts new file mode 100644 index 000000000..9370975b7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasInfoModel.ts @@ -0,0 +1,87 @@ +import type { + CanvasLayer, + CanvasOverlayStyle, + CanvasViewport, + QuickEditPanelState, +} from '@genarrative/image-canvas-core'; +import { resolveQuickEditPanelStyle } from '@genarrative/image-canvas-core'; + +import type { + ProjectResource, + ProjectResourceCategory, +} from '../../view/project-development/resourceProjectionModel'; +import { resourceReferenceCategoryLabel } from '../project-workspace/resourceReferences'; + +export type ResourceInfoFieldRow = { + label: string; + value: string; +}; + +const EMPTY_TAGS_TEXT = '暂无标签'; + +/** + * 栏目文案与资源筛选同源;`version` 不是跨端资源分类,和画布栏目一样单独给文案。 + */ +function resourceCategoryLabel(category: ProjectResourceCategory) { + return category === 'version' + ? '项目版本' + : resourceReferenceCategoryLabel(category); +} + +/** + * 资源信息面板的字段清单:名称 / 路径 / 类型 / 分类 / 标签。 + * + * 全部只读,且刻意不含「来源任务」「资产 ID」等 manifest 内部标识——面板是给创作者看的 + * 事实卡,不是调试视图。分类取画布栏目口径(与资源卡类型徽标、资源书栏目一致), + * 标签只读展示 manifest `tags`,没有标签时给明确文案而不是留空。 + */ +export function resolveResourceInfoFieldRows( + resource: ProjectResource, +): ResourceInfoFieldRow[] { + const tags = resource.assetTags ?? []; + return [ + { label: '名称', value: resource.label }, + { label: '路径', value: resource.path }, + { label: '类型', value: resource.mediaType }, + { label: '分类', value: resourceCategoryLabel(resource.category) }, + { + label: '标签', + value: tags.length > 0 ? tags.join('、') : EMPTY_TAGS_TEXT, + }, + ]; +} + +/** + * 信息浮层锚点:与快速编辑浮层共用同一条几何口径(贴着选中资源卡下沿居中), + * 所以直接复用 `resolveQuickEditPanelStyle`,不在这里另写一份锚点算法。 + * + * 该函数只读 `panel` 判空,这里传一个固定探针:信息浮层没有提示词之类的面板状态, + * 不该为了复用几何被迫持有一份快速编辑专属字段。 + */ +const INFO_PANEL_ANCHOR_PROBE: QuickEditPanelState = { + sourceLayerId: '', + prompt: '', + size: '', + model: '', + status: 'idle', +}; + +/** 信息浮层的锚点输入:选中图层 + 画布视口/尺寸。 */ +export type ResourceInfoPanelAnchor = { + sourceLayer: CanvasLayer | null; + viewport: CanvasViewport; + canvasSize: { width: number; height: number }; +}; + +export function resolveResourceInfoPanelStyle({ + sourceLayer, + viewport, + canvasSize, +}: ResourceInfoPanelAnchor): CanvasOverlayStyle | null { + return resolveQuickEditPanelStyle({ + panel: INFO_PANEL_ANCHOR_PROBE, + sourceLayer, + viewport, + canvasSize, + }); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceInfoPanelView.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceInfoPanelView.tsx new file mode 100644 index 000000000..94eadd92e --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceInfoPanelView.tsx @@ -0,0 +1,81 @@ +import { Info, X } from 'lucide-react'; + +import { + resolveResourceInfoFieldRows, + resolveResourceInfoPanelStyle, + type ResourceInfoPanelAnchor, +} from '../../features/resource-canvas/resourceCanvasInfoModel'; +import type { ProjectResource } from './resourceProjectionModel'; + +/** + * 只读资源信息字段。运行页签的「信息展示」与画布上的信息浮层共用这一份, + * 字段清单只在 `resolveResourceInfoFieldRows` 里定义,两处不会各说一套。 + */ +export function ResourceInfoFieldsView({ + resource, +}: { + resource: ProjectResource; +}) { + return ( +
+ {resolveResourceInfoFieldRows(resource).map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ ); +} + +export type ResourceInfoPanelViewProps = ResourceInfoPanelAnchor & { + resource: ProjectResource; + onClose: () => void; +}; + +/** + * 画布上的信息浮层:贴着选中资源卡,非模态。 + * + * 关闭时机(Esc / 点边界外)由宿主复用 `useImageCanvasFloatingOptionDismiss` 接管, + * 这里只负责渲染,所以面板本身不注册任何 document 监听。 + */ +export function ResourceInfoPanelView({ + resource, + sourceLayer, + viewport, + canvasSize, + onClose, +}: ResourceInfoPanelViewProps) { + const style = resolveResourceInfoPanelStyle({ + sourceLayer, + viewport, + canvasSize, + }); + if (!style) { + return null; + } + + return ( +
+
+
+ +
+ ); +} 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 9ab2bbdd4..1f4352ef4 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 @@ -97,8 +97,8 @@ import { import { canDismissResourceCanvasQuickEdit, isResourceCanvasInteractionTarget, + resolveResourceCanvasFloatingPanelDismissOpen, resolveResourceCanvasFocusEscapeActive, - resolveResourceCanvasQuickEditDismissOpen, } from '../../features/resource-canvas/resourceCanvasFocusModel'; import { isResourceCanvasGenerationAvailable, @@ -216,6 +216,10 @@ import { canonicalProjectedResourceMediaType, defaultDerivedResourceName, } from './resourceEditModel'; +import { + ResourceInfoFieldsView, + ResourceInfoPanelView, +} from './ResourceInfoPanelView'; import { type ProjectAgentResultSummary, type ProjectAttachmentResult, @@ -1366,6 +1370,8 @@ export default function ProjectDevelopmentView({ useState(null); const [quickEditPanel, setQuickEditPanel] = useState(null); + /** 画布上的只读信息浮层(「信息」动作的落点),与运行页签的信息面板同源。 */ + const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false); const [resourceCanvasMarquee, setResourceCanvasMarquee] = useState(null); /** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */ @@ -1502,12 +1508,14 @@ export default function ProjectDevelopmentView({ * 清资源画布焦点:对齐美术画布 `clearCanvasFocus()`。 * * 美术画布在画布空白 pointerdown 与 Escape 两处都会走这一步——清空选中让选中工具条 - * 收起,并关掉快速编辑浮层。资源画布保持同一套时机:点在资源卡或浮层里不清, - * 点画布空白、按 Esc 才清。生成中的面板不关,与美术画布 - * `closeTransientEditorPanels()` 的 `status === 'generating'` 判断一致。 + * 收起,并关掉画布浮层(快速编辑与信息)。资源画布保持同一套时机:点在资源卡或浮层里 + * 不清,点画布空白、按 Esc 才清。生成中的快速编辑面板不关,与美术画布 + * `closeTransientEditorPanels()` 的 `status === 'generating'` 判断一致;信息浮层只读, + * 没有生成态,随焦点一起收起。 */ const clearResourceCanvasFocus = useCallback(() => { setSelectedResourceIds([]); + setResourceInfoPanelOpen(false); if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) { return; } @@ -1515,19 +1523,19 @@ export default function ProjectDevelopmentView({ }, [closeResourceQuickEditPanel]); /** - * 快速编辑浮层的「点外部 / Esc 关闭」。 + * 画布浮层的「点外部 / Esc 关闭」。 * * 直接复用美术画布的 `useImageCanvasFloatingOptionDismiss`,不另写判据:它在 * document 上同时接管 click 与 Escape,点在边界外即关、点在边界内(含 * `.image-canvas-editor__portal-menu` 弹出层,例如模型 / 比例下拉)保留。 * - * 边界取整个资源画布管理器:面板内部的点(改提示词、切选项)不算外部;点到画布 - * 以外(顶部工具条、对话区、左侧导航)立即收起。 + * 边界取整个资源画布管理器:面板内部的点(改提示词、切选项、看信息)不算外部; + * 点到画布以外(顶部工具条、对话区、左侧导航)立即收起。 */ useImageCanvasFloatingOptionDismiss({ - isOpen: resolveResourceCanvasQuickEditDismissOpen({ + isOpen: resolveResourceCanvasFloatingPanelDismissOpen({ isCanvasVisible: mode === 'resources' && !uiEditorRoute, - isQuickEditPanelOpen: quickEditPanel !== null, + isFloatingPanelOpen: quickEditPanel !== null || resourceInfoPanelOpen, hostOverlay: { isResourcePanelOpen: resourcePanelOpen, isGenerationPanelOpen: resourceGenerationOpen, @@ -1553,7 +1561,7 @@ export default function ProjectDevelopmentView({ !resolveResourceCanvasFocusEscapeActive({ isCanvasVisible: mode === 'resources' && !uiEditorRoute, hasSelectedResource: selectedResourceIds.length > 0, - isQuickEditPanelOpen: quickEditPanel !== null, + isFloatingPanelOpen: quickEditPanel !== null || resourceInfoPanelOpen, hostOverlay: { isResourcePanelOpen: resourcePanelOpen, isGenerationPanelOpen: resourceGenerationOpen, @@ -1578,12 +1586,14 @@ export default function ProjectDevelopmentView({ quickEditPanel, resourceClassificationAssetId, resourceGenerationOpen, + resourceInfoPanelOpen, resourcePanelOpen, resourceRecoveryPanelOpen, resourceRenameAssetId, selectedResourceIds, uiEditorRoute, ]); + const resourceSectionScrollPositionsRef = useRef( new Map(), ); @@ -1883,6 +1893,14 @@ export default function ProjectDevelopmentView({ const selectedResource = canvasResources.find((resource) => resource.id === selectedResourceId) ?? null; + /** + * 信息浮层只对打开它的那次选中有效:换选、清空选中、切项目都会关掉它, + * 面板里不会留下上一张资源的陈旧信息。放在选中派生处而不是逐个重置点补一行, + * 是因为选中本身有多个清空入口(清焦点、切视图、切项目)。 + */ + useEffect(() => { + setResourceInfoPanelOpen(false); + }, [selectedResourceId]); const projectVersions = useMemo( () => manifest.versions ?? [], [manifest.versions], @@ -5746,6 +5764,20 @@ export default function ProjectDevelopmentView({ @引用 ) : null} + {selectedResource ? ( + } + onClick={() => + setResourceInfoPanelOpen((open) => !open) + } + > + 信息 + + ) : null} {selectedResource?.manifestAssetId ? ( setResourceInfoPanelOpen(false)} + /> + ) : null} ) } @@ -6241,20 +6284,7 @@ export default function ProjectDevelopmentView({ 信息展示 {selectedResource ? ( -
-
-
名称
-
{selectedResource.label}
-
-
-
路径
-
{selectedResource.path}
-
-
-
类型
-
{selectedResource.mediaType}
-
-
+ ) : null}
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 093e299e0..996edef92 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 @@ -215,6 +215,17 @@ function styleNumber(body: string, property: string) { return Number(new RegExp(`${property}\\s*:\\s*(\\d+)`).exec(body)?.[1]); } +/** 按渲染顺序读出只读信息字段,用来比对画布浮层与运行页签两处是否同一份字段。 */ +function resourceInfoFieldRows(scope: HTMLElement) { + const labels = Array.from(scope.querySelectorAll('dt')).map( + (node) => node.textContent, + ); + const values = Array.from(scope.querySelectorAll('dd')).map( + (node) => node.textContent, + ); + return labels.map((label, index) => [label, values[index]]); +} + function colorAlpha(value: string) { const match = /rgba?\(([^)]*)\)/u.exec(value); if (!match) { @@ -3082,6 +3093,169 @@ export function registerProjectWorkbenchFoundationTests() { }, ]); }); + + it('信息面板在画布浮层与运行页签里渲染同一份只读字段', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-resource-info', + '资源信息测试', + ); + const codePrototype = manifest.tasks.find( + (task) => task.id === 'code-prototype', + ); + if (!codePrototype) { + throw new Error('missing code-prototype seed task'); + } + codePrototype.status = 'completed'; + manifest.preview = { + status: 'running', + url: 'http://127.0.0.1:4173', + port: 4173, + }; + manifest.assets.push({ + id: 'character-hero', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + tags: ['主角', '待定稿'], + source: { kind: 'generated' }, + }); + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: layoutRevision, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + if (command === 'read_local_project_media_preview') { + return { + path: 'assets/hero.png', + mediaType: 'image/png', + byteLen: 48, + dataUrl: 'data:image/png;base64,iVBORw0KGgo=', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: '资源信息测试', + projectPath: '/tmp/workbench-resource-info', + manifest, + attachments: [], + recentRunStatus: 'completed', + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + // 只读字段与运行页签同源:名称 / 路径 / 类型 + 只读的分类与标签, + // 不含「来源任务」「资产 ID」这类 manifest 内部标识。 + const expectedRows = [ + ['名称', 'hero.png'], + ['路径', 'assets/hero.png'], + ['类型', 'image/png'], + ['分类', '角色与对象'], + ['标签', '主角、待定稿'], + ]; + + 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('编辑标签'), + ); + expect(infoButton.getAttribute('aria-pressed')).toBe('false'); + + fireEvent.click(infoButton); + const canvasPanel = await screen.findByRole('dialog', { + name: '资源信息', + }); + expect(resourceInfoFieldRows(canvasPanel)).toEqual(expectedRows); + expect(infoButton.getAttribute('aria-pressed')).toBe('true'); + // 再点一次同一个动作即收起,不用去别处找关闭入口。 + fireEvent.click(infoButton); + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + + // 关闭按钮只收面板:选中与工具条留着,方便接着看别的动作。 + fireEvent.click(infoButton); + fireEvent.click( + within(await screen.findByRole('dialog', { name: '资源信息' })).getByRole( + 'button', + { name: '关闭信息面板' }, + ), + ); + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); + + // 画布浮层的统一关闭时机:点画布以外立即收起。 + fireEvent.click(infoButton); + expect( + await screen.findByRole('dialog', { name: '资源信息' }), + ).not.toBeNull(); + fireEvent.click(document.body); + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + + // Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。 + fireEvent.click(await findResourceSelectButton('hero.png')); + const reopenedToolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + fireEvent.click( + within(reopenedToolbar).getByRole('button', { name: '信息' }), + ); + expect( + await screen.findByRole('dialog', { name: '资源信息' }), + ).not.toBeNull(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull(); + + // 运行页签的「信息展示」渲染的是同一份字段,不是第二套口径。 + fireEvent.click(await findResourceSelectButton('hero.png')); + const runTab = screen.getByRole('tab', { name: '运行' }); + fireEvent.click(runTab); + const runInfoPanel = screen.getByLabelText('资源信息面板'); + expect(resourceInfoFieldRows(runInfoPanel)).toEqual(expectedRows); + // 画布浮层只属于画布:切到运行视图后不再渲染。 + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + }); + it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => { const manifest = createGameCreationAppManifest( 'workbench-resource-media', @@ -3272,9 +3446,9 @@ export function registerProjectWorkbenchFoundationTests() { ).toBe(false); // 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」), // 并且只渲染宿主编排层真实接通的动作:「@引用」(从卡片挪进工具条的引用入口, - // 资源卡上的圆钮已删除)「编辑标签」(面板只编辑 manifest `assets[].tags`, - // 分类不再有手动入口)「重命名」已接面板,「下载按钮」复用资源面板同一条落盘 - // 链路,「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。 + // 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest + // `assets[].tags`,分类不再有手动入口)「重命名」已接面板,「下载按钮」复用资源 + // 面板同一条落盘链路,「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。 const audioToolbar = screen.getByRole('toolbar', { name: '素材工具栏', }); @@ -3285,7 +3459,7 @@ export function registerProjectWorkbenchFoundationTests() { within(audioToolbar) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), - ).toEqual(['引用资源 bgm.mp3', '编辑标签', '重命名', '下载按钮']); + ).toEqual(['引用资源 bgm.mp3', '信息', '编辑标签', '重命名', '下载按钮']); // 工具条的「下载按钮」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制, // 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index 5332ef887..6313488ab 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -11,8 +11,8 @@ import { canDismissResourceCanvasQuickEdit, isResourceCanvasHostOverlayOpen, isResourceCanvasInteractionTarget, + resolveResourceCanvasFloatingPanelDismissOpen, resolveResourceCanvasFocusEscapeActive, - resolveResourceCanvasQuickEditDismissOpen, } from '../src/features/resource-canvas/resourceCanvasFocusModel'; import { createResourceQuickEditPanelDraft } from '../src/features/resource-canvas/resourceCanvasQuickEditModel'; @@ -45,6 +45,7 @@ describe('resourceCanvasFocusModel', () => {
空白
提示词
+
封面图
`; @@ -82,6 +83,11 @@ describe('resourceCanvasFocusModel', () => { ), ).toBe(true); expect(isResourceCanvasInteractionTarget(query('#prompt'))).toBe(true); + // 信息浮层是只读的,主体不是按钮:点在字段值上也必须不算「画布空白」。 + expect( + isResourceCanvasInteractionTarget(query('.game-resource-info-panel')), + ).toBe(true); + expect(isResourceCanvasInteractionTarget(query('#info-value'))).toBe(true); expect(isResourceCanvasInteractionTarget(null)).toBe(false); document.body.innerHTML = ''; @@ -112,7 +118,7 @@ describe('resourceCanvasFocusModel', () => { } }); - test('画布可见且快速编辑浮层打开时才接管点外部关闭', () => { + test('画布可见且画布浮层打开时才接管点外部关闭', () => { const closed = { isResourcePanelOpen: false, isGenerationPanelOpen: false, @@ -122,25 +128,26 @@ describe('resourceCanvasFocusModel', () => { }; const base = { isCanvasVisible: true, - isQuickEditPanelOpen: true, + isFloatingPanelOpen: true, hostOverlay: closed, }; - expect(resolveResourceCanvasQuickEditDismissOpen(base)).toBe(true); + // 判据对浮层种类无差别:快速编辑与信息浮层共用这一条(宿主把两者合成一个布尔)。 + expect(resolveResourceCanvasFloatingPanelDismissOpen(base)).toBe(true); expect( - resolveResourceCanvasQuickEditDismissOpen({ + resolveResourceCanvasFloatingPanelDismissOpen({ ...base, - isQuickEditPanelOpen: false, + isFloatingPanelOpen: false, }), ).toBe(false); expect( - resolveResourceCanvasQuickEditDismissOpen({ + resolveResourceCanvasFloatingPanelDismissOpen({ ...base, isCanvasVisible: false, }), ).toBe(false); expect( - resolveResourceCanvasQuickEditDismissOpen({ + resolveResourceCanvasFloatingPanelDismissOpen({ ...base, hostOverlay: { ...closed, isResourcePanelOpen: true }, }), @@ -158,7 +165,7 @@ describe('resourceCanvasFocusModel', () => { const base = { isCanvasVisible: true, hasSelectedResource: false, - isQuickEditPanelOpen: true, + isFloatingPanelOpen: true, hostOverlay: closed, }; @@ -167,14 +174,14 @@ describe('resourceCanvasFocusModel', () => { expect( resolveResourceCanvasFocusEscapeActive({ ...base, - isQuickEditPanelOpen: false, + isFloatingPanelOpen: false, hasSelectedResource: true, }), ).toBe(true); expect( resolveResourceCanvasFocusEscapeActive({ ...base, - isQuickEditPanelOpen: false, + isFloatingPanelOpen: false, hasSelectedResource: false, }), ).toBe(false); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasInfoModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasInfoModel.test.ts new file mode 100644 index 000000000..3665e7ecc --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasInfoModel.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from 'vitest'; + +import { + resolveResourceInfoFieldRows, + resolveResourceInfoPanelStyle, +} from '../src/features/resource-canvas/resourceCanvasInfoModel'; +import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; + +function resourceFixture(overrides: Partial): ProjectResource { + return { + id: 'asset:hero', + category: 'character', + subtype: 'character', + label: 'hero.png', + path: 'assets/hero.png', + mediaType: 'image/png', + sourceLabel: 'Agent 生成', + taskTitle: null, + manifestAssetId: 'hero', + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + ...overrides, + }; +} + +const SOURCE_LAYER = { + id: 'asset:hero', + resourceId: 'asset:hero', + title: 'hero.png', + src: 'blob:hero', + x: 100, + y: 40, + width: 200, + height: 120, + originalWidth: 200, + originalHeight: 120, + zIndex: 1, + sourceType: 'generated' as const, +}; + +describe('resourceCanvasInfoModel', () => { + test('字段固定为名称 / 路径 / 类型 + 只读分类与标签', () => { + expect(resolveResourceInfoFieldRows(resourceFixture({}))).toEqual([ + { label: '名称', value: 'hero.png' }, + { label: '路径', value: 'assets/hero.png' }, + { label: '类型', value: 'image/png' }, + { label: '分类', value: '角色与对象' }, + { label: '标签', value: '暂无标签' }, + ]); + + // 面板是给用户看的事实卡:manifest 内部标识(来源任务、资产 ID、producer)不上桌。 + const labels = resolveResourceInfoFieldRows(resourceFixture({})).map( + (row) => row.label, + ); + for (const forbidden of ['来源任务', '资产 ID', 'ID', '来源']) { + expect(labels).not.toContain(forbidden); + } + }); + + test('分类跟随画布栏目口径,标签只读拼接', () => { + expect( + resolveResourceInfoFieldRows( + resourceFixture({ category: 'version', assetCategory: undefined }), + ).find((row) => row.label === '分类')?.value, + ).toBe('项目版本'); + expect( + resolveResourceInfoFieldRows( + resourceFixture({ category: 'unclassified', assetCategory: undefined }), + ).find((row) => row.label === '分类')?.value, + ).toBe('待归类'); + expect( + resolveResourceInfoFieldRows( + resourceFixture({ assetTags: ['主角', '待定稿'] }), + ).find((row) => row.label === '标签')?.value, + ).toBe('主角、待定稿'); + }); + + test('浮层锚点与快速编辑浮层同一条几何:没有选中图层就没有面板', () => { + const anchor = { + sourceLayer: SOURCE_LAYER, + viewport: { scale: 1, x: 0, y: 0 }, + canvasSize: { width: 900, height: 600 }, + }; + // 锚在图层下沿居中 + 12px 间距(与 `resolveQuickEditPanelStyle` 同口径)。 + expect(resolveResourceInfoPanelStyle(anchor)).toEqual({ + left: 200, + top: 172, + }); + expect( + resolveResourceInfoPanelStyle({ ...anchor, sourceLayer: null }), + ).toBeNull(); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1432ae9e9..491ff7a88 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8364,3 +8364,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 不做什么:不新增替代导航、不做栏目下拉/分段控件、不改 `resourceBookModel` 的 `main / child + category` 目标模型、不改每「排序模式 + 栏目」独立 viewport 的既有约定、不动 sidecar schema 与跨端契约。 - 验证方式:新增反向守卫用例(`资源栏目大纲` 标签、`.game-resource-outline` 类名、对应 `