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` 类名、对应 `