把素材类型从「编辑素材标签」面板拆成独立入口

- 新增 ResourceTypePanel(src/view/project-development/ResourceTypePanel.tsx):类型选一项即落盘,category 传用户选中值、tags 逐字回传落盘原值,保存在飞时沿用 closeOnBackdrop/closeOnEscape = !saving
- 新增 resourceAssetDisplayName.ts:两块面板共用同一个素材名副标题口径(localPath basename),不再各写一份
- 新增 resourceTypePanel.css:类型面板的弹窗骨架与信息浮层「分类」行入口样式,按 AGC 内独立文件约定,不动 packages/** 与 styles.css
- ResourceClassificationPanel 删掉类型 chip 与 categoryChoice:保存时恒回传 gameCreationAppAssetPersistedCategory(asset),「只改标签不动分类」由结构保证
- index.tsx 新增 resourceTypeAssetId 与 resourceTypeAsset;工具条在「编辑标签」旁新增「素材类型」按钮(Shapes 图标)
- index.tsx 用合并后的 resourceClassificationOverlayOpen 纳入点外部/Esc 浮层遮挡判据,并同步 useEffect 依赖项
- index.tsx 渲染新面板:保存成功后由宿主收起面板并沿用 manifest 重载路径,让卡片立刻落到新栏目
- 信息浮层「分类」行接出第二入口:按钮渲染在 dd 之外,不污染 dt/dd 字段读取口径;非 manifest 资产不给入口
- resourceCanvasInfoModel 导出 RESOURCE_INFO_CATEGORY_FIELD_LABEL,模型与入口共用同一行标识
- 新增 tests/resourceTypePanel.test.tsx:选中即落盘、tags 逐字回传、没碰过不写盘、显式待归类、失败回退、saving 锁、Esc/遮罩、面板骨架共 11 条
- 迁移「没碰过分类就回传落盘原值」对照用例并保留两条(标签面板侧),chip 用例搬到新面板文件
- 集成用例改走新入口:选中即落盘并自动关窗,另补 Esc/点外部不串台、只改标签后 manifest category 逐字不变两条宿主级用例
- appSurface 工具条清单补上「素材类型」
This commit is contained in:
2026-09-14 17:07:15 +08:00
parent f9bd0adae1
commit 7ce4a3a9c6
11 changed files with 1011 additions and 227 deletions
@@ -0,0 +1,69 @@
/*
* 「设置素材类型」面板的弹窗骨架与信息浮层的类型入口。
*
* 单独一个文件而不是塞进 styles.css:与「编辑素材标签」面板当初同样的理由 ——
* 这份样式只服务本次的素材类型入口,与工作台其它区块没有共享选择器,独立文件让改动
* 边界更清楚,也不会与同一时段其它 Agent 在 styles.css 里的编辑互相踩。
*
* 骨架沿用「编辑素材标签」那套三段式契约(`auto / minmax(0, 1fr)`):标题常驻、
* 中间一行可压缩可滚动、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。
*/
.game-resource-type-dialog {
width: min(480px, 100%);
max-height: min(720px, calc(100dvh - 40px));
grid-template-rows: auto minmax(0, 1fr);
}
/*
* 滚动落在 body 这一行:`min-height: 0` 是网格项能被 `1fr` 压缩的前提,
* 否则内容高度会顶回轨道、`overflow-y` 永远不触发。
*/
.game-resource-type-body {
display: grid;
gap: 10px;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
/*
* 类型选项之上的一句短提示。只说这一屏要选什么,不写规则说明或开发解释。
*/
.game-resource-type-hint {
margin: 0;
color: var(--platform-text-base);
font-size: 12px;
}
.game-resource-type-error {
margin: 0;
color: #b3261e;
font-size: 11px;
}
/*
* 第二入口:信息浮层「分类」行右侧的入口按钮。
*
* 放在 `dd` **外面**:信息字段的读取口径(`dt` / `dd` 文本逐行比对)在两处共用,
* 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。
*/
.game-resource-info-field-action {
align-self: start;
margin-left: auto;
padding: 0 6px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 8px;
background: transparent;
color: var(--platform-text-base);
font-size: 11px;
line-height: 20px;
cursor: pointer;
}
.game-resource-info-field-action:hover,
.game-resource-info-field-action:focus-visible {
border-color: var(--platform-surface-hover-border);
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
}
@@ -19,6 +19,14 @@ export type ResourceInfoFieldRow = {
const EMPTY_TAGS_TEXT = '暂无标签';
/**
* 「分类」字段的行标识。
*
* 画布上的信息浮层用这一行接出素材类型设置入口(运行页签的「信息展示」不带入口,
* 同一份字段清单在两处渲染);把字面量放在这里,模型与入口两侧不会各写一个。
*/
export const RESOURCE_INFO_CATEGORY_FIELD_LABEL = '分类';
/**
* 栏目文案与资源筛选同源;`version` 不是跨端资源分类,和画布栏目一样单独给文案。
*/
@@ -43,7 +51,10 @@ export function resolveResourceInfoFieldRows(
{ label: '名称', value: resource.label },
{ label: '路径', value: resource.path },
{ label: '类型', value: resource.mediaType },
{ label: '分类', value: resourceCategoryLabel(resource.category) },
{
label: RESOURCE_INFO_CATEGORY_FIELD_LABEL,
value: resourceCategoryLabel(resource.category),
},
{
label: '标签',
value: tags.length > 0 ? tags.join('、') : EMPTY_TAGS_TEXT,
@@ -5,38 +5,22 @@ import { useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
import {
GAME_CREATION_APP_ASSET_CATEGORIES,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetPersistedCategory,
gameCreationAppAssetTags,
normalizeGameCreationAppAssetTags,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences';
import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage';
import { resourceAssetDisplayName } from './resourceAssetDisplayName';
type UpdateLocalProjectResourceClassificationResult = {
asset: GameCreationAppAssetManifestEntry;
committedProjectRevision: number;
};
/**
* 素材类型(功能分类)选项 = 合法分类枚举 × 既有中文展示名。
*
* 展示名只从 `resourceReferenceCategoryLabel`(筛选与栏目的同一份口径)取,
* 不在业务页另写一张译名表 —— 面板说「角色与对象」而栏目说别的,用户会以为是两个东西。
*/
const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS =
GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({
id: category,
label: resourceReferenceCategoryLabel(category),
}));
/**
* 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。
* 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 pill。
@@ -57,16 +41,6 @@ function mergeResourceClassificationTagDraft(
return next;
}
/**
* 素材名取 `localPath` 的 basenamemanifest 资产没有独立的显示名字段,
* 与资源卡、`@` 面板的显示口径一致。
*/
function resourceAssetDisplayName(localPath: string) {
const normalized = localPath.replaceAll('\\', '/');
const segments = normalized.split('/');
return segments[segments.length - 1] || localPath;
}
function resourceClassificationErrorMessage(error: unknown) {
// 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出;
// 与重命名、删除共用同一份映射。
@@ -89,26 +63,17 @@ export function ResourceClassificationPanel({
onSaved,
}: ResourceClassificationPanelProps) {
/**
* 素材类型(功能分类)从本面板设置,与标签同一次保存、同一条写入路径
* 本面板只编辑标签:素材类型(功能分类)在「设置素材类型」面板里单独设置
*
* **选择器读的是「显示口径** `gameCreationAppAssetCategory`:它与资源画布栏目
* `projectResourceAssetCategory` / `projectResourceCanvasCategory`)同一份读数,
* 所以用户看到的选中项恰好就是他看到的那一栏,不存在「面板说 A、卡片在 B 栏」。
*
* **写回不能用这个读数**:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind` 能派生出
* 明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类
* **写回必须用落盘口径** `gameCreationAppAssetPersistedCategory`,不能用读显示口径
* `gameCreationAppAssetCategory`:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind`
* 能派生出明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类
* (真机上同一条 `kind:"ui"` 资产同时出现过 `unclassified` 与 `ui-interaction` 两种落盘值)。
*
* 因此用 `categoryChoice` 表达「用户是否主动选过」
* - `null`(没碰过分类控件)→ 回传 `gameCreationAppAssetPersistedCategory` 落盘原值
* - 用户选过 → 回传用户选的那个值
* 这条分叉是本次改动的核心不变量,两个方向都由
* `tests/resourceClassificationPanel.test.tsx` 的对照用例钉住。
* 拆出类型面板后这条不变量不再依赖"用户是否碰过控件",而是结构性的
* 本面板没有类型控件,`category` 恒为落盘原值
* `tests/resourceClassificationPanel.test.tsx` 两个方向各有用例钉住它
*/
const [categoryChoice, setCategoryChoice] =
useState<GameCreationAppAssetCategory | null>(null);
const displayedCategory =
categoryChoice ?? gameCreationAppAssetCategory(asset);
const [tags, setTags] = useState<string[]>(() =>
gameCreationAppAssetTags(asset),
);
@@ -159,9 +124,8 @@ export function ResourceClassificationPanel({
expectedProjectId: projectId,
expectedProjectRevision: status.revision,
assetId: asset.id,
// 用户没主动选类型就原样回传落盘值(不含读时自愈),选了就写用户选的那个
category:
categoryChoice ?? gameCreationAppAssetPersistedCategory(asset),
// 分类不由本面板编辑:恒回传落盘值(不含读时自愈)。
category: gameCreationAppAssetPersistedCategory(asset),
tags: normalizeGameCreationAppAssetTags(tagsToSave),
},
},
@@ -214,20 +178,10 @@ export function ResourceClassificationPanel({
</header>
<div className="game-resource-classification-body">
{/*
素材类型选择器:选中的那一项就是这张卡当前所在的画布栏目。
点任意一项即视为用户主动改类型(即便点的是当前已选中的那一项),
与「没碰过就回传落盘原值」的分叉保持同一条判据,不做隐式 no-op
本面板没有素材类型控件:类型是「设置素材类型」面板的编辑对象,入口在资源卡选中
工具条上。曾长在这里的类型 chip 只改本地 state、不落盘,保存又只能借道标签的
「添加」,导致"改了类型没生效"
*/}
<PlatformSegmentedTabs
items={RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS}
activeId={displayedCategory}
onChange={setCategoryChoice}
layout="scroll"
gap="sm"
frame="bare"
surface="transparent"
size="compact"
/>
{tags.length > 0 ? (
<ul className="game-resource-tag-list" aria-label="已有标签">
{tags.map((tag) => (
@@ -3,6 +3,7 @@ import { Info, X } from 'lucide-react';
import {
resolveResourceInfoFieldRows,
resolveResourceInfoPanelStyle,
RESOURCE_INFO_CATEGORY_FIELD_LABEL,
type ResourceInfoPanelAnchor,
} from '../../features/resource-canvas/resourceCanvasInfoModel';
import type { ProjectResource } from './resourceProjectionModel';
@@ -10,11 +11,17 @@ import type { ProjectResource } from './resourceProjectionModel';
/**
* 只读资源信息字段。运行页签的「信息展示」与画布上的信息浮层共用这一份,
* 字段清单只在 `resolveResourceInfoFieldRows` 里定义,两处不会各说一套。
*
* `onEditCategory` 是「分类」行的可选入口(只有画布浮层传):分类值本身仍然只读展示,
* 入口按钮渲染在 `dd` **外面** —— 字段值的读取口径是 `dt` / `dd` 的文本,
* 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。
*/
export function ResourceInfoFieldsView({
resource,
onEditCategory,
}: {
resource: ProjectResource;
onEditCategory?: () => void;
}) {
return (
<dl className="game-resource-info-fields">
@@ -22,6 +29,18 @@ export function ResourceInfoFieldsView({
<div key={row.label}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
{onEditCategory &&
row.label === RESOURCE_INFO_CATEGORY_FIELD_LABEL ? (
<button
type="button"
className="game-resource-info-field-action"
aria-label="设置素材类型"
title="设置素材类型"
onClick={onEditCategory}
>
</button>
) : null}
</div>
))}
</dl>
@@ -30,6 +49,10 @@ export function ResourceInfoFieldsView({
export type ResourceInfoPanelViewProps = ResourceInfoPanelAnchor & {
resource: ProjectResource;
/**
* 「分类」行的类型设置入口;不传就没有入口(资源不是 manifest 资产时宿主不传)。
*/
onEditCategory?: () => void;
onClose: () => void;
};
@@ -44,6 +67,7 @@ export function ResourceInfoPanelView({
sourceLayer,
viewport,
canvasSize,
onEditCategory,
onClose,
}: ResourceInfoPanelViewProps) {
const style = resolveResourceInfoPanelStyle({
@@ -75,7 +99,10 @@ export function ResourceInfoPanelView({
<X size={14} aria-hidden="true" />
</button>
</header>
<ResourceInfoFieldsView resource={resource} />
<ResourceInfoFieldsView
resource={resource}
onEditCategory={onEditCategory}
/>
</section>
);
}
@@ -0,0 +1,181 @@
import '../../features/project-workspace/resourceTypePanel.css';
import { useState } from 'react';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import {
GAME_CREATION_APP_ASSET_CATEGORIES,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetTags,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences';
import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage';
import { resourceAssetDisplayName } from './resourceAssetDisplayName';
type UpdateLocalProjectResourceClassificationResult = {
asset: GameCreationAppAssetManifestEntry;
committedProjectRevision: number;
};
/**
* 素材类型(功能分类)选项 = 合法分类枚举 × 既有中文展示名。
*
* 展示名只从 `resourceReferenceCategoryLabel`(筛选与栏目的同一份口径)取,
* 不在业务页另写一张译名表 —— 面板说「角色与对象」而栏目说别的,用户会以为是两个东西。
*/
const RESOURCE_TYPE_CATEGORY_OPTIONS = GAME_CREATION_APP_ASSET_CATEGORIES.map(
(category) => ({
id: category,
label: resourceReferenceCategoryLabel(category),
}),
);
function resourceTypeErrorMessage(error: unknown) {
// 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出;
// 与重命名、删除、标签共用同一份映射。
return projectAssetCommandErrorMessage(error, '设置素材类型失败');
}
type ResourceTypePanelProps = {
projectPath: string;
projectId: string;
asset: GameCreationAppAssetManifestEntry;
onClose: () => void;
onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void;
};
/**
* 「设置素材类型」面板:素材类型(功能分类)的独立入口,与「编辑素材标签」彻底分家。
*
* 拆开的理由是原设计的动作语义错位 —— 类型 chip 曾长在标签弹窗里,点它只改本地 state,
* 而全弹窗唯一的保存入口是标签的「添加」。于是「改类型」必须借道一个语义上是"加标签"的
* 按钮,只选类型就直接关窗(点遮罩 / Esc / ×)则改动静默丢失。
*
* 本面板把动作压成一步:**选中即落盘**,不再有也只不需要任何标签动作。
*
* 三个口径要点:
* 1. **显示**用读显示口径 `gameCreationAppAssetCategory`:它与画布栏目、资源卡角标同一份
* 读数,用户看到的选中项恰好就是他看到的那一栏。
* 2. **写回**用用户当次点的那个值,且只写这一个字段;`tags` 逐字回传
* `gameCreationAppAssetTags(asset)`(落盘原值),不使用任何读时自愈口径
* —— 改类型不许顺手改标签,也不许把自愈出来的值写回去。
* 3. **没碰过就不写**:面板本身不产生"打开即写"或"关闭时补写",没有用户动作就没有写入。
* 另一半对照(用户主动选了就必须写)由 `tests/resourceTypePanel.test.tsx` 钉住。
*/
export function ResourceTypePanel({
projectPath,
projectId,
asset,
onClose,
onSaved,
}: ResourceTypePanelProps) {
/**
* 保存在飞时先把用户点的那一项显出来(否则 await 期间面板像没反应)。
* 写入失败就退回显示口径,不留一个"看起来成功"的选中态。
*/
const [pendingCategory, setPendingCategory] =
useState<GameCreationAppAssetCategory | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeCategory = pendingCategory ?? gameCreationAppAssetCategory(asset);
async function saveResourceType(
category: GameCreationAppAssetCategory,
): Promise<void> {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setError('设置素材类型需要在客户端内保存');
return;
}
setSaving(true);
setError(null);
try {
const status = await invoke<{ revision: number }>(
'get_local_game_project_revision',
{ projectPath },
);
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
throw new Error('项目 revision 无效');
}
const result =
await invoke<UpdateLocalProjectResourceClassificationResult>(
'update_local_project_resource_classification',
{
input: {
projectPath,
expectedProjectId: projectId,
expectedProjectRevision: status.revision,
assetId: asset.id,
// 用户点的就是落盘值:不套用读时自愈,也不和"是否碰过控件"分叉。
category,
// 类型面板不改标签:逐字回传落盘原值。
tags: gameCreationAppAssetTags(asset),
},
},
);
onSaved(result);
} catch (saveError) {
setPendingCategory(null);
setError(resourceTypeErrorMessage(saveError));
} finally {
setSaving(false);
}
}
return (
<ThemedModal
open
ariaLabel="设置素材类型"
onClose={onClose}
// 保存在飞时不许用 Escape / 点遮罩把面板关掉:关掉后迟到的 `onSaved`
// 会打到一个已经卸载的面板上。头部 × 同样按 `saving` 禁用。
closeOnBackdrop={!saving}
closeOnEscape={!saving}
panelClassName="game-approval-dialog game-resource-type-dialog"
>
<header>
<div>
<h2></h2>
<p>{resourceAssetDisplayName(asset.localPath)}</p>
</div>
<button
type="button"
aria-label="关闭设置素材类型"
disabled={saving}
onClick={onClose}
>
×
</button>
</header>
<div className="game-resource-type-body">
{/* 只说这一屏要选什么,不写规则说明或开发解释。 */}
<p className="game-resource-type-hint"></p>
{/*
选中项就是这张卡当前所在的画布栏目;点任意一项即落盘(含点当前已选中的那一项:
用户显式确认归属,不做隐式 no-op)。
*/}
<div role="group" aria-label="素材类型">
<PlatformSegmentedTabs
items={RESOURCE_TYPE_CATEGORY_OPTIONS}
activeId={activeCategory}
onChange={(category) => {
setPendingCategory(category);
void saveResourceType(category);
}}
columns="threeToSix"
gap="sm"
disabled={saving}
/>
</div>
{error ? (
<p className="game-resource-type-error" role="alert">
{error}
</p>
) : null}
</div>
</ThemedModal>
);
}
@@ -39,6 +39,7 @@ import {
RotateCcw,
Search,
Settings2,
Shapes,
SlidersHorizontal,
Sparkles,
Trash2,
@@ -321,6 +322,7 @@ import {
clampProjectResourceSectionZoom,
projectResourceSectionZoomFromWheel,
} from './resourceSectionHeightModel';
import { ResourceTypePanel } from './ResourceTypePanel';
import {
describeProjectResourceCanvasLayoutRead,
type ProjectResourceCanvasLayoutReadReport,
@@ -1587,6 +1589,13 @@ export default function ProjectDevelopmentView({
useState(false);
const [resourceClassificationAssetId, setResourceClassificationAssetId] =
useState<string | null>(null);
/**
* `resourceClassificationAssetId`
* 宿
*/
const [resourceTypeAssetId, setResourceTypeAssetId] = useState<string | null>(
null,
);
/** 正在重命名的素材;改名沿用分类面板同一条 manifest 重载路径。 */
const [resourceRenameAssetId, setResourceRenameAssetId] = useState<
string | null
@@ -1824,6 +1833,14 @@ export default function ProjectDevelopmentView({
const resourceCanvasHostGenerationPanelOpen =
resourceGenerationOpen || resourceAssetGenerationAction !== null;
/**
* **宿**
* portal body Esc
* "只开其中一块"
*/
const resourceClassificationOverlayOpen =
resourceClassificationAssetId !== null || resourceTypeAssetId !== null;
useImageCanvasFloatingOptionDismiss({
isOpen: resolveResourceCanvasFloatingPanelDismissOpen({
isCanvasVisible: mode === 'resources' && !uiEditorRoute,
@@ -1831,7 +1848,7 @@ export default function ProjectDevelopmentView({
hostOverlay: {
isResourcePanelOpen: resourcePanelOpen,
isGenerationPanelOpen: resourceCanvasHostGenerationPanelOpen,
isClassificationPanelOpen: resourceClassificationAssetId !== null,
isClassificationPanelOpen: resourceClassificationOverlayOpen,
isRenameDialogOpen: resourceRenameAssetId !== null,
isRecoveryPanelOpen: resourceRecoveryPanelOpen,
},
@@ -1858,7 +1875,7 @@ export default function ProjectDevelopmentView({
hostOverlay: {
isResourcePanelOpen: resourcePanelOpen,
isGenerationPanelOpen: resourceCanvasHostGenerationPanelOpen,
isClassificationPanelOpen: resourceClassificationAssetId !== null,
isClassificationPanelOpen: resourceClassificationOverlayOpen,
isRenameDialogOpen: resourceRenameAssetId !== null,
isRecoveryPanelOpen: resourceRecoveryPanelOpen,
},
@@ -1878,7 +1895,8 @@ export default function ProjectDevelopmentView({
isResourceCanvasFloatingPanelOpen,
mode,
resourceCanvasHostGenerationPanelOpen,
resourceClassificationAssetId,
// 判据用的是合并后的开关:两块分类面板任一开着都算宿主浮层打开。
resourceClassificationOverlayOpen,
resourcePanelOpen,
resourceRecoveryPanelOpen,
resourceRenameAssetId,
@@ -2936,6 +2954,14 @@ export default function ProjectDevelopmentView({
: null,
[manifest.assets, resourceClassificationAssetId],
);
const resourceTypeAsset = useMemo(
() =>
resourceTypeAssetId
? (manifest.assets.find((asset) => asset.id === resourceTypeAssetId) ??
null)
: null,
[manifest.assets, resourceTypeAssetId],
);
const resourceRenameAsset = useMemo(
() =>
resourceRenameAssetId
@@ -7263,6 +7289,21 @@ export default function ProjectDevelopmentView({
<span></span>
</CanvasChromeButton>
) : null}
{selectedResource?.manifestAssetId ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="素材类型"
title="素材类型"
icon={<Shapes className="h-4 w-4" />}
onClick={() =>
setResourceTypeAssetId(
selectedResource.manifestAssetId,
)
}
>
<span></span>
</CanvasChromeButton>
) : null}
{selectedResource?.manifestAssetId ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
@@ -7461,6 +7502,23 @@ export default function ProjectDevelopmentView({
sourceLayer={selectedResourceLayer}
viewport={resourceCanvasSceneViewportRef.current}
canvasSize={resourceBookSceneSize}
/*
manifest
*/
onEditCategory={
selectedResource.manifestAssetId
? () => {
const assetId =
selectedResource.manifestAssetId;
if (!assetId) return;
setResourceInfoPanelOpen(false);
setResourceTypeAssetId(assetId);
}
: undefined
}
onClose={() => setResourceInfoPanelOpen(false)}
/>
) : null}
@@ -8073,6 +8131,28 @@ export default function ProjectDevelopmentView({
onSaved={(result) => void handleResourceClassificationSaved(result)}
/>
) : null}
{/*
=
"选中即落盘"宿
`reloadManifestAfterAssetCommand`
state `onSaved`
*/}
{resourceTypeAsset ? (
<ResourceTypePanel
key={resourceTypeAsset.id}
projectPath={projectPath}
projectId={manifest.projectId}
asset={resourceTypeAsset}
onClose={() => setResourceTypeAssetId(null)}
onSaved={(result) => {
setResourceTypeAssetId(null);
void reloadManifestAfterAssetCommand(
result.committedProjectRevision,
`asset-type:${result.asset.id}`,
);
}}
/>
) : null}
{/*
`ResourceAssetDeleteDialog` `useResourceAssetDeleteFlow`
@@ -0,0 +1,12 @@
/**
* 素材名取 `localPath` 的 basenamemanifest 资产没有独立的显示名字段,
* 与资源卡、`@` 面板的显示口径一致。
*
* 「编辑素材标签」与「设置素材类型」两块面板共用这一份 —— 副标题是同一个素材名,
* 两处各写一份 basename 实现迟早会出现一个带目录、一个不带。
*/
export function resourceAssetDisplayName(localPath: string) {
const normalized = localPath.replaceAll('\\', '/');
const segments = normalized.split('/');
return segments[segments.length - 1] || localPath;
}
@@ -4017,9 +4017,10 @@ export function registerProjectWorkbenchFoundationTests() {
// 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」),
// 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口,
// 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest
// `assets[].tags`,分类不再有手动入口)「重命名」已接面板「删除素材」(破坏性动作放末位,
// 前置共享分隔线,复用素材删除流程)「下载按钮」复用资源面板同一条落盘链路,
// 「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。
// `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」
// 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程)
// 「下载按钮」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调,
// 不能再渲染成点了没反应的按钮。
const audioToolbar = screen.getByRole('toolbar', {
name: '素材工具栏',
});
@@ -4034,6 +4035,7 @@ export function registerProjectWorkbenchFoundationTests() {
'引用资源 bgm.mp3',
'信息',
'编辑标签',
'素材类型',
'重命名',
'删除素材',
'下载按钮',
@@ -1954,9 +1954,9 @@ describe('project resource live canvas integration', () => {
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' }));
const dialog = await screen.findByRole('dialog', {
name: '编辑素材标签',
name: '设置素材类型',
});
// 面板里的类型选择器显示的就是卡片当前所在栏目。
@@ -1964,8 +1964,8 @@ describe('project resource live canvas integration', () => {
name: '角色与对象',
});
expect(categoryTab.getAttribute('aria-pressed')).toBe('true');
// 「选中即落盘」:这一次点击本身就是完整动作,不需要任何标签动作。
fireEvent.click(within(dialog).getByRole('button', { name: '场景与环境' }));
fireEvent.click(within(dialog).getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
@@ -1982,12 +1982,10 @@ describe('project resource live canvas integration', () => {
},
});
// 关掉面板再看栏目(保存本身不关窗,关窗是头部 × 的职责)。
fireEvent.click(
within(dialog).getByRole('button', { name: '关闭编辑素材标签' }),
);
// 保存成功后由宿主收起面板(关窗不是用户的第二个动作):用户马上就能在画布上
// 看到卡片落进新栏目,而不是隔着一块挡画布的浮层去猜。
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(),
expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(),
);
// 跟随一:总览「所有资源」摞里那张预览卡的角标**就地**跟着变(同一个卡片宿主,
@@ -2015,6 +2013,152 @@ describe('project resource live canvas integration', () => {
expect(queryResourceSelectButton('hero.png')).toBeNull();
});
/**
* 两块分类浮层的关闭时机互不串台:类型面板与标签面板都是 portal 到 body 的模态浮层,
* 开着时「点外部清画布焦点」与画布自己的 Esc 都必须让位 ——
* 关掉的只能是浮层本身,画布选中与工具条都得留着。
*
* 变异验证(已实测):把 `resourceTypeAssetId` 从 `isClassificationPanelOpen` 判据里去掉
* (只留标签面板那一半),Esc 那一步会被画布的 Esc 抢走:类型面板关不掉、工具条一起消失,
* 本用例必须失败。
*/
it('类型面板与标签面板:Esc / 点外部都只关浮层,不动画布选中', async () => {
const { invoke } = installTauri();
render(<ClassificationWorkbench />);
await openResourceBookCategory('角色与对象');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
// 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。
fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' }));
await screen.findByRole('dialog', { name: '设置素材类型' });
fireEvent.click(document.body);
expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull();
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
// Esc 只关类型面板,选中(工具条)留着 —— 用户接着还能做别的动作。
fireEvent.keyDown(document.body, { key: 'Escape' });
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(),
);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
// 标签面板走同一份判据:Esc 关面板,不清选中。
fireEvent.click(
within(screen.getByRole('toolbar', { name: '图片工具栏' })).getByRole(
'button',
{ name: '编辑标签' },
),
);
await screen.findByRole('dialog', { name: '编辑素材标签' });
fireEvent.keyDown(document.body, { key: 'Escape' });
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(),
);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
// 收尾自检:没有写入发生 —— 这两个动作都不该改数据。
expect(classificationWrites(invoke)).toHaveLength(0);
});
/**
* 只改标签不许动分类(验收判据里的"逐字不变"):落盘 `unclassified` + `kind:"ui"` 的资产
* 在读显示口径下自愈成「UI 交互」,用户在标签面板里加一个标签后,manifest 上的 `category`
* 必须还是 `unclassified` —— 真机上这条资产正因回写自愈值同时出现过两种落盘值。
*
* 变异验证(已实测):把标签面板的载荷改回读显示口径
* `gameCreationAppAssetCategory(asset)`),manifest 上的值会变成 `ui-interaction`
* 本用例必须失败。
*/
it('只改标签后 manifest 的 category 逐字不变(落盘 unclassified + kind ui 的资产)', async () => {
const { invoke } = installTauri();
render(<ClassificationWorkbench />);
await openResourceBookCategory('UI 交互');
fireEvent.click(await findResourceSelectButton('panel.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' }));
const dialog = await screen.findByRole('dialog', { name: '编辑素材标签' });
fireEvent.change(
within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'),
{ target: { value: '界面' } },
);
fireEvent.click(within(dialog).getByRole('button', { name: '添加' }));
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
const persisted = canvasFixture.manifest?.assets.find(
(entry) => entry.id === 'asset-ui',
);
// 落盘原值逐字不变(不是自愈出来的 ui-interaction),标签写进去了。
expect(persisted?.category).toBe('unclassified');
expect(persisted?.tags).toEqual(['界面']);
});
/**
* 第二入口:信息浮层的「分类」行点进「设置素材类型」。
*
* 信息浮层是只读事实卡,新增的可点字段只有「分类」——点它等同于点工具条的「素材类型」:
* 同一块面板、同一条写入命令,不另开第二条写路径。
*
* 变异验证:浮层不传 `onEditCategory`(或按钮渲染进 `dd`),本用例必须失败。
*/
it('信息浮层的「分类」行可以进类型设置,落盘走同一条链路', async () => {
const { invoke } = installTauri();
render(<ClassificationWorkbench />);
await openResourceBookCategory('角色与对象');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
fireEvent.click(within(toolbar).getByRole('button', { name: '信息' }));
const infoPanel = await screen.findByRole('dialog', { name: '资源信息' });
// 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。
expect(
Array.from(infoPanel.querySelectorAll('dt')).map(
(node) => node.textContent,
),
).toContain('分类');
expect(
Array.from(infoPanel.querySelectorAll('dd')).map(
(node) => node.textContent,
),
).toContain('角色与对象');
fireEvent.click(
within(infoPanel).getByRole('button', { name: '设置素材类型' }),
);
// 信息浮层先收起(它锚在卡片位置,而卡片马上要换栏目),类型面板接着打开。
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
const dialog = await screen.findByRole('dialog', { name: '设置素材类型' });
expect(
within(dialog)
.getByRole('button', { name: '角色与对象' })
.getAttribute('aria-pressed'),
).toBe('true');
fireEvent.click(within(dialog).getByRole('button', { name: '音频' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
input: {
projectPath,
expectedProjectId: 'live-canvas-project',
expectedProjectRevision: expect.any(Number),
assetId: 'asset-hero',
category: 'audio',
tags: [],
},
});
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(),
);
});
/**
* 用户报的原始现象(PR #316 反馈):在「所有资源」一栏里,文档卡与图片卡**一开始**就自动
* 重叠 —— 文档那一排的第 2、3 行被图片栏的卡片整排压住。
@@ -114,7 +114,8 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
// 「管理全部标签」没有实现。
expect(screen.queryByRole('button', { name: '管理全部标签' })).toBeNull();
// 素材类型(功能分类)重新有了用户入口:6 个合法分类各一项。
// 素材类型(功能分类)不再长在这个弹窗里:6 个分类项一个都不该出现,
// 它们是「设置素材类型」面板的内容(见 tests/resourceTypePanel.test.tsx)。
for (const label of [
'UI 交互',
'角色与对象',
@@ -123,7 +124,7 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
'文档',
'待归类',
]) {
expect(screen.getByRole('button', { name: label })).not.toBeNull();
expect(screen.queryByRole('button', { name: label })).toBeNull();
}
});
@@ -465,18 +466,18 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
});
/**
* 素材类型(功能分类)的用户入口。这一组钉住本次改动的核心不变量,两个方向互为对照:
* 素材类型(功能分类)**不再是这个面板的编辑对象**:它拆到了独立的「设置素材类型」面板
* `tests/resourceTypePanel.test.tsx`)。这一组钉住拆分后留下来的那条不变量 ——
*
* - 用户**主动选过**类型 → 载荷 `category` 必须是用户选的那个值;
* - 用户**没碰过**类型控件(只改标签)→ 载荷 `category` 必须仍是**落盘原值**
* 不能等于选择器显示的读显示值。
*
* 任何一边被改成另一边的口径,都会有一条例用变红,见各用例上的「变异验证」。
* - 用户**没碰过**类型(本面板里根本不存在类型控件)→ 载荷 `category` 必须是**落盘原值**
* 不能等于读显示口径自愈出来的值;
* - 对照的另一半(用户**主动选过**类型 → 载荷是用户选的那个值)在新面板那一侧,
* 两边各自都有一条例用,任何一边被改成另一边的口径都会变红。
*/
describe('ResourceClassificationPanel 变更素材类型', () => {
describe('ResourceClassificationPanel 不再编辑素材类型', () => {
/**
* 落盘 `unclassified` 而 `kind:"ui"` 能派生出 `ui-interaction`:这是读时自愈的触发条件,
* 也正是"选择器显示值 ≠ 落盘值"的现场。真机 57 条 `kind:"ui"` 资产就是这个形状。
* 也正是"显示值 ≠ 落盘值"的现场。真机 57 条 `kind:"ui"` 资产就是这个形状。
*/
const selfHealingAsset: GameCreationAppAssetManifestEntry = {
...asset,
@@ -498,13 +499,12 @@ describe('ResourceClassificationPanel 变更素材类型', () => {
});
}
function segmentedTabItem(label: string) {
return screen
.getByRole('button', { name: label })
.closest('.platform-segmented-tabs');
}
test('选择器给出 6 个合法分类,中文名与画布栏目同一份口径', () => {
/**
* 拆分的验收判据之一:类型 chip 从这个弹窗里彻底消失(一个都不许剩)。
*
* 变异验证:把 chips 加回面板,本用例必须失败。
*/
test('面板里没有任何素材类型控件', () => {
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
@@ -516,85 +516,27 @@ describe('ResourceClassificationPanel 变更素材类型', () => {
'文档',
'待归类',
]) {
// 6 项都在同一个分段页签容器里,用的是与资源筛选同一份中文展示名。
expect(segmentedTabItem(label)).not.toBeNull();
expect(screen.queryByRole('button', { name: label })).toBeNull();
}
expect(document.querySelector('.platform-segmented-tabs')).toBeNull();
});
/**
* 选择器的选中项是**读显示口径**(与资源卡栏目同源):该资产落盘是 `unclassified`
* 但画布把它放在「UI 交互」栏,选择器必须也显示「UI 交互」
* 只改标签(本面板**没有**类型控件,用户不可能在这里改类型):载荷 `category` 必须仍是
* 落盘原值 `unclassified`,即使读显示口径会把这个 `kind:"ui"` 资产自愈成 `ui-interaction`
*
* 变异验证:把选择器读数换成 `gameCreationAppAssetPersistedCategory(asset)`(落盘口径),
* 选中项会变成「待归类」,本用例必须失败 —— 那正是"面板说待归类、卡片在 UI 交互"的错位
*/
test('选择器显示的是显示口径:落盘 unclassified + kind ui 的资产选中「UI 交互」', () => {
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
expect(
screen
.getByRole('button', { name: 'UI 交互' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen
.getByRole('button', { name: '待归类' })
.getAttribute('aria-pressed'),
).toBe('false');
});
/**
* 主动改类型:载荷 `category` 是用户选的那个值,而不是落盘原值。
*
* 变异验证:把写入改回"永远回传落盘原值"
* `category: gameCreationAppAssetPersistedCategory(asset)`),载荷会变成 `unclassified`
* 本用例必须失败。
*/
test('主动选中某个素材类型后保存:载荷 category 是用户选的那个值', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: selfHealingAsset, onSaved: vi.fn() });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
assetId: 'asset-ui',
category: 'scene',
tags: [],
},
});
});
/**
* 只改标签(**没碰过**类型控件):载荷 `category` 必须仍是落盘原值 `unclassified`
* 即使选择器上显示的是自愈出来的 `ui-interaction`。
*
* 这是与上一条互为对照的关键用例:没有它,任何"顺手把显示值写回去"的实现都会绿。
* 这是与新面板「选中即落盘 → 载荷是用户选的值」互为对照的关键用例:
* 没有它,任何"顺手把显示值写回去"的实现都会绿
*
* 变异验证:把写入改成"永远回传读显示口径"`gameCreationAppAssetCategory(asset)`),
* 载荷会变成 `ui-interaction` —— 也就是把自愈值写回落盘、让"只改标签"静默改分类,
* 本用例必须失败。
*/
test('只改标签时载荷 category 仍是落盘原值,不把选择器上的自愈值写回去', async () => {
test('只改标签时载荷 category 仍是落盘原值,不把读显示口径的自愈值写回去', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: selfHealingAsset });
expect(
screen
.getByRole('button', { name: 'UI 交互' })
.getAttribute('aria-pressed'),
).toBe('true');
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'界面',
@@ -616,62 +558,13 @@ describe('ResourceClassificationPanel 变更素材类型', () => {
});
});
/**
* 显式选待归类」写下去的就是 `unclassified`。
*
* 注意这里只钉**写入值**,不给"卡片会挪到待归类栏目"的承诺读显示口径会把
* `kind` 已能明确分类的资产自愈回派生栏目(`unclassified` 无法区分"没有明确分类"
* 与"用户显式选了待归类",这是 `gameCreationApp.ts` 记录的有意取舍)。
* 选一个**能**被派生值承认的分类(上一条的 `scene`)不受该取舍影响。
/*
* 显式选待归类」「连续改两次类型」这两条原属于本面板的用例已随 chip 一起迁到
* `tests/resourceTypePanel.test.tsx`(那里点一下就是一次落盘,只钉写入值,不给
* "卡片会挪到待归类栏目"的承诺 —— 读显示口径会把 `kind` 已能明确分类的资产自愈回派生栏目,
* `unclassified` 无法区分"没有明确分类"与"用户显式选了待归类",这是 `gameCreationApp.ts`
* 记录的有意取舍)。
*/
test('显式选中「待归类」:载荷 category 是 unclassified', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: { ...asset, category: 'character' } });
await user.click(screen.getByRole('button', { name: '待归类' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const input = (
classificationWrites(invoke)[0]?.[1] as {
input: { category: string };
}
).input;
expect(input.category).toBe('unclassified');
});
/**
* 改类型与「点添加不关窗、可连续添加」不互斥:第一次改类型保存后,第二次接着改回另一类型
* 仍然各写一次盘,两次的 `category` 分别是用户当次选的值。
*
* 变异验证:把 `categoryChoice` 只在首次生效(保存后重置为落盘值),第二次写入的
* `category` 会退回落盘值,本用例必须失败。
*/
test('连续两次「添加」各带当次选择的类型,且面板不自己关窗', async () => {
const invoke = installClassificationInvoke();
const onClose = vi.fn();
renderPanel({ asset: selfHealingAsset, onClose });
const add = () => screen.getByRole('button', { name: '添加' });
fireEvent.click(screen.getByRole('button', { name: '角色与对象' }));
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: '音频' }));
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(2));
expect(
classificationWrites(invoke).map(
([, args]) => (args as { input: { category: string } }).input.category,
),
).toEqual(['character', 'audio']);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
});
});
describe('ResourceClassificationPanel 不再承载删除素材', () => {
@@ -679,9 +572,9 @@ describe('ResourceClassificationPanel 不再承载删除素材', () => {
* 删除素材挪到资源卡选中工具条(破坏性动作与它要改的对象放在一起),面板里**一个入口都不该再有**:
* 旧版 footer 那个 `tone="danger"` 的「删除」删的是素材,属于放错地方。
*
* 顺序也一并钉住:头部关闭 → 素材类型选择器 6 项 → 标签 pill 内的删除标签 → 底部「添加」
* 多出任何一个按钮都会红。
* 变异验证:把 footer 的删除素材按钮加回来,本用例必须失败。
* 顺序也一并钉住:头部关闭 → 标签 pill 内的删除标签 → 底部「添加」
* 类型 chip 已拆到「设置素材类型」面板,这里多出任何一个按钮都会红。
* 变异验证:把 footer 的删除素材按钮(或类型 chip加回来,本用例必须失败。
*/
test('面板里没有任何删除素材入口,打开面板不读写删除相关命令', async () => {
const invoke = installInvoke(async (command) => {
@@ -695,18 +588,7 @@ describe('ResourceClassificationPanel 不再承载删除素材', () => {
.map(
(button) => button.getAttribute('aria-label') ?? button.textContent,
),
).toEqual([
'关闭编辑素材标签',
// 素材类型选择器:6 个合法分类,顺序就是画布栏目顺序。
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
'删除标签 主角',
'添加',
]);
).toEqual(['关闭编辑素材标签', '删除标签 主角', '添加']);
// 打开面板不读引用、不写盘:删除那条链路在这里已经完全不存在。
expect(invoke).not.toHaveBeenCalled();
});
@@ -0,0 +1,422 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
import { ResourceTypePanel } from '../src/view/project-development/ResourceTypePanel';
const asset: GameCreationAppAssetManifestEntry = {
id: 'asset-hero',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'uploaded' },
category: 'character',
tags: ['主角'],
};
/**
* 落盘 `unclassified` 而 `kind:"ui"` 能派生出 `ui-interaction`:这是读时自愈的触发条件,
* 也正是"选择器显示值 ≠ 落盘值"的现场(真机 57 条 `kind:"ui"` 资产就是这个形状)。
*/
const selfHealingAsset: GameCreationAppAssetManifestEntry = {
...asset,
id: 'asset-ui',
kind: 'ui',
category: 'unclassified',
tags: [],
};
function installInvoke(
implementation: (command: string, args?: unknown) => Promise<unknown>,
) {
const invoke = vi.fn(implementation);
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__ = { core: { invoke } };
return invoke;
}
function removeInvoke() {
delete (
window as unknown as {
__TAURI__?: { core?: { invoke?: unknown } };
}
).__TAURI__;
}
function renderPanel(
overrides: {
asset?: GameCreationAppAssetManifestEntry;
onClose?: () => void;
onSaved?: (result: unknown) => void;
} = {},
) {
render(
<ResourceTypePanel
projectPath="C:/project"
projectId="project-1"
asset={overrides.asset ?? asset}
onClose={overrides.onClose ?? vi.fn()}
onSaved={overrides.onSaved ?? vi.fn()}
/>,
);
}
function classificationWrites(invoke: ReturnType<typeof vi.fn>) {
return invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_classification',
);
}
function typeTab(label: string) {
return screen.getByRole('button', { name: label });
}
function pressed(label: string) {
return typeTab(label).getAttribute('aria-pressed');
}
/** 一次写入的落地载荷(没有写入时返回 `null`)。 */
function firstWriteInput(invoke: ReturnType<typeof vi.fn>) {
const write = classificationWrites(invoke)[0];
return write ? (write[1] as { input: Record<string, unknown> }).input : null;
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
afterEach(() => {
cleanup();
removeInvoke();
});
describe('ResourceTypePanel 设置素材类型', () => {
/**
* 面板的身份就是「类型」这一个关注点:标题、素材名副标题、6 个类型项,
* 且**不含任何标签控件**。类型与标签拆成两个入口后,两边都不许再长出对方的控件 ——
* 这正是用户报的「改类型必须借道标签的『添加』按钮」的根因。
*/
test('标题是「设置素材类型」,副标题是素材名,内容只有 6 个类型项', () => {
installInvoke(async () => undefined);
renderPanel({
asset: { ...asset, localPath: 'assets/UI Assets/生成UI设计图.png' },
});
expect(
screen.getByRole('heading', { name: '设置素材类型' }),
).not.toBeNull();
// 副标题与「编辑素材标签」同一显示口径:`localPath` 的 basename。
expect(screen.getByText('生成UI设计图.png')).not.toBeNull();
expect(screen.queryByText('assets/UI Assets/生成UI设计图.png')).toBeNull();
// 6 个合法分类,顺序就是画布栏目顺序,文案与栏目同源。
const tabs = within(document.querySelector('.platform-segmented-tabs')!);
expect(
tabs.getAllByRole('button').map((button) => button.textContent),
).toEqual([
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
]);
// 标签不是这个面板的编辑对象:输入框与「添加」都不在这里。
expect(
screen.queryByPlaceholderText('新增标签,多个用逗号分隔'),
).toBeNull();
expect(screen.queryByRole('button', { name: '添加' })).toBeNull();
expect(screen.queryByRole('list', { name: '已有标签' })).toBeNull();
});
/**
* 选中项是**读显示口径**(与资源卡栏目、角标同一份读数):该资产落盘 `unclassified`
* 画布把它放在「UI 交互」栏,面板必须也显示「UI 交互」。
*
* 变异验证:把 `activeId` 换成 `gameCreationAppAssetPersistedCategory(asset)`(落盘口径),
* 选中项会变成「待归类」,本用例必须失败。
*/
test('选中项是显示口径:落盘 unclassified + kind ui 的资产选中「UI 交互」', () => {
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
expect(pressed('UI 交互')).toBe('true');
expect(pressed('待归类')).toBe('false');
});
/**
* **本次改动的核心**:选中一项本身就是一次完整动作 —— 该次操作自己落盘,
* 不需要任何标签动作(不收标签、也不点「添加」)。
*
* 变异验证:把载荷 `category` 改回 `gameCreationAppAssetPersistedCategory(asset)`
* (即"永远回传落盘原值"),载荷会变成 `unclassified`,本用例必须失败。
*/
test('选中一项即落盘:载荷 category 是用户选的值,且不依赖任何标签动作', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset: selfHealingAsset, committedProjectRevision: 8 };
});
const onSaved = vi.fn();
const onClose = vi.fn();
renderPanel({ asset: selfHealingAsset, onSaved, onClose });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(firstWriteInput(invoke)).toEqual({
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
assetId: 'asset-ui',
category: 'scene',
tags: [],
});
expect(onSaved).toHaveBeenCalledTimes(1);
// 关窗是宿主的职责(面板只报保存结果),与「编辑素材标签」面板同一分工。
expect(onClose).not.toHaveBeenCalled();
});
/**
* 只改类型时 `tags` 逐字回传**落盘原值**:类型面板不是改标签的地方,
* 写入载荷里的 tags 必须与 manifest 上的完全一致(不归一化、不去重、不排序)。
*
* 变异验证:把 `tags` 换成 `[]`(或任何重算口径),本用例必须失败。
*/
test('只改类型时 tags 逐字回传落盘原值', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset, committedProjectRevision: 8 };
});
renderPanel({ asset: { ...asset, tags: ['主角', '待定稿'] } });
await user.click(screen.getByRole('button', { name: '音频' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(firstWriteInput(invoke)?.tags).toEqual(['主角', '待定稿']);
expect(firstWriteInput(invoke)?.category).toBe('audio');
});
/**
* 对照用例(从「编辑素材标签」面板迁移过来的同一条不变量,方向相反):
* **用户没碰过类型控件**时,`category` 的落盘原值不许被写回 —— 面板打开、看一眼、关掉,
* 一次写入都不该发生,更不该顺手把读时自愈出来的值写回落盘。
*
* 本面板是「选中即落盘」,所以"没碰过"= 没有写入;对照的另一半(用户主动选了就必须写)
* 由上一条用例钉住。
*
* 变异验证:把面板改成"打开即按显示口径写一次"(或在关闭时补写一次),本用例必须失败。
*/
test('没碰过类型控件就不写盘:打开再关闭不产生任何分类写入', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
throw new Error(`unexpected command: ${command}`);
});
renderPanel({ asset: selfHealingAsset, onClose });
// 显示口径确实是 ui-interaction(没有被写成 unclassified 的现场)。
expect(pressed('UI 交互')).toBe('true');
await user.click(screen.getByRole('button', { name: '关闭设置素材类型' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(classificationWrites(invoke)).toHaveLength(0);
});
/**
* 显式选「待归类」写下去的就是 `unclassified`**不被 `kind` 派生值改写**
* 该资产 `kind:"ui"` 能派生出 `ui-interaction`,若载荷改走读显示口径,
* 用户点「待归类」就会落成 `ui-interaction`。
*
* 变异验证:把载荷 `category` 换成 `gameCreationAppAssetCategory(asset)`,本用例必须失败。
*/
test('显式选中「待归类」:载荷 category 是 unclassified,不被 kind 派生值覆盖', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset: selfHealingAsset, committedProjectRevision: 8 };
});
renderPanel({ asset: selfHealingAsset });
await user.click(screen.getByRole('button', { name: '待归类' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(firstWriteInput(invoke)?.category).toBe('unclassified');
});
test('原生拒绝原样透出,且不报保存成功、选中态退回显示口径', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
throw '非法资源分类:future-category';
});
renderPanel({ asset: selfHealingAsset, onSaved });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('非法资源分类');
expect(onSaved).not.toHaveBeenCalled();
// 写入没成功:选中态不能被留在失败的选项上。
expect(pressed('UI 交互')).toBe('true');
expect(pressed('场景与环境')).toBe('false');
});
test('客户端外不写盘,只给提示', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
renderPanel({ asset: selfHealingAsset, onSaved });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('客户端');
expect(onSaved).not.toHaveBeenCalled();
});
/**
* 保存在飞时面板锁住:选项与头部 × 都禁用,Esc / 点遮罩都不关窗。
* 否则第二次点击会用旧 revision 并发写,或在 `onSaved` 迟到时打到已卸载的面板上。
*
* 点遮罩走 `user.click(遮罩)` 这条真实路径(pointerdown / pointerup / click 全落在遮罩上):
* `ThemedModal` 用 pointer 序列区分「在面板内按下、拖到遮罩上松手」,只发一个 click
* 是模拟不出"点遮罩"的,那样的断言恒绿、钉不住 `closeOnBackdrop`。
*
* 变异验证:把 `closeOnBackdrop / closeOnEscape` 改回恒 `true`,本用例必须失败。
*/
test('保存在飞时禁用选项与关闭入口,Esc / 点遮罩都关不掉', async () => {
const user = userEvent.setup();
const deferred = createDeferred<unknown>();
installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return deferred.promise;
});
const onClose = vi.fn();
const onSaved = vi.fn();
renderPanel({ asset: selfHealingAsset, onClose, onSaved });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
const dialog = screen.getByRole('dialog', { name: '设置素材类型' });
await waitFor(() => {
expect((typeTab('场景与环境') as HTMLButtonElement).disabled).toBe(true);
});
expect(
(
screen.getByRole('button', {
name: '关闭设置素材类型',
}) as HTMLButtonElement
).disabled,
).toBe(true);
await user.keyboard('{Escape}');
expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull();
await user.click(dialog.parentElement!);
expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull();
expect(onClose).not.toHaveBeenCalled();
deferred.resolve({ asset: selfHealingAsset, committedProjectRevision: 8 });
await waitFor(() => {
expect(onSaved).toHaveBeenCalledTimes(1);
});
});
test('非保存在飞时:Esc 与点遮罩各关一次,点面板内部不关', async () => {
const user = userEvent.setup();
installInvoke(async () => undefined);
const onClose = vi.fn();
renderPanel({ onClose });
const dialog = screen.getByRole('dialog', { name: '设置素材类型' });
await user.click(screen.getByRole('heading', { name: '设置素材类型' }));
expect(onClose).not.toHaveBeenCalled();
await user.keyboard('{Escape}');
expect(onClose).toHaveBeenCalledTimes(1);
await user.click(dialog.parentElement!);
expect(onClose).toHaveBeenCalledTimes(2);
});
/**
* 弹窗骨架(`max-height` + 可压缩可滚的中间行)不能只挂在「编辑素材标签」上:
* 类型面板的 6 个选项在窄屏上是 3 行,缺了上界同样会顶出视口。
*/
test('面板有高度上界且中间行可滚动', () => {
const styles = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css',
),
'utf8',
);
const dialogRule = styles.match(
/\.game-resource-type-dialog\s*\{([^}]*)\}/s,
)?.[1];
expect(dialogRule).toBeDefined();
expect(dialogRule).toMatch(
/max-height:\s*min\(720px, calc\(100dvh - 40px\)\)/,
);
expect(dialogRule).toMatch(/grid-template-rows:\s*auto minmax\(0, 1fr\)/);
const bodyRule = styles.match(
/\.game-resource-type-body\s*\{([^}]*)\}/s,
)?.[1];
expect(bodyRule).toBeDefined();
expect(bodyRule).toMatch(/min-height:\s*0/);
expect(bodyRule).toMatch(/overflow-y:\s*auto/);
installInvoke(async () => undefined);
renderPanel();
const panel = document.querySelector('.game-resource-type-dialog');
expect(panel).not.toBeNull();
expect(
Array.from(panel!.children).map((child) =>
child.classList.contains('game-resource-type-body')
? 'body'
: child.tagName.toLowerCase(),
),
).toEqual(['header', 'body']);
});
});